agent-framework-foundry 1.1.0__tar.gz → 1.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agent-framework-foundry
3
- Version: 1.1.0
3
+ Version: 1.2.0
4
4
  Summary: Microsoft Foundry integrations for Microsoft Agent Framework.
5
5
  Author-email: Microsoft <af-support@microsoft.com>
6
6
  Requires-Python: >=3.10
@@ -16,7 +16,7 @@ Classifier: Programming Language :: Python :: 3.13
16
16
  Classifier: Programming Language :: Python :: 3.14
17
17
  Classifier: Typing :: Typed
18
18
  License-File: LICENSE
19
- Requires-Dist: agent-framework-core>=1.1.0,<2
19
+ Requires-Dist: agent-framework-core>=1.2.0,<2
20
20
  Requires-Dist: agent-framework-openai>=1.1.0,<2
21
21
  Requires-Dist: azure-ai-inference>=1.0.0b9,<1.0.0b10
22
22
  Requires-Dist: azure-ai-projects>=2.1.0,<3.0
@@ -2,7 +2,7 @@
2
2
 
3
3
  import importlib.metadata
4
4
 
5
- from ._agent import FoundryAgent, RawFoundryAgent, RawFoundryAgentChatClient
5
+ from ._agent import FoundryAgent, FoundryAgentOptions, RawFoundryAgent, RawFoundryAgentChatClient
6
6
  from ._chat_client import FoundryChatClient, FoundryChatOptions, RawFoundryChatClient
