@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.
360 lines (359 loc) • 15.4 kB
JavaScript
import { resolveSuggestChangesOptions } from "./suggest-changes-options.js";
import { FOLIO_DOCUMENT_OPERATION_CONTRACT_VERSION, FOLIO_DOCUMENT_OPERATION_KEYS_BY_TYPE, InvalidFolioDocumentOperationBatchError, parseFolioDocumentOperationBatch } from "@stll/folio-core/server";
//#region src/parse.ts
/**
* Validation-only parsers for `suggest_changes` / `add_comment` tool-call
* arguments — the canonical rules `execute.ts` runs before handing operations
* to a {@link FolioAgentBridge}, factored out so a host can validate a
* model's tool-call arguments with the exact same rules (a host review queue
* should prefer a queue bridge, see the README, but the parsers stay pure).
*
* `parseSuggestChangesInput` is a front door over the contract parser in
* `@stll/folio-core`, not a second parser: it decodes leniently (reporting
* every normalisation), enforces the agent-layer caps and the host's
* {@link FolioSuggestChangesOptions}, mints ids, wraps `comment` strings, and
* then delegates every per-operation rule to `parseFolioDocumentOperationBatch`.
*/
const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
const isNonEmptyString = (value) => typeof value === "string" && value.length > 0;
/**
* Hard caps on `suggest_changes` / `add_comment` / `reply_comment` input
* size. Without these, a single tool call could ask the bridge to apply an
* unbounded number of operations, or push an arbitrarily large string into
* the tracked-changes engine, in one shot. `execute.ts` reuses
* {@link MAX_OPERATION_TEXT_LENGTH} for `reply_comment`'s text cap too, so
* the limit stays a single number shared across every text-bearing tool.
* The per-call operation count is a host option
* (`FolioSuggestChangesOptions.maxOperations`), defaulting to
* {@link DEFAULT_MAX_OPERATIONS_PER_CALL}.
*/
const MAX_OPERATION_TEXT_LENGTH = 1e5;
const MAX_TABLE_INSERTION_CELL_TEXTS = 256;
/**
* Aggregate cap on the SUM of every text-bearing field's length across one
* `suggest_changes` call. Each field alone is bounded by
* {@link MAX_OPERATION_TEXT_LENGTH} and each call by the operation cap, but
* an operation can carry several capped fields (e.g. `comment` plus `text`),
* and a table-insertion operation can carry up to
* {@link MAX_TABLE_INSERTION_CELL_TEXTS} capped cell texts — so a
* maximally-shaped batch could still push an unbounded total into the
* tracked-changes engine in one call even though every per-field cap was
* respected. This budget bounds the running total instead.
*/
const MAX_TOTAL_OPERATION_TEXT_LENGTH = 2e6;
/** Plain-language error for a string argument over {@link MAX_OPERATION_TEXT_LENGTH}. */
const explainTextTooLong = (label, length) => `${label} is ${length.toLocaleString()} characters, over the ${MAX_OPERATION_TEXT_LENGTH.toLocaleString()}-character limit; shorten it or split it into multiple operations.`;
/** Plain-language error when the running total across all operations exceeds {@link MAX_TOTAL_OPERATION_TEXT_LENGTH}. */
const explainAggregateTextTooLong = (index) => `operations[${index}] pushes suggest_changes' combined text over the ${MAX_TOTAL_OPERATION_TEXT_LENGTH.toLocaleString()}-character aggregate limit across all operations; split the edit across multiple suggest_changes calls.`;
/** Normalized text hashes (`hashFolioAIBlockText`) look like `h` followed by base-36 digits. */
const NORMALIZED_TEXT_HASH_PATTERN = /^h[0-9a-z]+$/;
/**
* Parse an optional `precondition: { blockTextHash }` field on `add_comment`
* arguments, present when the caller echoes a `blockTextHash` returned by an
* earlier `read_document` / `read_section` / `find_text` call. Returns
* `undefined` when omitted, the parsed precondition when valid, or a
* plain-language error string otherwise.
*/
const readOperationPrecondition = (value) => {
if (value === void 0) return;
if (!isPlainObject(value)) return "`precondition` must be an object when provided.";
const blockTextHash = value["blockTextHash"];
if (!isNonEmptyString(blockTextHash) || !NORMALIZED_TEXT_HASH_PATTERN.test(blockTextHash)) return "`precondition.blockTextHash` must be a normalized block text hash, echoed from read_document / read_section / find_text.";
return { blockTextHash };
};
/** Decrement the shared budget by `length`; returns true once the aggregate cap is exceeded. */
const consumeTextBudget = (budget, length) => {
budget.remaining -= length;
return budget.remaining < 0;
};
/**
* Wrap agent-preprocessed operations in the versioned batch envelope and
* delegate the canonical contract validation to core's parser. The returned
* batch is safe to hand straight to `applyDocumentOperations`; core marks
* parsed batches internally so the downstream apply path can skip reparsing.
*/
const prepareFolioAgentDocumentOperationBatch = ({ operations, mode, atomic, dryRun, precondition }) => parseFolioDocumentOperationBatch({
version: FOLIO_DOCUMENT_OPERATION_CONTRACT_VERSION,
operations,
...mode !== void 0 && { mode },
...atomic !== void 0 && { atomic },
...dryRun !== void 0 && { dryRun },
...precondition !== void 0 && { precondition }
});
/**
* Validate `add_comment`'s raw tool-call arguments and build the
* `commentOnBlock` {@link FolioAIEditOperation} it applies. Pure: does not
* touch a bridge or document.
*/
const parseAddCommentInput = (args) => {
if (!isPlainObject(args)) return {
ok: false,
error: "add_comment expects an object with `blockId` and `text` strings."
};
const blockId = args["blockId"];
const quote = args["quote"];
const text = args["text"];
if (!isNonEmptyString(blockId)) return {
ok: false,
error: "add_comment requires a non-empty string `blockId`."
};
if (!isNonEmptyString(text)) return {
ok: false,
error: "add_comment requires a non-empty string `text`."
};
if (text.length > 1e5) return {
ok: false,
error: explainTextTooLong("add_comment's `text`", text.length)
};
if (quote !== void 0 && typeof quote !== "string") return {
ok: false,
error: "add_comment's `quote` must be a string when provided."
};
if (typeof quote === "string" && quote.length > 1e5) return {
ok: false,
error: explainTextTooLong("add_comment's `quote`", quote.length)
};
const precondition = readOperationPrecondition(args["precondition"]);
if (typeof precondition === "string") return {
ok: false,
error: `add_comment's ${precondition}`
};
return {
ok: true,
operation: {
id: "comment-1",
type: "commentOnBlock",
blockId,
comment: { text },
...quote !== void 0 ? { quote } : {},
...precondition !== void 0 ? { precondition } : {}
}
};
};
const SUGGEST_CHANGES_ARGUMENT_KEYS = ["operations", "documentVersion"];
/** String-valued operation fields that count against the text caps. */
const TEXT_FIELDS = [
"find",
"replace",
"text",
"quote",
"styleId",
"area"
];
/**
* Try to read a value the model serialised as a JSON string where an object
* or array was expected. Returns `undefined` when it is not such a string.
*/
const parseEmbeddedJson = (value) => {
if (typeof value !== "string") return;
const trimmed = value.trim();
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return;
try {
return JSON.parse(trimmed);
} catch {
return;
}
};
/**
* Lenient decode of the raw arguments: JSON-string envelopes and operations,
* `kind` for `type`, stray keys. Every normalisation is recorded so the host
* and the model can see what was tolerated. Shape errors that cannot be
* normalised come back as a plain-language string.
*/
const decodeSuggestChangesInput = (rawArgs, options) => {
const normalizations = [];
let args = rawArgs;
const embeddedArgs = parseEmbeddedJson(args);
if (isPlainObject(embeddedArgs)) {
normalizations.push({
path: "$",
message: "arguments were supplied as a JSON string"
});
args = embeddedArgs;
}
if (!isPlainObject(args)) return "suggest_changes requires an `operations` array.";
for (const key of Object.keys(args)) if (!SUGGEST_CHANGES_ARGUMENT_KEYS.includes(key)) normalizations.push({
path: key,
message: `unknown argument \`${key}\` was ignored`
});
let rawOperations = args["operations"];
const embeddedOperations = parseEmbeddedJson(rawOperations);
if (Array.isArray(embeddedOperations)) {
normalizations.push({
path: "operations",
message: "`operations` was supplied as a JSON string"
});
rawOperations = embeddedOperations;
}
if (!Array.isArray(rawOperations)) return "suggest_changes requires an `operations` array.";
if (rawOperations.length === 0) return "suggest_changes' `operations` array must not be empty.";
if (rawOperations.length > options.maxOperations) return `suggest_changes' \`operations\` array has ${rawOperations.length.toLocaleString()} entries, over the ${options.maxOperations}-operation limit; batch it across multiple suggest_changes calls.`;
const operations = [];
for (const [index, rawOperation] of rawOperations.entries()) {
const path = `operations[${index}]`;
let operation = rawOperation;
const embeddedOperation = parseEmbeddedJson(operation);
if (isPlainObject(embeddedOperation)) {
normalizations.push({
path,
message: "operation was supplied as a JSON string"
});
operation = embeddedOperation;
}
if (!isPlainObject(operation)) return `${path} must be an object.`;
let type = operation["type"];
let typeKey = "type";
if (type === void 0 && typeof operation["kind"] === "string") {
normalizations.push({
path: `${path}.kind`,
message: "`kind` was read as `type`"
});
type = operation["kind"];
typeKey = "kind";
}
if (!isAllowedOperationType(type, options)) return `${path}.type must be one of ${options.operationTypes.join(", ")}.`;
const allowedKeys = FOLIO_DOCUMENT_OPERATION_KEYS_BY_TYPE[type];
const decoded = { type };
for (const [key, value] of Object.entries(operation)) {
if (key === typeKey) continue;
if (allowedKeys.includes(key)) {
decoded[key] = value;
continue;
}
normalizations.push({
path: `${path}.${key}`,
message: `\`${key}\` does not apply to ${type} and was ignored`
});
}
operations.push(decoded);
}
return {
operations,
documentVersion: args["documentVersion"],
normalizations
};
};
const isAllowedOperationType = (value, options) => typeof value === "string" && options.operationTypes.includes(value) && Object.hasOwn(FOLIO_DOCUMENT_OPERATION_KEYS_BY_TYPE, value);
/** Enforce the per-field and aggregate text caps on one decoded operation, or explain the breach. */
const checkTextCaps = (operation, index, budget) => {
const label = (field) => `operations[${index}].${field}`;
const charge = (field, value) => {
if (typeof value !== "string") return;
if (value.length > 1e5) return explainTextTooLong(label(field), value.length);
return consumeTextBudget(budget, value.length) ? explainAggregateTextTooLong(index) : void 0;
};
for (const field of TEXT_FIELDS) {
const breach = charge(field, operation[field]);
if (breach !== void 0) return breach;
}
const comment = operation["comment"];
const commentBreach = charge("comment", isPlainObject(comment) ? comment["text"] : comment);
if (commentBreach !== void 0) return commentBreach;
const cellTexts = operation["cellTexts"];
if (Array.isArray(cellTexts)) {
if (cellTexts.length > MAX_TABLE_INSERTION_CELL_TEXTS) return `${label("cellTexts")} has ${cellTexts.length.toLocaleString()} entries, over the ${MAX_TABLE_INSERTION_CELL_TEXTS.toLocaleString()}-cell limit.`;
for (const [cellIndex, cellText] of cellTexts.entries()) {
const breach = charge(`cellTexts[${cellIndex}]`, cellText);
if (breach !== void 0) return breach;
}
}
const parties = operation["parties"];
if (Array.isArray(parties)) for (const [partyIndex, party] of parties.entries()) {
if (!isPlainObject(party)) continue;
for (const field of [
"name",
"signatory",
"title"
]) {
const breach = charge(`parties[${partyIndex}].${field}`, party[field]);
if (breach !== void 0) return breach;
}
}
};
/**
* Ids minted for operations the model left unnamed. The per-call UUID keeps
* them unique across calls so a host queue can key on them without
* rewriting; the index keeps them readable in the model's own output.
*/
const mintOperationIdPrefix = () => `op-${crypto.randomUUID()}`;
/** Turn a contract-parser failure into the model-facing message shape (`operations[0].find: expected a string.`). */
const explainContractError = (error) => {
if (error instanceof InvalidFolioDocumentOperationBatchError) return `${error.path.replace(/^\$\.?/, "")}: ${error.reason}.`;
return error instanceof Error ? error.message : String(error);
};
/**
* Validate `suggest_changes`' raw tool-call arguments and build the
* {@link FolioAIEditOperation}s it applies. Pure: does not touch a bridge or
* document. `options` must match the {@link FolioSuggestChangesOptions} the
* tool definition was built with.
*/
const parseSuggestChangesInput = (args, options) => {
const resolved = resolveSuggestChangesOptions(options);
const decoded = decodeSuggestChangesInput(args, resolved);
if (typeof decoded === "string") return {
ok: false,
error: decoded
};
let precondition;
if (resolved.documentVersion !== null) {
if (!isNonEmptyString(decoded.documentVersion)) return {
ok: false,
error: "suggest_changes requires `documentVersion`: copy the current document version from the tool schema."
};
precondition = { documentVersion: decoded.documentVersion };
} else if (decoded.documentVersion !== void 0) decoded.normalizations.push({
path: "documentVersion",
message: "`documentVersion` is not pinned on this surface and was ignored"
});
const budget = { remaining: MAX_TOTAL_OPERATION_TEXT_LENGTH };
const idPrefix = mintOperationIdPrefix();
const prepared = [];
for (const [index, operation] of decoded.operations.entries()) {
const breach = checkTextCaps(operation, index, budget);
if (breach !== void 0) return {
ok: false,
error: breach
};
if (resolved.reviewMeta === "required") {
if (operation["severity"] === void 0) return {
ok: false,
error: `operations[${index}].severity is required on this surface ("low", "medium", or "high").`
};
if (!isNonEmptyString(operation["area"])) return {
ok: false,
error: `operations[${index}].area is required on this surface: a short review area label.`
};
}
const id = operation["id"];
if (id !== void 0 && !isNonEmptyString(id)) return {
ok: false,
error: `operations[${index}].id must be a non-empty string when provided.`
};
const comment = operation["comment"];
prepared.push({
...operation,
id: id ?? `${idPrefix}-${index + 1}`,
...typeof comment === "string" && { comment: { text: comment } }
});
}
try {
const batch = parseFolioDocumentOperationBatch({
version: FOLIO_DOCUMENT_OPERATION_CONTRACT_VERSION,
operations: prepared,
...precondition !== void 0 && { precondition }
});
return {
ok: true,
operations: [...batch.operations],
...batch.precondition !== void 0 && { precondition: batch.precondition },
normalizations: decoded.normalizations
};
} catch (error) {
return {
ok: false,
error: explainContractError(error)
};
}
};
//#endregion
export { MAX_OPERATION_TEXT_LENGTH, MAX_TOTAL_OPERATION_TEXT_LENGTH, explainTextTooLong, parseAddCommentInput, parseSuggestChangesInput, prepareFolioAgentDocumentOperationBatch };