@bermudi/pi-delegate 0.1.10 → 0.1.11

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/schema.ts CHANGED
@@ -7,6 +7,8 @@ import type { DelegateArguments } from "./types.ts";
7
7
  // `Type.Union([Type.Literal…])` keeps the literals but serializes as `anyOf`,
8
8
  // which some providers handle poorly. `Type.Unsafe` gives both: the wire
9
9
  // format stays `{ type: "string", enum: [...] }` and the type stays narrow.
10
+ // (TypeBox 0.34's `Type.Enum` targets numeric TS enums, not string arrays, so
11
+ // it is not a drop-in replacement here.)
10
12
  function StringEnum<const T extends readonly string[]>(
11
13
  values: T,
12
14
  options?: SchemaOptions,
@@ -112,9 +114,8 @@ export const delegateTaskSchema = Type.Object({
112
114
  });
113
115
 
114
116
  // Single source of truth for registration and generated help. The exported
115
- // argument types in types.ts project this canonical schema and add deprecated
116
- // `action` aliases as a type-only compatibility overlay; providers never see
117
- // those legacy fields in this schema.
117
+ // argument types in types.ts project this canonical schema; providers see
118
+ // only these fields.
118
119
  export const delegateArgumentsSchema = Type.Object({
119
120
  ticketAction: Type.Optional(
120
121
  StringEnum(["poll", "cancel", "wait"], {
@@ -157,8 +158,8 @@ export const delegateArgumentsSchema = Type.Object({
157
158
  ),
158
159
  });
159
160
 
160
- /** Fields that belong to a task entry. Models sometimes place these at the
161
- * top level of the arguments; the shim folds them back into a single task. */
161
+ /** Fields that belong to a task entry. Models sometimes place these at the top
162
+ * level of the arguments; the shim folds them back into a single task. */
162
163
  const TASK_FIELD_NAMES = [
163
164
  "id",
164
165
  "prompt",
@@ -181,52 +182,26 @@ const TASK_FIELD_NAMES = [
181
182
  * corrective message instead of being silently ignored (observed in the
182
183
  * wild: a task-level `async: true` the caller believed had backgrounded
183
184
  * the work while the call in fact ran synchronously). */
184
- const VALID_TASK_KEYS = new Set<string>([...TASK_FIELD_NAMES, "sessionAction"]);
185
-
186
- /** Top-level ticket actions the legacy `action` field may map to. */
187
- const TICKET_ACTIONS = new Set(["poll", "cancel", "wait"]);
188
-
189
- /** Session actions that are valid at the task level. A flat `action` at the
190
- * top level may also fold into a wrapped task's `sessionAction`. */
191
- const TASK_ACTIONS = new Set(["prompt", "close", "list"]);
192
-
193
- /** Every value the legacy `action` field can carry before it is normalized to
194
- * `ticketAction` or `sessionAction`. */
195
- const LEGACY_ACTIONS = new Set([...TICKET_ACTIONS, ...TASK_ACTIONS]);
185
+ const VALID_TASK_KEYS = new Set<string>(TASK_FIELD_NAMES);
196
186
 
197
187
  /** Validate the three operation modes after compatibility reshaping. */
198
188
  export function validateDelegateOperation(
199
189
  params: DelegateArguments,
200
190
  ): string | undefined {
201
191
  const rawParams = params as Record<string, unknown>;
202
- const tasks = params.tasks ?? [];
203
-
204
- const hasLegacyAction = typeof rawParams.action === "string";
205
- const hasTicketAction = params.ticketAction !== undefined;
206
-
207
- if (hasLegacyAction) {
208
- if (!LEGACY_ACTIONS.has(rawParams.action as string)) {
209
- return `unknown action '${rawParams.action}'; valid ticket actions are poll/cancel/wait, valid session actions are prompt/close/list.`;
210
- }
211
- if (hasTicketAction) {
212
- return "ambiguous: supply only ticketAction (or only legacy action), not both.";
213
- }
214
- if (TASK_ACTIONS.has(rawParams.action as string) && tasks.length > 0) {
215
- return `legacy top-level action '${rawParams.action}' cannot be combined with an explicit tasks array; move it into the task's sessionAction or remove tasks.`;
216
- }
192
+ if ("action" in rawParams) {
193
+ return (
194
+ "unsupported field 'action'; use 'ticketAction' for poll/cancel/wait " +
195
+ "or 'sessionAction' for prompt/close/list."
196
+ );
217
197
  }
198
+ const tasks = params.tasks ?? [];
218
199
 
219
- const ticketAction: string | undefined =
220
- params.ticketAction ??
221
- (hasLegacyAction && TICKET_ACTIONS.has(rawParams.action as string)
222
- ? (rawParams.action as string)
223
- : undefined);
224
-
200
+ const ticketAction = params.ticketAction;
225
201
  const isTicketControl = ticketAction !== undefined;
226
202
 
227
203
  if (isTicketControl) {
228
- const topLevelTaskIntentFields = [...TASK_FIELD_NAMES, "tasks"] as const;
229
- const taskIntentFields = topLevelTaskIntentFields.filter(
204
+ const taskIntentFields = ([...TASK_FIELD_NAMES, "tasks"] as const).filter(
230
205
  (field) => rawParams[field] !== undefined,
231
206
  );
232
207
  if (taskIntentFields.length) {
@@ -268,9 +243,9 @@ export function validateDelegateOperation(
268
243
  // there is no tasks array, so a mixed call silently lets tasks win —
269
244
  // a model mistake that should fail loudly.
270
245
  if (tasks.length > 0) {
271
- const flatTaskFields = [
272
- ...new Set([...TASK_FIELD_NAMES, "sessionAction", "action"]),
273
- ].filter((field) => rawParams[field] !== undefined);
246
+ const flatTaskFields = TASK_FIELD_NAMES.filter(
247
+ (field) => rawParams[field] !== undefined,
248
+ );
274
249
  if (flatTaskFields.length) {
275
250
  return `cannot mix top-level task field(s) ${flatTaskFields
276
251
  .map((field) => `'${field}'`)
@@ -282,29 +257,11 @@ export function validateDelegateOperation(
282
257
 
283
258
  for (const [index, task] of tasks.entries()) {
284
259
  const rawTask = task as Record<string, unknown>;
285
- const hasLegacyTaskAction = typeof rawTask.action === "string";
286
- const hasSessionAction = task.sessionAction !== undefined;
287
-
288
- if (hasLegacyTaskAction) {
289
- if (!TASK_ACTIONS.has(rawTask.action as string)) {
290
- return `task ${index + 1}: unknown action '${rawTask.action}'; valid session actions are prompt/close/list.`;
291
- }
292
- if (hasSessionAction) {
293
- return `task ${index + 1}: ambiguous: supply only sessionAction (or only legacy action), not both.`;
294
- }
295
- }
296
-
297
- const sessionAction: string | undefined =
298
- task.sessionAction ??
299
- (hasLegacyTaskAction && TASK_ACTIONS.has(rawTask.action as string)
300
- ? (rawTask.action as string)
301
- : undefined);
260
+ const sessionAction = task.sessionAction;
302
261
 
303
- const unknownKeys = Object.keys(rawTask).filter((key) => {
304
- if (VALID_TASK_KEYS.has(key)) return false;
305
- if (key === "action" && sessionAction !== undefined) return false;
306
- return true;
307
- });
262
+ const unknownKeys = Object.keys(rawTask).filter(
263
+ (key) => !VALID_TASK_KEYS.has(key),
264
+ );
308
265
  if (unknownKeys.length) {
309
266
  const asyncHint = unknownKeys.includes("async")
310
267
  ? " 'async' is a top-level flag; move it out of the task entry."
@@ -336,11 +293,7 @@ export function validateDelegateOperation(
336
293
  return `task ${index + 1}: sessionAction 'close' requires sessionId.`;
337
294
  }
338
295
  const extras = Object.keys(rawTask).filter(
339
- (key) =>
340
- key !== "sessionAction" &&
341
- key !== "sessionId" &&
342
- key !== "action" &&
343
- key !== "id",
296
+ (key) => key !== "sessionAction" && key !== "sessionId" && key !== "id",
344
297
  );
345
298
  if (extras.length) {
346
299
  return `task ${index + 1}: sessionAction 'close' accepts only sessionAction and sessionId.`;
@@ -348,7 +301,7 @@ export function validateDelegateOperation(
348
301
  }
349
302
  if (sessionAction === "list") {
350
303
  const extras = Object.keys(rawTask).filter(
351
- (key) => key !== "sessionAction" && key !== "action" && key !== "id",
304
+ (key) => key !== "sessionAction" && key !== "id",
352
305
  );
353
306
  if (extras.length) {
354
307
  return `task ${index + 1}: sessionAction 'list' accepts only sessionAction.`;
@@ -378,6 +331,52 @@ function normalizeToolsField(value: string): unknown {
378
331
  return trimmed && !/[\s,]/.test(trimmed) ? [trimmed] : value;
379
332
  }
380
333
 
334
+ /** True when `record` carries a top-level ticket-control intent that makes a
335
+ * flat task-field wrap illegitimate: an explicit `ticketAction`, or a bare
336
+ * `ticket` id (which only makes sense with poll/cancel/wait). */
337
+ function hasTicketControlIntent(record: Record<string, unknown>): boolean {
338
+ return (
339
+ record.ticketAction === "poll" ||
340
+ record.ticketAction === "cancel" ||
341
+ record.ticketAction === "wait" ||
342
+ record.ticket !== undefined
343
+ );
344
+ }
345
+
346
+ /** Fold top-level task fields into a single `tasks` entry. Only fires when
347
+ * there is no usable tasks array and no ticket-control intent — those calls
348
+ * are legitimately taskless. `sessionAction` is part of `TASK_FIELD_NAMES`,
349
+ * so a top-level `sessionAction` rides along into the wrapped task. */
350
+ function wrapFlatTaskFields(record: Record<string, unknown>): void {
351
+ const hasTasks = Array.isArray(record.tasks) && record.tasks.length > 0;
352
+ if (hasTasks || hasTicketControlIntent(record)) return;
353
+ const task: Record<string, unknown> = {};
354
+ for (const key of TASK_FIELD_NAMES) {
355
+ if (record[key] !== undefined) {
356
+ task[key] = record[key];
357
+ delete record[key];
358
+ }
359
+ }
360
+ if (Object.keys(task).length > 0) record.tasks = [task];
361
+ }
362
+
363
+ /** Per-entry recovery for one task: stringified (or bare-token) `tools` → a
364
+ * real array, and `agent: ""` → omitted (ad-hoc). Other malformed input is
365
+ * left for schema validation to reject loudly. */
366
+ function normalizeTaskEntry(entry: unknown): unknown {
367
+ if (!entry || typeof entry !== "object") return entry;
368
+ const e = entry as Record<string, unknown>;
369
+ const rawTools = e.tools;
370
+ const fixAgent = e.agent === "";
371
+ if (typeof rawTools !== "string" && !fixAgent) return entry;
372
+ const out = { ...e };
373
+ if (typeof rawTools === "string") {
374
+ out.tools = normalizeToolsField(rawTools);
375
+ }
376
+ if (fixAgent) delete out.agent;
377
+ return out;
378
+ }
379
+
381
380
  /** Compatibility shim run by pi before schema validation. Recovers the
382
381
  * malformed shapes weaker models emit, instead of letting them silently
383
382
  * degrade to the help response (an empty `tasks` returns the manual, which
@@ -386,13 +385,9 @@ function normalizeToolsField(value: string): unknown {
386
385
  * - task fields (`prompt`, `systemPrompt`, `tools`, ...) placed at the top
387
386
  * level instead of inside a `tasks` entry — wrapped into a single task;
388
387
  * - `tools` as a JSON string (or bare token) inside a task entry;
389
- * - `agent: ""` inside a task entry — treated as omitted (ad-hoc);
390
- * - legacy `action` folded into `ticketAction` (top level) or `sessionAction`
391
- * (per task) for runtime compatibility.
392
- * Skipped when a ticket action is in play. Conflicts between the legacy
393
- * `action` field and its canonical replacement are left for
394
- * `validateDelegateOperation` to report. All other invalid input is left for
395
- * normal schema validation to reject loudly.
388
+ * - `agent: ""` inside a task entry — treated as omitted (ad-hoc).
389
+ * All other invalid input is left for normal schema validation to reject
390
+ * loudly.
396
391
  *
397
392
  * Silent by design: these rewrites are lossless re-shaping, so unlike the
398
393
  * model-suffix warning in task-resolution (which fires because thinking
@@ -409,83 +404,12 @@ export function normalizeDelegateArguments(args: unknown): DelegateArguments {
409
404
  if (parsed) record.tasks = parsed;
410
405
  }
411
406
 
412
- // Legacy top-level `action` (ticket verb) canonical `ticketAction`.
413
- // If both are present, leave the conflict for validateDelegateOperation.
414
- if (
415
- typeof record.action === "string" &&
416
- ["poll", "cancel", "wait"].includes(record.action)
417
- ) {
418
- if (record.ticketAction === undefined) {
419
- record.ticketAction = record.action;
420
- delete record.action;
421
- }
422
- }
423
-
424
- // Flat task fields at the top level → wrap into a single task. Only fires
425
- // when there is no usable tasks array and no ticket action (`ticket`,
426
- // poll/cancel/wait) — those calls are legitimately taskless.
427
- const hasTasks = Array.isArray(record.tasks) && record.tasks.length > 0;
428
- const isTicketAction =
429
- record.ticketAction === "poll" ||
430
- record.ticketAction === "cancel" ||
431
- record.ticketAction === "wait" ||
432
- record.action === "poll" ||
433
- record.action === "cancel" ||
434
- record.action === "wait";
435
- if (!hasTasks && !isTicketAction && record.ticket === undefined) {
436
- const task: Record<string, unknown> = {};
437
- for (const key of TASK_FIELD_NAMES) {
438
- if (record[key] !== undefined) {
439
- task[key] = record[key];
440
- delete record[key];
441
- }
442
- }
443
- // Canonical `sessionAction` at the top level folds into the wrapped task.
444
- if (typeof record.sessionAction === "string") {
445
- if (task.sessionAction === undefined) {
446
- task.sessionAction = record.sessionAction;
447
- }
448
- delete record.sessionAction;
449
- }
450
- // Legacy top-level session `action` folds into the wrapped task's
451
- // `sessionAction`. A conflict with an explicit `sessionAction` is left
452
- // for validateDelegateOperation to report.
453
- if (typeof record.action === "string" && TASK_ACTIONS.has(record.action)) {
454
- if (task.sessionAction === undefined) {
455
- task.sessionAction = record.action;
456
- } else {
457
- task.action = record.action;
458
- }
459
- delete record.action;
460
- }
461
- if (Object.keys(task).length > 0) record.tasks = [task];
462
- }
407
+ // Flat task fields at the top level wrap into a single task.
408
+ wrapFlatTaskFields(record);
463
409
 
464
- // Per-entry recovery: stringified (or bare-token) `tools` real arrays,
465
- // `agent: ""` → omitted, and legacy `action` → `sessionAction`.
410
+ // Per-entry recovery: stringified/bare-token `tools` and `agent: ""`.
466
411
  if (Array.isArray(record.tasks)) {
467
- record.tasks = record.tasks.map((entry: unknown) => {
468
- if (!entry || typeof entry !== "object") return entry;
469
- const e = entry as Record<string, unknown>;
470
- const rawTools = e.tools;
471
- const fixAgent = e.agent === "";
472
- const needsActionNorm =
473
- typeof e.action === "string" &&
474
- TASK_ACTIONS.has(e.action) &&
475
- e.sessionAction === undefined;
476
- if (typeof rawTools !== "string" && !fixAgent && !needsActionNorm)
477
- return entry;
478
- const out = { ...e };
479
- if (typeof rawTools === "string") {
480
- out.tools = normalizeToolsField(rawTools);
481
- }
482
- if (fixAgent) delete out.agent;
483
- if (needsActionNorm) {
484
- out.sessionAction = out.action;
485
- delete out.action;
486
- }
487
- return out;
488
- });
412
+ record.tasks = record.tasks.map(normalizeTaskEntry);
489
413
  }
490
414
 
491
415
  return record as DelegateArguments;
@@ -6,7 +6,11 @@ import {
6
6
  DEFAULT_TOOLS,
7
7
  VALID_THINKING,
8
8
  } from "./constants.ts";
9
- import { TOOL_FACTORIES, resolveToolGroups } from "./tools.ts";
9
+ import {
10
+ TOOL_FACTORIES,
11
+ availableToolNames,
12
+ resolveToolGroups,
13
+ } from "./tools.ts";
10
14
  import { configFor } from "./pool.ts";
11
15
  import { isSessionBusy } from "./tickets.ts";
12
16
  import { BUILTIN_AGENT_CONFIGS, buildSubagentSystemPrompt } from "./agents.ts";
@@ -120,7 +124,7 @@ export function validateTasks(
120
124
  ? (agents.get(task.agent) ?? BUILTIN_AGENT_CONFIGS[task.agent])
121
125
  : undefined;
122
126
  const workspace = task.workspace ?? agent?.workspace ?? "shared";
123
- const sessionAction = task.sessionAction ?? task.action;
127
+ const sessionAction = task.sessionAction;
124
128
  if (
125
129
  workspace === "scratch" &&
126
130
  (task.sessionId || task.resumeFrom || sessionAction !== undefined)
@@ -284,9 +288,7 @@ export function resolveTasks(
284
288
  !agent?.explicitTools
285
289
  ) {
286
290
  const denied = new Set(agent.deniedTools);
287
- effectiveParentTools = parentNativeTools.filter(
288
- (t) => !denied.has(t),
289
- );
291
+ effectiveParentTools = parentNativeTools.filter((t) => !denied.has(t));
290
292
  }
291
293
  tools = resolveToolGroups(
292
294
  t.tools ??
@@ -302,15 +304,6 @@ export function resolveTasks(
302
304
  (isPoolHit ? pooledConfig?.tools : undefined) ??
303
305
  DEFAULT_TOOLS,
304
306
  );
305
- const unknownTools = tools.filter(
306
- (name) => !Object.hasOwn(TOOL_FACTORIES, name),
307
- );
308
- if (unknownTools.length) {
309
- warnings.push(
310
- `Unknown tool(s) ignored: ${unknownTools.join(", ")}. Available: ${Object.keys(TOOL_FACTORIES).join(", ")}`,
311
- );
312
- }
313
- tools = tools.filter((name) => Object.hasOwn(TOOL_FACTORIES, name));
314
307
  }
315
308
 
316
309
  // System prompt resolution. AgentSession's resource loader owns
@@ -334,14 +327,14 @@ export function resolveTasks(
334
327
  parentSystemPrompt,
335
328
  tools,
336
329
  });
337
- const requestedSystemPrompt = t.systemPrompt?.trim()
330
+ let requestedSystemPrompt = t.systemPrompt?.trim()
338
331
  ? t.systemPrompt
339
332
  : agent?.systemPrompt?.trim()
340
333
  ? agent.systemPrompt
341
334
  : isDefaultAgent
342
335
  ? resolvedBasePrompt
343
336
  : undefined;
344
- const systemPrompt = buildSubagentSystemPrompt({
337
+ let systemPrompt = buildSubagentSystemPrompt({
345
338
  taskSystemPrompt: t.systemPrompt,
346
339
  agentSystemPrompt: agent?.systemPrompt,
347
340
  parentSystemPrompt,
@@ -504,6 +497,31 @@ export function resolveTasks(
504
497
  );
505
498
  }
506
499
  }
500
+
501
+ const availableTools = availableToolNames(model?.provider);
502
+ const availableToolSet = new Set(availableTools);
503
+ const unknownTools = tools.filter((name) => !availableToolSet.has(name));
504
+ if (unknownTools.length) {
505
+ warnings.push(
506
+ `Unknown tool(s) ignored: ${unknownTools.join(", ")}. Available: ${availableTools.join(", ")}`,
507
+ );
508
+ }
509
+ tools = tools.filter((name) => availableToolSet.has(name));
510
+ systemPrompt = buildSubagentSystemPrompt({
511
+ taskSystemPrompt: t.systemPrompt,
512
+ agentSystemPrompt: agent?.systemPrompt,
513
+ parentSystemPrompt,
514
+ pooledSystemPrompt: pooledConfig?.systemPrompt,
515
+ tools,
516
+ });
517
+ if (
518
+ isDefaultAgent &&
519
+ !t.systemPrompt?.trim() &&
520
+ !agent?.systemPrompt?.trim()
521
+ ) {
522
+ requestedSystemPrompt = systemPrompt;
523
+ }
524
+
507
525
  return {
508
526
  ...t,
509
527
  id: t.id,