agent-accelerator 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +174 -123
  2. package/{SYSTEM_PROMPT_AGENT.md → examples/prompts/SYSTEM_PROMPT_AGENT.md} +1 -1
  3. package/package.json +14 -9
  4. package/src/agent/agent.ts +385 -73
  5. package/src/agent/context.ts +1 -0
  6. package/src/agent/delegation.ts +443 -30
  7. package/src/agent/loop.ts +183 -53
  8. package/src/data/README.md +6 -6
  9. package/src/index.ts +163 -46
  10. package/src/models/catalog-cache.ts +60 -9
  11. package/src/models/catalog.ts +55 -8
  12. package/src/providers/google.ts +926 -0
  13. package/src/providers/openai-compat.ts +1147 -0
  14. package/src/providers/openai.ts +959 -0
  15. package/src/providers/openrouter-responses.ts +949 -0
  16. package/src/providers/openrouter.ts +1037 -0
  17. package/src/{ai-sdk → providers}/registry.ts +43 -55
  18. package/src/providers.ts +492 -0
  19. package/src/session/store.ts +806 -0
  20. package/src/streaming/sse-parser.ts +6 -4
  21. package/src/tools/executor.ts +23 -7
  22. package/src/tools/schema.ts +21 -11
  23. package/src/types/agent.ts +33 -2
  24. package/src/types/core.ts +3 -2
  25. package/src/types/message.ts +5 -0
  26. package/src/types/model.ts +3 -7
  27. package/src/types/provider-payloads.ts +2 -84
  28. package/src/types/response.ts +24 -0
  29. package/src/types/tool.ts +8 -0
  30. package/src/update-models.ts +56 -0
  31. package/src/utils/cache.ts +1 -1
  32. package/src/utils/documents.ts +517 -0
  33. package/src/utils/env.ts +0 -7
  34. package/src/{ai-sdk → utils}/errors.ts +61 -2
  35. package/src/utils/headers.ts +10 -20
  36. package/src/utils/media.ts +5 -2
  37. package/src/utils/retry.ts +89 -0
  38. package/src/utils/serialization.ts +15 -0
  39. package/src/utils/session.ts +36 -0
  40. package/src/ai-sdk/converters.ts +0 -342
  41. package/src/ai-sdk/executor.ts +0 -454
  42. package/src/ai-sdk/index.ts +0 -55
  43. package/src/ai-sdk/model-provider.ts +0 -303
  44. package/src/ai-sdk/options.ts +0 -306
  45. package/src/ai-sdk/provider.ts +0 -415
  46. package/src/tokens/counter.ts +0 -136
  47. /package/{SYSTEM_PROMPT.md → examples/prompts/SYSTEM_PROMPT.md} +0 -0
  48. /package/{SYSTEM_PROMPT_TOOLS.md → examples/prompts/SYSTEM_PROMPT_TOOLS.md} +0 -0
@@ -5,13 +5,30 @@ import type { ContentPart } from "../types/message.ts";
5
5
  import { AgentResponse, type StreamEvent } from "../types/response.ts";
6
6
  import { AssistantMessageEventStream } from "../streaming/event-stream.ts";
7
7
  import { AgentContext } from "./context.ts";
8
- import { resolveModel } from "../ai-sdk/registry.ts";
8
+ import { resolveModel } from "../providers/registry.ts";
9
9
  import { buildAgentTools, createSubagentSpawnTool } from "./delegation.ts";
10
+ import { convert_document_to_markdown } from "../utils/documents.ts";
10
11
  import { runAgentLoop, streamAgentLoop } from "./loop.ts";
11
12
  import { createSessionId } from "../utils/session.ts";
13
+ import { getSubAgentTrace, subscribeToSubAgent, listSubAgentTraceIds, type SubAgentTrace } from "./delegation.ts";
14
+ import { saveSessionDir, saveSessionFile } from "../session/store.ts";
12
15
  import { getModel, getSubModel } from "../utils/env.ts";
