pi-subagents 0.35.1 → 0.37.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.
Files changed (89) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/README.md +224 -31
  3. package/agents/advisor.md +73 -0
  4. package/agents/delegate.md +2 -0
  5. package/agents/worker.md +2 -0
  6. package/package.json +11 -13
  7. package/skills/pi-subagents/SKILL.md +42 -13
  8. package/src/agents/agent-management.ts +7 -1
  9. package/src/agents/agents.ts +121 -17
  10. package/src/api/capability-ceiling.ts +17 -0
  11. package/src/api/delegation.ts +127 -0
  12. package/src/api/preflight.ts +399 -0
  13. package/src/extension/config.ts +7 -1
  14. package/src/extension/index.ts +52 -38
  15. package/src/extension/rpc.ts +31 -2
  16. package/src/extension/schemas.ts +29 -3
  17. package/src/extension/tool-description.ts +4 -2
  18. package/src/intercom/intercom-bridge.ts +1 -1
  19. package/src/intercom/native-supervisor-channel.ts +45 -6
  20. package/src/intercom/result-intercom.ts +7 -0
  21. package/src/runs/background/async-execution.ts +158 -17
  22. package/src/runs/background/async-job-tracker.ts +4 -0
  23. package/src/runs/background/async-resume.ts +47 -8
  24. package/src/runs/background/async-status.ts +94 -3
  25. package/src/runs/background/chain-append.ts +2 -0
  26. package/src/runs/background/completion-batcher.ts +6 -4
  27. package/src/runs/background/completion-dedupe.ts +2 -11
  28. package/src/runs/background/fleet-view.ts +9 -4
  29. package/src/runs/background/notify.ts +132 -120
  30. package/src/runs/background/process-terminal.ts +280 -0
  31. package/src/runs/background/result-watcher.ts +138 -78
  32. package/src/runs/background/run-status.ts +10 -2
  33. package/src/runs/background/scheduled-runs.ts +6 -1
  34. package/src/runs/background/stale-run-reconciler.ts +6 -0
  35. package/src/runs/background/subagent-runner.ts +406 -54
  36. package/src/runs/background/subagent-wait.ts +130 -4
  37. package/src/runs/background/wait-tool.ts +2 -2
  38. package/src/runs/foreground/chain-execution.ts +181 -111
  39. package/src/runs/foreground/execution.ts +147 -47
  40. package/src/runs/foreground/foreground-control.ts +90 -0
  41. package/src/runs/foreground/subagent-executor.ts +426 -165
  42. package/src/runs/shared/acceptance.ts +94 -41
  43. package/src/runs/shared/agent-contract.ts +38 -0
  44. package/src/runs/shared/capability-ceiling.ts +177 -0
  45. package/src/runs/shared/child-protocol.ts +1 -1
  46. package/src/runs/shared/completion-guard.ts +36 -5
  47. package/src/runs/shared/context-mode.ts +44 -0
  48. package/src/runs/shared/dynamic-fanout.ts +5 -5
  49. package/src/runs/shared/long-running-guard.ts +4 -0
  50. package/src/runs/shared/mcp-direct-tool-allowlist.ts +12 -6
  51. package/src/runs/shared/nested-events.ts +35 -3
  52. package/src/runs/shared/parallel-handoff.ts +154 -0
  53. package/src/runs/shared/parallel-utils.ts +11 -0
  54. package/src/runs/shared/pi-args.ts +148 -56
  55. package/src/runs/shared/run-history.ts +90 -5
  56. package/src/runs/shared/session-lease.ts +25 -5
  57. package/src/runs/shared/structured-output.ts +112 -7
  58. package/src/runs/shared/subagent-control.ts +4 -0
  59. package/src/runs/shared/subagent-prompt-runtime.ts +31 -19
  60. package/src/runs/shared/task-intent.ts +10 -5
  61. package/src/runs/shared/tool-availability.ts +21 -3
  62. package/src/runs/shared/tool-budget.ts +11 -5
  63. package/src/runs/shared/turn-budget.ts +2 -1
  64. package/src/runs/shared/worktree.ts +63 -14
  65. package/src/shared/accessible-dir.ts +25 -0
  66. package/src/shared/artifacts.ts +37 -7
  67. package/src/shared/atomic-json.ts +14 -42
  68. package/src/shared/child-transcript.ts +52 -0
  69. package/src/shared/file-system-retry.ts +47 -0
  70. package/src/shared/launch-contract.ts +123 -0
  71. package/src/shared/settings.ts +9 -1
  72. package/src/shared/types.ts +314 -28
  73. package/src/shared/utils.ts +17 -42
  74. package/src/slash/delegation-adapters.ts +153 -6
  75. package/src/slash/delegation-json.ts +108 -0
  76. package/src/slash/delegation-request.ts +182 -36
  77. package/src/slash/prompt-template-bridge.ts +222 -37
  78. package/src/slash/selector.ts +147 -0
  79. package/src/slash/slash-commands.ts +15 -6
  80. package/src/slash/slash-live-state.ts +2 -2
  81. package/src/slash/subagents-admin.ts +42 -42
  82. package/src/tui/fleet-status.ts +362 -0
  83. package/src/tui/fleet-transcript.ts +472 -0
  84. package/src/tui/fleet.ts +318 -59
  85. package/src/tui/render.ts +17 -15
  86. package/src/watchdog/change-signature.ts +105 -12
  87. package/src/watchdog/review.ts +7 -2
  88. package/src/watchdog/runtime.ts +5 -3
  89. package/src/slash/subagents-editor.ts +0 -86
