Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Read by the CLI only. The library never reads this file.

# One of: anthropic | openrouter | modal | openai
LLM_PROVIDER=anthropic
LLM_PROVIDER=openrouter

ANTHROPIC_API_KEY=
ANTHROPIC_MODEL=claude-opus-5
Expand Down
6 changes: 6 additions & 0 deletions BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ Left over:
- **Hosted-tools guide examples (item 12).** The `agentloop-tools` parts of
`docs/guides/hosted-tools.md` (`native_web_search`, `DomainPolicy`) were written from this
backlog and not run here.
- **Companion package version compatibility.** `based-models-agentloop-tools` 0.1.0 pins
`based-models-agentloop>=0.4,<0.5`, while this checkout is 0.5.0. Update the companion
package's dependency range after verifying it against 0.5.0 so both can be installed
together by pip.
- **Public documentation hosting.** Add the `docs/` content to the website, then replace
the PyPI README's documentation placeholder with a link to the published pages.

---

Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ while `0.x`, a minor bump may break the public API (`agentloop.__all__`).
o-series reasoning models no longer receive `parallel_tool_calls`, which they reject.

### Changed
- **Breaking:** the default provider is now `openrouter`, not `anthropic`, for `Settings.provider`
and `Settings.from_env` when `LLM_PROVIDER` is unset. Set `LLM_PROVIDER=anthropic` (or
`Settings(provider="anthropic")`) to keep the old behavior; otherwise the missing
`OPENROUTER_API_KEY` raises `AuthError`.
- Resuming a transcript that ends in unanswered tool calls (the process died while tools ran)
runs those calls before the first model call instead of sending an invalid request. Any other
invalid transcript raises `TranscriptError` before a model call, not a provider 400.
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,5 +79,5 @@ the relevant `docs/api/*` and `docs/guides/*` pages, `docs/examples/*`, and `__a
## Releasing

Use the `/release` skill (`.claude/skills/release/SKILL.md`; background in
`docs/guides/releasing.md`). Pushing a `vX.Y.Z` tag publishes to PyPI immediately and a version
`RELEASING.md`). Pushing a `vX.Y.Z` tag publishes to PyPI immediately and a version
can never be reused. Never `git push --tags`, force-push a tag or `twine upload`.
425 changes: 425 additions & 0 deletions DOCUMENTATION.md

Large diffs are not rendered by default.

158 changes: 100 additions & 58 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,14 @@ The package is installed as `based-models-agentloop` and imported as `agentloop`
## Why use agentloop?

- Use the same agent code with Anthropic, OpenAI, OpenRouter, or Modal/vLLM.
- Run local Python tools or provider-hosted web search.
- Keep complete, typed, serializable transcripts—including tool activity, sources, and
citations.
- Execute independent tool calls concurrently while preserving their original result order.
- Get consistent retries, errors, usage data, time limits, and iteration limits across
providers.
- Record outcomes, event logs, distributed traces, and metrics without changing agent
behavior.
- Run local Python tools with approval hooks and trusted dependencies.
- Constrain provider-hosted web search with domain allowlists or blocklists.
- Keep typed, serializable transcripts with tool activity, sources, and citations.
- Run independent tool calls concurrently while preserving result order.
- Get consistent retries, error types, usage data, and run limits across providers.
- Record summaries, event logs, traces, and metrics with metadata-only defaults; redact
captured content before logging or export.
- Test agent behavior offline with the included scripted `FakeClient`.
- Install only the provider dependencies your application needs.

Requires Python 3.12 or newer.

Expand All @@ -41,15 +39,17 @@ import os

from agentloop import Agent

with Agent.from_env(
os.environ,
tools=[],
system="Answer clearly and concisely.",
) as agent:
print(agent.run("Why is the sky blue?"))
with Agent.from_env(os.environ, tools=[]) as agent:
print(agent.run("Reply with exactly: ready"))
```

Output:

```text
ready
```

`Agent.from_env()` receives an environment mapping explicitly—the library never reads the
`Agent.from_env()` receives an environment mapping explicitly. The library never reads the
process environment or loads a `.env` file on its own. The context manager closes the client
created by `from_env()` when the block exits.

Expand Down Expand Up @@ -97,7 +97,7 @@ contract shown to the model and is never inferred from the function signature.
```python
import os

from agentloop import Agent, ToolError, tool
from agentloop import Agent, tool

ADD_SCHEMA = {
"type": "object",
Expand All @@ -112,16 +112,24 @@ ADD_SCHEMA = {
@tool(parameters=ADD_SCHEMA)
def add(left: int, right: int) -> str:
"""Add two integers."""
if abs(left) > 1_000_000 or abs(right) > 1_000_000:
raise ToolError("numbers must be at most one million")
return str(left + right)

with Agent.from_env(
os.environ,
tools=[add],
system="Use the add tool for arithmetic.",
system="Use the add tool for arithmetic. Reply with only its numeric result.",
) as agent:
print(agent.run("What is 27 plus 15?"))
result = agent.run_result("What is 27 plus 15?")

print(result.text)
print(result.summary.tool_calls)
```

