@aragon-agent/cli 0.4.3 → 0.5.1
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 +131 -0
- package/README.md +227 -3
- package/dist/agent/agent-mode.d.ts +53 -0
- package/dist/agent/agent-mode.js +69 -0
- package/dist/agent/controller.d.ts +69 -0
- package/dist/agent/controller.js +154 -1
- package/dist/agent/plan-prompt.d.ts +18 -0
- package/dist/agent/plan-prompt.js +93 -0
- package/dist/agent/reducer.d.ts +14 -1
- package/dist/agent/reducer.js +9 -0
- package/dist/agent/system-prompt.d.ts +15 -0
- package/dist/agent/system-prompt.js +8 -0
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +183 -20
- package/dist/commands/builtins.js +60 -0
- package/dist/commands/registry.d.ts +17 -0
- package/dist/config/app-paths.d.ts +71 -4
- package/dist/config/app-paths.js +139 -8
- package/dist/config/cli-commands.d.ts +43 -0
- package/dist/config/cli-commands.js +196 -0
- package/dist/config/env.d.ts +7 -1
- package/dist/config/env.js +45 -1
- package/dist/config/load.d.ts +8 -0
- package/dist/config/load.js +59 -2
- package/dist/config/migrate-home.d.ts +63 -0
- package/dist/config/migrate-home.js +165 -0
- package/dist/config/migrate-legacy-state.d.ts +10 -0
- package/dist/config/migrate-legacy-state.js +15 -6
- package/dist/config/schema.d.ts +61 -0
- package/dist/config/schema.js +83 -0
- package/dist/config/store.d.ts +30 -12
- package/dist/config/store.js +47 -35
- package/dist/logging/cli-commands.d.ts +25 -0
- package/dist/logging/cli-commands.js +221 -0
- package/dist/logging/file-sink.d.ts +114 -0
- package/dist/logging/file-sink.js +326 -0
- package/dist/logging/install.d.ts +163 -0
- package/dist/logging/install.js +302 -0
- package/dist/logging/levels.d.ts +40 -0
- package/dist/logging/levels.js +59 -0
- package/dist/logging/logger.d.ts +110 -0
- package/dist/logging/logger.js +237 -0
- package/dist/logging/redact.d.ts +28 -0
- package/dist/logging/redact.js +90 -0
- package/dist/logging/secret-registry.d.ts +30 -0
- package/dist/logging/secret-registry.js +55 -0
- package/dist/skills/paths.d.ts +2 -4
- package/dist/skills/paths.js +7 -9
- package/dist/tools/human-input.d.ts +179 -0
- package/dist/tools/human-input.js +227 -0
- package/dist/tools/index.d.ts +35 -3
- package/dist/tools/index.js +96 -9
- package/dist/tools/plan-tools.d.ts +64 -0
- package/dist/tools/plan-tools.js +198 -0
- package/dist/ui/App.d.ts +4 -1
- package/dist/ui/App.js +182 -11
- package/dist/ui/Composer.d.ts +4 -1
- package/dist/ui/Composer.js +26 -7
- package/dist/ui/ModeChip.d.ts +29 -0
- package/dist/ui/ModeChip.js +15 -0
- package/dist/ui/PromptInput.d.ts +8 -1
- package/dist/ui/PromptInput.js +12 -2
- package/dist/ui/StatusBar.d.ts +15 -0
- package/dist/ui/StatusBar.js +10 -2
- package/dist/ui/entries/ToolPreview.d.ts +2 -0
- package/dist/ui/entries/ToolPreview.js +64 -0
- package/dist/ui/glyphs.d.ts +9 -0
- package/dist/ui/glyphs.js +8 -0
- package/dist/ui/layout/wrap-rows.d.ts +24 -0
- package/dist/ui/layout/wrap-rows.js +74 -0
- package/dist/ui/overlays/HelpOverlay.js +13 -0
- package/dist/ui/overlays/PlanReviewOverlay.d.ts +50 -0
- package/dist/ui/overlays/PlanReviewOverlay.js +117 -0
- package/dist/ui/overlays/QuestionOverlay.d.ts +30 -0
- package/dist/ui/overlays/QuestionOverlay.js +193 -0
- package/dist/ui/overlays/SettingsScreen.d.ts +7 -0
- package/dist/ui/overlays/SettingsScreen.js +8 -1
- package/dist/ui/use-startup-notices.d.ts +15 -0
- package/dist/ui/use-startup-notices.js +38 -0
- package/package.json +3 -2
- package/runtime/insecure-tls-warning.cjs +62 -0
- package/runtime/insecure-tls-warning.d.cts +5 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The human-input channel (plan-mode §3.5 / §5.1) — types, the fail-closed
|
|
3
|
+
* default gate, and the two pure normalizers that repair whatever the model
|
|
4
|
+
* actually sent.
|
|
5
|
+
*
|
|
6
|
+
* Everything here is pure except the type declarations: no Ink, no React, no
|
|
7
|
+
* `node:*`. `tools/` is inside the glyph scanner's scope, so every literal is
|
|
8
|
+
* ASCII.
|
|
9
|
+
*
|
|
10
|
+
* WHY THE NORMALIZERS ARE THE ONLY AUTHORITY ON THE LIMITS (P0-4). The
|
|
11
|
+
* model-facing schemas in `plan-tools.ts` declare SHAPE only — `type`,
|
|
12
|
+
* `required`, `default` — and put every bound in the `description`. They used to
|
|
13
|
+
* carry `minItems` / `maxItems` / `maxLength`, and that had two failure modes at
|
|
14
|
+
* once: `ToolExecutor` rejected a six-question call outright with `Invalid
|
|
15
|
+
* parameters: ...` before any repair could run, wasting a full research turn on
|
|
16
|
+
* a cosmetic defect; and `ajv` is an OPTIONAL dependency of core, so without it
|
|
17
|
+
* the fallback validator checks `required` plus primitive types only and the
|
|
18
|
+
* same call sailed through. One design, two behaviours, selected by whether an
|
|
19
|
+
* optional install step succeeded. Bounds live here instead, where they are
|
|
20
|
+
* deterministic and unit-tested.
|
|
21
|
+
*/
|
|
22
|
+
import { OTHER_OPTION_LABEL } from '../agent/agent-mode.js';
|
|
23
|
+
/**
|
|
24
|
+
* Fails CLOSED. The headless default — forgetting to pass a gate must refuse,
|
|
25
|
+
* never hang and never assume yes.
|
|
26
|
+
*/
|
|
27
|
+
export const DENY_ALL_HUMAN_INPUT = {
|
|
28
|
+
neverPrompts: true,
|
|
29
|
+
canPrompt: () => false,
|
|
30
|
+
request: async () => null,
|
|
31
|
+
};
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Limits (the single authority; see the module header for why not the schema)
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
export const QUESTION_LIMITS = {
|
|
36
|
+
maxQuestions: 5,
|
|
37
|
+
minOptions: 2,
|
|
38
|
+
maxOptions: 4,
|
|
39
|
+
headerChars: 12,
|
|
40
|
+
questionChars: 300,
|
|
41
|
+
labelChars: 60,
|
|
42
|
+
optionDescriptionChars: 120,
|
|
43
|
+
};
|
|
44
|
+
export const PLAN_LIMITS = {
|
|
45
|
+
titleChars: 80,
|
|
46
|
+
summaryChars: 600,
|
|
47
|
+
maxSteps: 20,
|
|
48
|
+
stepTitleChars: 100,
|
|
49
|
+
stepDetailChars: 400,
|
|
50
|
+
maxFiles: 30,
|
|
51
|
+
maxRisks: 10,
|
|
52
|
+
maxOpenQuestions: 10,
|
|
53
|
+
};
|
|
54
|
+
/** How far back a truncation will look for a space before cutting hard. */
|
|
55
|
+
const WORD_BOUNDARY_WINDOW = 12;
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// Small pure helpers
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
function asString(value) {
|
|
60
|
+
return typeof value === 'string' ? value : '';
|
|
61
|
+
}
|
|
62
|
+
function asArray(value) {
|
|
63
|
+
return Array.isArray(value) ? value : [];
|
|
64
|
+
}
|
|
65
|
+
function asRecord(value) {
|
|
66
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
67
|
+
? value
|
|
68
|
+
: {};
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Trim to `max` characters, preferring a word boundary within the last
|
|
72
|
+
* `WORD_BOUNDARY_WINDOW` characters. Cutting a sentence mid-word reads as
|
|
73
|
+
* corruption; cutting it at a space reads as a summary.
|
|
74
|
+
*/
|
|
75
|
+
export function clampText(raw, max) {
|
|
76
|
+
const text = asString(raw).trim();
|
|
77
|
+
if (text.length <= max)
|
|
78
|
+
return text;
|
|
79
|
+
const head = text.slice(0, max);
|
|
80
|
+
const space = head.lastIndexOf(' ');
|
|
81
|
+
if (space >= max - WORD_BOUNDARY_WINDOW && space > 0)
|
|
82
|
+
return head.slice(0, space).trimEnd();
|
|
83
|
+
return head.trimEnd();
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* DROP FIRST, CAP SECOND, and it matters.
|
|
87
|
+
*
|
|
88
|
+
* Capping the raw array first would let blank or duplicate entries consume slots
|
|
89
|
+
* the model meant for real content: a list of 30 paths with two repeats would
|
|
90
|
+
* come back with 28 real ones and no way to tell that anything was lost.
|
|
91
|
+
*/
|
|
92
|
+
function nonEmptyStrings(raw, max, dedupe = false) {
|
|
93
|
+
const cleaned = asArray(raw)
|
|
94
|
+
.map((v) => asString(v).trim())
|
|
95
|
+
.filter((s) => s.length > 0);
|
|
96
|
+
return (dedupe ? [...new Set(cleaned)] : cleaned).slice(0, max);
|
|
97
|
+
}
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
// normalizeQuestions
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
/**
|
|
102
|
+
* Repair whatever the model sent into something renderable.
|
|
103
|
+
*
|
|
104
|
+
* Returns `[]` when nothing survives, which the tool turns into the one hard
|
|
105
|
+
* failure this path has. Every rule below is reachable precisely BECAUSE the
|
|
106
|
+
* schema stopped declaring the bounds (P0-4); with `minItems` / `maxItems` in
|
|
107
|
+
* the schema, `ajv` rejected the call first and rules 1 and 4 were dead code.
|
|
108
|
+
*/
|
|
109
|
+
export function normalizeQuestions(raw) {
|
|
110
|
+
const out = [];
|
|
111
|
+
// Rule 1a: keep the first N questions.
|
|
112
|
+
for (const item of asArray(raw).slice(0, QUESTION_LIMITS.maxQuestions)) {
|
|
113
|
+
const question = normalizeOneQuestion(asRecord(item), out.length);
|
|
114
|
+
if (question)
|
|
115
|
+
out.push(question);
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
function normalizeOneQuestion(src, index) {
|
|
120
|
+
const text = clampText(src.question, QUESTION_LIMITS.questionChars);
|
|
121
|
+
if (text.length === 0)
|
|
122
|
+
return null;
|
|
123
|
+
// Rule 1b + 1c: at least 2 options, at most 4.
|
|
124
|
+
const rawOptions = asArray(src.options).slice(0, QUESTION_LIMITS.maxOptions);
|
|
125
|
+
const options = [];
|
|
126
|
+
const seen = new Set();
|
|
127
|
+
for (const rawOption of rawOptions) {
|
|
128
|
+
const record = asRecord(rawOption);
|
|
129
|
+
const label = clampText(record.label, QUESTION_LIMITS.labelChars);
|
|
130
|
+
if (label.length === 0)
|
|
131
|
+
continue;
|
|
132
|
+
// Rule 4: deduplicate labels case-insensitively. Two options the user cannot
|
|
133
|
+
// tell apart is worse than one option fewer.
|
|
134
|
+
const key = label.toLowerCase();
|
|
135
|
+
if (seen.has(key))
|
|
136
|
+
continue;
|
|
137
|
+
seen.add(key);
|
|
138
|
+
options.push({
|
|
139
|
+
label,
|
|
140
|
+
description: clampText(record.description, QUESTION_LIMITS.optionDescriptionChars),
|
|
141
|
+
recommended: record.recommended === true,
|
|
142
|
+
isOther: false,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
if (options.length < QUESTION_LIMITS.minOptions)
|
|
146
|
+
return null;
|
|
147
|
+
// Rules 2 and 3: exactly one recommended, always. The recommended option is
|
|
148
|
+
// the wizard's initial highlight, so "none" and "several" are both unusable.
|
|
149
|
+
const firstRecommended = options.findIndex((o) => o.recommended);
|
|
150
|
+
options.forEach((option, i) => {
|
|
151
|
+
option.recommended = i === (firstRecommended === -1 ? 0 : firstRecommended);
|
|
152
|
+
});
|
|
153
|
+
// Rule 5: the synthetic free-text escape hatch, unless the model already
|
|
154
|
+
// supplied something that reads as one.
|
|
155
|
+
if (!options.some((o) => /^other/i.test(o.label))) {
|
|
156
|
+
options.push({ label: OTHER_OPTION_LABEL, description: '', recommended: false, isOther: true });
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
const existing = options.find((o) => /^other/i.test(o.label));
|
|
160
|
+
existing.isOther = true;
|
|
161
|
+
}
|
|
162
|
+
const id = asString(src.id).trim();
|
|
163
|
+
return {
|
|
164
|
+
id: id.length > 0 ? id : `q${index + 1}`,
|
|
165
|
+
header: clampText(src.header, QUESTION_LIMITS.headerChars) || `Q${index + 1}`,
|
|
166
|
+
question: text,
|
|
167
|
+
options,
|
|
168
|
+
allowMultiple: src.allowMultiple === true,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
// normalizePlan
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
/**
|
|
175
|
+
* Repair a submitted plan. Returns `null` only when no step survives — the one
|
|
176
|
+
* hard failure, because there is then nothing left to review.
|
|
177
|
+
*
|
|
178
|
+
* A plan that is one step over the cap is still a plan, and rejecting it costs a
|
|
179
|
+
* full research turn to regain nothing. Truncation is silent to the model but
|
|
180
|
+
* VISIBLE to the user: the review overlay's position indicator counts the real
|
|
181
|
+
* rows, so a clipped plan cannot masquerade as complete.
|
|
182
|
+
*/
|
|
183
|
+
export function normalizePlan(raw) {
|
|
184
|
+
const src = asRecord(raw);
|
|
185
|
+
// Same drop-first, cap-second discipline as `nonEmptyStrings`: a blank-titled
|
|
186
|
+
// step is noise, and letting it consume one of the twenty slots would silently
|
|
187
|
+
// discard a real step at the other end.
|
|
188
|
+
const steps = [];
|
|
189
|
+
for (const rawStep of asArray(src.steps)) {
|
|
190
|
+
const record = asRecord(rawStep);
|
|
191
|
+
const title = clampText(record.title, PLAN_LIMITS.stepTitleChars);
|
|
192
|
+
if (title.length === 0)
|
|
193
|
+
continue;
|
|
194
|
+
steps.push({ title, detail: clampText(record.detail, PLAN_LIMITS.stepDetailChars) });
|
|
195
|
+
if (steps.length === PLAN_LIMITS.maxSteps)
|
|
196
|
+
break;
|
|
197
|
+
}
|
|
198
|
+
if (steps.length === 0)
|
|
199
|
+
return null;
|
|
200
|
+
return {
|
|
201
|
+
title: clampText(src.title, PLAN_LIMITS.titleChars) || 'Untitled plan',
|
|
202
|
+
summary: clampText(src.summary, PLAN_LIMITS.summaryChars),
|
|
203
|
+
steps,
|
|
204
|
+
// Exact deduplication, NOT case-insensitive: POSIX paths are case-sensitive
|
|
205
|
+
// and two casings are two files.
|
|
206
|
+
filesTouched: nonEmptyStrings(src.filesTouched, PLAN_LIMITS.maxFiles, true),
|
|
207
|
+
risks: nonEmptyStrings(src.risks, PLAN_LIMITS.maxRisks),
|
|
208
|
+
openQuestions: nonEmptyStrings(src.openQuestions, PLAN_LIMITS.maxOpenQuestions),
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Render the `ask_user` result: one human-readable line so the transcript
|
|
213
|
+
* preview is legible without parsing, then the JSON the model reads.
|
|
214
|
+
*
|
|
215
|
+
* `asked` is passed separately rather than derived from `answers.length`
|
|
216
|
+
* because a cancelled wizard returns fewer answers than it asked for, and
|
|
217
|
+
* "answered 1 of 3" is the whole information content of that line.
|
|
218
|
+
*/
|
|
219
|
+
export function formatAnswersResult(shape, asked) {
|
|
220
|
+
const headline = shape.cancelled
|
|
221
|
+
? 'User dismissed the questions.'
|
|
222
|
+
: `User answered ${shape.answers.length} of ${asked} questions.`;
|
|
223
|
+
const guidance = shape.cancelled
|
|
224
|
+
? ' Proceed with your best assumptions and say what you assumed.'
|
|
225
|
+
: '';
|
|
226
|
+
return `${headline}${guidance}\n${JSON.stringify(shape, null, 2)}`;
|
|
227
|
+
}
|
package/dist/tools/index.d.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* before the tool runs.
|
|
10
10
|
*/
|
|
11
11
|
import { type AgentTool, type ToolPolicyDecision, type ToolPolicyVerdict } from '@aragon-agent/core';
|
|
12
|
+
import type { AgentMode } from '../agent/agent-mode.js';
|
|
12
13
|
export interface ConfirmRequest {
|
|
13
14
|
tool: string;
|
|
14
15
|
summary: string;
|
|
@@ -36,7 +37,10 @@ export interface BuiltinToolsOptions {
|
|
|
36
37
|
* changes mid-turn (a `skill` call adds to it, a rescan can empty it), and a
|
|
37
38
|
* cached decision would ignore the skill the model just loaded.
|
|
38
39
|
*
|
|
39
|
-
* Omitting this leaves every tool unwrapped — see the
|
|
40
|
+
* Omitting this leaves every tool unwrapped BY THIS GATE — see the first of
|
|
41
|
+
* the two independent guards at the end of `createBuiltinTools`. It used to be
|
|
42
|
+
* an early return, and P0-1 replaced it precisely because an early return also
|
|
43
|
+
* skipped the plan-mode gate below it (R2-P2-3).
|
|
40
44
|
*/
|
|
41
45
|
toolPolicy?: () => ToolPolicyDecision;
|
|
42
46
|
/**
|
|
@@ -52,6 +56,22 @@ export interface BuiltinToolsOptions {
|
|
|
52
56
|
verdict: ToolPolicyVerdict;
|
|
53
57
|
sourceNames: string[];
|
|
54
58
|
}) => void;
|
|
59
|
+
/**
|
|
60
|
+
* Read the mode AT CALL TIME. Never cache: the mode can flip mid-run when the
|
|
61
|
+
* user approves a plan, and a cached `'plan'` would refuse the very work the
|
|
62
|
+
* user just authorized.
|
|
63
|
+
*
|
|
64
|
+
* Omitting this leaves every tool unwrapped by THIS gate — see the second,
|
|
65
|
+
* INDEPENDENT guard at the end of `createBuiltinTools`, which is what keeps
|
|
66
|
+
* the 7-tool / 11-tool baselines byte-identical (I-S1 / AC-G17).
|
|
67
|
+
*/
|
|
68
|
+
agentMode?: () => AgentMode;
|
|
69
|
+
/**
|
|
70
|
+
* `ask_user` / `submit_plan`, appended verbatim. Empty in headless mode, where
|
|
71
|
+
* no overlay can be rendered and showing the model a tool it cannot use just
|
|
72
|
+
* burns a turn.
|
|
73
|
+
*/
|
|
74
|
+
planTools?: AgentTool[];
|
|
55
75
|
}
|
|
56
76
|
/**
|
|
57
77
|
* Tools the ceiling always permits (D-G6). The ONLY place that knows which of
|
|
@@ -68,7 +88,19 @@ export interface BuiltinToolsOptions {
|
|
|
68
88
|
* is the whole of what this mechanism is worth, and saying so plainly beats
|
|
69
89
|
* implying it covers more.
|
|
70
90
|
*/
|
|
71
|
-
export declare const SKILL_TOOL_FLOOR: readonly ["read_file", "list_dir", "glob", "grep", "skill", "skill_find"];
|
|
91
|
+
export declare const SKILL_TOOL_FLOOR: readonly ["read_file", "list_dir", "glob", "grep", "skill", "skill_find", "ask_user", "submit_plan"];
|
|
92
|
+
/**
|
|
93
|
+
* The five tools plan mode refuses: exactly the complement of `SKILL_TOOL_FLOOR`
|
|
94
|
+
* within `HOST_TOOL_NAMES` — "the five that change something", the set this file
|
|
95
|
+
* already names in prose above.
|
|
96
|
+
*
|
|
97
|
+
* It lives HERE, next to the floor, rather than in `agent/agent-mode.ts` with
|
|
98
|
+
* the rest of the mode vocabulary. The invariant worth having is that these two
|
|
99
|
+
* sets PARTITION `HOST_TOOL_NAMES`, and a partition test is only as trustworthy
|
|
100
|
+
* as the distance between the two definitions it compares. Splitting them
|
|
101
|
+
* across modules is how they drift.
|
|
102
|
+
*/
|
|
103
|
+
export declare const PLAN_MODE_BLOCKED_TOOLS: ReadonlySet<string>;
|
|
72
104
|
/**
|
|
73
105
|
* Every tool name this host CAN register (7 built-ins + 4 skill tools).
|
|
74
106
|
*
|
|
@@ -89,5 +121,5 @@ export declare const SKILL_TOOL_FLOOR: readonly ["read_file", "list_dir", "glob"
|
|
|
89
121
|
* declaration MEANS, and that reading must not change with how the CLI was
|
|
90
122
|
* started.
|
|
91
123
|
*/
|
|
92
|
-
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"];
|
|
124
|
+
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"];
|
|
93
125
|
export declare function createBuiltinTools(options: BuiltinToolsOptions): AgentTool[];
|
package/dist/tools/index.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* before the tool runs.
|
|
10
10
|
*/
|
|
11
11
|
import { errorResult, evaluateToolCall, } from '@aragon-agent/core';
|
|
12
|
+
import { planRefusal, submitPlanRefusal } from '../agent/agent-mode.js';
|
|
12
13
|
import { makeReadFile, makeWriteFile, makeEditFile, makeListDir } from './fs-tools.js';
|
|
13
14
|
import { makeGlob, makeGrep } from './search-tools.js';
|
|
14
15
|
import { makeBash } from './bash-tool.js';
|
|
@@ -35,7 +36,35 @@ export const SKILL_TOOL_FLOOR = [
|
|
|
35
36
|
'grep',
|
|
36
37
|
'skill',
|
|
37
38
|
'skill_find',
|
|
39
|
+
// The two human-input tools join the floor for the same reason `skill` /
|
|
40
|
+
// `skill_find` did: a skill that declares `allowed-tools` without naming them
|
|
41
|
+
// would make `ask_user` unreachable and dead-end plan mode — the exact dead
|
|
42
|
+
// end iteration 2 of the skills work spent a round removing. Neither is a
|
|
43
|
+
// filesystem mutation, so the ceiling loses nothing it was protecting.
|
|
44
|
+
// (`submit_plan` does mutate the SESSION MODE, which is why the floor is now
|
|
45
|
+
// described as "everything that cannot write to disk" rather than
|
|
46
|
+
// "read-only".)
|
|
47
|
+
'ask_user',
|
|
48
|
+
'submit_plan',
|
|
38
49
|
];
|
|
50
|
+
/**
|
|
51
|
+
* The five tools plan mode refuses: exactly the complement of `SKILL_TOOL_FLOOR`
|
|
52
|
+
* within `HOST_TOOL_NAMES` — "the five that change something", the set this file
|
|
53
|
+
* already names in prose above.
|
|
54
|
+
*
|
|
55
|
+
* It lives HERE, next to the floor, rather than in `agent/agent-mode.ts` with
|
|
56
|
+
* the rest of the mode vocabulary. The invariant worth having is that these two
|
|
57
|
+
* sets PARTITION `HOST_TOOL_NAMES`, and a partition test is only as trustworthy
|
|
58
|
+
* as the distance between the two definitions it compares. Splitting them
|
|
59
|
+
* across modules is how they drift.
|
|
60
|
+
*/
|
|
61
|
+
export const PLAN_MODE_BLOCKED_TOOLS = new Set([
|
|
62
|
+
'write_file',
|
|
63
|
+
'edit_file',
|
|
64
|
+
'bash',
|
|
65
|
+
'skill_install',
|
|
66
|
+
'skill_create',
|
|
67
|
+
]);
|
|
39
68
|
/**
|
|
40
69
|
* Every tool name this host CAN register (7 built-ins + 4 skill tools).
|
|
41
70
|
*
|
|
@@ -68,6 +97,8 @@ export const HOST_TOOL_NAMES = [
|
|
|
68
97
|
'skill_find',
|
|
69
98
|
'skill_install',
|
|
70
99
|
'skill_create',
|
|
100
|
+
'ask_user',
|
|
101
|
+
'submit_plan',
|
|
71
102
|
];
|
|
72
103
|
function summarize(toolName, params) {
|
|
73
104
|
if (toolName === 'bash')
|
|
@@ -121,6 +152,42 @@ function withToolPolicy(tool, getDecision, notify) {
|
|
|
121
152
|
},
|
|
122
153
|
};
|
|
123
154
|
}
|
|
155
|
+
/**
|
|
156
|
+
* Wrap a tool in the plan-mode read-only gate. Same `{...tool, execute}` shape
|
|
157
|
+
* as `withConfirmation` / `withToolPolicy`, so nothing in the engine needs to
|
|
158
|
+
* know this happened.
|
|
159
|
+
*
|
|
160
|
+
* Read-only becomes a property of the WIRING rather than a promise in a prompt:
|
|
161
|
+
* the badge says PLAN because these five cannot run, not because the model was
|
|
162
|
+
* asked nicely.
|
|
163
|
+
*
|
|
164
|
+
* CODEACT IS NOT A HOLE TODAY, AND THAT IS WORTH WRITING DOWN. The
|
|
165
|
+
* ```execute-js``` path in the core agent loop runs through `ctx.sandbox`, not
|
|
166
|
+
* through any `AgentTool`, so no tool wrapper can see it. The CLI passes no
|
|
167
|
+
* `sandbox` to `new Agent(...)`, which makes the path inert. Anyone wiring a
|
|
168
|
+
* sandbox later must gate it as well, or plan mode gains a shell by the back
|
|
169
|
+
* door.
|
|
170
|
+
*/
|
|
171
|
+
function withPlanModeGate(tool, getMode) {
|
|
172
|
+
const original = tool.execute;
|
|
173
|
+
return {
|
|
174
|
+
...tool,
|
|
175
|
+
async execute(id, params, ctx) {
|
|
176
|
+
const mode = getMode();
|
|
177
|
+
if (mode === 'plan' && PLAN_MODE_BLOCKED_TOOLS.has(tool.name)) {
|
|
178
|
+
return errorResult(planRefusal(tool.name));
|
|
179
|
+
}
|
|
180
|
+
// Asymmetric on purpose: `ask_user` is available in BOTH modes, because a
|
|
181
|
+
// clarifying question is never harmful and refusing a tool the model can
|
|
182
|
+
// see just burns a turn. `submit_plan` mutates the session mode and only
|
|
183
|
+
// means something while a plan is being reviewed.
|
|
184
|
+
if (mode !== 'plan' && tool.name === 'submit_plan') {
|
|
185
|
+
return errorResult(submitPlanRefusal());
|
|
186
|
+
}
|
|
187
|
+
return original(id, params, ctx);
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
}
|
|
124
191
|
export function createBuiltinTools(options) {
|
|
125
192
|
const deps = { getCwd: options.getCwd };
|
|
126
193
|
let tools = [
|
|
@@ -139,19 +206,39 @@ export function createBuiltinTools(options) {
|
|
|
139
206
|
if (options.skillTools && options.skillTools.length > 0) {
|
|
140
207
|
tools = [...tools, ...options.skillTools];
|
|
141
208
|
}
|
|
209
|
+
if (options.planTools && options.planTools.length > 0) {
|
|
210
|
+
tools = [...tools, ...options.planTools];
|
|
211
|
+
}
|
|
142
212
|
// No decision provider (`--no-skills`, or a caller that never enabled the
|
|
143
|
-
// policy) means the
|
|
213
|
+
// policy) means the ceiling adds nothing.
|
|
144
214
|
//
|
|
145
|
-
// A ceiling that always says "allow" would behave identically, so
|
|
146
|
-
//
|
|
147
|
-
//
|
|
215
|
+
// A ceiling that always says "allow" would behave identically, so skipping it
|
|
216
|
+
// buys nothing at run time — it buys the ability to PROVE the claim. AC-G17
|
|
217
|
+
// asserts object identity on this path; with a wrapper in place, "the
|
|
148
218
|
// `--no-skills` path is unchanged from iteration 2" would demote from a fact
|
|
149
219
|
// anyone can check to an argument someone has to trust.
|
|
150
|
-
|
|
151
|
-
return tools;
|
|
220
|
+
//
|
|
152
221
|
// Outermost on purpose (D-G11). Asking a human "run bash?", waiting for them
|
|
153
222
|
// to say yes, and only then reporting that policy refused it is the worst
|
|
154
|
-
// available ordering. The ceiling needs no I/O, so it goes
|
|
155
|
-
|
|
156
|
-
|
|
223
|
+
// available ordering. The ceiling needs no I/O, so it goes before confirmation.
|
|
224
|
+
if (options.toolPolicy) {
|
|
225
|
+
const getDecision = options.toolPolicy;
|
|
226
|
+
tools = tools.map((t) => withToolPolicy(t, getDecision, options.onToolPolicyEvent));
|
|
227
|
+
}
|
|
228
|
+
// TWO OPTIONS, TWO INDEPENDENT GUARDS, NO SHARED EXIT.
|
|
229
|
+
//
|
|
230
|
+
// This used to be a single `if (!options.toolPolicy) return tools;` early
|
|
231
|
+
// return ending the function. That return sits BEFORE the plan gate, and
|
|
232
|
+
// `--no-skills` / `ARAGON_SKILLS=0` supply no decision provider — so
|
|
233
|
+
// `aragon --no-skills --plan` returned before the gate was applied and ran
|
|
234
|
+
// with write_file / edit_file / bash fully live while the badge said PLAN.
|
|
235
|
+
// If you ever collapse these two guards back into one, that is the bug you
|
|
236
|
+
// are reintroducing, and nothing reports it (AC-P20 / I-P3).
|
|
237
|
+
//
|
|
238
|
+
// The plan gate is applied LAST, which makes it OUTERMOST at call time: it
|
|
239
|
+
// needs no I/O and no state, so it must not sit behind a human wait.
|
|
240
|
+
if (!options.agentMode)
|
|
241
|
+
return tools;
|
|
242
|
+
const getMode = options.agentMode;
|
|
243
|
+
return tools.map((t) => withPlanModeGate(t, getMode));
|
|
157
244
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ask_user` and `submit_plan` — the two tools that only exist because a human
|
|
3
|
+
* is attached (plan-mode §4.1 / §4.2 / §5.3).
|
|
4
|
+
*
|
|
5
|
+
* ASCII ONLY: `tools/` is inside the glyph scanner's scope.
|
|
6
|
+
*
|
|
7
|
+
* THE SCHEMAS DECLARE SHAPE, NOT POLICY (P0-4). `type`, `required` and `default`
|
|
8
|
+
* stay, because a `questions` value that is not an array has nothing for a
|
|
9
|
+
* normalizer to repair and both validator strategies agree on that check.
|
|
10
|
+
* Everything else — how many questions, how many options, how long a header may
|
|
11
|
+
* be — lives in the `description`, which is what the model actually reads, and
|
|
12
|
+
* is enforced by `normalizeQuestions` / `normalizePlan`. Putting `maxItems: 5`
|
|
13
|
+
* in the schema instead had two failure modes at once: with `ajv` present
|
|
14
|
+
* `ToolExecutor` rejected a six-question call outright and the repair rules were
|
|
15
|
+
* dead code; without it (ajv is an OPTIONAL dependency of core) the same call
|
|
16
|
+
* sailed straight through to the repair path. One design, two behaviours,
|
|
17
|
+
* selected by whether an optional install step succeeded.
|
|
18
|
+
*/
|
|
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
|
+
}
|
|
28
|
+
export interface PlanToolsOptions {
|
|
29
|
+
gate: HumanInputGate;
|
|
30
|
+
/**
|
|
31
|
+
* Spend one `ask_user` round. The counter is owned by the controller because
|
|
32
|
+
* it resets per user turn, and the controller is the only object that sees a
|
|
33
|
+
* turn begin.
|
|
34
|
+
*/
|
|
35
|
+
takeAskRound: () => AskRoundTicket;
|
|
36
|
+
/**
|
|
37
|
+
* Run `fn` with the idle watchdog paused. `try/finally` inside, so a throw in
|
|
38
|
+
* the overlay path cannot leave the watchdog disarmed for the rest of the run.
|
|
39
|
+
*/
|
|
40
|
+
withHumanWait: <T>(fn: () => Promise<T>) => Promise<T>;
|
|
41
|
+
/**
|
|
42
|
+
* Approve the plan: flip the session to Build IMMEDIATELY, mid-run.
|
|
43
|
+
*
|
|
44
|
+
* This is the one place a `plan -> build` transition is not deferred to
|
|
45
|
+
* `agent_end`, and the asymmetry is the whole point: the user has just read
|
|
46
|
+
* the plan and authorized exactly this work. A stray `Shift+Tab` has not.
|
|
47
|
+
*/
|
|
48
|
+
onPlanApproved: () => void;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Build the plan tools, or `[]` for a channel that can never reach a human.
|
|
52
|
+
*
|
|
53
|
+
* KEYED ON `neverPrompts`, NOT `canPrompt()` (P0-3). This function runs inside
|
|
54
|
+
* `makeController()`, and in the interactive path the bridge handler is still
|
|
55
|
+
* null there — the App attaches it later, in an effect. So `canPrompt()` is
|
|
56
|
+
* false for EVERY session at this moment, and gating registration on it would
|
|
57
|
+
* ship plan mode with no `ask_user` and no `submit_plan`, in every interactive
|
|
58
|
+
* session, with nothing anywhere reporting it.
|
|
59
|
+
*
|
|
60
|
+
* Registration is a one-time structural question ("can this channel ever reach a
|
|
61
|
+
* human?"). `canPrompt()` answers a live one ("is one attached right now?") and
|
|
62
|
+
* is checked again inside each tool, where it belongs.
|
|
63
|
+
*/
|
|
64
|
+
export declare function createPlanTools(opts: PlanToolsOptions): AgentTool[];
|