From 0ce2654173478c5ffb9ec6766985b53eda1cd59b Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Sat, 12 Sep 2026 18:46:02 -0600 Subject: [PATCH] =?UTF-8?q?feat(TASK-023):=20deep-research=20hardening=20?= =?UTF-8?q?=E2=80=94=20fail-safe=20scripts,=20fault=20fallbacks,=20pipelin?= =?UTF-8?q?e=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R3 guards: parse_request (crash → ask_topic), bootstrap_research, combine_findings (crash → fault-text findings; also lifts branch-local PIPELINE-FAULT findings into pipeline_faults, deduped), reflexion_gate (crash fails forward to synthesize), incorporate_feedback (crash still loops with a generic feedback note), verify_sources (top-level guard + pipeline_notes fold). Fallbacks per the plan's naming pins: plan → plan_fault → end_fault (fail-closed; gains state_updates plan_failure per the TASK-016 rule); research_one_question → branch-local question_fault (map collects the fault finding); vet_sources/critique → degrade-visibly markers that continue to the stage each node's own next pointed to; synthesize → synth_fault → end_fault. end_fault renders 'DEEP_RESEARCH FAILED — PIPELINE-FAULT: '. Accepted-path visibility: verify_sources folds pipeline_faults into pipeline_notes ('' when fault-free), appended to end_accepted's output and the approval question — happy-path output stays byte-identical. initial_state seeds questions: [] so a dying plan inside the static fan-out leaves the sibling knowledge_lookup lane mapping over zero items while end_fault renders. R5: 19 python3-guarded regression tests + GraphParser wiring test; bundled warning baseline unchanged. --- assets/agents/deep-research/graph.yaml | 70 ++- .../scripts/bootstrap_research.py | 10 +- .../deep-research/scripts/combine_findings.py | 35 +- .../deep-research/scripts/critique_fault.py | 61 ++ .../scripts/incorporate_feedback.py | 38 +- .../deep-research/scripts/parse_request.py | 21 +- .../deep-research/scripts/plan_fault.py | 58 ++ .../deep-research/scripts/question_fault.py | 52 ++ .../deep-research/scripts/reflexion_gate.py | 23 +- .../deep-research/scripts/synth_fault.py | 51 ++ .../deep-research/scripts/verify_sources.py | 51 +- .../agents/deep-research/scripts/vet_fault.py | 60 ++ src/config/request_context.rs | 591 ++++++++++++++++++ 13 files changed, 1111 insertions(+), 10 deletions(-) create mode 100644 assets/agents/deep-research/scripts/critique_fault.py create mode 100644 assets/agents/deep-research/scripts/plan_fault.py create mode 100644 assets/agents/deep-research/scripts/question_fault.py create mode 100644 assets/agents/deep-research/scripts/synth_fault.py create mode 100644 assets/agents/deep-research/scripts/vet_fault.py diff --git a/assets/agents/deep-research/graph.yaml b/assets/agents/deep-research/graph.yaml index f8be351..757e668 100644 --- a/assets/agents/deep-research/graph.yaml +++ b/assets/agents/deep-research/graph.yaml @@ -38,6 +38,14 @@ initial_state: research_attempts: 0 local_context: '' local_sources: '' + # Fault-path defaults (TASK-023 hardening). `questions: []` also keeps the + # knowledge_lookup lane alive for one super-step when `plan` dies inside the + # static fan-out: research_each_question maps over zero items while the + # plan_fault → end_fault route renders the failure sentinel. + pipeline_faults: [] + pipeline_notes: '' + fault_text: '' + questions: [] start: parse_request @@ -91,8 +99,22 @@ nodes: maxItems: 6 description: 3-5 specific, self-contained sub-questions. required: [research_plan, questions] + # On success plan_failure holds the node's structured JSON output; on + # failure the engine writes "LLM node …failed: " into it BEFORE + # routing to the fallback (schema properties are only merged on success). + state_updates: + plan_failure: '{{output}}' + fallback: plan_fault next: research_each_question + plan_fault: + id: plan_fault + type: script + description: Fail-closed marker for a dead planning stage (reached only via plan's fallback) — normalizes the failure text captured in plan_failure into a PIPELINE-FAULT and continues to end_fault; no plan means nothing to research, so the DEEP_RESEARCH FAILED sentinel always renders. The sibling knowledge_lookup lane survives one more super-step by mapping over the empty initial_state `questions` list. + script: scripts/plan_fault.py + fallback: end_fault + next: end_fault + knowledge_lookup: id: knowledge_lookup type: rag @@ -153,6 +175,16 @@ nodes: max_attempts: 2 state_updates: finding: '{{output}}' + # On failure the engine writes "LLM node …failed: " into `finding` + # via the state_updates above BEFORE routing to the fallback. The marker + # is branch-local: a map branch's fallback must stay inside the branch. + fallback: question_fault + + question_fault: + id: question_fault + type: script + description: Fail-closed marker for a dead research lane (reached only via research_one_question's fallback route inside the map branch). Normalizes the engine's failure text in `finding` into a PIPELINE-FAULT finding the map collects — one dead lane never sinks the whole map. combine_findings later lifts PIPELINE-FAULT findings into pipeline_faults for the report's Pipeline notes. + script: scripts/question_fault.py combine_findings: id: combine_findings @@ -179,6 +211,15 @@ nodes: max_iterations: 15 state_updates: source_assessment: '{{output}}' + fallback: vet_fault + next: critique + + vet_fault: + id: vet_fault + type: script + description: Degrade-visibly marker for a dead vetting stage (reached only via vet_sources' fallback) — vetting is a quality lane, not the product. Records a PIPELINE-FAULT, rewrites source_assessment to a neutral "unvetted" note, and continues to critique, the stage vet_sources' own next pointed to. + script: scripts/vet_fault.py + fallback: critique next: critique critique: @@ -219,6 +260,15 @@ nodes: tools: [] state_updates: critique: '{{output}}' + fallback: critique_fault + next: reflexion_gate + + critique_fault: + id: critique_fault + type: script + description: Degrade-visibly marker for a dead critique stage (reached only via critique's fallback) — critique is a quality lane, not the product. Records a PIPELINE-FAULT and rewrites `critique` to a neutral note with deliberately NO "VERDICT:" line, so reflexion_gate's malformed-critique PASS default proceeds to synthesis; then continues to reflexion_gate, the stage critique's own next pointed to. + script: scripts/critique_fault.py + fallback: reflexion_gate next: reflexion_gate reflexion_gate: @@ -245,8 +295,19 @@ nodes: max_attempts: 2 state_updates: report: '{{output}}' + # On failure the engine writes "Agent node failed: " into `report` + # via the state_updates above BEFORE routing to the fallback. + fallback: synth_fault next: verify_sources + synth_fault: + id: synth_fault + type: script + description: Fail-closed marker for a dead synthesis stage (reached only via synthesize's fallback) — no report means no product. Normalizes the "Agent node failed:" text captured in `report` into a PIPELINE-FAULT and continues to end_fault, which renders the DEEP_RESEARCH FAILED sentinel. + script: scripts/synth_fault.py + fallback: end_fault + next: end_fault + verify_sources: id: verify_sources type: script @@ -262,7 +323,7 @@ nodes: {{report}} ---- - {{source_check}} + {{source_check}}{{pipeline_notes}} ---- Accept this report? Pick "accept" or "reject", or type specific @@ -285,9 +346,14 @@ nodes: end_accepted: id: end_accepted type: end - output: '{{report}}' + output: '{{report}}{{pipeline_notes}}' end_rejected: id: end_rejected type: end output: "Research on '{{topic}}' was rejected and discarded." + + end_fault: + id: end_fault + type: end + output: 'DEEP_RESEARCH FAILED — PIPELINE-FAULT: {{fault_text}}' diff --git a/assets/agents/deep-research/scripts/bootstrap_research.py b/assets/agents/deep-research/scripts/bootstrap_research.py index d230afc..f12a27c 100644 --- a/assets/agents/deep-research/scripts/bootstrap_research.py +++ b/assets/agents/deep-research/scripts/bootstrap_research.py @@ -6,6 +6,12 @@ list on this node fans out into two parallel branches (the LLM planner and the RAG knowledge lookup) as a single super-step. The validator requires declared parallel-branch script outputs, so we emit an empty JSON object explicitly here. + +Fail-safe (R3): the node reads no state and computes nothing, so its +happy-path output IS the sane degraded output — the guard emits the same +empty object on any crash. No PIPELINE-FAULT is recorded: appending one +would require reading state (the very thing that crashed) or clobbering +faults recorded upstream by parse_request, and a crash here loses nothing. """ import json @@ -14,5 +20,7 @@ def main(): print(json.dumps({})) -if __name__ == "__main__": +try: main() +except Exception: # noqa: BLE001 — the fan-out source must never crash the graph + print(json.dumps({})) diff --git a/assets/agents/deep-research/scripts/combine_findings.py b/assets/agents/deep-research/scripts/combine_findings.py index b55d7aa..7d84184 100644 --- a/assets/agents/deep-research/scripts/combine_findings.py +++ b/assets/agents/deep-research/scripts/combine_findings.py @@ -6,6 +6,18 @@ one entry per sub-question, in input order). Downstream nodes (`vet_sources`, `critique`, `synthesize`) read `{{findings}}` as a single block, so this script renders the array as a Markdown document with one section per question. + +Fault visibility: a research lane that died lands here as a finding +starting with "PIPELINE-FAULT:" (written by question_fault inside the map +branch, where `pipeline_faults` itself is out of reach — map branches only +surface their collected output_key). This script lifts such findings into +`pipeline_faults` (deduplicated, since a reflexion or feedback loop can +re-run the map) so dead lanes surface in the report's Pipeline notes. + +Fail-safe (R3): on a crash (e.g. malformed GRAPH_STATE), `findings` +becomes the fault text itself and a PIPELINE-FAULT entry is recorded — +the pipeline proceeds visibly degraded, and the human approval gate is +the backstop. Existing faults are preserved best-effort. """ import json import os @@ -19,21 +31,40 @@ def load_state(): return json.loads(os.environ.get("GRAPH_STATE", "{}")) +def safe_faults(): + try: + state = load_state() + return [f for f in (state.get("pipeline_faults") or []) if isinstance(f, str)] + except Exception: # noqa: BLE001 — best-effort preservation only + return [] + + def main(): state = load_state() questions = state.get("questions") or [] per_question = state.get("question_findings") or [] + faults = [f for f in (state.get("pipeline_faults") or []) if isinstance(f, str)] sections = [] for idx, q in enumerate(questions): body = per_question[idx] if idx < len(per_question) else "" if isinstance(body, dict) or isinstance(body, list): body = json.dumps(body, indent=2) + if isinstance(body, str) and body.startswith("PIPELINE-FAULT:") and body not in faults: + faults.append(body) sections.append(f"## {q}\n\n{body}") findings = "\n\n".join(sections) if sections else "No findings gathered." - print(json.dumps({"findings": findings})) + print(json.dumps({"findings": findings, "pipeline_faults": faults})) -if __name__ == "__main__": +try: main() +except Exception as e: # noqa: BLE001 — a crashed join must degrade, not die + faults = safe_faults() + fault = ( + f"PIPELINE-FAULT: combining findings crashed — {e}; per-question " + "findings may exist in state but could not be joined" + ) + faults.append(fault) + print(json.dumps({"findings": fault, "pipeline_faults": faults})) diff --git a/assets/agents/deep-research/scripts/critique_fault.py b/assets/agents/deep-research/scripts/critique_fault.py new file mode 100644 index 0000000..5254ee7 --- /dev/null +++ b/assets/agents/deep-research/scripts/critique_fault.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +"""Degrade-visibly marker for a dead critique stage of deep-research. + +Reached ONLY via critique's `fallback:` route. Grounded in engine behavior: +on llm-node failure the engine writes "LLM node …failed: " into +`critique` via the node's state_updates BEFORE routing to the fallback. +Critique is a quality lane, not the product, so this path degrades instead +of failing closed: it records a PIPELINE-FAULT (surfaced later in the +report's Pipeline notes) and rewrites `critique` to a neutral note, then +continues to `reflexion_gate` — the stage critique's own `next` pointed to. + +The rewritten note deliberately contains NO "VERDICT:" line: +reflexion_gate's parse_verdict defaults to PASS when no verdict line is +found (its documented malformed-critique behavior), so a dead critique +proceeds to synthesis instead of burning the revision budget on a fault. +""" + +import json +import os + +MAX_DETAIL_CHARS = 500 + +CRITIQUE_NOTE = ( + "Critique unavailable — the critique stage failed (pipeline fault); " + "proceeding to synthesis without an automated review pass." +) + + +def load_state(): + if path := os.environ.get("GRAPH_STATE_FILE"): + with open(path) as f: + return json.load(f) + return json.loads(os.environ.get("GRAPH_STATE", "{}")) + + +def main(): + state = load_state() + faults = [f for f in (state.get("pipeline_faults") or []) if isinstance(f, str)] + raw = state.get("critique") + detail = raw.strip().replace("\n", " ") if isinstance(raw, str) else "" + if not detail.startswith("LLM node"): + detail = "the critique stage died without recording a failure detail" + if len(detail) > MAX_DETAIL_CHARS: + detail = detail[: MAX_DETAIL_CHARS - 1] + "…" + faults.append(f"PIPELINE-FAULT: critique failed — {detail}") + print(json.dumps({"pipeline_faults": faults, "critique": CRITIQUE_NOTE})) + + +try: + main() +except Exception as e: # noqa: BLE001 — the fault marker itself must never crash + print( + json.dumps( + { + "pipeline_faults": [ + f"PIPELINE-FAULT: critique failed — fault-marker script error: {e}" + ], + "critique": CRITIQUE_NOTE, + } + ) + ) diff --git a/assets/agents/deep-research/scripts/incorporate_feedback.py b/assets/agents/deep-research/scripts/incorporate_feedback.py index 27c22f2..aa93e53 100644 --- a/assets/agents/deep-research/scripts/incorporate_feedback.py +++ b/assets/agents/deep-research/scripts/incorporate_feedback.py @@ -10,6 +10,13 @@ reflexion counter is reset so the user-driven pass gets a fresh revision budget. Routing (`_next`): always research_each_question. + +Fail-safe (R3): a crash here means the user's feedback text cannot be +recovered, but their INTENT — another research pass — is unambiguous +(they chose neither accept nor reject). The degraded route therefore +still loops to `research_each_question`, with a generic feedback note +naming the loss and a PIPELINE-FAULT entry (best-effort preserving +existing faults) that surfaces in the next approval's Pipeline notes. """ import json import os @@ -23,6 +30,14 @@ def load_state(): return json.loads(os.environ.get("GRAPH_STATE", "{}")) +def safe_faults(): + try: + state = load_state() + return [f for f in (state.get("pipeline_faults") or []) if isinstance(f, str)] + except Exception: # noqa: BLE001 — best-effort preservation only + return [] + + def main(): state = load_state() feedback = (state.get("decision") or "").strip() @@ -37,5 +52,26 @@ def main(): print(json.dumps(output)) -if __name__ == "__main__": +try: main() +except Exception as e: # noqa: BLE001 — a crashed feedback fold must degrade, not die + faults = safe_faults() + faults.append( + f"PIPELINE-FAULT: feedback incorporation crashed — {e}; the reviewer's " + "feedback text could not be recovered" + ) + print( + json.dumps( + { + "_next": "research_each_question", + "research_attempts": 0, + "research_feedback": ( + "The user reviewed the report and asked for changes, but " + "the feedback text could not be recovered (pipeline " + "fault). Re-run the research pass with extra care and " + "note this loss." + ), + "pipeline_faults": faults, + } + ) + ) diff --git a/assets/agents/deep-research/scripts/parse_request.py b/assets/agents/deep-research/scripts/parse_request.py index faae28c..49b830a 100644 --- a/assets/agents/deep-research/scripts/parse_request.py +++ b/assets/agents/deep-research/scripts/parse_request.py @@ -9,6 +9,13 @@ supply one interactively. Routing (`_next`): - prompt present -> (no _next; static next: plan) - prompt empty -> ask_topic + +Fail-safe (R3): a crash here (e.g. malformed GRAPH_STATE) means the +caller's prompt cannot be read at all, so proceeding silently is +meaningless — the degraded route is `ask_topic`, which asks the user for +the topic directly, plus a PIPELINE-FAULT entry so the loss is visible in +the final report's Pipeline notes. Clobbering `pipeline_faults` is safe +here: this is the start node, so the list can only hold its initial []. """ import json import os @@ -31,5 +38,17 @@ def main(): print(json.dumps({"_next": "ask_topic"})) -if __name__ == "__main__": +try: main() +except Exception as e: # noqa: BLE001 — a crashed entry router must degrade, not die + print( + json.dumps( + { + "_next": "ask_topic", + "pipeline_faults": [ + f"PIPELINE-FAULT: request parsing crashed — {e}; asked the " + "user for the research topic instead" + ], + } + ) + ) diff --git a/assets/agents/deep-research/scripts/plan_fault.py b/assets/agents/deep-research/scripts/plan_fault.py new file mode 100644 index 0000000..159fb04 --- /dev/null +++ b/assets/agents/deep-research/scripts/plan_fault.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Fail-closed marker for a dead planning stage of the deep-research graph. + +Reached ONLY via plan's `fallback:` route. Grounded in engine behavior: on +llm-node failure the engine writes "LLM node …failed: " into +`plan_failure` via the node's state_updates BEFORE routing to the fallback +(on success the same key holds the node's structured JSON output, which +never starts with the "LLM node" prefix). No plan means no sub-questions — +there is nothing to research — so this path is fail-closed: record the +PIPELINE-FAULT and continue to `end_fault`, which renders the +DEEP_RESEARCH FAILED sentinel. + +The planner runs inside the static fan-out [plan, knowledge_lookup]. While +this marker routes to end_fault, the sibling branch proceeds one more +super-step into `research_each_question`, whose `{{questions}}` resolves to +the initial_state [] — the doomed lane maps over zero items and the graph +returns end_fault's output at the next join. +""" + +import json +import os + +MAX_DETAIL_CHARS = 500 + + +def load_state(): + if path := os.environ.get("GRAPH_STATE_FILE"): + with open(path) as f: + return json.load(f) + return json.loads(os.environ.get("GRAPH_STATE", "{}")) + + +def main(): + state = load_state() + faults = [f for f in (state.get("pipeline_faults") or []) if isinstance(f, str)] + raw = state.get("plan_failure") + detail = raw.strip().replace("\n", " ") if isinstance(raw, str) else "" + if not detail.startswith("LLM node"): + detail = "the planning stage died without recording a failure detail" + if len(detail) > MAX_DETAIL_CHARS: + detail = detail[: MAX_DETAIL_CHARS - 1] + "…" + fault_text = f"planning failed — nothing to research: {detail}" + faults.append(f"PIPELINE-FAULT: {fault_text}") + print(json.dumps({"fault_text": fault_text, "pipeline_faults": faults})) + + +try: + main() +except Exception as e: # noqa: BLE001 — the fault marker itself must never crash + fault_text = f"planning failed — fault-marker script error: {e}" + print( + json.dumps( + { + "fault_text": fault_text, + "pipeline_faults": [f"PIPELINE-FAULT: {fault_text}"], + } + ) + ) diff --git a/assets/agents/deep-research/scripts/question_fault.py b/assets/agents/deep-research/scripts/question_fault.py new file mode 100644 index 0000000..ef9cbe7 --- /dev/null +++ b/assets/agents/deep-research/scripts/question_fault.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +"""Fail-closed marker for a dead research lane of the deep-research graph. + +Reached ONLY via research_one_question's `fallback:` route inside the +`research_each_question` map branch (branch-local — a map branch's fallback +must stay inside the branch subgraph). Grounded in engine behavior: on +llm-node failure the engine writes "LLM node …failed: " into +`finding` via the node's state_updates BEFORE routing to the fallback (on +success the same key holds the researched findings, which never start with +the "LLM node" prefix). This node normalizes the failure text into a +PIPELINE-FAULT finding and the chain ends here — the map collects the fault +as the lane's `finding`, so one dead research lane never sinks the whole +map. combine_findings later lifts PIPELINE-FAULT findings into +`pipeline_faults` so the fault also surfaces in the report's Pipeline notes. +""" + +import json +import os + +MAX_DETAIL_CHARS = 500 + + +def load_state(): + if path := os.environ.get("GRAPH_STATE_FILE"): + with open(path) as f: + return json.load(f) + return json.loads(os.environ.get("GRAPH_STATE", "{}")) + + +def fault_finding(detail): + return { + "finding": ( + f"PIPELINE-FAULT: question research failed — finding unavailable — {detail}" + ) + } + + +def main(): + state = load_state() + raw = state.get("finding") + detail = raw.strip().replace("\n", " ") if isinstance(raw, str) else "" + if not detail.startswith("LLM node"): + detail = "the research lane died without recording a failure detail" + if len(detail) > MAX_DETAIL_CHARS: + detail = detail[: MAX_DETAIL_CHARS - 1] + "…" + print(json.dumps(fault_finding(detail))) + + +try: + main() +except Exception as e: # noqa: BLE001 — the fault marker itself must never crash + print(json.dumps(fault_finding(f"fault-marker script error: {e}"))) diff --git a/assets/agents/deep-research/scripts/reflexion_gate.py b/assets/agents/deep-research/scripts/reflexion_gate.py index 2dd1e6b..2ffd57f 100644 --- a/assets/agents/deep-research/scripts/reflexion_gate.py +++ b/assets/agents/deep-research/scripts/reflexion_gate.py @@ -15,6 +15,12 @@ Routing (`_next`): Reflexion is a best-effort quality booster, not a hard gate: once the budget is spent the workflow proceeds anyway, and the human approval step is the final backstop. + +Fail-safe (R3): a crash here fails FORWARD to `synthesize` — the same +direction as the existing malformed-critique PASS default — with a +PIPELINE-FAULT entry recorded (best-effort preserving existing faults), +so a broken gate costs the run its automated critique routing, never the +run itself. """ import json import os @@ -33,6 +39,14 @@ def load_state(): return json.loads(os.environ.get("GRAPH_STATE", "{}")) +def safe_faults(): + try: + state = load_state() + return [f for f in (state.get("pipeline_faults") or []) if isinstance(f, str)] + except Exception: # noqa: BLE001 — best-effort preservation only + return [] + + def as_int(value, default=0): try: return int(value) @@ -72,5 +86,12 @@ def main(): print(json.dumps(output)) -if __name__ == "__main__": +try: main() +except Exception as e: # noqa: BLE001 — a crashed gate fails forward, never dies + faults = safe_faults() + faults.append( + f"PIPELINE-FAULT: reflexion gate crashed — {e}; proceeded to synthesis " + "without automated critique routing" + ) + print(json.dumps({"_next": "synthesize", "pipeline_faults": faults})) diff --git a/assets/agents/deep-research/scripts/synth_fault.py b/assets/agents/deep-research/scripts/synth_fault.py new file mode 100644 index 0000000..ea08c0f --- /dev/null +++ b/assets/agents/deep-research/scripts/synth_fault.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +"""Fail-closed marker for a dead synthesis stage of the deep-research graph. + +Reached ONLY via synthesize's `fallback:` route. Grounded in engine +behavior: on agent-node failure the engine writes "Agent node failed: +{e:#}" into `report` via the node's state_updates BEFORE routing to the +fallback (on success the same key holds the report-writer's report, which +never starts with that prefix). No report means no product, so this path is +fail-closed: normalize the failure text, record the PIPELINE-FAULT, and +continue to `end_fault`, which renders the DEEP_RESEARCH FAILED sentinel. +""" + +import json +import os + +MAX_DETAIL_CHARS = 500 + + +def load_state(): + if path := os.environ.get("GRAPH_STATE_FILE"): + with open(path) as f: + return json.load(f) + return json.loads(os.environ.get("GRAPH_STATE", "{}")) + + +def main(): + state = load_state() + faults = [f for f in (state.get("pipeline_faults") or []) if isinstance(f, str)] + raw = state.get("report") + detail = raw.strip().replace("\n", " ") if isinstance(raw, str) else "" + if not detail.startswith("Agent node failed:"): + detail = "the synthesis stage died without recording a failure detail" + if len(detail) > MAX_DETAIL_CHARS: + detail = detail[: MAX_DETAIL_CHARS - 1] + "…" + fault_text = f"report synthesis failed — no report was produced: {detail}" + faults.append(f"PIPELINE-FAULT: {fault_text}") + print(json.dumps({"fault_text": fault_text, "pipeline_faults": faults})) + + +try: + main() +except Exception as e: # noqa: BLE001 — the fault marker itself must never crash + fault_text = f"report synthesis failed — fault-marker script error: {e}" + print( + json.dumps( + { + "fault_text": fault_text, + "pipeline_faults": [f"PIPELINE-FAULT: {fault_text}"], + } + ) + ) diff --git a/assets/agents/deep-research/scripts/verify_sources.py b/assets/agents/deep-research/scripts/verify_sources.py index 9828341..d38cd88 100644 --- a/assets/agents/deep-research/scripts/verify_sources.py +++ b/assets/agents/deep-research/scripts/verify_sources.py @@ -6,6 +6,19 @@ request, and writes a `source_check` summary into state so the human reviewer sees broken citations at the approval step. Times out per request so a slow source cannot stall the graph. + +Pipeline-notes fold: end-node templates render unconditionally, so the +conditional "## Pipeline notes" section of the accepted report is built +HERE — the last stop before the approval gate on every accepted path, +including reflexion and feedback re-loops. `pipeline_notes` is the +rendered section when `pipeline_faults` is non-empty and the empty +string otherwise, keeping the happy-path `{{report}}{{pipeline_notes}}` +output byte-identical to the pre-hardening `{{report}}`. + +Fail-safe (R3): per-URL probes were already guarded; the top level now +is too. On a crash the summary says sources were NOT checked, a +PIPELINE-FAULT is recorded (best-effort preserving existing faults), and +pipeline_notes still renders — the approval gate remains the backstop. """ import json import os @@ -25,6 +38,21 @@ def load_state(): return json.loads(os.environ.get("GRAPH_STATE", "{}")) +def safe_faults(): + try: + state = load_state() + return [f for f in (state.get("pipeline_faults") or []) if isinstance(f, str)] + except Exception: # noqa: BLE001 — best-effort preservation only + return [] + + +def render_notes(faults): + entries = [f for f in (faults or []) if isinstance(f, str) and f.strip()] + if not entries: + return "" + return "\n\n## Pipeline notes\n\n" + "\n".join(f"- {e}" for e in entries) + + def reachable(url, timeout=5.0): req = urllib.request.Request(url, method="HEAD") try: @@ -62,8 +90,27 @@ def main(): + "\n".join(results) ) - print(json.dumps({"source_check": summary})) + notes = render_notes(state.get("pipeline_faults")) + print(json.dumps({"source_check": summary, "pipeline_notes": notes})) -if __name__ == "__main__": +try: main() +except Exception as e: # noqa: BLE001 — a crashed source check must degrade, not die + faults = safe_faults() + faults.append( + f"PIPELINE-FAULT: source verification crashed — {e}; cited sources " + "were not checked for reachability" + ) + print( + json.dumps( + { + "source_check": ( + "Source verification crashed (pipeline fault) — cited " + "sources were NOT checked for reachability." + ), + "pipeline_notes": render_notes(faults), + "pipeline_faults": faults, + } + ) + ) diff --git a/assets/agents/deep-research/scripts/vet_fault.py b/assets/agents/deep-research/scripts/vet_fault.py new file mode 100644 index 0000000..2e3bc11 --- /dev/null +++ b/assets/agents/deep-research/scripts/vet_fault.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +"""Degrade-visibly marker for a dead source-vetting stage of deep-research. + +Reached ONLY via vet_sources' `fallback:` route. Grounded in engine +behavior: on llm-node failure the engine writes "LLM node …failed: " +into `source_assessment` via the node's state_updates BEFORE routing to the +fallback. Vetting is a quality lane, not the product, so this path degrades +instead of failing closed: it records a PIPELINE-FAULT (surfaced later in +the report's Pipeline notes) and rewrites `source_assessment` to a neutral +"unvetted" note so downstream prompts (critique, synthesize) never carry +raw engine error text, then continues to `critique` — the stage +vet_sources' own `next` pointed to. +""" + +import json +import os + +MAX_DETAIL_CHARS = 500 + +UNVETTED_NOTE = ( + "Source credibility assessment unavailable — the vetting stage failed " + "(pipeline fault); treat every cited source as unvetted and needing " + "corroboration." +) + + +def load_state(): + if path := os.environ.get("GRAPH_STATE_FILE"): + with open(path) as f: + return json.load(f) + return json.loads(os.environ.get("GRAPH_STATE", "{}")) + + +def main(): + state = load_state() + faults = [f for f in (state.get("pipeline_faults") or []) if isinstance(f, str)] + raw = state.get("source_assessment") + detail = raw.strip().replace("\n", " ") if isinstance(raw, str) else "" + if not detail.startswith("LLM node"): + detail = "the vetting stage died without recording a failure detail" + if len(detail) > MAX_DETAIL_CHARS: + detail = detail[: MAX_DETAIL_CHARS - 1] + "…" + faults.append(f"PIPELINE-FAULT: source vetting failed — {detail}") + print(json.dumps({"pipeline_faults": faults, "source_assessment": UNVETTED_NOTE})) + + +try: + main() +except Exception as e: # noqa: BLE001 — the fault marker itself must never crash + print( + json.dumps( + { + "pipeline_faults": [ + f"PIPELINE-FAULT: source vetting failed — " + f"fault-marker script error: {e}" + ], + "source_assessment": UNVETTED_NOTE, + } + ) + ) diff --git a/src/config/request_context.rs b/src/config/request_context.rs index 7315d42..d41c733 100644 --- a/src/config/request_context.rs +++ b/src/config/request_context.rs @@ -10378,6 +10378,597 @@ mod tests { } } + // ---- deep-research suite-script regression tests (R5) ---- + // + // The deep-research graph's R3 guards and fault markers, exercised by + // invoking `python3