@inflexa-ai/harness 0.4.1 → 0.4.3

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.
@@ -1 +1 @@
1
- export declare const synthesisAgentPrompt = "# Run Synthesizer\n\nYou are a cross-step synthesizer for a completed bioinformatics analysis\nrun. You receive the plan's analytical narrative and a block of per-step\nsummaries (markdown). Your job is to distill them into a concise,\nliterature-grounded **interpretation** \u2014 not a catalog of what each step\nproduced.\n\nYou are task-oriented \u2014 you do NOT interact with the user. You receive\nthe run's context, investigate what you need via your research sub-agent,\nand return a structured synthesis.\n\n## CRITICAL \u2014 Read This First\n\n**You communicate results EXCLUSIVELY by calling tools. This is not optional.**\n\nA response that contains text, prose, JSON, markdown, code blocks, or any\nother non-tool-call content is a **failure** \u2014 the orchestrator does not\nread your text and has no way to use it. The only way a synthesis reaches\nthe user is via `submit_synthesis`. The only way a blocker reaches the\nuser is via `report_blocker`.\n\nEvery session MUST end with exactly one call to `submit_synthesis` or\n`report_blocker`. Do NOT respond with text before any tool call.\n\n## Your Mission\n\nThe reader of your synthesis is a scientist who ALREADY has the step\nsummaries. They do not need you to repeat what each step found \u2014 they\ncan read that themselves. What they need from you is:\n\n1. **Interpretation** \u2014 what do the results mean taken together?\n2. **Conclusions** \u2014 what can we confidently conclude? What is novel vs\n confirmatory? What is actionable from a translational perspective?\n3. **Limitations** \u2014 what caveats apply? Where should the reader be\n skeptical? What confounds exist?\n4. **Evidence** \u2014 key literature references that support, challenge, or\n contextualize the findings.\n\nThink like a senior reviewer writing the Discussion section of a paper,\nnot like a scribe summarizing the Results section.\n\n## Your Tools\n\n### literature_reviewer (research sub-agent \u2014 delegate focused research)\nA task-oriented sub-agent with access to bio-lookup tools (search_gene,\nsearch_pathway, lookup_go_term, search_interactions, pubmed,\nsearch_bgee_expression, get_impc_ko_profile). Delegate to it when you need\nevidence \u2014 you have no bio-lookup tools of your own.\n\nDelegations must be **focused**:\n- ONE well-scoped research brief per call (e.g., \"Investigate FOXM1 and\n MKI67 in the context of proliferation signatures in bulk RNA-seq of\n breast tumor vs normal. Assess novelty and find supporting PMIDs.\")\n- Include the experimental context the sub-agent needs (conditions, modality,\n what the computational finding showed).\n- Do NOT send the whole summary block and ask for \"a literature review\".\n The sub-agent will produce shallow results on diffuse briefs.\n\nTypical usage: 1-3 delegations per run. Multiple small focused briefs\nbeat one giant one. You may delegate again after an initial pass if a\ntheme becomes apparent and needs its own evidence sweep.\n\n### submit_synthesis (terminal \u2014 success path)\nPersist the synthesis. Re-validates the payload; returns either\n`{accepted: true}` (DONE \u2014 stop) or `{accepted: false, issues}` (fix\nspecific fields per issue path and call again, OR switch to\n`report_blocker` if the synthesis cannot be made valid).\n\n### report_blocker (terminal \u2014 cannot produce a synthesis)\nCall when the run produced no synthesizable content (all summaries\nempty, contradictory to the point of incoherence, or no findings worth\nsurfacing). Pass a short reason. STOP after calling.\n\n## Canonical Flow\n\n1. Read the analytical narrative + all step summaries end-to-end.\n Identify the 3-5 most important conclusions, any novel or\n contradicted findings, and cross-step themes.\n2. Delegate 1-3 focused research briefs to `literature_reviewer` \u2014\n targeting novel findings, contradictions, and themes that need\n literature grounding.\n3. Draft the synthesis payload with an interpretive focus.\n4. Call `submit_synthesis`. If rejected, fix the cited issue paths\n and call again. If unfixable, call `report_blocker`.\n\n## Writing the Synthesis\n\nThe `submit_synthesis` arg schema defines every field, its enum values,\nand its size expectations \u2014 read it, and follow it. This section is the\ncraft the schema cannot carry.\n\n**`conclusions` is the \"Discussion\" of the synthesis** \u2014 the part the\nreader will actually want to read. Be opinionated and interpretive, not\ndescriptive. Say where computational predictions agree or disagree with\nclinical evidence, and be honest about the translational distance: is this\ninteresting biology, or a validated target with a measurable biomarker and\na known drug?\n\n**Assigning `preclinical-validation`** \u2014 cite IMPC mouse-KO phenotypes\n(`get_impc_ko_profile`) for loss-of-function consequences and\ncross-species baseline expression (`search_bgee_expression`) for\ntissue-of-action context, when the literature reviewer's report includes\nthem.\n\n**What a good `limitation` looks like** \u2014 it names what it affects and\nwhy it matters:\n- \"Signatures derive from a single cohort (n=57); cross-validation in\n independent NASH datasets was not performed.\"\n- \"CMap-style scoring cannot distinguish targeted pathway reversal from\n non-specific cytotoxic transcriptional disruption.\"\n- \"Biomarker candidates identified computationally; analytical validation\n (assay performance, reproducibility) and clinical validation (outcome\n association in independent cohorts) not performed.\"\n- \"Target druggability not assessed \u2014 pathway importance does not imply\n tractability.\"\n\n## Cross-Field Invariants (enforced at submit_synthesis)\n\n- `finding.stepId` must match a stepId present in the input summaries.\n- Every `theme.findings[]` entry must reference a finding that exists\n in `findings[]` (same stepId + title).\n- Every `keyReferences[].pmid` must appear in at least one\n `finding.references[]` entry \u2014 the cross-run list summarizes what's\n already cited per finding.\n- Every `pmid` you write must come from a `literature_reviewer` response.\n Inventing PMIDs is a terminal failure \u2014 the synthesis is discarded on audit.\n\n## Investigation Strategy\n\n**Which findings need delegation?**\n- Findings labeled high-confidence in the summary but potentially novel \u2014\n needs literature check to confirm or challenge novelty.\n- Findings that could map to a known disease or pathway context \u2014 needs\n a PubMed sweep for the gene+condition combination.\n- Candidate themes spanning 2+ steps \u2014 delegate a theme-level brief to\n get references that tie the findings together.\n\n**Which findings do NOT need delegation?**\n- Trivial / expected results (QC passed, dimensions as expected).\n- Technical findings with no biological claim (batch effect detected,\n normalization applied).\n\n**Composing a delegation brief** \u2014 include:\n1. The specific genes/pathways/features to investigate (from the summary).\n2. The experimental context (conditions, modality, organism if known).\n3. What the computational finding showed (one sentence).\n4. What you want back (novelty assessment? supporting PMIDs? pathway\n context? interaction partners?).\n\nExample:\n> \"Investigate SPP1, TREM2, and APOE in the context of microglia\n> activation in Alzheimer's disease single-cell RNA-seq. Finding:\n> these three genes form a co-expression module enriched in\n> disease-associated microglia. Return: novelty assessment, 3-5 key\n> PMIDs supporting or challenging the DAM signature, and any known\n> interactions among the three.\"\n\n## Do NOT\n\n- Respond with plain text (even to explain your reasoning \u2014 nothing is read).\n- Invent PMIDs, citations, or gene facts. Only report what the reviewer returned.\n- Delegate a blanket brief (\"review all the findings\") \u2014 delegate targeted briefs.\n- **Reiterate step results.** The reader has the step summaries. Do not\n repeat gene lists, sample counts, table dimensions, method parameters,\n or per-step results in the synthesis. Refer to findings by their\n biological meaning, not their technical details.\n- **Create one finding per step.** That is a catalog, not a synthesis.\n Cross-reference across steps and surface only what matters.\n- **Write a long overview.** The conclusions field is where interpretation goes.\n- **Include methodology findings.** Gene intersection sizes, normalization\n choices, platform coverage \u2014 these are technical housekeeping, not\n synthesis findings.\n- **Include expected or confirmatory findings.** Canonical pathway\n enrichments, QC metrics, and well-established biology do not warrant a\n finding \u2014 unless the confirmation itself is noteworthy (e.g., a new\n dataset independently confirming a contested result).\n- **Inflate translational relevance.** A differentially expressed gene is\n NOT automatically a therapeutic target. A survival association is NOT\n automatically a predictive biomarker (it may be prognostic only) \u2014\n predictive requires evidence of treatment x marker interaction,\n prognostic only requires association with outcome.\n- Force findings into themes. If findings don't converge, return fewer (or zero) themes.\n- Call `submit_synthesis` before delegating at least once when findings need\n literature grounding. (A run with only technical/QC findings may skip delegation\n and submit directly, but state that clearly in the overview.)\n- Reference stepIds that aren't in the summaries, or theme-findings that\n aren't in `findings[]`.\n- Call `submit_synthesis` twice after `accepted: true`. Stop immediately.\n- End the session without a terminal tool call. That is a failure mode.\n";
1
+ export declare const synthesisAgentPrompt = "# Run Synthesizer\n\nYou are a cross-step synthesizer for a completed bioinformatics analysis\nrun. You receive the plan's analytical narrative and a block of per-step\nsummaries (markdown). Your job is to distill them into a concise,\nliterature-grounded **interpretation** \u2014 not a catalog of what each step\nproduced.\n\nYou are task-oriented \u2014 you do NOT interact with the user. You receive\nthe run's context, investigate what you need via your research sub-agent,\nand return a structured synthesis.\n\n## CRITICAL \u2014 Read This First\n\n**You communicate results EXCLUSIVELY by calling tools. This is not optional.**\n\nA response that contains text, prose, JSON, markdown, code blocks, or any\nother non-tool-call content is a **failure** \u2014 the orchestrator does not\nread your text and has no way to use it. The only way a synthesis reaches\nthe user is via `submit_synthesis`. The only way a blocker reaches the\nuser is via `report_blocker`.\n\nEvery session MUST end with exactly one call to `submit_synthesis` or\n`report_blocker`. Do NOT respond with text before any tool call.\n\n## Your Mission\n\nThe reader of your synthesis is a scientist who ALREADY has the step\nsummaries. They do not need you to repeat what each step found \u2014 they\ncan read that themselves. What they need from you is:\n\n1. **Interpretation** \u2014 what do the results mean taken together?\n2. **Conclusions** \u2014 what can we confidently conclude? What is novel vs\n confirmatory? What is actionable from a translational perspective?\n3. **Limitations** \u2014 what caveats apply? Where should the reader be\n skeptical? What confounds exist?\n4. **Evidence** \u2014 key literature references that support, challenge, or\n contextualize the findings.\n\nThink like a senior reviewer writing the Discussion section of a paper,\nnot like a scribe summarizing the Results section.\n\n## Your Tools\n\n### literature_reviewer (research sub-agent \u2014 delegate focused research)\nA task-oriented sub-agent with access to bio-lookup tools (search_gene,\nsearch_pathway, lookup_go_term, search_interactions, pubmed,\nsearch_bgee_expression, get_impc_ko_profile). Delegate to it when you need\nevidence \u2014 you have no bio-lookup tools of your own.\n\nDelegations must be **focused**:\n- ONE well-scoped research brief per call (e.g., \"Investigate FOXM1 and\n MKI67 in the context of proliferation signatures in bulk RNA-seq of\n breast tumor vs normal. Assess novelty and find supporting PMIDs.\")\n- Include the experimental context the sub-agent needs (conditions, modality,\n what the computational finding showed).\n- Do NOT send the whole summary block and ask for \"a literature review\".\n The sub-agent will produce shallow results on diffuse briefs.\n\nTypical usage: 1-3 delegations per run. Multiple small focused briefs\nbeat one giant one. You may delegate again after an initial pass if a\ntheme becomes apparent and needs its own evidence sweep.\n\n### submit_synthesis (terminal \u2014 success path)\nPersist the synthesis. Re-validates the payload; returns either\n`{accepted: true}` (DONE \u2014 stop) or `{accepted: false, issues}` (fix\nspecific fields per issue path and call again, OR switch to\n`report_blocker` if the synthesis cannot be made valid).\n\n### report_blocker (terminal \u2014 cannot produce a synthesis)\nCall ONLY when the run produced no synthesizable content \u2014 every summary is\nempty, or the summaries are contradictory to the point of incoherence.\n\"No findings worth surfacing\" is NOT a blocker: with non-empty summaries you\ncan always submit a synthesis (overview + conclusions) with an empty\n`findings[]`. Pass a short reason. STOP after calling.\n\n## Canonical Flow\n\n1. Read the analytical narrative + all step summaries end-to-end.\n Identify the 3-5 most important conclusions, any novel or\n contradicted findings, and cross-step themes.\n2. Delegate 1-3 focused research briefs to `literature_reviewer` \u2014\n targeting novel findings, contradictions, and themes that need\n literature grounding.\n3. Draft the synthesis payload with an interpretive focus.\n4. Call `submit_synthesis`. If rejected, fix the cited issue paths\n and call again. If unfixable, call `report_blocker`.\n\n## Writing the Synthesis\n\nThe `submit_synthesis` arg schema defines every field, its enum values,\nand its size expectations \u2014 read it, and follow it. This section is the\ncraft the schema cannot carry.\n\n**An empty `findings[]` is a valid submission.** `findings` is SELECTIVE,\nnot required \u2014 a run whose summaries yield nothing individually notable is\nstill synthesized: write the `overview` and `conclusions` from the\nsummaries and submit with `findings: []`. \"Nothing worth a finding\" is a\nreason to submit a lean synthesis, never a reason to `report_blocker`.\n\n**`conclusions` is the \"Discussion\" of the synthesis** \u2014 the part the\nreader will actually want to read. Be opinionated and interpretive, not\ndescriptive. Say where computational predictions agree or disagree with\nclinical evidence, and be honest about the translational distance: is this\ninteresting biology, or a validated target with a measurable biomarker and\na known drug?\n\n**Assigning `preclinical-validation`** \u2014 cite IMPC mouse-KO phenotypes\n(`get_impc_ko_profile`) for loss-of-function consequences and\ncross-species baseline expression (`search_bgee_expression`) for\ntissue-of-action context, when the literature reviewer's report includes\nthem.\n\n**What a good `limitation` looks like** \u2014 it names what it affects and\nwhy it matters:\n- \"Signatures derive from a single cohort (n=57); cross-validation in\n independent NASH datasets was not performed.\"\n- \"CMap-style scoring cannot distinguish targeted pathway reversal from\n non-specific cytotoxic transcriptional disruption.\"\n- \"Biomarker candidates identified computationally; analytical validation\n (assay performance, reproducibility) and clinical validation (outcome\n association in independent cohorts) not performed.\"\n- \"Target druggability not assessed \u2014 pathway importance does not imply\n tractability.\"\n\n## Cross-Field Invariants (enforced at submit_synthesis)\n\n- `finding.stepId` must match a stepId present in the input summaries.\n- Every `theme.findings[]` entry must reference a finding that exists\n in `findings[]` (same stepId + title).\n- Every `keyReferences[].pmid` must appear in at least one\n `finding.references[]` entry \u2014 the cross-run list summarizes what's\n already cited per finding.\n- Every `pmid` you write must come from a `literature_reviewer` response.\n Inventing PMIDs is a terminal failure \u2014 the synthesis is discarded on audit.\n\n## Investigation Strategy\n\n**Which findings need delegation?**\n- Findings labeled high-confidence in the summary but potentially novel \u2014\n needs literature check to confirm or challenge novelty.\n- Findings that could map to a known disease or pathway context \u2014 needs\n a PubMed sweep for the gene+condition combination.\n- Candidate themes spanning 2+ steps \u2014 delegate a theme-level brief to\n get references that tie the findings together.\n\n**Which findings do NOT need delegation?**\n- Trivial / expected results (QC passed, dimensions as expected).\n- Technical findings with no biological claim (batch effect detected,\n normalization applied).\n\n**Composing a delegation brief** \u2014 include:\n1. The specific genes/pathways/features to investigate (from the summary).\n2. The experimental context (conditions, modality, organism if known).\n3. What the computational finding showed (one sentence).\n4. What you want back (novelty assessment? supporting PMIDs? pathway\n context? interaction partners?).\n\nExample:\n> \"Investigate SPP1, TREM2, and APOE in the context of microglia\n> activation in Alzheimer's disease single-cell RNA-seq. Finding:\n> these three genes form a co-expression module enriched in\n> disease-associated microglia. Return: novelty assessment, 3-5 key\n> PMIDs supporting or challenging the DAM signature, and any known\n> interactions among the three.\"\n\n## Do NOT\n\n- Respond with plain text (even to explain your reasoning \u2014 nothing is read).\n- Invent PMIDs, citations, or gene facts. Only report what the reviewer returned.\n- Delegate a blanket brief (\"review all the findings\") \u2014 delegate targeted briefs.\n- **Reiterate step results.** The reader has the step summaries. Do not\n repeat gene lists, sample counts, table dimensions, method parameters,\n or per-step results in the synthesis. Refer to findings by their\n biological meaning, not their technical details.\n- **Create one finding per step.** That is a catalog, not a synthesis.\n Cross-reference across steps and surface only what matters.\n- **Write a long overview.** The conclusions field is where interpretation goes.\n- **Include methodology findings.** Gene intersection sizes, normalization\n choices, platform coverage \u2014 these are technical housekeeping, not\n synthesis findings.\n- **Include expected or confirmatory findings.** Canonical pathway\n enrichments, QC metrics, and well-established biology do not warrant a\n finding \u2014 unless the confirmation itself is noteworthy (e.g., a new\n dataset independently confirming a contested result).\n- **Inflate translational relevance.** A differentially expressed gene is\n NOT automatically a therapeutic target. A survival association is NOT\n automatically a predictive biomarker (it may be prognostic only) \u2014\n predictive requires evidence of treatment x marker interaction,\n prognostic only requires association with outcome.\n- Force findings into themes. If findings don't converge, return fewer (or zero) themes.\n- **Call `report_blocker` because no finding seems worth surfacing.** With\n non-empty summaries, submit a synthesis with an empty `findings[]` instead \u2014\n a blocker is only for empty or incoherent summaries.\n- Call `submit_synthesis` before delegating at least once when findings need\n literature grounding. (A run with only technical/QC findings may skip delegation\n and submit directly, but state that clearly in the overview.)\n- Reference stepIds that aren't in the summaries, or theme-findings that\n aren't in `findings[]`.\n- Call `submit_synthesis` twice after `accepted: true`. Stop immediately.\n- End the session without a terminal tool call. That is a failure mode.\n";
@@ -68,9 +68,11 @@ specific fields per issue path and call again, OR switch to
68
68
  \`report_blocker\` if the synthesis cannot be made valid).
69
69
 
70
70
  ### report_blocker (terminal — cannot produce a synthesis)
71
- Call when the run produced no synthesizable content (all summaries
72
- empty, contradictory to the point of incoherence, or no findings worth
73
- surfacing). Pass a short reason. STOP after calling.
71
+ Call ONLY when the run produced no synthesizable content — every summary is
72
+ empty, or the summaries are contradictory to the point of incoherence.
73
+ "No findings worth surfacing" is NOT a blocker: with non-empty summaries you
74
+ can always submit a synthesis (overview + conclusions) with an empty
75
+ \`findings[]\`. Pass a short reason. STOP after calling.
74
76
 
75
77
  ## Canonical Flow
76
78
 
@@ -90,6 +92,12 @@ The \`submit_synthesis\` arg schema defines every field, its enum values,
90
92
  and its size expectations — read it, and follow it. This section is the
91
93
  craft the schema cannot carry.
92
94
 
95
+ **An empty \`findings[]\` is a valid submission.** \`findings\` is SELECTIVE,
96
+ not required — a run whose summaries yield nothing individually notable is
97
+ still synthesized: write the \`overview\` and \`conclusions\` from the
98
+ summaries and submit with \`findings: []\`. "Nothing worth a finding" is a
99
+ reason to submit a lean synthesis, never a reason to \`report_blocker\`.
100
+
93
101
  **\`conclusions\` is the "Discussion" of the synthesis** — the part the
94
102
  reader will actually want to read. Be opinionated and interpretive, not
95
103
  descriptive. Say where computational predictions agree or disagree with
@@ -181,6 +189,9 @@ Example:
181
189
  predictive requires evidence of treatment x marker interaction,
182
190
  prognostic only requires association with outcome.
183
191
  - Force findings into themes. If findings don't converge, return fewer (or zero) themes.
192
+ - **Call \`report_blocker\` because no finding seems worth surfacing.** With
193
+ non-empty summaries, submit a synthesis with an empty \`findings[]\` instead —
194
+ a blocker is only for empty or incoherent summaries.
184
195
  - Call \`submit_synthesis\` before delegating at least once when findings need
185
196
  literature grounding. (A run with only technical/QC findings may skip delegation
186
197
  and submit directly, but state that clearly in the overview.)
@@ -86,7 +86,9 @@ export declare class ProvenanceCollector {
86
86
  * Called by sandbox exec provenance frame processing.
87
87
  *
88
88
  * @param mountPath - The workspace mount path (e.g., "/a1/data" or "/a1" for single-mount)
89
- * @param relativePath - Path relative to the mount root
89
+ * @param relativePath - Path relative to the mount root; or, for a frame
90
+ * report naming somewhere outside the mount, the verbatim absolute path
91
+ * (see `stripMountPrefix`)
90
92
  * @param hash - SHA-256 hash of the file content, or null if file no longer exists
91
93
  * @param context - Explicit classification context from the caller. When provided,
92
94
  * the collector uses it directly instead of classifying from the path.
@@ -124,10 +126,11 @@ export declare class ProvenanceCollector {
124
126
  * Drop an input ref from lineage entirely — removes it from the tracked-input
125
127
  * map and from every record's `inputs` array (identity match, since the refs
126
128
  * are shared objects). `reconcileManifestWithDisk` calls this for a read that
127
- * resolves to a directory (e.g. an `ls` / `list.files` of a mount): the
128
- * inotify frame tracks the open, but a directory is not a content-attestable
129
- * file artifact and must never reach registration. Mirrors the output-side
130
- * non-file drop.
129
+ * is not a content-attestable file of this analysis: one resolving to a
130
+ * directory (e.g. an `ls` / `list.files` of a mount, which the inotify frame
131
+ * tracks as an open), or one resolving outside the analysis tree (e.g.
132
+ * `/{resourceId}/..`, the container root). Neither may reach registration.
133
+ * Mirrors the output-side non-file and out-of-bounds drops.
131
134
  */
132
135
  dropInput(ref: InputRef): void;
133
136
  /**
@@ -121,7 +121,9 @@ export class ProvenanceCollector {
121
121
  * Called by sandbox exec provenance frame processing.
122
122
  *
123
123
  * @param mountPath - The workspace mount path (e.g., "/a1/data" or "/a1" for single-mount)
124
- * @param relativePath - Path relative to the mount root
124
+ * @param relativePath - Path relative to the mount root; or, for a frame
125
+ * report naming somewhere outside the mount, the verbatim absolute path
126
+ * (see `stripMountPrefix`)
125
127
  * @param hash - SHA-256 hash of the file content, or null if file no longer exists
126
128
  * @param context - Explicit classification context from the caller. When provided,
127
129
  * the collector uses it directly instead of classifying from the path.
@@ -133,7 +135,12 @@ export class ProvenanceCollector {
133
135
  return existing;
134
136
  // Use explicit context if provided, otherwise fall back to path classification
135
137
  const classification = context ?? classifyReadPath(relativePath, this.stepId, this.runId, this.dependsOn);
136
- const fullPath = `${mountPath}/${relativePath}`;
138
+ // A still-absolute `relativePath` is a report the caller could not
139
+ // relativize — it names somewhere outside the mount. It rides verbatim:
140
+ // prepending the mount root would forge an in-tree name for a foreign
141
+ // path, and reconcile would then see phantom drift (a missing file,
142
+ // which fails the step) instead of an out-of-tree read (which it drops).
143
+ const fullPath = relativePath.startsWith("/") ? relativePath : `${mountPath}/${relativePath}`;
137
144
  // Resolve fileId for data-source inputs via materialized file metadata map
138
145
  let fileId;
139
146
  if (classification.source === "data" && this.inputFileIdMap) {
@@ -240,10 +247,11 @@ export class ProvenanceCollector {
240
247
  * Drop an input ref from lineage entirely — removes it from the tracked-input
241
248
  * map and from every record's `inputs` array (identity match, since the refs
242
249
  * are shared objects). `reconcileManifestWithDisk` calls this for a read that
243
- * resolves to a directory (e.g. an `ls` / `list.files` of a mount): the
244
- * inotify frame tracks the open, but a directory is not a content-attestable
245
- * file artifact and must never reach registration. Mirrors the output-side
246
- * non-file drop.
250
+ * is not a content-attestable file of this analysis: one resolving to a
251
+ * directory (e.g. an `ls` / `list.files` of a mount, which the inotify frame
252
+ * tracks as an open), or one resolving outside the analysis tree (e.g.
253
+ * `/{resourceId}/..`, the container root). Neither may reach registration.
254
+ * Mirrors the output-side non-file and out-of-bounds drops.
247
255
  */
248
256
  dropInput(ref) {
249
257
  for (const [key, val] of this.inputAccesses) {
@@ -14,7 +14,13 @@
14
14
  */
15
15
  import type { ProvenanceFrame } from "../sandbox/types.js";
16
16
  import { type ProvenanceCollector } from "./collector.js";
17
- /** Strip the `/{resourceId}/` mount prefix from an absolute frame path. */
17
+ /**
18
+ * Strip the `/{resourceId}/` mount prefix from an absolute frame path.
19
+ * Separators doubled at the boundary collapse (`/{rid}//a` → `a`) so an
20
+ * in-mount name lands on its canonical relative form. A path not under the
21
+ * mount comes back unchanged — still absolute — and the collector carries it
22
+ * verbatim (see `trackInputAccess`).
23
+ */
18
24
  export declare function stripMountPrefix(mountRoot: string, absPath: string): string;
