@selesai/code 0.13.32 → 0.13.33
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/CHANGELOG.md +16 -0
- package/dist/extensions/capability-gateway/integration.test.ts +36 -0
- package/dist/extensions/pi-subagents/agents/worker.md +3 -2
- package/dist/extensions/pi-subagents/docs/agents.md +2 -0
- package/dist/extensions/pi-subagents/docs/extension-api.md +3 -1
- package/dist/extensions/pi-subagents/docs/observability.md +2 -0
- package/dist/extensions/pi-subagents/docs/tool-reference.md +2 -2
- package/dist/extensions/pi-subagents/docs/workflows.md +1 -1
- package/dist/extensions/pi-subagents/src/extension/rpc.ts +10 -1
- package/dist/extensions/pi-subagents/src/runs/background/active-async-capacity.ts +3 -21
- package/dist/extensions/pi-subagents/src/runs/background/async-execution.ts +2 -1
- package/dist/extensions/pi-subagents/src/runs/background/run-child-session.ts +1 -0
- package/dist/extensions/pi-subagents/src/runs/background/run-status.ts +5 -1
- package/dist/extensions/pi-subagents/src/runs/background/subagent-runner.ts +10 -3
- package/dist/extensions/pi-subagents/src/runs/background/workflow-terminal-proof.ts +67 -0
- package/dist/extensions/pi-subagents/src/runs/foreground/execution.ts +6 -2
- package/dist/extensions/pi-subagents/src/runs/shared/child-tool-plan.ts +67 -5
- package/dist/extensions/pi-subagents/src/runs/shared/completion-guard.ts +6 -0
- package/dist/extensions/pi-subagents/src/runs/shared/external-cli-runner.ts +2 -1
- package/dist/extensions/pi-subagents/src/runs/shared/git-environment.ts +29 -0
- package/dist/extensions/pi-subagents/src/runs/shared/structured-output.ts +69 -0
- package/dist/extensions/pi-subagents/src/shared/types.ts +20 -0
- package/dist/extensions/pi-subagents/src/slash/slash-commands.ts +4 -238
- package/dist/extensions/pi-subagents/src/slash/subagent-cost.ts +280 -0
- package/dist/extensions/pi-subagents/test/integration/async-execution.part-3.test.ts +67 -0
- package/dist/extensions/pi-subagents/test/integration/in-process-child.test.ts +29 -1
- package/dist/extensions/pi-subagents/test/integration/intercom-result-delivery.test.ts +6 -3
- package/dist/extensions/pi-subagents/test/integration/single-execution.part-2.test.ts +25 -0
- package/dist/extensions/pi-subagents/test/unit/agent-frontmatter.test.ts +5 -5
- package/dist/extensions/pi-subagents/test/unit/async-spawn-preload.test.ts +21 -0
- package/dist/extensions/pi-subagents/test/unit/child-tool-plan-permission-system.test.ts +98 -0
- package/dist/extensions/pi-subagents/test/unit/child-tool-plan.test.ts +11 -0
- package/dist/extensions/pi-subagents/test/unit/completion-guard.test.ts +12 -0
- package/dist/extensions/pi-subagents/test/unit/external-cli-runner.test.ts +25 -0
- package/dist/extensions/pi-subagents/test/unit/git-environment.test.ts +38 -0
- package/dist/extensions/pi-subagents/test/unit/preflight.test.ts +3 -1
- package/dist/extensions/pi-subagents/test/unit/rpc.test.ts +105 -1
- package/dist/extensions/pi-subagents/test/unit/run-status.test.ts +36 -0
- package/dist/extensions/pi-subagents/test/unit/structured-output-rejection.test.ts +67 -0
- package/dist/extensions/pi-subagents/test/unit/workflow-terminal-proof.test.ts +98 -0
- package/dist/skills/pi-subagents/references/constraints-and-recipes.md +8 -8
- package/dist/skills/pi-subagents/references/execution-controls.md +1 -1
- package/dist/skills/pi-subagents/references/prompting-and-roles.md +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import type { ExtensionContext } from "@selesai/code";
|
|
4
|
+
import { getArtifactPaths, getArtifactsDir } from "../shared/artifacts.ts";
|
|
5
|
+
import { formatTokens } from "../shared/formatters.ts";
|
|
6
|
+
import { DIRS, SLASH_RESULT_TYPE, type Details, type SingleResult, type SubagentState, type Usage } from "../shared/types.ts";
|
|
7
|
+
import { readStatus } from "../shared/utils.ts";
|
|
8
|
+
import { readWorkflowReceipt } from "../workflows/workflow-receipt.ts";
|
|
9
|
+
import { resolveSlashMessageDetails } from "./slash-live-state.ts";
|
|
10
|
+
|
|
11
|
+
/** Structured parent-plus-child accounting for one session; the source of `/subagent-cost` and the RPC `cost` method. */
|
|
12
|
+
export interface SubagentCostReport {
|
|
13
|
+
version: 1;
|
|
14
|
+
parent: Usage;
|
|
15
|
+
children: SubagentCostChild[];
|
|
16
|
+
childTotal: Usage;
|
|
17
|
+
total: Usage;
|
|
18
|
+
/** Async children whose usage metadata could not be resolved; the child total is a lower bound when this is non-zero. */
|
|
19
|
+
unresolvedAsyncChildren: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface SubagentCostChild {
|
|
23
|
+
label: string;
|
|
24
|
+
agent?: string;
|
|
25
|
+
runId?: string;
|
|
26
|
+
usage: Usage;
|
|
27
|
+
sessionFile?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const SUBAGENT_COST_REPORT_VERSION = 1 as const;
|
|
31
|
+
|
|
32
|
+
function emptyUsage(): Usage {
|
|
33
|
+
return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function addUsage(target: Usage, source: Usage): void {
|
|
37
|
+
target.input += source.input;
|
|
38
|
+
target.output += source.output;
|
|
39
|
+
target.cacheRead += source.cacheRead;
|
|
40
|
+
target.cacheWrite += source.cacheWrite;
|
|
41
|
+
target.cost += source.cost;
|
|
42
|
+
target.turns += source.turns;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function usageHasValue(usage: Usage): boolean {
|
|
46
|
+
return usage.input !== 0 || usage.output !== 0 || usage.cacheRead !== 0 || usage.cacheWrite !== 0 || usage.cost !== 0 || usage.turns !== 0;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function nonNegativeNumber(value: unknown): number | undefined {
|
|
50
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function usageFromValue(value: unknown, turnsOverride?: number): Usage | undefined {
|
|
54
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
55
|
+
const record = value as Record<string, unknown>;
|
|
56
|
+
const costValue = typeof record.cost === "object" && record.cost !== null && !Array.isArray(record.cost)
|
|
57
|
+
? (record.cost as Record<string, unknown>).total
|
|
58
|
+
: record.cost;
|
|
59
|
+
const turnsValue = turnsOverride ?? record.turns;
|
|
60
|
+
const usage = {
|
|
61
|
+
input: nonNegativeNumber(record.input) ?? 0,
|
|
62
|
+
output: nonNegativeNumber(record.output) ?? 0,
|
|
63
|
+
cacheRead: nonNegativeNumber(record.cacheRead) ?? 0,
|
|
64
|
+
cacheWrite: nonNegativeNumber(record.cacheWrite) ?? 0,
|
|
65
|
+
cost: nonNegativeNumber(costValue) ?? 0,
|
|
66
|
+
turns: nonNegativeNumber(turnsValue) ?? 0,
|
|
67
|
+
};
|
|
68
|
+
return Number.isSafeInteger(usage.turns) ? usage : undefined;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function assistantUsageFromMessage(message: unknown): Usage | undefined {
|
|
72
|
+
if (!message || typeof message !== "object") return undefined;
|
|
73
|
+
const msg = message as { role?: unknown; usage?: unknown };
|
|
74
|
+
return msg.role === "assistant" ? usageFromValue(msg.usage, 1) : undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function compactionUsageFromEntry(entry: unknown): Usage | undefined {
|
|
78
|
+
if (!entry || typeof entry !== "object") return undefined;
|
|
79
|
+
const record = entry as { type?: unknown; usage?: unknown };
|
|
80
|
+
return record.type === "compaction" ? usageFromValue(record.usage, 0) : undefined;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const MAX_USAGE_METADATA_BYTES = 2 * 1024 * 1024;
|
|
84
|
+
|
|
85
|
+
function readUsageMetadata(metadataPath: string): Record<string, unknown> | undefined {
|
|
86
|
+
let fd: number | undefined;
|
|
87
|
+
try {
|
|
88
|
+
fd = fs.openSync(metadataPath, "r");
|
|
89
|
+
const stat = fs.fstatSync(fd);
|
|
90
|
+
if (!stat.isFile() || stat.size > MAX_USAGE_METADATA_BYTES) return undefined;
|
|
91
|
+
const buffer = Buffer.allocUnsafe(stat.size);
|
|
92
|
+
let offset = 0;
|
|
93
|
+
while (offset < buffer.length) {
|
|
94
|
+
const bytesRead = fs.readSync(fd, buffer, offset, buffer.length - offset, null);
|
|
95
|
+
if (bytesRead <= 0) return undefined;
|
|
96
|
+
offset += bytesRead;
|
|
97
|
+
}
|
|
98
|
+
const value: unknown = JSON.parse(buffer.toString("utf-8"));
|
|
99
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
|
|
100
|
+
} finally {
|
|
101
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function isSubagentDetails(value: unknown): value is Details {
|
|
106
|
+
if (!value || typeof value !== "object") return false;
|
|
107
|
+
const details = value as { mode?: unknown; results?: unknown };
|
|
108
|
+
return typeof details.mode === "string" && Array.isArray(details.results);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function detailsFromSessionEntry(entry: unknown): Details | undefined {
|
|
112
|
+
if (!entry || typeof entry !== "object") return undefined;
|
|
113
|
+
const record = entry as { type?: unknown; customType?: unknown; details?: unknown; message?: unknown };
|
|
114
|
+
if (record.type === "custom_message" && record.customType === SLASH_RESULT_TYPE) {
|
|
115
|
+
const details = resolveSlashMessageDetails(record.details)?.result.details;
|
|
116
|
+
return isSubagentDetails(details) ? details : undefined;
|
|
117
|
+
}
|
|
118
|
+
if (record.type !== "message" || !record.message || typeof record.message !== "object") return undefined;
|
|
119
|
+
const message = record.message as { role?: unknown; toolName?: unknown; details?: unknown };
|
|
120
|
+
if (message.role !== "toolResult" || (message.toolName !== "subagent" && message.toolName !== "bg_wait")) return undefined;
|
|
121
|
+
return isSubagentDetails(message.details) ? message.details : undefined;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function metadataUsage(
|
|
125
|
+
artifactsDirs: string[],
|
|
126
|
+
input: { runId: string; agent: string },
|
|
127
|
+
): Usage | undefined {
|
|
128
|
+
if (!/^[A-Za-z0-9._-]+$/.test(input.runId)) return undefined;
|
|
129
|
+
for (const artifactsDir of artifactsDirs) {
|
|
130
|
+
for (const index of [0, undefined] as const) {
|
|
131
|
+
const metadataPath = getArtifactPaths(artifactsDir, input.runId, input.agent, index).metadataPath;
|
|
132
|
+
try {
|
|
133
|
+
const metadata = readUsageMetadata(metadataPath);
|
|
134
|
+
if (!metadata) continue;
|
|
135
|
+
if (metadata.runId !== input.runId || metadata.agent !== input.agent) continue;
|
|
136
|
+
const usage = usageFromValue(metadata.usage);
|
|
137
|
+
if (usage && usageHasValue(usage)) return usage;
|
|
138
|
+
} catch (error) {
|
|
139
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") console.error(`Failed to read subagent usage metadata '${metadataPath}':`, error);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Collect parent and child usage for the current session branch. Foreground
|
|
148
|
+
* children come from persisted `subagent`/`bg_wait` tool-result details; async
|
|
149
|
+
* workflow children are resolved through receipts and artifact metadata.
|
|
150
|
+
*/
|
|
151
|
+
export function collectSubagentCost(
|
|
152
|
+
ctx: ExtensionContext,
|
|
153
|
+
state: Pick<SubagentState, "baseCwd" | "artifactDirPreference">,
|
|
154
|
+
): SubagentCostReport {
|
|
155
|
+
const parent = emptyUsage();
|
|
156
|
+
const childTotal = emptyUsage();
|
|
157
|
+
const total = emptyUsage();
|
|
158
|
+
const children: SubagentCostChild[] = [];
|
|
159
|
+
const seenChildren = new Set<string>();
|
|
160
|
+
const workflowRunIds = new Set<string>();
|
|
161
|
+
let unresolvedAsyncChildren = 0;
|
|
162
|
+
|
|
163
|
+
const addChild = (input: { agent?: string; runId?: string; usage?: Usage; sessionFile?: string }): boolean => {
|
|
164
|
+
if (!input.usage || !usageHasValue(input.usage)) return false;
|
|
165
|
+
const identity = input.runId ? `run:${input.runId}` : input.sessionFile ? `session:${input.sessionFile}` : undefined;
|
|
166
|
+
if (identity && seenChildren.has(identity)) return true;
|
|
167
|
+
if (identity) seenChildren.add(identity);
|
|
168
|
+
const usage = { ...input.usage };
|
|
169
|
+
children.push({
|
|
170
|
+
label: `Child ${children.length + 1} (${input.agent ?? "unknown"})`,
|
|
171
|
+
...(input.agent ? { agent: input.agent } : {}),
|
|
172
|
+
...(input.runId ? { runId: input.runId } : {}),
|
|
173
|
+
usage,
|
|
174
|
+
...(input.sessionFile ? { sessionFile: input.sessionFile } : {}),
|
|
175
|
+
});
|
|
176
|
+
addUsage(childTotal, usage);
|
|
177
|
+
return true;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
for (const entry of ctx.sessionManager.getBranch()) {
|
|
181
|
+
const message = entry.type === "message" ? (entry as { message?: unknown }).message : undefined;
|
|
182
|
+
const parentUsage = assistantUsageFromMessage(message) ?? compactionUsageFromEntry(entry);
|
|
183
|
+
if (parentUsage) addUsage(parent, parentUsage);
|
|
184
|
+
const details = detailsFromSessionEntry(entry);
|
|
185
|
+
if (!details) continue;
|
|
186
|
+
if (details.mode === "workflow" && details.runId) workflowRunIds.add(details.runId);
|
|
187
|
+
for (const result of details.results) {
|
|
188
|
+
const resultRunId = (result as SingleResult & { runId?: unknown }).runId;
|
|
189
|
+
addChild({ agent: result.agent, runId: typeof resultRunId === "string" ? resultRunId : undefined, usage: usageFromValue(result.usage), sessionFile: result.sessionFile });
|
|
190
|
+
}
|
|
191
|
+
for (const completion of details.completions ?? []) {
|
|
192
|
+
if (completion.mode === "workflow") workflowRunIds.add(completion.runId);
|
|
193
|
+
for (const result of completion.results ?? []) {
|
|
194
|
+
addChild({ agent: result.agent, runId: result.runId, usage: usageFromValue(result.usage), sessionFile: result.sessionFile });
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
let sessionFile: string | null = null;
|
|
200
|
+
try {
|
|
201
|
+
sessionFile = ctx.sessionManager.getSessionFile() ?? null;
|
|
202
|
+
} catch { /* an unpersisted session can still report direct child usage */ }
|
|
203
|
+
const artifactsDirs = new Set<string>();
|
|
204
|
+
const addArtifactsDir = (cwd: string | undefined): void => {
|
|
205
|
+
try {
|
|
206
|
+
artifactsDirs.add(getArtifactsDir(sessionFile, cwd, state.artifactDirPreference));
|
|
207
|
+
} catch { /* invalid or unavailable project roots are ignored */ }
|
|
208
|
+
};
|
|
209
|
+
addArtifactsDir(ctx.cwd);
|
|
210
|
+
addArtifactsDir(state.baseCwd);
|
|
211
|
+
|
|
212
|
+
for (const workflowRunId of workflowRunIds) {
|
|
213
|
+
try {
|
|
214
|
+
const status = readStatus(path.join(DIRS.async, workflowRunId));
|
|
215
|
+
addArtifactsDir(status?.cwd);
|
|
216
|
+
const receipt = readWorkflowReceipt(DIRS.async, workflowRunId);
|
|
217
|
+
const summaryChildren = new Map((receipt.workflowChildren?.children ?? []).map((child) => [child.childId, child]));
|
|
218
|
+
const refsByRunId = new Map<string, { runId: string; agent?: string }>();
|
|
219
|
+
const addRef = (runId: string | undefined, agent: string | undefined): void => {
|
|
220
|
+
if (!runId) return;
|
|
221
|
+
const existing = refsByRunId.get(runId);
|
|
222
|
+
if (!existing || (!existing.agent && agent)) refsByRunId.set(runId, { runId, ...(agent ? { agent } : {}) });
|
|
223
|
+
};
|
|
224
|
+
for (const [key, entry] of Object.entries(receipt.entries)) {
|
|
225
|
+
const summaryChild = summaryChildren.get(key);
|
|
226
|
+
const runIds = entry.continuation?.runIds?.length
|
|
227
|
+
? entry.continuation.runIds
|
|
228
|
+
: entry.latestRunId ? [entry.latestRunId] : summaryChild?.runId ? [summaryChild.runId] : [];
|
|
229
|
+
for (const runId of runIds) addRef(runId, entry.agent ?? summaryChild?.agent);
|
|
230
|
+
}
|
|
231
|
+
for (const child of receipt.workflowChildren?.children ?? []) {
|
|
232
|
+
if (!Object.hasOwn(receipt.entries, child.childId)) addRef(child.runId, child.agent);
|
|
233
|
+
}
|
|
234
|
+
for (const ref of refsByRunId.values()) {
|
|
235
|
+
if (seenChildren.has(`run:${ref.runId}`)) continue;
|
|
236
|
+
if (!ref.agent) {
|
|
237
|
+
unresolvedAsyncChildren += 1;
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
const usage = metadataUsage([...artifactsDirs], { runId: ref.runId, agent: ref.agent });
|
|
241
|
+
if (!usage || !addChild({ ...ref, usage })) unresolvedAsyncChildren += 1;
|
|
242
|
+
}
|
|
243
|
+
} catch (error) {
|
|
244
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") console.error(`Failed to resolve async subagent usage for '${workflowRunId}':`, error);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
addUsage(total, parent);
|
|
249
|
+
addUsage(total, childTotal);
|
|
250
|
+
return { version: SUBAGENT_COST_REPORT_VERSION, parent, children, childTotal, total, unresolvedAsyncChildren };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function formatCostUsage(label: string, usage: Usage): string {
|
|
254
|
+
const extras = [
|
|
255
|
+
usage.cacheRead ? `cache read ${formatTokens(usage.cacheRead)}` : "",
|
|
256
|
+
usage.cacheWrite ? `cache write ${formatTokens(usage.cacheWrite)}` : "",
|
|
257
|
+
usage.turns ? `${usage.turns} turn${usage.turns === 1 ? "" : "s"}` : "",
|
|
258
|
+
].filter(Boolean);
|
|
259
|
+
return `${label}: ↑${formatTokens(usage.input)} ↓${formatTokens(usage.output)} $${usage.cost.toFixed(4)}${extras.length ? ` (${extras.join(", ")})` : ""}`;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** The `/subagent-cost` text rendering of a collected report. */
|
|
263
|
+
export function formatSubagentCostReport(report: SubagentCostReport): string {
|
|
264
|
+
const lines = [
|
|
265
|
+
"Subagent cost",
|
|
266
|
+
"",
|
|
267
|
+
formatCostUsage("Parent", report.parent),
|
|
268
|
+
];
|
|
269
|
+
if (report.children.length === 0) {
|
|
270
|
+
lines.push("No subagent child usage found in this session.");
|
|
271
|
+
} else {
|
|
272
|
+
for (const child of report.children) {
|
|
273
|
+
lines.push(formatCostUsage(child.label, child.usage));
|
|
274
|
+
if (child.sessionFile) lines.push(` Session: ${child.sessionFile}`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
if (report.unresolvedAsyncChildren > 0) lines.push(`Async child usage unavailable: ${report.unresolvedAsyncChildren}.`);
|
|
278
|
+
lines.push("────────────────────────────", formatCostUsage("Children", report.childTotal), formatCostUsage("Total", report.total));
|
|
279
|
+
return lines.join("\n");
|
|
280
|
+
}
|
|
@@ -17,6 +17,7 @@ import { createEventBus, createTempDir, events, makeAgent, makeMinimalCtx, remov
|
|
|
17
17
|
import { discoverAgents } from "../../src/agents/agents.ts";
|
|
18
18
|
import { ACTIVE_ASYNC_CAPACITY_DIR, acquireActiveAsyncCapacity, activeAsyncCapacitySessionKey } from "../../src/runs/background/active-async-capacity.ts";
|
|
19
19
|
import { deriveForkPromptCacheKey } from "../../src/runs/shared/child-tool-plan.ts";
|
|
20
|
+
import { INVALID_STRUCTURED_OUTPUT_SCHEMA_ERROR, validateStructuredOutputValue } from "../../src/runs/shared/structured-output.ts";
|
|
20
21
|
import type { AsyncExecutionResult, AsyncResultPayload, AsyncStatusPayload } from "../support/async-execution-fixture.ts";
|
|
21
22
|
import {
|
|
22
23
|
installAsyncExecutionHooks, waitForMockPiRuntime, available, isAsyncAvailable,
|
|
@@ -1102,6 +1103,72 @@ export default function() {
|
|
|
1102
1103
|
assert.deepEqual(payload.results[0]?.structuredOutput, { ok: true, note: "async" });
|
|
1103
1104
|
});
|
|
1104
1105
|
|
|
1106
|
+
it("background settlement preserves rejected structured_output evidence", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
1107
|
+
const diagnostic = "Structured output validation failed: ok: is required";
|
|
1108
|
+
mockPi.onCall({
|
|
1109
|
+
jsonl: [
|
|
1110
|
+
{ type: "message_end", message: { role: "assistant", content: [{ type: "toolCall", id: "structured-rejected", name: "structured_output", arguments: { value: {} } }], model: "mock/test-model", stopReason: "toolUse", usage: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0, cost: { total: 0 } } } },
|
|
1111
|
+
{ type: "tool_execution_start", toolCallId: "structured-rejected", toolName: "structured_output", args: { value: {} } },
|
|
1112
|
+
{ type: "tool_result_end", message: { role: "toolResult", toolCallId: "structured-rejected", toolName: "structured_output", isError: true, content: [{ type: "text", text: diagnostic }] } },
|
|
1113
|
+
{ type: "tool_execution_end", toolCallId: "structured-rejected", toolName: "structured_output" },
|
|
1114
|
+
],
|
|
1115
|
+
});
|
|
1116
|
+
const id = `async-single-schema-rejected-${Date.now().toString(36)}`;
|
|
1117
|
+
|
|
1118
|
+
executeAsyncSingle(id, {
|
|
1119
|
+
agent: "worker",
|
|
1120
|
+
task: "Return structured data",
|
|
1121
|
+
agentConfig: makeAgent("worker"),
|
|
1122
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
|
|
1123
|
+
artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
|
|
1124
|
+
shareEnabled: false,
|
|
1125
|
+
sessionRoot: path.join(tempDir, "sessions"),
|
|
1126
|
+
maxSubagentDepth: 2,
|
|
1127
|
+
acceptance: false,
|
|
1128
|
+
structuredOutputSchema: { type: "object", required: ["ok"], properties: { ok: { type: "boolean" } } },
|
|
1129
|
+
});
|
|
1130
|
+
|
|
1131
|
+
const payload = JSON.parse(fs.readFileSync(await waitForAsyncResultFile(id, 10_000), "utf-8")) as AsyncResultPayload;
|
|
1132
|
+
const status = await waitForAsyncState(id, (candidate) => candidate.state === "failed");
|
|
1133
|
+
assert.equal(payload.success, false);
|
|
1134
|
+
assert.match(payload.results[0]?.error ?? "", /Structured output validation failed: ok: is required/);
|
|
1135
|
+
assert.doesNotMatch(payload.results[0]?.error ?? "", /Missing structured_output call/);
|
|
1136
|
+
assert.equal(payload.results[0]?.structuredOutputFailed, true);
|
|
1137
|
+
assert.match(status.steps?.[0]?.error ?? "", /Structured output validation failed: ok: is required/);
|
|
1138
|
+
});
|
|
1139
|
+
|
|
1140
|
+
it("does not persist malformed outputSchema compiler text in background evidence", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
1141
|
+
const sentinel = "BACKGROUND_PRIVATE_SCHEMA_SENTINEL";
|
|
1142
|
+
const structuredOutputSchema = { type: "string", pattern: `${sentinel}_[invalid` };
|
|
1143
|
+
const validation = await validateStructuredOutputValue(structuredOutputSchema, "value");
|
|
1144
|
+
assert.equal(validation.status, "invalid");
|
|
1145
|
+
if (validation.status !== "invalid") return;
|
|
1146
|
+
assert.match(validation.message, new RegExp(sentinel));
|
|
1147
|
+
mockPi.onCall({
|
|
1148
|
+
jsonl: [
|
|
1149
|
+
{ type: "tool_execution_start", toolCallId: "structured-malformed-schema", toolName: "structured_output", args: { value: "value" } },
|
|
1150
|
+
{ type: "tool_result_end", message: { role: "toolResult", toolCallId: "structured-malformed-schema", toolName: "structured_output", isError: true, content: [{ type: "text", text: `Structured output validation failed: ${validation.message}` }] } },
|
|
1151
|
+
{ type: "tool_execution_end", toolCallId: "structured-malformed-schema", toolName: "structured_output" },
|
|
1152
|
+
],
|
|
1153
|
+
});
|
|
1154
|
+
const id = `async-single-malformed-schema-${Date.now().toString(36)}`;
|
|
1155
|
+
executeAsyncSingle(id, {
|
|
1156
|
+
agent: "worker", task: "Return structured data", agentConfig: makeAgent("worker"),
|
|
1157
|
+
ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
|
|
1158
|
+
artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
|
|
1159
|
+
shareEnabled: false, sessionRoot: path.join(tempDir, "sessions"), maxSubagentDepth: 2,
|
|
1160
|
+
acceptance: false, structuredOutputSchema,
|
|
1161
|
+
});
|
|
1162
|
+
|
|
1163
|
+
const resultPath = await waitForAsyncResultFile(id, 10_000);
|
|
1164
|
+
const payloadText = fs.readFileSync(resultPath, "utf-8");
|
|
1165
|
+
const payload = JSON.parse(payloadText) as AsyncResultPayload;
|
|
1166
|
+
const status = await waitForAsyncState(id, (candidate) => candidate.state === "failed");
|
|
1167
|
+
assert.match(payload.results[0]?.error ?? "", new RegExp(`^${escapeRegExp(INVALID_STRUCTURED_OUTPUT_SCHEMA_ERROR)}`));
|
|
1168
|
+
assert.equal(payloadText.includes(sentinel), false);
|
|
1169
|
+
assert.equal((status.steps?.[0]?.error ?? "").includes(sentinel), false);
|
|
1170
|
+
});
|
|
1171
|
+
|
|
1105
1172
|
it("background outputSchema runs fail closed when required acceptanceReport is missing", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
|
|
1106
1173
|
mockPi.onCall({ output: "structured", structuredOutput: { ok: true } });
|
|
1107
1174
|
const id = `async-schema-missing-acceptance-${Date.now().toString(36)}`;
|
|
@@ -9,6 +9,7 @@ import { describe, it, before, after, beforeEach } from "node:test";
|
|
|
9
9
|
import assert from "node:assert/strict";
|
|
10
10
|
import * as fs from "node:fs";
|
|
11
11
|
import * as path from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
12
13
|
import type { MockPi } from "../support/helpers.ts";
|
|
13
14
|
import { createMockPi, createTempDir, makeAgent, makeAgentConfigs, removeTempDir } from "../support/helpers.ts";
|
|
14
15
|
import { runSync } from "../../src/runs/foreground/execution.ts";
|
|
@@ -17,6 +18,9 @@ import { createNestedRoute } from "../../src/runs/shared/nested-events.ts";
|
|
|
17
18
|
import { createStructuredOutputRuntime } from "../../src/runs/shared/structured-output.ts";
|
|
18
19
|
import type { ForegroundChildSessionControls, SingleResult } from "../../src/shared/types.ts";
|
|
19
20
|
|
|
21
|
+
/** Explicit child tool surfaces also receive the scoped capability gateway controls. */
|
|
22
|
+
const CHILD_CAPABILITY_GATEWAY_TOOLS = ["capability_catalog", "capability_discover", "capability_skill_show"];
|
|
23
|
+
|
|
20
24
|
async function waitFor(read: () => boolean, timeoutMs = 5_000): Promise<void> {
|
|
21
25
|
const deadline = Date.now() + timeoutMs;
|
|
22
26
|
while (!read()) {
|
|
@@ -71,6 +75,30 @@ describe("in-process foreground child", () => {
|
|
|
71
75
|
}
|
|
72
76
|
});
|
|
73
77
|
|
|
78
|
+
it("passes wrapped core tools and the Graft provider to a launched foreground child", async () => {
|
|
79
|
+
const wrapperPath = path.join(tempDir, "wrapped-core-tools.ts");
|
|
80
|
+
const graftExtensionPath = fileURLToPath(new URL("../../../pi-graft/index.ts", import.meta.url));
|
|
81
|
+
const tools = ["read", "grep", "graft_find_code"];
|
|
82
|
+
fs.writeFileSync(wrapperPath, [
|
|
83
|
+
"export default function register(pi) {",
|
|
84
|
+
" const available = new Map(pi.getAllTools().map((tool) => [tool.name, tool]));",
|
|
85
|
+
" for (const name of ['read', 'grep']) {",
|
|
86
|
+
" const tool = available.get(name);",
|
|
87
|
+
" if (tool) pi.registerTool({ ...tool, description: 'wrapped ' + name + ': ' + tool.description });",
|
|
88
|
+
" }",
|
|
89
|
+
"}",
|
|
90
|
+
].join("\n"));
|
|
91
|
+
mockPi.onCall({ output: "read, grep, and Graft tools were available" });
|
|
92
|
+
const result = await runSync(tempDir, [makeAgent("worker", { tools, subagentOnlyExtensions: [wrapperPath, graftExtensionPath] })], "worker", "Inspect with read, grep, and Graft", { runId: "wrapped-graft-child" });
|
|
93
|
+
assert.equal(result.exitCode, 0, result.error);
|
|
94
|
+
const launch = mockPi.sessions[0]?.launch;
|
|
95
|
+
const expectedTools = [...tools, ...CHILD_CAPABILITY_GATEWAY_TOOLS];
|
|
96
|
+
assert.deepEqual(launch?.tools, expectedTools);
|
|
97
|
+
assert.deepEqual(launch?.runtime.requiredTools, expectedTools);
|
|
98
|
+
assert.ok(launch?.extensionPaths.includes(wrapperPath));
|
|
99
|
+
assert.ok(launch?.extensionPaths.includes(graftExtensionPath));
|
|
100
|
+
});
|
|
101
|
+
|
|
74
102
|
it("adds the fanout hook and nested route only for fanout-authorized children", async () => {
|
|
75
103
|
const route = createNestedRoute("hooks-fanout");
|
|
76
104
|
try {
|
|
@@ -84,7 +112,7 @@ describe("in-process foreground child", () => {
|
|
|
84
112
|
assert.equal(session?.launch.runtime.fanoutChild, true);
|
|
85
113
|
assert.deepEqual(session?.launch.runtime.nestedRoute, route);
|
|
86
114
|
assert.deepEqual(session?.launch.runtime.nestedParent, { parentRunId: "hooks-fanout", parentChildIndex: 0, depth: 1, path: [{ runId: "hooks-fanout", stepIndex: 0, agent: "delegator" }] });
|
|
87
|
-
assert.deepEqual(session?.launch.tools, ["read", "subagent"]);
|
|
115
|
+
assert.deepEqual(session?.launch.tools, ["read", "subagent", ...CHILD_CAPABILITY_GATEWAY_TOOLS]);
|
|
88
116
|
} finally {
|
|
89
117
|
fs.rmSync(path.dirname(route.eventSink), { recursive: true, force: true });
|
|
90
118
|
}
|
|
@@ -23,6 +23,9 @@ import {
|
|
|
23
23
|
tryImport,
|
|
24
24
|
} from "../support/helpers.ts";
|
|
25
25
|
|
|
26
|
+
/** Children with an explicit tool surface also receive the scoped capability gateway controls. */
|
|
27
|
+
const CHILD_CAPABILITY_GATEWAY_TOOLS = "capability_catalog,capability_discover,capability_skill_show";
|
|
28
|
+
|
|
26
29
|
interface ExecutorResult {
|
|
27
30
|
content: Array<{ text?: string }>;
|
|
28
31
|
isError?: boolean;
|
|
@@ -308,8 +311,8 @@ describe("intercom result delivery cutover", { skip: !available ? "executor not
|
|
|
308
311
|
|
|
309
312
|
const isolatedArgs = await readMockCallArgs(0);
|
|
310
313
|
const normalArgs = await readMockCallArgs(1);
|
|
311
|
-
assert.equal(isolatedArgs[isolatedArgs.indexOf("--tools") + 1],
|
|
312
|
-
assert.equal(normalArgs[normalArgs.indexOf("--tools") + 1],
|
|
314
|
+
assert.equal(isolatedArgs[isolatedArgs.indexOf("--tools") + 1], `read,${CHILD_CAPABILITY_GATEWAY_TOOLS}`);
|
|
315
|
+
assert.equal(normalArgs[normalArgs.indexOf("--tools") + 1], `read,contact_supervisor,${CHILD_CAPABILITY_GATEWAY_TOOLS}`);
|
|
313
316
|
assert.equal(events.emitted.filter((entry) => entry.channel === "subagent:result-intercom").length, 1);
|
|
314
317
|
assert.deepEqual(result.details?.workflow?.value, { isolated: "Isolated child output", normal: "Normal child output" });
|
|
315
318
|
});
|
|
@@ -1106,7 +1109,7 @@ describe("intercom result delivery cutover", { skip: !available ? "executor not
|
|
|
1106
1109
|
const args = await readMockCallArgs(0);
|
|
1107
1110
|
assert.equal(args[args.indexOf("--session") + 1], sessionFile);
|
|
1108
1111
|
assert.equal(args[args.indexOf("--model") + 1], "anthropic/claude-sonnet-4:high");
|
|
1109
|
-
assert.equal(args[args.indexOf("--tools") + 1],
|
|
1112
|
+
assert.equal(args[args.indexOf("--tools") + 1], `read,${CHILD_CAPABILITY_GATEWAY_TOOLS}`);
|
|
1110
1113
|
assert.equal(args.includes("--system-prompt"), true);
|
|
1111
1114
|
assert.equal(args.includes("--append-system-prompt"), false);
|
|
1112
1115
|
await waitForFile(path.join(RESULTS_DIR, `${revivedId}.json`));
|
|
@@ -1623,6 +1623,31 @@ if (!fs.existsSync(${JSON.stringify(holdPath)})) { console.log('{}'); } else {
|
|
|
1623
1623
|
if (child?.artifactPaths?.outputPath) assert.match(fs.readFileSync(child.artifactPaths.outputPath, "utf-8"), /"note": "captured"/);
|
|
1624
1624
|
});
|
|
1625
1625
|
|
|
1626
|
+
it("reports bounded structured_output rejection evidence without leaking the submitted value", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
1627
|
+
const secret = "PRIVATE_STRUCTURED_OUTPUT_VALUE";
|
|
1628
|
+
mockPi.onCall({
|
|
1629
|
+
stdoutRaw: [
|
|
1630
|
+
{ type: "tool_execution_start", toolCallId: "structured-rejected", toolName: "structured_output", args: { value: { secret } } },
|
|
1631
|
+
{ type: "tool_result_end", message: { role: "toolResult", toolCallId: "structured-rejected", toolName: "structured_output", isError: true, content: [{ type: "text", text: `Structured output validation failed: value.secret: expected boolean; received value: ${secret}` }] } },
|
|
1632
|
+
{ type: "tool_execution_end", toolCallId: "structured-rejected", toolName: "structured_output", isError: true },
|
|
1633
|
+
].map((entry) => JSON.stringify(entry)).join("\n") + "\n",
|
|
1634
|
+
});
|
|
1635
|
+
const executor = makeExecutor([makeAgent("echo")]);
|
|
1636
|
+
const result = await executor.execute(
|
|
1637
|
+
"single-schema-rejected",
|
|
1638
|
+
{ agent: "echo", task: "Return structured data", outputSchema: { type: "object", required: ["ok"], properties: { ok: { type: "boolean" } } }, acceptance: false },
|
|
1639
|
+
new AbortController().signal,
|
|
1640
|
+
undefined,
|
|
1641
|
+
makeMinimalCtx(tempDir),
|
|
1642
|
+
);
|
|
1643
|
+
|
|
1644
|
+
const child = result.details?.results?.[0];
|
|
1645
|
+
assert.equal(result.isError, true);
|
|
1646
|
+
assert.equal(child?.structuredOutputFailed, true);
|
|
1647
|
+
assert.match(child?.error ?? "", /Structured output validation failed: value.secret/);
|
|
1648
|
+
assert.doesNotMatch(child?.error ?? "", new RegExp(secret));
|
|
1649
|
+
});
|
|
1650
|
+
|
|
1626
1651
|
it("routes retained workflow follow-ups to distinct outputs without overwriting the writer report", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
|
|
1627
1652
|
for (const relative of [false, true]) {
|
|
1628
1653
|
const writerPath = path.join(tempDir, `writer-${relative}.md`);
|
|
@@ -638,16 +638,16 @@ Do work
|
|
|
638
638
|
assert.equal(worker?.defaultContext, "fork");
|
|
639
639
|
});
|
|
640
640
|
|
|
641
|
-
it("loads packaged worker
|
|
641
|
+
it("loads packaged worker with fresh writer defaults and keeps oracle fork context", () => {
|
|
642
642
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-subagents-builtin-default-context-"));
|
|
643
643
|
tempDirs.push(dir);
|
|
644
644
|
const agents = discoverAgentsAll(dir).builtin;
|
|
645
645
|
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
}
|
|
646
|
+
const worker = agents.find((candidate) => candidate.name === "worker");
|
|
647
|
+
assert.equal(worker?.defaultContext, "fresh");
|
|
648
|
+
assert.equal(worker?.acceptanceRole, "writer");
|
|
650
649
|
const oracle = agents.find((candidate) => candidate.name === "oracle");
|
|
650
|
+
assert.equal(oracle?.defaultContext, "fork");
|
|
651
651
|
assert.deepEqual(oracle?.aliases, ["advisor"]);
|
|
652
652
|
assert.doesNotMatch(oracle?.tools?.join(",") ?? "", /contact_supervisor/);
|
|
653
653
|
for (const name of ["scout", "researcher", "oracle", "reviewer"]) {
|
|
@@ -37,7 +37,19 @@ test("executeAsyncSingle preloads supplemental server aliases before jiti, but n
|
|
|
37
37
|
fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ name: pkg, version: "0.85.0", exports }));
|
|
38
38
|
}
|
|
39
39
|
const originalArgv1 = process.argv[1];
|
|
40
|
+
const inheritedGitEnv = {
|
|
41
|
+
GIT_DIR: "/outer/repo/.git",
|
|
42
|
+
GIT_WORK_TREE: "/outer/repo",
|
|
43
|
+
GIT_INDEX_FILE: "/outer/repo/.git/index",
|
|
44
|
+
GIT_CONFIG_COUNT: "1",
|
|
45
|
+
GIT_CONFIG_KEY_0: "core.hooksPath",
|
|
46
|
+
GIT_CONFIG_VALUE_0: "/outer/hooks",
|
|
47
|
+
GIT_AUTHOR_NAME: "Kept Author",
|
|
48
|
+
GIT_ENV_SENTINEL_KEEP: "kept",
|
|
49
|
+
};
|
|
50
|
+
const previousGitEnv = Object.fromEntries(Object.keys(inheritedGitEnv).map((key) => [key, process.env[key]]));
|
|
40
51
|
try {
|
|
52
|
+
Object.assign(process.env, inheritedGitEnv);
|
|
41
53
|
for (const pkg of Object.keys(hostExports)) {
|
|
42
54
|
if (pkg !== server) writeHostPackage(pkg);
|
|
43
55
|
}
|
|
@@ -79,6 +91,11 @@ test("executeAsyncSingle preloads supplemental server aliases before jiti, but n
|
|
|
79
91
|
assert.equal(spawn.mock.callCount(), scenario === "supplemental" ? 1 : scenario === "complete" ? 2 : 3);
|
|
80
92
|
const [command, args, options] = spawn.mock.calls.at(-1)!.arguments;
|
|
81
93
|
assert.ok(path.isAbsolute(command));
|
|
94
|
+
for (const key of ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_CONFIG_COUNT", "GIT_CONFIG_KEY_0", "GIT_CONFIG_VALUE_0"]) {
|
|
95
|
+
assert.equal(options.env[key], undefined, key);
|
|
96
|
+
}
|
|
97
|
+
assert.equal(options.env.GIT_AUTHOR_NAME, "Kept Author");
|
|
98
|
+
assert.equal(options.env.GIT_ENV_SENTINEL_KEEP, "kept");
|
|
82
99
|
assert.equal(options.env[SELESAI_CODING_AGENT_PACKAGE_ROOT_ENV], host);
|
|
83
100
|
const actualAliases = JSON.parse(options.env.JITI_ALIAS) as Record<string, string>;
|
|
84
101
|
assert.deepEqual(Object.fromEntries(Object.entries(actualAliases).map(([key, target]) => [key, fs.realpathSync(target)])), expectedAliases);
|
|
@@ -96,6 +113,10 @@ test("executeAsyncSingle preloads supplemental server aliases before jiti, but n
|
|
|
96
113
|
} finally {
|
|
97
114
|
t.mock.restoreAll();
|
|
98
115
|
syncBuiltinESMExports();
|
|
116
|
+
for (const [key, value] of Object.entries(previousGitEnv)) {
|
|
117
|
+
if (value === undefined) delete process.env[key];
|
|
118
|
+
else process.env[key] = value;
|
|
119
|
+
}
|
|
99
120
|
if (originalArgv1 === undefined) delete process.argv[1];
|
|
100
121
|
else process.argv[1] = originalArgv1;
|
|
101
122
|
fs.rmSync(root, { recursive: true, force: true });
|