Agent is the core agentic loop: LLM reasoning → tool execution → repeat, until the model finishes, an approval is required, or a runtime limit is reached.

import asyncio
from harnessx import Agent, AgentConfig, Limits

agent = Agent(
    config=AgentConfig(
        model="claude-sonnet-4-6",      # Choose a model available to your account.
        provider="anthropic",            # a built-in name or one passed to register_provider()
        max_tokens=None,                 # resolve the reply budget from the model's limits
        system_prompt="You are a helpful assistant.",
        temperature=None,                # omitted by default for safety
        limits=Limits(max_iterations=50),  # loop guardrail
    )
)

async def main():
    async with agent:
        result = await agent.run("What is 17 + 25?")
        if result.error:
            raise RuntimeError(result.error["message"])
        print(result.output)

asyncio.run(main())

AgentConfig keeps eight top-level fields and groups everything else into five sub-policies. All are optional. Only model, provider, and max_tokens may be passed positionally.

Field Default Purpose
model "claude-sonnet-4-6" Model id passed to the provider
provider "anthropic" "anthropic", "openai", "gemini", "openrouter", "azure", or a name added with register_provider()
max_tokens None Reply token budget. Unset, it comes from the model's own limits: its output limit, capped at 32K and fitted to the context left. See Long-run reliability
system_prompt "You are a helpful assistant." System prompt
temperature None Sampling temperature (omitted by default for safety)
limits Limits() Budgets the harness enforces; fields below
retry RetryPolicy() Transient-failure handling for model calls; fields below
prompt_cache PromptCachePolicy() Caching of the stable prompt prefix; None disables it. See Prompt caching
tools ToolPolicy() Registry-wide tool options applied to the registry the agent adopts. See Tools
planning True Registers write_todos and read_todos, adds a line to the system prompt asking the model to plan work of three or more steps, and puts the current list in every request. See Long-run reliability
max_parallel_subagents 4 How many specialists one agent runs at the same time. 1 runs them one after another. See Multi-agent
fallbacks () Fallback entries to try, in order, when the primary provider fails transiently. See Retries & fallback models
progress ProgressPolicy() When the run stream reports a slow model call with WAITING events. See Streaming

Limits fields:

Field Default Purpose
max_iterations 50 Iteration limit. 0 means unlimited
max_context_tokens None Context size condensing works against. None uses the model's own window
max_result_chars 12_000 Tool results above this are kept out of the context. See Results too large to keep
max_cost_dollars None Cost ceiling, enforced when prices are supplied
input_cost_per_m None Application-supplied input price per million tokens
output_cost_per_m None Application-supplied output price per million tokens
cache_read_cost_multiplier 0.1 What a prompt token read from the cache costs, as a share of input_cost_per_m. Anthropic charges a tenth; use 0.5 for OpenAI
cache_write_cost_multiplier 1.25 What a prompt token written to the cache costs, as a share of input_cost_per_m. Use 1.0 for OpenAI
loop_guard LoopGuard() Warns the model when it keeps making the same tool calls and getting the same results. On by default; it adds a note to the tool result and never fails the run. See Long-run reliability
max_truncation_recoveries 3 How many times one run recovers from a reply cut off at its budget, each time with a doubled budget. 0 ends the run at the first cut
final_answer_on_limit True At max_iterations, asks the model once more, with no tools, for an answer. The run completes with result.limited true. False fails it with MaxIterationsError

RetryPolicy governs transient model failures and failover. Its fields, and the Fallback chain that goes with it, are on Retries & fallback models.

ToolPolicy fields:

Field Default Purpose
default_timeout_seconds None Default for tools without their own timeout_seconds; None keeps the registry's default of 25 seconds. Built-ins with their own limit, such as run_bash, keep it
dedupe_calls False Identical repeated calls within one run return the first result
retry None Default ToolRetry for tools without their own policy; automatic retry requires a safe or idempotent replay policy

Results too large to keep

A tool result longer than Limits.max_result_chars doesn't go into the conversation as it is. What happens depends on what it looks like.

If it's JSON with a list of records, the kind search APIs and database tools return, every record's text is cut to the same length until the whole result fits. The model still gets every record and most of what each one says. Text that was cut ends with "[... N more characters]", and the full result is saved so the model can read the rest. A search 16% over the limit comes through at about 86%, instead of a one-line summary per record.

