agents — ReAct/MRKL agents, AgentExecutor, plan-and-execute

ReAct/MRKL agents, AgentExecutor, plan-and-execute

Module langchain_classic/agents/ — ReAct/MRKL agents, AgentExecutor, plan-and-execute. Part of the Orchestration group.

🎯 Why This Module Exists

This module is documented from the agents_deep.md Phase 1B research artifact. See sections below for design decisions, algorithms, error philosophy, performance, and evolution clues.

146
Source files
17
Sub-directories
Orchestration
Category
Python
Language

ReAct Tool-using Iterative 17 sub-modules

Architecture

flowchart LR
  subgraph Core["langchain_classic/agents/"]
    BASE["Base abstraction"]
    IMPLS["Concrete implementations"]
    BASE --> IMPLS
  end
  subgraph Up["Upstream callers"]
    U1["chains/"]
    U2["agents/"]
  end
  subgraph Down["Downstream"]
    D1["langchain_core"]
    D2["External providers"]
  end
  Up --> Core
  Core --> Down

Data Flow

sequenceDiagram
  participant Caller as Caller
  participant Agents as agents
  participant Core as langchain_core
  participant Ext as External
  Caller->>Agents: invoke(input)
  Note over Agents: Validate & route
  Agents->>Core: delegate primitive
  Core->>Ext: provider call
  Ext-->>Core: response
  Core-->>Agents: typed output
  Agents-->>Caller: result

Existence rationale

agents/ packages the act-observe-think loop that turns an LLM into a tool-using agent. Where a Chain runs a fixed pipeline, an Agent returns either "call this tool with these args" or "we're done, here's the final answer", and AgentExecutor runs that decision in a loop, accumulating a trajectory of (action, observation) pairs. Without agents/, library users would have to hand-write the parse → tool-dispatch → re-prompt cycle, which is exactly the boilerplate that distinguishes ReAct, MRKL, OpenAI Functions, OpenAI Tools, Structured Chat, and JSON Chat agents from one another. The module exists to (a) capture that loop once, (b) make agent flavours interchangeable behind a single executor, and (c) provide streaming via AgentExecutorIterator.

Design decisions visible in the code

