Skip to main content

roder_dynamic_workflows/
deep_research.rs

1use serde::{Deserialize, Serialize};
2use serde_json::{Value, json};
3
4pub const DEEP_RESEARCH_COMMAND_NAME: &str = "deep-research";
5
6pub fn deep_research_workflow_source() -> &'static str {
7    DEEP_RESEARCH_WORKFLOW_SOURCE
8}
9
10pub fn deep_research_arguments(
11    question: impl Into<String>,
12    provider: Option<&dyn DeepResearchSearchProvider>,
13) -> Value {
14    let question = question.into();
15    let seed_results = provider
16        .map(|provider| provider.search(&question))
17        .unwrap_or_default();
18    json!({
19        "question": question,
20        "seedResults": seed_results
21    })
22}
23
24pub trait DeepResearchSearchProvider {
25    fn search(&self, query: &str) -> Vec<DeepResearchSearchResult>;
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
29#[serde(rename_all = "camelCase")]
30pub struct DeepResearchSearchResult {
31    pub title: String,
32    pub url: String,
33    pub snippet: String,
34}
35
36#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
37#[serde(rename_all = "camelCase")]
38pub struct DeepResearchFixtureSearchProvider {
39    pub results: Vec<DeepResearchSearchResult>,
40}
41
42impl DeepResearchFixtureSearchProvider {
43    pub fn from_json_str(json: &str) -> serde_json::Result<Self> {
44        serde_json::from_str(json)
45    }
46}
47
48impl DeepResearchSearchProvider for DeepResearchFixtureSearchProvider {
49    fn search(&self, query: &str) -> Vec<DeepResearchSearchResult> {
50        let query_terms = normalized_terms(query);
51        let mut scored = self
52            .results
53            .iter()
54            .map(|result| (fixture_score(result, &query_terms), result))
55            .filter(|(score, _)| *score > 0)
56            .collect::<Vec<_>>();
57        scored.sort_by(|left, right| right.0.cmp(&left.0).then(left.1.title.cmp(&right.1.title)));
58        if scored.is_empty() {
59            return self.results.iter().take(4).cloned().collect();
60        }
61        scored
62            .into_iter()
63            .take(4)
64            .map(|(_, result)| result.clone())
65            .collect()
66    }
67}
68
69fn normalized_terms(value: &str) -> Vec<String> {
70    value
71        .split(|ch: char| !ch.is_ascii_alphanumeric())
72        .map(str::to_ascii_lowercase)
73        .filter(|term| term.len() > 2)
74        .collect()
75}
76
77fn fixture_score(result: &DeepResearchSearchResult, query_terms: &[String]) -> usize {
78    let text = format!("{} {}", result.title, result.snippet).to_ascii_lowercase();
79    query_terms
80        .iter()
81        .filter(|term| text.contains(term.as_str()))
82        .count()
83}
84
85const DEEP_RESEARCH_WORKFLOW_SOURCE: &str = r###"
86workflow.define({
87  name: "deep-research",
88  description: "Run a multi-agent deep research workflow with web search when available.",
89  hostApiVersion: 1,
90  argumentsSchema: {
91    type: "object",
92    additionalProperties: false,
93    required: ["question"],
94    properties: {
95      question: { type: "string", minLength: 1 },
96      queries: {
97        type: "array",
98        items: { type: "string" },
99        maxItems: 12
100      },
101      seedResults: {
102        type: "array",
103        items: {
104          type: "object",
105          required: ["title", "url", "snippet"],
106          properties: {
107            title: { type: "string" },
108            url: { type: "string" },
109            snippet: { type: "string" }
110          }
111        }
112      },
113      webSearchAvailable: {
114        type: "boolean"
115      }
116    }
117  },
118  phases: ["scope", "parallel-research", "synthesis", "verification"],
119  limits: {
120    maxConcurrentAgents: 8,
121    maxAgentsPerRun: 64,
122    defaultAgentTimeoutSeconds: 900,
123    defaultRunTimeoutSeconds: 7200,
124    maxReportBytes: 65536
125  }
126}, async (ctx) => {
127  const args = ctx.run.arguments || {};
128  const question = String(args.question || args.query || args.arguments || "").trim();
129  if (!question) {
130    throw new Error("deep-research requires a non-empty question");
131  }
132  const seedResults = Array.isArray(args.seedResults) ? args.seedResults : [];
133  const webSearchAvailable = args.webSearchAvailable !== false;
134  if (!webSearchAvailable && seedResults.length === 0) {
135    throw new Error("deep-research requires web-search capability or fixture seedResults");
136  }
137  const requestedQueries = Array.isArray(args.queries) && args.queries.length
138    ? args.queries.map((query) => String(query)).slice(0, 12)
139    : [
140      question,
141      `${question} current evidence`,
142      `${question} opposing evidence`,
143      `${question} implementation details`,
144      `${question} risks limitations`
145    ];
146
147  ctx.phase.start("scope");
148  const scope = await ctx.agents.run("research-lead", {
149    lane: "planning",
150    description: "scope the research question and source strategy",
151    prompt: `Scope this deep research question, identify likely source classes, and define acceptance criteria.\n\nQuestion: ${question}`,
152    output: `scope:${question}`
153  });
154  ctx.checkpoint.save("scope", {
155    question,
156    queryCount: requestedQueries.length,
157    seedResultCount: seedResults.length
158  });
159
160  ctx.phase.start("parallel-research");
161  const researchers = await ctx.agents.map("researcher", requestedQueries, (query, index) => ({
162    lane: "research",
163    description: `research query ${index + 1}`,
164    prompt: `Research this query for the deep research workflow.\n\nQuestion: ${question}\nQuery: ${query}\n\nUse Roder's canonical web-search tools when available. Prefer primary sources, quote sparingly, record URLs, and call out uncertainty. If fixture seed results are present, use them as offline evidence before adding live sources.\n\nFixture seed results:\n${JSON.stringify(seedResults)}`,
165    output: `research:${index + 1}:${query}`
166  }));
167  ctx.checkpoint.save("research", researchers.map((agent) => agent.output));
168
169  ctx.phase.start("synthesis");
170  const synthesis = await ctx.agents.run("synthesizer", {
171    lane: "synthesis",
172    description: "merge findings into a concise research answer",
173    prompt: `Synthesize the research outputs into a sourced answer.\n\nQuestion: ${question}\nScope: ${scope.output}\nResearch outputs:\n${researchers.map((agent) => agent.output).join("\n")}`,
174    output: `synthesis:${question}`
175  });
176
177  ctx.phase.start("verification");
178  const verification = await ctx.agents.run("verifier", {
179    lane: "verification",
180    description: "challenge the synthesis and check source quality",
181    prompt: `Verify this synthesis. Check for stale facts, unsupported claims, missing counterarguments, and source-quality issues.\n\nQuestion: ${question}\nSynthesis: ${synthesis.output}`,
182    output: `verification:${question}`
183  });
184
185  return ctx.report.markdown([
186    `# Deep research: ${question}`,
187    "",
188    `Scope: ${scope.output}`,
189    "",
190    "## Research lanes",
191    researchers.map((agent) => `- ${agent.output}`).join("\n"),
192    "",
193    `Synthesis: ${synthesis.output}`,
194    `Verification: ${verification.output}`
195  ]);
196});
197"###;