Skip to main content

A lightweight async tool-calling agent for any OpenAI-compatible API, usable as a library or CLI

Project description

coreloop

A lightweight async tool-calling agent for any OpenAI-compatible API (via httpx). The core is an async generator loop that streams Message objects; you observe and intercept it via lifecycle hooks. Usable as a library or through a minimal CLI.

The minimal core imposes no forced overhead, so it pairs well with small local models; early exit via hooks and stop() makes high-throughput batch work fast and keeps token costs down.

Built-in tools: path-scoped read, ls, edit, grep; a bash tool with best-effort guardrails (not a security sandbox — see below); optional web_search and web_fetch (via the [web] extra).

Install

pip install coreloop
pip install "coreloop[web]"   # adds web_search and web_fetch

Library quick-start

import asyncio
from coreloop import Agent, Message

agent = Agent(
    model="gpt-4o-mini",
    base_url="https://api.openai.com/v1",
    api_key="sk-...",
    tools=["read", "ls", "grep"],
    root="/tmp/sandbox",
)

async def main():
    async for msg in agent.run([Message(role="user", content="What files are here?")]):
        if msg.role == "assistant" and not msg.partial and msg.content:
            print(msg.content)

asyncio.run(main())

Or load settings from a named profile in ~/coreloop.toml:

agent = Agent.from_profile("openai")

CLI

core is a REPL / one-shot runner with profile support. On first run it copies the bundled coreloop.toml to ~/coreloop.toml — edit that file to set your default model, tools, and provider credentials.

# Interactive REPL using the default profile (Ollama)
core

# One-shot with a named profile
core --profile openai -p "Summarise this repo"

# Override model and enable file tools
core --profile openai --model gpt-4o --tools read,ls,grep --root .

# Bypass profiles entirely
core --base-url https://api.openai.com/v1 --api-key $OPENAI_API_KEY --model gpt-4o-mini -p "Hello"

# Enable reasoning
core --think -p "Explain this step by step" --model qwen3-14b
Flag Default Description
--profile default Named profile from ~/coreloop.toml
-m, --model profile value Model name — overrides profile (CORELOOP_MODEL)
--base-url profile value API base URL — overrides profile (CORELOOP_BASE_URL)
--api-key profile value API key — overrides profile (CORELOOP_API_KEY)
-s, --system System prompt
--tools profile value Comma-separated: read,ls,edit,grep,bash,web_search,web_fetch
-r, --root cwd Allowed root directory for file tools
--searxng-url $SEARXNG_URL SearXNG base URL for web tools
-t, --llm-timeout profile value Asyncio wall-clock timeout per LLM turn (seconds)
--tool-timeout profile value Hard timeout per tool call (seconds)
--http-request-timeout profile value httpx per-chunk read timeout (seconds)
--cache-dir profile value LLM response cache directory
--no-cache off Disable response caching
-e, --extra Extra JSON merged into the API request body
--think/--no-think off Set reasoning_effort to medium / none
-n, --max-turns 20 Maximum loop iterations
-p, --prompt Run once and print final response
--json off Output all non-partial messages as JSONL
-l, --log-level DEBUG, INFO, WARNING, ERROR

REPL commands: /quit /exit /q to exit; /new to clear history; /model <name> to switch models; /root <path> to change the file-tool root.

Agent

Agent(
    model: str,
    base_url: str = "http://localhost:11434/v1",
    api_key: str | None = None,
    system: str | None = None,
    tools: list[str | ToolInfo] | None = None,
    root: str | Path | None = None,
    http_request_timeout: float = 300.0,  # httpx per-chunk read timeout
    tool_timeout: float = 360.0,          # hard wall per tool call
    llm_timeout: float = 300.0,           # asyncio wall for the entire LLM turn
    hooks: AgentHooks | None = None,
    llm_extra_body: dict | None = None,
    cache_dir: Path | str | None = "~/.cache/coreloop-llm-cache",
)

