ReAct Loop

ReAct stands for Reasoning + Acting. The agent thinks about its current state, chooses an action, observes the result, and repeats until it completes the objective or hits a limit.


Iteration Lifecycle

Each iteration follows a strict cycle:

sequenceDiagram participant Engine participant LLM participant Tool Engine->>LLM: System prompt + history + "What next?" LLM->>Engine: { thought, action, action_input } Engine->>Engine: Validate action is in tool allowlist Engine->>Tool: Execute action with action_input Tool->>Engine: Observation (result) Engine->>Engine: Append to history, check limits Note over Engine: Repeat until __complete__<br/>or max_iterations reached

Step 1: Prompt. The engine constructs a prompt containing the objective, available tools, and the history selected by the configured context strategy.

Step 2: Decide. The model returns a structured JSON object with a short rationale, the selected action, and its parameters.

Step 3: Validate. The engine checks that the chosen action is in the declared tool list. Undeclared actions are rejected.

Step 4: Execute. The engine runs the tool and captures the result as an observation.

Step 5: Continue or stop. The rationale, action, and observation are appended to the iteration history. The engine checks its limits and stuck policy before the next turn.


LLM Response Format

The agent must respond with this JSON structure on every iteration:

json
{
  "thought": "I need to look up the customer's order history before I can assess the refund request.",
  "action": "get_order_history",
  "action_input": {
    "customer_id": "cust-12345",
    "limit": 10
  }
}
Field Type Description
thought string Short model-provided rationale for the selected action
action string Tool to call; it must be in the declared tools list
action_input object Parameters for the tool call

The thought field records the model's stated reason for choosing the action. Hyphen stores it with the action record when trace capture is enabled.


Prompt Construction

The engine constructs the LLM prompt from three parts:

System Prompt

Sets the agent's role, available tools, and response format requirements. The prompt includes the objective, each tool's description and parameter schema, and instructions for the agent to respond with structured JSON containing thought, action, and action_input fields. Security hardening rules are appended to help the model resist prompt injection from untrusted input data.

Iteration History

Every previous step is appended as context, giving the agent full visibility into what it has already done:

text
Step 1:
  Thought: "I need to look up ticket #12345 to understand the issue."
  Action: lookup_ticket
  Input: { "ticket_id": "12345" }
  Observation: { "subject": "Billing Error", "status": "open", "priority": "high" }

Step 2:
  Thought: "The ticket is about a billing error. Let me analyze the sentiment."
  Action: analyze_sentiment
  Input: { "text": "I've been charged twice for my subscription..." }
  Observation: { "sentiment": "negative", "urgency": "high" }

Current Turn Prompt

After the objective, tools, and history, the engine asks the model to determine its next action based on what it has learned so far.


Context Accumulation

As iterations progress, the agent builds up knowledge:

Iteration What the Agent Knows
1 Objective only
2 Objective + first tool result
3 Objective + two tool results + its own reasoning about them
N Full history of thoughts, actions, and observations

Each observation becomes available for the agent to reference in subsequent reasoning. The agent effectively has a growing "working memory" of everything it has done and learned.


Token Management

The iteration history grows with each cycle. For long-running agents, this can approach the LLM's context window limit. Hyphen manages this by:

  • Including the full history up to the context window limit
  • Summarizing older iterations when the history exceeds available tokens
  • Prioritizing recent iterations and the original objective

Set max_iterations conservatively to prevent excessive token consumption. Most tasks complete in 5-10 iterations. Complex orchestration tasks may need 15-20.


Termination Conditions

The loop ends when any of these occur:

Condition Result
Agent calls __complete__ Success: the agent's answer is stored in context
max_iterations reached Failure: timeout, no answer produced
timeout_ms exceeded Failure: execution time limit
Agent calls __pause_for_human__ Paused: waiting for human input
Stuck detection triggers with action: "fail" Failure: agent was looping
Stuck detection triggers with action: "escalate" Paused: routed to human

A step that declares items and on_incomplete: "continue_to_review" completes with an incomplete result instead of failing when the agent stops at a limit. See Per-Case Results.


Per-Case Results

When one agent works through a set of cases, declare the case ids with items and the shape of each case's result with item_schema. The agent then gets a __submit_item__ tool and submits each case as soon as it finishes it, so finished work is kept even if the agent later runs out of iterations or time.

json
{
  "type": "loop",
  "properties": {
    "mode": "react",
    "objective": "Investigate each case in this packet. Call __submit_item__ for each case id as soon as you finish it. When the evidence does not prove a cause, choose investigation_required.",
    "tools": [
      { "type": "action", "name": "lookup_ledger_entries" }
    ],
    "max_iterations": 14,
    "on_stuck": { "iterations": 3, "action": "fail" },
    "items": "@packet.case_ids",
    "item_schema": {
      "type": "object",
      "required": ["disposition", "rationale"],
      "properties": {
        "disposition": { "type": "string", "enum": ["timing_difference", "bank_charge", "investigation_required"] },
        "rationale": { "type": "string" },
        "needs_human": { "type": "boolean" }
      }
    },
    "on_incomplete": "continue_to_review",
    "result_key": "caseReview"
  }
}
Property Type Required Description
items @path / array With item_schema Case ids for this agent: 1 to 50 unique strings or numbers, or an @path that resolves to them when the step starts
item_schema object With items JSON Schema every submitted result must match. Cannot be combined with output_schema
on_incomplete "continue_to_review" No Requires items and item_schema. When the agent stops at a limit, the step completes with the cases submitted so far instead of failing

