Repository navigation
Fix 31 audit findings: red CI, broken fallback, cache collisions, dead model defaults - #7
Merged
Merged
Conversation
CI ran ruff check, ruff format --check and pytest, and all three failed on main. This clears the first two and the packaging problems behind them. - Resolve the 6 ruff errors in examples/ (unused variable, unused import, f-string without placeholders, unsorted imports, two long lines). - Apply ruff format across the repo, including the Python blocks in README.md that the CI format check covers. - Single-source the version: pyproject declared 0.1.0 while __init__ declared 0.6.0, so a tagged release would have published 0.1.0. hatch now reads the version from src/pure_agents/__init__.py. - Drop pydantic from dependencies. Nothing imports it.
The two failing tests asserted models that clients.py never defined, so the suite was red on main. The Anthropic default was also claude-sonnet-4-20250514, which retires on 2026-06-15. Defaults are now mistral-large-latest, gpt-5.2-instant and claude-opus-5, with docs, llms.txt and examples brought in line.
Coverage stopped at the @tool decorator and JSONMemory: nothing exercised run(), the tool loop, or either provider client, because there was no way to answer an HTTP request without one. - Add an injectable `transport` to LLMClient and AnthropicClient. It defaults to None, so production behaviour is unchanged. - tests/conftest.py: FakeLLM queues scripted responses, records request bodies, and fails loudly on an unscripted call. Response builders for both wire formats. An autouse fixture clears provider env vars so a developer's real keys can never leak into a run. - tests/test_run_loop.py: 15 tests over the final-answer path, system prompt placement, history, usage accounting, single and parallel tool calls, unknown tools, tool exceptions, max steps, and the Anthropic conversions.
Two bugs made both features unusable in the cases they exist for. _create_client read `os.environ.get(env_var, self.api_key)`, so an explicit api_key= lost to whatever was in the environment. In multi-tenant code the agent silently authenticated as the wrong account. Precedence is now explicit key first, environment second, as the README always said. _chat_with_retry appended the fallback client to the client list but kept sending the primary provider's model, so `provider="mistral", fallback="anthropic"` sent mistral-large-latest to the Anthropic API on every failover. Attempts now pair each client with the model that client serves, and the fallback resolves its own API key from its own environment variable. Add fallback_model= and fallback_api_key= for explicit control, and reject a fallback with no key at construction rather than at failover.
The cache key was computed before the system prompt was placed into the history, so on a fresh agent it hashed only the prompt and the model name. Two agents with different personalities, different providers or different tool sets shared entries and answered each other's questions. - Key on provider, model, tool set, tool_choice and the full message list, with the system prompt already in place. - Replace the unbounded module dict with a bounded LRU (128 entries, tunable through the new set_cache_size()). It used to grow for the life of the process. - A hit now appends the exchange to the history instead of returning early and leaving a gap, and honours output=Dataclass instead of handing back a raw string where the type says T. - Stop caching a response that failed its validator on the final attempt.
json.loads on the model's argument string sat outside the try block, so a malformed tool call raised JSONDecodeError straight through run() and into user code. Models emit invalid JSON; the surrounding code already knew how to hand an error back to the model, and this path skipped it. - Parse arguments inside the guarded section and report a parse failure, a non-object payload, or a wrong argument name as a tool result. - Tolerate a missing tool_call id by synthesising one, so a provider that omits it no longer raises KeyError. - gather(return_exceptions=True): one failing call used to propagate and leave its siblings orphaned mid-flight. - Wrap the on_tool_call and on_tool_result hooks so a user callback that raises cannot take the agent down.
_trim_messages sliced the history by count alone, so max_messages could leave a `tool` message whose assistant `tool_calls` message had been cut. A 3-message window over a conversation with tool use produced [system, tool, user], which OpenAI-compatible APIs and Anthropic both reject with a 400. max_messages broke exactly the conversations tools are used in. The window now snaps back to the nearest user turn, so no tool result is ever separated from its call and the window still opens on a user message the way Anthropic requires. That can keep a message or two more than the budget asked for; correctness on the wire is worth more than an exact count. Trimming also runs after tool results are appended, so a long tool loop no longer grows the history without bound within a single run.
Tool.call wrapped a coroutine that invoked the function inline, so a sync tool blocked the event loop for its whole duration. Two consequences: asyncio.wait_for never got a chance to fire, making @tool(timeout=) silently inert for every sync tool, and _execute_tools_parallel ran sync tools one after another despite the asyncio.gather around them. Sync functions now run through asyncio.to_thread. A 0.2s timeout on a time.sleep(1.5) tool now raises after ~0.2s instead of returning after 1.5s, and three 0.3s tools finish in ~0.3s instead of ~0.9s. A timeout still cannot kill the thread, which the docstring now says outright.
Two independent converters both stopped at str/int/float/bool and sent everything else to the model as a string, and both marked every field required. - Add pure_agents/schema.py as the single converter: typed lists and dicts, Optional and unions, Literal, Enum and nested dataclasses, with bool matched before int rather than after it. - @tool now derives `required` from the signature, so `limit: int = 10` stops being an argument the model must invent. The Anthropic conversion reads the same schema instead of rebuilding its own. - The README's own structured-output example advertised `keywords: list[str]` as a string. It is now an array of strings. - _parse_structured raises StructuredOutputError, carrying the raw reply, instead of letting a JSONDecodeError or TypeError escape from run(). It also copes with a reply that wraps the JSON in prose.
_convert_messages emitted one user message per tool result, so two parallel tool calls produced two consecutive user turns and the API rejected the request with "roles must alternate". Results from the same assistant turn now share a single user message. max_tokens was hardcoded at 4096 with no way to change it. Agent(max_tokens=) now reaches both clients; OpenAI-compatible requests only carry the field when it is set, so their own defaults still apply. A response that stopped at max_tokens left tool_use input truncated, and the json.loads on it crashed the run with no hint about the cause. A truncated tool call now raises TruncatedResponseError naming the limit, and truncated text is flagged in the reply instead of passing as complete.
Both clients built a fresh httpx.AsyncClient inside every call, so each model request paid a full TLS handshake and no connection was ever reused. They now keep one client, created lazily and reused, with aclose() to release it. Agent gains aclose() and async-context-manager support so the connections have an owner. The clients also hardcoded timeout=60.0 and Agent never passed its own value down, so Agent(timeout=300) still failed at 60 seconds. The agent's timeout now sets the httpx deadline; asyncio.wait_for stays as the outer bound on total time, which httpx's per-operation timeouts do not cover.
The retry loop caught bare Exception, so a 401 or a 400 was retried with exponential backoff before finally surfacing. Retries now cover 408, 409, 425, 429, 5xx and network failures only, honour the server's Retry-After header, and use equal jitter so a fleet of agents does not retry in lockstep. Usage had two problems. last_input_tokens was never reset, so a response with no usage block silently re-counted the previous call's tokens. And cost() carried hardcoded per-provider rates that were badly out of date, defaulted to Mistral regardless of which provider the agent used, and returned a plausible number whatever you asked it. Usage now knows its own model and provider. cost() prices against a table of published rates and returns None when the model is not in it, rather than inventing a figure. set_rates() covers unlisted models and negotiated pricing.
stream() yielded bare strings, so a caller could see text arriving but had no way to know a tool had been called, which tool it was, or what it returned. The README documented `event.type` and `event.content` against this, and that example did not run. stream() now yields StreamEvent objects: text for a chunk, tool_call when the model asks for a tool, tool_result once it has run, done with the final answer. It was also a second-class path around the reliability layer, going straight to self.client and skipping retries, timeout, fallback and usage entirely, so agent.usage stayed at zero for any streamed run. Streaming now shares those, retrying only before the first chunk since a stream in flight cannot be restarted. Both clients report streamed tokens (stream_options.include_usage for OpenAI-compatible APIs, message_start and message_delta for Anthropic), and stream() accepts images= like run() does.
batch() built its sub-agents by hand and dropped enabled_groups, the hooks, max_messages, max_tokens, the validator and validation_retries, so a batch run had a different configuration from a direct run: group-scoped tools were all enabled, hooks never fired, and validation was off. It also fired every prompt at once and counted one request per prompt regardless of how many API calls each actually made. A shared _clone() now carries the whole configuration and reuses the parent's connection pool, concurrency is bounded (default 5), and usage aggregates the real counts. Running out of steps returned "Max steps reached without final answer." as an ordinary string, so a caller could not tell failure from success, and with output=Dataclass the return type was a lie. It now raises MaxStepsError, which carries the step count and the model's last partial answer. stream() still ends with a done event, since its events have already reached the caller. Session ids went straight into a filename, so session="../../evil" wrote outside the sessions directory. Ids are now validated against a strict pattern. Session and conversation files are written through a temp file and renamed, so a crash mid-write no longer leaves a truncated file, and a corrupt file reports what is wrong instead of raising a bare JSONDecodeError.
Three of the README's ten code blocks failed if you copied them. Agent(system=) raised TypeError because the parameter was called system_prompt, the streaming example iterated event.type against a generator of plain strings, and the Graph example never called set_entry() so run() raised straight away. - Rename the parameter and attribute to `system`, and update docs, examples and llms.txt to match. - Graph uses the first node added as its entry point, so the documented example runs. set_entry() still wins when called. - Graph gives each run its own copy of its agents, so two runs of the same graph no longer inherit each other's conversation. It also names the known nodes on an unknown target and rejects a function node that does not return the state. - Router clears its LLM router between calls. It used to accumulate every prompt it had ever routed, and its choices drifted as a result. - Document what run(), stream(), batch() and cost() actually return now, add a State and concurrency section, and correct llms.txt entries that named a temperature parameter and usage fields that never existed. - Correct the "~1,500 lines" claim to ~2,200.
A timeout hands control back to the agent at the deadline, but the thread keeps running. Under run_sync() that surfaces at the end, because asyncio.run drains the thread pool before returning. Say so where someone reading @tool(timeout=) will see it.
gpt-5.2-instant does not exist. It appears in neither OpenAI's active nor its
legacy pricing tables; only gpt-5.2 and gpt-5.2-pro do. I took it from the
repo's tests without verifying it, so provider="openai" would have 404'd for
every user.
Defaults are now the cheapest current-generation tier each provider offers that
still drives a tool loop:
mistral mistral-large-latest $0.50 / $1.50 (already the cheapest
general-purpose Mistral;
Large 3 undercuts Medium 3.5)
openai gpt-5.6-luna $0.20 / $1.20
anthropic claude-sonnet-5 $2.00 / $10.00 (there is no Haiku 5; Haiku
4.5 is a generation behind
with no adaptive thinking)
MODEL_PRICING now covers all three providers with rates read off their pricing
pages on 2026-08-17, so usage.cost() returns a number for the common case
instead of None. Sonnet 5 was wrong at 3/15; the published rate is 2/10. A test
asserts every provider default has a rate on file.
Also drop gpt-5.2-thinking and gpt-5.2-codex from the provider guide. Neither
appears in OpenAI's model list.
The tests import shared helpers as `tests.conftest`. `python -m pytest` puts the current directory on sys.path, so it worked locally; CI runs `pytest` directly, which does not, and all 13 new test modules failed to collect with ModuleNotFoundError. Declare pythonpath = ["."] in the pytest config rather than relying on how the runner happens to be invoked.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
An audit of the library turned up 31 issues, from a red CI to a default OpenAI
model that does not exist. This fixes all of them across 17 incremental
commits, each with its own tests.
CI was red on
mainon all three counts.ci.ymlrunsruff check,ruff format --checkandpytest; every one failed. Two tests asserted modelsclients.pynever defined,ruffreported 6 errors and 35 unformatted files,pyproject.tomldeclared version 0.1.0 while__init__declared 0.6.0 (so atagged release would have published 0.1.0), and
pydanticwas a declareddependency nothing imported.
Coverage went from 17 tests to 206. The old suite stopped at the
@tooldecorator and
JSONMemory; nothing exercisedrun(), the tool loop, or eitherprovider client, because there was no way to answer an HTTP request without a
network. A
httpx.MockTransportharness (tests/conftest.py) now scripts bothwire formats, records request bodies, and fails loudly on an unscripted call.
Correctness fixes
provider="mistral", fallback="anthropic"put
mistral-large-lateston the Anthropic API on every failover. Attempts nowpair each client with the model it serves and its own API key.
api_key=lost to the environment variable. In multi-tenantcode the agent silently authenticated as the wrong account.
the system message was in place, so on a fresh agent it hashed only the prompt
and model. Agents with different personalities, providers or tool sets shared
entries. The cache was also unbounded, returned a raw string when
output=wasset, and left a gap in the conversation on a hit.
json.loadssatoutside the try block, so
JSONDecodeErrorescaped into user code. One failingcall also cancelled its siblings mid-flight.
max_messagesproduced requests the APIs reject. Slicing by count aloneleft a
toolmessage whose assistanttool_callsmessage had been cut. Thewindow now snaps to a turn boundary.
@tool(timeout=)did nothing on sync functions, and parallel tool callsran serially, because sync tools blocked the event loop. They run on a worker
thread now: a 0.2s timeout on a
time.sleep(1.5)tool fires at 0.2s instead ofreturning at 1.5s.
message, producing consecutive user turns the API rejects.
Agent(timeout=300)still died at 60s. The clients hardcoded their ownceiling and nothing propagated the agent's. Connections are pooled now too,
rather than a fresh TLS handshake per model call.
network failures only, honouring
Retry-After, with jitter.batch()silently changed the configuration, droppingenabled_groups,hooks,
max_messages, the validator andvalidation_retries, and fired everyprompt at once.
session="../../evil"wroteoutside the sessions directory. Writes are atomic now.
Type hints to JSON Schema
Two separate converters both stopped at
str/int/float/booland markedevery field required. A single
schema.pynow handles typed lists and dicts,Optional,Literal,Enumand nested dataclasses, and derivesrequiredfrom the signature. The README's own structured-output example advertised
keywords: list[str]to the model as a string.Documented examples that did not run
Three of the README's ten Python blocks failed if you copied them:
Agent(system=...)raisedTypeError, the streaming example iteratedevent.typeagainst a generator of strings, and theGraphexample nevercalled
set_entry().Model defaults, verified against each provider
gpt-5.2-instantdoes not exist — it is in neither OpenAI's active nor itslegacy pricing tables.
provider="openai"would have 404'd for every user.Defaults are now the cheapest current-generation tier that still drives a tool
loop, checked against each provider's own pricing page on 2026-08-17:
mistralmistral-large-latestopenaigpt-5.6-lunaanthropicclaude-sonnet-5MODEL_PRICINGcovers all three providers, sousage.cost()returns a numberfor the common case instead of
None.Type of change
Breaking changes
All are in
CHANGELOG.md. Each replaces behaviour that was itself the bug:Agent(system_prompt=)→Agent(system=).agent.stream()yieldsStreamEventobjects, not strings. This is what theREADME already documented, and it makes tool calls visible mid-stream.
max_stepsraisesMaxStepsErrorinstead of returning"Max steps reached without final answer."as an ordinary string, which acaller could not tell from an answer and which broke the return type under
output=.StructuredOutputError(carrying.raw).Usage.cost()takes a model, not a provider, and returnsNonefor a modelwith no rate on file rather than inventing a figure. The old per-provider
rates were badly out of date (Mistral at $0.25/1M).
Usage.set_rates()coversunlisted models and negotiated pricing.
Checklist
pytest) — 206 passing on Python 3.10 and 3.14ruff check .andruff format --check .)llms.txt,docs/, examples and CHANGELOGRelated issues
None filed; found by audit.