Agents
AgentConfig
Section titled “AgentConfig”AgentConfig is a Pydantic model that holds all runtime settings for an agent. Every field has a sensible default, so you only need to set what you care about.
from cyclops import AgentConfig
config = AgentConfig( model="groq/llama-3.1-8b-instant", temperature=0.3, max_tokens=1024, system_prompt="You are a concise technical assistant.", tool_mode="auto", max_steps=10,)| Field | Type | Default | Description |
|---|---|---|---|
model | str | required | LiteLLM model string, e.g. "gpt-4o-mini", "groq/llama-3.1-8b-instant", "ollama/qwen3:4b". |
fallback_models | list[str] | [] | Models a step’s completion goes to in turn when the model is still overloaded or down after its retries, or refuses. See Fallback models. |
temperature | float | None | None | Sampling temperature. Lower values are more deterministic. None uses the model’s default. |
max_tokens | int | None | None | Maximum tokens in the response. None uses the model’s default. |
system_prompt | str | None | None | System instruction prepended to every conversation. |
tool_mode | str | "auto" | "auto" uses native function calling and switches this agent to "naive" if LiteLLM reports the model does not support tools; "native" forces it; "naive" uses prompt-based tool parsing (works with any model). |
router | Router | None | None | Optional LiteLLM Router for fallback and load balancing. |
max_steps | int | 10 | Most LLM completions in one run, guard rounds and output retries included; at least 1. |
max_total_tokens | int | None | None | Most tokens one run may use, checked between steps. |
max_cost | float | None | None | Most USD one run may cost, checked between steps. |
max_subagent_depth | int | 5 | Most levels of subagent runs below one run. |
max_concurrent_subagents | int | 10 | Most subagent runs one run has going at once; more calls wait. |
max_parallel_tool_calls | int | 10 | Most tool calls of one step that run at the same time; at least 1. See Parallel calls and timeouts. |
tool_timeout | float | None | None | Seconds a tool call may run before it is cancelled and returns an error. A tool’s own timeout wins. |
hooks | AgentHooks | None | None | Lifecycle callbacks for observability and tool approval. See Hooks. |
hook_timeout | float | None | None | Seconds each async hook call may run before it raises TimeoutError. See Hook timeouts. |
num_retries | int | 2 | Retries of a completion after a rate limit, overload, 5xx, timeout or connection error. See Retries and timeouts. |
timeout | float | 600 | Seconds LiteLLM’s HTTP client waits to connect and for each read or write. |
stream_idle_timeout | float | 120 | Seconds a stream may go without a chunk after its first. |
completion_kwargs | dict | {} | Extra arguments for every LiteLLM completion call, also through a router. See Provider options. |
reasoning_effort | str | None | None | "none", "minimal", "low", "medium", "high", "xhigh" or "max", sent to models that take it. See Reasoning. |
thinking_budget | int | None | None | Tokens the model may think, sent to models that take a thinking budget; not with reasoning_effort. See Reasoning. |
prompt_caching | bool | "5m" | "1h" | True | Mark cache breakpoints for models with prompt caching on Anthropic, Bedrock and Vertex AI (Anthropic models); "5m" or "1h" sets the time to live, False turns it off. See Prompt caching. |
compaction | Compaction | None | None | Summarize older messages once a request nears the model’s input limit. See Context window. |
max_tool_output_chars | int | None | None | Longest tool result text sent to the model whole; longer text is stored and sent as its start and end, which the model can read between. See Tool output limit. |
Constructing an Agent
Section titled “Constructing an Agent”Pass a config and an optional list of tools.
from cyclops import Agent, AgentConfigfrom cyclops.toolkit import tool
@tooldef greet(name: str) -> str: """Greet someone by name.""" return f"Hello, {name}!"
config = AgentConfig(model="groq/llama-3.1-8b-instant")agent = Agent(config, tools=[greet])run() and arun()
Section titled “run() and arun()”run() is the simplest entry point. It sends a message, handles any tool calls, and returns the final text response.
response = agent.run("Who invented the telephone?")print(response)arun() is the async equivalent, useful inside coroutines or when running multiple agents concurrently.
import asyncio
async def main(): response = await agent.arun("Who invented the telephone?") print(response)
asyncio.run(main())Both methods accept an optional response_model parameter for structured output. See Structured Output.
The input is text or a list of content parts, such as text with images or a PDF. See Images and Files.
One event loop per process
Section titled “One event loop per process”LiteLLM runs async success callbacks on one background worker per process, and that worker serves one event loop at a time. When a call comes from another loop, LiteLLM moves the worker, logging LoggingWorker: event loop changed; carried N pending and revived N dequeued logging task(s) onto the new loop at WARNING when tasks were waiting, and asyncio logs “Task exception was never retrieved” and “Task was destroyed but it is pending!” for the tasks left on the old loop. Some callbacks run only when the process exits, and with loops in several threads some never run at all.
Any process with more than one event loop calling LiteLLM hits this, async-only code included, for example asyncio.run in two threads. The sync methods bring a loop of their own: they run on one shared loop in a background thread, while the async methods run on the caller’s loop, so a process that calls both has two.
Keep every call on one loop. An async application whose sync code needs an agent can hand the run to its own loop from another thread:
loop = asyncio.get_running_loop() # in async code
def answer(question: str) -> str: # sync code on another thread, e.g. asyncio.to_thread return asyncio.run_coroutine_threadsafe(agent.arun(question), loop).result()run_with_response() and arun_with_response()
Section titled “run_with_response() and arun_with_response()”When you need token counts or cost estimates alongside the text reply, use run_with_response(). It returns a RunResult instead of a bare string.
from cyclops import RunResult
result: RunResult = agent.run_with_response("Summarize quantum computing in two sentences.")
print(result.content)print(f"Tokens: {result.tokens_used} Cost: ${result.cost:.6f}")print(f"Tool calls made: {len(result.tool_calls)}")See Cost Tracking for the full field reference.
stream() and astream()
Section titled “stream() and astream()”stream() yields text tokens as they are produced. Every completion in the run is streamed, including the steps that call tools.
for token in agent.stream("Explain black holes briefly."): print(token, end="", flush=True)print() # newline after stream endsAsync streaming works the same way:
async def main(): async for token in agent.astream("Explain black holes briefly."): print(token, end="", flush=True) print()See Streaming for detailed examples.
start(): steer and cancel a run
Section titled “start(): steer and cancel a run”start() begins a run in a task on the running event loop and returns a RunHandle. Await the handle for the RunResult, iterate it (once) for the run’s events, and use it to steer or stop the run while it works.
from cyclops.core.events import TextDelta, ToolCallStarted
handle = agent.start("Research the three largest moons of Jupiter.")
async for event in handle: if isinstance(event, ToolCallStarted) and event.name == "web_search": handle.steer("Only use sources from the last five years.") elif isinstance(event, TextDelta): print(event.text, end="", flush=True)
result = await handlehandle.steer(message)adds a user message that the model sees in its next completion. A message that arrives during a completion goes into the one after, once the step’s tool calls finish. If that completion was the final answer, the run takes another step to answer the message (guards then judge the new answer); whenmax_stepsor a budget leaves no step for it, the message stays at the end of the session for its next run, and without a session it is dropped (a run that already answered completes with that answer). The message can be content parts, as run input can. Steering a run that has finished raisesRuntimeError.handle.cancel()stops the run. Tool calls in progress are cancelled and get a cancelled result, so the conversation stays valid, and awaiting the handle givesstop_reason="cancelled". A completion in progress is dropped. Calling it again does nothing.
Cancelling the task that awaits a handle (or arun()) also cancels the run, but as a plain asyncio.CancelledError: the await raises it and there is no "cancelled" result.
start(input, stream=False) makes the completions without streaming, so each step’s text arrives as one TextDelta. arun(), arun_with_response() and astream() are shortcuts over start(). Iterating a handle doesn’t hold the run back the way astream_events() does: events wait in the handle until you read them. Until you start iterating, the handle keeps them in short form: consecutive text or reasoning deltas as one event, and no subagent events. A handle you only await holds a few events per step, not one per token.
Conversations
Section titled “Conversations”Each run without a session starts from a fresh transcript, so an agent keeps no conversation of its own and can serve many callers at once. To continue a conversation, pass the same session to each run; its messages hold the transcript, tool turns included.
from cyclops import Session
session = Session()agent.run("My name is Alice.", session=session)agent.run("What is my name?", session=session)
for msg in session.messages: print(msg["role"], ":", msg["content"])Each message in session.messages is a plain dict with at least "role" and "content" keys. Tool messages also include "tool_call_id" and "name". A new Session() starts a new conversation.
Provider options
Section titled “Provider options”completion_kwargs is passed to every LiteLLM completion call the agent makes, so any argument LiteLLM accepts works per agent: credentials, endpoints, headers and reasoning settings. Keys here win over temperature, max_tokens and timeout. Set retries with num_retries (see Retries and timeouts): LiteLLM’s own num_retries here would retry on top of it.
import os
config = AgentConfig( model="anthropic/claude-sonnet-4-5", completion_kwargs={ "api_key": os.environ["MY_ANTHROPIC_KEY"], "extra_headers": {"X-Request-Source": "docs"}, "max_tokens": 8192, },)For reasoning, use the typed options in Reasoning. See the LiteLLM input docs for every argument.
Retries and timeouts
Section titled “Retries and timeouts”A completion that fails with a rate limit, an overloaded or 5xx response, a timeout or a connection error is retried up to num_retries times (default 2) with exponential backoff and jitter: about 0.5s, then 1s, doubling up to 8s, each plus up to 0.75s. When the provider sends a retry-after of 60 seconds or less, the retry waits that long instead; a longer retry-after falls back to the backoff. These delays are LiteLLM’s, set by its INITIAL_RETRY_DELAY, MAX_RETRY_DELAY and JITTER environment variables. Other errors, such as authentication, permission or invalid request errors, are never retried, nor is a spent quota or budget. A streamed completion is retried only while none of its text or reasoning has been emitted; after that the error ends the run. A failed attempt adds nothing to the run’s usage, though the provider may still bill for it.
timeout (default 600 seconds) is the request timeout LiteLLM gives its HTTP client, which applies it to connecting and to each read and write, not to the response as a whole. A timeout is retried like the errors above, so one completion can take up to (num_retries + 1) * timeout. stream_idle_timeout (default 120 seconds) is how long a stream may go without a chunk after its first. Only the chunks LiteLLM yields reset it; it drops keep-alive events such as Anthropic’s pings. A stalled stream is aborted with StreamIdleTimeout, a subclass of litellm.Timeout, and retried once if none of it has been emitted. A pause longer than stream_idle_timeout after the stream has emitted content ends the run.
config = AgentConfig( model="anthropic/claude-sonnet-4-5", num_retries=4, timeout=120, stream_idle_timeout=60,)Each attempt fires on_llm_start, and on_llm_error if it fails. num_retries=0 turns off the retries of failed requests; a stalled stream is still retried once. With a router, the request’s retries and timeout are the Router’s own (num_retries, retry_policy, timeout, stream_timeout), and the stream idle timeout and its one retry still apply.
Fallback models
Section titled “Fallback models”fallback_models lists the models a step’s completion goes to, in order, when the model can’t answer it:
- it is still overloaded or unavailable after its retries: a rate limit, an overloaded or 5xx response, a timeout or a connection error, or a spent quota, which is not retried;
- the provider blocks the request for its content (
litellm.ContentPolicyViolationError), or the model refuses: its completion’sfinish_reasoniscontent_filter.
config = AgentConfig( model="anthropic/claude-sonnet-4-5", fallback_models=["gpt-4.1", "gemini/gemini-2.5-pro"], num_retries=1,)Each model gets its own retries before the next one is tried, and each request is built for the model it goes to: reasoning options, prompt caching markers and structured output go only to the models that take them. The models share one conversation, so what shapes it follows model or the whole list: images a tool returns stay in its tool message only when every model takes them there, and the tool mode is the one model gets, so give every fallback model tool calling when model has it. completion_kwargs go to every model, so give a fallback model on another provider its credentials through the environment. Other errors, such as authentication or invalid request errors, end the run as before, and so does the last model’s error or refusal. A streamed completion falls back only while none of its text or reasoning has been emitted, as with retries.
The context window follows model: requests are counted, compacted and given a lower max_tokens against its limits, and that max_tokens goes to every model. A fallback model with a smaller window may overflow; its overflow doesn’t fall back but gets the same remedies as one of model, and the step then starts again with model. The compaction summary goes to Compaction.model, or else model, without fallbacks: when that model is down, the failed summary ends the run, so set Compaction.model to another provider’s model if compaction has to work through an outage.
A refusal whose usage reaches max_total_tokens or max_cost ends the run with stop_reason="budget" instead of going to the next model.
Every step starts with model again, as LiteLLM’s fallbacks start every request with the first model: a run returns to the model as soon as it recovers, at the cost of its retries on each step while it is down. Lower num_retries to switch sooner.
Each switch emits a ModelFallback event with the model that failed, the fallback_model tried next, and the error it raised; for a refusal, error is None and usage holds the refused completion’s usage, which counts in the run’s usage and cost since the provider bills it. StepFinished.model names the model that answered each step, and that step’s usage and cost are its own; RunResult.model stays the configured model, and RunResult.usage_by_model splits the run’s usage between the models. Every attempt fires on_llm_start, then on_llm_error, or on_llm_end for a refusal, so TelemetryHooks records each as a chat span, and gen_ai.response.model on the answering one names the model that answered.
LiteLLM’s own fallbacks argument is not used: it moves to the next model on every error, raises a plain Exception with only the last message once all fail, doesn’t act on a refusal, and for a stream covers only its opening. With a router, set fallbacks on the Router instead (fallbacks, content_policy_fallbacks); fallback_models and router can’t both be set.
Reasoning in history
Section titled “Reasoning in history”When a model returns reasoning, the assistant messages in the transcript keep it as reasoning_content and, for Anthropic extended thinking, thinking_blocks with their signatures. They are sent back on the next call, which Anthropic requires when thinking is combined with tool use: the signed thinking block has to precede the tool call it led to.
agent.run("What is 17 * 23? Use the calculator.", session=session)for msg in session.messages: if "thinking_blocks" in msg: print(msg["thinking_blocks"][0]["thinking"])Reasoning
Section titled “Reasoning”reasoning_effort sets how much a reasoning model thinks before it answers, and thinking_budget sets its thinking in tokens. Set one or the other; a config with both is rejected. Both are off by default, and the model keeps its own default behaviour.
LiteLLM maps them onto each provider’s own setting, and the mapping changes from model to model. Some notable cases with LiteLLM 1.105.1:
- OpenAI models get
reasoning_effortas is and take nothinking_budget. - Older Anthropic models, on the Anthropic API and Bedrock, get a thinking budget:
thinking_budgetas given, or one LiteLLM picks for the effort (1024 tokens for"low"). Newer ones such asclaude-opus-4-6get adaptive thinking withoutput_config.effortfor an effort, and on models that take only adaptive thinking (claude-opus-4-7and later) LiteLLM turns a budget into an effort too, so it caps nothing. - Gemini 2.5 gets
thinkingBudget. Gemini 3 getsthinkingLevelfor an effort and dropsthinking_budget.
Each option goes only to models where LiteLLM lists the matching parameter (reasoning_effort or thinking in get_supported_openai_params), and through a router every deployment has to list it; other models get nothing. The check is per parameter, not per level: the levels a model accepts vary, and LiteLLM or the provider rejects one it doesn’t: LiteLLM raises for gpt-5 with "xhigh", while o3-mini passes "none", "minimal" and "max" through to OpenAI. A listed parameter is also no promise that the model reasons: LiteLLM lists reasoning_effort for every Ollama model, so a model that cannot think rejects it. "none" turns reasoning off where the provider allows it. A reasoning_effort or thinking in completion_kwargs replaces both options.
config = AgentConfig( model="anthropic/claude-sonnet-4-5", reasoning_effort="medium",)Leave temperature unset with these options: Anthropic models accept thinking only at temperature 1, and OpenAI reasoning models reject any other value. When max_tokens is unset and the request carries a thinking budget, LiteLLM sends the budget plus 4096 tokens (its DEFAULT_MAX_TOKENS) as the Anthropic output limit; when it is set, it has to exceed the budget.
Prompt caching
Section titled “Prompt caching”Prompt caching is on by default. Every call marks two cache breakpoints: the system prompt, which caches the tool definitions before it too, and the latest message, which caches the conversation so far. Each step then reads the earlier prefix from the cache. LiteLLM places the breakpoints on a copy of the messages, so the history never keeps them, and it skips any past the four a request may carry, counting breakpoints already in the history.
The markers are sent only to providers that cache at them (Anthropic, Bedrock, and Anthropic models on Vertex AI), and only for models that LiteLLM lists with prompt caching (supports_prompt_caching). Through a router, every deployment of the model has to qualify. Every other model gets no markers: OpenAI caches long prefixes without them, Gemini would turn them into billed explicit cache resources, and Bedrock models that LiteLLM lists without prompt caching (such as Llama) get none. A model missing from LiteLLM’s model map gets no markers either.
True caches for the provider’s default time, five minutes on Anthropic models. "5m" or "1h" sets the time to live; a one hour cache costs more to write. Bedrock honours "1h" only for the models that support it.
config = AgentConfig( model="anthropic/claude-sonnet-4-5", system_prompt=LONG_INSTRUCTIONS, prompt_caching="1h",)agent = Agent(config)response = agent.run_with_response("Summarise the report.")print(response.usage.cache_write_tokens, response.cached_tokens)A provider caches a prefix only above a minimum length that depends on the model, from 512 to 4096 tokens on Anthropic models; below it the marker is ignored and costs nothing (see Anthropic’s prompt caching docs). Writing to the cache costs more than a plain input token, and later reads cost much less, so a run of several steps gains while a single call that is not repeated within the time to live pays the write for nothing. prompt_caching=False sends no markers. To place breakpoints yourself, pass LiteLLM’s cache_control_injection_points in completion_kwargs; they replace the default ones.
Step and budget limits
Section titled “Step and budget limits”When tools are in use the agent can call tools several times before producing the final answer. Three limits bound one run, each checked between steps (a step is one LLM completion and the tool calls it asks for):
max_stepscaps the number of completions (default10).max_total_tokenscaps the tokens summed over the run’s completions.max_costcaps the run’s estimated USD cost.
Reaching a limit is not an error. The run ends with stop_reason="max_steps" or stop_reason="budget" on the RunResult from run_with_response(), and content is empty. A step in progress always finishes, so a run can exceed a budget by its last step. A completion that reports no usage, or a cost LiteLLM can’t price, doesn’t count against a budget.
config = AgentConfig( model="groq/llama-3.1-8b-instant", max_steps=5, max_total_tokens=20_000, max_cost=0.05, # USD)Output limit and filtering
Section titled “Output limit and filtering”The run reads each completion’s finish_reason, as LiteLLM normalizes it across providers (stop, length, tool_calls, content_filter). Output cut by the output limit (max_tokens, or the model’s own) ends the run with stop_reason="max_tokens", and output the provider filtered or refused (LiteLLM maps Anthropic’s refusal and Bedrock’s guardrail to content_filter) ends it with stop_reason="content_filter". Either way content is empty, guards don’t run, and any partial text stays in the conversation as the assistant’s turn. Tool calls in such a completion are not run, since a cut one may be incomplete. The run doesn’t continue on its own: raise max_tokens or ask the model to go on in the next run. StepFinished.finish_reason gives the value of each step, and RunResult.finish_reason that of the last.
Context window
Section titled “Context window”A long conversation, or a large tool result, eventually outgrows the model’s context window. Two settings keep requests within it: compaction summarizes older messages, and max_tool_output_chars sends the start and end of a long tool result, with the rest to read on demand. A request that still overflows is retried with what fits. Both are off by default, as in other agent libraries, since compaction makes an extra completion and replaces what the model saw with a summary.
Compaction and overflow recovery take the model’s limits from LiteLLM. For Ollama, Lemonade and Hugging Face models Cyclops reads only LiteLLM’s model map, since LiteLLM’s get_model_info would ask the provider’s server and block the run. A model the map lacks, such as ollama/qwen3:4b, gets no limits: compaction raises ValueError and an overflow is not retried. Register its limits at startup, keyed by the exact model string, with the context length your server runs it with:
import litellm
litellm.register_model( { "ollama/qwen3:4b": { "max_input_tokens": 32768, "max_output_tokens": 8192, "litellm_provider": "ollama", "mode": "chat", } })Registering it also stops LiteLLM’s own /api/show requests: for an Ollama model its map lacks, LiteLLM prices each completion by asking the server, four or five requests per completion, sent mostly to OLLAMA_API_BASE or localhost even when the model has its own api_base.
Compaction
Section titled “Compaction”from cyclops import Agent, AgentConfig, Compaction
agent = Agent(AgentConfig(model="gpt-4o-mini", compaction=Compaction()))Before each completion the run counts the request’s tokens with LiteLLM’s token_counter (the system prompt and tools when they change, as when a search loads deferred tools, and each message once) and compares them with the model’s max_input_tokens from litellm.get_model_info. Once a request reaches threshold of that limit, a separate completion summarizes the older messages, and the summary replaces them as one user message starting “Here is a summary of the conversation to date:”. The system prompt is never summarized, and the most recent messages, up to keep of the limit, stay as they are. The last message is always kept, however long. A tool call and its results stay together: when keep ends among the results, the call and all its results are kept.
| Field | Type | Default | Description |
|---|---|---|---|
threshold | float | 0.8 | Fraction of the model’s max input tokens a request may reach before its older messages are summarized. |
keep | float | 0.2 | Fraction of the model’s max input tokens kept as the most recent messages; below threshold. |
model | str | None | None | Model that writes the summary; None uses the agent’s. Through a router, a model name the router serves. |
prompt | str | a summarizing instruction | Instruction the summary is written to; the older messages follow it as a plain text transcript. |
The summary is an ordinary message in the transcript, so a session keeps it and stays compacted when saved. Its completion goes through the same retries and hooks as a step’s, and its usage counts in the run’s usage, max_total_tokens and max_cost, though it is not a step. The run emits a Compacted event with the summary, the number of messages it replaced and the request’s tokens before and after, and calls the on_compaction hook.
The summarizing request carries the older messages as a plain text transcript, and its max_tokens is keep of the window, capped at the summarizing model’s output limit, so the summary fits where the summarized messages were. It is sent without the agent’s reasoning options, whose thinking budget that limit could fall under. When the transcript doesn’t fit the rest of the summarizing model’s window, the oldest messages are left out, with a note of how many; a window with no room for any of it raises a RuntimeError. A summary that comes back empty, cut by its output limit or filtered raises a RuntimeError and leaves the transcript as it was; its usage still counts. An earlier summary is not summarized again on its own, and when a compaction leaves the request past the threshold, as with a long summary or a long kept tail, the run logs a warning and does not compact again.
Token counts are LiteLLM’s: close for OpenAI models and an estimate for others, so the threshold leaves room. Through a router, the deployment with the smallest max_input_tokens sets the limit. Compaction needs that limit: with compaction set, a model LiteLLM has no max_input_tokens for ends the run with a ValueError at its first step, as LangChain’s summarization does; litellm.register_model adds the limits. The same goes for the summarizing model.
With a session_store, the save after a compaction stores the whole session, since earlier messages changed.
Compaction changes the start of the conversation, so with prompt caching the next request reads only the system prompt and tools from the cache and writes the rest anew.
To compact a session now, as between runs, call agent.compact(session) or await agent.acompact(session). It uses config.compaction, or the Compaction() defaults when that is None, adds the summary’s usage to the session, and returns False without changing anything when nothing but an earlier summary would be summarized. Like session.truncate(), it does not save: the next run’s first save stores the whole session.
Anthropic models also offer server-side context management. LiteLLM passes context_management through, so its clear_tool_uses_20250919 edit works from completion_kwargs; its compaction edit returns a block that has to be sent back in later requests, which the agent’s transcript does not keep.
Context overflow
Section titled “Context overflow”When the provider rejects a request as too long for the context window (LiteLLM’s ContextWindowExceededError), the run counts the request and tries each remedy at most once: first a lower max_tokens, the room the input leaves, when that is below max_tokens (or below the model’s output limit when max_tokens is unset) and above a thinking budget, which max_tokens has to exceed (with reasoning_effort, above the largest budget LiteLLM makes of an effort, 16384 tokens by default); then, on another overflow or when lowering doesn’t fit, compaction when compaction is set. An overflow with no remedy left ends the run with the error, so the run never loops; num_retries does not cover it. A model LiteLLM has no limits for is not retried.
Once the run has counted its transcript, from the start with compaction set or after its first overflow otherwise, every later request gets that lower max_tokens up front, so the steps that follow don’t overflow first. An answer the lowered limit cuts ends the run with stop_reason="max_tokens", as in Output limit and filtering.
Tool output limit
Section titled “Tool output limit”max_tool_output_chars limits the text of each tool result the model gets whole. A longer result is stored, and the model gets its start and end, half the limit each, around a note with the result’s handle:
[Characters 10000 to 170000 of 180000 omitted. Read them with read_tool_result(handle="3f9c...", offset=10000).]With any limit set, every completion of a run includes a read_tool_result tool, from the first step on, so the tools sent never change and a cached prompt stays valid. It takes the handle, an offset in characters and an optional limit, and returns that page with a note of where it is and the offset to read on from; the last page says it is the end. A page is at most the limit of the tool that returned the result. A tool’s own max_output_chars overrides the agent’s limit, and works without one:
config = AgentConfig(model="gpt-4o-mini", max_tool_output_chars=20_000)
@tool(max_output_chars=100_000)def read_log(path: str) -> str: """Read a log file.""" ...Images in a result are kept with the preview. The preview is what the transcript, a session, ToolCallFinished.result and RunResult.tool_calls carry, so compaction counts the preview and not the whole result; hooks and middleware see the whole result. read_tool_result is a read-only tool like any other: middleware and ToolPermissions see its calls, so a permission list that denies unlisted tools must allow it. With any limit set, read_tool_result is a reserved tool name.
Stored results last as long as their run: the run deletes them when it ends, finished, failed or cancelled, so a store never grows past what live runs hold. A handle lasts one run: a later run of the same session sees the preview, start and end, in the transcript, and reading its handle gets No stored tool result has the handle .... By default a run keeps its results in memory; to keep them elsewhere, such as on disk for very large results, pass Agent(..., tool_result_store=...) an object with the ToolResultStore methods:
class ToolResultStore(Protocol): async def put(self, handle: str, text: str) -> None: ... async def get(self, handle: str) -> Optional[str]: ... async def delete(self, handle: str) -> None: ...A store may be shared by several agents and runs: handles are random, and a run reads only the results it stored.
Full example
Section titled “Full example”"""Basic agent usage example"""
from cyclops import Agent, AgentConfig, Session
# Create agent with default settingsconfig = AgentConfig( model="ollama/qwen3:4b", system_prompt="You are a helpful assistant.")agent = Agent(config)session = Session() # holds the conversation across runs
# Run the agentresponse = agent.run("What is the capital of France?", session=session)print(response)
# Continue the conversation in the same sessionresponse = agent.run("What's the population?", session=session)print(response)