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
@@ -9,6 +9,8 @@ export interface SubAgentStep {
9
9
  name?: string;
10
10
  text?: string;
11
11
  thinking?: string;
12
+ /** Opaque provider thought signature captured with this step's reasoning, when present. */
13
+ thoughtSignature?: string;
12
14
  isError?: boolean;
13
15
  timestamp: number;
14
16
  /** True while the worker is still streaming this step; finalized on turn end. */
@@ -101,6 +103,8 @@ export function normalizeFinishReason(raw?: string): {
101
103
  /** JSON representation returned by AgentResponse.toJSON(). */
102
104
  export interface AgentResponseJSON {
103
105
  text: string;
106
+ /** Validated structured-output value when `AgentConfig.output` was set. */
107
+ parsed?: unknown;
104
108
  thinking?: string;
105
109
  thoughtSignature?: string;
106
110
  toolCalls?: ToolCallRecord[];
@@ -121,6 +125,8 @@ export interface AgentResponseJSON {
121
125
  /** Normalized final result returned by every Agent run. */
122
126
  export class AgentResponse {
123
127
  readonly text: string;
128
+ /** Validated structured-output value when `AgentConfig.output` was set. */
129
+ readonly parsed?: unknown;
124
130
  readonly thinking?: string;
125
131
  readonly thoughtSignature?: string;
126
132
  readonly toolCalls: ToolCallRecord[];
@@ -146,6 +152,7 @@ export class AgentResponse {
146
152
  */
147
153
  constructor(data: {
148
154
  text: string;
155
+ parsed?: unknown;
149
156
  thinking?: string;
150
157
  thoughtSignature?: string;
151
158
  toolCalls?: ToolCallRecord[];
@@ -162,6 +169,7 @@ export class AgentResponse {
162
169
  turns?: number;
163
170
  }) {
164
171
  this.text = data.text;
172
+ if (data.parsed !== undefined) (this as { parsed?: unknown }).parsed = data.parsed;
165
173
  this.thinking = data.thinking;
166
174
  this.thoughtSignature = data.thoughtSignature;
167
175
  this.toolCalls = data.toolCalls ?? [];
@@ -187,6 +195,7 @@ export class AgentResponse {
187
195
  toJSON(): AgentResponseJSON {
188
196
  return {
189
197
  text: this.text,
198
+ ...(this.parsed !== undefined ? { parsed: this.parsed } : {}),
190
199
  thinking: this.thinking,
191
200
  thoughtSignature: this.thoughtSignature,
192
201
  toolCalls: this.toolCalls.length > 0 ? this.toolCalls : undefined,
@@ -0,0 +1,81 @@
1
+ /** Lifecycle of a scheduled action as stored in `tasks.json`. */
2
+ export type ScheduledState = "waiting" | "cooking" | "missed" | "done";
3
+
4
+ /** Outcome of one run. */
5
+ export type ScheduledRunStatus = "ok" | "failed";
6
+
7
+ /** Schedule fields shared by `create` input and the stored record. */
8
+ export interface ScheduleSpec {
9
+ /** Delivery clock time `DD:HH:MM`: `DD` days from today, `HH:MM` on a 12-hour clock (see `meridiem`). */
10
+ timeToDeliver: string;
11
+ meridiem: "AM" | "PM";
12
+ /** Duration `DD:HH:MM` before delivery at which the run starts. Default 10 minutes (`00:00:10`). */
13
+ startCookingInAdvance?: string;
14
+ /** Duration `DD:HH:MM` between deliveries (`01:00:00` daily). Omit to run once. */
15
+ repeatEvery?: string;
16
+ /** Extra attempts if a run fails, only while time remains before delivery. Default 0. */
17
+ retries?: number;
18
+ }
19
+
20
+ /** What `createScheduledAction` receives: dynamic sub-agent fields plus the schedule. */
21
+ export interface ScheduledActionInput {
22
+ role?: string;
23
+ instructions: string;
24
+ task: string;
25
+ /** Names from the developer's `dynamicSubagents.tools` pool. */
26
+ tools?: string[];
27
+ timeoutMs?: number;
28
+ scheduled_action: ScheduleSpec & {
29
+ /** Human label. */
30
+ name: string;
31
+ /** Lowercase kebab-case, max 48 chars. The SDK appends 6 random chars. */
32
+ id: string;
33
+ };
34
+ }
35
+
36
+ /** What `updateScheduledAction` may change. `id` and the creating agent are immutable. */
37
+ export type ScheduledActionPatch = Partial<Omit<ScheduledActionInput, "scheduled_action">> & {
38
+ scheduled_action?: Partial<Omit<ScheduledActionInput["scheduled_action"], "id">>;
39
+ };
40
+
41
+ /** One entry of `tasks.json`. All times are local clock strings `YYYY-MM-DDTHH:mm:ss` (no offset). */
42
+ export interface ScheduledActionRecord {
43
+ id: string;
44
+ name: string;
45
+ /** Name of the creating Agent; it runs the worker. */
46
+ agent: string;
47
+ role?: string;
48
+ instructions: string;
49
+ task: string;
50
+ /** Tool NAMES only; resolved against the live agent's pool at run time. */
51
+ tools: string[];
52
+ timeoutMs?: number;
53
+ /** The schedule as given. The stored truth is `nextCookAt` / `nextDeliverAt`. */
54
+ schedule: ScheduleSpec & { startCookingInAdvance: string };
55
+ state: ScheduledState;
56
+ /** `null` for a finished one-shot. */
57
+ nextCookAt: string | null;
58
+ nextDeliverAt: string | null;
59
+ createdAt: string;
60
+ /** Present only while `state` is `cooking`. */
61
+ current?: { cookStartedAt: string; deliverAt: string };
62
+ lastRun?: { status: ScheduledRunStatus; file: string; deliverAt: string; deliveredAt: string; late: boolean };
63
+ }
64
+
65
+ /** Payload of the `scheduled_action.delivered` telemetry event. Plain JSON. */
66
+ export interface ScheduledDelivery {
67
+ id: string;
68
+ name: string;
69
+ status: ScheduledRunStatus;
70
+ /** Absolute path of the run file. */
71
+ file: string;
72
+ readyAt: string;
73
+ deliverAt: string;
74
+ late: boolean;
75
+ }
76
+
77
+ /** Result of `killScheduledAction`. */
78
+ export type KillResult =
79
+ | { ok: true }
80
+ | { ok: false; reason: "not_found" }
81
+ | { ok: false; reason: "running"; message: string };
package/src/types/tool.ts CHANGED
@@ -136,3 +136,18 @@ export interface ToolResultRecord {
136
136
  isError?: boolean;
137
137
  durationMs?: number;
138
138
  }
139
+
140
+ /**
141
+ * Key of the marker object that replaces tool arguments whose streamed JSON
142
+ * never parsed (`{ __truncated_raw: "<partial text>" }`). It is deliberately
143
+ * not a plausible parameter name, so a real tool with a `raw` parameter is
144
+ * never mistaken for a truncated call. Such calls are never executed.
145
+ */
146
+ export const TRUNCATED_ARGS_KEY = "__truncated_raw";
147
+
148
+ /** True when `args` is exactly the truncated-arguments marker. */
149
+ export function isTruncatedArguments(args: unknown): boolean {
150
+ if (!args || typeof args !== "object" || Array.isArray(args)) return false;
151
+ const keys = Object.keys(args as Record<string, unknown>);
152
+ return keys.length === 1 && keys[0] === TRUNCATED_ARGS_KEY && typeof (args as Record<string, unknown>)[TRUNCATED_ARGS_KEY] === "string";
153
+ }
@@ -71,7 +71,7 @@ export interface CreateExplicitCacheOptions {
71
71
 
72
72
  /**
73
73
  * Creates an explicit cached content object using Google Gemini's cachedContents API.
74
- * REST: POST https://generativelanguage.googleapis.com/v1beta/cachedContents?key=...
74
+ * REST: POST https://generativelanguage.googleapis.com/v1beta/cachedContents (key in the `x-goog-api-key` header)
75
75
  */
76
76
  /**
77
77
  * Creates a Google Gemini cachedContents resource through the REST API.
@@ -125,11 +125,13 @@ export async function createExplicitCache(
125
125
  (payload as any).tool_config = options.toolConfig;
126
126
  }
127
127
 
128
- const url = `${baseUrl}/cachedContents?key=${apiKey}`;
128
+ // The key travels in a header, never in the URL (URLs end up in proxy and access logs).
129
+ const url = `${baseUrl}/cachedContents`;
129
130
  const response = await fetch(url, {
130
131
  method: "POST",
131
132
  headers: {
132
133
  "Content-Type": "application/json",
134
+ "x-goog-api-key": apiKey,
133
135
  },
134
136
  body: JSON.stringify(payload),
135
137
  });
@@ -152,8 +154,15 @@ export async function createExplicitCache(
152
154
  /** Header-name deny-list (case-insensitive substring match). */
153
155
  const REDACT_HEADER_SUBSTRINGS = ["key", "token", "secret", "cookie", "authorization"];
154
156
 
155
- /** Secret-looking value prefixes. */
156
- const REDACT_VALUE_PREFIXES = ["sk-", "AIza", "gsk-", "xox-"];
157
+ /**
158
+ * Credential-looking values. Anchored on a non-alphanumeric boundary so plain
159
+ * words that merely contain "sk-" (e.g. `Task-Manager`, `Disk-Cache`) are NOT
160
+ * redacted, while `sk-…`, `gsk_…`, `xoxb-…` and `AIza…` tokens are.
161
+ */
162
+ const SENSITIVE_VALUE_PATTERNS: RegExp[] = [
163
+ /(^|[^A-Za-z0-9])(sk|gsk|xox[a-z]?)[-_][A-Za-z0-9_-]{8,}/,
164
+ /(^|[^A-Za-z0-9])AIza[A-Za-z0-9_-]{10,}/,
165
+ ];
157
166
 
158
167
  function headerNameIsSensitive(name: string): boolean {
159
168
  const lower = String(name ?? "").toLowerCase();
@@ -163,7 +172,7 @@ function headerNameIsSensitive(name: string): boolean {
163
172
  function valueLooksSensitive(value: unknown): boolean {
164
173
  if (typeof value !== "string") return false;
165
174
  const v = value.trim();
166
- if (REDACT_VALUE_PREFIXES.some((p) => v.includes(p))) return true;
175
+ if (SENSITIVE_VALUE_PATTERNS.some((re) => re.test(v))) return true;
167
176
  // Bearer-style long tokens: "Bearer <20+ chars>"
168
177
  if (/^bearer\s+\S{20,}/i.test(v)) return true;
169
178
  return false;
@@ -6,6 +6,7 @@ import { normalizeMediaInput } from "./media.ts";
6
6
  import { base64ToBytes } from "./base64.ts";
7
7
  import { escapeXml } from "./serialization.ts";
8
8
  import { getModelFromCatalog } from "../models/catalog.ts";
9
+ import { assertPathAllowed, fetchRemoteBytes, RemoteFetchError } from "./safe-io.ts";
9
10
 
10
11
  /**
11
12
  * Client-side document conversion (anydoc engine).
@@ -104,6 +105,16 @@ export interface ConvertDocumentOptions {
104
105
  maxChars?: number;
105
106
  /** Abort signal honored by the remote-fetch leg. */
106
107
  signal?: AbortSignal;
108
+ /**
109
+ * Allowed local roots for path inputs (see `AgentConfig.fileAccess`).
110
+ * Omitted = unrestricted (developer-supplied inputs); `[]` denies every path.
111
+ */
112
+ roots?: readonly string[];
113
+ /**
114
+ * Input is model/user-controlled: private/internal hosts are refused for
115
+ * remote URLs. The built-in tool always sets this.
116
+ */
117
+ untrusted?: boolean;
107
118
  }
108
119
 
109
120
  /** Normalized conversion result (pre-XML-envelope). */
@@ -296,7 +307,23 @@ export async function convertDocumentInput(
296
307
  try {
297
308
  // Remote URL leg: fetch bytes first (anydoc reads files, not URLs).
298
309
  if (typeof input === "string" && isHttpUrl(input)) {
299
- const res = await fetch(input, { signal: opts.signal });
310
+ let res;
311
+ try {
312
+ res = await fetchRemoteBytes(input, {
313
+ signal: opts.signal,
314
+ maxBytes: MAX_DOCUMENT_FETCH_BYTES,
315
+ allowPrivateNetwork: !opts.untrusted,
316
+ });
317
+ } catch (err) {
318
+ if (err instanceof RemoteFetchError) {
319
+ throw new DocumentConversionError(
320
+ err.reason === "too_large" ? "resourceLimit" : "blocked",
321
+ `[Agent Accelerator] Could not download document "${input}": ${err.message}`,
322
+ input
323
+ );
324
+ }
325
+ throw err;
326
+ }
300
327
  if (!res.ok) {
301
328
  throw new DocumentConversionError(
302
329
  "io",
@@ -304,19 +331,12 @@ export async function convertDocumentInput(
304
331
  input
305
332
  );
306
333
  }
307
- const buf = new Uint8Array(await res.arrayBuffer());
308
- if (buf.byteLength > MAX_DOCUMENT_FETCH_BYTES) {
309
- throw new DocumentConversionError(
310
- "resourceLimit",
311
- `[Agent Accelerator] Remote document "${input}" is ${(buf.byteLength / 1_000_000).toFixed(1)} MB, over the ${MAX_DOCUMENT_FETCH_BYTES / 1_000_000} MB fetch cap.`,
312
- input
313
- );
314
- }
334
+ const buf = res.bytes;
315
335
  const name = filenameOpt || basenameOf(input);
316
336
  const inferred = resolveDocumentFormat({
317
337
  filename: name,
318
338
  format: opts.format,
319
- mimeType: res.headers.get("content-type") || opts.mimeType,
339
+ mimeType: res.contentType || opts.mimeType,
320
340
  });
321
341
  let wire = canonicalizeFormat(anydoc, inferred);
322
342
  if (!wire) {
@@ -340,6 +360,17 @@ export async function convertDocumentInput(
340
360
 
341
361
  // Local path leg: the engine reads + detects from the path itself.
342
362
  if (typeof input === "string" && looksLikePath(input)) {
363
+ if (opts.roots) {
364
+ try {
365
+ await assertPathAllowed(input, opts.roots);
366
+ } catch (err) {
367
+ throw new DocumentConversionError(
368
+ "blocked",
369
+ `[Agent Accelerator] Could not read document "${input.slice(0, 120)}": ${err instanceof Error ? err.message : String(err)}`,
370
+ input
371
+ );
372
+ }
373
+ }
343
374
  const name = filenameOpt || basenameOf(input);
344
375
  const markdown = await anydoc.toMarkdown(input);
345
376
  const inferred = resolveDocumentFormat({ filename: name, format: opts.format });
@@ -424,7 +455,7 @@ export function modelSupportsFileInput(providerId: string, modelId: string): boo
424
455
  */
425
456
  export async function preprocessFilePartsForBypass(
426
457
  messages: Message[],
427
- opts: { providerId: string; modelId: string; signal?: AbortSignal; maxChars?: number }
458
+ opts: { providerId: string; modelId: string; signal?: AbortSignal; maxChars?: number; roots?: readonly string[] }
428
459
  ): Promise<void> {
429
460
  if (modelSupportsFileInput(opts.providerId, opts.modelId)) return;
430
461
  for (const m of messages) {
@@ -451,6 +482,7 @@ export async function preprocessFilePartsForBypass(
451
482
  mimeType,
452
483
  maxChars: opts.maxChars,
453
484
  signal: opts.signal,
485
+ roots: opts.roots,
454
486
  });
455
487
  const destination =
456
488
  filename ||
@@ -466,8 +498,75 @@ export async function preprocessFilePartsForBypass(
466
498
  }
467
499
  }
468
500
 
501
+ /** Options for {@link createConvertDocumentTool}. */
502
+ export interface ConvertDocumentToolOptions {
503
+ /**
504
+ * Local roots the model may read from. Defaults to `[process.cwd()]`; pass
505
+ * `[]` to deny every local path (URLs only).
506
+ */
507
+ roots?: readonly string[];
508
+ }
509
+
510
+ /**
511
+ * Builds the model-callable document converter. The `destination` is chosen
512
+ * by the model, so it is always treated as untrusted: local paths must sit
513
+ * inside `roots`, and remote URLs may not target private/internal hosts.
514
+ *
515
+ * @example `const tool = createConvertDocumentTool({ roots: ["./docs"] });`
516
+ */
517
+ export function createConvertDocumentTool(options: ConvertDocumentToolOptions = {}): ToolDefinition {
518
+ return tool({
519
+ name: "convert_document_to_markdown",
520
+ description:
521
+ "Convert a document file to Markdown text. Call this when the user provides a document file path or URL, or asks about the contents of a document. " +
522
+ "Supported formats: PDF (.pdf), Word (.doc, .docx, .docm), PowerPoint (.ppt, .pps, .pot, .pptx, .pptm, .ppsx, .ppsm), " +
523
+ "Excel (.xls, .xlsx, .xlsm, .xlsb), OpenDocument (.odt, .ods, .odp), RTF (.rtf), EPUB (.epub), CSV (.csv). " +
524
+ "Returns the content wrapped in <Document name destination> tags. Do not use for images, audio, or video.",
525
+ input: z.object({
526
+ destination: z
527
+ .string()
528
+ .min(1)
529
+ .describe("Local file path or http(s) URL of the document to convert."),
530
+ format: z
531
+ .string()
532
+ .optional()
533
+ .describe(
534
+ "Explicit format override, e.g. 'pdf', 'docx', 'xlsx', 'csv'. Inferred from the destination when omitted."
535
+ ),
536
+ maxChars: z
537
+ .number()
538
+ .int()
539
+ .positive()
540
+ .optional()
541
+ .describe("Maximum Markdown characters to return (default 100000). Excess is truncated with a marker."),
542
+ }),
543
+ timeoutMs: 60_000,
544
+ execute: async ({ destination, format, maxChars }, ctx) => {
545
+ const dest = String(destination ?? "").trim();
546
+ if (!dest) {
547
+ return "Error: convert_document_to_markdown needs a non-empty destination (local file path or http(s) URL).";
548
+ }
549
+ try {
550
+ const doc = await convertDocumentInput(dest, {
551
+ format,
552
+ maxChars,
553
+ signal: ctx?.signal,
554
+ untrusted: true,
555
+ roots: options.roots ?? [process.cwd()],
556
+ });
557
+ return buildDocumentXml(doc.name, dest, doc.markdown);
558
+ } catch (err) {
559
+ const detail = err instanceof Error ? err.message : String(err);
560
+ return `Error: ${detail}`;
561
+ }
562
+ },
563
+ });
564
+ }
565
+
469
566
  /**
470
- * Built-in model-callable document converter. Register it and move on:
567
+ * Built-in model-callable document converter with the default policy (local
568
+ * paths limited to the working directory, no private-network URLs). Use
569
+ * {@link createConvertDocumentTool} to choose other roots.
471
570
  *
472
571
  * @example
473
572
  * ```ts
@@ -475,43 +574,4 @@ export async function preprocessFilePartsForBypass(
475
574
  * const agent = new Agent({ model, tools: { convert_document_to_markdown } });
476
575
  * ```
477
576
  */
478
- export const convert_document_to_markdown: ToolDefinition = tool({
479
- name: "convert_document_to_markdown",
480
- description:
481
- "Convert a document file to Markdown text. Call this when the user provides a document file path or URL, or asks about the contents of a document. " +
482
- "Supported formats: PDF (.pdf), Word (.doc, .docx, .docm), PowerPoint (.ppt, .pps, .pot, .pptx, .pptm, .ppsx, .ppsm), " +
483
- "Excel (.xls, .xlsx, .xlsm, .xlsb), OpenDocument (.odt, .ods, .odp), RTF (.rtf), EPUB (.epub), CSV (.csv). " +
484
- "Returns the content wrapped in <Document name destination> tags. Do not use for images, audio, or video.",
485
- input: z.object({
486
- destination: z
487
- .string()
488
- .min(1)
489
- .describe("Local file path or http(s) URL of the document to convert."),
490
- format: z
491
- .string()
492
- .optional()
493
- .describe(
494
- "Explicit format override, e.g. 'pdf', 'docx', 'xlsx', 'csv'. Inferred from the destination when omitted."
495
- ),
496
- maxChars: z
497
- .number()
498
- .int()
499
- .positive()
500
- .optional()
501
- .describe("Maximum Markdown characters to return (default 100000). Excess is truncated with a marker."),
502
- }),
503
- timeoutMs: 60_000,
504
- execute: async ({ destination, format, maxChars }, ctx) => {
505
- const dest = String(destination ?? "").trim();
506
- if (!dest) {
507
- return "Error: convert_document_to_markdown needs a non-empty destination (local file path or http(s) URL).";
508
- }
509
- try {
510
- const doc = await convertDocumentInput(dest, { format, maxChars, signal: ctx?.signal });
511
- return buildDocumentXml(doc.name, dest, doc.markdown);
512
- } catch (err) {
513
- const detail = err instanceof Error ? err.message : String(err);
514
- return `Error: ${detail}`;
515
- }
516
- },
517
- });
577
+ export const convert_document_to_markdown: ToolDefinition = createConvertDocumentTool();
package/src/utils/env.ts CHANGED
@@ -9,7 +9,10 @@
9
9
  */
10
10
  export function getEnv(key: string, fallback?: string): string | undefined {
11
11
  if (typeof process !== "undefined" && process.env && process.env[key]) {
12
- return process.env[key];
12
+ // Trim quotes/whitespace from .env files (`sk-..."\n` -> 401 otherwise).
13
+ const raw = String(process.env[key]);
14
+ const trimmed = raw.trim().replace(/^["']|["']$/g, "").trim();
15
+ if (trimmed) return trimmed;
13
16
  }
14
17
  return fallback;
15
18
  }
@@ -22,20 +25,31 @@ export type ProviderEnv = Record<string, string>;
22
25
  * @example `const key = getApiKey("google");`
23
26
  */
24
27
  export function getApiKey(provider: string, explicitKey?: string, env?: ProviderEnv): string | undefined {
25
- if (explicitKey) return explicitKey;
28
+ const clean = (v: unknown): string | undefined => {
29
+ if (typeof v !== "string") return undefined;
30
+ const t = v.trim().replace(/^["']|["']$/g, "").trim();
31
+ return t || undefined;
32
+ };
33
+ const cleanedExplicit = clean(explicitKey);
34
+ if (cleanedExplicit) return cleanedExplicit;
26
35
  // ProviderEnv override takes precedence (agent-accel inspiration, SDK-light)
27
36
  if (env) {
28
37
  const upper = `${provider.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_API_KEY`;
29
- if (env[upper]) return env[upper];
38
+ const fromEnv = clean(env[upper]);
39
+ if (fromEnv) return fromEnv;
30
40
  // google aliases
31
41
  if (provider.toLowerCase().startsWith("google") || provider.toLowerCase() === "gemini") {
32
- if (env["GEMINI_API_KEY"]) return env["GEMINI_API_KEY"];
33
- if (env["GOOGLE_API_KEY"]) return env["GOOGLE_API_KEY"];
42
+ const g1 = clean(env["GEMINI_API_KEY"]);
43
+ if (g1) return g1;
44
+ const g2 = clean(env["GOOGLE_API_KEY"]);
45
+ if (g2) return g2;
34
46
  }
35
47
  // openai aliases
36
48
  if (provider.toLowerCase() === "openai") {
37
- if (env["OPENAI_BASE_API_KEY"]) return env["OPENAI_BASE_API_KEY"];
38
- if (env["OPENAI_API_KEY"]) return env["OPENAI_API_KEY"];
49
+ const o1 = clean(env["OPENAI_BASE_API_KEY"]);
50
+ if (o1) return o1;
51
+ const o2 = clean(env["OPENAI_API_KEY"]);
52
+ if (o2) return o2;
39
53
  }
40
54
  }
41
55
 
@@ -54,7 +54,18 @@ export function toConciseProviderError(error: unknown, providerId: string, model
54
54
  const err = error as any;
55
55
  const status = readStatus(err);
56
56
  const url = readUrl(err);
57
- const detail = readProviderMessage(err);
57
+ let detail = readProviderMessage(err);
58
+ // Append OpenRouter-style raw metadata once (pi api/openai-completions.ts:719
59
+ // rule): normalize already stringifies error.error, so only append raw when
60
+ // absent to avoid double-printing.
61
+ const rawMeta =
62
+ (typeof err?.raw === "string" && err.raw.trim() ? err.raw.trim() : undefined) ??
63
+ (typeof err?.error?.metadata?.raw === "string" && err.error.metadata.raw.trim()
64
+ ? err.error.metadata.raw.trim()
65
+ : undefined);
66
+ if (rawMeta && !detail.includes(rawMeta.slice(0, 80))) {
67
+ detail = `${detail}\n${rawMeta.slice(0, 500)}`.slice(0, 800);
68
+ }
58
69
  const concise = new Error(
59
70
  `[${displayModel(providerId, modelId)}] request failed${status ? ` (${status})` : ""}: ${detail}`
60
71
  );
@@ -75,6 +86,20 @@ export function toConciseProviderError(error: unknown, providerId: string, model
75
86
  ...(typeof err?.errorType === "string"
76
87
  ? { errorType: { value: err.errorType, enumerable: false } }
77
88
  : {}),
89
+ // Forwarded when present (e.g. Google's `not_found` chain-eviction code,
90
+ // which carries no HTTP status on SSE error frames). Survives re-wraps
91
+ // so chain-expiry detectors never lose it.
92
+ ...((typeof err?.code === "string" || typeof err?.code === "number") && err.code !== ""
93
+ ? { code: { value: err.code, enumerable: false } }
94
+ : {}),
95
+ // Server rate-limit hint in ms (from Retry-After); survives re-wraps so
96
+ // `withRetries` can honor it instead of hammering a 429.
97
+ ...(typeof err?.retryAfterMs === "number" && Number.isFinite(err.retryAfterMs)
98
+ ? { retryAfterMs: { value: err.retryAfterMs, enumerable: false } }
99
+ : {}),
100
+ ...(typeof (err as { raw?: unknown })?.raw === "string"
101
+ ? { raw: { value: (err as { raw: string }).raw.slice(0, 500), enumerable: false } }
102
+ : {}),
78
103
  });
79
104
  return concise;
80
105
  }
@@ -24,7 +24,7 @@ export function getPackageVersion(): string {
24
24
  const v = (globalThis as any).process?.env?.npm_package_version;
25
25
  if (typeof v === "string" && v) return v;
26
26
  } catch {}
27
- return "0.4.0";
27
+ return "0.5.0";
28
28
  }
29
29
 
30
30
  function getAgentAccelUserAgent(): string {
@@ -55,9 +55,9 @@ export interface AttributionConfig {
55
55
  let attribution: AttributionConfig = {};
56
56
 
57
57
  /**
58
- * Configures global attribution headers. Defaults to none (no hardcoded
59
- * site); OpenRouter keeps its legacy `sashvat.com` / `Agent Accelerator`
60
- * fallback when no custom attribution is set (backward compat).
58
+ * Configures global attribution headers (OpenRouter `HTTP-Referer` / `X-Title`).
59
+ * Defaults to none: nothing is sent unless you call this, so your traffic is
60
+ * never credited to a site or app you did not choose.
61
61
  * @example `setAttribution({ referer: "https://example.com", title: "My App" });`
62
62
  */
63
63
  export function setAttribution(cfg: AttributionConfig): void {
@@ -151,9 +151,8 @@ function stripForBrowser(headers: Record<string, string>): Record<string, string
151
151
  /**
152
152
  * Builds provider-specific session, cache-affinity, and attribution headers.
153
153
  *
154
- * Q-17: attribution is configurable via `setAttribution()` (default none).
155
- * OpenRouter keeps its legacy `https://sashvat.com` / `Agent Accelerator`
156
- * fallback when no custom attribution is set. Custom (non-first-class)
154
+ * Q-17: attribution is configurable via `setAttribution()` (default none; no
155
+ * referer/title is ever sent unless you set one). Custom (non-first-class)
157
156
  * prefixes receive minimal `x-session-id`/`x-client-request-id` affinity only
158
157
  * (no `session_id` alias, no attribution) when a session id is present.
159
158
  * `customHeaders` merge case-insensitively against canonical names.
@@ -193,14 +192,18 @@ export function buildSessionHeaders(
193
192
  return key !== undefined ? headers[key] : undefined;
194
193
  };
195
194
 
195
+ // OpenRouter app attribution is strictly opt-in (`setAttribution`): by default
196
+ // a user's traffic is not credited to any third-party app or site.
197
+ const applyOpenRouterAttribution = (): void => {
198
+ if (attr.referer !== undefined && getHeader("HTTP-Referer") === undefined) headers["HTTP-Referer"] = attr.referer;
199
+ if (attr.title !== undefined && getHeader("X-Title") === undefined) headers["X-Title"] = attr.title;
200
+ };
201
+
196
202
  if (sessionId && !browser) {
197
203
  if (provider === "openrouter") {
198
204
  setHeaderCaseInsensitive(headers, "x-session-id", sessionId);
199
205
  setHeaderCaseInsensitive(headers, "x-client-request-id", sessionId);
200
- const referer = attr.referer ?? "https://sashvat.com";
201
- const title = attr.title ?? "Agent Accelerator";
202
- if (getHeader("HTTP-Referer") === undefined) headers["HTTP-Referer"] = referer;
203
- if (getHeader("X-Title") === undefined) headers["X-Title"] = title;
206
+ applyOpenRouterAttribution();
204
207
  } else if (provider === "google") {
205
208
  if (getHeader("x-goog-api-client") === undefined) {
206
209
  headers["x-goog-api-client"] = "agent-accel/1.0";
@@ -219,10 +222,7 @@ export function buildSessionHeaders(
219
222
  setHeaderCaseInsensitive(headers, "x-client-request-id", sessionId);
220
223
  }
221
224
  } else if (provider === "openrouter") {
222
- const referer = attr.referer ?? "https://sashvat.com";
223
- const title = attr.title ?? "Agent Accelerator";
224
- if (getHeader("HTTP-Referer") === undefined) headers["HTTP-Referer"] = referer;
225
- if (getHeader("X-Title") === undefined) headers["X-Title"] = title;
225
+ applyOpenRouterAttribution();
226
226
  }
227
227
 
228
228
  if (provider === "google" && !browser && getHeader("x-goog-api-client") === undefined) {
@@ -0,0 +1,30 @@
1
+ import * as path from "node:path";
2
+ import { getEnv } from "./env.ts";
3
+
4
+ /**
5
+ * The Agent Accelerator home: ONE folder that holds everything the SDK stores
6
+ * (`sessions/`, `scheduled_actions/`, `telemetry/`, `catalog/`), so nothing is
7
+ * scattered across the project or the package.
8
+ */
9
+ let defaultHome: string | undefined;
10
+ const listeners = new Set<() => void>();
11
+
12
+ /**
13
+ * Sets where the home lives for every Agent created afterwards, so nothing lands
14
+ * in the working directory unless you want it there. Call once at startup;
15
+ * `setHome()` clears it.
16
+ */
17
+ export function setHome(dir?: string): void {
18
+ defaultHome = dir ? path.resolve(dir) : undefined;
19
+ for (const fn of listeners) fn();
20
+ }
21
+
22
+ /** The home folder: explicit `home`, else `setHome()`, else `AGENT_ACCEL_HOME`, else `.agent-accel/` in the working directory. */
23
+ export function resolveHome(home?: string): string {
24
+ return path.resolve(home || defaultHome || getEnv("AGENT_ACCEL_HOME") || ".agent-accel");
25
+ }
26
+
27
+ /** Runs `fn` whenever `setHome` changes the default (the catalog cache reloads from the new place). */
28
+ export function onHomeChange(fn: () => void): void {
29
+ listeners.add(fn);
30
+ }