# Long-run reliability

Notice a loop, keep a plan in view, and condense a long history.

A long run fails in ways a short one never does. It repeats itself. It outgrows
its context window. It loses the plan it made twenty turns ago. It hits a limit
part way through a reply.

These mechanisms notice those states and act, instead of letting a run
spend its whole budget first or stop with nothing to show.

All of them are on by default. Each can be switched off.

## Noticing a loop

Sometimes a model gets stuck calling the same tool with the same arguments and
getting the same answer back, over and over. `LoopGuard` spots that and tells
the model it's going in circles.

It remembers each tool call the run makes as three things: the tool's name, the
arguments, and the result. When the most recent calls repeat exactly, it steps
in. A repeat can be a single call (`search("Q3 revenue")` three times, same
results each time) or a short pattern (`read_file("a.py")`, then
`read_file("b.py")`, then `a.py` again, and so on, with neither file changing).

```python
from harnessx import Agent, AgentConfig, HookEvent, Limits, LoopGuard

agent = Agent(config=AgentConfig(
    limits=Limits(loop_guard=LoopGuard(threshold=3, max_period=4)),
))
```

| Field | Default | Purpose |
|---|---|---|
| `enabled` | `True` | `LoopGuard(enabled=False)` switches the guard off |
| `max_period` | `4` | The longest pattern it looks for, in calls. `1` only catches the same call repeated; `4` also catches a rotation of four different calls |
| `threshold` | `3` | How many times in a row the pattern has to repeat before the guard steps in |
| `history` | `64` | How many recent calls it remembers |

The result matters as much as the call. Running the tests again after fixing a
bug is the same call twice, but the output changes, so it doesn't count as a
repeat. Polling a job counts only while the job keeps returning the same status;
the moment the status changes, the model is making progress.

When the guard steps in, the run carries on. The guard puts a note at the top
of the tool result the model is about to read (the first one, if the model
called several tools that turn):

```text
[harness] This exact call has now run 3 times with the same result. Repeating it
will not produce anything new. Re-read what you have, then either take a
different approach or stop and explain what is blocking you.
```

It also fires `HookEvent.REPETITION`. The payload has `period`, how many calls
the repeating pattern has; `laps`, how many times it repeated (the guard steps
in as soon as that reaches `threshold`, so this is the threshold); and `tool`,
the first tool the model called in that turn:

```python
agent.hooks.on(HookEvent.REPETITION, lambda ctx: print(
    "cycle of", ctx.data["period"], "x", ctx.data["laps"], "ending in", ctx.data["tool"]))
```

The guard only warns. If the model ignores the note, `Limits.max_iterations` is
what eventually stops the run.

Try it with no API key: `uv run python examples/05-control/loop_guard.py`.

## A plan the model keeps seeing

Two tools are registered by default, `write_todos` and `read_todos`. The model
records its plan and ticks items off as it goes.

Each item needs `content` and a `status` of `pending`, `in_progress` or
`completed`, and other keys are fine. If a list doesn't fit that shape, the
model gets a one-line error and nothing changes. While the built-in
`write_todos` is installed, the system prompt also asks the model to plan
anything with three or more steps, update the list as part of the work, and
just answer when it's done. If you register your own `write_todos`, that
line goes away. `planning=False` removes the line and both tools.

```python
agent = Agent(config=AgentConfig(planning=False))   # opt out
```

After a run, the list is on the agent:

```python
for item in agent.todos:
    print("x" if item["status"] == "completed" else " ", item["content"])
```

Two decisions make the list useful rather than decorative.

**The list is rebuilt into every request, not stored once.** A plan the model
wrote twenty turns ago is still in front of it on this turn. Without that,
condensing eventually drops the tool result carrying the plan. The agent then
believes in a plan it cannot read, which is worse than no plan.

**It goes at the end of the request, not in the system prompt.** The system
prompt is the start of the cached prefix, so rewriting it every time a task is
ticked off would throw away the cache for the whole conversation. The planning
line in the system prompt is fixed text, so it doesn't cause that. Each copy of
the list also stays on the message it was first sent with. Every request is the
previous one plus new messages, and the cache keeps working as the list
changes. See [Prompt caching](../prompt-caching/).

Try it with no API key: `uv run python examples/04-context/planning_todos.py`.

### Plan events and an unfinished plan

