@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.
633 lines (632 loc) • 34.3 kB
JavaScript
import { getDecodedCommentHandlers, getOperationSession } from "./bridge.js";
import { decodeCommentId, decodeMainStoryTextRangeHandle, decodeSectionHandle, decodeStoryHandle } from "./codecs.js";
import { explainTextTooLong, parseAddCommentInput, parseSuggestChangesInput, prepareFolioAgentDocumentOperationBatch } from "./parse.js";
import { FOLIO_AGENT_TOOL_NAMES } from "./types.js";
import { FOLIO_DOCUMENT_OPERATION_CONTRACT_VERSION, createFolioAITextRangeHandle, getFolioDocumentOperationIssues, getFolioDocumentOutline, hashFolioAIBlockText, isFolioAIContentBlock, normalizeFolioAIBlockText, readFolioDocumentSection } from "@stll/folio-core/server";
import { sliceText } from "@stll/folio-core/utils/sliceText";
import { panic } from "better-result";
//#region src/execute.ts
const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
const isNonEmptyString = (value) => typeof value === "string" && value.length > 0;
const ok = (result) => ({
ok: true,
result
});
const fail = (error) => ({
ok: false,
error
});
const VALID_TOOL_NAMES = Object.values(FOLIO_AGENT_TOOL_NAMES);
/**
* The same normalized-text hash the core snapshot/apply machinery uses for
* `precondition.blockTextHash` (see `hashFolioAIBlockText` /
* `normalizeFolioAIBlockText`). Computed straight from a block's `text`
* rather than looked up from `snapshot.anchors` so every read path (main
* document, a section, a find_text match) can attach it without threading
* the anchors map around — it is defined to produce the exact same value.
*/
const blockTextHashOf = (text) => hashFolioAIBlockText(normalizeFolioAIBlockText(text));
/**
* A snapshot block as a `read_document` / `read_section` row. The authored
* numbering state and effective list reference stay separate; the display
* label carries the number the reader sees. Absent references stay omitted.
*/
const toAgentBlock = (block) => {
const fields = {
blockId: block.id,
text: block.text,
blockTextHash: blockTextHashOf(block.text)
};
switch (block.kind) {
case "paragraph":
case "heading":
case "listItem": return {
...fields,
kind: block.kind,
...block.displayLabel !== void 0 && { displayLabel: block.displayLabel },
...block.headingLevel !== void 0 && { headingLevel: block.headingLevel },
statedNumbering: block.statedNumbering,
...block.listReference !== void 0 && { listReference: block.listReference }
};
case "diagnostic": return {
...fields,
kind: block.kind,
...block.displayLabel !== void 0 && { displayLabel: block.displayLabel },
...block.headingLevel !== void 0 && { headingLevel: block.headingLevel }
};
default: return panic("Unhandled agent snapshot block kind", { block });
}
};
/** `find_text` requires a short `query` and caps how much of a large match set it returns in one call. */
const MAX_QUERY_LENGTH = 1e3;
const MAX_FIND_MATCHES = 200;
/**
* Execute one tool call against a {@link FolioAgentBridge}. Every EXPECTED
* failure — bad arguments, an unknown tool name, a capability the bridge does
* not implement, a domain-level skip — comes back as `{ ok: false, error }`
* with a message meant to be fed straight back to the model; it is never
* thrown. This function is the sole boundary layer allowed to catch an
* unexpected throw from the bridge and fold it into that same shape (per
* AGENTS.md, try/catch is acceptable at boundary layers).
*
* Every bridge member used here ({@link FolioDocxReviewer} and the live-editor
* ref) is synchronous, so this stays synchronous too.
*/
const executeFolioToolCallUntyped = (name, args, bridge, options = {}) => {
if (!VALID_TOOL_NAMES.includes(name)) return fail(`Unknown tool "${name}". Valid tools: ${VALID_TOOL_NAMES.join(", ")}.`);
try {
return dispatch(name, args, bridge, options);
} catch (error) {
return fail(error instanceof Error ? error.message : String(error));
}
};
function executeFolioToolCall(name, args, bridge, options = {}) {
return executeFolioToolCallUntyped(name, args, bridge, options);
}
const dispatch = (name, args, bridge, options) => {
if (!isFolioAgentToolName(name)) return fail(`Unknown tool "${name}".`);
return TOOL_HANDLERS[name](args, bridge, options);
};
const readDocument = (bridge) => {
const blocks = bridge.snapshot().blocks.filter(isFolioAIContentBlock);
return ok(blocks.map(toAgentBlock));
};
const getDocumentOutline = (args, bridge) => {
const maxDepth = isPlainObject(args) ? args["maxDepth"] : void 0;
if (maxDepth !== void 0 && (typeof maxDepth !== "number" || !Number.isInteger(maxDepth) || maxDepth < 1 || maxDepth > 9)) return fail("get_document_outline's `maxDepth` must be an integer from 1 to 9.");
const outline = getFolioDocumentOutline(bridge.snapshot());
const depth = maxDepth ?? 3;
const sections = outline.sections.filter(({ level }) => level <= depth).map((entry) => {
const page = bridge.getTargetPage?.({
type: "block",
story: "main",
blockId: entry.headingBlockId
}) ?? void 0;
return page === void 0 ? entry : Object.assign({}, entry, { page });
});
return ok({
sections,
totalSections: outline.sections.length,
truncated: sections.length < outline.sections.length
});
};
const readSection = (args, bridge) => {
if (!isPlainObject(args)) return fail("read_section expects a section `handle` from get_document_outline.");
const handle = decodeSectionHandle(args["handle"]);
if (handle === null) return fail("read_section requires a valid `handle` from get_document_outline.");
const maxBlocks = args["maxBlocks"] ?? 100;
if (typeof maxBlocks !== "number" || !Number.isInteger(maxBlocks) || maxBlocks < 1 || maxBlocks > 200) return fail("read_section's `maxBlocks` must be an integer from 1 to 200.");
const afterBlockId = args["afterBlockId"];
if (afterBlockId !== void 0 && !isNonEmptyString(afterBlockId)) return fail("read_section's `afterBlockId` must be a non-empty string when provided.");
const resolved = readFolioDocumentSection(bridge.snapshot(), handle);
if (resolved.status === "missing") return fail("The section no longer exists; run get_document_outline again.");
if (resolved.status === "stale") return fail("The section heading changed; run get_document_outline again for a fresh handle.");
let startIndex = 0;
if (afterBlockId !== void 0) {
const cursorIndex = resolved.section.blocks.findIndex(({ id }) => id === afterBlockId);
if (cursorIndex === -1) return fail("read_section's `afterBlockId` is not part of this section.");
startIndex = cursorIndex + 1;
}
const selected = resolved.section.blocks.slice(startIndex, startIndex + maxBlocks);
const hasMore = startIndex + selected.length < resolved.section.blocks.length;
const lastBlockId = selected.at(-1)?.id;
return ok({
handle,
heading: resolved.section.heading,
blocks: selected.map(toAgentBlock),
totalBlocks: resolved.section.blocks.length,
truncated: hasMore,
...hasMore && lastBlockId !== void 0 && { nextAfterBlockId: lastBlockId }
});
};
const readStory = (args, bridge) => {
if (!bridge.readStory) return fail("This editor surface does not support story reads.");
const parsed = isPlainObject(args) ? decodeStoryHandle(args["handle"]) : null;
if (parsed === null) return fail("read_story requires a valid typed `handle` from list_stories.");
const story = bridge.readStory(parsed);
return story ? ok(story) : fail("The requested story was not found; run list_stories again.");
};
const CONTEXT_RADIUS = 40;
const WORD_CHARACTER_AT_END = /[\p{L}\p{M}\p{N}_]$/u;
/** Whether `[start, end)` of a block's text starts or ends inside a note reference's marker. */
const cutsIntoNoteReference = (block, start, end) => {
switch (block.kind) {
case "diagnostic": return false;
case "paragraph":
case "heading":
case "listItem": return (block.structuralBoundaries ?? []).some((boundary) => boundary.type === "noteReference" && [start, end].some((offset) => offset > boundary.offset && offset < boundary.offset + boundary.length));
default: return panic("Unhandled agent snapshot block kind", { block });
}
};
const WORD_CHARACTER_AT_START = /^[\p{L}\p{M}\p{N}_]/u;
/**
* Window (UTF-16 code units) sliced on each side of a match for the
* whole-word boundary check. The regexes above only ever test the single
* grapheme adjacent to the match, but a surrogate pair or a base character
* with stacked combining marks can span a few code units — this window is
* generous enough to cover that while staying a constant, not
* `block.text.length`. Without a bound, `.slice(0, at)` / `.slice(at + len)`
* on every match makes a whole-word search O(n^2) in the block's length.
*/
const WORD_BOUNDARY_WINDOW = 8;
const findTextMatches = (blocks, query, matchCase, wholeWord, getTargetPage, pageFilter) => {
const matches = [];
let totalMatches = 0;
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const expression = new RegExp(escapedQuery, matchCase ? "gu" : "giu");
for (const block of blocks) {
let occurrence = 0;
let blockTextHash;
for (const match of block.text.matchAll(expression)) {
const at = match.index;
const matchedText = match[0];
if (wholeWord && (WORD_CHARACTER_AT_END.test(block.text.slice(Math.max(0, at - WORD_BOUNDARY_WINDOW), at)) || WORD_CHARACTER_AT_START.test(block.text.slice(at + matchedText.length, at + matchedText.length + WORD_BOUNDARY_WINDOW)))) continue;
if (cutsIntoNoteReference(block, at, at + matchedText.length)) continue;
const range = createFolioAITextRangeHandle({
blockId: block.id,
text: block.text,
startOffset: at,
endOffset: at + matchedText.length
});
if (range === null) continue;
const page = getTargetPage?.(range) ?? void 0;
if (pageFilter !== void 0 && page !== pageFilter) continue;
totalMatches += 1;
if (matches.length < MAX_FIND_MATCHES) {
const contextStart = Math.max(0, at - CONTEXT_RADIUS);
const contextEnd = Math.min(block.text.length, at + matchedText.length + CONTEXT_RADIUS);
blockTextHash ??= blockTextHashOf(block.text);
matches.push({
type: "main",
story: { type: "main" },
blockId: block.id,
blockTextHash,
range,
occurrenceInBlock: occurrence,
context: sliceText({
text: block.text,
units: "codeUnits",
start: contextStart,
end: contextEnd
}),
...page !== void 0 && page !== null && { page }
});
}
occurrence += 1;
}
}
return {
matches,
totalMatches
};
};
const findStoryTextMatches = (text, story, query, matchCase, wholeWord) => {
const matches = [];
let totalMatches = 0;
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const expression = new RegExp(escapedQuery, matchCase ? "gu" : "giu");
for (const match of text.matchAll(expression)) {
const at = match.index;
const matchedText = match[0];
if (wholeWord && (WORD_CHARACTER_AT_END.test(text.slice(Math.max(0, at - WORD_BOUNDARY_WINDOW), at)) || WORD_CHARACTER_AT_START.test(text.slice(at + matchedText.length, at + matchedText.length + WORD_BOUNDARY_WINDOW)))) continue;
if (matches.length < MAX_FIND_MATCHES) matches.push({
type: "story",
story,
startOffset: at,
endOffset: at + matchedText.length,
occurrenceInStory: totalMatches,
context: sliceText({
text,
units: "codeUnits",
start: Math.max(0, at - CONTEXT_RADIUS),
end: Math.min(text.length, at + matchedText.length + CONTEXT_RADIUS)
})
});
totalMatches += 1;
}
return {
matches,
totalMatches
};
};
const findText = (args, bridge) => {
if (!isPlainObject(args)) return fail("find_text expects an object with a non-empty `query` string.");
const query = args["query"];
const matchCase = args["matchCase"];
const wholeWord = args["wholeWord"];
if (!isNonEmptyString(query)) return fail("find_text requires a non-empty string `query`.");
if (query.length > MAX_QUERY_LENGTH) return fail(`find_text's \`query\` is ${query.length.toLocaleString()} characters, over the ${MAX_QUERY_LENGTH.toLocaleString()}-character limit; shorten it.`);
if (matchCase !== void 0 && typeof matchCase !== "boolean") return fail("find_text's `matchCase` must be a boolean when provided.");
if (wholeWord !== void 0 && typeof wholeWord !== "boolean") return fail("find_text's `wholeWord` must be a boolean when provided.");
const scope = args["scope"];
let blocks = bridge.snapshot().blocks.filter(isFolioAIContentBlock);
let getTargetPage;
let pageFilter;
if (scope !== void 0) {
if (!isPlainObject(scope) || !isNonEmptyString(scope["type"])) return fail("find_text's `scope` must be a document, section, page, or story scope.");
if (scope["type"] === "section") {
const handle = decodeSectionHandle(scope["handle"]);
if (handle === null) return fail("find_text's section scope requires a handle from get_document_outline.");
const section = readFolioDocumentSection(bridge.snapshot(), handle);
if (section.status !== "found") return fail("The scoped section is stale or missing; run get_document_outline again.");
blocks = section.section.blocks;
} else if (scope["type"] === "page") {
const page = scope["page"];
if (typeof page !== "number" || !Number.isInteger(page) || page < 1) return fail("find_text's page scope requires an integer `page` >= 1.");
if (!bridge.getTargetPage) return fail("This editor surface cannot search by page without live pagination.");
const pageCount = bridge.getPageCount?.();
if (pageCount !== void 0 && page > pageCount) return fail(`find_text's page (${page}) exceeds the document's page count (${pageCount}).`);
getTargetPage = bridge.getTargetPage;
pageFilter = page;
} else if (scope["type"] === "story") {
const handle = decodeStoryHandle(scope["handle"]);
if (handle === null) return fail("find_text's story scope requires a handle from list_stories.");
if (handle.type !== "main") {
if (!bridge.readStory) return fail("This editor surface does not support story search.");
const story = bridge.readStory(handle);
if (story === null) return fail("The scoped story was not found; run list_stories again.");
const found = findStoryTextMatches(story.text, handle, query, matchCase === true, wholeWord === true);
return ok({
matches: found.matches,
truncated: found.totalMatches > found.matches.length,
totalMatches: found.totalMatches
});
}
} else if (scope["type"] !== "document") return fail("find_text's `scope.type` must be document, section, page, or story.");
}
const { matches, totalMatches } = findTextMatches(blocks, query, matchCase === true, wholeWord === true, getTargetPage, pageFilter);
const result = {
matches,
truncated: totalMatches > matches.length,
totalMatches
};
return ok(result);
};
const isCommentFilter = (value) => value === "all" || value === "open" || value === "resolved";
const readComments = (args, bridge) => {
const filter = isPlainObject(args) ? args["filter"] : void 0;
if (filter !== void 0 && !isCommentFilter(filter)) return fail("read_comments' `filter` must be one of \"all\", \"open\", \"resolved\" when provided.");
const comments = bridge.getComments();
if (filter === void 0 || filter === "all") return ok(comments);
const wantResolved = filter === "resolved";
return ok(comments.filter((comment) => comment.resolved === wantResolved));
};
/**
* Turn a `FolioAIEditSkipReason` into a plain-language reason a model can act
* on — what to change and retry, not just a machine code.
*/
const explainSkipReason = (reason) => {
if (reason === "missingBlock") return "blockId not found; re-read the document (read_document or find_text) and retry with a fresh block id.";
if (reason === "changedBlock") return "the block changed since your snapshot; re-read the document and retry with fresh ids.";
if (reason === "ambiguousFind") return "`find` matches more than once in this block; narrow it so it matches exactly once, or use a replaceBlock operation instead.";
if (reason === "missingFind") return "`find` was not found in this block; re-read the block's current text and retry.";
if (reason === "unsupportedBlock") return "this block does not support that operation; use read_document to inspect its kind and embedded content, then choose an operation for its text or containing structure. If none fits, tell the user this edit cannot be applied.";
if (reason === "unsupportedMode") return "this operation does not support the requested mutation mode; inspect document operation capabilities and retry with a supported mode.";
if (reason === "atomicBatchRejected") return "another operation in this atomic batch could not be applied; no operations were committed.";
if (reason === "preconditionFailed") return "the target block changed after the operation was prepared; re-read the document and retry with a fresh operation.";
if (reason === "staleRange") return "the selected range changed or shifted; run find_text again and retry with the fresh range.";
if (reason === "emptyOperation") return "this operation has no effect; nothing to apply.";
if (reason === "pendingParagraphPropertyChange") return "this paragraph already has an unresolved tracked formatting change; accept or reject that change before retrying.";
if (reason === "noopOperation") return "this operation would not change the document (the text already matches what you asked for).";
if (reason === "documentVersionMismatch") return "the document changed after these edits were proposed; re-read it and regenerate the edits against the current version.";
if (reason === "protectedReference") return "the edit would change or remove a footnote/endnote reference (a `[^1]` / `[^e1]` marker), or write a marker as text; a reference is not text. Keep every marker your `find` covers in the replacement, in order, or match only the words beside it.";
if (reason === "documentNotEditable") return "the document is not open for editing right now; nothing was applied or queued. Ask the user to open it for editing, then retry.";
if (reason === "payloadDoesNotFit") return "the operation supplies more values than its target has cells for; nothing was applied. Supply fewer cellTexts, or anchor the row or column where no merged cell takes a slot.";
if (reason === "splitsCharacter") return "an offset falls inside a single character (between the two halves of an emoji or other surrogate pair, or, for a text change or split, between a letter and its combining marks or inside an emoji sequence); nothing was applied. Move the offset to the boundary before or after that character and retry.";
if (reason === "overlappingOperation") return "an earlier operation in this batch already deletes, rewrites, splits or merges this block, or edits an overlapping stretch of its text; nothing of this operation was applied. Re-read the document after this batch and send it again on its own.";
if (reason === "missingStyle") return "`styleId` names no paragraph style this document defines (it is undefined, or a table or character style); nothing was applied. Use a paragraph style the document defines (the ones it offers are named after this reason), or `null` to clear the style.";
if (reason === "pendingDeletion") return "this block is pending deletion (a tracked change deletes it; it reads as a blank block); nothing was applied, since text written there would join the next paragraph once the deletion is accepted. Reject that deletion first, or insert a new block next to it (insertAfterBlock / insertBeforeBlock).";
if (reason === "pendingParagraphMarkDeletion") return "this paragraph's mark is pending tracked deletion; resolve that change before editing its properties separately, or provide mergedParagraphProperties on the merge operation.";
if (reason === "missingNumbering") return "`numbering` names a numbering instance this document does not define; nothing was applied. Re-read the document and use a `{ kind: \"reference\", numId, ilvl? }` the document defines (the one a neighbouring list item carries), or choose another numbering state.";
if (reason === "invalidResult") return "applying this operation would have left a document that cannot be saved, so nothing from it was applied. Do not retry it as written; re-read the document and express the change differently.";
return reason;
};
const explainSkippedOperation = ({ reason, message }) => message === void 0 ? explainSkipReason(reason) : `${explainSkipReason(reason)} (${message})`;
/**
* Turn one apply-time `FolioAIEditNormalization` (an automatic adjustment the
* applier made to an operation's input, e.g. splitting a multi-line
* `insertAfterBlock` / `insertBeforeBlock` `text` into several paragraphs)
* into the same `{ path, message }` shape lenient decoding reports, so the
* model sees every normalization in one list regardless of where it happened.
*/
const explainApplyNormalization = (normalization) => {
if (normalization.code === "splitMultilineText") return {
path: `operations[id=${normalization.id}].text`,
message: `\`text\` contained line breaks; split into ${normalization.paragraphCount} consecutive paragraphs (one per non-blank line) instead of one paragraph with embedded newlines.`
};
if (normalization.code === "uniformReplacementFormatting") return {
path: `operations[id=${normalization.id}]`,
message: "the replaced text carried more than one formatting, link or comment; the new text takes the formatting of the first character it replaces and every link and comment over the replaced text. Check the result if part of it should be formatted differently."
};
return {
path: `operations[id=${normalization.id}].moveId`,
message: `\`moveId\` "${normalization.moveId}" did not name exactly one deletion and one insertion in this batch, so the operation applied as an ordinary insertion or deletion rather than half of a move.`
};
};
const summarizeApplyResult = (result, normalizations) => ({
version: result.version,
applied: result.applied.map((entry) => ({ id: entry.id })),
queued: (result.queued ?? []).map((entry) => ({ id: entry.id })),
replayed: [],
skipped: result.skipped.map((entry) => ({
id: entry.id,
reason: explainSkippedOperation(entry)
})),
issues: result.issues ?? [],
receipts: result.receipts ?? [],
normalizations: [...normalizations, ...(result.normalizations ?? []).map(explainApplyNormalization)]
});
/** Every operation skipped for one batch-wide reason, before anything reached the bridge. */
const rejectBatch = (operations, reason, normalizations) => {
const skipped = operations.map(({ id }) => ({
id,
reason
}));
return summarizeApplyResult({
version: FOLIO_DOCUMENT_OPERATION_CONTRACT_VERSION,
applied: [],
skipped,
issues: getFolioDocumentOperationIssues(operations, skipped),
receipts: []
}, normalizations);
};
const applyOperations = (bridge, { operations, precondition, normalizations = [] }) => {
const snapshot = bridge.snapshot();
const guardedOperations = [];
for (const operation of operations) {
if (operation.precondition !== void 0) {
guardedOperations.push(operation);
continue;
}
const blockId = operation.type === "replaceRange" || operation.type === "commentOnRange" || operation.type === "formatRange" ? operation.range.blockId : operation.blockId;
const blockTextHash = snapshot.anchors[blockId]?.textHash;
guardedOperations.push({
...operation,
...blockTextHash !== void 0 && { precondition: { blockTextHash } }
});
}
return summarizeApplyResult(bridge.applyDocumentOperations(prepareFolioAgentDocumentOperationBatch({
operations: guardedOperations,
...bridge.documentOperationMode !== void 0 && { mode: bridge.documentOperationMode },
...precondition !== void 0 && { precondition }
})), normalizations);
};
const addComment = (args, bridge) => {
const parsed = parseAddCommentInput(args);
if (!parsed.ok) return fail(parsed.error);
return ok(applyOperations(bridge, { operations: [parsed.operation] }));
};
const operationLedgers = /* @__PURE__ */ new WeakMap();
const ledgerOf = (bridge) => {
const session = getOperationSession(bridge);
const existing = operationLedgers.get(session);
if (existing !== void 0) return existing;
const created = /* @__PURE__ */ new Map();
operationLedgers.set(session, created);
return created;
};
const canonicalJson = (value) => {
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
if (isPlainObject(value)) return `{${Object.keys(value).toSorted().map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(",")}}`;
return JSON.stringify(value) ?? "null";
};
/**
* Split a call into operations to apply and operations the session already
* applied or queued under the same id. An id resent with a different
* operation is the caller's mistake, not a retry, and refuses the call.
*/
const partitionReplays = (operations, ledger) => {
const fresh = [];
const replays = [];
for (const [index, operation] of operations.entries()) {
const recorded = ledger.get(operation.id);
if (recorded === void 0) {
fresh.push(operation);
continue;
}
if (recorded.fingerprint !== canonicalJson(operation)) return `operations[${String(index)}].id "${operation.id}" already names a different operation this document applied. Give a new operation a new id, or omit ids to have them generated.`;
replays.push({
index,
id: operation.id,
receipt: recorded.receipt
});
}
return {
fresh,
replays
};
};
/** Remember what landed. A skipped operation is not recorded, so resending it tries again. */
const recordSettled = ({ ledger, operations, summary }) => {
const settled = new Set([...summary.applied, ...summary.queued].map(({ id }) => id));
for (const operation of operations) {
if (!settled.has(operation.id)) continue;
ledger.set(operation.id, {
fingerprint: canonicalJson(operation),
receipt: summary.receipts.find(({ operationId }) => operationId === operation.id)
});
}
};
/**
* The summary of the operations that ran, with the replayed ones added back:
* listed under `replayed`, their original receipts restored, and every index
* restated against the call as sent rather than the subset that ran.
*/
const withReplays = ({ summary, operations, replays }) => {
if (replays.length === 0) return summary;
const inputIndex = new Map(operations.map(({ id }, index) => [id, index]));
const indexOf = (id) => inputIndex.get(id) ?? panic("A result names an operation the call did not send", { id });
const receipts = [];
for (const receipt of summary.receipts) receipts.push({
...receipt,
operationIndex: indexOf(receipt.operationId)
});
for (const { index, receipt } of replays) if (receipt !== void 0) receipts.push({
...receipt,
operationIndex: index
});
return {
...summary,
replayed: replays.map(({ id }) => ({ id })),
receipts: receipts.toSorted((left, right) => left.operationIndex - right.operationIndex),
issues: summary.issues.map((issue) => {
const index = indexOf(issue.operationId);
return {
...issue,
operationIndex: index,
path: `$.operations[${index}]`
};
})
};
};
/**
* A version-pinned batch is compared against the bridge's document version
* BEFORE any operation is applied or queued: an approval that runs after
* the document moved on skips as a whole instead of landing half of its
* edits on a document the model never saw. A surface without a version
* notion cannot honour the pin and refuses rather than guessing.
*/
const suggestChanges = (args, bridge, options) => {
const parsed = parseSuggestChangesInput(args, options.suggestChanges);
if (!parsed.ok) return fail(parsed.error);
if (parsed.precondition !== void 0 && !bridge.getDocumentVersion) return fail("This editor surface cannot verify `documentVersion`; it has no document version to compare against.");
const ledger = ledgerOf(bridge);
const partitioned = partitionReplays(parsed.operations, ledger);
if (typeof partitioned === "string") return fail(partitioned);
const { fresh, replays } = partitioned;
const applyFresh = () => {
if (fresh.length === 0) return summarizeApplyResult({
version: FOLIO_DOCUMENT_OPERATION_CONTRACT_VERSION,
applied: [],
skipped: [],
issues: [],
receipts: []
}, parsed.normalizations);
if (parsed.precondition !== void 0 && bridge.getDocumentVersion?.() !== parsed.precondition.documentVersion) return rejectBatch(fresh, "documentVersionMismatch", parsed.normalizations);
return applyOperations(bridge, {
operations: fresh,
...parsed.precondition !== void 0 && { precondition: parsed.precondition },
normalizations: parsed.normalizations
});
};
const summary = applyFresh();
recordSettled({
ledger,
operations: fresh,
summary
});
return ok(withReplays({
summary,
operations: parsed.operations,
replays
}));
};
const replyComment = (args, bridge) => {
if (!isPlainObject(args)) return fail("reply_comment expects an object with `commentId` and `text` strings.");
const commentId = args["commentId"];
const text = args["text"];
if (!isNonEmptyString(commentId)) return fail("reply_comment requires a non-empty string `commentId`.");
if (!isNonEmptyString(text)) return fail("reply_comment requires a non-empty string `text`.");
if (text.length > 1e5) return fail(explainTextTooLong("reply_comment's `text`", text.length));
const decodedHandlers = getDecodedCommentHandlers(bridge);
if (decodedHandlers === void 0) return bridge.replyToComment(commentId, text) ? ok({ replied: true }) : fail(`No comment with id "${commentId}" was found.`);
const decodedCommentId = decodeCommentId(commentId);
if (decodedCommentId === null) return fail("reply_comment's `commentId` must be a comment id from `read_comments`.");
if (!decodedHandlers.replyToComment(decodedCommentId, text)) return fail(`No comment with id "${commentId}" was found.`);
return ok({ replied: true });
};
const resolveComment = (args, bridge) => {
if (!isPlainObject(args)) return fail("resolve_comment expects an object with a `commentId` string.");
const commentId = args["commentId"];
const reopen = args["reopen"];
if (!isNonEmptyString(commentId)) return fail("resolve_comment requires a non-empty string `commentId`.");
if (reopen !== void 0 && typeof reopen !== "boolean") return fail("resolve_comment's `reopen` must be a boolean when provided.");
const resolved = reopen !== true;
const decodedHandlers = getDecodedCommentHandlers(bridge);
if (decodedHandlers === void 0) return bridge.resolveComment(commentId, resolved) ? ok({ resolved }) : fail(`No comment with id "${commentId}" was found.`);
const decodedCommentId = decodeCommentId(commentId);
if (decodedCommentId === null) return fail("resolve_comment's `commentId` must be a comment id from `read_comments`.");
if (!decodedHandlers.resolveComment(decodedCommentId, resolved)) return fail(`No comment with id "${commentId}" was found.`);
return ok({ resolved });
};
const readPage = (args, bridge) => {
if (!bridge.getPageText) return fail("This editor surface does not support read_page (no live paginated view).");
const page = isPlainObject(args) ? args["page"] : void 0;
if (typeof page !== "number" || !Number.isInteger(page) || page < 1) return fail("read_page requires an integer `page` >= 1.");
const pageCount = bridge.getPageCount?.();
if (pageCount !== void 0 && page > pageCount) return fail(`read_page's \`page\` (${page}) exceeds the document's page count (${pageCount}).`);
return ok({
page,
...pageCount !== void 0 && { totalPages: pageCount },
text: bridge.getPageText(page)
});
};
const readSelection = (bridge) => {
if (!bridge.getSelectionText) return fail("This editor surface does not support read_selection (no live selection).");
return ok({ text: bridge.getSelectionText() });
};
const scrollToBlock = (args, bridge) => {
if (!bridge.scrollToBlock) return fail("This editor surface does not support scroll_to_block (no live editor view).");
const blockId = isPlainObject(args) ? args["blockId"] : void 0;
if (!isNonEmptyString(blockId)) return fail("scroll_to_block requires a non-empty string `blockId`.");
return ok({ scrolled: bridge.scrollToBlock(blockId) });
};
const showInDocument = (args, bridge) => {
if (!bridge.showInDocument) return fail("This editor surface does not support show_in_document (no live editor view).");
if (!isPlainObject(args)) return fail("show_in_document expects exactly one of `blockId` or `range`.");
const blockId = args["blockId"];
const rawRange = args["range"];
if (blockId === void 0 === (rawRange === void 0)) return fail("show_in_document requires exactly one of `blockId` or `range`.");
if (blockId !== void 0) {
if (!isNonEmptyString(blockId)) return fail("show_in_document's `blockId` must be a non-empty string.");
return ok({ shown: bridge.showInDocument({
type: "block",
story: "main",
blockId
}) });
}
const range = decodeMainStoryTextRangeHandle(rawRange);
if (range === null) return fail("show_in_document's `range` must be copied from find_text.");
return ok({ shown: bridge.showInDocument(range) });
};
const TOOL_HANDLERS = {
[FOLIO_AGENT_TOOL_NAMES.readDocument]: (_args, bridge) => readDocument(bridge),
[FOLIO_AGENT_TOOL_NAMES.getDocumentOutline]: getDocumentOutline,
[FOLIO_AGENT_TOOL_NAMES.readSection]: readSection,
[FOLIO_AGENT_TOOL_NAMES.listStories]: (_args, bridge) => bridge.listStories ? ok(bridge.listStories()) : fail("This editor surface does not support story discovery."),
[FOLIO_AGENT_TOOL_NAMES.readStory]: readStory,
[FOLIO_AGENT_TOOL_NAMES.findText]: findText,
[FOLIO_AGENT_TOOL_NAMES.readComments]: readComments,
[FOLIO_AGENT_TOOL_NAMES.readChanges]: (_args, bridge) => ok(bridge.getChanges()),
[FOLIO_AGENT_TOOL_NAMES.addComment]: addComment,
[FOLIO_AGENT_TOOL_NAMES.suggestChanges]: suggestChanges,
[FOLIO_AGENT_TOOL_NAMES.replyComment]: replyComment,
[FOLIO_AGENT_TOOL_NAMES.resolveComment]: resolveComment,
[FOLIO_AGENT_TOOL_NAMES.readPage]: readPage,
[FOLIO_AGENT_TOOL_NAMES.readSelection]: (_args, bridge) => readSelection(bridge),
[FOLIO_AGENT_TOOL_NAMES.scrollToBlock]: scrollToBlock,
[FOLIO_AGENT_TOOL_NAMES.showInDocument]: showInDocument
};
const isFolioAgentToolName = (name) => Object.hasOwn(TOOL_HANDLERS, name);
//#endregion
export { executeFolioToolCall, executeFolioToolCallUntyped };