agent-accelerator 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,577 +0,0 @@
1
- import { z } from "zod";
2
- import { tool } from "../tools/tool.ts";
3
- import type { ToolDefinition } from "../types/tool.ts";
4
- import type { Message } from "../types/message.ts";
5
- import { normalizeMediaInput } from "./media.ts";
6
- import { base64ToBytes } from "./base64.ts";
7
- import { escapeXml } from "./serialization.ts";
8
- import { getModelFromCatalog } from "../models/catalog.ts";
9
- import { assertPathAllowed, fetchRemoteBytes, RemoteFetchError } from "./safe-io.ts";
10
-
11
- /**
12
- * Client-side document conversion (anydoc engine).
13
- *
14
- * Converts PDF, Word, PowerPoint, Excel, OpenDocument, RTF, EPUB, and CSV
15
- * inputs into Markdown text, so models WITHOUT native document parsing can
16
- * still read them. Models WITH native support should keep using it — this is
17
- * an explicit opt-in escape hatch, never an automatic fallback.
18
- *
19
- * The `@firecrawl/anydoc` package is an OPTIONAL peer dependency and is only
20
- * ever loaded via dynamic import, so installs without it (and browser bundles)
21
- * are unaffected. Importing this module alone never touches native code.
22
- */
23
-
24
- /** Default cap for converted Markdown length (chars) — bounds context usage. */
25
- export const DEFAULT_DOCUMENT_MAX_CHARS = 100_000;
26
-
27
- /** Maximum bytes fetched from a remote document URL. */
28
- export const MAX_DOCUMENT_FETCH_BYTES = 50_000_000;
29
-
30
- const EXTENSION_TO_FORMAT: Record<string, string> = {
31
- pdf: "pdf",
32
- doc: "doc",
33
- docx: "docx",
34
- docm: "docm",
35
- ppt: "ppt",
36
- pps: "pps",
37
- pot: "pot",
38
- pptx: "pptx",
39
- pptm: "pptm",
40
- ppsx: "ppsx",
41
- ppsm: "ppsm",
42
- xls: "xls",
43
- xlsx: "xlsx",
44
- xlsm: "xlsm",
45
- xlsb: "xlsb",
46
- odt: "odt",
47
- ods: "ods",
48
- odp: "odp",
49
- rtf: "rtf",
50
- epub: "epub",
51
- csv: "csv",
52
- };
53
-
54
- const MIME_TO_FORMAT: Record<string, string> = {
55
- "application/pdf": "pdf",
56
- "text/csv": "csv",
57
- "application/csv": "csv",
58
- "application/msword": "doc",
59
- "application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx",
60
- "application/vnd.ms-excel": "xls",
61
- "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "xlsx",
62
- "application/vnd.ms-powerpoint": "ppt",
63
- "application/vnd.openxmlformats-officedocument.presentationml.presentation": "pptx",
64
- "application/rtf": "rtf",
65
- "text/rtf": "rtf",
66
- "application/epub+zip": "epub",
67
- "application/vnd.oasis.opendocument.text": "odt",
68
- "application/vnd.oasis.opendocument.spreadsheet": "ods",
69
- "application/vnd.oasis.opendocument.presentation": "odp",
70
- };
71
-
72
- /** Thrown when a document cannot be converted (anydoc codes pass through). */
73
- export class DocumentConversionError extends Error {
74
- readonly code: string;
75
- readonly file?: string;
76
-
77
- /**
78
- * Creates a conversion failure carrying the engine's error code.
79
- *
80
- * @param code anydoc `ConvertErrorCode` (`needsOcr`, `encrypted`, …) or
81
- * `missing_dependency` / `io` / `conversion_failed` for wrapper-level faults.
82
- * @param message Human-readable one-liner (already prefixed).
83
- * @param file Optional file label (path, URL, or display name).
84
- */
85
- constructor(code: string, message: string, file?: string) {
86
- super(message);
87
- this.name = "DocumentConversionError";
88
- this.code = code;
89
- if (file !== undefined) this.file = file;
90
- if (Error.captureStackTrace) {
91
- Error.captureStackTrace(this, DocumentConversionError);
92
- }
93
- }
94
- }
95
-
96
- /** Options accepted by {@link convertDocumentToMarkdown} / {@link convertDocumentInput}. */
97
- export interface ConvertDocumentOptions {
98
- /** Display name / format hint (e.g. `"data.csv"`). Required for extension-less bytes. */
99
- filename?: string;
100
- /** Explicit format override (`"pdf" | "docx" | "xlsx" | "csv" | …`). Wins over inference. */
101
- format?: string;
102
- /** MIME hint used when no filename is available. */
103
- mimeType?: string;
104
- /** Maximum Markdown chars returned (default {@link DEFAULT_DOCUMENT_MAX_CHARS}). */
105
- maxChars?: number;
106
- /** Abort signal honored by the remote-fetch leg. */
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;
118
- }
119
-
120
- /** Normalized conversion result (pre-XML-envelope). */
121
- export interface ConvertedDocument {
122
- markdown: string;
123
- name: string;
124
- format: string;
125
- truncated: boolean;
126
- }
127
-
128
- function missingDependencyError(): DocumentConversionError {
129
- return new DocumentConversionError(
130
- "missing_dependency",
131
- "[Agent Accelerator] Document conversion needs the optional peer '@firecrawl/anydoc'. Install it with: bun add @firecrawl/anydoc"
132
- );
133
- }
134
-
135
- type AnyDocModule = typeof import("@firecrawl/anydoc");
136
-
137
- let anydocModule: AnyDocModule | undefined;
138
- let anydocMissing = false;
139
-
140
- /** Loads the optional anydoc engine (dynamic import only — never bundled). */
141
- async function loadAnydoc(): Promise<AnyDocModule> {
142
- if (anydocModule) return anydocModule;
143
- if (anydocMissing) throw missingDependencyError();
144
- try {
145
- anydocModule = await import("@firecrawl/anydoc");
146
- return anydocModule;
147
- } catch {
148
- anydocMissing = true;
149
- throw missingDependencyError();
150
- }
151
- }
152
-
153
- /**
154
- * True when the optional conversion engine can be loaded.
155
- *
156
- * @example `if (await isAnydocAvailable()) { … }`
157
- */
158
- export async function isAnydocAvailable(): Promise<boolean> {
159
- if (anydocModule) return true;
160
- try {
161
- await import("@firecrawl/anydoc");
162
- return true;
163
- } catch {
164
- return false;
165
- }
166
- }
167
-
168
- /**
169
- * Resolves an anydoc format name from explicit, filename, or MIME hints.
170
- *
171
- * @example `resolveDocumentFormat({ filename: "data.csv" }) // "csv"`
172
- */
173
- export function resolveDocumentFormat(opts: {
174
- filename?: string;
175
- format?: string;
176
- mimeType?: string;
177
- }): string | undefined {
178
- if (opts.format && opts.format.trim()) return opts.format.trim().toLowerCase();
179
- if (opts.filename) {
180
- const clean = opts.filename.split("?")[0]!.split("#")[0]!;
181
- const ext = clean.split(".").pop()?.toLowerCase();
182
- if (ext) {
183
- const mapped = EXTENSION_TO_FORMAT[ext];
184
- if (mapped) return mapped;
185
- }
186
- }
187
- if (opts.mimeType) {
188
- const mime = opts.mimeType.split(";")[0]!.trim().toLowerCase();
189
- const mapped = MIME_TO_FORMAT[mime];
190
- if (mapped) return mapped;
191
- }
192
- return undefined;
193
- }
194
-
195
- /**
196
- * Truncates converted Markdown to a char budget with a visible marker.
197
- *
198
- * @example `truncateMarkdown(md, 1000)`
199
- */
200
- export function truncateMarkdown(markdown: string, maxChars?: number): { text: string; truncated: boolean } {
201
- const limit = maxChars && maxChars > 0 ? Math.floor(maxChars) : DEFAULT_DOCUMENT_MAX_CHARS;
202
- if (markdown.length <= limit) return { text: markdown, truncated: false };
203
- return {
204
- text:
205
- `${markdown.slice(0, limit)}\n\n[Truncated: showing ${limit} of ${markdown.length} chars. Pass a larger maxChars to read more.]`,
206
- truncated: true,
207
- };
208
- }
209
-
210
- /**
211
- * Wraps converted Markdown in the canonical `<Document>` envelope.
212
- *
213
- * @example `buildDocumentXml("report.pdf", "./report.pdf", md)`
214
- */
215
- export function buildDocumentXml(name: string, destination: string, markdown: string): string {
216
- return `<Document name="${escapeXml(name)}" destination="${escapeXml(destination)}">\n${escapeXml(markdown)}\n</Document>`;
217
- }
218
-
219
- /** Wire format accepted by `toMarkdownBytes` (the engine's `Format` enum, by query). */
220
- type AnyDocWireFormat = Parameters<AnyDocModule["toMarkdownBytes"]>[1];
221
-
222
- /**
223
- * Canonicalizes an inferred format name through the engine
224
- * (`pptm` → `pptx`, `.csv` → `csv`). Unknown names pass through untouched —
225
- * the engine validates and rejects with a coded error.
226
- */
227
- function canonicalizeFormat(
228
- anydoc: AnyDocModule,
229
- name: string | undefined
230
- ): Exclude<AnyDocWireFormat, undefined> {
231
- if (!name) return null;
232
- try {
233
- const canonical: unknown = anydoc.formatFromExtension(name.startsWith(".") ? name : `.${name}`);
234
- if (typeof canonical === "string" && canonical) {
235
- return canonical as Exclude<AnyDocWireFormat, undefined>;
236
- }
237
- } catch {
238
- // fall through to the raw name
239
- }
240
- return name as unknown as Exclude<AnyDocWireFormat, undefined>;
241
- }
242
-
243
- function isHttpUrl(value: string): boolean {
244
- return value.startsWith("http://") || value.startsWith("https://");
245
- }
246
-
247
- function isDataUrl(value: string): boolean {
248
- return value.startsWith("data:");
249
- }
250
-
251
- /** True for plausible local paths (excludes URLs, data URLs, and raw base64). */
252
- function looksLikePath(value: string): boolean {
253
- if (!value || value.length > 4096) return false;
254
- if (isHttpUrl(value) || isDataUrl(value)) return false;
255
- if (value.includes("\n") || value.includes("\0")) return false;
256
- if (/^[A-Za-z0-9+/=\s]+$/.test(value) && value.length > 100 && value.length % 4 === 0) return false;
257
- return true;
258
- }
259
-
260
- function basenameOf(value: string): string {
261
- const clean = value.split("?")[0]!.split("#")[0]!;
262
- const parts = clean.split(/[\\/]/);
263
- return parts.pop() || clean;
264
- }
265
-
266
- function toDocumentConversionError(err: unknown, file?: string): DocumentConversionError {
267
- if (err instanceof DocumentConversionError) return err;
268
- const code = (err as { code?: unknown })?.code;
269
- const detail = err instanceof Error ? err.message : String(err);
270
- const where = file ? ` "${file}"` : "";
271
- if (typeof code === "string" && code) {
272
- const hints: Record<string, string> = {
273
- needsOcr: " The document has scanned/image-only pages, which need OCR (not enabled).",
274
- encrypted: " The document is password-protected.",
275
- unsupported:
276
- " Pass an explicit format (e.g. format: \"csv\") or filename so the format can be determined.",
277
- };
278
- const hint = hints[code] ?? "";
279
- return new DocumentConversionError(
280
- code,
281
- `[Agent Accelerator] Could not convert document${where} (${code}): ${detail}.${hint}`,
282
- file
283
- );
284
- }
285
- return new DocumentConversionError(
286
- "conversion_failed",
287
- `[Agent Accelerator] Could not convert document${where}: ${detail}.`,
288
- file
289
- );
290
- }
291
-
292
- /**
293
- * Converts a document destination to normalized Markdown + metadata.
294
- *
295
- * Accepts a local file path (read by the engine directly), an http(s) URL
296
- * (fetched first, capped at {@link MAX_DOCUMENT_FETCH_BYTES}), a data URL,
297
- * raw base64, or binary bytes. Throws {@link DocumentConversionError} — the
298
- * built-in tool converts these into model-readable `Error: …` strings instead.
299
- */
300
- export async function convertDocumentInput(
301
- input: string | Uint8Array | ArrayBuffer,
302
- opts: ConvertDocumentOptions = {}
303
- ): Promise<ConvertedDocument> {
304
- const anydoc = await loadAnydoc();
305
- const filenameOpt = opts.filename?.trim() || undefined;
306
-
307
- try {
308
- // Remote URL leg: fetch bytes first (anydoc reads files, not URLs).
309
- if (typeof input === "string" && isHttpUrl(input)) {
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
- }
327
- if (!res.ok) {
328
- throw new DocumentConversionError(
329
- "io",
330
- `[Agent Accelerator] Could not download document "${input}" (HTTP ${res.status} ${res.statusText}).`,
331
- input
332
- );
333
- }
334
- const buf = res.bytes;
335
- const name = filenameOpt || basenameOf(input);
336
- const inferred = resolveDocumentFormat({
337
- filename: name,
338
- format: opts.format,
339
- mimeType: res.contentType || opts.mimeType,
340
- });
341
- let wire = canonicalizeFormat(anydoc, inferred);
342
- if (!wire) {
343
- try {
344
- wire = anydoc.formatFromBytes(buf);
345
- } catch {
346
- wire = null;
347
- }
348
- }
349
- if (!wire) {
350
- throw new DocumentConversionError(
351
- "unsupported",
352
- `[Agent Accelerator] Could not determine the format of "${input}". Pass filename (e.g. "data.csv") or format explicitly.`,
353
- input
354
- );
355
- }
356
- const markdown = await anydoc.toMarkdownBytes(buf, wire);
357
- const { text, truncated } = truncateMarkdown(markdown, opts.maxChars);
358
- return { markdown: text, name, format: String(wire), truncated };
359
- }
360
-
361
- // Local path leg: the engine reads + detects from the path itself.
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
- }
374
- const name = filenameOpt || basenameOf(input);
375
- const markdown = await anydoc.toMarkdown(input);
376
- const inferred = resolveDocumentFormat({ filename: name, format: opts.format });
377
- const format = String(canonicalizeFormat(anydoc, inferred) ?? inferred ?? "unknown");
378
- const { text, truncated } = truncateMarkdown(markdown, opts.maxChars);
379
- return { markdown: text, name, format, truncated };
380
- }
381
-
382
- // Bytes / data URL / base64 leg: normalize first, format must resolve.
383
- const normalized = await normalizeMediaInput(input, opts.mimeType);
384
- const name = filenameOpt || "document";
385
- const inferred = resolveDocumentFormat({
386
- filename: opts.filename,
387
- format: opts.format,
388
- mimeType: normalized.mimeType,
389
- });
390
- const bytes = base64ToBytes(normalized.base64Data);
391
- let wire = canonicalizeFormat(anydoc, inferred);
392
- if (!wire) {
393
- try {
394
- wire = anydoc.formatFromBytes(bytes);
395
- } catch {
396
- wire = null;
397
- }
398
- }
399
- if (!wire) {
400
- throw new DocumentConversionError(
401
- "unsupported",
402
- `[Agent Accelerator] Could not determine the document format. Pass filename (e.g. "data.csv") or format explicitly.`,
403
- name
404
- );
405
- }
406
- const markdown = await anydoc.toMarkdownBytes(bytes, wire);
407
- const { text, truncated } = truncateMarkdown(markdown, opts.maxChars);
408
- return { markdown: text, name, format: String(wire), truncated };
409
- } catch (err) {
410
- const label =
411
- typeof input === "string" && (isHttpUrl(input) || (looksLikePath(input) && input.length < 1024))
412
- ? input
413
- : (opts.filename ?? "document");
414
- throw toDocumentConversionError(err, label);
415
- }
416
- }
417
-
418
- /**
419
- * Converts a document to Markdown text. Explicit dev-facing primitive —
420
- * throws {@link DocumentConversionError} on failure.
421
- *
422
- * @example `const md = await convertDocumentToMarkdown("./report.xlsx");`
423
- */
424
- export async function convertDocumentToMarkdown(
425
- input: string | Uint8Array | ArrayBuffer,
426
- opts: ConvertDocumentOptions = {}
427
- ): Promise<string> {
428
- const doc = await convertDocumentInput(input, opts);
429
- return doc.markdown;
430
- }
431
-
432
- /**
433
- * True when the model can receive `file` parts natively (catalog `pdf` input).
434
- * Unknown models count as capable — the provider verdict stands, matching
435
- * `assertModalitiesSupported`.
436
- *
437
- * @example `modelSupportsFileInput("google", "gemini-3.5-flash-lite")`
438
- */
439
- export function modelSupportsFileInput(providerId: string, modelId: string): boolean {
440
- try {
441
- const spec = getModelFromCatalog(providerId, modelId);
442
- if (!spec) return true;
443
- return spec.modalities?.input?.includes("pdf") ?? true;
444
- } catch {
445
- return true;
446
- }
447
- }
448
-
449
- /**
450
- * Implements `bypassInputFileModality`: rewrites `file` parts to `<Document>`
451
- * Markdown text when the model lacks native support. Idempotent (converted
452
- * parts are plain text afterwards) and persistent in history for prefix-cache
453
- * stability. Conversion failures degrade to a readable placeholder so the run
454
- * continues.
455
- */
456
- export async function preprocessFilePartsForBypass(
457
- messages: Message[],
458
- opts: { providerId: string; modelId: string; signal?: AbortSignal; maxChars?: number; roots?: readonly string[] }
459
- ): Promise<void> {
460
- if (modelSupportsFileInput(opts.providerId, opts.modelId)) return;
461
- for (const m of messages) {
462
- if (!Array.isArray(m.content)) continue;
463
- for (let i = 0; i < m.content.length; i++) {
464
- const part = m.content[i];
465
- if (!part || (part as { type?: unknown }).type !== "file") continue;
466
- const raw = (part as { file?: unknown; filename?: unknown; mimeType?: unknown }).file as
467
- | string
468
- | Uint8Array
469
- | ArrayBuffer;
470
- const filename =
471
- typeof (part as { filename?: unknown }).filename === "string"
472
- ? ((part as { filename?: string }).filename as string)
473
- : undefined;
474
- const mimeType =
475
- typeof (part as { mimeType?: unknown }).mimeType === "string"
476
- ? ((part as { mimeType?: string }).mimeType as string)
477
- : undefined;
478
- const display = filename || "document";
479
- try {
480
- const doc = await convertDocumentInput(raw, {
481
- filename,
482
- mimeType,
483
- maxChars: opts.maxChars,
484
- signal: opts.signal,
485
- roots: opts.roots,
486
- });
487
- const destination =
488
- filename ||
489
- (typeof raw === "string" && (isHttpUrl(raw) || (looksLikePath(raw) && raw.length < 1024))
490
- ? raw
491
- : doc.name);
492
- m.content[i] = { type: "text", text: buildDocumentXml(doc.name, destination, doc.markdown) };
493
- } catch (err) {
494
- const reason = err instanceof Error ? err.message : String(err);
495
- m.content[i] = { type: "text", text: `[Document "${display}" could not be read: ${reason}]` };
496
- }
497
- }
498
- }
499
- }
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
-
566
- /**
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.
570
- *
571
- * @example
572
- * ```ts
573
- * import { convert_document_to_markdown } from "agent-accelerator";
574
- * const agent = new Agent({ model, tools: { convert_document_to_markdown } });
575
- * ```
576
- */
577
- export const convert_document_to_markdown: ToolDefinition = createConvertDocumentTool();