Skip to content

Repository files navigation

react-agent-loop

A minimal, bring-your-own-LLM ReAct (Reason + Act) agent loop for TypeScript.

CI License: MIT TypeScript Runtime deps

⚠️ ReAct = Reasoning and Acting — the LLM agent pattern of Think → Act → Observe (Yao et al., 2022). This is NOT React.js, the UI library. There is no JSX, no components, no DOM here — just an agent control loop.

react-agent-loop is a tiny, dependency-free control loop for building tool-using LLM agents. It implements the classic Reason + Act cycle — the model thinks, acts by calling tools, observes the results, and repeats until the task is done — while leaving every external concern (which LLM, which tools, how to persist) to you.

Why

Most agent frameworks bundle a specific LLM SDK, a vector store, a prompt DSL, and a runtime you have to adopt wholesale. This one doesn't.

  • A clean Think → Act → Observe loop you can drop any LLM into. Implement one method (complete) over OpenAI, Anthropic, a local model, or a mock.
  • Pluggable tools via a small ToolRegistryname, description, JSON-schema parameters, and an async handler.
  • Hooks for guardrails. beforeToolCall is the seam where you plug in a loop detector, a policy engine, or a budget guard to block or rewrite actions before they run.
  • Zero runtime dependencies. Node ≥ 20, ESM, strict TypeScript, full type declarations.
  • Bring your own everything — no SDK lock-in, no hidden network calls, no global state.

Install

npm i github:Princeu3/react-agent-loop

npm registry release coming soon. For now install straight from GitHub.

Quick start

import { runReActLoop } from "react-agent-loop";
import type { LlmClient, Tool } from "react-agent-loop";

// 1. Define a tool. `parameters` is a JSON Schema object.
const getWeather: Tool = {
  name: "get_weather",
  description: "Get the current weather for a city.",
  parameters: {
    type: "object",
    properties: { city: { type: "string" } },
    required: ["city"],
  },
  handler: async (args) => {
    const city = String(args.city);
    // ...call a real API here...
    return `It is 22°C and sunny in ${city}.`;
  },
};

// 2. Implement LlmClient over your provider (OpenAI shown; Anthropic works too).
//    The library never imports an SDK — you own the network call.
const llm: LlmClient = {
  async complete({ system, messages, tools }) {
    // Map `messages` + `tools` to your provider's format, call it, then map
    // the response back to { text?, toolCalls? }.
    const res = await callYourProvider({ system, messages, tools });
    return { text: res.text, toolCalls: res.toolCalls };
  },
};

// 3. Run the loop. It registers a built-in `task_done` tool the agent calls
//    to finish. Think → Act → Observe repeats until then (or maxTurns).
const result = await runReActLoop({
  llm,
  tools: [getWeather],
  system: "You are a helpful weather assistant. Call task_done when finished.",
  initialMessages: [{ role: "user", content: "What's the weather in Lisbon?" }],
  maxTurns: 10,
});

console.log(result.stopped);      // "task_done" | "shouldStop" | "maxTurns"
console.log(result.finalSummary); // summary the agent passed to task_done
console.log(result.turns);        // number of turns executed

Guardrails via beforeToolCall

const result = await runReActLoop({
  llm,
  tools: [getWeather, deleteEverything],
  system: "…",
  hooks: {
    // Block dangerous actions before they execute. Return { block, reason }.
    beforeToolCall(call) {
      if (call.name === "deleteEverything") {
        return { block: true, reason: "Blocked by policy: destructive action." };
      }
    },
    // Inject fresh context each turn (e.g. retrieved memory).
    buildContext(state) {
      // mutate state.messages here if needed
    },
    // Stop early on a custom condition.
    shouldStop: (state) => state.turn > 3 && somethingHappened(state),
    // Persist each completed turn.
    persistTurn: (turn) => saveToDb(turn),
  },
});

A blocked tool is not executed; instead a tool message carrying your reason is appended so the model can react and try something else.

API

runReActLoop(options): Promise<AgentRunResult>

Option Type Default Description
llm LlmClient Your provider adapter. Required.
tools Tool[] | ToolRegistry Tools the agent may call. Required.
system string System prompt. Required.
initialMessages Message[] [] Seed conversation (e.g. the user request).
hooks AgentHooks {} Lifecycle + guardrail hooks (all optional).
maxTurns number 25 Hard ceiling before the loop force-stops.
stopToolName string "task_done" Name of the tool that ends the run.
context Ctx Arbitrary value exposed on state.context.

Each turn: build context → llm.complete → append assistant message → for each tool call: beforeToolCall guard (skip + record if blocked) else run the handler → append the tool result as an observation → onToolResult → persist the turn. The loop stops when the stopToolName tool is called, shouldStop returns true, or maxTurns is reached.

Returns AgentRunResult: { stopped, turns, messages, finalSummary? }.

Core types

  • Message{ role: "system" | "user" | "assistant" | "tool"; content; toolCalls?; toolCallId?; name? }.
  • ToolCall{ id, name, arguments }.
  • ToolResult{ toolCallId, content, isError? }.
  • Tool{ name, description, parameters, handler(args, ctx) }. A handler may return a ToolResult or a plain string.
  • ToolRegistryregister / get / has / list / toSpecs.
  • LlmClient — you implement complete({ system, messages, tools }): Promise<{ text?, toolCalls? }>.
  • AgentHooksbuildContext, onTurnStart, beforeToolCall, onToolResult, onTurnEnd, shouldStop, persistTurn (all optional).

A built-in task_done tool is registered automatically (unless you supply one by that name); the agent calls it with a summary to finish.

Pairs with

beforeToolCall is a deliberate extension point. Compose it with:

  • agent-loop-detector — detect repetitive tool-call patterns and break out of loops (wire it into beforeToolCall / shouldStop).
  • agent-policy-engine — allow/deny/rewrite tool calls against a policy (wire it into beforeToolCall).
  • agent-budget-guard — cap turns, tokens, or spend and stop when exhausted.

Attribution

Distilled and authored by Prince Upadhyay (@Princeu3). The control-flow architecture is inspired by the MIT-licensed Conway-Research/automaton agent loop, re-authored here as a clean, framework-agnostic library with all project-specific coupling (SQLite, orchestration, provider SDKs) removed. See NOTICE.

Keywords

ai-agents, react-agent, reasoning-and-acting, agent-framework, llm-agents, tool-use, agent-loop, autonomous-agents, think-act-observe, llm, typescript, agentic, tool-calling, openai, anthropic — a ReAct (Reason + Act, not React.js) agent loop for building tool-using LLM agents in TypeScript.

License

MIT © 2026 Prince Upadhyay

About

A minimal, dependency-free ReAct (Reasoning + Acting) agent loop framework for TypeScript — bring your own LLM, register tools, and run a Think-Act-Observe loop with pluggable guardrail hooks.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages