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:
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:
{
"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:
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.
{
"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:
{
"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_schemawhen 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_idoutsideitemsis 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.
{
"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_idis always a string.proposalis the latest validated result, ornullwhen the agent did not submit one.needs_humanistruewhen there is no proposal, or when the proposal itself setsneeds_human: true.revisioncounts the accepted submissions for that case.revisionandsubmitted_atappear 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 setsneeds_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 withstatus: "completed"andcases. Cases the agent never submitted areneeds_human: true. Withitems,answeris JSON text holding the cases and the agent's own answer assummary, so read@<result_key>.casesinstead of parsing it. - The agent reaches
max_iterations,timeout_ms, an LLM budget limit, or stuck detection withaction: "fail". Withon_incomplete: "continue_to_review", the step completes withstatus: "incomplete",incomplete_reasonset to the agent's ending status (max_iterations,timeout,budget_exceeded, orstuck), andcases. The run continues to the next step. Withouton_incomplete, the step and the run fail as before. - The agent pauses with
__pause_for_human__or stuck detection withaction: "escalate". Unchanged: the step pauses for a person. - The agent run fails (an engine or provider error) or is cancelled. Unchanged:
on_incompletedoes not apply. itemsdoes 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:
{
"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
{
"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.