@bacnh85/pi-subagent 0.10.1 → 0.12.2

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.
@@ -14,13 +14,13 @@ import * as path from "node:path";
14
14
 
15
15
  /** Tools that child agents may use. The subagent tool is never included. */
16
16
  export const ALLOWED_CHILD_TOOLS = [
17
- "read",
18
- "grep",
19
- "find",
20
- "ls",
21
- "bash",
22
- "edit",
23
- "write",
17
+ "read",
18
+ "grep",
19
+ "find",
20
+ "ls",
21
+ "bash",
22
+ "edit",
23
+ "write",
24
24
  ] as const;
25
25
 
26
26
  export const READ_ONLY_TOOLS: readonly string[] = ["read", "grep", "find", "ls"];
@@ -53,11 +53,11 @@ const PARTIAL_REASONS = new Set(["length", "max_tokens", "context_limit"]);
53
53
 
54
54
  /** Pi SDK stop reasons that indicate a provider or tool error. */
55
55
  const ERROR_REASONS = new Set([
56
- "error",
57
- "tool_error",
58
- "authentication_error",
59
- "provider_error",
60
- "content_filter",
56
+ "error",
57
+ "tool_error",
58
+ "authentication_error",
59
+ "provider_error",
60
+ "content_filter",
61
61
  ]);
62
62
 
63
63
  /**
@@ -68,18 +68,18 @@ const ERROR_REASONS = new Set([
68
68
  * from their own abort controllers.
69
69
  */
70
70
  export function classifyStopReason(
71
- reason: string | undefined,
72
- isAborted: boolean,
73
- isTimeout: boolean,
71
+ reason: string | undefined,
72
+ isAborted: boolean,
73
+ isTimeout: boolean,
74
74
  ): SubagentStatus {
75
- if (isAborted) return "aborted";
76
- if (isTimeout) return "timeout";
77
- if (!reason) return "success";
78
- if (SUCCESS_REASONS.has(reason)) return "success";
79
- if (PARTIAL_REASONS.has(reason)) return "partial";
80
- if (ERROR_REASONS.has(reason)) return "error";
81
- // Unknown stop reason — classify conservatively.
82
- return "error";
75
+ if (isAborted) return "aborted";
76
+ if (isTimeout) return "timeout";
77
+ if (!reason) return "success";
78
+ if (SUCCESS_REASONS.has(reason)) return "success";
79
+ if (PARTIAL_REASONS.has(reason)) return "partial";
80
+ if (ERROR_REASONS.has(reason)) return "error";
81
+ // Unknown stop reason — classify conservatively.
82
+ return "error";
83
83
  }
84
84
 
85
85
  // ---------------------------------------------------------------------------
