@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.
Files changed (46) hide show
  1. package/README.md +1 -1
  2. package/package.json +10 -10
  3. package/src/agent-panel.mcp.test.ts +76 -0
  4. package/src/agent-panel.tools.test.ts +162 -0
  5. package/src/agent-panel.ts +37 -8
  6. package/src/agent-panel.width-audit.test.ts +258 -0
  7. package/src/agents-parse.test.ts +252 -0
  8. package/src/agents.ts +72 -5
  9. package/src/compact.test.ts +136 -57
  10. package/src/compact.ts +57 -37
  11. package/src/dispatch.test.ts +73 -1
  12. package/src/dispatch.ts +20 -2
  13. package/src/execute.test.ts +9 -1
  14. package/src/execute.ts +26 -29
  15. package/src/expanded.test.ts +61 -0
  16. package/src/expanded.ts +96 -80
  17. package/src/format.test.ts +71 -29
  18. package/src/format.ts +71 -28
  19. package/src/frontmatter-io.test.ts +62 -0
  20. package/src/frontmatter-io.ts +16 -2
  21. package/src/handlers.test.ts +21 -3
  22. package/src/handlers.ts +22 -3
  23. package/src/index.output-schema.test.ts +317 -0
  24. package/src/index.test.ts +486 -0
  25. package/src/index.ts +168 -8
  26. package/src/render.ts +46 -36
  27. package/src/row-block.test.ts +195 -0
  28. package/src/row-block.ts +186 -0
  29. package/src/run-outcome.test.ts +71 -0
  30. package/src/run-outcome.ts +84 -0
  31. package/src/spawn.test.ts +204 -19
  32. package/src/spawn.ts +49 -17
  33. package/src/stream.test.ts +215 -17
  34. package/src/stream.ts +47 -9
  35. package/src/thinking-validation.test.ts +96 -0
  36. package/src/thinking-validation.ts +78 -0
  37. package/src/tool-declarations.test.ts +58 -0
  38. package/src/tool-row.width-audit.test.ts +720 -0
  39. package/src/tool-schema.ts +85 -0
  40. package/src/tool-validation.test.ts +321 -0
  41. package/src/tool-validation.ts +262 -0
  42. package/src/types.ts +86 -0
  43. package/src/usage.test.ts +95 -0
  44. package/src/usage.ts +87 -0
  45. package/src/cost.test.ts +0 -62
  46. package/src/cost.ts +0 -8
