@bermudi/pi-delegate 0.1.15 → 0.1.17
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/agents.ts +2 -2
- package/constants.ts +10 -0
- package/delegate.ts +1 -0
- package/dispatch.ts +21 -7
- package/extension.ts +55 -1
- package/lifecycle.ts +17 -6
- package/manual.ts +5 -3
- package/package.json +1 -1
- package/render-branches.ts +2 -1
- package/schema.ts +163 -79
- package/task-resolution.ts +39 -19
- package/telemetry.ts +86 -11
- package/test-preload.ts +26 -0
- package/ticket-format.ts +6 -2
- package/types.ts +22 -4
- package/workspace.ts +183 -58
package/schema.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Type, type SchemaOptions } from "@sinclair/typebox";
|
|
2
|
-
import { VALID_THINKING_LEVELS } from "./constants.ts";
|
|
2
|
+
import { VALID_THINKING_LEVELS, isSessionControlAction } from "./constants.ts";
|
|
3
3
|
import type { DelegateArguments } from "./types.ts";
|
|
4
4
|
|
|
5
5
|
// JSON Schema string enum that keeps the literal union in `Static<>`.
|
|
@@ -30,19 +30,19 @@ export const delegateTaskSchema = Type.Object({
|
|
|
30
30
|
minLength: 1,
|
|
31
31
|
maxLength: 64,
|
|
32
32
|
description:
|
|
33
|
-
"Optional
|
|
33
|
+
"Optional correlation key; duplicates rejected; omit for index.",
|
|
34
34
|
}),
|
|
35
35
|
),
|
|
36
36
|
prompt: Type.Optional(
|
|
37
37
|
Type.String({
|
|
38
38
|
description:
|
|
39
|
-
"Self-contained task prompt; fresh context cannot see this chat. Omit only for
|
|
39
|
+
"Self-contained task prompt; fresh context cannot see this chat. Omit only for resumeFrom.",
|
|
40
40
|
}),
|
|
41
41
|
),
|
|
42
42
|
agent: Type.Optional(
|
|
43
43
|
Type.String({
|
|
44
44
|
description:
|
|
45
|
-
"
|
|
45
|
+
"default mirrors the parent's tools; scout/coder/reviewer specialists. Ad-hoc tasks get * tools even when the parent is narrower.",
|
|
46
46
|
}),
|
|
47
47
|
),
|
|
48
48
|
cwd: Type.Optional(
|
|
@@ -86,13 +86,6 @@ export const delegateTaskSchema = Type.Object({
|
|
|
86
86
|
"Live pool key for multi-turn reuse; omit for one-shot tasks.",
|
|
87
87
|
}),
|
|
88
88
|
),
|
|
89
|
-
sessionAction: Type.Optional(
|
|
90
|
-
StringEnum(["prompt", "close", "list"], {
|
|
91
|
-
description:
|
|
92
|
-
"Session action; close needs sessionId; list shows active pooled sessions.",
|
|
93
|
-
default: "prompt",
|
|
94
|
-
}),
|
|
95
|
-
),
|
|
96
89
|
resumeFrom: Type.Optional(
|
|
97
90
|
Type.String({
|
|
98
91
|
description:
|
|
@@ -108,7 +101,7 @@ export const delegateTaskSchema = Type.Object({
|
|
|
108
101
|
workspace: Type.Optional(
|
|
109
102
|
StringEnum(["shared", "scratch", "isolated"], {
|
|
110
103
|
description:
|
|
111
|
-
"shared; scratch
|
|
104
|
+
"shared edits source; scratch discards; isolated orders Git worktree proposals; none confine access.",
|
|
112
105
|
}),
|
|
113
106
|
),
|
|
114
107
|
});
|
|
@@ -124,6 +117,18 @@ export const delegateArgumentsSchema = Type.Object(
|
|
|
124
117
|
"Ticket control: poll=snapshot; wait=block until settled; cancel=abort. Prefer wait; never cancel for time.",
|
|
125
118
|
}),
|
|
126
119
|
),
|
|
120
|
+
sessionAction: Type.Optional(
|
|
121
|
+
StringEnum(["close", "list"], {
|
|
122
|
+
description:
|
|
123
|
+
'"close" ends the named pooled session; "list" lists active ones. Runs instead of tasks.',
|
|
124
|
+
}),
|
|
125
|
+
),
|
|
126
|
+
sessionId: Type.Optional(
|
|
127
|
+
Type.String({
|
|
128
|
+
description:
|
|
129
|
+
'With "close": the pooled session to end. Alone, a sessionId folds into a task as reuse.',
|
|
130
|
+
}),
|
|
131
|
+
),
|
|
127
132
|
async: Type.Optional(
|
|
128
133
|
Type.Boolean({
|
|
129
134
|
description:
|
|
@@ -162,7 +167,10 @@ export const delegateArgumentsSchema = Type.Object(
|
|
|
162
167
|
);
|
|
163
168
|
|
|
164
169
|
/** Fields that belong to a task entry. Models sometimes place these at the top
|
|
165
|
-
* level of the arguments; the
|
|
170
|
+
* level of the arguments; the normalizer folds them back into a single task.
|
|
171
|
+
* `sessionAction` is NOT here: it was promoted to a top-level field (#32), so
|
|
172
|
+
* top-level presence means session-RPC intent, not task intent — the classifier
|
|
173
|
+
* and its mode validators own it now. */
|
|
166
174
|
const TASK_FIELD_NAMES = [
|
|
167
175
|
"id",
|
|
168
176
|
"prompt",
|
|
@@ -174,7 +182,6 @@ const TASK_FIELD_NAMES = [
|
|
|
174
182
|
"tools",
|
|
175
183
|
"thinking",
|
|
176
184
|
"sessionId",
|
|
177
|
-
"sessionAction",
|
|
178
185
|
"resumeFrom",
|
|
179
186
|
"deadlineMs",
|
|
180
187
|
"workspace",
|
|
@@ -187,7 +194,18 @@ const TASK_FIELD_NAMES = [
|
|
|
187
194
|
* the work while the call in fact ran synchronously). */
|
|
188
195
|
const VALID_TASK_KEYS = new Set<string>(TASK_FIELD_NAMES);
|
|
189
196
|
|
|
190
|
-
/**
|
|
197
|
+
/** Task fields that are known spellings of top-level fields — rejected inside
|
|
198
|
+
* task entries with a corrective hint pointing at their real home. */
|
|
199
|
+
const TOP_LEVEL_TASK_KEY_HINTS = new Set(["async", "sessionAction"]);
|
|
200
|
+
|
|
201
|
+
/** Validate the four operation modes after compatibility reshaping.
|
|
202
|
+
*
|
|
203
|
+
* One classifier with fixed precedence runs first: `ticketAction` → ticket
|
|
204
|
+
* RPC; `sessionAction` → session RPC; non-empty `tasks` → dispatch; otherwise
|
|
205
|
+
* help. Each mode gets a small total validator that rejects foreign fields
|
|
206
|
+
* generically (naming the offending field and the fix), so ordering is no
|
|
207
|
+
* longer load-bearing across checks and a new field costs one schema entry
|
|
208
|
+
* plus one mode check. */
|
|
191
209
|
export function validateDelegateOperation(
|
|
192
210
|
params: DelegateArguments,
|
|
193
211
|
): string | undefined {
|
|
@@ -195,37 +213,105 @@ export function validateDelegateOperation(
|
|
|
195
213
|
if ("action" in rawParams) {
|
|
196
214
|
return (
|
|
197
215
|
"unsupported field 'action'; use 'ticketAction' for poll/cancel/wait " +
|
|
198
|
-
"or 'sessionAction' for
|
|
216
|
+
"or 'sessionAction' for close/list."
|
|
199
217
|
);
|
|
200
218
|
}
|
|
201
|
-
const tasks = params.tasks ?? [];
|
|
202
219
|
|
|
203
|
-
|
|
204
|
-
|
|
220
|
+
// Mode classification — precedence by selector presence.
|
|
221
|
+
if (params.ticketAction !== undefined) {
|
|
222
|
+
return validateTicketMode(params);
|
|
223
|
+
}
|
|
224
|
+
if (params.sessionAction !== undefined) {
|
|
225
|
+
return validateSessionMode(params);
|
|
226
|
+
}
|
|
227
|
+
return validateDispatchOrHelpMode(params);
|
|
228
|
+
}
|
|
205
229
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
230
|
+
/** Fields recognized at the top level in session mode: the selector itself
|
|
231
|
+
* plus its target. Anything else (ticket fields, async, tasks, stray task
|
|
232
|
+
* fields) is foreign to session RPC. */
|
|
233
|
+
const SESSION_MODE_FIELDS = new Set(["sessionAction", "sessionId"]);
|
|
234
|
+
|
|
235
|
+
/** Session RPC: one close/list action per call against one pooled session. */
|
|
236
|
+
function validateSessionMode(params: DelegateArguments): string | undefined {
|
|
237
|
+
const rawParams = params as Record<string, unknown>;
|
|
238
|
+
const { sessionAction } = params;
|
|
239
|
+
// Nothing strips or defaults anymore; any value besides 'close'/'list'
|
|
240
|
+
// fails closed below rather than misclassifying.
|
|
241
|
+
if (!isSessionControlAction(sessionAction)) {
|
|
242
|
+
return `sessionAction '${String(sessionAction)}' is not a session control action; use 'close' or 'list'.`;
|
|
243
|
+
}
|
|
244
|
+
if (sessionAction === "close" && !params.sessionId) {
|
|
245
|
+
return "sessionAction 'close' requires sessionId.";
|
|
246
|
+
}
|
|
247
|
+
// Skip only fields carrying their documented default/no-op value: a caller
|
|
248
|
+
// (or a schema validator that materialises defaults) may spell out
|
|
249
|
+
// `async: false`, `force: false`, or `tasks: []` explicitly. Other false or
|
|
250
|
+
// empty-array values remain foreign so future fields cannot bypass this mode
|
|
251
|
+
// boundary merely by sharing the same value shape.
|
|
252
|
+
const foreign = Object.keys(rawParams).filter((key) => {
|
|
253
|
+
if (SESSION_MODE_FIELDS.has(key) || rawParams[key] === undefined) {
|
|
254
|
+
return false;
|
|
220
255
|
}
|
|
221
|
-
if (
|
|
222
|
-
return
|
|
256
|
+
if ((key === "async" || key === "force") && rawParams[key] === false) {
|
|
257
|
+
return false;
|
|
223
258
|
}
|
|
224
|
-
if (
|
|
225
|
-
|
|
259
|
+
if (
|
|
260
|
+
key === "tasks" &&
|
|
261
|
+
Array.isArray(rawParams[key]) &&
|
|
262
|
+
rawParams[key].length === 0
|
|
263
|
+
) {
|
|
264
|
+
return false;
|
|
226
265
|
}
|
|
227
|
-
return
|
|
266
|
+
return true;
|
|
267
|
+
});
|
|
268
|
+
if (foreign.length) {
|
|
269
|
+
return `sessionAction '${sessionAction}' cannot be combined with ${foreign
|
|
270
|
+
.map((field) => `'${field}'`)
|
|
271
|
+
.join(
|
|
272
|
+
", ",
|
|
273
|
+
)}; run it alone — a session action takes only 'sessionAction' (plus 'sessionId' for 'close').`;
|
|
228
274
|
}
|
|
275
|
+
return undefined;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Ticket RPC: `ticketAction` owns the call; every other field family is
|
|
279
|
+
* foreign. Note `sessionAction` must be listed explicitly: it left
|
|
280
|
+
* `TASK_FIELD_NAMES` when it was promoted to top level, and without owning it
|
|
281
|
+
* here the old task-level exclusion would silently evaporate. */
|
|
282
|
+
function validateTicketMode(params: DelegateArguments): string | undefined {
|
|
283
|
+
const rawParams = params as Record<string, unknown>;
|
|
284
|
+
const ticketAction = params.ticketAction;
|
|
285
|
+
const incompatibleFields = (
|
|
286
|
+
[...TASK_FIELD_NAMES, "tasks", "sessionAction"] as const
|
|
287
|
+
).filter((field) => rawParams[field] !== undefined);
|
|
288
|
+
if (incompatibleFields.length) {
|
|
289
|
+
return `ticket control cannot be combined with field(s) ${incompatibleFields
|
|
290
|
+
.map((field) => `'${field}'`)
|
|
291
|
+
.join(", ")}; call it separately.`;
|
|
292
|
+
}
|
|
293
|
+
if (params.async === true) {
|
|
294
|
+
return "ticket control cannot include async; call it separately.";
|
|
295
|
+
}
|
|
296
|
+
if (ticketAction !== "poll" && !params.ticket) {
|
|
297
|
+
return `ticketAction '${ticketAction}' requires ticket.`;
|
|
298
|
+
}
|
|
299
|
+
if (ticketAction !== "cancel" && params.force === true) {
|
|
300
|
+
return "force is valid only with ticketAction 'cancel'.";
|
|
301
|
+
}
|
|
302
|
+
if (ticketAction !== "wait" && params.timeoutMs !== undefined) {
|
|
303
|
+
return "timeoutMs is valid only with ticketAction 'wait'.";
|
|
304
|
+
}
|
|
305
|
+
return undefined;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Dispatch (non-empty `tasks`) or help (empty/absent). Stray ticket- and
|
|
309
|
+
* session-mode selectors are foreign here and rejected generically. */
|
|
310
|
+
function validateDispatchOrHelpMode(
|
|
311
|
+
params: DelegateArguments,
|
|
312
|
+
): string | undefined {
|
|
313
|
+
const rawParams = params as Record<string, unknown>;
|
|
314
|
+
const tasks = params.tasks ?? [];
|
|
229
315
|
|
|
230
316
|
if (params.ticket !== undefined) {
|
|
231
317
|
return "ticket requires ticketAction 'poll', 'cancel', or 'wait'.";
|
|
@@ -249,30 +335,29 @@ export function validateDelegateOperation(
|
|
|
249
335
|
// nonempty tasks array. The normalize shim only wraps flat fields when
|
|
250
336
|
// there is no tasks array, so a mixed call silently lets tasks win —
|
|
251
337
|
// a model mistake that should fail loudly.
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
)} with an explicit tasks array; move them into a task entry or remove tasks.`;
|
|
262
|
-
}
|
|
338
|
+
const flatTaskFields = TASK_FIELD_NAMES.filter(
|
|
339
|
+
(field) => rawParams[field] !== undefined,
|
|
340
|
+
);
|
|
341
|
+
if (flatTaskFields.length) {
|
|
342
|
+
return `cannot mix top-level task field(s) ${flatTaskFields
|
|
343
|
+
.map((field) => `'${field}'`)
|
|
344
|
+
.join(
|
|
345
|
+
", ",
|
|
346
|
+
)} with an explicit tasks array; move them into a task entry or remove tasks.`;
|
|
263
347
|
}
|
|
264
348
|
|
|
265
349
|
for (const [index, task] of tasks.entries()) {
|
|
266
350
|
const rawTask = task as Record<string, unknown>;
|
|
267
|
-
const sessionAction = task.sessionAction;
|
|
268
351
|
|
|
269
352
|
const unknownKeys = Object.keys(rawTask).filter(
|
|
270
353
|
(key) => !VALID_TASK_KEYS.has(key),
|
|
271
354
|
);
|
|
272
355
|
if (unknownKeys.length) {
|
|
273
|
-
const misplacedTopLevel = unknownKeys.filter((key) =>
|
|
356
|
+
const misplacedTopLevel = unknownKeys.filter((key) =>
|
|
357
|
+
TOP_LEVEL_TASK_KEY_HINTS.has(key),
|
|
358
|
+
);
|
|
274
359
|
const topLevelHint = misplacedTopLevel.length
|
|
275
|
-
? ` ${misplacedTopLevel.map((key) => `'${key}'`).join(" and ")} ${misplacedTopLevel.length === 1 ? "is a" : "are"} top-level
|
|
360
|
+
? ` ${misplacedTopLevel.map((key) => `'${key}'`).join(" and ")} ${misplacedTopLevel.length === 1 ? "is a" : "are"} top-level field${misplacedTopLevel.length === 1 ? "" : "s"}; move ${misplacedTopLevel.length === 1 ? "it" : "them"} out of the task entry.`
|
|
276
361
|
: "";
|
|
277
362
|
return (
|
|
278
363
|
`task ${index + 1}: unknown field(s) ${unknownKeys
|
|
@@ -291,28 +376,9 @@ export function validateDelegateOperation(
|
|
|
291
376
|
}
|
|
292
377
|
if (
|
|
293
378
|
(task.workspace === "scratch" || task.workspace === "isolated") &&
|
|
294
|
-
(task.sessionId || task.resumeFrom
|
|
379
|
+
(task.sessionId || task.resumeFrom)
|
|
295
380
|
) {
|
|
296
|
-
return `task ${index + 1}: workspace '${task.workspace}' is one-shot and cannot be combined with sessionId
|
|
297
|
-
}
|
|
298
|
-
if (sessionAction === "close") {
|
|
299
|
-
if (!task.sessionId) {
|
|
300
|
-
return `task ${index + 1}: sessionAction 'close' requires sessionId.`;
|
|
301
|
-
}
|
|
302
|
-
const extras = Object.keys(rawTask).filter(
|
|
303
|
-
(key) => key !== "sessionAction" && key !== "sessionId" && key !== "id",
|
|
304
|
-
);
|
|
305
|
-
if (extras.length) {
|
|
306
|
-
return `task ${index + 1}: sessionAction 'close' accepts only sessionAction and sessionId.`;
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
if (sessionAction === "list") {
|
|
310
|
-
const extras = Object.keys(rawTask).filter(
|
|
311
|
-
(key) => key !== "sessionAction" && key !== "id",
|
|
312
|
-
);
|
|
313
|
-
if (extras.length) {
|
|
314
|
-
return `task ${index + 1}: sessionAction 'list' accepts only sessionAction.`;
|
|
315
|
-
}
|
|
381
|
+
return `task ${index + 1}: workspace '${task.workspace}' is one-shot and cannot be combined with sessionId or resumeFrom. Set workspace: "shared" to use a persistent agent.`;
|
|
316
382
|
}
|
|
317
383
|
}
|
|
318
384
|
|
|
@@ -350,13 +416,29 @@ function hasTicketControlIntent(record: Record<string, unknown>): boolean {
|
|
|
350
416
|
);
|
|
351
417
|
}
|
|
352
418
|
|
|
419
|
+
/** True when `record` carries top-level session-RPC intent: an explicit
|
|
420
|
+
* close/list `sessionAction`. A bare top-level `sessionId` is deliberately
|
|
421
|
+
* NOT session intent — it stays task-intent and wraps into a task as reuse.
|
|
422
|
+
* Only `sessionAction` presence selects the session mode. */
|
|
423
|
+
function hasSessionControlIntent(record: Record<string, unknown>): boolean {
|
|
424
|
+
return isSessionControlAction(record.sessionAction);
|
|
425
|
+
}
|
|
426
|
+
|
|
353
427
|
/** Fold top-level task fields into a single `tasks` entry. Only fires when
|
|
354
|
-
* there is no usable tasks array and
|
|
355
|
-
*
|
|
356
|
-
*
|
|
428
|
+
* there is no usable tasks array and neither ticket-control intent nor
|
|
429
|
+
* session-control intent makes those calls legitimately taskless.
|
|
430
|
+
* `sessionAction` is not part of `TASK_FIELD_NAMES`: a top-level close/list
|
|
431
|
+
* means session RPC and must reach the classifier unwrapped — wrapping first
|
|
432
|
+
* would swallow stray fields into a task instead of rejecting them. */
|
|
357
433
|
function wrapFlatTaskFields(record: Record<string, unknown>): void {
|
|
358
434
|
const hasTasks = Array.isArray(record.tasks) && record.tasks.length > 0;
|
|
359
|
-
if (
|
|
435
|
+
if (
|
|
436
|
+
hasTasks ||
|
|
437
|
+
hasTicketControlIntent(record) ||
|
|
438
|
+
hasSessionControlIntent(record)
|
|
439
|
+
) {
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
360
442
|
const task: Record<string, unknown> = {};
|
|
361
443
|
for (const key of TASK_FIELD_NAMES) {
|
|
362
444
|
if (record[key] !== undefined) {
|
|
@@ -390,9 +472,11 @@ function normalizeTaskEntry(entry: unknown): unknown {
|
|
|
390
472
|
* models then misread as "the tool is broken"):
|
|
391
473
|
* - `tasks` as a JSON string instead of an array;
|
|
392
474
|
* - task fields (`prompt`, `systemPrompt`, `tools`, ...) placed at the top
|
|
393
|
-
* level instead of inside a `tasks` entry — wrapped into a single task
|
|
475
|
+
* level instead of inside a `tasks` entry — wrapped into a single task,
|
|
476
|
+
* unless ticket- or session-control intent makes the call legitimately
|
|
477
|
+
* taskless (see `hasTicketControlIntent` / `hasSessionControlIntent`);
|
|
394
478
|
* - `tools` as a JSON string (or bare token) inside a task entry;
|
|
395
|
-
* - `agent: ""` inside a task entry — treated as omitted (ad-hoc)
|
|
479
|
+
* - `agent: ""` inside a task entry — treated as omitted (ad-hoc);
|
|
396
480
|
* All other invalid input is left for normal schema validation to reject
|
|
397
481
|
* loudly.
|
|
398
482
|
*
|
package/task-resolution.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
DEFAULT_AGENT_NAME,
|
|
6
6
|
DEFAULT_TOOLS,
|
|
7
7
|
VALID_THINKING,
|
|
8
|
+
isSessionControlAction,
|
|
8
9
|
} from "./constants.ts";
|
|
9
10
|
import {
|
|
10
11
|
TOOL_FACTORIES,
|
|
@@ -36,7 +37,8 @@ import type {
|
|
|
36
37
|
DelegateToolResult,
|
|
37
38
|
ParentAgentDefaults,
|
|
38
39
|
ResolvedTask,
|
|
39
|
-
|
|
40
|
+
ResolveTasksResult,
|
|
41
|
+
DispatchableTask,
|
|
40
42
|
} from "./types.ts";
|
|
41
43
|
|
|
42
44
|
const PROJECT_CONTEXT_START =
|
|
@@ -92,7 +94,7 @@ export function stripInheritedProjectContext(
|
|
|
92
94
|
/** Build a tool result for an error/notice with no task progress. */
|
|
93
95
|
function noticeResult(
|
|
94
96
|
text: string,
|
|
95
|
-
tasks:
|
|
97
|
+
tasks: DispatchableTask[],
|
|
96
98
|
parentModel: string | undefined,
|
|
97
99
|
): DelegateToolResult {
|
|
98
100
|
return {
|
|
@@ -110,7 +112,7 @@ function formatTaskRef(index: number, id: string | undefined): string {
|
|
|
110
112
|
* ticket, and unknown agent names. Returns an error result to short-circuit
|
|
111
113
|
* the call, or null when all checks pass. */
|
|
112
114
|
export function validateTasks(
|
|
113
|
-
tasks:
|
|
115
|
+
tasks: DispatchableTask[],
|
|
114
116
|
agents: Map<string, AgentConfig>,
|
|
115
117
|
parentModelId: string | undefined,
|
|
116
118
|
): DelegateToolResult | null {
|
|
@@ -152,7 +154,7 @@ export function validateTasks(
|
|
|
152
154
|
: `uses workspace \`${workspace}\``;
|
|
153
155
|
const persistentAgent = task.agent ?? "agent";
|
|
154
156
|
return noticeResult(
|
|
155
|
-
`${formatTaskRef(index, task.id)}: Agent \`${persistentAgent}\` ${defaultText}, which is one-shot and cannot use \`sessionId
|
|
157
|
+
`${formatTaskRef(index, task.id)}: Agent \`${persistentAgent}\` ${defaultText}, which is one-shot and cannot use \`sessionId\` or \`resumeFrom\`. Set \`workspace: "shared"\` to use a persistent ${persistentAgent}.`,
|
|
156
158
|
tasks,
|
|
157
159
|
parentModelId,
|
|
158
160
|
);
|
|
@@ -235,15 +237,17 @@ export function validateTasks(
|
|
|
235
237
|
|
|
236
238
|
/** Resolve every task into a fully-specified `ResolvedTask`: cwd, system
|
|
237
239
|
* prompt, model, tools, thinking, and prompt (with optional parent-transcript
|
|
238
|
-
* injection).
|
|
239
|
-
*
|
|
240
|
+
* injection). Returns `{ error }` — rejecting the whole batch before any
|
|
241
|
+
* dispatch — when any task names a tool outside the closed valid set for its
|
|
242
|
+
* resolved model's provider. Throws on unrecoverable misconfiguration
|
|
243
|
+
* (missing prompt, unavailable explicit model, no model at all). */
|
|
240
244
|
export function resolveTasks(
|
|
241
|
-
tasks:
|
|
245
|
+
tasks: DispatchableTask[],
|
|
242
246
|
ctx: DelegateToolCtx,
|
|
243
247
|
agents: Map<string, AgentConfig>,
|
|
244
248
|
parentDefaults: ParentAgentDefaults,
|
|
245
249
|
dispatchConfig: DelegateConfig = getDelegateConfigSnapshot(),
|
|
246
|
-
):
|
|
250
|
+
): ResolveTasksResult {
|
|
247
251
|
// Build parent transcript lazily — only computed once if any task uses with-parent-transcript
|
|
248
252
|
let parentTranscript: string | null = null;
|
|
249
253
|
const needsParentContext = tasks.some(
|
|
@@ -269,7 +273,10 @@ export function resolveTasks(
|
|
|
269
273
|
const agentOverrides = getAgentOverrides(dispatchConfig);
|
|
270
274
|
const overridesByParentModel = getAgentOverridesByParentModel(dispatchConfig);
|
|
271
275
|
|
|
272
|
-
|
|
276
|
+
const resolveTask = (
|
|
277
|
+
t: DispatchableTask,
|
|
278
|
+
i: number,
|
|
279
|
+
): ResolvedTask | { error: string } => {
|
|
273
280
|
const isDefaultAgent = t.agent === DEFAULT_AGENT_NAME;
|
|
274
281
|
const agent = t.agent
|
|
275
282
|
? (agents.get(t.agent) ?? BUILTIN_AGENT_CONFIGS[t.agent])
|
|
@@ -318,8 +325,7 @@ export function resolveTasks(
|
|
|
318
325
|
|
|
319
326
|
// Prompt is required for fresh tasks. ResumeFrom provides context already.
|
|
320
327
|
if (
|
|
321
|
-
t.sessionAction
|
|
322
|
-
t.sessionAction !== "list" &&
|
|
328
|
+
!isSessionControlAction(t.sessionAction) &&
|
|
323
329
|
!t.resumeFrom &&
|
|
324
330
|
!t.prompt?.trim()
|
|
325
331
|
) {
|
|
@@ -328,11 +334,12 @@ export function resolveTasks(
|
|
|
328
334
|
);
|
|
329
335
|
}
|
|
330
336
|
|
|
331
|
-
// Resolve tools —
|
|
337
|
+
// Resolve tools — unknown names reject the whole batch (checked below,
|
|
338
|
+
// post-model-resolution, because the provider decides the valid set).
|
|
332
339
|
// For active pooled sessions, fall back to the frozen pooled config so
|
|
333
340
|
// "continue with only sessionId" works without re-supplying tools.
|
|
334
341
|
// Explicit overrides that don't match get rejected by acquireAgentSession.
|
|
335
|
-
if (t.sessionAction
|
|
342
|
+
if (!isSessionControlAction(t.sessionAction)) {
|
|
336
343
|
// For `default` a deny-only override (no explicit allowlist) is not
|
|
337
344
|
// materialized at discovery; apply its denylist to the parent's actual
|
|
338
345
|
// tools here so a read-only parent stays read-only.
|
|
@@ -428,7 +435,7 @@ export function resolveTasks(
|
|
|
428
435
|
let modelSuffix: ThinkingLevel | undefined;
|
|
429
436
|
let thinking: ThinkingLevel = "off";
|
|
430
437
|
|
|
431
|
-
if (t.sessionAction
|
|
438
|
+
if (!isSessionControlAction(t.sessionAction)) {
|
|
432
439
|
const agentType = t.agent ?? "inline";
|
|
433
440
|
// The built-in `default` profile bypasses delegate.json model overrides.
|
|
434
441
|
// The other built-ins accept task and modern agent overrides, but
|
|
@@ -568,15 +575,20 @@ export function resolveTasks(
|
|
|
568
575
|
dispatchConfig,
|
|
569
576
|
);
|
|
570
577
|
|
|
578
|
+
// The valid tool set is closed: core TOOL_FACTORIES plus the static
|
|
579
|
+
// per-provider list. Unknown names hard-reject the whole batch (mirroring
|
|
580
|
+
// unknown agent names) instead of warn-dropping a silently weakened
|
|
581
|
+
// subagent. Frozen pooled configs are known-good: they were filtered
|
|
582
|
+
// against this same static set when first resolved, so bare
|
|
583
|
+
// "continue with only sessionId" resumes never trip this check.
|
|
571
584
|
const availableTools = availableToolNames(model?.provider);
|
|
572
585
|
const availableToolSet = new Set(availableTools);
|
|
573
586
|
const unknownTools = tools.filter((name) => !availableToolSet.has(name));
|
|
574
587
|
if (unknownTools.length) {
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
588
|
+
return {
|
|
589
|
+
error: `${formatTaskRef(i, t.id)}: unknown tool(s): ${unknownTools.join(", ")}. Available: ${availableTools.join(", ")}. Fix the tool names in the task's tools list or the agent profile's tools.`,
|
|
590
|
+
};
|
|
578
591
|
}
|
|
579
|
-
tools = tools.filter((name) => availableToolSet.has(name));
|
|
580
592
|
systemPrompt = buildSubagentSystemPrompt({
|
|
581
593
|
taskSystemPrompt: t.systemPrompt,
|
|
582
594
|
agentSystemPrompt: agent?.systemPrompt,
|
|
@@ -627,5 +639,13 @@ export function resolveTasks(
|
|
|
627
639
|
},
|
|
628
640
|
providerExtensionSources,
|
|
629
641
|
};
|
|
630
|
-
}
|
|
642
|
+
};
|
|
643
|
+
|
|
644
|
+
const resolved: ResolvedTask[] = [];
|
|
645
|
+
for (const [i, task] of tasks.entries()) {
|
|
646
|
+
const result = resolveTask(task, i);
|
|
647
|
+
if ("error" in result) return { error: result.error };
|
|
648
|
+
resolved.push(result);
|
|
649
|
+
}
|
|
650
|
+
return { tasks: resolved };
|
|
631
651
|
}
|