Graph-based agentic workflow for autonomous, iterative research and structured report synthesis using open-source LLMs.
Siddharth Rao · Enterprise AI Architect · TOGAF EA · GCP CA · MLE · Gen AI Leader
Abstract
MARG is a stateful, graph-based multi-agent system that decomposes the research and report generation task into five specialised agent roles — Planner, Researcher, Synthesizer, Critic, and Formatter — orchestrated by a LangGraph state machine. The architecture addresses the structural failure modes of single-pass LLM research tools: shallow coverage caused by a single retrieval context window, hallucinations caused by the absence of a grounding loop, and unattributed claims caused by no explicit source-tracking discipline. The Critic Agent introduces a scored quality gate whose output drives conditional re-research or re-synthesis until a configurable threshold is met, making iterative self-correction an architectural property rather than an operator concern. The system is deployable locally via Docker Compose with Ollama and publicly via a single Cloud Run container on the GCP free tier.
The single-pass LLM research pattern — submit query, receive response — is architecturally incapable of producing research-grade outputs. The failure is not a model capability problem. It is a structural problem: the pattern provides no mechanism for scope decomposition, iterative retrieval, multi-source grounding, or quality verification. MARG is the architectural remedy.
Time compression~80%reduction in initial research phase duration vs. manual analyst workflowtarget SLO · PI-3
Source attribution100%claims in final report carry a traceable source link — structural, not heuristicFormatter Agent contract
Critique cycles≤3max Critic→Synthesizer re-synthesis loops before escalation to operatorgraph edge constraint
Local-first$0API cost for full demo-tier operation — all inference via Ollama local containerfree-tier target
1.1 Failure Modes in Single-Pass LLM Research
These are not edge cases. They are the expected output characteristics of a single-pass retrieval-generation pattern applied to research tasks of non-trivial depth.
Failure mode
Cause
MARG structural remedy
Shallow coverage
Single retrieval call; context window is the upper bound on information gathered
Planner decomposes topic into N sub-queries; Researcher executes each independently across multiple search calls
Hallucinated claims
LLM fills retrieval gaps with plausible fabrication; no verification step
Critic Agent scores factual consistency as a first-class dimension; low score triggers re-research, not re-generation from stale context
No source attribution
Source URLs are not tracked through the generation pipeline
Researcher Agent writes (source_url, snippet) tuples to state; Formatter enforces citation presence per claim
No quality gate
Output goes directly to user regardless of quality; no internal verification
Critic Agent scores across four dimensions; scores below threshold route back through graph before delivery
Opaque reasoning
Chain-of-thought is discarded; user cannot audit how conclusions were reached
Each agent appends its rationale to state.trace; full trace is preserved and optionally surfaced in UI
Uncontrolled scope drift
LLM interprets topic breadth ad hoc; adjacent topics pulled in without constraint
Planner generates a bounded outline with section weights; Synthesizer is constrained to outline structure; Critic penalises scope violation
1.2 Target Value Stream
01
Query intake. User submits research topic with optional depth and focus-area parameters via API or UI. Input validation, parameter normalisation, and session initialisation.
~0s
02
Research planning. Planner Agent generates a scoped outline with N sections and relative weights. Decomposition into atomic sub-queries per section. Research plan written to state.
~8–15s
03
Information retrieval. Researcher Agent executes DuckDuckGo searches per sub-query. Snippets, titles, and URLs extracted and scored by relevance. Source map appended to state.
~20–60s
04
Section synthesis. Synthesizer Agent drafts each report section from outline and gathered sources. Inline citations injected. Draft sections written to state.
~30–90s
05
Critic evaluation. Critic Agent scores draft across four dimensions. Scores below threshold route back to Researcher (if retrieval gap) or Synthesizer (if synthesis quality). Max 3 iterations.
~10–20s
06
Report formatting. Formatter Agent applies structure: executive summary, table of contents, section bodies, reference list. Disclosure header applied per responsible AI policy.
~8–12s
07
Delivery. Structured Markdown or PDF delivered to caller with full source map, reasoning trace, quality scores, and iteration count. Session checkpointed to disk.
~0s
§2 System Architecture
2.1 C4 Level 1 — System Context
The system context establishes the MARG boundary and its interactions with external actors and systems. MARG has two external dependencies: the local LLM inference runtime (Ollama) and the public web search API (DuckDuckGo). Neither dependency involves authentication or data egress of user queries by default.
Figure 1 — C4 Level 1 System Context · MARG boundary and external interactions
C4 L1LangGraphOllama
2.2 C4 Level 2 — Container View
The container view decomposes MARG into its deployable runtime components. All components run in a single Docker Compose stack for local deployment, or a single Cloud Run container for the hosted tier. The LangGraph engine, FastAPI server, and Streamlit UI share the same process in the demo configuration.
The LangGraph graph is the architectural centrepiece. It defines the agent execution order, conditional branching logic, and the critique loop upper-bound constraint. The graph is acyclic in the nominal path and contains exactly one conditional back-edge: the Critic→Researcher/Synthesizer re-entry that enables iterative refinement.
Figure 3 — LangGraph state machine · node transitions and conditional critique loop
LangGraphStateGraphconditional_edges
2.4 Technology Stack
interface
POST /v1/researchGET /v1/status/:id · SSEStreamlit UI · port 8501Markdown exportPDF export · weasyprint
Docker Compose · localCloud Run · single containerOllama container · sidecarGKE · scale path
Free-tier viability
All LLM inference runs locally via Ollama — zero API cost at any scale. DuckDuckGo search wrapper requires no API key. Cloud Run free tier covers 2M requests/month and 400K GB-seconds compute. The demo tier operates entirely within these limits. The production graduation path to GKE with Vertex AI endpoints is documented in §6.
§3 Agent Catalogue & Specifications
Each agent is a deterministic function: (AgentState) → AgentState. No agent holds mutable external state between invocations. Each agent reads a defined subset of the state, calls its tools, and returns a partial state update. LangGraph merges updates and routes to the next node per the graph edge definitions.
agent-01 · planner_node
Planner Agent
Receives the raw research query and optional depth/focus parameters. Produces a scoped research outline: N sections with titles, weights, and per-section sub-queries. Prevents scope drift by explicitly bounding the research surface before any retrieval begins.
Executes the Planner's sub-queries against DuckDuckGo. Extracts snippet text, source URLs, and estimated recency. Scores each result for topical relevance against the sub-query. Builds a structured source map keyed by outline section for downstream attribution. May be re-invoked by the Critic if retrieval gaps are identified.
reads: outline, iteration_count writes: source_map[{section, url, snippet, relevance}] tool: DuckDuckGoSearchTool · LangChain interface failure: all searches empty → flag in state; Critic will escalate
agent-03 · synthesizer_node
Synthesizer Agent
Drafts each report section from the outline and the corresponding source map entries. Injects inline citation markers [N] at claim level. Constrained to the outline structure — cannot introduce sections not defined by the Planner. Returns a draft_sections list and a full citation index.
reads: outline, source_map, critic_feedback (if re-invoked) writes: draft_sections[{title, body, citations[]}], citation_index model: Llama 3.1 70B · higher quality tier constraint: must not introduce outline sections; must cite every factual claim
agent-04 · critic_node · GATE
Critic Agent
The quality gate. Scores the draft report across four dimensions using a structured rubric. Scores below per-dimension thresholds produce a structured failure report that identifies the failure type (retrieval gap vs. synthesis quality), the affected sections, and the recommended remediation. The conditional edge routing logic is derived directly from this failure report — not from the raw scores.
Assembles the final report from approved draft sections. Generates: executive summary (from section summaries), table of contents (from outline), numbered reference list (from citation index), and disclosure header. Applies consistent Markdown or PDF formatting. Does not make content decisions — it is a pure structural transformation.
Every report produced by MARG carries a structured disclosure header identifying the content as AI-generated and listing the source URLs used. This is a hard output constraint enforced by the Formatter Agent prompt, not an operator option.
3.5 Critic Scoring Rubric
The Critic Agent evaluates drafts against four dimensions. Each dimension has an independent threshold. A draft can pass on three dimensions and fail on one — the failure type, not the aggregate score, determines the routing decision.
Dimension
What is evaluated
Pass threshold
Failure route
Factual accuracy
Claims that lack a cited source; claims that contradict source snippets; implausible statistics
≥ 0.80
FAIL-RETRIEVAL → Researcher re-invoked with targeted sub-queries for the failing sections
Coverage completeness
Outline sections with insufficient source depth; sections shorter than weight-adjusted length
≥ 0.75
FAIL-RETRIEVAL → Researcher re-invoked for the thin sections only (not full re-research)
Analytical clarity
Incoherent paragraph structure; undefined abbreviations; contradictory statements within section
≥ 0.70
FAIL-QUALITY → Synthesizer re-invoked with specific section feedback from critic_feedback
Objectivity
Unsubstantiated opinions; promotional language; one-sided characterisation of contested topics
≥ 0.75
FAIL-QUALITY → Synthesizer re-invoked with objectivity-specific rewrite instructions
Max iteration guard
If iteration_count reaches 3 and the Critic still reports failures, the graph routes to the Formatter unconditionally. The final report includes the quality scores and a flag indicating the report did not pass all thresholds. The operator receives the best available output, not a blank error. This prevents infinite loops while preserving partial value delivery.
§4 State Schema & Data Flow
4.1 AgentState Schema
All inter-agent communication occurs exclusively through the AgentState TypedDict. No agent passes data to another through any channel other than the state object. This makes the entire system state inspectable, serialisable, and resumable from any checkpoint.
state.py · AgentState · TypedDict · LangGraph shared state objectall fields immutable per agent invocation · partial updates via Annotated[list, add]
fromtypingimportTypedDict, Annotated, Optional, Listfromlanggraph.graph.messageimportadd_messagesclassOutlineSection(TypedDict):
title: strweight: float# relative importance 0.0–1.0; used by Criticsub_queries: List[str] # atomic search queries generated by PlannerclassSourceEntry(TypedDict):
section: strurl: strsnippet: strrelevance: float# scored by Researcher; used by Critic coverage checkclassQualityScores(TypedDict):
accuracy: floatcoverage: floatclarity: floatobjectivity: floatclassAgentState(TypedDict):
# ── inputs ──────────────────────────────────────────────────────query: strdepth: str# "overview" | "standard" | "deep"focus_areas: List[str] # optional user-specified sub-topics# ── planner output ──────────────────────────────────────────────outline: List[OutlineSection]
# ── researcher output ───────────────────────────────────────────source_map: Annotated[List[SourceEntry], add_messages]
# Annotated[list, add_messages] → new entries are appended on re-invocation# this means re-search accumulates sources rather than replacing them# ── synthesizer output ──────────────────────────────────────────draft_sections: List[dict] # {title, body, citations[]}citation_index: dict# {ref_num → SourceEntry}# ── critic output ───────────────────────────────────────────────quality_scores: Optional[QualityScores]
critic_feedback: Optional[str] # targeted rewrite instructionsiteration_count: int# loop guard — max 3# ── formatter output ────────────────────────────────────────────final_report: Optional[str] # Markdown string# ── observability ───────────────────────────────────────────────trace: Annotated[List[str], add_messages]
# each agent appends: f"{agent_name}:{rationale}:{confidence}" to trace
4.2 Data Flow — End-to-End
# end-to-end data flow · nominal path (no critique loop)
user query ──→ POST /v1/research (FastAPI · Cloud Run)
When agent outputs conflict — the Synthesizer produces a claim not supported by any source, or the Planner's outline is contradicted by Critic-identified scope drift — the following priority ordering applies. This is encoded in the graph edge definitions, not in prompt instructions.
Priority
Agent
Scope of authority
Override mechanism
P1 — highest
Critic Agent
Quality threshold enforcement; routing decisions
Conditional edge; Critic output is the routing function input — no other agent can override it
P2
Planner Agent
Research scope; outline structure
Synthesizer is structurally constrained to the outline; cannot introduce sections the Planner did not define
P3
Researcher Agent
Source selection; relevance scoring
Synthesizer must cite from source_map; cannot invent sources not in the map
P4 — lowest
Synthesizer & Formatter
Prose generation; structural formatting
Overridden by Critic (quality) and Planner (scope) constraints; Formatter is pure transformation
The Researcher Agent's sole external interaction is through the DuckDuckGo LangChain tool wrapper. The wrapper returns structured results — title, URL, snippet — without authentication or query logging. Relevance scoring is performed locally by a lightweight cosine similarity pass against the sub-query embedding before results are written to source_map.
tools/search.py · DuckDuckGo wrapper · structured source extractionno API key · no query egress logging
Each agent emits a structured log entry on entry, exit, and tool call. The schema is consistent across all agents and is designed for downstream aggregation in Cloud Logging or local analysis via SQLite. Token usage, latency, and the critic scores are first-class fields — not embedded in free-text messages.
Log field
Type
Emitted by
Purpose
session_id
string
All agents
Correlates all events for a single research job
agent_name
string
All agents
e.g. researcher_node
event_type
enum
All agents
ENTER | EXIT | TOOL_CALL | TOOL_RESULT | ERROR
iteration
int
All agents
Critique loop counter at time of emission
latency_ms
int
All agents
Wall-clock time for the agent invocation
tokens_in / tokens_out
int
LLM agents
Token usage per invocation for cost tracking
quality_scores
dict
Critic
Accuracy, coverage, clarity, objectivity scores
routing_decision
string
Critic
PASS | FAIL-RETRIEVAL | FAIL-QUALITY | MAX-ITER
sources_found
int
Researcher
Count of sources written to source_map this invocation
§6 Deployment Topology, IAM & SRE
6.1 Deployment Topology
Figure 4 — Deployment topology · demo (local) vs. production (Cloud Run)
Docker ComposeCloud RunOllama
6.2 IAM & Least-Privilege
Principal
Role / permission
Rationale
Cloud Run service account
roles/storage.objectCreator
Write checkpoints to GCS; cannot read other buckets or delete objects
Cloud Run service account
roles/logging.logWriter
Emit structured agent logs to Cloud Logging; cannot read logs
Cloud Run service account
No Vertex AI roles
Demo tier uses Ollama sidecar; Vertex AI binding added only at production graduation
Cloud Build (CI)
roles/run.developer
Deploy new image revisions; cannot manage IAM or billing
External users
Unauthenticated invocation demo only
Cloud Armor rate-limits at 60 req/min/IP; auth layer added at production graduation
6.3 SRE — Resilience & Graceful Degradation
Failure scenario
Detection
Behaviour
Ollama unresponsive
HTTP 503 from localhost:11434
Retry 3× with exponential backoff (1s, 2s, 4s). If all fail, return 503 with structured error — no partial or hallucinated report delivered
DuckDuckGo returns no results
Empty result list from wrapper
Planner sub-query rewrite attempted once. If still empty, section flagged as source_gap=true; Critic will route to re-search on first pass
Critique loop reaches max iterations
iteration_count >= 3
Hard route to Formatter. Final report includes quality_warning: true and scores. Operator not blocked; best available output delivered
LLM structured output parse failure
JSON decode error on agent response
Retry with temperature=0 and explicit JSON schema reminder in prompt. Three retries before agent raises ParseError and state is checkpointed for inspection
Cloud Run cold start
Request latency > 15s
min-instances=1 at production tier. Demo tier accepts cold starts; Streamlit UI shows loading state. No user-visible error.
§7 Architecture Decision Records
ADR-001accepted
LangGraph over a custom agent loop
Context
The critique loop requires conditional back-edges, state persistence, and inspectable execution traces. A custom Python loop could implement these but would require non-trivial state management code and re-implementation of checkpointing.
Decision
LangGraph StateGraph with TypedDict state and SqliteSaver checkpointer. The add_conditional_edges API makes the routing logic an explicit, testable function rather than an implicit branch buried in agent code. Checkpointing is structural, not bolted-on.
Consequences
LangGraph 0.2 API is stable but evolving; minor breaking changes expected. Mitigation: pin version in requirements.txt and run regression tests on upgrade. The benefit — transparent, testable routing logic — outweighs the dependency risk.
ADR-002accepted
Ollama over OpenAI API for inference
Context
The system requires multi-turn LLM calls across five agents. At research depth=deep, a single job can consume 50K–200K tokens. At OpenAI GPT-4o pricing (~$15/M input tokens as of 2025), a single deep research job costs $0.75–$3.00. This is prohibitive for a demonstration system and creates a dependency on external API availability.
Decision
Ollama with Llama 3.1 8B (default) and 70B (enhanced) as primary inference runtime. Zero API cost. Runs on CPU; 8B model requires ~8GB RAM. The architecture accepts the quality-cost tradeoff: Llama 3.1 8B is 2–4 MMLU points below GPT-4o on reasoning tasks [2], which the Critic loop partially compensates by flagging low-quality outputs for re-synthesis.
Production graduation
The LLMClient abstraction in agents/base.py accepts any OpenAI-compatible endpoint. Switching to Vertex AI Gemini or OpenAI requires changing one environment variable, not refactoring agent code.
ADR-003accepted
Critic as a routing function, not a score threshold
Context
The simplest Critic implementation returns a binary pass/fail score and routes to re-synthesis on failure. But this loses information: a report that fails on retrieval gaps needs different remediation from one that fails on analytical clarity.
Decision
The Critic returns a structured critic_feedback object with a type field (RETRIEVAL_GAP | QUALITY_FAIL), the affected section titles, and a targeted rewrite instruction. The LangGraph conditional edge function reads type to route to Researcher (for retrieval gaps) or Synthesizer (for quality issues). This means the correct agent is invoked for each failure mode — not always the Synthesizer.
Rejected alternative
Binary pass/fail always routing to Synthesizer. Rejected because re-synthesising from the same thin source material produces the same thin output — iteration achieves nothing. The Researcher must be invoked when the root cause is retrieval depth, not synthesis quality.
Tradeoff
Single container vs. microservices
Single container simplifies deployment (docker compose up), eliminates network overhead between agents, and runs within Cloud Run free tier. Microservices would allow independent agent scaling but add network latency (~5ms/hop × 5 agents × 3 critique iterations = ~75ms) and deployment complexity that is not justified at demo scale.
chose · single container
Tradeoff
DuckDuckGo vs. SerpAPI/Bing
DuckDuckGo wrapper requires no API key, logs no queries, and has no monthly cost. SerpAPI provides higher result quality and structured metadata but costs $50+/month and requires query transmission to a third-party service. For a local-first, privacy-preserving demo, the quality reduction is acceptable; the architecture abstracts the tool interface so substitution is a one-line config change.
chose · DuckDuckGo
Tradeoff
Synchronous API vs. async SSE
A deep research job takes 2–5 minutes. A synchronous HTTP response would require the client to hold a connection open for the full duration, making it incompatible with most reverse proxies (60s timeout). The chosen design returns a session_id immediately and exposes a GET /v1/status/:id SSE endpoint for real-time progress streaming.
chose · SSE streaming
Tradeoff
Prompt-based routing vs. code-based conditional edges
Asking the Critic LLM to decide which agent to invoke next (prompt-based routing) introduces non-determinism into the control flow. A misrouted invocation could skip re-research or loop infinitely. Code-based conditional edges in LangGraph make routing a pure Python function with deterministic behaviour, testable in isolation, and independent of LLM output quality.
chose · code-based edges
§8 Live Agent Execution Simulator
The simulator traces a complete MARG workflow for a representative research query. Select a scenario, then click Run to animate the agent state machine. Each step shows the active agent, its inputs consumed from state, its outputs written to state, and an approximated latency.