ReAct Tool Loop¶
react_loop() runs the standard think → act → observe loop with tool calling. Each round, the LLM may either return a final answer or request one or more tool calls. Tool calls are approved if an ApprovalGate is supplied, validated with the built-in JSON-Schema subset, executed with timeout, appended to the conversation, and the loop continues until the model answers or max_rounds is hit.
When to use / when not to use¶
| Use it when… | Avoid it when… |
|---|---|
| The model needs external information (search, API lookup, math, file access). | The model can answer from its own knowledge — single completion is cheaper. |
| Each tool returns a bounded, summarizable result. | Tool outputs are large blobs (PDFs, full HTML pages). Pre-summarize before returning. |
You can bound the loop (max_rounds). |
You need long-running stateful agents — use a runtime like agentic-runtime-platform. |
| Your provider supports OpenAI-style tool calling. | Your provider is text-only — tools won't work. |
Call flow¶
sequenceDiagram
participant App
participant react
participant Provider
participant Tool
App->>react: react_loop(provider, prompt, tools, max_rounds=8)
loop until final answer or max_rounds
react->>Provider: complete(messages, tools=schemas)
Provider-->>react: response (may contain tool_calls)
alt no tool_calls
react-->>App: PatternResult(value=content, ...)
else has tool_calls
react->>react: append assistant msg with tool_calls
loop each tool_call
react->>react: validate args against JSON Schema
react->>Tool: execute(**args) with timeout
Tool-->>react: result string (truncated to max_observation_chars)
react->>react: append role="tool" message
end
end
end
react-->>App: raise MaxIterationsError
Minimal example¶
import asyncio
import ast
import operator
import os
from executionkit import Provider, Tool, react_loop
# Safe AST-based math evaluator — never use eval() on LLM output.
_OPS = {
ast.Add: operator.add, ast.Sub: operator.sub,
ast.Mult: operator.mul, ast.Div: operator.truediv,
ast.Pow: operator.pow, ast.USub: operator.neg,
}
def _safe_eval(node: ast.AST) -> float:
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
return node.value
if isinstance(node, ast.BinOp):
return _OPS[type(node.op)](_safe_eval(node.left), _safe_eval(node.right))
if isinstance(node, ast.UnaryOp):
return _OPS[type(node.op)](_safe_eval(node.operand))
raise ValueError(f"Unsupported expression node: {type(node).__name__}")
async def calculator(expression: str) -> str:
tree = ast.parse(expression, mode="eval")
return str(_safe_eval(tree.body))
calc = Tool(
name="calculator",
description="Evaluate an arithmetic expression. Supports + - * / ** and unary -.",
parameters={
"type": "object",
"properties": {"expression": {"type": "string"}},
"required": ["expression"],
"additionalProperties": False,
},
execute=calculator,
timeout=2.0,
)
async def main() -> None:
async with Provider(
base_url="https://api.openai.com/v1",
api_key=os.environ["OPENAI_API_KEY"],
model="gpt-4o-mini",
) as provider:
result = await react_loop(
provider,
"What is (17 * 83) + (12 ** 3)? Use the calculator.",
tools=[calc],
max_rounds=4,
)
print(result.value) # 3139
print(result.metadata["rounds"]) # 2
print(result.metadata["tool_calls_made"]) # 2
asyncio.run(main())
Configuration knobs¶
| Parameter | Default | Description |
|---|---|---|
max_rounds |
8 |
Maximum think-act-observe cycles. Raises MaxIterationsError if hit. |
max_observation_chars |
12000 |
Truncation limit for each tool result before appending to history. |
tool_timeout |
None |
Per-call timeout override. Falls back to Tool.timeout (default 30.0s). |
temperature |
0.3 |
Lower = more deterministic tool selection. |
max_tokens |
4096 |
Per-completion token cap. |
max_cost |
None |
TokenUsage budget across all rounds. |
retry |
DEFAULT_RETRY |
Per-call retry config. |
max_history_messages |
None |
Cap message history length per round. Always preserves the original prompt. |
trace |
None |
Optional callback for llm_call_* and tool_call_* events. |
approval_gate |
None |
Optional ApprovalGate checked before each tool body is executed. Denied calls become tool observations so the model can recover. |
Tool definition¶
@dataclass(frozen=True, slots=True)
class Tool:
name: str
description: str
parameters: Mapping[str, Any] # JSON Schema for arguments
execute: Callable[..., Awaitable[str]] # async function returning a string
timeout: float = 30.0
Arguments are validated against parameters (JSON Schema) before execute is called. Validation covers required, additionalProperties: false, and primitive type checks (string, integer, number, boolean, array, object) — using stdlib only, no jsonschema dependency.
Validation is shape-only — content safety is the tool's job
This is a deliberate JSON-Schema subset. It does not recurse into nested object/array contents, and it ignores format, enum, pattern, and numeric/length range constraints (minimum, maxLength, …). It confirms the argument shape, not that values are safe or in range — your execute must still validate and sanitize the arguments it receives (e.g. path/SQL/shell inputs) before acting on them.
execute must be async and return a string. Convert non-string results yourself.
Approval gates¶
Pass an ApprovalGate when a human, policy service, or test double should approve tool execution before side effects happen:
from executionkit import ApprovalDecision, ApprovalGate, react_loop
gate = ApprovalGate(
lambda request: ApprovalDecision(
approved=request.subject == "read_only_search",
reason="writes require review",
)
)
result = await react_loop(provider, prompt, tools, approval_gate=gate)
The gate receives an ApprovalRequest whose subject is the tool name and whose metadata["arguments"] holds the parsed tool arguments (with metadata["tool_call_id"] for correlation). A denial does not call the tool; it appends a bounded observation such as Tool 'name' blocked by approval: reason and lets the model continue.
Metadata keys¶
| Key | Type | Meaning |
|---|---|---|
rounds |
int |
Think-act-observe cycles completed. |
tool_calls_made |
int |
Total individual tool invocations across all rounds. |
truncated_responses |
int |
LLM responses cut off due to finish_reason=length. |
truncated_observations |
int |
Tool results truncated due to max_observation_chars. |
messages_trimmed |
int |
Rounds where history was trimmed by max_history_messages. |
Cost characteristics¶
O(rounds)LLM calls. Bounded bymax_rounds. Each round = one completion regardless of how many tools are called.- Sequential. Each round depends on prior tool outputs — no parallelism across rounds. (Tools within a round run sequentially in the current implementation.)
- Context grows with every round unless
max_history_messagesis set. For loops > ~20 rounds or with verbose tools, setmax_history_messagesto bound the prompt size. - Tool failures don't crash the loop. Unknown tools, schema violations, timeouts, and exceptions return an error string as the observation; the LLM gets a chance to recover.
Errors¶
| Exception | Cause |
|---|---|
TypeError |
Provider does not satisfy ToolCallingProvider (missing supports_tools=True). |
MaxIterationsError |
max_rounds exhausted without a final answer. Includes cost and metadata. |
BudgetExhaustedError |
max_cost exceeded mid-loop. |
Security notes¶
- Never
eval()LLM output. The example above uses a safe AST walker. Treat all tool inputs as adversarial. - Tool errors return only the exception class name to the LLM (e.g.
"Tool 'X' failed: TimeoutError"), not the full message — this prevents leaking internal details to the model. - JSON-Schema validation runs before
executeis called. Tools withadditionalProperties: falsereject unknown keys; missingrequiredfields are caught. - Tool timeout defaults to 30 s but is overridable per-call via
tool_timeout=. Set short timeouts for network tools.