19
25
  export interface FeedExecFrameArgs {
20
26
  readonly collector: ProvenanceCollector;
@@ -13,10 +13,16 @@
13
13
  * It never throws — provenance is best-effort and must not fail an exec.
14
14
  */
15
15
  import { classifyReadPath } from "./collector.js";
16
- /** Strip the `/{resourceId}/` mount prefix from an absolute frame path. */
16
+ /**
17
+ * Strip the `/{resourceId}/` mount prefix from an absolute frame path.
18
+ * Separators doubled at the boundary collapse (`/{rid}//a` → `a`) so an
19
+ * in-mount name lands on its canonical relative form. A path not under the
20
+ * mount comes back unchanged — still absolute — and the collector carries it
21
+ * verbatim (see `trackInputAccess`).
22
+ */
17
23
  export function stripMountPrefix(mountRoot, absPath) {
18
24
  const prefix = mountRoot.endsWith("/") ? mountRoot : `${mountRoot}/`;
19
- return absPath.startsWith(prefix) ? absPath.slice(prefix.length) : absPath;
25
+ return absPath.startsWith(prefix) ? absPath.slice(prefix.length).replace(/^\/+/, "") : absPath;
20
26
  }
21
27
  /**
22
28
  * Translate one exec's frame into a collector record. Per-command input
@@ -10,9 +10,12 @@
10
10
  * is required to disambiguate): the gateway emits `402` exclusively for
11
11
  * `budget_exceeded` and `403` for a blocked tenant. Provider-originated
12
12
  * failures (429, 5xx, connection) surface with their own status or as a
13
- * network error with no status at all.
13
+ * network error with no status at all. A `401` is `auth`: the credential the
14
+ * host put behind the call is expired, revoked, or absent, and only a human
15
+ * re-authenticating fixes it — which is a different remedy from every other
16
+ * non-retryable 4xx, so it is a different kind.
14
17
  */
15
- export type ProviderErrorKind = "budget" | "tenant-blocked" | "provider";
18
+ export type ProviderErrorKind = "auth" | "budget" | "tenant-blocked" | "provider";
16
19
  /**
17
20
  * The provider error value channel — a `DomainError`-conforming
18
21
  * discriminated union mirroring `ProviderErrorKind`. `chat` / `embed` return
@@ -23,6 +26,11 @@ export type ProviderErrorKind = "budget" | "tenant-blocked" | "provider";
23
26
  * step boundary.
24
27
  */
25
28
  export type ProviderError = {
29
+ readonly type: "auth";
30
+ readonly retryable: false;
31
+ readonly message: string;
32
+ readonly cause?: unknown;
33
+ } | {
26
34
  readonly type: "budget";
27
35
  readonly retryable: false;
28
36
  readonly message: string;
@@ -72,6 +80,7 @@ export interface ProviderErrorClassification {
72
80
  /**
73
81
  * Classify a provider failure by origin.
74
82
  *
83
+ * - Provider `401` → `auth`, not retryable.
75
84
  * - Billing-gateway `402` → `budget`, not retryable.
76
85
  * - Billing-gateway `403` → `tenant-blocked`, not retryable.
77
86
  * - Provider `429` / `5xx` / connection errors → `provider`, retryable.
@@ -10,7 +10,10 @@
10
10
  * is required to disambiguate): the gateway emits `402` exclusively for
11
11
  * `budget_exceeded` and `403` for a blocked tenant. Provider-originated
12
12
  * failures (429, 5xx, connection) surface with their own status or as a
13
- * network error with no status at all.
13
+ * network error with no status at all. A `401` is `auth`: the credential the
14
+ * host put behind the call is expired, revoked, or absent, and only a human
15
+ * re-authenticating fixes it — which is a different remedy from every other
16
+ * non-retryable 4xx, so it is a different kind.
14
17
  */
15
18
  /**
16
19
  * Structural guard for an already-constructed `ProviderError` value. A
@@ -22,7 +25,9 @@ export function isProviderError(value) {
22
25
  if (typeof value !== "object" || value === null)
23
26
  return false;
24
27
  const v = value;
25
- return (v.type === "budget" || v.type === "tenant-blocked" || v.type === "provider") && typeof v.retryable === "boolean" && typeof v.message === "string";
28
+ return ((v.type === "auth" || v.type === "budget" || v.type === "tenant-blocked" || v.type === "provider") &&
29
+ typeof v.retryable === "boolean" &&
30
+ typeof v.message === "string");
26
31
  }
27
32
  /**
28
33
  * Turn a caught SDK throwable into a `ProviderError` value. Routes through
@@ -42,6 +47,14 @@ export function toProviderError(e, workload) {
42
47
  return e;
43
48
  const { kind, retryable } = classifyProviderError(e);
44
49
  const detail = e instanceof Error ? e.message : String(e);
50
+ if (kind === "auth") {
51
+ return {
52
+ type: "auth",
53
+ retryable: false,
54
+ message: `Provider rejected the credential for ${workload} — it is expired, revoked, or absent: ${detail}`,
55
+ cause: e,
56
+ };
57
+ }
45
58
  if (kind === "budget") {
46
59
  return {
47
60
  type: "budget",
@@ -109,6 +122,7 @@ function looksLikeConnectionError(err) {
109
122
  /**
110
123
  * Classify a provider failure by origin.
111
124
  *
125
+ * - Provider `401` → `auth`, not retryable.
112
126
  * - Billing-gateway `402` → `budget`, not retryable.
113
127
  * - Billing-gateway `403` → `tenant-blocked`, not retryable.
114
128
  * - Provider `429` / `5xx` / connection errors → `provider`, retryable.
@@ -116,6 +130,13 @@ function looksLikeConnectionError(err) {
116
130
  */
117
131
  export function classifyProviderError(e) {
118
132
  const status = extractStatus(e);
133
+ // `auth` is its own kind rather than a plain non-retryable 4xx because the
134
+ // remedy is categorically different: the request was well-formed and the
135
+ // credential behind it is not, so no amount of re-issuing or rephrasing
136
+ // helps — a human has to re-authenticate. Callers surface that; the generic
137
+ // 4xx branch below would tell them the request was wrong, which is false.
138
+ if (status === 401)
139
+ return { kind: "auth", retryable: false };
119
140
  if (status === 402)
120
141
  return { kind: "budget", retryable: false };
121
142
  if (status === 403)
@@ -12,7 +12,7 @@ export { upsertAnalysis, resolveAnalysisBilling, suspendAnalysis, resumeAnalysis
12
12
  export { upsertArtifact, upsertArtifacts, queryInputArtifacts, queryStepArtifactPaths, queryUnsyncedStepArtifacts, updateArtifactId, updateFileIds, countArtifactsForRun, } from "./artifacts.js";
13
13
  export type { RegisterArtifactInput, InputArtifactMeta, StepArtifactRef } from "./artifacts.js";
14
14
  export { insertRun, RunDedupCollisionError, updateRunStatus, promoteFailedToPartial, setRunMandate, // oss-core-managed-ok: run-mandate ledger (nullable; OSS leaves null)
15
- queryRun, queryActiveRun, queryRunsByAnalysis, queryRunsByThread, } from "./runs.js";
15
+ setRunSynthesisOutcome, queryRun, queryActiveRun, queryRunsByAnalysis, queryRunsByThread, } from "./runs.js";
16
16
  export type { InsertRunInput } from "./runs.js";
17
17
  export { insertStepExecution, seedStepExecutions, sweepPendingStepExecutions, updateStepExecution, queryStepsByRun } from "./step-executions.js";
18
18
  export type { InsertStepExecutionInput, SeedStepExecutionRow, UpdateStepExecutionInput } from "./step-executions.js";
@@ -23,7 +23,7 @@ export type { InsertPlanInput } from "./plans.js";
23
23
  export { tryStartDataProfile, tryRetryDataProfile, tryRerunDataProfile, completeDataProfile, failDataProfile, expireStaleDataProfile, reconcileOrphanedDataProfile, clearDataProfile, loadDataProfileStatus, loadSeedInputFileIds, } from "./data-profile.js";
24
24
  export type { DataProfileStatus, DataProfileResult, DataProfileInputFile, DataProfileFile, DataProfileQualityAssessment } from "./data-profile.js";
25
25
  export { queryRunCountsByAnalyses, queryThreadCountsByAnalyses, queryDataProfileStatusByAnalyses } from "./analyses-metrics.js";
26
- export { AnalysisStateRowSchema, ArtifactRole, ArtifactRowSchema, RunStatus, StepExecutionStatus, CortexRunRowSchema, StepExecutionRowSchema, CortexPlanRowSchema, } from "./schema.js";
26
+ export { AnalysisStateRowSchema, ArtifactRole, ArtifactRowSchema, RunStatus, StepExecutionStatus, SynthesisStatus, CortexRunRowSchema, StepExecutionRowSchema, CortexPlanRowSchema, } from "./schema.js";
27
27
  export type { AnalysisStateRow, ArtifactRow, CortexRunRow, StepExecutionRow, CortexPlanRow } from "./schema.js";
28
28
  export { insertAssessment, updateProgress, setDossier, markFailed, getAssessment, listAssessmentsByOrg, softDeleteAssessment, TargetAssessmentStatusSchema, TargetAssessmentErrorSchema, TargetAssessmentRowSchema, } from "./target-assessments.js";
29
29
  export type { TargetAssessmentStatus, TargetAssessmentRow, TargetAssessmentError, InsertAssessmentInput, ListAssessmentsOptions, } from "./target-assessments.js";
@@ -10,11 +10,11 @@ export { initCortexState } from "./init.js";
10
10
  export { upsertAnalysis, resolveAnalysisBilling, suspendAnalysis, resumeAnalysis, loadAnalysisStatus } from "./analyses.js";
11
11
  export { upsertArtifact, upsertArtifacts, queryInputArtifacts, queryStepArtifactPaths, queryUnsyncedStepArtifacts, updateArtifactId, updateFileIds, countArtifactsForRun, } from "./artifacts.js";
12
12
  export { insertRun, RunDedupCollisionError, updateRunStatus, promoteFailedToPartial, setRunMandate, // oss-core-managed-ok: run-mandate ledger (nullable; OSS leaves null)
13
- queryRun, queryActiveRun, queryRunsByAnalysis, queryRunsByThread, } from "./runs.js";
13
+ setRunSynthesisOutcome, queryRun, queryActiveRun, queryRunsByAnalysis, queryRunsByThread, } from "./runs.js";
14
14
  export { insertStepExecution, seedStepExecutions, sweepPendingStepExecutions, updateStepExecution, queryStepsByRun } from "./step-executions.js";
15
15
  export { setSandboxRef, setActiveExecId, clearSandboxRef, queryActiveSandboxes, reconcileReapedSandbox } from "./active-sandboxes.js";
16
16
  export { insertPlan, loadPlan } from "./plans.js";
17
17
  export { tryStartDataProfile, tryRetryDataProfile, tryRerunDataProfile, completeDataProfile, failDataProfile, expireStaleDataProfile, reconcileOrphanedDataProfile, clearDataProfile, loadDataProfileStatus, loadSeedInputFileIds, } from "./data-profile.js";
18
18
  export { queryRunCountsByAnalyses, queryThreadCountsByAnalyses, queryDataProfileStatusByAnalyses } from "./analyses-metrics.js";
19
- export { AnalysisStateRowSchema, ArtifactRole, ArtifactRowSchema, RunStatus, StepExecutionStatus, CortexRunRowSchema, StepExecutionRowSchema, CortexPlanRowSchema, } from "./schema.js";
19
+ export { AnalysisStateRowSchema, ArtifactRole, ArtifactRowSchema, RunStatus, StepExecutionStatus, SynthesisStatus, CortexRunRowSchema, StepExecutionRowSchema, CortexPlanRowSchema, } from "./schema.js";
20
20
  export { insertAssessment, updateProgress, setDossier, markFailed, getAssessment, listAssessmentsByOrg, softDeleteAssessment, TargetAssessmentStatusSchema, TargetAssessmentErrorSchema, TargetAssessmentRowSchema, } from "./target-assessments.js";
@@ -61,6 +61,10 @@ CREATE TABLE IF NOT EXISTS cortex_runs (
61
61
  started_at TEXT NOT NULL,
62
62
  completed_at TEXT,
63
63
  error TEXT,
64
+ -- Run-level synthesis outcome. NULL means "unknown": a legacy row written
65
+ -- before these columns existed, or a run whose synthesis never recorded a result.
66
+ synthesis_status TEXT,
67
+ synthesis_reason TEXT,
64
68
  parts JSONB,
65
69
  mandate_jti TEXT, -- oss-core-managed-ok run-mandate ledger, nullable, OSS leaves null
66
70
  mandate_expires_at TEXT -- oss-core-managed-ok run-mandate ledger, nullable, OSS leaves null
@@ -318,6 +322,8 @@ export async function initCortexState(pool, injected) {
318
322
  "ALTER TABLE cortex_runs ADD COLUMN IF NOT EXISTS parts JSONB",
319
323
  "ALTER TABLE cortex_runs ADD COLUMN IF NOT EXISTS mandate_jti TEXT", // oss-core-managed-ok: run-mandate ledger (nullable; OSS leaves null)
320
324
  "ALTER TABLE cortex_runs ADD COLUMN IF NOT EXISTS mandate_expires_at TEXT", // oss-core-managed-ok: run-mandate ledger (nullable; OSS leaves null)
325
+ "ALTER TABLE cortex_runs ADD COLUMN IF NOT EXISTS synthesis_status TEXT",
326
+ "ALTER TABLE cortex_runs ADD COLUMN IF NOT EXISTS synthesis_reason TEXT",
321
327
  "ALTER TABLE cortex_analysis_state ADD COLUMN IF NOT EXISTS data_profile_result JSONB",
322
328
  "ALTER TABLE cortex_analysis_state ADD COLUMN IF NOT EXISTS billing_context JSONB",
323
329
  // Sandbox reliability telemetry (from sandbox-reliability change on main).
@@ -12,7 +12,7 @@
12
12
  import type { ResultAsync } from "neverthrow";
13
13
  import type { DbError } from "../lib/db-result.js";
14
14
  import type { Querier } from "./db.js";
15
- import type { CortexRunRow } from "./schema.js";
15
+ import type { CortexRunRow, SynthesisStatus } from "./schema.js";
16
16
  export interface InsertRunInput {
17
17
  runId: string;
18
18
  analysisId: string;
@@ -67,3 +67,9 @@ export declare function queryRunsByThread(pool: Querier, analysisId: string, thr
67
67
  * The JWT itself is NOT persisted — it rides in DBOS workflow input.
68
68
  */
69
69
  export declare function setRunMandate(pool: Querier, runId: string, jti: string, expiresAt: string): ResultAsync<void, DbError>;
70
+ /**
71
+ * Record the run-level synthesis outcome on the run ledger. `synthesisReason`
72
+ * carries operator-facing detail for a skipped/failed outcome; it stays null
73
+ * for the "produced" case.
74
+ */
75
+ export declare function setRunSynthesisOutcome(pool: Querier, runId: string, synthesisStatus: SynthesisStatus, synthesisReason?: string | null): ResultAsync<void, DbError>;
@@ -80,7 +80,7 @@ export function queryActiveRun(pool, analysisId, planId) {
80
80
  return tryQuery("runs.queryActiveRun", async () => {
81
81
  const result = await pool.query({
82
82
  text: `SELECT run_id, analysis_id, thread_id, workflow_name,
83
- status, started_at, completed_at, error, parts,
83
+ status, started_at, completed_at, error, synthesis_status, synthesis_reason, parts,
84
84
  mandate_jti, mandate_expires_at, plan_id -- oss-core-managed-ok: run-mandate ledger (nullable; OSS leaves null)
85
85
  FROM cortex_runs
86
86
  WHERE analysis_id = $1
@@ -118,7 +118,7 @@ export function queryRun(pool, runId) {
118
118
  return tryQuery("runs.queryRun", async () => {
119
119
  const result = await pool.query({
120
120
  text: `SELECT run_id, analysis_id, thread_id, workflow_name,
121
- status, started_at, completed_at, error, parts,
121
+ status, started_at, completed_at, error, synthesis_status, synthesis_reason, parts,
122
122
  mandate_jti, mandate_expires_at, plan_id -- oss-core-managed-ok: run-mandate ledger (nullable; OSS leaves null)
123
123
  FROM cortex_runs
124
124
  WHERE run_id = $1`,
@@ -132,7 +132,7 @@ export function queryRunsByAnalysis(pool, analysisId, { limit = 50, offset = 0 }
132
132
  return tryQuery("runs.queryRunsByAnalysis", async () => {
133
133
  const result = await pool.query({
134
134
  text: `SELECT run_id, analysis_id, thread_id, workflow_name,
135
- status, started_at, completed_at, error, parts,
135
+ status, started_at, completed_at, error, synthesis_status, synthesis_reason, parts,
136
136
  mandate_jti, mandate_expires_at, plan_id -- oss-core-managed-ok: run-mandate ledger (nullable; OSS leaves null)
137
137
  FROM cortex_runs
138
138
  WHERE analysis_id = $1
@@ -146,7 +146,7 @@ export function queryRunsByThread(pool, analysisId, threadId, { limit = 50, offs
146
146
  return tryQuery("runs.queryRunsByThread", async () => {
147
147
  const result = await pool.query({
148
148
  text: `SELECT run_id, analysis_id, thread_id, workflow_name,
149
- status, started_at, completed_at, error, parts,
149
+ status, started_at, completed_at, error, synthesis_status, synthesis_reason, parts,
150
150
  mandate_jti, mandate_expires_at, plan_id -- oss-core-managed-ok: run-mandate ledger (nullable; OSS leaves null)
151
151
  FROM cortex_runs
152
152
  WHERE analysis_id = $1 AND thread_id = $2
@@ -174,6 +174,21 @@ export function setRunMandate(pool, runId, jti, expiresAt) {
174
174
  });
175
175
  });
176
176
  }
177
+ /**
178
+ * Record the run-level synthesis outcome on the run ledger. `synthesisReason`
179
+ * carries operator-facing detail for a skipped/failed outcome; it stays null
180
+ * for the "produced" case.
181
+ */
182
+ export function setRunSynthesisOutcome(pool, runId, synthesisStatus, synthesisReason) {
183
+ return tryMutation("runs.setRunSynthesisOutcome", async () => {
184
+ await pool.query({
185
+ text: `UPDATE cortex_runs
186
+ SET synthesis_status = $1, synthesis_reason = $2
187
+ WHERE run_id = $3`,
188
+ values: [synthesisStatus, synthesisReason ?? null, runId],
189
+ });
190
+ });
191
+ }
177
192
  function mapRunRow(row) {
178
193
  // JSONB is parsed by `pg` into native arrays/objects. Treat legacy TEXT
179
194
  // rows (if anything slipped through) as strings and parse them.
@@ -198,6 +213,8 @@ function mapRunRow(row) {
198
213
  startedAt: row.started_at,
199
214
  completedAt: row.completed_at ?? null,
200
215
  error: row.error ?? null,
216
+ synthesisStatus: row.synthesis_status ?? null,
217
+ synthesisReason: row.synthesis_reason ?? null,
201
218
  parts,
202
219
  mandateJti: row.mandate_jti ?? null, // oss-core-managed-ok: run-mandate ledger (nullable; OSS leaves null)
203
220
  mandateExpiresAt: row.mandate_expires_at ?? null, // oss-core-managed-ok: run-mandate ledger (nullable; OSS leaves null)
@@ -64,6 +64,13 @@ export declare const StepExecutionStatus: z.ZodEnum<{
64
64
  blocked: "blocked";
65
65
  }>;
66
66
  export type StepExecutionStatus = z.infer<typeof StepExecutionStatus>;
67
+ export declare const SynthesisStatus: z.ZodEnum<{
68
+ failed: "failed";
69
+ produced: "produced";
70
+ skipped_no_summaries: "skipped_no_summaries";
71
+ skipped_blocker: "skipped_blocker";
72
+ }>;
73
+ export type SynthesisStatus = z.infer<typeof SynthesisStatus>;
67
74
  export declare const CortexRunRowSchema: z.ZodObject<{
68
75
  runId: z.ZodString;
69
76
  analysisId: z.ZodString;
@@ -80,6 +87,13 @@ export declare const CortexRunRowSchema: z.ZodObject<{
80
87
  startedAt: z.ZodString;
81
88
  completedAt: z.ZodNullable<z.ZodString>;
82
89
  error: z.ZodNullable<z.ZodString>;
90
+ synthesisStatus: z.ZodNullable<z.ZodEnum<{
91
+ failed: "failed";
92
+ produced: "produced";
93
+ skipped_no_summaries: "skipped_no_summaries";
94
+ skipped_blocker: "skipped_blocker";
95
+ }>>;
96
+ synthesisReason: z.ZodNullable<z.ZodString>;
83
97
  parts: z.ZodNullable<z.ZodArray<z.ZodAny>>;
84
98
  mandateJti: z.ZodNullable<z.ZodString>;
85
99
  mandateExpiresAt: z.ZodNullable<z.ZodString>;
@@ -35,6 +35,11 @@ export const ArtifactRowSchema = z.object({
35
35
  // ── Run & Step Execution Schemas ─────────────────────────────────────
36
36
  export const RunStatus = z.enum(["running", "completed", "partial", "failed", "canceled", "suspended_insufficient_funds"]);
37
37
  export const StepExecutionStatus = z.enum(["pending", "running", "completed", "failed", "skipped", "canceled", "blocked"]);
38
+ // Run-level synthesis outcome, distinct from RunStatus: a run can reach
39
+ // status="completed" while synthesis produced nothing. "produced" is the only
40
+ // outcome that wrote a synthesis result; the two "skipped_*" variants and
41
+ // "failed" record why none exists.
42
+ export const SynthesisStatus = z.enum(["produced", "skipped_no_summaries", "skipped_blocker", "failed"]);
38
43
  export const CortexRunRowSchema = z.object({
39
44
  runId: z.string(),
40
45
  analysisId: z.string(),
@@ -44,6 +49,10 @@ export const CortexRunRowSchema = z.object({
44
49
  startedAt: z.string(),
45
50
  completedAt: z.string().nullable(),
46
51
  error: z.string().nullable(),
52
+ // NULL synthesisStatus means "unknown" — a legacy row, or a run whose
53
+ // synthesis never recorded an outcome.
54
+ synthesisStatus: SynthesisStatus.nullable(),
55
+ synthesisReason: z.string().nullable(),
47
56
  parts: z.array(z.any()).nullable(),
48
57
  mandateJti: z.string().nullable(), // oss-core-managed-ok: run-mandate ledger (nullable; OSS leaves null)
49
58
  mandateExpiresAt: z.string().nullable(), // oss-core-managed-ok: run-mandate ledger (nullable; OSS leaves null)
@@ -9,8 +9,8 @@
9
9
  * conversation thread on completion. The retired `run-result-writer` push
10
10
  * payload (run status, step summaries, run synthesis, artifact paths) is
11
11
  * surfaced here on demand: the run row + step `summaryPath` references +
12
- * the `synthesisPath` for completed runs let the conversation agent
13
- * `read_file` the artefacts it needs.
12
+ * the `synthesisPath` for runs whose synthesis was produced let the
13
+ * conversation agent `read_file` the artefacts it needs.
14
14
  */
15
15
  import type { Pool } from "pg";
16
16
  import type { CortexRunRow } from "../../state/schema.js";
@@ -39,6 +39,7 @@ type InspectRunOutput = {
39
39
  };
40
40
  declare function formatRun(r: CortexRunRow, verbose: boolean): {
41
41
  error?: string | null | undefined;
42
+ synthesisReason?: string | undefined;
42
43
  runId: string;
43
44
  workflowName: string;
44
45
  status: "running" | "completed" | "failed" | "partial" | "canceled" | "suspended_insufficient_funds";
@@ -46,6 +47,7 @@ declare function formatRun(r: CortexRunRow, verbose: boolean): {
46
47
  completedAt: string | null;
47
48
  planId: string | null;
48
49
  synthesisPath: string | null;
50
+ synthesisStatus: "failed" | "produced" | "skipped_no_summaries" | "skipped_blocker" | null;
49
51
  };
50
52
  export declare function createInspectRunTool(pool: Pool): import("../define-tool.js").Tool<{
51
53
  runId?: string | undefined;
@@ -9,8 +9,8 @@
9
9
  * conversation thread on completion. The retired `run-result-writer` push
10
10
  * payload (run status, step summaries, run synthesis, artifact paths) is
11
11
  * surfaced here on demand: the run row + step `summaryPath` references +
12
- * the `synthesisPath` for completed runs let the conversation agent
13
- * `read_file` the artefacts it needs.
12
+ * the `synthesisPath` for runs whose synthesis was produced let the
13
+ * conversation agent `read_file` the artefacts it needs.
14
14
  */
15
15
  import { ok } from "neverthrow";
16
16
  import { z } from "zod";
@@ -26,7 +26,15 @@ function formatRun(r, verbose) {
26
26
  startedAt: r.startedAt,
27
27
  completedAt: r.completedAt,
28
28
  planId: r.planId,
29
- synthesisPath: r.status === "completed" ? `runs/${r.runId}/synthesis.json` : null,
29
+ // synthesis.json is written only when synthesis actually produced a
30
+ // result; a skipped/failed synthesis leaves no file, so pointing at a
31
+ // path would send the agent to a stale or absent artifact. The recorded
32
+ // outcome — surfaced as synthesisStatus (and synthesisReason when set) —
33
+ // lets a consumer tell "produced" from "skipped/failed/unknown" without
34
+ // reading a file.
35
+ synthesisPath: r.synthesisStatus === "produced" ? `runs/${r.runId}/synthesis.json` : null,
36
+ synthesisStatus: r.synthesisStatus,
37
+ ...(r.synthesisReason !== null ? { synthesisReason: r.synthesisReason } : {}),
30
38
  ...(verbose ? { error: r.error } : {}),
31
39
  };
32
40
  }
@@ -35,7 +43,7 @@ export function createInspectRunTool(pool) {
35
43
  id: "inspect_run",
36
44
  description: "Inspect workflow runs for this analysis. " +
37
45
  "Without a runId: lists all runs. " +
38
- "With a runId: returns step-level details with paths to summary.md (and synthesis.json on completed runs). " +
46
+ "With a runId: returns step-level details with paths to summary.md, plus each run's synthesisStatus and a synthesisPath to synthesis.json only when synthesis was produced. " +
39
47
  "Default response is lightweight — pass includeDiagnostics:true to add error/duration/retry telemetry. " +
40
48
  "Read summary.md or synthesis.json for findings, or use workspace search with type:summary or type:synthesis.",
41
49
  inputSchema: z.object({
@@ -58,6 +58,7 @@ import type { MachineBudget, ResourceSpec } from "../config/resource-limits.js";
58
58
  import type { RunSession } from "../auth/types.js";
59
59
  import type { RunAuthorizer } from "../execution/run-authorizer.js";
60
60
  import type { Logger } from "../lib/logger.js";
61
+ import type { SynthesisStatus } from "../state/index.js";
61
62
  import type { AnalysisStep } from "../schemas/workflow-state.js";
62
63
  import type { ChatProvider, EmbeddingProvider } from "../providers/types.js";
63
64
  import type { BioToolKeys } from "../tools/bio/keys.js";
@@ -297,6 +298,8 @@ export declare function synthesizeFindings(args: {
297
298
  deps: ExecuteAnalysisDeps;
298
299
  }): Promise<{
299
300
  findings: readonly RunFinding[];
301
+ synthesisStatus: Exclude<SynthesisStatus, "failed">;
302
+ synthesisReason: string | null;
300
303
  }>;
301
304
  /**
302
305
  * Register the executeAnalysis parent workflow with DBOS. Caller is