@@ -87,20 +87,20 @@ export function classifyStopReason(
87
87
  // ---------------------------------------------------------------------------
88
88
 
89
89
  export interface SafeCwdOptions {
90
- /** The workspace root (parent session's cwd). Must be an absolute path. */
91
- workspaceRoot: string;
92
- /** The child's requested working directory, if any. */
93
- childCwd?: string;
94
- /**
95
- * Trusted user setting that allows child cwd outside the workspace.
96
- * Must never come from the tool-calling model.
97
- */
98
- allowExternalCwd?: boolean;
90
+ /** The workspace root (parent session's cwd). Must be an absolute path. */
91
+ workspaceRoot: string;
92
+ /** The child's requested working directory, if any. */
93
+ childCwd?: string;
94
+ /**
95
+ * Trusted user setting that allows child cwd outside the workspace.
96
+ * Must never come from the tool-calling model.
97
+ */
98
+ allowExternalCwd?: boolean;
99
99
  }
100
100
 
101
101
  export interface SafeCwdResult {
102
- path: string;
103
- error?: string;
102
+ path: string;
103
+ error?: string;
104
104
  }
105
105
 
106
106
  /**
@@ -117,51 +117,51 @@ export interface SafeCwdResult {
117
117
  * - Paths that are files instead of directories are rejected.
118
118
  */
119
119
  export function resolveSafeCwd(options: SafeCwdOptions): SafeCwdResult {
120
- const { workspaceRoot, childCwd, allowExternalCwd } = options;
121
-
122
- // Normalise workspace root to an absolute canonical path.
123
- const resolvedRoot = resolveCanonical(workspaceRoot);
124
- if (!resolvedRoot) {
125
- return { path: "", error: `Workspace root does not exist: ${workspaceRoot}` };
126
- }
127
- if (!isDirectorySync(resolvedRoot)) {
128
- return { path: "", error: `Workspace root is not a directory: ${workspaceRoot}` };
129
- }
130
-
131
- // No child cwd → use workspace root.
132
- if (!childCwd) {
133
- return { path: resolvedRoot };
134
- }
135
-
136
- // Resolve the child path.
137
- const absPath = path.resolve(resolvedRoot, childCwd);
138
-
139
- // If the resolved path escapes the workspace via `..`, the resolved path
140
- // will differ from the canonical workspace root prefix. We check by
141
- // resolving the canonical absolute path.
142
- const canonicalPath = resolveCanonical(absPath);
143
- if (!canonicalPath) {
144
- return { path: "", error: `Child working directory does not exist: ${childCwd}` };
145
- }
146
-
147
- if (!isDirectorySync(canonicalPath)) {
148
- return { path: "", error: `Child working directory is a file, not a directory: ${childCwd}` };
149
- }
150
-
151
- // Check if the child path is inside the workspace.
152
- if (!isPathInside(canonicalPath, resolvedRoot)) {
153
- if (allowExternalCwd) {
154
- return { path: canonicalPath };
155
- }
156
- return {
157
- path: "",
158
- error:
159
- `Child working directory "${childCwd}" is outside the workspace root "${workspaceRoot}". ` +
160
- `Paths outside the workspace are rejected by default.`,
161
- };
162
- }
163
-
164
- return { path: canonicalPath };
120
+ const { workspaceRoot, childCwd, allowExternalCwd } = options;
121
+
122
+ // Normalise workspace root to an absolute canonical path.
123
+ const resolvedRoot = resolveCanonical(workspaceRoot);
124
+ if (!resolvedRoot) {
125
+ return { path: "", error: `Workspace root does not exist: ${workspaceRoot}` };
126
+ }
127
+ if (!isDirectorySync(resolvedRoot)) {
128
+ return { path: "", error: `Workspace root is not a directory: ${workspaceRoot}` };
129
+ }
130
+
131
+ // No child cwd → use workspace root.
132
+ if (!childCwd) {
133
+ return { path: resolvedRoot };
134
+ }
135
+
136
+ // Resolve the child path.
137
+ const absPath = path.resolve(resolvedRoot, childCwd);
138
+
139
+ // If the resolved path escapes the workspace via `..`, the resolved path
140
+ // will differ from the canonical workspace root prefix. We check by
141
+ // resolving the canonical absolute path.
142
+ const canonicalPath = resolveCanonical(absPath);
143
+ if (!canonicalPath) {
144
+ return { path: "", error: `Child working directory does not exist: ${childCwd}` };
145
+ }
146
+
147
+ if (!isDirectorySync(canonicalPath)) {
148
+ return { path: "", error: `Child working directory is a file, not a directory: ${childCwd}` };
149
+ }
150
+
151
+ // Check if the child path is inside the workspace.
152
+ if (!isPathInside(canonicalPath, resolvedRoot)) {
153
+ if (allowExternalCwd) {
154
+ return { path: canonicalPath };
155
+ }
156
+ return {
157
+ path: "",
158
+ error:
159
+ `Child working directory "${childCwd}" is outside the workspace root "${workspaceRoot}". ` +
160
+ `Paths outside the workspace are rejected by default.`,
161
+ };
162
+ }
163
+
164
+ return { path: canonicalPath };
165
165
  }
166
166
 
167
167
  /**
@@ -169,34 +169,34 @@ export function resolveSafeCwd(options: SafeCwdOptions): SafeCwdResult {
169
169
  * to canonical paths. Rejects `..` traversal that escapes `parent`.
170
170
  */
171
171
  function isPathInside(child: string, parent: string): boolean {
172
- // Both must be absolute.
173
- if (!path.isAbsolute(child) || !path.isAbsolute(parent)) return false;
172
+ // Both must be absolute.
173
+ if (!path.isAbsolute(child) || !path.isAbsolute(parent)) return false;
174
174
 
175
- const relative = path.relative(parent, child);
176
- // relative must not start with ".." and must not be an absolute path.
177
- return !relative.startsWith("..") && !path.isAbsolute(relative);
175
+ const relative = path.relative(parent, child);
176
+ // relative must not start with ".." and must not be an absolute path.
177
+ return !relative.startsWith("..") && !path.isAbsolute(relative);
178
178
  }
179
179
 
180
180
  /**
181
181
  * Resolve a path to its canonical real path, or return null if it doesn't exist.
182
182
  */
183
183
  function resolveCanonical(p: string): string | null {
184
- try {
185
- return fs.realpathSync(p);
186
- } catch {
187
- return null;
188
- }
184
+ try {
185
+ return fs.realpathSync(p);
186
+ } catch {
187
+ return null;
188
+ }
189
189
  }
190
190
 
191
191
  /**
192
192
  * Synchronously check if a path is a directory.
193
193
  */
194
194
  function isDirectorySync(p: string): boolean {
195
- try {
196
- return fs.statSync(p).isDirectory();
197
- } catch {
198
- return false;
199
- }
195
+ try {
196
+ return fs.statSync(p).isDirectory();
197
+ } catch {
198
+ return false;
199
+ }
200
200
  }
201
201
 
202
202
  // ---------------------------------------------------------------------------
@@ -204,17 +204,17 @@ function isDirectorySync(p: string): boolean {
204
204
  // ---------------------------------------------------------------------------
205
205
 
206
206
  export interface ValidateToolsOptions {
207
- /** Tool names from the agent definition or service override. */
208
- tools: string[];
209
- /** When true, only read-only tools are permitted. Mutation/execution tools are rejected. */
210
- readOnly?: boolean;
207
+ /** Tool names from the agent definition or service override. */
208
+ tools: string[];
209
+ /** When true, only read-only tools are permitted. Mutation/execution tools are rejected. */
210
+ readOnly?: boolean;
211
211
  }
212
212
 
213
213
  export interface ValidateToolsResult {
214
- /** Deduplicated and validated tool names. */
215
- tools: string[];
216
- /** Validation errors, if any. Empty array means valid. */
217
- errors: string[];
214
+ /** Deduplicated and validated tool names. */
215
+ tools: string[];
216
+ /** Validation errors, if any. Empty array means valid. */
217
+ errors: string[];
218
218
  }
219
219
 
220
220
  /**
@@ -225,43 +225,43 @@ export interface ValidateToolsResult {
225
225
  * When `readOnly` is true, only READ_ONLY_TOOLS are permitted.
226
226
  */
227
227
  export function validateAgentTools(options: ValidateToolsOptions): ValidateToolsResult {
228
- const { readOnly = false } = options;
229
- const seen = new Set<string>();
230
- const tools: string[] = [];
231
- const errors: string[] = [];
232
-
233
- for (const raw of options.tools) {
234
- const tool = raw.trim();
235
- if (!tool) continue;
236
-
237
- // Reject subagent regardless of casing (Pi tool names are case-sensitive
238
- // but "subagent" should never pass through).
239
- if (tool.toLowerCase() === "subagent") {
240
- errors.push(`Tool "subagent" is not allowed in child agents (recursive delegation is prevented).`);
241
- continue;
242
- }
243
-
244
- // Check against allowlist
245
- if (!ALLOWED_CHILD_TOOLS.includes(tool as any)) {
246
- errors.push(`Unknown tool "${tool}". Allowed tools: ${ALLOWED_CHILD_TOOLS.join(", ")}.`);
247
- continue;
248
- }
249
-
250
- // Check read-only constraint
251
- if (readOnly && !READ_ONLY_TOOLS.includes(tool)) {
252
- errors.push(
253
- `Tool "${tool}" is not allowed in read-only mode. Read-only tools: ${READ_ONLY_TOOLS.join(", ")}.`,
254
- );
255
- continue;
256
- }
257
-
258
- // Deduplicate
259
- if (seen.has(tool)) continue;
260
- seen.add(tool);
261
- tools.push(tool);
262
- }
263
-
264
- return { tools, errors };
228
+ const { readOnly = false } = options;
229
+ const seen = new Set<string>();
230
+ const tools: string[] = [];
231
+ const errors: string[] = [];
232
+
233
+ for (const raw of options.tools) {
234
+ const tool = raw.trim();
235
+ if (!tool) continue;
236
+
237
+ // Reject subagent regardless of casing (Pi tool names are case-sensitive
238
+ // but "subagent" should never pass through).
239
+ if (tool.toLowerCase() === "subagent") {
240
+ errors.push(`Tool "subagent" is not allowed in child agents (recursive delegation is prevented).`);
241
+ continue;
242
+ }
243
+
244
+ // Check against allowlist
245
+ if (!ALLOWED_CHILD_TOOLS.includes(tool as any)) {
246
+ errors.push(`Unknown tool "${tool}". Allowed tools: ${ALLOWED_CHILD_TOOLS.join(", ")}.`);
247
+ continue;
248
+ }
249
+
250
+ // Check read-only constraint
251
+ if (readOnly && !READ_ONLY_TOOLS.includes(tool)) {
252
+ errors.push(
253
+ `Tool "${tool}" is not allowed in read-only mode. Read-only tools: ${READ_ONLY_TOOLS.join(", ")}.`,
254
+ );
255
+ continue;
256
+ }
257
+
258
+ // Deduplicate
259
+ if (seen.has(tool)) continue;
260
+ seen.add(tool);
261
+ tools.push(tool);
262
+ }
263
+
264
+ return { tools, errors };
265
265
  }
266
266
 
267
267
  // ---------------------------------------------------------------------------
@@ -269,19 +269,19 @@ export function validateAgentTools(options: ValidateToolsOptions): ValidateTools
269
269
  // ---------------------------------------------------------------------------
270
270
 
271
271
  export interface NormalizeTimeoutOptions {
272
- /** Requested timeout value from tool params, if any. */
273
- requested?: number;
274
- /** Global default when no timeout is specified. */
275
- defaultValue?: number;
276
- /** Absolute maximum allowed value. */
277
- maxValue?: number;
272
+ /** Requested timeout value from tool params, if any. */
273
+ requested?: number;
274
+ /** Global default when no timeout is specified. */
275
+ defaultValue?: number;
276
+ /** Absolute maximum allowed value. */
277
+ maxValue?: number;
278
278
  }
279
279
 
280
280
  export interface NormalizeTimeoutResult {
281
- /** The validated timeout in milliseconds, or undefined if none was set and no default applies. */
282
- timeoutMs: number | undefined;
283
- /** Error message if the value is invalid. */
284
- error?: string;
281
+ /** The validated timeout in milliseconds, or undefined if none was set and no default applies. */
282
+ timeoutMs: number | undefined;
283
+ /** Error message if the value is invalid. */
284
+ error?: string;
285
285
  }
286
286
 
287
287
  /**
@@ -295,33 +295,33 @@ export interface NormalizeTimeoutResult {
295
295
  * - Returns undefined only when no value and no default are configured.
296
296
  */
297
297
  export function normalizeTimeout(options: NormalizeTimeoutOptions): NormalizeTimeoutResult {
298
- const { requested, defaultValue = DEFAULT_TIMEOUT_MS, maxValue = MAX_TIMEOUT_MS } = options;
298
+ const { requested, defaultValue = DEFAULT_TIMEOUT_MS, maxValue = MAX_TIMEOUT_MS } = options;
299
299
 
300
- if (requested === undefined || requested === null) {
301
- // No explicit timeout — apply default.
302
- return { timeoutMs: defaultValue };
303
- }
300
+ if (requested === undefined || requested === null) {
301
+ // No explicit timeout — apply default.
302
+ return { timeoutMs: defaultValue };
303
+ }
304
304
 
305
- if (typeof requested !== "number" || !Number.isFinite(requested)) {
306
- return { timeoutMs: undefined, error: "Timeout must be a finite number." };
307
- }
305
+ if (typeof requested !== "number" || !Number.isFinite(requested)) {
306
+ return { timeoutMs: undefined, error: "Timeout must be a finite number." };
307
+ }
308
308
 
309
- if (!Number.isInteger(requested)) {
310
- return { timeoutMs: undefined, error: "Timeout must be an integer (milliseconds)." };
311
- }
309
+ if (!Number.isInteger(requested)) {
310
+ return { timeoutMs: undefined, error: "Timeout must be an integer (milliseconds)." };
311
+ }
312
312
 
313
- if (requested <= 0) {
314
- return { timeoutMs: undefined, error: "Timeout must be a positive integer." };
315
- }
313
+ if (requested <= 0) {
314
+ return { timeoutMs: undefined, error: "Timeout must be a positive integer." };
315
+ }
316
316
 
317
- if (requested > maxValue) {
318
- return {
319
- timeoutMs: undefined,
320
- error: `Timeout ${requested}ms exceeds maximum allowed ${maxValue}ms (${maxValue / 60_000} minutes).`,
321
- };
322
- }
317
+ if (requested > maxValue) {
318
+ return {
319
+ timeoutMs: undefined,
320
+ error: `Timeout ${requested}ms exceeds maximum allowed ${maxValue}ms (${maxValue / 60_000} minutes).`,
321
+ };
322
+ }
323
323
 
324
- return { timeoutMs: requested };
324
+ return { timeoutMs: requested };
325
325
  }
326
326
 
327
327
  // ---------------------------------------------------------------------------
@@ -339,57 +339,57 @@ export function normalizeTimeout(options: NormalizeTimeoutOptions): NormalizeTim
339
339
  * Callers MUST call `cleanup()` in a `finally` block.
340
340
  */
341
341
  export function createCombinedAbortSignal(
342
- signals: (AbortSignal | undefined | null | false)[],
342
+ signals: (AbortSignal | undefined | null | false)[],
343
343
  ): { signal: AbortSignal; cleanup: () => void } {
344
- const valid = signals.filter(Boolean) as AbortSignal[];
345
-
346
- if (valid.length === 0) {
347
- const controller = new AbortController();
348
- return { signal: controller.signal, cleanup: () => {} };
349
- }
350
-
351
- if (valid.length === 1) {
352
- return { signal: valid[0], cleanup: () => {} };
353
- }
354
-
355
- // Check if any is already aborted.
356
- for (const sig of valid) {
357
- if (sig.aborted) {
358
- const controller = new AbortController();
359
- controller.abort(sig.reason);
360
- return { signal: controller.signal, cleanup: () => {} };
361
- }
362
- }
363
-
364
- // Use native AbortSignal.any when available.
365
- if (typeof (AbortSignal as any).any === "function") {
366
- const combined = (AbortSignal as any).any(valid);
367
- return { signal: combined, cleanup: () => {} };
368
- }
369
-
370
- // Manual fallback: create a controller and forward all signals.
371
- const controller = new AbortController();
372
- const listeners: Array<() => void> = [];
373
-
374
- for (const sig of valid) {
375
- const handler = () => {
376
- controller.abort(sig.reason);
377
- };
378
- sig.addEventListener("abort", handler, { once: true });
379
- listeners.push(() => sig.removeEventListener("abort", handler));
380
- }
381
-
382
- const cleanup = () => {
383
- for (const remove of listeners) {
384
- try {
385
- remove();
386
- } catch {
387
- // Best-effort cleanup.
388
- }
389
- }
390
- };
391
-
392
- return { signal: controller.signal, cleanup };
344
+ const valid = signals.filter(Boolean) as AbortSignal[];
345
+
346
+ if (valid.length === 0) {
347
+ const controller = new AbortController();
348
+ return { signal: controller.signal, cleanup: () => {} };
349
+ }
350
+
351
+ if (valid.length === 1) {
352
+ return { signal: valid[0], cleanup: () => {} };
353
+ }
354
+
355
+ // Check if any is already aborted.
356
+ for (const sig of valid) {
357
+ if (sig.aborted) {
358
+ const controller = new AbortController();
359
+ controller.abort(sig.reason);
360
+ return { signal: controller.signal, cleanup: () => {} };
361
+ }
362
+ }
363
+
364
+ // Use native AbortSignal.any when available.
365
+ if (typeof (AbortSignal as any).any === "function") {
366
+ const combined = (AbortSignal as any).any(valid);
367
+ return { signal: combined, cleanup: () => {} };
368
+ }
369
+
370
+ // Manual fallback: create a controller and forward all signals.
371
+ const controller = new AbortController();
372
+ const listeners: Array<() => void> = [];
373
+
374
+ for (const sig of valid) {
375
+ const handler = () => {
376
+ controller.abort(sig.reason);
377
+ };
378
+ sig.addEventListener("abort", handler, { once: true });
379
+ listeners.push(() => sig.removeEventListener("abort", handler));
380
+ }
381
+
382
+ const cleanup = () => {
383
+ for (const remove of listeners) {
384
+ try {
385
+ remove();
386
+ } catch {
387
+ // Best-effort cleanup.
388
+ }
389
+ }
390
+ };
391
+
392
+ return { signal: controller.signal, cleanup };
393
393
  }
394
394
 
395
395
  // ---------------------------------------------------------------------------
@@ -403,16 +403,16 @@ export const PER_TASK_OUTPUT_CAP = 50 * 1024; // 50 KB
403
403
  export const MAX_INSTRUCTIONS_LENGTH = 16 * 1024; // 16 KB
404
404
 
405
405
  export interface ValidateExecutionRequestOptions {
406
- agentName?: string;
407
- task?: string;
408
- tasks?: unknown[];
409
- chain?: unknown[];
410
- timeout?: number;
406
+ agentName?: string;
407
+ task?: string;
408
+ tasks?: unknown[];
409
+ chain?: unknown[];
410
+ timeout?: number;
411
411
  }
412
412
 
413
413
  export interface ValidationError {
414
- field: string;
415
- message: string;
414
+ field: string;
415
+ message: string;
416
416
  }
417
417
 
418
418
  /**
@@ -421,85 +421,85 @@ export interface ValidationError {
421
421
  * internal callers that bypass the public tool schema.
422
422
  */
423
423
  export function validateExecutionRequest(
424
- options: ValidateExecutionRequestOptions,
424
+ options: ValidateExecutionRequestOptions,
425
425
  ): ValidationError[] {
426
- const errors: ValidationError[] = [];
427
-
428
- // Agent name
429
- if (options.agentName !== undefined) {
430
- if (typeof options.agentName !== "string" || options.agentName.trim().length === 0) {
431
- errors.push({ field: "agent", message: "Agent name must be a non-empty string." });
432
- }
433
- }
434
-
435
- // Task
436
- if (options.task !== undefined) {
437
- if (typeof options.task !== "string") {
438
- errors.push({ field: "task", message: "Task must be a string." });
439
- }
440
- }
441
-
442
- // Parallel tasks
443
- if (options.tasks !== undefined) {
444
- if (!Array.isArray(options.tasks)) {
445
- errors.push({ field: "tasks", message: "Tasks must be an array." });
446
- } else {
447
- if (options.tasks.length > MAX_PARALLEL_TASKS) {
448
- errors.push({
449
- field: "tasks",
450
- message: `Too many parallel tasks (${options.tasks.length}). Maximum is ${MAX_PARALLEL_TASKS}.`,
451
- });
452
- }
453
- for (let i = 0; i < options.tasks.length; i++) {
454
- const t = options.tasks[i] as Record<string, unknown>;
455
- if (!t || typeof t.agent !== "string" || !t.agent.trim()) {
456
- errors.push({ field: `tasks[${i}].agent`, message: "Agent name must be a non-empty string." });
457
- }
458
- if (typeof t.task !== "string") {
459
- errors.push({ field: `tasks[${i}].task`, message: "Task must be a string." });
460
- }
461
- }
462
- }
463
- }
464
-
465
- // Chain
466
- if (options.chain !== undefined) {
467
- if (!Array.isArray(options.chain)) {
468
- errors.push({ field: "chain", message: "Chain must be an array." });
469
- } else {
470
- if (options.chain.length > MAX_CHAIN_LENGTH) {
471
- errors.push({
472
- field: "chain",
473
- message: `Too many chain steps (${options.chain.length}). Maximum is ${MAX_CHAIN_LENGTH}.`,
474
- });
475
- }
476
- for (let i = 0; i < options.chain.length; i++) {
477
- const s = options.chain[i] as Record<string, unknown>;
478
- if (!s || typeof s.agent !== "string" || !s.agent.trim()) {
479
- errors.push({ field: `chain[${i}].agent`, message: "Agent name must be a non-empty string." });
480
- }
481
- if (typeof s.task !== "string") {
482
- errors.push({ field: `chain[${i}].task`, message: "Task must be a string." });
483
- }
484
- }
485
- }
486
- }
487
-
488
- return errors;
426
+ const errors: ValidationError[] = [];
427
+
428
+ // Agent name
429
+ if (options.agentName !== undefined) {
430
+ if (typeof options.agentName !== "string" || options.agentName.trim().length === 0) {
431
+ errors.push({ field: "agent", message: "Agent name must be a non-empty string." });
432
+ }
433
+ }
434
+
435
+ // Task
436
+ if (options.task !== undefined) {
437
+ if (typeof options.task !== "string") {
438
+ errors.push({ field: "task", message: "Task must be a string." });
439
+ }
440
+ }
441
+
442
+ // Parallel tasks
443
+ if (options.tasks !== undefined) {
444
+ if (!Array.isArray(options.tasks)) {
445
+ errors.push({ field: "tasks", message: "Tasks must be an array." });
446
+ } else {
447
+ if (options.tasks.length > MAX_PARALLEL_TASKS) {
448
+ errors.push({
449
+ field: "tasks",
450
+ message: `Too many parallel tasks (${options.tasks.length}). Maximum is ${MAX_PARALLEL_TASKS}.`,
451
+ });
452
+ }
453
+ for (let i = 0; i < options.tasks.length; i++) {
454
+ const t = options.tasks[i] as Record<string, unknown>;
455
+ if (!t || typeof t.agent !== "string" || !t.agent.trim()) {
456
+ errors.push({ field: `tasks[${i}].agent`, message: "Agent name must be a non-empty string." });
457
+ }
458
+ if (typeof t.task !== "string") {
459
+ errors.push({ field: `tasks[${i}].task`, message: "Task must be a string." });
460
+ }
461
+ }
462
+ }
463
+ }
464
+
465
+ // Chain
466
+ if (options.chain !== undefined) {
467
+ if (!Array.isArray(options.chain)) {
468
+ errors.push({ field: "chain", message: "Chain must be an array." });
469
+ } else {
470
+ if (options.chain.length > MAX_CHAIN_LENGTH) {
471
+ errors.push({
472
+ field: "chain",
473
+ message: `Too many chain steps (${options.chain.length}). Maximum is ${MAX_CHAIN_LENGTH}.`,
474
+ });
475
+ }
476
+ for (let i = 0; i < options.chain.length; i++) {
477
+ const s = options.chain[i] as Record<string, unknown>;
478
+ if (!s || typeof s.agent !== "string" || !s.agent.trim()) {
479
+ errors.push({ field: `chain[${i}].agent`, message: "Agent name must be a non-empty string." });
480
+ }
481
+ if (typeof s.task !== "string") {
482
+ errors.push({ field: `chain[${i}].task`, message: "Task must be a string." });
483
+ }
484
+ }
485
+ }
486
+ }
487
+
488
+ return errors;
489
489
  }
490
490
 
491
491
  /**
492
492
  * Truncate parallel task output to the per-task cap.
493
493
  */
494
494
  export function truncateParallelOutput(output: string): string {
495
- const byteLength = Buffer.byteLength(output, "utf8");
496
- if (byteLength <= PER_TASK_OUTPUT_CAP) return output;
497
-
498
- let truncated = output.slice(0, PER_TASK_OUTPUT_CAP);
499
- while (Buffer.byteLength(truncated, "utf8") > PER_TASK_OUTPUT_CAP) {
500
- truncated = truncated.slice(0, -1);
501
- }
502
- return `${truncated}\n\n[Output truncated: ${byteLength - Buffer.byteLength(truncated, "utf8")} bytes omitted.]`;
495
+ const byteLength = Buffer.byteLength(output, "utf8");
496
+ if (byteLength <= PER_TASK_OUTPUT_CAP) return output;
497
+
498
+ let truncated = output.slice(0, PER_TASK_OUTPUT_CAP);
499
+ while (Buffer.byteLength(truncated, "utf8") > PER_TASK_OUTPUT_CAP) {
500
+ truncated = truncated.slice(0, -1);
501
+ }
502
+ return `${truncated}\n\n[Output truncated: ${byteLength - Buffer.byteLength(truncated, "utf8")} bytes omitted.]`;
503
503
  }
504
504
 
505
505
  // ---------------------------------------------------------------------------
@@ -507,19 +507,19 @@ export function truncateParallelOutput(output: string): string {
507
507
  // ---------------------------------------------------------------------------
508
508
 
509
509
  const RATE_LIMIT_PATTERNS = [
510
- /\b429\b/,
511
- /\b529\b/,
512
- /rate[\s_]limit/i,
513
- /ratelimit/i,
514
- /too[\s_]many[\s_]requests/i,
515
- /quota[\s_]exhausted/i,
516
- /quota[\s_]exceeded/i,
517
- /exceeded[\s_](?:your[\s_])?(?:current[\s_])?quota/i,
518
- /insufficient_quota/i,
519
- /resource[\s_]exhausted/i,
520
- /capacity[\s_]exceeded/i,
521
- /usage[\s_]limit/i,
522
- /overloaded/i,
510
+ /\b429\b/,
511
+ /\b529\b/,
512
+ /rate[\s_]limit/i,
513
+ /ratelimit/i,
514
+ /too[\s_]many[\s_]requests/i,
515
+ /quota[\s_]exhausted/i,
516
+ /quota[\s_]exceeded/i,
517
+ /exceeded[\s_](?:your[\s_])?(?:current[\s_])?quota/i,
518
+ /insufficient_quota/i,
519
+ /resource[\s_]exhausted/i,
520
+ /capacity[\s_]exceeded/i,
521
+ /usage[\s_]limit/i,
522
+ /overloaded/i,
523
523
  ];
524
524
 
525
525
  /**
@@ -529,5 +529,5 @@ const RATE_LIMIT_PATTERNS = [
529
529
  * primary model candidate hits a 429 or similar server-side capacity error.
530
530
  */
531
531
  export function isRateLimitError(message: string): boolean {
532
- return RATE_LIMIT_PATTERNS.some(p => p.test(message));
532
+ return RATE_LIMIT_PATTERNS.some(p => p.test(message));
533
533
  }