UNPKG

@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.

468 lines (467 loc) 25.1 kB
import { getDecodedCommentHandlers } 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, normalizeFolioAIBlockText, readFolioDocumentSection } from "@stll/folio-core/server"; //#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)); /** `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(); return ok(blocks.map((block) => ({ blockId: block.id, kind: block.kind, text: block.text, blockTextHash: blockTextHashOf(block.text) }))); }; 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(({ id, kind, text }) => ({ blockId: id, kind, text, blockTextHash: blockTextHashOf(text) })), 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; 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; 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: block.text.slice(contextStart, 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: text.slice(Math.max(0, at - CONTEXT_RADIUS), 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; 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 kind does not support this operation."; 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 === "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 === "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."; return reason; }; /** * 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.` }; return { path: `operations[id=${normalization.id}]`, message: `input was normalized (${normalization.code}).` }; }; const summarizeApplyResult = (result, normalizations) => ({ version: result.version, applied: result.applied.map((entry) => ({ id: entry.id })), queued: (result.queued ?? []).map((entry) => ({ id: entry.id })), skipped: result.skipped.map((entry) => ({ id: entry.id, reason: explainSkipReason(entry.reason) })), 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] })); }; /** * 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) { if (!bridge.getDocumentVersion) return fail("This editor surface cannot verify `documentVersion`; it has no document version to compare against."); if (bridge.getDocumentVersion() !== parsed.precondition.documentVersion) return ok(rejectBatch(parsed.operations, "documentVersionMismatch", parsed.normalizations)); } return ok(applyOperations(bridge, { operations: parsed.operations, ...parsed.precondition !== void 0 && { precondition: parsed.precondition }, normalizations: parsed.normalizations })); }; 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 };