Recursive deep-research agents across TypeScript, Python, Rust

A research agent has to decompose a topic into subtopics, run each subtopic as its own research task, then synthesize the results — and if a worker crashes halfway through a tree of 20 in-flight subtopics, the completed branches must not be re-asked of the model. Resonate models the agent as a recursive durable function: each research call decides whether to summarize directly or spawn a fresh research workflow per subtopic, and every call site is a durable promise. This family covers six sibling variants — a plain TypeScript base, three platform variants (Cloudflare Workers, Google Cloud Functions, Supabase Edge Functions), and ports to Python and Rust — that share one workflow definition and differ only in their deployment shape.

The shape of the solution

The workflow is a generator (TS) / generator function (Python) / async fn (Rust) that loops: prompt the model, branch on whether the response contains tool calls, and either recurse or return.

// imports + SYSTEM_PROMPT + TOOLS + prompt() defined above in the same file
export function* research(
ctx: Context,
topic: string,
depth: number,
): Generator<any, string, any> {
const messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] = [
{ role: "system", content: SYSTEM_PROMPT },
{ role: "user", content: `Research ${topic}` },
];

while (true) {
// Prompt the LLM
// Only allow tool access if depth > 0
const message = yield* ctx.run(prompt, messages, depth > 0);

messages.push(message);

// Handle parallel tool calls by recursively starting the deep research agent
// and subsequently awaiting the results
if (message.tool_calls) {
const handles = [];
for (const tool_call of message.tool_calls) {
const tool_name = tool_call.function.name;
const tool_args = JSON.parse(tool_call.function.arguments);
if (tool_name === "research") {
const handle = yield* ctx.beginRpc(
research,
tool_args.topic,
depth - 1,
);
handles.push([tool_call, handle]);
}
}
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 || "";
}
}
}
// from example-openai-deep-research-agent-ts/src/agent.ts:61-106

The depth argument gates tool access — when depth hits 0 the model is forced to summarize instead of branching, which bounds the recursion. The two-pass loop (collect handles, then await) is what produces concurrent subtopic execution: the SDK does not start sequential awaiting until every sibling has been dispatched.

The durable primitives in play

resonate.register("research", research) — declares research as a top-level workflow. After registration, researchFunction.run(id, topic, depth) invokes it against the durable promise system. (src/index.ts:7).

yield* ctx.run(prompt, messages, depth > 0) — runs the OpenAI call as a local durable leaf. On replay, a completed leaf returns its cached result instead of re-hitting the model. (src/agent.ts:74).

yield* ctx.beginRpc(research, tool_args.topic, depth - 1) — spawns a child workflow with its own promise id and returns a handle. The child can run on a different worker; the parent does not block until the matching yield* handle. (src/agent.ts:86).

yield* handle — durable await on the child. If the worker crashes between spawn and await, replay reconstructs the handle from the log and resumes the await. (src/agent.ts:95).

Depth as recursion bound — depth > 0 is passed as hasToolAccess. When the model can no longer call tools it must summarize. (src/agent.ts:74, src/agent.ts:42).

In Python the primitives are @resonate.register, ctx.lfc (local function call) for the prompt leaf, and ctx.rfi (remote function invocation) for the recursive child (research.py:60, 71, 83). In Rust both research and prompt are #[resonate::function]; the recursive child is dispatched via ctx.run(research, (subtopic, depth - 1)).spawn().await? (src/main.rs:63, 110-113, 135-136). The Rust prompt is annotated as a durable function on its own and takes args: PromptArgs rather than a &Context — every annotated function in the Rust SDK is durable independently.

What the SDK handles vs. what you write

You write three things: the recursive research workflow with its while/for shape and depth check, the prompt leaf that calls OpenAI, and the tool schema. There is no retry loop, no checkpoint table, no idempotency key plumbing, no queue, no per-step state machine.

The SDK handles: persisting every ctx.run / beginRpc / lfc / rfi / .spawn() site as a durable promise; matching replays of the workflow body to existing promises by deterministic call-site position; routing a parent's resume to whatever worker is available (not necessarily the one that started it); and tracking the recursion as a tree of child promises (research.1, research.1.1, research.1.1.0, …) that you can inspect with resonate tree.

Failure modes covered

Worker crashes mid-recursion. Completed child workflows are replayed from the durable log, not re-asked of OpenAI. The crashed level is re-entered; any sibling whose promise already resolved returns its cached result. Each recursion level is its own durable promise (src/agent.ts:86).

Crash inside a prompt call. ctx.run wraps the OpenAI request as a leaf durable promise. If the leaf had completed before the crash, replay returns the cached message; if not, the call is retried (src/agent.ts:74).

Crash between spawning a child and awaiting it. The child promise id was registered with the server at beginRpc time. On restart the parent's replay regenerates the same handle and proceeds to yield* handle, picking up the (possibly already-settled) child (src/agent.ts:86-95).

Platform timeouts. In the Cloudflare, GCP, and Supabase variants, each leaf and each recursive call is a separate invocation triggered by the Resonate Server, so a long research tree does not hold any single function instance open for its duration.

Worker reassignment between fan-out and fan-in. Replay reconstructs the handles array deterministically; the parent does not require the child to run on the same physical worker.

Platform variants