@@ -1,12 +1,23 @@
1
1
  import {
2
2
  SUBAGENT_DELEGATION_PROTOCOL_VERSION,
3
+ SUBAGENT_DELEGATION_V2_PROTOCOL_VERSION,
3
4
  type SubagentDelegationAcceptanceResult,
5
+ type SubagentDelegationEffectsResult,
6
+ type SubagentDelegationExecutionResult,
4
7
  type SubagentDelegationRequest,
5
8
  type SubagentDelegationResponse,
9
+ type SubagentDelegationReviewResult,
6
10
  type SubagentDelegationStatus,
7
11
  type SubagentDelegationUpdate,
12
+ type SubagentDelegationV2Request,
13
+ type SubagentDelegationV2Response,
14
+ type SubagentDelegationV2Thinking,
15
+ type SubagentDelegationV2Value,
16
+ type SubagentDelegationV2Update,
8
17
  } from "../api/delegation.ts";
9
- import type { AcceptanceInput, ToolBudgetConfig, TurnBudgetConfig } from "../shared/types.ts";
18
+ import type { AcceptanceInput, AgentContract, EffectsProjection, ExecutionProjection, JsonSchemaObject, ReviewProjection, ToolBudgetConfig, TurnBudgetConfig, Usage } from "../shared/types.ts";
19
+ import { isAgentContractV1 } from "../runs/shared/agent-contract.ts";
20
+ import { cloneJsonWithinByteLimit } from "./delegation-json.ts";
10
21
 
