feat(TASK-023): deep-research hardening — fail-safe scripts, fault fallbacks, pipeline notes
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: <fault text>'.
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.
This commit is contained in:
13 files changed
+1111
-10
No files matched your search
@@ -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: <chain>" 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: <chain>" 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: <chain>" 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}}'
|
||||
@@ -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({}))
|
||||
@@ -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}))
|
||||
@@ -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: <chain>" 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,
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -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"
|
||||
],
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -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: <chain>" 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}"],
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -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: <chain>" 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}")))
|
||||
@@ -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}))
|
||||
@@ -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}"],
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -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: <chain>"
|
||||
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,
|
||||
}
|
||||
)
|
||||
)
|
||||
Reference in new issue
Block a user