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).

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):

[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:

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.

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

After a run, the list is on the agent:

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.

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.

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:

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:

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.

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.

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.

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.

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.

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.

You configure none of this.