# Build Your First AI Agent Loop: Tools, Results, and Stop Conditions

**Harness Engineering · Module 2 of 16**

> **Start here:** basic Python helps. No paid account is required for the offline lab.
>
> **Your goal:** make tool requests, observations, and stopping behavior visible before connecting a real model.

An assistant says it checked a policy. Where is the evidence that the lookup happened? The first useful implementation is small enough to answer that question from its event log.

We will build the loop around a fictional shop policy. It can read one policy and prepare an internal answer. It cannot refund a customer or send a message. The offline lab uses scripted model events so you can inspect the machinery without credentials. Replacing those events with real model responses is the point at which this becomes a model-driven agent.

![The loop requests an event, validates a tool call, records its result, and either continues or stops.](https://cdn.hashnode.com/uploads/covers/5fb3b801b589f1618af9d143/a15731b5-7410-4a06-861c-dad5efdd98a8.png)

*A proposed call is input to the executor. The returned observation is evidence of execution.*

## 1. Define three event types

A model adapter should give the loop a small, explicit contract. Here are our internal event shapes:

| Kind | Required information | Harness behavior |
|---|---|---|
| `tool` | Call ID, tool name, arguments | Validate, execute, attach a result |
| `final` | Draft text | Return a completed result |
| Failure | Error category and retryability | Stop or apply a bounded recovery policy |

This is a teaching contract, not a provider's wire format. An adapter translates the selected API's message blocks, finish reasons, and tool calls into it. Preserve structured tool information rather than flattening everything into a string.

The policy is deliberately explicit: unopened products are eligible within 14 calendar days with a receipt; coffee is a consumable; opened consumables are excluded. The example does not establish store credit, compensation, or exception authority.

## 2. Build a loop you can inspect

Save this complete example as `agent_loop.py`. It uses only Python's standard library.

```python
POLICY = "Coffee is a consumable. Opened consumables cannot be returned."


def run(events, max_steps=3):
    observations = []
    seen = set()
    for step, event in enumerate(events):
        if step >= max_steps:
            return {"status": "budget_exhausted", "observations": observations}
        if event.get("kind") == "final":
            return {"status": "completed", "draft": event["text"],
                    "observations": observations}
        if event.get("kind") != "tool":
            return {"status": "invalid_event", "observations": observations}
        call_id = event.get("id")
        if not isinstance(call_id, str) or call_id in seen:
            return {"status": "invalid_call_id", "observations": observations}
        seen.add(call_id)
        if event.get("name") != "lookup_policy":
            result = {"ok": False, "error": "tool_not_allowed"}
        elif event.get("args") != {"policy_id": "returns"}:
            result = {"ok": False, "error": "unknown_or_invalid_policy"}
        else:
            result = {"ok": True, "source_id": "returns-v1", "text": POLICY}
        observations.append({"call_id": call_id, "result": result})
    return {"status": "incomplete", "observations": observations}


events = [
    {"kind": "tool", "id": "c1", "name": "lookup_policy",
     "args": {"policy_id": "returns"}},
    {"kind": "final", "text": "Opened coffee is excluded under returns-v1."},
]
result = run(events)
assert result["status"] == "completed"
assert result["observations"][0]["result"]["source_id"] == "returns-v1"
assert run(events, max_steps=1)["status"] == "budget_exhausted"
assert run([])["status"] == "incomplete"
print(result)
```

Run `python3 agent_loop.py`. The result includes `completed`, a draft, and an observation associated with `c1`. This block was run locally while preparing the draft; its assertions passed. The sentence in `events` is supplied by the fixture. It is not generated or independently evaluated by the loop.

Notice two stopping cases. One event of budget is insufficient to execute the lookup and accept the final answer. An empty event sequence is incomplete, rather than a successful empty response. A live adapter must likewise distinguish completion from a closed connection.

## 3. Connect a model without changing the boundary

In a live implementation, ask the model for the next response using the question, tool definitions, and accumulated history. Execute permitted calls, append their results with the provider's required call IDs, and request another response. Stop on a valid final answer or a limit.

The [LangChain agent documentation](https://docs.langchain.com/oss/python/langchain/agents) provides a higher-level implementation through `create_agent`. Its responsibilities include coordinating model and tool interactions; your application still owns permissions, configuration, and acceptance tests. This minimal configuration sketch requires the package, a supported provider integration, credentials, and an entitled model:

```python
# Optional integration sketch; not executed in this draft.
import os
from langchain.agents import create_agent


def lookup_policy(policy_id: str) -> str:
    """Read the fictional returns policy; no external actions."""
    return POLICY if policy_id == "returns" else "Unknown policy"


agent = create_agent(
    model=os.environ["AGENT_MODEL"],
    tools=[lookup_policy],
    system_prompt="Draft using policy evidence. Report missing information.",
)
result = agent.invoke({"messages": [{
    "role": "user",
    "content": "Can opened coffee bought seven days ago be returned?",
}]})
print(result["messages"][-1].content)
```

Set the model value using the chosen integration's current instructions. A chat subscription does not establish API access. An endpoint described as compatible may still lack tool calling or the output schema you need.

## 4. Test the awkward path first

Try changing the proposed name to `issue_refund`. The executor returns `tool_not_allowed`; no refund operation exists here. Now change the policy ID to an unknown value. The observation reports failure. A real model should then acknowledge the missing evidence, but that is a separate behavior to evaluate.

Retries also belong to the harness. An authentication failure calls for configuration repair; a transient timeout may justify a limited retry. Repeating a write after a timeout requires checking whether it already happened. This lab has no external writes, authentication, wall-clock timeout, or persisted state.

> **Keep separate checks:** tool execution can be correct while the model's answer is wrong. The executor's successful lookup does not prove that the draft applied the exception.

## 5. Follow along

Add three fixtures: a disallowed tool, a repeated call ID, and a missing policy. Record expected status and observation before running them. Then draw the live adapter boundary and list which fields must survive translation.

**Expected result:** no forbidden operation executes, duplicate IDs stop the fixture, missing evidence remains visible, and every observation names the originating call. Keep the final answer's factual evaluation separate from these execution checks.

For a transfer exercise, specify a personal-chef assistant using a fictional ingredient list. Reading ingredients can share the same loop. Allergy information, missing ingredients, and purchasing permission need their own domain rules.

## Further reading

- [Hugging Face: the agent cycle](https://huggingface.co/learn/agents-course/unit1/agent-steps-and-structure) — actions and observations.
- [LangChain: agents](https://docs.langchain.com/oss/python/langchain/agents) — connecting actual models and tools.
- [Python documentation](https://docs.python.org/3/) — language and standard-library reference.

## Next module

**Engineer the harness:** add validation, permissions, budgets, and reliable error handling around the loop.
