Skip to content

withReActLoop

Inserts a built-in ReAct (Reason + Act) agent loop into the flow. Automatically handles the think → tool calls → observation → repeat cycle until the agent signals it's done or the iteration limit is reached.

Setup

typescript
import { FlowBuilder } from "flowneer";
import { withReActLoop } from "flowneer/presets/agent";
import { withTools } from "flowneer/plugins/tools";

const AppFlow = FlowBuilder.extend([withTools, withReActLoop]);

Usage

typescript
import { z } from "zod";

interface AgentState {
  question: string;
  history: string[];
  __toolResults?: any[];
  __reactOutput?: string;
  __reactExhausted?: boolean;
}

const calculatorTool = {
  name: "calculator",
  description: "Evaluate a math expression",
  params: {
    expression: {
      type: "string" as const,
      description: "The expression to evaluate",
    },
  },
  execute: ({ expression }: { expression: string }) => eval(expression),
};

const flow = new AppFlow<AgentState>()
  .withTools([calculatorTool])
  .withReActLoop({
    think: async (s) => {
      const response = await callLlm(buildReActPrompt(s));

      // Parse tool calls from LLM response
      const toolCalls = parseToolCalls(response);
      if (toolCalls.length > 0) {
        return { action: "tool", calls: toolCalls };
      }

      return { action: "finish", output: response };
    },
    maxIterations: 10,
    onObservation: async (results, s) => {
      s.history.push(`Tool results: ${JSON.stringify(results)}`);
    },
  })
  .then((s) => {
    if (s.__reactExhausted) {
      console.log("Agent hit iteration limit");
    } else {
      console.log("Answer:", s.__reactOutput);
    }
  });

Options

OptionTypeDefaultDescription
think(shared, params) => ThinkResult | Promise<ThinkResult>The reasoning step
maxIterationsnumber10Maximum think→act cycles
onObservation(results, shared, params) => void | Promise<void>Called after each tool execution round

ThinkResult Type

typescript
type ThinkResult =
  | { action: "finish"; output?: unknown } // done — stop the loop
  | { action: "tool"; calls: ToolCall[] }; // call these tools and loop

State Keys

KeyDirectionDescription
__toolsSet by withToolsThe ToolRegistry — required
__toolResultsRead in thinkResults from the last tool round
__reactIterationsInternalRunning iteration count — reset each .run() call
__reactOutputRead after loopThe output from the final { action: "finish" }
__reactExhaustedRead after looptrue if maxIterations was reached

How It Works

Behind the scenes, withReActLoop compiles down to:

.loop(
  (s) => !s.__reactFinished && (s.__reactIterations ?? 0) < maxIterations,
  (b) => b
    .startWith(increment __reactIterations; think → set __pendingToolCalls or __reactFinished)
    .then(execute tools from __pendingToolCalls → set __toolResults)
)
.then(mark __reactExhausted if needed; delete __reactIterations)

The iteration counter lives on shared.__reactIterations (not in a closure), so the loop resets correctly when .run() is called multiple times on the same flow instance.

Requires withTools

withReActLoop expects shared.__tools to be a ToolRegistry. Always call .withTools([...]) before .withReActLoop().

See Also