From 8e0776c02982867b1f4563eeca4d028d5325e20e Mon Sep 17 00:00:00 2001 From: YaseenHQ Date: Thu, 13 Aug 2026 16:36:32 -0400 Subject: [PATCH 1/7] feat(tools): add ReadDocument for Word, PDF, Excel and friends Read returns raw bytes for these formats, which the model cannot use. ReadDocument converts them to Markdown locally through @firecrawl/anydoc (MIT, Rust with prebuilt binaries) and reuses Read's workspace path resolution, so it cannot reach outside the workspace. The import is lazy and its failure is cached and reported as a tool error: there is no prebuild for Windows on ARM, and a missing optional platform package must degrade to a clear message rather than break the agent. ReadDocument is v2-only, so the v1 parity projection filters it out. --- .changeset/read-document.md | 5 + apps/kimi-code/package.json | 3 + packages/agent-core-v2/package.json | 1 + .../tools/os/readDocument/read-document.md | 5 + .../tools/os/readDocument/readDocument.ts | 42 ++++++ .../tools/os/readDocument/readDocumentTool.ts | 123 ++++++++++++++++++ packages/agent-core-v2/src/index.ts | 2 + .../agentLifecycle/profile/profiles.ts | 3 + .../test/agent/loop/loop.test.ts | 4 +- .../sessionAgentProfileCatalog.test.ts | 1 + .../test/tool/readDocument.test.ts | 40 ++++++ packages/agent-core-v2/test/tool/tool.test.ts | 14 +- .../agent-core-v2/test/wire/resume.test.ts | 2 +- packages/node-sdk/test/v1-v2-parity.test.ts | 4 +- pnpm-lock.yaml | 91 ++++++++++++- 15 files changed, 328 insertions(+), 12 deletions(-) create mode 100644 .changeset/read-document.md create mode 100644 packages/agent-core-v2/src/agent/tools/os/readDocument/read-document.md create mode 100644 packages/agent-core-v2/src/agent/tools/os/readDocument/readDocument.ts create mode 100644 packages/agent-core-v2/src/agent/tools/os/readDocument/readDocumentTool.ts create mode 100644 packages/agent-core-v2/test/tool/readDocument.test.ts diff --git a/.changeset/read-document.md b/.changeset/read-document.md new file mode 100644 index 00000000000..5adaea0edad --- /dev/null +++ b/.changeset/read-document.md @@ -0,0 +1,5 @@ +--- +"echadron": minor +--- + +Added a `ReadDocument` tool. Reads Word, PowerPoint, Excel, OpenDocument, RTF, EPUB, CSV and PDF files as Markdown, so the agent can work with documents instead of getting raw bytes back from `Read`. Conversion runs locally through `@firecrawl/anydoc`; nothing is uploaded. Platforms without a prebuilt binary report that the file cannot be read rather than failing. diff --git a/apps/kimi-code/package.json b/apps/kimi-code/package.json index 49448a92e72..ff53974b5a0 100644 --- a/apps/kimi-code/package.json +++ b/apps/kimi-code/package.json @@ -111,5 +111,8 @@ }, "engines": { "node": ">=22.19.0" + }, + "dependencies": { + "@firecrawl/anydoc": "^0.1.8" } } diff --git a/packages/agent-core-v2/package.json b/packages/agent-core-v2/package.json index f0c415e5e80..7084d510464 100644 --- a/packages/agent-core-v2/package.json +++ b/packages/agent-core-v2/package.json @@ -60,6 +60,7 @@ "dependencies": { "@antfu/utils": "^9.3.0", "@anthropic-ai/sdk": "^0.95.2", + "@firecrawl/anydoc": "^0.1.8", "@google/genai": "^1.49.0", "@jsquash/webp": "^1.5.0", "@modelcontextprotocol/client": "2.0.0", diff --git a/packages/agent-core-v2/src/agent/tools/os/readDocument/read-document.md b/packages/agent-core-v2/src/agent/tools/os/readDocument/read-document.md new file mode 100644 index 00000000000..7b2b326b979 --- /dev/null +++ b/packages/agent-core-v2/src/agent/tools/os/readDocument/read-document.md @@ -0,0 +1,5 @@ +Read a Word, PowerPoint, Excel, OpenDocument, RTF, EPUB, CSV, or PDF file as Markdown. + +Use this instead of `Read` for those formats — `Read` returns their raw bytes, which are unusable. Everything else (source code, plain text, JSON, Markdown) still goes through `Read`. + +Conversion is local; nothing is uploaded. Large documents are truncated to fit the message, keeping the beginning. diff --git a/packages/agent-core-v2/src/agent/tools/os/readDocument/readDocument.ts b/packages/agent-core-v2/src/agent/tools/os/readDocument/readDocument.ts new file mode 100644 index 00000000000..2bda4f2268b --- /dev/null +++ b/packages/agent-core-v2/src/agent/tools/os/readDocument/readDocument.ts @@ -0,0 +1,42 @@ +/** + * `tools` domain (L7) — `ReadDocument` contract. + * + * `Read` returns bytes, which is useless for a Word file or a PDF. This + * converts those formats to Markdown so the model can actually read them. + */ + +import { z } from 'zod'; + +import { createDecorator } from '#/_base/di/instantiation'; +import { type AgentTool } from '#/tool/toolContract'; + +/** Extensions anydoc recognises; used to advertise support without probing. */ +export const READ_DOCUMENT_EXTENSIONS = [ + 'pdf', + 'docx', + 'doc', + 'pptx', + 'ppt', + 'xlsx', + 'xls', + 'odt', + 'odp', + 'ods', + 'rtf', + 'epub', + 'csv', +] as const; + +export const ReadDocumentInputSchema = z.object({ + path: z + .string() + .trim() + .min(1) + .describe('Path to the document. Relative paths resolve against the working directory.'), +}); + +export type ReadDocumentInput = z.infer; + +export interface IReadDocumentTool extends AgentTool {} + +export const IReadDocumentTool = createDecorator('readDocumentTool'); diff --git a/packages/agent-core-v2/src/agent/tools/os/readDocument/readDocumentTool.ts b/packages/agent-core-v2/src/agent/tools/os/readDocument/readDocumentTool.ts new file mode 100644 index 00000000000..f42c48bdd0b --- /dev/null +++ b/packages/agent-core-v2/src/agent/tools/os/readDocument/readDocumentTool.ts @@ -0,0 +1,123 @@ +/** + * `tools` domain (L7) — `ReadDocument` implementation. + * + * Converts document formats to Markdown through `@firecrawl/anydoc`, a local + * Rust converter with prebuilt binaries. The import is lazy and failure is + * reported as a tool error rather than thrown: no prebuild exists for Windows + * on ARM, and a missing optional platform package must degrade to a clear + * message instead of breaking the agent. + * + * Path access goes through the same workspace resolution as `Read`, so this + * cannot reach outside the workspace. Bound at Agent scope. + */ + +import { registerAgentToolService } from '#/agent/toolRegistry/toolContribution'; +import { literalRulePattern } from '#/tool/rule-match'; +import { IHostEnvironment } from '#/os/interface/hostEnvironment'; +import { resolvePathAccessPath } from '#/tool/path-access'; +import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; +import { ToolResultBuilder } from '#/tool/result-builder'; +import { toInputJsonSchema } from '#/tool/input-schema'; +import { ToolAccesses, type ToolExecution } from '#/tool/toolContract'; + +import DESCRIPTION from './read-document.md?raw'; +import { + IReadDocumentTool, + ReadDocumentInputSchema, + READ_DOCUMENT_EXTENSIONS, + type ReadDocumentInput, +} from './readDocument'; + +type AnydocModule = { + toMarkdown: (path: string) => Promise; + formatFromPath: (path: string) => string | null; +}; + +let anydoc: Promise | undefined; + +/** Loaded once and cached, including the failure, so a missing binary is not retried per call. */ +async function loadAnydoc(): Promise { + anydoc ??= import('@firecrawl/anydoc') + .then((mod) => mod as unknown as AnydocModule) + .catch(() => undefined); + return anydoc; +} + +export function extensionOf(path: string): string { + const base = path.slice(path.lastIndexOf('/') + 1); + const dot = base.lastIndexOf('.'); + return dot <= 0 ? '' : base.slice(dot + 1).toLowerCase(); +} + +export function isSupportedDocument(path: string): boolean { + return (READ_DOCUMENT_EXTENSIONS as readonly string[]).includes(extensionOf(path)); +} + +export class ReadDocumentTool implements IReadDocumentTool { + declare readonly _serviceBrand: undefined; + readonly name = 'ReadDocument' as const; + readonly description = DESCRIPTION; + readonly parameters: Record = toInputJsonSchema(ReadDocumentInputSchema); + + constructor( + @IHostEnvironment private readonly env: IHostEnvironment, + @ISessionWorkspaceContext private readonly workspaceCtx: ISessionWorkspaceContext, + ) {} + + resolveExecution(args: ReadDocumentInput): ToolExecution { + const path = resolvePathAccessPath(args.path, { + env: this.env, + workspace: { + workspaceDir: this.workspaceCtx.workDir, + additionalDirs: this.workspaceCtx.additionalDirs, + }, + operation: 'read', + }); + + return { + accesses: ToolAccesses.readFile(path), + description: `Reading ${args.path} as Markdown`, + display: { kind: 'file_io', operation: 'read', path }, + approvalRule: literalRulePattern(this.name, path), + execute: async () => { + if (!isSupportedDocument(path)) { + return { + isError: true, + output: + `ReadDocument does not handle "${extensionOf(path) || 'this file'}". ` + + `Supported: ${READ_DOCUMENT_EXTENSIONS.join(', ')}. Use Read for text files.`, + }; + } + + const mod = await loadAnydoc(); + if (mod === undefined) { + return { + isError: true, + output: + 'Document conversion is unavailable on this platform, so this file cannot be read. ' + + 'Ask the user to convert it to Markdown or plain text.', + }; + } + + let markdown: string; + try { + markdown = await mod.toMarkdown(path); + } catch (error) { + return { + isError: true, + output: `Could not read ${args.path}: ${error instanceof Error ? error.message : String(error)}`, + }; + } + + const builder = new ToolResultBuilder(); + builder.write(markdown); + return builder.ok(''); + }, + }; + } +} + +registerAgentToolService(IReadDocumentTool, ReadDocumentTool, { + name: 'ReadDocument', + domain: 'os/backends', +}); diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index 4f80347bd5f..77d267b0994 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -50,6 +50,8 @@ import '#/agent/tools/os/glob/globTool'; export * from '#/agent/tools/os/grep/grep'; import '#/agent/tools/os/grep/grepTool'; export * from '#/agent/tools/os/read/read'; +export * from '#/agent/tools/os/readDocument/readDocument'; +export * from '#/agent/tools/os/readDocument/readDocumentTool'; import '#/agent/tools/os/read/readTool'; export * from '#/agent/tools/os/write/write'; import '#/agent/tools/os/write/writeTool'; diff --git a/packages/agent-core-v2/src/session/agentLifecycle/profile/profiles.ts b/packages/agent-core-v2/src/session/agentLifecycle/profile/profiles.ts index 8730b6f494f..4b978ecaf17 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/profile/profiles.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/profile/profiles.ts @@ -25,6 +25,7 @@ import SUMMARY_CONTINUATION_PROMPT from './summary-continuation.md?raw'; const AGENT_TOOLS = [ 'Read', + 'ReadDocument', 'Write', 'Edit', 'Grep', @@ -69,6 +70,7 @@ const CODER_TOOLS = [ 'Glob', 'Grep', 'Read', + 'ReadDocument', 'ReadMediaFile', 'Skill', 'TaskList', @@ -83,6 +85,7 @@ const CODER_TOOLS = [ const EXPLORE_TOOLS = [ 'Read', + 'ReadDocument', 'ReadMediaFile', 'Glob', 'Grep', diff --git a/packages/agent-core-v2/test/agent/loop/loop.test.ts b/packages/agent-core-v2/test/agent/loop/loop.test.ts index de1c10dc6be..804bf3ff0a0 100644 --- a/packages/agent-core-v2/test/agent/loop/loop.test.ts +++ b/packages/agent-core-v2/test/agent/loop/loop.test.ts @@ -126,8 +126,8 @@ describe('Agent loop', () => { [emit] turn.step.started { "turnId": 0, "step": 1, "stepId": "" } [emit] agent.activity.updated { "lifecycle": "ready", "turn": { "turnId": 0, "origin": { "kind": "user" }, "phase": "running", "step": 1, "ending": false, "pendingApprovals": [], "activeToolCalls": [], "since": "