pi-subagents 0.47.1 → 0.48.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/CHANGELOG.md +23 -0
- package/README.md +2 -0
- package/docs/configuration.md +46 -2
- package/docs/observability.md +1 -1
- package/docs/tool-reference.md +1 -1
- package/package.json +1 -1
- package/src/agents/agents.ts +9 -3
- package/src/extension/config.ts +6 -0
- package/src/extension/doctor.ts +40 -0
- package/src/extension/index.ts +21 -0
- package/src/extension/public-execution.ts +5 -0
- package/src/extension/rpc.ts +3 -9
- package/src/extension/schemas.ts +2 -2
- package/src/intercom/intercom-bridge.ts +4 -1
- package/src/missions/lifecycle.ts +4 -7
- package/src/missions/store.ts +4 -4
- package/src/missions/workflow-state.ts +6 -2
- package/src/runs/background/active-async-capacity.ts +374 -0
- package/src/runs/background/active-run-index.ts +9 -5
- package/src/runs/background/async-execution.ts +117 -29
- package/src/runs/background/async-resume.ts +7 -1
- package/src/runs/background/async-status.ts +11 -5
- package/src/runs/background/chain-append.ts +33 -15
- package/src/runs/background/owned-process-tree.ts +104 -0
- package/src/runs/background/process-terminal.ts +17 -3
- package/src/runs/background/run-status.ts +5 -1
- package/src/runs/background/stale-run-reconciler.ts +3 -3
- package/src/runs/background/subagent-runner.ts +56 -31
- package/src/runs/foreground/chain-execution.ts +37 -2
- package/src/runs/foreground/execution.ts +90 -19
- package/src/runs/foreground/foreground-control.ts +12 -0
- package/src/runs/foreground/prompt-audit.ts +171 -0
- package/src/runs/foreground/subagent-executor.ts +614 -187
- package/src/runs/shared/acceptance.ts +13 -4
- package/src/runs/shared/llm-intent-arbiter.ts +286 -0
- package/src/runs/shared/parallel-utils.ts +2 -0
- package/src/runs/shared/pi-args.ts +44 -1
- package/src/runs/shared/run-fanout-budget.ts +280 -0
- package/src/runs/shared/single-output.ts +4 -2
- package/src/runs/shared/task-intent.ts +19 -3
- package/src/runs/shared/worktree.ts +17 -5
- package/src/shared/types.ts +92 -0
- package/src/shared/utils.ts +3 -1
- package/src/tui/fleet-status.ts +7 -5
- package/src/tui/fleet.ts +225 -12
- package/src/workflows/scripted-workflow.ts +22 -5
|
@@ -23,7 +23,7 @@ import type {
|
|
|
23
23
|
SubagentRunMode,
|
|
24
24
|
} from "../../shared/types.ts";
|
|
25
25
|
import { isAgentContractV1 } from "./agent-contract.ts";
|
|
26
|
-
import { classifyTaskMutationIntent, taskMayMutate } from "./task-intent.ts";
|
|
26
|
+
import { classifyTaskMutationIntent, stripSeverityCompounds, taskMayMutate } from "./task-intent.ts";
|
|
27
27
|
|
|
28
28
|
const LEVEL_RANK: Record<Exclude<AcceptanceLevel, "auto">, number> = {
|
|
29
29
|
none: 0,
|
|
@@ -93,7 +93,7 @@ function inferLevel(input: {
|
|
|
93
93
|
const rolePatchTask = input.acceptanceRole !== undefined
|
|
94
94
|
&& intent.kind !== "read-only"
|
|
95
95
|
&& !/\b(?:do not|don't|must not)\s+patch\b/.test(task)
|
|
96
|
-
&& /\bpatch\s+(?:(?:\.{0,2}[\\/])?(?:[\w.-]+[\\/])+[\w.-]+|[\w.-]+\.[a-z0-9]+\b|(?:the\s+)?parser\b)/.test(task);
|
|
96
|
+
&& /\bpatch\s+(?:(?:\.{0,2}[\\/])?(?:[\w.-]+[\\/])+[\w.-]+|[\w.-]+\.[a-z0-9]+\b|(?:the\s+)?parser\b)/.test(stripSeverityCompounds(task));
|
|
97
97
|
const taskMayWrite = readOnlyTask ? false : taskMayMutate(input.task ?? "") || intent.kind === "implementation" || rolePatchTask;
|
|
98
98
|
const readOnlyAgent = input.acceptanceRole === "read-only"
|
|
99
99
|
|| (input.acceptanceRole === undefined && /\b(?:reviewer|oracle|scout|researcher|analyst)\b/.test(agent));
|
|
@@ -433,6 +433,7 @@ export function formatAcceptancePrompt(acceptance: ResolvedAcceptanceConfig, opt
|
|
|
433
433
|
"",
|
|
434
434
|
"Finish with a fenced JSON block tagged `acceptance-report` in this shape:",
|
|
435
435
|
"Use empty arrays when no items apply; array fields contain strings unless object entries are shown.",
|
|
436
|
+
"Empty-string entries (`[\"\"]`) are ignored; use `[]` when nothing applies.",
|
|
436
437
|
"`criteriaSatisfied[].status` must be exactly one of: satisfied, not-satisfied, not-applicable.",
|
|
437
438
|
"`commandsRun[].result` must be exactly one of: passed, failed, not-run.",
|
|
438
439
|
"`manualNotes` and `notes` are optional strings; an empty string means no note and does not satisfy `manual-notes` evidence.",
|
|
@@ -612,9 +613,17 @@ function normalizeAcceptanceReportValue(value: unknown, pathLabel = ""): { value
|
|
|
612
613
|
case "testsAddedOrUpdated":
|
|
613
614
|
case "validationOutput":
|
|
614
615
|
case "residualRisks":
|
|
615
|
-
case "reviewFindings":
|
|
616
|
-
|
|
616
|
+
case "reviewFindings": {
|
|
617
|
+
// Tolerate empty-string entries ("[\"\"]"): models write them for "no items"
|
|
618
|
+
// and a single empty entry must not reject the whole report. Drop them at
|
|
619
|
+
// parse time; non-string entries are kept so validateStringArrayField still
|
|
620
|
+
// flags structural garbage.
|
|
621
|
+
const items = typeof fieldValue === "string" ? [fieldValue] : fieldValue;
|
|
622
|
+
normalized[canonical] = Array.isArray(items)
|
|
623
|
+
? items.filter((item) => typeof item !== "string" || item.trim().length > 0)
|
|
624
|
+
: items;
|
|
617
625
|
break;
|
|
626
|
+
}
|
|
618
627
|
case "noStagedFiles": {
|
|
619
628
|
const token = typeof fieldValue === "string" ? fieldValue.trim().toLowerCase() : undefined;
|
|
620
629
|
normalized[canonical] = token === "true" ? true : token === "false" ? false : fieldValue;
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { Agent, type AgentTool, type StreamFn } from "@earendil-works/pi-agent-core";
|
|
3
|
+
import { convertToLlm, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { streamSimple } from "@earendil-works/pi-ai/compat";
|
|
5
|
+
import { Type, type Static } from "typebox";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* LLM intent arbiter for the completion mutation guard.
|
|
9
|
+
*
|
|
10
|
+
* The regex classifier (task-intent.ts) is deliberately narrow, so exotic
|
|
11
|
+
* review wording can still look like an implementation task ("to fix this,
|
|
12
|
+
* compare the outputs", verbs inside URLs or quoted text). When the guard is
|
|
13
|
+
* about to hard-fail a run that made no edits, this arbiter asks a model
|
|
14
|
+
* whether the task actually instructed file changes. It can only downgrade a
|
|
15
|
+
* failure to a pass; every error, timeout, or non-read-only verdict keeps the
|
|
16
|
+
* guard's original behavior.
|
|
17
|
+
*
|
|
18
|
+
* Enabled by default; set PI_SUBAGENTS_LLM_INTENT_ARBITER=0 to disable.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const COMPLETION_GUARD_ERROR_PREFIX =
|
|
22
|
+
"Subagent completed without making edits for an implementation task.";
|
|
23
|
+
|
|
24
|
+
export type TaskMutationVerdict = "read-only" | "implementation" | "unavailable";
|
|
25
|
+
|
|
26
|
+
export type TaskMutationArbiter = (task: string) => Promise<TaskMutationVerdict>;
|
|
27
|
+
|
|
28
|
+
const DecisionParams = Type.Object(
|
|
29
|
+
{
|
|
30
|
+
classification: Type.String({ enum: ["read_only", "implementation"] }),
|
|
31
|
+
reason: Type.String({ description: "One concise reason for this classification." }),
|
|
32
|
+
},
|
|
33
|
+
{ additionalProperties: false },
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
type DecisionParams = Static<typeof DecisionParams>;
|
|
37
|
+
|
|
38
|
+
interface ArbiterRuntime {
|
|
39
|
+
model: NonNullable<RegistryModel>;
|
|
40
|
+
baseStreamFn: StreamFn;
|
|
41
|
+
timeoutMs: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface ArbiterAuth {
|
|
45
|
+
apiKey?: string;
|
|
46
|
+
headers?: Record<string, string>;
|
|
47
|
+
env?: Record<string, string>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface TaskMutationArbiterOptions {
|
|
51
|
+
/** Explicit "provider/id" model override (tests, config). */
|
|
52
|
+
model?: string;
|
|
53
|
+
/** Injectable stream function (tests). */
|
|
54
|
+
streamFn?: StreamFn;
|
|
55
|
+
timeoutMs?: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const DEFAULT_ARBITER_TIMEOUT_MS = 10_000;
|
|
59
|
+
|
|
60
|
+
type RegistryModel = ReturnType<NonNullable<ExtensionContext["modelRegistry"]["find"]>>;
|
|
61
|
+
|
|
62
|
+
function resolveArbiterModel(
|
|
63
|
+
ctx: ExtensionContext,
|
|
64
|
+
options?: TaskMutationArbiterOptions,
|
|
65
|
+
): NonNullable<RegistryModel> | null {
|
|
66
|
+
const registry = ctx.modelRegistry as {
|
|
67
|
+
find?: (provider: string, modelId: string) => RegistryModel | undefined;
|
|
68
|
+
getAvailable?: () => Array<{ provider?: string; id?: string }>;
|
|
69
|
+
};
|
|
70
|
+
const explicit = options?.model?.trim();
|
|
71
|
+
if (explicit) {
|
|
72
|
+
const [provider, id] = explicit.split("/");
|
|
73
|
+
if (provider && id) return registry.find?.(provider, id) ?? null;
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
if (ctx.model) return ctx.model as NonNullable<RegistryModel>;
|
|
77
|
+
const first = registry.getAvailable?.()[0];
|
|
78
|
+
if (first?.provider && first.id) return registry.find?.(first.provider, first.id) ?? null;
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function resolveArbiterRuntime(
|
|
83
|
+
ctx: ExtensionContext,
|
|
84
|
+
options?: TaskMutationArbiterOptions,
|
|
85
|
+
): ArbiterRuntime | null {
|
|
86
|
+
const model = resolveArbiterModel(ctx, options);
|
|
87
|
+
if (!model?.provider || !model.id) return null;
|
|
88
|
+
const registry = ctx.modelRegistry as {
|
|
89
|
+
getRegisteredProviderConfig?: (provider: string) => { api?: string; streamSimple?: StreamFn } | undefined;
|
|
90
|
+
};
|
|
91
|
+
const modelApi = (model as { api?: string }).api;
|
|
92
|
+
const registered = registry.getRegisteredProviderConfig?.(model.provider);
|
|
93
|
+
const baseStreamFn = options?.streamFn
|
|
94
|
+
?? (registered?.streamSimple && registered.api === modelApi
|
|
95
|
+
? registered.streamSimple
|
|
96
|
+
: streamSimple);
|
|
97
|
+
return {
|
|
98
|
+
model,
|
|
99
|
+
baseStreamFn,
|
|
100
|
+
timeoutMs: options?.timeoutMs ?? DEFAULT_ARBITER_TIMEOUT_MS,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function resolveArbiterAuth(
|
|
105
|
+
ctx: ExtensionContext,
|
|
106
|
+
model: RegistryModel,
|
|
107
|
+
): Promise<ArbiterAuth> {
|
|
108
|
+
const getAuth = (ctx.modelRegistry as {
|
|
109
|
+
getApiKeyAndHeaders?: (m: RegistryModel) => Promise<{
|
|
110
|
+
ok: boolean;
|
|
111
|
+
apiKey?: string;
|
|
112
|
+
headers?: Record<string, string>;
|
|
113
|
+
env?: Record<string, string>;
|
|
114
|
+
error?: string;
|
|
115
|
+
}>;
|
|
116
|
+
}).getApiKeyAndHeaders;
|
|
117
|
+
if (!getAuth) return {};
|
|
118
|
+
try {
|
|
119
|
+
const auth = await getAuth(model);
|
|
120
|
+
if (auth.ok === false) return {};
|
|
121
|
+
return {
|
|
122
|
+
...(auth.apiKey ? { apiKey: auth.apiKey } : {}),
|
|
123
|
+
...(auth.headers ? { headers: auth.headers } : {}),
|
|
124
|
+
...(auth.env ? { env: auth.env } : {}),
|
|
125
|
+
};
|
|
126
|
+
} catch {
|
|
127
|
+
return {};
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Build the effective stream function with resolved credentials wrapped in (watchdog pattern). */
|
|
132
|
+
function authWrappedStreamFn(
|
|
133
|
+
base: StreamFn,
|
|
134
|
+
auth: ArbiterAuth,
|
|
135
|
+
): StreamFn {
|
|
136
|
+
return (model, context, streamOptions) => base(model, context, {
|
|
137
|
+
...(streamOptions ?? {}),
|
|
138
|
+
...(auth.apiKey ? { apiKey: auth.apiKey } : {}),
|
|
139
|
+
...(auth.env || streamOptions?.env ? { env: { ...(auth.env ?? {}), ...(streamOptions?.env ?? {}) } } : {}),
|
|
140
|
+
headers: { ...(streamOptions?.headers ?? {}), ...(auth.headers ?? {}) },
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function runArbitration(
|
|
145
|
+
runtime: ArbiterRuntime,
|
|
146
|
+
auth: ArbiterAuth,
|
|
147
|
+
task: string,
|
|
148
|
+
): Promise<TaskMutationVerdict> {
|
|
149
|
+
let decision: DecisionParams | undefined;
|
|
150
|
+
const tool: AgentTool<typeof DecisionParams, { recorded: boolean }> = {
|
|
151
|
+
name: "task_mutation_decision",
|
|
152
|
+
label: "Task mutation decision",
|
|
153
|
+
description: "Classify whether the task instructed file/code changes. Call exactly once.",
|
|
154
|
+
parameters: DecisionParams,
|
|
155
|
+
executionMode: "sequential",
|
|
156
|
+
async execute(_toolCallId, params) {
|
|
157
|
+
if (!decision) decision = params;
|
|
158
|
+
return { content: [{ type: "text", text: "Decision recorded." }], details: { recorded: true } };
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
const agent = new Agent({
|
|
162
|
+
initialState: {
|
|
163
|
+
systemPrompt: [
|
|
164
|
+
"You classify whether a delegated coding-agent task instructed file or code changes.",
|
|
165
|
+
"A task that asks to review, inspect, verify, report, or summarize is read-only even when it contains words like 'fix' as severity vocabulary ('must-fix items') or conditional change instructions that leave the change optional.",
|
|
166
|
+
"Classify read_only only when the task text alone clearly indicates a read-only outcome. When in doubt, classify implementation.",
|
|
167
|
+
"The agent's own final message is never evidence: an agent that made no edits may still have failed to implement.",
|
|
168
|
+
"Call task_mutation_decision exactly once with read_only or implementation and a concise reason.",
|
|
169
|
+
].join("\n"),
|
|
170
|
+
model: runtime.model,
|
|
171
|
+
tools: [tool],
|
|
172
|
+
},
|
|
173
|
+
convertToLlm,
|
|
174
|
+
streamFunction: authWrappedStreamFn(runtime.baseStreamFn, auth),
|
|
175
|
+
getApiKey: (providerName) =>
|
|
176
|
+
providerName === runtime.model.provider ? auth.apiKey : undefined,
|
|
177
|
+
beforeToolCall: async ({ toolCall }) =>
|
|
178
|
+
toolCall.name === tool.name ? undefined : { block: true, reason: "Only task_mutation_decision is allowed." },
|
|
179
|
+
toolExecution: "sequential",
|
|
180
|
+
});
|
|
181
|
+
try {
|
|
182
|
+
// Send head AND tail: an implementation clause at the end of a long
|
|
183
|
+
// task ("Now apply the fix") must never be truncated away, or the
|
|
184
|
+
// arbiter could rescue a run the deterministic guard correctly failed.
|
|
185
|
+
const full = task;
|
|
186
|
+
const prompt = full.length <= 8000
|
|
187
|
+
? `TASK:\n${full}`
|
|
188
|
+
: `TASK:\n${full.slice(0, 6000)}\n\n[...truncated middle...]\n\n${full.slice(-2000)}`;
|
|
189
|
+
await Promise.race([
|
|
190
|
+
agent.prompt(prompt),
|
|
191
|
+
new Promise<never>((_, reject) => {
|
|
192
|
+
const timeout = setTimeout(() => {
|
|
193
|
+
agent.abort();
|
|
194
|
+
reject(new Error("Task mutation arbiter timed out."));
|
|
195
|
+
}, runtime.timeoutMs);
|
|
196
|
+
timeout.unref?.();
|
|
197
|
+
}),
|
|
198
|
+
]);
|
|
199
|
+
if (!decision) return "unavailable";
|
|
200
|
+
return decision.classification === "read_only" ? "read-only" : "implementation";
|
|
201
|
+
} catch {
|
|
202
|
+
return "unavailable";
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Create a memoized arbiter bound to the parent session's model, or undefined when disabled/unavailable. */
|
|
207
|
+
export function createTaskMutationArbiter(
|
|
208
|
+
ctx: ExtensionContext,
|
|
209
|
+
options?: TaskMutationArbiterOptions,
|
|
210
|
+
): TaskMutationArbiter | undefined {
|
|
211
|
+
if (process.env.PI_SUBAGENTS_LLM_INTENT_ARBITER === "0") return undefined;
|
|
212
|
+
const runtime = resolveArbiterRuntime(ctx, options);
|
|
213
|
+
if (!runtime) return undefined;
|
|
214
|
+
const cache = new Map<string, TaskMutationVerdict>();
|
|
215
|
+
return async (task) => {
|
|
216
|
+
const key = createHash("sha256").update(task).digest("base64url");
|
|
217
|
+
const cached = cache.get(key);
|
|
218
|
+
if (cached) return cached;
|
|
219
|
+
const auth = await resolveArbiterAuth(ctx, runtime.model);
|
|
220
|
+
const verdict = await runArbitration(runtime, auth, task);
|
|
221
|
+
if (cache.size > 200) cache.clear();
|
|
222
|
+
cache.set(key, verdict);
|
|
223
|
+
return verdict;
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function isCompletionGuardFailure(result: { error?: string }): boolean {
|
|
228
|
+
return result.error?.startsWith(COMPLETION_GUARD_ERROR_PREFIX) === true;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Decision helper for the runSync completion-guard arbitration: given the raw
|
|
233
|
+
* guard verdict, decide whether the arbiter rescues the run. Pure and
|
|
234
|
+
* unit-testable; runSync calls this BEFORE any failure side effect is
|
|
235
|
+
* published. Only a confident read-only verdict rescues; every error,
|
|
236
|
+
* timeout, or other verdict keeps the guard's original behavior.
|
|
237
|
+
*/
|
|
238
|
+
export async function arbitrateCompletionGuardRescue(input: {
|
|
239
|
+
guardTriggered: boolean;
|
|
240
|
+
task: string;
|
|
241
|
+
arbiter?: TaskMutationArbiter;
|
|
242
|
+
}): Promise<{ triggered: boolean; rescued: boolean }> {
|
|
243
|
+
if (!input.guardTriggered || !input.arbiter) {
|
|
244
|
+
return { triggered: input.guardTriggered, rescued: false };
|
|
245
|
+
}
|
|
246
|
+
try {
|
|
247
|
+
const verdict = await input.arbiter(input.task);
|
|
248
|
+
if (verdict === "read-only") return { triggered: false, rescued: true };
|
|
249
|
+
return { triggered: true, rescued: false };
|
|
250
|
+
} catch {
|
|
251
|
+
return { triggered: true, rescued: false };
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* When the completion guard hard-failed a run and the arbiter classifies the
|
|
257
|
+
* task as read-only, clear the failure. Returns true when rescued. All other
|
|
258
|
+
* verdicts and every failure mode keep the original guard behavior.
|
|
259
|
+
*
|
|
260
|
+
* Classification uses the task text only: the agent's own final message is
|
|
261
|
+
* never evidence, so a child that failed to implement cannot talk its way
|
|
262
|
+
* out of the guard by claiming the work was read-only.
|
|
263
|
+
*/
|
|
264
|
+
export async function maybeRescueCompletionGuardFailure(
|
|
265
|
+
result: { exitCode?: number; error?: string },
|
|
266
|
+
task: string,
|
|
267
|
+
arbiter: TaskMutationArbiter | undefined,
|
|
268
|
+
): Promise<boolean> {
|
|
269
|
+
if (!arbiter || !isCompletionGuardFailure(result)) return false;
|
|
270
|
+
const verdict = await arbitrateWithGuard(arbiter, task);
|
|
271
|
+
if (verdict !== "read-only") return false;
|
|
272
|
+
result.exitCode = 0;
|
|
273
|
+
delete result.error;
|
|
274
|
+
return true;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async function arbitrateWithGuard(
|
|
278
|
+
arbiter: TaskMutationArbiter,
|
|
279
|
+
task: string,
|
|
280
|
+
): Promise<TaskMutationVerdict> {
|
|
281
|
+
try {
|
|
282
|
+
return await arbiter(task);
|
|
283
|
+
} catch {
|
|
284
|
+
return "unavailable";
|
|
285
|
+
}
|
|
286
|
+
}
|
|
@@ -61,6 +61,8 @@ export interface RunnerSubagentStep {
|
|
|
61
61
|
toolBudget?: import("../../shared/types.ts").ResolvedToolBudget;
|
|
62
62
|
capabilityCeiling?: import("./capability-ceiling.ts").ResolvedSubagentCapabilityCeiling;
|
|
63
63
|
capabilityAudit?: import("./capability-ceiling.ts").SubagentCapabilityAudit;
|
|
64
|
+
/** Private stable logical-child path for inherited run fan-out accounting. */
|
|
65
|
+
runFanoutPath?: string;
|
|
64
66
|
}
|
|
65
67
|
|
|
66
68
|
export interface RunnerCheckpointStep {
|
|
@@ -23,8 +23,10 @@ import {
|
|
|
23
23
|
type JsonSchemaObject,
|
|
24
24
|
type LaunchResolvedChildExtensionsV1,
|
|
25
25
|
type ResolvedToolBudget,
|
|
26
|
+
type RunFanoutBudgetDescriptor,
|
|
26
27
|
} from "../../shared/types.ts";
|
|
27
28
|
import { THINKING_LEVELS } from "../../shared/model-info.ts";
|
|
29
|
+
import { encodeRunFanoutBudgetDescriptor, RUN_FANOUT_BUDGET_ENV } from "./run-fanout-budget.ts";
|
|
28
30
|
import {
|
|
29
31
|
TOOL_BUDGET_ENV,
|
|
30
32
|
TOOL_BUDGET_ZERO_AUTH_ENV,
|
|
@@ -63,6 +65,32 @@ import {
|
|
|
63
65
|
} from "./capability-ceiling.ts";
|
|
64
66
|
|
|
65
67
|
const TASK_ARG_LIMIT = 8000;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Env override for how the task text reaches the child process. Endpoint
|
|
71
|
+
* protection (EDR) pre-execution command-line scanning may deny exec of
|
|
72
|
+
* children whose argv embeds a long natural-language task, which surfaces
|
|
73
|
+
* as an immediate zero-activity SIGKILL. File delivery keeps the task out
|
|
74
|
+
* of argv entirely.
|
|
75
|
+
*/
|
|
76
|
+
export const SUBAGENT_TASK_DELIVERY_ENV = "PI_SUBAGENT_TASK_DELIVERY";
|
|
77
|
+
|
|
78
|
+
export type SubagentTaskDelivery = "auto" | "file";
|
|
79
|
+
|
|
80
|
+
export function resolveSubagentTaskDelivery(
|
|
81
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
82
|
+
): SubagentTaskDelivery {
|
|
83
|
+
return env[SUBAGENT_TASK_DELIVERY_ENV]?.trim().toLowerCase() === "file"
|
|
84
|
+
? "file"
|
|
85
|
+
: "auto";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function shouldDeliverTaskViaFile(
|
|
89
|
+
task: string,
|
|
90
|
+
delivery: SubagentTaskDelivery,
|
|
91
|
+
): boolean {
|
|
92
|
+
return delivery === "file" || task.length > TASK_ARG_LIMIT;
|
|
93
|
+
}
|
|
66
94
|
const MAX_LAUNCH_RESOLVED_EXTENSION_IDS = 32;
|
|
67
95
|
const PROMPT_RUNTIME_EXTENSION_PATH = path.join(
|
|
68
96
|
path.dirname(fileURLToPath(import.meta.url)),
|
|
@@ -136,6 +164,7 @@ export interface BuildPiArgsInput {
|
|
|
136
164
|
parentDepth?: number;
|
|
137
165
|
parentPath?: NestedPathEntry[];
|
|
138
166
|
parentCapabilityToken?: string;
|
|
167
|
+
runFanoutBudget?: RunFanoutBudgetDescriptor;
|
|
139
168
|
steerInboxDir?: string;
|
|
140
169
|
steerCapabilityPath?: string;
|
|
141
170
|
steerAckDir?: string;
|
|
@@ -149,6 +178,12 @@ export interface BuildPiArgsInput {
|
|
|
149
178
|
permissionRules?: PermissionRules;
|
|
150
179
|
permissionAuditPath?: string;
|
|
151
180
|
childWatchdog?: ChildWatchdogConfig;
|
|
181
|
+
/**
|
|
182
|
+
* Per-launch override of the task delivery mode. Startup-retry paths set
|
|
183
|
+
* this to "file" after an unexplained zero-activity SIGKILL so the retry
|
|
184
|
+
* keeps the task text out of the child's argv.
|
|
185
|
+
*/
|
|
186
|
+
taskDelivery?: SubagentTaskDelivery;
|
|
152
187
|
waitToolEnabled?: boolean;
|
|
153
188
|
capabilityCeiling?: ResolvedSubagentCapabilityCeiling;
|
|
154
189
|
}
|
|
@@ -585,7 +620,12 @@ export function buildPiArgs(input: BuildPiArgsInput): BuildPiArgsResult {
|
|
|
585
620
|
);
|
|
586
621
|
}
|
|
587
622
|
|
|
588
|
-
if (
|
|
623
|
+
if (
|
|
624
|
+
shouldDeliverTaskViaFile(
|
|
625
|
+
input.task,
|
|
626
|
+
input.taskDelivery ?? resolveSubagentTaskDelivery(),
|
|
627
|
+
)
|
|
628
|
+
) {
|
|
589
629
|
if (!tempDir) {
|
|
590
630
|
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-subagent-"));
|
|
591
631
|
}
|
|
@@ -694,6 +734,9 @@ export function buildPiArgs(input: BuildPiArgsInput): BuildPiArgsResult {
|
|
|
694
734
|
process.env[SUBAGENT_PARENT_CAPABILITY_TOKEN_ENV] ??
|
|
695
735
|
"")
|
|
696
736
|
: "";
|
|
737
|
+
env[RUN_FANOUT_BUDGET_ENV] = toolPlan.fanoutAuthorized
|
|
738
|
+
? (input.runFanoutBudget ? encodeRunFanoutBudgetDescriptor(input.runFanoutBudget) : process.env[RUN_FANOUT_BUDGET_ENV])
|
|
739
|
+
: undefined;
|
|
697
740
|
env.PI_SUBAGENT_INHERIT_PROJECT_CONTEXT = input.inheritProjectContext
|
|
698
741
|
? "1"
|
|
699
742
|
: "0";
|