Compare commits

...
Author SHA1 Message Date
Jacob AlberandGitHub d5777bc546 fix: Duplicate CallIds cause Handoff Message Filtering to fail (#5359)
Some providers, e.g. Gemini, do not use the CallId mechanism to disambiguate simultaneous function calls. This can result in message lists containing multiple turn to fail to filter properly.

The fix is to take advantage of the expectation that Handoff Orchestration is a "single-speaker" flow, which only has a single active AIAgent per "turn" and an agent's turn is not finished until all outstanding function calls are finished.

This allows us to expect that any ambiguous-CallId FunctionCallContent are either in separate turns or will have had a response before the next issued call with the same Id.
2026-04-21 08:14:35 +00:00
b6b191ad9c Python: Add second approval-required tool (set_stop_loss) to concurrent_builder_tool_approval sample (#4875)
* Add set_stop_loss tool to concurrent_builder_tool_approval sample

Add a second approval-gated tool (set_stop_loss) to the concurrent workflow
tool approval sample to demonstrate handling approval requests for different
tools in the same concurrent workflow.

Changes:
- Add set_stop_loss(symbol, stop_price) with approval_mode='always_require'
- Include new tool in both agents' tool lists
- Update agent instructions and prompt to encourage stop-loss usage
- Update docstring to reflect two approval-gated tools
- Update sample output to show mixed approval requests

Fixes #4874

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Print tool name and arguments in concurrent sample's process_event_stream (#4874)

Align process_event_stream in concurrent_builder_tool_approval.py to print
the tool name and arguments when collecting approval requests, matching the
sample output comment and the sequential_builder_tool_approval.py pattern.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Add None-guard for function_call access in tool approval sample (#4874)

Add explicit None-checks before accessing function_call.name and
function_call.arguments in concurrent_builder_tool_approval.py. The
function_call field is typed Content | None, so direct attribute access
without a guard could raise AttributeError and required type: ignore
comments. The None-guard is consistent with the pattern used in
_agent_run.py and removes the suppression comments.

Also add a regression test verifying that function_call defaults to None
and that the None-guard pattern is safe.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Apply same function_call None-guard to sibling tool-approval samples (#4874)

Apply the same fix to sequential_builder_tool_approval.py and
group_chat_builder_tool_approval.py, which had the identical pattern
of accessing function_call.name/arguments without a None-guard.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-21 07:08:50 +00:00
Evan MattsonandGitHub 2c8036779c Python: Bump versions for a release. Update CHANGELOG (#5385)
* Bump versions for a release. Update CHANGELOG

* Bump devui
2026-04-21 15:14:42 +09:00
ce8b6305d8 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>
2026-04-21 05:21:27 +00:00
07f4c8a8d6 Python: Expose forwardedProps to agents and tools via session metadata (#5264)
* Expose forwarded_props to agents and tools via session metadata (#5239)

Include forwarded_props from AG-UI request input_data in session.metadata
(agent runner) and function_invocation_kwargs (workflow runner) so that
agents, tools, and workflow executors can access request-level metadata
such as invocation source flags from CopilotKit.

- Add forwarded_props to base_metadata in _agent_run.py when present
- Add 'forwarded_props' to AG_UI_INTERNAL_METADATA_KEYS to filter it
  from LLM-bound client metadata
- Extract forwarded_props in _workflow_run.py and pass via
  function_invocation_kwargs to workflow.run()
- Accept both snake_case and camelCase keys (forwarded_props/forwardedProps)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(ag-ui): pass stream=True as literal to satisfy pyright overload resolution (#5239)

The previous fix passed stream=True via **kwargs dict, which prevented
pyright from resolving the Workflow.run() overload to the streaming
variant. Pass stream=True as an explicit keyword argument so pyright
can correctly infer the ResponseStream return type.

Also remove unused pytest import in test file.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: address PR review feedback for forwarded_props (#5239)

- Use key-presence checks instead of truthiness for forwarded_props so
  empty dict {} is forwarded correctly
- Gate function_invocation_kwargs on workflow.run() signature inspection
  to avoid TypeError for workflows without **kwargs
- Change _build_safe_metadata to drop (with warning) keys whose
  serialized values exceed 512 chars instead of truncating into invalid
  JSON
- Rewrite metadata tests to exercise _build_safe_metadata directly with
  JSON-decodability and truncation assertions
- Add workflow tests for empty dict forwarded_props, stream=True
  assertion, and signature-gated kwarg dropping

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* test: add stream=True assertions to CapturingWorkflow tests (#5239)

Guard against accidental removal of the explicit stream=True kwarg
in all forwarded_props CapturingWorkflow test cases.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address review feedback for #5239: Python: Expose forwardedProps to agents and tools via session metadata

---------

Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-21 04:25:45 +00:00
126 changed files with 4294 additions and 1410 deletions
@@ -426,7 +426,7 @@ internal sealed class HandoffAgentExecutor :
{
AgentId = this._agent.Id,
AuthorName = this._agent.Name ?? this._agent.Id,
Contents = [new FunctionResultContent(handoffRequest.CallId, "Transferred.")],
Contents = [CreateHandoffResult(handoffRequest.CallId)],
CreatedAt = DateTimeOffset.UtcNow,
MessageId = Guid.NewGuid().ToString("N"),
Role = ChatRole.Tool,
@@ -459,4 +459,6 @@ internal sealed class HandoffAgentExecutor :
? this._handoffFunctionToAgentId.TryGetValue(requestedHandoff, out string? targetId) ? targetId : null
: null;
}
internal static FunctionResultContent CreateHandoffResult(string requestCallId) => new(requestCallId, "Transferred.");
}
@@ -3,7 +3,6 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Specialized;
@@ -31,113 +30,78 @@ internal sealed class HandoffMessagesFilter
return messages;
}
Dictionary<string, FilterCandidateState> filteringCandidates = new();
List<ChatMessage> filteredMessages = [];
HashSet<int> messagesToRemove = [];
HashSet<string> filteredCallsWithoutResponses = new();
List<ChatMessage> retainedMessages = [];
bool filterAllToolCalls = this._filteringBehavior == HandoffToolCallFilteringBehavior.All;
// The logic of filtering is fairly straightforward: We are only interested in FunctionCallContent and FunctionResponseContent.
// We are going to assume that Handoff operates as follows:
// * Each agent is only taking one turn at a time
// * Each agent is taking a turn alone
//
// In the case of certain providers, like Gemini (see microsoft/agent-framework #5244), we will see the function call name as the
// call id as well, so we may see multiple calls with the same call id, and assume that the call is terminated before another
// "CallId-less" FCC is issued. We also need to rely on the idea that FRC follows their corresponding FCC in the message stream.
// (This changes the previous behaviour where FRC could arrive earlier, and relies on strict ordering).
//
// The benefit of expecting all the AIContent to be strictly ordered is that we never need to reach back into a post-filtered
// content to retroactively remove it, or to try to inject it back into the middle of a Message that has already been processed.
bool filterHandoffOnly = this._filteringBehavior == HandoffToolCallFilteringBehavior.HandoffOnly;
foreach (ChatMessage unfilteredMessage in messages)
{
ChatMessage filteredMessage = unfilteredMessage.Clone();
// .Clone() is shallow, so we cannot modify the contents of the cloned message in place.
List<AIContent> contents = [];
contents.Capacity = unfilteredMessage.Contents?.Count ?? 0;
filteredMessage.Contents = contents;
// Because this runs after the role changes from assistant to user for the target agent, we cannot rely on tool calls
// originating only from messages with the Assistant role. Instead, we need to inspect the contents of all non-Tool (result)
// FunctionCallContent.
if (unfilteredMessage.Role != ChatRole.Tool)
if (unfilteredMessage.Contents is null || unfilteredMessage.Contents.Count == 0)
{
for (int i = 0; i < unfilteredMessage.Contents!.Count; i++)
{
AIContent content = unfilteredMessage.Contents[i];
if (content is not FunctionCallContent fcc || (filterHandoffOnly && !IsHandoffFunctionName(fcc.Name)))
{
filteredMessage.Contents.Add(content);
// Track non-handoff function calls so their tool results are preserved in HandoffOnly mode
if (filterHandoffOnly && content is FunctionCallContent nonHandoffFcc)
{
filteringCandidates[nonHandoffFcc.CallId] = new FilterCandidateState(nonHandoffFcc.CallId)
{
IsHandoffFunction = false,
};
}
}
else if (filterHandoffOnly)
{
if (!filteringCandidates.TryGetValue(fcc.CallId, out FilterCandidateState? candidateState))
{
filteringCandidates[fcc.CallId] = new FilterCandidateState(fcc.CallId)
{
IsHandoffFunction = true,
};
}
else
{
candidateState.IsHandoffFunction = true;
(int messageIndex, int contentIndex) = candidateState.FunctionCallResultLocation!.Value;
ChatMessage messageToFilter = filteredMessages[messageIndex];
messageToFilter.Contents.RemoveAt(contentIndex);
if (messageToFilter.Contents.Count == 0)
{
messagesToRemove.Add(messageIndex);
}
}
}
else
{
// All mode: strip all FunctionCallContent
}
}
retainedMessages.Add(unfilteredMessage);
continue;
}
else
// We may need to filter out a subset of the message's content, but we won't know until we iterate through it. Create a new list
// of AIContent which we will stuff into a clone of the message if we need to filter out any content.
List<AIContent> retainedContents = new(capacity: unfilteredMessage.Contents.Count);
foreach (AIContent content in unfilteredMessage.Contents)
{
if (!filterHandoffOnly)
if (content is FunctionCallContent fcc
&& (filterAllToolCalls || IsHandoffFunctionName(fcc.Name)))
{
// If we already have an unmatched candidate with the same CallId, that means we have two FCCs in a row without an FRC,
// which violates our assumption of strict ordering.
if (!filteredCallsWithoutResponses.Add(fcc.CallId))
{
throw new InvalidOperationException($"Duplicate FunctionCallContent with CallId '{fcc.CallId}' without corresponding FunctionResultContent.");
}
// If we are filtering all tool calls, or this is a handoff call (and we are not filtering None, already checked), then
// filter this FCC
continue;
}
for (int i = 0; i < unfilteredMessage.Contents!.Count; i++)
else if (content is FunctionResultContent frc)
{
AIContent content = unfilteredMessage.Contents[i];
if (content is not FunctionResultContent frc
|| (filteringCandidates.TryGetValue(frc.CallId, out FilterCandidateState? candidateState)
&& candidateState.IsHandoffFunction is false))
// We rely on the corresponding FCC to have already been processed, so check if it is in the candidate dictionary.
// If it is, we can filter out the FRC, but we need to remove the candidate from the dictionary, since a future FCC can
// come in with the same CallId, and should be considered a new call that may need to be filtered.
if (filteredCallsWithoutResponses.Remove(frc.CallId))
{
// Either this is not a function result content, so we should let it through, or it is a FRC that
// we know is not related to a handoff call. In either case, we should include it.
filteredMessage.Contents.Add(content);
continue;
}
else if (candidateState is null)
{
// We haven't seen the corresponding function call yet, so add it as a candidate to be filtered later
filteringCandidates[frc.CallId] = new FilterCandidateState(frc.CallId)
{
FunctionCallResultLocation = (filteredMessages.Count, filteredMessage.Contents.Count),
};
}
// else we have seen the corresponding function call and it is a handoff, so we should filter it out.
}
// FCC/FRC, but not filtered, or neither FCC nor FRC: this should not be filtered out
retainedContents.Add(content);
}
if (filteredMessage.Contents.Count > 0)
if (retainedContents.Count == 0)
{
filteredMessages.Add(filteredMessage);
// message was fully filtered, skip it
continue;
}
ChatMessage filteredMessage = unfilteredMessage.Clone();
filteredMessage.Contents = retainedContents;
retainedMessages.Add(filteredMessage);
}
return filteredMessages.Where((_, index) => !messagesToRemove.Contains(index));
}
private class FilterCandidateState(string callId)
{
public (int MessageIndex, int ContentIndex)? FunctionCallResultLocation { get; set; }
public string CallId => callId;
public bool? IsHandoffFunction { get; set; }
return retainedMessages;
}
}
@@ -0,0 +1,115 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using FluentAssertions;
using Microsoft.Agents.AI.Workflows.Specialized;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
public class HandoffMessageFilterTests
{
private List<ChatMessage> CreateTestMessages(bool firstAgentUsesCallId, bool secondAgentUsesCallId, HandoffToolCallFilteringBehavior filter = HandoffToolCallFilteringBehavior.None)
{
FunctionCallContent handoffRequest1 = CreateHandoffCall(1, firstAgentUsesCallId);
FunctionResultContent handoffResponse1 = CreateHandoffResponse(handoffRequest1);
FunctionCallContent toolCall = CreateToolCall(secondAgentUsesCallId);
FunctionResultContent toolResponse = CreateToolResponse(toolCall);
// Approvals come from the function call middleware over ChatClient, so we can expect there to be a RequestId (not that we
// care, because we do not filter approval content)
ToolApprovalRequestContent toolApproval = new(Guid.NewGuid().ToString("N"), toolCall);
ToolApprovalResponseContent toolApprovalResponse = new(toolApproval.RequestId, true, toolCall);
FunctionCallContent handoffRequest2 = CreateHandoffCall(1, secondAgentUsesCallId);
FunctionResultContent handoffResponse2 = CreateHandoffResponse(handoffRequest2);
List<ChatMessage> result = [new(ChatRole.User, "Hello")];
// Agent 1 turn
result.Add(new(ChatRole.Assistant, "Hello! What do you want help with today?"));
result.Add(new(ChatRole.User, "Please explain temperature"));
// Unless we are filtering none, we expect the handoff call to be filtered out, so we add it conditionally
if (filter == HandoffToolCallFilteringBehavior.None)
{
result.Add(new(ChatRole.Assistant, [handoffRequest1]));
result.Add(new(ChatRole.Tool, [handoffResponse1]));
}
// Agent 2 turn
// Tool approvals are never filtered, so we add them unconditionally
result.Add(new(ChatRole.Assistant, [toolApproval]));
result.Add(new(ChatRole.User, [toolApprovalResponse]));
// Unless we are filtering all, we expect the tool call to be retained, so we add it conditionally
if (filter != HandoffToolCallFilteringBehavior.All)
{
result.Add(new(ChatRole.Assistant, [toolCall]));
result.Add(new(ChatRole.Tool, [toolResponse]));
}
result.Add(new(ChatRole.Assistant, "Temperature is a measure of the average kinetic energy of the particles in a substance."));
if (filter == HandoffToolCallFilteringBehavior.None)
{
result.Add(new(ChatRole.Assistant, [handoffRequest2]));
result.Add(new(ChatRole.Tool, [handoffResponse2]));
}
return result;
}
private static FunctionCallContent CreateHandoffCall(int id, bool useCallId)
{
string callName = $"{HandoffWorkflowBuilder.FunctionPrefix}{id}";
string callId = useCallId ? Guid.NewGuid().ToString("N") : callName;
return new FunctionCallContent(callId, callName);
}
private static FunctionResultContent CreateHandoffResponse(FunctionCallContent call)
=> HandoffAgentExecutor.CreateHandoffResult(call.CallId);
private static FunctionCallContent CreateToolCall(bool useCallId)
{
const string CallName = "ToolFunction";
string callId = useCallId ? Guid.NewGuid().ToString("N") : CallName;
return new FunctionCallContent(callId, CallName);
}
private static FunctionResultContent CreateToolResponse(FunctionCallContent call)
=> new(call.CallId, new object());
[Theory]
[InlineData(true, true, HandoffToolCallFilteringBehavior.None)]
[InlineData(true, false, HandoffToolCallFilteringBehavior.None)]
[InlineData(false, true, HandoffToolCallFilteringBehavior.None)]
[InlineData(false, false, HandoffToolCallFilteringBehavior.None)]
[InlineData(true, true, HandoffToolCallFilteringBehavior.HandoffOnly)]
[InlineData(true, false, HandoffToolCallFilteringBehavior.HandoffOnly)]
[InlineData(false, true, HandoffToolCallFilteringBehavior.HandoffOnly)]
[InlineData(false, false, HandoffToolCallFilteringBehavior.HandoffOnly)]
[InlineData(true, true, HandoffToolCallFilteringBehavior.All)]
[InlineData(true, false, HandoffToolCallFilteringBehavior.All)]
[InlineData(false, true, HandoffToolCallFilteringBehavior.All)]
[InlineData(false, false, HandoffToolCallFilteringBehavior.All)]
public void Test_HandoffMessageFilter_FiltersOnlyExpectedMessages(bool firstAgentUsesCallId, bool secondAgentUsesCallId, HandoffToolCallFilteringBehavior behavior)
{
// Arrange
List<ChatMessage> messages = this.CreateTestMessages(firstAgentUsesCallId, secondAgentUsesCallId);
List<ChatMessage> expected = this.CreateTestMessages(firstAgentUsesCallId, secondAgentUsesCallId, behavior);
HandoffMessagesFilter filter = new(behavior);
// Act
IEnumerable<ChatMessage> filteredMessages = filter.FilterMessages(messages);
// Assert
filteredMessages.Should().BeEquivalentTo(expected);
}
}
+1
View File
@@ -24,6 +24,7 @@
],
"words": [
"aeiou",
"agentserver",
"agui",
"aiplatform",
"azuredocindex",
+38 -1
View File
@@ -7,8 +7,44 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [1.1.0] - 2026-04-21
### Added
- **agent-framework-gemini**: Add `GeminiChatClient` ([#4847](https://github.com/microsoft/agent-framework/pull/4847))
- **agent-framework-core**: Add `context_providers` and `description` to `workflow.as_agent()` ([#4651](https://github.com/microsoft/agent-framework/pull/4651))
- **agent-framework-core**: Add experimental file history provider ([#5248](https://github.com/microsoft/agent-framework/pull/5248))
- **agent-framework-core**: Add OpenAI types to the default checkpoint encoding allow list ([#5297](https://github.com/microsoft/agent-framework/pull/5297))
- **agent-framework-core**: Add `AgentExecutorResponse.with_text()` to preserve conversation history through custom executors ([#5255](https://github.com/microsoft/agent-framework/pull/5255))
- **agent-framework-a2a**: Propagate A2A metadata from `Message`, `Artifact`, `Task`, and event types ([#5256](https://github.com/microsoft/agent-framework/pull/5256))
- **agent-framework-core**: Add `finish_reason` support to `AgentResponse` and `AgentResponseUpdate` ([#5211](https://github.com/microsoft/agent-framework/pull/5211))
- **agent-framework-hyperlight**: Add Hyperlight CodeAct package and docs ([#5185](https://github.com/microsoft/agent-framework/pull/5185))
- **agent-framework-openai**: Add search tool content support for OpenAI responses ([#5302](https://github.com/microsoft/agent-framework/pull/5302))
- **agent-framework-foundry**: Add support for Foundry Toolboxes ([#5346](https://github.com/microsoft/agent-framework/pull/5346))
- **agent-framework-ag-ui**: Expose `forwardedProps` to agents and tools via session metadata ([#5264](https://github.com/microsoft/agent-framework/pull/5264))
- **agent-framework-foundry**: Add hosted agent V2 support ([#5379](https://github.com/microsoft/agent-framework/pull/5379))
### Changed
- **agent-framework-azure-cosmos**: [BREAKING] `CosmosCheckpointStorage` now uses restricted pickle deserialization by default, matching `FileCheckpointStorage` behavior. If your checkpoints contain application-defined types, pass them via `allowed_checkpoint_types=["my_app.models:MyState"]`. ([#5200](https://github.com/microsoft/agent-framework/issues/5200))
- **agent-framework-core**: Improve skill name validation ([#4530](https://github.com/microsoft/agent-framework/pull/4530))
- **agent-framework-azure-cosmos**: Add `allowed_checkpoint_types` support to `CosmosCheckpointStorage` for parity with `FileCheckpointStorage` ([#5202](https://github.com/microsoft/agent-framework/pull/5202))
- **agent-framework-core**: Move `InMemory` history provider injection to first invocation ([#5236](https://github.com/microsoft/agent-framework/pull/5236))
- **agent-framework-github-copilot**: Forward provider config to `SessionConfig` in `GitHubCopilotAgent` ([#5195](https://github.com/microsoft/agent-framework/pull/5195))
- **agent-framework-hyperlight-codeact**: Flatten `execute_code` output ([#5333](https://github.com/microsoft/agent-framework/pull/5333))
- **dependencies**: Bump `pygments` from `2.19.2` to `2.20.0` in `/python` ([#4978](https://github.com/microsoft/agent-framework/pull/4978))
- **tests**: Bump misc integration retry delay to 30s ([#5293](https://github.com/microsoft/agent-framework/pull/5293))
- **tests**: Improve misc integration test robustness ([#5295](https://github.com/microsoft/agent-framework/pull/5295))
- **tests**: Skip hosted tools test on transient upstream MCP errors ([#5296](https://github.com/microsoft/agent-framework/pull/5296))
### Fixed
- **agent-framework-core**: Fix `python-feature-lifecycle` skill YAML frontmatter ([#5226](https://github.com/microsoft/agent-framework/pull/5226))
- **agent-framework-core**: Fix `HandoffBuilder` dropping function-level middleware when cloning agents ([#5220](https://github.com/microsoft/agent-framework/pull/5220))
- **agent-framework-ag-ui**: Fix deterministic state updates from tool results ([#5201](https://github.com/microsoft/agent-framework/pull/5201))
- **agent-framework-devui**: Fix streaming memory growth and add cross-platform regression coverage ([#5221](https://github.com/microsoft/agent-framework/pull/5221))
- **agent-framework-core**: Skip `get_final_response` in `_finalize_stream` when the stream has errored ([#5232](https://github.com/microsoft/agent-framework/pull/5232))
- **agent-framework-openai**: Fix reasoning replay when `store=False` ([#5250](https://github.com/microsoft/agent-framework/pull/5250))
- **agent-framework-foundry**: Handle `url_citation` annotations in `FoundryChatClient` streaming responses ([#5071](https://github.com/microsoft/agent-framework/pull/5071))
- **agent-framework-gemini**: Fix Gemini client support for Gemini API and Vertex AI ([#5258](https://github.com/microsoft/agent-framework/pull/5258))
- **agent-framework-copilotstudio**: Fix `CopilotStudioAgent` to reuse conversation ID from an existing session ([#5299](https://github.com/microsoft/agent-framework/pull/5299))
## [devui-1.0.0b260414] - 2026-04-14
@@ -903,7 +939,8 @@ Release candidate for **agent-framework-core** and **agent-framework-azure-ai**
For more information, see the [announcement blog post](https://devblogs.microsoft.com/foundry/introducing-microsoft-agent-framework-the-open-source-engine-for-agentic-ai-apps/).
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.0.1...HEAD
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.1.0...HEAD
[1.1.0]: https://github.com/microsoft/agent-framework/compare/python-1.0.1...python-1.1.0
[1.0.1]: https://github.com/microsoft/agent-framework/compare/python-1.0.0...python-1.0.1
[1.0.0]: https://github.com/microsoft/agent-framework/compare/python-1.0.0rc6...python-1.0.0
[1.0.0rc6]: https://github.com/microsoft/agent-framework/compare/python-1.0.0rc5...python-1.0.0rc6
+2 -2
View File
@@ -4,7 +4,7 @@ description = "A2A integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260409"
version = "1.0.0b260421"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.0.1,<2",
"agent-framework-core>=1.1.0,<2",
"a2a-sdk>=0.3.5,<0.3.24",
]
@@ -69,19 +69,23 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
# Keys that are internal to AG-UI orchestration and should not be passed to chat clients
AG_UI_INTERNAL_METADATA_KEYS = {"ag_ui_thread_id", "ag_ui_run_id", "current_state"}
AG_UI_INTERNAL_METADATA_KEYS = {"ag_ui_thread_id", "ag_ui_run_id", "current_state", "forwarded_props"}
def _build_safe_metadata(thread_metadata: dict[str, Any] | None) -> dict[str, Any]:
"""Build metadata dict with truncated string values for Azure compatibility.
"""Build metadata dict with string values for Azure compatibility.
Azure has a 512 character limit per metadata value.
Azure has a 512 character limit per metadata value. String values that
already fit are kept as-is. Non-string values are JSON-serialized. If the
resulting string exceeds 512 characters the key is **dropped** (with a
warning) instead of truncated, because truncation can produce invalid JSON
that downstream consumers cannot decode.
Args:
thread_metadata: Raw metadata dict
Returns:
Metadata with string values truncated to 512 chars
Metadata with safe string values (each <= 512 chars)
"""
if not thread_metadata:
return {}
@@ -89,7 +93,12 @@ def _build_safe_metadata(thread_metadata: dict[str, Any] | None) -> dict[str, An
for key, value in thread_metadata.items():
value_str = value if isinstance(value, str) else json.dumps(value)
if len(value_str) > 512:
value_str = value_str[:512]
logger.warning(
"Dropping metadata key %r: serialized value is %d chars (limit 512)",
key,
len(value_str),
)
continue
safe_metadata[key] = value_str
return safe_metadata
@@ -790,6 +799,10 @@ async def run_agent_stream(
"ag_ui_thread_id": thread_id,
"ag_ui_run_id": run_id,
}
if "forwarded_props" in input_data:
base_metadata["forwarded_props"] = input_data["forwarded_props"]
elif "forwardedProps" in input_data:
base_metadata["forwarded_props"] = input_data["forwardedProps"]
if flow.current_state:
base_metadata["current_state"] = flow.current_state
session.metadata = _build_safe_metadata(base_metadata) # type: ignore[attr-defined]
@@ -4,6 +4,7 @@
from __future__ import annotations
import inspect
import json
import logging
import uuid
@@ -581,11 +582,33 @@ async def run_workflow_stream(
flow.accumulated_text = ""
return [TextMessageEndEvent(message_id=current_message_id)]
fwd_kwargs: dict[str, Any] = {}
if "forwarded_props" in input_data:
forwarded_props = input_data["forwarded_props"]
fwd_kwargs["function_invocation_kwargs"] = {"forwarded_props": forwarded_props}
elif "forwardedProps" in input_data:
forwarded_props = input_data["forwardedProps"]
fwd_kwargs["function_invocation_kwargs"] = {"forwarded_props": forwarded_props}
# Only pass function_invocation_kwargs if the workflow.run signature accepts it
if fwd_kwargs:
try:
sig = inspect.signature(workflow.run)
params = sig.parameters
accepts_fwd = "function_invocation_kwargs" in params or any(
p.kind == inspect.Parameter.VAR_KEYWORD for p in params.values()
)
except (ValueError, TypeError):
accepts_fwd = False
if not accepts_fwd:
logger.debug("workflow.run() does not accept function_invocation_kwargs; dropping forwarded_props")
fwd_kwargs = {}
try:
if responses:
event_stream = workflow.run(responses=responses, stream=True)
event_stream = workflow.run(responses=responses, stream=True, **fwd_kwargs)
else:
event_stream = workflow.run(message=messages, stream=True)
event_stream = workflow.run(message=messages, stream=True, **fwd_kwargs)
async for event in event_stream:
event_type = getattr(event, "type", None)
+2 -2
View File
@@ -1,6 +1,6 @@
[project]
name = "agent-framework-ag-ui"
version = "1.0.0b260409"
version = "1.0.0b260421"
description = "AG-UI protocol integration for Agent Framework"
readme = "README.md"
license-files = ["LICENSE"]
@@ -22,7 +22,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.0.1,<2",
"agent-framework-core>=1.1.0,<2",
"ag-ui-protocol==0.1.13",
"fastapi>=0.115.0,<0.133.1",
"uvicorn[standard]>=0.30.0,<0.42.0"
@@ -0,0 +1,83 @@
# Copyright (c) Microsoft. All rights reserved.
"""Tests for forwarded_props inclusion in AG-UI session metadata."""
import json
from typing import Any
from agent_framework_ag_ui._agent_run import AG_UI_INTERNAL_METADATA_KEYS, _build_safe_metadata
class TestForwardedPropsInSessionMetadata:
"""Verify that forwarded_props is surfaced in session metadata and filtered from LLM metadata."""
def test_forwarded_props_in_internal_metadata_keys(self):
"""forwarded_props is listed in AG_UI_INTERNAL_METADATA_KEYS to prevent LLM leakage."""
assert "forwarded_props" in AG_UI_INTERNAL_METADATA_KEYS
def test_forwarded_props_filtered_from_client_metadata(self):
"""forwarded_props is filtered out when building LLM-bound client metadata."""
session_metadata: dict[str, Any] = {
"ag_ui_thread_id": "t1",
"ag_ui_run_id": "r1",
"forwarded_props": '{"custom_flag": true}',
}
client_metadata = {k: v for k, v in session_metadata.items() if k not in AG_UI_INTERNAL_METADATA_KEYS}
assert "forwarded_props" not in client_metadata
assert "ag_ui_thread_id" not in client_metadata
class TestBuildSafeMetadata:
"""Verify _build_safe_metadata handles various value types correctly."""
def test_string_value_unchanged(self):
result = _build_safe_metadata({"key": "hello"})
assert result == {"key": "hello"}
def test_dict_value_serialized_to_json(self):
result = _build_safe_metadata({"fp": {"flag": True, "source": "frontend"}})
assert "fp" in result
assert isinstance(result["fp"], str)
# Must be valid, decodable JSON
decoded = json.loads(result["fp"])
assert decoded == {"flag": True, "source": "frontend"}
def test_empty_dict_serialized_to_json(self):
result = _build_safe_metadata({"fp": {}})
assert result["fp"] == "{}"
assert json.loads(result["fp"]) == {}
def test_value_within_limit_kept(self):
value = "x" * 512
result = _build_safe_metadata({"key": value})
assert result["key"] == value
def test_value_exceeding_limit_dropped(self):
"""Values exceeding 512 chars are dropped entirely (not truncated)."""
value = "x" * 513
result = _build_safe_metadata({"key": value})
assert "key" not in result
def test_json_value_exceeding_limit_dropped(self):
"""JSON-serialized dict exceeding 512 chars is dropped, not truncated into invalid JSON."""
big_dict = {f"key_{i}": "v" * 100 for i in range(50)}
result = _build_safe_metadata({"forwarded_props": big_dict})
assert "forwarded_props" not in result
def test_other_keys_preserved_when_one_dropped(self):
"""Dropping one oversized key does not affect other keys."""
result = _build_safe_metadata(
{
"small": "ok",
"big": "x" * 600,
}
)
assert result == {"small": "ok"}
def test_none_input_returns_empty(self):
assert _build_safe_metadata(None) == {}
def test_empty_input_returns_empty(self):
assert _build_safe_metadata({}) == {}
@@ -63,12 +63,12 @@ class TestBuildSafeMetadata:
result = _build_safe_metadata(metadata)
assert result == metadata
def test_truncates_long_strings(self):
"""Truncates strings over 512 chars."""
def test_drops_long_strings(self):
"""Drops strings over 512 chars instead of truncating."""
long_value = "x" * 1000
metadata = {"key": long_value}
result = _build_safe_metadata(metadata)
assert len(result["key"]) == 512
assert "key" not in result
def test_serializes_non_strings(self):
"""Serializes non-string values to JSON."""
@@ -77,12 +77,12 @@ class TestBuildSafeMetadata:
assert result["count"] == "42"
assert result["items"] == "[1, 2, 3]"
def test_truncates_serialized_values(self):
"""Truncates serialized values over 512 chars."""
def test_drops_oversized_serialized_values(self):
"""Drops serialized values over 512 chars instead of truncating."""
long_list = list(range(200))
metadata = {"data": long_list}
result = _build_safe_metadata(metadata)
assert len(result["data"]) == 512
assert "data" not in result
class TestHasOnlyToolCalls:
@@ -1672,3 +1672,210 @@ async def test_workflow_run_non_terminal_status_emits_custom():
custom = [e for e in events if e.type == "CUSTOM" and e.name == "status"]
assert len(custom) == 1
assert custom[0].value == {"state": "running"}
async def test_workflow_run_passes_forwarded_props_as_function_invocation_kwargs() -> None:
"""forwarded_props from input_data is forwarded to workflow.run() via function_invocation_kwargs."""
class CapturingWorkflow:
def __init__(self) -> None:
self.captured_kwargs: dict[str, Any] = {}
def run(self, **kwargs: Any):
self.captured_kwargs = dict(kwargs)
async def _stream():
yield SimpleNamespace(type="started")
return _stream()
workflow = CapturingWorkflow()
events = [
event
async for event in run_workflow_stream(
{
"messages": [{"role": "user", "content": "hello"}],
"forwarded_props": {"custom_flag": True, "source": "copilotkit"},
},
cast(Any, workflow),
)
]
event_types = [event.type for event in events]
assert "RUN_STARTED" in event_types
assert "RUN_FINISHED" in event_types
assert workflow.captured_kwargs["stream"] is True
assert "function_invocation_kwargs" in workflow.captured_kwargs
assert workflow.captured_kwargs["function_invocation_kwargs"] == {
"forwarded_props": {"custom_flag": True, "source": "copilotkit"},
}
async def test_workflow_run_omits_function_invocation_kwargs_when_no_forwarded_props() -> None:
"""function_invocation_kwargs is not passed when forwarded_props is absent."""
class CapturingWorkflow:
def __init__(self) -> None:
self.captured_kwargs: dict[str, Any] = {}
def run(self, **kwargs: Any):
self.captured_kwargs = dict(kwargs)
async def _stream():
yield SimpleNamespace(type="started")
return _stream()
workflow = CapturingWorkflow()
events = [
event
async for event in run_workflow_stream(
{"messages": [{"role": "user", "content": "hello"}]},
cast(Any, workflow),
)
]
event_types = [event.type for event in events]
assert "RUN_STARTED" in event_types
assert workflow.captured_kwargs["stream"] is True
assert "function_invocation_kwargs" not in workflow.captured_kwargs
async def test_workflow_run_accepts_camel_case_forwarded_props() -> None:
"""forwardedProps (camelCase) is accepted as an alternative key."""
class CapturingWorkflow:
def __init__(self) -> None:
self.captured_kwargs: dict[str, Any] = {}
def run(self, **kwargs: Any):
self.captured_kwargs = dict(kwargs)
async def _stream():
yield SimpleNamespace(type="started")
return _stream()
workflow = CapturingWorkflow()
events = [
event
async for event in run_workflow_stream(
{
"messages": [{"role": "user", "content": "hello"}],
"forwardedProps": {"source": "frontend"},
},
cast(Any, workflow),
)
]
event_types = [event.type for event in events]
assert "RUN_STARTED" in event_types
assert workflow.captured_kwargs["stream"] is True
assert "function_invocation_kwargs" in workflow.captured_kwargs
assert workflow.captured_kwargs["function_invocation_kwargs"] == {
"forwarded_props": {"source": "frontend"},
}
async def test_workflow_run_passes_empty_dict_forwarded_props() -> None:
"""An empty dict forwarded_props={} should still be forwarded (not dropped by truthiness)."""
class CapturingWorkflow:
def __init__(self) -> None:
self.captured_kwargs: dict[str, Any] = {}
def run(self, **kwargs: Any):
self.captured_kwargs = dict(kwargs)
async def _stream():
yield SimpleNamespace(type="started")
return _stream()
workflow = CapturingWorkflow()
events = [
event
async for event in run_workflow_stream(
{
"messages": [{"role": "user", "content": "hello"}],
"forwarded_props": {},
},
cast(Any, workflow),
)
]
event_types = [event.type for event in events]
assert "RUN_STARTED" in event_types
assert "RUN_FINISHED" in event_types
assert workflow.captured_kwargs["stream"] is True
assert "function_invocation_kwargs" in workflow.captured_kwargs
assert workflow.captured_kwargs["function_invocation_kwargs"] == {
"forwarded_props": {},
}
async def test_workflow_run_stream_true_always_passed() -> None:
"""stream=True is always passed to workflow.run()."""
class CapturingWorkflow:
def __init__(self) -> None:
self.captured_kwargs: dict[str, Any] = {}
def run(self, **kwargs: Any):
self.captured_kwargs = dict(kwargs)
async def _stream():
yield SimpleNamespace(type="started")
return _stream()
workflow = CapturingWorkflow()
_ = [
event
async for event in run_workflow_stream(
{
"messages": [{"role": "user", "content": "hello"}],
"forwarded_props": {"key": "val"},
},
cast(Any, workflow),
)
]
assert workflow.captured_kwargs["stream"] is True
async def test_workflow_run_drops_fwd_kwargs_when_run_lacks_param() -> None:
"""function_invocation_kwargs is silently dropped if workflow.run() does not accept it."""
class StrictWorkflow:
def __init__(self) -> None:
self.captured_kwargs: dict[str, Any] = {}
def run(self, *, message: Any = None, responses: Any = None, stream: bool = False):
self.captured_kwargs = {"message": message, "responses": responses, "stream": stream}
async def _stream():
yield SimpleNamespace(type="started")
return _stream()
workflow = StrictWorkflow()
events = [
event
async for event in run_workflow_stream(
{
"messages": [{"role": "user", "content": "hello"}],
"forwarded_props": {"custom": True},
},
cast(Any, workflow),
)
]
event_types = [event.type for event in events]
assert "RUN_STARTED" in event_types
assert "RUN_FINISHED" in event_types
# No TypeError raised, and function_invocation_kwargs was not passed
assert "function_invocation_kwargs" not in workflow.captured_kwargs
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Anthropic integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260409"
version = "1.0.0b260421"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.0.1,<2",
"agent-framework-core>=1.1.0,<2",
"anthropic>=0.80.0,<0.80.1",
]
@@ -4,7 +4,7 @@ description = "Azure AI Search integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260409"
version = "1.0.0b260421"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.0.1,<2",
"agent-framework-core>=1.1.0,<2",
"azure-search-documents>=11.7.0b2,<11.7.0b3",
]
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Azure Cosmos DB history provider integration for Microsoft Agent
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260409"
version = "1.0.0b260421"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.0.1,<2",
"agent-framework-core>=1.1.0,<2",
"azure-cosmos>=4.3.0,<5",
]
@@ -4,7 +4,7 @@ description = "Azure Functions integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260409"
version = "1.0.0b260421"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -22,7 +22,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.0.1,<2",
"agent-framework-core>=1.1.0,<2",
"agent-framework-durabletask",
"azure-functions>=1.24.0,<2",
"azure-functions-durable>=1.3.1,<2",
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Amazon Bedrock integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260409"
version = "1.0.0b260421"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.0.1,<2",
"agent-framework-core>=1.1.0,<2",
"boto3>=1.35.0,<2.0.0",
"botocore>=1.35.0,<2.0.0",
]
+2 -2
View File
@@ -4,7 +4,7 @@ description = "OpenAI ChatKit integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260409"
version = "1.0.0b260421"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -22,7 +22,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.0.1,<2",
"agent-framework-core>=1.1.0,<2",
"openai-chatkit>=1.4.1,<2.0.0",
]
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Claude Agent SDK integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260409"
version = "1.0.0b260421"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.0.1,<2",
"agent-framework-core>=1.1.0,<2",
"claude-agent-sdk>=0.1.36,<0.1.49",
]
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Copilot Studio integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260409"
version = "1.0.0b260421"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.0.1,<2",
"agent-framework-core>=1.1.0,<2",
"microsoft-agents-copilotstudio-client>=0.3.1,<0.3.2",
]
@@ -4,6 +4,9 @@ from __future__ import annotations
import logging
import os
from collections.abc import Generator
from contextlib import contextmanager
from contextvars import ContextVar
from typing import Any, Final
from . import __version__ as version_info
@@ -26,6 +29,35 @@ USER_AGENT_KEY: Final[str] = "User-Agent"
HTTP_USER_AGENT: Final[str] = "agent-framework-python"
AGENT_FRAMEWORK_USER_AGENT = f"{HTTP_USER_AGENT}/{version_info}" # type: ignore[has-type]
_user_agent_prefixes: ContextVar[tuple[str, ...]] = ContextVar("_user_agent_prefixes", default=())
@contextmanager
def user_agent_prefix(prefix: str) -> Generator[None]:
"""Context manager that adds a prefix to the user agent string for the current scope.
This is useful for upstream layers that want to identify themselves in telemetry
for the duration of a request without permanently mutating global state.
Args:
prefix: The prefix to add (e.g. "foundry-hosting").
"""
current = _user_agent_prefixes.get()
token = _user_agent_prefixes.set((*current, prefix)) if prefix and prefix not in current else None
try:
yield
finally:
if token is not None:
_user_agent_prefixes.reset(token)
def _get_user_agent() -> str:
"""Return the full user agent string including any context-scoped prefixes."""
prefixes = _user_agent_prefixes.get()
if not prefixes:
return AGENT_FRAMEWORK_USER_AGENT
return f"{'/'.join(prefixes)}/{AGENT_FRAMEWORK_USER_AGENT}"
def prepend_agent_framework_to_user_agent(headers: dict[str, Any] | None = None) -> dict[str, Any]:
"""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)
"""
if not IS_TELEMETRY_ENABLED:
return headers or {}
user_agent = _get_user_agent()
if not headers:
return {USER_AGENT_KEY: AGENT_FRAMEWORK_USER_AGENT}
headers[USER_AGENT_KEY] = (
f"{AGENT_FRAMEWORK_USER_AGENT} {headers[USER_AGENT_KEY]}"
if USER_AGENT_KEY in headers
else AGENT_FRAMEWORK_USER_AGENT
)
return {USER_AGENT_KEY: user_agent}
headers[USER_AGENT_KEY] = f"{user_agent} {headers[USER_AGENT_KEY]}" if USER_AGENT_KEY in headers else user_agent
return headers
+1 -1
View File
@@ -4,7 +4,7 @@ description = "Microsoft Agent Framework for building AI Agents with Python. Thi
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.1"
version = "1.1.0"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -8,6 +8,7 @@ from agent_framework import (
USER_AGENT_TELEMETRY_DISABLED_ENV_VAR,
prepend_agent_framework_to_user_agent,
)
from agent_framework._telemetry import user_agent_prefix
# region Test constants
@@ -96,3 +97,56 @@ def test_modifies_original_dict():
assert result is headers # Same object
assert "User-Agent" in headers
# region Test user_agent_prefix context manager
def test_user_agent_prefix_adds_prefix():
"""Test that the context manager adds a prefix within its scope."""
with user_agent_prefix("test-host"):
result = prepend_agent_framework_to_user_agent()
assert result["User-Agent"].startswith("test-host/")
assert AGENT_FRAMEWORK_USER_AGENT in result["User-Agent"]
# Prefix is removed after exiting the context
result = prepend_agent_framework_to_user_agent()
assert result["User-Agent"] == AGENT_FRAMEWORK_USER_AGENT
def test_user_agent_prefix_ignores_duplicates():
"""Test that duplicate prefixes are not added within nested scopes."""
with user_agent_prefix("test-host"), user_agent_prefix("test-host"):
result = prepend_agent_framework_to_user_agent()
assert result["User-Agent"].count("test-host") == 1
def test_user_agent_prefix_ignores_empty():
"""Test that empty strings are not added as prefixes."""
with user_agent_prefix(""):
result = prepend_agent_framework_to_user_agent()
assert result["User-Agent"] == AGENT_FRAMEWORK_USER_AGENT
def test_user_agent_prefix_restores_on_exit():
"""Test that prefixes are fully restored after the context manager exits."""
with user_agent_prefix("test-host"):
pass
result = prepend_agent_framework_to_user_agent()
assert result["User-Agent"] == AGENT_FRAMEWORK_USER_AGENT
def test_user_agent_prefix_nesting():
"""Test that nested context managers compose prefixes correctly."""
with user_agent_prefix("outer"):
with user_agent_prefix("inner"):
result = prepend_agent_framework_to_user_agent()
assert "outer" in result["User-Agent"]
assert "inner" in result["User-Agent"]
# Inner prefix removed
result = prepend_agent_framework_to_user_agent()
assert "outer" in result["User-Agent"]
assert "inner" not in result["User-Agent"]
# Both removed
result = prepend_agent_framework_to_user_agent()
assert result["User-Agent"] == AGENT_FRAMEWORK_USER_AGENT
@@ -664,6 +664,21 @@ def test_function_approval_serialization_roundtrip():
# The Content union will need to be handled differently when we fully migrate
def test_function_approval_request_function_call_none_guard():
"""Test that accessing function_call attributes is safe when function_call is None."""
# Construct a Content with type "function_approval_request" but no function_call.
# This verifies the None-guard pattern used in samples to prevent AttributeError.
content = Content("function_approval_request", id="req-none")
assert content.function_call is None
# A proper approval request always has function_call set
fc = Content.from_function_call(call_id="call-1", name="do_something", arguments={"a": 1})
req = Content.from_function_approval_request(id="req-1", function_call=fc)
assert req.function_call is not None
assert req.function_call.name == "do_something"
assert req.function_call.arguments == {"a": 1}
def test_function_approval_accepts_mcp_call():
"""Ensure FunctionApprovalRequestContent supports MCP server tool calls."""
mcp_call = Content.from_mcp_server_tool_call(
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Declarative specification support for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260409"
version = "1.0.0b260421"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -22,7 +22,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.0.1,<2",
"agent-framework-core>=1.1.0,<2",
"powerfx>=0.0.32,<0.0.35; python_version < '3.14'",
"pyyaml>=6.0,<7.0",
]
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Debug UI for Microsoft Agent Framework with OpenAI-compatible API
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260414"
version = "1.0.0b260421"
license-files = ["LICENSE"]
urls.homepage = "https://github.com/microsoft/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.0.1,<2",
"agent-framework-core>=1.1.0,<2",
"openai>=1.99.0,<3",
"opentelemetry-sdk>=1.39.0,<2",
"fastapi>=0.115.0,<0.133.1",
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Durable Task integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260409"
version = "1.0.0b260421"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -22,7 +22,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.0.1,<2",
"agent-framework-core>=1.1.0,<2",
"durabletask>=1.3.0,<2",
"durabletask-azuremanaged>=1.3.0,<2",
"python-dateutil>=2.8.0,<3",
+3 -3
View File
@@ -4,7 +4,7 @@ description = "Microsoft Foundry integrations for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.1"
version = "1.1.0"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,8 +23,8 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.0.1,<2",
"agent-framework-openai>=1.0.1,<2",
"agent-framework-core>=1.1.0,<2",
"agent-framework-openai>=1.1.0,<2",
"azure-ai-inference>=1.0.0b9,<1.0.0b10",
"azure-ai-projects>=2.1.0,<3.0",
]
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) Microsoft Corporation.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE
@@ -0,0 +1,3 @@
# Foundry Hosting
This package provides the integration of Agent Framework agents and workflows with the Foundry Agent Server, which can be hosted on Foundry infrastructure.
@@ -0,0 +1,13 @@
# Copyright (c) Microsoft. All rights reserved.
import importlib.metadata
from ._invocations import InvocationsHostServer
from ._responses import ResponsesHostServer
try:
__version__ = importlib.metadata.version(__name__)
except importlib.metadata.PackageNotFoundError:
__version__ = "0.0.0"
__all__ = ["InvocationsHostServer", "ResponsesHostServer"]
@@ -0,0 +1,80 @@
# Copyright (c) Microsoft. All rights reserved.
from agent_framework import AgentSession, BaseAgent, SupportsAgentRun
from agent_framework._telemetry import user_agent_prefix
from azure.ai.agentserver.invocations import InvocationAgentServerHost
from starlette.requests import Request
from starlette.responses import JSONResponse, Response, StreamingResponse
from typing_extensions import Any, AsyncGenerator
class InvocationsHostServer(InvocationAgentServerHost):
"""An invocations server host for an agent."""
USER_AGENT_PREFIX = "foundry-hosting"
def __init__(
self,
agent: BaseAgent,
*,
openapi_spec: dict[str, Any] | None = None,
**kwargs: Any,
) -> None:
"""Initialize an InvocationsHostServer.
Args:
agent: The agent to handle responses for.
openapi_spec: The OpenAPI specification for the server.
**kwargs: Additional keyword arguments.
This host will expect the request to be a JSON body with a "message" field.
The response from the host will be a JSON object with a "response" field containing
the agent's response and a "session_id" field containing the session ID.
"""
super().__init__(openapi_spec=openapi_spec, **kwargs)
if not isinstance(agent, SupportsAgentRun):
raise TypeError("Agent must support the SupportsAgentRun interface")
self._agent = agent
self._sessions: dict[str, AgentSession] = {}
self.invoke_handler(self._handle_invoke) # pyright: ignore[reportUnknownMemberType]
async def _handle_invoke(self, request: Request) -> Response:
"""Invoke the agent with the given request."""
with user_agent_prefix(self.USER_AGENT_PREFIX):
return await self._handle_invoke_inner(request)
async def _handle_invoke_inner(self, request: Request) -> Response:
"""Core invoke handler logic."""
data = await request.json()
session_id: str = request.state.session_id
stream = data.get("stream", False)
user_message = data.get("message", None)
if user_message is None:
error = "Missing 'message' in request"
if stream:
return StreamingResponse(content=error, status_code=400)
return Response(content=error, status_code=400)
session = self._sessions.setdefault(session_id, AgentSession(session_id=session_id))
if stream:
async def stream_response() -> AsyncGenerator[str]:
async for update in self._agent.run(user_message, session=session, stream=True):
if update.text:
yield update.text
return StreamingResponse(
stream_response(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "Connection": "keep-alive"},
)
response = await self._agent.run([user_message], session=session, stream=stream)
return JSONResponse({
"response": response.text,
"session_id": session_id,
})
@@ -0,0 +1,983 @@
# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
import asyncio
import json
import logging
import os
from collections.abc import AsyncIterable, AsyncIterator, Generator, Mapping, Sequence
from typing import cast
from agent_framework import (
ChatOptions,
Content,
ContextProvider,
FileCheckpointStorage,
HistoryProvider,
Message,
RawAgent,
SupportsAgentRun,
WorkflowAgent,
)
from agent_framework._telemetry import user_agent_prefix
from azure.ai.agentserver.responses import (
ResponseContext,
ResponseEventStream,
ResponseProviderProtocol,
ResponsesServerOptions,
)
from azure.ai.agentserver.responses.hosting import ResponsesAgentServerHost
from azure.ai.agentserver.responses.models import (
ComputerScreenshotContent,
CreateResponse,
FunctionCallOutputItemParam,
FunctionShellAction,
FunctionShellCallOutputContent,
FunctionShellCallOutputExitOutcome,
LocalEnvironmentResource,
MessageContent,
MessageContentInputFileContent,
MessageContentInputImageContent,
MessageContentInputTextContent,
MessageContentOutputTextContent,
MessageContentReasoningTextContent,
MessageContentRefusalContent,
OAuthConsentRequestOutputItem,
OutputItem,
OutputItemApplyPatchToolCall,
OutputItemApplyPatchToolCallOutput,
OutputItemCodeInterpreterToolCall,
OutputItemComputerToolCall,
OutputItemComputerToolCallOutputResource,
OutputItemCustomToolCall,
OutputItemCustomToolCallOutput,
OutputItemFileSearchToolCall,
OutputItemFunctionShellCall,
OutputItemFunctionShellCallOutput,
OutputItemFunctionToolCall,
OutputItemImageGenToolCall,
OutputItemLocalShellToolCall,
OutputItemLocalShellToolCallOutput,
OutputItemMcpApprovalRequest,
OutputItemMcpApprovalResponseResource,
OutputItemMcpToolCall,
OutputItemMessage,
OutputItemOutputMessage,
OutputItemReasoningItem,
OutputItemWebSearchToolCall,
OutputMessageContent,
OutputMessageContentOutputTextContent,
OutputMessageContentRefusalContent,
ResponseStreamEvent,
StructuredOutputsOutputItem,
SummaryTextContent,
TextContent,
)
from azure.ai.agentserver.responses.streaming._builders import (
OutputItemFunctionCallBuilder,
OutputItemMcpCallBuilder,
OutputItemMessageBuilder,
OutputItemReasoningItemBuilder,
ReasoningSummaryPartBuilder,
TextContentBuilder,
)
from typing_extensions import Any
logger = logging.getLogger(__name__)
class ResponsesHostServer(ResponsesAgentServerHost):
"""A responses server host for an agent."""
USER_AGENT_PREFIX = "foundry-hosting"
# TODO(@taochen): Allow a different checkpoint storage that stores checkpoints externally
CHECKPOINT_STORAGE_PATH = "/.checkpoints"
def __init__(
self,
agent: SupportsAgentRun,
*,
prefix: str = "",
options: ResponsesServerOptions | None = None,
store: ResponseProviderProtocol | None = None,
**kwargs: Any,
) -> None:
"""Initialize a ResponsesHostServer.
Args:
agent: The agent to handle responses for.
prefix: The URL prefix for the server.
options: Optional server options.
store: Optional response store.
**kwargs: Additional keyword arguments.
Note:
1. The agent must not have a history provider with `load_messages=True`,
because history is managed by the hosting infrastructure.
2. The agent must not have any context providers that maintain context
in memory, because the hosting environment may get deactivated between
requests, and any in-memory context would be lost.
"""
super().__init__(prefix=prefix, options=options, store=store, **kwargs)
for provider in getattr(agent, "context_providers", []):
if isinstance(provider, HistoryProvider) and provider.load_messages:
raise RuntimeError(
"There shouldn't be a history provider with `load_messages=True` already present. "
"History is managed by the hosting infrastructure."
)
provider = cast(ContextProvider, provider)
logger.warning(
"Context provider %s is present. If it maintains context in memory, "
"the context may be lost between requests. Use with caution.",
provider.source_id,
)
self._is_workflow_agent = False
self._checkpoint_storage_path = None
if isinstance(agent, WorkflowAgent):
if agent.workflow._runner_context.has_checkpointing(): # pyright: ignore[reportPrivateUsage]
raise RuntimeError(
"There should not be a checkpoint storage already present in the workflow agent. "
"The hosting infrastructure will manage checkpoints instead."
)
self._checkpoint_storage_path = (
self.CHECKPOINT_STORAGE_PATH
if self.config.is_hosted
else os.path.join(os.getcwd(), self.CHECKPOINT_STORAGE_PATH.lstrip("/"))
)
self._is_workflow_agent = True
self._agent = agent
self.response_handler(self._handler) # pyright: ignore[reportUnknownMemberType]
@staticmethod
def _is_streaming_request(request: CreateResponse) -> bool:
"""Check if the request is a streaming request."""
return request.stream is not None and request.stream is True
async def _handler(
self,
request: CreateResponse,
context: ResponseContext,
cancellation_signal: asyncio.Event,
) -> AsyncIterable[ResponseStreamEvent | dict[str, Any]]:
"""Handle the creation of a response."""
with user_agent_prefix(self.USER_AGENT_PREFIX):
async for event in self._handle_inner(request, context, cancellation_signal):
yield event
async def _handle_inner(
self,
request: CreateResponse,
context: ResponseContext,
cancellation_signal: asyncio.Event,
) -> AsyncIterable[ResponseStreamEvent | dict[str, Any]]:
"""Core handler logic."""
if self._is_workflow_agent:
# Workflow agents are handled differently because they require checkpoint restoration
async for event in self._handle_workflow_agent(request, context, cancellation_signal):
yield event
return
input_text = await context.get_input_text()
history = await context.get_history()
messages: list[str | Content | Message] = [*_to_messages(history), input_text]
chat_options, are_options_set = _to_chat_options(request)
is_streaming_request = self._is_streaming_request(request)
response_event_stream = ResponseEventStream(response_id=context.response_id, model=request.model)
yield response_event_stream.emit_created()
yield response_event_stream.emit_in_progress()
if not is_streaming_request:
# Run the agent in non-streaming mode
if isinstance(self._agent, RawAgent):
raw_agent = cast("RawAgent[Any]", self._agent) # type: ignore[redundant-cast] # pyright: ignore[reportUnknownMemberType]
response = await raw_agent.run(messages, stream=False, options=chat_options)
else:
if are_options_set:
logger.warning("Agent doesn't support runtime options. They will be ignored.")
response = await self._agent.run(messages, stream=False)
for message in response.messages:
for content in message.contents:
async for item in _to_outputs(response_event_stream, content):
yield item
yield response_event_stream.emit_completed()
return
# Run the agent in streaming mode
if isinstance(self._agent, RawAgent):
raw_agent = cast("RawAgent[Any]", self._agent) # type: ignore[redundant-cast] # pyright: ignore[reportUnknownMemberType]
response_stream = raw_agent.run(messages, stream=True, options=chat_options)
else:
if are_options_set:
logger.warning("Agent doesn't support runtime options. They will be ignored.")
response_stream = self._agent.run(messages, stream=True)
# Track the current active output item builder for streaming;
# lazily created on matching content, closed when a different type arrives.
tracker = _OutputItemTracker(response_event_stream)
async for update in response_stream:
for content in update.contents:
for event in tracker.handle(content):
yield event
if tracker.needs_async:
async for item in _to_outputs(response_event_stream, content):
yield item
tracker.needs_async = False
# Close any remaining active builder
for event in tracker.close():
yield event
yield response_event_stream.emit_completed()
async def _handle_workflow_agent(
self,
request: CreateResponse,
context: ResponseContext,
cancellation_signal: asyncio.Event,
) -> AsyncIterable[ResponseStreamEvent | dict[str, Any]]:
"""Handle the creation of a response for a workflow agent.
Why this is required:
The sandbox may be deactivated after some period of inactivity, and only data managed
by the hosting infrastructure or files will be preserved upon deactivation.
"""
input_text = await context.get_input_text()
is_streaming_request = self._is_streaming_request(request)
_, are_options_set = _to_chat_options(request)
if are_options_set:
logger.warning("Workflow agent doesn't support runtime options. They will be ignored.")
if request.previous_response_id is not None and context.conversation_id is not None:
raise RuntimeError("Previous response ID cannot be used in conjunction with conversation ID.")
context_id = request.previous_response_id or context.conversation_id
# The following should never happen due to the checks above.
# This is for type safety and defensive programming.
if self._checkpoint_storage_path is None:
raise RuntimeError("Checkpoint storage path is not configured for workflow agent.")
if not isinstance(self._agent, WorkflowAgent):
raise RuntimeError("Agent is not a workflow agent.")
# Restore from the latest checkpoint if available, otherwise start with an empty history
if context_id is not None:
checkpoint_storage = FileCheckpointStorage(os.path.join(self._checkpoint_storage_path, context_id))
latest_checkpoint = await checkpoint_storage.get_latest(workflow_name=self._agent.workflow.name)
if latest_checkpoint is not None:
if not is_streaming_request:
_ = await self._agent.run(
stream=False,
checkpoint_id=latest_checkpoint.checkpoint_id,
checkpoint_storage=checkpoint_storage,
)
else:
# Consume the streaming or the invocation will result in a no-op
async for _ in self._agent.run(
stream=True,
checkpoint_id=latest_checkpoint.checkpoint_id,
checkpoint_storage=checkpoint_storage,
):
pass
# Now run the agent with the latest input
response_event_stream = ResponseEventStream(response_id=context.response_id, model=request.model)
# Create a new checkpoint storage for this response based on the following rules:
# - If no previous response ID or conversation ID is provided, create a new checkpoint storage for this response
# - If a previous response ID is provided, create a new checkpoint storage for this response
# - If a conversation ID is provided, reuse the existing checkpoint storage for the conversation
context_id = context.conversation_id or context.response_id
checkpoint_storage = FileCheckpointStorage(os.path.join(self._checkpoint_storage_path, context_id))
yield response_event_stream.emit_created()
yield response_event_stream.emit_in_progress()
if not is_streaming_request:
# Run the agent in non-streaming mode
response = await self._agent.run(input_text, stream=False, checkpoint_storage=checkpoint_storage)
for message in response.messages:
for content in message.contents:
async for item in _to_outputs(response_event_stream, content):
yield item
await self._delete_not_latest_checkpoints(checkpoint_storage, self._agent.workflow.name)
yield response_event_stream.emit_completed()
return
# Run the agent in streaming mode
response_stream = self._agent.run(input_text, stream=True, checkpoint_storage=checkpoint_storage)
# Track the current active output item builder for streaming;
# lazily created on matching content, closed when a different type arrives.
tracker = _OutputItemTracker(response_event_stream)
async for update in response_stream:
for content in update.contents:
for event in tracker.handle(content):
yield event
if tracker.needs_async:
async for item in _to_outputs(response_event_stream, content):
yield item
tracker.needs_async = False
# Close any remaining active builder
for event in tracker.close():
yield event
await self._delete_not_latest_checkpoints(checkpoint_storage, self._agent.workflow.name)
yield response_event_stream.emit_completed()
return
@staticmethod
async def _delete_not_latest_checkpoints(checkpoint_storage: FileCheckpointStorage, workflow_name: str) -> None:
"""Delete all checkpoints except the latest one.
We only need the last checkpoint for each invocation.
"""
latest_checkpoint = await checkpoint_storage.get_latest(workflow_name=workflow_name)
if latest_checkpoint is not None:
all_checkpoints = await checkpoint_storage.list_checkpoints(workflow_name=workflow_name)
for checkpoint in all_checkpoints:
if checkpoint.checkpoint_id != latest_checkpoint.checkpoint_id:
await checkpoint_storage.delete(checkpoint.checkpoint_id)
# region Active Builder State
class _OutputItemTracker:
"""Tracks the current active output item builder during streaming.
Handles lazy creation, delta emission, and closing of streaming builders
for text messages, reasoning, function calls, and MCP calls.
"""
_DELTA_TYPES = frozenset({"text", "text_reasoning", "function_call", "mcp_server_tool_call"})
def __init__(self, stream: ResponseEventStream) -> None:
self._stream = stream
self._active_type: str | None = None
self._active_id: str | None = None
# Accumulated delta text for the current active builder
self._accumulated: list[str] = []
# Builder state — only one is active at a time
self._message_item: OutputItemMessageBuilder | None = None
self._text_content: TextContentBuilder | None = None
self._reasoning_item: OutputItemReasoningItemBuilder | None = None
self._summary_part: ReasoningSummaryPartBuilder | None = None
self._fc_builder: OutputItemFunctionCallBuilder | None = None
self._mcp_builder: OutputItemMcpCallBuilder | None = None
self.needs_async = False
def handle(self, content: Content) -> Generator[ResponseStreamEvent]:
"""Process a content item, yielding sync events.
Sets ``needs_async = True`` if the caller must also drain an
async ``_to_outputs`` call for this content.
"""
if content.type == "text" and content.text is not None:
if self._active_type != "text":
yield from self._close()
yield from self._open_message()
self._accumulated.append(content.text)
if self._text_content is not None:
yield self._text_content.emit_delta(content.text)
elif content.type == "text_reasoning" and content.text is not None:
if self._active_type != "text_reasoning":
yield from self._close()
yield from self._open_reasoning()
self._accumulated.append(content.text)
if self._summary_part is not None:
yield self._summary_part.emit_text_delta(content.text)
elif content.type == "function_call" and content.call_id is not None:
if self._active_type != "function_call" or self._active_id != content.call_id:
yield from self._close()
yield from self._open_function_call(content)
args_str = _arguments_to_str(content.arguments)
self._accumulated.append(args_str)
if self._fc_builder is not None:
yield self._fc_builder.emit_arguments_delta(args_str)
elif content.type == "mcp_server_tool_call" and content.tool_name:
key = f"{content.server_name or 'default'}::{content.tool_name}"
if self._active_type != "mcp_server_tool_call" or self._active_id != key:
yield from self._close()
yield from self._open_mcp_call(content)
args_str = _arguments_to_str(content.arguments)
self._accumulated.append(args_str)
if self._mcp_builder is not None:
yield self._mcp_builder.emit_arguments_delta(args_str)
else:
yield from self._close()
self.needs_async = True
def close(self) -> Generator[ResponseStreamEvent]:
"""Close any remaining active builder."""
yield from self._close()
# -- Private open/close helpers --
def _open_message(self) -> Generator[ResponseStreamEvent]:
self._message_item = self._stream.add_output_item_message()
self._text_content = self._message_item.add_text_content()
self._active_type = "text"
self._active_id = None
yield self._message_item.emit_added()
yield self._text_content.emit_added()
def _open_reasoning(self) -> Generator[ResponseStreamEvent]:
self._reasoning_item = self._stream.add_output_item_reasoning_item()
self._summary_part = self._reasoning_item.add_summary_part()
self._active_type = "text_reasoning"
self._active_id = None
yield self._reasoning_item.emit_added()
yield self._summary_part.emit_added()
def _open_function_call(self, content: Content) -> Generator[ResponseStreamEvent]:
self._fc_builder = self._stream.add_output_item_function_call(
name=content.name or "",
call_id=content.call_id or "",
)
self._active_type = "function_call"
self._active_id = content.call_id
yield self._fc_builder.emit_added()
def _open_mcp_call(self, content: Content) -> Generator[ResponseStreamEvent]:
self._mcp_builder = self._stream.add_output_item_mcp_call(
server_label=content.server_name or "default",
name=content.tool_name or "",
)
self._active_type = "mcp_server_tool_call"
self._active_id = f"{content.server_name or 'default'}::{content.tool_name}"
yield self._mcp_builder.emit_added()
def _close(self) -> Generator[ResponseStreamEvent]:
accumulated = "".join(self._accumulated)
if self._active_type == "text" and self._text_content and self._message_item:
yield self._text_content.emit_text_done(accumulated)
yield self._text_content.emit_done()
yield self._message_item.emit_done()
self._text_content = None
self._message_item = None
elif self._active_type == "text_reasoning" and self._summary_part and self._reasoning_item:
yield self._summary_part.emit_text_done(accumulated)
yield self._summary_part.emit_done()
yield self._reasoning_item.emit_done()
self._summary_part = None
self._reasoning_item = None
elif self._active_type == "function_call" and self._fc_builder:
yield self._fc_builder.emit_arguments_done(accumulated)
yield self._fc_builder.emit_done()
self._fc_builder = None
elif self._active_type == "mcp_server_tool_call" and self._mcp_builder:
yield self._mcp_builder.emit_arguments_done(accumulated)
yield self._mcp_builder.emit_completed()
yield self._mcp_builder.emit_done()
self._mcp_builder = None
self._active_type = None
self._active_id = None
self._accumulated.clear()
# endregion
# region Option Conversion
def _to_chat_options(request: CreateResponse) -> tuple[ChatOptions, bool]:
"""Converts a CreateResponse request to ChatOptions.
Args:
request (CreateResponse): The request to convert.
Returns:
ChatOptions: The converted ChatOptions.
bool: Whether any options were set.
"""
chat_options = ChatOptions()
are_options_set = False
if request.temperature is not None:
chat_options["temperature"] = request.temperature
are_options_set = True
if request.top_p is not None:
chat_options["top_p"] = request.top_p
are_options_set = True
if request.max_output_tokens is not None:
chat_options["max_tokens"] = request.max_output_tokens
are_options_set = True
if request.parallel_tool_calls is not None:
chat_options["allow_multiple_tool_calls"] = request.parallel_tool_calls
are_options_set = True
return chat_options, are_options_set
# endregion
# region Input Message Conversion
def _to_messages(history: Sequence[OutputItem]) -> list[Message]:
"""Converts a sequence of OutputItem objects to a list of Message objects.
Args:
history (Sequence[OutputItem]): The sequence of OutputItem objects to convert.
Returns:
list[Message]: The list of Message objects.
"""
messages: list[Message] = []
for item in history:
messages.append(_to_message(item))
return messages
def _to_message(item: OutputItem) -> Message:
"""Converts an OutputItem to a Message.
Args:
item (OutputItem): The OutputItem to convert.
Returns:
Message: The converted Message.
Raises:
ValueError: If the OutputItem type is not supported.
"""
if item.type == "output_message":
output_msg = cast(OutputItemOutputMessage, item)
return Message(
role=output_msg.role, contents=[_convert_output_message_content(part) for part in output_msg.content]
)
if item.type == "message":
msg = cast(OutputItemMessage, item)
return Message(role=msg.role, contents=[_convert_message_content(part) for part in msg.content])
if item.type == "function_call":
fc = cast(OutputItemFunctionToolCall, item)
return Message(
role="assistant",
contents=[Content.from_function_call(fc.call_id, fc.name, arguments=fc.arguments)],
)
if item.type == "function_call_output":
fco = cast(FunctionCallOutputItemParam, item)
output = fco.output if isinstance(fco.output, str) else str(fco.output)
return Message(
role="tool",
contents=[Content.from_function_result(fco.call_id, result=output)],
)
if item.type == "reasoning":
reasoning = cast(OutputItemReasoningItem, item)
contents: list[Content] = []
if reasoning.summary:
for summary in reasoning.summary:
contents.append(Content.from_text(summary.text))
return Message(role="assistant", contents=contents)
if item.type == "mcp_call":
mcp = cast(OutputItemMcpToolCall, item)
return Message(
role="assistant",
contents=[
Content.from_mcp_server_tool_call(
mcp.id,
mcp.name,
server_name=mcp.server_label,
arguments=mcp.arguments,
)
],
)
if item.type == "mcp_approval_request":
mcp_req = cast(OutputItemMcpApprovalRequest, item)
mcp_call_content = Content.from_mcp_server_tool_call(
mcp_req.id,
mcp_req.name,
server_name=mcp_req.server_label,
arguments=mcp_req.arguments,
)
return Message(
role="assistant",
contents=[Content.from_function_approval_request(mcp_req.id, mcp_call_content)],
)
if item.type == "mcp_approval_response":
mcp_resp = cast(OutputItemMcpApprovalResponseResource, item)
# Build a placeholder function_call Content since the original call details are not available
placeholder_content = Content.from_function_call(mcp_resp.approval_request_id, "mcp_approval")
return Message(
role="user",
contents=[Content.from_function_approval_response(mcp_resp.approve, mcp_resp.id, placeholder_content)],
)
if item.type == "code_interpreter_call":
ci = cast(OutputItemCodeInterpreterToolCall, item)
return Message(
role="assistant",
contents=[Content.from_code_interpreter_tool_call(call_id=ci.id)],
)
if item.type == "image_generation_call":
ig = cast(OutputItemImageGenToolCall, item)
return Message(
role="assistant",
contents=[Content.from_image_generation_tool_call(image_id=ig.id)],
)
if item.type == "shell_call":
sc = cast(OutputItemFunctionShellCall, item)
return Message(
role="assistant",
contents=[
Content.from_shell_tool_call(
call_id=sc.call_id,
commands=sc.action.commands,
status=str(sc.status),
)
],
)
if item.type == "shell_call_output":
sco = cast(OutputItemFunctionShellCallOutput, item)
outputs = [
Content.from_shell_command_output(
stdout=out.stdout or "",
stderr=out.stderr or "",
exit_code=getattr(out.outcome, "exit_code", None) if hasattr(out, "outcome") else None,
)
for out in (sco.output or [])
]
return Message(
role="tool",
contents=[
Content.from_shell_tool_result(
call_id=sco.call_id,
outputs=outputs,
max_output_length=sco.max_output_length,
)
],
)
if item.type == "local_shell_call":
lsc = cast(OutputItemLocalShellToolCall, item)
commands = lsc.action.command if hasattr(lsc.action, "command") and lsc.action.command else []
return Message(
role="assistant",
contents=[
Content.from_shell_tool_call(
call_id=lsc.call_id,
commands=commands,
status=str(lsc.status),
)
],
)
if item.type == "local_shell_call_output":
lsco = cast(OutputItemLocalShellToolCallOutput, item)
return Message(
role="tool",
contents=[
Content.from_shell_tool_result(
call_id=lsco.id,
outputs=[Content.from_shell_command_output(stdout=lsco.output)],
)
],
)
if item.type == "file_search_call":
fs = cast(OutputItemFileSearchToolCall, item)
return Message(
role="assistant",
contents=[
Content.from_function_call(
fs.id,
"file_search",
arguments=json.dumps({"queries": fs.queries}),
)
],
)
if item.type == "web_search_call":
ws = cast(OutputItemWebSearchToolCall, item)
return Message(
role="assistant",
contents=[Content.from_function_call(ws.id, "web_search")],
)
if item.type == "computer_call":
cc = cast(OutputItemComputerToolCall, item)
return Message(
role="assistant",
contents=[
Content.from_function_call(
cc.call_id,
"computer_use",
arguments=str(cc.action),
)
],
)
if item.type == "computer_call_output":
cco = cast(OutputItemComputerToolCallOutputResource, item)
return Message(
role="tool",
contents=[Content.from_function_result(cco.call_id, result=str(cco.output))],
)
if item.type == "custom_tool_call":
ct = cast(OutputItemCustomToolCall, item)
return Message(
role="assistant",
contents=[Content.from_function_call(ct.call_id, ct.name, arguments=ct.input)],
)
if item.type == "custom_tool_call_output":
cto = cast(OutputItemCustomToolCallOutput, item)
output = cto.output if isinstance(cto.output, str) else str(cto.output)
return Message(
role="tool",
contents=[Content.from_function_result(cto.call_id, result=output)],
)
if item.type == "apply_patch_call":
ap = cast(OutputItemApplyPatchToolCall, item)
return Message(
role="assistant",
contents=[
Content.from_function_call(
ap.call_id,
"apply_patch",
arguments=str(ap.operation),
)
],
)
if item.type == "apply_patch_call_output":
apo = cast(OutputItemApplyPatchToolCallOutput, item)
return Message(
role="tool",
contents=[Content.from_function_result(apo.call_id, result=apo.output or "")],
)
if item.type == "oauth_consent_request":
oauth = cast(OAuthConsentRequestOutputItem, item)
return Message(
role="assistant",
contents=[Content.from_oauth_consent_request(oauth.consent_link)],
)
if item.type == "structured_outputs":
so = cast(StructuredOutputsOutputItem, item)
text = json.dumps(so.output) if not isinstance(so.output, str) else so.output
return Message(role="assistant", contents=[Content.from_text(text)])
raise ValueError(f"Unsupported OutputItem type: {item.type}")
def _convert_output_message_content(content: OutputMessageContent) -> Content:
"""Converts an OutputMessageContent to a Content object.
Args:
content (OutputMessageContent): The OutputMessageContent to convert.
Returns:
Content: The converted Content object.
Raises:
ValueError: If the OutputMessageContent type is not supported.
"""
if content.type == "output_text":
text_content = cast(OutputMessageContentOutputTextContent, content)
return Content.from_text(text_content.text)
if content.type == "refusal":
refusal_content = cast(OutputMessageContentRefusalContent, content)
return Content.from_text(refusal_content.refusal)
raise ValueError(f"Unsupported OutputMessageContent type: {content.type}")
def _convert_message_content(content: MessageContent) -> Content:
"""Converts a MessageContent to a Content object.
Args:
content (MessageContent): The MessageContent to convert.
Returns:
Content: The converted Content object.
Raises:
ValueError: If the MessageContent type is not supported.
"""
if content.type == "input_text":
input_text = cast(MessageContentInputTextContent, content)
return Content.from_text(input_text.text)
if content.type == "output_text":
output_text = cast(MessageContentOutputTextContent, content)
return Content.from_text(output_text.text)
if content.type == "text":
text = cast(TextContent, content)
return Content.from_text(text.text)
if content.type == "summary_text":
summary = cast(SummaryTextContent, content)
return Content.from_text(summary.text)
if content.type == "refusal":
refusal = cast(MessageContentRefusalContent, content)
return Content.from_text(refusal.refusal)
if content.type == "reasoning_text":
reasoning = cast(MessageContentReasoningTextContent, content)
return Content.from_text_reasoning(text=reasoning.text)
if content.type == "input_image":
image = cast(MessageContentInputImageContent, content)
if image.image_url:
return Content.from_uri(image.image_url)
if image.file_id:
return Content.from_hosted_file(image.file_id)
if content.type == "input_file":
file = cast(MessageContentInputFileContent, content)
if file.file_url:
return Content.from_uri(file.file_url)
if file.file_id:
return Content.from_hosted_file(file.file_id, name=file.filename)
if content.type == "computer_screenshot":
screenshot = cast(ComputerScreenshotContent, content)
return Content.from_uri(screenshot.image_url)
raise ValueError(f"Unsupported MessageContent type: {content.type}")
# endregion
# region Output Item Conversion
def _arguments_to_str(arguments: str | Mapping[str, Any] | None) -> str:
"""Convert arguments to a JSON string.
Args:
arguments: The arguments to convert, can be a string, mapping, or None.
Returns:
The arguments as a JSON string.
"""
if arguments is None:
return ""
if isinstance(arguments, str):
return arguments
return json.dumps(arguments)
async def _to_outputs(stream: ResponseEventStream, content: Content) -> AsyncIterator[ResponseStreamEvent]:
"""Converts a Content object to an async sequence of ResponseStreamEvent objects.
Args:
stream: The ResponseEventStream to use for building events.
content: The Content to convert.
Yields:
ResponseStreamEvent: The converted event objects.
Raises:
ValueError: If the Content type is not supported.
"""
if content.type == "text" and content.text is not None:
async for event in stream.aoutput_item_message(content.text):
yield event
elif content.type == "text_reasoning" and content.text is not None:
async for event in stream.aoutput_item_reasoning_item(content.text):
yield event
elif content.type == "function_call":
async for event in stream.aoutput_item_function_call(
content.name, # type: ignore[arg-type]
content.call_id, # type: ignore[arg-type]
_arguments_to_str(content.arguments),
):
yield event
elif content.type == "function_result":
async for event in stream.aoutput_item_function_call_output(
content.call_id, # type: ignore[arg-type]
str(content.result or ""),
):
yield event
elif content.type == "image_generation_tool_result" and content.outputs is not None:
async for event in stream.aoutput_item_image_gen_call(str(content.outputs)):
yield event
elif content.type == "mcp_server_tool_call":
mcp_call = stream.add_output_item_mcp_call(
server_label=content.server_name or "default",
name=content.tool_name or "",
)
yield mcp_call.emit_added()
async for event in mcp_call.aarguments(_arguments_to_str(content.arguments)):
yield event
yield mcp_call.emit_completed()
yield mcp_call.emit_done()
elif content.type == "mcp_server_tool_result":
output = (
content.output
if isinstance(content.output, str)
else str(content.output)
if content.output is not None
else ""
)
async for event in stream.aoutput_item_custom_tool_call_output(content.call_id or "", output):
yield event
elif content.type == "shell_tool_call":
action = FunctionShellAction(commands=content.commands or [], timeout_ms=0, max_output_length=0)
async for event in stream.aoutput_item_function_shell_call(
content.call_id or "",
action,
LocalEnvironmentResource(),
status=content.status or "completed",
):
yield event
elif content.type == "shell_tool_result":
output_items: list[FunctionShellCallOutputContent] = []
if content.outputs:
for out in content.outputs:
exit_code = getattr(out, "exit_code", None)
output_items.append(
FunctionShellCallOutputContent(
stdout=getattr(out, "stdout", "") or "",
stderr=getattr(out, "stderr", "") or "",
outcome=FunctionShellCallOutputExitOutcome(exit_code=exit_code if exit_code is not None else 0),
)
)
async for event in stream.aoutput_item_function_shell_call_output(
content.call_id or "",
output_items,
status=content.status or "completed",
max_output_length=content.max_output_length,
):
yield event
else:
# Log a warning for unsupported content types instead of raising an error to avoid breaking the response stream.
logger.warning(f"Content type '{content.type}' is not supported yet. This is usually safe to ignore.")
# endregion
@@ -0,0 +1,99 @@
[project]
name = "agent-framework-foundry-hosting"
description = "Foundry Hosting integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0a260421"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true"
urls.issues = "https://github.com/microsoft/agent-framework/issues"
classifiers = [
"License :: OSI Approved :: MIT License",
"Development Status :: 4 - Alpha",
"Intended Audience :: Developers",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.1.0,<2",
"azure-ai-agentserver-core==2.0.0b2",
"azure-ai-agentserver-responses==1.0.0b4",
"azure-ai-agentserver-invocations==1.0.0b2",
]
[tool.uv]
prerelease = "if-necessary-or-explicit"
environments = [
"sys_platform == 'darwin'",
"sys_platform == 'linux'",
"sys_platform == 'win32'"
]
[tool.uv-dynamic-versioning]
fallback-version = "0.0.0"
[tool.pytest.ini_options]
testpaths = 'tests'
addopts = "-ra -q -r fEX"
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
filterwarnings = []
timeout = 120
markers = [
"integration: marks tests as integration tests that require external services",
]
[tool.ruff]
extend = "../../pyproject.toml"
[tool.coverage.run]
omit = [
"**/__init__.py"
]
[tool.pyright]
extends = "../../pyproject.toml"
include = ["agent_framework_foundry_hosting"]
exclude = ['tests']
[tool.mypy]
plugins = ['pydantic.mypy']
strict = true
python_version = "3.10"
ignore_missing_imports = true
disallow_untyped_defs = true
no_implicit_optional = true
check_untyped_defs = true
warn_return_any = true
show_error_codes = true
warn_unused_ignores = false
disallow_incomplete_defs = true
disallow_untyped_decorators = true
[tool.bandit]
targets = ["agent_framework_foundry_hosting"]
exclude_dirs = ["tests"]
[tool.poe]
executor.type = "uv"
include = "../../shared_tasks.toml"
[tool.poe.tasks.mypy]
help = "Run MyPy for this package."
cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_foundry_hosting"
[tool.poe.tasks.test]
help = "Run the default unit test suite for this package."
cmd = 'pytest -m "not integration" --cov=agent_framework_foundry_hosting --cov-report=term-missing:skip-covered tests'
[build-system]
requires = ["flit-core >= 3.11,<4.0"]
build-backend = "flit_core.buildapi"
@@ -0,0 +1,917 @@
# Copyright (c) Microsoft. All rights reserved.
"""HTTP round-trip tests for ResponsesHostServer.
These tests exercise the full HTTP pipeline using httpx.AsyncClient with
ASGITransport — no real server process is started. Requests go through
the Starlette routing stack, the Responses API middleware, and arrive at
the registered _handle_create handler.
"""
from __future__ import annotations
import json
from collections.abc import AsyncIterator
from unittest.mock import AsyncMock, MagicMock
import httpx
import pytest
from agent_framework import (
AgentResponse,
AgentResponseUpdate,
Content,
HistoryProvider,
Message,
RawAgent,
ResponseStream,
)
from azure.ai.agentserver.responses import InMemoryResponseProvider
from typing_extensions import Any
from agent_framework_foundry_hosting import ResponsesHostServer
from agent_framework_foundry_hosting._responses import _to_message # pyright: ignore[reportPrivateUsage]
# region Helpers
def _make_agent(
*,
response: AgentResponse | None = None,
stream_updates: list[AgentResponseUpdate] | None = None,
) -> MagicMock:
"""Create a mock agent implementing SupportsAgentRun."""
agent = MagicMock(spec=RawAgent)
agent.id = "test-agent"
agent.name = "Test Agent"
agent.description = "A mock agent for testing"
agent.context_providers = []
if response is not None:
async def run_non_streaming(*args: Any, **kwargs: Any) -> AgentResponse:
return response
agent.run = AsyncMock(side_effect=run_non_streaming)
if stream_updates is not None:
async def _stream_gen() -> AsyncIterator[AgentResponseUpdate]:
for update in stream_updates:
yield update
def run_streaming(*args: Any, **kwargs: Any) -> Any:
if kwargs.get("stream"):
return ResponseStream(_stream_gen()) # type: ignore
raise NotImplementedError("Only streaming is configured on this mock")
agent.run = MagicMock(side_effect=run_streaming)
return agent
def _make_server(agent: MagicMock, **kwargs: Any) -> ResponsesHostServer:
"""Create a ResponsesHostServer with an in-memory store."""
return ResponsesHostServer(agent, store=InMemoryResponseProvider(), **kwargs)
async def _post(
server: ResponsesHostServer,
*,
input_text: str = "Hello",
model: str = "test-model",
stream: bool = False,
temperature: float | None = None,
top_p: float | None = None,
max_output_tokens: int | None = None,
parallel_tool_calls: bool | None = None,
) -> httpx.Response:
"""Send a POST /responses request through the ASGI transport."""
payload: dict[str, Any] = {"model": model, "input": input_text, "stream": stream}
if temperature is not None:
payload["temperature"] = temperature
if top_p is not None:
payload["top_p"] = top_p
if max_output_tokens is not None:
payload["max_output_tokens"] = max_output_tokens
if parallel_tool_calls is not None:
payload["parallel_tool_calls"] = parallel_tool_calls
transport = httpx.ASGITransport(app=server)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
return await client.post("/responses", json=payload)
def _parse_sse_events(body: str) -> list[dict[str, Any]]:
"""Parse SSE text into a list of event dicts with 'event' and 'data' keys."""
events: list[dict[str, Any]] = []
current_event: str | None = None
current_data_lines: list[str] = []
for line in body.split("\n"):
if line.startswith("event: "):
current_event = line[len("event: ") :]
elif line.startswith("data: "):
current_data_lines.append(line[len("data: ") :])
elif line.strip() == "" and current_event is not None:
data_str = "\n".join(current_data_lines)
try:
data = json.loads(data_str)
except json.JSONDecodeError:
data = data_str
events.append({"event": current_event, "data": data})
current_event = None
current_data_lines = []
return events
def _sse_event_types(events: list[dict[str, Any]]) -> list[str]:
"""Extract event type strings from parsed SSE events."""
return [e["event"] for e in events]
# endregion
# region Initialization
class TestResponsesHostServerInit:
def test_init_basic(self) -> None:
agent = _make_agent(
response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("hi")])])
)
server = _make_server(agent)
assert server is not None
def test_init_rejects_history_provider_with_load_messages(self) -> None:
hp = HistoryProvider(source_id="test", load_messages=True)
agent = _make_agent(
response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("hi")])])
)
agent.context_providers = [hp]
with pytest.raises(RuntimeError, match="history provider"):
ResponsesHostServer(agent)
# endregion
# region Health Check
class TestHealthCheck:
async def test_readiness(self) -> None:
agent = _make_agent(
response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("hi")])])
)
server = _make_server(agent)
transport = httpx.ASGITransport(app=server)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.get("/readiness")
assert resp.status_code == 200
# endregion
# region Non-streaming
class TestNonStreaming:
async def test_basic_text_response(self) -> None:
agent = _make_agent(
response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("Hello!")])])
)
server = _make_server(agent)
resp = await _post(server, input_text="Hi", stream=False)
assert resp.status_code == 200
assert "application/json" in resp.headers["content-type"]
body = resp.json()
assert body["object"] == "response"
assert body["status"] == "completed"
assert len(body["output"]) > 0
# Find the message output item with our text
text_found = False
for item in body["output"]:
assert item["type"] == "message"
for part in item.get("content", []):
if part.get("type") == "output_text" and part.get("text") == "Hello!":
text_found = True
assert text_found, f"Expected 'Hello!' in output, got: {body['output']}"
async def test_function_call_and_result(self) -> None:
agent = _make_agent(
response=AgentResponse(
messages=[
Message(
role="assistant",
contents=[Content.from_function_call("call_1", "get_weather", arguments='{"loc": "NYC"}')],
),
Message(role="tool", contents=[Content.from_function_result("call_1", result="sunny")]),
Message(role="assistant", contents=[Content.from_text("The weather is sunny!")]),
]
)
)
server = _make_server(agent)
resp = await _post(server, stream=False)
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "completed"
types = [item["type"] for item in body["output"]]
assert "function_call" in types
assert "function_call_output" in types
assert "message" in types
async def test_reasoning_content(self) -> None:
agent = _make_agent(
response=AgentResponse(
messages=[
Message(
role="assistant",
contents=[
Content.from_text_reasoning(text="Let me think..."),
Content.from_text("The answer is 42"),
],
),
]
)
)
server = _make_server(agent)
resp = await _post(server, stream=False)
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "completed"
types = [item["type"] for item in body["output"]]
assert "reasoning" in types
assert "message" in types
async def test_empty_response(self) -> None:
agent = _make_agent(response=AgentResponse(messages=[]))
server = _make_server(agent)
resp = await _post(server, stream=False)
assert resp.status_code == 200
body = resp.json()
assert body["status"] == "completed"
async def test_chat_options_forwarded(self) -> None:
agent = _make_agent(
response=AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("ok")])])
)
server = _make_server(agent)
resp = await _post(server, stream=False, temperature=0.5, top_p=0.9, max_output_tokens=1024)
assert resp.status_code == 200
agent.run.assert_awaited_once()
call_kwargs = agent.run.call_args.kwargs
assert call_kwargs["stream"] is False
options = call_kwargs["options"]
assert options["temperature"] == 0.5
assert options["top_p"] == 0.9
assert options["max_tokens"] == 1024
# endregion
# region Streaming
class TestStreaming:
async def test_basic_text_streaming(self) -> None:
agent = _make_agent(
stream_updates=[
AgentResponseUpdate(contents=[Content.from_text("Hello ")], role="assistant"),
AgentResponseUpdate(contents=[Content.from_text("world!")], role="assistant"),
]
)
server = _make_server(agent)
resp = await _post(server, stream=True)
assert resp.status_code == 200
assert "text/event-stream" in resp.headers["content-type"]
events = _parse_sse_events(resp.text)
types = _sse_event_types(events)
assert types[0] == "response.created"
assert types[1] == "response.in_progress"
assert types[-1] == "response.completed"
assert "response.output_text.delta" in types
assert types.count("response.output_text.delta") == 2
assert "response.output_text.done" in types
# Verify the accumulated text in the done event
done_events = [e for e in events if e["event"] == "response.output_text.done"]
assert len(done_events) == 1
assert done_events[0]["data"]["text"] == "Hello world!"
async def test_function_call_streaming(self) -> None:
agent = _make_agent(
stream_updates=[
AgentResponseUpdate(
contents=[Content.from_function_call("call_1", "search", arguments='{"q":')],
role="assistant",
),
AgentResponseUpdate(
contents=[Content.from_function_call("call_1", "search", arguments=' "hello"}')],
role="assistant",
),
]
)
server = _make_server(agent)
resp = await _post(server, stream=True)
assert resp.status_code == 200
events = _parse_sse_events(resp.text)
types = _sse_event_types(events)
assert types[0] == "response.created"
assert types[-1] == "response.completed"
assert types.count("response.function_call_arguments.delta") == 2
assert "response.function_call_arguments.done" in types
# Verify accumulated arguments
args_done = [e for e in events if e["event"] == "response.function_call_arguments.done"]
assert len(args_done) == 1
assert args_done[0]["data"]["arguments"] == '{"q": "hello"}'
async def test_alternating_text_and_function_call(self) -> None:
agent = _make_agent(
stream_updates=[
# Text deltas
AgentResponseUpdate(contents=[Content.from_text("Let me ")], role="assistant"),
AgentResponseUpdate(contents=[Content.from_text("search...")], role="assistant"),
# Function call argument deltas
AgentResponseUpdate(
contents=[Content.from_function_call("call_1", "search", arguments='{"q":')],
role="assistant",
),
AgentResponseUpdate(
contents=[Content.from_function_call("call_1", "search", arguments=' "x"}')],
role="assistant",
),
# More text deltas
AgentResponseUpdate(contents=[Content.from_text("Found ")], role="assistant"),
AgentResponseUpdate(contents=[Content.from_text("it!")], role="assistant"),
]
)
server = _make_server(agent)
resp = await _post(server, stream=True)
assert resp.status_code == 200
events = _parse_sse_events(resp.text)
types = _sse_event_types(events)
assert types[0] == "response.created"
assert types[-1] == "response.completed"
# 4 text deltas + 2 function call argument deltas
assert types.count("response.output_text.delta") == 4
assert types.count("response.function_call_arguments.delta") == 2
# 3 distinct output items (text, fc, text)
assert types.count("response.output_item.added") == 3
assert types.count("response.output_item.done") == 3
# Verify accumulated content
text_done = [e for e in events if e["event"] == "response.output_text.done"]
assert len(text_done) == 2
assert text_done[0]["data"]["text"] == "Let me search..."
assert text_done[1]["data"]["text"] == "Found it!"
args_done = [e for e in events if e["event"] == "response.function_call_arguments.done"]
assert len(args_done) == 1
assert args_done[0]["data"]["arguments"] == '{"q": "x"}'
async def test_reasoning_then_text_streaming(self) -> None:
agent = _make_agent(
stream_updates=[
# Reasoning deltas
AgentResponseUpdate(contents=[Content.from_text_reasoning(text="Let me ")], role="assistant"),
AgentResponseUpdate(contents=[Content.from_text_reasoning(text="think...")], role="assistant"),
# Text deltas
AgentResponseUpdate(contents=[Content.from_text("The answer ")], role="assistant"),
AgentResponseUpdate(contents=[Content.from_text("is 42")], role="assistant"),
]
)
server = _make_server(agent)
resp = await _post(server, stream=True)
assert resp.status_code == 200
events = _parse_sse_events(resp.text)
types = _sse_event_types(events)
assert types[0] == "response.created"
assert types[-1] == "response.completed"
# Reasoning + text = 2 output items
assert types.count("response.output_item.added") == 2
assert types.count("response.output_item.done") == 2
assert types.count("response.output_text.delta") == 2
# Verify accumulated text
text_done = [e for e in events if e["event"] == "response.output_text.done"]
assert len(text_done) == 1
assert text_done[0]["data"]["text"] == "The answer is 42"
async def test_empty_streaming(self) -> None:
agent = _make_agent(stream_updates=[])
server = _make_server(agent)
resp = await _post(server, stream=True)
assert resp.status_code == 200
events = _parse_sse_events(resp.text)
types = _sse_event_types(events)
assert types == ["response.created", "response.in_progress", "response.completed"]
async def test_mixed_contents_in_single_update(self) -> None:
"""Text and function call in one update switches builder mid-update."""
agent = _make_agent(
stream_updates=[
AgentResponseUpdate(
contents=[
Content.from_text("Let me search"),
Content.from_function_call("call_1", "search", arguments='{"q": "test"}'),
],
role="assistant",
),
]
)
server = _make_server(agent)
resp = await _post(server, stream=True)
assert resp.status_code == 200
events = _parse_sse_events(resp.text)
types = _sse_event_types(events)
assert "response.output_text.delta" in types
assert "response.output_text.done" in types
assert "response.function_call_arguments.delta" in types
assert "response.function_call_arguments.done" in types
async def test_different_function_call_ids_produce_separate_items(self) -> None:
agent = _make_agent(
stream_updates=[
AgentResponseUpdate(
contents=[Content.from_function_call("call_1", "func_a", arguments='{"x":1}')],
role="assistant",
),
AgentResponseUpdate(
contents=[Content.from_function_call("call_2", "func_b", arguments='{"y":2}')],
role="assistant",
),
]
)
server = _make_server(agent)
resp = await _post(server, stream=True)
assert resp.status_code == 200
events = _parse_sse_events(resp.text)
types = _sse_event_types(events)
# Two separate function call items
assert types.count("response.output_item.added") == 2
assert types.count("response.function_call_arguments.done") == 2
async def test_mcp_tool_call_streaming(self) -> None:
agent = _make_agent(
stream_updates=[
AgentResponseUpdate(
contents=[
Content(
type="mcp_server_tool_call",
server_name="my_server",
tool_name="search",
arguments='{"query":',
)
],
role="assistant",
),
AgentResponseUpdate(
contents=[
Content(
type="mcp_server_tool_call",
server_name="my_server",
tool_name="search",
arguments=' "test"}',
)
],
role="assistant",
),
]
)
server = _make_server(agent)
resp = await _post(server, stream=True)
assert resp.status_code == 200
events = _parse_sse_events(resp.text)
types = _sse_event_types(events)
assert types[0] == "response.created"
assert types[-1] == "response.completed"
assert "response.output_item.added" in types
assert "response.output_item.done" in types
# endregion
# region _to_message conversion
class TestToMessage:
"""Tests for _to_message covering all supported OutputItem types."""
def test_output_message(self) -> None:
from azure.ai.agentserver.responses.models import OutputItemOutputMessage, OutputMessageContentOutputTextContent
item = OutputItemOutputMessage({
"type": "output_message",
"role": "assistant",
"content": [OutputMessageContentOutputTextContent({"type": "output_text", "text": "hello"})],
"status": "completed",
"id": "msg-1",
})
msg = _to_message(item)
assert msg.role == "assistant"
assert len(msg.contents) == 1
assert msg.contents[0].type == "text"
assert msg.contents[0].text == "hello"
def test_message(self) -> None:
from azure.ai.agentserver.responses.models import MessageContentInputTextContent, OutputItemMessage
item = OutputItemMessage({
"type": "message",
"role": "user",
"content": [MessageContentInputTextContent({"type": "input_text", "text": "hi"})],
})
msg = _to_message(item)
assert msg.role == "user"
assert len(msg.contents) == 1
assert msg.contents[0].text == "hi"
def test_function_call(self) -> None:
from azure.ai.agentserver.responses.models import OutputItemFunctionToolCall
item = OutputItemFunctionToolCall({
"type": "function_call",
"call_id": "call_1",
"name": "get_weather",
"arguments": '{"city": "NYC"}',
"status": "completed",
"id": "fc-1",
})
msg = _to_message(item)
assert msg.role == "assistant"
assert msg.contents[0].type == "function_call"
assert msg.contents[0].call_id == "call_1"
assert msg.contents[0].name == "get_weather"
def test_function_call_output(self) -> None:
from azure.ai.agentserver.responses.models import FunctionCallOutputItemParam
item = FunctionCallOutputItemParam({"type": "function_call_output", "call_id": "call_1", "output": "sunny"})
msg = _to_message(item) # type: ignore[arg-type]
assert msg.role == "tool"
assert msg.contents[0].type == "function_result"
assert msg.contents[0].call_id == "call_1"
assert msg.contents[0].result == "sunny"
def test_reasoning(self) -> None:
from azure.ai.agentserver.responses.models import OutputItemReasoningItem, SummaryTextContent
item = OutputItemReasoningItem({
"type": "reasoning",
"id": "r-1",
"summary": [SummaryTextContent({"type": "summary_text", "text": "thinking hard"})],
})
msg = _to_message(item)
assert msg.role == "assistant"
assert len(msg.contents) == 1
assert msg.contents[0].text == "thinking hard"
def test_reasoning_no_summary(self) -> None:
from azure.ai.agentserver.responses.models import OutputItemReasoningItem
item = OutputItemReasoningItem({"type": "reasoning", "id": "r-2"})
msg = _to_message(item)
assert msg.role == "assistant"
assert msg.contents == []
def test_mcp_call(self) -> None:
from azure.ai.agentserver.responses.models import OutputItemMcpToolCall
item = OutputItemMcpToolCall({
"type": "mcp_call",
"id": "mcp-1",
"server_label": "my_server",
"name": "search",
"arguments": '{"q": "test"}',
})
msg = _to_message(item)
assert msg.role == "assistant"
assert msg.contents[0].type == "mcp_server_tool_call"
assert msg.contents[0].server_name == "my_server"
assert msg.contents[0].tool_name == "search"
def test_mcp_approval_request(self) -> None:
from azure.ai.agentserver.responses.models import OutputItemMcpApprovalRequest
item = OutputItemMcpApprovalRequest({
"type": "mcp_approval_request",
"id": "apr-1",
"server_label": "srv",
"name": "dangerous_tool",
"arguments": "{}",
})
msg = _to_message(item)
assert msg.role == "assistant"
assert msg.contents[0].type == "function_approval_request"
def test_mcp_approval_response(self) -> None:
from azure.ai.agentserver.responses.models import OutputItemMcpApprovalResponseResource
item = OutputItemMcpApprovalResponseResource({
"type": "mcp_approval_response",
"id": "resp-1",
"approval_request_id": "apr-1",
"approve": True,
})
msg = _to_message(item)
assert msg.role == "user"
assert msg.contents[0].type == "function_approval_response"
assert msg.contents[0].approved is True
def test_code_interpreter_call(self) -> None:
from azure.ai.agentserver.responses.models import OutputItemCodeInterpreterToolCall
item = OutputItemCodeInterpreterToolCall({
"type": "code_interpreter_call",
"id": "ci-1",
"status": "completed",
"container_id": "c-1",
"code": "print('hi')",
"outputs": [],
})
msg = _to_message(item)
assert msg.role == "assistant"
assert msg.contents[0].type == "code_interpreter_tool_call"
def test_image_generation_call(self) -> None:
from azure.ai.agentserver.responses.models import OutputItemImageGenToolCall
item = OutputItemImageGenToolCall({"type": "image_generation_call", "id": "ig-1", "status": "completed"})
msg = _to_message(item)
assert msg.role == "assistant"
assert msg.contents[0].type == "image_generation_tool_call"
def test_shell_call(self) -> None:
from azure.ai.agentserver.responses.models import (
FunctionShellAction,
FunctionShellCallEnvironment,
OutputItemFunctionShellCall,
)
item = OutputItemFunctionShellCall({
"type": "shell_call",
"id": "sc-1",
"call_id": "call_sc",
"action": FunctionShellAction({"commands": ["ls", "-la"], "timeout_ms": 5000, "max_output_length": 1024}),
"status": "completed",
"environment": FunctionShellCallEnvironment({"type": "local"}),
})
msg = _to_message(item)
assert msg.role == "assistant"
assert msg.contents[0].type == "shell_tool_call"
assert msg.contents[0].commands == ["ls", "-la"]
assert msg.contents[0].call_id == "call_sc"
def test_shell_call_output(self) -> None:
from azure.ai.agentserver.responses.models import (
FunctionShellCallOutputContent,
FunctionShellCallOutputExitOutcome,
OutputItemFunctionShellCallOutput,
)
item = OutputItemFunctionShellCallOutput({
"type": "shell_call_output",
"id": "sco-1",
"call_id": "call_sc",
"status": "completed",
"output": [
FunctionShellCallOutputContent({
"stdout": "file.txt",
"stderr": "",
"outcome": FunctionShellCallOutputExitOutcome({"exit_code": 0}),
})
],
"max_output_length": 1024,
})
msg = _to_message(item)
assert msg.role == "tool"
assert msg.contents[0].type == "shell_tool_result"
assert msg.contents[0].call_id == "call_sc"
def test_local_shell_call(self) -> None:
from azure.ai.agentserver.responses.models import LocalShellExecAction, OutputItemLocalShellToolCall
item = OutputItemLocalShellToolCall({
"type": "local_shell_call",
"id": "lsc-1",
"call_id": "call_lsc",
"action": LocalShellExecAction({"type": "exec", "command": ["echo", "hello"], "env": {}}),
"status": "completed",
})
msg = _to_message(item)
assert msg.role == "assistant"
assert msg.contents[0].type == "shell_tool_call"
assert msg.contents[0].commands == ["echo", "hello"]
def test_local_shell_call_output(self) -> None:
from azure.ai.agentserver.responses.models import OutputItemLocalShellToolCallOutput
item = OutputItemLocalShellToolCallOutput({
"type": "local_shell_call_output",
"id": "lsco-1",
"output": "hello\n",
})
msg = _to_message(item)
assert msg.role == "tool"
assert msg.contents[0].type == "shell_tool_result"
def test_file_search_call(self) -> None:
from azure.ai.agentserver.responses.models import OutputItemFileSearchToolCall
item = OutputItemFileSearchToolCall({
"type": "file_search_call",
"id": "fs-1",
"status": "completed",
"queries": ["what is AI"],
})
msg = _to_message(item)
assert msg.role == "assistant"
assert msg.contents[0].type == "function_call"
assert msg.contents[0].name == "file_search"
assert '"what is AI"' in (msg.contents[0].arguments or "")
def test_web_search_call(self) -> None:
from azure.ai.agentserver.responses.models import OutputItemWebSearchToolCall, WebSearchActionSearch
item = OutputItemWebSearchToolCall({
"type": "web_search_call",
"id": "ws-1",
"status": "completed",
"action": WebSearchActionSearch({"type": "search", "query": "test"}),
})
msg = _to_message(item)
assert msg.role == "assistant"
assert msg.contents[0].type == "function_call"
assert msg.contents[0].name == "web_search"
def test_computer_call(self) -> None:
from azure.ai.agentserver.responses.models import ComputerAction, OutputItemComputerToolCall
item = OutputItemComputerToolCall({
"type": "computer_call",
"id": "cc-1",
"call_id": "call_cc",
"action": ComputerAction({"type": "click"}),
"pending_safety_checks": [],
"status": "completed",
})
msg = _to_message(item)
assert msg.role == "assistant"
assert msg.contents[0].type == "function_call"
assert msg.contents[0].name == "computer_use"
def test_computer_call_output(self) -> None:
from azure.ai.agentserver.responses.models import (
ComputerScreenshotImage,
OutputItemComputerToolCallOutputResource,
)
item = OutputItemComputerToolCallOutputResource({
"type": "computer_call_output",
"call_id": "call_cc",
"output": ComputerScreenshotImage({
"type": "computer_screenshot",
"image_url": "data:image/png;base64,abc",
}),
})
msg = _to_message(item)
assert msg.role == "tool"
assert msg.contents[0].type == "function_result"
assert msg.contents[0].call_id == "call_cc"
def test_custom_tool_call(self) -> None:
from azure.ai.agentserver.responses.models import OutputItemCustomToolCall
item = OutputItemCustomToolCall({
"type": "custom_tool_call",
"call_id": "call_ct",
"name": "my_tool",
"input": '{"key": "value"}',
})
msg = _to_message(item)
assert msg.role == "assistant"
assert msg.contents[0].type == "function_call"
assert msg.contents[0].name == "my_tool"
assert msg.contents[0].arguments == '{"key": "value"}'
def test_custom_tool_call_output(self) -> None:
from azure.ai.agentserver.responses.models import OutputItemCustomToolCallOutput
item = OutputItemCustomToolCallOutput({
"type": "custom_tool_call_output",
"call_id": "call_ct",
"output": "result text",
})
msg = _to_message(item)
assert msg.role == "tool"
assert msg.contents[0].type == "function_result"
assert msg.contents[0].result == "result text"
def test_apply_patch_call(self) -> None:
from azure.ai.agentserver.responses.models import ApplyPatchUpdateFileOperation, OutputItemApplyPatchToolCall
item = OutputItemApplyPatchToolCall({
"type": "apply_patch_call",
"id": "ap-1",
"call_id": "call_ap",
"status": "completed",
"operation": ApplyPatchUpdateFileOperation({
"type": "update_file",
"path": "file.py",
"diff": "+ new line",
}),
})
msg = _to_message(item)
assert msg.role == "assistant"
assert msg.contents[0].type == "function_call"
assert msg.contents[0].name == "apply_patch"
def test_apply_patch_call_output(self) -> None:
from azure.ai.agentserver.responses.models import OutputItemApplyPatchToolCallOutput
item = OutputItemApplyPatchToolCallOutput({
"type": "apply_patch_call_output",
"id": "apo-1",
"call_id": "call_ap",
"status": "completed",
"output": "patch applied",
})
msg = _to_message(item)
assert msg.role == "tool"
assert msg.contents[0].type == "function_result"
assert msg.contents[0].result == "patch applied"
def test_oauth_consent_request(self) -> None:
from azure.ai.agentserver.responses.models import OAuthConsentRequestOutputItem
item = OAuthConsentRequestOutputItem({
"type": "oauth_consent_request",
"id": "oauth-1",
"consent_link": "https://example.com/consent",
"server_label": "my_server",
})
msg = _to_message(item)
assert msg.role == "assistant"
assert msg.contents[0].type == "oauth_consent_request"
assert msg.contents[0].consent_link == "https://example.com/consent"
def test_structured_outputs_dict(self) -> None:
from azure.ai.agentserver.responses.models import StructuredOutputsOutputItem
item = StructuredOutputsOutputItem({"type": "structured_outputs", "id": "so-1", "output": {"answer": 42}})
msg = _to_message(item)
assert msg.role == "assistant"
assert msg.contents[0].type == "text"
assert json.loads(msg.contents[0].text or "") == {"answer": 42}
def test_structured_outputs_string(self) -> None:
from azure.ai.agentserver.responses.models import StructuredOutputsOutputItem
item = StructuredOutputsOutputItem({"type": "structured_outputs", "id": "so-2", "output": "plain text"})
msg = _to_message(item)
assert msg.role == "assistant"
assert msg.contents[0].text == "plain text"
def test_unsupported_type_raises(self) -> None:
from azure.ai.agentserver.responses.models import OutputItem
item = OutputItem({"type": "some_unknown_type"})
with pytest.raises(ValueError, match="Unsupported OutputItem type: some_unknown_type"):
_to_message(item)
# endregion
+3 -3
View File
@@ -4,7 +4,7 @@ description = "Foundry Local integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260409"
version = "1.0.0b260421"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,8 +23,8 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.0.1,<2",
"agent-framework-openai>=1.0.1,<2",
"agent-framework-core>=1.1.0,<2",
"agent-framework-openai>=1.1.0,<2",
"foundry-local-sdk>=0.5.1,<0.5.2",
]
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Google Gemini integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0a260410"
version = "1.0.0a260421"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -24,7 +24,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.0.0,<2.0",
"agent-framework-core>=1.1.0,<2.0",
"google-genai>=1.0.0,<2.0.0",
]
@@ -4,7 +4,7 @@ description = "GitHub Copilot integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260409"
version = "1.0.0b260421"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.0.1,<2",
"agent-framework-core>=1.1.0,<2",
"github-copilot-sdk>=0.2.1,<=0.2.1; python_version >= '3.11'",
]
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Hyperlight CodeAct integrations for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0a260409"
version = "1.0.0a260421"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -22,7 +22,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.0.0,<2",
"agent-framework-core>=1.1.0,<2",
"hyperlight-sandbox>=0.3.0,<0.4",
"hyperlight-sandbox-backend-wasm>=0.3.0,<0.4 ; ((sys_platform == 'linux' and platform_machine == 'x86_64') or (sys_platform == 'win32' and platform_machine == 'AMD64')) and python_version < '3.14'",
"hyperlight-sandbox-python-guest>=0.3.0,<0.4",
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Experimental modules for Microsoft Agent Framework"
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260409"
version = "1.0.0b260421"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -22,7 +22,7 @@ classifiers = [
"Programming Language :: Python :: 3.14",
]
dependencies = [
"agent-framework-core>=1.0.1,<2",
"agent-framework-core>=1.1.0,<2",
]
[project.optional-dependencies]
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Mem0 integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260409"
version = "1.0.0b260421"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.0.1,<2",
"agent-framework-core>=1.1.0,<2",
"mem0ai>=1.0.0,<2",
]
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Ollama integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260409"
version = "1.0.0b260421"
license-files = ["LICENSE"]
urls.homepage = "https://learn.microsoft.com/en-us/agent-framework/"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.0.1,<2",
"agent-framework-core>=1.1.0,<2",
"ollama>=0.5.3,<0.5.4",
]
+2 -2
View File
@@ -4,7 +4,7 @@ description = "OpenAI integrations for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.1"
version = "1.1.0"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.0.1,<2",
"agent-framework-core>=1.1.0,<2",
"openai>=1.99.0,<3",
]
@@ -4,7 +4,7 @@ description = "Orchestration patterns for Microsoft Agent Framework. Includes Se
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260409"
version = "1.0.0b260421"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.0.1,<2",
"agent-framework-core>=1.1.0,<2",
]
[tool.uv]
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Microsoft Purview (Graph dataSecurityAndGovernance) integration f
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260409"
version = "1.0.0b260421"
license-files = ["LICENSE"]
urls.homepage = "https://github.com/microsoft/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -24,7 +24,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.0.1,<2",
"agent-framework-core>=1.1.0,<2",
"azure-core>=1.30.0,<2",
"httpx>=0.27.0,<0.29",
]
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Redis integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260409"
version = "1.0.0b260421"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.0.1,<2",
"agent-framework-core>=1.1.0,<2",
"redis>=6.4.0,<7.2.1",
"redisvl>=0.11.0,<0.16",
"numpy>=2.2.6,<3"
+4 -2
View File
@@ -4,7 +4,7 @@ description = "Microsoft Agent Framework for building AI Agents with Python. Thi
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.1"
version = "1.1.0"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core[all]==1.0.1",
"agent-framework-core[all]==1.1.0",
]
[dependency-groups]
@@ -41,6 +41,7 @@ dev = [
"pyright==1.1.408",
"mcp[ws]==1.27.0",
"opentelemetry-sdk==1.40.0",
"azure-monitor-opentelemetry==1.8.7",
#tasks
"poethepoet==0.42.1",
"rich>=13.7.1,<15.0.0",
@@ -79,6 +80,7 @@ agent-framework-declarative = { workspace = true }
agent-framework-devui = { workspace = true }
agent-framework-durabletask = { workspace = true }
agent-framework-foundry = { workspace = true }
agent-framework-foundry-hosting = { workspace = true }
agent-framework-foundry-local = { workspace = true }
agent-framework-gemini = { workspace = true }
agent-framework-github-copilot = { workspace = true }
@@ -347,28 +347,29 @@ setup_observability(
```
**After (Current):**
```python
# For Microsoft Foundry projects
from agent_framework.foundry import FoundryChatClient
from azure.identity import AzureCliCredential
client = FoundryChatClient(
project_endpoint="https://your-project.services.ai.azure.com",
model="gpt-4o",
credential=AzureCliCredential(),
)
await client.configure_azure_monitor(enable_live_metrics=True)
# For non-Azure AI projects
from azure.monitor.opentelemetry import configure_azure_monitor
from agent_framework.observability import create_resource, enable_instrumentation
from azure.identity import AzureCliCredential
from azure.monitor.opentelemetry import configure_azure_monitor
configure_azure_monitor(
connection_string="InstrumentationKey=...",
resource=create_resource(),
enable_live_metrics=True,
)
enable_instrumentation()
async def main():
# For Microsoft Foundry projects
client = FoundryChatClient(
project_endpoint="https://your-project.services.ai.azure.com",
model="gpt-4o",
credential=AzureCliCredential(),
)
await client.configure_azure_monitor(enable_live_metrics=True)
# For non-Azure AI projects
configure_azure_monitor(
connection_string="InstrumentationKey=...",
resource=create_resource(),
enable_live_metrics=True,
)
enable_instrumentation()
```
### Console Output
@@ -29,19 +29,19 @@ approval will pause the workflow until the human responds.
This sample works as follows:
1. A ConcurrentBuilder workflow is created with two agents running in parallel.
2. Both agents have the same tools, including one requiring approval (execute_trade).
2. Both agents have the same tools, including two requiring approval (execute_trade, set_stop_loss).
3. Both agents receive the same task and work concurrently on their respective stocks.
4. When either agent tries to execute a trade, it triggers an approval request.
4. When either agent tries to execute a trade or set a stop-loss, it triggers an approval request.
5. The sample simulates human approval and the workflow completes.
6. Results from both agents are aggregated and output.
Purpose:
Show how tool call approvals work in parallel execution scenarios where multiple
agents may independently trigger approval requests.
agents may independently trigger approval requests for different tools.
Demonstrate:
- Handling multiple approval requests from different agents in concurrent workflows.
- Handling during concurrent agent execution.
- Handling approval requests for different tools during concurrent agent execution.
- Understanding that approval pauses only the agent that triggered it, not all agents.
Prerequisites:
@@ -89,6 +89,15 @@ def execute_trade(
return f"Trade executed: {action.upper()} {quantity} shares of {symbol.upper()}"
@tool(approval_mode="always_require")
def set_stop_loss(
symbol: Annotated[str, "The stock ticker symbol"],
stop_price: Annotated[float, "The stop-loss price"],
) -> str:
"""Set a stop-loss order for a stock. Requires human approval due to financial impact."""
return f"Stop-loss set for {symbol.upper()} at ${stop_price:.2f}"
@tool(approval_mode="never_require")
def get_portfolio_balance() -> str:
"""Get current portfolio balance and available funds."""
@@ -118,14 +127,17 @@ async def process_event_stream(stream: AsyncIterable[WorkflowEvent]) -> dict[str
if event.type == "request_info" and isinstance(event.data, Content):
# We are only expecting tool approval requests in this sample
requests[event.request_id] = event.data
if event.data.type == "function_approval_request" and event.data.function_call is not None:
print(f"\nApproval requested for tool: {event.data.function_call.name}")
print(f"Arguments: {event.data.function_call.arguments}")
elif event.type == "output":
_print_output(event)
responses: dict[str, Content] = {}
if requests:
for request_id, request in requests.items():
if request.type == "function_approval_request":
print(f"\nSimulating human approval for: {request.function_call.name}") # type: ignore
if request.type == "function_approval_request" and request.function_call is not None:
print(f"\nSimulating human approval for: {request.function_call.name}")
# Create approval response
responses[request_id] = request.to_function_approval_response(approved=True)
@@ -145,9 +157,10 @@ async def main() -> None:
name="MicrosoftAgent",
instructions=(
"You are a personal trading assistant focused on Microsoft (MSFT). "
"You manage my portfolio and take actions based on market data."
"You manage my portfolio and take actions based on market data. "
"Use stop-loss orders to manage risk."
),
tools=[get_stock_price, get_market_sentiment, get_portfolio_balance, execute_trade],
tools=[get_stock_price, get_market_sentiment, get_portfolio_balance, execute_trade, set_stop_loss],
)
google_agent = Agent(
@@ -155,9 +168,10 @@ async def main() -> None:
name="GoogleAgent",
instructions=(
"You are a personal trading assistant focused on Google (GOOGL). "
"You manage my trades and portfolio based on market conditions."
"You manage my trades and portfolio based on market conditions. "
"Use stop-loss orders to manage risk."
),
tools=[get_stock_price, get_market_sentiment, get_portfolio_balance, execute_trade],
tools=[get_stock_price, get_market_sentiment, get_portfolio_balance, execute_trade, set_stop_loss],
)
# 4. Build a concurrent workflow with both agents
@@ -172,7 +186,8 @@ async def main() -> None:
# Runs are not isolated; state is preserved across multiple calls to run.
stream = workflow.run(
"Manage my portfolio. Use a max of 5000 dollars to adjust my position using "
"your best judgment based on market sentiment. No need to confirm trades with me.",
"your best judgment based on market sentiment. Set stop-loss orders to manage risk. "
"No need to confirm trades with me.",
stream=True,
)
@@ -191,22 +206,32 @@ async def main() -> None:
Approval requested for tool: execute_trade
Arguments: {"symbol":"MSFT","action":"buy","quantity":13}
Approval requested for tool: set_stop_loss
Arguments: {"symbol":"MSFT","stop_price":340.0}
Approval requested for tool: execute_trade
Arguments: {"symbol":"GOOGL","action":"buy","quantity":35}
Simulating human approval for: execute_trade
Approval requested for tool: set_stop_loss
Arguments: {"symbol":"GOOGL","stop_price":126.0}
Simulating human approval for: execute_trade
Simulating human approval for: set_stop_loss
Simulating human approval for: execute_trade
Simulating human approval for: set_stop_loss
------------------------------------------------------------
Workflow completed. Aggregated results from both agents:
- user: Manage my portfolio. Use a max of 5000 dollars to adjust my position using your best judgment based on
market sentiment. No need to confirm trades with me.
- MicrosoftAgent: I have successfully executed the trade, purchasing 13 shares of Microsoft (MSFT). This action
was based on the positive market sentiment and available funds within the specified limit.
Your portfolio has been adjusted accordingly.
- GoogleAgent: I have successfully executed the trade, purchasing 35 shares of GOOGL. If you need further
assistance or any adjustments, feel free to ask!
market sentiment. Set stop-loss orders to manage risk. No need to confirm trades with me.
- MicrosoftAgent: I have successfully purchased 13 shares of Microsoft (MSFT) and set a stop-loss at $340.00.
This action was based on the positive market sentiment and available funds within the
specified limit. Your portfolio has been adjusted accordingly.
- GoogleAgent: I have successfully purchased 35 shares of GOOGL and set a stop-loss at $126.00. If you need
further assistance or any adjustments, feel free to ask!
"""
@@ -121,11 +121,11 @@ async def process_event_stream(stream: AsyncIterable[WorkflowEvent]) -> dict[str
responses: dict[str, Content] = {}
if requests:
for request_id, request in requests.items():
if request.type == "function_approval_request":
if request.type == "function_approval_request" and request.function_call is not None:
print("\n[APPROVAL REQUIRED]")
print(f" Tool: {request.function_call.name}") # type: ignore
print(f" Arguments: {request.function_call.arguments}") # type: ignore
print(f"Simulating human approval for: {request.function_call.name}") # type: ignore
print(f" Tool: {request.function_call.name}")
print(f" Arguments: {request.function_call.arguments}")
print(f"Simulating human approval for: {request.function_call.name}")
# Create approval response
responses[request_id] = request.to_function_approval_response(approved=True)
@@ -94,11 +94,11 @@ async def process_event_stream(stream: AsyncIterable[WorkflowEvent]) -> dict[str
responses: dict[str, Content] = {}
if requests:
for request_id, request in requests.items():
if request.type == "function_approval_request":
if request.type == "function_approval_request" and request.function_call is not None:
print("\n[APPROVAL REQUIRED]")
print(f" Tool: {request.function_call.name}") # type: ignore
print(f" Arguments: {request.function_call.arguments}") # type: ignore
print(f"Simulating human approval for: {request.function_call.name}") # type: ignore
print(f" Tool: {request.function_call.name}")
print(f" Arguments: {request.function_call.arguments}")
print(f"Simulating human approval for: {request.function_call.name}")
# Create approval response
responses[request_id] = request.to_function_approval_response(approved=True)
@@ -0,0 +1,56 @@
# Foundry Hosted Agents Samples
This directory contains samples that demonstrate how to use the Agent Framework to host agents on Foundry with different capabilities and configurations. Each sample includes a README with instructions on how to set up, run, and interact with the agent.
Read more about Foundry Hosted Agents [here](https://learn.microsoft.com/en-us/azure/foundry/agents/concepts/hosted-agents).
## Environment setup
1. Navigate to the sample directory you want to run. For example:
```bash
python -m venv .venv
# Windows
.venv\Scripts\Activate
# macOS/Linux
source .venv/bin/activate
```
2. Install dependencies:
```bash
pip install -r requirements.txt
```
3. Create a `.env` file with your Foundry configuration following the `env.example` file in the sample.
4. Make sure you are logged in with the Azure CLI:
```bash
az login
```
## Deploying to a Docker container
Navigate to the sample directory and build the Docker image:
```bash
docker build -t hosted-agent-sample .
```
Run the container, passing in the required environment variables:
```bash
docker run -p 8088:8088 \
-e FOUNDRY_PROJECT_ENDPOINT=<your-endpoint> \
-e MODEL_DEPLOYMENT_NAME=<your-model> \
hosted-agent-sample
```
The server will be available at `http://localhost:8088`. You can send requests using the same `curl` command shown above.
## Deploying to Foundry
Follow this [guide](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent?tabs=bash#configure-your-agent) to deploy your agent to Foundry.
@@ -0,0 +1,6 @@
.venv
__pycache__
*.pyc
*.pyo
*.pyd
.Python
@@ -0,0 +1,2 @@
FOUNDRY_PROJECT_ENDPOINT="..."
MODEL_DEPLOYMENT_NAME="..."
@@ -0,0 +1,44 @@
# Basic example of hosting an agent with the `invocations` API
## Running the server locally
### Environment setup
Follow the instructions in the [Environment setup](../../README.md#environment-setup) section of the README in the parent directory to set up your environment and install dependencies.
Run the following command to start the server:
```bash
python main.py
```
### Interacting with the agent
Send a POST request to the server with a JSON body containing a "message" field to interact with the agent. For example:
```bash
curl -X POST http://localhost:8088/invocations -i -H "Content-Type: application/json" -d '{"message": "Hi"}'
```
The server will respond with a JSON object containing the response text. The `-i` flag in the `curl` command includes the HTTP response headers in the output, which includes the session ID that can be used for multi-turn conversations. Here is an example of the response:
```bash
HTTP/1.1 200
content-length: 34
content-type: application/json
x-agent-invocation-id: ec04d020-a0e7-441e-ae83-db75635a9f83
x-agent-session-id: 9370b9d4-cd13-4436-a57f-03b843ac0e17
x-platform-server: azure-ai-agentserver-core/2.0.0a20260410006 (python/3.12)
date: Fri, 17 Apr 2026 23:46:44 GMT
server: hypercorn-h11
{"response":"Hi! How can I help?"}
```
### Multi-turn conversation
To have a multi-turn conversation with the agent, take the session ID from the response headers of the previous request and include it in URL parameters for the next request. For example:
```bash
curl -X POST http://localhost:8088/invocations?agent_session_id=9370b9d4-cd13-4436-a57f-03b843ac0e17 -i -H "Content-Type: application/json" -d '{"message": "How are you?"}'
```
@@ -0,0 +1,23 @@
name: agent-framework-agent-basic-invocations
description: >
A basic Agent Framework agent hosted by Foundry.
metadata:
tags:
- Agent Framework
- AI Agent Hosting
- Azure AI AgentServer
- Invocations Protocol
- Streaming
template:
name: agent-framework-agent-basic-invocations
kind: hosted
protocols:
- protocol: invocations
version: 1.0.0
environment_variables:
- name: MODEL_DEPLOYMENT_NAME
value: "{{MODEL_DEPLOYMENT_NAME}}"
resources:
- kind: model
id: gpt-4.1-mini
name: MODEL_DEPLOYMENT_NAME
@@ -0,0 +1,9 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml
kind: hosted
name: agent-framework-agent-basic-invocations
protocols:
- protocol: invocations
version: 1.0.0
resources:
cpu: '0.25'
memory: '0.5Gi'
@@ -0,0 +1,36 @@
# Copyright (c) Microsoft. All rights reserved.
import os
from agent_framework import Agent
from agent_framework.foundry import FoundryChatClient
from agent_framework_foundry_hosting import InvocationsHostServer
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv()
def main():
client = FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["MODEL_DEPLOYMENT_NAME"],
credential=AzureCliCredential(),
)
agent = Agent(
client=client,
instructions="You are a friendly assistant. Keep your answers brief.",
# History will be managed by the hosting infrastructure, thus there
# is no need to store history by the service. Learn more at:
# https://developers.openai.com/api/reference/resources/responses/methods/create
default_options={"store": False},
)
server = InvocationsHostServer(agent)
server.run()
if __name__ == "__main__":
main()
@@ -0,0 +1,2 @@
agent-framework
agent-framework-foundry-hosting
@@ -0,0 +1,6 @@
.venv
__pycache__
*.pyc
*.pyo
*.pyd
.Python
@@ -0,0 +1,2 @@
FOUNDRY_PROJECT_ENDPOINT="..."
MODEL_DEPLOYMENT_NAME="..."
@@ -0,0 +1,46 @@
# Basic example of hosting an agent with the `invocations` API
This is the same as the [01_basic](../01_basic/README.md) example, but demonstrates the "break glass" scenario where you can create your own `invoke_handler` to handle specific types of invocations. This is useful when you want to override the default behavior for certain requests or add custom processing logic.
## Running the server locally
### Environment setup
Follow the instructions in the [Environment setup](../../README.md#environment-setup) section of the README in the parent directory to set up your environment and install dependencies.
Run the following command to start the server:
```bash
python main.py
```
### Interacting with the agent
Send a POST request to the server with a JSON body containing a "message" field to interact with the agent. For example:
```bash
curl -X POST http://localhost:8088/invocations -i -H "Content-Type: application/json" -d '{"message": "Hi"}'
```
The server will respond with a JSON object containing the response text. The `-i` flag in the `curl` command includes the HTTP response headers in the output, which includes the session ID that can be used for multi-turn conversations. Here is an example of the response:
```bash
HTTP/1.1 200
content-length: 34
content-type: application/json
x-agent-invocation-id: ec04d020-a0e7-441e-ae83-db75635a9f83
x-agent-session-id: 9370b9d4-cd13-4436-a57f-03b843ac0e17
x-platform-server: azure-ai-agentserver-core/2.0.0a20260410006 (python/3.12)
date: Fri, 17 Apr 2026 23:46:44 GMT
server: hypercorn-h11
{"response":"Hi! How can I help?"}
```
### Multi-turn conversation
To have a multi-turn conversation with the agent, take the session ID from the response headers of the previous request and include it in URL parameters for the next request. For example:
```bash
curl -X POST http://localhost:8088/invocations?agent_session_id=9370b9d4-cd13-4436-a57f-03b843ac0e17 -i -H "Content-Type: application/json" -d '{"message": "How are you?"}'
```
@@ -0,0 +1,23 @@
name: agent-framework-agent-basic-invocations
description: >
A basic Agent Framework agent hosted by Foundry.
metadata:
tags:
- Agent Framework
- AI Agent Hosting
- Azure AI AgentServer
- Invocations Protocol
- Streaming
template:
name: agent-framework-agent-basic-invocations
kind: hosted
protocols:
- protocol: invocations
version: 1.0.0
environment_variables:
- name: MODEL_DEPLOYMENT_NAME
value: "{{MODEL_DEPLOYMENT_NAME}}"
resources:
- kind: model
id: gpt-4.1-mini
name: MODEL_DEPLOYMENT_NAME
@@ -0,0 +1,9 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml
kind: hosted
name: agent-framework-agent-basic-invocations
protocols:
- protocol: invocations
version: 1.0.0
resources:
cpu: '0.25'
memory: '0.5Gi'
@@ -0,0 +1,74 @@
# Copyright (c) Microsoft. All rights reserved.
import os
from collections.abc import AsyncGenerator
from agent_framework import Agent, AgentSession
from agent_framework.foundry import FoundryChatClient
from azure.ai.agentserver.invocations import InvocationAgentServerHost
from azure.identity import DefaultAzureCredential
from dotenv import load_dotenv
from starlette.requests import Request
from starlette.responses import JSONResponse, Response, StreamingResponse
# Load environment variables from .env file
load_dotenv()
# In-memory session store — keyed by session ID.
# WARNING: This is lost on restart. Use durable storage in production.
_sessions: dict[str, AgentSession] = {}
# Create the agent
client = FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["MODEL_DEPLOYMENT_NAME"],
credential=DefaultAzureCredential(),
)
agent = Agent(
client=client,
instructions="You are a friendly assistant. Keep your answers brief.",
# History will be managed by the hosting infrastructure, thus there
# is no need to store history by the service. Learn more at:
# https://developers.openai.com/api/reference/resources/responses/methods/create
default_options={"store": False},
)
app = InvocationAgentServerHost()
@app.invoke_handler
async def handle_invoke(request: Request):
"""Handle streaming multi-turn chat with Azure OpenAI via SSE."""
data = await request.json()
session_id = request.state.session_id
stream = data.get("stream", False)
user_message = data.get("message", None)
if user_message is None:
error = "Missing 'message' in request"
if stream:
return StreamingResponse(content=error, status_code=400)
return Response(content=error, status_code=400)
session = _sessions.setdefault(session_id, AgentSession(session_id=session_id))
if stream:
async def stream_response() -> AsyncGenerator[str]:
async for update in agent.run(user_message, session=session, stream=True):
yield update.text
return StreamingResponse(
stream_response(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "Connection": "keep-alive"},
)
response = await agent.run([user_message], session=session, stream=stream)
return JSONResponse({"response": response.text})
if __name__ == "__main__":
app.run()
@@ -0,0 +1,2 @@
agent-framework
azure-ai-agentserver-invocations
@@ -0,0 +1,8 @@
# Hosting agents with Foundry Hosting and the `invocations` API
This folder contains a list of samples that show how to host agents using the `invocations` API and deploy them to Foundry Hosting.
| Sample | Description |
| --- | --- |
| [01_basic](./01_basic) | A basic example of hosting an agent with the `invocations` API and carrying on a multi-turn conversation. |
| [02_break_glass](./02_break_glass) | An example of hosting an agent with the `invocations` API and a "break glass" scenario where you can create your own `invoke_handler` to handle specific types of invocations. |
@@ -0,0 +1,6 @@
.venv
__pycache__
*.pyc
*.pyo
*.pyd
.Python
@@ -0,0 +1,2 @@
FOUNDRY_PROJECT_ENDPOINT="..."
MODEL_DEPLOYMENT_NAME="..."
@@ -0,0 +1,31 @@
# Basic example of hosting an agent with the `responses` API
This agent only contains an instruction (personal). It's the most basic agent with an LLM and no tools.
## Running the server locally
### Environment setup
Follow the instructions in the [Environment setup](../../README.md#environment-setup) section of the README in the parent directory to set up your environment and install dependencies.
Run the following command to start the server:
```bash
python main.py
```
## Interacting with the agent
Send a POST request to the server with a JSON body containing a "input" field to interact with the agent. For example:
```bash
curl -X POST http://localhost:8088/responses -H "Content-Type: application/json" -d '{"input": "Hi"}'
```
## Multi-turn conversation
To have a multi-turn conversation with the agent, include the previous response id in the request body. For example:
```bash
curl -X POST http://localhost:8088/responses -H "Content-Type: application/json" -d '{"input": "How are you?", "previous_response_id": "REPLACE_WITH_PREVIOUS_RESPONSE_ID"}'
```
@@ -0,0 +1,23 @@
name: agent-framework-agent-basic
description: >
A basic Agent Framework agent hosted by Foundry.
metadata:
tags:
- Agent Framework
- AI Agent Hosting
- Azure AI AgentServer
- Responses Protocol
- Streaming
template:
name: agent-framework-agent-basic
kind: hosted
protocols:
- protocol: responses
version: 1.0.0
environment_variables:
- name: MODEL_DEPLOYMENT_NAME
value: "{{MODEL_DEPLOYMENT_NAME}}"
resources:
- kind: model
id: gpt-4.1-mini
name: MODEL_DEPLOYMENT_NAME
@@ -0,0 +1,8 @@
kind: hosted
name: agent-framework-agent-basic
protocols:
- protocol: responses
version: 1.0.0
resources:
cpu: "0.25"
memory: 0.5Gi
@@ -0,0 +1,36 @@
# Copyright (c) Microsoft. All rights reserved.
import os
from agent_framework import Agent
from agent_framework.foundry import FoundryChatClient
from agent_framework_foundry_hosting import ResponsesHostServer
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv()
def main():
client = FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["MODEL_DEPLOYMENT_NAME"],
credential=AzureCliCredential(),
)
agent = Agent(
client=client,
instructions="You are a friendly assistant. Keep your answers brief.",
# History will be managed by the hosting infrastructure, thus there
# is no need to store history by the service. Learn more at:
# https://developers.openai.com/api/reference/resources/responses/methods/create
default_options={"store": False},
)
server = ResponsesHostServer(agent)
server.run()
if __name__ == "__main__":
main()
@@ -0,0 +1,2 @@
agent-framework
agent-framework-foundry-hosting
@@ -0,0 +1,6 @@
.venv
__pycache__
*.pyc
*.pyo
*.pyd
.Python
@@ -0,0 +1,2 @@
FOUNDRY_PROJECT_ENDPOINT="..."
MODEL_DEPLOYMENT_NAME="..."
@@ -1,11 +1,11 @@
FROM python:3.14-slim
FROM python:3.12-slim
WORKDIR /app
COPY ./ .
COPY . user_agent/
WORKDIR /app/user_agent
RUN pip install --upgrade pip && \
if [ -f requirements.txt ]; then \
RUN if [ -f requirements.txt ]; then \
pip install -r requirements.txt; \
else \
echo "No requirements.txt found"; \
@@ -13,4 +13,4 @@ RUN pip install --upgrade pip && \
EXPOSE 8088
CMD ["python", "main.py"]
CMD ["python", "main.py"]
@@ -0,0 +1,27 @@
# Basic example of hosting an agent with the `responses` API and local tools
This agent is equipped with a function tool and a local shell tool.
> We recommend deploying this sample on a local container or to Foundry Hosting because the agent has access to a local shell tool, which can run arbitrary commands on the machine.
## Running the server locally
### Environment setup
Follow the instructions in the [Environment setup](../../README.md#environment-setup) section of the README in the parent directory to set up your environment and install dependencies.
Run the following command to start the server:
```bash
python main.py
```
## Interacting with the agent
Send a POST request to the server with a JSON body containing a "input" field to interact with the agent. For example:
```bash
curl -X POST http://localhost:8088/responses -H "Content-Type: application/json" -d '{"input": "What is the weather in Seattle?"}'
curl -X POST http://localhost:8088/responses -H "Content-Type: application/json" -d '{"input": "List the files in the current directory."}'
```
@@ -0,0 +1,23 @@
name: agent-framework-agent-with-local-tools
description: >
An Agent Framework agent with local tools hosted by Foundry.
metadata:
tags:
- Agent Framework
- AI Agent Hosting
- Azure AI AgentServer
- Responses Protocol
- Streaming
template:
name: agent-framework-agent-with-local-tools
kind: hosted
protocols:
- protocol: responses
version: 1.0.0
environment_variables:
- name: MODEL_DEPLOYMENT_NAME
value: "{{MODEL_DEPLOYMENT_NAME}}"
resources:
- kind: model
id: gpt-4.1-mini
name: MODEL_DEPLOYMENT_NAME
@@ -0,0 +1,8 @@
kind: hosted
name: agent-framework-agent-with-local-tools
protocols:
- protocol: responses
version: 1.0.0
resources:
cpu: "0.25"
memory: 0.5Gi
@@ -0,0 +1,74 @@
# Copyright (c) Microsoft. All rights reserved.
import os
import subprocess
from random import randint
from agent_framework import Agent, tool
from agent_framework.foundry import FoundryChatClient
from agent_framework_foundry_hosting import ResponsesHostServer
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
from pydantic import Field
from typing import Annotated
# Load environment variables from .env file
load_dotenv()
@tool(approval_mode="never_require")
def get_weather(
location: Annotated[str, Field(description="The location to get the weather for.")],
) -> str:
"""Get the weather for a given location."""
conditions = ["sunny", "cloudy", "rainy", "stormy"]
return f"The weather in {location} is {conditions[randint(0, 3)]} with a high of {randint(10, 30)}°C."
@tool(approval_mode="always_require")
def run_bash(command: str) -> str:
"""Execute a shell command locally and return stdout, stderr, and exit code."""
try:
result = subprocess.run(
command,
shell=True,
capture_output=True,
text=True,
timeout=30,
)
parts: list[str] = []
if result.stdout:
parts.append(result.stdout)
if result.stderr:
parts.append(f"stderr: {result.stderr}")
parts.append(f"exit_code: {result.returncode}")
return "\n".join(parts)
except subprocess.TimeoutExpired:
return "Command timed out after 30 seconds"
except Exception as e:
return f"Error executing command: {e}"
def main():
client = FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["MODEL_DEPLOYMENT_NAME"],
credential=AzureCliCredential(),
)
agent = Agent(
client=client,
instructions="You are a friendly assistant. Keep your answers brief.",
tools=[get_weather, run_bash],
# History will be managed by the hosting infrastructure, thus there
# is no need to store history by the service. Learn more at:
# https://developers.openai.com/api/reference/resources/responses/methods/create
default_options={"store": False},
)
server = ResponsesHostServer(agent)
server.run()
if __name__ == "__main__":
main()
@@ -0,0 +1,2 @@
agent-framework
agent-framework-foundry-hosting
@@ -0,0 +1,6 @@
.venv
__pycache__
*.pyc
*.pyo
*.pyd
.Python
@@ -0,0 +1,4 @@
FOUNDRY_PROJECT_ENDPOINT="..."
MODEL_DEPLOYMENT_NAME="..."
TOOLBOX_NAME="..."
GITHUB_PAT="..."
@@ -1,11 +1,11 @@
FROM python:3.14-slim
FROM python:3.12-slim
WORKDIR /app
COPY ./ .
COPY . user_agent/
WORKDIR /app/user_agent
RUN pip install --upgrade pip && \
if [ -f requirements.txt ]; then \
RUN if [ -f requirements.txt ]; then \
pip install -r requirements.txt; \
else \
echo "No requirements.txt found"; \
@@ -13,4 +13,4 @@ RUN pip install --upgrade pip && \
EXPOSE 8088
CMD ["python", "main.py"]
CMD ["python", "main.py"]
@@ -0,0 +1,25 @@
# Basic example of hosting an agent with the `responses` API and a remote MCP
This agent is equipped with a GitHub MCP server and a Foundry Toolbox, which are both remote MCPs.
> Note that there are other ways to interact with Foundry toolboxes. Using it as a MCP is just one of the options.
## Running the server locally
### Environment setup
Follow the instructions in the [Environment setup](../../README.md#environment-setup) section of the README in the parent directory to set up your environment and install dependencies.
Run the following command to start the server:
```bash
python main.py
```
## Interacting with the agent
Send a POST request to the server with a JSON body containing a "input" field to interact with the agent. For example:
```bash
curl -X POST http://localhost:8088/responses -H "Content-Type: application/json" -d '{"input": "List all the repositories I own on GitHub."}'
```
@@ -0,0 +1,27 @@
name: agent-framework-agent-with-remote-mcp-tools
description: >
An Agent Framework agent with remote MCP tools hosted by Foundry.
metadata:
tags:
- Agent Framework
- AI Agent Hosting
- Azure AI AgentServer
- Responses Protocol
- Streaming
template:
name: agent-framework-agent-with-remote-mcp-tools
kind: hosted
protocols:
- protocol: responses
version: 1.0.0
environment_variables:
- name: MODEL_DEPLOYMENT_NAME
value: "{{MODEL_DEPLOYMENT_NAME}}"
- name: GITHUB_PAT
value: ${GITHUB_PAT}
- name: TOOLBOX_NAME
value: ${TOOLBOX_NAME}
resources:
- kind: model
id: gpt-4.1-mini
name: MODEL_DEPLOYMENT_NAME
@@ -0,0 +1,8 @@
kind: hosted
name: agent-framework-agent-with-remote-mcp-tools
protocols:
- protocol: responses
version: 1.0.0
resources:
cpu: "0.25"
memory: 0.5Gi
@@ -0,0 +1,76 @@
# Copyright (c) Microsoft. All rights reserved.
import os
import httpx
from agent_framework import Agent, MCPStreamableHTTPTool
from agent_framework.foundry import FoundryChatClient
from agent_framework_foundry_hosting import ResponsesHostServer
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv()
class ToolboxAuth(httpx.Auth):
"""httpx Auth that injects a fresh bearer token on every request."""
def auth_flow(self, request: httpx.Request):
credential = AzureCliCredential()
token = credential.get_token("https://ai.azure.com/.default").token
request.headers["Authorization"] = f"Bearer {token}"
yield request
def main():
client = FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["MODEL_DEPLOYMENT_NAME"],
credential=AzureCliCredential(),
)
# Foundry Toolbox as a MCP tool
project_endpoint = os.environ["FOUNDRY_PROJECT_ENDPOINT"]
toolbox_name = os.environ["TOOLBOX_NAME"]
toolbox_endpoint = f"{project_endpoint.rstrip('/')}/toolboxes/{toolbox_name}/mcp?api-version=v1"
http_client = httpx.AsyncClient(auth=ToolboxAuth(), headers={"Foundry-Features": "Toolboxes=V1Preview"})
foundry_mcp_tool = MCPStreamableHTTPTool(
name="toolbox",
url=toolbox_endpoint,
http_client=http_client,
load_prompts=False,
)
# GitHub MCP server
github_pat = os.environ["GITHUB_PAT"]
if not github_pat:
raise ValueError(
"GITHUB_PAT environment variable must be set. Create a token at https://github.com/settings/tokens"
)
github_mcp_tool = client.get_mcp_tool(
name="GitHub",
url="https://api.githubcopilot.com/mcp/",
headers={
"Authorization": f"Bearer {github_pat}",
},
approval_mode="never_require",
)
agent = Agent(
client=client,
instructions="You are a friendly assistant. Keep your answers brief.",
tools=[foundry_mcp_tool, github_mcp_tool],
# History will be managed by the hosting infrastructure, thus there
# is no need to store history by the service. Learn more at:
# https://developers.openai.com/api/reference/resources/responses/methods/create
default_options={"store": False},
)
server = ResponsesHostServer(agent)
server.run()
if __name__ == "__main__":
main()
@@ -0,0 +1,2 @@
agent-framework
agent-framework-foundry-hosting
@@ -0,0 +1,6 @@
.venv
__pycache__
*.pyc
*.pyo
*.pyd
.Python
@@ -0,0 +1,2 @@
FOUNDRY_PROJECT_ENDPOINT="..."
MODEL_DEPLOYMENT_NAME="..."
@@ -0,0 +1,16 @@
FROM python:3.12-slim
WORKDIR /app
COPY . user_agent/
WORKDIR /app/user_agent
RUN if [ -f requirements.txt ]; then \
pip install -r requirements.txt; \
else \
echo "No requirements.txt found"; \
fi
EXPOSE 8088
CMD ["python", "main.py"]
@@ -0,0 +1,23 @@
# Basic example of hosting an agent with the `responses` API and a workflow
This sample demonstrates how to host a workflow using the `responses` API.
## Running the server locally
### Environment setup
Follow the instructions in the [Environment setup](../../README.md#environment-setup) section of the README in the parent directory to set up your environment and install dependencies.
Run the following command to start the server:
```bash
python main.py
```
## Interacting with the agent
Send a POST request to the server with a JSON body containing a "input" field to interact with the agent. For example:
```bash
curl -X POST http://localhost:8088/responses -H "Content-Type: application/json" -d '{"input": "Create a slogan for a new electric SUV that is affordable and fun to drive."}'
```
@@ -0,0 +1,23 @@
name: agent-framework-workflows
description: >
An Agent Framework workflow hosted by Foundry.
metadata:
tags:
- Agent Framework
- AI Agent Hosting
- Azure AI AgentServer
- Responses Protocol
- Streaming
template:
name: agent-framework-workflows
kind: hosted
protocols:
- protocol: responses
version: 1.0.0
environment_variables:
- name: MODEL_DEPLOYMENT_NAME
value: "{{MODEL_DEPLOYMENT_NAME}}"
resources:
- kind: model
id: gpt-4.1-mini
name: MODEL_DEPLOYMENT_NAME
@@ -0,0 +1,8 @@
kind: hosted
name: agent-framework-workflows
protocols:
- protocol: responses
version: 1.0.0
resources:
cpu: "0.25"
memory: 0.5Gi
@@ -0,0 +1,70 @@
# Copyright (c) Microsoft. All rights reserved.
import os
from agent_framework import Agent, AgentExecutor, WorkflowBuilder
from agent_framework.foundry import FoundryChatClient
from agent_framework_foundry_hosting import ResponsesHostServer
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv()
def main():
client = FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["MODEL_DEPLOYMENT_NAME"],
credential=AzureCliCredential(),
)
writer_agent = Agent(
client=client,
instructions=("You are an excellent slogan writer. You create new slogans based on the given topic."),
name="writer",
)
legal_agent = Agent(
client=client,
instructions=(
"You are an excellent legal reviewer. "
"Make necessary corrections to the slogan so that it is legally compliant."
),
name="legal_reviewer",
)
format_agent = Agent(
client=client,
instructions=(
"You are an excellent content formatter. "
"You take the slogan and format it in a cool retro style when printing to a terminal."
),
name="formatter",
)
# Set the context mode to `last_agent` so that each agent only sees the output of the
# previous agent instead of the full conversation history
writer_executor = AgentExecutor(writer_agent, context_mode="last_agent")
legal_executor = AgentExecutor(legal_agent, context_mode="last_agent")
format_executor = AgentExecutor(format_agent, context_mode="last_agent")
workflow_agent = (
WorkflowBuilder(
start_executor=writer_executor,
# Limiting the output to only the final formatted result.
# If this is not set, all intermediate results will be included in the output.
output_executors=[format_executor],
)
.add_edge(writer_executor, legal_executor)
.add_edge(legal_executor, format_executor)
.build()
.as_agent()
)
server = ResponsesHostServer(workflow_agent)
server.run()
if __name__ == "__main__":
main()

Some files were not shown because too many files have changed in this diff Show More