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
6 changes: 3 additions & 3 deletions .claude/.codebase-info/.map-state.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"tool": "codebase-mapper",
"version": "2.15.10",
"mappedAt": "2026-10-19",
"gitCommit": "eb257d2d13cda893f8f9a8cb3df20628e2ddb569",
"gitCommit": "1d8ef5a32cebd9653c231062ba0bdee8e1e836eb",
"documents": [
"architecture.md",
"coding-style.md",
Expand All @@ -21,12 +21,12 @@
"architecture.md": "59d1265c89d3b42671b7c47165408625018537c016a72df421113cae3f80bb70",
"coding-style.md": "b63e8dff06d16a2d882f02624c62f490291317aec8b3032f6d79b3ee035e8567",
"communication.md": "70fbeedf239b130cfd3d7a92f27d56e96b207d85117b2f83cc9eef8ec6d6c6b3",
"dependencies.md": "e39579db4e32a05b34467df9af4ac7c64bc47df935704e1e95c3a4a79bfc304b",
"dependencies.md": "35b8ff6cdf4342969a1f29e8e1dde23332e6040c9a128b166aa541650ed65f6f",
"directory-structure.md": "4efb931cdefecf2c733f0ffb905ad519aff3a81e245a978c0b62b2c5cf0ad49a",
"entry-points.md": "3c94fd8a821fc76789794b6d3f4d89cc88a7903055755d97437f9f3ca6193cb6",
"modules.md": "cd4f81f8ce74dfc4a7bab15ba91dc569245bc342ddf494845d658b51a0a1dc51",
"onboarding.md": "3d28c671dc095a2aadb297bd7ab200c677779a99c7534e03e55a3d64c935f07e",
"patterns.md": "dbd5ff107b7567c6424af31910ca88148836d16f31002bfed226f28b6725147f",
"tech-landscape.md": "31f74730fcc6e75fc4127315a5c707af261d5ad3add7161bfdeb9719b8195af0"
"tech-landscape.md": "f7101adf177e947d5e765984326247bbbd898a46cb66bd10f49300093c391ecc"
}
}
9 changes: 5 additions & 4 deletions .claude/.codebase-info/dependencies.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ Declared in `pyproject.toml` (Hatchling build, `uv` workspace; versions locked i
## Runtime — core
| Package | Purpose |
|---------|---------|
| instructor (==1.14.5) | Structured (Pydantic) LLM outputs across providers — the core dependency |
| pydantic (≥2.11,<3) | Schemas / data validation for all I/O |
| instructor (>=1.17.0,<2) | Structured (Pydantic) LLM outputs across providers — the core dependency |
| pydantic (≥2.11.4,<3) | Schemas / data validation for all I/O; 2.11.4 is the first version whose `create_model` accepts `__config__` together with `__base__` (pydantic#11714), which the MCP schema transformer relies on |
| litellm (≥1.50,<2) | Provider-agnostic token counting & model metadata |
| rich (≥13.7.1,<14) | Console formatting |

Expand All @@ -26,7 +26,7 @@ already brings the two OpenInference packages, so the Phoenix auto path works wi
| Package | Purpose |
|---------|---------|
| textual (≥5.3,<6) | TUI framework for the `atomic` assembler |
| mcp[cli] (≥1.6,<2) | Model Context Protocol client + CLI |
| mcp[cli] (≥1.8,<2) | Model Context Protocol client + CLI; `mcp.client.streamable_http` first shipped in 1.8.0 and was renamed in 2.0.0, so 2.x waits for a migration |
| requests (≥2.32,<3) | HTTP |
| gitpython (≥3.1.43,<4) | Clone the repo to fetch forge tools |
| pyyaml (≥6,<7) | Read tool `config.yaml` metadata |
Expand All @@ -50,5 +50,6 @@ already brings the two OpenInference packages, so the Phoenix auto path works wi

## Notes
- `requirements.txt` / `setup.py` are legacy shims; `pyproject.toml` + `uv.lock` are the source of truth.
- Instructor is pinned exactly (`==1.14.5`); most other deps use compatible ranges.
- Instructor was an exact pin (`==1.14.5`) through 2.12.1 because patch releases had removed import paths (567-labs/instructor#1796); it is a range now, guarded by two CI jobs: `fresh-install` (build the wheel, install it without the lockfile, import every module) and `lowest-versions` (run the suite with every runtime dependency at its declared floor). The framework references `instructor.Instructor` / `instructor.AsyncInstructor` from the package root; `instructor.core` is not an attribute path on 1.17.0.
- Every example project and the legacy `requirements.txt` carry the same Instructor range.
- Each `atomic-forge` tool and each `atomic-examples` project declares its **own** dependencies.
17 changes: 10 additions & 7 deletions .claude/.codebase-info/tech-landscape.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,10 @@
| Layer | Technology | Notes |
|-------|------------|-------|
| Language | Python ≥3.12 | PEP 695 generics |
| Structured LLM I/O | Instructor 1.14.5 (pinned) | provider-agnostic structured outputs |
| Data modeling | Pydantic v2 (≥2.11) | all schemas are Pydantic models |
| Structured LLM I/O | Instructor >=1.17.0,<2 (range in 2.13.0) | provider-agnostic structured outputs |
| Data modeling | Pydantic v2 (≥2.11.4) | all schemas are Pydantic models |
| Provider / token utils | LiteLLM (≥1.50) | token counting, model metadata |
| Tool protocol | MCP, `mcp[cli]` (≥1.6,<2) | Model Context Protocol connectors |
| Tool protocol | MCP — `mcp[cli]` (≥1.8,<2) | Model Context Protocol connectors |
| TUI | Textual (≥5.3,<6) | the `atomic` assembler UI |
| Console output | Rich, pyfiglet | |
| HTTP / Git | requests, GitPython | assembler fetches forge from GitHub |
Expand All @@ -37,10 +37,13 @@
## Infrastructure

- **CI (GitHub Actions):**
- `code-quality.yml` — on push/PR to `main`/`v2.0`: `uv sync`, `black --check`, `flake8`, then
`pytest --cov=atomic_agents` for the core. Formatting and lint cover all four project directories.
A separate `fresh-install` job builds the wheel, installs it without the lockfile, and imports
every `atomic_agents` subpackage.
- `code-quality.yml` — on push/PR to `main`/`v2.0`, three jobs: `quality` (`uv sync`,
`black --check` and `flake8` across all four project directories, then
`pytest --cov=atomic_agents` for the core); `fresh-install` (build the wheel, install
it with the observability extra into a clean venv without the lockfile, import every module under
`atomic_agents`, which is what a new user's `pip install` sees); `lowest-versions` (resolve runtime
dependencies with `uv pip compile --resolution lowest-direct`, install current pytest tooling, run
the suite, so every declared floor is a tested floor).
- `docs.yml` — on push to `main`: build Sphinx docs, run
`scripts/generate_llms_files.py` (spec-compliant `llms.txt` index + `llms-*.txt`
bundles, copied to the site root), deploy to GitHub Pages. The docs version needs
Expand Down
31 changes: 31 additions & 0 deletions .github/workflows/code-quality.yml
Original file line number Diff line number Diff line change
Expand Up @@ -103,3 +103,34 @@ jobs:
if failures:
raise SystemExit(1)
PY

lowest-versions:
name: Tests on the lowest allowed version of every direct dependency
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'

- name: Install uv
uses: astral-sh/setup-uv@v4
with:
version: "latest"

- name: Resolve runtime dependencies at their declared floors
run: uv pip compile pyproject.toml --resolution lowest-direct --extra observability -o lowest-requirements.txt

- name: Install the floors plus current test tooling
run: |
uv venv lowest-venv
uv pip install --python lowest-venv -r lowest-requirements.txt pytest pytest-asyncio pytest-cov
uv pip install --python lowest-venv --no-deps -e .

- name: Show resolved versions
run: lowest-venv/bin/python -c "import importlib.metadata as m; print(' '.join(p + '=' + m.version(p) for p in ['instructor', 'openai', 'pydantic', 'mcp', 'litellm']))"

- name: Run Tests
run: lowest-venv/bin/python -m pytest atomic-agents
20 changes: 20 additions & 0 deletions .release/2.13.0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Atomic Agents 2.13.0

Draft. Collects what has landed on main since 2.12.1.

## Dependencies

- Instructor is a range again: `instructor>=1.17.0,<2`. The exact pin dated from Instructor 1.9.2 (upstream issue 567-labs/instructor#1796, an import path removed in a patch release) and was bumped by hand ever since. The framework now references `instructor.Instructor` and `instructor.AsyncInstructor` from the package root, which is the only change 1.17.0 needed; the suite passes unchanged otherwise.
- Floors are now true floors: `pydantic>=2.11.4` (the MCP schema transformer passes `__config__` together with `__base__` to `create_model`, allowed since pydantic 2.11.4) and `mcp>=1.8.0,<2` (`mcp.client.streamable_http` first shipped in 1.8.0).
- Two CI jobs guard this: a fresh install of the built wheel that imports every module, and a run of the suite on the lowest allowed version of every runtime dependency. Together they catch the two ways the old pin bit us: an import path removed upstream, and a floor nobody had tested.

## Fixes

- `agent.run()` and `run_async()` work on Instructor 1.15.3 and later. Instructor moved its generated response-model wrapper to `instructor.v2.core.function_calls`, and `BaseIOSchema` only exempted the older module names from its docstring rule, so every non-streaming call raised before the request went out. The check now accepts any class Instructor generates.
- `run_stream()` with `model_api_parameters={"strict": ...}` no longer fails on Instructor 1.17 ("got multiple values for keyword argument 'strict'").
- Both are covered by a new test module that drives a real Instructor client through an `httpx.MockTransport`, so Instructor's own request and parse path runs in the suite instead of a mocked client.

## Compatibility

- `max_retries` counts differently on Instructor 1.17: 1.14.5 stopped after `max_retries` attempts in total, 1.17.0 stops after the first attempt plus `max_retries` retries. `max_retries=3` passed through `model_api_parameters` now makes up to four calls instead of three. Lower it by one to keep the old budget.
Python 3.12 or newer. Applications that pin `instructor==1.14.5` keep working; applications that move to 1.17.0 need no code change on their side. openai 3.x resolves and passes the suite with Instructor 1.17.0.
16 changes: 9 additions & 7 deletions atomic-agents/atomic_agents/agents/atomic_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,22 +20,24 @@


def model_from_chunks_patched(cls, json_chunks, **kwargs):
kwargs.setdefault("strict", None)
potential_object = ""
partial_model = cls.get_partial_model()
for chunk in json_chunks:
potential_object += chunk
obj = from_json((potential_object or "{}").encode(), partial_mode="trailing-strings")
obj = partial_model.model_validate(obj, strict=None, **kwargs)
obj = partial_model.model_validate(obj, **kwargs)
yield obj


async def model_from_chunks_async_patched(cls, json_chunks, **kwargs):
kwargs.setdefault("strict", None)
potential_object = ""
partial_model = cls.get_partial_model()
async for chunk in json_chunks:
potential_object += chunk
obj = from_json((potential_object or "{}").encode(), partial_mode="trailing-strings")
obj = partial_model.model_validate(obj, strict=None, **kwargs)
obj = partial_model.model_validate(obj, **kwargs)
yield obj


Expand Down Expand Up @@ -65,7 +67,7 @@ class BasicChatOutputSchema(BaseIOSchema):


class AgentConfig(BaseModel):
client: instructor.core.client.Instructor = Field(..., description="Client for interacting with the language model.")
client: instructor.Instructor = Field(..., description="Client for interacting with the language model.")
model: str = Field(default="gpt-5-mini", description="The model to use for generating responses.")
history: Optional[BaseChatHistory] = Field(default=None, description="History component for storing chat history.")
system_prompt_generator: Optional[BaseSystemPromptGenerator] = Field(
Expand Down Expand Up @@ -577,7 +579,7 @@ def run(self, user_input: Optional[InputSchema] = None) -> OutputSchema:
OutputSchema: The response from the chat agent.
"""
assert not isinstance(
self.client, instructor.core.client.AsyncInstructor
self.client, instructor.AsyncInstructor
), "The run method is not supported for async clients. Use run_async instead."

if user_input:
Expand Down Expand Up @@ -612,7 +614,7 @@ def run_stream(self, user_input: Optional[InputSchema] = None) -> Generator[Outp
OutputSchema: The final response from the chat agent.
"""
assert not isinstance(
self.client, instructor.core.client.AsyncInstructor
self.client, instructor.AsyncInstructor
), "The run_stream method is not supported for async clients. Use run_async instead."

if user_input:
Expand Down Expand Up @@ -658,7 +660,7 @@ async def run_async(self, user_input: Optional[InputSchema] = None) -> OutputSch
NotAsyncIterableError: If used as an async generator (in an async for loop).
Use run_async_stream() method instead for streaming responses.
"""
assert isinstance(self.client, instructor.core.client.AsyncInstructor), "The run_async method is for async clients."
assert isinstance(self.client, instructor.AsyncInstructor), "The run_async method is for async clients."

if user_input:
self.history.initialize_turn()
Expand Down Expand Up @@ -686,7 +688,7 @@ async def run_async_stream(self, user_input: Optional[InputSchema] = None) -> As
Yields:
OutputSchema: Partial responses from the chat agent.
"""
assert isinstance(self.client, instructor.core.client.AsyncInstructor), "The run_async method is for async clients."
assert isinstance(self.client, instructor.AsyncInstructor), "The run_async method is for async clients."
if user_input:
self.history.initialize_turn()
self.current_user_input = user_input
Expand Down
9 changes: 5 additions & 4 deletions atomic-agents/atomic_agents/base/base_io_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,13 @@ def _validate_description(cls):
description = cls.__doc__

if not description or not description.strip():
# Skip validation for instructor-generated schemas (both old and new module paths)
if cls.__module__ not in ("instructor.function_calls", "instructor.processing.function_calls") and not hasattr(
cls, "from_streaming_response"
):
if not cls._is_generated_by_instructor():
raise ValueError(f"{cls.__name__} must have a non-empty docstring to serve as its description")

@classmethod
def _is_generated_by_instructor(cls) -> bool:
return cls.__module__.startswith("instructor.") or hasattr(cls, "from_streaming_response")

@classmethod
def model_json_schema(cls, *args, **kwargs):
schema = super().model_json_schema(*args, **kwargs)
Expand Down
3 changes: 1 addition & 2 deletions atomic-agents/tests/agents/test_atomic_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,7 @@ def mock_instructor():

@pytest.fixture
def mock_instructor_async():
# Changed spec from instructor.Instructor to instructor.core.client.AsyncInstructor
mock = Mock(spec=instructor.core.client.AsyncInstructor)
mock = Mock(spec=instructor.AsyncInstructor)

# Configure chat.completions structure
mock.chat = Mock()
Expand Down
112 changes: 112 additions & 0 deletions atomic-agents/tests/agents/test_instructor_transport.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
"""Drive a real Instructor client through a mocked HTTP transport.

The rest of the suite mocks the Instructor client itself, so it never sees how Instructor wraps the
response model or which keyword arguments it forwards into streaming. Both broke on Instructor 1.17
without any mocked test noticing.
"""

import json

import httpx
import instructor
import openai
import pytest

from atomic_agents import AgentConfig, AtomicAgent, BasicChatInputSchema, BasicChatOutputSchema

ASSISTANT_ARGUMENTS = json.dumps({"chat_message": "hi there"})


def tool_call_chunk(arguments_piece: str) -> str:
delta = {
"tool_calls": [
{
"index": 0,
"id": "call-1",
"type": "function",
"function": {"name": "BasicChatOutputSchema", "arguments": arguments_piece},
}
]
}
chunk = {"id": "chunk", "object": "chat.completion.chunk", "created": 0, "model": "m"}
chunk["choices"] = [{"index": 0, "delta": delta, "finish_reason": None}]
return "data: " + json.dumps(chunk) + "\n\n"


def respond_with_tool_call(request: httpx.Request) -> httpx.Response:
body = json.loads(request.content)
if body.get("stream"):
frames = tool_call_chunk(ASSISTANT_ARGUMENTS[:10]) + tool_call_chunk(ASSISTANT_ARGUMENTS[10:]) + "data: [DONE]\n\n"
return httpx.Response(200, headers={"content-type": "text/event-stream"}, content=frames.encode())
message = {
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call-1",
"type": "function",
"function": {"name": "BasicChatOutputSchema", "arguments": ASSISTANT_ARGUMENTS},
}
],
}
completion = {"id": "completion", "object": "chat.completion", "created": 0, "model": "m"}
completion["choices"] = [{"index": 0, "message": message, "finish_reason": "tool_calls"}]
completion["usage"] = {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}
return httpx.Response(200, json=completion)


def agent_over_mock_transport(**model_api_parameters) -> AtomicAgent[BasicChatInputSchema, BasicChatOutputSchema]:
raw_client = openai.OpenAI(api_key="test", http_client=httpx.Client(transport=httpx.MockTransport(respond_with_tool_call)))
config = AgentConfig(client=instructor.from_openai(raw_client), model="m", model_api_parameters=model_api_parameters)
return AtomicAgent[BasicChatInputSchema, BasicChatOutputSchema](config=config)


def async_agent_over_mock_transport(**model_api_parameters: bool) -> AtomicAgent[BasicChatInputSchema, BasicChatOutputSchema]:
raw_client = openai.AsyncOpenAI(
api_key="test", http_client=httpx.AsyncClient(transport=httpx.MockTransport(respond_with_tool_call))
)
config = AgentConfig(client=instructor.from_openai(raw_client), model="m", model_api_parameters=model_api_parameters)
return AtomicAgent[BasicChatInputSchema, BasicChatOutputSchema](config=config)


def test_run_parses_the_tool_call_through_instructor():
response = agent_over_mock_transport().run(BasicChatInputSchema(chat_message="hey"))

assert response.chat_message == "hi there"


@pytest.mark.asyncio
async def test_run_async_parses_the_tool_call_through_instructor():
response = await async_agent_over_mock_transport().run_async(BasicChatInputSchema(chat_message="hey"))

assert response.chat_message == "hi there"


def test_run_stream_yields_partials_then_the_complete_response():
partials = list(agent_over_mock_transport().run_stream(BasicChatInputSchema(chat_message="hey")))

assert partials[-1].chat_message == "hi there"


def test_run_stream_honors_a_caller_strict_setting():
partials = list(agent_over_mock_transport(strict=True).run_stream(BasicChatInputSchema(chat_message="hey")))

assert partials[-1].chat_message == "hi there"


@pytest.mark.asyncio
async def test_run_async_stream_yields_the_complete_response() -> None:
agent = async_agent_over_mock_transport()

partials = [response async for response in agent.run_async_stream(BasicChatInputSchema(chat_message="hey"))]

assert partials[-1].chat_message == "hi there"


@pytest.mark.asyncio
async def test_run_async_stream_honors_a_caller_strict_setting() -> None:
agent = async_agent_over_mock_transport(strict=True)

partials = [response async for response in agent.run_async_stream(BasicChatInputSchema(chat_message="hey"))]

assert partials[-1].chat_message == "hi there"
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ def create_partial(**kwargs):


def async_client(response=None, partials=None):
client = Mock(spec=instructor.core.client.AsyncInstructor)
client = Mock(spec=instructor.AsyncInstructor)
client.chat = Mock()
client.chat.completions = Mock()

Expand Down
2 changes: 1 addition & 1 deletion atomic-examples/basic-multimodal/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ authors = [
requires-python = ">=3.12"
dependencies = [
"atomic-agents",
"instructor==1.14.5",
"instructor>=1.17.0,<2",
"openai>=2.0.0,<3.0.0",
]

Expand Down
2 changes: 1 addition & 1 deletion atomic-examples/basic-pdf-analysis/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ authors = [
requires-python = ">=3.12,<3.14"
dependencies = [
"atomic-agents",
"instructor[google-genai]==1.14.5",
"instructor[google-genai]>=1.17.0,<2",
]

[tool.uv.sources]
Expand Down
2 changes: 1 addition & 1 deletion atomic-examples/fastapi-memory/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ dependencies = [
"atomic-agents",
"fastapi>=0.115.14,<1.0.0",
"uvicorn>=0.32.1,<1.0.0",
"instructor==1.14.5",
"instructor>=1.17.0,<2",
"openai>=2.0.0,<3.0.0",
"pydantic>=2.10.3,<3.0.0",
"httpx>=0.28.1,<1.0.0",
Expand Down
2 changes: 1 addition & 1 deletion atomic-examples/hooks-example/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ authors = [
requires-python = ">=3.12"
dependencies = [
"atomic-agents",
"instructor==1.14.5",
"instructor>=1.17.0,<2",
"openai>=2.0.0,<3.0.0",
"python-dotenv>=1.0.1,<2.0.0",
]
Expand Down
Loading
Loading