pi-subagents 0.27.0 → 0.28.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.
@@ -19,6 +19,7 @@ import {
19
19
  type ModelAttempt,
20
20
  type NestedRouteInfo,
21
21
  type ResolvedControlConfig,
22
+ type ResourceLimitExceeded,
22
23
  type SubagentRunMode,
23
24
  type TokenUsage,
24
25
  type Usage,
@@ -53,7 +54,7 @@ import { collectDynamicResults, DynamicFanoutError, materializeDynamicParallelSt
53
54
  import { nestedSummaryFromAsyncStatus, writeNestedEvent } from "../shared/nested-events.ts";
54
55
  import { formatModelAttemptNote, isRetryableModelFailure } from "../shared/model-fallback.ts";
55
56
  import { attachPostExitStdioGuard, trySignalChild } from "../../shared/post-exit-stdio-guard.ts";
56
- import { detectSubagentError, extractTextFromContent, extractToolArgsPreview, getFinalOutput } from "../../shared/utils.ts";
57
+ import { detectSubagentError, extractTextFromContent, extractToolArgsPreview, formatResourceLimitExceeded, getFinalOutput } from "../../shared/utils.ts";
57
58
  import { evaluateCompletionMutationGuard, resolveCompletionPolicy } from "../shared/completion-guard.ts";
58
59
  import {
59
60
  createMutatingFailureState,
@@ -140,6 +141,7 @@ interface StepResult {
140
141
  structuredOutputPath?: string;
141
142
  structuredOutputSchemaPath?: string;
142
143
  acceptance?: AcceptanceLedger;
144
+ resourceLimitExceeded?: ResourceLimitExceeded;
143
145
  }
144
146
 
145
147
  const ASYNC_INTERRUPT_SIGNAL: NodeJS.Signals = process.platform === "win32" ? "SIGBREAK" : "SIGUSR2";
@@ -234,6 +236,7 @@ interface RunPiStreamingResult {
234
236
  finalOutput: string;
235
237
  interrupted?: boolean;
236
238
  observedMutationAttempt?: boolean;
239
+ resourceLimitExceeded?: ResourceLimitExceeded;
237
240
  }
238
241
 
239
242
  function runPiStreaming(
@@ -247,6 +250,8 @@ function runPiStreaming(
247
250
  childEventContext?: ChildEventContext,
248
251
  registerInterrupt?: (interrupt: (() => void) | undefined) => void,
249
252
  onChildEvent?: (event: ChildEvent) => void,
253
+ maxExecutionTimeMs?: number,
254
+ maxTokens?: number,
250
255
  ): Promise<RunPiStreamingResult> {
251
256
  return new Promise((resolve) => {
252
257
  const outputStream = fs.createWriteStream(outputFile, { flags: "w" });
@@ -270,7 +275,10 @@ function runPiStreaming(
270
275
  let error: string | undefined;
271
276
  let assistantError: string | undefined;
272
277
  let interrupted = false;
278
+ let resourceLimitExceeded: ResourceLimitExceeded | undefined;
273
279
  let observedMutationAttempt = false;
280
+ let resourceLimitTimer: NodeJS.Timeout | undefined;
281
+ let resourceLimitEscalationTimer: NodeJS.Timeout | undefined;
274
282
  const rawStdoutLines: string[] = [];
275
283
 
276
284
  const writeOutputLine = (line: string) => {
@@ -284,6 +292,19 @@ function runPiStreaming(
284
292
  }
285
293
  };
286
294
 
295
+ const triggerResourceLimit = (kind: ResourceLimitExceeded["kind"], limit: number, observed?: number) => {
296
+ if (settled || resourceLimitExceeded) return;
297
+ const message = formatResourceLimitExceeded({ agent: childEventContext?.agent ?? "subagent", kind, limit, observed });
298
+ resourceLimitExceeded = { kind, limit, ...(observed !== undefined ? { observed } : {}), message };
299
+ error = message;
300
+ writeOutputLine(message);
301
+ trySignalChild(child, "SIGINT");
302
+ resourceLimitEscalationTimer = setTimeout(() => {
303
+ if (!settled) trySignalChild(child, "SIGTERM");
304
+ }, 1000);
305
+ resourceLimitEscalationTimer.unref?.();
306
+ };
307
+
287
308
  const appendChildEvent = (event: Record<string, unknown>) => {
288
309
  if (!childEventContext) return;
289
310
  appendJsonl(childEventContext.eventsPath, JSON.stringify({
@@ -338,6 +359,10 @@ function runPiStreaming(
338
359
  usage.cacheRead += eventUsage.cacheRead ?? 0;
339
360
  usage.cacheWrite += eventUsage.cacheWrite ?? 0;
340
361
  usage.cost += eventUsage.cost?.total ?? 0;
362
+ const observedTokens = usage.input + usage.output;
363
+ if (maxTokens !== undefined && observedTokens >= maxTokens) {
364
+ triggerResourceLimit("maxTokens", maxTokens, observedTokens);
365
+ }
341
366
  }
342
367
  const stopReason = (event.message as { stopReason?: string }).stopReason;
343
368
  const hasToolCall = Array.isArray(event.message.content)
@@ -373,6 +398,12 @@ function runPiStreaming(
373
398
  let finalDrainTimer: NodeJS.Timeout | undefined;
374
399
  let finalHardKillTimer: NodeJS.Timeout | undefined;
375
400
  let settled = false;
401
+ if (maxExecutionTimeMs !== undefined) {
402
+ resourceLimitTimer = setTimeout(() => {
403
+ triggerResourceLimit("maxExecutionTimeMs", maxExecutionTimeMs);
404
+ }, maxExecutionTimeMs);
405
+ resourceLimitTimer.unref?.();
406
+ }
376
407
  const clearStdioGuard = attachPostExitStdioGuard(child, { idleMs: 2000, hardMs: 8000 });
377
408
  child.stdout.on("data", (chunk: Buffer) => {
378
409
  const text = chunk.toString();
@@ -386,7 +417,7 @@ function runPiStreaming(
386
417
  processStderrText(chunk.toString());
387
418
  });
388
419
  registerInterrupt?.(() => {
389
- if (settled) return;
420
+ if (settled || resourceLimitExceeded) return;
390
421
  interrupted = true;
391
422
  if (!error) error = "Interrupted. Waiting for explicit next action.";
392
423
  trySignalChild(child, "SIGINT");
@@ -403,6 +434,14 @@ function runPiStreaming(
403
434
  clearTimeout(finalHardKillTimer);
404
435
  finalHardKillTimer = undefined;
405
436
  }
437
+ if (resourceLimitTimer) {
438
+ clearTimeout(resourceLimitTimer);
439
+ resourceLimitTimer = undefined;
440
+ }
441
+ if (resourceLimitEscalationTimer) {
442
+ clearTimeout(resourceLimitEscalationTimer);
443
+ resourceLimitEscalationTimer = undefined;
444
+ }
406
445
  };
407
446
  function startFinalDrain(): void {
408
447
  if (childExited || finalDrainTimer || settled) return;
@@ -434,12 +473,12 @@ function runPiStreaming(
434
473
  if (stdoutBuf.trim()) processStdoutLine(stdoutBuf);
435
474
  if (stderrBuf.trim()) appendChildLine("subagent.child.stderr", stderrBuf);
436
475
  outputStream.end();
437
- const finalOutput = getFinalOutput(messages) || rawStdoutLines.join("\n").trim();
438
- const finalError = error ?? assistantError;
476
+ const finalOutput = resourceLimitExceeded?.message ?? (getFinalOutput(messages) || rawStdoutLines.join("\n").trim());
477
+ const finalError = resourceLimitExceeded?.message ?? error ?? assistantError;
439
478
  const forcedDrainAfterFinalSuccess = forcedTerminationSignal && cleanTerminalAssistantStopReceived && !finalError;
440
479
  resolve({
441
480
  stderr,
442
- exitCode: interrupted || forcedDrainAfterFinalSuccess ? 0 : forcedTerminationSignal || signal ? (exitCode ?? 1) : exitCode,
481
+ exitCode: resourceLimitExceeded ? 1 : interrupted || forcedDrainAfterFinalSuccess ? 0 : forcedTerminationSignal || signal ? (exitCode ?? 1) : exitCode,
443
482
  messages,
444
483
  usage,
445
484
  model,
@@ -447,6 +486,7 @@ function runPiStreaming(
447
486
  finalOutput,
448
487
  interrupted,
449
488
  observedMutationAttempt,
489
+ resourceLimitExceeded,
450
490
  });
451
491
  });
452
492
 
@@ -456,9 +496,9 @@ function runPiStreaming(
456
496
  clearDrainTimers();
457
497
  clearStdioGuard();
458
498
  outputStream.end();
459
- const finalOutput = getFinalOutput(messages) || rawStdoutLines.join("\n").trim();
499
+ const finalOutput = resourceLimitExceeded?.message ?? (getFinalOutput(messages) || rawStdoutLines.join("\n").trim());
460
500
  const spawnErrorMessage = spawnError instanceof Error ? spawnError.message : String(spawnError);
461
- resolve({ stderr, exitCode: 1, messages, usage, model, error: error ?? assistantError ?? spawnErrorMessage, finalOutput, observedMutationAttempt });
501
+ resolve({ stderr, exitCode: 1, messages, usage, model, error: resourceLimitExceeded?.message ?? error ?? assistantError ?? spawnErrorMessage, finalOutput, observedMutationAttempt, resourceLimitExceeded });
462
502
  });
463
503
  });
464
504
  }
@@ -614,6 +654,7 @@ async function runSingleStep(
614
654
  structuredOutputPath?: string;
615
655
  structuredOutputSchemaPath?: string;
616
656
  acceptance?: AcceptanceLedger;
657
+ resourceLimitExceeded?: ResourceLimitExceeded;
617
658
  }> {
618
659
  const effectiveStructuredOutput = step.structuredOutput ?? (step.structuredOutputSchema
619
660
  ? createStructuredOutputRuntime(step.structuredOutputSchema, path.join(path.dirname(ctx.outputFile), "structured-output"))
@@ -701,6 +742,8 @@ async function runSingleStep(
701
742
  { eventsPath, runId: ctx.id, stepIndex: ctx.flatIndex, agent: step.agent },
702
743
  ctx.registerInterrupt,
703
744
  ctx.onChildEvent,
745
+ step.maxExecutionTimeMs,
746
+ step.maxTokens,
704
747
  );
705
748
  cleanupTempDir(tempDir);
706
749
 
@@ -766,7 +809,7 @@ async function runSingleStep(
766
809
  finalOutputSnapshot = outputSnapshot;
767
810
  finalResult = { ...run, exitCode: effectiveExitCode, model: candidate ?? run.model, error, structuredOutput } as RunPiStreamingResult & { structuredOutput?: unknown };
768
811
  if (attempt.success || completionGuardError) break;
769
- if (!isRetryableModelFailure(error) || index === candidates.length - 1) break;
812
+ if (run.resourceLimitExceeded || !isRetryableModelFailure(error) || index === candidates.length - 1) break;
770
813
  attemptNotes.push(formatModelAttemptNote(attempt, candidates[index + 1]));
771
814
  }
772
815
 
@@ -861,6 +904,8 @@ async function runSingleStep(
861
904
  { eventsPath, runId: ctx.id, stepIndex: ctx.flatIndex, agent: step.agent },
862
905
  ctx.registerInterrupt,
863
906
  ctx.onChildEvent,
907
+ step.maxExecutionTimeMs,
908
+ step.maxTokens,
864
909
  );
865
910
  cleanupTempDir(tempDir);
866
911
  modelAttempts.push({
@@ -923,6 +968,7 @@ async function runSingleStep(
923
968
  model: finalResult?.model,
924
969
  attemptedModels: attemptedModels.length > 0 ? attemptedModels : undefined,
925
970
  modelAttempts,
971
+ resourceLimitExceeded: finalResult?.resourceLimitExceeded,
926
972
  skills: step.skills,
927
973
  timestamp: Date.now(),
928
974
  }, null, 2),
@@ -948,6 +994,7 @@ async function runSingleStep(
948
994
  structuredOutputPath: effectiveStructuredOutput?.outputPath,
949
995
  structuredOutputSchemaPath: effectiveStructuredOutput?.schemaPath,
950
996
  acceptance,
997
+ resourceLimitExceeded: finalResult?.resourceLimitExceeded,
951
998
  };
952
999
  }
953
1000
 
@@ -1712,12 +1759,14 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1712
1759
  statusPayload.steps[fi].structuredOutputPath = singleResult.structuredOutputPath;
1713
1760
  statusPayload.steps[fi].structuredOutputSchemaPath = singleResult.structuredOutputSchemaPath;
1714
1761
  statusPayload.steps[fi].acceptance = singleResult.acceptance;
1762
+ statusPayload.steps[fi].resourceLimitExceeded = singleResult.resourceLimitExceeded;
1715
1763
  statusPayload.lastUpdate = taskEndTime;
1716
1764
  writeStatusPayload();
1717
1765
  appendJsonl(eventsPath, JSON.stringify({
1718
1766
  type: singleResult.exitCode === 0 ? "subagent.step.completed" : "subagent.step.failed",
1719
1767
  ts: taskEndTime, runId: id, stepIndex: fi, agent: task.agent,
1720
1768
  exitCode: singleResult.exitCode, durationMs: taskEndTime - taskStartTime,
1769
+ resourceLimitExceeded: singleResult.resourceLimitExceeded,
1721
1770
  }));
1722
1771
  if (singleResult.exitCode !== 0 && failFast) aborted = true;
1723
1772
  return { ...singleResult, skipped: false };
@@ -1742,6 +1791,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1742
1791
  structuredOutputPath: pr.structuredOutputPath,
1743
1792
  structuredOutputSchemaPath: pr.structuredOutputSchemaPath,
1744
1793
  acceptance: pr.acceptance,
1794
+ resourceLimitExceeded: pr.resourceLimitExceeded,
1745
1795
  });
1746
1796
  }
1747
1797
  const collection = collectDynamicResults(step as Parameters<typeof collectDynamicResults>[0], materialized.items, parallelResults);
@@ -1941,6 +1991,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1941
1991
  statusPayload.steps[fi].structuredOutputPath = singleResult.structuredOutputPath;
1942
1992
  statusPayload.steps[fi].structuredOutputSchemaPath = singleResult.structuredOutputSchemaPath;
1943
1993
  statusPayload.steps[fi].acceptance = singleResult.acceptance;
1994
+ statusPayload.steps[fi].resourceLimitExceeded = singleResult.resourceLimitExceeded;
1944
1995
  statusPayload.lastUpdate = taskEndTime;
1945
1996
  writeStatusPayload();
1946
1997
 
@@ -1948,6 +1999,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
1948
1999
  type: singleResult.exitCode === 0 ? "subagent.step.completed" : "subagent.step.failed",
1949
2000
  ts: taskEndTime, runId: id, stepIndex: fi, agent: task.agent,
1950
2001
  exitCode: singleResult.exitCode, durationMs: taskDuration,
2002
+ resourceLimitExceeded: singleResult.resourceLimitExceeded,
1951
2003
  }));
1952
2004
  if (singleResult.completionGuardTriggered) {
1953
2005
  const event = buildControlEvent({
@@ -2006,6 +2058,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
2006
2058
  structuredOutputPath: pr.structuredOutputPath,
2007
2059
  structuredOutputSchemaPath: pr.structuredOutputSchemaPath,
2008
2060
  acceptance: pr.acceptance,
2061
+ resourceLimitExceeded: pr.resourceLimitExceeded,
2009
2062
  });
2010
2063
  }
2011
2064
  for (let t = 0; t < group.parallel.length; t++) {
@@ -2107,6 +2160,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
2107
2160
  structuredOutputPath: singleResult.structuredOutputPath,
2108
2161
  structuredOutputSchemaPath: singleResult.structuredOutputSchemaPath,
2109
2162
  acceptance: singleResult.acceptance,
2163
+ resourceLimitExceeded: singleResult.resourceLimitExceeded,
2110
2164
  });
2111
2165
  if (seqStep.outputName) {
2112
2166
  outputs[seqStep.outputName] = outputEntryFromAsyncResult({
@@ -2152,6 +2206,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
2152
2206
  statusPayload.steps[flatIndex].structuredOutputPath = singleResult.structuredOutputPath;
2153
2207
  statusPayload.steps[flatIndex].structuredOutputSchemaPath = singleResult.structuredOutputSchemaPath;
2154
2208
  statusPayload.steps[flatIndex].acceptance = singleResult.acceptance;
2209
+ statusPayload.steps[flatIndex].resourceLimitExceeded = singleResult.resourceLimitExceeded;
2155
2210
  if (stepTokens) {
2156
2211
  statusPayload.steps[flatIndex].tokens = stepTokens;
2157
2212
  statusPayload.totalTokens = { ...previousCumulativeTokens };
@@ -2168,6 +2223,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
2168
2223
  exitCode: singleResult.exitCode,
2169
2224
  durationMs: stepEndTime - stepStartTime,
2170
2225
  tokens: stepTokens,
2226
+ resourceLimitExceeded: singleResult.resourceLimitExceeded,
2171
2227
  }));
2172
2228
  if (singleResult.completionGuardTriggered) {
2173
2229
  const event = buildControlEvent({
@@ -2314,6 +2370,7 @@ async function runSubagent(config: SubagentRunConfig): Promise<void> {
2314
2370
  structuredOutputPath: r.structuredOutputPath,
2315
2371
  structuredOutputSchemaPath: r.structuredOutputSchemaPath,
2316
2372
  acceptance: r.acceptance,
2373
+ resourceLimitExceeded: r.resourceLimitExceeded,
2317
2374
  })),
2318
2375
  outputs,
2319
2376
  workflowGraph: statusPayload.workflowGraph,
@@ -81,7 +81,7 @@ interface ChainExecutionDetailsInput {
81
81
  outputs?: ChainOutputMap;
82
82
  currentFlatIndex?: number;
83
83
  dynamicChildren?: Record<number, Array<{ agent: string; label?: string; flatIndex: number; itemKey: string; outputName?: string; structured?: boolean; error?: string }>>;
84
- dynamicGroupStatuses?: Record<number, { status: "pending" | "running" | "completed" | "failed" | "paused" | "detached"; error?: string; acceptance?: SingleResult["acceptance"] }>;
84
+ dynamicGroupStatuses?: Record<number, { status: "pending" | "running" | "completed" | "failed" | "paused" | "detached" | "timed-out"; error?: string; acceptance?: SingleResult["acceptance"] }>;
85
85
  }
86
86
 
87
87
  interface ParallelChainRunInput {
@@ -103,6 +103,8 @@ interface ParallelChainRunInput {
103
103
  sessionFileForIndex?: (idx?: number) => string | undefined;
104
104
  shareEnabled: boolean;
105
105
  artifactConfig: ArtifactConfig;
106
+ timeoutMs?: number;
107
+ timeoutAt?: number;
106
108
  artifactsDir: string;
107
109
  signal?: AbortSignal;
108
110
  onUpdate?: (r: AgentToolResult<Details>) => void;
@@ -265,6 +267,7 @@ async function runParallelChainTasks(input: ParallelChainRunInput): Promise<Sing
265
267
  cwd: taskCwd,
266
268
  signal: input.signal,
267
269
  interruptSignal: interruptController.signal,
270
+ ...(input.timeoutMs !== undefined && input.timeoutAt !== undefined ? { timeoutMs: input.timeoutMs, timeoutAt: input.timeoutAt } : {}),
268
271
  allowIntercomDetach: taskAgentConfig?.systemPrompt?.includes(INTERCOM_BRIDGE_MARKER) === true,
269
272
  intercomEvents: input.intercomEvents,
270
273
  runId: input.runId,
@@ -277,6 +280,8 @@ async function runParallelChainTasks(input: ParallelChainRunInput): Promise<Sing
277
280
  outputPath,
278
281
  outputMode: behavior.outputMode,
279
282
  maxSubagentDepth,
283
+ maxExecutionTimeMs: taskAgentConfig?.maxExecutionTimeMs,
284
+ maxTokens: taskAgentConfig?.maxTokens,
280
285
  controlConfig: input.controlConfig,
281
286
  onControlEvent: input.onControlEvent,
282
287
  intercomSessionName: input.childIntercomTarget?.(task.agent, input.globalTaskIndex + taskIndex),
@@ -391,6 +396,7 @@ interface ChainExecutionParams {
391
396
  nestedRoute?: NestedRouteInfo;
392
397
  worktreeSetupHook?: string;
393
398
  worktreeSetupHookTimeoutMs?: number;
399
+ timeoutMs?: number;
394
400
  }
395
401
 
396
402
  interface ChainExecutionResult {
@@ -580,6 +586,7 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
580
586
  tuiBehaviorOverrides = result.behaviorOverrides;
581
587
  }
582
588
 
589
+ const timeoutAt = params.timeoutMs !== undefined ? Date.now() + params.timeoutMs : undefined;
583
590
  let prev = "";
584
591
  let globalTaskIndex = 0;
585
592
  let progressCreated = false;
@@ -648,6 +655,7 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
648
655
  shareEnabled,
649
656
  artifactConfig,
650
657
  artifactsDir,
658
+ ...(params.timeoutMs !== undefined && timeoutAt !== undefined ? { timeoutMs: params.timeoutMs, timeoutAt } : {}),
651
659
  signal,
652
660
  onUpdate,
653
661
  results,
@@ -674,6 +682,18 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
674
682
  if (result.progress) allProgress.push(result.progress);
675
683
  if (result.artifactPaths) allArtifactPaths.push(result.artifactPaths);
676
684
  }
685
+ const timedOutIndexInStep = parallelResults.findIndex((result) => result.timedOut);
686
+ const timedOut = timedOutIndexInStep >= 0 ? parallelResults[timedOutIndexInStep] : undefined;
687
+ if (timedOut) {
688
+ return {
689
+ content: [{ type: "text", text: `Chain timed out at step ${stepIndex + 1} (${timedOut.agent}): ${timedOut.error ?? "timeout expired"}` }],
690
+ isError: true,
691
+ details: buildChainExecutionDetails(makeDetailsInput({
692
+ currentStepIndex: stepIndex,
693
+ currentFlatIndex: globalTaskIndex - step.parallel.length + timedOutIndexInStep,
694
+ })),
695
+ };
696
+ }
677
697
  const interruptedIndexInStep = parallelResults.findIndex((result) => result.interrupted);
678
698
  const interrupted = interruptedIndexInStep >= 0 ? parallelResults[interruptedIndexInStep] : undefined;
679
699
  if (interrupted) {
@@ -835,6 +855,7 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
835
855
  shareEnabled,
836
856
  artifactConfig,
837
857
  artifactsDir,
858
+ ...(params.timeoutMs !== undefined && timeoutAt !== undefined ? { timeoutMs: params.timeoutMs, timeoutAt } : {}),
838
859
  signal,
839
860
  onUpdate,
840
861
  results,
@@ -861,6 +882,19 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
861
882
  if (result.artifactPaths) allArtifactPaths.push(result.artifactPaths);
862
883
  }
863
884
  const collected = collectDynamicResults(step, materialized.items, parallelResults);
885
+ const timedOutIndexInStep = parallelResults.findIndex((result) => result.timedOut);
886
+ const timedOut = timedOutIndexInStep >= 0 ? parallelResults[timedOutIndexInStep] : undefined;
887
+ if (timedOut) {
888
+ dynamicGroupStatuses[stepIndex] = { status: "timed-out", error: timedOut.error };
889
+ return {
890
+ content: [{ type: "text", text: `Chain timed out at step ${stepIndex + 1} (${timedOut.agent}): ${timedOut.error ?? "timeout expired"}` }],
891
+ isError: true,
892
+ details: buildChainExecutionDetails(makeDetailsInput({
893
+ currentStepIndex: stepIndex,
894
+ currentFlatIndex: globalTaskIndex - dynamicParallelStep.parallel.length + timedOutIndexInStep,
895
+ })),
896
+ };
897
+ }
864
898
  const interruptedIndexInStep = parallelResults.findIndex((result) => result.interrupted);
865
899
  const interrupted = interruptedIndexInStep >= 0 ? parallelResults[interruptedIndexInStep] : undefined;
866
900
  if (interrupted) {
@@ -1009,6 +1043,7 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
1009
1043
  cwd: resolveChildCwd(cwd ?? ctx.cwd, seqStep.cwd),
1010
1044
  signal,
1011
1045
  interruptSignal: interruptController.signal,
1046
+ ...(params.timeoutMs !== undefined && timeoutAt !== undefined ? { timeoutMs: params.timeoutMs, timeoutAt } : {}),
1012
1047
  allowIntercomDetach: agentConfig.systemPrompt?.includes(INTERCOM_BRIDGE_MARKER) === true,
1013
1048
  intercomEvents,
1014
1049
  runId,
@@ -1021,6 +1056,8 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
1021
1056
  outputPath,
1022
1057
  outputMode: behavior.outputMode,
1023
1058
  maxSubagentDepth,
1059
+ maxExecutionTimeMs: agentConfig.maxExecutionTimeMs,
1060
+ maxTokens: agentConfig.maxTokens,
1024
1061
  controlConfig,
1025
1062
  onControlEvent,
1026
1063
  intercomSessionName: childIntercomTarget?.(seqStep.agent, globalTaskIndex),
@@ -1088,6 +1125,13 @@ export async function executeChain(params: ChainExecutionParams): Promise<ChainE
1088
1125
  if (r.progress) allProgress.push(r.progress);
1089
1126
  if (r.artifactPaths) allArtifactPaths.push(r.artifactPaths);
1090
1127
 
1128
+ if (r.timedOut) {
1129
+ return {
1130
+ content: [{ type: "text", text: `Chain timed out at step ${stepIndex + 1} (${r.agent}): ${r.error ?? "timeout expired"}` }],
1131
+ isError: true,
1132
+ details: buildChainExecutionDetails(makeDetailsInput({ currentStepIndex: stepIndex, currentFlatIndex: globalTaskIndex - 1 })),
1133
+ };
1134
+ }
1091
1135
  if (r.interrupted) {
1092
1136
  return {
1093
1137
  content: [{ type: "text", text: `Chain paused after interrupt at step ${stepIndex + 1} (${r.agent}). Waiting for explicit next action.` }],
@@ -44,6 +44,7 @@ import {
44
44
  detectSubagentError,
45
45
  extractToolArgsPreview,
46
46
  extractTextFromContent,
47
+ formatResourceLimitExceeded,
47
48
  } from "../../shared/utils.ts";
48
49
  import { buildSkillInjection, resolveSkillsWithFallback } from "../../agents/skills.ts";
49
50
  import { evaluateCompletionMutationGuard, resolveCompletionPolicy, type CompletionPolicy } from "../shared/completion-guard.ts";
@@ -108,6 +109,43 @@ function appendRecentOutput(progress: AgentProgress, lines: string[]): void {
108
109
  }
109
110
  }
110
111
 
112
+ const FOREGROUND_TIMEOUT_EXIT_CODE = 124;
113
+
114
+ function formatForegroundTimeoutMessage(timeoutMs: number | undefined): string {
115
+ return timeoutMs ? `Timed out after ${timeoutMs}ms.` : "Timed out.";
116
+ }
117
+
118
+ function createTimedOutResult(agent: string, task: string, options: RunSyncOptions): SingleResult {
119
+ const message = formatForegroundTimeoutMessage(options.timeoutMs);
120
+ return {
121
+ agent,
122
+ task,
123
+ exitCode: FOREGROUND_TIMEOUT_EXIT_CODE,
124
+ messages: [],
125
+ usage: emptyUsage(),
126
+ error: message,
127
+ finalOutput: message,
128
+ timedOut: true,
129
+ progress: {
130
+ index: options.index ?? 0,
131
+ agent,
132
+ status: "failed",
133
+ task,
134
+ recentTools: [],
135
+ recentOutput: [message],
136
+ toolCount: 0,
137
+ tokens: 0,
138
+ durationMs: 0,
139
+ lastActivityAt: Date.now(),
140
+ },
141
+ progressSummary: {
142
+ toolCount: 0,
143
+ tokens: 0,
144
+ durationMs: 0,
145
+ },
146
+ };
147
+ }
148
+
111
149
  function stripAcceptanceReportsFromMessages(messages: Message[] | undefined): void {
112
150
  for (const message of messages ?? []) {
113
151
  if (message.role !== "assistant" || !Array.isArray(message.content)) continue;
@@ -263,6 +301,12 @@ async function runSingleAttempt(
263
301
  let detached = false;
264
302
  let intercomStarted = false;
265
303
  let assistantError: string | undefined;
304
+ let timedOut = false;
305
+ let resourceLimited = false;
306
+ let timeoutTimer: NodeJS.Timeout | undefined;
307
+ let timeoutEscalationTimer: NodeJS.Timeout | undefined;
308
+ let resourceLimitTimer: NodeJS.Timeout | undefined;
309
+ let resourceLimitEscalationTimer: NodeJS.Timeout | undefined;
266
310
  let removeAbortListener: (() => void) | undefined;
267
311
  let removeInterruptListener: (() => void) | undefined;
268
312
  let activityTimer: NodeJS.Timeout | undefined;
@@ -334,6 +378,22 @@ async function runSingleAttempt(
334
378
  settled = true;
335
379
  clearFinalDrainTimers();
336
380
  clearStdioGuard();
381
+ if (timeoutTimer) {
382
+ clearTimeout(timeoutTimer);
383
+ timeoutTimer = undefined;
384
+ }
385
+ if (timeoutEscalationTimer) {
386
+ clearTimeout(timeoutEscalationTimer);
387
+ timeoutEscalationTimer = undefined;
388
+ }
389
+ if (resourceLimitTimer) {
390
+ clearTimeout(resourceLimitTimer);
391
+ resourceLimitTimer = undefined;
392
+ }
393
+ if (resourceLimitEscalationTimer) {
394
+ clearTimeout(resourceLimitEscalationTimer);
395
+ resourceLimitEscalationTimer = undefined;
396
+ }
337
397
  if (activityTimer) {
338
398
  clearInterval(activityTimer);
339
399
  activityTimer = undefined;
@@ -428,6 +488,26 @@ async function runSingleAttempt(
428
488
  };
429
489
 
430
490
 
491
+ const triggerResourceLimit = (kind: "maxExecutionTimeMs" | "maxTokens", limit: number, observed?: number) => {
492
+ if (processClosed || detached || settled || timedOut || resourceLimited) return;
493
+ resourceLimited = true;
494
+ const message = formatResourceLimitExceeded({ agent: agent.name, kind, limit, observed });
495
+ result.resourceLimitExceeded = { kind, limit, ...(observed !== undefined ? { observed } : {}), message };
496
+ result.error = message;
497
+ result.finalOutput = message;
498
+ progress.status = "failed";
499
+ progress.durationMs = Date.now() - startTime;
500
+ appendRecentOutput(progress, [message]);
501
+ progress.activityState = undefined;
502
+ fireUpdate();
503
+ trySignalChild(proc, "SIGINT");
504
+ resourceLimitEscalationTimer = setTimeout(() => {
505
+ if (settled || processClosed || detached) return;
506
+ trySignalChild(proc, "SIGTERM");
507
+ }, 1000);
508
+ resourceLimitEscalationTimer.unref?.();
509
+ };
510
+
431
511
  const emitUpdateSnapshot = (text: string) => {
432
512
  if (!options.onUpdate || processClosed) return;
433
513
  const progressSnapshot = snapshotProgress(progress);
@@ -512,6 +592,9 @@ async function runSingleAttempt(
512
592
  result.usage.cacheWrite += u.cacheWrite || 0;
513
593
  result.usage.cost += u.cost?.total || 0;
514
594
  progress.tokens = result.usage.input + result.usage.output;
595
+ if (options.maxTokens !== undefined && progress.tokens >= options.maxTokens) {
596
+ triggerResourceLimit("maxTokens", options.maxTokens, progress.tokens);
597
+ }
515
598
  }
516
599
  if (!result.model && evt.message.model) result.model = evt.message.model;
517
600
  if (evt.message.errorMessage) assistantError = evt.message.errorMessage;
@@ -640,9 +723,45 @@ async function runSingleAttempt(
640
723
  }
641
724
  }
642
725
 
726
+ if (options.timeoutAt !== undefined) {
727
+ const triggerTimeout = () => {
728
+ if (processClosed || detached || settled || timedOut || resourceLimited) return;
729
+ timedOut = true;
730
+ const message = formatForegroundTimeoutMessage(options.timeoutMs);
731
+ result.timedOut = true;
732
+ result.error = message;
733
+ result.finalOutput = message;
734
+ progress.status = "failed";
735
+ progress.durationMs = Date.now() - startTime;
736
+ appendRecentOutput(progress, [message]);
737
+ progress.activityState = undefined;
738
+ fireUpdate();
739
+ trySignalChild(proc, "SIGINT");
740
+ timeoutEscalationTimer = setTimeout(() => {
741
+ if (settled || processClosed || detached) return;
742
+ trySignalChild(proc, "SIGTERM");
743
+ }, 1000);
744
+ timeoutEscalationTimer.unref?.();
745
+ };
746
+ const delay = options.timeoutAt - Date.now();
747
+ if (delay <= 0) triggerTimeout();
748
+ else {
749
+ timeoutTimer = setTimeout(triggerTimeout, delay);
750
+ timeoutTimer.unref?.();
751
+ }
752
+ }
753
+
754
+ if (options.maxExecutionTimeMs !== undefined) {
755
+ const maxExecutionTimeMs = options.maxExecutionTimeMs;
756
+ resourceLimitTimer = setTimeout(() => {
757
+ triggerResourceLimit("maxExecutionTimeMs", maxExecutionTimeMs);
758
+ }, maxExecutionTimeMs);
759
+ resourceLimitTimer.unref?.();
760
+ }
761
+
643
762
  if (options.interruptSignal) {
644
763
  const interrupt = () => {
645
- if (processClosed || detached || settled) return;
764
+ if (processClosed || detached || settled || timedOut || resourceLimited) return;
646
765
  interruptedByControl = true;
647
766
  progress.status = "running";
648
767
  progress.durationMs = Date.now() - startTime;
@@ -664,6 +783,40 @@ async function runSingleAttempt(
664
783
  }
665
784
  });
666
785
  result.exitCode = exitCode;
786
+ if (result.resourceLimitExceeded) {
787
+ result.exitCode = 1;
788
+ result.error = result.error ?? result.resourceLimitExceeded.message;
789
+ result.finalOutput = result.finalOutput || result.error;
790
+ if (result.progress) {
791
+ result.progress.status = "failed";
792
+ result.progress.activityState = undefined;
793
+ result.progress.durationMs = Date.now() - startTime;
794
+ }
795
+ result.progressSummary = {
796
+ toolCount: progress.toolCount,
797
+ tokens: progress.tokens,
798
+ durationMs: result.progress?.durationMs ?? Date.now() - startTime,
799
+ };
800
+ result.controlEvents = allControlEvents.length ? allControlEvents : undefined;
801
+ return result;
802
+ }
803
+ if (result.timedOut) {
804
+ result.exitCode = FOREGROUND_TIMEOUT_EXIT_CODE;
805
+ result.error = result.error ?? formatForegroundTimeoutMessage(options.timeoutMs);
806
+ result.finalOutput = result.finalOutput || result.error;
807
+ if (result.progress) {
808
+ result.progress.status = "failed";
809
+ result.progress.activityState = undefined;
810
+ result.progress.durationMs = Date.now() - startTime;
811
+ }
812
+ result.progressSummary = {
813
+ toolCount: progress.toolCount,
814
+ tokens: progress.tokens,
815
+ durationMs: result.progress?.durationMs ?? Date.now() - startTime,
816
+ };
817
+ result.controlEvents = allControlEvents.length ? allControlEvents : undefined;
818
+ return result;
819
+ }
667
820
  if (interruptedByControl) {
668
821
  result.exitCode = 0;
669
822
  result.interrupted = true;
@@ -915,8 +1068,16 @@ export async function runSync(
915
1068
  error: outputModeValidationError,
916
1069
  };
917
1070
  }
1071
+ if (options.timeoutAt !== undefined && Date.now() >= options.timeoutAt) {
1072
+ return createTimedOutResult(agentName, task, options);
1073
+ }
1074
+ const effectiveOptions: RunSyncOptions = {
1075
+ ...options,
1076
+ maxExecutionTimeMs: options.maxExecutionTimeMs ?? agent.maxExecutionTimeMs,
1077
+ maxTokens: options.maxTokens ?? agent.maxTokens,
1078
+ };
918
1079
 
919
- const shareEnabled = options.share === true;
1080
+ const shareEnabled = effectiveOptions.share === true;
920
1081
  const effectiveAcceptance = resolveEffectiveAcceptance({
921
1082
  explicit: options.acceptance,
922
1083
  agentName,
@@ -967,13 +1128,13 @@ export async function runSync(
967
1128
 
968
1129
  let artifactPathsResult: ArtifactPaths | undefined;
969
1130
  let jsonlPath: string | undefined;
970
- if (options.artifactsDir && options.artifactConfig?.enabled !== false) {
971
- artifactPathsResult = getArtifactPaths(options.artifactsDir, options.runId, agentName, options.index);
972
- ensureArtifactsDir(options.artifactsDir);
973
- if (options.artifactConfig?.includeInput !== false) {
1131
+ if (effectiveOptions.artifactsDir && effectiveOptions.artifactConfig?.enabled !== false) {
1132
+ artifactPathsResult = getArtifactPaths(effectiveOptions.artifactsDir, effectiveOptions.runId, agentName, effectiveOptions.index);
1133
+ ensureArtifactsDir(effectiveOptions.artifactsDir);
1134
+ if (effectiveOptions.artifactConfig?.includeInput !== false) {
974
1135
  writeArtifact(artifactPathsResult.inputPath, `# Task for ${agentName}\n\n${taskWithAcceptance}`);
975
1136
  }
976
- if (options.artifactConfig?.includeJsonl !== false) {
1137
+ if (effectiveOptions.artifactConfig?.includeJsonl !== false) {
977
1138
  jsonlPath = artifactPathsResult.jsonlPath;
978
1139
  }
979
1140
  }
@@ -983,8 +1144,8 @@ export async function runSync(
983
1144
  for (let i = 0; i < modelsToTry.length; i++) {
984
1145
  const candidate = modelsToTry[i];
985
1146
  if (candidate) attemptedModels.push(candidate);
986
- const outputSnapshot = captureSingleOutputSnapshot(options.outputPath);
987
- const result = await runSingleAttempt(runtimeCwd, agent, taskWithAcceptance, candidate, options, {
1147
+ const outputSnapshot = captureSingleOutputSnapshot(effectiveOptions.outputPath);
1148
+ const result = await runSingleAttempt(runtimeCwd, agent, taskWithAcceptance, candidate, effectiveOptions, {
988
1149
  sessionEnabled,
989
1150
  systemPrompt,
990
1151
  resolvedSkillNames: resolvedSkills.length > 0 ? resolvedSkills.map((skill) => skill.name) : undefined,
@@ -1019,7 +1180,7 @@ export async function runSync(
1019
1180
  if (attemptSucceeded) {
1020
1181
  break;
1021
1182
  }
1022
- if (!isRetryableModelFailure(result.error) || i === modelsToTry.length - 1) {
1183
+ if (result.timedOut || result.resourceLimitExceeded || !isRetryableModelFailure(result.error) || i === modelsToTry.length - 1) {
1023
1184
  break;
1024
1185
  }
1025
1186
  attemptNotes.push(formatModelAttemptNote(attempt, modelsToTry[i + 1]));