Skip to content

Commit ce8b630

Browse files
TaoChenOSUmoonbox3
andauthored
Python: Foundry hosted agent V2 (#5379)
* Python: Wrapper + Samples 1st (#5177) * Experiment * Update dependency and add non streaming * Add more samples * Rename samples * Add invocations * Comments 1 * Comments 2 * Comments 3 * Improve README * Add local shell sample * WIP: Add eval and memory samples * Update user agent prefix * Update user agent prefix doc * Update dependency (#5215) * Add tests and more content types (#5235) * Add tests * fix tests and sample * Fix formatting * Remove function approval contents * Python: Refine samples and upgrade packages (#5261) * Refine samples and upgrade pacakges * Upgrade to a new package that fixes a bug * Update model env var * Move samples (#5281) * Python: Upgrade agentserver packages (#5284) * Upgrade agentserver packages * Fix new types * Python: Add special handling for workflows (#5298) * Add special handling for workflows * Address comments * Improve samples (#5372) * Python: Add more types (#5378) * Add more type supports * Upgrade packages * Remove TODOs in README * Fix README * Comments and mypy * User agent scoped * Fix README * Fix pre commit * Fix pre commit 2 * Fix pre commit 3 * Fix pre commit 4 * Fix pre commit 5 * Fix pre commit 6 * Add azure-monitor-opentelemetry to dev deps Fixes Samples & Markdown CI failure. The PR's new transitive dep on azure-monitor-opentelemetry-exporter (via azure-ai-agentserver-core) makes pyright resolve the azure.monitor.opentelemetry namespace, flipping the check_md_code_blocks diagnostic for `configure_azure_monitor` from reportMissingImports (filtered) to reportAttributeAccessIssue (not filtered). Installing the umbrella azure-monitor-opentelemetry package in dev makes pyright resolve the symbol correctly, matching the install guidance the observability README already gives users. --------- Co-authored-by: Evan Mattson <evan.mattson@microsoft.com>
1 parent 07f4c8a commit ce8b630

87 files changed

Lines changed: 3596 additions & 1196 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

python/.cspell.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
],
2525
"words": [
2626
"aeiou",
27+
"agentserver",
2728
"agui",
2829
"aiplatform",
2930
"azuredocindex",

python/packages/core/agent_framework/_telemetry.py

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44

55
import logging
66
import os
7+
from collections.abc import Generator
8+
from contextlib import contextmanager
9+
from contextvars import ContextVar
710
from typing import Any, Final
811

912
from . import __version__ as version_info
@@ -26,6 +29,35 @@
2629
HTTP_USER_AGENT: Final[str] = "agent-framework-python"
2730
AGENT_FRAMEWORK_USER_AGENT = f"{HTTP_USER_AGENT}/{version_info}" # type: ignore[has-type]
2831

32+
_user_agent_prefixes: ContextVar[tuple[str, ...]] = ContextVar("_user_agent_prefixes", default=())
33+
34+
35+
@contextmanager
36+
def user_agent_prefix(prefix: str) -> Generator[None]:
37+
"""Context manager that adds a prefix to the user agent string for the current scope.
38+
39+
This is useful for upstream layers that want to identify themselves in telemetry
40+
for the duration of a request without permanently mutating global state.
41+
42+
Args:
43+
prefix: The prefix to add (e.g. "foundry-hosting").
44+
"""
45+
current = _user_agent_prefixes.get()
46+
token = _user_agent_prefixes.set((*current, prefix)) if prefix and prefix not in current else None
47+
try:
48+
yield
49+
finally:
50+
if token is not None:
51+
_user_agent_prefixes.reset(token)
52+
53+
54+
def _get_user_agent() -> str:
55+
"""Return the full user agent string including any context-scoped prefixes."""
56+
prefixes = _user_agent_prefixes.get()
57+
if not prefixes:
58+
return AGENT_FRAMEWORK_USER_AGENT
59+
return f"{'/'.join(prefixes)}/{AGENT_FRAMEWORK_USER_AGENT}"
60+
2961

3062
def prepend_agent_framework_to_user_agent(headers: dict[str, Any] | None = None) -> dict[str, Any]:
3163
"""Prepend "agent-framework" to the User-Agent in the headers.
@@ -57,12 +89,9 @@ def prepend_agent_framework_to_user_agent(headers: dict[str, Any] | None = None)
5789
"""
5890
if not IS_TELEMETRY_ENABLED:
5991
return headers or {}
92+
user_agent = _get_user_agent()
6093
if not headers:
61-
return {USER_AGENT_KEY: AGENT_FRAMEWORK_USER_AGENT}
62-
headers[USER_AGENT_KEY] = (
63-
f"{AGENT_FRAMEWORK_USER_AGENT} {headers[USER_AGENT_KEY]}"
64-
if USER_AGENT_KEY in headers
65-
else AGENT_FRAMEWORK_USER_AGENT
66-
)
94+
return {USER_AGENT_KEY: user_agent}
95+
headers[USER_AGENT_KEY] = f"{user_agent} {headers[USER_AGENT_KEY]}" if USER_AGENT_KEY in headers else user_agent
6796

6897
return headers

python/packages/core/tests/core/test_telemetry.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
USER_AGENT_TELEMETRY_DISABLED_ENV_VAR,
99
prepend_agent_framework_to_user_agent,
1010
)
11+
from agent_framework._telemetry import user_agent_prefix
1112

1213
# region Test constants
1314

@@ -96,3 +97,56 @@ def test_modifies_original_dict():
9697

9798
assert result is headers # Same object
9899
assert "User-Agent" in headers
100+
101+
102+
# region Test user_agent_prefix context manager
103+
104+
105+
def test_user_agent_prefix_adds_prefix():
106+
"""Test that the context manager adds a prefix within its scope."""
107+
with user_agent_prefix("test-host"):
108+
result = prepend_agent_framework_to_user_agent()
109+
assert result["User-Agent"].startswith("test-host/")
110+
assert AGENT_FRAMEWORK_USER_AGENT in result["User-Agent"]
111+
112+
# Prefix is removed after exiting the context
113+
result = prepend_agent_framework_to_user_agent()
114+
assert result["User-Agent"] == AGENT_FRAMEWORK_USER_AGENT
115+
116+
117+
def test_user_agent_prefix_ignores_duplicates():
118+
"""Test that duplicate prefixes are not added within nested scopes."""
119+
with user_agent_prefix("test-host"), user_agent_prefix("test-host"):
120+
result = prepend_agent_framework_to_user_agent()
121+
assert result["User-Agent"].count("test-host") == 1
122+
123+
124+
def test_user_agent_prefix_ignores_empty():
125+
"""Test that empty strings are not added as prefixes."""
126+
with user_agent_prefix(""):
127+
result = prepend_agent_framework_to_user_agent()
128+
assert result["User-Agent"] == AGENT_FRAMEWORK_USER_AGENT
129+
130+
131+
def test_user_agent_prefix_restores_on_exit():
132+
"""Test that prefixes are fully restored after the context manager exits."""
133+
with user_agent_prefix("test-host"):
134+
pass
135+
result = prepend_agent_framework_to_user_agent()
136+
assert result["User-Agent"] == AGENT_FRAMEWORK_USER_AGENT
137+
138+
139+
def test_user_agent_prefix_nesting():
140+
"""Test that nested context managers compose prefixes correctly."""
141+
with user_agent_prefix("outer"):
142+
with user_agent_prefix("inner"):
143+
result = prepend_agent_framework_to_user_agent()
144+
assert "outer" in result["User-Agent"]
145+
assert "inner" in result["User-Agent"]
146+
# Inner prefix removed
147+
result = prepend_agent_framework_to_user_agent()
148+
assert "outer" in result["User-Agent"]
149+
assert "inner" not in result["User-Agent"]
150+
# Both removed
151+
result = prepend_agent_framework_to_user_agent()
152+
assert result["User-Agent"] == AGENT_FRAMEWORK_USER_AGENT
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) Microsoft Corporation.
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Foundry Hosting
2+
3+
This package provides the integration of Agent Framework agents and workflows with the Foundry Agent Server, which can be hosted on Foundry infrastructure.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# Copyright (c) Microsoft. All rights reserved.
2+
3+
import importlib.metadata
4+
5+
from ._invocations import InvocationsHostServer
6+
from ._responses import ResponsesHostServer
7+
8+
try:
9+
__version__ = importlib.metadata.version(__name__)
10+
except importlib.metadata.PackageNotFoundError:
11+
__version__ = "0.0.0"
12+
13+
__all__ = ["InvocationsHostServer", "ResponsesHostServer"]
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
# Copyright (c) Microsoft. All rights reserved.
2+
3+
from agent_framework import AgentSession, BaseAgent, SupportsAgentRun
4+
from agent_framework._telemetry import user_agent_prefix
5+
from azure.ai.agentserver.invocations import InvocationAgentServerHost
6+
from starlette.requests import Request
7+
from starlette.responses import JSONResponse, Response, StreamingResponse
8+
from typing_extensions import Any, AsyncGenerator
9+
10+
11+
class InvocationsHostServer(InvocationAgentServerHost):
12+
"""An invocations server host for an agent."""
13+
14+
USER_AGENT_PREFIX = "foundry-hosting"
15+
16+
def __init__(
17+
self,
18+
agent: BaseAgent,
19+
*,
20+
openapi_spec: dict[str, Any] | None = None,
21+
**kwargs: Any,
22+
) -> None:
23+
"""Initialize an InvocationsHostServer.
24+
25+
Args:
26+
agent: The agent to handle responses for.
27+
openapi_spec: The OpenAPI specification for the server.
28+
**kwargs: Additional keyword arguments.
29+
30+
This host will expect the request to be a JSON body with a "message" field.
31+
The response from the host will be a JSON object with a "response" field containing
32+
the agent's response and a "session_id" field containing the session ID.
33+
"""
34+
super().__init__(openapi_spec=openapi_spec, **kwargs)
35+
36+
if not isinstance(agent, SupportsAgentRun):
37+
raise TypeError("Agent must support the SupportsAgentRun interface")
38+
39+
self._agent = agent
40+
self._sessions: dict[str, AgentSession] = {}
41+
self.invoke_handler(self._handle_invoke) # pyright: ignore[reportUnknownMemberType]
42+
43+
async def _handle_invoke(self, request: Request) -> Response:
44+
"""Invoke the agent with the given request."""
45+
with user_agent_prefix(self.USER_AGENT_PREFIX):
46+
return await self._handle_invoke_inner(request)
47+
48+
async def _handle_invoke_inner(self, request: Request) -> Response:
49+
"""Core invoke handler logic."""
50+
data = await request.json()
51+
session_id: str = request.state.session_id
52+
53+
stream = data.get("stream", False)
54+
user_message = data.get("message", None)
55+
if user_message is None:
56+
error = "Missing 'message' in request"
57+
if stream:
58+
return StreamingResponse(content=error, status_code=400)
59+
return Response(content=error, status_code=400)
60+
61+
session = self._sessions.setdefault(session_id, AgentSession(session_id=session_id))
62+
63+
if stream:
64+
65+
async def stream_response() -> AsyncGenerator[str]:
66+
async for update in self._agent.run(user_message, session=session, stream=True):
67+
if update.text:
68+
yield update.text
69+
70+
return StreamingResponse(
71+
stream_response(),
72+
media_type="text/event-stream",
73+
headers={"Cache-Control": "no-cache", "Connection": "keep-alive"},
74+
)
75+
76+
response = await self._agent.run([user_message], session=session, stream=stream)
77+
return JSONResponse({
78+
"response": response.text,
79+
"session_id": session_id,
80+
})

0 commit comments

Comments
 (0)