pdf-server: add save_as interact action (#580)

* pdf-server: add save_as interact action

Adds a model-callable save_as action to the interact tool that writes the
viewer's current annotated state (annotations + form values) to a new file.

Reuses the get_pages request/reply pattern: model's interact call blocks
while the viewer builds bytes via getAnnotatedPdfBytes() and posts them
back through a new submit_save_data app-only tool.

Validation runs synchronously before bothering the viewer:
- path must be absolute or file://
- isWritablePath gate (same as save_pdf)
- existence check; fails with a descriptive error unless overwrite: true

The viewer can report build errors back via submit_save_data({error}),
so the model gets a real message instead of the 45s timeout.

* pdf-server: warn model off app-only tools in descriptions

Hosts that don't filter visibility:["app"] tools from the model's tool
list will still surface these. The save_pdf one in particular is a
near-miss for the new save_as interact action — point the model at the
right one.

* pdf-server: save_as without path overwrites original

When path is omitted, save_as falls back to the displayed PDF's local
path (tracked per-viewUUID by display_pdf). Same writability gate as
the viewer's save button: isWritablePath + OS-level W_OK probe, so it
refuses on read-only mounts before the viewer roundtrip.

Requires explicit overwrite: true since the original always exists —
the model can't accidentally clobber it.

Remote PDFs have no source path stored; the error tells the model to
pass an explicit path.
This commit is contained in:
Olivier Chafik 2026-04-01 22:45:00 +01:00 • committed by GitHub
commit 4b3f6252a4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -188,13 +188,13 @@ When roots are ignored the server logs:
## Tools
| Tool | Visibility | Purpose |
| ---------------- | ---------- | ----------------------------------------------------- |
| `list_pdfs` | Model | List available local files and origins |
| `display_pdf` | Model + UI | Display interactive viewer |
| `interact`¹ | Model | Navigate, annotate, search, extract pages, fill forms |
| `read_pdf_bytes` | App only | Stream PDF data in chunks |
| `save_pdf` | App only | Save annotated PDF back to local file |
| Tool | Visibility | Purpose |
| ---------------- | ---------- | ------------------------------------------------------------------- |
| `list_pdfs` | Model | List available local files and origins |
| `display_pdf` | Model + UI | Display interactive viewer |
| `interact`¹ | Model | Navigate, annotate, search, extract pages, fill forms, save to file |
| `read_pdf_bytes` | App only | Stream PDF data in chunks |
| `save_pdf` | App only | Save annotated PDF back to local file |
¹ stdio only by default; in HTTP mode requires `--enable-interact` — see [Deployment](#deployment).
@ -250,6 +250,16 @@ After the model calls `display_pdf`, it receives the `viewUUID` and a descriptio
>
> _Model calls `interact` with action `fill_form`, fields `[{name:"Name", value:"Alice"}, {name:"Date", value:"2026-02-26"}]`_
### Saving
> **User:** Save the annotated version as `/docs/contract-signed.pdf`.
>
> _Model calls `interact` with action `save_as`, path `/docs/contract-signed.pdf`. Fails if the file exists; pass `overwrite: true` to replace. The target path must be under a mounted directory root._
> **User:** Save my changes back to the file.
>
> _Model calls `interact` with action `save_as`, `overwrite: true` (no `path`). Overwrites the original — same writability gate as the viewer's save button. Only works for local files; remote PDFs need an explicit `path`._
## Testing
### E2E Tests (Playwright)