From 6d64b9ba0ffba05729f4ffb42c7832da928a676e Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Sun, 10 May 2026 14:39:34 -0700 Subject: [PATCH 1/3] Make `stream` take only keyword arguments Update all callers in src/, tests/, examples/, and README to pass `model=` and `messages=` explicitly. --- README.md | 6 +++- examples/fastapi-vite/backend/agent.py | 4 ++- examples/multiagent-textual/server.py | 6 +++- examples/samples/agent_custom_loop.py | 4 ++- examples/samples/agent_hooks.py | 6 +++- examples/samples/agent_hooks_inline.py | 6 +++- examples/samples/agent_hooks_serverless.py | 4 ++- examples/samples/builtin_web_search.py | 2 +- .../samples/builtin_web_search_gateway.py | 8 ++--- examples/samples/explicit_client.py | 2 +- examples/samples/inline_image.py | 2 +- examples/samples/model_params.py | 2 +- examples/samples/multimodal_input.py | 2 +- examples/samples/stream.py | 2 +- examples/samples/stream_all.py | 2 +- examples/samples/tools_schema.py | 2 +- examples/temporal-direct/main.py | 2 +- examples/temporal-middleware/main.py | 2 +- src/ai/agents/agent.py | 4 ++- src/ai/models/__init__.py | 4 +-- src/ai/models/ai_gateway/__init__.py | 8 ++--- src/ai/models/anthropic/__init__.py | 3 +- src/ai/models/core/api.py | 4 +-- tests/agents/test_hooks.py | 20 +++++++++---- tests/models/ai_gateway/test_stream.py | 12 ++++---- tests/models/core/test_api.py | 29 ++++++++++++------- tests/test_middleware.py | 4 ++- tests/types/test_integrity.py | 4 +-- 28 files changed, 101 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index 56496df7..78a3beb4 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,11 @@ Override the default loop when you need approval gates, routing, or custom orche @agent.loop async def custom(context: ai.Context): while True: - async with ai.stream(context.model, context.messages, tools=context.tools) as s: + async with ai.stream( + model=context.model, + messages=context.messages, + tools=context.tools, + ) as s: async for event in s: yield event context.add(s.message) diff --git a/examples/fastapi-vite/backend/agent.py b/examples/fastapi-vite/backend/agent.py index e89e1c08..b92115a6 100644 --- a/examples/fastapi-vite/backend/agent.py +++ b/examples/fastapi-vite/backend/agent.py @@ -57,7 +57,9 @@ async def graph(context: ai.Context) -> AsyncGenerator[ai.events.AgentEvent]: """ while context.keep_running(): async with ai.models.stream( - context.model, context.messages, tools=context.tools + model=context.model, + messages=context.messages, + tools=context.tools, ) as s: async for event in s: yield event diff --git a/examples/multiagent-textual/server.py b/examples/multiagent-textual/server.py index 43919d4d..d080acb1 100644 --- a/examples/multiagent-textual/server.py +++ b/examples/multiagent-textual/server.py @@ -110,7 +110,11 @@ def _gated_agent( async def gated_loop(context: ai.Context) -> AsyncGenerator[ai.events.AgentEvent]: while context.keep_running(): async with ( - ai.stream(context.model, context.messages, tools=context.tools) as s, + ai.stream( + model=context.model, + messages=context.messages, + tools=context.tools, + ) as s, ai.agents.ToolRunner() as tr, ): async for event in ai.util.merge(s, tr.events()): diff --git a/examples/samples/agent_custom_loop.py b/examples/samples/agent_custom_loop.py index 1ffe5fe3..da776fe1 100644 --- a/examples/samples/agent_custom_loop.py +++ b/examples/samples/agent_custom_loop.py @@ -28,7 +28,9 @@ async def default_loop( while context.keep_running(): async with ( ai.models.stream( - context.model, context.messages, tools=context.tools + model=context.model, + messages=context.messages, + tools=context.tools, ) as stream, ai.ToolRunner() as tr, ): diff --git a/examples/samples/agent_hooks.py b/examples/samples/agent_hooks.py index dc50c9a6..3c406b7a 100644 --- a/examples/samples/agent_hooks.py +++ b/examples/samples/agent_hooks.py @@ -70,7 +70,11 @@ async def with_approval( ) -> AsyncGenerator[ai.events.AgentEvent]: while context.keep_running(): async with ( - ai.stream(context.model, context.messages, tools=context.tools) as s, + ai.stream( + model=context.model, + messages=context.messages, + tools=context.tools, + ) as s, ai.ToolRunner() as tr, ): async for event in ai.util.merge(s, tr.events()): diff --git a/examples/samples/agent_hooks_inline.py b/examples/samples/agent_hooks_inline.py index aa9f0cf0..ebb702df 100644 --- a/examples/samples/agent_hooks_inline.py +++ b/examples/samples/agent_hooks_inline.py @@ -46,7 +46,11 @@ async def with_approval( ) -> AsyncGenerator[ai.events.AgentEvent]: while context.keep_running(): async with ( - ai.stream(context.model, context.messages, tools=context.tools) as s, + ai.stream( + model=context.model, + messages=context.messages, + tools=context.tools, + ) as s, ai.ToolRunner() as tr, ): async for event in ai.util.merge(s, tr.events()): diff --git a/examples/samples/agent_hooks_serverless.py b/examples/samples/agent_hooks_serverless.py index 8a958a9b..037de42b 100644 --- a/examples/samples/agent_hooks_serverless.py +++ b/examples/samples/agent_hooks_serverless.py @@ -48,7 +48,9 @@ async def with_confirmation( ) -> AsyncGenerator[ai.events.AgentEvent]: while context.keep_running(): async with ai.models.stream( - context.model, context.messages, tools=context.tools + model=context.model, + messages=context.messages, + tools=context.tools, ) as s: async for event in s: yield event diff --git a/examples/samples/builtin_web_search.py b/examples/samples/builtin_web_search.py index 460d0849..fedebd77 100644 --- a/examples/samples/builtin_web_search.py +++ b/examples/samples/builtin_web_search.py @@ -48,7 +48,7 @@ def format(value: object) -> str: async def main() -> None: - async with ai.stream(model, messages, tools=tools) as s: + async with ai.stream(model=model, messages=messages, tools=tools) as s: async for event in s: match event: case ai.events.TextDelta(): diff --git a/examples/samples/builtin_web_search_gateway.py b/examples/samples/builtin_web_search_gateway.py index ed2263e1..f875f4ae 100644 --- a/examples/samples/builtin_web_search_gateway.py +++ b/examples/samples/builtin_web_search_gateway.py @@ -40,8 +40,8 @@ def format(value: object) -> str: async def main() -> None: print("anthropic web search") async with ai.stream( - model, - messages, + model=model, + messages=messages, tools=[ai.anthropic.tools.web_search(max_uses=3)], ) as s: async for event in s: @@ -59,8 +59,8 @@ async def main() -> None: print("perplexity web search") async with ai.stream( - model, - messages, + model=model, + messages=messages, tools=[ai.ai_gateway.tools.perplexity_search(max_results=5)], ) as s: async for event in s: diff --git a/examples/samples/explicit_client.py b/examples/samples/explicit_client.py index 60254a6b..69493ee6 100644 --- a/examples/samples/explicit_client.py +++ b/examples/samples/explicit_client.py @@ -19,7 +19,7 @@ async def main() -> None: try: - async with ai.stream(model, messages) as s: + async with ai.stream(model=model, messages=messages) as s: async for event in s: if isinstance(event, ai.events.TextDelta): print(event.chunk, end="", flush=True) diff --git a/examples/samples/inline_image.py b/examples/samples/inline_image.py index 218dca37..77979b76 100644 --- a/examples/samples/inline_image.py +++ b/examples/samples/inline_image.py @@ -25,7 +25,7 @@ async def main() -> None: # Stream — text deltas arrive as TextDelta events, generated images # arrive as FileEvent events and accumulate on s.message. - async with ai.stream(model, messages) as s: + async with ai.stream(model=model, messages=messages) as s: async for event in s: if isinstance(event, ai.events.TextDelta): print(event.chunk, end="", flush=True) diff --git a/examples/samples/model_params.py b/examples/samples/model_params.py index 41f9fbb1..937ed97d 100644 --- a/examples/samples/model_params.py +++ b/examples/samples/model_params.py @@ -19,7 +19,7 @@ async def main() -> None: GatewayParams(sort="cost"), AnthropicParams(speed="fast"), ] - async with ai.stream(model, messages, params=params) as stream: + async with ai.stream(model=model, messages=messages, params=params) as stream: async for event in stream: if isinstance(event, ai.events.TextDelta): print(event.chunk, end="", flush=True) diff --git a/examples/samples/multimodal_input.py b/examples/samples/multimodal_input.py index ada446e1..40c4b976 100644 --- a/examples/samples/multimodal_input.py +++ b/examples/samples/multimodal_input.py @@ -19,7 +19,7 @@ async def main() -> None: - async with ai.stream(model, messages) as s: + async with ai.stream(model=model, messages=messages) as s: async for event in s: if isinstance(event, ai.events.TextDelta): print(event.chunk, end="", flush=True) diff --git a/examples/samples/stream.py b/examples/samples/stream.py index 856f2995..29bffd6d 100644 --- a/examples/samples/stream.py +++ b/examples/samples/stream.py @@ -13,7 +13,7 @@ async def main() -> None: - async with ai.stream(model, messages) as s: + async with ai.stream(model=model, messages=messages) as s: async for event in s: if isinstance(event, ai.events.TextDelta): print(event.chunk, end="", flush=True) diff --git a/examples/samples/stream_all.py b/examples/samples/stream_all.py index 4627c3e2..cc235052 100644 --- a/examples/samples/stream_all.py +++ b/examples/samples/stream_all.py @@ -27,7 +27,7 @@ async def _run(name: str, provider: ai.Provider[Any], model_id: str) -> None: model = provider(model_id) try: - async with ai.stream(model, messages) as s: + async with ai.stream(model=model, messages=messages) as s: async for event in s: if isinstance(event, ai.events.TextDelta): print(event.chunk, end="", flush=True) diff --git a/examples/samples/tools_schema.py b/examples/samples/tools_schema.py index 081a9bc2..993c3bd9 100644 --- a/examples/samples/tools_schema.py +++ b/examples/samples/tools_schema.py @@ -27,7 +27,7 @@ async def main() -> None: # Stream with tools — the model may emit tool calls. - async with ai.stream(model, messages, tools=[get_weather]) as s: + async with ai.stream(model=model, messages=messages, tools=[get_weather]) as s: async for event in s: if isinstance(event, ai.events.TextDelta): print(event.chunk, end="", flush=True) diff --git a/examples/temporal-direct/main.py b/examples/temporal-direct/main.py index 23ef9fc7..e6d79fb3 100644 --- a/examples/temporal-direct/main.py +++ b/examples/temporal-direct/main.py @@ -107,7 +107,7 @@ async def llm_call_activity(params: LLMParams) -> LLMResult: for t in params.tool_schemas ] - async with ai.models.stream(model, messages, tools=tools) as s: + async with ai.models.stream(model=model, messages=messages, tools=tools) as s: async for _event in s: pass if s.message is None: diff --git a/examples/temporal-middleware/main.py b/examples/temporal-middleware/main.py index 8b8483d2..ea502832 100644 --- a/examples/temporal-middleware/main.py +++ b/examples/temporal-middleware/main.py @@ -124,7 +124,7 @@ async def llm_call_activity(params: LLMParams) -> LLMResult: for t in params.tool_schemas ] - async with ai.models.stream(model, messages, tools=tools) as s: + async with ai.models.stream(model=model, messages=messages, tools=tools) as s: async for _event in s: pass if s.message is None: diff --git a/src/ai/agents/agent.py b/src/ai/agents/agent.py index b8ce72d2..ead71c0b 100644 --- a/src/ai/agents/agent.py +++ b/src/ai/agents/agent.py @@ -722,7 +722,9 @@ async def default_loop( while context.keep_running(): async with ( models.stream( - context.model, context.messages, tools=context.tools + model=context.model, + messages=context.messages, + tools=context.tools, ) as stream, ToolRunner() as tr, ): diff --git a/src/ai/models/__init__.py b/src/ai/models/__init__.py index 0b371104..e0454c4a 100644 --- a/src/ai/models/__init__.py +++ b/src/ai/models/__init__.py @@ -11,7 +11,7 @@ # stream — auto-creates client from env vars msgs = [ai.user_message("hello")] - async with ai.stream(model, msgs) as s: + async with ai.stream(model=model, messages=msgs) as s: async for event in s: if isinstance(event, ai.events.TextDelta): print(event.chunk, end="", flush=True) @@ -19,7 +19,7 @@ # explicit client for custom auth client = ai.Client(base_url="https://custom.example.com/v1", api_key="sk-...") model = openai("gpt-5.4", client=client) - async with ai.stream(model, msgs) as s: + async with ai.stream(model=model, messages=msgs) as s: ... # list available models diff --git a/src/ai/models/ai_gateway/__init__.py b/src/ai/models/ai_gateway/__init__.py index 4347a1b1..9947668f 100644 --- a/src/ai/models/ai_gateway/__init__.py +++ b/src/ai/models/ai_gateway/__init__.py @@ -10,8 +10,8 @@ # Provider-specific request options and built-in tools come from the # native packages and are forwarded through the gateway transparently. async with ai.stream( - model, - msgs, + model=model, + messages=msgs, params=[anthropic.AnthropicParams(speed="fast")], tools=[anthropic.tools.web_search(max_uses=5)], ) as s: @@ -20,8 +20,8 @@ # The gateway also exposes its own provider-executed tools that work # with any gateway-routed model regardless of the underlying provider. async with ai.stream( - model, - msgs, + model=model, + messages=msgs, tools=[ai_gateway.tools.perplexity_search(max_results=5)], ) as s: ... diff --git a/src/ai/models/anthropic/__init__.py b/src/ai/models/anthropic/__init__.py index 7b101afb..cf2db674 100644 --- a/src/ai/models/anthropic/__init__.py +++ b/src/ai/models/anthropic/__init__.py @@ -9,7 +9,8 @@ # built-in tools async with ai.stream( - model, msgs, + model=model, + messages=msgs, tools=[anthropic.tools.web_search(max_uses=5)], ) as s: ... diff --git a/src/ai/models/core/api.py b/src/ai/models/core/api.py index 18c581a9..4ff91654 100644 --- a/src/ai/models/core/api.py +++ b/src/ai/models/core/api.py @@ -331,9 +331,9 @@ async def _replay_tool_calls( @contextlib.asynccontextmanager async def stream[ProviderParamsT: pydantic.BaseModel]( + *, model: model_.Model[ProviderParamsT], messages: list[types.messages.Message], - *, tools: Sequence[types.tools.Tool] | None = None, output_type: type[pydantic.BaseModel] | None = None, params: params_.StreamParams[ProviderParamsT] | None = None, @@ -343,7 +343,7 @@ async def stream[ProviderParamsT: pydantic.BaseModel]( Used as an async context manager whose value is the :class:`Stream`:: - async with ai.stream(model, messages) as s: + async with ai.stream(model=model, messages=messages) as s: async for event in s: ... print(s.message) diff --git a/tests/agents/test_hooks.py b/tests/agents/test_hooks.py index 4c9623d7..531c6a7a 100644 --- a/tests/agents/test_hooks.py +++ b/tests/agents/test_hooks.py @@ -31,7 +31,9 @@ async def test_resolve_live_future() -> None: @my_agent.loop async def custom(context: ai.Context) -> AsyncGenerator[ai.events.Event]: nonlocal resolved_value - async with ai.models.stream(context.model, context.messages) as stream: + async with ai.models.stream( + model=context.model, messages=context.messages + ) as stream: async for event in stream: yield event result = await ai.hook("confirm_1", payload=Confirmation) @@ -63,7 +65,9 @@ async def test_cancel_live_hook() -> None: @my_agent.loop async def custom(context: ai.Context) -> AsyncGenerator[ai.events.Event]: nonlocal was_cancelled - async with ai.models.stream(context.model, context.messages) as stream: + async with ai.models.stream( + model=context.model, messages=context.messages + ) as stream: async for event in stream: yield event try: @@ -102,7 +106,9 @@ async def test_pre_registered_resolution_consumed() -> None: @my_agent.loop async def custom(context: ai.Context) -> AsyncGenerator[ai.events.Event]: nonlocal resolved_value - async with ai.models.stream(context.model, context.messages) as stream: + async with ai.models.stream( + model=context.model, messages=context.messages + ) as stream: async for event in stream: yield event resolved_value = await ai.hook("pre_reg_1", payload=Confirmation) @@ -142,7 +148,9 @@ async def test_resolved_hook_emits_message() -> None: @my_agent.loop async def custom(context: ai.Context) -> AsyncGenerator[ai.events.Event]: - async with ai.models.stream(context.model, context.messages) as stream: + async with ai.models.stream( + model=context.model, messages=context.messages + ) as stream: async for event in stream: yield event await ai.hook("emit_test", payload=Confirmation) @@ -171,7 +179,9 @@ async def test_hook_metadata_in_pending() -> None: @my_agent.loop async def custom(context: ai.Context) -> AsyncGenerator[ai.events.Event]: - async with ai.models.stream(context.model, context.messages) as stream: + async with ai.models.stream( + model=context.model, messages=context.messages + ) as stream: async for event in stream: yield event await ai.hook( diff --git a/tests/models/ai_gateway/test_stream.py b/tests/models/ai_gateway/test_stream.py index 5208ec51..a046c871 100644 --- a/tests/models/ai_gateway/test_stream.py +++ b/tests/models/ai_gateway/test_stream.py @@ -353,8 +353,8 @@ def handler(req: httpx.Request) -> httpx.Response: ), ] async with models.stream( - model, - [user_msg("Hi")], + model=model, + messages=[user_msg("Hi")], params=request_params, ) as stream: async for _ in stream: @@ -389,8 +389,8 @@ def handler(req: httpx.Request) -> httpx.Response: ] with pytest.raises(ValueError, match="duplicate provider params for 'openai'"): async with models.stream( - model, - [user_msg("Hi")], + model=model, + messages=[user_msg("Hi")], params=request_params, ) as stream: async for _ in stream: @@ -411,8 +411,8 @@ def handler(req: httpx.Request) -> httpx.Response: ] with pytest.raises(ValueError, match="duplicate provider params for 'openai'"): async with models.stream( - model, - [user_msg("Hi")], + model=model, + messages=[user_msg("Hi")], params=request_params, ) as stream: async for _ in stream: diff --git a/tests/models/core/test_api.py b/tests/models/core/test_api.py index 8d2aedaf..0a5c7fcf 100644 --- a/tests/models/core/test_api.py +++ b/tests/models/core/test_api.py @@ -35,7 +35,9 @@ async def test_stream_aggregates_registered_adapter_events() -> None: mock = mock_llm([[text_msg("Hello world")]]) deltas: list[str] = [] - async with models.stream(MOCK_MODEL, [ai.user_message("Hi")]) as stream: + async with models.stream( + model=MOCK_MODEL, messages=[ai.user_message("Hi")] + ) as stream: async for event in stream: if isinstance(event, events_.TextDelta): deltas.append(event.chunk) @@ -68,7 +70,9 @@ async def _tool_stream( models.register_stream("mock", _tool_stream) tool_end: events_.ToolEnd | None = None - async with models.stream(MOCK_MODEL, [ai.user_message("Check weather")]) as stream: + async with models.stream( + model=MOCK_MODEL, messages=[ai.user_message("Check weather")] + ) as stream: async for event in stream: if isinstance(event, events_.ToolEnd): tool_end = event @@ -188,7 +192,7 @@ async def _spy_stream( provider=MOCK_PROVIDER, client=explicit, ) - async with models.stream(model, [ai.user_message("Hi")]) as stream: + async with models.stream(model=model, messages=[ai.user_message("Hi")]) as stream: async for _ in stream: pass @@ -220,8 +224,8 @@ async def _spy_stream( params = _MockStreamParams(value="ok") async with models.stream( - MOCK_MODEL, - [ai.user_message("Hi")], + model=MOCK_MODEL, + messages=[ai.user_message("Hi")], output_type=Answer, params=params, ) as stream: @@ -236,8 +240,8 @@ async def test_normalize_params_rejects_non_pydantic_value() -> None: """``stream(...)`` rejects raw dicts (and anything not a BaseModel).""" with pytest.raises(TypeError, match="pydantic BaseModel"): async with models.stream( - openai("gpt-5.4"), - [ai.user_message("Hi")], + model=openai("gpt-5.4"), + messages=[ai.user_message("Hi")], params=cast(Any, {"reasoning_effort": "high"}), ): pass @@ -336,8 +340,11 @@ async def _spy_stream( ) async with models.stream( - MOCK_MODEL, - [ai.user_message("Hi"), assistant_msg.model_copy(update={"replay": True})], + model=MOCK_MODEL, + messages=[ + ai.user_message("Hi"), + assistant_msg.model_copy(update={"replay": True}), + ], ) as stream: events: list[events_.Event] = [event async for event in stream] @@ -396,7 +403,9 @@ async def _spy_stream( parts=[messages_.TextPart(text="just talking")], ) - async with models.stream(MOCK_MODEL, [assistant_text_only]) as stream: + async with models.stream( + model=MOCK_MODEL, messages=[assistant_text_only] + ) as stream: async for _ in stream: pass diff --git a/tests/test_middleware.py b/tests/test_middleware.py index 14118b70..b1c70cdd 100644 --- a/tests/test_middleware.py +++ b/tests/test_middleware.py @@ -79,7 +79,9 @@ async def wrap_hook(self, call: middleware.HookContext, next: Any) -> Any: @my_agent.loop async def custom(context: ai.Context) -> AsyncGenerator[ai.events.Event]: - async with ai.models.stream(context.model, context.messages) as stream: + async with ai.models.stream( + model=context.model, messages=context.messages + ) as stream: async for event in stream: yield event await ai.hook("test_hook", payload=Confirmation) diff --git a/tests/types/test_integrity.py b/tests/types/test_integrity.py index 4eaaaadc..2ba0f076 100644 --- a/tests/types/test_integrity.py +++ b/tests/types/test_integrity.py @@ -482,7 +482,7 @@ async def test_stream_calls_prepare_messages() -> None: with patch( "ai.models.core.api.integrity.prepare_messages", wraps=lambda m: m ) as spy: - async with models.stream(MOCK_MODEL, msgs) as s: + async with models.stream(model=MOCK_MODEL, messages=msgs) as s: async for _ in s: pass spy.assert_called_once_with(msgs) @@ -518,7 +518,7 @@ async def _spy_stream( messages.Message(role="internal", parts=[messages.TextPart(text="internal")]), ai.assistant_message("hello"), ] - async with models.stream(MOCK_MODEL, msgs) as s: + async with models.stream(model=MOCK_MODEL, messages=msgs) as s: async for _ in s: pass From e4d8302adca1b2ad00f8023c41d375458f2bf70f Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Sun, 10 May 2026 15:59:07 -0700 Subject: [PATCH 2/3] Let `stream` accept an `agents.Context` instead of model/messages/tools Adds a `StreamContext` Protocol (so the model layer doesn't have to import the agents package) and overloads `stream` to accept either a context positionally or explicit `model=`/`messages=`/`tools=` kwargs. The runtime check rejects mixing the two forms. Updates the default agent loop, all custom-loop examples, and the context-bearing tests to use the shorthand. --- README.md | 6 +- examples/fastapi-vite/backend/agent.py | 6 +- examples/multiagent-textual/server.py | 6 +- examples/samples/agent_custom_loop.py | 6 +- examples/samples/agent_hooks.py | 6 +- examples/samples/agent_hooks_inline.py | 6 +- examples/samples/agent_hooks_serverless.py | 6 +- src/ai/agents/agent.py | 6 +- src/ai/models/core/api.py | 86 +++++++++++++++++++--- tests/agents/test_hooks.py | 20 ++--- tests/models/core/test_api.py | 34 +++++++++ tests/test_middleware.py | 4 +- 12 files changed, 125 insertions(+), 67 deletions(-) diff --git a/README.md b/README.md index 78a3beb4..701d4493 100644 --- a/README.md +++ b/README.md @@ -107,11 +107,7 @@ Override the default loop when you need approval gates, routing, or custom orche @agent.loop async def custom(context: ai.Context): while True: - async with ai.stream( - model=context.model, - messages=context.messages, - tools=context.tools, - ) as s: + async with ai.stream(context) as s: async for event in s: yield event context.add(s.message) diff --git a/examples/fastapi-vite/backend/agent.py b/examples/fastapi-vite/backend/agent.py index b92115a6..8efe04d0 100644 --- a/examples/fastapi-vite/backend/agent.py +++ b/examples/fastapi-vite/backend/agent.py @@ -56,11 +56,7 @@ async def graph(context: ai.Context) -> AsyncGenerator[ai.events.AgentEvent]: Reject buttons and sends the decision back on the next request. """ while context.keep_running(): - async with ai.models.stream( - model=context.model, - messages=context.messages, - tools=context.tools, - ) as s: + async with ai.models.stream(context) as s: async for event in s: yield event context.add(s.message) diff --git a/examples/multiagent-textual/server.py b/examples/multiagent-textual/server.py index d080acb1..797fe209 100644 --- a/examples/multiagent-textual/server.py +++ b/examples/multiagent-textual/server.py @@ -110,11 +110,7 @@ def _gated_agent( async def gated_loop(context: ai.Context) -> AsyncGenerator[ai.events.AgentEvent]: while context.keep_running(): async with ( - ai.stream( - model=context.model, - messages=context.messages, - tools=context.tools, - ) as s, + ai.stream(context) as s, ai.agents.ToolRunner() as tr, ): async for event in ai.util.merge(s, tr.events()): diff --git a/examples/samples/agent_custom_loop.py b/examples/samples/agent_custom_loop.py index da776fe1..8736891b 100644 --- a/examples/samples/agent_custom_loop.py +++ b/examples/samples/agent_custom_loop.py @@ -27,11 +27,7 @@ async def default_loop( """Stream, execute tools with logging, repeat.""" while context.keep_running(): async with ( - ai.models.stream( - model=context.model, - messages=context.messages, - tools=context.tools, - ) as stream, + ai.models.stream(context) as stream, ai.ToolRunner() as tr, ): async for event in ai.util.merge(stream, tr.events()): diff --git a/examples/samples/agent_hooks.py b/examples/samples/agent_hooks.py index 3c406b7a..0920f56e 100644 --- a/examples/samples/agent_hooks.py +++ b/examples/samples/agent_hooks.py @@ -70,11 +70,7 @@ async def with_approval( ) -> AsyncGenerator[ai.events.AgentEvent]: while context.keep_running(): async with ( - ai.stream( - model=context.model, - messages=context.messages, - tools=context.tools, - ) as s, + ai.stream(context) as s, ai.ToolRunner() as tr, ): async for event in ai.util.merge(s, tr.events()): diff --git a/examples/samples/agent_hooks_inline.py b/examples/samples/agent_hooks_inline.py index ebb702df..d1ae7a92 100644 --- a/examples/samples/agent_hooks_inline.py +++ b/examples/samples/agent_hooks_inline.py @@ -46,11 +46,7 @@ async def with_approval( ) -> AsyncGenerator[ai.events.AgentEvent]: while context.keep_running(): async with ( - ai.stream( - model=context.model, - messages=context.messages, - tools=context.tools, - ) as s, + ai.stream(context) as s, ai.ToolRunner() as tr, ): async for event in ai.util.merge(s, tr.events()): diff --git a/examples/samples/agent_hooks_serverless.py b/examples/samples/agent_hooks_serverless.py index 037de42b..243f7aac 100644 --- a/examples/samples/agent_hooks_serverless.py +++ b/examples/samples/agent_hooks_serverless.py @@ -47,11 +47,7 @@ async def with_confirmation( context: ai.Context, ) -> AsyncGenerator[ai.events.AgentEvent]: while context.keep_running(): - async with ai.models.stream( - model=context.model, - messages=context.messages, - tools=context.tools, - ) as s: + async with ai.models.stream(context) as s: async for event in s: yield event diff --git a/src/ai/agents/agent.py b/src/ai/agents/agent.py index ead71c0b..1f1a0980 100644 --- a/src/ai/agents/agent.py +++ b/src/ai/agents/agent.py @@ -721,11 +721,7 @@ async def default_loop( """Stream, execute tools, repeat.""" while context.keep_running(): async with ( - models.stream( - model=context.model, - messages=context.messages, - tools=context.tools, - ) as stream, + models.stream(context) as stream, ToolRunner() as tr, ): async for event in util.merge(stream, tr.events()): diff --git a/src/ai/models/core/api.py b/src/ai/models/core/api.py index 4ff91654..620402fe 100644 --- a/src/ai/models/core/api.py +++ b/src/ai/models/core/api.py @@ -1,7 +1,8 @@ import contextlib import dataclasses from collections.abc import AsyncGenerator, AsyncIterator, Sequence -from typing import Any, Protocol, Self, cast, runtime_checkable +from contextlib import AbstractAsyncContextManager +from typing import Any, Protocol, Self, cast, overload, runtime_checkable import pydantic @@ -329,8 +330,33 @@ async def _replay_tool_calls( ) -@contextlib.asynccontextmanager -async def stream[ProviderParamsT: pydantic.BaseModel]( +@runtime_checkable +class StreamContext[ProviderParamsT: pydantic.BaseModel](Protocol): + """Anything that exposes ``model``/``messages``/``tools``. + + Used to let callers pass an ``agents.Context`` to :func:`stream` + without an import-time circular dependency. + """ + + @property + def model(self) -> model_.Model[ProviderParamsT]: ... + @property + def messages(self) -> list[types.messages.Message]: ... + @property + def tools(self) -> list[types.tools.Tool]: ... + + +@overload +def stream[ProviderParamsT: pydantic.BaseModel]( + context: StreamContext[ProviderParamsT], + /, + *, + output_type: type[pydantic.BaseModel] | None = None, + params: params_.StreamParams[ProviderParamsT] | None = None, + executor: StreamExecutor = _default_executor, +) -> AbstractAsyncContextManager[Stream]: ... +@overload +def stream[ProviderParamsT: pydantic.BaseModel]( *, model: model_.Model[ProviderParamsT], messages: list[types.messages.Message], @@ -338,19 +364,61 @@ async def stream[ProviderParamsT: pydantic.BaseModel]( output_type: type[pydantic.BaseModel] | None = None, params: params_.StreamParams[ProviderParamsT] | None = None, executor: StreamExecutor = _default_executor, -) -> AsyncIterator[Stream]: +) -> AbstractAsyncContextManager[Stream]: ... +def stream[ProviderParamsT: pydantic.BaseModel]( + context: StreamContext[ProviderParamsT] | None = None, + /, + *, + model: model_.Model[ProviderParamsT] | None = None, + messages: list[types.messages.Message] | None = None, + tools: Sequence[types.tools.Tool] | None = None, + output_type: type[pydantic.BaseModel] | None = None, + params: params_.StreamParams[ProviderParamsT] | None = None, + executor: StreamExecutor = _default_executor, +) -> AbstractAsyncContextManager[Stream]: """Stream an LLM response. - Used as an async context manager whose value is the :class:`Stream`:: + Used as an async context manager whose value is the :class:`Stream`. + Pass either an ``agents.Context`` (or anything matching + :class:`StreamContext`) or explicit ``model=``/``messages=``/``tools=``:: - async with ai.stream(model=model, messages=messages) as s: - async for event in s: - ... - print(s.message) + async with ai.stream(context) as s: ... + async with ai.stream(model=model, messages=messages) as s: ... If the last message is marked ``replay=True``, replay that turn as synthetic stream events instead of calling the model. """ + if context is not None: + if model is not None or messages is not None or tools is not None: + raise TypeError( + "stream() takes either a context or model/messages/tools, not both" + ) + model = context.model + messages = context.messages + tools = context.tools + elif model is None or messages is None: + raise TypeError("stream() requires either a context or model= and messages=") + + return _stream( + model=model, + messages=messages, + tools=tools, + output_type=output_type, + params=params, + executor=executor, + ) + + +@contextlib.asynccontextmanager +async def _stream[ProviderParamsT: pydantic.BaseModel]( + *, + model: model_.Model[ProviderParamsT], + messages: list[types.messages.Message], + tools: Sequence[types.tools.Tool] | None, + output_type: type[pydantic.BaseModel] | None, + params: params_.StreamParams[ProviderParamsT] | None, + executor: StreamExecutor, +) -> AsyncIterator[Stream]: if messages and messages[-1].replay: last = messages[-1] s = Stream(_replay_tool_calls(last), seed_message=last.model_copy(deep=True)) diff --git a/tests/agents/test_hooks.py b/tests/agents/test_hooks.py index 531c6a7a..1fa7473b 100644 --- a/tests/agents/test_hooks.py +++ b/tests/agents/test_hooks.py @@ -31,9 +31,7 @@ async def test_resolve_live_future() -> None: @my_agent.loop async def custom(context: ai.Context) -> AsyncGenerator[ai.events.Event]: nonlocal resolved_value - async with ai.models.stream( - model=context.model, messages=context.messages - ) as stream: + async with ai.models.stream(context) as stream: async for event in stream: yield event result = await ai.hook("confirm_1", payload=Confirmation) @@ -65,9 +63,7 @@ async def test_cancel_live_hook() -> None: @my_agent.loop async def custom(context: ai.Context) -> AsyncGenerator[ai.events.Event]: nonlocal was_cancelled - async with ai.models.stream( - model=context.model, messages=context.messages - ) as stream: + async with ai.models.stream(context) as stream: async for event in stream: yield event try: @@ -106,9 +102,7 @@ async def test_pre_registered_resolution_consumed() -> None: @my_agent.loop async def custom(context: ai.Context) -> AsyncGenerator[ai.events.Event]: nonlocal resolved_value - async with ai.models.stream( - model=context.model, messages=context.messages - ) as stream: + async with ai.models.stream(context) as stream: async for event in stream: yield event resolved_value = await ai.hook("pre_reg_1", payload=Confirmation) @@ -148,9 +142,7 @@ async def test_resolved_hook_emits_message() -> None: @my_agent.loop async def custom(context: ai.Context) -> AsyncGenerator[ai.events.Event]: - async with ai.models.stream( - model=context.model, messages=context.messages - ) as stream: + async with ai.models.stream(context) as stream: async for event in stream: yield event await ai.hook("emit_test", payload=Confirmation) @@ -179,9 +171,7 @@ async def test_hook_metadata_in_pending() -> None: @my_agent.loop async def custom(context: ai.Context) -> AsyncGenerator[ai.events.Event]: - async with ai.models.stream( - model=context.model, messages=context.messages - ) as stream: + async with ai.models.stream(context) as stream: async for event in stream: yield event await ai.hook( diff --git a/tests/models/core/test_api.py b/tests/models/core/test_api.py index 0a5c7fcf..1e0447ac 100644 --- a/tests/models/core/test_api.py +++ b/tests/models/core/test_api.py @@ -236,6 +236,40 @@ async def _spy_stream( assert received_params == [params] +async def test_stream_accepts_context() -> None: + """``stream(context)`` reads model/messages/tools off the context.""" + mock = mock_llm([[text_msg("ok")]]) + ctx = ai.Context( + model=MOCK_MODEL, + messages=[ai.user_message("Hi")], + tools=[], + ) + async with models.stream(ctx) as s: + async for _ in s: + pass + assert mock.call_count == 1 + assert s.text == "ok" + + +async def test_stream_rejects_context_with_explicit_kwargs() -> None: + """Passing both ``context`` and ``model=`` is a TypeError.""" + ctx = ai.Context( + model=MOCK_MODEL, + messages=[ai.user_message("Hi")], + tools=[], + ) + with pytest.raises(TypeError, match="either a context or"): + async with models.stream(ctx, model=MOCK_MODEL): # type: ignore[call-overload] + pass + + +async def test_stream_requires_context_or_kwargs() -> None: + """Passing nothing is a TypeError.""" + with pytest.raises(TypeError, match="either a context or"): + async with models.stream(): # type: ignore[call-overload] + pass + + async def test_normalize_params_rejects_non_pydantic_value() -> None: """``stream(...)`` rejects raw dicts (and anything not a BaseModel).""" with pytest.raises(TypeError, match="pydantic BaseModel"): diff --git a/tests/test_middleware.py b/tests/test_middleware.py index b1c70cdd..8e412784 100644 --- a/tests/test_middleware.py +++ b/tests/test_middleware.py @@ -79,9 +79,7 @@ async def wrap_hook(self, call: middleware.HookContext, next: Any) -> Any: @my_agent.loop async def custom(context: ai.Context) -> AsyncGenerator[ai.events.Event]: - async with ai.models.stream( - model=context.model, messages=context.messages - ) as stream: + async with ai.models.stream(context) as stream: async for event in stream: yield event await ai.hook("test_hook", payload=Confirmation) From 34f1f42b2ef1edac244deb0cfd6ef1a35770c8f4 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Sun, 10 May 2026 16:06:38 -0700 Subject: [PATCH 3/3] Make `Agent.run` take only keyword arguments Mirrors the kwargs-only style now used by `stream`. --- README.md | 2 +- examples/fastapi-vite/backend/agent.py | 2 +- examples/fastapi-vite/backend/main.py | 4 +++- examples/multiagent-textual/server.py | 14 +++++++------- examples/samples/agent_custom_loop.py | 6 ++++-- examples/samples/agent_hooks.py | 2 +- examples/samples/agent_hooks_inline.py | 2 +- examples/samples/agent_hooks_serverless.py | 4 ++-- examples/samples/agent_nested.py | 4 ++-- examples/samples/agent_simple.py | 2 +- examples/samples/mcp_tools.py | 2 +- examples/samples/middleware_simple.py | 4 +++- examples/samples/streaming_tool.py | 2 +- examples/temporal-direct/main.py | 2 +- examples/temporal-middleware/main.py | 4 +++- skills/ai/SKILL.md | 14 +++++++------- src/ai/agents/agent.py | 13 ++++++++----- src/ai/agents/middleware.py | 2 +- tests/agents/mcp/test_client.py | 4 +++- tests/agents/test_aggregate_marker.py | 4 +++- tests/agents/test_generator_tools.py | 10 +++++++--- tests/agents/test_hooks.py | 20 +++++++++++++++----- tests/agents/test_runtime.py | 14 ++++++++++---- tests/test_middleware.py | 16 +++++++++------- 24 files changed, 95 insertions(+), 58 deletions(-) diff --git a/README.md b/README.md index 701d4493..8e58fc98 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ async def main() -> None: ai.user_message("What's the weather in Tokyo?"), ] - async with agent.run(model, messages) as stream: + async with agent.run(model=model, messages=messages) as stream: async for event in stream: if isinstance(event, ai.TextDelta): print(event.chunk, end="", flush=True) diff --git a/examples/fastapi-vite/backend/agent.py b/examples/fastapi-vite/backend/agent.py index 8efe04d0..190756b9 100644 --- a/examples/fastapi-vite/backend/agent.py +++ b/examples/fastapi-vite/backend/agent.py @@ -36,7 +36,7 @@ async def talk_to_mothership(question: str) -> ai.SubAgentTool: ai.system_message(MOTHERSHIP_SYSTEM), ai.user_message(question), ] - async with mothership.run(MOTHERSHIP_MODEL, messages) as stream: + async with mothership.run(model=MOTHERSHIP_MODEL, messages=messages) as stream: async for event in stream: yield event diff --git a/examples/fastapi-vite/backend/main.py b/examples/fastapi-vite/backend/main.py index 2deb7d5d..10219694 100644 --- a/examples/fastapi-vite/backend/main.py +++ b/examples/fastapi-vite/backend/main.py @@ -63,7 +63,9 @@ async def chat(request: ChatRequest) -> fastapi.responses.StreamingResponse: ai.agents.ui.ai_sdk.apply_approvals(approvals) async def stream_response() -> AsyncGenerator[str]: - async with agent_.chat_agent.run(agent_.MODEL, messages) as result: + async with agent_.chat_agent.run( + model=agent_.MODEL, messages=messages + ) as result: async for chunk in ai.agents.ui.ai_sdk.to_sse(result): yield chunk diff --git a/examples/multiagent-textual/server.py b/examples/multiagent-textual/server.py index 797fe209..be25cf0c 100644 --- a/examples/multiagent-textual/server.py +++ b/examples/multiagent-textual/server.py @@ -158,8 +158,8 @@ async def multiagent_loop(context: ai.Context) -> AsyncGenerator[ai.events.Agent # carrying the branch label, so the TUI can route to the right panel. async with ( mothership_agent.run( - context.model, - [ + model=context.model, + messages=[ ai.system_message( "You are assistant 1. Use contact_mothership " "when asked about the future." @@ -168,8 +168,8 @@ async def multiagent_loop(context: ai.Context) -> AsyncGenerator[ai.events.Agent ], ) as mothership_stream, data_centers_agent.run( - context.model, - [ + model=context.model, + messages=[ ai.system_message( "You are assistant 2. Use contact_data_centers " "when asked about the future." @@ -198,8 +198,8 @@ async def multiagent_loop(context: ai.Context) -> AsyncGenerator[ai.events.Agent # panel as its default. summary_agent = ai.agent() async with summary_agent.run( - context.model, - [ + model=context.model, + messages=[ ai.system_message( "You are assistant 3. Summarise the results from the other assistants." ), @@ -263,7 +263,7 @@ async def read_resolutions() -> None: try: async with orchestrator.run( - MODEL, [ai.user_message("When will the robots take over?")] + model=MODEL, messages=[ai.user_message("When will the robots take over?")] ) as result: async for event in result: data = _normalise_event(event.model_dump()) diff --git a/examples/samples/agent_custom_loop.py b/examples/samples/agent_custom_loop.py index 8736891b..5cfce93a 100644 --- a/examples/samples/agent_custom_loop.py +++ b/examples/samples/agent_custom_loop.py @@ -53,8 +53,10 @@ async def main() -> None: my_agent = CustomAgent(tools=tools) async with my_agent.run( - model, - [ai.user_message("Compare the weather and population of New York and Tokyo.")], + model=model, + messages=[ + ai.user_message("Compare the weather and population of New York and Tokyo.") + ], ) as stream: async for event in stream: if ( diff --git a/examples/samples/agent_hooks.py b/examples/samples/agent_hooks.py index 0920f56e..9a0fef7a 100644 --- a/examples/samples/agent_hooks.py +++ b/examples/samples/agent_hooks.py @@ -92,7 +92,7 @@ async def with_approval( ai.user_message("When will the robots take over?"), ] - async with my_agent.run(model, messages) as stream: + async with my_agent.run(model=model, messages=messages) as stream: async for event in stream: if isinstance(event, ai.events.TextDelta): print(event.chunk, end="", flush=True) diff --git a/examples/samples/agent_hooks_inline.py b/examples/samples/agent_hooks_inline.py index d1ae7a92..12d455a5 100644 --- a/examples/samples/agent_hooks_inline.py +++ b/examples/samples/agent_hooks_inline.py @@ -84,7 +84,7 @@ async def with_approval( ai.user_message("When will the robots take over?"), ] - async with my_agent.run(model, messages) as stream: + async with my_agent.run(model=model, messages=messages) as stream: async for event in stream: if isinstance(event, ai.events.TextDelta): print(event.chunk, end="", flush=True) diff --git a/examples/samples/agent_hooks_serverless.py b/examples/samples/agent_hooks_serverless.py index 243f7aac..a5885105 100644 --- a/examples/samples/agent_hooks_serverless.py +++ b/examples/samples/agent_hooks_serverless.py @@ -91,7 +91,7 @@ async def with_confirmation( print("--- Run 1: hook fires, no resolution, run suspends ---") pending_hook_labels: list[str] = [] - async with my_agent.run(model, messages) as stream: + async with my_agent.run(model=model, messages=messages) as stream: async for event in stream: # HACK?: When we get a complete assistant message, add it to # messages so it can get replayed easily. @@ -118,7 +118,7 @@ async def with_confirmation( for label in pending_hook_labels: ai.resolve_hook(label, Confirmation(approved=True, reason="user approved")) - async with my_agent.run(model, messages) as stream: + async with my_agent.run(model=model, messages=messages) as stream: async for event in stream: if isinstance(event, ai.events.TextDelta): print(event.chunk, end="", flush=True) diff --git a/examples/samples/agent_nested.py b/examples/samples/agent_nested.py index d7339bd6..fd8beaf9 100644 --- a/examples/samples/agent_nested.py +++ b/examples/samples/agent_nested.py @@ -30,7 +30,7 @@ async def research(topic: str) -> ai.SubAgentTool: ai.user_message(f"Research: {topic}"), ] - async with researcher.run(model, messages) as stream: + async with researcher.run(model=model, messages=messages) as stream: async for event in stream: yield event @@ -45,7 +45,7 @@ async def main() -> None: ai.user_message("Tell me about Mars."), ] - async with orchestrator.run(model, messages) as stream: + async with orchestrator.run(model=model, messages=messages) as stream: async for event in stream: # Subtool results if isinstance(event, ai.events.PartialToolCallResult): diff --git a/examples/samples/agent_simple.py b/examples/samples/agent_simple.py index a7104b89..ee4c4d06 100644 --- a/examples/samples/agent_simple.py +++ b/examples/samples/agent_simple.py @@ -21,7 +21,7 @@ async def main() -> None: ai.user_message("What's the weather in Tokyo?"), ] - async with my_agent.run(model, messages) as stream: + async with my_agent.run(model=model, messages=messages) as stream: async for event in stream: if isinstance(event, ai.events.TextDelta): print(event.chunk, end="", flush=True) diff --git a/examples/samples/mcp_tools.py b/examples/samples/mcp_tools.py index 2fa316ea..1fa25c7f 100644 --- a/examples/samples/mcp_tools.py +++ b/examples/samples/mcp_tools.py @@ -24,7 +24,7 @@ async def main() -> None: ai.user_message("How do I create middleware in Next.js?"), ] - async with my_agent.run(model, messages) as stream: + async with my_agent.run(model=model, messages=messages) as stream: async for event in stream: if isinstance(event, ai.events.TextDelta): print(event.chunk, end="", flush=True) diff --git a/examples/samples/middleware_simple.py b/examples/samples/middleware_simple.py index 146fe7d1..ffab9f93 100644 --- a/examples/samples/middleware_simple.py +++ b/examples/samples/middleware_simple.py @@ -120,7 +120,9 @@ async def main() -> None: ] print("--- starting agent run ---\n") - async with my_agent.run(model, messages, middleware=[PrintMiddleware()]) as stream: + async with my_agent.run( + model=model, messages=messages, middleware=[PrintMiddleware()] + ) as stream: async for event in stream: if isinstance(event, ai.events.TextDelta): print(event.chunk, end="", flush=True) diff --git a/examples/samples/streaming_tool.py b/examples/samples/streaming_tool.py index c14fab8b..435faf62 100644 --- a/examples/samples/streaming_tool.py +++ b/examples/samples/streaming_tool.py @@ -35,7 +35,7 @@ async def main() -> None: ai.user_message("When will the robots take over?"), ] - async with my_agent.run(model, messages) as stream: + async with my_agent.run(model=model, messages=messages) as stream: async for event in stream: if isinstance(event, ai.events.PartialToolCallResult): print(f" [{event.value}]") diff --git a/examples/temporal-direct/main.py b/examples/temporal-direct/main.py index e6d79fb3..5589c389 100644 --- a/examples/temporal-direct/main.py +++ b/examples/temporal-direct/main.py @@ -187,7 +187,7 @@ async def run(self, user_query: str) -> str: ] final_text = "" - async with weather_agent.run(model, messages) as stream: + async with weather_agent.run(model=model, messages=messages) as stream: async for event in stream: if isinstance(event, ai.events.TerminalEvent): final_text = event.message.text diff --git a/examples/temporal-middleware/main.py b/examples/temporal-middleware/main.py index ea502832..719208ea 100644 --- a/examples/temporal-middleware/main.py +++ b/examples/temporal-middleware/main.py @@ -250,7 +250,9 @@ async def run(self, user_query: str) -> str: mw = TemporalMiddleware(tool_schemas) final_text = "" - async with weather_agent.run(model, messages, middleware=[mw]) as stream: + async with weather_agent.run( + model=model, messages=messages, middleware=[mw] + ) as stream: async for event in stream: if isinstance(event, ai.events.TerminalEvent): final_text = event.message.text diff --git a/skills/ai/SKILL.md b/skills/ai/SKILL.md index fdb01327..5dad7569 100644 --- a/skills/ai/SKILL.md +++ b/skills/ai/SKILL.md @@ -29,7 +29,7 @@ messages = [ ai.user_message("What's the weather in Tokyo?"), ] -async for msg in agent.run(model, messages): +async for msg in agent.run(model=model, messages=messages): print(msg.text_delta, end="") ``` @@ -85,7 +85,7 @@ async def render(prompt: str) -> ai.StreamingTextTool: @ai.tool async def research(topic: str) -> ai.SubAgentTool: sub = ai.agent(tools=[...]) - async for event in sub.run(model, msgs): + async for event in sub.run(model=model, messages=msgs): yield event # final assistant text becomes the tool result ``` @@ -164,12 +164,12 @@ async def multi(model: ai.Model, query: str) -> str: r1, r2 = await asyncio.gather( ai.yield_from( - researcher.run(model, msgs1), + researcher.run(model=model, messages=msgs1), label="researcher", aggregator=ai.MessageAggregator, ), ai.yield_from( - analyst.run(model, msgs2), + analyst.run(model=model, messages=msgs2), label="analyst", aggregator=ai.MessageAggregator, ), @@ -217,7 +217,7 @@ Hook messages have `role="signal"` with a `HookPart`. Consuming hooks in the iterator: ```python -async for msg in my_agent.run(model, messages): +async for msg in my_agent.run(model=model, messages=messages): if msg.role == "signal" and (hook := msg.get_hook_part()): answer = input(f"Approve {hook.hook_id}? [y/n] ") ai.resolve_hook( @@ -264,7 +264,7 @@ from ai.ai_sdk_ui import UI_MESSAGE_STREAM_HEADERS, to_messages, to_sse_stream messages = to_messages(request.messages) return StreamingResponse( - to_sse_stream(agent.run(model, messages)), + to_sse_stream(agent.run(model=model, messages=messages)), headers=UI_MESSAGE_STREAM_HEADERS, ) ``` @@ -285,7 +285,7 @@ class LoggingMiddleware(ai.Middleware): print(f"tool {call.tool_name}({call.kwargs})") return await next(call) -async for msg in agent.run(model, messages, middleware=[LoggingMiddleware()]): +async for msg in agent.run(model=model, messages=messages, middleware=[LoggingMiddleware()]): ... ``` diff --git a/src/ai/agents/agent.py b/src/ai/agents/agent.py index 1f1a0980..d210faf5 100644 --- a/src/ai/agents/agent.py +++ b/src/ai/agents/agent.py @@ -163,7 +163,7 @@ async def fetch(url: str) -> StreamingStatusTool[str]: @ai.tool async def research(topic: str) -> SubAgentTool: sub = ai.agent(tools=[...]) - async with sub.run(model, messages) as stream: + async with sub.run(model=model, messages=messages) as stream: async for event in stream: yield event """ @@ -658,12 +658,15 @@ async def yield_from[T, R]( Use inside a custom loop to stream messages from a sub-agent to the consumer without adding them to the parent agent's message history:: - async with sub.run(model, msgs) as stream: + async with sub.run(model=model, messages=msgs) as stream: result = await yield_from(stream, label="researcher") Works with :func:`asyncio.gather` for concurrent fan-out:: - async with a.run(model, m1) as sa, b.run(model, m2) as sb: + async with ( + a.run(model=model, messages=m1) as sa, + b.run(model=model, messages=m2) as sb, + ): r1, r2 = await asyncio.gather( yield_from(sa, label="a"), yield_from(sb, label="b"), @@ -740,16 +743,16 @@ async def default_loop( @contextlib.asynccontextmanager async def run( self, + *, model: models.Model[Any], messages: list[types.messages.Message], - *, middleware: list[middleware_.Middleware] | None = None, ) -> AsyncIterator[AsyncGenerator[events_.AgentEvent]]: """Run the agent loop, yielding events to the consumer. Used as an async context manager whose value is the event stream:: - async with agent.run(model, messages) as stream: + async with agent.run(model=model, messages=messages) as stream: async for event in stream: ... diff --git a/src/ai/agents/middleware.py b/src/ai/agents/middleware.py index 54aeff4b..20312584 100644 --- a/src/ai/agents/middleware.py +++ b/src/ai/agents/middleware.py @@ -2,7 +2,7 @@ Middleware is run-scoped — pass it to :meth:`Agent.run`:: - agent.run(model, messages, middleware=[LoggingMiddleware()]) + agent.run(model=model, messages=messages, middleware=[LoggingMiddleware()]) Middleware wraps agent runs, model calls, generate calls, tool calls, and hook calls. Subclass :class:`Middleware` and override the methods you care diff --git a/tests/agents/mcp/test_client.py b/tests/agents/mcp/test_client.py index 238d5700..8c92f549 100644 --- a/tests/agents/mcp/test_client.py +++ b/tests/agents/mcp/test_client.py @@ -89,7 +89,9 @@ async def fake_fn(**kwargs: str) -> str: call2 = [text_msg("Done.", id="msg-2")] llm = mock_llm([call1, call2]) - async with my_agent.run(MOCK_MODEL, [ai.user_message("echo hello")]) as stream: + async with my_agent.run( + model=MOCK_MODEL, messages=[ai.user_message("echo hello")] + ) as stream: msgs = await collect_messages(stream) # Tool was called with the right args. diff --git a/tests/agents/test_aggregate_marker.py b/tests/agents/test_aggregate_marker.py index 38a9f5d5..29aad400 100644 --- a/tests/agents/test_aggregate_marker.py +++ b/tests/agents/test_aggregate_marker.py @@ -128,7 +128,9 @@ async def test_alias_declared_tool_runs_end_to_end() -> None: llm = mock_llm([call, reply]) all_events: list[agent_events_.AgentEvent] = [] - async with my_agent.run(MOCK_MODEL, [ai.user_message("Go")]) as stream: + async with my_agent.run( + model=MOCK_MODEL, messages=[ai.user_message("Go")] + ) as stream: async for event in stream: all_events.append(event) diff --git a/tests/agents/test_generator_tools.py b/tests/agents/test_generator_tools.py index cc753711..33e1bc11 100644 --- a/tests/agents/test_generator_tools.py +++ b/tests/agents/test_generator_tools.py @@ -45,7 +45,9 @@ async def test_generator_tool_streams_and_returns_result() -> None: llm = mock_llm([call, reply]) all_events: list[agent_events_.AgentEvent] = [] - async with my_agent.run(MOCK_MODEL, [ai.user_message("Go")]) as stream: + async with my_agent.run( + model=MOCK_MODEL, messages=[ai.user_message("Go")] + ) as stream: async for event in stream: all_events.append(event) @@ -117,7 +119,7 @@ async def research_tool(topic: str) -> AsyncGenerator[agent_events_.AgentEvent]: ai.system_message("Be concise."), ai.user_message(f"Research: {topic}"), ] - async with inner.run(MOCK_MODEL, msgs) as stream: + async with inner.run(model=MOCK_MODEL, messages=msgs) as stream: async for event in stream: yield event @@ -146,7 +148,9 @@ async def test_yield_from_nested_agent() -> None: models.register_stream("mock", adapter.stream) all_events: list[agent_events_.AgentEvent] = [] - async with outer.run(MOCK_MODEL, [ai.user_message("Tell me about Mars")]) as stream: + async with outer.run( + model=MOCK_MODEL, messages=[ai.user_message("Tell me about Mars")] + ) as stream: async for event in stream: all_events.append(event) diff --git a/tests/agents/test_hooks.py b/tests/agents/test_hooks.py index 1fa7473b..3b51fdd5 100644 --- a/tests/agents/test_hooks.py +++ b/tests/agents/test_hooks.py @@ -39,7 +39,9 @@ async def custom(context: ai.Context) -> AsyncGenerator[ai.events.Event]: mock_llm([[text_msg("OK")]]) - async with my_agent.run(MOCK_MODEL, [ai.user_message("go")]) as stream: + async with my_agent.run( + model=MOCK_MODEL, messages=[ai.user_message("go")] + ) as stream: async for event in stream: if not isinstance(event, agent_events_.HookEvent): continue @@ -73,7 +75,9 @@ async def custom(context: ai.Context) -> AsyncGenerator[ai.events.Event]: mock_llm([[text_msg("OK")]]) - async with my_agent.run(MOCK_MODEL, [ai.user_message("go")]) as stream: + async with my_agent.run( + model=MOCK_MODEL, messages=[ai.user_message("go")] + ) as stream: async for event in stream: if not isinstance(event, agent_events_.HookEvent): continue @@ -111,7 +115,9 @@ async def custom(context: ai.Context) -> AsyncGenerator[ai.events.Event]: ai.resolve_hook("pre_reg_1", {"approved": True}) mock_llm([[text_msg("OK")]]) - async with my_agent.run(MOCK_MODEL, [ai.user_message("go")]) as stream: + async with my_agent.run( + model=MOCK_MODEL, messages=[ai.user_message("go")] + ) as stream: async for _msg in stream: pass @@ -150,7 +156,9 @@ async def custom(context: ai.Context) -> AsyncGenerator[ai.events.Event]: mock_llm([[text_msg("OK")]]) hooks: list[ai.messages.HookPart[Any]] = [] - async with my_agent.run(MOCK_MODEL, [ai.user_message("go")]) as stream: + async with my_agent.run( + model=MOCK_MODEL, messages=[ai.user_message("go")] + ) as stream: async for event in stream: if not isinstance(event, agent_events_.HookEvent): continue @@ -183,7 +191,9 @@ async def custom(context: ai.Context) -> AsyncGenerator[ai.events.Event]: mock_llm([[text_msg("OK")]]) hooks: list[ai.messages.HookPart[Any]] = [] - async with my_agent.run(MOCK_MODEL, [ai.user_message("go")]) as stream: + async with my_agent.run( + model=MOCK_MODEL, messages=[ai.user_message("go")] + ) as stream: async for event in stream: if isinstance(event, agent_events_.HookEvent): hooks.append(event.hook) diff --git a/tests/agents/test_runtime.py b/tests/agents/test_runtime.py index 17ebef75..44159c67 100644 --- a/tests/agents/test_runtime.py +++ b/tests/agents/test_runtime.py @@ -30,7 +30,9 @@ async def test_agent_text_only() -> None: my_agent = ai.agent(tools=[double]) llm = mock_llm([[text_msg("Hello!")]]) - async with my_agent.run(MOCK_MODEL, [ai.user_message("Hi")]) as stream: + async with my_agent.run( + model=MOCK_MODEL, messages=[ai.user_message("Hi")] + ) as stream: msgs = await collect_messages(stream) assert llm.call_count == 1 assert any(m.text == "Hello!" for m in msgs) @@ -47,7 +49,9 @@ async def test_agent_tool_then_text() -> None: call2 = [text_msg("The answer is 10.")] llm = mock_llm([call1, call2]) - async with my_agent.run(MOCK_MODEL, [ai.user_message("Double 5")]) as stream: + async with my_agent.run( + model=MOCK_MODEL, messages=[ai.user_message("Double 5")] + ) as stream: msgs = await collect_messages(stream) assert llm.call_count == 2 tool_results = [m for m in msgs if m.role == "tool" and m.tool_results] @@ -81,7 +85,9 @@ async def test_agent_parallel_tools() -> None: call2 = [text_msg("6 and 14", id="msg-2")] llm = mock_llm([[two_tools], call2]) - async with my_agent.run(MOCK_MODEL, [ai.user_message("Double 3 and 7")]) as stream: + async with my_agent.run( + model=MOCK_MODEL, messages=[ai.user_message("Double 3 and 7")] + ) as stream: msgs = await collect_messages(stream) assert llm.call_count == 2 tool_result_msgs = [m for m in msgs if m.role == "tool" and m.tool_results] @@ -103,7 +109,7 @@ async def test_agent_multi_turn() -> None: llm = mock_llm([turn1, turn2, turn3]) async with my_agent.run( - MOCK_MODEL, [ai.user_message("Concat then double")] + model=MOCK_MODEL, messages=[ai.user_message("Concat then double")] ) as stream: await collect_messages(stream) assert llm.call_count == 3 diff --git a/tests/test_middleware.py b/tests/test_middleware.py index 8e412784..63b5d692 100644 --- a/tests/test_middleware.py +++ b/tests/test_middleware.py @@ -52,7 +52,7 @@ async def double(x: int) -> int: mock_llm([call1, call2]) async with my_agent.run( - MOCK_MODEL, [ai.user_message("Double 7")], middleware=[Spy()] + model=MOCK_MODEL, messages=[ai.user_message("Double 7")], middleware=[Spy()] ) as stream: async for _m in stream: pass @@ -87,7 +87,7 @@ async def custom(context: ai.Context) -> AsyncGenerator[ai.events.Event]: mock_llm([[text_msg("OK")]]) async with my_agent.run( - MOCK_MODEL, [ai.user_message("go")], middleware=[Spy()] + model=MOCK_MODEL, messages=[ai.user_message("go")], middleware=[Spy()] ) as stream: async for event in stream: if not isinstance(event, agent_events_.HookEvent): @@ -130,7 +130,9 @@ async def wrap_agent_run( mock_llm([[text_msg("Hi")]]) async with my_agent.run( - MOCK_MODEL, [ai.user_message("Hi")], middleware=[Outer(), Inner()] + model=MOCK_MODEL, + messages=[ai.user_message("Hi")], + middleware=[Outer(), Inner()], ) as stream: async for _m in stream: pass @@ -158,7 +160,7 @@ async def echo(x: int) -> int: mock_llm([call1, call2]) async with my_agent.run( - MOCK_MODEL, [ai.user_message("go")], middleware=[Rewriter()] + model=MOCK_MODEL, messages=[ai.user_message("go")], middleware=[Rewriter()] ) as stream: msgs = await collect_messages(stream) tool_result_msgs = [m for m in msgs if m.role == "tool" and m.tool_results] @@ -188,7 +190,7 @@ async def echo(x: int) -> int: with pytest.raises(ExceptionGroup) as exc_info: async with my_agent.run( - MOCK_MODEL, [ai.user_message("go")], middleware=[Rewriter()] + model=MOCK_MODEL, messages=[ai.user_message("go")], middleware=[Rewriter()] ) as stream: async for _m in stream: pass @@ -214,7 +216,7 @@ async def wrap_model(self, call: middleware.ModelContext, next: Any) -> Any: mock_llm([[text_msg("Hi")]]) async with my_agent.run( - MOCK_MODEL, original_messages, middleware=[Mutator()] + model=MOCK_MODEL, messages=original_messages, middleware=[Mutator()] ) as stream: async for _m in stream: pass @@ -250,7 +252,7 @@ async def double(x: int) -> int: mock_llm([call1, call2]) async with my_agent.run( - MOCK_MODEL, [ai.user_message("go")], middleware=[ArgFixer()] + model=MOCK_MODEL, messages=[ai.user_message("go")], middleware=[ArgFixer()] ) as stream: msgs = await collect_messages(stream) tool_result_msgs = [m for m in msgs if m.role == "tool" and m.tool_results]