@@ -0,0 +1,252 @@
1
+ /**
2
+ * The agent-config **read** path: frontmatter on disk → AgentConfig.
3
+ *
4
+ * `discoverAgents()` is pure filesystem (no ExtensionAPI handle, no tool
5
+ * registry), so this is where a config can be rejected on **lexical** grounds
6
+ * and nowhere that a name's membership in the tool set could be checked.
7
+ */
8
+
9
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
10
+ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import { tmpdir } from "node:os";
13
+ import { discoverAgents } from "./agents.js";
14
+
15
+ // Redirect getAgentDir() (and therefore the user agents dir) into a sandbox.
16
+ const testDir = join(tmpdir(), "pi-test-agents-parse");
17
+ process.env.PI_CODING_AGENT_DIR = testDir;
18
+
19
+ const agentsDir = join(testDir, "agents");
20
+
21
+ function writeAgent(file: string, frontmatter: string, body = "prompt"): void {
22
+ mkdirSync(agentsDir, { recursive: true });
23
+ writeFileSync(
24
+ join(agentsDir, `${file}.md`),
25
+ `---\nname: ${file}\ndescription: desc for ${file}\n${frontmatter}---\n${body}\n`,
26
+ "utf-8",
27
+ );
28
+ }
29
+
30
+ /** Discover the named agent from the sandbox user dir. */
31
+ function readAgent(name: string) {
32
+ return discoverAgents(testDir).find((a) => a.name === name);
33
+ }
34
+
35
+ describe("agent config read: mcp opt-in", () => {
36
+ beforeEach(() => {
37
+ rmSync(testDir, { recursive: true, force: true });
38
+ mkdirSync(testDir, { recursive: true });
39
+ });
40
+
41
+ afterEach(() => {
42
+ rmSync(testDir, { recursive: true, force: true });
43
+ });
44
+
45
+ it("mcp: true is parsed onto the config, not left in extraFields", () => {
46
+ writeAgent("closed", "tools: read, bash\nmcp: true\n");
47
+ const agent = readAgent("closed");
48
+ expect(agent?.mcp).toBe(true);
49
+ expect(agent?.extraFields ?? {}).not.toHaveProperty("mcp");
50
+ });
51
+
52
+ it("an absent mcp key leaves the config untouched (the closed default needs no key)", () => {
53
+ writeAgent("quiet", "tools: read, bash\n");
54
+ const agent = readAgent("quiet");
55
+ expect(agent?.mcp).toBeUndefined();
56
+ expect(agent?.extraFields ?? {}).not.toHaveProperty("mcp");
57
+ });
58
+
59
+ it("mcp: false is not an opt-in and is not kept as an unknown field", () => {
60
+ writeAgent("off", "tools: read\nmcp: false\n");
61
+ const agent = readAgent("off");
62
+ expect(agent?.mcp).not.toBe(true);
63
+ expect(agent?.extraFields ?? {}).not.toHaveProperty("mcp");
64
+ });
65
+
66
+ it("a quoted mcp: \"true\" still means opted in (frontmatter strings survive quoting)", () => {
67
+ writeAgent("quoted", "tools: read\nmcp: \"true\"\n");
68
+ expect(readAgent("quoted")?.mcp).toBe(true);
69
+ });
70
+
71
+ it("mcp without a tools allowlist is parsed but changes nothing downstream", () => {
72
+ writeAgent("noallow", "mcp: true\n");
73
+ expect(readAgent("noallow")?.mcp).toBe(true);
74
+ });
75
+ });
76
+
77
+ // ── The lexical half of validation (ADR 0028) ───────────────────────────────
78
+ //
79
+ // This read path has no tool registry — `discoverAgents()` touches only the
80
+ // filesystem — so nothing here may depend on whether a name is a real tool.
81
+ // A rejected `tools:` list is never applied (an allowlist that silently becomes
82
+ // a wider one is the failure this exists to prevent); the reason rides on the
83
+ // config so dispatch refuses it and the panel can show it.
84
+
85
+ describe("agent config read: malformed tools entries are rejected", () => {
86
+ beforeEach(() => {
87
+ rmSync(testDir, { recursive: true, force: true });
88
+ mkdirSync(testDir, { recursive: true });
89
+ });
90
+
91
+ afterEach(() => {
92
+ rmSync(testDir, { recursive: true, force: true });
93
+ });
94
+
95
+ it("accepts a well-formed list, unchanged", () => {
96
+ writeAgent("good", "tools: read, bash\n");
97
+ const agent = readAgent("good");
98
+ expect(agent?.tools).toEqual(["read", "bash"]);
99
+ expect(agent?.toolsError).toBeUndefined();
100
+ });
101
+
102
+ it("rejects an empty entry and names it on the config", () => {
103
+ writeAgent("empty", "tools: read, , bash\n");
104
+ const agent = readAgent("empty");
105
+ expect(agent?.tools).toBeUndefined();
106
+ expect(agent?.toolsError).toContain('""');
107
+ });
108
+
109
+ it("rejects a trailing comma (the empty entry it creates)", () => {
110
+ writeAgent("trailing", "tools: read, bash,\n");
111
+ const agent = readAgent("trailing");
112
+ expect(agent?.tools).toBeUndefined();
113
+ expect(agent?.toolsError).toBeTruthy();
114
+ });
115
+
116
+ it("rejects a duplicated entry and names it", () => {
117
+ writeAgent("dupe", "tools: read, bash, read\n");
118
+ const agent = readAgent("dupe");
119
+ expect(agent?.tools).toBeUndefined();
120
+ expect(agent?.toolsError).toContain("read");
121
+ });
122
+
123
+ it("rejects a +name entry and names it", () => {
124
+ writeAgent("mod", "tools: read, +web_search\n");
125
+ const agent = readAgent("mod");
126
+ expect(agent?.tools).toBeUndefined();
127
+ expect(agent?.toolsError).toContain("+web_search");
128
+ });
129
+
130
+ it("rejects a -name entry and names it", () => {
131
+ writeAgent("neg", "tools: read, -bash\n");
132
+ const agent = readAgent("neg");
133
+ expect(agent?.tools).toBeUndefined();
134
+ expect(agent?.toolsError).toContain("-bash");
135
+ });
136
+
137
+ it("still preserves a rejected list in extraFields, so saving the agent does not delete the user's line", () => {
138
+ writeAgent("mod2", "tools: read, +web_search\n");
139
+ const agent = readAgent("mod2");
140
+ // Preserved verbatim for the writer's benefit; nothing downstream reads
141
+ // extraFields as a tool list, and dispatch refuses the agent outright.
142
+ expect(agent?.extraFields?.tools).toBe("read, +web_search");
143
+ });
144
+
145
+ it("the rejection message names the agent and the file it came from", () => {
146
+ writeAgent("dupe2", "tools: read, read\n");
147
+ const agent = readAgent("dupe2");
148
+ expect(agent?.toolsError).toContain("dupe2");
149
+ expect(agent?.toolsError).toContain(join(agentsDir, "dupe2.md"));
150
+ });
151
+
152
+ it("a rejected tools list does not disturb the rest of the config", () => {
153
+ writeAgent("mixed", "tools: read, read\nmodel: anthropic/claude-opus-4.5\n", "body text");
154
+ const agent = readAgent("mixed");
155
+ expect(agent?.model).toBe("anthropic/claude-opus-4.5");
156
+ expect(agent?.systemPrompt).toContain("body text");
157
+ expect(agent?.toolsError).toBeTruthy();
158
+ });
159
+
160
+ it("list_agents says which agents have a rejected tools list", async () => {
161
+ const { formatAgentList } = await import("./agents.js");
162
+ writeAgent("broken", "tools: read, +bash\n");
163
+ writeAgent("fine", "tools: read\n");
164
+ const out = formatAgentList(discoverAgents(testDir));
165
+ const brokenLine = out.split("\n").find((l) => l.includes("broken"));
166
+ const fineLine = out.split("\n").find((l) => l.includes("fine "));
167
+ expect(brokenLine).toMatch(/invalid tools|tools rejected/i);
168
+ expect(fineLine).not.toMatch(/invalid tools|tools rejected/i);
169
+ });
170
+ });
171
+
172
+ describe("agent config read: `tools:` written as a YAML list", () => {
173
+ // pi's own frontmatter parser turns both `tools: a, b`-style block lists and
174
+ // inline lists into real arrays, and a hand-written agent file is far more
175
+ // likely to use one than the comma form this suite's writer emits. Reading
176
+ // only the string form meant a list-shaped allowlist was dropped on the floor:
177
+ // no allowlist, no error, and a child that runs with every tool the host has —
178
+ // the exact "wider than the config asked for" outcome the read path exists to
179
+ // prevent (and which a live dispatch reproduced: `tools: [read]` + MCP on).
180
+ beforeEach(() => {
181
+ rmSync(testDir, { recursive: true, force: true });
182
+ mkdirSync(testDir, { recursive: true });
183
+ });
184
+
185
+ afterEach(() => {
186
+ rmSync(testDir, { recursive: true, force: true });
187
+ });
188
+
189
+ it("applies a block list as the allowlist", () => {
190
+ writeAgent("blocky", "tools:\n - read\n - bash\nmcp: true\n");
191
+ const agent = readAgent("blocky");
192
+ expect(agent?.tools).toEqual(["read", "bash"]);
193
+ expect(agent?.toolsError).toBeUndefined();
194
+ // The list is an allowlist, so the MCP opt-in still has to survive next to it.
195
+ expect(agent?.mcp).toBe(true);
196
+ });
197
+
198
+ it("applies an inline list the same way", () => {
199
+ writeAgent("inliney", "tools: [read, bash]\n");
200
+ expect(readAgent("inliney")?.tools).toEqual(["read", "bash"]);
201
+ });
202
+
203
+ it("applies a one-entry list (which YAML reads as a bare string only when written that way)", () => {
204
+ writeAgent("single", "tools:\n - read\n");
205
+ expect(readAgent("single")?.tools).toEqual(["read"]);
206
+ });
207
+
208
+ it("rejects a list entry pi cannot read as a name, and applies nothing", () => {
209
+ writeAgent("blockbad", "tools:\n - read\n - +web_search\n");
210
+ const agent = readAgent("blockbad");
211
+ expect(agent?.tools).toBeUndefined();
212
+ expect(agent?.toolsError).toContain("+web_search");
213
+ // Preserved for the writer, same contract as the comma form.
214
+ expect(agent?.extraFields?.tools).toEqual(["read", "+web_search"]);
215
+ });
216
+
217
+ it("rejects a duplicate inside a list", () => {
218
+ writeAgent("blockdupe", "tools:\n - read\n - read\n");
219
+ const agent = readAgent("blockdupe");
220
+ expect(agent?.tools).toBeUndefined();
221
+ expect(agent?.toolsError).toContain("read");
222
+ });
223
+
224
+ it("rejects a list entry hiding a modifier behind a comma, naming the modifier", () => {
225
+ // The child re-splits `--tools` on commas, so this config is `--tools
226
+ // read,+bash` — which pi rejects outright, dropping the allowlist and
227
+ // leaving the child with the full default set. Read it as the two entries
228
+ // the child would see and refuse it here instead.
229
+ writeAgent("commainner", "tools:\n - read,+web_search\n");
230
+ const agent = readAgent("commainner");
231
+ expect(agent?.tools).toBeUndefined();
232
+ expect(agent?.toolsError).toContain("+web_search");
233
+ });
234
+
235
+ it("rejects a list holding something that is not a name at all", () => {
236
+ writeAgent("mixed", "tools:\n - read\n - count: 2\n");
237
+ const agent = readAgent("mixed");
238
+ expect(agent?.tools).toBeUndefined();
239
+ expect(agent?.toolsError).toBeTruthy();
240
+ });
241
+
242
+ it("rejects a tools value that is neither a list nor a list of names, instead of ignoring it", () => {
243
+ // `tools: 42` used to be preserved in extraFields and otherwise ignored —
244
+ // silently unbounded again. A config that means something by the key gets an
245
+ // error, not a shrug.
246
+ writeAgent("scalar", "tools: 42\n");
247
+ const agent = readAgent("scalar");
248
+ expect(agent?.tools).toBeUndefined();
249
+ expect(agent?.toolsError).toBeTruthy();
250
+ expect(agent?.extraFields?.tools).toBe(42);
251
+ });
252
+ });
package/src/agents.ts CHANGED
@@ -8,16 +8,49 @@ import * as os from "node:os";
8
8
  import * as path from "node:path";
