@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.
- package/dist/contracts/message.d.ts +6 -0
- package/dist/execution/report-runner.d.ts +6 -1
- package/dist/execution/report-runner.js +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/loop/run-agent.d.ts +1 -1
- package/dist/loop/run-agent.js +55 -2
- package/dist/memory/ai-sdk-message-storage.d.ts +42 -0
- package/dist/memory/ai-sdk-message-storage.js +62 -0
- package/dist/memory/content-to-cortex.d.ts +8 -1
- package/dist/memory/content-to-cortex.js +36 -4
- package/dist/memory/thread-history.d.ts +37 -0
- package/dist/memory/thread-history.js +74 -7
- package/dist/prompts/conversation.d.ts +1 -1
- package/dist/prompts/conversation.js +3 -3
- package/dist/prompts/report-builder.d.ts +1 -1
- package/dist/prompts/report-builder.js +1 -1
- package/dist/providers/ai-sdk.d.ts +23 -0
- package/dist/providers/ai-sdk.js +254 -34
- package/dist/providers/anthropic.d.ts +2 -0
- package/dist/providers/anthropic.js +1 -0
- package/dist/providers/streaming-chat.d.ts +11 -2
- package/dist/providers/streaming-chat.js +31 -6
- package/dist/providers/types.d.ts +10 -1
- package/dist/tools/iterate-report.d.ts +0 -1
- package/dist/tools/iterate-report.js +125 -11
- package/dist/tools/report/mint-preview-url.d.ts +1 -1
- package/dist/tools/report/mint-preview-url.js +7 -5
- package/dist/tools/report/preview-publisher.d.ts +16 -0
- package/dist/tools/report/preview-publisher.js +20 -0
- package/dist/tools/report/preview-snapshot.d.ts +9 -1
- package/dist/tools/report/preview-snapshot.js +15 -1
- package/dist/tools/report/submit-report.d.ts +1 -0
- package/dist/tools/report/submit-report.js +32 -11
- package/dist/tools/report/version-fs.d.ts +4 -4
- package/dist/tools/report/version-fs.js +24 -17
- 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\`,
|
|
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
|
package/dist/providers/ai-sdk.js
CHANGED
|
@@ -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 {
|
|
7
|
-
|
|
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
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
headers,
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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({
|
|
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({
|
|
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)
|
|
21
|
-
*
|
|
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)
|
|
23
|
-
*
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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>;
|