Skip to main content

The tool-calling loop for LLM agents — iterator-first, protocol-hooked, zero runtime dependencies.

Project description

looplet

demo — 4-tool data-cleanup loop with a DebugHook trace and a human approval pause

CI codecov PyPI version Python 3.11+ License: Apache 2.0 Status: Beta

A small, framework-agnostic Python library for building LLM agents that call tools in a loop. It hands you a for step in loop(...): iterator so you can observe, filter, or interrupt any step — no graph DSL, no subclassing, no vendor lock-in. Zero runtime dependencies.

from looplet import composable_loop

for step in composable_loop(llm=llm, tools=tools, task=task, config=cfg, state=state):
    print(step.pretty())          # → "#1 ✓ search(query='…') → 12 items [182ms]"
    if step.tool_result.error:
        break                     # your loop, your control flow
pip install looplet               # core — zero third-party packages pulled in
pip install "looplet[openai]"     # works with OpenAI, Ollama, Together, Groq, vLLM, …
pip install "looplet[anthropic]"  # or Anthropic directly

Why it exists

Most agent frameworks give you agent.run(task) and a black box. When the agent does something wrong at step 7, you can't step in between step 6 and step 8. You end up forking the library or writing a second agent to babysit the first.

looplet does the opposite: the loop is the whole product, and hooks are the whole API. Every tool call is a Step object you can print, save, or diff. Every decision the loop makes — what goes in the next prompt, whether to compact context, whether to dispatch a dangerous tool, whether to stop — is a Protocol method you implement in 3 lines. Hooks compose without inheritance. Nothing is hidden.

That one design choice is where the library's three practical superpowers come from:

  • Shape agent behaviour without forking — a 10-line hook can redact PII from every prompt, inject retrieved docs, rewrite tool arguments, or rate-limit calls to a single tool. Hooks are the extension point the framework can't close off because the loop itself is built on them.
  • Manage context on your termscompact_chain(Prune, Summarize, Truncate) is three hooks you wire together. Swap the strategy, change the budget, fire on a different threshold — no monkey-patching.
  • Debug and eval without a second toolstep.pretty() is a human-readable trace, ProvenanceSink dumps every prompt the LLM saw plus every tool result into a diff-friendly directory, and pytest-style eval_* functions turn that trace into a regression suite. Your debug output is your eval harness.

It's what you'd build if you wrote an agent once, got tired of fighting the framework, and decided the framework was the problem.


The mental model — one picture

looplet is a for-loop you own, three loop phases, and four Protocol methods you can implement to change any part of the loop. That's the whole thing:

