agent-accelerator 0.5.1 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/agent/loop.ts CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  shouldBlockRepeat,
19
19
  toolCallFingerprint,
20
20
  traceToolEvent,
21
+ type ExecuteToolsOptions,
21
22
  } from "../tools/executor.ts";
22
23
  import { getModelFromCatalog, ensureModelCatalogFresh, validateModelThinking } from "../models/catalog.ts";
23
24
  import { enforceFileAccess } from "../utils/safe-io.ts";
@@ -70,6 +71,15 @@ export interface AgentLoopConfig {
70
71
  enforceBudget?: (usage?: TokenUsage) => void;
71
72
  /** Called after every turn for durable persistence checkpoints (never fails a turn). */
72
73
  onProgress?: () => void;
74
+ /** Called the moment each tool call finishes, before the rest of its batch (the crash-proof record of finished work). */
75
+ onToolResult?: (result: ToolResultRecord) => void;
76
+ /** Opens the log file of one tool call (see `ToolLogs`); unset when the Agent does not persist to a folder. */
77
+ openToolLog?: ExecuteToolsOptions["openToolLog"];
78
+ /**
79
+ * Continue a run that a crash cut off instead of starting from a new prompt: tool calls the last assistant turn made
80
+ * without a stored result are finished first (a result in `stored` is reused, anything else runs now), then the loop goes on.
81
+ */
82
+ resume?: { stored: Map<string, ToolResultRecord> };
73
83
  /**
74
84
  * Shared steer inbox for mid-session redirection. Entries are drained
75
85
  * between turns (after the current provider/tool work finishes) and
@@ -438,7 +448,7 @@ export function computeCostFromPricing(usage: TokenUsage, spec?: ModelSpec): Tok
438
448
  // This function never mutates `source` (Q-35): the priced cost is cloned
439
449
  // into a local before folding into the target.
440
450
  // ---------------------------------------------------------------------------
441
- function accumulateUsage(target: TokenUsage, source: TokenUsage, spec?: ModelSpec): void {
451
+ export function accumulateUsage(target: TokenUsage, source: TokenUsage, spec?: ModelSpec): void {
442
452
  target.inputTokens += source.inputTokens || 0;
443
453
  target.outputTokens += source.outputTokens || 0;
444
454
  target.totalTokens += source.totalTokens || 0;
@@ -506,6 +516,8 @@ function sanitizeToolResult(r: ToolResultRecord): { sanitized: ToolResultRecord;
506
516
  async function executeToolCallsWithRepeatGuard(options: {
507
517
  tools: Record<string, ToolDefinition>;
508
518
  toolCalls: ToolCallRecord[];
519
+ onResult?: (result: ToolResultRecord) => void;
520
+ openToolLog?: ExecuteToolsOptions["openToolLog"];
509
521
  previousFingerprints: Set<string>;
510
522
  repeatCounts: Map<string, number>;
511
523
  agentName?: string;
@@ -546,6 +558,8 @@ async function executeToolCallsWithRepeatGuard(options: {
546
558
  signal: options.signal,
547
559
  sessionId: options.sessionId,
548
560
  onSubagentEvent: options.onSubagentEvent,
561
+ onResult: options.onResult,
562
+ openToolLog: options.openToolLog,
549
563
  })
550
564
  : [];
551
565
  const byId = new Map<string, ToolResultRecord[]>();
@@ -572,6 +586,73 @@ async function executeToolCallsWithRepeatGuard(options: {
572
586
  };
573
587
  }
574
588
 
589
+ /** Tool calls of the LAST assistant message that have no result in the conversation yet (a crash or interrupt cut them off). */
590
+ export function unfinishedToolCalls(messages: AgentContext["messages"]): ToolCallRecord[] {
591
+ let lastAssistant = -1;
592
+ for (let i = messages.length - 1; i >= 0; i--) {
593
+ if (messages[i]!.role === "assistant") {
594
+ lastAssistant = i;
595
+ break;
596
+ }
597
+ }
598
+ const calls = lastAssistant < 0 ? [] : messages[lastAssistant]!.content;
599
+ if (!Array.isArray(calls)) return [];
600
+ const answered = new Set<string>();
601
+ for (const m of messages.slice(lastAssistant + 1)) {
602
+ if (m.role !== "tool" || !Array.isArray(m.content)) continue;
603
+ for (const p of m.content) if (p.type === "tool_result") answered.add(p.id);
604
+ }
605
+ const open: ToolCallRecord[] = [];
606
+ for (const p of calls) {
607
+ if (p.type === "tool_call" && !answered.has(p.id)) {
608
+ open.push({ id: p.id, name: p.name, arguments: p.arguments, ...(p.rawArguments ? { rawArguments: p.rawArguments } : {}), ...(p.callId ? { callId: p.callId } : {}) });
609
+ }
610
+ }
611
+ return open;
612
+ }
613
+
614
+ /** True when the conversation ends with tool results of a batch in which a tool called `ctx.stop` (the run was meant to end there). */
615
+ export function endsWithStop(messages: AgentContext["messages"]): boolean {
616
+ for (let i = messages.length - 1; i >= 0 && messages[i]!.role === "tool"; i--) {
617
+ const content = messages[i]!.content;
618
+ if (Array.isArray(content) && content.some((p) => p.type === "tool_result" && p.stopped === true)) return true;
619
+ }
620
+ return false;
621
+ }
622
+
623
+ // Finishes the tool calls a crash left open (stored result reused, anything else executed now) and records the results in the conversation; in: a resumed run's config, out: tool results in context + what the response should report.
624
+ async function finishInterruptedTools(
625
+ config: AgentLoopConfig
626
+ ): Promise<{ calls: ToolCallRecord[]; results: ToolResultRecord[]; subagents: SubAgentExecutionMetadata[] }> {
627
+ const open = unfinishedToolCalls(config.context.messages);
628
+ const none = { calls: [], results: [], subagents: [] };
629
+ if (open.length === 0 || !config.resume) return none;
630
+ const stored = config.resume.stored;
631
+ const toRun = open.filter((c) => !stored.has(c.id));
632
+ const executed = toRun.length
633
+ ? await executeToolCalls({
634
+ tools: config.tools,
635
+ toolCalls: toRun,
636
+ agentName: config.agentName,
637
+ parallel: true,
638
+ signal: config.runOptions?.signal,
639
+ sessionId: config.runOptions?.sessionId || config.options?.sessionId,
640
+ onResult: config.onToolResult,
641
+ openToolLog: config.openToolLog,
642
+ })
643
+ : [];
644
+ const ran = new Map(executed.map((r) => [r.id, r]));
645
+ const subagents: SubAgentExecutionMetadata[] = [];
646
+ const results = open.map((call) => {
647
+ const { sanitized, metas } = sanitizeToolResult(ran.get(call.id) ?? stored.get(call.id)!);
648
+ subagents.push(...metas);
649
+ return sanitized;
650
+ });
651
+ config.context.addToolResults(results);
652
+ emitProgress(config);
653
+ return { calls: open, results, subagents };
654
+ }
655
+
575
656
  /**
576
657
  * Runs a single non-streaming agent turn or multi-turn loop
577
658
  */
@@ -614,13 +695,28 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
614
695
  let finalResult: any = null;
615
696
  let turns = 0;
616
697
  let toolsExecuted = false;
698
+ let stoppedByTool = false;
617
699
  let previousToolCallFingerprints = new Set<string>();
618
700
  const repeatCounts = new Map<string, number>();
619
701
 
620
702
  try {
621
703
  const standardTools = toStandardToolDeclarations(tools);
622
704
 
623
- while (turns < maxTurns) {
705
+ if (config.resume) {
706
+ const finished = await finishInterruptedTools(config);
707
+ stoppedByTool = finished.results.some((r) => r.stopped) || endsWithStop(context.messages);
708
+ if (finished.calls.length > 0) {
709
+ toolsExecuted = true;
710
+ allToolCalls.push(...finished.calls);
711
+ allToolResults.push(...finished.results);
712
+ for (const meta of finished.subagents) {
713
+ allSubagents.push(meta);
714
+ accumulateUsage(accumulatedUsage, meta.usage, getModelFromCatalog(meta.provider, meta.model));
715
+ }
716
+ }
717
+ }
718
+
719
+ while (!stoppedByTool && turns < maxTurns) {
624
720
  // Q-21: fail fast on abort (throws AbortError with partial).
625
721
  throwIfAborted(runOptions?.signal, { usage: accumulatedUsage, turns, text: finalResult?.text });
626
722
  checkDeadline(startTime, limits.deadlineMs);
@@ -666,6 +762,8 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
666
762
  if (gTier === "paid" || gTier === "free") providerOptions.googleTier = gTier;
667
763
  } catch {}
668
764
 
765
+ // A long-running call (e.g. Google long decode) times each HTTP request itself: no whole-call race.
766
+ const longRunning = provider.isLongRunning?.(modelId, providerOptions) === true;
669
767
  const genResult = await generateWithTimeout(
670
768
  () => provider.generate(
671
769
  modelId,
@@ -675,7 +773,7 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
675
773
  },
676
774
  providerOptions
677
775
  ),
678
- limits.requestTimeoutMs
776
+ longRunning ? Infinity : limits.requestTimeoutMs
679
777
  );
680
778
 
681
779
  finalResult = genResult;
@@ -710,7 +808,8 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
710
808
  genResult.text,
711
809
  genResult.toolCalls,
712
810
  genResult.thinking,
713
- genResult.thoughtSignature
811
+ genResult.thoughtSignature,
812
+ genResult.providerSteps
714
813
  );
715
814
 
716
815
  // Thinking-trace telemetry (observability-only, full text, no limit):
@@ -755,6 +854,7 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
755
854
  // Execute tool calls — always parallel (model-driven, bloatfree DX7)
756
855
  // Q-21: check abort before tools (throws AbortError with partial).
757
856
  throwIfAborted(runOptions?.signal, { usage: accumulatedUsage, turns, text: genResult.text });
857
+ emitProgress(config); // the calls are on record BEFORE they run, so a crash can finish them instead of forgetting them
758
858
  allToolCalls.push(...genResult.toolCalls);
759
859
  // Q-22: per-run tool-call budget.
760
860
  if (allToolCalls.length > limits.maxToolCallsPerRun) {
@@ -771,6 +871,8 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
771
871
  agentName,
772
872
  signal: runOptions?.signal,
773
873
  sessionId: runOptions?.sessionId || options?.sessionId,
874
+ onResult: config.onToolResult,
875
+ openToolLog: config.openToolLog,
774
876
  });
