From d1f0d2ec6807d981a80b4d03c2ee1e0bc2ad2996 Mon Sep 17 00:00:00 2001 From: folathecoder Date: Mon, 6 Jul 2026 12:44:53 +0100 Subject: [PATCH 1/6] Add CI workflow: ruff, mypy, and pytest on 3.11/3.12 for PRs --- .github/workflows/ci.yml | 47 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..791da37 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,47 @@ +name: CI + +# Run on every pull request, and on pushes to main (so merges are verified too). +on: + pull_request: + push: + branches: [main] + +# Only need read access to check out the code. +permissions: + contents: read + +# Cancel superseded runs for the same ref (e.g. new pushes to an open PR). +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # Match the versions in pyproject's classifiers (requires-python >=3.11). + python-version: ["3.11", "3.12"] + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Install uv and Python ${{ matrix.python-version }} + uses: astral-sh/setup-uv@v6 + with: + python-version: ${{ matrix.python-version }} + enable-cache: true + + - name: Install dependencies + run: uv sync + + - name: Lint (ruff) + run: uv run --with ruff ruff check src tests + + - name: Type-check (mypy) + run: uv run --with mypy mypy src tests + + - name: Test (pytest) + run: uv run pytest From fbc5995ba1b5f5ea23783c3fc220721b198c3a4b Mon Sep 17 00:00:00 2001 From: folathecoder Date: Mon, 6 Jul 2026 12:49:51 +0100 Subject: [PATCH 2/6] Add pytest configuration and dev dependencies (pytest, pytest-asyncio) --- pyproject.toml | 10 +++++++ uv.lock | 77 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 9bea834..fb4b43d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,3 +29,13 @@ Issues = "https://github.com/folathecoder/agentling/issues" [build-system] requires = ["hatchling"] build-backend = "hatchling.build" + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] + +[dependency-groups] +dev = [ + "pytest>=9.1.1", + "pytest-asyncio>=1.4.0", +] diff --git a/uv.lock b/uv.lock index 406f35f..e52005d 100644 --- a/uv.lock +++ b/uv.lock @@ -10,9 +10,21 @@ dependencies = [ { name = "openai" }, ] +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, +] + [package.metadata] requires-dist = [{ name = "openai", specifier = ">=2.44.0" }] +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=9.1.1" }, + { name = "pytest-asyncio", specifier = ">=1.4.0" }, +] + [[package]] name = "annotated-types" version = "0.7.0" @@ -108,6 +120,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "jiter" version = "0.16.0" @@ -213,6 +234,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ae/f4/561ed79fd94876160018a5e75254cfcb9b0e62d4dded9dcb20072e86d623/openai-2.44.0-py3-none-any.whl", hash = "sha256:0a2a3ab2e29aeda368700f662ff9ba0f9df17ba4c54577a64e08b8115a3cc0ad", size = 1366216, upload-time = "2026-06-24T20:55:58.882Z" }, ] +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +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 = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "pydantic" version = "2.13.4" @@ -330,6 +369,44 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, ] +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + [[package]] name = "sniffio" version = "1.3.1" From 278a4b2eaac84b419427cf31d6cae8e12483f0ca Mon Sep 17 00:00:00 2001 From: folathecoder Date: Mon, 6 Jul 2026 12:50:07 +0100 Subject: [PATCH 3/6] Add tools.py: @tool decorator, schema generation, and validation Turn a plain function into a model-ready tool. @tool builds a JSON Schema from type hints (Optional, Literal -> enum, list[str] -> array+items) and a Google-style docstring; Tool.call validates arguments against that schema before running, raising ToolCallError on bad input. - Tool base class (name/description/parameters, async forward, to_schema, call) - @tool decorator wrapping sync or async functions - _build_schema (hints -> JSON Schema) + _parse_docstring (per-arg descriptions) - schema hardening: additionalProperties:false, default exposure, and a param.kind guard rejecting *args/**kwargs/positional-only tools - arg validation: required/unknown/type/enum + non-dict guard -> ToolCallError - FinalAnswerTool for explicit termination - JsonSchema vs ToolSpec type split (parameters object vs full function spec) --- src/agentling/tools.py | 384 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 384 insertions(+) create mode 100644 src/agentling/tools.py diff --git a/src/agentling/tools.py b/src/agentling/tools.py new file mode 100644 index 0000000..9a55a08 --- /dev/null +++ b/src/agentling/tools.py @@ -0,0 +1,384 @@ +"""Tool primitives and schema generation utilities. + +This module defines the framework's tool abstraction, the @tool decorator for +wrapping Python functions, lightweight argument validation, and the built-in +final_answer tool used to terminate agent runs explicitly. +""" + +from __future__ import annotations + +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 .models import ToolSpec + +# JSON Schema for a tool's parameters object. This is intentionally separate +# from ToolSpec, which represents the full function tool wrapper. +JsonSchema = dict[str, Any] + + +class ToolCallError(Exception): + """Raised when a tool receives invalid model-generated arguments. + + Agent loops can catch this error and return it to the model as an + observation, allowing the model to recover from invalid tool calls without + aborting the run. + """ + + +class Tool: + """Base class for executable tools. + + Subclass this for stateful tools. For simple stateless functions, prefer the + @tool decorator. + """ + + name: str + description: str + parameters: JsonSchema + + async def forward(self, **kwargs: Any) -> Any: + """Execute the tool implementation. + + Subclasses should override this method. Arguments are validated by + call() before this method is invoked. + """ + + raise NotImplementedError + + def to_schema(self) -> ToolSpec: + """Return the provider-facing schema for this tool.""" + + return { + "type": "function", + "function": { + "name": self.name, + "description": self.description, + "parameters": self.parameters, + }, + } + + async def call(self, arguments: dict[str, Any]) -> Any: + """Validate model-generated arguments and execute the tool. + + This is the agent loop entry point. Invalid arguments raise + ToolCallError so the loop can expose the validation failure back to the + model. + """ + + if not isinstance(arguments, dict): + raise ToolCallError( + f"Tool {self.name!r} expected an arguments object, " + f"got {type(arguments).__name__}" + ) + + self._validate(arguments) + return await self.forward(**arguments) + + def _validate(self, arguments: dict[str, Any]) -> None: + """Validate arguments against the tool's JSON Schema subset.""" + + properties: dict[str, Any] = self.parameters.get("properties", {}) + required: list[str] = self.parameters.get("required", []) + + missing = [name for name in required if name not in arguments] + if missing: + raise ToolCallError( + f"Tool {self.name!r} is missing required argument(s): " + f"{', '.join(missing)}" + ) + + 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)}" + ) + + for name, value in arguments.items(): + spec = properties[name] + + expected = spec.get("type") + if expected and not _matches_json_type(value, expected): + raise ToolCallError( + f"Tool {self.name!r} argument {name!r} expects {expected}, " + f"got {type(value).__name__}" + ) + + enum = spec.get("enum") + if enum is not None and value not in enum: + raise ToolCallError( + f"Tool {self.name!r} argument {name!r} must be one of {enum}, " + f"got {value!r}" + ) + + +_JSON_TYPES: dict[type, str] = { + str: "string", + int: "integer", + float: "number", + bool: "boolean", + list: "array", + dict: "object", +} + +# JSON Schema type name -> Python runtime type(s). +# Used for lightweight validation of model-generated arguments. +_PY_TYPES: dict[str, type | tuple[type, ...]] = { + "string": str, + "integer": int, + "number": (int, float), + "boolean": bool, + "array": list, + "object": dict, +} + + +def _matches_json_type(value: Any, json_type: str) -> bool: + """Return whether a value matches a JSON Schema primitive type.""" + + # bool is a subclass of int in Python. Treat it as a distinct JSON boolean + # so True/False are not accepted for integer or number fields. + if isinstance(value, bool): + return json_type == "boolean" + + expected = _PY_TYPES.get(json_type) + return expected is None or isinstance(value, expected) + + +def _schema_for_type(python_type: Any) -> JsonSchema: + """Convert a Python type hint into a JSON Schema property schema. + + This intentionally supports a small, practical subset of Python typing: + primitive types, list/dict generics, Optional/Union with None, and Literal. + Unsupported or ambiguous types fall back to string. + """ + + origin = get_origin(python_type) + args = get_args(python_type) + + # Literal["c", "f"] becomes an enum. A JSON Schema "type" is included only + # when all literal values share the same primitive type. + if origin is Literal: + values = list(args) + if not values: + return {"type": "string"} + + value_types = {type(value) for value in values} + if len(value_types) == 1: + json_type = _JSON_TYPES.get(next(iter(value_types)), "string") + return {"type": json_type, "enum": values} + + return {"enum": values} + + # Treat Optional[X] as "the field may be omitted". If the field is supplied, + # it must still match X. Null is intentionally not advertised as valid. + if origin is Union or origin is types.UnionType: + non_none = [arg for arg in args if arg is not type(None)] + if len(non_none) == 1: + return _schema_for_type(non_none[0]) + + return {"type": "string"} + + # list[str] becomes an array with typed items. Bare list becomes an array + # without item constraints. + if origin is list: + if args: + return { + "type": "array", + "items": _schema_for_type(args[0]), + } + + return {"type": "array"} + + # dict[...] is represented as an object without per-key constraints. + if origin is dict: + return {"type": "object"} + + # For other generic aliases, fall back to the origin's base type. + if origin is not None: + return {"type": _JSON_TYPES.get(origin, "string")} + + # Plain types: str, int, float, bool, list, dict, etc. + return {"type": _JSON_TYPES.get(python_type, "string")} + + +_ARGS_HEADERS = { + "args", + "arguments", + "parameters", + "keyword args", + "keyword arguments", +} + +_HEADER_RE = re.compile(r"^([A-Za-z][A-Za-z ]*):$") +_ARG_RE = re.compile(r"^(\w+)\s*(?:\([^)]*\))?:\s*(.*)$") + + +def _parse_docstring(func: Callable[..., Any]) -> tuple[str, dict[str, str]]: + """Extract a summary and argument descriptions from a docstring. + + This is a pragmatic Google-style parser, not a complete docstring parser. + It recognizes Args/Arguments/Parameters sections and stops parsing argument + descriptions when another section header is encountered. + """ + + doc = inspect.getdoc(func) or "" + summary_lines: list[str] = [] + arg_docs: dict[str, str] = {} + + section: str | None = None + current_arg: str | None = None + + for raw in doc.splitlines(): + stripped = raw.strip() + + header = _HEADER_RE.match(stripped) + if header: + key = header.group(1).strip().lower() + section = "args" if key in _ARGS_HEADERS else "other" + current_arg = None + continue + + if section is None: + summary_lines.append(stripped) + + elif section == "args": + match = _ARG_RE.match(stripped) + + if match: + current_arg = match.group(1) + arg_docs[current_arg] = match.group(2).strip() + + elif current_arg and stripped: + arg_docs[current_arg] += " " + stripped + + # Other sections, such as Returns or Raises, are ignored. + + summary = " ".join(line for line in summary_lines if line).strip() + return summary, arg_docs + + +def _build_schema(func: Callable[..., Any]) -> JsonSchema: + """Build a JSON Schema parameters object from a function signature.""" + + hints = get_type_hints(func) + signature = inspect.signature(func) + _, arg_docs = _parse_docstring(func) + + properties: dict[str, Any] = {} + required: list[str] = [] + + for name, param in signature.parameters.items(): + if name in {"self", "cls"}: + continue + + # Tools are invoked with a JSON object of named arguments. Variadic and + # positional-only parameters cannot be represented safely, so reject + # them during tool registration rather than failing during a model run. + if param.kind in { + inspect.Parameter.VAR_POSITIONAL, + inspect.Parameter.VAR_KEYWORD, + inspect.Parameter.POSITIONAL_ONLY, + }: + raise TypeError( + f"Tool {func.__name__!r} has unsupported parameter {name!r}. " + "Tools support only normal or keyword-only parameters " + "(no *args, **kwargs, or positional-only parameters)." + ) + + schema = _schema_for_type(hints.get(name, str)) + + if name in arg_docs: + schema["description"] = arg_docs[name] + + if param.default is inspect.Parameter.empty: + required.append(name) + else: + schema["default"] = param.default + + properties[name] = schema + + return { + "type": "object", + "properties": properties, + "required": required, + "additionalProperties": False, + } + + +class _FunctionTool(Tool): + """Tool implementation backed by a plain Python function.""" + + def __init__(self, func: Callable[..., Any]) -> None: + summary, _ = _parse_docstring(func) + + self._func = func + self.name = func.__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: + """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. + + Both synchronous and asynchronous functions are supported. + + Example: + @tool + def get_weather(city: str) -> str: + '''Get the current weather for a city. + + Args: + city: The city to look up. + ''' + ... + """ + + return _FunctionTool(func) + + +class FinalAnswerTool(Tool): + """Built-in tool used to return the final answer from an agent run. + + Including final_answer in the tool set makes termination explicit: the model + signals completion by calling this tool instead of the loop inferring that a + normal assistant message should end the run. + """ + + name = "final_answer" + description = "Provide the final answer to the task and end the run." + parameters: JsonSchema = { + "type": "object", + "properties": { + "answer": { + "type": "string", + "description": "The final answer to return to the user.", + } + }, + "required": ["answer"], + "additionalProperties": False, + } + + async def forward(self, answer: str) -> str: # type: ignore[override] + # The public Tool.forward signature accepts **kwargs because tools are + # invoked generically. This implementation is intentionally narrower + # because call() validates arguments against the schema before dispatch. + return answer From 71756140cc27e98d0b6d7f50a97990278a5075f0 Mon Sep 17 00:00:00 2001 From: folathecoder Date: Mon, 6 Jul 2026 12:50:22 +0100 Subject: [PATCH 4/6] Add unit tests for tools.py (schema edge cases, validation, execution) --- tests/test_tools.py | 173 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 tests/test_tools.py diff --git a/tests/test_tools.py b/tests/test_tools.py new file mode 100644 index 0000000..f6073e1 --- /dev/null +++ b/tests/test_tools.py @@ -0,0 +1,173 @@ +from typing import Literal, Optional + +import pytest + +from agentling.tools import FinalAnswerTool, Tool, ToolCallError, tool + + +# --------------------------------------------------------------------------- # +# Schema generation +# --------------------------------------------------------------------------- # +def test_basic_types_required_and_descriptions() -> None: + @tool + def add(a: int, b: int = 0) -> int: + """Add two integers. + + Args: + a: The first number. + b: The second number. + """ + return a + b + + fn = add.to_schema()["function"] + props = fn["parameters"]["properties"] + + assert fn["name"] == "add" + assert fn["description"] == "Add two integers." + assert props["a"]["type"] == "integer" + assert props["a"]["description"] == "The first number." + assert props["b"]["type"] == "integer" + assert fn["parameters"]["required"] == ["a"] # b has a default + + +def test_literal_becomes_enum() -> None: + @tool + def pick(unit: Literal["c", "f"] = "c") -> str: + return unit + + prop = pick.to_schema()["function"]["parameters"]["properties"]["unit"] + assert prop["type"] == "string" + assert prop["enum"] == ["c", "f"] + assert prop["default"] == "c" # defaults are now exposed in the schema + + +def test_optional_is_unwrapped() -> None: + @tool + def f(x: Optional[int] = None, y: str | None = None) -> str: + return "ok" + + schema = f.to_schema()["function"]["parameters"] + assert schema["properties"]["x"]["type"] == "integer" + assert schema["properties"]["y"]["type"] == "string" + assert schema["required"] == [] + + +def test_list_and_dict() -> None: + @tool + def f(tags: list[str], meta: dict) -> str: + return "ok" + + props = f.to_schema()["function"]["parameters"]["properties"] + assert props["tags"]["type"] == "array" + assert props["meta"]["type"] == "object" + + +def test_array_includes_items() -> None: + @tool + def f(tags: list[str]) -> str: + return ",".join(tags) + + prop = f.to_schema()["function"]["parameters"]["properties"]["tags"] + assert prop == {"type": "array", "items": {"type": "string"}} + + +def test_default_is_exposed() -> None: + @tool + def f(limit: int = 5) -> int: + return limit + + prop = f.to_schema()["function"]["parameters"]["properties"]["limit"] + assert prop["default"] == 5 + + +def test_schema_forbids_additional_properties() -> None: + @tool + def f(a: int) -> int: + return a + + assert f.to_schema()["function"]["parameters"]["additionalProperties"] is False + + +def test_varargs_are_rejected() -> None: + with pytest.raises(TypeError, match="unsupported parameter"): + + @tool + def f(*args: str) -> str: + return "x" + + +# --------------------------------------------------------------------------- # +# @tool execution (sync + async) +# --------------------------------------------------------------------------- # +async def test_sync_tool_runs() -> None: + @tool + def greet(name: str) -> str: + return f"hi {name}" + + assert await greet.call({"name": "Ada"}) == "hi Ada" + + +async def test_async_tool_runs() -> None: + @tool + async def fetch(url: str) -> str: + return f"data:{url}" + + assert await fetch.call({"url": "x"}) == "data:x" + + +# --------------------------------------------------------------------------- # +# Argument validation -> ToolCallError +# --------------------------------------------------------------------------- # +@pytest.fixture +def weather() -> Tool: + @tool + def get_weather(city: str, units: Literal["c", "f"] = "c") -> str: + """Get weather. + + Args: + city: The city. + """ + return f"{city}:{units}" + + return get_weather + + +async def test_valid_call(weather: Tool) -> None: + assert await weather.call({"city": "Paris"}) == "Paris:c" + + +async def test_missing_required(weather: Tool) -> None: + with pytest.raises(ToolCallError, match="missing required"): + await weather.call({}) + + +async def test_unknown_argument(weather: Tool) -> None: + with pytest.raises(ToolCallError, match="unexpected argument"): + await weather.call({"city": "Paris", "foo": 1}) + + +async def test_wrong_type(weather: Tool) -> None: + with pytest.raises(ToolCallError, match="expects string"): + await weather.call({"city": 123}) + + +async def test_bad_enum(weather: Tool) -> None: + with pytest.raises(ToolCallError, match="must be one of"): + await weather.call({"city": "Paris", "units": "kelvin"}) + + +async def test_non_dict_arguments_rejected(weather: Tool) -> None: + with pytest.raises(ToolCallError, match="arguments object"): + await weather.call(["Paris"]) # type: ignore[arg-type] + + +# --------------------------------------------------------------------------- # +# FinalAnswerTool +# --------------------------------------------------------------------------- # +async def test_final_answer_tool() -> None: + final = FinalAnswerTool() + props = final.to_schema()["function"]["parameters"]["properties"] + + assert final.name == "final_answer" + assert "answer" in props + assert await final.call({"answer": "42"}) == "42" From 0e3ad7d21764b991be3bb1aac0cfa135a1e8c5c6 Mon Sep 17 00:00:00 2001 From: folathecoder Date: Mon, 6 Jul 2026 12:50:37 +0100 Subject: [PATCH 5/6] Document models.py: add module docstring and fill method docstrings --- src/agentling/models.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/agentling/models.py b/src/agentling/models.py index 74e49e6..ed00f27 100644 --- a/src/agentling/models.py +++ b/src/agentling/models.py @@ -1,3 +1,12 @@ +"""Provider-neutral model layer and the OpenAI-compatible adapter. + +This module defines the framework's message types (ChatMessage, ToolCall, +Usage) and streaming types (Delta, ToolCallDelta), the Model protocol that the +agent loop depends on, and OpenAIModel — an async adapter for any +OpenAI-compatible chat-completions endpoint, with rate-limit retries, +streaming, and token-usage capture. +""" + from __future__ import annotations import asyncio @@ -59,6 +68,7 @@ class Usage: @property def total_tokens(self) -> int: + """Total tokens consumed (input + output).""" return self.input_tokens + self.output_tokens @@ -85,13 +95,17 @@ async def generate( self, messages: Sequence[ChatMessage], tools: Sequence[ToolSpec] | None = None, - ) -> ChatMessage: ... + ) -> ChatMessage: + """Generate one assistant response for the conversation so far.""" + ... def stream( self, messages: Sequence[ChatMessage], tools: Sequence[ToolSpec] | None = None, - ) -> AsyncIterator[Delta]: ... + ) -> AsyncIterator[Delta]: + """Stream the assistant response as incremental deltas.""" + ... class OpenAIModel: From 944fe57cffaaed0b8e5a868619361cbb9387e58f Mon Sep 17 00:00:00 2001 From: folathecoder Date: Mon, 6 Jul 2026 12:50:53 +0100 Subject: [PATCH 6/6] Add unit tests for models.py (conversion, agglomeration, streaming, retry) --- tests/test_models.py | 290 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 290 insertions(+) create mode 100644 tests/test_models.py diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..0d59087 --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,290 @@ +from collections.abc import AsyncIterator +from types import SimpleNamespace +from typing import Any, cast + +import openai +import pytest + +from agentling.models import ( + ChatMessage, + Delta, + OpenAIModel, + ToolCall, + ToolCallDelta, + Usage, + agglomerate_deltas, +) + + +# --------------------------------------------------------------------------- # +# Helpers — fake the OpenAI client so no network calls happen. +# --------------------------------------------------------------------------- # +def _make_model(create: Any) -> OpenAIModel: + """Build an OpenAIModel whose client.chat.completions.create is `create`.""" + + model = OpenAIModel("test-model", api_key="test", retry_base_delay=0) + model.client = cast( + Any, + SimpleNamespace( + chat=SimpleNamespace(completions=SimpleNamespace(create=create)) + ), + ) + return model + + +def _rate_limit_error() -> openai.RateLimitError: + """A RateLimitError instance, built without invoking its constructor.""" + + return openai.RateLimitError.__new__(openai.RateLimitError) + + +@pytest.fixture +def model() -> OpenAIModel: + return OpenAIModel("test-model", api_key="test") + + +# --------------------------------------------------------------------------- # +# Usage +# --------------------------------------------------------------------------- # +def test_usage_total_tokens() -> None: + assert Usage(input_tokens=3, output_tokens=4).total_tokens == 7 + + +# --------------------------------------------------------------------------- # +# Outbound conversion: ChatMessage -> OpenAI dicts +# --------------------------------------------------------------------------- # +def test_to_openai_messages_system_and_user(model: OpenAIModel) -> None: + out = model._to_openai_messages( + [ + ChatMessage(role="system", content="sys"), + ChatMessage(role="user", content="hi"), + ] + ) + assert out == [ + {"role": "system", "content": "sys"}, + {"role": "user", "content": "hi"}, + ] + + +def test_to_openai_messages_assistant_with_tool_calls(model: OpenAIModel) -> None: + msg = ChatMessage( + role="assistant", + content="", + tool_calls=[ToolCall(id="c1", name="f", arguments={"a": 1})], + ) + out = model._to_openai_messages([msg]) + assert out == [ + { + "role": "assistant", + "content": None, # empty string is coerced to null on the way out + "tool_calls": [ + { + "id": "c1", + "type": "function", + "function": {"name": "f", "arguments": '{"a": 1}'}, + } + ], + } + ] + + +def test_to_openai_messages_tool_result(model: OpenAIModel) -> None: + out = model._to_openai_messages( + [ChatMessage(role="tool", content="result", tool_call_id="c1")] + ) + assert out == [{"role": "tool", "content": "result", "tool_call_id": "c1"}] + + +def test_tool_message_without_id_raises(model: OpenAIModel) -> None: + with pytest.raises(ValueError, match="tool_call_id"): + model._to_openai_messages([ChatMessage(role="tool", content="r")]) + + +# --------------------------------------------------------------------------- # +# Inbound conversion helpers +# --------------------------------------------------------------------------- # +def test_from_openai_usage_none(model: OpenAIModel) -> None: + assert model._from_openai_usage(None) == Usage(0, 0) + + +def test_from_openai_usage_maps_fields(model: OpenAIModel) -> None: + usage = SimpleNamespace(prompt_tokens=7, completion_tokens=3) + assert model._from_openai_usage(usage) == Usage(7, 3) + + +def test_from_openai_tool_calls_none(model: OpenAIModel) -> None: + assert model._from_openai_tool_calls(None) == [] + + +def test_from_openai_tool_calls_maps(model: OpenAIModel) -> None: + tc = SimpleNamespace( + id="c1", function=SimpleNamespace(name="f", arguments='{"a": 1}') + ) + assert model._from_openai_tool_calls([tc]) == [ + ToolCall(id="c1", name="f", arguments={"a": 1}) + ] + + +def test_parse_tool_arguments_valid(model: OpenAIModel) -> None: + assert model._parse_tool_arguments('{"x": 1}') == {"x": 1} + + +def test_parse_tool_arguments_empty_defaults_to_object(model: OpenAIModel) -> None: + assert model._parse_tool_arguments("") == {} + + +def test_parse_tool_arguments_invalid_json_raises(model: OpenAIModel) -> None: + with pytest.raises(ValueError, 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"): + model._parse_tool_arguments("[1, 2]") + + +# --------------------------------------------------------------------------- # +# agglomerate_deltas — reassemble a stream into one ChatMessage +# --------------------------------------------------------------------------- # +def test_agglomerate_concatenates_content() -> None: + msg = agglomerate_deltas([Delta(content="Hel"), Delta(content="lo"), Delta()]) + assert msg.role == "assistant" + assert msg.content == "Hello" + assert msg.tool_calls == [] + + +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, arguments='ty": "Paris"}')]), + ] + msg = agglomerate_deltas(deltas) + assert msg.tool_calls == [ + ToolCall(id="c1", name="wx", arguments={"city": "Paris"}) + ] + + +def test_agglomerate_groups_parallel_calls_by_index() -> None: + deltas = [ + Delta(tool_calls=[ToolCallDelta(index=0, id="a", name="f", arguments="{}")]), + Delta(tool_calls=[ToolCallDelta(index=1, id="b", name="g", arguments="{}")]), + ] + msg = agglomerate_deltas(deltas) + assert [tc.id for tc in msg.tool_calls] == ["a", "b"] + assert [tc.name for tc in msg.tool_calls] == ["f", "g"] + + +def test_agglomerate_captures_usage() -> None: + msg = agglomerate_deltas([Delta(content="x"), Delta(usage=Usage(5, 2))]) + assert msg.usage == Usage(5, 2) + + +# --------------------------------------------------------------------------- # +# generate (fake client) +# --------------------------------------------------------------------------- # +async def test_generate_returns_message_with_usage() -> None: + async def fake_create(**kwargs: Any) -> Any: + return SimpleNamespace( + choices=[ + SimpleNamespace( + message=SimpleNamespace(content="hello", tool_calls=None) + ) + ], + usage=SimpleNamespace(prompt_tokens=5, completion_tokens=2), + ) + + model = _make_model(fake_create) + msg = await model.generate([ChatMessage(role="user", content="hi")]) + + assert msg.role == "assistant" + assert msg.content == "hello" + assert msg.usage == Usage(5, 2) + + +async def test_generate_parses_tool_calls_and_coerces_content() -> None: + async def fake_create(**kwargs: Any) -> Any: + tc = SimpleNamespace( + id="call_1", + function=SimpleNamespace(name="get_weather", arguments='{"city": "Paris"}'), + ) + return SimpleNamespace( + choices=[SimpleNamespace(message=SimpleNamespace(content=None, tool_calls=[tc]))], + usage=SimpleNamespace(prompt_tokens=1, completion_tokens=1), + ) + + model = _make_model(fake_create) + msg = await model.generate([ChatMessage(role="user", content="weather?")]) + + assert msg.content == "" # None coerced to "" + assert msg.tool_calls == [ + ToolCall(id="call_1", name="get_weather", arguments={"city": "Paris"}) + ] + + +# --------------------------------------------------------------------------- # +# stream (fake client) +# --------------------------------------------------------------------------- # +async def test_stream_yields_deltas_then_agglomerates() -> None: + chunks = [ + SimpleNamespace( + choices=[SimpleNamespace(delta=SimpleNamespace(content="Hel", tool_calls=None))], + usage=None, + ), + SimpleNamespace( + choices=[SimpleNamespace(delta=SimpleNamespace(content="lo", tool_calls=None))], + usage=None, + ), + SimpleNamespace( # final usage-only chunk + choices=[], + usage=SimpleNamespace(prompt_tokens=3, completion_tokens=1), + ), + ] + + async def fake_create(**kwargs: Any) -> Any: + async def gen() -> AsyncIterator[Any]: + for chunk in chunks: + yield chunk + + return gen() + + model = _make_model(fake_create) + deltas = [d async for d in model.stream([ChatMessage(role="user", content="hi")])] + final = agglomerate_deltas(deltas) + + assert final.content == "Hello" + assert final.usage == Usage(3, 1) + + +# --------------------------------------------------------------------------- # +# retry-on-429 +# --------------------------------------------------------------------------- # +async def test_retry_then_succeeds() -> None: + calls = 0 + result = SimpleNamespace( + choices=[SimpleNamespace(message=SimpleNamespace(content="ok", tool_calls=None))], + usage=SimpleNamespace(prompt_tokens=1, completion_tokens=1), + ) + + async def flaky_create(**kwargs: Any) -> Any: + nonlocal calls + calls += 1 + if calls == 1: + raise _rate_limit_error() + return result + + model = _make_model(flaky_create) + msg = await model.generate([ChatMessage(role="user", content="hi")]) + + assert msg.content == "ok" + assert calls == 2 # failed once, retried, then succeeded + + +async def test_retry_gives_up_after_max_retries() -> None: + async def always_fails(**kwargs: Any) -> Any: + raise _rate_limit_error() + + model = _make_model(always_fails) + model.max_retries = 1 # 2 attempts total + + with pytest.raises(openai.RateLimitError): + await model.generate([ChatMessage(role="user", content="hi")])