@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,96 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ THINKING_LEVELS,
4
+ THINKING_LEVELS_PI_VERSION,
5
+ isThinkingLevel,
6
+ resolveThinkingLevel,
7
+ formatUnknownThinkingWarning,
8
+ } from "./thinking-validation.js";
9
+
10
+ // pi's CLI keeps its own list of accepted `--thinking` levels and, for anything
11
+ // outside it, warns and drops the flag rather than failing
12
+ // (`pi/dist/cli/args.js`: `VALID_THINKING_LEVELS`). A child configured with a
13
+ // level pi does not take therefore runs at the default while its config claims
14
+ // otherwise, and in `--mode json` the warning lands in stderr — which the stream
15
+ // reader only ever shows for a failed run.
16
+ //
17
+ // This file pins the copy of that list the suite checks against. The host's own
18
+ // validator cannot be consulted: pi's `exports` map publishes only `"."` and
19
+ // `"./rpc-entry"`, so `dist/cli/args.js` is not importable, and the root entry
20
+ // re-exports neither `isValidThinkingLevel` nor `VALID_THINKING_LEVELS` nor the
21
+ // `ThinkingLevel` type that could pin it at compile time. What the test can do is
22
+ // pin the list and the pi version it claims to describe, so drift is a
23
+ // deliberately-edited constant rather than an invisible difference — and the
24
+ // consequence of not editing it stays a warning plus pi's default, never a
25
+ // dispatch refused over a word the host has since learned.
26
+
27
+ describe("isThinkingLevel", () => {
28
+ it("accepts exactly pi's seven levels", () => {
29
+ expect([...THINKING_LEVELS]).toEqual([
30
+ "off",
31
+ "minimal",
32
+ "low",
33
+ "medium",
34
+ "high",
35
+ "xhigh",
36
+ "max",
37
+ ]);
38
+ for (const level of THINKING_LEVELS) expect(isThinkingLevel(level)).toBe(true);
39
+ });
40
+
41
+ it("refines the value it accepts", () => {
42
+ const value: unknown = "high";
43
+ if (isThinkingLevel(value)) {
44
+ const asString: string = value;
45
+ expect(asString).toBe("high");
46
+ } else {
47
+ throw new Error("high should be a thinking level");
48
+ }
49
+ });
50
+
51
+ it("refuses a level pi warns about and drops", () => {
52
+ for (const bad of ["ultra", "Medium", "medium ", "highest", "auto", "0"]) {
53
+ expect(isThinkingLevel(bad), bad).toBe(false);
54
+ }
55
+ });
56
+
57
+ it("refuses anything that is not a string at all", () => {
58
+ for (const bad of [undefined, null, 2, true, ["low"]]) {
59
+ expect(isThinkingLevel(bad)).toBe(false);
60
+ }
61
+ });
62
+ });
63
+
64
+ describe("resolveThinkingLevel", () => {
65
+ it("passes a valid level through", () => {
66
+ expect(resolveThinkingLevel("xhigh")).toEqual({ level: "xhigh", warning: undefined });
67
+ });
68
+
69
+ it("says nothing when the config set no level", () => {
70
+ expect(resolveThinkingLevel(undefined)).toEqual({ level: undefined, warning: undefined });
71
+ expect(resolveThinkingLevel("")).toEqual({ level: undefined, warning: undefined });
72
+ });
73
+
74
+ it("withholds a level pi would drop, and says so", () => {
75
+ const resolved = resolveThinkingLevel("ultra");
76
+ expect(resolved.level).toBeUndefined();
77
+ expect(resolved.warning).toContain('"ultra"');
78
+ expect(resolved.warning).toMatch(/default thinking level/i);
79
+ for (const level of THINKING_LEVELS) {
80
+ expect(resolved.warning).toContain(level);
81
+ }
82
+ });
83
+ });
84
+
85
+ describe("formatUnknownThinkingWarning", () => {
86
+ it("names the value rather than describing the problem in the abstract", () => {
87
+ expect(formatUnknownThinkingWarning("turbo")).toContain("turbo");
88
+ });
89
+
90
+ it("says which pi the level list came from", () => {
91
+ // The list is a copy of the host's, so the sentence that quotes it has to
92
+ // say whose list it is — a reader on a newer pi should question it rather
93
+ // than trust it.
94
+ expect(formatUnknownThinkingWarning("turbo")).toContain(THINKING_LEVELS_PI_VERSION);
95
+ });
96
+ });
@@ -0,0 +1,78 @@
1
+ // ── The `thinking:` field: what the child will actually accept ─────────────
2
+ //
3
+ // A child is spawned with `--thinking <level>`, and pi's CLI parser keeps its
4
+ // own list (`pi/dist/cli/args.js`: `VALID_THINKING_LEVELS`, `isValidThinkingLevel`).
5
+ // A level outside that list is **not** an error there: the parser pushes a
6
+ // `warning` diagnostic and drops the flag, so the child runs at the default
7
+ // thinking level while the config says something else. In `--mode json` that
8
+ // warning goes to stderr, and `stream.ts` only reads stderr for a *failed* run —
9
+ // so a successful dispatch reported "thinking: medium" having run nothing of the
10
+ // sort. Greptile found it; a live 1.2.0 child confirmed the mechanism.
11
+ //
12
+ // So the suite drops the same value first and says so out loud, at dispatch,
13
+ // where the user is watching. Not a refusal: the run still happens, at exactly
14
+ // the level pi would have used anyway — the difference is that it is now named
15
+ // instead of swallowed. That is also what keeps this safe across a pi upgrade
16
+ // that *adds* a level: the worst case is a warning and the default, never a
17
+ // dispatch refused over a word the host has since learned.
18
+ //
19
+ // The list is duplicated here because there is no other way to read it. pi
20
+ // exports only `"."` and `"./rpc-entry"` (its package `exports` map), so
21
+ // `@earendil-works/pi-coding-agent/dist/cli/args.js` — where `isValidThinkingLevel`
22
+ // and `VALID_THINKING_LEVELS` live — is not importable; the root entry does not
23
+ // re-export either, nor the `ThinkingLevel` type that would pin the set at
24
+ // compile time (that lives in `@earendil-works/pi-agent-core`, which this package
25
+ // cannot resolve). So the copy below is checked by test, not by the host, and the
26
+ // warning names the pi version it was read from — `thinking-validation.test.ts`
27
+ // pins both. `model-validation.ts` (ADR 0008) already keeps a host-owned set the
28
+ // same way for model ids, obtaining those through the `modelRegistry` handle
29
+ // instead, which for levels does not exist.
30
+
31
+ /** pi's own `VALID_THINKING_LEVELS` were read from, and the version they were
32
+ * read from. The warning quotes the version so a reader on a newer host knows
33
+ * which list they are being handed. */
34
+ export const THINKING_LEVELS_PI_VERSION = "1.2.0";
35
+
36
+ /** pi 1.2.0's `VALID_THINKING_LEVELS`, in pi's order. */
37
+ export const THINKING_LEVELS = [
38
+ "off",
39
+ "minimal",
40
+ "low",
41
+ "medium",
42
+ "high",
43
+ "xhigh",
44
+ "max",
45
+ ] as const;
46
+
47
+ /** Whether `level` is a thinking level pi's own `--thinking` accepts. */
48
+ export function isThinkingLevel(level: unknown): level is string {
49
+ return typeof level === "string" && (THINKING_LEVELS as readonly string[]).includes(level);
50
+ }
51
+
52
+ /**
53
+ * The thinking level to put on the child's command line, and what to say about
54
+ * the one we are not putting there.
55
+ *
56
+ * `level` is what to pass (undefined = no `--thinking` flag at all); `warning`
57
+ * is set when the config asked for something pi would have dropped anyway.
58
+ */
59
+ export function resolveThinkingLevel(
60
+ configured: string | undefined,
61
+ ): { level: string | undefined; warning: string | undefined } {
62
+ if (configured === undefined || configured === "") return { level: undefined, warning: undefined };
63
+ if (isThinkingLevel(configured)) return { level: configured, warning: undefined };
64
+ return { level: undefined, warning: formatUnknownThinkingWarning(configured) };
65
+ }
66
+
67
+ /**
68
+ * The warning for a level pi's `--thinking` does not take.
69
+ *
70
+ * It names the value, the accepted set, and what the child is running at
71
+ * instead — "the flag was dropped" is only useful if you learn the consequence.
72
+ */
73
+ export function formatUnknownThinkingWarning(configured: string): string {
74
+ return [
75
+ `Subagent asked for thinking level "${configured}", which pi's --thinking does not take.`,
76
+ `The run proceeds at pi's default thinking level. Valid levels (pi ${THINKING_LEVELS_PI_VERSION}): ${THINKING_LEVELS.join(", ")}.`,
77
+ ].join(" ");
78
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Task 11 item 3 — `subagent` is for the model, not for scripts, and
3
+ * `list_agents` stays reachable both ways.
4
+ *
5
+ * `exposure: "model-only"` is measured, not assumed (see the same file in
6
+ * `ask` for the method and the pi source references). For `subagent` the cost is
7
+ * concrete and it was observed before the change: a codemode script called
8
+ * `tools.subagent({ task: "Reply with exactly the single word: child" })` and
9
+ * got the whole run back as a structured value —
10
+ * `{"mode":"single","results":[{... "outcome":"completed","finalOutput":"child" ...}]}`.
11
+ * After the change that same script line reports
12
+ * `tools.subagent does not exist` and `subagent` is absent from `ALL_TOOLS`.
13
+ *
14
+ * The plan takes the exposure anyway (`docs/roadmap/pi-1-x-alignment.md`,
15
+ * Task 11 item 3): a subagent dispatch is an orchestration step that pi's own
16
+ * docs file under "Use it for orchestrating or interactive tools", the model
17
+ * still drives it, and the nested-call path it does remove is one where the
18
+ * child's row renders inside a script row the user cannot expand. Say so to the
19
+ * maintainer rather than discover it later.
20
+ */
21
+
22
+ import { describe, it, expect, vi } from "vitest";
23
+ import { registerSubagent } from "./index.js";
24
+
25
+ type RegisteredTool = { name: string; exposure?: string; annotations?: Record<string, boolean> };
26
+
27
+ function register(): Record<string, RegisteredTool> {
28
+ const tools: RegisteredTool[] = [];
29
+ registerSubagent({
30
+ registerTool: (t: unknown) => tools.push(t as RegisteredTool),
31
+ on: () => {},
32
+ getAllTools: () => [],
33
+ events: { emit: () => {}, on: () => () => {} },
34
+ } as never);
35
+ return Object.fromEntries(tools.map((t) => [t.name, t]));
36
+ }
37
+
38
+ describe("subagent exposure", () => {
39
+ it("subagent is model-only: declared to the model, never callable from a script", () => {
40
+ expect(register().subagent!.exposure).toBe("model-only");
41
+ });
42
+
43
+ it("list_agents stays direct — a script that dispatches nothing still reads the catalogue", () => {
44
+ expect(register().list_agents!.exposure ?? "direct").toBe("direct");
45
+ });
46
+
47
+ it("neither tool carries annotations", () => {
48
+ const tools = register();
49
+ expect(tools.subagent!.annotations).toBeUndefined();
50
+ expect(tools.list_agents!.annotations).toBeUndefined();
51
+ });
52
+
53
+ it("nothing in this package is codemode or deferred exposure (ADR 0029)", () => {
54
+ for (const tool of Object.values(register())) {
55
+ expect(["codemode", "deferred", "hidden"]).not.toContain(tool.exposure);
56
+ }
57
+ });
58
+ });