CAG Deep Research

A causal-adversarial deep-research engine: LangGraph plans a causal graph of a question, red and blue team agents investigate each causal edge in parallel, and a dialectical judge returns verdicts. Built on a hexagonal architecture with swappable search and LLM providers.

ROLE
Builder
PERIOD
2024
DOMAIN
AI/ML
STATUS
Published

OVERVIEW

CAG (Causal-Adversarial Graph) is a deep-research engine built as a Popperian falsification loop. A LangGraph workflow turns a question into a causal graph, then investigates each causal edge with a red team (adversary) and blue team (supporter) running in parallel; a dialectical judge weighs both and returns a verdict (VERIFIED / FALSIFIED / UNCLEAR) with confidence, while an auditor enforces depth caps and loop detection and the writer synthesises a verdict-tagged report. It is built on a hexagonal architecture, so search engines (DuckDuckGo, Tavily, Exa) and LLM providers (Ollama, Groq, GitHub Models) are swappable by configuration.

Impact of remote work on productivity

Static sample showing the shape of a CAG run, verdict-tagged findings. A real run grounds each claim in web citations and evidence objects.

  • VERIFIED

    Productivity impact is not uniform; it varies by role, tooling, and meeting load.

  • CONTESTED

    Fully remote always increases productivity compared to hybrid arrangements.

  • VERIFIED

    Strong async practices reduce coordination overhead for distributed teams.

  • UNVERIFIED

    A single policy works well for every team without exceptions.

Run it yourself on GitHub ↗

ARRIVED AS

Single-LLM research tends to confirm whatever it first believes. The goal was a system that actively tries to falsify its own claims: model a question as a causal graph, then attack each causal link with a dedicated adversary before trusting it.

Most LLM research tools are confirmation engines: ask a question, get a confident synthesis, rarely an argument against it. That is the opposite of how good research works, where a claim is only trusted after someone has actually tried to break it. CAG (Causal-Adversarial Graph) is built around that idea, a Popperian falsification loop. It turns a question into a causal graph of hypothesised cause-and-effect links, then, for each link, runs an adversary whose only job is to find contradicting evidence alongside a supporter looking for confirming evidence. A judge weighs both and rules. The output is not a single narrative but a set of causal claims, each tagged with a verdict and the evidence on both sides.

WHAT I BUILT

  1. 01A LangGraph workflow (CAG, Causal-Adversarial Graph) that plans a causal DAG, then investigates each edge with a parallel red team (adversary) and blue team (supporter).
  2. 02A dialectical judge weighs the two sides on scientific criteria and returns a structured verdict: VERIFIED, FALSIFIED, or UNCLEAR, with a confidence score.
  3. 03Hexagonal architecture (ports + adapters) so search engines (DuckDuckGo, Tavily, Exa) and LLM providers (Ollama, Groq, GitHub Models) are swappable.

WHAT CHANGED

  • Falsification-first design: every causal claim faces a dedicated adversary before it is accepted, instead of one model synthesising unchecked.
  • Runs entirely on free backends (DuckDuckGo + Ollama or GitHub Models) or scales up to paid search and faster cloud LLMs, with no code changes.
  • Safety rails throughout: recursion-depth caps, per-edge investigation limits, loop detection via action hashes, and an auditor that decides loop-or-finish.

Data flow

click a stage

The Causal Planner turns the query into a causal DAG of hypothesised cause-to-effect edges.

COMPONENT

Causal Planner

Turns the query into a causal DAG of hypothesised cause-and-effect edges, the system's world model.

Decisions, with the cost of each.

A decision without its trade-off is marketing. Each row says what was chosen, why, and what it gave up.

Causal-adversarial (Popperian) design over a single-LLM synthesiser

A question is modelled as a causal graph, and each edge is attacked by a dedicated adversary before being accepted. Trying to falsify a claim catches confident-but-wrong synthesis that a single model would wave through.

Far more LLM and search calls per question than a one-shot answer, which is why recursion depth and per-edge investigations are capped.

Run adversary and supporter in parallel, then judge

Red and blue teams run concurrently (asyncio.gather) so each edge gets both perspectives quickly, and a separate judge synthesises a verdict, no single agent both argues and rules.

Parallel writes need care: supporting and contradicting evidence go to separate buffers with custom LangGraph reducers so concurrent updates do not clobber each other.

Command(goto=...) routing instead of conditional edges

LangGraph 1.0.x conditional edges hung in this environment, so nodes return explicit Command(goto=...) to route (e.g. auditor decides selector vs writer). It is deterministic and debuggable.

Routing logic lives in node wrappers rather than as declarative graph edges, slightly less of the graph is visible at a glance.

Hexagonal ports and adapters for search and LLMs

