agent-accelerator 0.1.0 → 0.2.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 (45) hide show
  1. package/README.md +83 -112
  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 +191 -71
  5. package/src/agent/context.ts +1 -0
  6. package/src/agent/delegation.ts +61 -12
  7. package/src/agent/loop.ts +71 -48
  8. package/src/data/README.md +6 -6
  9. package/src/index.ts +130 -45
  10. package/src/models/catalog-cache.ts +60 -9
  11. package/src/models/catalog.ts +53 -7
  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 +490 -0
  19. package/src/streaming/sse-parser.ts +6 -4
  20. package/src/tools/executor.ts +19 -6
  21. package/src/tools/schema.ts +21 -11
  22. package/src/types/agent.ts +8 -1
  23. package/src/types/core.ts +1 -1
  24. package/src/types/message.ts +5 -0
  25. package/src/types/model.ts +3 -7
  26. package/src/types/provider-payloads.ts +2 -84
  27. package/src/types/tool.ts +6 -0
  28. package/src/update-models.ts +56 -0
  29. package/src/utils/cache.ts +1 -1
  30. package/src/utils/documents.ts +517 -0
  31. package/src/utils/env.ts +0 -7
  32. package/src/{ai-sdk → utils}/errors.ts +61 -2
  33. package/src/utils/headers.ts +10 -20
  34. package/src/utils/media.ts +5 -2
  35. package/src/utils/retry.ts +89 -0
  36. package/src/utils/serialization.ts +15 -0
  37. package/src/ai-sdk/converters.ts +0 -342
  38. package/src/ai-sdk/executor.ts +0 -454
  39. package/src/ai-sdk/index.ts +0 -55
  40. package/src/ai-sdk/model-provider.ts +0 -303
  41. package/src/ai-sdk/options.ts +0 -306
  42. package/src/ai-sdk/provider.ts +0 -415
  43. package/src/tokens/counter.ts +0 -136
  44. /package/{SYSTEM_PROMPT.md → examples/prompts/SYSTEM_PROMPT.md} +0 -0
  45. /package/{SYSTEM_PROMPT_TOOLS.md → examples/prompts/SYSTEM_PROMPT_TOOLS.md} +0 -0
@@ -5,13 +5,26 @@ 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";
12
13
  import { getModel, getSubModel } from "../utils/env.ts";
13
- import { validateModelThinking } from "../models/catalog.ts";
14
- import { resolveEffectiveThinking } from "../ai-sdk/options.ts";
14
+ import { validateModelThinking, ensureModelCatalogFresh } from "../models/catalog.ts";
15
+
16
+ /**
17
+ * Resolves a per-run thinking override without mutating agent config.
18
+ */
19
+ export function resolveEffectiveThinking(
20
+ base?: ThinkingConfig,
21
+ overrideLevel?: string
22
+ ): ThinkingConfig | undefined {
23
+ if (!overrideLevel) return base;
24
+ if (overrideLevel === "none") return { enabled: false, level: "none", budgetTokens: 0 };
25
+ if (overrideLevel === "dynamic") return { enabled: true, level: "dynamic", budgetTokens: -1 };
26
+ return { enabled: true, level: overrideLevel as ThinkingConfig["level"] };
27
+ }
15
28
 
16
29
  /** Thrown when dynamic sub-agent spawning is enabled without an explicit model. */
