@aragon-agent/cli 0.6.38 → 0.6.40
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 +263 -0
- package/README.md +544 -11
- package/dist/agent/controller.d.ts +245 -10
- package/dist/agent/controller.js +367 -22
- package/dist/agent/model-profile-settings.js +12 -1
- package/dist/agent/plan-prompt.d.ts +1 -3
- package/dist/agent/plan-prompt.js +13 -7
- package/dist/agent/reducer.d.ts +70 -2
- package/dist/agent/reducer.js +59 -6
- package/dist/agent/skill-triage-prompt.d.ts +42 -0
- package/dist/agent/skill-triage-prompt.js +74 -0
- package/dist/agent/system-prompt.d.ts +28 -2
- package/dist/agent/system-prompt.js +12 -4
- package/dist/cli.js +106 -32
- package/dist/commands/builtins.js +24 -3
- package/dist/commands/keys-command.d.ts +23 -0
- package/dist/commands/keys-command.js +38 -0
- package/dist/commands/registry.d.ts +39 -0
- package/dist/commands/skills.js +86 -1
- package/dist/commands/subagent-command.d.ts +25 -0
- package/dist/commands/subagent-command.js +242 -0
- package/dist/commands/task-command.d.ts +39 -0
- package/dist/commands/task-command.js +102 -0
- package/dist/compaction/command.js +16 -3
- package/dist/compaction/compactor.d.ts +25 -0
- package/dist/compaction/compactor.js +111 -10
- package/dist/compaction/digest.d.ts +24 -1
- package/dist/compaction/digest.js +139 -17
- package/dist/compaction/limits.d.ts +21 -0
- package/dist/compaction/limits.js +21 -0
- package/dist/compaction/reason-text.d.ts +36 -0
- package/dist/compaction/reason-text.js +78 -0
- package/dist/compaction/types.d.ts +82 -14
- package/dist/compaction/wiring.d.ts +13 -8
- package/dist/compaction/wiring.js +1 -0
- package/dist/config/cli-commands.d.ts +23 -0
- package/dist/config/cli-commands.js +36 -0
- package/dist/config/env.js +17 -4
- package/dist/config/load.js +55 -3
- package/dist/config/schema.d.ts +125 -10
- package/dist/config/schema.js +138 -14
- package/dist/config/store.js +23 -3
- package/dist/diagnostics/doctor.d.ts +9 -0
- package/dist/diagnostics/doctor.js +34 -0
- package/dist/diagnostics/info.d.ts +71 -0
- package/dist/diagnostics/info.js +91 -5
- package/dist/exec/bridge/caller-tools.d.ts +96 -0
- package/dist/exec/bridge/caller-tools.js +213 -0
- package/dist/exec/bridge/decisions.d.ts +78 -0
- package/dist/exec/bridge/decisions.js +116 -0
- package/dist/exec/bridge/human-answers.d.ts +38 -0
- package/dist/exec/bridge/human-answers.js +164 -0
- package/dist/exec/bridge/limits.d.ts +68 -0
- package/dist/exec/bridge/limits.js +68 -0
- package/dist/exec/bridge/ndjson.d.ts +17 -0
- package/dist/exec/bridge/ndjson.js +83 -0
- package/dist/exec/bridge/pending.d.ts +70 -0
- package/dist/exec/bridge/pending.js +132 -0
- package/dist/exec/bridge/protocol.d.ts +346 -0
- package/dist/exec/bridge/protocol.js +256 -0
- package/dist/exec/bridge/server.d.ts +142 -0
- package/dist/exec/bridge/server.js +502 -0
- package/dist/exec/bridge/source.d.ts +58 -0
- package/dist/exec/bridge/source.js +145 -0
- package/dist/exec/bridge/writer.d.ts +26 -0
- package/dist/exec/bridge/writer.js +156 -0
- package/dist/exec/cli-commands.js +30 -2
- package/dist/exec/emitter.d.ts +16 -2
- package/dist/exec/emitter.js +26 -6
- package/dist/exec/events.d.ts +465 -10
- package/dist/exec/events.js +47 -1
- package/dist/exec/hooks/dispatch.d.ts +76 -0
- package/dist/exec/hooks/dispatch.js +169 -0
- package/dist/exec/hooks/index.d.ts +83 -0
- package/dist/exec/hooks/index.js +205 -0
- package/dist/exec/hooks/limits.d.ts +56 -0
- package/dist/exec/hooks/limits.js +49 -0
- package/dist/exec/hooks/registry.d.ts +49 -0
- package/dist/exec/hooks/registry.js +91 -0
- package/dist/exec/hooks/tool-wrapper.d.ts +94 -0
- package/dist/exec/hooks/tool-wrapper.js +154 -0
- package/dist/exec/hooks/turn.d.ts +61 -0
- package/dist/exec/hooks/turn.js +59 -0
- package/dist/exec/hooks/validate.d.ts +85 -0
- package/dist/exec/hooks/validate.js +310 -0
- package/dist/exec/index.d.ts +130 -4
- package/dist/exec/index.js +501 -10
- package/dist/exec/options.d.ts +124 -2
- package/dist/exec/options.js +413 -6
- package/dist/exec/permission.d.ts +9 -1
- package/dist/exec/permission.js +7 -1
- package/dist/exec/request/limits.d.ts +51 -0
- package/dist/exec/request/limits.js +49 -0
- package/dist/exec/request/scope.d.ts +106 -0
- package/dist/exec/request/scope.js +149 -0
- package/dist/exec/result/limits.d.ts +74 -0
- package/dist/exec/result/limits.js +72 -0
- package/dist/exec/result/prompt.d.ts +28 -0
- package/dist/exec/result/prompt.js +74 -0
- package/dist/exec/result/schema.d.ts +99 -0
- package/dist/exec/result/schema.js +464 -0
- package/dist/exec/result/sink.d.ts +56 -0
- package/dist/exec/result/sink.js +50 -0
- package/dist/exec/result/tool.d.ts +37 -0
- package/dist/exec/result/tool.js +118 -0
- package/dist/exec/runner.d.ts +395 -1
- package/dist/exec/runner.js +860 -8
- package/dist/exec/threads/aggregate.d.ts +34 -0
- package/dist/exec/threads/aggregate.js +30 -0
- package/dist/exec/threads/capacity.d.ts +23 -0
- package/dist/exec/threads/capacity.js +38 -0
- package/dist/exec/threads/dispatch-source.d.ts +60 -0
- package/dist/exec/threads/dispatch-source.js +106 -0
- package/dist/exec/threads/execute-threads.d.ts +53 -0
- package/dist/exec/threads/execute-threads.js +234 -0
- package/dist/exec/threads/gate.d.ts +42 -0
- package/dist/exec/threads/gate.js +81 -0
- package/dist/exec/threads/identity.d.ts +36 -0
- package/dist/exec/threads/identity.js +67 -0
- package/dist/exec/threads/interrupt.d.ts +29 -0
- package/dist/exec/threads/interrupt.js +79 -0
- package/dist/exec/threads/limits.d.ts +58 -0
- package/dist/exec/threads/limits.js +35 -0
- package/dist/exec/threads/outcome.d.ts +47 -0
- package/dist/exec/threads/outcome.js +26 -0
- package/dist/exec/threads/registry.d.ts +81 -0
- package/dist/exec/threads/registry.js +427 -0
- package/dist/exec/threads/runtime.d.ts +107 -0
- package/dist/exec/threads/runtime.js +523 -0
- package/dist/exec/threads/thread-source.d.ts +20 -0
- package/dist/exec/threads/thread-source.js +134 -0
- package/dist/fast/limits.d.ts +54 -0
- package/dist/fast/limits.js +54 -0
- package/dist/fast/summary-call.d.ts +72 -0
- package/dist/fast/summary-call.js +133 -0
- package/dist/fast/summary-prompt.d.ts +106 -0
- package/dist/fast/summary-prompt.js +320 -0
- package/dist/fast/task-summary-types.d.ts +66 -0
- package/dist/fast/task-summary-types.js +17 -0
- package/dist/logging/logger.d.ts +1 -1
- package/dist/proc/limits.d.ts +7 -0
- package/dist/proc/limits.js +7 -0
- package/dist/proc/log-mirror.d.ts +21 -4
- package/dist/proc/log-mirror.js +29 -7
- package/dist/proc/output-streams.d.ts +50 -0
- package/dist/proc/output-streams.js +54 -0
- package/dist/skills/service.d.ts +41 -0
- package/dist/skills/service.js +62 -0
- package/dist/skills/triage.d.ts +194 -0
- package/dist/skills/triage.js +393 -0
- package/dist/team/forced-dispatch.d.ts +95 -0
- package/dist/team/forced-dispatch.js +208 -0
- package/dist/team/limits.d.ts +21 -1
- package/dist/team/limits.js +21 -1
- package/dist/team/normalize.d.ts +15 -0
- package/dist/team/normalize.js +21 -4
- package/dist/team/prompt.d.ts +6 -3
- package/dist/team/prompt.js +9 -5
- package/dist/team/runtime.d.ts +9 -0
- package/dist/team/runtime.js +8 -0
- package/dist/team/subagent.d.ts +15 -0
- package/dist/team/subagent.js +45 -1
- package/dist/team/task-tool.js +11 -7
- package/dist/tools/bash-tool.d.ts +10 -0
- package/dist/tools/bash-tool.js +71 -6
- package/dist/tools/human-input.d.ts +35 -2
- package/dist/tools/human-input.js +38 -0
- package/dist/tools/index.d.ts +93 -1
- package/dist/tools/index.js +112 -3
- package/dist/tools/plan-tools.d.ts +20 -12
- package/dist/tools/plan-tools.js +24 -17
- package/dist/tools/tool-gate.d.ts +100 -0
- package/dist/tools/tool-gate.js +87 -0
- package/dist/ui/App.js +238 -12
- package/dist/ui/Header.d.ts +28 -16
- package/dist/ui/Header.js +21 -19
- package/dist/ui/StatusBar.d.ts +1 -0
- package/dist/ui/StatusBar.js +1 -0
- package/dist/ui/Transcript.js +1 -1
- package/dist/ui/entries/CompactionCard.d.ts +11 -1
- package/dist/ui/entries/CompactionCard.js +65 -4
- package/dist/ui/interaction-hints.js +6 -0
- package/dist/ui/layout/OverlayFrame.d.ts +18 -0
- package/dist/ui/layout/OverlayFrame.js +19 -4
- package/dist/ui/layout/header-hints.d.ts +55 -62
- package/dist/ui/layout/header-hints.js +59 -166
- package/dist/ui/layout/header-left.d.ts +115 -0
- package/dist/ui/layout/header-left.js +206 -0
- package/dist/ui/layout/shortcut-panel-layout.d.ts +191 -0
- package/dist/ui/layout/shortcut-panel-layout.js +321 -0
- package/dist/ui/layout/status-detail-layout.js +5 -1
- package/dist/ui/layout/status-layout.d.ts +10 -0
- package/dist/ui/layout/status-layout.js +18 -1
- package/dist/ui/layout/task-summary-cell.d.ts +61 -0
- package/dist/ui/layout/task-summary-cell.js +65 -0
- package/dist/ui/overlays/HelpOverlay.d.ts +12 -0
- package/dist/ui/overlays/HelpOverlay.js +29 -2
- package/dist/ui/overlays/SettingsScreen.js +29 -5
- package/dist/ui/overlays/ShortcutPanel.d.ts +39 -0
- package/dist/ui/overlays/ShortcutPanel.js +106 -0
- package/dist/ui/overlays/TeamFanoutPage.d.ts +35 -0
- package/dist/ui/overlays/TeamFanoutPage.js +78 -0
- package/dist/ui/overlays/model-profile-state.d.ts +19 -1
- package/dist/ui/overlays/model-profile-state.js +12 -0
- package/dist/ui/overlays/team-fanout-state.d.ts +73 -0
- package/dist/ui/overlays/team-fanout-state.js +97 -0
- package/dist/ui/shortcuts.d.ts +81 -0
- package/dist/ui/shortcuts.js +221 -0
- package/package.json +2 -2
package/dist/tools/bash-tool.js
CHANGED
|
@@ -19,12 +19,22 @@
|
|
|
19
19
|
* inheriting the pipe — `Start-Process`, `&`, `nohup`, and every
|
|
20
20
|
* `npm run dev` that re-execs — keeps that pipe open after the shell is
|
|
21
21
|
* long gone. The shell exited; the promise did not.
|
|
22
|
+
*
|
|
22
23
|
* 2. `stdio` defaulted to `'pipe'` for stdin, so a command that PROMPTS —
|
|
23
24
|
* `npm init`, a package manager asking to install something, `sudo`, a git
|
|
24
25
|
* credential prompt — blocked on a read that could never be satisfied.
|
|
25
26
|
* 3. An abort killed the tree and then went on waiting for a `'close'` that a
|
|
26
27
|
* detached grandchild was still holding.
|
|
27
28
|
*
|
|
29
|
+
* WHY THE DRAIN WINDOW IS A BOUND AND NOT A VERDICT. The first build called
|
|
30
|
+
* anything still unsettled when that window closed "a held pipe" and printed
|
|
31
|
+
* the `'exit'` drain note on the strength of it. Under load the two disagree:
|
|
32
|
+
* the release gate caught a command that had printed two lines and exited
|
|
33
|
+
* immediately being reported as holding its output stream, because the timers
|
|
34
|
+
* phase came round before libuv delivered the close notification. The note is a
|
|
35
|
+
* claim about STATE, so it is decided by asking the streams
|
|
36
|
+
* (`proc/output-streams.ts`) instead of guessing from a clock.
|
|
37
|
+
*
|
|
28
38
|
* The happy path is BYTE-IDENTICAL to the pre-change build: `$ cmd\n<output>\n
|
|
29
39
|
* [exit code N]`, same truncation, same footer. That is what keeps the existing
|
|
30
40
|
* `bash` assertions meaningful rather than rewritten.
|
|
@@ -35,6 +45,7 @@ import { defineTool, errorResult, textResult, } from '@aragon-agent/core';
|
|
|
35
45
|
import { looksLongRunning } from '../proc/classify.js';
|
|
36
46
|
import { killTree } from '../proc/kill-tree.js';
|
|
37
47
|
import { PROC_LIMITS } from '../proc/limits.js';
|
|
48
|
+
import { hasOpenOutputStreams } from '../proc/output-streams.js';
|
|
38
49
|
import { isTerminalStatus } from '../proc/types.js';
|
|
39
50
|
/**
|
|
40
51
|
* The two sentences appended to the tool description when background launches
|
|
@@ -285,6 +296,14 @@ export function makeBash(deps, options = {}) {
|
|
|
285
296
|
* the same choice for its `killedByTimeout` flag, for the same reason.
|
|
286
297
|
*/
|
|
287
298
|
let killReason;
|
|
299
|
+
/**
|
|
300
|
+
* The exit code, remembered the moment `'exit'` reports it.
|
|
301
|
+
*
|
|
302
|
+
* THREE PATHS SETTLE THE ORDINARY CASE NOW - the two streams ending, the
|
|
303
|
+
* `'close'` event, and the drain bound - and all three must build the same
|
|
304
|
+
* body, so none of them may depend on having been handed the code itself.
|
|
305
|
+
*/
|
|
306
|
+
let exitInfo = null;
|
|
288
307
|
/**
|
|
289
308
|
* The result body, from whatever is known at settle time.
|
|
290
309
|
*
|
|
@@ -342,6 +361,25 @@ export function makeBash(deps, options = {}) {
|
|
|
342
361
|
releaseForeground();
|
|
343
362
|
resolvePromise(build());
|
|
344
363
|
};
|
|
364
|
+
/** See `proc/output-streams.ts` for why this is asked and not assumed. */
|
|
365
|
+
const streamsStillOpen = () => hasOpenOutputStreams(child);
|
|
366
|
+
/**
|
|
367
|
+
* THE ORDINARY BODY, built from whatever the streams say at this instant.
|
|
368
|
+
*
|
|
369
|
+
* Without a held pipe it is byte-identical to the `'close'` path - no note -
|
|
370
|
+
* which is what AC-30 measures. With one it is the honest diagnosis, and the
|
|
371
|
+
* sentence is only ever printed while it is TRUE.
|
|
372
|
+
*/
|
|
373
|
+
const settleOrdinary = () => {
|
|
374
|
+
const info = exitInfo;
|
|
375
|
+
if (!info)
|
|
376
|
+
return;
|
|
377
|
+
settle(() => buildBody({
|
|
378
|
+
code: info.code,
|
|
379
|
+
signalName: info.signalName,
|
|
380
|
+
note: streamsStillOpen(),
|
|
381
|
+
}));
|
|
382
|
+
};
|
|
345
383
|
const captured = () => (output.length > 0 ? output : '(no output)');
|
|
346
384
|
/**
|
|
347
385
|
* Arm the post-kill force-settle. AT MOST ONCE.
|
|
@@ -421,16 +459,43 @@ export function makeBash(deps, options = {}) {
|
|
|
421
459
|
settle(() => errorResult(`Failed to run command: ${err.message}`));
|
|
422
460
|
});
|
|
423
461
|
/**
|
|
424
|
-
* DEFENCE 1. `'exit'` fires when the PROCESS ends; `'close'` waits for
|
|
425
|
-
*
|
|
426
|
-
*
|
|
427
|
-
*
|
|
462
|
+
* DEFENCE 1. `'exit'` fires when the PROCESS ends; `'close'` waits for every
|
|
463
|
+
* stdio stream, which a detached grandchild can hold open forever. So `'exit'`
|
|
464
|
+
* starts a short drain and `'close'` - if it arrives - wins the race and
|
|
465
|
+
* produces the byte-identical happy-path body.
|
|
466
|
+
*
|
|
467
|
+
* AND THE COMMON CASE NO LONGER WAITS FOR THAT RACE AT ALL. Two ended streams
|
|
468
|
+
* after `'exit'` mean not one more byte can arrive, so the body `'close'` would
|
|
469
|
+
* build is already known: settle it there. That removes the only window in which
|
|
470
|
+
* a busy event loop could hand the timer a verdict the pipes had already
|
|
471
|
+
* contradicted - the release-gate failure recorded in `proc/output-streams.ts`.
|
|
428
472
|
*/
|
|
473
|
+
const onStreamSettled = () => {
|
|
474
|
+
if (!exitInfo || settled)
|
|
475
|
+
return;
|
|
476
|
+
if (!streamsStillOpen())
|
|
477
|
+
settleOrdinary();
|
|
478
|
+
};
|
|
479
|
+
child.stdout?.on('end', onStreamSettled);
|
|
480
|
+
child.stderr?.on('end', onStreamSettled);
|
|
429
481
|
child.on('exit', (code, signalName) => {
|
|
430
|
-
|
|
482
|
+
exitInfo = { code, signalName };
|
|
483
|
+
if (settled)
|
|
484
|
+
return;
|
|
485
|
+
if (!streamsStillOpen()) {
|
|
486
|
+
settleOrdinary();
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
if (drainTimer)
|
|
431
490
|
return;
|
|
432
491
|
drainTimer = setTimeout(() => {
|
|
433
|
-
|
|
492
|
+
// DEFERRED PAST THE POLL PHASE, DELIBERATELY. Asking the streams from
|
|
493
|
+
// inside the timer callback would look at them before the loop has
|
|
494
|
+
// dispatched an `'end'`/`'close'` already queued behind it - which is
|
|
495
|
+
// precisely the starvation that produced the false note. The check phase
|
|
496
|
+
// runs after the poll phase of the same iteration, so by then the answer is
|
|
497
|
+
// observable rather than guessable.
|
|
498
|
+
setImmediate(settleOrdinary);
|
|
434
499
|
}, PROC_LIMITS.drainMs);
|
|
435
500
|
});
|
|
436
501
|
child.on('close', (code, signalName) => {
|
|
@@ -137,6 +137,23 @@ export declare const PLAN_LIMITS: {
|
|
|
137
137
|
readonly maxRisks: 10;
|
|
138
138
|
readonly maxOpenQuestions: 10;
|
|
139
139
|
};
|
|
140
|
+
/**
|
|
141
|
+
* The STORAGE bound of the `ask_user` de-duplication guard — NOT a refusal
|
|
142
|
+
* condition, and the distinction is the whole point.
|
|
143
|
+
*
|
|
144
|
+
* Storage is memory hygiene: a turn that asks a thousand DISTINCT question sets
|
|
145
|
+
* is pathological, but it is still the user's turn, and Esc interrupts it. The
|
|
146
|
+
* refusal rule is semantic: "never ask the same answered questions twice". Folding
|
|
147
|
+
* the bound into the refusal rule would restore the round cap this feature
|
|
148
|
+
* deletes, with a different number written on it.
|
|
149
|
+
*
|
|
150
|
+
* Owned here rather than in `plan-tools.ts` (the caller) or `controller.ts` (the
|
|
151
|
+
* state holder) because this file is already the single authority on the
|
|
152
|
+
* structural limits of human input, beside `QUESTION_LIMITS` / `PLAN_LIMITS`.
|
|
153
|
+
*/
|
|
154
|
+
export declare const ASK_GUARD_LIMITS: {
|
|
155
|
+
readonly maxRemembered: 128;
|
|
156
|
+
};
|
|
140
157
|
/**
|
|
141
158
|
* Trim to `max` characters, preferring a word boundary within the last
|
|
142
159
|
* `WORD_BOUNDARY_WINDOW` characters. Cutting a sentence mid-word reads as
|
|
@@ -165,13 +182,29 @@ export declare function normalizePlan(raw: unknown): NormalizedPlan | null;
|
|
|
165
182
|
export interface AnswersResultShape {
|
|
166
183
|
answers: Answer[];
|
|
167
184
|
cancelled: boolean;
|
|
168
|
-
roundsUsed: number;
|
|
169
|
-
roundsRemaining: number;
|
|
170
185
|
}
|
|
186
|
+
/**
|
|
187
|
+
* A question SET's identity: the question texts, trimmed, lowercased, whitespace
|
|
188
|
+
* collapsed, sorted, joined.
|
|
189
|
+
*
|
|
190
|
+
* `id` and `header` are excluded on purpose — a model that re-asks the same thing
|
|
191
|
+
* under a new id is asking the same thing, and the ids are the model's own
|
|
192
|
+
* bookkeeping rather than part of what the user reads.
|
|
193
|
+
*
|
|
194
|
+
* The fingerprint is stable because `normalizeQuestions` already ran on the TOOL
|
|
195
|
+
* side: header truncation and option trimming cannot make the same question look
|
|
196
|
+
* different here. Worst case is 5 questions x 300 chars = 1,500 chars.
|
|
197
|
+
*/
|
|
198
|
+
export declare function questionFingerprint(questions: NormalizedQuestion[]): string;
|
|
171
199
|
/**
|
|
172
200
|
* Render the `ask_user` result: one human-readable line so the transcript
|
|
173
201
|
* preview is legible without parsing, then the JSON the model reads.
|
|
174
202
|
*
|
|
203
|
+
* CARRIES NO ROUND COUNTER, deliberately (D-4). The model used to be told
|
|
204
|
+
* `roundsUsed` / `roundsRemaining` on every call, which is a per-turn reminder
|
|
205
|
+
* that it is running out — and an agent that reads "you have 1 round left"
|
|
206
|
+
* behaves like one. There is no limit any more, so there is nothing to report.
|
|
207
|
+
*
|
|
175
208
|
* `asked` is passed separately rather than derived from `answers.length`
|
|
176
209
|
* because a cancelled wizard returns fewer answers than it asked for, and
|
|
177
210
|
* "answered 1 of 3" is the whole information content of that line.
|
|
@@ -51,6 +51,21 @@ export const PLAN_LIMITS = {
|
|
|
51
51
|
maxRisks: 10,
|
|
52
52
|
maxOpenQuestions: 10,
|
|
53
53
|
};
|
|
54
|
+
/**
|
|
55
|
+
* The STORAGE bound of the `ask_user` de-duplication guard — NOT a refusal
|
|
56
|
+
* condition, and the distinction is the whole point.
|
|
57
|
+
*
|
|
58
|
+
* Storage is memory hygiene: a turn that asks a thousand DISTINCT question sets
|
|
59
|
+
* is pathological, but it is still the user's turn, and Esc interrupts it. The
|
|
60
|
+
* refusal rule is semantic: "never ask the same answered questions twice". Folding
|
|
61
|
+
* the bound into the refusal rule would restore the round cap this feature
|
|
62
|
+
* deletes, with a different number written on it.
|
|
63
|
+
*
|
|
64
|
+
* Owned here rather than in `plan-tools.ts` (the caller) or `controller.ts` (the
|
|
65
|
+
* state holder) because this file is already the single authority on the
|
|
66
|
+
* structural limits of human input, beside `QUESTION_LIMITS` / `PLAN_LIMITS`.
|
|
67
|
+
*/
|
|
68
|
+
export const ASK_GUARD_LIMITS = { maxRemembered: 128 };
|
|
54
69
|
/** How far back a truncation will look for a space before cutting hard. */
|
|
55
70
|
const WORD_BOUNDARY_WINDOW = 12;
|
|
56
71
|
// ---------------------------------------------------------------------------
|
|
@@ -208,10 +223,33 @@ export function normalizePlan(raw) {
|
|
|
208
223
|
openQuestions: nonEmptyStrings(src.openQuestions, PLAN_LIMITS.maxOpenQuestions),
|
|
209
224
|
};
|
|
210
225
|
}
|
|
226
|
+
/**
|
|
227
|
+
* A question SET's identity: the question texts, trimmed, lowercased, whitespace
|
|
228
|
+
* collapsed, sorted, joined.
|
|
229
|
+
*
|
|
230
|
+
* `id` and `header` are excluded on purpose — a model that re-asks the same thing
|
|
231
|
+
* under a new id is asking the same thing, and the ids are the model's own
|
|
232
|
+
* bookkeeping rather than part of what the user reads.
|
|
233
|
+
*
|
|
234
|
+
* The fingerprint is stable because `normalizeQuestions` already ran on the TOOL
|
|
235
|
+
* side: header truncation and option trimming cannot make the same question look
|
|
236
|
+
* different here. Worst case is 5 questions x 300 chars = 1,500 chars.
|
|
237
|
+
*/
|
|
238
|
+
export function questionFingerprint(questions) {
|
|
239
|
+
return questions
|
|
240
|
+
.map((q) => q.question.trim().toLowerCase().replace(/\s+/g, ' '))
|
|
241
|
+
.sort()
|
|
242
|
+
.join(' | ');
|
|
243
|
+
}
|
|
211
244
|
/**
|
|
212
245
|
* Render the `ask_user` result: one human-readable line so the transcript
|
|
213
246
|
* preview is legible without parsing, then the JSON the model reads.
|
|
214
247
|
*
|
|
248
|
+
* CARRIES NO ROUND COUNTER, deliberately (D-4). The model used to be told
|
|
249
|
+
* `roundsUsed` / `roundsRemaining` on every call, which is a per-turn reminder
|
|
250
|
+
* that it is running out — and an agent that reads "you have 1 round left"
|
|
251
|
+
* behaves like one. There is no limit any more, so there is nothing to report.
|
|
252
|
+
*
|
|
215
253
|
* `asked` is passed separately rather than derived from `answers.length`
|
|
216
254
|
* because a cancelled wizard returns fewer answers than it asked for, and
|
|
217
255
|
* "answered 1 of 3" is the whole information content of that line.
|
package/dist/tools/index.d.ts
CHANGED
|
@@ -14,6 +14,8 @@ import type { ToolPermission } from '../exec/permission.js';
|
|
|
14
14
|
import type { FilePatch } from './patch.js';
|
|
15
15
|
import type { ProcSupervisorPort } from '../proc/types.js';
|
|
16
16
|
import type { JobNotice } from '../proc/notices.js';
|
|
17
|
+
import { type ToolGate } from './tool-gate.js';
|
|
18
|
+
import { type ToolHookRunner } from '../exec/hooks/tool-wrapper.js';
|
|
17
19
|
export interface ConfirmRequest {
|
|
18
20
|
tool: string;
|
|
19
21
|
summary: string;
|
|
@@ -191,6 +193,22 @@ export interface BuiltinToolsOptions {
|
|
|
191
193
|
* to the pre-feature build.
|
|
192
194
|
*/
|
|
193
195
|
jobTools?: AgentTool[];
|
|
196
|
+
/**
|
|
197
|
+
* `submit_result`, appended verbatim when the caller supplied `--result-schema`
|
|
198
|
+
* (headless-request-scope-and-typed-result section 2.3.9).
|
|
199
|
+
*
|
|
200
|
+
* THROUGH THE FACTORY FOR THE REASON `teamTools`, `todoTools`, `procTools` AND
|
|
201
|
+
* `jobTools` EACH RECORD IN CAPITALS: `tools.test.ts::C7` asserts `HOST_TOOL_NAMES`
|
|
202
|
+
* equals what this factory PRODUCES, so appending `submit_result` in
|
|
203
|
+
* `AgentController` after the factory returned would turn it red with a message
|
|
204
|
+
* about two lists of names that says nothing about structured results.
|
|
205
|
+
*
|
|
206
|
+
* ABSENT ON EVERY OTHER PATH - the TUI, `-p`, every subagent - which leaves their
|
|
207
|
+
* tool array byte-identical to the pre-feature build (I-S2). A subagent must never
|
|
208
|
+
* receive it: its product is a report that goes back through `task`, and a second
|
|
209
|
+
* submitter in one request would compete with the lead's schema.
|
|
210
|
+
*/
|
|
211
|
+
resultTool?: AgentTool;
|
|
194
212
|
/**
|
|
195
213
|
* The completion-notice queue's read side, for the PULL channel (§4.6).
|
|
196
214
|
*
|
|
@@ -260,7 +278,81 @@ export interface BuiltinToolsOptions {
|
|
|
260
278
|
* `ToolPermission.isAllowed` applies for itself.
|
|
261
279
|
*/
|
|
262
280
|
permission?: ToolPermission;
|
|
281
|
+
/**
|
|
282
|
+
* The per-call approval gate (headless-host-bridge §2.3.3). Bridge mode only.
|
|
283
|
+
*
|
|
284
|
+
* ABSENT EVERYWHERE ELSE, which is what keeps every other tool array identical
|
|
285
|
+
* by object identity: the mapping below runs only when this key exists, the same
|
|
286
|
+
* house rule `permission` and `jobNotices` follow.
|
|
287
|
+
*/
|
|
288
|
+
toolGate?: ToolGate;
|
|
289
|
+
/**
|
|
290
|
+
* The caller's lifecycle hooks, wrapping `tool_pre` / `tool_post`
|
|
291
|
+
* (headless-threads-and-lifecycle-hooks section 2.4.9).
|
|
292
|
+
*
|
|
293
|
+
* IT IS PRESENT WHENEVER THE HOOK CHANNEL IS OPEN, not only once a hook is registered:
|
|
294
|
+
* `hook_register` arrives AFTER the controller (and therefore after this factory) was
|
|
295
|
+
* built, so a wrapper installed lazily would never be installed at all. The wrappers
|
|
296
|
+
* read the table live and cost one `chainFor` when it is empty, which is why the
|
|
297
|
+
* observable behaviour with zero hooks is exactly today's.
|
|
298
|
+
*
|
|
299
|
+
* ABSENT FOR THE TUI, `-p`, every subagent and every non-bridge exec run - so their tool
|
|
300
|
+
* arrays stay byte-identical to the pre-feature build (I-H1's spirit, and `tools.test.ts`
|
|
301
|
+
* C7's letter).
|
|
302
|
+
*/
|
|
303
|
+
hookRunner?: ToolHookRunner;
|
|
304
|
+
/**
|
|
305
|
+
* The host's watchdog pause, handed to the gate wrapper.
|
|
306
|
+
*
|
|
307
|
+
* THE SAME FUNCTION the plan tools receive (`withPausedWatchdog`), not a second
|
|
308
|
+
* wrapper around it: that method is documented as NOT reentrant, and nesting two
|
|
309
|
+
* pauses would resume the watchdog one level too early.
|
|
310
|
+
*/
|
|
311
|
+
withToolWait?: <T>(fn: () => Promise<T>) => Promise<T>;
|
|
263
312
|
}
|
|
313
|
+
/**
|
|
314
|
+
* The filesystem-mutating set the `--confirm` wrapper shares.
|
|
315
|
+
*
|
|
316
|
+
* EXPORTED SO `CALLER_GATED_TOOLS` CAN EXTEND IT rather than restating it
|
|
317
|
+
* (headless-host-bridge §2.3.2). `exec/permission.ts` already records the rule:
|
|
318
|
+
* a second copy of a name list is a list that drifts, and the drift is discovered
|
|
319
|
+
* by the tool that is refused in one mode and allowed in the other.
|
|
320
|
+
*/
|
|
321
|
+
export declare const MUTATING_TOOLS: ReadonlySet<string>;
|
|
322
|
+
/**
|
|
323
|
+
* The names `--permission-mode ask` asks the caller about.
|
|
324
|
+
*
|
|
325
|
+
* `MUTATING_TOOLS` is the filesystem set the confirm wrapper already shares.
|
|
326
|
+
* `skill_install` is deliberately ABSENT: it already has its own gate
|
|
327
|
+
* (`ApprovalGate`, `exec/bridge/decisions.ts`) and asking twice reads as "no
|
|
328
|
+
* approval at all" - the same argument that makes `--confirm` and `ask` mutually
|
|
329
|
+
* exclusive in `exec/options.ts`. `skill_create` is PRESENT because it writes
|
|
330
|
+
* SKILL.md plus arbitrary bundled files and has no gate of any kind: without it,
|
|
331
|
+
* `ask` mode would let a headless run create files on disk that the caller never
|
|
332
|
+
* saw.
|
|
333
|
+
*
|
|
334
|
+
* ONE DEFINITION, THREE CONSUMERS: `ToolGate.applies`, the executor's two-phase
|
|
335
|
+
* timeout override in `agent/controller.ts`, and the acks and docs that report
|
|
336
|
+
* which tools are gated. That is `BRIDGE_LIMITS`-style discipline (I-6) for a name
|
|
337
|
+
* set instead of a number.
|
|
338
|
+
*/
|
|
339
|
+
export declare const CALLER_GATED_TOOLS: ReadonlySet<string>;
|
|
340
|
+
/**
|
|
341
|
+
* The executor timeout each gated tool needs while a human may be answering.
|
|
342
|
+
*
|
|
343
|
+
* THE TWO PHASES, ADDED (headless-host-bridge section 2.3.4). `ToolExecutor` arms
|
|
344
|
+
* its timer BEFORE the approval wrapper runs, so a ceiling of `toolTimeoutMs`
|
|
345
|
+
* alone would leave the tool less than its configured budget by exactly the time
|
|
346
|
+
* the caller took to answer. ADMITTED OVER-GRANT: a caller that answers instantly
|
|
347
|
+
* still gets the sum. The opposite trade - charging the wait to the tool - would
|
|
348
|
+
* make "the human thought for two minutes" read as "this command is too slow",
|
|
349
|
+
* and a tool timeout is supposed to mean the latter.
|
|
350
|
+
*
|
|
351
|
+
* IT IS A FUNCTION RATHER THAN A LINE IN THE CONTROLLER so the formula has a name
|
|
352
|
+
* and a test: the numbers here reach a place (`toolTimeoutOverrides`) that no
|
|
353
|
+
* assertion can read back, and an unreadable ceiling is one that quietly drifts.
|
|
354
|
+
*/
|
|
355
|
+
export declare function gatedToolTimeouts(toolTimeoutMs: number, decisionBudgetMs: number): Record<string, number>;
|
|
264
356
|
/**
|
|
265
357
|
* Tools the ceiling always permits (D-G6). The ONLY place that knows which of
|
|
266
358
|
* this host's tools are read-only — core must not (D-G7).
|
|
@@ -309,5 +401,5 @@ export declare const PLAN_MODE_BLOCKED_TOOLS: ReadonlySet<string>;
|
|
|
309
401
|
* declaration MEANS, and that reading must not change with how the CLI was
|
|
310
402
|
* started.
|
|
311
403
|
*/
|
|
312
|
-
export declare const HOST_TOOL_NAMES: readonly ["read_file", "write_file", "edit_file", "list_dir", "glob", "grep", "bash", "skill", "skill_find", "skill_install", "skill_create", "ask_user", "submit_plan", "todo_write", "bash_output", "bash_kill", "job_wait", "job_list", "task"];
|
|
404
|
+
export declare const HOST_TOOL_NAMES: readonly ["read_file", "write_file", "edit_file", "list_dir", "glob", "grep", "bash", "skill", "skill_find", "skill_install", "skill_create", "ask_user", "submit_plan", "todo_write", "bash_output", "bash_kill", "job_wait", "job_list", "submit_result", "task"];
|
|
313
405
|
export declare function createBuiltinTools(options: BuiltinToolsOptions): AgentTool[];
|
package/dist/tools/index.js
CHANGED
|
@@ -14,7 +14,57 @@ import { makeReadFile, makeWriteFile, makeEditFile, makeListDir } from './fs-too
|
|
|
14
14
|
import { makeGlob, makeGrep } from './search-tools.js';
|
|
15
15
|
import { makeBash } from './bash-tool.js';
|
|
16
16
|
import { withJobNoticeTail } from './job-tools.js';
|
|
17
|
-
|
|
17
|
+
import { withToolApproval } from './tool-gate.js';
|
|
18
|
+
// VALUE IMPORTS, AND THE DIRECTION IS SAFE: `exec/hooks/tool-wrapper.ts` imports only Core
|
|
19
|
+
// and its own siblings, so `tools/index.ts -> exec/hooks -> (nothing back here)` is not a
|
|
20
|
+
// cycle. The wrappers are plain `{...tool, execute}` objects, exactly like the four this
|
|
21
|
+
// file already composes.
|
|
22
|
+
import { withToolPostHooks, withToolPreHooks, } from '../exec/hooks/tool-wrapper.js';
|
|
23
|
+
/**
|
|
24
|
+
* The filesystem-mutating set the `--confirm` wrapper shares.
|
|
25
|
+
*
|
|
26
|
+
* EXPORTED SO `CALLER_GATED_TOOLS` CAN EXTEND IT rather than restating it
|
|
27
|
+
* (headless-host-bridge §2.3.2). `exec/permission.ts` already records the rule:
|
|
28
|
+
* a second copy of a name list is a list that drifts, and the drift is discovered
|
|
29
|
+
* by the tool that is refused in one mode and allowed in the other.
|
|
30
|
+
*/
|
|
31
|
+
export const MUTATING_TOOLS = new Set(['write_file', 'edit_file', 'bash']);
|
|
32
|
+
/**
|
|
33
|
+
* The names `--permission-mode ask` asks the caller about.
|
|
34
|
+
*
|
|
35
|
+
* `MUTATING_TOOLS` is the filesystem set the confirm wrapper already shares.
|
|
36
|
+
* `skill_install` is deliberately ABSENT: it already has its own gate
|
|
37
|
+
* (`ApprovalGate`, `exec/bridge/decisions.ts`) and asking twice reads as "no
|
|
38
|
+
* approval at all" - the same argument that makes `--confirm` and `ask` mutually
|
|
39
|
+
* exclusive in `exec/options.ts`. `skill_create` is PRESENT because it writes
|
|
40
|
+
* SKILL.md plus arbitrary bundled files and has no gate of any kind: without it,
|
|
41
|
+
* `ask` mode would let a headless run create files on disk that the caller never
|
|
42
|
+
* saw.
|
|
43
|
+
*
|
|
44
|
+
* ONE DEFINITION, THREE CONSUMERS: `ToolGate.applies`, the executor's two-phase
|
|
45
|
+
* timeout override in `agent/controller.ts`, and the acks and docs that report
|
|
46
|
+
* which tools are gated. That is `BRIDGE_LIMITS`-style discipline (I-6) for a name
|
|
47
|
+
* set instead of a number.
|
|
48
|
+
*/
|
|
49
|
+
export const CALLER_GATED_TOOLS = new Set([...MUTATING_TOOLS, 'skill_create']);
|
|
50
|
+
/**
|
|
51
|
+
* The executor timeout each gated tool needs while a human may be answering.
|
|
52
|
+
*
|
|
53
|
+
* THE TWO PHASES, ADDED (headless-host-bridge section 2.3.4). `ToolExecutor` arms
|
|
54
|
+
* its timer BEFORE the approval wrapper runs, so a ceiling of `toolTimeoutMs`
|
|
55
|
+
* alone would leave the tool less than its configured budget by exactly the time
|
|
56
|
+
* the caller took to answer. ADMITTED OVER-GRANT: a caller that answers instantly
|
|
57
|
+
* still gets the sum. The opposite trade - charging the wait to the tool - would
|
|
58
|
+
* make "the human thought for two minutes" read as "this command is too slow",
|
|
59
|
+
* and a tool timeout is supposed to mean the latter.
|
|
60
|
+
*
|
|
61
|
+
* IT IS A FUNCTION RATHER THAN A LINE IN THE CONTROLLER so the formula has a name
|
|
62
|
+
* and a test: the numbers here reach a place (`toolTimeoutOverrides`) that no
|
|
63
|
+
* assertion can read back, and an unreadable ceiling is one that quietly drifts.
|
|
64
|
+
*/
|
|
65
|
+
export function gatedToolTimeouts(toolTimeoutMs, decisionBudgetMs) {
|
|
66
|
+
return Object.fromEntries([...CALLER_GATED_TOOLS].map((name) => [name, toolTimeoutMs + decisionBudgetMs]));
|
|
67
|
+
}
|
|
18
68
|
/**
|
|
19
69
|
* Tools the ceiling always permits (D-G6). The ONLY place that knows which of
|
|
20
70
|
* this host's tools are read-only — core must not (D-G7).
|
|
@@ -146,6 +196,13 @@ export const HOST_TOOL_NAMES = [
|
|
|
146
196
|
// (long-task-jobs §4.3).
|
|
147
197
|
'job_wait',
|
|
148
198
|
'job_list',
|
|
199
|
+
// `submit_result` (headless-request-scope-and-typed-result section 2.3.9) belongs to
|
|
200
|
+
// the superset for the reason the two job names above do: a skill's
|
|
201
|
+
// `allowed-tools: [submit_result]` must be answerable, and `--allow-tool` /
|
|
202
|
+
// `--deny-tool` validate against THIS list. REGISTRATION is a separate decision -
|
|
203
|
+
// only `--result-schema` registers it, which is why `--deny-tool submit_result`
|
|
204
|
+
// without a schema is warned about rather than obeyed (`exec/options.ts` W-3).
|
|
205
|
+
'submit_result',
|
|
149
206
|
// `team_send` / `team_wait` are DELIBERATELY ABSENT (P2-5). This constant
|
|
150
207
|
// answers "can a skill's `allowed-tools` declaration ever take effect?", and
|
|
151
208
|
// those two are never registered on a lead — so a skill naming them genuinely
|
|
@@ -309,12 +366,53 @@ export function createBuiltinTools(options) {
|
|
|
309
366
|
if (options.jobTools && options.jobTools.length > 0) {
|
|
310
367
|
tools = [...tools, ...options.jobTools];
|
|
311
368
|
}
|
|
369
|
+
// AFTER `jobTools`, BEFORE `teamTools`: `submit_result` is the answer-shaped sibling
|
|
370
|
+
// of the plan tools rather than a team tool, and keeping a lead's `task` last means
|
|
371
|
+
// `HOST_TOOL_NAMES` grows by one insertion instead of a reshuffle
|
|
372
|
+
// (headless-request-scope-and-typed-result section 2.6 row 16).
|
|
373
|
+
//
|
|
374
|
+
// INSIDE THE FACTORY, NEVER APPENDED IN `AgentController` - `tools.test.ts::C7`
|
|
375
|
+
// compares `HOST_TOOL_NAMES` against what this function produces.
|
|
376
|
+
if (options.resultTool) {
|
|
377
|
+
tools = [...tools, options.resultTool];
|
|
378
|
+
}
|
|
312
379
|
// BEFORE both wrappers, so the lead's `task` is covered by the plan gate and
|
|
313
380
|
// the ceiling like everything else, and a child's comm tools are covered by
|
|
314
381
|
// everything except the ceiling (see `policyExempt`).
|
|
315
382
|
if (options.teamTools && options.teamTools.length > 0) {
|
|
316
383
|
tools = [...tools, ...options.teamTools];
|
|
317
384
|
}
|
|
385
|
+
// --- The caller's per-call approval gate (headless-host-bridge §2.3.3). ---
|
|
386
|
+
//
|
|
387
|
+
// AFTER THE LAST APPEND AND BEFORE THE PERMISSION FILTER, for the two structural
|
|
388
|
+
// reasons the `permission` block below records, plus one of its own: the appends
|
|
389
|
+
// above are what put `skill_create` in this array, and that is the single
|
|
390
|
+
// write-capable tool with no other gate. Placing this immediately after
|
|
391
|
+
// `withConfirmation` - where the array still holds only the seven built-ins -
|
|
392
|
+
// would leave it permanently unaskable, so a headless run could create files in
|
|
393
|
+
// the skills directory that the caller never saw.
|
|
394
|
+
//
|
|
395
|
+
// At CALL time this ends up inside the plan gate and inside the skill ceiling
|
|
396
|
+
// (both applied below), which is the order their comments demand: neither needs
|
|
397
|
+
// I/O, so neither may sit behind a human wait.
|
|
398
|
+
if (options.toolGate) {
|
|
399
|
+
const gate = options.toolGate;
|
|
400
|
+
const withToolWait = options.withToolWait;
|
|
401
|
+
tools = tools.map((tool) => gate.applies(tool.name)
|
|
402
|
+
? withToolApproval(tool, gate, withToolWait ? { withHumanWait: withToolWait } : {})
|
|
403
|
+
: tool);
|
|
404
|
+
}
|
|
405
|
+
// --- The caller's lifecycle hooks: `tool_pre` (section 2.4.9). ---
|
|
406
|
+
//
|
|
407
|
+
// IMMEDIATELY AFTER THE APPROVAL GATE, WHICH MAKES IT OUTSIDE IT AT CALL TIME: a human
|
|
408
|
+
// must approve the arguments the hook produced, never the ones it replaced. The two
|
|
409
|
+
// policy wrappers below are applied later and are therefore outside this one, so a tool
|
|
410
|
+
// plan mode already refuses never costs a hook round trip (I-H4b - a recorded trade-off,
|
|
411
|
+
// not an oversight).
|
|
412
|
+
if (options.hookRunner) {
|
|
413
|
+
const hookRunner = options.hookRunner;
|
|
414
|
+
tools = tools.map((tool) => withToolPreHooks(tool, hookRunner));
|
|
415
|
+
}
|
|
318
416
|
// --- The `aragon exec` permission filter (cli-integration-surface §4.2). ---
|
|
319
417
|
//
|
|
320
418
|
// ITS OWN `if`, AFTER THE LAST APPEND AND BEFORE THE `toolPolicy` WRAPPER, and
|
|
@@ -386,8 +484,19 @@ export function createBuiltinTools(options) {
|
|
|
386
484
|
return list;
|
|
387
485
|
return list.map((tool) => withJobNoticeTail(tool, () => queue.drain()));
|
|
388
486
|
};
|
|
487
|
+
// THE OUTERMOST LAYER OF ALL, AND THAT IS THE WHOLE OF I-H13 (section 2.4.9): the
|
|
488
|
+
// `<job_notices>` tail is appended INSIDE this wrapper and carries a background job's
|
|
489
|
+
// last raw output lines, so a redactor placed any deeper would miss the most likely
|
|
490
|
+
// leak. The cost - a `tool_post` `replace` swallows the notice block - is accepted and
|
|
491
|
+
// documented: every notice is also a `job` event the caller already received.
|
|
492
|
+
const withHooksPost = (list) => {
|
|
493
|
+
const hookRunner = options.hookRunner;
|
|
494
|
+
if (!hookRunner)
|
|
495
|
+
return list;
|
|
496
|
+
return list.map((tool) => withToolPostHooks(tool, hookRunner));
|
|
497
|
+
};
|
|
389
498
|
if (!options.agentMode)
|
|
390
|
-
return withJobNotices(tools);
|
|
499
|
+
return withHooksPost(withJobNotices(tools));
|
|
391
500
|
const getMode = options.agentMode;
|
|
392
|
-
return withJobNotices(tools.map((t) => withPlanModeGate(t, getMode)));
|
|
501
|
+
return withHooksPost(withJobNotices(tools.map((t) => withPlanModeGate(t, getMode))));
|
|
393
502
|
}
|
|
@@ -17,22 +17,30 @@
|
|
|
17
17
|
* selected by whether an optional install step succeeded.
|
|
18
18
|
*/
|
|
19
19
|
import { type AgentTool } from '@aragon-agent/core';
|
|
20
|
-
import { type HumanInputGate } from './human-input.js';
|
|
21
|
-
/** The outcome of trying to spend one `ask_user` round. */
|
|
22
|
-
export interface AskRoundTicket {
|
|
23
|
-
ok: boolean;
|
|
24
|
-
used: number;
|
|
25
|
-
remaining: number;
|
|
26
|
-
max: number;
|
|
27
|
-
}
|
|
20
|
+
import { type HumanInputGate, type NormalizedQuestion } from './human-input.js';
|
|
28
21
|
export interface PlanToolsOptions {
|
|
29
22
|
gate: HumanInputGate;
|
|
30
23
|
/**
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
24
|
+
* Read-only: has this exact question SET already been ANSWERED in this turn?
|
|
25
|
+
*
|
|
26
|
+
* Called BEFORE `gate.request`, and it must have no side effects — that is why
|
|
27
|
+
* the guard is two callbacks rather than one. A single read-and-write function
|
|
28
|
+
* can only be placed before the question (which records an answer nobody gave)
|
|
29
|
+
* or after it (which cannot prevent a repeat). Both are wrong, and the second
|
|
30
|
+
* one silently turns "cancelled rounds stay askable" into a lie.
|
|
31
|
+
*/
|
|
32
|
+
hasAnsweredQuestions: (questions: NormalizedQuestion[]) => boolean;
|
|
33
|
+
/**
|
|
34
|
+
* Write-only: remember that this question SET was answered. Called AFTER
|
|
35
|
+
* `gate.request`, and NEVER when the round was cancelled.
|
|
36
|
+
*
|
|
37
|
+
* Cancellation is not consent. `Esc` may be a mis-touch, and turning a
|
|
38
|
+
* mis-touch into "this question may never be asked again" forces the model to
|
|
39
|
+
* guess exactly when it most needs to ask — the behaviour this feature exists
|
|
40
|
+
* to remove. The cheap failure (asking once more) is preferable to the
|
|
41
|
+
* expensive one (information lost for the rest of the turn).
|
|
34
42
|
*/
|
|
35
|
-
|
|
43
|
+
noteAnsweredQuestions: (questions: NormalizedQuestion[]) => void;
|
|
36
44
|
/**
|
|
37
45
|
* Run `fn` with the idle watchdog paused. `try/finally` inside, so a throw in
|
|
38
46
|
* the overlay path cannot leave the watchdog disarmed for the rest of the run.
|
package/dist/tools/plan-tools.js
CHANGED
|
@@ -25,18 +25,25 @@ const NO_INTERACTIVE_USER = 'No interactive user is attached (-p/--print mode).
|
|
|
25
25
|
'state your assumptions explicitly and continue.';
|
|
26
26
|
const NO_INTERACTIVE_REVIEWER = 'No interactive reviewer is attached. Emit the full plan as your final answer ' +
|
|
27
27
|
'in markdown instead.';
|
|
28
|
-
function roundCapRefusal(used) {
|
|
29
|
-
return (`You have already asked ${used} rounds of questions (the configured maximum). ` +
|
|
30
|
-
'Proceed with your best assumptions and state them in the plan.');
|
|
31
|
-
}
|
|
32
28
|
// ---------------------------------------------------------------------------
|
|
33
29
|
// ask_user
|
|
34
30
|
// ---------------------------------------------------------------------------
|
|
35
31
|
const ASK_USER_DESCRIPTION = 'Ask the user 1-5 multiple-choice questions when a decision would change the work. ' +
|
|
36
|
-
'
|
|
37
|
-
'
|
|
38
|
-
'
|
|
39
|
-
'
|
|
32
|
+
'There is no limit on how many rounds you ask: call this as many times as the work ' +
|
|
33
|
+
'needs, one batch at a time. Give each question a stable id, a header of at most 12 ' +
|
|
34
|
+
'characters, and 2-4 options; exactly one option must set recommended:true, with the ' +
|
|
35
|
+
'reason in its description. Extra questions or options beyond the per-call limits are ' +
|
|
36
|
+
'dropped, so keep each round inside them. Do not ask anything you could answer by ' +
|
|
37
|
+
'reading the workspace, and never re-ask a question this turn already answered.';
|
|
38
|
+
/**
|
|
39
|
+
* The de-duplication notice, as a NON-error result.
|
|
40
|
+
*
|
|
41
|
+
* `textResult`, not `errorResult`: a tool failure invites the model to retry the
|
|
42
|
+
* same call, and its only possible outcome is the same refusal. This is the same
|
|
43
|
+
* reasoning as the cancellation path below.
|
|
44
|
+
*/
|
|
45
|
+
const DUPLICATE_ROUND_RESULT = 'You already asked this exact set of questions in this turn and the user answered. ' +
|
|
46
|
+
'Use those answers; if you need something else, ask a different question.';
|
|
40
47
|
function makeAskUser(opts) {
|
|
41
48
|
return {
|
|
42
49
|
name: 'ask_user',
|
|
@@ -91,9 +98,10 @@ function makeAskUser(opts) {
|
|
|
91
98
|
if (questions.length === 0) {
|
|
92
99
|
return errorResult('No usable questions: each needs 2-4 distinct options.');
|
|
93
100
|
}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
101
|
+
// BEFORE the wait: a set the user has already answered must not open an
|
|
102
|
+
// overlay a second time. Read-only — nothing is recorded here.
|
|
103
|
+
if (opts.hasAnsweredQuestions(questions))
|
|
104
|
+
return textResult(DUPLICATE_ROUND_RESULT);
|
|
97
105
|
const response = await opts.withHumanWait(() => opts.gate.request({ kind: 'questions', questions }, ctx.signal));
|
|
98
106
|
// `null` covers cancel, unmount, abort AND timeout. From the model's point
|
|
99
107
|
// of view an unanswered question and an abandoned one are the same
|
|
@@ -102,12 +110,11 @@ function makeAskUser(opts) {
|
|
|
102
110
|
// invites a retry loop.
|
|
103
111
|
const cancelled = response === null || response.kind !== 'answers' || response.cancelled;
|
|
104
112
|
const answers = response !== null && response.kind === 'answers' ? response.answers : [];
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
}, questions.length));
|
|
113
|
+
// AFTER the answer, and only for a real answer. A cancelled round leaves
|
|
114
|
+
// the set askable: mis-touch must not cost the user information.
|
|
115
|
+
if (!cancelled)
|
|
116
|
+
opts.noteAnsweredQuestions(questions);
|
|
117
|
+
return textResult(formatAnswersResult({ answers, cancelled }, questions.length));
|
|
111
118
|
},
|
|
112
119
|
};
|
|
113
120
|
}
|