@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,262 @@
|
|
|
1
|
+
import type { AgentConfig } from "./agents.js";
|
|
2
|
+
|
|
3
|
+
// ── Tool-name validation, split by what each site is allowed to know ────────
|
|
4
|
+
//
|
|
5
|
+
// Two questions look similar and are not:
|
|
6
|
+
//
|
|
7
|
+
// **Lexical** — can this string be a tool *name* at all? Empty/whitespace
|
|
8
|
+
// entries, duplicates, entries containing a comma (the child re-splits the one
|
|
9
|
+
// argv string on commas, so a comma means two entries by then), and entries
|
|
10
|
+
// starting with `+` or `-` cannot. pi 1.1.0
|
|
11
|
+
// made `+name`/`-name` **modifiers** on the default tool set, and its own CLI
|
|
12
|
+
// parser rejects a `--tools` list that mixes them with plain names outright —
|
|
13
|
+
// the flag is dropped with `Error: --tools: tool names cannot be mixed with
|
|
14
|
+
// +name or -name entries` and exit code 1, so such a list does not merely
|
|
15
|
+
// misbehave, it takes the whole allowlist (and the run) down with it. Nothing
|
|
16
|
+
// else about the entry matters here, and that is what lets the config read
|
|
17
|
+
// (`agents.ts`) and the `/agents` panel reject malformed input: neither site has
|
|
18
|
+
// a tool registry — `discoverAgents()` is pure filesystem and
|
|
19
|
+
// `pi.getAllTools()` needs the `ExtensionAPI` handle.
|
|
20
|
+
//
|
|
21
|
+
// **Membership** — does pi have this tool *right now*? Needs the handle, so it
|
|
22
|
+
// is asked at dispatch, and the answer is a warning naming the entry, never a
|
|
23
|
+
// refusal: a legitimate name is absent whenever the plugin that provides it is
|
|
24
|
+
// toggled off or the MCP server behind it is disconnected. Failing there would
|
|
25
|
+
// make `tools: web_search` unusable for anyone who disabled `web`.
|
|
26
|
+
//
|
|
27
|
+
// Shape and wording follow the model pre-validation precedent (ADR 0008,
|
|
28
|
+
// `model-validation.ts`): a pure gate returning `{ ok }`, a message that names
|
|
29
|
+
// the offending value and the file it came from, and says what to do instead.
|
|
30
|
+
|
|
31
|
+
export interface ValidateToolsContext {
|
|
32
|
+
/** Agent name the entry belongs to (used to address the message). */
|
|
33
|
+
agentName?: string | undefined;
|
|
34
|
+
/** File path of the agent config whose `tools:` field is being validated. */
|
|
35
|
+
agentFilePath?: string | undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type ValidateToolsResult = { ok: true } | { ok: false; error: string };
|
|
39
|
+
|
|
40
|
+
function address(context: ValidateToolsContext): { subject: string; where: string } {
|
|
41
|
+
return {
|
|
42
|
+
subject: context.agentName ? `Agent "${context.agentName}"` : "This agent config",
|
|
43
|
+
where: context.agentFilePath ? ` (in ${context.agentFilePath})` : "",
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Split a `tools:` value into entries.
|
|
49
|
+
*
|
|
50
|
+
* Empty entries are **kept** — filtering them here is what made a malformed list
|
|
51
|
+
* look legitimate. Entries are trimmed, because that is what the child receives.
|
|
52
|
+
*/
|
|
53
|
+
export function splitToolEntries(raw: string): string[] {
|
|
54
|
+
if (raw.length === 0) return [];
|
|
55
|
+
return raw.split(",").map((entry) => entry.trim());
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Split one YAML list entry the way the child will.
|
|
60
|
+
*
|
|
61
|
+
* `--tools` is a single argv string that the host re-splits on commas
|
|
62
|
+
* (`pi/dist/cli/args.js`: `args[++i].split(",")`), so a list entry written
|
|
63
|
+
* `"read,+bash"` is *two* entries by the time anything judges it. Validating the
|
|
64
|
+
* unsplit form certifies a list the child never runs with — and the resulting
|
|
65
|
+
* mix of names and modifiers is rejected wholesale by pi, so the child ends up
|
|
66
|
+
* with no allowlist at all. An entry without a comma is returned as the trimmed
|
|
67
|
+
* single entry, so a deliberate empty entry (`- ""`) stays visible for the name
|
|
68
|
+
* rules to report rather than vanishing here.
|
|
69
|
+
*/
|
|
70
|
+
function splitListEntry(item: string): string[] {
|
|
71
|
+
return item.includes(",") ? splitToolEntries(item) : [item.trim()];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export type ReadToolEntriesResult = { entries: string[] } | { error: string };
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Read a `tools:` **frontmatter value** as entries, before any name rules apply.
|
|
78
|
+
*
|
|
79
|
+
* pi's frontmatter parser hands over whatever YAML said, and there are three
|
|
80
|
+
* spellings in the wild: the comma-separated string this suite's writer emits,
|
|
81
|
+
* a hand-written block list (`tools:\n - read`), and an inline list
|
|
82
|
+
* (`tools: [read]`) — the two list forms both arrive as real arrays. All three
|
|
83
|
+
* mean the same allowlist, and a list entry is comma-split on the way in so the
|
|
84
|
+
* entries judged here are the entries the child receives (`splitListEntry`).
|
|
85
|
+
* Anything else (a number, a mapping, a list holding a
|
|
86
|
+
* mapping) is not a tool list, and must be *said* to be broken rather than
|
|
87
|
+
* skipped: an allowlist this function refuses to read is a child that runs with
|
|
88
|
+
* every tool the host has, which is the whole thing ADR 0028 is about.
|
|
89
|
+
*/
|
|
90
|
+
export function readToolEntries(
|
|
91
|
+
raw: unknown,
|
|
92
|
+
context: ValidateToolsContext = {},
|
|
93
|
+
): ReadToolEntriesResult {
|
|
94
|
+
const { subject, where } = address(context);
|
|
95
|
+
if (typeof raw === "string") return { entries: splitToolEntries(raw) };
|
|
96
|
+
if (Array.isArray(raw)) {
|
|
97
|
+
const entries: string[] = [];
|
|
98
|
+
for (const item of raw) {
|
|
99
|
+
if (typeof item !== "string") {
|
|
100
|
+
return {
|
|
101
|
+
error: `${subject} has a tools entry that is not a tool name${where}: ${JSON.stringify(item) ?? String(item)}. Each entry is a tool name on its own line (or one comma-separated list).`,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
entries.push(...splitListEntry(item));
|
|
105
|
+
}
|
|
106
|
+
return { entries };
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
error: `${subject}'s tools field${where} is not a list of tool names: ${JSON.stringify(raw) ?? String(raw)}. Write it as "read, bash" or as a YAML list with one tool name per entry.`,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Reject entries that cannot be tool names. Order matters: the first problem in
|
|
115
|
+
* list order is reported, so the user fixes one thing at a time.
|
|
116
|
+
*/
|
|
117
|
+
export function validateToolEntries(
|
|
118
|
+
entries: readonly string[],
|
|
119
|
+
context: ValidateToolsContext = {},
|
|
120
|
+
): ValidateToolsResult {
|
|
121
|
+
const { subject, where } = address(context);
|
|
122
|
+
const seen = new Set<string>();
|
|
123
|
+
|
|
124
|
+
for (const [index, entry] of entries.entries()) {
|
|
125
|
+
// An entry that is only whitespace is the empty entry it trims to; the list
|
|
126
|
+
// the child receives is trimmed, so it is reported that way.
|
|
127
|
+
if (entry.trim() === "") {
|
|
128
|
+
return {
|
|
129
|
+
ok: false,
|
|
130
|
+
error: `${subject} has an empty entry${where} in its tools list: "" (entry ${index + 1} of ${entries.length}). Entries are comma-separated tool names — remove the stray comma.`,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
const sign = entry.startsWith("+") ? "+" : entry.startsWith("-") ? "-" : "";
|
|
134
|
+
if (sign !== "") {
|
|
135
|
+
return {
|
|
136
|
+
ok: false,
|
|
137
|
+
error: `${subject} has a tools entry starting with "${sign}"${where}: "${entry}". pi reads +name/-name entries as modifiers on the default tool set, not as tool names, and a list that mixes them with names is rejected outright — the whole allowlist is dropped. Use the bare name "${entry.slice(1)}" to allow it, or leave it out.`,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
// The comma rule sits *after* the modifier rule deliberately: for an entry
|
|
141
|
+
// like `+a,b` the modifier is what the child chokes on first, and that
|
|
142
|
+
// message says more. `readToolEntries` already splits commas on the read
|
|
143
|
+
// path, so this rule guards every other caller (`applyToolEntries`, and
|
|
144
|
+
// anything that builds an AgentConfig directly): the list we validate has to
|
|
145
|
+
// be the list the child runs with, because pi re-splits the one argv string
|
|
146
|
+
// it is handed and drops a mixed list outright.
|
|
147
|
+
if (entry.includes(",")) {
|
|
148
|
+
const parts = entry.split(",").map((part) => part.trim()).filter((part) => part !== "");
|
|
149
|
+
return {
|
|
150
|
+
ok: false,
|
|
151
|
+
error: `${subject} has a tools entry containing a comma${where}: "${entry}". The allowlist reaches the child as one comma-separated list, so this entry becomes ${parts.length} entries by the time pi reads it — write them separately (${parts.map((part) => `"${part}"`).join(", ")}).`,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
if (seen.has(entry)) {
|
|
155
|
+
return {
|
|
156
|
+
ok: false,
|
|
157
|
+
error: `${subject} lists the tool "${entry}" twice${where} in its tools list. List each tool once.`,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
seen.add(entry);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return { ok: true };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Write a tools list onto an agent config, or refuse it.
|
|
168
|
+
*
|
|
169
|
+
* The edit-site half of the rule: a rejected value leaves `agent.tools` exactly
|
|
170
|
+
* as it was — a half-applied list is what a user would otherwise hand to a child
|
|
171
|
+
* without noticing. Returns the error message, or `null` when it applied.
|
|
172
|
+
*
|
|
173
|
+
* A commit also settles the two pieces of state that describe the list it is
|
|
174
|
+
* replacing. The config read keeps an unreadable `tools:` in `extraFields` so a
|
|
175
|
+
* save cannot delete the user's line, and records `toolsError` so dispatch and
|
|
176
|
+
* the panel refuse the agent; both are claims about the *old* value, and
|
|
177
|
+
* `serializeAgent` writes `tools` and every extra field — so leaving them
|
|
178
|
+
* behind puts a second (or, after a clear, the only) `tools:` key back in the
|
|
179
|
+
* file and the agent reads as broken again after a reload.
|
|
180
|
+
*/
|
|
181
|
+
export function applyToolEntries(
|
|
182
|
+
agent: AgentConfig,
|
|
183
|
+
entries: readonly string[],
|
|
184
|
+
context: ValidateToolsContext = {},
|
|
185
|
+
): string | null {
|
|
186
|
+
// Normalised first: the allowlist reaches the child as names, so " read " and
|
|
187
|
+
// "read" are the same entry here, not two.
|
|
188
|
+
const normalized = entries.map((entry) => entry.trim());
|
|
189
|
+
const result = validateToolEntries(normalized, {
|
|
190
|
+
agentName: context.agentName ?? agent.name,
|
|
191
|
+
agentFilePath: context.agentFilePath ?? agent.filePath,
|
|
192
|
+
});
|
|
193
|
+
if (!result.ok) return result.error;
|
|
194
|
+
if (normalized.length > 0) {
|
|
195
|
+
agent.tools = [...normalized];
|
|
196
|
+
} else {
|
|
197
|
+
delete agent.tools;
|
|
198
|
+
}
|
|
199
|
+
delete agent.toolsError;
|
|
200
|
+
// Copy, never mutate in place: `/agents` builds its edit copy with
|
|
201
|
+
// `{ ...agent }`, so `extraFields` is one object shared with `editOriginal`
|
|
202
|
+
// and the discovered agent. Deleting the key there would erase the preserved
|
|
203
|
+
// raw value from the original too, so a commit followed by a discard would
|
|
204
|
+
// save the agent with no `tools:` line at all.
|
|
205
|
+
if (agent.extraFields && "tools" in agent.extraFields) {
|
|
206
|
+
const rest: Record<string, unknown> = { ...agent.extraFields };
|
|
207
|
+
delete rest.tools;
|
|
208
|
+
if (Object.keys(rest).length > 0) agent.extraFields = rest;
|
|
209
|
+
else delete agent.extraFields;
|
|
210
|
+
}
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* `*` matches any characters, as pi's own `--tools` / `--exclude-tools` entries
|
|
216
|
+
* accept it (mirrors `createToolNameMatcher` in pi's `core/mcp-servers.js`, which
|
|
217
|
+
* is not part of pi's public API surface).
|
|
218
|
+
*/
|
|
219
|
+
function patternRegExp(pattern: string): RegExp {
|
|
220
|
+
const source = pattern
|
|
221
|
+
.split("*")
|
|
222
|
+
.map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&"))
|
|
223
|
+
.join(".*");
|
|
224
|
+
return new RegExp(`^${source}$`);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Entries that no currently-registered tool satisfies.
|
|
229
|
+
*
|
|
230
|
+
* Membership, so this belongs at dispatch (where `pi.getAllTools()` is in
|
|
231
|
+
* scope). A `*` entry is judged the way the host judges it — satisfied when at
|
|
232
|
+
* least one available tool matches — so `mcp__radius__*` is not reported while
|
|
233
|
+
* that server's tools are connected.
|
|
234
|
+
*/
|
|
235
|
+
export function unknownToolEntries(
|
|
236
|
+
entries: readonly string[],
|
|
237
|
+
knownNames: Iterable<string>,
|
|
238
|
+
): string[] {
|
|
239
|
+
const known = [...knownNames];
|
|
240
|
+
return entries.filter((entry) => {
|
|
241
|
+
if (entry.includes("*")) {
|
|
242
|
+
const pattern = patternRegExp(entry);
|
|
243
|
+
return !known.some((name) => pattern.test(name));
|
|
244
|
+
}
|
|
245
|
+
return !known.includes(entry);
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* The dispatch warning for unavailable entries.
|
|
251
|
+
*
|
|
252
|
+
* It says the run is proceeding because that is the decision worth reading:
|
|
253
|
+
* the alternative reading of an absent tool is "this config is broken", and it
|
|
254
|
+
* is usually wrong (plugin off, MCP server down).
|
|
255
|
+
*/
|
|
256
|
+
export function formatUnknownToolsWarning(agentName: string, entries: readonly string[]): string {
|
|
257
|
+
const listed = entries.map((entry) => `"${entry}"`).join(", ");
|
|
258
|
+
return [
|
|
259
|
+
`Subagent "${agentName}" asked for tool(s) pi does not currently have: ${listed}.`,
|
|
260
|
+
"The run proceeds without them — expected while the plugin that provides a tool is off, or its MCP server is disconnected.",
|
|
261
|
+
].join(" ");
|
|
262
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { Usage } from "@earendil-works/pi-ai";
|
|
2
|
+
|
|
1
3
|
export interface SubagentUsage {
|
|
2
4
|
input: number;
|
|
3
5
|
output: number;
|
|
@@ -13,9 +15,27 @@ export interface SubagentToolCall {
|
|
|
13
15
|
error: boolean;
|
|
14
16
|
}
|
|
15
17
|
|
|
18
|
+
/**
|
|
19
|
+
* The terminal state of a finished run — the suite's fixed vocabulary for it
|
|
20
|
+
* (`CONTEXT.md`: *Run outcome*).
|
|
21
|
+
*
|
|
22
|
+
* `completed` finished within limits; `truncated` finished but hit the output
|
|
23
|
+
* token limit, so the answer is partial; `aborted` was cancelled. None of them
|
|
24
|
+
* is a *failed* run — a pre/during-run error carries `error` and **no** outcome.
|
|
25
|
+
*/
|
|
26
|
+
export type RunOutcome = "completed" | "truncated" | "aborted";
|
|
27
|
+
|
|
16
28
|
export interface SubagentProgress {
|
|
17
29
|
agent: string;
|
|
30
|
+
/**
|
|
31
|
+
* The coarse view axis (still running / finished / broke). The three-valued
|
|
32
|
+
* *Run outcome* lives in `outcome`, which is what the renderers show when a
|
|
33
|
+
* run has finished — a truncated run is not `failed`, and an aborted one is
|
|
34
|
+
* not `completed`, whatever the row's status has to say for layout.
|
|
35
|
+
*/
|
|
18
36
|
status: "running" | "completed" | "failed";
|
|
37
|
+
/** Set once the run reaches a terminal state; absent for a failed run. */
|
|
38
|
+
outcome?: RunOutcome;
|
|
19
39
|
task: string;
|
|
20
40
|
currentTool: string | undefined;
|
|
21
41
|
currentToolArgs: string | undefined;
|
|
@@ -43,7 +63,29 @@ export interface SubagentResult {
|
|
|
43
63
|
/** Logical Pi session UUID for this spawned subagent process. */
|
|
44
64
|
childSessionId?: string;
|
|
45
65
|
exitCode: number;
|
|
66
|
+
/**
|
|
67
|
+
* How the run ended, when it ended. Absent when the run **failed** (an error
|
|
68
|
+
* before or during the run), which is the case `error` and `isError` describe.
|
|
69
|
+
*/
|
|
70
|
+
outcome?: RunOutcome;
|
|
46
71
|
usage: SubagentUsage;
|
|
72
|
+
/**
|
|
73
|
+
* The run happened somewhere that already books its usage, so **this** session
|
|
74
|
+
* must not also report it on the tool result.
|
|
75
|
+
*
|
|
76
|
+
* Set for a bridge-dispatched run: the desktop runs it as a session of its own
|
|
77
|
+
* on its worker runtime, and that session's total is the number its Client
|
|
78
|
+
* shows. `usage` still carries the metrics for display in `details`, but
|
|
79
|
+
* `toNativeUsage`/`sumNativeUsage` leave it out, because pi folds a tool
|
|
80
|
+
* result's `usage` into *this* session's totals — publishing it here would add
|
|
81
|
+
* the same money to the parent's view of a run the parent did not perform
|
|
82
|
+
* (ADR 0028: cost is counted once, which is why the `COST_UPDATE` bus route was
|
|
83
|
+
* deleted rather than left alone).
|
|
84
|
+
*
|
|
85
|
+
* Absent on the fork path, where the child is a bare `pi --mode json` process
|
|
86
|
+
* that reports nothing to anyone: the tool result is the only cost report.
|
|
87
|
+
*/
|
|
88
|
+
usageAccountedElsewhere?: boolean;
|
|
47
89
|
model: string | undefined;
|
|
48
90
|
finalOutput: string | undefined;
|
|
49
91
|
error: string | undefined;
|
|
@@ -54,9 +96,49 @@ export interface SubagentResult {
|
|
|
54
96
|
export interface SubagentToolResult {
|
|
55
97
|
content: Array<{ type: "text"; text: string }>;
|
|
56
98
|
details: SubagentDetails;
|
|
99
|
+
/**
|
|
100
|
+
* The machine-readable result for programmatic callers, matching
|
|
101
|
+
* `SUBAGENT_OUTPUT_SCHEMA`. Success results only: a failed run reports
|
|
102
|
+
* `isError` and carries none, so a codemode script keeps rejecting with the
|
|
103
|
+
* text it rejects with today.
|
|
104
|
+
*
|
|
105
|
+
* A type alias, not an interface: `AgentToolResult.structuredContent` is
|
|
106
|
+
* `JsonValue`, and TypeScript will not match an interface against its index
|
|
107
|
+
* signature.
|
|
108
|
+
*/
|
|
109
|
+
structuredContent?: {
|
|
110
|
+
mode: "single" | "parallel";
|
|
111
|
+
results: SubagentRunReport[];
|
|
112
|
+
};
|
|
113
|
+
/**
|
|
114
|
+
* The runs' summed usage, in pi's own shape (see `usage.ts`).
|
|
115
|
+
*
|
|
116
|
+
* This is the cost report: pi persists it onto the toolResult message and
|
|
117
|
+
* folds it into `/session`, which is why the `COST_UPDATE` bus route to the
|
|
118
|
+
* footer was deleted instead of merely abandoned — the footer reads the same
|
|
119
|
+
* entries and would have counted the money twice.
|
|
120
|
+
*/
|
|
121
|
+
usage?: Usage;
|
|
57
122
|
isError?: boolean;
|
|
58
123
|
}
|
|
59
124
|
|
|
125
|
+
/**
|
|
126
|
+
* The machine-readable report for one run — `SubagentResult` without the live
|
|
127
|
+
* progress blob (see `subagentStructuredContent()` in `tool-schema.ts`).
|
|
128
|
+
*/
|
|
129
|
+
export type SubagentRunReport = {
|
|
130
|
+
agent: string;
|
|
131
|
+
task: string;
|
|
132
|
+
exitCode: number;
|
|
133
|
+
childSessionId?: string;
|
|
134
|
+
outcome?: RunOutcome;
|
|
135
|
+
usage: { input: number; output: number; cacheRead: number; cacheWrite: number; cost: number; turns: number };
|
|
136
|
+
model?: string;
|
|
137
|
+
finalOutput?: string;
|
|
138
|
+
error?: string;
|
|
139
|
+
progressSummary?: { toolCount: number; tokens: number; durationMs: number };
|
|
140
|
+
};
|
|
141
|
+
|
|
60
142
|
export interface SubagentDetails {
|
|
61
143
|
mode: "single" | "parallel";
|
|
62
144
|
results: SubagentResult[];
|
|
@@ -73,6 +155,10 @@ export interface StreamState {
|
|
|
73
155
|
totalCacheRead: number;
|
|
74
156
|
totalCacheWrite: number;
|
|
75
157
|
totalCost: number;
|
|
158
|
+
/** `stopReason` of the last assistant message seen (`length` ⇒ truncated). */
|
|
159
|
+
lastStopReason?: string;
|
|
160
|
+
/** `agent_settled.aborted` — pi 1.1.0's own account of a cancelled run. */
|
|
161
|
+
settledAborted?: boolean;
|
|
76
162
|
currentTool: string | undefined;
|
|
77
163
|
currentToolArgs: string | undefined;
|
|
78
164
|
currentToolStartedAt: number | undefined;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { toNativeUsage, sumNativeUsage } from "./usage.js";
|
|
3
|
+
import type { SubagentResult, SubagentUsage } from "./types.js";
|
|
4
|
+
|
|
5
|
+
// pi folds a tool result's `usage` into the session totals, and it reads exactly
|
|
6
|
+
// these fields (`addUsageToTotals`: input/output/cacheRead/cacheWrite and
|
|
7
|
+
// `cost.total`). A field it has to dereference and finds missing is a NaN in the
|
|
8
|
+
// session's cost, so the shape is the contract these tests pin.
|
|
9
|
+
|
|
10
|
+
function usage(overrides: Partial<SubagentUsage> = {}): SubagentUsage {
|
|
11
|
+
return {
|
|
12
|
+
input: 4000,
|
|
13
|
+
output: 1500,
|
|
14
|
+
cacheRead: 512,
|
|
15
|
+
cacheWrite: 8,
|
|
16
|
+
cost: 0.2,
|
|
17
|
+
turns: 3,
|
|
18
|
+
...overrides,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function result(withUsage: SubagentUsage): SubagentResult {
|
|
23
|
+
return {
|
|
24
|
+
agent: "reviewer",
|
|
25
|
+
task: "t",
|
|
26
|
+
exitCode: 0,
|
|
27
|
+
usage: withUsage,
|
|
28
|
+
model: undefined,
|
|
29
|
+
finalOutput: "x",
|
|
30
|
+
error: undefined,
|
|
31
|
+
progress: undefined,
|
|
32
|
+
progressSummary: undefined,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("toNativeUsage", () => {
|
|
37
|
+
it("carries every field pi dereferences, with the cost under cost.total", () => {
|
|
38
|
+
const native = toNativeUsage(usage());
|
|
39
|
+
expect(native.input).toBe(4000);
|
|
40
|
+
expect(native.output).toBe(1500);
|
|
41
|
+
expect(native.cacheRead).toBe(512);
|
|
42
|
+
expect(native.cacheWrite).toBe(8);
|
|
43
|
+
expect(native.cost.total).toBeCloseTo(0.2);
|
|
44
|
+
expect(typeof native.cost.input).toBe("number");
|
|
45
|
+
expect(typeof native.cost.output).toBe("number");
|
|
46
|
+
expect(typeof native.cost.cacheRead).toBe("number");
|
|
47
|
+
expect(typeof native.cost.cacheWrite).toBe("number");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("totalTokens is the sum of the four token kinds, as pi defines it", () => {
|
|
51
|
+
expect(toNativeUsage(usage()).totalTokens).toBe(4000 + 1500 + 512 + 8);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("a zero run produces zeros, not undefined", () => {
|
|
55
|
+
const native = toNativeUsage(usage({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 }));
|
|
56
|
+
expect(native.totalTokens).toBe(0);
|
|
57
|
+
expect(native.cost.total).toBe(0);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("sumNativeUsage", () => {
|
|
62
|
+
it("adds a parallel batch into one usage (one tool call, one toolResult message)", () => {
|
|
63
|
+
const total = sumNativeUsage([result(usage()), result(usage())])!;
|
|
64
|
+
expect(total.input).toBe(8000);
|
|
65
|
+
expect(total.output).toBe(3000);
|
|
66
|
+
expect(total.cost.total).toBeCloseTo(0.4);
|
|
67
|
+
expect(total.totalTokens).toBe(2 * (4000 + 1500 + 512 + 8));
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("returns undefined when there is nothing to report", () => {
|
|
71
|
+
expect(sumNativeUsage([])).toBeUndefined();
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("sumNativeUsage: which runs this session is allowed to report", () => {
|
|
76
|
+
// A bridge-dispatched run is a session of the desktop's own, and that session
|
|
77
|
+
// reports its usage to its own Client. Folding the same metrics into THIS
|
|
78
|
+
// session's totals (which is what a toolResult `usage` does) is the
|
|
79
|
+
// double-count ADR 0028 deleted the `COST_UPDATE` route to avoid — one tool
|
|
80
|
+
// call performed elsewhere must not cost twice.
|
|
81
|
+
const elsewhere = (over: Partial<SubagentUsage> = {}): SubagentResult => ({
|
|
82
|
+
...result(usage(over)),
|
|
83
|
+
usageAccountedElsewhere: true,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("leaves out a run whose usage the runtime that performed it already books", () => {
|
|
87
|
+
const total = sumNativeUsage([result(usage()), elsewhere()])!;
|
|
88
|
+
expect(total.input).toBe(4000);
|
|
89
|
+
expect(total.cost.total).toBeCloseTo(0.2);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("reports nothing at all when every run in the batch was performed elsewhere", () => {
|
|
93
|
+
expect(sumNativeUsage([elsewhere(), elsewhere()])).toBeUndefined();
|
|
94
|
+
});
|
|
95
|
+
});
|
package/src/usage.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { Usage } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { SubagentResult, SubagentUsage } from "./types.js";
|
|
3
|
+
|
|
4
|
+
// ── Native cost reporting (ADR 0028) ────────────────────────────────────────
|
|
5
|
+
//
|
|
6
|
+
// A tool result may carry `usage`, and pi does the accounting from there: the
|
|
7
|
+
// field is copied onto the toolResult message it persists
|
|
8
|
+
// (`pi-agent-core/dist/agent-loop.js`, `createToolResultMessage` — `usage:
|
|
9
|
+
// finalized.result.usage`) and `AgentSession.getSessionStats()` folds every
|
|
10
|
+
// toolResult message's usage into the session totals
|
|
11
|
+
// (`pi-coding-agent/dist/core/agent-session.js`, the `message.role ===
|
|
12
|
+
// "toolResult"` branch). So a subagent that returns `usage` is counted by
|
|
13
|
+
// `/session`, by the footer's own session scan, and by anything else that reads
|
|
14
|
+
// session stats — with no side channel involved.
|
|
15
|
+
//
|
|
16
|
+
// That is why the old bus route (`subagent/src/cost.ts` → `COST_UPDATE` →
|
|
17
|
+
// `footer/CostAccumulator`) had to go rather than just stop being used: the
|
|
18
|
+
// footer already counts toolResult usage, so leaving the accumulator in place
|
|
19
|
+
// would add the same money twice.
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The child's summed usage in pi's own `Usage` shape.
|
|
23
|
+
*
|
|
24
|
+
* The child's JSON stream reports one cost **total** per message, which is what
|
|
25
|
+
* pi adds up (`addUsageToTotals` reads `usage.cost.total`); the per-kind split is
|
|
26
|
+
* not observable from out here, so it is reported as zero rather than guessed at.
|
|
27
|
+
* `totalTokens` follows pi's own definition — input + output + both cache kinds.
|
|
28
|
+
*/
|
|
29
|
+
export function toNativeUsage(usage: SubagentUsage): Usage {
|
|
30
|
+
return {
|
|
31
|
+
input: usage.input,
|
|
32
|
+
output: usage.output,
|
|
33
|
+
cacheRead: usage.cacheRead,
|
|
34
|
+
cacheWrite: usage.cacheWrite,
|
|
35
|
+
totalTokens: usage.input + usage.output + usage.cacheRead + usage.cacheWrite,
|
|
36
|
+
cost: {
|
|
37
|
+
input: 0,
|
|
38
|
+
output: 0,
|
|
39
|
+
cacheRead: 0,
|
|
40
|
+
cacheWrite: 0,
|
|
41
|
+
total: usage.cost,
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Sum the usage of several runs into the one `usage` a single tool result can
|
|
48
|
+
* carry — a parallel batch is one tool call, so it has one toolResult message.
|
|
49
|
+
*
|
|
50
|
+
* Runs performed by another runtime (`usageAccountedElsewhere`, i.e. everything
|
|
51
|
+
* the bridge dispatched) are left out: their session reports its own usage, and
|
|
52
|
+
* this sum lands in *this* session's totals. A batch of nothing but such runs
|
|
53
|
+
* reports nothing at all.
|
|
54
|
+
*/
|
|
55
|
+
export function sumNativeUsage(results: SubagentResult[]): Usage | undefined {
|
|
56
|
+
const counted = results.filter(
|
|
57
|
+
(r) => r.usage !== undefined && r.usageAccountedElsewhere !== true,
|
|
58
|
+
);
|
|
59
|
+
if (counted.length === 0) return undefined;
|
|
60
|
+
const zero: Usage = {
|
|
61
|
+
input: 0,
|
|
62
|
+
output: 0,
|
|
63
|
+
cacheRead: 0,
|
|
64
|
+
cacheWrite: 0,
|
|
65
|
+
totalTokens: 0,
|
|
66
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
67
|
+
};
|
|
68
|
+
return counted.reduce<Usage>((total, result) => {
|
|
69
|
+
const next = toNativeUsage(result.usage);
|
|
70
|
+
return {
|
|
71
|
+
input: total.input + next.input,
|
|
72
|
+
output: total.output + next.output,
|
|
73
|
+
cacheRead: total.cacheRead + next.cacheRead,
|
|
74
|
+
cacheWrite: total.cacheWrite + next.cacheWrite,
|
|
75
|
+
totalTokens: total.totalTokens + next.totalTokens,
|
|
76
|
+
cost: {
|
|
77
|
+
input: total.cost.input + next.cost.input,
|
|
78
|
+
output: total.cost.output + next.cost.output,
|
|
79
|
+
cacheRead: total.cost.cacheRead + next.cost.cacheRead,
|
|
80
|
+
cacheWrite: total.cost.cacheWrite + next.cost.cacheWrite,
|
|
81
|
+
total: total.cost.total + next.cost.total,
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
},
|
|
85
|
+
zero,
|
|
86
|
+
);
|
|
87
|
+
}
|
package/src/cost.test.ts
DELETED
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
-
import { Events } from "@pi-archimedes/core/bus";
|
|
3
|
-
|
|
4
|
-
// ── mock bus ────────────────────────────────────────────────────────────────
|
|
5
|
-
|
|
6
|
-
let mockEmit: ReturnType<typeof vi.fn>;
|
|
7
|
-
|
|
8
|
-
vi.mock("@pi-archimedes/core/bus", async (importOriginal) => {
|
|
9
|
-
const actual = await importOriginal() as typeof import("@pi-archimedes/core/bus");
|
|
10
|
-
return {
|
|
11
|
-
...actual,
|
|
12
|
-
getBus: () => ({
|
|
13
|
-
emit: mockEmit,
|
|
14
|
-
}),
|
|
15
|
-
};
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
const { emitCostUpdate } = await import("./cost.js");
|
|
19
|
-
|
|
20
|
-
describe("emitCostUpdate", () => {
|
|
21
|
-
beforeEach(() => {
|
|
22
|
-
mockEmit = vi.fn();
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
it("emits COST_UPDATE event with correct source prefix", () => {
|
|
26
|
-
emitCostUpdate("my-agent", { inputTokens: 100, outputTokens: 50 });
|
|
27
|
-
|
|
28
|
-
expect(mockEmit).toHaveBeenCalledWith(Events.COST_UPDATE, {
|
|
29
|
-
source: "subagent:my-agent",
|
|
30
|
-
inputTokens: 100,
|
|
31
|
-
outputTokens: 50,
|
|
32
|
-
});
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
it("passes through all usage fields", () => {
|
|
36
|
-
emitCostUpdate("test-agent", {
|
|
37
|
-
inputTokens: 1000,
|
|
38
|
-
outputTokens: 500,
|
|
39
|
-
cacheReadTokens: 200,
|
|
40
|
-
cacheWriteTokens: 100,
|
|
41
|
-
cost: 0.05,
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
expect(mockEmit).toHaveBeenCalledWith(Events.COST_UPDATE, {
|
|
45
|
-
source: "subagent:test-agent",
|
|
46
|
-
inputTokens: 1000,
|
|
47
|
-
outputTokens: 500,
|
|
48
|
-
cacheReadTokens: 200,
|
|
49
|
-
cacheWriteTokens: 100,
|
|
50
|
-
cost: 0.05,
|
|
51
|
-
});
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
it("works with partial usage fields", () => {
|
|
55
|
-
emitCostUpdate("minimal", { cost: 0.01 });
|
|
56
|
-
|
|
57
|
-
expect(mockEmit).toHaveBeenCalledWith(Events.COST_UPDATE, {
|
|
58
|
-
source: "subagent:minimal",
|
|
59
|
-
cost: 0.01,
|
|
60
|
-
});
|
|
61
|
-
});
|
|
62
|
-
});
|
package/src/cost.ts
DELETED
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
import { getBus, Events } from "@pi-archimedes/core/bus";
|
|
2
|
-
|
|
3
|
-
export function emitCostUpdate(agent: string, usage: { inputTokens?: number; outputTokens?: number; cacheReadTokens?: number; cacheWriteTokens?: number; cost?: number }): void {
|
|
4
|
-
getBus().emit(Events.COST_UPDATE, {
|
|
5
|
-
source: `subagent:${agent}`,
|
|
6
|
-
...usage,
|
|
7
|
-
});
|
|
8
|
-
}
|