@cr1ms0n/pi-subagent 0.8.9 → 0.9.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/src/policy.ts CHANGED
@@ -7,7 +7,7 @@ import type { OutputMode, TaskProfile, TaskSpec } from "./types.js";
7
7
  import type { ParallelTaskInput, SubagentParams } from "./schema.js";
8
8
  import { BACKEND_NAMES, checkCapabilities, type BackendName } from "./backend.js";
9
9
  import { resolveBackend } from "./backends/index.js";
10
- import { validateModelRequest, type ModelPolicySnapshot } from "./model-policy.js";
10
+ import type { JevRoutingConfig, RoutingDecision, RoutingModelCandidate } from "./routing-types.js";
11
11
  import { isThinkingLevel } from "./thinking.js";
12
12
 
13
13
  export const DEPTH_ENV_VAR = "PI_SUBAGENT_DEPTH";
@@ -62,6 +62,25 @@ export interface ResolvedTask extends TaskSpec {
62
62
  resolutionNotes: string[];
63
63
  }
64
64
 
65
+ /** Local preparation cannot launch: it has candidates, not an execution model/tools. */
66
+ export interface PreparedTask extends Omit<ResolvedTask, "model" | "canWrite" | "effectiveTools" | "routing"> {
67
+ candidateTools: string[];
68
+ mandatoryTools: string[];
69
+ /** Request > agent > profile. Selected candidate and parent are applied only after routing. */
70
+ requestedThinking?: TaskSpec["thinking"];
71
+ parentThinking?: TaskSpec["thinking"];
72
+ }
73
+
74
+ export interface PreparationOptions {
75
+ maxDepth?: number;
76
+ maxTasks?: number;
77
+ defaultTimeoutMs?: number;
78
+ taskDefaults?: TaskDefaultsByProfile;
79
+ agents?: Map<string, AgentDefinition>;
80
+ jevRouting?: JevRoutingConfig;
81
+ jevRoutingError?: string;
82
+ }
83
+
65
84
  export type ManagementMode = "status" | "wait" | "cancel" | "steer" | "diff" | "apply" | "discard";
66
85
 
67
86
  export type ValidationResult =
@@ -73,7 +92,7 @@ export type ValidationResult =
73
92
  message?: string;
74
93
  index?: number;
75
94
  synthesis?: string;
76
- tasks: ResolvedTask[];
95
+ tasks: PreparedTask[];
77
96
  /** True when action:"plan" requested a dry-run — no spawn. */
78
97
  planOnly?: boolean;
79
98
  }
