@inflexa-ai/harness 0.22.1 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.md +1 -2
  2. package/dist/agents/conversation-agent.d.ts +0 -9
  3. package/dist/app/chat-turn.js +1 -0
  4. package/dist/app/message-assembly.d.ts +3 -0
  5. package/dist/app/message-assembly.js +17 -0
  6. package/dist/contracts/chat-parts.d.ts +38 -26
  7. package/dist/contracts/content-url.d.ts +7 -41
  8. package/dist/contracts/content-url.js +7 -48
  9. package/dist/contracts/index.d.ts +2 -2
  10. package/dist/contracts/index.js +1 -1
  11. package/dist/contracts/message.d.ts +1 -1
  12. package/dist/contracts/message.js +1 -1
  13. package/dist/contracts/part-registry.js +2 -2
  14. package/dist/contracts/schemas/chat-parts.d.ts +16 -44
  15. package/dist/contracts/schemas/chat-parts.js +15 -18
  16. package/dist/contracts/schemas/index.d.ts +1 -1
  17. package/dist/contracts/schemas/index.js +1 -1
  18. package/dist/execution/run-authorizer.d.ts +1 -1
  19. package/dist/execution/run-authorizer.js +1 -1
  20. package/dist/index.d.ts +1 -3
  21. package/dist/index.js +0 -2
  22. package/dist/lib/zod-issues.d.ts +2 -3
  23. package/dist/lib/zod-issues.js +2 -3
  24. package/dist/loop/__fixtures__/scripted-provider.js +3 -1
  25. package/dist/loop/run-agent.d.ts +5 -0
  26. package/dist/loop/run-agent.js +50 -2
  27. package/dist/loop/run-to-terminal.d.ts +1 -1
  28. package/dist/loop/run-to-terminal.js +1 -1
  29. package/dist/memory/card-builders.d.ts +0 -27
  30. package/dist/memory/card-builders.js +0 -75
  31. package/dist/memory/conversation-display-backfill.d.ts +2 -4
  32. package/dist/memory/conversation-display-backfill.js +3 -9
  33. package/dist/memory/conversation-display-storage.d.ts +3 -3
  34. package/dist/memory/conversation-display-storage.js +3 -3
  35. package/dist/memory/reconstruct-cards.d.ts +5 -7
  36. package/dist/memory/reconstruct-cards.js +6 -20
  37. package/dist/memory/tool-call-integrity.d.ts +46 -0
  38. package/dist/memory/tool-call-integrity.js +92 -0
  39. package/dist/runtime/boot.js +2 -12
  40. package/dist/tools/report-session/preview-report.d.ts +2 -1
  41. package/dist/tools/report-session/preview-report.js +9 -1
  42. package/dist/tools/start-report-session.js +17 -7
  43. package/dist/workspace/paths.d.ts +0 -21
  44. package/dist/workspace/paths.js +0 -37
  45. package/package.json +1 -3
  46. package/dist/agents/report-builder.d.ts +0 -34
  47. package/dist/agents/report-builder.js +0 -34
  48. package/dist/execution/report-render.d.ts +0 -39
  49. package/dist/execution/report-render.js +0 -98
  50. package/dist/execution/report-runner.d.ts +0 -111
  51. package/dist/execution/report-runner.js +0 -230
  52. package/dist/prompts/report-builder.d.ts +0 -1
  53. package/dist/prompts/report-builder.js +0 -174
  54. package/dist/tools/iterate-report.d.ts +0 -227
  55. package/dist/tools/iterate-report.js +0 -883
  56. package/dist/tools/lib/preview-lock.d.ts +0 -20
  57. package/dist/tools/lib/preview-lock.js +0 -35
  58. package/dist/tools/lib/report-preflight.d.ts +0 -39
  59. package/dist/tools/lib/report-preflight.js +0 -283
  60. package/dist/tools/report/build-report.d.ts +0 -13
  61. package/dist/tools/report/build-report.js +0 -27
  62. package/dist/tools/report/index.d.ts +0 -15
  63. package/dist/tools/report/index.js +0 -15
  64. package/dist/tools/report/mint-preview-url.d.ts +0 -26
  65. package/dist/tools/report/mint-preview-url.js +0 -47
  66. package/dist/tools/report/preview-publisher.d.ts +0 -53
  67. package/dist/tools/report/preview-publisher.js +0 -45
  68. package/dist/tools/report/preview-snapshot.d.ts +0 -34
  69. package/dist/tools/report/preview-snapshot.js +0 -98
  70. package/dist/tools/report/submit-report.d.ts +0 -26
  71. package/dist/tools/report/submit-report.js +0 -148
  72. package/dist/tools/report/version-fs.d.ts +0 -15
  73. package/dist/tools/report/version-fs.js +0 -165