%%{init: {"theme":"base","themeVariables":{
  "fontFamily":"ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif",
  "fontSize":"15px",
  "lineColor":"#94a3b8"
}}}%%
flowchart LR
    you(["<b>your code</b><br/><span style='font-size:11px;opacity:.75'>for&nbsp;step&nbsp;in&nbsp;loop(...)</span>"]):::you

    h1["<b>pre_prompt</b><br/><span style='font-size:11px;opacity:.9'>redact&nbsp;·&nbsp;inject&nbsp;·&nbsp;compact</span>"]:::hookBlue
    h2["<b>pre_dispatch</b><br/><span style='font-size:11px;opacity:.9'>permissions&nbsp;·&nbsp;approval&nbsp;·&nbsp;rewrite</span>"]:::hookAmber
    h3["<b>post_dispatch</b><br/><span style='font-size:11px;opacity:.9'>trace&nbsp;·&nbsp;metrics&nbsp;·&nbsp;checkpoint</span>"]:::hookAmber
    h4["<b>check_done</b><br/><span style='font-size:11px;opacity:.9'>stop&nbsp;rules&nbsp;·&nbsp;budgets</span>"]:::hookGreen

    subgraph loop[" "]
      direction LR
      prompt(["<b>PROMPT</b><br/><span style='font-size:11px;opacity:.85'>build&nbsp;·&nbsp;call&nbsp;LLM</span>"]):::phaseBlue
      dispatch(["<b>DISPATCH</b><br/><span style='font-size:11px;opacity:.85'>validate&nbsp;·&nbsp;run&nbsp;tool</span>"]):::phaseAmber
      done{{"<b>DONE?</b>"}}:::phaseGreen
      prompt -- "tool call" --> dispatch
      dispatch -- "observation" --> done
      done -- "no" --> prompt
    end

    step[/"<b>Step</b><br/><span style='font-size:11px;opacity:.85'>prompt&nbsp;·&nbsp;call&nbsp;·&nbsp;result&nbsp;·&nbsp;usage</span>"/]:::step

    you == "task" ==> prompt
    done == "yes" ==> step
    step == "yield" ==> you

    h1 -.-> prompt
    h2 -.-> dispatch
    h3 -.-> dispatch
    h4 -.-> done

    linkStyle 0,1,2 stroke:#94a3b8,stroke-width:2px
    linkStyle 3 stroke:#475569,stroke-width:3px
    linkStyle 4 stroke:#059669,stroke-width:3px
    linkStyle 5 stroke:#475569,stroke-width:3px
    linkStyle 6 stroke:#3b82f6,stroke-width:1.5px
    linkStyle 7 stroke:#f59e0b,stroke-width:1.5px
    linkStyle 8 stroke:#f59e0b,stroke-width:1.5px
    linkStyle 9 stroke:#10b981,stroke-width:1.5px

    classDef you        fill:#0f172a,stroke:#334155,stroke-width:2px,color:#f8fafc;
    classDef phaseBlue  fill:#dbeafe,stroke:#2563eb,stroke-width:2.5px,color:#1e3a8a;
    classDef phaseAmber fill:#fef3c7,stroke:#d97706,stroke-width:2.5px,color:#78350f;
    classDef phaseGreen fill:#d1fae5,stroke:#059669,stroke-width:2.5px,color:#064e3b;
    classDef step       fill:#eef2ff,stroke:#4338ca,stroke-width:2.5px,color:#312e81;
    classDef hookBlue   fill:#eff6ff,stroke:#3b82f6,stroke-width:1.5px,color:#1e40af;
    classDef hookAmber  fill:#fffbeb,stroke:#f59e0b,stroke-width:1.5px,color:#92400e;
    classDef hookGreen  fill:#ecfdf5,stroke:#10b981,stroke-width:1.5px,color:#065f46;

    style loop fill:#f8fafc,stroke:#cbd5e1,stroke-width:2px,stroke-dasharray:10 6,color:#1e293b;

Every amber box is a Protocol method. A hook is any object that implements one or more of them — no base class, no inheritance:

class RedactPII:
    def pre_prompt(self, state, log, ctx, step):
        return _scrub_emails(ctx)          # mutates the next LLM prompt

class RetryFlakyTool:
    def pre_dispatch(self, state, log, tc, step):
        if tc.tool == "web_search" and state.last_error:
            return Deny("retry with backoff", retry=True)

for step in composable_loop(..., hooks=[RedactPII(), RetryFlakyTool()]):
    ...

Ship-ready hooks already wired in: ApprovalHook, PermissionHook, CheckpointHook, ContextPressureHook, ThresholdCompactHook, ProvenanceSink, TracingHook, MetricsHook, EvalHook, plus the compact_chain(Prune, Summarize, Truncate) context strategy. Use any, all, or none — and drop in your own in 10 lines.


Your first agent (60 seconds)

from looplet import (
    BaseToolRegistry, DefaultState, LoopConfig, OpenAIBackend,
    ToolSpec, composable_loop, register_done_tool,
)
import os

llm = OpenAIBackend(
    base_url=os.environ.get("OPENAI_BASE_URL", "https://api.openai.com/v1"),
    api_key=os.environ.get("OPENAI_API_KEY", ""),
    model=os.environ.get("OPENAI_MODEL", "gpt-4o-mini"),
)

tools = BaseToolRegistry()
tools.register(ToolSpec(
    name="greet", description="Greet someone.",
    parameters={"name": "str"},
    execute=lambda *, name: {"greeting": f"Hello, {name}!"},
))
register_done_tool(tools)

for step in composable_loop(
    llm=llm, tools=tools,
    state=DefaultState(max_steps=5),
    config=LoopConfig(max_steps=5),
    task={"goal": "Greet Alice and Bob, then finish."},
):
    print(step.pretty())

Works out of the box with any OpenAI-compatible endpoint. No Claude-only SDK, no pydantic schema gymnastics, no LangChain memory objects.

Try it on your laptop against a local Ollama in three lines:

OPENAI_BASE_URL=http://127.0.0.1:11434/v1 \
OPENAI_API_KEY=ollama OPENAI_MODEL=llama3.1 \
python -m looplet.examples.hello_world

When should you reach for looplet?