@@ -87,7 +106,6 @@ function resolveTools(
87
106
  profile: TaskProfile,
88
107
  requested: string[] | undefined,
89
108
  availableTools: string[],
90
- activeTools: string[],
91
109
  backend: BackendName,
92
110
  ): { tools?: string[]; canWrite?: boolean; error?: string } {
93
111
  const available = new Set(availableTools);
@@ -116,9 +134,9 @@ function resolveTools(
116
134
  return { tools: source, canWrite: false };
117
135
  }
118
136
 
119
- const source = addContextTools(requested ?? activeTools);
137
+ const source = addContextTools(requested ?? availableTools);
120
138
  const unknown = source.filter((tool) => !available.has(tool));
121
- if (unknown.length) return { error: `Active tools are unavailable: ${unknown.join(", ")}` };
139
+ if (unknown.length) return { error: `Candidate tools are unavailable: ${unknown.join(", ")}` };
122
140
  // General-profile custom tools are conservatively write-capable unless explicitly known non-writing.
123
141
  return {
124
142
  tools: source,
@@ -158,8 +176,8 @@ function normalizeTask(
158
176
  index: number,
159
177
  parent: ParentContext,
160
178
  defaultProfile: TaskProfile,
161
- defaults: { timeoutMs?: number; taskDefaults?: TaskDefaultsByProfile; agents?: Map<string, AgentDefinition>; modelPolicy?: ModelPolicySnapshot; modelPolicyError?: string } = {},
162
- ): { task?: ResolvedTask; error?: string } {
179
+ defaults: PreparationOptions = {},
180
+ ): { task?: PreparedTask; error?: string } {
163
181
  if (!item.task?.trim()) return { error: `Task ${index + 1} must not be blank` };
164
182
 
165
183
  // Named agent resolution is still used for persona/profile/tool behavior;
@@ -172,17 +190,9 @@ function normalizeTask(
172
190
  if (!lookup.agent) return { error: `Task ${index + 1}: ${lookup.error}` };
173
191
  agent = lookup.agent;
174
192
  }
175
- if (!defaults.modelPolicy) {
176
- return { error: defaults.modelPolicyError ?? "Model policy is not configured. Add modelPolicy.default to ~/.pi/subagent.json; management actions remain available, but new spawns are rejected." };
193
+ if (item.model !== undefined || item.fallback_models !== undefined) {
194
+ return { error: `Task ${index + 1}: omit model and fallback_models (including empty lists). Jev must select from jevRouting.models; manual/fixed routing is no longer supported.` };
177
195
  }
178
- const modelCheck = validateModelRequest(defaults.modelPolicy, {
179
- agent: agent?.name ?? item.agent,
180
- model: item.model,
181
- fallbackModels: item.fallback_models,
182
- fallbackModelsProvided: item.fallback_models !== undefined,
183
- });
184
- if (modelCheck.error || !modelCheck.route) return { error: `Task ${index + 1}: ${modelCheck.error ?? "model policy validation failed"}` };
185
- const modelRoute = modelCheck.route;
186
196
  if (item.output_mode && !item.output) return { error: `Task ${index + 1}: output_mode requires output` };
187
197
  if (item.fork_resume && !item.resume) return { error: `Task ${index + 1}: fork_resume requires resume` };
188
198
  if (item.timeout_ms !== undefined && (!Number.isInteger(item.timeout_ms) || item.timeout_ms < 1)) {
@@ -220,17 +230,22 @@ function normalizeTask(
220
230
  if (!BACKEND_NAMES.includes(backend)) {
221
231
  return { error: `Task ${index + 1}: unknown backend '${backend}' (expected ${BACKEND_NAMES.join(", ")})` };
222
232
  }
233
+ if (backend !== "pi") return { error: `Task ${index + 1}: new Jev-routed work supports backend:"pi" only; ${backend} is not supported. Existing-run management remains available.` };
223
234
  const profile = item.profile ?? agent?.profile ?? defaultProfile;
224
- const requestedTools = item.tools ?? agent?.tools;
225
- const resolved = resolveTools(profile, requestedTools, parent.availableTools, parent.activeTools ?? parent.availableTools, backend);
235
+ const requestedTools = item.tools;
236
+ const childDepth = (parent.depth ?? parseDepth()) + 1;
237
+ const nestedAllowed = profile === "general" && childDepth < (defaults.maxDepth ?? defaultConfig.maxDepth)
238
+ && agent?.spawns !== false && (!Array.isArray(agent?.spawns) || agent.spawns.length > 0);
239
+ const availableTools = parent.availableTools.filter((tool) => nestedAllowed || !["subagent", "subagent_wait"].includes(tool));
240
+ const resolved = resolveTools(profile, requestedTools, availableTools, backend);
226
241
  if (resolved.error || !resolved.tools || resolved.canWrite === undefined) return { error: resolved.error ?? "Tool resolution failed" };
227
242
  const cwd = resolvePath(parent.cwd, item.cwd);
228
243
  const output = item.output ? resolvePath(cwd, item.output) : undefined;
229
244
  // Non-model fields retain the existing precedence: explicit request > agent
230
- // file > per-profile config defaults > parent inheritance. Model routing was
231
- // validated above and is exclusively owned by modelPolicy.
245
+ // file > per-profile config defaults; candidate/parent thinking waits for routing.
232
246
  const profileDefaults: TaskDefaults = defaults.taskDefaults?.[profile] ?? {};
233
- const effectiveThinking = item.thinking ?? agent?.thinking ?? profileDefaults.thinking ?? modelRoute.thinking ?? parent.thinking;
247
+ const requestedThinking = item.thinking ?? agent?.thinking ?? profileDefaults.thinking;
248
+ const effectiveThinking = requestedThinking ?? parent.thinking;
234
249
  if (effectiveThinking !== undefined && !isThinkingLevel(effectiveThinking)) {
235
250
  return { error: `Task ${index + 1}: thinking must be a non-empty Pi thinking level string without whitespace or control characters` };
236
251
  }
@@ -272,20 +287,19 @@ function normalizeTask(
272
287
  label,
273
288
  task: repairDoubleEncodedText(item.task.trim()),
274
289
  systemPrompt: systemPrompt ? repairDoubleEncodedText(systemPrompt) : systemPrompt,
275
- // Model routing is exclusively owned by modelPolicy. Legacy agent,
276
- // taskDefaults, and parent-session model fields never participate.
277
- model: item.model!.trim(),
278
- thinking: effectiveThinking,
279
- tools: resolved.tools,
290
+ // No model or effective tool set exists until finalizeRoutedTasks succeeds.
291
+ requestedThinking,
292
+ parentThinking: parent.thinking,
293
+ thinking: requestedThinking,
294
+ candidateTools: resolved.tools.filter((tool) => !CONTEXT_MANAGEMENT_TOOLS.has(tool)),
295
+ mandatoryTools: resolved.tools.filter((tool) => CONTEXT_MANAGEMENT_TOOLS.has(tool)),
280
296
  profile,
281
297
  cwd,
282
- timeoutMs: item.timeout_ms ?? agent?.timeoutMs ?? profileDefaults.timeoutMs ?? defaults.timeoutMs ?? defaultConfig.defaultTimeoutMs,
298
+ timeoutMs: item.timeout_ms ?? agent?.timeoutMs ?? profileDefaults.timeoutMs ?? defaults.defaultTimeoutMs ?? defaultConfig.defaultTimeoutMs,
283
299
  maxTurns: item.max_turns ?? agent?.maxTurns ?? profileDefaults.maxTurns,
284
300
  maxCost: item.max_cost ?? agent?.maxCost ?? profileDefaults.maxCost,
285
301
  graceTurns: item.grace_turns ?? agent?.graceTurns,
286
- // The immutable configured route is copied into the TaskSpec; callers
287
- // cannot add, remove, or reorder fallback models after validation.
288
- fallbackModels: [...modelRoute.fallbackModels],
302
+ fallbackModels: [],
289
303
  maxRetries: item.max_retries ?? agent?.maxRetries ?? profileDefaults.maxRetries,
290
304
  contextFork: item.context === "fork",
291
305
  parentSessionFile: item.context === "fork" ? parent.sessionFile : undefined,
@@ -301,27 +315,26 @@ function normalizeTask(
301
315
  // Child's own future-spawn allowlist never inherits from boot env —
302
316
  // only the named persona's frontmatter `spawns` restricts grandchildren.
303
317
  spawns: agent?.spawns,
304
- canWrite: resolved.canWrite,
305
- effectiveTools: resolved.tools,
318
+
306
319
  resolutionNotes: [
307
320
  `backend=${backend}`,
308
321
  `profile=${profile}`,
309
- `access=${resolved.canWrite ? "RW" : "RO"}`,
322
+
310
323
  ...(agent ? [`agent=${agent.name}`] : []),
311
- `model-policy=${defaults.modelPolicy.agents.has(agent?.name ?? item.agent?.trim().toLowerCase() ?? "") ? "agent" : "default"}`,
324
+ "routing=jev (pending)",
312
325
  ],
313
326
  },
314
327
  };
315
328
  }
316
329
 
317
- function validateParallel(tasks: ResolvedTask[]): string | undefined {
330
+ function validateParallel(tasks: Array<Pick<TaskSpec, "output" | "canWrite" | "isolation" | "cwd" | "allowSharedWrites">>): string | undefined {
318
331
  const outputs = new Set<string>();
319
332
  for (const task of tasks) {
320
333
  if (task.output && outputs.has(task.output)) return `Duplicate output path: ${task.output}`;
321
334
  if (task.output) outputs.add(task.output);
322
335
  }
323
336
 
324
- const sharedByCwd = new Map<string, ResolvedTask[]>();
337
+ const sharedByCwd = new Map<string, Array<Pick<TaskSpec, "output" | "canWrite" | "isolation" | "cwd" | "allowSharedWrites">>>();
325
338
  for (const task of tasks.filter((task) => task.canWrite && task.isolation !== "worktree")) {
326
339
  const list = sharedByCwd.get(task.cwd!) ?? [];
327
340
  list.push(task);
@@ -395,57 +408,9 @@ export function shouldRegisterSubagentTool(
395
408
  export function validateSubagentRequest(
396
409
  params: SubagentParams,
397
410
  parent: ParentContext,
398
- options: {
399
- maxDepth?: number;
400
- maxTasks?: number;
401
- defaultTimeoutMs?: number;
402
- taskDefaults?: TaskDefaultsByProfile;
403
- agents?: Map<string, AgentDefinition>;
404
- modelPolicy?: ModelPolicySnapshot;
405
- modelPolicyError?: string;
406
- } = {},
411
+ options: PreparationOptions = {},
407
412
  ): ValidationResult {
408
- const defaults = {
409
- timeoutMs: options.defaultTimeoutMs,
410
- taskDefaults: options.taskDefaults,
411
- agents: options.agents,
412
- modelPolicy: options.modelPolicy,
413
- modelPolicyError: options.modelPolicyError,
414
- };
415
- const depth = parent.depth ?? parseDepth();
416
- const maxDepth = options.maxDepth ?? defaultConfig.maxDepth;
417
- if (depth >= maxDepth) return { ok: false, error: `Subagent nesting depth limit reached (${depth} >= ${maxDepth})` };
418
-
419
- // Boot spawn policy (from our parent) — fail closed; applies to new spawn modes only.
420
- const spawnPolicy = parseSpawnPolicy(process.env[SPAWNS_ENV_VAR]);
421
- if (spawnPolicy.kind !== "unrestricted") {
422
- const hasSpawnWork = typeof params.task === "string" || Array.isArray(params.tasks);
423
- if (hasSpawnWork) {
424
- if (spawnPolicy.kind === "disabled") {
425
- return { ok: false, error: `Subagent spawning is disabled by parent policy (${describeSpawnPolicy(spawnPolicy)})` };
426
- }
427
- // Allowlist requires a named agent from the list; agentless is rejected.
428
- const requestedAgents: Array<string | undefined> = Array.isArray(params.tasks)
429
- ? params.tasks.map((t) => t.agent)
430
- : [params.agent];
431
- for (let i = 0; i < requestedAgents.length; i++) {
432
- const agentName = requestedAgents[i]?.trim().toLowerCase();
433
- if (!agentName) {
434
- return {
435
- ok: false,
436
- error: `Task ${i + 1}: agentless tasks are not allowed under parent ${describeSpawnPolicy(spawnPolicy)}`,
437
- };
438
- }
439
- if (!spawnPolicy.agents.includes(agentName)) {
440
- return {
441
- ok: false,
442
- error: `Task ${i + 1}: agent "${agentName}" is not in parent ${describeSpawnPolicy(spawnPolicy)}`,
443
- };
444
- }
445
- }
446
- }
447
- }
448
-
413
+ const defaults = options;
449
414
  const hasAction = params.action !== undefined;
450
415
  const hasTask = typeof params.task === "string";
451
416
  const hasTasks = Array.isArray(params.tasks);
@@ -486,12 +451,46 @@ export function validateSubagentRequest(
486
451
  return { ok: true, mode: params.action as ManagementMode, async: false, id: params.id, message: params.message, index: params.index, tasks: [] };
487
452
  }
488
453
 
454
+ const depth = parent.depth ?? parseDepth();
455
+ const maxDepth = options.maxDepth ?? defaultConfig.maxDepth;
456
+ if (depth >= maxDepth) return { ok: false, error: `Subagent nesting depth limit reached (${depth} >= ${maxDepth})` };
457
+ if (!options.jevRouting) return { ok: false, error: options.jevRoutingError ?? "Jev routing is not configured. Add jevRouting.models with exact IDs and descriptions to ~/.pi/subagent.json; existing-run management remains available." };
458
+ // Boot spawn policy (from our parent) — fail closed; applies to new spawn modes only.
459
+ const spawnPolicy = parseSpawnPolicy(process.env[SPAWNS_ENV_VAR]);
460
+ if (spawnPolicy.kind !== "unrestricted") {
461
+ const hasSpawnWork = typeof params.task === "string" || Array.isArray(params.tasks);
462
+ if (hasSpawnWork) {
463
+ if (spawnPolicy.kind === "disabled") {
464
+ return { ok: false, error: `Subagent spawning is disabled by parent policy (${describeSpawnPolicy(spawnPolicy)})` };
465
+ }
466
+ // Allowlist requires a named agent from the list; agentless is rejected.
467
+ const requestedAgents: Array<string | undefined> = Array.isArray(params.tasks)
468
+ ? params.tasks.map((t) => t.agent)
469
+ : [params.agent];
470
+ for (let i = 0; i < requestedAgents.length; i++) {
471
+ const agentName = requestedAgents[i]?.trim().toLowerCase();
472
+ if (!agentName) {
473
+ return {
474
+ ok: false,
475
+ error: `Task ${i + 1}: agentless tasks are not allowed under parent ${describeSpawnPolicy(spawnPolicy)}`,
476
+ };
477
+ }
478
+ if (!spawnPolicy.agents.includes(agentName)) {
479
+ return {
480
+ ok: false,
481
+ error: `Task ${i + 1}: agent "${agentName}" is not in parent ${describeSpawnPolicy(spawnPolicy)}`,
482
+ };
483
+ }
484
+ }
485
+ }
486
+ }
487
+
489
488
  if (hasTasks) {
490
489
  const rawTasks = params.tasks!;
491
490
  const maxTasks = options.maxTasks ?? defaultConfig.maxTasksPerRun;
492
491
  if (!rawTasks.length || rawTasks.length > maxTasks) return { ok: false, error: `Expected 1..${maxTasks} tasks (configurable via maxTasksPerRun)` };
493
492
  // Top-level TaskFields apply only to single-task mode.
494
- if (params.system_prompt !== undefined || params.model !== undefined || params.tools !== undefined || params.profile !== undefined || params.cwd !== undefined || params.resume !== undefined || params.agent !== undefined) {
493
+ if (params.system_prompt !== undefined || params.model !== undefined || params.fallback_models !== undefined || params.tools !== undefined || params.profile !== undefined || params.cwd !== undefined || params.resume !== undefined || params.agent !== undefined) {
495
494
  return { ok: false, error: "Top-level task options cannot be combined with tasks[]; set them on each tasks[] item" };
496
495
  }
497
496
  // Context forking duplicates the whole parent conversation per child;
@@ -499,7 +498,7 @@ export function validateSubagentRequest(
499
498
  if (rawTasks.length > 1 && rawTasks.some((task) => task.context === "fork")) {
500
499
  return { ok: false, error: "context:'fork' is single-task only; parallel fanout would duplicate the parent conversation per child" };
501
500
  }
502
- const tasks: ResolvedTask[] = [];
501
+ const tasks: PreparedTask[] = [];
503
502
  for (let index = 0; index < rawTasks.length; index++) {
504
503
  const normalized = normalizeTask(rawTasks[index] as ParallelTaskInput, index, parent, "explore", defaults);
505
504
  if (normalized.error || !normalized.task) return { ok: false, error: normalized.error ?? "Invalid task" };
@@ -529,3 +528,35 @@ export function validateSubagentRequest(
529
528
  export function describeCapability(task: ResolvedTask): string {
530
529
  return `${task.profile}/${task.canWrite ? "RW" : "RO"} tools=[${task.effectiveTools.join(",")}]`;
531
530
  }
531
+
532
+ /** Final local authority: no raw caller model field is synthesized to bypass policy. */
533
+ export function finalizeRoutedTasks(
534
+ prepared: readonly PreparedTask[],
535
+ decisions: readonly RoutingDecision[],
536
+ models: readonly RoutingModelCandidate[],
537
+ ): { ok: true; tasks: ResolvedTask[] } | { ok: false; error: string } {
538
+ if (prepared.length !== decisions.length) return { ok: false, error: "Routing decision count does not match the prepared tasks." };
539
+ const tasks: ResolvedTask[] = [];
540
+ for (let index = 0; index < prepared.length; index++) {
541
+ const item = prepared[index]!;
542
+ const decision = decisions[index]!;
543
+ const candidate = models.find((entry) => entry.model === decision.selectedModel);
544
+ if (!candidate) return { ok: false, error: `Task ${index + 1}: selector chose a model outside the available dedicated candidates.` };
545
+ if (new Set(decision.selectedTools).size !== decision.selectedTools.length || decision.selectedTools.some((tool) => !item.candidateTools.includes(tool))) {
546
+ return { ok: false, error: `Task ${index + 1}: selector chose tools outside the locally permitted candidates.` };
547
+ }
548
+ const tools = [...new Set([...decision.selectedTools, ...item.mandatoryTools])];
549
+ const canWrite = tools.some((tool) => !NON_WRITING_TOOLS.has(tool));
550
+ if (item.profile !== "general" && canWrite) return { ok: false, error: `Task ${index + 1}: writable selector choice violates ${item.profile}.` };
551
+ const { candidateTools: _candidates, mandatoryTools, requestedThinking, parentThinking, ...spec } = item;
552
+ const thinking = requestedThinking ?? candidate.thinking ?? parentThinking;
553
+ tasks.push({
554
+ ...spec, model: candidate.model, thinking, tools, effectiveTools: tools, canWrite,
555
+ fallbackModels: [],
556
+ routing: { ...decision, mandatoryTools: [...mandatoryTools], outcome: "success" },
557
+ resolutionNotes: [...item.resolutionNotes.filter((note) => !note.startsWith("routing=")), "routing=jev", `access=${canWrite ? "RW" : "RO"}`],
558
+ });
559
+ }
560
+ const problem = validateParallel(tasks);
561
+ return problem ? { ok: false, error: problem } : { ok: true, tasks };
562
+ }