11
22
  export interface PromptTemplateDelegationTask {
12
23
  agent: string;
@@ -86,15 +97,22 @@ export interface PromptTemplateBridgeResult {
86
97
  exitCode?: number;
87
98
  error?: string;
88
99
  model?: string;
100
+ thinking?: string;
101
+ structuredOutput?: unknown;
89
102
  interrupted?: boolean;
90
103
  timedOut?: boolean;
91
104
  stopped?: boolean;
92
105
  turnBudgetExceeded?: boolean;
93
106
  toolBudgetBlocked?: boolean;
107
+ structuredOutputFailed?: boolean;
94
108
  savedOutputPath?: string;
95
109
  sessionFile?: string;
110
+ agentContract?: AgentContract;
111
+ execution?: ExecutionProjection;
96
112
  acceptance?: SubagentDelegationAcceptanceResult;
97
- usage?: { turns?: number };
113
+ review?: ReviewProjection;
114
+ effects?: EffectsProjection;
115
+ usage?: Usage;
98
116
  progressSummary?: { toolCount?: number; durationMs?: number; tokens?: number };
99
117
  skillsWarning?: string;
100
118
  outputSaveError?: string;
@@ -125,12 +143,20 @@ export interface DelegatedSubagentExecutionParams {
125
143
  worktree?: boolean;
126
144
  timeoutMs?: number;
127
145
  turnBudget?: TurnBudgetConfig;
146
+ /** Internal-only strict turn-boundary enforcement for versioned foreground delegation. */
147
+ enforceHardTurnLimit?: boolean;
128
148
  toolBudget?: ToolBudgetConfig;
129
149
  skill?: string | string[] | boolean;
130
150
  output?: string | boolean;
131
151
  outputMode?: "inline" | "file-only";
152
+ outputSchema?: JsonSchemaObject;
153
+ agentContract?: AgentContract;
132
154
  acceptance?: AcceptanceInput;
133
155
  artifacts?: boolean;
156
+ /** Internal-only thinking override accepted by executeDelegated. */
157
+ delegatedThinkingOverride?: SubagentDelegationV2Thinking;
158
+ /** Internal-only V2 capability accepted and stripped by executeDelegated. */
159
+ delegatedAllowZeroToolBudget?: true;
134
160
  async: false;
135
161
  foregroundOnly: true;
136
162
  clarify: false;
@@ -175,8 +201,8 @@ export function parsePromptTemplateRequest(data: unknown): PromptTemplateDelegat
175
201
  const fallbackTask = tasks[0];
176
202
  return {
177
203
  requestId: value.requestId,
178
- agent: hasSingle ? value.agent : fallbackTask!.agent,
179
- task: hasSingle ? value.task : fallbackTask!.task,
204
+ agent: hasSingle ? value.agent! : fallbackTask!.agent,
205
+ task: hasSingle ? value.task! : fallbackTask!.task,
180
206
  ...(tasks.length > 0 ? { tasks } : {}),
181
207
  context: value.context,
182
208
  model: value.model,
@@ -343,10 +369,13 @@ export function toSubagentDelegationExecutionParams(request: SubagentDelegationR
343
369
  model: request.model,
344
370
  timeoutMs: request.timeoutMs,
345
371
  turnBudget: request.turnBudget,
372
+ enforceHardTurnLimit: true,
346
373
  toolBudget: request.toolBudget,
347
374
  skill: request.skill,
348
375
  output: request.output,
349
376
  outputMode: request.outputMode,
377
+ outputSchema: request.outputSchema,
378
+ agentContract: request.agentContract,
350
379
  acceptance: request.acceptance,
351
380
  artifacts: request.artifacts,
352
381
  async: false,
@@ -355,6 +384,30 @@ export function toSubagentDelegationExecutionParams(request: SubagentDelegationR
355
384
  };
356
385
  }
357
386
 
387
+ export function toSubagentDelegationV2ExecutionParams(request: SubagentDelegationV2Request): DelegatedSubagentExecutionParams {
388
+ return {
389
+ agent: request.agent,
390
+ task: request.task,
391
+ context: request.context,
392
+ cwd: request.cwd,
393
+ model: request.model,
394
+ timeoutMs: request.timeoutMs,
395
+ turnBudget: request.turnBudget,
396
+ enforceHardTurnLimit: true,
397
+ toolBudget: request.toolBudget,
398
+ skill: request.skill,
399
+ output: false,
400
+ ...(request.result.kind === "structured" ? { outputSchema: request.result.schema } : {}),
401
+ acceptance: false,
402
+ artifacts: request.artifacts,
403
+ delegatedThinkingOverride: request.thinking,
404
+ delegatedAllowZeroToolBudget: true,
405
+ async: false,
406
+ foregroundOnly: true,
407
+ clarify: false,
408
+ };
409
+ }
410
+
358
411
  export function toSubagentDelegationUpdate(requestId: string, result: PromptTemplateBridgeResult): SubagentDelegationUpdate | undefined {
359
412
  const legacy = toDelegationUpdate(requestId, result);
360
413
  if (!legacy) return undefined;
@@ -373,6 +426,29 @@ export function toSubagentDelegationUpdate(requestId: string, result: PromptTemp
373
426
  };
374
427
  }
375
428
 
429
+ export function toSubagentDelegationV2Update(
430
+ request: SubagentDelegationV2Request,
431
+ result: PromptTemplateBridgeResult,
432
+ ): SubagentDelegationV2Update | undefined {
433
+ const legacy = toDelegationUpdate(request.requestId, result);
434
+ if (!legacy) return undefined;
435
+ return {
436
+ version: SUBAGENT_DELEGATION_V2_PROTOCOL_VERSION,
437
+ requestId: request.requestId,
438
+ ownerRunId: request.ownerRunId,
439
+ nodeId: request.nodeId,
440
+ ...(legacy.currentTool ? { currentTool: legacy.currentTool } : {}),
441
+ ...(legacy.currentToolArgs ? { currentToolArgs: legacy.currentToolArgs } : {}),
442
+ ...(legacy.recentOutput ? { recentOutput: legacy.recentOutput } : {}),
443
+ ...(legacy.recentOutputLines ? { recentOutputLines: legacy.recentOutputLines } : {}),
444
+ ...(legacy.recentTools ? { recentTools: legacy.recentTools } : {}),
445
+ ...(legacy.model ? { model: legacy.model } : {}),
446
+ ...(typeof legacy.toolCount === "number" ? { toolCount: legacy.toolCount } : {}),
447
+ ...(typeof legacy.durationMs === "number" ? { durationMs: legacy.durationMs } : {}),
448
+ ...(typeof legacy.tokens === "number" ? { tokens: legacy.tokens } : {}),
449
+ };
450
+ }
451
+
376
452
  function resolveSubagentDelegationStatus(
377
453
  result: PromptTemplateBridgeResult,
378
454
  aborted: boolean,
@@ -381,9 +457,10 @@ function resolveSubagentDelegationStatus(
381
457
  const child = result.details?.results?.[0];
382
458
  if (!child) return "failed";
383
459
  if (result.details?.timedOut || child.timedOut) return "timed_out";
460
+ if (child?.structuredOutputFailed) return "structured_output_failed";
384
461
  if (child?.turnBudgetExceeded) return "turn_budget_exhausted";
385
462
  if (child?.toolBudgetBlocked) return "tool_budget_exhausted";
386
- if (child?.acceptance?.status === "rejected" && child.acceptance.explicit) return "acceptance_failed";
463
+ if (!isAgentContractV1(child.agentContract) && child?.acceptance?.status === "rejected" && child.acceptance.explicit) return "acceptance_failed";
387
464
  if (result.details?.stopped || child?.stopped || child?.interrupted) return "interrupted";
388
465
  if (result.isError || child?.error || (typeof child?.exitCode === "number" && child.exitCode !== 0)) return "failed";
389
466
  return "completed";
@@ -410,10 +487,13 @@ export function toSubagentDelegationResponse(
410
487
  ...(child?.agent ? { agent: child.agent } : {}),
411
488
  ...(child?.model ? { model: child.model } : {}),
412
489
  ...(typeof child?.exitCode === "number" ? { exitCode: child.exitCode } : {}),
490
+ ...(child?.execution ? { execution: child.execution as SubagentDelegationExecutionResult } : {}),
413
491
  ...(child?.finalOutput ? { output: child.finalOutput } : {}),
414
492
  ...(child?.savedOutputPath ? { outputPath: child.savedOutputPath } : {}),
415
493
  ...(child?.sessionFile ? { sessionFile: child.sessionFile } : {}),
416
- ...(child?.acceptance ? { acceptance: { status: child.acceptance.status, explicit: child.acceptance.explicit } } : {}),
494
+ ...(child?.acceptance ? { acceptance: { status: child.acceptance.status, evidenceStatus: child.acceptance.evidenceStatus, explicit: child.acceptance.explicit } } : {}),
495
+ ...(child?.review ? { review: child.review as SubagentDelegationReviewResult } : {}),
496
+ ...(child?.effects ? { effects: child.effects as SubagentDelegationEffectsResult } : {}),
417
497
  ...(typeof child?.usage?.turns === "number" ? { turns: child.usage.turns } : {}),
418
498
  ...(typeof progress?.toolCount === "number" ? { toolCount: progress.toolCount } : {}),
419
499
  ...(typeof progress?.durationMs === "number" ? { durationMs: progress.durationMs } : {}),
@@ -422,6 +502,73 @@ export function toSubagentDelegationResponse(
422
502
  };
423
503
  }
424
504
 
505
+ const MAX_V2_RESULT_BYTES = 1024 * 1024;
506
+
507
+ export function toSubagentDelegationV2Response(
508
+ request: SubagentDelegationV2Request,
509
+ result: PromptTemplateBridgeResult,
510
+ aborted: boolean,
511
+ ): SubagentDelegationV2Response {
512
+ const child = result.details?.results?.[0];
513
+ const progress = child?.progressSummary ?? result.details?.progress?.[0];
514
+ let status = resolveSubagentDelegationStatus(result, aborted);
515
+ let error = child?.error ?? (status === "failed" ? firstTextContent(result.content) : undefined);
516
+ let projectedResult: SubagentDelegationV2Value | undefined;
517
+ if (status === "completed") {
518
+ if (request.result.kind === "text") {
519
+ if (typeof child?.finalOutput !== "string") {
520
+ status = "failed";
521
+ error = "Delegated subagent did not capture a text result.";
522
+ } else if (Buffer.byteLength(child.finalOutput, "utf8") > MAX_V2_RESULT_BYTES) {
523
+ status = "failed";
524
+ error = "Delegated text result exceeds 1 MiB when UTF-8 encoded.";
525
+ } else {
526
+ projectedResult = { kind: "text", text: child.finalOutput };
527
+ }
528
+ } else if (child?.structuredOutput === undefined) {
529
+ status = "failed";
530
+ error = "Delegated subagent did not capture the requested structured result.";
531
+ } else {
532
+ const inspected = cloneJsonWithinByteLimit(child.structuredOutput, MAX_V2_RESULT_BYTES);
533
+ if (!inspected.ok) {
534
+ status = "failed";
535
+ error = inspected.reason === "too_large"
536
+ ? "Delegated structured result exceeds 1 MiB when encoded."
537
+ : "Delegated structured result is not plain JSON data.";
538
+ } else {
539
+ projectedResult = { kind: "structured", value: inspected.value };
540
+ }
541
+ }
542
+ }
543
+ const usage = child?.usage;
544
+ return {
545
+ version: SUBAGENT_DELEGATION_V2_PROTOCOL_VERSION,
546
+ requestId: request.requestId,
547
+ ownerRunId: request.ownerRunId,
548
+ nodeId: request.nodeId,
549
+ status,
550
+ ...(error ? { error } : {}),
551
+ ...(result.details?.runId ? { runId: result.details.runId } : {}),
552
+ ...(child?.agent ? { agent: child.agent } : {}),
553
+ ...(child?.model ? { model: child.model } : {}),
554
+ ...(child?.thinking ? { thinking: child.thinking } : {}),
555
+ ...(typeof child?.exitCode === "number" ? { exitCode: child.exitCode } : {}),
556
+ ...(projectedResult ? { result: projectedResult } : {}),
557
+ ...(usage ? {
558
+ usage: {
559
+ input: usage.input,
560
+ output: usage.output,
561
+ cacheRead: usage.cacheRead,
562
+ cacheWrite: usage.cacheWrite,
563
+ cost: usage.cost,
564
+ turns: usage.turns,
565
+ toolCalls: progress?.toolCount ?? 0,
566
+ durationMs: progress?.durationMs ?? 0,
567
+ },
568
+ } : {}),
569
+ };
570
+ }
571
+
425
572
  export function toPromptTemplateResponse(
426
573
  request: PromptTemplateDelegationRequest,
427
574
  result: PromptTemplateBridgeResult,
@@ -0,0 +1,108 @@
1
+ const MAX_JSON_DEPTH = 64;
2
+ const MAX_JSON_ENTRIES = 100_000;
3
+
4
+ export type BoundedJsonClone =
5
+ | { ok: true; value: unknown; encodedBytes: number }
6
+ | { ok: false; reason: "invalid" | "too_large" };
7
+
8
+ /** Clone plain JSON data without invoking getters or toJSON hooks. */
9
+ export function cloneJsonWithinByteLimit(input: unknown, maxBytes: number): BoundedJsonClone {
10
+ let minimumBytes = 0;
11
+ let entries = 0;
12
+ const active = new WeakSet<object>();
13
+
14
+ const addMinimumBytes = (bytes: number): void => {
15
+ minimumBytes += bytes;
16
+ if (minimumBytes > maxBytes) throw new RangeError("too_large");
17
+ };
18
+
19
+ const visit = (value: unknown, depth: number): unknown => {
20
+ if (depth > MAX_JSON_DEPTH) throw new TypeError("invalid");
21
+ if (value === null) {
22
+ addMinimumBytes(4);
23
+ return null;
24
+ }
25
+ if (typeof value === "boolean") {
26
+ addMinimumBytes(value ? 4 : 5);
27
+ return value;
28
+ }
29
+ if (typeof value === "number") {
30
+ if (!Number.isFinite(value)) throw new TypeError("invalid");
31
+ const encoded = JSON.stringify(Object.is(value, -0) ? 0 : value);
32
+ addMinimumBytes(Buffer.byteLength(encoded, "utf8"));
33
+ return Object.is(value, -0) ? 0 : value;
34
+ }
35
+ if (typeof value === "string") {
36
+ // Quoting and escaping can only increase the encoded size.
37
+ addMinimumBytes(Buffer.byteLength(value, "utf8") + 2);
38
+ return value;
39
+ }
40
+ if (typeof value !== "object") throw new TypeError("invalid");
41
+
42
+ const object = value as object;
43
+ if (active.has(object)) throw new TypeError("invalid");
44
+ active.add(object);
45
+ try {
46
+ const prototype = Object.getPrototypeOf(object);
47
+ const keys = Reflect.ownKeys(object);
48
+ if (Array.isArray(object)) {
49
+ if (prototype !== Array.prototype) throw new TypeError("invalid");
50
+ const lengthDescriptor = Object.getOwnPropertyDescriptor(object, "length");
51
+ if (!lengthDescriptor || !("value" in lengthDescriptor) || !Number.isSafeInteger(lengthDescriptor.value) || lengthDescriptor.value < 0) {
52
+ throw new TypeError("invalid");
53
+ }
54
+ const length = lengthDescriptor.value as number;
55
+ addMinimumBytes(2 + Math.max(0, length - 1));
56
+ const output: unknown[] = [];
57
+ for (const key of keys) {
58
+ if (typeof key === "symbol") throw new TypeError("invalid");
59
+ if (key === "length") continue;
60
+ if (!/^(?:0|[1-9][0-9]*)$/.test(key) || Number(key) >= length) throw new TypeError("invalid");
61
+ }
62
+ for (let index = 0; index < length; index++) {
63
+ const descriptor = Object.getOwnPropertyDescriptor(object, String(index));
64
+ if (!descriptor || !("value" in descriptor) || !descriptor.enumerable) throw new TypeError("invalid");
65
+ entries++;
66
+ if (entries > MAX_JSON_ENTRIES) throw new TypeError("invalid");
67
+ output.push(visit(descriptor.value, depth + 1));
68
+ }
69
+ return output;
70
+ }
71
+
72
+ if (prototype !== Object.prototype && prototype !== null) throw new TypeError("invalid");
73
+ addMinimumBytes(2 + Math.max(0, keys.length - 1));
74
+ const output: Record<string, unknown> = {};
75
+ for (const key of keys) {
76
+ if (typeof key === "symbol") throw new TypeError("invalid");
77
+ const descriptor = Object.getOwnPropertyDescriptor(object, key);
78
+ if (!descriptor || !("value" in descriptor) || !descriptor.enumerable) throw new TypeError("invalid");
79
+ entries++;
80
+ if (entries > MAX_JSON_ENTRIES) throw new TypeError("invalid");
81
+ addMinimumBytes(Buffer.byteLength(JSON.stringify(key), "utf8") + 1);
82
+ Object.defineProperty(output, key, {
83
+ value: visit(descriptor.value, depth + 1),
84
+ enumerable: true,
85
+ configurable: true,
86
+ writable: true,
87
+ });
88
+ }
89
+ return output;
90
+ } finally {
91
+ active.delete(object);
92
+ }
93
+ };
94
+
95
+ try {
96
+ const value = visit(input, 0);
97
+ const encoded = JSON.stringify(value);
98
+ if (encoded === undefined) return { ok: false, reason: "invalid" };
99
+ const encodedBytes = Buffer.byteLength(encoded, "utf8");
100
+ if (encodedBytes > maxBytes) return { ok: false, reason: "too_large" };
101
+ return { ok: true, value, encodedBytes };
102
+ } catch (error) {
103
+ return {
104
+ ok: false,
105
+ reason: error instanceof RangeError && error.message === "too_large" ? "too_large" : "invalid",
106
+ };
107
+ }
108
+ }
@@ -1,16 +1,19 @@
1
1
  import {
2
2
  SUBAGENT_DELEGATION_PROTOCOL_VERSION,
3
+ SUBAGENT_DELEGATION_V2_PROTOCOL_VERSION,
3
4
  type SubagentDelegationRequest,
5
+ type SubagentDelegationV2Request,
4
6
  } from "../api/delegation.ts";
5
7
  import { validateAcceptanceInput } from "../runs/shared/acceptance.ts";
6
8
  import { validateToolBudgetConfig } from "../runs/shared/tool-budget.ts";
7
9
  import { resolveTurnBudgetConfig } from "../runs/shared/turn-budget.ts";
10
+ import { cloneJsonWithinByteLimit } from "./delegation-json.ts";
8
11
 
9
12
  export type SubagentDelegationParseResult =
10
- | { ok: true; request: SubagentDelegationRequest }
11
- | { ok: false; requestId?: string; error: string };
13
+ | { ok: true; request: SubagentDelegationRequest | SubagentDelegationV2Request }
14
+ | { ok: false; version?: 1 | 2; requestId?: string; ownerRunId?: string; nodeId?: string; error: string };
12
15
 
13
- const supportedFields = new Set([
16
+ const v1SupportedFields = new Set([
14
17
  "version",
15
18
  "requestId",
16
19
  "agent",
@@ -24,67 +27,178 @@ const supportedFields = new Set([
24
27
  "skill",
25
28
  "output",
26
29
  "outputMode",
30
+ "outputSchema",
31
+ "agentContract",
27
32
  "acceptance",
28
33
  "artifacts",
29
34
  ]);
30
35
 
36
+ const v2SupportedFields = new Set([
37
+ "version",
38
+ "requestId",
39
+ "ownerRunId",
40
+ "nodeId",
41
+ "agent",
42
+ "task",
43
+ "context",
44
+ "cwd",
45
+ "model",
46
+ "thinking",
47
+ "timeoutMs",
48
+ "turnBudget",
49
+ "toolBudget",
50
+ "skill",
51
+ "artifacts",
52
+ "result",
53
+ ]);
54
+
55
+ const v2ThinkingLevels = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
56
+ const MAX_SCHEMA_BYTES = 64 * 1024;
57
+ const MAX_V2_TASK_BYTES = 1024 * 1024;
58
+ const MAX_V2_CWD_BYTES = 32 * 1024;
59
+ const MAX_V2_SHORT_TEXT_BYTES = 1024;
60
+ const MAX_V2_SKILL_ENTRIES = 256;
61
+ const MAX_V2_SKILL_AGGREGATE_BYTES = 64 * 1024;
62
+
31
63
  function nonEmptyString(value: unknown): value is string {
32
64
  return typeof value === "string" && value.trim().length > 0;
33
65
  }
34
66
 
35
- function validateRequestId(value: unknown): string | undefined {
67
+ function validateId(value: unknown): string | undefined {
36
68
  if (!nonEmptyString(value) || value.length > 256 || /[\r\n]/.test(value)) return undefined;
37
69
  return value;
38
70
  }
39
71
 
40
- export function parseSubagentDelegationRequest(data: unknown): SubagentDelegationParseResult {
41
- if (!data || typeof data !== "object" || Array.isArray(data)) {
42
- return { ok: false, error: "Delegation request must be an object." };
43
- }
44
- const value = data as Record<string, unknown>;
45
- const requestId = validateRequestId(value.requestId);
46
- if (value.version !== SUBAGENT_DELEGATION_PROTOCOL_VERSION) {
47
- return {
48
- ...(requestId ? { requestId } : {}),
49
- ok: false,
50
- error: `Unsupported delegation protocol version: ${String(value.version)}.`,
51
- };
52
- }
53
- if (!requestId) {
54
- return { ok: false, error: "Delegation requestId must be a non-empty string of at most 256 characters without newlines." };
55
- }
56
- const unsupportedField = Object.keys(value).find((key) => !supportedFields.has(key));
57
- if (unsupportedField) return { ok: false, requestId, error: `Unsupported delegation field: ${unsupportedField}.` };
58
- if (!nonEmptyString(value.agent)) return { ok: false, requestId, error: "Delegation agent must be a non-empty string." };
59
- if (!nonEmptyString(value.task)) return { ok: false, requestId, error: "Delegation task must be a non-empty string." };
72
+ function validateSharedFields(
73
+ value: Record<string, unknown>,
74
+ identity: { requestId: string; version?: 1 | 2; ownerRunId?: string; nodeId?: string },
75
+ ): SubagentDelegationParseResult | undefined {
76
+ if (!nonEmptyString(value.agent)) return { ok: false, ...identity, error: "Delegation agent must be a non-empty string." };
77
+ if (!nonEmptyString(value.task)) return { ok: false, ...identity, error: "Delegation task must be a non-empty string." };
60
78
  if (value.context !== "fresh" && value.context !== "fork") {
61
- return { ok: false, requestId, error: "Delegation context must be fresh or fork." };
79
+ return { ok: false, ...identity, error: "Delegation context must be fresh or fork." };
62
80
  }
63
- if (!nonEmptyString(value.cwd)) return { ok: false, requestId, error: "Delegation cwd must be a non-empty string." };
81
+ if (!nonEmptyString(value.cwd)) return { ok: false, ...identity, error: "Delegation cwd must be a non-empty string." };
64
82
  if (value.model !== undefined && !nonEmptyString(value.model)) {
65
- return { ok: false, requestId, error: "model must be a non-empty string when provided." };
83
+ return { ok: false, ...identity, error: "model must be a non-empty string when provided." };
66
84
  }
67
85
  if (value.timeoutMs !== undefined && (typeof value.timeoutMs !== "number" || !Number.isInteger(value.timeoutMs) || value.timeoutMs < 1)) {
68
- return { ok: false, requestId, error: "timeoutMs must be an integer >= 1." };
86
+ return { ok: false, ...identity, error: "timeoutMs must be an integer >= 1." };
87
+ }
88
+ if (identity.version === SUBAGENT_DELEGATION_V2_PROTOCOL_VERSION && value.timeoutMs !== undefined && value.timeoutMs > 2_147_483_647) {
89
+ return { ok: false, ...identity, error: "timeoutMs must be <= 2147483647 for delegation v2." };
69
90
  }
70
91
  const turnBudget = resolveTurnBudgetConfig(value.turnBudget);
71
- if (turnBudget.error) return { ok: false, requestId, error: turnBudget.error };
92
+ if (turnBudget.error) return { ok: false, ...identity, error: turnBudget.error };
72
93
  if (value.toolBudget && typeof value.toolBudget === "object" && !Array.isArray(value.toolBudget)) {
73
94
  const unsupportedToolBudgetField = Object.keys(value.toolBudget).find((key) => key !== "soft" && key !== "hard" && key !== "block");
74
95
  if (unsupportedToolBudgetField) {
75
- return { ok: false, requestId, error: `toolBudget.${unsupportedToolBudgetField} is not supported.` };
96
+ return { ok: false, ...identity, error: `toolBudget.${unsupportedToolBudgetField} is not supported.` };
76
97
  }
77
98
  }
78
- const toolBudget = validateToolBudgetConfig(value.toolBudget);
79
- if (toolBudget.error) return { ok: false, requestId, error: toolBudget.error };
99
+ const toolBudget = validateToolBudgetConfig(
100
+ value.toolBudget,
101
+ "toolBudget",
102
+ identity.version === SUBAGENT_DELEGATION_V2_PROTOCOL_VERSION ? { minimumHard: 0 } : undefined,
103
+ );
104
+ if (toolBudget.error) return { ok: false, ...identity, error: toolBudget.error };
80
105
  if (value.skill !== undefined) {
81
106
  const validSkill = typeof value.skill === "boolean"
82
107
  || nonEmptyString(value.skill)
83
108
  || (Array.isArray(value.skill) && value.skill.length > 0 && value.skill.every(nonEmptyString));
84
109
  if (!validSkill) {
85
- return { ok: false, requestId, error: "skill must be a boolean, non-empty string, or non-empty string array." };
110
+ return { ok: false, ...identity, error: "skill must be a boolean, non-empty string, or non-empty string array." };
111
+ }
112
+ }
113
+ if (value.artifacts !== undefined && typeof value.artifacts !== "boolean") {
114
+ return { ok: false, ...identity, error: "artifacts must be a boolean." };
115
+ }
116
+ return undefined;
117
+ }
118
+
119
+ function parseV2(value: Record<string, unknown>, requestId: string): SubagentDelegationParseResult {
120
+ const ownerRunId = validateId(value.ownerRunId);
121
+ if (!ownerRunId) {
122
+ return { ok: false, version: 2, requestId, error: "Delegation ownerRunId must be a non-empty string of at most 256 characters without newlines." };
123
+ }
124
+ const nodeId = validateId(value.nodeId);
125
+ if (!nodeId) {
126
+ return { ok: false, version: 2, requestId, ownerRunId, error: "Delegation nodeId must be a non-empty string of at most 256 characters without newlines." };
127
+ }
128
+ const identity = { version: 2 as const, requestId, ownerRunId, nodeId };
129
+ const unsupportedField = Object.keys(value).find((key) => !v2SupportedFields.has(key));
130
+ if (unsupportedField) return { ok: false, ...identity, error: `Unsupported delegation field: ${unsupportedField}.` };
131
+ const sharedError = validateSharedFields(value, identity);
132
+ if (sharedError) return sharedError;
133
+ if (Buffer.byteLength(value.task as string, "utf8") > MAX_V2_TASK_BYTES) {
134
+ return { ok: false, ...identity, error: "Delegation task exceeds 1 MiB when UTF-8 encoded." };
135
+ }
136
+ if (Buffer.byteLength(value.cwd as string, "utf8") > MAX_V2_CWD_BYTES) {
137
+ return { ok: false, ...identity, error: "Delegation cwd exceeds 32 KiB when UTF-8 encoded." };
138
+ }
139
+ if (Buffer.byteLength(value.agent as string, "utf8") > MAX_V2_SHORT_TEXT_BYTES) {
140
+ return { ok: false, ...identity, error: "Delegation agent exceeds 1 KiB when UTF-8 encoded." };
141
+ }
142
+ if (typeof value.model === "string" && Buffer.byteLength(value.model, "utf8") > MAX_V2_SHORT_TEXT_BYTES) {
143
+ return { ok: false, ...identity, error: "Delegation model exceeds 1 KiB when UTF-8 encoded." };
144
+ }
145
+ const skillEntries = typeof value.skill === "string" ? [value.skill] : Array.isArray(value.skill) ? value.skill as string[] : [];
146
+ if (skillEntries.length > MAX_V2_SKILL_ENTRIES) {
147
+ return { ok: false, ...identity, error: "Delegation skill supports at most 256 entries." };
148
+ }
149
+ if (skillEntries.some((entry) => Buffer.byteLength(entry, "utf8") > MAX_V2_SHORT_TEXT_BYTES)) {
150
+ return { ok: false, ...identity, error: "Delegation skill entry exceeds 1 KiB when UTF-8 encoded." };
151
+ }
152
+ if (skillEntries.reduce((total, entry) => total + Buffer.byteLength(entry, "utf8"), 0) > MAX_V2_SKILL_AGGREGATE_BYTES) {
153
+ return { ok: false, ...identity, error: "Delegation skill entries exceed 64 KiB in aggregate when UTF-8 encoded." };
154
+ }
155
+ if (value.thinking !== undefined && (typeof value.thinking !== "string" || !v2ThinkingLevels.has(value.thinking))) {
156
+ return { ok: false, ...identity, error: "thinking must be one of off, minimal, low, medium, high, xhigh, or max." };
157
+ }
158
+ if (!value.result || typeof value.result !== "object" || Array.isArray(value.result)) {
159
+ return { ok: false, ...identity, error: "result must be { kind: \"text\" } or { kind: \"structured\", schema: object }." };
160
+ }
161
+ const result = value.result as Record<string, unknown>;
162
+ let structuredSchema: Record<string, unknown> | undefined;
163
+ if (result.kind === "text") {
164
+ const unsupportedResultField = Object.keys(result).find((key) => key !== "kind");
165
+ if (unsupportedResultField) return { ok: false, ...identity, error: `result.${unsupportedResultField} is not supported for text results.` };
166
+ } else if (result.kind === "structured") {
167
+ const unsupportedResultField = Object.keys(result).find((key) => key !== "kind" && key !== "schema");
168
+ if (unsupportedResultField) return { ok: false, ...identity, error: `result.${unsupportedResultField} is not supported for structured results.` };
169
+ if (!result.schema || typeof result.schema !== "object" || Array.isArray(result.schema)) {
170
+ return { ok: false, ...identity, error: "result.schema must be a JSON Schema object." };
171
+ }
172
+ const inspectedSchema = cloneJsonWithinByteLimit(result.schema, MAX_SCHEMA_BYTES);
173
+ if (!inspectedSchema.ok) {
174
+ return {
175
+ ok: false,
176
+ ...identity,
177
+ error: inspectedSchema.reason === "too_large"
178
+ ? "result.schema exceeds 64 KiB when encoded."
179
+ : "result.schema must be plain JSON data.",
180
+ };
86
181
  }
182
+ structuredSchema = inspectedSchema.value as Record<string, unknown>;
183
+ } else {
184
+ return { ok: false, ...identity, error: "result.kind must be text or structured." };
87
185
  }
186
+ return {
187
+ ok: true,
188
+ request: {
189
+ ...value,
190
+ result: structuredSchema
191
+ ? { kind: "structured", schema: structuredSchema }
192
+ : { kind: "text" },
193
+ } as unknown as SubagentDelegationV2Request,
194
+ };
195
+ }
196
+
197
+ function parseV1(value: Record<string, unknown>, requestId: string): SubagentDelegationParseResult {
198
+ const unsupportedField = Object.keys(value).find((key) => !v1SupportedFields.has(key));
199
+ if (unsupportedField) return { ok: false, requestId, error: `Unsupported delegation field: ${unsupportedField}.` };
200
+ const sharedError = validateSharedFields(value, { requestId });
201
+ if (sharedError) return sharedError;
88
202
  if (value.output !== undefined && typeof value.output !== "boolean" && !nonEmptyString(value.output)) {
89
203
  return { ok: false, requestId, error: "output must be a boolean or non-empty string." };
90
204
  }
@@ -94,10 +208,42 @@ export function parseSubagentDelegationRequest(data: unknown): SubagentDelegatio
94
208
  if (value.outputMode === "file-only" && !nonEmptyString(value.output)) {
95
209
  return { ok: false, requestId, error: 'outputMode "file-only" requires output to be a non-empty path.' };
96
210
  }
211
+ if (value.outputSchema !== undefined && (!value.outputSchema || typeof value.outputSchema !== "object" || Array.isArray(value.outputSchema))) {
212
+ return { ok: false, requestId, error: "outputSchema must be a JSON Schema object." };
213
+ }
214
+ if (value.agentContract !== undefined) {
215
+ if (!value.agentContract || typeof value.agentContract !== "object" || Array.isArray(value.agentContract)) return { ok: false, requestId, error: "agentContract must be an object." };
216
+ const contract = value.agentContract as Record<string, unknown>;
217
+ if (contract.version !== 1 || Object.keys(contract).some((key) => key !== "version")) return { ok: false, requestId, error: "agentContract must be { version: 1 }." };
218
+ }
97
219
  const acceptanceErrors = validateAcceptanceInput(value.acceptance);
98
220
  if (acceptanceErrors.length > 0) return { ok: false, requestId, error: acceptanceErrors.join(" ") };
99
- if (value.artifacts !== undefined && typeof value.artifacts !== "boolean") {
100
- return { ok: false, requestId, error: "artifacts must be a boolean." };
101
- }
102
221
  return { ok: true, request: value as unknown as SubagentDelegationRequest };
103
222
  }
223
+
224
+ export function parseSubagentDelegationRequest(data: unknown): SubagentDelegationParseResult {
225
+ if (!data || typeof data !== "object" || Array.isArray(data)) {
226
+ return { ok: false, error: "Delegation request must be an object." };
227
+ }
228
+ const value = data as Record<string, unknown>;
229
+ const requestId = validateId(value.requestId);
230
+ if (value.version !== SUBAGENT_DELEGATION_PROTOCOL_VERSION && value.version !== SUBAGENT_DELEGATION_V2_PROTOCOL_VERSION) {
231
+ return {
232
+ ...(requestId ? { requestId } : {}),
233
+ ok: false,
234
+ error: `Unsupported delegation protocol version: ${String(value.version)}.`,
235
+ };
236
+ }
237
+ if (!requestId) {
238
+ return { ok: false, error: "Delegation requestId must be a non-empty string of at most 256 characters without newlines." };
239
+ }
240
+ if (value.version === SUBAGENT_DELEGATION_V2_PROTOCOL_VERSION) {
241
+ // Preserve the v1 parser's historical diagnostic for a v1-shaped payload
242
+ // whose version alone was changed to 2.
243
+ if (!Object.hasOwn(value, "ownerRunId") && !Object.hasOwn(value, "nodeId") && !Object.hasOwn(value, "result")) {
244
+ return { ok: false, version: 2, requestId, error: "Unsupported delegation protocol version: 2." };
245
+ }
246
+ return parseV2(value, requestId);
247
+ }
248
+ return parseV1(value, requestId);
249
+ }