/** * Coerce a capability result into a JSON value before it becomes an AI SDK tool * result. streamText validates every tool output against the ModelMessage * schema on each continuation step of a multi-tool turn, and that schema only * accepts JSON values. A raw `Date` (Drizzle `{ mode: "timestamp" }` columns) or * an `undefined` field is not a JSON value, so it throws * `AI_InvalidPromptError: messages do not match the ModelMessage schema` — but * only after a SECOND tool runs in the same turn, which is why it slips through * single-tool responses. Round-tripping through JSON turns Dates into ISO * strings and drops `undefined`, the exact shape the model (and the MCP * JSON-RPC transport) already expect. Applied at the chat tool-adapter seam so * no individual capability — present or future — can crash the model loop. */ export function toJsonSafeToolOutput(value: unknown): unknown { return JSON.parse(JSON.stringify(value ?? null)); } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } /** ActivityReport shape marker — the container whose raw `events` echo must * never ride into a chat context (see {@link toChatSafeToolOutput}). */ function isActivityReportLike( value: unknown, ): value is Record { return ( isRecord(value) && Array.isArray(value.projects) && typeof value.totalEstimatedMinutes === "number" ); } /** * Shape a capability result for the AI-chat consumer: JSON-safe (via * {@link toJsonSafeToolOutput}) AND without any raw timeline-event echo. * * An exhaustive report legitimately carries `report.events` — up to 10k full * event rows with metadata — for machine consumers (the reports page, PDF * export, MCP). Fed back into a model loop as a tool result it is a context * bomb: a real 7-day report (2,513 events) serialized to ~509k tokens and * blew Grok's 500k window from a four-word prompt. Chat is a conversation * surface, not a data export: the model reasons over the structured rollups, * the chat UI renders from them too, and the full per-event report belongs * behind a link (saveReport returns `url`). So the events echo is removed * here — at the chat adapter seam, for every present and future report-shaped * output — and replaced with a note telling the model where the full data * lives. This is dataset shaping for the consumer, not a size cap: nothing is * truncated, summary rollups remain complete, and MCP/REST callers still get * the full echo. * * Handles both nestings: `{ report: ActivityReport }` (generateReport) and * `{ report: { report: ActivityReport } }` (getReport). */ export function toChatSafeToolOutput(value: unknown): unknown { const output = toJsonSafeToolOutput(value); if (!isRecord(output)) return output; for (const container of [output, output.report]) { if (!isRecord(container)) continue; const report = container.report; if (isActivityReportLike(report) && Array.isArray(report.events)) { const count = report.events.length; delete report.events; container.eventsOmittedFromChat = count; container.eventsNote = `The ${count} raw events are not loaded into chat (the structured rollups above are complete). For full per-event detail, use saveReport and give the user its link.`; } } return output; }