775
877
  const results = guarded.results;
776
878
  previousToolCallFingerprints = guarded.fingerprints;
@@ -808,6 +910,12 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
808
910
  toolCalls: genResult.toolCalls,
809
911
  toolResults: sanitizedResults,
810
912
  });
913
+ // A tool ended the run (`ctx.stop`): the whole batch has finished, so stop here. Waiting steers become follow-ups.
914
+ if (sanitizedResults.some((r) => r.stopped)) {
915
+ stoppedByTool = true;
916
+ emitProgress(config);
917
+ break;
918
+ }
811
919
  // Steer drain AFTER tool results so call/result pairs stay adjacent.
812
920
  // The next loop iteration sends the injected user message to the model
813
921
  // within the same run (same usage/turn budget).
@@ -826,7 +934,7 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
826
934
  // results above were never sent back, so the run is truncated — surface
827
935
  // it via finishReason + warning instead of silently returning.
828
936
  const truncatedByMaxTurns =
829
- turns >= maxTurns && !!finalResult?.toolCalls && finalResult.toolCalls.length > 0;
937
+ !stoppedByTool && turns >= maxTurns && !!finalResult?.toolCalls && finalResult.toolCalls.length > 0;
830
938
  if (truncatedByMaxTurns) {
831
939
  warnMaxTurnsHit(
832
940
  agentName,
@@ -838,7 +946,7 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
838
946
  // Structured outputs: validate final text only (tool turns skip this).
839
947
  // Strict-fail throws ValidationError with partial attached via catch below.
840
948
  let parsedOutput: unknown | undefined;
841
- if (loopOutput.raw !== undefined && !truncatedByMaxTurns) {
949
+ if (loopOutput.raw !== undefined && !truncatedByMaxTurns && !stoppedByTool) {
842
950
  parsedOutput = parseFinalStructuredOutput(finalResult?.text || "", loopOutput.raw, loopOutput.spec);
843
951
  }
844
952
 
@@ -855,7 +963,9 @@ export async function runAgentLoop(config: AgentLoopConfig): Promise<AgentRespon
855
963
  model: modelId,
856
964
  provider: provider.id,
857
965
  // Q-04: canonical finish reason + raw vendor passthrough.
858
- ...canonicalFinishReason(finalResult?.finishReason, truncatedByMaxTurns),
966
+ ...(stoppedByTool
967
+ ? { finishReason: "stopped" as const, ...(finalResult?.finishReason ? { rawFinishReason: finalResult.finishReason } : {}) }
968
+ : canonicalFinishReason(finalResult?.finishReason, truncatedByMaxTurns)),
859
969
  durationMs: Date.now() - startTime,
860
970
  raw: applyCaptureRaw(finalResult?.raw, config.captureRaw) as ProviderRawData,
861
971
  turns,
@@ -1013,6 +1123,7 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
1013
1123
  turns = 0;
1014
1124
  let previousToolCallFingerprints = new Set<string>();
1015
1125
  const repeatCounts = new Map<string, number>();
1126
+ let stoppedByTool = false;
1016
1127
 
1017
1128
  while (turns < maxTurns) {
1018
1129
  throwIfCancelled({ usage: accumulatedUsage, turns, text: lastResponse?.text });
@@ -1053,6 +1164,7 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
1053
1164
  if (gTier === "paid" || gTier === "free") providerOptions.googleTier = gTier;
1054
1165
  } catch {}
1055
1166
 
1167
+ const longRunning = provider.isLongRunning?.(modelId, providerOptions) === true;
1056
1168
  const innerStream = provider.stream(
1057
1169
  modelId,
1058
1170
  {
@@ -1071,7 +1183,8 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
1071
1183
  let first = true;
1072
1184
  let doneIter = false;
1073
1185
  while (!doneIter) {
1074
- const waitMs = first ? streamLimits.firstByteTimeoutMs : streamLimits.streamIdleTimeoutMs;
1186
+ // Long-running streams pause for minutes between chunks; the adapter guards stalls and reconnects.
1187
+ const waitMs = longRunning ? Infinity : first ? streamLimits.firstByteTimeoutMs : streamLimits.streamIdleTimeoutMs;
1075
1188
  let timer: ReturnType<typeof setTimeout> | undefined;
1076
1189
  const timeoutP =
1077
1190
  Number.isFinite(waitMs) && waitMs !== Infinity && waitMs > 0
@@ -1147,7 +1260,8 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
1147
1260
  turnResponse.text,
1148
1261
  turnResponse.toolCalls,
1149
1262
  turnResponse.thinking,
1150
- turnResponse.thoughtSignature
1263
+ turnResponse.thoughtSignature,
1264
+ turnResponse.providerSteps
1151
1265
  );
1152
1266
 
1153
1267
  // Thinking-trace telemetry (observability-only, full text, no limit):
@@ -1192,6 +1306,7 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
1192
1306
  }
1193
1307
 
1194
1308
  throwIfCancelled({ usage: accumulatedUsage, turns, text: turnResponse.text });
1309
+ emitProgress(config); // the calls are on record BEFORE they run, so a crash can finish them instead of forgetting them
1195
1310
  allToolCalls.push(...turnResponse.toolCalls);
1196
1311
  if (allToolCalls.length > streamLimits.maxToolCallsPerRun) {
1197
1312
  throw new Error(
@@ -1213,6 +1328,8 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
1213
1328
  agentName,
1214
1329
  signal: linked.signal,
1215
1330
  sessionId: runOptions?.sessionId || options?.sessionId,
1331
+ onResult: config.onToolResult,
1332
+ openToolLog: config.openToolLog,
1216
1333
  onSubagentEvent: (ev) => {
1217
1334
  try {
1218
1335
  outerStream.push({
@@ -1278,6 +1395,12 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
1278
1395
  toolCalls: turnResponse.toolCalls,
1279
1396
  toolResults: sanitizedResults,
1280
1397
  });
1398
+ // A tool ended the run (`ctx.stop`): the whole batch has finished, so stop here. Waiting steers become follow-ups.
1399
+ if (sanitizedResults.some((r) => r.stopped)) {
1400
+ stoppedByTool = true;
1401
+ emitProgress(config);
1402
+ break;
1403
+ }
1281
1404
  const toolSteered = drainSteerInbox(context, config.steerInbox);
1282
1405
  if (toolSteered.length > 0) {
1283
1406
  for (const preview of toolSteered) {
@@ -1295,7 +1418,7 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
1295
1418
  }
1296
1419
 
1297
1420
  const truncatedByMaxTurns =
1298
- turns >= maxTurns && !!lastResponse?.toolCalls && lastResponse.toolCalls.length > 0;
1421
+ !stoppedByTool && turns >= maxTurns && !!lastResponse?.toolCalls && lastResponse.toolCalls.length > 0;
1299
1422
  if (truncatedByMaxTurns) {
1300
1423
  warnMaxTurnsHit(
1301
1424
  agentName,
@@ -1303,10 +1426,12 @@ export function streamAgentLoop(config: AgentLoopConfig): AssistantMessageEventS
1303
1426
  lastResponse!.toolCalls.map((c) => c.name)
1304
1427
  );
1305
1428
  }
1306
- const truncatedFinish = canonicalFinishReason(lastResponse?.finishReason, truncatedByMaxTurns);
1429
+ const truncatedFinish = stoppedByTool
1430
+ ? { finishReason: "stopped" as const, ...(lastResponse?.finishReason ? { rawFinishReason: lastResponse.finishReason } : {}) }
1431
+ : canonicalFinishReason(lastResponse?.finishReason, truncatedByMaxTurns);
1307
1432
 
1308
1433
  let streamParsed: unknown | undefined;
1309
- if (streamOutputRaw !== undefined && !truncatedByMaxTurns) {
1434
+ if (streamOutputRaw !== undefined && !truncatedByMaxTurns && !stoppedByTool) {
1310
1435
  try {
1311
1436
  streamParsed = parseFinalStructuredOutput(lastResponse?.text || "", streamOutputRaw, streamOutputSpec);
1312
1437
  } catch (err) {
package/src/index.ts CHANGED
@@ -317,7 +317,7 @@ export {
317
317
  formatSessionBanner,
318
318
  emptyTotals,
319
319
  } from "./session/store.ts";
320
- export type { SessionLimits, SessionPayloadStatus, PersistedAgentSession, SessionTotals, SessionAgentLike, LoadedSessionDir, PersistedSubAgentTrace } from "./session/store.ts";
320
+ export type { SessionLimits, SessionPayloadStatus, PersistedAgentSession, SessionTotals, SessionAgentLike, LoadedSessionDir, PersistedSubAgentTrace, SessionRunMark, SessionRunEnd, SessionPending, SessionPendingPrompt, SessionToolRecord } from "./session/store.ts";
321
321
 
322
322
  // Utilities
323
323
  /**
@@ -362,6 +362,8 @@ export type {
362
362
  ToolResultPart,
363
363
  ThinkingPart,
364
364
  ProviderContext,
365
+ ProviderSteps,
366
+ MediaResolution,
365
367
  } from "./types/message.ts";
366
368
 
367
369
  export type {
@@ -412,6 +414,7 @@ export type {
412
414
  Budget,
413
415
  BudgetState,
414
416
  SubAgentRunIds,
417
+ InterruptedRun,
415
418
  } from "./types/agent.ts";
416
419
 
417
420
  // Minimalist core: harness modules (RLM/RSI, OTel, testing, CLI doctor, hooks,
@@ -4,6 +4,7 @@ import { createHash } from "node:crypto";
4
4
  import { getEnv } from "../utils/env.ts";
5
5
  import { telemetryBus } from "../telemetry.ts";
6
6
  import { onHomeChange, resolveHome } from "../utils/home.ts";
7
+ import { writeFileDurable } from "../utils/durable.ts";
7
8
 
8
9
  /** Default TTL: 12 Hours (in milliseconds). */
9
10
  export const DEFAULT_CATALOG_TTL_MS = 12 * 60 * 60 * 1000;
@@ -102,17 +103,9 @@ function readJsonFileSync(filePath: string): any {
102
103
  }
103
104
  }
104
105
 
106
+ // Writes the catalog cache durably but without a `.bak` (it is re-downloadable); in: path + payload from refreshModelCatalog, out: the cache file.
105
107
  function writeJsonFileSync(filePath: string, data: any): void {
106
- const dir = path.dirname(filePath);
107
- if (!fs.existsSync(dir)) {
108
- fs.mkdirSync(dir, { recursive: true });
109
- }
110
- // Atomic tmp+rename with 0600 so a crash never leaves half-written JSON (Q-16a).
111
- const rand = Math.random().toString(36).slice(2, 10);
112
- const tmp = `${filePath}.tmp-${process.pid}-${rand}`;
113
- fs.writeFileSync(tmp, JSON.stringify(data), { encoding: "utf-8", mode: 0o600 });
114
- try { fs.chmodSync(tmp, 0o600); } catch {}
115
- fs.renameSync(tmp, filePath);
108
+ writeFileDurable(filePath, JSON.stringify(data), { backup: false });
116
109
  }
117
110
 
118
111
  /** Verifies optional sha256 pin from MODELS_DEV_SHA256 (Q-16a). Returns false on mismatch. */