17
30
  export class SubAgentModelError extends Error {
@@ -91,6 +104,8 @@ export class Agent {
91
104
  readonly cacheConfig?: CacheConfig;
92
105
  /** Provider service tier. */
93
106
  readonly serviceTier?: ServiceTier;
107
+ /** Convert `file` parts client-side for pdf-incapable models. */
108
+ readonly bypassInputFileModality: boolean;
94
109
  /** Maximum model/tool turns per run. */
95
110
  readonly maxTurns: number;
96
111
  /** Session ID used for history and cache affinity. */
@@ -198,6 +213,7 @@ export class Agent {
198
213
  const initialCachedId = (this.cacheConfig as any)?.cachedContentId;
199
214
 
200
215
  this.serviceTier = config.serviceTier;
216
+ this.bypassInputFileModality = config.bypassInputFileModality ?? false;
201
217
  this.stateless = config.stateless ?? false;
202
218
 
203
219
  // Tools registration
@@ -225,6 +241,10 @@ export class Agent {
225
241
  }
226
242
  }
227
243
 
244
+ if (this.bypassInputFileModality && !this.tools["convert_document_to_markdown"]) {
245
+ this.tools["convert_document_to_markdown"] = convert_document_to_markdown;
246
+ }
247
+
228
248
  if (this.dynamicSubagents?.enabled === true) {
229
249
  if (!this.subagentModel) {
230
250
  throw new SubAgentModelError(
@@ -288,6 +308,11 @@ export class Agent {
288
308
  }
289
309
 
290
310
  private prepareTurn(prompt: string | ContentPart[], options?: AgentRunOptions): void {
311
+ if (Array.isArray(prompt) && prompt.length === 0) {
312
+ throw new Error(
313
+ "[Agent Accelerator] Prompt cannot be empty: pass a non-empty string or at least one content part."
314
+ );
315
+ }
291
316
  if (this.stateless) {
292
317
  this.context.messages = [];
293
318
  this.context.thoughtSignatures = [];
@@ -317,10 +342,26 @@ export class Agent {
317
342
  * Runs one or more model/tool turns and resolves to a normalized AgentResponse.
318
343
  * @example `const response = await agent.run("Summarize this document");`
319
344
  */
345
+ run(
346
+ prompt: string | ContentPart[],
347
+ options: AgentRunOptions & { stream: true } & (
348
+ | { onDelta: NonNullable<AgentRunOptions["onDelta"]> }
349
+ | { onThinkingDelta: NonNullable<AgentRunOptions["onThinkingDelta"]> }
350
+ | { onEvent: NonNullable<AgentRunOptions["onEvent"]> }
351
+ )
352
+ ): Promise<AgentResponse> & AsyncIterable<StreamEvent>;
353
+ run(
354
+ prompt: string | ContentPart[],
355
+ options: AgentRunOptions & { stream: true }
356
+ ): AssistantMessageEventStream;
357
+ run(
358
+ prompt: string | ContentPart[],
359
+ options?: AgentRunOptions
360
+ ): Promise<AgentResponse>;
320
361
  run(
321
362
  prompt: string | ContentPart[],
322
363
  options?: AgentRunOptions
323
- ): Promise<AgentResponse> & AssistantMessageEventStream {
364
+ ): Promise<AgentResponse> | AssistantMessageEventStream {
324
365
  const isStream = options?.stream === true;
325
366
  if (isStream) {
326
367
  const hasCallbacks = !!(options?.onDelta || options?.onThinkingDelta || options?.onEvent);
@@ -343,44 +384,48 @@ export class Agent {
343
384
  return s;
344
385
  }
345
386
 
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
- };
387
+ return (async (): Promise<AgentResponse> => {
388
+ // Refresh the catalog BEFORE thinking validation so levels are checked
389
+ // against live data instead of a possibly-empty cold cache.
390
+ await ensureModelCatalogFresh();
391
+ const resolved = resolveModel(this.modelStringOrSpec);
392
+ const effectiveLevel = options?.thinkingLevel || this.thinkingConfig?.level;
393
+ if (effectiveLevel) {
394
+ validateModelThinking(resolved.provider.id, resolved.modelId, effectiveLevel);
395
+ }
396
+ this.prepareTurn(prompt, options);
397
+
398
+ const providerOptions = {
399
+ apiKey: this.apiKey,
400
+ baseUrl: this.baseUrl,
401
+ headers: { ...(this.customHeaders ?? {}), ...(options?.headers ?? {}) },
402
+ thinking: resolveEffectiveThinking(this.thinkingConfig, options?.thinkingLevel),
403
+ cache: this.stateless
404
+ ? { sessionId: options?.sessionId || this.sessionId }
405
+ : { ...this.cacheConfig, sessionId: options?.sessionId || this.sessionId },
406
+ serviceTier: this.serviceTier,
407
+ sessionId: options?.sessionId || this.sessionId,
408
+ };
364
409
 
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
- };
410
+ const loopConfig = {
411
+ agentName: this.name,
412
+ provider: resolved.provider,
413
+ modelId: resolved.modelId,
414
+ context: this.context,
415
+ tools: this.tools,
416
+ options: providerOptions,
417
+ runOptions: options,
418
+ maxTurns: this.maxTurns,
419
+ bypassInputFileModality: this.bypassInputFileModality,
420
+ };
375
421
 
376
- const promise = runAgentLoop(loopConfig).then((res) => {
422
+ const res = await runAgentLoop(loopConfig);
377
423
  if (this.stateless) {
378
424
  this.context.messages = [];
379
425
  this.context.thoughtSignatures = [];
380
426
  }
381
427
  return res;
382
- });
383
- return promise as any;
428
+ })();
384
429
  }
385
430
 
386
431
  /**
@@ -409,10 +454,16 @@ export class Agent {
409
454
  }
410
455
  const stringStream: any = {
411
456
  [Symbol.asyncIterator]: async function* () {
412
- for await (const chunk of stream) {
413
- if (chunk.type === "text_delta" && chunk.delta) {
414
- yield chunk.delta;
457
+ try {
458
+ for await (const chunk of stream) {
459
+ if (chunk.type === "text_delta" && chunk.delta) {
460
+ yield chunk.delta;
461
+ }
415
462
  }
463
+ } finally {
464
+ // Breaking out early must stop the underlying provider stream
465
+ // instead of leaving the HTTP connection running in the background.
466
+ stream.cancel();
416
467
  }
417
468
  },
418
469
  result: () => stream.result(),
@@ -428,7 +479,7 @@ export class Agent {
428
479
  return stringStream;
429
480
  }
430
481
 
431
- return this.run(prompt, runOpts);
482
+ return this.run(prompt, runOpts) as any;
432
483
  }
433
484
 
434
485
  /**
@@ -472,6 +523,7 @@ export class Agent {
472
523
  options: providerOptions,
473
524
  runOptions: options,
474
525
  maxTurns: this.maxTurns,
526
+ bypassInputFileModality: this.bypassInputFileModality,
475
527
  };
476
528
 
477
529
  const s = streamAgentLoop(loopConfig);
@@ -483,8 +535,32 @@ export class Agent {
483
535
  }
484
536
  // Wire one-liner callbacks so `stream:true` + onDelta is enough — no manual for-await needed
485
537
  if (options?.wrapThinking) {
486
- // Auto-wrap reasoning as <think>\n...\n</think>\n\n per-turn — clean boundaries across multi-turn agent runs
538
+ // Auto-wrap reasoning as <think>…</think> per-turn — clean boundaries across multi-turn agent runs.
539
+ //
540
+ // Display-only whitespace hygiene (cache-safe): provider thought
541
+ // summaries routinely carry edge blank lines (observed `"...\n\n\n"`),
542
+ // and the wrapper itself adds newlines around the span. Without care
543
+ // this renders as blank lines after `<think>`, double blanks between
544
+ // sections, and a stray blank line before `</think>`. The buffering
545
+ // below touches ONLY the presented strings/callback args and synthetic
546
+ // tag events — never provider accumulation, AgentContext history, or
547
+ // request bodies — so prefix-cache affinity is untouched.
548
+ //
549
+ // Trailing-newline buffering: each chunk's trailing `\n` run is
550
+ // withheld and prepended to the next chunk (or dropped at close), so
551
+ // emitted display text never ends with `\n` and `</think>` never gets
552
+ // a preceding blank line. Interior breaks are preserved (collapsed to
553
+ // a single blank line at most).
487
554
  let isThinking = false;
555
+ let emittedAny = false;
556
+ let pendingNewlines = "";
557
+ // True once text has been emitted without a trailing newline: the next
558
+ // <think> open tag then needs its own leading newline, otherwise it
559
+ // glues onto the streamed text mid-line. False at stream start and
560
+ // after every tag (both end with newlines), preserving the exact
561
+ // "<think>\n...\n</think>\n\n" contract for thinking-first streams.
562
+ let needThinkNewline = false;
563
+ const collapseBlankLines = (s: string): string => s.replace(/\n{3,}/g, "\n\n");
488
564
  const userOnThinking = options.onThinkingDelta;
489
565
  const userOnDelta = options.onDelta;
490
566
  const userOnEvent = options.onEvent;
@@ -493,7 +569,10 @@ export class Agent {
493
569
  const openThink = (e?: any) => {
494
570
  if (!isThinking) {
495
571
  isThinking = true;
496
- const tag = "<think>\n";
572
+ emittedAny = false;
573
+ pendingNewlines = "";
574
+ const tag = (needThinkNewline ? "\n" : "") + "<think>\n";
575
+ needThinkNewline = false;
497
576
  if (userOnThinking) userOnThinking(tag, e);
498
577
  else if (userOnDelta) userOnDelta(tag, e as any);
499
578
  }
@@ -502,20 +581,54 @@ export class Agent {
502
581
  const closeThink = (e?: any) => {
503
582
  if (isThinking) {
504
583
  isThinking = false;
505
- const close = "\n</think>\n\n";
584
+ // Drop any withheld trailing newlines: the span ends cleanly and
585
+ // the tag supplies its own line breaks. Empty spans still close.
586
+ pendingNewlines = "";
587
+ const close = emittedAny ? "\n</think>\n\n" : "</think>\n\n";
588
+ emittedAny = false;
506
589
  if (userOnThinking) userOnThinking(close, e);
507
590
  else if (userOnDelta) userOnDelta(close, e as any);
508
591
  }
509
592
  };
510
593
 
594
+ /** Buffers one raw thinking chunk, returning the display string to
595
+ * emit now (`""` when nothing displayable yet). Mutates the event in
596
+ * place so manual `for-await` consumers see the same clean text; safe
597
+ * because loop context derives from the provider result, not these
598
+ * outer events. */
599
+ const bufferThinkingEvent = (e: any): string => {
600
+ const raw: string = e.thinkingDelta ?? "";
601
+ let chunk = collapseBlankLines(pendingNewlines + raw);
602
+ pendingNewlines = "";
603
+ if (!emittedAny) {
604
+ chunk = chunk.replace(/^\n+/, "");
605
+ }
606
+ if (!chunk) return "";
607
+ const m = chunk.match(/\n+$/);
608
+ if (m) {
609
+ pendingNewlines = m[0];
610
+ chunk = chunk.slice(0, -m[0].length);
611
+ if (!chunk) return "";
612
+ }
613
+ emittedAny = true;
614
+ e.thinkingDelta = chunk;
615
+ if (typeof e.partialThinking === "string") {
616
+ e.partialThinking = collapseBlankLines(e.partialThinking);
617
+ }
618
+ return chunk;
619
+ };
620
+
511
621
  if (userOnThinking || userOnDelta) {
512
622
  s.on("thinking_delta", (e: any) => {
513
623
  openThink(e);
514
- if (userOnThinking) userOnThinking(e.thinkingDelta!, e);
515
- else if (userOnDelta) userOnDelta(e.thinkingDelta!, e as any);
624
+ const chunk = bufferThinkingEvent(e);
625
+ if (!chunk) return;
626
+ if (userOnThinking) userOnThinking(chunk, e);
627
+ else if (userOnDelta) userOnDelta(chunk, e as any);
516
628
  });
517
629
  s.on("text_delta", (e: any) => {
518
630
  closeThink(e);
631
+ needThinkNewline = !((e.delta ?? "").endsWith("\n"));
519
632
  if (userOnDelta) userOnDelta(e.delta!, e);
520
633
  else if (userOnThinking) userOnThinking(e.delta!, e as any);
521
634
  });
@@ -528,48 +641,55 @@ export class Agent {
528
641
  closeThink(e || ({ type: "done" } as any));
529
642
  });
530
643
  } else {
531
- // No callbacks but wrapThinking true — still emit tags as events for manual iteration
644
+ // No callbacks but wrapThinking true — still emit tags as events for manual iteration.
645
+ // Same display hygiene as above (in-place event normalization is safe:
646
+ // loop context derives from the provider result, not outer events).
647
+ const closeSynthetic = () => {
648
+ if (isThinking) {
649
+ isThinking = false;
650
+ pendingNewlines = "";
651
+ const tag = emittedAny ? "\n</think>\n\n" : "</think>\n\n";
652
+ emittedAny = false;
653
+ s.push({ type: "thinking_delta", thinkingDelta: tag, __synthetic: true } as any);
654
+ }
655
+ };
532
656
  s.on("thinking_delta", (e: any) => {
657
+ // Ignore our own synthetic tags: push() redelivers events to
658
+ // listeners synchronously, so without this guard the handler
659
+ // re-enters on every tag it emits (duplicate <think> spans and
660
+ // mutated queued events).
661
+ if ((e as any).__synthetic) return;
533
662
  if (!isThinking) {
534
663
  isThinking = true;
535
- s.push({ type: "thinking_delta", thinkingDelta: "<think>\n" } as any);
664
+ emittedAny = false;
665
+ pendingNewlines = "";
666
+ const openTag = (needThinkNewline ? "\n" : "") + "<think>\n";
667
+ needThinkNewline = false;
668
+ s.push({ type: "thinking_delta", thinkingDelta: openTag, __synthetic: true } as any);
669
+ }
670
+ const chunk = bufferThinkingEvent(e);
671
+ if (!chunk) {
672
+ e.thinkingDelta = "";
536
673
  }
537
674
  });
538
675
  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
- }
676
+ closeSynthetic();
677
+ needThinkNewline = !((e.delta ?? "").endsWith("\n"));
543
678
  });
544
679
  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
- }
680
+ closeSynthetic();
549
681
  });
550
682
  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
- }
683
+ closeSynthetic();
555
684
  });
