Core Concepts
This page explains the fundamental concepts in Stirrup.
Agent
The Agent class is the main entry point. It manages the agent loop: generating LLM responses, executing tools, and accumulating messages until a task is complete.
Configuration Options
from stirrup import Agent
from stirrup.clients.chat_completions_client import ChatCompletionsClient
client = ChatCompletionsClient(...)
agent = Agent(
client=client, # (required) LLM client for generating responses
name="my_agent", # (required) Agent name for logging
max_turns=30, # (default: 30) Max iterations before stopping
system_prompt="You are an agent specializing in ...", # (default: None) Instructions prepended to runs
tools=None, # (default: default_tools()) Available tools
finish_tool=None, # (default: SIMPLE_FINISH_TOOL) Completion signal(s)
context_summarization_cutoff=0.7, # (default: 0.7) Context % before summarization
run_sync_in_thread=True, # (default: True) Run sync tools in thread
text_only_tool_responses=True, # (default: True) Extract images from responses
block_successive_assistant_messages=True, # (default: True) Inject continue prompt between assistant messages
recover_from_context_overflow=True, # (default: True) Retry overflows by unwinding recent progress
logger=None, # (default: None) Custom logger instance
)
Full Parameter Reference
| Parameter | Type | Default | Description |
|---|---|---|---|
client |
LLMClient |
required | LLM client (use factory methods or create directly) |
name |
str |
required | Agent name for logging |
max_turns |
int |
30 |
Maximum turns before stopping |
system_prompt |
str \| None |
None |
System prompt prepended to runs |
tools |
list[Tool \| ToolProvider] \| None |
default_tools() |
Available tools |
finish_tool |
Tool \| list[Tool] |
SIMPLE_FINISH_TOOL |
Tool(s) to signal completion |
context_summarization_cutoff |
float |
0.7 |
Context % before summarization |
run_sync_in_thread |
bool |
True |
Run sync tools in separate thread |
text_only_tool_responses |
bool |
True |
Extract images to user messages |
block_successive_assistant_messages |
bool |
True |
Inject continue prompt to prevent back-to-back assistant messages |
recover_from_context_overflow |
bool |
True |
Retry context overflows by unwinding recent completed turns |
logger |
AgentLoggerBase \| None |
None |
Custom logger instance |
Understanding Agent Output
The run() method returns a tuple of three values:
finish_params
Contains the agent's final response when it calls the finish tool:
reason: Explanation of what was accomplishedpaths: List of files created/modified in the execution environment
finish_params = {
"reason": "Successfully found Australia's population for 2022-2024 and created a chart.",
"paths": ["australia_population_chart.png"]
}
history
A list of message groups representing the conversation history. Each group contains:
SystemMessage: System promptsUserMessage: User inputs and file contentsAssistantMessage: LLM responses — an ordered sequence of blocks (reasoning, text, tool calls, media)ToolMessage: Results from tool executions
An assistant turn is stored as blocks, preserving the model's actual emission order
(modern models interleave thinking → text → thinking → tool call). Each block is
discriminated on kind: text, reasoning (in-band), signed_reasoning,
redacted_reasoning, reasoning_ref (provider-side reference), encrypted_reasoning
(opaque payload for stateless / zero-data-retention passback), opaque
(provider-native block carried uninterpreted), tool_call, and the media block kinds.
Alongside its blocks, an AssistantMessage may carry provider_response_id —
provider-attached continuation state (e.g. an OpenAI Responses resp_... id). The
Responses client uses it in its default stateful mode to continue conversations via
previous_response_id instead of replaying full history. Provider response IDs are
provider/project scoped and may expire; on a definitive not-found response the client
replays full local history once when every block is losslessly representable, otherwise
it raises because reference-only reasoning cannot be reconstructed.
history = [
SystemMessage(role='system', content="You are an AI agent..."),
UserMessage(role='user', content="What is the population of Australia..."),
AssistantMessage(
blocks=[
TextBlock(text="I'll search for Australia's population data..."),
ToolCall(name='web_search', arguments='{"query": "..."}', tool_call_id='...'),
],
token_usage=TokenUsage(input=1523, answer=156, reasoning=0)
),
ToolMessage(role='tool', content="<results>...ABS data...</results>", name='web_search', ...),
# ... additional turns ...
]
The channel-era content and tool_calls attributes remain temporarily available as
deprecated views of blocks; reading them emits a DeprecationWarning. content is a
bare string only for an empty response or a single text block and otherwise exposes the
block list directly. The deprecated reasoning attribute raises with guidance to use
reasoning_blocks. New messages must be constructed from blocks; channel-shaped v0.1
data is accepted only as a legacy deserialization format and is synthesized in reasoning
→ text → tool-call order. Construct a replacement message instead of assigning channels.
Convenience accessors joined_text, final_text, tool_call_blocks, and
reasoning_blocks operate on any block list.
metadata
A dictionary containing metadata from tool executions:
token_usage: Total token counts (input, output, reasoning)- Per-tool metadata (e.g.,
code_exec,web_search,web_fetch)
metadata = {
"web_search": [WebSearchMetadata(num_uses=1, pages_returned=5)],
"fetch_web_page": [WebFetchMetadata(num_uses=1, pages_fetched=['https://...'])],
"code_exec": [ToolUseCountMetadata(num_uses=3)],
"finish": [ToolUseCountMetadata(num_uses=1)],
"token_usage": [TokenUsage(input=239283, answer=4189, reasoning=0)]
}
Use aggregate_metadata to combine metadata across tool calls:
from stirrup import aggregate_metadata
aggregated = aggregate_metadata(metadata)
print(f"Total tokens: {aggregated['token_usage'].total}")
Speed metrics are available directly on each AssistantMessage via request_start_time, request_end_time, and the derived e2e_otps property. Similarly, ToolMessage has tool_start_time, tool_end_time, and a tool_duration property.
Session
The session() method returns the agent configured as an async context manager. Sessions handle:
- Tool lifecycle (setup and teardown of ToolProviders)
- File uploads to execution environment
- Skills loading and system prompt addition
- Output file saving
- Logging
async with agent.session(
output_dir="./output", # Where to save output files
input_files=["data.csv"], # Files to upload
skills_dir="skills", # Directory containing skills
) as session:
result = await session.run("Your task")
Passing Input Files to the Agent
Provide files to the agent's execution environment via input_files:
async with agent.session(
input_files=["data.csv", "config.json"],
output_dir="./output",
) as session:
await session.run("Analyze the data in data.csv")
Supported formats:
| Format | Example | Description |
|---|---|---|
| Single file | "data.csv" |
Upload one file |
| Multiple files | ["file1.txt", "file2.txt"] |
Upload a list of files |
| Directory | "./data/" |
Upload directory contents recursively |
| Glob pattern | "data/*.csv", "**/*.py" |
Upload files matching pattern |
Receiving Output Files from the Agent
When the agent creates files, save them to a local directory via output_dir:
async with agent.session(output_dir="./results") as session:
finish_params, _, _ = await session.run(
"Create a Python script that prints hello world"
)
# Files listed in finish_params.paths are saved to ./results/
The agent signals which files to save by including their paths in finish_params.paths when calling the finish tool.
Saving is best-effort: a declared path may fail (it doesn't exist, isn't a regular file, escapes the execution root, or collides with another output). After the session, session.last_output_files_result reports what happened — .saved lists the files written and .failed maps each rejected source path to the reason. Files are copied, so the originals stay in the execution environment.
Loading Skills
Skills are modular packages that extend agent capabilities with domain-specific instructions and scripts. Pass a skills directory to make them available:
async with agent.session(
skills_dir="skills",
output_dir="./output",
) as session:
await session.run("Analyze the data using the data_analysis skill")
The agent receives a list of available skills in its system prompt and can read the full instructions via cat skills/<skill_name>/SKILL.md.
→ See Skills Guide for full documentation.
Client
Stirrup supports multiple ways to connect to LLM providers.
max_tokens and context_window_tokens are separate budgets: the first caps a single response,
the second is the model context capacity the agent summarizes history against. Built-in clients
require context_window_tokens at construction and reject max_tokens values that exceed it —
note the default max_tokens is 64_000, so small context windows need an explicit max_tokens
too. The examples in this repository pair an explicit max_tokens=8_192 response budget with an
explicit context window. Exceeding a response budget raises OutputTokenLimitError and aborts
the run rather than retrying, so increase max_tokens if your task needs long responses.
ChatCompletionsClient
Use ChatCompletionsClient for OpenAI or OpenAI-compatible APIs:
# Create client using Deepseek's OpenAI-compatible endpoint
client = ChatCompletionsClient(
base_url="https://api.deepseek.com",
model="deepseek-v4-flash", # or "deepseek-v4-pro" for the larger model
max_tokens=8_192,
context_window_tokens=1_000_000,
api_key=os.environ["DEEPSEEK_API_KEY"],
)
agent = Agent(client=client, name="deepseek_agent")
| Parameter | Type | Default | Description |
|---|---|---|---|
model |
str |
required | Model identifier (e.g., "gpt-5.6-luna", "deepseek-v4-flash") |
max_tokens |
int |
64_000 |
Maximum provider response/output tokens |
context_window_tokens |
int |
required | Context capacity for summarization |
base_url |
str \| None |
None |
Custom API URL (for Deepseek, vLLM, etc.) |
api_key |
str \| None |
None |
API key (defaults to OPENROUTER_API_KEY env var) |
timeout |
float \| None |
None |
Request timeout in seconds |
max_retries |
int |
2 |
Number of retries for transient errors |
LiteLLMClient
Use LiteLLMClient for Anthropic, Google, and other providers via LiteLLM:
# Create LiteLLM client for Anthropic Claude
# See https://docs.litellm.ai/docs/providers for all supported providers
client = LiteLLMClient(
model_slug="anthropic/claude-opus-5",
max_tokens=8_192,
context_window_tokens=1_000_000,
)
# Pass client to Agent - model info comes from client.model_slug
agent = Agent(
client=client,
name="claude_agent",
)
| Parameter | Type | Default | Description |
|---|---|---|---|
model_slug |
str |
required | Provider/model string (e.g., "anthropic/claude-opus-5") |
max_tokens |
int |
64_000 |
Maximum provider response/output tokens |
context_window_tokens |
int |
required | Context capacity for summarization |
reasoning_effort |
str \| None |
None |
For reasoning models (e.g. GPT-5.6) |
kwargs |
dict \| None |
None |
Additional provider-specific arguments |
LiteLLM Installation
Requires pip install stirrup[litellm] (or: uv add stirrup[litellm])
Creating Your Own Client
Implement the LLMClient protocol to create a custom client:
from stirrup.core.models import LLMClient, AssistantMessage, ChatMessage, Tool
class MyCustomClient(LLMClient):
async def generate(self, messages: list[ChatMessage], tools: dict[str, Tool]) -> AssistantMessage:
# Make API call and return AssistantMessage
...
@property
def model_slug(self) -> str:
return "my-model"
@property
def context_window_tokens(self) -> int:
return 128_000
Agent reads context_window_tokens at construction and raises a ValueError
when the returned value is not a positive int.
→ See Custom Clients for full documentation.
Tools
default_tools()
When you create an Agent without specifying tools, it uses default_tools():
from stirrup.tools import default_tools
# default_tools() returns a fresh list containing:
# - LocalCodeExecToolProvider() → provides "code_exec" tool
# - WebToolProvider() → provides "web_fetch" and "web_search" tools
| Tool Provider | Tools Provided | Description |
|---|---|---|
LocalCodeExecToolProvider |
code_exec |
Execute shell commands in an isolated temp directory |
WebToolProvider |
web_fetch, web_search |
Fetch web pages and search (search requires BRAVE_API_KEY) |
Each call returns fresh provider instances. Provider instances hold per-session state (a temp directory, an HTTP client), so concurrent sessions must not share them.
Breaking change: the DEFAULT_TOOLS list was removed because every caller shared the same two
provider instances. Migrate tools=DEFAULT_TOOLS to tools=default_tools(), and
tools=[*DEFAULT_TOOLS, extra_tool] to tools=[*default_tools(), extra_tool].
Extending vs Replacing
import argparse
import asyncio
from stirrup import Agent, Tool, ToolProvider
from stirrup.clients.chat_completions_client import ChatCompletionsClient
from stirrup.tools import CALCULATOR_TOOL
from stirrup.tools.code_backends.e2b import E2BCodeExecToolProvider
from stirrup.tools.web import WebToolProvider
DEFAULT_OPENROUTER_SLUG = "anthropic/claude-opus-5"
# Claude Opus 5's context window. Other slugs need their own --context-window-tokens.
DEFAULT_CONTEXT_WINDOW_TOKENS = 1_000_000
# Create client for OpenRouter
client = ChatCompletionsClient(
base_url="https://openrouter.ai/api/v1",
model=DEFAULT_OPENROUTER_SLUG,
max_tokens=8_192,
context_window_tokens=DEFAULT_CONTEXT_WINDOW_TOKENS,
)
# Create agent with E2B execution + web tools + calculator
# (This is just for the docs snippet above — the actual runnable code is in main() below)
agent = Agent(
client=client,
name="web_calculator_agent",
tools=[E2BCodeExecToolProvider(), WebToolProvider(), CALCULATOR_TOOL],
)
Tool
A Tool has the following attributes:
- name: Unique identifier
- description: What the tool does (shown to the LLM)
- parameters: Pydantic model defining the input schema
- executor: Function that executes the tool
class GreetParams(BaseModel):
"""Parameters for the greet tool."""
name: str = Field(description="Name of the person to greet")
formal: bool = Field(default=False, description="Use formal greeting")
def greet(params: GreetParams) -> ToolResult[ToolUseCountMetadata]:
greeting = f"Good day, {params.name}." if params.formal else f"Hey {params.name}!"
return ToolResult(
content=greeting,
metadata=ToolUseCountMetadata(),
)
GREET_TOOL = Tool(
name="greet",
description="Greet someone by name",
parameters=GreetParams,
executor=greet,
)
# Create client for OpenRouter
client = ChatCompletionsClient(
base_url="https://openrouter.ai/api/v1",
model="anthropic/claude-opus-5",
max_tokens=8_192,
context_window_tokens=1_000_000,
)
# Add custom tool to default tools
agent = Agent(
client=client,
name="greeting_agent",
tools=[*default_tools(), GREET_TOOL],
)
→ See Creating Tools for full documentation.
Sub-agents
Convert any agent into a tool using agent.to_tool(). This enables hierarchical agent patterns where a supervisor delegates to specialized workers:
research_agent = Agent(
client=client,
name="research_sub_agent",
tools=[WebToolProvider(), LocalCodeExecToolProvider()],
max_turns=5,
system_prompt=(
"You are a research agent. When asked to complete research, save it all to a markdown file "
"(using a code executor tool) and pass the filepath to the finish tool and mention it in the "
"finish_reason. Remember you will need a turn to write the markdown file and a separate turn to finish."
),
)
# Convert agent to a tool for use by supervisor
research_subagent_tool = research_agent.to_tool(
description="Agent that can search the web and return the results.",
)
The supervisor can then use sub-agents as tools:
supervisor_agent = Agent(
client=client,
name="supervisor",
tools=[research_subagent_tool, writer_subagent_tool],
)
→ See Sub-Agents Guide for full documentation.
Tool Provider
A ToolProvider is a class that manages resources and returns tools via async context manager. Use for tools requiring:
- Connections (HTTP clients, databases)
- Temporary directories
- Cleanup logic
from stirrup import ToolProvider, Tool
class MyToolProvider(ToolProvider):
async def __aenter__(self) -> Tool | list[Tool]:
# Setup resources
self.client = await create_client()
return self._create_tool()
async def __aexit__(self, *args):
# Cleanup
await self.client.close()
The agent's session() automatically calls __aenter__ and __aexit__ for all ToolProviders.
→ See Tool Providers for full documentation.
Finish Tools
A finish tool signals task completion. By default, agents use SIMPLE_FINISH_TOOL:
from stirrup.tools.finish import FinishParams, SIMPLE_FINISH_TOOL
# Default FinishParams has:
# - reason: str - Explanation of what was accomplished
# - paths: list[str] - Files created/modified
Create custom finish tools for structured output:
from pydantic import BaseModel, Field
from stirrup import Tool, ToolResult, ToolUseCountMetadata
class AnalysisResult(BaseModel):
summary: str = Field(description="Analysis summary")
confidence: float = Field(description="Confidence score 0-1")
paths: list[str] = Field(default_factory=list)
custom_finish = Tool(
name="finish",
description="Complete the analysis task",
parameters=AnalysisResult,
executor=lambda p: ToolResult(
content=p.summary,
metadata=ToolUseCountMetadata()
),
)
agent = Agent(client=client, name="analyst", finish_tool=custom_finish)
You can also provide multiple finish tools. A successful call to any of them ends the agent loop:
agent = Agent(
client=client,
name="analyst",
finish_tool=[submit_files_tool, finish_without_files_tool],
)
Tool Metadata
Tools return ToolResult[M] where M is the metadata type:
from stirrup import ToolResult, ToolUseCountMetadata
def my_tool(params: MyParams) -> ToolResult[ToolUseCountMetadata]:
return ToolResult(
content="Result text",
metadata=ToolUseCountMetadata(), # Tracks number of uses
)
Metadata aggregates across tool calls during a run. Built-in metadata types:
| Type | Description |
|---|---|
ToolUseCountMetadata |
Counts number of tool invocations |
TokenUsage |
Tracks input/output/reasoning tokens |
SubAgentMetadata |
Captures sub-agent message history |
Access aggregated metadata:
from stirrup import aggregate_metadata
_, _, metadata = await session.run("task")
aggregated = aggregate_metadata(metadata)
print(f"Total tokens: {aggregated['token_usage'].total}")
Context Overflow Recovery
By default, Stirrup retries context overflow errors by shortening the conversation and trying again.
An output-limit stop is different: OutputTokenLimitError surfaces without
summarization or retry with the same max_tokens limit.
The built-in OpenAI-family clients detect overflow from OpenAI's context_length_exceeded
error code. OpenAI-compatible endpoints that report a different code (such as OpenRouter,
whose error codes depend on the upstream provider) surface a plain BadRequestError instead,
so this recovery does not trigger for them — proactive summarization at
context_summarization_cutoff remains the primary protection.
When overflow happens, the agent removes the latest completed assistant turn. It will not remove the original prompt, existing summaries, or the only completed turn after either boundary; this ensures the surviving trajectory still has forward progress.
This also applies when eager summarization overflows. Any removed turn is also removed from final metadata and does not count against max_turns.
To fail immediately instead:
Recovery only covers ContextOverflowError. Two other client failures deliberately abort the run:
- Summarization is itself a model call, so
summarize_messagescan raiseOutputTokenLimitErrorout ofsession.run()even though you never call it directly. Retrying it with the samemax_tokenscannot succeed, so it is not recovered. OpenResponsesClientraisesIncompleteResponseErrorfor incomplete responses that are not output-limit stops, such asincomplete_details.reason == "content_filter". Before token budgets were split these unwound a turn and retried; only context overflow does that now.
Logging
The agent uses AgentLogger by default, which provides rich console output with:
- Progress spinners showing steps, tool calls, and token usage
- Visual hierarchy for sub-agents
- Syntax-highlighted tool results
from stirrup.utils.logging import AgentLogger
import logging
# Custom log level
logger = AgentLogger(level=logging.DEBUG)
agent = Agent(client=client, name="assistant", logger=logger)
Custom Loggers
Implement AgentLoggerBase for custom logging:
from stirrup.utils.logging import AgentLoggerBase
class MyLogger(AgentLoggerBase):
def __enter__(self):
# Setup logging
return self
def __exit__(self, *args):
# Cleanup
pass
def on_step(self, step: int, tool_calls: int = 0, input_tokens: int = 0, output_tokens: int = 0):
# Called after each step
print(f"Step {step}: {tool_calls} tool calls")
# Implement other required methods...
→ See Custom Loggers for full documentation.
Next Steps
- Examples - Working examples for common patterns
- Creating Tools - Build your own tools
- Code Execution - Execution backends
- Sub-Agents - Hierarchical agent patterns