Recursive ReAct Agent
This tutorial demonstrates the controller primitive in a concrete setting. Recursion, delegation, and fan-out fan-in become almost trivial once you understand what controller gives you. We’ll build a recursive search agent as the working example.
Recursive ReAct agents are Reasoning + Action Agents that can break down a task and delegate them to N parallel instances of itself. Taking this one step further, each of these children can recurse as well.
This is a powerful pattern because
- Speed : N parallel workers outpace a single agent considerably
- Performance : each subagent handles a narrow task and performs better for it
- Context Isolation : the parent agent receives only the final summary, keeping its context uncluttered
- Lower Costs : narrower tasks mean shorter contexts per call
However, it is imperative to have depth guards to prevent runaway recursion.
In this example, we will implement a recursive search agent. We will use the prebuilt agent helpers to quickly set one up and then build one from scratch.
This tutorial assumes you have the Chat UI, etc. set up as in the other tutorials and only walk through setting up the nodes.
Using Prebuilt Agent Helpers
Creating a recursive ReAct agent using the prebuilt helpers is straightforward.
import { tavily } from '@tavily/core';
import { createRecursiveReactAgent } from '@cascaide-ts/helpers';
const tvly = tavily({ apiKey: process.env.TAVILY_API_KEY });
const SYSTEM_PROMPT = `
You are an expert technical AI assistant equipped with web search capabilities.
You have access to the following tools:
1. search_tool
Use for general web searches, finding documentation, or retrieving facts.
Pass a clear, concise query.
In addition you can also delegate to instances of yourself. Use this capability when the search scope is large.
`.trim();
export const nodes = {
...createRecursiveReactAgent('recursiveSearch', {
provider: 'openai',
model: 'gpt-5.4-nano',
systemPrompt: SYSTEM_PROMPT,
isStreaming: true,
env: 'server',
maxDepth : 2,
tools: [
{
name: 'search_tool',
description: 'Searches the web using the input query.',
parameters: {
type: 'object' as const,
properties: {
query: { type: 'string', description: 'The natural language query' },
},
required: ['query'],
},
execute: async (args: Record<string, any>) => {
const { query } = args;
const res = await tvly.search(query, {
searchDepth: 'basic',
maxResults: 5,
topic: 'general',
});
return res.results;
},
},
],
}).nodes,
};This will give you the nodes you can directly spread into your server side graph.
//server side
import {nodes as recursiveSearchNodes } from "@/bubbles/recursive"
export const serverWorkflowGraph: ServerWorkflowGraph = {
...recursiveSearchNodes
};
//client side
export const clientWorkflowGraph: ClientWorkflowGraph = {
recursiveSearchAgentNode: { name: 'RecursiveSearchAgentNode', isUINode: false, env: 'server' },
recursiveSearchToolNode: { name: 'recursiveSearchToolNode', isUINode: false, env: 'server' },
}
Manually Building a Recursive Search Agent
In this section, we will build a recursive search agent from scratch. We will manually implement depth guards
using cascadeId conventions, use the controller to handle self delegation, waiting for completion, and reading results.
We will define two nodes
recursiveSearchAgentNode: this will be an llm node (streaming). We will give it two toolssearch_toolanddelegate_to_selftool.recursiveSearchToolNode: this node will actually execute the tools and write results back. We will set up the depth guards here.
recursiveSearchAgentNode
We will first look at the code and then walk through each of the prep, exec, post steps.
import type {
StreamingServerNodeDefinition,
WorkflowContext,
StreamConfig,
StreamingExecOutput,
PostResult,
CascadeController,
Updates,
ServerNodeDefinition,
Spawns,
} from '@cascaide-ts/core';
import { callLLM, CanonicalToolCall, ToolParam,
buildErrorToolResultMessage,
buildToolResultMessage, buildTools,
CanonicalMessage, LLMProvider,
toProviderHistory
} from '@cascaide-ts/helpers';
type SearchAgentPrepOut = {
history: any[]; //Todo : type should be anthropicMessage[], as we convert before passing
cascadeId: string;
};
export const recursiveSearchAgentNode: StreamingServerNodeDefinition<SearchAgentPrepOut> = {
name: 'recursiveSearchAgentNode',
isUINode: false,
env: 'server',
isStreaming: true,
async prep(cascadeContext: WorkflowContext, initialContext: any): Promise<SearchAgentPrepOut> {
const cascadeId = initialContext.cascadeId;
const dataArray = cascadeContext[cascadeId];
const canonicalHistory = dataArray.flatMap((item: any) => item.history || []);
const history = toProviderHistory('anthropic', canonicalHistory);
return { history, cascadeId };
},
async exec(prepOutput: SearchAgentPrepOut, controller?: CascadeController): Promise<StreamConfig> {
const { history } = prepOutput;
const systemPrompt = `
You are an expert technical AI assistant equipped with web search capabilities.
You have access to the following tools:
1. search_tool
Use for general web searches, finding documentation, or retrieving facts.
Pass a clear, concise query.
2. delegate_to_self
Use this tool to break down tasks into subtasks and delegate to a fresh AI agent
`.trim();
const searchToolParams: ToolParam[] = [
{
name: 'search_tool',
description: 'Searches the web using the input query.',
parameters: {
type: 'object' as const,
properties: {
query: { type: 'string', description: 'The natural language query' },
},
required: ['query'],
},
},
{
name: 'delegate_to_self',
description: 'Break down a search task and delegate to instances of yourself',
parameters: {
type: 'object' as const,
properties: {
subtask: { type: 'string', description: 'The search subtask for the new instances to work on, be descriptive about answer expectations' },
},
required: ['subtask'],
},
},
];
const anthropicTools = buildTools('anthropic', searchToolParams);
const {stream, provider} = await callLLM('anthropic', 'claude-haiku-4-5-20251001', systemPrompt, history, anthropicTools, true);
return { stream, provider } as StreamConfig;
},
async post(execOutput: StreamingExecOutput): Promise<PostResult> {
const { assistantMessage, uiAssistantMessage, history, cascadeId, userId } = execOutput;
const pendingToolCalls = assistantMessage.tool_calls ?? [] as CanonicalToolCall[]
const isDifferent = uiAssistantMessage !== undefined &&
JSON.stringify(assistantMessage) !== JSON.stringify(uiAssistantMessage);
return {
updates: {
[cascadeId as string]: {
history: [assistantMessage as CanonicalMessage],
status: pendingToolCalls.length > 0 ? 'calling_tool' : 'complete',
lastUpdate: Date.now(),
},
} as Updates,
...(isDifferent ? {
uiUpdates: {
[cascadeId as string]: { history: [uiAssistantMessage as CanonicalMessage], status: pendingToolCalls.length > 0 ? 'calling_tool' : 'complete' },
},
} : {}),
spawns: pendingToolCalls.length > 0
? { // we spawn the hardcoded searchToolNode if there are tool calls
['recursiveSearchToolNode']: {
history: [assistantMessage],
toolCallsToExecute: pendingToolCalls,
cascadeId,
},
} as Spawns
: undefined,
};
}
};As you can see this is the same as in the case of a regular ReAct agent, except for the delegate_to_self tool.
prep: we flatmap out thehistory(canonical shape) and convert it into provider specific shape.exec: we define the system prompt and tools and return theStreamConfigfor cascaide to handle streaming.post: we accept the results from streaming, check if a separateuiMessageexists and returnPostResult. If there arependingToolCalls, we spawn therecursiveSearchToolNode.
All the magic happens in the tool node.
recursiveSearchToolNode
We will first look at the code and then walk through the steps.
import { tavily } from "@tavily/core";
const tvly = tavily({ apiKey: process.env.TAVILY_API_KEY });
type SearchToolPrepOut = {
searchCalls: CanonicalToolCall[];
delegationCalls: CanonicalToolCall[];
cascadeId: string;
depth: number;
userId: string;
}
type SearchToolExecOut = {
results: CanonicalMessage[];
cascadeId: string;
}
export const recursiveSearchToolNode: ServerNodeDefinition<SearchToolPrepOut, SearchToolExecOut> = {
name: 'recursiveSearchToolNode',
isUINode: false,
env: 'server',
isStreaming: false,
async prep(cascadeContext: WorkflowContext, initialContext: any): Promise<SearchToolPrepOut> {
const { toolCallsToExecute, cascadeId, userId } = initialContext;
const all: CanonicalToolCall[] = toolCallsToExecute ?? [];
const searchCalls = all.filter(tc => tc.name === 'search_tool');
const delegationCalls = all.filter(tc => tc.name === 'delegate_to_self');
const depth = getDepth(cascadeId);
return { searchCalls, delegationCalls, cascadeId, depth , userId};
},
async exec(prepOutput: SearchToolPrepOut, controller?: CascadeController): Promise<SearchToolExecOut> {
const { searchCalls, delegationCalls, cascadeId, depth, userId } = prepOutput;
// ── 1. Regular searches ───────────────────────────────────────────────
const searchResultsPromise = executeToolCalls(
searchCalls,
{
search_tool: async ({ query }) => {
return await tvly.search(query, {
searchDepth: 'basic',
maxResults: 5,
topic: 'general',
});
},
},
'anthropic',
);
// ── 2. Delegation calls ───────────────────────────────────────────────
const delegationResultsPromise = Promise.all(
delegationCalls.map(async (toolCall): Promise<{ toolCall: CanonicalToolCall; toolResult: CanonicalMessage }> => {
if (depth >= MAX_DEPTH) {
return {
toolCall,
toolResult: buildErrorToolResultMessage(
'anthropic',
toolCall,
`Max delegation depth (${MAX_DEPTH}) reached. Cannot spawn further sub-agents.`,
),
};
}
const subCascadeId = makeSubCascadeId(cascadeId);
await controller!.spawn({
['recursiveSearchAgentNode']: {
cascadeId: subCascadeId,
history: [
{
role: 'user',
content: toolCall.args.subtask,
} as CanonicalMessage,
],
userId
},
});
const subState = controller!.getCascadeState(subCascadeId);
const subHistory: CanonicalMessage[] = subState?.history ?? [];
const lastMessage = subHistory[subHistory.length - 1];
if (!lastMessage) {
return {
toolCall,
toolResult: buildErrorToolResultMessage(
'anthropic',
toolCall,
`Sub-agent cascade ${subCascadeId} completed but produced no messages.`,
),
};
}
return {
toolCall,
toolResult: buildToolResultMessage('anthropic', toolCall, lastMessage),
};
}),
);
// ── 3. Merge in parallel ──────────────────────────────────────────────
const [searchResults, delegationResults] = await Promise.all([
searchResultsPromise,
delegationResultsPromise,
]);
const toolResultMessages = [...searchResults, ...delegationResults]
.map(({ toolResult }) => toolResult);
return { results: toolResultMessages, cascadeId };
},
async post(execOutput: SearchToolExecOut): Promise<PostResult> {
const { results, cascadeId } = execOutput;
return {
updates: {
[cascadeId as string]: {
history: results,
status: 'complete',
},
} as Updates,
spawns: {
['recursiveSearchAgentNode']: {
history: results,
cascadeId,
},
} as Spawns,
};
},
};We will define a couple of generic helpers as well.
import { v7 as uuidv7 } from 'uuid';
const MAX_DEPTH = 1;
function getDepth(cascadeId: string): number {
const match = cascadeId.match(/^call(\d+)_/);
if (!match) return 0;
return parseInt(match[1], 10);
}
function makeSubCascadeId(parentCascadeId: string): string {
const depth = getDepth(parentCascadeId);
return `call${depth + 1}_${uuidv7()}`;
}
export type ManualToolExecute = (args: Record<string, any>) => Promise<unknown>;
export type ManualToolExecuteMap = Record<string, ManualToolExecute>;
export async function executeToolCalls(
calls: CanonicalToolCall[],
executeMap: ManualToolExecuteMap,
provider: LLMProvider,
): Promise<{ toolCall: CanonicalToolCall; toolResult: CanonicalMessage }[]> {
return Promise.all(
calls.map(async (toolCall) => {
const executeFn = executeMap[toolCall.name];
if (!executeFn) {
return {
toolCall,
toolResult: buildErrorToolResultMessage(provider, toolCall, `Unknown tool: ${toolCall.name}`),
};
}
try {
const rawResult = await executeFn(toolCall.args);
return {
toolCall,
toolResult: buildToolResultMessage(provider, toolCall, rawResult),
};
} catch (err: any) {
return {
toolCall,
toolResult: buildErrorToolResultMessage(provider, toolCall, err.message),
};
}
})
);
}Depth Guard, Conventions and Helpers
We use cascadeId conventions to enforce depth guards here.
cascadeIdstarting with “call” is a sub agent (delegated to by some parent)call{n}implies a depth of 1. So the first recursion will havecascadeIdsstarting withcall1
Our helpers getDepth and makeSubCascadeId reflect this. Please check out the conventions section for more information.
executeToolCalls is a simple helper function to execute functions as tools. Nothing fancy there.
prep
We split the tool calls into searchCalls and delegationCalls and we get the current depth from the cascadeId.
We retrieve other data like userId as well and return all required data to exec.
exec
This is where the magic happens.
First we execute the search calls as usual. What’s important here is to observe how we use the controller to handle recursion and depth guards.
First we make sure that MAX_DEPTH has not exceeded by checking the cascadeId. If it has exceeded, we simply construct an error tool response
letting the agent know that the depth has exceeded.
If MAX_DEPTH has not exceeded, we first create a new cascadeId for the agent which reflects it’s depth (+1 depth).
Then we use controller.spawn to initiate the new child agent instance. Please note that this method will return a promise that will only resolve
once the new cascade has completed. If you name another agent (not a new instance of the parent agent), this pattern is essentially multi agent supervisor.
Notice that you could also use the controller to spawn observer UI nodes for each child agent/recursion depth.
Once the promise resolves, we use controller.getCascadeState to read the result of the child agents. We merge the results
with the regular search tool calls. We then return the results and cascadeId to post.
post
This is identical to all the tool nodes we have seen so far. We return the PostResult (Updates and Spawns the recursiveSearchAgentNode).