toolloop
Agent loops for LLM providers without native tool use.
toolloop is a Python framework for building autonomous agents — tool use,
exploration, coding — on top of any LLM endpoint, even (especially) the ones
whose SDKs never exposed a tools parameter. If you can send messages and get
text back, you can run an agent on it.
┌─────────────────────────────────────────────┐
│ │
input ──▶ │ system prompt (tool instructions) │
│ + conversation history │
▼ │
┌───────────┐ {"type":"tool_call", ...} ┌────────────┐
│ provider │ ────────────────────────────▶ │ tool runs │
│ (yours) │ └────────────┘
└───────────┘ │
│ │ observation
│ {"type":"final_answer", ...} ▼
▼ back to provider
output
Why
Plenty of real-world LLM access goes through proprietary corporate SDKs that proxy the big providers (Anthropic, OpenAI, Kimi, DeepSeek, ...) but strip or never implemented the tool-use layer. The models behind them are perfectly capable of agentic work — the SDK just won't carry function calls.
toolloop solves this at the application layer:
- Bring your own provider. The framework never manages providers. The
whole contract is one async method:
complete(messages) -> str. - Tools over plain text. Tool schemas are rendered into the system prompt; tool calls are parsed out of the model's text responses. Parse errors are fed back to the model (auto-repair) until it gets the envelope right.
- Loop until satisfied. Given an input, the agent calls tools, receives
observations, and iterates until it emits a
final_answer.
Install
Requires Python 3.11+.
pip install toolloop # once published
# or, from source:
uv sync --extra dev
Quickstart (no LLM needed)
import asyncio
from toolloop import Agent, tool
@tool
async def add(a: int, b: int) -> int:
"""Add two numbers."""
return a + b
class DemoProvider:
"""A scripted provider standing in for your real one."""
def __init__(self):
self.turns = [
'{"type": "tool_call", "calls": [{"id": "c1", "name": "add", "args": {"a": 2, "b": 3}}]}',
'{"type": "final_answer", "output": "2 + 3 = 5"}',
]
async def complete(self, messages):
return self.turns.pop(0)
agent = Agent(DemoProvider(), tools=[add])
result = asyncio.run(agent.run("how much is 2 + 3?"))
print(result.output) # 2 + 3 = 5
print(result.status) # Status.COMPLETED
print(result.history[0].calls[0].result) # 5
Bring your own provider
Implement one async method and you are done — SDK, plain HTTP, whatever:
class MyCorporateProxyProvider:
async def complete(self, messages):
response = await my_corporate_sdk.chat(
[{"role": m.role.value, "content": m.content} for m in messages]
)
return response.text
Reference adapters (OpenAI-compatible endpoints and Anthropic) live in
examples/ as copy-paste code, not dependencies.
Tour
Defining tools
@tool # name = function, schema = type hints
async def search_docs(query: str, limit: int = 5) -> str:
"""Search the internal documentation."""
...
@tool(dangerous=True) # flagged for approval hooks
async def run_migration(env: str) -> str:
"""Run the database migration."""
...
Arguments are validated with pydantic; invalid arguments and raised exceptions become error observations the model repairs from — they never crash the loop.
Running agents
result = await agent.run(
"summarize open PRs",
max_iterations=25,
on_max=OnMax.WRAP_UP, # or RAISE (default) or PARTIAL
output_model=Summary, # pydantic model: validated structured output
)
result.status # Status.COMPLETED | Status.MAX_ITERATIONS
result.output # str, or a validated Summary instance
result.history # full audit trail of every step and call
Control modes and hooks
Two modes, configurable on the Agent and overridable per run():
ControlMode.BYPASS(default) — autonomous; hooks may still veto or rewrite calls.ControlMode.APPROVE— default-deny; every tool call must be allowed by anon_tool_callhook (human-in-the-loop).
async def gatekeeper(ctx) -> Decision:
if ctx.dangerous:
answer = input(f"allow {ctx.name}({ctx.args})? [y/N] ")
return Decision.allow() if answer == "y" else Decision.deny("no")
return Decision.allow()
agent = Agent(provider, tools=STD_TOOLS, control=ControlMode.APPROVE, on_tool_call=gatekeeper)
on_step and on_tool_result hooks give you full observability (audit,
logging, tracing) in both modes.
Context management
Set max_context_tokens and the agent keeps the conversation within budget:
old tool observations are truncated first, then the middle of the conversation
is compacted via summarization by the provider itself. The standard toolset
already returns compact results by design (a write tool confirms the size it
wrote, it does not echo the content).
agent = Agent(provider, tools=STD_TOOLS, max_context_tokens=16_000)
Subagents
Wrap an agent as a tool: it explores with its own isolated context and only its final answer comes back.
from toolloop import subagent_tool
researcher = Agent(provider, tools=[search_docs])
agent = Agent(provider, tools=[subagent_tool(researcher), write_file])
Standard toolset (optional)
from toolloop import STD_TOOLS
# bash, read_file, write_file, edit_file, list_files, grep
Pure-Python coding-agent toolset; import it or ignore it — the core knows nothing about it.
Testing your agents
The provider contract is one method, so deterministic tests are trivial:
script a fake provider with canned responses (see tests/conftest.py in this
repo) and assert on result.history — no LLM, no flakes.
Project
- License: MIT
- Python: 3.11+
- Dependencies: pydantic (only)
- Roadmap: streaming, parallel tool calls, a small CLI (
init,test), ...
How it works
- Tool schemas and the JSON envelope format are rendered into the system
prompt by a pluggable
ToolProtocol(default:JsonToolProtocol). - The agent calls the provider and parses the response envelope:
tool_call(a list of calls, executed sequentially in v1) orfinal_answer. - Tool results are appended as observations; parse/validation errors are fed back so the model can repair its own output.
- The loop ends on
final_answer, onmax_iterations(per the configured policy), or when a hook denies everything.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file toolloop-0.1.0.tar.gz.
File metadata
- Download URL: toolloop-0.1.0.tar.gz
- Upload date:
- Size: 26.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
uv/0.10.9 {"installer":{"name":"uv","version":"0.10.9","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"CachyOS Linux","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
15462924f3114d57a57a491303aa81fd0c9ee5e6c9f70535eae9ab9131d83164
|
|
| MD5 |
063f1deccf5514bc1fc49409a6c492a7
|
|
| BLAKE2b-256 |
8acf597fc30979247f304eb0f4d79169cc2a9eafc2926a64379855962f6493f3
|
File details
Details for the file toolloop-0.1.0-py3-none-any.whl.
File metadata
- Download URL: toolloop-0.1.0-py3-none-any.whl
- Upload date:
- Size: 22.0 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
uv/0.10.9 {"installer":{"name":"uv","version":"0.10.9","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"CachyOS Linux","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
772b49667cda7bcc88cd38720760da418fd70510033e3826eed79fbb1cb02c3b
|
|
| MD5 |
a06b3d10908cad1eca3f213fa3cbdc80
|
|
| BLAKE2b-256 |
21861cef6e273d1942e4287bac0a38d46adfc942cc77ad8987470849ac69be8c
|