9
9
  import { getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
10
10
  import { readLocalConfig } from "./local-config.js";
11
+ import { readToolEntries, validateToolEntries } from "./tool-validation.js";
11
12
 
12
13
  export interface AgentConfig {
13
14
  name: string;
14
15
  description: string;
15
16
  tools?: string[];
17
+ /**
18
+ * Opt this agent's child back in to connecting its MCP servers.
19
+ *
20
+ * pi 1.0.4 stopped a non-empty `--tools` filtering MCP tools: they stay
21
+ * registered (reachable from codemode and `tool_search`) unless the list names
22
+ * an `mcp__` entry (ADR 0028). The suite therefore passes `--no-mcp` alongside
23
+ * a non-empty allowlist; `mcp: true` is the one way a config asks for the
24
+ * servers back. Absent or false means the closed reading. Note the honest
25
+ * limit, verified against a live 1.2.0 child: pi does not *declare* MCP tools
26
+ * to a model whose allowlist does not match them, so with a non-empty `tools:`
27
+ * list the child sees the same declared set with or without this flag — what
28
+ * the flag changes is whether the servers connect at all (ADR 0028
29
+ * verification note). Parsed in `loadAgentsFromDir`, written by
30
+ * `serializeAgent`, shown on the `/agents` detail screen.
31
+ */
32
+ mcp?: boolean;
16
33
  model?: string;
17
34
  thinking?: string;
18
35
  systemPrompt: string;
19
36
  source: "global" | "user" | "project";
20
37
  filePath: string;
38
+ /**
39
+ * Set when the `tools:` list was **rejected at config read** — an entry pi
40
+ * cannot read as a tool name (empty, duplicated, containing a comma, or a
41
+ * `+`/`-` modifier), or a value that is not a tool list in any spelling pi's
42
+ * YAML parser produces (comma string, block list, inline list) — a number, a
43
+ * mapping, or a list holding something that is not a name.
44
+ *
45
+ * The list is never applied in that case: a rejected allowlist that falls back
46
+ * to "no allowlist" would hand the child a *wider* tool set than the config
47
+ * asked for. Dispatch refuses such an agent (naming the entry and the file),
48
+ * and the `/agents` panel and `list_agents` report it. The raw value stays in
49
+ * `extraFields.tools` so saving the agent does not silently delete the line; a
50
+ * list committed through `applyToolEntries` retires both the raw value and this
51
+ * error, so the fix actually reaches the file.
52
+ */
53
+ toolsError?: string;
21
54
  // Extra fields preserved from frontmatter but not editable in TUI
22
55
  extraFields?: Record<string, unknown>;
23
56
  }
@@ -52,11 +85,35 @@ function loadAgentsFromDir(dir: string, source: "global" | "user" | "project"):
52
85
 
53
86
  // Handle tools specially — preserve non-string values in extraFields
54
87
  let parsedTools: string[] | undefined;
55
- if (typeof frontmatter.tools === "string") {
56
- parsedTools = frontmatter.tools.split(",").map((t: string) => t.trim()).filter(Boolean);
88
+ let toolsError: string | undefined;
89
+ if (frontmatter.tools !== undefined) {
90
+ // Every YAML spelling is read (comma string, block list, inline list);
91
+ // anything that is not a list of names is rejected loudly rather than
92
+ // dropped, because an unread allowlist means an unrestricted child.
93
+ const read = readToolEntries(frontmatter.tools, {
94
+ agentName: String(frontmatter.name),
95
+ agentFilePath: filePath,
96
+ });
97
+ if ("error" in read) {
98
+ toolsError = read.error;
99
+ } else {
100
+ const check = validateToolEntries(read.entries, {
101
+ agentName: String(frontmatter.name),
102
+ agentFilePath: filePath,
103
+ });
104
+ if (check.ok) {
105
+ parsedTools = read.entries;
106
+ } else {
107
+ toolsError = check.error;
108
+ }
109
+ }
57
110
  }
58
111
 
59
- const knownKeys = new Set(["name", "description", "model", "thinking"]);
112
+ // `mcp: true` opts the child back in to MCP tools (ADR 0028). Quoted YAML
113
+ // gives the string "true", which means the same thing to a human reader.
114
+ const mcpOptIn = frontmatter.mcp === true || frontmatter.mcp === "true";
115
+
116
+ const knownKeys = new Set(["name", "description", "model", "thinking", "mcp"]);
60
117
  const extraFields: Record<string, unknown> = {};
61
118
  for (const [key, value] of Object.entries(frontmatter)) {
62
119
  if (key === "tools") continue; // tools is handled separately above/below
@@ -66,7 +123,9 @@ function loadAgentsFromDir(dir: string, source: "global" | "user" | "project"):
66
123
  extraFields[key] = value;
67
124
  }
68
125
  }
69
- // If tools was non-string, store it in extraFields for round-trip preservation
126
+ // If tools was non-string, store it in extraFields for round-trip preservation.
127
+ // A rejected string list is preserved for the same reason: the writer must not
128
+ // quietly delete a line the user typed.
70
129
  if (frontmatter.tools !== undefined && !parsedTools && "tools" in frontmatter) {
71
130
  extraFields.tools = frontmatter.tools;
72
131
  }
@@ -80,6 +139,8 @@ function loadAgentsFromDir(dir: string, source: "global" | "user" | "project"):
80
139
  };
