Compare commits

..
64 changed files with 196 additions and 2443 deletions
-159
View File
@@ -1,159 +0,0 @@
name: Sync Project Status to Labels
on:
projects_v2_item:
types: [edited]
# Prevent race conditions when status changes rapidly.
# Key by project item (node_id) so updates for the same card serialize.
concurrency:
group: status-sync-${{ github.event.projects_v2_item.node_id }}
cancel-in-progress: true
jobs:
sync_status:
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- uses: actions/github-script@v8
with:
# Use PAT/App token because Projects GraphQL often requires project scope.
# GITHUB_TOKEN is repo-scoped and may not access org Projects. 【4-75ee64】【5-e66679】
github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
script: |
const item = context.payload.projects_v2_item;
const changes = context.payload.changes || {};
// 1) Logging project id so that we can filter out by project in next revision.
console.log(`Processing issue from project: ${item.project_node_id}`);
// 2) Only act on Issues
if (item.content_type !== "Issue") return;
// 3) Map project Status values to labels
const labelMap = {
"Planned": "status:planned",
"In Progress": "status:in-progress",
"In Review": "status:in-review",
"Done": "status:done"
};
const allStatusLabels = Object.values(labelMap);
// 4) Fast path: If this edit is a Status change and the payload includes "to.name", use it.
// Some payloads include field_value.to with { name, ... } for single-select fields. 【6-4092ed】【3-e3ddba】
let statusValue = null;
const fv = changes.field_value;
if (fv && fv.field_name === "Status" && fv.to && fv.to.name) {
statusValue = fv.to.name;
console.log(`Fast path: Status changed to "${statusValue}"`);
}
// 5) Otherwise, query GraphQL once to get both Issue number and Status field value.
if (!statusValue) {
try {
const result = await github.graphql(
`query($id: ID!) {
node(id: $id) {
... on ProjectV2Item {
content { ... on Issue { number } }
fieldValues(first: 50) {
nodes {
... on ProjectV2ItemFieldSingleSelectValue {
name
field { ... on ProjectV2SingleSelectField { name } }
}
}
}
}
}
}`,
{ id: item.node_id }
);
const node = result?.node;
const values = node?.fieldValues?.nodes ?? [];
statusValue = values.find(v => v.field?.name === "Status")?.name;
// If no status found, nothing to do.
if (!statusValue) {
console.log("No Status field value found in project item");
return;
}
// Fetch issue number from GraphQL content if present
var issue_number = node?.content?.number;
if (!issue_number) {
console.error("Could not extract issue number from GraphQL response");
return;
}
} catch (graphqlError) {
console.error(`GraphQL query failed: ${graphqlError.message}`);
throw graphqlError;
}
} else {
// If we used fast-path for status, we still need issue_number:
try {
const result = await github.graphql(
`query($id: ID!) { node(id: $id) { ... on Issue { number } } }`,
{ id: item.content_node_id }
);
var issue_number = result?.node?.number;
} catch (graphqlError) {
console.error(`Failed to fetch issue number: ${graphqlError.message}`);
throw graphqlError;
}
if (!issue_number) return;
}
const targetLabel = labelMap[statusValue];
if (!targetLabel) {
console.warn(`Status "${statusValue}" has no mapped label. Skipping.`);
return;
}
console.log(`Mapped status "${statusValue}" to label "${targetLabel}"`);
// 6) Get existing labels
let issue;
try {
const response = await github.rest.issues.get({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number
});
issue = response.data;
} catch (restError) {
console.error(`Failed to fetch issue #${issue_number}: ${restError.message}`);
throw restError;
}
const existingLabels = issue.labels.map(l => l.name);
// If already correct, exit (reduces churn)
if (existingLabels.includes(targetLabel) &&
existingLabels.filter(l => allStatusLabels.includes(l)).length === 1) {
console.log(`Issue #${issue_number} already has correct label "${targetLabel}". No changes needed.`);
return;
}
// 7) Avoid "remove then add" partial failure by using setLabels once.
// This preserves all non-status labels and ensures exactly one status label.
const nextLabels = existingLabels
.filter(l => !allStatusLabels.includes(l))
.concat([targetLabel]);
const removedLabels = existingLabels.filter(l => allStatusLabels.includes(l) && l !== targetLabel);
try {
await github.rest.issues.setLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number,
labels: nextLabels
});
console.log(`Updated issue #${issue_number}: removed [${removedLabels.join(", ")}], added "${targetLabel}"`);
} catch (updateError) {
console.error(`Failed to update labels for issue #${issue_number}: ${updateError.message}`);
throw updateError;
}
+1 -1
View File
@@ -25,7 +25,7 @@
<PackageVersion Include="Azure.AI.AgentServer.Core" Version="1.0.0-beta.23" />
<PackageVersion Include="Azure.AI.AgentServer.Invocations" Version="1.0.0-beta.3" />
<PackageVersion Include="Azure.AI.AgentServer.Responses" Version="1.0.0-beta.4" />
<PackageVersion Include="Azure.AI.Projects" Version="2.1.0-beta.1" />
<PackageVersion Include="Azure.AI.Projects" Version="2.0.0" />
<PackageVersion Include="Azure.AI.Agents.Persistent" Version="1.2.0-beta.10" />
<PackageVersion Include="Azure.AI.OpenAI" Version="2.9.0-beta.1" />
<PackageVersion Include="Azure.Core" Version="1.53.0" />
+1 -2
View File
@@ -30,8 +30,7 @@
"src\\Microsoft.Agents.AI.Workflows.Generators\\Microsoft.Agents.AI.Workflows.Generators.csproj",
"src\\Microsoft.Agents.AI.Workflows\\Microsoft.Agents.AI.Workflows.csproj",
"src\\Microsoft.Agents.AI\\Microsoft.Agents.AI.csproj",
"src\\Aspire.Hosting.AgentFramework.DevUI\\Aspire.Hosting.AgentFramework.DevUI.csproj",
"src\\Microsoft.Agents.AI.Hyperlight\\Microsoft.Agents.AI.Hyperlight.csproj"
"src\\Aspire.Hosting.AgentFramework.DevUI\\Aspire.Hosting.AgentFramework.DevUI.csproj"
]
}
}
+3 -3
View File
@@ -1,14 +1,14 @@
<Project>
<PropertyGroup>
<!-- Central version prefix - applies to all nuget packages. -->
<VersionPrefix>1.5.0</VersionPrefix>
<VersionPrefix>1.4.0</VersionPrefix>
<RCNumber>1</RCNumber>
<DateSuffix>260507</DateSuffix>
<DateSuffix>260505</DateSuffix>
<PackageVersion Condition="'$(IsReleaseCandidate)' == 'true'">$(VersionPrefix)-rc$(RCNumber)</PackageVersion>
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).$(DateSuffix).1</PackageVersion>
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' == ''">$(VersionPrefix)-preview.$(DateSuffix).1</PackageVersion>
<PackageVersion Condition="'$(IsReleased)' == 'true'">$(VersionPrefix)</PackageVersion>
<GitTag>1.5.0</GitTag>
<GitTag>1.4.0</GitTag>
<Configurations>Debug;Release;Publish</Configurations>
<IsPackable>true</IsPackable>
@@ -50,12 +50,12 @@ Console.WriteLine(await agent.RunAsync("My name is Ruaidhrí", session));
Console.WriteLine(await agent.RunAsync("I am 20 years old", session));
// We can serialize the session. The serialized state will include the state of the memory component.
JsonElement sessionElement = await agent.SerializeSessionAsync(session);
JsonElement sesionElement = await agent.SerializeSessionAsync(session);
Console.WriteLine("\n>> Use deserialized session with previously created memories\n");
// Later we can deserialize the session and continue the conversation with the previous memory component state.
var deserializedSession = await agent.DeserializeSessionAsync(sessionElement);
var deserializedSession = await agent.DeserializeSessionAsync(sesionElement);
Console.WriteLine(await agent.RunAsync("What is my name and age?", deserializedSession));
Console.WriteLine("\n>> Read memories using memory component\n");
@@ -8,11 +8,6 @@
// even if the process is interrupted mid-loop, but may also result in chat history that is not
// yet finalized (e.g., tool calls without results) being persisted, which may be undesirable in some cases.
//
// Additionally, this sample demonstrates the MessageInjectingChatClient feature, which allows tool
// code to inject new user messages during the function execution loop. When a tool or anything else enqueues
// a message via MessageInjectingChatClient.EnqueueMessages during the tool execution loop, the PerServiceCallChatHistoryPersistingChatClient
// detects the pending message before the next service call and includes the injected message in the request.
//
// To use end-of-run persistence instead (atomic run semantics), remove the
// RequirePerServiceCallChatHistoryPersistence = true setting (or set it to false). End-of-run
// persistence is the default behavior.
@@ -59,37 +54,6 @@ static string GetTime([Description("The city name.")] string city) =>
_ => $"{city}: time data not available."
};
// This tool demonstrates message injection during the function execution loop.
// When called, it checks travel advisories for a city. If an advisory is active, it uses
// the ambient run context to resolve MessageInjectingChatClient and injects a follow-up user message
// asking for alternative destinations. The model will process this injected message on the next
// service call — even though the parent FunctionInvokingChatClient loop would otherwise stop.
[Description("Check current travel advisories for a city.")]
static string CheckTravelAdvisory([Description("The city name.")] string city)
{
// Simulated travel advisory data.
var advisory = city.ToUpperInvariant() switch
{
"LONDON" => "Travel advisory: Severe fog warnings in London. Flights may be delayed or cancelled.",
"SEATTLE" => "Travel advisory: Heavy rainfall expected. Flooding possible in low-lying areas.",
_ => null
};
if (advisory is null)
{
return $"{city}: No active travel advisories.";
}
// When an advisory is found, inject a follow-up question so the model automatically
// suggests alternatives without the user needing to ask.
var runContext = AIAgent.CurrentRunContext!;
runContext.Agent.GetService<MessageInjectingChatClient>()?.EnqueueMessages(
runContext.Session!,
[new ChatMessage(ChatRole.User, $"Given the travel advisory for {city}, what alternative cities would you recommend instead?")]);
return advisory;
}
// Create the agent — per-service-call persistence is enabled via RequirePerServiceCallChatHistoryPersistence.
// The in-memory ChatHistoryProvider is used by default when the service does not require service stored chat
// history, so for those cases, we can inspect the chat history via session.TryGetInMemoryChatHistory().
@@ -101,11 +65,10 @@ AIAgent agent = chatClient.AsAIAgent(
{
Name = "WeatherAssistant",
RequirePerServiceCallChatHistoryPersistence = true,
EnableMessageInjection = true,
ChatOptions = new()
{
Instructions = "You are a helpful travel assistant. When asked about cities, call the appropriate tools for each city.",
Tools = [AIFunctionFactory.Create(GetWeather), AIFunctionFactory.Create(GetTime), AIFunctionFactory.Create(CheckTravelAdvisory)]
Instructions = "You are a helpful assistant. When asked about multiple cities, call the appropriate tool for each city.",
Tools = [AIFunctionFactory.Create(GetWeather), AIFunctionFactory.Create(GetTime)]
},
});
@@ -146,18 +109,6 @@ async Task RunNonStreamingAsync()
response = await agent.RunAsync(FollowUp2, session);
PrintAgentResponse(response.Text);
PrintChatHistory(session, "After third run", ref lastChatHistorySize, ref lastConversationId);
// Fourth turn — demonstrates message injection during the function loop.
// The CheckTravelAdvisory tool detects an advisory for London and injects a follow-up
// user message asking for alternative cities. After the tool completes, the internal loop
// in PerServiceCallChatHistoryPersistingChatClient detects the pending injected message
// and calls the service again, so the model answers the follow-up automatically.
const string TravelPrompt = "I'm planning to travel to London next week. Check if there are any travel advisories.";
PrintUserMessage(TravelPrompt);
response = await agent.RunAsync(TravelPrompt, session);
PrintAgentResponse(response.Text);
PrintChatHistory(session, "After travel advisory run", ref lastChatHistorySize, ref lastConversationId);
}
async Task RunStreamingAsync()
@@ -230,30 +181,6 @@ async Task RunStreamingAsync()
Console.WriteLine();
PrintChatHistory(session, "After third run", ref lastChatHistorySize, ref lastConversationId);
// Fourth turn — demonstrates message injection during the function loop (streaming).
// The CheckTravelAdvisory tool detects an advisory for London and injects a follow-up
// user message asking for alternative cities. After the tool completes, the internal loop
// in PerServiceCallChatHistoryPersistingChatClient detects the pending injected message
// and calls the service again, so the model answers the follow-up automatically.
const string TravelPrompt = "I'm planning to travel to London next week. Check if there are any travel advisories.";
PrintUserMessage(TravelPrompt);
Console.ForegroundColor = ConsoleColor.Cyan;
Console.Write("\n[Agent] ");
Console.ResetColor();
await foreach (var update in agent.RunStreamingAsync(TravelPrompt, session))
{
Console.Write(update);
// During streaming we should be able to see updates to the chat history
// before the full run completes, as each service call is made and persisted.
PrintChatHistory(session, "During travel advisory run", ref lastChatHistorySize, ref lastConversationId);
}
Console.WriteLine();
PrintChatHistory(session, "After travel advisory run", ref lastChatHistorySize, ref lastConversationId);
}
void PrintUserMessage(string message)
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
@@ -11,7 +11,7 @@
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
<PackageReference Include="Azure.AI.Projects" />
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
</ItemGroup>
</Project>
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks>
@@ -11,7 +11,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.Projects" />
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="DotNetEnv" />
</ItemGroup>
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks>
@@ -11,7 +11,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.Projects" />
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="ModelContextProtocol" VersionOverride="1.2.0" />
<PackageReference Include="DotNetEnv" />
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks>
@@ -11,7 +11,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.Projects" />
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="DotNetEnv" />
</ItemGroup>
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks>
@@ -11,7 +11,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.Projects" />
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="DotNetEnv" />
</ItemGroup>
@@ -11,7 +11,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.Projects" />
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="DotNetEnv" />
</ItemGroup>
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFrameworks>net10.0</TargetFrameworks>
@@ -11,7 +11,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.Projects" />
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="DotNetEnv" />
</ItemGroup>
@@ -190,7 +190,7 @@ internal static class InputConverter
private static ChatMessage ConvertFunctionCallOutput(FunctionCallOutputItemParam funcOutput)
{
var output = DecodeFunctionResultPayload(funcOutput.Output);
var output = funcOutput.Output?.ToString() ?? string.Empty;
return new ChatMessage(
ChatRole.Tool,
[new FunctionResultContent(funcOutput.CallId, output)]);
@@ -482,54 +482,9 @@ internal static class InputConverter
private static ChatMessage ConvertFunctionToolCallOutput(OutputItemFunctionToolCallOutput funcOutput)
{
var output = DecodeFunctionResultPayload(funcOutput.Output);
return new ChatMessage(
ChatRole.Tool,
[new FunctionResultContent(funcOutput.CallId, output)]);
}
/// <summary>
/// Decodes the wire payload of a <c>function_call_output.output</c> field back into the
/// underlying tool-result text suitable for replay as <see cref="FunctionResultContent.Result"/>.
/// </summary>
/// <remarks>
/// Mirrors <c>OutputConverter.EncodeFunctionResultAsJsonStringPayload</c>. Per the OpenAI
/// Responses spec, <c>output</c> is a JSON string; we extract its underlying value. Legacy
/// producers that emitted raw JSON values (arrays/objects) are tolerated by passing the raw
/// bytes through unchanged.
/// </remarks>
private static string DecodeFunctionResultPayload(BinaryData? rawOutput)
{
if (rawOutput is null)
{
return string.Empty;
}
var raw = rawOutput.ToString();
if (string.IsNullOrEmpty(raw))
{
return string.Empty;
}
try
{
using var doc = JsonDocument.Parse(raw);
if (doc.RootElement.ValueKind == JsonValueKind.String)
{
return doc.RootElement.GetString() ?? string.Empty;
}
// Legacy/non-conforming producers may have emitted a raw JSON value
// (array/object/number/bool/null). Pass the raw text through as the
// payload so the replayed FunctionResultContent.Result preserves the
// original tool output shape.
return raw;
}
catch (JsonException)
{
// Not valid JSON — treat the bytes as a literal string payload.
return raw;
}
[new FunctionResultContent(funcOutput.CallId, funcOutput.Output)]);
}
private static ChatRole ConvertMessageRole(MessageRole role)
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
@@ -31,7 +31,7 @@
<ItemGroup>
<PackageReference Include="Azure.AI.AgentServer.Responses" />
<PackageReference Include="Azure.AI.Projects" />
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="ModelContextProtocol" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" />
@@ -279,7 +279,12 @@ internal static class OutputConverter
accumulatedText = null;
previousMessageId = null;
var outputText = EncodeFunctionResultAsJsonStringPayload(functionResult.Result);
var outputText = functionResult.Result switch
{
null => string.Empty,
string s => s,
_ => JsonSerializer.Serialize(functionResult.Result),
};
var itemId = GenerateItemId("fc");
var outputItem = new OutputItemFunctionToolCallOutput(
@@ -443,44 +448,4 @@ internal static class OutputConverter
var body = Convert.ToHexString(bytes); // 50 hex chars, uppercase
return $"{prefix}_{body}";
}
/// <summary>
/// Encodes a <see cref="FunctionResultContent.Result"/> value into the wire payload for
/// the OpenAI Responses <c>function_call_output.output</c> field.
/// </summary>
/// <remarks>
/// The OpenAI Responses spec requires <c>output</c> to be a JSON string. The Responses
/// SDK's <see cref="OutputItemFunctionToolCallOutput"/> accepts a <see cref="BinaryData"/>
/// containing the *raw JSON value* for the field, so the returned text is always a JSON
/// string literal (quoted, with escapes). This avoids two bugs:
/// <list type="bullet">
/// <item>Complex results (e.g. <c>List&lt;TodoItem&gt;</c>) landing on the wire as an
/// unquoted JSON array, which the strict-parsing OpenAI .NET client
/// (<c>FunctionCallOutputResponseItem</c>) rejects with
/// "requires an element of type 'String', but the target element has type 'Array'".</item>
/// <item>Numeric- or JSON-shaped string results (e.g. <c>"42"</c> or <c>"{\"k\":1}"</c>)
/// silently changing type on the wire because <c>BinaryData</c> auto-detects JSON.</item>
/// </list>
/// <see cref="JsonElement"/> / <see cref="JsonDocument"/> values are unwrapped first so
/// a string-kind element does not get double-encoded into <c>"\"value\""</c>.
/// </remarks>
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Serializing function call result payload.")]
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Serializing function call result payload.")]
private static string EncodeFunctionResultAsJsonStringPayload(object? result)
{
string innerText = result switch
{
null => string.Empty,
string s => s,
JsonElement je => je.ValueKind == JsonValueKind.String
? (je.GetString() ?? string.Empty)
: je.GetRawText(),
JsonDocument jd => jd.RootElement.ValueKind == JsonValueKind.String
? (jd.RootElement.GetString() ?? string.Empty)
: jd.RootElement.GetRawText(),
_ => JsonSerializer.Serialize(result),
};
return JsonSerializer.Serialize(innerText);
}
}
@@ -2,7 +2,6 @@
using System;
using System.ClientModel;
using System.ClientModel.Primitives;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
@@ -39,28 +38,7 @@ namespace Microsoft.Agents.AI.Foundry;
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
public sealed class FoundryAgent : DelegatingAIAgent
{
/// <summary>
/// Default OAuth scope for the Azure AI resource. Matches the scope used by
/// <c>Azure.AI.Extensions.OpenAI</c>'s internal authentication helper so the bearer token is
/// accepted by the Foundry control plane.
/// </summary>
private const string AzureAiResourceScope = "https://ai.azure.com/.default";
/// <summary>
/// The cached <see cref="AIProjectClient"/> when one was supplied or constructed by the active
/// constructor. Null when the agent was constructed via the agent-endpoint constructor, which
/// does not build a full <see cref="AIProjectClient"/>.
/// </summary>
private readonly AIProjectClient? _aiProjectClient;
/// <summary>
/// Project-scoped <see cref="ProjectOpenAIClient"/>. Always non-null. Used for project-level
/// operations such as <see cref="CreateConversationSessionAsync(CancellationToken)"/>.
/// In agent-endpoint mode this is built directly from the project root derived from the
/// supplied agent endpoint; in project-endpoint mode it is the cached client returned by
/// <see cref="AIProjectClient"/>.
/// </summary>
private readonly ProjectOpenAIClient _projectOpenAIClient;
private readonly AIProjectClient _aiProjectClient;
/// <summary>
/// Initializes a new instance of the <see cref="FoundryAgent"/> class using the direct Responses API path.
@@ -94,49 +72,30 @@ public sealed class FoundryAgent : DelegatingAIAgent
out var aiProjectClient))
{
this._aiProjectClient = aiProjectClient;
this._projectOpenAIClient = aiProjectClient.GetProjectOpenAIClient();
}
/// <summary>
/// Initializes a new instance of the <see cref="FoundryAgent"/> class from an agent-specific endpoint.
/// </summary>
/// <param name="agentEndpoint">
/// The agent-specific endpoint URI. Must be of the shape
/// <c>https://&lt;host&gt;/.../projects/&lt;project&gt;/agents/&lt;agentName&gt;/endpoint/protocols/openai</c>.
/// </param>
/// <param name="agentEndpoint">The agent-specific endpoint URI (must contain the agent name in the path).</param>
/// <param name="credential">The authentication credential.</param>
/// <param name="clientOptions">
/// Optional configuration for the underlying <see cref="ProjectOpenAIClient"/>. When supplied:
/// <list type="bullet">
/// <item><description>The instance is passed through to the per-agent client; pipeline policies added via <c>AddPolicy(...)</c> on it execute on the per-agent traffic.</description></item>
/// <item><description><c>Endpoint</c> and <see cref="ProjectOpenAIClientOptions.AgentName"/> are owned by this constructor and are overwritten with values derived from <paramref name="agentEndpoint"/>; any caller value is replaced.</description></item>
/// <item><description>For the project-level conversations client a separate fresh options bag is built that copies only <see cref="ClientPipelineOptions.RetryPolicy"/>, <see cref="ClientPipelineOptions.NetworkTimeout"/>, <see cref="ClientPipelineOptions.Transport"/>, and <c>UserAgentApplicationId</c>; pipeline policies added via <c>AddPolicy(...)</c> do <strong>not</strong> propagate to the conversations pipeline.</description></item>
/// </list>
/// </param>
/// <param name="clientOptions">Optional configuration options for the <see cref="AIProjectClient"/>.</param>
/// <param name="tools">Optional tools to use when interacting with the agent.</param>
/// <param name="clientFactory">Provides a way to customize the creation of the underlying <see cref="IChatClient"/>.</param>
/// <param name="services">Optional service provider for resolving dependencies required by AI functions.</param>
/// <exception cref="ArgumentNullException"><paramref name="agentEndpoint"/> or <paramref name="credential"/> is null.</exception>
/// <exception cref="ArgumentException"><paramref name="agentEndpoint"/> does not match the expected agent-endpoint shape.</exception>
/// <remarks>
/// This is the lightweight constructor for invoking an existing Foundry hosted agent when the
/// caller already has the per-agent endpoint URL. It populates <see cref="ChatClientAgentOptions.Id"/>
/// and <see cref="ChatClientAgentOptions.Name"/> from the agent name parsed out of the endpoint
/// path; <c>Description</c>, <c>Instructions</c>, <c>Temperature</c>, and <c>TopP</c> are not
/// populated. Callers that need those fields hydrated from server-side state should use
/// <c>AIProjectClient.AsAIAgent(ProjectsAgentVersion)</c> or
/// <c>AIProjectClient.AsAIAgent(ProjectsAgentRecord)</c> instead.
/// </remarks>
public FoundryAgent(
Uri agentEndpoint,
AuthenticationTokenProvider credential,
ProjectOpenAIClientOptions? clientOptions = null,
AIProjectClientOptions? clientOptions = null,
IList<AITool>? tools = null,
Func<IChatClient, IChatClient>? clientFactory = null,
IServiceProvider? services = null)
: base(CreateInnerAgentFromAgentEndpoint(agentEndpoint, credential, clientOptions, tools, clientFactory, services))
: base(CreateInnerAgentFromEndpoint(
CreateProjectClient(agentEndpoint, credential, clientOptions),
agentEndpoint, tools, clientFactory, services,
out var aiProjectClient))
{
this._projectOpenAIClient = CreateProjectLevelOpenAIClientFromAgentEndpoint(agentEndpoint, credential, clientOptions);
this._aiProjectClient = aiProjectClient;
}
/// <summary>
@@ -146,7 +105,6 @@ public sealed class FoundryAgent : DelegatingAIAgent
: base(WireClientHeaders(Throw.IfNull(innerAgent)))
{
this._aiProjectClient = Throw.IfNull(aiProjectClient);
this._projectOpenAIClient = aiProjectClient.GetProjectOpenAIClient();
}
#region Convenience methods
@@ -179,7 +137,9 @@ public sealed class FoundryAgent : DelegatingAIAgent
/// <returns>A <see cref="ChatClientAgentSession"/> linked to the newly created server-side conversation.</returns>
public async Task<ChatClientAgentSession> CreateConversationSessionAsync(CancellationToken cancellationToken = default)
{
var conversationsClient = this._projectOpenAIClient.GetProjectConversationsClient();
var conversationsClient = this._aiProjectClient
.GetProjectOpenAIClient()
.GetProjectConversationsClient();
var conversation = (await conversationsClient.CreateProjectConversationAsync(options: null, cancellationToken).ConfigureAwait(false)).Value;
@@ -201,11 +161,6 @@ public sealed class FoundryAgent : DelegatingAIAgent
return this._aiProjectClient;
}
if (serviceKey is null && serviceType == typeof(ProjectOpenAIClient))
{
return this._projectOpenAIClient;
}
return base.GetService(serviceType, serviceKey);
}
@@ -283,181 +238,47 @@ public sealed class FoundryAgent : DelegatingAIAgent
OpenAIRequestPoliciesReflection.AddPolicyIfMissing(
policies,
ClientHeadersPolicy.Instance,
PipelinePosition.PerCall);
System.ClientModel.Primitives.PipelinePosition.PerCall);
}
return new ClientHeadersAgent(innerAgent);
}
/// <summary>
/// Builds the inner <see cref="ChatClientAgent"/> for the agent-endpoint constructor by
/// constructing a per-agent <see cref="ProjectOpenAIClient"/> via the
/// <c>ProjectOpenAIClient(AuthenticationPolicy, ProjectOpenAIClientOptions)</c>
/// constructor with <see cref="ProjectOpenAIClientOptions.AgentName"/> set. This routes the
/// outbound URL through the per-agent endpoint shape that the Foundry service expects for
/// hosted agents and lets the SDK auto-append the <c>api-version</c> query string.
/// Caller-supplied <paramref name="clientOptions"/> are passed through to the per-agent
/// client with <c>Endpoint</c> and
/// <see cref="ProjectOpenAIClientOptions.AgentName"/> overridden by values derived from
/// <paramref name="agentEndpoint"/>; any policies the caller added via <c>AddPolicy</c>
/// remain in effect on the per-agent pipeline. The MEAI user-agent policy is appended last.
/// </summary>
private static AIAgent CreateInnerAgentFromAgentEndpoint(
private static AIAgent CreateInnerAgentFromEndpoint(
AIProjectClient aiProjectClient,
Uri agentEndpoint,
AuthenticationTokenProvider credential,
ProjectOpenAIClientOptions? clientOptions,
IList<AITool>? tools,
Func<IChatClient, IChatClient>? clientFactory,
IServiceProvider? services)
IServiceProvider? services,
out AIProjectClient outClient)
{
Throw.IfNull(agentEndpoint);
Throw.IfNull(credential);
outClient = aiProjectClient;
var (agentName, _) = ParseAgentEndpoint(agentEndpoint);
AgentReference agentReference = agentEndpoint.Segments[^1].TrimEnd('/');
var perAgentOptions = clientOptions ?? new ProjectOpenAIClientOptions();
perAgentOptions.Endpoint = agentEndpoint;
perAgentOptions.AgentName = agentName;
perAgentOptions.AddPolicy(RequestOptionsExtensions.UserAgentPolicy, PipelinePosition.PerCall);
ChatClientAgentOptions agentOptions = new()
{
Name = agentReference.Name,
ChatOptions = new() { Tools = tools },
};
var authPolicy = new BearerTokenPolicy(credential, AzureAiResourceScope);
var perAgentClient = new ProjectOpenAIClient(authPolicy, perAgentOptions);
IChatClient chatClient = new AzureAIProjectChatClient(aiProjectClient, agentReference, defaultModelId: null, agentOptions.ChatOptions);
IChatClient chatClient = perAgentClient.GetProjectResponsesClient().AsIChatClient();
if (clientFactory is not null)
{
chatClient = clientFactory(chatClient);
}
ChatClientAgentOptions agentOptions = new()
{
Id = agentName,
Name = agentName,
ChatOptions = new() { Tools = tools },
};
return WireClientHeaders(new ChatClientAgent(chatClient, agentOptions, services: services));
}
/// <summary>
/// Builds the project-scoped <see cref="ProjectOpenAIClient"/> for the agent-endpoint
/// constructor by deriving the project root from the supplied agent endpoint and constructing
/// a fresh client without <see cref="ProjectOpenAIClientOptions.AgentName"/> so the SDK
/// appends the standard <c>/openai/v1</c> suffix expected for project-level surfaces such as
/// conversations.
/// </summary>
/// <remarks>
/// Only the four observable primitive properties (<see cref="ClientPipelineOptions.RetryPolicy"/>,
/// <see cref="ClientPipelineOptions.NetworkTimeout"/>, <see cref="ClientPipelineOptions.Transport"/>,
/// and <c>UserAgentApplicationId</c>) are copied from the caller's options bag. Pipeline
/// policies added via <c>AddPolicy</c> on the caller bag do not propagate because
/// <see cref="ClientPipelineOptions"/> does not publicly enumerate its policies. The MEAI
/// user-agent policy is appended last.
/// </remarks>
private static ProjectOpenAIClient CreateProjectLevelOpenAIClientFromAgentEndpoint(
Uri agentEndpoint,
AuthenticationTokenProvider credential,
ProjectOpenAIClientOptions? clientOptions)
{
var (_, projectRoot) = ParseAgentEndpoint(agentEndpoint);
var projectOptions = new ProjectOpenAIClientOptions();
if (clientOptions is not null)
{
if (clientOptions.RetryPolicy is not null)
{
projectOptions.RetryPolicy = clientOptions.RetryPolicy;
}
if (clientOptions.NetworkTimeout is not null)
{
projectOptions.NetworkTimeout = clientOptions.NetworkTimeout;
}
if (clientOptions.Transport is not null)
{
projectOptions.Transport = clientOptions.Transport;
}
if (!string.IsNullOrEmpty(clientOptions.UserAgentApplicationId))
{
projectOptions.UserAgentApplicationId = clientOptions.UserAgentApplicationId;
}
}
projectOptions.AddPolicy(RequestOptionsExtensions.UserAgentPolicy, PipelinePosition.PerCall);
return new ProjectOpenAIClient(projectRoot, credential, projectOptions);
}
/// <summary>
/// Parses an agent endpoint URI of shape
/// <c>https://&lt;host&gt;/.../projects/&lt;project&gt;/agents/&lt;agentName&gt;/endpoint/protocols/openai</c>
/// and returns the agent name and the derived project-root URI.
/// </summary>
/// <remarks>
/// Single source of truth for both agent-name extraction and project-root derivation.
/// Tolerates trailing slash, casing variants on <c>/agents/</c> and the suffix segment, and
/// strips query string and fragment. Throws <see cref="ArgumentException"/> for inputs that
/// do not match the expected shape.
/// </remarks>
/// <exception cref="ArgumentException">
/// The endpoint is missing the <c>/agents/</c> segment, has an empty agent name, or has a
/// suffix other than <c>/endpoint/protocols/openai</c>.
/// </exception>
internal static (string AgentName, Uri ProjectRoot) ParseAgentEndpoint(Uri agentEndpoint)
{
Throw.IfNull(agentEndpoint);
const string AgentsSegment = "/agents/";
const string ExpectedSuffix = "/endpoint/protocols/openai";
var path = agentEndpoint.AbsolutePath.TrimEnd('/');
var idx = path.IndexOf(AgentsSegment, StringComparison.OrdinalIgnoreCase);
if (idx < 0)
{
throw new ArgumentException(
$"Expected an agent endpoint of shape 'https://<host>/.../projects/<project>/agents/<agentName>/endpoint/protocols/openai' but got '{agentEndpoint}'. " +
"If you want to construct a FoundryAgent against a project endpoint, use the (Uri projectEndpoint, AuthenticationTokenProvider credential, string model, string instructions, ...) constructor instead.",
nameof(agentEndpoint));
}
var afterAgents = path.Substring(idx + AgentsSegment.Length);
var nextSlash = afterAgents.IndexOf('/');
if (nextSlash <= 0)
{
throw new ArgumentException(
$"Agent endpoint '{agentEndpoint}' is missing the '<agentName>{ExpectedSuffix}' suffix.",
nameof(agentEndpoint));
}
var agentName = afterAgents.Substring(0, nextSlash);
var suffix = afterAgents.Substring(nextSlash);
if (!string.Equals(suffix, ExpectedSuffix, StringComparison.OrdinalIgnoreCase))
{
throw new ArgumentException(
$"Agent endpoint '{agentEndpoint}' has an unexpected suffix '{suffix}'. Expected '{ExpectedSuffix}'.",
nameof(agentEndpoint));
}
var rootPath = path.Substring(0, idx);
var projectRoot = new UriBuilder(agentEndpoint)
{
Path = rootPath,
Query = string.Empty,
Fragment = string.Empty,
}.Uri;
return (agentName, projectRoot);
}
private static AIProjectClient CreateProjectClient(Uri endpoint, AuthenticationTokenProvider credential, AIProjectClientOptions? clientOptions = null)
{
Throw.IfNull(endpoint);
Throw.IfNull(credential);
clientOptions ??= new AIProjectClientOptions();
clientOptions.AddPolicy(RequestOptionsExtensions.UserAgentPolicy, PipelinePosition.PerCall);
clientOptions.AddPolicy(RequestOptionsExtensions.UserAgentPolicy, System.ClientModel.Primitives.PipelinePosition.PerCall);
return new AIProjectClient(endpoint, credential, clientOptions);
}
@@ -1,10 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<!-- Preview while we depend on Azure.AI.Projects 2.1.0-beta.1 for hosted-agent routing
(ProjectOpenAIClientOptions.AgentName, the (AuthenticationPolicy, options) ctor, and
related per-agent endpoint surface). Flip back to IsReleased=true once Azure.AI.Projects
ships a stable 2.1.0. -->
<IsReleased>true</IsReleased>
<InjectSharedThrow>true</InjectSharedThrow>
<NoWarn>$(NoWarn);OPENAI001</NoWarn>
</PropertyGroup>
@@ -151,36 +151,6 @@ public sealed class ChatClientAgentOptions
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public bool RequirePerServiceCallChatHistoryPersistence { get; set; }
/// <summary>
/// Gets or sets a value indicating whether to include a <see cref="MessageInjectingChatClient"/>
/// in the chat client pipeline.
/// </summary>
/// <remarks>
/// <para>
/// When set to <see langword="true"/>, a <see cref="MessageInjectingChatClient"/> is added to the pipeline
/// between the <see cref="FunctionInvokingChatClient"/> and the inner client. This enables external code
/// (such as tool delegates) to inject messages into the function execution loop via the
/// <see cref="MessageInjectingChatClient"/> class, which can be resolved from the chat client using
/// <c>GetService&lt;MessageInjectingChatClient&gt;()</c>.
/// </para>
/// <para>
/// This setting can be used independently of <see cref="RequirePerServiceCallChatHistoryPersistence"/>,
/// however it is recommended to also enable per-service-call persistence when using message injection
/// so that injected messages are persisted to chat history between service calls.
/// </para>
/// <para>
/// When setting the <see cref="UseProvidedChatClientAsIs"/> setting to <see langword="true"/> and
/// <see cref="EnableMessageInjection"/> to <see langword="true"/>, ensure that your custom chat client stack
/// includes a <see cref="MessageInjectingChatClient"/>. You can add one manually via the
/// <see cref="ChatClientBuilderExtensions.UseMessageInjection"/> extension method.
/// </para>
/// </remarks>
/// <value>
/// Default is <see langword="false"/>.
/// </value>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public bool EnableMessageInjection { get; set; }
/// <summary>
/// Creates a new instance of <see cref="ChatClientAgentOptions"/> with the same values as this instance.
/// </summary>
@@ -198,6 +168,5 @@ public sealed class ChatClientAgentOptions
WarnOnChatHistoryProviderConflict = this.WarnOnChatHistoryProviderConflict,
ThrowOnChatHistoryProviderConflict = this.ThrowOnChatHistoryProviderConflict,
RequirePerServiceCallChatHistoryPersistence = this.RequirePerServiceCallChatHistoryPersistence,
EnableMessageInjection = this.EnableMessageInjection,
};
}
@@ -114,38 +114,4 @@ public static class ChatClientBuilderExtensions
{
return builder.Use(innerClient => new PerServiceCallChatHistoryPersistingChatClient(innerClient));
}
/// <summary>
/// Adds a <see cref="MessageInjectingChatClient"/> to the chat client pipeline.
/// </summary>
/// <remarks>
/// <para>
/// This decorator enables external code (such as tool delegates) to inject messages into the function
/// execution loop. It should be positioned between the <see cref="FunctionInvokingChatClient"/> and
/// the <see cref="PerServiceCallChatHistoryPersistingChatClient"/> (or the leaf <see cref="IChatClient"/>)
/// in the pipeline.
/// </para>
/// <para>
/// The <see cref="MessageInjectingChatClient"/> can be retrieved from the chat client via
/// <c>GetService&lt;MessageInjectingChatClient&gt;</c> to enqueue messages from tool delegates or other code.
/// </para>
/// <para>
/// This extension method is intended for use with custom chat client stacks when
/// <see cref="ChatClientAgentOptions.UseProvidedChatClientAsIs"/> is <see langword="true"/>.
/// When <see cref="ChatClientAgentOptions.UseProvidedChatClientAsIs"/> is <see langword="false"/> (the default),
/// the <see cref="ChatClientAgent"/> automatically includes this decorator in the pipeline when
/// <see cref="ChatClientAgentOptions.RequirePerServiceCallChatHistoryPersistence"/> is <see langword="true"/>.
/// </para>
/// <para>
/// This decorator only works within the context of a running <see cref="ChatClientAgent"/> and will throw an
/// exception if used in any other stack.
/// </para>
/// </remarks>
/// <param name="builder">The <see cref="ChatClientBuilder"/> to add the decorator to.</param>
/// <returns>The <paramref name="builder"/> for chaining.</returns>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public static ChatClientBuilder UseMessageInjection(this ChatClientBuilder builder)
{
return builder.Use(innerClient => new MessageInjectingChatClient(innerClient));
}
}
@@ -63,21 +63,13 @@ public static class ChatClientExtensions
});
}
// MessageInjectingChatClient is injected when EnableMessageInjection is enabled.
// It is registered after FunctionInvokingChatClient so that it sits between FIC and the inner client.
// PerServiceCallChatHistoryPersistingChatClient is only injected when RequirePerServiceCallChatHistoryPersistence is enabled.
// It is registered after FunctionInvokingChatClient so that it sits between FIC and the leaf client.
// ChatClientBuilder.Build applies factories in reverse order, making the first Use() call outermost.
// MessageInjectingChatClient enables injecting messages during the function loop and looping when needed.
if (options?.EnableMessageInjection is true)
{
chatBuilder.Use(innerClient => new MessageInjectingChatClient(innerClient));
}
// PerServiceCallChatHistoryPersistingChatClient is injected when RequirePerServiceCallChatHistoryPersistence is enabled.
// It is registered after MessageInjectingChatClient (if present) so it sits closest to the leaf client.
// The resulting pipeline is:
// FunctionInvokingChatClient → [MessageInjectingChatClient] → [PerServiceCallChatHistoryPersistingChatClient] → leaf IChatClient
// PerServiceCallChatHistoryPersistingChatClient simulates service-stored chat history by loading history
// before each service call, persisting after each call, and returning a sentinel ConversationId.
// By adding our decorator second, the resulting pipeline is:
// FunctionInvokingChatClient → PerServiceCallChatHistoryPersistingChatClient → leaf IChatClient
// This allows the decorator to simulate service-stored chat history by loading history before
// each service call, persisting after each call, and returning a sentinel ConversationId.
if (options?.RequirePerServiceCallChatHistoryPersistence is true)
{
chatBuilder.Use(innerClient => new PerServiceCallChatHistoryPersistingChatClient(innerClient));
@@ -1,320 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Shared.DiagnosticIds;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI;
/// <summary>
/// A delegating chat client that supports injecting messages into the function execution loop.
/// </summary>
/// <remarks>
/// <para>
/// This decorator enables external code (such as tool delegates) to enqueue messages that will be
/// sent to the underlying model at the next opportunity. It sits between the <see cref="FunctionInvokingChatClient"/>
/// and the <see cref="PerServiceCallChatHistoryPersistingChatClient"/> (or the leaf <see cref="IChatClient"/>)
/// in a <see cref="ChatClientAgent"/> pipeline.
/// </para>
/// <para>
/// The injected messages queue is stored per-session in the <see cref="AgentSession.StateBag"/>, ensuring
/// isolation between concurrent sessions.
/// </para>
/// <para>
/// After each service call, if no actionable <see cref="FunctionCallContent"/> is returned but injected
/// messages are pending, the decorator loops internally and calls the inner client again with the new
/// messages. When actionable function calls are present, control returns to the parent
/// <see cref="FunctionInvokingChatClient"/> loop.
/// </para>
/// <para>
/// This chat client must be used within the context of a running <see cref="ChatClientAgent"/>. It retrieves the
/// current session from <see cref="AIAgent.CurrentRunContext"/>, which is set automatically when an agent's
/// <see cref="AIAgent.RunAsync(IEnumerable{ChatMessage}, AgentSession?, AgentRunOptions?, CancellationToken)"/> or
/// <see cref="AIAgent.RunStreamingAsync(IEnumerable{ChatMessage}, AgentSession?, AgentRunOptions?, CancellationToken)"/>
/// method is called.
/// </para>
/// </remarks>
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
public sealed class MessageInjectingChatClient : DelegatingChatClient
{
/// <summary>
/// The key used to store the pending injected messages queue in the session's <see cref="AgentSessionStateBag"/>.
/// </summary>
internal const string PendingMessagesStateKey = "MessageInjectingChatClient.PendingInjectedMessages";
/// <summary>
/// Initializes a new instance of the <see cref="MessageInjectingChatClient"/> class.
/// </summary>
/// <param name="innerClient">The underlying chat client that will handle the core operations.</param>
public MessageInjectingChatClient(IChatClient innerClient)
: base(innerClient)
{
}
/// <inheritdoc/>
public override async Task<ChatResponse> GetResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options = null,
CancellationToken cancellationToken = default)
{
var session = GetRequiredSession();
var queue = GetOrCreateQueue(session);
var newMessages = DrainInjectedMessages(queue, messages as IList<ChatMessage> ?? messages.ToList());
// Loop to process injected messages: after each service call, if no actionable function calls
// are pending but new messages have been injected into the queue, we call the service again
// so the model can process them. The loop exits when the response contains actionable
// function calls (handed off to the parent FunctionInvokingChatClient) or the queue is empty.
while (true)
{
var response = await base.GetResponseAsync(newMessages, options, cancellationToken).ConfigureAwait(false);
// If the response contains actionable function calls, the parent FunctionInvokingChatClient
// loop will iterate — return immediately so it can process them.
if (HasActionableFunctionCalls(response.Messages))
{
return response;
}
// No actionable function calls. If there are pending injected messages, loop again
// to send them to the service. Otherwise, we're done.
bool queueEmpty;
lock (queue)
{
queueEmpty = queue.Count == 0;
}
if (queueEmpty)
{
return response;
}
// Propagate any ConversationId returned by the service so subsequent iterations
// continue within the same conversation.
UpdateOptionsForNextIteration(ref options, response.ConversationId);
newMessages = DrainInjectedMessages(queue, Array.Empty<ChatMessage>());
}
}
/// <inheritdoc/>
public override async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
var session = GetRequiredSession();
var queue = GetOrCreateQueue(session);
var newMessages = DrainInjectedMessages(queue, messages as IList<ChatMessage> ?? messages.ToList());
// Loop to process injected messages: after each service call, if no actionable function calls
// are pending but new messages have been injected into the queue, we call the service again
// so the model can process them. The loop exits when the response contains actionable
// function calls (handed off to the parent FunctionInvokingChatClient) or the queue is empty.
while (true)
{
bool hasActionableFunctionCalls = false;
string? lastConversationId = null;
var enumerator = base.GetStreamingResponseAsync(newMessages, options, cancellationToken).GetAsyncEnumerator(cancellationToken);
try
{
while (await enumerator.MoveNextAsync().ConfigureAwait(false))
{
var update = enumerator.Current;
// Check each update for actionable function call content as it streams through.
if (!hasActionableFunctionCalls && HasActionableFunctionCalls(update))
{
hasActionableFunctionCalls = true;
}
// Track the latest ConversationId from the stream.
if (update.ConversationId is not null)
{
lastConversationId = update.ConversationId;
}
yield return update;
}
}
finally
{
await enumerator.DisposeAsync().ConfigureAwait(false);
}
// If the response contains actionable function calls, the parent FunctionInvokingChatClient
// loop will iterate — return immediately so it can process them.
if (hasActionableFunctionCalls)
{
yield break;
}
// No actionable function calls. If there are pending injected messages, loop again
// to send them to the service. Otherwise, we're done.
bool queueEmpty;
lock (queue)
{
queueEmpty = queue.Count == 0;
}
if (queueEmpty)
{
yield break;
}
// Propagate any ConversationId returned by the service so subsequent iterations
// continue within the same conversation.
UpdateOptionsForNextIteration(ref options, lastConversationId);
newMessages = DrainInjectedMessages(queue, Array.Empty<ChatMessage>());
}
}
/// <summary>
/// Enqueues one or more messages to be used at the next opportunity.
/// </summary>
/// <remarks>
/// This method is thread-safe and can be called concurrently from tool delegates or other code
/// while the function execution loop is in progress. The enqueued messages will be picked up
/// at the next opportunity.
/// </remarks>
/// <param name="session">The agent session to enqueue messages for.</param>
/// <param name="messages">The messages to enqueue.</param>
public void EnqueueMessages(AgentSession session, IEnumerable<ChatMessage> messages)
{
Throw.IfNull(session);
Throw.IfNull(messages);
var queue = GetOrCreateQueue(session);
lock (queue)
{
foreach (var message in messages)
{
queue.Add(message);
}
}
}
/// <summary>
/// Gets or creates the pending injected messages queue from the session's <see cref="AgentSessionStateBag"/>.
/// </summary>
private static List<ChatMessage> GetOrCreateQueue(AgentSession session)
{
if (session.StateBag.TryGetValue<List<ChatMessage>>(PendingMessagesStateKey, out var queue))
{
return queue!;
}
var newQueue = new List<ChatMessage>();
session.StateBag.SetValue(PendingMessagesStateKey, newQueue);
return newQueue;
}
/// <summary>
/// Gets the current <see cref="AgentSession"/> from the run context.
/// </summary>
private static AgentSession GetRequiredSession()
{
var runContext = AIAgent.CurrentRunContext
?? throw new InvalidOperationException(
$"{nameof(MessageInjectingChatClient)} can only be used within the context of a running AIAgent. " +
"Ensure that the chat client is being invoked as part of an AIAgent.RunAsync or AIAgent.RunStreamingAsync call.");
return runContext.Session
?? throw new InvalidOperationException(
$"{nameof(MessageInjectingChatClient)} requires a session. " +
"The current run context does not have a session.");
}
/// <summary>
/// Drains all pending injected messages from the queue and returns a new list combining
/// the original messages with the drained messages. The original list is never modified.
/// </summary>
private static IList<ChatMessage> DrainInjectedMessages(List<ChatMessage> queue, IList<ChatMessage> newMessages)
{
lock (queue)
{
if (queue.Count == 0)
{
return newMessages;
}
var combined = new List<ChatMessage>(newMessages);
combined.AddRange(queue);
queue.Clear();
return combined;
}
}
/// <summary>
/// Determines whether any message in the list contains a <see cref="FunctionCallContent"/>
/// that is not marked as <see cref="FunctionCallContent.InformationalOnly"/>.
/// </summary>
private static bool HasActionableFunctionCalls(IList<ChatMessage> responseMessages)
{
for (int i = 0; i < responseMessages.Count; i++)
{
var contents = responseMessages[i].Contents;
for (int j = 0; j < contents.Count; j++)
{
if (contents[j] is FunctionCallContent fcc && !fcc.InformationalOnly)
{
return true;
}
}
}
return false;
}
/// <summary>
/// Determines whether a streaming update contains a <see cref="FunctionCallContent"/>
/// that is not marked as <see cref="FunctionCallContent.InformationalOnly"/>.
/// </summary>
private static bool HasActionableFunctionCalls(ChatResponseUpdate update)
{
var contents = update.Contents;
for (int i = 0; i < contents.Count; i++)
{
if (contents[i] is FunctionCallContent fcc && !fcc.InformationalOnly)
{
return true;
}
}
return false;
}
/// <summary>
/// Propagates the <paramref name="conversationId"/> from the service response into
/// <paramref name="options"/> so that subsequent loop iterations continue within the
/// same conversation. Clones <paramref name="options"/> before mutating to avoid
/// affecting the caller's instance.
/// </summary>
private static void UpdateOptionsForNextIteration(ref ChatOptions? options, string? conversationId)
{
if (options is null)
{
if (conversationId is not null)
{
options = new() { ConversationId = conversationId };
}
}
else if (options.ConversationId != conversationId)
{
options = options.Clone();
options.ConversationId = conversationId;
}
}
}
@@ -207,10 +207,9 @@ public class InputConverterTests
[Fact]
public void ConvertOutputItemsToMessages_FunctionToolCallOutput_ReturnsToolMessage()
{
// Spec-compliant payload: a JSON string literal.
var funcOutput = new OutputItemFunctionToolCallOutput(
callId: "call_def",
output: BinaryData.FromString("\"result data\""));
output: BinaryData.FromString("result data"));
var messages = InputConverter.ConvertOutputItemsToMessages([funcOutput]);
@@ -219,52 +218,6 @@ public class InputConverterTests
var result = messages[0].Contents.OfType<FunctionResultContent>().FirstOrDefault();
Assert.NotNull(result);
Assert.Equal("call_def", result.CallId);
// Round-trip: the JSON-string wire payload is unwrapped to the original tool result text.
Assert.Equal("result data", result.Result as string);
}
[Fact]
public void ConvertOutputItemsToMessages_FunctionToolCallOutput_LegacyRawJsonArray_PassesThrough()
{
// Legacy/non-conforming producers that emitted a raw JSON value (array/object) in
// `output` are tolerated: the raw text is forwarded as the FunctionResultContent.Result
// so the model still sees the original tool-output shape on replay.
var funcOutput = new OutputItemFunctionToolCallOutput(
callId: "call_legacy",
output: BinaryData.FromString("[{\"id\":1}]"));
var messages = InputConverter.ConvertOutputItemsToMessages([funcOutput]);
var result = messages[0].Contents.OfType<FunctionResultContent>().FirstOrDefault();
Assert.NotNull(result);
Assert.Equal("[{\"id\":1}]", result.Result as string);
}
[Fact]
public void ConvertInputToMessages_FunctionCallOutput_JsonStringPayload_Unwraps()
{
// Spec-compliant inbound payload — a JSON string literal — must be unwrapped so
// FunctionResultContent.Result is the original tool result text, not the JSON-encoded form.
var input = new[]
{
new
{
type = "function_call_output",
id = "fc_out_002",
call_id = "call_456",
output = "sunny"
}
};
var request = new CreateResponse();
request.Input = BinaryData.FromObjectAsJson(input);
var messages = InputConverter.ConvertInputToMessages(request);
Assert.Single(messages);
var funcResult = messages[0].Contents.OfType<FunctionResultContent>().FirstOrDefault();
Assert.NotNull(funcResult);
Assert.Equal("sunny", funcResult.Result as string);
}
[Fact]
@@ -9,7 +9,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging" />
<PackageReference Include="Azure.AI.AgentServer.Responses" />
<PackageReference Include="Azure.AI.Projects" />
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
<PackageReference Include="Microsoft.AspNetCore.TestHost" />
<PackageReference Include="OpenTelemetry" />
<PackageReference Include="OpenTelemetry.Exporter.InMemory" />
@@ -616,10 +616,9 @@ public class OutputConverterTests
Assert.IsType<ResponseCompletedEvent>(events[^1]);
}
// K-06: FRC payloads are wrapped as JSON string literals on the wire so the field is
// always a spec-compliant OpenAI Responses `function_call_output.output` string value.
// K-06: FRC string results are emitted as raw text on the wire (not JSON-quoted).
[Fact]
public async Task ConvertUpdatesToEventsAsync_FunctionResultStringPayload_EmittedAsJsonStringAsync()
public async Task ConvertUpdatesToEventsAsync_FunctionResultStringPayload_EmittedAsRawTextAsync()
{
var (stream, _) = CreateTestStream();
var update = new AgentResponseUpdate { Contents = [new FunctionResultContent("call_1", "sunny")] };
@@ -632,76 +631,8 @@ public class OutputConverterTests
var added = Assert.Single(events.OfType<ResponseOutputItemAddedEvent>());
var output = Assert.IsType<OutputItemFunctionToolCallOutput>(added.Item);
// The wire payload is a JSON string literal — `"sunny"`, not the bare bytes `sunny`.
Assert.Equal("\"sunny\"", output.Output.ToString());
}
// K-06b: List/object FRC payloads must be JSON-stringified into a JSON string value
// so the OpenAI .NET client (FunctionCallOutputResponseItem.Output: string) can parse them.
[Fact]
public async Task ConvertUpdatesToEventsAsync_FunctionResultObjectPayload_EmittedAsJsonStringAsync()
{
var (stream, _) = CreateTestStream();
var todoList = new[] { new { id = 1, text = "Buy milk" } };
var update = new AgentResponseUpdate { Contents = [new FunctionResultContent("call_1", todoList)] };
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream))
{
events.Add(evt);
}
var added = Assert.Single(events.OfType<ResponseOutputItemAddedEvent>());
var output = Assert.IsType<OutputItemFunctionToolCallOutput>(added.Item);
// The wire payload must be a quoted JSON string containing the JSON-serialized object.
var raw = output.Output.ToString();
Assert.StartsWith("\"", raw);
Assert.EndsWith("\"", raw);
// The unwrapped value must round-trip back to the original JSON.
var inner = System.Text.Json.JsonSerializer.Deserialize<string>(raw);
Assert.Equal("[{\"id\":1,\"text\":\"Buy milk\"}]", inner);
}
// K-06c: A JsonElement of kind String must not be double-encoded.
[Fact]
public async Task ConvertUpdatesToEventsAsync_FunctionResultJsonElementStringPayload_NotDoubleEncodedAsync()
{
var (stream, _) = CreateTestStream();
using var doc = System.Text.Json.JsonDocument.Parse("\"sunny\"");
var update = new AgentResponseUpdate { Contents = [new FunctionResultContent("call_1", doc.RootElement.Clone())] };
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream))
{
events.Add(evt);
}
var added = Assert.Single(events.OfType<ResponseOutputItemAddedEvent>());
var output = Assert.IsType<OutputItemFunctionToolCallOutput>(added.Item);
// Must be `"sunny"`, not `"\"sunny\""`.
Assert.Equal("\"sunny\"", output.Output.ToString());
}
// K-06d: A JsonElement of non-string kind (e.g. array) must be JSON-stringified, not
// emitted as a raw JSON array on the wire.
[Fact]
public async Task ConvertUpdatesToEventsAsync_FunctionResultJsonElementArrayPayload_EmittedAsJsonStringAsync()
{
var (stream, _) = CreateTestStream();
using var doc = System.Text.Json.JsonDocument.Parse("[{\"id\":1}]");
var update = new AgentResponseUpdate { Contents = [new FunctionResultContent("call_1", doc.RootElement.Clone())] };
var events = new List<ResponseStreamEvent>();
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream))
{
events.Add(evt);
}
var added = Assert.Single(events.OfType<ResponseOutputItemAddedEvent>());
var output = Assert.IsType<OutputItemFunctionToolCallOutput>(added.Item);
var raw = output.Output.ToString();
var inner = System.Text.Json.JsonSerializer.Deserialize<string>(raw);
Assert.Equal("[{\"id\":1}]", inner);
// String FRC payloads must not be double-encoded — `sunny`, not `"sunny"`.
Assert.Equal("sunny", output.Output.ToString());
}
// L-01
@@ -7,7 +7,6 @@ using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Azure.AI.Extensions.OpenAI;
using Azure.AI.Projects;
using Microsoft.Extensions.AI;
@@ -185,7 +184,7 @@ public class FoundryAgentTests
// Act: this AsAIAgent path constructs FoundryAgent via its internal
// (AIProjectClient, ChatClientAgent) constructor, which previously bypassed pre-wiring.
var agent = projectClient.AsAIAgent(new AgentReference("agent-name"));
var agent = projectClient.AsAIAgent(new Azure.AI.Extensions.OpenAI.AgentReference("agent-name"));
// Assert
Assert.NotNull(agent.GetService<ClientHeadersAgent>());
@@ -399,379 +398,4 @@ public class FoundryAgentTests
}
#endregion
#region Agent-endpoint constructor tests
private const string TestAgentEndpoint = "https://test.services.ai.azure.com/api/projects/test-project/agents/it-happy-path/endpoint/protocols/openai";
private static readonly Uri s_testAgentEndpoint = new(TestAgentEndpoint);
[Fact]
public void AgentEndpointConstructor_NullEndpoint_ThrowsArgumentNullException()
{
ArgumentNullException ex = Assert.Throws<ArgumentNullException>(() =>
new FoundryAgent(agentEndpoint: null!, credential: new FakeAuthenticationTokenProvider()));
Assert.Equal("agentEndpoint", ex.ParamName);
}
[Fact]
public void AgentEndpointConstructor_NullCredential_ThrowsArgumentNullException()
{
ArgumentNullException ex = Assert.Throws<ArgumentNullException>(() =>
new FoundryAgent(agentEndpoint: s_testAgentEndpoint, credential: null!));
Assert.Equal("credential", ex.ParamName);
}
[Fact]
public void AgentEndpointConstructor_PopulatesNameAndIdFromEndpointSlug()
{
FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider());
Assert.Equal("it-happy-path", agent.Name);
Assert.Equal("it-happy-path", agent.Id);
}
[Fact]
public void AgentEndpointConstructor_GetServiceProjectOpenAIClient_ReturnsNonNull()
{
FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider());
Assert.NotNull(agent.GetService<ProjectOpenAIClient>());
}
[Fact]
public void AgentEndpointConstructor_GetServiceAIProjectClient_ReturnsNull()
{
FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider());
Assert.Null(agent.GetService<AIProjectClient>());
}
[Fact]
public void ProjectEndpointConstructor_GetServiceProjectOpenAIClient_ReturnsNonNull()
{
FoundryAgent agent = new(
s_testEndpoint,
new FakeAuthenticationTokenProvider(),
model: "gpt-4o-mini",
instructions: "Test");
Assert.NotNull(agent.GetService<ProjectOpenAIClient>());
}
[Fact]
public void AgentEndpointConstructor_AppliesClientFactoryOnce()
{
int count = 0;
FoundryAgent agent = new(
s_testAgentEndpoint,
new FakeAuthenticationTokenProvider(),
clientFactory: c => { count++; return c; });
Assert.Equal(1, count);
Assert.NotNull(agent);
}
[Fact]
public async Task AgentEndpointConstructor_RunAsync_RoutesThroughPerAgentResponsesUrlAsync()
{
Uri? capturedUri = null;
using HttpHandlerAssert handler = new(req =>
{
capturedUri = req.RequestUri;
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(TestDataUtil.GetOpenAIDefaultResponseJson(), Encoding.UTF8, "application/json"),
};
});
#pragma warning disable CA5399
using HttpClient http = new(handler);
#pragma warning restore CA5399
ProjectOpenAIClientOptions opts = new() { Transport = new HttpClientPipelineTransport(http) };
FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider(), clientOptions: opts);
await agent.RunAsync("Hello");
Assert.NotNull(capturedUri);
string path = capturedUri!.AbsolutePath;
Assert.Contains("/agents/it-happy-path/endpoint/protocols/openai/responses", path, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("/openai/v1/responses", path, StringComparison.OrdinalIgnoreCase);
Assert.Contains("api-version=v1", capturedUri.Query, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task AgentEndpointConstructor_RunStreamingAsync_RoutesThroughPerAgentResponsesUrlAsync()
{
Uri? capturedUri = null;
bool sawStreamTrue = false;
using HttpHandlerAssert handler = new(async req =>
{
capturedUri = req.RequestUri;
if (req.Content is not null)
{
string body = await req.Content.ReadAsStringAsync().ConfigureAwait(false);
if (body.Contains("\"stream\":true", StringComparison.Ordinal))
{
sawStreamTrue = true;
}
}
// Minimal SSE response; xUnit assertion only cares about the URL/body shape.
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("data: [DONE]\n\n", Encoding.UTF8, "text/event-stream"),
};
});
#pragma warning disable CA5399
using HttpClient http = new(handler);
#pragma warning restore CA5399
ProjectOpenAIClientOptions opts = new() { Transport = new HttpClientPipelineTransport(http) };
FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider(), clientOptions: opts);
try
{
await foreach (var _ in agent.RunStreamingAsync("Hello"))
{
// drain
}
}
catch
{
// SSE parse errors are acceptable; we only assert the request shape.
}
Assert.NotNull(capturedUri);
Assert.Contains("/agents/it-happy-path/endpoint/protocols/openai/responses", capturedUri!.AbsolutePath, StringComparison.OrdinalIgnoreCase);
Assert.Contains("api-version=v1", capturedUri.Query, StringComparison.OrdinalIgnoreCase);
Assert.True(sawStreamTrue, "Expected request body to include \"stream\":true.");
}
[Fact]
public async Task AgentEndpointConstructor_CreateConversationSessionAsync_RoutesThroughProjectLevelUrlAsync()
{
Uri? capturedUri = null;
using HttpHandlerAssert handler = new(req =>
{
capturedUri = req.RequestUri;
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{\"id\":\"conv_123\"}", Encoding.UTF8, "application/json"),
};
});
#pragma warning disable CA5399
using HttpClient http = new(handler);
#pragma warning restore CA5399
ProjectOpenAIClientOptions opts = new() { Transport = new HttpClientPipelineTransport(http) };
FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider(), clientOptions: opts);
try
{
_ = await agent.CreateConversationSessionAsync();
}
catch
{
// Underlying SDK may attempt extra parsing on the minimal response. We only assert URL routing.
}
Assert.NotNull(capturedUri);
string path = capturedUri!.AbsolutePath;
Assert.Contains("/api/projects/test-project/openai/v1/conversations", path, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("/agents/", path, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task AgentEndpointConstructor_StampsMeaiUserAgentHeaderAsync()
{
bool meaiSeen = false;
using HttpHandlerAssert handler = new(req =>
{
if (req.Headers.TryGetValues("User-Agent", out var values))
{
foreach (string v in values)
{
if (v.IndexOf("MEAI/", StringComparison.OrdinalIgnoreCase) >= 0)
{
meaiSeen = true;
}
}
}
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(TestDataUtil.GetOpenAIDefaultResponseJson(), Encoding.UTF8, "application/json"),
};
});
#pragma warning disable CA5399
using HttpClient http = new(handler);
#pragma warning restore CA5399
ProjectOpenAIClientOptions opts = new() { Transport = new HttpClientPipelineTransport(http) };
FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider(), clientOptions: opts);
await agent.RunAsync("Hello");
Assert.True(meaiSeen, "Expected MEAI/x.y.z to appear in the User-Agent header on the agent-endpoint pipeline.");
}
[Fact]
public async Task AgentEndpointConstructor_PassesThroughCallerPolicyOnPerAgentPipelineAsync()
{
// Direct switch to ProjectOpenAIClientOptions means caller-supplied pipeline policies
// (added via AddPolicy) actually flow through to the per-agent traffic. Assert that a
// tag-stamping policy executes on each outbound per-agent request.
bool tagSeen = false;
using HttpHandlerAssert handler = new(req =>
{
if (req.Headers.TryGetValues("X-Test-Tag", out var values))
{
foreach (string v in values)
{
if (v == "tag-1")
{
tagSeen = true;
}
}
}
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(TestDataUtil.GetOpenAIDefaultResponseJson(), Encoding.UTF8, "application/json"),
};
});
#pragma warning disable CA5399
using HttpClient http = new(handler);
#pragma warning restore CA5399
ProjectOpenAIClientOptions opts = new() { Transport = new HttpClientPipelineTransport(http) };
opts.AddPolicy(new HeaderStampPolicy("X-Test-Tag", "tag-1"), PipelinePosition.PerCall);
FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider(), clientOptions: opts);
await agent.RunAsync("Hello");
Assert.True(tagSeen, "Expected caller-supplied per-call policy to execute on the per-agent pipeline.");
}
[Fact]
public void AgentEndpointConstructor_OverridesCallerEndpointAndAgentName()
{
// The caller may set Endpoint/AgentName on the options bag; we must override both with
// values derived from agentEndpoint so the URL routing is correct regardless.
ProjectOpenAIClientOptions opts = new()
{
Endpoint = new Uri("https://wrong.example.com/openai/v1"),
AgentName = "wrong-agent",
};
FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider(), clientOptions: opts);
Assert.Equal("it-happy-path", agent.Name);
Assert.Equal(s_testAgentEndpoint, opts.Endpoint);
Assert.Equal("it-happy-path", opts.AgentName);
}
[Fact]
public void AgentEndpointConstructor_PropagatesUserAgentApplicationId_ToProjectLevelClient()
{
// The MEAI policy adds its own User-Agent header so we cannot reliably observe the OpenAI SDK's
// application-id stamp in the outbound request. Verify the value is propagated onto the
// project-level client's options via the public ProjectOpenAIClient surface.
ProjectOpenAIClientOptions opts = new() { UserAgentApplicationId = "my-app-id" };
FoundryAgent agent = new(s_testAgentEndpoint, new FakeAuthenticationTokenProvider(), clientOptions: opts);
ProjectOpenAIClient? projectClient = agent.GetService<ProjectOpenAIClient>();
Assert.NotNull(projectClient);
// Caller's UserAgentApplicationId is preserved on the per-agent options bag verbatim.
Assert.Equal("my-app-id", opts.UserAgentApplicationId);
}
#endregion
#region ParseAgentEndpoint tests
[Fact]
public void ParseAgentEndpoint_StandardShape_Parses()
{
var (name, root) = FoundryAgent.ParseAgentEndpoint(new Uri("https://h.example.com/api/projects/p1/agents/a1/endpoint/protocols/openai"));
Assert.Equal("a1", name);
Assert.Equal("https://h.example.com/api/projects/p1", root.AbsoluteUri.TrimEnd('/'));
}
[Fact]
public void ParseAgentEndpoint_TrailingSlash_Parses()
{
var (name, root) = FoundryAgent.ParseAgentEndpoint(new Uri("https://h.example.com/api/projects/p1/agents/a1/endpoint/protocols/openai/"));
Assert.Equal("a1", name);
Assert.Equal("https://h.example.com/api/projects/p1", root.AbsoluteUri.TrimEnd('/'));
}
[Fact]
public void ParseAgentEndpoint_UppercaseAgentsSegment_Parses()
{
var (name, _) = FoundryAgent.ParseAgentEndpoint(new Uri("https://h.example.com/api/projects/p1/Agents/a1/endpoint/protocols/openai"));
Assert.Equal("a1", name);
}
[Fact]
public void ParseAgentEndpoint_SpecialCharsInName_Parses()
{
var (name, _) = FoundryAgent.ParseAgentEndpoint(new Uri("https://h.example.com/api/projects/p/agents/it-happy_path-1/endpoint/protocols/openai"));
Assert.Equal("it-happy_path-1", name);
}
[Fact]
public void ParseAgentEndpoint_QueryAndFragmentStripped()
{
var (_, root) = FoundryAgent.ParseAgentEndpoint(new Uri("https://h.example.com/api/projects/p/agents/a/endpoint/protocols/openai?x=1#frag"));
Assert.Equal(string.Empty, root.Query);
Assert.Equal(string.Empty, root.Fragment);
}
[Fact]
public void ParseAgentEndpoint_SovereignCloudHostNoApiPrefix_Parses()
{
var (name, root) = FoundryAgent.ParseAgentEndpoint(new Uri("https://h.cognitive.microsoft.us/projects/p/agents/a1/endpoint/protocols/openai"));
Assert.Equal("a1", name);
Assert.Equal("https://h.cognitive.microsoft.us/projects/p", root.AbsoluteUri.TrimEnd('/'));
}
[Fact]
public void ParseAgentEndpoint_MissingAgentsSegment_Throws()
{
ArgumentException ex = Assert.Throws<ArgumentException>(() =>
FoundryAgent.ParseAgentEndpoint(new Uri("https://h.example.com/api/projects/p1/openai/v1")));
Assert.Equal("agentEndpoint", ex.ParamName);
}
[Fact]
public void ParseAgentEndpoint_WrongSuffix_Throws()
{
ArgumentException ex = Assert.Throws<ArgumentException>(() =>
FoundryAgent.ParseAgentEndpoint(new Uri("https://h.example.com/api/projects/p/agents/a1/openai/v1")));
Assert.Equal("agentEndpoint", ex.ParamName);
}
[Fact]
public void ParseAgentEndpoint_EmptyAgentName_Throws()
{
ArgumentException ex = Assert.Throws<ArgumentException>(() =>
FoundryAgent.ParseAgentEndpoint(new Uri("https://h.example.com/api/projects/p/agents//endpoint/protocols/openai")));
Assert.Equal("agentEndpoint", ex.ParamName);
}
#endregion
private sealed class HeaderStampPolicy : PipelinePolicy
{
private readonly string _name;
private readonly string _value;
public HeaderStampPolicy(string name, string value) { this._name = name; this._value = value; }
public override void Process(PipelineMessage message, System.Collections.Generic.IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
{
message.Request.Headers.Set(this._name, this._value);
ProcessNext(message, pipeline, currentIndex);
}
public override ValueTask ProcessAsync(PipelineMessage message, System.Collections.Generic.IReadOnlyList<PipelinePolicy> pipeline, int currentIndex)
{
message.Request.Headers.Set(this._name, this._value);
return ProcessNextAsync(message, pipeline, currentIndex);
}
}
}
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
@@ -7,7 +7,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging" />
<PackageReference Include="Azure.AI.Projects" />
<PackageReference Include="Azure.AI.Projects" VersionOverride="2.1.0-beta.1" />
</ItemGroup>
<ItemGroup>
@@ -1,493 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using Moq;
using Moq.Protected;
namespace Microsoft.Agents.AI.UnitTests;
/// <summary>
/// Unit tests for <see cref="MessageInjectingChatClient"/>.
/// </summary>
public class MessageInjectingChatClientTests
{
/// <summary>
/// Verifies that <see cref="MessageInjectingChatClient"/> is resolvable via GetService when the decorator is active.
/// </summary>
[Fact]
public void GetService_ReturnsMessageInjectingChatClient_WhenDecoratorActive()
{
// Arrange
Mock<IChatClient> mockService = new();
ChatClientAgent agent = new(mockService.Object, options: new()
{
EnableMessageInjection = true,
});
// Act
var injector = agent.ChatClient.GetService<MessageInjectingChatClient>();
// Assert
Assert.NotNull(injector);
}
/// <summary>
/// Verifies that <see cref="MessageInjectingChatClient"/> is null when the decorator is not active.
/// </summary>
[Fact]
public void GetService_ReturnsNull_WhenDecoratorNotActive()
{
// Arrange
Mock<IChatClient> mockService = new();
ChatClientAgent agent = new(mockService.Object, options: new());
// Act
var injector = agent.ChatClient.GetService<MessageInjectingChatClient>();
// Assert
Assert.Null(injector);
}
/// <summary>
/// Verifies that messages enqueued on the session before RunAsync are included in the service call messages.
/// </summary>
[Fact]
public async Task RunAsync_IncludesInjectedMessages_WhenEnqueuedBeforeCallAsync()
{
// Arrange
List<ChatMessage> capturedMessages = [];
Mock<IChatClient> mockService = new();
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback((IEnumerable<ChatMessage> msgs, ChatOptions? _, CancellationToken _) =>
capturedMessages.AddRange(msgs))
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
Mock<ChatHistoryProvider> mockChatHistoryProvider = new(null, null, null);
mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
mockChatHistoryProvider
.Protected()
.Setup<ValueTask<IEnumerable<ChatMessage>>>("InvokingCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
.Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
new ValueTask<IEnumerable<ChatMessage>>(ctx.RequestMessages.ToList()));
mockChatHistoryProvider
.Protected()
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
.Returns(new ValueTask());
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatHistoryProvider = mockChatHistoryProvider.Object,
RequirePerServiceCallChatHistoryPersistence = true,
EnableMessageInjection = true,
});
// Create session and enqueue a message directly onto the session's StateBag queue before calling RunAsync
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
var queue = new List<ChatMessage>();
queue.Add(new ChatMessage(ChatRole.User, "injected message"));
session!.StateBag.SetValue("MessageInjectingChatClient.PendingInjectedMessages", queue);
// Act
await agent.RunAsync([new(ChatRole.User, "original")], session);
// Assert — the service should have received both the original and injected messages
Assert.Contains(capturedMessages, m => m.Text == "original");
Assert.Contains(capturedMessages, m => m.Text == "injected message");
}
/// <summary>
/// Verifies that the queue is drained after a call (messages are not re-delivered on subsequent calls).
/// </summary>
[Fact]
public async Task RunAsync_DrainsQueue_MessagesNotRedeliveredAsync()
{
// Arrange
List<ChatMessage> capturedMessages = [];
Mock<IChatClient> mockService = new();
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback((IEnumerable<ChatMessage> msgs, ChatOptions? _, CancellationToken _) =>
capturedMessages.AddRange(msgs))
.ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]));
Mock<ChatHistoryProvider> mockChatHistoryProvider = new(null, null, null);
mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
mockChatHistoryProvider
.Protected()
.Setup<ValueTask<IEnumerable<ChatMessage>>>("InvokingCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
.Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
new ValueTask<IEnumerable<ChatMessage>>(ctx.RequestMessages.ToList()));
mockChatHistoryProvider
.Protected()
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
.Returns(new ValueTask());
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatHistoryProvider = mockChatHistoryProvider.Object,
RequirePerServiceCallChatHistoryPersistence = true,
EnableMessageInjection = true,
});
// Create session and enqueue a message directly onto the session's StateBag queue
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
var queue = new List<ChatMessage>();
queue.Add(new ChatMessage(ChatRole.User, "injected once"));
session!.StateBag.SetValue("MessageInjectingChatClient.PendingInjectedMessages", queue);
// Act
await agent.RunAsync([new(ChatRole.User, "first call")], session);
// Assert — the injected message was included in the service call
Assert.Contains(capturedMessages, m => m.Text == "injected once");
// Assert — the session's queue is now empty (drained)
Assert.Empty(queue);
}
/// <summary>
/// Verifies that the internal loop fires when no actionable FunctionCallContent is returned
/// but there are pending injected messages in the queue.
/// </summary>
[Fact]
public async Task RunAsync_LoopsInternally_WhenNoActionableFCCButPendingMessagesAsync()
{
// Arrange
int serviceCallCount = 0;
Mock<IChatClient> mockService = new();
MessageInjectingChatClient? injectorRef = null;
ChatClientAgentSession? sessionRef = null;
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Returns((IEnumerable<ChatMessage> msgs, ChatOptions? _, CancellationToken _) =>
{
serviceCallCount++;
if (serviceCallCount == 1)
{
// First call — simulate that something enqueues a message (e.g., a provider or background task)
injectorRef!.EnqueueMessages(sessionRef!, [new ChatMessage(ChatRole.User, "injected during first call")]);
}
// Return a plain text response (no FunctionCallContent) to trigger the internal loop
return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, $"response {serviceCallCount}")]));
});
Mock<ChatHistoryProvider> mockChatHistoryProvider = new(null, null, null);
mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
mockChatHistoryProvider
.Protected()
.Setup<ValueTask<IEnumerable<ChatMessage>>>("InvokingCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
.Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
new ValueTask<IEnumerable<ChatMessage>>(ctx.RequestMessages.ToList()));
mockChatHistoryProvider
.Protected()
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
.Returns(new ValueTask());
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatHistoryProvider = mockChatHistoryProvider.Object,
RequirePerServiceCallChatHistoryPersistence = true,
EnableMessageInjection = true,
});
injectorRef = agent.ChatClient.GetService<MessageInjectingChatClient>()!;
// Act
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
sessionRef = session;
await agent.RunAsync([new(ChatRole.User, "original")], session);
// Assert — should have made 2 service calls (internal loop triggered by the injected message)
Assert.Equal(2, serviceCallCount);
}
/// <summary>
/// Verifies that the internal loop does NOT fire when the response contains actionable
/// FunctionCallContent, even if there are pending injected messages.
/// </summary>
[Fact]
public async Task RunAsync_DoesNotLoopInternally_WhenActionableFCCPresentAsync()
{
// Arrange
int serviceCallCount = 0;
Mock<IChatClient> mockService = new();
MessageInjectingChatClient? injectorRef = null;
ChatClientAgentSession? sessionRef = null;
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Returns((IEnumerable<ChatMessage> msgs, ChatOptions? _, CancellationToken _) =>
{
serviceCallCount++;
if (serviceCallCount == 1)
{
// Enqueue a message during the first call
injectorRef!.EnqueueMessages(sessionRef!, [new ChatMessage(ChatRole.User, "injected")]);
// Return a response with an actionable FunctionCallContent
return Task.FromResult(new ChatResponse([new(ChatRole.Assistant,
[new FunctionCallContent("call1", "myTool", new Dictionary<string, object?>())])]));
}
// Subsequent calls return plain text (the FCC loop will call back after tool execution)
return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, "final")]));
});
Mock<ChatHistoryProvider> mockChatHistoryProvider = new(null, null, null);
mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
mockChatHistoryProvider
.Protected()
.Setup<ValueTask<IEnumerable<ChatMessage>>>("InvokingCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
.Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
new ValueTask<IEnumerable<ChatMessage>>(ctx.RequestMessages.ToList()));
mockChatHistoryProvider
.Protected()
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
.Returns(new ValueTask());
var tool = AIFunctionFactory.Create(() => "tool result", "myTool", "A test tool");
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatOptions = new() { Tools = [tool] },
ChatHistoryProvider = mockChatHistoryProvider.Object,
RequirePerServiceCallChatHistoryPersistence = true,
EnableMessageInjection = true,
}, services: new ServiceCollection().BuildServiceProvider());
injectorRef = agent.ChatClient.GetService<MessageInjectingChatClient>()!;
// Act
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
sessionRef = session;
await agent.RunAsync([new(ChatRole.User, "original")], session);
// Assert — The first service call returned actionable FCC, so no internal injected-message loop
// occurred there. The FCC loop invokes the tool and calls the service again (second call).
// The injected message should be picked up by the second service call (drained at start of
// GetResponseAsync), but no extra internal loop should fire. Exactly 2 service calls expected.
Assert.Equal(2, serviceCallCount);
}
/// <summary>
/// Verifies that the internal loop fires when the response contains only InformationalOnly
/// FunctionCallContent (which are not actionable) and there are pending injected messages.
/// </summary>
[Fact]
public async Task RunAsync_LoopsInternally_WhenOnlyInformationalOnlyFCCAndPendingMessagesAsync()
{
// Arrange
int serviceCallCount = 0;
Mock<IChatClient> mockService = new();
MessageInjectingChatClient? injectorRef = null;
ChatClientAgentSession? sessionRef = null;
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Returns((IEnumerable<ChatMessage> msgs, ChatOptions? _, CancellationToken _) =>
{
serviceCallCount++;
if (serviceCallCount == 1)
{
// Enqueue a message during the first call
injectorRef!.EnqueueMessages(sessionRef!, [new ChatMessage(ChatRole.User, "injected")]);
// Return a response with InformationalOnly FCC (not actionable)
return Task.FromResult(new ChatResponse([new(ChatRole.Assistant,
[new FunctionCallContent("call1", "myTool", new Dictionary<string, object?>()) { InformationalOnly = true }])]));
}
return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, "final")]));
});
Mock<ChatHistoryProvider> mockChatHistoryProvider = new(null, null, null);
mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
mockChatHistoryProvider
.Protected()
.Setup<ValueTask<IEnumerable<ChatMessage>>>("InvokingCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
.Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
new ValueTask<IEnumerable<ChatMessage>>(ctx.RequestMessages.ToList()));
mockChatHistoryProvider
.Protected()
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
.Returns(new ValueTask());
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatHistoryProvider = mockChatHistoryProvider.Object,
RequirePerServiceCallChatHistoryPersistence = true,
EnableMessageInjection = true,
});
injectorRef = agent.ChatClient.GetService<MessageInjectingChatClient>()!;
// Act
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
sessionRef = session;
await agent.RunAsync([new(ChatRole.User, "original")], session);
// Assert — InformationalOnly FCC is NOT actionable, so internal loop should trigger
Assert.Equal(2, serviceCallCount);
}
/// <summary>
/// Verifies that when the inner client returns a ConversationId on the first call, the
/// MessageInjectingChatClient propagates it to options on subsequent loop iterations.
/// </summary>
[Fact]
public async Task RunAsync_PropagatesConversationId_AcrossInternalLoopIterationsAsync()
{
// Arrange
int serviceCallCount = 0;
List<string?> capturedConversationIds = [];
MessageInjectingChatClient? injectorRef = null;
ChatClientAgentSession? sessionRef = null;
Mock<IChatClient> mockService = new();
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Returns((IEnumerable<ChatMessage> _, ChatOptions? opts, CancellationToken _) =>
{
serviceCallCount++;
capturedConversationIds.Add(opts?.ConversationId);
if (serviceCallCount == 1)
{
// First call: inject a message and return a ConversationId
injectorRef!.EnqueueMessages(sessionRef!, [new ChatMessage(ChatRole.User, "injected")]);
return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, "first response")])
{
ConversationId = "conv-123",
});
}
// Second call (from loop): should have the propagated ConversationId
return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, "second response")]));
});
ChatClientAgent agent = new(mockService.Object, options: new()
{
EnableMessageInjection = true,
}, services: new ServiceCollection().BuildServiceProvider());
injectorRef = agent.ChatClient.GetService<MessageInjectingChatClient>()!;
// Act
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
sessionRef = session;
await agent.RunAsync([new(ChatRole.User, "hello")], session);
// Assert — The second call should have received the ConversationId propagated from the first response
Assert.Equal(2, serviceCallCount);
Assert.Null(capturedConversationIds[0]); // First call: no ConversationId yet
Assert.Equal("conv-123", capturedConversationIds[1]); // Second call: propagated from first response
}
/// <summary>
/// Verifies that a session with pending injected messages can be serialized and deserialized,
/// and that the deserialized session correctly delivers the injected messages on the next run.
/// </summary>
[Fact]
public async Task RunAsync_DeliversInjectedMessages_AfterSessionSerializationRoundTripAsync()
{
// Arrange
List<ChatMessage> capturedMessagesFirstRun = [];
List<ChatMessage> capturedMessagesSecondRun = [];
int runCount = 0;
Mock<IChatClient> mockService = new();
MessageInjectingChatClient? injectorRef = null;
ChatClientAgentSession? sessionRef = null;
mockService.Setup(
s => s.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Returns((IEnumerable<ChatMessage> msgs, ChatOptions? _, CancellationToken _) =>
{
if (runCount == 1)
{
capturedMessagesFirstRun.AddRange(msgs);
// Inject a message during the first run — this will remain pending (not drained)
// because we return an actionable FCC that causes the parent loop to take over.
injectorRef!.EnqueueMessages(sessionRef!, [new ChatMessage(ChatRole.User, "injected before serialization")]);
// Return actionable FCC so the injection loop does NOT drain the message
return Task.FromResult(new ChatResponse([new(ChatRole.Assistant,
[new FunctionCallContent("call1", "myTool", new Dictionary<string, object?>())])]));
}
// Second run (after deserialization) — capture what messages come through
capturedMessagesSecondRun.AddRange(msgs);
return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, "final response")]));
});
Mock<ChatHistoryProvider> mockChatHistoryProvider = new(null, null, null);
mockChatHistoryProvider.SetupGet(p => p.StateKeys).Returns(["TestChatHistoryProvider"]);
mockChatHistoryProvider
.Protected()
.Setup<ValueTask<IEnumerable<ChatMessage>>>("InvokingCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokingContext>(), ItExpr.IsAny<CancellationToken>())
.Returns((ChatHistoryProvider.InvokingContext ctx, CancellationToken _) =>
new ValueTask<IEnumerable<ChatMessage>>(ctx.RequestMessages.ToList()));
mockChatHistoryProvider
.Protected()
.Setup<ValueTask>("InvokedCoreAsync", ItExpr.IsAny<ChatHistoryProvider.InvokedContext>(), ItExpr.IsAny<CancellationToken>())
.Returns(new ValueTask());
var tool = AIFunctionFactory.Create(() => "tool result", "myTool", "A test tool");
ChatClientAgent agent = new(mockService.Object, options: new()
{
ChatOptions = new() { Tools = [tool] },
ChatHistoryProvider = mockChatHistoryProvider.Object,
RequirePerServiceCallChatHistoryPersistence = true,
EnableMessageInjection = true,
}, services: new ServiceCollection().BuildServiceProvider());
injectorRef = agent.ChatClient.GetService<MessageInjectingChatClient>()!;
// Act — First run: inject a message that stays pending
var session = await agent.CreateSessionAsync() as ChatClientAgentSession;
sessionRef = session;
runCount = 1;
await agent.RunAsync([new(ChatRole.User, "first run message")], session);
// Serialize the session and deserialize into a new instance
var serialized = await agent.SerializeSessionAsync(session!);
var deserializedSession = await agent.DeserializeSessionAsync(serialized) as ChatClientAgentSession;
// Second run on the deserialized session — the injected message should be delivered
runCount = 2;
sessionRef = deserializedSession;
await agent.RunAsync([new(ChatRole.User, "second run message")], deserializedSession);
// Assert — the second run should include the injected message from before serialization
Assert.Contains(capturedMessagesSecondRun, m => m.Text == "injected before serialization");
Assert.Contains(capturedMessagesSecondRun, m => m.Text == "second run message");
}
}
-32
View File
@@ -7,38 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [1.3.0] - 2026-05-07
### Added
- **agent-framework-core**: Add `ClassSkill` for class-based skill definitions with declarative metadata and automatic method discovery ([#5678](https://github.com/microsoft/agent-framework/pull/5678))
- **agent-framework-core**: Add experimental session-mode harness context provider ([#5611](https://github.com/microsoft/agent-framework/pull/5611))
- **agent-framework-core**: Add experimental todo-list harness context provider ([#5612](https://github.com/microsoft/agent-framework/pull/5612))
- **agent-framework-core**: Add experimental memory harness context provider ([#5613](https://github.com/microsoft/agent-framework/pull/5613))
- **agent-framework-core**: Notify agent of external `AgentModeProvider` mode changes ([#5650](https://github.com/microsoft/agent-framework/pull/5650))
- **agent-framework-core**: Information-flow control prompt injection defense ([#5331](https://github.com/microsoft/agent-framework/pull/5331))
- **agent-framework-openai**: Support OpenAI and Gemini `allowed_tools` tool choice ([#5322](https://github.com/microsoft/agent-framework/pull/5322))
- **agent-framework-openai**: Support GPT-5 verbosity option and restore Foundry `agent_reference` ([#5619](https://github.com/microsoft/agent-framework/pull/5619))
- **agent-framework-anthropic**: Add `base_url` parameter to `AnthropicClient` and `RawAnthropicClient` ([#5685](https://github.com/microsoft/agent-framework/pull/5685))
- **agent-framework-foundry-hosting**: Add support for function approval flow in Foundry hosted agent ([#5666](https://github.com/microsoft/agent-framework/pull/5666))
- **agent-framework-declarative**: Add Python parity for `InvokeMcpTool` in declarative workflow ([#5630](https://github.com/microsoft/agent-framework/pull/5630))
- **agent-framework-declarative**: Add Python parity for `HttpRequestAction` in declarative workflow ([#5599](https://github.com/microsoft/agent-framework/pull/5599))
- **agent-framework-claude**, **agent-framework-github-copilot**: Enforce `approval_mode` in Claude and GitHub Copilot agents ([#5562](https://github.com/microsoft/agent-framework/pull/5562))
- **agent-framework-github-copilot**: Upgrade `github-copilot-sdk` to v1.0.0b2 with `instruction_directories`, `copilot_home`, and runtime options forwarding on session resume ([#5665](https://github.com/microsoft/agent-framework/pull/5665))
- **samples**: Add hosted agent sample with observability ([#5608](https://github.com/microsoft/agent-framework/pull/5608))
- **samples**: Add sample for hosted agent with files ([#5596](https://github.com/microsoft/agent-framework/pull/5596))
### Changed
- **agent-framework-core**: [BREAKING — experimental skills API] Restructure agent skills to use multi-source architecture ([#5584](https://github.com/microsoft/agent-framework/pull/5584))
- **agent-framework-foundry**: Remove bespoke Foundry toolbox helpers; standardize on MCP for toolbox consumption ([#5671](https://github.com/microsoft/agent-framework/pull/5671))
### Fixed
- **agent-framework-core**: Fix `MCPStreamableHTTPTool` leaking `asyncio.CancelledError` when MCP server is unreachable ([#5687](https://github.com/microsoft/agent-framework/pull/5687))
- **agent-framework-openai**: Drop completed `continuation_token` from shared options in tool loop ([#5462](https://github.com/microsoft/agent-framework/pull/5462))
- **agent-framework-bedrock**: Don't send `toolChoice` when no tools are configured ([#5172](https://github.com/microsoft/agent-framework/pull/5172))
- **agent-framework-hyperlight**: Fix `WasmSandbox` cross-thread Drop and harden hosted-agent sample ([#5603](https://github.com/microsoft/agent-framework/pull/5603))
- **agent-framework-devui**: Fix incorrect workflow timings by adding `created_at` to executor events ([#5615](https://github.com/microsoft/agent-framework/pull/5615))
- **agent-framework-foundry-hosting**: Fix hosted MCP replay producing orphan `function_call_output` ([#5581](https://github.com/microsoft/agent-framework/pull/5581))
## [1.2.2] - 2026-04-29
### Added
+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.0b260507"
version = "1.0.0b260429"
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.3.0,<2",
"agent-framework-core>=1.2.2,<2",
"a2a-sdk>=0.3.5,<0.3.24",
]
+2 -2
View File
@@ -1,6 +1,6 @@
[project]
name = "agent-framework-ag-ui"
version = "1.0.0b260507"
version = "1.0.0b260429"
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.3.0,<2",
"agent-framework-core>=1.2.2,<2",
"ag-ui-protocol>=0.1.16,<0.2",
"fastapi>=0.115.0,<0.133.1",
"uvicorn[standard]>=0.30.0,<0.42.0"
+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.0b260507"
version = "1.0.0b260429"
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.3.0,<2",
"agent-framework-core>=1.2.2,<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.0b260507"
version = "1.0.0b260429"
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.3.0,<2",
"agent-framework-core>=1.2.2,<2",
"azure-search-documents>=11.7.0b2,<11.7.0b3",
]
@@ -4,7 +4,7 @@ description = "Azure Content Understanding integration for Microsoft Agent Frame
authors = [{ name = "Microsoft", email = "af-support@microsoft.com" }]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0a260507"
version = "1.0.0a260429"
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.3.0,<2",
"agent-framework-foundry>=1.3.0,<2",
"agent-framework-core>=1.2.2,<2",
"agent-framework-foundry>=1.2.2,<2",
"azure-ai-contentunderstanding>=1.0.1,<1.1",
"aiohttp>=3.9,<4",
"filetype>=1.2,<2",
+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.0b260507"
version = "1.0.0b260429"
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.3.0,<2",
"agent-framework-core>=1.2.2,<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.0b260507"
version = "1.0.0b260429"
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.3.0,<2",
"agent-framework-core>=1.2.2,<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.0b260507"
version = "1.0.0b260429"
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.3.0,<2",
"agent-framework-core>=1.2.2,<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.0b260507"
version = "1.0.0b260429"
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.3.0,<2",
"agent-framework-core>=1.2.2,<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.0b260507"
version = "1.0.0b260429"
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.3.0,<2",
"agent-framework-core>=1.2.2,<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.0b260507"
version = "1.0.0b260429"
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.3.0,<2",
"agent-framework-core>=1.2.2,<2",
"microsoft-agents-copilotstudio-client>=0.3.1,<0.3.2",
]
@@ -1107,7 +1107,9 @@ class ClassSkill(Skill, ABC):
def decorator(f: Callable[..., Any]) -> Callable[..., Any]:
if isinstance(f, (property, classmethod, staticmethod)):
raise TypeError("@ClassSkill.script must be applied before @property, @classmethod, or @staticmethod.")
raise TypeError(
"@ClassSkill.script must be applied before @property, @classmethod, or @staticmethod."
)
if name is not None:
_validate_member_name(name, "script")
f._skill_script_marker = { # type: ignore[attr-defined]
+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.3.0"
version = "1.2.2"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
+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.0b260507"
version = "1.0.0b260429"
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.3.0,<2",
"agent-framework-core>=1.2.2,<2",
"httpx>=0.27,<1",
"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.0b260507"
version = "1.0.0b260429"
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.3.0,<2",
"agent-framework-core>=1.2.2,<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.0b260507"
version = "1.0.0b260429"
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.3.0,<2",
"agent-framework-core>=1.2.2,<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.3.0"
version = "1.2.2"
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.3.0,<2",
"agent-framework-openai>=1.3.0,<2",
"agent-framework-core>=1.2.2,<2",
"agent-framework-openai>=1.2.2,<2",
"azure-ai-inference>=1.0.0b9,<1.0.0b10",
"azure-ai-projects>=2.1.0,<3.0",
]
@@ -4,7 +4,7 @@ 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.0a260507"
version = "1.0.0a260429"
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.3.0,<2",
"agent-framework-core>=1.2.2,<2",
"azure-ai-agentserver-core>=2.0.0b3,<3",
"azure-ai-agentserver-responses>=1.0.0b5,<2",
"azure-ai-agentserver-invocations>=1.0.0b3,<2",
+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.0b260507"
version = "1.0.0b260429"
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.3.0,<2",
"agent-framework-openai>=1.3.0,<2",
"agent-framework-core>=1.2.2,<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.0a260507"
version = "1.0.0a260429"
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.3.0,<2.0",
"agent-framework-core>=1.2.2,<2.0",
"google-genai>=1.65.0,<2.0.0",
]
@@ -140,18 +140,12 @@ class GitHubCopilotSettings(TypedDict, total=False):
Can be set via environment variable GITHUB_COPILOT_TIMEOUT.
log_level: CLI log level.
Can be set via environment variable GITHUB_COPILOT_LOG_LEVEL.
copilot_home: Directory where the CLI stores session state, configuration,
and other persistent data. Can be set via environment variable
GITHUB_COPILOT_COPILOT_HOME. Defaults to ~/.copilot when not set.
Only applicable when the SDK spawns the CLI process (ignored when
connecting to an external server via a pre-configured client).
"""
cli_path: str | None
model: str | None
timeout: float | None
log_level: str | None
copilot_home: str | None
class GitHubCopilotOptions(TypedDict, total=False):
@@ -193,12 +187,6 @@ class GitHubCopilotOptions(TypedDict, total=False):
instead of the default GitHub Copilot backend.
"""
instruction_directories: list[str]
"""Additional directories to search for custom instruction files.
Lets applications point the CLI at project-specific or team-shared instruction
files beyond the default locations.
"""
on_function_approval: FunctionApprovalCallback
"""Approval callback for ``FunctionTool`` instances declared with
``approval_mode="always_require"``. The callback is awaited (sync or async)
@@ -312,9 +300,7 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
on_permission_request: PermissionHandlerType | None = opts.pop("on_permission_request", None)
mcp_servers: dict[str, MCPServerConfig] | None = opts.pop("mcp_servers", None)
provider: ProviderConfig | None = opts.pop("provider", None)
instruction_directories: list[str] | None = opts.pop("instruction_directories", None)
on_function_approval: FunctionApprovalCallback | None = opts.pop("on_function_approval", None)
copilot_home = opts.pop("copilot_home", None)
self._settings = load_settings(
GitHubCopilotSettings,
@@ -323,7 +309,6 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
model=model,
timeout=timeout,
log_level=log_level,
copilot_home=copilot_home,
env_file_path=env_file_path,
env_file_encoding=env_file_encoding,
)
@@ -333,7 +318,6 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
self._function_approval_handler: FunctionApprovalCallback | None = on_function_approval
self._mcp_servers = mcp_servers
self._provider = provider
self._instruction_directories = instruction_directories
self._default_options = opts
self._started = False
@@ -362,13 +346,10 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
if self._client is None:
cli_path = self._settings.get("cli_path") or None
log_level = self._settings.get("log_level") or None
copilot_home = self._settings.get("copilot_home") or None
subprocess_kwargs: dict[str, Any] = {"cli_path": cli_path}
if log_level:
subprocess_kwargs["log_level"] = log_level
if copilot_home:
subprocess_kwargs["copilot_home"] = copilot_home
self._client = CopilotClient(SubprocessConfig(**subprocess_kwargs))
try:
@@ -542,14 +523,13 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
# send_and_wait returns only the final ASSISTANT_MESSAGE event;
# other events (deltas, tool calls) are handled internally by the SDK.
if response_event and response_event.type == SessionEventType.ASSISTANT_MESSAGE:
data: Any = response_event.data
message_id = data.message_id
message_id = response_event.data.message_id
if data.content:
if response_event.data.content:
response_messages.append(
Message(
role="assistant",
contents=[Content.from_text(data.content)],
contents=[Content.from_text(response_event.data.content)],
message_id=message_id,
raw_representation=response_event,
)
@@ -623,13 +603,12 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
def event_handler(event: SessionEvent) -> None:
if event.type == SessionEventType.ASSISTANT_MESSAGE_DELTA:
data: Any = event.data
if data.delta_content:
if event.data.delta_content:
update = AgentResponseUpdate(
role="assistant",
contents=[Content.from_text(data.delta_content)],
response_id=data.message_id,
message_id=data.message_id,
contents=[Content.from_text(event.data.delta_content)],
response_id=event.data.message_id,
message_id=event.data.message_id,
raw_representation=event,
)
queue.put_nowait(update)
@@ -673,8 +652,7 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
elif event.type == SessionEventType.SESSION_IDLE:
queue.put_nowait(None)
elif event.type == SessionEventType.SESSION_ERROR:
error_data: Any = event.data
error_msg = error_data.message or "Unknown error"
error_msg = event.data.message or "Unknown error"
queue.put_nowait(AgentException(f"GitHub Copilot session error: {error_msg}"))
unsubscribe = copilot_session.on(event_handler)
@@ -860,7 +838,7 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
try:
if agent_session.service_session_id:
return await self._resume_session(agent_session.service_session_id, streaming, runtime_options)
return await self._resume_session(agent_session.service_session_id, streaming)
session = await self._create_session(streaming, runtime_options)
agent_session.service_session_id = session.session_id
@@ -890,7 +868,6 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
)
mcp_servers = opts.get("mcp_servers") or self._mcp_servers or None
provider = opts.get("provider") or self._provider or None
instruction_directories = opts.get("instruction_directories", self._instruction_directories)
tools = self._prepare_tools(self._tools) if self._tools else None
return await self._client.create_session(
@@ -901,46 +878,23 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
tools=tools or None,
mcp_servers=mcp_servers or None,
provider=provider or None,
instruction_directories=instruction_directories,
)
async def _resume_session(
self,
session_id: str,
streaming: bool,
runtime_options: dict[str, Any] | None = None,
) -> CopilotSession:
"""Resume an existing Copilot session by ID.
Args:
session_id: The session ID to resume.
streaming: Whether to enable streaming for the session.
runtime_options: Runtime options that take precedence over default_options.
"""
async def _resume_session(self, session_id: str, streaming: bool) -> CopilotSession:
"""Resume an existing Copilot session by ID."""
if not self._client:
raise RuntimeError("GitHub Copilot client not initialized. Call start() first.")
opts = runtime_options or {}
model = opts.get("model") or self._settings.get("model") or None
system_message = opts.get("system_message") or self._default_options.get("system_message") or None
permission_handler: PermissionHandlerType = (
opts.get("on_permission_request") or self._permission_handler or _deny_all_permissions
)
mcp_servers = opts.get("mcp_servers") or self._mcp_servers or None
provider = opts.get("provider") or self._provider or None
instruction_directories = opts.get("instruction_directories", self._instruction_directories)
permission_handler: PermissionHandlerType = self._permission_handler or _deny_all_permissions
tools = self._prepare_tools(self._tools) if self._tools else None
return await self._client.resume_session(
session_id,
on_permission_request=permission_handler,
streaming=streaming,
model=model or None,
system_message=system_message or None,
tools=tools or None,
mcp_servers=mcp_servers or None,
provider=provider or None,
instruction_directories=instruction_directories,
mcp_servers=self._mcp_servers or None,
provider=self._provider or None,
)
@@ -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.0b260507"
version = "1.0.0b260429"
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.3.0,<2",
"github-copilot-sdk>=1.0.0b2,<=1.0.0b2; python_version >= '3.11'",
"agent-framework-core>=1.2.2,<2",
"github-copilot-sdk>=0.2.1,<=0.2.1; python_version >= '3.11'",
]
[tool.uv]
@@ -22,13 +22,7 @@ from agent_framework import (
Message,
)
from agent_framework.exceptions import AgentException
from copilot.generated.session_events import (
Data,
SessionEvent,
SessionEventType,
ToolExecutionCompleteError,
ToolExecutionCompleteResult,
)
from copilot.generated.session_events import Data, ErrorClass, Result, SessionEvent, SessionEventType
from copilot.tools import ToolInvocation, ToolResult
from agent_framework_github_copilot import GitHubCopilotAgent, GitHubCopilotOptions
@@ -218,18 +212,6 @@ class TestGitHubCopilotAgentInit:
opts["model"] = "mutated"
assert agent._settings.get("model") == "gpt-5.1-mini"
def test_init_stores_instruction_directories(self) -> None:
"""Test that instruction_directories are stored on the agent instance."""
agent: GitHubCopilotAgent[GitHubCopilotOptions] = GitHubCopilotAgent(
default_options={"instruction_directories": ["/my/instructions"]}
)
assert agent._instruction_directories == ["/my/instructions"] # type: ignore
def test_init_without_instruction_directories(self) -> None:
"""Test that instruction_directories default to None when not provided."""
agent = GitHubCopilotAgent()
assert agent._instruction_directories is None # type: ignore
class TestGitHubCopilotAgentLifecycle:
"""Test cases for agent lifecycle management."""
@@ -312,50 +294,6 @@ class TestGitHubCopilotAgentLifecycle:
assert call_args.cli_path == "/custom/path"
assert call_args.log_level == "debug"
async def test_start_passes_copilot_home_to_subprocess_config(self) -> None:
"""Test that copilot_home is passed through to SubprocessConfig."""
with patch("agent_framework_github_copilot._agent.CopilotClient") as MockClient:
mock_client = MagicMock()
mock_client.start = AsyncMock()
MockClient.return_value = mock_client
agent: GitHubCopilotAgent[GitHubCopilotOptions] = GitHubCopilotAgent(
default_options={"copilot_home": "/custom/copilot/home"}
)
await agent.start()
call_args = MockClient.call_args[0][0]
assert call_args.copilot_home == "/custom/copilot/home"
async def test_start_copilot_home_not_set_when_unspecified(self) -> None:
"""Test that copilot_home is not included in SubprocessConfig when not specified."""
with patch("agent_framework_github_copilot._agent.CopilotClient") as MockClient:
mock_client = MagicMock()
mock_client.start = AsyncMock()
MockClient.return_value = mock_client
agent = GitHubCopilotAgent()
await agent.start()
call_args = MockClient.call_args[0][0]
assert call_args.copilot_home is None
async def test_start_copilot_home_from_env_variable(self) -> None:
"""Test that copilot_home can be set via GITHUB_COPILOT_COPILOT_HOME env variable."""
with (
patch("agent_framework_github_copilot._agent.CopilotClient") as MockClient,
patch.dict("os.environ", {"GITHUB_COPILOT_COPILOT_HOME": "/env/copilot/home"}),
):
mock_client = MagicMock()
mock_client.start = AsyncMock()
MockClient.return_value = mock_client
agent = GitHubCopilotAgent()
await agent.start()
call_args = MockClient.call_args[0][0]
assert call_args.copilot_home == "/env/copilot/home"
class TestGitHubCopilotAgentRun:
"""Test cases for run method."""
@@ -599,7 +537,7 @@ class TestGitHubCopilotAgentRunStreaming:
"""Test that TOOL_EXECUTION_COMPLETE events produce function_result content."""
tool_event_data = MagicMock()
tool_event_data.tool_call_id = "call_abc123"
tool_event_data.result = ToolExecutionCompleteResult(content="Sunny, 72°F")
tool_event_data.result = Result(content="Sunny, 72°F")
tool_event_data.success = True
tool_event_data.error = None
@@ -714,9 +652,9 @@ class TestGitHubCopilotAgentRunStreaming:
"""Test that a failed tool result surfaces the error as exception."""
tool_event_data = MagicMock()
tool_event_data.tool_call_id = "call_fail"
tool_event_data.result = ToolExecutionCompleteResult(content="Error: connection timeout")
tool_event_data.result = Result(content="Error: connection timeout")
tool_event_data.success = False
tool_event_data.error = ToolExecutionCompleteError(message="connection timeout")
tool_event_data.error = ErrorClass(message="connection timeout")
tool_event = SessionEvent(
data=tool_event_data,
@@ -753,7 +691,7 @@ class TestGitHubCopilotAgentRunStreaming:
"""Test that a failed tool result with a string error is surfaced."""
tool_event_data = MagicMock()
tool_event_data.tool_call_id = "call_fail2"
tool_event_data.result = ToolExecutionCompleteResult(content="")
tool_event_data.result = Result(content="")
tool_event_data.success = False
tool_event_data.error = "something went wrong"
@@ -791,7 +729,7 @@ class TestGitHubCopilotAgentRunStreaming:
"""Test that a successful tool result with error field does not propagate exception."""
tool_event_data = MagicMock()
tool_event_data.tool_call_id = "call_ok"
tool_event_data.result = ToolExecutionCompleteResult(content="partial result")
tool_event_data.result = Result(content="partial result")
tool_event_data.success = True
tool_event_data.error = "some warning"
@@ -879,7 +817,7 @@ class TestGitHubCopilotAgentRunStreaming:
# Tool result event
result_data = MagicMock()
result_data.tool_call_id = "call_001"
result_data.result = ToolExecutionCompleteResult(content="72°F and sunny")
result_data.result = Result(content="72°F and sunny")
result_data.success = True
result_data.error = None
tool_result_event = SessionEvent(
@@ -944,12 +882,9 @@ class TestGitHubCopilotAgentSessionManagement:
mock_session.session_id,
on_permission_request=unittest.mock.ANY,
streaming=unittest.mock.ANY,
model=unittest.mock.ANY,
system_message=unittest.mock.ANY,
tools=unittest.mock.ANY,
mcp_servers=unittest.mock.ANY,
provider=unittest.mock.ANY,
instruction_directories=unittest.mock.ANY,
)
async def test_session_config_includes_model(
@@ -1081,100 +1016,6 @@ class TestGitHubCopilotAgentSessionManagement:
assert "tools" in config
assert "on_permission_request" in config
async def test_instruction_directories_passed_to_create_session(
self,
mock_client: MagicMock,
mock_session: MagicMock,
) -> None:
"""Test that instruction_directories are passed through to create_session."""
agent: GitHubCopilotAgent[GitHubCopilotOptions] = GitHubCopilotAgent(
client=mock_client,
default_options={"instruction_directories": ["/path/to/instructions", "/other/path"]},
)
await agent.start()
await agent._get_or_create_session(AgentSession()) # type: ignore
call_args = mock_client.create_session.call_args
config = call_args.kwargs
assert config["instruction_directories"] == ["/path/to/instructions", "/other/path"]
async def test_instruction_directories_runtime_override(
self,
mock_client: MagicMock,
mock_session: MagicMock,
) -> None:
"""Test that runtime instruction_directories take precedence over defaults."""
agent: GitHubCopilotAgent[GitHubCopilotOptions] = GitHubCopilotAgent(
client=mock_client,
default_options={"instruction_directories": ["/default/path"]},
)
await agent.start()
runtime_options: GitHubCopilotOptions = {"instruction_directories": ["/runtime/path"]}
await agent._get_or_create_session(AgentSession(), runtime_options=runtime_options) # type: ignore
call_args = mock_client.create_session.call_args
config = call_args.kwargs
assert config["instruction_directories"] == ["/runtime/path"]
async def test_instruction_directories_none_when_not_specified(
self,
mock_client: MagicMock,
mock_session: MagicMock,
) -> None:
"""Test that instruction_directories is None when not specified."""
agent = GitHubCopilotAgent(client=mock_client)
await agent.start()
await agent._get_or_create_session(AgentSession()) # type: ignore
call_args = mock_client.create_session.call_args
config = call_args.kwargs
assert config["instruction_directories"] is None
async def test_instruction_directories_empty_list_clears_defaults(
self,
mock_client: MagicMock,
mock_session: MagicMock,
) -> None:
"""Test that an explicit empty list at runtime clears the agent-level defaults."""
agent: GitHubCopilotAgent[GitHubCopilotOptions] = GitHubCopilotAgent(
client=mock_client,
default_options={"instruction_directories": ["/default/path"]},
)
await agent.start()
runtime_options: GitHubCopilotOptions = {"instruction_directories": []}
await agent._get_or_create_session(AgentSession(), runtime_options=runtime_options) # type: ignore
call_args = mock_client.create_session.call_args
config = call_args.kwargs
assert config["instruction_directories"] == []
async def test_instruction_directories_override_on_resumed_session(
self,
mock_client: MagicMock,
mock_session: MagicMock,
) -> None:
"""Test that instruction_directories override works on resumed sessions."""
agent: GitHubCopilotAgent[GitHubCopilotOptions] = GitHubCopilotAgent(
client=mock_client,
default_options={"instruction_directories": ["/default/path"]},
)
await agent.start()
# Simulate a session that already has a service_session_id (resume path)
session = AgentSession()
session.service_session_id = "existing-session-id"
runtime_options: GitHubCopilotOptions = {"instruction_directories": ["/override/path"]}
await agent._get_or_create_session(session, runtime_options=runtime_options) # type: ignore
call_args = mock_client.resume_session.call_args
config = call_args.kwargs
assert config["instruction_directories"] == ["/override/path"]
class TestGitHubCopilotAgentMCPServers:
"""Test cases for MCP server configuration."""
+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.0b260507"
version = "1.0.0b260501"
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.3.0,<2",
"agent-framework-core>=1.2.2,<2",
"hyperlight-sandbox>=0.4.0,<0.5",
"hyperlight-sandbox-backend-wasm>=0.4.0,<0.5 ; ((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.4.0,<0.5",
+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.0b260507"
version = "1.0.0b260429"
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.3.0,<2",
"agent-framework-core>=1.2.2,<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.0b260507"
version = "1.0.0b260429"
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.3.0,<2",
"agent-framework-core>=1.2.2,<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.0b260507"
version = "1.0.0b260429"
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.3.0,<2",
"agent-framework-core>=1.2.2,<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.3.0"
version = "1.2.2"
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.3.0,<2",
"agent-framework-core>=1.2.2,<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.0b260507"
version = "1.0.0b260429"
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.3.0,<2",
"agent-framework-core>=1.2.2,<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.0b260507"
version = "1.0.0b260429"
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.3.0,<2",
"agent-framework-core>=1.2.2,<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.0b260507"
version = "1.0.0b260429"
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.3.0,<2",
"agent-framework-core>=1.2.2,<2",
"redis>=6.4.0,<7.2.1",
"redisvl>=0.11.0,<0.16",
"numpy>=2.2.6,<3"
+2 -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.3.0"
version = "1.2.2"
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.3.0",
"agent-framework-core[all]==1.2.2",
]
[dependency-groups]
@@ -23,7 +23,6 @@ The following environment variables can be configured:
| `GITHUB_COPILOT_MODEL` | Model to use (e.g., "gpt-5", "claude-sonnet-4") | Server default |
| `GITHUB_COPILOT_TIMEOUT` | Request timeout in seconds | `60` |
| `GITHUB_COPILOT_LOG_LEVEL` | CLI log level | `info` |
| `GITHUB_COPILOT_COPILOT_HOME` | Directory for CLI session state and config | `~/.copilot` |
## Observability
@@ -51,5 +50,4 @@ See the [observability samples](../../../02-agents/observability/) for full exam
| [`github_copilot_with_file_operations.py`](github_copilot_with_file_operations.py) | Shows how to enable file read and write permissions. Demonstrates reading file contents and creating new files. |
| [`github_copilot_with_url.py`](github_copilot_with_url.py) | Shows how to enable URL fetching permissions. Demonstrates fetching and processing web content. |
| [`github_copilot_with_mcp.py`](github_copilot_with_mcp.py) | Shows how to configure MCP (Model Context Protocol) servers, including local (stdio) and remote (HTTP) servers. |
| [`github_copilot_with_instruction_directories.py`](github_copilot_with_instruction_directories.py) | Shows how to configure custom instruction directories for project-specific or team-shared guidelines. |
| [`github_copilot_with_multiple_permissions.py`](github_copilot_with_multiple_permissions.py) | Shows how to combine multiple permission types for complex tasks that require shell, read, and write access. |
@@ -1,137 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
"""
GitHub Copilot Agent with Instruction Directories
This sample demonstrates how to configure custom instruction directories with
GitHubCopilotAgent. Instruction directories let the CLI load project-specific
or team-shared instruction files that shape the agent's behavior beyond the
default system message.
Use cases:
- Point the agent at a team-shared set of coding conventions.
- Load project-specific guidelines from a local `.copilot/instructions/` folder.
- Override or augment default instructions per session at runtime.
Environment variables (optional):
- GITHUB_COPILOT_CLI_PATH - Path to the Copilot CLI executable
- GITHUB_COPILOT_MODEL - Model to use (e.g., "gpt-5", "claude-sonnet-4")
"""
import asyncio
from pathlib import Path
from agent_framework.github import GitHubCopilotAgent
from copilot.generated.session_events import PermissionRequest
from copilot.session import PermissionRequestResult
from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv()
def prompt_permission(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult:
"""Permission handler that prompts the user for approval."""
print(f"\n[Permission Request: {request.kind}]")
response = input("Approve? (y/n): ").strip().lower()
if response in ("y", "yes"):
return PermissionRequestResult(kind="approved")
return PermissionRequestResult(kind="denied-interactively-by-user")
async def default_instructions_example() -> None:
"""Example of pointing the agent at project-specific instruction directories."""
print("=== Instruction Directories (Default) ===\n")
# 1. Define instruction directories.
# These paths contain custom instruction files the CLI will load
# alongside its built-in instructions.
project_root = Path.cwd()
instruction_dirs = [
str(project_root / ".copilot" / "instructions"),
str(project_root / "docs" / "agent-guidelines"),
]
# 2. Create the agent with instruction directories in default_options.
# These directories apply to every session created by this agent.
agent = GitHubCopilotAgent(
instructions="You are a helpful coding assistant.",
default_options={
"on_permission_request": prompt_permission,
"instruction_directories": instruction_dirs,
},
)
# 3. Run the agent — instruction files from those directories are loaded
# automatically by the CLI when the session starts.
async with agent:
query = "Summarize the coding conventions I should follow in this project."
print(f"User: {query}")
result = await agent.run(query)
print(f"Agent: {result}\n")
async def runtime_override_example() -> None:
"""Example of overriding instruction directories at runtime."""
print("=== Instruction Directories (Runtime Override) ===\n")
agent = GitHubCopilotAgent(
instructions="You are a helpful assistant.",
default_options={
"on_permission_request": prompt_permission,
"instruction_directories": ["/team/shared/instructions"],
},
)
async with agent:
# First call uses the default instruction directories
query = "What instructions are you following?"
print(f"User: {query}")
result1 = await agent.run(query)
print(f"Agent: {result1}\n")
# Second call overrides with different instruction directories at runtime.
# Runtime options take precedence over the defaults for that session.
print("Overriding with project-specific instructions...\n")
query2 = "Now what instructions are you following?"
print(f"User: {query2}")
result2 = await agent.run(
query2,
options={
"instruction_directories": ["/project/specific/instructions"],
},
)
print(f"Agent: {result2}\n")
async def main() -> None:
print("=== GitHub Copilot Agent with Instruction Directories ===\n")
await default_instructions_example()
await runtime_override_example()
if __name__ == "__main__":
asyncio.run(main())
"""
Sample output:
=== GitHub Copilot Agent with Instruction Directories ===
=== Instruction Directories (Default) ===
User: Summarize the coding conventions I should follow in this project.
Agent: Based on the project instructions, you should follow these conventions...
=== Instruction Directories (Runtime Override) ===
User: What instructions are you following?
Agent: I'm following the team-shared coding guidelines which include...
Overriding with project-specific instructions...
User: Now what instructions are you following?
Agent: I'm now following the project-specific instructions which include...
"""
+37 -37
View File
@@ -104,7 +104,7 @@ wheels = [
[[package]]
name = "agent-framework"
version = "1.3.0"
version = "1.2.2"
source = { virtual = "." }
dependencies = [
{ name = "agent-framework-core", extra = ["all"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -159,7 +159,7 @@ dev = [
[[package]]
name = "agent-framework-a2a"
version = "1.0.0b260507"
version = "1.0.0b260429"
source = { editable = "packages/a2a" }
dependencies = [
{ name = "a2a-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -174,7 +174,7 @@ requires-dist = [
[[package]]
name = "agent-framework-ag-ui"
version = "1.0.0b260507"
version = "1.0.0b260429"
source = { editable = "packages/ag-ui" }
dependencies = [
{ name = "ag-ui-protocol", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -202,7 +202,7 @@ provides-extras = ["dev"]
[[package]]
name = "agent-framework-anthropic"
version = "1.0.0b260507"
version = "1.0.0b260429"
source = { editable = "packages/anthropic" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -217,7 +217,7 @@ requires-dist = [
[[package]]
name = "agent-framework-azure-ai-search"
version = "1.0.0b260507"
version = "1.0.0b260429"
source = { editable = "packages/azure-ai-search" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -232,7 +232,7 @@ requires-dist = [
[[package]]
name = "agent-framework-azure-contentunderstanding"
version = "1.0.0a260507"
version = "1.0.0a260429"
source = { editable = "packages/azure-contentunderstanding" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -253,7 +253,7 @@ requires-dist = [
[[package]]
name = "agent-framework-azure-cosmos"
version = "1.0.0b260507"
version = "1.0.0b260429"
source = { editable = "packages/azure-cosmos" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -268,7 +268,7 @@ requires-dist = [
[[package]]
name = "agent-framework-azurefunctions"
version = "1.0.0b260507"
version = "1.0.0b260429"
source = { editable = "packages/azurefunctions" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -290,7 +290,7 @@ dev = []
[[package]]
name = "agent-framework-bedrock"
version = "1.0.0b260507"
version = "1.0.0b260429"
source = { editable = "packages/bedrock" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -307,7 +307,7 @@ requires-dist = [
[[package]]
name = "agent-framework-chatkit"
version = "1.0.0b260507"
version = "1.0.0b260429"
source = { editable = "packages/chatkit" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -322,7 +322,7 @@ requires-dist = [
[[package]]
name = "agent-framework-claude"
version = "1.0.0b260507"
version = "1.0.0b260429"
source = { editable = "packages/claude" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -337,7 +337,7 @@ requires-dist = [
[[package]]
name = "agent-framework-copilotstudio"
version = "1.0.0b260507"
version = "1.0.0b260429"
source = { editable = "packages/copilotstudio" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -352,7 +352,7 @@ requires-dist = [
[[package]]
name = "agent-framework-core"
version = "1.3.0"
version = "1.2.2"
source = { editable = "packages/core" }
dependencies = [
{ name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -426,7 +426,7 @@ provides-extras = ["all"]
[[package]]
name = "agent-framework-declarative"
version = "1.0.0b260507"
version = "1.0.0b260429"
source = { editable = "packages/declarative" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -453,7 +453,7 @@ dev = [{ name = "types-pyyaml", specifier = "==6.0.12.20250915" }]
[[package]]
name = "agent-framework-devui"
version = "1.0.0b260507"
version = "1.0.0b260429"
source = { editable = "packages/devui" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -491,7 +491,7 @@ provides-extras = ["dev", "all"]
[[package]]
name = "agent-framework-durabletask"
version = "1.0.0b260507"
version = "1.0.0b260429"
source = { editable = "packages/durabletask" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -518,7 +518,7 @@ dev = [{ name = "types-python-dateutil", specifier = "==2.9.0.20260402" }]
[[package]]
name = "agent-framework-foundry"
version = "1.3.0"
version = "1.2.2"
source = { editable = "packages/foundry" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -537,7 +537,7 @@ requires-dist = [
[[package]]
name = "agent-framework-foundry-hosting"
version = "1.0.0a260507"
version = "1.0.0a260429"
source = { editable = "packages/foundry_hosting" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -556,7 +556,7 @@ requires-dist = [
[[package]]
name = "agent-framework-foundry-local"
version = "1.0.0b260507"
version = "1.0.0b260429"
source = { editable = "packages/foundry_local" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -573,7 +573,7 @@ requires-dist = [
[[package]]
name = "agent-framework-gemini"
version = "1.0.0a260507"
version = "1.0.0a260429"
source = { editable = "packages/gemini" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -588,7 +588,7 @@ requires-dist = [
[[package]]
name = "agent-framework-github-copilot"
version = "1.0.0b260507"
version = "1.0.0b260429"
source = { editable = "packages/github_copilot" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -598,12 +598,12 @@ dependencies = [
[package.metadata]
requires-dist = [
{ name = "agent-framework-core", editable = "packages/core" },
{ name = "github-copilot-sdk", marker = "python_full_version >= '3.11'", specifier = "<=1.0.0b2,>=1.0.0b2" },
{ name = "github-copilot-sdk", marker = "python_full_version >= '3.11'", specifier = ">=0.2.1,<=0.2.1" },
]
[[package]]
name = "agent-framework-hyperlight"
version = "1.0.0b260507"
version = "1.0.0b260501"
source = { editable = "packages/hyperlight" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -622,7 +622,7 @@ requires-dist = [
[[package]]
name = "agent-framework-lab"
version = "1.0.0b260507"
version = "1.0.0b260429"
source = { editable = "packages/lab" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -703,7 +703,7 @@ dev = [
[[package]]
name = "agent-framework-mem0"
version = "1.0.0b260507"
version = "1.0.0b260429"
source = { editable = "packages/mem0" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -718,7 +718,7 @@ requires-dist = [
[[package]]
name = "agent-framework-ollama"
version = "1.0.0b260507"
version = "1.0.0b260429"
source = { editable = "packages/ollama" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -733,7 +733,7 @@ requires-dist = [
[[package]]
name = "agent-framework-openai"
version = "1.3.0"
version = "1.2.2"
source = { editable = "packages/openai" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -748,7 +748,7 @@ requires-dist = [
[[package]]
name = "agent-framework-orchestrations"
version = "1.0.0b260507"
version = "1.0.0b260429"
source = { editable = "packages/orchestrations" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -759,7 +759,7 @@ requires-dist = [{ name = "agent-framework-core", editable = "packages/core" }]
[[package]]
name = "agent-framework-purview"
version = "1.0.0b260507"
version = "1.0.0b260429"
source = { editable = "packages/purview" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -776,7 +776,7 @@ requires-dist = [
[[package]]
name = "agent-framework-redis"
version = "1.0.0b260507"
version = "1.0.0b260429"
source = { editable = "packages/redis" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -2514,19 +2514,19 @@ wheels = [
[[package]]
name = "github-copilot-sdk"
version = "1.0.0b2"
version = "0.2.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic", marker = "(python_full_version >= '3.11' and sys_platform == 'darwin') or (python_full_version >= '3.11' and sys_platform == 'linux') or (python_full_version >= '3.11' and sys_platform == 'win32')" },
{ name = "python-dateutil", marker = "(python_full_version >= '3.11' and sys_platform == 'darwin') or (python_full_version >= '3.11' and sys_platform == 'linux') or (python_full_version >= '3.11' and sys_platform == 'win32')" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/82/fe/2cb98d4b9f57f8062ea72775bde72aed1958305016753f7296398e0ceb45/github_copilot_sdk-1.0.0b2-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:1b5941d8b6e3d94d42a5bec6607a26f562e6535d5c981089d23d3d224b94601c", size = 67061619, upload-time = "2026-05-06T20:02:08.636Z" },
{ url = "https://files.pythonhosted.org/packages/57/45/76567821b2d36f81e6bca78c98d265e2762733f765fa51d69602b7f81867/github_copilot_sdk-1.0.0b2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b8f6a087a0cf02bb0d33976e8f8c009578d84d701a0b28d52051304791ac70", size = 63790955, upload-time = "2026-05-06T20:02:12.354Z" },
{ url = "https://files.pythonhosted.org/packages/15/67/684b0da0b1207a2bdf025c22ee075d34a1736d61a4973651035d4fd4d8dc/github_copilot_sdk-1.0.0b2-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:f403638c11b82bddb81c94675fc4e8014a1bb2e86a679a39fa167dcc3ad5416a", size = 69538664, upload-time = "2026-05-06T20:02:16.363Z" },
{ url = "https://files.pythonhosted.org/packages/57/1d/80d88ecf83683535d1a16d4817f1683db3b125f52a924ebdfe9764f5e4c3/github_copilot_sdk-1.0.0b2-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:433d16bb31171fee8d3a5b70259c527f63b297e83a8f8761ae1f16f14d641f32", size = 68163648, upload-time = "2026-05-06T20:02:21.139Z" },
{ url = "https://files.pythonhosted.org/packages/32/d3/b72aa2fbb3194b50b53e8cb1484f5606a1f8eedcdb0bfb5747da52079553/github_copilot_sdk-1.0.0b2-py3-none-win_amd64.whl", hash = "sha256:a6e9782dae4c3c2ab3527b45bb5de0f61998104c10e9ff64698280eaf37ab5dd", size = 62649144, upload-time = "2026-05-06T20:02:24.953Z" },
{ url = "https://files.pythonhosted.org/packages/b6/e2/be95b8ea0ac11d1ca474e28a59284f4e395c2710734eadfb657f5de8ace2/github_copilot_sdk-1.0.0b2-py3-none-win_arm64.whl", hash = "sha256:2e97d0ce4bad67dc5929091cb429e7bbae7d4643e4908a6af256a41439000740", size = 60374365, upload-time = "2026-05-06T20:02:29.02Z" },
{ url = "https://files.pythonhosted.org/packages/67/41/76a9d50d7600bf8d26c659dc113be62e4e56e00a5cbfd544e1b5b200f45c/github_copilot_sdk-0.2.1-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:c0823150f3b73431f04caee43d1dbafac22ae7e8bd1fc83727ee8363089ee038", size = 61076141, upload-time = "2026-04-03T20:18:22.062Z" },
{ url = "https://files.pythonhosted.org/packages/04/04/d2e8bf4587c4da270ccb9cbd5ab8a2c4b41217c2bf04a43904be8a27ae20/github_copilot_sdk-0.2.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ef7ff68eb8960515e1a2e199ac0ffb9a17cd3325266461e6edd7290e43dcf012", size = 57838464, upload-time = "2026-04-03T20:18:26.042Z" },
{ url = "https://files.pythonhosted.org/packages/78/8b/cc8ee46724bd9fdfd6afe855a043c8403ed6884c5f3a55a9737780810396/github_copilot_sdk-0.2.1-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:890f7124e3b147532a1ac6c8d5f66421ea37757b2b9990d7967f3f147a2f533a", size = 63940155, upload-time = "2026-04-03T20:18:30.297Z" },
{ url = "https://files.pythonhosted.org/packages/cf/ee/facf04e22e42d4bdd4fe3d356f3a51180a6ea769ae2ac306d0897f9bf9d9/github_copilot_sdk-0.2.1-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:6502be0b9ececacbda671835e5f61c7aaa906c6b8657ee252cad6cc8335cac8e", size = 62130538, upload-time = "2026-04-03T20:18:34.061Z" },
{ url = "https://files.pythonhosted.org/packages/3f/1c/8b105f14bf61d1d304a00ac29460cb0d4e7406ceb89907d5a7b41a72fe85/github_copilot_sdk-0.2.1-py3-none-win_amd64.whl", hash = "sha256:8275ca8e387e6b29bc5155a3c02a0eb3d035c6bc7b1896253eb0d469f2385790", size = 56547331, upload-time = "2026-04-03T20:18:37.859Z" },
{ url = "https://files.pythonhosted.org/packages/cb/c1/0ce319d2f618e9bc89f275e60b1920f4587eb0218bba6cbb84283dc7a7f3/github_copilot_sdk-0.2.1-py3-none-win_arm64.whl", hash = "sha256:1f9b59b7c41f31be416bf20818f58e25b6adc76f6d17357653fde6fbab662606", size = 54499549, upload-time = "2026-04-03T20:18:41.77Z" },
]
[[package]]