Variant | Deployment shape | What's identical | What's different
example-openai-deep-research-agent-ts | Plain Node script (tsx src/index.ts <id> <topic> [depth]) | Base — research generator and primitives | OpenAI client at module scope; new Resonate() with default config; CLI parses id, topic, depth from process.argv (src/index.ts:11-12); SDK @resonatehq/sdk ^0.10.0
example-openai-deep-research-agent-cloudflare-ts | Cloudflare Worker (wrangler dev / wrangler deploy) | Workflow + primitives | Entry exports resonate.handlerHttp() (src/index.ts:19); OpenAI client built at isolate init via resonate.onInitialize(env => resonate.setDependency("aiclient", new OpenAI(...))) (src/index.ts:8-15); workflow pulls it with ctx.getDependency("aiclient") + ctx.assert and threads it through ctx.run(prompt, aiclient!, messages, depth > 0) (src/agent.ts:60-61, 71); prompt signature takes aiclient: OpenAI as a parameter (src/agent.ts:26-31); SDK @resonatehq/cloudflare ^0.1.2
example-openai-deep-research-agent-gcp-ts | Google Cloud Function via functions-framework, target handler | Workflow + primitives + module-scope OpenAI client | Entry exports handler = resonate.handlerHttp() (src/index.ts:9); local dev script: npm run build && npx functions-framework --target=handler --port=8080 --source=dist (package.json:12); SDK @resonatehq/gcp ^0.1.6
example-openai-deep-research-agent-supabase-ts | Supabase Edge Function (Deno) at supabase/functions/deep-research-agent/ | Workflow + primitives | Single-file entry calls resonate.httpHandler() (line 116); registration is resonate.register(research) with no string name (line 107) — the other TS variants pass "research" explicitly; OpenAI client built at module top-level from Deno.env.get("OPENAI_API_KEY") and registered via setDependency (lines 109-114); workflow pulls and asserts it on lines 61-62 and threads it through ctx.run(prompt, aiclient!, messages, depth > 0) (line 72); prompt signature takes aiclient: OpenAI (lines 27-32); verify_jwt = false in supabase/config.toml:4; SDK @resonatehq/supabase ^0.1.4
example-openai-deep-research-agent-py | Python module (uv run research.py) | Workflow shape, system prompt, tool schema | @resonate.register decorator (research.py:60); ctx.lfc(prompt, ...) replaces ctx.run (research.py:71); ctx.rfi(research, ...) replaces ctx.beginRpc (research.py:83); SDK resonate-sdk >=0.6.7; hardcoded research.1 id and topic in __main__ (research.py:93)
example-openai-deep-research-agent-rs | Rust binary (cargo run --bin research -- <id> <topic> [depth]) | Workflow shape, system prompt, tool schema | #[resonate::function] on both research and prompt (src/main.rs:63, 135-136); recursive spawn via ctx.run(research, (subtopic, depth - 1)).spawn().await? (src/main.rs:110-113); prompt takes args: PromptArgs rather than a &Context (src/main.rs:136); OpenAI call hand-rolled with reqwest (src/main.rs:168-202); Resonate::new(ResonateConfig { url: Some("http://localhost:8001"), ... }) (src/main.rs:220-223); SDK pulled from github.com/resonatehq/resonate-sdk-rs branch main

The TypeScript base, GCP, Python, and Rust variants all build the OpenAI client at module / process scope. Cloudflare and Supabase build it inside the SDK's dependency container at function init — Cloudflare via an onInitialize callback that fires once when the Worker isolate boots, Supabase at module top-level using Deno.env.get(...) — because their runtimes can't expose process.env to top-level Node code the way the plain Node base can. The workflow body in those two variants adds two lines — a ctx.getDependency("aiclient") lookup and a ctx.assert(...) guard — threads aiclient as an extra argument into ctx.run(prompt, ...), and changes the prompt function signature to take aiclient: OpenAI as a parameter. The recursive shape is otherwise the same generator.

When to reach for this pattern

If you are building an agent whose call graph is decided by the model at runtime (decompose, recurse per branch, synthesize) and you want crash-resumable execution without writing your own checkpoint store.

If you need fan-out / fan-in where the fan width is dynamic (the model decides how many subtopics) and each branch is itself a recursive workflow.

If you need to bound recursion depth so the model is forced to summarize at the leaves — pass depth and gate tool access on depth > 0.

If you are on Cloudflare Workers, Google Cloud Functions, or Supabase Edge Functions and need per-step durability across HTTP-triggered invocations — use the matching platform variant, which exposes an HTTP entrypoint the Resonate Server calls into.

If your team is on Python, use the Python variant — ctx.lfc and ctx.rfi map one-to-one onto the TypeScript primitives. On Rust, use the Rust variant — ctx.run(...).spawn() replaces the explicit beginRpc primitive.

If you want to inspect a recursive run's call tree mid-flight, resonate tree <root-id> shows every spawned child and its state (see the Cloudflare and Supabase READMEs for example trees).

Sources

Example repos:

https://github.com/resonatehq-examples/example-openai-deep-research-agent-ts

https://github.com/resonatehq-examples/example-openai-deep-research-agent-cloudflare-ts

https://github.com/resonatehq-examples/example-openai-deep-research-agent-gcp-ts

https://github.com/resonatehq-examples/example-openai-deep-research-agent-supabase-ts

https://github.com/resonatehq-examples/example-openai-deep-research-agent-py

https://github.com/resonatehq-examples/example-openai-deep-research-agent-rs

Resonate SDK repos:

https://github.com/resonatehq/resonate-sdk-ts

https://github.com/resonatehq/resonate-sdk-py

https://github.com/resonatehq/resonate-sdk-rs

Resonate docs (deep research agent pattern): https://docs.resonatehq.io/get-started/examples/deep-research-agent