Skip to content

Deep Research Agent

%%{init: {'look': 'handDrawn', 'theme': 'base', 'themeVariables': {'primaryColor': '#eef2ff', 'primaryBorderColor': '#1e40af', 'primaryTextColor': '#1e293b', 'lineColor': '#1e3a8a', 'edgeLabelBackground': '#ffffff', 'clusterBkg': '#fbfcff', 'clusterBorder': '#2563eb', 'fontFamily': '-apple-system, system-ui, Segoe UI, Roboto, Helvetica, Arial, sans-serif', 'fontSize': '15px'}, 'flowchart': {'nodeSpacing': 50, 'rankSpacing': 58, 'padding': 14, 'htmlLabels': true, 'curve': 'basis'}}}%%
flowchart LR
  G(["Research goal"]) --> D("Break it into<br/>subtopics")
  D --> F

  subgraph round["keep going until it holds up"]
    direction LR
    F("Research each one<br/>in parallel") --> R{"Enough<br/>evidence?"}
  end

  R -. "no · dig into the gaps" .-> F
  R == "yes" ==> W("Write the brief")
  W --> P("Hand back a PDF")
  style round stroke-dasharray: 6 5

Outcome: turn a research goal into a decision-ready brief — decomposed into subtopics, researched in parallel with provider-native web search, reviewed for coverage after each round, and rendered as a PDF once the evidence holds up.

The loop is the recipe

A single research prompt with web search enabled returns something that reads well and stops at whatever the model found on its first pass. There is no notion of "this is thin" because nothing is checking.

This workflow separates finding from judging, and lets judging drive the next round:

  1. decompose_goal splits the goal into 3–5 independently searchable subtopics.
  2. prepare_research_fanout builds one LLM_CHAT_COMPLETE input per open subtopic in JQ — the subtopic count determines the width of the fan-out at runtime.
  3. research_subtopics is a FORK_JOIN_DYNAMIC over LLM_CHAT_COMPLETE with webSearch: true. Subtopics are researched concurrently, each as its own durable, retryable task.
  4. review_coverage runs on gpt-4o and is explicitly forbidden from writing the brief. It returns {sufficient, gaps, nextSubtopics}.
  5. When sufficient is false, nextSubtopics becomes the next round's fan-out — the loop researches the gaps, not the original list again.

The loop condition bounds both dimensions:

$.research_loop['iteration'] < 5 && $.sufficient !== true

Five rounds maximum, and !== true means an absent or malformed verdict keeps the loop from exiting on a false positive. Whatever the state when the loop ends, write_brief receives the accumulated evidence and the unresolved gaps, and is instructed to carry them into an Open questions section rather than resolving them from its own knowledge. A brief that admits what it could not find is the useful output.

Prerequisites

An OpenAI integration whose model supports webSearch. PDF rendering is built in — GENERATE_PDF needs no external service.

Cost scales as rounds × subtopics, so the worst case here is 5 × 5 = 25 web-search calls plus 5 review calls. The research calls use gpt-4o-mini; only review_coverage and write_brief use gpt-4o. Lower the round cap before widening the fan-out if you need to cut spend.

Runnable definition

Save this as deep-research-agent.json:

