@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/CHANGELOG.md +11 -1
- package/README.md +218 -115
- package/docs/ARCHITECTURE.md +56 -13
- package/docs/COST-ACCOUNTING.md +116 -66
- package/docs/RELEASING.md +32 -32
- package/docs/SECURITY.md +42 -5
- package/docs/UX.md +158 -141
- package/package.json +2 -2
- package/skills/subagent/SKILL.md +78 -49
- package/src/backends/pi.ts +164 -94
- package/src/child-preflight.ts +166 -0
- package/src/config.ts +254 -252
- package/src/dispatch-preflight.ts +87 -0
- package/src/dispatch-routing.ts +56 -0
- package/src/extension.ts +366 -158
- package/src/format.ts +436 -365
- package/src/jev-router.ts +1036 -0
- package/src/orchestrator.ts +75 -19
- package/src/persistence.ts +643 -335
- package/src/policy.ts +120 -89
- package/src/process-lock.ts +730 -687
- package/src/protocol.ts +320 -290
- package/src/registry.ts +730 -632
- package/src/routing-policy.ts +268 -0
- package/src/routing-types.ts +217 -0
- package/src/runner.ts +1299 -850
- package/src/schema.ts +10 -10
- package/src/startup-check.ts +481 -0
- package/src/types.ts +208 -198
- package/src/usage.ts +316 -274
- package/src/model-policy.ts +0 -169
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 {
|
|
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:
|
|
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 ??
|
|
137
|
+
const source = addContextTools(requested ?? availableTools);
|
|
120
138
|
const unknown = source.filter((tool) => !available.has(tool));
|
|
121
|
-
if (unknown.length) return { error: `
|
|
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:
|
|
162
|
-
): { task?:
|
|
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 (
|
|
176
|
-
return { error:
|
|
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
|
|
225
|
-
const
|
|
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
|
|
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
|
|
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
|
-
//
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
thinking:
|
|
279
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
305
|
-
effectiveTools: resolved.tools,
|
|
318
|
+
|
|
306
319
|
resolutionNotes: [
|
|
307
320
|
`backend=${backend}`,
|
|
308
321
|
`profile=${profile}`,
|
|
309
|
-
|
|
322
|
+
|
|
310
323
|
...(agent ? [`agent=${agent.name}`] : []),
|
|
311
|
-
|
|
324
|
+
"routing=jev (pending)",
|
|
312
325
|
],
|
|
313
326
|
},
|
|
314
327
|
};
|
|
315
328
|
}
|
|
316
329
|
|
|
317
|
-
function validateParallel(tasks:
|
|
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,
|
|
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:
|
|
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
|
+
}
|