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
2 changed files with 557 additions and 19 deletions

View file

@ -65,4 +65,5 @@ export type PdfCommand =
getText: boolean;
getScreenshots: boolean;
}
| { type: "save_as"; requestId: string }
| { type: "file_changed"; mtimeMs: number };

View file

@ -4298,6 +4298,30 @@ async function processCommands(commands: PdfCommand[]): Promise<void> {
.catch(() => {});
}
break;
case "save_as":
// Same await-before-next-poll discipline as get_pages — submit must
// be SENT before we re-poll, or it queues behind the 30s long-poll.
try {
const pdfBytes = await getAnnotatedPdfBytes();
const base64 = uint8ArrayToBase64(pdfBytes);
await app.callServerTool({
name: "submit_save_data",
arguments: { requestId: cmd.requestId, data: base64 },
});
log.info(`save_as: submitted ${pdfBytes.length} bytes`);
} catch (err) {
log.error("save_as: failed to build bytes — submitting error:", err);
await app
.callServerTool({
name: "submit_save_data",
arguments: {
requestId: cmd.requestId,
error: err instanceof Error ? err.message : String(err),
},
})
.catch(() => {});
}
break;
case "file_changed": {
// Skip our own save_pdf echo: either save is still in flight, or the
// event's mtime matches what save_pdf just returned.
@ -4337,10 +4361,15 @@ async function processCommands(commands: PdfCommand[]): Promise<void> {
}
// Persist after processing batch — but only if anything mutated.
// get_pages / file_changed are read-only; writing localStorage and
// recomputing the diff for them is wasted work.
// get_pages / save_as / file_changed are read-only; writing localStorage
// and recomputing the diff for them is wasted work.
if (
commands.some((c) => c.type !== "get_pages" && c.type !== "file_changed")
commands.some(
(c) =>
c.type !== "get_pages" &&
c.type !== "save_as" &&
c.type !== "file_changed",
)
) {
persistAnnotations();
}