Skip to main content

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 an on_tool_call hook (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

  1. Tool schemas and the JSON envelope format are rendered into the system prompt by a pluggable ToolProtocol (default: JsonToolProtocol).
  2. The agent calls the provider and parses the response envelope: tool_call (a list of calls, executed sequentially in v1) or final_answer.
  3. Tool results are appended as observations; parse/validation errors are fed back so the model can repair its own output.
  4. The loop ends on final_answer, on max_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

toolloop-0.1.0.tar.gz (26.9 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

toolloop-0.1.0-py3-none-any.whl (22.0 kB view details)

Uploaded Python 3

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

Hashes for toolloop-0.1.0.tar.gz
Algorithm Hash digest
SHA256 15462924f3114d57a57a491303aa81fd0c9ee5e6c9f70535eae9ab9131d83164
MD5 063f1deccf5514bc1fc49409a6c492a7
BLAKE2b-256 8acf597fc30979247f304eb0f4d79169cc2a9eafc2926a64379855962f6493f3

See more details on using hashes here.

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

Hashes for toolloop-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 772b49667cda7bcc88cd38720760da418fd70510033e3826eed79fbb1cb02c3b
MD5 a06b3d10908cad1eca3f213fa3cbdc80
BLAKE2b-256 21861cef6e273d1942e4287bac0a38d46adfc942cc77ad8987470849ac69be8c

See more details on using hashes here.

Release history Release notifications | RSS feed

1.1.0

2 files

1.0.1

2 files

1.0.0

2 files

This release

0.1.0 This release

2 files

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page