@@ -1,883 +0,0 @@
1
- /**
2
- * Report authoring tools for the conversation agent: `plan_report` + `submit_report`.
3
- *
4
- * WHY TWO TOOLS. The report brief (title/audience/sources plus the section
5
- * union — narrative/metrics/figure/table/chart, each with its own
6
- * encoding/transform/asset sub-fields) is a ~12k-char JSON schema. Every
7
- * registered tool's schema ships on EVERY conversation turn, so parking that
8
- * brief on an always-on tool would tax every turn for a tool used only when a
9
- * report is actually built. We split the single former tool in two and deliver
10
- * the heavy schema just-in-time:
11
- *
12
- * - `plan_report` — a tiny, always-on trigger (no-arg). Its `execute` RETURNS
13
- * the brief schema (`z.toJSONSchema(ReportBriefSchema)`) plus the authoring
14
- * rules as its tool RESULT, so the full contract enters context only on a
15
- * report-building turn — and thereafter rides in the cached history prefix,
16
- * not at full price on every request.
17
- * - `submit_report` — takes the composed brief and does the real work
18
- * (pre-flight staging, previewId/versioning, `runReportIteration`, preview
19
- * publishing). It accepts the brief as `unknown` so the 12k schema stays off
20
- * the always-on surface, then validates it against `ReportBriefSchema` INSIDE
21
- * `execute`, returning `{ ok: false, issues }` as DATA the model can fix (not
22
- * a thrown error). That is the same trade `validate_plan` makes: a runtime
23
- * contract in exchange for keeping the schema out of every request. The small
24
- * iterate-mode + common fields (modifications / previewId / baseVersion /
25
- * sources / format) stay fully typed — they cost little and guide iteration.
26
- *
27
- * On a valid brief, `submit_report`'s behaviour is identical to the former
28
- * `iterate_report`: two modes (Create v1 via `report`; Iterate v2+ via
29
- * `modifications` + `previewId`), pre-flight stages every source into the
30
- * preview's assets/ dir, and it emits a `data-report-preview` chat data part on
31
- * success or `data-report-preview-failed` on pre-flight/builder failure. The
32
- * hosted preview surface is reached through an injected `PreviewPublisher` seam
33
- * (managed default mints a short-lived run-authorization grant; local default
34
- * returns "unavailable"). The 4 custom report tools the builder drives are
35
- * constructed inside `runReportIteration` — see design.md Decision #9.
36
- *
37
- * Naming note: the report-builder SUB-agent has its OWN same-id `submit_report`
38
- * terminal tool (`tools/report/submit-report.ts`). The two never share a tool
39
- * roster — this one is the conversation agent's brief-submission entry point;
40
- * that one is the builder's finalize gate.
41
- */
42
- import { ok } from "neverthrow";
43
- import { z } from "zod";
44
- import { randomUUID } from "node:crypto";
45
- import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
46
- import { join } from "node:path";
47
- import { scopeResource } from "../auth/types.js";
48
- import { defineTool } from "./define-tool.js";
49
- import { latestPreviewVersion, previewDir } from "../workspace/paths.js";
50
- import { runReportIteration } from "../execution/report-runner.js";
51
- import { formatBytes, stageReportAssets } from "./lib/report-preflight.js";
52
- import { createNoopLogger } from "../lib/console-logger.js";
53
- import { hintForZodIssue, repairToolInput } from "../lib/zod-issues.js";
54
- const REPORT_TOOL_ACCESS_TTL_SECONDS = 3600;
55
- const PREVIEW_META_FILE = "preview-meta.json";
56
- async function readPreviewMeta(metaPath) {
57
- try {
58
- const raw = await readFile(metaPath, "utf8");
59
- const parsed = JSON.parse(raw);
60
- if (typeof parsed.title === "string" && (parsed.format === "html" || parsed.format === "pdf")) {
61
- return {
62
- title: parsed.title,
63
- audience: typeof parsed.audience === "string" ? parsed.audience : undefined,
64
- format: parsed.format,
65
- };
66
- }
67
- }
68
- catch {
69
- /* missing or malformed — caller falls back */
70
- }
71
- return null;
72
- }
73
- async function writePreviewMeta(metaPath, meta) {
74
- await writeFile(metaPath, JSON.stringify(meta, null, 2), "utf8");
75
- }
76
- // ── Source + section schemas ────────────────────────────────────────
77
- const SourceSchema = z.object({
78
- path: z
79
- .string()
80
- .min(1)
81
- .describe("Path relative to the analysis root (e.g. 'runs/abc/output/foo.csv'). " +
82
- "Pre-flight copies the file into the preview's assets/ dir and, for " +
83
- "CSV/TSV, parses the header + first 5 rows + total row count into the brief."),
84
- as: z
85
- .string()
86
- .min(1)
87
- .optional()
88
- .describe("Optional rename in assets/ (defaults to the source path's basename). " + "Use to disambiguate when two source paths share a basename."),
89
- });
90
- const StatItemSchema = z.object({
91
- label: z.string(),
92
- value: z.union([z.string(), z.number()]),
93
- unit: z.string().optional().describe("Unit rendered next to the value (e.g. '%', 'genes', 'kb')."),
94
- });
95
- const SortSpecSchema = z.object({
96
- column: z.string(),
97
- order: z.enum(["asc", "desc"]),
98
- });
99
- const ChartTypeSchema = z.enum(["bar", "line", "scatter", "histogram", "box", "heatmap", "pie"]);
100
- const ChartEncodingSchema = z.object({
101
- x: z.string().optional().describe("Column on the x / category axis."),
102
- y: z
103
- .union([z.string(), z.array(z.string()).min(1)])
104
- .optional()
105
- .describe("Column on the y axis; an array plots one series per column."),
106
- group: z.string().optional().describe("Column to split series (and colour) by."),
107
- value: z.string().optional().describe("Value column for pie / heatmap-shaped charts."),
108
- });
109
- const INLINE_CHART_ROW_CAP = 500;
110
- const ChartInlineDataSchema = z.object({
111
- columns: z.array(z.string()).min(1).describe("Column names of the inline rows, in render order."),
112
- rows: z
113
- .array(z.record(z.string(), z.union([z.string(), z.number(), z.null()])))
114
- .min(1)
115
- .max(INLINE_CHART_ROW_CAP)
116
- .describe("The pre-computed rows. Every value must come from a file you actually read — never fabricate, never estimate."),
117
- source: z
118
- .string()
119
- .min(1)
120
- .describe("Where the values came from — cite the real files (e.g. 'aggregated " +
121
- "significant-gene counts from runs/r1/.../sig.csv, runs/r2/.../sig.csv'). " +
122
- "The builder renders this verbatim as a footnote under the chart."),
123
- });
124
- /**
125
- * The editorial-emphasis channel every section carries. A fresh instance per
126
- * section type — Zod inlines reused schemas rather than sharing a `$defs` ref.
127
- */
128
- const intentField = () => z
129
- .string()
130
- .describe("Why this section exists and what to emphasize (e.g. 'hero — headline " +
131
- "finding', 'downplay if space is tight'). Emphasis, not layout — the " +
132
- "builder picks the components, sizing and alternation.");
133
- /** Free-text row transform — the reason to avoid launching analysis just to pre-slice a CSV. */
134
- const transformField = () => z
135
- .string()
136
- .optional()
137
- .describe("Free-text row transform the builder applies client-side to the loaded " +
138
- "asset — filter, derive, aggregate, sort (e.g. 'filter padj < 0.05 and " +
139
- "abs(log2FoldChange) > 1', 'compute -log10(padj) as neg_log_padj', 'group " +
140
- "by sample and sum count', 'sort by mean_count desc, take top 50'). Use " +
141
- "this instead of launching an ad hoc analysis to pre-slice the file. The text " +
142
- "is rendered verbatim as a provenance footnote, so the data reads as " +
143
- "processed, not fabricated.");
144
- const SectionSchema = z.discriminatedUnion("type", [
145
- z
146
- .object({
147
- type: z.literal("narrative"),
148
- title: z.string(),
149
- intent: intentField(),
150
- content: z.object({
151
- prose: z.string().min(1).describe("Markdown prose you wrote. The builder never opens your data files to write it."),
152
- }),
153
- })
154
- .describe("Prose you wrote — context, story, interpretation of the results."),
155
- z
156
- .object({
157
- type: z.literal("metrics"),
158
- title: z.string(),
159
- intent: intentField(),
160
- content: z.object({
161
- stats: z.array(StatItemSchema).min(1).describe("Headline numbers you already derived — the builder does not compute them."),
162
- }),
163
- })
164
- .describe("Labeled numbers you extracted — headline stats, at-a-glance summary."),
165
- z
166
- .object({
167
- type: z.literal("figure"),
168
- title: z.string(),
169
- intent: intentField(),
170
- content: z.object({
171
- imageAsset: z.string().min(1).describe("Staged asset name, not a path — a `sources[].as`, or the basename of a `sources[].path`."),
172
- caption: z.string().optional(),
173
- }),
174
- })
175
- .describe("A static image the analysis already produced (PNG/SVG). Prefer `chart` " +
176
- "whenever the underlying data file exists — a chart is interactive, themed " +
177
- "and re-encodable on iteration. Reach for `figure` only when the user asks " +
178
- "for the existing image, the visual is genuinely image-only (e.g. a heatmap " +
179
- "baked by an R script with no exportable data), or the image carries " +
180
- "annotations the data does not."),
181
- z
182
- .object({
183
- type: z.literal("table"),
184
- title: z.string(),
185
- intent: intentField(),
186
- content: z.object({
187
- dataAsset: z.string().min(1).describe("Staged asset name, not a path — `.csv`, `.tsv`, or `.json` (array of row objects)."),
188
- columns: z.array(z.string()).optional().describe("Column subset to render, in order. Omit to render every column."),
189
- topN: z.number().int().positive().optional().describe("Keep only the first N rows, applied after `sortBy` and `transform`."),
190
- sortBy: SortSpecSchema.optional().describe("Sort applied before `topN`."),
191
- transform: transformField(),
192
- caption: z.string().optional(),
193
- }),
194
- })
195
- .describe("A tabular asset rendered as a table. Pick a column subset and `topN` that fit the audience."),
196
- z
197
- .object({
198
- type: z.literal("chart"),
199
- title: z.string(),
200
- intent: intentField(),
201
- content: z
202
- .object({
203
- dataAsset: z
204
- .string()
205
- .min(1)
206
- .optional()
207
- .describe("Staged asset name, not a path — `.csv`, `.tsv`, or `.json` (an array " +
208
- "of row objects, or a pre-built ECharts option object the builder " +
209
- "hands straight to `setOption`)."),
210
- data: ChartInlineDataSchema.optional().describe("Escape hatch: inline rows for a cross-file aggregate you computed " +
211
- "yourself from analysis files you ACTUALLY read (e.g. significant-gene " +
212
- "counts pulled from several runs). Prefer `dataAsset`. More than one " +
213
- "inline-data chart per report is a smell — the aggregate belongs in a " +
214
- "persisted derived CSV."),
215
- chartType: ChartTypeSchema.describe("Editorial choice — bar vs scatter changes the story. Always required, " + "including for a pre-built ECharts option asset."),
216
- encoding: ChartEncodingSchema.describe("Which column feeds which channel, resolved AFTER `transform` — it may " +
217
- "name a column the transform derives. Always required: pass `{}` when " +
218
- "`dataAsset` is a pre-built ECharts option object."),
219
- topN: z.number().int().positive().optional().describe("Keep only the first N rows, applied after `sortBy` and `transform`."),
220
- sortBy: SortSpecSchema.optional().describe("Sort applied before `topN`."),
221
- transform: transformField(),
222
- caption: z.string().optional(),
223
- })
224
- .refine((v) => (v.dataAsset !== undefined) !== (v.data !== undefined), { message: "Chart content needs exactly one of `dataAsset` or `data`." })
225
- .refine((v) => !(v.data !== undefined && v.transform !== undefined), {
226
- message: "`transform` is for dataAsset only — inline `data` is already pre-computed.",
227
- }),
228
- })
229
- .describe("A tabular asset (or inline rows) rendered as an interactive ECharts plot. " +
230
- "Default to this over `figure` whenever the data file exists. To show the " +
231
- "same data as both a chart and a table, write two sections against the same " +
232
- "`dataAsset` — the file is staged once."),
233
- z
234
- .object({
235
- type: z.literal("methods"),
236
- title: z.string(),
237
- intent: intentField(),
238
- content: z.object({
239
- prose: z.string().min(1).describe("Markdown prose you wrote. The builder never opens your data files to write it."),
240
- }),
241
- })
242
- .describe("Prose you wrote — what was done, how, and with which parameters."),
243
- ]);
244
- /**
245
- * The report brief the conversation agent composes: title/audience + sources +
246
- * the section union. This is the ~12k-char schema that `plan_report` hands back
247
- * just-in-time and that `submit_report` validates its `report` field against
248
- * inside `execute`. Exported for `plan_report`, `submit_report`, and the
249
- * brief-shape tests.
250
- */
251
- export const ReportBriefSchema = z.object({
252
- title: z.string().describe("Report title. Persisted with the preview and reused as the card title on every later version."),
253
- audience: z
254
- .string()
255
- .describe("Who reads this (e.g. 'wet-lab collaborators', 'a computational-biology PI'). The builder tunes tone, density and jargon to it."),
256
- styleGuidance: z.string().optional().describe("Optional art direction for the builder (e.g. 'dense and print-ready', 'no hero imagery')."),
257
- sources: z
258
- .array(SourceSchema)
259
- .default([])
260
- .describe("Every file the report renders — CSV/TSV, PNG/SVG, JSON. A section's " +
261
- "`imageAsset` / `dataAsset` must name one of these staged assets or the " +
262
- "call fails. Do NOT list markdown or synthesis.json: those are inputs to " +
263
- "the prose you write, not to the renderer."),
264
- sections: z.array(SectionSchema).min(1).describe("The report's sections, in the order they are rendered."),
265
- });
266
- /**
267
- * `submit_report`'s always-on input surface. The heavy brief rides in `report`
268
- * as `unknown` — typed that way ON PURPOSE so the ~12k `ReportBriefSchema` does
269
- * NOT ship on every conversation turn; `execute` validates it against
270
- * `ReportBriefSchema` and returns issues as data. The iterate-mode + common
271
- * fields stay fully typed (they are small and guide iteration). Exported for
272
- * the envelope-validation tests.
273
- */
274
- export const submitReportInputSchema = z
275
- .object({
276
- previewId: z
277
- .string()
278
- .regex(/^[a-z0-9-]+$/, "previewId must be lowercase alphanumeric with dashes only")
279
- .max(64)
280
- .optional()
281
- .describe("Preview group ID. Omit for new reports (auto-generated). " + "Required for iterations — must match the existing preview."),
282
- baseVersion: z
283
- .number()
284
- .int()
285
- .min(1)
286
- .optional()
287
- .describe("Version to branch from (1-based) — use when the user prefers an earlier version. Defaults to the latest."),
288
- format: z.enum(["html"]).default("html").describe("Output format. HTML is the only format the report path produces; you can leave this unset."),
289
- report: z
290
- .unknown()
291
- .optional()
292
- .describe("CREATION ONLY (v1): the composed report brief. Get its schema + " +
293
- "authoring rules from `plan_report` first. Validated on submit — an " +
294
- "invalid brief comes back as `{ ok: false, issues }`, not a thrown error. " +
295
- "Mutually exclusive with `modifications`."),
296
- modifications: z
297
- .string()
298
- .optional()
299
- .describe("ITERATION ONLY (v2+). Natural-language change instructions. The " +
300
- "report-builder sees ONLY this text plus the previous version's template " +
301
- "— name what changes and leave everything else alone."),
302
- sources: z
303
- .array(SourceSchema)
304
- .optional()
305
- .describe("Iteration only — additional assets to stage on top of the existing " + "assets/ dir. For creation, put sources inside the brief's `sources`."),
306
- })
307
- .refine((data) => (data.report !== undefined) !== (data.modifications !== undefined), {
308
- message: "Exactly one of `report` or `modifications` must be provided.",
309
- })
310
- .refine((data) => !(data.report !== undefined && data.sources), {
311
- message: "Top-level `sources` is for iteration mode. For creation, put sources inside the brief's `sources`.",
312
- })
313
- // `baseVersion` names a version to branch from, and a creation call has
314
- // none. Left admissible it is not inert: the base-existence guard below is
315
- // scoped to iteration, so the value reaches the runner unchecked and numbers
316
- // a brand-new report `v{baseVersion + 1}`.
317
- .refine((data) => !(data.report !== undefined && data.baseVersion !== undefined), {
318
- message: "`baseVersion` is for iteration mode — a new report has no earlier version to branch from. " +
319
- "To build on an existing version, pass `modifications` plus that report's `previewId` instead of a `report` brief.",
320
- })
321
- // `previewId` names the report the change text applies to. Without one there
322
- // is nothing to iterate, and the tool would mint a fresh id — an id the
323
- // caller never chose, which its own refusal would then quote back as though
324
- // it were a real report worth retrying against.
325
- .refine((data) => !(data.modifications !== undefined && data.previewId === undefined), {
326
- message: "Iterating with `modifications` requires the `previewId` of the report to change — take it from that report's preview card. " +
327
- "To build a new report instead, pass a `report` brief and no `previewId`.",
328
- });
329
- // ── plan_report: the just-in-time authoring contract ────────────────
330
- /**
331
- * The authoring rules `plan_report` hands back alongside the brief schema.
332
- * These are the cross-field, when-to-use rules the schema's per-field
333
- * `.describe()` text cannot carry on its own — pulled from the former
334
- * `iterate_report` description and `prompts/report-builder.ts`. Guidance for
335
- * the conversation agent composing the brief, NOT for the builder.
336
- */
337
- const REPORT_AUTHORING_RULES = `# Composing a report brief
338
-
339
- The report-builder agent NEVER sees the analysis tree — it receives only the
340
- brief you compose, so the brief must be complete. You write the prose, the
341
- numbers, and the chart encodings; the builder only does layout and visual
342
- treatment. Pass the composed brief as \`submit_report\`'s \`report\` field.
343
-
344
- ## Create vs iterate
345
- - CREATE (v1): pass \`report\` (this brief), omit \`previewId\`. Every CSV / image /
346
- JSON the report renders goes in \`report.sources\` — pre-flight stages each into
347
- the preview's assets/ dir and parses its columns, first 5 rows, and row count
348
- into the brief for you.
349
- - ITERATE (v2+): pass \`modifications\` (natural-language change instructions)
350
- plus the existing \`previewId\`, and NEVER \`report\` — passing \`report\` builds a
351
- fresh report and discards all prior work. New data files go in the top-level
352
- \`sources\`. \`baseVersion\` branches from an earlier version instead of the latest.
353
-
354
- ## Sources
355
- - Every file the report renders — CSV/TSV, PNG/SVG, JSON — must be listed in
356
- \`sources\`. A section's \`imageAsset\` / \`dataAsset\` must name one of these staged
357
- assets (its \`as\`, or the basename of its \`path\`) or the call fails.
358
- - Markdown is NOT a source. Keep summary.md / synthesis.json out of \`sources\`;
359
- their content reaches the report as prose you write into \`narrative\` /
360
- \`methods\` sections.
361
-
362
- ## Do NOT launch ad hoc analysis for a section transform
363
- - Not to peek at a CSV — pre-flight already parsed its columns and head rows.
364
- - Not to filter, slice, rank, or derive columns from a single CSV about to be
365
- rendered — \`chart.content.transform\` and \`table.content.transform\` do exactly
366
- that client-side, and the transform text renders as a provenance footnote.
367
- - A durable ad hoc analysis is appropriate only when the user explicitly asks
368
- for computation no single section transform covers: cross-file aggregation,
369
- statistics needing a real numerical library, or a derived CSV the report then
370
- lists as a fresh source.
371
-
372
- ## Choosing a section type
373
- - narrative — prose you wrote: context, story, interpretation.
374
- - methods — prose you wrote: what was done, how, with which parameters.
375
- - metrics — labeled headline numbers you already derived (the builder does not
376
- compute them).
377
- - table — a tabular asset rendered as a table; pick a column subset + topN that
378
- fit the audience.
379
- - chart — a tabular asset (or inline rows) rendered as an interactive ECharts
380
- plot. PREFER chart over figure whenever the data file exists — a chart is
381
- interactive, themed, and re-encodable on iteration.
382
- - figure — a static image the analysis already produced (PNG/SVG). Reach for it
383
- only when the user asks for the existing image, the visual is genuinely
384
- image-only, or the image carries annotations the data does not.
385
-
386
- ## Inline chart data
387
- \`chart.content.data\` is an escape hatch for a cross-file aggregate you computed
388
- yourself from files you ACTUALLY read. Prefer \`dataAsset\`. Never fabricate or
389
- estimate values — every inline value must come from a file you read, and cite
390
- the real files in \`data.source\`. More than one inline-data chart per report is a
391
- smell — persist the aggregate as a derived CSV instead.
392
-
393
- ## Result
394
- \`submit_report\` returns the preview id + version and emits a preview card.
395
- Pre-flight and builder failures come back as an \`error\` string on the result,
396
- not as a thrown error. An invalid brief comes back as \`{ ok: false, issues }\` —
397
- fix the named fields and resubmit.`;
398
- /** Precomputed once at module load — the brief schema `plan_report` returns as data. */
399
- const REPORT_BRIEF_JSON_SCHEMA = z.toJSONSchema(ReportBriefSchema);
400
- /**
401
- * `plan_report` — the tiny, always-on trigger (no args). Its result delivers
402
- * the brief schema + authoring rules just-in-time, so the ~12k contract enters
403
- * context only on a report-building turn (and thereafter rides in the cached
404
- * history prefix). Compose the brief from what it returns, then call
405
- * `submit_report`. Pure logic, no deps — hence a module-scope leaf tool.
406
- */
407
- export const planReportTool = defineTool({
408
- id: "plan_report",
409
- description: "Start building or iterating a report. Returns the report-brief schema " +
410
- "and the authoring rules as its result — the full contract you compose " +
411
- "against — so it is not carried on every turn. Call this first, compose " +
412
- "the brief it describes, then call `submit_report` with it. (Iterating an " +
413
- "existing preview with `modifications` needs no brief — you may go " +
414
- "straight to `submit_report`.)",
415
- inputSchema: z.object({}),
416
- describeCall: "none",
417
- executionMode: "inline",
418
- execute: async () => ok({
419
- schema: REPORT_BRIEF_JSON_SCHEMA,
420
- rules: REPORT_AUTHORING_RULES,
421
- }),
422
- });
423
- /**
424
- * Validate a submitted brief, first repairing one that arrived double-encoded.
425
- *
426
- * Every other committing tool (`submit_plan`, `submit_synthesis`) puts its real
427
- * schema on the wire, so a JSON-encoded argument fails the tool's input schema and
428
- * the agent loop's issue-guided repair pass catches it before `execute` ever runs.
429
- * `report` rides as `z.unknown()` to keep the ~12k brief schema off the always-on
430
- * tool surface — which also means a double-encoded brief *satisfies* the input
431
- * schema, the loop sees no failure to act on, and the repair never fires. That
432
- * makes this the one committing path where the brief must be repaired locally.
433
- *
434
- * When the brief decodes but is then invalid, the issues reported are the decoded
435
- * value's, not the encoding error's — once the string has been parsed, "expected
436
- * object, received string" describes a problem this function already solved, and
437
- * would send the model chasing it instead of the fields actually missing. A brief
438
- * that does not decode at all keeps its original failure.
439
- *
440
- * `validated` is the value the returned issues are addressed to, which is the
441
- * repaired brief whenever repair happened. An issue's `path` indexes into that
442
- * value alone, so a diagnostic that walks the path — `hintForZodIssue` — must be
443
- * handed the same value; walking a decoded field's path into the still-encoded
444
- * original resolves nothing and silently yields no hint.
445
- */
446
- function acceptBrief(report) {
447
- const direct = ReportBriefSchema.safeParse(report);
448
- if (direct.success)
449
- return { parsed: direct, validated: report };
450
- const repaired = repairToolInput(report, direct.error);
451
- if (repaired === undefined)
452
- return { parsed: direct, validated: report };
453
- return { parsed: ReportBriefSchema.safeParse(repaired), validated: repaired };
454
- }
455
- /**
456
- * `submit_report` — takes the composed brief and drives the report iteration.
457
- * On a valid brief its behaviour is byte-for-byte the former `iterate_report`:
458
- * pre-flight staging, previewId/versioning, `runReportIteration`, preview
459
- * publishing. The brief rides as `unknown` on the wire (keeping its ~12k schema
460
- * off the always-on surface) and is validated against `ReportBriefSchema` here.
461
- */
462
- export function createReportSubmitTool(deps) {
463
- return defineTool({
464
- id: "submit_report",
465
- description: "Submit a composed report brief to build or iterate an HTML report " +
466
- "(the report-builder renders it; it never sees the analysis tree). Call " +
467
- "`plan_report` FIRST to get the brief schema + authoring rules, compose " +
468
- "the brief, then call this. CREATE: pass `report` (the composed brief), " +
469
- "omit `previewId`. ITERATE: pass `modifications` + the existing " +
470
- "`previewId`, and NEVER `report`. An invalid brief comes back as " +
471
- "`{ ok: false, issues }` — fix the named fields and resubmit (see " +
472
- "`plan_report` for the schema); pre-flight and builder failures come back " +
473
- "as an `error` string. Returns the preview id + version and emits a " +
474
- "preview card.",
475
- inputSchema: submitReportInputSchema,
476
- describeCall: "none",
477
- execute: async (input, ctx) => {
478
- // The brief rides as `unknown` so its ~12k schema stays off the
479
- // always-on tool surface. Validate it here and return issues as DATA
480
- // (the validate_plan trade) — never a thrown error.
481
- let brief;
482
- if (input.report !== undefined) {
483
- const { parsed, validated } = acceptBrief(input.report);
484
- if (!parsed.success) {
485
- return ok({
486
- ok: false,
487
- issues: parsed.error.issues.map((i) => ({
488
- path: i.path.join(".") || "(root)",
489
- message: i.message,
490
- hint: hintForZodIssue(i, validated),
491
- })),
492
- hint: "The `report` brief did not match the schema. Call `plan_report` for the full schema, fix the fields named in `issues`, and resubmit.",
493
- });
494
- }
495
- brief = parsed.data;
496
- }
497
- const { resourceId } = scopeResource(ctx.session.scope);
498
- const analysisRoot = deps.resolveWorkspaceRoot(resourceId);
499
- const format = input.format;
500
- const previewId = input.previewId ?? `prv-${randomUUID().slice(0, 8)}`;
501
- const previewRootAbs = join(analysisRoot, previewDir(previewId));
502
- const assetsDirAbs = join(previewRootAbs, "assets");
503
- const metaPathAbs = join(previewRootAbs, PREVIEW_META_FILE);
504
- // Iteration carries no brief: the builder's entire input is the change
505
- // text plus the base version's template. A base that is not on disk
506
- // therefore hands it a change request and nothing to change, and it
507
- // authors a fresh version while being told the previous template is
508
- // already in its working directory. Two ways to reach that state — a
509
- // preview holding no version at all, and a `baseVersion` naming one
510
- // that was never written — so the base is resolved here exactly as the
511
- // runner resolves it (`baseVersion ?? latest`) and both are refused.
512
- // Scoping to iteration is exact, not incidental: the input schema
513
- // refuses `baseVersion` alongside a brief, so a creation call carries
514
- // no base to resolve.
515
- // The check is a directory read and precedes access minting, asset
516
- // staging, and the builder, so a bad id costs no model turns and
517
- // leaves no partial state.
518
- //
519
- // A version directory implies its `report.html.j2`, so the directory
520
- // read is the whole check and no template stat is needed. What holds
521
- // that up is the builder's own finalize gate
522
- // (`tools/report/submit-report.ts`), which refuses a version whose
523
- // `report.html.j2` is absent; a version the gate never accepted is
524
- // never recorded, and the runner removes the directory it created.
525
- // Relaxing that gate is what would make this check insufficient.
526
- //
527
- // The read may also sit outside `withPreviewLock`: nothing in the
528
- // report path removes an existing version, so a concurrent iteration
529
- // can only add, and a base seen here is still there when the runner
530
- // takes the lock. Introducing version deletion makes that ordering
531
- // load-bearing.
532
- if (!brief) {
533
- let versionDirs = [];
534
- try {
535
- versionDirs = (await readdir(previewRootAbs, { withFileTypes: true })).filter((d) => d.isDirectory()).map((d) => d.name);
536
- }
537
- catch {
538
- /* no preview root — indistinguishable from one holding no versions */
539
- }
540
- const latestVersion = latestPreviewVersion(versionDirs);
541
- let reason;
542
- if (latestVersion === 0) {
543
- reason =
544
- `No report exists under previewId '${previewId}', so there is nothing to modify. ` +
545
- "Create the report first by calling `submit_report` with a `report` brief and no `previewId`, " +
546
- "or retry with the `previewId` shown on the preview card of the report you meant to change.";
547
- }
548
- else if (input.baseVersion !== undefined && !versionDirs.includes(`v${input.baseVersion}`)) {
549
- reason =
550
- `Preview '${previewId}' has no version ${input.baseVersion} to branch from — its latest version is ${latestVersion}. ` +
551
- "Retry with a `baseVersion` that exists, or omit `baseVersion` to branch from the latest version.";
552
- }
553
- if (reason !== undefined) {
554
- await ctx.emit({
555
- type: "data-report-preview-failed",
556
- data: {
557
- id: randomUUID(),
558
- previewId,
559
- version: 0,
560
- reason,
561
- errorKind: "build",
562
- },
563
- });
564
- return ok({
565
- previewId,
566
- version: 0,
567
- previewPath: "",
568
- error: reason,
569
- });
570
- }
571
- }
572
- // Iteration mode: recover the title from the creation-time meta file
573
- // so the data-report-preview part keeps the original title across versions.
574
- const existingMeta = brief ? null : await readPreviewMeta(metaPathAbs);
575
- // Build the preview-publishing seam for this iteration. The managed
576
- // default mints a 1h scoped access grant so preview calls outlive the chat
577
- // turn; the local default returns an "unavailable" publisher.
578
- const iterationId = `report-${previewId}`;
579
- let previews;
580
- try {
581
- previews = await deps.createPreviewPublisher({
582
- session: ctx.session,
583
- resourceId,
584
- runId: iterationId,
585
- previewId,
586
- ttlSeconds: REPORT_TOOL_ACCESS_TTL_SECONDS,
587
- });
588
- }
589
- catch (err) {
590
- return ok({
591
- previewId,
592
- version: 0,
593
- previewPath: "",
594
- error: `Failed to mint iteration access: ${err instanceof Error ? err.message : String(err)}`,
595
- });
596
- }
597
- // ── Pre-flight: stage declared sources into assets/. ────────
598
- const sources = brief?.sources ?? input.sources ?? [];
599
- let staged = [];
600
- if (sources.length > 0) {
601
- const result = await stageReportAssets({
602
- sources,
603
- analysisRoot,
604
- assetsDirAbs,
605
- });
606
- if (!result.ok) {
607
- await ctx.emit({
608
- type: "data-report-preview-failed",
609
- data: {
610
- id: randomUUID(),
611
- previewId,
612
- version: 0,
613
- reason: `pre-flight failed: ${result.reason}`,
614
- errorKind: "build",
615
- },
616
- });
617
- return ok({
618
- previewId,
619
- version: 0,
620
- previewPath: "",
621
- error: `Pre-flight failed: ${result.reason}`,
622
- });
623
- }
624
- staged = result.staged;
625
- }
626
- // Cross-check creation-mode briefs — every section asset reference
627
- // must be present in staged[].
628
- if (brief) {
629
- const stagedNames = new Set(staged.map((s) => s.name));
630
- const missing = collectMissingAssetRefs(brief.sections, stagedNames);
631
- if (missing.length > 0) {
632
- const reason = `section asset references not staged: ${missing.join(", ")} (pass them in report.sources)`;
633
- await ctx.emit({
634
- type: "data-report-preview-failed",
635
- data: {
636
- id: randomUUID(),
637
- previewId,
638
- version: 0,
639
- reason,
640
- errorKind: "build",
641
- },
642
- });
643
- return ok({
644
- previewId,
645
- version: 0,
646
- previewPath: "",
647
- error: reason,
648
- });
649
- }
650
- }
651
- const prompt = brief ? buildCreationPrompt(brief, format, staged) : buildModificationPrompt(input.modifications, format, staged);
652
- const result = await runReportIteration({
653
- provider: deps.provider,
654
- pool: deps.pool,
655
- model: deps.model,
656
- templatesDir: deps.templatesDir,
657
- skillsDir: deps.skillsDir,
658
- chrome: deps.chrome,
659
- usageRecorder: deps.usageRecorder,
660
- }, {
661
- resourceId,
662
- workspaceRoot: analysisRoot,
663
- previews,
664
- previewId,
665
- baseVersion: input.baseVersion,
666
- format,
667
- prompt,
668
- session: ctx.session,
669
- signal: ctx.signal,
670
- emit: ctx.emit,
671
- turnUsage: ctx.turnUsage,
672
- invocationId: ctx.invocationId,
673
- });
674
- if (!result.ok) {
675
- await ctx.emit({
676
- type: "data-report-preview-failed",
677
- data: {
678
- id: randomUUID(),
679
- previewId: result.previewId,
680
- version: result.version,
681
- reason: result.reason,
682
- errorKind: result.errorKind,
683
- },
684
- });
685
- return ok({
686
- previewId: result.previewId,
687
- version: result.version,
688
- previewPath: "",
689
- error: `Report generation failed (${result.errorKind}): ${result.reason}`,
690
- });
691
- }
692
- // Persist title on creation so iteration runs can recover it.
693
- if (brief) {
694
- try {
695
- await mkdir(previewRootAbs, { recursive: true });
696
- await writePreviewMeta(metaPathAbs, {
697
- title: brief.title,
698
- audience: brief.audience,
699
- format,
700
- });
701
- }
702
- catch (err) {
703
- const logger = (deps.logger ?? createNoopLogger()).named("submit-report");
704
- logger.warn("failed to persist preview meta", logger.errorFields(err));
705
- }
706
- }
707
- const previewTitle = brief?.title ?? existingMeta?.title ?? "Report";
708
- await ctx.emit({
709
- type: "data-report-preview",
710
- data: {
711
- id: randomUUID(),
712
- previewId: result.previewId,
713
- version: result.version,
714
- title: previewTitle,
715
- previewPath: result.previewPath,
716
- format,
717
- },
718
- });
719
- return ok({
720
- previewId: result.previewId,
721
- version: result.version,
722
- previewPath: result.previewPath,
723
- ...(result.notes.length > 0 ? { notes: result.notes } : {}),
724
- });
725
- },
726
- });
727
- }
728
- // ── Brief composition ────────────────────────────────────────────────
729
- function collectMissingAssetRefs(sections, staged) {
730
- const missing = new Set();
731
- for (const s of sections) {
732
- if (s.type === "figure" && !staged.has(s.content.imageAsset)) {
733
- missing.add(s.content.imageAsset);
734
- }
735
- else if (s.type === "table" && !staged.has(s.content.dataAsset)) {
736
- missing.add(s.content.dataAsset);
737
- }
738
- else if (s.type === "chart" && s.content.dataAsset && !staged.has(s.content.dataAsset)) {
739
- missing.add(s.content.dataAsset);
740
- }
741
- }
742
- return [...missing];
743
- }
744
- function buildCreationPrompt(report, format, staged) {
745
- const out = [];
746
- out.push(`Build a new ${format.toUpperCase()} report.`);
747
- out.push("");
748
- out.push(`**Title:** ${report.title}`);
749
- out.push(`**Audience:** ${report.audience}`);
750
- if (report.styleGuidance)
751
- out.push(`**Style:** ${report.styleGuidance}`);
752
- out.push("");
753
- out.push(stagedAssetsBlock(staged));
754
- out.push("## Sections");
755
- out.push("");
756
- out.push("Each section below carries `intent` (why it exists, what to emphasize) " +
757
- "and `content` (the data shape to render). You choose the design-system " +
758
- "components, layout, alternation, and visual treatment. Stay in the " +
759
- "Inflexa Design Blueprint — base.html.j2, components.");
760
- out.push("");
761
- for (let i = 0; i < report.sections.length; i++) {
762
- out.push(`### ${i + 1}. ${report.sections[i].title} — type: \`${report.sections[i].type}\``);
763
- out.push("");
764
- out.push(`**Intent:** ${report.sections[i].intent}`);
765
- out.push("");
766
- out.push(formatSectionContent(report.sections[i]));
767
- out.push("");
768
- }
769
- out.push("---");
770
- out.push("");
771
- out.push("Author `report.html.j2`, then call `build_report` → `preview_snapshot` → " +
772
- "`submit_report`. The brief is complete — every asset you need is in " +
773
- "`assets/`, every column is named above. Do not go looking for more.");
774
- return out.join("\n");
775
- }
776
- function buildModificationPrompt(modifications, format, staged) {
777
- const out = [];
778
- out.push(`Modify the existing ${format.toUpperCase()} report.`);
779
- out.push("");
780
- out.push("**IMPORTANT**: This is an iteration. The previous version's template " +
781
- "(`report.html.j2`) and assets are already in your working directory. " +
782
- "Read the existing template first and apply only the requested changes. " +
783
- "Do NOT rewrite from scratch. Preserve the design system, component " +
784
- "includes, section layout, and all content not mentioned below.");
785
- out.push("");
786
- if (staged.length > 0) {
787
- out.push(stagedAssetsBlock(staged, { newOnly: true }));
788
- }
789
- out.push("## Requested Changes");
790
- out.push("");
791
- out.push(modifications);
792
- out.push("");
793
- out.push("---");
794
- out.push("");
795
- out.push("Apply the changes in place, then call `build_report` → `preview_snapshot` " + "→ `submit_report`.");
796
- return out.join("\n");
797
- }
798
- /** Exported for unit tests — pipe-escape correctness. */
799
- export function stagedAssetsBlock(staged, opts = {}) {
800
- if (staged.length === 0) {
801
- return "## Staged Assets\n\n_No file assets — this report uses prose, metrics, and inline chart data only._\n";
802
- }
803
- const out = [];
804
- out.push(opts.newOnly ? "## Newly Staged Assets" : "## Staged Assets");
805
- out.push("");
806
- out.push("Already in your `assets/` dir. Reference them by name in section content. " + "Do NOT call any staging tool — the work is done.");
807
- out.push("");
808
- out.push("|name|kind|size|rows|columns|");
809
- out.push("|-|-|-|-|-|");
810
- for (const a of staged) {
811
- const rows = a.rowCount !== undefined ? String(a.rowCount) : "—";
812
- const cols = a.columns ? a.columns.join(", ") : "—";
813
- out.push(`|${a.name}|${a.kind}|${formatBytes(a.sizeBytes)}|${rows}|${cols}|`);
814
- }
815
- out.push("");
816
- for (const a of staged) {
817
- if ((a.kind === "csv" || a.kind === "tsv") && a.headRows && a.headRows.length > 0 && a.columns) {
818
- out.push(`### Head: ${a.name}`);
819
- out.push("");
820
- out.push("|" + a.columns.map(escapePipe).join("|") + "|");
821
- out.push("|" + a.columns.map(() => "-").join("|") + "|");
822
- for (const row of a.headRows) {
823
- out.push("|" + row.map(escapePipe).join("|") + "|");
824
- }
825
- out.push("");
826
- }
827
- }
828
- return out.join("\n");
829
- }
830
- function escapePipe(s) {
831
- // Escape backslashes first so a literal `\` in the cell cannot combine with
832
- // the escape we add and re-expose an unescaped `|` that breaks the table.
833
- return s.replace(/\\/g, "\\\\").replace(/\|/g, "\\|");
834
- }
835
- function formatSectionContent(s) {
836
- switch (s.type) {
837
- case "narrative":
838
- case "methods":
839
- return `**Prose:**\n\n${s.content.prose}`;
840
- case "metrics": {
841
- const lines = s.content.stats.map((st) => `- **${st.label}:** ${st.value}${st.unit ? " " + st.unit : ""}`).join("\n");
842
- return `**Stats:**\n\n${lines}`;
843
- }
844
- case "figure": {
845
- const cap = s.content.caption ? `\n**Caption:** ${s.content.caption}` : "";
846
- return `**Image:** \`assets/${s.content.imageAsset}\`${cap}`;
847
- }
848
- case "table": {
849
- const cols = s.content.columns ? `\n**Columns:** ${s.content.columns.join(", ")}` : "";
850
- const top = s.content.topN ? `\n**Top N:** ${s.content.topN}` : "";
851
- const sort = s.content.sortBy ? `\n**Sort:** ${s.content.sortBy.column} ${s.content.sortBy.order}` : "";
852
- const xform = s.content.transform ? `\n**Transform (apply client-side, render verbatim as footnote):** ${s.content.transform}` : "";
853
- const cap = s.content.caption ? `\n**Caption:** ${s.content.caption}` : "";
854
- return `**Source:** \`assets/${s.content.dataAsset}\`${cols}${top}${sort}${xform}${cap}`;
855
- }
856
- case "chart": {
857
- const out = [];
858
- if (s.content.dataAsset) {
859
- out.push(`**Source:** \`assets/${s.content.dataAsset}\``);
860
- }
861
- else if (s.content.data) {
862
- out.push(`**Inline data** (${s.content.data.rows.length} rows, columns: ${s.content.data.columns.join(", ")})`);
863
- out.push(`**Inline source:** ${s.content.data.source}`);
864
- out.push("```json");
865
- out.push(JSON.stringify(s.content.data.rows, null, 2));
866
- out.push("```");
867
- }
868
- out.push(`**Chart type:** ${s.content.chartType}`);
869
- out.push(`**Encoding:** ${JSON.stringify(s.content.encoding)}`);
870
- if (s.content.topN)
871
- out.push(`**Top N:** ${s.content.topN}`);
872
- if (s.content.sortBy) {
873
- out.push(`**Sort:** ${s.content.sortBy.column} ${s.content.sortBy.order}`);
874
- }
875
- if (s.content.transform) {
876
- out.push(`**Transform (apply client-side, render verbatim as footnote):** ${s.content.transform}`);
877
- }
878
- if (s.content.caption)
879
- out.push(`**Caption:** ${s.content.caption}`);
880
- return out.join("\n");
881
- }
882
- }
883
- }