diff --git a/examples/agents-temporal/activities.py b/examples/agents-temporal/activities.py new file mode 100644 index 00000000..dbce3b1e --- /dev/null +++ b/examples/agents-temporal/activities.py @@ -0,0 +1,101 @@ +"""Temporal activities — all real I/O lives here. + +Both examples (direct composition and provider) share these activities. +Each activity is a plain async function that does real I/O. +""" + +from __future__ import annotations + +import dataclasses +from typing import Any + +import temporalio.activity + +import vercel_ai_sdk as ai + +# ── Tool activities ────────────────────────────────────────────── + + +@temporalio.activity.defn(name="get_weather") +async def get_weather_activity(city: str) -> str: + return f"Sunny, 72F in {city}" + + +@temporalio.activity.defn(name="get_population") +async def get_population_activity(city: str) -> int: + return {"new york": 8_336_817, "los angeles": 3_979_576}.get( + city.lower(), 1_000_000 + ) + + +# ── Generic tool dispatch activity ────────────────────────────── +# +# Used by the provider example: the provider routes tool calls here +# instead of executing them inside the workflow. + +_TOOL_REGISTRY: dict[str, Any] = { + "get_weather": get_weather_activity, + "get_population": get_population_activity, +} + + +@dataclasses.dataclass +class ToolDispatchParams: + tool_name: str + tool_args: str # JSON string + + +@dataclasses.dataclass +class ToolDispatchResult: + result: Any + is_error: bool = False + + +@temporalio.activity.defn(name="tool_dispatch") +async def tool_dispatch_activity(params: ToolDispatchParams) -> ToolDispatchResult: + """Dispatch a tool call by name. Runs the real tool function.""" + import json + + fn = _TOOL_REGISTRY.get(params.tool_name) + if fn is None: + return ToolDispatchResult( + result=f"Unknown tool: {params.tool_name}", is_error=True + ) + + try: + kwargs = json.loads(params.tool_args) if params.tool_args else {} + result = await fn(**kwargs) + return ToolDispatchResult(result=result) + except Exception as exc: + return ToolDispatchResult(result=str(exc), is_error=True) + + +# ── LLM activity ──────────────────────────────────────────────── + + +@dataclasses.dataclass +class LLMCallParams: + messages: list[dict[str, Any]] + tool_schemas: list[dict[str, Any]] + + +@dataclasses.dataclass +class LLMCallResult: + message: dict[str, Any] # serialized ai.Message + + +@temporalio.activity.defn(name="llm_call") +async def llm_call_activity(params: LLMCallParams) -> LLMCallResult: + """Call the LLM, drain the stream, return the final message.""" + model = ai.Model( + id="anthropic/claude-sonnet-4-20250514", + adapter="ai-gateway-v3", + provider="ai-gateway", + ) + + messages = [ai.Message.model_validate(m) for m in params.messages] + tools = [ai.ToolSchema(return_type=None, **t) for t in params.tool_schemas] + + s = await ai.models.stream(model, messages, tools=tools) + result = await ai.models.buffer(s) + return LLMCallResult(message=result.model_dump()) diff --git a/examples/agents-temporal/direct.py b/examples/agents-temporal/direct.py new file mode 100644 index 00000000..24e0b52a --- /dev/null +++ b/examples/agents-temporal/direct.py @@ -0,0 +1,129 @@ +"""Direct composition: a custom loop where every I/O call is a Temporal activity. + +No DurabilityProvider is involved. The user writes a plain ``@agent.loop`` +that replaces ``models.stream()`` and tool execution with Temporal +``execute_activity()`` calls. Temporal's event history provides durability. + +This is the "lego bricks" approach: the framework gives you ``Agent``, +``Context``, ``@tool`` (for schema extraction), and the message types. +You compose them with Temporal yourself. +""" + +from __future__ import annotations + +import asyncio +import datetime +from collections.abc import AsyncGenerator +from typing import Any + +import temporalio.common +import temporalio.workflow + +with temporalio.workflow.unsafe.imports_passed_through(): + import activities + + import vercel_ai_sdk as ai + from vercel_ai_sdk.agents import Context, agent, tool + + +# ── Tools ──────────────────────────────────────────────────────── +# +# Defined with @tool so the agent can extract JSON schemas for the +# LLM. The bodies are never called inside the workflow — execution +# goes through Temporal activities instead. + + +@tool +async def get_weather(city: str) -> str: + """Get current weather for a city.""" + raise RuntimeError("should not be called inside workflow") + + +@tool +async def get_population(city: str) -> int: + """Get population of a city.""" + raise RuntimeError("should not be called inside workflow") + + +# ── Agent with custom loop ─────────────────────────────────────── + +weather_agent = agent(tools=[get_weather, get_population]) + + +@weather_agent.loop +async def temporal_loop(context: Context) -> AsyncGenerator[ai.Message]: + """Agent loop where every I/O call is a durable Temporal activity.""" + tool_schemas = [ + { + "name": t.name, + "description": t.description, + "param_schema": t.param_schema, + } + for t in context.tools + ] + + while True: + # LLM call via activity. + result = await temporalio.workflow.execute_activity( + activities.llm_call_activity, + activities.LLMCallParams( + messages=[m.model_dump() for m in context.messages], + tool_schemas=tool_schemas, + ), + start_to_close_timeout=datetime.timedelta(minutes=5), + retry_policy=temporalio.common.RetryPolicy(maximum_attempts=3), + ) + msg = ai.Message.model_validate(result.message) + yield msg + + if not msg.tool_calls: + break + + # Tool calls via activities (parallel). + tool_call_parts = msg.tool_calls + + async def _run_tool(tc: Any) -> ai.ToolResultPart: + dispatch_result = await temporalio.workflow.execute_activity( + activities.tool_dispatch_activity, + activities.ToolDispatchParams( + tool_name=tc.tool_name, + tool_args=tc.tool_args, + ), + start_to_close_timeout=datetime.timedelta(minutes=2), + ) + return ai.ToolResultPart( + tool_call_id=tc.tool_call_id, + tool_name=tc.tool_name, + result=dispatch_result.result, + is_error=dispatch_result.is_error, + ) + + tasks = [asyncio.ensure_future(_run_tool(tc)) for tc in tool_call_parts] + results = await asyncio.gather(*tasks) + yield ai.tool_message(*results) + + +# ── Workflow ───────────────────────────────────────────────────── + + +@temporalio.workflow.defn +class DirectWorkflow: + @temporalio.workflow.run + async def run(self, user_query: str) -> str: + model = ai.Model( + id="anthropic/claude-sonnet-4-20250514", + adapter="ai-gateway-v3", + provider="ai-gateway", + ) + messages: list[ai.Message] = [ + ai.system_message( + "Answer questions using the weather and population tools." + ), + ai.user_message(user_query), + ] + + final_text = "" + async for msg in weather_agent.run(model, messages): + if msg.text: + final_text = msg.text + return final_text diff --git a/examples/agents-temporal/main.py b/examples/agents-temporal/main.py new file mode 100644 index 00000000..4b269e4d --- /dev/null +++ b/examples/agents-temporal/main.py @@ -0,0 +1,78 @@ +"""Entry point — starts a Temporal worker and executes the agent workflow. + +Two examples in one project: + - ``direct`` — custom loop, each I/O call is a Temporal activity + - ``provider`` — default loop, DurabilityProvider routes I/O to activities + +Prerequisites: + 1. Temporal dev server: temporal server start-dev + 2. AI_GATEWAY_API_KEY environment variable set + +Usage: + uv run python main.py direct + uv run python main.py provider + uv run python main.py direct "What is the weather in Tokyo?" + uv run python main.py provider "Compare weather in NYC and LA" +""" + +from __future__ import annotations + +import asyncio +import sys +import uuid + +import activities +import direct +import provider +import temporalio.client +import temporalio.worker + +TASK_QUEUE = "agents-durable" + + +async def main(mode: str, user_query: str) -> None: + temporal = await temporalio.client.Client.connect("localhost:7233") + + workflows = { + "direct": direct.DirectWorkflow, + "provider": provider.ProviderWorkflow, + } + workflow_cls = workflows[mode] + + async with temporalio.worker.Worker( + temporal, + task_queue=TASK_QUEUE, + workflows=[workflow_cls], + activities=[ + activities.llm_call_activity, + activities.get_weather_activity, + activities.get_population_activity, + activities.tool_dispatch_activity, + ], + ): + workflow_id = f"agents-{mode}-{uuid.uuid4().hex[:8]}" + print(f"Mode: {mode}") + print(f"Workflow: {workflow_id}") + print(f"Query: {user_query}\n") + + result = await temporal.execute_workflow( + workflow_cls.run, + user_query, + id=workflow_id, + task_queue=TASK_QUEUE, + ) + print(result) + + +if __name__ == "__main__": + if len(sys.argv) < 2 or sys.argv[1] not in ("direct", "provider"): + print("Usage: python main.py [query]") + sys.exit(1) + + mode = sys.argv[1] + query = ( + sys.argv[2] + if len(sys.argv) > 2 + else "What's the weather and population of New York and Los Angeles?" + ) + asyncio.run(main(mode, query)) diff --git a/examples/agents-temporal/provider.py b/examples/agents-temporal/provider.py new file mode 100644 index 00000000..bd5891ac --- /dev/null +++ b/examples/agents-temporal/provider.py @@ -0,0 +1,204 @@ +"""Provider-based durability: a DurabilityProvider backed by Temporal activities. + +The agent uses the **default loop** unchanged. A TemporalDurabilityProvider +is passed to ``agent.run(durability=...)``, and the framework auto-routes +``models.stream()`` and ``ToolCall.__call__()`` through the provider via +context var. + +The provider ignores the factory closures it receives (they can't be +serialized to a Temporal activity) and calls its own Temporal activities +instead. It tracks the conversation internally so it can serialize the +correct messages to the LLM activity at each turn. +""" + +from __future__ import annotations + +import datetime +from collections.abc import AsyncGenerator, Awaitable, Callable +from typing import Any + +import temporalio.common +import temporalio.workflow + +with temporalio.workflow.unsafe.imports_passed_through(): + import activities + + import vercel_ai_sdk as ai + from vercel_ai_sdk.agents import ( + Checkpoint, + agent, + tool, + ) + + +# ── Tools ──────────────────────────────────────────────────────── +# +# Defined with @tool for schema extraction. The default loop will +# try to call them via ToolCall.__call__(), but the provider intercepts +# and routes to Temporal activities instead. + + +@tool +async def get_weather(city: str) -> str: + """Get current weather for a city.""" + raise RuntimeError("should not be called inside workflow") + + +@tool +async def get_population(city: str) -> int: + """Get population of a city.""" + raise RuntimeError("should not be called inside workflow") + + +# ── Temporal durability provider ───────────────────────────────── + + +class _ActivityStreamResult: + """StreamResult-like wrapper around a buffered message from an activity.""" + + def __init__(self, message: ai.Message) -> None: + self._message = message + + def __aiter__(self) -> AsyncGenerator[ai.Message]: + return self._generate() + + async def _generate(self) -> AsyncGenerator[ai.Message]: + yield self._message + + @property + def tool_calls(self) -> list[ai.ToolCallPart]: + return self._message.tool_calls + + @property + def text(self) -> str: + return self._message.text + + @property + def usage(self) -> ai.Usage | None: + return self._message.usage + + @property + def output(self) -> Any: + return self._message.output + + +class TemporalDurabilityProvider: + """DurabilityProvider that routes LLM and tool I/O through Temporal activities. + + Temporal's event history provides durability — on replay, activities + return cached results automatically. No checkpoint needed on our side. + + The provider maintains its own copy of the conversation so it can + serialize the correct messages to the LLM activity at each step. + """ + + def __init__( + self, + initial_messages: list[ai.Message], + tool_schemas: list[dict[str, Any]], + ) -> None: + self._messages: list[ai.Message] = list(initial_messages) + self._tool_schemas = tool_schemas + + async def execute_stream( + self, + fn: Callable[[], Awaitable[Any]], + ) -> _ActivityStreamResult: + """Call the LLM via a Temporal activity instead of fn().""" + result = await temporalio.workflow.execute_activity( + activities.llm_call_activity, + activities.LLMCallParams( + messages=[m.model_dump() for m in self._messages], + tool_schemas=self._tool_schemas, + ), + start_to_close_timeout=datetime.timedelta(minutes=5), + retry_policy=temporalio.common.RetryPolicy(maximum_attempts=3), + ) + msg = ai.Message.model_validate(result.message) + # Track the assistant response. + self._messages.append(msg) + return _ActivityStreamResult(msg) + + async def execute_tool( + self, + fn: Callable[[], Awaitable[ai.ToolResultPart]], + *, + tool_call_id: str, + tool_name: str, + ) -> ai.ToolResultPart: + """Execute a tool via a Temporal activity instead of fn().""" + # Find tool_args from the last assistant message we recorded. + tool_args = "" + for msg in reversed(self._messages): + for tc in msg.tool_calls: + if tc.tool_call_id == tool_call_id: + tool_args = tc.tool_args + break + + result = await temporalio.workflow.execute_activity( + activities.tool_dispatch_activity, + activities.ToolDispatchParams( + tool_name=tool_name, + tool_args=tool_args, + ), + start_to_close_timeout=datetime.timedelta(minutes=2), + ) + + tool_result = ai.ToolResultPart( + tool_call_id=tool_call_id, + tool_name=tool_name, + result=result.result, + is_error=result.is_error, + ) + # Track the tool result. The default loop yields a tool_message + # after gathering all results, and _collect_messages appends it + # to context.messages. We mirror that here so our next LLM call + # has the full conversation. + self._messages.append(ai.tool_message(tool_result)) + return tool_result + + def checkpoint(self) -> Checkpoint: + """Temporal is the event store — no checkpoint needed.""" + return Checkpoint() + + +# ── Agent (uses default loop) ──────────────────────────────────── + +weather_agent = agent(tools=[get_weather, get_population]) + + +# ── Workflow ───────────────────────────────────────────────────── + + +@temporalio.workflow.defn +class ProviderWorkflow: + @temporalio.workflow.run + async def run(self, user_query: str) -> str: + model = ai.Model( + id="anthropic/claude-sonnet-4-20250514", + adapter="ai-gateway-v3", + provider="ai-gateway", + ) + messages: list[ai.Message] = [ + ai.system_message( + "Answer questions using the weather and population tools." + ), + ai.user_message(user_query), + ] + + tool_schemas = [ + { + "name": t.name, + "description": t.description, + "param_schema": t.param_schema, + } + for t in weather_agent._tools + ] + + provider = TemporalDurabilityProvider(messages, tool_schemas) + + final_text = "" + async for msg in weather_agent.run(model, messages, durability=provider): + if msg.text: + final_text = msg.text + return final_text diff --git a/examples/temporal-durable/pyproject.toml b/examples/agents-temporal/pyproject.toml similarity index 90% rename from examples/temporal-durable/pyproject.toml rename to examples/agents-temporal/pyproject.toml index dcd7b894..787e4328 100644 --- a/examples/temporal-durable/pyproject.toml +++ b/examples/agents-temporal/pyproject.toml @@ -1,5 +1,5 @@ [project] -name = "temporal-durable" +name = "agents-temporal" version = "0.1.0" description = "Durable agent execution with Temporal" requires-python = ">=3.12" diff --git a/examples/temporal-durable/uv.lock b/examples/agents-temporal/uv.lock similarity index 74% rename from examples/temporal-durable/uv.lock rename to examples/agents-temporal/uv.lock index f4dc0c58..dbb06dae 100644 --- a/examples/temporal-durable/uv.lock +++ b/examples/agents-temporal/uv.lock @@ -2,6 +2,21 @@ version = 1 revision = 3 requires-python = ">=3.12" +[[package]] +name = "agents3-temporal" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "temporalio" }, + { name = "vercel-ai-sdk" }, +] + +[package.metadata] +requires-dist = [ + { name = "temporalio", specifier = ">=1.9.0" }, + { name = "vercel-ai-sdk", editable = "../../" }, +] + [[package]] name = "annotated-types" version = "0.7.0" @@ -13,7 +28,7 @@ wheels = [ [[package]] name = "anthropic" -version = "0.79.0" +version = "0.92.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -25,40 +40,70 @@ dependencies = [ { name = "sniffio" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/15/b1/91aea3f8fd180d01d133d931a167a78a3737b3fd39ccef2ae8d6619c24fd/anthropic-0.79.0.tar.gz", hash = "sha256:8707aafb3b1176ed6c13e2b1c9fb3efddce90d17aee5d8b83a86c70dcdcca871", size = 509825, upload-time = "2026-02-07T18:06:18.388Z" } +sdist = { url = "https://files.pythonhosted.org/packages/01/2d/fc5c5a369db977efbaa646d77ba42b38a6de4e95789884032b0e2e3fc834/anthropic-0.92.0.tar.gz", hash = "sha256:d1e792ed0692379452a1af6b266df495e973c3695cd0aace2a108b838393cbc4", size = 652420, upload-time = "2026-04-08T16:55:35.37Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/95/b2/cc0b8e874a18d7da50b0fda8c99e4ac123f23bf47b471827c5f6f3e4a767/anthropic-0.79.0-py3-none-any.whl", hash = "sha256:04cbd473b6bbda4ca2e41dd670fe2f829a911530f01697d0a1e37321eb75f3cf", size = 405918, upload-time = "2026-02-07T18:06:20.246Z" }, + { url = "https://files.pythonhosted.org/packages/c3/21/bf5b5ab10b6932c5c43eaa66b6e3f256de569cf0323d89f9cc281a0d0f39/anthropic-0.92.0-py3-none-any.whl", hash = "sha256:f92a4bd065d5cab90a96b65bb44e473bf7c6fe731a743cd156e9ad1d245c381e", size = 621195, upload-time = "2026-04-08T16:55:33.639Z" }, ] [[package]] name = "anyio" -version = "4.12.1" +version = "4.13.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, + { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" }, ] [[package]] name = "attrs" -version = "25.4.0" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "cbor2" +version = "5.9.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/cb/09939728be094d155b5d4ac262e39877875f5f7e36eea66beb359f647bd0/cbor2-5.9.0.tar.gz", hash = "sha256:85c7a46279ac8f226e1059275221e6b3d0e370d2bb6bd0500f9780781615bcea", size = 111231, upload-time = "2026-03-22T15:56:50.638Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, + { url = "https://files.pythonhosted.org/packages/ee/39/72d8a5a4b06565561ec28f4fcb41aff7bb77f51705c01f00b8254a2aca4f/cbor2-5.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1f223dffb1bcdd2764665f04c1152943d9daa4bc124a576cd8dee1cad4264313", size = 71223, upload-time = "2026-03-22T15:56:13.68Z" }, + { url = "https://files.pythonhosted.org/packages/09/fd/7ddf3d3153b54c69c3be77172b8d9aa3a9d74f62a7fbde614d53eaeed9a4/cbor2-5.9.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae6c706ac1d85a0b3cb3395308fd0c4d55e3202b4760773675957e93cdff45fc", size = 287865, upload-time = "2026-03-22T15:56:14.813Z" }, + { url = "https://files.pythonhosted.org/packages/db/9d/7ede2cc42f9bb4260492e7d29d2aab781eacbbcfb09d983de1e695077199/cbor2-5.9.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4cd43d8fc374b31643b2830910f28177a606a7bc84975a62675dd3f2e320fc7b", size = 288246, upload-time = "2026-03-22T15:56:16.113Z" }, + { url = "https://files.pythonhosted.org/packages/ce/9d/588ebc7c5bc5843f609b05fe07be8575c7dec987735b0bbc908ac9c1264a/cbor2-5.9.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4aa07b392cc3d76fb31c08a46a226b58c320d1c172ff3073e864409ced7bc50f", size = 280214, upload-time = "2026-03-22T15:56:17.519Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a1/6fc8f4b15c6a27e7fbb7966c30c2b4b18c274a3221fa2f5e6235502d34bc/cbor2-5.9.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:971d425b3a23b75953d8853d5f9911bdeefa09d759ee3b5e6b07b5ff3cbd9073", size = 282162, upload-time = "2026-03-22T15:56:18.975Z" }, + { url = "https://files.pythonhosted.org/packages/cf/20/9a22cfe08be16ddfeef2542cf4eeed1b29f3f57ddbba0b42f7e0bb8331fd/cbor2-5.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:34a6cb15e6ab6a8eae94ad2041731cd3ef786af43a8df99f847969af5b902ee7", size = 70049, upload-time = "2026-03-22T15:56:20.502Z" }, + { url = "https://files.pythonhosted.org/packages/c6/9e/695f92d09006614034e25a9f5b10620f3b219f79c1bec3c37b7c6f27a7a9/cbor2-5.9.0-cp312-cp312-win_arm64.whl", hash = "sha256:7d1ddc4541e7367ac58c2470cc0df847f7137167fe4f5729e2d3cc0b993d7da4", size = 65382, upload-time = "2026-03-22T15:56:21.526Z" }, + { url = "https://files.pythonhosted.org/packages/81/c5/4901e21a8afe9448fd947b11e8f383903207cd6dd0800e5f5a386838de5b/cbor2-5.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fbb06f34aa645b4deca66643bba3d400d20c15312d1fe88d429be60c1ab50f27", size = 71284, upload-time = "2026-03-22T15:56:22.836Z" }, + { url = "https://files.pythonhosted.org/packages/1b/10/df643a381aebc3f05486de4813662bc58accb640fc3275cb276a75e89694/cbor2-5.9.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac684fe195c39821fca70d18afbf748f728aefbfbf88456018d299e559b8cae0", size = 287682, upload-time = "2026-03-22T15:56:24.024Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0c/8aa6b766059ae4a0ca1ec3ff96fe3823a69a7be880dba2e249f7fbe2700b/cbor2-5.9.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2a54fbb32cb828c214f7f333a707e4aec61182e7efdc06ea5d9596d3ecee624a", size = 288009, upload-time = "2026-03-22T15:56:25.305Z" }, + { url = "https://files.pythonhosted.org/packages/74/07/6236bc25c183a9cf7e8062e5dddf9eae9b0b14ebf14a58a69fe5a1e872c6/cbor2-5.9.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4753a6d1bc71054d9179557bc65740860f185095ccb401d46637fff028a5b3ec", size = 280437, upload-time = "2026-03-22T15:56:26.479Z" }, + { url = "https://files.pythonhosted.org/packages/4e/0a/84328d23c3c68874ac6497edb9b1900579a1028efa54734df3f1762bbc15/cbor2-5.9.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:380e534482b843e43442b87d8777a7bf9bed20cb7526f89b780c3400f617304b", size = 282247, upload-time = "2026-03-22T15:56:28.644Z" }, + { url = "https://files.pythonhosted.org/packages/9b/f6/89b4627e09d028c8e5fcaf7cb55f225c33ce6e037ec1844e65d02bcfa945/cbor2-5.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:dcf0f695873e5c94bd072d6af8698e72b8fb7f7a18f37e0bced1041b7111a6cf", size = 70089, upload-time = "2026-03-22T15:56:29.801Z" }, + { url = "https://files.pythonhosted.org/packages/e2/7c/efadcd5f0102db692490e4e206988a2f98d39a09912090db497a2b800885/cbor2-5.9.0-cp313-cp313-win_arm64.whl", hash = "sha256:f7c9751a9611601ab326d8f5837f01379195bbf06175fb4effeb552140e7c9e8", size = 65466, upload-time = "2026-03-22T15:56:30.823Z" }, + { url = "https://files.pythonhosted.org/packages/08/7d/9ccc36d10ef96e6038e48046ebe1ce35a1e7814da0e1e204d09e6ef09b8d/cbor2-5.9.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23606d31ba1368bd1b6602e3020ee88fe9523ca80e8630faf6b2fc904fd84560", size = 71500, upload-time = "2026-03-22T15:56:31.876Z" }, + { url = "https://files.pythonhosted.org/packages/70/e1/a6cca2cc72e13f00030c6a649f57ae703eb2c620806ab70c40db8eab33fa/cbor2-5.9.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0322296b9d52f55880e300ba8ba09ecf644303b99b51138bbb1c0fb644fa7c3e", size = 286953, upload-time = "2026-03-22T15:56:33.292Z" }, + { url = "https://files.pythonhosted.org/packages/08/3c/24cd5ef488a957d90e016f200a3aad820e4c2f85edd61c9fe4523007a1ee/cbor2-5.9.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:422817286c1d0ce947fb2f7eca9212b39bddd7231e8b452e2d2cc52f15332dba", size = 285454, upload-time = "2026-03-22T15:56:34.703Z" }, + { url = "https://files.pythonhosted.org/packages/a4/35/dca96818494c0ba47cdd73e8d809b27fa91f8fa0ce32a068a09237687454/cbor2-5.9.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9a4907e0c3035bb8836116854ed8e56d8aef23909d601fa59706320897ec2551", size = 279441, upload-time = "2026-03-22T15:56:35.888Z" }, + { url = "https://files.pythonhosted.org/packages/a4/44/d3362378b16e53cf7e535a3f5aed8476e2109068154e24e31981ef5bde9e/cbor2-5.9.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fb7afe77f8d269e42d7c4b515c6fd14f1ccc0625379fb6829b269f493d16eddd", size = 279673, upload-time = "2026-03-22T15:56:37.08Z" }, + { url = "https://files.pythonhosted.org/packages/43/d1/3533a697e5842fff7c2f64912eb251f8dcab3a8b5d88e228d6eebc3b5021/cbor2-5.9.0-cp314-cp314-win_amd64.whl", hash = "sha256:86baf870d4c0bfc6f79de3801f3860a84ab76d9c8b0abb7f081f2c14c38d79d3", size = 71940, upload-time = "2026-03-22T15:56:38.366Z" }, + { url = "https://files.pythonhosted.org/packages/ff/e2/c6ba75f3fb25dfa15ab6999cc8709c821987e9ed8e375d7f58539261bcb9/cbor2-5.9.0-cp314-cp314-win_arm64.whl", hash = "sha256:7221483fad0c63afa4244624d552abf89d7dfdbc5f5edfc56fc1ff2b4b818975", size = 67639, upload-time = "2026-03-22T15:56:39.39Z" }, + { url = "https://files.pythonhosted.org/packages/42/ff/b83492b096fbef26e9cb62c1a4bf2d3cef579ea7b33138c6c37c4ae66f67/cbor2-5.9.0-py3-none-any.whl", hash = "sha256:27695cbd70c90b8de5c4a284642c2836449b14e2c2e07e3ffe0744cb7669a01b", size = 24627, upload-time = "2026-03-22T15:56:48.847Z" }, ] [[package]] name = "certifi" -version = "2026.1.4" +version = "2026.2.25" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, ] [[package]] @@ -120,14 +165,14 @@ wheels = [ [[package]] name = "click" -version = "8.3.1" +version = "8.3.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +sdist = { url = "https://files.pythonhosted.org/packages/57/75/31212c6bf2503fdf920d87fee5d7a86a2e3bcf444984126f13d8e4016804/click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5", size = 302856, upload-time = "2026-04-03T19:14:45.118Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, + { url = "https://files.pythonhosted.org/packages/e4/20/71885d8b97d4f3dde17b1fdb92dbd4908b00541c5a3379787137285f602e/click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d", size = 108379, upload-time = "2026-04-03T19:14:43.505Z" }, ] [[package]] @@ -141,55 +186,55 @@ wheels = [ [[package]] name = "cryptography" -version = "46.0.5" +version = "46.0.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" }, - { url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" }, - { url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" }, - { url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" }, - { url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" }, - { url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" }, - { url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" }, - { url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" }, - { url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" }, - { url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" }, - { url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" }, - { url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" }, - { url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" }, - { url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" }, - { url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287, upload-time = "2026-02-10T19:17:33.801Z" }, - { url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" }, - { url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" }, - { url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" }, - { url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539, upload-time = "2026-02-10T19:17:40.241Z" }, - { url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" }, - { url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131, upload-time = "2026-02-10T19:17:43.379Z" }, - { url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" }, - { url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170, upload-time = "2026-02-10T19:17:46.997Z" }, - { url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" }, - { url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" }, - { url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" }, - { url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637, upload-time = "2026-02-10T19:17:52.997Z" }, - { url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487, upload-time = "2026-02-10T19:17:54.549Z" }, - { url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" }, - { url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" }, - { url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" }, - { url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" }, - { url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" }, - { url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" }, - { url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" }, - { url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" }, - { url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" }, - { url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" }, - { url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" }, - { url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" }, - { url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" }, - { url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869, upload-time = "2026-04-08T01:56:17.157Z" }, + { url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" }, + { url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" }, + { url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" }, + { url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" }, + { url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" }, + { url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" }, + { url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" }, + { url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" }, + { url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" }, + { url = "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", size = 3026504, upload-time = "2026-04-08T01:56:39.666Z" }, + { url = "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", size = 3488363, upload-time = "2026-04-08T01:56:41.893Z" }, + { url = "https://files.pythonhosted.org/packages/7b/56/15619b210e689c5403bb0540e4cb7dbf11a6bf42e483b7644e471a2812b3/cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842", size = 7119671, upload-time = "2026-04-08T01:56:44Z" }, + { url = "https://files.pythonhosted.org/packages/74/66/e3ce040721b0b5599e175ba91ab08884c75928fbeb74597dd10ef13505d2/cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", size = 4268551, upload-time = "2026-04-08T01:56:46.071Z" }, + { url = "https://files.pythonhosted.org/packages/03/11/5e395f961d6868269835dee1bafec6a1ac176505a167f68b7d8818431068/cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", size = 4408887, upload-time = "2026-04-08T01:56:47.718Z" }, + { url = "https://files.pythonhosted.org/packages/40/53/8ed1cf4c3b9c8e611e7122fb56f1c32d09e1fff0f1d77e78d9ff7c82653e/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", size = 4271354, upload-time = "2026-04-08T01:56:49.312Z" }, + { url = "https://files.pythonhosted.org/packages/50/46/cf71e26025c2e767c5609162c866a78e8a2915bbcfa408b7ca495c6140c4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022", size = 4905845, upload-time = "2026-04-08T01:56:50.916Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ea/01276740375bac6249d0a971ebdf6b4dc9ead0ee0a34ef3b5a88c1a9b0d4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce", size = 4444641, upload-time = "2026-04-08T01:56:52.882Z" }, + { url = "https://files.pythonhosted.org/packages/3d/4c/7d258f169ae71230f25d9f3d06caabcff8c3baf0978e2b7d65e0acac3827/cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f", size = 3967749, upload-time = "2026-04-08T01:56:54.597Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2a/2ea0767cad19e71b3530e4cad9605d0b5e338b6a1e72c37c9c1ceb86c333/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99", size = 4270942, upload-time = "2026-04-08T01:56:56.416Z" }, + { url = "https://files.pythonhosted.org/packages/41/3d/fe14df95a83319af25717677e956567a105bb6ab25641acaa093db79975d/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1", size = 4871079, upload-time = "2026-04-08T01:56:58.31Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/4a479e0f36f8f378d397f4eab4c850b4ffb79a2f0d58704b8fa0703ddc11/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", size = 4443999, upload-time = "2026-04-08T01:57:00.508Z" }, + { url = "https://files.pythonhosted.org/packages/28/17/b59a741645822ec6d04732b43c5d35e4ef58be7bfa84a81e5ae6f05a1d33/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", size = 4399191, upload-time = "2026-04-08T01:57:02.654Z" }, + { url = "https://files.pythonhosted.org/packages/59/6a/bb2e166d6d0e0955f1e9ff70f10ec4b2824c9cfcdb4da772c7dd69cc7d80/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", size = 4655782, upload-time = "2026-04-08T01:57:04.592Z" }, + { url = "https://files.pythonhosted.org/packages/95/b6/3da51d48415bcb63b00dc17c2eff3a651b7c4fed484308d0f19b30e8cb2c/cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298", size = 3002227, upload-time = "2026-04-08T01:57:06.91Z" }, + { url = "https://files.pythonhosted.org/packages/32/a8/9f0e4ed57ec9cebe506e58db11ae472972ecb0c659e4d52bbaee80ca340a/cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb", size = 3475332, upload-time = "2026-04-08T01:57:08.807Z" }, + { url = "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618, upload-time = "2026-04-08T01:57:10.645Z" }, + { url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" }, + { url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" }, + { url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" }, + { url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" }, + { url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" }, + { url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" }, + { url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" }, + { url = "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", size = 3013001, upload-time = "2026-04-08T01:57:34.933Z" }, + { url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" }, ] [[package]] @@ -265,6 +310,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] +[[package]] +name = "importlib-metadata" +version = "8.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" }, +] + [[package]] name = "jiter" version = "0.13.0" @@ -362,7 +419,7 @@ wheels = [ [[package]] name = "mcp" -version = "1.26.0" +version = "1.27.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -380,26 +437,26 @@ dependencies = [ { name = "typing-inspection" }, { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fc/6d/62e76bbb8144d6ed86e202b5edd8a4cb631e7c8130f3f4893c3f90262b10/mcp-1.26.0.tar.gz", hash = "sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66", size = 608005, upload-time = "2026-01-24T19:40:32.468Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8b/eb/c0cfc62075dc6e1ec1c64d352ae09ac051d9334311ed226f1f425312848a/mcp-1.27.0.tar.gz", hash = "sha256:d3dc35a7eec0d458c1da4976a48f982097ddaab87e278c5511d5a4a56e852b83", size = 607509, upload-time = "2026-04-02T14:48:08.88Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca", size = 233615, upload-time = "2026-01-24T19:40:30.652Z" }, + { url = "https://files.pythonhosted.org/packages/9c/46/f6b4ad632c67ef35209a66127e4bddc95759649dd595f71f13fba11bdf9a/mcp-1.27.0-py3-none-any.whl", hash = "sha256:5ce1fa81614958e267b21fb2aa34e0aea8e2c6ede60d52aba45fd47246b4d741", size = 215967, upload-time = "2026-04-02T14:48:07.24Z" }, ] [[package]] name = "nexus-rpc" -version = "1.3.0" +version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2e/f2/d54f5c03d8f4672ccc0875787a385f53dcb61f98a8ae594b5620e85b9cb3/nexus_rpc-1.3.0.tar.gz", hash = "sha256:e56d3b57b60d707ce7a72f83f23f106b86eca1043aa658e44582ab5ff30ab9ad", size = 75650, upload-time = "2025-12-08T22:59:13.002Z" } +sdist = { url = "https://files.pythonhosted.org/packages/35/d5/cd1ffb202b76ebc1b33c1332a3416e55a39929006982adc2b1eb069aaa9b/nexus_rpc-1.4.0.tar.gz", hash = "sha256:3b8b373d4865671789cc43623e3dc0bcbf192562e40e13727e17f1c149050fba", size = 82367, upload-time = "2026-02-25T22:01:34.053Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d6/74/0afd841de3199c148146c1d43b4bfb5605b2f1dc4c9a9087fe395091ea5a/nexus_rpc-1.3.0-py3-none-any.whl", hash = "sha256:aee0707b4861b22d8124ecb3f27d62dafbe8777dc50c66c91e49c006f971b92d", size = 28873, upload-time = "2025-12-08T22:59:12.024Z" }, + { url = "https://files.pythonhosted.org/packages/11/52/6327a5f4fda01207205038a106a99848a41c83e933cd23ea2cab3d2ebc6c/nexus_rpc-1.4.0-py3-none-any.whl", hash = "sha256:14c953d3519113f8ccec533a9efdb6b10c28afef75d11cdd6d422640c40b3a49", size = 29645, upload-time = "2026-02-25T22:01:33.122Z" }, ] [[package]] name = "openai" -version = "2.20.0" +version = "2.30.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -411,24 +468,37 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6e/5a/f495777c02625bfa18212b6e3b73f1893094f2bf660976eb4bc6f43a1ca2/openai-2.20.0.tar.gz", hash = "sha256:2654a689208cd0bf1098bb9462e8d722af5cbe961e6bba54e6f19fb843d88db1", size = 642355, upload-time = "2026-02-10T19:02:54.145Z" } +sdist = { url = "https://files.pythonhosted.org/packages/88/15/52580c8fbc16d0675d516e8749806eda679b16de1e4434ea06fb6feaa610/openai-2.30.0.tar.gz", hash = "sha256:92f7661c990bda4b22a941806c83eabe4896c3094465030dd882a71abe80c885", size = 676084, upload-time = "2026-03-25T22:08:59.96Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/9e/5bfa2270f902d5b92ab7d41ce0475b8630572e71e349b2a4996d14bdda93/openai-2.30.0-py3-none-any.whl", hash = "sha256:9a5ae616888eb2748ec5e0c5b955a51592e0b201a11f4262db920f2a78c5231d", size = 1146656, upload-time = "2026-03-25T22:08:58.2Z" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.40.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2c/1d/4049a9e8698361cc1a1aa03a6c59e4fa4c71e0c0f94a30f988a6876a2ae6/opentelemetry_api-1.40.0.tar.gz", hash = "sha256:159be641c0b04d11e9ecd576906462773eb97ae1b657730f0ecf64d32071569f", size = 70851, upload-time = "2026-03-04T14:17:21.555Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b5/a0/cf4297aa51bbc21e83ef0ac018947fa06aea8f2364aad7c96cbf148590e6/openai-2.20.0-py3-none-any.whl", hash = "sha256:38d989c4b1075cd1f76abc68364059d822327cf1a932531d429795f4fc18be99", size = 1098479, upload-time = "2026-02-10T19:02:52.157Z" }, + { url = "https://files.pythonhosted.org/packages/5f/bf/93795954016c522008da367da292adceed71cca6ee1717e1d64c83089099/opentelemetry_api-1.40.0-py3-none-any.whl", hash = "sha256:82dd69331ae74b06f6a874704be0cfaa49a1650e1537d4a813b86ecef7d0ecf9", size = 68676, upload-time = "2026-03-04T14:17:01.24Z" }, ] [[package]] name = "protobuf" -version = "6.33.5" +version = "6.33.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ba/25/7c72c307aafc96fa87062aa6291d9f7c94836e43214d43722e86037aac02/protobuf-6.33.5.tar.gz", hash = "sha256:6ddcac2a081f8b7b9642c09406bc6a4290128fce5f471cddd165960bb9119e5c", size = 444465, upload-time = "2026-01-29T21:51:33.494Z" } +sdist = { url = "https://files.pythonhosted.org/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531, upload-time = "2026-03-18T19:05:00.988Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b1/79/af92d0a8369732b027e6d6084251dd8e782c685c72da161bd4a2e00fbabb/protobuf-6.33.5-cp310-abi3-win32.whl", hash = "sha256:d71b040839446bac0f4d162e758bea99c8251161dae9d0983a3b88dee345153b", size = 425769, upload-time = "2026-01-29T21:51:21.751Z" }, - { url = "https://files.pythonhosted.org/packages/55/75/bb9bc917d10e9ee13dee8607eb9ab963b7cf8be607c46e7862c748aa2af7/protobuf-6.33.5-cp310-abi3-win_amd64.whl", hash = "sha256:3093804752167bcab3998bec9f1048baae6e29505adaf1afd14a37bddede533c", size = 437118, upload-time = "2026-01-29T21:51:24.022Z" }, - { url = "https://files.pythonhosted.org/packages/a2/6b/e48dfc1191bc5b52950246275bf4089773e91cb5ba3592621723cdddca62/protobuf-6.33.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:a5cb85982d95d906df1e2210e58f8e4f1e3cdc088e52c921a041f9c9a0386de5", size = 427766, upload-time = "2026-01-29T21:51:25.413Z" }, - { url = "https://files.pythonhosted.org/packages/4e/b1/c79468184310de09d75095ed1314b839eb2f72df71097db9d1404a1b2717/protobuf-6.33.5-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:9b71e0281f36f179d00cbcb119cb19dec4d14a81393e5ea220f64b286173e190", size = 324638, upload-time = "2026-01-29T21:51:26.423Z" }, - { url = "https://files.pythonhosted.org/packages/c5/f5/65d838092fd01c44d16037953fd4c2cc851e783de9b8f02b27ec4ffd906f/protobuf-6.33.5-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:8afa18e1d6d20af15b417e728e9f60f3aa108ee76f23c3b2c07a2c3b546d3afd", size = 339411, upload-time = "2026-01-29T21:51:27.446Z" }, - { url = "https://files.pythonhosted.org/packages/9b/53/a9443aa3ca9ba8724fdfa02dd1887c1bcd8e89556b715cfbacca6b63dbec/protobuf-6.33.5-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:cbf16ba3350fb7b889fca858fb215967792dc125b35c7976ca4818bee3521cf0", size = 323465, upload-time = "2026-01-29T21:51:28.925Z" }, - { url = "https://files.pythonhosted.org/packages/57/bf/2086963c69bdac3d7cff1cc7ff79b8ce5ea0bec6797a017e1be338a46248/protobuf-6.33.5-py3-none-any.whl", hash = "sha256:69915a973dd0f60f31a08b8318b73eab2bd6a392c79184b3612226b0a3f8ec02", size = 170687, upload-time = "2026-01-29T21:51:32.557Z" }, + { url = "https://files.pythonhosted.org/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739, upload-time = "2026-03-18T19:04:48.373Z" }, + { url = "https://files.pythonhosted.org/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326", size = 437089, upload-time = "2026-03-18T19:04:50.381Z" }, + { url = "https://files.pythonhosted.org/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a", size = 427737, upload-time = "2026-03-18T19:04:51.866Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2", size = 324610, upload-time = "2026-03-18T19:04:53.096Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3", size = 339381, upload-time = "2026-03-18T19:04:54.616Z" }, + { url = "https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593", size = 323436, upload-time = "2026-03-18T19:04:55.768Z" }, + { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" }, ] [[package]] @@ -528,25 +598,25 @@ wheels = [ [[package]] name = "pydantic-settings" -version = "2.12.0" +version = "2.13.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "python-dotenv" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/43/4b/ac7e0aae12027748076d72a8764ff1c9d82ca75a7a52622e67ed3f765c54/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0", size = 194184, upload-time = "2025-11-10T14:25:47.013Z" } +sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" }, + { url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" }, ] [[package]] name = "pyjwt" -version = "2.11.0" +version = "2.12.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5c/5a/b46fa56bf322901eee5b0454a34343cdbdae202cd421775a8ee4e42fd519/pyjwt-2.11.0.tar.gz", hash = "sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623", size = 98019, upload-time = "2026-01-30T19:59:55.694Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl", hash = "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469", size = 28224, upload-time = "2026-01-30T19:59:54.539Z" }, + { url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" }, ] [package.optional-dependencies] @@ -556,20 +626,20 @@ crypto = [ [[package]] name = "python-dotenv" -version = "1.2.1" +version = "1.2.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] [[package]] name = "python-multipart" -version = "0.0.22" +version = "0.0.24" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612, upload-time = "2026-01-25T10:15:56.219Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8a/45/e23b5dc14ddb9918ae4a625379506b17b6f8fc56ca1d82db62462f59aea6/python_multipart-0.0.24.tar.gz", hash = "sha256:9574c97e1c026e00bc30340ef7c7d76739512ab4dfd428fec8c330fa6a5cc3c8", size = 37695, upload-time = "2026-04-05T20:49:13.829Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" }, + { url = "https://files.pythonhosted.org/packages/a3/73/89930efabd4da63cea44a3f438aeb753d600123570e6d6264e763617a9ce/python_multipart-0.0.24-py3-none-any.whl", hash = "sha256:9b110a98db707df01a53c194f0af075e736a770dc5058089650d70b4a182f950", size = 24420, upload-time = "2026-04-05T20:49:12.555Z" }, ] [[package]] @@ -694,48 +764,33 @@ wheels = [ [[package]] name = "sse-starlette" -version = "3.2.0" +version = "3.3.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "starlette" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8b/8d/00d280c03ffd39aaee0e86ec81e2d3b9253036a0f93f51d10503adef0e65/sse_starlette-3.2.0.tar.gz", hash = "sha256:8127594edfb51abe44eac9c49e59b0b01f1039d0c7461c6fd91d4e03b70da422", size = 27253, upload-time = "2026-01-17T13:11:05.62Z" } +sdist = { url = "https://files.pythonhosted.org/packages/26/8c/f9290339ef6d79badbc010f067cd769d6601ec11a57d78569c683fb4dd87/sse_starlette-3.3.4.tar.gz", hash = "sha256:aaf92fc067af8a5427192895ac028e947b484ac01edbc3caf00e7e7137c7bef1", size = 32427, upload-time = "2026-03-29T09:00:23.307Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/96/7f/832f015020844a8b8f7a9cbc103dd76ba8e3875004c41e08440ea3a2b41a/sse_starlette-3.2.0-py3-none-any.whl", hash = "sha256:5876954bd51920fc2cd51baee47a080eb88a37b5b784e615abb0b283f801cdbf", size = 12763, upload-time = "2026-01-17T13:11:03.775Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7f/3de5402f39890ac5660b86bcf5c03f9d855dad5c4ed764866d7b592b46fd/sse_starlette-3.3.4-py3-none-any.whl", hash = "sha256:84bb06e58939a8b38d8341f1bc9792f06c2b53f48c608dd207582b664fc8f3c1", size = 14330, upload-time = "2026-03-29T09:00:21.846Z" }, ] [[package]] name = "starlette" -version = "0.52.1" +version = "1.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" } +sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" }, -] - -[[package]] -name = "temporal-durable" -version = "0.1.0" -source = { virtual = "." } -dependencies = [ - { name = "temporalio" }, - { name = "vercel-ai-sdk" }, -] - -[package.metadata] -requires-dist = [ - { name = "temporalio", specifier = ">=1.9.0" }, - { name = "vercel-ai-sdk", editable = "../../" }, + { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, ] [[package]] name = "temporalio" -version = "1.22.0" +version = "1.25.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "nexus-rpc" }, @@ -743,13 +798,13 @@ dependencies = [ { name = "types-protobuf" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b5/28/2a79a1e98e4280924f08ea0989ee045aa0b65f17f8d4f2ae7b53c2f4c38d/temporalio-1.22.0.tar.gz", hash = "sha256:896452fad246de2277cbb0408e4e0899882da1843480d5cbb57c7a5767440834", size = 1906162, upload-time = "2026-02-03T20:58:37.042Z" } +sdist = { url = "https://files.pythonhosted.org/packages/de/9c/3782bab0bf11a40b550147c19a5d1a476c17405391751982408902d9f138/temporalio-1.25.0.tar.gz", hash = "sha256:a3bbec1dcc904f674402cfa4faae480fda490b1c53ea5440c1f1996c562016fb", size = 2152534, upload-time = "2026-04-08T18:53:55.388Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/da/00/4dd57b3be03cc22523fd7083a3f63700188d7856ada875c99e71ca9a72bd/temporalio-1.22.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:532ef90cdee487a76c46eb71348f94f0a8a432e9dd241a0552b384314bbe28c0", size = 12198015, upload-time = "2026-02-03T20:57:04.056Z" }, - { url = "https://files.pythonhosted.org/packages/a7/15/1c24ea8005f1abc58dbb35b26ea93e5067afc385e56dda0e50c64c75cc07/temporalio-1.22.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:2a5f8646c1a0c36d5d4472462f2d315bafa7e2c9b1f52f15a07d01d1ccc33778", size = 11697647, upload-time = "2026-02-03T20:57:26.252Z" }, - { url = "https://files.pythonhosted.org/packages/09/2e/b65ec41f73030a109c253ac30545e4aac19044cd30083231b9b5993914e8/temporalio-1.22.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f8cf7c909f7d6bc236a45aa09fc347cb53b02fa3287df29409d0500fc21c0dc5", size = 11972722, upload-time = "2026-02-03T20:57:47.371Z" }, - { url = "https://files.pythonhosted.org/packages/b4/de/048bf901417940f62bd69243d95762a63e97a5ad138c514c76852d364cd6/temporalio-1.22.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ac4adf1ac20f594066b8091de22f463a31f0926c23be1e990519fd6dbbb9d1b", size = 12275101, upload-time = "2026-02-03T20:58:09.636Z" }, - { url = "https://files.pythonhosted.org/packages/14/8e/f5852ef5326990ae5a15cb5f764254df1d086463ddc8244766d13872c3d3/temporalio-1.22.0-cp310-abi3-win_amd64.whl", hash = "sha256:4453ba03681e4ed39bf410f76997b7e0b9ec239d0dff7cabd53eb89c7fbaa6b0", size = 12713408, upload-time = "2026-02-03T20:58:32.086Z" }, + { url = "https://files.pythonhosted.org/packages/19/e3/5676dd10d1164b6d6ca8752314054097b89c5da931e936af402a7b15236c/temporalio-1.25.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:6dc1bc8e1773b1a833d86a7ede2dd90ef4e031ced5b748b59e7f09a5bf9b327d", size = 13943906, upload-time = "2026-04-08T18:53:30.022Z" }, + { url = "https://files.pythonhosted.org/packages/89/50/7cbf7f845973be986ec165348f72f7a409750842a04d554965a39be5cb4f/temporalio-1.25.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:3c8fdcf79ea5ae8ae2cf6f48072e4a86c3e0f4778f6a8a066c6ff1d336587db4", size = 13298719, upload-time = "2026-04-08T18:53:35.95Z" }, + { url = "https://files.pythonhosted.org/packages/d2/31/d474bab8535552add6ed289911bf1ffae5d7071823ece1069842190fcaed/temporalio-1.25.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:141f37aaafd7d090ba5c8776e4e9bc60df1fbc64b9f50c8f00e905a436588ddc", size = 13555435, upload-time = "2026-04-08T18:53:41.36Z" }, + { url = "https://files.pythonhosted.org/packages/2a/c8/e7dc053d6107bf2a037a3c9fe7b86639a25dcb888bde0e1ca366901ee47f/temporalio-1.25.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ff7ca5bb80264976477d4dc7a839b3d22af8577ae92306526a061481db49bf92", size = 14052050, upload-time = "2026-04-08T18:53:46.44Z" }, + { url = "https://files.pythonhosted.org/packages/08/70/9340ed3a578321cbc153041d34834bb1ec3f1f3e3d9cded47cd1b7c3e403/temporalio-1.25.0-cp310-abi3-win_amd64.whl", hash = "sha256:9411534279a2e64847231b6059c214bff4d57cfd1532bd09f333d0b1603daa7f", size = 14299684, upload-time = "2026-04-08T18:53:52.482Z" }, ] [[package]] @@ -766,11 +821,11 @@ wheels = [ [[package]] name = "types-protobuf" -version = "6.32.1.20251210" +version = "6.32.1.20260221" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c2/59/c743a842911887cd96d56aa8936522b0cd5f7a7f228c96e81b59fced45be/types_protobuf-6.32.1.20251210.tar.gz", hash = "sha256:c698bb3f020274b1a2798ae09dc773728ce3f75209a35187bd11916ebfde6763", size = 63900, upload-time = "2025-12-10T03:14:25.451Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/e2/9aa4a3b2469508bd7b4e2ae11cbedaf419222a09a1b94daffcd5efca4023/types_protobuf-6.32.1.20260221.tar.gz", hash = "sha256:6d5fb060a616bfb076cbb61b4b3c3969f5fc8bec5810f9a2f7e648ee5cbcbf6e", size = 64408, upload-time = "2026-02-21T03:55:13.916Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/aa/43/58e75bac4219cbafee83179505ff44cae3153ec279be0e30583a73b8f108/types_protobuf-6.32.1.20251210-py3-none-any.whl", hash = "sha256:2641f78f3696822a048cfb8d0ff42ccd85c25f12f871fbebe86da63793692140", size = 77921, upload-time = "2025-12-10T03:14:24.477Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e8/1fd38926f9cf031188fbc5a96694203ea6f24b0e34bd64a225ec6f6291ba/types_protobuf-6.32.1.20260221-py3-none-any.whl", hash = "sha256:da7cdd947975964a93c30bfbcc2c6841ee646b318d3816b033adc2c4eb6448e4", size = 77956, upload-time = "2026-02-21T03:55:12.894Z" }, ] [[package]] @@ -796,62 +851,85 @@ wheels = [ [[package]] name = "uvicorn" -version = "0.40.0" +version = "0.44.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c3/d1/8f3c683c9561a4e6689dd3b1d345c815f10f86acd044ee1fb9a4dcd0b8c5/uvicorn-0.40.0.tar.gz", hash = "sha256:839676675e87e73694518b5574fd0f24c9d97b46bea16df7b8c05ea1a51071ea", size = 81761, upload-time = "2025-12-21T14:16:22.45Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/da/6eee1ff8b6cbeed47eeb5229749168e81eb4b7b999a1a15a7176e51410c9/uvicorn-0.44.0.tar.gz", hash = "sha256:6c942071b68f07e178264b9152f1f16dfac5da85880c4ce06366a96d70d4f31e", size = 86947, upload-time = "2026-04-06T09:23:22.826Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee", size = 68502, upload-time = "2025-12-21T14:16:21.041Z" }, + { url = "https://files.pythonhosted.org/packages/b7/23/a5bbd9600dd607411fa644c06ff4951bec3a4d82c4b852374024359c19c0/uvicorn-0.44.0-py3-none-any.whl", hash = "sha256:ce937c99a2cc70279556967274414c087888e8cec9f9c94644dfca11bd3ced89", size = 69425, upload-time = "2026-04-06T09:23:21.524Z" }, ] [[package]] name = "vercel" -version = "0.4.0" +version = "0.5.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, + { name = "cbor2" }, { name = "httpx" }, { name = "pydantic" }, { name = "python-dotenv" }, + { name = "vercel-workers" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/55/69/566d75db8b86cca1884fe9d0eb063587a884c899e9a3cb52ef1723d22733/vercel-0.4.0.tar.gz", hash = "sha256:a8bc19823de2b6ac12b514b32af0823ac40b608c8dbb77386bd8ce965f6f6d94", size = 56643, upload-time = "2026-02-12T19:05:46.84Z" } +sdist = { url = "https://files.pythonhosted.org/packages/44/2e/3ea1143db8fe7dcf2b03556cc050e60b18695f51fc3d8267132ef3e37ef8/vercel-0.5.3.tar.gz", hash = "sha256:0d19b41bf87b63563a3ab0e80c63dfa707572ffac96dbbede75eac41149d98ea", size = 98217, upload-time = "2026-03-27T23:59:59.779Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/60/c7/32d878612d6cc14f394f5440cb400793759eac657d52c992555fe010763a/vercel-0.4.0-py3-none-any.whl", hash = "sha256:d842b328222ed835b280adb7f61df560e68592c70aad9478ca2d9ab81188864d", size = 72659, upload-time = "2026-02-12T19:05:45.678Z" }, + { url = "https://files.pythonhosted.org/packages/74/4e/dad34da1211b6028173464f1a27f226249a0e81560507e7c3f277f91b821/vercel-0.5.3-py3-none-any.whl", hash = "sha256:79b4d19e10b7bc239b569725fcfa56bd40a2d565360a757e3d4674cacd972cd6", size = 118064, upload-time = "2026-03-27T23:59:58.423Z" }, ] [[package]] name = "vercel-ai-sdk" -version = "0.0.1.dev3" +version = "0.0.1.dev9" source = { editable = "../../" } dependencies = [ { name = "anthropic" }, { name = "httpx" }, { name = "mcp" }, { name = "openai" }, + { name = "opentelemetry-api" }, { name = "pydantic" }, { name = "vercel" }, ] [package.metadata] requires-dist = [ - { name = "anthropic", specifier = ">=0.40.0" }, + { name = "anthropic", specifier = ">=0.83.0" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "mcp", specifier = ">=1.18.0" }, { name = "openai", specifier = ">=2.14.0" }, + { name = "opentelemetry-api", specifier = ">=1.0" }, { name = "pydantic", specifier = ">=2.12.5" }, { name = "vercel", specifier = ">=0.3.8" }, ] [package.metadata.requires-dev] dev = [ + { name = "mypy", specifier = ">=1.11" }, + { name = "opentelemetry-sdk", specifier = ">=1.0" }, + { name = "pyright", specifier = ">=1.1.408" }, { name = "pytest", specifier = ">=8.0" }, { name = "pytest-asyncio", specifier = ">=0.24" }, { name = "python-dotenv", specifier = ">=1.2.1" }, { name = "rich", specifier = ">=14.2.0" }, + { name = "ruff", specifier = ">=0.8" }, +] + +[[package]] +name = "vercel-workers" +version = "0.0.13" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "python-dotenv" }, + { name = "vercel" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/77/95/f89de37c534aa87395be0e68058af38d8b4608370efde783dfbaf1721021/vercel_workers-0.0.13.tar.gz", hash = "sha256:cbc6d698e381996fdf0b7f60dd814191b0f4657e9390fdfddc483b0fc0ef6fd4", size = 50591, upload-time = "2026-03-19T19:07:32.551Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/b1/276e3e196c660ca2b4b073afd69458572afa8467f3c1b8e426d5df5536ae/vercel_workers-0.0.13-py3-none-any.whl", hash = "sha256:fcd7e232239a62b400971179dfb8819f365297970eb1dbbf95e0f0f2fac4c031", size = 50542, upload-time = "2026-03-19T19:07:31.482Z" }, ] [[package]] @@ -898,3 +976,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, ] + +[[package]] +name = "zipp" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, +] diff --git a/examples/agents/custom_loop.py b/examples/agents/custom_loop.py new file mode 100644 index 00000000..3d4b7885 --- /dev/null +++ b/examples/agents/custom_loop.py @@ -0,0 +1,67 @@ +"""Custom loop: manual control over streaming and tool execution.""" + +import asyncio +from collections.abc import AsyncGenerator + +import vercel_ai_sdk as ai +from vercel_ai_sdk.agents import Context, agent, tool + + +@tool +async def get_weather(city: str) -> str: + """Get current weather for a city.""" + return f"Sunny, 72F in {city}" + + +@tool +async def get_population(city: str) -> int: + """Get population of a city.""" + return {"new york": 8_336_817, "tokyo": 13_960_000}.get(city.lower(), 1_000_000) + + +async def main() -> None: + model = ai.Model( + id="anthropic/claude-sonnet-4-20250514", + adapter="ai-gateway-v3", + provider="ai-gateway", + ) + + tools = [get_weather, get_population] + my_agent = agent(tools=tools) + + @my_agent.loop + async def custom(context: Context) -> AsyncGenerator[ai.Message]: + """Stream, execute tools with logging, repeat.""" + while True: + s = await ai.models.stream( + context.model, context.messages, tools=context.tools + ) + async for msg in s: + yield msg + + tool_calls = context.resolve(s.tool_calls) + if not tool_calls: + return + + print( + f"\n [calling {len(tool_calls)} tool(s): " + f"{', '.join(tc.name for tc in tool_calls)}]" + ) + + async with asyncio.TaskGroup() as tg: + tasks = [tg.create_task(tc()) for tc in tool_calls] + + # Yield a tool-result message — history auto-collects it. + yield ai.tool_message(*(t.result() for t in tasks)) + + async for msg in my_agent.run( + model, + [ai.user_message("Compare the weather and population of New York and Tokyo.")], + ): + if msg.text_delta: + print(msg.text_delta, end="", flush=True) + print() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/agents/hooks.py b/examples/agents/hooks.py new file mode 100644 index 00000000..9ccaf42f --- /dev/null +++ b/examples/agents/hooks.py @@ -0,0 +1,102 @@ +"""Human-in-the-loop approval hooks. + +Demonstrates the function-based hook API: + - await hook("label", payload=Model) to suspend inside the loop + - resolve_hook("label", data) to unblock from outside + - Hook messages arrive with role="signal" +""" + +import asyncio +from collections.abc import AsyncGenerator + +import pydantic + +import vercel_ai_sdk as ai +from vercel_ai_sdk.agents import Context, agent, hook, resolve_hook, tool + + +class Approval(pydantic.BaseModel): + granted: bool + reason: str = "" + + +@tool +async def contact_mothership(query: str) -> str: + """Contact the mothership for important decisions.""" + return "Soon." + + +async def main() -> None: + model = ai.Model( + id="anthropic/claude-sonnet-4-20250514", + adapter="ai-gateway-v3", + provider="ai-gateway", + ) + + my_agent = agent( + system="Use the contact_mothership tool when asked about the future.", + tools=[contact_mothership], + ) + + @my_agent.loop + async def with_approval(context: Context) -> AsyncGenerator[ai.Message]: + while True: + s = await ai.models.stream( + context.model, context.messages, tools=context.tools + ) + async for msg in s: + yield msg + + tool_calls = context.resolve(s.tool_calls) + if not tool_calls: + return + + results = [] + for tc in tool_calls: + if tc.name == "contact_mothership": + # Suspends until resolved from outside the loop. + approval = await hook( + f"approve_{tc.id}", + payload=Approval, + metadata={"tool": tc.name, "args": tc.args}, + ) + if approval.granted: + results.append(await tc()) + else: + results.append( + ai.ToolResultPart( + tool_call_id=tc.id, + tool_name=tc.name, + result=f"Rejected: {approval.reason}", + is_error=True, + ) + ) + else: + results.append(await tc()) + + yield ai.tool_message(*results) + + async for msg in my_agent.run( + model, [ai.user_message("When will the robots take over?")] + ): + # Hook signals arrive with role="signal" + if msg.role == "signal": + hook_part = msg.get_hook_part() + if hook_part and hook_part.status == "pending": + answer = input(f"Approve {hook_part.hook_id}? [y/n] ") + resolve_hook( + hook_part.hook_id, + Approval( + granted=answer.strip().lower() in ("y", "yes"), + reason="operator decision", + ), + ) + continue + + if msg.text_delta: + print(msg.text_delta, end="", flush=True) + print() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/agents/hooks_serverless.py b/examples/agents/hooks_serverless.py new file mode 100644 index 00000000..0280332a --- /dev/null +++ b/examples/agents/hooks_serverless.py @@ -0,0 +1,141 @@ +"""Serverless hook pattern: interrupt_loop=True. + +Demonstrates the serverless/stateless pattern where the agent run suspends +cleanly when a hook has no resolution, and resumes from a checkpoint on +re-entry with a pre-registered resolution. + +Flow: + 1. First run: hook fires, interrupt_loop=True cancels the future, + CancelledError is caught, run ends with a checkpoint. + 2. Second run: resolve_hook() pre-registers the answer, agent.run() + replays from checkpoint, hook finds the resolution immediately. +""" + +import asyncio +from collections.abc import AsyncGenerator + +import pydantic + +import vercel_ai_sdk as ai +from vercel_ai_sdk.agents import ( + Context, + EventLogProvider, + agent, + hook, + resolve_hook, + tool, +) + + +class Confirmation(pydantic.BaseModel): + approved: bool + reason: str = "" + + +@tool +async def delete_file(path: str) -> str: + """Delete a file at the given path.""" + return f"Deleted {path}" + + +async def main() -> None: + model = ai.Model( + id="anthropic/claude-sonnet-4-20250514", + adapter="ai-gateway-v3", + provider="ai-gateway", + ) + + my_agent = agent( + system="Delete files when asked. Always use the delete_file tool.", + tools=[delete_file], + ) + + @my_agent.loop + async def with_confirmation(context: Context) -> AsyncGenerator[ai.Message]: + while True: + s = await ai.models.stream( + context.model, context.messages, tools=context.tools + ) + async for msg in s: + yield msg + + tool_calls = context.resolve(s.tool_calls) + if not tool_calls: + return + + results = [] + for tc in tool_calls: + try: + confirmation = await hook( + f"confirm_{tc.id}", + payload=Confirmation, + metadata={"tool": tc.name, "args": tc.args}, + interrupt_loop=True, # serverless: cancel if unresolved + ) + except asyncio.CancelledError: + # No resolution available — bail out cleanly. + # The checkpoint captures everything up to this point. + return + + if confirmation.approved: + results.append(await tc()) + else: + results.append( + ai.ToolResultPart( + tool_call_id=tc.id, + tool_name=tc.name, + result=f"Rejected: {confirmation.reason}", + is_error=True, + ) + ) + + yield ai.tool_message(*results) + + # ── First run: no resolution, hook interrupts ───────────── + print("--- Run 1: hook fires, no resolution, run suspends ---") + pending_hook_labels: list[str] = [] + + durability = EventLogProvider() + async for msg in my_agent.run( + model, + [ai.user_message("Delete /tmp/old_logs.txt")], + durability=durability, + ): + if msg.role == "signal": + hook_part = msg.get_hook_part() + if hook_part and hook_part.status == "pending": + pending_hook_labels.append(hook_part.hook_id) + print( + f" Hook pending: {hook_part.hook_id}" + f" (metadata={hook_part.metadata})" + ) + elif msg.text_delta: + print(msg.text_delta, end="", flush=True) + + saved_checkpoint = durability.checkpoint() + print(f"\n Checkpoint saved: {len(saved_checkpoint.steps)} steps\n") + + # ── Second run: pre-register resolution, replay from checkpoint ── + print("--- Run 2: pre-register approval, resume from checkpoint ---") + # Resolve each pending hook captured from run 1. + # In a real app this would come from a user action (API call, button click). + for label in pending_hook_labels: + resolve_hook(label, Confirmation(approved=True, reason="user approved")) + + durability = EventLogProvider(saved_checkpoint) + async for msg in my_agent.run( + model, + [ai.user_message("Delete /tmp/old_logs.txt")], + durability=durability, + ): + if msg.role == "signal": + hook_part = msg.get_hook_part() + if hook_part: + print(f" Hook {hook_part.status}: {hook_part.hook_id}") + elif msg.text_delta: + print(msg.text_delta, end="", flush=True) + print() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/agents/nested_agents.py b/examples/agents/nested_agents.py new file mode 100644 index 00000000..b3e5a695 --- /dev/null +++ b/examples/agents/nested_agents.py @@ -0,0 +1,53 @@ +"""Nested agents: a research tool that is itself an agent streaming through the sink.""" + +import asyncio +from collections.abc import AsyncGenerator + +import vercel_ai_sdk as ai +from vercel_ai_sdk.agents import agent, tool + +model = ai.Model( + id="anthropic/claude-sonnet-4-20250514", + adapter="ai-gateway-v3", + provider="ai-gateway", +) + + +@tool +async def get_facts(topic: str) -> str: + """Look up facts about a topic.""" + facts = { + "mars": "Mars has two moons: Phobos and Deimos. A day on Mars is 24.6 hours.", + "venus": "Venus rotates backwards. Its surface temperature is 450C.", + } + return facts.get(topic.lower(), f"No facts found for {topic}.") + + +# This tool is an async generator — it streams intermediate messages +# through the runtime sink, then returns the final result. +@tool +async def research(topic: str) -> AsyncGenerator[ai.Message]: + """Research a topic in depth using a sub-agent.""" + researcher = agent( + system="You are a research assistant. Be concise.", + tools=[get_facts], + ) + + async for msg in researcher.run(model, [ai.user_message(f"Research: {topic}")]): + yield msg + + +async def main() -> None: + orchestrator = agent( + system="Use the research tool to answer questions. Summarize the findings.", + tools=[research], + ) + + async for msg in orchestrator.run(model, [ai.user_message("Tell me about Mars.")]): + if msg.text_delta: + print(msg.text_delta, end="", flush=True) + print() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/agents/simple.py b/examples/agents/simple.py new file mode 100644 index 00000000..8c5aaf15 --- /dev/null +++ b/examples/agents/simple.py @@ -0,0 +1,36 @@ +"""Simplest agent: model + tool, default loop handles everything.""" + +import asyncio + +import vercel_ai_sdk as ai +from vercel_ai_sdk.agents import agent, tool + + +@tool +async def get_weather(city: str) -> str: + """Get current weather for a city.""" + return f"Sunny, 72F in {city}" + + +async def main() -> None: + model = ai.Model( + id="anthropic/claude-sonnet-4-20250514", + adapter="ai-gateway-v3", + provider="ai-gateway", + ) + + my_agent = agent( + system="You are a helpful weather assistant.", + tools=[get_weather], + ) + + async for msg in my_agent.run( + model, [ai.user_message("What's the weather in Tokyo?")] + ): + if msg.text_delta: + print(msg.text_delta, end="", flush=True) + print() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/samples/custom_loop.py b/examples/samples/custom_loop.py index 0e87ec37..0d51961e 100644 --- a/examples/samples/custom_loop.py +++ b/examples/samples/custom_loop.py @@ -29,7 +29,7 @@ async def custom_stream_step( label: str | None = None, ) -> AsyncGenerator[ai.Message]: """Wraps models.stream to inject a label on every message.""" - async for msg in ai.models.stream(model, messages, tools=tools): + async for msg in await ai.models.stream(model, messages, tools=tools): msg.label = label yield msg diff --git a/examples/temporal-durable/.gitignore b/examples/temporal-durable/.gitignore deleted file mode 100644 index 393fc622..00000000 --- a/examples/temporal-durable/.gitignore +++ /dev/null @@ -1,10 +0,0 @@ -# Python -__pycache__/ -*.py[cod] -*.egg-info/ -.venv/ -dist/ - -# Environment -.env -.env*.local diff --git a/examples/temporal-durable/.python-version b/examples/temporal-durable/.python-version deleted file mode 100644 index e4fba218..00000000 --- a/examples/temporal-durable/.python-version +++ /dev/null @@ -1 +0,0 @@ -3.12 diff --git a/examples/temporal-durable/README.md b/examples/temporal-durable/README.md deleted file mode 100644 index 2e6e8400..00000000 --- a/examples/temporal-durable/README.md +++ /dev/null @@ -1,61 +0,0 @@ -# Durable Agent Execution with Temporal - -An agent (weather + population tools) running as a Temporal workflow. -Every LLM call and tool call is a durable activity — the agent survives -crashes and restarts because Temporal replays activity results from its -event history. - -## How it works - -The framework and Temporal compose via plain async/await: - -- **Tools**: `@ai.tool` with `execute_activity()` in the body — each tool - is its own activity with a matching signature -- **LLM**: `DurableModel` wraps an activity call into a `LanguageModel`; - the activity uses `llm.buffer()` and `ToolSchema` -- **Loop**: `ai.stream_loop()` runs the agent loop unchanged -- **Bus**: `ai.run()` provides the unified message bus for streaming - -The agent function is identical to the non-Temporal version. -Temporal doesn't know about the framework; the framework doesn't know -about Temporal. - -**3 files:** `activities.py` (I/O), `workflow.py` (agent + wrappers), `main.py` - -``` -Workflow (deterministic) Activities (real I/O) -┌─────────────────────────┐ ┌──────────────────────┐ -│ while True: │ │ │ -│ response = activity───┼─────────>│ llm_call(messages) │ -│ │<─────────┼ → Anthropic API │ -│ if no tool_calls: │ │ │ -│ return text │ │ │ -│ │ │ │ -│ gather( │ │ │ -│ activity(tool1) ────┼─────────>│ get_weather(city) │ -│ activity(tool2) ────┼─────────>│ get_population(city)│ -│ ) │<─────────┼ → plain functions │ -└─────────────────────────┘ └──────────────────────┘ -``` - -On crash/restart, Temporal replays activity results from its event history. -The workflow re-executes deterministically — each `execute_activity()` call -returns the cached result instead of re-running the I/O. - -## Setup - -```bash -# 1. Install & start Temporal -brew install temporal -temporal server start-dev - -# 2. Install deps -cd examples/temporal-durable -uv sync - -# 3. Set API key -export AI_GATEWAY_API_KEY=... - -# 4. Run -uv run python main.py -``` diff --git a/examples/temporal-durable/activities.py b/examples/temporal-durable/activities.py deleted file mode 100644 index 22cc864d..00000000 --- a/examples/temporal-durable/activities.py +++ /dev/null @@ -1,55 +0,0 @@ -"""Temporal activities — all real I/O lives here. - -Each tool is its own activity with a plain function signature. -The LLM activity uses ToolSchema (no dummy fn) and llm.buffer() -(no manual drain loop). -""" - -from __future__ import annotations - -import dataclasses -from typing import Any - -import temporalio.activity - -import vercel_ai_sdk as ai - -# ── Tool activities (one per tool, plain functions) ─────────────── - - -@temporalio.activity.defn(name="get_weather") -async def get_weather_activity(city: str) -> str: - return f"Sunny, 72F in {city}" - - -@temporalio.activity.defn(name="get_population") -async def get_population_activity(city: str) -> int: - return {"new york": 8_336_817, "los angeles": 3_979_576}.get( - city.lower(), 1_000_000 - ) - - -# ── LLM activity ───────────────────────────────────────────────── - - -@dataclasses.dataclass -class LLMCallParams: - messages: list[dict[str, Any]] - tool_schemas: list[dict[str, Any]] - - -@dataclasses.dataclass -class LLMCallResult: - message: dict[str, Any] # serialized ai.Message - - -@temporalio.activity.defn(name="llm_call") -async def llm_call_activity(params: LLMCallParams) -> LLMCallResult: - """Call the LLM, drain the stream, return the final message.""" - llm = ai.ai_gateway.GatewayModel(model="anthropic/claude-opus-4.6") - - messages = [ai.Message.model_validate(m) for m in params.messages] - tools = [ai.ToolSchema.model_validate(t) for t in params.tool_schemas] - - result = await llm.buffer(messages, tools) - return LLMCallResult(message=result.model_dump()) diff --git a/examples/temporal-durable/main.py b/examples/temporal-durable/main.py deleted file mode 100644 index c8af4266..00000000 --- a/examples/temporal-durable/main.py +++ /dev/null @@ -1,58 +0,0 @@ -"""Entry point — starts a Temporal worker and executes the agent workflow. - -Prerequisites: - 1. Temporal dev server: temporal server start-dev - 2. AI_GATEWAY_API_KEY environment variable set - -Usage: - uv run python main.py - uv run python main.py "What is the weather in Tokyo?" -""" - -from __future__ import annotations - -import asyncio -import sys -import uuid - -import activities -import temporalio.client -import temporalio.worker -import workflow - -TASK_QUEUE = "agent-durable" - - -async def main(user_query: str) -> None: - temporal = await temporalio.client.Client.connect("localhost:7233") - - async with temporalio.worker.Worker( - temporal, - task_queue=TASK_QUEUE, - workflows=[workflow.AgentWorkflow], - activities=[ - activities.llm_call_activity, - activities.get_weather_activity, - activities.get_population_activity, - ], - ): - workflow_id = f"agent-durable-{uuid.uuid4().hex[:8]}" - print(f"Workflow {workflow_id}") - print(f"Query: {user_query}\n") - - result = await temporal.execute_workflow( - workflow.AgentWorkflow.run, - user_query, - id=workflow_id, - task_queue=TASK_QUEUE, - ) - print(result) - - -if __name__ == "__main__": - query = ( - sys.argv[1] - if len(sys.argv) > 1 - else ("What's the weather and population of New York and Los Angeles?") - ) - asyncio.run(main(query)) diff --git a/examples/temporal-durable/workflow.py b/examples/temporal-durable/workflow.py deleted file mode 100644 index 4bbd2b10..00000000 --- a/examples/temporal-durable/workflow.py +++ /dev/null @@ -1,150 +0,0 @@ -"""Temporal workflow — the durable agent loop. - -NOTE: This example still uses the old models.LanguageModel ABC because -it wraps Temporal activities as a custom model. When the models layer -is fully migrated to models, this will need a custom adapter instead. -""" - -from __future__ import annotations - -import asyncio -import datetime -from collections.abc import AsyncGenerator, Awaitable, Callable, Sequence -from typing import Any, override - -import pydantic -import temporalio.common -import temporalio.workflow - -with temporalio.workflow.unsafe.imports_passed_through(): - import activities - - import vercel_ai_sdk as ai - - -class DurableModel(ai.models.LanguageModel): - def __init__( - self, - call_fn: Callable[ - [activities.LLMCallParams], Awaitable[activities.LLMCallResult] - ], - ) -> None: - self.call_fn = call_fn - - @override - async def stream( - self, - messages: list[ai.Message], - tools: Sequence[ai.ToolLike] | None = None, - output_type: type[pydantic.BaseModel] | None = None, - ) -> AsyncGenerator[ai.Message]: - result = await self.call_fn( - activities.LLMCallParams( - messages=[m.model_dump() for m in messages], - tool_schemas=[ - { - "name": t.name, - "description": t.description, - "param_schema": t.param_schema, - } - for t in (tools or []) - ], - ) - ) - yield ai.Message.model_validate(result.message) - - -# ── Durable tools ──────────────────────────────────────────────── -# -# Plain @ai.tool — the decorator builds the JSON schema from the -# signature. The body calls execute_activity, making each tool -# invocation a durable Temporal activity. - - -@ai.tool -async def get_weather(city: str) -> str: - """Get current weather for a city.""" - return await temporalio.workflow.execute_activity( - activities.get_weather_activity, - args=[city], - start_to_close_timeout=datetime.timedelta(minutes=2), - ) - - -@ai.tool -async def get_population(city: str) -> int: - """Get population of a city.""" - return await temporalio.workflow.execute_activity( - activities.get_population_activity, - args=[city], - start_to_close_timeout=datetime.timedelta(minutes=2), - ) - - -# ── Agent ──────────────────────────────────────────────────────── -# -# TODO: This example uses the old LanguageModel ABC and ai.run() / -# ai.stream_loop free-function patterns. Once the models layer is -# migrated, convert to use ai.agent() + models.Model with a custom -# adapter for Temporal activity-based LLM calls. - - -async def agent(llm: Any, user_query: str) -> ai.StreamResult: - """Agent loop — uses old-style stream_loop via models.LanguageModel. - - This is a transitional pattern. The old ai.stream_loop and ai.run - are no longer part of the public API. This example needs a custom - models adapter to work with the new Agent API. - """ - messages = [ - ai.system_message("Answer questions using the weather and population tools."), - ai.user_message(user_query), - ] - - # Manually implement the loop since we can't use Agent with LanguageModel - tools = [get_weather, get_population] - local_messages = list(messages) - - while True: - result_messages: list[ai.Message] = [] - async for msg in llm.stream(local_messages, tools=tools): - result_messages.append(msg) - result = ai.StreamResult(messages=result_messages) - - if not result.tool_calls: - return result - - last_msg = result.last_message - if last_msg is not None: - local_messages.append(last_msg) - - await asyncio.gather( - *(ai.execute_tool(tc, message=last_msg) for tc in result.tool_calls) - ) - - -# ── Workflow ───────────────────────────────────────────────────── - - -@temporalio.workflow.defn -class AgentWorkflow: - @temporalio.workflow.run - async def run(self, user_query: str) -> str: - llm = DurableModel( - lambda params: temporalio.workflow.execute_activity( - activities.llm_call_activity, - params, - start_to_close_timeout=datetime.timedelta(minutes=5), - retry_policy=temporalio.common.RetryPolicy(maximum_attempts=3), - ) - ) - - # TODO: This uses the old free-function pattern. Once models - # supports custom adapters for Temporal, use Agent.run() instead. - from vercel_ai_sdk.agents import run - - final_text = "" - async for msg in run(agent, llm, user_query): - if msg.text: - final_text = msg.text - return final_text diff --git a/src/vercel_ai_sdk/__init__.py b/src/vercel_ai_sdk/__init__.py index 5dcc0c59..3b23fc0a 100644 --- a/src/vercel_ai_sdk/__init__.py +++ b/src/vercel_ai_sdk/__init__.py @@ -1,28 +1,24 @@ from . import adapters, models, telemetry from .adapters import ai_sdk_ui from .agents import ( + TOOL_APPROVAL_HOOK_TYPE, Agent, - AgentRun, Checkpoint, Context, - Hook, - HookInfo, - LoopFn, + DurabilityProvider, + EventLogProvider, + HookEvent, PendingHookInfo, - RunResult, - Runtime, - StreamResult, + StepEvent, Tool, ToolApproval, - ToolSource, + ToolCall, + ToolEvent, agent, - execute_tool, - get_checkpoint, - get_context, + cancel_hook, hook, mcp, - stream, - stream_step, + resolve_hook, tool, ) from .models import Client, Model, ModelCost @@ -37,13 +33,23 @@ ReasoningPart, StructuredOutputPart, TextPart, + ToolCallPart, ToolDelta, ToolLike, - ToolPart, + ToolResultPart, ToolSchema, Usage, make_messages, ) +from .types.builders import ( + assistant_message, + file_part, + system_message, + thinking, + tool_message, + tool_result, + user_message, +) __all__ = [ # Types (from types/) @@ -51,7 +57,8 @@ "Part", "PartState", "TextPart", - "ToolPart", + "ToolCallPart", + "ToolResultPart", "ToolDelta", "ReasoningPart", "FilePart", @@ -61,6 +68,14 @@ "ToolSchema", "Usage", "make_messages", + # Builders (from types/builders) + "user_message", + "assistant_message", + "system_message", + "tool_message", + "tool_result", + "file_part", + "thinking", # Models (from models/) "Model", "ModelCost", @@ -68,33 +83,27 @@ "models", # Agents — primary API "Agent", - "AgentRun", "agent", - "LoopFn", - # Agents — composition primitives - "stream_step", - "execute_tool", - "get_checkpoint", - "stream", - "StreamResult", + "Context", # Agents — tools "Tool", + "ToolCall", "tool", # Agents — hooks - "Hook", "hook", + "resolve_hook", + "cancel_hook", "ToolApproval", - # Agents — context - "Context", - "ToolSource", - "get_context", - # Agents — runtime (developer API) - "Runtime", - "RunResult", - "HookInfo", + "TOOL_APPROVAL_HOOK_TYPE", + # Agents — durability + "DurabilityProvider", + "EventLogProvider", # Agents — checkpoint "Checkpoint", "PendingHookInfo", + "StepEvent", + "ToolEvent", + "HookEvent", # Submodules "telemetry", "mcp", diff --git a/src/vercel_ai_sdk/_durability.py b/src/vercel_ai_sdk/_durability.py new file mode 100644 index 00000000..4f85fe97 --- /dev/null +++ b/src/vercel_ai_sdk/_durability.py @@ -0,0 +1,37 @@ +"""Shared durability context var. + +Lives at the package root so that both ``models`` (lower-level) and +``agents`` (higher-level) can import it without circular dependencies. +The actual ``DurabilityProvider`` protocol and implementations live in +``agents.durability``; this module only holds the context var and a +thin accessor. +""" + +from __future__ import annotations + +import contextvars +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from .agents import durability + +# The context var stores Any at runtime to avoid importing the protocol +# at module level. ``agents.durability`` provides the typed accessor. +_provider: contextvars.ContextVar[Any] = contextvars.ContextVar( + "durability_provider", default=None +) + + +def get_provider() -> durability.DurabilityProvider | None: + """Return the active durability provider, or ``None``.""" + return _provider.get(None) # type: ignore[no-any-return] + + +def set_provider(provider: Any) -> contextvars.Token[Any]: + """Set the active durability provider. Returns a reset token.""" + return _provider.set(provider) + + +def reset_provider(token: contextvars.Token[Any]) -> None: + """Reset the durability provider to its previous value.""" + _provider.reset(token) diff --git a/src/vercel_ai_sdk/adapters/ai_sdk_ui/adapter.py b/src/vercel_ai_sdk/adapters/ai_sdk_ui/adapter.py index ba0ec8e4..01b0bb62 100644 --- a/src/vercel_ai_sdk/adapters/ai_sdk_ui/adapter.py +++ b/src/vercel_ai_sdk/adapters/ai_sdk_ui/adapter.py @@ -8,9 +8,10 @@ import json import logging from collections.abc import AsyncGenerator, AsyncIterable -from typing import Any, Literal +from typing import Any from ...agents import hooks +from ...agents.hooks import TOOL_APPROVAL_HOOK_TYPE from ...types import messages as messages_ from . import protocol, ui_message @@ -138,7 +139,7 @@ def _tool_call_id_from_approval_hook( Returns the tool_call_id if this is a ToolApproval hook whose hook_id follows the ``approve_{tool_call_id}`` convention, otherwise None. """ - if hook_part.hook_type != hooks.ToolApproval.hook_type: # type: ignore[attr-defined] + if hook_part.hook_type != TOOL_APPROVAL_HOOK_TYPE: return None prefix = "approve_" if hook_part.hook_id.startswith(prefix): @@ -169,6 +170,13 @@ async def to_ui_message_stream( state = _StreamState() async for msg in messages: + # Tool-result messages (role="tool") are emitted by the Runtime + # as separate Message objects (with their own auto-generated id). + # To the frontend they belong to the *same* step as the tool + # call, so we pin the message id to avoid a spurious step boundary. + if msg.role == "tool" and state.message_id: + msg = msg.model_copy(update={"id": state.message_id}) + # Tool-approval hook messages are emitted by the Runtime as # separate Message objects (with their own id). To the frontend # they belong to the *same* step as the tool call, so we pin @@ -217,26 +225,21 @@ async def to_ui_message_stream( for part in state.close_open_blocks(): yield part - # Scan tool parts for new pending/completed states - has_new_pending_tools = False - has_new_tool_results = False - - for msg_part in msg.parts: - if isinstance(msg_part, messages_.ToolPart): - if ( - msg_part.status == "pending" - and msg_part.tool_call_id not in state.pending_tool_calls - ): - has_new_pending_tools = True - elif ( - msg_part.status in ("result", "error") - and msg_part.tool_call_id not in state.emitted_tool_results - ): - has_new_tool_results = True + # Scan for new pending tool calls or tool results + has_new_pending_tools = any( + isinstance(p, messages_.ToolCallPart) + and p.tool_call_id not in state.pending_tool_calls + for p in msg.parts + ) + has_new_tool_results = any( + isinstance(p, messages_.ToolResultPart) + and p.tool_call_id not in state.emitted_tool_results + for p in msg.parts + ) - # Process parts in two passes: - # 1. First handle text and pending tools - # 2. Then handle tool results (which may need their own step) + # Process parts in passes: + # 1. Text and pending tool calls (from assistant messages) + # 2. Tool results (from tool messages) # Pass 1: Text and pending tool inputs for msg_part in msg.parts: @@ -250,8 +253,7 @@ async def to_ui_message_stream( text_id = messages_.generate_id("text") yield protocol.TextStartPart(id=text_id) yield protocol.TextEndPart(id=text_id) - case messages_.ToolPart( - status="pending", + case messages_.ToolCallPart( tool_call_id=tc_id, tool_name=name, tool_args=args, @@ -270,25 +272,19 @@ async def to_ui_message_stream( input=args, ) - # Pass 2: Tool outputs (same step as tool input per AI SDK protocol) - # Tool input and output are part of the same "step" (one LLM turn) + # Pass 2: Tool results if has_new_tool_results: for msg_part in msg.parts: - match msg_part: - case messages_.ToolPart( - tool_call_id=tc_id, - result=result, - status=status, - ) if ( - status in ("result", "error") - and tc_id not in state.emitted_tool_results - ): - state.emitted_tool_results.add(tc_id) - state.pending_tool_calls.discard(tc_id) - yield protocol.ToolOutputAvailablePart( - tool_call_id=tc_id, - output=result, - ) + if ( + isinstance(msg_part, messages_.ToolResultPart) + and msg_part.tool_call_id not in state.emitted_tool_results + ): + state.emitted_tool_results.add(msg_part.tool_call_id) + state.pending_tool_calls.discard(msg_part.tool_call_id) + yield protocol.ToolOutputAvailablePart( + tool_call_id=msg_part.tool_call_id, + output=msg_part.result, + ) # Pass 3: Hook-based tool approvals for msg_part in msg.parts: @@ -356,15 +352,14 @@ async def to_sse_stream( _TOOL_ERROR_STATES: frozenset[str] = frozenset({"output-error", "output-denied"}) -def _map_tool_status( - state: ui_message.UIToolInvocationState, -) -> Literal["pending", "result", "error"]: - """Map AI SDK v6 tool invocation state to internal status.""" - if state in _TOOL_ERROR_STATES: - return "error" - if state in _TOOL_RESULT_STATES: - return "result" - return "pending" +def _is_tool_completed(state: ui_message.UIToolInvocationState) -> bool: + """Return True if the tool invocation state indicates a completed tool.""" + return state in _TOOL_RESULT_STATES or state in _TOOL_ERROR_STATES + + +def _is_tool_error(state: ui_message.UIToolInvocationState) -> bool: + """Return True if the tool invocation state indicates an error.""" + return state in _TOOL_ERROR_STATES def _normalize_tool_args(tool_input: str | dict[str, Any] | None) -> str: @@ -379,9 +374,9 @@ def _normalize_tool_args(tool_input: str | dict[str, Any] | None) -> str: def _normalize_tool_result(output: Any) -> dict[str, Any] | None: - """Normalize tool output to dict format for internal ToolPart. + """Normalize tool output to dict format for internal ToolResultPart. - The internal ToolPart.result expects dict | None, but AI SDK + The internal ToolResultPart.result expects dict | None, but AI SDK output can be any type. Wrap non-dict results for compatibility. """ if output is None: @@ -412,39 +407,56 @@ def to_messages( resolved_any_approval = False for ui_msg in ui_messages: - internal_parts: list[messages_.Part] = [] + # For assistant messages, separate tool calls from tool results. + assistant_parts: list[messages_.Part] = [] + tool_result_parts: list[messages_.ToolResultPart] = [] for part in ui_msg.parts: match part: case ui_message.UITextPart(text=text) if text: - internal_parts.append(messages_.TextPart(text=text)) + assistant_parts.append(messages_.TextPart(text=text)) case ui_message.UIReasoningPart(reasoning=reasoning): - internal_parts.append(messages_.ReasoningPart(text=reasoning)) + assistant_parts.append(messages_.ReasoningPart(text=reasoning)) case ui_message.UIToolInvocationPart() as inv: - # Legacy tool-invocation type - internal_parts.append( - messages_.ToolPart( + # Legacy tool-invocation type — always create the call part + tool_args = json.dumps(inv.args) if inv.args else "{}" + assistant_parts.append( + messages_.ToolCallPart( tool_call_id=inv.tool_invocation_id, tool_name=inv.tool_name, - tool_args=json.dumps(inv.args) if inv.args else "{}", - status=_map_tool_status(inv.state), - result=inv.result, + tool_args=tool_args, ) ) + if _is_tool_completed(inv.state): + tool_result_parts.append( + messages_.ToolResultPart( + tool_call_id=inv.tool_invocation_id, + tool_name=inv.tool_name, + result=inv.result, + is_error=_is_tool_error(inv.state), + ) + ) case ui_message.UIToolPart() as tp: # Dynamic tool-{toolName} type (e.g., "tool-get_weather") - internal_parts.append( - messages_.ToolPart( + assistant_parts.append( + messages_.ToolCallPart( tool_call_id=tp.tool_call_id, tool_name=tp.tool_name, tool_args=_normalize_tool_args(tp.input), - status=_map_tool_status(tp.state), - result=_normalize_tool_result(tp.output), ) ) + if _is_tool_completed(tp.state): + tool_result_parts.append( + messages_.ToolResultPart( + tool_call_id=tp.tool_call_id, + tool_name=tp.tool_name, + result=_normalize_tool_result(tp.output), + is_error=_is_tool_error(tp.state), + ) + ) # Side-effect: resolve ToolApproval hooks from approval # responses so the agent loop can resume execution. if ( @@ -452,7 +464,7 @@ def to_messages( and tp.approval is not None and tp.approval.approved is not None ): - hooks.ToolApproval.resolve( # type: ignore[attr-defined] + hooks.resolve_hook( tp.approval.id, { "granted": tp.approval.approved, @@ -462,7 +474,7 @@ def to_messages( resolved_any_approval = True case ui_message.UIFilePart() as fp: - internal_parts.append( + assistant_parts.append( messages_.FilePart( data=fp.url, media_type=fp.media_type, @@ -477,9 +489,8 @@ def to_messages( ): pass # Skip unsupported/boundary parts - # Validate user/system messages have content - OpenAI requires it there. - # Assistant messages can have empty content if they have tool calls. - if ui_msg.role in ("user", "system") and not internal_parts: + # Validate user/system messages have content - OpenAI requires it. + if ui_msg.role in ("user", "system") and not assistant_parts: raise ValueError( f"Message '{ui_msg.id}' has role '{ui_msg.role}' but no content. " "User and system messages require non-empty content." @@ -487,16 +498,21 @@ def to_messages( # The UI sends one assistant message per conversation turn, but a # single turn may span multiple default-loop iterations (e.g. - # [text, tool(done), text, tool(done), text]). LLM APIs expect - # one message per iteration, so split at completed-tool boundaries. + # [text, tool_call, tool_result, text, tool_call, tool_result, text]). + # LLM APIs expect one message per iteration, so split into + # assistant + tool message pairs at tool-result boundaries. if ui_msg.role == "assistant": - result.extend(_split_assistant_parts(internal_parts, msg_id=ui_msg.id)) + result.extend( + _split_assistant_parts( + assistant_parts, tool_result_parts, msg_id=ui_msg.id + ) + ) else: result.append( messages_.Message( id=ui_msg.id, role=ui_msg.role, - parts=internal_parts, + parts=assistant_parts, ) ) @@ -515,34 +531,79 @@ def to_messages( def _split_assistant_parts( parts: list[messages_.Part], + tool_results: list[messages_.ToolResultPart], msg_id: str, ) -> list[messages_.Message]: - """Split assistant parts at completed-tool → non-tool boundaries. + """Split assistant parts into assistant + tool message pairs. - Returns one ``Message`` per default-loop iteration so that LLM - adapters receive correctly-shaped single-iteration messages. + The UI sends one big assistant message per turn, but internally each + loop iteration produces an assistant message (with tool calls) followed + by a tool message (with results). This reconstructs that structure. + + Returns a list of Messages: alternating assistant and tool messages, + split at tool-call boundaries when results are available. """ + # Index tool results by their tool_call_id for lookup + results_by_id = {tr.tool_call_id: tr for tr in tool_results} + messages: list[messages_.Message] = [] current: list[messages_.Part] = [] - has_completed_tool = False + pending_results: list[messages_.ToolResultPart] = [] + + for part in parts: + current.append(part) + + # When we see a ToolCallPart that has a result, accumulate it + if ( + isinstance(part, messages_.ToolCallPart) + and part.tool_call_id in results_by_id + ): + pending_results.append(results_by_id[part.tool_call_id]) + + # If there are pending results and more parts follow, we need to split. + # Walk again, splitting at boundaries where all accumulated tool calls + # have results and a non-tool part follows. + if not pending_results: + # No completed tools — single assistant message + if current: + messages.append( + messages_.Message(role="assistant", parts=current, id=msg_id) + ) + return messages + + # Re-walk to split at tool-call boundaries + messages = [] + current = [] + current_results: list[messages_.ToolResultPart] = [] + seen_tool_call = False for part in parts: - if has_completed_tool and not isinstance(part, messages_.ToolPart): + # If we had a completed tool call group and now see a non-tool part, + # split here + if ( + seen_tool_call + and current_results + and not isinstance(part, messages_.ToolCallPart) + ): messages.append( messages_.Message(role="assistant", parts=current, id=msg_id) ) + messages.append(messages_.Message(role="tool", parts=list(current_results))) current = [] - has_completed_tool = False + current_results = [] + seen_tool_call = False current.append(part) - if isinstance(part, messages_.ToolPart) and part.status in ( - "result", - "error", - ): - has_completed_tool = True + if isinstance(part, messages_.ToolCallPart): + seen_tool_call = True + if part.tool_call_id in results_by_id: + current_results.append(results_by_id[part.tool_call_id]) + # Flush remaining if current: messages.append(messages_.Message(role="assistant", parts=current, id=msg_id)) + if current_results: + messages.append(messages_.Message(role="tool", parts=list(current_results))) return messages diff --git a/src/vercel_ai_sdk/agents/__init__.py b/src/vercel_ai_sdk/agents/__init__.py index d7a62b0f..48e184e6 100644 --- a/src/vercel_ai_sdk/agents/__init__.py +++ b/src/vercel_ai_sdk/agents/__init__.py @@ -1,64 +1,33 @@ -"""Agent loop orchestration — tools, hooks, runtime, and streaming. - -Depends on types/ and models/. Provides the loop machinery that -plugs a model into a tool-calling loop with hooks and checkpoints. -""" - from . import mcp -from .agent import Agent, AgentRun, LoopFn, agent, stream_step -from .checkpoint import Checkpoint, PendingHookInfo -from .context import Context, ToolSource, get_context -from .hooks import Hook, ToolApproval, hook -from .runtime import ( - EventLog, - HookInfo, - LoopExecutor, - RunResult, - Runtime, - execute_tool, - get_checkpoint, - run, +from .agent import Agent, Context, Tool, ToolCall, agent, tool +from .checkpoint import Checkpoint, HookEvent, PendingHookInfo, StepEvent, ToolEvent +from .durability import DurabilityProvider, EventLogProvider +from .hooks import ( + TOOL_APPROVAL_HOOK_TYPE, + ToolApproval, + cancel_hook, + hook, + resolve_hook, ) -from .streams import StreamResult, stream -from .tools import Tool, ToolLike, ToolSchema, get_tool, tool __all__ = [ - # Agent (primary user API) "Agent", - "AgentRun", - "agent", - "LoopFn", - # Composition primitives - "stream_step", - "execute_tool", - "get_checkpoint", - # Context + "Checkpoint", "Context", - "ToolSource", - "get_context", - # Runtime (developer API) - "Runtime", - "EventLog", - "LoopExecutor", - "RunResult", - "HookInfo", - "run", - # Stream - "stream", - "StreamResult", - # Tools + "DurabilityProvider", + "EventLogProvider", + "HookEvent", + "PendingHookInfo", + "StepEvent", "Tool", - "ToolLike", - "ToolSchema", - "tool", - "get_tool", - # Hooks - "Hook", + "ToolCall", + "ToolEvent", + "agent", + "cancel_hook", "hook", - "ToolApproval", - # Checkpoint - "Checkpoint", - "PendingHookInfo", - # Submodules "mcp", + "resolve_hook", + "tool", + "ToolApproval", + "TOOL_APPROVAL_HOOK_TYPE", ] diff --git a/src/vercel_ai_sdk/agents/agent.py b/src/vercel_ai_sdk/agents/agent.py index 5d9e2688..e54c68dc 100644 --- a/src/vercel_ai_sdk/agents/agent.py +++ b/src/vercel_ai_sdk/agents/agent.py @@ -1,266 +1,311 @@ -"""Agent — the primary user-facing API. - -Bundles model, system prompt, and tools into a reusable, composable -unit. Provides a default tool-calling loop and a decorator to -override it. - -Usage:: - - agent = ai.agent( - model=my_model, - system="You are a helpful assistant.", - tools=[get_weather, get_population], - ) - - # stream messages - async for msg in agent.run(messages): - print(msg.text_delta, end="") - - # or collect the final result - result = await agent.run(messages).collect() - print(result.text) -""" +"""Agent, Context, StreamResult, and the stream() function.""" from __future__ import annotations import asyncio -from collections.abc import AsyncGenerator, Awaitable, Callable, Sequence -from typing import Any +import inspect +import json +from collections.abc import AsyncGenerator, Awaitable, Callable +from typing import Any, Protocol, get_type_hints import pydantic -from .. import models -from ..types import messages as messages_ +from .. import _durability as _dctx +from .. import models, types +from ..telemetry import events as telemetry +from ..types import builders from . import checkpoint as checkpoint_ -from . import context, runtime, streams -from . import tools as tools_ - -# ── Types ───────────────────────────────────────────────────────── - -LoopFn = Callable[ - ["Agent", list[messages_.Message]], Awaitable[streams.StreamResult | None] -] - +from . import durability as durability_ +from . import runtime -# ── Composition primitives ──────────────────────────────────────── +class Tool[**P, R]: + """Wraps async function, introspects schema, attaches a validator""" -@streams.stream -async def stream_step( - model: models.Model, - messages: list[messages_.Message], - tools: Sequence[tools_.ToolLike] | None = None, - label: str | None = None, - output_type: type[pydantic.BaseModel] | None = None, - **kwargs: Any, -) -> AsyncGenerator[messages_.Message]: - """Single LLM call that streams into the Runtime queue. - - This is a composition primitive for custom ``@agent.loop`` - functions and multi-agent orchestration. It is decorated with - ``@stream``, so each call becomes a replayable step in the - event log. - """ - async for msg in models.stream( - model, messages, tools=tools, output_type=output_type, **kwargs - ): - yield msg.model_copy(update={"label": label}) if label is not None else msg - + def __init__( + self, + fn: Callable[P, Awaitable[R]], + schema: types.ToolSchema, + validator: type[pydantic.BaseModel] | None = None, + *, + is_gen: bool = False, + ) -> None: + self._fn = fn + self._validator = validator + self._is_gen = is_gen + self.schema = schema + + async def __call__(self, json_args: str) -> R: + """Parse json_args into kwargs, validate, and call the function.""" + kwargs = json.loads(json_args) if json_args else {} + if self._validator is not None: + self._validator.model_validate(kwargs) + + if not self._is_gen: + return await self._fn(**kwargs) # type: ignore[call-arg] + + # Generator tool (e.g. agent-as-a-tool): drain the async + # generator, pipe each yielded message to the runtime for + # real-time streaming, and return the final text as the result. + rt = runtime.get_runtime() + last: types.Message | None = None + async for message in self._fn(**kwargs): # type: ignore[call-arg,attr-defined] + await rt.put_message(message) + last = message + return last.text if last else "" # type: ignore[return-value] -# ── AgentRun ────────────────────────────────────────────────────── + @property + def name(self) -> str: + return self.schema.name + @property + def description(self) -> str: + return self.schema.description -class AgentRun: - """Returned by ``agent.run()``. Async-iterate for messages, then - inspect post-run state. + @property + def param_schema(self) -> dict[str, Any]: + return self.schema.param_schema + + +def tool[**P, R](fn: Callable[P, Awaitable[R]]) -> Tool[P, R]: + """Decorator: turn an async function into a :class:`Tool`.""" + sig = inspect.signature(fn) + hints = get_type_hints(fn) if hasattr(fn, "__annotations__") else {} + + fields: dict[str, Any] = {} + for param_name, param in sig.parameters.items(): + param_type = hints.get(param_name, str) + if param.default is inspect.Parameter.empty: + fields[param_name] = (param_type, ...) + else: + fields[param_name] = (param_type, param.default) + + validator = pydantic.create_model(f"{fn.__name__}_Args", **fields) + + schema = types.ToolSchema( + name=fn.__name__, + description=inspect.getdoc(fn) or "", + param_schema=validator.model_json_schema(), + return_type=hints.get("return", None), + ) - Usage:: + return Tool( + fn=fn, + schema=schema, + validator=validator, + is_gen=inspect.isasyncgenfunction(fn), + ) - run = agent.run(messages) - # streaming - async for msg in run: - print(msg.text_delta, end="") - run.checkpoint # checkpoint after iteration - run.pending_hooks # unresolved hooks (empty if completed) +class ToolCall: + """Callable that binds a :class:`ToolCallPart` to its :class:`Tool`. - # non-streaming - result = await agent.run(messages).collect() - print(result.text) + Calling it executes the tool and returns a :class:`ToolResultPart`. """ - def __init__(self, inner: runtime.RunResult) -> None: - self._inner = inner + def __init__(self, part: types.ToolCallPart, tool: Tool[..., Any]) -> None: + self._part = part + self._tool = tool - async def __aiter__(self) -> AsyncGenerator[messages_.Message]: - async for msg in self._inner: - yield msg - - async def collect(self) -> streams.StreamResult: - """Drain the stream and return a :class:`StreamResult`.""" - msgs: list[messages_.Message] = [] - async for msg in self._inner: - msgs.append(msg) - return streams.StreamResult(messages=msgs) + @property + def id(self) -> str: + return self._part.tool_call_id @property - def checkpoint(self) -> checkpoint_.Checkpoint: - return self._inner.checkpoint + def name(self) -> str: + return self._part.tool_name @property - def pending_hooks(self) -> dict[str, runtime.HookInfo]: - return self._inner.pending_hooks + def args(self) -> str: + return self._part.tool_args + async def __call__(self) -> types.ToolResultPart: + """Execute the tool and return a :class:`ToolResultPart`. -# ── Agent ───────────────────────────────────────────────────────── + If a durability provider is active, the call is routed through + it for recording or replay. + """ + provider = _dctx.get_provider() + telemetry.handle( + telemetry.ToolCallStartEvent( + tool_name=self.name, + tool_call_id=self.id, + args=self.args, + ) + ) + t0 = telemetry.time_ms() + error_str: str | None = None + + async def _execute() -> types.ToolResultPart: + nonlocal error_str + try: + result = await self._tool(self._part.tool_args) + except Exception as exc: + error_str = str(exc) + return types.ToolResultPart( + tool_call_id=self._part.tool_call_id, + tool_name=self._part.tool_name, + result=str(exc), + is_error=True, + ) + return types.ToolResultPart( + tool_call_id=self._part.tool_call_id, + tool_name=self._part.tool_name, + result=result, + ) -class Agent: - """An agent — bundles model, system prompt, tools, and loop logic. + if provider is not None: + result_part = await provider.execute_tool( + _execute, + tool_call_id=self.id, + tool_name=self.name, + ) + else: + result_part = await _execute() + + telemetry.handle( + telemetry.ToolCallFinishEvent( + tool_name=self.name, + tool_call_id=self.id, + result=result_part.result, + error=error_str, + duration_ms=telemetry.time_ms() - t0, + ) + ) + return result_part - Create via :func:`agent`:: - weather = ai.agent( - model=my_model, - system="Answer questions about weather.", - tools=[get_weather], - ) +class Context(pydantic.BaseModel): + """Everything that goes into the LLM.""" - Tools default to all globally registered tools when ``None`` - (the default). Pass ``tools=[]`` to explicitly disable tools. + model: models.Model + messages: list[types.Message] + tools: list[Tool[..., Any]] - Override the default tool-calling loop with ``@agent.loop``:: + model_config = pydantic.ConfigDict(arbitrary_types_allowed=True) - @weather.loop - async def custom(agent, messages): - ... - """ + def resolve(self, tool_parts: list[types.ToolCallPart]) -> list[ToolCall]: + """Resolve ToolCallParts into callable ToolCall objects.""" + tools_by_name = {t.name: t for t in self.tools} + return [ + ToolCall(part=tp, tool=tools_by_name[tp.tool_name]) for tp in tool_parts + ] - def __init__( - self, - model: models.Model, - system: str = "", - tools: list[tools_.Tool[..., Any]] | None = None, - ) -> None: - self._model = model - self._system = system - self._tools = tools - self._custom_loop: LoopFn | None = None - @property - def model(self) -> models.Model: - return self._model +class LoopFn(Protocol): + def __call__(self, context: Context) -> AsyncGenerator[types.Message]: ... - @property - def system(self) -> str: - return self._system - @property - def tools(self) -> list[tools_.Tool[..., Any]]: - """Registered tools. ``None`` at init resolves to all globally - registered tools at access time.""" - if self._tools is None: - return list(tools_._tool_registry.values()) - return list(self._tools) +async def _default_loop(context: Context) -> AsyncGenerator[types.Message]: + step_index = 0 + while True: + telemetry.handle(telemetry.StepStartEvent(step_index=step_index)) - def loop(self, fn: LoopFn) -> LoopFn: - """Decorator to override the default agent loop. + stream = await models.stream( + context.model, context.messages, tools=context.tools + ) + done_message: types.Message | None = None + async for message in stream: + done_message = message + yield message + + if done_message is not None: + telemetry.handle( + telemetry.StepFinishEvent(step_index=step_index, message=done_message) + ) + step_index += 1 - The decorated function receives the :class:`Agent` instance and - the per-run messages:: + tool_calls = context.resolve(stream.tool_calls) + if not tool_calls: + break - @my_agent.loop - async def custom( - agent: ai.Agent, messages: list[ai.Message], - ) -> ai.StreamResult: - ... - """ - self._custom_loop = fn - return fn + # Execute tool calls in parallel. + async with asyncio.TaskGroup() as tg: + tasks = [tg.create_task(tc()) for tc in tool_calls] - async def _default_loop( - self, messages: list[messages_.Message] - ) -> streams.StreamResult: - """Built-in loop: stream LLM, execute tools, repeat.""" - local_messages = list(messages) + # Yield a tool-result message — history auto-collects it. + yield builders.tool_message(*(t.result() for t in tasks)) - while True: - result = await stream_step(self.model, local_messages, self.tools) - if not result.tool_calls: - return result +async def _collect_messages( + source: AsyncGenerator[types.Message], + messages: list[types.Message], +) -> AsyncGenerator[types.Message]: + """Intercept yielded messages and collect done ones into *messages*. - last_msg = result.last_message - if last_msg is None: - return result + This runs on the **producer** side (same coroutine as the loop function), + so ``messages`` is always up-to-date by the time the loop reads it for + the next model call — avoiding the race that would occur if collection + happened on the consumer side of the runtime queue. + """ + async for message in source: + if message.is_done: + for i, existing in enumerate(messages): + if existing.id == message.id: + messages[i] = message + break + else: + messages.append(message) + yield message - updated_parts = await asyncio.gather( - *( - runtime.execute_tool(tc, message=last_msg) - for tc in result.tool_calls - ) - ) - updated_msg = last_msg - for updated_tc in updated_parts: - updated_msg = updated_msg.replace(updated_tc) - local_messages.append(updated_msg) - def run( +class Agent: + """Bag of configuration: model + tools + loop.""" + + def __init__( + self, + *, + tools: list[Tool[..., Any]] | None = None, + ) -> None: + self._tools: list[Tool[..., Any]] = tools or [] + self._loop_fn: LoopFn = _default_loop + + def loop(self, fn: LoopFn) -> LoopFn: + """Decorator: override the default loop function.""" + self._loop_fn = fn + return fn + + async def run( self, - messages: list[messages_.Message], + model: models.Model, + messages: list[types.Message], *, + durability: durability_.DurabilityProvider | None = None, checkpoint: checkpoint_.Checkpoint | None = None, - ) -> AgentRun: - """Run the agent. - - Returns an :class:`AgentRun` — async-iterate for streamed - messages, or call ``.collect()`` for the final result. + ) -> AsyncGenerator[types.Message]: + """Run the agent loop, yielding messages to the consumer. Args: - messages: Conversation messages (user, assistant, etc.). - checkpoint: Resume from a previous checkpoint. + model: The model to use for LLM calls. + messages: Initial conversation messages. + durability: Explicit durability provider. If ``None`` but + *checkpoint* is given, an :class:`EventLogProvider` is + created automatically. + checkpoint: Checkpoint to resume from. Implies eventlog + durability when no explicit *durability* is provided. """ - # Prepend system prompt - full_messages: list[messages_.Message] = [] - if self._system: - full_messages.append( - messages_.Message( - role="system", - parts=[messages_.TextPart(text=self._system)], - ) - ) - full_messages.extend(messages) - - ctx = context.Context(tools=self.tools) - - # Build the graph function that runtime.run() expects - async def _graph() -> streams.StreamResult | None: - if self._custom_loop: - return await self._custom_loop(self, full_messages) - return await self._default_loop(full_messages) - - inner = runtime.run( - _graph, - checkpoint=checkpoint, - context=ctx, - ) - return AgentRun(inner) + # Convenience: checkpoint implies eventlog provider. + if checkpoint is not None and durability is None: + durability = durability_.EventLogProvider(checkpoint) + context = Context(model=model, messages=list(messages), tools=self._tools) -# ── Factory ─────────────────────────────────────────────────────── + # Set the durability provider on the shared context var so that + # models.stream() and ToolCall.__call__() auto-detect it. + token = _dctx.set_provider(durability) if durability is not None else None + try: + source = _collect_messages(self._loop_fn(context), context.messages) + async for message in runtime.run(source): + yield message + finally: + if token is not None: + _dctx.reset_provider(token) def agent( - model: models.Model, - system: str = "", - tools: list[tools_.Tool[..., Any]] | None = None, + *, + tools: list[Tool[..., Any]] | None = None, ) -> Agent: - """Create an :class:`Agent`. - - Args: - model: The language model to use. - system: System prompt. - tools: Tools available to the agent. ``None`` (default) means - all globally registered tools. Pass ``[]`` to disable. - """ - return Agent(model=model, system=system, tools=tools) + """Create an Agent.""" + return Agent(tools=tools) diff --git a/src/vercel_ai_sdk/agents/checkpoint.py b/src/vercel_ai_sdk/agents/checkpoint.py index 30c9bda6..6cd0d96e 100644 --- a/src/vercel_ai_sdk/agents/checkpoint.py +++ b/src/vercel_ai_sdk/agents/checkpoint.py @@ -1,3 +1,10 @@ +"""Checkpoint data model for durable agent execution. + +A Checkpoint is a serializable snapshot of all completed work in an agent +run. On re-entry, the durability provider replays cached results from the +checkpoint instead of re-executing LLM calls and tool invocations. +""" + from __future__ import annotations from typing import Any @@ -5,23 +12,20 @@ import pydantic from ..types import messages as messages_ -from . import streams class StepEvent(pydantic.BaseModel): - """A completed @stream step.""" + """A completed LLM stream step — stores the final done message.""" index: int - messages: list[messages_.Message] - - def to_stream_result(self) -> streams.StreamResult: - return streams.StreamResult(messages=list(self.messages)) + message: messages_.Message class ToolEvent(pydantic.BaseModel): """A completed tool execution.""" tool_call_id: str + tool_name: str result: Any status: str = "result" # "result" | "error" @@ -37,11 +41,13 @@ class PendingHookInfo(pydantic.BaseModel): """A hook that was suspended but not resolved when the run ended.""" label: str - hook_type: str + payload_type: str # fully qualified name of the pydantic model metadata: dict[str, Any] = {} class Checkpoint(pydantic.BaseModel): + """Serializable snapshot of all completed work in an agent run.""" + steps: list[StepEvent] = [] tools: list[ToolEvent] = [] hooks: list[HookEvent] = [] diff --git a/src/vercel_ai_sdk/agents/context.py b/src/vercel_ai_sdk/agents/context.py deleted file mode 100644 index 73dd56b1..00000000 --- a/src/vercel_ai_sdk/agents/context.py +++ /dev/null @@ -1,206 +0,0 @@ -"""Context — everything the LLM sees during a run. - -Consolidates tool registry, system prompt, message history, and model -reference into a single, serializable object. Independent of execution -machinery (Runtime) — can be constructed, inspected, and serialized -without starting a run. - -The context is stashed in a contextvar during ``run()`` so that -framework internals (``execute_tool``, MCP client, etc.) can access it. -""" - -from __future__ import annotations - -import contextvars -from collections.abc import Sequence -from typing import TYPE_CHECKING, Any - -import pydantic - -from ..types import messages as messages_ - -if TYPE_CHECKING: - from . import tools as tools_ - - -# ── ToolSource ──────────────────────────────────────────────────── - - -class ToolSource(pydantic.BaseModel): - """Provenance info for a tool — how to find or reconstruct it. - - Carries enough information to locate the code behind a tool, - whether it's a decorated Python function or an MCP server. - - Attributes: - kind: ``"python"``, ``"mcp_stdio"``, or ``"mcp_http"``. - module: Python module path, e.g. ``"myapp.tools"``. - qualname: Qualified name, e.g. ``"get_weather"``. - uri: Remote URL for HTTP-based MCP servers. - server_command: Launch command for stdio MCP servers. - """ - - model_config = pydantic.ConfigDict(frozen=True) - - kind: str - module: str | None = None - qualname: str | None = None - uri: str | None = None - server_command: str | None = None - - -# ── Context ─────────────────────────────────────────────────────── - - -class Context(pydantic.BaseModel): - """Everything the LLM sees: tools, system prompt, messages, model. - - Independent of execution machinery (Runtime). Constructable by the - user or auto-constructed by ``run()``. - - Usage:: - - ctx = Context( - system_prompt="You are a helpful assistant.", - tools=[get_weather, get_population], - ) - ctx.get_tool("get_weather") # look up by name - data = ctx.model_dump() # serializable snapshot - """ - - model_config = pydantic.ConfigDict(arbitrary_types_allowed=True) - - model: Any = None - system_prompt: str = "" - messages: list[messages_.Message] = pydantic.Field(default_factory=list) - - _tools: dict[str, tools_.Tool[..., Any]] = pydantic.PrivateAttr( - default_factory=dict - ) - - def __init__( - self, - *, - tools: Sequence[tools_.Tool[..., Any]] | None = None, - **data: Any, - ) -> None: - super().__init__(**data) - if tools: - for t in tools: - self.register_tool(t) - - # ── Tool registry (scoped to this context) ──────────────── - - def register_tool(self, tool: tools_.Tool[..., Any]) -> None: - """Register a tool in this context's scoped registry.""" - self._tools[tool.name] = tool - - def get_tool(self, name: str) -> tools_.Tool[..., Any] | None: - """Look up a tool by name. Returns ``None`` if not found.""" - return self._tools.get(name) - - @property - def tools(self) -> list[tools_.Tool[..., Any]]: - """All tools registered in this context.""" - return list(self._tools.values()) - - @property - def tool_schemas(self) -> list[tools_.ToolSchema]: - """Tool schemas — what gets sent to the LLM.""" - return [t.schema for t in self._tools.values()] - - # ── Serialization ───────────────────────────────────────── - - @pydantic.model_serializer - def _serialize(self) -> dict[str, Any]: - """Serialize including tool schemas and sources. - - Tool code is not serialized — only schemas and source - references. - """ - return { - "system_prompt": self.system_prompt, - "messages": [m.model_dump() for m in self.messages], - "tools": [ - { - "schema": t.schema.model_dump(), - "source": (t.source.model_dump() if t.source is not None else None), - } - for t in self._tools.values() - ], - } - - @pydantic.model_validator(mode="wrap") - @classmethod - def _validate( - cls, - data: Any, - handler: pydantic.ValidatorFunctionWrapHandler, - ) -> Context: - """Reconstruct from serialized form or pass through normal init. - - When deserializing, tools are schema-only (not executable) - unless their sources can be resolved from the global registry. - """ - # Normal construction (already a Context, or keyword args without - # a ``tools`` key that looks like serialized tool dicts). - if isinstance(data, cls): - return data - if not isinstance(data, dict) or "tools" not in data: - result: Context = handler(data) - return result - - # Check whether tools contains serialized dicts (from model_dump) - # vs. live Tool objects (from normal __init__). - tools_value = data["tools"] - if tools_value and isinstance(tools_value[0], dict): - return cls._from_serialized(data) - - # Live Tool objects — let the normal init path handle it. - result = handler(data) - return result - - @classmethod - def _from_serialized(cls, data: dict[str, Any]) -> Context: - """Reconstruct from ``model_dump()`` output.""" - from . import tools as tools_ - - ctx = cls( - system_prompt=data.get("system_prompt", ""), - messages=[ - messages_.Message.model_validate(m) for m in data.get("messages", []) - ], - ) - - for tool_data in data.get("tools", []): - schema = tools_.ToolSchema.model_validate(tool_data["schema"]) - source_data = tool_data.get("source") - source = ToolSource(**source_data) if source_data else None - - # Try to resolve the tool from the global registry - live_tool = tools_.get_tool(schema.name) - if live_tool is not None: - ctx.register_tool(live_tool) - else: - # Schema-only placeholder — inspectable but not executable - placeholder = tools_.Tool( - fn=tools_._unresolvable_tool_fn(schema.name), - schema=schema, - source=source, - ) - ctx.register_tool(placeholder) - - return ctx - - -# ── Contextvar ──────────────────────────────────────────────────── - -_context: contextvars.ContextVar[Context] = contextvars.ContextVar("context") - - -def get_context() -> Context: - """Get the active Context from the current run. - - Raises ``LookupError`` if called outside of ``ai.run()``. - """ - return _context.get() diff --git a/src/vercel_ai_sdk/agents/durability.py b/src/vercel_ai_sdk/agents/durability.py new file mode 100644 index 00000000..265c3eb9 --- /dev/null +++ b/src/vercel_ai_sdk/agents/durability.py @@ -0,0 +1,288 @@ +"""Durability provider protocol and built-in EventLog implementation. + +A DurabilityProvider intercepts execution boundaries (LLM streams and +tool calls) to record results on fresh execution or replay cached results +on re-entry. The provider is set on a context var by ``Agent.run()`` and +auto-detected by ``models.stream()`` and ``ToolCall.__call__()``. + +Two ways to get durability: + +1. **Direct composability** — write a custom loop that wraps primitives in + your own SDK (Temporal activities, Restate handlers, etc.). No provider + needed. + +2. **Provider interface** — pass a ``DurabilityProvider`` to ``Agent.run()``. + The framework routes ``models.stream()`` and ``ToolCall.__call__()`` + through the provider automatically via context var. +""" + +from __future__ import annotations + +import logging +from collections.abc import AsyncGenerator, Awaitable, Callable +from typing import Any, Protocol + +from .. import _durability as _dctx +from ..types import messages as messages_ +from . import checkpoint as checkpoint_ + +logger = logging.getLogger(__name__) + +# Re-export the shared accessors for convenience. +get_provider = _dctx.get_provider +set_provider = _dctx.set_provider +reset_provider = _dctx.reset_provider + + +# ── Protocol ───────────────────────────────────────────────────── + + +class StreamResultLike(Protocol): + """Minimal interface that models.StreamResult satisfies.""" + + def __aiter__(self) -> AsyncGenerator[messages_.Message]: ... + + @property + def tool_calls(self) -> list[messages_.ToolCallPart]: ... + + @property + def text(self) -> str: ... + + @property + def usage(self) -> messages_.Usage | None: ... + + +class DurabilityProvider(Protocol): + """Abstract interface for durable execution of agent operations. + + Implementations intercept LLM streams and tool calls to either + record results (fresh execution) or replay cached results (re-entry). + """ + + async def execute_stream( + self, + fn: Callable[[], Awaitable[StreamResultLike]], + ) -> StreamResultLike: + """Wrap an LLM stream step. + + *fn* is an async factory that creates the real ``StreamResult``. + The provider may call it (fresh execution) or skip it and return + a ``StreamResult`` wrapping cached messages (replay). + + The provider manages its own step counter internally. + """ + ... + + async def execute_tool( + self, + fn: Callable[[], Awaitable[messages_.ToolResultPart]], + *, + tool_call_id: str, + tool_name: str, + ) -> messages_.ToolResultPart: + """Wrap a tool call. + + *fn* executes the real tool. The provider may call it or return + a cached ``ToolResultPart`` from the checkpoint. + """ + ... + + def get_hook_resolution(self, label: str) -> dict[str, Any] | None: + """Return a cached hook resolution, or ``None`` if not cached.""" + ... + + def record_hook(self, label: str, resolution: dict[str, Any]) -> None: + """Record a hook resolution for checkpoint.""" + ... + + def checkpoint(self) -> checkpoint_.Checkpoint: + """Return a snapshot of all completed work.""" + ... + + +# ── EventLogProvider ───────────────────────────────────────────── + + +class _ReplayStreamResult: + """Lightweight StreamResult substitute for replayed steps. + + Yields the cached final message and exposes ``.tool_calls``, + ``.text``, and ``.usage`` just like ``models.StreamResult``. + """ + + def __init__(self, message: messages_.Message) -> None: + self._message = message + + def __aiter__(self) -> AsyncGenerator[messages_.Message]: + return self._generate() + + async def _generate(self) -> AsyncGenerator[messages_.Message]: + yield self._message + + @property + def tool_calls(self) -> list[messages_.ToolCallPart]: + return self._message.tool_calls + + @property + def text(self) -> str: + return self._message.text + + @property + def usage(self) -> messages_.Usage | None: + return self._message.usage + + @property + def output(self) -> Any: + return self._message.output + + +class _RecordingStreamResult: + """Wraps a real StreamResult, forwarding and recording.""" + + def __init__( + self, + inner: StreamResultLike, + *, + index: int, + steps: list[checkpoint_.StepEvent], + ) -> None: + self._inner = inner + self._index = index + self._steps = steps + self._final: messages_.Message | None = None + + def __aiter__(self) -> AsyncGenerator[messages_.Message]: + return self._generate() + + async def _generate(self) -> AsyncGenerator[messages_.Message]: + async for msg in self._inner: + self._final = msg + yield msg + + # Record the final done message. + if self._final is not None and self._final.is_done: + self._steps.append( + checkpoint_.StepEvent(index=self._index, message=self._final) + ) + + @property + def tool_calls(self) -> list[messages_.ToolCallPart]: + return self._final.tool_calls if self._final else [] + + @property + def text(self) -> str: + return self._final.text if self._final else "" + + @property + def usage(self) -> messages_.Usage | None: + return self._final.usage if self._final else None + + @property + def output(self) -> Any: + return self._final.output if self._final else None + + +class EventLogProvider: + """Built-in durability via event log replay. + + Records LLM stream results and tool call results during fresh + execution. On re-entry with a checkpoint, replays cached results + instead of re-executing. + + Usage:: + + provider = EventLogProvider(checkpoint) + async for msg in agent.run(model, messages, durability=provider): + ... + new_checkpoint = provider.checkpoint() + """ + + def __init__(self, cp: checkpoint_.Checkpoint | None = None) -> None: + self._checkpoint = cp or checkpoint_.Checkpoint() + self._step_cursor: int = 0 + self._tool_cache: dict[str, checkpoint_.ToolEvent] = { + t.tool_call_id: t for t in self._checkpoint.tools + } + self._hook_cache: dict[str, dict[str, Any]] = { + h.label: h.resolution for h in self._checkpoint.hooks + } + + # New recordings + self._steps: list[checkpoint_.StepEvent] = [] + self._tools: list[checkpoint_.ToolEvent] = [] + self._hooks: list[checkpoint_.HookEvent] = [] + + # ── Stream ──────────────────────────────────────────────── + + async def execute_stream( + self, + fn: Callable[[], Awaitable[StreamResultLike]], + ) -> StreamResultLike: + idx = self._step_cursor + self._step_cursor += 1 + + # Replay from checkpoint. + if idx < len(self._checkpoint.steps): + cached = self._checkpoint.steps[idx] + logger.info("Replaying stream step %d from checkpoint", idx) + return _ReplayStreamResult(cached.message) + + # Fresh execution — wrap to record. + return _RecordingStreamResult(await fn(), index=idx, steps=self._steps) + + # ── Tool ────────────────────────────────────────────────── + + async def execute_tool( + self, + fn: Callable[[], Awaitable[messages_.ToolResultPart]], + *, + tool_call_id: str, + tool_name: str, + ) -> messages_.ToolResultPart: + # Replay from checkpoint. + cached = self._tool_cache.get(tool_call_id) + if cached is not None: + logger.info( + "Replaying tool %s (call_id=%s) from checkpoint", + tool_name, + tool_call_id, + ) + return messages_.ToolResultPart( + tool_call_id=tool_call_id, + tool_name=tool_name, + result=cached.result, + is_error=cached.status == "error", + ) + + # Fresh execution. + result = await fn() + self._tools.append( + checkpoint_.ToolEvent( + tool_call_id=tool_call_id, + tool_name=tool_name, + result=result.result, + status="error" if result.is_error else "result", + ) + ) + return result + + # ── Hook ────────────────────────────────────────────────── + + def get_hook_resolution(self, label: str) -> dict[str, Any] | None: + cached = self._hook_cache.get(label) + if cached is not None: + logger.info("Resolving hook '%s' from checkpoint", label) + return cached + + def record_hook(self, label: str, resolution: dict[str, Any]) -> None: + self._hooks.append(checkpoint_.HookEvent(label=label, resolution=resolution)) + + # ── Checkpoint ──────────────────────────────────────────── + + def checkpoint(self) -> checkpoint_.Checkpoint: + """Build a full Checkpoint merging prior state + new recordings.""" + return checkpoint_.Checkpoint( + steps=list(self._checkpoint.steps) + self._steps, + tools=list(self._checkpoint.tools) + self._tools, + hooks=list(self._checkpoint.hooks) + self._hooks, + ) diff --git a/src/vercel_ai_sdk/agents/hooks.py b/src/vercel_ai_sdk/agents/hooks.py index 948539bb..0ab850e8 100644 --- a/src/vercel_ai_sdk/agents/hooks.py +++ b/src/vercel_ai_sdk/agents/hooks.py @@ -1,245 +1,268 @@ +"""Hooks: suspension points that require external input to continue. + +Usage inside an agent loop:: + + result = await hook("approve_delete", payload=ToolApproval, metadata={"tool": "rm"}) + if result.granted: + ... + +Resolution from outside the loop:: + + resolve_hook("approve_delete", {"granted": True}) + +Cancellation:: + + await cancel_hook("approve_delete", reason="denied") + +Behavior depends on ``interrupt_loop``: + +interrupt_loop=False (default, long-running): the await blocks until +resolve_hook() is called from outside (e.g. websocket handler, API endpoint). + +interrupt_loop=True (serverless): if no resolution is available, the +hook's future is cancelled. The branch receives CancelledError and dies +cleanly. On re-entry, call resolve_hook() before agent.run() to +pre-register the resolution, then pass checkpoint= to replay. +""" + from __future__ import annotations import asyncio -from typing import TYPE_CHECKING, Any, ClassVar +from typing import Any import pydantic +from .. import _durability as _dctx from ..types import messages as messages_ - -if TYPE_CHECKING: - from . import runtime as runtime_ - +from . import runtime as runtime_ # --------------------------------------------------------------------------- # Module-level hook registries # # _live_hooks: -# Populated by Hook.create() when a hook suspends inside a running graph. +# Populated by hook() when it suspends inside a running agent. # Maps hook label -> (future, metadata dict, Runtime). -# Consumed by Hook.resolve() / Hook.cancel() to unblock the awaiting +# Consumed by resolve_hook() / cancel_hook() to unblock the awaiting # coroutine. Entries are removed when the hook resolves, cancels, or # the run completes. # # _pending_resolutions: -# Populated by Hook.resolve() when no live hook exists yet (serverless -# re-entry: the user calls resolve() *before* ai.run() replays the graph). -# Maps hook label -> validated resolution dict. -# Consumed by Hook.create() at the start of graph execution — if a -# pre-registered resolution exists for the label, the hook returns -# immediately without suspending. Entries are removed on consumption. +# Populated by resolve_hook() when no live hook exists yet (serverless +# re-entry: the user calls resolve_hook() *before* agent.run() replays). +# Maps hook label -> (payload type, validated resolution dict). +# Consumed by hook() at the start of execution — if a pre-registered +# resolution exists for the label, the hook returns immediately without +# suspending. Entries are removed on consumption. # --------------------------------------------------------------------------- _live_hooks: dict[ - str, tuple[asyncio.Future[Any], dict[str, Any], runtime_.Runtime] + str, tuple[asyncio.Future[dict[str, Any]], dict[str, Any], runtime_.Runtime] ] = {} _pending_resolutions: dict[str, dict[str, Any]] = {} -# label -> validated resolution dict -def _cleanup_run(labels: set[str]) -> None: +def cleanup_run(labels: set[str]) -> None: """Remove all registry entries associated with a finished run.""" for label in labels: _live_hooks.pop(label, None) _pending_resolutions.pop(label, None) -class Hook[T: pydantic.BaseModel]: - """Hook: a suspension point that requires external input to continue. - - Usage in graph code: - - approval = await ToolApproval.create("approve_delete", metadata={...}) - if approval.granted: - ... - - Resolution from outside the graph: - - ToolApproval.resolve("approve_delete", {"granted": True, ...}) - - Behavior depends on the ``cancels_future`` class variable: - - cancels_future=False (default, long-running): the await blocks until - Hook.resolve() is called from outside the graph (e.g., websocket - handler, API endpoint). - - cancels_future=True (serverless): if no resolution is available, the - hook's future is cancelled by run(). The branch receives CancelledError - and dies cleanly. On re-entry, call Hook.resolve() before ai.run() to - pre-register the resolution, then pass checkpoint= to replay. +async def hook[T: pydantic.BaseModel]( + label: str, + *, + payload: type[T], + metadata: dict[str, Any] | None = None, + interrupt_loop: bool = False, +) -> T: + """Create a hook suspension point and await its resolution. + + Args: + label: Unique identifier for this hook instance. + payload: Pydantic model class — the resolution data must validate + against this type. The return value is a validated instance. + metadata: Arbitrary metadata surfaced in the pending signal message + and checkpoint. Useful for UI rendering (e.g. which tool needs + approval, what arguments it received). + interrupt_loop: When ``True`` (serverless mode), the hook's future + is cancelled if no resolution is available, causing + ``CancelledError`` in the awaiting coroutine. When ``False`` + (long-running mode), the future is held until resolved + externally. """ - - _schema: ClassVar[type[pydantic.BaseModel]] - hook_type: ClassVar[str] - cancels_future: ClassVar[bool] = False - - @classmethod - async def create(cls, label: str, metadata: dict[str, Any] | None = None) -> T: - """Create a hook and await its resolution. - - The hook is submitted to the LoopExecutor's step queue. run() will - either: - - Resolve immediately (if a resolution is available from checkpoint - or pre-registered via Hook.resolve()) - - Cancel the future (cancels_future=True, serverless mode) - - Hold the future (cancels_future=False, long-running mode) - """ - from . import runtime as rt_mod - - rt = rt_mod._runtime.get(None) - if rt is None: - raise ValueError("No Runtime context - must be called within ai.run()") - - # Check pre-registered resolutions (serverless re-entry path) - pre_registered = _pending_resolutions.pop(label, None) - if pre_registered is not None: - rt.log.record_hook(label, pre_registered) - return cls._schema(**pre_registered) # type: ignore[return-value] - - # Check checkpoint for a previously resolved value - resolution = rt.log.get_hook_resolution(label) - if resolution is not None: - rt.log.record_hook(label, resolution) - return cls._schema(**resolution) # type: ignore[return-value] - - # Submit to executor queue — run() decides what to do - future: asyncio.Future[dict[str, Any]] = asyncio.Future() - suspension = rt_mod.HookSuspension( - label=label, - hook_type=cls.hook_type, - metadata=metadata or {}, - future=future, - cancels_future=cls.cancels_future, + rt = runtime_.get_runtime() + hook_metadata = metadata or {} + + provider = _dctx.get_provider() + + # Path 1: pre-registered resolution (serverless re-entry). + pre_registered = _pending_resolutions.pop(label, None) + if pre_registered is not None: + if provider is not None: + provider.record_hook(label, pre_registered) + return payload(**pre_registered) + + # Path 2: cached resolution from checkpoint (durability replay). + if provider is not None: + cached = provider.get_hook_resolution(label) + if cached is not None: + provider.record_hook(label, cached) + return payload(**cached) + + # Path 3: no resolution available — suspend. + future: asyncio.Future[dict[str, Any]] = asyncio.Future() + + _live_hooks[label] = (future, hook_metadata, rt) + rt.track_hook_label(label) + + # Emit pending signal message. + await rt.put_message( + messages_.Message( + role="signal", + parts=[ + messages_.HookPart( + hook_id=label, + hook_type=payload.__name__, + status="pending", + metadata=hook_metadata, + ) + ], ) - await rt.executor.put_hook(suspension) + ) - # Register in module-level registry for external resolution - hook_metadata = metadata or {} - _live_hooks[label] = (future, hook_metadata, rt) - rt.executor.track_hook_label(label) + if interrupt_loop: + # Yield control so the consumer can see the pending message, + # then cancel — the caller catches CancelledError. + await asyncio.sleep(0) + if not future.done(): + future.cancel() + + # Await resolution — may be resolved externally or cancelled. + resolution = await future + + # Clean up live registry. + _live_hooks.pop(label, None) + + # Record for checkpoint. + if provider is not None: + provider.record_hook(label, resolution) + + # Emit resolved signal message. + await rt.put_message( + messages_.Message( + role="signal", + parts=[ + messages_.HookPart( + hook_id=label, + hook_type=payload.__name__, + status="resolved", + metadata=hook_metadata, + resolution=resolution, + ) + ], + ) + ) - # Await resolution — may be resolved immediately by run(), - # cancelled by run() (serverless), or resolved later by - # Hook.resolve() (long-running). - resolution = await future + return payload(**resolution) - # Clean up - _live_hooks.pop(label, None) - # Record for checkpoint - rt.log.record_hook(label, resolution) - - # Emit resolved message - await rt.executor.put_message( - messages_.Message( - role="assistant", - parts=[ - messages_.HookPart( - hook_id=label, - hook_type=cls.hook_type, - status="resolved", - metadata=hook_metadata, - resolution=resolution, - ) - ], - ) - ) +def resolve_hook( + label: str, + data: pydantic.BaseModel | dict[str, Any], + *, + payload: type[pydantic.BaseModel] | None = None, +) -> None: + """Resolve a hook by label. - return cls._schema(**resolution) # type: ignore[return-value] + Works in two modes: - @classmethod - def resolve(cls, label: str, data: T | dict[str, Any]) -> None: - """Resolve a hook by label. + 1. **Live hook exists** (long-running): validates data (if ``payload`` + type is provided), resolves the future immediately, unblocking the + awaiting coroutine. - Works in two modes: + 2. **No live hook yet** (serverless re-entry): stashes the resolution + in the pre-registration registry. When ``hook()`` executes during + replay, it finds the pre-registered value and returns without + suspending. - 1. Live hook exists (long-running): validates data, resolves the - future immediately, unblocking the awaiting coroutine. - - 2. No live hook yet (serverless re-entry): validates data and - stashes it in the pre-registration registry. When ai.run() - replays the graph and Hook.create() executes, it finds the - pre-registered resolution and returns without suspending. - """ - # Validate and normalize to dict - if isinstance(data, dict): - validated = cls._schema(**data) + Args: + label: The hook label to resolve. + data: Resolution data — a dict or pydantic model instance. + payload: Optional pydantic model class for validation. When + omitted and *data* is a model instance, its type is used. + """ + # Normalize to dict. + if isinstance(data, pydantic.BaseModel): + resolution = data.model_dump() + elif isinstance(data, dict): + if payload is not None: + # Validate against the payload type. + validated = payload(**data) resolution = validated.model_dump() else: - if not isinstance(data, cls._schema): - raise TypeError( - f"Expected {cls._schema.__name__} or dict, " - f"got {type(data).__name__}" - ) - resolution = data.model_dump() - - # Path 1: live hook — resolve the future directly - if label in _live_hooks: - future, _, _rt = _live_hooks[label] - future.set_result(resolution) - return - - # Path 2: no live hook — pre-register for later consumption - _pending_resolutions[label] = resolution - - @classmethod - async def cancel(cls, label: str, reason: str | None = None) -> None: - """Cancel a pending hook. - - Only works for live hooks (long-running mode). Raises if the - hook is not currently pending. - """ - if label not in _live_hooks: - raise ValueError(f"No pending hook with label: {label}") - - future, hook_metadata, rt = _live_hooks.pop(label) - future.cancel(reason) - - await rt.executor.put_message( - messages_.Message( - role="assistant", - parts=[ - messages_.HookPart( - hook_id=label, - hook_type=cls.hook_type, - status="cancelled", - metadata=hook_metadata, - ) - ], - ) - ) + resolution = data + else: + raise TypeError(f"Expected dict or pydantic model, got {type(data).__name__}") + + # Path 1: live hook — resolve the future directly. + if label in _live_hooks: + future, _, _rt = _live_hooks[label] + future.set_result(resolution) + return + # Path 2: no live hook — pre-register for later consumption. + _pending_resolutions[label] = resolution -def hook[T: pydantic.BaseModel](cls: type[T]) -> type[Hook[T]]: - """Decorator to create a Hook type from a pydantic model. - The pydantic model defines the schema for the hook's resolution payload. +async def cancel_hook(label: str, *, reason: str | None = None) -> None: + """Cancel a pending hook. + + Only works for live hooks (long-running mode). Raises ValueError + if the hook is not currently pending. """ - hook_impl = type( - cls.__name__, - (Hook,), - { - "_schema": cls, - "hook_type": cls.__name__, - "cancels_future": cls.__dict__.get("cancels_future", False), - "__doc__": cls.__doc__, - }, + if label not in _live_hooks: + raise ValueError(f"No pending hook with label: {label!r}") + + future, hook_metadata, rt = _live_hooks.pop(label) + future.cancel(reason) + + # Emit cancelled signal message. + await rt.put_message( + messages_.Message( + role="signal", + parts=[ + messages_.HookPart( + hook_id=label, + hook_type="", # not available at cancel site + status="cancelled", + metadata=hook_metadata, + ) + ], + ) ) - return hook_impl + +# ── Built-in hook payloads ──────────────────────────────────────── -@hook class ToolApproval(pydantic.BaseModel): - """Prewired hook for tool call approval. + """Payload schema for tool-approval hooks. - Used by the AI SDK UI adapter to bridge the protocol's - tool-approval-request / approval-responded flow to the - hook system. - """ + Usage inside a loop:: - cancels_future: ClassVar[bool] = True + approval = await hook( + f"approve_{tc.tool_call_id}", + payload=ToolApproval, + metadata={"tool_name": tc.tool_name}, + interrupt_loop=True, + ) + if approval.granted: + ... + """ granted: bool reason: str | None = None + + +TOOL_APPROVAL_HOOK_TYPE = ToolApproval.__name__ diff --git a/src/vercel_ai_sdk/agents/mcp/__init__.py b/src/vercel_ai_sdk/agents/mcp/__init__.py index c1202f63..873e7008 100644 --- a/src/vercel_ai_sdk/agents/mcp/__init__.py +++ b/src/vercel_ai_sdk/agents/mcp/__init__.py @@ -1,6 +1,7 @@ -from .client import get_http_tools, get_stdio_tools +from .client import close_connections, get_http_tools, get_stdio_tools __all__ = [ "get_stdio_tools", "get_http_tools", + "close_connections", ] diff --git a/src/vercel_ai_sdk/agents/mcp/client.py b/src/vercel_ai_sdk/agents/mcp/client.py index 17d7bb3a..c42f0d9e 100644 --- a/src/vercel_ai_sdk/agents/mcp/client.py +++ b/src/vercel_ai_sdk/agents/mcp/client.py @@ -14,7 +14,8 @@ import mcp.client.streamable_http import mcp.types -from .. import context, tools +from ... import types +from ..agent import Tool as Tool_ __all__ = [ "get_stdio_tools", @@ -31,8 +32,7 @@ class _Connection: exit_stack: contextlib.AsyncExitStack -# Connection pool stored in contextvar, scoped to execute() -# The pool is set by execute() and cleaned up when execute() finishes +# Connection pool stored in contextvar, scoped to Agent.run() _pool: contextvars.ContextVar[dict[str, _Connection] | None] = contextvars.ContextVar( "mcp_connections", default=None ) @@ -49,7 +49,7 @@ async def _get_or_create_connection( if pool is None: raise RuntimeError( - "MCP tools must be used inside ai.execute(). " + "MCP tools must be used inside agent.run(). " "The connection pool is not initialized." ) @@ -57,17 +57,12 @@ async def _get_or_create_connection( if key in pool: return pool[key].client - # Use AsyncExitStack for clean resource management exit_stack = contextlib.AsyncExitStack() try: - # Enter the transport context streams = await exit_stack.enter_async_context(transport_factory()) - - # Handle both (read, write) and (read, write, callback) returns read_stream, write_stream = streams[0], streams[1] - # Create and initialize the client session client = mcp.client.session.ClientSession( read_stream=read_stream, write_stream=write_stream, @@ -79,7 +74,6 @@ async def _get_or_create_connection( return client except BaseException: - # Clean up on any error during setup await exit_stack.aclose() raise @@ -103,7 +97,6 @@ async def call_tool(**kwargs: Any) -> Any: f"MCP tool call timed out after 30 seconds: {tool_name}" ) from e - # Handle error responses if result.isError: error_text = " ".join( part.text @@ -112,15 +105,12 @@ async def call_tool(**kwargs: Any) -> Any: ) raise RuntimeError(f"MCP tool error: {error_text or 'Unknown error'}") - # Prefer structured content if available if result.structuredContent is not None: return result.structuredContent - # Fall back to parsing content for part in result.content: if isinstance(part, mcp.types.TextContent): text = part.text - # Try to parse JSON, otherwise return raw text if text.startswith(("{", "[")): try: return json.loads(text) @@ -139,12 +129,11 @@ async def get_stdio_tools( env: dict[str, str] | None = None, cwd: str | None = None, tool_prefix: str | None = None, -) -> list[tools.Tool[..., Any]]: - """ - Get tools from an MCP server running as a subprocess. +) -> list[Tool_[..., Any]]: + """Get tools from an MCP server running as a subprocess. Connection is managed automatically - created on first use, cleaned up - when execute() finishes. + when the agent run finishes. Args: command: The command to run (e.g., "npx", "python"). @@ -156,7 +145,8 @@ async def get_stdio_tools( Returns: List of Tool objects that can be passed to an agent or custom loop. - Example: + Example:: + tools = await ai.mcp.get_stdio_tools( "npx", "-y", "@anthropic/mcp-server-filesystem", "/tmp" ) @@ -187,12 +177,11 @@ async def get_http_tools( *, headers: dict[str, str] | None = None, tool_prefix: str | None = None, -) -> list[tools.Tool[..., Any]]: - """ - Get tools from an MCP server over HTTP (Streamable HTTP transport). +) -> list[Tool_[..., Any]]: + """Get tools from an MCP server over HTTP (Streamable HTTP transport). Connection is managed automatically - created on first use, cleaned up - when execute() finishes. + when the agent run finishes. Args: url: The URL of the MCP server endpoint. @@ -202,7 +191,8 @@ async def get_http_tools( Returns: List of Tool objects that can be passed to an agent or custom loop. - Example: + Example:: + tools = await ai.mcp.get_http_tools( "http://localhost:3000/mcp", headers={"Authorization": "Bearer xxx"} @@ -230,53 +220,30 @@ def _mcp_tool_to_native( connection_key: str, transport_factory: Callable[[], contextlib.AbstractAsyncContextManager[Any]], tool_prefix: str | None, -) -> tools.Tool[..., Any]: +) -> Tool_[..., Any]: """Convert an MCP tool to a native Tool.""" name = mcp_tool.name if tool_prefix: name = f"{tool_prefix}_{name}" - schema = tools.ToolSchema( + schema = types.ToolSchema( name=name, description=mcp_tool.description or "", param_schema=mcp_tool.inputSchema, return_type=Any, ) - # Determine source provenance from connection key - if connection_key.startswith("http:"): - source = context.ToolSource( - kind="mcp_http", - uri=connection_key.removeprefix("http:"), - ) - elif connection_key.startswith("stdio:"): - source = context.ToolSource( - kind="mcp_stdio", - server_command=connection_key.removeprefix("stdio:"), - ) - else: - source = context.ToolSource(kind="mcp") - - t = tools.Tool( + return Tool_( fn=_make_tool_fn(connection_key, mcp_tool.name, transport_factory), schema=schema, - source=source, ) - # Register on active Context if available, else fall back to global - ctx = context._context.get(None) - if ctx is not None: - ctx.register_tool(t) - tools._tool_registry[name] = t - return t - async def close_connections() -> None: - """ - Close all MCP connections in the current context. + """Close all MCP connections in the current context. - This is called automatically by execute(), but can be called - manually for explicit cleanup. + Called automatically at the end of an agent run, but can also be + called manually for explicit cleanup. """ pool = _pool.get() if pool is None: @@ -286,7 +253,6 @@ async def close_connections() -> None: if not pool: return - # Use TaskGroup for concurrent cleanup async with asyncio.TaskGroup() as tg: for conn in pool.values(): tg.create_task(_close_connection_safely(conn)) diff --git a/src/vercel_ai_sdk/agents/runtime.py b/src/vercel_ai_sdk/agents/runtime.py index 19098aa7..45e69b77 100644 --- a/src/vercel_ai_sdk/agents/runtime.py +++ b/src/vercel_ai_sdk/agents/runtime.py @@ -1,150 +1,17 @@ +"""Runtime: message sink that connects producer coroutines to the consumer.""" + from __future__ import annotations import asyncio import contextvars -import dataclasses -import json -import logging -from collections.abc import AsyncGenerator, Awaitable, Callable, Coroutine -from typing import Any, get_type_hints - -import pydantic +from collections.abc import AsyncGenerator, AsyncIterable, Awaitable +from .. import types from ..telemetry import events as telemetry -from ..types import messages as messages_ -from . import checkpoint as checkpoint_ -from . import context as context_ -from . import hooks, mcp, streams, tools - -logger = logging.getLogger(__name__) - - -# ── EventLog ────────────────────────────────────────────────────── -# -# Pure bookkeeping: replay from checkpoint + record new events. -# No asyncio, no queues — just data in, data out. -# - - -class EventLog: - """Replay/record layer backed by a Checkpoint. - - Holds replay cursors (read pointer into the checkpoint) and - recording lists (new events produced during this run). - Completely synchronous — no queues, no async. - """ - - def __init__(self, checkpoint: checkpoint_.Checkpoint | None = None) -> None: - self._checkpoint = checkpoint or checkpoint_.Checkpoint() - - # Replay cursors - self._step_index: int = 0 - self._tool_replay: dict[str, checkpoint_.ToolEvent] = { - t.tool_call_id: t for t in self._checkpoint.tools - } - self._hook_replay: dict[str, dict[str, Any]] = { - h.label: h.resolution for h in self._checkpoint.hooks - } - - # Recording lists (new events from this run) - self._step_log: list[checkpoint_.StepEvent] = [] - self._tool_log: list[checkpoint_.ToolEvent] = [] - self._hook_log: list[checkpoint_.HookEvent] = [] - - # ── Steps ───────────────────────────────────────────────── - - @property - def step_index(self) -> int: - return self._step_index - - def try_replay_step(self) -> streams.StreamResult | None: - if self._step_index < len(self._checkpoint.steps): - event = self._checkpoint.steps[self._step_index] - self._step_index += 1 - logger.info("Replaying step %d from checkpoint", event.index) - return event.to_stream_result() - return None - - def record_step(self, result: streams.StreamResult) -> None: - event = checkpoint_.StepEvent( - index=self._step_index, - messages=list(result.messages), - ) - self._step_log.append(event) - self._step_index += 1 - - # ── Tools ───────────────────────────────────────────────── - - def try_replay_tool(self, tool_call_id: str) -> checkpoint_.ToolEvent | None: - event = self._tool_replay.get(tool_call_id) - if event is not None: - logger.info( - "Replaying tool %s (call_id=%s) from checkpoint", - event.tool_call_id, - tool_call_id, - ) - return event - - def record_tool( - self, tool_call_id: str, result: Any, *, status: str = "result" - ) -> None: - self._tool_log.append( - checkpoint_.ToolEvent( - tool_call_id=tool_call_id, result=result, status=status - ) - ) - - # ── Hooks ───────────────────────────────────────────────── - - def get_hook_resolution(self, label: str) -> dict[str, Any] | None: - resolution = self._hook_replay.get(label) - if resolution is not None: - logger.info("Resolving hook '%s' from checkpoint", label) - return resolution - - def record_hook(self, label: str, resolution: dict[str, Any]) -> None: - self._hook_log.append(checkpoint_.HookEvent(label=label, resolution=resolution)) - - # ── Snapshot ────────────────────────────────────────────── - - def checkpoint( - self, pending_hooks: list[checkpoint_.PendingHookInfo] | None = None - ) -> checkpoint_.Checkpoint: - """Build a full Checkpoint merging prior state + new recordings.""" - return checkpoint_.Checkpoint( - steps=list(self._checkpoint.steps) + self._step_log, - tools=list(self._checkpoint.tools) + self._tool_log, - hooks=list(self._checkpoint.hooks) + self._hook_log, - pending_hooks=pending_hooks or [], - ) - - -# ── LoopExecutor ───────────────────────────────────────────────── -# -# Async coordination: queues that let graph code (streams, hooks, -# tools) talk to the driver loop. Pure mailbox — no replay, no -# checkpoint awareness. -# - -@dataclasses.dataclass -class HookSuspension: - """Submitted to the step queue when a hook needs resolution.""" - label: str - hook_type: str - metadata: dict[str, Any] - future: asyncio.Future[Any] - cancels_future: bool = False - - -class LoopExecutor: - """Async coordination layer between graph code and the driver loop. - - Graph code (``@stream`` decorators, hooks, tool execution) submits - work via the producer methods. The driver loop consumes via - ``next()`` and ``drain_messages()``. - """ +class Runtime: + """Central message queue. Producers put messages, run() yields them.""" class _Sentinel: pass @@ -152,453 +19,93 @@ class _Sentinel: _SENTINEL = _Sentinel() def __init__(self) -> None: - self._step_queue: asyncio.Queue[ - tuple[streams.Stream, asyncio.Future[streams.StreamResult]] - | HookSuspension - | LoopExecutor._Sentinel - ] = asyncio.Queue() - - self._message_queue: asyncio.Queue[messages_.Message] = asyncio.Queue() - - # Pending hooks (unresolved during this run) - self._pending_hooks: dict[str, HookSuspension] = {} - - # Track hook labels registered in this run for cleanup + self._message_queue: asyncio.Queue[types.Message | Runtime._Sentinel] = ( + asyncio.Queue() + ) self._hook_labels: set[str] = set() - # ── Producers (called by graph code) ────────────────────── - - async def put_step( - self, step_fn: streams.Stream, future: asyncio.Future[streams.StreamResult] - ) -> None: - await self._step_queue.put((step_fn, future)) - - async def put_hook(self, suspension: HookSuspension) -> None: - await self._step_queue.put(suspension) - - async def put_message(self, message: messages_.Message) -> None: + async def put_message(self, message: types.Message) -> None: await self._message_queue.put(message) - async def done(self) -> None: - await self._step_queue.put(self._SENTINEL) - - # ── Consumer (called by driver loop) ────────────────────── - - async def next( - self, timeout: float = 0.1 - ) -> ( - tuple[streams.Stream, asyncio.Future[streams.StreamResult]] - | HookSuspension - | None - ): - """Pull the next item from the step queue. - - Returns ``None`` on timeout (no item available). - Returns the sentinel's semantic equivalent by raising StopIteration - when the graph signals completion. - """ - try: - item = await asyncio.wait_for(self._step_queue.get(), timeout=timeout) - except TimeoutError: - return None - - if isinstance(item, LoopExecutor._Sentinel): - raise _LoopDone - return item - - def drain_messages(self) -> list[messages_.Message]: - msgs: list[messages_.Message] = [] - while not self._message_queue.empty(): - try: - msgs.append(self._message_queue.get_nowait()) - except asyncio.QueueEmpty: - break - return msgs - - # ── Hook label tracking ─────────────────────────────────── + async def signal_done(self) -> None: + await self._message_queue.put(self._SENTINEL) def track_hook_label(self, label: str) -> None: + """Register a hook label for cleanup when the run ends.""" self._hook_labels.add(label) - def pending_hook_infos(self) -> list[checkpoint_.PendingHookInfo]: - return [ - checkpoint_.PendingHookInfo( - label=sus.label, - hook_type=sus.hook_type, - metadata=sus.metadata, - ) - for sus in self._pending_hooks.values() - ] - - -class _LoopDone(Exception): - """Internal signal: the loop function has finished.""" + def cleanup_hooks(self) -> None: + """Remove all hook registry entries for this run.""" + from . import hooks as hooks_ - -# ── Runtime ─────────────────────────────────────────────────────── -# -# Thin composition of EventLog + LoopExecutor. -# The context var points here; graph code accesses rt.log and -# rt.executor directly. -# - - -class Runtime: - """Central coordinator — composes EventLog and LoopExecutor. - - Graph code accesses ``rt.log`` for replay/record and - ``rt.executor`` for async coordination. - """ - - def __init__(self, checkpoint: checkpoint_.Checkpoint | None = None) -> None: - self.log = EventLog(checkpoint) - self.executor = LoopExecutor() - - def checkpoint(self) -> checkpoint_.Checkpoint: - return self.log.checkpoint( - pending_hooks=self.executor.pending_hook_infos(), - ) + hooks_.cleanup_run(self._hook_labels) + self._hook_labels.clear() _runtime: contextvars.ContextVar[Runtime] = contextvars.ContextVar("runtime") -def get_checkpoint() -> checkpoint_.Checkpoint: - """Get the current checkpoint from the active Runtime.""" - return _runtime.get().checkpoint() +def get_runtime() -> Runtime: + """Return the active Runtime. Raises LookupError outside of run().""" + return _runtime.get() -def _find_runtime_param(fn: Callable[..., Any]) -> str | None: - """Find a parameter typed as Runtime, return its name or None.""" - try: - hints = get_type_hints(fn) - except Exception: - return None - for name, hint in hints.items(): - if hint is Runtime: - return name - return None - - -async def execute_tool( - tool_call: messages_.ToolPart, - message: messages_.Message | None = None, -) -> messages_.ToolPart: - """Execute a single tool call with replay support. - - Looks up the tool by name — first from the active Context (if any), - then from the global registry. Returns an updated (immutable) - ToolPart with the result filled in. Emits the updated message to - the LoopExecutor queue so the UI sees the transition from - status="pending" to status="result" (or "error"). - - If a checkpoint exists with a cached result for this tool_call_id, - returns the cached result without re-executing. - """ - rt = _runtime.get(None) - - # Replay: return updated part from cache - if rt: - cached = rt.log.try_replay_tool(tool_call.tool_call_id) - if cached is not None: - if cached.status == "error": - return tool_call.with_error(cached.result) - return tool_call.with_result(cached.result) - - telemetry.handle( - telemetry.ToolCallStartEvent( - tool_name=tool_call.tool_name, - tool_call_id=tool_call.tool_call_id, - args=tool_call.tool_args, - ) - ) - t0 = telemetry.time_ms() - - # Fresh execution — resolve from Context first, then global registry - tool: tools.Tool[..., Any] | None = None - ctx = context_._context.get(None) - if ctx is not None: - tool = ctx.get_tool(tool_call.tool_name) - if tool is None: - tool = tools.get_tool(tool_call.tool_name) - if tool is None: - raise ValueError(f"Tool not found in registry: {tool_call.tool_name}") - - error_str: str | None = None +async def _stop_when_done(runtime: Runtime, task: Awaitable[None]) -> None: try: - result = await tool.validate_and_call(tool_call.tool_args, rt) - updated = tool_call.with_result(result) - except (json.JSONDecodeError, pydantic.ValidationError) as exc: - result = f"{type(exc).__name__}: {exc}" - error_str = result - updated = tool_call.with_error(result) - - telemetry.handle( - telemetry.ToolCallFinishEvent( - tool_name=tool_call.tool_name, - tool_call_id=tool_call.tool_call_id, - result=result, - error=error_str, - duration_ms=telemetry.time_ms() - t0, - ) - ) - - # Record for checkpoint - if rt: - rt.log.record_tool(tool_call.tool_call_id, result, status=updated.status) - - # Emit updated message so UI sees status change - if rt and message: - await rt.executor.put_message(message.replace(updated)) - - return updated - + await task + finally: + await runtime.signal_done() -# ── RunResult ───────────────────────────────────────────────────── +async def run( + source: AsyncIterable[types.Message], +) -> AsyncGenerator[types.Message]: + """Run *source* and yield every message that gets put into the Runtime queue.""" + from .mcp import client as mcp_client -@dataclasses.dataclass -class HookInfo: - """Info about a pending (unresolved) hook, exposed on RunResult.""" + rt = Runtime() + token = _runtime.set(rt) - label: str - hook_type: str - metadata: dict[str, Any] + # MCP connection pool — scoped to this run. + mcp_pool: dict[str, mcp_client._Connection] = {} + mcp_token = mcp_client._pool.set(mcp_pool) + token_run_id = telemetry.start_run() + telemetry.handle(telemetry.RunStartEvent()) -class RunResult: - """Returned by run(). Async-iterate for messages, then check state. + run_error: BaseException | None = None + total_usage: types.Usage | None = None - Usage: - result = ai.run(my_graph, llm, query) - async for msg in result: - ... - result.checkpoint # Checkpoint with all completed work - result.pending_hooks # dict of unresolved hooks (empty if graph completed) - """ + async def _drain() -> None: + async for message in source: + await rt.put_message(message) - def __init__(self) -> None: - self._messages: AsyncGenerator[messages_.Message] | None = None - self._runtime: Runtime | None = None - - @property - def checkpoint(self) -> checkpoint_.Checkpoint: - if self._runtime is None: - return checkpoint_.Checkpoint() - return self._runtime.checkpoint() - - @property - def pending_hooks(self) -> dict[str, HookInfo]: - if self._runtime is None: - return {} - return { - label: HookInfo( - label=sus.label, - hook_type=sus.hook_type, - metadata=sus.metadata, - ) - for label, sus in self._runtime.executor._pending_hooks.items() - } - - async def __aiter__(self) -> AsyncGenerator[messages_.Message]: - if self._messages is not None: - async for msg in self._messages: - yield msg - - -# ── run() ───────────────────────────────────────────────────────── - - -async def _stop_when_done(executor: LoopExecutor, task: Awaitable[None]) -> None: try: - await task - finally: - await executor.done() - - -def run( - root: Callable[..., Coroutine[Any, Any, Any]], - *args: Any, - checkpoint: checkpoint_.Checkpoint | None = None, - context: context_.Context | None = None, -) -> RunResult: - """Main entry point. - - 1. Starts the root function as a background task - 2. Pulls steps and hook suspensions from the LoopExecutor queue - 3. Executes each step, yielding messages - 4. Resolves or suspends hooks depending on the hook's cancels_future - 5. Returns RunResult with .checkpoint and .pending_hooks - - Args: - root: The loop function to execute. - *args: Positional arguments forwarded to ``root``. - checkpoint: Checkpoint to resume from. - context: LLM prompt context (tools, system prompt, messages). - If ``None``, an empty Context is created automatically. - """ - result = RunResult() - - # Discard stale checkpoints: if the checkpoint has pending hooks but - # none of them have been resolved, this isn't a resume. - effective_checkpoint = checkpoint - if checkpoint and checkpoint.pending_hooks: - pending_labels = [ph.label for ph in checkpoint.pending_hooks] - has_resolution = any( - label in hooks._pending_resolutions for label in pending_labels - ) - if not has_resolution: - logger.info( - "Discarding stale checkpoint: pending hooks %s have no " - "matching resolutions", - pending_labels, - ) - effective_checkpoint = None - else: - logger.info( - "Resuming from checkpoint with %d pending hook(s): %s", - len(pending_labels), - pending_labels, - ) - - async def _generate() -> AsyncGenerator[messages_.Message]: - rt = Runtime(checkpoint=effective_checkpoint) - result._runtime = rt - token_runtime = _runtime.set(rt) - - ctx = context or context_.Context() - token_context = context_._context.set(ctx) - - token_run_id = telemetry.start_run() - - telemetry.handle(telemetry.RunStartEvent()) - - mcp_pool: dict[str, mcp.client._Connection] = {} - mcp_token = mcp.client._pool.set(mcp_pool) - - kwargs: dict[str, Any] = {} - if runtime_param := _find_runtime_param(root): - kwargs[runtime_param] = rt - - run_error: BaseException | None = None - total_usage: messages_.Usage | None = None - - try: - async with asyncio.TaskGroup() as tg: - _task: asyncio.Task[None] = tg.create_task( - _stop_when_done(rt.executor, root(*args, **kwargs)) - ) - - while True: - # Drain pending messages - for msg in rt.executor.drain_messages(): - yield msg - - # Pull next item from the graph executor - try: - item = await rt.executor.next() - except _LoopDone: - for msg in rt.executor.drain_messages(): - yield msg - break - - if item is None: - # Timeout — no item available, loop again - continue - - # ── Hook suspension ──────────────────────── - if isinstance(item, HookSuspension): - resolution = rt.log.get_hook_resolution(item.label) - if resolution is not None: - item.future.set_result(resolution) - rt.log.record_hook(item.label, resolution) - else: - rt.executor._pending_hooks[item.label] = item - if item.cancels_future: - item.future.cancel() - - yield messages_.Message( - role="assistant", - parts=[ - messages_.HookPart( - hook_id=item.label, - hook_type=item.hook_type, - status="pending", - metadata=item.metadata, - ) - ], - ) - - await asyncio.sleep(0) - for msg in rt.executor.drain_messages(): - yield msg - continue - - # ── Regular step ─────────────────────────── - step_fn, future = item - - telemetry.handle( - telemetry.StepStartEvent( - step_index=rt.log.step_index, - ) + async with asyncio.TaskGroup() as tg: + tg.create_task(_stop_when_done(rt, _drain())) + + while True: + item = await rt._message_queue.get() + if isinstance(item, Runtime._Sentinel): + return + # Track usage from done messages. + if item.is_done and item.usage is not None: + total_usage = ( + item.usage if total_usage is None else total_usage + item.usage ) + yield item - for tool_msg in rt.executor.drain_messages(): - yield tool_msg - - result_messages: list[messages_.Message] = [] + except BaseException as exc: + run_error = exc + raise - async for msg in step_fn(): - yield msg - result_messages.append(msg) - - for tool_msg in rt.executor.drain_messages(): - yield tool_msg + finally: + telemetry.handle(telemetry.RunFinishEvent(usage=total_usage, error=run_error)) + telemetry.end_run(token_run_id) - step_result = streams.StreamResult(messages=result_messages) - future.set_result(step_result) + rt.cleanup_hooks() - telemetry.handle( - telemetry.StepFinishEvent( - step_index=rt.log.step_index, - result=step_result, - ) - ) + await mcp_client.close_connections() + mcp_client._pool.reset(mcp_token) - # Accumulate usage for run-level telemetry - step_usage = step_result.usage - if step_usage is not None: - total_usage = ( - step_usage - if total_usage is None - else total_usage + step_usage - ) - - await asyncio.sleep(0) - for tool_msg in rt.executor.drain_messages(): - yield tool_msg - - except BaseException as exc: - run_error = exc - raise - - finally: - telemetry.handle( - telemetry.RunFinishEvent( - usage=total_usage, - error=run_error, - ) - ) - telemetry.end_run(token_run_id) - - hooks._cleanup_run(rt.executor._hook_labels) - - if mcp_token is not None: - await mcp.client.close_connections() - mcp.client._pool.reset(mcp_token) - - context_._context.reset(token_context) - _runtime.reset(token_runtime) - - result._messages = _generate() - return result + _runtime.reset(token) diff --git a/src/vercel_ai_sdk/agents/streams.py b/src/vercel_ai_sdk/agents/streams.py deleted file mode 100644 index fadf6747..00000000 --- a/src/vercel_ai_sdk/agents/streams.py +++ /dev/null @@ -1,105 +0,0 @@ -from __future__ import annotations - -import asyncio -import dataclasses -import functools -from collections.abc import AsyncGenerator, Awaitable, Callable -from typing import Any - -from ..types import messages as messages_ - - -@dataclasses.dataclass -class StreamResult: - messages: list[messages_.Message] = dataclasses.field(default_factory=list) - - @property - def last_message(self) -> messages_.Message | None: - return self.messages[-1] if self.messages else None - - @property - def tool_calls(self) -> list[messages_.ToolPart]: - """Get tool calls from the last message.""" - if self.last_message: - return self.last_message.tool_calls - return [] - - @property - def text(self) -> str: - if self.last_message: - return self.last_message.text - return "" - - @property - def output(self) -> Any: - """Parsed structured output from the last message, if available.""" - if self.last_message: - return self.last_message.output - return None - - @property - def usage(self) -> messages_.Usage | None: - """Token usage from the last (most recent) LLM call.""" - if self.last_message: - return self.last_message.usage - return None - - @property - def total_usage(self) -> messages_.Usage | None: - """Accumulated token usage across all LLM calls in this result. - - Sums usage from every message that carries it (i.e. assistant - messages produced by LLM calls). Returns ``None`` if no message - reported usage. - """ - total: messages_.Usage | None = None - for msg in self.messages: - if msg.usage is not None: - total = msg.usage if total is None else total + msg.usage - return total - - -Stream = Callable[[], AsyncGenerator[messages_.Message]] -# maybe it should have a name and an id inferred from LLM outputs - - -def stream[**P]( - fn: Callable[P, AsyncGenerator[messages_.Message]], -) -> Callable[P, Awaitable[StreamResult]]: - """Decorator to put an async generator into the LoopExecutor queue. - - The decorated function submits its work to the executor queue and - blocks until run() processes it, then returns the StreamResult. - - If a checkpoint exists with a cached result for this step index, - returns the cached result without submitting to the queue (replay). - """ - - from . import runtime as runtime_ - - @functools.wraps(fn) - async def wrapped(*args: Any, **kwargs: Any) -> StreamResult: - rt: runtime_.Runtime | None = runtime_._runtime.get(None) - if rt is None: - raise ValueError("No Runtime context - must be called within ai.run()") - - # Replay: return cached result if available - cached = rt.log.try_replay_step() - if cached is not None: - return cached - - # Fresh execution: submit to executor queue and wait - future: asyncio.Future[StreamResult] = asyncio.Future() - - async def stream_fn() -> AsyncGenerator[messages_.Message]: - async for msg in fn(*args, **kwargs): - yield msg - - await rt.executor.put_step(stream_fn, future) - result = await future - - # Record for checkpoint - rt.log.record_step(result) - return result - - return wrapped diff --git a/src/vercel_ai_sdk/agents/tools.py b/src/vercel_ai_sdk/agents/tools.py deleted file mode 100644 index 39a9aa28..00000000 --- a/src/vercel_ai_sdk/agents/tools.py +++ /dev/null @@ -1,136 +0,0 @@ -from __future__ import annotations - -import inspect -import json -from collections.abc import Awaitable, Callable -from typing import TYPE_CHECKING, Any, get_type_hints - -import pydantic - -from ..types.tools import ToolLike as ToolLike -from ..types.tools import ToolSchema as ToolSchema -from .context import ToolSource - -if TYPE_CHECKING: - from . import runtime as runtime_ - -# Module-level tool registry - populated at decoration time -_tool_registry: dict[str, Tool[..., Any]] = {} - - -def get_tool(name: str) -> Tool[..., Any] | None: - """Look up a tool by name from the global registry.""" - return _tool_registry.get(name) - - -def _is_runtime_type(hint: Any) -> bool: - """Check if a type hint is the Runtime class.""" - # Import here to avoid circular import at runtime - from .runtime import Runtime - - return hint is Runtime - - -class Tool[**P, R]: - def __init__( - self, - fn: Callable[P, Awaitable[R]], - schema: ToolSchema, - validator: type[pydantic.BaseModel] | None = None, - source: ToolSource | None = None, - ) -> None: - self._fn = fn - self._validator = validator - self.schema = schema - self.source = source - - async def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R: - return await self._fn(*args, **kwargs) - - async def validate_and_call( - self, json_str: str, runtime: runtime_.Runtime | None - ) -> R: - from .runtime import _find_runtime_param - - kwargs = json.loads(json_str) if json_str else {} - - if runtime and (rt_param := _find_runtime_param(self._fn)): - kwargs[rt_param] = runtime - - # validate llm-generated inputs (skipped for MCP tools) - if self._validator is not None: - self._validator.model_validate(kwargs) - return await self._fn(**kwargs) # type: ignore[call-arg] - - @property - def name(self) -> str: - return self.schema.name - - @property - def description(self) -> str: - return self.schema.description - - @property - def param_schema(self) -> dict[str, Any]: - return self.schema.param_schema - - -def tool[**P, R](fn: Callable[P, Awaitable[R]]) -> Tool[P, R]: - """Decorator to define a tool from an async function.""" - - # 1. build tool schema by parsing the function - sig = inspect.signature(fn) - hints = get_type_hints(fn) if hasattr(fn, "__annotations__") else {} - - fields: dict[str, Any] = {} - - for param_name, param in sig.parameters.items(): - param_type = hints.get(param_name, str) - - if _is_runtime_type(param_type): - continue - if param.default is inspect.Parameter.empty: - fields[param_name] = (param_type, ...) - else: - fields[param_name] = (param_type, param.default) - - validator = pydantic.create_model(f"{fn.__name__}_Args", **fields) - - # 2. instantiate the tool - - schema = ToolSchema( - name=fn.__name__, - description=inspect.getdoc(fn) or "", - param_schema=validator.model_json_schema(), - return_type=hints.get("return", None), - ) - - source = ToolSource( - kind="python", - module=getattr(fn, "__module__", None), - qualname=getattr(fn, "__qualname__", None), - ) - - t = Tool(fn=fn, schema=schema, validator=validator, source=source) - - # 3. register in global registry - _tool_registry[t.name] = t - return t - - -def _unresolvable_tool_fn(name: str) -> Callable[..., Awaitable[Any]]: - """Create a placeholder async function for schema-only tools. - - Used by ``Context.from_dict()`` when a tool's source cannot be - resolved to live code. - """ - - async def _placeholder(**kwargs: Any) -> Any: - raise RuntimeError( - f"Tool {name!r} was reconstructed from serialized context " - f"and has no executable implementation." - ) - - _placeholder.__name__ = name - _placeholder.__qualname__ = name - return _placeholder diff --git a/src/vercel_ai_sdk/models/__init__.py b/src/vercel_ai_sdk/models/__init__.py index 9e921afd..d6bc2d4e 100644 --- a/src/vercel_ai_sdk/models/__init__.py +++ b/src/vercel_ai_sdk/models/__init__.py @@ -13,18 +13,20 @@ msgs = [Message(role="user", parts=[TextPart(text="hello")])] # stream — auto-creates client from env vars - async for msg in models.stream(model, msgs): + s = await models.stream(model, msgs) + async for msg in s: print(msg.text_delta, end="") # buffer the whole response - result = await models.buffer(models.stream(model, msgs)) + result = await models.buffer(await models.stream(model, msgs)) print(result.text) # explicit client client = models.Client( base_url="https://custom.example.com/v3/ai", api_key="sk-...", ) - async for msg in models.stream(model, msgs, client=client): + s = await models.stream(model, msgs, client=client) + async for msg in s: ... """ @@ -115,6 +117,43 @@ def _auto_client(model: Model) -> Client: # --------------------------------------------------------------------------- +class StreamResult: + """Wrapper around a message stream. Async-iterable; collects the final result. + + Properties like ``.text`` and ``.tool_calls`` delegate to the final + ``Message`` snapshot and are available after iteration completes. + """ + + def __init__(self, gen: AsyncGenerator[messages_.Message]) -> None: + self._gen = gen + self._final: messages_.Message | None = None + + def __aiter__(self) -> AsyncGenerator[messages_.Message]: + return self._iterate() + + async def _iterate(self) -> AsyncGenerator[messages_.Message]: + async for msg in self._gen: + self._final = msg + yield msg + + @property + def text(self) -> str: + return self._final.text if self._final else "" + + @property + def tool_calls(self) -> list[messages_.ToolCallPart]: + return self._final.tool_calls if self._final else [] + + @property + def usage(self) -> messages_.Usage | None: + return self._final.usage if self._final else None + + @property + def output(self) -> Any: + """Parsed structured output from the final message, if available.""" + return self._final.output if self._final else None + + async def stream( model: Model, messages: list[messages_.Message], @@ -123,26 +162,45 @@ async def stream( output_type: type[pydantic.BaseModel] | None = None, client: Client | None = None, **kwargs: Any, -) -> AsyncGenerator[messages_.Message]: +) -> StreamResult: """Stream an LLM response. - Resolves the adapter function from ``model.adapter``, auto-creates a - :class:`Client` from env vars if none is provided, and yields - ``Message`` snapshots. + Returns a :class:`StreamResult` that is async-iterable and collects + the final ``Message``. After iteration, access ``.text``, + ``.tool_calls``, ``.usage``, etc. + + If a :class:`~vercel_ai_sdk.agents.durability.DurabilityProvider` is + active (set by ``Agent.run()``), the stream is routed through the + provider for recording or replay. """ - _ensure_adapters() - c = client or _auto_client(model) - adapter_fn = _stream_adapters.get(model.adapter) - if adapter_fn is None: - registered = ", ".join(sorted(_stream_adapters)) or "(none)" - raise KeyError( - f"No stream adapter registered for adapter={model.adapter!r}. " - f"Registered: {registered}" + # Lazy import to avoid circular dependency at module level. + from .._durability import get_provider + + provider = get_provider() + + async def _make_raw() -> StreamResult: + _ensure_adapters() + c = client or _auto_client(model) + adapter_fn = _stream_adapters.get(model.adapter) + if adapter_fn is None: + registered = ", ".join(sorted(_stream_adapters)) or "(none)" + raise KeyError( + f"No stream adapter registered for adapter={model.adapter!r}. " + f"Registered: {registered}" + ) + return StreamResult( + adapter_fn( + c, model, messages, tools=tools, output_type=output_type, **kwargs + ) ) - async for msg in adapter_fn( - c, model, messages, tools=tools, output_type=output_type, **kwargs - ): - yield msg + + if provider is not None: + # Provider returns a StreamResultLike — may be a replay or + # a recording wrapper. We return it typed as StreamResult; + # callers only use the shared protocol surface (.tool_calls, etc.). + return await provider.execute_stream(_make_raw) # type: ignore[return-value] + + return await _make_raw() async def generate( @@ -197,6 +255,7 @@ async def buffer(gen: AsyncGenerator[messages_.Message]) -> messages_.Message: "Model", "ModelCost", "StreamFn", + "StreamResult", "VideoParams", # Public API "buffer", diff --git a/src/vercel_ai_sdk/models/ai_gateway/stream.py b/src/vercel_ai_sdk/models/ai_gateway/stream.py index f50a8658..9ba0f73e 100644 --- a/src/vercel_ai_sdk/models/ai_gateway/stream.py +++ b/src/vercel_ai_sdk/models/ai_gateway/stream.py @@ -67,19 +67,15 @@ async def _messages_to_prompt( case "assistant": assistant_content: list[dict[str, Any]] = [] - tool_results: list[dict[str, Any]] = [] - for part in msg.parts: match part: case messages_.ReasoningPart(text=text): assistant_content.append( {"type": "reasoning", "text": text} ) - case messages_.TextPart(text=text): assistant_content.append({"type": "text", "text": text}) - - case messages_.ToolPart() as tp: + case messages_.ToolCallPart() as tp: tool_input: Any = ( json.loads(tp.tool_args) if tp.tool_args else {} ) @@ -91,32 +87,33 @@ async def _messages_to_prompt( "input": tool_input, } ) - if tp.status in ("result", "error"): - output = ( - { - "type": "error-text", - "value": ( - str(tp.result) - if tp.result is not None - else "" - ), - } - if tp.status == "error" - else { - "type": "json", - "value": tp.result, - } - ) - tool_results.append( - { - "type": "tool-result", - "toolCallId": tp.tool_call_id, - "toolName": tp.tool_name, - "output": output, - } - ) - result.append({"role": "assistant", "content": assistant_content}) + + case "tool": + tool_results: list[dict[str, Any]] = [] + for part in msg.parts: + if isinstance(part, messages_.ToolResultPart): + output = ( + { + "type": "error-text", + "value": ( + str(part.result) if part.result is not None else "" + ), + } + if part.is_error + else { + "type": "json", + "value": part.result, + } + ) + tool_results.append( + { + "type": "tool-result", + "toolCallId": part.tool_call_id, + "toolName": part.tool_name, + "output": output, + } + ) if tool_results: result.append({"role": "tool", "content": tool_results}) diff --git a/src/vercel_ai_sdk/models/anthropic/adapter.py b/src/vercel_ai_sdk/models/anthropic/adapter.py index 23c1f01b..b5d2e024 100644 --- a/src/vercel_ai_sdk/models/anthropic/adapter.py +++ b/src/vercel_ai_sdk/models/anthropic/adapter.py @@ -126,8 +126,6 @@ async def _messages_to_anthropic( ) case "assistant": content: list[dict[str, Any]] = [] - tool_results: list[dict[str, Any]] = [] - for part in msg.parts: match part: case messages_.ReasoningPart(text=text, signature=signature): @@ -141,7 +139,7 @@ async def _messages_to_anthropic( ) case messages_.TextPart(text=text): content.append({"type": "text", "text": text}) - case messages_.ToolPart(): + case messages_.ToolCallPart(): tool_input = ( json.loads(part.tool_args) if part.tool_args else {} ) @@ -153,20 +151,23 @@ async def _messages_to_anthropic( "input": tool_input, } ) - if part.status in ("result", "error"): - entry: dict[str, Any] = { - "type": "tool_result", - "tool_use_id": part.tool_call_id, - "content": str(part.result) - if part.result is not None - else "", - } - if part.status == "error": - entry["is_error"] = True - tool_results.append(entry) - if content: result.append({"role": "assistant", "content": content}) + + case "tool": + tool_results: list[dict[str, Any]] = [] + for part in msg.parts: + if isinstance(part, messages_.ToolResultPart): + entry: dict[str, Any] = { + "type": "tool_result", + "tool_use_id": part.tool_call_id, + "content": str(part.result) + if part.result is not None + else "", + } + if part.is_error: + entry["is_error"] = True + tool_results.append(entry) if tool_results: result.append({"role": "user", "content": tool_results}) diff --git a/src/vercel_ai_sdk/models/core/helpers/streaming.py b/src/vercel_ai_sdk/models/core/helpers/streaming.py index fb776295..1fa91441 100644 --- a/src/vercel_ai_sdk/models/core/helpers/streaming.py +++ b/src/vercel_ai_sdk/models/core/helpers/streaming.py @@ -212,11 +212,11 @@ def _build_message( ) ) - # Tool parts + # Tool call parts for tcid, (name, args) in self._tool_calls.items(): is_active = tcid in self._active_tool_ids parts.append( - messages_.ToolPart( + messages_.ToolCallPart( id=tcid, tool_call_id=tcid, tool_name=name, diff --git a/src/vercel_ai_sdk/models/openai/adapter.py b/src/vercel_ai_sdk/models/openai/adapter.py index c0d6261d..eb77b9f6 100644 --- a/src/vercel_ai_sdk/models/openai/adapter.py +++ b/src/vercel_ai_sdk/models/openai/adapter.py @@ -111,7 +111,6 @@ async def _messages_to_openai( content = "" reasoning = "" tool_calls: list[dict[str, Any]] = [] - tool_results: list[dict[str, Any]] = [] for part in msg.parts: match part: @@ -119,7 +118,7 @@ async def _messages_to_openai( reasoning += text case messages_.TextPart(text=text): content += text - case messages_.ToolPart(): + case messages_.ToolCallPart(): tool_calls.append( { "id": part.tool_call_id, @@ -130,16 +129,6 @@ async def _messages_to_openai( }, } ) - if part.status in ("result", "error"): - tool_results.append( - { - "role": "tool", - "tool_call_id": part.tool_call_id, - "content": str(part.result) - if part.result is not None - else "", - } - ) entry: dict[str, Any] = {"role": "assistant"} if content: @@ -149,7 +138,19 @@ async def _messages_to_openai( if tool_calls: entry["tool_calls"] = tool_calls result.append(entry) - result.extend(tool_results) + + case "tool": + for part in msg.parts: + if isinstance(part, messages_.ToolResultPart): + result.append( + { + "role": "tool", + "tool_call_id": part.tool_call_id, + "content": str(part.result) + if part.result is not None + else "", + } + ) case "system": content_text = "".join( diff --git a/src/vercel_ai_sdk/telemetry/events.py b/src/vercel_ai_sdk/telemetry/events.py index 3064ad9f..bf631523 100644 --- a/src/vercel_ai_sdk/telemetry/events.py +++ b/src/vercel_ai_sdk/telemetry/events.py @@ -29,7 +29,6 @@ def handle(self, event: ai.telemetry.TelemetryEvent) -> None: import uuid from typing import Any, Protocol, runtime_checkable -from ..agents import streams from ..types import messages as messages_ # ── Protocol ─────────────────────────────────────────────────────── @@ -66,10 +65,10 @@ class StepStartEvent(TelemetryEvent): @dataclasses.dataclass(frozen=True, slots=True) class StepFinishEvent(TelemetryEvent): - """Emitted when a ``@stream``-decorated step finishes.""" + """Emitted when an LLM stream step finishes.""" step_index: int - result: streams.StreamResult + message: messages_.Message @dataclasses.dataclass(frozen=True, slots=True) diff --git a/src/vercel_ai_sdk/telemetry/otel.py b/src/vercel_ai_sdk/telemetry/otel.py index d4f72a00..4e327b07 100644 --- a/src/vercel_ai_sdk/telemetry/otel.py +++ b/src/vercel_ai_sdk/telemetry/otel.py @@ -232,20 +232,21 @@ def _on_step_finish(self, event: StepFinishEvent) -> None: return span, _ctx = entry + msg = event.message attrs: dict[str, Any] = {} - usage = event.result.usage + usage = msg.usage if usage: attrs["gen_ai.usage.input_tokens"] = usage.input_tokens attrs["gen_ai.usage.output_tokens"] = usage.output_tokens - text = event.result.text + text = msg.text if text: attrs["ai.response.text"] = {"output": lambda: text} finish_reason = None - if event.result.last_message and not event.result.tool_calls: + if not msg.tool_calls: finish_reason = "stop" - elif event.result.tool_calls: + elif msg.tool_calls: finish_reason = "tool_calls" if finish_reason: attrs["ai.response.finishReason"] = finish_reason diff --git a/src/vercel_ai_sdk/types/__init__.py b/src/vercel_ai_sdk/types/__init__.py index 803f8b2a..49ecc00a 100644 --- a/src/vercel_ai_sdk/types/__init__.py +++ b/src/vercel_ai_sdk/types/__init__.py @@ -13,8 +13,9 @@ ReasoningPart, StructuredOutputPart, TextPart, + ToolCallPart, ToolDelta, - ToolPart, + ToolResultPart, Usage, generate_id, make_messages, @@ -30,9 +31,10 @@ "ReasoningPart", "StructuredOutputPart", "TextPart", + "ToolCallPart", "ToolDelta", - "ToolPart", "ToolLike", + "ToolResultPart", "ToolSchema", "Usage", "generate_id", diff --git a/src/vercel_ai_sdk/types/builders.py b/src/vercel_ai_sdk/types/builders.py index 0a4d9f62..7f48c54d 100644 --- a/src/vercel_ai_sdk/types/builders.py +++ b/src/vercel_ai_sdk/types/builders.py @@ -8,6 +8,8 @@ from __future__ import annotations +from typing import Any + from .messages import ( FilePart, HookPart, @@ -16,12 +18,14 @@ ReasoningPart, StructuredOutputPart, TextPart, - ToolPart, + ToolCallPart, + ToolResultPart, ) _PART_TYPES = ( TextPart, - ToolPart, + ToolCallPart, + ToolResultPart, ReasoningPart, HookPart, StructuredOutputPart, @@ -91,3 +95,30 @@ def thinking(text: str, *, signature: str | None = None) -> ReasoningPart: Useful for replaying conversation history that includes model reasoning. """ return ReasoningPart(text=text, signature=signature) + + +def tool_message(*parts: ToolResultPart) -> Message: + """Create a tool-result message from one or more :class:`ToolResultPart` objects. + + >>> ai.tool_message(ai.tool_result("tc-1", result=72, tool_name="weather")) + """ + return Message(role="tool", parts=list(parts)) + + +def tool_result( + tool_call_id: str, + *, + result: Any = None, + tool_name: str = "", + is_error: bool = False, +) -> ToolResultPart: + """Create a :class:`ToolResultPart`. + + >>> ai.tool_result("tc-1", result={"temp": 72}, tool_name="weather") + """ + return ToolResultPart( + tool_call_id=tool_call_id, + tool_name=tool_name, + result=result, + is_error=is_error, + ) diff --git a/src/vercel_ai_sdk/types/messages.py b/src/vercel_ai_sdk/types/messages.py index ef6279fa..d3c7450e 100644 --- a/src/vercel_ai_sdk/types/messages.py +++ b/src/vercel_ai_sdk/types/messages.py @@ -28,35 +28,41 @@ class TextPart(pydantic.BaseModel): delta: str | None = None # Current delta, None when not actively streaming -class ToolPart(pydantic.BaseModel): +class ToolCallPart(pydantic.BaseModel): + """A tool invocation requested by the LLM. + + Lives inside ``role="assistant"`` messages. The corresponding result + (if any) will appear as a :class:`ToolResultPart` in a separate + ``role="tool"`` message, linked by ``tool_call_id``. + """ + model_config = pydantic.ConfigDict(frozen=True) id: str = pydantic.Field(default_factory=generate_id) tool_call_id: str tool_name: str tool_args: str - status: Literal["pending", "result", "error"] = "pending" # Execution status - result: Any = None - type: Literal["tool"] = "tool" + type: Literal["tool_call"] = "tool_call" # Streaming state (for args streaming) state: PartState | None = None args_delta: str | None = None # Delta for tool_args - def with_result(self, result: Any) -> ToolPart: - """Return a copy with status='result' and the given result.""" - if self.status != "pending": - raise ValueError( - f"Tool call '{self.tool_call_id}' already has status '{self.status}'" - ) - return self.model_copy(update={"status": "result", "result": result}) - def with_error(self, message: str) -> ToolPart: - """Return a copy with status='error' and the error message.""" - if self.status != "pending": - raise ValueError( - f"Tool call '{self.tool_call_id}' already has status '{self.status}'" - ) - return self.model_copy(update={"status": "error", "result": message}) +class ToolResultPart(pydantic.BaseModel): + """The result of executing a tool call. + + Lives inside ``role="tool"`` messages. Back-references the + originating call via ``tool_call_id``. + """ + + model_config = pydantic.ConfigDict(frozen=True) + + id: str = pydantic.Field(default_factory=generate_id) + tool_call_id: str + tool_name: str + result: Any = None + is_error: bool = False + type: Literal["tool_result"] = "tool_result" class ReasoningPart(pydantic.BaseModel): @@ -202,7 +208,13 @@ def from_bytes( Part = Annotated[ - TextPart | ToolPart | ReasoningPart | HookPart | StructuredOutputPart | FilePart, + TextPart + | ToolCallPart + | ToolResultPart + | ReasoningPart + | HookPart + | StructuredOutputPart + | FilePart, pydantic.Field(discriminator="type"), ] @@ -270,7 +282,7 @@ class ToolDelta(pydantic.BaseModel): class Message(pydantic.BaseModel): model_config = pydantic.ConfigDict(frozen=True) - role: Literal["user", "assistant", "system"] + role: Literal["user", "assistant", "system", "tool", "signal"] parts: list[Part] id: str = pydantic.Field(default_factory=generate_id) label: str | None = None @@ -328,7 +340,7 @@ def is_done(self) -> bool: """Message is done when all parts are done (or have no streaming state).""" for part in self.parts: if ( - isinstance(part, (TextPart, ReasoningPart, ToolPart)) + isinstance(part, (TextPart, ReasoningPart, ToolCallPart)) and part.state == "streaming" ): return False @@ -355,7 +367,7 @@ def tool_deltas(self) -> list[ToolDelta]: """Get current tool deltas from parts.""" deltas = [] for part in self.parts: - if isinstance(part, ToolPart) and part.args_delta: + if isinstance(part, ToolCallPart) and part.args_delta: deltas.append( ToolDelta( tool_call_id=part.tool_call_id, @@ -403,9 +415,14 @@ def reasoning(self) -> str: return "" @property - def tool_calls(self) -> list[ToolPart]: - # TODO properly validate args? - return [part for part in self.parts if isinstance(part, ToolPart)] + def tool_calls(self) -> list[ToolCallPart]: + """All tool-call parts in this message.""" + return [part for part in self.parts if isinstance(part, ToolCallPart)] + + @property + def tool_results(self) -> list[ToolResultPart]: + """All tool-result parts in this message.""" + return [part for part in self.parts if isinstance(part, ToolResultPart)] def get_hook_part(self, hook_id: str | None = None) -> HookPart | None: """Find a HookPart by hook_id, or return the first HookPart if no id given.""" diff --git a/src/vercel_ai_sdk/types/tools.py b/src/vercel_ai_sdk/types/tools.py index 01cee703..0661ff29 100644 --- a/src/vercel_ai_sdk/types/tools.py +++ b/src/vercel_ai_sdk/types/tools.py @@ -1,7 +1,7 @@ """Tool schema types — what the LLM layer sees. These are schema-only definitions used by LanguageModel.stream(tools=...). -The executable Tool class and @tool decorator live in agents.tools. +The executable Tool class and @tool decorator live in agents.agent. """ from __future__ import annotations diff --git a/tests/adapters/ai_sdk_ui/test_adapter.py b/tests/adapters/ai_sdk_ui/test_adapter.py index 86e2a415..1b1e4519 100644 --- a/tests/adapters/ai_sdk_ui/test_adapter.py +++ b/tests/adapters/ai_sdk_ui/test_adapter.py @@ -12,7 +12,7 @@ from vercel_ai_sdk.agents import hooks from vercel_ai_sdk.types import messages -from ...conftest import MOCK_MODEL, mock_llm, tool_msg +from ...conftest import MOCK_MODEL, mock_llm, tool_call_msg async def get_event_types(msgs: list[messages.Message]) -> list[str]: @@ -74,32 +74,28 @@ async def test_tool_roundtrip() -> None: Reference: process-ui-message-stream.test.ts "server-side tool roundtrip" """ msgs = [ - # Tool pending + # Tool call (assistant message) messages.Message( id="msg-1", role="assistant", parts=[ - messages.ToolPart( + messages.ToolCallPart( tool_call_id="tc-1", tool_name="get_weather", tool_args='{"city": "London"}', - status="pending", state="done", ), ], ), - # Tool result + # Tool result (tool message, pinned to same id for same step) messages.Message( id="msg-1", - role="assistant", + role="tool", parts=[ - messages.ToolPart( + messages.ToolResultPart( tool_call_id="tc-1", tool_name="get_weather", - tool_args='{"city": "London"}', - status="result", result={"weather": "sunny"}, - state="done", ), ], ), @@ -151,34 +147,29 @@ async def test_text_then_tool_then_text() -> None: ) ], ), - # Text done + tool pending + # Text done + tool call messages.Message( id="msg-1", role="assistant", parts=[ messages.TextPart(text="I'll check with the mothership.", state="done"), - messages.ToolPart( + messages.ToolCallPart( tool_call_id="tc-1", tool_name="talk_to_mothership", tool_args='{"question": "when?"}', - status="pending", state="done", ), ], ), - # Tool result + # Tool result (pinned to same id for same step) messages.Message( id="msg-1", - role="assistant", + role="tool", parts=[ - messages.TextPart(text="I'll check with the mothership.", state="done"), - messages.ToolPart( + messages.ToolResultPart( tool_call_id="tc-1", tool_name="talk_to_mothership", - tool_args='{"question": "when?"}', - status="result", result={"answer": "Soon."}, - state="done", ), ], ), @@ -256,11 +247,7 @@ async def test_runtime_tool_roundtrip() -> None: status="pending", but pydantic models are mutable so when we collect them at the end, we see the mutated state. """ - weather_agent = ai.agent( - model=MOCK_MODEL, - system="You are helpful.", - tools=[get_weather], - ) + weather_agent = ai.agent(tools=[get_weather]) # First LLM call: returns a tool call tool_call_response = [ @@ -268,11 +255,10 @@ async def test_runtime_tool_roundtrip() -> None: id="msg-1", role="assistant", parts=[ - messages.ToolPart( + messages.ToolCallPart( tool_call_id="tc-1", tool_name="get_weather", tool_args='{"city": "London"}', - status="pending", state="done", ), ], @@ -293,7 +279,7 @@ async def test_runtime_tool_roundtrip() -> None: # Collect all messages from the runtime runtime_messages: list[messages.Message] = [] async for msg in weather_agent.run( - ai.make_messages(user="What's the weather in London?") + MOCK_MODEL, ai.make_messages(user="What's the weather in London?") ): runtime_messages.append(msg) @@ -423,12 +409,13 @@ def test_ui_to_internal_two_turn_with_tool() -> None: # The single UI assistant message contains [text, tool(done), text] from # two stream_loop iterations. to_messages splits at the tool-result - # boundary so LLM adapters receive one message per iteration. - assert len(internal) == 4 + # boundary so LLM adapters receive one message per iteration: + # user, assistant (text + tool_call), tool (result), assistant (text), user + assert len(internal) == 5 assert internal[0].role == "user" assert internal[0].text == "when will the robots take over?" - # First iteration: text + tool call + # First iteration: text + tool call (assistant message) assert internal[1].role == "assistant" assert internal[1].text == ( "I'll check with the mothership about this important question." @@ -436,16 +423,19 @@ def test_ui_to_internal_two_turn_with_tool() -> None: assert len(internal[1].tool_calls) == 1 assert internal[1].tool_calls[0].tool_name == "talk_to_mothership" assert internal[1].tool_calls[0].tool_call_id == "toolu_01FiXNXhq1kHx4TegRjSaJyv" - assert internal[1].tool_calls[0].status == "result" - assert internal[1].tool_calls[0].result == {"value": "Soon."} + + # Tool result (separate tool message) + assert internal[2].role == "tool" + assert len(internal[2].tool_results) == 1 + assert internal[2].tool_results[0].result == {"value": "Soon."} # Second iteration: follow-up text - assert internal[2].role == "assistant" - assert internal[2].text == "The mothership has spoken: Soon." - assert len(internal[2].tool_calls) == 0 + assert internal[3].role == "assistant" + assert internal[3].text == "The mothership has spoken: Soon." + assert len(internal[3].tool_calls) == 0 - assert internal[3].role == "user" - assert internal[3].text == "this is a test run. can you remember the first turn?" + assert internal[4].role == "user" + assert internal[4].text == "this is a test run. can you remember the first turn?" def test_ui_tool_part_with_dict_input() -> None: @@ -470,7 +460,7 @@ def test_ui_tool_part_with_dict_input() -> None: tool_part = internal[0].tool_calls[0] assert tool_part.tool_name == "get_weather" assert tool_part.tool_args == '{"city": "London"}' - assert tool_part.status == "pending" # input-available maps to pending + # input-available means call is present but no result yet (no tool message) def test_ui_file_part_converted_to_core_file_part() -> None: @@ -541,16 +531,15 @@ async def test_tool_approval_hook_emits_approval_request() -> None: helper can find the tool part when the user responds to the approval. """ msgs = [ - # Tool pending (args complete, awaiting approval) + # Tool call (args complete, awaiting approval) messages.Message( id="msg-1", role="assistant", parts=[ - messages.ToolPart( + messages.ToolCallPart( tool_call_id="tc-1", tool_name="rm_rf", tool_args='{"path": "/"}', - status="pending", state="done", ), ], @@ -563,7 +552,7 @@ async def test_tool_approval_hook_emits_approval_request() -> None: parts=[ messages.HookPart( hook_id="approve_tc-1", - hook_type=hooks.ToolApproval.hook_type, # type: ignore[attr-defined] + hook_type=hooks.TOOL_APPROVAL_HOOK_TYPE, status="pending", metadata={"tool_name": "rm_rf", "tool_args": '{"path": "/"}'}, ), @@ -624,51 +613,56 @@ def test_approval_responded_resolves_hook() -> None: async def test_runtime_tool_approval_same_step() -> None: """E2E: tool-approval-request must land in the same SSE step as the tool call. - Runs a graph with ToolApproval (cancels_future=True) through ai.run(), + Runs a graph with ToolApproval (interrupt_loop=True) through agent.run(), collects runtime messages, streams through the adapter, and asserts that no spurious step boundary appears between tool-input-available and tool-approval-request. - - This is the test that would have caught the bug where the Runtime's - HookPart message (which has a different id from the LLM message) - caused the adapter to open a new step. """ + from collections.abc import AsyncGenerator as AG @ai.tool async def dangerous_action(path: str) -> str: """Do something dangerous.""" return f"deleted {path}" - approval_agent = ai.agent( - model=MOCK_MODEL, - system="You are helpful.", - tools=[dangerous_action], - ) + approval_agent = ai.agent(tools=[dangerous_action]) @approval_agent.loop - async def custom(agent: ai.Agent, msgs: list[ai.Message]) -> None: - result = await ai.stream_step(agent.model, msgs, agent.tools) - if not result.tool_calls: + async def custom(context: ai.Context) -> AG[ai.Message]: + stream = await ai.models.stream( + context.model, context.messages, tools=context.tools + ) + async for msg in stream: + yield msg + + tool_calls = context.resolve(stream.tool_calls) + if not tool_calls: return - last_msg = result.last_message - assert last_msg is not None - - async def approve_and_execute(tc: ai.ToolPart) -> ai.ToolPart: - approval = await ai.ToolApproval.create( # type: ignore[attr-defined] - f"approve_{tc.tool_call_id}", - metadata={"tool_name": tc.tool_name}, + async def approve_and_execute(tc: ai.ToolCall) -> ai.ToolResultPart: + approval = await ai.hook( + f"approve_{tc.id}", + payload=ai.ToolApproval, + metadata={"tool_name": tc.name}, + interrupt_loop=True, ) if approval.granted: - return await ai.execute_tool(tc, message=last_msg) - return tc.with_error("denied") + return await tc() + return ai.ToolResultPart( + tool_call_id=tc.id, + tool_name=tc.name, + result="denied", + is_error=True, + ) - await asyncio.gather(*(approve_and_execute(tc) for tc in result.tool_calls)) + async with asyncio.TaskGroup() as tg: + tasks = [tg.create_task(approve_and_execute(tc)) for tc in tool_calls] + yield ai.tool_message(*(t.result() for t in tasks)) mock_llm( [ [ - tool_msg( + tool_call_msg( tc_id="tc-1", name="dangerous_action", args='{"path": "/tmp"}', @@ -678,13 +672,11 @@ async def approve_and_execute(tc: ai.ToolPart) -> ai.ToolPart: ) runtime_messages: list[messages.Message] = [] - result = approval_agent.run(ai.make_messages(user="delete /tmp")) - async for msg in result: + async for msg in approval_agent.run( + MOCK_MODEL, ai.make_messages(user="delete /tmp") + ): runtime_messages.append(msg) - # The run should have a pending hook (approval not yet granted) - assert "approve_tc-1" in result.pending_hooks - # Stream through UI adapter event_types = [ p.type @@ -692,10 +684,6 @@ async def approve_and_execute(tc: ai.ToolPart) -> ai.ToolPart: ] # tool-approval-request must be in the SAME step as tool-input. - # If a spurious step boundary sneaks in, we'd see: - # [..., "tool-input-available", "finish-step", "start-step", - # "tool-approval-request", ...] - # which breaks the frontend's sendAutomaticallyWhen helper. assert event_types == [ "start", "start-step", diff --git a/tests/agents/mcp/test_client.py b/tests/agents/mcp/test_client.py index a1220ac8..7c4fc2a1 100644 --- a/tests/agents/mcp/test_client.py +++ b/tests/agents/mcp/test_client.py @@ -1,4 +1,4 @@ -"""MCP client: tool registration in global registry, end-to-end execution.""" +"""MCP client: tool conversion, end-to-end execution.""" import contextlib from typing import Any @@ -8,9 +8,8 @@ import vercel_ai_sdk as ai from vercel_ai_sdk.agents.mcp.client import _mcp_tool_to_native -from vercel_ai_sdk.agents.tools import _tool_registry, get_tool -from ...conftest import MOCK_MODEL, mock_llm, text_msg, tool_msg +from ...conftest import MOCK_MODEL, mock_llm, text_msg, tool_call_msg def _fake_mcp_tool( @@ -33,26 +32,24 @@ def _noop_transport_factory() -> contextlib.AbstractAsyncContextManager[Any]: raise NotImplementedError("should not be called") -# -- _mcp_tool_to_native registers in global registry ---------------------- +# -- _mcp_tool_to_native produces a valid Tool ---------------------------- -def test_mcp_tool_to_native_registers_in_global_registry() -> None: - """Converting an MCP tool to native registers it in _tool_registry.""" - mcp_tool = _fake_mcp_tool(name="mcp_reg_test") +def test_mcp_tool_to_native_basic() -> None: + """Converting an MCP tool to native produces a Tool with correct schema.""" + mcp_tool = _fake_mcp_tool(name="mcp_basic_test") native = _mcp_tool_to_native(mcp_tool, "test:key", _noop_transport_factory, None) - assert native.name == "mcp_reg_test" - assert get_tool("mcp_reg_test") is native - assert _tool_registry["mcp_reg_test"] is native + assert native.name == "mcp_basic_test" + assert native.description == "Echo input" def test_mcp_tool_to_native_with_prefix() -> None: - """Tool prefix is prepended to the name and both name forms are correct.""" + """Tool prefix is prepended to the name.""" mcp_tool = _fake_mcp_tool(name="echo") native = _mcp_tool_to_native(mcp_tool, "test:key", _noop_transport_factory, "ctx7") assert native.name == "ctx7_echo" - assert get_tool("ctx7_echo") is native def test_mcp_tool_to_native_schema_preserved() -> None: @@ -76,33 +73,33 @@ async def fake_fn(**kwargs: str) -> str: call_log.append(kwargs) return f"echoed: {kwargs.get('text', '')}" - # Build and register a tool the same way the MCP client does, + # Build a tool the same way the MCP client does, # but with a fake fn so we don't need a real MCP server. mcp_tool = _fake_mcp_tool(name="mcp_e2e_echo") native = _mcp_tool_to_native(mcp_tool, "test:key", _noop_transport_factory, None) - # Replace the real fn (which would try to connect) with our fake + # Replace the real fn (which would try to connect) with our fake. native._fn = fake_fn - _tool_registry[native.name] = native - my_agent = ai.agent(model=MOCK_MODEL, tools=[native]) + my_agent = ai.agent(tools=[native]) - call1 = [tool_msg(tc_id="tc-mcp-1", name="mcp_e2e_echo", args='{"text": "hello"}')] + call1 = [ + tool_call_msg(tc_id="tc-mcp-1", name="mcp_e2e_echo", args='{"text": "hello"}') + ] call2 = [text_msg("Done.", id="msg-2")] llm = mock_llm([call1, call2]) - result = my_agent.run(ai.make_messages(user="echo hello")) - msgs = [m async for m in result] + msgs: list[ai.Message] = [] + async for msg in my_agent.run(MOCK_MODEL, ai.make_messages(user="echo hello")): + msgs.append(msg) - # Tool was called with the right args + # Tool was called with the right args. assert len(call_log) == 1 assert call_log[0] == {"text": "hello"} - # Tool result is visible in messages - tool_results = [ - m for m in msgs if m.tool_calls and m.tool_calls[0].status == "result" - ] + # Tool result is visible in messages. + tool_results = [m for m in msgs if m.role == "tool" and m.tool_results] assert len(tool_results) >= 1 - assert tool_results[0].tool_calls[0].result == "echoed: hello" + assert tool_results[0].tool_results[0].result == "echoed: hello" - # LLM was called twice (tool call + final text) + # LLM was called twice (tool call + final text). assert llm.call_count == 2 diff --git a/tests/agents/test_checkpoint.py b/tests/agents/test_checkpoint.py index 22fbd583..4f2f24dc 100644 --- a/tests/agents/test_checkpoint.py +++ b/tests/agents/test_checkpoint.py @@ -1,7 +1,6 @@ """Checkpoint replay, hook cancellation/resolution, serialization.""" -import asyncio -from typing import Any, ClassVar +from collections.abc import AsyncGenerator import pydantic import pytest @@ -9,12 +8,10 @@ import vercel_ai_sdk as ai from vercel_ai_sdk.agents.checkpoint import Checkpoint, HookEvent, StepEvent, ToolEvent -from ..conftest import MOCK_MODEL, mock_llm, text_msg, tool_msg +from ..conftest import MOCK_MODEL, mock_llm, text_msg, tool_call_msg -@ai.hook class Approval(pydantic.BaseModel): - cancels_future: ClassVar[bool] = True granted: bool @@ -23,21 +20,28 @@ class Approval(pydantic.BaseModel): @pytest.mark.asyncio async def test_step_replay_skips_llm() -> None: - my_agent = ai.agent(model=MOCK_MODEL) - - @my_agent.loop - async def custom(agent: ai.Agent, msgs: list[ai.Message]) -> ai.StreamResult: - return await ai.stream_step(agent.model, msgs) + my_agent = ai.agent() + # First run: LLM is called. + provider1 = ai.EventLogProvider() llm1 = mock_llm([[text_msg("Hi there!")]]) - result1 = my_agent.run(ai.make_messages(system="test", user="hello")) - [msg async for msg in result1] + async for _msg in my_agent.run( + MOCK_MODEL, + ai.make_messages(system="test", user="hello"), + durability=provider1, + ): + pass assert llm1.call_count == 1 + cp = provider1.checkpoint() - cp = result1.checkpoint + # Replay: LLM is NOT called. llm2 = mock_llm([]) - result2 = my_agent.run(ai.make_messages(system="test", user="hello"), checkpoint=cp) - [msg async for msg in result2] + async for _msg in my_agent.run( + MOCK_MODEL, + ai.make_messages(system="test", user="hello"), + checkpoint=cp, + ): + pass assert llm2.call_count == 0 @@ -52,32 +56,35 @@ async def counting_tool(x: int) -> int: execution_count += 1 return x + 1 - my_agent = ai.agent(model=MOCK_MODEL, tools=[counting_tool]) - - @my_agent.loop - async def custom(agent: ai.Agent, msgs: list[ai.Message]) -> ai.StreamResult: - result = await ai.stream_step(agent.model, msgs, agent.tools) - if result.tool_calls: - await asyncio.gather( - *( - ai.execute_tool(tc, message=result.last_message) - for tc in result.tool_calls - ) - ) - return result + my_agent = ai.agent(tools=[counting_tool]) - mock_llm([[tool_msg(tc_id="tc-1", name="counting_tool", args='{"x": 5}')]]) - result1 = my_agent.run(ai.make_messages(system="t", user="go")) - [msg async for msg in result1] + # First run: tool should execute. + mock_llm( + [ + [tool_call_msg(tc_id="tc-1", name="counting_tool", args='{"x": 5}')], + [text_msg("Done", id="msg-2")], + ] + ) + provider1 = ai.EventLogProvider() + async for _msg in my_agent.run( + MOCK_MODEL, + ai.make_messages(system="t", user="go"), + durability=provider1, + ): + pass assert execution_count == 1 - assert result1.checkpoint.tools[0].result == 6 + cp = provider1.checkpoint() + assert cp.tools[0].result == 6 + # Replay: tool should NOT execute. execution_count = 0 mock_llm([]) - result2 = my_agent.run( - ai.make_messages(system="t", user="go"), checkpoint=result1.checkpoint - ) - [msg async for msg in result2] + async for _msg in my_agent.run( + MOCK_MODEL, + ai.make_messages(system="t", user="go"), + checkpoint=cp, + ): + pass assert execution_count == 0 @@ -86,99 +93,65 @@ async def custom(agent: ai.Agent, msgs: list[ai.Message]) -> ai.StreamResult: @pytest.mark.asyncio async def test_hook_cancellation_pending() -> None: - my_agent = ai.agent(model=MOCK_MODEL) + my_agent = ai.agent() @my_agent.loop - async def custom(agent: ai.Agent, msgs: list[ai.Message]) -> Any: - await ai.stream_step(agent.model, msgs) - return await Approval.create("my_approval", metadata={"tool": "test"}) # type: ignore[attr-defined] + async def custom(context: ai.Context) -> AsyncGenerator[ai.Message]: + async for msg in await ai.models.stream(context.model, context.messages): + yield msg + await ai.hook( + "my_approval", + payload=Approval, + metadata={"tool": "test"}, + interrupt_loop=True, + ) mock_llm([[text_msg("OK")]]) - result = my_agent.run(ai.make_messages(system="t", user="go")) - msgs = [msg async for msg in result] - assert "my_approval" in result.pending_hooks + msgs: list[ai.Message] = [] + async for msg in my_agent.run(MOCK_MODEL, ai.make_messages(system="t", user="go")): + msgs.append(msg) + hook_msgs = [m for m in msgs if any(isinstance(p, ai.HookPart) for p in m.parts)] + assert len(hook_msgs) >= 1 assert hook_msgs[0].parts[0].status == "pending" # type: ignore[union-attr] @pytest.mark.asyncio async def test_hook_resolution_on_reentry() -> None: - my_agent = ai.agent(model=MOCK_MODEL) + my_agent = ai.agent() @my_agent.loop - async def custom(agent: ai.Agent, msgs: list[ai.Message]) -> Any: - await ai.stream_step(agent.model, msgs) - return await Approval.create("my_approval") # type: ignore[attr-defined] - - resp = [text_msg("OK")] - mock_llm([resp]) - result1 = my_agent.run(ai.make_messages(system="t", user="go")) - [msg async for msg in result1] - cp = result1.checkpoint - - Approval.resolve("my_approval", {"granted": True}) # type: ignore[attr-defined] - mock_llm([]) - result2 = my_agent.run(ai.make_messages(system="t", user="go"), checkpoint=cp) - [msg async for msg in result2] - assert len(result2.pending_hooks) == 0 - assert result2.checkpoint.hooks[-1].label == "my_approval" - - -@pytest.mark.asyncio -async def test_parallel_hooks_all_collected() -> None: - my_agent = ai.agent(model=MOCK_MODEL) - - @my_agent.loop - async def custom(agent: ai.Agent, msgs: list[ai.Message]) -> None: - await ai.stream_step(agent.model, msgs) - - async def a() -> Any: - return await Approval.create("hook_a") # type: ignore[attr-defined] - - async def b() -> Any: - return await Approval.create("hook_b") # type: ignore[attr-defined] - - async with asyncio.TaskGroup() as tg: - tg.create_task(a()) - tg.create_task(b()) + async def custom(context: ai.Context) -> AsyncGenerator[ai.Message]: + async for msg in await ai.models.stream(context.model, context.messages): + yield msg + await ai.hook( + "my_approval", + payload=Approval, + interrupt_loop=True, + ) mock_llm([[text_msg("OK")]]) - result = my_agent.run(ai.make_messages(system="t", user="go")) - [msg async for msg in result] - assert {"hook_a", "hook_b"} <= set(result.pending_hooks) - - -@pytest.mark.asyncio -async def test_parallel_hooks_resolve_on_reentry() -> None: - my_agent = ai.agent(model=MOCK_MODEL) - - @my_agent.loop - async def custom(agent: ai.Agent, msgs: list[ai.Message]) -> Any: - await ai.stream_step(agent.model, msgs) - - async def a() -> Any: - return await Approval.create("hook_a") # type: ignore[attr-defined] - - async def b() -> Any: - return await Approval.create("hook_b") # type: ignore[attr-defined] - - async with asyncio.TaskGroup() as tg: - ta = tg.create_task(a()) - tb = tg.create_task(b()) - return ta.result(), tb.result() - - resp = [text_msg("OK")] - mock_llm([resp]) - result1 = my_agent.run(ai.make_messages(system="t", user="go")) - [msg async for msg in result1] - cp = result1.checkpoint - - Approval.resolve("hook_a", {"granted": True}) # type: ignore[attr-defined] - Approval.resolve("hook_b", {"granted": False}) # type: ignore[attr-defined] + provider1 = ai.EventLogProvider() + async for _msg in my_agent.run( + MOCK_MODEL, + ai.make_messages(system="t", user="go"), + durability=provider1, + ): + pass + cp = provider1.checkpoint() + + # Pre-register resolution, then replay. + ai.resolve_hook("my_approval", {"granted": True}) mock_llm([]) - result2 = my_agent.run(ai.make_messages(system="t", user="go"), checkpoint=cp) - [msg async for msg in result2] - assert len(result2.pending_hooks) == 0 + provider2 = ai.EventLogProvider(cp) + async for _msg in my_agent.run( + MOCK_MODEL, + ai.make_messages(system="t", user="go"), + durability=provider2, + ): + pass + cp2 = provider2.checkpoint() + assert any(h.label == "my_approval" for h in cp2.hooks) # -- Serialization --------------------------------------------------------- @@ -189,16 +162,14 @@ def test_checkpoint_serialization_roundtrip() -> None: steps=[ StepEvent( index=0, - messages=[ - ai.Message( - id="m1", - role="assistant", - parts=[ai.TextPart(text="hi")], - ) - ], + message=ai.Message( + id="m1", + role="assistant", + parts=[ai.TextPart(text="hi")], + ), ) ], - tools=[ToolEvent(tool_call_id="tc-1", result=42)], + tools=[ToolEvent(tool_call_id="tc-1", tool_name="test", result=42)], hooks=[HookEvent(label="h1", resolution={"granted": True})], ) cp2 = Checkpoint.model_validate(cp.model_dump()) diff --git a/tests/agents/test_hooks.py b/tests/agents/test_hooks.py index 65bfd15b..58bba9dd 100644 --- a/tests/agents/test_hooks.py +++ b/tests/agents/test_hooks.py @@ -1,7 +1,7 @@ """Hooks: live resolution, cancellation, pre-registration, schema validation.""" import asyncio -from typing import Any, ClassVar +from collections.abc import AsyncGenerator import pydantic import pytest @@ -11,88 +11,75 @@ from ..conftest import MOCK_MODEL, mock_llm, text_msg -@ai.hook class Confirmation(pydantic.BaseModel): approved: bool reason: str = "" -@ai.hook -class CancellingConfirmation(pydantic.BaseModel): - cancels_future: ClassVar[bool] = True - approved: bool - reason: str = "" - - -# -- Hook.resolve() with live future (long-running mode) ------------------- +# -- resolve_hook() with live future (long-running mode) ------------------- @pytest.mark.asyncio async def test_resolve_live_future() -> None: - """In long-running mode, Hook.resolve() unblocks the awaiting coroutine.""" - resolved_value = None - my_agent = ai.agent(model=MOCK_MODEL) + """In long-running mode, resolve_hook() unblocks the awaiting coroutine.""" + resolved_value: Confirmation | None = None + my_agent = ai.agent() @my_agent.loop - async def custom(agent: ai.Agent, msgs: list[ai.Message]) -> None: + async def custom(context: ai.Context) -> AsyncGenerator[ai.Message]: nonlocal resolved_value - await ai.stream_step(agent.model, msgs) - result = await Confirmation.create("confirm_1") # type: ignore[attr-defined] + async for msg in await ai.models.stream(context.model, context.messages): + yield msg + result = await ai.hook("confirm_1", payload=Confirmation) resolved_value = result mock_llm([[text_msg("OK")]]) - # Confirmation.cancels_future=False -> long-running mode - run_result = my_agent.run(ai.make_messages(user="go")) - collected = [] - async for msg in run_result: - collected.append(msg) - # When we see the pending hook message, resolve it + async for msg in my_agent.run(MOCK_MODEL, ai.make_messages(user="go")): + # When we see the pending hook message, resolve it. if any(isinstance(p, ai.HookPart) and p.status == "pending" for p in msg.parts): - Confirmation.resolve( # type: ignore[attr-defined] - "confirm_1", {"approved": True, "reason": "looks good"} - ) + ai.resolve_hook("confirm_1", {"approved": True, "reason": "looks good"}) assert resolved_value is not None assert resolved_value.approved is True assert resolved_value.reason == "looks good" -# -- Hook.cancel() -------------------------------------------------------- +# -- cancel_hook() -------------------------------------------------------- @pytest.mark.asyncio async def test_cancel_live_hook() -> None: - """Hook.cancel() cancels the future, causing CancelledError in graph.""" + """cancel_hook() cancels the future, causing CancelledError in graph.""" was_cancelled = False - my_agent = ai.agent(model=MOCK_MODEL) + my_agent = ai.agent() @my_agent.loop - async def custom(agent: ai.Agent, msgs: list[ai.Message]) -> None: + async def custom(context: ai.Context) -> AsyncGenerator[ai.Message]: nonlocal was_cancelled - await ai.stream_step(agent.model, msgs) + async for msg in await ai.models.stream(context.model, context.messages): + yield msg try: - await Confirmation.create("cancel_me") # type: ignore[attr-defined] + await ai.hook("cancel_me", payload=Confirmation) except asyncio.CancelledError: was_cancelled = True mock_llm([[text_msg("OK")]]) - run_result = my_agent.run(ai.make_messages(user="go")) - async for msg in run_result: + async for msg in my_agent.run(MOCK_MODEL, ai.make_messages(user="go")): if any(isinstance(p, ai.HookPart) and p.status == "pending" for p in msg.parts): - await Confirmation.cancel("cancel_me", reason="denied") # type: ignore[attr-defined] + await ai.cancel_hook("cancel_me", reason="denied") assert was_cancelled -# -- Hook.cancel() on non-existent label raises ---------------------------- +# -- cancel_hook() on non-existent label raises ---------------------------- @pytest.mark.asyncio async def test_cancel_nonexistent_raises() -> None: with pytest.raises(ValueError, match="No pending hook"): - await Confirmation.cancel("does_not_exist_xyz") # type: ignore[attr-defined] + await ai.cancel_hook("does_not_exist_xyz") # -- Pre-registration (serverless re-entry) -------------------------------- @@ -100,36 +87,44 @@ async def test_cancel_nonexistent_raises() -> None: @pytest.mark.asyncio async def test_pre_registered_resolution_consumed() -> None: - """Pre-registered resolution is consumed by Hook.create() without suspending.""" - my_agent = ai.agent(model=MOCK_MODEL) + """Pre-registered resolution is consumed by hook() without suspending.""" + my_agent = ai.agent() @my_agent.loop - async def custom(agent: ai.Agent, msgs: list[ai.Message]) -> Any: - await ai.stream_step(agent.model, msgs) - result = await Confirmation.create("pre_reg_1") # type: ignore[attr-defined] - return result + async def custom(context: ai.Context) -> AsyncGenerator[ai.Message]: + async for msg in await ai.models.stream(context.model, context.messages): + yield msg + await ai.hook("pre_reg_1", payload=Confirmation) - # Pre-register BEFORE run - Confirmation.resolve("pre_reg_1", {"approved": True}) # type: ignore[attr-defined] + # Pre-register BEFORE run. + ai.resolve_hook("pre_reg_1", {"approved": True}) mock_llm([[text_msg("OK")]]) - run_result = my_agent.run(ai.make_messages(user="go")) - [m async for m in run_result] + provider = ai.EventLogProvider() + async for _msg in my_agent.run( + MOCK_MODEL, + ai.make_messages(user="go"), + durability=provider, + ): + pass - # Should have completed with no pending hooks - assert len(run_result.pending_hooks) == 0 - # Hook event should be in checkpoint - assert any(h.label == "pre_reg_1" for h in run_result.checkpoint.hooks) + # Hook event should be recorded in checkpoint. + cp = provider.checkpoint() + assert any(h.label == "pre_reg_1" for h in cp.hooks) # -- Schema validation on resolve ----------------------------------------- def test_resolve_validates_schema() -> None: - """resolve() with invalid data raises from pydantic validation.""" - # 'approved' is required bool, passing string should raise + """resolve_hook() with invalid data raises from pydantic validation.""" + # 'approved' is required bool, passing string should raise. with pytest.raises(pydantic.ValidationError): - Confirmation.resolve("schema_test", {"approved": "not_a_bool"}) # type: ignore[attr-defined] + ai.resolve_hook( + "schema_test", + {"approved": "not_a_bool"}, + payload=Confirmation, + ) # -- Resolved hook emits message ------------------------------------------- @@ -138,21 +133,21 @@ def test_resolve_validates_schema() -> None: @pytest.mark.asyncio async def test_resolved_hook_emits_message() -> None: """After resolution, a 'resolved' HookPart message is emitted.""" - my_agent = ai.agent(model=MOCK_MODEL) + my_agent = ai.agent() @my_agent.loop - async def custom(agent: ai.Agent, msgs: list[ai.Message]) -> None: - await ai.stream_step(agent.model, msgs) - await Confirmation.create("emit_test") # type: ignore[attr-defined] + async def custom(context: ai.Context) -> AsyncGenerator[ai.Message]: + async for msg in await ai.models.stream(context.model, context.messages): + yield msg + await ai.hook("emit_test", payload=Confirmation) mock_llm([[text_msg("OK")]]) - run_result = my_agent.run(ai.make_messages(user="go")) - msgs = [] - async for msg in run_result: + msgs: list[ai.Message] = [] + async for msg in my_agent.run(MOCK_MODEL, ai.make_messages(user="go")): msgs.append(msg) if any(isinstance(p, ai.HookPart) and p.status == "pending" for p in msg.parts): - Confirmation.resolve("emit_test", {"approved": False}) # type: ignore[attr-defined] + ai.resolve_hook("emit_test", {"approved": False}) hook_msgs = [ m @@ -160,7 +155,7 @@ async def custom(agent: ai.Agent, msgs: list[ai.Message]) -> None: if any(isinstance(p, ai.HookPart) and p.status == "resolved" for p in m.parts) ] assert len(hook_msgs) == 1 - assert hook_msgs[0].parts[0].resolution == {"approved": False, "reason": ""} # type: ignore[union-attr] + assert hook_msgs[0].parts[0].resolution == {"approved": False} # type: ignore[union-attr] # -- Hook metadata surfaces in pending message ----------------------------- @@ -168,18 +163,24 @@ async def custom(agent: ai.Agent, msgs: list[ai.Message]) -> None: @pytest.mark.asyncio async def test_hook_metadata_in_pending() -> None: - my_agent = ai.agent(model=MOCK_MODEL) + my_agent = ai.agent() @my_agent.loop - async def custom(agent: ai.Agent, msgs: list[ai.Message]) -> None: - await ai.stream_step(agent.model, msgs) - await CancellingConfirmation.create( # type: ignore[attr-defined] - "meta_test", metadata={"tool": "rm -rf", "path": "/"} + async def custom(context: ai.Context) -> AsyncGenerator[ai.Message]: + async for msg in await ai.models.stream(context.model, context.messages): + yield msg + await ai.hook( + "meta_test", + payload=Confirmation, + metadata={"tool": "rm -rf", "path": "/"}, + interrupt_loop=True, ) mock_llm([[text_msg("OK")]]) - run_result = my_agent.run(ai.make_messages(user="go")) - [m async for m in run_result] + msgs: list[ai.Message] = [] + async for msg in my_agent.run(MOCK_MODEL, ai.make_messages(user="go")): + msgs.append(msg) - info = run_result.pending_hooks["meta_test"] - assert info.metadata == {"tool": "rm -rf", "path": "/"} + hook_msgs = [m for m in msgs if any(isinstance(p, ai.HookPart) for p in m.parts)] + assert len(hook_msgs) >= 1 + assert hook_msgs[0].parts[0].metadata == {"tool": "rm -rf", "path": "/"} # type: ignore[union-attr] diff --git a/tests/agents/test_runtime.py b/tests/agents/test_runtime.py index 8cd2e8cd..2efe50a0 100644 --- a/tests/agents/test_runtime.py +++ b/tests/agents/test_runtime.py @@ -1,14 +1,11 @@ -"""Agent default loop, execute_tool, multi-turn, Runtime injection.""" - -import asyncio +"""Agent default loop, tool execution, multi-turn.""" import pytest import vercel_ai_sdk as ai -from vercel_ai_sdk.agents.runtime import Runtime from vercel_ai_sdk.types import messages -from ..conftest import MOCK_MODEL, mock_llm, text_msg, tool_msg +from ..conftest import MOCK_MODEL, mock_llm, text_msg, tool_call_msg # -- Tool definitions for tests -------------------------------------------- @@ -31,11 +28,12 @@ async def concat(a: str, b: str) -> str: @pytest.mark.asyncio async def test_agent_text_only() -> None: """Agent default loop with no tool calls returns after one LLM call.""" - my_agent = ai.agent(model=MOCK_MODEL, tools=[double]) + my_agent = ai.agent(tools=[double]) llm = mock_llm([[text_msg("Hello!")]]) - result = my_agent.run(ai.make_messages(user="Hi")) - msgs = [m async for m in result] + msgs: list[ai.Message] = [] + async for m in my_agent.run(MOCK_MODEL, ai.make_messages(user="Hi")): + msgs.append(m) assert llm.call_count == 1 assert any(m.text == "Hello!" for m in msgs) @@ -46,21 +44,19 @@ async def test_agent_text_only() -> None: @pytest.mark.asyncio async def test_agent_tool_then_text() -> None: """Agent default loop calls tool, feeds result back, gets final text.""" - my_agent = ai.agent(model=MOCK_MODEL, tools=[double]) + my_agent = ai.agent(tools=[double]) - call1 = [tool_msg(tc_id="tc-1", name="double", args='{"x": 5}')] + call1 = [tool_call_msg(tc_id="tc-1", name="double", args='{"x": 5}')] call2 = [text_msg("The answer is 10.")] llm = mock_llm([call1, call2]) - result = my_agent.run(ai.make_messages(user="Double 5")) - msgs = [m async for m in result] + msgs: list[ai.Message] = [] + async for m in my_agent.run(MOCK_MODEL, ai.make_messages(user="Double 5")): + msgs.append(m) assert llm.call_count == 2 - # Tool should have been executed: 5 * 2 = 10 - tool_results = [ - m for m in msgs if m.tool_calls and m.tool_calls[0].status == "result" - ] + tool_results = [m for m in msgs if m.role == "tool" and m.tool_results] assert len(tool_results) >= 1 - assert tool_results[0].tool_calls[0].result == 10 + assert tool_results[0].tool_results[0].result == 10 # -- Agent default loop: multiple tool calls in one message ---------------- @@ -69,24 +65,22 @@ async def test_agent_tool_then_text() -> None: @pytest.mark.asyncio async def test_agent_parallel_tools() -> None: """LLM returns two tool calls in one message; both execute.""" - my_agent = ai.agent(model=MOCK_MODEL, tools=[double]) + my_agent = ai.agent(tools=[double]) two_tools = messages.Message( id="msg-1", role="assistant", parts=[ - messages.ToolPart( + messages.ToolCallPart( tool_call_id="tc-1", tool_name="double", tool_args='{"x": 3}', - status="pending", state="done", ), - messages.ToolPart( + messages.ToolCallPart( tool_call_id="tc-2", tool_name="double", tool_args='{"x": 7}', - status="pending", state="done", ), ], @@ -94,15 +88,11 @@ async def test_agent_parallel_tools() -> None: call2 = [text_msg("6 and 14", id="msg-2")] llm = mock_llm([[two_tools], call2]) - result = my_agent.run(ai.make_messages(user="Double 3 and 7")) - msgs = [m async for m in result] + msgs: list[ai.Message] = [] + async for m in my_agent.run(MOCK_MODEL, ai.make_messages(user="Double 3 and 7")): + msgs.append(m) assert llm.call_count == 2 - # Both tools should have results - tool_result_msgs = [ - m - for m in msgs - if m.tool_calls and any(tc.status == "result" for tc in m.tool_calls) - ] + tool_result_msgs = [m for m in msgs if m.role == "tool" and m.tool_results] assert len(tool_result_msgs) >= 1 @@ -112,123 +102,18 @@ async def test_agent_parallel_tools() -> None: @pytest.mark.asyncio async def test_agent_multi_turn() -> None: """LLM calls a tool, then calls another tool, then returns text.""" - my_agent = ai.agent(model=MOCK_MODEL, tools=[double, concat]) + my_agent = ai.agent(tools=[double, concat]) turn1 = [ - tool_msg(tc_id="tc-1", name="concat", args='{"a": "hello", "b": " world"}') + tool_call_msg(tc_id="tc-1", name="concat", args='{"a": "hello", "b": " world"}') ] - turn2 = [tool_msg(tc_id="tc-2", name="double", args='{"x": 3}', id="msg-2")] + turn2 = [tool_call_msg(tc_id="tc-2", name="double", args='{"x": 3}', id="msg-2")] turn3 = [text_msg("Done: hello world, 6", id="msg-3")] llm = mock_llm([turn1, turn2, turn3]) - result = my_agent.run(ai.make_messages(user="Concat then double")) - [m async for m in result] + msgs: list[ai.Message] = [] + async for m in my_agent.run( + MOCK_MODEL, ai.make_messages(user="Concat then double") + ): + msgs.append(m) assert llm.call_count == 3 - - -# -- execute_tool: missing tool raises ------------------------------------ - - -@pytest.mark.asyncio -async def test_execute_tool_missing_raises() -> None: - """execute_tool with unknown tool name raises ValueError. - - Wrapped in ExceptionGroup by TaskGroup. - """ - tc = messages.ToolPart( - tool_call_id="tc-1", tool_name="nonexistent_tool_zzz", tool_args="{}" - ) - my_agent = ai.agent(model=MOCK_MODEL, tools=[]) - - @my_agent.loop - async def custom(agent: ai.Agent, msgs: list[ai.Message]) -> None: - await ai.execute_tool(tc) - - mock_llm([]) - result = my_agent.run(ai.make_messages(user="go")) - with pytest.raises(ExceptionGroup) as exc_info: - [m async for m in result] - assert any(isinstance(e, ValueError) for e in exc_info.value.exceptions) - - -# -- execute_tool: Runtime injection --------------------------------------- - - -@pytest.mark.asyncio -async def test_execute_tool_injects_runtime() -> None: - """Tools with a Runtime parameter get the active runtime injected.""" - received_rt = None - - @ai.tool - async def introspect(query: str, rt: Runtime) -> str: - """Tool that inspects runtime.""" - nonlocal received_rt - received_rt = rt - return "ok" - - my_agent = ai.agent(model=MOCK_MODEL, tools=[introspect]) - - @my_agent.loop - async def custom(agent: ai.Agent, msgs: list[ai.Message]) -> None: - result = await ai.stream_step(agent.model, msgs, agent.tools) - if result.tool_calls: - await asyncio.gather( - *( - ai.execute_tool(tc, message=result.last_message) - for tc in result.tool_calls - ) - ) - - call = [tool_msg(tc_id="tc-1", name="introspect", args='{"query": "test"}')] - mock_llm([call]) - result = my_agent.run(ai.make_messages(user="go")) - [m async for m in result] - assert received_rt is not None - assert isinstance(received_rt, Runtime) - - -# -- execute_tool: returns updated ToolPart -------------------------------- - - -@pytest.mark.asyncio -async def test_execute_tool_returns_updated_part() -> None: - """execute_tool returns an updated ToolPart; the original is unchanged.""" - my_agent = ai.agent(model=MOCK_MODEL, tools=[double]) - - @my_agent.loop - async def custom(agent: ai.Agent, msgs: list[ai.Message]) -> None: - result = await ai.stream_step(agent.model, msgs, agent.tools) - if result.tool_calls: - msg = result.last_message - assert msg is not None - for tc in result.tool_calls: - updated_tc = await ai.execute_tool(tc, message=msg) - # Returned part has the result - assert updated_tc.status == "result" - assert updated_tc.result == 10 - # Original message is unchanged (immutable) - assert msg.tool_calls[0].status == "pending" - - call = [tool_msg(tc_id="tc-1", name="double", args='{"x": 5}')] - mock_llm([call]) - result = my_agent.run(ai.make_messages(user="go")) - [m async for m in result] - - -# -- Checkpoint records tools from Agent default loop ---------------------- - - -@pytest.mark.asyncio -async def test_agent_checkpoint_records_tools() -> None: - """Agent default loop's tool executions are recorded in the checkpoint.""" - my_agent = ai.agent(model=MOCK_MODEL, tools=[double]) - - call1 = [tool_msg(tc_id="tc-1", name="double", args='{"x": 4}')] - call2 = [text_msg("8", id="msg-2")] - mock_llm([call1, call2]) - - result = my_agent.run(ai.make_messages(user="Double 4")) - [m async for m in result] - - cp = result.checkpoint - assert any(t.tool_call_id == "tc-1" and t.result == 8 for t in cp.tools) diff --git a/tests/agents/test_streams.py b/tests/agents/test_streams.py deleted file mode 100644 index db7770ee..00000000 --- a/tests/agents/test_streams.py +++ /dev/null @@ -1,113 +0,0 @@ -"""@stream decorator: context requirement, replay, queue submission.""" - -import pydantic -import pytest - -import vercel_ai_sdk as ai -from vercel_ai_sdk.agents.streams import StreamResult -from vercel_ai_sdk.types import messages - -from ..conftest import MOCK_MODEL, mock_llm, text_msg - - -class _Weather(pydantic.BaseModel): - city: str - temperature: float - - -# -- StreamResult properties ----------------------------------------------- - - -def test_stream_result_empty() -> None: - r = StreamResult() - assert r.last_message is None - assert r.tool_calls == [] - assert r.text == "" - - -def test_stream_result_last_message() -> None: - m1 = text_msg("first", id="m1") - m2 = text_msg("second", id="m2") - r = StreamResult(messages=[m1, m2]) - last = r.last_message - assert last is not None - assert last.id == "m2" - assert r.text == "second" - - -def test_stream_result_tool_calls() -> None: - m = messages.Message( - id="m1", - role="assistant", - parts=[ - messages.ToolPart( - tool_call_id="tc1", tool_name="t", tool_args="{}", state="done" - ), - messages.ToolPart( - tool_call_id="tc2", tool_name="u", tool_args="{}", state="done" - ), - ], - ) - r = StreamResult(messages=[m]) - assert len(r.tool_calls) == 2 - - -# -- @stream requires Runtime context ------------------------------------- - - -@pytest.mark.asyncio -async def test_stream_outside_run_raises() -> None: - """@stream-decorated fn called without ai.run() should raise.""" - mock_llm([[text_msg("hi")]]) - with pytest.raises(ValueError, match="No Runtime context"): - await ai.stream_step( - MOCK_MODEL, - ai.make_messages(user="test"), - ) - - -# -- @stream replays from checkpoint -------------------------------------- - - -@pytest.mark.asyncio -async def test_stream_step_replays_from_checkpoint() -> None: - """stream_step inside Agent.run with a checkpoint replays without calling LLM.""" - - my_agent = ai.agent(model=MOCK_MODEL) - - @my_agent.loop - async def custom(agent: ai.Agent, msgs: list[ai.Message]) -> ai.StreamResult: - return await ai.stream_step(agent.model, msgs) - - # First run - mock_llm([[text_msg("Hi")]]) - r1 = my_agent.run(ai.make_messages(user="hello")) - [msg async for msg in r1] - cp = r1.checkpoint - - # Replay - llm2 = mock_llm([]) - r2 = my_agent.run(ai.make_messages(user="hello"), checkpoint=cp) - [msg async for msg in r2] - assert llm2.call_count == 0 - - -# -- StreamResult.output --------------------------------------------------- - - -def test_stream_result_output_from_last_message() -> None: - """StreamResult.output delegates to the last message's StructuredOutputPart.""" - m = messages.Message( - id="m1", - role="assistant", - parts=[ - messages.TextPart(text="{}", state="done"), - messages.StructuredOutputPart( - data={"city": "SF", "temperature": 62.0}, - output_type_name=f"{_Weather.__module__}.{_Weather.__qualname__}", - ), - ], - ) - r = StreamResult(messages=[text_msg("streaming..."), m]) - assert r.output is not None - assert r.output.city == "SF" diff --git a/tests/agents/test_tools.py b/tests/agents/test_tools.py index 33a11672..70c75dde 100644 --- a/tests/agents/test_tools.py +++ b/tests/agents/test_tools.py @@ -1,10 +1,8 @@ -"""@tool decorator: schema extraction, registry, Runtime parameter handling.""" +"""@tool decorator: schema extraction, execution, ToolCall.""" import pytest import vercel_ai_sdk as ai -from vercel_ai_sdk.agents.runtime import Runtime -from vercel_ai_sdk.agents.tools import get_tool # -- Schema extraction from type hints ------------------------------------ @@ -31,7 +29,6 @@ async def search(query: str, limit: int | None = None) -> str: assert "query" in search.param_schema.get("required", []) assert "limit" not in search.param_schema.get("required", []) - # limit should still appear in properties assert "limit" in search.param_schema["properties"] @@ -41,8 +38,8 @@ async def fetch(url: str, timeout: int = 30) -> str: """Fetch URL.""" return url - assert "url" in search_required(fetch) - assert "timeout" not in search_required(fetch) + assert "url" in _required(fetch) + assert "timeout" not in _required(fetch) def test_complex_type_schema() -> None: @@ -56,55 +53,67 @@ async def send(recipients: list[str], urgent: bool = False) -> str: assert props["recipients"]["items"]["type"] == "string" -# -- Runtime parameter skipping ------------------------------------------- +# -- Execution (Tool.__call__) -------------------------------------------- -def test_runtime_param_excluded_from_schema() -> None: +@pytest.mark.asyncio +async def test_tool_call_with_json_args() -> None: @ai.tool - async def needs_runtime(query: str, rt: Runtime) -> str: - """Tool that needs runtime.""" - return query + async def add(a: int, b: int) -> int: + """Add two numbers.""" + return a + b - props = needs_runtime.param_schema["properties"] - assert "rt" not in props - assert "query" in props - assert set(needs_runtime.param_schema.get("required", [])) == {"query"} + result = await add('{"a": 1, "b": 2}') + assert result == 3 -# -- Registry ------------------------------------------------------------- +# -- ToolCall binds a ToolCallPart to a Tool and returns ToolResultPart ---- -def test_tool_registered_on_decoration() -> None: +@pytest.mark.asyncio +async def test_tool_call_returns_result_part() -> None: @ai.tool - async def unique_tool_abc() -> str: - """Unique.""" - return "ok" - - assert get_tool("unique_tool_abc") is unique_tool_abc + async def double(x: int) -> int: + """Double a number.""" + return x * 2 + part = ai.ToolCallPart( + tool_call_id="tc-1", + tool_name="double", + tool_args='{"x": 5}', + ) + tc = ai.ToolCall(part=part, tool=double) + result = await tc() -def test_get_tool_returns_none_for_missing() -> None: - assert get_tool("nonexistent_tool_xyz") is None - - -# -- Execution ------------------------------------------------------------ + assert result.tool_call_id == "tc-1" + assert result.tool_name == "double" + assert result.result == 10 + assert not result.is_error @pytest.mark.asyncio -async def test_tool_fn_is_callable() -> None: +async def test_tool_call_catches_errors() -> None: @ai.tool - async def add(a: int, b: int) -> int: - """Add two numbers.""" - return a + b + async def fail(x: int) -> int: + """Always fails.""" + raise ValueError("boom") - result = await add(a=1, b=2) - assert result == 3 + part = ai.ToolCallPart( + tool_call_id="tc-err", + tool_name="fail", + tool_args='{"x": 1}', + ) + tc = ai.ToolCall(part=part, tool=fail) + result = await tc() + + assert result.is_error + assert "boom" in str(result.result) # -- Helpers --------------------------------------------------------------- -def search_required(tool: ai.Tool[..., object]) -> list[str]: +def _required(tool: ai.Tool[..., object]) -> list[str]: result = tool.param_schema.get("required", []) assert isinstance(result, list) return result diff --git a/tests/conftest.py b/tests/conftest.py index e949d1b9..953e8087 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import AsyncGenerator, Sequence -from typing import Any, Literal +from typing import Any import pydantic @@ -71,7 +71,7 @@ async def stream( streaming_.ReasoningEnd(block_id=bid, signature=part.signature) ) - elif isinstance(part, messages_.ToolPart): + elif isinstance(part, messages_.ToolCallPart): yield handler.handle_event( streaming_.ToolStart( tool_call_id=part.tool_call_id, @@ -116,21 +116,35 @@ def text_msg( return messages_.Message(id=id, role="assistant", parts=[part]) -def tool_msg( +def tool_call_msg( *, id: str = "msg-1", tc_id: str = "tc-1", name: str = "test_tool", args: str = "{}", - status: Literal["pending", "result", "error"] = "pending", - result: dict[str, object] | None = None, ) -> messages_.Message: - part: messages_.Part = messages_.ToolPart( + """Assistant message containing a tool call.""" + part: messages_.Part = messages_.ToolCallPart( tool_call_id=tc_id, tool_name=name, tool_args=args, - status=status, - result=result, state="done", ) return messages_.Message(id=id, role="assistant", parts=[part]) + + +def tool_result_msg( + *, + tc_id: str = "tc-1", + name: str = "test_tool", + result: Any = None, + is_error: bool = False, +) -> messages_.Message: + """Tool-result message.""" + part: messages_.Part = messages_.ToolResultPart( + tool_call_id=tc_id, + tool_name=name, + result=result, + is_error=is_error, + ) + return messages_.Message(role="tool", parts=[part]) diff --git a/tests/models/ai_gateway/test_protocol.py b/tests/models/ai_gateway/test_protocol.py index 8695b3ec..5cb25639 100644 --- a/tests/models/ai_gateway/test_protocol.py +++ b/tests/models/ai_gateway/test_protocol.py @@ -78,15 +78,23 @@ async def test_tool_call_with_result_produces_two_messages(self) -> None: messages.Message( role="assistant", parts=[ - messages.ToolPart( + messages.ToolCallPart( tool_call_id="tc-1", tool_name="get_weather", tool_args='{"city": "SF"}', - status="result", + ) + ], + ), + messages.Message( + role="tool", + parts=[ + messages.ToolResultPart( + tool_call_id="tc-1", + tool_name="get_weather", result={"temp": 72}, ) ], - ) + ), ] result = await stream_mod._messages_to_prompt(msgs) assert len(result) == 2 @@ -107,15 +115,24 @@ async def test_tool_error_result(self) -> None: messages.Message( role="assistant", parts=[ - messages.ToolPart( + messages.ToolCallPart( tool_call_id="tc-1", tool_name="get_weather", tool_args="{}", - status="error", + ) + ], + ), + messages.Message( + role="tool", + parts=[ + messages.ToolResultPart( + tool_call_id="tc-1", + tool_name="get_weather", result="Connection timeout", + is_error=True, ) ], - ) + ), ] result = await stream_mod._messages_to_prompt(msgs) tr = result[1]["content"][0] @@ -181,16 +198,16 @@ async def test_user_message_text_only_unchanged(self) -> None: ] async def test_pending_tool_call_no_tool_message(self) -> None: - """A pending tool call should NOT produce a tool-result message.""" + """A tool call without a corresponding tool-result message + should NOT produce a tool-result in the prompt.""" msgs = [ messages.Message( role="assistant", parts=[ - messages.ToolPart( + messages.ToolCallPart( tool_call_id="tc-1", tool_name="search", tool_args="{}", - status="pending", ) ], ) diff --git a/tests/models/ai_gateway/test_stream.py b/tests/models/ai_gateway/test_stream.py index 784dfac1..ed4e1241 100644 --- a/tests/models/ai_gateway/test_stream.py +++ b/tests/models/ai_gateway/test_stream.py @@ -339,16 +339,20 @@ def handler(req: httpx.Request) -> httpx.Response: text=_sse({"type": "finish", "finishReason": "stop", "usage": {}}), ) - tool_part = messages.ToolPart( + tool_call = messages.ToolCallPart( tool_call_id="tc-1", tool_name="search", tool_args='{"q": "weather"}', - status="result", + ) + tool_result = messages.ToolResultPart( + tool_call_id="tc-1", + tool_name="search", result={"temp": 72}, ) conversation = [ _user("What's the weather?"), - messages.Message(role="assistant", parts=[tool_part]), + messages.Message(role="assistant", parts=[tool_call]), + messages.Message(role="tool", parts=[tool_result]), _user("Thanks, and tomorrow?"), ] diff --git a/tests/models/core/test_streaming.py b/tests/models/core/test_streaming.py index 538d3a50..7eb92702 100644 --- a/tests/models/core/test_streaming.py +++ b/tests/models/core/test_streaming.py @@ -18,7 +18,7 @@ FilePart, ReasoningPart, TextPart, - ToolPart, + ToolCallPart, Usage, ) @@ -81,7 +81,7 @@ def test_tool_lifecycle() -> None: h.handle_event(ToolStart(tool_call_id="tc1", tool_name="get_weather")) m = h.handle_event(ToolArgsDelta(tool_call_id="tc1", delta='{"ci')) part = m.parts[0] - assert isinstance(part, ToolPart) + assert isinstance(part, ToolCallPart) assert part.tool_name == "get_weather" assert part.tool_args == '{"ci' assert part.state == "streaming" @@ -89,12 +89,12 @@ def test_tool_lifecycle() -> None: m = h.handle_event(ToolArgsDelta(tool_call_id="tc1", delta='ty":"London"}')) part = m.parts[0] - assert isinstance(part, ToolPart) + assert isinstance(part, ToolCallPart) assert part.tool_args == '{"city":"London"}' m = h.handle_event(ToolEnd(tool_call_id="tc1")) part = m.parts[0] - assert isinstance(part, ToolPart) + assert isinstance(part, ToolCallPart) assert part.state == "done" assert part.args_delta is None @@ -120,11 +120,11 @@ def test_reasoning_then_text_then_tool() -> None: assert len(m.parts) == 3 assert isinstance(m.parts[0], ReasoningPart) assert isinstance(m.parts[1], TextPart) - assert isinstance(m.parts[2], ToolPart) + assert isinstance(m.parts[2], ToolCallPart) assert all( p.state == "done" for p in m.parts - if isinstance(p, (TextPart, ToolPart, ReasoningPart)) + if isinstance(p, (TextPart, ToolCallPart, ReasoningPart)) ) @@ -136,7 +136,7 @@ def test_multiple_tool_calls() -> None: m = h.handle_event(ToolArgsDelta(tool_call_id="tc1", delta='{"path":"a.py"}')) # Both tools should be in parts - tool_parts = [p for p in m.parts if isinstance(p, ToolPart)] + tool_parts = [p for p in m.parts if isinstance(p, ToolCallPart)] assert len(tool_parts) == 2 # tc1 has args, tc2 is empty assert tool_parts[0].tool_args == '{"path":"a.py"}' @@ -148,7 +148,7 @@ def test_multiple_tool_calls() -> None: assert all( p.state == "done" for p in m.parts - if isinstance(p, (TextPart, ToolPart, ReasoningPart)) + if isinstance(p, (TextPart, ToolCallPart, ReasoningPart)) ) diff --git a/tests/telemetry/test_otel_handler.py b/tests/telemetry/test_otel_handler.py index a30dcf7c..40c0fc2b 100644 --- a/tests/telemetry/test_otel_handler.py +++ b/tests/telemetry/test_otel_handler.py @@ -12,7 +12,7 @@ import vercel_ai_sdk as ai from vercel_ai_sdk.telemetry.otel import OtelHandler -from ..conftest import MOCK_MODEL, mock_llm, text_msg, tool_msg +from ..conftest import MOCK_MODEL, mock_llm, text_msg, tool_call_msg @pytest.fixture @@ -35,11 +35,11 @@ async def double(x: int) -> int: @pytest.mark.asyncio async def test_text_only_spans(spans: InMemorySpanExporter) -> None: """Text-only run produces ai.run > ai.stream span hierarchy.""" - my_agent = ai.agent(model=MOCK_MODEL, tools=[]) + my_agent = ai.agent() mock_llm([[text_msg("Hello!")]]) - result = my_agent.run(ai.make_messages(user="Hi")) - [m async for m in result] + async for _m in my_agent.run(MOCK_MODEL, ai.make_messages(user="Hi")): + pass finished = spans.get_finished_spans() names = [s.name for s in finished] @@ -64,16 +64,16 @@ async def test_text_only_spans(spans: InMemorySpanExporter) -> None: @pytest.mark.asyncio async def test_tool_call_spans(spans: InMemorySpanExporter) -> None: """Tool-calling run produces ai.tool spans with correct attributes.""" - my_agent = ai.agent(model=MOCK_MODEL, tools=[double]) + my_agent = ai.agent(tools=[double]) mock_llm( [ - [tool_msg(tc_id="tc-1", name="double", args='{"x": 5}')], + [tool_call_msg(tc_id="tc-1", name="double", args='{"x": 5}')], [text_msg("10")], ] ) - result = my_agent.run(ai.make_messages(user="Double 5")) - [m async for m in result] + async for _m in my_agent.run(MOCK_MODEL, ai.make_messages(user="Double 5")): + pass finished = spans.get_finished_spans() names = [s.name for s in finished] diff --git a/tests/telemetry/test_telemetry.py b/tests/telemetry/test_telemetry.py index b1b06eb3..640055d4 100644 --- a/tests/telemetry/test_telemetry.py +++ b/tests/telemetry/test_telemetry.py @@ -3,7 +3,7 @@ from __future__ import annotations import dataclasses -from collections.abc import Generator +from collections.abc import AsyncGenerator, Generator from typing import Any import pytest @@ -17,7 +17,7 @@ ToolCallStartEvent, ) -from ..conftest import MOCK_MODEL, mock_llm, text_msg, tool_msg +from ..conftest import MOCK_MODEL, mock_llm, text_msg, tool_call_msg # ── Recording handler ──────────────────────────────────────────── @@ -55,11 +55,11 @@ async def double(x: int) -> int: @pytest.mark.asyncio async def test_text_only_run_events(handler: RecordingHandler) -> None: """Simplest run emits RunStart, StepStart, StepFinish, RunFinish.""" - my_agent = ai.agent(model=MOCK_MODEL, tools=[]) + my_agent = ai.agent() mock_llm([[text_msg("Hello!")]]) - result = my_agent.run(ai.make_messages(user="Hi")) - [m async for m in result] + async for _m in my_agent.run(MOCK_MODEL, ai.make_messages(user="Hi")): + pass types = [type(e).__name__ for e in handler.events] assert types == [ @@ -77,16 +77,16 @@ async def test_text_only_run_events(handler: RecordingHandler) -> None: @pytest.mark.asyncio async def test_tool_call_events(handler: RecordingHandler) -> None: """Tool-calling run emits tool events between steps.""" - my_agent = ai.agent(model=MOCK_MODEL, tools=[double]) + my_agent = ai.agent(tools=[double]) mock_llm( [ - [tool_msg(tc_id="tc-1", name="double", args='{"x": 5}')], + [tool_call_msg(tc_id="tc-1", name="double", args='{"x": 5}')], [text_msg("10")], ] ) - result = my_agent.run(ai.make_messages(user="Double 5")) - [m async for m in result] + async for _m in my_agent.run(MOCK_MODEL, ai.make_messages(user="Double 5")): + pass types = [type(e).__name__ for e in handler.events] assert types == [ @@ -125,11 +125,11 @@ def handle(self, event: TelemetryEvent) -> None: ai.telemetry.enable(Capture()) try: - my_agent = ai.agent(model=MOCK_MODEL, tools=[]) + my_agent = ai.agent() mock_llm([[text_msg("Hello!")]]) - result = my_agent.run(ai.make_messages(user="Hi")) - [m async for m in result] + async for _m in my_agent.run(MOCK_MODEL, ai.make_messages(user="Hi")): + pass assert len(captured) == 16 finally: ai.telemetry.disable() @@ -144,19 +144,19 @@ async def test_disable_reverts_to_noop() -> None: handler = RecordingHandler() ai.telemetry.enable(handler) - my_agent = ai.agent(model=MOCK_MODEL, tools=[]) + my_agent = ai.agent() mock_llm([[text_msg("Hello!")]]) - result = my_agent.run(ai.make_messages(user="Hi")) - [m async for m in result] + async for _m in my_agent.run(MOCK_MODEL, ai.make_messages(user="Hi")): + pass assert len(handler.of_type(RunStartEvent)) == 1 ai.telemetry.disable() handler.events.clear() mock_llm([[text_msg("Hello!")]]) - result = my_agent.run(ai.make_messages(user="Hi")) - [m async for m in result] + async for _m in my_agent.run(MOCK_MODEL, ai.make_messages(user="Hi")): + pass assert len(handler.events) == 0 @@ -171,16 +171,17 @@ async def test_user_emitted_custom_event(handler: RecordingHandler) -> None: class CustomEvent(TelemetryEvent): message: str - my_agent = ai.agent(model=MOCK_MODEL, tools=[]) + my_agent = ai.agent() @my_agent.loop - async def custom(agent: ai.Agent, msgs: list[ai.Message]) -> ai.StreamResult: + async def custom(context: ai.Context) -> AsyncGenerator[ai.Message]: ai.telemetry.handle(CustomEvent(message="hello")) - return await ai.stream_step(agent.model, msgs) + async for msg in await ai.models.stream(context.model, context.messages): + yield msg mock_llm([[text_msg("Hello!")]]) - result = my_agent.run(ai.make_messages(user="Hi")) - [m async for m in result] + async for _m in my_agent.run(MOCK_MODEL, ai.make_messages(user="Hi")): + pass custom_events = [e for e in handler.events if isinstance(e, CustomEvent)] assert len(custom_events) == 1 @@ -193,16 +194,17 @@ async def custom(agent: ai.Agent, msgs: list[ai.Message]) -> ai.StreamResult: @pytest.mark.asyncio async def test_run_error_in_finish_event(handler: RecordingHandler) -> None: """RunFinishEvent captures the error when the loop function raises.""" - my_agent = ai.agent(model=MOCK_MODEL, tools=[]) + my_agent = ai.agent() @my_agent.loop - async def failing(agent: ai.Agent, msgs: list[ai.Message]) -> None: + async def failing(context: ai.Context) -> AsyncGenerator[ai.Message]: raise ValueError("boom") + yield # make it a generator mock_llm([]) - result = my_agent.run(ai.make_messages(user="Hi")) with pytest.raises(ExceptionGroup): - [m async for m in result] + async for _m in my_agent.run(MOCK_MODEL, ai.make_messages(user="Hi")): + pass run_finish = handler.of_type(RunFinishEvent)[0] assert run_finish.error is not None diff --git a/tests/types/test_builders.py b/tests/types/test_builders.py index 00dc6557..5c6de420 100644 --- a/tests/types/test_builders.py +++ b/tests/types/test_builders.py @@ -13,7 +13,7 @@ FilePart, ReasoningPart, TextPart, - ToolPart, + ToolCallPart, ) # -- system_message -------------------------------------------------------- @@ -77,11 +77,11 @@ def test_assistant_message_with_thinking() -> None: assert isinstance(msg.parts[1], TextPart) -def test_assistant_message_with_tool_part() -> None: - tool = ToolPart(tool_call_id="tc-1", tool_name="test", tool_args="{}") +def test_assistant_message_with_tool_call_part() -> None: + tool = ToolCallPart(tool_call_id="tc-1", tool_name="test", tool_args="{}") msg = assistant_message("calling tool", tool) assert len(msg.parts) == 2 - assert isinstance(msg.parts[1], ToolPart) + assert isinstance(msg.parts[1], ToolCallPart) # -- file_part ------------------------------------------------------------- diff --git a/tests/types/test_messages.py b/tests/types/test_messages.py index 791c89ba..7c8ef3b1 100644 --- a/tests/types/test_messages.py +++ b/tests/types/test_messages.py @@ -1,4 +1,4 @@ -"""Message model: properties, immutability, part IDs, ToolPart.with_result/with_error, +"""Message model: properties, immutability, part IDs, ToolCallPart, ToolResultPart, Message.replace, make_messages, StructuredOutputPart, FilePart.""" import pydantic @@ -11,7 +11,8 @@ ReasoningPart, StructuredOutputPart, TextPart, - ToolPart, + ToolCallPart, + ToolResultPart, Usage, make_messages, ) @@ -34,7 +35,9 @@ def test_is_done_all_done() -> None: role="assistant", parts=[ TextPart(text="hello", state="done"), - ToolPart(tool_call_id="tc1", tool_name="t", tool_args="{}", state="done"), + ToolCallPart( + tool_call_id="tc1", tool_name="t", tool_args="{}", state="done" + ), ], ) assert m.is_done is True @@ -74,7 +77,7 @@ def test_text_empty_when_no_text_parts() -> None: m = Message( id="m1", role="assistant", - parts=[ToolPart(tool_call_id="tc1", tool_name="t", tool_args="{}")], + parts=[ToolCallPart(tool_call_id="tc1", tool_name="t", tool_args="{}")], ) assert m.text == "" @@ -119,7 +122,7 @@ def test_tool_deltas() -> None: id="m1", role="assistant", parts=[ - ToolPart( + ToolCallPart( tool_call_id="tc1", tool_name="search", tool_args='{"q":"te', @@ -143,8 +146,8 @@ def test_tool_calls() -> None: role="assistant", parts=[ TextPart(text="hi"), - ToolPart(tool_call_id="tc1", tool_name="a", tool_args="{}"), - ToolPart(tool_call_id="tc2", tool_name="b", tool_args="{}"), + ToolCallPart(tool_call_id="tc1", tool_name="a", tool_args="{}"), + ToolCallPart(tool_call_id="tc2", tool_name="b", tool_args="{}"), ], ) assert len(m.tool_calls) == 2 @@ -182,49 +185,34 @@ def test_get_hook_part_missing() -> None: def test_frozen_rejects_field_mutation() -> None: """Frozen models reject direct attribute assignment.""" - tp = ToolPart(tool_call_id="tc1", tool_name="t", tool_args="{}") + tc = ToolCallPart(tool_call_id="tc1", tool_name="t", tool_args="{}") with pytest.raises(pydantic.ValidationError): - tp.status = "result" # type: ignore[misc] + tc.tool_name = "other" # type: ignore[misc] m = Message(id="m1", role="assistant", parts=[TextPart(text="hi")]) with pytest.raises(pydantic.ValidationError): m.label = "test" # type: ignore[misc] -# -- ToolPart.with_result / with_error ------------------------------------ - - -def test_with_result() -> None: - tp = ToolPart(tool_call_id="tc1", tool_name="t", tool_args="{}") - assert tp.status == "pending" - updated = tp.with_result({"answer": 42}) - assert updated.status == "result" - assert updated.result == {"answer": 42} - assert updated.id == tp.id # id preserved - # Original unchanged - assert tp.status == "pending" - assert tp.result is None +# -- ToolResultPart -------------------------------------------------------- -def test_with_error() -> None: - tp = ToolPart(tool_call_id="tc1", tool_name="t", tool_args="{}") - updated = tp.with_error("Something went wrong") - assert updated.status == "error" - assert updated.result == "Something went wrong" - assert updated.id == tp.id # id preserved - # Original unchanged - assert tp.status == "pending" +def test_tool_result_part() -> None: + tr = ToolResultPart(tool_call_id="tc1", tool_name="t", result={"answer": 42}) + assert tr.tool_call_id == "tc1" + assert tr.result == {"answer": 42} + assert tr.is_error is False -def test_with_result_rejects_non_pending() -> None: - """with_result / with_error reject non-pending tool calls.""" - tp = ToolPart( - tool_call_id="tc1", tool_name="t", tool_args="{}", status="result", result=42 +def test_tool_result_part_error() -> None: + tr = ToolResultPart( + tool_call_id="tc1", + tool_name="t", + result="Something went wrong", + is_error=True, ) - with pytest.raises(ValueError, match="already has status"): - tp.with_result("new result") - with pytest.raises(ValueError, match="already has status"): - tp.with_error("oops") + assert tr.is_error is True + assert tr.result == "Something went wrong" # -- Part ids -------------------------------------------------------------- @@ -233,7 +221,7 @@ def test_with_result_rejects_non_pending() -> None: def test_parts_have_auto_generated_ids() -> None: """All parts get an auto-generated id.""" text = TextPart(text="hi") - tool = ToolPart(tool_call_id="tc1", tool_name="t", tool_args="{}") + tool = ToolCallPart(tool_call_id="tc1", tool_name="t", tool_args="{}") reasoning = ReasoningPart(text="thinking") assert text.id # non-empty assert tool.id @@ -252,20 +240,17 @@ def test_part_id_explicit() -> None: def test_replace() -> None: - tp = ToolPart(id="p1", tool_call_id="tc1", tool_name="t", tool_args="{}") + old_text = TextPart(id="p0", text="hello") m = Message( id="m1", role="assistant", - parts=[TextPart(id="p0", text="hi"), tp], + parts=[old_text, TextPart(id="p1", text="world")], ) - updated_tp = tp.with_result({"answer": 42}) - updated_m = m.replace(updated_tp) - tc = next(p for p in updated_m.parts if isinstance(p, ToolPart)) - assert tc.status == "result" - assert tc.result == {"answer": 42} + new_text = TextPart(id="p0", text="updated") + updated_m = m.replace(new_text) + assert updated_m.parts[0].text == "updated" # type: ignore[union-attr] # Original unchanged - orig_tc = next(p for p in m.parts if isinstance(p, ToolPart)) - assert orig_tc.status == "pending" + assert m.parts[0].text == "hello" # type: ignore[union-attr] def test_replace_missing_id() -> None: