@stll/folio-agents
Version:
Framework-neutral LLM tool layer over folio's ai-edits engine: function-calling tools so an agent can read and mutate .docx documents through @stll/folio-core.
238 lines (214 loc) • 13.9 kB
Markdown
---
name: folio-agents
description: >-
Integrate and use @stll/folio-agents, the framework-neutral LLM tool layer
over @stll/folio-core: function-calling tools that read and mutate a .docx
document, with every mutation landing as a tracked change or comment
pending human review. Load this skill when wiring folio's tools into an
agent's tool-use loop (TanStack AI, Vercel AI SDK, raw Anthropic/OpenAI
SDKs, or a custom loop), choosing between the headless reviewer bridge and
the live-editor-ref bridge, or summarizing what changed between two
document versions.
metadata:
type: core
library: "@stll/folio-agents"
library_version: "0.0.0"
---
# @stll/folio-agents
Framework-neutral LLM tool layer over `@stll/folio-core`'s ai-edits engine.
Gives a model function-calling tools to read and mutate a `.docx` document;
every mutation (`add_comment`, `suggest_changes`) lands as a tracked change or
comment pending human review — nothing is silently finalized.
## Integration recipe
1. `getFolioToolDefinitions(options?)` returns `FolioAgentToolDefinition[]` —
plain `{ name, description, inputSchema }` objects with a conservative JSON
Schema subset (`type: "object"`, `properties`, `required`, `enum`,
`additionalProperties: false`, plain arrays — safe for providers that
reject uncommon keywords, e.g. Gemini's OpenAPI-3.0 subset). Pass
`{ suggestChanges }` options to shape `suggest_changes` for the surface
(see below) and pass the same object to `executeFolioToolCall`.
2. Register them with your framework:
- TanStack AI: pass `inputSchema` directly as `toolDefinition({ inputSchema })` — no wrapper needed.
- Raw Anthropic/OpenAI SDKs: `toAnthropicTools(defs)` / `toOpenAITools(defs)`.
- Vercel AI SDK: wrap each `inputSchema` in `jsonSchema()` from `ai`.
3. In your tool-use loop, for every `tool_use` / `tool_call` the model emits:
call `executeFolioToolCallUntyped(name, args, bridge, { suggestChanges })`
with the same options object as step 1 → returns
`{ ok: true, result } | { ok: false, error }`.
4. Feed that result straight back to the model as the tool result message —
both branches are meant to reach the model, not just the `ok: true` one:
error strings are plain language ("blockId not found; re-read...") that
the model can act on directly.
## Bridge selection
A `FolioAgentBridge` is the structural seam `executeFolioToolCall` drives.
Two are shipped:
- `createReviewerBridge(FolioDocxReviewer.fromBuffer(...))` — server/headless,
full capability. Use for a document that isn't open in a live editor (a
backend job, a batch review pass). Defaults to `mode: "tracked-changes"`
(pass `{ mode: "direct" }` to edit in place instead — rarely what you want
for an unreviewed agent).
- `createEditorRefBridge({ ref, author, getComments, setComments })` — drives
a live `DocxEditorRef` from `@stll/folio-react` (or anything structurally
matching `FolioAgentEditorRefLike`). Comments live in host React state, not
on the ref, hence the `getComments` / `setComments` pair (the same ones the
host already passes to `DocxEditor`).
The editor-ref bridge's capability now depends on what the underlying
`DocxEditorRef` implements. Against a ref with the read surface
(`getTrackedChanges`, `getCommentAnchors`, `getSelectionText`, `getPageText`),
it has full parity with the headless one: `read_changes` returns real tracked
changes, comment entries carry a resolved `blockId` / `quote`, and `read_page`
/ `read_selection` work against the live view. Against an older ref that
predates those methods, each falls back independently: `read_changes` returns
`[]`, comment entries keep `blockId: null` / `quote: ""`, and `read_page` /
`read_selection` report an unsupported-capability error (see
`src/bridges/editor-ref.ts` for the exact per-method fallback). A bridge
omits an optional capability member entirely rather than implementing it as a
no-op, and `executeFolioToolCall` reports the corresponding tool call as
unsupported rather than throwing — this is how the headless reviewer bridge
signals `read_page` / `read_selection` / `scroll_to_block` as unsupported too,
since a headless document has no live page/selection/scroll surface at all.
## Configuring `suggest_changes`
`suggest_changes` is configured per host surface, never re-derived. Pass one
`FolioSuggestChangesOptions` object to both `getFolioToolDefinitions({
suggestChanges })` and `executeFolioToolCall(name, args, bridge, {
suggestChanges })`:
- `operationTypes`: any subset of the contract's operation types (default:
everything except `commentOnBlock`, covered by `add_comment`, and
`insertSignatureTable`, which is direct-only). The schema advertises only
the properties those types accept.
- `reviewMeta: "required"`: every operation must carry `severity` and `area`
(a review queue that sorts and groups needs them). Default `"optional"`.
- `maxOperations`: 1 to 200 per call, default 50.
- `documentVersion: { current }`: the model must echo the token; the executor
compares it to `bridge.getDocumentVersion()` and skips the whole batch with
`documentVersionMismatch` when the document moved on.
`describeSuggestChangesCapabilities(options)` returns the capability text the
tool description carries; paste it into a system prompt instead of
hand-writing a "what this tool can do" paragraph that drifts.
The parser decodes leniently (a `kind` key, an operation given as a JSON
string, a property that does not apply to the type) and reports each step in
the result's `normalizations`; it then enforces the caps, mints ids unique
across calls, and delegates every per-operation rule to
`parseFolioDocumentOperationBatch` in `@stll/folio-core`.
## Host-managed review queue
A host that already has its own review-queue UX — its own place to store a
model's proposed edits pending approval, separate from folio's
tracked-changes redlines — writes a `FolioAgentBridge` whose
`applyDocumentOperations` enqueues the batch and returns `status: "queued"`
with the operations listed in `queued` (build `receipts` with
`getFolioDocumentOperationReceipts` from `@stll/folio-core/server`). The
executor then produces the standard envelope for the model (`queued`,
`skipped` with plain-language reasons, `receipts`, `normalizations`) and keeps
the automatic `precondition` stamping and the document-version check. A
surface with nothing editable reports every operation as skipped with
`documentNotEditable`. `parseSuggestChangesInput` / `parseAddCommentInput`
stay exported for validation-only paths.
## Ground rules for the model
- Block ids (`blockId`) and comment ids (`commentId`) only ever come from a
prior tool call in the same conversation — `read_document`, `find_text`, or
`read_comments`. Never invent or reuse one from outside the conversation;
ids are opaque values a caller reads, never ones it constructs, and they
change whenever the document's structure changes. The same holds for a
numbering reference: use `{ kind: "reference", numId, ilvl? }` with an
instance a list block of this document carries; an instance the document
does not define skips the operation (`missingNumbering`) before anything is
applied. Use `{ kind: "none" }` to cancel numbering or
`{ kind: "inherit" }` to remove the direct numbering override.
Use `{ kind: "levelOnly", ilvl }` to set a level without an instance, or
`{ kind: "newList", format: "numbered" | "bullet", level? }` to start a
new list.
- A clause number ("clause 2.1") is found through a block's `displayLabel`,
the number the document shows beside it; a numbered heading is
`kind: "heading"` with its `headingLevel` and its number in `displayLabel`.
- `suggest_changes` operations that get skipped (`skipped: [{ id, reason }]`)
return a plain-language reason, not a machine code (e.g. "the block changed
since your snapshot; re-read the document and retry with fresh ids"). Treat
a skip as a retry signal: re-read (`read_document` or `find_text`) and
reissue with fresh ids, not a dead end. The one exception is
`invalidResult`: the operation would have left a document that cannot be
saved, so nothing from it was applied; rephrase it rather than resend it.
- An operation `id` names one operation for the whole document. If a
`suggest_changes` response is lost, resend the same call: operations that
already landed come back under `replayed` and are not applied twice. Never
reuse an id for a different operation (that fails the call); omit ids to
have fresh ones generated.
- Every operation of one `suggest_changes` call addresses the document as it
was read. Give each block or stretch of text at most one operation that
deletes, rewrites, splits or merges it: a later operation on a target an
earlier one of the same call already claims is skipped
(`overlappingOperation`). Send it again, on its own, after re-reading.
- Offsets and ranges count UTF-16 code units and must fall between two
characters: not inside an emoji, and for a text change or split not between
a letter and its combining marks either (`splitsCharacter`, with the
offsets on either side).
- A `styleId` must name a paragraph style the document defines; an undefined
id, or a table or character style, skips the operation (`missingStyle`, with
the paragraph styles the document offers) before anything is applied.
`null` clears the style.
- A block pending deletion (a tracked change deletes its text and paragraph
mark, or its table row) reads as a blank block; `replaceBlock` on it skips
(`pendingDeletion`), since accepting would join the new text to the next
paragraph. Reject that deletion first, or insert a new block next to it.
- Successful mutations return input-ordered `receipts`. Use their typed
`affected` targets to identify updated blocks, stable ranges, insertions,
and created comments without inferring effects from document text.
- `suggest_changes` defaults to tracked-changes mode on both shipped bridges
— it proposes redlines for a human to accept or reject, never edits the
visible text directly.
- The operation kinds a surface accepts are exactly the ones in its
`suggest_changes` schema's `type` enum (by default `replaceInBlock`,
`replaceRange`, `commentOnRange`, `formatRange`, `insertAfterBlock`,
`insertBeforeBlock`, `replaceBlock`, `deleteBlock`, `splitBlock`,
`mergeBlockWithNext`, `setBlockParagraphProperties`, `insertTable`,
`deleteTable`, `insertTableRow`, `deleteTableRow`,
`insertTableColumn`, `deleteTableColumn`, `mergeTableCells`,
`splitTableCell`) plus comment/reply/resolve. Do not
invent an operation kind or a directive marker; if a document needs a
structural operation the contract lacks, extend `@stll/folio-core`'s
ai-edits engine and the contract, then the schema follows.
- **A blank paragraph is a block like any other.** It has an id, it is
addressable, and adding or removing one is a real edit. `insertAfterBlock` /
`insertBeforeBlock` with `text: ""` insert a blank line; `deleteBlock` on a
blank removes it rather than doing nothing. Only an operation that would
change nothing at all is refused.
- `insertTableRow` builds one cell per column the new row has, so `cellTexts`
fills those cells in order and any it does not name stay empty. A vertical
merge crossing the insertion point grows through the new row and keeps its
column, so the row has fewer cells there; more `cellTexts` than cells is
refused as `payloadDoesNotFit` (the issue's `message` names the texts), never
truncated. The same holds for `insertTableColumn` across a horizontal merge.
- A `queued` list in a `suggest_changes` result means the host parked those
operations in its own review queue; treat it like `applied` for the purpose
of "the edit has been proposed", never as a failure.
- **Untrusted documents:** `read_document`, `read_comments`, `read_changes`,
and `find_text` return document content verbatim, so a `.docx` from an
untrusted party can inject prompt instructions straight into the model's
context via its text — hosts should treat any document-derived tool result
as untrusted model input. Mutations stay safe regardless: `suggest_changes`
and `add_comment` only ever produce tracked changes or comments pending
human review, so an injected instruction can propose an edit, never apply
one.
## Summarizing what changed
Two different questions, two different tools:
- **Pending redlines in one document** — what a reviewer would see as tracked
changes right now: use the `read_changes` tool on a capable bridge or call
`reviewer.getChanges()` directly.
- **Between two saved `.docx` versions** — `compareDocxVersions(previousBuffer,
currentBuffer)` + `formatVersionDiffForLLM(diff)`. Both are plain async
functions, not tool definitions: a model can't attach two buffers to one
tool call (they aren't JSON-serializable arguments). Wrap them in a
host-side tool keyed by version identifiers instead — the model calls it
with two version ids, your backend resolves the buffers and returns the
formatted diff text.
- **Reviewable output between two saved versions** —
`generateRedlineDocx(previousBuffer, currentBuffer, options)` returns the
generated package, operation results, unmatched stories, and a structured
package-metadata privacy report.
`compareDocxVersions` compares the AS-ACCEPTED view of each buffer: any
tracked changes already pending in either one count as already applied before
the diff runs, so diffing two versions that each carry their own uncommitted
redlines still produces a clean diff instead of raw markup noise.
## Reference
Tool names, full JSON Schemas, and the headless/live-editor quickstarts are
in this package's README.md. Read `src/tools.ts` for the exact input schema
of every tool and `src/execute.ts` for the skip-reason vocabulary.