pi-subagents 0.53.0 → 0.54.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 +31 -4
- package/README.md +1 -1
- package/docs/configuration.md +3 -3
- package/docs/models.md +17 -3
- package/index.ts +10 -1
- package/package.json +1 -1
- package/prompts/council.md +10 -7
- package/skills/council-mode/SKILL.md +17 -16
- package/skills/pi-subagents/SKILL.md +5 -1
- package/skills/pi-subagents/references/execution-controls.md +2 -0
- package/skills/pi-subagents/references/prompting-and-roles.md +7 -0
- package/src/agents/agent-management.ts +85 -5
- package/src/agents/agents.ts +67 -24
- package/src/api/preflight.ts +7 -2
- package/src/extension/doctor.ts +10 -0
- package/src/extension/fanout-child.ts +3 -2
- package/src/extension/index.ts +4 -3
- package/src/extension/public-execution.ts +1 -1
- package/src/extension/tool-result.ts +19 -0
- package/src/inspectors/herdr/client.ts +3 -3
- package/src/runs/background/async-execution.ts +10 -5
- package/src/runs/background/async-retention.ts +1 -1
- package/src/runs/background/chain-root-attachment.ts +5 -0
- package/src/runs/background/result-watcher.ts +4 -3
- package/src/runs/background/stale-run-reconciler.ts +3 -0
- package/src/runs/background/subagent-runner.ts +26 -2
- package/src/runs/background/wait-completions.ts +2 -0
- package/src/runs/background/wait-tool.ts +4 -3
- package/src/runs/foreground/execution.ts +29 -1
- package/src/runs/foreground/subagent-executor.ts +14 -6
- package/src/runs/shared/model-fallback.ts +36 -15
- package/src/runs/shared/model-scope.ts +106 -39
- package/src/runs/shared/pi-args.ts +34 -1
- package/src/runs/shared/subagent-prompt-runtime.ts +36 -9
- package/src/shared/types.ts +13 -3
- package/src/watchdog/permission-arbiter.ts +59 -51
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
import { splitKnownThinkingSuffix } from "../../shared/model-info.ts";
|
|
17
17
|
|
|
18
|
-
export interface
|
|
18
|
+
export interface ModelScopeRule {
|
|
19
19
|
enforce?: boolean;
|
|
20
20
|
/** Reject inherited and fallback models outside the allowlist instead of warning. */
|
|
21
21
|
strict?: boolean;
|
|
@@ -23,6 +23,19 @@ export interface ModelScopeConfig {
|
|
|
23
23
|
allow?: string[];
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
export interface ModelScopeConfig extends ModelScopeRule {
|
|
27
|
+
/** Additional restrictions keyed by canonical agent name. */
|
|
28
|
+
agents?: Record<string, ModelScopeRule>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ModelScopeCheckRule extends ModelScopeRule {
|
|
32
|
+
origin?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ResolvedModelScope extends ModelScopeCheckRule {
|
|
36
|
+
origin: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
26
39
|
/** Where a resolved model originated, deciding enforcement severity. */
|
|
27
40
|
export type ModelSource = "explicit" | "inherited";
|
|
28
41
|
|
|
@@ -32,6 +45,7 @@ export interface ModelScopeViolation {
|
|
|
32
45
|
severity: "warn" | "error";
|
|
33
46
|
message: string;
|
|
34
47
|
allowedPatterns: string[];
|
|
48
|
+
origin: string;
|
|
35
49
|
}
|
|
36
50
|
|
|
37
51
|
function stripThinkingSuffix(model: string): string {
|
|
@@ -61,7 +75,7 @@ export function matchesScopePattern(model: string, pattern: string): boolean {
|
|
|
61
75
|
*/
|
|
62
76
|
export function checkModelScope(
|
|
63
77
|
model: string | undefined,
|
|
64
|
-
scope:
|
|
78
|
+
scope: ModelScopeCheckRule | undefined,
|
|
65
79
|
source: ModelSource,
|
|
66
80
|
): ModelScopeViolation | undefined {
|
|
67
81
|
if (!model || !scope?.enforce) return undefined;
|
|
@@ -71,16 +85,89 @@ export function checkModelScope(
|
|
|
71
85
|
|
|
72
86
|
const baseModel = stripThinkingSuffix(model);
|
|
73
87
|
const severity: ModelScopeViolation["severity"] = source === "explicit" || scope.strict === true ? "error" : "warn";
|
|
88
|
+
const origin = scope.origin ?? "modelScope";
|
|
74
89
|
return {
|
|
75
90
|
model: baseModel,
|
|
76
91
|
severity,
|
|
77
92
|
allowedPatterns: allow,
|
|
93
|
+
origin,
|
|
78
94
|
message:
|
|
79
|
-
`Model '${baseModel}' is outside the configured subagent model scope. ` +
|
|
95
|
+
`Model '${baseModel}' is outside the configured subagent model scope (${origin}). ` +
|
|
80
96
|
`Allowed patterns: ${allow.join(", ")}.`,
|
|
81
97
|
};
|
|
82
98
|
}
|
|
83
99
|
|
|
100
|
+
function expandInheritPattern(pattern: string, parentModel: { provider: string; id: string } | undefined): string {
|
|
101
|
+
return pattern === "inherit" && parentModel ? `${parentModel.provider}/${parentModel.id}` : pattern;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function assertRecord(value: unknown, field: string, meta: { filePath: string }, expected = "an object"): Record<string, unknown> {
|
|
105
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
106
|
+
throw new Error(`Subagent settings in '${meta.filePath}' have invalid '${field}'; expected ${expected}.`);
|
|
107
|
+
}
|
|
108
|
+
return value as Record<string, unknown>;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function parseAllowList(value: unknown, field: string, meta: { filePath: string }): string[] {
|
|
112
|
+
if (!Array.isArray(value)) {
|
|
113
|
+
throw new Error(`Subagent settings in '${meta.filePath}' have invalid '${field}'; expected an array of strings.`);
|
|
114
|
+
}
|
|
115
|
+
const allow: string[] = [];
|
|
116
|
+
for (const entry of value) {
|
|
117
|
+
if (typeof entry !== "string") {
|
|
118
|
+
throw new Error(`Subagent settings in '${meta.filePath}' have invalid '${field}'; expected an array of strings.`);
|
|
119
|
+
}
|
|
120
|
+
const trimmed = entry.trim();
|
|
121
|
+
if (trimmed) allow.push(trimmed);
|
|
122
|
+
}
|
|
123
|
+
if (allow.length === 0) {
|
|
124
|
+
throw new Error(`Subagent settings in '${meta.filePath}' have invalid '${field}'; expected a non-empty array of patterns.`);
|
|
125
|
+
}
|
|
126
|
+
return allow;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function parseScopeRule(input: Record<string, unknown>, field: string, meta: { filePath: string }): ModelScopeRule {
|
|
130
|
+
const rule: ModelScopeRule = {};
|
|
131
|
+
if ("enforce" in input) {
|
|
132
|
+
if (typeof input.enforce !== "boolean") throw new Error(`Subagent settings in '${meta.filePath}' have invalid '${field}.enforce'; expected a boolean.`);
|
|
133
|
+
rule.enforce = input.enforce;
|
|
134
|
+
}
|
|
135
|
+
if ("strict" in input) {
|
|
136
|
+
if (typeof input.strict !== "boolean") throw new Error(`Subagent settings in '${meta.filePath}' have invalid '${field}.strict'; expected a boolean.`);
|
|
137
|
+
rule.strict = input.strict;
|
|
138
|
+
}
|
|
139
|
+
if ("allow" in input) rule.allow = parseAllowList(input.allow, `${field}.allow`, meta);
|
|
140
|
+
return rule;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Resolve the global and matching agent policies into independent launch-time checks. */
|
|
144
|
+
export function resolveModelScopesForAgent(
|
|
145
|
+
config: ModelScopeConfig | undefined,
|
|
146
|
+
agentName: string,
|
|
147
|
+
parentModel: { provider: string; id: string } | undefined,
|
|
148
|
+
): ResolvedModelScope[] {
|
|
149
|
+
if (!config) return [];
|
|
150
|
+
const scopes: ResolvedModelScope[] = [];
|
|
151
|
+
if (config.allow) {
|
|
152
|
+
scopes.push({
|
|
153
|
+
...(config.enforce !== undefined ? { enforce: config.enforce } : {}),
|
|
154
|
+
...(config.strict !== undefined ? { strict: config.strict } : {}),
|
|
155
|
+
allow: config.allow.map((pattern) => expandInheritPattern(pattern, parentModel)),
|
|
156
|
+
origin: "modelScope",
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
const agentScope = config.agents?.[agentName];
|
|
160
|
+
if (agentScope?.allow) {
|
|
161
|
+
scopes.push({
|
|
162
|
+
enforce: agentScope.enforce ?? config.enforce,
|
|
163
|
+
strict: agentScope.strict ?? config.strict,
|
|
164
|
+
allow: agentScope.allow.map((pattern) => expandInheritPattern(pattern, parentModel)),
|
|
165
|
+
origin: `modelScope.agents.${agentName}`,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
return scopes;
|
|
169
|
+
}
|
|
170
|
+
|
|
84
171
|
/**
|
|
85
172
|
* Validate and normalize a raw `subagents.modelScope` value from settings.
|
|
86
173
|
* Throws a descriptive error for malformed configs (matching the surrounding
|
|
@@ -91,46 +178,26 @@ export function parseModelScopeConfig(
|
|
|
91
178
|
meta: { filePath: string },
|
|
92
179
|
): ModelScopeConfig | undefined {
|
|
93
180
|
if (value === undefined) return undefined;
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
if ("strict" in input) {
|
|
109
|
-
if (typeof input.strict !== "boolean") {
|
|
110
|
-
throw new Error(`Subagent settings in '${meta.filePath}' have invalid 'modelScope.strict'; expected a boolean.`);
|
|
111
|
-
}
|
|
112
|
-
config.strict = input.strict;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
if ("allow" in input) {
|
|
116
|
-
if (!Array.isArray(input.allow)) {
|
|
117
|
-
throw new Error(`Subagent settings in '${meta.filePath}' have invalid 'modelScope.allow'; expected an array of strings.`);
|
|
118
|
-
}
|
|
119
|
-
const allow: string[] = [];
|
|
120
|
-
for (const entry of input.allow) {
|
|
121
|
-
if (typeof entry !== "string") {
|
|
122
|
-
throw new Error(`Subagent settings in '${meta.filePath}' have invalid 'modelScope.allow'; expected an array of strings.`);
|
|
181
|
+
const input = assertRecord(value, "modelScope", meta);
|
|
182
|
+
const config: ModelScopeConfig = parseScopeRule(input, "modelScope", meta);
|
|
183
|
+
|
|
184
|
+
if ("agents" in input) {
|
|
185
|
+
const agents: Record<string, ModelScopeRule> = {};
|
|
186
|
+
for (const [rawName, rawScope] of Object.entries(assertRecord(input.agents, "modelScope.agents", meta, "an object keyed by agent name"))) {
|
|
187
|
+
const name = rawName.trim();
|
|
188
|
+
const field = `modelScope.agents.${name}`;
|
|
189
|
+
if (!name) throw new Error(`Subagent settings in '${meta.filePath}' have invalid 'modelScope.agents' key; expected a non-empty agent name.`);
|
|
190
|
+
const agentInput = assertRecord(rawScope, field, meta);
|
|
191
|
+
if ("agents" in agentInput) {
|
|
192
|
+
throw new Error(`Subagent settings in '${meta.filePath}' have invalid '${field}.agents'; nested agent scopes are not supported.`);
|
|
123
193
|
}
|
|
124
|
-
|
|
125
|
-
if (trimmed) allow.push(trimmed);
|
|
126
|
-
}
|
|
127
|
-
if (allow.length === 0) {
|
|
128
|
-
throw new Error(`Subagent settings in '${meta.filePath}' have invalid 'modelScope.allow'; expected a non-empty array of patterns.`);
|
|
194
|
+
agents[name] = parseScopeRule(agentInput, field, meta);
|
|
129
195
|
}
|
|
130
|
-
config.
|
|
196
|
+
config.agents = agents;
|
|
131
197
|
}
|
|
132
198
|
|
|
133
|
-
|
|
199
|
+
const hasAnyAllow = Boolean(config.allow?.length) || Object.values(config.agents ?? {}).some((scope) => Boolean(scope.allow?.length));
|
|
200
|
+
if (config.enforce === true && !hasAnyAllow) {
|
|
134
201
|
throw new Error(`Subagent settings in '${meta.filePath}' set modelScope.enforce without a non-empty 'allow' list; supply allowed model patterns or disable enforcement.`);
|
|
135
202
|
}
|
|
136
203
|
|
|
@@ -191,6 +191,7 @@ export interface BuildPiArgsInput {
|
|
|
191
191
|
export interface BuildPiArgsResult {
|
|
192
192
|
args: string[];
|
|
193
193
|
env: Record<string, string | undefined>;
|
|
194
|
+
warnings: string[];
|
|
194
195
|
tempDir?: string;
|
|
195
196
|
toolDiagnosticPath?: string;
|
|
196
197
|
runtimeAcknowledgedExtensionsPath?: string;
|
|
@@ -251,6 +252,7 @@ export interface ResolvePiLaunchToolPlanInput {
|
|
|
251
252
|
capabilityCeiling?: ResolvedSubagentCapabilityCeiling;
|
|
252
253
|
inheritedCapabilityCeiling?: ResolvedSubagentCapabilityCeiling;
|
|
253
254
|
agentName?: string;
|
|
255
|
+
permissionRules?: PermissionRules;
|
|
254
256
|
}
|
|
255
257
|
|
|
256
258
|
export interface PiLaunchToolPlan {
|
|
@@ -271,6 +273,13 @@ export interface PiLaunchToolPlan {
|
|
|
271
273
|
extensionArgs: string[];
|
|
272
274
|
disableAmbientExtensions: boolean;
|
|
273
275
|
capabilityAudit?: SubagentCapabilityAudit;
|
|
276
|
+
/**
|
|
277
|
+
* Non-fatal footguns surfaced during plan resolution (currently: an agent
|
|
278
|
+
* `extensions: []` override, which disables ALL ambient extensions for the
|
|
279
|
+
* child rather than "adding nothing" — see the empty-extensions-override
|
|
280
|
+
* warning below). Callers may log these; they never change behavior.
|
|
281
|
+
*/
|
|
282
|
+
warnings: string[];
|
|
274
283
|
}
|
|
275
284
|
|
|
276
285
|
function extensionIdentifier(value: string): string {
|
|
@@ -288,6 +297,10 @@ function boundedExtensionIdentifiers(values: string[]): {
|
|
|
288
297
|
};
|
|
289
298
|
}
|
|
290
299
|
|
|
300
|
+
function hasPermissionRules(rules: PermissionRules | undefined): boolean {
|
|
301
|
+
return rules !== undefined && Object.keys(rules).length > 0;
|
|
302
|
+
}
|
|
303
|
+
|
|
291
304
|
export function projectLaunchResolvedChildExtensions(
|
|
292
305
|
toolPlan: Pick<
|
|
293
306
|
PiLaunchToolPlan,
|
|
@@ -451,7 +464,9 @@ export function resolvePiLaunchToolPlan(
|
|
|
451
464
|
: [];
|
|
452
465
|
const permSystemExt = capabilityCeiling?.denyExtensions
|
|
453
466
|
? undefined
|
|
454
|
-
:
|
|
467
|
+
: hasPermissionRules(input.permissionRules)
|
|
468
|
+
? resolvePermissionSystemExtension()
|
|
469
|
+
: undefined;
|
|
455
470
|
const runtimeExtensions = [
|
|
456
471
|
PROMPT_RUNTIME_EXTENSION_PATH,
|
|
457
472
|
...(fanoutAuthorized ? [FANOUT_CHILD_EXTENSION_PATH] : []),
|
|
@@ -460,6 +475,21 @@ export function resolvePiLaunchToolPlan(
|
|
|
460
475
|
const disableAmbientExtensions =
|
|
461
476
|
capabilityCeiling?.denyExtensions === true ||
|
|
462
477
|
input.extensions !== undefined;
|
|
478
|
+
const warnings: string[] = [];
|
|
479
|
+
// An agent override that sets `extensions: []` reads like "add nothing", but
|
|
480
|
+
// any *defined* extensions list (including an empty one) disables every
|
|
481
|
+
// ambient extension for this child — not just skips adding extras. That
|
|
482
|
+
// silently strips load-bearing ambient extensions (e.g. a model provider
|
|
483
|
+
// extension), so a provider-qualified model pin becomes unresolvable in the
|
|
484
|
+
// child with no error pointing back at the override that caused it.
|
|
485
|
+
if (capabilityCeiling?.denyExtensions !== true && Array.isArray(input.extensions) && input.extensions.length === 0) {
|
|
486
|
+
const agentLabel = input.agentName ? ` for agent '${input.agentName}'` : "";
|
|
487
|
+
warnings.push(
|
|
488
|
+
`extensions: [] override${agentLabel} disables ALL ambient extensions for this child (not just "adds nothing"), `
|
|
489
|
+
+ "including any model-provider extension needed to resolve a provider-qualified model. "
|
|
490
|
+
+ "List the extensions this child actually needs instead of an empty array.",
|
|
491
|
+
);
|
|
492
|
+
}
|
|
463
493
|
const configuredExtensions = capabilityCeiling?.denyExtensions
|
|
464
494
|
? []
|
|
465
495
|
: [
|
|
@@ -540,6 +570,7 @@ export function resolvePiLaunchToolPlan(
|
|
|
540
570
|
configuredExtensions,
|
|
541
571
|
extensionArgs,
|
|
542
572
|
disableAmbientExtensions,
|
|
573
|
+
warnings,
|
|
543
574
|
...(capabilityAudit ? { capabilityAudit } : {}),
|
|
544
575
|
};
|
|
545
576
|
}
|
|
@@ -587,6 +618,7 @@ export function buildPiArgs(input: BuildPiArgsInput): BuildPiArgsResult {
|
|
|
587
618
|
process.env[SUBAGENT_CAPABILITY_CEILING_ENV],
|
|
588
619
|
),
|
|
589
620
|
agentName: input.childAgentName,
|
|
621
|
+
permissionRules: input.permissionRules,
|
|
590
622
|
});
|
|
591
623
|
if (toolPlan.explicitToolAllowlist) {
|
|
592
624
|
args.push(
|
|
@@ -829,6 +861,7 @@ export function buildPiArgs(input: BuildPiArgsInput): BuildPiArgsResult {
|
|
|
829
861
|
return {
|
|
830
862
|
args,
|
|
831
863
|
env,
|
|
864
|
+
warnings: toolPlan.warnings,
|
|
832
865
|
tempDir,
|
|
833
866
|
toolDiagnosticPath,
|
|
834
867
|
runtimeAcknowledgedExtensionsPath,
|
|
@@ -21,7 +21,7 @@ import type { JsonSchemaObject, ResolvedToolBudget, SubagentState } from "../../
|
|
|
21
21
|
import { resolveCurrentSessionId } from "../../shared/session-identity.ts";
|
|
22
22
|
import { resolveWatchPath } from "../../shared/utils.ts";
|
|
23
23
|
import { registerChildWatchdog } from "../../watchdog/register-child.ts";
|
|
24
|
-
import { CHILD_WATCHDOG_CONFIG_ENV } from "../../watchdog/child-status.ts";
|
|
24
|
+
import { CHILD_WATCHDOG_CONFIG_ENV, decodeChildWatchdogConfig } from "../../watchdog/child-status.ts";
|
|
25
25
|
import { requestWatchdogPermission, type WatchdogPermissionRequest, type WatchdogPermissionResult } from "../../watchdog/permission-arbiter.ts";
|
|
26
26
|
import { SUBAGENT_WATCHDOG_WARNING_TYPE } from "../../watchdog/types.ts";
|
|
27
27
|
import { resolveWaitToolConfig } from "../background/wait-config.ts";
|
|
@@ -296,14 +296,41 @@ export function registerPermissionGate(
|
|
|
296
296
|
const decision = permissionDecision(rules, toolName);
|
|
297
297
|
if (decision === "allow") return undefined;
|
|
298
298
|
if (decision === "deny") return { block: true, reason: `Blocked by pi-subagents permission rule: '${toolName}' is denied.` };
|
|
299
|
-
const
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
}
|
|
299
|
+
const rawWatchdogConfig = process.env[CHILD_WATCHDOG_CONFIG_ENV];
|
|
300
|
+
let timeoutMs = 30_000;
|
|
301
|
+
try {
|
|
302
|
+
timeoutMs = decodeChildWatchdogConfig(rawWatchdogConfig)?.agentEndTimeoutMs ?? timeoutMs;
|
|
303
|
+
} catch {
|
|
304
|
+
// The arbiter reports invalid configuration with the concrete decode error.
|
|
305
|
+
}
|
|
306
|
+
if (ctx.signal?.aborted) return { block: true, reason: "Blocked by pi-subagents permission rule: Watchdog permission decision was cancelled." };
|
|
307
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
308
|
+
let abort: (() => void) | undefined;
|
|
309
|
+
let result: WatchdogPermissionResult;
|
|
310
|
+
try {
|
|
311
|
+
result = await Promise.race([
|
|
312
|
+
requestPermission({
|
|
313
|
+
ctx,
|
|
314
|
+
toolName,
|
|
315
|
+
args: event.input ?? {},
|
|
316
|
+
rawWatchdogConfig,
|
|
317
|
+
auditPath: process.env[PERMISSION_AUDIT_PATH_ENV],
|
|
318
|
+
...(ctx.signal ? { signal: ctx.signal } : {}),
|
|
319
|
+
}),
|
|
320
|
+
new Promise<WatchdogPermissionResult>((resolve) => {
|
|
321
|
+
if (!ctx.signal) return;
|
|
322
|
+
abort = () => resolve({ approved: false, reason: "Watchdog permission decision was cancelled.", source: "watchdog" });
|
|
323
|
+
ctx.signal.addEventListener("abort", abort, { once: true });
|
|
324
|
+
}),
|
|
325
|
+
new Promise<never>((_, reject) => { timeout = setTimeout(() => reject(new Error(`Watchdog permission decision timed out after ${timeoutMs}ms.`)), timeoutMs); }),
|
|
326
|
+
]);
|
|
327
|
+
} catch (error) {
|
|
328
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
329
|
+
return { block: true, reason: `Blocked by pi-subagents permission rule: Watchdog permission arbiter failed closed: ${reason}` };
|
|
330
|
+
} finally {
|
|
331
|
+
if (timeout) clearTimeout(timeout);
|
|
332
|
+
if (abort) ctx.signal?.removeEventListener("abort", abort);
|
|
333
|
+
}
|
|
307
334
|
if (result.approved) return undefined;
|
|
308
335
|
return { block: true, reason: `Blocked by pi-subagents permission rule: ${result.reason}` };
|
|
309
336
|
});
|
package/src/shared/types.ts
CHANGED
|
@@ -8,7 +8,7 @@ import type { Message } from "@earendil-works/pi-ai";
|
|
|
8
8
|
import type { AgentConfig } from "../agents/agents.ts";
|
|
9
9
|
import type { FSWatcher } from "node:fs";
|
|
10
10
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
11
|
-
import type {
|
|
11
|
+
import type { ModelScopeRule } from "../runs/shared/model-scope.ts";
|
|
12
12
|
import type { ResolvedSubagentCapabilityCeiling, SubagentCapabilityAudit } from "../runs/shared/capability-ceiling.ts";
|
|
13
13
|
import type { AuthorityPolicyConfig } from "../policy/authority.ts";
|
|
14
14
|
import type { GlobalMissionIndexRecord, MissionRecord, MissionStoreConfig } from "../missions/types.ts";
|
|
@@ -957,6 +957,13 @@ export interface SingleResult {
|
|
|
957
957
|
modelAttempts?: ModelAttempt[];
|
|
958
958
|
controlEvents?: ControlEvent[];
|
|
959
959
|
error?: string;
|
|
960
|
+
/**
|
|
961
|
+
* True when the dispatch failed because the input exceeded the model's
|
|
962
|
+
* context window. The model fallback loop stops immediately (retrying the
|
|
963
|
+
* same input on another model cannot succeed). Callers should treat this as
|
|
964
|
+
* a signal to reduce input size or re-decompose the task.
|
|
965
|
+
*/
|
|
966
|
+
contextOverflow?: boolean;
|
|
960
967
|
protocolError?: ProtocolOutputLimit;
|
|
961
968
|
sessionFile?: string;
|
|
962
969
|
skills?: string[];
|
|
@@ -1022,6 +1029,7 @@ export interface WaitCompletionChild {
|
|
|
1022
1029
|
outputState?: SubagentOutputState;
|
|
1023
1030
|
error?: string;
|
|
1024
1031
|
model?: string;
|
|
1032
|
+
contextOverflow?: boolean;
|
|
1025
1033
|
artifactPaths?: Partial<ArtifactPaths>;
|
|
1026
1034
|
}
|
|
1027
1035
|
|
|
@@ -1496,6 +1504,8 @@ export interface AsyncStatus {
|
|
|
1496
1504
|
thinking?: string;
|
|
1497
1505
|
attemptedModels?: string[];
|
|
1498
1506
|
modelAttempts?: ModelAttempt[];
|
|
1507
|
+
/** True when the child input exceeded the model context window. */
|
|
1508
|
+
contextOverflow?: boolean;
|
|
1499
1509
|
totalCost?: CostSummary;
|
|
1500
1510
|
steering?: SteeringStatus;
|
|
1501
1511
|
error?: string;
|
|
@@ -1890,7 +1900,7 @@ export interface RunSyncOptions {
|
|
|
1890
1900
|
/** Current parent-session provider to prefer for ambiguous bare model ids */
|
|
1891
1901
|
preferredModelProvider?: string;
|
|
1892
1902
|
/** Optional subagent model-scope enforcement for fallback candidates */
|
|
1893
|
-
modelScope?:
|
|
1903
|
+
modelScope?: ModelScopeRule | ModelScopeRule[];
|
|
1894
1904
|
/** Skills to make available (overrides agent default if provided) */
|
|
1895
1905
|
skills?: string[];
|
|
1896
1906
|
structuredOutput?: {
|
|
@@ -2054,7 +2064,7 @@ export interface ExtensionConfig {
|
|
|
2054
2064
|
/** Artifact cleanup retention. Set cleanupDays to 0 to disable cleanup. */
|
|
2055
2065
|
artifactConfig?: Pick<ArtifactConfig, "cleanupDays">;
|
|
2056
2066
|
intercomBridge?: IntercomBridgeConfig;
|
|
2057
|
-
/** Control how slow result-index scans are logged. Defaults to \"
|
|
2067
|
+
/** Control how slow result-index scans are logged. Defaults to \"activity\".
|
|
2058
2068
|
* - \"all\": log every slow scan, including scans that find nothing.
|
|
2059
2069
|
* - \"activity\": log only slow scans that found or scheduled work. Silences
|
|
2060
2070
|
* the periodic healthy rescan that inspects zero files while no async runs
|
|
@@ -45,7 +45,11 @@ export function createWatchdogPermissionArbiter(options: WatchdogPermissionArbit
|
|
|
45
45
|
const createdAt = Date.now();
|
|
46
46
|
const auditBase = { type: "permission.request", createdAt, toolName: request.toolName, preview, matchedRule: "ask", decisionSource: "watchdog" };
|
|
47
47
|
appendPermissionAudit(request.auditPath, auditBase);
|
|
48
|
+
let completed = false;
|
|
48
49
|
const finish = (approved: boolean, reason: string, decision: string): WatchdogPermissionResult => {
|
|
50
|
+
const concise = conciseReason(reason);
|
|
51
|
+
if (completed) return { approved, reason: concise, source: "watchdog" };
|
|
52
|
+
completed = true;
|
|
49
53
|
appendPermissionAudit(request.auditPath, {
|
|
50
54
|
type: "permission.decision",
|
|
51
55
|
createdAt: Date.now(),
|
|
@@ -54,9 +58,9 @@ export function createWatchdogPermissionArbiter(options: WatchdogPermissionArbit
|
|
|
54
58
|
decision,
|
|
55
59
|
approved,
|
|
56
60
|
decisionSource: "watchdog",
|
|
57
|
-
reason:
|
|
61
|
+
reason: concise,
|
|
58
62
|
});
|
|
59
|
-
return { approved, reason:
|
|
63
|
+
return { approved, reason: concise, source: "watchdog" };
|
|
60
64
|
};
|
|
61
65
|
|
|
62
66
|
let childConfig;
|
|
@@ -84,61 +88,65 @@ export function createWatchdogPermissionArbiter(options: WatchdogPermissionArbit
|
|
|
84
88
|
|
|
85
89
|
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
86
90
|
let agent: Agent | undefined;
|
|
91
|
+
let abort: (() => void) | undefined;
|
|
87
92
|
try {
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
return finish(approved, decision.reason, decision.decision);
|
|
93
|
+
const run = async (): Promise<WatchdogPermissionResult> => {
|
|
94
|
+
const config = childResolvedConfig(childConfig);
|
|
95
|
+
const selection = await resolveWatchdogReviewModel(request.ctx, config);
|
|
96
|
+
const auth = selection.auth;
|
|
97
|
+
const registeredProvider = (request.ctx.modelRegistry as {
|
|
98
|
+
getRegisteredProviderConfig?: (provider: string) => { api?: string; streamSimple?: StreamFn } | undefined;
|
|
99
|
+
}).getRegisteredProviderConfig?.(selection.model.provider);
|
|
100
|
+
const baseStreamFn = options.streamFn ?? (registeredProvider?.streamSimple && registeredProvider.api === selection.model.api
|
|
101
|
+
? registeredProvider.streamSimple
|
|
102
|
+
: streamSimple);
|
|
103
|
+
const streamFn: StreamFn = (model, context, streamOptions) => baseStreamFn(model, context, {
|
|
104
|
+
...streamOptions,
|
|
105
|
+
...(auth.apiKey ? { apiKey: auth.apiKey } : {}),
|
|
106
|
+
env: auth.env || streamOptions?.env ? { ...(auth.env ?? {}), ...(streamOptions?.env ?? {}) } : undefined,
|
|
107
|
+
headers: { ...(streamOptions?.headers ?? {}), ...(auth.headers ?? {}) },
|
|
108
|
+
});
|
|
109
|
+
agent = new Agent({
|
|
110
|
+
initialState: {
|
|
111
|
+
systemPrompt: [
|
|
112
|
+
"You are the pi-subagents watchdog permission arbiter.",
|
|
113
|
+
"Decide only whether this exact non-bash child tool call should proceed.",
|
|
114
|
+
"Call watchdog_permission_decision exactly once with approve or deny and a concise reason.",
|
|
115
|
+
"Deny when uncertain. Do not produce freeform advice or ask the parent orchestrator.",
|
|
116
|
+
].join("\n"),
|
|
117
|
+
model: selection.model,
|
|
118
|
+
thinkingLevel: selection.thinkingLevel,
|
|
119
|
+
tools: [tool],
|
|
120
|
+
},
|
|
121
|
+
convertToLlm,
|
|
122
|
+
...agentStreamOptions(streamFn),
|
|
123
|
+
getApiKey: (providerName) => providerName === selection.model.provider ? auth.apiKey : undefined,
|
|
124
|
+
beforeToolCall: async ({ toolCall }) => toolCall.name === tool.name ? undefined : { block: true, reason: `Permission arbiter tool '${toolCall.name}' is not allowed.` },
|
|
125
|
+
toolExecution: "sequential",
|
|
126
|
+
});
|
|
127
|
+
await agent.prompt(`Tool: ${request.toolName}\nRedacted arguments: ${preview}`);
|
|
128
|
+
if (!decision) return finish(false, "Watchdog permission arbiter returned no decision.", "malformed");
|
|
129
|
+
const approved = decision.decision === "approve";
|
|
130
|
+
return finish(approved, decision.reason, decision.decision);
|
|
131
|
+
};
|
|
132
|
+
return await Promise.race([
|
|
133
|
+
run(),
|
|
134
|
+
new Promise<WatchdogPermissionResult>((resolve) => { timeout = setTimeout(() => { agent?.abort(); resolve(finish(false, "Watchdog permission decision timed out.", "timeout")); }, childConfig.agentEndTimeoutMs); }),
|
|
135
|
+
new Promise<WatchdogPermissionResult>((resolve) => {
|
|
136
|
+
abort = () => { agent?.abort(); resolve(finish(false, "Watchdog permission decision was cancelled.", "cancelled")); };
|
|
137
|
+
request.signal?.addEventListener("abort", abort, { once: true });
|
|
138
|
+
request.ctx.signal?.addEventListener("abort", abort, { once: true });
|
|
139
|
+
}),
|
|
140
|
+
]);
|
|
137
141
|
} catch (error) {
|
|
138
142
|
const reason = error instanceof Error ? error.message : String(error);
|
|
139
143
|
return finish(false, `Watchdog permission arbiter failed closed: ${reason}`, reason.includes("timed out") ? "timeout" : "error");
|
|
140
144
|
} finally {
|
|
141
145
|
if (timeout) clearTimeout(timeout);
|
|
146
|
+
if (abort) {
|
|
147
|
+
request.signal?.removeEventListener("abort", abort);
|
|
148
|
+
request.ctx.signal?.removeEventListener("abort", abort);
|
|
149
|
+
}
|
|
142
150
|
}
|
|
143
151
|
};
|
|
144
152
|
}
|