556
685
  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
- }
686
+ closeSynthetic();
561
687
  });
562
688
  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
- }
689
+ closeSynthetic();
567
690
  });
568
691
  s.on("done", () => {
569
- if (isThinking) {
570
- isThinking = false;
571
- s.push({ type: "thinking_delta", thinkingDelta: "\n</think>\n\n" } as any);
572
- }
692
+ closeSynthetic();
573
693
  });
574
694
  if (userOnEvent) s.on("*", userOnEvent as any);
575
695
  }
@@ -598,7 +718,7 @@ export function normalizeThinking(config: AgentConfig, fallbackLevel?: string):
598
718
  const provStr = typeof rawModel === "object" && rawModel?.provider ? rawModel.provider : modelStr.includes("/") ? modelStr.split("/")[0] : "";
599
719
  const actualModelId = modelStr.includes("/") ? modelStr.split("/").slice(1).join("/") : modelStr;
600
720
  if (actualModelId) {
601
- validateModelThinking(provStr || "opencode", actualModelId, level);
721
+ validateModelThinking(provStr || "openrouter", actualModelId, level);
602
722
  }
603
723
  } catch (e) {
604
724
  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
  }
@@ -1,9 +1,10 @@
1
1
  import { z } from "zod";
2
2
  import { tool } from "../tools/tool.ts";