Output:

```text
42
{'add': 1}
```

`ToolError` returns safe feedback to the model. Unexpected exceptions are logged and
Expand All @@ -140,21 +148,33 @@ search is supported by Anthropic and by OpenAI's Responses API:
```python
import os

from agentloop import Agent, HostedTool
from agentloop import Agent, HostedTool, HostedToolPart

search = HostedTool(
kind="web_search",
options={
"allowed_domains": ["europa.eu"],
"search_context_size": "high",
},
)
search = HostedTool(kind="web_search", options={"allowed_domains": ["python.org"]})

with Agent.from_env(os.environ, tools=[search]) as agent:
print(agent.run("What changed in EU AI Act guidance this month?"))
result = agent.run_result(
"Use web search to find the title of the Python home page. "
"Reply with only the title, without explanation or citation."
)

print(result.text)
print(any(
isinstance(part, HostedToolPart)
for message in result.transcript.messages
for part in message.content
))
```

Search activity, consulted sources, and citations are preserved in the transcript. Passing a
Output:

```text
Welcome to Python.org
True
```

The `True` means the transcript recorded a hosted search call. Search activity, consulted
sources, and citations are preserved in the transcript. Passing a
hosted tool to an unsupported provider or API fails when the agent is constructed instead of
silently dropping the capability.

Expand All @@ -170,15 +190,26 @@ from agentloop import Agent, Transcript, final_text

transcript = Transcript()

with Agent.from_env(os.environ, tools=[]) as agent:
with Agent.from_env(
os.environ,
tools=[],
system="When asked for the user's name, reply with only the name.",
) as agent:
transcript.add_user_message("My name is Ada.")
agent.converse(transcript)

transcript.add_user_message("What is my name?")
agent.converse(transcript)

print(final_text(transcript))
print(transcript.model_dump_json(indent=2))
print(len(transcript.messages))
```

Output:

```text
Ada
4
```

A transcript can contain text, model reasoning, local tool calls and results, hosted-tool
Expand All @@ -196,21 +227,22 @@ Rate limits, provider unavailability, timeouts, and invalid responses are retrya
authentication, bad-request, context-length, missing-model, and credit errors fail
immediately. Server `Retry-After` values are honored within the configured delay limit.

```python
import os
A missing credential fails before a model request is sent:

from agentloop import Agent, AgentLoopError
from agentloop.errors import AuthError, RateLimited
```python
from agentloop import Agent
from agentloop.errors import AuthError

try:
with Agent.from_env(os.environ, tools=[]) as agent:
print(agent.run("Hello"))
except AuthError:
print("Check provider credentials")
except RateLimited as error:
print("The provider remained rate limited", error.retry_after)
except AgentLoopError as error:
print(f"Agent failed: {type(error).__name__}: {error}")
Agent.from_env({"LLM_PROVIDER": "openai"}, tools=[])
except AuthError as error:
print(type(error).__name__, error)
```

Output:

```text
AuthError OPENAI_API_KEY is not set
```

The loop also enforces iteration and wall-clock budgets, protects against truncated tool
Expand All @@ -228,21 +260,30 @@ Choose the level of detail that fits the task:
| Build dashboards and alerts | OpenTelemetry metrics |
| Send conversation events to an existing system | A custom observer |

Every conversation produces a `ConversationSummary` on success and failure. It records the
status, model calls, elapsed time, tool calls and errors, retries, token use, and provider-
reported cost. It contains metadata, not prompts, tool arguments, results, or exception
messages.
Every started conversation produces a `ConversationSummary` on success and failure. It
records the status, model calls, elapsed time, tool calls and errors, retries, token use,
and provider-reported cost when available. It contains metadata, not prompts, tool
arguments, results, or exception messages.

```python
import os

from agentloop import Agent

with Agent.from_env(os.environ, tools=[]) as agent:
result = agent.run_result(
"Summarize this account's open support cases.",
correlation_id="case-review-42",
)
result = agent.run_result("Reply with exactly: ready")

print(result.text)
print(result.summary.status)
print(result.summary.usage)
print(result.summary.status.value, result.summary.iterations)
print(result.summary.usage.input_tokens, result.summary.usage.output_tokens)
```

Output:

```text
ready
completed 1
11 11
```