13
- import { validateModelThinking } from "../models/catalog.ts";
14
- import { resolveEffectiveThinking } from "../ai-sdk/options.ts";
16
+ import { validateModelThinking, ensureModelCatalogFresh } from "../models/catalog.ts";
17
+ import { buildSessionData, type PersistedAgentSession, type SessionTotals,} from "../session/store.ts";
18
+ import { SessionTelemetry } from "../session/store.ts";
19
+
20
+ /**
21
+ * Resolves a per-run thinking override without mutating agent config.
22
+ */
23
+ export function resolveEffectiveThinking(
24
+ base?: ThinkingConfig,
25
+ overrideLevel?: string
26
+ ): ThinkingConfig | undefined {
27
+ if (!overrideLevel) return base;
28
+ if (overrideLevel === "none") return { enabled: false, level: "none", budgetTokens: 0 };
29
+ if (overrideLevel === "dynamic") return { enabled: true, level: "dynamic", budgetTokens: -1 };
30
+ return { enabled: true, level: overrideLevel as ThinkingConfig["level"] };
31
+ }
15
32
 
16
33
  /** Thrown when dynamic sub-agent spawning is enabled without an explicit model. */
17
34
  export class SubAgentModelError extends Error {
@@ -91,6 +108,8 @@ export class Agent {
91
108
  readonly cacheConfig?: CacheConfig;
92
109
  /** Provider service tier. */
93
110
  readonly serviceTier?: ServiceTier;
111
+ /** Convert `file` parts client-side for pdf-incapable models. */
112
+ readonly bypassInputFileModality: boolean;
94
113
  /** Maximum model/tool turns per run. */
95
114
  readonly maxTurns: number;
96
115
  /** Session ID used for history and cache affinity. */
@@ -105,6 +124,8 @@ export class Agent {
105
124
  readonly context: AgentContext;
106
125
  /** Whether history is cleared around each run. */
107
126
  readonly stateless: boolean;
127
+ /** Durable persistence target: session file rewritten after every step. */
128
+ readonly persistConfig?: { dir?: string; file?: string };
108
129
 
109
130
  /**
110
131
  * Creates an agent and registers its model, tools, cache, and delegation settings.
@@ -182,8 +203,15 @@ export class Agent {
182
203
  this.apiKey = (rawModel as any)?.apiKey || config.apiKey;
183
204
  this.baseUrl = (rawModel as any)?.baseUrl || config.baseUrl;
184
205
  this.customHeaders = config.headers;
185
- this.maxTurns = config.maxTurns ?? 10;
206
+ this.maxTurns =
207
+ config.maxTurns === undefined || config.maxTurns === 0 || config.maxTurns === Infinity
208
+ ? Infinity
209
+ : Number.isFinite(config.maxTurns as number)
210
+ ? Math.max(1, Math.floor(config.maxTurns as number))
211
+ : Infinity;
186
212
  this.sessionId = config.sessionId || config.cache?.sessionId || createSessionId();
213
+ (this as any).subagentTraces = {};
214
+ this.persistConfig = config.persist;
187
215
 
188
216
  // DX4: single thinkingLevel flag — also inherit from ModelProviderInstance when omitted
189
217
  const mpThinking = (rawModel as any)?.thinkingLevel;
@@ -198,6 +226,7 @@ export class Agent {
198
226
  const initialCachedId = (this.cacheConfig as any)?.cachedContentId;
199
227
 
200
228
  this.serviceTier = config.serviceTier;
229
+ this.bypassInputFileModality = config.bypassInputFileModality ?? false;
201
230
  this.stateless = config.stateless ?? false;
202
231
 
203
232
  // Tools registration
@@ -225,6 +254,10 @@ export class Agent {
225
254
  }
226
255
  }
227
256
 
257
+ if (this.bypassInputFileModality && !this.tools["convert_document_to_markdown"]) {
258
+ this.tools["convert_document_to_markdown"] = convert_document_to_markdown;
259
+ }
260
+
228
261
  if (this.dynamicSubagents?.enabled === true) {
229
262
  if (!this.subagentModel) {
230
263
  throw new SubAgentModelError(
@@ -287,7 +320,179 @@ export class Agent {
287
320
  this.context.systemPrompt = this.getFullInstructions();
288
321
  }
289
322
 
323
+ /**
324
+ * Returns a snapshot of one sub-agent worker's trace by tracking id
325
+ * (`SUBAGENT-NAME-{32hex}` or the raw 32-hex suffix). Sees live running
326
+ * state: steps appear as the worker acts, not just after it finishes.
327
+ *
328
+ * @example `const trace = agent.track("RAM-RESEARCH-AGENT-9f2c…");`
329
+ */
330
+ track(trackingId: string): SubAgentTrace | undefined {
331
+ if (!trackingId) return undefined;
332
+ try {
333
+ const own = (this as any).subagentTraces as Record<string, SubAgentTrace> | undefined;
334
+ const hit = own?.[trackingId];
335
+ if (hit) return { ...hit, steps: hit.steps.map((s) => ({ ...s })) };
336
+ } catch {}
337
+ return getSubAgentTrace(trackingId);
338
+ }
339
+
340
+ /** Lists TrackingIDs of worker traces visible to this agent (deduped). */
341
+ listTrackedSubAgents(): string[] {
342
+ const rawOf = (id: string): string => {
343
+ const tail = id.split("-").pop() ?? "";
344
+ return /^[0-9a-f]{32}$/.test(tail) ? tail : id;
345
+ };
346
+ const seen = new Set<string>();
347
+ const out: string[] = [];
348
+ for (const id of [...Object.keys((this as any).subagentTraces ?? {}), ...listSubAgentTraceIds()]) {
349
+ const raw = rawOf(id);
350
+ if (!seen.has(raw)) {
351
+ seen.add(raw);
352
+ out.push(raw);
353
+ }
354
+ }
355
+ return out;
356
+ }
357
+
358
+ /**
359
+ * Subscribes to live updates of one worker trace. The callback fires on
360
+ * every logged step; returns an unsubscribe fn.
361
+ *
362
+ * @example `const off = agent.subscribeToSubAgent(id, (t) => render(t));`
363
+ */
364
+ subscribeToSubAgent(trackingId: string, fn: (trace: SubAgentTrace) => void): () => void {
365
+ return subscribeToSubAgent(trackingId, fn);
366
+ }
367
+
368
+ /**
369
+ * Rewrites the configured session file now (when `persist` is set).
370
+ * Called automatically after every step during runs; safe to call
371
+ * manually. Never throws.
372
+ */
373
+ persistNow(): void {
374
+ const cfg = this.persistConfig;
375
+ if (!cfg || (!cfg.dir && !cfg.file)) return;
376
+ try {
377
+ if (cfg.dir) saveSessionDir(cfg.dir, this as any, null);
378
+ else if (cfg.file) saveSessionFile(cfg.file, this as any, null);
379
+ } catch {}
380
+ }
381
+
382
+ /** Merges completed worker metadata (incl. steps) into this agent's trace registry. */
383
+ private mergeSubagentTraces(subagents: Array<{ trackingId?: string; name: string; sessionId?: string; parentSessionId?: string; task: string; steps?: SubAgentTrace["steps"]; [k: string]: unknown }>): void {
384
+ if (!subagents || subagents.length === 0) return;
385
+ const rawOf = (id?: string): string | undefined => {
386
+ if (!id) return undefined;
387
+ const tail = id.split("-").pop() ?? "";
388
+ return /^[0-9a-f]{32}$/.test(tail) ? tail : id;
389
+ };
390
+ try {
391
+ const registry = ((this as any).subagentTraces ??= {}) as Record<string, SubAgentTrace>;
392
+ for (const s of subagents) {
393
+ const raw = rawOf(s.trackingId);
394
+ const key = raw || s.name;
395
+ const existing = registry[key] ?? (raw ? registry[s.trackingId ?? ""] : undefined);
396
+ if (existing) {
397
+ existing.status = "done";
398
+ if (s.steps && s.steps.length > 0 && existing.steps.length === 0) {
399
+ existing.steps = s.steps.map((x) => ({ ...x }));
400
+ }
401
+ if (raw && !registry[raw]) registry[raw] = existing;
402
+ } else if (raw) {
403
+ registry[raw] = {
404
+ trackingId: raw,
405
+ name: s.name,
406
+ sessionId: s.sessionId || "",
407
+ parentSessionId: s.parentSessionId || this.sessionId,
408
+ status: "done",
409
+ task: s.task,
410
+ turns: 0,
411
+ steps: (s.steps ?? []).map((x) => ({ ...x })),
412
+ };
413
+ }
414
+ }
415
+ } catch {}
416
+ }
417
+
418
+ /**
419
+ * Exports this agent's conversation + config as a storable session snapshot.
420
+ * Pair with `importSession` / `loadSessionFile` / `saveSessionFile`.
421
+ *
422
+ * @example `saveSessionFile(".session.json", agent, telemetry)`
423
+ */
424
+ exportSession(telemetry?: SessionTelemetry | SessionTotals | null): PersistedAgentSession {
425
+ return buildSessionData(this as any, telemetry);
426
+ }
427
+
428
+ /**
429
+ * Restores conversation + config from `exportSession` / `loadSessionFile`.
430
+ * Restores messages, system prompt, cached content id, sub-agent traces,
431
+ * and (when present) session id, model, thinking level, instructions,
432
+ * cache, and worker model. No-ops on nullish input.
433
+ *
434
+ * @example `const saved = loadSessionFile(".session.json"); if (saved) agent.importSession(saved);`
435
+ */
436
+ importSession(data?: PersistedAgentSession | null): void {
437
+ if (!data) return;
438
+ if (data.sessionId) (this as any).sessionId = data.sessionId;
439
+ if ((data as any).parentSessionId) (this as any).parentSessionId = (data as any).parentSessionId;
440
+ if ((data as any).subagents && typeof (data as any).subagents === "object") {
441
+ try {
442
+ (this as any).subagentTraces = JSON.parse(JSON.stringify((data as any).subagents));
443
+ } catch {
444
+ (this as any).subagentTraces = { ...((data as any).subagents as object) };
445
+ }
446
+ }
447
+ if (data.model) (this as any).modelStringOrSpec = data.model;
448
+ if (data.thinkingLevel) {
449
+ const lvl = String(data.thinkingLevel);
450
+ (this as any).thinkingConfig =
451
+ lvl === "none"
452
+ ? { enabled: false, level: "none", budgetTokens: 0 }
453
+ : lvl === "dynamic"
454
+ ? { enabled: true, level: "dynamic", budgetTokens: -1 }
455
+ : { enabled: true, level: lvl };
456
+ }
457
+ if (typeof data.instructions === "string") this.instructions = data.instructions;
458
+ if (data.cache) {
459
+ (this as any).cacheConfig = {
460
+ ...this.cacheConfig,
461
+ ...data.cache,
462
+ sessionId: data.sessionId ?? this.sessionId,
463
+ };
464
+ }
465
+ if (data.subagentModel) {
466
+ (this as any).subagentModel = data.subagentModel;
467
+ const dyn = (this as any).dynamicSubagents;
468
+ if (dyn) dyn.model = data.subagentModel;
469
+ }
470
+ try {
471
+ const g: any = globalThis as any;
472
+ this.context.messages = Array.isArray(data.messages)
473
+ ? (typeof g.structuredClone === "function"
474
+ ? g.structuredClone(data.messages)
475
+ : JSON.parse(JSON.stringify(data.messages)))
476
+ : [];
477
+ } catch {
478
+ this.context.messages = Array.isArray(data.messages) ? [...data.messages] : [];
479
+ }
480
+ this.context.thoughtSignatures = [];
481
+ if (data.systemPrompt) {
482
+ this.context.systemPrompt = data.systemPrompt;
483
+ } else {
484
+ this.context.systemPrompt = this.getFullInstructions();
485
+ }
486
+ this.context.cachedContentId =
487
+ data.cachedContentId ?? (this.cacheConfig as any)?.cachedContentId;
488
+ }
489
+
290
490
  private prepareTurn(prompt: string | ContentPart[], options?: AgentRunOptions): void {
491
+ if (Array.isArray(prompt) && prompt.length === 0) {
492
+ throw new Error(
493
+ "[Agent Accelerator] Prompt cannot be empty: pass a non-empty string or at least one content part."
494
+ );
495
+ }
291
496
  if (this.stateless) {
292
497
  this.context.messages = [];
293
498
  this.context.thoughtSignatures = [];
@@ -317,10 +522,26 @@ export class Agent {
317
522
  * Runs one or more model/tool turns and resolves to a normalized AgentResponse.
318
523
  * @example `const response = await agent.run("Summarize this document");`
319
524
  */
525
+ run(
526
+ prompt: string | ContentPart[],
527
+ options: AgentRunOptions & { stream: true } & (
528
+ | { onDelta: NonNullable<AgentRunOptions["onDelta"]> }
529
+ | { onThinkingDelta: NonNullable<AgentRunOptions["onThinkingDelta"]> }
530
+ | { onEvent: NonNullable<AgentRunOptions["onEvent"]> }
531
+ )
532
+ ): Promise<AgentResponse> & AsyncIterable<StreamEvent>;
533
+ run(
534
+ prompt: string | ContentPart[],
535
+ options: AgentRunOptions & { stream: true }
536
+ ): AssistantMessageEventStream;
320
537
  run(
321
538
  prompt: string | ContentPart[],
322
539
  options?: AgentRunOptions
323
- ): Promise<AgentResponse> & AssistantMessageEventStream {
540
+ ): Promise<AgentResponse>;
541
+ run(
542
+ prompt: string | ContentPart[],
543
+ options?: AgentRunOptions
544
+ ): Promise<AgentResponse> | AssistantMessageEventStream {
324
545
  const isStream = options?.stream === true;
325
546
  if (isStream) {
326
547
  const hasCallbacks = !!(options?.onDelta || options?.onThinkingDelta || options?.onEvent);
@@ -343,44 +564,52 @@ export class Agent {
343
564
  return s;
344
565
  }
345
566
 
346
- const resolved = resolveModel(this.modelStringOrSpec);
347
- const effectiveLevel = options?.thinkingLevel || this.thinkingConfig?.level;
348
- if (effectiveLevel) {
349
- validateModelThinking(resolved.provider.id, resolved.modelId, effectiveLevel);
350
- }
351
- this.prepareTurn(prompt, options);
352
-
353
- const providerOptions = {
354
- apiKey: this.apiKey,
355
- baseUrl: this.baseUrl,
356
- headers: { ...(this.customHeaders ?? {}), ...(options?.headers ?? {}) },
357
- thinking: resolveEffectiveThinking(this.thinkingConfig, options?.thinkingLevel),
358
- cache: this.stateless
359
- ? { sessionId: options?.sessionId || this.sessionId }
360
- : { ...this.cacheConfig, sessionId: options?.sessionId || this.sessionId },
361
- serviceTier: this.serviceTier,
362
- sessionId: options?.sessionId || this.sessionId,
363
- };
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);
575
+ }
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
+ };
364
590
 
365
- const loopConfig = {
366
- agentName: this.name,
367
- provider: resolved.provider,
368
- modelId: resolved.modelId,
369
- context: this.context,
370
- tools: this.tools,
371
- options: providerOptions,
372
- runOptions: options,
373
- maxTurns: this.maxTurns,
374
- };
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
+ };
375
603
 
376
- const promise = runAgentLoop(loopConfig).then((res) => {
604
+ const res = await runAgentLoop(loopConfig);
605
+ this.mergeSubagentTraces(res.subagents as any);
606
+ this.persistNow();
377
607
  if (this.stateless) {
378
608
  this.context.messages = [];
379
609
  this.context.thoughtSignatures = [];
380
610
  }
381
611
  return res;
382
- });
383
- return promise as any;
612
+ })();
384
613
  }
385
614
 
386
615
  /**
@@ -409,10 +638,16 @@ export class Agent {
409
638
  }
410
639
  const stringStream: any = {
411
640
  [Symbol.asyncIterator]: async function* () {
412
- for await (const chunk of stream) {
413
- if (chunk.type === "text_delta" && chunk.delta) {
414
- yield chunk.delta;
641
+ try {
642
+ for await (const chunk of stream) {
643
+ if (chunk.type === "text_delta" && chunk.delta) {
644
+ yield chunk.delta;
645
+ }
415
646
  }
647
+ } finally {
648
+ // Breaking out early must stop the underlying provider stream
649
+ // instead of leaving the HTTP connection running in the background.
650
+ stream.cancel();
416
651
  }
417
652
  },
418
653
  result: () => stream.result(),
@@ -428,7 +663,7 @@ export class Agent {
428
663
  return stringStream;
429
664
  }
430
665
 
431
- return this.run(prompt, runOpts);
666
+ return this.run(prompt, runOpts) as any;
432
667
  }
433
668
 
434
669
  /**
@@ -450,6 +685,7 @@ export class Agent {
450
685
  validateModelThinking(resolved.provider.id, resolved.modelId, effectiveLevel);
451
686
  }
452
687
  this.prepareTurn(prompt, options);
688
+ this.persistNow();
453
689
 
454
690
  const providerOptions = {
455
691
  apiKey: this.apiKey,
@@ -472,9 +708,17 @@ export class Agent {
472
708
  options: providerOptions,
473
709
  runOptions: options,
474
710
  maxTurns: this.maxTurns,
711
+ bypassInputFileModality: this.bypassInputFileModality,
712
+ onProgress: () => this.persistNow(),
475
713
  };
476
714
 
477
715
  const s = streamAgentLoop(loopConfig);
716
+ s.result().then((res) => {
717
+ try {
718
+ this.mergeSubagentTraces(res.subagents as any);
719
+ this.persistNow();
720
+ } catch {}
721
+ }).catch(() => {});
478
722
  if (this.stateless) {
479
723
  s.result().then(() => {
480
724
  this.context.messages = [];
@@ -483,8 +727,32 @@ export class Agent {
483
727
  }
484
728
  // Wire one-liner callbacks so `stream:true` + onDelta is enough — no manual for-await needed
485
729
  if (options?.wrapThinking) {
486
- // Auto-wrap reasoning as <think>\n...\n</think>\n\n per-turn — clean boundaries across multi-turn agent runs
730
+ // Auto-wrap reasoning as <think>…</think> per-turn — clean boundaries across multi-turn agent runs.
731
+ //
732
+ // Display-only whitespace hygiene (cache-safe): provider thought
733
+ // summaries routinely carry edge blank lines (observed `"...\n\n\n"`),
734
+ // and the wrapper itself adds newlines around the span. Without care
735
+ // this renders as blank lines after `<think>`, double blanks between
736
+ // sections, and a stray blank line before `</think>`. The buffering
737
+ // below touches ONLY the presented strings/callback args and synthetic
738
+ // tag events — never provider accumulation, AgentContext history, or
739
+ // request bodies — so prefix-cache affinity is untouched.
740
+ //
741
+ // Trailing-newline buffering: each chunk's trailing `\n` run is
742
+ // withheld and prepended to the next chunk (or dropped at close), so
743
+ // emitted display text never ends with `\n` and `</think>` never gets
744
+ // a preceding blank line. Interior breaks are preserved (collapsed to
745
+ // a single blank line at most).
487
746
  let isThinking = false;
747
+ let emittedAny = false;
748
+ let pendingNewlines = "";
749
+ // True once text has been emitted without a trailing newline: the next
750
+ // <think> open tag then needs its own leading newline, otherwise it
751
+ // glues onto the streamed text mid-line. False at stream start and
752
+ // after every tag (both end with newlines), preserving the exact
753
+ // "<think>\n...\n</think>\n\n" contract for thinking-first streams.
754
+ let needThinkNewline = false;
755
+ const collapseBlankLines = (s: string): string => s.replace(/\n{3,}/g, "\n\n");
488
756
  const userOnThinking = options.onThinkingDelta;
489
757
  const userOnDelta = options.onDelta;
490
758
  const userOnEvent = options.onEvent;
@@ -493,7 +761,10 @@ export class Agent {
493
761
  const openThink = (e?: any) => {
494
762
  if (!isThinking) {
495
763
  isThinking = true;
496
- const tag = "<think>\n";
764
+ emittedAny = false;
765
+ pendingNewlines = "";
766
+ const tag = (needThinkNewline ? "\n" : "") + "<think>\n";
767
+ needThinkNewline = false;
497
768
  if (userOnThinking) userOnThinking(tag, e);
498
769
  else if (userOnDelta) userOnDelta(tag, e as any);
499
770
  }
@@ -502,20 +773,54 @@ export class Agent {
502
773
  const closeThink = (e?: any) => {
503
774
  if (isThinking) {
504
775
  isThinking = false;
505
- const close = "\n</think>\n\n";
776
+ // Drop any withheld trailing newlines: the span ends cleanly and
777
+ // the tag supplies its own line breaks. Empty spans still close.
778
+ pendingNewlines = "";
779
+ const close = emittedAny ? "\n</think>\n\n" : "</think>\n\n";
780
+ emittedAny = false;
506
781
  if (userOnThinking) userOnThinking(close, e);
507
782
  else if (userOnDelta) userOnDelta(close, e as any);
508
783
  }
509
784
  };
510
785
 
786
+ /** Buffers one raw thinking chunk, returning the display string to
787
+ * emit now (`""` when nothing displayable yet). Mutates the event in
788
+ * place so manual `for-await` consumers see the same clean text; safe
789
+ * because loop context derives from the provider result, not these
790
+ * outer events. */
791
+ const bufferThinkingEvent = (e: any): string => {
792
+ const raw: string = e.thinkingDelta ?? "";
793
+ let chunk = collapseBlankLines(pendingNewlines + raw);
794
+ pendingNewlines = "";
795
+ if (!emittedAny) {
796
+ chunk = chunk.replace(/^\n+/, "");
797
+ }
798
+ if (!chunk) return "";
799
+ const m = chunk.match(/\n+$/);
800
+ if (m) {
801
+ pendingNewlines = m[0];
802
+ chunk = chunk.slice(0, -m[0].length);
803
+ if (!chunk) return "";
804
+ }
805
+ emittedAny = true;
806
+ e.thinkingDelta = chunk;
807
+ if (typeof e.partialThinking === "string") {
808
+ e.partialThinking = collapseBlankLines(e.partialThinking);
809
+ }
810
+ return chunk;
811
+ };
812
+
511
813
  if (userOnThinking || userOnDelta) {
512
814
  s.on("thinking_delta", (e: any) => {
513
815
  openThink(e);
514
- if (userOnThinking) userOnThinking(e.thinkingDelta!, e);
515
- else if (userOnDelta) userOnDelta(e.thinkingDelta!, e as any);
816
+ const chunk = bufferThinkingEvent(e);
817
+ if (!chunk) return;
818
+ if (userOnThinking) userOnThinking(chunk, e);
819
+ else if (userOnDelta) userOnDelta(chunk, e as any);
516
820
  });
517
821
  s.on("text_delta", (e: any) => {
518
822
  closeThink(e);
823
+ needThinkNewline = !((e.delta ?? "").endsWith("\n"));
519
824
  if (userOnDelta) userOnDelta(e.delta!, e);
520
825
  else if (userOnThinking) userOnThinking(e.delta!, e as any);
521
826
  });
@@ -528,48 +833,55 @@ export class Agent {
528
833
  closeThink(e || ({ type: "done" } as any));
529
834
  });
530
835
  } else {
531
- // No callbacks but wrapThinking true — still emit tags as events for manual iteration
836
+ // No callbacks but wrapThinking true — still emit tags as events for manual iteration.
837
+ // Same display hygiene as above (in-place event normalization is safe:
838
+ // loop context derives from the provider result, not outer events).
839
+ const closeSynthetic = () => {
840
+ if (isThinking) {
841
+ isThinking = false;
842
+ pendingNewlines = "";
843
+ const tag = emittedAny ? "\n</think>\n\n" : "</think>\n\n";
844
+ emittedAny = false;
845
+ s.push({ type: "thinking_delta", thinkingDelta: tag, __synthetic: true } as any);
846
+ }
847
+ };
532
848
  s.on("thinking_delta", (e: any) => {
849
+ // Ignore our own synthetic tags: push() redelivers events to
850
+ // listeners synchronously, so without this guard the handler
851
+ // re-enters on every tag it emits (duplicate <think> spans and
852
+ // mutated queued events).
853
+ if ((e as any).__synthetic) return;
533
854
  if (!isThinking) {
534
855
  isThinking = true;
535
- s.push({ type: "thinking_delta", thinkingDelta: "<think>\n" } as any);
856
+ emittedAny = false;
857
+ pendingNewlines = "";
858
+ const openTag = (needThinkNewline ? "\n" : "") + "<think>\n";
859
+ needThinkNewline = false;
860
+ s.push({ type: "thinking_delta", thinkingDelta: openTag, __synthetic: true } as any);
861
+ }
862
+ const chunk = bufferThinkingEvent(e);
863
+ if (!chunk) {
864
+ e.thinkingDelta = "";
536
865
  }
537
866
  });
538
867
  s.on("text_delta", (e: any) => {
539
- if (isThinking) {
540
- isThinking = false;
541
- s.push({ type: "thinking_delta", thinkingDelta: "\n</think>\n\n" } as any);
542
- }
868
+ closeSynthetic();
869
+ needThinkNewline = !((e.delta ?? "").endsWith("\n"));
543
870
  });
544
871
  s.on("tool_call_start" as any, () => {
545
- if (isThinking) {
546
- isThinking = false;
547
- s.push({ type: "thinking_delta", thinkingDelta: "\n</think>\n\n" } as any);
548
- }
872
+ closeSynthetic();
549
873
  });
550
874
  s.on("tool_call_complete" as any, () => {
551
- if (isThinking) {
552
- isThinking = false;
553
- s.push({ type: "thinking_delta", thinkingDelta: "\n</think>\n\n" } as any);
554
- }
875
+ closeSynthetic();
555
876
  });
556
877
  s.on("tool_result" as any, () => {
557
- if (isThinking) {
558
- isThinking = false;
559
- s.push({ type: "thinking_delta", thinkingDelta: "\n</think>\n\n" } as any);
560
- }
878
+ closeSynthetic();
561
879
  });
562
880
  s.on("subagent_complete" as any, () => {
563
- if (isThinking) {
564
- isThinking = false;
565
- s.push({ type: "thinking_delta", thinkingDelta: "\n</think>\n\n" } as any);
566
- }
881
+ closeSynthetic();
567
882
  });
568
883
  s.on("done", () => {
569
- if (isThinking) {
570
- isThinking = false;
571
- s.push({ type: "thinking_delta", thinkingDelta: "\n</think>\n\n" } as any);
572
- }
884
+ closeSynthetic();
573
885
  });
574
886
  if (userOnEvent) s.on("*", userOnEvent as any);
575
887
  }
