Recursive deep research with durable subagents

An LLM agent that decomposes a topic, spawns parallel subagents, and joins their results — every call durable and recoverable.

Deep Research Agent banner

A research agent receives a broad topic, asks an LLM to break it into 2–3 subtopics, and recursively dispatches each subtopic to itself. Every LLM call and every recursive subagent is a durable promise — kill the worker mid-research, the tree resumes from the last unanswered branch.

SDK versions

TypeScript: @resonatehq/sdk v0.10.1 (current). Python: resonate-sdk v0.6.x against the legacy Resonate Server. Rust: 0.4.0, in active development.

The problem#

Real research is recursive. A topic like "distributed systems" decomposes into "consensus", "replication", "failure detection" — each of which decomposes again. Doing this by hand is tedious; doing it with a single long LLM call hits context limits, takes forever, and can't recover from a transient failure.

Implementing the loop yourself means handling parallelism, retries, partial failure, and crash recovery on top of the LLM logic. Most agents skip recovery entirely — one timeout and the whole tree is lost.

Resonate's solution#

Define one durable function — research(topic, depth) — that prompts the LLM, dispatches subagents recursively when the LLM calls a tool, and joins the results. Each LLM call checkpoints. Each subagent is its own durable execution. Crashes mid-tree resume from the last unfinished branch; nothing re-runs that already finished.

Code walkthrough#

The agent is a single recursive function. Its only inputs are a topic and a depth budget; depth gates whether the LLM can call the research tool again or must summarize.

src/agent.ts
export function* research(
  ctx: Context,
  topic: string,
  depth: number,
): Generator<any, string, any> {
  const messages = [
    { role: "system", content: SYSTEM_PROMPT },
    { role: "user", content: `Research ${topic}` },
  ];

  while (true) {
    // Prompt the LLM. Tool access only when depth > 0.
    const message = yield* ctx.run(prompt, messages, depth > 0);
    messages.push(message);

    if (message.tool_calls) {
      // Fan out: dispatch one durable subagent per tool call, in parallel.
      const handles = [];
      for (const tool_call of message.tool_calls) {
        const args = JSON.parse(tool_call.function.arguments);
        if (tool_call.function.name === "research") {
          const handle = yield* ctx.beginRpc(research, args.topic, depth - 1);
          handles.push([tool_call, handle]);
        }
      }
      // Fan in: each handle is durable; await them in order.
      for (const [tool_call, handle] of handles) {
        const result = yield* handle;
        messages.push({ role: "tool", tool_call_id: tool_call.id, content: result });
      }
    } else {
      return message.content || "";
    }
  }
}

resonate.register("research", research);

The recursion is the whole pattern. ctx.beginRpc (TS) / ctx.rfi (Python) / ctx.run(...).spawn() (Rust) dispatches a child research execution and returns a handle immediately — children run in parallel, each checkpointing on its own LLM calls. The outer loop awaits handles in order and feeds their results back to the LLM as tool messages.

Run it locally#

You need an OpenAI API key, the Resonate Server, and the worker.

code
git clone https://github.com/resonatehq-examples/example-openai-deep-research-agent-ts
cd example-openai-deep-research-agent-ts
npm install
export OPENAI_API_KEY=sk-...
Terminal 1 — Resonate Server
brew install resonatehq/tap/resonate
resonate dev
Terminal 2 — agent
npx tsx src/index.ts

The agent prints a recursive trace as it decomposes the topic. Try killing the worker mid-research and restarting — execution resumes from the last unfinished branch.

Try the depth dial#

The depth argument bounds the recursion. depth=0 forces the LLM to summarize without calling the research tool. depth=1 allows one layer of subagents but their children must summarize. Increase the depth and watch the call graph grow — resonate tree research.1 from the CLI renders the full durable structure.