Read the summary from a result, from `.summary` on an `AgentLoopError`, or in an observer's
Expand All @@ -251,8 +292,9 @@ warnings as a conversation approaches its limits, and detection of repeated tool

`EventTraceObserver` writes an ordered JSONL event stream to a function supplied by your
application, such as a file writer, logger, or queue. Its default output contains metadata
only. Capturing prompts, responses, tool arguments, results, or error text requires an
explicit setting and a redaction function.
only. Capturing prompts, responses, tool arguments, results, or error text requires
`capture_content=True` and a redaction function, unless you explicitly choose
`allow_unredacted=True`.

Install the `otel` extra to use OpenTelemetry spans and metrics:

Expand Down Expand Up @@ -285,7 +327,7 @@ and the rest of the package.

| Variable | Default | Purpose |
|---|---|---|
| `LLM_PROVIDER` | `anthropic` | `anthropic`, `openai`, `openrouter`, or `modal` |
| `LLM_PROVIDER` | `openrouter` | `anthropic`, `openai`, `openrouter`, or `modal` |
| `LLM_MAX_ATTEMPTS` | `3` | Total attempts, including the first request |
| `LLM_TIMEOUT_SECONDS` | provider default | Read timeout for one model call (Anthropic: the SDK's overall timeout) |
| `ANTHROPIC_API_KEY` | unset | Anthropic credential |
Expand Down
77 changes: 77 additions & 0 deletions RELEASING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# Releasing

Create release tags from the intended commit on `main`. Pushing a `vX.Y.Z` tag starts the
release workflow, which builds the package and publishes it to PyPI through Trusted
Publishing. The workflow does not check which branch contains the tagged commit. Complete
the checks below before pushing the tag.

## Checklist

1. Move the `[Unreleased]` entries in `CHANGELOG.md` under a new `## [X.Y.Z]` heading.
2. Set the version in **both** `pyproject.toml` and `src/agentloop/__init__.py`.
3. In a fresh clone, use Python 3.12 or newer to run the checks and test the built package:

```bash
git clone . /tmp/agentloop-release && cd /tmp/agentloop-release
python -m pip install -e '.[all,dev]'
bash scripts/check.sh
bash scripts/test-wheel.sh
```

4. Once the checks pass, create and push the tag for that commit:

```bash
git tag vX.Y.Z && git push origin vX.Y.Z
```

The `Release` workflow then:

1. **build**: refuses to continue unless the tag, `pyproject.toml` and `__version__` agree
and the changelog has an entry; runs ruff, mypy, pytest and `scripts/test-wheel.sh`;
builds the wheel and sdist once and uploads them as an artifact.
2. **publish-pypi**: uploads those exact files to PyPI via Trusted Publishing. PyPI records
provenance attestations for the published files.
3. **github-release**: attaches the same files plus `SHA256SUMS` to the GitHub release.

## Publishing setup

- The PyPI and TestPyPI projects need trusted publishers for owner `based-models`, repository
`agent-loop`, workflow `release.yml`, and their respective environments: `pypi` and
`testpypi`.
- The GitHub repository needs environments named `pypi` and `testpypi` to match those
publisher configurations. Check their settings before starting a release.

## TestPyPI rehearsal

In GitHub Actions, select **Release** then **Run workflow** on the version-bump branch. This
manual run builds and tests the package, then publishes to TestPyPI only. Choose a version
that has not been uploaded there before; an uploaded version cannot be reused. Check the
TestPyPI release page before running it, then install the result in a clean environment:

```bash
python -m pip install --index-url https://test.pypi.org/simple/ \
--extra-index-url https://pypi.org/simple/ 'based-models-agentloop[anthropic]==X.Y.Z'
```

The extra index lets pip fetch dependencies that are not available on TestPyPI. Use a
version unique to TestPyPI so the command does not select the same version from PyPI.

## What `scripts/test-wheel.sh` proves

It builds the wheel and source distribution, then installs the wheel into clean virtual
environments. An install with only `anthropic` checks that `httpx` and the OpenTelemetry SDK
remain optional and that missing extras produce useful errors. A separate install with `all`
checks provider imports. The script also checks `py.typed`, license metadata, and package
contents. It deletes and recreates the clone's `dist/` directory.

## Rolling back

PyPI files are immutable and a version number cannot be reused. If a release is broken,
[yank it on PyPI](https://docs.pypi.org/project-management/yanking/), fix the issue, and
publish a new version. Installers usually skip yanked releases, but an exact `==` or `===`
pin may still select one. Do not move a published tag.

## Versioning

This project uses SemVer. While the version is `0.x`, a minor bump may change the public API
exposed through `agentloop.__all__`.
Loading
Loading