3
+ import { normalizeToolName } from "../tools/executor.ts";
3
4
  import type { ToolDefinition } from "../types/tool.ts";
4
5
  import type { SubAgentExecutionMetadata } from "../types/response.ts";
5
6
  import type { Agent } from "./agent.ts";
6
- import { resolveModel } from "../ai-sdk/registry.ts";
7
+ import { resolveModel } from "../providers/registry.ts";
7
8
 
8
9
  /** Task descriptor accepted by the automatic `spawn_subagents` tool.
9
10
  *
@@ -58,7 +59,27 @@ function createChildSessionId(parentId: string, tag: string): string {
58
59
  } catch {
59
60
  rand = Math.random().toString(36).slice(2, 10);
60
61
  }
61
- return `${parentId}-sub-${tag.toLowerCase().slice(0, 16)}-${rand}`;
62
+ // Provider-safe: OpenAI `prompt_cache_key` enforces max 64 chars (400
63
+ // otherwise). Parent ids are already ~42 chars (`accel-<uuid>`), so naive
64
+ // `${parent}-sub-${tag}-${rand}` overflows (observed 65-72 chars → every
65
+ // sub-agent 400s with 0 usage). Truncate the parent portion to fit, keeping
66
+ // the tag + rand suffix intact for uniqueness/debuggability.
67
+ const cleanTag = tag.toLowerCase().slice(0, 16);
68
+ const suffix = `-sub-${cleanTag}-${rand}`;
69
+ const maxParent = Math.max(0, 64 - suffix.length);
70
+ const truncatedParent = parentId.slice(0, maxParent);
71
+ return `${truncatedParent}${suffix}`;
72
+ }
73
+
74
+ /** Builds a deterministic fixed-subagent session id that fits 64 chars. */
75
+ function createFixedChildSessionId(parentId: string, name: string): string {
76
+ const suffix = `-sub-${name}`;
77
+ if ((parentId + suffix).length <= 64) return parentId + suffix;
78
+ // Truncate parent first (preserves full tool name for debugging); if still
79
+ // over (very long tool name), truncate the name tail as last resort.
80
+ const maxParent = Math.max(0, 64 - suffix.length);
81
+ if (maxParent > 0) return parentId.slice(0, maxParent) + suffix;
82
+ return (`${parentId}-sub-${name}`).slice(0, 64);
62
83
  }
