agent-accelerator 0.4.0 → 0.5.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 (46) hide show
  1. package/README.md +149 -57
  2. package/bunfig.toml +1 -0
  3. package/package.json +2 -3
  4. package/src/agent/agent.ts +414 -118
  5. package/src/agent/context.ts +11 -16
  6. package/src/agent/delegation.ts +111 -274
  7. package/src/agent/loop.ts +327 -99
  8. package/src/agent/subagent.ts +4 -12
  9. package/src/agent/worker.ts +435 -0
  10. package/src/index.ts +58 -5
  11. package/src/models/catalog-cache.ts +65 -25
  12. package/src/models/catalog.ts +47 -2
  13. package/src/providers/google.ts +338 -81
  14. package/src/providers/openai-compat.ts +171 -61
  15. package/src/providers/openai.ts +174 -44
  16. package/src/providers/openrouter.ts +129 -44
  17. package/src/providers/shared.ts +169 -17
  18. package/src/providers.ts +375 -11
  19. package/src/scheduler/scheduled-actions.md +41 -0
  20. package/src/scheduler/scheduler.ts +814 -0
  21. package/src/scheduler/store.ts +263 -0
  22. package/src/scheduler/time.ts +148 -0
  23. package/src/scheduler/tool.ts +151 -0
  24. package/src/session/store.ts +229 -113
  25. package/src/streaming/event-stream.ts +6 -4
  26. package/src/telemetry-file.ts +31 -0
  27. package/src/telemetry.ts +65 -0
  28. package/src/tools/executor.ts +163 -38
  29. package/src/tools/subagent-result.ts +60 -0
  30. package/src/types/agent.ts +74 -22
  31. package/src/types/model.ts +39 -0
  32. package/src/types/response.ts +9 -0
  33. package/src/types/scheduler.ts +81 -0
  34. package/src/types/tool.ts +15 -0
  35. package/src/utils/cache.ts +14 -5
  36. package/src/utils/documents.ts +112 -52
  37. package/src/utils/env.ts +21 -7
  38. package/src/utils/errors.ts +26 -1
  39. package/src/utils/headers.ts +15 -15
  40. package/src/utils/home.ts +30 -0
  41. package/src/utils/media.ts +57 -12
  42. package/src/utils/raw-capture.ts +76 -0
  43. package/src/utils/retry.ts +11 -3
  44. package/src/utils/safe-io.ts +252 -0
  45. package/src/utils/session.ts +7 -8
  46. package/src/data/README.md +0 -84
@@ -12,10 +12,11 @@
12
12
  * effort, service_tier flex/priority, prompt_cache_key affinity, usage,
13
13
  * finish reasons, switching, abort, errors.
14
14
  *
15
+ * Also implemented: `text.format` structured output, `max_output_tokens` (stop + seed have no Responses equivalent: warn+drop).
16
+ *
15
17
  * Out of scope (never sent): background, conversation, previous_response_id
16
- * chaining, include, metadata, temperature/top_p, max_output_tokens,
17
- * truncation, safety_identifier, prompt_cache_options explicit breakpoints,
18
- * text.format structured output, built-in/MCP tools. Those remain
18
+ * chaining, include, metadata, truncation, safety_identifier,
19
+ * prompt_cache_options explicit breakpoints, built-in/MCP tools. Those remain
19
20
  * UNSUPPORTED and are handled via warn+drop in the canonical layer where
20
21
  * applicable.
21
22
  *
@@ -59,11 +60,18 @@ import {
59
60
  redactedHeaders,
60
61
  readErrorPayload,
61
62
  incompleteStreamError,
63
+ hasTruncatedToolArguments,
62
64
  combinedSignal,
63
65
  timeoutFor,
66
+ hasAuthHeader,
67
+ clampToolCallId,
68
+ openStreamResponse,
69
+ retryOptionsFrom,
70
+ headersToRecord,
71
+ parseRetryAfterMs,
64
72
  } from "./shared.ts";
65
73
  import { createGenericModelSpec } from "../models/catalog.ts";
