@inflexa-ai/harness 0.7.2 → 0.9.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 (36) hide show
  1. package/dist/contracts/message.d.ts +6 -0
  2. package/dist/execution/report-runner.d.ts +6 -1
  3. package/dist/execution/report-runner.js +1 -0
  4. package/dist/index.d.ts +1 -1
  5. package/dist/loop/run-agent.d.ts +1 -1
  6. package/dist/loop/run-agent.js +55 -2
  7. package/dist/memory/ai-sdk-message-storage.d.ts +42 -0
  8. package/dist/memory/ai-sdk-message-storage.js +62 -0
  9. package/dist/memory/content-to-cortex.d.ts +8 -1
  10. package/dist/memory/content-to-cortex.js +36 -4
  11. package/dist/memory/thread-history.d.ts +37 -0
  12. package/dist/memory/thread-history.js +74 -7
  13. package/dist/prompts/conversation.d.ts +1 -1
  14. package/dist/prompts/conversation.js +3 -3
  15. package/dist/prompts/report-builder.d.ts +1 -1
  16. package/dist/prompts/report-builder.js +1 -1
  17. package/dist/providers/ai-sdk.d.ts +23 -0
  18. package/dist/providers/ai-sdk.js +254 -34
  19. package/dist/providers/anthropic.d.ts +2 -0
  20. package/dist/providers/anthropic.js +1 -0
  21. package/dist/providers/streaming-chat.d.ts +11 -2
  22. package/dist/providers/streaming-chat.js +31 -6
  23. package/dist/providers/types.d.ts +10 -1
  24. package/dist/tools/iterate-report.d.ts +0 -1
  25. package/dist/tools/iterate-report.js +125 -11
  26. package/dist/tools/report/mint-preview-url.d.ts +1 -1
  27. package/dist/tools/report/mint-preview-url.js +7 -5
  28. package/dist/tools/report/preview-publisher.d.ts +16 -0
  29. package/dist/tools/report/preview-publisher.js +20 -0
  30. package/dist/tools/report/preview-snapshot.d.ts +9 -1
  31. package/dist/tools/report/preview-snapshot.js +15 -1
  32. package/dist/tools/report/submit-report.d.ts +1 -0
  33. package/dist/tools/report/submit-report.js +32 -11
  34. package/dist/tools/report/version-fs.d.ts +4 -4
  35. package/dist/tools/report/version-fs.js +24 -17
  36. package/package.json +2 -1