tools accepts built-in names ("read", "ls", "edit", "grep", "bash", "web_search", "web_fetch"), names of @tool-registered functions, or ToolInfo objects. File tools are scoped to root; an unknown name raises ValueError. An agent has exactly the tools you list — there is no implicit inclusion of the global registry.

run(messages) is an async generator. Partial streaming chunks have partial=True; the final assembled message for each LLM turn has partial=False. Pass usage=Usage() to accumulate token counts across turns.

Method / property Description
run(messages, *, usage=None) Run the agent loop, yielding Message objects
stop() Finish the current turn cleanly, then exit. Safe from a hook or tool.
abort() Cancel immediately; on_after_agent is not called
reset() Clear history and stop flag
stopped True after stop() or abort()
messages Shallow copy of full chat history from the last run()

Restart pattern — pass agent.messages to keep history across runs:

async for msg in agent.run([Message(role="user", content="Hello")]):
    ...

agent.model = "stronger-model"
async for msg in agent.run(agent.messages + [Message(role="user", content="Now do X")]):
    ...

Built-in tools

All file tools reject path traversal and are scoped to root.

Tool Description
read Read a text file; offset/limit for paging (1-based line numbers)
ls List a directory
edit Replace an exact string in a file; fails on ambiguous matches
grep Regex search via rg; supports type, after_context, files_with_matches
bash Run arbitrary shell commands via bash -c; kills the entire process group on timeout

bash is not sandboxed. It runs whatever the model sends with your full user privileges. The workdir is scoped to root, but a command can still read, write, or delete anything your account can reach, and make network calls. The dangerous-pattern blocklist (e.g. rm -rf /, mkfs, fork bombs) is a speed bump against obvious accidents, not a security boundary — trivial variants slip through (curl … | bash, python -c "…", unusual flag orders). Only enable bash for models and prompts you trust, and prefer running in a container or VM.

Web tools require pip install "coreloop[web]" and a running SearXNG instance:

docker run -d -p 8080:8080 searxng/searxng
Tool Description
web_search Search via SearXNG; returns titles, URLs, snippets; supports max_results, domain_filter, recency
web_fetch Fetch a URL; extract_mode: markdown (default), article, raw, metadata

Custom tools

from coreloop import tool

@tool
async def read_env(name: str) -> str:
    """Read an environment variable."""
    import os
    return os.environ.get(name, "(not set)")

# Attach by name or pass the ToolInfo object directly
agent = Agent(model="...", tools=["read_env"])
agent = Agent(model="...", tools=[read_env])  # equivalent

@tool infers JSON Schema from type annotations (str, int, float, bool, list[T], dict, Optional[T]). Unrecognised types fall back to string with a warning. Override name or description:

@tool(name="my_read", description="Read a local file")
async def _impl(path: str) -> str:
    ...

@tool(allow_override=True)   # re-register if a tool with that name already exists
async def read_env(name: str) -> str:
    ...

The decorated object is both a ToolInfo and directly callable (await my_tool(...) works alongside registration).

Registry helpers:

from coreloop import list_tools, get_tool, clear_registry

list_tools()          # list[ToolInfo] — all globally registered tools
get_tool("read_env")  # ToolInfo | None
clear_registry()      # remove all tools (useful in tests)

Hooks

Subclass AgentHooks to observe or intercept any stage of the loop:

from coreloop import AgentHooks

class LogHook(AgentHooks):
    async def on_before_tool(self, agent, name, args):
        print(f"→ {name}({args})")
        return None  # None = run the tool; return str to inject a result instead

agent = Agent(..., hooks=LogHook())

Hook firing order per turn:

on_before_turn
on_before_llm   → return Message to skip the LLM call entirely
  <LLM streams>
on_after_llm    → return Message to replace before appending to history
  for each tool (in parallel):
    on_before_tool  → return str to skip tool execution
      <tool runs>
    on_after_tool   → return str to replace result in history
on_after_turn

