Two follow-up improvements on top of renderDocxFromFile so the docx
pipeline can handle real long-form deliverables instead of just
prose-only memos.
Image embedding (P1).
MarkdownDocxRenderer now recognizes single-line  markdown
and embeds the referenced file via POI's XWPFRun.addPicture():
- PNG / JPG / GIF / BMP read straight from disk
- SVG rasterized via Apache Batik (PNGTranscoder, target width 1400px)
before embedding — OOXML stores raster images, so any vector source
needs conversion. Batik runs in-JVM, no rsvg-convert / cairo on host.
- Pictures are pinned to roughly the printable page width (≈ 5.77 in
for A4 minus default 1800-twip margins) and given a 4:3 height
fallback. Mixing images inline with other paragraph text is not
supported by design — the markdown subset assumes one image per
block paragraph. Inline images would require splitting paragraphs
across runs with explicit positioning, well beyond what this
renderer covers.
- Failure modes (missing file, unsupported format, Batik blowing up)
emit an italicised "[image: alt — reason]" placeholder so the rest
of the document still renders; the agent can read its own log to
see why the picture didn't make it.
- Adds two transitive deps via pom: batik-transcoder + batik-codec at
1.18, ~10 MB combined. Worth it given the alternative is shelling
out to system tooling.
Multi-file render (P2-lite).
New tool renderDocxFromFiles(List<String> filePaths, filename, pageSize)
reads several markdown files in order and renders one combined docx.
Lets the agent split a 30-page proposal into cover.md / ch1.md /
ch2.md / appendix.md and produce a single deliverable in one tool
call. Each path goes through WorkspacePathGuard.validatePath; any
empty or unreadable file aborts with a typed error so the agent
fixes its file list before retrying. Files are joined with a blank
line — no separator markup is injected, headings carry over cleanly.
I deliberately did NOT build the heavier mutable-docx state
("appendDocxChapter / finalizeDocx") flavor of P2: the multi-file
form covers the same workflow with no per-conversation state to
clean up, and the agent can iterate by rewriting the chapter file
and re-running the tool. Stateful append can come later if a
streaming use case actually shows up.
renderDocx and renderDocxFromFile @Tool descriptions updated to point
the agent at renderDocxFromFile for >5 KB markdown and to advertise
the new image-embedding capability.
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 () 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.
Three small but high-impact fixes that all surfaced together while
verifying the long-form generation flow.
1. ChatConsole onBeforeUnmount no longer kills the backend turn.
Previously, switching tabs / route navigation / any cause that
unmounted the chat view called stopChatGeneration(), which POSTs
/chat/{cid}/stop and aborts the in-flight LLM call. The user
reported a turn dying mid-generation just from switching pages.
Replaced with resetForNewConversation() — front-end SSE disconnect
only, no /stop. Backend keeps running; pollActivity / status probe
reconnects on return. Aligns with the existing comment in
selectConversation: "let A's backend agent run continue running."
2. Agent max_iterations raised 25 → 100 with a hard ceiling.
The previous 25-step ceiling caused LimitExceededNode to fire on
substantive multi-tool tasks (document generation + image conversion
+ retry loops). 100 matches QwenPaw's _MAX_MAX_ITERATIONS upper
bound. New plumbing:
- BaseAgent.MAX_ITERATIONS_HARD_CEILING = 100 public constant
- BaseAgent default field 25 → 100 (Java-side fallback)
- AgentGraphBuilder clamps any per-agent DB override to the
ceiling at runtime; if the row holds 200, runtime sees 100 and
a WARN is logged with the original value.
- V47 migration (h2 + mysql) idempotently bumps the three default
seeded agents (1000000001, 1000000002, 1000000003) only if they
still hold the old defaults (25 / 20). User-customized values
are not touched.
- data-en/zh/-mysql-en/-mysql-zh seed files updated to 100 for
fresh installs.
3. DocxRenderTool tells the LLM not to prepend a host to the URL.
DeepSeek and Claude have both been observed wrapping the
/api/v1/files/generated/{id} relative path returned by renderDocx
into an absolute URL with a hallucinated domain (e.g.
https://ai-tools-system.com/...), breaking the download link in
the rendered chat bubble. The tool's return string now appends an
explicit "must use the relative path verbatim, do not add any
https:// or http:// prefix" instruction, which Claude and
DeepSeek both honor.