Skip to content

Commit 8a2727b

Browse files
authored
fix(agent): bound spreadsheet and media generation (#273)
## Why Routine spreadsheet requests were falling into a multi-step Python/LibreOffice path because the native XLSX generator could not represent formulas. Image requests also shared one strict schema with video-only fields, causing model adapters to materialize incompatible placeholders and enter validation retry loops. ## What changed - add typed workbook-local formula cells with cached values and standard number formats to the bounded XLSX generator - route routine workbooks through one native publishing call while retaining the custom Office path for complex work - replace the mixed media tool with separate strict image and video capabilities - align capability policy, activity copy, system guidance, skill bundles, and architecture documentation - remove all legacy mixed-media tool references; no compatibility shim ## Architecture and migration effects - tool capability contract changes from one mixed media capability to two mode-specific capabilities - no database migration or environment change - Google AI keys remain request-scoped and lazily resolved ## Verification - `pnpm lint` - `pnpm typecheck` - `pnpm turbo build --force` - `pnpm deadcode` (existing Knip configuration hint only) - `pnpm architecture:check` - `pnpm turbo skills:build` - parsed a representative bakery workbook with total and percentage formula cells - verified an external-network spreadsheet formula is rejected - production acceptance will repeat the same ordinary-user spreadsheet and image prompts after the exact merge commit is deployed
1 parent 0d954f6 commit 8a2727b

16 files changed

Lines changed: 278 additions & 306 deletions

File tree

apps/web/src/components/chat/message-activity-model.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ const TOOL_VERBS: Record<string, ToolVerbSpec> = {
6464
docs_generate_pdf: { verb: "Generated a PDF" },
6565
docs_generate_slides: { verb: "Generated slides" },
6666
docs_generate_xlsx: { verb: "Generated a spreadsheet" },
67+
generate_or_edit_image: { verb: "Generated or edited an image" },
68+
generate_or_extend_video: { verb: "Generated or extended a video" },
6769
search_scrape: { verb: "Scraped", argKeys: ["url"] },
6870
search_web_content: { verb: "Searched the web", argKeys: ["query", "q"] },
6971
search_extract: { verb: "Extracted", argKeys: ["url"] },

packages/agent-core/README.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,11 @@ Sandbox and artifact capabilities cross tool-domain boundaries only through
2727
Single-consumer data, document, and media implementations live under
2828
`src/tools/`. Data tools profile and normalize bounded tabular inputs and render
2929
deterministic SVG/Recharts output. Document tools generate sandbox-side PPTX,
30-
DOCX, XLSX, and PDF source against `/opt/cheatcode-doc-runtime`. Media and Google-backed
31-
browser tools resolve the user's Google AI BYOK key lazily when invoked. All three receive
30+
DOCX, XLSX, and PDF source against `/opt/cheatcode-doc-runtime`; routine XLSX generation
31+
supports bounded scalar and workbook-local formula cells with cached results and standard number
32+
formats, while complex models remain on the custom Office path. Image and video generation use
33+
separate strict contracts so one mode cannot acquire the other mode's arguments. Media and
34+
Google-backed browser tools resolve the user's Google AI BYOK key lazily when invoked. All three receive
3235
sandbox and R2 artifact capabilities through request-scoped contracts; they do
3336
not read environment variables, persist credentials, or log keys.
3437
Published media is rendered automatically in the chat artifact card and is available in Files;

packages/agent-core/src/mastra/agent-tool-policy.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,10 @@ const DOCUMENT_TOOLS = [
5454
"docs_generate_xlsx",
5555
] as const satisfies readonly ToolCapabilityName[];
5656

57-
const MEDIA_TOOLS = ["generate_or_edit_media"] as const satisfies readonly ToolCapabilityName[];
57+
const MEDIA_TOOLS = [
58+
"generate_or_edit_image",
59+
"generate_or_extend_video",
60+
] as const satisfies readonly ToolCapabilityName[];
5861

5962
const RESEARCH_TOOLS = [
6063
"search_extract",

packages/agent-core/src/mastra/system-prompt.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -322,15 +322,15 @@ This project is scaffolded at the workspace root and its dev server + live previ
322322

323323
const DOCS_MODULE = `## Building documents & slides
324324
325-
Build decks and docs from scratch with the preinstalled libraries — pptxgenjs for .pptx, docx, @react-pdf/renderer, exceljs — when the request needs custom layout control, or use docs_generate_slides / docs_generate_docx / docs_generate_pdf / docs_generate_xlsx for a fast structured deliverable. Preserve exact requested counts and scope. The structured generators validate their bounded layouts, publish the file, return its canonical read-only project path, and report the authoritative slide, section, or sheet count. For routine structured output, confirm that returned count and finish; do not search for the file, convert it, start a local server, or take screenshots. Use the custom path and inspect real renders when the user explicitly requests visual QA, the layout is custom or template-driven, or the document is high-stakes. In that path, check for faint text, overflow, placeholder text, and unfilled sections; re-render only affected pages after a fix. The file lands in the Deliverables automatically — refer to it naturally ("your deck is ready below"), don't paste a download link.`;
325+
Build decks and docs from scratch with the preinstalled libraries — pptxgenjs for .pptx, docx, @react-pdf/renderer, exceljs — when the request needs custom layout control, or use docs_generate_slides / docs_generate_docx / docs_generate_pdf / docs_generate_xlsx for a fast structured deliverable. Preserve exact requested counts and scope. The XLSX generator accepts scalar cells plus typed formula cells with cached results and number formats. The structured generators validate their bounded layouts, publish the file, return its canonical read-only project path, and report the authoritative slide, section, or sheet count. For routine structured output, confirm that returned count and finish; do not search for the file, convert it, start a local server, or take screenshots. Use the custom path and inspect real renders when the user explicitly requests visual QA, the layout is custom or template-driven, or the document is high-stakes. In that path, check for faint text, overflow, placeholder text, and unfilled sections; re-render only affected pages after a fix. The file lands in the Deliverables automatically — refer to it naturally ("your deck is ready below"), don't paste a download link.`;
326326

327327
const DATA_MODULE = `## Data & analysis
328328
329329
For a quick question, compute the answer and just tell the user — a small code_run or data_analyze_csv is enough; don't build a chart or open a browser unless asked. For a real analysis, profile the data (data_analyze_csv, or pandas / Node in the sandbox), surface the key findings, and build charts (data_chart) only when they add insight. Verify: open the produced file, confirm the numbers reconcile and formulas evaluate (no #REF! / #DIV/0!), and sanity-check every chart against the data.`;
330330

331331
const MEDIA_MODULE = `## Images & video
332332
333-
Load the generate-media skill before creating or editing an image or generating or extending a video. Use generate_or_edit_media for the final asset, choose the requested aspect ratio intentionally, preserve stated invariants when editing, and refer to the resulting Deliverable naturally without pasting its download URL. A successful result is rendered automatically in the chat and can be opened in Files; do not open its sandbox path in the browser or claim that the user cannot preview it.`;
333+
Load the generate-media skill before creating or editing an image or generating or extending a video. Use generate_or_edit_image for an image and generate_or_extend_video for a video. Call the selected tool once, choose the requested aspect ratio intentionally, preserve stated invariants when editing, and refer to the resulting Deliverable naturally without pasting its download URL. A successful result is rendered automatically in the chat and can be opened in Files; do not open its sandbox path in the browser or claim that the user cannot preview it. If the provider rejects or fails the request, report that failure instead of retrying the same request or substituting a hand-authored asset.`;
334334

335335
const RESEARCH_MODULE = `## Research
336336
@@ -343,7 +343,7 @@ Pick the path that fits and load the matching skill (skill_invoke) for its full
343343
- Web or mobile app → build it in the sandbox (React / Next.js, or Expo for mobile), start the dev server, and verify it in the browser; the running app shows in the Computer panel.
344344
- Slides or documents → use the fast structured generator when it fits, or the document libraries for custom control; preserve exact counts and verify proportionately before finishing. The file lands in the Deliverables.
345345
- Data → profile it (data_analyze_csv, or pandas / Node) and chart it (data_chart) when it adds insight; verify the numbers.
346-
- Image or video → load generate-media, then use generate_or_edit_media; the asset lands in the project and Deliverables and renders automatically in chat and Files.
346+
- Image or video → load generate-media, then use generate_or_edit_image for an image or generate_or_extend_video for a video; the asset lands in the project and Deliverables and renders automatically in chat and Files.
347347
- Research → gather and cross-check real sources (search_web / firecrawl_* / research_deep); cite everything, and use the deep-research workflow when the user asks for a report so its PDF is delivered automatically.
348348
- Acting in the user's connected apps → composio_list_tools then composio_execute, only when they ask.`;
349349

packages/agent-core/src/mastra/tool-defs/document-tools.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ export const mastraDocsGenerateDocx = createTool({
4545
export const mastraDocsGenerateXlsx = createTool({
4646
id: "docs_generate_xlsx",
4747
description:
48-
"Generate an XLSX workbook from sheets, columns, and rows. Publishes it as a Deliverable and returns its exact project filePath.",
48+
"Generate an XLSX workbook from sheets, columns, scalar cells, and typed formula cells with cached results and number formats. Publishes it as a Deliverable and returns its exact project filePath; routine workbooks need no custom script or recalculation pass.",
4949
inputSchema: GenerateSpreadsheetInputSchema,
5050
outputSchema: GenerateXlsxOutputSchema,
5151
execute: async (input, context) =>

packages/agent-core/src/mastra/tool-defs/media-tools.ts

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,47 @@
11
import { createTool } from "@mastra/core/tools";
2-
import { executeGenerateOrEditMedia } from "../../tools/media/execute";
32
import {
4-
GenerateOrEditMediaInputSchema,
5-
GenerateOrEditMediaOutputSchema,
3+
executeGenerateOrEditImage,
4+
executeGenerateOrExtendVideo,
5+
} from "../../tools/media/execute";
6+
import {
7+
GenerateImageOutputSchema,
8+
GenerateOrEditImageInputSchema,
9+
GenerateOrExtendVideoInputSchema,
10+
GenerateVideoOutputSchema,
611
} from "../../tools/media/schemas";
712
import { resolveGoogleToolApiKey } from "./request-context";
813
import { requestContextFromToolContext, workspaceRuntimeFromContext } from "./tool-runtime-context";
914

10-
/** Generates or edits media after lazily resolving the user's Google AI tool key. */
11-
export const mastraGenerateOrEditMedia = createTool({
12-
id: "generate_or_edit_media",
15+
/** Generates or edits one image through a contract that cannot accept video-only fields. */
16+
export const mastraGenerateOrEditImage = createTool({
17+
id: "generate_or_edit_image",
18+
description:
19+
"Generate a new image or edit/reference provided images with Google AI. Publishes the result to the project and Deliverables in one bounded call.",
20+
inputSchema: GenerateOrEditImageInputSchema,
21+
outputSchema: GenerateImageOutputSchema,
22+
execute: async (input, context) => {
23+
const requestContext = requestContextFromToolContext(context);
24+
const googleApiKey = await resolveGoogleToolApiKey(requestContext);
25+
return executeGenerateOrEditImage(
26+
GenerateOrEditImageInputSchema.parse(input),
27+
await workspaceRuntimeFromContext(context),
28+
googleApiKey ?? "",
29+
);
30+
},
31+
});
32+
33+
/** Generates or extends one video through a contract that cannot accept image-edit fields. */
34+
export const mastraGenerateOrExtendVideo = createTool({
35+
id: "generate_or_extend_video",
1336
description:
14-
"Generate or edit an image, or generate/extend a video, using Google media models. Stores the result in the project and Deliverables.",
15-
inputSchema: GenerateOrEditMediaInputSchema,
16-
outputSchema: GenerateOrEditMediaOutputSchema,
37+
"Generate a new video or extend a provided video with Google AI. Publishes the result to the project and Deliverables in one bounded call.",
38+
inputSchema: GenerateOrExtendVideoInputSchema,
39+
outputSchema: GenerateVideoOutputSchema,
1740
execute: async (input, context) => {
1841
const requestContext = requestContextFromToolContext(context);
1942
const googleApiKey = await resolveGoogleToolApiKey(requestContext);
20-
return executeGenerateOrEditMedia(
21-
GenerateOrEditMediaInputSchema.parse(input),
43+
return executeGenerateOrExtendVideo(
44+
GenerateOrExtendVideoInputSchema.parse(input),
2245
await workspaceRuntimeFromContext(context),
2346
googleApiKey ?? "",
2447
);

packages/agent-core/src/mastra/tool-defs/registry.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ export {
3232
mastraDocsGenerateSlides,
3333
mastraDocsGenerateXlsx,
3434
} from "./document-tools";
35-
export { mastraGenerateOrEditMedia } from "./media-tools";
35+
export { mastraGenerateOrEditImage, mastraGenerateOrExtendVideo } from "./media-tools";
3636
export {
3737
mastraDeepResearch,
3838
mastraFirecrawlExtract,

packages/agent-core/src/mastra/tool-defs/tool-set.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@ import {
2424
mastraFsRead,
2525
mastraFsSearch,
2626
mastraFsWrite,
27-
mastraGenerateOrEditMedia,
27+
mastraGenerateOrEditImage,
28+
mastraGenerateOrExtendVideo,
2829
mastraGitClone,
2930
mastraGitCommit,
3031
mastraGitPush,
@@ -74,7 +75,8 @@ export const cheatcodeTools = {
7475
fs_read: mastraFsRead,
7576
fs_search: mastraFsSearch,
7677
fs_write: mastraFsWrite,
77-
generate_or_edit_media: mastraGenerateOrEditMedia,
78+
generate_or_edit_image: mastraGenerateOrEditImage,
79+
generate_or_extend_video: mastraGenerateOrExtendVideo,
7880
git_clone: mastraGitClone,
7981
git_commit: mastraGitCommit,
8082
git_push: mastraGitPush,

packages/agent-core/src/tools/docs/schemas.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,26 @@ export const GenerateMarkdownPdfInputSchema = z.strictObject({
5353
title: TextValueSchema.optional(),
5454
});
5555

56-
const SpreadsheetCellSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]);
56+
const SpreadsheetScalarSchema = z.union([
57+
z.string().max(10_000),
58+
z.number(),
59+
z.boolean(),
60+
z.null(),
61+
]);
62+
63+
const SpreadsheetFormulaSchema = z.strictObject({
64+
formula: z
65+
.string()
66+
.trim()
67+
.min(1)
68+
.max(1_000)
69+
.refine(isSafeSpreadsheetFormula, "Formula must use workbook-local values and functions."),
70+
numberFormat: z.enum(["general", "integer", "decimal", "percent", "currency"]).optional(),
71+
result: z.union([z.string().max(10_000), z.number(), z.boolean()]),
72+
type: z.literal("formula"),
73+
});
74+
75+
const SpreadsheetCellSchema = z.union([SpreadsheetScalarSchema, SpreadsheetFormulaSchema]);
5776
const SpreadsheetRowSchema = z.record(z.string().min(1).max(80), SpreadsheetCellSchema);
5877

5978
const SpreadsheetSheetSchema = z.strictObject({
@@ -107,3 +126,9 @@ export type GenerateSlidesInput = z.input<typeof GenerateSlidesInputSchema>;
107126
export type GenerateSlidesOutput = z.output<typeof GenerateSlidesOutputSchema>;
108127
export type GenerateSpreadsheetInput = z.input<typeof GenerateSpreadsheetInputSchema>;
109128
export type GenerateXlsxOutput = z.output<typeof GenerateXlsxOutputSchema>;
129+
130+
function isSafeSpreadsheetFormula(value: string): boolean {
131+
const formula = value.startsWith("=") ? value.slice(1) : value;
132+
const blockedFunction = /\b(?:CALL|DDE|EXEC|FILTERXML|HYPERLINK|RTD|WEBSERVICE)\s*\(/i;
133+
return formula.length > 0 && !/[[\]"'|@]/.test(formula) && !blockedFunction.test(formula);
134+
}

packages/agent-core/src/tools/docs/scripts.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,14 +107,34 @@ export function buildXlsxScript(inputPath: string, outputPath: string, filename:
107107
'workbook.creator = "Cheatcode";',
108108
"workbook.created = new Date();",
109109
"workbook.title = input.title;",
110+
"const numberFormats = {",
111+
' general: "General",',
112+
' integer: "#,##0",',
113+
' decimal: "#,##0.00",',
114+
' percent: "0.0%",',
115+
' currency: "$#,##0.00;($#,##0.00);-",',
116+
"};",
117+
"function cellValue(value) {",
118+
' if (!value || typeof value !== "object" || value.type !== "formula") return value;',
119+
' return { formula: value.formula.replace(/^=/, ""), result: value.result };',
120+
"}",
110121
"for (const sheet of input.sheets) {",
111122
" const worksheet = workbook.addWorksheet(sheet.name);",
112123
" worksheet.columns = sheet.columns.map((column) => ({",
113124
" header: column,",
114125
" key: column,",
115126
" width: Math.max(12, Math.min(36, column.length + 8)),",
116127
" }));",
117-
" worksheet.addRows(sheet.rows);",
128+
" for (const inputRow of sheet.rows) {",
129+
" const values = Object.fromEntries(sheet.columns.map((column) => [column, cellValue(inputRow[column])]));",
130+
" const row = worksheet.addRow(values);",
131+
" sheet.columns.forEach((column, columnIndex) => {",
132+
" const value = inputRow[column];",
133+
' if (value && typeof value === "object" && value.type === "formula" && value.numberFormat) {',
134+
" row.getCell(columnIndex + 1).numFmt = numberFormats[value.numberFormat];",
135+
" }",
136+
" });",
137+
" }",
118138
" worksheet.getRow(1).font = { bold: true };",
119139
" worksheet.views = [{ state: 'frozen', ySplit: 1 }];",
120140
"}",

0 commit comments

Comments
 (0)