on_before_agent / on_after_agent bracket the entire run() call. on_after_agent is not called after abort(). Hook exceptions are logged and swallowed — they cannot crash the agent.

MaxTurnsHook(n) calls agent.stop() after n turns; the counter resets on each run(), so it enforces a per-run limit, not a lifetime one.

Config profiles

Agent.from_profile("openai") builds an AgentConfig from ~/coreloop.toml (or $CORELOOP_CONFIG). Every profile inherits from [profiles.default]; named profiles override individual keys.

[profiles.default]
base_url = "http://localhost:11434/v1"
model    = "qwen3.5:9b"

[profiles.openai]
base_url = "https://api.openai.com/v1"
api_key  = "{{OPENAI_API_KEY}}"
model    = "gpt-4o-mini"
tools    = ["read", "ls", "grep"]

[profiles.together]
base_url = "https://api.together.xyz/v1"
api_key  = "{{TOGETHER_API_KEY}}"

{{VAR_NAME}} in any string value is interpolated from the environment. Unknown keys are silently ignored. The shipped src/coreloop/coreloop.toml includes pre-configured profiles for Ollama, OpenAI, Groq, DeepSeek, Together, and OpenRouter.

AgentConfig is a dataclass mirroring the Agent constructor (excluding hooks). Use dataclasses.replace(cfg, model="other") to derive variants, or Agent.from_config(cfg) to construct an agent from one.

Message

Field Type Description
role str "user", "assistant", "system", "tool"
content str | None Text content; None when tool_calls is set
tool_calls list[ToolCall] | None Tool calls emitted by the assistant
tool_call_id str | None Links a "tool" message to its call
name str | None Tool name on role="tool" messages
reasoning str | None Thinking-model scratchpad (Qwen3, DeepSeek). Not sent back to the API.
partial bool True for streaming delta chunks
usage Usage | None Token counts from the model
model str | None Model name reported by the API
duration float | None Seconds elapsed for the LLM turn or tool call
timestamp datetime UTC creation time

Caching

LLM responses are disk-cached by default (~/.cache/coreloop-llm-cache/), keyed by SHA-256 of (model, messages, tools, extra_body). This replays identical requests without hitting the API — useful during development. Pass cache_dir=None to disable.

agent = Agent(model="...", cache_dir="/tmp/my-cache")  # custom location
agent = Agent(model="...", cache_dir=None)             # disabled

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

coreloop-0.1.2.tar.gz (35.4 kB view details)

Uploaded Source

Built Distribution

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

coreloop-0.1.2-py3-none-any.whl (46.3 kB view details)

Uploaded Python 3

File details

Details for the file coreloop-0.1.2.tar.gz.

File metadata

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

File hashes

Hashes for coreloop-0.1.2.tar.gz
Algorithm Hash digest
SHA256 ca43e5a4189aea04e7eb4cb77b3cf9a1e3affe14603ae108c774aa584ce63895
MD5 6cb33116d82f7885a2438beb18ca3171
BLAKE2b-256 86b69dce15942c53a757c55041374f898668144470208944b78993be0df6bfbd

See more details on using hashes here.

Provenance

The following attestation bundles were made for coreloop-0.1.2.tar.gz:

Publisher: python-publish.yml on rhiza-fr/coreloop

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

File details

Details for the file coreloop-0.1.2-py3-none-any.whl.

File metadata

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

File hashes

Hashes for coreloop-0.1.2-py3-none-any.whl
Algorithm Hash digest
SHA256 362a892d84281206280383ab78e6f92616d7125d15fb93ff5cfec759a62fc1cb
MD5 e02807e6ae862398f059fb15eb79e323
BLAKE2b-256 d819b5c114720aa139bd755c958ca180a047bd03441a491cd39ad72f0c0a73a4

See more details on using hashes here.

Provenance

The following attestation bundles were made for coreloop-0.1.2-py3-none-any.whl:

Publisher: python-publish.yml on rhiza-fr/coreloop

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