81
140
  if (frontmatter.model) config.model = frontmatter.model as string;
82
141
  if (frontmatter.thinking) config.thinking = frontmatter.thinking as string;
142
+ if (mcpOptIn) config.mcp = true;
143
+ if (toolsError) config.toolsError = toolsError;
83
144
  if (parsedTools && parsedTools.length > 0) config.tools = parsedTools;
84
145
  if (Object.keys(extraFields).length > 0) config.extraFields = extraFields;
85
146
 
@@ -226,7 +287,13 @@ export function formatAgentList(agents: AgentConfig[]): string {
226
287
  let line = `• ${a.name} [${a.source}] — ${a.description}`;
227
288
  const extras: string[] = [];
228
289
  if (a.model) extras.push(`model: ${a.model}`);
229
- if (a.tools && a.tools.length > 0) extras.push(`${a.tools.length} tools`);
290
+ if (a.toolsError) {
291
+ // The list was rejected at read, so no allowlist is in force — say so here
292
+ // rather than letting the agent look like an unconfigured one.
293
+ extras.push("tools rejected — fix the tools: field");
294
+ } else if (a.tools && a.tools.length > 0) {
295
+ extras.push(`${a.tools.length} tools`);
296
+ }
230
297
  if (extras.length > 0) line += ` (${extras.join(", ")})`;
231
298
  return line;
232
299
  });