DecisionChoice madePlausible alternativesInferred rationale
Agent return typeUnion[AgentAction, AgentFinish]bool flag + payload; Result typeTwo unrelated payload shapes (action call vs final string) — a tagged union beats a flag-bool
Multi-action supportBaseMultiActionAgent.plan() returns list[AgentAction]Always one action per stepOpenAI tools/parallel function calls return multiple at once; not all flavours support it
Loop controlAgentExecutor owns the loop, agent is statelessAgent owns loopLets users swap agent strategy without rewriting executor; supports iteration / streaming
Output parsingPluggable AgentOutputParser per flavourOne parser keyed by AgentTypeEach flavour has its own format (ReAct text, JSON, XML, OpenAI tool calls); strategy is the right pattern
Scratchpad renderingPluggable format_scratchpad/* functionsHardcoded formatSame agent loop with different prompt rendering — ReAct text vs OpenAI tool messages vs XML
Tool inputPydantic schema on BaseTool.args_schemadict-onlyPydantic gives validation, JSON-schema export (for OpenAI fn calling), and natural-language description for prompts
Stop conditionsmax_iterations AND max_execution_timeiterations onlyWall-clock cap protects against pathological tool latency / model loops
Early stopping methods"force" (return last text) or "generate" (LLM final call from trajectory)Hard failSome agents loop forever near the cap; "generate" produces a graceful summary
Initialisation APITwo surfaces: initialize_agent(AgentType, …) (legacy) and create_*_agent(...) factories returning RunnablePick oneFactories yield LCEL-friendly Runnables; legacy enum kept for backwards compat

Algorithm deep-dives

1. The agent loop (AgentExecutor._call)

Problem. Run an agent until it returns AgentFinish or hits an iteration / time cap, while invoking the right tool, propagating callbacks, and streaming intermediate state.

Trace.

  1. Initialise intermediate_steps: list[tuple[AgentAction, str]] = [].
  2. Loop:
  3. Build prompt inputs {"input": q, "intermediate_steps": intermediate_steps, "tool_names": tool_names, "tools": tools_doc}.
  4. The agent (a Runnable) runs prompt-formatter → LLM → output-parser → returns AgentAction|AgentFinish.
  5. If AgentFinish: call on_agent_finish, return outputs.
  6. Else for each AgentAction:
  1. Check iterations >= max_iterations or time_elapsed >= max_execution_time → return_stopped_response(early_stopping_method).
  2. Output dict has output, optionally intermediate_steps.

Complexity. Per iteration: 1 LLM call + N tool calls (1 for single-action, ≥1 for multi). Total iterations bounded by max_iterations (default 15).

Why this design. The executor is stateless across calls — all state flows through intermediate_steps. This makes the agent serialisable mid-run for retry, makes streaming trivial (the iterator just yields after each step), and lets users implement custom termination by subclassing AgentExecutor and overriding _should_continue.

Edge cases.

2. Scratchpad formatting (format_scratchpad/)

Problem. Different agent prompts expect different serialisations of the running trajectory. ReAct expects plaintext (Thought: … Action: … Observation: …), OpenAI tools expects a list of ToolMessage objects, XML expects <tool_input> tags.

Solutions.

The agent's prompt template references {agent_scratchpad}; the chosen formatter is the only thing that varies per flavour.

3. Output parsing (agents/output_parsers/)

Problem. Convert raw LLM text → AgentAction|AgentFinish.

ReAct (react_single_input.py):

  1. Look for Final Answer: → AgentFinish(return_values={"output": text_after}).
  2. Else regex Action: <tool>\nAction Input: <input> → AgentAction(tool, input, log=raw_text).
  3. Both miss → OutputParserException (caller decides recovery).

OpenAI tools (openai_tools.py): no parsing — the LLM has already produced structured tool_calls; convert directly.

JSON Chat (json.py): expect {"action": "...", "action_input": ...} blob; parse with json.loads, fall back to parse_json_markdown for fenced blocks.

XML (xml.py): regex <tool>name</tool><tool_input>...</tool_input> → AgentAction.

Why pluggable. Each format makes the LLM more or less reliable depending on training data — Claude does best with XML, OpenAI tool-tuned models do best with native tools, smaller open models often ground better in JSON. The parser is interchangeable behind AgentOutputParser.

Error philosophy

Tolerate parser errors when configured; surface tool errors but feed them back as observations. The default behaviour is propagate (so the user notices), but handle_parsing_errors=True flips into a self-correct mode where the LLM sees its own mistake on the next iteration. This trades safety for autonomy — explicit opt-in.

InvalidTool (a built-in Tool returned when the LLM picks a non-existent name) is the cleanest example: the LLM hallucinated a tool, so we return "Tool X not found. Available tools: …" as the observation instead of crashing.

Performance characteristics

Evolution clues

Step-by-Step Walkthrough

Follow a typical operation through the agents/ module:

  1. Caller invokes the public surface — usually .invoke(input) on a Runnable, or a legacy __call__(inputs_dict) on a Chain subclass.
  2. Pydantic validation — every entry point is a BaseModel; constructor args, input dicts, and tool args_schema are validated before any expensive work runs. Fail-fast on type errors.
  3. CallbackManager.configure — a fresh manager is built per call, fans on_*_start events to all subscribers (LangSmith tracer, stdout handler, custom logging), and stamps a UUID4 run_id for tree reconstruction.
  4. agents-specific logic — the module's core method runs: prompt rendering, retrieval, tool execution, memory load, document split, etc. See the deep-dive sections above.
  5. Provider call — typically a single HTTP request to OpenAI/Anthropic/etc., or a local SDK call. Retries are handled by tenacity with exponential backoff and a configurable max-attempts knob.
  6. Streaming fan-out — if the provider streams tokens, each chunk fires on_llm_new_token through the callback manager so downstream observers (UI, log, tracer) see partial output.
  7. OutputParser pass — if a parser is attached, the raw provider output is parsed into a structured Python object; OutputParserException may trigger a retry chain (RetryWithErrorOutputParser).
  8. Memory write-back — for chains/agents that have a BaseMemory attached, save_context(inputs, outputs) persists the turn before returning to the caller.
  9. on_*_end fan-out — the callback manager closes the run span; tracers serialise the Run tree and ship it to LangSmith asynchronously.
  10. Result returned — caller receives a typed object (AIMessage, dict, BaseModel, list[Document], ...) matching the module's declared output schema.

What Could Go Wrong

Failure Mode Trigger Detection Recovery
Provider 5xx / rate-limit OpenAI/Anthropic/etc. transient error HTTP status from SDK tenacity retries with exponential backoff up to max_retries; then surface to caller
Output parser failure LLM returned malformed JSON / wrong schema OutputParserException in parser RetryWithErrorOutputParser re-prompts with the parser error included; or surface
Pydantic validation Caller passes wrong-shaped dict ValidationError at __init__ / invoke Surface immediately — no fallback is correct here
Callback handler exception User-supplied tracer raises Try/except around each fan-out Logged and swallowed by default; raise_error=True to opt in to propagation
Provider auth missing OPENAI_API_KEY unset SDK AuthenticationError Surface with a helpful message; no retry (would just fail again)

Performance Profile

Hot Paths

Resource Usage

Directory Map

Source tree for agents
agents/
├── __init__.py
├── agent.py
├── agent_iterator.py
├── agent_toolkits/
├── agent_types.py
├── chat/
├── conversational/
├── conversational_chat/
├── format_scratchpad/
├── initialize.py
├── json_chat/
├── load_tools.py
├── loading.py
├── mrkl/
├── openai_assistant/
├── openai_functions_agent/
├── openai_functions_multi_agent/
├── openai_tools/
├── output_parsers/
├── react/
├── schema.py
├── self_ask_with_search/
├── structured_chat/
├── tool_calling_agent/
├── tools.py
├── types.py
├── utils.py
└── xml/