Use it when you want to build your own agent loop and actually own the details. Concretely:

  • You need to insert logic at an exact phase of the loop — before the prompt is built, before a tool is dispatched, after a tool returns — without forking a framework.
  • You need to swap context-management strategy at runtime (prune, summarize, truncate, your own) without losing the rest of your stack.
  • You need the loop to pause for human approval, then resume where it left off when approval arrives.
  • You want first-class debugging and evaluation — a printable Step, a prompt-level provenance dump, pytest-style eval_* functions — without bolting on a second tool.
  • You want zero runtime dependencies and a loop that cold-imports in ~300 ms (numbers in docs/benchmarks.md).

Don't reach for looplet if you want agent.run(task) to handle everything and return a string, or if you want a visual graph DSL — a higher-level framework will feel more natural and the overlap in features won't be worth the extra control looplet gives you.


Examples

All three real-LLM examples read OPENAI_BASE_URL, OPENAI_API_KEY, and OPENAI_MODEL from the environment. Point them at Ollama or any OpenAI-compatible endpoint.

python -m looplet.examples.hello_world                            # 30-line starter
python -m looplet.examples.coding_agent "implement fizzbuzz"      # bash/read/write/edit/grep
python -m looplet.examples.coding_agent --trace ./traces/         # save full trajectory
python -m looplet.examples.data_agent --clean                     # approval + compact + checkpoints
python -m looplet.examples.data_agent --resume                    # resume from last checkpoint

Plus scripted_demo.py — a scripted MockLLMBackend run used only to record the GIF above. Not a usage reference.


Learn more

Doc What's in it
docs/tutorial.md Build your first agent in 5 steps
docs/hooks.md Writing and composing hooks
docs/evals.md pytest-style agent evaluation
docs/provenance.md Capturing prompts + trajectories
docs/recipes.md Ollama, OTel, MCP, cost accounting, checkpoints
docs/benchmarks.md Cold-import time & dep footprint vs alternatives
docs/faq.md FAQ, including "why not LangGraph?"
ROADMAP.md What's planned, what's frozen, what's out of scope
CONTRIBUTING.md Dev setup, conventions, PR checklist
CHANGELOG.md Release notes

Stability

looplet follows SemVer. Pre-1.0, minor versions may introduce breaking changes as the design stabilises — pin conservatively:

looplet>=0.1.7,<0.2

See ROADMAP.md § v1.0 API contract for the frozen surface and the path to 1.0.

Contributors

Thanks to everyone who has contributed to looplet:

See CONTRIBUTING.md for how to get started.

Contributing

Contributions welcome: bug reports, docs, backends, examples, evals. Start with CONTRIBUTING.md and docs/good-first-issues.md. Security issues go through SECURITY.md.

License

Apache 2.0. See LICENSE.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

looplet-0.1.8.tar.gz (303.6 kB view details)

Uploaded Source

Built Distribution

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

looplet-0.1.8-py3-none-any.whl (204.5 kB view details)

Uploaded Python 3

File details

Details for the file looplet-0.1.8.tar.gz.

File metadata

  • Download URL: looplet-0.1.8.tar.gz
  • Upload date:
  • Size: 303.6 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for looplet-0.1.8.tar.gz
Algorithm Hash digest
SHA256 aab3ca41ce78136ba2a84d08a6d5529f3543f43f3372f0a961cd1472188fe6c9
MD5 fb05a3c585dd1000433ac954d39a7061
BLAKE2b-256 edc2b4d0d90fa02936f0bd65e89e9b3175979afdc3869a3a6d7b7bc7dbf19a9d

See more details on using hashes here.

Provenance

The following attestation bundles were made for looplet-0.1.8.tar.gz:

Publisher: publish.yml on hsaghir/looplet

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file looplet-0.1.8-py3-none-any.whl.

File metadata

  • Download URL: looplet-0.1.8-py3-none-any.whl
  • Upload date:
  • Size: 204.5 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for looplet-0.1.8-py3-none-any.whl
Algorithm Hash digest
SHA256 2c52429a53bc36a3d098d459c65f53e571ffbb91483251fcce51ab95cbec53b5
MD5 c9a7af45789dfe2c5bcda1d928749437
BLAKE2b-256 cc6310073a212493e2f642936e273022d8668ccd231ea57bff56c9e82a7c2ff8

See more details on using hashes here.

Provenance

The following attestation bundles were made for looplet-0.1.8-py3-none-any.whl:

Publisher: publish.yml on hsaghir/looplet

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

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