Anything else, or a list too long to fit even with every record cut short, is saved to a file and the model gets a preview. The note above the preview says how much of the result it shows, asks the model to read what it needs before relying on it, and gives the exact call to do that. A list of records is previewed one line per record (title, link or id, and its first real sentence, skipping page navigation).

The model reads the rest with the built-in read_tool_result tool:

Argument Default Purpose
result_id required The tool-use id named in the notice
offset, limit 0, 2000 Lines to return, for a result with many lines
char_offset, max_chars 0, 0 Character position and size of one read. 0 means as much as fits under max_result_chars, so a result that only just spilled comes back whole
pattern "" A case-insensitive regular expression. Returns the passages that match, with their character positions, instead of reading in order

A single read never returns more than fits under max_result_chars, so reading a saved result back never sends it to disk again.

The sub-policies are frozen dataclasses; derive a variant with dataclasses.replace. Invalid values are rejected when the config is constructed, not when a run starts.

To recover from a cut-off reply only once and fail at the step limit:

from harnessx import AgentConfig, Limits

config = AgentConfig(limits=Limits(
    max_iterations=30,
    max_truncation_recoveries=1,
    final_answer_on_limit=False,
))

See Long-run reliability.

Inject your own components

You can also inject your own pieces. Everything is a constructor parameter:

from harnessx import (
    Agent, AgentConfig, ConversationMemory, HookManager,
    MiddlewarePipeline, PermissionManager, ToolRegistry,
)

agent = Agent(
    config=AgentConfig(system_prompt="..."),
    tools=ToolRegistry(),               # your own registry
    subagents=[],                      # SubAgent specialist definitions
    memory=ConversationMemory(),        # your own memory
    permissions=PermissionManager(),    # your own permission policy
    hooks=HookManager(),                # lifecycle hooks
    middleware=MiddlewarePipeline(),    # request/response transforms
)

Conversation loop

agent.run() keeps the conversation in memory, so calling it repeatedly is a multi-turn chat:

async def chat(agent):
    while True:
        text = input("You: ").strip()
        if text in ("", "quit"):
            break
        print("Agent:", (await agent.run(text)).output)

agent.run() also takes a list of content blocks, to send an image, PDF or audio clip with the text. See Model providers.

Inspect the result

RunResult has predicates and a raiser, so a caller rarely compares status by hand:

result = await agent.run("Summarize the report")
if result.ok:             # status is COMPLETED
    print(result.output)
elif result.failed:       # status is FAILED; result.error has type and message
    print(result.error["message"])
elif result.needs_input:  # status is AWAITING_INPUT; result.pending lists PendingTool objects
    print([pending.call.name for pending in result.pending])

result.raise_for_status()  # returns result, or raises a RunError subclass

ok means the run completed with a whole answer. A reply cut off at the token budget completes but is not ok: result.truncated is true, stop_reason is "max_tokens", and raise_for_status() raises RunTruncated.

A failed run raises RunFailed, an approval wait RunAwaitingInput, and a cancelled run RunCancelled. Each exception carries .result.

One agent runs one turn at a time. agent.busy is True while a run is in progress and agent.closed is True once aclose() has started; starting a run in either state raises RuntimeStateError. Use async with agent: to close it.

Use Agent(subagents=[SubAgent(...)]) to declare specialists at construction. Each delegated task gets a fresh conversation and returns its result to the orchestrator. See multi-agent orchestration for configuration and recovery boundaries.

Refusals and the step limit

Each completed result that is not ok has its own predicate and exception:

Predicate stop_reason raise_for_status() raises
result.truncated max_tokens, after recovery ran out RunTruncated
result.refused refusal or safety: the model declined or a filter stopped the reply RunRefused
result.limited max_iterations: the output is the model's account of where it got to RunLimitReached

StopReason.REFUSAL marks a model that declines, and StopReason.PAUSE_TURN a server that pauses a long turn; the loop sends a paused turn back to resume it. See Model providers for how each vendor's reasons map.

async def answer(agent, prompt):
    result = await agent.run(prompt)
    if result.refused:
        return "The model declined: " + result.output
    if result.limited:
        return "Stopped at the step limit: " + result.output
    return result.raise_for_status().output