63
84
 
64
85
  function providerOf(modelStr: string | any | undefined): string | undefined {
@@ -247,10 +268,18 @@ export function createSubagentSpawnTool(parentAgent: Agent): ToolDefinition {
247
268
  if (context?.signal?.aborted) throw new Error("Aborted before spawn");
248
269
  const childSessionId = createChildSessionId(parentAgent.sessionId, subagentName);
249
270
  // Grant ONLY the Main Agent-selected subset from the developer pool. Unknown names are dropped.
271
+ // Matching is case/format-insensitive (same rules as tool execution): the model may emit
272
+ // "RECENT_NEWS" or "recent news" for a registered "recent_news" tool.
273
+ const poolByNormalized = new Map<string, ToolDefinition>();
274
+ for (const [key, def] of Object.entries(toolPool)) {
275
+ poolByNormalized.set(normalizeToolName(key), def);
276
+ const declared = (def as ToolDefinition)?.name;
277
+ if (declared) poolByNormalized.set(normalizeToolName(declared), def);
278
+ }
250
279
  const grantedTools: Record<string, ToolDefinition> = {};
251
280
  for (const toolName of (t as any).tools ?? []) {
252
- const pooled = toolPool[toolName];
253
- if (pooled) grantedTools[toolName] = pooled;
281
+ const pooled = toolPool[toolName] ?? poolByNormalized.get(normalizeToolName(String(toolName)));
282
+ if (pooled) grantedTools[(pooled as ToolDefinition).name || toolName] = pooled;
254
283
  }
255
284
  // Timeout: >0 fixed for every worker; 0 = no limit; -1 = per-task timeoutMs from the Main Agent.
256
285
  const workerTimeout = cfgTimeout > 0 ? cfgTimeout : cfgTimeout === -1 && (t as any).timeoutMs > 0 ? (t as any).timeoutMs : 0;
@@ -271,21 +300,40 @@ export function createSubagentSpawnTool(parentAgent: Agent): ToolDefinition {
271
300
  maxTurns: parentAgent.maxTurns,
272
301
  });
273
302
  let abortListener: (() => void) | null = null;
274
- const abortPromise = context?.signal
303
+ // Per-worker controller: the parent signal alone cannot stop a
304
+ // worker that hits its own timeout, so the timeout path aborts
305
+ // the worker explicitly instead of leaving it running unseen.
306
+ const workerController = new AbortController();
307
+ const parentSignal = context?.signal;
308
+ const forwardParentAbort = () => {
309
+ try {
310
+ workerController.abort((parentSignal as any)?.reason);
311
+ } catch {
312
+ try { workerController.abort(); } catch {}
313
+ }
314
+ };
315
+ if (parentSignal?.aborted) forwardParentAbort();
316
+ else parentSignal?.addEventListener("abort", forwardParentAbort, { once: true });
317
+ const abortPromise = parentSignal
275
318
  ? new Promise<never>((_, reject) => {
276
319
  const onAbort = () => reject(new Error("Sub-agent aborted via parent signal"));
277
320
  abortListener = onAbort;
278
- if (context.signal!.aborted) reject(new Error("Sub-agent aborted via parent signal"));
279
- else context.signal!.addEventListener("abort", onAbort, { once: true });
321
+ if (parentSignal.aborted) reject(new Error("Sub-agent aborted via parent signal"));
322
+ else parentSignal.addEventListener("abort", onAbort, { once: true });
280
323
  })
281
324
  : null;
282
325
  let timeoutId: ReturnType<typeof setTimeout> | null = null;
283
326
  const timeoutPromise = workerTimeout > 0
284
327
  ? new Promise<never>((_, reject) => {
285
- timeoutId = setTimeout(() => reject(new Error(`Sub-agent ${subagentName} timed out after ${workerTimeout}ms`)), workerTimeout);
328
+ timeoutId = setTimeout(() => {
329
+ try {
330
+ workerController.abort(new Error(`Sub-agent ${subagentName} timed out after ${workerTimeout}ms`));
331
+ } catch {}
332
+ reject(new Error(`Sub-agent ${subagentName} timed out after ${workerTimeout}ms`));
333
+ }, workerTimeout);
286
334
  })
287
335
  : null;
288
- const taskPromise = subAgent.run(t.task, { signal: context?.signal } as any);
336
+ const taskPromise = subAgent.run(t.task, { signal: workerController.signal } as any);
289
337
  try {
290
338
  const racers: Promise<unknown>[] = [taskPromise as unknown as Promise<unknown>];
291
339
  if (abortPromise) racers.push(abortPromise);
@@ -294,9 +342,10 @@ export function createSubagentSpawnTool(parentAgent: Agent): ToolDefinition {
294
342
  return res;
295
343
  } finally {
296
344
  if (timeoutId) clearTimeout(timeoutId);
297
- if (abortListener && context?.signal) {
298
- try { context.signal.removeEventListener("abort", abortListener as any); } catch {}
345
+ if (abortListener && parentSignal) {
346
+ try { parentSignal.removeEventListener("abort", abortListener as any); } catch {}
299
347
  }
348
+ try { parentSignal?.removeEventListener("abort", forwardParentAbort); } catch {}
300
349
  }
301
350
  };
302
351
 
@@ -404,7 +453,7 @@ export function agentToTool(
404
453
  const startTime = Date.now();
405
454
  try {
406
455
  const parentSessionId = ctx?.sessionId || agentInstance.sessionId || "session";
407
- const subSessionId = `${parentSessionId}-sub-${sanitizeToolName(name)}`;
456
+ const subSessionId = createFixedChildSessionId(parentSessionId, sanitizeToolName(name));
408
457
  const response: any = await agentInstance.run(task, { signal: ctx?.signal, sessionId: subSessionId } as any);
409
458
  const durationMs = Date.now() - startTime;
410
459
  const metadata: SubAgentExecutionMetadata = {