@pi-archimedes/subagent 2.10.3 → 2.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +10 -10
- package/src/agent-panel.mcp.test.ts +76 -0
- package/src/agent-panel.tools.test.ts +162 -0
- package/src/agent-panel.ts +37 -8
- package/src/agent-panel.width-audit.test.ts +258 -0
- package/src/agents-parse.test.ts +252 -0
- package/src/agents.ts +72 -5
- package/src/compact.test.ts +136 -57
- package/src/compact.ts +57 -37
- package/src/dispatch.test.ts +73 -1
- package/src/dispatch.ts +20 -2
- package/src/execute.test.ts +9 -1
- package/src/execute.ts +26 -29
- package/src/expanded.test.ts +61 -0
- package/src/expanded.ts +96 -80
- package/src/format.test.ts +71 -29
- package/src/format.ts +71 -28
- package/src/frontmatter-io.test.ts +62 -0
- package/src/frontmatter-io.ts +16 -2
- package/src/handlers.test.ts +21 -3
- package/src/handlers.ts +22 -3
- package/src/index.output-schema.test.ts +317 -0
- package/src/index.test.ts +486 -0
- package/src/index.ts +168 -8
- package/src/render.ts +46 -36
- package/src/row-block.test.ts +195 -0
- package/src/row-block.ts +186 -0
- package/src/run-outcome.test.ts +71 -0
- package/src/run-outcome.ts +84 -0
- package/src/spawn.test.ts +204 -19
- package/src/spawn.ts +49 -17
- package/src/stream.test.ts +215 -17
- package/src/stream.ts +47 -9
- package/src/thinking-validation.test.ts +96 -0
- package/src/thinking-validation.ts +78 -0
- package/src/tool-declarations.test.ts +58 -0
- package/src/tool-row.width-audit.test.ts +720 -0
- package/src/tool-schema.ts +85 -0
- package/src/tool-validation.test.ts +321 -0
- package/src/tool-validation.ts +262 -0
- package/src/types.ts +86 -0
- package/src/usage.test.ts +95 -0
- package/src/usage.ts +87 -0
- package/src/cost.test.ts +0 -62
- package/src/cost.ts +0 -8
package/src/tool-schema.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Type } from "typebox";
|
|
2
|
+
import type { SubagentResult, SubagentRunReport } from "./types.js";
|
|
2
3
|
|
|
3
4
|
// ── JSON Schema for tool parameters (TypeBox) ──────────────────────────────
|
|
4
5
|
|
|
@@ -31,3 +32,87 @@ export const SUBAGENT_PARAMS_SCHEMA = Type.Object({
|
|
|
31
32
|
description: "Working directory for the subagent",
|
|
32
33
|
})),
|
|
33
34
|
});
|
|
35
|
+
|
|
36
|
+
// ── JSON Schema for `structuredContent` ────────────────────────────────
|
|
37
|
+
//
|
|
38
|
+
// One report per run: what ran, how it ended, what it cost, what it answered.
|
|
39
|
+
// It is `details` minus the live-progress blob — `details.progress[i].output`
|
|
40
|
+
// is the child's whole streamed scrollback and `toolCalls` its call history, for
|
|
41
|
+
// the row renderer, and a script that wants the answer already has
|
|
42
|
+
// `finalOutput`. `usageAccountedElsewhere` is left out too: it is this session's
|
|
43
|
+
// bookkeeping flag (ADR 0028), not a fact about the run.
|
|
44
|
+
//
|
|
45
|
+
// pi hands this to a codemode script instead of the text and validates it
|
|
46
|
+
// against nothing, so `index.output-schema.test.ts` runs the handler and checks
|
|
47
|
+
// the payload against this declaration.
|
|
48
|
+
//
|
|
49
|
+
// A single run that reports itself as failed is `isError` and carries none of it
|
|
50
|
+
// (see the note in `index.ts`), so a script keeps rejecting with the text it
|
|
51
|
+
// rejects with today. A parallel batch is the other case: one failed run inside
|
|
52
|
+
// a batch does not make the batch an error — it stays a report of N runs, with
|
|
53
|
+
// that run's `error` field set inside `results` (pre-existing shape, unchanged
|
|
54
|
+
// by these schemas; see the per-run failure markers in `formatParallelResults`).
|
|
55
|
+
|
|
56
|
+
const UsageSchema = Type.Object({
|
|
57
|
+
input: Type.Number(),
|
|
58
|
+
output: Type.Number(),
|
|
59
|
+
cacheRead: Type.Number(),
|
|
60
|
+
cacheWrite: Type.Number(),
|
|
61
|
+
cost: Type.Number(),
|
|
62
|
+
turns: Type.Number(),
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const ProgressSummarySchema = Type.Object({
|
|
66
|
+
toolCount: Type.Number(),
|
|
67
|
+
tokens: Type.Number(),
|
|
68
|
+
durationMs: Type.Number(),
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const RunReportSchema = Type.Object({
|
|
72
|
+
agent: Type.String(),
|
|
73
|
+
task: Type.String(),
|
|
74
|
+
exitCode: Type.Number(),
|
|
75
|
+
childSessionId: Type.Optional(Type.String()),
|
|
76
|
+
outcome: Type.Optional(
|
|
77
|
+
Type.Union([Type.Literal("completed"), Type.Literal("truncated"), Type.Literal("aborted")]),
|
|
78
|
+
),
|
|
79
|
+
usage: UsageSchema,
|
|
80
|
+
model: Type.Optional(Type.String()),
|
|
81
|
+
finalOutput: Type.Optional(Type.String({ description: "The run's answer text, when it produced one" })),
|
|
82
|
+
error: Type.Optional(Type.String({ description: "Set when the run failed before or during execution" })),
|
|
83
|
+
progressSummary: Type.Optional(ProgressSummarySchema),
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
export const SUBAGENT_OUTPUT_SCHEMA = Type.Object({
|
|
87
|
+
mode: Type.Union([Type.Literal("single"), Type.Literal("parallel")]),
|
|
88
|
+
results: Type.Array(RunReportSchema),
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
/** The report for one run; `undefined` fields are omitted, not null. */
|
|
92
|
+
export function subagentStructuredContent(
|
|
93
|
+
mode: "single" | "parallel",
|
|
94
|
+
results: SubagentResult[],
|
|
95
|
+
): { mode: "single" | "parallel"; results: SubagentRunReport[] } {
|
|
96
|
+
return {
|
|
97
|
+
mode,
|
|
98
|
+
results: results.map((result) => ({
|
|
99
|
+
agent: result.agent,
|
|
100
|
+
task: result.task,
|
|
101
|
+
exitCode: result.exitCode,
|
|
102
|
+
...(result.childSessionId !== undefined && { childSessionId: result.childSessionId }),
|
|
103
|
+
...(result.outcome !== undefined && { outcome: result.outcome }),
|
|
104
|
+
usage: {
|
|
105
|
+
input: result.usage.input,
|
|
106
|
+
output: result.usage.output,
|
|
107
|
+
cacheRead: result.usage.cacheRead,
|
|
108
|
+
cacheWrite: result.usage.cacheWrite,
|
|
109
|
+
cost: result.usage.cost,
|
|
110
|
+
turns: result.usage.turns,
|
|
111
|
+
},
|
|
112
|
+
...(result.model !== undefined && { model: result.model }),
|
|
113
|
+
...(result.finalOutput !== undefined && { finalOutput: result.finalOutput }),
|
|
114
|
+
...(result.error !== undefined && { error: result.error }),
|
|
115
|
+
...(result.progressSummary !== undefined && { progressSummary: { ...result.progressSummary } }),
|
|
116
|
+
})),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
splitToolEntries,
|
|
4
|
+
readToolEntries,
|
|
5
|
+
validateToolEntries,
|
|
6
|
+
applyToolEntries,
|
|
7
|
+
unknownToolEntries,
|
|
8
|
+
formatUnknownToolsWarning,
|
|
9
|
+
} from "./tool-validation.js";
|
|
10
|
+
import type { AgentConfig } from "./agents.js";
|
|
11
|
+
|
|
12
|
+
// ── The split this module encodes ───────────────────────────────────────────
|
|
13
|
+
//
|
|
14
|
+
// Two questions, asked at two different moments:
|
|
15
|
+
//
|
|
16
|
+
// * Can this string be a tool name at all? — **lexical**. Answerable anywhere,
|
|
17
|
+
// including at config read, where there is no tool registry: `discoverAgents()`
|
|
18
|
+
// is pure filesystem. Rejects here.
|
|
19
|
+
// * Does pi have this tool right now? — **membership**. Needs the ExtensionAPI
|
|
20
|
+
// handle (`pi.getAllTools()`), so it can only be asked at dispatch, and the
|
|
21
|
+
// answer is a warning, never a refusal: a legitimate name is absent whenever
|
|
22
|
+
// its plugin is toggled off or its MCP server is disconnected.
|
|
23
|
+
//
|
|
24
|
+
// Nothing in this file asserts that a name is or is not a known tool except in
|
|
25
|
+
// the `unknownToolEntries` block, which is the membership half.
|
|
26
|
+
|
|
27
|
+
function makeAgent(overrides: Partial<AgentConfig> = {}): AgentConfig {
|
|
28
|
+
return {
|
|
29
|
+
name: "reviewer",
|
|
30
|
+
description: "d",
|
|
31
|
+
systemPrompt: "p",
|
|
32
|
+
source: "user",
|
|
33
|
+
filePath: "/tmp/agents/reviewer.md",
|
|
34
|
+
...overrides,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const CTX = { agentName: "reviewer", agentFilePath: "/tmp/agents/reviewer.md" };
|
|
39
|
+
|
|
40
|
+
describe("splitToolEntries", () => {
|
|
41
|
+
it("keeps empty entries so they can be reported instead of filtered away", () => {
|
|
42
|
+
expect(splitToolEntries("read,,bash")).toEqual(["read", "", "bash"]);
|
|
43
|
+
expect(splitToolEntries("read, bash ")).toEqual(["read", "bash"]);
|
|
44
|
+
expect(splitToolEntries(" ")).toEqual([""]);
|
|
45
|
+
expect(splitToolEntries("")).toEqual([]);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("validateToolEntries (lexical only)", () => {
|
|
50
|
+
it("accepts a plain list of names", () => {
|
|
51
|
+
expect(validateToolEntries(["read", "bash", "mcp__radius__query"], CTX)).toEqual({ ok: true });
|
|
52
|
+
expect(validateToolEntries([], CTX)).toEqual({ ok: true });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("rejects an empty entry and names it", () => {
|
|
56
|
+
const result = validateToolEntries(["read", "", "bash"], CTX);
|
|
57
|
+
expect(result.ok).toBe(false);
|
|
58
|
+
if (!result.ok) {
|
|
59
|
+
expect(result.error).toContain('""');
|
|
60
|
+
expect(result.error).toContain("reviewer");
|
|
61
|
+
expect(result.error).toContain("/tmp/agents/reviewer.md");
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("rejects a whitespace-only entry as the empty entry it parses to", () => {
|
|
66
|
+
const result = validateToolEntries(splitToolEntries("read, , bash"), CTX);
|
|
67
|
+
expect(result.ok).toBe(false);
|
|
68
|
+
if (!result.ok) {
|
|
69
|
+
expect(result.error).toContain('""');
|
|
70
|
+
expect(result.error).toMatch(/empty/i);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("rejects a leading-+ entry as a modifier and names it", () => {
|
|
75
|
+
const result = validateToolEntries(["read", "+web_search"], CTX);
|
|
76
|
+
expect(result.ok).toBe(false);
|
|
77
|
+
if (!result.ok) {
|
|
78
|
+
expect(result.error).toContain("+web_search");
|
|
79
|
+
// The reason matters: pi reads it as a modifier, not a name.
|
|
80
|
+
expect(result.error).toMatch(/modifier/i);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("rejects a leading-- entry as a modifier and names it", () => {
|
|
85
|
+
const result = validateToolEntries(["-bash"], CTX);
|
|
86
|
+
expect(result.ok).toBe(false);
|
|
87
|
+
if (!result.ok) expect(result.error).toContain("-bash");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("rejects a duplicated entry and names it", () => {
|
|
91
|
+
const result = validateToolEntries(["read", "bash", "read"], CTX);
|
|
92
|
+
expect(result.ok).toBe(false);
|
|
93
|
+
if (!result.ok) {
|
|
94
|
+
expect(result.error).toContain("read");
|
|
95
|
+
expect(result.error).toMatch(/twice|more than once/i);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("reports the first problem it finds, in list order", () => {
|
|
100
|
+
const result = validateToolEntries(["read", "read", "+bash"], CTX);
|
|
101
|
+
expect(result.ok).toBe(false);
|
|
102
|
+
if (!result.ok) expect(result.error).not.toContain("+bash");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("says the field and file when both are known, and degrades to a generic message when not", () => {
|
|
106
|
+
const named = validateToolEntries(["read", "read"], { agentName: "explore" });
|
|
107
|
+
expect(!named.ok && named.error).toContain("explore");
|
|
108
|
+
const bare = validateToolEntries(["read", "read"]);
|
|
109
|
+
expect(bare.ok).toBe(false);
|
|
110
|
+
if (!bare.ok) expect(bare.error).toContain("read");
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe("applyToolEntries (the edit-site half: reject leaves the previous value alone)", () => {
|
|
115
|
+
it("applies a valid list", () => {
|
|
116
|
+
const agent = makeAgent({ tools: ["bash"] });
|
|
117
|
+
const error = applyToolEntries(agent, ["read", "bash"], CTX);
|
|
118
|
+
expect(error).toBeNull();
|
|
119
|
+
expect(agent.tools).toEqual(["read", "bash"]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("an empty list means no allowlist, and clears the field", () => {
|
|
123
|
+
const agent = makeAgent({ tools: ["bash"] });
|
|
124
|
+
expect(applyToolEntries(agent, [], CTX)).toBeNull();
|
|
125
|
+
expect(agent.tools).toBeUndefined();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("a rejected entry leaves the previous list intact", () => {
|
|
129
|
+
const agent = makeAgent({ tools: ["read", "bash"] });
|
|
130
|
+
const error = applyToolEntries(agent, ["read", "+bash"], CTX);
|
|
131
|
+
expect(error).toBeTruthy();
|
|
132
|
+
expect(agent.tools).toEqual(["read", "bash"]);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("a rejected empty entry leaves the previous list intact", () => {
|
|
136
|
+
const agent = makeAgent({ tools: ["read"] });
|
|
137
|
+
expect(applyToolEntries(agent, ["read", " "], CTX)).toBeTruthy();
|
|
138
|
+
expect(agent.tools).toEqual(["read"]);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("a rejected duplicate leaves the previous list intact", () => {
|
|
142
|
+
const agent = makeAgent();
|
|
143
|
+
expect(applyToolEntries(agent, ["read", "read"], CTX)).toBeTruthy();
|
|
144
|
+
expect(agent.tools).toBeUndefined();
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe("applyToolEntries: a commit replaces the rejected value it is superseding", () => {
|
|
149
|
+
// The config read keeps an unreadable `tools:` in `extraFields` so that saving
|
|
150
|
+
// an agent cannot silently delete the line the user typed (`agents.ts`). That
|
|
151
|
+
// promise is to the *rejected* value. Once the panel commits a list, the old
|
|
152
|
+
// raw value has to go with the rejection: `serializeAgent` writes `tools` and
|
|
153
|
+
// then every extra field, so a surviving `extraFields.tools` puts a SECOND
|
|
154
|
+
// `tools:` key in the file (or the only one, when the list was cleared) and the
|
|
155
|
+
// agent comes back broken after a reload.
|
|
156
|
+
const rejected = () =>
|
|
157
|
+
makeAgent({
|
|
158
|
+
toolsError:
|
|
159
|
+
'Agent "reviewer" has a tools entry starting with "+" (in /tmp/agents/reviewer.md): "+web_search".',
|
|
160
|
+
extraFields: { tools: "read, +web_search", owner: "someone" },
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("drops the preserved raw value when a valid list is committed", () => {
|
|
164
|
+
const agent = rejected();
|
|
165
|
+
expect(applyToolEntries(agent, ["read"], CTX)).toBeNull();
|
|
166
|
+
expect(agent.tools).toEqual(["read"]);
|
|
167
|
+
expect(agent.extraFields ?? {}).not.toHaveProperty("tools");
|
|
168
|
+
// Only the superseded value goes; unrelated extras stay.
|
|
169
|
+
expect(agent.extraFields?.owner).toBe("someone");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("drops the preserved raw value when the allowlist is cleared", () => {
|
|
173
|
+
// Clearing is the case that used to look like success and change nothing:
|
|
174
|
+
// `tools` was deleted, the raw value stayed, and the writer put the broken
|
|
175
|
+
// line straight back.
|
|
176
|
+
const agent = rejected();
|
|
177
|
+
expect(applyToolEntries(agent, [], CTX)).toBeNull();
|
|
178
|
+
expect(agent.tools).toBeUndefined();
|
|
179
|
+
expect(agent.extraFields ?? {}).not.toHaveProperty("tools");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("clears the stale rejection so the agent dispatches again in this session", () => {
|
|
183
|
+
const agent = rejected();
|
|
184
|
+
expect(applyToolEntries(agent, ["read"], CTX)).toBeNull();
|
|
185
|
+
expect(agent.toolsError).toBeUndefined();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("a REFUSED commit destroys neither the list, the raw value, nor the rejection", () => {
|
|
189
|
+
const agent = rejected();
|
|
190
|
+
expect(applyToolEntries(agent, ["read", "+x"], CTX)).toBeTruthy();
|
|
191
|
+
expect(agent.tools).toBeUndefined();
|
|
192
|
+
expect(agent.extraFields?.tools).toBe("read, +web_search");
|
|
193
|
+
expect(agent.toolsError).toBeTruthy();
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("never mutates the extraFields object it was handed", () => {
|
|
197
|
+
// `/agents` builds its edit copy with `{ ...agent }` (`agent-panel.ts`), so
|
|
198
|
+
// `extraFields` is ONE object shared with `state.editOriginal` and with the
|
|
199
|
+
// discovered agent. Deleting a key in place therefore discards the preserved
|
|
200
|
+
// raw value from the *original* too: commit, then discard the edit, and the
|
|
201
|
+
// next save writes no `tools:` line at all — the allowlist disappears from
|
|
202
|
+
// the file without the user asking. Copy instead.
|
|
203
|
+
const shared = { tools: "read, +web_search", owner: "someone" };
|
|
204
|
+
const agent = makeAgent({ extraFields: shared });
|
|
205
|
+
expect(applyToolEntries(agent, ["read"], CTX)).toBeNull();
|
|
206
|
+
expect(shared.tools).toBe("read, +web_search");
|
|
207
|
+
expect(agent.extraFields).not.toBe(shared);
|
|
208
|
+
expect(agent.extraFields?.owner).toBe("someone");
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("drops the whole extraFields object when tools was the only field", () => {
|
|
212
|
+
// An empty record would otherwise render as an empty "extra fields" section
|
|
213
|
+
// and be re-written as nothing at all.
|
|
214
|
+
const agent = makeAgent({ extraFields: { tools: "read, +x" } });
|
|
215
|
+
expect(applyToolEntries(agent, ["read"], CTX)).toBeNull();
|
|
216
|
+
expect(agent.extraFields).toBeUndefined();
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
describe("unknownToolEntries (membership, asked at dispatch)", () => {
|
|
221
|
+
const KNOWN = ["read", "bash", "mcp__radius__query"];
|
|
222
|
+
|
|
223
|
+
it("reports entries pi does not currently have", () => {
|
|
224
|
+
expect(unknownToolEntries(["read", "web_search"], KNOWN)).toEqual(["web_search"]);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("reports nothing when every entry is available", () => {
|
|
228
|
+
expect(unknownToolEntries(["read", "bash"], KNOWN)).toEqual([]);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("treats a * entry as a pattern over the tools pi actually has", () => {
|
|
232
|
+
expect(unknownToolEntries(["mcp__radius__*"], KNOWN)).toEqual([]);
|
|
233
|
+
expect(unknownToolEntries(["mcp__nope__*"], KNOWN)).toEqual(["mcp__nope__*"]);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("names every offending entry in the warning", () => {
|
|
237
|
+
const warning = formatUnknownToolsWarning("reviewer", ["web_search", "sudo_exec"]);
|
|
238
|
+
expect(warning).toContain("web_search");
|
|
239
|
+
expect(warning).toContain("sudo_exec");
|
|
240
|
+
// The whole point of warn-not-fail: the run still happens.
|
|
241
|
+
expect(warning).toMatch(/proceed|continues/i);
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
describe("readToolEntries: every YAML spelling of a tools value", () => {
|
|
246
|
+
// pi's frontmatter parser hands the config read whatever YAML said, so the
|
|
247
|
+
// comma string this suite writes is only one of three shapes that mean
|
|
248
|
+
// "allowlist". Reading only that one silently dropped list-shaped configs —
|
|
249
|
+
// no allowlist at all, which is the unrestricted child ADR 0028 forbids.
|
|
250
|
+
const ok = (r: ReturnType<typeof readToolEntries>) => ("entries" in r ? r.entries : undefined);
|
|
251
|
+
|
|
252
|
+
it("reads the comma-separated form the suite's writer emits", () => {
|
|
253
|
+
expect(ok(readToolEntries("read, bash"))).toEqual(["read", "bash"]);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("reads a block list, trimming each entry", () => {
|
|
257
|
+
expect(ok(readToolEntries(["read", " bash "]))).toEqual(["read", "bash"]);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("reads an inline list, and keeps an empty entry visible for the name rules to reject", () => {
|
|
261
|
+
expect(ok(readToolEntries(["read", ""]))).toEqual(["read", ""]);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("reports an entry that is not a string, naming it", () => {
|
|
265
|
+
const r = readToolEntries(["read", { count: 2 }], { agentName: "probe" });
|
|
266
|
+
expect("error" in r ? r.error : undefined).toMatch(/not a tool name/);
|
|
267
|
+
expect("error" in r ? r.error : "").toContain('{"count":2}');
|
|
268
|
+
expect("error" in r ? r.error : "").toContain('Agent "probe"');
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("reports a value that is not a list at all, and says what to write instead", () => {
|
|
272
|
+
const r = readToolEntries(42, { agentFilePath: "/x/probe.md" });
|
|
273
|
+
expect("error" in r ? r.error : undefined).toMatch(/not a list of tool names/);
|
|
274
|
+
expect("error" in r ? r.error : "").toContain("/x/probe.md");
|
|
275
|
+
expect("error" in r ? r.error : "").toContain("read, bash");
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("an absent key is the caller's business, not an error here (it never reaches this gate)", () => {
|
|
279
|
+
expect(ok(readToolEntries(""))).toEqual([]);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("splits a list entry that carries a comma, because the child splits it too", () => {
|
|
283
|
+
// `--tools` is ONE argv string the host re-splits on commas
|
|
284
|
+
// (`pi/dist/cli/args.js`: `args[++i].split(",")`), so an entry written
|
|
285
|
+
// `"read,+bash"` in a YAML list reaches the child as two entries — the
|
|
286
|
+
// validator has to look at the same list the child will run with, or it
|
|
287
|
+
// certifies a shape it never sees.
|
|
288
|
+
expect(ok(readToolEntries(["read,+bash"]))).toEqual(["read", "+bash"]);
|
|
289
|
+
expect(ok(readToolEntries([" read , bash "]))).toEqual(["read", "bash"]);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it("keeps a hidden modifier visible for the name rules, so the read rejects it", () => {
|
|
293
|
+
const entries = readToolEntries(["read,+bash"]);
|
|
294
|
+
if (!("entries" in entries)) throw new Error("expected entries");
|
|
295
|
+
const check = validateToolEntries(entries.entries, CTX);
|
|
296
|
+
expect(check.ok).toBe(false);
|
|
297
|
+
if (!check.ok) expect(check.error).toContain("+bash");
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
describe("validateToolEntries: a comma inside one entry", () => {
|
|
302
|
+
it("is refused rather than certified, because the child would split it", () => {
|
|
303
|
+
// The read path splits, so this rule guards every *other* caller —
|
|
304
|
+
// `applyToolEntries` and anything that builds an AgentConfig directly. A
|
|
305
|
+
// list we validated must be the list the child runs with: pi would turn
|
|
306
|
+
// `["read,+bash"]` into `--tools read,+bash`, reject the whole flag as
|
|
307
|
+
// mixed names-and-modifiers, and run the child with NO allowlist at all
|
|
308
|
+
// (verified against a live 1.2.0 child: exit 1, `Error: --tools: tool
|
|
309
|
+
// names cannot be mixed with +name or -name entries`).
|
|
310
|
+
const result = validateToolEntries(["read", "read,+bash"], CTX);
|
|
311
|
+
expect(result.ok).toBe(false);
|
|
312
|
+
if (!result.ok) {
|
|
313
|
+
expect(result.error).toContain("read,+bash");
|
|
314
|
+
expect(result.error).toContain("reviewer");
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("is refused even when it is the only entry", () => {
|
|
319
|
+
expect(validateToolEntries(["a,b"], CTX).ok).toBe(false);
|
|
320
|
+
});
|
|
321
|
+
});
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import type { AgentConfig } from "./agents.js";
|
|
2
|
+
|
|
3
|
+
// ── Tool-name validation, split by what each site is allowed to know ────────
|
|
4
|
+
//
|
|
5
|
+
// Two questions look similar and are not:
|
|
6
|
+
//
|
|
7
|
+
// **Lexical** — can this string be a tool *name* at all? Empty/whitespace
|
|
8
|
+
// entries, duplicates, entries containing a comma (the child re-splits the one
|
|
9
|
+
// argv string on commas, so a comma means two entries by then), and entries
|
|
10
|
+
// starting with `+` or `-` cannot. pi 1.1.0
|
|
11
|
+
// made `+name`/`-name` **modifiers** on the default tool set, and its own CLI
|
|
12
|
+
// parser rejects a `--tools` list that mixes them with plain names outright —
|
|
13
|
+
// the flag is dropped with `Error: --tools: tool names cannot be mixed with
|
|
14
|
+
// +name or -name entries` and exit code 1, so such a list does not merely
|
|
15
|
+
// misbehave, it takes the whole allowlist (and the run) down with it. Nothing
|
|
16
|
+
// else about the entry matters here, and that is what lets the config read
|
|
17
|
+
// (`agents.ts`) and the `/agents` panel reject malformed input: neither site has
|
|
18
|
+
// a tool registry — `discoverAgents()` is pure filesystem and
|
|
19
|
+
// `pi.getAllTools()` needs the `ExtensionAPI` handle.
|
|
20
|
+
//
|
|
21
|
+
// **Membership** — does pi have this tool *right now*? Needs the handle, so it
|
|
22
|
+
// is asked at dispatch, and the answer is a warning naming the entry, never a
|
|
23
|
+
// refusal: a legitimate name is absent whenever the plugin that provides it is
|
|
24
|
+
// toggled off or the MCP server behind it is disconnected. Failing there would
|
|
25
|
+
// make `tools: web_search` unusable for anyone who disabled `web`.
|
|
26
|
+
//
|
|
27
|
+
// Shape and wording follow the model pre-validation precedent (ADR 0008,
|
|
28
|
+
// `model-validation.ts`): a pure gate returning `{ ok }`, a message that names
|
|
29
|
+
// the offending value and the file it came from, and says what to do instead.
|
|
30
|
+
|
|
31
|
+
export interface ValidateToolsContext {
|
|
32
|
+
/** Agent name the entry belongs to (used to address the message). */
|
|
33
|
+
agentName?: string | undefined;
|
|
34
|
+
/** File path of the agent config whose `tools:` field is being validated. */
|
|
35
|
+
agentFilePath?: string | undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type ValidateToolsResult = { ok: true } | { ok: false; error: string };
|
|
39
|
+
|
|
40
|
+
function address(context: ValidateToolsContext): { subject: string; where: string } {
|
|
41
|
+
return {
|
|
42
|
+
subject: context.agentName ? `Agent "${context.agentName}"` : "This agent config",
|
|
43
|
+
where: context.agentFilePath ? ` (in ${context.agentFilePath})` : "",
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Split a `tools:` value into entries.
|
|
49
|
+
*
|
|
50
|
+
* Empty entries are **kept** — filtering them here is what made a malformed list
|
|
51
|
+
* look legitimate. Entries are trimmed, because that is what the child receives.
|
|
52
|
+
*/
|
|
53
|
+
export function splitToolEntries(raw: string): string[] {
|
|
54
|
+
if (raw.length === 0) return [];
|
|
55
|
+
return raw.split(",").map((entry) => entry.trim());
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Split one YAML list entry the way the child will.
|
|
60
|
+
*
|
|
61
|
+
* `--tools` is a single argv string that the host re-splits on commas
|
|
62
|
+
* (`pi/dist/cli/args.js`: `args[++i].split(",")`), so a list entry written
|
|
63
|
+
* `"read,+bash"` is *two* entries by the time anything judges it. Validating the
|
|
64
|
+
* unsplit form certifies a list the child never runs with — and the resulting
|
|
65
|
+
* mix of names and modifiers is rejected wholesale by pi, so the child ends up
|
|
66
|
+
* with no allowlist at all. An entry without a comma is returned as the trimmed
|
|
67
|
+
* single entry, so a deliberate empty entry (`- ""`) stays visible for the name
|
|
68
|
+
* rules to report rather than vanishing here.
|
|
69
|
+
*/
|
|
70
|
+
function splitListEntry(item: string): string[] {
|
|
71
|
+
return item.includes(",") ? splitToolEntries(item) : [item.trim()];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export type ReadToolEntriesResult = { entries: string[] } | { error: string };
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Read a `tools:` **frontmatter value** as entries, before any name rules apply.
|
|
78
|
+
*
|
|
79
|
+
* pi's frontmatter parser hands over whatever YAML said, and there are three
|
|
80
|
+
* spellings in the wild: the comma-separated string this suite's writer emits,
|
|
81
|
+
* a hand-written block list (`tools:\n - read`), and an inline list
|
|
82
|
+
* (`tools: [read]`) — the two list forms both arrive as real arrays. All three
|
|
83
|
+
* mean the same allowlist, and a list entry is comma-split on the way in so the
|
|
84
|
+
* entries judged here are the entries the child receives (`splitListEntry`).
|
|
85
|
+
* Anything else (a number, a mapping, a list holding a
|
|
86
|
+
* mapping) is not a tool list, and must be *said* to be broken rather than
|
|
87
|
+
* skipped: an allowlist this function refuses to read is a child that runs with
|
|
88
|
+
* every tool the host has, which is the whole thing ADR 0028 is about.
|
|
89
|
+
*/
|
|
90
|
+
export function readToolEntries(
|
|
91
|
+
raw: unknown,
|
|
92
|
+
context: ValidateToolsContext = {},
|
|
93
|
+
): ReadToolEntriesResult {
|
|
94
|
+
const { subject, where } = address(context);
|
|
95
|
+
if (typeof raw === "string") return { entries: splitToolEntries(raw) };
|
|
96
|
+
if (Array.isArray(raw)) {
|
|
97
|
+
const entries: string[] = [];
|
|
98
|
+
for (const item of raw) {
|
|
99
|
+
if (typeof item !== "string") {
|
|
100
|
+
return {
|
|
101
|
+
error: `${subject} has a tools entry that is not a tool name${where}: ${JSON.stringify(item) ?? String(item)}. Each entry is a tool name on its own line (or one comma-separated list).`,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
entries.push(...splitListEntry(item));
|
|
105
|
+
}
|
|
106
|
+
return { entries };
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
error: `${subject}'s tools field${where} is not a list of tool names: ${JSON.stringify(raw) ?? String(raw)}. Write it as "read, bash" or as a YAML list with one tool name per entry.`,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Reject entries that cannot be tool names. Order matters: the first problem in
|
|
115
|
+
* list order is reported, so the user fixes one thing at a time.
|
|
116
|
+
*/
|
|
117
|
+
export function validateToolEntries(
|
|
118
|
+
entries: readonly string[],
|
|
119
|
+
context: ValidateToolsContext = {},
|
|
120
|
+
): ValidateToolsResult {
|
|
121
|
+
const { subject, where } = address(context);
|
|
122
|
+
const seen = new Set<string>();
|
|
123
|
+
|
|
124
|
+
for (const [index, entry] of entries.entries()) {
|
|
125
|
+
// An entry that is only whitespace is the empty entry it trims to; the list
|
|
126
|
+
// the child receives is trimmed, so it is reported that way.
|
|
127
|
+
if (entry.trim() === "") {
|
|
128
|
+
return {
|
|
129
|
+
ok: false,
|
|
130
|
+
error: `${subject} has an empty entry${where} in its tools list: "" (entry ${index + 1} of ${entries.length}). Entries are comma-separated tool names — remove the stray comma.`,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
const sign = entry.startsWith("+") ? "+" : entry.startsWith("-") ? "-" : "";
|
|
134
|
+
if (sign !== "") {
|
|
135
|
+
return {
|
|
136
|
+
ok: false,
|
|
137
|
+
error: `${subject} has a tools entry starting with "${sign}"${where}: "${entry}". pi reads +name/-name entries as modifiers on the default tool set, not as tool names, and a list that mixes them with names is rejected outright — the whole allowlist is dropped. Use the bare name "${entry.slice(1)}" to allow it, or leave it out.`,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
// The comma rule sits *after* the modifier rule deliberately: for an entry
|
|
141
|
+
// like `+a,b` the modifier is what the child chokes on first, and that
|
|
142
|
+
// message says more. `readToolEntries` already splits commas on the read
|
|
143
|
+
// path, so this rule guards every other caller (`applyToolEntries`, and
|
|
144
|
+
// anything that builds an AgentConfig directly): the list we validate has to
|
|
145
|
+
// be the list the child runs with, because pi re-splits the one argv string
|
|
146
|
+
// it is handed and drops a mixed list outright.
|
|
147
|
+
if (entry.includes(",")) {
|
|
148
|
+
const parts = entry.split(",").map((part) => part.trim()).filter((part) => part !== "");
|
|
149
|
+
return {
|
|
150
|
+
ok: false,
|
|
151
|
+
error: `${subject} has a tools entry containing a comma${where}: "${entry}". The allowlist reaches the child as one comma-separated list, so this entry becomes ${parts.length} entries by the time pi reads it — write them separately (${parts.map((part) => `"${part}"`).join(", ")}).`,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
if (seen.has(entry)) {
|
|
155
|
+
return {
|
|
156
|
+
ok: false,
|
|
157
|
+
error: `${subject} lists the tool "${entry}" twice${where} in its tools list. List each tool once.`,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
seen.add(entry);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return { ok: true };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Write a tools list onto an agent config, or refuse it.
|
|
168
|
+
*
|
|
169
|
+
* The edit-site half of the rule: a rejected value leaves `agent.tools` exactly
|
|
170
|
+
* as it was — a half-applied list is what a user would otherwise hand to a child
|
|
171
|
+
* without noticing. Returns the error message, or `null` when it applied.
|
|
172
|
+
*
|
|
173
|
+
* A commit also settles the two pieces of state that describe the list it is
|
|
174
|
+
* replacing. The config read keeps an unreadable `tools:` in `extraFields` so a
|
|
175
|
+
* save cannot delete the user's line, and records `toolsError` so dispatch and
|
|
176
|
+
* the panel refuse the agent; both are claims about the *old* value, and
|
|
177
|
+
* `serializeAgent` writes `tools` and every extra field — so leaving them
|
|
178
|
+
* behind puts a second (or, after a clear, the only) `tools:` key back in the
|
|
179
|
+
* file and the agent reads as broken again after a reload.
|
|
180
|
+
*/
|
|
181
|
+
export function applyToolEntries(
|
|
182
|
+
agent: AgentConfig,
|
|
183
|
+
entries: readonly string[],
|
|
184
|
+
context: ValidateToolsContext = {},
|
|
185
|
+
): string | null {
|
|
186
|
+
// Normalised first: the allowlist reaches the child as names, so " read " and
|
|
187
|
+
// "read" are the same entry here, not two.
|
|
188
|
+
const normalized = entries.map((entry) => entry.trim());
|
|
189
|
+
const result = validateToolEntries(normalized, {
|
|
190
|
+
agentName: context.agentName ?? agent.name,
|
|
191
|
+
agentFilePath: context.agentFilePath ?? agent.filePath,
|
|
192
|
+
});
|
|
193
|
+
if (!result.ok) return result.error;
|
|
194
|
+
if (normalized.length > 0) {
|
|
195
|
+
agent.tools = [...normalized];
|
|
196
|
+
} else {
|
|
197
|
+
delete agent.tools;
|
|
198
|
+
}
|
|
199
|
+
delete agent.toolsError;
|
|
200
|
+
// Copy, never mutate in place: `/agents` builds its edit copy with
|
|
201
|
+
// `{ ...agent }`, so `extraFields` is one object shared with `editOriginal`
|
|
202
|
+
// and the discovered agent. Deleting the key there would erase the preserved
|
|
203
|
+
// raw value from the original too, so a commit followed by a discard would
|
|
204
|
+
// save the agent with no `tools:` line at all.
|
|
205
|
+
if (agent.extraFields && "tools" in agent.extraFields) {
|
|
206
|
+
const rest: Record<string, unknown> = { ...agent.extraFields };
|
|
207
|
+
delete rest.tools;
|
|
208
|
+
if (Object.keys(rest).length > 0) agent.extraFields = rest;
|
|
209
|
+
else delete agent.extraFields;
|
|
210
|
+
}
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* `*` matches any characters, as pi's own `--tools` / `--exclude-tools` entries
|
|
216
|
+
* accept it (mirrors `createToolNameMatcher` in pi's `core/mcp-servers.js`, which
|
|
217
|
+
* is not part of pi's public API surface).
|
|
218
|
+
*/
|
|
219
|
+
function patternRegExp(pattern: string): RegExp {
|
|
220
|
+
const source = pattern
|
|
221
|
+
.split("*")
|
|
222
|
+
.map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&"))
|
|
223
|
+
.join(".*");
|
|
224
|
+
return new RegExp(`^${source}$`);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Entries that no currently-registered tool satisfies.
|
|
229
|
+
*
|
|
230
|
+
* Membership, so this belongs at dispatch (where `pi.getAllTools()` is in
|
|
231
|
+
* scope). A `*` entry is judged the way the host judges it — satisfied when at
|
|
232
|
+
* least one available tool matches — so `mcp__radius__*` is not reported while
|
|
233
|
+
* that server's tools are connected.
|
|
234
|
+
*/
|
|
235
|
+
export function unknownToolEntries(
|
|
236
|
+
entries: readonly string[],
|
|
237
|
+
knownNames: Iterable<string>,
|
|
238
|
+
): string[] {
|
|
239
|
+
const known = [...knownNames];
|
|
240
|
+
return entries.filter((entry) => {
|
|
241
|
+
if (entry.includes("*")) {
|
|
242
|
+
const pattern = patternRegExp(entry);
|
|
243
|
+
return !known.some((name) => pattern.test(name));
|
|
244
|
+
}
|
|
245
|
+
return !known.includes(entry);
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* The dispatch warning for unavailable entries.
|
|
251
|
+
*
|
|
252
|
+
* It says the run is proceeding because that is the decision worth reading:
|
|
253
|
+
* the alternative reading of an absent tool is "this config is broken", and it
|
|
254
|
+
* is usually wrong (plugin off, MCP server down).
|
|
255
|
+
*/
|
|
256
|
+
export function formatUnknownToolsWarning(agentName: string, entries: readonly string[]): string {
|
|
257
|
+
const listed = entries.map((entry) => `"${entry}"`).join(", ");
|
|
258
|
+
return [
|
|
259
|
+
`Subagent "${agentName}" asked for tool(s) pi does not currently have: ${listed}.`,
|
|
260
|
+
"The run proceeds without them — expected while the plugin that provides a tool is off, or its MCP server is disconnected.",
|
|
261
|
+
].join(" ");
|
|
262
|
+
}
|