From 9ed9ee6ca7b82521bbf121036001a661dc28c41e Mon Sep 17 00:00:00 2001 From: matevip Date: Mon, 27 Apr 2026 08:36:37 +0800 Subject: [PATCH] feat(tool): add renderDocxFromFile to bypass LLM token cost on large markdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit renderDocx requires the markdown body to flow through the LLM as a tool argument. For an 80 KB project proposal that's ≈ 20 K tokens of streaming output spent just to repeat back content the model already wrote to disk a turn earlier — multi-minute generation, real money. renderDocxFromFile takes a file path instead. The agent uses write_file / edit_file to assemble the markdown locally, then calls this tool with just the path. JVM reads the file in one IO syscall and feeds it to the existing MarkdownDocxRenderer. Token cost drops from ≈ 20 K to ≈ 50 (the path string). Behavior: - Path resolution honors WorkspacePathGuard, same boundary as read_file / write_file. No path traversal. - UTF-8 read; rejects empty / missing / non-regular paths with typed error messages so the agent can recover. - Output cached in GeneratedFileCache and returned as a relative /api/v1/files/generated/{id} link, with the same anti-host- hallucination instruction renderDocx already carries. - Same supported markdown subset (headings, bold, lists, tables). Image references (![alt](path)) still render as raw text — full image embedding (P1) and SVG → PNG conversion (also P1) need Apache Batik plus image-rendering plumbing in MarkdownDocxRenderer and is tracked separately. Chapter-mode merge (P2) likewise needs its own plumbing. The @Tool description tells the agent to prefer this path when markdown exceeds ~5 KB and shows the full write_file → renderDocxFromFile workflow inline. --- .../vip/mate/tool/builtin/DocxRenderTool.java | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/DocxRenderTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/DocxRenderTool.java index 734646e2..fb47a871 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/DocxRenderTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/DocxRenderTool.java @@ -7,6 +7,11 @@ import org.springframework.ai.tool.annotation.ToolParam; import org.springframework.stereotype.Component; import vip.mate.tool.document.GeneratedFileCache; import vip.mate.tool.document.MarkdownDocxRenderer; +import vip.mate.tool.guard.WorkspacePathGuard; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; /** * Render a brand-new .docx from Markdown without ever forking a process. @@ -83,6 +88,96 @@ public class DocxRenderTool { } } + /** + * File-based renderer — reads markdown from disk instead of taking it as a + * tool argument. Bypasses the LLM token-cost cliff: a 80 KB markdown body + * would otherwise be streamed through the chat completion as part of + * {@code renderDocx.markdown} args (≈ 20 K tokens, several minutes of + * generation just to repeat back content the LLM already wrote to disk). + *

+ * Workflow: agent uses {@code write_file} / {@code edit_file} to assemble + * the markdown locally → calls this tool with the file path → docx is + * rendered from disk in one IO call. Token cost ≈ 50 (just the path). + */ + @Tool(description = """ + Render a .docx file from a markdown FILE on disk and return a one-time download URL. + Use this instead of `renderDocx` when the markdown body is large (>5 KB) — the + LLM does not need to repeat its own previous output as a tool argument. + + Typical workflow: + 1. write_file(path="report.md", content="# Report\\n...") // assemble markdown + 2. renderDocxFromFile(filePath="report.md", filename="monthly-report") + 3. return the download link to the user + + The markdown file is read with UTF-8. Path resolution honors the workspace + boundary (same rules as read_file / write_file). + + Same supported markdown subset as renderDocx (headings, bold, lists, tables). + Image references (![alt](path)) are NOT yet rendered into the docx — they will + appear as raw markdown text. SVG inline embedding requires a follow-up tool. + """) + public String renderDocxFromFile( + @ToolParam(description = "Absolute or workspace-relative path to a markdown file") + String filePath, + @ToolParam(description = "Output filename without extension, e.g. 'monthly-report'") + String filename, + @ToolParam(description = "Page size: A4 or LETTER (default: A4)", required = false) + String pageSize) { + + if (filePath == null || filePath.isBlank()) { + return "Error: filePath parameter is empty."; + } + + Path resolved; + try { + resolved = WorkspacePathGuard.validatePath(filePath); + } catch (Exception e) { + return "Error: path validation failed — " + e.getMessage(); + } + if (!Files.exists(resolved)) { + return "Error: file not found at " + resolved; + } + if (!Files.isRegularFile(resolved) || !Files.isReadable(resolved)) { + return "Error: path is not a readable regular file " + resolved; + } + + String markdown; + long mdBytes; + try { + mdBytes = Files.size(resolved); + markdown = Files.readString(resolved, StandardCharsets.UTF_8); + } catch (Exception e) { + log.error("[DocxRender] read markdown failed for {}: {}", resolved, e.getMessage(), e); + return "Error: failed to read markdown — " + e.getMessage(); + } + if (markdown.isBlank()) { + return "Error: markdown file is empty " + resolved; + } + + String safeName = sanitizeFilename(filename); + String displayName = safeName + ".docx"; + String size = (pageSize == null || pageSize.isBlank()) ? "A4" : pageSize.trim(); + + try { + long t0 = System.currentTimeMillis(); + byte[] bytes = renderer.render(markdown, size); + String id = cache.put(bytes, displayName, DOCX_MIME); + long elapsed = System.currentTimeMillis() - t0; + log.info("[DocxRender] generated {} ({} bytes from {} bytes md, {}ms, id={})", + displayName, bytes.length, mdBytes, elapsed, id); + + String url = "/api/v1/files/generated/" + id; + return "Document generated: [" + displayName + "](" + url + ") (link valid for 10 minutes).\n" + + "IMPORTANT: when replying to the user you **must** use the relative path `" + + url + "` verbatim. Do **not** prepend any https://, http:// or domain — " + + "the frontend will resolve the current host automatically."; + } catch (Exception e) { + log.error("[DocxRender] render failed for {} (source: {}): {}", + displayName, resolved, e.getMessage(), e); + return "Render failed: " + e.getMessage(); + } + } + /** * Strip path separators and other unsafe characters from a user-supplied * filename. Falls back to a generic name when nothing usable remains.