Skip to content

Commit e21140c

Browse files
authored
[BREAKING] Python: Add request-scoped Foundry agent factories (microsoft#8372)
* Python: Add request-scoped Foundry agent factories * Python: Refine Foundry factory ownership and session persistence Validate functional response batches before state changes, persist ordinary Invocations factory sessions, and document fresh execution objects as the factory author responsibility. Stabilize recovery fixtures and correct CI test typing. * fix: align workflow continuation checks Require recorded completion for both Invocations workflow types. Preserve stored state when incomplete continuation is rejected. * refactor: simplify hosting checkpoint handling Keep checkpoint error policy with the workflow runtime. Add HTTP coverage for request-scoped factory behavior. * Python: Simplify request-scoped Foundry agents * Python: Fix ResponseStream test typing * Python: Align request agent validation and stream cleanup * Python: Manage request-scoped Invocations agents * Python: Validate request agent callable shape * Python: Address request agent review feedback * docs: Clarify request-scoped Foundry agent creation
1 parent f2ba504 commit e21140c

16 files changed

Lines changed: 724 additions & 190 deletions

File tree

‎python/packages/core/agent_framework/_types.py‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3520,6 +3520,24 @@ async def __anext__(self) -> UpdateT:
35203520
update = await update
35213521
return await self._record_update(update)
35223522

3523+
async def close(self) -> None:
3524+
"""Close the active iterator and run cleanup hooks.
3525+
3526+
This method is idempotent and also closes nested ``ResponseStream`` wrappers.
3527+
"""
3528+
try:
3529+
iterator: AsyncIterator[UpdateT] | None = self._iterator
3530+
if iterator is not None:
3531+
if isinstance(iterator, ResponseStream):
3532+
await cast(ResponseStream[UpdateT, Any], iterator).close()
3533+
else:
3534+
close = getattr(iterator, "aclose", None)
3535+
if close is not None:
3536+
await close()
3537+
finally:
3538+
self._consumed = True
3539+
await self._run_cleanup_hooks()
3540+
35233541
async def _resolve_stream_with_pull_contexts(self) -> AsyncIterable[UpdateT]:
35243542
"""Resolve the underlying stream while activating any registered pull context managers.
35253543

‎python/packages/core/agent_framework/_workflows/_functional.py‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
from copy import deepcopy
5050
from typing import Any, Generic, Literal, TypeVar, overload
5151

52+
from .._agents import BaseAgent
5253
from .._feature_stage import ExperimentalFeature, experimental
5354
from .._serialization import make_json_safe
5455
from .._types import AgentResponse, AgentResponseUpdate, ResponseStream
@@ -1407,7 +1408,7 @@ def _decorator(fn: Callable[..., Awaitable[Any]]) -> FunctionalWorkflowDefinitio
14071408

14081409

14091410
@experimental(feature_id=ExperimentalFeature.FUNCTIONAL_WORKFLOWS)
1410-
class FunctionalWorkflowAgent:
1411+
class FunctionalWorkflowAgent(BaseAgent):
14111412
"""Agent adapter for a :class:`FunctionalWorkflow`.
14121413
14131414
Provides a ``run()`` method with the same overloaded signature as
@@ -1452,10 +1453,13 @@ def __init__(
14521453
# but not otherwise consumed.
14531454
del kwargs
14541455
self._workflow = workflow
1455-
self.name = name or workflow.name
1456-
self.id = f"FunctionalWorkflowAgent_{self.name}"
1457-
self.description: str | None = description if description is not None else workflow.description
1458-
self.context_providers: Sequence[Any] | None = context_providers
1456+
resolved_name = name or workflow.name
1457+
super().__init__(
1458+
id=f"FunctionalWorkflowAgent_{resolved_name}",
1459+
name=resolved_name,
1460+
description=description if description is not None else workflow.description,
1461+
context_providers=context_providers,
1462+
)
14591463
self._pending_requests: dict[str, WorkflowEvent[Any]] = {}
14601464

14611465
@property

‎python/packages/core/tests/core/test_types.py‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4192,6 +4192,48 @@ async def async_hook(update: ChatResponseUpdate) -> ChatResponseUpdate:
41924192
class TestResponseStreamCleanupHooks:
41934193
"""Tests for cleanup hooks (after stream consumption, before finalizer)."""
41944194

4195+
async def test_close_closes_iterator_and_runs_cleanup_once(self) -> None:
4196+
"""Closing a partially consumed stream releases its iterator and cleanup hooks."""
4197+
events: list[str] = []
4198+
4199+
async def updates() -> AsyncIterable[ChatResponseUpdate]:
4200+
try:
4201+
yield ChatResponseUpdate(contents=[Content.from_text("first")], role="assistant")
4202+
yield ChatResponseUpdate(contents=[Content.from_text("second")], role="assistant")
4203+
finally:
4204+
events.append("iterator")
4205+
4206+
stream: ResponseStream[ChatResponseUpdate, Sequence[ChatResponseUpdate]] = ResponseStream(
4207+
updates(), cleanup_hooks=[lambda: events.append("cleanup")]
4208+
)
4209+
await anext(stream)
4210+
4211+
await stream.close()
4212+
await stream.close()
4213+
4214+
assert events == ["iterator", "cleanup"]
4215+
4216+
async def test_close_closes_wrapped_stream(self) -> None:
4217+
"""Closing a wrapper releases the concrete inner stream."""
4218+
events: list[str] = []
4219+
4220+
async def updates() -> AsyncIterable[ChatResponseUpdate]:
4221+
try:
4222+
yield ChatResponseUpdate(contents=[Content.from_text("first")], role="assistant")
4223+
yield ChatResponseUpdate(contents=[Content.from_text("second")], role="assistant")
4224+
finally:
4225+
events.append("iterator")
4226+
4227+
inner: ResponseStream[ChatResponseUpdate, Sequence[ChatResponseUpdate]] = ResponseStream(
4228+
updates(), cleanup_hooks=[lambda: events.append("inner")]
4229+
)
4230+
outer = inner.map(lambda update: update, _combine_updates).with_cleanup_hook(lambda: events.append("outer"))
4231+
await anext(outer)
4232+
4233+
await outer.close()
4234+
4235+
assert events == ["iterator", "inner", "outer"]
4236+
41954237
async def test_cleanup_hook_called_after_iteration(self) -> None:
41964238
"""Cleanup hook is called after iteration completes."""
41974239
cleanup_called = {"value": False}

‎python/packages/core/tests/workflow/test_functional_workflow.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
InMemoryCheckpointStorage,
2727
RunContext,
2828
StepWrapper,
29+
SupportsAgentRun,
2930
WorkflowEvent,
3031
WorkflowEventSource,
3132
WorkflowRunResult,
@@ -1051,6 +1052,13 @@ async def wf(x: int) -> int:
10511052
assert agent.id == "FunctionalWorkflowAgent_my_agent"
10521053
assert agent.description == "A test workflow"
10531054

1055+
async def test_as_agent_implements_supports_agent_run(self):
1056+
@built_workflow
1057+
async def wf(x: int) -> int:
1058+
return x
1059+
1060+
assert isinstance(wf.as_agent(), SupportsAgentRun)
1061+
10541062

10551063
# ---------------------------------------------------------------------------
10561064
# Concurrent execution guard

‎python/packages/foundry_hosting/README.md‎

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,33 @@
22

33
This package provides the integration of Agent Framework agents and workflows with the Foundry Agent Server, which can be hosted on Foundry infrastructure.
44

5+
## Agent instances and factories
6+
7+
`ResponsesHostServer` and `InvocationsHostServer` accept an agent instance or a zero-argument callable through the
8+
existing `agent` parameter. The callable may be synchronous or asynchronous, must return an object implementing
9+
`SupportsAgentRun`, and is invoked once for each request:
10+
11+
```python
12+
server = ResponsesHostServer(agent=create_agent)
13+
```
14+
15+
Passing an instance reuses that object for the lifetime of the host. Pass a callable when the agent keeps mutable state
16+
outside `AgentSession`. In particular, a `WorkflowAgent` wraps a stateful workflow, so its callable should build a new
17+
workflow, executors, and wrapped agents. Keep the workflow name and executor IDs stable so later requests can find and
18+
restore its checkpoints:
19+
20+
```python
21+
def create_agent():
22+
return build_workflow().as_agent()
23+
24+
25+
server = ResponsesHostServer(agent=create_agent)
26+
```
27+
28+
The Responses host continues regular agents through its existing session store and workflow agents through its
29+
existing checkpoint store. A callable does not make arbitrary instance fields persistent; state needed by later
30+
requests must remain in the supported stores.
31+
532
## Conversation history
633

734
`ResponsesHostServer` uses AgentServer response history as the model's conversation history by default:
@@ -21,8 +48,9 @@ AgentServer history requires a framework `RawAgent` whose client declares the bo
2148
the agent's runtime options then let hosting enforce downstream storage behavior. Custom `SupportsAgentRun`
2249
implementations must use `history_source="agent"` because that protocol does not accept runtime chat options.
2350

24-
`ResponsesHostServer` owns the supplied agent instance and may add hosting-specific context providers. Do not reuse that
25-
agent with another host or invoke it directly after constructing the server.
51+
`ResponsesHostServer` owns a supplied agent instance and may add hosting-specific context providers. Do not reuse that
52+
instance with another host or invoke it directly after constructing the server. An agent returned by a callable belongs
53+
to that request.
2654

2755
To preserve the agent's regular history and service-storage behavior, select the agent as the history source:
2856

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Copyright (c) Microsoft. All rights reserved.
2+
3+
from __future__ import annotations
4+
5+
import inspect
6+
from collections.abc import Awaitable, Callable
7+
from typing import TypeAlias, TypeGuard, cast
8+
9+
from agent_framework import SupportsAgentRun
10+
11+
AgentSource: TypeAlias = SupportsAgentRun | Callable[[], SupportsAgentRun | Awaitable[SupportsAgentRun]]
12+
13+
14+
def is_agent(value: object) -> TypeGuard[SupportsAgentRun]:
15+
return not inspect.isclass(value) and isinstance(value, SupportsAgentRun)
16+
17+
18+
def validate_agent_source(source: object) -> None:
19+
if is_agent(source):
20+
return
21+
if not callable(source):
22+
raise TypeError("agent must be an agent instance or a zero-argument callable that creates one.")
23+
try:
24+
inspect.signature(source).bind()
25+
except (TypeError, ValueError) as exc:
26+
raise TypeError("agent callable must accept no arguments.") from exc
27+
28+
29+
async def resolve_agent(source: AgentSource) -> SupportsAgentRun:
30+
"""Resolve an agent instance or request-scoped agent factory."""
31+
if is_agent(source):
32+
return source
33+
34+
factory = cast(Callable[[], SupportsAgentRun | Awaitable[SupportsAgentRun]], source)
35+
result = factory()
36+
agent = await result if inspect.isawaitable(result) else result
37+
if not is_agent(agent):
38+
raise TypeError("The agent factory must return an object implementing SupportsAgentRun.")
39+
return agent

‎python/packages/foundry_hosting/agent_framework_foundry_hosting/_invocations.py‎

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
# Copyright (c) Microsoft. All rights reserved.
22

33
import json
4+
from collections.abc import Awaitable, Callable
5+
from contextlib import AbstractAsyncContextManager, AsyncExitStack, asynccontextmanager
46

5-
from agent_framework import AgentSession, SupportsAgentRun
7+
from agent_framework import AgentSession, ResponseStream, SupportsAgentRun
68
from agent_framework._telemetry import mark_feature_used
79
from azure.ai.agentserver.core import get_request_context
810
from azure.ai.agentserver.invocations import InvocationAgentServerHost
911
from starlette.requests import Request
1012
from starlette.responses import Response, StreamingResponse
1113
from typing_extensions import Any, AsyncGenerator
1214

15+
from ._agent_source import is_agent, resolve_agent, validate_agent_source
1316
from ._feature_usage import FeatureIndex
1417

1518

@@ -18,25 +21,28 @@ class InvocationsHostServer(InvocationAgentServerHost):
1821

1922
def __init__(
2023
self,
21-
agent: SupportsAgentRun,
24+
agent: SupportsAgentRun | Callable[[], SupportsAgentRun | Awaitable[SupportsAgentRun]],
2225
*,
2326
openapi_spec: dict[str, Any] | None = None,
2427
**kwargs: Any,
2528
) -> None:
2629
"""Initialize an InvocationsHostServer.
2730
2831
Args:
29-
agent: The agent to handle responses for.
32+
agent: The agent to handle responses for, or a zero-argument sync or async callable that creates one for
33+
each request. Use a callable for agents that keep mutable state outside `AgentSession`.
3034
openapi_spec: The OpenAPI specification for the server.
3135
**kwargs: Additional keyword arguments.
3236
3337
This host will expect the request to be a JSON body with a "message" field.
3438
The response from the host will be a JSON object with a "response" field containing
3539
the agent's response and a "session_id" field containing the session ID.
3640
"""
41+
validate_agent_source(agent)
3742
super().__init__(openapi_spec=openapi_spec, **kwargs)
3843

3944
self._agent = agent
45+
self._owns_request_agent = not is_agent(agent)
4046
self._sessions: dict[str | tuple[str, str], AgentSession] = {}
4147
self.invoke_handler(self._handle_invoke)
4248
mark_feature_used(FeatureIndex.FOUNDRY_HOSTING)
@@ -72,6 +78,14 @@ def _partition_key(self) -> str | tuple[str, str]:
7278

7379
return context.session_id
7480

81+
@asynccontextmanager
82+
async def _request_agent(self) -> AsyncGenerator[SupportsAgentRun]:
83+
agent = await resolve_agent(self._agent)
84+
async with AsyncExitStack() as resources:
85+
if self._owns_request_agent and isinstance(agent, AbstractAsyncContextManager):
86+
await resources.enter_async_context(agent)
87+
yield agent
88+
7589
async def _handle_invoke(self, request: Request) -> Response:
7690
"""Invoke the agent with the given request."""
7791
try:
@@ -100,15 +114,26 @@ async def _handle_invoke(self, request: Request) -> Response:
100114
if stream:
101115

102116
async def stream_response() -> AsyncGenerator[str]:
103-
async for update in self._agent.run(user_message, session=session, stream=True):
104-
if update.text:
105-
yield update.text
117+
async with self._request_agent() as agent:
118+
stream = agent.run(user_message, session=session, stream=True)
119+
try:
120+
async for update in stream:
121+
if update.text:
122+
yield update.text
123+
finally:
124+
if isinstance(stream, ResponseStream):
125+
await stream.close()
126+
else:
127+
close = getattr(stream, "aclose", None)
128+
if close is not None:
129+
await close()
106130

107131
return StreamingResponse(
108132
stream_response(),
109133
media_type="text/event-stream",
110134
headers={"Cache-Control": "no-cache", "Connection": "keep-alive"},
111135
)
112136

113-
response = await self._agent.run([user_message], session=session)
137+
async with self._request_agent() as agent:
138+
response = await agent.run([user_message], session=session)
114139
return Response(content=response.text)

0 commit comments

Comments
 (0)