agent-accelerator 0.3.0 → 0.4.5

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.
@@ -1,4 +1,4 @@
1
- import type { AgentConfig, AgentRunOptions } from "../types/agent.ts";
1
+ import type { AgentConfig, AgentRunOptions, MidSessionConfig } from "../types/agent.ts";
2
2
  import type { ToolDefinition } from "../types/tool.ts";
3
3
  import type { ThinkingLevel, ThinkingConfig, CacheConfig, ServiceTier } from "../types/core.ts";
4
4
  import type { ContentPart } from "../types/message.ts";
@@ -8,10 +8,13 @@ import { AgentContext } from "./context.ts";
8
8
  import { resolveModel } from "../providers/registry.ts";
9
9
  import { buildAgentTools, createSubagentSpawnTool } from "./delegation.ts";
10
10
  import { convert_document_to_markdown } from "../utils/documents.ts";
11
- import { runAgentLoop, streamAgentLoop } from "./loop.ts";
11
+ import { runAgentLoop, streamAgentLoop, type SteerEntry } from "./loop.ts";
12
12
  import { createSessionId } from "../utils/session.ts";
13
13
  import { getSubAgentTrace, subscribeToSubAgent, listSubAgentTraceIds, type SubAgentTrace } from "./delegation.ts";
14
14
  import { saveSessionDir, saveSessionFile } from "../session/store.ts";
15
+ import { clearSessionRouting, normalizeStructuredOutput } from "../providers.ts";
16
+ import type { StructuredOutputSpec } from "../types/model.ts";
17
+ import { clearInteractionChains } from "../providers/google.ts";
15
18
  import { getModel, getSubModel } from "../utils/env.ts";
16
19
  import { validateModelThinking, ensureModelCatalogFresh } from "../models/catalog.ts";
17
20
  import { buildSessionData, type PersistedAgentSession, type SessionTotals,} from "../session/store.ts";
@@ -30,8 +33,49 @@ export function resolveEffectiveThinking(
30
33
  return { enabled: true, level: overrideLevel as ThinkingConfig["level"] };
31
34
  }
32
35
 
36
+ import { ConfigError } from "../types/errors.ts";
37
+
38
+ /**
39
+ * Disposers for the manual user-signal forward listeners (Q-21 fallback when
40
+ * `AbortSignal.any` is unavailable). Keyed by run controller so no expando
41
+ * properties are attached to AbortController instances.
42
+ */
43
+ const mergeDisposers = new WeakMap<AbortController, () => void>();
44
+
45
+ /** Max named sessions kept per Agent (Q-19a LRU). */
46
+ const SESSION_LRU_MAX = 100;
47
+ /** Idle TTL for named sessions (Q-19a). */
48
+ const SESSION_TTL_MS = 60 * 60 * 1000;
49
+
50
+ /** Normalized per-run limits (Q-22). Sub-agents inherit these unless overridden. */
51
+ export interface AgentRunLimits {
52
+ maxTurns?: number;
53
+ deadlineMs?: number;
54
+ maxToolCallsPerRun?: number;
55
+ requestTimeoutMs: number;
56
+ firstByteTimeoutMs: number;
57
+ streamIdleTimeoutMs: number;
58
+ }
59
+
60
+ /** Resolves `(config as any).limits` with Q-22 defaults (timeouts tolerate reasoning). */
61
+ function resolveAgentLimits(config: { limits?: Record<string, unknown> }): AgentRunLimits {
62
+ const raw = (config as { limits?: Record<string, unknown> }).limits ?? {};
63
+ const num = (v: unknown): number | undefined =>
64
+ typeof v === "number" && Number.isFinite(v) ? v : undefined;
65
+ return {
66
+ ...(num(raw.maxTurns) !== undefined ? { maxTurns: Math.max(1, Math.floor(num(raw.maxTurns)!)) } : {}),
67
+ ...(num(raw.deadlineMs) !== undefined ? { deadlineMs: num(raw.deadlineMs)! } : {}),
68
+ ...(num(raw.maxToolCallsPerRun) !== undefined
69
+ ? { maxToolCallsPerRun: Math.max(1, Math.floor(num(raw.maxToolCallsPerRun)!)) }
70
+ : {}),
71
+ requestTimeoutMs: num(raw.requestTimeoutMs) ?? 300_000,
72
+ firstByteTimeoutMs: num(raw.firstByteTimeoutMs) ?? 120_000,
73
+ streamIdleTimeoutMs: num(raw.streamIdleTimeoutMs) ?? 300_000,
74
+ };
75
+ }
76
+
33
77
  /** Thrown when dynamic sub-agent spawning is enabled without an explicit model. */
