From dd8b84011cc27c358640f1133892d25284cb286a Mon Sep 17 00:00:00 2001 From: folathecoder Date: Tue, 7 Jul 2026 12:14:55 +0100 Subject: [PATCH 01/17] Add exception hierarchy and a real public API Introduce errors.py with an AgentlingError base and domain subclasses, and reparent ToolCallError under it so one except catches everything the framework raises. Replace the placeholder __init__.py with real re-exports and __all__, and read the version from installed package metadata so it cannot drift from pyproject. --- src/agentling/__init__.py | 91 +++++++++++++++++++++++++++++++++++++-- src/agentling/errors.py | 56 ++++++++++++++++++++++++ src/agentling/tools.py | 3 +- tests/test_public_api.py | 30 +++++++++++++ 4 files changed, 176 insertions(+), 4 deletions(-) create mode 100644 src/agentling/errors.py create mode 100644 tests/test_public_api.py diff --git a/src/agentling/__init__.py b/src/agentling/__init__.py index c9f4f4a..07c1872 100644 --- a/src/agentling/__init__.py +++ b/src/agentling/__init__.py @@ -1,5 +1,90 @@ -def hello() -> str: - return "Hello from agentling!" +"""agentling: a tiny async tool-calling agent framework. +This module is the public API. Import from `agentling` directly; the submodules +under it are implementation detail and may be reorganized. +""" -__version__ = "0.0.1" +from importlib.metadata import PackageNotFoundError, version + +from .agent import Agent +from .errors import ( + AgentlingError, + AgentRunError, + MemoryLoadError, + ModelError, + ModelOutputError, + SkillLoadError, + ToolExecutionError, + ToolTimeoutError, +) +from .events import ( + Event, + FinalEvent, + StepEvent, + TextDelta, + ToolCallEvent, + ToolResultEvent, + print_events, +) +from .memory import ActionStep, FinalStep, Memory, Step, TaskStep, ToolResult +from .models import ( + ChatMessage, + Delta, + Model, + OpenAIModel, + ToolCall, + ToolCallDelta, + Usage, + agglomerate_deltas, +) +from .skills import Skill +from .tools import Tool, ToolCallError, tool + +try: + __version__ = version("agentling") +except PackageNotFoundError: # Running from a source tree without an install. + __version__ = "0.0.0" + +__all__ = [ + # Core + "Agent", + "Model", + "OpenAIModel", + "Skill", + "Tool", + "tool", + "print_events", + # Errors + "AgentlingError", + "AgentRunError", + "ModelError", + "ModelOutputError", + "ToolCallError", + "ToolExecutionError", + "ToolTimeoutError", + "MemoryLoadError", + "SkillLoadError", + # Messages and model types + "ChatMessage", + "ToolCall", + "ToolCallDelta", + "Delta", + "Usage", + "agglomerate_deltas", + # Memory + "Memory", + "Step", + "TaskStep", + "ActionStep", + "FinalStep", + "ToolResult", + # Events + "Event", + "TextDelta", + "ToolCallEvent", + "ToolResultEvent", + "StepEvent", + "FinalEvent", + # Version + "__version__", +] diff --git a/src/agentling/errors.py b/src/agentling/errors.py new file mode 100644 index 0000000..7a4da82 --- /dev/null +++ b/src/agentling/errors.py @@ -0,0 +1,56 @@ +"""Exception hierarchy for agentling. + +Everything the framework raises descends from AgentlingError, so a host can +catch all of it with a single except. The subclasses mark the failure domain, +which is what lets different layers treat them differently: some are fatal, some +are surfaced back to the model as recoverable observations, and some are +user-facing configuration errors. +""" + +from __future__ import annotations + + +class AgentlingError(Exception): + """Base class for every error raised by agentling.""" + + +class AgentRunError(AgentlingError): + """A run could not start or continue. + + Raised for lifecycle violations such as starting a second run on state that + is already in use. + """ + + +class ModelError(AgentlingError): + """A model provider failed in a way the run cannot recover from.""" + + +class ModelOutputError(AgentlingError): + """The model produced output the framework could not parse. + + Raised for malformed streamed tool calls (invalid JSON arguments, a + non-object arguments payload, or a missing tool name). The agent loop can + turn this into a recoverable observation so the model gets a chance to + retry. + """ + + +class ToolExecutionError(AgentlingError): + """A tool failed unexpectedly while executing. + + This is distinct from ToolCallError, which signals invalid model-supplied + arguments. ToolExecutionError covers failures inside the tool itself. + """ + + +class ToolTimeoutError(ToolExecutionError): + """A tool did not finish within its allotted time.""" + + +class MemoryLoadError(AgentlingError): + """Serialized memory could not be loaded (unknown version or bad shape).""" + + +class SkillLoadError(AgentlingError): + """A skill could not be loaded from its SKILL.md.""" diff --git a/src/agentling/tools.py b/src/agentling/tools.py index 9a55a08..5277062 100644 --- a/src/agentling/tools.py +++ b/src/agentling/tools.py @@ -13,6 +13,7 @@ from collections.abc import Callable from typing import Any, Literal, Union, get_args, get_origin, get_type_hints +from .errors import AgentlingError from .models import ToolSpec # JSON Schema for a tool's parameters object. This is intentionally separate @@ -20,7 +21,7 @@ JsonSchema = dict[str, Any] -class ToolCallError(Exception): +class ToolCallError(AgentlingError): """Raised when a tool receives invalid model-generated arguments. Agent loops can catch this error and return it to the model as an diff --git a/tests/test_public_api.py b/tests/test_public_api.py new file mode 100644 index 0000000..ea9be72 --- /dev/null +++ b/tests/test_public_api.py @@ -0,0 +1,30 @@ +import agentling + + +def test_every_name_in_all_resolves() -> None: + for name in agentling.__all__: + assert hasattr(agentling, name), f"{name!r} is in __all__ but not importable" + + +def test_key_symbols_are_the_expected_objects() -> None: + from agentling import Agent, OpenAIModel, Skill, print_events, tool + from agentling.agent import Agent as AgentImpl + from agentling.models import OpenAIModel as OpenAIModelImpl + from agentling.skills import Skill as SkillImpl + + assert Agent is AgentImpl + assert OpenAIModel is OpenAIModelImpl + assert Skill is SkillImpl + assert callable(tool) + assert callable(print_events) + + +def test_tool_call_error_is_under_the_agentling_base() -> None: + from agentling import AgentlingError, ToolCallError + + assert issubclass(ToolCallError, AgentlingError) + + +def test_version_is_a_nonempty_string() -> None: + assert isinstance(agentling.__version__, str) + assert agentling.__version__ From 233f06741b2e4ffe6f2961f084c57af73fd5a587 Mon Sep 17 00:00:00 2001 From: folathecoder Date: Tue, 7 Jul 2026 13:16:53 +0100 Subject: [PATCH 02/17] Split Agent into immutable config and a per-run AgentSession Move mutable run state (memory, the interrupt token, and dynamically loaded skill tools) off Agent onto a new AgentSession, so one Agent can be built once and shared safely across concurrent runs. Agent becomes config plus a factory: agent.start() mints a session, and agent.run() is a one-shot convenience that runs on a fresh session. Each session copies the base tool set, so a skill loaded in one run never leaks into another. Breaking: agent.memory -> session.memory, agent.interrupt() -> session.interrupt(). Migrate the affected tests and add session-isolation tests. --- src/agentling/__init__.py | 3 +- src/agentling/agent.py | 162 ++++++++++++++++++++++++++++---------- tests/test_agent.py | 129 ++++++++++++++++++++++-------- 3 files changed, 215 insertions(+), 79 deletions(-) diff --git a/src/agentling/__init__.py b/src/agentling/__init__.py index 07c1872..94ee390 100644 --- a/src/agentling/__init__.py +++ b/src/agentling/__init__.py @@ -6,7 +6,7 @@ from importlib.metadata import PackageNotFoundError, version -from .agent import Agent +from .agent import Agent, AgentSession from .errors import ( AgentlingError, AgentRunError, @@ -48,6 +48,7 @@ __all__ = [ # Core "Agent", + "AgentSession", "Model", "OpenAIModel", "Skill", diff --git a/src/agentling/agent.py b/src/agentling/agent.py index 9bd17c2..51097d0 100644 --- a/src/agentling/agent.py +++ b/src/agentling/agent.py @@ -1,9 +1,17 @@ """The agent loop. -Agent ties the framework's primitives (a Model, some Tools, and a Memory of -typed steps) into an async ReAct loop. One async generator, _run_stream, does -the real work and yields Events. run() is a thin dispatcher: it hands back that -stream when stream is True, or drains it and returns the final answer otherwise. +Agent holds immutable configuration (a Model, some Tools, some Skills, and the +run settings) and acts as a factory for sessions. AgentSession owns one +conversation's mutable state (a Memory of typed steps, an interrupt token, and +its own tool set) and runs the ReAct loop. + +Splitting the two means a single Agent can be built once and shared safely +across concurrent runs: each run gets its own AgentSession, so memories, +interrupts, and dynamically loaded skill tools never leak between them. + +One async generator, AgentSession._run_stream, does the real work and yields +Events. run() is a thin dispatcher: it hands back that stream when stream is +True, or drains it and returns the final answer otherwise. Skills are disclosed progressively: only their names and descriptions are added to the system prompt up front. The full instructions, and any tools a skill @@ -46,10 +54,12 @@ class Agent: - """An async tool-calling agent. + """Immutable configuration and a factory for sessions. - Wires a Model, Tools (with final_answer always available), and a Memory into - a ReAct loop. Call run() to execute a task, optionally streaming Events. + An Agent bundles a Model, the base Tools (final_answer is always added), + Skills, and the run settings. It holds no per-run state, so one Agent can be + built once and shared across many concurrent runs. Call start() for a fresh + session, or run() for a one-shot convenience run. """ def __init__( @@ -67,21 +77,24 @@ def __init__( self.model = model self.max_steps = max_steps - self.step_callbacks = list(step_callbacks) self.parallel_tools = parallel_tools + # Default callbacks applied to every session. A session copies these and + # may append its own. + self.step_callbacks = list(step_callbacks) - self.memory = Memory() - self._interrupt = asyncio.Event() - - # Register the caller's tools plus the always-available final_answer. A - # duplicate name among these is a programming error, so fail loudly here - # rather than silently letting one tool shadow another. - self.tools: dict[str, Tool] = {} - self._tool_schemas: list[ToolSpec] = [] + # Base tool set: the caller's tools plus the always-available + # final_answer. A duplicate name here is a programming error, so fail + # loudly rather than letting one tool shadow another. Each session gets + # its own copy of this set (see AgentSession). + base_tools: dict[str, Tool] = {} + base_schemas: list[ToolSpec] = [] for base_tool in (*tools, FinalAnswerTool()): - if base_tool.name in self.tools: + if base_tool.name in base_tools: raise ValueError(f"Duplicate tool name: {base_tool.name!r}") - self._register_tool(base_tool) + base_tools[base_tool.name] = base_tool + base_schemas.append(base_tool.to_schema()) + self._base_tools = base_tools + self._base_schemas = base_schemas # Load skills up front but reveal only their names and descriptions. The # full instructions and any skill tools arrive when the model calls @@ -91,14 +104,81 @@ def __init__( } self.instructions = instructions or DEFAULT_INSTRUCTIONS if self.skills: - self._register_tool(self._build_load_skill_tool()) self.instructions += _skill_catalog(self.skills.values()) + def start(self) -> AgentSession: + """Create a fresh session with its own memory, tools, and interrupt token.""" + + return AgentSession(self) + + @overload + def run( + self, + task: str, + *, + stream: Literal[False] = False, + reset: bool = True, + max_steps: int | None = None, + ) -> Awaitable[str]: ... + + @overload + def run( + self, + task: str, + *, + stream: Literal[True], + reset: bool = True, + max_steps: int | None = None, + ) -> AsyncIterator[Event]: ... + + def run( + self, + task: str, + *, + stream: bool = False, + reset: bool = True, + max_steps: int | None = None, + ) -> Awaitable[str] | AsyncIterator[Event]: + """Run a task on a fresh one-shot session. + + Convenience for the common single-run case. Each call gets its own + session, so concurrent calls on one Agent stay isolated. For a multi-turn + conversation, or to inspect memory afterwards, use start() and hold on to + the session. + """ + + session = self.start() + if stream: + return session.run(task, stream=True, reset=reset, max_steps=max_steps) + return session.run(task, stream=False, reset=reset, max_steps=max_steps) + + +class AgentSession: + """One conversation's mutable state plus the agent loop. + + A session owns its own Memory, interrupt token, and tool set (a copy of the + Agent's base tools, so skill tools loaded here never leak into another + session). Create one with Agent.start(). + """ + + def __init__(self, agent: Agent) -> None: + self.agent = agent + self.memory = Memory() + self.step_callbacks = list(agent.step_callbacks) + self._interrupt = asyncio.Event() + + # Per-session tool view. Copying the agent's base set keeps any skill + # tools loaded during this run isolated to this session. + self.tools: dict[str, Tool] = dict(agent._base_tools) + self._tool_schemas: list[ToolSpec] = list(agent._base_schemas) + if agent.skills: + self._register_tool(self._build_load_skill_tool()) + def _register_tool(self, new_tool: Tool) -> None: - """Add a tool to the live tool set, skipping names already registered. + """Add a tool to this session's tool set, skipping names already present. Registration is idempotent so a skill can be loaded more than once, or - declare a tool the agent already has, without raising mid-run. + declare a tool the session already has, without raising mid-run. """ if new_tool.name in self.tools: @@ -134,17 +214,12 @@ def run( reset: bool = True, max_steps: int | None = None, ) -> Awaitable[str] | AsyncIterator[Event]: - """Run the agent on a task. + """Run the agent on a task within this session. With stream=False (the default) this returns an awaitable that resolves - to the final answer string: - - answer = await agent.run(task) - - With stream=True it returns an async iterator of Events instead: - - async for event in agent.run(task, stream=True): - ... + to the final answer string. With stream=True it returns an async iterator + of Events. Pass reset=False to continue from this session's existing + memory (multi-turn). """ events = self._run_stream(task, reset=reset, max_steps=max_steps) @@ -174,7 +249,7 @@ async def _run_stream( self.memory.add(TaskStep(task=task)) - limit = self.max_steps if max_steps is None else max_steps + limit = self.agent.max_steps if max_steps is None else max_steps # Remember the previous step's calls so we can spot an exact repeat. previous_signature: tuple[tuple[str, str], ...] | None = None @@ -186,12 +261,14 @@ async def _run_stream( return started = time.monotonic() - messages = self.memory.to_messages(self.instructions) + messages = self.memory.to_messages(self.agent.instructions) # Stream the model turn: emit text as it arrives, then rebuild the # full ChatMessage from the deltas for the rest of the step to use. deltas: list[Delta] = [] - async for delta in self.model.stream(messages, tools=self._tool_schemas): + async for delta in self.agent.model.stream( + messages, tools=self._tool_schemas + ): if delta.content: yield TextDelta(text=delta.content) deltas.append(delta) @@ -216,7 +293,7 @@ async def _run_stream( for tool_call in response.tool_calls: yield ToolCallEvent(tool_call=tool_call) - if self.parallel_tools: + if self.agent.parallel_tools: results: list[ToolResult] = await asyncio.gather( *(self._execute_tool(tc) for tc in response.tool_calls) ) @@ -252,7 +329,7 @@ async def _run_stream( return # Step limit reached: force one tool-free answer. - messages = self.memory.to_messages(self.instructions) + messages = self.memory.to_messages(self.agent.instructions) messages.append( ChatMessage( role="user", @@ -261,15 +338,13 @@ async def _run_stream( ) deltas = [] - - async for delta in self.model.stream(messages): + async for delta in self.agent.model.stream(messages): if delta.content: yield TextDelta(text=delta.content) deltas.append(delta) response = agglomerate_deltas(deltas) self.memory.add(FinalStep(answer=response.content)) - yield FinalEvent(answer=response.content, usage=response.usage) async def _execute_tool(self, tool_call: ToolCall) -> ToolResult: @@ -305,7 +380,7 @@ async def _execute_tool(self, tool_call: ToolCall) -> ToolResult: ) def _build_load_skill_tool(self) -> Tool: - """Build the built-in load_skill tool, bound to this agent's skills.""" + """Build the built-in load_skill tool, bound to this session.""" @tool def load_skill(name: str) -> str: @@ -316,10 +391,10 @@ def load_skill(name: str) -> str: the system prompt. """ - skill = self.skills.get(name) + skill = self.agent.skills.get(name) if skill is None: raise ToolCallError( - f"Unknown skill {name!r}. Available: {sorted(self.skills)}" + f"Unknown skill {name!r}. Available: {sorted(self.agent.skills)}" ) loaded = skill.load_tools() @@ -337,8 +412,9 @@ def load_skill(name: str) -> str: def interrupt(self) -> None: """Request a graceful stop before the next step. - The current run pauses rather than crashing; resume it later with - run(..., reset=False), which continues from the steps already in memory. + The current run pauses rather than crashing; resume it later by calling + run(..., reset=False) on this same session, which continues from the + steps already in memory. """ self._interrupt.set() diff --git a/tests/test_agent.py b/tests/test_agent.py index e8211a2..8046429 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -118,12 +118,13 @@ def multiply(a: int, b: int) -> int: async def test_forgiving_termination_returns_content() -> None: model = FakeModel([_assistant(content="42")]) agent = Agent(model=model) + session = agent.start() - answer = await agent.run("What is 6 times 7?") + answer = await session.run("What is 6 times 7?") assert answer == "42" - assert isinstance(agent.memory.steps[0], TaskStep) - assert isinstance(agent.memory.steps[-1], FinalStep) + assert isinstance(session.memory.steps[0], TaskStep) + assert isinstance(session.memory.steps[-1], FinalStep) async def test_explicit_final_answer() -> None: @@ -154,11 +155,12 @@ async def test_tool_call_then_answer() -> None: ] ) agent = Agent(model=model, tools=[add]) + session = agent.start() - answer = await agent.run("add 2 and 3") + answer = await session.run("add 2 and 3") assert answer == "The sum is 5." - action = agent.memory.steps[1] + action = session.memory.steps[1] assert isinstance(action, ActionStep) assert action.tool_results[0].content == "5" assert action.tool_results[0].is_error is False @@ -199,9 +201,10 @@ def boom() -> str: ] ) agent = Agent(model=model, tools=[boom]) + session = agent.start() - assert await agent.run("try boom") == "recovered" - action = agent.memory.steps[1] + assert await session.run("try boom") == "recovered" + action = session.memory.steps[1] assert isinstance(action, ActionStep) assert action.tool_results[0].is_error is True @@ -282,9 +285,10 @@ async def test_loop_detector_nudges_on_repeat() -> None: ] ) agent = Agent(model=model, tools=[add]) - await agent.run("repeat the same call") + session = agent.start() + await session.run("repeat the same call") - first, second = agent.memory.steps[1], agent.memory.steps[2] + first, second = session.memory.steps[1], session.memory.steps[2] assert isinstance(first, ActionStep) assert isinstance(second, ActionStep) assert _REPEAT_MARKER not in first.tool_results[0].content @@ -300,9 +304,10 @@ async def test_loop_detector_ignores_different_args() -> None: ] ) agent = Agent(model=model, tools=[add]) - await agent.run("two different sums") + session = agent.start() + await session.run("two different sums") - for step in agent.memory.steps: + for step in session.memory.steps: if isinstance(step, ActionStep): assert _REPEAT_MARKER not in step.tool_results[0].content @@ -313,10 +318,11 @@ async def test_loop_detector_ignores_different_args() -> None: async def test_unknown_tool_recovers() -> None: model = FakeModel([_tool_turn("c1", "ghost"), _assistant(content="recovered")]) agent = Agent(model=model) # only final_answer is registered - answer = await agent.run("call a missing tool") + session = agent.start() + answer = await session.run("call a missing tool") assert answer == "recovered" - action = agent.memory.steps[1] + action = session.memory.steps[1] assert isinstance(action, ActionStep) assert action.tool_results[0].is_error is True assert "Unknown tool" in action.tool_results[0].content @@ -335,10 +341,11 @@ def boom() -> str: ] ) agent = Agent(model=model, tools=[boom]) - answer = await agent.run("trigger boom") + session = agent.start() + answer = await session.run("trigger boom") assert answer == "handled" - action = agent.memory.steps[1] + action = session.memory.steps[1] assert isinstance(action, ActionStep) assert action.tool_results[0].is_error is True assert "RuntimeError" in action.tool_results[0].content @@ -352,18 +359,19 @@ async def test_interrupt_stops_run_gracefully() -> None: [_tool_turn("c1", "add", a=1, b=1), _assistant(content="unreached")] ) agent = Agent(model=model, tools=[add]) + session = agent.start() fired: list[bool] = [] def interrupt_after_first(step: object) -> None: if not fired: fired.append(True) - agent.interrupt() + session.interrupt() - agent.step_callbacks.append(interrupt_after_first) + session.step_callbacks.append(interrupt_after_first) - assert await agent.run("loop") == "Run interrupted." - assert isinstance(agent.memory.steps[-1], ActionStep) # paused before a FinalStep + assert await session.run("loop") == "Run interrupted." + assert isinstance(session.memory.steps[-1], ActionStep) # paused before a FinalStep assert len(model.calls) == 1 @@ -375,21 +383,22 @@ async def test_interrupt_then_resume_completes() -> None: ] ) agent = Agent(model=model, tools=[add]) + session = agent.start() fired: list[bool] = [] def interrupt_once(step: object) -> None: if not fired: fired.append(True) - agent.interrupt() + session.interrupt() - agent.step_callbacks.append(interrupt_once) + session.step_callbacks.append(interrupt_once) - assert await agent.run("start") == "Run interrupted." + assert await session.run("start") == "Run interrupted." # reset=False keeps the interrupted run's memory; the loop resumes from it. - assert await agent.run("continue", reset=False) == "finished on resume" + assert await session.run("continue", reset=False) == "finished on resume" assert len(model.calls) == 2 - assert sum(isinstance(s, TaskStep) for s in agent.memory.steps) == 2 + assert sum(isinstance(s, TaskStep) for s in session.memory.steps) == 2 # --------------------------------------------------------------------------- # @@ -406,8 +415,9 @@ def _load_skill_turn(call_id: str, skill_name: str) -> ChatMessage: def test_skill_catalog_is_added_to_the_system_prompt() -> None: agent = Agent(model=FakeModel([]), skills=[_REVIEWER_SKILL]) + session = agent.start() - assert "load_skill" in agent.tools + assert "load_skill" in session.tools assert "reviewer" in agent.instructions assert "Review code for bugs and style issues." in agent.instructions # Only the name and description surface up front; the body stays hidden @@ -418,7 +428,7 @@ def test_skill_catalog_is_added_to_the_system_prompt() -> None: def test_no_skills_means_no_load_skill_tool() -> None: agent = Agent(model=FakeModel([])) - assert "load_skill" not in agent.tools + assert "load_skill" not in agent.start().tools async def test_load_skill_reveals_the_body() -> None: @@ -429,9 +439,10 @@ async def test_load_skill_reveals_the_body() -> None: ] ) agent = Agent(model=model, skills=[_REVIEWER_SKILL]) + session = agent.start() - assert await agent.run("review this") == "done" - action = agent.memory.steps[1] + assert await session.run("review this") == "done" + action = session.memory.steps[1] assert isinstance(action, ActionStep) assert action.tool_results[0].content == _REVIEWER_SKILL.instructions assert action.tool_results[0].is_error is False @@ -446,17 +457,18 @@ async def test_load_skill_registers_the_skills_tools() -> None: ] ) agent = Agent(model=model, skills=[_CALC_SKILL]) + session = agent.start() # multiply is hidden until the skill that provides it is loaded. - assert "multiply" not in agent.tools + assert "multiply" not in session.tools - assert await agent.run("compute six times seven") == "42" + assert await session.run("compute six times seven") == "42" - assert "multiply" in agent.tools - load = agent.memory.steps[1] + assert "multiply" in session.tools + load = session.memory.steps[1] assert isinstance(load, ActionStep) assert "Tools now available: multiply." in load.tool_results[0].content - product = agent.memory.steps[2] + product = session.memory.steps[2] assert isinstance(product, ActionStep) assert product.tool_results[0].content == "42" @@ -469,9 +481,10 @@ async def test_load_unknown_skill_is_an_error_observation() -> None: ] ) agent = Agent(model=model, skills=[_REVIEWER_SKILL]) + session = agent.start() - assert await agent.run("load a missing skill") == "recovered" - action = agent.memory.steps[1] + assert await session.run("load a missing skill") == "recovered" + action = session.memory.steps[1] assert isinstance(action, ActionStep) assert action.tool_results[0].is_error is True assert "Unknown skill" in action.tool_results[0].content @@ -508,3 +521,49 @@ async def test_run_max_steps_below_one_raises() -> None: with pytest.raises(ValueError, match="at least 1"): await agent.run("hi", max_steps=0) + + +# --------------------------------------------------------------------------- # +# Session isolation +# --------------------------------------------------------------------------- # +async def test_sessions_keep_separate_memory() -> None: + agent = Agent(model=FakeModel([_assistant(content="one")])) + s1 = agent.start() + s2 = agent.start() + + await s1.run("first") + + assert s1.memory is not s2.memory + assert len(s1.memory.steps) > 0 + assert s2.memory.steps == [] # a sibling session is untouched + + +async def test_skill_tools_do_not_leak_between_sessions() -> None: + model = FakeModel( + [ + _load_skill_turn("c1", "calc"), + _tool_turn("c2", "multiply", a=2, b=2), + _assistant(content="4"), + ] + ) + agent = Agent(model=model, skills=[_CALC_SKILL]) + loader = agent.start() + other = agent.start() + + await loader.run("use calc") + + assert "multiply" in loader.tools # registered by load_skill in this session + assert "multiply" not in other.tools # never leaks into a sibling session + + +async def test_interrupt_affects_only_its_session() -> None: + agent = Agent(model=FakeModel([_assistant(content="s2 done")])) + s1 = agent.start() + s2 = agent.start() + + s1.interrupt() + + # s1 sees its own interrupt and stops before the first step (no model call). + assert await s1.run("stop") == "Run interrupted." + # s2 is unaffected and runs to completion. + assert await s2.run("go") == "s2 done" From e75128277427a238d53b94ad01282173daae9f61 Mon Sep 17 00:00:00 2001 From: folathecoder Date: Tue, 7 Jul 2026 13:23:21 +0100 Subject: [PATCH 03/17] Add project README with usage, architecture, and the session API --- README.md | 615 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 615 insertions(+) diff --git a/README.md b/README.md index e69de29..a319a55 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,615 @@ +# agentling + +[![Python](https://img.shields.io/badge/python-3.11%2B-blue.svg)](https://www.python.org/downloads/) +[![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE) +[![Typed](https://img.shields.io/badge/typed-mypy-blue.svg)](https://mypy-lang.org/) +[![Lint](https://img.shields.io/badge/lint-ruff-orange.svg)](https://docs.astral.sh/ruff/) + +A tiny async tool-calling agent framework. The good ideas from larger agent +libraries (a clean ReAct loop, typed memory, streaming, progressive-disclosure +skills) in a codebase small enough to read in one sitting. + +agentling is built around one idea: an agent is a loop that turns a model, some +tools, and a memory of what happened into more actions, until it has an answer. +Everything else (streaming, skills, self-healing, persistence) is a thin layer +on top of that loop. + +```python +import asyncio + +from agentling import Agent, OpenAIModel, tool + + +@tool +def get_weather(city: str) -> str: + """Get the current weather for a city. + + Args: + city: The city to look up. + """ + return f"It is 22C and sunny in {city}." + + +async def main() -> None: + agent = Agent(model=OpenAIModel("gpt-4o-mini"), tools=[get_weather]) + print(await agent.run("What's the weather in Paris?")) + + +asyncio.run(main()) +``` + +## Contents + +- [Why agentling](#why-agentling) +- [Install](#install) +- [Quickstart](#quickstart) +- [Usage](#usage) + - [Tools](#tools) + - [Running: blocking vs streaming](#running-blocking-vs-streaming) + - [Sessions and concurrency](#sessions-and-concurrency) + - [Skills](#skills) + - [Memory and resuming a run](#memory-and-resuming-a-run) + - [Interruption](#interruption) + - [Models and other providers](#models-and-other-providers) +- [Architecture](#architecture) +- [Configuration reference](#configuration-reference) +- [Development](#development) +- [License](#license) + +## Why agentling + +- **Async first.** The loop, tools, and model calls are all `async`. Tool calls + in a single step run concurrently by default. +- **One code path.** Blocking and streaming share the exact same loop. There is + a single async generator; blocking mode just drains it. +- **Typed memory.** A run is a list of typed steps, not a bag of raw messages. + Steps know how to render themselves back into model messages and serialize to + JSON for persistence and replay. +- **Progressive-disclosure skills.** Drop a `SKILL.md` folder in and the model + sees only its name and description until it decides to load it. Big skill + libraries stay cheap. +- **Self-healing.** A tool that raises becomes an observation the model can + recover from, not a crash. +- **Small and readable.** No metaclasses, no plugin registry, no DSL. Around + 800 lines of source you can actually read. + +## Install + +```bash +pip install agentling +``` + +Or with [uv](https://docs.astral.sh/uv/): + +```bash +uv add agentling +``` + +Requires Python 3.11 or newer. The only runtime dependencies are `openai` (the +client used by the built-in provider adapter) and `pyyaml` (for skill +frontmatter). + +Set your provider key in the environment: + +```bash +export OPENAI_API_KEY="sk-..." +``` + +## Quickstart + +```python +import asyncio + +from agentling import Agent, OpenAIModel, tool + + +@tool +def add(a: int, b: int) -> int: + """Add two integers. + + Args: + a: The first number. + b: The second number. + """ + return a + b + + +async def main() -> None: + agent = Agent(model=OpenAIModel("gpt-4o-mini"), tools=[add]) + answer = await agent.run("What is 19 + 23, and why?") + print(answer) + + +asyncio.run(main()) +``` + +## Usage + +### Tools + +A tool is any Python function wrapped with `@tool`. The function name becomes +the tool name, the docstring summary becomes the description, and the type hints +plus a Google-style `Args:` section become the JSON Schema the model sees. Both +synchronous and asynchronous functions are supported. + +```python +from agentling import tool + + +@tool +async def search(query: str, limit: int = 5) -> str: + """Search the docs and return the top matches. + + Args: + query: What to search for. + limit: How many results to return. + """ + ... +``` + +Supported parameter types map to JSON Schema: `str`, `int`, `float`, `bool`, +`list[...]`, `dict[...]`, `Optional[...]` / `X | None` (treated as "may be +omitted"), and `Literal[...]` (becomes an enum). Parameters without a default +are marked required. `*args`, `**kwargs`, and positional-only parameters are +rejected at registration time, because a tool is always called with a JSON +object of named arguments. + +If the model sends arguments that do not match the schema (missing a required +field, wrong type, unknown key), the tool raises a `ToolCallError` which the +loop feeds back to the model as an error observation. The model gets a chance to +fix its call rather than the run blowing up. + +Every agent also has a built-in `final_answer` tool. The model can call it to +end the run explicitly, or it can just reply with plain text (see +[forgiving termination](#forgiving-termination)). + +### Running: blocking vs streaming + +`run()` has two modes that share one implementation. + +Blocking mode returns the final answer: + +```python +answer: str = await agent.run("Summarize this.") +``` + +Streaming mode returns an async iterator of typed events: + +```python +from agentling import FinalEvent, TextDelta, ToolCallEvent + +async for event in agent.run("Summarize this.", stream=True): + if isinstance(event, TextDelta): + print(event.text, end="", flush=True) + elif isinstance(event, ToolCallEvent): + print(f"\n[calling {event.tool_call.name}]") + elif isinstance(event, FinalEvent): + print(f"\nDone: {event.answer}") +``` + +There is a ready-made renderer, `print_events`, that consumes the stream, prints +text as it arrives along with each tool call and result, and returns the final +answer. It is the "streaming CLI" in about thirty lines: + +```python +import asyncio + +from agentling import Agent, OpenAIModel, print_events, tool + + +@tool +def add(a: int, b: int) -> int: + """Add two integers. + + Args: + a: The first number. + b: The second number. + """ + return a + b + + +async def main() -> None: + agent = Agent(model=OpenAIModel("gpt-4o-mini"), tools=[add]) + answer = await print_events(agent.run("What is 19 + 23?", stream=True)) + print("\nFinal answer:", answer) + + +asyncio.run(main()) +``` + +### Skills + +A skill is a folder containing a `SKILL.md` file: YAML frontmatter followed by a +markdown body of instructions. + +```markdown +--- +name: code-reviewer +description: Review a code change for bugs, security issues, and style problems. +--- + +# Code Reviewer + +You are reviewing a code change. Work through it methodically and report only +findings you are confident about... +``` + +Skills use **progressive disclosure**. When you pass skills to an agent, only +their names and descriptions are added to the system prompt as a catalog. The +full instruction body stays out of context until the model calls the built-in +`load_skill(name)` tool, at which point the body is returned as an observation +and any tools the skill declares are registered. This keeps the base prompt +small even with a large library of skills installed. + +```python +import asyncio + +from agentling import Agent, OpenAIModel, tool + + +@tool +def read_file(path: str) -> str: + """Read a UTF-8 text file and return its contents. + + Args: + path: Path to the file to read. + """ + with open(path, encoding="utf-8") as handle: + return handle.read() + + +async def main() -> None: + agent = Agent( + model=OpenAIModel("gpt-4o-mini"), + tools=[read_file], + skills=["examples/skills/code-reviewer"], + ) + print(await agent.run("Review the code in src/agentling/agent.py")) + + +asyncio.run(main()) +``` + +A skill can also bring its own tools. List Python entry points in the +frontmatter and they are imported and registered when the skill loads: + +```markdown +--- +name: linting +description: Lint Python files and report issues. +tools: + - my_package.lint_tools:run_ruff +--- +``` + +Each entry point is a `"module.path:attribute"` string that must resolve to a +`Tool` (a function decorated with `@tool`). You can pass skills as folder paths +(strings or `Path`) or as pre-built `Skill` objects. + +### Sessions and concurrency + +An `Agent` is immutable configuration (model, tools, skills, settings) and is +safe to build once and share. The per-run state (memory, the interrupt token, +and any skill tools loaded during a run) lives on an `AgentSession`. + +`agent.run(task)` is a one-shot convenience: it spins up a fresh session, runs +it, and returns the answer. Because each call gets its own session, concurrent +calls on one shared agent never touch each other's memory or tools. When you +need multi-turn, inspection, or interruption, hold a session with +`agent.start()`. + +```python +# one-shot: simplest, and safe under concurrency +answer = await agent.run("A single question") + +# hold a session for multi-turn, inspection, or interruption +session = agent.start() +answer = await session.run("First question") +print(session.memory.steps) +``` + +### Memory and resuming a run + +Each session keeps a `Memory` of typed steps. You can serialize it and reload +it later: + +```python +session = agent.start() +await session.run("First question") + +saved = session.memory.dump_json() +# ... later, in another process ... +from agentling import Memory +restored = agent.start() +restored.memory = Memory.load_json(saved) +``` + +By default each `run()` starts fresh. Pass `reset=False` to continue from the +session's existing memory, which is how a multi-turn conversation or a resumed +run works: + +```python +session = agent.start() +await session.run("First question") +await session.run("A follow-up", reset=False) # sees the earlier turn +``` + +### Interruption + +Call `session.interrupt()` to request a graceful stop. The current run does not +crash; it finishes at the next step boundary and the session's memory is +preserved, so you can resume it with `run(..., reset=False)`. + +```python +session = agent.start() +session.interrupt() # from a signal handler, another task, a UI button +await session.run(task) # returns "Run interrupted." at the next boundary +# ... later ... +await session.run(task, reset=False) # picks up where it left off +``` + +### Models and other providers + +`OpenAIModel` is an adapter for any OpenAI-compatible chat-completions endpoint. +Point it at a different `base_url` to use a compatible provider (a local server, +a gateway, or another vendor's OpenAI-compatible API): + +```python +from agentling import OpenAIModel + +model = OpenAIModel( + "llama-3.1-70b", + base_url="http://localhost:8000/v1", + api_key="not-needed-locally", +) +``` + +Transient failures (rate limits, connection or timeout errors, 5xx responses) +are retried with exponential backoff. Permanent errors (a bad request, bad auth) +fail fast without retrying. + +Any object implementing the `Model` protocol works, so you can write your own +adapter: + +```python +class Model(Protocol): + async def generate(self, messages, tools=None) -> ChatMessage: ... + def stream(self, messages, tools=None) -> AsyncIterator[Delta]: ... +``` + +## Architecture + +agentling is six small modules. Each one owns a single concept, and they depend +on each other in one direction only (agent depends on skills, tools, memory, +events, models; nothing depends on agent). + +| Module | Responsibility | +| --- | --- | +| [`models.py`](src/agentling/models.py) | Provider-neutral message types (`ChatMessage`, `ToolCall`, `Usage`), streaming types (`Delta`, `ToolCallDelta`), the `Model` protocol, and the `OpenAIModel` adapter. | +| [`tools.py`](src/agentling/tools.py) | The `Tool` abstraction, the `@tool` decorator, JSON Schema generation from function signatures, argument validation, and the built-in `final_answer` tool. | +| [`memory.py`](src/agentling/memory.py) | Typed steps (`TaskStep`, `ActionStep`, `FinalStep`), the `Memory` container, rendering to model messages, and JSON serialization. | +| [`events.py`](src/agentling/events.py) | The streaming event types, the `Event` union, and the `print_events` renderer. | +| [`skills.py`](src/agentling/skills.py) | The `Skill` dataclass, the `SKILL.md` loader (frontmatter plus body), and entry-point tool resolution. | +| [`agent.py`](src/agentling/agent.py) | The `Agent` config/factory, the `AgentSession` that holds one run's state, and the ReAct loop that ties everything together. | + +### The agent loop + +The whole framework hangs off a single async generator, `AgentSession._run_stream`. +`run()` is a thin dispatcher: in streaming mode it hands back that generator; in +blocking mode it drains the generator and returns the final answer. There is no +second implementation to keep in sync. + +``` + run(task, stream=True) run(task) (stream=False) + │ │ + ▼ ▼ + _run_stream(task) ◀─────────────── _drain(_run_stream(task)) + (async generator) (awaits, returns the answer str) + │ + │ one iteration == one "step" + ▼ + ┌───────────────────────────────────────────────────────────────┐ + │ 1. interrupt requested? -> yield FinalEvent, stop (resumable) │ + │ 2. Memory.to_messages(instructions) -> the full prompt │ + │ 3. Model.stream(messages, tools) -> Delta stream │ + │ - each text chunk is yielded as a TextDelta │ + │ - agglomerate_deltas() rebuilds one ChatMessage │ + │ 4. no tool calls? -> that text is the answer; finish │ + │ 5. for each tool call: yield ToolCallEvent │ + │ 6. execute tools (concurrently by default) -> ToolResults │ + │ - a raised exception becomes an error observation │ + │ - an exact repeat of last step's calls gets a nudge │ + │ 7. yield ToolResultEvent per result; record an ActionStep │ + │ 8. final_answer called? -> finish with FinalEvent │ + └───────────────────────────────────────────────────────────────┘ + │ + ▼ + step limit reached -> ask once for a tool-free answer, then finish +``` + +Each iteration is a step. A step streams one model turn, runs whatever tools the +model asked for, records the outcome, and checks whether the run is done. The +loop ends in one of three ways: the model calls `final_answer`, the model +replies with plain text and no tool calls, or the step limit is hit and the loop +asks for one last tool-free answer. + +### Message and model layer + +Everything above the provider speaks in framework-neutral types, not vendor +payloads: + +- **`ChatMessage`** is the one message type used internally. It has a role, + content, optional tool calls, an optional tool-call id (for tool results), and + optional usage. +- **`ToolCall`** is a provider-neutral tool call: an id, a name, and a parsed + arguments dict. +- **`Usage`** is input and output token counts, with a `total_tokens` property. + +`OpenAIModel` is the only place that knows OpenAI's wire format. It converts +`ChatMessage` lists into OpenAI messages on the way out and converts responses +back into `ChatMessage` on the way in, so the rest of the framework never sees a +provider-specific shape. Swapping providers means writing one adapter, not +touching the loop. + +**Streaming and reassembly.** `Model.stream` yields `Delta` objects: small +chunks of content or fragments of a tool call. Tool-call arguments in particular +arrive in pieces across many deltas. The module-level `agglomerate_deltas` +function reassembles a delta stream back into a single `ChatMessage`: it +concatenates content, merges tool-call fragments by their index, and captures +the final usage. This is why the loop can stream text to the user and still work +with a complete `ChatMessage` for tool execution: it streams and reassembles at +the same time. + +### Memory and typed steps + +A run is recorded as a list of typed steps rather than a flat list of messages: + +- **`TaskStep`** is the user's task (or a continued turn). +- **`ActionStep`** is one loop iteration: the assistant message the model + produced, the tool results from it, and metadata (token usage, wall-clock + duration). +- **`FinalStep`** is the terminal answer. + +Each step knows how to render itself into the `ChatMessage` list the model sees, +via `to_messages()`. `Memory.to_messages(system_prompt)` prepends the system +prompt (which is runtime configuration, not history, so it is never stored in a +step) and then renders every step in order. This separation is what makes the +memory serializable: `dump_json` / `load_json` round-trip the whole run, tagging +each step with its kind so it can be rebuilt, which gives you free persistence, +replay, and multi-turn continuation. + +### Events and streaming + +The loop communicates progress through a small set of frozen event types: + +| Event | Meaning | +| --- | --- | +| `TextDelta` | A chunk of streamed assistant text. | +| `ToolCallEvent` | Emitted just before a tool call runs. | +| `ToolResultEvent` | Emitted after a tool call completes (success or error). | +| `StepEvent` | Emitted after a step is recorded to memory; carries the step. | +| `FinalEvent` | Emitted once when the run ends; carries the answer and usage. | + +`StepEvent` is the bridge between the live event stream and the durable memory: +it carries the exact `ActionStep` that was just written. `print_events` is a +reference consumer of this stream, but you can write your own to drive a UI, log +to a database, or compute metrics. + +### Tools and schema generation + +`@tool` wraps a function into a `Tool`. At registration time it reads the +function's signature and docstring and builds a JSON Schema `parameters` object: +type hints become JSON types, the `Args:` section becomes per-parameter +descriptions, and defaults decide what is required. Before a tool runs, the loop +validates the model's arguments against that schema and raises `ToolCallError` +on a mismatch. For stateful tools you can subclass `Tool` directly instead of +using the decorator. + +### Skills and progressive disclosure + +`Skill.from_path` parses a `SKILL.md` into a name, description, instruction body, +and an optional list of tool entry points. When an agent is built with skills, +it registers a single built-in `load_skill` tool and appends a catalog of +`name: description` lines to the system prompt. It does not put any skill bodies +in the prompt. + +When the model calls `load_skill(name)`, the agent returns that skill's full +instruction body as the tool observation and registers any tools the skill +declared. Because the loop reads the live tool set fresh on every step, those +newly registered tools are available on the very next turn. The result is that +the cost of a skill (its instructions, its tools) is only paid once the model +actually decides to use it. + +### Cross-cutting behaviors + +These are small rules layered onto the loop that make agents robust in practice. + +#### Forgiving termination + +Not every model reliably calls `final_answer`. So if the model replies with +plain text and no tool calls, agentling treats that text as the answer and ends +the run. Explicit `final_answer` and plain-text replies both work. + +#### Self-healing tool errors + +`_execute_tool` catches any exception a tool raises and turns it into a +`ToolResult` with `is_error=True`. That error is rendered back to the model as an +observation ("Error from 'search': ... Fix the arguments and try again"), so the +model can correct course. One bad tool call does not kill the run. + +#### Loop detection + +If a step's tool calls are an exact repeat of the previous step's (same names, +same arguments), the loop appends a short nudge to the observations telling the +model it already made that call and got the same result. This helps the model +break out of a stuck cycle without a hard failure. + +#### Graceful interruption and resume + +`interrupt()` sets an event that the loop checks at the top of each step. When +set, the loop emits a final "Run interrupted." event and returns without writing +a terminal `FinalStep`. Because memory is left intact and no terminal step is +written, `run(..., reset=False)` can pick the run back up exactly where it +paused. + +#### Concurrent tool execution + +When a single model turn requests several tool calls, they run concurrently with +`asyncio.gather` by default. Set `parallel_tools=False` to run them in order +instead (useful when tools share state or must not interleave). + +## Configuration reference + +`Agent(...)`: + +| Parameter | Default | Description | +| --- | --- | --- | +| `model` | required | Any object implementing the `Model` protocol. | +| `tools` | `()` | Tools to register (a `final_answer` tool is always added). | +| `skills` | `()` | Skills as folder paths or `Skill` objects. | +| `instructions` | built-in default | The system prompt. A skill catalog is appended when skills are present. | +| `max_steps` | `15` | Maximum loop iterations before a forced answer. Must be at least 1. | +| `step_callbacks` | `()` | Callables invoked with each `ActionStep` as it is recorded. | +| `parallel_tools` | `True` | Run a turn's tool calls concurrently, or in order when `False`. | + +`OpenAIModel(...)`: + +| Parameter | Default | Description | +| --- | --- | --- | +| `model` | required | The model name to request. | +| `api_key` | env | Falls back to the OpenAI SDK's environment configuration. | +| `base_url` | `None` | Point at any OpenAI-compatible endpoint. | +| `context_window` | `128_000` | Advertised context window for this model. | +| `max_retries` | `2` | Retries after the initial request for transient errors. | +| `retry_base_delay` | `0.5` | Initial backoff delay in seconds (doubles each retry). | + +`agent.run(task, *, stream=False, reset=True, max_steps=None)`: + +- `stream=False` returns an awaitable that resolves to the final answer string. +- `stream=True` returns an async iterator of `Event` objects. +- `reset=False` continues from existing memory instead of starting fresh. +- `max_steps` overrides the agent's limit for this run only. + +## Development + +The project uses [uv](https://docs.astral.sh/uv/) for environment and +dependency management. + +```bash +uv sync # install everything, including dev deps +uv run pytest -q # run the test suite +uv run --with ruff ruff check src tests # lint +uv run --with mypy mypy src tests # type-check +``` + +## License + +[MIT](LICENSE) (c) Folarin Akinloye. + +## Acknowledgements + +The design borrows the best ideas from the broader agent ecosystem: the clean +ReAct loop and code-first tools popularized by smolagents, and the +progressive-disclosure skill format used by Claude. agentling's contribution is +packaging those ideas into a small, typed, async codebase you can read end to +end. From 1c332f659bdf19a126dafb639705f47d75f69fe8 Mon Sep 17 00:00:00 2001 From: folathecoder Date: Tue, 7 Jul 2026 13:28:36 +0100 Subject: [PATCH 04/17] Recover from malformed streamed tool calls instead of crashing Route both the streaming and non-streaming paths through one parse_tool_arguments that raises ModelOutputError on invalid JSON or a non-object arguments payload, and reject a streamed tool call with no name. The loop catches ModelOutputError, re-prompts the model with a correction up to a small cap, then raises ModelError if it keeps failing. Previously a bad JSON tool-call argument raised JSONDecodeError straight out of the loop and killed the run. --- src/agentling/agent.py | 46 +++++++++++++++++++++++++++-- src/agentling/models.py | 58 +++++++++++++++++++++++-------------- tests/test_agent.py | 64 +++++++++++++++++++++++++++++++++++++++++ tests/test_models.py | 27 +++++++++++++++-- 4 files changed, 170 insertions(+), 25 deletions(-) diff --git a/src/agentling/agent.py b/src/agentling/agent.py index 51097d0..0279ae8 100644 --- a/src/agentling/agent.py +++ b/src/agentling/agent.py @@ -28,6 +28,7 @@ from pathlib import Path from typing import Literal, overload +from .errors import ModelError, ModelOutputError from .events import ( Event, FinalEvent, @@ -52,6 +53,9 @@ "Try a different approach." ) +# How many consecutive unparseable model turns to tolerate before giving up. +_MAX_MALFORMED_RETRIES = 2 + class Agent: """Immutable configuration and a factory for sessions. @@ -254,6 +258,11 @@ async def _run_stream( # Remember the previous step's calls so we can spot an exact repeat. previous_signature: tuple[tuple[str, str], ...] | None = None + # Corrective user messages appended to the prompt after the model emits + # unparseable output, so it can retry. Cleared once a turn parses. + correction_notes: list[ChatMessage] = [] + malformed_retries = 0 + for _ in range(limit): if self._interrupt.is_set(): self._interrupt.clear() @@ -262,6 +271,7 @@ async def _run_stream( started = time.monotonic() messages = self.memory.to_messages(self.agent.instructions) + messages.extend(correction_notes) # Stream the model turn: emit text as it arrives, then rebuild the # full ChatMessage from the deltas for the rest of the step to use. @@ -273,7 +283,33 @@ async def _run_stream( yield TextDelta(text=delta.content) deltas.append(delta) - response = agglomerate_deltas(deltas) + # Malformed tool calls (bad JSON, missing name) are recoverable: + # re-prompt the model with a correction, up to a small cap, rather + # than crashing the run. + try: + response = agglomerate_deltas(deltas) + except ModelOutputError as exc: + malformed_retries += 1 + if malformed_retries > _MAX_MALFORMED_RETRIES: + raise ModelError( + f"Model produced unparseable tool calls on " + f"{malformed_retries} consecutive turns: {exc}" + ) from exc + correction_notes.append( + ChatMessage( + role="user", + content=( + f"Your previous response could not be parsed: {exc} " + "Reply again with valid tool-call arguments as a JSON " + "object." + ), + ) + ) + continue + + # A well-formed turn clears any accumulated corrections. + correction_notes.clear() + malformed_retries = 0 # Forgiving termination: no tool calls means the content is the answer. if not response.tool_calls: @@ -343,7 +379,13 @@ async def _run_stream( yield TextDelta(text=delta.content) deltas.append(delta) - response = agglomerate_deltas(deltas) + try: + response = agglomerate_deltas(deltas) + except ModelOutputError as exc: + raise ModelError( + f"Model produced unparseable output on the forced final answer: {exc}" + ) from exc + self.memory.add(FinalStep(answer=response.content)) yield FinalEvent(answer=response.content, usage=response.usage) diff --git a/src/agentling/models.py b/src/agentling/models.py index 2ef4f3b..ed60c99 100644 --- a/src/agentling/models.py +++ b/src/agentling/models.py @@ -16,6 +16,8 @@ import openai +from .errors import ModelOutputError + Role = Literal["system", "user", "assistant", "tool"] ToolSpec = dict[str, Any] OpenAIMessage = dict[str, Any] @@ -345,22 +347,11 @@ def _from_openai_tool_calls(self, tool_calls: Any | None) -> list[ToolCall]: def _parse_tool_arguments(self, arguments: str) -> dict[str, Any]: """Parse and validate model-generated tool arguments. - Tool-call arguments should be a JSON object. If the model emits invalid - JSON or a non-object value, fail loudly so the caller can decide how to - handle the error. + Thin wrapper over the module-level parse_tool_arguments so the streaming + and non-streaming paths validate identically. """ - try: - parsed = json.loads(arguments or "{}") - except json.JSONDecodeError as exc: - raise ValueError(f"Invalid tool call arguments: {arguments!r}") from exc - - if not isinstance(parsed, dict): - raise ValueError( - f"Tool call arguments must be a JSON object: {arguments!r}" - ) - - return parsed + return parse_tool_arguments(arguments) @dataclass @@ -372,6 +363,28 @@ class _ToolCallFragment: arguments: str = "" +def parse_tool_arguments(arguments: str) -> dict[str, Any]: + """Parse and validate model-generated tool arguments. + + Tool-call arguments must be a JSON object. Invalid JSON or a non-object + value is malformed model output, so this raises ModelOutputError; the agent + loop can turn that into a recoverable observation. Both the streaming and + non-streaming paths route through here so they validate identically. + """ + + try: + parsed = json.loads(arguments or "{}") + except json.JSONDecodeError as exc: + raise ModelOutputError(f"Invalid tool call arguments: {arguments!r}") from exc + + if not isinstance(parsed, dict): + raise ModelOutputError( + f"Tool call arguments must be a JSON object: {arguments!r}" + ) + + return parsed + + def agglomerate_deltas(deltas: Iterable[Delta]) -> ChatMessage: """Reassemble streamed deltas into a single ChatMessage. @@ -400,14 +413,17 @@ def agglomerate_deltas(deltas: Iterable[Delta]) -> ChatMessage: if delta.usage is not None: usage = delta.usage - tool_calls = [ - ToolCall( - id=fragment.id or "", - name=fragment.name or "", - arguments=json.loads(fragment.arguments or "{}"), + tool_calls: list[ToolCall] = [] + for _index, fragment in sorted(fragments.items()): + if not fragment.name: + raise ModelOutputError("Streamed tool call is missing a name.") + tool_calls.append( + ToolCall( + id=fragment.id or "", + name=fragment.name, + arguments=parse_tool_arguments(fragment.arguments), + ) ) - for _index, fragment in sorted(fragments.items()) - ] return ChatMessage( role="assistant", diff --git a/tests/test_agent.py b/tests/test_agent.py index 8046429..e2c0896 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -6,6 +6,7 @@ import pytest from agentling.agent import Agent +from agentling.errors import ModelError from agentling.events import ( FinalEvent, StepEvent, @@ -567,3 +568,66 @@ async def test_interrupt_affects_only_its_session() -> None: assert await s1.run("stop") == "Run interrupted." # s2 is unaffected and runs to completion. assert await s2.run("go") == "s2 done" + + +# --------------------------------------------------------------------------- # +# Malformed model output (recoverable) +# --------------------------------------------------------------------------- # +class _MalformedThenAnswerModel: + """Streams one unparseable tool call, then a plain answer on the retry.""" + + def __init__(self) -> None: + self.calls = 0 + + async def generate( + self, messages: Sequence[ChatMessage], tools: Sequence[Any] | None = None + ) -> ChatMessage: + raise NotImplementedError + + async def stream( + self, messages: Sequence[ChatMessage], tools: Sequence[Any] | None = None + ) -> AsyncIterator[Delta]: + self.calls += 1 + if self.calls == 1: + yield Delta( + tool_calls=[ + ToolCallDelta(index=0, id="c1", name="add", arguments="{bad json") + ] + ) + else: + yield Delta(content="recovered") + yield Delta(usage=Usage(1, 1)) + + +class _AlwaysMalformedModel: + """Every turn streams an unparseable tool call.""" + + async def generate( + self, messages: Sequence[ChatMessage], tools: Sequence[Any] | None = None + ) -> ChatMessage: + raise NotImplementedError + + async def stream( + self, messages: Sequence[ChatMessage], tools: Sequence[Any] | None = None + ) -> AsyncIterator[Delta]: + yield Delta( + tool_calls=[ + ToolCallDelta(index=0, id="c1", name="add", arguments="{bad json") + ] + ) + yield Delta(usage=Usage(1, 1)) + + +async def test_malformed_tool_call_is_recoverable() -> None: + model = _MalformedThenAnswerModel() + agent = Agent(model=model) + + assert await agent.run("do something") == "recovered" + assert model.calls == 2 # first turn malformed, re-prompted, then answered + + +async def test_persistently_malformed_output_raises_model_error() -> None: + agent = Agent(model=_AlwaysMalformedModel()) + + with pytest.raises(ModelError): + await agent.run("do something") diff --git a/tests/test_models.py b/tests/test_models.py index 657e2b1..7fded33 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -5,6 +5,7 @@ import openai import pytest +from agentling.errors import ModelOutputError from agentling.models import ( ChatMessage, Delta, @@ -134,12 +135,12 @@ def test_parse_tool_arguments_empty_defaults_to_object(model: OpenAIModel) -> No def test_parse_tool_arguments_invalid_json_raises(model: OpenAIModel) -> None: - with pytest.raises(ValueError, match="Invalid tool call arguments"): + with pytest.raises(ModelOutputError, match="Invalid tool call arguments"): model._parse_tool_arguments("{not json}") def test_parse_tool_arguments_non_object_raises(model: OpenAIModel) -> None: - with pytest.raises(ValueError, match="must be a JSON object"): + with pytest.raises(ModelOutputError, match="must be a JSON object"): model._parse_tool_arguments("[1, 2]") @@ -179,6 +180,28 @@ def test_agglomerate_captures_usage() -> None: assert msg.usage == Usage(5, 2) +def test_agglomerate_rejects_invalid_tool_json() -> None: + deltas = [ + Delta(tool_calls=[ToolCallDelta(index=0, id="c1", name="f", arguments="{bad")]) + ] + with pytest.raises(ModelOutputError, match="Invalid tool call arguments"): + agglomerate_deltas(deltas) + + +def test_agglomerate_rejects_non_object_tool_args() -> None: + deltas = [ + Delta(tool_calls=[ToolCallDelta(index=0, id="c1", name="f", arguments="[1, 2]")]) + ] + with pytest.raises(ModelOutputError, match="must be a JSON object"): + agglomerate_deltas(deltas) + + +def test_agglomerate_rejects_tool_call_without_name() -> None: + deltas = [Delta(tool_calls=[ToolCallDelta(index=0, id="c1", arguments="{}")])] + with pytest.raises(ModelOutputError, match="missing a name"): + agglomerate_deltas(deltas) + + # --------------------------------------------------------------------------- # # generate (fake client) # --------------------------------------------------------------------------- # From 84d38a23dd9f56eae0fd42a68579f8fd6916c26a Mon Sep 17 00:00:00 2001 From: folathecoder Date: Tue, 7 Jul 2026 13:44:43 +0100 Subject: [PATCH 05/17] Add tool and model timeouts, and prompt cancellation Wrap tool.call in asyncio.wait_for (a per-tool Tool.timeout overrides the agent's tool_timeout); a timed-out tool becomes a ToolTimeoutError observation the model can recover from, while a genuine task cancellation propagates and stops the run. Bound each model turn with asyncio.timeout (model_timeout) and check the interrupt token between streamed chunks, so a long or hung stream can be stopped without waiting for it to finish. --- src/agentling/agent.py | 69 +++++++++++++++++++----- src/agentling/tools.py | 3 ++ tests/test_agent.py | 120 ++++++++++++++++++++++++++++++++++++++++- 3 files changed, 179 insertions(+), 13 deletions(-) diff --git a/src/agentling/agent.py b/src/agentling/agent.py index 0279ae8..a1b3d65 100644 --- a/src/agentling/agent.py +++ b/src/agentling/agent.py @@ -28,7 +28,7 @@ from pathlib import Path from typing import Literal, overload -from .errors import ModelError, ModelOutputError +from .errors import ModelError, ModelOutputError, ToolTimeoutError from .events import ( Event, FinalEvent, @@ -75,6 +75,8 @@ def __init__( max_steps: int = 15, step_callbacks: Sequence[Callable[[Step], None]] = (), parallel_tools: bool = True, + tool_timeout: float | None = None, + model_timeout: float | None = None, ) -> None: if max_steps < 1: raise ValueError("max_steps must be at least 1.") @@ -82,6 +84,8 @@ def __init__( self.model = model self.max_steps = max_steps self.parallel_tools = parallel_tools + self.tool_timeout = tool_timeout + self.model_timeout = model_timeout # Default callbacks applied to every session. A session copies these and # may append its own. self.step_callbacks = list(step_callbacks) @@ -275,13 +279,30 @@ async def _run_stream( # Stream the model turn: emit text as it arrives, then rebuild the # full ChatMessage from the deltas for the rest of the step to use. + # model_timeout bounds a hung turn, and the per-chunk interrupt check + # lets a long stream be stopped without waiting for it to finish. deltas: list[Delta] = [] - async for delta in self.agent.model.stream( - messages, tools=self._tool_schemas - ): - if delta.content: - yield TextDelta(text=delta.content) - deltas.append(delta) + interrupted = False + try: + async with asyncio.timeout(self.agent.model_timeout): + async for delta in self.agent.model.stream( + messages, tools=self._tool_schemas + ): + if delta.content: + yield TextDelta(text=delta.content) + deltas.append(delta) + if self._interrupt.is_set(): + interrupted = True + break + except TimeoutError as exc: + raise ModelError( + f"Model stream exceeded the {self.agent.model_timeout}s timeout." + ) from exc + + if interrupted: + self._interrupt.clear() + yield FinalEvent(answer="Run interrupted.") + return # Malformed tool calls (bad JSON, missing name) are recoverable: # re-prompt the model with a correction, up to a small cap, rather @@ -374,10 +395,16 @@ async def _run_stream( ) deltas = [] - async for delta in self.agent.model.stream(messages): - if delta.content: - yield TextDelta(text=delta.content) - deltas.append(delta) + try: + async with asyncio.timeout(self.agent.model_timeout): + async for delta in self.agent.model.stream(messages): + if delta.content: + yield TextDelta(text=delta.content) + deltas.append(delta) + except TimeoutError as exc: + raise ModelError( + f"Model stream exceeded the {self.agent.model_timeout}s timeout." + ) from exc try: response = agglomerate_deltas(deltas) @@ -403,8 +430,26 @@ async def _execute_tool(self, tool_call: ToolCall) -> ToolResult: ), is_error=True, ) + # A per-tool timeout overrides the agent-wide default. + timeout = tool.timeout if tool.timeout is not None else self.agent.tool_timeout try: - output = await tool.call(tool_call.arguments) + if timeout is not None: + output = await asyncio.wait_for(tool.call(tool_call.arguments), timeout) + else: + output = await tool.call(tool_call.arguments) + except TimeoutError: + # A slow tool is an observation, not a crash. A genuine task + # cancellation raises CancelledError (a BaseException), which is + # deliberately not caught here so it propagates and stops the run. + err = ToolTimeoutError( + f"tool {tool_call.name!r} exceeded its {timeout}s timeout" + ) + return ToolResult( + tool_call_id=tool_call.id, + name=tool_call.name, + content=f"{type(err).__name__}: {err}", + is_error=True, + ) except Exception as exc: # A failing tool becomes an observation the model can recover from # rather than a crash, so catching broadly here is intentional. diff --git a/src/agentling/tools.py b/src/agentling/tools.py index 5277062..dc56ea0 100644 --- a/src/agentling/tools.py +++ b/src/agentling/tools.py @@ -40,6 +40,9 @@ class Tool: name: str description: str parameters: JsonSchema + # Optional per-tool time budget in seconds. None means use the agent's + # tool_timeout default (which is itself None unless the caller sets it). + timeout: float | None = None async def forward(self, **kwargs: Any) -> Any: """Execute the tool implementation. diff --git a/tests/test_agent.py b/tests/test_agent.py index e2c0896..2552ad3 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -1,3 +1,4 @@ +import asyncio import json from collections.abc import AsyncIterator, Sequence from pathlib import Path @@ -5,7 +6,7 @@ import pytest -from agentling.agent import Agent +from agentling.agent import Agent, AgentSession from agentling.errors import ModelError from agentling.events import ( FinalEvent, @@ -96,6 +97,17 @@ def multiply(a: int, b: int) -> int: return a * b +@tool +async def slow(seconds: float) -> str: + """Sleep for a while, then return. + + Args: + seconds: How long to sleep. + """ + await asyncio.sleep(seconds) + return "slept" + + _REVIEWER_SKILL = Skill( name="reviewer", description="Review code for bugs and style issues.", @@ -631,3 +643,109 @@ async def test_persistently_malformed_output_raises_model_error() -> None: with pytest.raises(ModelError): await agent.run("do something") + + +# --------------------------------------------------------------------------- # +# Timeouts and cancellation +# --------------------------------------------------------------------------- # +async def test_tool_timeout_becomes_observation() -> None: + model = FakeModel([_tool_turn("c1", "slow", seconds=5), _assistant(content="done")]) + agent = Agent(model=model, tools=[slow], tool_timeout=0.01) + session = agent.start() + + assert await session.run("call slow") == "done" + action = session.memory.steps[1] + assert isinstance(action, ActionStep) + assert action.tool_results[0].is_error is True + assert "ToolTimeoutError" in action.tool_results[0].content + + +async def test_parallel_timeout_keeps_other_results() -> None: + model = FakeModel( + [ + _assistant( + tool_calls=[ + ToolCall(id="c1", name="slow", arguments={"seconds": 5}), + ToolCall(id="c2", name="add", arguments={"a": 2, "b": 3}), + ] + ), + _assistant(content="both handled"), + ] + ) + agent = Agent(model=model, tools=[slow, add], tool_timeout=0.01) + session = agent.start() + + assert await session.run("two tools") == "both handled" + action = session.memory.steps[1] + assert isinstance(action, ActionStep) + by_name = {result.name: result for result in action.tool_results} + assert by_name["slow"].is_error is True + assert by_name["add"].is_error is False + assert by_name["add"].content == "5" + + +class _SlowStreamModel: + """A model whose stream stalls before producing anything.""" + + async def generate( + self, messages: Sequence[ChatMessage], tools: Sequence[Any] | None = None + ) -> ChatMessage: + raise NotImplementedError + + async def stream( + self, messages: Sequence[ChatMessage], tools: Sequence[Any] | None = None + ) -> AsyncIterator[Delta]: + await asyncio.sleep(5) + yield Delta(content="too late") + + +async def test_model_stream_timeout_raises_model_error() -> None: + agent = Agent(model=_SlowStreamModel(), model_timeout=0.01) + + with pytest.raises(ModelError): + await agent.run("hi") + + +async def test_cancellation_propagates_during_a_tool() -> None: + model = FakeModel([_tool_turn("c1", "slow", seconds=5), _assistant(content="done")]) + agent = Agent(model=model, tools=[slow]) # no timeout: relies on cancellation + + async def run_it() -> str: + return await agent.run("call slow") + + task = asyncio.ensure_future(run_it()) + await asyncio.sleep(0.02) # let the run reach the slow tool + + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + +class _InterruptMidStreamModel: + """Interrupts its own session partway through streaming.""" + + def __init__(self) -> None: + self.session: AgentSession | None = None + + async def generate( + self, messages: Sequence[ChatMessage], tools: Sequence[Any] | None = None + ) -> ChatMessage: + raise NotImplementedError + + async def stream( + self, messages: Sequence[ChatMessage], tools: Sequence[Any] | None = None + ) -> AsyncIterator[Delta]: + yield Delta(content="par") + assert self.session is not None + self.session.interrupt() + yield Delta(content="tial") + yield Delta(usage=Usage(1, 1)) + + +async def test_interrupt_during_stream_stops_the_run() -> None: + model = _InterruptMidStreamModel() + agent = Agent(model=model) + session = agent.start() + model.session = session + + assert await session.run("stream") == "Run interrupted." From 3cef7087af39c0bd72ad47d225239d1e5b1be961 Mon Sep 17 00:00:00 2001 From: folathecoder Date: Tue, 7 Jul 2026 14:04:17 +0100 Subject: [PATCH 06/17] Harden tool execution: threads, metadata, truncation, redaction Run sync @tool functions via asyncio.to_thread so a blocking call cannot stall the event loop. Add per-tool metadata (timeout, parallel_safe, max_output_chars) through the @tool decorator, which now works bare or parameterized. Truncate long tool observations head and tail (max_tool_output_chars). Redact unexpected tool exceptions to the type only, logging the detail, when redact_errors is set, while ToolCallError stays fully visible so the model can fix its arguments. Validate tool names against provider naming constraints. --- src/agentling/agent.py | 66 +++++++++++++++++++-- src/agentling/tools.py | 86 +++++++++++++++++++++------ tests/test_agent.py | 128 ++++++++++++++++++++++++++++++++++++++++- tests/test_tools.py | 45 +++++++++++++++ 4 files changed, 300 insertions(+), 25 deletions(-) diff --git a/src/agentling/agent.py b/src/agentling/agent.py index a1b3d65..d15c1cb 100644 --- a/src/agentling/agent.py +++ b/src/agentling/agent.py @@ -22,6 +22,7 @@ import asyncio import json +import logging import time from collections.abc import AsyncIterator, Awaitable, Callable, Iterable, Sequence from dataclasses import replace @@ -56,6 +57,8 @@ # How many consecutive unparseable model turns to tolerate before giving up. _MAX_MALFORMED_RETRIES = 2 +logger = logging.getLogger("agentling") + class Agent: """Immutable configuration and a factory for sessions. @@ -77,6 +80,8 @@ def __init__( parallel_tools: bool = True, tool_timeout: float | None = None, model_timeout: float | None = None, + max_tool_output_chars: int | None = None, + redact_errors: bool = False, ) -> None: if max_steps < 1: raise ValueError("max_steps must be at least 1.") @@ -86,6 +91,8 @@ def __init__( self.parallel_tools = parallel_tools self.tool_timeout = tool_timeout self.model_timeout = model_timeout + self.max_tool_output_chars = max_tool_output_chars + self.redact_errors = redact_errors # Default callbacks applied to every session. A session copies these and # may append its own. self.step_callbacks = list(step_callbacks) @@ -350,7 +357,14 @@ async def _run_stream( for tool_call in response.tool_calls: yield ToolCallEvent(tool_call=tool_call) - if self.agent.parallel_tools: + # Run this step's tools concurrently only when the agent allows it + # and every tool in the step is parallel_safe; otherwise run them in + # order so a side-effectful tool cannot interleave with others. + step_tools = [self.tools.get(tc.name) for tc in response.tool_calls] + run_parallel = self.agent.parallel_tools and all( + t is None or t.parallel_safe for t in step_tools + ) + if run_parallel: results: list[ToolResult] = await asyncio.gather( *(self._execute_tool(tc) for tc in response.tool_calls) ) @@ -450,19 +464,46 @@ async def _execute_tool(self, tool_call: ToolCall) -> ToolResult: content=f"{type(err).__name__}: {err}", is_error=True, ) - except Exception as exc: - # A failing tool becomes an observation the model can recover from - # rather than a crash, so catching broadly here is intentional. + except ToolCallError as exc: + # Invalid-argument errors are safe and useful, so the model always + # sees them in full to correct its call. return ToolResult( tool_call_id=tool_call.id, name=tool_call.name, content=f"{type(exc).__name__}: {exc}", is_error=True, ) + except Exception as exc: + # An unexpected tool failure becomes an observation the model can + # recover from. With redact_errors the message (which may carry + # secrets or internal detail) is suppressed and logged instead. + if self.agent.redact_errors: + logger.exception("Tool %r raised", tool_call.name) + content = ( + f"{type(exc).__name__} while running {tool_call.name!r} " + "(details suppressed)." + ) + else: + content = f"{type(exc).__name__}: {exc}" + return ToolResult( + tool_call_id=tool_call.id, + name=tool_call.name, + content=content, + is_error=True, + ) + + content = str(output) + limit = ( + tool.max_output_chars + if tool.max_output_chars is not None + else self.agent.max_tool_output_chars + ) + if limit is not None: + content = _truncate_middle(content, limit) return ToolResult( tool_call_id=tool_call.id, name=tool_call.name, - content=str(output), + content=content, is_error=False, ) @@ -507,6 +548,21 @@ def interrupt(self) -> None: self._interrupt.set() +def _truncate_middle(text: str, limit: int) -> str: + """Trim the middle of an over-long string, keeping the head and tail. + + Large tool outputs can flood the context window; keeping both ends preserves + the most useful parts (a summary at the top, a result at the bottom) while + bounding size. + """ + + if len(text) <= limit: + return text + keep = limit // 2 + omitted = len(text) - 2 * keep + return f"{text[:keep]}\n... [{omitted} characters omitted] ...\n{text[-keep:]}" + + def _as_skill(entry: Skill | str | Path) -> Skill: """Coerce a skill entry (a Skill, or a path to a skill folder) into a Skill.""" diff --git a/src/agentling/tools.py b/src/agentling/tools.py index dc56ea0..8c7bca5 100644 --- a/src/agentling/tools.py +++ b/src/agentling/tools.py @@ -7,11 +7,12 @@ from __future__ import annotations +import asyncio import inspect import re import types from collections.abc import Callable -from typing import Any, Literal, Union, get_args, get_origin, get_type_hints +from typing import Any, Literal, Union, get_args, get_origin, get_type_hints, overload from .errors import AgentlingError from .models import ToolSpec @@ -43,6 +44,12 @@ class Tool: # Optional per-tool time budget in seconds. None means use the agent's # tool_timeout default (which is itself None unless the caller sets it). timeout: float | None = None + # Whether this tool is safe to run concurrently with others in the same + # step. If any tool in a step is not parallel_safe, the step runs in order. + parallel_safe: bool = True + # Optional cap on observation length in characters. None means use the + # agent's max_tool_output_chars default. + max_output_chars: int | None = None async def forward(self, **kwargs: Any) -> Any: """Execute the tool implementation. @@ -314,38 +321,66 @@ def _build_schema(func: Callable[..., Any]) -> JsonSchema: } +_VALID_TOOL_NAME = re.compile(r"^[A-Za-z0-9_-]{1,64}$") + + class _FunctionTool(Tool): """Tool implementation backed by a plain Python function.""" def __init__(self, func: Callable[..., Any]) -> None: summary, _ = _parse_docstring(func) + name = func.__name__ + if not _VALID_TOOL_NAME.match(name): + raise ValueError( + f"Tool name {name!r} is not a valid tool name: use letters, " + "digits, underscore, or hyphen, up to 64 characters." + ) + self._func = func - self.name = func.__name__ + self.name = name self.description = summary self.parameters = _build_schema(func) async def forward(self, **kwargs: Any) -> Any: - result = self._func(**kwargs) - - # Support both sync and async functions. Only await values that are - # actually awaitable; ordinary return values pass through unchanged. - if inspect.isawaitable(result): - return await result - - return result - - -def tool(func: Callable[..., Any]) -> Tool: + # Async functions are awaited directly; a plain sync function runs in a + # worker thread so a blocking call (CPU or I/O) cannot stall the event + # loop, the stream, or other tools running in the same step. + if inspect.iscoroutinefunction(self._func): + return await self._func(**kwargs) + return await asyncio.to_thread(self._func, **kwargs) + + +@overload +def tool(func: Callable[..., Any]) -> Tool: ... + + +@overload +def tool( + *, + timeout: float | None = ..., + parallel_safe: bool = ..., + max_output_chars: int | None = ..., +) -> Callable[[Callable[..., Any]], Tool]: ... + + +def tool( + func: Callable[..., Any] | None = None, + *, + timeout: float | None = None, + parallel_safe: bool = True, + max_output_chars: int | None = None, +) -> Tool | Callable[[Callable[..., Any]], Tool]: """Create a Tool from a plain Python function. - The function name becomes the tool name. The docstring summary becomes the - tool description. Argument descriptions are read from a Google-style Args - section, and type hints are converted into a JSON Schema parameters object. + The function name becomes the tool name, the docstring summary becomes the + description, argument descriptions come from a Google-style Args section, + and type hints become a JSON Schema parameters object. Synchronous and + asynchronous functions are both supported; sync functions run in a worker + thread so they cannot block the event loop. - Both synchronous and asynchronous functions are supported. + Use it bare, or with metadata: - Example: @tool def get_weather(city: str) -> str: '''Get the current weather for a city. @@ -354,9 +389,22 @@ def get_weather(city: str) -> str: city: The city to look up. ''' ... + + @tool(timeout=30, max_output_chars=2000, parallel_safe=False) + def run_query(sql: str) -> str: + ... """ - return _FunctionTool(func) + def make(target: Callable[..., Any]) -> Tool: + built = _FunctionTool(target) + built.timeout = timeout + built.parallel_safe = parallel_safe + built.max_output_chars = max_output_chars + return built + + if func is not None: + return make(func) + return make class FinalAnswerTool(Tool): diff --git a/tests/test_agent.py b/tests/test_agent.py index 2552ad3..319d31c 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -1,5 +1,6 @@ import asyncio import json +import threading from collections.abc import AsyncIterator, Sequence from pathlib import Path from typing import Any @@ -18,7 +19,7 @@ from agentling.memory import ActionStep, FinalStep, TaskStep from agentling.models import ChatMessage, Delta, ToolCall, ToolCallDelta, Usage from agentling.skills import Skill -from agentling.tools import tool +from agentling.tools import ToolCallError, tool class FakeModel: @@ -749,3 +750,128 @@ async def test_interrupt_during_stream_stops_the_run() -> None: model.session = session assert await session.run("stream") == "Run interrupted." + + +# --------------------------------------------------------------------------- # +# Tool execution hardening +# --------------------------------------------------------------------------- # +async def test_sync_tools_run_off_the_event_loop_thread() -> None: + @tool + def where() -> int: + """Return the id of the thread the tool ran on.""" + return threading.get_ident() + + model = FakeModel([_tool_turn("c1", "where"), _assistant(content="ok")]) + agent = Agent(model=model, tools=[where]) + session = agent.start() + + await session.run("which thread") + action = session.memory.steps[1] + assert isinstance(action, ActionStep) + # The sync tool ran in a worker thread, not the event loop's thread. + assert action.tool_results[0].content != str(threading.get_ident()) + + +async def test_tool_output_is_truncated() -> None: + @tool + def big() -> str: + """Return a large string.""" + return "x" * 1000 + + model = FakeModel([_tool_turn("c1", "big"), _assistant(content="ok")]) + agent = Agent(model=model, tools=[big], max_tool_output_chars=100) + session = agent.start() + + await session.run("go") + action = session.memory.steps[1] + assert isinstance(action, ActionStep) + content = action.tool_results[0].content + assert "characters omitted" in content + assert len(content) < 300 + + +async def test_errors_are_shown_by_default() -> None: + @tool + def leak() -> str: + """Raise with a message.""" + raise RuntimeError("visible detail") + + model = FakeModel([_tool_turn("c1", "leak"), _assistant(content="ok")]) + agent = Agent(model=model, tools=[leak]) + session = agent.start() + + await session.run("go") + action = session.memory.steps[1] + assert isinstance(action, ActionStep) + assert "visible detail" in action.tool_results[0].content + + +async def test_error_redaction_hides_the_message() -> None: + @tool + def leak() -> str: + """Raise with a secret.""" + raise RuntimeError("token=SECRET123") + + model = FakeModel([_tool_turn("c1", "leak"), _assistant(content="ok")]) + agent = Agent(model=model, tools=[leak], redact_errors=True) + session = agent.start() + + await session.run("go") + action = session.memory.steps[1] + assert isinstance(action, ActionStep) + content = action.tool_results[0].content + assert "SECRET123" not in content # message suppressed + assert "RuntimeError" in content # the type is still surfaced + + +async def test_tool_call_error_shown_even_when_redacting() -> None: + @tool + def bad() -> str: + """Raise a ToolCallError.""" + raise ToolCallError("use a positive number") + + model = FakeModel([_tool_turn("c1", "bad"), _assistant(content="ok")]) + agent = Agent(model=model, tools=[bad], redact_errors=True) + session = agent.start() + + await session.run("go") + action = session.memory.steps[1] + assert isinstance(action, ActionStep) + assert "use a positive number" in action.tool_results[0].content + + +async def test_non_parallel_safe_tools_run_in_order() -> None: + order: list[str] = [] + + @tool(parallel_safe=False) + async def first() -> str: + """First tool.""" + order.append("first-start") + await asyncio.sleep(0.02) + order.append("first-end") + return "1" + + @tool + async def second() -> str: + """Second tool.""" + order.append("second") + return "2" + + model = FakeModel( + [ + _assistant( + tool_calls=[ + ToolCall(id="c1", name="first", arguments={}), + ToolCall(id="c2", name="second", arguments={}), + ] + ), + _assistant(content="done"), + ] + ) + agent = Agent(model=model, tools=[first, second]) + session = agent.start() + + await session.run("go") + # first is not parallel_safe, so the step runs sequentially: it finishes + # before second starts. + assert order == ["first-start", "first-end", "second"] diff --git a/tests/test_tools.py b/tests/test_tools.py index f6073e1..9af8523 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -171,3 +171,48 @@ async def test_final_answer_tool() -> None: assert final.name == "final_answer" assert "answer" in props assert await final.call({"answer": "42"}) == "42" + + +# --------------------------------------------------------------------------- # +# @tool metadata and name validation +# --------------------------------------------------------------------------- # +def test_bare_tool_decorator_has_default_metadata() -> None: + @tool + def t(x: int) -> int: + """Doc. + + Args: + x: A number. + """ + return x + + assert isinstance(t, Tool) + assert t.timeout is None + assert t.parallel_safe is True + assert t.max_output_chars is None + + +def test_tool_decorator_accepts_metadata() -> None: + @tool(timeout=1.5, parallel_safe=False, max_output_chars=100) + def t(x: int) -> int: + """Doc. + + Args: + x: A number. + """ + return x + + assert isinstance(t, Tool) + assert t.timeout == 1.5 + assert t.parallel_safe is False + assert t.max_output_chars == 100 + + +def test_invalid_tool_name_is_rejected() -> None: + def f() -> str: + """A tool with a bad name.""" + return "" + + f.__name__ = "bad name!" + with pytest.raises(ValueError, match="valid tool name"): + tool(f) From 6b49352742f88d6fea686b02a58e9b6bbea368d6 Mon Sep 17 00:00:00 2001 From: folathecoder Date: Tue, 7 Jul 2026 14:14:37 +0100 Subject: [PATCH 07/17] Harden memory: load validation, cumulative usage, context hook, error wording Validate the version and shape in Memory.from_dict, raising MemoryLoadError instead of a raw KeyError. Accumulate token usage across turns and report the run total on the terminal FinalEvent (Usage gains __add__). Add an optional context_manager hook on Agent to trim or summarize the prompt before each model call. Tag tool failures with an error_kind so an observation says "fix the arguments" only for a validation error and "try a different approach" otherwise. --- src/agentling/agent.py | 36 +++++++++++++++++++++++----- src/agentling/memory.py | 52 +++++++++++++++++++++++++++++++++-------- src/agentling/models.py | 7 ++++++ tests/test_agent.py | 26 +++++++++++++++++++++ tests/test_memory.py | 41 +++++++++++++++++++++++++++++--- 5 files changed, 143 insertions(+), 19 deletions(-) diff --git a/src/agentling/agent.py b/src/agentling/agent.py index d15c1cb..c64eba1 100644 --- a/src/agentling/agent.py +++ b/src/agentling/agent.py @@ -39,7 +39,15 @@ ToolResultEvent, ) from .memory import ActionStep, FinalStep, Memory, Step, TaskStep, ToolResult -from .models import ChatMessage, Delta, Model, ToolCall, ToolSpec, agglomerate_deltas +from .models import ( + ChatMessage, + Delta, + Model, + ToolCall, + ToolSpec, + Usage, + agglomerate_deltas, +) from .skills import Skill from .tools import FinalAnswerTool, Tool, ToolCallError, tool @@ -82,6 +90,7 @@ def __init__( model_timeout: float | None = None, max_tool_output_chars: int | None = None, redact_errors: bool = False, + context_manager: Callable[[list[ChatMessage]], list[ChatMessage]] | None = None, ) -> None: if max_steps < 1: raise ValueError("max_steps must be at least 1.") @@ -93,6 +102,10 @@ def __init__( self.model_timeout = model_timeout self.max_tool_output_chars = max_tool_output_chars self.redact_errors = redact_errors + # Optional hook to trim or summarize the prompt before each model call, + # so long runs do not blow the context window. It receives the full + # message list and returns the (possibly shorter) list to send. + self.context_manager = context_manager # Default callbacks applied to every session. A session copies these and # may append its own. self.step_callbacks = list(step_callbacks) @@ -273,16 +286,19 @@ async def _run_stream( # unparseable output, so it can retry. Cleared once a turn parses. correction_notes: list[ChatMessage] = [] malformed_retries = 0 + total_usage = Usage(0, 0) for _ in range(limit): if self._interrupt.is_set(): self._interrupt.clear() - yield FinalEvent(answer="Run interrupted.") + yield FinalEvent(answer="Run interrupted.", usage=total_usage) return started = time.monotonic() messages = self.memory.to_messages(self.agent.instructions) messages.extend(correction_notes) + if self.agent.context_manager is not None: + messages = self.agent.context_manager(messages) # Stream the model turn: emit text as it arrives, then rebuild the # full ChatMessage from the deltas for the rest of the step to use. @@ -308,7 +324,7 @@ async def _run_stream( if interrupted: self._interrupt.clear() - yield FinalEvent(answer="Run interrupted.") + yield FinalEvent(answer="Run interrupted.", usage=total_usage) return # Malformed tool calls (bad JSON, missing name) are recoverable: @@ -338,11 +354,13 @@ async def _run_stream( # A well-formed turn clears any accumulated corrections. correction_notes.clear() malformed_retries = 0 + if response.usage is not None: + total_usage = total_usage + response.usage # Forgiving termination: no tool calls means the content is the answer. if not response.tool_calls: self.memory.add(FinalStep(answer=response.content)) - yield FinalEvent(answer=response.content, usage=response.usage) + yield FinalEvent(answer=response.content, usage=total_usage) return # Fingerprint this step's calls: (name, canonical JSON args) each. @@ -396,7 +414,7 @@ async def _run_stream( ) if final is not None: self.memory.add(FinalStep(answer=final.content)) - yield FinalEvent(answer=final.content, usage=response.usage) + yield FinalEvent(answer=final.content, usage=total_usage) return # Step limit reached: force one tool-free answer. @@ -427,8 +445,10 @@ async def _run_stream( f"Model produced unparseable output on the forced final answer: {exc}" ) from exc + if response.usage is not None: + total_usage = total_usage + response.usage self.memory.add(FinalStep(answer=response.content)) - yield FinalEvent(answer=response.content, usage=response.usage) + yield FinalEvent(answer=response.content, usage=total_usage) async def _execute_tool(self, tool_call: ToolCall) -> ToolResult: """Run one tool call, turning any failure into an error observation.""" @@ -443,6 +463,7 @@ async def _execute_tool(self, tool_call: ToolCall) -> ToolResult: f"Available: {list(self.tools)}" ), is_error=True, + error_kind="unknown_tool", ) # A per-tool timeout overrides the agent-wide default. timeout = tool.timeout if tool.timeout is not None else self.agent.tool_timeout @@ -463,6 +484,7 @@ async def _execute_tool(self, tool_call: ToolCall) -> ToolResult: name=tool_call.name, content=f"{type(err).__name__}: {err}", is_error=True, + error_kind="timeout", ) except ToolCallError as exc: # Invalid-argument errors are safe and useful, so the model always @@ -472,6 +494,7 @@ async def _execute_tool(self, tool_call: ToolCall) -> ToolResult: name=tool_call.name, content=f"{type(exc).__name__}: {exc}", is_error=True, + error_kind="validation", ) except Exception as exc: # An unexpected tool failure becomes an observation the model can @@ -490,6 +513,7 @@ async def _execute_tool(self, tool_call: ToolCall) -> ToolResult: name=tool_call.name, content=content, is_error=True, + error_kind="execution", ) content = str(output) diff --git a/src/agentling/memory.py b/src/agentling/memory.py index b4f26ad..0875e0e 100644 --- a/src/agentling/memory.py +++ b/src/agentling/memory.py @@ -14,8 +14,12 @@ from dataclasses import asdict, dataclass, field from typing import Any, ClassVar +from .errors import MemoryLoadError from .models import ChatMessage, ToolCall, Usage +# Serialization format version written by to_dict and checked by from_dict. +_MEMORY_VERSION = 1 + @dataclass class ToolResult: @@ -25,6 +29,10 @@ class ToolResult: name: str content: str is_error: bool = False + # For failures, the kind of failure: "validation" (bad arguments), + # "execution" (the tool raised), "timeout", or "unknown_tool". None on + # success. Drives the recovery hint shown to the model. + error_kind: str | None = None @dataclass @@ -62,13 +70,14 @@ def to_messages(self) -> list[ChatMessage]: for result in self.tool_results: content = result.content # Failed tool calls are returned to the model as observations so the - # next turn can recover. The tool name is included because a single - # step may contain several calls. + # next turn can recover. Only a validation error is fixed by changing + # the arguments; other failures need a different approach. if result.is_error: - content = ( - f"Error from {result.name!r}: {result.content}. " - "Fix the arguments and try again." - ) + if result.error_kind == "validation": + hint = "Fix the arguments and try again." + else: + hint = "Consider a different approach or tool." + content = f"Error from {result.name!r}: {result.content}. {hint}" messages.append( ChatMessage( role="tool", content=content, tool_call_id=result.tool_call_id @@ -142,7 +151,7 @@ def to_dict(self) -> dict[str, Any]: # the load path restores from each step's "type" tag. The version field # lets the format evolve without breaking older saved runs. return { - "version": 1, + "version": _MEMORY_VERSION, "steps": [ {"type": self._KIND[type(step)], "data": asdict(step)} for step in self.steps @@ -151,9 +160,32 @@ def to_dict(self) -> dict[str, Any]: @classmethod def from_dict(cls, data: dict[str, Any]) -> Memory: - """Rebuild a Memory from the dict produced by to_dict().""" + """Rebuild a Memory from the dict produced by to_dict(). + + Validates the version and overall shape, raising MemoryLoadError with a + clear message rather than letting a malformed payload fail with a raw + KeyError deep in reconstruction. + """ + + if not isinstance(data, dict): + raise MemoryLoadError("Serialized memory must be a JSON object.") + + version = data.get("version") + if version != _MEMORY_VERSION: + raise MemoryLoadError( + f"Unsupported memory version {version!r} (expected {_MEMORY_VERSION})." + ) + + entries = data.get("steps", []) + if not isinstance(entries, list): + raise MemoryLoadError("Memory 'steps' must be a list.") + + try: + steps = [_step_from_dict(entry) for entry in entries] + except (KeyError, TypeError) as exc: + raise MemoryLoadError(f"Malformed memory step: {exc}") from exc - return cls(steps=[_step_from_dict(entry) for entry in data.get("steps", [])]) + return cls(steps=steps) def dump_json(self) -> str: """Serialize the memory to a JSON string.""" @@ -199,4 +231,4 @@ def _step_from_dict(entry: dict[str, Any]) -> Step: duration=data["duration"], ) - raise ValueError(f"Unknown step type: {kind!r}") + raise MemoryLoadError(f"Unknown step type: {kind!r}") diff --git a/src/agentling/models.py b/src/agentling/models.py index ed60c99..49be3ec 100644 --- a/src/agentling/models.py +++ b/src/agentling/models.py @@ -72,6 +72,13 @@ def total_tokens(self) -> int: """Total tokens consumed (input + output).""" return self.input_tokens + self.output_tokens + def __add__(self, other: Usage) -> Usage: + """Sum two usages field-wise (used to accumulate a run's total).""" + return Usage( + input_tokens=self.input_tokens + other.input_tokens, + output_tokens=self.output_tokens + other.output_tokens, + ) + @dataclass class ChatMessage: diff --git a/tests/test_agent.py b/tests/test_agent.py index 319d31c..dd23800 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -875,3 +875,29 @@ async def second() -> str: # first is not parallel_safe, so the step runs sequentially: it finishes # before second starts. assert order == ["first-start", "first-end", "second"] + + +# --------------------------------------------------------------------------- # +# Memory hardening: cumulative usage and the context-window hook +# --------------------------------------------------------------------------- # +async def test_final_event_reports_cumulative_usage() -> None: + model = FakeModel([_tool_turn("c1", "add", a=1, b=1), _assistant(content="2")]) + agent = Agent(model=model, tools=[add]) + + events = [event async for event in agent.run("go", stream=True)] + final = events[-1] + assert isinstance(final, FinalEvent) + # Each FakeModel turn reports Usage(1, 1); two turns sum to Usage(2, 2). + assert final.usage == Usage(2, 2) + + +async def test_context_manager_trims_the_prompt() -> None: + def keep_only_system(messages: list[ChatMessage]) -> list[ChatMessage]: + return messages[:1] + + model = FakeModel([_assistant(content="ok")]) + agent = Agent(model=model, context_manager=keep_only_system) + + assert await agent.run("hello") == "ok" + # The model saw only the trimmed message list. + assert len(model.calls[0]) == 1 diff --git a/tests/test_memory.py b/tests/test_memory.py index 7c48867..ee0d55b 100644 --- a/tests/test_memory.py +++ b/tests/test_memory.py @@ -9,6 +9,7 @@ TaskStep, ToolResult, ) +from agentling.errors import MemoryLoadError from agentling.models import ChatMessage, ToolCall, Usage @@ -54,13 +55,37 @@ def test_action_step_renders_error_observation() -> None: tool_calls=[ToolCall(id="c1", name="f", arguments={})], ), tool_results=[ - ToolResult(tool_call_id="c1", name="f", content="boom", is_error=True) + ToolResult( + tool_call_id="c1", + name="f", + content="boom", + is_error=True, + error_kind="validation", + ) ], ) tool_msg = step.to_messages()[1] assert tool_msg.content == "Error from 'f': boom. Fix the arguments and try again." +def test_action_step_execution_error_uses_a_different_hint() -> None: + step = ActionStep( + model_message=ChatMessage(role="assistant"), + tool_results=[ + ToolResult( + tool_call_id="c1", + name="f", + content="network down", + is_error=True, + error_kind="execution", + ) + ], + ) + tool_msg = step.to_messages()[1] + assert "different approach" in tool_msg.content + assert "Fix the arguments" not in tool_msg.content + + def test_final_step_renders_assistant_message() -> None: msgs = FinalStep(answer="done").to_messages() assert [m.role for m in msgs] == ["assistant"] @@ -148,5 +173,15 @@ def test_round_trip_preserves_error_flag() -> None: def test_from_dict_rejects_unknown_step_type() -> None: - with pytest.raises(ValueError, match="Unknown step type"): - Memory.from_dict({"steps": [{"type": "bogus", "data": {}}]}) + with pytest.raises(MemoryLoadError, match="Unknown step type"): + Memory.from_dict({"version": 1, "steps": [{"type": "bogus", "data": {}}]}) + + +def test_from_dict_rejects_unsupported_version() -> None: + with pytest.raises(MemoryLoadError, match="version"): + Memory.from_dict({"version": 999, "steps": []}) + + +def test_from_dict_rejects_non_list_steps() -> None: + with pytest.raises(MemoryLoadError, match="steps"): + Memory.from_dict({"version": 1, "steps": "not a list"}) From 213d6760edf20770bc73e2beb03d0eb1f1e83920 Mon Sep 17 00:00:00 2001 From: folathecoder Date: Tue, 7 Jul 2026 14:24:30 +0100 Subject: [PATCH 08/17] Add terminal run status and a redirectable print_events FinalEvent gains a status field (completed / interrupted / max_steps) so a stream consumer can tell how a run ended; failures still raise. Interrupt keeps writing no FinalStep, so the resume contract holds and the distinction lives in the event. print_events takes a file argument for testing and redirection, and the module documents the event ordering guarantees. --- src/agentling/agent.py | 12 ++++++--- src/agentling/events.py | 36 ++++++++++++++++++------- tests/test_agent.py | 60 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 95 insertions(+), 13 deletions(-) diff --git a/src/agentling/agent.py b/src/agentling/agent.py index c64eba1..a380168 100644 --- a/src/agentling/agent.py +++ b/src/agentling/agent.py @@ -291,7 +291,9 @@ async def _run_stream( for _ in range(limit): if self._interrupt.is_set(): self._interrupt.clear() - yield FinalEvent(answer="Run interrupted.", usage=total_usage) + yield FinalEvent( + answer="Run interrupted.", usage=total_usage, status="interrupted" + ) return started = time.monotonic() @@ -324,7 +326,9 @@ async def _run_stream( if interrupted: self._interrupt.clear() - yield FinalEvent(answer="Run interrupted.", usage=total_usage) + yield FinalEvent( + answer="Run interrupted.", usage=total_usage, status="interrupted" + ) return # Malformed tool calls (bad JSON, missing name) are recoverable: @@ -448,7 +452,9 @@ async def _run_stream( if response.usage is not None: total_usage = total_usage + response.usage self.memory.add(FinalStep(answer=response.content)) - yield FinalEvent(answer=response.content, usage=total_usage) + yield FinalEvent( + answer=response.content, usage=total_usage, status="max_steps" + ) async def _execute_tool(self, tool_call: ToolCall) -> ToolResult: """Run one tool call, turning any failure into an error observation.""" diff --git a/src/agentling/events.py b/src/agentling/events.py index 881eeaa..5a50cd6 100644 --- a/src/agentling/events.py +++ b/src/agentling/events.py @@ -6,6 +6,10 @@ StepEvent connects the live event stream to memory by carrying the step that was just recorded. + +Ordering within a run is deterministic: a turn's TextDelta chunks arrive in +order, then a ToolCallEvent and its matching ToolResultEvent for each tool call, +then the StepEvent for that step. Exactly one FinalEvent is emitted, last. """ from __future__ import annotations @@ -13,10 +17,13 @@ import json from collections.abc import AsyncIterator from dataclasses import dataclass +from typing import IO, Literal from .memory import Step, ToolResult from .models import ToolCall, Usage +RunStatus = Literal["completed", "interrupted", "max_steps"] + @dataclass(frozen=True, slots=True) class TextDelta: @@ -48,10 +55,16 @@ class StepEvent: @dataclass(frozen=True, slots=True) class FinalEvent: - """Emitted once when the run completes, carrying the answer and total usage.""" + """Emitted once when the run ends, carrying the answer, usage, and status. + + status is how the run ended: "completed" (the model finished), "interrupted" + (a caller interrupted it), or "max_steps" (the step limit was hit and a final + answer was forced). Failures the run cannot recover from raise instead. + """ answer: str usage: Usage | None = None + status: RunStatus = "completed" # Public union type for the values yielded by the agent's event stream. @@ -64,13 +77,16 @@ def _truncate(text: str, limit: int = 500) -> str: return text if len(text) <= limit else text[:limit] + "..." -async def print_events(events: AsyncIterator[Event]) -> str: - """Render an agent's event stream to stdout as it arrives. +async def print_events( + events: AsyncIterator[Event], *, file: IO[str] | None = None +) -> str: + """Render an agent's event stream as it arrives. Consumes the iterator from Agent.run(..., stream=True): assistant text prints token by token, each tool call and its result get their own line, and the final answer is shown at the end. Returns that answer so the caller - can keep using it once the run has been displayed. + can keep using it once the run has been displayed. Output goes to file, or + to stdout when file is None. """ answer = "" @@ -79,22 +95,22 @@ async def print_events(events: AsyncIterator[Event]) -> str: async for event in events: match event: case TextDelta(text=text): - print(text, end="", flush=True) + print(text, end="", flush=True, file=file) mid_line = True case ToolCallEvent(tool_call=call): if mid_line: - print() + print(file=file) mid_line = False - print(f"-> {call.name}({json.dumps(call.arguments)})") + print(f"-> {call.name}({json.dumps(call.arguments)})", file=file) case ToolResultEvent(result=result): status = "error" if result.is_error else "ok" - print(f"<- [{status}] {_truncate(result.content)}") + print(f"<- [{status}] {_truncate(result.content)}", file=file) case FinalEvent(answer=final): if mid_line: - print() + print(file=file) mid_line = False answer = final - print(f"= {final}") + print(f"= {final}", file=file) case StepEvent(): pass # Its contents already surfaced through the events above. diff --git a/tests/test_agent.py b/tests/test_agent.py index dd23800..5ba7749 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -1,4 +1,5 @@ import asyncio +import io import json import threading from collections.abc import AsyncIterator, Sequence @@ -15,6 +16,7 @@ TextDelta, ToolCallEvent, ToolResultEvent, + print_events, ) from agentling.memory import ActionStep, FinalStep, TaskStep from agentling.models import ChatMessage, Delta, ToolCall, ToolCallDelta, Usage @@ -901,3 +903,61 @@ def keep_only_system(messages: list[ChatMessage]) -> list[ChatMessage]: assert await agent.run("hello") == "ok" # The model saw only the trimmed message list. assert len(model.calls[0]) == 1 + + +# --------------------------------------------------------------------------- # +# Terminal run status and print_events +# --------------------------------------------------------------------------- # +async def test_completed_run_reports_status() -> None: + model = FakeModel([_assistant(content="done")]) + agent = Agent(model=model) + + events = [event async for event in agent.run("go", stream=True)] + final = events[-1] + assert isinstance(final, FinalEvent) + assert final.status == "completed" + + +async def test_interrupted_run_reports_status() -> None: + model = FakeModel([_tool_turn("c1", "add", a=1, b=1), _assistant(content="x")]) + agent = Agent(model=model, tools=[add]) + session = agent.start() + + fired: list[bool] = [] + + def stop(step: object) -> None: + if not fired: + fired.append(True) + session.interrupt() + + session.step_callbacks.append(stop) + + events = [event async for event in session.run("loop", stream=True)] + final = events[-1] + assert isinstance(final, FinalEvent) + assert final.status == "interrupted" + + +async def test_max_steps_run_reports_status() -> None: + looping = [_tool_turn(f"c{i}", "add", a=1, b=1) for i in range(2)] + model = FakeModel([*looping, _assistant(content="forced")]) + agent = Agent(model=model, tools=[add], max_steps=2) + + events = [event async for event in agent.run("loop", stream=True)] + final = events[-1] + assert isinstance(final, FinalEvent) + assert final.answer == "forced" + assert final.status == "max_steps" + + +async def test_print_events_writes_to_file_and_returns_answer() -> None: + model = FakeModel([_tool_turn("c1", "add", a=2, b=3), _assistant(content="5")]) + agent = Agent(model=model, tools=[add]) + + buffer = io.StringIO() + answer = await print_events(agent.run("add", stream=True), file=buffer) + + assert answer == "5" + output = buffer.getvalue() + assert "add" in output # the tool call was rendered + assert "5" in output # the final answer was rendered From fab8ec8b670d7c015c9f8a19ee858d37f865bcee Mon Sep 17 00:00:00 2001 From: folathecoder Date: Tue, 7 Jul 2026 14:36:56 +0100 Subject: [PATCH 09/17] Adopt ruff format and mypy config, wire CI, bump to 0.1.0 Declare ruff and mypy configuration in pyproject and pin them as dev dependencies so CI runs the same versions instead of fetching them ad hoc. CI now also checks formatting and builds the package. Reformat the tree with ruff format. Bump the version to 0.1.0. --- .github/workflows/ci.yml | 10 +- pyproject.toml | 16 ++- src/agentling/agent.py | 7 +- src/agentling/tools.py | 3 +- tests/test_agent.py | 4 +- tests/test_memory.py | 2 +- tests/test_models.py | 28 +++-- tests/test_skills.py | 21 +--- uv.lock | 220 ++++++++++++++++++++++++++++++++++++++- 9 files changed, 269 insertions(+), 42 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 791da37..b401822 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,10 +38,16 @@ jobs: run: uv sync - name: Lint (ruff) - run: uv run --with ruff ruff check src tests + run: uv run ruff check src tests + + - name: Format check (ruff) + run: uv run ruff format --check src tests - name: Type-check (mypy) - run: uv run --with mypy mypy src tests + run: uv run mypy src tests - name: Test (pytest) run: uv run pytest + + - name: Build (verify the package builds) + run: uv build diff --git a/pyproject.toml b/pyproject.toml index ed2f95d..80e9c7a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "agentling" -version = "0.0.1" +version = "0.1.0" description = "A tiny async tool-calling agent framework" readme = "README.md" requires-python = ">=3.11" @@ -35,9 +35,23 @@ build-backend = "hatchling.build" asyncio_mode = "auto" testpaths = ["tests"] +[tool.ruff] +line-length = 88 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "I"] + +[tool.mypy] +python_version = "3.11" +warn_unused_ignores = true +warn_redundant_casts = true + [dependency-groups] dev = [ "pytest>=9.1.1", "pytest-asyncio>=1.4.0", "types-PyYAML>=6.0", + "ruff>=0.9.0", + "mypy>=1.14.0", ] diff --git a/src/agentling/agent.py b/src/agentling/agent.py index a380168..ee0bc85 100644 --- a/src/agentling/agent.py +++ b/src/agentling/agent.py @@ -452,9 +452,7 @@ async def _run_stream( if response.usage is not None: total_usage = total_usage + response.usage self.memory.add(FinalStep(answer=response.content)) - yield FinalEvent( - answer=response.content, usage=total_usage, status="max_steps" - ) + yield FinalEvent(answer=response.content, usage=total_usage, status="max_steps") async def _execute_tool(self, tool_call: ToolCall) -> ToolResult: """Run one tool call, turning any failure into an error observation.""" @@ -465,8 +463,7 @@ async def _execute_tool(self, tool_call: ToolCall) -> ToolResult: tool_call_id=tool_call.id, name=tool_call.name, content=( - f"Unknown tool {tool_call.name!r}. " - f"Available: {list(self.tools)}" + f"Unknown tool {tool_call.name!r}. Available: {list(self.tools)}" ), is_error=True, error_kind="unknown_tool", diff --git a/src/agentling/tools.py b/src/agentling/tools.py index 8c7bca5..19a56ed 100644 --- a/src/agentling/tools.py +++ b/src/agentling/tools.py @@ -105,8 +105,7 @@ def _validate(self, arguments: dict[str, Any]) -> None: unknown = [name for name in arguments if name not in properties] if unknown: raise ToolCallError( - f"Tool {self.name!r} got unexpected argument(s): " - f"{', '.join(unknown)}" + f"Tool {self.name!r} got unexpected argument(s): {', '.join(unknown)}" ) for name, value in arguments.items(): diff --git a/tests/test_agent.py b/tests/test_agent.py index 5ba7749..0a33da9 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -281,9 +281,7 @@ async def test_max_steps_forces_a_final_answer() -> None: def _tool_turn(call_id: str, name: str, **arguments: Any) -> ChatMessage: """A model turn that requests a single tool call.""" - return _assistant( - tool_calls=[ToolCall(id=call_id, name=name, arguments=arguments)] - ) + return _assistant(tool_calls=[ToolCall(id=call_id, name=name, arguments=arguments)]) # --------------------------------------------------------------------------- # diff --git a/tests/test_memory.py b/tests/test_memory.py index ee0d55b..dcf0e7d 100644 --- a/tests/test_memory.py +++ b/tests/test_memory.py @@ -2,6 +2,7 @@ import pytest +from agentling.errors import MemoryLoadError from agentling.memory import ( ActionStep, FinalStep, @@ -9,7 +10,6 @@ TaskStep, ToolResult, ) -from agentling.errors import MemoryLoadError from agentling.models import ChatMessage, ToolCall, Usage diff --git a/tests/test_models.py b/tests/test_models.py index 7fded33..86f4716 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -156,13 +156,13 @@ def test_agglomerate_concatenates_content() -> None: def test_agglomerate_merges_tool_call_fragments() -> None: deltas = [ - Delta(tool_calls=[ToolCallDelta(index=0, id="c1", name="wx", arguments='{"ci')]), + Delta( + tool_calls=[ToolCallDelta(index=0, id="c1", name="wx", arguments='{"ci')] + ), Delta(tool_calls=[ToolCallDelta(index=0, arguments='ty": "Paris"}')]), ] msg = agglomerate_deltas(deltas) - assert msg.tool_calls == [ - ToolCall(id="c1", name="wx", arguments={"city": "Paris"}) - ] + assert msg.tool_calls == [ToolCall(id="c1", name="wx", arguments={"city": "Paris"})] def test_agglomerate_groups_parallel_calls_by_index() -> None: @@ -190,7 +190,9 @@ def test_agglomerate_rejects_invalid_tool_json() -> None: def test_agglomerate_rejects_non_object_tool_args() -> None: deltas = [ - Delta(tool_calls=[ToolCallDelta(index=0, id="c1", name="f", arguments="[1, 2]")]) + Delta( + tool_calls=[ToolCallDelta(index=0, id="c1", name="f", arguments="[1, 2]")] + ) ] with pytest.raises(ModelOutputError, match="must be a JSON object"): agglomerate_deltas(deltas) @@ -231,7 +233,9 @@ async def fake_create(**kwargs: Any) -> Any: function=SimpleNamespace(name="get_weather", arguments='{"city": "Paris"}'), ) return SimpleNamespace( - choices=[SimpleNamespace(message=SimpleNamespace(content=None, tool_calls=[tc]))], + choices=[ + SimpleNamespace(message=SimpleNamespace(content=None, tool_calls=[tc])) + ], usage=SimpleNamespace(prompt_tokens=1, completion_tokens=1), ) @@ -250,11 +254,15 @@ async def fake_create(**kwargs: Any) -> Any: async def test_stream_yields_deltas_then_agglomerates() -> None: chunks = [ SimpleNamespace( - choices=[SimpleNamespace(delta=SimpleNamespace(content="Hel", tool_calls=None))], + choices=[ + SimpleNamespace(delta=SimpleNamespace(content="Hel", tool_calls=None)) + ], usage=None, ), SimpleNamespace( - choices=[SimpleNamespace(delta=SimpleNamespace(content="lo", tool_calls=None))], + choices=[ + SimpleNamespace(delta=SimpleNamespace(content="lo", tool_calls=None)) + ], usage=None, ), SimpleNamespace( # final usage-only chunk @@ -284,7 +292,9 @@ async def gen() -> AsyncIterator[Any]: async def test_retry_then_succeeds() -> None: calls = 0 result = SimpleNamespace( - choices=[SimpleNamespace(message=SimpleNamespace(content="ok", tool_calls=None))], + choices=[ + SimpleNamespace(message=SimpleNamespace(content="ok", tool_calls=None)) + ], usage=SimpleNamespace(prompt_tokens=1, completion_tokens=1), ) diff --git a/tests/test_skills.py b/tests/test_skills.py index d485e30..d0cabc2 100644 --- a/tests/test_skills.py +++ b/tests/test_skills.py @@ -97,15 +97,7 @@ def test_from_path_empty_tools_key_yields_no_tools(tmp_path: Path) -> None: def test_from_path_preserves_horizontal_rule_in_body(tmp_path: Path) -> None: folder = _write_skill( tmp_path / "s", - "---\n" - "name: s\n" - "description: d\n" - "---\n" - "Section one\n" - "\n" - "---\n" - "\n" - "Section two\n", + "---\nname: s\ndescription: d\n---\nSection one\n\n---\n\nSection two\n", ) skill = Skill.from_path(folder) @@ -222,9 +214,7 @@ def test_resolve_tool_unknown_attribute_raises() -> None: # Skill.load_tools # --------------------------------------------------------------------------- # def test_load_tools_empty_returns_empty_list() -> None: - skill = Skill( - name="x", description="y", instructions="", path=Path("."), tools=[] - ) + skill = Skill(name="x", description="y", instructions="", path=Path("."), tools=[]) assert skill.load_tools() == [] @@ -267,12 +257,7 @@ def test_from_path_then_load_tools_end_to_end(tmp_path: Path) -> None: # Bundled example # --------------------------------------------------------------------------- # def test_bundled_code_reviewer_example_loads() -> None: - example = ( - Path(__file__).parent.parent - / "examples" - / "skills" - / "code-reviewer" - ) + example = Path(__file__).parent.parent / "examples" / "skills" / "code-reviewer" skill = Skill.from_path(example) diff --git a/uv.lock b/uv.lock index 74a1ad3..4685fc4 100644 --- a/uv.lock +++ b/uv.lock @@ -1,10 +1,14 @@ version = 1 revision = 3 requires-python = ">=3.11" +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version < '3.15'", +] [[package]] name = "agentling" -version = "0.0.1" +version = "0.1.0" source = { editable = "." } dependencies = [ { name = "openai" }, @@ -13,8 +17,10 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "mypy" }, { name = "pytest" }, { name = "pytest-asyncio" }, + { name = "ruff" }, { name = "types-pyyaml" }, ] @@ -26,8 +32,10 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ + { name = "mypy", specifier = ">=1.14.0" }, { name = "pytest", specifier = ">=9.1.1" }, { name = "pytest-asyncio", specifier = ">=1.4.0" }, + { name = "ruff", specifier = ">=0.9.0" }, { name = "types-pyyaml", specifier = ">=6.0" }, ] @@ -53,6 +61,47 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", size = 124875, upload-time = "2026-06-24T20:56:04.413Z" }, ] +[[package]] +name = "ast-serialize" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/ad/0d70a3a2d6e01968d985415259e8ec7ad3f777903f9b1c1f3c8c44642c60/ast_serialize-0.6.0.tar.gz", hash = "sha256:aadd3ffcf4858c9726bf3515f7b199c7eadbe504f96028e4a87172c0da65a8fe", size = 61489, upload-time = "2026-06-30T20:02:55.555Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/12/3e5f575f156555547c250a8b0d1347517a3a20fc7f4492e9703a69d4f45e/ast_serialize-0.6.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:a7520b672827885bafeae7501f684d14d47d17e5f45256f9df547686cca52264", size = 1177640, upload-time = "2026-06-30T20:02:06.708Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a4/921a9e27951627983b0f368859ea00f8330a551dc0bf4c2fdcb11855a98b/ast_serialize-0.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a14191beec7e0c078d2fc1f6edc0aee88bcd4db9f18e1bc9f8052b559c22dddc", size = 1168111, upload-time = "2026-06-30T20:02:08.366Z" }, + { url = "https://files.pythonhosted.org/packages/00/69/950cf404de7b8782cf95e5c1237e25e2aa46177b287f39f9eeddf481fd6f/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32ef62ec34cf6be20ad77d4799556638fbdf187f3ae10698dfb20ef9f2c89516", size = 1227656, upload-time = "2026-06-30T20:02:09.843Z" }, + { url = "https://files.pythonhosted.org/packages/4c/a8/46f8f6a6479d9d2273980957bb091a506c55f5b95d3c029ee58518a78407/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:13b7769970a39983b0adf2f38917b1cd3b8946f76df045756c3d741bc689f089", size = 1227706, upload-time = "2026-06-30T20:02:11.367Z" }, + { url = "https://files.pythonhosted.org/packages/b7/b9/9ac415bda0a40e49eab8fea3b2741c19c98bb84d57d62c4cfc6230eb67be/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6f7a408601bb3edaefb3bc67a4c01f5235e3253653b6a5729a2ee2382b35341c", size = 1431705, upload-time = "2026-06-30T20:02:12.737Z" }, + { url = "https://files.pythonhosted.org/packages/e5/06/8807115d441444879f7561b5eede5ac18fc80392f11826d61ccf31f503b1/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8670bfa51208a2c0c8d138928e40e998fab158f9200d53bb80c088b5b8eda7b8", size = 1249533, upload-time = "2026-06-30T20:02:14.571Z" }, + { url = "https://files.pythonhosted.org/packages/3e/c0/c2ba82ef9618650357d9421a1fdb27ffec862a7f57e8e2de82a3ccd11e12/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4826809eb8597a8cd59fd924b6d7c285b8969a1e0007e2cb652cab62376270f", size = 1252619, upload-time = "2026-06-30T20:02:16.219Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a7/fa31d52dd4102cede29fb9634e98d214129b2783b4f95528c6dc6a8f6587/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:577a6c189068686869f5f1ddc38363f3ae1808a4753b577266f9202071a7bb66", size = 1242983, upload-time = "2026-06-30T20:02:17.813Z" }, + { url = "https://files.pythonhosted.org/packages/b1/20/ddf742b5ad3c4bafd3466f2265037cfd99bc1b9a5ee46a5d58c90d523242/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:085de7f62dc9cc247eb01e965a362707d1d90b1d89a82c5bf78301a60a3c417b", size = 1296148, upload-time = "2026-06-30T20:02:19.146Z" }, + { url = "https://files.pythonhosted.org/packages/24/cb/9f6f217cce8b3b632c5568b478d195a35e79dce4dbe309438cb89ba6ea4f/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9f8a8b78b13173de6a9ec22111d9be674874cd5bdccda04f14ae5ebc2bef403a", size = 1403826, upload-time = "2026-06-30T20:02:20.696Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f8/9d16d4f0107a183924425cc0e7618d8bf76f96b45afa9ff19f924ed1ad57/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f2ff3baffc3a29c1f15bc9098aa0c09763410262d5e6cef42116f7356c184554", size = 1502943, upload-time = "2026-06-30T20:02:22.034Z" }, + { url = "https://files.pythonhosted.org/packages/80/dd/bbc1c38756350dddf7e24acae1c9482ef42051c267417e019aecc1ed4075/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0067b25fce104eaae5b88383de9ab803faeb671831e14ca698b771b356e2600f", size = 1497632, upload-time = "2026-06-30T20:02:23.517Z" }, + { url = "https://files.pythonhosted.org/packages/42/7e/9daffefcf5b97e6bb4c3e0b3c024c1aee9722f23d3cf7cd2ff80d6fb4a40/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c617417f9cbb0cb144f6283c3cbe0d2e0f01beaf9f608f662b21191058a626ec", size = 1448858, upload-time = "2026-06-30T20:02:24.889Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1f/f9baaab81a677ea0af7d2458cac2f94ebcc85958f8a3c15ba9d9e5dab653/ast_serialize-0.6.0-cp314-cp314t-win32.whl", hash = "sha256:5337cb256dcea3df9288205213d1601581536526b8f4da44b6974f1180f3252a", size = 1052600, upload-time = "2026-06-30T20:02:26.263Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1f/41b535866519512d8cf6669cb2cff7823b7672bb6279c0333b4ff89d7d9f/ast_serialize-0.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2d947e45cafc4b09bd7528917fa84c517654a43de173c79785574b7b3068ac24", size = 1095570, upload-time = "2026-06-30T20:02:27.639Z" }, + { url = "https://files.pythonhosted.org/packages/50/64/e472fe3e3a2d33d874b987e8518aedf24562919e3b6161a4fa1797e89c0f/ast_serialize-0.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6e15ec740436e1a0d62de848641abe5f3a2f89a7f94907d534795ac91bbacf14", size = 1067267, upload-time = "2026-06-30T20:02:28.949Z" }, + { url = "https://files.pythonhosted.org/packages/52/19/ac8348ae8711c9b5ae834634f635780cab62a0f5e6f988882e048b89c2ae/ast_serialize-0.6.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:093cb8bb91b720d8523580498d031791bb1bbaa048599c3d21085d380e11a596", size = 1185367, upload-time = "2026-06-30T20:02:30.427Z" }, + { url = "https://files.pythonhosted.org/packages/c1/f6/ec7ec652c51db77c2f61d8573338e13e4704303265ccc658cb4031d9f354/ast_serialize-0.6.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:e61580a69faf47e3689795367ed211f2a10fd741478cc0f36a0f128793360aad", size = 1178657, upload-time = "2026-06-30T20:02:31.964Z" }, + { url = "https://files.pythonhosted.org/packages/6f/02/613a7534a41d0122f37d1e0c64aa8ac78bfb831f8c92f6db057a311abb3c/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:305802f2ce2a7c4e87835078ea85c58b586ddda8095b92fe2ead9364ae19c80a", size = 1238620, upload-time = "2026-06-30T20:02:33.664Z" }, + { url = "https://files.pythonhosted.org/packages/4d/21/087957bba486242afc52f49b2d9e21c9dad00289356cf9efe67084015a9d/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c7b8b8f0c42f752ea00b2b7d7c090b3f80d9c1c5c75cadf16423790a0cc74081", size = 1236075, upload-time = "2026-06-30T20:02:34.936Z" }, + { url = "https://files.pythonhosted.org/packages/82/04/78128bbb170071c2c72a210a181f1c00e11cc1cec60a8beef747b07f9201/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cd5b91b9e6f2356ace3a556963b0cd783b395fbbb0bb17b4defc283415466e77", size = 1441348, upload-time = "2026-06-30T20:02:36.245Z" }, + { url = "https://files.pythonhosted.org/packages/64/64/62fb99d6faf199b4c3e5b08a07136e9a0d7664bb249c6de3670e5b63e9b6/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4d6ef91590258ada18909b9caea344dac4de2013906b035473cd674a43f4b790", size = 1258580, upload-time = "2026-06-30T20:02:37.53Z" }, + { url = "https://files.pythonhosted.org/packages/ca/87/b4d6c38e0ccd5e85dc54cecdf933a152c60b28fe5d993a6d8a72fa6d5896/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcbed41e9386059fc0261d602445ede0976c2ecec2939688bcbcb9ed0b6f28b7", size = 1261693, upload-time = "2026-06-30T20:02:39.123Z" }, + { url = "https://files.pythonhosted.org/packages/0e/4b/3676ca2191f39bafb75f93f99b2f429ec464586158fece2165f3572805dc/ast_serialize-0.6.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:cdc4e6f930b9090c2f92c9036ad12ffb8e6e44d4a5ba06f1458a05d60f203f7b", size = 1252517, upload-time = "2026-06-30T20:02:40.511Z" }, + { url = "https://files.pythonhosted.org/packages/f3/58/494ef8c4b4acb2f4a265ac934caf45f792a08fe27d6b853de35ad991941a/ast_serialize-0.6.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:897ac47b5637be41c0c07061c8a912fafa967ef1dc73fa115e4bfa70882a093b", size = 1304843, upload-time = "2026-06-30T20:02:41.961Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f2/13736d920ab3d49bbee80ef1a277dd7b7aaf3b3545efd9d2a8114fe05525/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c4af9a1386166e40ed01464991806f89038a2d89782576c7774876fa77034e32", size = 1413698, upload-time = "2026-06-30T20:02:44.179Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5a/e046f3899e2acba4677d7427b76431443a1aa1a0e583dfb05b55b69d55cf/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:c901adbd750029b9ac4ad3d6aa56853e0ad4875119fbf52b7b8298afc223828b", size = 1512209, upload-time = "2026-06-30T20:02:45.584Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c7/e42aaca7bb2d22a7c06d5a8c7930086c5a334e93d716e6fa5e6647a4515f/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:3ae22a366b752ab4496191525b78b097b5b72d531752e3c1dd7e383a8f2c8a1a", size = 1508464, upload-time = "2026-06-30T20:02:46.942Z" }, + { url = "https://files.pythonhosted.org/packages/95/93/5524a3dc6c3f593de3228ed9cbef73afa047625b7000ec21b7f58e6eb4d4/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4ed29121da8b3fdc291002801a1de0f76248fa07dce89157a5f277842cf6126e", size = 1457164, upload-time = "2026-06-30T20:02:48.294Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c0/36a6ffb4d653cf621427b4c4928671f53ad800c453474de2b82564a44ad9/ast_serialize-0.6.0-cp39-abi3-pyemscripten_2026_0_wasm32.whl", hash = "sha256:b1dac4e09d341c1300ba69cdcbe62867b32a8c75d90db9bf4d083bec3b039f0b", size = 863014, upload-time = "2026-06-30T20:02:49.742Z" }, + { url = "https://files.pythonhosted.org/packages/09/c7/7d5ad8b49e1278e1c2a1e0274bd7850560b3f09313aa00c13bc8d5544792/ast_serialize-0.6.0-cp39-abi3-win32.whl", hash = "sha256:82c312a7844d2fdeb4d5c48bd3d215bf940dafd4704e1a9bcf252a99010a99b1", size = 1063165, upload-time = "2026-06-30T20:02:50.98Z" }, + { url = "https://files.pythonhosted.org/packages/47/ae/6710c14ecb276031cf10249f6adf5a59e2d3fdb3b5183bd59f70524067ee/ast_serialize-0.6.0-cp39-abi3-win_amd64.whl", hash = "sha256:113b58346f9ceb664352032770caca817d4a3c86f611c6088e6ef65ddaa70f0e", size = 1101444, upload-time = "2026-06-30T20:02:52.554Z" }, + { url = "https://files.pythonhosted.org/packages/66/40/c53deb2cd0c9b0fb636d24d9f40924cf2e65028e6b20b10cd5c1eeb2c730/ast_serialize-0.6.0-cp39-abi3-win_arm64.whl", hash = "sha256:ccd132fe8db56f61fe743b1f644d01b8d65b83248a8da506f3132bda86d6ed5e", size = 1072965, upload-time = "2026-06-30T20:02:54.097Z" }, +] + [[package]] name = "certifi" version = "2026.6.17" @@ -221,6 +270,141 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/f7/18a1afcd64f35314b68c1f23afcd9994d0bc13e65cc77517afff4e83986d/jiter-0.16.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64d613743df53199b1aa256a7d328340da6d7078aac7705a7db9d7a791e9cfd2", size = 343885, upload-time = "2026-06-29T13:05:12.087Z" }, ] +[[package]] +name = "librt" +version = "0.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c6/e0/dbd0f2a68a1c1a1991eb7921ff6014465d56608cdc9a9fb468a616210a37/librt-0.12.0.tar.gz", hash = "sha256:cb26faedbd09c6130e9c1b64d8000efec5076ffd18d606c6cd1cf02730e6d8b0", size = 203841, upload-time = "2026-06-30T16:14:29.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/ab/628490f42d1eba82f3c7e5821aa62013e6df7f525b7a9e92c048f8d1cc1c/librt-0.12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3f13c1e8563102c2b17581cf37fcb2c6dae7ad485ccea93ae46258998c25f9a1", size = 143821, upload-time = "2026-06-30T16:12:23.248Z" }, + { url = "https://files.pythonhosted.org/packages/38/5f/793e8b6f4b6ac16e7d7198478c0af3670606fbb535c768d5f3e954781423/librt-0.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d1ddff067610a122387024c4df527493b909d41e54a6e5b2d0e6c1041d6dfa09", size = 148442, upload-time = "2026-06-30T16:12:24.582Z" }, + { url = "https://files.pythonhosted.org/packages/ad/92/c780fe37a9e0982f3bd8fd9a631d6b95d09a5a7201c6c50366ce843b7e42/librt-0.12.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8dc7ebb5f3eec062398e9d0ef1938acd21b589e74286c4a8906d0183318d91b", size = 478276, upload-time = "2026-06-30T16:12:26.101Z" }, + { url = "https://files.pythonhosted.org/packages/41/bb/226d444bc20d7dff4a19ec6c1ff2c13a76385eebddb59c9c00c923b67536/librt-0.12.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:198de569ea9d5f6f33808f1c00cc3db9de62bf4d6deafa3b052bd08255083038", size = 472337, upload-time = "2026-06-30T16:12:27.83Z" }, + { url = "https://files.pythonhosted.org/packages/12/79/98ac0840ee90a75d4e1155c79062860b12ccca508587ff2119fc086965f2/librt-0.12.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e958678a8bca56016aedc891b391c0e0813ea382a874b54a2c1b313c1d232720", size = 502087, upload-time = "2026-06-30T16:12:29.443Z" }, + { url = "https://files.pythonhosted.org/packages/6f/72/a6b1a0d080606a7f5f646b79a1496f21d709f8563877759ace9ce5adad73/librt-0.12.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575a6eca68c8437ed4a8e0f534e31d74b562ba1049a0ee4b5f09e114bcc21be1", size = 493202, upload-time = "2026-06-30T16:12:31.077Z" }, + { url = "https://files.pythonhosted.org/packages/69/cf/e1b036b45f2fc272205ee18bf272b47e8d684bf1a75af26db440c7504359/librt-0.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:86f241c50dc9e9a3f0db6dbb37a607c8205aa87b920802dabbd50b70d40f6939", size = 514139, upload-time = "2026-06-30T16:12:33.032Z" }, + { url = "https://files.pythonhosted.org/packages/40/34/b193b3e6985469a2f8afa86c90012329c86480b6ff4f2e4bd7b5b937e134/librt-0.12.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:113417b934fbf38220a9c7fe94578cefbe7dbb047adcb75aa197905af2b13724", size = 519486, upload-time = "2026-06-30T16:12:34.996Z" }, + { url = "https://files.pythonhosted.org/packages/31/9e/7de4947b1695f247c813f833e3c1e7b77b52e52a7dba2c35411cf806b58e/librt-0.12.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:762f17c0eb6b5d74e269126996cea8a89e35ab6464c5151619163abcd8623ae2", size = 499609, upload-time = "2026-06-30T16:12:36.663Z" }, + { url = "https://files.pythonhosted.org/packages/59/11/f3730e04e758b1fbf215359062ad2d5b6bd0b0ab5ac46b1c140628795be7/librt-0.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6aa93b3bd7f7588c628f6e9bf66485d3467fd9a1ccdb8975b770178f39f35697", size = 542205, upload-time = "2026-06-30T16:12:38.56Z" }, + { url = "https://files.pythonhosted.org/packages/1f/8f/710453617eabe20e18433864f335534c8aff63fbc68d8cd9dbc70a3d08f6/librt-0.12.0-cp311-cp311-win32.whl", hash = "sha256:aaa04b44d4fe86d824616b1f9c13e34c7c01ec0c96dd2abc4f59423696f788e2", size = 98067, upload-time = "2026-06-30T16:12:40.102Z" }, + { url = "https://files.pythonhosted.org/packages/42/53/401bff50a56e95daf151d911c99adf5732af2190e8f4d11886c9a229103c/librt-0.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:9aaeeddb8e7e4ae3bb9f944e0e618418cb91c0071d5ddbfcc3584b3cf59d39f0", size = 118346, upload-time = "2026-06-30T16:12:41.388Z" }, + { url = "https://files.pythonhosted.org/packages/e5/9a/a3a9078fe88bfc2d2d99dcf1c18593938ae830089cf84c3b2532a6c49d63/librt-0.12.0-cp311-cp311-win_arm64.whl", hash = "sha256:18a2402fa3123ab76ecca670e6fb33038fde7c1e91181b885226ec4d30af2c2c", size = 104760, upload-time = "2026-06-30T16:12:43.112Z" }, + { url = "https://files.pythonhosted.org/packages/d5/1a/5bec493821b0e85b91de4f234912b50133d1aedb875048eef27938ec3f96/librt-0.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9bce19aa7c05f91c989f9da7b567f81d21d57a2e6501e2b811aa0f3f79614c1a", size = 146756, upload-time = "2026-06-30T16:12:44.395Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d0/cc04b48a57c1f275387f5578847214c4a6c21bfb24c6c8c8d6ba753fe403/librt-0.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0ace09f5bf4d982fe726015f102fb856658b41580597104e301e630ed1d8d86", size = 145537, upload-time = "2026-06-30T16:12:45.95Z" }, + { url = "https://files.pythonhosted.org/packages/9e/10/c02325556beb2aa158c9e549ddade8cc9a23b36cdad14756dbed730c1ff1/librt-0.12.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d007efe9243ede81ce75990ad7aa172da1e2024144b3eff17ba46a5fff1fff3c", size = 488637, upload-time = "2026-06-30T16:12:47.658Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9e/7b49ca1c30baa9c8df96024aa09a97c35a97455e36004c9b5311703c56f3/librt-0.12.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:ad324a5e4858388a4864915b90a42efc8b374376393f14b9940f2454e791912b", size = 483651, upload-time = "2026-06-30T16:12:49.283Z" }, + { url = "https://files.pythonhosted.org/packages/4d/71/03c8c8cec39645fda451132ff9d6d662fc5aea42a1a188a77a4fddb35906/librt-0.12.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10a40cf74cdd97b6f8f905056db73f5d459783de2ca04c6ebd1bf47652818e7e", size = 518359, upload-time = "2026-06-30T16:12:50.999Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ec/a9f357f94bbcba92277d22af22cff42ef706ae5d9d6d58b69bebf3a67954/librt-0.12.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:92e61c09de95217ae02a9d17f4f66cf073253cdc51bcfdc0f15c62c9a70baa85", size = 509510, upload-time = "2026-06-30T16:12:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/7a/34/717055325d028743aa01a7691ad59a63352a26a8ff2e7eeb0c9249514150/librt-0.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0461344061d6fc3718940f5855d95647831cef6d03a6c7506897f98222784ad4", size = 527302, upload-time = "2026-06-30T16:12:54.244Z" }, + { url = "https://files.pythonhosted.org/packages/95/f8/7612eeedb3395d92f7c6a84dca5f15e282d650483a4dc01aa5b9cffdfda3/librt-0.12.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e6dfe89074732c9287b3c0f5a6af575c9ede380a788013876cc7b14fe0da0361", size = 532568, upload-time = "2026-06-30T16:12:55.74Z" }, + { url = "https://files.pythonhosted.org/packages/79/1e/a9afe85d5bb8b65dc27be3809ed1d69082079e1e9717fd2c66aa9939600c/librt-0.12.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9efed79d51ad1383bba0855f613cca7aa91c943e709af2413ac7f4bb9936ce08", size = 521579, upload-time = "2026-06-30T16:12:57.884Z" }, + { url = "https://files.pythonhosted.org/packages/b3/1e/93aebb219d52c37ea578f83b0588cd7b040974e464d4e435086a48b4dc4d/librt-0.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1eac6cc0e23e448fb3c1446ed85ff796afb616eed5897c978d35dbec030b7c7c", size = 558743, upload-time = "2026-06-30T16:12:59.577Z" }, + { url = "https://files.pythonhosted.org/packages/3c/85/1680c0ec332f238e3145c5608d313ab0a43281e210a5dd87e3bc3cc25631/librt-0.12.0-cp312-cp312-win32.whl", hash = "sha256:0ab8ee0210047ae86ca023ccfbfe3df82077fd1c9bc021aebbf37d993ef64af0", size = 99200, upload-time = "2026-06-30T16:13:01.015Z" }, + { url = "https://files.pythonhosted.org/packages/30/0e/abca12d8904875aa2ad66327390a3f7b1b75ebc43c0a00fc763cecf32ea5/librt-0.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:51c8bfa12632c81b94401c101bcedd0c56c3a1f8fa3273ca3472b28cd2f54003", size = 119390, upload-time = "2026-06-30T16:13:02.493Z" }, + { url = "https://files.pythonhosted.org/packages/32/a5/4203481b6d3a3bb348c82ac71abf1fcb4cb3ae8422a24a8dee4cd3ac5bd7/librt-0.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:5eebd451f5def089369ba6d8ff0291303d035e8154f9f26f7633835c5b029ade", size = 105117, upload-time = "2026-06-30T16:13:03.952Z" }, + { url = "https://files.pythonhosted.org/packages/f2/87/568d948c8079c9ff3c9e8110cf85f1eb70218e1209af29d0b7b89aa4a60c/librt-0.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8d9a55760a34ae5ce70434aabb6a6c61c6c44a0ec58ca1cfd9cd86e4745d417d", size = 146808, upload-time = "2026-06-30T16:13:05.417Z" }, + { url = "https://files.pythonhosted.org/packages/e7/1d/bea471ecea210088847bb5f3c4b4b424d596518934c06679b78ca85d6e63/librt-0.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ff0b197e338b4cf432873e0d6ef025213fdea85311ec4d87d2ea88c28adf2409", size = 145503, upload-time = "2026-06-30T16:13:07.023Z" }, + { url = "https://files.pythonhosted.org/packages/eb/9e/984ad422b56de95fdce158f06b051655373784ebea0aba9a7fcbc41614d1/librt-0.12.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e69f120a20b69e2539d603bbd4d62db38399b10f8bf73a1cf445038a621e8af", size = 488421, upload-time = "2026-06-30T16:13:08.492Z" }, + { url = "https://files.pythonhosted.org/packages/50/03/1a2f94009b07ea71f8e1a4cfe53370565b56da9caa341b89e0699325e9f5/librt-0.12.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:fde3cde595e947fc8e755b0a21f919a1622483d07c662d00496e040773d22591", size = 483488, upload-time = "2026-06-30T16:13:10.169Z" }, + { url = "https://files.pythonhosted.org/packages/aa/3b/084bdc295823fbb6ab91670047adf8f420787f9e8794bf2d140b66dc196b/librt-0.12.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d977447315fa09ea4e8c7ae9b4e22f7659b5128161c1fd55ff786b5349f73503", size = 518428, upload-time = "2026-06-30T16:13:11.681Z" }, + { url = "https://files.pythonhosted.org/packages/c9/22/5a307390b93a115ffbecd95c64eecb4e56269680e45e9415ada7285f2cf4/librt-0.12.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7ffac8a67e4143cea9a549d4822b93bc0bbaad73fc25aa0ab0ba5ec27d178677", size = 509744, upload-time = "2026-06-30T16:13:13.217Z" }, + { url = "https://files.pythonhosted.org/packages/b5/90/83f3cb6184f5d669660717b4b2e317c9ddaccf7ca5bb97f2196deac1a3b7/librt-0.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:94af1ed773ff104ef08ef3d669a0ba9d3a5916c609eb698cffe5d5476d66ff9b", size = 527749, upload-time = "2026-06-30T16:13:15.277Z" }, + { url = "https://files.pythonhosted.org/packages/7d/3b/f162be5cc88d47378e3a20776fe425fa1c2bece755da15e2783ebf06d3d6/librt-0.12.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:548199d21d22fb26398dfbbe0ba953a52465c66f3a49f38e6fddce1b127faf53", size = 532582, upload-time = "2026-06-30T16:13:17.074Z" }, + { url = "https://files.pythonhosted.org/packages/c9/28/6c5d2f6b7232fd24f284fc4cab37a459fe69a9096a09942f44cc5c55e073/librt-0.12.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c8f1f413b966a9dd3ecf80cd337b0ad7bb3de2474a4ff448ed3ebabfc3f803fc", size = 522235, upload-time = "2026-06-30T16:13:18.823Z" }, + { url = "https://files.pythonhosted.org/packages/a9/1c/bd115360587fdc22c8ae8fac14c040a556b442e2965d4370d2cf274c8b95/librt-0.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:55f13f95b629be5b6ab38918e439bf14169d6f9a8deaae55e0c14e12fb0c74b9", size = 559055, upload-time = "2026-06-30T16:13:20.509Z" }, + { url = "https://files.pythonhosted.org/packages/fe/5a/c26f49f576437014825a86faea3cec60c1ed17f976abd567b6c12b8e35a7/librt-0.12.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:8b2dc079dfe29e77a47a19073d2040fa4879aa3656501f1650f8402ddce0313c", size = 79809, upload-time = "2026-06-30T16:13:22.401Z" }, + { url = "https://files.pythonhosted.org/packages/69/0b/a55244261d9ad7375ac039b8af06d42602722e2e8b8d8d6b86e4a3888c02/librt-0.12.0-cp313-cp313-win32.whl", hash = "sha256:da58944be8270f2bfee628a9a2a60c1cf6a12c8bea8e2c9b6edf3e5414ca7793", size = 99308, upload-time = "2026-06-30T16:13:23.661Z" }, + { url = "https://files.pythonhosted.org/packages/c9/bf/ed9465e58d44c5a5637795547d0841c8934aab905ea452cac1adf14672cf/librt-0.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:1db4be3037e4ce065a071fa7deee93e78ebc25f448340a02a6c1c0b82c37e383", size = 119438, upload-time = "2026-06-30T16:13:25.188Z" }, + { url = "https://files.pythonhosted.org/packages/c0/44/3cad652aeb892e6e8ffe48d0fafa2bc652f28ec7ed3f4403fcbb1be4f948/librt-0.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:05fd2542892ad770b5dd45003fd080477cf220b611d3ee59b0792097eb0873a9", size = 105118, upload-time = "2026-06-30T16:13:26.533Z" }, + { url = "https://files.pythonhosted.org/packages/0e/51/3a0e05618c12423b6fc5141b590ec02a6efb645833edc8736a6c7b46d1ec/librt-0.12.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:b37ee42e09722284a6d9288fe44a191f7276060a3195939bb77c6502058dbb34", size = 145579, upload-time = "2026-06-30T16:13:27.909Z" }, + { url = "https://files.pythonhosted.org/packages/77/9e/fd399d099dfb4020f3f7c34e7e6210c389fa89f7d79ca92f5afb0395f278/librt-0.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ade11988728b3e4768dadc5696e82c60e9b35fc95335a9b4d1f5d69e753ccec7", size = 150139, upload-time = "2026-06-30T16:13:29.357Z" }, + { url = "https://files.pythonhosted.org/packages/7a/ee/610239fbd8c4b005443664c5d4c3bc1717daedd8c71369bf45011aa87194/librt-0.12.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f351ed425380e39bd86df382578aa5b8c5b98e2e265112de7379e7d030258150", size = 480457, upload-time = "2026-06-30T16:13:30.78Z" }, + { url = "https://files.pythonhosted.org/packages/0c/10/ceddc9010f26c541444be36e1153a79b64626694db2d33a524c719fa3e46/librt-0.12.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:857d2163e088c868967717ace8e980017fd868a735f3de010412af02bdc30319", size = 479002, upload-time = "2026-06-30T16:13:32.398Z" }, + { url = "https://files.pythonhosted.org/packages/4e/f1/b1523d9718e8192e5403e6b41a02742e17ba554369f0729b9f30ab590e2d/librt-0.12.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2befc80aa5f2f5b93f28abaaf11feff6677931dd548320e44c52deaa9399744", size = 510527, upload-time = "2026-06-30T16:13:34.615Z" }, + { url = "https://files.pythonhosted.org/packages/f6/0e/0f3ff43befb18a531615736791e52fb67eaa71ff7b89e6e5f7004b64cc6e/librt-0.12.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:be3694dcfa97c6715dd19ac73d3e1b21a805514a5785663e57fecacd3ff64e5a", size = 500988, upload-time = "2026-06-30T16:13:36.408Z" }, + { url = "https://files.pythonhosted.org/packages/a8/1a/0278ea4a9e599dc507c43839a87f2c764ad04bf69418e2d763d58659e55f/librt-0.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2d5f67e86f45638843d025b0828f2e9e55fc45ff9180d2618ccdeaf72a796050", size = 519318, upload-time = "2026-06-30T16:13:37.883Z" }, + { url = "https://files.pythonhosted.org/packages/59/55/090e10e62be2f35265e41601337f83ac9f83be9aca1bf92692e3a82effdd/librt-0.12.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:64572c85e4ab7d572c9b72cd76b5f90b21181b1459fa6b1aac6f8958c4fcff31", size = 527127, upload-time = "2026-06-30T16:13:39.682Z" }, + { url = "https://files.pythonhosted.org/packages/1f/34/8052c9ec678be6ba751279947831f089aa69b009000b985ce91d1979669a/librt-0.12.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:8b961912b0e688c1eb4658a46bdb0606b31918d65597fbe7356ca83aa653ffcc", size = 509766, upload-time = "2026-06-30T16:13:41.266Z" }, + { url = "https://files.pythonhosted.org/packages/6f/f8/8761b36189e9ec8dc20b49fa84cef22852c6c41fcda56f760f7fc1360da5/librt-0.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:722375903e3f079436a7a33da51ce73931536dd041f9feb01536f05d8e010c96", size = 552043, upload-time = "2026-06-30T16:13:43.197Z" }, + { url = "https://files.pythonhosted.org/packages/c8/98/7283971ef6b70269938b49c7b25f670ec6325d252265fbcc996f9b364379/librt-0.12.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:a5a96a8f536b65ef1bf910c09e7e71647edde5111f6e1b51f413c6fba5bfe71b", size = 79472, upload-time = "2026-06-30T16:13:44.64Z" }, + { url = "https://files.pythonhosted.org/packages/c3/5e/b30940dea935e8ac5bd0e0abb1985f5274590d557ac3a252ca0d5392ce52/librt-0.12.0-cp314-cp314-win32.whl", hash = "sha256:8ffc99c356f1777c506e1b69dc303879153ae2640ba15b8f3d4448bc87139149", size = 94246, upload-time = "2026-06-30T16:13:45.962Z" }, + { url = "https://files.pythonhosted.org/packages/7d/4e/0af9fe63f35fa304da3b05688f30ff6a329bcc59581b1cc51dc87fd30141/librt-0.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:1e68fb20798f455cda41d20a306a23c901218883f17a4bab1ed6e1331b265fb7", size = 114951, upload-time = "2026-06-30T16:13:47.279Z" }, + { url = "https://files.pythonhosted.org/packages/b1/8e/843c495d7db35e13b84cd533898fa89145c40dc255da0bc316d53d631464/librt-0.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:2df534f97916cf38ec9b1ddafeb68ae1a4cd4a54775ff26a797026774c0517cf", size = 100562, upload-time = "2026-06-30T16:13:48.699Z" }, + { url = "https://files.pythonhosted.org/packages/75/30/c686d0f978d5fd6867c5bbad96b015c9445746764d1c228e16a2d30d9382/librt-0.12.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c09e581b1c2b8a62b809d4f4bd101ca3de93791e5b0ed1a14085d911be3dee3f", size = 153897, upload-time = "2026-06-30T16:13:50.017Z" }, + { url = "https://files.pythonhosted.org/packages/40/46/f6f2d77ce46628b48fb5280709013b5109cf3a2c46a2472093cdfc03519d/librt-0.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:976888d0d831402086e641018bcc3208e0a38f0835789da91f72894b2cb4161f", size = 156391, upload-time = "2026-06-30T16:13:51.462Z" }, + { url = "https://files.pythonhosted.org/packages/c2/46/cd790c7e19e460779471530ffab454541d6ea4a3b7d338cad7f16ff96995/librt-0.12.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:563c37cdb41d08fe1e3f08b201abac0e317ca18e88b91285466ee0a585797520", size = 564151, upload-time = "2026-06-30T16:13:53.146Z" }, + { url = "https://files.pythonhosted.org/packages/54/12/724559a15fb023cbdef7aee1e81fbfbc3ee22fd09009baa816cea63e3a60/librt-0.12.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:b97eb1a3140e279cc76f85b0fb92b7eb3dfbe0471260ee878bc9dc4bf9a0d649", size = 546002, upload-time = "2026-06-30T16:13:54.665Z" }, + { url = "https://files.pythonhosted.org/packages/4b/7e/f9d8c257ab4909f101c7c13734367749e782fd8625545f0343502c2f09f1/librt-0.12.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:06e0623351ab9904cf628245f99c714586f4dd23dc740b88c8bc670d8401a847", size = 584204, upload-time = "2026-06-30T16:13:56.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/33/64665810575ac23b6cb6ef364de51309b7803620c12885b6e895ebc29591/librt-0.12.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:da12f017b2e404554be14d466cd992459feaa44f252b0f18d909a85266ce1237", size = 573688, upload-time = "2026-06-30T16:13:58.1Z" }, + { url = "https://files.pythonhosted.org/packages/0f/01/27522995c6627455abc7a939d57535fb1a7836d398ccedb3d7585f46039e/librt-0.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d97f31003a5c86b9e78155a829572c3a26484064fb7ac1d9695fe628bd93d029", size = 604719, upload-time = "2026-06-30T16:13:59.831Z" }, + { url = "https://files.pythonhosted.org/packages/ee/1f/099e61b1b688551d6d2ce9d4d2ae2242a938759db8551e6cbac7f7176ee5/librt-0.12.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:bd43a6c69876aef4f04eaae3d3b99b0be64755fda274002fa445b92480bf664e", size = 598183, upload-time = "2026-06-30T16:14:01.457Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c1/050400249665503bdd5b83cec518fa7b183b609341c8dcd58161775c4226/librt-0.12.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c01755c72fca1dc6b8d5c2ed228b8e7b2ffe184675c22f0f05ebd8fe188b9250", size = 582559, upload-time = "2026-06-30T16:14:03.29Z" }, + { url = "https://files.pythonhosted.org/packages/da/d1/eef8f0e6722518b65a3d3bcd9309f9f44e208ce5d6728070820f988e7078/librt-0.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:625ae561d5fa36400856dcc27464400d047bc2d5e3446be88f437b03fefd72e4", size = 626375, upload-time = "2026-06-30T16:14:04.957Z" }, + { url = "https://files.pythonhosted.org/packages/8b/78/f0bb41a6f2bbd3c77bdcc66980dc0d69ca1192a0ecec25377afcc5e6db73/librt-0.12.0-cp314-cp314t-win32.whl", hash = "sha256:8d73191883553ee0739741544bf3b00aba2a1224e45d9580b30cbc29e21dc03b", size = 97752, upload-time = "2026-06-30T16:14:06.555Z" }, + { url = "https://files.pythonhosted.org/packages/92/24/e279c27972ab051a070237cfa45728fa51670c3f22f1a4d391711e9f4c31/librt-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e1cbb037324e759f0afa270229731ff0047772667f3cb38ef5df2cabf0175ede", size = 119562, upload-time = "2026-06-30T16:14:07.908Z" }, + { url = "https://files.pythonhosted.org/packages/06/e6/42a475bfca683b0cd5366f6dd06580062b7e567bb8534d225c877c2f14f3/librt-0.12.0-cp314-cp314t-win_arm64.whl", hash = "sha256:bca1472acbd473eff61059b4409f802c5a1bcb4cd0344d06f939df9c4c125d40", size = 104282, upload-time = "2026-06-30T16:14:09.29Z" }, +] + +[[package]] +name = "mypy" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/15/cca9d88503549ed6fedeaa1d448cdddd542ee8a490232d732e278036fbf2/mypy-2.1.0.tar.gz", hash = "sha256:81e76ad12c2d804512e9b13240d1588316531bfba07558286078bfbce9613633", size = 3898359, upload-time = "2026-05-11T18:37:36.237Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/a1/639f3024794a2a15899cb90707fe02e044c4412794c39c5769fd3df2e2ef/mypy-2.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a683016b16fe2f572dc04c72be7ee0504ac1605a265d0200f5cea695fb788f41", size = 14691685, upload-time = "2026-05-11T18:33:27.973Z" }, + { url = "https://files.pythonhosted.org/packages/3b/08/9a585dea4325f20d8b80dc78623fa50d1fd2173b710f6237afd6ba6ab39b/mypy-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1a293c534adb55271fef24a26da04b855540a8c13cc07bc5917b9fd2c394f2ca", size = 13555165, upload-time = "2026-05-11T18:32:16.107Z" }, + { url = "https://files.pythonhosted.org/packages/81/dc/7c42cc9c6cb01e8eb09961f1f738741d3e9c7e9d5c5b30ec69222625cd5f/mypy-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7406f4d048e71e576f5356d317e5b0a9e666dfd966bd99f9d14ca06e1a341538", size = 13994376, upload-time = "2026-05-11T18:32:39.256Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/285946c33bce716e082c11dfeee9ee196eaf1f5042efb3581a31f9f205e4/mypy-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e0210d626fc8b31ccc90233754c7bc90e1f43205e85d96387f7db1285b55c398", size = 14864618, upload-time = "2026-05-11T18:34:49.765Z" }, + { url = "https://files.pythonhosted.org/packages/2b/83/82397f48af6c27e295d57979ded8490c9829040152cf7571b2f026aeb9a0/mypy-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3712c20deed54e814eaaa825603bada8ea1c390670a397c95b98405347acc563", size = 15102063, upload-time = "2026-05-11T18:34:05.855Z" }, + { url = "https://files.pythonhosted.org/packages/40/68/b02dec39057b88eb03dc0aa854732e26e8361f34f9d0e20c7614967d1eba/mypy-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:fcaa0e479066e31f7cceb6a3bea39cb22b2ff51a6b2f24f193d19179ba17c389", size = 11060564, upload-time = "2026-05-11T18:35:36.494Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a8/ea3dcbef31f99b634f2ee23bb0321cbc8c1b388b76a861eb849f13c347dc/mypy-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:0b1a5260c95aa443083f9ed3592662941951bca3d4ca224a5dc517c38b7cf666", size = 9966983, upload-time = "2026-05-11T18:37:14.139Z" }, + { url = "https://files.pythonhosted.org/packages/95/b1/55861beb5c339b44f9a2ba92df9e2cb1eeb4ae1eee674cdf7772c797778b/mypy-2.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:244358bf1c0da7722230bce60683d52e8e9fd030554926f15b747a84efb5b3af", size = 14874381, upload-time = "2026-05-11T18:37:31.784Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b3/b7f770114b7d0ac92d0f76e8d93c2780844a70488a90e91821927850da86/mypy-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4ec7c57657493c7a75534df2751c8ae2cda383c16ecc55d2106c54476b1b16f6", size = 13665501, upload-time = "2026-05-11T18:34:23.063Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f3/8ae2037967e2126689a0c11d99e2b707134a565191e92c60ca2572aec60a/mypy-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8161b6ff4392410023224f0969d17db93e1e154bc3e4ba62598e720723ae211", size = 14045750, upload-time = "2026-05-11T18:31:48.151Z" }, + { url = "https://files.pythonhosted.org/packages/a0/32/615eb5911859e43d054941b0d0a7d06cfa2870eba86529cf385b052b111c/mypy-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf03e12003084a67395184d3eb8cbd6a489dc3655b5664b28c210a9e2403ab0b", size = 15061630, upload-time = "2026-05-11T18:37:06.898Z" }, + { url = "https://files.pythonhosted.org/packages/d4/03/4eafbfff8bfab1b87082741eae6e6a624028c984e6708b73bce2a8570c9d/mypy-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:20509760fd791c51579d573153407d226385ec1f8bcce55d730b354f3336bc22", size = 15288831, upload-time = "2026-05-11T18:31:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/919661478e5891a3c96e549c036e467e64563ab85995b10c53c8358e16a3/mypy-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:6753d0c1fdd6b1a23b9e4f283ce80b2153b724adcb2653b20b85a8a28ac6436b", size = 11135228, upload-time = "2026-05-11T18:34:31.23Z" }, + { url = "https://files.pythonhosted.org/packages/24/0a/6a12b9782ca0831a553192f351679f4548abc9d19a7cc93bb7feb02084c7/mypy-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:98ebb6589bb3b6d0c6f0c459d53ca55b8091fbc13d277c4041c885392e8195e8", size = 10040684, upload-time = "2026-05-11T18:36:48.199Z" }, + { url = "https://files.pythonhosted.org/packages/6e/dd/c7191469c777f07689c032a8f7326e393ea34c92d6d76eb7ce5ba57ea66d/mypy-2.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:35aac3bb114e03888f535d5eb51b8bafbb3266586b599da1940f9b1be3ec5bd5", size = 14852174, upload-time = "2026-05-11T18:31:38.929Z" }, + { url = "https://files.pythonhosted.org/packages/55/8c/aed55408879043d72bb9135f4d0d19a02b886dd569631e113e3d2706cb8d/mypy-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8de55a8c861f2a49331f807be98d90caeceeef520bde13d43a160207f8af613e", size = 13651542, upload-time = "2026-05-11T18:36:04.636Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8e/f371a824b1f1fa8ea6e3dbb8703d232977d572be2329554a3bc4d960302f/mypy-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5fdf2941a07434af755837d9880f7d7d25f1dacb1af9dcd4b9b66f2220a3024e", size = 14033929, upload-time = "2026-05-11T18:35:55.742Z" }, + { url = "https://files.pythonhosted.org/packages/94/21/f54be870d6dd53a82c674407e0f8eed7174b05ec78d42e5abd7b42e84fd5/mypy-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e195b817c13f02352a9c124301f9f30f078405444679b6753c1b96b6eed37285", size = 15039200, upload-time = "2026-05-11T18:33:10.281Z" }, + { url = "https://files.pythonhosted.org/packages/17/99/bf21748626a40ce59fd29a39386ab46afec88b7bd2f0fa6c3a97c995523f/mypy-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5431d42af987ebd92ba2f71d45c85ed41d8e6ca9f5fd209a69f68f707d2469e5", size = 15272690, upload-time = "2026-05-11T18:32:07.205Z" }, + { url = "https://files.pythonhosted.org/packages/d6/d7/9e90d2cf47100bea550ed2bc7b0d4de3a62181d84d5e37da0003e8462637/mypy-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:767fe8c66dc3e01e19e1737d4c38ebefead16125e1b8e58ad421903b376f5c65", size = 11147435, upload-time = "2026-05-11T18:33:56.477Z" }, + { url = "https://files.pythonhosted.org/packages/ec/46/e5c449e858798e35ffc90946282a27c62a77be743fe17480e4977374eb91/mypy-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:ecfe70d43775ab99562ab128ce49854a362044c9f894961f68f898c23cb7429d", size = 10035052, upload-time = "2026-05-11T18:32:30.049Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ca/b279a672e874aedd5498ae25f722dacc8aa86bbffb939b3f97cbb1cf6686/mypy-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7354c5a7f69d9345c3d6e69921d57088eea3ddeeb6b20d34c1b3855b02c36ec2", size = 14848422, upload-time = "2026-05-11T18:35:45.984Z" }, + { url = "https://files.pythonhosted.org/packages/27/e6/3efe56c631d959b9b4454e208b0ac4b7f4f58b404c89f8bec7b49efdfc21/mypy-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:49890d4f76ac9e06ec117f9e09f3174da70a620a0c300953d8595c926e80947f", size = 13677374, upload-time = "2026-05-11T18:36:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/84/7f/8107ea87a44fd1f1b59882442f033c9c3488c127201b1d1d15f1cbd6022e/mypy-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:761be68e023ef5d94678772396a8af1220030f80837a3afd8d0aef3b419666f4", size = 14055743, upload-time = "2026-05-11T18:35:18.361Z" }, + { url = "https://files.pythonhosted.org/packages/51/4d/b6d34db183133b83761b9199a82d31557cdbb70a380d8c3b3438e11882a3/mypy-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c90345fc182dc363b891350457ec69c35140858538f38b4540845afcc32b1aef", size = 15020937, upload-time = "2026-05-11T18:34:59.618Z" }, + { url = "https://files.pythonhosted.org/packages/ff/d7/f08360c691d758acb02f45022c34d98b92892f4ea756644e1000d4b9f3d8/mypy-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b84802e7b5a6daf1f5e15bc9fcd7ddae77be13981ffab037f1c67bb84d67d135", size = 15253371, upload-time = "2026-05-11T18:36:41.081Z" }, + { url = "https://files.pythonhosted.org/packages/67/1b/09460a13719530a19bce27bd3bc8449e83569dd2ba7faf51c9c3c30c0b61/mypy-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:022c771234936ceac541ebaf836fe9e2abeb3f5e09aff21588fe543ff006fe21", size = 11326429, upload-time = "2026-05-11T18:34:13.526Z" }, + { url = "https://files.pythonhosted.org/packages/40/62/75dbf0f82f7b6680340efc614af29dd0b3c17b8a4f1cd09b8bd2fd6bc814/mypy-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:498207db725cec88829a6a5c2fc771205fd043719ef98bc49aba8fb9fc4e6d57", size = 10218799, upload-time = "2026-05-11T18:32:23.491Z" }, + { url = "https://files.pythonhosted.org/packages/b2/66/caca04ed7d972fb6eb6dd1ccd6df1de5c38fae8c5b3dc1c4e8e0d85ee6b9/mypy-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d5e5cad0efeba72b93cd17490cc0d69c5ac9ca132994fe3fb0314808aeeb83e", size = 15923458, upload-time = "2026-05-11T18:35:28.64Z" }, + { url = "https://files.pythonhosted.org/packages/ed/52/2d90cbe49d014b13ed7ff337930c30bad35893fe38a1e4641e756bb62191/mypy-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ff715050c127d724fd260a2e666e7747fdd83511c0c47d449d98238970aef780", size = 14757697, upload-time = "2026-05-11T18:36:14.208Z" }, + { url = "https://files.pythonhosted.org/packages/ac/37/d98f4a14e081b238992d0ed96b6d39c7cc0148c9699eb71eaa68629665ea/mypy-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82208da9e09414d520e912d3e462d454854bed0810b71540bb016dcbca7308fd", size = 15405638, upload-time = "2026-05-11T18:33:48.249Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c2/15c46613b24a84fad2aea1248bf9619b99c2767ae9071fe224c179a0b7d4/mypy-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e79ebc1b904b84f0310dff7469655a9c36c7a68bddb37bdd42b67a332df61d08", size = 16215852, upload-time = "2026-05-11T18:32:50.296Z" }, + { url = "https://files.pythonhosted.org/packages/5c/90/9c16a57f482c76d25f6379762b56bbf65c711d8158cf271fb2802cfb0640/mypy-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e583edc957cfb0deb142079162ae826f58449b116c1d442f2d91c69d9fced081", size = 16452695, upload-time = "2026-05-11T18:33:38.182Z" }, + { url = "https://files.pythonhosted.org/packages/0f/4c/215a4eeb63cacc5f17f516691ea7285d11e249802b942476bff15922a314/mypy-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b33b6cd332695bba180d55e717a79d3038e479a2c49cc5eb3d53603409b9a5d7", size = 12866622, upload-time = "2026-05-11T18:34:39.945Z" }, + { url = "https://files.pythonhosted.org/packages/4b/50/1043e1db5f455ffe4c9ab22747cd8ca2bc492b1e4f4e21b130a44ee2b217/mypy-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:4f910fe825376a7b66ef7ca8c98e5a149e8cd64c19ae71d84047a74ee060d4e6", size = 10610798, upload-time = "2026-05-11T18:36:31.444Z" }, + { url = "https://files.pythonhosted.org/packages/0d/2a/13ca1f292f6db1b98ff495ef3467736b331621c5917cad984b7043e7348d/mypy-2.1.0-py3-none-any.whl", hash = "sha256:a663814603a5c563fb87a4f96fb473eeb30d1f5a4885afcf44f9db000a366289", size = 2693302, upload-time = "2026-05-11T18:31:29.246Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + [[package]] name = "openai" version = "2.44.0" @@ -249,6 +433,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -468,6 +661,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "ruff" +version = "0.15.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/dc/35b341fc554ba02f217fc10da57d1a75168cfbcf75b0ef2202176d4c4f2d/ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566", size = 4755489, upload-time = "2026-06-25T17:20:37.578Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/d9/2d5014f0253ba541d2061d9fa7193f48e941c8b21bb88a7ff9bbe0bd0596/ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078", size = 10839665, upload-time = "2026-06-25T17:19:44.702Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d3/ac1798ba64f670698867fcfc591d50e7e421bef137db564858f619a30fcf/ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b", size = 11208649, upload-time = "2026-06-25T17:19:48.787Z" }, + { url = "https://files.pythonhosted.org/packages/47/47/d3ac899991202095dfcf3d5176be4272642be3cf981a2f1a30f72a2afb95/ruff-0.15.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632", size = 10622638, upload-time = "2026-06-25T17:19:51.354Z" }, + { url = "https://files.pythonhosted.org/packages/33/13/4e043fe30aa94d4ff5213a9881fc296d12960f5971b234a5263fdc225312/ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd", size = 10984227, upload-time = "2026-06-25T17:19:54.044Z" }, + { url = "https://files.pythonhosted.org/packages/76/e6/92e7bf40388bc5800073b96564f56264f7e48bfd1a498f5ced6ae6d5a769/ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b", size = 10622882, upload-time = "2026-06-25T17:19:57.037Z" }, + { url = "https://files.pythonhosted.org/packages/13/7a/43460be3f24495a3aa46d4b16873e2c4941b3b5f0b00cf88c03b7b94b339/ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267", size = 11474808, upload-time = "2026-06-25T17:20:00.357Z" }, + { url = "https://files.pythonhosted.org/packages/27/a0/f37077884873221c6b33b4ab49eb18f9f88e54a16a25a5bca59bef46dd66/ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c", size = 12293094, upload-time = "2026-06-25T17:20:03.446Z" }, + { url = "https://files.pythonhosted.org/packages/a6/74/165545b60256a9704c21ac0ec4a0d07933b320812f9584836c9f4aca4292/ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae", size = 11526176, upload-time = "2026-06-25T17:20:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/86/b1/a976a136d40ade83ce743578399865f57001003a409acadc0ecbb3051082/ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b", size = 11520767, upload-time = "2026-06-25T17:20:09.191Z" }, + { url = "https://files.pythonhosted.org/packages/19/0f/f032696cb01c9b54c0263fa393474d7758f1cdc021a01b04e3cbc2500999/ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487", size = 11500132, upload-time = "2026-06-25T17:20:13.602Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f4/51b1a14bc69e8c224b15dab9cce8e99b425e0455d462caa2b3c9be2b6a8e/ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3", size = 10943828, upload-time = "2026-06-25T17:20:16.635Z" }, + { url = "https://files.pythonhosted.org/packages/71/4b/fe267640783cd02bf6c5cc290b1df1051be2ec294c678b5c15fe19e52343/ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053", size = 10645418, upload-time = "2026-06-25T17:20:19.4Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c0/a65aa4ec2f5e87a1df32dc3ec1fede434fe3dfd5cbcf3b503cafc676ab54/ruff-0.15.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4", size = 11211770, upload-time = "2026-06-25T17:20:22.033Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a4/0caa331d954ae2723d729d351c989cb4ca8b6077d5c6c2cb6de75e98c041/ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460", size = 11618698, upload-time = "2026-06-25T17:20:25.259Z" }, + { url = "https://files.pythonhosted.org/packages/10/9b/5f14927848d2fd4aa891fd88d883788c5a7baba561c7874732364045708c/ruff-0.15.20-py3-none-win32.whl", hash = "sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21", size = 10857322, upload-time = "2026-06-25T17:20:28.612Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f0/fe47c501f9dea92a26d788ff98bb5d92ed4cb4c88792c5c88af6b697dc8e/ruff-0.15.20-py3-none-win_amd64.whl", hash = "sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415", size = 11993274, upload-time = "2026-06-25T17:20:31.871Z" }, + { url = "https://files.pythonhosted.org/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca", size = 11343498, upload-time = "2026-06-25T17:20:35.03Z" }, +] + [[package]] name = "sniffio" version = "1.3.1" From 3fc412b1192c031959aa28686b61fa80711e3cee Mon Sep 17 00:00:00 2001 From: folathecoder Date: Tue, 7 Jul 2026 14:37:05 +0100 Subject: [PATCH 10/17] Document the production API and add project docs Extend the README config reference with the timeout, redaction, truncation, and context-manager knobs, refresh the dev commands, and add a security note for skill-provided tools. Add SECURITY.md (trust model and reporting), CONTRIBUTING.md, and CHANGELOG.md. --- CHANGELOG.md | 29 +++++++++++++++++++++++++++++ CONTRIBUTING.md | 35 +++++++++++++++++++++++++++++++++++ README.md | 26 ++++++++++++++++++++++---- SECURITY.md | 36 ++++++++++++++++++++++++++++++++++++ 4 files changed, 122 insertions(+), 4 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 SECURITY.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..aa7c5e9 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,29 @@ +# Changelog + +All notable changes to agentling are documented here. The format follows +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project aims +to follow [semantic versioning](https://semver.org/). + +## [0.1.0] - 2026-07-07 + +The first real release: the core framework plus a production-hardening pass. + +### Added + +- Async ReAct agent loop with a single streaming code path (`Agent` config and + factory, `AgentSession` run state); blocking and streaming `run()`. +- Provider-neutral model layer and an OpenAI-compatible adapter with retries. +- `@tool` decorator with JSON Schema generation, argument validation, and + per-tool metadata (`timeout`, `parallel_safe`, `max_output_chars`). +- Typed memory (`TaskStep` / `ActionStep` / `FinalStep`) with JSON persistence + and load validation (`MemoryLoadError`). +- Streaming events, a `print_events` renderer, and a terminal run `status` + (completed / interrupted / max_steps). +- Progressive-disclosure skills (`SKILL.md`) with a built-in `load_skill` tool. +- An exception hierarchy under `AgentlingError`, public API exports, and + `__all__`. +- Timeouts (`tool_timeout`, `model_timeout`), prompt cancellation, sync tools + run off the event loop, optional error redaction (`redact_errors`), cumulative + token usage on the final event, and a `context_manager` hook for long runs. + +[0.1.0]: https://github.com/folathecoder/agentling/releases/tag/v0.1.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..2275754 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,35 @@ +# Contributing to agentling + +Thanks for your interest in improving agentling. + +## Development setup + +agentling uses [uv](https://docs.astral.sh/uv/): + +```bash +uv sync # create the environment and install all dependencies +``` + +## The checks + +These all run in CI and must pass. Run them locally before opening a PR: + +```bash +uv run pytest # tests +uv run ruff check src tests # lint +uv run ruff format --check src tests # formatting (run `ruff format` to fix) +uv run mypy src tests # type-check +uv build # the package builds +``` + +## Guidelines + +- Keep the framework small and readable; prefer clarity over cleverness. +- Add tests for new behavior, including the failure paths. +- Type everything; the codebase is checked with mypy. +- Match the surrounding style; `ruff format` is the source of truth. +- Avoid new runtime dependencies unless they clearly earn their place. + +## Reporting bugs and vulnerabilities + +Open a GitHub issue for bugs. For security reports, see [SECURITY.md](SECURITY.md). diff --git a/README.md b/README.md index a319a55..d9ee4b8 100644 --- a/README.md +++ b/README.md @@ -286,6 +286,10 @@ Each entry point is a `"module.path:attribute"` string that must resolve to a `Tool` (a function decorated with `@tool`). You can pass skills as folder paths (strings or `Path`) or as pre-built `Skill` objects. +> **Security:** a skill's `tools:` entry point is imported, which runs that +> module's code. Load skills only from sources you trust, exactly as you would a +> Python import. See [SECURITY.md](SECURITY.md) for the full trust model. + ### Sessions and concurrency An `Agent` is immutable configuration (model, tools, skills, settings) and is @@ -571,6 +575,11 @@ instead (useful when tools share state or must not interleave). | `max_steps` | `15` | Maximum loop iterations before a forced answer. Must be at least 1. | | `step_callbacks` | `()` | Callables invoked with each `ActionStep` as it is recorded. | | `parallel_tools` | `True` | Run a turn's tool calls concurrently, or in order when `False`. | +| `tool_timeout` | `None` | Per-call time budget (seconds) for tools; a timeout becomes a recoverable observation. | +| `model_timeout` | `None` | Time budget (seconds) for each model turn; exceeding it raises `ModelError`. | +| `max_tool_output_chars` | `None` | Truncate tool observations head and tail beyond this length. | +| `redact_errors` | `False` | Hide unexpected tool-exception messages from the model and log them instead. | +| `context_manager` | `None` | Callable `messages -> messages` applied before each model call, to trim or summarize. | `OpenAIModel(...)`: @@ -596,12 +605,21 @@ The project uses [uv](https://docs.astral.sh/uv/) for environment and dependency management. ```bash -uv sync # install everything, including dev deps -uv run pytest -q # run the test suite -uv run --with ruff ruff check src tests # lint -uv run --with mypy mypy src tests # type-check +uv sync # install everything, including dev deps +uv run pytest # run the test suite +uv run ruff check src tests # lint +uv run ruff format --check src tests # formatting (run `ruff format` to fix) +uv run mypy src tests # type-check +uv build # verify the package builds ``` +## Security + +Tools and skill-provided tools run as trusted code in your process, and tool +output is fed back to the model. See [SECURITY.md](SECURITY.md) for the trust +model, the `redact_errors` and `max_tool_output_chars` knobs, and how to report +a vulnerability. + ## License [MIT](LICENSE) (c) Folarin Akinloye. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..2f3f0e7 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,36 @@ +# Security Policy + +## Reporting a vulnerability + +Please report suspected vulnerabilities privately by emailing me@folarin.dev. +Do not open a public issue for a security report. I will acknowledge receipt +within a few days and keep you posted on the fix. + +## Trust model + +agentling runs code you give it. Two boundaries are worth calling out: + +- **Tools are trusted code.** A `@tool` function runs in your process with your + privileges. Only register tools you trust, and treat the tool *arguments* + (which come from the model) as untrusted input inside the tool. +- **Skills with `tools:` execute code.** A `SKILL.md` `tools:` entry point is + imported with `importlib`, which runs the target module's top-level code. + Load skills only from sources you trust, exactly as you would a Python import. + +## Model and tool output + +- Tool return values and error messages are fed back to the model as + observations. Do not put secrets in tool return values or exception messages. + Set `Agent(redact_errors=True)` to keep unexpected exception messages out of + the model's context (the exception type is still shown, and the full detail is + logged via the `agentling` logger). +- Tool output is injected into the model's context, so treat any external data + a tool returns as untrusted: it can attempt to steer later steps (prompt + injection). Bound its size with `max_tool_output_chars`. + +## Concurrency + +An `Agent` is immutable configuration and is safe to share across concurrent +runs. Per-run state lives on an `AgentSession` (from `agent.start()`, or created +implicitly by `agent.run()`), so concurrent runs never share memory, tools, or +interrupts. From 1c8f035543b9daa2c3e6601f59dcdd50cc2d780f Mon Sep 17 00:00:00 2001 From: folathecoder Date: Tue, 7 Jul 2026 14:44:04 +0100 Subject: [PATCH 11/17] Adopt ruff format and mypy config, wire CI, bump to 0.1.0 Declare ruff and mypy configuration in pyproject and pin them as dev dependencies so CI runs the same versions instead of fetching them ad hoc. CI now also checks formatting and builds the package. Reformat the tree with ruff format. Bump the version to 0.1.0. --- src/agentling/agent.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/agentling/agent.py b/src/agentling/agent.py index ee0bc85..565a1f6 100644 --- a/src/agentling/agent.py +++ b/src/agentling/agent.py @@ -375,7 +375,6 @@ async def _run_stream( looping = signature == previous_signature previous_signature = signature - # Announce every call, then run them (concurrently or in order). for tool_call in response.tool_calls: yield ToolCallEvent(tool_call=tool_call) From 029d58fd381bb065fa00ba6ccf9d813ce05cfd4f Mon Sep 17 00:00:00 2001 From: folathecoder Date: Tue, 7 Jul 2026 14:55:47 +0100 Subject: [PATCH 12/17] Reject id-less streamed tool calls and validate skill frontmatter agglomerate_deltas now rejects a streamed tool call with no id, as it already did for a missing name, since an empty id breaks the tool-result round-trip. Skill loading validates that `tools:` is a list of strings and requires exact `---` fence lines, so a stray string or a leading `---` in the body is not mistaken for frontmatter. --- src/agentling/models.py | 6 +++++- src/agentling/skills.py | 40 +++++++++++++++++++++++++--------------- tests/test_models.py | 6 ++++++ tests/test_skills.py | 30 +++++++++++++++++++++++++++++- 4 files changed, 65 insertions(+), 17 deletions(-) diff --git a/src/agentling/models.py b/src/agentling/models.py index 49be3ec..5811d1e 100644 --- a/src/agentling/models.py +++ b/src/agentling/models.py @@ -422,11 +422,15 @@ def agglomerate_deltas(deltas: Iterable[Delta]) -> ChatMessage: tool_calls: list[ToolCall] = [] for _index, fragment in sorted(fragments.items()): + # A tool call needs both an id (to match its result message) and a name; + # a missing either one is malformed output the loop can retry. + if not fragment.id: + raise ModelOutputError("Streamed tool call is missing an id.") if not fragment.name: raise ModelOutputError("Streamed tool call is missing a name.") tool_calls.append( ToolCall( - id=fragment.id or "", + id=fragment.id, name=fragment.name, arguments=parse_tool_arguments(fragment.arguments), ) diff --git a/src/agentling/skills.py b/src/agentling/skills.py index e53d75d..f1cb7be 100644 --- a/src/agentling/skills.py +++ b/src/agentling/skills.py @@ -55,12 +55,22 @@ def from_path(cls, path: str | Path) -> Skill: f"{folder / SKILL_FILE} is missing required frontmatter key {exc}." ) from exc + raw_tools = frontmatter.get("tools", []) + if raw_tools is None: + raw_tools = [] + if not isinstance(raw_tools, list) or not all( + isinstance(item, str) for item in raw_tools + ): + raise ValueError( + f"{folder / SKILL_FILE}: 'tools' must be a list of strings." + ) + return cls( name=name, description=description, instructions=body.strip(), path=folder, - tools=list(frontmatter.get("tools") or []), + tools=raw_tools, ) def load_tools(self) -> list[Tool]: @@ -72,25 +82,25 @@ def load_tools(self) -> list[Tool]: def _split_frontmatter(source: str) -> tuple[dict[str, Any], str]: """Split a SKILL.md into its YAML frontmatter dict and markdown body. - The frontmatter is the block between the leading '---' fence and the next - '---' line. A file with no leading fence is treated as all body. + Frontmatter is delimited by two fence lines, each exactly '---' on its own. + A file whose first line is not exactly '---' is treated as all body, so a + '---' horizontal rule inside the body is never mistaken for a fence. """ - if not source.startswith("---"): + lines = source.splitlines(keepends=True) + if not lines or lines[0].strip() != "---": return {}, source - # maxsplit=2 splits on only the first two fences, so a '---' horizontal - # rule inside the body is left intact. parts[0] is empty because the - # string starts with the opening fence. - parts = source.split("---", 2) - if len(parts) < 3: - raise ValueError("Unterminated SKILL.md frontmatter (missing closing '---').") - - data = yaml.safe_load(parts[1]) or {} - if not isinstance(data, dict): - raise ValueError("SKILL.md frontmatter must be a YAML mapping.") + # The body starts after the first closing fence, so a later '---' in the + # body is left untouched. + for index in range(1, len(lines)): + if lines[index].strip() == "---": + data = yaml.safe_load("".join(lines[1:index])) or {} + if not isinstance(data, dict): + raise ValueError("SKILL.md frontmatter must be a YAML mapping.") + return data, "".join(lines[index + 1 :]) - return data, parts[2] + raise ValueError("Unterminated SKILL.md frontmatter (missing closing '---').") def _resolve_tool(spec: str) -> Tool: diff --git a/tests/test_models.py b/tests/test_models.py index 86f4716..1fdd632 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -204,6 +204,12 @@ def test_agglomerate_rejects_tool_call_without_name() -> None: agglomerate_deltas(deltas) +def test_agglomerate_rejects_tool_call_without_id() -> None: + deltas = [Delta(tool_calls=[ToolCallDelta(index=0, name="f", arguments="{}")])] + with pytest.raises(ModelOutputError, match="missing an id"): + agglomerate_deltas(deltas) + + # --------------------------------------------------------------------------- # # generate (fake client) # --------------------------------------------------------------------------- # diff --git a/tests/test_skills.py b/tests/test_skills.py index d0cabc2..1d75aaa 100644 --- a/tests/test_skills.py +++ b/tests/test_skills.py @@ -160,7 +160,7 @@ def test_split_returns_mapping_and_body() -> None: data, body = _split_frontmatter("---\nname: x\ndescription: y\n---\nHello") assert data == {"name": "x", "description": "y"} - assert body == "\nHello" + assert body == "Hello" def test_split_empty_frontmatter_returns_empty_dict() -> None: @@ -180,6 +180,34 @@ def test_split_non_mapping_frontmatter_raises() -> None: _split_frontmatter("---\njust a scalar\n---\nbody") +def test_split_first_line_must_be_an_exact_fence() -> None: + # A leading '---' with text on the same line is not a frontmatter fence. + data, body = _split_frontmatter("---not a fence\nname: x\n") + + assert data == {} + assert body == "---not a fence\nname: x\n" + + +def test_from_path_rejects_non_list_tools(tmp_path: Path) -> None: + folder = _write_skill( + tmp_path / "s", + "---\nname: s\ndescription: d\ntools: oops\n---\nbody\n", + ) + + with pytest.raises(ValueError, match="list of strings"): + Skill.from_path(folder) + + +def test_from_path_rejects_non_string_tool_entries(tmp_path: Path) -> None: + folder = _write_skill( + tmp_path / "s", + "---\nname: s\ndescription: d\ntools:\n - 123\n---\nbody\n", + ) + + with pytest.raises(ValueError, match="list of strings"): + Skill.from_path(folder) + + # --------------------------------------------------------------------------- # # _resolve_tool # --------------------------------------------------------------------------- # From 2582da060e8dec05f61333a8c9bf58596dee8571 Mon Sep 17 00:00:00 2001 From: folathecoder Date: Tue, 7 Jul 2026 14:56:08 +0100 Subject: [PATCH 13/17] Document the production API and add project docs Extend the README config reference with the timeout, redaction, truncation, and context-manager knobs, note the sync-tool timeout caveat, refresh the dev commands, and add a security note for skill-provided tools. Add SECURITY.md, CONTRIBUTING.md, and CHANGELOG.md. --- README.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d9ee4b8..d932b7b 100644 --- a/README.md +++ b/README.md @@ -130,7 +130,13 @@ asyncio.run(main()) A tool is any Python function wrapped with `@tool`. The function name becomes the tool name, the docstring summary becomes the description, and the type hints plus a Google-style `Args:` section become the JSON Schema the model sees. Both -synchronous and asynchronous functions are supported. +synchronous and asynchronous functions are supported; a synchronous tool runs in +a worker thread so it cannot block the event loop. + +> **`tool_timeout` caveat:** a timeout stops the agent from *waiting* on a tool +> and turns it into an observation, but a synchronous tool already running in a +> thread cannot be forcibly cancelled and will finish in the background. Prefer +> async tools, or make blocking tools cooperative, when timeouts matter. ```python from agentling import tool From 76408c04db82ca06758fb36a32a1193fc0f4d3c8 Mon Sep 17 00:00:00 2001 From: folathecoder Date: Tue, 7 Jul 2026 15:09:41 +0100 Subject: [PATCH 14/17] Reject id-less streamed tool calls and validate skill frontmatter agglomerate_deltas now rejects a streamed tool call with no id, as it already did for a missing name, since an empty id breaks the tool-result round-trip. Skill loading validates that `tools:` is a list of strings and requires exact `---` fence lines, so a stray string or a leading `---` in the body is not mistaken for frontmatter. --- tests/test_models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_models.py b/tests/test_models.py index 1fdd632..89c2d32 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -293,7 +293,7 @@ async def gen() -> AsyncIterator[Any]: # --------------------------------------------------------------------------- # -# retry-on-429 +# retry policy # --------------------------------------------------------------------------- # async def test_retry_then_succeeds() -> None: calls = 0 From 160702a5def5fc97ae1d3bc04371ee1e416939e1 Mon Sep 17 00:00:00 2001 From: folathecoder Date: Tue, 7 Jul 2026 15:09:46 +0100 Subject: [PATCH 15/17] Fix stale docs and comments; add project docs Correct the ModelOutputError docstring (now also a missing id), the event ordering docstring (all tool-call events precede all tool-result events), the malformed-call comment, and a test section label. Update the README with a value-first opener, an alpha status note, real CI and PyPI badges, an errors.py module row, the two-hint self-healing wording, a sync-tool timeout caveat, the production knobs, and a Limitations section. Add SECURITY.md, CONTRIBUTING.md, and CHANGELOG.md. --- README.md | 38 +++++++++++++++++++++++++++++--------- src/agentling/agent.py | 2 +- src/agentling/errors.py | 6 +++--- src/agentling/events.py | 5 +++-- 4 files changed, 36 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index d932b7b..5fbb4a7 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,16 @@ # agentling +[![PyPI](https://img.shields.io/pypi/v/agentling.svg)](https://pypi.org/project/agentling/) +[![CI](https://github.com/folathecoder/agentling/actions/workflows/ci.yml/badge.svg)](https://github.com/folathecoder/agentling/actions/workflows/ci.yml) [![Python](https://img.shields.io/badge/python-3.11%2B-blue.svg)](https://www.python.org/downloads/) [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE) -[![Typed](https://img.shields.io/badge/typed-mypy-blue.svg)](https://mypy-lang.org/) -[![Lint](https://img.shields.io/badge/lint-ruff-orange.svg)](https://docs.astral.sh/ruff/) -A tiny async tool-calling agent framework. The good ideas from larger agent -libraries (a clean ReAct loop, typed memory, streaming, progressive-disclosure -skills) in a codebase small enough to read in one sitting. +A tiny async framework for reliable, observable tool-using agents: a clean +ReAct loop, typed memory, streaming events, recoverable failures, and +progressive-disclosure skills, in a codebase small enough to read in one +sitting. + +> **Status: alpha (0.x).** The API may still change before 1.0. agentling is built around one idea: an agent is a loop that turns a model, some tools, and a memory of what happened into more actions, until it has an answer. @@ -389,7 +392,7 @@ class Model(Protocol): ## Architecture -agentling is six small modules. Each one owns a single concept, and they depend +agentling is a small set of focused modules. Each one owns a single concept, and they depend on each other in one direction only (agent depends on skills, tools, memory, events, models; nothing depends on agent). @@ -399,6 +402,7 @@ events, models; nothing depends on agent). | [`tools.py`](src/agentling/tools.py) | The `Tool` abstraction, the `@tool` decorator, JSON Schema generation from function signatures, argument validation, and the built-in `final_answer` tool. | | [`memory.py`](src/agentling/memory.py) | Typed steps (`TaskStep`, `ActionStep`, `FinalStep`), the `Memory` container, rendering to model messages, and JSON serialization. | | [`events.py`](src/agentling/events.py) | The streaming event types, the `Event` union, and the `print_events` renderer. | +| [`errors.py`](src/agentling/errors.py) | The exception hierarchy: `AgentlingError` and its domain subclasses. | | [`skills.py`](src/agentling/skills.py) | The `Skill` dataclass, the `SKILL.md` loader (frontmatter plus body), and entry-point tool resolution. | | [`agent.py`](src/agentling/agent.py) | The `Agent` config/factory, the `AgentSession` that holds one run's state, and the ReAct loop that ties everything together. | @@ -543,9 +547,11 @@ the run. Explicit `final_answer` and plain-text replies both work. #### Self-healing tool errors `_execute_tool` catches any exception a tool raises and turns it into a -`ToolResult` with `is_error=True`. That error is rendered back to the model as an -observation ("Error from 'search': ... Fix the arguments and try again"), so the -model can correct course. One bad tool call does not kill the run. +`ToolResult` with `is_error=True`, tagged by kind. The observation the model +sees carries a hint that depends on the failure: an invalid-argument error says +"Fix the arguments and try again", while an execution, timeout, or unknown-tool +error says "Consider a different approach or tool". So the model can correct +course, and one bad tool call does not kill the run. #### Loop detection @@ -619,6 +625,20 @@ uv run mypy src tests # type-check uv build # verify the package builds ``` +## Limitations + +agentling is deliberately small, so some things are out of scope for now: + +- **Schema validation is shallow.** Tool arguments are checked against a + practical subset of JSON Schema (primitives, `list`/`dict`, `Optional`, + `Literal`), not the full spec. +- **No built-in tracing backend.** `step_callbacks` and the event stream are the + hooks; wiring them to a tracer or metrics store is up to you. +- **No sandboxing.** Tools and skill-provided tools run as trusted code (see + [Security](#security)). +- **No automatic context summarization.** Long runs can outgrow the context + window; supply a `context_manager` to trim or summarize. + ## Security Tools and skill-provided tools run as trusted code in your process, and tool diff --git a/src/agentling/agent.py b/src/agentling/agent.py index 565a1f6..7589f2c 100644 --- a/src/agentling/agent.py +++ b/src/agentling/agent.py @@ -331,7 +331,7 @@ async def _run_stream( ) return - # Malformed tool calls (bad JSON, missing name) are recoverable: + # Malformed tool calls (bad JSON, missing id or name) are recoverable: # re-prompt the model with a correction, up to a small cap, rather # than crashing the run. try: diff --git a/src/agentling/errors.py b/src/agentling/errors.py index 7a4da82..3721ba0 100644 --- a/src/agentling/errors.py +++ b/src/agentling/errors.py @@ -30,9 +30,9 @@ class ModelOutputError(AgentlingError): """The model produced output the framework could not parse. Raised for malformed streamed tool calls (invalid JSON arguments, a - non-object arguments payload, or a missing tool name). The agent loop can - turn this into a recoverable observation so the model gets a chance to - retry. + non-object arguments payload, a missing tool name, or a missing tool-call + id). The agent loop can turn this into a recoverable observation so the + model gets a chance to retry. """ diff --git a/src/agentling/events.py b/src/agentling/events.py index 5a50cd6..b0b4467 100644 --- a/src/agentling/events.py +++ b/src/agentling/events.py @@ -8,8 +8,9 @@ was just recorded. Ordering within a run is deterministic: a turn's TextDelta chunks arrive in -order, then a ToolCallEvent and its matching ToolResultEvent for each tool call, -then the StepEvent for that step. Exactly one FinalEvent is emitted, last. +order, then every ToolCallEvent for the step, then every ToolResultEvent in the +same call order, then the StepEvent for that step. Exactly one FinalEvent is +emitted, last. """ from __future__ import annotations From 21391a5dfb176cafa826a63a84d8a1310e1e0e0d Mon Sep 17 00:00:00 2001 From: folathecoder Date: Tue, 7 Jul 2026 15:22:12 +0100 Subject: [PATCH 16/17] Address PR review: reserve load_skill name and fix truncation edge Reserve the load_skill tool name when skills are configured, so a caller tool cannot silently shadow the built-in skill loader (raise at construction). Fix _truncate_middle to cut the head for tiny limits (0 or 1), where keep=0 and text[-0:] would otherwise return the whole string. --- src/agentling/agent.py | 12 ++++++++++++ tests/test_agent.py | 29 ++++++++++++++++++++++++++++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/agentling/agent.py b/src/agentling/agent.py index 7589f2c..d244044 100644 --- a/src/agentling/agent.py +++ b/src/agentling/agent.py @@ -132,6 +132,14 @@ def __init__( } self.instructions = instructions or DEFAULT_INSTRUCTIONS if self.skills: + # load_skill is the built-in that sessions register to reveal skills. + # A caller tool of the same name would silently shadow it, so reserve + # the name loudly instead. + if "load_skill" in self._base_tools: + raise ValueError( + "'load_skill' is a reserved tool name when skills are " + "configured; rename the conflicting tool." + ) self.instructions += _skill_catalog(self.skills.values()) def start(self) -> AgentSession: @@ -585,6 +593,10 @@ def _truncate_middle(text: str, limit: int) -> str: if len(text) <= limit: return text keep = limit // 2 + if keep <= 0: + # The limit is too small for a head+tail window (0 or 1), so just cut + # the head. text[-0:] would otherwise return the whole string. + return text[:limit] omitted = len(text) - 2 * keep return f"{text[:keep]}\n... [{omitted} characters omitted] ...\n{text[-keep:]}" diff --git a/tests/test_agent.py b/tests/test_agent.py index 0a33da9..9132d2d 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -8,7 +8,7 @@ import pytest -from agentling.agent import Agent, AgentSession +from agentling.agent import Agent, AgentSession, _truncate_middle from agentling.errors import ModelError from agentling.events import ( FinalEvent, @@ -959,3 +959,30 @@ async def test_print_events_writes_to_file_and_returns_answer() -> None: output = buffer.getvalue() assert "add" in output # the tool call was rendered assert "5" in output # the final answer was rendered + + +# --------------------------------------------------------------------------- # +# Pre-release edge fixes +# --------------------------------------------------------------------------- # +def test_load_skill_name_is_reserved_when_skills_are_configured() -> None: + @tool + def load_skill(name: str) -> str: + """A tool that collides with the built-in skill loader. + + Args: + name: Unused. + """ + return name + + with pytest.raises(ValueError, match="reserved"): + Agent(model=FakeModel([]), tools=[load_skill], skills=[_REVIEWER_SKILL]) + + +def test_truncate_middle_handles_tiny_limits() -> None: + assert _truncate_middle("abc", 10) == "abc" # under the limit, unchanged + assert _truncate_middle("abcdef", 0) == "" + assert _truncate_middle("abcdef", 1) == "a" + + windowed = _truncate_middle("x" * 100, 20) + assert "omitted" in windowed + assert len(windowed) < 100 From 30c4aacebe0ff724dab4d8ea91daceaaebb72955 Mon Sep 17 00:00:00 2001 From: folathecoder Date: Tue, 7 Jul 2026 15:42:24 +0100 Subject: [PATCH 17/17] Add a runnable examples suite covering the feature set Six self-contained examples that progress from a basic tool agent to failure recovery, memory replay, and a full event observer. The three offline examples (failure_recovery, memory_chat, advanced_observer) run with a scripted model and no API key; the API-backed three (math_tutor, repo_assistant, notes_agent) take an injectable model so tests drive them offline too. Adds tests/test_examples.py (no network), a README examples section with a feature matrix, a pytest pythonpath so examples import, and extends CI's ruff/mypy to cover examples. --- .github/workflows/ci.yml | 6 +- README.md | 38 ++++++ examples/__init__.py | 6 + examples/cli_advanced_observer.py | 141 ++++++++++++++++++++ examples/cli_failure_recovery.py | 134 +++++++++++++++++++ examples/cli_math_tutor.py | 62 +++++++++ examples/cli_memory_chat.py | 102 ++++++++++++++ examples/cli_notes_agent.py | 86 ++++++++++++ examples/cli_repo_assistant.py | 72 ++++++++++ pyproject.toml | 2 + tests/test_examples.py | 214 ++++++++++++++++++++++++++++++ 11 files changed, 860 insertions(+), 3 deletions(-) create mode 100644 examples/__init__.py create mode 100644 examples/cli_advanced_observer.py create mode 100644 examples/cli_failure_recovery.py create mode 100644 examples/cli_math_tutor.py create mode 100644 examples/cli_memory_chat.py create mode 100644 examples/cli_notes_agent.py create mode 100644 examples/cli_repo_assistant.py create mode 100644 tests/test_examples.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b401822..78acc21 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,13 +38,13 @@ jobs: run: uv sync - name: Lint (ruff) - run: uv run ruff check src tests + run: uv run ruff check src tests examples - name: Format check (ruff) - run: uv run ruff format --check src tests + run: uv run ruff format --check src tests examples - name: Type-check (mypy) - run: uv run mypy src tests + run: uv run mypy src tests examples - name: Test (pytest) run: uv run pytest diff --git a/README.md b/README.md index 5fbb4a7..0816034 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ asyncio.run(main()) - [Why agentling](#why-agentling) - [Install](#install) - [Quickstart](#quickstart) +- [Examples](#examples) - [Usage](#usage) - [Tools](#tools) - [Running: blocking vs streaming](#running-blocking-vs-streaming) @@ -126,6 +127,43 @@ async def main() -> None: asyncio.run(main()) ``` +## Examples + +Runnable examples live in [`examples/`](examples/). The offline ones need no API +key; the rest use an OpenAI-compatible model (set `OPENAI_API_KEY`, and +optionally `AGENTLING_EXAMPLE_MODEL`). + +Offline (no API key): + +```bash +uv run python examples/cli_failure_recovery.py # recover from tool errors +uv run python examples/cli_memory_chat.py # persist and continue a session +uv run python examples/cli_advanced_observer.py # watch every event type +``` + +Needs an API key: + +```bash +uv run python examples/cli_math_tutor.py # smallest useful agent +uv run python examples/cli_repo_assistant.py "..." # streaming + safe file tools +uv run python examples/cli_notes_agent.py # file-backed notes tools +``` + +What each one covers: + +| Feature | Example | +| --- | --- | +| Basic tool calling | `cli_math_tutor.py` | +| Streaming events (`print_events`) | `cli_repo_assistant.py`, `cli_advanced_observer.py` | +| Every event type + `context_manager` | `cli_advanced_observer.py` | +| Safe, sandboxed file tools | `cli_repo_assistant.py`, `cli_notes_agent.py` | +| Failure recovery (tool errors) | `cli_failure_recovery.py` | +| Memory persistence + `reset=False` | `cli_memory_chat.py` | +| Skills (progressive disclosure) | `cli_repo_assistant.py`, [`skills/code-reviewer`](examples/skills/code-reviewer) | +| Timeouts, redaction, `parallel_safe` | `cli_notes_agent.py` | +| `max_tool_output_chars` | `cli_repo_assistant.py` | +| Custom scripted/fake model | `cli_failure_recovery.py`, `cli_memory_chat.py` | + ## Usage ### Tools diff --git a/examples/__init__.py b/examples/__init__.py new file mode 100644 index 0000000..f6afda1 --- /dev/null +++ b/examples/__init__.py @@ -0,0 +1,6 @@ +"""Runnable agentling examples. + +This package marker lets the tests import the example modules (e.g. +`import examples.cli_failure_recovery`). Each example is still self-contained and +runnable directly, for example `uv run python examples/cli_failure_recovery.py`. +""" diff --git a/examples/cli_advanced_observer.py b/examples/cli_advanced_observer.py new file mode 100644 index 0000000..6f30fa8 --- /dev/null +++ b/examples/cli_advanced_observer.py @@ -0,0 +1,141 @@ +"""Event-observer demo: watch every event type flow from a single run. + +Runs fully offline with a scripted model, so no API key is needed: + + uv run python examples/cli_advanced_observer.py + +Iterates the streaming event API directly and prints each TextDelta, tool call, +tool result, step, and the final event. It also wires a context_manager that +trims the prompt and sets a model_timeout budget, two of the production knobs. +""" + +from __future__ import annotations + +import asyncio +import json +from collections.abc import AsyncIterator, Sequence +from typing import Any + +from agentling import ( + Agent, + ChatMessage, + Delta, + FinalEvent, + StepEvent, + TextDelta, + ToolCall, + ToolCallDelta, + ToolCallEvent, + ToolResultEvent, + Usage, + tool, +) + + +@tool +def health_check() -> str: + """Report a one-line service health status.""" + return "all systems nominal" + + +class ScriptedModel: + """A deterministic offline model that replays fixed assistant turns.""" + + def __init__(self, turns: Sequence[ChatMessage]) -> None: + self._turns = list(turns) + self._index = 0 + + async def generate( + self, messages: Sequence[ChatMessage], tools: Sequence[Any] | None = None + ) -> ChatMessage: + turn = self._turns[self._index] + self._index += 1 + return turn + + async def stream( + self, messages: Sequence[ChatMessage], tools: Sequence[Any] | None = None + ) -> AsyncIterator[Delta]: + turn = self._turns[self._index] + self._index += 1 + if turn.content: + yield Delta(content=turn.content) + for index, call in enumerate(turn.tool_calls): + yield Delta( + tool_calls=[ + ToolCallDelta( + index=index, + id=call.id, + name=call.name, + arguments=json.dumps(call.arguments), + ) + ] + ) + yield Delta(usage=turn.usage) + + +def _assistant( + content: str = "", tool_calls: list[ToolCall] | None = None +) -> ChatMessage: + return ChatMessage( + role="assistant", + content=content, + tool_calls=tool_calls or [], + usage=Usage(1, 1), + ) + + +def keep_recent(messages: list[ChatMessage]) -> list[ChatMessage]: + """A context_manager that keeps the system prompt and the recent tail.""" + + if len(messages) <= 6: + return messages + return [messages[0], *messages[-5:]] + + +def build_agent() -> Agent: + """Build an offline agent that calls one tool, then answers.""" + + model = ScriptedModel( + [ + _assistant( + tool_calls=[ToolCall(id="c1", name="health_check", arguments={})] + ), + _assistant(content="All systems nominal."), + ] + ) + return Agent( + model=model, + tools=[health_check], + context_manager=keep_recent, + model_timeout=30.0, + ) + + +async def observe(agent: Agent) -> dict[str, int]: + """Stream a run, print each event, and tally how many of each type arrived.""" + + counts: dict[str, int] = {} + session = agent.start() + async for event in session.run("Check system health.", stream=True): + counts[type(event).__name__] = counts.get(type(event).__name__, 0) + 1 + match event: + case TextDelta(text=text): + print(text, end="", flush=True) + case ToolCallEvent(tool_call=call): + print(f"\n-> {call.name}()") + case ToolResultEvent(result=result): + print(f"<- {result.content}") + case StepEvent(): + print("[step recorded]") + case FinalEvent(answer=answer, status=status): + print(f"\n= [{status}] {answer}") + return counts + + +async def main() -> None: + counts = await observe(build_agent()) + print("\nEvent counts:", counts) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/cli_failure_recovery.py b/examples/cli_failure_recovery.py new file mode 100644 index 0000000..2b1bf37 --- /dev/null +++ b/examples/cli_failure_recovery.py @@ -0,0 +1,134 @@ +"""Failure-recovery demo: watch the agent turn tool errors into observations. + +Runs fully offline with a scripted model, so no API key is needed: + + uv run python examples/cli_failure_recovery.py + +The scripted model first calls a tool that raises an unexpected exception, then +one that rejects invalid arguments, then retries with valid arguments and +answers. agentling feeds each failure back to the model as an observation +rather than crashing, so the run recovers on its own. +""" + +from __future__ import annotations + +import asyncio +import json +from collections.abc import AsyncIterator, Sequence +from typing import Any + +from agentling import ( + ActionStep, + Agent, + ChatMessage, + Delta, + ToolCall, + ToolCallDelta, + ToolCallError, + Usage, + tool, +) + + +@tool +def divide(a: float, b: float) -> float: + """Divide a by b. + + Args: + a: The numerator. + b: The denominator; must be non-zero. + """ + if b == 0: + raise ToolCallError("b must be non-zero") + return a / b + + +@tool +def fetch_rate() -> float: + """Fetch a conversion rate from an unreliable upstream service.""" + raise RuntimeError("upstream service unavailable") + + +class ScriptedModel: + """A deterministic offline model that replays fixed assistant turns.""" + + def __init__(self, turns: Sequence[ChatMessage]) -> None: + self._turns = list(turns) + self._index = 0 + + async def generate( + self, messages: Sequence[ChatMessage], tools: Sequence[Any] | None = None + ) -> ChatMessage: + turn = self._turns[self._index] + self._index += 1 + return turn + + async def stream( + self, messages: Sequence[ChatMessage], tools: Sequence[Any] | None = None + ) -> AsyncIterator[Delta]: + turn = self._turns[self._index] + self._index += 1 + if turn.content: + yield Delta(content=turn.content) + for index, call in enumerate(turn.tool_calls): + yield Delta( + tool_calls=[ + ToolCallDelta( + index=index, + id=call.id, + name=call.name, + arguments=json.dumps(call.arguments), + ) + ] + ) + yield Delta(usage=turn.usage) + + +def _assistant( + content: str = "", tool_calls: list[ToolCall] | None = None +) -> ChatMessage: + return ChatMessage( + role="assistant", + content=content, + tool_calls=tool_calls or [], + usage=Usage(1, 1), + ) + + +def build_agent() -> Agent: + """Build an offline agent scripted to fail twice, then recover.""" + + model = ScriptedModel( + [ + _assistant(tool_calls=[ToolCall(id="c1", name="fetch_rate", arguments={})]), + _assistant( + tool_calls=[ + ToolCall(id="c2", name="divide", arguments={"a": 10, "b": 0}) + ] + ), + _assistant( + tool_calls=[ + ToolCall(id="c3", name="divide", arguments={"a": 10, "b": 2}) + ] + ), + _assistant(content="10 divided by 2 is 5."), + ] + ) + return Agent(model=model, tools=[divide, fetch_rate]) + + +async def main() -> None: + session = build_agent().start() + answer = await session.run("Compute 10 / 2, working around any errors.") + + print(f"Answer: {answer}\n") + print("How the run got there:") + for step in session.memory.steps: + if isinstance(step, ActionStep): + for result in step.tool_results: + flag = "error" if result.is_error else "ok" + print(f" [{flag}] {result.name}: {result.content}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/cli_math_tutor.py b/examples/cli_math_tutor.py new file mode 100644 index 0000000..6bc589b --- /dev/null +++ b/examples/cli_math_tutor.py @@ -0,0 +1,62 @@ +"""The smallest useful agentling agent: a math tutor with two tools. + +Needs an OpenAI-compatible API key (set OPENAI_API_KEY). The model name can be +overridden with AGENTLING_EXAMPLE_MODEL: + + uv run python examples/cli_math_tutor.py + +build_agent() accepts an optional model so tests can inject a fake one and run +without any network access. +""" + +from __future__ import annotations + +import asyncio +import os + +from agentling import Agent, Model, OpenAIModel, tool + + +@tool +def add(a: float, b: float) -> float: + """Add two numbers. + + Args: + a: The first number. + b: The second number. + """ + return a + b + + +@tool +def multiply(a: float, b: float) -> float: + """Multiply two numbers. + + Args: + a: The first number. + b: The second number. + """ + return a * b + + +def build_agent(model: Model | None = None) -> Agent: + """Build the tutor agent, defaulting to an OpenAI-compatible model.""" + + return Agent( + model=model + or OpenAIModel(os.environ.get("AGENTLING_EXAMPLE_MODEL", "gpt-4o-mini")), + tools=[add, multiply], + instructions=( + "You are a patient math tutor. Use the tools to compute, then " + "explain the result in one sentence." + ), + ) + + +async def main() -> None: + answer = await build_agent().run("What is 6 times 7, then plus 3?") + print(answer) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/cli_memory_chat.py b/examples/cli_memory_chat.py new file mode 100644 index 0000000..22257fe --- /dev/null +++ b/examples/cli_memory_chat.py @@ -0,0 +1,102 @@ +"""Memory-persistence demo: dump a session, reload it, and continue. + +Runs fully offline with a scripted model, so no API key is needed: + + uv run python examples/cli_memory_chat.py + +The first session learns a fact; its typed memory is serialized to JSON; a fresh +session reloads that memory and continues the conversation with reset=False. The +continuation is streamed so the terminal FinalEvent's status is shown too. +""" + +from __future__ import annotations + +import asyncio +import json +from collections.abc import AsyncIterator, Sequence +from typing import Any + +from agentling import ( + Agent, + ChatMessage, + Delta, + FinalEvent, + Memory, + ToolCallDelta, + Usage, +) + + +class ScriptedModel: + """A deterministic offline model that replays fixed assistant turns.""" + + def __init__(self, turns: Sequence[ChatMessage]) -> None: + self._turns = list(turns) + self._index = 0 + + async def generate( + self, messages: Sequence[ChatMessage], tools: Sequence[Any] | None = None + ) -> ChatMessage: + turn = self._turns[self._index] + self._index += 1 + return turn + + async def stream( + self, messages: Sequence[ChatMessage], tools: Sequence[Any] | None = None + ) -> AsyncIterator[Delta]: + turn = self._turns[self._index] + self._index += 1 + if turn.content: + yield Delta(content=turn.content) + for index, call in enumerate(turn.tool_calls): + yield Delta( + tool_calls=[ + ToolCallDelta( + index=index, + id=call.id, + name=call.name, + arguments=json.dumps(call.arguments), + ) + ] + ) + yield Delta(usage=turn.usage) + + +def _assistant(content: str) -> ChatMessage: + return ChatMessage(role="assistant", content=content, usage=Usage(1, 1)) + + +def build_agent() -> Agent: + """Build an offline agent scripted for a two-turn conversation.""" + + model = ScriptedModel( + [ + _assistant("Nice to meet you, Sam."), + _assistant("Your name is Sam."), + ] + ) + return Agent(model=model) + + +async def main() -> None: + agent = build_agent() + + # First session: learn something, then serialize the run to JSON. + first = agent.start() + await first.run("Hi, my name is Sam.") + saved = first.memory.dump_json() + print(f"Saved {len(first.memory.steps)} steps to JSON.\n") + + # Later, in a fresh session (or another process), restore and continue. + second = agent.start() + second.memory = Memory.load_json(saved) + + answer, status = "", "unknown" + async for event in second.run("What is my name?", reset=False, stream=True): + if isinstance(event, FinalEvent): + answer, status = event.answer, event.status + print(f"[{status}] {answer}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/cli_notes_agent.py b/examples/cli_notes_agent.py new file mode 100644 index 0000000..ec2e74a --- /dev/null +++ b/examples/cli_notes_agent.py @@ -0,0 +1,86 @@ +"""A file-backed notes assistant: add, search, and list notes. + +Needs an OpenAI-compatible API key (set OPENAI_API_KEY): + + uv run python examples/cli_notes_agent.py + +Demonstrates practical file tools sandboxed to a notes directory, an async +tool (search), a non-parallel-safe mutating tool (add), and the production +knobs tool_timeout and redact_errors. build_agent() takes an optional model so +tests run offline against a temporary directory. +""" + +from __future__ import annotations + +import asyncio +import os +from pathlib import Path + +from agentling import Agent, Model, OpenAIModel, ToolCallError, tool + + +def _resolve_within(base: Path, candidate: str) -> Path: + """Resolve candidate under base, rejecting anything that escapes the root.""" + + target = (base / candidate).resolve() + if target != base and base not in target.parents: + raise ToolCallError(f"path {candidate!r} escapes the notes directory") + return target + + +def build_agent(model: Model | None = None, notes_dir: str = "notes") -> Agent: + """Build a notes assistant whose tools live under `notes_dir`.""" + + base = Path(notes_dir).resolve() + + @tool(parallel_safe=False) + def add_note(name: str, text: str) -> str: + """Save a note (overwrites one with the same name). + + Args: + name: The note's name (no path separators). + text: The note body. + """ + base.mkdir(parents=True, exist_ok=True) + _resolve_within(base, f"{name}.txt").write_text(text, encoding="utf-8") + return f"saved note {name!r}" + + @tool + async def search_notes(query: str) -> str: + """Return the names of notes whose text contains the query. + + Args: + query: Text to search for. + """ + base.mkdir(parents=True, exist_ok=True) + hits = [ + note.stem + for note in sorted(base.glob("*.txt")) + if query in note.read_text(encoding="utf-8") + ] + return "\n".join(hits) or "no matches" + + @tool + def list_notes() -> str: + """List the names of all saved notes.""" + base.mkdir(parents=True, exist_ok=True) + return "\n".join(sorted(note.stem for note in base.glob("*.txt"))) or "no notes" + + return Agent( + model=model + or OpenAIModel(os.environ.get("AGENTLING_EXAMPLE_MODEL", "gpt-4o-mini")), + tools=[add_note, search_notes, list_notes], + tool_timeout=10.0, + redact_errors=True, + ) + + +async def main() -> None: + agent = build_agent() + print( + await agent.run("Add a note called groceries: milk, eggs. Then list my notes.") + ) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/cli_repo_assistant.py b/examples/cli_repo_assistant.py new file mode 100644 index 0000000..51ac1bc --- /dev/null +++ b/examples/cli_repo_assistant.py @@ -0,0 +1,72 @@ +"""A streaming repo assistant with safe, read-only file tools. + +Needs an OpenAI-compatible API key (set OPENAI_API_KEY): + + uv run python examples/cli_repo_assistant.py "Summarize the README" + +Demonstrates streaming with print_events, file tools sandboxed to a project +root, an output cap (max_tool_output_chars), and loading the bundled +code-reviewer skill. build_agent() takes an optional model so tests run offline. +""" + +from __future__ import annotations + +import asyncio +import os +import sys +from pathlib import Path + +from agentling import Agent, Model, OpenAIModel, ToolCallError, print_events, tool + + +def _resolve_within(base: Path, candidate: str) -> Path: + """Resolve candidate under base, rejecting anything that escapes the root.""" + + target = (base / candidate).resolve() + if target != base and base not in target.parents: + raise ToolCallError(f"path {candidate!r} escapes the project root") + return target + + +def build_agent(model: Model | None = None, root: str = ".") -> Agent: + """Build a repo assistant whose file tools are sandboxed to `root`.""" + + base = Path(root).resolve() + + @tool + def read_file(path: str) -> str: + """Read a UTF-8 text file under the project root. + + Args: + path: Path relative to the project root. + """ + return _resolve_within(base, path).read_text(encoding="utf-8") + + @tool + def list_files(subdir: str = ".") -> str: + """List the entries of a directory under the project root. + + Args: + subdir: Directory relative to the project root. + """ + target = _resolve_within(base, subdir) + return "\n".join(sorted(entry.name for entry in target.iterdir())) + + return Agent( + model=model + or OpenAIModel(os.environ.get("AGENTLING_EXAMPLE_MODEL", "gpt-4o-mini")), + tools=[read_file, list_files], + skills=[Path(__file__).parent / "skills" / "code-reviewer"], + max_tool_output_chars=2000, + ) + + +async def main() -> None: + prompt = " ".join(sys.argv[1:]) or ( + "List the files in the project root, then summarize the README." + ) + await print_events(build_agent().run(prompt, stream=True)) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/pyproject.toml b/pyproject.toml index 80e9c7a..d657570 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,8 @@ build-backend = "hatchling.build" [tool.pytest.ini_options] asyncio_mode = "auto" testpaths = ["tests"] +# Put the repo root on sys.path so tests can `import examples.*`. +pythonpath = ["."] [tool.ruff] line-length = 88 diff --git a/tests/test_examples.py b/tests/test_examples.py new file mode 100644 index 0000000..982996e --- /dev/null +++ b/tests/test_examples.py @@ -0,0 +1,214 @@ +import json +from collections.abc import AsyncIterator, Sequence +from pathlib import Path +from typing import Any + +import pytest + +import examples.cli_advanced_observer as observer +import examples.cli_failure_recovery as failure_recovery +import examples.cli_math_tutor as math_tutor +import examples.cli_memory_chat as memory_chat +import examples.cli_notes_agent as notes_agent +import examples.cli_repo_assistant as repo_assistant +from agentling import ( + ActionStep, + ChatMessage, + Delta, + Memory, + TaskStep, + ToolCall, + ToolCallDelta, + ToolCallError, + ToolResultEvent, + Usage, +) + + +class _ScriptedModel: + """A deterministic fake model for driving the API-backed examples offline.""" + + def __init__(self, turns: Sequence[ChatMessage]) -> None: + self._turns = list(turns) + self._index = 0 + + async def generate( + self, messages: Sequence[ChatMessage], tools: Sequence[Any] | None = None + ) -> ChatMessage: + turn = self._turns[self._index] + self._index += 1 + return turn + + async def stream( + self, messages: Sequence[ChatMessage], tools: Sequence[Any] | None = None + ) -> AsyncIterator[Delta]: + turn = self._turns[self._index] + self._index += 1 + if turn.content: + yield Delta(content=turn.content) + for index, call in enumerate(turn.tool_calls): + yield Delta( + tool_calls=[ + ToolCallDelta( + index=index, + id=call.id, + name=call.name, + arguments=json.dumps(call.arguments), + ) + ] + ) + yield Delta(usage=turn.usage) + + +def _assistant( + content: str = "", tool_calls: list[ToolCall] | None = None +) -> ChatMessage: + return ChatMessage( + role="assistant", + content=content, + tool_calls=tool_calls or [], + usage=Usage(1, 1), + ) + + +def _call(call_id: str, name: str, /, **arguments: Any) -> ToolCall: + # name is positional-only so a tool argument literally called "name" + # (as in add_note) does not collide with this parameter. + return ToolCall(id=call_id, name=name, arguments=arguments) + + +# --------------------------------------------------------------------------- # +# Import / shape +# --------------------------------------------------------------------------- # +def test_offline_examples_expose_build_agent_and_main() -> None: + for module in (failure_recovery, memory_chat, observer): + assert callable(module.build_agent) + assert callable(module.main) + + +# --------------------------------------------------------------------------- # +# Failure recovery (offline) +# --------------------------------------------------------------------------- # +async def test_failure_recovery_recovers_from_tool_errors() -> None: + session = failure_recovery.build_agent().start() + answer = await session.run("compute") + + assert "5" in answer # the recovered final answer + action_steps = [s for s in session.memory.steps if isinstance(s, ActionStep)] + assert action_steps # at least one step was recorded + errors = [r for s in action_steps for r in s.tool_results if r.is_error] + assert errors # a tool failure surfaced as a recoverable observation + + +# --------------------------------------------------------------------------- # +# Memory replay (offline) +# --------------------------------------------------------------------------- # +async def test_memory_replay_dumps_reloads_and_continues() -> None: + agent = memory_chat.build_agent() + + first = agent.start() + await first.run("Hi, my name is Sam.") + saved = first.memory.dump_json() + assert saved # memory serialized to JSON + + second = agent.start() + second.memory = Memory.load_json(saved) + assert second.memory.steps # restored the prior run's steps + + await second.run("What is my name?", reset=False) + task_steps = [s for s in second.memory.steps if isinstance(s, TaskStep)] + assert len(task_steps) > 1 # continuation built on the restored memory + + +# --------------------------------------------------------------------------- # +# Event observer (offline) +# --------------------------------------------------------------------------- # +async def test_observer_handles_every_event_type() -> None: + counts = await observer.observe(observer.build_agent()) + + for event_type in ( + "TextDelta", + "ToolCallEvent", + "ToolResultEvent", + "StepEvent", + "FinalEvent", + ): + assert counts.get(event_type, 0) >= 1 + + +# --------------------------------------------------------------------------- # +# API-backed examples (driven offline with an injected fake model) +# --------------------------------------------------------------------------- # +def test_api_examples_expose_build_agent_and_main() -> None: + for module in (math_tutor, repo_assistant, notes_agent): + assert callable(module.build_agent) + assert callable(module.main) + + +async def test_math_tutor_uses_its_tools() -> None: + model = _ScriptedModel( + [ + _assistant(tool_calls=[_call("c1", "multiply", a=6, b=7)]), + _assistant(tool_calls=[_call("c2", "add", a=42, b=3)]), + _assistant(content="6 times 7 plus 3 is 45."), + ] + ) + + answer = await math_tutor.build_agent(model=model).run("6*7+3?") + assert "45" in answer + + +async def test_repo_assistant_reads_within_root_and_streams(tmp_path: Path) -> None: + (tmp_path / "hello.txt").write_text("hi there", encoding="utf-8") + model = _ScriptedModel( + [ + _assistant(tool_calls=[_call("c1", "read_file", path="hello.txt")]), + _assistant(content="The file says hi there."), + ] + ) + agent = repo_assistant.build_agent(model=model, root=str(tmp_path)) + + events = [event async for event in agent.run("read hello.txt", stream=True)] + results = [e for e in events if isinstance(e, ToolResultEvent)] + assert results and results[0].result.content == "hi there" + + +def test_repo_assistant_rejects_path_traversal(tmp_path: Path) -> None: + with pytest.raises(ToolCallError): + repo_assistant._resolve_within(tmp_path, "../secret.txt") + + +def test_repo_assistant_sets_output_cap() -> None: + agent = repo_assistant.build_agent(model=_ScriptedModel([])) + assert agent.max_tool_output_chars == 2000 + + +async def test_notes_agent_adds_and_searches(tmp_path: Path) -> None: + notes_dir = tmp_path / "notes" + model = _ScriptedModel( + [ + _assistant( + tool_calls=[_call("c1", "add_note", name="todo", text="buy milk")] + ), + _assistant(tool_calls=[_call("c2", "search_notes", query="milk")]), + _assistant(content="Found it in your notes."), + ] + ) + session = notes_agent.build_agent(model=model, notes_dir=str(notes_dir)).start() + await session.run("add and search a note") + + assert (notes_dir / "todo.txt").exists() + search_step = session.memory.steps[2] + assert isinstance(search_step, ActionStep) + assert "todo" in search_step.tool_results[0].content + + +def test_notes_agent_rejects_unsafe_paths(tmp_path: Path) -> None: + with pytest.raises(ToolCallError): + notes_agent._resolve_within(tmp_path, "../escape.txt") + + +def test_notes_agent_configures_timeout_and_redaction() -> None: + agent = notes_agent.build_agent(model=_ScriptedModel([]), notes_dir="unused") + assert agent.tool_timeout == 10.0 + assert agent.redact_errors is True