@@ -584,7 +896,7 @@ export class Agent {
584
896
 
585
897
  /** Normalizes the public `thinkingLevel` flag into provider-neutral thinking settings. */
586
898
  export function normalizeThinking(config: AgentConfig, fallbackLevel?: string): ThinkingConfig | undefined {
587
- // DX4: only thinkingLevel, values: none, dynamic, minimal, low, medium, high, xhigh
899
+ // DX4: only thinkingLevel, values: none, dynamic, minimal, low, medium, high, xhigh, max
588
900
  const rawLevel = config.thinkingLevel ?? fallbackLevel;
589
901
  const level = rawLevel as ThinkingLevel | undefined;
590
902
 
@@ -598,7 +910,7 @@ export function normalizeThinking(config: AgentConfig, fallbackLevel?: string):
598
910
  const provStr = typeof rawModel === "object" && rawModel?.provider ? rawModel.provider : modelStr.includes("/") ? modelStr.split("/")[0] : "";
599
911
  const actualModelId = modelStr.includes("/") ? modelStr.split("/").slice(1).join("/") : modelStr;
600
912
  if (actualModelId) {
601
- validateModelThinking(provStr || "opencode", actualModelId, level);
913
+ validateModelThinking(provStr || "openrouter", actualModelId, level);
602
914
  }
603
915
  } catch (e) {
604
916
  throw e;
@@ -96,6 +96,7 @@ export class AgentContext {
96
96
  arguments: tc.arguments,
97
97
  rawArguments: tc.rawArguments,
98
98
  thoughtSignature: tc.thoughtSignature || (!thinkingSig && !textSig ? thoughtSignature : undefined),
99
+ callId: tc.callId,
99
100
  });
100
101
  }
101
102
  }