66
- import { getModelFromCatalog, getModelsForProvider } from "../models/catalog.ts";
74
+ import { getModelFromCatalog, getModelsForProvider, modelSupportsStructuredOutput } from "../models/catalog.ts";
67
75
  import {
68
76
  mapThinkingLevelToOpenAI,
69
77
  mapServiceTierToOpenAI,
@@ -71,6 +79,12 @@ import {
71
79
  mapToolChoiceToOpenAI,
72
80
  noteProviderTurn,
73
81
  parseStreamedToolArguments,
82
+ selectRawArguments,
83
+ mapOutputToOpenAI,
84
+ mapGenerationParamsToOpenAI,
85
+ normalizeStructuredOutput,
86
+ augmentSystemPromptWithStructuredOutput,
87
+ emitProviderWarning,
74
88
  } from "../providers.ts";
75
89
 
76
90
  // ---------------------------------------------------------------------------
@@ -102,6 +116,7 @@ interface ResponsesRequestBody {
102
116
  prompt_cache_key?: string;
103
117
  store?: boolean;
104
118
  stream?: boolean;
119
+ text?: { format: { type: "json_schema"; name: string; schema: Record<string, unknown>; strict: boolean; description?: string } };
105
120
  [k: string]: unknown;
106
121
  }
107
122
 
@@ -170,7 +185,10 @@ function resolveBaseUrl(options?: ProviderRequestOptions): string {
170
185
  }
171
186
 
172
187
  function resolveApiKey(options?: ProviderRequestOptions): string | undefined {
173
- return options?.apiKey || getApiKey("openai", undefined, options?.env);
188
+ const key = getApiKey("openai", options?.apiKey, options?.env);
189
+ if (key) return key;
190
+ if (hasAuthHeader(options?.headers)) return "unused";
191
+ return undefined;
174
192
  }
175
193
 
176
194
  /** Strips ONLY the `openai/` prefix. Bare ids pass through untouched. */
@@ -251,6 +269,20 @@ function resultToOutput(result: unknown): string {
251
269
  return safeStringify(result);
252
270
  }
253
271
 
272
+ function normalizeResponsesIdPart(part: string): string {
273
+ const sanitized = String(part ?? "").replace(/[^a-zA-Z0-9_-]/g, "_");
274
+ const truncated = sanitized.length > 64 ? sanitized.slice(0, 64) : sanitized;
275
+ return truncated.replace(/_+$/, "") || "fc_unknown";
276
+ }
277
+
278
+ function normalizeResponsesItemId(itemId: string): string {
279
+ let norm = normalizeResponsesIdPart(itemId);
280
+ if (!norm.startsWith("fc_")) {
281
+ norm = normalizeResponsesIdPart(`fc_${norm}`);
282
+ }
283
+ return norm;
284
+ }
285
+
254
286
  /**
255
287
  * Builds the FULL explicit history (stateless — no server state, no
256
288
  * chaining). Assistant items intentionally carry no provider `id`/`status`:
@@ -300,10 +332,25 @@ async function fullHistoryInput(context: ProviderContext): Promise<ResponseInput
300
332
  const texts: string[] = [];
301
333
  for (const part of m.content) {
302
334
  if (part.type === "tool_call") {
335
+ // Cross-provider pipe IDs (`callId|itemId`) are split and
336
+ // normalized (64-char, `fc_` prefix) so replay never 400s.
337
+ const rawId = String(part.id ?? "");
338
+ const rawCallId = String(part.callId ?? part.id ?? "");
339
+ let itemId = rawId;
340
+ let callId = rawCallId;
341
+ if (rawId.includes("|")) {
342
+ const [c, i] = rawId.split("|");
343
+ if (c) callId = c;
344
+ if (i) itemId = i;
345
+ }
346
+ if (callId.includes("|")) {
347
+ const [c] = callId.split("|");
348
+ if (c) callId = c;
349
+ }
303
350
  items.push({
304
351
  type: "function_call",
305
- id: part.id,
306
- call_id: part.callId || part.id,
352
+ id: normalizeResponsesItemId(itemId),
353
+ call_id: normalizeResponsesIdPart(callId),
307
354
  name: part.name,
308
355
  arguments: JSON.stringify(part.arguments || {}),
309
356
  });
@@ -319,9 +366,11 @@ async function fullHistoryInput(context: ProviderContext): Promise<ResponseInput
319
366
  } else if (m.role === "tool") {
320
367
  if (typeof m.content === "string") {
321
368
  const queue = (m.name && idsByName.get(m.name)) || [];
369
+ const rawPair = queue.shift() || newToolCallId();
370
+ const rawCall = rawPair.includes("|") ? rawPair.split("|")[0]! : rawPair;
322
371
  items.push({
323
372
  type: "function_call_output",
324
- call_id: queue.shift() || newToolCallId(),
373
+ call_id: normalizeResponsesIdPart(rawCall),
325
374
  output: m.content,
326
375
  });
327
376
  continue;
@@ -329,9 +378,11 @@ async function fullHistoryInput(context: ProviderContext): Promise<ResponseInput
329
378
  if (!Array.isArray(m.content)) continue;
330
379
  for (const part of m.content) {
331
380
  if (part.type === "tool_result") {
381
+ const rawPair = pairing.get(part.id) || part.id;
382
+ const rawCall = rawPair.includes("|") ? rawPair.split("|")[0]! : rawPair;
332
383
  items.push({
333
384
  type: "function_call_output",
334
- call_id: pairing.get(part.id) || part.id,
385
+ call_id: normalizeResponsesIdPart(rawCall),
335
386
  output: resultToOutput(part.result),
336
387
  });
337
388
  }
@@ -486,16 +537,38 @@ export class OpenAIResponsesProvider implements Provider {
486
537
  const serviceTier = mapServiceTierToOpenAI(options?.serviceTier);
487
538
  applyCacheForOpenAI(options?.cache, `openai/${modelId}`);
488
539
 
540
+ // Structured outputs: native `text.format` when supported, else emulated
541
+ // via instructions (catalog `structured_output: false` would 400).
542
+ let effectiveSystemPrompt = context.systemPrompt;
543
+ let outputEmulated = false;
544
+ let outputSpec: import("../types/model.ts").StructuredOutputSpec | undefined;
545
+ try {
546
+ outputSpec = normalizeStructuredOutput(options?.output ?? (options as { outputSchema?: unknown })?.outputSchema);
547
+ } catch {
548
+ outputSpec = undefined;
549
+ }
550
+ if (outputSpec && !modelSupportsStructuredOutput("openai", modelId)) {
551
+ outputEmulated = true;
552
+ effectiveSystemPrompt = augmentSystemPromptWithStructuredOutput(context.systemPrompt, outputSpec);
553
+ emitProviderWarning({
554
+ provider: "openai",
555
+ capability: "structured output",
556
+ requested: `${outputSpec.name} (openai/${modelId})`,
557
+ reason: "the catalog reports structured_output: false for this model, so native text.format is unsupported.",
558
+ fallback: "emulated JSON instruction + client validation (no text.format is sent)",
559
+ });
560
+ }
561
+
489
562
  const body: ResponsesRequestBody = {
490
563
  model: modelId,
491
- input: await fullHistoryInput(context),
564
+ input: await fullHistoryInput({ ...context, systemPrompt: effectiveSystemPrompt }),
492
565
  // Stateless by design: the server defaults store:true, so opt out
493
566
  // explicitly. Full history is always sent, so no chaining is needed
494
567
  // and provider switching stays trivial.
495
568
  store: false,
496
569
  ...(stream ? { stream: true } : {}),
497
570
  };
498
- if (context.systemPrompt) body.instructions = context.systemPrompt;
571
+ if (effectiveSystemPrompt) body.instructions = effectiveSystemPrompt;
499
572
  const aiTools = toOpenAITools(tools);
500
573
  if (aiTools) body.tools = aiTools;
501
574
  const toolChoice = mapToolChoiceToOpenAI(
@@ -504,8 +577,19 @@ export class OpenAIResponsesProvider implements Provider {
504
577
  if (toolChoice !== undefined) body.tool_choice = toolChoice;
505
578
  if (effort) body.reasoning = { effort };
506
579
  if (serviceTier) body.service_tier = serviceTier;
580
+ // max_output_tokens (stop + seed warn+drop: not on this wire).
581
+ Object.assign(body, mapGenerationParamsToOpenAI(options, `openai/${modelId}`));
582
+ // Structured outputs: Responses `text.format{type:json_schema,name,schema,strict}`.
583
+ // Skipped when emulated (instruction already in `instructions`).
584
+ if (outputSpec && !outputEmulated) {
585
+ try {
586
+ const mapped = mapOutputToOpenAI(outputSpec);
587
+ if (mapped) body.text = { format: mapped.format };
588
+ } catch {
589
+ // why: invalid output shapes fail fast at Agent construction/loop validation.
590
+ }
591
+ }
507
592
  // Cache affinity: prompt_cache_key replaces the legacy `user` field.
508
- // (prompt_cache_options explicit breakpoints are out of scope.)
509
593
  // OpenAI enforces max 64 chars — clamp defensively so child session ids
510
594
  // (`parent-sub-tag-rand`) and user-supplied long ids never 400. Headers
511
595
  // are clamped the same way via buildSessionHeaders, keeping affinity
@@ -514,6 +598,29 @@ export class OpenAIResponsesProvider implements Provider {
514
598
  const cacheKey = clampCacheKey(sessionId);
515
599
  if (cacheKey) body.prompt_cache_key = cacheKey;
516
600
  }
601
+ // Long retention: `prompt_cache_retention: "24h"` when the catalog
602
+ // supports it (pi api/openai-responses.ts:97 rule). Other explicit modes
603
+ // stay warn+drop via applyCacheForOpenAI above.
604
+ if (options?.cache?.retention === "long") {
605
+ let supportsLong = true;
606
+ try {
607
+ const spec = getModelFromCatalog("openai", modelId);
608
+ if (spec && typeof spec.capabilities?.supportsLongCacheRetention === "boolean") {
609
+ supportsLong = spec.capabilities.supportsLongCacheRetention;
610
+ }
611
+ } catch {}
612
+ if (supportsLong) {
613
+ (body as Record<string, unknown>)["prompt_cache_retention"] = "24h";
614
+ } else {
615
+ emitProviderWarning({
616
+ provider: "openai",
617
+ capability: "cache retention",
618
+ requested: `long (openai/${modelId})`,
619
+ reason: "the catalog reports no long-cache support for this model.",
620
+ fallback: "default caching with prompt_cache_key affinity (no retention payload is sent)",
621
+ });
622
+ }
623
+ }
517
624
  // Stateless API use: previous_response_id / background / conversation are
518
625
  // NEVER sent (server state would break provider-agnostic switching).
519
626
  return body;
@@ -536,9 +643,10 @@ export class OpenAIResponsesProvider implements Provider {
536
643
  if (INTERNAL_HEADERS.has(k.toLowerCase())) delete headers[k];
537
644
  }
538
645
  // Case-insensitive merge so a lowercase `authorization` custom header
539
- // never coexists with the canonical bearer.
646
+ // never coexists with the canonical bearer. Proxy flows
647
+ // (`apiKey === "unused"`) preserve custom auth instead of overwriting.
540
648
  setHeaderCaseInsensitive(headers, "Content-Type", "application/json");
541
- setHeaderCaseInsensitive(headers, "Authorization", `Bearer ${apiKey}`);
649
+ if (apiKey !== "unused") setHeaderCaseInsensitive(headers, "Authorization", `Bearer ${apiKey}`);
542
650
  return { method: "POST", headers, body: JSON.stringify(body), signal };
543
651
  }
544
652
 
@@ -552,7 +660,7 @@ export class OpenAIResponsesProvider implements Provider {
552
660
  ): Promise<{ status: number; statusText: string; headers: Record<string, string>; text: string }> {
553
661
  const { signal: effective, cleanup } = combinedSignal(signal, timeoutFor(options as { timeoutMs?: number } | undefined));
554
662
  try {
555
- const res = await fetch(url, this.requestInit(body, options, apiKey, sessionId, effective));
663
+ const res = await (options?.fetch ?? fetch)(url, this.requestInit(body, options, apiKey, sessionId, effective));
556
664
  const text = await res.text();
557
665
  const headers: Record<string, string> = {};
558
666
  res.headers.forEach((v, k) => {
@@ -568,17 +676,29 @@ export class OpenAIResponsesProvider implements Provider {
568
676
  status: number,
569
677
  url: string,
570
678
  bodyText: string,
571
- modelId: string
679
+ modelId: string,
680
+ headers?: Record<string, string>
572
681
  ): void {
573
682
  if (status >= 200 && status < 300) return;
574
- const { message, code, errorType } = readErrorPayload(bodyText);
575
- const err: Record<string, unknown> & Error = new Error(message) as Record<string, unknown> & Error;
683
+ const { message, code, errorType, raw } = readErrorPayload(bodyText);
684
+ const hint =
685
+ status === 401
686
+ ? " Check OPENAI_API_KEY (no quotes/spaces, correct provider), OPENAI_BASE_URL, and that the key is loaded (bun --env-file=.env.local)."
687
+ : "";
688
+ const fullMessage = `${message}${hint}`;
689
+ const err: Record<string, unknown> & Error = new Error(fullMessage) as Record<string, unknown> & Error;
576
690
  (err as Record<string, unknown>)["statusCode"] = status;
577
691
  (err as Record<string, unknown>)["status"] = status;
578
692
  (err as Record<string, unknown>)["responseBody"] = bodyText.slice(0, 500);
579
693
  (err as Record<string, unknown>)["url"] = url.split("?")[0];
694
+ // Preserve hint through toConciseProviderError re-wrap (it prefers
695
+ // data.error.message over responseBody).
696
+ (err as Record<string, unknown>)["data"] = { error: { message: fullMessage } };
580
697
  if (code !== undefined) (err as Record<string, unknown>)["code"] = code;
581
698
  if (errorType) (err as Record<string, unknown>)["errorType"] = errorType;
699
+ if (raw) (err as Record<string, unknown>)["raw"] = raw;
700
+ const retryAfterMs = parseRetryAfterMs(headers);
701
+ if (retryAfterMs !== undefined) (err as Record<string, unknown>)["retryAfterMs"] = retryAfterMs;
582
702
  throw toConciseProviderError(err, "openai", modelId);
583
703
  }
584
704
 
@@ -628,7 +748,7 @@ export class OpenAIResponsesProvider implements Provider {
628
748
 
629
749
  const doCall = async (): Promise<ProviderGenerateResult> => {
630
750
  const res = await this.doFetch(url, body, options, apiKey, sessionId, options?.signal);
631
- this.throwIfError(res.status, url, res.text, clean);
751
+ this.throwIfError(res.status, url, res.text, clean, res.headers);
632
752
  let response: ResponsesObject;
633
753
  try {
634
754
  response = JSON.parse(res.text) as ResponsesObject;
@@ -656,8 +776,7 @@ export class OpenAIResponsesProvider implements Provider {
656
776
 
657
777
  try {
658
778
  return await withRetries(doCall, {
659
- maxRetries: options?.maxRetries,
660
- maxRetryDelayMs: options?.maxRetryDelayMs,
779
+ ...retryOptionsFrom(options),
661
780
  signal: options?.signal,
662
781
  label: { providerId: "openai", modelId: clean },
663
782
  });
@@ -727,25 +846,30 @@ export class OpenAIResponsesProvider implements Provider {
727
846
 
728
847
  // Q-17: optional per-call timeout races the linked abort signal.
729
848
  const streamTimeoutMs = timeoutFor(options as { timeoutMs?: number } | undefined);
730
- const streamTimeoutTimer =
731
- streamTimeoutMs && streamTimeoutMs > 0
732
- ? setTimeout(() => {
733
- try {
734
- linked.abort();
735
- } catch {}
736
- }, streamTimeoutMs)
737
- : undefined;
738
- let res: Response;
739
- try {
740
- res = await fetch(url, this.requestInit(body, options, apiKey, sessionId, linked.signal));
741
- } finally {
742
- if (streamTimeoutTimer) clearTimeout(streamTimeoutTimer);
743
- }
744
- if (!res.ok || !res.body) {
745
- const text = !res.ok ? await res.text().catch(() => "") : "";
746
- if (!res.ok) this.throwIfError(res.status, url, text, clean);
747
- throw toConciseProviderError(new Error("OpenAI streaming response had no body"), "openai", clean);
748
- }
849
+ // Transient failures (429/5xx/network) are retried here, before the
850
+ // first byte; once the stream is live it is never retried.
851
+ const res = await openStreamResponse({
852
+ fetchOnce: async () => {
853
+ const timer =
854
+ streamTimeoutMs && streamTimeoutMs > 0
855
+ ? setTimeout(() => {
856
+ try {
857
+ linked.abort();
858
+ } catch {}
859
+ }, streamTimeoutMs)
860
+ : undefined;
861
+ try {
862
+ return await (options?.fetch ?? fetch)(url, this.requestInit(body, options, apiKey, sessionId, linked.signal));
863
+ } finally {
864
+ if (timer) clearTimeout(timer);
865
+ }
866
+ },
867
+ onHttpError: (r, text) => this.throwIfError(r.status, url, text, clean, headersToRecord(r.headers)),
868
+ noBodyError: () => toConciseProviderError(new Error("OpenAI streaming response had no body"), "openai", clean),
869
+ retry: retryOptionsFrom(options),
870
+ signal: linked.signal,
871
+ label: { providerId: "openai", modelId: clean },
872
+ });
749
873
  const responseHeaders: Record<string, string> = {};
750
874
  res.headers.forEach((v, k) => {
751
875
  responseHeaders[k] = v;
@@ -754,7 +878,7 @@ export class OpenAIResponsesProvider implements Provider {
754
878
  eventStream.push({ type: "start", raw: { request: rawRequest } } as never);
755
879
 
756
880
  const parser = new SSEParser();
757
- const reader = res.body.getReader();
881
+ const reader = res.body!.getReader(); // openStreamResponse guarantees a body
758
882
  const decoder = new TextDecoder();
759
883
  let text = "";
760
884
  let thinking = "";
@@ -962,7 +1086,7 @@ export class OpenAIResponsesProvider implements Provider {
962
1086
  callId: c.callId || undefined,
963
1087
  name: c.name,
964
1088
  arguments: args,
965
- rawArguments: c.startArgs + c.deltaArgs,
1089
+ rawArguments: selectRawArguments(c.startArgs, c.deltaArgs),
966
1090
  };
967
1091
  toolCalls.push(record);
968
1092
  eventStream.push({ type: "tool_call_complete", toolCall: record });
@@ -994,6 +1118,13 @@ export class OpenAIResponsesProvider implements Provider {
994
1118
  eventStream.fail(incompleteStreamError("openai", clean));
995
1119
  return;
996
1120
  }
1121
+ // Refuse unfinished tool calls whose args never parsed (output_item.done
1122
+ // never arrived): truncated `{raw}` must not reach the executor (pi
1123
+ // api/openai-responses-shared.ts:766 rule). Non-SSE mocks preserve legacy.
1124
+ if (!hasTerminal && toolCalls.some((c) => hasTruncatedToolArguments(c.arguments)) && isSSEPayload) {
1125
+ eventStream.fail(incompleteStreamError("openai", clean));
1126
+ return;
1127
+ }
997
1128
 
998
1129
  const cleanThinking = cleanThinkingEarly;
999
1130
  const finalResponse = new AgentResponse({
@@ -1019,8 +1150,7 @@ export class OpenAIResponsesProvider implements Provider {
1019
1150
  const isAbort =
1020
1151
  linked.signal.aborted ||
1021
1152
  eventStream.isCancelled() ||
1022
- (raw as { name?: string }).name === "AbortError" ||
1023
- /abort|cancell?ed/i.test(String((raw as { message?: string }).message ?? raw));
1153
+ (raw as { name?: string }).name === "AbortError";
1024
1154
  eventStream.fail(
1025
1155
  isAbort ? Object.assign(new Error("Stream aborted"), { name: "AbortError" }) : raw
1026
1156
  );