@bermudi/pi-delegate 0.1.9 → 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)
@@ -274,25 +278,32 @@ export function resolveTasks(
274
278
  // "continue with only sessionId" works without re-supplying tools.
275
279
  // Explicit overrides that don't match get rejected by acquireAgentSession.
276
280
  if (t.sessionAction !== "close" && t.sessionAction !== "list") {
281
+ // For `default` a deny-only override (no explicit allowlist) is not
282
+ // materialized at discovery; apply its denylist to the parent's actual
283
+ // tools here so a read-only parent stays read-only.
284
+ let effectiveParentTools = parentNativeTools;
285
+ if (
286
+ isDefaultAgent &&
287
+ agent?.deniedTools?.length &&
288
+ !agent?.explicitTools
289
+ ) {
290
+ const denied = new Set(agent.deniedTools);
291
+ effectiveParentTools = parentNativeTools.filter((t) => !denied.has(t));
292
+ }
277
293
  tools = resolveToolGroups(
278
294
  t.tools ??
279
295
  parentModelOverride?.tools ??
280
296
  agentOverride?.tools ??
281
- (isDefaultAgent ? parentNativeTools : undefined) ??
297
+ (isDefaultAgent
298
+ ? agent?.explicitTools
299
+ ? agent.tools
300
+ : effectiveParentTools
301
+ : undefined) ??
282
302
  (isBuiltinAgent ? agent?.tools : undefined) ??
283
303
  agent?.tools ??
284
304
  (isPoolHit ? pooledConfig?.tools : undefined) ??
285
305
  DEFAULT_TOOLS,
286
306
  );
287
- const unknownTools = tools.filter(
288
- (name) => !Object.hasOwn(TOOL_FACTORIES, name),
289
- );
290
- if (unknownTools.length) {
291
- warnings.push(
292
- `Unknown tool(s) ignored: ${unknownTools.join(", ")}. Available: ${Object.keys(TOOL_FACTORIES).join(", ")}`,
293
- );
294
- }
295
- tools = tools.filter((name) => Object.hasOwn(TOOL_FACTORIES, name));
296
307
  }
297
308
 
298
309
  // System prompt resolution. AgentSession's resource loader owns
@@ -316,14 +327,14 @@ export function resolveTasks(
316
327
  parentSystemPrompt,
317
328
  tools,
318
329
  });
319
- const requestedSystemPrompt = t.systemPrompt?.trim()
330
+ let requestedSystemPrompt = t.systemPrompt?.trim()
320
331
  ? t.systemPrompt
321
332
  : agent?.systemPrompt?.trim()
322
333
  ? agent.systemPrompt
323
334
  : isDefaultAgent
324
335
  ? resolvedBasePrompt
325
336
  : undefined;
326
- const systemPrompt = buildSubagentSystemPrompt({
337
+ let systemPrompt = buildSubagentSystemPrompt({
327
338
  taskSystemPrompt: t.systemPrompt,
328
339
  agentSystemPrompt: agent?.systemPrompt,
329
340
  parentSystemPrompt,
@@ -369,10 +380,16 @@ export function resolveTasks(
369
380
  // task and settings.json model overrides, but deliberately ignore the
370
381
  // legacy delegate.json agent model map so they inherit the parent unless
371
382
  // an explicit modern override wins.
383
+ // Overridden built-ins can still provide an explicit `model` in their
384
+ // Markdown frontmatter – when `explicitModel` is set, honor it instead
385
+ // of silently ignoring it (which would contradict the Markdown contract).
372
386
  const modelSpec = isDefaultAgent
373
- ? t.model
387
+ ? (t.model ?? (agent?.explicitModel ? agent.model : undefined))
374
388
  : isBuiltinAgent
375
- ? (t.model ?? parentModelOverride?.model ?? agentOverride?.model)
389
+ ? (t.model ??
390
+ parentModelOverride?.model ??
391
+ agentOverride?.model ??
392
+ (agent?.explicitModel ? agent.model : undefined))
376
393
  : resolveModelSpec({
377
394
  taskModel:
378
395
  t.model ?? parentModelOverride?.model ?? agentOverride?.model,
@@ -449,6 +466,7 @@ export function resolveTasks(
449
466
  ? (t.thinking ??
450
467
  parentModelOverride?.thinking ??
451
468
  agentOverride?.thinking ??
469
+ (agent?.explicitThinking ? agent.thinking : undefined) ??
452
470
  modelSuffix ??
453
471
  parentDefaults.thinking ??
454
472
  (isPoolHit ? pooledConfig?.thinking : undefined) ??
@@ -456,6 +474,7 @@ export function resolveTasks(
456
474
  : (t.thinking ??
457
475
  parentModelOverride?.thinking ??
458
476
  agentOverride?.thinking ??
477
+ (agent?.explicitThinking ? agent.thinking : undefined) ??
459
478
  (isPoolHit ? pooledConfig?.thinking : undefined) ??
460
479
  modelSuffix ??
461
480
  parentDefaults.thinking ??
@@ -478,6 +497,31 @@ export function resolveTasks(
478
497
  );
479
498
  }
480
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
+
481
525
  return {
482
526
  ...t,
483
527
  id: t.id,