{
  "name": "deep_research_agent",
  "description": "Decomposes a research goal into subtopics, researches them in parallel with provider-native web search, has a model review coverage after each round, and loops for up to five rounds before rendering a source-linked brief as a PDF.",
  "version": 1,
  "schemaVersion": 2,
  "timeoutSeconds": 3600,
  "timeoutPolicy": "TIME_OUT_WF",
  "inputParameters": [
    "goal",
    "audience"
  ],
  "variables": {
    "open_subtopics": [],
    "evidence": [],
    "review": {
      "sufficient": false,
      "gaps": [],
      "rounds": 0
    }
  },
  "tasks": [
    {
      "name": "decompose_goal",
      "taskReferenceName": "decompose",
      "type": "LLM_CHAT_COMPLETE",
      "inputParameters": {
        "llmProvider": "openai",
        "model": "gpt-4o-mini",
        "messages": [
          {
            "role": "system",
            "message": "Break a research goal into 3 to 5 independent, specifically searchable subtopics. Return JSON: {\"subtopics\": [string]}. Each subtopic must be answerable on its own with a web search; do not return overlapping subtopics."
          },
          {
            "role": "user",
            "message": "Goal: ${workflow.input.goal}\nAudience: ${workflow.input.audience}"
          }
        ],
        "temperature": 0.2,
        "maxTokens": 500,
        "jsonOutput": true
      }
    },
    {
      "name": "seed_subtopics",
      "taskReferenceName": "seed_subtopics",
      "type": "SET_VARIABLE",
      "inputParameters": {
        "open_subtopics": "${decompose.output.result.subtopics}"
      }
    },
    {
      "name": "research_rounds",
      "taskReferenceName": "research_loop",
      "type": "DO_WHILE",
      "evaluatorType": "graaljs",
      "inputParameters": {
        "research_loop": "${research_loop.output}",
        "sufficient": "${review_coverage.output.result.sufficient}"
      },
      "loopCondition": "(function(){ return $.research_loop['iteration'] < 5 && $.sufficient !== true; })();",
      "loopOver": [
        {
          "name": "prepare_research_fanout",
          "taskReferenceName": "prepare_fanout",
          "type": "JSON_JQ_TRANSFORM",
          "inputParameters": {
            "subtopics": "${workflow.variables.open_subtopics}",
            "goal": "${workflow.input.goal}",
            "queryExpression": ". as $root | {forkInputs: (($root.subtopics // [])[:5] | map({llmProvider: \"openai\", model: \"gpt-4o-mini\", webSearch: true, temperature: 0.2, maxTokens: 1200, messages: [{role: \"system\", message: \"Research the subtopic with web search. Return markdown with findings and a Sources section of full URLs. If the evidence is thin or contested, say so explicitly rather than filling the gap.\"}, {role: \"user\", message: (\"Goal: \" + $root.goal + \"\\nSubtopic: \" + .)}]}))}"
          }
        },
        {
          "name": "research_subtopics",
          "taskReferenceName": "research_fanout",
          "type": "FORK_JOIN_DYNAMIC",
          "inputParameters": {
            "forkTaskType": "LLM_CHAT_COMPLETE",
            "forkTaskInputs": "${prepare_fanout.output.result.forkInputs}"
          }
        },
        {
          "name": "join_research",
          "taskReferenceName": "join_research",
          "type": "JOIN",
          "joinOn": []
        },
        {
          "name": "accumulate_evidence",
          "taskReferenceName": "accumulate",
          "type": "JSON_JQ_TRANSFORM",
          "inputParameters": {
            "existing": "${workflow.variables.evidence}",
            "round": "${join_research.output}",
            "queryExpression": "{evidence: (((.existing // []) + [((.round | tojson)[0:20000])]) | .[-5:])}"
          }
        },
        {
          "name": "store_evidence",
          "taskReferenceName": "store_evidence",
          "type": "SET_VARIABLE",
          "inputParameters": {
            "evidence": "${accumulate.output.result.evidence}"
          }
        },
        {
          "name": "review_coverage",
          "taskReferenceName": "review_coverage",
          "type": "LLM_CHAT_COMPLETE",
          "inputParameters": {
            "llmProvider": "openai",
            "model": "gpt-4o",
            "messages": [
              {
                "role": "system",
                "message": "You review research coverage against a goal. You do not write the brief. Return JSON: {\"sufficient\": boolean, \"gaps\": [string], \"nextSubtopics\": [string]}. Set sufficient=true only when the evidence supports a decision-ready brief with real sources. When false, nextSubtopics must be 1 to 3 new searchable subtopics that close the largest gaps."
              },
              {
                "role": "user",
                "message": "Goal: ${workflow.input.goal}\nAudience: ${workflow.input.audience}\nEvidence so far: ${workflow.variables.evidence}"
              }
            ],
            "temperature": 0.0,
            "maxTokens": 700,
            "jsonOutput": true
          }
        },
        {
          "name": "record_review",
          "taskReferenceName": "record_review",
          "type": "SET_VARIABLE",
          "inputParameters": {
            "open_subtopics": "${review_coverage.output.result.nextSubtopics}",
            "review": {
              "sufficient": "${review_coverage.output.result.sufficient}",
              "gaps": "${review_coverage.output.result.gaps}",
              "rounds": "${research_loop.output.iteration}"
            }
          }
        }
      ]
    },
    {
      "name": "write_brief",
      "taskReferenceName": "write_brief",
      "type": "LLM_CHAT_COMPLETE",
      "inputParameters": {
        "llmProvider": "openai",
        "model": "gpt-4o",
        "messages": [
          {
            "role": "system",
            "message": "Write a decision-ready research brief in markdown for the stated audience. Sections: Executive summary, Findings, Competing evidence, Open questions, Sources. Every non-obvious claim must trace to a source URL in the evidence. Carry any remaining gaps into Open questions rather than resolving them from your own knowledge."
          },
          {
            "role": "user",
            "message": "Goal: ${workflow.input.goal}\nAudience: ${workflow.input.audience}\nEvidence: ${workflow.variables.evidence}\nKnown gaps: ${workflow.variables.review.gaps}"
          }
        ],
        "temperature": 0.3,
        "maxTokens": 3000
      }
    },
    {
      "name": "render_brief_pdf",
      "taskReferenceName": "render_pdf",
      "type": "GENERATE_PDF",
      "inputParameters": {
        "markdown": "${write_brief.output.result}",
        "pageSize": "A4",
        "theme": "default",
        "pdfMetadata": {
          "title": "${workflow.input.goal}",
          "author": "Conductor Deep Research Agent",
          "subject": "Research brief for ${workflow.input.audience}"
        }
      }
    }
  ],
  "outputParameters": {
    "brief": "${write_brief.output.result}",
    "pdf": "${render_pdf.output}",
    "review": "${workflow.variables.review}",
    "rounds": "${research_loop.output.iteration}"
  }
}

Register and run

conductor workflow create deep-research-agent.json
conductor workflow start -w deep_research_agent --sync -i '{"goal":"Assess the market category for organic coffee in North America","audience":"engineering leadership"}'

Open Executions in the Conductor UI and select the new execution to review the task graph, and each task's inputs and outputs.

rounds in the output tells you how much work the goal actually needed. A vague goal typically burns all five rounds and still reports gaps; a narrow one converges in one or two. That number is a useful signal about the question, not just the run.

Production notes

  • Cap the evidence you carry. Unbounded accumulation runs past the context window and the review call starts failing.
  • Use your best model for the review. It's the only thing deciding whether the work is done.
  • Store the PDF, pass a URI. Don't push binaries through workflow state.
  • Keep the source URLs. A conclusion you can't re-derive in six months isn't evidence.
  • Cost is rounds x subtopics. Lower the round cap before widening the fan-out.
  • Web results are untrusted input. Review the Sources section before circulating anything regulated.
  • It publishes nothing. Put an approval in front of external delivery.