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.0.tar.gz (35.6 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.0-py3-none-any.whl (46.3 kB view details)

Uploaded Python 3

File details

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

File metadata

  • Download URL: coreloop-0.1.0.tar.gz
  • Upload date:
  • Size: 35.6 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.0.tar.gz
Algorithm Hash digest
SHA256 cbaf4698733ea3e05f59a4c55dcfd59b9f22592b1434758cc78bcba24d6c2e49
MD5 66e6e7619c9b1aebadc915ef90ef30d2
BLAKE2b-256 3b327af66e0b069543819db68840d0ea9a6ebe2eb93bbba964ca030d186432d1

See more details on using hashes here.

Provenance

The following attestation bundles were made for coreloop-0.1.0.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.0-py3-none-any.whl.

File metadata

  • Download URL: coreloop-0.1.0-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.0-py3-none-any.whl
Algorithm Hash digest
SHA256 bcf4eada6b849b267f1e451d453d11ced8dc82ab10babcb0aaa1676ec34fe309
MD5 db9fea4538e799c271fef4a9cb6684e2
BLAKE2b-256 84213e3c6c6fb185fbbbe5b11e067fce67d3b96ceab663a0fe487bb64ee7f6e9

See more details on using hashes here.

Provenance

The following attestation bundles were made for coreloop-0.1.0-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