__submit_item__ is added only when the step declares items and item_schema. Do not list it in tools. The agent calls it with a case_id and a result:

json
{
  "thought": "Case C-102 is the monthly account fee on the statement.",
  "action": "__submit_item__",
  "action_input": {
    "case_id": "C-102",
    "result": { "disposition": "bank_charge", "rationale": "Statement line matches the monthly account fee of 25.00." }
  }
}

The observation is { "accepted": true, "case_id": "C-102", "remaining": ["C-101", "C-103"] }, or a refusal such as { "accepted": false, "case_id": "C-102", "errors": ["/disposition must be equal to one of the allowed values"] }.

  • Each submission is validated against item_schema when it is made. An invalid result is refused with the reasons and nothing is stored, so the agent can correct it and submit again.
  • A case_id outside items is refused.
  • A later valid submission for the same case replaces the earlier one.
  • Submissions are saved with the agent run's checkpoint every iteration, so they survive a timeout or a budget stop.
  • A submission writes nothing outside the agent run. Later steps decide what is applied.

The cases Result

The step result under result_key carries cases: one entry per declared case id, in the order of items.

json
{
  "status": "incomplete",
  "react_status": "max_iterations",
  "incomplete_reason": "max_iterations",
  "iterations": 14,
  "cases": [
    {
      "case_id": "C-101",
      "proposal": { "disposition": "timing_difference", "rationale": "Settles in the next statement, two days after the ledger entry." },
      "needs_human": false,
      "revision": 1,
      "submitted_at": "2026-10-10T10:02:11.000Z"
    },
    { "case_id": "C-102", "proposal": null, "needs_human": true },
    { "case_id": "C-103", "proposal": null, "needs_human": true }
  ]
}
  • case_id is always a string.
  • proposal is the latest validated result, or null when the agent did not submit one.
  • needs_human is true when there is no proposal, or when the proposal itself sets needs_human: true.
  • revision counts the accepted submissions for that case. revision and submitted_at appear only on submitted cases.

Next to cases, the result lists which cases a review can decide:

  • proposed_cases: the cases with a validated proposal that does not ask for a person, in full.
  • proposed_case_ids: their case ids.
  • open_case_ids: every other case id, with no proposal or with one that sets needs_human: true.

Approving the packet should decide only proposed_cases. Write decisions, claims, and follow-ups for them, and leave the open cases for another review. A transaction can filter its rows to proposed_case_ids.

What Happens When the Agent Stops

  • The agent calls __complete__. The step completes with status: "completed" and cases. Cases the agent never submitted are needs_human: true. With items, answer is JSON text holding the cases and the agent's own answer as summary, so read @<result_key>.cases instead of parsing it.
  • The agent reaches max_iterations, timeout_ms, an LLM budget limit, or stuck detection with action: "fail". With on_incomplete: "continue_to_review", the step completes with status: "incomplete", incomplete_reason set to the agent's ending status (max_iterations, timeout, budget_exceeded, or stuck), and cases. The run continues to the next step. Without on_incomplete, the step and the run fail as before.
  • The agent pauses with __pause_for_human__ or stuck detection with action: "escalate". Unchanged: the step pauses for a person.
  • The agent run fails (an engine or provider error) or is cancelled. Unchanged: on_incomplete does not apply.
  • items does not resolve to 1 to 50 unique case ids when the step starts. The agent run fails.

A later step reads the cases from context. Here an approval shows a person every proposal, and the cases with no proposal:

json
{
  "type": "PbotApproval",
  "properties": {
    "comment": "Review the agent's proposals. Cases marked needs_human have no proposal and need your judgement.",
    "request_payload": {
      "packet_id": "@packet.packet_id",
      "review_status": "@caseReview.status",
      "cases": "@caseReview.cases"
    }
  }
}

Configuration Reference

json
{
  "type": "loop",
  "properties": {
    "mode": "react",
    "objective": "Your task description here",
    "tools": [
      { "type": "action", "name": "tool_a" },
      { "type": "action", "name": "tool_b" }
    ],
    "model": "gpt-4",
    "max_iterations": 10,
    "timeout_ms": 300000,
    "temperature": 0.7,
    "on_stuck": {
      "iterations": 3,
      "action": "escalate"
    },
    "include_reasoning_trace": true,
    "result_key": "agentResult"
  }
}

Implicit tools are auto-injected. Built-in tools like __complete__, __pause_for_human__, __store_memory__, __retrieve_memory__, and __log_progress__ are always available: you don't need to declare them. Only declare your custom action and workflow tools. See Tool Declarations for the full format reference.

→ Next: [Built-in Tools](/agents/built-in-tools)