acuvo-code 0.2.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/CHANGELOG.md +328 -0
- package/ENTERPRISE.md +927 -0
- package/LICENSE +120 -0
- package/README.md +1245 -0
- package/ROADMAP.md +556 -0
- package/bin/acuvo-mcp.mjs +208 -0
- package/bin/acuvo.mjs +3108 -0
- package/lib/acceptance-consent.mjs +168 -0
- package/lib/acceptance.mjs +859 -0
- package/lib/account.mjs +226 -0
- package/lib/acuvo-dir.mjs +72 -0
- package/lib/acuvo-models.mjs +141 -0
- package/lib/apply-patch.mjs +570 -0
- package/lib/ask-user.mjs +173 -0
- package/lib/audit.mjs +530 -0
- package/lib/auto-lease.mjs +174 -0
- package/lib/background.mjs +842 -0
- package/lib/best-of.mjs +334 -0
- package/lib/board.mjs +232 -0
- package/lib/breaker.mjs +93 -0
- package/lib/budget.mjs +1375 -0
- package/lib/builtin-skills.mjs +135 -0
- package/lib/cache-floor.mjs +204 -0
- package/lib/chain.mjs +303 -0
- package/lib/changed-paths.mjs +84 -0
- package/lib/chat.mjs +434 -0
- package/lib/checkpoint.mjs +637 -0
- package/lib/child-lifetime.mjs +71 -0
- package/lib/cli-args.mjs +1255 -0
- package/lib/code-review.mjs +1382 -0
- package/lib/colour.mjs +82 -0
- package/lib/command.mjs +2847 -0
- package/lib/compact.mjs +1151 -0
- package/lib/completion.mjs +515 -0
- package/lib/creative-engines.mjs +779 -0
- package/lib/db-inspect.mjs +1624 -0
- package/lib/delete.mjs +111 -0
- package/lib/design-loop.mjs +570 -0
- package/lib/diff-preview.mjs +1044 -0
- package/lib/doctor.mjs +2139 -0
- package/lib/dropped.mjs +216 -0
- package/lib/edit-diagnostics.mjs +277 -0
- package/lib/edit.mjs +460 -0
- package/lib/env-file.mjs +250 -0
- package/lib/escalate.mjs +702 -0
- package/lib/evaluate.mjs +284 -0
- package/lib/fetch-text.mjs +952 -0
- package/lib/fleet-budget.mjs +256 -0
- package/lib/gh.mjs +1536 -0
- package/lib/git.mjs +1341 -0
- package/lib/github.mjs +261 -0
- package/lib/h2.mjs +194 -0
- package/lib/handoff.mjs +417 -0
- package/lib/hooks.mjs +626 -0
- package/lib/http-probe.mjs +907 -0
- package/lib/image-director.mjs +322 -0
- package/lib/image-edit.mjs +522 -0
- package/lib/imagegen.mjs +998 -0
- package/lib/interrupt.mjs +234 -0
- package/lib/learned.mjs +353 -0
- package/lib/lease-watch.mjs +115 -0
- package/lib/lease.mjs +868 -0
- package/lib/localize.mjs +834 -0
- package/lib/log-tail.mjs +1052 -0
- package/lib/login.mjs +157 -0
- package/lib/lsp.mjs +1613 -0
- package/lib/mcp-consent.mjs +377 -0
- package/lib/mcp-defaults.mjs +780 -0
- package/lib/mcp-server.mjs +1343 -0
- package/lib/mcp.mjs +1263 -0
- package/lib/media.mjs +1283 -0
- package/lib/memory-workspace.mjs +179 -0
- package/lib/model-json.mjs +132 -0
- package/lib/model-tier.mjs +171 -0
- package/lib/model.mjs +1445 -0
- package/lib/parallel.mjs +144 -0
- package/lib/perchance.mjs +210 -0
- package/lib/plan-coherence.mjs +1461 -0
- package/lib/plan-ledger.mjs +981 -0
- package/lib/plan.mjs +461 -0
- package/lib/policy.mjs +783 -0
- package/lib/prefix-order.mjs +38 -0
- package/lib/project-memory.mjs +127 -0
- package/lib/prompt.mjs +109 -0
- package/lib/python.mjs +862 -0
- package/lib/rcfile.mjs +853 -0
- package/lib/read-window.mjs +743 -0
- package/lib/refute-tools.mjs +34 -0
- package/lib/refute.mjs +806 -0
- package/lib/repl-driver.mjs +264 -0
- package/lib/repl.mjs +324 -0
- package/lib/replay.mjs +1218 -0
- package/lib/repo-map.mjs +1101 -0
- package/lib/report.mjs +419 -0
- package/lib/search-rank.mjs +99 -0
- package/lib/search.mjs +659 -0
- package/lib/secret-paths.mjs +54 -0
- package/lib/session.mjs +1017 -0
- package/lib/skills.mjs +703 -0
- package/lib/slash.mjs +356 -0
- package/lib/spawn-argv.mjs +1151 -0
- package/lib/spend.mjs +250 -0
- package/lib/steer.mjs +280 -0
- package/lib/stream.mjs +253 -0
- package/lib/stuck.mjs +712 -0
- package/lib/subagent.mjs +749 -0
- package/lib/terminal-graphics.mjs +171 -0
- package/lib/tool-prefix.mjs +226 -0
- package/lib/tool-shortlist.mjs +162 -0
- package/lib/tools.mjs +2333 -0
- package/lib/tsserver.mjs +423 -0
- package/lib/turn.mjs +5672 -0
- package/lib/untrusted-block.mjs +271 -0
- package/lib/verify-claim.mjs +299 -0
- package/lib/vision.mjs +330 -0
- package/lib/voice-task.mjs +561 -0
- package/lib/warm-provider.mjs +255 -0
- package/lib/websearch.mjs +401 -0
- package/lib/workspace.mjs +928 -0
- package/lib/write-approval.mjs +235 -0
- package/lib/write-many.mjs +162 -0
- package/package.json +62 -0
- package/scripts/bundle.mjs +768 -0
- package/scripts/cache-floor.mjs +176 -0
- package/scripts/machine.mjs +226 -0
- package/scripts/test.mjs +139 -0
- package/skills/accessibility.md +87 -0
- package/skills/acuvo-design-system.md +123 -0
- package/skills/animation.md +84 -0
- package/skills/api-design.md +82 -0
- package/skills/auth-and-sessions.md +78 -0
- package/skills/build-with-a-framework.md +101 -0
- package/skills/colour-and-contrast.md +112 -0
- package/skills/creative-engines.md +81 -0
- package/skills/css-layout.md +85 -0
- package/skills/data-and-charts.md +77 -0
- package/skills/debugging.md +76 -0
- package/skills/designing-by-looking.md +84 -0
- package/skills/error-handling.md +78 -0
- package/skills/forms-and-validation.md +93 -0
- package/skills/nextjs-app-router.md +75 -0
- package/skills/page-composition.md +103 -0
- package/skills/performance.md +77 -0
- package/skills/plan-before-building.md +52 -0
- package/skills/planning-and-delegating.md +72 -0
- package/skills/refactoring.md +70 -0
- package/skills/security-basics.md +76 -0
- package/skills/state-management.md +73 -0
- package/skills/supabase-multitenant.md +72 -0
- package/skills/typescript-strict.md +90 -0
- package/skills/typography.md +135 -0
- package/skills/verify-your-own-work.md +62 -0
- package/skills/web-app-quality.md +62 -0
- package/skills/working-in-the-background.md +64 -0
package/lib/tools.mjs
ADDED
|
@@ -0,0 +1,2333 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE TOOL REGISTRY — one declaration, read by the model and by the dispatcher.
|
|
3
|
+
*
|
|
4
|
+
* ── WHY A REGISTRY FOR THREE TOOLS ──────────────────────────────────────────
|
|
5
|
+
* Because the architecture is ONE capability registry, TWO clients. The web
|
|
6
|
+
* console already has 160-odd tools whose executor is a cloud sandbox; this CLI
|
|
7
|
+
* is the same idea with a LOCAL executor. Three tools is the first slice, not
|
|
8
|
+
* the design — and the whole point of the slice is to establish the seam where
|
|
9
|
+
* more get added, rather than to hardcode three `if` branches that the second
|
|
10
|
+
* client would have to fork.
|
|
11
|
+
*
|
|
12
|
+
* ⚠️ THE SCHEMA AND THE DISPATCH LIVE IN ONE FILE ON PURPOSE. The recurring bug
|
|
13
|
+
* in tool-calling systems is a model that has been TOLD about a tool the
|
|
14
|
+
* dispatcher does not implement (or that takes a differently-named argument) —
|
|
15
|
+
* a silent capability hole, because the model dutifully calls it and the turn
|
|
16
|
+
* quietly reports "unknown tool". Keeping the JSON Schema next to the code that
|
|
17
|
+
* reads the arguments means the drift has to be committed deliberately, and
|
|
18
|
+
* `console/lib/acuvo-code-workspace.test.ts` asserts every declared tool has a
|
|
19
|
+
* handler and vice versa.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
// ⚠️ The ONLY direct filesystem use in this file, and it is for the OFFER, not
|
|
23
|
+
// for a tool: `languagesPresent` below has to look at the workspace to decide
|
|
24
|
+
// whether a language server could ever answer here. Every tool still reads and
|
|
25
|
+
// writes through the executor.
|
|
26
|
+
import { readdirSync, existsSync } from 'node:fs';
|
|
27
|
+
import { join } from 'node:path';
|
|
28
|
+
|
|
29
|
+
import { executeRunCommand } from './command.mjs';
|
|
30
|
+
/**
|
|
31
|
+
* ⚠️ FROM THE LEAF, NOT FROM `git.mjs`. The credential list moved to
|
|
32
|
+
* `secret-paths.mjs` so `workspace.mjs` could use it for `move_file` without
|
|
33
|
+
* creating a cycle the bundler cannot order. See that file's header.
|
|
34
|
+
*/
|
|
35
|
+
import { refusedCommitPath } from './secret-paths.mjs';
|
|
36
|
+
import { generateImage, imageToolSchema, imageConfig } from './imagegen.mjs';
|
|
37
|
+
import { listEngines, listEnginesToolSchema } from './creative-engines.mjs';
|
|
38
|
+
import { refusedWriteResult } from './write-approval.mjs';
|
|
39
|
+
import { findFiles, searchText, searchToolSchemas } from './search.mjs';
|
|
40
|
+
import { editThroughExecutor, editToolSchema, applyEdit } from './edit.mjs';
|
|
41
|
+
import { deleteToolSchema } from './delete.mjs';
|
|
42
|
+
import { evaluateSnippet, evaluateToolSchema } from './evaluate.mjs';
|
|
43
|
+
import {
|
|
44
|
+
gitStatus, gitDiff, gitLog, gitCommit, gitBranch, gitPush,
|
|
45
|
+
gitToolSchemas, gitPushToolNames, pushEnabled, ALLOW_PUSH_ENV,
|
|
46
|
+
} from './git.mjs';
|
|
47
|
+
import { speak, transcribe, makeDocument, readDocument, readTable, mediaToolSchemas, mediaToolNames } from './media.mjs';
|
|
48
|
+
import { editImage, expandImage, imageEditToolSchemas, imageEditToolNames } from './image-edit.mjs';
|
|
49
|
+
/**
|
|
50
|
+
* ⭐ `designPass` IS A STRICT SUPERSET OF `seePage`, deliberately, so wiring it
|
|
51
|
+
* is a SWAP rather than a migration: `ok`, `path`, `screenshot`,
|
|
52
|
+
* `screenshotBytes`, `viewport`, `findings` and `looked` are untouched, and
|
|
53
|
+
* every existing consumer (report.mjs, parallel.mjs, turn.mjs) keeps working.
|
|
54
|
+
* What it adds is the ~89-token `verdict` the model actually acts on, plus a
|
|
55
|
+
* `trustworthy` flag so a render that cannot be believed is never phrased as
|
|
56
|
+
* an all-clear.
|
|
57
|
+
*
|
|
58
|
+
* ⚠️ `seePage` IS NO LONGER IMPORTED HERE, AND THAT IS THE POINT. Leaving both
|
|
59
|
+
* in scope is exactly how this package ended up with a hardened `editFile()`
|
|
60
|
+
* while the dispatcher called the unhardened one — two paths to one capability,
|
|
61
|
+
* and the wrong one wired. design-loop.mjs calls media.mjs's `seePage` itself;
|
|
62
|
+
* it wraps the transport, it does not fork it.
|
|
63
|
+
*/
|
|
64
|
+
import { designPass } from './design-loop.mjs';
|
|
65
|
+
import { planStart, planStep, planStatus, planToolSchemas, planFileFor } from './plan-ledger.mjs';
|
|
66
|
+
import { skillsToolSchemas } from './skills.mjs';
|
|
67
|
+
import { discoverAllSkills, loadAnySkill } from './builtin-skills.mjs';
|
|
68
|
+
import { remember, forget, learnedToolSchemas } from './learned.mjs';
|
|
69
|
+
import { lspToolSchemas, runLspTool, discoverLanguageServer, LANGUAGE_SERVERS, LSP_TOOL_NAMES } from './lsp.mjs';
|
|
70
|
+
import { backgroundToolSchemas, runBackgroundTool, BACKGROUND_TOOL_NAMES } from './background.mjs';
|
|
71
|
+
import { httpProbeToolSchemas, runHttpProbeTool, HTTP_PROBE_TOOL_NAMES } from './http-probe.mjs';
|
|
72
|
+
/**
|
|
73
|
+
* ── ⭐⭐ FOUR MODULES, 5,409 LINES, REACHABLE FROM NOTHING UNTIL NOW ─────────
|
|
74
|
+
*
|
|
75
|
+
* `code-review` (1,382), `db-inspect` (1,624), `gh` (1,351) and `log-tail`
|
|
76
|
+
* (1,052) were all written, tested and never given a door. The wiring-reach
|
|
77
|
+
* guard has been naming them for weeks and CI has been red on it for days.
|
|
78
|
+
*
|
|
79
|
+
* ⭐ Every one already shipped its own `*ToolSchemas()` and its own executor —
|
|
80
|
+
* the same shape `http-probe` uses — so this is a registration, not a rewrite.
|
|
81
|
+
* That is exactly why leaving them dark was so expensive: the work was done.
|
|
82
|
+
*/
|
|
83
|
+
import { codeReviewToolSchemas, executeReviewCode } from './code-review.mjs';
|
|
84
|
+
import { dbToolSchemas, inspectDatabase} from './db-inspect.mjs';
|
|
85
|
+
import { ghToolSchemas, executeGh } from './gh.mjs';
|
|
86
|
+
import { logTailToolSchemas, runLogTailTool } from './log-tail.mjs';
|
|
87
|
+
import { tsserverAvailable, runTsserverTool, handlesFile as tsHandlesFile } from './tsserver.mjs';
|
|
88
|
+
import { replToolSchemas, runReplTool, REPL_TOOL_NAMES } from './repl.mjs';
|
|
89
|
+
import { listSessions, sessionToolSchemas } from './session.mjs';
|
|
90
|
+
import { askUserToolSchemas } from './ask-user.mjs';
|
|
91
|
+
import { writeManyToolSchemas, writeMany } from './write-many.mjs';
|
|
92
|
+
/**
|
|
93
|
+
* ⭐⭐ THE ENGINE WAS FINISHED AND UNREACHED. `apply-patch.mjs` shipped with 13
|
|
94
|
+
* tests and two mutation-proven properties on 2026-08-19 and was imported by
|
|
95
|
+
* nothing on the runtime path — the defect `wiring-reach.test.mjs` exists for.
|
|
96
|
+
*
|
|
97
|
+
* ⭐ IT IS THE TOP REMAINING COST LEVER, measured on a real build: output is
|
|
98
|
+
* $0.045 of $0.080 — 56% of the spend, ~53,000 tokens — and a prompt cache
|
|
99
|
+
* (already 83.2%, 100% steady-state) can never discount output. The output is
|
|
100
|
+
* dominated by re-emitting whole files, and a patch is 10-50x smaller.
|
|
101
|
+
*/
|
|
102
|
+
import { applyPatchToolSchemas, planPatch, commitPatch } from './apply-patch.mjs';
|
|
103
|
+
import { declareAcceptance, checkAcceptance, acceptanceToolSchemas } from './acceptance.mjs';
|
|
104
|
+
import { fetchText, fetchToolSchemas } from './fetch-text.mjs';
|
|
105
|
+
import { webSearch, formatResults, webSearchToolSchemas } from './websearch.mjs';
|
|
106
|
+
import { readImage, visionToolSchemas } from './vision.mjs';
|
|
107
|
+
import { readWindow, readWindowToolSchemas } from './read-window.mjs';
|
|
108
|
+
import { runProgram, spawnArgvToolSchemas } from './spawn-argv.mjs';
|
|
109
|
+
import { runSubagent, subagentToolSchemas } from './subagent.mjs';
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The sentinel `workspace.mjs` gives an executor with no disk (the browser
|
|
113
|
+
* builder's Map-backed one). Spelled once here because SIX of the tools below
|
|
114
|
+
* have to refuse on it, and six copies of a magic string is how one of them
|
|
115
|
+
* ends up spelled `"(memory)"` with different brackets.
|
|
116
|
+
*/
|
|
117
|
+
const MEMORY_ROOT = '(memory)';
|
|
118
|
+
|
|
119
|
+
/** OpenAI-shaped tool definitions. OpenRouter, Groq, Cerebras and Gemini's
|
|
120
|
+
* compatibility endpoint all speak this, which is why the console's transport
|
|
121
|
+
* uses the same shape. */
|
|
122
|
+
export const TOOL_SCHEMAS = [
|
|
123
|
+
{
|
|
124
|
+
type: 'function',
|
|
125
|
+
function: {
|
|
126
|
+
name: 'read_file',
|
|
127
|
+
description:
|
|
128
|
+
'Read a UTF-8 text file from the workspace. Paths are relative to the workspace root; anything outside it is refused.',
|
|
129
|
+
parameters: {
|
|
130
|
+
type: 'object',
|
|
131
|
+
properties: {
|
|
132
|
+
path: { type: 'string', description: 'Workspace-relative path, e.g. "src/index.js".' },
|
|
133
|
+
},
|
|
134
|
+
required: ['path'],
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
type: 'function',
|
|
140
|
+
function: {
|
|
141
|
+
name: 'write_file',
|
|
142
|
+
/**
|
|
143
|
+
* ⚠️ THE OLD TEXT SAID "there is no patch mode", AND THAT BECAME FALSE THE
|
|
144
|
+
* MOMENT `apply_patch` WAS WIRED. A description that denies a capability
|
|
145
|
+
* IS the capability not existing — `run_command`'s shell note above says
|
|
146
|
+
* the same thing, and it cost that flag its whole effect for weeks.
|
|
147
|
+
*
|
|
148
|
+
* ⭐ The pointer is here, not only on `apply_patch`, because this is the
|
|
149
|
+
* verb the model is already reaching for when the cheaper one applies.
|
|
150
|
+
*/
|
|
151
|
+
description:
|
|
152
|
+
'Create a new UTF-8 text file, or replace an existing one outright, creating parent directories as needed. '
|
|
153
|
+
+ 'Write the COMPLETE file contents — anything you omit is deleted. '
|
|
154
|
+
+ 'If the file ALREADY EXISTS, prefer apply_patch: re-emitting a whole file spends output tokens, which are '
|
|
155
|
+
+ '56% of a run\'s cost and the one part a prompt cache cannot discount, and a patch also measures 9x fewer '
|
|
156
|
+
+ 'editing errors. Use this verb for a new file, or when genuinely rewriting one end to end.',
|
|
157
|
+
parameters: {
|
|
158
|
+
type: 'object',
|
|
159
|
+
properties: {
|
|
160
|
+
path: { type: 'string', description: 'Workspace-relative path, e.g. "src/index.js".' },
|
|
161
|
+
content: { type: 'string', description: 'The complete new contents of the file.' },
|
|
162
|
+
},
|
|
163
|
+
required: ['path', 'content'],
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
type: 'function',
|
|
169
|
+
function: {
|
|
170
|
+
name: 'list_dir',
|
|
171
|
+
description: 'List the entries of a directory in the workspace. Use "." for the workspace root.',
|
|
172
|
+
parameters: {
|
|
173
|
+
type: 'object',
|
|
174
|
+
properties: {
|
|
175
|
+
path: { type: 'string', description: 'Workspace-relative directory, or "." for the root.' },
|
|
176
|
+
},
|
|
177
|
+
required: [],
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
type: 'function',
|
|
183
|
+
function: {
|
|
184
|
+
name: 'run_command',
|
|
185
|
+
description: [
|
|
186
|
+
'Run ONE allowlisted command in the workspace and get its exit code, stdout and stderr back.',
|
|
187
|
+
'This is how you VERIFY what you wrote — a non-zero exit code is the fact you fix in the next round.',
|
|
188
|
+
'Allowed: `node <file>`, `node --test <file-or-dir>`, `npm test`, `npm run <script>`,',
|
|
189
|
+
'`npx vitest run [paths]`, `tsc --noEmit`.',
|
|
190
|
+
'There is NO SHELL: pipes, &&, ;, quotes, redirection, backticks, $() and every other program',
|
|
191
|
+
'(rm, curl, git, python, …) are refused. Run one plain command per call.',
|
|
192
|
+
].join(' '),
|
|
193
|
+
parameters: {
|
|
194
|
+
type: 'object',
|
|
195
|
+
properties: {
|
|
196
|
+
command: {
|
|
197
|
+
type: 'string',
|
|
198
|
+
description: 'One command, e.g. "node --test src/math.test.js" or "npm test".',
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
required: ['command'],
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
];
|
|
206
|
+
|
|
207
|
+
// ⚠️ The image tool is appended to the registry rather than declared inline, so
|
|
208
|
+
// its schema and its `imageConfig` gate live together in imagegen.mjs — one file
|
|
209
|
+
// owns whether the capability exists and what it looks like.
|
|
210
|
+
/**
|
|
211
|
+
* ── ⭐⭐ RENAMING WAS IMPOSSIBLE, NOT MERELY EXPENSIVE ──────────────────────
|
|
212
|
+
*
|
|
213
|
+
* Without this verb the only rename was read + write + delete: three rounds of
|
|
214
|
+
* a five-round default, and the file's whole content through the context twice.
|
|
215
|
+
* MEASURED against the real executor, two ordinary files cannot do it at all —
|
|
216
|
+
* a 250KB source file ("over the 200000-byte read limit") and any binary
|
|
217
|
+
* ("logo.png" is refused as binary, which is the good outcome; the alternative
|
|
218
|
+
* is silent corruption). So an agent could not rename a large module or move an
|
|
219
|
+
* image into `assets/`, and the only explanation it got was a read error about
|
|
220
|
+
* a file it never wanted to read.
|
|
221
|
+
*
|
|
222
|
+
* The refusals live on `executor.moveFile`, where the credential-laundering
|
|
223
|
+
* rule and the directory rule are argued in full.
|
|
224
|
+
*/
|
|
225
|
+
TOOL_SCHEMAS.push({
|
|
226
|
+
type: 'function',
|
|
227
|
+
function: {
|
|
228
|
+
name: 'move_file',
|
|
229
|
+
description: [
|
|
230
|
+
'Rename or move ONE file inside the workspace, creating parent directories as needed.',
|
|
231
|
+
'Use this instead of read_file + write_file + delete_file: it is one round instead of three,',
|
|
232
|
+
'it does not put the file through your context, and it is the ONLY way to move a binary file',
|
|
233
|
+
'or one larger than the read limit.',
|
|
234
|
+
'One file per call: no globs, no directories — move a directory\'s files individually, or use `git mv` yourself.',
|
|
235
|
+
'It refuses to overwrite an existing destination unless you pass overwrite: true.',
|
|
236
|
+
].join(' '),
|
|
237
|
+
parameters: {
|
|
238
|
+
type: 'object',
|
|
239
|
+
properties: {
|
|
240
|
+
from: { type: 'string', description: 'Workspace-relative path of the existing file, e.g. "src/old.ts".' },
|
|
241
|
+
to: { type: 'string', description: 'Workspace-relative destination, e.g. "src/lib/new.ts".' },
|
|
242
|
+
overwrite: { type: 'boolean', description: 'Replace the destination if it already exists. Defaults to false.' },
|
|
243
|
+
},
|
|
244
|
+
required: ['from', 'to'],
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
});
|
|
248
|
+
TOOL_SCHEMAS.push(editToolSchema());
|
|
249
|
+
TOOL_SCHEMAS.push(deleteToolSchema());
|
|
250
|
+
// ⭐ Kills the `node -e` round tax structurally — see evaluate.mjs.
|
|
251
|
+
TOOL_SCHEMAS.push(evaluateToolSchema());
|
|
252
|
+
TOOL_SCHEMAS.push(...searchToolSchemas()); // find_files · search_code
|
|
253
|
+
TOOL_SCHEMAS.push(...gitToolSchemas());
|
|
254
|
+
TOOL_SCHEMAS.push(imageToolSchema());
|
|
255
|
+
/**
|
|
256
|
+
* ⭐ `list_engines` — WHAT AN ENGINE COSTS, WITHOUT SPENDING ONE TO FIND OUT.
|
|
257
|
+
* Declared unconditionally: unlike the media half it needs no endpoint of our
|
|
258
|
+
* hosting to be USEFUL, because "prices unavailable, and here is why" is a real
|
|
259
|
+
* answer that a model can act on. It is the only creative verb that asks the
|
|
260
|
+
* gateway for prices; the render verbs read the cache it leaves behind.
|
|
261
|
+
*/
|
|
262
|
+
TOOL_SCHEMAS.push(listEnginesToolSchema());
|
|
263
|
+
/**
|
|
264
|
+
* ⭐ THE NATIVE MEDIA HALF. Declared always (so the drift guard can see them) but
|
|
265
|
+
* OFFERED only where the endpoint is configured — see toolNamesForRounds.
|
|
266
|
+
*
|
|
267
|
+
* ⚠️ WHAT `see_page` IS NOT. This comment used to claim it was "the one no other
|
|
268
|
+
* terminal agent has: every competitor writes a page and is blind to what it
|
|
269
|
+
* looks like". That is FALSE and was struck from README.md on 2026-08-10 for the
|
|
270
|
+
* same reason: Playwright MCP and Chrome DevTools MCP are free, one install
|
|
271
|
+
* away, and give any MCP-speaking agent a browser.
|
|
272
|
+
*
|
|
273
|
+
* ⭐ THE DEFENSIBLE CLAIM IS THE RETURN VALUE, NOT THE BROWSER. Handing a model
|
|
274
|
+
* the screenshot costs ~3,072 image tokens per look; `see_page` renders the page
|
|
275
|
+
* and returns an ~89-token verdict — 34x less for the thing the next round
|
|
276
|
+
* actually acts on. Anyone can take the photograph; the compression is the
|
|
277
|
+
* product.
|
|
278
|
+
*/
|
|
279
|
+
TOOL_SCHEMAS.push(...mediaToolSchemas({
|
|
280
|
+
// Declaration is unconditional; the per-turn OFFER is what gates on config.
|
|
281
|
+
RENDER_AUDIT_URL: 'declared', MODAL_TTS_URL: 'declared',
|
|
282
|
+
MODAL_TRANSCRIBE_URL: 'declared', MODAL_PRESS_URL: 'declared',
|
|
283
|
+
// ⭐ The INPUT half — read_document · read_table. Same unconditional
|
|
284
|
+
// declaration for the same reason: the drift guard must not be able to see a
|
|
285
|
+
// different tool list on a machine that happens to have different env.
|
|
286
|
+
MODAL_DOC_READ_URL: 'declared', MODAL_TABLE_READ_URL: 'declared',
|
|
287
|
+
}));
|
|
288
|
+
/**
|
|
289
|
+
* ⭐ CHANGING a picture rather than re-rolling it — edit_image · expand_image.
|
|
290
|
+
* Declared unconditionally like the rest of the media half; the offer gates on
|
|
291
|
+
* config, and edit_image needs BOTH acuvo-select and acuvo-flux-studio.
|
|
292
|
+
*/
|
|
293
|
+
TOOL_SCHEMAS.push(...imageEditToolSchemas({
|
|
294
|
+
MODAL_SELECT_URL: 'declared', MODAL_FLUX_URL: 'declared',
|
|
295
|
+
}));
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* ── ⭐ THE MODULES THAT WERE BUILT FOR THIS SEAM AND NEVER PLUGGED INTO IT ──
|
|
299
|
+
*
|
|
300
|
+
* Each of these shipped finished, documented and tested, exporting a
|
|
301
|
+
* `*ToolSchemas()` written against this exact registration point — and each was
|
|
302
|
+
* imported by nothing on the runtime path. A capability that no user can reach
|
|
303
|
+
* is not a capability; it is 7,397 lines of very well-commented dead weight.
|
|
304
|
+
*
|
|
305
|
+
* ⚠️ DECLARED UNCONDITIONALLY, EXACTLY LIKE MEDIA, AND FOR THE SAME REASON: the
|
|
306
|
+
* drift guard compares this list against the dispatcher's cases, and a schema
|
|
307
|
+
* that only exists on some machines makes that guard machine-dependent. What
|
|
308
|
+
* varies per machine is the OFFER, decided in `toolNamesForRounds` below.
|
|
309
|
+
*/
|
|
310
|
+
TOOL_SCHEMAS.push(...planToolSchemas()); // plan_start · plan_step · plan_status
|
|
311
|
+
TOOL_SCHEMAS.push(...learnedToolSchemas()); // remember · forget
|
|
312
|
+
TOOL_SCHEMAS.push(...subagentToolSchemas()); // delegate
|
|
313
|
+
TOOL_SCHEMAS.push(...sessionToolSchemas()); // list_sessions
|
|
314
|
+
TOOL_SCHEMAS.push(...askUserToolSchemas()); // ask_user
|
|
315
|
+
TOOL_SCHEMAS.push(...writeManyToolSchemas()); // write_files
|
|
316
|
+
/**
|
|
317
|
+
* ⚠️ DECLARED HERE, IN THE UNCONDITIONAL BLOCK, because declaration order IS the
|
|
318
|
+
* prompt-cache prefix (see the note further down: moving the conditional groups
|
|
319
|
+
* last took the shared prefix from 69.2% to 93.3%). A new schema appended after
|
|
320
|
+
* a conditional group would push every later tool's identical bytes into a cold
|
|
321
|
+
* read whenever that group's presence changed.
|
|
322
|
+
*/
|
|
323
|
+
TOOL_SCHEMAS.push(...applyPatchToolSchemas()); // apply_patch
|
|
324
|
+
TOOL_SCHEMAS.push(...acceptanceToolSchemas()); // declare_acceptance · check_acceptance
|
|
325
|
+
TOOL_SCHEMAS.push(...fetchToolSchemas()); // fetch_url
|
|
326
|
+
TOOL_SCHEMAS.push(...webSearchToolSchemas()); // web_search
|
|
327
|
+
TOOL_SCHEMAS.push(...visionToolSchemas()); // read_image
|
|
328
|
+
TOOL_SCHEMAS.push(...readWindowToolSchemas()); // read_lines · read_around
|
|
329
|
+
TOOL_SCHEMAS.push(...backgroundToolSchemas()); // start_process · check_process · stop_process
|
|
330
|
+
TOOL_SCHEMAS.push(...httpProbeToolSchemas()); // call_endpoint
|
|
331
|
+
/**
|
|
332
|
+
* ⚠️ `review_code` and `inspect_db` are declared UNCONDITIONALLY because they
|
|
333
|
+
* read what is already on disk — no endpoint of ours, no process, no key. A
|
|
334
|
+
* workspace with no database simply gets "no schema found", which is a real
|
|
335
|
+
* answer a model can act on rather than a dead button.
|
|
336
|
+
*/
|
|
337
|
+
TOOL_SCHEMAS.push(...codeReviewToolSchemas()); // review_code
|
|
338
|
+
/**
|
|
339
|
+
* ⚠️ The gh and log verbs ride with `allowRun` — see `toolNamesForRounds`. gh
|
|
340
|
+
* spawns the `gh` binary; the log verbs can only read a process `start_process`
|
|
341
|
+
* started, and that is refused under `--no-run`.
|
|
342
|
+
*/
|
|
343
|
+
TOOL_SCHEMAS.push(...ghToolSchemas()); // gh_issue, gh_pr, gh_run
|
|
344
|
+
TOOL_SCHEMAS.push(...logTailToolSchemas()); // read_log, wait_for_output, summarize_log
|
|
345
|
+
TOOL_SCHEMAS.push(...replToolSchemas()); // repl · repl_reset
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* ── ⚠️⭐ AND THE TENTH ONE, WHICH WAS LEFT OUT AS "A PRODUCT DECISION" ──────
|
|
349
|
+
*
|
|
350
|
+
* `spawn-argv.mjs` (801 lines) was the one tool-shaped orphan that the wiring
|
|
351
|
+
* pass deliberately skipped, on the grounds that `run_program` is a SECOND verb
|
|
352
|
+
* onto process spawning and someone had to decide whether this CLI should have
|
|
353
|
+
* two. Deciding it is this pass's job, and the decision is yes, for two reasons
|
|
354
|
+
* that are measurements rather than preferences.
|
|
355
|
+
*
|
|
356
|
+
* ⭐ IT IS NOT A SECOND DOOR — IT IS THE SAME DOOR WITH THE PARSER REMOVED.
|
|
357
|
+
* `run_command` takes a STRING and must guess, from the string alone, whether a
|
|
358
|
+
* quote is the model composing a second command or the model passing a value.
|
|
359
|
+
* It cannot tell, so it refuses the character — correctly, and that is exactly
|
|
360
|
+
* why the string is the wrong input. `runProgram` takes `program` + `args[]`,
|
|
361
|
+
* spawns with `shell: false`, and asks `command.mjs` about every pre-boundary
|
|
362
|
+
* flag rather than keeping a second copy of the flag lists. Same
|
|
363
|
+
* `ALLOWED_BINARIES` (node · npm · npx · tsc), same `buildInvocation`, same
|
|
364
|
+
* `spawnBounded`, same `scrubEnvironment` — plus it additionally deletes
|
|
365
|
+
* `NODE_OPTIONS` and `NODE_TEST_CONTEXT`, which `run_command` does not.
|
|
366
|
+
*
|
|
367
|
+
* ⚠️ AND IT IS A STRICT SUBSET OF THE ALLOWLIST, NEVER A WIDENING.
|
|
368
|
+
* `.acuvo/commands.json` may only ADD presets (`parseCommandsConfig` refuses
|
|
369
|
+
* anything else), so the four fixed binaries here can never exceed what
|
|
370
|
+
* `run_command` would have permitted on the same machine. The one real
|
|
371
|
+
* asymmetry is the other way: a user who enabled the `python` preset reaches it
|
|
372
|
+
* through `run_command` only, and that is stated in the README.
|
|
373
|
+
*
|
|
374
|
+
* ⚠️ WHAT ITS ABSENCE COST, from spawn-argv.mjs's own measured header: three
|
|
375
|
+
* probe runs hit the string wall and two SHIPPED A WRONG ARTIFACT because of
|
|
376
|
+
* it — `node bin/todo.js add "buy milk"`, `node bin/todo.js list --all` and
|
|
377
|
+
* `node --test test/*.test.mjs` were all refused, so the agent could never
|
|
378
|
+
* execute the code paths it had just written and documented what it imagined
|
|
379
|
+
* the output was instead. That is the single most expensive failure this
|
|
380
|
+
* package has, and the fix was sitting in the tree unimported.
|
|
381
|
+
*/
|
|
382
|
+
TOOL_SCHEMAS.push(...spawnArgvToolSchemas()); // run_program
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* ── ⭐⭐⭐ DECLARATION ORDER IS THE PROMPT-CACHE PREFIX ─────────────────────
|
|
386
|
+
*
|
|
387
|
+
* `toolSchemasFor` returns `TOOL_SCHEMAS.filter(...)`, so the WIRE ORDER is
|
|
388
|
+
* the order of these pushes, not the order the caller asked for. Every tool
|
|
389
|
+
* declared AFTER a conditional group is re-sent cold whenever that group's
|
|
390
|
+
* presence changes, even though its bytes are identical.
|
|
391
|
+
*
|
|
392
|
+
* ⚠️ MEASURED 2026-08-20 between two real project shapes — one plain, one with
|
|
393
|
+
* a migrations directory:
|
|
394
|
+
*
|
|
395
|
+
* conditional groups mid-list (before) 69.2% shared prefix (34,561 B)
|
|
396
|
+
* conditional groups LAST (now) 93.3% shared prefix (46,608 B)
|
|
397
|
+
*
|
|
398
|
+
* ⭐ ~12,000 bytes — roughly 3,000 tokens — that a user switching between
|
|
399
|
+
* project shapes was paying for at cold-read prices on every first round.
|
|
400
|
+
*
|
|
401
|
+
* ⚠️ AND IT ONLY WORKS BECAUSE THE SYSTEM PROMPT DOES NOT VARY. Measured the
|
|
402
|
+
* same day: `systemPrompt` is byte-identical across both shapes (3,861 chars),
|
|
403
|
+
* so the tool block really is where divergence begins. If the prompt ever
|
|
404
|
+
* starts carrying project detail, it moves in front of this and the ordering
|
|
405
|
+
* below stops buying anything — check that before trusting these numbers.
|
|
406
|
+
*
|
|
407
|
+
* ⭐ This is the same lever recorded in `project_acuvo_byte_order_is_the_cache
|
|
408
|
+
* _lever` (25.8% -> 95.6% by moving one line): put the stable bytes first and
|
|
409
|
+
* everything that varies last.
|
|
410
|
+
*/
|
|
411
|
+
TOOL_SCHEMAS.push(...skillsToolSchemas()); // read_skill — present only when the project HAS skills
|
|
412
|
+
TOOL_SCHEMAS.push(...lspToolSchemas()); // find_definition · find_references · check_types · list_symbols — needs a language server
|
|
413
|
+
TOOL_SCHEMAS.push(...dbToolSchemas()); // inspect_db, sample_db_rows — needs schema evidence
|
|
414
|
+
|
|
415
|
+
export const TOOL_NAMES = TOOL_SCHEMAS.map((t) => t.function.name);
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* ── ⚠️ WHAT A SINGLE-SHOT TURN IS ALLOWED TO OFFER THE MODEL ───────────────
|
|
419
|
+
*
|
|
420
|
+
* MEASURED, three live runs against `deepseek/deepseek-v3.2`, 2026-08-09, all
|
|
421
|
+
* three tools declared and the system prompt explicitly saying reads cannot
|
|
422
|
+
* reach it this turn:
|
|
423
|
+
*
|
|
424
|
+
* run 1 "let me check the version.js file" → 1 read_file, 0 writes
|
|
425
|
+
* run 2 (contents now pre-loaded) → 1 write_file ✓
|
|
426
|
+
* run 3 "let me check the directory structure" → 1 list_dir, 0 writes
|
|
427
|
+
*
|
|
428
|
+
* Two of three turns were spent fetching context the CLI had ALREADY put in the
|
|
429
|
+
* prompt. That is not a prompt-wording problem — coder models are trained on
|
|
430
|
+
* agentic loops and reach for the tools they can see, and no amount of shouting
|
|
431
|
+
* in a system prompt outranks a tool definition sitting in the payload.
|
|
432
|
+
*
|
|
433
|
+
* ⭐ AND THE DEEPER POINT IS THIS REPO'S OWN RULE: a control that presents
|
|
434
|
+
* itself and does nothing is worse than one that is absent. In a turn with no
|
|
435
|
+
* second round, a `read_file` result has nowhere to go — so declaring it is a
|
|
436
|
+
* DEAD BUTTON, and the model pressing it is the predictable consequence rather
|
|
437
|
+
* than a surprise.
|
|
438
|
+
*
|
|
439
|
+
* ⚠️ THE CAPABILITY IS NOT REMOVED, ONLY THE OFFER. `read_file` and `list_dir`
|
|
440
|
+
* are implemented, dispatched, tested, and used every single run — the CLI's own
|
|
441
|
+
* `gatherWorkspaceContext` reads the tree and the small files THROUGH THIS SAME
|
|
442
|
+
* EXECUTOR before the model is asked anything. What changes here is who gets to
|
|
443
|
+
* call them: the deterministic gather, not the model. When the multi-round turn
|
|
444
|
+
* lands, it passes `TOOL_SCHEMAS` instead of this and the reads become live in
|
|
445
|
+
* one line.
|
|
446
|
+
*/
|
|
447
|
+
/**
|
|
448
|
+
* ⭐ `write_files` IS HERE FOR THE SAME REASON `write_file` IS: its result lands
|
|
449
|
+
* on disk, so it has somewhere to go even with no second round. "Create these
|
|
450
|
+
* five files" is an ordinary one-round request, and withholding the plural form
|
|
451
|
+
* would push it back into `evaluate`, which is exactly where a bulk write is
|
|
452
|
+
* invisible to the leases and to the change count.
|
|
453
|
+
*/
|
|
454
|
+
export const SINGLE_SHOT_TOOL_NAMES = ['write_file', 'write_files'];
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* ── ⭐ AND WHAT A MULTI-ROUND SESSION OFFERS — THE SAME RULE, INVERTED ──────
|
|
458
|
+
*
|
|
459
|
+
* The note above says a read tool is a DEAD BUTTON when its result has nowhere
|
|
460
|
+
* to go. The corollary is that the moment a second round exists, the button is
|
|
461
|
+
* live and withholding it is the defect: the loop's whole premise is that the
|
|
462
|
+
* model sees what happened and reacts, and "what happened" includes the file it
|
|
463
|
+
* needed that was too large for the deterministic gather.
|
|
464
|
+
*
|
|
465
|
+
* So the offer is not a fixed list, it is a FUNCTION OF THE ROUND BUDGET. One
|
|
466
|
+
* round → write only. More than one → everything, because everything can now
|
|
467
|
+
* come back. `run_command` is the reason the loop exists at all and is the one
|
|
468
|
+
* entry here that can execute code; `--no-run` withholds it without collapsing
|
|
469
|
+
* the loop, which is the honest middle setting for a task you have not read yet.
|
|
470
|
+
*/
|
|
471
|
+
/**
|
|
472
|
+
* ── ⚠️ GATE ON AVAILABILITY, NOT ON PRESENCE ────────────────────────────────
|
|
473
|
+
*
|
|
474
|
+
* `imageConfig(env).configured` is the precedent and it is a good one: the file
|
|
475
|
+
* that owns a capability owns the question "does it exist here", and the offer
|
|
476
|
+
* asks it rather than assuming. These two do the same for the two capabilities
|
|
477
|
+
* whose dependency lives OUTSIDE this package — a directory the user wrote, and
|
|
478
|
+
* a language server someone installed.
|
|
479
|
+
*
|
|
480
|
+
* ⚠️ NEVER THROWS, BY CONSTRUCTION AND THEN AGAIN BY CATCH. Both callees
|
|
481
|
+
* document that they never throw ("not installed" is the expected answer). The
|
|
482
|
+
* try/catch is not distrust of them, it is the rule that computing the OFFER can
|
|
483
|
+
* never be what kills a run: an unreadable directory on a locked-down machine
|
|
484
|
+
* must cost the user one tool, not the whole session.
|
|
485
|
+
*/
|
|
486
|
+
export function skillsAvailable(root) {
|
|
487
|
+
if (typeof root !== 'string' || root === '' || root === MEMORY_ROOT) return false;
|
|
488
|
+
try {
|
|
489
|
+
const found = discoverAllSkills(root);
|
|
490
|
+
return found.ok === true && found.skills.length > 0;
|
|
491
|
+
} catch {
|
|
492
|
+
return false;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* ── ⚠️⚠️ AN INSTALLED SERVER IS NOT ENOUGH. THE PROJECT HAS TO SPEAK IT. ─────
|
|
498
|
+
*
|
|
499
|
+
* The first version of this gate asked one question — is ANY language server
|
|
500
|
+
* installed — and it shipped the exact dead button it was written to prevent.
|
|
501
|
+
* MEASURED ON THIS MACHINE, 2026-08-11, integrating the four lanes: this
|
|
502
|
+
* package is zero-dependency JavaScript, `typescript-language-server` is not
|
|
503
|
+
* installed, and `find_definition` / `find_references` / `check_types` /
|
|
504
|
+
* `list_symbols` were all offered anyway — because `rust-analyzer` happens to
|
|
505
|
+
* sit in `~/.cargo/bin` from unrelated work. Every one of those tools, called
|
|
506
|
+
* on any file in this repo, can only answer "typescript-language-server is not
|
|
507
|
+
* installed". Four buttons, none of them wired to anything reachable.
|
|
508
|
+
*
|
|
509
|
+
* ⭐ THE GATE IS THE INTERSECTION: a server that is installed AND a language
|
|
510
|
+
* this workspace actually contains. Both halves are necessary and neither is
|
|
511
|
+
* sufficient — a Rust repo on a machine with only pyright is the same dead
|
|
512
|
+
* button seen from the other side.
|
|
513
|
+
*
|
|
514
|
+
* ⚠️ AND IT IS STILL "ANY MATCH", NOT "EVERY MATCH". A polyglot repo with Go
|
|
515
|
+
* and TypeScript and only `gopls` installed keeps the four tools, because they
|
|
516
|
+
* are per-FILE and `.go` files are genuinely served. Withholding a working
|
|
517
|
+
* capability because a SECOND language is unserved would be the opposite error,
|
|
518
|
+
* and the not-installed path returns lsp.mjs's own install instruction, which
|
|
519
|
+
* is a good answer to get for the one file that cannot be served.
|
|
520
|
+
*
|
|
521
|
+
* Pure `existsSync` / PATH probing plus at most two shallow `readdir`s. No
|
|
522
|
+
* process is spawned, and nothing recurses into the tree.
|
|
523
|
+
*/
|
|
524
|
+
/**
|
|
525
|
+
* ── ⚠️⚠️ THREE TOOLS WERE DECLARED AND NEVER NAMED ─────────────────────────
|
|
526
|
+
*
|
|
527
|
+
* Measured 2026-08-20: `TOOL_SCHEMAS` declares 63 tools and
|
|
528
|
+
* `toolNamesForRounds` offered 47 at every round budget. Sixteen were absent,
|
|
529
|
+
* and thirteen of those are honestly environment-gated — LSP, the media
|
|
530
|
+
* secret, an explicit render URL, the git-push opt-in.
|
|
531
|
+
*
|
|
532
|
+
* ⭐ THREE WERE NOT GATED ON ANYTHING. `review_code`, `inspect_db` and
|
|
533
|
+
* `sample_db_rows` are declared UNCONDITIONALLY, directly above a comment
|
|
534
|
+
* saying why: *"they read what is already on disk — no endpoint of ours, no
|
|
535
|
+
* process, no key."* Nothing ever put their names in the list, so the model
|
|
536
|
+
* could not call them.
|
|
537
|
+
*
|
|
538
|
+
* ⚠️ AND ONE OF THEM IS ADVERTISED TO THE USER. `code-review.mjs` prints
|
|
539
|
+
* *"run `review_code` on the file for the full list"* — a hint pointing at a
|
|
540
|
+
* verb the model has never been offered.
|
|
541
|
+
*
|
|
542
|
+
* ⚠️⚠️ AND THE GATE HAS TO BE CHEAP. My first version called
|
|
543
|
+
* `readSchemaFromWorkspace`, which recursively globs every .sql file in the
|
|
544
|
+
* tree and PARSES what it finds: **478ms per call**, once per turn. It took
|
|
545
|
+
* the CLI suite from 111s to 285s with one run cancelled. A gate costing half
|
|
546
|
+
* a second to decide whether to offer a tool is worse than a missing tool.
|
|
547
|
+
*
|
|
548
|
+
* ⭐ So it probes a handful of CONVENTIONAL locations with `existsSync` and
|
|
549
|
+
* memoises per root. The trade is stated rather than hidden: a project keeping
|
|
550
|
+
* SQL somewhere unconventional will not be offered the tools. That is a miss,
|
|
551
|
+
* not a break — the tools are an offer, and 478ms of every turn is not payable.
|
|
552
|
+
*
|
|
553
|
+
* Verified working before wiring: `inspect_db` returned `ok:true` with real
|
|
554
|
+
* tables read off disk, and `review_code` found an `eval-non-literal` with
|
|
555
|
+
* severity, confidence and a reason.
|
|
556
|
+
*
|
|
557
|
+
* ⚠️ THEY ARE NOT FREE — 418 + 535 + 300 tokens against an 11,235-token
|
|
558
|
+
* surface, on every turn. So the DB pair follows the pattern this file already
|
|
559
|
+
* uses for skills and LSP: offer it where there is evidence it can answer.
|
|
560
|
+
* `review_code` rides always; it applies to any source file, and 418 tokens is
|
|
561
|
+
* the cheapest of the three.
|
|
562
|
+
*/
|
|
563
|
+
const DB_EVIDENCE_PATHS = Object.freeze([
|
|
564
|
+
'prisma/schema.prisma',
|
|
565
|
+
'supabase/schema.sql', 'supabase/migrations',
|
|
566
|
+
'migrations', 'db/migrations', 'database/migrations', 'drizzle',
|
|
567
|
+
'schema.sql', 'db/schema.sql',
|
|
568
|
+
]);
|
|
569
|
+
const DB_EVIDENCE_ENV = Object.freeze(['DATABASE_URL', 'POSTGRES_URL', 'SUPABASE_DB_URL', 'MYSQL_URL']);
|
|
570
|
+
/** Memoised per root: the answer cannot change inside one turn. */
|
|
571
|
+
const dbEvidenceCache = new Map();
|
|
572
|
+
|
|
573
|
+
export function dbEvidence(root, env = process.env) {
|
|
574
|
+
if (typeof root !== 'string' || root === '' || root === MEMORY_ROOT) return false;
|
|
575
|
+
for (const key of DB_EVIDENCE_ENV) if ((env[key] ?? '').trim()) return true;
|
|
576
|
+
const cached = dbEvidenceCache.get(root);
|
|
577
|
+
if (cached !== undefined) return cached;
|
|
578
|
+
let found = false;
|
|
579
|
+
for (const rel of DB_EVIDENCE_PATHS) {
|
|
580
|
+
try { if (existsSync(join(root, rel))) { found = true; break; } } catch { /* unreadable is not evidence */ }
|
|
581
|
+
}
|
|
582
|
+
dbEvidenceCache.set(root, found);
|
|
583
|
+
return found;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
export function lspAvailable(root, env = process.env) {
|
|
587
|
+
if (typeof root !== 'string' || root === '' || root === MEMORY_ROOT) return false;
|
|
588
|
+
try {
|
|
589
|
+
const present = languagesPresent(root);
|
|
590
|
+
if (present.size === 0) return false;
|
|
591
|
+
for (const language of Object.keys(LANGUAGE_SERVERS)) {
|
|
592
|
+
if (!present.has(language)) continue;
|
|
593
|
+
if (discoverLanguageServer(root, language, { env }).ok === true) return true;
|
|
594
|
+
}
|
|
595
|
+
} catch {
|
|
596
|
+
return false;
|
|
597
|
+
}
|
|
598
|
+
return false;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* Which of the four languages this workspace visibly contains.
|
|
603
|
+
*
|
|
604
|
+
* ⚠️ CHEAP BY CONSTRUCTION, because it runs on the offer path of every
|
|
605
|
+
* multi-round session. One `readdir` of the root, plus one of each of up to
|
|
606
|
+
* `LANG_PROBE_DIRS` first-level directories. It never recurses, never reads a
|
|
607
|
+
* file, and never spawns anything — a project whose only Python lives four
|
|
608
|
+
* levels down is a MISS, and a miss costs four tools rather than correctness.
|
|
609
|
+
*
|
|
610
|
+
* ⭐ TWO SIGNALS, BOTH CHEAP AND EITHER SUFFICES. A manifest (`Cargo.toml`,
|
|
611
|
+
* `go.mod`, `package.json`, `pyproject.toml`) is the strong one and catches the
|
|
612
|
+
* monorepo whose source is all in `crates/` or `src/`. An extension seen in the
|
|
613
|
+
* shallow walk is the weak one and catches the script folder with no manifest
|
|
614
|
+
* at all. Requiring both would fail the common cases in opposite directions.
|
|
615
|
+
*/
|
|
616
|
+
const LANG_PROBE_DIRS = 12;
|
|
617
|
+
const LANG_PROBE_SKIP = new Set(['node_modules', '.git', 'dist', 'build', 'out', 'target', 'vendor', '.next', 'coverage', '__pycache__']);
|
|
618
|
+
const LANG_MANIFESTS = {
|
|
619
|
+
typescript: ['package.json', 'tsconfig.json', 'jsconfig.json', 'deno.json'],
|
|
620
|
+
python: ['pyproject.toml', 'requirements.txt', 'setup.py', 'setup.cfg', 'Pipfile'],
|
|
621
|
+
rust: ['Cargo.toml'],
|
|
622
|
+
go: ['go.mod', 'go.work'],
|
|
623
|
+
};
|
|
624
|
+
/** extension → language, built from the registry so the two can never disagree. */
|
|
625
|
+
const LANG_BY_EXTENSION = (() => {
|
|
626
|
+
const map = new Map();
|
|
627
|
+
for (const [language, spec] of Object.entries(LANGUAGE_SERVERS)) {
|
|
628
|
+
for (const ext of Object.keys(spec.extensions ?? {})) map.set(ext.toLowerCase(), language);
|
|
629
|
+
}
|
|
630
|
+
return map;
|
|
631
|
+
})();
|
|
632
|
+
|
|
633
|
+
export function languagesPresent(root) {
|
|
634
|
+
const found = new Set();
|
|
635
|
+
const note = (name) => {
|
|
636
|
+
for (const [language, manifests] of Object.entries(LANG_MANIFESTS)) {
|
|
637
|
+
if (manifests.includes(name)) found.add(language);
|
|
638
|
+
}
|
|
639
|
+
const dot = name.lastIndexOf('.');
|
|
640
|
+
if (dot > 0) {
|
|
641
|
+
const language = LANG_BY_EXTENSION.get(name.slice(dot).toLowerCase());
|
|
642
|
+
if (language) found.add(language);
|
|
643
|
+
}
|
|
644
|
+
};
|
|
645
|
+
|
|
646
|
+
let entries;
|
|
647
|
+
try {
|
|
648
|
+
entries = readdirSync(root, { withFileTypes: true });
|
|
649
|
+
} catch {
|
|
650
|
+
return found;
|
|
651
|
+
}
|
|
652
|
+
const dirs = [];
|
|
653
|
+
for (const e of entries) {
|
|
654
|
+
if (e.isDirectory()) {
|
|
655
|
+
if (!e.name.startsWith('.') && !LANG_PROBE_SKIP.has(e.name) && dirs.length < LANG_PROBE_DIRS) dirs.push(e.name);
|
|
656
|
+
continue;
|
|
657
|
+
}
|
|
658
|
+
note(e.name);
|
|
659
|
+
}
|
|
660
|
+
for (const dir of dirs) {
|
|
661
|
+
// A manifest one level down counts too: `packages/api/package.json` and
|
|
662
|
+
// `crates/core/Cargo.toml` are how real repositories are shaped.
|
|
663
|
+
let children;
|
|
664
|
+
try {
|
|
665
|
+
children = readdirSync(join(root, dir), { withFileTypes: true });
|
|
666
|
+
} catch {
|
|
667
|
+
continue;
|
|
668
|
+
}
|
|
669
|
+
for (const c of children) {
|
|
670
|
+
if (!c.isDirectory()) note(c.name);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
return found;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* @param {number} maxRounds
|
|
678
|
+
* @param {{ allowRun?: boolean, env?: Record<string, any>, root?: string }} [opts]
|
|
679
|
+
*
|
|
680
|
+
* ⚠️ `root` DEFAULTS TO `process.cwd()` AND THAT IS A KNOWN SEAM, not a
|
|
681
|
+
* preference. `bin/acuvo.mjs` computes `resolve(opts.dir ?? process.cwd())`, so
|
|
682
|
+
* for every run without `--dir` the default is exactly right; with `--dir` it
|
|
683
|
+
* probes the wrong tree until `turn.mjs` passes `root: executor.root` (one word,
|
|
684
|
+
* another lane's file). The failure it can produce is small and one-directional
|
|
685
|
+
* — `read_skill` offered in a `--dir` run because the CURRENT directory has
|
|
686
|
+
* skills — and it is stated here rather than left to be discovered.
|
|
687
|
+
*/
|
|
688
|
+
export function toolNamesForRounds(maxRounds, { allowRun = true, env = process.env, root = process.cwd(), subagent = false, interactive = false } = {}) {
|
|
689
|
+
/**
|
|
690
|
+
* ── ⭐ `generate_image` IS OFFERED IN BOTH SHAPES, AND ONLY WHEN IT EXISTS ──
|
|
691
|
+
* It is available even in a single-shot run — "build me a landing page with a
|
|
692
|
+
* hero image" is a complete, one-round request and withholding the image would
|
|
693
|
+
* make the answer worse for no reason. It writes a file, so unlike a read tool
|
|
694
|
+
* its result has somewhere to go even with no second round.
|
|
695
|
+
*
|
|
696
|
+
* ⚠️ IT IS NOT "the one capability no other coding agent has" — that claim
|
|
697
|
+
* stood here until 2026-08-11 and it was never true. An MCP-speaking agent is
|
|
698
|
+
* one `npx` away from an image server, the same way it is one away from a
|
|
699
|
+
* browser. What is ours is that it needs NO key, NO account and NO config
|
|
700
|
+
* (see the default below), and that the result lands as a file in the
|
|
701
|
+
* workspace rather than as a URL the model has to describe.
|
|
702
|
+
*
|
|
703
|
+
* ⚠️ GATED ON THE SERVICE BEING CONFIGURED — but read what "configured" means
|
|
704
|
+
* before trusting this comment, because its previous version was FALSE and the
|
|
705
|
+
* README repeated the falsehood.
|
|
706
|
+
*
|
|
707
|
+
* `imageConfig` defaults to an XXIautomate-hosted endpoint when the variable is
|
|
708
|
+
* UNSET, so on a bare machine `configured` is TRUE and the tool IS offered.
|
|
709
|
+
* That is deliberate — a capability you must discover and configure is one most
|
|
710
|
+
* people never see — but it means every installed copy can send a prompt to our
|
|
711
|
+
* infrastructure, which is a disclosure obligation and not an implementation
|
|
712
|
+
* detail. It is now stated outright in README.md under "generate_image is
|
|
713
|
+
* different".
|
|
714
|
+
*
|
|
715
|
+
* ⭐ The gate that still bites: `PERCHANCE_IMAGE_URL=` (explicitly empty) means
|
|
716
|
+
* OFF, and then the model is never told the capability exists — because
|
|
717
|
+
* offering a tool that can only return "no image service is set up" teaches it
|
|
718
|
+
* to try, wait, and apologise, which is a dead button by another name.
|
|
719
|
+
*/
|
|
720
|
+
const withImage = (names) => (imageConfig(env).configured ? [...names, 'generate_image'] : names);
|
|
721
|
+
|
|
722
|
+
if (maxRounds <= 1) return withImage([...SINGLE_SHOT_TOOL_NAMES]);
|
|
723
|
+
/**
|
|
724
|
+
* ⭐ SEARCH IS IN THE MULTI-ROUND OFFER AND NOT THE SINGLE-SHOT ONE, for the
|
|
725
|
+
* same reason the read tools are not: a search result has nowhere to go when
|
|
726
|
+
* the turn ends immediately after it. With a second round it becomes the most
|
|
727
|
+
* valuable tool here — it is what turns "writes files" into "works in your
|
|
728
|
+
* codebase", because a model that cannot find a function will invent a
|
|
729
|
+
* plausible file and write over the wrong one.
|
|
730
|
+
*/
|
|
731
|
+
/**
|
|
732
|
+
* ⭐ `edit_file` sits beside write_file, and the ORDER here is a hint the model
|
|
733
|
+
* reads: edit before write, because for a file that already exists write_file
|
|
734
|
+
* is a destructive operation wearing the costume of an edit.
|
|
735
|
+
*/
|
|
736
|
+
/**
|
|
737
|
+
* ⭐ `delete_file` IS HERE BECAUSE ITS ABSENCE CAPTURED A WHOLE SESSION.
|
|
738
|
+
* Measured 2026-08-09: with no delete verb the model wrote 0 bytes over its
|
|
739
|
+
* scratch file, then spent every remaining round trying to remove it and
|
|
740
|
+
* never reached the commit it had been asked for. See delete.mjs.
|
|
741
|
+
*/
|
|
742
|
+
const names = ['read_file', 'write_file', 'edit_file', 'delete_file', 'move_file', 'list_dir', 'find_files', 'search_text'];
|
|
743
|
+
/**
|
|
744
|
+
* ⭐ `run_program` SITS IMMEDIATELY AFTER `run_command`, AND THE ORDER IS THE
|
|
745
|
+
* HINT — the same reason `edit_file` sits before `write_file`. A model reading
|
|
746
|
+
* the offer top-down meets the string runner first (right for `npm test`) and
|
|
747
|
+
* the argv runner second (right the moment an argument has a space, a quote or
|
|
748
|
+
* a leading dash). Both are gated on `allowRun`: it spawns a process, so
|
|
749
|
+
* `--no-run` must withhold it or the flag is a lie by a side door.
|
|
750
|
+
*/
|
|
751
|
+
if (allowRun) names.push('run_command', 'run_program', 'evaluate');
|
|
752
|
+
/**
|
|
753
|
+
* ── ⭐⭐ BACKGROUND — MULTI-ROUND ONLY, AND GATED ON `allowRun` ────────────
|
|
754
|
+
*
|
|
755
|
+
* Multi-round because the whole shape is "start it now, look at it later": in
|
|
756
|
+
* a single-shot turn there is no later, and a server started in the last
|
|
757
|
+
* round of a run is killed by the teardown before anything can use it — the
|
|
758
|
+
* dead button this file refuses to ship, in its most expensive form.
|
|
759
|
+
*
|
|
760
|
+
* ⚠️ And it spawns a process, so `--no-run` must withhold it or the flag is a
|
|
761
|
+
* lie by a side door — the same rule `run_program` and `evaluate` obey.
|
|
762
|
+
*/
|
|
763
|
+
if (allowRun) names.push(...BACKGROUND_TOOL_NAMES);
|
|
764
|
+
/**
|
|
765
|
+
* ── ⭐⭐ AND THE VERB THAT MAKES A STARTED SERVER WORTH STARTING ───────────
|
|
766
|
+
*
|
|
767
|
+
* `start_process` gave the agent a dev server it could not talk to.
|
|
768
|
+
* `check_process` answers one question — "is anything listening" — and
|
|
769
|
+
* `fetch_url` refuses loopback by design, so the agent could build an API and
|
|
770
|
+
* never once call it. `POST /users` returning 201 is the difference between
|
|
771
|
+
* "wrote a route" and "the route works".
|
|
772
|
+
*
|
|
773
|
+
* ⚠️ TIED TO `allowRun`, AND TIED TO `start_process` SPECIFICALLY. It can only
|
|
774
|
+
* reach a port a process from THIS run started and the OS has confirmed that
|
|
775
|
+
* process holds (`portVerified`); with `--no-run` there are no such processes,
|
|
776
|
+
* so listing it would be a button that can only ever refuse.
|
|
777
|
+
*
|
|
778
|
+
* ⚠️ AND MULTI-ROUND FOR THE SAME REASON AS THE SERVER IT CALLS: in a
|
|
779
|
+
* single-shot turn there is no round in which the server is already up.
|
|
780
|
+
*/
|
|
781
|
+
if (allowRun) names.push(...HTTP_PROBE_TOOL_NAMES);
|
|
782
|
+
// ⚠️ Same rule, same reason: gh spawns a binary, and the log verbs can only
|
|
783
|
+
// read a process `start_process` started — which --no-run refuses.
|
|
784
|
+
if (allowRun) names.push('gh_issue', 'gh_pr', 'gh_run', 'read_log', 'wait_for_output', 'summarize_log');
|
|
785
|
+
/**
|
|
786
|
+
* ── ⭐⭐ THE REPL — MULTI-ROUND ONLY, AND OBVIOUSLY SO ─────────────────────
|
|
787
|
+
*
|
|
788
|
+
* Its whole value is that call N+1 sees what call N defined. In a single-shot
|
|
789
|
+
* turn there is no call N+1, so it is the dead button this file refuses to
|
|
790
|
+
* ship — and worse than most, because it would spend the only round starting a
|
|
791
|
+
* process instead of doing the task.
|
|
792
|
+
*
|
|
793
|
+
* ⚠️ `allowRun` because it executes the user's JavaScript for real.
|
|
794
|
+
*/
|
|
795
|
+
if (allowRun) names.push(...REPL_TOOL_NAMES);
|
|
796
|
+
/**
|
|
797
|
+
* ── ⭐ GIT IS MULTI-ROUND ONLY, AND `git_commit` IS GATED ON `allowRun` ─────
|
|
798
|
+
*
|
|
799
|
+
* `git_status` and `git_diff` are reads: their result has nowhere to go in a
|
|
800
|
+
* single-shot turn, so offering them there would be the dead button this file
|
|
801
|
+
* already refuses to ship. With a second round they are the most valuable
|
|
802
|
+
* reads here — a diff is how the model checks its own edit landed, which is
|
|
803
|
+
* the one verification it currently cannot perform without running code.
|
|
804
|
+
*
|
|
805
|
+
* ⚠️ AND COMMIT RIDES WITH `--no-run`, WHICH IS NOT AN OBVIOUS PAIRING. The
|
|
806
|
+
* flag reads as "do not execute anything", and a user who passes it is saying
|
|
807
|
+
* they have not read the task yet. A commit is not code execution, but it IS
|
|
808
|
+
* the one irreversible-looking thing in the package — a wrong commit is
|
|
809
|
+
* recoverable and does not feel it. So the cautious flag withholds the
|
|
810
|
+
* cautious verb, and reading the repo stays available either way.
|
|
811
|
+
*/
|
|
812
|
+
// ⭐ Media joins the multi-round offer, gated on real configuration. A tool
|
|
813
|
+
// whose service is absent is never mentioned — the model must not spend a
|
|
814
|
+
// round discovering what the schema could have told it for free.
|
|
815
|
+
names.push(...mediaToolNames(env));
|
|
816
|
+
// ⚠️ MULTI-ROUND ONLY, and not for the usual "a read has nowhere to go"
|
|
817
|
+
// reason: an edit is a 3-second-to-6-minute GPU job whose whole point is that
|
|
818
|
+
// the model then LOOKS at the result. Offering it on a single-shot turn buys
|
|
819
|
+
// the render and throws away the check.
|
|
820
|
+
names.push(...imageEditToolNames(env));
|
|
821
|
+
names.push('git_status', 'git_diff', 'git_log');
|
|
822
|
+
if (allowRun) names.push('git_commit');
|
|
823
|
+
/**
|
|
824
|
+
* ⭐ `git_branch` RIDES WITH COMMIT, and for the weaker half of the same
|
|
825
|
+
* reason. It executes git and changes repository state, so `--no-run`
|
|
826
|
+
* withholds it — but on its own it touches no remote and destroys nothing,
|
|
827
|
+
* which is why it needs no gate beyond that. Without it the agent could
|
|
828
|
+
* commit and had no way to keep the commit off the branch it started on.
|
|
829
|
+
*/
|
|
830
|
+
if (allowRun) names.push('git_branch');
|
|
831
|
+
/**
|
|
832
|
+
* ── ⚠️⚠️ PUSH IS OFF UNLESS AN OPERATOR NAMED IT ──────────────────────────
|
|
833
|
+
* `ACUVO_ALLOW_PUSH=1`, checked in `git.mjs`. This is the same shape as the
|
|
834
|
+
* media tools directly above — a capability whose configuration is absent is
|
|
835
|
+
* never mentioned — and it is deliberately a SECOND gate on top of
|
|
836
|
+
* `allowRun`, because push is the only verb in the package whose effect is
|
|
837
|
+
* visible to people who are not at this keyboard.
|
|
838
|
+
*
|
|
839
|
+
* ⭐ AND IT COSTS ZERO TOKENS WHEN OFF. The schema exists in the registry;
|
|
840
|
+
* `toolSchemasFor` only serialises the names in this list.
|
|
841
|
+
*/
|
|
842
|
+
names.push(...gitPushToolNames(env, { allowRun }));
|
|
843
|
+
|
|
844
|
+
/**
|
|
845
|
+
* ── ⭐ THE WINDOWED READS — MULTI-ROUND ONLY, LIKE EVERY OTHER READ ────────
|
|
846
|
+
* `read_lines` and `read_around` are `read_file` with an honest truncation
|
|
847
|
+
* story: read_file cuts the MIDDLE out of a large file and says nothing the
|
|
848
|
+
* model can act on, these cut the END and hand back `nextOffset`. Same
|
|
849
|
+
* dead-button rule as read_file, so the same placement — a window of a file
|
|
850
|
+
* has nowhere to go when the turn ends immediately after it.
|
|
851
|
+
*/
|
|
852
|
+
names.push('read_lines', 'read_around');
|
|
853
|
+
|
|
854
|
+
/**
|
|
855
|
+
* ── ⭐ `list_engines` — MULTI-ROUND ONLY, AND FOR THE STRONGEST VERSION OF
|
|
856
|
+
* THE DEAD-BUTTON RULE ────────────────────────────────────────────────────
|
|
857
|
+
*
|
|
858
|
+
* Its answer exists to change the NEXT call — "ultra costs 48 credits an
|
|
859
|
+
* image, the core one costs 4, which do you want". In a single-shot turn
|
|
860
|
+
* there is no next call, so it would burn the only round finding out a price
|
|
861
|
+
* it can never use.
|
|
862
|
+
*
|
|
863
|
+
* ⚠️ NOT GATED ON `imageConfig`. It answers a question about the ACCOUNT, not
|
|
864
|
+
* about whether a render endpoint is configured on this machine — and "what
|
|
865
|
+
* would this cost me" is a fair question to ask before setting anything up.
|
|
866
|
+
*/
|
|
867
|
+
names.push('list_engines');
|
|
868
|
+
|
|
869
|
+
/**
|
|
870
|
+
* ── ⭐ THE PLAN LEDGER — MULTI-ROUND ONLY, AND OBVIOUSLY SO ────────────────
|
|
871
|
+
* Its entire value is the banner on every LATER tool result: "2/5 done, 3
|
|
872
|
+
* rounds left". With one round there is no later, so `plan_start` in a
|
|
873
|
+
* single-shot turn is a file written for a reader who never arrives — the
|
|
874
|
+
* dead button in its purest form.
|
|
875
|
+
*/
|
|
876
|
+
names.push('plan_start', 'plan_step', 'plan_status');
|
|
877
|
+
|
|
878
|
+
/**
|
|
879
|
+
* ── ⭐ SESSIONS — READ-ONLY, MULTI-ROUND ONLY ─────────────────────────────
|
|
880
|
+
* `list_sessions` cannot resume anything (resume is an operator action, from
|
|
881
|
+
* the command line, between runs — see session.mjs's header on replayed side
|
|
882
|
+
* effects). It is a read, and reads need a next round.
|
|
883
|
+
*/
|
|
884
|
+
names.push('list_sessions');
|
|
885
|
+
|
|
886
|
+
/**
|
|
887
|
+
* ── ⭐ FETCH — MULTI-ROUND ONLY ───────────────────────────────────────────
|
|
888
|
+
* A page of documentation is context for the NEXT decision. Fetched in a turn
|
|
889
|
+
* with no next decision it is a paid round that changes nothing.
|
|
890
|
+
* Not gated on configuration because there is none: GET only, no headers,
|
|
891
|
+
* private and loopback addresses refused, 10 fetches per run. It either
|
|
892
|
+
* reaches the internet or returns a sentence saying it could not.
|
|
893
|
+
*/
|
|
894
|
+
names.push('fetch_url');
|
|
895
|
+
|
|
896
|
+
/**
|
|
897
|
+
* ── ⭐⭐ SEARCH — MULTI-ROUND ONLY, FOR THE SAME REASON ────────────────────
|
|
898
|
+
*
|
|
899
|
+
* `fetch_url` could read a page it was TOLD about; it could not FIND one. A
|
|
900
|
+
* search result is not an answer, it is a pointer to the round that reads it,
|
|
901
|
+
* so in a single-round turn it is a paid call that changes nothing.
|
|
902
|
+
*
|
|
903
|
+
* ⭐ AND IT IS THE HALF THAT STOPS THE GUESSING. A model that cannot look up
|
|
904
|
+
* an option name invents one, confidently, and the invention compiles.
|
|
905
|
+
* Keyless: DuckDuckGo plus the StackOverflow API, capped per run.
|
|
906
|
+
*/
|
|
907
|
+
names.push('web_search');
|
|
908
|
+
|
|
909
|
+
/**
|
|
910
|
+
* ── ⭐⭐ EYES — MULTI-ROUND ONLY, AND FOR A SHARPER REASON THAN THE OTHERS ──
|
|
911
|
+
*
|
|
912
|
+
* Looking is only worth paying for if there is a round left to ACT on what
|
|
913
|
+
* was seen. A single-round turn that renders something, looks at it, and then
|
|
914
|
+
* stops has bought a description nobody can use.
|
|
915
|
+
*/
|
|
916
|
+
names.push('read_image');
|
|
917
|
+
|
|
918
|
+
/**
|
|
919
|
+
* ── ⚠️ ACCEPTANCE RIDES WITH `allowRun`, AND BOTH VERBS TOGETHER ──────────
|
|
920
|
+
*
|
|
921
|
+
* `check_acceptance` EXECUTES COMMANDS — it takes a runner and runs every
|
|
922
|
+
* declared criterion. Offering it under `--no-run` would make that flag a lie
|
|
923
|
+
* by a side door, exactly as `evaluate` would (see the test that pins it).
|
|
924
|
+
*
|
|
925
|
+
* ⭐ And `declare_acceptance` goes with it rather than staying behind. Alone
|
|
926
|
+
* it is a promise nothing can keep: the model records "npm test must pass",
|
|
927
|
+
* no round can ever run it, and the run ends having declared a criterion it
|
|
928
|
+
* never checked — which reads as verification and is not. Two halves of one
|
|
929
|
+
* capability; neither is worth offering without the other.
|
|
930
|
+
*/
|
|
931
|
+
if (allowRun) names.push('declare_acceptance', 'check_acceptance');
|
|
932
|
+
|
|
933
|
+
/**
|
|
934
|
+
* ── ⭐ SKILLS — GATED ON THE DIRECTORY EXISTING AND HAVING SOMETHING IN IT ─
|
|
935
|
+
* skills.mjs states the rule itself: "a read_skill in a project with no skills
|
|
936
|
+
* is a dead button". Most projects have none, so this is the common case and
|
|
937
|
+
* the tool is usually absent — correctly. There is no `list_skills` because
|
|
938
|
+
* the catalogue belongs in the system prompt (turn.mjs's job, not this file's).
|
|
939
|
+
*/
|
|
940
|
+
/**
|
|
941
|
+
* ⭐ DECLARED SINCE FOREVER, NAMED SINCE 2026-08-20. `review_code` reads a
|
|
942
|
+
* file already on disk — no key, no endpoint, no process — and `code-review
|
|
943
|
+
* .mjs` has been telling users to "run `review_code` on the file" the whole
|
|
944
|
+
* time. 418 tokens.
|
|
945
|
+
*/
|
|
946
|
+
names.push('review_code');
|
|
947
|
+
/**
|
|
948
|
+
* ⚠️ THE DB PAIR IS GATED ON EVIDENCE, not offered blindly: 835 tokens on
|
|
949
|
+
* every turn is real money in a package whose binding constraint is the token
|
|
950
|
+
* budget. Same shape as `skillsAvailable` and `lspAvailable` above.
|
|
951
|
+
*/
|
|
952
|
+
if (dbEvidence(root, env)) names.push('inspect_db', 'sample_db_rows');
|
|
953
|
+
if (skillsAvailable(root)) names.push('read_skill');
|
|
954
|
+
|
|
955
|
+
/**
|
|
956
|
+
* ── ⭐⭐ ASK_USER — OFFERED ONLY WHEN THERE IS SOMEBODY TO ASK ─────────────
|
|
957
|
+
*
|
|
958
|
+
* ⚠️ ABSENCE, NOT REFUSAL. `prompt.mjs`'s `createAsker` returns null unless
|
|
959
|
+
* stdin and stdout are BOTH terminals, and that null arrives here as
|
|
960
|
+
* `interactive: false`. In CI, a pipe or a task runner the tool is simply not
|
|
961
|
+
* in the list — which is stronger than a tool that is offered and always
|
|
962
|
+
* refuses, because a schema costs tokens every single round and invites the
|
|
963
|
+
* model to spend one discovering the button is dead. Same rule the file
|
|
964
|
+
* already applies to `read_skill` in a project with no skills.
|
|
965
|
+
*
|
|
966
|
+
* ⚠️ MULTI-ROUND ONLY, for this file's standing reason: in a single-shot turn
|
|
967
|
+
* the answer arrives as a tool result with nowhere to go, and the round it
|
|
968
|
+
* costs would be the only round there was. An agent that spends its one round
|
|
969
|
+
* asking a question it can no longer act on is strictly worse than one that
|
|
970
|
+
* guessed.
|
|
971
|
+
*/
|
|
972
|
+
if (interactive) names.push('ask_user');
|
|
973
|
+
|
|
974
|
+
/**
|
|
975
|
+
* ⭐ `write_files` RIDES WITH THE WRITE CAPABILITY, not with a round budget.
|
|
976
|
+
* It is `write_file` for more than one file; anywhere the model may write, it
|
|
977
|
+
* may write several. Offering it only in long runs would leave the bulk edit
|
|
978
|
+
* exactly where it was — inside `evaluate`, where nothing can see it.
|
|
979
|
+
*/
|
|
980
|
+
names.push('write_files');
|
|
981
|
+
|
|
982
|
+
/**
|
|
983
|
+
* ── ⭐⭐ `apply_patch` — MULTI-ROUND ONLY, AND THAT IS NOT AN OVERSIGHT ─────
|
|
984
|
+
*
|
|
985
|
+
* ⭐ WHY IT IS OFFERED AT ALL: output is 56% of a build's spend ($0.045 of
|
|
986
|
+
* $0.080, ~53,000 tokens) and a prompt cache — already at 83.2% and 100%
|
|
987
|
+
* steady-state — cannot discount output at all. The output is dominated by
|
|
988
|
+
* re-emitting whole files, and a patch is 10-50x smaller. It is also the
|
|
989
|
+
* accuracy fix: flexible patch application measures 9x fewer editing errors.
|
|
990
|
+
*
|
|
991
|
+
* ⚠️ WHY NOT IN `SINGLE_SHOT_TOOL_NAMES`: a patch's context lines must match
|
|
992
|
+
* the file ON DISK, and when they do not the only repair is the next round.
|
|
993
|
+
* A one-round turn has none, so it would be precisely the dead button this
|
|
994
|
+
* file spends four hundred lines refusing to ship — and `write_file` still
|
|
995
|
+
* works there, so withholding it costs the user nothing.
|
|
996
|
+
*/
|
|
997
|
+
names.push('apply_patch');
|
|
998
|
+
|
|
999
|
+
/**
|
|
1000
|
+
* ── ⭐ REMEMBER / FORGET — ALWAYS OFFERED IN A MULTI-ROUND TURN ────────────
|
|
1001
|
+
*
|
|
1002
|
+
* Unlike `read_skill` there is nothing to gate on: an empty memory is the
|
|
1003
|
+
* NORMAL starting state and the whole point is that the agent fills it. A
|
|
1004
|
+
* project with no learned facts is exactly where remembering the first one
|
|
1005
|
+
* matters most.
|
|
1006
|
+
*
|
|
1007
|
+
* ⚠️ MULTI-ROUND ONLY, for this file's standing reason. In a single-shot turn
|
|
1008
|
+
* the run ends before anything could act on what was recorded, so `remember`
|
|
1009
|
+
* would be a button whose result has nowhere to go — and worse, it would spend
|
|
1010
|
+
* the one round on bookkeeping instead of the task.
|
|
1011
|
+
*
|
|
1012
|
+
* ⚠️ `forget` RIDES WITH IT and is not optional. A wrong memory is worse than
|
|
1013
|
+
* no memory, and shipping the write verb without the correction verb means the
|
|
1014
|
+
* only way to fix a bad fact is to edit a file by hand.
|
|
1015
|
+
*/
|
|
1016
|
+
names.push('remember', 'forget');
|
|
1017
|
+
|
|
1018
|
+
/**
|
|
1019
|
+
* ── ⭐ DELEGATE — MULTI-ROUND ONLY, AND NEVER TO A SUBAGENT ────────────────
|
|
1020
|
+
*
|
|
1021
|
+
* ⚠️ A helper's answer arrives as a tool result, so in a single-round turn it
|
|
1022
|
+
* has nowhere to go — the dead button this file refuses to ship. And the
|
|
1023
|
+
* round it costs would be the only round there was.
|
|
1024
|
+
*
|
|
1025
|
+
* ⚠️ `subagent: true` REMOVES IT ENTIRELY. `SUBAGENT_TOOL_NAMES` already omits
|
|
1026
|
+
* it, so this is the second lock rather than the only one: the offer a
|
|
1027
|
+
* subagent computes and the list it is handed must agree, or a future refactor
|
|
1028
|
+
* that starts calling this function for helpers quietly reopens recursion.
|
|
1029
|
+
*/
|
|
1030
|
+
if (!subagent) names.push('delegate');
|
|
1031
|
+
|
|
1032
|
+
/**
|
|
1033
|
+
* ── ⭐ LSP — GATED ON A LANGUAGE SERVER BEING INSTALLED ───────────────────
|
|
1034
|
+
* On a machine with no server these four tools can only ever return "install
|
|
1035
|
+
* it with npm i -D …", which teaches the model to try, wait and apologise.
|
|
1036
|
+
* `lspAvailable` probes PATH and node_modules; it spawns nothing.
|
|
1037
|
+
*/
|
|
1038
|
+
/**
|
|
1039
|
+
* ── ⭐⭐ TWO WAYS TO SERVE THE SAME FOUR TOOLS ──────────────────────────────
|
|
1040
|
+
*
|
|
1041
|
+
* `lspAvailable` needs `typescript-language-server`, a package almost nobody
|
|
1042
|
+
* installs — measured false on a real Next.js app AND a real API server, so
|
|
1043
|
+
* these four shipped dark on every machine including the author's.
|
|
1044
|
+
*
|
|
1045
|
+
* ⭐ `tsserverAvailable` needs only `typescript`, which every TypeScript
|
|
1046
|
+
* project already has because it is what compiles the project. It answers for
|
|
1047
|
+
* TS and JS; a real language server still wins when present because it also
|
|
1048
|
+
* covers Python, Rust and Go. Either way the model sees the same four tools
|
|
1049
|
+
* returning the same shapes and never learns which one answered.
|
|
1050
|
+
*/
|
|
1051
|
+
if (lspAvailable(root, env) || tsserverAvailable(root)) names.push(...LSP_TOOL_NAMES);
|
|
1052
|
+
|
|
1053
|
+
return withImage(names);
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
/** The subset of the registry to put in a request payload. */
|
|
1057
|
+
/**
|
|
1058
|
+
* ⚠️⭐ THE DESCRIPTION HAS TO TELL THE TRUTH ABOUT THE MODE IT IS RUNNING IN.
|
|
1059
|
+
* `run_command`'s static text says "There is NO SHELL: pipes, &&, ; … are
|
|
1060
|
+
* refused". Left unchanged under `--shell` that is a LIE THAT DISABLES THE
|
|
1061
|
+
* FEATURE: the model reads it, believes pipes are impossible, and never tries —
|
|
1062
|
+
* so the flag the operator deliberately turned on does nothing, and the failure
|
|
1063
|
+
* is invisible because nothing errors. A capability the model is told it does
|
|
1064
|
+
* not have is a capability it does not have.
|
|
1065
|
+
*/
|
|
1066
|
+
const SHELL_RUN_DESCRIPTION = [
|
|
1067
|
+
'Run a command in the workspace through a real shell and get its exit code, stdout and stderr back.',
|
|
1068
|
+
'This is how you VERIFY what you wrote — a non-zero exit code is the fact you fix in the next round.',
|
|
1069
|
+
'A SHELL IS AVAILABLE in this run: pipes, &&, ||, ;, quoting, redirection and $(...) all work,',
|
|
1070
|
+
'and you may run any program installed on this machine (python, go, cargo, git, curl, make, …).',
|
|
1071
|
+
'The working directory is the workspace root.',
|
|
1072
|
+
'Prefer one command per call so a failure names itself; chain only when the steps are genuinely one step.',
|
|
1073
|
+
].join(' ');
|
|
1074
|
+
|
|
1075
|
+
export function toolSchemasFor(names, { shell = false } = {}) {
|
|
1076
|
+
const wanted = new Set(names);
|
|
1077
|
+
const picked = TOOL_SCHEMAS.filter((t) => wanted.has(t.function.name));
|
|
1078
|
+
if (!shell) return picked;
|
|
1079
|
+
return picked.map((t) => (t.function.name === 'run_command'
|
|
1080
|
+
? { ...t, function: { ...t.function, description: SHELL_RUN_DESCRIPTION } }
|
|
1081
|
+
: t));
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
/**
|
|
1085
|
+
* Tool arguments arrive as a STRING of JSON that a model wrote, so malformed
|
|
1086
|
+
* JSON is a normal Tuesday rather than an exceptional condition. Parsing it in
|
|
1087
|
+
* one guarded place means no handler has to think about it.
|
|
1088
|
+
*
|
|
1089
|
+
* Pure, and separately tested — this is the function that decides whether a
|
|
1090
|
+
* fumbled argument blob crashes the CLI or produces a sentence the model could
|
|
1091
|
+
* have acted on if there were a next round.
|
|
1092
|
+
*/
|
|
1093
|
+
export function parseToolArguments(raw) {
|
|
1094
|
+
if (raw === undefined || raw === null || raw === '') return { ok: true, args: {} };
|
|
1095
|
+
if (typeof raw === 'object') return { ok: true, args: raw };
|
|
1096
|
+
if (typeof raw !== 'string') return { ok: false, error: 'tool arguments were neither a string nor an object' };
|
|
1097
|
+
let parsed;
|
|
1098
|
+
try {
|
|
1099
|
+
parsed = JSON.parse(raw);
|
|
1100
|
+
} catch (err) {
|
|
1101
|
+
return { ok: false, error: `tool arguments were not valid JSON: ${err instanceof Error ? err.message : String(err)}` };
|
|
1102
|
+
}
|
|
1103
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
1104
|
+
return { ok: false, error: 'tool arguments must be a JSON object' };
|
|
1105
|
+
}
|
|
1106
|
+
return { ok: true, args: parsed };
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
/**
|
|
1110
|
+
* Run one tool call against a local executor.
|
|
1111
|
+
*
|
|
1112
|
+
* Returns `{ id, name, args, result, mutated }`. `mutated` is what the summary
|
|
1113
|
+
* counts — it is set by the DISPATCHER rather than inferred from the tool name
|
|
1114
|
+
* downstream, so a future tool that also touches disk cannot be missed by a
|
|
1115
|
+
* summary that only knows the string 'write_file'.
|
|
1116
|
+
*
|
|
1117
|
+
* ⚠️ ASYNC AS OF `run_command`, AND ALL OF IT RATHER THAN HALF. Three of these
|
|
1118
|
+
* handlers are synchronous and one cannot be, and the tempting shape — a sync
|
|
1119
|
+
* dispatcher plus a separate async path for the one tool — is how a registry
|
|
1120
|
+
* grows two front doors and then two sets of rules. One dispatcher, one
|
|
1121
|
+
* contract, `await` at the single call site.
|
|
1122
|
+
*
|
|
1123
|
+
* ⚠️ `round` IS OPTIONAL AND ITS ABSENCE IS SAFE. The plan ledger prints a
|
|
1124
|
+
* countdown ("· round 3 of 8") when it is told where in the budget it is, and
|
|
1125
|
+
* omits the clause entirely when it is not — plan-ledger.mjs says outright that
|
|
1126
|
+
* a WRONG countdown is worse than no countdown, so the degradation is the
|
|
1127
|
+
* designed one. `turn.mjs` passing `{ round: { roundIndex: round, maxRounds } }`
|
|
1128
|
+
* turns it on; nothing breaks until it does.
|
|
1129
|
+
*
|
|
1130
|
+
* ⚠️ `allowRun` DEFAULTS TO TRUE so today's callers are byte-identical. Passing
|
|
1131
|
+
* `false` refuses `check_acceptance` at the DISPATCHER, not just at the offer —
|
|
1132
|
+
* a model can emit a call for a tool it was never shown, and that one executes
|
|
1133
|
+
* commands.
|
|
1134
|
+
*
|
|
1135
|
+
* ⚠️ AND `id` IS CARRIED THROUGH, which is not bookkeeping: a multi-round turn
|
|
1136
|
+
* has to send each result back as a `tool` message keyed by the id the model
|
|
1137
|
+
* gave the call, and a mismatched or missing id makes the whole conversation
|
|
1138
|
+
* rejected by the provider rather than merely confused.
|
|
1139
|
+
*/
|
|
1140
|
+
/**
|
|
1141
|
+
* ── ⭐⭐ THE WRITE REVIEW SEAM ───────────────────────────────────────────────
|
|
1142
|
+
*
|
|
1143
|
+
* Returns a refusal RESULT when the person said no, and `null` when the write
|
|
1144
|
+
* should proceed. `null` is the do-nothing answer so that every caller without
|
|
1145
|
+
* an approver — which is every existing caller, the MCP server, the acceptance
|
|
1146
|
+
* harness and every test — is byte-identical.
|
|
1147
|
+
*
|
|
1148
|
+
* ⚠️ IT READS `before` THROUGH THE EXECUTOR, never through `fs`. The in-memory
|
|
1149
|
+
* executor that lets the browser builder run this loop has no filesystem, and a
|
|
1150
|
+
* direct read here would make the gate silently wrong in exactly the place the
|
|
1151
|
+
* registry exists to serve.
|
|
1152
|
+
*/
|
|
1153
|
+
async function gateWrite(approveWrite, executor, path, after, extra = {}) {
|
|
1154
|
+
if (typeof approveWrite !== 'function') return null;
|
|
1155
|
+
let before = null;
|
|
1156
|
+
let exists = false;
|
|
1157
|
+
try {
|
|
1158
|
+
const read = executor.readFile(path);
|
|
1159
|
+
if (read && read.ok !== false && typeof read.content === 'string') {
|
|
1160
|
+
before = read.content;
|
|
1161
|
+
exists = true;
|
|
1162
|
+
}
|
|
1163
|
+
} catch { /* unreadable is "new file" for review purposes, never a hard failure */ }
|
|
1164
|
+
|
|
1165
|
+
const decision = await approveWrite({ path, before, after, exists, ...extra });
|
|
1166
|
+
if (decision?.allowed === false) return refusedWriteResult(path);
|
|
1167
|
+
return null;
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
export async function executeToolCall(call, executor, {
|
|
1171
|
+
commandTimeoutMs,
|
|
1172
|
+
/**
|
|
1173
|
+
* ⭐ The write reviewer, built per run by `createWriteApprover`. Optional and
|
|
1174
|
+
* defaulted to null, so nothing changes for a caller that does not pass one.
|
|
1175
|
+
*/
|
|
1176
|
+
approveWrite = null,
|
|
1177
|
+
/** The bulk counterpart — one question for a whole `write_files` batch. */
|
|
1178
|
+
approveBatch = null,
|
|
1179
|
+
round = null,
|
|
1180
|
+
allowRun = true,
|
|
1181
|
+
/**
|
|
1182
|
+
* ⚠️ `delegate` IS THE FIRST TOOL THAT NEEDS TO CALL A MODEL ITSELF, so the
|
|
1183
|
+
* dispatcher needs the credentials the turn loop already holds. Optional and
|
|
1184
|
+
* defaulted, so every existing call site is byte-identical in behaviour — the
|
|
1185
|
+
* tool simply refuses when nobody passed it, which is the honest failure.
|
|
1186
|
+
*
|
|
1187
|
+
* `depth` is how a subagent knows it is one: the turn loop passes 0, and a
|
|
1188
|
+
* subagent's own dispatcher is handed 1, which `runSubagent` refuses.
|
|
1189
|
+
*/
|
|
1190
|
+
config = null,
|
|
1191
|
+
depth = 0,
|
|
1192
|
+
/**
|
|
1193
|
+
* ⚠️ DEFAULTS TO FALSE, so every existing call site keeps the locked
|
|
1194
|
+
* allowlist unchanged. A capability this large must be reachable only by a
|
|
1195
|
+
* caller that NAMES it — never by one that merely forgot to pass a flag.
|
|
1196
|
+
*/
|
|
1197
|
+
shell = false,
|
|
1198
|
+
/**
|
|
1199
|
+
* ⚠️ THE BUDGETED ASKER, OR NULL. The per-run allowance lives in
|
|
1200
|
+
* `ask-user.mjs`'s closure and is created ONCE by the turn loop, because this
|
|
1201
|
+
* dispatcher is a pure switch over a single call and has no memory of the
|
|
1202
|
+
* round before it. Threading a counter through every tool in this file for
|
|
1203
|
+
* the sake of one would be the wrong trade; the turn loop already owns
|
|
1204
|
+
* per-run state.
|
|
1205
|
+
*/
|
|
1206
|
+
ask = null,
|
|
1207
|
+
/**
|
|
1208
|
+
* ⚠️ THE PARENT'S BUDGET, so `delegate` cannot spend outside it. The helper's
|
|
1209
|
+
* ceiling is whatever the parent has LEFT, and its cost is charged back the
|
|
1210
|
+
* moment it returns — otherwise the run's stated ceiling is a claim about the
|
|
1211
|
+
* parent only, while the model can spawn unbounded helpers at will.
|
|
1212
|
+
*/
|
|
1213
|
+
budget = null,
|
|
1214
|
+
/**
|
|
1215
|
+
* ⚠️ INJECTABLE, like every other outward call in this package. Without it the
|
|
1216
|
+
* only way to test the delegate path is to spend real money on a real model,
|
|
1217
|
+
* which means in practice it is not tested — and the two things being pinned
|
|
1218
|
+
* here (the helper gets a ceiling, and its spend is charged back) are about
|
|
1219
|
+
* MONEY, which is the last place to accept "we checked it once by hand".
|
|
1220
|
+
*/
|
|
1221
|
+
subagentImpl = null,
|
|
1222
|
+
} = {}) {
|
|
1223
|
+
const name = call?.function?.name;
|
|
1224
|
+
const id = call?.id ?? null;
|
|
1225
|
+
const parsed = parseToolArguments(call?.function?.arguments);
|
|
1226
|
+
if (!parsed.ok) return { id, name, args: {}, result: { ok: false, error: parsed.error }, mutated: false };
|
|
1227
|
+
const args = parsed.args;
|
|
1228
|
+
|
|
1229
|
+
switch (name) {
|
|
1230
|
+
case 'read_file': {
|
|
1231
|
+
/**
|
|
1232
|
+
* ── ⚠️⚠️ THE FOURTH PATH TO THIS FILE, AND THE ONLY UNHARDENED ONE ────
|
|
1233
|
+
*
|
|
1234
|
+
* `turn.mjs`'s automatic pre-load, `search.mjs`, `repo-map.mjs` and
|
|
1235
|
+
* `session.mjs` all refuse credential files using this same shared list.
|
|
1236
|
+
* The model-driven read used none of it. Probed 2026-08-13: `.env`,
|
|
1237
|
+
* `.env.local`, `id_rsa`, `server.pem`, `.npmrc`, `credentials.yml` and
|
|
1238
|
+
* `secrets.json` all came back in full, while `search_text` for the same
|
|
1239
|
+
* canary correctly matched only the source file — so search had been
|
|
1240
|
+
* fixed and read had not.
|
|
1241
|
+
*
|
|
1242
|
+
* ⚠️ A TOOL RESULT GOES STRAIGHT INTO THE PROMPT, which goes to a
|
|
1243
|
+
* third-party provider. `turn.mjs:112` calls this class "THE WORST BUG
|
|
1244
|
+
* THIS PACKAGE HAS HAD" — and fixed it in exactly one of the four places
|
|
1245
|
+
* it lives.
|
|
1246
|
+
*
|
|
1247
|
+
* ⭐ THE GUARD IS HERE, ON THE MODEL'S DOOR, NOT IN THE EXECUTOR. Learned
|
|
1248
|
+
* an hour earlier on the `.acuvo/` guard: a model-facing refusal pushed
|
|
1249
|
+
* down into shared plumbing broke seven tests of legitimate internal work.
|
|
1250
|
+
* `--doctor` still needs to see whether a `.env` exists.
|
|
1251
|
+
*/
|
|
1252
|
+
const credential = refusedCommitPath(String(args.path ?? ''));
|
|
1253
|
+
if (credential) {
|
|
1254
|
+
return {
|
|
1255
|
+
id,
|
|
1256
|
+
name,
|
|
1257
|
+
args,
|
|
1258
|
+
result: {
|
|
1259
|
+
ok: false,
|
|
1260
|
+
error: `${args.path} looks like a credential file, so it is not read into the conversation — `
|
|
1261
|
+
+ 'anything returned here becomes part of the prompt sent to the model provider. If you need a '
|
|
1262
|
+
+ 'value from it, ask the owner to paste just that value, or tell them which key you need and why.',
|
|
1263
|
+
},
|
|
1264
|
+
mutated: false,
|
|
1265
|
+
};
|
|
1266
|
+
}
|
|
1267
|
+
return { id, name, args, result: executor.readFile(args.path), mutated: false };
|
|
1268
|
+
}
|
|
1269
|
+
case 'write_file': {
|
|
1270
|
+
const gate = await gateWrite(approveWrite, executor, args.path, args.content);
|
|
1271
|
+
if (gate) return { id, name, args, result: gate, mutated: false };
|
|
1272
|
+
const result = executor.writeFile(args.path, args.content);
|
|
1273
|
+
return { id, name, args, result, mutated: result.ok === true };
|
|
1274
|
+
}
|
|
1275
|
+
/**
|
|
1276
|
+
* ⭐ THE BULK EDIT, MADE GOVERNED. The model already does bulk edits — it
|
|
1277
|
+
* writes a loop inside `evaluate`, which is the right instinct and is
|
|
1278
|
+
* invisible to leases, to the change count and to collision detection. This
|
|
1279
|
+
* is the same operation through `executor.writeFile`, so every guard
|
|
1280
|
+
* applies. See lib/write-many.mjs.
|
|
1281
|
+
*
|
|
1282
|
+
* ⚠️ `mutated` is true when ANY file landed. A call that wrote 44 of 45 did
|
|
1283
|
+
* real work, and reporting it as untouched would put the summary back where
|
|
1284
|
+
* `evaluate` had it.
|
|
1285
|
+
*/
|
|
1286
|
+
case 'write_files': {
|
|
1287
|
+
/**
|
|
1288
|
+
* ⚠️ THE BULK CASE IS GATED ONCE, ON THE WHOLE BATCH, and that is a
|
|
1289
|
+
* deliberate choice rather than a shortcut. Asking per file turns one
|
|
1290
|
+
* intent — "apply this refactor" — into forty prompts, and a prompt
|
|
1291
|
+
* answered forty times is answered without reading by the third. The
|
|
1292
|
+
* batch carries its file list so the person sees the scope.
|
|
1293
|
+
*/
|
|
1294
|
+
const batch = (args?.files ?? []).filter((f) => f && typeof f.path === 'string');
|
|
1295
|
+
if (typeof approveBatch === 'function' && batch.length > 0) {
|
|
1296
|
+
// ⚠️ `before` read through the EXECUTOR, so the in-memory backend the
|
|
1297
|
+
// browser builder uses answers the same as the filesystem one.
|
|
1298
|
+
const writes = batch.map((f) => {
|
|
1299
|
+
let before = null; let exists = false;
|
|
1300
|
+
try {
|
|
1301
|
+
const r = executor.readFile(f.path);
|
|
1302
|
+
if (r && r.ok !== false && typeof r.content === 'string') { before = r.content; exists = true; }
|
|
1303
|
+
} catch { /* unreadable is "new file" for review purposes */ }
|
|
1304
|
+
return { path: f.path, before, after: f.content, exists };
|
|
1305
|
+
});
|
|
1306
|
+
const verdict = await approveBatch(writes);
|
|
1307
|
+
if (verdict?.allowed === false) {
|
|
1308
|
+
return { id, name, args, result: refusedWriteResult(`${batch.length} files`), mutated: false };
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
const result = writeMany(executor, args);
|
|
1312
|
+
return {
|
|
1313
|
+
id, name, args, result,
|
|
1314
|
+
mutated: (result.written?.length ?? 0) > 0,
|
|
1315
|
+
mutatedPath: result.written?.length === 1 ? result.written[0].path : undefined,
|
|
1316
|
+
};
|
|
1317
|
+
}
|
|
1318
|
+
/**
|
|
1319
|
+
* ── ⭐⭐⭐ THE CHEAP EDIT. Same door, same gates, a tenth of the output. ───
|
|
1320
|
+
*
|
|
1321
|
+
* ⚠️ IT WRITES THROUGH `executor.writeFile` AND `executor.deleteFile` AND
|
|
1322
|
+
* NOWHERE ELSE, which is what buys — for free and without a line of code
|
|
1323
|
+
* here — the file leases, the `.acuvo/` leash, the `node_modules`/`.git`
|
|
1324
|
+
* refusals, `--dry-run`, and the `--plan` read-only executor that replaces
|
|
1325
|
+
* exactly those two methods with refusals. A bulk verb with its own path to
|
|
1326
|
+
* disk would have defeated all five at once.
|
|
1327
|
+
*
|
|
1328
|
+
* ⚠️ AND IT IS GATED BEFORE ANY OF THEM RUN. `planPatch` computes the whole
|
|
1329
|
+
* changeset in memory first, so the approval question can carry every path —
|
|
1330
|
+
* a person asked "apply this refactor?" must be shown the scope, not asked
|
|
1331
|
+
* once per file until they stop reading.
|
|
1332
|
+
*/
|
|
1333
|
+
case 'apply_patch': {
|
|
1334
|
+
const plan = planPatch(executor, args.patch);
|
|
1335
|
+
if (!plan.ok) return { id, name, args, result: { ok: false, error: plan.error }, mutated: false };
|
|
1336
|
+
|
|
1337
|
+
/**
|
|
1338
|
+
* ⚠️ THE SAME BATCH GATE `write_files` USES, and deliberately the same
|
|
1339
|
+
* one rather than a second: `approveMany` decides risk across the whole
|
|
1340
|
+
* list and a patch is by construction one intent. `planPatch` has already
|
|
1341
|
+
* read every `before` THROUGH THE EXECUTOR, so the in-memory backend
|
|
1342
|
+
* answers this identically to the filesystem one.
|
|
1343
|
+
*/
|
|
1344
|
+
if (typeof approveBatch === 'function' && plan.batch.length > 0) {
|
|
1345
|
+
const verdict = await approveBatch(plan.batch);
|
|
1346
|
+
if (verdict?.allowed === false) {
|
|
1347
|
+
return {
|
|
1348
|
+
id, name, args, mutated: false,
|
|
1349
|
+
result: refusedWriteResult(`${plan.batch.length} file${plan.batch.length === 1 ? '' : 's'} in one patch`),
|
|
1350
|
+
};
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
const result = commitPatch(executor, plan);
|
|
1355
|
+
/**
|
|
1356
|
+
* ⚠️ `written[]` IS `write_files`' SHAPE ON PURPOSE. `changed-paths.mjs`
|
|
1357
|
+
* reads it, and through it so do report.mjs, parallel.mjs, best-of.mjs and
|
|
1358
|
+
* handoff.mjs — a new `applied[]` field would have needed an arm in every
|
|
1359
|
+
* one of them, which is the three-way disagreement that file was written
|
|
1360
|
+
* to end. And on a rollback that could not fully restore, `written[]`
|
|
1361
|
+
* carries the still-modified paths, so the summary owns them.
|
|
1362
|
+
*/
|
|
1363
|
+
return {
|
|
1364
|
+
id, name, args, result,
|
|
1365
|
+
mutated: (result.written?.length ?? 0) > 0,
|
|
1366
|
+
mutatedPath: result.written?.length === 1 ? result.written[0].path : undefined,
|
|
1367
|
+
};
|
|
1368
|
+
}
|
|
1369
|
+
case 'list_dir':
|
|
1370
|
+
return { id, name, args, result: executor.listDir(args.path ?? '.'), mutated: false };
|
|
1371
|
+
case 'edit_file': {
|
|
1372
|
+
/**
|
|
1373
|
+
* ⚠️⚠️ THE COMMENT HERE WAS RIGHT AND THE CODE WAS NOT (fixed 2026-08-19).
|
|
1374
|
+
* It said "gated on the RESULTING content, which `editThroughExecutor`
|
|
1375
|
+
* computes" — and then passed `null`, because `editThroughExecutor` is not
|
|
1376
|
+
* called until the line below the gate.
|
|
1377
|
+
*
|
|
1378
|
+
* `approvalDecision` derives `existsAfter = after !== null && after !==
|
|
1379
|
+
* undefined`, so a null `after` means **every edit was classified as the
|
|
1380
|
+
* file being DELETED**: `high` risk, and a prompt reading "app.js is being
|
|
1381
|
+
* DELETED" for a one-line change. Under `ACUVO_APPROVE=always` — the
|
|
1382
|
+
* documented unattended mode — high-risk writes are hard-blocked, so
|
|
1383
|
+
* `edit_file` could never land at all.
|
|
1384
|
+
*
|
|
1385
|
+
* ⭐ `applyEdit` is PURE, so the resulting content is computable before
|
|
1386
|
+
* anything is written. Now the person sees the real change and the risk
|
|
1387
|
+
* rules measure the real file, which is what the comment promised.
|
|
1388
|
+
*
|
|
1389
|
+
* ⚠️ A failed preview gates on `null` deliberately: `editThroughExecutor`
|
|
1390
|
+
* is about to return that same failure, and asking someone to approve a
|
|
1391
|
+
* write that cannot happen is noise. Nothing is written either way.
|
|
1392
|
+
*/
|
|
1393
|
+
const beforeRead = executor.readFile(args.path);
|
|
1394
|
+
const preview = beforeRead.ok
|
|
1395
|
+
? applyEdit(beforeRead.content, args.old_string, args.new_string)
|
|
1396
|
+
: null;
|
|
1397
|
+
const editedAfter = preview && preview.ok ? preview.content : null;
|
|
1398
|
+
const gate = await gateWrite(approveWrite, executor, args.path, editedAfter, { kind: 'edited', edit: args });
|
|
1399
|
+
if (gate) return { id, name, args, result: gate, mutated: false };
|
|
1400
|
+
// ⭐ Through the EXECUTOR, not through fs — this is the line that lets the
|
|
1401
|
+
// browser builder run the same loop the CLI does.
|
|
1402
|
+
const result = editThroughExecutor(executor, args.path, args.old_string, args.new_string);
|
|
1403
|
+
return { id, name, args, result, mutated: result.ok === true };
|
|
1404
|
+
}
|
|
1405
|
+
case 'delete_file': {
|
|
1406
|
+
const result = executor.deleteFile(args.path);
|
|
1407
|
+
return { id, name, args, result, mutated: result.ok === true };
|
|
1408
|
+
}
|
|
1409
|
+
case 'move_file': {
|
|
1410
|
+
/**
|
|
1411
|
+
* ⚠️ AN EXECUTOR WITHOUT `moveFile` MUST SAY SO, NOT CRASH. The browser
|
|
1412
|
+
* builder implements this dispatcher's verbs over a Map, and it gained
|
|
1413
|
+
* `deleteFile` only because someone remembered. A `TypeError: not a
|
|
1414
|
+
* function` mid-round costs the round and tells the model nothing it can
|
|
1415
|
+
* act on; a sentence tells it to use write_file + delete_file instead.
|
|
1416
|
+
*/
|
|
1417
|
+
if (typeof executor.moveFile !== 'function') {
|
|
1418
|
+
return {
|
|
1419
|
+
id, name, args, mutated: false,
|
|
1420
|
+
result: { ok: false, error: 'this executor cannot move files. Use write_file to create the new path and delete_file to remove the old one.' },
|
|
1421
|
+
};
|
|
1422
|
+
}
|
|
1423
|
+
const result = executor.moveFile(args.from, args.to, { overwrite: args.overwrite === true });
|
|
1424
|
+
return { id, name, args, result, mutated: result.ok === true };
|
|
1425
|
+
}
|
|
1426
|
+
case 'find_files':
|
|
1427
|
+
return { id, name, args, result: findFiles(executor.root, args.pattern, { offset: args.offset }), mutated: false };
|
|
1428
|
+
case 'search_text':
|
|
1429
|
+
return { id, name, args, result: searchText(executor.root, args.pattern, { glob: args.glob, offset: args.offset }), mutated: false };
|
|
1430
|
+
case 'see_page': {
|
|
1431
|
+
const result = await designPass(executor.root, args.path, { dryRun: executor.dryRun });
|
|
1432
|
+
/**
|
|
1433
|
+
* ⚠️ mutated: it writes a screenshot into .acuvo/ — the summary must own up
|
|
1434
|
+
* to every file that appears on disk, including ones the user did not ask for.
|
|
1435
|
+
*
|
|
1436
|
+
* ⚠️⚠️ BUT `result.path` IS THE PAGE IT READ, NOT THE FILE IT WROTE, and
|
|
1437
|
+
* every consumer of a mutating record reads `result.path`. Observed live:
|
|
1438
|
+
* looking at `index.html` printed `replaced index.html (0 bytes)` — a
|
|
1439
|
+
* report that the agent had BLANKED the user's file, when all it did was
|
|
1440
|
+
* take a photograph of it. `parallel.mjs` reads the same field, so two
|
|
1441
|
+
* tasks that merely looked at one page would be reported as colliding
|
|
1442
|
+
* over it.
|
|
1443
|
+
*
|
|
1444
|
+
* ⭐ So the written path is stated explicitly. This is the same lesson
|
|
1445
|
+
* `delete_file` taught: a new tool whose result shape differs from
|
|
1446
|
+
* `write_file`'s breaks every downstream reader that assumed one shape.
|
|
1447
|
+
*/
|
|
1448
|
+
return {
|
|
1449
|
+
id, name, args, result,
|
|
1450
|
+
mutated: result.ok === true && Boolean(result.screenshot),
|
|
1451
|
+
mutatedPath: result.screenshot ?? null,
|
|
1452
|
+
};
|
|
1453
|
+
}
|
|
1454
|
+
case 'speak': {
|
|
1455
|
+
const result = await speak(executor.root, args.text, args.path, { dryRun: executor.dryRun, engine: args.engine ?? null });
|
|
1456
|
+
return { id, name, args, result, mutated: result.ok === true };
|
|
1457
|
+
}
|
|
1458
|
+
/**
|
|
1459
|
+
* ── ⭐⭐ THE PRICE QUESTION, ASKED BEFORE THE MONEY MOVES ────────────────
|
|
1460
|
+
*
|
|
1461
|
+
* ⚠️ `mutated: false` — it writes nothing into the workspace. It does
|
|
1462
|
+
* refresh a cache under HOME, which is deliberately NOT counted: the
|
|
1463
|
+
* "N files written" line is about the user's tree, and a credential-adjacent
|
|
1464
|
+
* cache file appearing in it would be noise in the one honest number in the
|
|
1465
|
+
* summary. (Same reasoning `evaluate` uses for its temp file.)
|
|
1466
|
+
*
|
|
1467
|
+
* ⭐ IT IS THE ONE CREATIVE VERB ALLOWED TO GO TO THE NETWORK FOR PRICES.
|
|
1468
|
+
* `generate_image` and `speak` read the cache and never ask, so a render
|
|
1469
|
+
* never pays for a round trip — the question is asked by the verb that
|
|
1470
|
+
* exists to answer it.
|
|
1471
|
+
*/
|
|
1472
|
+
case 'list_engines': {
|
|
1473
|
+
const result = await listEngines({ medium: args.medium ?? 'all' });
|
|
1474
|
+
return { id, name, args, result, mutated: false };
|
|
1475
|
+
}
|
|
1476
|
+
case 'transcribe': {
|
|
1477
|
+
const result = await transcribe(executor.root, args.path, { dryRun: executor.dryRun });
|
|
1478
|
+
return { id, name, args, result, mutated: false };
|
|
1479
|
+
}
|
|
1480
|
+
case 'make_document': {
|
|
1481
|
+
const result = await makeDocument(executor.root, args.path, args.out, args.format, { dryRun: executor.dryRun });
|
|
1482
|
+
return { id, name, args, result, mutated: result.ok === true };
|
|
1483
|
+
}
|
|
1484
|
+
/**
|
|
1485
|
+
* ⚠️ BOTH READERS ARE `mutated: false`. They write nothing — and the "N files
|
|
1486
|
+
* written" line is the one honest number in the summary, so a read that
|
|
1487
|
+
* inflates it turns the summary into an estimate. Same rule `evaluate`
|
|
1488
|
+
* already follows for its temp file.
|
|
1489
|
+
*/
|
|
1490
|
+
case 'read_document': {
|
|
1491
|
+
const result = await readDocument(executor.root, args.path, {
|
|
1492
|
+
ocr: args.ocr, fromPage: args.from_page, maxPages: args.max_pages,
|
|
1493
|
+
});
|
|
1494
|
+
return { id, name, args, result, mutated: false };
|
|
1495
|
+
}
|
|
1496
|
+
case 'read_table': {
|
|
1497
|
+
const result = await readTable(executor.root, args.path, { page: args.page });
|
|
1498
|
+
return { id, name, args, result, mutated: false };
|
|
1499
|
+
}
|
|
1500
|
+
/**
|
|
1501
|
+
* ⚠️ `mutated: true`, and `mutatedPath` is the NEW file. Both of these write
|
|
1502
|
+
* an image the run must account for — and neither touches its source, so
|
|
1503
|
+
* reporting `args.path` here would say the original changed when it did not.
|
|
1504
|
+
* That is the exact bug `see_page` shipped: "replaced index.html (0 bytes)"
|
|
1505
|
+
* for a tool that only took a photograph of it.
|
|
1506
|
+
*/
|
|
1507
|
+
case 'edit_image': {
|
|
1508
|
+
const result = await editImage(executor.root, args.path, args.target, args.replacement, {
|
|
1509
|
+
dryRun: executor.dryRun, out: args.out,
|
|
1510
|
+
});
|
|
1511
|
+
return { id, name, args, result, mutated: result.ok === true, mutatedPath: result.path ?? null };
|
|
1512
|
+
}
|
|
1513
|
+
case 'expand_image': {
|
|
1514
|
+
const result = await expandImage(executor.root, args.path, args.aspect, {
|
|
1515
|
+
dryRun: executor.dryRun, out: args.out, prompt: args.prompt,
|
|
1516
|
+
});
|
|
1517
|
+
return { id, name, args, result, mutated: result.ok === true, mutatedPath: result.path ?? null };
|
|
1518
|
+
}
|
|
1519
|
+
case 'generate_image': {
|
|
1520
|
+
/**
|
|
1521
|
+
* ⚠️ `mutated: true` — this DOES write a file into the workspace, unlike
|
|
1522
|
+
* run_command. The summary's "N files written" must count it, or a user
|
|
1523
|
+
* gets an image on disk that the run never mentioned.
|
|
1524
|
+
*/
|
|
1525
|
+
/**
|
|
1526
|
+
* ⚠️ `engine` IS PASSED THROUGH RATHER THAN DEFAULTED HERE. The default
|
|
1527
|
+
* belongs in one place (`checkEngine`), and it is the CORE engine — a
|
|
1528
|
+
* dispatcher that picked an engine would be the software choosing to
|
|
1529
|
+
* spend somebody's credits, which is the exact thing the rule forbids.
|
|
1530
|
+
*/
|
|
1531
|
+
const result = await generateImage({
|
|
1532
|
+
prompt: args.prompt, width: args.width, height: args.height, engine: args.engine ?? null, executor,
|
|
1533
|
+
});
|
|
1534
|
+
return { id, name, args, result, mutated: result.ok === true };
|
|
1535
|
+
}
|
|
1536
|
+
case 'evaluate': {
|
|
1537
|
+
const result = await evaluateSnippet({ executor, source: args.source, timeoutMs: commandTimeoutMs });
|
|
1538
|
+
/**
|
|
1539
|
+
* ⚠️ `mutated: false`. It writes a temp file and deletes it again, so no
|
|
1540
|
+
* file the user cares about changed — counting it would put a phantom
|
|
1541
|
+
* entry in the "files written" line, which is the one honest number in
|
|
1542
|
+
* the summary.
|
|
1543
|
+
*/
|
|
1544
|
+
return { id, name, args, result, mutated: false };
|
|
1545
|
+
}
|
|
1546
|
+
case 'run_command': {
|
|
1547
|
+
/**
|
|
1548
|
+
* ── ⚠️⚠️ `--no-run` WAS ENFORCED AT THE OFFER AND NOT HERE ─────────────
|
|
1549
|
+
*
|
|
1550
|
+
* `run_program`, ONE CASE BELOW, already checks `allowRun` at the
|
|
1551
|
+
* dispatcher, and its comment spells out exactly why: "a model can emit a
|
|
1552
|
+
* call for a tool it was never shown (a resumed session, a stale
|
|
1553
|
+
* conversation, a provider echoing an old tool list), and the flag has to
|
|
1554
|
+
* hold at the point the process would actually start."
|
|
1555
|
+
*
|
|
1556
|
+
* ⚠️ EVERY WORD OF THAT APPLIES TO `run_command`, WHICH IS THE ONE THE
|
|
1557
|
+
* MODEL REACHES FOR CONSTANTLY, and it was the one without the check.
|
|
1558
|
+
* Reproduced: `executeToolCall({name:'run_command', command:'npm install
|
|
1559
|
+
* evil-package'}, executor, {allowRun:false})` returned `{ok:true,
|
|
1560
|
+
* exitCode:0}` and the executor really ran it.
|
|
1561
|
+
*
|
|
1562
|
+
* ⚠️ AND `executor.runCommand` MAKES IT WORSE, not better. The browser
|
|
1563
|
+
* builder's own runner is reached on the line below without passing
|
|
1564
|
+
* through `executeRunCommand` at all — so whatever gate lives downstream
|
|
1565
|
+
* is not on that path. A flag whose enforcement depends on which executor
|
|
1566
|
+
* is installed is not a flag.
|
|
1567
|
+
*
|
|
1568
|
+
* ⭐ The sentence is `run_program`'s, changed only from "program" to
|
|
1569
|
+
* "command": one flag must not grow two explanations.
|
|
1570
|
+
*/
|
|
1571
|
+
if (allowRun === false) {
|
|
1572
|
+
return {
|
|
1573
|
+
id, name, args, mutated: false,
|
|
1574
|
+
result: { ok: false, error: 'this run was started with --no-run, so no command is executed. Report what you changed and say plainly that nothing was verified.' },
|
|
1575
|
+
};
|
|
1576
|
+
}
|
|
1577
|
+
/**
|
|
1578
|
+
* ⭐ AN EXECUTOR MAY OWN ITS OWN RUNNER. The CLI does not — it uses the
|
|
1579
|
+
* allowlisted local spawner below, which is the thing `command.mjs`
|
|
1580
|
+
* exists to keep safe. The browser builder DOES: its files never touch a
|
|
1581
|
+
* server disk, so "run" means shipping the map to the Modal sandbox.
|
|
1582
|
+
* Same tool, same loop, two very different executions.
|
|
1583
|
+
*/
|
|
1584
|
+
const result = typeof executor.runCommand === 'function'
|
|
1585
|
+
? await executor.runCommand(args.command)
|
|
1586
|
+
: await executeRunCommand({ command: args.command, executor, timeoutMs: commandTimeoutMs, shell });
|
|
1587
|
+
/**
|
|
1588
|
+
* ⚠️ `mutated: false` EVEN THOUGH A COMMAND CAN WRITE FILES. `mutated`
|
|
1589
|
+
* feeds the "N files written" line, and that line names paths the
|
|
1590
|
+
* EXECUTOR wrote — a build's output is real but unattributable, and
|
|
1591
|
+
* inventing a count for it would make the one honest number in the
|
|
1592
|
+
* summary an estimate. What the command did is reported separately, in
|
|
1593
|
+
* full, as its own output.
|
|
1594
|
+
*/
|
|
1595
|
+
return { id, name, args, result, mutated: false };
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
/**
|
|
1599
|
+
* ── ⭐⭐ `run_program` — THE SAME SPAWN, WITH A REAL ARGV ─────────────────
|
|
1600
|
+
*
|
|
1601
|
+
* Three guards before anything is spawned, and each one exists because the
|
|
1602
|
+
* module cannot check it itself: `runProgram` takes a `root` string, not an
|
|
1603
|
+
* executor, so `dryRun`, the memory sentinel and `allowRun` are facts only
|
|
1604
|
+
* this dispatcher holds.
|
|
1605
|
+
*
|
|
1606
|
+
* ⚠️ THE DRY-RUN SENTENCE IS `executeRunCommand`'s, deliberately reworded
|
|
1607
|
+
* only where it must be. `--dry-run` promises the disk is untouched and a
|
|
1608
|
+
* program is free to write to it; the promise can only be kept by refusing
|
|
1609
|
+
* here, and refusing with a DIFFERENT explanation for the same flag would
|
|
1610
|
+
* teach the model that the two runners have two policies.
|
|
1611
|
+
*
|
|
1612
|
+
* ⚠️ `allowRun` IS ENFORCED AT THE DISPATCHER as well as at the offer — the
|
|
1613
|
+
* `check_acceptance` argument, one tool over: a model can emit a call for a
|
|
1614
|
+
* tool it was never shown (a resumed session, a stale conversation, a
|
|
1615
|
+
* provider echoing an old tool list), and the flag has to hold at the point
|
|
1616
|
+
* the process would actually start.
|
|
1617
|
+
*
|
|
1618
|
+
* ⚠️ THE MEMORY EXECUTOR IS REFUSED RATHER THAN ROUTED TO ITS OWN RUNNER.
|
|
1619
|
+
* `run_command` hands a STRING to `executor.runCommand`, and that is the
|
|
1620
|
+
* whole contract the browser builder's Modal sandbox implements — there is
|
|
1621
|
+
* no argv-shaped entry point on the other side. Silently joining the array
|
|
1622
|
+
* back into a string here would re-introduce the exact quoting ambiguity
|
|
1623
|
+
* this tool exists to remove, and it would do it invisibly. So the refusal
|
|
1624
|
+
* names the alternative, like every other memory guard in this file.
|
|
1625
|
+
*/
|
|
1626
|
+
case 'run_program': {
|
|
1627
|
+
if (allowRun === false) {
|
|
1628
|
+
return {
|
|
1629
|
+
id, name, args, mutated: false,
|
|
1630
|
+
result: { ok: false, error: 'this run was started with --no-run, so no program is executed. Report what you changed and say plainly that nothing was verified.' },
|
|
1631
|
+
};
|
|
1632
|
+
}
|
|
1633
|
+
if (executor.dryRun) {
|
|
1634
|
+
return {
|
|
1635
|
+
id, name, args, mutated: false,
|
|
1636
|
+
result: { ok: false, error: 'this is a --dry-run, so no program is executed (a program could write to disk, which a dry run promises not to do)' },
|
|
1637
|
+
};
|
|
1638
|
+
}
|
|
1639
|
+
if (executor.root === MEMORY_ROOT) {
|
|
1640
|
+
return {
|
|
1641
|
+
id, name, args, mutated: false,
|
|
1642
|
+
result: { ok: false, error: 'this workspace is held in memory rather than on disk, so run_program is unavailable here — use run_command, which this executor runs in its own sandbox.' },
|
|
1643
|
+
};
|
|
1644
|
+
}
|
|
1645
|
+
const result = await runProgram({
|
|
1646
|
+
root: executor.root, program: args.program, args: args.args, timeoutMs: args.timeoutMs ?? commandTimeoutMs,
|
|
1647
|
+
});
|
|
1648
|
+
/**
|
|
1649
|
+
* ⚠️ `mutated: false`, the `run_command` precedent exactly: a program can
|
|
1650
|
+
* write files, and those writes are real but unattributable. `mutated`
|
|
1651
|
+
* feeds the "N files written" line, which names paths the EXECUTOR wrote,
|
|
1652
|
+
* and inventing a count for a build's output would make the one honest
|
|
1653
|
+
* number in the summary an estimate.
|
|
1654
|
+
*/
|
|
1655
|
+
return { id, name, args, result, mutated: false };
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
/**
|
|
1659
|
+
* ── ⭐ THE PLAN LEDGER ────────────────────────────────────────────────────
|
|
1660
|
+
*
|
|
1661
|
+
* ⚠️ `mutated: false`, AND THIS IS THE `evaluate` PRECEDENT RATHER THAN THE
|
|
1662
|
+
* `see_page` ONE. `mutated` feeds the "N files written" line — the one
|
|
1663
|
+
* honest number in the summary — and that line names files the USER cares
|
|
1664
|
+
* about. `.acuvo/plan.json` is the agent's own bookkeeping; counting it
|
|
1665
|
+
* would put a phantom entry in every multi-step run's report.
|
|
1666
|
+
*
|
|
1667
|
+
* ⭐ And `parallel.mjs` is the second, sharper reason. It reads mutated
|
|
1668
|
+
* records to detect two tasks colliding over a file. Every parallel task in
|
|
1669
|
+
* one workspace writes the SAME `.acuvo/plan.json`, so counting it would
|
|
1670
|
+
* report a conflict on literally every parallel pair — a guard that fires
|
|
1671
|
+
* always is a guard that gets ignored.
|
|
1672
|
+
*
|
|
1673
|
+
* `round` is forwarded verbatim: plan-ledger decides what to do with a
|
|
1674
|
+
* missing budget, not this dispatcher.
|
|
1675
|
+
*/
|
|
1676
|
+
case 'plan_start':
|
|
1677
|
+
return { id, name, args, result: planStart(executor.root, args, { ...(round ?? {}), planFile: planFileFor(executor.holder) }), mutated: false };
|
|
1678
|
+
case 'plan_step':
|
|
1679
|
+
return { id, name, args, result: planStep(executor.root, args, { ...(round ?? {}), planFile: planFileFor(executor.holder) }), mutated: false };
|
|
1680
|
+
case 'plan_status':
|
|
1681
|
+
// ⚠️ Takes the ROUND options as its second argument, not model arguments —
|
|
1682
|
+
// there is nothing here a model could pass. See planStatus's own note.
|
|
1683
|
+
return { id, name, args, result: planStatus(executor.root, { ...(round ?? {}), planFile: planFileFor(executor.holder) }), mutated: false };
|
|
1684
|
+
|
|
1685
|
+
/**
|
|
1686
|
+
* ── ⭐ SKILLS — the project's own written procedure ───────────────────────
|
|
1687
|
+
* There is no path argument and no way to reach a file that is not a skill;
|
|
1688
|
+
* `loadSkill` resolves the NAME against the discovered catalogue rather than
|
|
1689
|
+
* joining it onto a directory, which is why `../../.ssh/id_rsa` is simply a
|
|
1690
|
+
* name that matches nothing.
|
|
1691
|
+
*/
|
|
1692
|
+
case 'read_skill':
|
|
1693
|
+
return { id, name, args, result: loadAnySkill(executor.root, args.name), mutated: false };
|
|
1694
|
+
|
|
1695
|
+
/**
|
|
1696
|
+
* ── ⭐⭐ DELEGATE — A HELPER WITH ITS OWN HEAD ────────────────────────────
|
|
1697
|
+
*
|
|
1698
|
+
* ⚠️ `mutated: false` is a FACT here, not a convention: a subagent is
|
|
1699
|
+
* offered no verb that can change anything, and `allowRun: false` locks the
|
|
1700
|
+
* dispatcher behind the offer.
|
|
1701
|
+
*
|
|
1702
|
+
* ⚠️ IT REFUSES WITHOUT CREDENTIALS RATHER THAN GUESSING. A caller that did
|
|
1703
|
+
* not thread `config` gets a sentence naming the cause; inventing a model
|
|
1704
|
+
* config here would spend the owner's money on a shape nobody chose.
|
|
1705
|
+
*/
|
|
1706
|
+
case 'delegate': {
|
|
1707
|
+
if (!config?.apiKey) {
|
|
1708
|
+
return {
|
|
1709
|
+
id, name, args, mutated: false,
|
|
1710
|
+
result: { ok: false, error: 'delegation is unavailable in this run — no model credentials reached the dispatcher' },
|
|
1711
|
+
};
|
|
1712
|
+
}
|
|
1713
|
+
/**
|
|
1714
|
+
* ⚠️ `depth` IS THE CALLER'S DEPTH, NOT THE CALLEE'S — and passing
|
|
1715
|
+
* `depth + 1` here made the top-level `delegate` refuse ITSELF with
|
|
1716
|
+
* "a helper cannot delegate again (depth 1)". Every unit test passed,
|
|
1717
|
+
* because all thirteen called `runSubagent` directly and none came
|
|
1718
|
+
* through this dispatcher. ⭐ The real run found it in one command.
|
|
1719
|
+
*/
|
|
1720
|
+
/**
|
|
1721
|
+
* ⭐ THE HELPER INHERITS THE PARENT'S REMAINDER. Not a fraction — a
|
|
1722
|
+
* fraction is an invented constant somebody has to defend, whereas the
|
|
1723
|
+
* remainder makes the arithmetic self-evident: a helper cannot spend
|
|
1724
|
+
* money the run does not have, so the TOTAL stays the number the user
|
|
1725
|
+
* typed. `Infinity` (a `--budget none` run) becomes `null`, i.e.
|
|
1726
|
+
* unbounded, which is exactly the behaviour that run asked for.
|
|
1727
|
+
*/
|
|
1728
|
+
const left = budget?.canContinue?.()?.remainingUsd;
|
|
1729
|
+
const share = Number.isFinite(left) ? left : null;
|
|
1730
|
+
|
|
1731
|
+
const result = await (subagentImpl ? subagentImpl : runSubagent)({
|
|
1732
|
+
task: args.task,
|
|
1733
|
+
/**
|
|
1734
|
+
* ⭐ THE BRIEF. Passed RAW, and folded into the prompt by
|
|
1735
|
+
* `subagent.mjs:briefFor` — one place decides how a helper is briefed,
|
|
1736
|
+
* so the dispatcher cannot grow a second, differently-worded version of
|
|
1737
|
+
* the same paragraph. A helper that receives only a task string spends
|
|
1738
|
+
* its four rounds rediscovering what the parent already knows.
|
|
1739
|
+
*/
|
|
1740
|
+
context: args.context,
|
|
1741
|
+
executor,
|
|
1742
|
+
config,
|
|
1743
|
+
depth,
|
|
1744
|
+
maxRounds: args.maxRounds,
|
|
1745
|
+
commandTimeoutMs,
|
|
1746
|
+
budgetUsd: share,
|
|
1747
|
+
fleetGate: budget?.fleetGate ?? null,
|
|
1748
|
+
/**
|
|
1749
|
+
* ⭐ THE BUILD MODE. `runSubagent` re-checks `=== true` itself; passing
|
|
1750
|
+
* the raw argument through means the string "false" — which a model
|
|
1751
|
+
* emits about once in fifty when a schema says boolean — is decided in
|
|
1752
|
+
* ONE place rather than differently in two.
|
|
1753
|
+
*/
|
|
1754
|
+
write: args.write,
|
|
1755
|
+
/**
|
|
1756
|
+
* ⚠️ AND SO IS `verify`, WHICH DECIDES WHETHER A PROCESS STARTS. Same
|
|
1757
|
+
* rule for the same reason: `runSubagent` requires `=== true` AND
|
|
1758
|
+
* `write === true`, so no reading of a stray string can turn a research
|
|
1759
|
+
* question into a command run.
|
|
1760
|
+
*/
|
|
1761
|
+
verify: args.verify,
|
|
1762
|
+
});
|
|
1763
|
+
|
|
1764
|
+
/**
|
|
1765
|
+
* ⚠️⚠️ CHARGED BACK EVEN WHEN THE HELPER FAILED. `runSubagent` returns
|
|
1766
|
+
* `costUsd` on both paths precisely because a helper that crashed after
|
|
1767
|
+
* three rounds still spent three rounds of money. Recording only the
|
|
1768
|
+
* successes would let a run of failing delegations cost an unbounded
|
|
1769
|
+
* amount while the parent's ledger insisted nothing had happened.
|
|
1770
|
+
*/
|
|
1771
|
+
/**
|
|
1772
|
+
* ── ⚠️⚠️ AND `> 0` THREW AWAY THE FALLBACK THAT WAS BUILT FOR THIS ──────
|
|
1773
|
+
*
|
|
1774
|
+
* MEASURED: `subagent.mjs:213` is `costUsd: Number.isFinite(usage?.cost)
|
|
1775
|
+
* ? usage.cost : 0` — so a provider that reports tokens but no `cost`
|
|
1776
|
+
* yields **0**, the `> 0` guard skipped `record` entirely, and the TOKENS
|
|
1777
|
+
* went in the bin with it. The helper is capped at 6 rounds
|
|
1778
|
+
* (`subagent.mjs:81`) with a 12,000-token reply ceiling
|
|
1779
|
+
* (`model.mjs:138`) — up to ~72k output tokens per call, and the parent
|
|
1780
|
+
* may delegate every round. All of it was free in the governor's book, so
|
|
1781
|
+
* the parent kept spending against a ceiling it had already crossed.
|
|
1782
|
+
*
|
|
1783
|
+
* ⭐ `budget.record` ALREADY KNOWS WHAT TO DO — `budget.mjs:396-404` prices
|
|
1784
|
+
* from tokens when no cost is reported, and from the projection when there
|
|
1785
|
+
* is neither. Those two branches were unreachable from here. So the rule is
|
|
1786
|
+
* now: report what we actually know, and let the one module that owns
|
|
1787
|
+
* pricing do the pricing.
|
|
1788
|
+
*
|
|
1789
|
+
* ⚠️ `costUsd` IS OMITTED, NOT PASSED AS 0, when nothing was reported.
|
|
1790
|
+
* `record` treats any finite `>= 0` cost as REPORTED and stops looking —
|
|
1791
|
+
* passing the zero would re-close the fallback from one line further down.
|
|
1792
|
+
*
|
|
1793
|
+
* ⚠️ AND A HELPER THAT NEVER RAN A ROUND IS CHARGED NOTHING. Charging a
|
|
1794
|
+
* projected round for a crash that happened before the first model call
|
|
1795
|
+
* would be inventing money, which is the opposite failure and just as bad.
|
|
1796
|
+
*/
|
|
1797
|
+
if (budget) {
|
|
1798
|
+
const cost = Number.isFinite(result?.costUsd) ? result.costUsd : 0;
|
|
1799
|
+
const tokens = Number.isFinite(result?.tokens) ? result.tokens : 0;
|
|
1800
|
+
const rounds = Number.isFinite(result?.roundsUsed) ? result.roundsUsed : 0;
|
|
1801
|
+
if (cost > 0) budget.record({ costUsd: cost, tokens });
|
|
1802
|
+
else if (tokens > 0) budget.record({ tokens });
|
|
1803
|
+
else if (rounds > 0) budget.record({});
|
|
1804
|
+
}
|
|
1805
|
+
/**
|
|
1806
|
+
* ── ⚠️⚠️ `mutated` IS NO LONGER ALWAYS FALSE, AND THAT IS THE WHOLE
|
|
1807
|
+
* DIFFERENCE BETWEEN A FEATURE AND A HALF-CONNECTED ONE ─────────────────
|
|
1808
|
+
*
|
|
1809
|
+
* A building helper's files are ON DISK by the time this returns. Every
|
|
1810
|
+
* downstream reader keys off this flag and nothing else:
|
|
1811
|
+
*
|
|
1812
|
+
* · the run summary counts `executed.filter(e => e.mutated)` — a false
|
|
1813
|
+
* here prints "NOTHING WAS WRITTEN" over real edits;
|
|
1814
|
+
* · `parallel.mjs:84` skips any record where it is false, so two
|
|
1815
|
+
* terminals could delegate writes to one file and the collision
|
|
1816
|
+
* report would be empty;
|
|
1817
|
+
* · `best-of.mjs:166` skips it too, so a winning attempt's delegated
|
|
1818
|
+
* files would never be copied out of the attempt directory.
|
|
1819
|
+
*
|
|
1820
|
+
* ⭐ AND THE RESULT REPORTS `written[{path,bytes,previousBytes,created}]`
|
|
1821
|
+
* — `write_files`' EXISTING shape (`write-many.mjs:132`), not a new one.
|
|
1822
|
+
* A first version returned bare strings and a real run printed
|
|
1823
|
+
* `replaced src/calc.test.mjs (0 bytes)` for a file that was CREATED at
|
|
1824
|
+
* 510 bytes, because `report.mjs:describeChange` reads `bytes`/`created`
|
|
1825
|
+
* off the result and found neither. Reusing the shape means every reader
|
|
1826
|
+
* that already understood a bulk write understands this for free, and
|
|
1827
|
+
* `changed-paths.mjs` needs no delegate-specific arm at all.
|
|
1828
|
+
*
|
|
1829
|
+
* `mutatedPath` follows the same convention exactly (`tools.mjs:954` —
|
|
1830
|
+
* set only when there is exactly one file).
|
|
1831
|
+
*/
|
|
1832
|
+
const built = Array.isArray(result?.written) ? result.written : [];
|
|
1833
|
+
return {
|
|
1834
|
+
id,
|
|
1835
|
+
name,
|
|
1836
|
+
args,
|
|
1837
|
+
result,
|
|
1838
|
+
mutated: built.length > 0,
|
|
1839
|
+
mutatedPath: built.length === 1 ? built[0].path : undefined,
|
|
1840
|
+
};
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
/**
|
|
1844
|
+
* ── ⭐ WHAT THIS RUN LEARNED, KEPT FOR THE NEXT ONE ───────────────────────
|
|
1845
|
+
*
|
|
1846
|
+
* ⚠️ `mutated: false` DELIBERATELY, following `plan_start`'s precedent. These
|
|
1847
|
+
* write `.acuvo/memory/*.md`, but `mutated` feeds the "N files written" line,
|
|
1848
|
+
* which names files the USER cares about — and it is what `parallel.mjs`
|
|
1849
|
+
* reads to detect two tasks colliding over a path. Every parallel task in one
|
|
1850
|
+
* workspace writes into the same memory directory, so counting it would
|
|
1851
|
+
* report a conflict on literally every parallel pair. A guard that fires
|
|
1852
|
+
* always is a guard that gets ignored.
|
|
1853
|
+
*
|
|
1854
|
+
* ⚠️ The memory executor has no disk. `learned.mjs` reaches for `fs`
|
|
1855
|
+
* directly, so it is refused there by name rather than half-working.
|
|
1856
|
+
*/
|
|
1857
|
+
case 'remember':
|
|
1858
|
+
case 'forget': {
|
|
1859
|
+
if (executor.root === MEMORY_ROOT) {
|
|
1860
|
+
return {
|
|
1861
|
+
id, name, args, mutated: false,
|
|
1862
|
+
result: { ok: false, error: `${name} needs a real workspace on disk — this run has none` },
|
|
1863
|
+
};
|
|
1864
|
+
}
|
|
1865
|
+
const result = name === 'remember'
|
|
1866
|
+
? remember(executor.root, args)
|
|
1867
|
+
: forget(executor.root, args.name);
|
|
1868
|
+
return { id, name, args, result, mutated: false };
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
/**
|
|
1872
|
+
* ── ⭐ SESSIONS — read-only, and deliberately the only session verb ───────
|
|
1873
|
+
* No resume tool exists and none may be added here: a model that can rewrite
|
|
1874
|
+
* its own message history mid-run replays side effects. Resume is an
|
|
1875
|
+
* operator action taken between runs.
|
|
1876
|
+
*/
|
|
1877
|
+
case 'list_sessions':
|
|
1878
|
+
return { id, name, args, result: listSessions(executor.root, { limit: args.limit }), mutated: false };
|
|
1879
|
+
|
|
1880
|
+
/**
|
|
1881
|
+
* ── ⭐⭐ ASK_USER — THE ONLY TOOL WHOSE RESULT COMES FROM A PERSON ────────
|
|
1882
|
+
*
|
|
1883
|
+
* ⚠️ `mutated: false`. It writes nothing. The flag feeds the "N files
|
|
1884
|
+
* written" line and `parallel.mjs`'s collision detector, and a question is
|
|
1885
|
+
* neither a file nor a conflict.
|
|
1886
|
+
*
|
|
1887
|
+
* ⚠️ THE REFUSAL WHEN `ask` IS MISSING IS A REAL PATH, not defensive
|
|
1888
|
+
* padding. The offer is gated on `interactive` in `toolNamesForRounds`, but
|
|
1889
|
+
* a model can name any tool in the schema list, and a library caller may
|
|
1890
|
+
* dispatch without one. Saying so plainly — rather than throwing — keeps
|
|
1891
|
+
* the run alive and tells the model exactly what to do instead.
|
|
1892
|
+
*/
|
|
1893
|
+
case 'ask_user': {
|
|
1894
|
+
if (typeof ask !== 'function') {
|
|
1895
|
+
return {
|
|
1896
|
+
id,
|
|
1897
|
+
name,
|
|
1898
|
+
args,
|
|
1899
|
+
mutated: false,
|
|
1900
|
+
result: {
|
|
1901
|
+
ok: true,
|
|
1902
|
+
answer: '(nobody is available to ask — this run has no terminal attached). '
|
|
1903
|
+
+ 'Make the most reasonable choice, continue, and state the assumption you took in your final message.',
|
|
1904
|
+
answered: false,
|
|
1905
|
+
},
|
|
1906
|
+
};
|
|
1907
|
+
}
|
|
1908
|
+
return { id, name, args, result: await ask(args.question), mutated: false };
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
/**
|
|
1912
|
+
* ── ⭐ ACCEPTANCE — make the verdict be about the command the USER named ──
|
|
1913
|
+
*
|
|
1914
|
+
* ⚠️ `check_acceptance` IS THE ONE NEW TOOL THAT EXECUTES CODE, so it is
|
|
1915
|
+
* refused when the caller says `allowRun: false`. The offer already withholds
|
|
1916
|
+
* it under `--no-run`; this closes the door a model could still knock on.
|
|
1917
|
+
*
|
|
1918
|
+
* ⚠️ THE RUNNER IS INJECTED, NEVER IMPORTED BY acceptance.mjs — that module
|
|
1919
|
+
* starts no process by itself, which is what keeps ONE audited gate
|
|
1920
|
+
* (`executeRunCommand`: allowlist, no shell, scrubbed env) rather than two.
|
|
1921
|
+
* The executor's own runner wins where it has one, exactly as `run_command`
|
|
1922
|
+
* does above, so the browser builder checks criteria in its sandbox.
|
|
1923
|
+
*
|
|
1924
|
+
* `mutated: false` on both: `declare_acceptance` writes `.acuvo/acceptance.json`
|
|
1925
|
+
* (bookkeeping, same reasoning as the plan ledger) and `check_acceptance`
|
|
1926
|
+
* writes nothing at all.
|
|
1927
|
+
*/
|
|
1928
|
+
case 'declare_acceptance':
|
|
1929
|
+
return { id, name, args, result: declareAcceptance(executor.root, { commands: args.commands }), mutated: false };
|
|
1930
|
+
case 'check_acceptance': {
|
|
1931
|
+
if (allowRun === false) {
|
|
1932
|
+
return {
|
|
1933
|
+
id, name, args, mutated: false,
|
|
1934
|
+
result: { ok: false, error: 'this run was started with --no-run, so acceptance criteria cannot be executed. Report what you changed and say plainly that nothing was verified.' },
|
|
1935
|
+
};
|
|
1936
|
+
}
|
|
1937
|
+
const runner = (command) => (typeof executor.runCommand === 'function'
|
|
1938
|
+
? executor.runCommand(command)
|
|
1939
|
+
: executeRunCommand({ command, executor, timeoutMs: commandTimeoutMs }));
|
|
1940
|
+
const result = await checkAcceptance({ root: executor.root, runner });
|
|
1941
|
+
return { id, name, args, result, mutated: false };
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
/**
|
|
1945
|
+
* ── ⭐ FETCH — a public GET, rendered as text ─────────────────────────────
|
|
1946
|
+
*
|
|
1947
|
+
* ⚠️ THE MODEL'S ARGUMENTS ARE SPREAD IN WHOLE, ON PURPOSE. `fetchText`
|
|
1948
|
+
* refuses unknown keys BY NAME ("fetch_url does not accept \"headers\"") and
|
|
1949
|
+
* that refusal only works if it can see what was passed. Picking out url,
|
|
1950
|
+
* offset and limit here would silently drop a `headers` the model believed
|
|
1951
|
+
* it had sent — the worse of the two failures, and it is documented as such
|
|
1952
|
+
* in fetch-text.mjs.
|
|
1953
|
+
*
|
|
1954
|
+
* `root` is added for the on-disk cache; a memory workspace gets no cache
|
|
1955
|
+
* and the module already handles that.
|
|
1956
|
+
*/
|
|
1957
|
+
case 'fetch_url':
|
|
1958
|
+
return { id, name, args, result: await fetchText({ ...args, root: executor.root }), mutated: false };
|
|
1959
|
+
|
|
1960
|
+
/**
|
|
1961
|
+
* ── ⭐⭐ SEARCH — find the page, then read it ─────────────────────────────
|
|
1962
|
+
*
|
|
1963
|
+
* ⚠️ THE RENDERED TEXT RIDES ALONGSIDE THE STRUCTURE, not instead of it.
|
|
1964
|
+
* `formatResults` is what makes a fallback announce itself ("duckduckgo
|
|
1965
|
+
* failed (served a bot check) — coverage is narrower than usual"), and that
|
|
1966
|
+
* sentence is the whole reason a degraded search does not read like a
|
|
1967
|
+
* confident one. The raw `results` stay on the object for anything that
|
|
1968
|
+
* wants to program against them.
|
|
1969
|
+
*/
|
|
1970
|
+
/**
|
|
1971
|
+
* ── ⭐⭐ EYES ────────────────────────────────────────────────────────────
|
|
1972
|
+
*
|
|
1973
|
+
* ⚠️ THIS DOES NOT ATTACH THE IMAGE TO THE CODER MODEL. The default model
|
|
1974
|
+
* is text-only; handed an image it does not fail loudly, it answers anyway
|
|
1975
|
+
* from the filename and the surrounding conversation. A confident sentence
|
|
1976
|
+
* about a picture nobody looked at is worse than silence, because it ENDS
|
|
1977
|
+
* the investigation. vision.mjs makes its own call to a model that can see.
|
|
1978
|
+
*/
|
|
1979
|
+
/**
|
|
1980
|
+
* ── ⚠️⚠️ AND IT SPENDS MONEY THE GOVERNOR COULD NOT SEE ──────────────────
|
|
1981
|
+
*
|
|
1982
|
+
* `vision.mjs` makes its OWN model call and returns `costUsd`
|
|
1983
|
+
* (`vision.mjs:261`) — and nothing read it. The only bound was a COUNT:
|
|
1984
|
+
* `MAX_LOOKS_PER_PROCESS = 12` (`vision.mjs:47`). A count is not a ceiling.
|
|
1985
|
+
* Vision calls are the expensive per-token kind, twelve looks is a real
|
|
1986
|
+
* number for a design loop, and `--budget` is the one differentiator this
|
|
1987
|
+
* package actually claims — so a run could cross the number the user typed
|
|
1988
|
+
* twelve times over and report having stayed inside it.
|
|
1989
|
+
*
|
|
1990
|
+
* ⭐ Charged back through the same `budget.record` the model rounds use, so
|
|
1991
|
+
* `acuvo spend`, the audit ledger and the projection all see one number.
|
|
1992
|
+
* ⚠️ Only on a LOOK that happened: a refusal (no key, over the cap, unreadable
|
|
1993
|
+
* file) returns `ok: false` and costs nothing, and charging for it would make
|
|
1994
|
+
* the ledger a work of fiction in the cheapest possible direction.
|
|
1995
|
+
*
|
|
1996
|
+
* ⚠️ THE DOLLAR IS EXACT; THE TOKEN COUNT IS NOT RECORDED, and that is stated
|
|
1997
|
+
* rather than papered over. `vision.mjs:253-262` returns `costUsd` and
|
|
1998
|
+
* `approxImageTokens` but never `usage.total_tokens` — and
|
|
1999
|
+
* `approxImageTokens` is an ESTIMATE OF THE IMAGE, not the round's usage, so
|
|
2000
|
+
* feeding it to `budget.record` would corrupt the one honest token total with
|
|
2001
|
+
* a different quantity wearing the same name. The ceiling is expressed in
|
|
2002
|
+
* dollars and the dollars are right; the token counter under-reports a look,
|
|
2003
|
+
* which is a gap in `vision.mjs`'s return shape, not one to fake here.
|
|
2004
|
+
*/
|
|
2005
|
+
case 'read_image': {
|
|
2006
|
+
const result = await readImage({ ...args, root: executor.root });
|
|
2007
|
+
if (budget && result?.ok === true && Number.isFinite(result.costUsd) && result.costUsd > 0) {
|
|
2008
|
+
budget.record({ costUsd: result.costUsd });
|
|
2009
|
+
}
|
|
2010
|
+
return { id, name, args, result, mutated: false };
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
case 'web_search': {
|
|
2014
|
+
const result = await webSearch(args);
|
|
2015
|
+
return {
|
|
2016
|
+
id,
|
|
2017
|
+
name,
|
|
2018
|
+
args,
|
|
2019
|
+
result: result.ok ? { ...result, text: formatResults(result) } : result,
|
|
2020
|
+
mutated: false,
|
|
2021
|
+
};
|
|
2022
|
+
}
|
|
2023
|
+
|
|
2024
|
+
/**
|
|
2025
|
+
* ── ⭐ WINDOWED READS ────────────────────────────────────────────────────
|
|
2026
|
+
*
|
|
2027
|
+
* ⚠️ THE TOOL NAME IS PASSED EXPLICITLY rather than inferred. `readWindow`
|
|
2028
|
+
* can guess from the presence of `pattern`, but the guess exists for direct
|
|
2029
|
+
* callers and tests — a dispatcher that knows which tool was called and
|
|
2030
|
+
* declines to say so is choosing to be wrong occasionally for no gain.
|
|
2031
|
+
*
|
|
2032
|
+
* ⚠️ Refused on a memory workspace with a sentence that says why, exactly as
|
|
2033
|
+
* git is below: `resolveInWorkspace('(memory)', …)` would resolve a real
|
|
2034
|
+
* relative directory named "(memory)" under the process's cwd and fail with
|
|
2035
|
+
* an ENOENT about a path that does not describe anything the model did.
|
|
2036
|
+
*/
|
|
2037
|
+
case 'read_lines':
|
|
2038
|
+
case 'read_around': {
|
|
2039
|
+
if (executor.root === MEMORY_ROOT) {
|
|
2040
|
+
return {
|
|
2041
|
+
id, name, args, mutated: false,
|
|
2042
|
+
result: { ok: false, error: 'this workspace is held in memory rather than on disk, so windowed reads are unavailable here — use read_file, which reads through the executor.' },
|
|
2043
|
+
};
|
|
2044
|
+
}
|
|
2045
|
+
return { id, name, args, result: readWindow(executor.root, args, name), mutated: false };
|
|
2046
|
+
}
|
|
2047
|
+
|
|
2048
|
+
/**
|
|
2049
|
+
* ── ⭐ THE LANGUAGE SERVER ───────────────────────────────────────────────
|
|
2050
|
+
*
|
|
2051
|
+
* One entry point for all four verbs, so the registration cannot drift from
|
|
2052
|
+
* the schemas — `runLspTool` owns the name→function mapping and lives beside
|
|
2053
|
+
* them. `opts` is left empty: timeouts, server lifetime and the shutdown
|
|
2054
|
+
* grace are lsp.mjs's decisions, and a dispatcher that started overriding
|
|
2055
|
+
* them would become a second place those numbers live.
|
|
2056
|
+
*
|
|
2057
|
+
* ⚠️ `mutated: false` on all four. A language server opens documents in its
|
|
2058
|
+
* own memory; nothing on disk changes.
|
|
2059
|
+
*/
|
|
2060
|
+
/**
|
|
2061
|
+
* ── ⚠️ BACKGROUND PROCESSES RIDE WITH `allowRun`, AND MUTATE THE DISK ────
|
|
2062
|
+
*
|
|
2063
|
+
* ⚠️ `mutated: false` ON ALL THREE, AND THE FIRST VERSION GOT THIS WRONG.
|
|
2064
|
+
* The reasoning for `true` was sound — a dev server writes `.next/`, logs and
|
|
2065
|
+
* caches within a second — but `turn.mjs` reads `mutated` to mean "this
|
|
2066
|
+
* record NAMES A FILE", and a process names none. It crashed a real run with
|
|
2067
|
+
* `Cannot read properties of undefined` after the agent had already finished
|
|
2068
|
+
* the task. `turn.mjs` is now hardened against a pathless record too, but the
|
|
2069
|
+
* honest value here is `false`: the AGENT wrote no file, and what a process
|
|
2070
|
+
* it started did to `.next/` is not something this summary can enumerate.
|
|
2071
|
+
*/
|
|
2072
|
+
case 'repl':
|
|
2073
|
+
case 'repl_reset': {
|
|
2074
|
+
/**
|
|
2075
|
+
* ── ⚠️⚠️ `--no-run` HELD AT THE OFFER AND NOWHERE ELSE. MEASURED. ────────
|
|
2076
|
+
*
|
|
2077
|
+
* `run_program` (this file, the `allowRun === false` guard above) states
|
|
2078
|
+
* the rule and the reason: *"a model can emit a call for a tool it was
|
|
2079
|
+
* never shown (a resumed session, a stale conversation, a provider echoing
|
|
2080
|
+
* an old tool list), and the flag has to hold at the point the process
|
|
2081
|
+
* would actually start."* `repl` and `start_process` were withheld from
|
|
2082
|
+
* the OFFER (`toolNamesForRounds`, the `allowRun` pushes) and then
|
|
2083
|
+
* dispatched anyway if the call arrived.
|
|
2084
|
+
*
|
|
2085
|
+
* MEASURED 2026-08-14 through the real `executeToolCall` with
|
|
2086
|
+
* `allowRun: false`:
|
|
2087
|
+
* run_program → refused, correctly
|
|
2088
|
+
* repl → RAN THE CODE, `1+1` came back as 2
|
|
2089
|
+
* start_process → STARTED A REAL SERVER, pid 780, still running after
|
|
2090
|
+
*
|
|
2091
|
+
* ⭐ `start_process` is the sharper one, because a background process
|
|
2092
|
+
* OUTLIVES the round: `--no-run` could return having left a server bound
|
|
2093
|
+
* to a port. "Nothing was executed" is the one promise this flag makes.
|
|
2094
|
+
*
|
|
2095
|
+
* ⚠️ `repl_reset` IS NOT GATED, AND THAT IS THE POINT OF SPLITTING THEM.
|
|
2096
|
+
* It executes nothing — it KILLS the child process. Refusing the cleanup
|
|
2097
|
+
* verb because of a flag about running things would strand exactly what
|
|
2098
|
+
* the flag exists to prevent.
|
|
2099
|
+
*/
|
|
2100
|
+
if (allowRun === false && name === 'repl') {
|
|
2101
|
+
return {
|
|
2102
|
+
id, name, args, mutated: false,
|
|
2103
|
+
result: { ok: false, error: 'this run was started with --no-run, so no code is executed — the REPL runs the workspace\'s JavaScript for real. Report what you changed and say plainly that nothing was verified.' },
|
|
2104
|
+
};
|
|
2105
|
+
}
|
|
2106
|
+
if (executor.root === MEMORY_ROOT) {
|
|
2107
|
+
return {
|
|
2108
|
+
id, name, args, mutated: false,
|
|
2109
|
+
result: { ok: false, error: 'this workspace is held in memory rather than on disk, so there is no directory for a REPL to run in.' },
|
|
2110
|
+
};
|
|
2111
|
+
}
|
|
2112
|
+
/**
|
|
2113
|
+
* ⚠️ `mutated: false` — the REPL can of course write files if the user's
|
|
2114
|
+
* code does, but the RECORD names no path, and `turn.mjs` reads `mutated`
|
|
2115
|
+
* to mean "this record names a file". Claiming otherwise crashed a real
|
|
2116
|
+
* run when `start_process` did it this morning.
|
|
2117
|
+
*/
|
|
2118
|
+
return { id, name, args, result: await runReplTool(name, args, { executor }), mutated: false };
|
|
2119
|
+
}
|
|
2120
|
+
|
|
2121
|
+
case 'start_process':
|
|
2122
|
+
case 'check_process':
|
|
2123
|
+
case 'stop_process': {
|
|
2124
|
+
/**
|
|
2125
|
+
* ⚠️⚠️ THE SAME GAP AS `repl`, AND WORSE — see the note there for the
|
|
2126
|
+
* measurement. A background process is the one thing in this package that
|
|
2127
|
+
* OUTLIVES the round that started it, so a `--no-run` run could finish,
|
|
2128
|
+
* report that nothing was executed, and leave a server holding a port.
|
|
2129
|
+
* MEASURED: pid 780, still in the registry after the call returned.
|
|
2130
|
+
*
|
|
2131
|
+
* ⚠️ ONLY THE VERB THAT STARTS SOMETHING IS GATED. `check_process` reads a
|
|
2132
|
+
* buffer and `stop_process` KILLS a process — refusing those under a flag
|
|
2133
|
+
* that means "do not run things" would leave a live process unreachable,
|
|
2134
|
+
* which is the orphan this module's header says the repo has already paid
|
|
2135
|
+
* for twice.
|
|
2136
|
+
*/
|
|
2137
|
+
if (allowRun === false && name === 'start_process') {
|
|
2138
|
+
return {
|
|
2139
|
+
id, name, args, mutated: false,
|
|
2140
|
+
result: { ok: false, error: 'this run was started with --no-run, so no process is started — and a background process would outlive this run holding a port. Report what you changed and say plainly that nothing was verified.' },
|
|
2141
|
+
};
|
|
2142
|
+
}
|
|
2143
|
+
if (executor.root === MEMORY_ROOT) {
|
|
2144
|
+
return {
|
|
2145
|
+
id, name, args, mutated: false,
|
|
2146
|
+
result: { ok: false, error: 'this workspace is held in memory rather than on disk, so there is no directory for a process to run in.' },
|
|
2147
|
+
};
|
|
2148
|
+
}
|
|
2149
|
+
return {
|
|
2150
|
+
id, name, args,
|
|
2151
|
+
result: await runBackgroundTool(name, args, { executor, shell }),
|
|
2152
|
+
mutated: false,
|
|
2153
|
+
};
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2156
|
+
case 'review_code':
|
|
2157
|
+
// ⚠️ Synchronous by design — it reads and analyses, it never spawns.
|
|
2158
|
+
return { id, name, args, result: executeReviewCode(args, { root: executor.root, executor }), mutated: false };
|
|
2159
|
+
case 'inspect_db':
|
|
2160
|
+
case 'sample_db_rows':
|
|
2161
|
+
return { id, name, args, result: await inspectDatabase(executor.root, { ...args, sample: name === 'sample_db_rows' }), mutated: false };
|
|
2162
|
+
case 'gh_issue':
|
|
2163
|
+
case 'gh_pr':
|
|
2164
|
+
case 'gh_run': {
|
|
2165
|
+
/**
|
|
2166
|
+
* ⚠️ The noun is derived from the verb rather than taken from `args`, so a
|
|
2167
|
+
* model cannot reach `gh_run`'s surface by passing `noun: 'run'` to
|
|
2168
|
+
* `gh_issue`. The tool name IS the permission.
|
|
2169
|
+
*/
|
|
2170
|
+
const noun = name.slice('gh_'.length);
|
|
2171
|
+
return { id, name, args, result: await executeGh(executor.root, noun, args), mutated: false };
|
|
2172
|
+
}
|
|
2173
|
+
case 'read_log':
|
|
2174
|
+
case 'wait_for_output':
|
|
2175
|
+
case 'summarize_log':
|
|
2176
|
+
return { id, name, args, result: await runLogTailTool(name, args, { executor }), mutated: false };
|
|
2177
|
+
case 'call_endpoint': {
|
|
2178
|
+
/**
|
|
2179
|
+
* ── ⚠️ `--no-run` REACHES HERE TOO, AND FOR A LESS OBVIOUS REASON ──────
|
|
2180
|
+
*
|
|
2181
|
+
* This does not spawn anything, so the usual argument does not apply. It
|
|
2182
|
+
* is withheld anyway because it can ONLY reach a server `start_process`
|
|
2183
|
+
* started, `start_process` is refused under `--no-run`, and a verb that
|
|
2184
|
+
* can only ever answer "there is no such process" is the dead button this
|
|
2185
|
+
* file refuses to ship. Saying so plainly beats a confusing refusal from
|
|
2186
|
+
* the registry check.
|
|
2187
|
+
*/
|
|
2188
|
+
if (allowRun === false) {
|
|
2189
|
+
return {
|
|
2190
|
+
id, name, args, mutated: false,
|
|
2191
|
+
result: {
|
|
2192
|
+
ok: false,
|
|
2193
|
+
error: 'this run was started with --no-run, so no server was started and there is nothing local to call. '
|
|
2194
|
+
+ 'Report what you changed and say plainly that nothing was verified.',
|
|
2195
|
+
},
|
|
2196
|
+
};
|
|
2197
|
+
}
|
|
2198
|
+
/**
|
|
2199
|
+
* ⚠️ `mutated: false` — a POST changes the SERVER's state, never a file in
|
|
2200
|
+
* this workspace, and `turn.mjs` reads `mutated` to mean "this record
|
|
2201
|
+
* names a path". Claiming otherwise crashed a real run when
|
|
2202
|
+
* `start_process` did it.
|
|
2203
|
+
*/
|
|
2204
|
+
return { id, name, args, result: await runHttpProbeTool(name, args, { executor }), mutated: false };
|
|
2205
|
+
}
|
|
2206
|
+
|
|
2207
|
+
case 'find_definition':
|
|
2208
|
+
case 'find_references':
|
|
2209
|
+
case 'check_types':
|
|
2210
|
+
case 'list_symbols': {
|
|
2211
|
+
if (executor.root === MEMORY_ROOT) {
|
|
2212
|
+
return {
|
|
2213
|
+
id, name, args, mutated: false,
|
|
2214
|
+
result: { ok: false, error: 'this workspace is held in memory rather than on disk, and a language server can only read real files — use search_text and read_file instead.' },
|
|
2215
|
+
};
|
|
2216
|
+
}
|
|
2217
|
+
/**
|
|
2218
|
+
* ⚠️ THE REAL LANGUAGE SERVER WINS WHEN IT EXISTS — it covers four
|
|
2219
|
+
* languages, tsserver covers two. But `lspAvailable` being false is the
|
|
2220
|
+
* common case, and falling through to tsserver is what makes these tools
|
|
2221
|
+
* reachable at all. ⚠️ And tsserver is only tried for files it can
|
|
2222
|
+
* actually answer about: handing it a `.py` would produce a confident
|
|
2223
|
+
* refusal from the wrong component.
|
|
2224
|
+
*/
|
|
2225
|
+
if (!lspAvailable(executor.root) && tsHandlesFile(args.file)) {
|
|
2226
|
+
return { id, name, args, result: await runTsserverTool(executor.root, name, args), mutated: false };
|
|
2227
|
+
}
|
|
2228
|
+
return { id, name, args, result: await runLspTool(executor.root, name, args), mutated: false };
|
|
2229
|
+
}
|
|
2230
|
+
|
|
2231
|
+
/**
|
|
2232
|
+
* ⚠️ GIT NEEDS A REAL REPOSITORY ON A REAL DISK. A memory workspace has
|
|
2233
|
+
* neither, and `git -C "(memory)"` would fail with something incoherent.
|
|
2234
|
+
* Refused by capability, with a sentence that says why — the model gets
|
|
2235
|
+
* another round and must not spend it retrying.
|
|
2236
|
+
*/
|
|
2237
|
+
case 'git_status':
|
|
2238
|
+
case 'git_diff':
|
|
2239
|
+
case 'git_log':
|
|
2240
|
+
case 'git_commit':
|
|
2241
|
+
case 'git_branch':
|
|
2242
|
+
case 'git_push':
|
|
2243
|
+
if (executor.root === '(memory)') {
|
|
2244
|
+
return {
|
|
2245
|
+
id, name, args, mutated: false,
|
|
2246
|
+
result: { ok: false, error: 'this workspace is not backed by a git repository, so git commands are unavailable here' },
|
|
2247
|
+
};
|
|
2248
|
+
}
|
|
2249
|
+
/**
|
|
2250
|
+
* ⚠️ THE GATES HOLD AT THE DISPATCHER, NOT ONLY AT THE OFFER. This file
|
|
2251
|
+
* already paid for that lesson twice — `repl` RAN CODE and
|
|
2252
|
+
* `start_process` STARTED A SERVER under `--no-run`, because their gate
|
|
2253
|
+
* lived in `toolNamesForRounds` alone (see
|
|
2254
|
+
* `test/no-run-holds-at-dispatcher.test.mjs`). A resumed session or a
|
|
2255
|
+
* provider echoing a stale tool list is all it takes.
|
|
2256
|
+
*/
|
|
2257
|
+
if (allowRun === false && (name === 'git_commit' || name === 'git_branch' || name === 'git_push')) {
|
|
2258
|
+
return {
|
|
2259
|
+
id, name, args, mutated: false,
|
|
2260
|
+
result: { ok: false, error: `--no-run was passed, so ${name} is not available in this run` },
|
|
2261
|
+
};
|
|
2262
|
+
}
|
|
2263
|
+
// ⚠️ `executor.env` is not set by `createLocalExecutor`; it exists so a
|
|
2264
|
+
// test can hand in a plain `{ root, env }` executor and drive this branch
|
|
2265
|
+
// without mutating the real process environment.
|
|
2266
|
+
if (name === 'git_push' && !pushEnabled(executor.env ?? process.env)) {
|
|
2267
|
+
return {
|
|
2268
|
+
id, name, args, mutated: false,
|
|
2269
|
+
result: { ok: false, error: `pushing is turned off. The operator has to enable it by name: ${ALLOW_PUSH_ENV}=1. Commit the work and hand the branch over instead.` },
|
|
2270
|
+
};
|
|
2271
|
+
}
|
|
2272
|
+
return dispatchGit(name, id, args, executor);
|
|
2273
|
+
default:
|
|
2274
|
+
return {
|
|
2275
|
+
id,
|
|
2276
|
+
name: name ?? '(unnamed)',
|
|
2277
|
+
args,
|
|
2278
|
+
result: { ok: false, error: `unknown tool "${name}" — this CLI implements ${TOOL_NAMES.join(', ')}` },
|
|
2279
|
+
mutated: false,
|
|
2280
|
+
};
|
|
2281
|
+
}
|
|
2282
|
+
}
|
|
2283
|
+
|
|
2284
|
+
/** The git verbs, split out so the capability guard above reads in one glance. */
|
|
2285
|
+
async function dispatchGit(name, id, args, executor) {
|
|
2286
|
+
switch (name) {
|
|
2287
|
+
case 'git_status':
|
|
2288
|
+
return { id, name, args, result: await gitStatus(executor.root), mutated: false };
|
|
2289
|
+
case 'git_diff':
|
|
2290
|
+
return { id, name, args, result: await gitDiff(executor.root, { path: args.path, staged: args.staged === true }), mutated: false };
|
|
2291
|
+
case 'git_log':
|
|
2292
|
+
return { id, name, args, result: await gitLog(executor.root, { count: args.count, path: args.path }), mutated: false };
|
|
2293
|
+
case 'git_branch':
|
|
2294
|
+
return {
|
|
2295
|
+
id, name, args, mutated: false,
|
|
2296
|
+
result: await gitBranch(executor.root, { name: args.name, dryRun: executor.dryRun }),
|
|
2297
|
+
};
|
|
2298
|
+
case 'git_push':
|
|
2299
|
+
return {
|
|
2300
|
+
id, name, args, mutated: false,
|
|
2301
|
+
result: await gitPush(executor.root, {
|
|
2302
|
+
remote: args.remote,
|
|
2303
|
+
openPullRequest: args.openPullRequest === true,
|
|
2304
|
+
pullRequestTitle: args.pullRequestTitle,
|
|
2305
|
+
pullRequestBody: args.pullRequestBody,
|
|
2306
|
+
pullRequestBase: args.pullRequestBase,
|
|
2307
|
+
dryRun: executor.dryRun,
|
|
2308
|
+
env: executor.env ?? process.env,
|
|
2309
|
+
}),
|
|
2310
|
+
};
|
|
2311
|
+
case 'git_commit': {
|
|
2312
|
+
const result = await gitCommit(executor.root, {
|
|
2313
|
+
message: args.message, paths: args.paths, dryRun: executor.dryRun,
|
|
2314
|
+
});
|
|
2315
|
+
/**
|
|
2316
|
+
* ⚠️ `mutated: false` AND THAT IS DELIBERATE. `mutated` feeds the
|
|
2317
|
+
* "N files written" line, which names files this run CHANGED ON DISK.
|
|
2318
|
+
* A commit changes no file contents — counting it would inflate the one
|
|
2319
|
+
* honest number in the summary with files that were already written and
|
|
2320
|
+
* already counted, reporting each of them twice.
|
|
2321
|
+
*/
|
|
2322
|
+
return { id, name, args, result, mutated: false };
|
|
2323
|
+
}
|
|
2324
|
+
default:
|
|
2325
|
+
return {
|
|
2326
|
+
id,
|
|
2327
|
+
name: name ?? '(unnamed)',
|
|
2328
|
+
args,
|
|
2329
|
+
result: { ok: false, error: `unknown tool "${name}" — this CLI implements ${TOOL_NAMES.join(', ')}` },
|
|
2330
|
+
mutated: false,
|
|
2331
|
+
};
|
|
2332
|
+
}
|
|
2333
|
+
}
|