@pi-archimedes/subagent 2.10.4 → 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/format.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
|
-
import {
|
|
2
|
+
import { formatParallelResults } from "./format.js";
|
|
3
3
|
import type { SubagentResult } from "./types.js";
|
|
4
4
|
|
|
5
5
|
function makeResult(overrides: Partial<SubagentResult> = {}): SubagentResult {
|
|
@@ -17,33 +17,14 @@ function makeResult(overrides: Partial<SubagentResult> = {}): SubagentResult {
|
|
|
17
17
|
};
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
// ──
|
|
20
|
+
// ── Row truncation: moved ─────────────────────────────────────────────────
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
expect(truncLine("hello world", 8)).toBe("hello...");
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
it("stops at newline boundary instead of bleeding into next line", () => {
|
|
32
|
-
expect(truncLine("line one\nline two\nline three", 15)).toBe("line one...");
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
it("truncates first line if it itself exceeds limit", () => {
|
|
36
|
-
expect(truncLine("this is a very long first line\nsecond", 12)).toBe("this is a...");
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
it("handles multiple consecutive newlines", () => {
|
|
40
|
-
expect(truncLine("a\n\n\nb", 10)).toBe("a...");
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
it("handles text starting with newline", () => {
|
|
44
|
-
expect(truncLine("\nhello", 10)).toBe("...");
|
|
45
|
-
});
|
|
46
|
-
});
|
|
22
|
+
// The character cap this section used to pin (`truncLine`, at 60/80 characters)
|
|
23
|
+
// is gone. Rows are now cut at the width pi renders them at, which keeps the two
|
|
24
|
+
// properties the cap existed for — the ellipsis stays inside the styled span, and
|
|
25
|
+
// a newline is a hard boundary — while letting a row fill the frame. Those
|
|
26
|
+
// properties are pinned with measured bytes in `row-block.test.ts`, and the width
|
|
27
|
+
// contract itself in `tool-row.width-audit.test.ts`.
|
|
47
28
|
|
|
48
29
|
describe("formatParallelResults", () => {
|
|
49
30
|
it("renders header + body per task with exactly one blank line between sections", () => {
|
|
@@ -90,9 +71,9 @@ describe("formatParallelResults", () => {
|
|
|
90
71
|
expect(formatParallelResults([r])).toBe("✗ test-agent 5 tools · 0k tok · 3s\nspawn failed");
|
|
91
72
|
});
|
|
92
73
|
|
|
93
|
-
it("prefers finalOutput
|
|
74
|
+
it("prefers finalOutput as the body when both are set, and still marks the failure", () => {
|
|
94
75
|
const r = makeResult({ finalOutput: "out", error: "boom" });
|
|
95
|
-
expect(formatParallelResults([r])).toBe("
|
|
76
|
+
expect(formatParallelResults([r])).toBe("✗ test-agent 5 tools · 0k tok · 3s\nout");
|
|
96
77
|
});
|
|
97
78
|
|
|
98
79
|
it("keeps internal newlines in a multi-line body and trims only the tail", () => {
|
|
@@ -117,3 +98,64 @@ describe("formatParallelResults", () => {
|
|
|
117
98
|
);
|
|
118
99
|
});
|
|
119
100
|
});
|
|
101
|
+
|
|
102
|
+
describe("formatParallelResults: run outcomes", () => {
|
|
103
|
+
it("says truncated on the header row of a cut-off result", () => {
|
|
104
|
+
const out = formatParallelResults([
|
|
105
|
+
makeResult({ outcome: "truncated", finalOutput: "partial answer" }),
|
|
106
|
+
]);
|
|
107
|
+
expect(out.split("\n")[0]).toMatch(/truncated/i);
|
|
108
|
+
expect(out).toContain("partial answer");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("marks a run that failed at exit 0 with ✗, because the exit code is not the signal", () => {
|
|
112
|
+
// `--mode json` — what the child actually runs — never maps `stopReason`
|
|
113
|
+
// onto the exit code (that mapping is print mode's text branch), so a child
|
|
114
|
+
// whose last response errored exits **0**. `streamEvents` reports that as
|
|
115
|
+
// `error` with no `outcome`; a header that read only `exitCode` handed the
|
|
116
|
+
// calling model a ✓ for a run that produced nothing, while `index.ts` set
|
|
117
|
+
// `isError` on the very same result.
|
|
118
|
+
const out = formatParallelResults([
|
|
119
|
+
makeResult({ exitCode: 0, error: "401 unauthorized" }),
|
|
120
|
+
]);
|
|
121
|
+
expect(out.split("\n")[0]?.startsWith("✗")).toBe(true);
|
|
122
|
+
expect(out).toContain("401 unauthorized");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("marks a run the parent cancelled through the bridge (exit 0, error set)", () => {
|
|
126
|
+
// The bridge contract: a local cancel settles as a FAILED result carrying
|
|
127
|
+
// error "cancelled" and exitCode 0 — same shape, same wrong ✓ without the
|
|
128
|
+
// error check.
|
|
129
|
+
const out = formatParallelResults([
|
|
130
|
+
makeResult({ exitCode: 0, error: "cancelled" }),
|
|
131
|
+
]);
|
|
132
|
+
expect(out.split("\n")[0]?.startsWith("✗")).toBe(true);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("never shows a success marker for a result the tool reports as an error", () => {
|
|
136
|
+
// The single assertion that ties the two halves together: `index.ts` decides
|
|
137
|
+
// `isError` from `error`, so the header has to agree with it.
|
|
138
|
+
const out = formatParallelResults([
|
|
139
|
+
makeResult({ exitCode: 0, error: "boom" }),
|
|
140
|
+
makeResult({ agent: "ok", exitCode: 0, finalOutput: "fine" }),
|
|
141
|
+
]);
|
|
142
|
+
const rows = out.split("\n").filter((l) => l.startsWith("✓") || l.startsWith("✗"));
|
|
143
|
+
expect(rows[0]).toContain("✗");
|
|
144
|
+
expect(rows[1]).toContain("✓");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("says aborted on the header row of a cancelled result", () => {
|
|
148
|
+
const out = formatParallelResults([makeResult({ outcome: "aborted" })]);
|
|
149
|
+
expect(out.split("\n")[0]).toMatch(/aborted/i);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("leaves a completed header byte-identical to the pre-outcome contract", () => {
|
|
153
|
+
const r = makeResult({
|
|
154
|
+
outcome: "completed",
|
|
155
|
+
agent: "reviewer",
|
|
156
|
+
progressSummary: { toolCount: 4, tokens: 2000, durationMs: 12000 },
|
|
157
|
+
finalOutput: "Looks good.",
|
|
158
|
+
});
|
|
159
|
+
expect(formatParallelResults([r])).toBe("✓ reviewer 4 tools · 2k tok · 12s\nLooks good.");
|
|
160
|
+
});
|
|
161
|
+
});
|
package/src/format.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { SubagentResult } from "./types.js";
|
|
1
|
+
import type { RunOutcome, SubagentResult } from "./types.js";
|
|
2
2
|
|
|
3
3
|
export function formatTokens(n: number): string {
|
|
4
4
|
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M";
|
|
@@ -22,27 +22,6 @@ export function formatCost(cost: number): string {
|
|
|
22
22
|
return "$" + cost.toFixed(2);
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
/**
|
|
26
|
-
* Truncate plain text to a max character length, appending "..." if truncated.
|
|
27
|
-
*
|
|
28
|
-
* Unlike truncateToWidth from pi-tui, this does not handle wide characters or
|
|
29
|
-
* measure ANSI display width. The benefit: the "..." is plain text so it
|
|
30
|
-
* inherits any ANSI color wrapper applied around the result (pi-tui's
|
|
31
|
-
* truncateToWidth appends "..." after closing color codes, leaving it unstyled).
|
|
32
|
-
*
|
|
33
|
-
* If the text contains newlines, truncates at the first newline boundary
|
|
34
|
-
* rather than bleeding into the next line.
|
|
35
|
-
*/
|
|
36
|
-
export function truncLine(text: string, maxLen: number): string {
|
|
37
|
-
// Check for newline first — it's a hard boundary regardless of length
|
|
38
|
-
const nlIdx = text.indexOf("\n");
|
|
39
|
-
if (nlIdx !== -1) {
|
|
40
|
-
return text.slice(0, nlIdx).slice(0, maxLen - 3) + "...";
|
|
41
|
-
}
|
|
42
|
-
if (text.length <= maxLen) return text;
|
|
43
|
-
return text.slice(0, maxLen - 3) + "...";
|
|
44
|
-
}
|
|
45
|
-
|
|
46
25
|
/**
|
|
47
26
|
* Renders one section per parallel result. The header line is intentionally
|
|
48
27
|
* byte-identical to the pre-#55 metrics summary (a contract, not an
|
|
@@ -50,20 +29,84 @@ export function truncLine(text: string, maxLen: number): string {
|
|
|
50
29
|
* (finalOutput ?? error ?? "completed"), `trimEnd()`-ed so sections stay
|
|
51
30
|
* separated by exactly one blank line, and is deliberately untruncated by
|
|
52
31
|
* design (see docs/decisions/0020-subagent-parallel-full-output.md).
|
|
32
|
+
*
|
|
33
|
+
* A run that merely completed keeps that header exactly as it was. A truncated
|
|
34
|
+
* or aborted one says so on the row, because a "✓" the calling model has no way
|
|
35
|
+
* to check would be the worse kind of lie.
|
|
36
|
+
*
|
|
37
|
+
* The failure check reads `error`, not the exit code, and comes before the
|
|
38
|
+
* success marker: a `--mode json` child whose last response errored **exits 0**
|
|
39
|
+
* (`stream.ts` turns that into `error` with no `outcome`), and `index.ts` calls
|
|
40
|
+
* the same result `isError`. A header keyed off `exitCode` alone told the model
|
|
41
|
+
* the opposite of the flag on the tool result it was reading.
|
|
53
42
|
*/
|
|
54
43
|
export function formatParallelResults(results: SubagentResult[]): string {
|
|
55
44
|
const sections = results.map((r) => {
|
|
56
|
-
const status =
|
|
45
|
+
const status =
|
|
46
|
+
r.outcome === "truncated"
|
|
47
|
+
? "⚠ truncated"
|
|
48
|
+
: r.outcome === "aborted"
|
|
49
|
+
? "⚠ aborted"
|
|
50
|
+
: r.error || r.exitCode !== 0
|
|
51
|
+
? "✗"
|
|
52
|
+
: "✓";
|
|
57
53
|
const summary = r.progressSummary
|
|
58
54
|
? `${r.progressSummary.toolCount} tools · ${Math.round(r.progressSummary.tokens / 1000)}k tok · ${Math.round(r.progressSummary.durationMs / 1000)}s`
|
|
59
55
|
: "";
|
|
60
56
|
const header = `${status} ${r.agent}${summary ? " " + summary : ""}`;
|
|
61
|
-
const body = (r.finalOutput ?? r.error ??
|
|
57
|
+
const body = (r.finalOutput ?? r.error ?? fallbackBody(r)).trimEnd();
|
|
62
58
|
return `${header}\n${body}`;
|
|
63
59
|
});
|
|
64
60
|
return sections.join("\n\n");
|
|
65
61
|
}
|
|
66
62
|
|
|
63
|
+
/** What to say when a result has neither output nor an error to show. */
|
|
64
|
+
function fallbackBody(result: SubagentResult): string {
|
|
65
|
+
return result.outcome ? "(no output)" : "completed";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The single-run result text the calling model reads.
|
|
70
|
+
*
|
|
71
|
+
* The outcome is spelled out in words as well as carried in
|
|
72
|
+
* `details.results[].outcome`, because `content` is the half of the contract the
|
|
73
|
+
* model is guaranteed to see — and "this answer was cut off" is not something it
|
|
74
|
+
* can recover from the answer itself.
|
|
75
|
+
*/
|
|
76
|
+
export function formatSingleResult(result: SubagentResult): string {
|
|
77
|
+
const body = result.finalOutput ?? result.error ?? fallbackBody(result);
|
|
78
|
+
const note =
|
|
79
|
+
result.outcome === "truncated"
|
|
80
|
+
? "[truncated: the subagent reached its output token limit — the answer above is partial]"
|
|
81
|
+
: result.outcome === "aborted"
|
|
82
|
+
? "[aborted: the subagent run was cancelled — anything above is partial]"
|
|
83
|
+
: undefined;
|
|
84
|
+
return note ? `${body}\n\n${note}` : body;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The finished-run row, shared by the compact and expanded views.
|
|
89
|
+
*
|
|
90
|
+
* `undefined` means "nothing to report yet" (the run is still going), which is
|
|
91
|
+
* what leaves the callers' running / tool-call rendering untouched.
|
|
92
|
+
*
|
|
93
|
+
* The error text is not capped here: rows are cut at the width pi renders them
|
|
94
|
+
* at, by `RowBlock` (see `row-block.ts`). A fixed character cap is what made
|
|
95
|
+
* these rows die at a third of the frame on a wide terminal.
|
|
96
|
+
*/
|
|
97
|
+
export function buildOutcomeLine(
|
|
98
|
+
data: { outcome?: RunOutcome | undefined; error: string | undefined },
|
|
99
|
+
theme: { fg: (token: string, text: string) => string },
|
|
100
|
+
): string | undefined {
|
|
101
|
+
if (data.error) return theme.fg("error", "✗ " + data.error);
|
|
102
|
+
if (data.outcome === "truncated") {
|
|
103
|
+
return theme.fg("warning", "⚠ Truncated — hit the output token limit, answer is partial");
|
|
104
|
+
}
|
|
105
|
+
if (data.outcome === "aborted") return theme.fg("warning", "⚠ Aborted");
|
|
106
|
+
if (data.outcome === "completed") return theme.fg("success", "✓ Done");
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
|
|
67
110
|
export interface StatsData {
|
|
68
111
|
turns: number | undefined;
|
|
69
112
|
toolCount: number | undefined;
|
|
@@ -96,9 +139,9 @@ export function buildStatsLine(
|
|
|
96
139
|
return parts.map(p => theme.fg("dim", "· " + p)).join(" ");
|
|
97
140
|
}
|
|
98
141
|
|
|
99
|
-
// Build an agent label for display: "agentName:
|
|
100
|
-
//
|
|
101
|
-
//
|
|
142
|
+
// Build an agent label for display: "agentName: task preview".
|
|
143
|
+
// The task is passed through whole: the row it lands on is cut at the render
|
|
144
|
+
// width by `RowBlock`, so the preview fills whatever frame pi draws it in.
|
|
102
145
|
export function buildAgentLabel(
|
|
103
146
|
agent: string,
|
|
104
147
|
task: string | undefined,
|
|
@@ -106,7 +149,7 @@ export function buildAgentLabel(
|
|
|
106
149
|
): string {
|
|
107
150
|
const name = theme.bold(agent);
|
|
108
151
|
if (task) {
|
|
109
|
-
return name + theme.fg("dim", ": " +
|
|
152
|
+
return name + theme.fg("dim", ": " + task);
|
|
110
153
|
}
|
|
111
154
|
return name;
|
|
112
155
|
}
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
2
|
import * as fc from "fast-check";
|
|
3
3
|
import { validateAgentName, serializeAgent, AGENT_NAME_REGEX } from "./frontmatter-io.js";
|
|
4
|
+
// Imported here rather than inside the tests below: nothing in this file mocks
|
|
5
|
+
// `parseFrontmatter`, so a dynamic `await import()` in a test body only served
|
|
6
|
+
// to charge whichever test ran first with loading the whole pi-coding-agent
|
|
7
|
+
// package — measured at 2.6s alone, 6-7.8s on a loaded machine, against a 5s
|
|
8
|
+
// per-test timeout. That is the "Test timed out in 5000ms" flake, and it lands
|
|
9
|
+
// on collection instead, where no test budget is spent.
|
|
10
|
+
import { parseFrontmatter } from "@earendil-works/pi-coding-agent";
|
|
4
11
|
import type { AgentConfig } from "./agents.js";
|
|
5
12
|
|
|
6
13
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
@@ -117,3 +124,58 @@ describe("serializeAgent", () => {
|
|
|
117
124
|
);
|
|
118
125
|
});
|
|
119
126
|
});
|
|
127
|
+
|
|
128
|
+
// ── mcp round-trip (the writer's half of ADR 0028) ──────────────────────────
|
|
129
|
+
|
|
130
|
+
describe("serializeAgent: mcp opt-in", () => {
|
|
131
|
+
it("writes the mcp key when the agent opts in", () => {
|
|
132
|
+
const output = serializeAgent(makeAgent({ tools: ["read", "bash"], mcp: true }));
|
|
133
|
+
expect(output).toContain("mcp: true");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("writes no mcp key when the field is absent or false (closed is the default)", () => {
|
|
137
|
+
expect(serializeAgent(makeAgent({ tools: ["read"] }))).not.toContain("mcp:");
|
|
138
|
+
expect(serializeAgent(makeAgent({ tools: ["read"], mcp: false }))).not.toContain("mcp:");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("survives a write → read round trip as a real boolean, not a string", () => {
|
|
142
|
+
const output = serializeAgent(makeAgent({ tools: ["read", "bash"], mcp: true }));
|
|
143
|
+
const { frontmatter } = parseFrontmatter(output);
|
|
144
|
+
expect(frontmatter.mcp).toBe(true);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("keeps the tools list intact alongside the mcp key", () => {
|
|
148
|
+
const output = serializeAgent(makeAgent({ tools: ["read", "bash"], mcp: true }));
|
|
149
|
+
expect(output).toContain("tools: read, bash");
|
|
150
|
+
expect(output).toContain("mcp: true");
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// ── One `tools:` key, whatever the config object looks like ───────────────
|
|
155
|
+
|
|
156
|
+
describe("serializeAgent: never writes two tools keys", () => {
|
|
157
|
+
// The read path preserves an unreadable `tools:` in `extraFields` so a save
|
|
158
|
+
// cannot delete a line the user typed, while `applyToolEntries` owns the real
|
|
159
|
+
// `tools` field. If both are populated the file gets two `tools:` keys and the
|
|
160
|
+
// last one wins on the next read — which is the broken one.
|
|
161
|
+
const toolsKeys = (yaml: string): string[] =>
|
|
162
|
+
yaml.split("\n").filter((line) => /^tools:/.test(line));
|
|
163
|
+
|
|
164
|
+
it("writes the real field and drops the preserved raw value when both exist", () => {
|
|
165
|
+
const output = serializeAgent(
|
|
166
|
+
makeAgent({ tools: ["read"], extraFields: { tools: "read, +web_search", owner: "x" } }),
|
|
167
|
+
);
|
|
168
|
+
expect(toolsKeys(output)).toEqual(["tools: read"]);
|
|
169
|
+
expect(parseFrontmatter(output).frontmatter.tools).toBe("read");
|
|
170
|
+
// The unrelated extra still round-trips.
|
|
171
|
+
expect(output).toContain("owner: x");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("still writes a preserved raw value when there is no parsed list to replace it", () => {
|
|
175
|
+
// This is the preservation the read path promised: an agent whose `tools:`
|
|
176
|
+
// was rejected keeps its line on save, rather than being silently rewritten
|
|
177
|
+
// without it.
|
|
178
|
+
const output = serializeAgent(makeAgent({ extraFields: { tools: "read, +web_search" } }));
|
|
179
|
+
expect(toolsKeys(output)).toEqual(["tools: read, +web_search"]);
|
|
180
|
+
});
|
|
181
|
+
});
|
package/src/frontmatter-io.ts
CHANGED
|
@@ -65,8 +65,17 @@ export function serializeAgent(config: AgentConfig): string {
|
|
|
65
65
|
lines.push(`description: ${quoteYamlValue(config.description)}`);
|
|
66
66
|
|
|
67
67
|
// Optional known fields
|
|
68
|
-
|
|
69
|
-
|
|
68
|
+
const toolsLine =
|
|
69
|
+
config.tools && config.tools.length > 0
|
|
70
|
+
? `tools: ${quoteYamlValue(config.tools.join(", "))}`
|
|
71
|
+
: undefined;
|
|
72
|
+
if (toolsLine !== undefined) {
|
|
73
|
+
lines.push(toolsLine);
|
|
74
|
+
}
|
|
75
|
+
// Only the opt-in is written: a closed allowlist is the default, so the
|
|
76
|
+
// absence of the key is the meaningful statement (ADR 0028).
|
|
77
|
+
if (config.mcp === true) {
|
|
78
|
+
lines.push("mcp: true");
|
|
70
79
|
}
|
|
71
80
|
if (config.model) {
|
|
72
81
|
lines.push(`model: ${quoteYamlValue(config.model)}`);
|
|
@@ -78,6 +87,11 @@ export function serializeAgent(config: AgentConfig): string {
|
|
|
78
87
|
// Extra fields (sorted alphabetically)
|
|
79
88
|
if (config.extraFields) {
|
|
80
89
|
for (const key of Object.keys(config.extraFields).sort()) {
|
|
90
|
+
// `extraFields.tools` is the read path's copy of a `tools:` line it could
|
|
91
|
+
// not read, kept so a save cannot delete it. It only gets to speak for
|
|
92
|
+
// `tools:` when there is no parsed list to speak for it — otherwise this
|
|
93
|
+
// loop writes a second `tools:` key and the later one wins on reload.
|
|
94
|
+
if (key === "tools" && toolsLine !== undefined) continue;
|
|
81
95
|
const value = config.extraFields[key]!;
|
|
82
96
|
if (typeof value === "string") {
|
|
83
97
|
lines.push(`${key}: ${quoteYamlValue(value)}`);
|
package/src/handlers.test.ts
CHANGED
|
@@ -4,9 +4,27 @@ import { extractArgsPreview } from "./handlers.js";
|
|
|
4
4
|
// ── extractArgsPreview ──────────────────────────────────────────────────────
|
|
5
5
|
|
|
6
6
|
describe("extractArgsPreview", () => {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
// The preview feeds a transcript row, and the row is what decides how much of
|
|
8
|
+
// it fits (`RowBlock` cuts at the width pi renders at). A cap here is therefore
|
|
9
|
+
// a second, invisible limit: cut it tighter than any terminal is wide and the
|
|
10
|
+
// row stops short of the frame with no cut marker, which is the defect #108
|
|
11
|
+
// set out to remove. pi's own bash row keeps the command whole for the same
|
|
12
|
+
// reason. It stays bounded, because the preview is carried in the streamed
|
|
13
|
+
// progress object for the life of the run.
|
|
14
|
+
it("keeps enough of a long command for a wide frame to draw", () => {
|
|
15
|
+
const long = "a".repeat(300);
|
|
16
|
+
expect(extractArgsPreview(long)).toBe(long);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("still bounds what it returns, since the preview is streamed", () => {
|
|
20
|
+
const preview = extractArgsPreview("a".repeat(100_000));
|
|
21
|
+
expect(preview.length).toBeLessThan(1_000);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("bounds the serialised form of object args too", () => {
|
|
25
|
+
const preview = extractArgsPreview({ a: "b".repeat(100_000), c: 1 });
|
|
26
|
+
expect(preview.length).toBeLessThan(1_000);
|
|
27
|
+
expect(preview.length).toBeGreaterThanOrEqual(300);
|
|
10
28
|
});
|
|
11
29
|
|
|
12
30
|
it("replaces newlines in string args", () => {
|
package/src/handlers.ts
CHANGED
|
@@ -1,7 +1,19 @@
|
|
|
1
1
|
import type { StreamState } from "./types.js";
|
|
2
2
|
|
|
3
3
|
// Truncation limits for previews
|
|
4
|
-
|
|
4
|
+
//
|
|
5
|
+
// The args preview is drawn on a transcript row, and the row is what decides how
|
|
6
|
+
// much of it fits: `RowBlock` cuts at the width pi hands `render()` (see
|
|
7
|
+
// `row-block.ts`). A cap here is therefore a second, invisible character limit —
|
|
8
|
+
// the very defect #108 removed — because a preview shorter than the frame ends
|
|
9
|
+
// mid-word with no cut marker. pi's own bash row keeps the command whole for the
|
|
10
|
+
// same reason (`formatShellCall`). So this bound sits above anything a terminal
|
|
11
|
+
// can draw, and exists only to keep the preview from growing the streamed
|
|
12
|
+
// progress object without limit (it is carried for the life of the run).
|
|
13
|
+
const ARGS_PREVIEW_MAX = 512;
|
|
14
|
+
// `recentOutput` is carried in the progress object but drawn by no row, so it
|
|
15
|
+
// keeps the old, tighter bound: widening it would only grow what is streamed.
|
|
16
|
+
const OUTPUT_LINE_MAX = 120;
|
|
5
17
|
const TOOL_CALLS_MAX = 50;
|
|
6
18
|
const RECENT_OUTPUT_MAX = 50;
|
|
7
19
|
|
|
@@ -97,13 +109,13 @@ export function handleToolResult(state: StreamState, event: JsonEvent): void {
|
|
|
97
109
|
|
|
98
110
|
if (typeof content === "string" && content.trim()) {
|
|
99
111
|
const lines = content.split("\n").filter((l) => l.trim());
|
|
100
|
-
state.recentOutput.push(`[${toolName}] ${lines[0]?.slice(0,
|
|
112
|
+
state.recentOutput.push(`[${toolName}] ${lines[0]?.slice(0, OUTPUT_LINE_MAX)}`);
|
|
101
113
|
} else if (Array.isArray(content)) {
|
|
102
114
|
for (const part of content) {
|
|
103
115
|
if (part.type === "text" && (part.text as string)?.trim()) {
|
|
104
116
|
const text = part.text as string;
|
|
105
117
|
const lines = text.split("\n").filter((l) => l.trim());
|
|
106
|
-
state.recentOutput.push(`[${toolName}] ${lines[0]?.slice(0,
|
|
118
|
+
state.recentOutput.push(`[${toolName}] ${lines[0]?.slice(0, OUTPUT_LINE_MAX)}`);
|
|
107
119
|
break;
|
|
108
120
|
}
|
|
109
121
|
}
|
|
@@ -126,6 +138,13 @@ export function handleMessageEnd(state: StreamState, event: JsonEvent): void {
|
|
|
126
138
|
state.model = message.model as string;
|
|
127
139
|
}
|
|
128
140
|
|
|
141
|
+
// How this response ended. The LAST assistant message's stopReason is what
|
|
142
|
+
// decides the run's outcome (`length` ⇒ truncated, `aborted` ⇒ cancelled,
|
|
143
|
+
// `error` ⇒ failed) — the exit code cannot tell them apart.
|
|
144
|
+
if (typeof message.stopReason === "string") {
|
|
145
|
+
state.lastStopReason = message.stopReason;
|
|
146
|
+
}
|
|
147
|
+
|
|
129
148
|
// Collect text + thinking output
|
|
130
149
|
const content = message.content as Array<Record<string, unknown>> | string | undefined;
|
|
131
150
|
if (typeof content === "string" && content.trim()) {
|