agent-accelerator 0.5.2 → 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/README.md +54 -5
- package/package.json +1 -1
- package/src/agent/agent.ts +283 -15
- package/src/agent/context.ts +2 -0
- package/src/agent/loop.ts +127 -8
- package/src/index.ts +2 -1
- package/src/models/catalog-cache.ts +3 -10
- package/src/scheduler/scheduler.ts +2 -0
- package/src/scheduler/store.ts +34 -21
- package/src/session/journal.ts +201 -0
- package/src/session/store.ts +181 -82
- package/src/session/tool-log.ts +62 -0
- package/src/telemetry-file.ts +4 -1
- package/src/tools/executor.ts +72 -15
- package/src/tools/tool.ts +0 -6
- package/src/types/agent.ts +26 -0
- package/src/types/message.ts +4 -0
- package/src/types/response.ts +2 -0
- package/src/types/tool.ts +18 -6
- package/src/utils/durable.ts +218 -0
- package/src/utils/headers.ts +1 -1
- package/src/utils/retry.ts +15 -1
package/src/agent/agent.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import type { AgentConfig, AgentRunOptions, MidSessionConfig, ToolChoiceOption, RetryPolicyConfig, Budget, BudgetState } from "../types/agent.ts";
|
|
1
|
+
import type { AgentConfig, AgentRunOptions, MidSessionConfig, ToolChoiceOption, RetryPolicyConfig, Budget, BudgetState, InterruptedRun } from "../types/agent.ts";
|
|
2
2
|
import type { TokenUsage } from "../types/core.ts";
|
|
3
3
|
import type { ProviderRequestOptions } from "../types/model.ts";
|
|
4
4
|
import { normalizeThinkingV2, resolveWhenBusy, normalizeWorkerTimeout } from "../tools/tool.ts";
|
|
5
|
-
import type { ToolDefinition } from "../types/tool.ts";
|
|
5
|
+
import type { ToolDefinition, ToolResultRecord } from "../types/tool.ts";
|
|
6
|
+
import { isSubagentResult } from "../tools/subagent-result.ts";
|
|
6
7
|
import { CACHE_OPTIONS, type ThinkingLevel, type ThinkingConfig, type CacheOption, type ServiceTier } from "../types/core.ts";
|
|
7
8
|
import type { ContentPart } from "../types/message.ts";
|
|
8
9
|
import { AgentResponse, type StreamEvent } from "../types/response.ts";
|
|
@@ -19,10 +20,12 @@ import * as path from "node:path";
|
|
|
19
20
|
import { registerAgent } from "../scheduler/scheduler.ts";
|
|
20
21
|
import { resolveHome } from "../scheduler/store.ts";
|
|
21
22
|
import { persistTelemetry } from "../telemetry-file.ts";
|
|
23
|
+
import { lockFolder } from "../utils/durable.ts";
|
|
22
24
|
import { createScheduledActionsTool, getDatetimeTool } from "../scheduler/tool.ts";
|
|
23
25
|
import { assertNoToolCollisions } from "../tools/executor.ts";
|
|
24
|
-
import { runAgentLoop, streamAgentLoop, type SteerEntry } from "./loop.ts";
|
|
25
|
-
import {
|
|
26
|
+
import { runAgentLoop, streamAgentLoop, unfinishedToolCalls, endsWithStop, accumulateUsage, type SteerEntry } from "./loop.ts";
|
|
27
|
+
import { isRetryableRun, readRetryAfterMs, sleep } from "../utils/retry.ts";
|
|
28
|
+
import { createSessionId, nowMs, newRunId } from "../utils/session.ts";
|
|
26
29
|
import {
|
|
27
30
|
chargeBudgetUsage,
|
|
28
31
|
checkBudget,
|
|
@@ -31,7 +34,9 @@ import {
|
|
|
31
34
|
listSubAgentTraceIdsForSessions,
|
|
32
35
|
type SubAgentTrace,
|
|
33
36
|
} from "./delegation.ts";
|
|
34
|
-
import {
|
|
37
|
+
import { saveSessionFile, sessionDirFor, loadSessionDir, findInvalidMessage, telemetryBus, type SessionRunMark, type SessionRunEnd, type SessionPending, type SessionToolRecord } from "../session/store.ts";
|
|
38
|
+
import { SessionJournal } from "../session/journal.ts";
|
|
39
|
+
import { ToolLogs, type ToolLogWriter } from "../session/tool-log.ts";
|
|
35
40
|
import { isLocalPathInput } from "../utils/safe-io.ts";
|
|
36
41
|
import { clearSessionRouting, normalizeStructuredOutput, normalizeStop } from "../providers.ts";
|
|
37
42
|
import type { StructuredOutputSpec } from "../types/model.ts";
|
|
@@ -53,7 +58,7 @@ export function resolveEffectiveThinking(
|
|
|
53
58
|
return { enabled: true, level: overrideLevel as ThinkingConfig["level"] };
|
|
54
59
|
}
|
|
55
60
|
|
|
56
|
-
import { ConfigError, SessionLoadError } from "../types/errors.ts";
|
|
61
|
+
import { ConfigError, SessionLoadError, isAbortError } from "../types/errors.ts";
|
|
57
62
|
|
|
58
63
|
/**
|
|
59
64
|
* Disposers for the manual user-signal forward listeners (Q-21 fallback when
|
|
@@ -62,6 +67,9 @@ import { ConfigError, SessionLoadError } from "../types/errors.ts";
|
|
|
62
67
|
*/
|
|
63
68
|
const mergeDisposers = new WeakMap<AbortController, () => void>();
|
|
64
69
|
|
|
70
|
+
/** Waits before run retry 1, 2, 3 ... (the last one repeats). Module-level so tests can shrink it; not public API. */
|
|
71
|
+
export const RUN_RETRY_DELAYS_MS: number[] = [5_000, 15_000, 45_000, 60_000];
|
|
72
|
+
|
|
65
73
|
/** Max named sessions kept per Agent (Q-19a LRU). */
|
|
66
74
|
const SESSION_LRU_MAX = 100;
|
|
67
75
|
/** Idle TTL for named sessions (Q-19a). */
|
|
@@ -246,6 +254,23 @@ export class Agent {
|
|
|
246
254
|
private sessions = new Map<string, { context: AgentContext; lastAccess: number }>();
|
|
247
255
|
/** Coalesced persistence single-flight flag (Q-28). */
|
|
248
256
|
private persistScheduled = false;
|
|
257
|
+
/** Step journal of the persisted session folder (dir mode only). */
|
|
258
|
+
private journal?: SessionJournal;
|
|
259
|
+
/** Log files of tool calls in the persisted session folder (dir mode only). */
|
|
260
|
+
private toolLogs?: ToolLogs;
|
|
261
|
+
private toolLogsDir?: string;
|
|
262
|
+
/** Where each run of the default session began (saved with the session). @internal */
|
|
263
|
+
readonly runMarks: SessionRunMark[] = [];
|
|
264
|
+
/** Tool calls finished in the current run; saved the moment each finishes so a crash never repeats them. @internal */
|
|
265
|
+
readonly toolRecords: SessionToolRecord[] = [];
|
|
266
|
+
/** The run in flight (its mark has no ending yet, but it is not "interrupted"). */
|
|
267
|
+
private activeMark?: SessionRunMark;
|
|
268
|
+
/** What to do with a cut-off run at construction (see `AgentConfig.resume`). */
|
|
269
|
+
private readonly resumeMode: "automatic" | "explicit";
|
|
270
|
+
/** How often a failed run is tried again (see `AgentConfig.runRetries`). */
|
|
271
|
+
private readonly runRetries: number;
|
|
272
|
+
/** The resume in flight, so a second `resume()` returns the same promise. */
|
|
273
|
+
private resuming?: Promise<AgentResponse | undefined>;
|
|
249
274
|
|
|
250
275
|
/**
|
|
251
276
|
* Creates an agent and registers its model, tools, cache, and delegation settings.
|
|
@@ -313,6 +338,11 @@ export class Agent {
|
|
|
313
338
|
config.persist && !config.persist.dir && !config.persist.file
|
|
314
339
|
? { ...config.persist, dir: path.join(this.home, "sessions") }
|
|
315
340
|
: config.persist;
|
|
341
|
+
// One process per folder: fail fast here, before anything is written (the home for `persist: {}`, else the folder named).
|
|
342
|
+
if (config.persist) {
|
|
343
|
+
const { dir, file } = config.persist;
|
|
344
|
+
lockFolder(dir ?? (file ? path.dirname(path.resolve(file)) : this.home));
|
|
345
|
+
}
|
|
316
346
|
// `whenBusy` (canonical) wins over legacy `mode`; anything else is "auto".
|
|
317
347
|
const normalizedMode = resolveWhenBusy(config.midSession);
|
|
318
348
|
const rawMaxQueued = (config.midSession as any)?.maxQueued;
|
|
@@ -347,6 +377,14 @@ export class Agent {
|
|
|
347
377
|
);
|
|
348
378
|
}
|
|
349
379
|
this.cache = config.cache;
|
|
380
|
+
if (config.resume !== undefined && config.resume !== "automatic" && config.resume !== "explicit") {
|
|
381
|
+
throw new ConfigError(`[Agent Accelerator] Invalid resume ${JSON.stringify(config.resume)}: expected "automatic" (default) or "explicit".`);
|
|
382
|
+
}
|
|
383
|
+
this.resumeMode = config.resume ?? "automatic";
|
|
384
|
+
if (config.runRetries !== undefined && !(Number.isInteger(config.runRetries) && config.runRetries >= 0)) {
|
|
385
|
+
throw new ConfigError(`[Agent Accelerator] Invalid runRetries ${JSON.stringify(config.runRetries)}: expected a whole number >= 0 (default 3, 0 turns it off).`);
|
|
386
|
+
}
|
|
387
|
+
this.runRetries = config.runRetries ?? 3;
|
|
350
388
|
|
|
351
389
|
this.serviceTier = config.serviceTier;
|
|
352
390
|
this.onFailure =
|
|
@@ -415,6 +453,115 @@ export class Agent {
|
|
|
415
453
|
|
|
416
454
|
this.context = new AgentContext(this.getFullInstructions());
|
|
417
455
|
if (dyn?.allowScheduledActions) registerAgent(this);
|
|
456
|
+
if (this.recoverSession() && this.resumeMode === "automatic") {
|
|
457
|
+
// Synchronously, so a run() issued right after construction queues BEHIND the resumed run.
|
|
458
|
+
this.resume().catch((err) => this.reportResumeFailure(err));
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Loads a persisted session that a crash cut off (a run without an ending) or that still had waiting prompts, so it can be resumed; true when there is something to resume; in: the session folder of `persist.dir`, out: context, runMarks, toolRecords, pendingQueue.
|
|
463
|
+
private recoverSession(): boolean {
|
|
464
|
+
const root = this.persistConfig?.dir;
|
|
465
|
+
if (!root || this.stateless) return false;
|
|
466
|
+
let dir: string;
|
|
467
|
+
try {
|
|
468
|
+
dir = sessionDirFor(root, this.sessionId);
|
|
469
|
+
} catch {
|
|
470
|
+
return false; // not a usable session id for a folder name: nothing was ever saved under it
|
|
471
|
+
}
|
|
472
|
+
const session = loadSessionDir(dir)?.session; // a session over the load limits throws SessionLoadError: better than overwriting it
|
|
473
|
+
if (!session) return false;
|
|
474
|
+
const waiting = [...(session.pending?.steer ?? []), ...(session.pending?.queue ?? [])];
|
|
475
|
+
if (!(session.runs ?? []).some((r) => !r.end) && waiting.length === 0) return false;
|
|
476
|
+
this.context.messages = session.messages;
|
|
477
|
+
this.runMarks.splice(0, this.runMarks.length, ...(session.runs ?? []));
|
|
478
|
+
const open = new Set(unfinishedToolCalls(this.context.messages).map((c) => c.id));
|
|
479
|
+
this.toolRecords.splice(0, this.toolRecords.length, ...(session.tools ?? []).filter((t) => open.has(t.id)));
|
|
480
|
+
for (const w of waiting) {
|
|
481
|
+
this.pendingQueue.push({
|
|
482
|
+
prompt: w.prompt,
|
|
483
|
+
options: w.additionalContext ? { additionalContext: w.additionalContext } : undefined,
|
|
484
|
+
isStream: false,
|
|
485
|
+
resolve: () => {},
|
|
486
|
+
reject: (err) => this.reportResumeFailure(err),
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
return true;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// Says on the telemetry bus that resuming (or a prompt that was waiting) failed; in: a rejected resume, out: telemetryBus.
|
|
493
|
+
private reportResumeFailure(err: unknown): void {
|
|
494
|
+
telemetryBus.emitWarning("agent.resume_failed", `[Agent Accelerator] Resuming session ${this.sessionId} failed: ${err instanceof Error ? err.message : String(err)}`, { sessionId: this.sessionId });
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/** Runs that a crash cut off (they began, never ended and are not running now), oldest first. Needs `persist: { dir }` and the same `sessionId` as before the restart. */
|
|
498
|
+
interrupted(): InterruptedRun[] {
|
|
499
|
+
const calls = unfinishedToolCalls(this.context.messages).map((c) => c.name);
|
|
500
|
+
return this.runMarks
|
|
501
|
+
.filter((m) => !m.end && m !== this.activeMark)
|
|
502
|
+
.map((m) => ({
|
|
503
|
+
runId: m.id,
|
|
504
|
+
startedAt: m.at,
|
|
505
|
+
prompt: this.previewPrompt((this.context.messages[m.i]?.content ?? "") as string | ContentPart[]),
|
|
506
|
+
pendingToolCalls: calls,
|
|
507
|
+
}));
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Continues the run a crash cut off, without a new prompt: tool calls that already have a saved result are NOT run
|
|
512
|
+
* again (only calls with no result are), then the model carries on from the saved conversation, and prompts that
|
|
513
|
+
* were waiting run afterwards. Resolves to that run's response, or `undefined` when nothing was cut off. Returns the
|
|
514
|
+
* same promise while a resume is in flight. With `resume: "automatic"` (default) the constructor already did this.
|
|
515
|
+
*/
|
|
516
|
+
resume(): Promise<AgentResponse | undefined> {
|
|
517
|
+
if (this.resuming) return this.resuming;
|
|
518
|
+
if (this.currentRun) throw new Error("[Agent Accelerator] resume() needs an idle agent: a run is already active.");
|
|
519
|
+
const mark = this.runMarks.filter((m) => !m.end && m !== this.activeMark).pop();
|
|
520
|
+
const last = this.context.messages[this.context.messages.length - 1];
|
|
521
|
+
const nothingOpen = unfinishedToolCalls(this.context.messages).length === 0;
|
|
522
|
+
if (!mark || (nothingOpen && (last?.role === "assistant" || endsWithStop(this.context.messages)))) {
|
|
523
|
+
// The answer was complete (or a tool had ended the run); only the note that it ended was lost.
|
|
524
|
+
if (mark) this.endRun(mark, endsWithStop(this.context.messages) ? "stopped" : "done");
|
|
525
|
+
this.pumpQueue();
|
|
526
|
+
return Promise.resolve(undefined);
|
|
527
|
+
}
|
|
528
|
+
telemetryBus.emit({
|
|
529
|
+
code: "agent.resumed",
|
|
530
|
+
message: `[Agent Accelerator] resuming run ${mark.id} of session ${this.sessionId}`,
|
|
531
|
+
data: { sessionId: this.sessionId, runId: mark.id, pendingToolCalls: unfinishedToolCalls(this.context.messages).map((c) => c.name) },
|
|
532
|
+
});
|
|
533
|
+
const run = this.startNonStreamingRun("", undefined, mark);
|
|
534
|
+
const clear = (): void => {
|
|
535
|
+
if (this.resuming === run) this.resuming = undefined;
|
|
536
|
+
};
|
|
537
|
+
this.resuming = run;
|
|
538
|
+
run.then(clear, clear);
|
|
539
|
+
return run;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// The finished tool calls of the current run as results the loop can reuse; in: toolRecords, out: loopConfig.resume.stored.
|
|
543
|
+
private storedToolResults(): Map<string, ToolResultRecord> {
|
|
544
|
+
return new Map(this.toolRecords.map((t) => [t.id, { id: t.id, name: t.name, result: t.result, ...(t.isError ? { isError: true } : {}), ...(t.stopped ? { stopped: true } : {}) } as ToolResultRecord]));
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// Closes a run's mark with how it ended and saves that at once (not on the next tick), so the disk is up to date when `await run()` returns; in: the run's promise handlers, out: runMarks + journal.
|
|
548
|
+
private endRun(mark: SessionRunMark | undefined, status: SessionRunEnd): void {
|
|
549
|
+
if (!mark) return;
|
|
550
|
+
if (!mark.end) mark.end = status;
|
|
551
|
+
if (this.activeMark === mark) this.activeMark = undefined;
|
|
552
|
+
this.doPersist();
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// Remembers a finished tool call and saves it at once (synchronously): the work is on disk before anything else can happen; in: the executor's per-call callback, out: toolRecords + journal.
|
|
556
|
+
private recordTool(result: ToolResultRecord): void {
|
|
557
|
+
this.toolRecords.push({
|
|
558
|
+
id: result.id,
|
|
559
|
+
name: result.name,
|
|
560
|
+
result: isSubagentResult(result.result) ? result.result.xml : result.result,
|
|
561
|
+
...(result.isError ? { isError: true } : {}),
|
|
562
|
+
...(result.stopped ? { stopped: true } : {}),
|
|
563
|
+
});
|
|
564
|
+
this.doPersist();
|
|
418
565
|
}
|
|
419
566
|
|
|
420
567
|
private getFullInstructions(): string | undefined {
|
|
@@ -461,6 +608,7 @@ export class Agent {
|
|
|
461
608
|
this.context.messages = [];
|
|
462
609
|
this.context.thoughtSignatures = [];
|
|
463
610
|
this.context.systemPrompt = this.getFullInstructions();
|
|
611
|
+
this.runMarks.length = 0;
|
|
464
612
|
// Q-25: reset also drops session-scoped provider chaining/routing.
|
|
465
613
|
try { clearInteractionChains(sid); } catch {}
|
|
466
614
|
try { clearSessionRouting(sid); } catch {}
|
|
@@ -546,6 +694,7 @@ export class Agent {
|
|
|
546
694
|
try { entry.reject(err); } catch {}
|
|
547
695
|
try { entry.deferredStream?.fail(err instanceof Error ? err : new Error(String(err))); } catch {}
|
|
548
696
|
}
|
|
697
|
+
this.persistNow();
|
|
549
698
|
return n;
|
|
550
699
|
}
|
|
551
700
|
|
|
@@ -716,6 +865,7 @@ export class Agent {
|
|
|
716
865
|
}
|
|
717
866
|
return new Promise<AgentResponse>((resolve, reject) => {
|
|
718
867
|
this.pendingQueue.push({ prompt, options, isStream: false, resolve, reject });
|
|
868
|
+
this.persistNow();
|
|
719
869
|
try {
|
|
720
870
|
this.currentRun?.outerStream?.push({
|
|
721
871
|
type: "queued",
|
|
@@ -730,6 +880,7 @@ export class Agent {
|
|
|
730
880
|
if (this.currentRun !== null) return;
|
|
731
881
|
const next = this.pendingQueue.shift();
|
|
732
882
|
if (!next) return;
|
|
883
|
+
this.persistNow();
|
|
733
884
|
// Q-20: reject queued items whose signal aborted before start.
|
|
734
885
|
try {
|
|
735
886
|
if ((next.options as { signal?: AbortSignal } | undefined)?.signal?.aborted) {
|
|
@@ -788,6 +939,8 @@ export class Agent {
|
|
|
788
939
|
if (leftover.length > 0) {
|
|
789
940
|
try {
|
|
790
941
|
const max = this.midSessionConfig.maxQueued ?? 20;
|
|
942
|
+
// Late steers were typed BEFORE anything already queued: they go to the front, in the order they arrived.
|
|
943
|
+
const lateSteers: Agent["pendingQueue"] = [];
|
|
791
944
|
for (const entry of leftover) {
|
|
792
945
|
try {
|
|
793
946
|
if ((entry.options as { signal?: AbortSignal } | undefined)?.signal?.aborted) {
|
|
@@ -795,14 +948,14 @@ export class Agent {
|
|
|
795
948
|
continue;
|
|
796
949
|
}
|
|
797
950
|
} catch { continue; }
|
|
798
|
-
if (this.pendingQueue.length >= max) {
|
|
951
|
+
if (this.pendingQueue.length + lateSteers.length >= max) {
|
|
799
952
|
entry.settle?.reject(new Error("[Agent Accelerator] Steer dropped: the follow-up queue is full."));
|
|
800
953
|
continue;
|
|
801
954
|
}
|
|
802
955
|
// The steer was never injected: it now runs as a follow-up, and the
|
|
803
956
|
// caller's promise resolves with that run's response.
|
|
804
957
|
if (entry.settle) entry.settle.deferredToQueue = true;
|
|
805
|
-
|
|
958
|
+
lateSteers.push({
|
|
806
959
|
prompt: entry.prompt,
|
|
807
960
|
options: entry.options,
|
|
808
961
|
isStream: false,
|
|
@@ -810,8 +963,10 @@ export class Agent {
|
|
|
810
963
|
reject: entry.settle?.reject ?? (() => {}),
|
|
811
964
|
});
|
|
812
965
|
}
|
|
966
|
+
this.pendingQueue.unshift(...lateSteers);
|
|
813
967
|
} catch {}
|
|
814
968
|
}
|
|
969
|
+
this.persistNow();
|
|
815
970
|
// Defer pump so the completing run's .then handlers settle first.
|
|
816
971
|
queueMicrotask(() => this.pumpQueue());
|
|
817
972
|
}
|
|
@@ -962,9 +1117,11 @@ export class Agent {
|
|
|
962
1117
|
|
|
963
1118
|
private startNonStreamingRun(
|
|
964
1119
|
prompt: string | ContentPart[],
|
|
965
|
-
options?: AgentRunOptions
|
|
1120
|
+
options?: AgentRunOptions,
|
|
1121
|
+
resumeMark?: SessionRunMark
|
|
966
1122
|
): Promise<AgentResponse> {
|
|
967
1123
|
this.resetBudgetState();
|
|
1124
|
+
let mark: SessionRunMark | undefined;
|
|
968
1125
|
const runController = new AbortController();
|
|
969
1126
|
const steerInbox: SteerEntry[] = [];
|
|
970
1127
|
const mergedSignal = this.mergeUserSignal(options?.signal, runController);
|
|
@@ -978,7 +1135,14 @@ export class Agent {
|
|
|
978
1135
|
if (effectiveLevel) {
|
|
979
1136
|
validateModelThinking(resolved.provider.id, resolved.modelId, effectiveLevel);
|
|
980
1137
|
}
|
|
981
|
-
|
|
1138
|
+
if (resumeMark) {
|
|
1139
|
+
// Continuing a cut-off run: no new prompt, the saved conversation is the input.
|
|
1140
|
+
mark = resumeMark;
|
|
1141
|
+
this.activeMark = mark;
|
|
1142
|
+
targetContext.systemPrompt = this.getFullInstructions();
|
|
1143
|
+
} else {
|
|
1144
|
+
mark = this.prepareTurn(prompt, runOptions, targetContext);
|
|
1145
|
+
}
|
|
982
1146
|
this.persistNow();
|
|
983
1147
|
|
|
984
1148
|
const providerOptions = this.buildProviderOptions(runOptions);
|
|
@@ -997,6 +1161,9 @@ export class Agent {
|
|
|
997
1161
|
...(this.captureRaw !== undefined ? { captureRaw: this.captureRaw } : {}),
|
|
998
1162
|
...(this.budget ? { enforceBudget: (u?: TokenUsage) => this.enforceBudget(u) } : {}),
|
|
999
1163
|
onProgress: () => this.persistNow(),
|
|
1164
|
+
onToolResult: (r: ToolResultRecord) => this.recordTool(r),
|
|
1165
|
+
openToolLog: (call: { name: string }, sid: string | undefined) => this.openToolLog(call, sid),
|
|
1166
|
+
...(resumeMark ? { resume: { stored: this.storedToolResults() } } : {}),
|
|
1000
1167
|
steerInbox,
|
|
1001
1168
|
onSteerInjected: () => this.persistNow(),
|
|
1002
1169
|
// Q-22: loop enforces these (plus local timeout races).
|
|
@@ -1004,7 +1171,28 @@ export class Agent {
|
|
|
1004
1171
|
...(this.output !== undefined ? { output: this.output } : {}),
|
|
1005
1172
|
};
|
|
1006
1173
|
|
|
1007
|
-
|
|
1174
|
+
// A run that failed for a transient reason is tried again from where it stopped (the prompt is already in history,
|
|
1175
|
+
// finished tool calls are kept), holding the agent busy meanwhile; anything else fails at once.
|
|
1176
|
+
let res: AgentResponse;
|
|
1177
|
+
const failedAttempts: FailedAttempt[] = [];
|
|
1178
|
+
for (let attempt = 0; ; attempt++) {
|
|
1179
|
+
try {
|
|
1180
|
+
res = await runAgentLoop(attempt === 0 ? loopConfig : { ...loopConfig, resume: { stored: this.storedToolResults() } });
|
|
1181
|
+
break;
|
|
1182
|
+
} catch (err) {
|
|
1183
|
+
if (attempt >= this.runRetries || runOptions?.signal?.aborted || !isRetryableRun(err)) throw err;
|
|
1184
|
+
const partial = (err as { partial?: FailedAttempt }).partial;
|
|
1185
|
+
if (partial?.usage) failedAttempts.push(partial);
|
|
1186
|
+
const delayMs = Math.max(RUN_RETRY_DELAYS_MS[Math.min(attempt, RUN_RETRY_DELAYS_MS.length - 1)]!, readRetryAfterMs(err) ?? 0);
|
|
1187
|
+
telemetryBus.emitWarning(
|
|
1188
|
+
"agent.run_retry",
|
|
1189
|
+
`[Agent Accelerator] Run failed (${err instanceof Error ? err.message : String(err)}); retry ${attempt + 1} of ${this.runRetries} in ${delayMs} ms.`,
|
|
1190
|
+
{ sessionId: this.sessionId, attempt: attempt + 1, of: this.runRetries, delayMs }
|
|
1191
|
+
);
|
|
1192
|
+
await sleep(delayMs, runOptions?.signal);
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
if (failedAttempts.length > 0) res = withFailedAttempts(res, failedAttempts);
|
|
1008
1196
|
this.mergeSubagentTraces(res.subagents as any);
|
|
1009
1197
|
this.persistNow();
|
|
1010
1198
|
if (this.stateless) {
|
|
@@ -1015,6 +1203,10 @@ export class Agent {
|
|
|
1015
1203
|
})();
|
|
1016
1204
|
|
|
1017
1205
|
this.currentRun = { steerInbox, abortController: runController, runPromise: task };
|
|
1206
|
+
task.then(
|
|
1207
|
+
(res) => this.endRun(mark, res.finishReason === "stopped" ? "stopped" : "done"),
|
|
1208
|
+
(err) => this.endRun(mark, isAbortError(err) ? "aborted" : "failed")
|
|
1209
|
+
);
|
|
1018
1210
|
const cleanup = () => {
|
|
1019
1211
|
try { this.cleanupMerge(runController); } catch {}
|
|
1020
1212
|
if (this.currentRun?.runPromise === task) this.finishCurrentRun();
|
|
@@ -1034,7 +1226,7 @@ export class Agent {
|
|
|
1034
1226
|
}
|
|
1035
1227
|
// Q-19a: named session routing (default session for compat).
|
|
1036
1228
|
const targetContext = this.getSessionContext(options?.sessionId || this.sessionId);
|
|
1037
|
-
this.prepareTurn(prompt, options, targetContext);
|
|
1229
|
+
const mark = this.prepareTurn(prompt, options, targetContext);
|
|
1038
1230
|
this.persistNow();
|
|
1039
1231
|
|
|
1040
1232
|
this.resetBudgetState();
|
|
@@ -1059,6 +1251,8 @@ export class Agent {
|
|
|
1059
1251
|
...(this.captureRaw !== undefined ? { captureRaw: this.captureRaw } : {}),
|
|
1060
1252
|
...(this.budget ? { enforceBudget: (u?: TokenUsage) => this.enforceBudget(u) } : {}),
|
|
1061
1253
|
onProgress: () => this.persistNow(),
|
|
1254
|
+
onToolResult: (r: ToolResultRecord) => this.recordTool(r),
|
|
1255
|
+
openToolLog: (call: { name: string }, sid: string | undefined) => this.openToolLog(call, sid),
|
|
1062
1256
|
steerInbox,
|
|
1063
1257
|
onSteerInjected: () => this.persistNow(),
|
|
1064
1258
|
// Q-22: loop enforces these (plus local timeout races).
|
|
@@ -1070,6 +1264,7 @@ export class Agent {
|
|
|
1070
1264
|
this.currentRun = { steerInbox, outerStream: s, abortController: runController };
|
|
1071
1265
|
s.result().then(
|
|
1072
1266
|
(res) => {
|
|
1267
|
+
this.endRun(mark, res.finishReason === "stopped" ? "stopped" : "done");
|
|
1073
1268
|
try {
|
|
1074
1269
|
this.mergeSubagentTraces(res.subagents as any);
|
|
1075
1270
|
this.persistNow();
|
|
@@ -1081,7 +1276,8 @@ export class Agent {
|
|
|
1081
1276
|
}
|
|
1082
1277
|
if (this.currentRun?.outerStream === s) this.finishCurrentRun();
|
|
1083
1278
|
},
|
|
1084
|
-
() => {
|
|
1279
|
+
(err) => {
|
|
1280
|
+
this.endRun(mark, isAbortError(err) ? "aborted" : "failed");
|
|
1085
1281
|
try { this.cleanupMerge(runController); } catch {}
|
|
1086
1282
|
if (this.currentRun?.outerStream === s) this.finishCurrentRun();
|
|
1087
1283
|
}
|
|
@@ -1168,6 +1364,25 @@ export class Agent {
|
|
|
1168
1364
|
});
|
|
1169
1365
|
}
|
|
1170
1366
|
|
|
1367
|
+
// Opens the log file of one tool call when this Agent persists its default session to a folder, else nothing; in: the executor, once per call, out: a writer for `tool-logs/<n>-<tool>.ndjson`.
|
|
1368
|
+
private openToolLog(call: { name: string }, sessionId: string | undefined): ToolLogWriter | undefined {
|
|
1369
|
+
const root = this.persistConfig?.dir;
|
|
1370
|
+
if (!root || this.stateless || sessionId !== this.sessionId) return undefined;
|
|
1371
|
+
const dir = sessionDirFor(root, this.sessionId);
|
|
1372
|
+
if (this.toolLogsDir !== dir) {
|
|
1373
|
+
this.toolLogs = new ToolLogs(dir);
|
|
1374
|
+
this.toolLogsDir = dir;
|
|
1375
|
+
}
|
|
1376
|
+
return this.toolLogs!.open(call);
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
/** The step journal of this session's folder (recreated when `sessionId` changes). */
|
|
1380
|
+
private journalFor(root: string): SessionJournal {
|
|
1381
|
+
const dir = sessionDirFor(root, this.sessionId);
|
|
1382
|
+
if (this.journal?.dir !== dir) this.journal = new SessionJournal(dir);
|
|
1383
|
+
return this.journal;
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1171
1386
|
/** Synchronous persistence write behind the coalesced `persistNow()` gate. */
|
|
1172
1387
|
private doPersist(): void {
|
|
1173
1388
|
const cfg = this.persistConfig;
|
|
@@ -1176,7 +1391,7 @@ export class Agent {
|
|
|
1176
1391
|
if (this.stateless && !(cfg as unknown as { allowStateless?: unknown }).allowStateless) return;
|
|
1177
1392
|
} catch {}
|
|
1178
1393
|
try {
|
|
1179
|
-
if (cfg.dir)
|
|
1394
|
+
if (cfg.dir) this.journalFor(cfg.dir).sync(this as any);
|
|
1180
1395
|
else if (cfg.file) saveSessionFile(cfg.file, this as any, null);
|
|
1181
1396
|
} catch (err) {
|
|
1182
1397
|
// Q-28: surface via hook instead of swallowing silently.
|
|
@@ -1285,6 +1500,7 @@ export class Agent {
|
|
|
1285
1500
|
this.context.messages = Array.isArray(data.messages) ? [...data.messages] : [];
|
|
1286
1501
|
}
|
|
1287
1502
|
this.context.thoughtSignatures = [];
|
|
1503
|
+
this.runMarks.splice(0, this.runMarks.length, ...(data.runs ?? []).filter((r) => r.i < this.context.messages.length));
|
|
1288
1504
|
if (data.systemPrompt) {
|
|
1289
1505
|
this.context.systemPrompt = data.systemPrompt;
|
|
1290
1506
|
} else {
|
|
@@ -1324,7 +1540,7 @@ export class Agent {
|
|
|
1324
1540
|
} catch {}
|
|
1325
1541
|
}
|
|
1326
1542
|
|
|
1327
|
-
private prepareTurn(prompt: string | ContentPart[], options?: AgentRunOptions, target?: AgentContext):
|
|
1543
|
+
private prepareTurn(prompt: string | ContentPart[], options?: AgentRunOptions, target?: AgentContext): SessionRunMark | undefined {
|
|
1328
1544
|
// Q-20: prompt validation throws synchronously in both run() and
|
|
1329
1545
|
// stream() (same timing; documented here).
|
|
1330
1546
|
if (Array.isArray(prompt) && prompt.length === 0) {
|
|
@@ -1352,7 +1568,28 @@ export class Agent {
|
|
|
1352
1568
|
} else if (ctx.systemPrompt !== fullInstructions) {
|
|
1353
1569
|
ctx.systemPrompt = fullInstructions;
|
|
1354
1570
|
}
|
|
1571
|
+
const mark = ctx === this.context ? this.markRunStart(ctx.messages.length) : undefined;
|
|
1355
1572
|
ctx.addUserMessage(prompt);
|
|
1573
|
+
return mark;
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
// Records where a run of the default session begins; in: prepareTurn, out: runMarks, saved by the session journal.
|
|
1577
|
+
private markRunStart(index: number): SessionRunMark {
|
|
1578
|
+
for (const old of this.runMarks) if (!old.end) old.end = "abandoned"; // a newer run replaces a cut-off one
|
|
1579
|
+
this.toolRecords.length = 0;
|
|
1580
|
+
const mark: SessionRunMark = { id: newRunId(), i: index, at: Date.now() };
|
|
1581
|
+
this.runMarks.push(mark);
|
|
1582
|
+
this.activeMark = mark;
|
|
1583
|
+
return mark;
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
/** Prompts still waiting (steers not yet injected, queued follow-ups), as the session journal saves them. @internal */
|
|
1587
|
+
pendingPrompts(): SessionPending {
|
|
1588
|
+
const pack = (e: { prompt: string | ContentPart[]; options?: AgentRunOptions }) => ({
|
|
1589
|
+
prompt: e.prompt,
|
|
1590
|
+
...(e.options?.additionalContext ? { additionalContext: e.options.additionalContext } : {}),
|
|
1591
|
+
});
|
|
1592
|
+
return { steer: (this.currentRun?.steerInbox ?? []).map(pack), queue: this.pendingQueue.map(pack) };
|
|
1356
1593
|
}
|
|
1357
1594
|
|
|
1358
1595
|
/**
|
|
@@ -1432,6 +1669,7 @@ export class Agent {
|
|
|
1432
1669
|
}
|
|
1433
1670
|
return new Promise<AgentResponse>((resolve, reject) => {
|
|
1434
1671
|
this.pendingQueue.push({ prompt, options, isStream: false, resolve, reject });
|
|
1672
|
+
this.persistNow();
|
|
1435
1673
|
try {
|
|
1436
1674
|
this.currentRun?.outerStream?.push({
|
|
1437
1675
|
type: "queued",
|
|
@@ -1615,6 +1853,7 @@ export class Agent {
|
|
|
1615
1853
|
} catch {}
|
|
1616
1854
|
});
|
|
1617
1855
|
this.pendingQueue.push(queuedEntry as any);
|
|
1856
|
+
this.persistNow();
|
|
1618
1857
|
// Attach deferred completion to queued promise without exposing it.
|
|
1619
1858
|
// Real events are piped in pumpQueue() when the turn starts.
|
|
1620
1859
|
void done.then(
|
|
@@ -1852,6 +2091,35 @@ export class Agent {
|
|
|
1852
2091
|
}
|
|
1853
2092
|
}
|
|
1854
2093
|
|
|
2094
|
+
/** What a failed run attempt got done before it failed (the `partial` the loop attaches to its error). */
|
|
2095
|
+
type FailedAttempt = Partial<AgentResponse> & { usage: TokenUsage };
|
|
2096
|
+
|
|
2097
|
+
// Adds what failed attempts already did (tool calls, results, turns, token usage) to the response of the attempt that finally worked, so a retry never hides side effects or cost; in: the final response + each failed attempt's `err.partial`, out: the run's AgentResponse.
|
|
2098
|
+
function withFailedAttempts(res: AgentResponse, failed: FailedAttempt[]): AgentResponse {
|
|
2099
|
+
const usage: TokenUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
|
2100
|
+
for (const f of failed) accumulateUsage(usage, f.usage);
|
|
2101
|
+
accumulateUsage(usage, res.usage);
|
|
2102
|
+
return new AgentResponse({
|
|
2103
|
+
text: res.text,
|
|
2104
|
+
...(res.parsed !== undefined ? { parsed: res.parsed } : {}),
|
|
2105
|
+
thinking: res.thinking,
|
|
2106
|
+
thoughtSignature: res.thoughtSignature,
|
|
2107
|
+
toolCalls: [...failed.flatMap((f) => f.toolCalls ?? []), ...res.toolCalls],
|
|
2108
|
+
toolResults: [...failed.flatMap((f) => f.toolResults ?? []), ...res.toolResults],
|
|
2109
|
+
subagents: res.subagents,
|
|
2110
|
+
usage,
|
|
2111
|
+
responseId: res.responseId,
|
|
2112
|
+
model: res.model,
|
|
2113
|
+
provider: res.provider,
|
|
2114
|
+
finishReason: res.finishReason,
|
|
2115
|
+
rawFinishReason: res.rawFinishReason,
|
|
2116
|
+
durationMs: res.durationMs,
|
|
2117
|
+
raw: res.raw,
|
|
2118
|
+
turns: res.turns + failed.reduce((n, f) => n + (typeof f.turns === "number" ? f.turns : 0), 0),
|
|
2119
|
+
providerSteps: res.providerSteps,
|
|
2120
|
+
});
|
|
2121
|
+
}
|
|
2122
|
+
|
|
1855
2123
|
/** Normalizes the public `thinkingLevel` flag into provider-neutral thinking settings. */
|
|
1856
2124
|
export function normalizeThinking(config: AgentConfig, fallbackLevel?: string): ThinkingConfig | undefined {
|
|
1857
2125
|
// DX4: only thinkingLevel, values: none, dynamic, minimal, low, medium, high, xhigh, max
|
package/src/agent/context.ts
CHANGED