34
- export class SubAgentModelError extends Error {
78
+ export class SubAgentModelError extends ConfigError {
35
79
  readonly parentModel?: string;
36
80
 
37
81
  /**
@@ -43,20 +87,12 @@ export class SubAgentModelError extends Error {
43
87
  constructor(parentModel?: string) {
44
88
  const parentName = parentModel || "your main model";
45
89
  const formatted =
46
- `\x1b[31m[Agent Accelerator] Missing Configuration: a sub-agent model is required when dynamic sub-agents are enabled\x1b[0m\n` +
47
- ` \x1b[1mMain Agent Model:\x1b[0m ${parentName}\n` +
48
- ` \x1b[1mIssue:\x1b[0m Dynamic sub-agent delegation was enabled (dynamicSubagents.enabled), but no model was assigned for sub-agents.\n` +
49
- ` Sub-agents must never run on unverified models or default implicitly.\n\n` +
50
- ` \x1b[36m💡 How to fix:\x1b[0m\n` +
51
- ` 1. Pass a model in your Agent configuration:\n` +
52
- ` const agent = new Agent({\n` +
53
- ` model: "${parentName}",\n` +
54
- ` dynamicSubagents: { enabled: true, model: "provider/model-id" },\n` +
55
- ` });\n\n` +
56
- ` 2. Or set the SUB_AGENT_MODEL environment variable in your .env or shell:\n` +
57
- ` SUB_AGENT_MODEL="provider/model-id"`;
58
-
59
- super(formatted);
90
+ `[Agent Accelerator] Missing Configuration: a sub-agent model is required when dynamic sub-agents are enabled. ` +
91
+ `Main Agent Model: ${parentName}. ` +
92
+ `Dynamic sub-agent delegation was enabled (dynamicSubagents.enabled), but no model was assigned for sub-agents. ` +
93
+ `Fix: pass dynamicSubagents.model in Agent config or set SUB_AGENT_MODEL.`;
94
+
95
+ super(formatted, { parentModel: parentName });
60
96
  this.name = "SubAgentModelError";
61
97
  this.parentModel = parentModel;
62
98
 
@@ -104,6 +140,10 @@ export class Agent {
104
140
  readonly tools: Record<string, ToolDefinition> = {};
105
141
  /** Normalized reasoning configuration. */
106
142
  readonly thinkingConfig?: ThinkingConfig;
143
+ /** Canonical structured-output request (raw `AgentConfig.output`). */
144
+ readonly output?: unknown;
145
+ /** Normalized structured-output spec sent to providers. */
146
+ readonly outputSpec?: StructuredOutputSpec;
107
147
  /** Cache retention/session settings. */
108
148
  readonly cacheConfig?: CacheConfig;
109
149
  /** Provider service tier. */
@@ -126,6 +166,35 @@ export class Agent {
126
166
  readonly stateless: boolean;
127
167
  /** Durable persistence target: session file rewritten after every step. */
128
168
  readonly persistConfig?: { dir?: string; file?: string };
169
+ /** Mid-session queue policy for new queries arriving while a run is active. */
170
+ readonly midSessionConfig: Required<Pick<MidSessionConfig, "mode">> & MidSessionConfig & { maxSteers?: number };
171
+ /** Max buffered steer entries per run (Q-20, default 20). */
172
+ readonly maxSteers: number;
173
+ /**
174
+ * Normalized run limits (Q-22). Read from `(config as any).limits`;
175
+ * sub-agents inherit these unless overridden. `maxTurns`/`deadlineMs`/
176
+ * `maxToolCallsPerRun` default to unlimited; timeouts default to
177
+ * request 300s / first-byte 120s / idle 300s (tolerate reasoning).
178
+ */
179
+ readonly runLimits: AgentRunLimits;
180
+ private currentRun: {
181
+ steerInbox: SteerEntry[];
182
+ outerStream?: AssistantMessageEventStream;
183
+ runPromise?: Promise<AgentResponse>;
184
+ abortController: AbortController;
185
+ } | null = null;
186
+ private pendingQueue: Array<{
187
+ prompt: string | ContentPart[];
188
+ options?: AgentRunOptions;
189
+ isStream: boolean;
190
+ deferredStream?: AssistantMessageEventStream;
191
+ resolve: (res: AgentResponse) => void;
192
+ reject: (err: unknown) => void;
193
+ }> = [];
194
+ /** Named session contexts for `sessionId` runs (Q-19a, LRU 100 + TTL). */
195
+ private sessions = new Map<string, { context: AgentContext; lastAccess: number }>();
196
+ /** Coalesced persistence single-flight flag (Q-28). */
197
+ private persistScheduled = false;
129
198
 
130
199
  /**
131
200
  * Creates an agent and registers its model, tools, cache, and delegation settings.
@@ -212,11 +281,34 @@ export class Agent {
212
281
  this.sessionId = config.sessionId || config.cache?.sessionId || createSessionId();
213
282
  (this as any).subagentTraces = {};
214
283
  this.persistConfig = config.persist;
284
+ const rawMode = config.midSession?.mode ?? "auto";
285
+ const normalizedMode = rawMode === "steer" || rawMode === "queue" ? rawMode : "auto";
286
+ const rawMaxQueued = (config.midSession as any)?.maxQueued;
287
+ const rawMaxSteers = (config.midSession as any)?.maxSteers;
288
+ this.midSessionConfig = {
289
+ mode: normalizedMode,
290
+ ...(Number.isFinite(rawMaxQueued) && (rawMaxQueued as number) >= 0
291
+ ? { maxQueued: Math.floor(rawMaxQueued as number) }
292
+ : { maxQueued: 20 }),
293
+ ...(Number.isFinite(rawMaxSteers) && (rawMaxSteers as number) >= 0
294
+ ? { maxSteers: Math.floor(rawMaxSteers as number) }
295
+ : { maxSteers: 20 }),
296
+ };
297
+ // Q-20: bound on buffered steer entries (default 20).
298
+ this.maxSteers = this.midSessionConfig.maxSteers ?? 20;
299
+ // Q-22: run limits (sub-agents inherit unless overridden).
300
+ this.runLimits = resolveAgentLimits(config as unknown as { limits?: Record<string, unknown> });
215
301
 
216
302
  // DX4: single thinkingLevel flag — also inherit from ModelProviderInstance when omitted
217
303
  const mpThinking = (rawModel as any)?.thinkingLevel;
218
304
  this.thinkingConfig = normalizeThinking(config, mpThinking);
219
305
 
306
+ // Structured outputs: validate early so invalid schemas fail fast at construction.
307
+ if (config.output !== undefined && config.output !== null) {
308
+ this.output = config.output;
309
+ this.outputSpec = normalizeStructuredOutput(config.output);
310
+ }
311
+
220
312
  // DX5: cache retention short|medium|long, undefined = no explicit
221
313
  this.cacheConfig = {
222
314
  sessionId: this.sessionId,
@@ -314,10 +406,520 @@ export class Agent {
314
406
 
315
407
  /** Clears conversation messages and provider thought signatures while keeping configuration. */
316
408
  reset(): void {
409
+ const sid = this.sessionId;
317
410
  this.context.messages = [];
318
411
  this.context.thoughtSignatures = [];
319
412
  this.context.cachedContentId = (this.cacheConfig as any)?.cachedContentId;
320
413
  this.context.systemPrompt = this.getFullInstructions();
414
+ // Q-25: reset also drops session-scoped provider chaining/routing.
415
+ try { clearInteractionChains(sid); } catch {}
416
+ try { clearSessionRouting(sid); } catch {}
417
+ }
418
+
419
+ /**
420
+ * Returns a named session handle (Q-19a). `session()` creates an isolated
421
+ * `AgentContext` bound to `id` (separate histories per id); `agent.run`
422
+ * with `{ sessionId }` selects/creates the same entry. Sessions are
423
+ * ephemeral in-memory (LRU 100 + 1h TTL). `agent.run`/`stream` without a
424
+ * `sessionId` remain sugar over the default session for compat.
425
+ */
426
+ session(id?: string): { id: string; context: AgentContext; runController: AbortController | null } {
427
+ const sid = id || this.sessionId;
428
+ const ctx = this.getSessionContext(sid);
429
+ return { id: sid, context: ctx, runController: null };
430
+ }
431
+
432
+ /** Drops one named session (Q-19a). No-op for unknown ids. */
433
+ disposeSession(id: string): void {
434
+ try { this.sessions.delete(id); } catch {}
435
+ try { clearInteractionChains(id); } catch {}
436
+ try { clearSessionRouting(id); } catch {}
437
+ }
438
+
439
+ /** Drops all named sessions except the default context (Q-19a). */
440
+ clearSessions(): void {
441
+ try {
442
+ for (const id of [...this.sessions.keys()]) {
443
+ try { clearInteractionChains(id); } catch {}
444
+ try { clearSessionRouting(id); } catch {}
445
+ }
446
+ this.sessions.clear();
447
+ } catch {}
448
+ }
449
+
450
+ /** Resolves the context for a `sessionId` run (default session = `this.context`). */
451
+ private getSessionContext(sessionId?: string): AgentContext {
452
+ const sid = sessionId || this.sessionId;
453
+ if (sid === this.sessionId) return this.context;
454
+ const now = Date.now();
455
+ // TTL eviction.
456
+ try {
457
+ for (const [key, entry] of [...this.sessions.entries()]) {
458
+ if (now - entry.lastAccess > SESSION_TTL_MS) {
459
+ this.sessions.delete(key);
460
+ try { clearInteractionChains(key); } catch {}
461
+ try { clearSessionRouting(key); } catch {}
462
+ }
463
+ }
464
+ } catch {}
465
+ const existing = this.sessions.get(sid);
466
+ if (existing) {
467
+ existing.lastAccess = now;
468
+ return existing.context;
469
+ }
470
+ const fresh = new AgentContext(this.getFullInstructions());
471
+ try {
472
+ const cached = (this.cacheConfig as unknown as { cachedContentId?: string })?.cachedContentId;
473
+ if (cached) fresh.cachedContentId = cached;
474
+ } catch {}
475
+ this.sessions.set(sid, { context: fresh, lastAccess: now });
476
+ // LRU eviction (oldest lastAccess first).
477
+ try {
478
+ while (this.sessions.size > SESSION_LRU_MAX) {
479
+ let oldest: string | undefined;
480
+ let oldestTs = Infinity;
481
+ for (const [key, entry] of this.sessions) {
482
+ if (entry.lastAccess < oldestTs) { oldestTs = entry.lastAccess; oldest = key; }
483
+ }
484
+ if (!oldest) break;
485
+ this.sessions.delete(oldest);
486
+ try { clearInteractionChains(oldest); } catch {}
487
+ try { clearSessionRouting(oldest); } catch {}
488
+ }
489
+ } catch {}
490
+ return fresh;
491
+ }
492
+
493
+ /** Rejects + drops all queued follow-ups (Q-20). Returns the cleared count. */
494
+ clearQueue(): number {
495
+ const n = this.pendingQueue.length;
496
+ if (n === 0) return 0;
497
+ const err = Object.assign(new Error("Queue cleared"), { name: "AbortError" });
498
+ const pending = this.pendingQueue.splice(0);
499
+ for (const entry of pending) {
500
+ try { entry.reject(err); } catch {}
501
+ try { entry.deferredStream?.fail(err instanceof Error ? err : new Error(String(err))); } catch {}
502
+ }
503
+ return n;
504
+ }
505
+
506
+ /** Throws when the steer inbox is at its Q-20 bound. */
507
+ private assertSteerRoom(inbox: SteerEntry[]): void {
508
+ const max = this.maxSteers ?? 20;
509
+ if (inbox.length >= max) {
510
+ throw new Error(
511
+ `[Agent Accelerator] Steer inbox is full (${inbox.length}/${max}). Wait for the active run to drain or increase midSession.maxSteers.`
512
+ );
513
+ }
514
+ }
515
+
516
+ /**
517
+ * True while a `run()`/`stream()` turn is active on this agent.
518
+ * Use with `steer()` (current-turn redirect) vs `queue()` (next-turn follow-up).
519
+ */
520
+ get isBusy(): boolean {
521
+ return this.currentRun !== null;
522
+ }
523
+
524
+ /** Number of queued follow-up requests waiting for the active run to finish. */
525
+ get pendingCount(): number {
526
+ return this.pendingQueue.length;
527
+ }
528
+
529
+ /** Number of steer messages buffered for injection into the active turn. */
530
+ get steerPendingCount(): number {
531
+ return this.currentRun?.steerInbox.length ?? 0;
532
+ }
533
+
534
+ /** Effective mid-session policy (`steer` | `queue` | `auto`). */
535
+ get midSessionMode(): "steer" | "queue" | "auto" {
536
+ return this.midSessionConfig.mode;
537
+ }
538
+
539
+ private resolveEnqueueMode(requested?: "steer" | "queue"): "steer" | "queue" {
540
+ const enforced = this.midSessionConfig.mode;
541
+ if (enforced === "steer" || enforced === "queue") return enforced;
542
+ return requested === "steer" ? "steer" : "queue";
543
+ }
544
+
545
+ private assertPromptValid(prompt: string | ContentPart[]): void {
546
+ if (Array.isArray(prompt) && prompt.length === 0) {
547
+ throw new Error(
548
+ "[Agent Accelerator] Prompt cannot be empty: pass a non-empty string or at least one content part."
549
+ );
550
+ }
551
+ }
552
+
553
+ private previewPrompt(prompt: string | ContentPart[]): string {
554
+ if (typeof prompt === "string") return prompt.slice(0, 500);
555
+ try {
556
+ const texts = prompt
557
+ .filter((p) => (p as { type?: unknown }).type === "text")
558
+ .map((p) => (p as { text?: string }).text ?? "")
559
+ .join("\n");
560
+ return (texts || "[multipart]").slice(0, 500);
561
+ } catch {
562
+ return "[multipart]";
563
+ }
564
+ }
565
+
566
+ /**
567
+ * Interrupts the active run immediately (STOP. Do this instead).
568
+ * Cancels the active stream and aborts provider/tool/sub-agent work via
569
+ * the run's internal `AbortController`. No-op when idle. The interrupted
570
+ * run rejects with `AbortError`; queued follow-ups still run afterwards.
571
+ */
572
+ interrupt(reason?: unknown): void {
573
+ const active = this.currentRun;
574
+ if (!active) return;
575
+ try {
576
+ active.abortController.abort(reason instanceof Error ? reason : new Error("Agent interrupted"));
577
+ } catch {}
578
+ try {
579
+ active.outerStream?.cancel(reason);
580
+ } catch {}
581
+ }
582
+
583
+ /**
584
+ * Steers the active turn (While you're doing that, change direction).
585
+ * The current provider/tool work finishes first, then `prompt` is injected
586
+ * as a user message into the SAME run — same `AgentResponse`, same
587
+ * `maxTurns` budget. Resolves to the active run's final response.
588
+ * When idle, behaves like `run(prompt, options)`.
589
+ * Throws when the developer enforces `midSession: { mode: "queue" }`.
590
+ * Note: `options` only honors `additionalContext` mid-turn (thinking level,
591
+ * session, and streaming callbacks stay with the active run). For a
592
+ * streaming follow-up use `stream(prompt, { enqueue: "steer" })`.
593
+ */
594
+ steer(prompt: string | ContentPart[], options?: AgentRunOptions): Promise<AgentResponse> {
595
+ this.assertPromptValid(prompt);
596
+ if (options?.stream === true) {
597
+ throw new Error(
598
+ '[Agent Accelerator] steer() is non-streaming. Use stream(prompt, { enqueue: "steer" }) for a streaming steer.'
599
+ );
600
+ }
601
+ if (this.midSessionConfig.mode === "queue") {
602
+ throw new Error(
603
+ '[Agent Accelerator] steer() is disabled by midSession.mode "queue". Use queue() or set mode to "auto"/"steer".'
604
+ );
605
+ }
606
+ const active = this.currentRun;
607
+ if (!active) return this.run(prompt, options) as Promise<AgentResponse>;
608
+ this.assertSteerRoom(active.steerInbox);
609
+ active.steerInbox.push({ prompt, options });
610
+ this.persistNow();
611
+ if (active.runPromise) return active.runPromise;
612
+ if (active.outerStream) return active.outerStream.result();
613
+ return this.run(prompt, options) as Promise<AgentResponse>;
614
+ }
615
+
616
+ /**
617
+ * Queues a follow-up for the next turn (When you're done, do this next).
618
+ * The active run finishes completely first; this prompt then starts a new
619
+ * run with its own turn budget and `AgentResponse`. Resolves to that next
620
+ * turn's response. When idle, behaves like `run(prompt, options)`.
621
+ * Throws when the developer enforces `midSession: { mode: "steer" }` or
622
+ * when `maxQueued` is exceeded. For a streaming follow-up use
623
+ * `stream(prompt, { enqueue: "queue" })`.
624
+ */
625
+ queue(prompt: string | ContentPart[], options?: AgentRunOptions): Promise<AgentResponse> {
626
+ this.assertPromptValid(prompt);
627
+ if (options?.stream === true) {
628
+ throw new Error(
629
+ '[Agent Accelerator] queue() is non-streaming. Use stream(prompt, { enqueue: "queue" }) for a streaming follow-up.'
630
+ );
631
+ }
632
+ if (this.midSessionConfig.mode === "steer") {
633
+ throw new Error(
634
+ '[Agent Accelerator] queue() is disabled by midSession.mode "steer". Use steer() or set mode to "auto"/"queue".'
635
+ );
636
+ }
637
+ if (!this.currentRun) return this.run(prompt, options) as Promise<AgentResponse>;
638
+ const max = this.midSessionConfig.maxQueued ?? 20;
639
+ if (this.pendingQueue.length >= max) {
640
+ throw new Error(
641
+ `[Agent Accelerator] Queue is full (${this.pendingQueue.length}/${max}). Wait for the active run to finish or increase midSession.maxQueued.`
642
+ );
643
+ }
644
+ return new Promise<AgentResponse>((resolve, reject) => {
645
+ this.pendingQueue.push({ prompt, options, isStream: false, resolve, reject });
646
+ try {
647
+ this.currentRun?.outerStream?.push({
648
+ type: "queued",
649
+ queuedPrompt: this.previewPrompt(prompt),
650
+ queueLength: this.pendingQueue.length,
651
+ } as any);
652
+ } catch {}
653
+ });
654
+ }
655
+
656
+ private pumpQueue(): void {
657
+ if (this.currentRun !== null) return;
658
+ const next = this.pendingQueue.shift();
659
+ if (!next) return;
660
+ // Q-20: reject queued items whose signal aborted before start.
661
+ try {
662
+ if ((next.options as { signal?: AbortSignal } | undefined)?.signal?.aborted) {
663
+ const reason = ((next.options as { signal?: AbortSignal }).signal as unknown as { reason?: unknown })?.reason;
664
+ const abortErr = Object.assign(
665
+ reason instanceof Error ? reason : new Error("Queued run aborted before start"),
666
+ { name: "AbortError" }
667
+ );
668
+ try { next.reject(abortErr); } catch {}
669
+ try { next.deferredStream?.fail(abortErr instanceof Error ? abortErr : new Error(String(abortErr))); } catch {}
670
+ queueMicrotask(() => this.pumpQueue());
671
+ return;
672
+ }
673
+ } catch {}
674
+ if (next.isStream && next.deferredStream) {
675
+ const deferred = next.deferredStream;
676
+ try {
677
+ const real = this.startStreamingRun(next.prompt, next.options);
678
+ try { (deferred as any).__piped = true; } catch {}
679
+ const forward = (e: StreamEvent) => {
680
+ try { deferred.push(e); } catch {}
681
+ };
682
+ real.on("*", forward as any);
683
+ try {
684
+ deferred.onCancel(() => {
685
+ try { real.cancel(); } catch {}
686
+ });
687
+ } catch {}
688
+ real.result().then(
689
+ (res) => {
690
+ try { next.resolve(res); } catch {}
691
+ try { deferred.end(res); } catch {}
692
+ },
693
+ (err) => {
694
+ try { next.reject(err); } catch {}
695
+ // Pipe failures into the deferred stream so for-await sees them.
696
+ try { deferred.fail(err instanceof Error ? err : new Error(String(err))); } catch {}
697
+ }
698
+ );
699
+ } catch (err) {
700
+ try { next.reject(err); } catch {}
701
+ try { deferred.fail(err instanceof Error ? err : new Error(String(err))); } catch {}
702
+ // Keep draining even if starting failed.
703
+ queueMicrotask(() => this.pumpQueue());
704
+ }
705
+ return;
706
+ }
707
+ this.startNonStreamingRun(next.prompt, next.options).then(next.resolve, next.reject);
708
+ }
709
+
710
+ private finishCurrentRun(): void {
711
+ // Q-20: leftover steer entries become queued follow-ups instead of dropping.
712
+ let leftover: SteerEntry[] = [];
713
+ try { leftover = this.currentRun?.steerInbox?.splice(0) ?? []; } catch { leftover = []; }
714
+ this.currentRun = null;
715
+ if (leftover.length > 0) {
716
+ try {
717
+ const max = this.midSessionConfig.maxQueued ?? 20;
718
+ for (const entry of leftover) {
719
+ try {
720
+ if ((entry.options as { signal?: AbortSignal } | undefined)?.signal?.aborted) continue;
721
+ } catch { continue; }
722
+ if (this.pendingQueue.length >= max) break;
723
+ this.pendingQueue.push({
724
+ prompt: entry.prompt,
725
+ options: entry.options,
726
+ isStream: false,
727
+ resolve: () => {},
728
+ reject: () => {},
729
+ });
730
+ }
731
+ } catch {}
732
+ }
733
+ // Defer pump so the completing run's .then handlers settle first.
734
+ queueMicrotask(() => this.pumpQueue());
735
+ }
736
+
737
+ private cleanupMerge(runController: AbortController): void {
738
+ try {
739
+ const dispose = mergeDisposers.get(runController);
740
+ if (dispose) {
741
+ try { dispose(); } catch {}
742
+ mergeDisposers.delete(runController);
743
+ }
744
+ } catch {}
745
+ }
746
+
747
+ private mergeUserSignal(userSignal?: AbortSignal, runController?: AbortController): AbortSignal | undefined {
748
+ if (!runController) return userSignal;
749
+ if (!userSignal) return runController.signal;
750
+ if (userSignal.aborted) {
751
+ try {
752
+ runController.abort((userSignal as any).reason);
753
+ } catch {
754
+ try { runController.abort(); } catch {}
755
+ }
756
+ return runController.signal;
757
+ }
758
+ // Q-21: prefer AbortSignal.any (no expando, no listener leak).
759
+ try {
760
+ const anyFn = (AbortSignal as unknown as { any?: (signals: AbortSignal[]) => AbortSignal }).any;
761
+ if (typeof anyFn === "function") {
762
+ return anyFn.call(AbortSignal, [userSignal, runController.signal]);
763
+ }
764
+ } catch {}
765
+ // Fallback: manual forward without expando (disposer in WeakMap).
766
+ const forward = () => {
767
+ try {
768
+ runController.abort((userSignal as any).reason);
769
+ } catch {
770
+ try { runController.abort(); } catch {}
771
+ }
772
+ };
773
+ try { userSignal.addEventListener("abort", forward, { once: true }); } catch {}
774
+ try {
775
+ mergeDisposers.set(runController, () => {
776
+ try { userSignal.removeEventListener("abort", forward); } catch {}
777
+ });
778
+ } catch {}
779
+ return runController.signal;
780
+ }
781
+
782
+ private startNonStreamingRun(
783
+ prompt: string | ContentPart[],
784
+ options?: AgentRunOptions
785
+ ): Promise<AgentResponse> {
786
+ const runController = new AbortController();
787
+ const steerInbox: SteerEntry[] = [];
788
+ const mergedSignal = this.mergeUserSignal(options?.signal, runController);
789
+ const runOptions = { ...options, signal: mergedSignal };
790
+ // Q-19a: route to the named session context (default = this.context).
791
+ const targetContext = this.getSessionContext(runOptions?.sessionId || this.sessionId);
792
+ const task = (async (): Promise<AgentResponse> => {
793
+ await ensureModelCatalogFresh();
794
+ const resolved = resolveModel(this.modelStringOrSpec);
795
+ const effectiveLevel = runOptions?.thinkingLevel || this.thinkingConfig?.level;
796
+ if (effectiveLevel) {
797
+ validateModelThinking(resolved.provider.id, resolved.modelId, effectiveLevel);
798
+ }
799
+ this.prepareTurn(prompt, runOptions, targetContext);
800
+ this.persistNow();
801
+
802
+ const providerOptions = {
803
+ apiKey: this.apiKey,
804
+ baseUrl: this.baseUrl,
805
+ headers: { ...(this.customHeaders ?? {}), ...(runOptions?.headers ?? {}) },
806
+ thinking: resolveEffectiveThinking(this.thinkingConfig, runOptions?.thinkingLevel),
807
+ cache: this.stateless
808
+ ? { sessionId: runOptions?.sessionId || this.sessionId }
809
+ : { ...this.cacheConfig, sessionId: runOptions?.sessionId || this.sessionId },
810
+ serviceTier: this.serviceTier,
811
+ sessionId: runOptions?.sessionId || this.sessionId,
812
+ ...(this.outputSpec ? { output: this.outputSpec } : {}),
813
+ };
814
+
815
+ const loopConfig = {
816
+ agentName: this.name,
817
+ provider: resolved.provider,
818
+ modelId: resolved.modelId,
819
+ context: targetContext,
820
+ tools: this.tools,
821
+ options: providerOptions,
822
+ runOptions,
823
+ maxTurns: this.maxTurns,
824
+ bypassInputFileModality: this.bypassInputFileModality,
825
+ onProgress: () => this.persistNow(),
826
+ steerInbox,
827
+ onSteerInjected: () => this.persistNow(),
828
+ // Q-22: loop enforces these (plus local timeout races).
829
+ limits: { ...this.runLimits },
830
+ ...(this.output !== undefined ? { output: this.output } : {}),
831
+ };
832
+
833
+ const res = await runAgentLoop(loopConfig);
834
+ this.mergeSubagentTraces(res.subagents as any);
835
+ this.persistNow();
836
+ if (this.stateless) {
837
+ targetContext.messages = [];
838
+ targetContext.thoughtSignatures = [];
839
+ }
840
+ return res;
841
+ })();
842
+
843
+ this.currentRun = { steerInbox, abortController: runController, runPromise: task };
844
+ const cleanup = () => {
845
+ try { this.cleanupMerge(runController); } catch {}
846
+ if (this.currentRun?.runPromise === task) this.finishCurrentRun();
847
+ };
848
+ task.then(cleanup, cleanup);
849
+ return task;
850
+ }
851
+
852
+ private startStreamingRun(
853
+ prompt: string | ContentPart[],
854
+ options?: AgentRunOptions
855
+ ): AssistantMessageEventStream {
856
+ const resolved = resolveModel(this.modelStringOrSpec);
857
+ const effectiveLevel = options?.thinkingLevel || this.thinkingConfig?.level;
858
+ if (effectiveLevel) {
859
+ validateModelThinking(resolved.provider.id, resolved.modelId, effectiveLevel);
860
+ }
861
+ // Q-19a: named session routing (default session for compat).
862
+ const targetContext = this.getSessionContext(options?.sessionId || this.sessionId);
863
+ this.prepareTurn(prompt, options, targetContext);
864
+ this.persistNow();
865
+
866
+ const runController = new AbortController();
867
+ const mergedSignal = this.mergeUserSignal(options?.signal, runController);
868
+ const runOptions = { ...options, signal: mergedSignal };
869
+ const steerInbox: SteerEntry[] = [];
870
+
871
+ const providerOptions = {
872
+ apiKey: this.apiKey,
873
+ baseUrl: this.baseUrl,
874
+ headers: { ...(this.customHeaders ?? {}), ...(runOptions?.headers ?? {}) },
875
+ thinking: resolveEffectiveThinking(this.thinkingConfig, runOptions?.thinkingLevel),
876
+ cache: this.stateless
877
+ ? { sessionId: runOptions?.sessionId || this.sessionId }
878
+ : { ...this.cacheConfig, sessionId: runOptions?.sessionId || this.sessionId },
879
+ serviceTier: this.serviceTier,
880
+ sessionId: runOptions?.sessionId || this.sessionId,
881
+ ...(this.outputSpec ? { output: this.outputSpec } : {}),
882
+ };
883
+
884
+ const loopConfig = {
885
+ agentName: this.name,
886
+ provider: resolved.provider,
887
+ modelId: resolved.modelId,
888
+ context: targetContext,
889
+ tools: this.tools,
890
+ options: providerOptions,
891
+ runOptions,
892
+ maxTurns: this.maxTurns,
893
+ bypassInputFileModality: this.bypassInputFileModality,
894
+ onProgress: () => this.persistNow(),
895
+ steerInbox,
896
+ onSteerInjected: () => this.persistNow(),
897
+ // Q-22: loop enforces these (plus local timeout races).
898
+ limits: { ...this.runLimits },
899
+ ...(this.output !== undefined ? { output: this.output } : {}),
900
+ };
901
+
902
+ const s = streamAgentLoop(loopConfig);
903
+ this.currentRun = { steerInbox, outerStream: s, abortController: runController };
904
+ s.result().then(
905
+ (res) => {
906
+ try {
907
+ this.mergeSubagentTraces(res.subagents as any);
908
+ this.persistNow();
909
+ } catch {}
910
+ try { this.cleanupMerge(runController); } catch {}
911
+ if (this.stateless) {
912
+ targetContext.messages = [];
913
+ targetContext.thoughtSignatures = [];
914
+ }
915
+ if (this.currentRun?.outerStream === s) this.finishCurrentRun();
916
+ },
917
+ () => {
918
+ try { this.cleanupMerge(runController); } catch {}
919
+ if (this.currentRun?.outerStream === s) this.finishCurrentRun();
920
+ }
921
+ );
922
+ return s;
321
923
  }
322
924
 
323
925
  /**
@@ -368,15 +970,41 @@ export class Agent {
368
970
  /**
369
971
  * Rewrites the configured session file now (when `persist` is set).
370
972
  * Called automatically after every step during runs; safe to call
371
- * manually. Never throws.
973
+ * manually. Never throws (failures route to `onPersistError`).
974
+ *
975
+ * Q-28: stateless agents skip persistence unless
976
+ * `(persist as any).allowStateless` is set. Calls in the same tick are
977
+ * coalesced via a `queueMicrotask` single flight.
372
978
  */
373
979
  persistNow(): void {
980
+ const cfg = this.persistConfig;
981
+ if (!cfg || (!cfg.dir && !cfg.file)) return;
982
+ // Q-28: no stateless persist by default.
983
+ try {
984
+ if (this.stateless && !(cfg as unknown as { allowStateless?: unknown }).allowStateless) return;
985
+ } catch {}
986
+ if (this.persistScheduled) return;
987
+ this.persistScheduled = true;
988
+ queueMicrotask(() => {
989
+ this.persistScheduled = false;
990
+ this.doPersist();
991
+ });
992
+ }
993
+
994
+ /** Synchronous persistence write behind the coalesced `persistNow()` gate. */
995
+ private doPersist(): void {
374
996
  const cfg = this.persistConfig;
375
997
  if (!cfg || (!cfg.dir && !cfg.file)) return;
998
+ try {
999
+ if (this.stateless && !(cfg as unknown as { allowStateless?: unknown }).allowStateless) return;
1000
+ } catch {}
376
1001
  try {
377
1002
  if (cfg.dir) saveSessionDir(cfg.dir, this as any, null);
378
1003
  else if (cfg.file) saveSessionFile(cfg.file, this as any, null);
379
- } catch {}
1004
+ } catch (err) {
1005
+ // Q-28: surface via hook instead of swallowing silently.
1006
+ try { (cfg as unknown as { onPersistError?: (e: unknown) => void }).onPersistError?.(err); } catch {}
1007
+ }
380
1008
  }
381
1009
 
382
1010
  /** Merges completed worker metadata (incl. steps) into this agent's trace registry. */
@@ -435,6 +1063,7 @@ export class Agent {
435
1063
  */
436
1064
  importSession(data?: PersistedAgentSession | null): void {
437
1065
  if (!data) return;
1066
+ const prevSid = this.sessionId;
438
1067
  if (data.sessionId) (this as any).sessionId = data.sessionId;
439
1068
  if ((data as any).parentSessionId) (this as any).parentSessionId = (data as any).parentSessionId;
440
1069
  if ((data as any).subagents && typeof (data as any).subagents === "object") {
@@ -485,37 +1114,47 @@ export class Agent {
485
1114
  }
486
1115
  this.context.cachedContentId =
487
1116
  data.cachedContentId ?? (this.cacheConfig as any)?.cachedContentId;
1117
+ // Q-25: importing a snapshot invalidates prior provider chaining/routing
1118
+ // for both the previous and the restored session.
1119
+ try { clearInteractionChains(prevSid); } catch {}
1120
+ try { clearSessionRouting(prevSid); } catch {}
1121
+ try { clearInteractionChains(this.sessionId); } catch {}
1122
+ try { clearSessionRouting(this.sessionId); } catch {}
488
1123
  }
489
1124
 
490
- private prepareTurn(prompt: string | ContentPart[], options?: AgentRunOptions): void {
1125
+ private prepareTurn(prompt: string | ContentPart[], options?: AgentRunOptions, target?: AgentContext): void {
1126
+ // Q-20: prompt validation throws synchronously in both run() and
1127
+ // stream() (same timing; documented here).
491
1128
  if (Array.isArray(prompt) && prompt.length === 0) {
492
1129
  throw new Error(
493
1130
  "[Agent Accelerator] Prompt cannot be empty: pass a non-empty string or at least one content part."
494
1131
  );
495
1132
  }
1133
+ const ctx = target ?? this.getSessionContext(options?.sessionId || this.sessionId);
496
1134
  if (this.stateless) {
497
- this.context.messages = [];
498
- this.context.thoughtSignatures = [];
1135
+ // Q-19a: stateless runs use a fresh ephemeral context (never shared).
1136
+ ctx.messages = [];
1137
+ ctx.thoughtSignatures = [];
499
1138
  }
500
1139
  const fullInstructions = this.getFullInstructions();
501
1140
  // C13: keep systemPrompt stable for Google implicit cache; additionalContext goes as user prefix, not system mutation
502
1141
  if (options?.additionalContext) {
503
1142
  // Preserve stable instructions as systemPrompt
504
- this.context.systemPrompt = fullInstructions;
1143
+ ctx.systemPrompt = fullInstructions;
505
1144
  const prefix = `[Additional Context]\n${options.additionalContext}\n\n`;
506
1145
  if (typeof prompt === "string") {
507
1146
  prompt = prefix + prompt;
508
1147
  } else if (Array.isArray(prompt)) {
509
1148
  prompt = [{ type: "text", text: prefix } as ContentPart, ...prompt];
510
1149
  }
511
- } else if (this.context.systemPrompt !== fullInstructions) {
512
- this.context.systemPrompt = fullInstructions;
1150
+ } else if (ctx.systemPrompt !== fullInstructions) {
1151
+ ctx.systemPrompt = fullInstructions;
513
1152
  }
514
1153
  // C11: keep context cachedContentId in sync with cacheConfig if updated via options
515
- if (options?.headers && (this.cacheConfig as any)?.cachedContentId && !this.context.cachedContentId) {
516
- this.context.cachedContentId = (this.cacheConfig as any).cachedContentId;
1154
+ if (options?.headers && (this.cacheConfig as any)?.cachedContentId && !ctx.cachedContentId) {
1155
+ ctx.cachedContentId = (this.cacheConfig as any).cachedContentId;
517
1156
  }
518
- this.context.addUserMessage(prompt);
1157
+ ctx.addUserMessage(prompt);
519
1158
  }
520
1159
 
521
1160
  /**
@@ -542,6 +1181,9 @@ export class Agent {
542
1181
  prompt: string | ContentPart[],
543
1182
  options?: AgentRunOptions
544
1183
  ): Promise<AgentResponse> | AssistantMessageEventStream {
1184
+ // Q-20: prompt validation throws synchronously in both run() and
1185
+ // stream() (same timing; async work starts only after validation).
1186
+ this.assertPromptValid(prompt);
545
1187
  const isStream = options?.stream === true;
546
1188
  if (isStream) {
547
1189
  const hasCallbacks = !!(options?.onDelta || options?.onThinkingDelta || options?.onEvent);
@@ -564,52 +1206,51 @@ export class Agent {
564
1206
  return s;
565
1207
  }
566
1208
 
567
- return (async (): Promise<AgentResponse> => {
568
- // Refresh the catalog BEFORE thinking validation so levels are checked
569
- // against live data instead of a possibly-empty cold cache.
570
- await ensureModelCatalogFresh();
571
- const resolved = resolveModel(this.modelStringOrSpec);
572
- const effectiveLevel = options?.thinkingLevel || this.thinkingConfig?.level;
573
- if (effectiveLevel) {
574
- validateModelThinking(resolved.provider.id, resolved.modelId, effectiveLevel);
1209
+ // Mid-session: busy agent serializes via steer (same turn) or queue (next turn).
1210
+ if (this.currentRun) {
1211
+ const enforced = this.midSessionConfig.mode;
1212
+ const requested = options?.enqueue;
1213
+ if (requested && (enforced === "steer" || enforced === "queue") && requested !== enforced) {
1214
+ throw new Error(
1215
+ `[Agent Accelerator] enqueue "${requested}" is disabled by midSession.mode "${enforced}". Use ${enforced}() or set mode to "auto"/"${requested}".`
1216
+ );
575
1217
  }
576
- this.prepareTurn(prompt, options);
577
- this.persistNow();
578
-
579
- const providerOptions = {
580
- apiKey: this.apiKey,
581
- baseUrl: this.baseUrl,
582
- headers: { ...(this.customHeaders ?? {}), ...(options?.headers ?? {}) },
583
- thinking: resolveEffectiveThinking(this.thinkingConfig, options?.thinkingLevel),
584
- cache: this.stateless
585
- ? { sessionId: options?.sessionId || this.sessionId }
586
- : { ...this.cacheConfig, sessionId: options?.sessionId || this.sessionId },
587
- serviceTier: this.serviceTier,
588
- sessionId: options?.sessionId || this.sessionId,
589
- };
590
-
591
- const loopConfig = {
592
- agentName: this.name,
593
- provider: resolved.provider,
594
- modelId: resolved.modelId,
595
- context: this.context,
596
- tools: this.tools,
597
- options: providerOptions,
598
- runOptions: options,
599
- maxTurns: this.maxTurns,
600
- bypassInputFileModality: this.bypassInputFileModality,
601
- onProgress: () => this.persistNow(),
602
- };
603
-
604
- const res = await runAgentLoop(loopConfig);
605
- this.mergeSubagentTraces(res.subagents as any);
606
- this.persistNow();
607
- if (this.stateless) {
608
- this.context.messages = [];
609
- this.context.thoughtSignatures = [];
1218
+ const mode = this.resolveEnqueueMode(requested);
1219
+ if (mode === "steer") {
1220
+ this.assertPromptValid(prompt);
1221
+ this.assertSteerRoom(this.currentRun.steerInbox);
1222
+ this.currentRun.steerInbox.push({ prompt, options });
1223
+ this.persistNow();
1224
+ const active = this.currentRun;
1225
+ if (active.runPromise) return active.runPromise;
1226
+ if (active.outerStream) return active.outerStream.result();
1227
+ } else {
1228
+ if (this.midSessionConfig.mode === "steer") {
1229
+ throw new Error(
1230
+ '[Agent Accelerator] enqueue "queue" is disabled by midSession.mode "steer". Use steer() or set mode to "auto"/"queue".'
1231
+ );
1232
+ }
1233
+ this.assertPromptValid(prompt);
1234
+ const max = this.midSessionConfig.maxQueued ?? 20;
1235
+ if (this.pendingQueue.length >= max) {
1236
+ throw new Error(
1237
+ `[Agent Accelerator] Queue is full (${this.pendingQueue.length}/${max}). Wait for the active run to finish or increase midSession.maxQueued.`
1238
+ );
1239
+ }
1240
+ return new Promise<AgentResponse>((resolve, reject) => {
1241
+ this.pendingQueue.push({ prompt, options, isStream: false, resolve, reject });
1242
+ try {
1243
+ this.currentRun?.outerStream?.push({
1244
+ type: "queued",
1245
+ queuedPrompt: this.previewPrompt(prompt),
1246
+ queueLength: this.pendingQueue.length,
1247
+ } as any);
1248
+ } catch {}
1249
+ });
610
1250
  }
611
- return res;
612
- })();
1251
+ }
1252
+
1253
+ return this.startNonStreamingRun(prompt, options);
613
1254
  }
614
1255
 
615
1256
  /**
@@ -679,52 +1320,141 @@ export class Agent {
679
1320
  prompt: string | ContentPart[],
680
1321
  options?: AgentRunOptions
681
1322
  ): AssistantMessageEventStream {
682
- const resolved = resolveModel(this.modelStringOrSpec);
683
- const effectiveLevel = options?.thinkingLevel || this.thinkingConfig?.level;
684
- if (effectiveLevel) {
685
- validateModelThinking(resolved.provider.id, resolved.modelId, effectiveLevel);
686
- }
687
- this.prepareTurn(prompt, options);
688
- this.persistNow();
689
-
690
- const providerOptions = {
691
- apiKey: this.apiKey,
692
- baseUrl: this.baseUrl,
693
- headers: { ...(this.customHeaders ?? {}), ...(options?.headers ?? {}) },
694
- thinking: resolveEffectiveThinking(this.thinkingConfig, options?.thinkingLevel),
695
- cache: this.stateless
696
- ? { sessionId: options?.sessionId || this.sessionId }
697
- : { ...this.cacheConfig, sessionId: options?.sessionId || this.sessionId },
698
- serviceTier: this.serviceTier,
699
- sessionId: options?.sessionId || this.sessionId,
700
- };
701
-
702
- const loopConfig = {
703
- agentName: this.name,
704
- provider: resolved.provider,
705
- modelId: resolved.modelId,
706
- context: this.context,
707
- tools: this.tools,
708
- options: providerOptions,
709
- runOptions: options,
710
- maxTurns: this.maxTurns,
711
- bypassInputFileModality: this.bypassInputFileModality,
712
- onProgress: () => this.persistNow(),
713
- };
714
-
715
- const s = streamAgentLoop(loopConfig);
716
- s.result().then((res) => {
717
- try {
718
- this.mergeSubagentTraces(res.subagents as any);
1323
+ // Q-20: same sync validation timing as run().
1324
+ this.assertPromptValid(prompt);
1325
+ // Mid-session: busy agent serializes via steer (same turn) or queue (next turn).
1326
+ if (this.currentRun) {
1327
+ const enforcedStream = this.midSessionConfig.mode;
1328
+ const requestedStream = options?.enqueue;
1329
+ if (requestedStream && (enforcedStream === "steer" || enforcedStream === "queue") && requestedStream !== enforcedStream) {
1330
+ throw new Error(
1331
+ `[Agent Accelerator] enqueue "${requestedStream}" is disabled by midSession.mode "${enforcedStream}". Use ${enforcedStream}() or set mode to "auto"/"${requestedStream}".`
1332
+ );
1333
+ }
1334
+ const mode = this.resolveEnqueueMode(requestedStream);
1335
+ if (mode === "steer") {
1336
+ this.assertPromptValid(prompt);
1337
+ this.assertSteerRoom(this.currentRun.steerInbox);
1338
+ this.currentRun.steerInbox.push({ prompt, options });
719
1339
  this.persistNow();
1340
+ const activeStream = this.currentRun.outerStream;
1341
+ if (activeStream) {
1342
+ this.wireStreamCallbacks(activeStream, options);
1343
+ return activeStream;
1344
+ }
1345
+ // Active run is non-streaming: attach a lightweight stream that ends
1346
+ // with the same final response so streaming callers still get events.
1347
+ const stub = new AssistantMessageEventStream();
1348
+ this.wireStreamCallbacks(stub, options);
1349
+ const activePromise = this.currentRun.runPromise;
1350
+ if (activePromise) {
1351
+ activePromise.then(
1352
+ (res) => {
1353
+ try {
1354
+ stub.push({ type: "done", delta: "", usage: res.usage, finishReason: res.finishReason, responseId: res.responseId } as any);
1355
+ } catch {}
1356
+ try { stub.end(res); } catch {}
1357
+ },
1358
+ (err) => {
1359
+ try { stub.fail(err instanceof Error ? err : new Error(String(err))); } catch {}
1360
+ }
1361
+ );
1362
+ } else {
1363
+ try { stub.fail(new Error("[Agent Accelerator] No active run to steer.")); } catch {}
1364
+ }
1365
+ return stub;
1366
+ }
1367
+ if (this.midSessionConfig.mode === "steer") {
1368
+ throw new Error(
1369
+ '[Agent Accelerator] enqueue "queue" is disabled by midSession.mode "steer". Use steer() or set mode to "auto"/"queue".'
1370
+ );
1371
+ }
1372
+ this.assertPromptValid(prompt);
1373
+ const max = this.midSessionConfig.maxQueued ?? 20;
1374
+ if (this.pendingQueue.length >= max) {
1375
+ throw new Error(
1376
+ `[Agent Accelerator] Queue is full (${this.pendingQueue.length}/${max}). Wait for the active run to finish or increase midSession.maxQueued.`
1377
+ );
1378
+ }
1379
+ const deferred = new AssistantMessageEventStream();
1380
+ this.wireStreamCallbacks(deferred, options);
1381
+ // Notify the active stream (if any) that a follow-up was queued.
1382
+ try {
1383
+ this.currentRun.outerStream?.push({
1384
+ type: "queued",
1385
+ queuedPrompt: this.previewPrompt(prompt),
1386
+ queueLength: this.pendingQueue.length + 1,
1387
+ } as any);
720
1388
  } catch {}
721
- }).catch(() => {});
722
- if (this.stateless) {
723
- s.result().then(() => {
724
- this.context.messages = [];
725
- this.context.thoughtSignatures = [];
726
- }).catch(() => {});
1389
+ const queuedEntry: {
1390
+ prompt: string | ContentPart[];
1391
+ options?: AgentRunOptions;
1392
+ isStream: boolean;
1393
+ deferredStream?: AssistantMessageEventStream;
1394
+ resolve: (res: AgentResponse) => void;
1395
+ reject: (err: unknown) => void;
1396
+ } = {
1397
+ prompt,
1398
+ options,
1399
+ isStream: true,
1400
+ deferredStream: deferred,
1401
+ resolve: () => {},
1402
+ reject: () => {},
1403
+ };
1404
+ const done = new Promise<AgentResponse>((resolve, reject) => {
1405
+ queuedEntry.resolve = resolve;
1406
+ queuedEntry.reject = reject;
1407
+ });
1408
+ // Keep result() in sync: deferred ends when the queued turn completes.
1409
+ done.then(
1410
+ () => {},
1411
+ () => {}
1412
+ );
1413
+ // Forward cancellation before start: drop from queue and fail.
1414
+ deferred.onCancel(() => {
1415
+ try {
1416
+ const idx = this.pendingQueue.indexOf(queuedEntry as any);
1417
+ if (idx >= 0) {
1418
+ this.pendingQueue.splice(idx, 1);
1419
+ queuedEntry.reject(Object.assign(new Error("Stream aborted"), { name: "AbortError" }));
1420
+ try { deferred.fail(Object.assign(new Error("Stream aborted"), { name: "AbortError" })); } catch {}
1421
+ }
1422
+ } catch {}
1423
+ });
1424
+ this.pendingQueue.push(queuedEntry as any);
1425
+ // Attach deferred completion to queued promise without exposing it.
1426
+ // Real events are piped in pumpQueue() when the turn starts.
1427
+ void done.then(
1428
+ (res) => {
1429
+ // If pump already piped via real stream, deferred is already ended.
1430
+ // Otherwise (edge), end it here.
1431
+ try {
1432
+ if ((deferred as any).__piped !== true) {
1433
+ deferred.push({ type: "done", delta: "", usage: res.usage, finishReason: res.finishReason, responseId: res.responseId } as any);
1434
+ deferred.end(res);
1435
+ }
1436
+ } catch {}
1437
+ },
1438
+ (err) => {
1439
+ try {
1440
+ if ((deferred as any).__piped !== true) {
1441
+ deferred.fail(err instanceof Error ? err : new Error(String(err)));
1442
+ }
1443
+ } catch {}
1444
+ }
1445
+ );
1446
+ return deferred;
727
1447
  }
1448
+
1449
+ const s = this.startStreamingRun(prompt, options);
1450
+ this.wireStreamCallbacks(s, options);
1451
+ return s;
1452
+ }
1453
+
1454
+ private wireStreamCallbacks(
1455
+ s: AssistantMessageEventStream,
1456
+ options?: AgentRunOptions
1457
+ ): void {
728
1458
  // Wire one-liner callbacks so `stream:true` + onDelta is enough — no manual for-await needed
729
1459
  if (options?.wrapThinking) {
730
1460
  // Auto-wrap reasoning as <think>…</think> per-turn — clean boundaries across multi-turn agent runs.
@@ -890,7 +1620,6 @@ export class Agent {
890
1620
  if (options?.onDelta) s.on("text_delta", (e: any) => options.onDelta!(e.delta!, e));
891
1621
  if (options?.onThinkingDelta) s.on("thinking_delta", (e: any) => options.onThinkingDelta!(e.thinkingDelta!, e));
892
1622
  }
893
- return s;
894
1623
  }
895
1624
  }
896
1625