@@ -1 +1 @@
1
- export declare const reportBuilderPrompt = "# Report Builder\n\nYou build polished HTML reports from a structured brief using Jinja2 templating with the Inflexa Design Blueprint. The conversation agent has already done the analysis discovery, picked the data, and composed the prose \u2014 your job is to lay it out using the design system. Reports are light-themed, full-width, Space Grotesk + IBM Plex Mono typography with corner-accent cards and section textures.\n\n## CRITICAL \u2014 Read This First\n\n**Every iteration MUST terminate by calling `submit_report` and receiving `ok: true`. That is the only signal the runner acts on.**\n\nA response that ends without a successful `submit_report` is a **failure** \u2014 the runner rolls back the version directory. `build_report` returning `ok: true` does NOT ship the report. `preview_snapshot` returning a screenshot does NOT ship the report. Only `submit_report` does.\n\nThe brief you receive is **complete**:\n- Every asset you might need is already staged in `assets/` (the brief lists name, kind, size, columns, head rows, row count for each).\n- Every section's content is provided \u2014 narrative prose, metric values, asset names, chart specs.\n- You **cannot see the analysis tree.** There is no discovery to do, no files to find, no columns to peek at. `workspace_search`, `list_files`, and `file_stat` are disabled.\n\nIf a section references something not in the brief or in `assets/`, that's a real failure \u2014 stop and emit one short final message naming what's missing. Do NOT attempt to find it or fabricate substitutes.\n\n## Workflow\n\n1. **Read the brief.** Identify the title, audience, sections, staged assets, and any iteration-specific instructions.\n2. **For iterations only:** read the existing `report.html.j2` first. Apply only the requested changes \u2014 surgical edits, no rewrites.\n3. **Author** `report.html.j2` using the design system. Reference staged assets directly: `<img src=\"assets/foo.png\">`, ECharts loading `assets/data.csv`, etc.\n4. **Build** with `build_report`. On Jinja error, fix and rebuild. Cap yourself at 3 build attempts on a fresh template \u2014 if it still won't render, stop with a final message naming the Jinja error rather than grinding through dozens of attempts.\n5. **Verify** with one `preview_snapshot` after build is green. Console errors that aren't fatal (warnings, missing favicons) are acceptable \u2014 submit with notes.\n6. **Submit** with `submit_report`. Pass `notes` for caveats the user should know.\n\nYou may NOT use `execute_command` \u2014 there is no shell. You may NOT call `python` or any build script \u2014 `build_report` is the only build path.\n\n## Templating model\n\n**Always use relative paths.** Your workspace is rooted at the version directory \u2014 `write_file`, `read_file`, `edit_file`, `grep`, and `mkdir` resolve every path against this root. Pass `report.html.j2`, `assets/foo.csv`, `index.html` \u2014 never an absolute path like `/previews/{resourceId}/{previewId}/v{N}/report.html.j2`. Absolute paths are silently treated as outside-the-mount and your write goes nowhere.\n\nWorking directory: `report.html.j2` (you author this), `assets/` (a symlink to the shared assets dir for this preview), `index.html` (output of build_report).\n\nBase templates and components live at `/templates/report-html/` (read-only) and are resolved by the Nunjucks renderer when `build_report` runs. Reference them via short paths:\n\n```jinja2\n{% extends \"base.html.j2\" %}\n{% include \"components/stat-card.html.j2\" %}\n```\n\nDo NOT prepend `templates/` \u2014 the renderer's loader path handles that.\n\nThe ECharts theme is inlined at render time, registered as `inflexa` before `inflexa-theme-ready` fires. Wire chart initialization with `document.addEventListener('inflexa-theme-ready', () => { ... })`.\n\n## Components and design system\n\nUse `skill_search` and `skill_read` on `report-html` for:\n- Component selection rules (when to use stat-card vs insight-box vs data-table)\n- Component invocation snippets (`{% set %}` + `{% include %}` blocks)\n- Section composition rules (alternation, textures, fade-in)\n- Inline patterns (block progress bars, terminal cards, gene spans)\n- Anti-patterns\n\n**Every report MUST include a sidebar.** Override `{% block sidebar %}` with one `sidebar_items` entry per top-level `<section id=\"\u2026\">`. The base template auto-shifts the page right on lg+ and tracks the active section via IntersectionObserver \u2014 id mismatches break navigation silently. See `SKILL.md` \"Required component invocations\" for the snippet.\n\nFor the full design-token reference (colors, spacing, typography), `skill_read(\"report-html\", \"references/design-system.md\")`.\n\n## Reading the brief\n\nThe brief is **markdown, not JSON**. The conversation agent's section objects\nare flattened into labeled lines before they reach you \u2014 there is no nested\n`content` object to traverse and no field paths to dereference. Read the labels.\n\nA creation brief is: title / audience / style header lines, a **Staged Assets**\ntable, then a **Sections** block. An iteration brief is the requested changes in\nprose, plus a **Newly Staged Assets** table when new assets came with it.\n\n### Staged Assets\n\nA table with columns `name | kind | size | rows | columns`. `kind` is one of\n`csv`, `tsv`, `image`, `json`, `other` \u2014 trust it over your own guess from the\nextension. Each CSV/TSV asset is followed by a `### Head: <name>` table of its\nfirst rows. Reference every asset as `assets/<name>`.\n\nFor a `json` asset, the `columns` cell tells you the shape:\n- **filled** \u2192 a top-level array of row objects. Treat it like a CSV: iterate rows, keys are columns.\n- **empty** \u2192 not row-shaped. Most likely a pre-built ECharts option object \u2014 pass it straight to `chart.setOption(json, true)`, no row mapping. (Very large JSON is also left un-enriched, so peek before assuming.)\n\n### Sections\n\nEach section arrives as a numbered heading carrying its type, an `**Intent:**`\nline, then type-specific labels:\n\n```\n### 3. Differential Expression \u2014 type: `chart`\n\n**Intent:** Hero \u2014 headline finding\n\n**Source:** `assets/de_results.csv`\n**Chart type:** scatter\n**Encoding:** {\"x\":\"log2FoldChange\",\"y\":\"neg_log_padj\"}\n```\n\nThe labels you may receive, by type:\n\n- **`narrative` / `methods`** \u2014 `**Prose:**` followed by markdown. Render in a prose component.\n- **`metrics`** \u2014 `**Stats:**` followed by `- **Label:** value unit` bullets. Pick a stat-card row, hero callout, or inline summary based on the intent.\n- **`figure`** \u2014 `**Image:**` with an `assets/\u2026` path; optional `**Caption:**`. Use the figure component.\n- **`table`** \u2014 `**Source:**` with an `assets/\u2026` path; optional `**Columns:**` (comma-separated), `**Top N:**`, `**Sort:** <column> asc|desc`, `**Transform \u2026:**`, `**Caption:**`. Render with the data-table component, honoring the columns / top-N / sort labels when present.\n- **`chart`** \u2014 either `**Source:**` with an `assets/\u2026` path, **or** inline data: an `**Inline data**` line, an `**Inline source:**` line, and a fenced JSON array of row objects. Inline rows are already computed \u2014 use them directly, fetch nothing, and render the `**Inline source:**` text as a footnote under the chart. Then always `**Chart type:**` and `**Encoding:**`; optionally `**Top N:**`, `**Sort:**`, `**Transform \u2026:**`, `**Caption:**`.\n\n`**Chart type:**` is always supplied \u2014 you never choose one. It is one of `bar`,\n`line`, `scatter`, `histogram`, `box`, `heatmap`, `pie`. Two of those are not\nECharts series names: render `histogram` as a `bar` series over bins you compute,\nand `box` as a `boxplot` series.\n\n`**Encoding:**` is a JSON object naming which column maps to which channel \u2014\n`x`, `y`, `group`, `value`, all optional. `y` may be a single column or an array\nof columns (multi-series).\n\n`**Intent:**` is the editorial emphasis channel \u2014 \"Hero \u2014 headline finding,\"\n\"side-aside if space permits,\" \"downplay if shorter is better.\" You choose\ncomponents, sizing, alternation, and arrangement. Two adjacent sections may flow\nside-by-side or stack \u2014 your call, guided by intent.\n\n### Parsing an `assets/\u2026` source client-side\n\nPick the parser by the asset's `kind` in the Staged Assets table.\n\n- **`csv`** \u2014 `fetch().then(r => r.text())`, then `text.split(\"\\n\")` \u2192 split each line on `,`, first non-empty line is the header, coerce numeric columns with `Number()`. Skip blank lines.\n- **`tsv`** \u2014 same as CSV but split on `\"\\t\"`. Bioinformatics tools (DESeq2, featureCounts, etc.) emit TSV by default; do NOT assume CSV when the brief says `tsv`.\n- **`json`** \u2014 `fetch().then(r => r.json())`. Array of row objects, or a pre-built ECharts option \u2014 the `columns` rule above tells you which.\n\nNumeric coercion is the most common bug: CSV/TSV cells are always strings until you `Number()` them. Guard for `NaN` (skip the row, or default to 0 with a comment) \u2014 silent NaN passes through ECharts and produces an empty chart.\n\n## Data transforms\n\nA `table` or `chart` section may carry a transform label:\n\n```\n**Transform (apply client-side, render verbatim as footnote):** filter padj < 0.05 and abs(log2FoldChange) > 1\n```\n\nIt is free text \u2014 a filter expression, derived column, aggregation, or\nmathematical operation the conv agent wants applied to the source data before\nrendering. Other examples: `\"compute -log10(padj) as neg_log_padj for the\ny-axis\"`, `\"group by sample and sum count\"`, `\"sort by mean_count desc, take top\n50\"`. Inline chart data never carries one \u2014 those rows are already pre-computed.\n\nWhen you see a transform:\n\n1. **Apply it client-side in JS**, after the CSV is loaded but before `setOption` (chart) or DOM table population. Use plain array filters, map, reduce \u2014 no extra libraries. For numeric coercion, `Number(row[col])` with NaN guards is enough.\n2. **Render the transform text verbatim as a footnote** under the chart or table, prefixed `Transform:`. Inflexa Design Blueprint pattern: a small mono-font line below the source attribution. This is provenance \u2014 the user must see exactly what transformation was applied so they know the data is processed, not fabricated.\n3. **Footnote layout:** `Source: assets/de_results.csv \u00B7 Transform: filter padj < 0.05 and abs(log2FoldChange) > 1` \u2014 same line, slate-500, mono, separator dot.\n4. **If a transform is ambiguous** (e.g. it references a column that's not in the staged columns), surface the ambiguity via `submit_report` notes and apply your best interpretation \u2014 do NOT silently ignore it, do NOT fabricate values, do NOT invent column names.\n\nInterpretation is your job. A volcano-plot section with transform `\"filter padj < 0.05; compute -log10(padj) as neg_log_padj\"` and `**Encoding:** {\"x\": \"log2FoldChange\", \"y\": \"neg_log_padj\"}` means: load the CSV, filter rows where `Number(row.padj) < 0.05`, compute `neg_log_padj = -Math.log10(Number(row.padj))`, then plot `log2FoldChange` vs `neg_log_padj`.\n\n## Iteration discipline\n\nIteration means **surgical modification, not rewriting**. The previous version's `report.html.j2` is already in your working directory. Read it first, identify the exact change, edit in place. Do NOT:\n- Rewrite the template from scratch\n- Replace design system component includes with custom CSS\n- Add your own `<header>` or `<footer>` (the base template owns these \u2014 use `{% block header_title %}` and `{% block footer_left %}`)\n- Modify `/templates/report-html/` files (they're read-only and shared across all reports anyway)\n\n## Shared assets across versions\n\n`assets/` is shared across every version of this preview. A CSV staged in v1 is reachable from v3 with no re-staging. Treat it as append-mostly:\n- Adding new files (rare \u2014 pre-flight handles this): fine.\n- Changing or deleting files: those files belong to older versions too. If you need a different variant, give it a new name (e.g. `de_v3.csv`).\n\n## Anti-patterns\n\n- Skipping `build_report` and writing `index.html` by hand.\n- Ending the iteration without calling `submit_report`. The runner rolls back the version dir \u2014 the user sees nothing.\n- Going looking for files that aren't in the brief. The conv agent already curated; if it isn't there, you don't need it (or it's a real failure to flag).\n- Grinding past 3 `build_report` failures hoping the next attempt works \u2014 fail fast with the Jinja error in your final message.\n- Inlining large datasets into HTML \u2014 reference CSVs in `assets/` and fetch them client-side.\n- Empty sections with just a caption and no content. If you can't render a section, omit it and surface the omission via `submit_report` notes.\n- Ignoring `intent`. The conv agent set it deliberately.\n- Fabricating data that isn't in the brief.\n";
1
+ export declare const reportBuilderPrompt = "# Report Builder\n\nYou build polished HTML reports from a structured brief using Jinja2 templating with the Inflexa Design Blueprint. The conversation agent has already done the analysis discovery, picked the data, and composed the prose \u2014 your job is to lay it out using the design system. Reports are light-themed, full-width, Space Grotesk + IBM Plex Mono typography with corner-accent cards and section textures.\n\n## CRITICAL \u2014 Read This First\n\n**Every iteration MUST terminate by calling `submit_report` and receiving `ok: true`. That is the only signal the runner acts on.**\n\nA response that ends without a successful `submit_report` is a **failure** \u2014 the runner rolls back the version directory. `build_report` returning `ok: true` does NOT ship the report. `preview_snapshot` returning a screenshot does NOT ship the report. Only `submit_report` does.\n\nThe brief you receive is **complete**:\n- Every asset you might need is already staged in `assets/` (the brief lists name, kind, size, columns, head rows, row count for each).\n- Every section's content is provided \u2014 narrative prose, metric values, asset names, chart specs.\n- You **cannot see the analysis tree.** There is no discovery to do, no files to find, no columns to peek at. `workspace_search`, `list_files`, and `file_stat` are disabled.\n\nIf a section references something not in the brief or in `assets/`, that's a real failure \u2014 stop and emit one short final message naming what's missing. Do NOT attempt to find it or fabricate substitutes.\n\n## Workflow\n\n1. **Read the brief.** Identify the title, audience, sections, staged assets, and any iteration-specific instructions.\n2. **For iterations only:** read the existing `report.html.j2` first. Apply only the requested changes \u2014 surgical edits, no rewrites.\n3. **Author** `report.html.j2` using the design system. Reference staged assets directly: `<img src=\"assets/foo.png\">`, ECharts loading `assets/data.csv`, etc.\n4. **Build** with `build_report`. On Jinja error, fix and rebuild. Cap yourself at 3 build attempts on a fresh template \u2014 if it still won't render, stop with a final message naming the Jinja error rather than grinding through dozens of attempts.\n5. **Verify** with one `preview_snapshot` after build is green. Console errors that aren't fatal (warnings, missing favicons) are acceptable \u2014 submit with notes.\n6. **Submit** with `submit_report`. Pass `notes` for caveats the user should know.\n\nYou may NOT use `execute_command` \u2014 there is no shell. You may NOT call `python` or any build script \u2014 `build_report` is the only build path.\n\n## Templating model\n\n**Always use relative paths.** Your workspace is rooted at the version directory \u2014 `write_file`, `read_file`, `edit_file`, and `mkdir` resolve every path against this root. Pass `report.html.j2`, `assets/foo.csv`, `index.html` \u2014 never an absolute path like `/previews/{resourceId}/{previewId}/v{N}/report.html.j2`. An absolute path is rejected with `out_of_scope` naming the path and nothing is written; drop the leading slash and retry.\n\nWorking directory: `report.html.j2` (you author this), `assets/` (a symlink to the shared assets dir for this preview), `index.html` (output of build_report).\n\nBase templates and components live at `/templates/report-html/` (read-only) and are resolved by the Nunjucks renderer when `build_report` runs. Reference them via short paths:\n\n```jinja2\n{% extends \"base.html.j2\" %}\n{% include \"components/stat-card.html.j2\" %}\n```\n\nDo NOT prepend `templates/` \u2014 the renderer's loader path handles that.\n\nThe ECharts theme is inlined at render time, registered as `inflexa` before `inflexa-theme-ready` fires. Wire chart initialization with `document.addEventListener('inflexa-theme-ready', () => { ... })`.\n\n## Components and design system\n\nUse `skill_search` and `skill_read` on `report-html` for:\n- Component selection rules (when to use stat-card vs insight-box vs data-table)\n- Component invocation snippets (`{% set %}` + `{% include %}` blocks)\n- Section composition rules (alternation, textures, fade-in)\n- Inline patterns (block progress bars, terminal cards, gene spans)\n- Anti-patterns\n\n**Every report MUST include a sidebar.** Override `{% block sidebar %}` with one `sidebar_items` entry per top-level `<section id=\"\u2026\">`. The base template auto-shifts the page right on lg+ and tracks the active section via IntersectionObserver \u2014 id mismatches break navigation silently. See `SKILL.md` \"Required component invocations\" for the snippet.\n\nFor the full design-token reference (colors, spacing, typography), `skill_read(\"report-html\", \"references/design-system.md\")`.\n\n## Reading the brief\n\nThe brief is **markdown, not JSON**. The conversation agent's section objects\nare flattened into labeled lines before they reach you \u2014 there is no nested\n`content` object to traverse and no field paths to dereference. Read the labels.\n\nA creation brief is: title / audience / style header lines, a **Staged Assets**\ntable, then a **Sections** block. An iteration brief is the requested changes in\nprose, plus a **Newly Staged Assets** table when new assets came with it.\n\n### Staged Assets\n\nA table with columns `name | kind | size | rows | columns`. `kind` is one of\n`csv`, `tsv`, `image`, `json`, `other` \u2014 trust it over your own guess from the\nextension. Each CSV/TSV asset is followed by a `### Head: <name>` table of its\nfirst rows. Reference every asset as `assets/<name>`.\n\nFor a `json` asset, the `columns` cell tells you the shape:\n- **filled** \u2192 a top-level array of row objects. Treat it like a CSV: iterate rows, keys are columns.\n- **empty** \u2192 not row-shaped. Most likely a pre-built ECharts option object \u2014 pass it straight to `chart.setOption(json, true)`, no row mapping. (Very large JSON is also left un-enriched, so peek before assuming.)\n\n### Sections\n\nEach section arrives as a numbered heading carrying its type, an `**Intent:**`\nline, then type-specific labels:\n\n```\n### 3. Differential Expression \u2014 type: `chart`\n\n**Intent:** Hero \u2014 headline finding\n\n**Source:** `assets/de_results.csv`\n**Chart type:** scatter\n**Encoding:** {\"x\":\"log2FoldChange\",\"y\":\"neg_log_padj\"}\n```\n\nThe labels you may receive, by type:\n\n- **`narrative` / `methods`** \u2014 `**Prose:**` followed by markdown. Render in a prose component.\n- **`metrics`** \u2014 `**Stats:**` followed by `- **Label:** value unit` bullets. Pick a stat-card row, hero callout, or inline summary based on the intent.\n- **`figure`** \u2014 `**Image:**` with an `assets/\u2026` path; optional `**Caption:**`. Use the figure component.\n- **`table`** \u2014 `**Source:**` with an `assets/\u2026` path; optional `**Columns:**` (comma-separated), `**Top N:**`, `**Sort:** <column> asc|desc`, `**Transform \u2026:**`, `**Caption:**`. Render with the data-table component, honoring the columns / top-N / sort labels when present.\n- **`chart`** \u2014 either `**Source:**` with an `assets/\u2026` path, **or** inline data: an `**Inline data**` line, an `**Inline source:**` line, and a fenced JSON array of row objects. Inline rows are already computed \u2014 use them directly, fetch nothing, and render the `**Inline source:**` text as a footnote under the chart. Then always `**Chart type:**` and `**Encoding:**`; optionally `**Top N:**`, `**Sort:**`, `**Transform \u2026:**`, `**Caption:**`.\n\n`**Chart type:**` is always supplied \u2014 you never choose one. It is one of `bar`,\n`line`, `scatter`, `histogram`, `box`, `heatmap`, `pie`. Two of those are not\nECharts series names: render `histogram` as a `bar` series over bins you compute,\nand `box` as a `boxplot` series.\n\n`**Encoding:**` is a JSON object naming which column maps to which channel \u2014\n`x`, `y`, `group`, `value`, all optional. `y` may be a single column or an array\nof columns (multi-series).\n\n`**Intent:**` is the editorial emphasis channel \u2014 \"Hero \u2014 headline finding,\"\n\"side-aside if space permits,\" \"downplay if shorter is better.\" You choose\ncomponents, sizing, alternation, and arrangement. Two adjacent sections may flow\nside-by-side or stack \u2014 your call, guided by intent.\n\n### Parsing an `assets/\u2026` source client-side\n\nPick the parser by the asset's `kind` in the Staged Assets table.\n\n- **`csv`** \u2014 `fetch().then(r => r.text())`, then `text.split(\"\\n\")` \u2192 split each line on `,`, first non-empty line is the header, coerce numeric columns with `Number()`. Skip blank lines.\n- **`tsv`** \u2014 same as CSV but split on `\"\\t\"`. Bioinformatics tools (DESeq2, featureCounts, etc.) emit TSV by default; do NOT assume CSV when the brief says `tsv`.\n- **`json`** \u2014 `fetch().then(r => r.json())`. Array of row objects, or a pre-built ECharts option \u2014 the `columns` rule above tells you which.\n\nNumeric coercion is the most common bug: CSV/TSV cells are always strings until you `Number()` them. Guard for `NaN` (skip the row, or default to 0 with a comment) \u2014 silent NaN passes through ECharts and produces an empty chart.\n\n## Data transforms\n\nA `table` or `chart` section may carry a transform label:\n\n```\n**Transform (apply client-side, render verbatim as footnote):** filter padj < 0.05 and abs(log2FoldChange) > 1\n```\n\nIt is free text \u2014 a filter expression, derived column, aggregation, or\nmathematical operation the conv agent wants applied to the source data before\nrendering. Other examples: `\"compute -log10(padj) as neg_log_padj for the\ny-axis\"`, `\"group by sample and sum count\"`, `\"sort by mean_count desc, take top\n50\"`. Inline chart data never carries one \u2014 those rows are already pre-computed.\n\nWhen you see a transform:\n\n1. **Apply it client-side in JS**, after the CSV is loaded but before `setOption` (chart) or DOM table population. Use plain array filters, map, reduce \u2014 no extra libraries. For numeric coercion, `Number(row[col])` with NaN guards is enough.\n2. **Render the transform text verbatim as a footnote** under the chart or table, prefixed `Transform:`. Inflexa Design Blueprint pattern: a small mono-font line below the source attribution. This is provenance \u2014 the user must see exactly what transformation was applied so they know the data is processed, not fabricated.\n3. **Footnote layout:** `Source: assets/de_results.csv \u00B7 Transform: filter padj < 0.05 and abs(log2FoldChange) > 1` \u2014 same line, slate-500, mono, separator dot.\n4. **If a transform is ambiguous** (e.g. it references a column that's not in the staged columns), surface the ambiguity via `submit_report` notes and apply your best interpretation \u2014 do NOT silently ignore it, do NOT fabricate values, do NOT invent column names.\n\nInterpretation is your job. A volcano-plot section with transform `\"filter padj < 0.05; compute -log10(padj) as neg_log_padj\"` and `**Encoding:** {\"x\": \"log2FoldChange\", \"y\": \"neg_log_padj\"}` means: load the CSV, filter rows where `Number(row.padj) < 0.05`, compute `neg_log_padj = -Math.log10(Number(row.padj))`, then plot `log2FoldChange` vs `neg_log_padj`.\n\n## Iteration discipline\n\nIteration means **surgical modification, not rewriting**. The previous version's `report.html.j2` is already in your working directory. Read it first, identify the exact change, edit in place. Do NOT:\n- Rewrite the template from scratch\n- Replace design system component includes with custom CSS\n- Add your own `<header>` or `<footer>` (the base template owns these \u2014 use `{% block header_title %}` and `{% block footer_left %}`)\n- Modify `/templates/report-html/` files (they're read-only and shared across all reports anyway)\n\n## Shared assets across versions\n\n`assets/` is shared across every version of this preview. A CSV staged in v1 is reachable from v3 with no re-staging. Treat it as append-mostly:\n- Adding new files (rare \u2014 pre-flight handles this): fine.\n- Changing or deleting files: those files belong to older versions too. If you need a different variant, give it a new name (e.g. `de_v3.csv`).\n\n## Anti-patterns\n\n- Skipping `build_report` and writing `index.html` by hand.\n- Ending the iteration without calling `submit_report`. The runner rolls back the version dir \u2014 the user sees nothing.\n- Going looking for files that aren't in the brief. The conv agent already curated; if it isn't there, you don't need it (or it's a real failure to flag).\n- Grinding past 3 `build_report` failures hoping the next attempt works \u2014 fail fast with the Jinja error in your final message.\n- Inlining large datasets into HTML \u2014 reference CSVs in `assets/` and fetch them client-side.\n- Empty sections with just a caption and no content. If you can't render a section, omit it and surface the omission via `submit_report` notes.\n- Ignoring `intent`. The conv agent set it deliberately.\n- Fabricating data that isn't in the brief.\n";
@@ -28,7 +28,7 @@ You may NOT use \`execute_command\` — there is no shell. You may NOT call \`py
28
28
 
29
29
  ## Templating model
30
30
 
31
- **Always use relative paths.** Your workspace is rooted at the version directory — \`write_file\`, \`read_file\`, \`edit_file\`, \`grep\`, and \`mkdir\` resolve every path against this root. Pass \`report.html.j2\`, \`assets/foo.csv\`, \`index.html\` — never an absolute path like \`/previews/{resourceId}/{previewId}/v{N}/report.html.j2\`. Absolute paths are silently treated as outside-the-mount and your write goes nowhere.
31
+ **Always use relative paths.** Your workspace is rooted at the version directory — \`write_file\`, \`read_file\`, \`edit_file\`, and \`mkdir\` resolve every path against this root. Pass \`report.html.j2\`, \`assets/foo.csv\`, \`index.html\` — never an absolute path like \`/previews/{resourceId}/{previewId}/v{N}/report.html.j2\`. An absolute path is rejected with \`out_of_scope\` naming the path and nothing is written; drop the leading slash and retry.
32
32
 
33
33
  Working directory: \`report.html.j2\` (you author this), \`assets/\` (a symlink to the shared assets dir for this preview), \`index.html\` (output of build_report).
34
34
 
@@ -1,10 +1,23 @@
1
1
  import { type LanguageModel } from "ai";
2
2
  import type { ResolveBilling } from "../billing/resolver.js";
3
+ import type { Logger } from "../lib/logger.js";
3
4
  import type { ChatProvider, FetchLike, ProviderCapabilities } from "./types.js";
5
+ /**
6
+ * The harness-owned retry envelope. The AI SDK's built-in retry exposes only a
7
+ * count: its 2s initial delay and ×2 factor are fixed, it caps neither the
8
+ * per-attempt delay nor the total, and it ignores a server-named `Retry-After`.
9
+ * Wrapping each wire attempt in `retryWithExponentialBackoff` with these values
10
+ * gives a bounded, jittered envelope with a hard per-attempt delay ceiling.
11
+ */
12
+ export declare const RETRY_MAX_RETRIES = 10;
13
+ export declare const RETRY_INITIAL_DELAY_MS = 2000;
14
+ export declare const RETRY_BACKOFF_FACTOR = 2;
15
+ export declare const RETRY_MAX_DELAY_MS = 30000;
4
16
  export interface AiSdkProviderDeps {
5
17
  readonly model: LanguageModel;
6
18
  readonly resolveBilling: ResolveBilling;
7
19
  readonly capabilities?: Partial<ProviderCapabilities>;
20
+ readonly logger?: Logger;
8
21
  }
9
22
  /**
10
23
  * Connection + model configuration for one `ChatProvider`, discriminated by
@@ -35,7 +48,17 @@ export type AiSdkProviderConfig = {
35
48
  export interface ConfiguredAiSdkProviderDeps {
36
49
  readonly config: AiSdkProviderConfig;
37
50
  readonly resolveBilling: ResolveBilling;
51
+ readonly logger?: Logger;
38
52
  }
53
+ /**
54
+ * The delay before the next retry attempt. A server-named `Retry-After` inside
55
+ * the envelope is honored exactly — the server pointed at a precise time, so
56
+ * jitter would only fight it. Otherwise apply full jitter over the exponential
57
+ * delay capped at `RETRY_MAX_DELAY_MS`: full jitter spreads a crowd of callers
58
+ * that failed at the same instant across the window instead of re-colliding on
59
+ * the same retry tick. `random` is injectable so the delay is testable.
60
+ */
61
+ export declare function computeRetryDelayMs(error: unknown, exponentialBackoffDelay: number, random?: () => number): number;
39
62
  export declare function createAiSdkProvider(deps: AiSdkProviderDeps): ChatProvider;
40
63
  /**
41
64
  * Realize an `AiSdkProviderConfig` into a `ChatProvider` bound to that config's
@@ -1,10 +1,23 @@
1
1
  import { generateText, streamText } from "ai";
2
2
  import { createAnthropic } from "@ai-sdk/anthropic";
3
3
  import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
4
+ import { APICallError } from "@ai-sdk/provider";
5
+ import { retryWithExponentialBackoff } from "@ai-sdk/provider-utils";
4
6
  import { ResultAsync, err, ok } from "neverthrow";
5
7
  import { scopeWorkloadId } from "../auth/types.js";
6
- import { toProviderError } from "./errors.js";
7
- const DEFAULT_MAX_RETRIES = 2;
8
+ import { createNoopLogger } from "../lib/console-logger.js";
9
+ import { classifyProviderError, toProviderError } from "./errors.js";
10
+ /**
11
+ * The harness-owned retry envelope. The AI SDK's built-in retry exposes only a
12
+ * count: its 2s initial delay and ×2 factor are fixed, it caps neither the
13
+ * per-attempt delay nor the total, and it ignores a server-named `Retry-After`.
14
+ * Wrapping each wire attempt in `retryWithExponentialBackoff` with these values
15
+ * gives a bounded, jittered envelope with a hard per-attempt delay ceiling.
16
+ */
17
+ export const RETRY_MAX_RETRIES = 10;
18
+ export const RETRY_INITIAL_DELAY_MS = 2_000;
19
+ export const RETRY_BACKOFF_FACTOR = 2;
20
+ export const RETRY_MAX_DELAY_MS = 30_000;
8
21
  function workloadOf(session) {
9
22
  return `${session.scope.kind}:${scopeWorkloadId(session.scope)}`;
10
23
  }
@@ -15,6 +28,153 @@ function isAbortError(value) {
15
28
  return true;
16
29
  return false;
17
30
  }
31
+ /**
32
+ * Locate an `APICallError` on the throwable itself or one hop down its `cause`.
33
+ * A wire failure the SDK raises is an `APICallError` directly; once rewrapped it
34
+ * sits on the wrapper's `cause`, which is as deep as a `Retry-After` header is
35
+ * ever carried.
36
+ */
37
+ function apiCallErrorOf(error) {
38
+ if (APICallError.isInstance(error))
39
+ return error;
40
+ const cause = error?.cause;
41
+ if (APICallError.isInstance(cause))
42
+ return cause;
43
+ return undefined;
44
+ }
45
+ /** Case-insensitive header lookup; `name` must already be lowercase. */
46
+ function headerValue(headers, name) {
47
+ if (headers === undefined)
48
+ return undefined;
49
+ const direct = headers[name];
50
+ if (direct !== undefined)
51
+ return direct;
52
+ for (const [key, value] of Object.entries(headers)) {
53
+ if (key.toLowerCase() === name)
54
+ return value;
55
+ }
56
+ return undefined;
57
+ }
58
+ /** Parse a header to a finite number, or `undefined` when it is not numeric. */
59
+ function finiteNumber(value) {
60
+ const trimmed = value.trim();
61
+ if (trimmed === "")
62
+ return undefined;
63
+ const n = Number(trimmed);
64
+ return Number.isFinite(n) ? n : undefined;
65
+ }
66
+ /**
67
+ * The delay a server named via `Retry-After`, in ms, or `undefined` when none
68
+ * applies. `retry-after-ms` is float ms; `retry-after` is float seconds or an
69
+ * HTTP date read relative to now. A value outside `[0, RETRY_MAX_DELAY_MS]` is
70
+ * treated as absent so the jittered exponential delay governs instead — this
71
+ * envelope does not honor a wait longer than its own ceiling verbatim.
72
+ */
73
+ function serverNamedDelayMs(error) {
74
+ const headers = apiCallErrorOf(error)?.responseHeaders;
75
+ if (headers === undefined)
76
+ return undefined;
77
+ let ms;
78
+ const retryAfterMs = headerValue(headers, "retry-after-ms");
79
+ if (retryAfterMs !== undefined)
80
+ ms = finiteNumber(retryAfterMs);
81
+ if (ms === undefined) {
82
+ const retryAfter = headerValue(headers, "retry-after");
83
+ if (retryAfter !== undefined) {
84
+ const seconds = finiteNumber(retryAfter);
85
+ if (seconds !== undefined) {
86
+ ms = seconds * 1_000;
87
+ }
88
+ else {
89
+ const at = Date.parse(retryAfter);
90
+ if (Number.isFinite(at))
91
+ ms = at - Date.now();
92
+ }
93
+ }
94
+ }
95
+ if (ms === undefined || ms < 0 || ms > RETRY_MAX_DELAY_MS)
96
+ return undefined;
97
+ return ms;
98
+ }
99
+ /**
100
+ * The delay before the next retry attempt. A server-named `Retry-After` inside
101
+ * the envelope is honored exactly — the server pointed at a precise time, so
102
+ * jitter would only fight it. Otherwise apply full jitter over the exponential
103
+ * delay capped at `RETRY_MAX_DELAY_MS`: full jitter spreads a crowd of callers
104
+ * that failed at the same instant across the window instead of re-colliding on
105
+ * the same retry tick. `random` is injectable so the delay is testable.
106
+ */
107
+ export function computeRetryDelayMs(error, exponentialBackoffDelay, random = Math.random) {
108
+ const named = serverNamedDelayMs(error);
109
+ if (named !== undefined)
110
+ return named;
111
+ return random() * Math.min(exponentialBackoffDelay, RETRY_MAX_DELAY_MS);
112
+ }
113
+ /**
114
+ * Marks a failure that came from the `ResolveBilling` seam rather than the model
115
+ * wire. The retry envelope's budget is sized for model-provider outages; the
116
+ * billing seam is a separate system whose failure must surface immediately, not
117
+ * after a multi-minute retry window — even when the underlying failure is
118
+ * connection-shaped and would otherwise read as retryable. The original throwable
119
+ * rides on `cause` so the outer catch can hand it to `toProviderError` unchanged
120
+ * and classify it byte-identically to a billing failure raised outside the
121
+ * envelope (right down to the surfaced message).
122
+ */
123
+ class BillingSeamFailure extends Error {
124
+ constructor(cause) {
125
+ super("billing resolution failed", { cause });
126
+ this.name = "BillingSeamFailure";
127
+ }
128
+ }
129
+ /**
130
+ * Resolve attribution headers for one attempt, tagging any non-abort failure as
131
+ * a `BillingSeamFailure` so the retry envelope fails fast on it. An abort is
132
+ * rethrown raw so it stays on the abort control-flow path.
133
+ */
134
+ async function resolveBillingHeaders(resolveBilling, session) {
135
+ try {
136
+ return await resolveBilling(session);
137
+ }
138
+ catch (e) {
139
+ if (isAbortError(e))
140
+ throw e;
141
+ throw new BillingSeamFailure(e);
142
+ }
143
+ }
144
+ /** Unwrap a billing-seam failure to the original throwable for classification. */
145
+ function unwrapForClassification(e) {
146
+ return e instanceof BillingSeamFailure ? e.cause : e;
147
+ }
148
+ /**
149
+ * Wrap a single wire attempt in the harness retry envelope. Built per call so
150
+ * the caller's `AbortSignal` reaches the primitive: it rethrows abort errors
151
+ * without retrying and cancels its own backoff sleep when the signal fires, so
152
+ * the retried closure adds no abort handling of its own. `shouldRetry` defers to
153
+ * the harness retryability taxonomy — but never retries a `BillingSeamFailure`,
154
+ * whose fail-fast is the whole point of the marker — and a non-retried first
155
+ * failure is rethrown untouched, preserving the exact throwable the outer catch
156
+ * classifies. `createRetryError` carries the last real failure on `cause` so
157
+ * that, once retries are exhausted, `toProviderError`'s status walk reaches the
158
+ * true HTTP status instead of stopping at a synthetic wrapper. The attempt
159
+ * counter is per-instance because each call builds its own retry.
160
+ */
161
+ function createRetry(signal, logger) {
162
+ let retryCount = 0;
163
+ return retryWithExponentialBackoff({
164
+ maxRetries: RETRY_MAX_RETRIES,
165
+ initialDelayInMs: RETRY_INITIAL_DELAY_MS,
166
+ backoffFactor: RETRY_BACKOFF_FACTOR,
167
+ abortSignal: signal,
168
+ shouldRetry: (e) => !(e instanceof BillingSeamFailure) && classifyProviderError(e).retryable,
169
+ getDelayInMs: ({ error, exponentialBackoffDelay }) => {
170
+ const delayMs = computeRetryDelayMs(error, exponentialBackoffDelay);
171
+ retryCount += 1;
172
+ logger.debug("retrying provider call", { attempt: retryCount, delayMs, ...logger.errorFields(error) });
173
+ return delayMs;
174
+ },
175
+ createRetryError: ({ message, errors }) => new Error(message, { cause: errors[errors.length - 1] }),
176
+ });
177
+ }
18
178
  /**
19
179
  * Map AI SDK token accounting onto the harness's neutral `ChatUsage`.
20
180
  *
@@ -85,51 +245,101 @@ function sanitizeMessages(messages) {
85
245
  }
86
246
  export function createAiSdkProvider(deps) {
87
247
  const capabilities = { toolCalling: deps.capabilities?.toolCalling ?? true };
248
+ const logger = (deps.logger ?? createNoopLogger()).named("providers.ai-sdk");
88
249
  function chat(req, session, signal) {
89
250
  const run = async () => {
251
+ const retry = createRetry(signal, logger);
90
252
  try {
91
- const headers = await deps.resolveBilling(session);
92
- const result = await generateText({
93
- model: deps.model,
94
- system: req.system,
95
- messages: sanitizeMessages(req.messages),
96
- tools: req.tools,
97
- toolChoice: req.toolChoice ?? "auto",
98
- stopWhen: [],
99
- maxRetries: DEFAULT_MAX_RETRIES,
100
- headers,
101
- abortSignal: signal,
102
- providerOptions: req.providerOptions,
253
+ const result = await retry(async () => {
254
+ // Attribution headers are time-limited; resolving them inside the
255
+ // retried closure keeps them fresh across a multi-minute window.
256
+ const headers = await resolveBillingHeaders(deps.resolveBilling, session);
257
+ return generateText({
258
+ model: deps.model,
259
+ system: req.system,
260
+ messages: sanitizeMessages(req.messages),
261
+ tools: req.tools,
262
+ toolChoice: req.toolChoice ?? "auto",
263
+ stopWhen: [],
264
+ // The harness envelope owns retries; leaving the SDK default in
265
+ // place would multiply attempts (10 outer × 3 inner).
266
+ maxRetries: 0,
267
+ headers,
268
+ abortSignal: signal,
269
+ providerOptions: req.providerOptions,
270
+ });
103
271
  });
104
272
  return ok(responseFromGenerate(result));
105
273
  }
106
274
  catch (e) {
107
275
  if (isAbortError(e) || signal?.aborted)
108
276
  throw e;
109
- return err(toProviderError(e, workloadOf(session)));
277
+ return err(toProviderError(unwrapForClassification(e), workloadOf(session)));
110
278
  }
111
279
  };
112
280
  return new ResultAsync(run());
113
281
  }
114
282
  async function* chatStream(req, session, signal) {
283
+ const retry = createRetry(signal, logger);
115
284
  try {
116
- const headers = await deps.resolveBilling(session);
117
- const result = streamText({
118
- model: deps.model,
119
- system: req.system,
120
- messages: sanitizeMessages(req.messages),
121
- tools: req.tools,
122
- toolChoice: req.toolChoice ?? "auto",
123
- stopWhen: [],
124
- maxRetries: DEFAULT_MAX_RETRIES,
125
- headers,
126
- abortSignal: signal,
127
- providerOptions: req.providerOptions,
285
+ // Retry covers only stream establishment: streamText defers wire errors
286
+ // to consumption, so a failure is not visible until the stream is read.
287
+ // The retried closure resolves to one of two outcomes — the stream is
288
+ // live with a first delta in hand, or it finished without yielding any
289
+ // text — and a delta is only ever yielded OUTSIDE the closure, so a
290
+ // retried attempt can never re-emit a delta the consumer already saw.
291
+ // Resolving billing inside the closure keeps attribution headers fresh
292
+ // per attempt.
293
+ const opened = await retry(async () => {
294
+ const headers = await resolveBillingHeaders(deps.resolveBilling, session);
295
+ const result = streamText({
296
+ model: deps.model,
297
+ system: req.system,
298
+ messages: sanitizeMessages(req.messages),
299
+ tools: req.tools,
300
+ toolChoice: req.toolChoice ?? "auto",
301
+ stopWhen: [],
302
+ maxRetries: 0,
303
+ headers,
304
+ abortSignal: signal,
305
+ providerOptions: req.providerOptions,
306
+ });
307
+ const iterator = result.textStream[Symbol.asyncIterator]();
308
+ const first = await iterator.next();
309
+ if (first.done) {
310
+ // A doStream promise rejection — the shape a real connection
311
+ // failure takes — does NOT surface at this first pull: streamText
312
+ // resolves it `{ done: true }` and defers the rejection to the
313
+ // deferred result promises. Awaiting them here brings that failure
314
+ // inside the retry envelope so a text-less establishment error is
315
+ // retried; a genuine text-less turn resolves them, and the terminal
316
+ // event is built from these already-resolved values without a
317
+ // second await. (These promises only settle once the stream is
318
+ // fully drained, so they can be awaited here only because the first
319
+ // pull already reported the stream done.)
320
+ return {
321
+ kind: "completed",
322
+ messages: await result.responseMessages,
323
+ finishReason: await result.finishReason,
324
+ rawFinishReason: await result.rawFinishReason,
325
+ usage: await result.usage,
326
+ };
327
+ }
328
+ return { kind: "streaming", result, iterator, firstDelta: first.value };
128
329
  });
129
- let text = "";
130
- for await (const delta of result.textStream) {
131
- text += delta;
132
- yield { type: "text-delta", text: delta };
330
+ if (opened.kind === "completed") {
331
+ const response = responseFromMessages(opened.messages, "", opened.finishReason, opened.rawFinishReason, opened.usage);
332
+ yield { type: "done", response };
333
+ return;
334
+ }
335
+ const { result, iterator, firstDelta } = opened;
336
+ let text = firstDelta;
337
+ yield { type: "text-delta", text: firstDelta };
338
+ // Drain the same iterator: past the first delta, errors propagate
339
+ // unchanged (no retry, no re-emit).
340
+ for (let next = await iterator.next(); !next.done; next = await iterator.next()) {
341
+ text += next.value;
342
+ yield { type: "text-delta", text: next.value };
133
343
  }
134
344
  const response = responseFromMessages(await result.responseMessages, text, await result.finishReason, await result.rawFinishReason, await result.usage);
135
345
  yield { type: "done", response };
@@ -137,7 +347,7 @@ export function createAiSdkProvider(deps) {
137
347
  catch (e) {
138
348
  if (isAbortError(e) || signal?.aborted)
139
349
  throw e;
140
- throw toProviderError(e, workloadOf(session));
350
+ throw toProviderError(unwrapForClassification(e), workloadOf(session));
141
351
  }
142
352
  }
143
353
  return { capabilities, chat, chatStream };
@@ -157,7 +367,12 @@ export function createConfiguredAiSdkProvider(deps) {
157
367
  apiKey: config.apiKey,
158
368
  fetch: config.fetch,
159
369
  });
160
- return createAiSdkProvider({ model: provider.chat(config.model), resolveBilling: deps.resolveBilling, capabilities: config.capabilities });
370
+ return createAiSdkProvider({
371
+ model: provider.chat(config.model),
372
+ resolveBilling: deps.resolveBilling,
373
+ capabilities: config.capabilities,
374
+ logger: deps.logger,
375
+ });
161
376
  }
162
377
  const provider = createOpenAICompatible({
163
378
  name: config.name,
@@ -165,5 +380,10 @@ export function createConfiguredAiSdkProvider(deps) {
165
380
  apiKey: config.apiKey,
166
381
  fetch: config.fetch,
167
382
  });
168
- return createAiSdkProvider({ model: provider.chatModel(config.model), resolveBilling: deps.resolveBilling, capabilities: config.capabilities });
383
+ return createAiSdkProvider({
384
+ model: provider.chatModel(config.model),
385
+ resolveBilling: deps.resolveBilling,
386
+ capabilities: config.capabilities,
387
+ logger: deps.logger,
388
+ });
169
389
  }
@@ -5,6 +5,7 @@
5
5
  * old factory name for embedders while returning the new `ChatProvider` seam.
6
6
  */
7
7
  import type { ResolveBilling } from "../billing/resolver.js";
8
+ import type { Logger } from "../lib/logger.js";
8
9
  import type { ChatProvider, FetchLike } from "./types.js";
9
10
  export interface AnthropicProviderDeps {
10
11
  readonly baseURL?: string;
@@ -12,6 +13,7 @@ export interface AnthropicProviderDeps {
12
13
  readonly model: string;
13
14
  readonly resolveBilling: ResolveBilling;
14
15
  readonly fetch?: FetchLike;
16
+ readonly logger?: Logger;
15
17
  }
16
18
  /**
17
19
  * Convenience over the `anthropic` arm of the public `AiSdkProviderConfig`
@@ -14,6 +14,7 @@ import { createConfiguredAiSdkProvider } from "./ai-sdk.js";
14
14
  export function createAnthropicProvider(deps) {
15
15
  return createConfiguredAiSdkProvider({
16
16
  resolveBilling: deps.resolveBilling,
17
+ logger: deps.logger,
17
18
  config: {
18
19
  kind: "anthropic",
19
20
  baseURL: deps.baseURL,
@@ -11,13 +11,22 @@
11
11
  * The chat route is the one caller — it is the single place token streaming
12
12
  * is wired. Sub-agent loops (planner, literature-reviewer) and workflow
13
13
  * loops run on the plain `ChatProvider`, so they never stream.
14
+ *
15
+ * This wrapper is also where a client abort stops being control flow: it sees
16
+ * every delta, so it alone can assemble the partial. On the stream's abort it
17
+ * resolves an `"aborted"` `ChatResponse` carrying that partial rather than
18
+ * re-throwing, letting the interactive turn keep what the model produced. The
19
+ * plain provider keeps throwing on abort, so durable loops are untouched.
14
20
  */
15
21
  import type { AgentChat, ChatProvider } from "./types.js";
16
22
  /**
17
23
  * Wrap a `ChatProvider` as a streaming `AgentChat`. `onText` is called once
18
24
  * per text delta, in arrival order, as a side effect of `chat`. `chat` is
19
25
  * Result-returning: it consumes the throwing `chatStream` and maps a thrown
20
- * SDK failure to `err(ProviderError)`; a client abort is re-thrown verbatim
21
- * (control-flow, never the error channel).
26
+ * SDK failure to `err(ProviderError)`. A client abort is not an error here — it
27
+ * resolves `ok` with finish reason `"aborted"` and an assistant message holding
28
+ * exactly the deltas already forwarded (empty when the abort beat the first
29
+ * delta), so the interactive turn keeps the partial reply instead of losing it
30
+ * to a throw.
22
31
  */
23
32
  export declare function createStreamingChat(provider: ChatProvider, onText: (text: string) => void): AgentChat;
@@ -11,6 +11,12 @@
11
11
  * The chat route is the one caller — it is the single place token streaming
12
12
  * is wired. Sub-agent loops (planner, literature-reviewer) and workflow
13
13
  * loops run on the plain `ChatProvider`, so they never stream.
14
+ *
15
+ * This wrapper is also where a client abort stops being control flow: it sees
16
+ * every delta, so it alone can assemble the partial. On the stream's abort it
17
+ * resolves an `"aborted"` `ChatResponse` carrying that partial rather than
18
+ * re-throwing, letting the interactive turn keep what the model produced. The
19
+ * plain provider keeps throwing on abort, so durable loops are untouched.
14
20
  */
15
21
  import { ResultAsync, err, ok } from "neverthrow";
16
22
  import { scopeWorkloadId } from "../auth/types.js";
@@ -19,8 +25,11 @@ import { toProviderError } from "./errors.js";
19
25
  * Wrap a `ChatProvider` as a streaming `AgentChat`. `onText` is called once
20
26
  * per text delta, in arrival order, as a side effect of `chat`. `chat` is
21
27
  * Result-returning: it consumes the throwing `chatStream` and maps a thrown
22
- * SDK failure to `err(ProviderError)`; a client abort is re-thrown verbatim
23
- * (control-flow, never the error channel).
28
+ * SDK failure to `err(ProviderError)`. A client abort is not an error here — it
29
+ * resolves `ok` with finish reason `"aborted"` and an assistant message holding
30
+ * exactly the deltas already forwarded (empty when the abort beat the first
31
+ * delta), so the interactive turn keeps the partial reply instead of losing it
32
+ * to a throw.
24
33
  */
25
34
  export function createStreamingChat(provider, onText) {
26
35
  return {
@@ -29,9 +38,11 @@ export function createStreamingChat(provider, onText) {
29
38
  const workload = `${session.scope.kind}:${scopeWorkloadId(session.scope)}`;
30
39
  const run = async () => {
31
40
  let final;
41
+ let partial = "";
32
42
  try {
33
43
  for await (const event of provider.chatStream(req, session, signal)) {
34
44
  if (event.type === "text-delta") {
45
+ partial += event.text;
35
46
  onText(event.text);
36
47
  }
37
48
  else {
@@ -40,10 +51,24 @@ export function createStreamingChat(provider, onText) {
40
51
  }
41
52
  }
42
53
  catch (e) {
43
- if (e instanceof DOMException && e.name === "AbortError")
44
- throw e;
45
- if (e instanceof Error && e.name === "AbortError")
46
- throw e;
54
+ // A client abort resolves with what streamed so far, never through
55
+ // the error channel: the interactive turn persists the partial. The
56
+ // gate here must be at least as wide as the underlying provider's
57
+ // re-throw gate — an abort-named error OR an already-aborted client
58
+ // signal — or a transport failure that lands while the signal is
59
+ // aborted slips past into the error channel. Once the user has
60
+ // aborted, that raced failure is noise: resolving with the partial
61
+ // serves their intent, while an error banner would blame their own
62
+ // keypress.
63
+ const aborted = ((e instanceof DOMException || e instanceof Error) && e.name === "AbortError") || signal?.aborted === true;
64
+ if (aborted) {
65
+ // A complete response already in hand beats a partial reconstruction: if the
66
+ // stream had yielded its terminal `done` before the abort threw, that whole
67
+ // response is authoritative, so return it rather than the assembled deltas.
68
+ if (final !== undefined)
69
+ return ok(final);
70
+ return ok({ message: { role: "assistant", content: partial }, finishReason: "aborted" });
71
+ }
47
72
  return err(toProviderError(e, workload));
48
73
  }
49
74
  if (final === undefined) {
@@ -60,7 +60,16 @@ export interface ChatResponse {
60
60
  readonly message: Extract<ModelMessage, {
61
61
  role: "assistant";
62
62
  }>;
63
- readonly finishReason: FinishReason;
63
+ /**
64
+ * The terminal reason for this reply. `"aborted"` is produced ONLY by the
65
+ * streaming wrapper (`createStreamingChat`), which resolves a client abort
66
+ * with the partial assistant text assembled from the deltas it already
67
+ * forwarded. The non-streaming `ChatProvider.chat` never yields `"aborted"`:
68
+ * it propagates a client abort as a throw, because durable workflow loops —
69
+ * the non-streaming provider's callers — record cancellation only when it
70
+ * surfaces as a thrown exception.
71
+ */
72
+ readonly finishReason: FinishReason | "aborted";
64
73
  readonly rawFinishReason?: string;
65
74
  readonly usage?: ChatUsage;
66
75
  }
@@ -166,7 +166,6 @@ export declare const submitReportInputSchema: z.ZodObject<{
166
166
  baseVersion: z.ZodOptional<z.ZodNumber>;
167
167
  format: z.ZodDefault<z.ZodEnum<{
168
168
  html: "html";
169
- pdf: "pdf";
170
169
  }>>;
171
170
  report: z.ZodOptional<z.ZodUnknown>;
172
171
  modifications: z.ZodOptional<z.ZodString>;