Each change to the list fires `HookEvent.TODOS_UPDATED` and the
`RunEventType.TODOS_UPDATED` stream event, with the whole list, the number
completed, the total and the item in progress. See
[Streaming](../streaming/#task-list-updates).

If the model finishes with an answer, the run ends there, even with items still
open on the list. The list stays as the model left it, which also means a plan
can carry into the session's next run. Only a reply with no answer at all and
an open list gets a nudge, once, in case work was dropped. That fires
`HookEvent.RECOVERY` with the reason `unfinished_plan`.

## Reply budgets from the model

With `AgentConfig.max_tokens` unset, the reply budget is the model's own output
limit, capped at 32K tokens. When the history has been counted, the budget is
also fitted into what is left of the context window. A long conversation then
cannot turn a generous budget into an overflow.

The harness looks up a model's limits in this order:

1. Limits the application registered with `register_model_limits()`. These always win.
2. A limit a provider named when it refused a request. It is learned for that provider and that exact model or deployment.
3. The provider's own lookup. Anthropic's Models API reports both limits for every model it serves, and the answer is kept for the rest of the process. If the built-in table already knows the model, the first call doesn't wait for the API. It uses the table, and the API's numbers take over once they arrive.
4. A built-in table of Anthropic, OpenAI and Gemini models, matched by longest prefix.
5. For a model nothing knows: a 128K window and a 32K reply.

Register limits for a model the table does not know, or knows wrongly:

```python
from harnessx import register_model_limits

register_model_limits("my-finetune", context_window=200_000, max_output=16_000)
```

The prefix is matched after the id is normalized. Router and cloud prefixes
(`anthropic/`, `us.anthropic.`), date suffixes and Bedrock's `-v1:0` need no
entries of their own. A custom provider that can look limits up overrides
`LLMProvider.model_limits(model)`. It returns a `ModelLimits(context_window=...,
max_output=...)`, or `None` to fall back to the table, and may be a coroutine.
A custom provider's `default_max_tokens()` override sets its default budget.

An explicit `max_tokens` is the starting budget, never more than the model's
limit. A truncated reply doubles it for the rest of the run. To make it a
hard ceiling, also set `Limits(max_truncation_recoveries=0)`.

## Recovering from a limit

A reply cut off at its budget does not end the run.

- **A truncated tool call is refused.** Its arguments may parse and still be
  incomplete, so it never runs. The model gets an error result that says why,
  and is asked again.
- **Truncated text is continued.** The model is asked to carry on where it
  stopped. The pieces are joined into `result.output`.

Each time, the budget doubles up to the model's limit and stays raised for the
rest of the run. Anthropic's `model_context_window_exceeded` stop counts as a
truncation. A run recovers up to `Limits.max_truncation_recoveries` times, 3 by
default. After that, or with 0, the run ends with `result.truncated`.

A request the provider refuses for asking too much is repeated:

- **A reply budget above the model's limit** ("max_tokens: X > Y") is retried
  at Y. Y is remembered for that model.
- **A prompt that is too long** condenses the history and repeats the call. The
  window the provider named is remembered. If the history is too short to
  condense, tool results over 2,000 characters are saved to disk instead, the
  same way an oversized result is when it first arrives, and the call is
  repeated. Condensing and spilling happen at most twice per run, between them.
- **A short prompt refused as too long.** If the request looks like it's under a
  quarter of the window, it's tried once more (once per run). A refusal like that
  can't really be about size.

If there's nothing left to condense or spill, the run fails with
`ContextOverflowError` from `harnessx.errors`. The message gives the estimated
request size and the window, and the exception has them as `estimated_tokens`
and `context_window`. You can also run the spill step yourself with
`ConversationMemory.spill_large_results(threshold_chars=2000)`, which returns
the number of characters it freed.

Three other replies are recovered:

- **A tool call with unreadable arguments**, garbled in the stream or cut off
  halfway, doesn't run. The model gets an error result asking for the complete
  JSON and sends the call again. If the broken call can't even be identified,
  the agent asks for the whole reply again, once. A second unreadable reply
  fails the run with `MalformedToolInputError`. Replies that ran out of token
  budget go through the truncation recovery above instead.
- **An empty reply**, with no text and no tool call, is not stored. Sent back,
  it would be rejected and break the session. The model is nudged to go on, at most
  twice.
- **A paused turn** (`StopReason.PAUSE_TURN`, from long server-tool turns) is
  sent back as it stands to resume it, up to five times.

When empty-reply or paused-turn recovery runs out, the run fails with the error
type `RecoveryExhaustedError` in `result.error["type"]`. `result.output` keeps
the partial output and `result.usage` the known token counts.

Every recovery fires `HookEvent.RECOVERY`:

```python
agent.hooks.on(HookEvent.RECOVERY, lambda ctx: print(
    ctx.data["reason"], ctx.data["attempt"], ctx.data["reply_budget"]))
```

`reason` names what happened, `attempt` counts recoveries of that kind in this
run, and `reply_budget` is the budget the next call asks for. The reasons are
listed on [Hooks](../hooks/#recovery-events).

## The step limit ends with an answer

On reaching `Limits.max_iterations`, the model is asked once more for what it
did, what is left, and its best answer, and told to use no tools. The run then
completes with `stop_reason="max_iterations"`. That result is not `ok`:
`result.limited` is true and `raise_for_status()` raises `RunLimitReached`.

```python
async def migrate(agent):
    result = await agent.run("Migrate every module")
    if result.limited:
        print("Stopped at the step limit:", result.output)
    return result
```

A tool call in that last reply is answered, not run. To fail the run at the
limit with `MaxIterationsError` instead, set `Limits(final_answer_on_limit=False)`.

## Condensing a history that has outgrown its window

When the conversation approaches `Limits.max_context_tokens`, the harness
condenses it. It summarizes the oldest part of the
conversation with the agent's own model, as a `compact` phase of its own, and
the summary replaces what it dropped.

```python
from harnessx import Agent, AgentConfig, HookEvent, Limits

agent = Agent(config=AgentConfig(limits=Limits(max_context_tokens=100_000)))
agent.hooks.on(HookEvent.CONTEXT_CONDENSED,
               lambda ctx: print(ctx.data["messages_dropped"], "dropped",
                                 "summarized" if ctx.data["summarized"] else "truncated"))
```

`CONTEXT_CONDENSED` carries `messages_dropped`, `summary_chars` and
`summarized`.

The summary is a real model call. Its tokens count against
`Limits.max_cost_dollars` and appear in `RunResult.usage`, and a failure is
retried under `RetryPolicy`. Only once the summary's attempts are spent does it
fall back to character-level truncation, which is lossy but always works;
`summarized` is then `False`.

Condensing runs at most three times in one run. A summary that fails to shrink
the history would otherwise re-trigger on the very next iteration and loop.

Trim boundaries never separate a tool call from its result, so condensing cannot
produce a transcript a provider will reject.

Try it with no API key: `uv run python examples/04-context/condensing_a_long_history.py`.

### Clearing old tool output first

`Limits.max_context_tokens` defaults to `None`, which means the model's own
context window. Set it to condense earlier.

The cheap step comes first. Past the newest ~40K tokens of tool output, older
tool results are replaced by a one-line note. This happens only when it frees
~20K tokens or more. Old tool output is usually most of the context and the
part the model needs least, and the conversation itself stays intact.
`ConversationMemory.prune_tool_results()` is this step; it returns the
characters it freed. If the history still does not fit, the summary runs.

```python
freed = agent.memory.prune_tool_results()
print(f"cleared {freed} characters of old tool output")
```

When pruning alone was enough, `CONTEXT_CONDENSED` fires with
`messages_dropped=0` and `summarized=False`.

The summary may use up to 8K tokens. Condensing mid-run is not a
retry of the model call: it emits no `ATTEMPT_RESET`, does not mark usage
incomplete, and the summary keeps its full retry budget.

Before every call, the agent checks whether the history needs condensing. It
starts from the provider's own token count for the previous request and reply,
plus a rough size for the newest message. While that's under 60% of the room
left after the reply budget, that estimate is enough. Above that, it counts the
prompt exactly, which on Anthropic means an extra API call.

A request the provider refuses as too long is condensed and repeated; see
[Recovering from a limit](#recovering-from-a-limit).

## Transcripts a provider will accept

An assistant message carrying a tool call that nothing answers is not a
conversation a provider will take. The request is rejected and the session is
stuck.

No path leaves a tool call unanswered: truncation, failure and cancellation all
close open calls with a result. Tool calls are honored whatever the stop reason
says, since providers return `end_turn` or `stop` while still carrying them. Two
turns of the same role in a row are joined on the outbound request only.

### Repair before every request

The outbound transcript is repaired before every request. The stored one is
left alone.

- A call with no answer gets one that says so.
- An answer to no call is dropped.
- Of two answers to one call, the later is kept.
- Empty assistant turns are left out.
- Two turns of the same role in a row are joined. A tool result is a user-role
  message, so several ordinary shapes produce two user turns together.

The repair is deterministic, so the prompt-cache prefix stays stable.

Tool calls are checked before they run:

- **Arguments that are not a JSON object** reach the model as the parse error.
  Raw control characters inside strings, such
  as a literal newline in a file body, are accepted. A call without an id gets
  one.
- **A tool that times out** in a direct `Agent.run()` is answered with an error
  result the model reads. Durable runtimes stop for recovery instead.
- **One failing call in a concurrent batch** does not cancel the others.
- **A tool name with the wrong formatting** is repaired only when the match is
  unambiguous. See [Tools](../tools/#tool-names-the-model-gets-wrong).

You configure none of this.

## Related

- [Agents & the loop](../agents/) for `Limits` and the rest of `AgentConfig`
- [Retries & fallback models](../retries-and-fallbacks/) for `RetryPolicy`
- [Hooks](../hooks/) for the event payloads named here
- [Tools](../tools/) for replay policies and the built-in bundles
