@pi-archimedes/subagent 2.10.4 → 2.11.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/README.md +2 -2
- 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 +472 -0
- package/src/expanded.ts +202 -82
- 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 +566 -0
- package/src/index.ts +169 -16
- package/src/render.ts +46 -36
- package/src/row-block.test.ts +244 -0
- package/src/row-block.ts +209 -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.test.ts +154 -0
- package/src/tool-schema.ts +106 -4
- 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
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Issue #114 — the parameter set `subagent` declares is the whole contract, so
|
|
3
|
+
* every name in it must be a name `execute` actually reads.
|
|
4
|
+
*
|
|
5
|
+
* #114 deleted a declared `async` boolean ("Run asynchronously
|
|
6
|
+
* (fire-and-forget)") that nothing read: a model that passed `async: true` got a
|
|
7
|
+
* fully synchronous run with no warning. That is a false statement in a
|
|
8
|
+
* model-facing contract, and worse than a missing feature, because the contract
|
|
9
|
+
* *is* what the model believes about the tool — it is what gets rendered into
|
|
10
|
+
* the tool list, what the model plans around, what a wrong answer gets blamed
|
|
11
|
+
* on. Deleting the claim is the fix; implementing fire-and-forget is out of
|
|
12
|
+
* scope and blocked on #113 (reaping subagent children across session
|
|
13
|
+
* replacement).
|
|
14
|
+
*
|
|
15
|
+
* The review that followed found a SECOND instance of the same defect class the
|
|
16
|
+
* issue never named: `count?: number` on the parallel task item, read nowhere.
|
|
17
|
+
* It differed from `async` in one instructive way: it lived only in the
|
|
18
|
+
* hand-written `params` type on `execute` in `index.ts`, never in `TaskItem`, so
|
|
19
|
+
* the JSON Schema the model sees never advertised it and a guard that enumerates
|
|
20
|
+
* the schema alone could not have caught it. The root cause of both was one
|
|
21
|
+
* contract stated twice — once for the model, once for TypeScript — with nothing
|
|
22
|
+
* keeping the two in step. So the fix is structural rather than another
|
|
23
|
+
* assertion: `SubagentParams` in `tool-schema.ts` is
|
|
24
|
+
* `Static<typeof SUBAGENT_PARAMS_SCHEMA>` and `execute` takes that, which is the
|
|
25
|
+
* `ask` / `sudo` precedent. There is no second list left to drift on its own, so
|
|
26
|
+
* the only low-friction way to introduce a `count` is to say so at one of the
|
|
27
|
+
* two surfaces a reviewer reads: the schema, or `execute`'s signature.
|
|
28
|
+
*
|
|
29
|
+
* What is guarded where, stated honestly:
|
|
30
|
+
*
|
|
31
|
+
* - the schema's declared sets are pinned below, exactly and enumerated from
|
|
32
|
+
* the schema object rather than restated;
|
|
33
|
+
* - there is no second TS-side list left for a test to police. `tsc` does
|
|
34
|
+
* reject a params type that *contradicts* the schema — `model?: number` at
|
|
35
|
+
* the params position fails `registerTool`'s `execute` with TS2322 — but
|
|
36
|
+
* that check is neither new nor the derivation's doing: `registerTool`
|
|
37
|
+
* infers `TParams` from `parameters` and declares `execute`'s `params` as
|
|
38
|
+
* `Static<TParams>` (`pi-coding-agent` `dist/core/extensions/types.d.ts:514`),
|
|
39
|
+
* so the same TS2322 fires with or without `SubagentParams` (measured against
|
|
40
|
+
* the pre-derivation literal, and it failed there too).
|
|
41
|
+
* - what `tsc` never caught, and still does not, is the phantom itself. An
|
|
42
|
+
* extra *optional* member only widens the params type, and pi declares
|
|
43
|
+
* `execute` as a method, so method parameters are bivariant: `count?: number`
|
|
44
|
+
* in the old literal, in `TaskItem` inside that literal, and spliced on as
|
|
45
|
+
* `params: SubagentParams & { count?: number }` all compile (each measured).
|
|
46
|
+
* So the derivation removes the *place* a phantom hid — it does not add a
|
|
47
|
+
* compiler check that would catch one. Writing a phantom now takes a
|
|
48
|
+
* deliberate `& { … }` at the params position, an edit a reviewer sees, where
|
|
49
|
+
* before it was the default way to state the contract.
|
|
50
|
+
* - the residual gap, stated against what this branch removed rather than
|
|
51
|
+
* against the compiler. The ~370-line source-text scanner deleted in
|
|
52
|
+
* `ca1a328` DID catch a declared-and-unread task field: it parsed
|
|
53
|
+
* `index.ts` as text, derived the task fields the parallel branch reads
|
|
54
|
+
* (`readTaskFields()`) and compared them against
|
|
55
|
+
* `declaredTaskItemParams()`, pinning both directions — declared and not
|
|
56
|
+
* read, and read and not declared. So type erasure was never what defeated
|
|
57
|
+
* that guard: it read the file, and a test over source text does see what
|
|
58
|
+
* the body reads. It was deleted for its false-positive cost, not because it
|
|
59
|
+
* did not work — it could redden on an unrelated `index.ts` edit (Greptile's
|
|
60
|
+
* P2 on PR #143), and that brittleness is why the maintainer took the
|
|
61
|
+
* schema-shared-type option instead. Losing it has a real price: on the
|
|
62
|
+
* task-item surface a schema field that `execute` declares but ignores is
|
|
63
|
+
* once again undetectable here, which is exactly how `async` survived. The
|
|
64
|
+
* cheap, non-brittle replacement is a BEHAVIOURAL pin, not a second parser:
|
|
65
|
+
* `index.test.ts` ("the object forwarded to the executor") now asserts the
|
|
66
|
+
* exact key set handed to `executeParallel` / `executeSubagent`, so dropping
|
|
67
|
+
* a forwarded task field — or adding one nobody declared — fails a test. Its
|
|
68
|
+
* limit, stated rather than sold: it pins the shape handed over, so a schema
|
|
69
|
+
* field that `execute` never forwards at all is invisible to it, and it
|
|
70
|
+
* cannot prove the executor reads a key it is handed. That residue is the
|
|
71
|
+
* phantom class, and it is genuinely uncovered again. What still covers it is
|
|
72
|
+
* the two pins below, a schema small enough to read in one screen — five
|
|
73
|
+
* top-level parameters, four task fields — and review.
|
|
74
|
+
*
|
|
75
|
+
* If a name reappears here it has to arrive together with the behaviour that
|
|
76
|
+
* honours it (#113 first, for `async`) and a consumer that reads it; until then
|
|
77
|
+
* the honest description is the one that does not mention it.
|
|
78
|
+
*/
|
|
79
|
+
|
|
80
|
+
import { describe, it, expect } from "vitest";
|
|
81
|
+
import { Value } from "typebox/value";
|
|
82
|
+
import { SUBAGENT_PARAMS_SCHEMA } from "./tool-schema.js";
|
|
83
|
+
|
|
84
|
+
/** Read a schema node's declared property names, failing loudly if it has none. */
|
|
85
|
+
function propertyNames(node: unknown, what: string): string[] {
|
|
86
|
+
const properties = (node as { properties?: Record<string, unknown> }).properties;
|
|
87
|
+
if (!properties) throw new Error(`${what} declares no properties`);
|
|
88
|
+
return Object.keys(properties);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The top-level parameters `subagent` declares. Read from the schema, never restated. */
|
|
92
|
+
function declaredParams(): string[] {
|
|
93
|
+
return propertyNames(SUBAGENT_PARAMS_SCHEMA, "SUBAGENT_PARAMS_SCHEMA");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The properties declared for ONE entry of the `tasks` array — `TaskItem`.
|
|
98
|
+
* Reached through the schema rather than exported from `tool-schema.ts`, so the
|
|
99
|
+
* guard depends on the contract the model actually receives and the module
|
|
100
|
+
* keeps its current public surface (`TaskItem` is deliberately unexported).
|
|
101
|
+
*/
|
|
102
|
+
function declaredTaskItemParams(): string[] {
|
|
103
|
+
const tasks = (SUBAGENT_PARAMS_SCHEMA as { properties?: Record<string, unknown> }).properties?.tasks;
|
|
104
|
+
if (!tasks) throw new Error("SUBAGENT_PARAMS_SCHEMA declares no `tasks` property");
|
|
105
|
+
const items = (tasks as { items?: unknown }).items;
|
|
106
|
+
if (!items) throw new Error("`tasks` is not an array schema — no item schema to enumerate");
|
|
107
|
+
return propertyNames(items, "The `tasks` item schema (TaskItem)");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
describe("SUBAGENT_PARAMS_SCHEMA — declared parameter set (issue #114)", () => {
|
|
111
|
+
it("pins the five declared parameters", () => {
|
|
112
|
+
// Exact and sorted, so it fails in BOTH directions: adding a name the pin
|
|
113
|
+
// does not know (`async` would) and deleting one of the five. Read from the
|
|
114
|
+
// schema object, never restated by hand. And because `SubagentParams` is
|
|
115
|
+
// derived, adding a name here is also adding it to what `execute` is handed,
|
|
116
|
+
// so this pin is where a new name has to be justified. What it does NOT
|
|
117
|
+
// check is whether anything reads the name — that cross-surface claim
|
|
118
|
+
// belonged to the scanner described in the header and no longer exists, so
|
|
119
|
+
// the name says only what the body proves.
|
|
120
|
+
expect([...declaredParams()].sort()).toEqual(["agent", "cwd", "model", "task", "tasks"]);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("TaskItem — declared parallel task item (issue #114, second instance: `count`)", () => {
|
|
125
|
+
it("pins the four declared task-item properties", () => {
|
|
126
|
+
// `count` was the proof this surface needs its own pin: a phantom can be
|
|
127
|
+
// added to the task item while the top-level set above stays green. Like the
|
|
128
|
+
// pin above this one enumerates the SCHEMA only — with the scanner gone
|
|
129
|
+
// nothing here compares the four against what the parallel branch reads, so
|
|
130
|
+
// the name claims the enumeration and no more.
|
|
131
|
+
expect([...declaredTaskItemParams()].sort()).toEqual(["agent", "cwd", "model", "task"]);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// Legacy-caller compatibility check — a real behavioural guarantee, so it is
|
|
136
|
+
// pinned rather than assumed. TypeBox `Type.Object` does not set
|
|
137
|
+
// `additionalProperties: false`, so deleting `async` — and deleting `count` from
|
|
138
|
+
// the TS type, which the schema never advertised anyway — stops promising the
|
|
139
|
+
// option without turning an existing caller's payload into a validation error: a
|
|
140
|
+
// caller still passing `async: true`, or a `count` on a task, keeps validating, it
|
|
141
|
+
// just no longer gets a promise about it. That is what makes both deletions safe
|
|
142
|
+
// to land in a patch release, and it is the one thing here that fails on its own
|
|
143
|
+
// if anyone ever tightens the schema with `additionalProperties: false`.
|
|
144
|
+
describe("SUBAGENT_PARAMS_SCHEMA — legacy callers passing the deleted fields still validate", () => {
|
|
145
|
+
it("accepts a payload carrying `async`", () => {
|
|
146
|
+
expect(Value.Check(SUBAGENT_PARAMS_SCHEMA as never, { task: "x", async: true })).toBe(true);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("accepts a task item carrying `count`", () => {
|
|
150
|
+
expect(
|
|
151
|
+
Value.Check(SUBAGENT_PARAMS_SCHEMA as never, { tasks: [{ task: "x", count: 1 }] }),
|
|
152
|
+
).toBe(true);
|
|
153
|
+
});
|
|
154
|
+
});
|
package/src/tool-schema.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { Type } from "typebox";
|
|
1
|
+
import { Type, type Static } from "typebox";
|
|
2
|
+
import type { SubagentResult, SubagentRunReport } from "./types.js";
|
|
2
3
|
|
|
3
4
|
// ── JSON Schema for tool parameters (TypeBox) ──────────────────────────────
|
|
4
5
|
|
|
@@ -24,10 +25,111 @@ export const SUBAGENT_PARAMS_SCHEMA = Type.Object({
|
|
|
24
25
|
model: Type.Optional(Type.String({
|
|
25
26
|
description: "Model override for the subagent",
|
|
26
27
|
})),
|
|
27
|
-
async: Type.Optional(Type.Boolean({
|
|
28
|
-
description: "Run asynchronously (fire-and-forget)",
|
|
29
|
-
})),
|
|
30
28
|
cwd: Type.Optional(Type.String({
|
|
31
29
|
description: "Working directory for the subagent",
|
|
32
30
|
})),
|
|
33
31
|
});
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* What `execute` is handed, derived from the schema rather than written out —
|
|
35
|
+
* the `ask` / `sudo` precedent (`AskParams`, `SudoExecInput`).
|
|
36
|
+
*
|
|
37
|
+
* #114's two phantoms were only possible because one contract was stated twice,
|
|
38
|
+
* independently: `async` in this schema, `count?: number` in the hand-written
|
|
39
|
+
* `params` type in `index.ts`. Nothing kept the two in step, and only one of them
|
|
40
|
+
* is what the model sees. Stating the contract once removes the second list: the
|
|
41
|
+
* schema is now the only *default* source of a name in `execute`'s declared
|
|
42
|
+
* params type — see the caveat below, which is the whole difference between
|
|
43
|
+
* "only default" and "only".
|
|
44
|
+
*
|
|
45
|
+
* That is a structural fix, not a check. It does not stop anyone reading a
|
|
46
|
+
* declared field, and it does not make a phantom a compile error — `execute` is
|
|
47
|
+
* declared as a method, so bivariant parameters accept a widening `& { … }` at
|
|
48
|
+
* the params position. See the header of `tool-schema.test.ts` for what is
|
|
49
|
+
* enforced where, measured.
|
|
50
|
+
*/
|
|
51
|
+
export type SubagentParams = Static<typeof SUBAGENT_PARAMS_SCHEMA>;
|
|
52
|
+
|
|
53
|
+
// ── JSON Schema for `structuredContent` ────────────────────────────────
|
|
54
|
+
//
|
|
55
|
+
// One report per run: what ran, how it ended, what it cost, what it answered.
|
|
56
|
+
// It is `details` minus the live-progress blob — `details.progress[i].output`
|
|
57
|
+
// is the child's whole streamed scrollback and `toolCalls` its call history, for
|
|
58
|
+
// the row renderer, and a script that wants the answer already has
|
|
59
|
+
// `finalOutput`. `usageAccountedElsewhere` is left out too: it is this session's
|
|
60
|
+
// bookkeeping flag (ADR 0028), not a fact about the run.
|
|
61
|
+
//
|
|
62
|
+
// pi hands this to a codemode script instead of the text and validates it
|
|
63
|
+
// against nothing, so `index.output-schema.test.ts` runs the handler and checks
|
|
64
|
+
// the payload against this declaration.
|
|
65
|
+
//
|
|
66
|
+
// A single run that reports itself as failed is `isError` and carries none of it
|
|
67
|
+
// (see the note in `index.ts`), so a script keeps rejecting with the text it
|
|
68
|
+
// rejects with today. A parallel batch is the other case: one failed run inside
|
|
69
|
+
// a batch does not make the batch an error — it stays a report of N runs, with
|
|
70
|
+
// that run's `error` field set inside `results` (pre-existing shape, unchanged
|
|
71
|
+
// by these schemas; see the per-run failure markers in `formatParallelResults`).
|
|
72
|
+
|
|
73
|
+
const UsageSchema = Type.Object({
|
|
74
|
+
input: Type.Number(),
|
|
75
|
+
output: Type.Number(),
|
|
76
|
+
cacheRead: Type.Number(),
|
|
77
|
+
cacheWrite: Type.Number(),
|
|
78
|
+
cost: Type.Number(),
|
|
79
|
+
turns: Type.Number(),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const ProgressSummarySchema = Type.Object({
|
|
83
|
+
toolCount: Type.Number(),
|
|
84
|
+
tokens: Type.Number(),
|
|
85
|
+
durationMs: Type.Number(),
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const RunReportSchema = Type.Object({
|
|
89
|
+
agent: Type.String(),
|
|
90
|
+
task: Type.String(),
|
|
91
|
+
exitCode: Type.Number(),
|
|
92
|
+
childSessionId: Type.Optional(Type.String()),
|
|
93
|
+
outcome: Type.Optional(
|
|
94
|
+
Type.Union([Type.Literal("completed"), Type.Literal("truncated"), Type.Literal("aborted")]),
|
|
95
|
+
),
|
|
96
|
+
usage: UsageSchema,
|
|
97
|
+
model: Type.Optional(Type.String()),
|
|
98
|
+
finalOutput: Type.Optional(Type.String({ description: "The run's answer text, when it produced one" })),
|
|
99
|
+
error: Type.Optional(Type.String({ description: "Set when the run failed before or during execution" })),
|
|
100
|
+
progressSummary: Type.Optional(ProgressSummarySchema),
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
export const SUBAGENT_OUTPUT_SCHEMA = Type.Object({
|
|
104
|
+
mode: Type.Union([Type.Literal("single"), Type.Literal("parallel")]),
|
|
105
|
+
results: Type.Array(RunReportSchema),
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
/** The report for one run; `undefined` fields are omitted, not null. */
|
|
109
|
+
export function subagentStructuredContent(
|
|
110
|
+
mode: "single" | "parallel",
|
|
111
|
+
results: SubagentResult[],
|
|
112
|
+
): { mode: "single" | "parallel"; results: SubagentRunReport[] } {
|
|
113
|
+
return {
|
|
114
|
+
mode,
|
|
115
|
+
results: results.map((result) => ({
|
|
116
|
+
agent: result.agent,
|
|
117
|
+
task: result.task,
|
|
118
|
+
exitCode: result.exitCode,
|
|
119
|
+
...(result.childSessionId !== undefined && { childSessionId: result.childSessionId }),
|
|
120
|
+
...(result.outcome !== undefined && { outcome: result.outcome }),
|
|
121
|
+
usage: {
|
|
122
|
+
input: result.usage.input,
|
|
123
|
+
output: result.usage.output,
|
|
124
|
+
cacheRead: result.usage.cacheRead,
|
|
125
|
+
cacheWrite: result.usage.cacheWrite,
|
|
126
|
+
cost: result.usage.cost,
|
|
127
|
+
turns: result.usage.turns,
|
|
128
|
+
},
|
|
129
|
+
...(result.model !== undefined && { model: result.model }),
|
|
130
|
+
...(result.finalOutput !== undefined && { finalOutput: result.finalOutput }),
|
|
131
|
+
...(result.error !== undefined && { error: result.error }),
|
|
132
|
+
...(result.progressSummary !== undefined && { progressSummary: { ...result.progressSummary } }),
|
|
133
|
+
})),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
@@ -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
|
+
});
|