@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.
516 lines (515 loc) • 22.7 kB
JavaScript
import { FOLIO_COMMENT_ID_JSON_SCHEMA, FOLIO_SCOPED_HANDLE_JSON_SCHEMA, FOLIO_SECTION_HANDLE_JSON_SCHEMA, FOLIO_STORY_HANDLE_JSON_SCHEMA } from "./codecs.js";
import { FOLIO_PRECONDITION_JSON_SCHEMA, FOLIO_TEXT_RANGE_JSON_SCHEMA } from "./operation-schema.js";
import { DEFAULT_SUGGEST_CHANGES_OPERATION_TYPES, resolveSuggestChangesOptions } from "./suggest-changes-options.js";
import { defineFolioAgentToolDefinition } from "./tool-contract.js";
import { FOLIO_AGENT_TOOL_NAMES } from "./types.js";
import { FOLIO_DOCUMENT_OPERATION_KEYS_BY_TYPE } from "@stll/folio-core/server";
//#region src/tools.ts
/**
* `suggest_changes` is a host-configurable projection of the document-operation
* contract (see `FOLIO_DOCUMENT_OPERATION_JSON_SCHEMA` in `operation-schema.ts`):
* the allowed operation types, the review-metadata policy, the per-call cap,
* and an optional document-version pin all come from
* {@link FolioSuggestChangesOptions}. The schema is derived from the resolved
* options and from core's per-type key map, so the properties it advertises
* are exactly the ones the contract parser accepts for the allowed types.
* Two model-facing conveniences differ from the contract on the wire:
* `id` is optional (the parser mints unique ids) and `comment` may be a plain
* string (the parser wraps it into `{ text }`). `suggestionId` is host-side
* grouping state and is never exposed to the model.
*/
const SUGGEST_CHANGES_OPERATION_TYPES = DEFAULT_SUGGEST_CHANGES_OPERATION_TYPES;
/** One-line meaning of each contract operation type, for schemas and prompts. */
const OPERATION_TYPE_SUMMARIES = {
replaceInBlock: "replace an exact text match inside one block (`find` -> `replace`)",
replaceRange: "replace the text covered by a `range` copied from find_text",
commentOnRange: "attach a comment to a `range` copied from find_text",
formatRange: "toggle bold, italic, or underline on a `range` copied from find_text",
insertAfterBlock: "insert a new paragraph after a block",
insertBeforeBlock: "insert a new paragraph before a block",
replaceBlock: "replace one block's entire text",
deleteBlock: "delete one block",
commentOnBlock: "attach a comment to one block, optionally quoting text within it",
insertSignatureTable: "insert a side-by-side signature table for the given `parties`",
insertTableRow: "insert a table row next to the row containing a cell block",
deleteTableRow: "delete the table row containing a cell block",
insertTableColumn: "insert a table column next to the column containing a cell block",
deleteTableColumn: "delete the table column containing a cell block",
mergeTableCells: "merge table cells from a cell block to `endBlockId`, or `rowCount` rows downward",
splitTableCell: "split a previously merged table cell"
};
/**
* Model-facing schema for every operation property the contract knows,
* keyed by wire name. A `suggest_changes` schema includes a property only
* when at least one allowed operation type accepts it (per
* `FOLIO_DOCUMENT_OPERATION_KEYS_BY_TYPE`), so a three-type host surface
* advertises three types' worth of fields.
*/
const OPERATION_PROPERTY_SCHEMAS = {
id: {
type: "string",
description: "Optional caller-supplied operation id, echoed back in `applied` / `queued` / `skipped`. Generated when omitted."
},
blockId: {
type: "string",
description: "The block to edit, from `read_document` or `find_text`."
},
severity: {
type: "string",
enum: [
"low",
"medium",
"high"
],
description: "Review severity of this edit, used to sort a review queue."
},
area: {
type: "string",
description: "Short review area label (e.g. \"Payment terms\"), used to group a review queue."
},
precondition: FOLIO_PRECONDITION_JSON_SCHEMA,
endBlockId: {
type: "string",
minLength: 1,
description: "For cell merging, a block in the opposite corner cell."
},
rowCount: {
type: "integer",
minimum: 2,
description: "For vertical cell merging, the number of grid rows to merge downward."
},
range: {
...FOLIO_TEXT_RANGE_JSON_SCHEMA,
description: "Required for `replaceRange`, `commentOnRange`, and `formatRange`: copy the range object returned by `find_text`."
},
find: {
type: "string",
description: "Required for `replaceInBlock`: the exact text to find within the block, up to 100,000 characters."
},
replace: {
type: "string",
description: "Required for `replaceInBlock` and `replaceRange`: replacement text, up to 100,000 characters."
},
text: {
type: "string",
description: "Required for `insertAfterBlock` / `insertBeforeBlock` / `replaceBlock`: the text to insert or replace the block with, up to 100,000 characters."
},
quote: {
type: "string",
description: "For `commentOnBlock`: optional exact text within the block the comment is about, up to 100,000 characters."
},
styleId: {
type: "string",
description: "For inserts and `replaceBlock`: paragraph style id to apply (e.g. a clause-heading style from the document)."
},
pageBreakBefore: {
type: "boolean",
description: "For inserts: start the inserted paragraph on a new page."
},
inheritFormatting: {
type: "boolean",
description: "For inserts: inherit the anchor block's formatting for the inserted paragraph."
},
preserveFormatting: {
type: "boolean",
description: "For `replaceBlock`: keep the block's existing formatting for the replacement text."
},
position: {
type: "string",
enum: ["after", "before"],
description: "For row, column, or signature-table insertion, place the new structure after the anchor (default) or before it."
},
parties: {
type: "array",
description: "For `insertSignatureTable`: the signing parties, one table cell per party.",
items: {
type: "object",
properties: {
name: {
type: "string",
description: "Party name (rendered bold)."
},
signatory: {
type: "string",
description: "Name of the person signing."
},
title: {
type: "string",
description: "Signatory title (rendered in italics)."
}
},
required: ["name"],
additionalProperties: false
}
},
cellTexts: {
type: "array",
description: "For row or column insertion, initial text for new physical cells in source order, up to 100,000 characters per cell.",
maxItems: 256,
items: { type: "string" }
},
formatting: {
type: "object",
description: "Required for `formatRange`: set one or more inline properties to enable or disable.",
properties: {
bold: { type: "boolean" },
italic: { type: "boolean" },
underline: { type: "boolean" }
},
minProperties: 1,
additionalProperties: false
},
comment: {
type: "string",
description: "Optional comment explaining this edit, attached to the affected text, up to 100,000 characters. Required for comment operations."
}
};
/** Wire keys the model never sees (`type` is emitted separately with its enum). */
const HIDDEN_OPERATION_KEYS = /* @__PURE__ */ new Set(["suggestionId", "type"]);
const operationPropertyKeys = (types) => {
const keys = /* @__PURE__ */ new Set();
for (const type of types) for (const key of FOLIO_DOCUMENT_OPERATION_KEYS_BY_TYPE[type]) if (!HIDDEN_OPERATION_KEYS.has(key)) keys.add(key);
return keys;
};
const summarizeOperationTypes = (types) => types.map((type) => `${type} (${OPERATION_TYPE_SUMMARIES[type]})`).join("; ");
const buildSuggestChangesOperationSchema = (resolved) => {
const keys = operationPropertyKeys(resolved.operationTypes);
const properties = { type: {
type: "string",
enum: resolved.operationTypes,
description: `The kind of edit: ${summarizeOperationTypes(resolved.operationTypes)}.`
} };
for (const [key, schema] of Object.entries(OPERATION_PROPERTY_SCHEMAS)) if (keys.has(key)) properties[key] = schema;
return {
type: "object",
properties,
required: resolved.reviewMeta === "required" ? [
"type",
"severity",
"area"
] : ["type"],
additionalProperties: false
};
};
const hasAnyType = (resolved, types) => types.some((type) => resolved.operationTypes.includes(type));
/**
* Plain-language capability statement for a configured `suggest_changes`
* surface: the allowed operations, the structural knobs they unlock, the
* limits, and the review-metadata and document-version requirements. Used
* inside the tool description and exported so a host can paste the same
* text into its system prompt instead of hand-maintaining a copy that drifts.
*/
const describeSuggestChangesCapabilities = (options) => describeResolvedCapabilities(resolveSuggestChangesOptions(options));
const describeResolvedCapabilities = (resolved) => {
const lines = [`Supported operations: ${summarizeOperationTypes(resolved.operationTypes)}.`];
const styleIdTargets = [...hasAnyType(resolved, ["insertAfterBlock", "insertBeforeBlock"]) ? ["an insert"] : [], ...hasAnyType(resolved, ["replaceBlock"]) ? ["replaceBlock"] : []];
if (styleIdTargets.length > 0) {
const pageBreak = hasAnyType(resolved, ["insertAfterBlock", "insertBeforeBlock"]) ? "set `pageBreakBefore: true` on an insert to start it on a new page; " : "";
lines.push(`Structural edits: ${pageBreak}set \`styleId\` on ${styleIdTargets.join(" or ")} to apply a paragraph style such as a clause heading. Never emit directive markers or markdown syntax as paragraph text.`);
}
if (hasAnyType(resolved, ["insertSignatureTable"])) lines.push("Signature blocks: use insertSignatureTable with `parties`; never draw one out of paragraphs.");
if (!hasAnyType(resolved, ["formatRange"])) lines.push("This surface cannot change run formatting (fonts, bold/italic/underline, size, colour, alignment, spacing, list style); do not promise formatting changes.");
lines.push(`Limits: at most ${resolved.maxOperations} operations per call (batch larger edits across calls); each text field at most 100,000 characters.`);
lines.push(resolved.reviewMeta === "required" ? "Every operation must set `severity` (low, medium, or high) and a short `area` label; the review queue sorts and groups by them." : "Set `severity` and `area` on operations when the edit is part of a structured review.");
if (resolved.documentVersion !== null) lines.push("Pass `documentVersion` exactly as given in this tool's schema; the batch is skipped as a whole when the document has moved on since.");
return lines.join("\n");
};
const SUGGEST_CHANGES_BASE_DESCRIPTION = "Propose one or more edits as tracked changes for a human to accept or reject — nothing is applied directly to the visible text. Each operation needs a blockId from `read_document` or `find_text`; if the document changed since that read, re-read it and retry with fresh ids (a skip reason will say so). Pass the `blockTextHash` from that read as `precondition.blockTextHash` to guard against the document changing between the read and this call.";
const buildSuggestChangesToolDefinition = (resolved) => defineFolioAgentToolDefinition({
name: FOLIO_AGENT_TOOL_NAMES.suggestChanges,
description: `${SUGGEST_CHANGES_BASE_DESCRIPTION}\n${describeResolvedCapabilities(resolved)}`,
inputSchema: {
type: "object",
properties: {
...resolved.documentVersion !== null && { documentVersion: {
type: "string",
enum: [resolved.documentVersion.current],
description: "The current document version shown here. Copy it exactly; the batch is skipped if the document changes before it applies."
} },
operations: {
type: "array",
description: `The edits to propose, applied in order. At most ${resolved.maxOperations} per call.`,
minItems: 1,
maxItems: resolved.maxOperations,
items: buildSuggestChangesOperationSchema(resolved)
}
},
required: resolved.documentVersion !== null ? ["documentVersion", "operations"] : ["operations"],
additionalProperties: false
}
});
const definitionsFromRegistry = (registry) => Object.values(registry);
/**
* The tools this package exposes, described for an LLM. Every tool that reads
* or mutates the document expects `blockId` values that came from
* `read_document` or `find_text` in THIS conversation — block ids are not
* guessable and change whenever the document's structure changes. Every
* mutation (`add_comment`, `suggest_changes`) becomes a tracked change or
* comment pending human review; nothing is silently finalized.
*/
const FOLIO_AGENT_TOOL_REGISTRY = {
[FOLIO_AGENT_TOOL_NAMES.readDocument]: defineFolioAgentToolDefinition({
name: FOLIO_AGENT_TOOL_NAMES.readDocument,
description: "Read the full document body as a list of blocks (paragraphs, headings, list items). Call this first, or whenever you need fresh block ids after a mutation — block ids from a stale read may no longer resolve. Each block includes a `blockTextHash`; echo it as `precondition.blockTextHash` on a suggest_changes / add_comment operation to guard against the block changing before that call runs.",
inputSchema: {
type: "object",
properties: {},
required: [],
additionalProperties: false
}
}),
[FOLIO_AGENT_TOOL_NAMES.getDocumentOutline]: defineFolioAgentToolDefinition({
name: FOLIO_AGENT_TOOL_NAMES.getDocumentOutline,
description: "Read a lightweight heading outline before opening document content. Returns stable section handles, heading hierarchy, and real rendered page numbers when a live paginated editor is available.",
inputSchema: {
type: "object",
properties: { maxDepth: {
type: "integer",
minimum: 1,
maximum: 9,
description: "Deepest heading level to return. Defaults to 3."
} },
required: [],
additionalProperties: false
}
}),
[FOLIO_AGENT_TOOL_NAMES.readSection]: defineFolioAgentToolDefinition({
name: FOLIO_AGENT_TOOL_NAMES.readSection,
description: "Read one logical heading section using a handle from get_document_outline. Content is block-bounded and paginated with an afterBlockId cursor, avoiding a full-document read. Each block includes a `blockTextHash`; echo it as `precondition.blockTextHash` on a suggest_changes / add_comment operation.",
inputSchema: {
type: "object",
properties: {
handle: FOLIO_SECTION_HANDLE_JSON_SCHEMA,
maxBlocks: {
type: "integer",
minimum: 1,
maximum: 200,
description: "Maximum blocks to return. Defaults to 100."
},
afterBlockId: {
type: "string",
description: "Continue after this block id from the preceding read_section response."
}
},
required: ["handle"],
additionalProperties: false
}
}),
[FOLIO_AGENT_TOOL_NAMES.listStories]: defineFolioAgentToolDefinition({
name: FOLIO_AGENT_TOOL_NAMES.listStories,
description: "List readable document stories and their typed handles.",
inputSchema: {
type: "object",
properties: {},
required: [],
additionalProperties: false
}
}),
[FOLIO_AGENT_TOOL_NAMES.readStory]: defineFolioAgentToolDefinition({
name: FOLIO_AGENT_TOOL_NAMES.readStory,
description: "Read one document story using a handle returned by `list_stories`.",
inputSchema: {
type: "object",
properties: { handle: FOLIO_STORY_HANDLE_JSON_SCHEMA },
required: ["handle"],
additionalProperties: false
}
}),
[FOLIO_AGENT_TOOL_NAMES.findText]: defineFolioAgentToolDefinition({
name: FOLIO_AGENT_TOOL_NAMES.findText,
description: "Search a document, section, rendered page, or story and return `{ matches, truncated, totalMatches }`. Main-story matches include a stable block, exact range, and `blockTextHash` (echo it as `precondition.blockTextHash` on a later suggest_changes / add_comment operation); other stories return story-relative offsets. Every match includes surrounding context. `matches` is capped at 200 entries; `truncated` is true and `totalMatches` reports the real count when there were more — narrow the query or scope instead of assuming you saw every hit.",
inputSchema: {
type: "object",
properties: {
query: {
type: "string",
description: "Non-empty text to search for, up to 1,000 characters."
},
matchCase: {
type: "boolean",
description: "Case-sensitive match. Defaults to false (case-insensitive)."
},
wholeWord: {
type: "boolean",
description: "Match only Unicode word boundaries. Defaults to false."
},
scope: {
type: "object",
properties: {
type: {
type: "string",
enum: [
"document",
"section",
"page",
"story"
]
},
handle: FOLIO_SCOPED_HANDLE_JSON_SCHEMA,
page: {
type: "integer",
minimum: 1
}
},
required: ["type"],
additionalProperties: false,
description: "Limit search to the main document, a section, a real rendered page, or a story."
}
},
required: ["query"],
additionalProperties: false
}
}),
[FOLIO_AGENT_TOOL_NAMES.readComments]: defineFolioAgentToolDefinition({
name: FOLIO_AGENT_TOOL_NAMES.readComments,
description: "Read comment threads in the document, each with its author, text, resolved status, anchored block, and replies. Filter to unresolved (\"open\") comments to see what still needs attention.",
inputSchema: {
type: "object",
properties: { filter: {
type: "string",
enum: [
"all",
"open",
"resolved"
],
description: "Which comments to return. Defaults to \"all\"."
} },
required: [],
additionalProperties: false
}
}),
[FOLIO_AGENT_TOOL_NAMES.readChanges]: defineFolioAgentToolDefinition({
name: FOLIO_AGENT_TOOL_NAMES.readChanges,
description: "Read pending tracked changes awaiting human review. Use this to see the effect of edits already suggested via `suggest_changes` before proposing more.",
inputSchema: {
type: "object",
properties: {},
required: [],
additionalProperties: false
}
}),
[FOLIO_AGENT_TOOL_NAMES.addComment]: defineFolioAgentToolDefinition({
name: FOLIO_AGENT_TOOL_NAMES.addComment,
description: "Attach a comment to a block, optionally quoting the specific text it is about. The comment is added immediately (comments are not tracked changes) but the underlying text is left untouched — use this for notes/questions, and `suggest_changes` for edits. `text` and `quote` are each capped at 100,000 characters.",
inputSchema: {
type: "object",
properties: {
blockId: {
type: "string",
description: "The block to comment on, from `read_document` or `find_text`."
},
quote: {
type: "string",
description: "Optional exact text within the block this comment is about, up to 100,000 characters."
},
text: {
type: "string",
description: "The comment body, up to 100,000 characters."
},
precondition: FOLIO_PRECONDITION_JSON_SCHEMA
},
required: ["blockId", "text"],
additionalProperties: false
}
}),
[FOLIO_AGENT_TOOL_NAMES.suggestChanges]: buildSuggestChangesToolDefinition(resolveSuggestChangesOptions()),
[FOLIO_AGENT_TOOL_NAMES.replyComment]: defineFolioAgentToolDefinition({
name: FOLIO_AGENT_TOOL_NAMES.replyComment,
description: "Reply to an existing comment thread, referenced by the id from `read_comments`. `text` is capped at 100,000 characters.",
inputSchema: {
type: "object",
properties: {
commentId: FOLIO_COMMENT_ID_JSON_SCHEMA,
text: {
type: "string",
description: "The reply body, up to 100,000 characters."
}
},
required: ["commentId", "text"],
additionalProperties: false
}
}),
[FOLIO_AGENT_TOOL_NAMES.resolveComment]: defineFolioAgentToolDefinition({
name: FOLIO_AGENT_TOOL_NAMES.resolveComment,
description: "Mark a comment thread resolved, or pass `reopen: true` to reopen a previously resolved one.",
inputSchema: {
type: "object",
properties: {
commentId: FOLIO_COMMENT_ID_JSON_SCHEMA,
reopen: {
type: "boolean",
description: "Reopen an already-resolved thread instead of resolving it."
}
},
required: ["commentId"],
additionalProperties: false
}
}),
[FOLIO_AGENT_TOOL_NAMES.readPage]: defineFolioAgentToolDefinition({
name: FOLIO_AGENT_TOOL_NAMES.readPage,
description: "Read the plain text of one page (1-based) as currently paginated in the live editor. Only available when the document is open in a live, paginated editor surface — not on a headless document.",
inputSchema: {
type: "object",
properties: { page: {
type: "number",
description: "1-based page number."
} },
required: ["page"],
additionalProperties: false
}
}),
[FOLIO_AGENT_TOOL_NAMES.readSelection]: defineFolioAgentToolDefinition({
name: FOLIO_AGENT_TOOL_NAMES.readSelection,
description: "Read the user's current text selection in the live editor, as plain text. Only available on a live editor surface with an active selection.",
inputSchema: {
type: "object",
properties: {},
required: [],
additionalProperties: false
}
}),
[FOLIO_AGENT_TOOL_NAMES.showInDocument]: defineFolioAgentToolDefinition({
name: FOLIO_AGENT_TOOL_NAMES.showInDocument,
description: "Reveal and select a block or exact text range in the live editor. Pass either blockId or the range returned by find_text; stale or missing targets return false.",
inputSchema: {
type: "object",
properties: {
blockId: {
type: "string",
description: "A main-story block id."
},
range: FOLIO_TEXT_RANGE_JSON_SCHEMA
},
required: [],
additionalProperties: false
}
}),
[FOLIO_AGENT_TOOL_NAMES.scrollToBlock]: defineFolioAgentToolDefinition({
name: FOLIO_AGENT_TOOL_NAMES.scrollToBlock,
description: "Scroll the live editor to the given block and select it, so the user can see what you are discussing. Only available on a live editor surface.",
inputSchema: {
type: "object",
properties: { blockId: {
type: "string",
description: "The block to scroll to, from `read_document` or `find_text`."
} },
required: ["blockId"],
additionalProperties: false
}
})
};
const FOLIO_AGENT_TOOLS = definitionsFromRegistry(FOLIO_AGENT_TOOL_REGISTRY);
/**
* The tool definitions this package exposes. Without options this is
* {@link FOLIO_AGENT_TOOLS}; with `suggestChanges` options the
* `suggest_changes` definition is rebuilt for that surface. Pass the same
* options to `executeFolioToolCall` so the parser enforces what the schema
* advertises.
*/
const getFolioToolDefinitions = (options = {}) => {
if (options.suggestChanges === void 0) return FOLIO_AGENT_TOOLS;
const suggestChanges = buildSuggestChangesToolDefinition(resolveSuggestChangesOptions(options.suggestChanges));
return FOLIO_AGENT_TOOLS.map((definition) => definition.name === FOLIO_AGENT_TOOL_NAMES.suggestChanges ? suggestChanges : definition);
};
//#endregion
export { FOLIO_AGENT_TOOLS, FOLIO_AGENT_TOOL_REGISTRY, SUGGEST_CHANGES_OPERATION_TYPES, describeSuggestChangesCapabilities, getFolioToolDefinitions };