The Reason-Act-Observe Loop

How an agent actually runs: the model reasons, calls a tool, sees the result and reasons again — plus how planning and task decomposition fit into that loop.

Every agent, on every platform, is the same small loop wrapped around a model call. The model reasons about the goal and the state so far, acts by emitting a structured request to call a tool, and then observes the tool's result, which is appended to the conversation and fed back in. The loop repeats until the model answers instead of calling a tool, or until you stop it. This is the pattern usually referred to as ReAct, and it is what Amazon Bedrock Agents runs for you and what the Strands Agents SDK exposes as its agent loop.

The mechanics, turn by turn

  1. You send the goal, the system instructions and the tool definitions (names, descriptions, JSON Schema for the inputs).
  2. The model returns either text or a toolUse block containing a tool name, an id and an argument object.
  3. Your code executes the tool. The model never executes anything — it only asks. This is the security boundary of the entire system.
  4. You append a toolResult block, matched to the id, and call the model again with the whole history.
  5. Repeat. The transcript grows by one reason-act-observe cycle each time.

Because the transcript is the agent's only working state, the observations you feed back matter enormously. A tool that returns a 400-row JSON dump will burn context and drown the signal; a tool that returns a summarised, already-filtered result keeps the agent sharp for longer.

Planning and decomposition

Reason-act-observe on its own is greedy: it picks a good next step, not a good overall route. Planning adds an explicit step where the model breaks the goal into sub-tasks before acting. Two flavours matter:

  • Plan-then-execute. The model writes the full plan up front, then works through it. Good when the steps are largely independent and you want the plan reviewed or logged before anything runs.
  • Interleaved planning. The model re-plans after each observation. More robust when early results change the shape of the task — which is most real work.

Task decomposition is the same idea one level down: turning "reconcile this invoice" into "fetch invoice", "fetch purchase order", "compare line items", "flag differences". Decomposition improves reliability mainly because each sub-task fits a single tool call with a checkable result.

Where the loop fails

  • Thrashing. The model retries a failing tool with near-identical arguments. Always cap iterations and detect repeats.
  • Context growth. Cost and latency rise on every turn because the whole transcript is re-sent. Trim or summarise old observations.
  • Observation poisoning. Tool output is untrusted input. Text fetched from a web page or a ticket body can contain instructions aimed at your agent; treat it as data, never as a new system prompt.
  • No stop condition. Define what "done" means, and enforce it in code as well as in the prompt — with a maximum number of turns, a wall-clock budget and a token budget.

Example

Example · python
import boto3, json

bedrock = boto3.client("bedrock-runtime")
TOOLS = {"tools": [{"toolSpec": {
    "name": "get_invoice",
    "description": "Fetch one invoice by id. Returns totals and line items.",
    "inputSchema": {"json": {
        "type": "object",
        "properties": {"invoice_id": {"type": "string"}},
        "required": ["invoice_id"],
    }},
}}]}

messages = [{"role": "user", "content": [{"text": "Is invoice INV-4471 duplicated?"}]}]

for turn in range(6):                      # hard cap - never loop unbounded
    r = bedrock.converse(
        modelId="anthropic.claude-3-5-sonnet-20240620-v1:0",
        messages=messages,
        toolConfig=TOOLS,
    )
    messages.append(r["output"]["message"])          # REASON + ACT

    if r["stopReason"] != "tool_use":
        print(r["output"]["message"]["content"][0]["text"])
        break

    results = []
    for block in r["output"]["message"]["content"]:
        if "toolUse" not in block:
            continue
        use = block["toolUse"]
        out = my_invoice_lookup(**use["input"])      # YOUR code runs the tool
        results.append({"toolResult": {
            "toolUseId": use["toolUseId"],
            "content": [{"json": out}],
        }})
    messages.append({"role": "user", "content": results})   # OBSERVE

When to use it

  • A claims agent looks up a policy, discovers the cover expired, and re-plans to a rejection letter instead of continuing down the payout path.
  • An SRE assistant caps itself at eight tool calls per alert so a flapping metrics API cannot spin the loop indefinitely.
  • A research agent summarises each fetched document before appending it to the transcript, keeping twenty sources within a workable context.

Discussion

  • Be the first to comment on this lesson.