7
7
  from ._embedding_client import (
8
8
  FoundryEmbeddingClient,
@@ -25,6 +25,7 @@ except importlib.metadata.PackageNotFoundError:
25
25
 
26
26
  __all__ = [
27
27
  "FoundryAgent",
28
+ "FoundryAgentOptions",
28
29
  "FoundryChatClient",
29
30
  "FoundryChatOptions",
30
31
  "FoundryEmbeddingClient",
@@ -15,10 +15,11 @@ from collections.abc import Awaitable, Callable, Mapping, MutableMapping, Sequen
15
15
  from typing import TYPE_CHECKING, Any, ClassVar, Generic, cast
16
16
 
17
17
  from agent_framework import (
18
- AGENT_FRAMEWORK_USER_AGENT,
19
18
  AgentMiddlewareLayer,
19
+ AgentSession,
20
20
  ChatAndFunctionMiddlewareTypes,
21
21
  ChatMiddlewareLayer,
22
+ ChatResponseUpdate,
22
23
  ContextProvider,
23
24
  FunctionInvocationConfiguration,
24
25
  FunctionInvocationLayer,
@@ -28,13 +29,16 @@ from agent_framework import (
28
29
  load_settings,
29
30
  )
30
31
  from agent_framework._compaction import CompactionStrategy, TokenizerProtocol
32
+ from agent_framework._telemetry import get_user_agent
31
33
  from agent_framework.observability import AgentTelemetryLayer, ChatTelemetryLayer
32
34
  from agent_framework_openai._chat_client import OpenAIChatOptions, RawOpenAIChatClient
33
35
  from azure.ai.projects.aio import AIProjectClient
34
36
  from azure.core.credentials import TokenCredential
35
37
  from azure.core.credentials_async import AsyncTokenCredential
36
38
 
37
- from ._tools import sanitize_foundry_response_tool
39
+ from agent_framework_foundry._oauth_helpers import try_parse_oauth_consent_event
40
+
41
+ from ._tools import _sanitize_foundry_response_tool # pyright: ignore[reportPrivateUsage]
38
42
 
39
43
  if sys.version_info >= (3, 13):
40
44
  from typing import TypeVar # type: ignore # pragma: no cover
@@ -52,11 +56,13 @@ else:
52
56
  if TYPE_CHECKING:
53
57
  from agent_framework import (
54
58
  Agent,
59
+ AgentRunInputs,
55
60
  ChatAndFunctionMiddlewareTypes,
56
61
  ContextProvider,
57
62
  MiddlewareTypes,
58
63
  ToolTypes,
59
64
  )
65
+ from agent_framework._agents import _RunContext # pyright: ignore[reportPrivateUsage]
60
66
 
61
67
  logger: logging.Logger = logging.getLogger("agent_framework.foundry")
62
68
 
@@ -81,14 +87,54 @@ class FoundryAgentSettings(TypedDict, total=False):
81
87
  agent_version: str | None
82
88
 
83
89
 
90
+ class FoundryAgentOptions(OpenAIChatOptions, total=False):
91
+ """Microsoft Foundry agent-specific chat options.
92
+
93
+ Extends ``OpenAIChatOptions`` with hosted-agent session configuration used by
94
+ ``FoundryAgent`` / ``RawFoundryAgent``.
95
+
96
+ Keyword Args:
97
+ extra_body: Additional request body values sent to the Responses API.
98
+ isolation_key: Isolation key used when lazily creating a hosted-agent
99
+ session through ``project_client.beta.agents.create_session(...)``.
100
+ """
101
+
102
+ extra_body: dict[str, Any]
103
+ isolation_key: str
104
+
105
+
84
106
  FoundryAgentOptionsT = TypeVar(
85
107
  "FoundryAgentOptionsT",
86
108
  bound=TypedDict, # type: ignore[valid-type]
87
- default="OpenAIChatOptions",
109
+ default="FoundryAgentOptions",
88
110
  covariant=True,
89
111
  )
90
112
 
91
113
 
114
+ def _merge_extra_body(extra_body: Any | None, *, additions: Mapping[str, Any] | None = None) -> dict[str, Any]:
115
+ """Normalize and merge provider-specific extra_body values."""
116
+ if extra_body is None:
117
+ merged: dict[str, Any] = {}
118
+ elif isinstance(extra_body, Mapping):
119
+ merged = dict(cast(Mapping[str, Any], extra_body))
120
+ else:
121
+ raise TypeError(f"extra_body must be a mapping when provided, got {type(extra_body).__name__}.")
122
+
123
+ if additions:
124
+ merged.update(additions)
125
+ return merged
126
+
127
+
128
+ def _uses_foundry_agent_session(conversation_id: Any) -> bool:
129
+ """Return whether a conversation_id should be treated as a Foundry agent session id."""
130
+ return (
131
+ isinstance(conversation_id, str)
132
+ and bool(conversation_id)
133
+ and not conversation_id.startswith("resp_")
134
+ and not conversation_id.startswith("conv_")
135
+ )
136
+
137
+
92
138
  class RawFoundryAgentChatClient( # type: ignore[misc]
93
139
  RawOpenAIChatClient[FoundryAgentOptionsT],
94
140
  Generic[FoundryAgentOptionsT],
@@ -167,13 +213,15 @@ class RawFoundryAgentChatClient( # type: ignore[misc]
167
213
  )
168
214
 
169
215
  resolved_endpoint = settings.get("project_endpoint")
170
- self.agent_name = settings.get("agent_name")
171
- self.agent_version = settings.get("agent_version")
216
+ agent_name_setting = settings.get("agent_name")
217
+ self.agent_version: str | None = settings.get("agent_version")
218
+ self.allow_preview = allow_preview or False
172
219
 
173
- if not self.agent_name:
220
+ if not agent_name_setting:
174
221
  raise ValueError(
175
222
  "Agent name is required. Set via 'agent_name' parameter or 'FOUNDRY_AGENT_NAME' environment variable."
176
223
  )
224
+ self.agent_name = agent_name_setting
177
225
 
178
226
  # Create or use provided project client
179
227
  self._should_close_client = False
@@ -190,18 +238,20 @@ class RawFoundryAgentChatClient( # type: ignore[misc]
190
238
  project_client_kwargs: dict[str, Any] = {
191
239
  "endpoint": resolved_endpoint,
192
240
  "credential": credential,
193
- "user_agent": AGENT_FRAMEWORK_USER_AGENT,
241
+ "user_agent": get_user_agent(),
194
242
  }
195
243
  if allow_preview is not None:
196
244
  project_client_kwargs["allow_preview"] = allow_preview
197
245
  self.project_client = AIProjectClient(**project_client_kwargs)
198
246
  self._should_close_client = True
199
247
 
200
- # Get OpenAI client from project
201
- async_client = self.project_client.get_openai_client()
202
-
248
+ openai_client_kwargs: dict[str, Any] = {}
249
+ if default_headers:
250
+ openai_client_kwargs["default_headers"] = dict(default_headers)
251
+ if allow_preview:
252
+ openai_client_kwargs["agent_name"] = self.agent_name
203
253
  super().__init__(
204
- async_client=async_client,
254
+ async_client=self.project_client.get_openai_client(**openai_client_kwargs),
205
255
  default_headers=default_headers,
206
256
  instruction_role=instruction_role,
207
257
  compaction_strategy=compaction_strategy,
@@ -209,13 +259,6 @@ class RawFoundryAgentChatClient( # type: ignore[misc]
209
259
  additional_properties=additional_properties,
210
260
  )
211
261
 
212
- def _get_agent_reference(self) -> dict[str, str]:
213
- """Build the agent reference dict for the Responses API."""
214
- ref: dict[str, str] = {"name": self.agent_name, "type": "agent_reference"} # type: ignore[dict-item]
215
- if self.agent_version:
216
- ref["version"] = self.agent_version
217
- return ref
218
-
219
262
  @override
220
263
  def as_agent(
221
264
  self,
@@ -270,7 +313,7 @@ class RawFoundryAgentChatClient( # type: ignore[misc]
270
313
  options: Mapping[str, Any],
271
314
  **kwargs: Any,
272
315
  ) -> dict[str, Any]:
273
- """Prepare options for the Responses API, injecting agent reference and validating tools."""
316
+ """Prepare options for the Responses API and validate client-side tools."""
274
317
  # Validate tools — only FunctionTool allowed
275
318
  tools = options.get("tools", [])
276
319
  if tools:
@@ -292,18 +335,61 @@ class RawFoundryAgentChatClient( # type: ignore[misc]
292
335
  if "input" in run_options and isinstance(run_options["input"], list):
293
336
  run_options["input"] = self._transform_input_for_azure_ai(cast(list[dict[str, Any]], run_options["input"]))
294
337
 
295
- # Inject agent reference
296
- run_options["extra_body"] = {"agent_reference": self._get_agent_reference()}
338
+ # Merge caller-supplied extra_body with any agent-specific request payload.
339
+ conversation_id = options.get("conversation_id")
340
+ extra_body = _merge_extra_body(run_options.pop("extra_body", None))
341
+ if _uses_foundry_agent_session(conversation_id):
342
+ run_options.pop("previous_response_id", None)
343
+ run_options.pop("conversation", None)
344
+ extra_body["agent_session_id"] = conversation_id
345
+ if extra_body:
346
+ run_options["extra_body"] = extra_body
347
+
348
+ run_options.pop("isolation_key", None)
297
349
 
298
350
  # Strip tools from request body - Foundry API rejects requests with both
299
- # agent_reference and tools present. FunctionTools are invoked client-side
351
+ # agent endpoint and tools present. FunctionTools are invoked client-side
300
352
  # by the function invocation layer, not sent to the service.
301
- run_options.pop("tools", None)
302
- run_options.pop("tool_choice", None)
303
- run_options.pop("parallel_tool_calls", None)
353
+ run_options.pop("model", None)
354
+ if not self.allow_preview:
355
+ run_options.pop("tools", None)
356
+ run_options.pop("tool_choice", None)
357
+ run_options.pop("parallel_tool_calls", None)
304
358
 
305
359
  return run_options
306
360
 
361
+ @override
362
+ def _parse_response_from_openai(
363
+ self,
364
+ response: Any,
365
+ options: dict[str, Any],
366
+ ) -> Any:
367
+ parsed_response = super()._parse_response_from_openai(response, options)
368
+ if _uses_foundry_agent_session(options.get("conversation_id")):
369
+ parsed_response.conversation_id = None
370
+ return parsed_response
371
+
372
+ @override
373
+ def _parse_chunk_from_openai(
374
+ self,
375
+ event: Any,
376
+ options: dict[str, Any],
377
+ function_call_ids: dict[int, tuple[str, str]],
378
+ seen_reasoning_delta_item_ids: set[str] | None = None,
379
+ ) -> ChatResponseUpdate:
380
+ """Parse streaming events while preserving hosted-agent session state."""
381
+ update = try_parse_oauth_consent_event(event, self.model)
382
+ if update is None:
383
+ update = super()._parse_chunk_from_openai(
384
+ event,
385
+ options,
386
+ function_call_ids,
387
+ seen_reasoning_delta_item_ids,
388
+ )
389
+ if _uses_foundry_agent_session(options.get("conversation_id")):
390
+ update.conversation_id = None
391
+ return update
392
+
307
393
  @override
308
394
  def _check_model_presence(self, options: dict[str, Any]) -> None:
309
395
  """Skip model check — model is configured on the Foundry agent."""
@@ -321,7 +407,7 @@ class RawFoundryAgentChatClient( # type: ignore[misc]
321
407
  surface.
322
408
  """
323
409
  response_tools = super()._prepare_tools_for_openai(tools)
324
- return [sanitize_foundry_response_tool(tool_item) for tool_item in response_tools]
410
+ return [_sanitize_foundry_response_tool(tool_item) for tool_item in response_tools]
325
411
 
326
412
  def _prepare_messages_for_azure_ai(self, messages: Sequence[Message]) -> tuple[list[Message], str | None]:
327
413
  """Extract system/developer messages as instructions for Azure AI.
@@ -368,6 +454,26 @@ class RawFoundryAgentChatClient( # type: ignore[misc]
368
454
 
369
455
  return transformed
370
456
 
457
+ async def get_agent_version(self) -> str | None:
458
+ """Return the agent version if available, else None."""
459
+ if self.agent_version is not None:
460
+ return self.agent_version
461
+ if not self.allow_preview:
462
+ return None
463
+ agent_details = await cast(Any, self.project_client.beta.agents).get( # pyright: ignore[reportAttributeAccessIssue, reportUnknownMemberType]
464
+ agent_name=self.agent_name
465
+ )
466
+ versions_object = getattr(agent_details, "versions", None)
467
+ if not isinstance(versions_object, Mapping):
468
+ raise TypeError("Foundry agent details did not include a versions mapping.")
469
+ versions = cast(Mapping[str, Any], versions_object)
470
+ latest_version = versions.get("latest")
471
+ agent_version = getattr(cast(Any, latest_version), "version", None)
472
+ if not isinstance(agent_version, str):
473
+ raise TypeError("Foundry agent details did not include a latest version string.")
474
+ self.agent_version = agent_version
475
+ return agent_version
476
+
371
477
  async def close(self) -> None:
372
478
  """Close the project client if we created it."""
373
479
  if self._should_close_client:
@@ -395,7 +501,7 @@ class _FoundryAgentChatClient( # type: ignore[misc]
395
501
  client = FoundryAgentClient(
396
502
  project_endpoint="https://your-project.services.ai.azure.com",
397
503
  agent_name="my-prompt-agent",
398
- agent_version="1.0",
504
+ agent_version="1",
399
505
  credential=AzureCliCredential(),
400
506
  )
401
507
 
@@ -477,7 +583,7 @@ class RawFoundryAgent( # type: ignore[misc]
477
583
  agent = RawFoundryAgent(
478
584
  project_endpoint="https://your-project.services.ai.azure.com",
479
585
  agent_name="my-prompt-agent",
480
- agent_version="1.0",
586
+ agent_version="1",
481
587
  credential=AzureCliCredential(),
482
588
  )
483
589
  result = await agent.run("Hello!")
@@ -570,7 +676,7 @@ class RawFoundryAgent( # type: ignore[misc]
570
676
  client=client, # type: ignore[arg-type]
571
677
  instructions=instructions,
572
678
  id=id,
573
- name=name,
679
+ name=name or agent_name,
574
680
  description=description,
575
681
  tools=tools, # type: ignore[arg-type]
576
682
  default_options=cast(FoundryAgentOptionsT | None, default_options),
@@ -582,6 +688,81 @@ class RawFoundryAgent( # type: ignore[misc]
582
688
  additional_properties=dict(additional_properties) if additional_properties is not None else None,
583
689
  )
584
690
 
691
+ def _resolve_service_session_isolation_key(self, isolation_key: str | None = None) -> str:
692
+ """Resolve the isolation key from an explicit value or default_options."""
693
+ resolved_isolation_key = (
694
+ isolation_key if isolation_key is not None else self.default_options.get("isolation_key")
695
+ )
696
+ if resolved_isolation_key is None:
697
+ raise ValueError("isolation_key is required. Pass it explicitly or set default_options['isolation_key'].")
698
+ return resolved_isolation_key
699
+
700
+ async def _create_service_session_id(
701
+ self,
702
+ *,
703
+ isolation_key: str | None = None,
704
+ ) -> str:
705
+ """Create a hosted Foundry service session and return the service session ID."""
706
+ if not isinstance(self.client, RawFoundryAgentChatClient):
707
+ raise TypeError("_create_service_session_id requires a RawFoundryAgentChatClient-based client.")
708
+ if not self.client.allow_preview:
709
+ raise RuntimeError("Hosted Foundry service sessions require allow_preview=True.")
710
+
711
+ create_session_kwargs: dict[str, Any] = {
712
+ "agent_name": self.client.agent_name,
713
+ "isolation_key": self._resolve_service_session_isolation_key(isolation_key),
714
+ }
715
+ if version := await self.client.get_agent_version():
716
+ from azure.ai.projects.models import VersionRefIndicator
717
+
718
+ create_session_kwargs["version_indicator"] = VersionRefIndicator(agent_version=version) # type: ignore
719
+
720
+ service_session = await self.client.project_client.beta.agents.create_session(**create_session_kwargs)
721
+ agent_session_id = getattr(service_session, "agent_session_id", None)
722
+ if not isinstance(agent_session_id, str) or not agent_session_id:
723
+ raise ValueError("Hosted Foundry session creation did not return a non-empty agent_session_id.")
724
+
725
+ return agent_session_id
726
+
727
+ @override
728
+ async def _prepare_run_context(
729
+ self,
730
+ *,
731
+ messages: AgentRunInputs | None,
732
+ session: AgentSession | None,
733
+ tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None,
734
+ options: Mapping[str, Any] | None,
735
+ compaction_strategy: CompactionStrategy | None,
736
+ tokenizer: TokenizerProtocol | None,
737
+ function_invocation_kwargs: Mapping[str, Any] | None,
738
+ client_kwargs: Mapping[str, Any] | None,
739
+ ) -> _RunContext:
740
+ runtime_options = dict(options) if options else {}
741
+ effective_options = {
742
+ **{key: value for key, value in self.default_options.items() if value is not None},
743
+ **{key: value for key, value in runtime_options.items() if value is not None},
744
+ }
745
+
746
+ if (
747
+ session is not None
748
+ and session.service_session_id is None
749
+ and effective_options.get("isolation_key") is not None
750
+ ):
751
+ session.service_session_id = await self._create_service_session_id(
752
+ isolation_key=cast(str | None, effective_options.get("isolation_key")),
753
+ )
754
+
755
+ return await super()._prepare_run_context(
756
+ messages=messages,
757
+ session=session,
758
+ tools=tools,
759
+ options=runtime_options,
760
+ compaction_strategy=compaction_strategy,
761
+ tokenizer=tokenizer,
762
+ function_invocation_kwargs=function_invocation_kwargs,
763
+ client_kwargs=client_kwargs,
764
+ )
765
+
585
766
  async def configure_azure_monitor(
586
767
  self,
587
768
  enable_sensitive_data: bool = False,
@@ -708,6 +889,19 @@ class FoundryAgent( # type: ignore[misc]
708
889
  ) -> None:
709
890
  """Initialize a Foundry Agent with full middleware and telemetry.
710
891
 
892
+ ``FoundryAgent`` supports both PromptAgents and HostedAgents. PromptAgents
893
+ typically provide ``agent_version`` directly. HostedAgents can omit
894
+ ``agent_version`` and, when they need preview-only session APIs, should
895
+ opt in with ``allow_preview=True`` when this class creates the underlying
896
+ ``AIProjectClient``. If you pass ``project_client`` explicitly, it must
897
+ already be configured for preview APIs before being passed to
898
+ ``FoundryAgent``.
899
+
900
+ To lazily create HostedAgent service sessions inside the agent, pass an
901
+ ``isolation_key`` through ``default_options`` (or per-run options). The
902
+ agent stores the resulting HostedAgent session ID in
903
+ ``AgentSession.service_session_id`` and reuses it on subsequent runs.
904
+
711
905
  Keyword Args:
712
906
  project_endpoint: The Foundry project endpoint URL.
713
907
  agent_name: The name of the Foundry agent to connect to.
@@ -715,6 +909,9 @@ class FoundryAgent( # type: ignore[misc]
715
909
  credential: Azure credential for authentication.
716
910
  project_client: An existing AIProjectClient to use.
717
911
  allow_preview: Enables preview opt-in on internally-created AIProjectClient.
912
+ Set this to ``True`` for HostedAgents that need preview-only
913
+ session APIs, including lazy service session creation from
914
+ ``isolation_key``.
718
915
  tools: Function tools to provide to the agent. Only ``FunctionTool`` objects are accepted.
719
916
  context_providers: Optional context providers.
720
917
  middleware: Optional agent-level middleware.
@@ -726,6 +923,8 @@ class FoundryAgent( # type: ignore[misc]
726
923
  description: Optional local description for the local agent wrapper.
727
924
  instructions: Optional instructions for the local agent wrapper.
728
925
  default_options: Default chat options for the local agent wrapper.
926
+ ``FoundryAgentOptions`` can include ``isolation_key`` and
927
+ ``extra_body`` when working with HostedAgents.
729
928
  require_per_service_call_history_persistence: Whether to require per-service-call
730
929
  chat history persistence when using local history providers.
731
930
  function_invocation_configuration: Optional function invocation configuration override.
@@ -8,8 +8,8 @@ from collections.abc import Awaitable, Callable, Mapping, Sequence
8
8
  from typing import TYPE_CHECKING, Any, ClassVar, Generic, Literal
9
9
 
10
10
  from agent_framework import (
11
- AGENT_FRAMEWORK_USER_AGENT,
12
11
  ChatMiddlewareLayer,
12
+ ChatResponseUpdate,
13
13
  Content,
14
14
  FunctionInvocationConfiguration,
15
15
  FunctionInvocationLayer,
@@ -17,6 +17,7 @@ from agent_framework import (
17
17
  )
18
18
  from agent_framework._compaction import CompactionStrategy, TokenizerProtocol
19
19
  from agent_framework._feature_stage import ExperimentalFeature, experimental
20
+ from agent_framework._telemetry import get_user_agent
20
21
  from agent_framework.observability import ChatTelemetryLayer
21
22
  from agent_framework_openai._chat_client import OpenAIChatOptions, RawOpenAIChatClient
22
23
  from azure.ai.projects.aio import AIProjectClient
@@ -33,7 +34,9 @@ from azure.ai.projects.models import MCPTool as FoundryMCPTool
33
34
  from azure.core.credentials import TokenCredential
34
35
  from azure.core.credentials_async import AsyncTokenCredential
35
36
 
36
- from ._tools import fetch_toolbox, sanitize_foundry_response_tool
37
+ from agent_framework_foundry._oauth_helpers import try_parse_oauth_consent_event
38
+
39
+ from ._tools import _sanitize_foundry_response_tool, fetch_toolbox # pyright: ignore[reportPrivateUsage]
37
40
 
38
41
  if sys.version_info >= (3, 13):
39
42
  from typing import TypeVar # type: ignore # pragma: no cover
@@ -198,15 +201,19 @@ class RawFoundryChatClient( # type: ignore[misc]
198
201
  project_client_kwargs: dict[str, Any] = {
199
202
  "endpoint": project_endpoint,
200
203
  "credential": credential, # type: ignore[arg-type]
201
- "user_agent": AGENT_FRAMEWORK_USER_AGENT,
204
+ "user_agent": get_user_agent(),
202
205
  }
203
206
  if allow_preview is not None:
204
207
  project_client_kwargs["allow_preview"] = allow_preview
205
208
  project_client = AIProjectClient(**project_client_kwargs)
206
209
 
210
+ openai_kwargs: dict[str, Any] = {}
211
+ if default_headers:
212
+ openai_kwargs["default_headers"] = default_headers
213
+
207
214
  super().__init__(
208
215
  model=resolved_model,
209
- async_client=project_client.get_openai_client(),
216
+ async_client=project_client.get_openai_client(**openai_kwargs),
210
217
  default_headers=default_headers,
211
218
  instruction_role=instruction_role,
212
219
  compaction_strategy=compaction_strategy,
@@ -235,7 +242,21 @@ class RawFoundryChatClient( # type: ignore[misc]
235
242
  them downstream.
236
243
  """
237
244
  response_tools = super()._prepare_tools_for_openai(tools)
238
- return [sanitize_foundry_response_tool(tool_item) for tool_item in response_tools]
245
+ return [_sanitize_foundry_response_tool(tool_item) for tool_item in response_tools]
246
+
247
+ @override
248
+ def _parse_chunk_from_openai(
249
+ self,
250
+ event: Any,
251
+ options: dict[str, Any],
252
+ function_call_ids: dict[int, tuple[str, str]],
253
+ seen_reasoning_delta_item_ids: set[str] | None = None,
254
+ ) -> ChatResponseUpdate:
255
+ """Parse streaming event, intercepting oauth_consent_request items."""
256
+ update = try_parse_oauth_consent_event(event, self.model)
257
+ if update is not None:
258
+ return update
259
+ return super()._parse_chunk_from_openai(event, options, function_call_ids, seen_reasoning_delta_item_ids)
239
260
 
240
261
  async def configure_azure_monitor(
241
262
  self,
@@ -455,8 +476,18 @@ class RawFoundryChatClient( # type: ignore[misc]
455
476
 
456
477
  Returns:
457
478
  An MCPTool configuration ready to pass to an Agent.
479
+
480
+ Raises:
481
+ ValueError: If neither ``url`` nor ``project_connection_id`` is supplied
482
+ — one is required by the Foundry Responses API.
458
483
  """
459
- mcp = FoundryMCPTool(server_label=name.replace(" ", "_"), server_url=url or "", **kwargs)
484
+ if not url and not project_connection_id:
485
+ raise ValueError("MCP tool requires either 'url' or 'project_connection_id' to be specified.")
486
+
487
+ mcp_kwargs: dict[str, Any] = {"server_label": name.replace(" ", "_"), **kwargs}
488
+ if url:
489
+ mcp_kwargs["server_url"] = url
490
+ mcp = FoundryMCPTool(**mcp_kwargs)
460
491
 
461
492
  if description:
462
493
  mcp["server_description"] = description
@@ -75,6 +75,11 @@ _TOOL_EVALUATORS: set[str] = {
75
75
  "builtin.tool_call_success",
76
76
  }
77
77
 
78
+ # Evaluators that require a ground_truth / expected_output field.
79
+ _GROUND_TRUTH_EVALUATORS: set[str] = {
80
+ "builtin.similarity",
81
+ }
82
+
78
83
  _BUILTIN_EVALUATORS: dict[str, str] = {
79
84
  # Agent behavior
80
85
  "intent_resolution": "builtin.intent_resolution",
@@ -196,6 +201,8 @@ def _build_testing_criteria(
196
201
  }
197
202
  if qualified == "builtin.groundedness":
198
203
  mapping["context"] = "{{item.context}}"
204
+ if qualified in _GROUND_TRUTH_EVALUATORS:
205
+ mapping["ground_truth"] = "{{item.ground_truth}}"
199
206
  if qualified in _TOOL_EVALUATORS:
200
207
  mapping["tool_definitions"] = "{{item.tool_definitions}}"
201
208
  entry["data_mapping"] = mapping
@@ -204,7 +211,9 @@ def _build_testing_criteria(
204
211
  return criteria
205
212
 
206
213
 
207
- def _build_item_schema(*, has_context: bool = False, has_tools: bool = False) -> dict[str, Any]:
214
+ def _build_item_schema(
215
+ *, has_context: bool = False, has_tools: bool = False, has_ground_truth: bool = False
216
+ ) -> dict[str, Any]:
208
217
  """Build the ``item_schema`` for custom JSONL eval definitions."""
209
218
  properties: dict[str, Any] = {
210
219
  "query": {"type": "string"},
@@ -214,6 +223,8 @@ def _build_item_schema(*, has_context: bool = False, has_tools: bool = False) ->
214
223
  }
215
224
  if has_context:
216
225
  properties["context"] = {"type": "string"}
226
+ if has_ground_truth:
227
+ properties["ground_truth"] = {"type": "string"}
217
228
  if has_tools:
218
229
  properties["tool_definitions"] = {"type": "array"}
219
230
  return {
@@ -681,16 +692,21 @@ class FoundryEvals:
681
692
  ]
682
693
  if item.context:
683
694
  d["context"] = item.context
695
+ if item.expected_output is not None:
696
+ d["ground_truth"] = item.expected_output
684
697
  dicts.append(d)
685
698
 
686
699
  has_context = any("context" in d for d in dicts)
700
+ has_ground_truth = any("ground_truth" in d for d in dicts)
687
701
  has_tools = any("tool_definitions" in d for d in dicts)
688
702
 
689
703
  eval_obj = await self._client.evals.create(
690
704
  name=eval_name,
691
705
  data_source_config={ # type: ignore[arg-type] # pyright: ignore[reportArgumentType]
692
706
  "type": "custom",
693
- "item_schema": _build_item_schema(has_context=has_context, has_tools=has_tools),
707
+ "item_schema": _build_item_schema(
708
+ has_context=has_context, has_ground_truth=has_ground_truth, has_tools=has_tools
709
+ ),
694
710
  "include_sample_schema": True,
695
711
  },
696
712
  testing_criteria=_build_testing_criteria( # type: ignore[arg-type] # pyright: ignore[reportArgumentType]
@@ -14,13 +14,13 @@ from contextlib import AbstractAsyncContextManager
14
14
  from typing import TYPE_CHECKING, Any, ClassVar
15
15
 
16
16
  from agent_framework import (
17
- AGENT_FRAMEWORK_USER_AGENT,
18
17
  AgentSession,
19
18
  ContextProvider,
20
19
  Message,
21
20
  SessionContext,
22
21
  load_settings,
23
22
  )
23
+ from agent_framework._telemetry import get_user_agent
24
24
  from azure.ai.projects.aio import AIProjectClient
25
25
  from azure.core.credentials import TokenCredential
26
26
  from azure.core.credentials_async import AsyncTokenCredential
@@ -119,7 +119,7 @@ class FoundryMemoryProvider(ContextProvider):
119
119
  project_client_kwargs: dict[str, Any] = {
120
120
  "endpoint": resolved_endpoint,
121
121
  "credential": credential, # type: ignore[arg-type]
122
- "user_agent": AGENT_FRAMEWORK_USER_AGENT,
122
+ "user_agent": get_user_agent(),
123
123
  }
124
124
  if allow_preview is not None:
125
125
  project_client_kwargs["allow_preview"] = allow_preview
@@ -0,0 +1,75 @@
1
+ # Copyright (c) Microsoft. All rights reserved.
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import Any
7
+ from urllib.parse import urlparse
8
+
9
+ from agent_framework import ChatResponseUpdate, Content
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ def _validate_consent_link(consent_link: str, item_id: str) -> str:
15
+ """Validate a consent link is HTTPS with a valid netloc.
16
+
17
+ Returns the link unchanged if valid, or an empty string if not.
18
+ """
19
+ parsed = urlparse(consent_link)
20
+ if parsed.scheme.lower() != "https" or not parsed.netloc:
21
+ logger.warning(
22
+ "Skipping oauth_consent_request with non-HTTPS consent_link (item id=%s)",
23
+ item_id,
24
+ )
25
+ return ""
26
+ return consent_link
27
+
28
+
29
+ def try_parse_oauth_consent_event(event: Any, model: str) -> ChatResponseUpdate | None:
30
+ """Parse an oauth_consent_request from a streaming event, if present.
31
+
32
+ Returns a ``ChatResponseUpdate`` when *event* is a
33
+ ``response.output_item.added`` carrying an ``oauth_consent_request`` item
34
+ or a top-level ``response.oauth_consent_requested`` event,
35
+ or ``None`` so the caller can fall through to the base implementation.
36
+ """
37
+ consent_link: str = ""
38
+ raw_item: Any = None
39
+
40
+ event_type = getattr(event, "type", None)
41
+
42
+ if event_type == "response.output_item.added" and getattr(event.item, "type", None) == "oauth_consent_request":
43
+ raw_item = event.item
44
+ consent_link = getattr(raw_item, "consent_link", None) or ""
45
+ elif event_type == "response.oauth_consent_requested":
46
+ raw_item = event
47
+ consent_link = getattr(event, "consent_link", None) or ""
48
+ else:
49
+ return None
50
+
51
+ item_id = getattr(raw_item, "id", "<unknown>")
52
+
53
+ if consent_link:
54
+ consent_link = _validate_consent_link(consent_link, item_id)
55
+
56
+ contents: list[Content] = []
57
+ if consent_link:
58
+ contents.append(
59
+ Content.from_oauth_consent_request(
60
+ consent_link=consent_link,
61
+ raw_representation=raw_item,
62
+ )
63
+ )
64
+ else:
65
+ logger.warning(
66
+ "Received oauth_consent_request output without valid consent_link (item id=%s)",
67
+ item_id,
68
+ )
69
+
70
+ return ChatResponseUpdate(
71
+ contents=contents,
72
+ role="assistant",
73
+ model=model,
74
+ raw_representation=event,
75
+ )
@@ -133,26 +133,54 @@ def select_toolbox_tools(
133
133
  return selected
134
134
 
135
135
 
136
- @experimental(feature_id=ExperimentalFeature.TOOLBOXES)
137
- def sanitize_foundry_response_tool(tool_item: Any) -> Any:
138
- """Return a Responses-API-safe tool payload for Foundry hosted tools.
139
-
140
- Azure AI Projects toolbox reads can currently return hosted tool objects with
141
- extra read-model decoration fields such as top-level ``name`` and
142
- ``description``. Azure AI Foundry rejects at least ``name`` on Responses API
143
- requests with:
136
+ def _validate_hosted_tool_payload(sanitized: Mapping[str, Any]) -> None:
137
+ """Fail fast on hosted tool payloads that would always be rejected by the Responses API.
144
138
 
145
- ``Unknown parameter: 'tools[0].name'``.
139
+ These mismatches are not injectable defaults — the caller must supply the
140
+ missing information — so surfacing a clear error here points at the toolbox
141
+ definition instead of letting the API return a generic 400.
142
+ """
143
+ tool_type = sanitized.get("type")
144
+ if tool_type == "file_search" and not sanitized.get("vector_store_ids"):
145
+ raise ValueError(
146
+ "'file_search' tool is missing required 'vector_store_ids'. "
147
+ "If this came from a Foundry toolbox, update the toolbox definition "
148
+ "to include at least one vector store ID."
149
+ )
150
+ if tool_type == "mcp" and not sanitized.get("server_url") and not sanitized.get("project_connection_id"):
151
+ raise ValueError(
152
+ "'mcp' tool is missing both 'server_url' and 'project_connection_id'. "
153
+ "If this came from a Foundry toolbox, update the toolbox definition "
154
+ "to include one of these."
155
+ )
156
+
157
+
158
+ def _sanitize_foundry_response_tool(tool_item: Any) -> Any: # pyright: ignore[reportUnusedFunction]
159
+ """Return a Responses-API-safe tool payload for Foundry hosted tools.
146
160
 
147
- We defensively strip these decoration fields for non-function hosted tools so
148
- the round-trip
149
- ``toolbox.tools -> Agent(..., tools=...) -> run()`` works, while the Azure
150
- SDK/service behavior is corrected upstream.
161
+ Reconciles known mismatches between toolbox reads and the Responses API:
162
+
163
+ 1. Toolbox reads can return hosted tool objects decorated with read-model
164
+ fields such as top-level ``name`` and ``description``. The Responses API
165
+ rejects at least ``name`` with ``Unknown parameter: 'tools[0].name'``.
166
+ These fields are stripped from non-function hosted tool payloads.
167
+ 2. ``code_interpreter`` tools stored in a toolbox without a ``container``
168
+ field (the Azure SDK treats it as optional) are rejected by the Responses
169
+ API with ``Missing required parameter: 'tools[N].container'``. A default
170
+ ``{"type": "auto"}`` container is injected when absent.
171
+ 3. Hosted tools that are structurally incomplete in ways that cannot be
172
+ defaulted (``file_search`` without ``vector_store_ids``, ``mcp`` without
173
+ either ``server_url`` or ``project_connection_id``) raise ``ValueError``
174
+ with a message that points at the toolbox definition.
175
+
176
+ These are workarounds until the toolbox/Responses proxy normalizes payloads
177
+ server-side.
151
178
  """
152
179
  if isinstance(tool_item, FoundryMCPTool):
153
180
  sanitized: dict[str, Any] = dict(cast("Mapping[str, Any]", tool_item))
154
181
  sanitized.pop("name", None)
155
182
  sanitized.pop("description", None)
183
+ _validate_hosted_tool_payload(sanitized)
156
184
  return sanitized
157
185
 
158
186
  if isinstance(tool_item, Mapping):
@@ -161,6 +189,9 @@ def sanitize_foundry_response_tool(tool_item: Any) -> Any:
161
189
  sanitized = dict(mapping)
162
190
  sanitized.pop("name", None)
163
191
  sanitized.pop("description", None)
192
+ if sanitized.get("type") == "code_interpreter" and "container" not in sanitized:
193
+ sanitized["container"] = {"type": "auto"}
194
+ _validate_hosted_tool_payload(sanitized)
164
195
  return sanitized
165
196
 
166
197
  return cast(Any, tool_item)
@@ -4,7 +4,7 @@ description = "Microsoft Foundry integrations for Microsoft Agent Framework."
4
4
  authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
7
- version = "1.1.0"
7
+ version = "1.2.0"
8
8
  license-files = ["LICENSE"]
9
9
  urls.homepage = "https://aka.ms/agent-framework"
10
10
  urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
23
23
  "Typing :: Typed",
24
24
  ]
25
25
  dependencies = [
26
- "agent-framework-core>=1.1.0,<2",
26
+ "agent-framework-core>=1.2.0,<2",
27
27
  "agent-framework-openai>=1.1.0,<2",
28
28
  "azure-ai-inference>=1.0.0b9,<1.0.0b10",
29
29
  "azure-ai-projects>=2.1.0,<3.0",