Search engines and model providers sit behind ports, so the engine runs on entirely free backends (DuckDuckGo + Ollama or GitHub Models) for development and swaps to paid search or faster cloud LLMs for quality, by configuration.

More upfront interface and adapter code than calling an SDK directly.

The part that mattered.

The numbers behind the work, and the code that produced them.

in the graph
7 nodes
planner · selector · adversary · supporter · judge · auditor · writer
per causal edge
Red / Blue
adversary vs supporter, then a dialectical judge
ports + adapters
Hexagonal
swappable search and LLM providers
providers
5+
DuckDuckGo / Tavily / Exa · Ollama / Groq / GitHub Models
The CAG graph: a falsification looppython
class CAGGraphBuilder:
    """Causal-Adversarial Graph (CAG): a Popperian falsification engine.
    1. Planner   -> builds a causal DAG from the query
    2. Selector  -> picks the next edge to investigate
    3. Adversary + Supporter -> parallel red / blue research
    4. Judge     -> resolves the conflict, updates the graph
    5. loop to Selector until edges resolve or max depth
    6. Writer    -> synthesises the final report
    """
    def build(self) -> StateGraph:
        wf = StateGraph(ResearchState)
        wf.add_node("planner", self._run_planner)
        wf.add_node("selector", self._run_selector)
        wf.add_node("investigate", self._run_parallel_investigation)
        wf.add_node("judge", self._run_judge)
        wf.add_node("auditor", self._run_auditor)
        wf.add_node("writer", self._run_writer)
        wf.add_edge(START, "planner")
        wf.add_edge("planner", "auditor")
        wf.add_edge("investigate", "judge")
        wf.add_edge("judge", "auditor")
        wf.add_edge("writer", END)
        # routing via Command(goto=...) inside nodes: a deliberate
        # workaround for LangGraph 1.0.x conditional-edge hangs
        return wf.compile()

The whole engine in one place. Note the honest detail: conditional edges hung on this LangGraph version, so the auditor and selector route by returning Command(goto=...) instead, which kept the loop deterministic and debuggable.

Red team + blue team, in parallelpython
async def _run_parallel_investigation(self, state):
    """Adversary (red) and Supporter (blue) research the same edge at once."""
    adversary, supporter = await asyncio.gather(
        self._run_adversary(state),   # hunts contradicting evidence
        self._run_supporter(state),   # hunts supporting evidence
    )
    return {
        "contradicting_evidence": adversary.get("contradicting_evidence", []),
        "supporting_evidence":    supporter.get("supporting_evidence", []),
        # node_visit_counts / action_hashes merged via state reducers
    }

Both teams investigate the same causal edge concurrently and write to separate evidence buffers. Custom reducers on the ResearchState merge the two parallel updates so neither side's writes are lost, which is the tricky part of doing this in LangGraph.

The dialectical judge's structured verdictpython
class JudgmentOutput(BaseModel):
    verdict: Literal["VERIFIED", "FALSIFIED", "UNCLEAR"]
    confidence: float = Field(ge=0.0, le=1.0)
    reasoning: str
    key_supporting_points: list[str] = []
    key_contradicting_points: list[str] = []
    methodological_concerns: list[str] = []

# The judge weighs evidence on explicit criteria:
# credibility, recency, methodology, replication,
# effect size, confounders -> one structured verdict per edge.

The judge does not return a vibe. It emits a typed verdict with confidence and the key points on each side, decided against named scientific criteria, so every accepted causal link is auditable.

✓ LEARNED

  1. Falsification beats confirmation. Giving each causal edge a dedicated adversary surfaced contradictions that a single synthesising model would have smoothed over. The adversary is the most valuable agent in the system.

  2. Parallelism is easy; merging parallel state is not. Running red and blue teams with asyncio.gather was trivial, but getting their concurrent writes into LangGraph state without clobbering took custom reducers and separate evidence buffers.

  3. Sometimes the honest fix is a workaround. Conditional edges hung on this LangGraph version, so routing moved into Command(goto=...) returns. Less elegant, but deterministic and debuggable beat clever-but-hanging.

  4. Hexagonal architecture earned its keep. Because search and LLMs sit behind ports, the same engine runs free on DuckDuckGo + Ollama or scales to paid search and cloud models by config, which made iterating cheap.

  5. Agent loops need hard rails. Recursion-depth caps, per-edge limits, node-visit counts, and action-hash loop detection are what stop a self-directed research loop from running forever.

◔ NOT DONE YET

  1. Persist and visualise the causal graph so a reader can see which edges were verified, falsified, or left unclear.
  2. Wire the live demo to real runs with grounded citations; the current published sample is a static shape preview.
  3. Add a calibration pass that checks whether judge confidence scores track real-world correctness.