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/subagent.mjs
ADDED
|
@@ -0,0 +1,749 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ── ⭐⭐ SUBAGENTS — DELEGATION, AND THE CHEAPEST CONTEXT THERE IS ───────────
|
|
3
|
+
*
|
|
4
|
+
* The main loop can now say "go and find where this is defined across 400
|
|
5
|
+
* files" and get back two hundred tokens instead of fifty thousand.
|
|
6
|
+
*
|
|
7
|
+
* ⚠️ THIS IS NOT `--parallel`. That runs N unrelated tasks a HUMAN typed, in
|
|
8
|
+
* separate workspaces, with collision detection between them. This is one task
|
|
9
|
+
* asking for help mid-thought, and the help happens in its own head.
|
|
10
|
+
*
|
|
11
|
+
* ── ⭐ WHY IT IS A MARGIN FEATURE AS WELL AS A CONTEXT ONE ──────────────────
|
|
12
|
+
* Measured 2026-08-11: DeepSeek caches prompt prefixes automatically and a hit
|
|
13
|
+
* costs up to 50x less — but ONLY while the prefix repeats byte-for-byte from
|
|
14
|
+
* token 0. Every noisy search a parent performs itself lands in the parent's
|
|
15
|
+
* message array and pushes the useful part further from the cached head. A
|
|
16
|
+
* subagent's exploration never touches that array at all, so the expensive
|
|
17
|
+
* cached prefix survives work that would otherwise pollute it.
|
|
18
|
+
*
|
|
19
|
+
* ⭐ Context hygiene and cost are the same lever, and almost nobody treats them
|
|
20
|
+
* that way.
|
|
21
|
+
*
|
|
22
|
+
* ── ⚠️ THE FOUR RULES, AND THE FAILURE EACH ONE PREVENTS ───────────────────
|
|
23
|
+
*
|
|
24
|
+
* 1. ⚠️⚠️ READ-ONLY **BY DEFAULT**, AND LOCKED TWICE. A subagent that writes
|
|
25
|
+
* can collide with the parent editing the same file, and whoever finishes
|
|
26
|
+
* second wins silently. `parallel.mjs` exists to catch exactly that between
|
|
27
|
+
* processes and is blind to it here, because these run INSIDE one session.
|
|
28
|
+
* So the offer excludes every mutating verb AND `allowRun: false` is passed,
|
|
29
|
+
* because a model can emit a call for a tool it was never shown — the same
|
|
30
|
+
* belt-and-braces `check_acceptance` already uses.
|
|
31
|
+
*
|
|
32
|
+
* ── ⭐⭐ AND THAT BLOCKER IS NOW SOLVED — `write: true` ───────────────────
|
|
33
|
+
* Read the rule again: the reason is TECHNICAL ("blind to it here"), not
|
|
34
|
+
* moral. `lib/handoff.mjs` removes the blindness — a writing helper works in
|
|
35
|
+
* an isolated COPY of the workspace (so it cannot race anybody), and its
|
|
36
|
+
* changes are re-applied THROUGH THE PARENT'S EXECUTOR with a per-file
|
|
37
|
+
* SHA-256 collision check, so a file that moved underneath us is refused BY
|
|
38
|
+
* NAME instead of silently overwritten. The parent's own guards — leases,
|
|
39
|
+
* `--dry-run`, the `.acuvo/` leash, path containment — all still apply,
|
|
40
|
+
* because the apply path is the same `executor.writeFile` every other tool
|
|
41
|
+
* uses. ⚠️ It is OPT-IN per call: a helper asked a question still cannot
|
|
42
|
+
* write a byte.
|
|
43
|
+
*
|
|
44
|
+
* 2. ⚠️ NO RECURSION. A subagent that can delegate is an unbounded fork bomb
|
|
45
|
+
* billed to the user. `delegate` is absent from `SUBAGENT_TOOL_NAMES`, and
|
|
46
|
+
* depth is checked before a model is called, so both the offer and the
|
|
47
|
+
* dispatcher refuse.
|
|
48
|
+
*
|
|
49
|
+
* 3. ⚠️ DISTILLED, NEVER FORWARDED. Returning the transcript would defeat the
|
|
50
|
+
* whole point: the parent would pay for precisely the context it delegated
|
|
51
|
+
* to avoid. The parent gets a sentence, a cost, and a list of files touched.
|
|
52
|
+
*
|
|
53
|
+
* 4. ⚠️ A FAILURE IS DATA. A dead subagent returns a reason the parent can act
|
|
54
|
+
* on. Throwing into the parent's loop would take down a session that was
|
|
55
|
+
* otherwise fine, to report that a helper was not.
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* ⚠️ STATIC, AND SAFE TO BE. `turn.mjs` is still imported LAZILY below because
|
|
60
|
+
* it imports `tools.mjs`, which imports THIS file — a cycle. `handoff.mjs` and
|
|
61
|
+
* `workspace.mjs` sit underneath both and import neither, so there is no cycle
|
|
62
|
+
* to dodge here, and `summariseForParent` is synchronous and could not await
|
|
63
|
+
* one anyway.
|
|
64
|
+
*/
|
|
65
|
+
import { describeHandoff, runInIsolatedCopy } from './handoff.mjs';
|
|
66
|
+
import { createLocalExecutor } from './workspace.mjs';
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* What a researcher may hold. Read, search, navigate — nothing that changes
|
|
70
|
+
* anything.
|
|
71
|
+
*
|
|
72
|
+
* ⚠️ SPELLED OUT RATHER THAN FILTERED FROM THE REGISTRY. A denylist would
|
|
73
|
+
* silently hand a subagent every future tool anyone adds; this way a new
|
|
74
|
+
* capability reaches subagents only when somebody decides it should.
|
|
75
|
+
*/
|
|
76
|
+
export const SUBAGENT_TOOL_NAMES = Object.freeze([
|
|
77
|
+
'read_file',
|
|
78
|
+
'read_lines',
|
|
79
|
+
'read_around',
|
|
80
|
+
'list_dir',
|
|
81
|
+
'find_files',
|
|
82
|
+
'search_text',
|
|
83
|
+
'find_definition',
|
|
84
|
+
'find_references',
|
|
85
|
+
'list_symbols',
|
|
86
|
+
'git_status',
|
|
87
|
+
'git_diff',
|
|
88
|
+
'git_log',
|
|
89
|
+
]);
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* ── ⭐⭐ WHAT A *BUILDER* MAY HOLD — the read verbs plus three write verbs ────
|
|
93
|
+
*
|
|
94
|
+
* ⚠️ SPELLED OUT, LIKE THE LIST ABOVE, and for the same reason: a denylist
|
|
95
|
+
* would hand every future tool to a writing helper the day it is registered.
|
|
96
|
+
*
|
|
97
|
+
* ⚠️⚠️ THE THREE GIT VERBS ARE DELIBERATELY ABSENT, AND THIS IS THE TRAP THAT
|
|
98
|
+
* WOULD HAVE SHIPPED. A builder runs inside the isolated copy, and the copy
|
|
99
|
+
* skips `.git` (`best-of.mjs:42 COPY_SKIP_DIRS` — copying a repo's object
|
|
100
|
+
* store per delegation is enormous and the helper cannot commit anything
|
|
101
|
+
* anyway). So `git_status`, `git_diff` and `git_log` could only ever return
|
|
102
|
+
* "not a git repository" there. ⭐ That is precisely the dead button
|
|
103
|
+
* `tools.mjs` refuses to ship everywhere else — a tool the model is offered,
|
|
104
|
+
* tries, and learns to apologise for. Offering them would cost ~750 tokens a
|
|
105
|
+
* round to teach the helper to fail.
|
|
106
|
+
*
|
|
107
|
+
* ⚠️ AND STILL NOTHING THAT EXECUTES. `allowRun: false` is passed for a builder
|
|
108
|
+
* exactly as for a researcher — not from timidity, but because the copy has no
|
|
109
|
+
* `node_modules` either (same skip set), so `npm test` inside it would fail for
|
|
110
|
+
* a reason that has nothing to do with the code. A verification that fails for
|
|
111
|
+
* the wrong reason is worse than no verification: the parent would act on it.
|
|
112
|
+
* ⭐ The parent verifies, after the changes land in the real workspace where
|
|
113
|
+
* the dependencies actually are.
|
|
114
|
+
*/
|
|
115
|
+
/**
|
|
116
|
+
* ── ⭐⭐ AND WHAT A *VERIFYING* BUILDER MAY HOLD — the write verbs plus ONE ────
|
|
117
|
+
*
|
|
118
|
+
* ⚠️ THE COMMENT ON `SUBAGENT_WRITE_TOOL_NAMES` BELOW IS STILL TRUE AND IS
|
|
119
|
+
* DELIBERATELY LEFT STANDING. It argues that a builder must execute nothing
|
|
120
|
+
* because the isolated copy has no `node_modules`, so a verification run in
|
|
121
|
+
* there would fail for a reason that has nothing to do with the code, and "a
|
|
122
|
+
* verification that fails for the wrong reason is worse than no verification:
|
|
123
|
+
* the parent would act on it." Every word of that was correct — and it is an
|
|
124
|
+
* argument about the COPY, not about the helper.
|
|
125
|
+
*
|
|
126
|
+
* ⭐ SO THE COPY CHANGED. `runInIsolatedCopy({ linkNodeModules: true })` links
|
|
127
|
+
* the real dependency tree into the copy (a junction on Windows, measured safe
|
|
128
|
+
* under the recursive delete — see its comment). With the dependencies present,
|
|
129
|
+
* `npm test` inside the copy means what it says, and the old objection no longer
|
|
130
|
+
* applies to a run that asked for this.
|
|
131
|
+
*
|
|
132
|
+
* ⚠️ A SEPARATE LIST, NOT A MUTATION OF THE ONE BELOW. `run_command` must not
|
|
133
|
+
* appear in the default builder's offer: `subagent-write.test.mjs` pins it as a
|
|
134
|
+
* dead button there and it is RIGHT to, because a plain `write:true` helper
|
|
135
|
+
* still gets an unlinked copy and `allowRun: false`. Two capabilities, two
|
|
136
|
+
* lists, and neither can leak into the other by accident.
|
|
137
|
+
*
|
|
138
|
+
* ⚠️ ONE RUN VERB, NOT FIVE. `check_types`, `check_acceptance`, `evaluate` and
|
|
139
|
+
* `run_program` would each cost tokens in the offer on every round of every
|
|
140
|
+
* delegated build; `run_command` reaches all of them through the same
|
|
141
|
+
* allowlist (`node`, `npm`, `npx`, `tsc`) that governs the parent. Breadth in
|
|
142
|
+
* the offer is not breadth in capability here.
|
|
143
|
+
*/
|
|
144
|
+
/* Declared just below `SUBAGENT_WRITE_TOOL_NAMES`, because it is that list
|
|
145
|
+
* plus one verb and retyping thirteen names would be two documents to keep in
|
|
146
|
+
* agreement — the drift this file already records three times. */
|
|
147
|
+
|
|
148
|
+
export const SUBAGENT_WRITE_TOOL_NAMES = Object.freeze([
|
|
149
|
+
'read_file',
|
|
150
|
+
'read_lines',
|
|
151
|
+
'read_around',
|
|
152
|
+
'list_dir',
|
|
153
|
+
'find_files',
|
|
154
|
+
'search_text',
|
|
155
|
+
'find_definition',
|
|
156
|
+
'find_references',
|
|
157
|
+
'list_symbols',
|
|
158
|
+
/**
|
|
159
|
+
* ⭐ `edit_file` BEFORE `write_file`, because the order in the offer is a
|
|
160
|
+
* hint the model reads — `tools.mjs:520` makes the same point: for a file
|
|
161
|
+
* that already exists, `write_file` is a destructive operation wearing the
|
|
162
|
+
* costume of an edit.
|
|
163
|
+
*/
|
|
164
|
+
'edit_file',
|
|
165
|
+
'write_file',
|
|
166
|
+
/**
|
|
167
|
+
* ⭐ THE BULK WRITE EARNS ITS ~300 TOKENS HERE MORE THAN ANYWHERE. A builder
|
|
168
|
+
* is capped at 6 rounds; "create these four files" as four rounds is a
|
|
169
|
+
* helper that runs out of budget mid-feature, and `handoff.mjs:changedPaths`
|
|
170
|
+
* already reads `result.written[]` so every one of them is applied.
|
|
171
|
+
*/
|
|
172
|
+
'write_files',
|
|
173
|
+
'delete_file',
|
|
174
|
+
]);
|
|
175
|
+
|
|
176
|
+
export const SUBAGENT_VERIFY_TOOL_NAMES = Object.freeze([...SUBAGENT_WRITE_TOOL_NAMES, 'run_command']);
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* ⚠️ ONE LEVEL. A parent may delegate; a subagent may not. Two levels sounds
|
|
180
|
+
* harmless and is how a five-round task becomes a hundred model calls nobody
|
|
181
|
+
* authorised.
|
|
182
|
+
*/
|
|
183
|
+
export const MAX_SUBAGENT_DEPTH = 1;
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* ⚠️ A SUBAGENT MUST NOT OUTSPEND ITS PARENT. A researcher that needs more than
|
|
187
|
+
* a handful of rounds is being asked the wrong question, and the honest answer
|
|
188
|
+
* is a worse summary rather than a bigger bill.
|
|
189
|
+
*/
|
|
190
|
+
export const MAX_SUBAGENT_ROUNDS = 6;
|
|
191
|
+
const DEFAULT_SUBAGENT_ROUNDS = 4;
|
|
192
|
+
|
|
193
|
+
const MAX_SUMMARY_CHARS = 900;
|
|
194
|
+
const MAX_FILES_LISTED = 12;
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* ── ⭐⭐ THE CONTEXT CHANNEL — THE HELPER USED TO GET A TASK STRING AND NOTHING ─
|
|
198
|
+
*
|
|
199
|
+
* Rule 3 at the top of this file says the RESULT is distilled and never
|
|
200
|
+
* forwarded, and it is right. Nobody ever wrote the rule for the other
|
|
201
|
+
* direction, so the input was a single sentence by default rather than by
|
|
202
|
+
* decision — and a helper that does not know what the parent has already
|
|
203
|
+
* established re-derives it. Measured cost of that: a builder capped at 6 rounds
|
|
204
|
+
* spending two of them opening files the parent read a minute earlier, and
|
|
205
|
+
* arriving at a different opinion about them, which the parent then has to
|
|
206
|
+
* reconcile against its own.
|
|
207
|
+
*
|
|
208
|
+
* ⭐ IT IS A BRIEF, NOT A TRANSCRIPT. Handing over the message array would
|
|
209
|
+
* reproduce the exact cost the whole feature exists to avoid — the parent would
|
|
210
|
+
* pay to send the context it delegated in order not to send. What travels is
|
|
211
|
+
* what the PARENT chose to write down, which is also the only part it can
|
|
212
|
+
* vouch for.
|
|
213
|
+
*
|
|
214
|
+
* ⚠️ AND IT IS CAPPED, OUT LOUD. Without a ceiling the cheapest way for a model
|
|
215
|
+
* to "give context" is to paste a file, and delegation would quietly become the
|
|
216
|
+
* most expensive tool in the package instead of the cheapest. 4,000 characters
|
|
217
|
+
* is ~1,000 estimated tokens — about a quarter of `read_file`'s own budget, and
|
|
218
|
+
* enough for a paragraph of findings and a list of constraints. Over that it is
|
|
219
|
+
* TRIMMED and the trim is announced inside the prompt, so the helper knows it
|
|
220
|
+
* was handed a truncated brief rather than a complete one.
|
|
221
|
+
*/
|
|
222
|
+
export const MAX_CONTEXT_CHARS = 4_000;
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Fold the parent's brief into the task the helper is given.
|
|
226
|
+
*
|
|
227
|
+
* ⚠️ PURE, AND SEPARATE FROM `runSubagent`, because it decides what a paid model
|
|
228
|
+
* call is asked — reviewable on its own rather than buried in an options object.
|
|
229
|
+
*
|
|
230
|
+
* ⚠️ THE CONTEXT GOES AFTER THE TASK, NOT BEFORE IT. The first thing the helper
|
|
231
|
+
* reads has to be the thing it was asked to do; a brief on top buries the
|
|
232
|
+
* question under its own background, and `prompt.mjs` makes the same call for
|
|
233
|
+
* the same reason.
|
|
234
|
+
*
|
|
235
|
+
* @param {string} task
|
|
236
|
+
* @param {unknown} context
|
|
237
|
+
* @returns {string}
|
|
238
|
+
*/
|
|
239
|
+
export function briefFor(task, context) {
|
|
240
|
+
const extra = typeof context === 'string' ? context.trim() : '';
|
|
241
|
+
if (!extra) return task;
|
|
242
|
+
const trimmed = extra.length > MAX_CONTEXT_CHARS;
|
|
243
|
+
const body = trimmed ? extra.slice(0, MAX_CONTEXT_CHARS) : extra;
|
|
244
|
+
return `${task}\n\n`
|
|
245
|
+
+ '--- WHAT THE AGENT THAT ASKED YOU HAS ALREADY ESTABLISHED ---\n'
|
|
246
|
+
+ 'Treat this as known. Do not spend a round re-deriving it, and do not contradict it without saying so.\n'
|
|
247
|
+
+ `${body}\n`
|
|
248
|
+
+ (trimmed
|
|
249
|
+
? `[the brief was longer than ${MAX_CONTEXT_CHARS} characters and was cut here — ask for anything you are missing in your summary]\n`
|
|
250
|
+
: '')
|
|
251
|
+
+ '--- end of brief ---';
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Every path a set of tool records looked at, in first-seen order. */
|
|
255
|
+
function filesTouched(executed) {
|
|
256
|
+
const seen = [];
|
|
257
|
+
for (const rec of executed ?? []) {
|
|
258
|
+
const p = rec?.args?.path ?? rec?.result?.path;
|
|
259
|
+
if (typeof p === 'string' && p && !seen.includes(p)) seen.push(p);
|
|
260
|
+
}
|
|
261
|
+
return seen;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Turn a finished session into the few hundred characters the parent actually
|
|
266
|
+
* needs.
|
|
267
|
+
*
|
|
268
|
+
* ⭐ THE MODEL'S OWN CLOSING NOTE IS THE ANSWER. It is the one place the
|
|
269
|
+
* subagent states what it concluded, in its own words, having seen everything.
|
|
270
|
+
* Re-deriving that from tool results here would be a second, worse summariser.
|
|
271
|
+
*
|
|
272
|
+
* ⚠️ AND WHEN THERE IS NO NOTE, SAY SO OUT LOUD. An empty string tells the
|
|
273
|
+
* parent nothing and reads as success; "found nothing" is a fact it can act on.
|
|
274
|
+
*/
|
|
275
|
+
/**
|
|
276
|
+
* ── ⭐⭐ WHAT THE HELPER PROVED, AS A FACT RATHER THAN AS A CLAIM ─────────────
|
|
277
|
+
*
|
|
278
|
+
* ⚠️ IT IS APPENDED SEPARATELY AND NEVER FOLDED INTO THE NOTE, for the same
|
|
279
|
+
* reason `describeHandoff` is: the note is what the helper MEANT, capped at
|
|
280
|
+
* `MAX_SUMMARY_CHARS`, and a verdict squeezed out by a chatty model is a verdict
|
|
281
|
+
* the parent never hears. `outcome.verification` is what a process actually did.
|
|
282
|
+
*
|
|
283
|
+
* ⚠️ AND SILENCE IS NOT SUCCESS. A helper that ran nothing says so in words,
|
|
284
|
+
* because "the tests pass" and "no test was run" look identical to a parent
|
|
285
|
+
* reading a summary that mentions neither — and the second one is the state the
|
|
286
|
+
* parent must not report to the user as the first.
|
|
287
|
+
*
|
|
288
|
+
* @param {{ ran?: boolean, passed?: boolean|null, command?: string|null, exitCode?: number|null }|null} v
|
|
289
|
+
*/
|
|
290
|
+
export function describeVerification(v, { offered = false } = {}) {
|
|
291
|
+
if (!offered) return '';
|
|
292
|
+
if (!v || v.ran !== true) {
|
|
293
|
+
return 'VERIFIED: nothing. It was allowed to run commands and did not — treat the change as unproven.';
|
|
294
|
+
}
|
|
295
|
+
const cmd = v.command ? `\`${v.command}\`` : 'a command';
|
|
296
|
+
return v.passed === true
|
|
297
|
+
? `VERIFIED: it ran ${cmd} in its isolated copy and it passed (exit ${v.exitCode ?? 0}).`
|
|
298
|
+
: `⚠ NOT VERIFIED: it ran ${cmd} in its isolated copy and it FAILED (exit ${v.exitCode ?? '?'}). `
|
|
299
|
+
+ 'The files below were still applied — re-run it yourself before saying the work is done.';
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export function summariseForParent(outcome, handoff = null, { verifyOffered = false } = {}) {
|
|
303
|
+
if (!outcome || outcome.ok !== true) {
|
|
304
|
+
return `the helper did not finish: ${outcome?.error ?? 'no reason given'}`;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const note = typeof outcome.note === 'string' ? outcome.note.trim() : '';
|
|
308
|
+
const files = filesTouched(outcome.executed);
|
|
309
|
+
const proof = describeVerification(outcome.verification, { offered: verifyOffered });
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* ── ⚠️⚠️ WHAT LANDED IS A FACT, AND IT OUTRANKS THE MODEL'S OPINION ───────
|
|
313
|
+
*
|
|
314
|
+
* A builder's note says what it MEANT to do. `handoff` says what is on disk.
|
|
315
|
+
* When those disagree — a collision refused one file, the leash refused
|
|
316
|
+
* another — the parent must be told by the second, or it will report the
|
|
317
|
+
* first to the user as if it were the second. So this paragraph is appended
|
|
318
|
+
* unconditionally in write mode, including when the helper wrote nothing.
|
|
319
|
+
*/
|
|
320
|
+
const landed = handoff ? describeHandoff(handoff) : '';
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* ⚠️ THE PROOF SITS WITH `landed`, ABOVE THE FILE LIST AND BELOW THE NOTE —
|
|
324
|
+
* both are facts about what happened rather than about what was intended, and
|
|
325
|
+
* keeping them adjacent is what stops a reader taking the note's confidence
|
|
326
|
+
* and the handoff's file list as one statement with no verdict between them.
|
|
327
|
+
*/
|
|
328
|
+
const tail = [landed, proof].filter(Boolean).join('\n');
|
|
329
|
+
|
|
330
|
+
if (!note) {
|
|
331
|
+
const nothing = files.length
|
|
332
|
+
? `Nothing conclusive. It looked at ${files.slice(0, MAX_FILES_LISTED).join(', ')} and did not report a finding.`
|
|
333
|
+
: 'Nothing conclusive — it did not report a finding, and opened no files.';
|
|
334
|
+
return tail ? `${nothing}\n${tail}` : nothing;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const body = note.length > MAX_SUMMARY_CHARS
|
|
338
|
+
? `${note.slice(0, MAX_SUMMARY_CHARS)}…`
|
|
339
|
+
: note;
|
|
340
|
+
|
|
341
|
+
const looked = files.length === 0
|
|
342
|
+
? ''
|
|
343
|
+
: `\n(looked at: ${files.slice(0, MAX_FILES_LISTED).join(', ')}${files.length > MAX_FILES_LISTED ? ` +${files.length - MAX_FILES_LISTED} more` : ''})`;
|
|
344
|
+
|
|
345
|
+
return tail ? `${body}${looked}\n${tail}` : `${body}${looked}`;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Run one scoped helper and return what it concluded — and, when it was asked
|
|
350
|
+
* to build, what it changed.
|
|
351
|
+
*
|
|
352
|
+
* @param {{ task: string, executor: any, config: any, depth?: number, maxRounds?: number,
|
|
353
|
+
* maxTokens?: number, timeoutMs?: number, write?: boolean, onEvent?: (e:any)=>void }} args
|
|
354
|
+
* @param {{ sessionImpl?: Function, isolateImpl?: Function, makeExecutor?: Function }} [deps]
|
|
355
|
+
*/
|
|
356
|
+
export async function runSubagent(args = {}, {
|
|
357
|
+
sessionImpl = null,
|
|
358
|
+
/**
|
|
359
|
+
* ⚠️ INJECTED, NOT IMPORTED AT THE CALL SITE, for the reason every outward
|
|
360
|
+
* call in this package is injected: without it the ONLY way to exercise the
|
|
361
|
+
* write path is a real model call against a real temp directory, which means
|
|
362
|
+
* in practice it is exercised once by hand and never again. The two things
|
|
363
|
+
* being pinned here — a collision is refused, and `--dry-run` still holds —
|
|
364
|
+
* are the two that must never regress quietly.
|
|
365
|
+
*/
|
|
366
|
+
isolateImpl = null,
|
|
367
|
+
makeExecutor = null,
|
|
368
|
+
} = {}) {
|
|
369
|
+
const task = typeof args.task === 'string' ? args.task.trim() : '';
|
|
370
|
+
if (!task) {
|
|
371
|
+
// ⚠️ Refused BEFORE a model is called. An empty task still costs a round.
|
|
372
|
+
return { ok: false, error: 'a delegated task needs a specific question — an empty one would spend a round to learn nothing' };
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const depth = Number.isFinite(args.depth) ? args.depth : 0;
|
|
376
|
+
if (depth >= MAX_SUBAGENT_DEPTH) {
|
|
377
|
+
return {
|
|
378
|
+
ok: false,
|
|
379
|
+
error: `a helper cannot delegate again (depth ${depth}) — ask it a narrower question, or do the remaining step yourself`,
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const run = sessionImpl ?? (await import('./turn.mjs')).runSession;
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* ── ⭐⭐ RESEARCHER OR BUILDER — ONE FLAG, TWO OFFERS ──────────────────────
|
|
387
|
+
*
|
|
388
|
+
* ⚠️ `=== true` RATHER THAN TRUTHINESS. This decides whether a helper may
|
|
389
|
+
* change files on someone's disk; the string "false", which is what a model
|
|
390
|
+
* emits about once in fifty when a schema says boolean, is not a yes.
|
|
391
|
+
*/
|
|
392
|
+
const wantsWrite = args.write === true;
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* ── ⭐⭐ AND MAY IT PROVE ITS OWN WORK? ────────────────────────────────────
|
|
396
|
+
*
|
|
397
|
+
* ⚠️ ONLY WITH `write`, AND THAT IS NOT TIDINESS. A RESEARCHER runs against
|
|
398
|
+
* the PARENT'S OWN EXECUTOR — the real workspace, not a copy — so letting one
|
|
399
|
+
* execute commands would put an unsupervised process in the user's actual
|
|
400
|
+
* repository to answer a question. A builder already works in an isolated
|
|
401
|
+
* copy, which is the only place a command can be both meaningful and contained.
|
|
402
|
+
*
|
|
403
|
+
* ⚠️ `=== true` again, for the reason above: this decides whether a process
|
|
404
|
+
* starts, and the string "false" is not a yes.
|
|
405
|
+
*/
|
|
406
|
+
const wantsVerify = wantsWrite && args.verify === true;
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* ⚠️ THE DEFAULT MOVES WITH THE JOB, AND THE CEILING DOES NOT. Four rounds is
|
|
410
|
+
* right for "read these files and tell me X". A helper that must WRITE, RUN,
|
|
411
|
+
* READ THE FAILURE and FIX IT has spent four before it has looked at anything,
|
|
412
|
+
* so the default for a verifying build is the whole allowance —
|
|
413
|
+
* `MAX_SUBAGENT_ROUNDS`, which is unchanged, so nothing about the worst case
|
|
414
|
+
* moves. An explicit `maxRounds` from the model still wins, and is still
|
|
415
|
+
* clamped by the same ceiling.
|
|
416
|
+
*/
|
|
417
|
+
const maxRounds = Math.min(
|
|
418
|
+
Math.max(1, Number.isFinite(args.maxRounds)
|
|
419
|
+
? Math.floor(args.maxRounds)
|
|
420
|
+
: (wantsVerify ? MAX_SUBAGENT_ROUNDS : DEFAULT_SUBAGENT_ROUNDS)),
|
|
421
|
+
MAX_SUBAGENT_ROUNDS,
|
|
422
|
+
);
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* ⚠️ A BUILDER NEEDS A REAL WORKSPACE TO COPY. `runSession` is given an
|
|
426
|
+
* executor by embedders that have no disk at all (`memory-workspace.mjs`
|
|
427
|
+
* exists precisely so the browser builder can run this loop), and
|
|
428
|
+
* `runInIsolatedCopy` would `cpSync` from `undefined`. Refusing here names
|
|
429
|
+
* the cause; failing inside the copier would name a path that never existed.
|
|
430
|
+
*/
|
|
431
|
+
if (wantsWrite && typeof args.executor?.root !== 'string') {
|
|
432
|
+
return { ok: false, error: 'a building helper needs a real workspace on disk, and this run has none — do this part yourself' };
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const build = wantsWrite ? (isolateImpl ?? runInIsolatedCopy) : null;
|
|
436
|
+
const buildExecutor = wantsWrite ? (makeExecutor ?? createLocalExecutor) : null;
|
|
437
|
+
|
|
438
|
+
/** Everything both modes pass, so the two call sites cannot drift apart. */
|
|
439
|
+
const sessionArgs = {
|
|
440
|
+
/**
|
|
441
|
+
* ⭐ THE BRIEF, NOT THE BARE TASK. `briefFor` returns `task` unchanged
|
|
442
|
+
* when no context was supplied, so a caller that never heard of the
|
|
443
|
+
* channel sends byte-for-byte what it sent before — which matters more
|
|
444
|
+
* than usual here, because the task text is the head of the helper's
|
|
445
|
+
* cacheable prefix.
|
|
446
|
+
*/
|
|
447
|
+
task: briefFor(task, args.context),
|
|
448
|
+
executor: args.executor,
|
|
449
|
+
config: args.config,
|
|
450
|
+
maxRounds,
|
|
451
|
+
maxTokens: args.maxTokens,
|
|
452
|
+
timeoutMs: args.timeoutMs,
|
|
453
|
+
/**
|
|
454
|
+
* ── ⚠️⚠️ `allowRun` WAS false IN BOTH MODES, AND THE REASON WAS THE COPY ─
|
|
455
|
+
*
|
|
456
|
+
* The reasoning it shipped with, kept verbatim because it was CORRECT:
|
|
457
|
+
* *"a builder still executes nothing: the isolated copy has no
|
|
458
|
+
* `node_modules` and no `.git` (`COPY_SKIP_DIRS`), so a verification
|
|
459
|
+
* command run in there would fail for a reason that has nothing to do with
|
|
460
|
+
* the code, and the parent would act on that answer."*
|
|
461
|
+
*
|
|
462
|
+
* ⭐ THAT IS AN ARGUMENT ABOUT THE COPY. It said the helper cannot verify
|
|
463
|
+
* because the copy cannot support a verification — not that a helper
|
|
464
|
+
* should not. So the copy changed: with `verify: true`,
|
|
465
|
+
* `runInIsolatedCopy` links the real `node_modules` in (see its comment,
|
|
466
|
+
* and the measurement proving the recursive delete does not follow it),
|
|
467
|
+
* and `npm test` in there means what it says.
|
|
468
|
+
*
|
|
469
|
+
* ⚠️ `.git` IS STILL ABSENT AND THE GIT VERBS ARE STILL NOT OFFERED. This
|
|
470
|
+
* change buys exactly one thing — running the project's own checks — and
|
|
471
|
+
* nothing about committing, which the helper still cannot do.
|
|
472
|
+
*
|
|
473
|
+
* ⚠️ AND IT IS STILL false BY DEFAULT. A `write: true` helper that did not
|
|
474
|
+
* ask for this gets the unlinked copy and the old lock, so the sentence
|
|
475
|
+
* above remains true of it word for word.
|
|
476
|
+
*/
|
|
477
|
+
allowRun: wantsVerify,
|
|
478
|
+
toolNames: wantsVerify
|
|
479
|
+
? [...SUBAGENT_VERIFY_TOOL_NAMES]
|
|
480
|
+
: (wantsWrite ? [...SUBAGENT_WRITE_TOOL_NAMES] : [...SUBAGENT_TOOL_NAMES]),
|
|
481
|
+
/**
|
|
482
|
+
* ⚠️ THE HELPER'S OWN DISPATCHER MUST KNOW IT IS A HELPER. The offer
|
|
483
|
+
* already omits `delegate`, but a model can emit a call for a tool it was
|
|
484
|
+
* never shown — so the depth travels with the session and the dispatcher
|
|
485
|
+
* refuses independently. Two locks, because one of them is a list.
|
|
486
|
+
*/
|
|
487
|
+
depth: depth + 1,
|
|
488
|
+
/**
|
|
489
|
+
* ── ⚠️⚠️ THE HELPER USED TO SPEND OUTSIDE EVERY CEILING ────────────────
|
|
490
|
+
*
|
|
491
|
+
* `runSession` defaults `budgetUsd` to null, and nothing here passed one.
|
|
492
|
+
* So the moment the model called `delegate`, the one differentiator this
|
|
493
|
+
* package actually claims — *tell me the price before it runs and stop at
|
|
494
|
+
* the number you gave me* — became false: the parent stopped at its
|
|
495
|
+
* ceiling, and the helper it spawned had none at all. The cost came back
|
|
496
|
+
* in `costUsd` and was reported, so the money was MEASURED and simply not
|
|
497
|
+
* BOUNDED, which is the most misleading of the three possible states.
|
|
498
|
+
*
|
|
499
|
+
* ⭐ THE HELPER'S CEILING IS THE PARENT'S REMAINDER, not a fraction of it.
|
|
500
|
+
* A fraction would be an invented constant to defend; the remainder makes
|
|
501
|
+
* the arithmetic self-evident — the helper cannot spend money the run does
|
|
502
|
+
* not have, so the TOTAL is still the number the user typed, which is the
|
|
503
|
+
* only number that was ever promised. `null` (a run with `--budget none`)
|
|
504
|
+
* passes through as unbounded, exactly as before.
|
|
505
|
+
*/
|
|
506
|
+
budgetUsd: Number.isFinite(args.budgetUsd) ? args.budgetUsd : null,
|
|
507
|
+
/**
|
|
508
|
+
* ⭐ And the fleet ceiling travels too, or seven terminals could each
|
|
509
|
+
* delegate their way around the workspace-wide cap.
|
|
510
|
+
*/
|
|
511
|
+
fleetGate: args.fleetGate ?? null,
|
|
512
|
+
onEvent: args.onEvent,
|
|
513
|
+
};
|
|
514
|
+
|
|
515
|
+
let outcome;
|
|
516
|
+
/**
|
|
517
|
+
* ⚠️ DECLARED OUT HERE, NOT INSIDE THE BRANCH. The parent's dispatcher needs
|
|
518
|
+
* these to mark its own record `mutated` — and a record that is silent about
|
|
519
|
+
* the files a helper changed is exactly the half-connected defect this
|
|
520
|
+
* package keeps shipping: the run summary would say "0 files written" over
|
|
521
|
+
* work that is on disk.
|
|
522
|
+
*/
|
|
523
|
+
let handoff = null;
|
|
524
|
+
/** ⚠️ Only ever true when `verify` was asked for AND the link actually took. */
|
|
525
|
+
let dependenciesLinked = false;
|
|
526
|
+
try {
|
|
527
|
+
if (build) {
|
|
528
|
+
const isolated = await build({
|
|
529
|
+
root: args.executor.root,
|
|
530
|
+
executor: args.executor,
|
|
531
|
+
/**
|
|
532
|
+
* ⭐ THE DEPENDENCIES TRAVEL ONLY WHEN THERE IS SOMETHING TO RUN. A
|
|
533
|
+
* `write: true` helper that did not ask to verify gets exactly the copy
|
|
534
|
+
* it always got, so the one thing this flag can change is the one thing
|
|
535
|
+
* it is for.
|
|
536
|
+
*/
|
|
537
|
+
linkNodeModules: wantsVerify,
|
|
538
|
+
/**
|
|
539
|
+
* ── ⭐⭐ THE HELPER'S EXECUTOR IS **NEVER** A DRY RUN, AND I HAD THIS
|
|
540
|
+
* BACKWARDS ─────────────────────────────────────────────────────────
|
|
541
|
+
*
|
|
542
|
+
* My first version inherited `args.executor.dryRun` into the copy,
|
|
543
|
+
* reasoning that a `--dry-run` parent should produce a previewing
|
|
544
|
+
* helper. A mutation test SURVIVED, which is how I found out the test
|
|
545
|
+
* was passing for the wrong reason, and the probe that followed showed
|
|
546
|
+
* the inheritance was not merely untested but WRONG. Measured, with a
|
|
547
|
+
* `--dry-run` parent and a helper that wrote two files:
|
|
548
|
+
*
|
|
549
|
+
* applied : ["existing.txt"] ← the ORIGINAL bytes, not the
|
|
550
|
+
* helper's — a preview of
|
|
551
|
+
* nothing
|
|
552
|
+
* ⚠ brand-new.txt: no such file — nothing was deleted
|
|
553
|
+
* ← a file the helper CREATED,
|
|
554
|
+
* reported as a failed DELETE
|
|
555
|
+
*
|
|
556
|
+
* ⭐ Because a dry-run write is a no-op, the file never appeared in the
|
|
557
|
+
* copy, so `applyHandoff` saw an absent source and correctly concluded
|
|
558
|
+
* "the helper deleted this" — the right inference from a lie.
|
|
559
|
+
*
|
|
560
|
+
* ⭐⭐ THE COPY IS SCRATCH SPACE. There is nothing in it to protect, and
|
|
561
|
+
* the whole point of copying was that writes there are free. The
|
|
562
|
+
* `--dry-run` PROMISE is about the USER'S files, and it is kept where
|
|
563
|
+
* it belongs: at the apply boundary, by `executor.writeFile` on the
|
|
564
|
+
* PARENT (`workspace.mjs:653`), which validates everything and then
|
|
565
|
+
* declines to touch the disk. So a dry run now previews the real work
|
|
566
|
+
* instead of previewing an empty directory.
|
|
567
|
+
*/
|
|
568
|
+
run: (copyRoot) => run({
|
|
569
|
+
...sessionArgs,
|
|
570
|
+
executor: buildExecutor(copyRoot),
|
|
571
|
+
}),
|
|
572
|
+
});
|
|
573
|
+
if (!isolated.ok) return { ok: false, error: isolated.error, costUsd: 0, tokens: 0, roundsUsed: 0, files: [] };
|
|
574
|
+
outcome = isolated.outcome;
|
|
575
|
+
/**
|
|
576
|
+
* ⚠️⚠️ A VERIFICATION THAT COULD NOT HAVE WORKED MUST NOT BE REPORTED AS
|
|
577
|
+
* ONE. If the link failed — no `node_modules` to link, a filesystem that
|
|
578
|
+
* refuses links — every command in the copy failed with "Cannot find
|
|
579
|
+
* module", which is exactly the wrong-reason failure the old `allowRun:
|
|
580
|
+
* false` existed to avoid. Carrying the flag out here is what lets the
|
|
581
|
+
* summary say so instead of handing the parent a red verdict about the
|
|
582
|
+
* environment dressed as a red verdict about the code.
|
|
583
|
+
*/
|
|
584
|
+
dependenciesLinked = isolated.dependenciesLinked === true;
|
|
585
|
+
handoff = { written: isolated.written, refused: isolated.refused, problems: isolated.problems };
|
|
586
|
+
} else {
|
|
587
|
+
outcome = await run(sessionArgs);
|
|
588
|
+
}
|
|
589
|
+
} catch (err) {
|
|
590
|
+
// ⚠️ A helper's death must not take the parent's session with it.
|
|
591
|
+
return { ok: false, error: `the helper crashed: ${err?.message ?? String(err)}` };
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
const usage = outcome?.usage ?? null;
|
|
595
|
+
const shared = {
|
|
596
|
+
costUsd: Number.isFinite(usage?.cost) ? usage.cost : 0,
|
|
597
|
+
tokens: Number.isFinite(usage?.total_tokens) ? usage.total_tokens : 0,
|
|
598
|
+
roundsUsed: Number.isFinite(outcome?.roundsUsed) ? outcome.roundsUsed : 0,
|
|
599
|
+
files: filesTouched(outcome?.executed),
|
|
600
|
+
/**
|
|
601
|
+
* ⚠️⚠️ REPORTED ON THE FAILURE PATH TOO, and this is not tidiness. A
|
|
602
|
+
* builder that wrote three files and then ran out of rounds has ALREADY
|
|
603
|
+
* had those three files applied to the real workspace — the apply happens
|
|
604
|
+
* the moment the session returns, whatever the session's verdict. A caller
|
|
605
|
+
* told "it failed" and given an empty change list would leave real edits
|
|
606
|
+
* unmentioned on someone's disk, which is the worst of the three states.
|
|
607
|
+
*/
|
|
608
|
+
written: handoff?.written ?? [],
|
|
609
|
+
refused: handoff?.refused ?? [],
|
|
610
|
+
};
|
|
611
|
+
|
|
612
|
+
if (!outcome || outcome.ok !== true) {
|
|
613
|
+
return { ok: false, error: outcome?.error ?? 'the helper returned no result', ...shared };
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* ⚠️ THE HONEST DEGRADATION, AND IT IS ONE SENTENCE. `verify: true` with no
|
|
618
|
+
* dependencies to link is a helper that COULD run commands and whose every
|
|
619
|
+
* import would have failed. Saying nothing would let the parent read a red
|
|
620
|
+
* `npm test` as a broken change; saying this makes it a broken environment,
|
|
621
|
+
* which is a completely different next move.
|
|
622
|
+
*/
|
|
623
|
+
const degraded = wantsVerify && !dependenciesLinked
|
|
624
|
+
? '\n⚠ Its isolated copy had no dependencies linked, so anything it ran there could not resolve imports — '
|
|
625
|
+
+ 'treat any command result from it as inconclusive and verify in the real workspace yourself.'
|
|
626
|
+
: '';
|
|
627
|
+
|
|
628
|
+
return {
|
|
629
|
+
ok: true,
|
|
630
|
+
summary: `${summariseForParent(outcome, handoff, { verifyOffered: wantsVerify })}${degraded}`,
|
|
631
|
+
verified: wantsVerify && dependenciesLinked ? (outcome?.verification ?? null) : null,
|
|
632
|
+
...shared,
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* ⭐ THE DESCRIPTION IS WHERE THE JUDGEMENT LIVES. A model that does not know it
|
|
638
|
+
* gets a SUMMARY back will delegate and then ask for the same files anyway,
|
|
639
|
+
* paying twice. So the contract is stated in the first sentence, and the good
|
|
640
|
+
* use — a broad search whose answer is small — is named explicitly.
|
|
641
|
+
*/
|
|
642
|
+
export function subagentToolSchemas() {
|
|
643
|
+
return [
|
|
644
|
+
{
|
|
645
|
+
type: 'function',
|
|
646
|
+
function: {
|
|
647
|
+
name: 'delegate',
|
|
648
|
+
description:
|
|
649
|
+
'Hand a self-contained piece of work to a helper with its own fresh context; you get back a short '
|
|
650
|
+
+ 'SUMMARY, not everything it read. Default it only READS — best when answering would mean opening '
|
|
651
|
+
+ 'many files to produce a small answer ("where is X defined", "which files call Y"). With '
|
|
652
|
+
+ 'write:true it also BUILDS a piece you have already specified ("write the tests for parser.mjs"), and '
|
|
653
|
+
+ 'write+verify runs the project\'s own check inside its copy. Pass `context` with anything you have '
|
|
654
|
+
+ 'already worked out — it starts with a blank head. It cannot delegate further.',
|
|
655
|
+
parameters: {
|
|
656
|
+
type: 'object',
|
|
657
|
+
additionalProperties: false,
|
|
658
|
+
required: ['task'],
|
|
659
|
+
properties: {
|
|
660
|
+
task: {
|
|
661
|
+
type: 'string',
|
|
662
|
+
description: 'One specific question, with enough detail to answer without asking you anything back.',
|
|
663
|
+
},
|
|
664
|
+
/**
|
|
665
|
+
* ── ⭐⭐ THE INPUT HALF OF THE CONTRACT, WHICH NEVER EXISTED ───────
|
|
666
|
+
*
|
|
667
|
+
* Rule 3 of this file governs what comes BACK (distilled, never the
|
|
668
|
+
* transcript). Nothing ever governed what goes IN, so the answer was
|
|
669
|
+
* "one sentence" by default rather than by decision — and a helper
|
|
670
|
+
* that does not know what the parent has established re-derives it,
|
|
671
|
+
* out of an allowance of four rounds.
|
|
672
|
+
*
|
|
673
|
+
* ⚠️ THE DESCRIPTION HAS TO SAY WHAT *NOT* TO PUT HERE. The cheapest
|
|
674
|
+
* way for a model to "give context" is to paste a file, which would
|
|
675
|
+
* turn the cheapest tool in the package into the most expensive —
|
|
676
|
+
* the exact cost `delegate` exists to avoid. So it names findings
|
|
677
|
+
* and constraints, and rules out file contents explicitly.
|
|
678
|
+
*/
|
|
679
|
+
context: {
|
|
680
|
+
type: 'string',
|
|
681
|
+
description:
|
|
682
|
+
'What you already know that it would otherwise spend rounds rediscovering: findings so far, '
|
|
683
|
+
+ 'constraints, decisions already made, names of the files that matter. NOT file contents — it '
|
|
684
|
+
+ 'can read those itself, and pasting them here costs you the saving you delegated for. '
|
|
685
|
+
+ `Trimmed at ${MAX_CONTEXT_CHARS} characters.`,
|
|
686
|
+
},
|
|
687
|
+
/**
|
|
688
|
+
* ── ⭐ A BOOLEAN ON AN EXISTING TOOL, NOT A SECOND TOOL ──────────
|
|
689
|
+
*
|
|
690
|
+
* ⚠️ AND HERE IS THE MEASURED PRICE, because my first version of
|
|
691
|
+
* this comment guessed "roughly 60 tokens" and the guess was out by
|
|
692
|
+
* more than 4x. MEASURED with `JSON.stringify` on the real schema
|
|
693
|
+
* (~3.6 chars/token): the `delegate` schema was **771 chars / ~214
|
|
694
|
+
* tokens** and is now **1,239 chars / ~344 tokens** — a delta of
|
|
695
|
+
* **468 chars, ~130 tokens on every round of every run**. The first
|
|
696
|
+
* draft of this text measured ~264 tokens; re-measuring it is what
|
|
697
|
+
* prompted the trim, which is the entire argument for measuring
|
|
698
|
+
* rather than asserting.
|
|
699
|
+
*
|
|
700
|
+
* ⭐ IT IS STILL THE CHEAPER SHAPE. A separate `delegate_build`
|
|
701
|
+
* tool would cost ~250-300 tokens ON TOP of the 214 this one
|
|
702
|
+
* already costs — call it ~500 against ~344 — and it would leave
|
|
703
|
+
* two schemas sharing `task`, `maxRounds`, the depth rule and the
|
|
704
|
+
* budget rule, which is two documents to keep in agreement. This
|
|
705
|
+
* package has three recorded cases of exactly that drifting.
|
|
706
|
+
*
|
|
707
|
+
* ⭐ AND IT SITS IN THE CACHED PREFIX. Tool schemas are identical
|
|
708
|
+
* byte-for-byte on every round, so the delta is paid at full price
|
|
709
|
+
* once and at cache rates (up to 50x less) thereafter.
|
|
710
|
+
*
|
|
711
|
+
* ⚠️ THE DESCRIPTION MUST SAY WHEN *NOT* TO USE IT. A model that
|
|
712
|
+
* delegates the whole task builds nothing itself and cannot verify
|
|
713
|
+
* what came back, because the helper never ran a command.
|
|
714
|
+
*/
|
|
715
|
+
write: {
|
|
716
|
+
type: 'boolean',
|
|
717
|
+
description:
|
|
718
|
+
'Default false. True lets the helper CHANGE FILES — it works in an isolated copy and its '
|
|
719
|
+
+ 'changes are applied back for you. Only for a bounded, fully-specified piece: never the '
|
|
720
|
+
+ 'whole task, and never while you are still deciding what to build. A file that changed '
|
|
721
|
+
+ 'underneath it is refused, not overwritten, and the summary names it. Nothing is verified '
|
|
722
|
+
+ 'unless you also pass verify.',
|
|
723
|
+
},
|
|
724
|
+
/**
|
|
725
|
+
* ⚠️ THE DESCRIPTION SAYS THE COPY IS WHERE IT RUNS, AND IT MUST.
|
|
726
|
+
* A model told only "it can verify" would ask for a command whose
|
|
727
|
+
* effects it expects to see in the real workspace — a server it
|
|
728
|
+
* started, a file its script wrote outside the change set. Only the
|
|
729
|
+
* change set comes back, and the sentence has to say so or the
|
|
730
|
+
* capability quietly lies about its own scope.
|
|
731
|
+
*/
|
|
732
|
+
verify: {
|
|
733
|
+
type: 'boolean',
|
|
734
|
+
description:
|
|
735
|
+
'Default false, and only meaningful with write. True lets the helper RUN the project\'s own '
|
|
736
|
+
+ 'check (e.g. `npm test`) inside its isolated copy, with the real dependencies linked in, and '
|
|
737
|
+
+ 'fix what it broke before handing back. The verdict comes back in the summary. Only the files '
|
|
738
|
+
+ 'it changed are applied — anything else the command did happens in the copy and is discarded.',
|
|
739
|
+
},
|
|
740
|
+
maxRounds: {
|
|
741
|
+
type: 'integer',
|
|
742
|
+
description: `How many rounds it may spend, 1-${MAX_SUBAGENT_ROUNDS} (default ${DEFAULT_SUBAGENT_ROUNDS}, or ${MAX_SUBAGENT_ROUNDS} when verifying).`,
|
|
743
|
+
},
|
|
744
|
+
},
|
|
745
|
+
},
|
|
746
|
+
},
|
|
747
|
+
},
|
|
748
|
+
];
|
|
749
|
+
}
|