mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d83bc07582 | ||
|
|
4b0aeb76a5 | ||
|
|
c0ea099bd8 | ||
|
|
3498f9dc66 | ||
|
|
564259a4aa | ||
|
|
3753d938f5 | ||
|
|
60cc5ee4e4 | ||
|
|
dd29f9aa65 | ||
|
|
a5f4e0078e | ||
|
|
3c0c12cd46 | ||
|
|
8dde9ef627 | ||
|
|
93cbf6b3f0 | ||
|
|
9a56bc9f16 | ||
|
|
cea83bd8d5 | ||
|
|
7ae73a68d6 |
@@ -88,6 +88,7 @@ jobs:
|
||||
env:
|
||||
COSMOSDB_ENDPOINT: https://localhost:8081
|
||||
COSMOSDB_KEY: C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==
|
||||
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
|
||||
OpenAI__ApiKey: ${{ secrets.OPENAI__APIKEY }}
|
||||
OpenAI__ChatModelId: ${{ vars.OPENAI__CHATMODELID }}
|
||||
OpenAI__ChatReasoningModelId: ${{ vars.OPENAI__CHATREASONINGMODELID }}
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
# "Cleanup artifacts", "Agent", "Prepare", and "Upload results" are check runs
|
||||
# created by an org-level GitHub App (MSDO), not by any workflow in this repo.
|
||||
# They are outside our control and their transient failures should not block merges.
|
||||
IGNORED_NAMES: "CodeQL,CodeQL analysis (csharp),Cleanup artifacts,Agent,Prepare,Upload results"
|
||||
IGNORED_NAMES: "CodeQL,CodeQL analysis (csharp),Cleanup artifacts,Agent,Prepare,Upload results,review"
|
||||
with:
|
||||
script: |
|
||||
const timeoutSeconds = Number(process.env.TIMEOUT_SECONDS);
|
||||
|
||||
@@ -206,6 +206,7 @@ temp*/
|
||||
.temp/
|
||||
|
||||
# AI
|
||||
**/.checkpoints/
|
||||
.claude/
|
||||
.omc/
|
||||
.omx/
|
||||
|
||||
@@ -1125,7 +1125,7 @@ Naming (Python): N/A (Composable Components)
|
||||
Supports: N
|
||||
Observation: No explicit middleware/filters; modularity allows composable units but no dedicated interception hooks or callbacks for custom reading/modification mid-execution.
|
||||
|
||||
For more details, see the official documentation: [Atomic Agents Docs](https://brainblend-ai.github.io/atomic-agents/). No specific code examples available for interception.
|
||||
No specific code examples available for interception.
|
||||
|
||||
#### Smolagents (Hugging Face)
|
||||
|
||||
|
||||
@@ -41,19 +41,19 @@
|
||||
<!-- Newtonsoft.Json -->
|
||||
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
<!-- System.* -->
|
||||
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="10.0.6" />
|
||||
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="10.0.8" />
|
||||
<PackageVersion Include="Microsoft.Bcl.HashCode" Version="6.0.0" />
|
||||
<PackageVersion Include="Microsoft.Bcl.Memory" Version="10.0.5" />
|
||||
<PackageVersion Include="System.ClientModel" Version="1.12.0" />
|
||||
<PackageVersion Include="System.CodeDom" Version="10.0.0" />
|
||||
<PackageVersion Include="System.Collections.Immutable" Version="10.0.1" />
|
||||
<PackageVersion Include="System.CommandLine" Version="2.0.0-rc.2.25502.107" />
|
||||
<PackageVersion Include="System.Diagnostics.DiagnosticSource" Version="10.0.6" />
|
||||
<PackageVersion Include="System.Diagnostics.DiagnosticSource" Version="10.0.8" />
|
||||
<PackageVersion Include="System.Linq.AsyncEnumerable" Version="10.0.5" />
|
||||
<PackageVersion Include="System.Net.Http.Json" Version="10.0.0" />
|
||||
<PackageVersion Include="System.Net.ServerSentEvents" Version="10.0.5" />
|
||||
<PackageVersion Include="System.Text.Json" Version="10.0.6" />
|
||||
<PackageVersion Include="System.Threading.Channels" Version="10.0.6" />
|
||||
<PackageVersion Include="System.Text.Json" Version="10.0.8" />
|
||||
<PackageVersion Include="System.Threading.Channels" Version="10.0.8" />
|
||||
<PackageVersion Include="System.Threading.Tasks.Extensions" Version="4.6.3" />
|
||||
<PackageVersion Include="System.Net.Security" Version="4.3.2" />
|
||||
<!-- OpenTelemetry -->
|
||||
@@ -72,12 +72,12 @@
|
||||
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" />
|
||||
<PackageVersion Include="Swashbuckle.AspNetCore.SwaggerUI" Version="10.0.0" />
|
||||
<!-- Microsoft.Extensions.* -->
|
||||
<PackageVersion Include="Microsoft.Extensions.AI" Version="10.5.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.Abstractions" Version="10.5.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.Evaluation" Version="10.4.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.Evaluation.Quality" Version="10.4.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.Evaluation.Safety" Version="10.3.0-preview.1.26109.11" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.OpenAI" Version="10.5.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI" Version="10.6.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.Abstractions" Version="10.6.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.Evaluation" Version="10.6.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.Evaluation.Quality" Version="10.6.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.Evaluation.Safety" Version="10.6.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.OpenAI" Version="10.6.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Compliance.Abstractions" Version="10.5.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.1" />
|
||||
@@ -86,12 +86,12 @@
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="10.0.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.6" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.8" />
|
||||
<PackageVersion Include="Microsoft.Extensions.FileSystemGlobbing" Version="10.0.6" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.6" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.8" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging.Console" Version="10.0.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.ServiceDiscovery" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.VectorData.Abstractions" Version="9.7.0" />
|
||||
|
||||
@@ -344,6 +344,9 @@
|
||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/">
|
||||
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/HostedToolbox.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox-AuthPaths/">
|
||||
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox-AuthPaths/Hosted-Toolbox-AuthPaths.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/">
|
||||
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/HostedToolboxMcpSkills.csproj" />
|
||||
</Folder>
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<!-- Central version prefix - applies to all nuget packages. -->
|
||||
<VersionPrefix>1.9.0</VersionPrefix>
|
||||
<VersionPrefix>1.10.0</VersionPrefix>
|
||||
<RCNumber>1</RCNumber>
|
||||
<DateSuffix>260603</DateSuffix>
|
||||
<DateSuffix>260610</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.9.0</GitTag>
|
||||
<GitTag>1.10.0</GitTag>
|
||||
|
||||
<Configurations>Debug;Release;Publish</Configurations>
|
||||
<IsPackable>true</IsPackable>
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@
|
||||
<PackageReference Include="Azure.AI.OpenAI" Version="2.9.0-beta.1" />
|
||||
<PackageReference Include="Azure.Identity" Version="1.19.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.OpenAI" Version="1.0.0-rc4" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.4.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" Version="10.6.0" />
|
||||
<PackageReference Include="Neo4j.AgentFramework.GraphRAG" Version="0.1.0-preview.2" />
|
||||
<PackageReference Include="Neo4j.Driver" Version="5.28.0" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -109,3 +109,9 @@ For end-to-end hosted agent deployment guidance, see the [official deployment gu
|
||||
## NuGet package users
|
||||
|
||||
Use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in `HostedMcpTools.csproj` for the `PackageReference` alternative.
|
||||
|
||||
## Related samples
|
||||
|
||||
- [`Hosted-Toolbox/`](../Hosted-Toolbox/) — connects to a single Foundry Toolbox via the AF Foundry hosting bridge (`AddFoundryToolboxes` + `FoundryAITool.CreateHostedMcpToolbox`).
|
||||
- [`Hosted-Toolbox-AuthPaths/`](../Hosted-Toolbox-AuthPaths/) — same hosting bones as `Hosted-Toolbox/`, but the toolbox bundles three MCP tools each authenticated differently (key, Entra agent identity, inline `Authorization`), driven by the shared `Using-Samples/SimpleAgent/` REPL.
|
||||
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
# Azure AI Foundry project endpoint (auto-injected in hosted containers).
|
||||
AZURE_AI_PROJECT_ENDPOINT=https://<your-foundry-account>.services.ai.azure.com/api/projects/<your-project>
|
||||
|
||||
# Model deployment name. Must exist in the Foundry project above.
|
||||
AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o
|
||||
|
||||
# Name of the Foundry Toolbox you provisioned in the portal (see README.md).
|
||||
TOOLBOX_NAME=auth-paths-toolbox
|
||||
|
||||
# Agent name advertised over the wire. Must be unique if running side-by-side with
|
||||
# other Hosted-* samples (e.g. Hosted-Toolbox), otherwise the REPL client cannot
|
||||
# disambiguate which agent to chat with.
|
||||
AGENT_NAME=hosted-toolbox-auth-paths-agent
|
||||
|
||||
# Application Insights connection string (auto-injected in hosted containers; optional locally).
|
||||
# APPLICATIONINSIGHTS_CONNECTION_STRING=InstrumentationKey=...
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
# Use the official .NET 10.0 ASP.NET runtime as a parent image
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
|
||||
WORKDIR /app
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
WORKDIR /src
|
||||
COPY . .
|
||||
RUN dotnet restore
|
||||
RUN dotnet publish -c Release -o /app/publish
|
||||
|
||||
# Final stage
|
||||
FROM base AS final
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/publish .
|
||||
EXPOSE 8088
|
||||
ENV ASPNETCORE_URLS=http://+:8088
|
||||
ENTRYPOINT ["dotnet", "HostedToolboxAuthPaths.dll"]
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
# Dockerfile for contributors building from the agent-framework repository source.
|
||||
#
|
||||
# This project uses ProjectReference to the local source, which means a standard
|
||||
# multi-stage Docker build cannot resolve dependencies outside this folder.
|
||||
# Pre-publish the app targeting the container runtime and copy the output:
|
||||
#
|
||||
# dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out
|
||||
# docker build -f Dockerfile.contributor -t hosted-toolbox-auth-paths .
|
||||
# docker run --rm -p 8088:8088 \
|
||||
# -e AGENT_NAME=hosted-toolbox-auth-paths-agent \
|
||||
# -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN \
|
||||
# --env-file .env hosted-toolbox-auth-paths
|
||||
#
|
||||
# For end-users consuming the NuGet package (not ProjectReference), use the standard
|
||||
# Dockerfile which performs a full dotnet restore + publish inside the container.
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final
|
||||
WORKDIR /app
|
||||
COPY out/ .
|
||||
EXPOSE 8088
|
||||
ENV ASPNETCORE_URLS=http://+:8088
|
||||
ENTRYPOINT ["dotnet", "HostedToolboxAuthPaths.dll"]
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
|
||||
<RootNamespace>HostedToolboxAuthPaths</RootNamespace>
|
||||
<AssemblyName>HostedToolboxAuthPaths</AssemblyName>
|
||||
<NoWarn>$(NoWarn);OPENAI001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="DotNetEnv" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- For contributors: uses ProjectReference to build against local source -->
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||
<ProjectReference Include="..\Hosted_Shared_Contributor_Setup\Hosted_Shared_Contributor_Setup.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReference above
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.0.0" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.0.0" />
|
||||
</ItemGroup>
|
||||
-->
|
||||
|
||||
</Project>
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// Foundry Toolbox Auth Paths Agent — A hosted agent backed by a single Foundry Toolbox
|
||||
// that bundles MCP tools using THREE different authentication paths.
|
||||
//
|
||||
// This sample demonstrates the same hosting bones as Hosted-Toolbox/, but the toolbox
|
||||
// (provisioned by the user out-of-band) contains three MCP tool entries each authenticated
|
||||
// differently. The agent code itself is agnostic to authentication — the educational
|
||||
// surface lives in the toolbox configuration in the Foundry portal and in this sample's
|
||||
// README.md.
|
||||
//
|
||||
// Required environment variables:
|
||||
// AZURE_AI_PROJECT_ENDPOINT (local-dev) OR FOUNDRY_PROJECT_ENDPOINT (hosted runtime)
|
||||
// - Azure AI Foundry project endpoint. The Foundry hosted
|
||||
// runtime auto-injects FOUNDRY_PROJECT_ENDPOINT; locally
|
||||
// set AZURE_AI_PROJECT_ENDPOINT (the AF-repo convention).
|
||||
// TOOLBOX_NAME - Name of the Foundry Toolbox to load
|
||||
// (default: auth-paths-toolbox)
|
||||
//
|
||||
// Optional:
|
||||
// AZURE_AI_MODEL_DEPLOYMENT_NAME - Model deployment name (default: gpt-4o)
|
||||
// AGENT_NAME - Defaults to "hosted-toolbox-auth-paths-agent".
|
||||
//
|
||||
// The Foundry.Hosting package builds the toolbox proxy URL from FOUNDRY_PROJECT_ENDPOINT
|
||||
// per tools-integration-spec.md §2–§3, so the sample does not need to plumb any
|
||||
// toolbox-specific URL env var.
|
||||
//
|
||||
// NOTE: All FOUNDRY_* and AGENT_* env-var prefixes (other than the platform-injected ones
|
||||
// listed above) are reserved by the Foundry container platform and rejected by the
|
||||
// agent-create API. Use TOOLBOX_NAME, not FOUNDRY_TOOLBOX_NAME, for sample-owned config.
|
||||
|
||||
#pragma warning disable OPENAI001 // FoundryAITool.CreateHostedMcpToolbox is experimental
|
||||
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Core;
|
||||
using Azure.Identity;
|
||||
using DotNetEnv;
|
||||
using Hosted_Shared_Contributor_Setup;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
// Load .env file if present (for local development)
|
||||
Env.TraversePath().Load();
|
||||
|
||||
// Project endpoint resolution order:
|
||||
// 1. FOUNDRY_PROJECT_ENDPOINT — auto-injected by the Foundry hosted runtime.
|
||||
// 2. AZURE_AI_PROJECT_ENDPOINT — the convention developers set locally for `dotnet run`.
|
||||
// When deployed, only (1) is available; the AF-repo sample convention to set (2) at
|
||||
// deploy time fails silently because the platform reserves all FOUNDRY_* env-var names
|
||||
// and rejects them at agent-create time. Read both, prefer the platform-injected one.
|
||||
string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT")
|
||||
?? Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
|
||||
?? throw new InvalidOperationException(
|
||||
"Neither FOUNDRY_PROJECT_ENDPOINT (platform-injected in hosted runtime) " +
|
||||
"nor AZURE_AI_PROJECT_ENDPOINT (local-dev convention) is set.");
|
||||
string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o";
|
||||
string toolboxName = Environment.GetEnvironmentVariable("TOOLBOX_NAME") ?? "auth-paths-toolbox";
|
||||
string agentName = Environment.GetEnvironmentVariable("AGENT_NAME") ?? "hosted-toolbox-auth-paths-agent";
|
||||
|
||||
TokenCredential credential = new ChainedTokenCredential(
|
||||
new DevTemporaryTokenCredential(),
|
||||
new DefaultAzureCredential());
|
||||
|
||||
// Notes on toolbox wiring — there are two ways to attach a Foundry Toolbox to an agent:
|
||||
// - Server-side "baked-in" (what this sample uses): calling AddFoundryToolboxes(name)
|
||||
// below registers the toolbox with the Foundry.Hosting layer, which resolves that
|
||||
// toolbox's MCP tools once at startup and automatically makes them available to the
|
||||
// agent on every request. The agent code does nothing per request.
|
||||
// - Per-request / caller-driven (NOT used here): a client can attach a toolbox for a
|
||||
// single call by placing a FoundryAITool.CreateHostedMcpToolbox(name) marker in the
|
||||
// request body's tool list.
|
||||
// Because this sample bakes the toolbox in on the server, it uses AddFoundryToolboxes and
|
||||
// does NOT put the CreateHostedMcpToolbox marker in the agent's `tools:` array.
|
||||
AIAgent agent = new AIProjectClient(new Uri(endpoint), credential)
|
||||
.AsAIAgent(
|
||||
model: deploymentName,
|
||||
instructions: """
|
||||
You are a helpful assistant with access to several tools, each provided by a different
|
||||
upstream service authenticated through a distinct mechanism (API key, agent managed
|
||||
identity, and a literal token
|
||||
shipped with the tool definition). Pick the tool that best fits the user's question
|
||||
and explain which upstream service answered when you respond.
|
||||
""",
|
||||
name: agentName,
|
||||
description: "Hosted agent demonstrating three MCP-tool authentication paths via a Foundry Toolbox.");
|
||||
|
||||
// Tier 3 spine (WebApplication.CreateBuilder + AddFoundryResponses + MapFoundryResponses):
|
||||
// the Foundry.Hosting package auto-maps the spec-required GET /readiness probe inside
|
||||
// MapFoundryResponses (idempotent — skipped when AgentHost or the developer already
|
||||
// mapped it), so the sample stays free of platform plumbing.
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services.AddFoundryResponses(agent);
|
||||
// Pre-register the toolbox name so FoundryToolboxService resolves the foundry-toolbox://
|
||||
// marker at request time. With FOUNDRY_PROJECT_ENDPOINT injected by the platform, startup
|
||||
// MCP tools/list against the toolbox proxy is typically <100ms in-region.
|
||||
builder.Services.AddFoundryToolboxes(toolboxName);
|
||||
|
||||
var app = builder.Build();
|
||||
app.MapFoundryResponses();
|
||||
|
||||
// Contributor-only: in Development, also map the per-agent OpenAI route shape that live Foundry
|
||||
// uses so a local REPL client can target this server via AIProjectClient.AsAIAgent(Uri agentEndpoint).
|
||||
// Do not use this in production. Hosted Foundry agents only support the agent-endpoint path.
|
||||
app.MapDevTemporaryLocalAgentEndpoint();
|
||||
|
||||
app.Run();
|
||||
|
||||
// ── DevTemporaryTokenCredential ───────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="TokenCredential"/> for local Docker debugging only.
|
||||
/// Reads a pre-fetched bearer token from the <c>AZURE_BEARER_TOKEN</c> environment variable
|
||||
/// once at startup. This should NOT be used in production.
|
||||
///
|
||||
/// Generate a token on your host and pass it to the container:
|
||||
/// export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv)
|
||||
/// docker run -e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN ...
|
||||
/// </summary>
|
||||
internal sealed class DevTemporaryTokenCredential : TokenCredential
|
||||
{
|
||||
private const string EnvironmentVariable = "AZURE_BEARER_TOKEN";
|
||||
private readonly string? _token;
|
||||
|
||||
public DevTemporaryTokenCredential()
|
||||
{
|
||||
this._token = Environment.GetEnvironmentVariable(EnvironmentVariable);
|
||||
}
|
||||
|
||||
public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken)
|
||||
=> this.GetAccessToken();
|
||||
|
||||
public override ValueTask<AccessToken> GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken)
|
||||
=> new(this.GetAccessToken());
|
||||
|
||||
private AccessToken GetAccessToken()
|
||||
{
|
||||
if (string.IsNullOrEmpty(this._token) || this._token == "DefaultAzureCredential")
|
||||
{
|
||||
throw new CredentialUnavailableException($"{EnvironmentVariable} environment variable is not set.");
|
||||
}
|
||||
|
||||
return new AccessToken(this._token, DateTimeOffset.MaxValue);
|
||||
}
|
||||
}
|
||||
+197
@@ -0,0 +1,197 @@
|
||||
# Hosted Toolbox — Authentication Paths
|
||||
|
||||
A hosted Foundry agent backed by a single Foundry Toolbox that bundles MCP tools using **three different authentication paths**. The educational surface lives in the toolbox configuration (which you provision in the Foundry portal) and in this README — the agent code itself is identical to the existing [`Hosted-Toolbox/`](../Hosted-Toolbox/) sample.
|
||||
|
||||
Drive the agent interactively across the auth paths with the shared [`Using-Samples/SimpleAgent/`](../Using-Samples/SimpleAgent/) REPL client, pointed at this agent.
|
||||
|
||||
## What this sample teaches
|
||||
|
||||
| Aspect | This sample | Existing siblings |
|
||||
|---|---|---|
|
||||
| Toolbox marker pattern | `FoundryAITool.CreateHostedMcpToolbox(name)` + `AddFoundryToolboxes(name)` | Same as [`Hosted-Toolbox/`](../Hosted-Toolbox/) |
|
||||
| Tools per toolbox | **Three MCP tools, each with a different auth method** | `Hosted-Toolbox/`: typically one demo tool |
|
||||
| Consumption | Server-side (Foundry resolves the marker) | Same |
|
||||
| Client | Shared [`Using-Samples/SimpleAgent/`](../Using-Samples/SimpleAgent/) REPL, pointed at this agent | `Hosted-Toolbox/`: any client |
|
||||
|
||||
Related samples:
|
||||
- [`Hosted-Toolbox/`](../Hosted-Toolbox/) — simpler single-tool toolbox.
|
||||
- [`Hosted-McpTools/`](../Hosted-McpTools/) — contrasts client-side `McpClient` vs server-side `HostedMcpServerTool` for non-toolbox MCP servers.
|
||||
|
||||
## Authentication-path matrix
|
||||
|
||||
The sample's purpose is to enumerate every authentication path a Foundry toolbox can drive, so each path appears alongside the others. Pick the ones your scenario needs — each connection in a toolbox is independent.
|
||||
|
||||
| # | Auth method | MCP target | Connection `authType` | What flows where | When to pick this |
|
||||
|---|---|---|---|---|---|
|
||||
| 1 | **Key-based via project connection** | GitHub MCP at `https://api.githubcopilot.com/mcp` | `CustomKeys` | A PAT stored as `Authorization: Bearer <pat>` lives in the Foundry connection. The toolbox proxy reads it server-side and injects on every MCP call. | The upstream service only accepts API keys or PATs. |
|
||||
| 2 | **Microsoft Entra — agent identity** | Any Azure Cognitive Services MCP endpoint your project can reach (e.g., Language service MCP) | `AgenticIdentityToken` | Foundry mints an Entra token for the agent's own identity (`instance_identity` in the new agent object model), scoped to the connection's `audience`, and forwards it to the MCP server. The agent identity must hold the required role (typically `Cognitive Services User`) on the target resource. | Per-agent least-privilege access to Entra-protected services. Recommended default for new agents. |
|
||||
| 3 | **Inline `Authorization` (anti-pattern)** | `https://gitmcp.io/Azure/azure-rest-api-specs` | none | A literal bearer string lives on the toolbox tool entry's `authorization` field. **Do not do this in production** — there's no rotation, no secret store, no per-user identity. Shown for completeness. | Local-dev or public MCP servers that accept any (or no) bearer. |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### 0. (Path #2 only) Identify an Entra-authenticated MCP target
|
||||
|
||||
Path #2 requires an MCP server that accepts Microsoft Entra tokens. Any **Azure Cognitive Services** resource that exposes an MCP endpoint works — they all accept Entra ID tokens and gate access via standard RBAC.
|
||||
|
||||
The reference walkthrough below uses an **Azure Language service** MCP endpoint:
|
||||
|
||||
```
|
||||
https://<your-language-service>.cognitiveservices.azure.com/language/mcp?api-version=2025-11-15-preview
|
||||
```
|
||||
|
||||
Substitute any other Cognitive Services MCP endpoint you have. If your project has none, omit tool #2 from your toolbox — the remaining two paths still work.
|
||||
|
||||
#### RBAC for path #2
|
||||
|
||||
Grant the **`Cognitive Services User`** role on the target resource to the agent's instance identity. Find it on the agent ARM resource (Azure portal → your agent → JSON view) at `instance_identity.principal_id`. This is the principal the Foundry proxy uses when minting tokens for `AgenticIdentityToken` connections.
|
||||
|
||||
```powershell
|
||||
$lang = "/subscriptions/<sub>/resourceGroups/<rg>/providers/Microsoft.CognitiveServices/accounts/<lang-svc>"
|
||||
|
||||
az role assignment create `
|
||||
--assignee-object-id <agent-instance-identity-principal-id> `
|
||||
--assignee-principal-type ServicePrincipal `
|
||||
--role "Cognitive Services User" `
|
||||
--scope $lang
|
||||
```
|
||||
|
||||
Repeat for any additional Cognitive Services resources the agent identity needs to call.
|
||||
|
||||
> The RBAC grant requires `Microsoft.Authorization/roleAssignments/write` on the target scope. In many enterprise subscriptions this needs a PIM JIT activation.
|
||||
|
||||
### 1. Foundry project + Azure AI User role
|
||||
|
||||
- An active Microsoft Foundry project ([create one](https://learn.microsoft.com/en-us/azure/foundry/how-to/create-projects)).
|
||||
- The **Azure AI User** role on the project assigned to:
|
||||
- The developer (you) creating the toolbox.
|
||||
- The agent identity for tool invocation.
|
||||
|
||||
### 2. Create the project connections
|
||||
|
||||
The Entra-based connection (path #2) is not available in the Foundry portal connection wizard today. Create it via ARM REST:
|
||||
|
||||
```powershell
|
||||
$armToken = az account get-access-token --query accessToken -o tsv
|
||||
$h = @{ Authorization = "Bearer $armToken"; "Content-Type" = "application/json" }
|
||||
$proj = "/subscriptions/<sub>/resourceGroups/<rg>/providers/Microsoft.CognitiveServices/accounts/<foundry-account>/projects/<project>"
|
||||
$lang = "https://<lang-svc>.cognitiveservices.azure.com/language/mcp?api-version=2025-11-15-preview"
|
||||
|
||||
# Path 2 — agent identity
|
||||
$body2 = @{ properties = @{
|
||||
category = "RemoteTool"; target = $lang
|
||||
authType = "AgenticIdentityToken"; audience = "https://cognitiveservices.azure.com"
|
||||
isSharedToAll = $false
|
||||
}} | ConvertTo-Json -Depth 5
|
||||
az rest --method PUT --headers "Content-Type=application/json" `
|
||||
--url "https://management.azure.com$proj/connections/lang-mcp-agent-id?api-version=2025-04-01-preview" `
|
||||
--body $body2
|
||||
```
|
||||
|
||||
Connection summary:
|
||||
|
||||
| Connection name (used by the toolbox) | `category` | `authType` | `audience` |
|
||||
|---|---|---|---|
|
||||
| `github-mcp-key` | `CustomKeys` | `CustomKeys` | n/a (key value carries `Authorization: Bearer <pat>`) |
|
||||
| `lang-mcp-agent-id` | `RemoteTool` | `AgenticIdentityToken` | `https://cognitiveservices.azure.com` |
|
||||
|
||||
Path #3 (`gitmcp.io`) needs no connection — the auth lives on the toolbox tool entry itself.
|
||||
|
||||
The `audience` value is the token resource identifier of the target service — for any Cognitive Services resource it is `https://cognitiveservices.azure.com`. For other Azure services consult [Agent identity — runtime token exchange](https://learn.microsoft.com/azure/foundry/agents/concepts/agent-identity#runtime-token-exchange).
|
||||
|
||||
### 3. Create the toolbox
|
||||
|
||||
In the Foundry portal → Tools → Add Toolbox. Name it `auth-paths-toolbox` (or whatever you prefer; export the name as `TOOLBOX_NAME`). Add three MCP tool entries:
|
||||
|
||||
| Tool `server_label` | `server_url` | Auth |
|
||||
|---|---|---|
|
||||
| `github_pat` | `https://api.githubcopilot.com/mcp` | `project_connection_id: github-mcp-key` |
|
||||
| `lang_agent` | Your Language service MCP URL | `project_connection_id: lang-mcp-agent-id` |
|
||||
| `gitmcp_inline` | `https://gitmcp.io/Azure/azure-rest-api-specs` | `authorization: "Bearer demo-only-not-real"` (no `project_connection_id`) |
|
||||
|
||||
Each entry should also carry:
|
||||
|
||||
- `require_approval: never` (this sample is focused on auth, not approval flows; see [`ToolCallingApprovalHostedAgentFixture.cs`](../../../../../tests/Foundry.Hosting.IntegrationTests/Fixtures/ToolCallingApprovalHostedAgentFixture.cs) for that concern).
|
||||
- A tight `allowed_tools` list. GitHub MCP exposes ~50 tools; restrict to what you actually want the model to invoke. For example: `github_pat` → `["search_issues", "list_pull_requests"]`. **Every name in `allowed_tools` must match a real tool on the upstream server** — an unknown name (e.g., `get_issue`, which GitHub MCP does not expose) makes the whole source fail enumeration. See the partial-failure note below.
|
||||
|
||||
### Sidebar — what the toolbox-creation code looks like
|
||||
|
||||
This sample assumes the toolbox already exists; it does not provision one programmatically. For an end-to-end code example of toolbox creation from a publisher script (suitable for a CI/CD pipeline), see [`02-agents/AgentsWithFoundry/Agent_Step25_FoundryToolboxMcp/Program.cs`](../../../../02-agents/AgentsWithFoundry/Agent_Step25_FoundryToolboxMcp/Program.cs) — its `CreateSampleToolboxAsync` helper uses `AgentAdministrationClient.GetAgentToolboxes().CreateToolboxVersionAsync(...)` and is the canonical pattern.
|
||||
|
||||
## Run the agent
|
||||
|
||||
Set environment variables (or copy `.env.example` to `.env` and fill it in):
|
||||
|
||||
```powershell
|
||||
$env:AZURE_AI_PROJECT_ENDPOINT = "https://<account>.services.ai.azure.com/api/projects/<project>"
|
||||
$env:AZURE_AI_MODEL_DEPLOYMENT_NAME = "gpt-4o"
|
||||
$env:TOOLBOX_NAME = "auth-paths-toolbox"
|
||||
```
|
||||
|
||||
Locally, the `Foundry.Hosting` package reads `AZURE_AI_PROJECT_ENDPOINT` as a fallback when `FOUNDRY_PROJECT_ENDPOINT` is absent. In the hosted Foundry runtime, the platform auto-injects `FOUNDRY_PROJECT_ENDPOINT` and the package builds the toolbox proxy URL as `{FOUNDRY_PROJECT_ENDPOINT}/toolboxes/{TOOLBOX_NAME}/mcp?api-version=v1` per [`tools-integration-spec.md`](https://github.com/microsoft/AgentSchema/blob/main/specs/agents/hosted_agents/container-spec/docs/tools-integration-spec.md) §2–§3.
|
||||
|
||||
Then sign in (`az login`) and start the server:
|
||||
|
||||
```powershell
|
||||
dotnet run --tl:off
|
||||
```
|
||||
|
||||
The server logs at `http://localhost:8088/`. In Development it also maps the per-agent OpenAI route shape (`MapDevTemporaryLocalAgentEndpoint()`), so the shared `SimpleAgent` REPL client can reach it through `AsAIAgent(agentEndpoint)` — the only supported way to consume a hosted Foundry agent. In a separate terminal:
|
||||
|
||||
**Against the local dev server** (point the client at localhost; the `{project}` segment is a wildcard the server ignores):
|
||||
|
||||
```powershell
|
||||
cd ../Using-Samples/SimpleAgent
|
||||
$env:AZURE_AI_PROJECT_ENDPOINT = "http://localhost:8088/api/projects/local"
|
||||
$env:AZURE_AI_AGENT_NAME = "hosted-toolbox-auth-paths-agent"
|
||||
dotnet run --tl:off
|
||||
```
|
||||
|
||||
**Against a deployed agent** (point the client at the real project endpoint and the deployed agent name):
|
||||
|
||||
```powershell
|
||||
cd ../Using-Samples/SimpleAgent
|
||||
$env:AZURE_AI_PROJECT_ENDPOINT = "https://<account>.services.ai.azure.com/api/projects/<project>"
|
||||
$env:AZURE_AI_AGENT_NAME = "hosted-toolbox-auth-paths-agent"
|
||||
dotnet run --tl:off
|
||||
```
|
||||
|
||||
Either way the client derives the per-agent endpoint URL (`{AZURE_AI_PROJECT_ENDPOINT}/agents/{AZURE_AI_AGENT_NAME}/endpoint/protocols/openai`) and consumes the agent via `AsAIAgent(agentEndpoint)`. Run `az login` first so the client can mint a bearer token.
|
||||
|
||||
> **Parallel-run warning**: `Hosted-Toolbox/` and other `Hosted-*` samples default to the same port (8088) and the same agent name slot. Always set a unique `AGENT_NAME` (this sample defaults to `hosted-toolbox-auth-paths-agent`) and stop other hosted samples before starting this one.
|
||||
|
||||
## Sample prompts
|
||||
|
||||
One per auth path so each tool gets exercised at least once:
|
||||
|
||||
```
|
||||
List the latest 3 issues in microsoft/agent-framework. # path #1 — GitHub MCP (key)
|
||||
Detect the language of "Bonjour le monde". # path #2 — Language MCP (agent identity)
|
||||
What's the latest API version for Microsoft.CognitiveServices? # path #3 — gitmcp.io (inline Authorization)
|
||||
```
|
||||
|
||||
## Troubleshooting / partial-failure semantics
|
||||
|
||||
`AddFoundryToolboxes` resolves the toolbox at startup by listing its tools via MCP `tools/list`. This enumeration is **all-or-nothing**: if *any* single tool source fails to enumerate, the Foundry toolbox proxy returns a top-level JSON-RPC error (`-32007`) instead of a partial list, the hosting package marks the toolbox startup as failed, `/readiness` returns 503, and *every* invoke against the agent returns **HTTP 424** — even for the auth paths that are configured correctly. So one misconfigured connection or one bad `allowed_tools` entry bricks the whole agent at startup, not just at tool-call time. Get each source enumerating cleanly before deploying. Symptoms per auth path:
|
||||
|
||||
| Symptom | Likely cause |
|
||||
|---|---|
|
||||
| **All invokes return HTTP 424 ("Failed Dependency")** | One or more tool sources failed `tools/list` at startup (see all-or-nothing note above). Common causes: an `allowed_tools` name that does not exist on the upstream server, or an Entra connection whose token is rejected. Reproduce by calling the toolbox `tools/list` directly with your own token — a `-32007` top-level error names the failing source. |
|
||||
| **HTTP 401 "audience is incorrect"** | The connection's `audience` field is missing or does not match the OAuth resource identifier the target service accepts. For Cognitive Services targets, set `audience: "https://cognitiveservices.azure.com"`. |
|
||||
| **HTTP 401 / 403 "principal does not have access"** | Path #1: PAT expired or scope insufficient. Path #2: the agent's instance identity is missing the required role on the target resource. |
|
||||
| **Container reports zero tools but startup succeeded** | `FoundryToolboxService.StartAsync` caches the `tools/list` result at startup. If a connection or RBAC grant changed after the container started, force a fresh container (re-deploy the agent version) — the cache won't pick up the change until then. |
|
||||
| **HTTP 404 from a tool call** | Toolbox name mismatch (`TOOLBOX_NAME` vs the name in the portal), or the toolbox was deleted. |
|
||||
| **Server logs a warning "Neither FOUNDRY_PROJECT_ENDPOINT nor AZURE_AI_PROJECT_ENDPOINT is set; toolbox support is disabled"** | Local dev without the env var set. The agent will load with zero tools and respond as if it has none. Set `AZURE_AI_PROJECT_ENDPOINT` (local-dev fallback) or `FOUNDRY_PROJECT_ENDPOINT` to your project endpoint. |
|
||||
| **Tools appear but model never invokes them** | `instructions:` in `Program.cs` may not surface what each tool is for. Tighten the `allowed_tools` lists and rephrase prompts to mention the upstream service by name. |
|
||||
|
||||
## Region and model compatibility
|
||||
|
||||
Foundry Toolboxes have region constraints; some tool types are limited to specific models. This sample defaults to `gpt-4o`, which works in all supported regions. For the full matrix, see the [Foundry tools compatibility matrix](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/tools/toolbox#region-and-model-compatibility).
|
||||
|
||||
## Anti-pattern note for path #3
|
||||
|
||||
Inline `authorization` on a toolbox tool entry stores credentials **inside the toolbox definition**. There is no rotation, no per-user scoping, no secret-store integration. Use it only for:
|
||||
|
||||
- Public MCP servers that ignore the bearer (the `gitmcp.io` case demonstrated here).
|
||||
- Local development against a test MCP server with a throwaway token.
|
||||
|
||||
For everything else use `project_connection_id` and let the platform inject credentials.
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml
|
||||
name: hosted-toolbox-auth-paths
|
||||
displayName: "Hosted Toolbox - Authentication Paths"
|
||||
|
||||
description: >
|
||||
A hosted agent demonstrating three MCP-tool authentication paths in a single
|
||||
Foundry Toolbox: API key via project connection, Microsoft Entra agent
|
||||
identity, and inline Authorization
|
||||
(anti-pattern). The toolbox itself is
|
||||
provisioned out of band; see this sample's README for the portal walkthrough.
|
||||
|
||||
metadata:
|
||||
tags:
|
||||
- AI Agent Hosting
|
||||
- Azure AI AgentServer
|
||||
- Responses Protocol
|
||||
- Agent Framework
|
||||
- Foundry Toolbox
|
||||
- Authentication
|
||||
- MCP
|
||||
|
||||
template:
|
||||
name: hosted-toolbox-auth-paths
|
||||
kind: hosted
|
||||
protocols:
|
||||
- protocol: responses
|
||||
version: 1.0.0
|
||||
resources:
|
||||
cpu: "0.25"
|
||||
memory: 0.5Gi
|
||||
environment_variables:
|
||||
- name: AZURE_AI_MODEL_DEPLOYMENT_NAME
|
||||
value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}"
|
||||
- name: TOOLBOX_NAME
|
||||
value: "{{TOOLBOX_NAME}}"
|
||||
parameters:
|
||||
properties:
|
||||
- name: TOOLBOX_NAME
|
||||
type: string
|
||||
default: "auth-paths-toolbox"
|
||||
description: "Name of the Foundry Toolbox to load at runtime."
|
||||
resources:
|
||||
- kind: model
|
||||
id: gpt-4o
|
||||
name: AZURE_AI_MODEL_DEPLOYMENT_NAME
|
||||
- kind: toolbox
|
||||
name: "{{TOOLBOX_NAME}}"
|
||||
tools: []
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml
|
||||
kind: hosted
|
||||
name: hosted-toolbox-auth-paths
|
||||
protocols:
|
||||
- protocol: responses
|
||||
version: 1.0.0
|
||||
resources:
|
||||
cpu: "0.25"
|
||||
memory: 0.5Gi
|
||||
@@ -1,21 +1,27 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// Foundry Toolbox Agent - A hosted agent that uses Foundry Toolset MCP tools.
|
||||
// Foundry Toolbox Agent - A hosted agent that uses Foundry Toolbox MCP tools.
|
||||
//
|
||||
// Demonstrates how to register one or more Foundry toolsets so the agent can
|
||||
// Demonstrates how to register one or more Foundry toolboxes so the agent can
|
||||
// call tools provided by the Foundry platform's managed MCP proxy.
|
||||
//
|
||||
// Required environment variables:
|
||||
// AZURE_AI_PROJECT_ENDPOINT - Azure AI Foundry project endpoint
|
||||
// AZURE_AI_PROJECT_ENDPOINT (local-dev) OR FOUNDRY_PROJECT_ENDPOINT (hosted runtime)
|
||||
// - Azure AI Foundry project endpoint. The Foundry hosted
|
||||
// runtime auto-injects FOUNDRY_PROJECT_ENDPOINT; locally
|
||||
// set AZURE_AI_PROJECT_ENDPOINT.
|
||||
// AZURE_AI_MODEL_DEPLOYMENT_NAME - Model deployment name (default: gpt-4o)
|
||||
// FOUNDRY_AGENT_TOOLSET_ENDPOINT - Foundry Toolsets proxy base URL
|
||||
// (injected automatically by Foundry platform at runtime)
|
||||
//
|
||||
// Optional:
|
||||
// FOUNDRY_TOOLBOX_NAME - Name of the toolset to load (default: my-toolset)
|
||||
// FOUNDRY_AGENT_NAME - Client name reported to MCP server
|
||||
// FOUNDRY_AGENT_VERSION - Client version reported to MCP server
|
||||
// FOUNDRY_AGENT_TOOLSET_FEATURES - Feature flags sent to Foundry proxy via header
|
||||
// TOOLBOX_NAME - Name of the toolbox to load (default: my-toolbox)
|
||||
// FOUNDRY_AGENT_NAME - Client name reported to MCP server (auto-injected in hosted runtime)
|
||||
// FOUNDRY_AGENT_VERSION - Client version reported to MCP server (auto-injected in hosted runtime)
|
||||
// FOUNDRY_AGENT_TOOLSET_FEATURES - Additional Foundry-Features header flags (the mandatory
|
||||
// Toolboxes=V1Preview flag is always sent; this env var
|
||||
// appends additional flags if present).
|
||||
//
|
||||
// The Foundry.Hosting package builds the toolbox proxy URL from FOUNDRY_PROJECT_ENDPOINT
|
||||
// per tools-integration-spec.md §2–§3.
|
||||
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Core;
|
||||
@@ -28,10 +34,13 @@ using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
// Load .env file if present (for local development)
|
||||
Env.TraversePath().Load();
|
||||
|
||||
string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
|
||||
?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
|
||||
string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT")
|
||||
?? Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")
|
||||
?? throw new InvalidOperationException(
|
||||
"Neither FOUNDRY_PROJECT_ENDPOINT (platform-injected in hosted runtime) " +
|
||||
"nor AZURE_AI_PROJECT_ENDPOINT (local-dev convention) is set.");
|
||||
string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-4o";
|
||||
string toolboxName = Environment.GetEnvironmentVariable("FOUNDRY_TOOLBOX_NAME") ?? "my-toolset";
|
||||
string toolboxName = Environment.GetEnvironmentVariable("TOOLBOX_NAME") ?? "my-toolbox";
|
||||
|
||||
// Use a chained credential: try a temporary dev token first (for local Docker debugging),
|
||||
// then fall back to DefaultAzureCredential (for local dev via dotnet run / managed identity in production).
|
||||
@@ -45,12 +54,12 @@ AIAgent agent = new AIProjectClient(new Uri(endpoint), credential)
|
||||
.AsAIAgent(
|
||||
model: deploymentName,
|
||||
instructions: """
|
||||
You are a helpful assistant with access to tools provided by the Foundry Toolset.
|
||||
You are a helpful assistant with access to tools provided by the Foundry Toolbox.
|
||||
Use the available tools to answer user questions.
|
||||
If a tool is not available for a request, let the user know clearly.
|
||||
""",
|
||||
name: Environment.GetEnvironmentVariable("AGENT_NAME") ?? "hosted-toolbox-agent",
|
||||
description: "Hosted agent backed by Foundry Toolset MCP tools");
|
||||
description: "Hosted agent backed by Foundry Toolbox MCP tools");
|
||||
|
||||
// ── Build the host ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -61,8 +70,8 @@ builder.Services.AddFoundryResponses(agent);
|
||||
builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debugging only - must not be used in production.
|
||||
|
||||
// Register Foundry Toolbox: connects to the MCP proxy at startup and makes tools available.
|
||||
// The toolset name must match a toolset registered in your Foundry project.
|
||||
// When FOUNDRY_AGENT_TOOLSET_ENDPOINT is absent (e.g., in local development without Foundry
|
||||
// The toolbox name must match a toolbox registered in your Foundry project.
|
||||
// When FOUNDRY_PROJECT_ENDPOINT is absent (e.g., in local development without Foundry
|
||||
// infrastructure), startup succeeds without error and no toolbox tools are loaded.
|
||||
builder.Services.AddFoundryToolboxes(toolboxName);
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# Hosted-Toolbox
|
||||
|
||||
A hosted Foundry agent that loads tools from a Foundry Toolbox via the AF Foundry hosting bridge.
|
||||
|
||||
The agent declares one `FoundryAITool.CreateHostedMcpToolbox(name)` marker; `AddFoundryToolboxes(name)` registers a `FoundryToolboxService` that resolves the marker into the individual MCP tools the toolbox bundles, connecting to the Foundry Toolboxes MCP proxy at startup and discovering tools via `tools/list`.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A Microsoft Foundry project with a Toolbox configured.
|
||||
- Azure CLI logged in (`az login`).
|
||||
- Set environment variables:
|
||||
- `AZURE_AI_PROJECT_ENDPOINT` (local-dev) or `FOUNDRY_PROJECT_ENDPOINT` (auto-injected in hosted containers)
|
||||
- `AZURE_AI_MODEL_DEPLOYMENT_NAME` (default `gpt-4o`)
|
||||
- `TOOLBOX_NAME` (default `my-toolbox`)
|
||||
|
||||
The `Foundry.Hosting` package builds the toolbox proxy URL from `FOUNDRY_PROJECT_ENDPOINT` as `{FOUNDRY_PROJECT_ENDPOINT}/toolboxes/{TOOLBOX_NAME}/mcp?api-version=v1` per [`tools-integration-spec.md`](https://github.com/microsoft/AgentSchema/blob/main/specs/agents/hosted_agents/container-spec/docs/tools-integration-spec.md) §2–§3.
|
||||
|
||||
## Run
|
||||
|
||||
```powershell
|
||||
dotnet run --tl:off
|
||||
```
|
||||
|
||||
## Related samples
|
||||
|
||||
- [`Hosted-Toolbox-AuthPaths/`](../Hosted-Toolbox-AuthPaths/) — extends this pattern with a three-tool toolbox demonstrating different MCP-tool authentication paths (key, Entra agent identity, inline `Authorization`), driven by the shared `Using-Samples/SimpleAgent/` REPL.
|
||||
- [`Hosted-McpTools/`](../Hosted-McpTools/) — contrasts client-side `McpClient` vs server-side `HostedMcpServerTool` for non-toolbox MCP servers.
|
||||
+87
-10
@@ -1,6 +1,7 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
@@ -13,24 +14,32 @@ namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
/// <summary>
|
||||
/// An <see cref="DelegatingHandler"/> that:
|
||||
/// <list type="bullet">
|
||||
/// <item>Acquires a fresh Azure bearer token (scope: <c>https://cognitiveservices.azure.com/.default</c>) per request.</item>
|
||||
/// <item>Injects the <c>Foundry-Features</c> header from <c>FOUNDRY_AGENT_TOOLSET_FEATURES</c> when non-empty.</item>
|
||||
/// <item>Acquires a fresh Azure bearer token (scope: <c>https://ai.azure.com/.default</c>) per request, per <c>tools-integration-spec.md</c> §4.</item>
|
||||
/// <item>Always injects the mandatory <c>Foundry-Features: Toolboxes=V1Preview</c> header per spec §2, merging any additional flags from <c>FOUNDRY_AGENT_TOOLSET_FEATURES</c>.</item>
|
||||
/// <item>Propagates W3C trace context (<c>traceparent</c>, <c>tracestate</c>, <c>baggage</c>) from <see cref="Activity.Current"/> per spec §6.3.</item>
|
||||
/// <item>Retries on HTTP 429, 500, 502, and 503 with exponential back-off (max 3 attempts, per spec §7).</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
internal sealed class FoundryToolboxBearerTokenHandler : DelegatingHandler
|
||||
{
|
||||
private const int MaxRetries = 3;
|
||||
|
||||
// Per tools-integration-spec.md §4, the container authenticates to the Foundry Toolbox
|
||||
// proxy with a bearer token whose audience is https://ai.azure.com.
|
||||
private static readonly TokenRequestContext s_tokenContext =
|
||||
new(["https://cognitiveservices.azure.com/.default"]);
|
||||
new(["https://ai.azure.com/.default"]);
|
||||
|
||||
// Per tools-integration-spec.md §2, every proxy request MUST include the
|
||||
// Foundry-Features: Toolboxes=V1Preview opt-in header while the service is in preview.
|
||||
private const string MandatoryFeatureFlag = "Toolboxes=V1Preview";
|
||||
|
||||
private readonly TokenCredential _credential;
|
||||
private readonly string? _featuresHeaderValue;
|
||||
private readonly string? _additionalFeaturesHeaderValue;
|
||||
|
||||
internal FoundryToolboxBearerTokenHandler(TokenCredential credential, string? featuresHeaderValue)
|
||||
internal FoundryToolboxBearerTokenHandler(TokenCredential credential, string? additionalFeaturesHeaderValue)
|
||||
{
|
||||
this._credential = credential;
|
||||
this._featuresHeaderValue = featuresHeaderValue;
|
||||
this._additionalFeaturesHeaderValue = additionalFeaturesHeaderValue;
|
||||
}
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(
|
||||
@@ -43,10 +52,9 @@ internal sealed class FoundryToolboxBearerTokenHandler : DelegatingHandler
|
||||
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Token);
|
||||
|
||||
if (!string.IsNullOrEmpty(this._featuresHeaderValue))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation("Foundry-Features", this._featuresHeaderValue);
|
||||
}
|
||||
request.Headers.TryAddWithoutValidation("Foundry-Features", BuildFeaturesHeaderValue(this._additionalFeaturesHeaderValue));
|
||||
|
||||
PropagateTraceContext(request);
|
||||
|
||||
// MaxRetries is the total number of attempts (not additional retries after the first).
|
||||
for (int attempt = 0; attempt < MaxRetries; attempt++)
|
||||
@@ -82,6 +90,75 @@ internal sealed class FoundryToolboxBearerTokenHandler : DelegatingHandler
|
||||
throw new InvalidOperationException("Retry loop completed without returning a response.");
|
||||
}
|
||||
|
||||
// Returns "Toolboxes=V1Preview" when no override is set, or
|
||||
// "Toolboxes=V1Preview,<override-value>" when an override is set and doesn't already include it.
|
||||
internal static string BuildFeaturesHeaderValue(string? additional)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(additional))
|
||||
{
|
||||
return MandatoryFeatureFlag;
|
||||
}
|
||||
|
||||
// Avoid duplicating the mandatory flag if the override happens to already include it
|
||||
// (case-insensitive, ignore surrounding whitespace).
|
||||
foreach (var part in additional!.Split(','))
|
||||
{
|
||||
if (string.Equals(part.Trim(), MandatoryFeatureFlag, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return additional;
|
||||
}
|
||||
}
|
||||
|
||||
return $"{MandatoryFeatureFlag},{additional}";
|
||||
}
|
||||
|
||||
// Per tools-integration-spec.md §6.3, propagate W3C trace context onto outbound requests.
|
||||
// Skip headers already set on the message (callers / inner handlers may override).
|
||||
private static void PropagateTraceContext(HttpRequestMessage request)
|
||||
{
|
||||
var activity = Activity.Current;
|
||||
if (activity is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!request.Headers.Contains("traceparent"))
|
||||
{
|
||||
var traceparent = activity.Id;
|
||||
if (!string.IsNullOrEmpty(traceparent))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation("traceparent", traceparent);
|
||||
}
|
||||
}
|
||||
|
||||
var traceState = activity.TraceStateString;
|
||||
if (!string.IsNullOrEmpty(traceState) && !request.Headers.Contains("tracestate"))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation("tracestate", traceState);
|
||||
}
|
||||
|
||||
// Baggage is a comma-separated list of key=value pairs per the W3C Baggage spec.
|
||||
if (!request.Headers.Contains("baggage"))
|
||||
{
|
||||
string? baggageHeader = null;
|
||||
foreach (var pair in activity.Baggage)
|
||||
{
|
||||
if (pair.Value is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var entry = $"{Uri.EscapeDataString(pair.Key)}={Uri.EscapeDataString(pair.Value)}";
|
||||
baggageHeader = baggageHeader is null ? entry : $"{baggageHeader},{entry}";
|
||||
}
|
||||
|
||||
if (baggageHeader is not null)
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation("baggage", baggageHeader);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<HttpRequestMessage> CloneRequestAsync(
|
||||
HttpRequestMessage original,
|
||||
CancellationToken cancellationToken)
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Adapts <see cref="FoundryToolboxService.StartupStatus"/> to the AspNetCore
|
||||
/// HealthChecks pipeline so the <c>GET /readiness</c> probe (mapped by
|
||||
/// <see cref="FoundryHostingExtensions.MapFoundryResponses"/>) reflects whether
|
||||
/// pre-registered toolbox connections are usable. Registered automatically by
|
||||
/// <see cref="FoundryHostingExtensions.AddFoundryToolboxes(IServiceCollection, string[])"/>
|
||||
/// and its overloads.
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
internal sealed class FoundryToolboxHealthCheck : IHealthCheck
|
||||
{
|
||||
private readonly FoundryToolboxService _toolboxService;
|
||||
|
||||
public FoundryToolboxHealthCheck(FoundryToolboxService toolboxService)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(toolboxService);
|
||||
this._toolboxService = toolboxService;
|
||||
}
|
||||
|
||||
public Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
switch (this._toolboxService.StartupStatus)
|
||||
{
|
||||
case FoundryToolboxStartupStatus.Healthy:
|
||||
return Task.FromResult(HealthCheckResult.Healthy(
|
||||
description: $"Foundry toolbox: {this._toolboxService.Tools.Count} tool(s) available."));
|
||||
|
||||
case FoundryToolboxStartupStatus.NoEndpoint:
|
||||
return Task.FromResult(HealthCheckResult.Healthy(
|
||||
description: "Foundry toolbox: neither FOUNDRY_PROJECT_ENDPOINT nor AZURE_AI_PROJECT_ENDPOINT is set; toolbox support disabled (local dev)."));
|
||||
|
||||
case FoundryToolboxStartupStatus.Pending:
|
||||
return Task.FromResult(new HealthCheckResult(
|
||||
status: context.Registration.FailureStatus,
|
||||
description: "Foundry toolbox: startup has not completed yet."));
|
||||
|
||||
case FoundryToolboxStartupStatus.Unhealthy:
|
||||
var data = new Dictionary<string, object>(StringComparer.Ordinal)
|
||||
{
|
||||
["failedToolboxes"] = this._toolboxService.FailedToolboxNames,
|
||||
};
|
||||
return Task.FromResult(new HealthCheckResult(
|
||||
status: context.Registration.FailureStatus,
|
||||
description: $"Foundry toolbox: {this._toolboxService.FailedToolboxNames.Count} pre-registered toolbox(es) failed to open at startup.",
|
||||
data: data));
|
||||
|
||||
default:
|
||||
return Task.FromResult(new HealthCheckResult(
|
||||
status: context.Registration.FailureStatus,
|
||||
description: $"Foundry toolbox: unknown startup status '{this._toolboxService.StartupStatus}'."));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,14 +16,15 @@ public sealed class FoundryToolboxOptions
|
||||
/// Gets the list of toolbox names to connect to at startup.
|
||||
/// Each name corresponds to a toolbox registered in the Foundry project.
|
||||
/// The platform proxy URL is constructed as:
|
||||
/// <c>{FOUNDRY_AGENT_TOOLSET_ENDPOINT}/{toolboxName}/mcp?api-version={ApiVersion}</c>
|
||||
/// <c>{FOUNDRY_PROJECT_ENDPOINT}/toolboxes/{toolboxName}/mcp?api-version={ApiVersion}</c>
|
||||
/// per <c>tools-integration-spec.md</c> §2–§3.
|
||||
/// </summary>
|
||||
public IList<string> ToolboxNames { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the Toolsets API version to use when constructing proxy URLs.
|
||||
/// Gets or sets the Toolboxes API version to use when constructing proxy URLs.
|
||||
/// </summary>
|
||||
public string ApiVersion { get; set; } = "2025-05-01-preview";
|
||||
public string ApiVersion { get; set; } = "v1";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether per-request toolbox markers (referenced via
|
||||
@@ -36,7 +37,9 @@ public sealed class FoundryToolboxOptions
|
||||
public bool StrictMode { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// For testing only: overrides <c>FOUNDRY_AGENT_TOOLSET_ENDPOINT</c>.
|
||||
/// For testing only: overrides the toolbox proxy base URL (skipping the
|
||||
/// <c>FOUNDRY_PROJECT_ENDPOINT</c>-derived default). When set, the proxy URL
|
||||
/// becomes <c>{EndpointOverride}/toolboxes/{toolboxName}/mcp?api-version={ApiVersion}</c>.
|
||||
/// Not part of the public API.
|
||||
/// </summary>
|
||||
internal string? EndpointOverride { get; set; }
|
||||
|
||||
@@ -24,7 +24,13 @@ namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// When <c>FOUNDRY_AGENT_TOOLSET_ENDPOINT</c> is absent the service starts without error and
|
||||
/// The toolbox proxy base URL is derived from the platform-injected
|
||||
/// <c>FOUNDRY_PROJECT_ENDPOINT</c> environment variable per <c>tools-integration-spec.md</c>
|
||||
/// §2–§3. The per-toolbox proxy URL is constructed as
|
||||
/// <c>{FOUNDRY_PROJECT_ENDPOINT}/toolboxes/{toolboxName}/mcp?api-version={ApiVersion}</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// When <c>FOUNDRY_PROJECT_ENDPOINT</c> is absent the service starts without error and
|
||||
/// no tools are registered, keeping the container healthy per spec §2.
|
||||
/// </para>
|
||||
/// <para>
|
||||
@@ -56,6 +62,24 @@ public sealed class FoundryToolboxService : IHostedService, IAsyncDisposable
|
||||
/// </summary>
|
||||
public IReadOnlyList<AITool> Tools { get; private set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Gets the startup status of the service. Reflects the outcome of pre-registered
|
||||
/// toolbox connections opened in <see cref="StartAsync"/>; lazy-opens triggered by
|
||||
/// per-request markers do not change this value.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Consumed by <see cref="FoundryToolboxHealthCheck"/> to gate the
|
||||
/// <c>GET /readiness</c> probe so the Foundry hosted runtime does not start routing
|
||||
/// traffic to a container whose pre-registered toolbox failed to open at startup.
|
||||
/// </remarks>
|
||||
public FoundryToolboxStartupStatus StartupStatus { get; private set; } = FoundryToolboxStartupStatus.Pending;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the names of pre-registered toolboxes that failed to open during
|
||||
/// <see cref="StartAsync"/>. Empty when startup was successful or has not run yet.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> FailedToolboxNames { get; private set; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of <see cref="FoundryToolboxService"/>.
|
||||
/// </summary>
|
||||
@@ -75,16 +99,24 @@ public sealed class FoundryToolboxService : IHostedService, IAsyncDisposable
|
||||
/// <inheritdoc/>
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
this._resolvedEndpoint = this._options.EndpointOverride
|
||||
?? Environment.GetEnvironmentVariable("FOUNDRY_AGENT_TOOLSET_ENDPOINT");
|
||||
// Per tools-integration-spec.md §2-§3, the container derives the toolbox proxy base
|
||||
// URL from the platform-injected FOUNDRY_PROJECT_ENDPOINT. The EndpointOverride
|
||||
// option exists for tests; AZURE_AI_PROJECT_ENDPOINT is honored as a local-dev
|
||||
// fallback to mirror the convention used by AF-repo samples.
|
||||
var projectEndpoint = this._options.EndpointOverride
|
||||
?? Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT")
|
||||
?? Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT");
|
||||
|
||||
if (string.IsNullOrEmpty(this._resolvedEndpoint))
|
||||
if (string.IsNullOrEmpty(projectEndpoint))
|
||||
{
|
||||
this._logger.LogInformation("FOUNDRY_AGENT_TOOLSET_ENDPOINT is not set; toolbox support is disabled.");
|
||||
this._logger.LogWarning(
|
||||
"Neither FOUNDRY_PROJECT_ENDPOINT nor AZURE_AI_PROJECT_ENDPOINT is set; toolbox support is disabled.");
|
||||
this.Tools = [];
|
||||
this.StartupStatus = FoundryToolboxStartupStatus.NoEndpoint;
|
||||
return;
|
||||
}
|
||||
|
||||
this._resolvedEndpoint = projectEndpoint.TrimEnd('/');
|
||||
this._featuresHeader = Environment.GetEnvironmentVariable("FOUNDRY_AGENT_TOOLSET_FEATURES");
|
||||
this._agentName = Environment.GetEnvironmentVariable("FOUNDRY_AGENT_NAME") ?? "hosted-agent";
|
||||
this._agentVersion = Environment.GetEnvironmentVariable("FOUNDRY_AGENT_VERSION") ?? "1.0.0";
|
||||
@@ -93,10 +125,12 @@ public sealed class FoundryToolboxService : IHostedService, IAsyncDisposable
|
||||
{
|
||||
this._logger.LogInformation("No pre-registered toolbox names configured.");
|
||||
this.Tools = [];
|
||||
this.StartupStatus = FoundryToolboxStartupStatus.Healthy;
|
||||
return;
|
||||
}
|
||||
|
||||
var allTools = new List<AITool>();
|
||||
var failed = new List<string>();
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var toolboxName in this._options.ToolboxNames)
|
||||
@@ -121,10 +155,16 @@ public sealed class FoundryToolboxService : IHostedService, IAsyncDisposable
|
||||
"Failed to connect to toolbox '{ToolboxName}'. Tools from this toolbox will not be available.",
|
||||
toolboxName);
|
||||
}
|
||||
|
||||
failed.Add(toolboxName);
|
||||
}
|
||||
}
|
||||
|
||||
this.Tools = allTools;
|
||||
this.FailedToolboxNames = failed;
|
||||
this.StartupStatus = failed.Count == 0
|
||||
? FoundryToolboxStartupStatus.Healthy
|
||||
: FoundryToolboxStartupStatus.Unhealthy;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -165,7 +205,7 @@ public sealed class FoundryToolboxService : IHostedService, IAsyncDisposable
|
||||
if (string.IsNullOrEmpty(this._resolvedEndpoint))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Cannot resolve toolbox '{toolboxName}': FOUNDRY_AGENT_TOOLSET_ENDPOINT is not set.");
|
||||
$"Cannot resolve toolbox '{toolboxName}': FOUNDRY_PROJECT_ENDPOINT is not set.");
|
||||
}
|
||||
|
||||
await this._lazyOpenLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
@@ -192,7 +232,7 @@ public sealed class FoundryToolboxService : IHostedService, IAsyncDisposable
|
||||
string? version,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var proxyUrl = $"{this._resolvedEndpoint!.TrimEnd('/')}/{toolboxName}/mcp?api-version={this._options.ApiVersion}";
|
||||
var proxyUrl = $"{this._resolvedEndpoint!}/toolboxes/{toolboxName}/mcp?api-version={this._options.ApiVersion}";
|
||||
|
||||
if (this._logger.IsEnabled(LogLevel.Information))
|
||||
{
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Outcome of <see cref="FoundryToolboxService"/> startup. Drives the
|
||||
/// <c>foundry-toolbox</c> health-check that gates the <c>GET /readiness</c> probe so the
|
||||
/// Foundry hosted runtime does not start routing traffic before pre-registered toolbox
|
||||
/// connections are confirmed open (per <c>container-image-spec.md</c> §3.1).
|
||||
/// </summary>
|
||||
[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)]
|
||||
public enum FoundryToolboxStartupStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// <see cref="FoundryToolboxService.StartAsync"/> has not run yet. The health-check
|
||||
/// reports <c>Unhealthy</c> in this state so the platform waits for startup to
|
||||
/// complete before the first invocation.
|
||||
/// </summary>
|
||||
Pending = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Startup completed and either every pre-registered toolbox opened successfully or
|
||||
/// no pre-registered toolboxes were configured. The health-check reports
|
||||
/// <c>Healthy</c>.
|
||||
/// </summary>
|
||||
Healthy = 1,
|
||||
|
||||
/// <summary>
|
||||
/// One or more pre-registered toolboxes failed to open during startup (including the
|
||||
/// partial case where some opened and some did not). The health-check reports
|
||||
/// <c>Unhealthy</c> and exposes the failed names in the <c>HealthCheckResult.Data</c>
|
||||
/// dictionary so operators can diagnose the failure without parsing log output.
|
||||
/// </summary>
|
||||
Unhealthy = 2,
|
||||
|
||||
/// <summary>
|
||||
/// Neither the <c>FOUNDRY_PROJECT_ENDPOINT</c> nor the <c>AZURE_AI_PROJECT_ENDPOINT</c>
|
||||
/// environment variable is set. This is normal for local <c>dotnet run</c> flows and the
|
||||
/// health-check reports <c>Healthy</c> so the container is still routable; toolbox tools
|
||||
/// will simply not be available.
|
||||
/// </summary>
|
||||
NoEndpoint = 3,
|
||||
}
|
||||
+1
-1
@@ -13,7 +13,7 @@
|
||||
<InjectSharedDiagnosticIds>true</InjectSharedDiagnosticIds>
|
||||
<InjectExperimentalAttributeOnLegacy>true</InjectExperimentalAttributeOnLegacy>
|
||||
<InjectSharedRedaction>true</InjectSharedRedaction>
|
||||
<NoWarn>$(NoWarn);OPENAI001;MEAI001;NU1903</NoWarn> <!-- NU1903: Microsoft.Bcl.Memory 9.0.4 transitive vulnerability via Azure SDK; awaiting upstream fix -->
|
||||
<NoWarn>$(NoWarn);OPENAI001;MEAI001;MAAI001;NU1903</NoWarn> <!-- NU1903: Microsoft.Bcl.Memory 9.0.4 transitive vulnerability via Azure SDK; awaiting upstream fix -->
|
||||
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
|
||||
</PropertyGroup>
|
||||
|
||||
|
||||
@@ -7,10 +7,12 @@ using System.Runtime.CompilerServices;
|
||||
using Azure.AI.AgentServer.Responses;
|
||||
using Azure.Core;
|
||||
using Azure.Identity;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
@@ -49,6 +51,7 @@ public static class FoundryHostingExtensions
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
services.AddResponsesServer();
|
||||
services.AddHealthChecks();
|
||||
services.TryAddSingleton<AgentSessionStore>(_ => FileSystemAgentSessionStore.CreateDefault());
|
||||
services.TryAddSingleton<ResponseHandler, AgentFrameworkResponseHandler>();
|
||||
return services;
|
||||
@@ -84,6 +87,7 @@ public static class FoundryHostingExtensions
|
||||
ArgumentNullException.ThrowIfNull(agent);
|
||||
|
||||
services.AddResponsesServer();
|
||||
services.AddHealthChecks();
|
||||
agentSessionStore ??= FileSystemAgentSessionStore.CreateDefault();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(agent.Name))
|
||||
@@ -109,10 +113,10 @@ public static class FoundryHostingExtensions
|
||||
/// <para>
|
||||
/// Each string in <paramref name="toolboxNames"/> is a toolbox name registered in the Foundry
|
||||
/// project. The proxy URL per toolbox is constructed as:
|
||||
/// <c>{FOUNDRY_AGENT_TOOLSET_ENDPOINT}/{toolboxName}/mcp?api-version=2025-05-01-preview</c>
|
||||
/// <c>{FOUNDRY_PROJECT_ENDPOINT}/toolboxes/{toolboxName}/mcp?api-version=v1</c>
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// When <c>FOUNDRY_AGENT_TOOLSET_ENDPOINT</c> is absent, startup succeeds without error and
|
||||
/// When <c>FOUNDRY_PROJECT_ENDPOINT</c> is absent, startup succeeds without error and
|
||||
/// no tools are loaded (the container remains healthy per spec §2).
|
||||
/// </para>
|
||||
/// <para>
|
||||
@@ -167,12 +171,61 @@ public static class FoundryHostingExtensions
|
||||
// multiple times will not invoke StartAsync twice on the same singleton.
|
||||
services.AddHostedService(sp => sp.GetRequiredService<FoundryToolboxService>());
|
||||
|
||||
// Register the toolbox health check on the same /readiness pipeline that
|
||||
// MapFoundryResponses maps. This gates the Foundry hosted runtime's readiness
|
||||
// probe (per container-image-spec.md §3.1) on the outcome of the pre-registered
|
||||
// toolbox connections opened in FoundryToolboxService.StartAsync.
|
||||
// AddCheck<T>(name, ...) does NOT dedupe by name, so guard against duplicate
|
||||
// registration when AddFoundryToolboxes is called multiple times.
|
||||
const string HealthCheckName = "foundry-toolbox";
|
||||
services.AddHealthChecks();
|
||||
services.Configure<HealthCheckServiceOptions>(opts =>
|
||||
{
|
||||
foreach (var existing in opts.Registrations)
|
||||
{
|
||||
if (string.Equals(existing.Name, HealthCheckName, StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
opts.Registrations.Add(new HealthCheckRegistration(
|
||||
name: HealthCheckName,
|
||||
factory: sp => ActivatorUtilities.CreateInstance<FoundryToolboxHealthCheck>(sp),
|
||||
failureStatus: HealthStatus.Unhealthy,
|
||||
tags: ["foundry", "toolbox", "readiness"]));
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps the Responses API routes for the agent-framework handler to the endpoint routing pipeline.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Also maps the Foundry-required <c>GET /readiness</c> health probe to
|
||||
/// <see cref="HealthCheckEndpointRouteBuilderExtensions.MapHealthChecks(IEndpointRouteBuilder, string)"/>
|
||||
/// when no <c>/readiness</c> route is already registered. This makes the package
|
||||
/// spec-compliant in the Foundry hosted runtime (which probes <c>/readiness</c>
|
||||
/// before accepting any invocation per <c>container-image-spec.md</c> §2; without
|
||||
/// it every request fails with HTTP 424 <c>session_not_ready</c>) regardless of the
|
||||
/// host spine the developer chose:
|
||||
/// </para>
|
||||
/// <list type="bullet">
|
||||
/// <item><description><b>Tier 1/2</b> (<c>AgentHost.CreateBuilder</c>) — the Core SDK
|
||||
/// already maps <c>/readiness</c>. The duplicate-route guard below skips
|
||||
/// re-mapping it.</description></item>
|
||||
/// <item><description><b>Tier 3</b> (<c>WebApplication.CreateBuilder</c> +
|
||||
/// <c>AddFoundryResponses</c> + <c>MapFoundryResponses</c>) — the Core SDK
|
||||
/// does NOT map it. This call covers the gap automatically.</description></item>
|
||||
/// </list>
|
||||
/// <para>
|
||||
/// Developers can still opt out by registering their own <c>/readiness</c> route
|
||||
/// before calling <c>MapFoundryResponses</c>; the existing route is detected and
|
||||
/// preserved.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="endpoints">The endpoint route builder.</param>
|
||||
/// <param name="prefix">Optional route prefix (e.g., "/openai/v1"). Default: empty (routes at /responses).</param>
|
||||
/// <returns>The endpoint route builder for chaining.</returns>
|
||||
@@ -180,9 +233,37 @@ public static class FoundryHostingExtensions
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(endpoints);
|
||||
endpoints.MapResponsesServer(prefix);
|
||||
MapReadinessIfMissing(endpoints);
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps <c>GET /readiness</c> to the AspNetCore HealthChecks pipeline only when no
|
||||
/// route already serves that path. The duplicate guard scans
|
||||
/// <see cref="EndpointDataSource"/> entries by route pattern, which catches both the
|
||||
/// SDK-mapped <c>MapHealthChecks("/readiness")</c> path used by
|
||||
/// <c>AgentHostBuilder</c> and any user-registered <c>app.MapGet("/readiness", ...)</c>
|
||||
/// route. Idempotent across multiple <c>MapFoundryResponses</c> invocations.
|
||||
/// </summary>
|
||||
private static void MapReadinessIfMissing(IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
const string ReadinessPath = "/readiness";
|
||||
|
||||
foreach (var dataSource in endpoints.DataSources)
|
||||
{
|
||||
foreach (var endpoint in dataSource.Endpoints)
|
||||
{
|
||||
if (endpoint is RouteEndpoint route &&
|
||||
string.Equals(route.RoutePattern.RawText, ReadinessPath, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
endpoints.MapHealthChecks(ReadinessPath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The ActivitySource name for the Responses hosting pipeline.
|
||||
/// </summary>
|
||||
|
||||
+49
-1
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<VersionSuffix>preview</VersionSuffix>
|
||||
<IsReleaseCandidate>true</IsReleaseCandidate>
|
||||
<!-- GitHub.Copilot.SDK only supports .NET 8.0+ -->
|
||||
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
|
||||
<NoWarn>$(NoWarn);GHCP001</NoWarn>
|
||||
@@ -32,4 +32,52 @@
|
||||
<Description>Provides Microsoft Agent Framework support for GitHub Copilot SDK.</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<!--
|
||||
buildTransitive bridge for GitHub.Copilot.SDK's CLI binary-download targets.
|
||||
|
||||
GitHub.Copilot.SDK ships its CLI download targets under build/, which NuGet
|
||||
only auto-imports for projects with a DIRECT PackageReference to the SDK.
|
||||
Consumers of this package (who reference Microsoft.Agents.AI.GitHub.Copilot
|
||||
instead of GitHub.Copilot.SDK directly) would otherwise get only the managed
|
||||
adapter .dll, no copilot.exe in their output, and a runtime failure at the
|
||||
first RunAsync call.
|
||||
|
||||
The targets file in buildTransitive/ is static and contains all of the
|
||||
Condition / Import logic. The .props file generated here just bakes the
|
||||
SDK version this package was built against into a dedicated property
|
||||
($(_MicrosoftAgentsAICopilotSdkPackagedVersion)) — no Condition or
|
||||
inner $(...) reference in the generated file, so we avoid any MSBuild
|
||||
escape gymnastics during string construction. The static targets file
|
||||
then defaults $(_MicrosoftAgentsAICopilotSdkVersion) to the packaged
|
||||
version unless the consumer overrides it.
|
||||
-->
|
||||
<ItemGroup>
|
||||
<None Include="buildTransitive\Microsoft.Agents.AI.GitHub.Copilot.targets" Pack="true" PackagePath="buildTransitive\" />
|
||||
<None Include="buildTransitive\Microsoft.Agents.AI.GitHub.Copilot.props" Pack="true" PackagePath="buildTransitive\" />
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="_GenerateBuildTransitiveProps" BeforeTargets="Build;Pack;_GetPackageFiles">
|
||||
<ItemGroup>
|
||||
<_CopilotSdkPackageVersion Include="@(PackageVersion)" Condition="'%(Identity)' == 'GitHub.Copilot.SDK'" />
|
||||
</ItemGroup>
|
||||
<PropertyGroup>
|
||||
<_CopilotSdkResolvedVersion>@(_CopilotSdkPackageVersion->'%(Version)')</_CopilotSdkResolvedVersion>
|
||||
</PropertyGroup>
|
||||
<Error Condition="'$(_CopilotSdkResolvedVersion)' == ''"
|
||||
Text="Could not resolve GitHub.Copilot.SDK version from PackageVersion items. Ensure the central package version is declared in Directory.Packages.props." />
|
||||
<PropertyGroup>
|
||||
<_BuildTransitivePropsContent>
|
||||
<![CDATA[<Project>
|
||||
<PropertyGroup>
|
||||
<_MicrosoftAgentsAICopilotSdkPackagedVersion>$(_CopilotSdkResolvedVersion)</_MicrosoftAgentsAICopilotSdkPackagedVersion>
|
||||
</PropertyGroup>
|
||||
</Project>]]>
|
||||
</_BuildTransitivePropsContent>
|
||||
</PropertyGroup>
|
||||
<WriteLinesToFile File="$(MSBuildThisFileDirectory)buildTransitive\Microsoft.Agents.AI.GitHub.Copilot.props"
|
||||
Lines="$(_BuildTransitivePropsContent)"
|
||||
Overwrite="true"
|
||||
WriteOnlyWhenDifferent="true" />
|
||||
</Target>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# Auto-generated at pack time by _GenerateBuildTransitiveProps in the csproj.
|
||||
Microsoft.Agents.AI.GitHub.Copilot.props
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
<Project>
|
||||
<!--
|
||||
Bridge GitHub.Copilot.SDK's build/ targets to transitive consumers.
|
||||
|
||||
GitHub.Copilot.SDK ships its CLI download / binary-copy MSBuild targets under
|
||||
build/, which means they only auto-import for projects with a DIRECT
|
||||
PackageReference to GitHub.Copilot.SDK. Consumers of this package
|
||||
(Microsoft.Agents.AI.GitHub.Copilot) would otherwise get only the managed
|
||||
adapter .dll, no copilot CLI binary in their output, and the SDK would fail
|
||||
at runtime with:
|
||||
|
||||
Copilot CLI not found at 'bin/{config}/{tfm}/runtimes/{rid}/native/copilot.exe'
|
||||
|
||||
This file ships under buildTransitive/ so NuGet auto-imports it for every
|
||||
transitive consumer, locates the SDK in the NuGet package cache, and imports
|
||||
the SDK's build/ targets so the CLI binary gets downloaded and copied to the
|
||||
consumer's output as expected.
|
||||
|
||||
The companion .props file is generated at this package's pack time and sets
|
||||
$(_MicrosoftAgentsAICopilotSdkPackagedVersion) to the SDK version this
|
||||
package was built against. The Condition below uses that as the default for
|
||||
$(_MicrosoftAgentsAICopilotSdkVersion), so consumers may override the SDK
|
||||
version path by setting $(_MicrosoftAgentsAICopilotSdkVersion) before this
|
||||
file is imported. Consumers may also opt out of the binary download via the
|
||||
SDK's own $(CopilotSkipCliDownload)=true, which the SDK targets honor.
|
||||
-->
|
||||
<PropertyGroup>
|
||||
<_MicrosoftAgentsAICopilotSdkVersion Condition="'$(_MicrosoftAgentsAICopilotSdkVersion)' == ''">$(_MicrosoftAgentsAICopilotSdkPackagedVersion)</_MicrosoftAgentsAICopilotSdkVersion>
|
||||
<_MicrosoftAgentsAICopilotSdkTargetsPath Condition="'$(_MicrosoftAgentsAICopilotSdkVersion)' != ''">$([System.IO.Path]::Combine('$(NuGetPackageRoot)', 'github.copilot.sdk', '$(_MicrosoftAgentsAICopilotSdkVersion)', 'build', 'GitHub.Copilot.SDK.targets'))</_MicrosoftAgentsAICopilotSdkTargetsPath>
|
||||
</PropertyGroup>
|
||||
|
||||
<Import Project="$(_MicrosoftAgentsAICopilotSdkTargetsPath)"
|
||||
Condition="'$(_MicrosoftAgentsAICopilotSdkTargetsPath)' != '' And Exists('$(_MicrosoftAgentsAICopilotSdkTargetsPath)')" />
|
||||
</Project>
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
@@ -39,7 +39,7 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable
|
||||
private static readonly JsonWriterOptions s_toolListJsonWriterOptions = new() { Indented = true };
|
||||
|
||||
private readonly Func<string, CancellationToken, Task<HttpClient?>>? _httpClientProvider;
|
||||
private readonly Dictionary<string, McpClient> _clients = [];
|
||||
private readonly Dictionary<(string Url, string Label, string Connection, string HeadersHash), McpClient> _clients = [];
|
||||
private readonly Dictionary<string, HttpClient> _ownedHttpClients = [];
|
||||
private readonly SemaphoreSlim _clientLock = new(1, 1);
|
||||
|
||||
@@ -66,16 +66,15 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable
|
||||
string? connectionName,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// TODO: Handle connectionName and server label appropriately when Hosted scenario supports them. For now, ignore
|
||||
if (IsListToolsToolName(toolName))
|
||||
{
|
||||
ThrowIfListToolsArgumentsSpecified(arguments);
|
||||
McpClient listToolsClient = await this.GetOrCreateClientAsync(serverUrl, serverLabel, headers, cancellationToken).ConfigureAwait(false);
|
||||
McpClient listToolsClient = await this.GetOrCreateClientAsync(serverUrl, serverLabel, headers, connectionName, cancellationToken).ConfigureAwait(false);
|
||||
IList<McpClientTool> tools = await listToolsClient.ListToolsAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
return CreateListToolsResultContent(tools.Select(tool => tool.ProtocolTool));
|
||||
}
|
||||
|
||||
McpClient client = await this.GetOrCreateClientAsync(serverUrl, serverLabel, headers, cancellationToken).ConfigureAwait(false);
|
||||
McpClient client = await this.GetOrCreateClientAsync(serverUrl, serverLabel, headers, connectionName, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
McpServerToolResultContent resultContent = new(Guid.NewGuid().ToString());
|
||||
|
||||
@@ -145,10 +144,11 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable
|
||||
string serverUrl,
|
||||
string? serverLabel,
|
||||
IDictionary<string, string>? headers,
|
||||
string? connectionName,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string normalizedUrl = serverUrl.Trim().ToUpperInvariant();
|
||||
string clientCacheKey = $"{normalizedUrl}|{ComputeHeadersHash(headers)}";
|
||||
string trimmedUrl = serverUrl.Trim();
|
||||
var clientCacheKey = BuildCacheKey(trimmedUrl, serverLabel, connectionName, headers);
|
||||
|
||||
await this._clientLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
@@ -158,7 +158,7 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable
|
||||
return existingClient;
|
||||
}
|
||||
|
||||
McpClient newClient = await this.CreateClientAsync(serverUrl, serverLabel, headers, normalizedUrl, cancellationToken).ConfigureAwait(false);
|
||||
McpClient newClient = await this.CreateClientAsync(trimmedUrl, serverLabel, headers, trimmedUrl, cancellationToken).ConfigureAwait(false);
|
||||
this._clients[clientCacheKey] = newClient;
|
||||
return newClient;
|
||||
}
|
||||
@@ -168,6 +168,19 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the per-client cache key as a 4-tuple of
|
||||
/// (trimmed serverUrl, serverLabel, connectionName, headers hash). All four components
|
||||
/// participate so that callers using different labels/connections/headers receive
|
||||
/// distinct <see cref="McpClient"/> instances even when targeting the same URL.
|
||||
/// </summary>
|
||||
internal static (string Url, string Label, string Connection, string HeadersHash) BuildCacheKey(
|
||||
string trimmedUrl,
|
||||
string? serverLabel,
|
||||
string? connectionName,
|
||||
IDictionary<string, string>? headers) =>
|
||||
(trimmedUrl, serverLabel ?? string.Empty, connectionName ?? string.Empty, ComputeHeadersHash(headers));
|
||||
|
||||
private async Task<McpClient> CreateClientAsync(
|
||||
string serverUrl,
|
||||
string? serverLabel,
|
||||
@@ -185,7 +198,12 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable
|
||||
|
||||
if (httpClient is null && !this._ownedHttpClients.TryGetValue(httpClientCacheKey, out httpClient))
|
||||
{
|
||||
httpClient = new HttpClient();
|
||||
// Disable cookies so handler-level state (cookie jar) cannot cross the cache-key
|
||||
// isolation boundary established by GetOrCreateClientAsync. The actual MCP auth
|
||||
// travels via AdditionalHeaders (set per-transport below), not session cookies.
|
||||
// CheckCertificateRevocationList satisfies CA5399 since we're explicitly constructing the handler.
|
||||
HttpClientHandler handler = new() { UseCookies = false, CheckCertificateRevocationList = true };
|
||||
httpClient = new HttpClient(handler);
|
||||
this._ownedHttpClients[httpClientCacheKey] = httpClient;
|
||||
}
|
||||
|
||||
@@ -202,26 +220,50 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable
|
||||
return await McpClient.CreateAsync(transport, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static string ComputeHeadersHash(IDictionary<string, string>? headers)
|
||||
/// <summary>
|
||||
/// Computes a deterministic, order-independent hash of the header set.
|
||||
/// Header names are lower-cased for case-insensitive matching (RFC 7230 §3.2).
|
||||
/// Header values remain case-sensitive (RFC 7235 — credentials are case-sensitive).
|
||||
/// </summary>
|
||||
#pragma warning disable CA1308 // RFC 7230 §3.2 requires lower-cased header names for case-insensitive comparison; CA1308's uppercase preference does not apply here
|
||||
internal static string ComputeHeadersHash(IDictionary<string, string>? headers)
|
||||
{
|
||||
if (headers is null || headers.Count == 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
// Build a deterministic, sorted representation of the headers
|
||||
// Within a single process lifetime, the hashcodes are consistent.
|
||||
// This will ensure that the same set of headers always produces the same hash, regardless of order.
|
||||
SortedDictionary<string, string> sorted = new(headers.ToDictionary(h => h.Key.ToUpperInvariant(), h => h.Value.ToUpperInvariant()));
|
||||
int hashCode = 17;
|
||||
foreach (KeyValuePair<string, string> kvp in sorted)
|
||||
// Sort by lower-cased key for deterministic ordering, preserving value case.
|
||||
SortedDictionary<string, string> sorted = new(StringComparer.Ordinal);
|
||||
foreach (KeyValuePair<string, string> header in headers)
|
||||
{
|
||||
hashCode = (hashCode * 31) + StringComparer.OrdinalIgnoreCase.GetHashCode(kvp.Key);
|
||||
hashCode = (hashCode * 31) + StringComparer.OrdinalIgnoreCase.GetHashCode(kvp.Value);
|
||||
sorted[header.Key.ToLowerInvariant()] = header.Value;
|
||||
}
|
||||
|
||||
return hashCode.ToString(CultureInfo.InvariantCulture);
|
||||
StringBuilder payload = new();
|
||||
foreach (KeyValuePair<string, string> kvp in sorted)
|
||||
{
|
||||
payload.Append(kvp.Key).Append(':').Append(kvp.Value).Append('\n');
|
||||
}
|
||||
|
||||
byte[] inputBytes = Encoding.UTF8.GetBytes(payload.ToString());
|
||||
#if NET5_0_OR_GREATER
|
||||
byte[] hashBytes = SHA256.HashData(inputBytes);
|
||||
#else
|
||||
using SHA256 sha256 = SHA256.Create();
|
||||
byte[] hashBytes = sha256.ComputeHash(inputBytes);
|
||||
#endif
|
||||
|
||||
// Convert to hex string (compatible with net472/netstandard2.0)
|
||||
StringBuilder hex = new(hashBytes.Length * 2);
|
||||
foreach (byte b in hashBytes)
|
||||
{
|
||||
hex.Append(b.ToString("X2", System.Globalization.CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
return hex.ToString();
|
||||
}
|
||||
#pragma warning restore CA1308
|
||||
|
||||
private static void ThrowIfListToolsArgumentsSpecified(IDictionary<string, object?>? arguments)
|
||||
{
|
||||
|
||||
+65
-3
@@ -13,6 +13,7 @@ using Microsoft.Agents.AI.Workflows.Declarative.Kit;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
|
||||
using Microsoft.Agents.ObjectModel;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
|
||||
@@ -27,6 +28,13 @@ internal sealed class InvokeFunctionToolExecutor(
|
||||
WorkflowFormulaState state) :
|
||||
DeclarativeActionExecutor<InvokeFunctionTool>(model, state)
|
||||
{
|
||||
private const string ApprovalSnapshotStateKey = nameof(_approvalSnapshot);
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of evaluated parameters at approval-request time.
|
||||
/// </summary>
|
||||
private ApprovalSnapshot? _approvalSnapshot;
|
||||
|
||||
/// <summary>
|
||||
/// Step identifiers for the function tool invocation workflow.
|
||||
/// </summary>
|
||||
@@ -69,6 +77,10 @@ internal sealed class InvokeFunctionToolExecutor(
|
||||
// If approval is required, add user input request content
|
||||
if (requireApproval)
|
||||
{
|
||||
// Snapshot the evaluated parameters.
|
||||
// If state mutates during the approval window, the approved values are used on resume.
|
||||
this._approvalSnapshot = new ApprovalSnapshot(functionName, arguments);
|
||||
|
||||
requestMessage.Contents.Add(new ToolApprovalRequestContent(this.Id, functionCall));
|
||||
}
|
||||
|
||||
@@ -155,6 +167,31 @@ internal sealed class InvokeFunctionToolExecutor(
|
||||
|
||||
// Completes the action after processing the function result.
|
||||
await context.RaiseCompletionEventAsync(this.Model, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Clear the approval snapshot after the action completes so a subsequent
|
||||
// execution of the same executor instance doesn't reuse stale data.
|
||||
this._approvalSnapshot = null;
|
||||
await context.QueueStateUpdateAsync<ApprovalSnapshot?>(ApprovalSnapshotStateKey, null, null, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>
|
||||
/// Persists the approval snapshot to workflow state so it survives checkpoint/restore cycles.
|
||||
/// </remarks>
|
||||
protected override async ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await context.QueueStateUpdateAsync(ApprovalSnapshotStateKey, this._approvalSnapshot, null, cancellationToken).ConfigureAwait(false);
|
||||
await base.OnCheckpointingAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>
|
||||
/// Restores the approval snapshot from workflow state after a checkpoint restore.
|
||||
/// </remarks>
|
||||
protected override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await base.OnCheckpointRestoredAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
this._approvalSnapshot = await context.ReadStateAsync<ApprovalSnapshot>(ApprovalSnapshotStateKey, null, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -262,7 +299,24 @@ internal sealed class InvokeFunctionToolExecutor(
|
||||
|
||||
private async ValueTask<FunctionResultContent?> InvokeRegisteredFunctionAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
string functionName = this.GetFunctionName();
|
||||
string functionName;
|
||||
Dictionary<string, object?>? arguments;
|
||||
|
||||
if (this._approvalSnapshot is { } snapshot)
|
||||
{
|
||||
// Use the snapshot captured at approval-request time so we invoke exactly what
|
||||
// the user approved, even if Power Fx state has mutated during the approval window.
|
||||
functionName = snapshot.FunctionName;
|
||||
arguments = snapshot.Arguments;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fallback for checkpoints created before approval snapshots were introduced.
|
||||
this.Logger.LogWarning("Approval snapshot missing for '{ActionId}'; falling back to expression re-evaluation.", this.Id);
|
||||
functionName = this.GetFunctionName();
|
||||
arguments = this.GetArguments();
|
||||
}
|
||||
|
||||
AIFunction? function = agentProvider.Functions?.FirstOrDefault(
|
||||
f => string.Equals(f.Name, functionName, StringComparison.Ordinal));
|
||||
|
||||
@@ -275,8 +329,7 @@ internal sealed class InvokeFunctionToolExecutor(
|
||||
};
|
||||
}
|
||||
|
||||
Dictionary<string, object?>? arguments = this.GetArguments();
|
||||
AIFunctionArguments? functionArguments = arguments is null ? null : new AIFunctionArguments(arguments);
|
||||
AIFunctionArguments? functionArguments = arguments is null ? null : new AIFunctionArguments(arguments.NormalizePortableValues());
|
||||
|
||||
object? result;
|
||||
try
|
||||
@@ -341,4 +394,13 @@ internal sealed class InvokeFunctionToolExecutor(
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stores the evaluated parameters at approval-request time so that
|
||||
/// <see cref="CaptureResponseAsync"/> uses the values the user reviewed,
|
||||
/// even if <see cref="WorkflowFormulaState"/> mutates during the approval window.
|
||||
/// </summary>
|
||||
internal sealed record ApprovalSnapshot(
|
||||
string FunctionName,
|
||||
Dictionary<string, object?>? Arguments);
|
||||
}
|
||||
|
||||
@@ -54,7 +54,6 @@ builder.Services.AddFoundryResponses(agent);
|
||||
|
||||
var app = builder.Build();
|
||||
app.MapFoundryResponses();
|
||||
app.MapGet("/readiness", () => Results.Ok());
|
||||
app.Run();
|
||||
|
||||
static AIAgent CreateHappyPathAgent(AIProjectClient client, string deployment) =>
|
||||
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// xUnit collection that serializes tests mutating the <c>FOUNDRY_PROJECT_ENDPOINT</c>
|
||||
/// process environment variable. Without this, parallel test execution causes flaky
|
||||
/// races between tests that set / unset the variable.
|
||||
/// </summary>
|
||||
[CollectionDefinition(Name, DisableParallelization = true)]
|
||||
public sealed class FoundryProjectEndpointEnvFixture
|
||||
{
|
||||
public const string Name = "FoundryProjectEndpointEnv";
|
||||
}
|
||||
+126
-7
@@ -1,6 +1,9 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
@@ -51,28 +54,144 @@ public class FoundryToolboxBearerTokenHandlerTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_InjectsFoundryFeaturesHeaderAsync()
|
||||
public async Task SendAsync_UsesAiAzureComScopeAsync()
|
||||
{
|
||||
// Arrange
|
||||
var capturedContexts = new List<TokenRequestContext>();
|
||||
var credential = new Mock<TokenCredential>();
|
||||
credential
|
||||
.Setup(c => c.GetTokenAsync(It.IsAny<TokenRequestContext>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<TokenRequestContext, CancellationToken>((ctx, _) => capturedContexts.Add(ctx))
|
||||
.ReturnsAsync(new AccessToken(FakeToken, DateTimeOffset.MaxValue));
|
||||
var (handler, _) = CreateHandlerPair(credential);
|
||||
using var invoker = new HttpMessageInvoker(handler);
|
||||
|
||||
// Act
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api");
|
||||
await invoker.SendAsync(request, CancellationToken.None);
|
||||
|
||||
// Assert: spec §4 mandates the https://ai.azure.com audience.
|
||||
Assert.Single(capturedContexts);
|
||||
Assert.Contains("https://ai.azure.com/.default", capturedContexts[0].Scopes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_AlwaysInjectsMandatoryFoundryFeaturesHeaderAsync()
|
||||
{
|
||||
// Arrange
|
||||
var (handler, _) = CreateHandlerPair(featuresHeader: null);
|
||||
using var invoker = new HttpMessageInvoker(handler);
|
||||
|
||||
// Act
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api");
|
||||
using var response = await invoker.SendAsync(request, CancellationToken.None);
|
||||
|
||||
// Assert: spec §2 requires Foundry-Features: Toolboxes=V1Preview on every request.
|
||||
Assert.True(request.Headers.TryGetValues("Foundry-Features", out var values));
|
||||
Assert.Equal("Toolboxes=V1Preview", values.Single());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_MergesMandatoryAndOverrideFeaturesAsync()
|
||||
{
|
||||
var (handler, _) = CreateHandlerPair(featuresHeader: "feature1,feature2");
|
||||
using var invoker = new HttpMessageInvoker(handler);
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api");
|
||||
using var response = await invoker.SendAsync(request, CancellationToken.None);
|
||||
await invoker.SendAsync(request, CancellationToken.None);
|
||||
|
||||
Assert.True(request.Headers.TryGetValues("Foundry-Features", out var values));
|
||||
Assert.Contains("feature1,feature2", values);
|
||||
var header = values.Single();
|
||||
Assert.Contains("Toolboxes=V1Preview", header, StringComparison.Ordinal);
|
||||
Assert.Contains("feature1", header, StringComparison.Ordinal);
|
||||
Assert.Contains("feature2", header, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_OmitsFeaturesHeaderWhenNullAsync()
|
||||
public async Task SendAsync_DoesNotDuplicateMandatoryFlagAsync()
|
||||
{
|
||||
var (handler, _) = CreateHandlerPair(featuresHeader: null);
|
||||
// Override already contains the mandatory flag — must not be duplicated in the merged value.
|
||||
var (handler, _) = CreateHandlerPair(featuresHeader: "Toolboxes=V1Preview");
|
||||
using var invoker = new HttpMessageInvoker(handler);
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api");
|
||||
using var response = await invoker.SendAsync(request, CancellationToken.None);
|
||||
await invoker.SendAsync(request, CancellationToken.None);
|
||||
|
||||
Assert.False(request.Headers.Contains("Foundry-Features"));
|
||||
Assert.True(request.Headers.TryGetValues("Foundry-Features", out var values));
|
||||
var header = values.Single();
|
||||
var count = 0;
|
||||
var idx = 0;
|
||||
while ((idx = header.IndexOf("Toolboxes=V1Preview", idx, StringComparison.OrdinalIgnoreCase)) >= 0)
|
||||
{
|
||||
count++;
|
||||
idx += "Toolboxes=V1Preview".Length;
|
||||
}
|
||||
Assert.Equal(1, count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_PropagatesTraceContextFromActivityAsync()
|
||||
{
|
||||
// Arrange: activate an Activity so Activity.Current is populated.
|
||||
using var listener = new ActivityListener
|
||||
{
|
||||
ShouldListenTo = _ => true,
|
||||
Sample = (ref ActivityCreationOptions<ActivityContext> _) => ActivitySamplingResult.AllData,
|
||||
};
|
||||
ActivitySource.AddActivityListener(listener);
|
||||
using var source = new ActivitySource("test-source");
|
||||
using var activity = source.StartActivity("test-op")!;
|
||||
Assert.NotNull(activity);
|
||||
activity.TraceStateString = "vendor=value";
|
||||
activity.AddBaggage("user", "alice");
|
||||
|
||||
var (handler, _) = CreateHandlerPair();
|
||||
using var invoker = new HttpMessageInvoker(handler);
|
||||
|
||||
// Act
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api");
|
||||
await invoker.SendAsync(request, CancellationToken.None);
|
||||
|
||||
// Assert: spec §6.3 requires traceparent/tracestate/baggage propagation.
|
||||
Assert.True(request.Headers.TryGetValues("traceparent", out var tpValues));
|
||||
Assert.Contains(activity.TraceId.ToString(), tpValues.Single(), StringComparison.Ordinal);
|
||||
|
||||
Assert.True(request.Headers.TryGetValues("tracestate", out var tsValues));
|
||||
Assert.Equal("vendor=value", tsValues.Single());
|
||||
|
||||
Assert.True(request.Headers.TryGetValues("baggage", out var bgValues));
|
||||
Assert.Contains("user=alice", bgValues.Single(), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_DoesNotOverrideExistingTraceparentAsync()
|
||||
{
|
||||
// Caller pre-set traceparent on the message; must not be duplicated or replaced.
|
||||
using var listener = new ActivityListener
|
||||
{
|
||||
ShouldListenTo = _ => true,
|
||||
Sample = (ref ActivityCreationOptions<ActivityContext> _) => ActivitySamplingResult.AllData,
|
||||
};
|
||||
ActivitySource.AddActivityListener(listener);
|
||||
using var source = new ActivitySource("test-source");
|
||||
using var activity = source.StartActivity("test-op")!;
|
||||
Assert.NotNull(activity);
|
||||
|
||||
var (handler, _) = CreateHandlerPair();
|
||||
using var invoker = new HttpMessageInvoker(handler);
|
||||
|
||||
const string PresetTraceparent = "00-00000000000000000000000000000001-0000000000000001-01";
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, "https://example.com/api");
|
||||
request.Headers.TryAddWithoutValidation("traceparent", PresetTraceparent);
|
||||
|
||||
// Act
|
||||
await invoker.SendAsync(request, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.True(request.Headers.TryGetValues("traceparent", out var values));
|
||||
var list = values.ToList();
|
||||
Assert.Single(list);
|
||||
Assert.Equal(PresetTraceparent, list[0]);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.Core;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
|
||||
|
||||
[Collection(FoundryProjectEndpointEnvFixture.Name)]
|
||||
public class FoundryToolboxHealthCheckTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task CheckHealthAsync_PendingStatus_ReturnsConfiguredFailureAsync()
|
||||
{
|
||||
// Arrange: a fresh FoundryToolboxService whose StartAsync has never run reports
|
||||
// Pending. The health check must surface that as the registration's failure
|
||||
// status so the platform waits before sending traffic.
|
||||
var service = CreateServiceWithoutStarting();
|
||||
var check = new FoundryToolboxHealthCheck(service);
|
||||
var context = NewContext(failureStatus: HealthStatus.Unhealthy);
|
||||
|
||||
// Act
|
||||
var result = await check.CheckHealthAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HealthStatus.Unhealthy, result.Status);
|
||||
Assert.Contains("startup has not completed", result.Description, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckHealthAsync_NoEndpointStatus_ReturnsHealthyAsync()
|
||||
{
|
||||
// Arrange: no FOUNDRY_PROJECT_ENDPOINT / AZURE_AI_PROJECT_ENDPOINT is normal local-dev.
|
||||
// The container must still pass readiness because the rest of the agent is functional.
|
||||
var savedFoundry = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT");
|
||||
var savedAzure = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT");
|
||||
Environment.SetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT", null);
|
||||
Environment.SetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT", null);
|
||||
try
|
||||
{
|
||||
var service = CreateServiceWithoutStarting(toolbox: "any");
|
||||
await service.StartAsync(CancellationToken.None);
|
||||
|
||||
var check = new FoundryToolboxHealthCheck(service);
|
||||
var context = NewContext(failureStatus: HealthStatus.Unhealthy);
|
||||
|
||||
// Act
|
||||
var result = await check.CheckHealthAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HealthStatus.Healthy, result.Status);
|
||||
Assert.Equal(FoundryToolboxStartupStatus.NoEndpoint, service.StartupStatus);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT", savedFoundry);
|
||||
Environment.SetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT", savedAzure);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckHealthAsync_UnhealthyStatus_ReturnsConfiguredFailureWithFailedNamesAsync()
|
||||
{
|
||||
// Arrange: pre-registered toolbox at an unreachable endpoint forces StartAsync to
|
||||
// record the failure. The health-check must reflect Unhealthy and expose the
|
||||
// failed toolbox names in the result data so operators can diagnose without log
|
||||
// diving.
|
||||
var options = new FoundryToolboxOptions
|
||||
{
|
||||
EndpointOverride = "http://127.0.0.1:1/unreachable",
|
||||
};
|
||||
options.ToolboxNames.Add("broken-toolbox");
|
||||
var service = new FoundryToolboxService(Options.Create(options), Mock.Of<TokenCredential>());
|
||||
await service.StartAsync(CancellationToken.None);
|
||||
|
||||
var check = new FoundryToolboxHealthCheck(service);
|
||||
var context = NewContext(failureStatus: HealthStatus.Unhealthy);
|
||||
|
||||
// Act
|
||||
var result = await check.CheckHealthAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HealthStatus.Unhealthy, result.Status);
|
||||
Assert.True(result.Data.ContainsKey("failedToolboxes"));
|
||||
var failed = Assert.IsAssignableFrom<IReadOnlyList<string>>(result.Data["failedToolboxes"]);
|
||||
Assert.Equal("broken-toolbox", Assert.Single(failed));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckHealthAsync_HealthyStatus_ReturnsHealthyAsync()
|
||||
{
|
||||
// Arrange: an endpoint set but no pre-registered toolboxes is the legitimate
|
||||
// lazy-only setup. StartAsync reports Healthy and the check must agree.
|
||||
var options = new FoundryToolboxOptions
|
||||
{
|
||||
EndpointOverride = "http://127.0.0.1:1/unused",
|
||||
};
|
||||
var service = new FoundryToolboxService(Options.Create(options), Mock.Of<TokenCredential>());
|
||||
await service.StartAsync(CancellationToken.None);
|
||||
|
||||
var check = new FoundryToolboxHealthCheck(service);
|
||||
var context = NewContext(failureStatus: HealthStatus.Unhealthy);
|
||||
|
||||
// Act
|
||||
var result = await check.CheckHealthAsync(context);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HealthStatus.Healthy, result.Status);
|
||||
}
|
||||
|
||||
private static FoundryToolboxService CreateServiceWithoutStarting(string? toolbox = null)
|
||||
{
|
||||
var options = new FoundryToolboxOptions();
|
||||
if (toolbox is not null)
|
||||
{
|
||||
options.ToolboxNames.Add(toolbox);
|
||||
}
|
||||
return new FoundryToolboxService(Options.Create(options), Mock.Of<TokenCredential>());
|
||||
}
|
||||
|
||||
private static HealthCheckContext NewContext(HealthStatus failureStatus) =>
|
||||
new()
|
||||
{
|
||||
Registration = new HealthCheckRegistration(
|
||||
name: "foundry-toolbox",
|
||||
instance: Mock.Of<IHealthCheck>(),
|
||||
failureStatus: failureStatus,
|
||||
tags: null),
|
||||
};
|
||||
}
|
||||
+155
-5
@@ -9,6 +9,7 @@ using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
|
||||
|
||||
[Collection(FoundryProjectEndpointEnvFixture.Name)]
|
||||
public class FoundryToolboxServiceTests
|
||||
{
|
||||
[Fact]
|
||||
@@ -39,15 +40,17 @@ public class FoundryToolboxServiceTests
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
async () => await service.GetToolboxToolsAsync("missing", version: null, CancellationToken.None));
|
||||
|
||||
Assert.Contains("FOUNDRY_AGENT_TOOLSET_ENDPOINT", ex.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("FOUNDRY_PROJECT_ENDPOINT", ex.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_WithoutEndpoint_LeavesToolsEmptyAsync()
|
||||
{
|
||||
// Ensure env var is not set (tests may run in any CI environment)
|
||||
var saved = Environment.GetEnvironmentVariable("FOUNDRY_AGENT_TOOLSET_ENDPOINT");
|
||||
Environment.SetEnvironmentVariable("FOUNDRY_AGENT_TOOLSET_ENDPOINT", null);
|
||||
// Ensure neither env var is set (tests may run in any CI environment)
|
||||
var savedFoundry = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT");
|
||||
var savedAzure = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT");
|
||||
Environment.SetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT", null);
|
||||
Environment.SetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT", null);
|
||||
try
|
||||
{
|
||||
var options = new FoundryToolboxOptions();
|
||||
@@ -59,10 +62,157 @@ public class FoundryToolboxServiceTests
|
||||
await service.StartAsync(CancellationToken.None);
|
||||
|
||||
Assert.Empty(service.Tools);
|
||||
Assert.Equal(FoundryToolboxStartupStatus.NoEndpoint, service.StartupStatus);
|
||||
Assert.Empty(service.FailedToolboxNames);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("FOUNDRY_AGENT_TOOLSET_ENDPOINT", saved);
|
||||
Environment.SetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT", savedFoundry);
|
||||
Environment.SetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT", savedAzure);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_AttemptsOpenForPreRegisteredToolboxFromProjectEndpointAsync()
|
||||
{
|
||||
// Arrange: point the service at an unreachable host and confirm StartAsync
|
||||
// attempts to open the pre-registered toolbox (verified via FailedToolboxNames
|
||||
// recording the attempted name and StartupStatus reflecting the failure).
|
||||
var saved = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT");
|
||||
Environment.SetEnvironmentVariable(
|
||||
"FOUNDRY_PROJECT_ENDPOINT",
|
||||
"https://example.invalid/api/projects/proj");
|
||||
try
|
||||
{
|
||||
var options = new FoundryToolboxOptions { ApiVersion = "v1" };
|
||||
options.ToolboxNames.Add("my-toolbox");
|
||||
var service = new FoundryToolboxService(
|
||||
Options.Create(options),
|
||||
Mock.Of<TokenCredential>());
|
||||
|
||||
// Act: StartAsync attempts to connect to the invalid endpoint and fails.
|
||||
// The failure path records FailedToolboxNames; the value confirms the resolver ran.
|
||||
await service.StartAsync(CancellationToken.None);
|
||||
|
||||
// Assert: open failed, status reflects that (resolver was reached), and
|
||||
// the failed name matches — i.e. we attempted the right toolbox.
|
||||
Assert.Equal(FoundryToolboxStartupStatus.Unhealthy, service.StartupStatus);
|
||||
Assert.Single(service.FailedToolboxNames);
|
||||
Assert.Equal("my-toolbox", service.FailedToolboxNames[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT", saved);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_TrailingSlashOnProjectEndpoint_AttemptsOpenAsync()
|
||||
{
|
||||
var saved = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT");
|
||||
Environment.SetEnvironmentVariable(
|
||||
"FOUNDRY_PROJECT_ENDPOINT",
|
||||
"https://example.invalid/api/projects/proj/");
|
||||
try
|
||||
{
|
||||
var options = new FoundryToolboxOptions();
|
||||
options.ToolboxNames.Add("tb");
|
||||
var service = new FoundryToolboxService(
|
||||
Options.Create(options),
|
||||
Mock.Of<TokenCredential>());
|
||||
|
||||
await service.StartAsync(CancellationToken.None);
|
||||
|
||||
// Arrange/Act: when trailing-slash normalization works the open still fails
|
||||
// (host is unreachable), but FailedToolboxNames records the attempted name —
|
||||
// proof that the resolver did not throw on the slash and the URL was built.
|
||||
Assert.Equal(FoundryToolboxStartupStatus.Unhealthy, service.StartupStatus);
|
||||
Assert.Single(service.FailedToolboxNames);
|
||||
Assert.Equal("tb", service.FailedToolboxNames[0]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT", saved);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_EndpointOverrideWinsOverEnvAsync()
|
||||
{
|
||||
var saved = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT");
|
||||
Environment.SetEnvironmentVariable(
|
||||
"FOUNDRY_PROJECT_ENDPOINT",
|
||||
"https://from-env.invalid/api/projects/proj");
|
||||
try
|
||||
{
|
||||
// EndpointOverride should take precedence over the env var.
|
||||
var options = new FoundryToolboxOptions
|
||||
{
|
||||
EndpointOverride = "http://127.0.0.1:1/from-override",
|
||||
};
|
||||
options.ToolboxNames.Add("tb");
|
||||
|
||||
var service = new FoundryToolboxService(
|
||||
Options.Create(options),
|
||||
Mock.Of<TokenCredential>());
|
||||
|
||||
await service.StartAsync(CancellationToken.None);
|
||||
|
||||
// Override URL is unreachable; we expect Unhealthy (proving Start did try to open
|
||||
// a toolbox, i.e. did not fall into the NoEndpoint branch).
|
||||
Assert.Equal(FoundryToolboxStartupStatus.Unhealthy, service.StartupStatus);
|
||||
Assert.Single(service.FailedToolboxNames);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT", saved);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_WithEndpointButFailingToolbox_RecordsFailureAndStaysReachableAsync()
|
||||
{
|
||||
// Arrange: a syntactically valid but unreachable endpoint forces OpenToolboxAsync
|
||||
// to throw inside the catch-and-log path. The service must still complete StartAsync
|
||||
// (so the host doesn't crash) and surface the failure via StartupStatus.
|
||||
var options = new FoundryToolboxOptions
|
||||
{
|
||||
EndpointOverride = "http://127.0.0.1:1/unreachable",
|
||||
};
|
||||
options.ToolboxNames.Add("broken-toolbox");
|
||||
|
||||
var service = new FoundryToolboxService(
|
||||
Options.Create(options),
|
||||
Mock.Of<TokenCredential>());
|
||||
|
||||
// Act
|
||||
await service.StartAsync(CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(FoundryToolboxStartupStatus.Unhealthy, service.StartupStatus);
|
||||
Assert.Single(service.FailedToolboxNames);
|
||||
Assert.Equal("broken-toolbox", service.FailedToolboxNames[0]);
|
||||
Assert.Empty(service.Tools);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_WithEndpointAndNoToolboxes_ReportsHealthyAsync()
|
||||
{
|
||||
// No pre-registered toolboxes is a legitimate "lazy-only" setup. Health-check
|
||||
// should report Healthy so the readiness probe passes.
|
||||
var options = new FoundryToolboxOptions
|
||||
{
|
||||
EndpointOverride = "http://127.0.0.1:1/unused",
|
||||
};
|
||||
|
||||
var service = new FoundryToolboxService(
|
||||
Options.Create(options),
|
||||
Mock.Of<TokenCredential>());
|
||||
|
||||
await service.StartAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(FoundryToolboxStartupStatus.Healthy, service.StartupStatus);
|
||||
Assert.Empty(service.FailedToolboxNames);
|
||||
Assert.Empty(service.Tools);
|
||||
}
|
||||
}
|
||||
|
||||
+75
@@ -2,9 +2,15 @@
|
||||
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Threading.Tasks;
|
||||
using Azure.AI.AgentServer.Responses;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Moq;
|
||||
using OpenAI.Responses;
|
||||
|
||||
@@ -135,4 +141,73 @@ public class ServiceCollectionExtensionsTests
|
||||
Assert.True(typeof(IChatClient).IsAssignableFrom(meaiType!),
|
||||
$"Expected MEAI {meaiType!.FullName} to implement IChatClient.");
|
||||
}
|
||||
|
||||
// ── /readiness auto-mapping (Foundry container-image-spec §2) ────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task MapFoundryResponses_MapsReadinessEndpoint_WhenTier3HostHasNotMappedItAsync()
|
||||
{
|
||||
// Arrange: Tier 3 host (WebApplication.CreateBuilder, no AgentHost) — Core SDK does
|
||||
// NOT map /readiness in this case, so MapFoundryResponses must cover the gap.
|
||||
using var host = await BuildTestHostAsync(static app => app.MapFoundryResponses());
|
||||
|
||||
// Act
|
||||
var response = await host.GetTestClient().GetAsync(new Uri("/readiness", UriKind.Relative));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MapFoundryResponses_DoesNotDuplicateReadiness_WhenAlreadyMappedAsync()
|
||||
{
|
||||
// Arrange: developer already mapped /readiness with a custom body. The auto-map
|
||||
// must detect the existing route and leave it untouched (no AmbiguousMatchException
|
||||
// at runtime, no override of the developer's response).
|
||||
const string CustomBody = "ready-from-developer";
|
||||
using var host = await BuildTestHostAsync(static app =>
|
||||
{
|
||||
app.MapGet("/readiness", () => Results.Text("ready-from-developer"));
|
||||
app.MapFoundryResponses();
|
||||
});
|
||||
|
||||
// Act
|
||||
var response = await host.GetTestClient().GetAsync(new Uri("/readiness", UriKind.Relative));
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
Assert.Equal(CustomBody, body);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MapFoundryResponses_CalledTwice_StillOnlyMapsReadinessOnceAsync()
|
||||
{
|
||||
// Arrange: defensive coverage for callers that map the responses pipeline twice
|
||||
// (e.g. once at the root and once under "openai/v1" in the existing AF samples).
|
||||
using var host = await BuildTestHostAsync(static app =>
|
||||
{
|
||||
app.MapFoundryResponses();
|
||||
app.MapFoundryResponses("openai/v1");
|
||||
});
|
||||
|
||||
// Act + Assert: a single GET /readiness must succeed without ambiguous-match throw.
|
||||
var response = await host.GetTestClient().GetAsync(new Uri("/readiness", UriKind.Relative));
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
private static async Task<IHost> BuildTestHostAsync(Action<WebApplication> configure)
|
||||
{
|
||||
var builder = WebApplication.CreateBuilder();
|
||||
builder.WebHost.UseTestServer();
|
||||
|
||||
var mockAgent = new Mock<AIAgent>();
|
||||
mockAgent.SetupGet(a => a.Name).Returns("test-agent");
|
||||
builder.Services.AddFoundryResponses(mockAgent.Object);
|
||||
|
||||
var app = builder.Build();
|
||||
configure(app);
|
||||
await app.StartAsync();
|
||||
return app;
|
||||
}
|
||||
}
|
||||
|
||||
+197
-81
@@ -10,57 +10,86 @@ using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.GitHub.Copilot.IntegrationTests;
|
||||
|
||||
[Trait("Category", "Integration")]
|
||||
public class GitHubCopilotAgentTests
|
||||
{
|
||||
private const string SkipReason = "Integration tests require GitHub Copilot CLI installed. For local execution only.";
|
||||
private static void SkipIfCopilotNotConfigured()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("COPILOT_GITHUB_TOKEN")))
|
||||
{
|
||||
Assert.Skip("COPILOT_GITHUB_TOKEN not set; skipping GitHub Copilot integration tests.");
|
||||
}
|
||||
}
|
||||
|
||||
private static Task<PermissionDecision> OnPermissionRequestAsync(PermissionRequest request, PermissionInvocation invocation)
|
||||
=> Task.FromResult(PermissionDecision.ApproveOnce());
|
||||
|
||||
[Fact(Skip = SkipReason)]
|
||||
[Fact]
|
||||
public async Task RunAsync_WithSimplePrompt_ReturnsResponseAsync()
|
||||
{
|
||||
// Arrange
|
||||
SkipIfCopilotNotConfigured();
|
||||
|
||||
await using CopilotClient client = new(new CopilotClientOptions());
|
||||
await client.StartAsync();
|
||||
|
||||
await using GitHubCopilotAgent agent = new(client, sessionConfig: null);
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
|
||||
// Act
|
||||
AgentResponse response = await agent.RunAsync("What is 2 + 2? Answer with just the number.");
|
||||
try
|
||||
{
|
||||
// Act
|
||||
AgentResponse response = await agent.RunAsync("What is 2 + 2? Answer with just the number.", session);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(response);
|
||||
Assert.NotEmpty(response.Messages);
|
||||
Assert.Contains("4", response.Text);
|
||||
// Assert
|
||||
Assert.NotNull(response);
|
||||
Assert.NotEmpty(response.Messages);
|
||||
Assert.Contains("4", response.Text);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DeleteSessionAsync(client, session);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact(Skip = SkipReason)]
|
||||
[Fact]
|
||||
public async Task RunStreamingAsync_WithSimplePrompt_ReturnsUpdatesAsync()
|
||||
{
|
||||
// Arrange
|
||||
SkipIfCopilotNotConfigured();
|
||||
|
||||
await using CopilotClient client = new(new CopilotClientOptions());
|
||||
await client.StartAsync();
|
||||
|
||||
await using GitHubCopilotAgent agent = new(client, sessionConfig: null);
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
|
||||
// Act
|
||||
List<AgentResponseUpdate> updates = [];
|
||||
await foreach (AgentResponseUpdate update in agent.RunStreamingAsync("What is 2 + 2? Answer with just the number."))
|
||||
try
|
||||
{
|
||||
updates.Add(update);
|
||||
}
|
||||
// Act
|
||||
List<AgentResponseUpdate> updates = [];
|
||||
await foreach (AgentResponseUpdate update in agent.RunStreamingAsync("What is 2 + 2? Answer with just the number.", session))
|
||||
{
|
||||
updates.Add(update);
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.NotEmpty(updates);
|
||||
string fullText = string.Join("", updates.Select(u => u.Text));
|
||||
Assert.Contains("4", fullText);
|
||||
// Assert
|
||||
Assert.NotEmpty(updates);
|
||||
string fullText = string.Join("", updates.Select(u => u.Text));
|
||||
Assert.Contains("4", fullText);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DeleteSessionAsync(client, session);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact(Skip = SkipReason)]
|
||||
[Fact]
|
||||
public async Task RunAsync_WithFunctionTool_InvokesToolAsync()
|
||||
{
|
||||
// Arrange
|
||||
SkipIfCopilotNotConfigured();
|
||||
|
||||
bool toolInvoked = false;
|
||||
|
||||
AIFunction weatherTool = AIFunctionFactory.Create((string location) =>
|
||||
@@ -72,24 +101,42 @@ public class GitHubCopilotAgentTests
|
||||
await using CopilotClient client = new(new CopilotClientOptions());
|
||||
await client.StartAsync();
|
||||
|
||||
await using GitHubCopilotAgent agent = new(
|
||||
client,
|
||||
tools: [weatherTool],
|
||||
instructions: "You are a helpful weather agent. Use the GetWeather tool to answer weather questions.");
|
||||
SessionConfig sessionConfig = new()
|
||||
{
|
||||
Tools = [weatherTool],
|
||||
OnPermissionRequest = OnPermissionRequestAsync,
|
||||
SystemMessage = new SystemMessageConfig
|
||||
{
|
||||
Mode = SystemMessageMode.Append,
|
||||
Content = "You are a weather assistant. Always use the GetWeather tool to answer weather questions.",
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
AgentResponse response = await agent.RunAsync("What's the weather like in Seattle?");
|
||||
await using GitHubCopilotAgent agent = new(client, sessionConfig);
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(response);
|
||||
Assert.NotEmpty(response.Messages);
|
||||
Assert.True(toolInvoked);
|
||||
try
|
||||
{
|
||||
// Act
|
||||
AgentResponse response = await agent.RunAsync("What's the weather like in Seattle?", session);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(response);
|
||||
Assert.NotEmpty(response.Messages);
|
||||
Assert.True(toolInvoked);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DeleteSessionAsync(client, session);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact(Skip = SkipReason)]
|
||||
[Fact]
|
||||
public async Task RunAsync_WithSession_MaintainsContextAsync()
|
||||
{
|
||||
// Arrange
|
||||
SkipIfCopilotNotConfigured();
|
||||
|
||||
await using CopilotClient client = new(new CopilotClientOptions());
|
||||
await client.StartAsync();
|
||||
|
||||
@@ -99,23 +146,32 @@ public class GitHubCopilotAgentTests
|
||||
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
|
||||
// Act - First turn
|
||||
AgentResponse response1 = await agent.RunAsync("My name is Alice.", session);
|
||||
Assert.NotNull(response1);
|
||||
try
|
||||
{
|
||||
// Act - First turn
|
||||
AgentResponse response1 = await agent.RunAsync("My name is Alice.", session);
|
||||
Assert.NotNull(response1);
|
||||
|
||||
// Act - Second turn using same session
|
||||
AgentResponse response2 = await agent.RunAsync("What is my name?", session);
|
||||
// Act - Second turn using same session
|
||||
AgentResponse response2 = await agent.RunAsync("What is my name?", session);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(response2);
|
||||
Assert.Contains("Alice", response2.Text, StringComparison.OrdinalIgnoreCase);
|
||||
// Assert
|
||||
Assert.NotNull(response2);
|
||||
Assert.Contains("Alice", response2.Text, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DeleteSessionAsync(client, session);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact(Skip = SkipReason)]
|
||||
[Fact]
|
||||
public async Task RunAsync_WithSessionResume_ContinuesConversationAsync()
|
||||
{
|
||||
// Arrange - First agent instance starts a conversation
|
||||
string? sessionId;
|
||||
SkipIfCopilotNotConfigured();
|
||||
|
||||
string? sessionId = null;
|
||||
|
||||
await using CopilotClient client1 = new(new CopilotClientOptions());
|
||||
await client1.StartAsync();
|
||||
@@ -125,31 +181,44 @@ public class GitHubCopilotAgentTests
|
||||
instructions: "You are a helpful assistant. Keep your answers short.");
|
||||
|
||||
AgentSession session1 = await agent1.CreateSessionAsync();
|
||||
await agent1.RunAsync("Remember this number: 42.", session1);
|
||||
|
||||
sessionId = ((GitHubCopilotAgentSession)session1).SessionId;
|
||||
Assert.NotNull(sessionId);
|
||||
try
|
||||
{
|
||||
await agent1.RunAsync("Remember this number: 42.", session1);
|
||||
|
||||
// Act - Second agent instance resumes the session
|
||||
await using CopilotClient client2 = new(new CopilotClientOptions());
|
||||
await client2.StartAsync();
|
||||
sessionId = ((GitHubCopilotAgentSession)session1).SessionId;
|
||||
Assert.NotNull(sessionId);
|
||||
|
||||
await using GitHubCopilotAgent agent2 = new(
|
||||
client2,
|
||||
instructions: "You are a helpful assistant. Keep your answers short.");
|
||||
// Act - Second agent instance resumes the session
|
||||
await using CopilotClient client2 = new(new CopilotClientOptions());
|
||||
await client2.StartAsync();
|
||||
|
||||
AgentSession session2 = await agent2.CreateSessionAsync(sessionId);
|
||||
AgentResponse response = await agent2.RunAsync("What number did I ask you to remember?", session2);
|
||||
await using GitHubCopilotAgent agent2 = new(
|
||||
client2,
|
||||
instructions: "You are a helpful assistant. Keep your answers short.");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(response);
|
||||
Assert.Contains("42", response.Text);
|
||||
AgentSession session2 = await agent2.CreateSessionAsync(sessionId);
|
||||
AgentResponse response = await agent2.RunAsync("What number did I ask you to remember?", session2);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(response);
|
||||
Assert.Contains("42", response.Text);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (sessionId is not null)
|
||||
{
|
||||
await client1.DeleteSessionAsync(sessionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact(Skip = SkipReason)]
|
||||
[Fact]
|
||||
public async Task RunAsync_WithShellPermissions_ExecutesCommandAsync()
|
||||
{
|
||||
// Arrange
|
||||
SkipIfCopilotNotConfigured();
|
||||
|
||||
await using CopilotClient client = new(new CopilotClientOptions());
|
||||
await client.StartAsync();
|
||||
|
||||
@@ -159,20 +228,30 @@ public class GitHubCopilotAgentTests
|
||||
};
|
||||
|
||||
await using GitHubCopilotAgent agent = new(client, sessionConfig);
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
|
||||
// Act
|
||||
AgentResponse response = await agent.RunAsync("Run a shell command to print 'hello world'");
|
||||
try
|
||||
{
|
||||
// Act
|
||||
AgentResponse response = await agent.RunAsync("Run a shell command to print 'hello world'", session);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(response);
|
||||
Assert.NotEmpty(response.Messages);
|
||||
Assert.Contains("hello", response.Text, StringComparison.OrdinalIgnoreCase);
|
||||
// Assert
|
||||
Assert.NotNull(response);
|
||||
Assert.NotEmpty(response.Messages);
|
||||
Assert.Contains("hello", response.Text, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DeleteSessionAsync(client, session);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact(Skip = SkipReason)]
|
||||
[Fact]
|
||||
public async Task RunAsync_WithUrlPermissions_FetchesContentAsync()
|
||||
{
|
||||
// Arrange
|
||||
SkipIfCopilotNotConfigured();
|
||||
|
||||
await using CopilotClient client = new(new CopilotClientOptions());
|
||||
await client.StartAsync();
|
||||
|
||||
@@ -182,20 +261,30 @@ public class GitHubCopilotAgentTests
|
||||
};
|
||||
|
||||
await using GitHubCopilotAgent agent = new(client, sessionConfig);
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
|
||||
// Act
|
||||
AgentResponse response = await agent.RunAsync(
|
||||
"Fetch https://learn.microsoft.com/agent-framework/tutorials/quick-start and summarize its contents in one sentence");
|
||||
try
|
||||
{
|
||||
// Act
|
||||
AgentResponse response = await agent.RunAsync(
|
||||
"Fetch https://learn.microsoft.com/agent-framework/tutorials/quick-start and summarize its contents in one sentence", session);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(response);
|
||||
Assert.Contains("Agent Framework", response.Text, StringComparison.OrdinalIgnoreCase);
|
||||
// Assert
|
||||
Assert.NotNull(response);
|
||||
Assert.Contains("Agent Framework", response.Text, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DeleteSessionAsync(client, session);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact(Skip = SkipReason)]
|
||||
[Fact]
|
||||
public async Task RunAsync_WithLocalMcpServer_UsesServerToolsAsync()
|
||||
{
|
||||
// Arrange
|
||||
SkipIfCopilotNotConfigured();
|
||||
|
||||
await using CopilotClient client = new(new CopilotClientOptions());
|
||||
await client.StartAsync();
|
||||
|
||||
@@ -214,20 +303,31 @@ public class GitHubCopilotAgentTests
|
||||
};
|
||||
|
||||
await using GitHubCopilotAgent agent = new(client, sessionConfig);
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
|
||||
// Act
|
||||
AgentResponse response = await agent.RunAsync("List the files in the current directory");
|
||||
try
|
||||
{
|
||||
// Act
|
||||
AgentResponse response = await agent.RunAsync("List the files in the current directory", session);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(response);
|
||||
Assert.NotEmpty(response.Messages);
|
||||
Assert.NotEmpty(response.Text);
|
||||
// Assert
|
||||
Assert.NotNull(response);
|
||||
Assert.NotEmpty(response.Messages);
|
||||
Assert.NotEmpty(response.Text);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DeleteSessionAsync(client, session);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact(Skip = SkipReason)]
|
||||
[Fact]
|
||||
[Trait("Category", "IntegrationDisabled")]
|
||||
public async Task RunAsync_WithRemoteMcpServer_UsesServerToolsAsync()
|
||||
{
|
||||
// Arrange
|
||||
SkipIfCopilotNotConfigured();
|
||||
|
||||
await using CopilotClient client = new(new CopilotClientOptions());
|
||||
await client.StartAsync();
|
||||
|
||||
@@ -245,12 +345,28 @@ public class GitHubCopilotAgentTests
|
||||
};
|
||||
|
||||
await using GitHubCopilotAgent agent = new(client, sessionConfig);
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
|
||||
// Act
|
||||
AgentResponse response = await agent.RunAsync("Search Microsoft Learn for 'Azure Functions' and summarize the top result");
|
||||
try
|
||||
{
|
||||
// Act
|
||||
AgentResponse response = await agent.RunAsync("Search Microsoft Learn for 'Azure Functions' and summarize the top result", session);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(response);
|
||||
Assert.Contains("Azure Functions", response.Text, StringComparison.OrdinalIgnoreCase);
|
||||
// Assert
|
||||
Assert.NotNull(response);
|
||||
Assert.Contains("Azure Functions", response.Text, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await DeleteSessionAsync(client, session);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task DeleteSessionAsync(CopilotClient client, AgentSession session)
|
||||
{
|
||||
if (session is GitHubCopilotAgentSession { SessionId: { } sessionId })
|
||||
{
|
||||
await client.DeleteSessionAsync(sessionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+183
@@ -321,6 +321,189 @@ public sealed class DefaultMcpToolHandlerTests
|
||||
|
||||
#endregion
|
||||
|
||||
#region ComputeHeadersHash Tests
|
||||
|
||||
[Fact]
|
||||
public void ComputeHeadersHash_WithNullHeaders_ReturnsEmptyString()
|
||||
{
|
||||
// Act
|
||||
string result = DefaultMcpToolHandler.ComputeHeadersHash(null);
|
||||
|
||||
// Assert
|
||||
result.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeHeadersHash_WithEmptyHeaders_ReturnsEmptyString()
|
||||
{
|
||||
// Act
|
||||
string result = DefaultMcpToolHandler.ComputeHeadersHash(new Dictionary<string, string>());
|
||||
|
||||
// Assert
|
||||
result.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeHeadersHash_SameHeadersDifferentOrder_ReturnsSameHash()
|
||||
{
|
||||
// Arrange
|
||||
Dictionary<string, string> headers1 = new()
|
||||
{
|
||||
["Authorization"] = "Bearer token123",
|
||||
["X-Custom"] = "value1"
|
||||
};
|
||||
Dictionary<string, string> headers2 = new()
|
||||
{
|
||||
["X-Custom"] = "value1",
|
||||
["Authorization"] = "Bearer token123"
|
||||
};
|
||||
|
||||
// Act
|
||||
string hash1 = DefaultMcpToolHandler.ComputeHeadersHash(headers1);
|
||||
string hash2 = DefaultMcpToolHandler.ComputeHeadersHash(headers2);
|
||||
|
||||
// Assert
|
||||
hash1.Should().Be(hash2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeHeadersHash_SameKeysDifferentCaseKeys_ReturnsSameHash()
|
||||
{
|
||||
// Arrange — RFC 7230: header names are case-insensitive
|
||||
Dictionary<string, string> headers1 = new() { ["Authorization"] = "Bearer token" };
|
||||
Dictionary<string, string> headers2 = new() { ["authorization"] = "Bearer token" };
|
||||
|
||||
// Act
|
||||
string hash1 = DefaultMcpToolHandler.ComputeHeadersHash(headers1);
|
||||
string hash2 = DefaultMcpToolHandler.ComputeHeadersHash(headers2);
|
||||
|
||||
// Assert
|
||||
hash1.Should().Be(hash2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeHeadersHash_SameKeysDifferentCaseValues_ReturnsDifferentHash()
|
||||
{
|
||||
// Arrange — RFC 7235: credentials are case-sensitive
|
||||
Dictionary<string, string> headers1 = new() { ["Authorization"] = "Bearer ABC" };
|
||||
Dictionary<string, string> headers2 = new() { ["Authorization"] = "Bearer abc" };
|
||||
|
||||
// Act
|
||||
string hash1 = DefaultMcpToolHandler.ComputeHeadersHash(headers1);
|
||||
string hash2 = DefaultMcpToolHandler.ComputeHeadersHash(headers2);
|
||||
|
||||
// Assert
|
||||
hash1.Should().NotBe(hash2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeHeadersHash_DifferentHeaders_ReturnsDifferentHash()
|
||||
{
|
||||
// Arrange
|
||||
Dictionary<string, string> headers1 = new() { ["Authorization"] = "Bearer token1" };
|
||||
Dictionary<string, string> headers2 = new() { ["Authorization"] = "Bearer token2" };
|
||||
|
||||
// Act
|
||||
string hash1 = DefaultMcpToolHandler.ComputeHeadersHash(headers1);
|
||||
string hash2 = DefaultMcpToolHandler.ComputeHeadersHash(headers2);
|
||||
|
||||
// Assert
|
||||
hash1.Should().NotBe(hash2);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Cache Key Discrimination Tests
|
||||
|
||||
// These tests exercise BuildCacheKey directly because the integration path
|
||||
// (InvokeToolAsync against a fake server) doesn't surface cache-hit behavior
|
||||
// without standing up a real MCP server — McpClient.CreateAsync fails before
|
||||
// _clients[key] = newClient runs, so nothing ever gets cached.
|
||||
// Tuple equality on the returned 4-tuple verifies that the dimensions
|
||||
// collectively discriminate cache entries.
|
||||
|
||||
[Fact]
|
||||
public void BuildCacheKey_SameInputs_ReturnsEqualKeys()
|
||||
{
|
||||
// Arrange
|
||||
Dictionary<string, string> headers = new() { ["Authorization"] = "Bearer token" };
|
||||
|
||||
// Act
|
||||
var key1 = DefaultMcpToolHandler.BuildCacheKey("http://localhost/mcp", "label", "conn", headers);
|
||||
var key2 = DefaultMcpToolHandler.BuildCacheKey("http://localhost/mcp", "label", "conn", headers);
|
||||
|
||||
// Assert
|
||||
key1.Should().Be(key2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildCacheKey_DifferentConnectionName_ReturnsDifferentKeys()
|
||||
{
|
||||
// Act
|
||||
var key1 = DefaultMcpToolHandler.BuildCacheKey("http://localhost/mcp", "label", "connection-a", null);
|
||||
var key2 = DefaultMcpToolHandler.BuildCacheKey("http://localhost/mcp", "label", "connection-b", null);
|
||||
|
||||
// Assert
|
||||
key1.Should().NotBe(key2);
|
||||
key1.Connection.Should().Be("connection-a");
|
||||
key2.Connection.Should().Be("connection-b");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildCacheKey_DifferentServerLabel_ReturnsDifferentKeys()
|
||||
{
|
||||
// Act
|
||||
var key1 = DefaultMcpToolHandler.BuildCacheKey("http://localhost/mcp", "label-a", null, null);
|
||||
var key2 = DefaultMcpToolHandler.BuildCacheKey("http://localhost/mcp", "label-b", null, null);
|
||||
|
||||
// Assert
|
||||
key1.Should().NotBe(key2);
|
||||
key1.Label.Should().Be("label-a");
|
||||
key2.Label.Should().Be("label-b");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildCacheKey_CaseSensitiveUrlPath_ReturnsDifferentKeys()
|
||||
{
|
||||
// Arrange — RFC 3986: URL path is case-sensitive
|
||||
// Act
|
||||
var key1 = DefaultMcpToolHandler.BuildCacheKey("http://localhost/Tools", null, null, null);
|
||||
var key2 = DefaultMcpToolHandler.BuildCacheKey("http://localhost/tools", null, null, null);
|
||||
|
||||
// Assert
|
||||
key1.Should().NotBe(key2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildCacheKey_HeaderValuesCaseSensitive_ReturnsDifferentKeys()
|
||||
{
|
||||
// Arrange — RFC 7235: credentials are case-sensitive
|
||||
Dictionary<string, string> headers1 = new() { ["Authorization"] = "Bearer ABC" };
|
||||
Dictionary<string, string> headers2 = new() { ["Authorization"] = "Bearer abc" };
|
||||
|
||||
// Act
|
||||
var key1 = DefaultMcpToolHandler.BuildCacheKey("http://localhost/mcp", null, null, headers1);
|
||||
var key2 = DefaultMcpToolHandler.BuildCacheKey("http://localhost/mcp", null, null, headers2);
|
||||
|
||||
// Assert — header value case must propagate into the cache key
|
||||
key1.Should().NotBe(key2);
|
||||
key1.HeadersHash.Should().NotBe(key2.HeadersHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildCacheKey_NullLabelAndConnection_NormalizesToEmptyString()
|
||||
{
|
||||
// Act
|
||||
var key = DefaultMcpToolHandler.BuildCacheKey("http://localhost/mcp", null, null, null);
|
||||
|
||||
// Assert — verifies null-safety contract callers rely on
|
||||
key.Label.Should().BeEmpty();
|
||||
key.Connection.Should().BeEmpty();
|
||||
key.HeadersHash.Should().BeEmpty();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Reserved Tools/List Tests
|
||||
|
||||
[Fact]
|
||||
|
||||
+355
@@ -1,11 +1,21 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.Events;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.Kit;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
|
||||
using Microsoft.Agents.ObjectModel;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.PowerFx.Types;
|
||||
using Moq;
|
||||
using ApprovalSnapshot = Microsoft.Agents.AI.Workflows.Declarative.ObjectModel.InvokeFunctionToolExecutor.ApprovalSnapshot;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel;
|
||||
|
||||
@@ -261,6 +271,323 @@ public sealed class InvokeFunctionToolExecutorTest(ITestOutputHelper output) : W
|
||||
|
||||
#endregion
|
||||
|
||||
#region Approval Snapshot Security Tests
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that mutating the function-name variable after approval does not change
|
||||
/// which function is actually invoked. The originally-approved name must be used.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task InvokeFunctionToolCaptureResponseUsesApprovedFunctionNameNotMutatedAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string ApprovedFunctionName = "safe_readonly_query";
|
||||
const string MutatedFunctionName = "dangerous_admin_tool";
|
||||
|
||||
this.State.Set("TargetFunction", FormulaValue.New(ApprovedFunctionName));
|
||||
this.State.InitializeSystem();
|
||||
this.State.Bind();
|
||||
|
||||
InvokeFunctionTool model = this.CreateModelWithVariableFunctionName(
|
||||
displayName: nameof(InvokeFunctionToolCaptureResponseUsesApprovedFunctionNameNotMutatedAsync),
|
||||
variableName: "TargetFunction");
|
||||
|
||||
string? capturedFunctionName = null;
|
||||
TestFunctionAgentProvider testAgentProvider = new(
|
||||
[
|
||||
AIFunctionFactory.Create(() => "safe-result", name: ApprovedFunctionName),
|
||||
AIFunctionFactory.Create(() => "dangerous-result", name: MutatedFunctionName),
|
||||
],
|
||||
onInvoke: name => capturedFunctionName = name);
|
||||
InvokeFunctionToolExecutor action = new(model, testAgentProvider, this.State);
|
||||
|
||||
// Act - trigger ExecuteAsync to store the approval snapshot
|
||||
Mock<IWorkflowContext> mockContext = CreateMockWorkflowContext();
|
||||
await action.HandleAsync(new ActionExecutorResult(action.Id), mockContext.Object, CancellationToken.None);
|
||||
|
||||
// Simulate parallel branch mutating state during the approval window
|
||||
this.State.Set("TargetFunction", FormulaValue.New(MutatedFunctionName));
|
||||
this.State.Bind();
|
||||
|
||||
// User clicks approve (they saw "safe_readonly_query" in the approval UI)
|
||||
ExternalInputResponse response = CreateApprovalResponse(action.Id, approved: true);
|
||||
|
||||
// Resume after approval
|
||||
await action.CaptureResponseAsync(mockContext.Object, response, CancellationToken.None);
|
||||
|
||||
// Assert - the originally-approved function must be invoked, not the mutated one
|
||||
Assert.NotNull(capturedFunctionName);
|
||||
Assert.Equal(ApprovedFunctionName, capturedFunctionName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that mutating an argument variable after approval does not change
|
||||
/// the arguments actually passed to the invoked function.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task InvokeFunctionToolCaptureResponseUsesApprovedArgumentsNotMutatedAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string FunctionName = "process_query";
|
||||
const string ArgumentKey = "query";
|
||||
const string ApprovedQuery = "SELECT * FROM users LIMIT 10";
|
||||
const string MutatedQuery = "DROP TABLE users CASCADE; --";
|
||||
|
||||
this.State.Set("SqlQuery", FormulaValue.New(ApprovedQuery));
|
||||
this.State.InitializeSystem();
|
||||
this.State.Bind();
|
||||
|
||||
InvokeFunctionTool model = this.CreateModelWithVariableArgument(
|
||||
displayName: nameof(InvokeFunctionToolCaptureResponseUsesApprovedArgumentsNotMutatedAsync),
|
||||
functionName: FunctionName,
|
||||
argumentKey: ArgumentKey,
|
||||
variableName: "SqlQuery");
|
||||
|
||||
AIFunctionArguments? capturedArguments = null;
|
||||
TestFunctionAgentProvider testAgentProvider = new(
|
||||
[AIFunctionFactory.Create((string query) => $"executed:{query}", name: FunctionName)],
|
||||
onInvokeArguments: args => capturedArguments = args);
|
||||
InvokeFunctionToolExecutor action = new(model, testAgentProvider, this.State);
|
||||
|
||||
// Act - trigger ExecuteAsync to store the approval snapshot
|
||||
Mock<IWorkflowContext> mockContext = CreateMockWorkflowContext();
|
||||
await action.HandleAsync(new ActionExecutorResult(action.Id), mockContext.Object, CancellationToken.None);
|
||||
|
||||
// Simulate parallel branch mutating state during the approval window
|
||||
this.State.Set("SqlQuery", FormulaValue.New(MutatedQuery));
|
||||
this.State.Bind();
|
||||
|
||||
// User clicks approve
|
||||
ExternalInputResponse response = CreateApprovalResponse(action.Id, approved: true);
|
||||
|
||||
// Resume after approval
|
||||
await action.CaptureResponseAsync(mockContext.Object, response, CancellationToken.None);
|
||||
|
||||
// Assert - the originally-approved argument must be used, not the mutated one
|
||||
Assert.NotNull(capturedArguments);
|
||||
Assert.Equal(ApprovedQuery, capturedArguments[ArgumentKey]?.ToString());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the approval snapshot survives a checkpoint/restore cycle.
|
||||
/// After restore, the originally-approved function must still be used even if state was mutated.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task InvokeFunctionToolCaptureResponseUsesSnapshotAfterCheckpointRestoreAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string ApprovedFunctionName = "safe_readonly_query";
|
||||
const string MutatedFunctionName = "dangerous_admin_tool";
|
||||
|
||||
this.State.Set("TargetFunction", FormulaValue.New(ApprovedFunctionName));
|
||||
this.State.InitializeSystem();
|
||||
this.State.Bind();
|
||||
|
||||
InvokeFunctionTool model = this.CreateModelWithVariableFunctionName(
|
||||
displayName: nameof(InvokeFunctionToolCaptureResponseUsesSnapshotAfterCheckpointRestoreAsync),
|
||||
variableName: "TargetFunction");
|
||||
|
||||
string? capturedFunctionName = null;
|
||||
TestFunctionAgentProvider testAgentProvider = new(
|
||||
[
|
||||
AIFunctionFactory.Create(() => "safe-result", name: ApprovedFunctionName),
|
||||
AIFunctionFactory.Create(() => "dangerous-result", name: MutatedFunctionName),
|
||||
],
|
||||
onInvoke: name => capturedFunctionName = name);
|
||||
InvokeFunctionToolExecutor action = new(model, testAgentProvider, this.State);
|
||||
|
||||
// Act - trigger ExecuteAsync to store the approval snapshot
|
||||
Mock<IWorkflowContext> mockContext = CreateMockWorkflowContextWithStateStore();
|
||||
await action.HandleAsync(new ActionExecutorResult(action.Id), mockContext.Object, CancellationToken.None);
|
||||
|
||||
// Simulate checkpoint: persist to state store
|
||||
await InvokeProtectedMethodAsync(action, "OnCheckpointingAsync", mockContext.Object, CancellationToken.None);
|
||||
|
||||
// Simulate restore on a "new" executor instance by clearing the in-memory field via reflection
|
||||
// (In production, a new executor instance would be created with _approvalSnapshot == null)
|
||||
typeof(InvokeFunctionToolExecutor)
|
||||
.GetField("_approvalSnapshot", BindingFlags.NonPublic | BindingFlags.Instance)!
|
||||
.SetValue(action, null);
|
||||
|
||||
// Restore from state store
|
||||
await InvokeProtectedMethodAsync(action, "OnCheckpointRestoredAsync", mockContext.Object, CancellationToken.None);
|
||||
|
||||
// Mutate state after restore (simulating parallel branch)
|
||||
this.State.Set("TargetFunction", FormulaValue.New(MutatedFunctionName));
|
||||
this.State.Bind();
|
||||
|
||||
// User clicks approve
|
||||
ExternalInputResponse response = CreateApprovalResponse(action.Id, approved: true);
|
||||
|
||||
// Resume after approval
|
||||
await action.CaptureResponseAsync(mockContext.Object, response, CancellationToken.None);
|
||||
|
||||
// Assert - the originally-approved function must be invoked, not the mutated one
|
||||
Assert.NotNull(capturedFunctionName);
|
||||
Assert.Equal(ApprovedFunctionName, capturedFunctionName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the approval snapshot is cleared after a completed approval cycle,
|
||||
/// both in-memory and in the persisted state store. This prevents stale data from
|
||||
/// influencing a subsequent execution of the same executor instance.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task InvokeFunctionToolCaptureResponseClearsSnapshotAfterCompletionAsync()
|
||||
{
|
||||
// Arrange
|
||||
const string FunctionName = "any_function";
|
||||
|
||||
this.State.InitializeSystem();
|
||||
this.State.Bind();
|
||||
|
||||
InvokeFunctionTool model = this.CreateModel(
|
||||
displayName: nameof(InvokeFunctionToolCaptureResponseClearsSnapshotAfterCompletionAsync),
|
||||
functionName: FunctionName,
|
||||
requireApproval: true);
|
||||
|
||||
TestFunctionAgentProvider testAgentProvider = new(
|
||||
[AIFunctionFactory.Create(() => "result", name: FunctionName)]);
|
||||
InvokeFunctionToolExecutor action = new(model, testAgentProvider, this.State);
|
||||
|
||||
// Act - run the full approval cycle
|
||||
Dictionary<string, object?> stateStore = [];
|
||||
Mock<IWorkflowContext> mockContext = CreateMockWorkflowContextWithStateStore(stateStore);
|
||||
await action.HandleAsync(new ActionExecutorResult(action.Id), mockContext.Object, CancellationToken.None);
|
||||
|
||||
// Sanity: snapshot was captured
|
||||
FieldInfo snapshotField = typeof(InvokeFunctionToolExecutor)
|
||||
.GetField("_approvalSnapshot", BindingFlags.NonPublic | BindingFlags.Instance)!;
|
||||
Assert.NotNull(snapshotField.GetValue(action));
|
||||
|
||||
ExternalInputResponse response = CreateApprovalResponse(action.Id, approved: true);
|
||||
await action.CaptureResponseAsync(mockContext.Object, response, CancellationToken.None);
|
||||
|
||||
// Assert - both in-memory field and persisted state are cleared
|
||||
Assert.Null(snapshotField.GetValue(action));
|
||||
Assert.True(stateStore.ContainsKey("_approvalSnapshot"));
|
||||
Assert.Null(stateStore["_approvalSnapshot"]);
|
||||
}
|
||||
|
||||
private static ExternalInputResponse CreateApprovalResponse(string actionId, bool approved)
|
||||
{
|
||||
FunctionCallContent functionCall = new(callId: actionId, name: "ignored");
|
||||
ToolApprovalRequestContent approvalRequest = new(actionId, functionCall);
|
||||
ToolApprovalResponseContent approvalResponse = approvalRequest.CreateResponse(approved);
|
||||
return new ExternalInputResponse(new ChatMessage(ChatRole.User, [approvalResponse]));
|
||||
}
|
||||
|
||||
private static Mock<IWorkflowContext> CreateMockWorkflowContext()
|
||||
{
|
||||
Mock<IWorkflowContext> mockContext = new();
|
||||
mockContext.Setup(c => c.AddEventAsync(It.IsAny<WorkflowEvent>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(default(ValueTask));
|
||||
mockContext.Setup(c => c.QueueStateUpdateAsync(It.IsAny<string>(), It.IsAny<object?>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(default(ValueTask));
|
||||
mockContext.Setup(c => c.SendMessageAsync(It.IsAny<object>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(default(ValueTask));
|
||||
return mockContext;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a mock workflow context that actually stores state values (for checkpoint/restore tests).
|
||||
/// Optionally accepts an externally-owned dictionary so callers can inspect the persisted state.
|
||||
/// </summary>
|
||||
private static Mock<IWorkflowContext> CreateMockWorkflowContextWithStateStore(Dictionary<string, object?>? stateStore = null)
|
||||
{
|
||||
stateStore ??= [];
|
||||
Mock<IWorkflowContext> mockContext = new();
|
||||
mockContext.Setup(c => c.AddEventAsync(It.IsAny<WorkflowEvent>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(default(ValueTask));
|
||||
mockContext.Setup(c => c.QueueStateUpdateAsync(It.IsAny<string>(), It.IsAny<ApprovalSnapshot?>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<string, ApprovalSnapshot?, string?, CancellationToken>((key, value, _, _) => stateStore[key] = value)
|
||||
.Returns(default(ValueTask));
|
||||
mockContext.Setup(c => c.SendMessageAsync(It.IsAny<object>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
|
||||
.Returns(default(ValueTask));
|
||||
mockContext.Setup(c => c.ReadStateAsync<ApprovalSnapshot>(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<CancellationToken>()))
|
||||
.Returns<string, string?, CancellationToken>((key, _, _) =>
|
||||
new ValueTask<ApprovalSnapshot?>(stateStore.TryGetValue(key, out object? val) ? val as ApprovalSnapshot : null));
|
||||
mockContext.Setup(c => c.ReadStateKeysAsync(It.IsAny<string?>(), It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new HashSet<string>());
|
||||
return mockContext;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invokes a protected method on the executor via reflection (for testing checkpoint hooks).
|
||||
/// </summary>
|
||||
private static async ValueTask InvokeProtectedMethodAsync(InvokeFunctionToolExecutor action, string methodName, IWorkflowContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
MethodInfo method = typeof(InvokeFunctionToolExecutor)
|
||||
.GetMethod(methodName, BindingFlags.NonPublic | BindingFlags.Instance)!;
|
||||
ValueTask result = (ValueTask)method.Invoke(action, [context, cancellationToken])!;
|
||||
await result.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimal concrete <see cref="ResponseAgentProvider"/> that exposes an injected
|
||||
/// <see cref="AIFunction"/> registry and records which function got invoked.
|
||||
/// Used by the framework-invoke approval branch (<c>InvokeRegisteredFunctionAsync</c>).
|
||||
/// </summary>
|
||||
private sealed class TestFunctionAgentProvider : ResponseAgentProvider
|
||||
{
|
||||
private readonly Action<string>? _onInvoke;
|
||||
private readonly Action<AIFunctionArguments>? _onInvokeArguments;
|
||||
|
||||
public TestFunctionAgentProvider(
|
||||
IEnumerable<AIFunction> functions,
|
||||
Action<string>? onInvoke = null,
|
||||
Action<AIFunctionArguments>? onInvokeArguments = null)
|
||||
{
|
||||
this._onInvoke = onInvoke;
|
||||
this._onInvokeArguments = onInvokeArguments;
|
||||
this.Functions = functions.Select(f => (AIFunction)new RecordingAIFunction(f, this)).ToList();
|
||||
}
|
||||
|
||||
internal void RecordInvocation(string name, AIFunctionArguments? arguments)
|
||||
{
|
||||
this._onInvoke?.Invoke(name);
|
||||
if (arguments is not null)
|
||||
{
|
||||
this._onInvokeArguments?.Invoke(arguments);
|
||||
}
|
||||
}
|
||||
|
||||
public override Task<string> CreateConversationAsync(CancellationToken cancellationToken = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public override Task<ChatMessage> CreateMessageAsync(string conversationId, ChatMessage conversationMessage, CancellationToken cancellationToken = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public override Task<ChatMessage> GetMessageAsync(string conversationId, string messageId, CancellationToken cancellationToken = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public override IAsyncEnumerable<AgentResponseUpdate> InvokeAgentAsync(
|
||||
string agentId, string? agentVersion, string? conversationId,
|
||||
IEnumerable<ChatMessage>? messages, IDictionary<string, object?>? inputArguments,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public override IAsyncEnumerable<ChatMessage> GetMessagesAsync(
|
||||
string conversationId, int? limit = null, string? after = null, string? before = null,
|
||||
bool newestFirst = false, CancellationToken cancellationToken = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
private sealed class RecordingAIFunction(AIFunction inner, TestFunctionAgentProvider owner) : AIFunction
|
||||
{
|
||||
public override string Name => inner.Name;
|
||||
public override string Description => inner.Description;
|
||||
public override JsonElement JsonSchema => inner.JsonSchema;
|
||||
|
||||
protected override ValueTask<object?> InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken)
|
||||
{
|
||||
owner.RecordInvocation(inner.Name, arguments);
|
||||
return inner.InvokeAsync(arguments, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private async Task ExecuteTestAsync(InvokeFunctionTool model)
|
||||
@@ -318,5 +645,33 @@ public sealed class InvokeFunctionToolExecutorTest(ITestOutputHelper output) : W
|
||||
return AssignParent<InvokeFunctionTool>(builder);
|
||||
}
|
||||
|
||||
private InvokeFunctionTool CreateModelWithVariableFunctionName(string displayName, string variableName)
|
||||
{
|
||||
InvokeFunctionTool.Builder builder = new()
|
||||
{
|
||||
Id = this.CreateActionId(),
|
||||
DisplayName = this.FormatDisplayName(displayName),
|
||||
FunctionName = new StringExpression.Builder(
|
||||
StringExpression.Variable(PropertyPath.TopicVariable(variableName))),
|
||||
RequireApproval = new BoolExpression.Builder(BoolExpression.Literal(true)),
|
||||
};
|
||||
return AssignParent<InvokeFunctionTool>(builder);
|
||||
}
|
||||
|
||||
private InvokeFunctionTool CreateModelWithVariableArgument(
|
||||
string displayName, string functionName, string argumentKey, string variableName)
|
||||
{
|
||||
InvokeFunctionTool.Builder builder = new()
|
||||
{
|
||||
Id = this.CreateActionId(),
|
||||
DisplayName = this.FormatDisplayName(displayName),
|
||||
FunctionName = new StringExpression.Builder(StringExpression.Literal(functionName)),
|
||||
RequireApproval = new BoolExpression.Builder(BoolExpression.Literal(true)),
|
||||
};
|
||||
builder.Arguments.Add(argumentKey,
|
||||
ValueExpression.Variable(PropertyPath.TopicVariable(variableName)));
|
||||
return AssignParent<InvokeFunctionTool>(builder);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -82,6 +82,7 @@ agent_framework/
|
||||
- **`MCPStdioTool`** / **`MCPStreamableHTTPTool`** / **`MCPWebsocketTool`** - Transport-specific subclasses.
|
||||
- **Argument allowlist (`_prepare_call_kwargs`)** - Before each `tools/call`, kwargs are filtered to an **allowlist** built from the tool's declared parameters (`inputSchema.properties`) plus any user-configured extras. Framework runtime kwargs injected through the function-invocation pipeline (e.g. `thread`, `conversation_id`, `chat_options`, `options`, `response_format`) are stripped by default rather than forwarded. A tool that declares no usable `properties` (including schemas with `additionalProperties: true`) forwards only the configured extras. The `_MCP_FRAMEWORK_DENYLIST` is a safety net for framework-named params a server *declares* in its schema (those are dropped); names explicitly opted in via `additional_tool_argument_names` always win. The reserved `_meta` key is extracted as MCP request metadata, never forwarded as an argument.
|
||||
- **`additional_tool_argument_names`** (constructor arg on all `MCPTool` subclasses) - Opt extra argument names back into the allowlist. Accepts a `Sequence[str]` (applied to every tool) or a `Mapping[str, Sequence[str]]` keyed by **remote tool name**, where the reserved key `"*"` denotes global extras. It is configured only in user code at construction; there is **no per-call/runtime override**, so a model-issued tool call cannot change which names pass through. To use a server that accepts `additionalProperties: true`, list the extra names here and then either (1) manually extend that tool's `inputSchema` (via the `.functions` list after connecting) so the model is prompted to supply them, or (2) supply the values yourself via `function_invocation_kwargs`. If a name is supplied by both the model and `function_invocation_kwargs`, the model-supplied value wins.
|
||||
- **Sampling guardrails** (`sampling_callback`) - Passing `client=` advertises `SamplingCapability` so the server can send `sampling/createMessage`. Because remote servers are untrusted (confused-deputy risk), the default `sampling_callback` is **deny-by-default** and applies, in order: a per-session rate limit (`sampling_max_requests`, default `_DEFAULT_SAMPLING_MAX_REQUESTS`), an approval gate (`sampling_approval_callback`), and a `maxTokens` cap (`sampling_max_tokens`, default `_DEFAULT_SAMPLING_MAX_TOKENS`). The approval callback (constructor arg on all subclasses; exported type alias `SamplingApprovalCallback`) receives the raw `CreateMessageRequestParams`, may be sync or async, and must return truthy to approve. When it is `None` (the default) every sampling request is denied; pass `lambda params: True` to restore legacy auto-approve as an explicit opt-in. Requests and denials are logged at WARNING (content is not logged). The per-session counter resets in `_reset_session_state`.
|
||||
- **`MCPTaskOptions`** (experimental, `MCP_LONG_RUNNING_TASKS` feature, **frozen**) - Per-tool-instance options controlling the SEP-2663 long-running task lifecycle. When the server advertises a tool with `execution.taskSupport == "required"`, `MCPTool.call_tool` transparently routes through `call_tool_as_task`, which sends an augmented `tools/call`, polls `tasks/get` until terminal, and reinterprets `tasks/result` as a normal `CallToolResult`. Instances are immutable; replace via `MCPTool.task_options = MCPTaskOptions(...)`. Fields:
|
||||
- `default_ttl: timedelta | None` — forwarded to the server as `params.task.ttl` (milliseconds). When `None`, the server's default applies.
|
||||
- `cancel_remote_task_on_local_cancellation: bool = True` — only gates the `CancelledError` path. Abandonment paths (see below) always cancel.
|
||||
|
||||
@@ -124,7 +124,7 @@ from ._harness._todo import (
|
||||
TodoSessionStore,
|
||||
TodoStore,
|
||||
)
|
||||
from ._mcp import MCPStdioTool, MCPStreamableHTTPTool, MCPTaskOptions, MCPWebsocketTool
|
||||
from ._mcp import MCPStdioTool, MCPStreamableHTTPTool, MCPTaskOptions, MCPWebsocketTool, SamplingApprovalCallback
|
||||
from ._middleware import (
|
||||
AgentContext,
|
||||
AgentMiddleware,
|
||||
@@ -472,6 +472,7 @@ __all__ = [
|
||||
"RunContext",
|
||||
"Runner",
|
||||
"RunnerContext",
|
||||
"SamplingApprovalCallback",
|
||||
"SecretString",
|
||||
"SelectiveToolCallCompactionStrategy",
|
||||
"SessionContext",
|
||||
|
||||
@@ -66,23 +66,45 @@ def _assemble_instructions(
|
||||
def _assemble_compaction_provider(
|
||||
*,
|
||||
disable_compaction: bool,
|
||||
max_context_window_tokens: int,
|
||||
max_output_tokens: int,
|
||||
max_context_window_tokens: int | None,
|
||||
max_output_tokens: int | None,
|
||||
history_source_id: str,
|
||||
before_compaction_strategy: CompactionStrategy | None,
|
||||
after_compaction_strategy: CompactionStrategy | None,
|
||||
tokenizer: TokenizerProtocol | None,
|
||||
) -> CompactionProvider | None:
|
||||
"""Build the compaction provider from parameters or defaults."""
|
||||
"""Build the compaction provider from parameters or defaults.
|
||||
|
||||
The token-budget defaults (``ContextWindowCompactionStrategy`` for the before phase and
|
||||
``ToolResultCompactionStrategy`` for the after phase) are only applied when the token
|
||||
params are provided. Caller-supplied strategies are always honored. Either phase may end
|
||||
up ``None``, which ``CompactionProvider`` interprets as "skip that phase".
|
||||
|
||||
Returns None when compaction is explicitly disabled, or when neither phase has a strategy
|
||||
(no custom strategies and no token budget to build the defaults).
|
||||
"""
|
||||
if disable_compaction:
|
||||
return None
|
||||
|
||||
before_strategy = before_compaction_strategy or ContextWindowCompactionStrategy(
|
||||
max_context_window_tokens=max_context_window_tokens,
|
||||
max_output_tokens=max_output_tokens,
|
||||
tokenizer=tokenizer,
|
||||
)
|
||||
after_strategy = after_compaction_strategy or ToolResultCompactionStrategy(keep_last_tool_call_groups=2)
|
||||
# Resolve the before-strategy: custom strategy wins; otherwise fall back to the
|
||||
# token-budget-aware default when token params are available.
|
||||
before_strategy = before_compaction_strategy
|
||||
if before_strategy is None and max_context_window_tokens is not None and max_output_tokens is not None:
|
||||
before_strategy = ContextWindowCompactionStrategy(
|
||||
max_context_window_tokens=max_context_window_tokens,
|
||||
max_output_tokens=max_output_tokens,
|
||||
tokenizer=tokenizer,
|
||||
)
|
||||
|
||||
# Resolve the after-strategy: custom strategy wins; otherwise fall back to the default
|
||||
# when token params are available.
|
||||
after_strategy = after_compaction_strategy
|
||||
if after_strategy is None and max_context_window_tokens is not None and max_output_tokens is not None:
|
||||
after_strategy = ToolResultCompactionStrategy(keep_last_tool_call_groups=2)
|
||||
|
||||
# Nothing to compact in either phase: skip the provider entirely.
|
||||
if before_strategy is None and after_strategy is None:
|
||||
return None
|
||||
|
||||
return CompactionProvider(
|
||||
before_strategy=before_strategy,
|
||||
@@ -157,8 +179,8 @@ def create_harness_agent(
|
||||
harness_instructions: str | None = None,
|
||||
agent_instructions: str | None = None,
|
||||
tools: ToolTypes | Callable[..., Any] | Sequence[ToolTypes | Callable[..., Any]] | None = None,
|
||||
max_context_window_tokens: int,
|
||||
max_output_tokens: int,
|
||||
max_context_window_tokens: int | None = None,
|
||||
max_output_tokens: int | None = None,
|
||||
history_provider: HistoryProvider | None = None,
|
||||
disable_compaction: bool = False,
|
||||
before_compaction_strategy: CompactionStrategy | None = None,
|
||||
@@ -206,8 +228,6 @@ def create_harness_agent(
|
||||
|
||||
agent = create_harness_agent(
|
||||
OpenAIChatClient(model="gpt-4o"),
|
||||
max_context_window_tokens=128_000,
|
||||
max_output_tokens=16_384,
|
||||
)
|
||||
session = agent.create_session()
|
||||
response = await agent.run("Plan a weekend trip to Seattle", session=session)
|
||||
@@ -243,13 +263,21 @@ def create_harness_agent(
|
||||
(e.g., "You are a research assistant focused on academic sources.").
|
||||
tools: Additional tools to include in the agent's toolset.
|
||||
max_context_window_tokens: Maximum tokens the model's context window supports.
|
||||
Used to construct the default token-budget-aware compaction strategies. When None
|
||||
(default) and no custom ``before_compaction_strategy`` / ``after_compaction_strategy``
|
||||
is provided, compaction is automatically disabled.
|
||||
max_output_tokens: Maximum output tokens per response.
|
||||
Used to construct the default compaction strategies and sets a default max_tokens
|
||||
chat option. When None (default), no default max_tokens option is set, and unless a
|
||||
custom compaction strategy is provided, compaction is automatically disabled.
|
||||
history_provider: Custom history provider. When None, an InMemoryHistoryProvider is used.
|
||||
disable_compaction: When True, skip compaction provider setup.
|
||||
before_compaction_strategy: Custom before-run compaction strategy.
|
||||
Defaults to ContextWindowCompactionStrategy (token-budget aware).
|
||||
after_compaction_strategy: Custom after-run compaction strategy.
|
||||
Defaults to ToolResultCompactionStrategy.
|
||||
before_compaction_strategy: Custom before-run compaction strategy. When provided,
|
||||
compaction runs even if token params are omitted. Defaults to
|
||||
ContextWindowCompactionStrategy (token-budget aware) when token params are provided.
|
||||
after_compaction_strategy: Custom after-run compaction strategy. When provided,
|
||||
compaction runs even if token params are omitted. Defaults to
|
||||
ToolResultCompactionStrategy when token params are provided.
|
||||
tokenizer: Custom tokenizer for compaction strategies.
|
||||
disable_todo: When True, skip the TodoProvider.
|
||||
todo_provider: Custom TodoProvider instance. Ignored when disable_todo is True.
|
||||
@@ -283,14 +311,19 @@ def create_harness_agent(
|
||||
A fully configured :class:`~agent_framework.Agent` instance.
|
||||
|
||||
Raises:
|
||||
ValueError: If max_context_window_tokens <= 0 or max_output_tokens < 0
|
||||
or max_output_tokens >= max_context_window_tokens.
|
||||
ValueError: If max_context_window_tokens is provided and <= 0, or
|
||||
max_output_tokens is provided and <= 0, or max_output_tokens >=
|
||||
max_context_window_tokens when both are provided.
|
||||
"""
|
||||
if max_context_window_tokens <= 0:
|
||||
if max_context_window_tokens is not None and max_context_window_tokens <= 0:
|
||||
raise ValueError("max_context_window_tokens must be positive.")
|
||||
if max_output_tokens < 0:
|
||||
raise ValueError("max_output_tokens must be non-negative.")
|
||||
if max_output_tokens >= max_context_window_tokens:
|
||||
if max_output_tokens is not None and max_output_tokens <= 0:
|
||||
raise ValueError("max_output_tokens must be positive.")
|
||||
if (
|
||||
max_context_window_tokens is not None
|
||||
and max_output_tokens is not None
|
||||
and max_output_tokens >= max_context_window_tokens
|
||||
):
|
||||
raise ValueError("max_output_tokens must be less than max_context_window_tokens.")
|
||||
|
||||
# Build history provider.
|
||||
@@ -347,7 +380,8 @@ def create_harness_agent(
|
||||
|
||||
# Build default options dict.
|
||||
default_opts: dict[str, Any] = dict(default_options) if default_options else {}
|
||||
default_opts.setdefault("max_tokens", max_output_tokens)
|
||||
if max_output_tokens is not None:
|
||||
default_opts.setdefault("max_tokens", max_output_tokens)
|
||||
|
||||
agent = Agent(
|
||||
client,
|
||||
|
||||
@@ -16,6 +16,7 @@ from contextlib import AsyncExitStack, _AsyncGeneratorContextManager # type: ig
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from functools import partial
|
||||
from inspect import isawaitable
|
||||
from typing import TYPE_CHECKING, Any, Literal, TypedDict, cast
|
||||
|
||||
from opentelemetry import propagate
|
||||
@@ -99,6 +100,22 @@ _mcp_call_headers: contextvars.ContextVar[dict[str, str]] = contextvars.ContextV
|
||||
MCP_DEFAULT_TIMEOUT = 30
|
||||
MCP_DEFAULT_SSE_READ_TIMEOUT = 60 * 5
|
||||
|
||||
# Default safety limits applied to server-initiated MCP sampling requests
|
||||
# (``sampling/createMessage``). MCP servers are untrusted third parties, so the
|
||||
# default ``sampling_callback`` denies requests unless an approval callback is
|
||||
# supplied, and bounds the cost of any approved request.
|
||||
# - ``_DEFAULT_SAMPLING_MAX_TOKENS`` clamps the server-requested ``maxTokens``.
|
||||
# - ``_DEFAULT_SAMPLING_MAX_REQUESTS`` caps the number of sampling requests per
|
||||
# session connection (the counter resets on reconnect).
|
||||
_DEFAULT_SAMPLING_MAX_TOKENS = 4096
|
||||
_DEFAULT_SAMPLING_MAX_REQUESTS = 25
|
||||
|
||||
# A user-supplied gate invoked before each server-initiated sampling request is
|
||||
# forwarded to the chat client. It receives the raw ``CreateMessageRequestParams``
|
||||
# and returns (or awaits to) a truthy value to approve the request or a falsy
|
||||
# value to deny it. Both synchronous and asynchronous callables are supported.
|
||||
SamplingApprovalCallback = Callable[["types.CreateMessageRequestParams"], "bool | Coroutine[Any, Any, bool]"]
|
||||
|
||||
# region: Helpers
|
||||
|
||||
LOG_LEVEL_MAPPING: dict[str, int] = {
|
||||
@@ -345,6 +362,9 @@ class MCPTool:
|
||||
session: ClientSession | None = None,
|
||||
request_timeout: int | None = None,
|
||||
client: SupportsChatGetResponse | None = None,
|
||||
sampling_approval_callback: SamplingApprovalCallback | None = None,
|
||||
sampling_max_tokens: int | None = _DEFAULT_SAMPLING_MAX_TOKENS,
|
||||
sampling_max_requests: int | None = _DEFAULT_SAMPLING_MAX_REQUESTS,
|
||||
additional_properties: dict[str, Any] | None = None,
|
||||
task_options: MCPTaskOptions | None = None,
|
||||
additional_tool_argument_names: Sequence[str] | Mapping[str, Sequence[str]] | None = None,
|
||||
@@ -378,6 +398,20 @@ class MCPTool:
|
||||
session: An existing MCP client session to use.
|
||||
request_timeout: Timeout in seconds for MCP requests.
|
||||
client: A chat client for sampling callbacks.
|
||||
sampling_approval_callback: Optional gate invoked before each server-initiated
|
||||
``sampling/createMessage`` request is forwarded to ``client``. It receives the
|
||||
raw ``CreateMessageRequestParams`` and may be synchronous or asynchronous;
|
||||
returning a truthy value approves the request and a falsy value denies it. When
|
||||
``None`` (the default), every sampling request is **denied** because MCP servers
|
||||
are untrusted third parties (confused-deputy risk). To restore the legacy
|
||||
auto-approve behavior, pass ``lambda params: True`` as an explicit, conscious
|
||||
opt-in.
|
||||
sampling_max_tokens: Upper bound applied to the server-requested ``maxTokens`` for an
|
||||
approved sampling request. The effective value is ``min(requested, cap)``. Set to
|
||||
``None`` to disable the cap. Defaults to ``_DEFAULT_SAMPLING_MAX_TOKENS``.
|
||||
sampling_max_requests: Maximum number of sampling requests allowed per session
|
||||
connection; further requests are rejected. The counter resets on reconnect. Set
|
||||
to ``None`` to disable the limit. Defaults to ``_DEFAULT_SAMPLING_MAX_REQUESTS``.
|
||||
additional_properties: Additional properties for the tool.
|
||||
task_options: Options controlling how long-running MCP tasks are driven for
|
||||
tools that advertise ``execution.taskSupport == "required"``. When ``None``,
|
||||
@@ -410,6 +444,10 @@ class MCPTool:
|
||||
self.session = session
|
||||
self.request_timeout = request_timeout
|
||||
self.client = client
|
||||
self.sampling_approval_callback = sampling_approval_callback
|
||||
self.sampling_max_tokens = sampling_max_tokens
|
||||
self.sampling_max_requests = sampling_max_requests
|
||||
self._sampling_request_count = 0
|
||||
self._functions: list[FunctionTool] = []
|
||||
self._tool_call_meta_by_name: dict[str, dict[str, Any]] = {}
|
||||
self._tool_task_support_by_name: dict[str, str] = {}
|
||||
@@ -539,6 +577,9 @@ class MCPTool:
|
||||
case _:
|
||||
result.append(Content.from_text(str(item)))
|
||||
|
||||
if mcp_type.structuredContent is not None:
|
||||
result.append(Content.from_text(json.dumps(mcp_type.structuredContent, default=str)))
|
||||
|
||||
if not result:
|
||||
result.append(Content.from_text("null"))
|
||||
return result
|
||||
@@ -840,6 +881,7 @@ class MCPTool:
|
||||
self._supports_prompts = True
|
||||
self._supports_logging = None
|
||||
self._ping_available = True
|
||||
self._sampling_request_count = 0
|
||||
|
||||
def _set_server_capabilities(self, capabilities: types.ServerCapabilities | None) -> None:
|
||||
self._server_capabilities = capabilities
|
||||
@@ -994,6 +1036,49 @@ class MCPTool:
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to set log level to %s", logger.level, exc_info=exc)
|
||||
|
||||
async def _sampling_request_approved(self, params: types.CreateMessageRequestParams) -> bool:
|
||||
"""Run the configured sampling approval gate.
|
||||
|
||||
Returns ``True`` only when an approval callback is configured and approves the request.
|
||||
When no callback is set, the request is denied (safe default for untrusted servers).
|
||||
"""
|
||||
callback = self.sampling_approval_callback
|
||||
if callback is None:
|
||||
logger.warning(
|
||||
"Denying MCP sampling request from '%s': no 'sampling_approval_callback' configured.",
|
||||
self.name,
|
||||
)
|
||||
return False
|
||||
try:
|
||||
outcome = callback(params)
|
||||
if isawaitable(outcome):
|
||||
outcome = await outcome
|
||||
except Exception as ex:
|
||||
logger.warning(
|
||||
"Denying MCP sampling request from '%s': approval callback raised %s.",
|
||||
self.name,
|
||||
ex,
|
||||
exc_info=True,
|
||||
)
|
||||
return False
|
||||
approved = bool(outcome)
|
||||
if not approved:
|
||||
logger.warning("MCP sampling request from '%s' was denied by the approval callback.", self.name)
|
||||
return approved
|
||||
|
||||
def _capped_sampling_max_tokens(self, requested: int) -> int:
|
||||
"""Clamp the server-requested ``maxTokens`` to ``sampling_max_tokens`` when configured."""
|
||||
cap = self.sampling_max_tokens
|
||||
if cap is not None and requested > cap:
|
||||
logger.warning(
|
||||
"Capping MCP sampling maxTokens for '%s' from %d to %d.",
|
||||
self.name,
|
||||
requested,
|
||||
cap,
|
||||
)
|
||||
return cap
|
||||
return requested
|
||||
|
||||
async def sampling_callback(
|
||||
self,
|
||||
context: RequestContext[ClientSession, Any],
|
||||
@@ -1001,20 +1086,32 @@ class MCPTool:
|
||||
) -> types.CreateMessageResult | types.ErrorData:
|
||||
"""Callback function for sampling.
|
||||
|
||||
This function is called when the MCP server needs to get a message completed.
|
||||
It uses the configured chat client to generate responses.
|
||||
This function is called when the MCP server sends a ``sampling/createMessage``
|
||||
request. It enforces safety guardrails and, if the request is approved, uses the
|
||||
configured chat client to generate a response.
|
||||
|
||||
Safety:
|
||||
MCP servers are untrusted third parties, so forwarding server-controlled prompts
|
||||
to the chat client without review is a confused-deputy risk. This callback
|
||||
therefore applies, in order: a per-session rate limit
|
||||
(``sampling_max_requests``), an approval gate (``sampling_approval_callback``,
|
||||
which **denies by default** when not configured), and a ``maxTokens`` cap
|
||||
(``sampling_max_tokens``). To allow sampling, pass a ``sampling_approval_callback``
|
||||
that returns a truthy value (use ``lambda params: True`` to auto-approve as an
|
||||
explicit opt-in).
|
||||
|
||||
Note:
|
||||
This is a simple version of this function. It can be overridden to allow
|
||||
more complex sampling. It gets added to the session at initialization time,
|
||||
so overriding it is the best way to customize this behavior.
|
||||
This is the default implementation. It can be overridden to allow more complex
|
||||
sampling. It gets added to the session at initialization time, so overriding it is
|
||||
the best way to customize this behavior.
|
||||
|
||||
Args:
|
||||
context: The request context from the MCP server.
|
||||
params: The message creation request parameters.
|
||||
|
||||
Returns:
|
||||
Either a CreateMessageResult with the generated message or ErrorData if generation fails.
|
||||
Either a CreateMessageResult with the generated message or ErrorData if the request
|
||||
is denied, rate limited, or generation fails.
|
||||
"""
|
||||
from mcp import types
|
||||
|
||||
@@ -1023,7 +1120,38 @@ class MCPTool:
|
||||
code=types.INTERNAL_ERROR,
|
||||
message="No chat client available. Please set a chat client.",
|
||||
)
|
||||
logger.debug("Sampling callback called with params: %s", params)
|
||||
|
||||
logger.warning(
|
||||
"MCP server '%s' sent a sampling/createMessage request (%d message(s), maxTokens=%s).",
|
||||
self.name,
|
||||
len(params.messages),
|
||||
params.maxTokens,
|
||||
)
|
||||
|
||||
if self.sampling_max_requests is not None:
|
||||
if self._sampling_request_count >= self.sampling_max_requests:
|
||||
logger.warning(
|
||||
"Denying MCP sampling request from '%s': per-session limit of %d reached.",
|
||||
self.name,
|
||||
self.sampling_max_requests,
|
||||
)
|
||||
return types.ErrorData(
|
||||
code=types.INVALID_REQUEST,
|
||||
message="Sampling rate limit exceeded for this MCP session.",
|
||||
)
|
||||
self._sampling_request_count += 1
|
||||
|
||||
if not await self._sampling_request_approved(params):
|
||||
if self.sampling_approval_callback is None:
|
||||
message = (
|
||||
"Sampling request denied. MCP sampling is disabled by default for untrusted "
|
||||
"servers; provide a 'sampling_approval_callback' that approves the request to "
|
||||
"enable it."
|
||||
)
|
||||
else:
|
||||
message = "Sampling request denied by the 'sampling_approval_callback'."
|
||||
return types.ErrorData(code=types.INVALID_REQUEST, message=message)
|
||||
|
||||
messages: list[Message] = []
|
||||
for msg in params.messages:
|
||||
messages.append(self._parse_message_from_mcp(msg))
|
||||
@@ -1045,7 +1173,7 @@ class MCPTool:
|
||||
|
||||
if params.temperature is not None:
|
||||
options["temperature"] = params.temperature
|
||||
options["max_tokens"] = params.maxTokens
|
||||
options["max_tokens"] = self._capped_sampling_max_tokens(params.maxTokens)
|
||||
if params.stopSequences is not None:
|
||||
options["stop"] = params.stopSequences
|
||||
|
||||
@@ -2219,6 +2347,9 @@ class MCPStdioTool(MCPTool):
|
||||
env: dict[str, str] | None = None,
|
||||
encoding: str | None = None,
|
||||
client: SupportsChatGetResponse | None = None,
|
||||
sampling_approval_callback: SamplingApprovalCallback | None = None,
|
||||
sampling_max_tokens: int | None = _DEFAULT_SAMPLING_MAX_TOKENS,
|
||||
sampling_max_requests: int | None = _DEFAULT_SAMPLING_MAX_REQUESTS,
|
||||
additional_properties: dict[str, Any] | None = None,
|
||||
task_options: MCPTaskOptions | None = None,
|
||||
additional_tool_argument_names: Sequence[str] | Mapping[str, Sequence[str]] | None = None,
|
||||
@@ -2266,6 +2397,16 @@ class MCPStdioTool(MCPTool):
|
||||
env: The environment variables to set for the command.
|
||||
encoding: The encoding to use for the command output.
|
||||
client: The chat client to use for sampling.
|
||||
sampling_approval_callback: Optional gate run before each server-initiated
|
||||
``sampling/createMessage`` request reaches ``client``. Receives the raw
|
||||
``CreateMessageRequestParams`` (sync or async); a truthy return approves the
|
||||
request, a falsy return denies it. When ``None`` (the default) every sampling
|
||||
request is **denied**, since MCP servers are untrusted (confused-deputy risk).
|
||||
Pass ``lambda params: True`` to auto-approve as an explicit opt-in.
|
||||
sampling_max_tokens: Cap applied to an approved request's ``maxTokens``
|
||||
(``min(requested, cap)``); ``None`` disables it.
|
||||
sampling_max_requests: Per-session cap on the number of sampling requests; further
|
||||
requests are rejected. Resets on reconnect. ``None`` disables it.
|
||||
task_options: Options for tools that advertise
|
||||
``execution.taskSupport == "required"``. See :class:`MCPTaskOptions`.
|
||||
additional_tool_argument_names: Extra argument names to forward to the MCP server in
|
||||
@@ -2300,6 +2441,9 @@ class MCPStdioTool(MCPTool):
|
||||
request_timeout=request_timeout,
|
||||
task_options=task_options,
|
||||
additional_tool_argument_names=additional_tool_argument_names,
|
||||
sampling_approval_callback=sampling_approval_callback,
|
||||
sampling_max_tokens=sampling_max_tokens,
|
||||
sampling_max_requests=sampling_max_requests,
|
||||
)
|
||||
self.command = command
|
||||
self.args = args or []
|
||||
@@ -2375,6 +2519,9 @@ class MCPStreamableHTTPTool(MCPTool):
|
||||
allowed_tools: Collection[str] | None = None,
|
||||
terminate_on_close: bool | None = None,
|
||||
client: SupportsChatGetResponse | None = None,
|
||||
sampling_approval_callback: SamplingApprovalCallback | None = None,
|
||||
sampling_max_tokens: int | None = _DEFAULT_SAMPLING_MAX_TOKENS,
|
||||
sampling_max_requests: int | None = _DEFAULT_SAMPLING_MAX_REQUESTS,
|
||||
additional_properties: dict[str, Any] | None = None,
|
||||
http_client: AsyncClient | None = None,
|
||||
header_provider: Callable[[dict[str, Any]], dict[str, str]] | None = None,
|
||||
@@ -2423,6 +2570,16 @@ class MCPStreamableHTTPTool(MCPTool):
|
||||
additional_properties: Additional properties.
|
||||
terminate_on_close: Close the transport when the MCP client is terminated.
|
||||
client: The chat client to use for sampling.
|
||||
sampling_approval_callback: Optional gate run before each server-initiated
|
||||
``sampling/createMessage`` request reaches ``client``. Receives the raw
|
||||
``CreateMessageRequestParams`` (sync or async); a truthy return approves the
|
||||
request, a falsy return denies it. When ``None`` (the default) every sampling
|
||||
request is **denied**, since MCP servers are untrusted (confused-deputy risk).
|
||||
Pass ``lambda params: True`` to auto-approve as an explicit opt-in.
|
||||
sampling_max_tokens: Cap applied to an approved request's ``maxTokens``
|
||||
(``min(requested, cap)``); ``None`` disables it.
|
||||
sampling_max_requests: Per-session cap on the number of sampling requests; further
|
||||
requests are rejected. Resets on reconnect. ``None`` disables it.
|
||||
http_client: Optional asyncClient to use. If not provided, the
|
||||
``streamable_http_client`` API will create and manage a default client.
|
||||
To configure headers, timeouts, or other HTTP client settings, create
|
||||
@@ -2466,6 +2623,9 @@ class MCPStreamableHTTPTool(MCPTool):
|
||||
request_timeout=request_timeout,
|
||||
task_options=task_options,
|
||||
additional_tool_argument_names=additional_tool_argument_names,
|
||||
sampling_approval_callback=sampling_approval_callback,
|
||||
sampling_max_tokens=sampling_max_tokens,
|
||||
sampling_max_requests=sampling_max_requests,
|
||||
)
|
||||
self.url = url
|
||||
self.terminate_on_close = terminate_on_close
|
||||
@@ -2590,6 +2750,9 @@ class MCPWebsocketTool(MCPTool):
|
||||
approval_mode: (Literal["always_require", "never_require"] | MCPSpecificApproval | None) = None,
|
||||
allowed_tools: Collection[str] | None = None,
|
||||
client: SupportsChatGetResponse | None = None,
|
||||
sampling_approval_callback: SamplingApprovalCallback | None = None,
|
||||
sampling_max_tokens: int | None = _DEFAULT_SAMPLING_MAX_TOKENS,
|
||||
sampling_max_requests: int | None = _DEFAULT_SAMPLING_MAX_REQUESTS,
|
||||
additional_properties: dict[str, Any] | None = None,
|
||||
task_options: MCPTaskOptions | None = None,
|
||||
additional_tool_argument_names: Sequence[str] | Mapping[str, Sequence[str]] | None = None,
|
||||
@@ -2635,6 +2798,16 @@ class MCPWebsocketTool(MCPTool):
|
||||
allowed_tools: A list of tools that are allowed to use this tool.
|
||||
additional_properties: Additional properties.
|
||||
client: The chat client to use for sampling.
|
||||
sampling_approval_callback: Optional gate run before each server-initiated
|
||||
``sampling/createMessage`` request reaches ``client``. Receives the raw
|
||||
``CreateMessageRequestParams`` (sync or async); a truthy return approves the
|
||||
request, a falsy return denies it. When ``None`` (the default) every sampling
|
||||
request is **denied**, since MCP servers are untrusted (confused-deputy risk).
|
||||
Pass ``lambda params: True`` to auto-approve as an explicit opt-in.
|
||||
sampling_max_tokens: Cap applied to an approved request's ``maxTokens``
|
||||
(``min(requested, cap)``); ``None`` disables it.
|
||||
sampling_max_requests: Per-session cap on the number of sampling requests; further
|
||||
requests are rejected. Resets on reconnect. ``None`` disables it.
|
||||
task_options: Options for tools that advertise
|
||||
``execution.taskSupport == "required"``. See :class:`MCPTaskOptions`.
|
||||
additional_tool_argument_names: Extra argument names to forward to the MCP server in
|
||||
@@ -2669,6 +2842,9 @@ class MCPWebsocketTool(MCPTool):
|
||||
request_timeout=request_timeout,
|
||||
task_options=task_options,
|
||||
additional_tool_argument_names=additional_tool_argument_names,
|
||||
sampling_approval_callback=sampling_approval_callback,
|
||||
sampling_max_tokens=sampling_max_tokens,
|
||||
sampling_max_requests=sampling_max_requests,
|
||||
)
|
||||
self.url = url
|
||||
self._client_kwargs = kwargs
|
||||
|
||||
@@ -3516,9 +3516,7 @@ class MCPSkill(Skill):
|
||||
result = await self._client.read_resource(_mcp_any_url(self._skill_md_uri))
|
||||
text = _mcp_join_text(result)
|
||||
if not text:
|
||||
raise ValueError(
|
||||
f"The MCP server returned no text content for SKILL.md resource '{self._skill_md_uri}'."
|
||||
)
|
||||
raise ValueError(f"The MCP server returned no text content for SKILL.md resource '{self._skill_md_uri}'.")
|
||||
self._content = text
|
||||
return text
|
||||
|
||||
@@ -3572,11 +3570,7 @@ class MCPSkill(Skill):
|
||||
or ``None`` if the name is unsafe.
|
||||
"""
|
||||
normalized = name.replace("\\", "/")
|
||||
if (
|
||||
normalized.startswith("/")
|
||||
or "://" in normalized
|
||||
or any(seg == ".." for seg in normalized.split("/"))
|
||||
):
|
||||
if normalized.startswith("/") or "://" in normalized or any(seg == ".." for seg in normalized.split("/")):
|
||||
logger.debug("Rejecting resource name with unsafe path components: %r", name)
|
||||
return None
|
||||
return normalized
|
||||
|
||||
@@ -194,6 +194,63 @@ def test_create_harness_agent_returns_full_agent() -> None:
|
||||
assert isinstance(agent, FullAgent)
|
||||
|
||||
|
||||
def test_create_harness_agent_no_token_params_disables_compaction() -> None:
|
||||
"""When token params are omitted, compaction is automatically disabled."""
|
||||
agent = create_harness_agent(
|
||||
client=_FakeChatClient(), # type: ignore[arg-type]
|
||||
)
|
||||
provider_types = [type(p) for p in agent.context_providers]
|
||||
assert CompactionProvider not in provider_types
|
||||
|
||||
|
||||
def test_create_harness_agent_no_token_params_skips_max_tokens_option() -> None:
|
||||
"""When max_output_tokens is omitted, max_tokens should not be set in default options."""
|
||||
agent = create_harness_agent(
|
||||
client=_FakeChatClient(), # type: ignore[arg-type]
|
||||
)
|
||||
assert agent.default_options.get("max_tokens") is None
|
||||
|
||||
|
||||
def test_create_harness_agent_custom_before_strategy_enables_compaction_without_tokens() -> None:
|
||||
"""A custom before_compaction_strategy enables compaction even when token params are omitted."""
|
||||
from agent_framework import ToolResultCompactionStrategy
|
||||
|
||||
agent = create_harness_agent(
|
||||
client=_FakeChatClient(), # type: ignore[arg-type]
|
||||
before_compaction_strategy=ToolResultCompactionStrategy(),
|
||||
)
|
||||
provider_types = [type(p) for p in agent.context_providers]
|
||||
assert CompactionProvider in provider_types
|
||||
|
||||
|
||||
def test_create_harness_agent_disable_compaction_overrides_custom_before_strategy() -> None:
|
||||
"""disable_compaction=True wins even when a custom before strategy is provided."""
|
||||
from agent_framework import ToolResultCompactionStrategy
|
||||
|
||||
agent = create_harness_agent(
|
||||
client=_FakeChatClient(), # type: ignore[arg-type]
|
||||
before_compaction_strategy=ToolResultCompactionStrategy(),
|
||||
disable_compaction=True,
|
||||
)
|
||||
provider_types = [type(p) for p in agent.context_providers]
|
||||
assert CompactionProvider not in provider_types
|
||||
|
||||
|
||||
def test_create_harness_agent_custom_after_strategy_enables_compaction_without_tokens() -> None:
|
||||
"""A custom after_compaction_strategy enables compaction even when token params are omitted."""
|
||||
from agent_framework import ToolResultCompactionStrategy
|
||||
|
||||
agent = create_harness_agent(
|
||||
client=_FakeChatClient(), # type: ignore[arg-type]
|
||||
after_compaction_strategy=ToolResultCompactionStrategy(),
|
||||
)
|
||||
compaction_providers = [p for p in agent.context_providers if isinstance(p, CompactionProvider)]
|
||||
assert len(compaction_providers) == 1
|
||||
# Before phase is skipped (no token budget, no custom before strategy), after phase is set.
|
||||
assert compaction_providers[0].before_strategy is None
|
||||
assert compaction_providers[0].after_strategy is not None
|
||||
|
||||
|
||||
# --- Validation Tests ---
|
||||
|
||||
|
||||
@@ -207,14 +264,15 @@ def test_create_harness_agent_rejects_invalid_context_tokens() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_create_harness_agent_rejects_negative_output_tokens() -> None:
|
||||
"""max_output_tokens must be non-negative."""
|
||||
with pytest.raises(ValueError, match="max_output_tokens must be non-negative"):
|
||||
create_harness_agent(
|
||||
client=_FakeChatClient(), # type: ignore[arg-type]
|
||||
max_context_window_tokens=1000,
|
||||
max_output_tokens=-1,
|
||||
)
|
||||
def test_create_harness_agent_rejects_non_positive_output_tokens() -> None:
|
||||
"""max_output_tokens must be positive when provided."""
|
||||
for invalid_value in (0, -1):
|
||||
with pytest.raises(ValueError, match="max_output_tokens must be positive"):
|
||||
create_harness_agent(
|
||||
client=_FakeChatClient(), # type: ignore[arg-type]
|
||||
max_context_window_tokens=1000,
|
||||
max_output_tokens=invalid_value,
|
||||
)
|
||||
|
||||
|
||||
def test_create_harness_agent_rejects_output_gte_context() -> None:
|
||||
|
||||
@@ -342,6 +342,69 @@ def test_parse_tool_result_from_mcp_resource_link_text_resource_and_unknown():
|
||||
assert result[1].text == "Embedded result"
|
||||
|
||||
|
||||
def test_parse_tool_result_from_mcp_structured_content_only():
|
||||
"""Test that structuredContent is parsed when content list is empty."""
|
||||
mcp_result = types.CallToolResult(
|
||||
content=[],
|
||||
structuredContent={"Tables": [{"Name": "Sales", "Columns": ["Amount", "Date"]}]},
|
||||
)
|
||||
result = _HELPER_MCP_TOOL._parse_tool_result_from_mcp(mcp_result)
|
||||
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 1
|
||||
assert result[0].type == "text"
|
||||
parsed = json.loads(result[0].text)
|
||||
assert parsed == {"Tables": [{"Name": "Sales", "Columns": ["Amount", "Date"]}]}
|
||||
|
||||
|
||||
def test_parse_tool_result_from_mcp_structured_content_with_text():
|
||||
"""Test that structuredContent is appended alongside regular content items."""
|
||||
mcp_result = types.CallToolResult(
|
||||
content=[types.TextContent(type="text", text="Summary")],
|
||||
structuredContent={"data": [1, 2, 3]},
|
||||
)
|
||||
result = _HELPER_MCP_TOOL._parse_tool_result_from_mcp(mcp_result)
|
||||
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 2
|
||||
assert result[0].type == "text"
|
||||
assert result[0].text == "Summary"
|
||||
assert result[1].type == "text"
|
||||
parsed = json.loads(result[1].text)
|
||||
assert parsed == {"data": [1, 2, 3]}
|
||||
|
||||
|
||||
def test_parse_tool_result_from_mcp_structured_content_none():
|
||||
"""Test that None structuredContent does not affect results."""
|
||||
mcp_result = types.CallToolResult(
|
||||
content=[types.TextContent(type="text", text="Hello")],
|
||||
structuredContent=None,
|
||||
)
|
||||
result = _HELPER_MCP_TOOL._parse_tool_result_from_mcp(mcp_result)
|
||||
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 1
|
||||
assert result[0].type == "text"
|
||||
assert result[0].text == "Hello"
|
||||
|
||||
|
||||
def test_parse_tool_result_from_mcp_structured_content_non_serializable():
|
||||
"""Test that non-JSON-serializable values in structuredContent degrade gracefully."""
|
||||
mcp_result = types.CallToolResult(
|
||||
content=[],
|
||||
structuredContent={"data": b"raw bytes", "count": 42},
|
||||
)
|
||||
result = _HELPER_MCP_TOOL._parse_tool_result_from_mcp(mcp_result)
|
||||
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 1
|
||||
assert result[0].type == "text"
|
||||
parsed = json.loads(result[0].text)
|
||||
assert parsed["count"] == 42
|
||||
# bytes should be converted to string representation via default=str
|
||||
assert "raw bytes" in parsed["data"]
|
||||
|
||||
|
||||
def test_mcp_content_types_to_ai_content_text():
|
||||
"""Test conversion of MCP text content to AI content."""
|
||||
mcp_content = types.TextContent(type="text", text="Sample text")
|
||||
@@ -1813,6 +1876,18 @@ async def test_mcp_tool_message_handler_cancel_and_replace():
|
||||
assert len(tool._pending_reload_tasks) == 0
|
||||
|
||||
|
||||
def _approve(_params: object) -> bool:
|
||||
"""Approving sampling gate used by tests that exercise forwarding behavior."""
|
||||
return True
|
||||
|
||||
|
||||
def _make_sampling_response(text: str = "response", model: str = "test-model") -> Mock:
|
||||
mock_response = Mock()
|
||||
mock_response.messages = [Message(role="assistant", contents=[Content.from_text(text)])]
|
||||
mock_response.model = model
|
||||
return mock_response
|
||||
|
||||
|
||||
async def test_mcp_tool_sampling_callback_no_client():
|
||||
"""Test sampling callback error path when no chat client is available."""
|
||||
tool = MCPStdioTool(name="test_tool", command="python")
|
||||
@@ -1828,9 +1903,190 @@ async def test_mcp_tool_sampling_callback_no_client():
|
||||
assert "No chat client available" in result.message
|
||||
|
||||
|
||||
async def test_mcp_tool_sampling_callback_denies_by_default():
|
||||
"""Sampling is denied when no approval callback is configured (safe default)."""
|
||||
tool = MCPStdioTool(name="test_tool", command="python")
|
||||
mock_chat_client = AsyncMock()
|
||||
tool.client = mock_chat_client
|
||||
|
||||
params = Mock()
|
||||
params.messages = []
|
||||
params.maxTokens = 128
|
||||
|
||||
result = await tool.sampling_callback(Mock(), params)
|
||||
|
||||
assert isinstance(result, types.ErrorData)
|
||||
assert result.code == types.INVALID_REQUEST
|
||||
assert "denied" in result.message
|
||||
assert "sampling_approval_callback" in result.message
|
||||
mock_chat_client.get_response.assert_not_called()
|
||||
|
||||
|
||||
async def test_mcp_tool_sampling_callback_denied_by_callback():
|
||||
"""Sampling is denied when the approval callback returns a falsy value."""
|
||||
tool = MCPStdioTool(name="test_tool", command="python", sampling_approval_callback=lambda params: False)
|
||||
mock_chat_client = AsyncMock()
|
||||
tool.client = mock_chat_client
|
||||
|
||||
params = Mock()
|
||||
params.messages = []
|
||||
params.maxTokens = 128
|
||||
|
||||
result = await tool.sampling_callback(Mock(), params)
|
||||
|
||||
assert isinstance(result, types.ErrorData)
|
||||
assert result.code == types.INVALID_REQUEST
|
||||
assert "denied by the 'sampling_approval_callback'" in result.message
|
||||
mock_chat_client.get_response.assert_not_called()
|
||||
|
||||
|
||||
async def test_mcp_tool_sampling_callback_callback_exception_denies():
|
||||
"""An approval callback that raises results in denial, not an LLM call."""
|
||||
|
||||
def boom(_params: object) -> bool:
|
||||
raise RuntimeError("approval error")
|
||||
|
||||
tool = MCPStdioTool(name="test_tool", command="python", sampling_approval_callback=boom)
|
||||
mock_chat_client = AsyncMock()
|
||||
tool.client = mock_chat_client
|
||||
|
||||
params = Mock()
|
||||
params.messages = []
|
||||
params.maxTokens = 128
|
||||
|
||||
result = await tool.sampling_callback(Mock(), params)
|
||||
|
||||
assert isinstance(result, types.ErrorData)
|
||||
assert result.code == types.INVALID_REQUEST
|
||||
mock_chat_client.get_response.assert_not_called()
|
||||
|
||||
|
||||
async def test_mcp_tool_sampling_callback_async_approval():
|
||||
"""An async approval callback that approves allows the request through."""
|
||||
|
||||
async def approve(_params: object) -> bool:
|
||||
return True
|
||||
|
||||
tool = MCPStdioTool(name="test_tool", command="python", sampling_approval_callback=approve)
|
||||
mock_chat_client = AsyncMock()
|
||||
mock_chat_client.get_response.return_value = _make_sampling_response("ok")
|
||||
tool.client = mock_chat_client
|
||||
|
||||
params = Mock()
|
||||
params.messages = [types.PromptMessage(role="user", content=types.TextContent(type="text", text="Hi"))]
|
||||
params.temperature = None
|
||||
params.maxTokens = 100
|
||||
params.stopSequences = None
|
||||
params.systemPrompt = None
|
||||
params.tools = None
|
||||
params.toolChoice = None
|
||||
|
||||
result = await tool.sampling_callback(Mock(), params)
|
||||
|
||||
assert isinstance(result, types.CreateMessageResult)
|
||||
assert result.content.text == "ok"
|
||||
mock_chat_client.get_response.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_mcp_tool_sampling_callback_clamps_max_tokens():
|
||||
"""An approved request's maxTokens is clamped to sampling_max_tokens."""
|
||||
tool = MCPStdioTool(
|
||||
name="test_tool",
|
||||
command="python",
|
||||
sampling_approval_callback=_approve,
|
||||
sampling_max_tokens=512,
|
||||
)
|
||||
mock_chat_client = AsyncMock()
|
||||
mock_chat_client.get_response.return_value = _make_sampling_response()
|
||||
tool.client = mock_chat_client
|
||||
|
||||
params = Mock()
|
||||
params.messages = [types.PromptMessage(role="user", content=types.TextContent(type="text", text="Hi"))]
|
||||
params.temperature = None
|
||||
params.maxTokens = 1_000_000
|
||||
params.stopSequences = None
|
||||
params.systemPrompt = None
|
||||
params.tools = None
|
||||
params.toolChoice = None
|
||||
|
||||
result = await tool.sampling_callback(Mock(), params)
|
||||
|
||||
assert isinstance(result, types.CreateMessageResult)
|
||||
options = mock_chat_client.get_response.call_args.kwargs.get("options") or {}
|
||||
assert options["max_tokens"] == 512
|
||||
|
||||
|
||||
async def test_mcp_tool_sampling_callback_does_not_clamp_under_cap():
|
||||
"""A request below the cap keeps its requested maxTokens."""
|
||||
tool = MCPStdioTool(
|
||||
name="test_tool",
|
||||
command="python",
|
||||
sampling_approval_callback=_approve,
|
||||
sampling_max_tokens=512,
|
||||
)
|
||||
mock_chat_client = AsyncMock()
|
||||
mock_chat_client.get_response.return_value = _make_sampling_response()
|
||||
tool.client = mock_chat_client
|
||||
|
||||
params = Mock()
|
||||
params.messages = [types.PromptMessage(role="user", content=types.TextContent(type="text", text="Hi"))]
|
||||
params.temperature = None
|
||||
params.maxTokens = 100
|
||||
params.stopSequences = None
|
||||
params.systemPrompt = None
|
||||
params.tools = None
|
||||
params.toolChoice = None
|
||||
|
||||
result = await tool.sampling_callback(Mock(), params)
|
||||
|
||||
assert isinstance(result, types.CreateMessageResult)
|
||||
options = mock_chat_client.get_response.call_args.kwargs.get("options") or {}
|
||||
assert options["max_tokens"] == 100
|
||||
|
||||
|
||||
async def test_mcp_tool_sampling_callback_rate_limited():
|
||||
"""Sampling requests beyond sampling_max_requests are rejected per session."""
|
||||
tool = MCPStdioTool(
|
||||
name="test_tool",
|
||||
command="python",
|
||||
sampling_approval_callback=_approve,
|
||||
sampling_max_requests=2,
|
||||
)
|
||||
mock_chat_client = AsyncMock()
|
||||
mock_chat_client.get_response.return_value = _make_sampling_response()
|
||||
tool.client = mock_chat_client
|
||||
|
||||
def make_params() -> Mock:
|
||||
params = Mock()
|
||||
params.messages = [types.PromptMessage(role="user", content=types.TextContent(type="text", text="Hi"))]
|
||||
params.temperature = None
|
||||
params.maxTokens = 100
|
||||
params.stopSequences = None
|
||||
params.systemPrompt = None
|
||||
params.tools = None
|
||||
params.toolChoice = None
|
||||
return params
|
||||
|
||||
first = await tool.sampling_callback(Mock(), make_params())
|
||||
second = await tool.sampling_callback(Mock(), make_params())
|
||||
third = await tool.sampling_callback(Mock(), make_params())
|
||||
|
||||
assert isinstance(first, types.CreateMessageResult)
|
||||
assert isinstance(second, types.CreateMessageResult)
|
||||
assert isinstance(third, types.ErrorData)
|
||||
assert third.code == types.INVALID_REQUEST
|
||||
assert "rate limit" in third.message.lower()
|
||||
assert mock_chat_client.get_response.await_count == 2
|
||||
|
||||
# The counter resets on a session reset.
|
||||
tool._reset_session_state()
|
||||
fourth = await tool.sampling_callback(Mock(), make_params())
|
||||
assert isinstance(fourth, types.CreateMessageResult)
|
||||
|
||||
|
||||
async def test_mcp_tool_sampling_callback_chat_client_exception():
|
||||
"""Test sampling callback when chat client raises exception."""
|
||||
tool = MCPStdioTool(name="test_tool", command="python")
|
||||
tool = MCPStdioTool(name="test_tool", command="python", sampling_approval_callback=_approve)
|
||||
|
||||
# Mock chat client that raises exception
|
||||
mock_chat_client = AsyncMock()
|
||||
@@ -1846,7 +2102,7 @@ async def test_mcp_tool_sampling_callback_chat_client_exception():
|
||||
mock_message.content.text = "Test question"
|
||||
params.messages = [mock_message]
|
||||
params.temperature = None
|
||||
params.maxTokens = None
|
||||
params.maxTokens = 100
|
||||
params.stopSequences = None
|
||||
params.systemPrompt = None
|
||||
params.tools = None
|
||||
@@ -1863,7 +2119,7 @@ async def test_mcp_tool_sampling_callback_no_valid_content():
|
||||
"""Test sampling callback when response has no valid content types."""
|
||||
from agent_framework import Message
|
||||
|
||||
tool = MCPStdioTool(name="test_tool", command="python")
|
||||
tool = MCPStdioTool(name="test_tool", command="python", sampling_approval_callback=_approve)
|
||||
|
||||
# Mock chat client with response containing only invalid content types
|
||||
mock_chat_client = AsyncMock()
|
||||
@@ -1892,7 +2148,7 @@ async def test_mcp_tool_sampling_callback_no_valid_content():
|
||||
mock_message.content.text = "Test question"
|
||||
params.messages = [mock_message]
|
||||
params.temperature = None
|
||||
params.maxTokens = None
|
||||
params.maxTokens = 100
|
||||
params.stopSequences = None
|
||||
params.systemPrompt = None
|
||||
params.tools = None
|
||||
@@ -1905,18 +2161,18 @@ async def test_mcp_tool_sampling_callback_no_valid_content():
|
||||
assert "Failed to get right content types from the response." in result.message
|
||||
mock_chat_client.get_response.assert_awaited_once()
|
||||
_, kwargs = mock_chat_client.get_response.await_args
|
||||
assert kwargs["options"] == {"max_tokens": None}
|
||||
assert kwargs["options"] == {"max_tokens": 100}
|
||||
|
||||
|
||||
async def test_mcp_tool_sampling_callback_no_response_and_successful_message_creation():
|
||||
"""Test sampling callback when the chat client returns no response and then valid content."""
|
||||
tool = MCPStdioTool(name="test_tool", command="python")
|
||||
tool = MCPStdioTool(name="test_tool", command="python", sampling_approval_callback=_approve)
|
||||
tool.client = AsyncMock()
|
||||
|
||||
params = Mock()
|
||||
params.messages = [types.PromptMessage(role="user", content=types.TextContent(type="text", text="Hi"))]
|
||||
params.temperature = None
|
||||
params.maxTokens = None
|
||||
params.maxTokens = 100
|
||||
params.stopSequences = None
|
||||
params.systemPrompt = None
|
||||
params.tools = None
|
||||
@@ -1955,7 +2211,7 @@ async def test_mcp_tool_sampling_callback_forwards_system_prompt():
|
||||
"""Test sampling callback passes systemPrompt as instructions in options."""
|
||||
from agent_framework import Message
|
||||
|
||||
tool = MCPStdioTool(name="test_tool", command="python")
|
||||
tool = MCPStdioTool(name="test_tool", command="python", sampling_approval_callback=_approve)
|
||||
|
||||
mock_chat_client = AsyncMock()
|
||||
mock_response = Mock()
|
||||
@@ -1972,7 +2228,7 @@ async def test_mcp_tool_sampling_callback_forwards_system_prompt():
|
||||
mock_message.content.text = "Test question"
|
||||
params.messages = [mock_message]
|
||||
params.temperature = None
|
||||
params.maxTokens = None
|
||||
params.maxTokens = 100
|
||||
params.stopSequences = None
|
||||
params.systemPrompt = "You are a helpful assistant"
|
||||
params.tools = None
|
||||
@@ -1990,7 +2246,7 @@ async def test_mcp_tool_sampling_callback_forwards_tools():
|
||||
"""Test sampling callback converts MCP tools to FunctionTools and passes them in options."""
|
||||
from agent_framework import FunctionTool, Message
|
||||
|
||||
tool = MCPStdioTool(name="test_tool", command="python")
|
||||
tool = MCPStdioTool(name="test_tool", command="python", sampling_approval_callback=_approve)
|
||||
|
||||
mock_chat_client = AsyncMock()
|
||||
mock_response = Mock()
|
||||
@@ -2013,7 +2269,7 @@ async def test_mcp_tool_sampling_callback_forwards_tools():
|
||||
mock_message.content.text = "Test question"
|
||||
params.messages = [mock_message]
|
||||
params.temperature = None
|
||||
params.maxTokens = None
|
||||
params.maxTokens = 100
|
||||
params.stopSequences = None
|
||||
params.systemPrompt = None
|
||||
params.tools = [mcp_tool]
|
||||
@@ -2036,7 +2292,7 @@ async def test_mcp_tool_sampling_callback_forwards_tool_choice():
|
||||
"""Test sampling callback passes toolChoice mode in options."""
|
||||
from agent_framework import Message
|
||||
|
||||
tool = MCPStdioTool(name="test_tool", command="python")
|
||||
tool = MCPStdioTool(name="test_tool", command="python", sampling_approval_callback=_approve)
|
||||
|
||||
mock_chat_client = AsyncMock()
|
||||
mock_response = Mock()
|
||||
@@ -2053,7 +2309,7 @@ async def test_mcp_tool_sampling_callback_forwards_tool_choice():
|
||||
mock_message.content.text = "Test question"
|
||||
params.messages = [mock_message]
|
||||
params.temperature = None
|
||||
params.maxTokens = None
|
||||
params.maxTokens = 100
|
||||
params.stopSequences = None
|
||||
params.systemPrompt = None
|
||||
params.tools = None
|
||||
@@ -2071,7 +2327,7 @@ async def test_mcp_tool_sampling_callback_forwards_empty_system_prompt():
|
||||
"""Test sampling callback forwards empty string systemPrompt as instructions."""
|
||||
from agent_framework import Message
|
||||
|
||||
tool = MCPStdioTool(name="test_tool", command="python")
|
||||
tool = MCPStdioTool(name="test_tool", command="python", sampling_approval_callback=_approve)
|
||||
|
||||
mock_chat_client = AsyncMock()
|
||||
mock_response = Mock()
|
||||
@@ -2088,7 +2344,7 @@ async def test_mcp_tool_sampling_callback_forwards_empty_system_prompt():
|
||||
mock_message.content.text = "Test question"
|
||||
params.messages = [mock_message]
|
||||
params.temperature = None
|
||||
params.maxTokens = None
|
||||
params.maxTokens = 100
|
||||
params.stopSequences = None
|
||||
params.systemPrompt = ""
|
||||
params.tools = None
|
||||
@@ -2106,7 +2362,7 @@ async def test_mcp_tool_sampling_callback_forwards_empty_tools_list():
|
||||
"""Test sampling callback forwards empty tools list in options."""
|
||||
from agent_framework import Message
|
||||
|
||||
tool = MCPStdioTool(name="test_tool", command="python")
|
||||
tool = MCPStdioTool(name="test_tool", command="python", sampling_approval_callback=_approve)
|
||||
|
||||
mock_chat_client = AsyncMock()
|
||||
mock_response = Mock()
|
||||
@@ -2123,7 +2379,7 @@ async def test_mcp_tool_sampling_callback_forwards_empty_tools_list():
|
||||
mock_message.content.text = "Test question"
|
||||
params.messages = [mock_message]
|
||||
params.temperature = None
|
||||
params.maxTokens = None
|
||||
params.maxTokens = 100
|
||||
params.stopSequences = None
|
||||
params.systemPrompt = None
|
||||
params.tools = []
|
||||
@@ -2141,7 +2397,7 @@ async def test_mcp_tool_sampling_callback_forwards_generation_params_in_options(
|
||||
"""Test sampling callback passes temperature, max_tokens, and stop in options."""
|
||||
from agent_framework import Message
|
||||
|
||||
tool = MCPStdioTool(name="test_tool", command="python")
|
||||
tool = MCPStdioTool(name="test_tool", command="python", sampling_approval_callback=_approve)
|
||||
|
||||
mock_chat_client = AsyncMock()
|
||||
mock_response = Mock()
|
||||
@@ -2182,7 +2438,7 @@ async def test_mcp_tool_sampling_callback_omits_temperature_when_none():
|
||||
"""Test sampling callback does not set temperature in options when it is None."""
|
||||
from agent_framework import Message
|
||||
|
||||
tool = MCPStdioTool(name="test_tool", command="python")
|
||||
tool = MCPStdioTool(name="test_tool", command="python", sampling_approval_callback=_approve)
|
||||
|
||||
mock_chat_client = AsyncMock()
|
||||
mock_response = Mock()
|
||||
@@ -2219,7 +2475,7 @@ async def test_mcp_tool_sampling_callback_always_passes_max_tokens():
|
||||
"""Test sampling callback always sets max_tokens in options since maxTokens is a required int field."""
|
||||
from agent_framework import Message
|
||||
|
||||
tool = MCPStdioTool(name="test_tool", command="python")
|
||||
tool = MCPStdioTool(name="test_tool", command="python", sampling_approval_callback=_approve)
|
||||
|
||||
mock_chat_client = AsyncMock()
|
||||
mock_response = Mock()
|
||||
|
||||
@@ -76,6 +76,7 @@ def _make_call_tool_result(text: str = "result", is_error: bool = False) -> Mock
|
||||
result = Mock()
|
||||
result.isError = is_error
|
||||
result.content = [types.TextContent(type="text", text=text)]
|
||||
result.structuredContent = None
|
||||
return result
|
||||
|
||||
|
||||
@@ -281,9 +282,7 @@ async def test_mcp_prompts_get_creates_client_span(span_exporter: InMemorySpanEx
|
||||
async def test_mcp_prompts_get_mcp_error_sets_error_type(span_exporter: InMemorySpanExporter):
|
||||
"""When session.get_prompt() raises McpError, the span should have error.type and ERROR status."""
|
||||
tool = _make_connected_mcp_tool()
|
||||
tool.session.get_prompt = AsyncMock(
|
||||
side_effect=McpError(ErrorData(code=-32602, message="prompt not found"))
|
||||
)
|
||||
tool.session.get_prompt = AsyncMock(side_effect=McpError(ErrorData(code=-32602, message="prompt not found")))
|
||||
|
||||
span_exporter.clear()
|
||||
with pytest.raises(ToolExecutionException):
|
||||
|
||||
@@ -35,26 +35,22 @@ description: Convert between common units.
|
||||
Body content here.
|
||||
"""
|
||||
|
||||
SAMPLE_SKILL_INDEX = json.dumps(
|
||||
{
|
||||
"$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
|
||||
"skills": [
|
||||
{
|
||||
"name": "unit-converter",
|
||||
"type": "skill-md",
|
||||
"description": "Convert between common units.",
|
||||
"url": "skill://unit-converter/SKILL.md",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
SAMPLE_SKILL_INDEX = json.dumps({
|
||||
"$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
|
||||
"skills": [
|
||||
{
|
||||
"name": "unit-converter",
|
||||
"type": "skill-md",
|
||||
"description": "Convert between common units.",
|
||||
"url": "skill://unit-converter/SKILL.md",
|
||||
}
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
def _make_text_result(text: str, uri: str = "skill://test") -> ReadResourceResult:
|
||||
"""Create a ReadResourceResult with a single TextResourceContents."""
|
||||
return ReadResourceResult(
|
||||
contents=[TextResourceContents(uri=AnyUrl(uri), text=text, mimeType="text/markdown")]
|
||||
)
|
||||
return ReadResourceResult(contents=[TextResourceContents(uri=AnyUrl(uri), text=text, mimeType="text/markdown")])
|
||||
|
||||
|
||||
def _make_blob_result(
|
||||
@@ -230,12 +226,10 @@ class TestMCPSkill:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_resource_text(self) -> None:
|
||||
client = _make_client(
|
||||
**{
|
||||
"skill://unit-converter/SKILL.md": _make_text_result(SAMPLE_SKILL_MD),
|
||||
"skill://unit-converter/references/checklist.md": _make_text_result("- check thing 1\n- check thing 2"),
|
||||
}
|
||||
)
|
||||
client = _make_client(**{
|
||||
"skill://unit-converter/SKILL.md": _make_text_result(SAMPLE_SKILL_MD),
|
||||
"skill://unit-converter/references/checklist.md": _make_text_result("- check thing 1\n- check thing 2"),
|
||||
})
|
||||
from agent_framework import SkillFrontmatter
|
||||
|
||||
fm = SkillFrontmatter(name="unit-converter", description="Convert between common units.")
|
||||
@@ -249,12 +243,10 @@ class TestMCPSkill:
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_resource_binary(self) -> None:
|
||||
data = bytes([0x01, 0x02, 0x03, 0x04])
|
||||
client = _make_client(
|
||||
**{
|
||||
"skill://unit-converter/SKILL.md": _make_text_result(SAMPLE_SKILL_MD),
|
||||
"skill://unit-converter/assets/icon.bin": _make_blob_result(data),
|
||||
}
|
||||
)
|
||||
client = _make_client(**{
|
||||
"skill://unit-converter/SKILL.md": _make_text_result(SAMPLE_SKILL_MD),
|
||||
"skill://unit-converter/assets/icon.bin": _make_blob_result(data),
|
||||
})
|
||||
from agent_framework import SkillFrontmatter
|
||||
|
||||
fm = SkillFrontmatter(name="unit-converter", description="Convert between common units.")
|
||||
@@ -345,12 +337,10 @@ class TestMCPSkillsSource:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_index_based_discovery_returns_skill(self) -> None:
|
||||
client = _make_client(
|
||||
**{
|
||||
"skill://index.json": _make_text_result(SAMPLE_SKILL_INDEX, uri="skill://index.json"),
|
||||
"skill://unit-converter/SKILL.md": _make_text_result(SAMPLE_SKILL_MD),
|
||||
}
|
||||
)
|
||||
client = _make_client(**{
|
||||
"skill://index.json": _make_text_result(SAMPLE_SKILL_INDEX, uri="skill://index.json"),
|
||||
"skill://unit-converter/SKILL.md": _make_text_result(SAMPLE_SKILL_MD),
|
||||
})
|
||||
source = MCPSkillsSource(client=client)
|
||||
skills = await source.get_skills()
|
||||
|
||||
@@ -373,9 +363,7 @@ class TestMCPSkillsSource:
|
||||
async def test_does_not_read_skill_md_during_discovery(self) -> None:
|
||||
# Index points to a skill, but SKILL.md is not registered on the server.
|
||||
# Discovery should succeed because it only reads the index.
|
||||
client = _make_client(
|
||||
**{"skill://index.json": _make_text_result(SAMPLE_SKILL_INDEX, uri="skill://index.json")}
|
||||
)
|
||||
client = _make_client(**{"skill://index.json": _make_text_result(SAMPLE_SKILL_INDEX, uri="skill://index.json")})
|
||||
source = MCPSkillsSource(client=client)
|
||||
skills = await source.get_skills()
|
||||
|
||||
@@ -384,19 +372,17 @@ class TestMCPSkillsSource:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_name_is_skipped(self) -> None:
|
||||
index_json = json.dumps(
|
||||
{
|
||||
"$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
|
||||
"skills": [
|
||||
{
|
||||
"name": "UnitConverter", # Invalid: uppercase
|
||||
"type": "skill-md",
|
||||
"description": "Convert between common units.",
|
||||
"url": "skill://UnitConverter/SKILL.md",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
index_json = json.dumps({
|
||||
"$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
|
||||
"skills": [
|
||||
{
|
||||
"name": "UnitConverter", # Invalid: uppercase
|
||||
"type": "skill-md",
|
||||
"description": "Convert between common units.",
|
||||
"url": "skill://UnitConverter/SKILL.md",
|
||||
}
|
||||
],
|
||||
})
|
||||
client = _make_client(**{"skill://index.json": _make_text_result(index_json, uri="skill://index.json")})
|
||||
source = MCPSkillsSource(client=client)
|
||||
skills = await source.get_skills()
|
||||
@@ -404,18 +390,16 @@ class TestMCPSkillsSource:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_required_fields_is_skipped(self) -> None:
|
||||
index_json = json.dumps(
|
||||
{
|
||||
"$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
|
||||
"skills": [
|
||||
{
|
||||
"name": "unit-converter",
|
||||
"type": "skill-md",
|
||||
# Missing description and url
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
index_json = json.dumps({
|
||||
"$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
|
||||
"skills": [
|
||||
{
|
||||
"name": "unit-converter",
|
||||
"type": "skill-md",
|
||||
# Missing description and url
|
||||
}
|
||||
],
|
||||
})
|
||||
client = _make_client(**{"skill://index.json": _make_text_result(index_json, uri="skill://index.json")})
|
||||
source = MCPSkillsSource(client=client)
|
||||
skills = await source.get_skills()
|
||||
@@ -423,19 +407,17 @@ class TestMCPSkillsSource:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unsupported_type_is_skipped(self) -> None:
|
||||
index_json = json.dumps(
|
||||
{
|
||||
"$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
|
||||
"skills": [
|
||||
{
|
||||
"name": "some-skill",
|
||||
"type": "archive",
|
||||
"description": "Packaged skill.",
|
||||
"url": "skill://some-skill.tar.gz",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
index_json = json.dumps({
|
||||
"$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
|
||||
"skills": [
|
||||
{
|
||||
"name": "some-skill",
|
||||
"type": "archive",
|
||||
"description": "Packaged skill.",
|
||||
"url": "skill://some-skill.tar.gz",
|
||||
}
|
||||
],
|
||||
})
|
||||
client = _make_client(**{"skill://index.json": _make_text_result(index_json, uri="skill://index.json")})
|
||||
source = MCPSkillsSource(client=client)
|
||||
skills = await source.get_skills()
|
||||
@@ -443,18 +425,16 @@ class TestMCPSkillsSource:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_template_type_is_skipped(self) -> None:
|
||||
index_json = json.dumps(
|
||||
{
|
||||
"$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
|
||||
"skills": [
|
||||
{
|
||||
"type": "mcp-resource-template",
|
||||
"description": "Per-product documentation skill",
|
||||
"url": "skill://docs/{product}/SKILL.md",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
index_json = json.dumps({
|
||||
"$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
|
||||
"skills": [
|
||||
{
|
||||
"type": "mcp-resource-template",
|
||||
"description": "Per-product documentation skill",
|
||||
"url": "skill://docs/{product}/SKILL.md",
|
||||
}
|
||||
],
|
||||
})
|
||||
client = _make_client(**{"skill://index.json": _make_text_result(index_json, uri="skill://index.json")})
|
||||
source = MCPSkillsSource(client=client)
|
||||
skills = await source.get_skills()
|
||||
@@ -462,31 +442,25 @@ class TestMCPSkillsSource:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_index_returns_empty(self) -> None:
|
||||
client = _make_client(
|
||||
**{"skill://index.json": _make_text_result('{"skills": []}', uri="skill://index.json")}
|
||||
)
|
||||
client = _make_client(**{"skill://index.json": _make_text_result('{"skills": []}', uri="skill://index.json")})
|
||||
source = MCPSkillsSource(client=client)
|
||||
skills = await source.get_skills()
|
||||
assert skills == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_malformed_index_json_returns_empty(self) -> None:
|
||||
client = _make_client(
|
||||
**{"skill://index.json": _make_text_result("not valid json", uri="skill://index.json")}
|
||||
)
|
||||
client = _make_client(**{"skill://index.json": _make_text_result("not valid json", uri="skill://index.json")})
|
||||
source = MCPSkillsSource(client=client)
|
||||
skills = await source.get_skills()
|
||||
assert skills == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sibling_text_resource(self) -> None:
|
||||
client = _make_client(
|
||||
**{
|
||||
"skill://index.json": _make_text_result(SAMPLE_SKILL_INDEX, uri="skill://index.json"),
|
||||
"skill://unit-converter/SKILL.md": _make_text_result(SAMPLE_SKILL_MD),
|
||||
"skill://unit-converter/references/checklist.md": _make_text_result("- check thing 1\n- check thing 2"),
|
||||
}
|
||||
)
|
||||
client = _make_client(**{
|
||||
"skill://index.json": _make_text_result(SAMPLE_SKILL_INDEX, uri="skill://index.json"),
|
||||
"skill://unit-converter/SKILL.md": _make_text_result(SAMPLE_SKILL_MD),
|
||||
"skill://unit-converter/references/checklist.md": _make_text_result("- check thing 1\n- check thing 2"),
|
||||
})
|
||||
source = MCPSkillsSource(client=client)
|
||||
skill = (await source.get_skills())[0]
|
||||
resource = await skill.get_resource("references/checklist.md")
|
||||
@@ -497,13 +471,11 @@ class TestMCPSkillsSource:
|
||||
@pytest.mark.asyncio
|
||||
async def test_sibling_binary_resource(self) -> None:
|
||||
data = bytes([0x01, 0x02, 0x03, 0x04])
|
||||
client = _make_client(
|
||||
**{
|
||||
"skill://index.json": _make_text_result(SAMPLE_SKILL_INDEX, uri="skill://index.json"),
|
||||
"skill://unit-converter/SKILL.md": _make_text_result(SAMPLE_SKILL_MD),
|
||||
"skill://unit-converter/assets/icon.bin": _make_blob_result(data),
|
||||
}
|
||||
)
|
||||
client = _make_client(**{
|
||||
"skill://index.json": _make_text_result(SAMPLE_SKILL_INDEX, uri="skill://index.json"),
|
||||
"skill://unit-converter/SKILL.md": _make_text_result(SAMPLE_SKILL_MD),
|
||||
"skill://unit-converter/assets/icon.bin": _make_blob_result(data),
|
||||
})
|
||||
source = MCPSkillsSource(client=client)
|
||||
skill = (await source.get_skills())[0]
|
||||
resource = await skill.get_resource("assets/icon.bin")
|
||||
@@ -649,9 +621,7 @@ class TestMCPSkillsSourceErrorCodeBranching:
|
||||
from agent_framework import SkillFrontmatter
|
||||
|
||||
client = AsyncMock()
|
||||
client.read_resource = AsyncMock(
|
||||
side_effect=McpError(error=ErrorData(code=0, message="Handler error"))
|
||||
)
|
||||
client.read_resource = AsyncMock(side_effect=McpError(error=ErrorData(code=0, message="Handler error")))
|
||||
fm = SkillFrontmatter(name="test-skill", description="Test.")
|
||||
skill = MCPSkill(frontmatter=fm, skill_md_uri="skill://test/SKILL.md", client=client)
|
||||
with pytest.raises(McpError):
|
||||
|
||||
+121
-60
@@ -63,6 +63,9 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
_ENV_REFERENCE_RE = re.compile(r"\bEnv\.([A-Za-z_][A-Za-z0-9_]*)")
|
||||
|
||||
# Allowed identifier shape for object-attribute steps in declarative state paths
|
||||
_SAFE_PATH_SEGMENT_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_]*$")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DeclarativeEnvConfig:
|
||||
@@ -266,6 +269,9 @@ class DeclarativeWorkflowState:
|
||||
- Conversation: Conversation history
|
||||
"""
|
||||
|
||||
# Sentinel marking "no prior value" for temporary-key bookkeeping.
|
||||
_MISSING: Any = object()
|
||||
|
||||
def __init__(self, state: State, env_config: DeclarativeEnvConfig | None = None):
|
||||
"""Initialize with a State instance.
|
||||
|
||||
@@ -331,16 +337,21 @@ class DeclarativeWorkflowState:
|
||||
def get(self, path: str, default: Any = None) -> Any:
|
||||
"""Get a value from the state using a dot-notated path.
|
||||
|
||||
Dict-keyed segments may use arbitrary string keys (e.g. UUIDs in
|
||||
``System.conversations.<id>.messages``). Segments that would resolve
|
||||
via object-attribute access must be valid declarative identifiers
|
||||
(``[A-Za-z][A-Za-z0-9_]*``); other shapes return ``default``.
|
||||
|
||||
Args:
|
||||
path: Dot-notated path like 'Local.results' or 'Workflow.Inputs.query'
|
||||
default: Default value if path doesn't exist
|
||||
|
||||
Returns:
|
||||
The value at the path, or default if not found
|
||||
The value at the path, or default if not found or unreachable.
|
||||
"""
|
||||
state_data = self.get_state_data()
|
||||
parts = path.split(".")
|
||||
if not parts:
|
||||
if not parts or any(not p for p in parts):
|
||||
return default
|
||||
|
||||
namespace = parts[0]
|
||||
@@ -377,10 +388,19 @@ class DeclarativeWorkflowState:
|
||||
obj = obj.get(part, default) # type: ignore[union-attr]
|
||||
if obj is default:
|
||||
return default
|
||||
elif hasattr(obj, part): # type: ignore[arg-type]
|
||||
obj = getattr(obj, part) # type: ignore[arg-type]
|
||||
else:
|
||||
return default
|
||||
# Attribute access is only allowed for safe declarative identifiers.
|
||||
if not _SAFE_PATH_SEGMENT_RE.match(part):
|
||||
logger.warning(
|
||||
"DeclarativeWorkflowState.get: rejecting attribute segment %r in path %r",
|
||||
part,
|
||||
path,
|
||||
)
|
||||
return default
|
||||
if hasattr(obj, part): # type: ignore[arg-type]
|
||||
obj = getattr(obj, part) # type: ignore[arg-type]
|
||||
else:
|
||||
return default
|
||||
|
||||
return obj # type: ignore[return-value]
|
||||
|
||||
@@ -392,12 +412,14 @@ class DeclarativeWorkflowState:
|
||||
value: The value to set
|
||||
|
||||
Raises:
|
||||
ValueError: If attempting to set Workflow.Inputs (which is read-only)
|
||||
ValueError: If ``path`` is empty or contains empty segments
|
||||
(e.g. ``"Local."``, ``"Local..foo"``), or if attempting to set
|
||||
``Workflow.Inputs`` (which is read-only).
|
||||
"""
|
||||
state_data = self.get_state_data()
|
||||
parts = path.split(".")
|
||||
if not parts:
|
||||
return
|
||||
if not parts or any(not p for p in parts):
|
||||
raise ValueError(f"Invalid path {path!r}: empty segments are not allowed")
|
||||
|
||||
namespace = parts[0]
|
||||
remaining = parts[1:]
|
||||
@@ -453,7 +475,16 @@ class DeclarativeWorkflowState:
|
||||
Args:
|
||||
path: Dot-notated path to a list
|
||||
value: The value to append
|
||||
|
||||
Raises:
|
||||
ValueError: If ``path`` is empty or contains empty segments
|
||||
(e.g. ``"Local."``, ``"Local..foo"``), or if the existing
|
||||
value at ``path`` is not a list.
|
||||
"""
|
||||
parts = path.split(".")
|
||||
if not parts or any(not p for p in parts):
|
||||
raise ValueError(f"Invalid path {path!r}: empty segments are not allowed")
|
||||
|
||||
existing = self.get(path)
|
||||
if existing is None:
|
||||
self.set(path, [value])
|
||||
@@ -464,6 +495,15 @@ class DeclarativeWorkflowState:
|
||||
else:
|
||||
raise ValueError(f"Cannot append to non-list at path '{path}'")
|
||||
|
||||
def _clear_local_path(self, name: str) -> None:
|
||||
"""Remove ``name`` from the ``Local`` namespace, if present."""
|
||||
state_data = self.get_state_data()
|
||||
local = state_data.get("Local")
|
||||
if local is None or name not in local:
|
||||
return
|
||||
local.pop(name, None)
|
||||
self.set_state_data(state_data)
|
||||
|
||||
def eval(self, expression: str) -> Any:
|
||||
"""Evaluate a PowerFx expression with the current state.
|
||||
|
||||
@@ -504,53 +544,64 @@ class DeclarativeWorkflowState:
|
||||
return result
|
||||
|
||||
# Pre-process nested custom functions (e.g., Upper(MessageText(...)))
|
||||
# Replace them with their evaluated results before sending to PowerFx
|
||||
formula = self._preprocess_custom_functions(formula)
|
||||
# and run PowerFx. The finally below restores any temporary state
|
||||
# written during preprocessing, regardless of where execution exits.
|
||||
temp_writes: list[tuple[str, Any]] = []
|
||||
|
||||
if Engine is None:
|
||||
raise RuntimeError(
|
||||
f"PowerFx is not available (dotnet runtime not installed). "
|
||||
f"Expression '={formula[:80]}' cannot be evaluated. "
|
||||
f"Install dotnet and the powerfx package for full PowerFx support."
|
||||
)
|
||||
|
||||
symbols = self._to_powerfx_symbols()
|
||||
# Use setlocale(category) query form so we can restore the exact prior value.
|
||||
# getlocale() returns a normalized tuple and is not always a lossless
|
||||
# round-trip for setlocale across platforms/locales.
|
||||
original_numeric_locale = locale.setlocale(locale.LC_NUMERIC)
|
||||
try:
|
||||
for locale_candidate in _POWERFX_NUMERIC_LOCALE_CANDIDATES:
|
||||
try:
|
||||
locale.setlocale(locale.LC_NUMERIC, locale_candidate)
|
||||
break
|
||||
except locale.Error:
|
||||
continue
|
||||
formula = self._preprocess_custom_functions(formula, temp_writes)
|
||||
|
||||
engine = Engine()
|
||||
try:
|
||||
from System.Globalization import ( # pyright: ignore[reportMissingImports]
|
||||
CultureInfo, # pyright: ignore[reportUnknownVariableType]
|
||||
if Engine is None:
|
||||
raise RuntimeError(
|
||||
f"PowerFx is not available (dotnet runtime not installed). "
|
||||
f"Expression '={formula[:80]}' cannot be evaluated. "
|
||||
f"Install dotnet and the powerfx package for full PowerFx support."
|
||||
)
|
||||
except ImportError:
|
||||
return engine.eval(formula, symbols=symbols, locale=_POWERFX_EVAL_LOCALE)
|
||||
|
||||
original_culture = cast(Any, CultureInfo.CurrentCulture) # pyright: ignore[reportUnknownMemberType]
|
||||
symbols = self._to_powerfx_symbols()
|
||||
# Use setlocale(category) query form so we can restore the exact prior value.
|
||||
# getlocale() returns a normalized tuple and is not always a lossless
|
||||
# round-trip for setlocale across platforms/locales.
|
||||
original_numeric_locale = locale.setlocale(locale.LC_NUMERIC)
|
||||
try:
|
||||
CultureInfo.CurrentCulture = CultureInfo(_POWERFX_EVAL_LOCALE) # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType]
|
||||
return engine.eval(formula, symbols=symbols, locale=_POWERFX_EVAL_LOCALE)
|
||||
for locale_candidate in _POWERFX_NUMERIC_LOCALE_CANDIDATES:
|
||||
try:
|
||||
locale.setlocale(locale.LC_NUMERIC, locale_candidate)
|
||||
break
|
||||
except locale.Error:
|
||||
continue
|
||||
|
||||
engine = Engine()
|
||||
try:
|
||||
from System.Globalization import ( # pyright: ignore[reportMissingImports]
|
||||
CultureInfo, # pyright: ignore[reportUnknownVariableType]
|
||||
)
|
||||
except ImportError:
|
||||
return engine.eval(formula, symbols=symbols, locale=_POWERFX_EVAL_LOCALE)
|
||||
|
||||
original_culture = cast(Any, CultureInfo.CurrentCulture) # pyright: ignore[reportUnknownMemberType]
|
||||
try:
|
||||
CultureInfo.CurrentCulture = CultureInfo(_POWERFX_EVAL_LOCALE) # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType]
|
||||
return engine.eval(formula, symbols=symbols, locale=_POWERFX_EVAL_LOCALE)
|
||||
finally:
|
||||
CultureInfo.CurrentCulture = original_culture # pyright: ignore[reportUnknownMemberType]
|
||||
except ValueError as e:
|
||||
error_msg = str(e)
|
||||
# Handle undefined variable errors gracefully by returning None
|
||||
# This matches the behavior of the legacy fallback parser
|
||||
if "isn't recognized" in error_msg or "Name isn't valid" in error_msg:
|
||||
logger.debug(f"PowerFx: undefined variable in expression '{formula}', returning None")
|
||||
return None
|
||||
raise
|
||||
finally:
|
||||
CultureInfo.CurrentCulture = original_culture # pyright: ignore[reportUnknownMemberType]
|
||||
except ValueError as e:
|
||||
error_msg = str(e)
|
||||
# Handle undefined variable errors gracefully by returning None
|
||||
# This matches the behavior of the legacy fallback parser
|
||||
if "isn't recognized" in error_msg or "Name isn't valid" in error_msg:
|
||||
logger.debug(f"PowerFx: undefined variable in expression '{formula}', returning None")
|
||||
return None
|
||||
raise
|
||||
locale.setlocale(locale.LC_NUMERIC, original_numeric_locale)
|
||||
finally:
|
||||
locale.setlocale(locale.LC_NUMERIC, original_numeric_locale)
|
||||
# Restore each temporary key to its prior value (or remove it).
|
||||
for path, previous in reversed(temp_writes):
|
||||
if previous is self._MISSING:
|
||||
self._clear_local_path(path.removeprefix("Local."))
|
||||
else:
|
||||
self.set(path, previous)
|
||||
|
||||
def _eval_custom_function(self, formula: str) -> Any | None:
|
||||
"""Handle custom functions not supported by the Python PowerFx library.
|
||||
@@ -609,7 +660,7 @@ class DeclarativeWorkflowState:
|
||||
|
||||
return None
|
||||
|
||||
def _preprocess_custom_functions(self, formula: str) -> str:
|
||||
def _preprocess_custom_functions(self, formula: str, temp_writes: list[tuple[str, Any]]) -> str:
|
||||
"""Pre-process custom functions nested inside other PowerFx functions.
|
||||
|
||||
Custom functions like MessageText() are not supported by the PowerFx engine.
|
||||
@@ -624,9 +675,14 @@ class DeclarativeWorkflowState:
|
||||
|
||||
Args:
|
||||
formula: The PowerFx formula to pre-process
|
||||
temp_writes: Caller-owned list. Each write to a temporary key
|
||||
appends a ``(path, previous_value)`` entry where
|
||||
``previous_value`` is the value at ``path`` before the write
|
||||
or :attr:`_MISSING` if none. The caller must restore every
|
||||
entry, including when this method raises mid-write.
|
||||
|
||||
Returns:
|
||||
The formula with custom function calls replaced by their evaluated results
|
||||
The rewritten formula.
|
||||
"""
|
||||
import re
|
||||
|
||||
@@ -635,7 +691,6 @@ class DeclarativeWorkflowState:
|
||||
# We use 500 to leave room for the rest of the expression around the replaced value.
|
||||
MAX_INLINE_LENGTH = 500
|
||||
|
||||
# Counter for generating unique temp variable names
|
||||
temp_var_counter = 0
|
||||
|
||||
# Custom functions that need pre-processing: (regex pattern, handler)
|
||||
@@ -691,11 +746,14 @@ class DeclarativeWorkflowState:
|
||||
# Replace in formula
|
||||
if isinstance(replacement, str):
|
||||
if len(replacement) > MAX_INLINE_LENGTH:
|
||||
# Store long strings in a temp variable to avoid PowerFx expression limit
|
||||
# Store long results in an underscore-prefixed temp key;
|
||||
# record the prior value so eval() can restore it.
|
||||
temp_var_name = f"_TempMessageText{temp_var_counter}"
|
||||
temp_var_counter += 1
|
||||
self.set(f"Local.{temp_var_name}", replacement)
|
||||
replacement_str = f"Local.{temp_var_name}"
|
||||
temp_var_path = f"Local.{temp_var_name}"
|
||||
temp_writes.append((temp_var_path, self.get(temp_var_path, default=self._MISSING)))
|
||||
self.set(temp_var_path, replacement)
|
||||
replacement_str = temp_var_path
|
||||
logger.debug(
|
||||
f"Stored long MessageText result ({len(replacement)} chars) "
|
||||
f"in temp variable {temp_var_name}"
|
||||
@@ -847,11 +905,13 @@ class DeclarativeWorkflowState:
|
||||
return value
|
||||
|
||||
def interpolate_string(self, text: str) -> str:
|
||||
"""Interpolate {Variable.Path} references in a string.
|
||||
"""Interpolate ``{Variable.Path}`` references in a string.
|
||||
|
||||
This handles template-style variable substitution like:
|
||||
- "Created ticket #{Local.TicketParameters.TicketId}"
|
||||
- "Routing to {Local.RoutingParameters.TeamName}"
|
||||
Captures brace-delimited tokens whose root segment is an identifier
|
||||
(``[A-Za-z][A-Za-z0-9_]*``) followed by zero or more ``.`` separated
|
||||
dict-key segments. Resolution is delegated to :meth:`get`; unresolved
|
||||
tokens are replaced with the empty string. Tokens that do not look
|
||||
like state paths (e.g. ``{foo-bar}``, ``{Ctrl+C}``) are left literal.
|
||||
|
||||
Args:
|
||||
text: Text that may contain {Variable.Path} references
|
||||
@@ -866,10 +926,11 @@ class DeclarativeWorkflowState:
|
||||
value = self.get(var_path)
|
||||
return str(value) if value is not None else ""
|
||||
|
||||
# Match {Variable.Path} patterns
|
||||
pattern = r"\{([A-Za-z][A-Za-z0-9_.]*)\}"
|
||||
# Root segment must be an identifier; follow-on segments accept any
|
||||
# non-empty dict-key (e.g. ``_id``, ``1``, UUIDs). ``get()`` enforces
|
||||
# per-segment safety on attribute traversal.
|
||||
pattern = r"\{([A-Za-z][A-Za-z0-9_]*(?:\.[^{}\s.]+)*)\}"
|
||||
|
||||
# Replace all matches
|
||||
result = text
|
||||
for match in re.finditer(pattern, text):
|
||||
replacement = replace_var(match)
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
# pyright: reportUnknownParameterType=false, reportUnknownArgumentType=false
|
||||
# pyright: reportMissingParameterType=false, reportUnknownMemberType=false
|
||||
# pyright: reportPrivateUsage=false, reportUnknownVariableType=false
|
||||
# pyright: reportGeneralTypeIssues=false
|
||||
|
||||
"""Path-segment validation tests for DeclarativeWorkflowState.
|
||||
|
||||
Path segments handed to ``get``/``set``/``append`` and ``{Variable.Path}``
|
||||
placeholders in ``interpolate_string`` are subject to three distinct rules
|
||||
that this module pins:
|
||||
|
||||
- **Empty segments** (e.g. ``""``, ``"Local."``, ``"Local..foo"``) are rejected
|
||||
by all of ``get``/``set``/``append`` and ``interpolate_string``. ``get`` and
|
||||
``interpolate_string`` return their default / leave the placeholder literal;
|
||||
``set`` and ``append`` raise ``ValueError``.
|
||||
- **Object-attribute segments** — segments that ``get`` would resolve via
|
||||
``getattr`` because the parent is a non-dict object — must match the safe
|
||||
identifier shape ``[A-Za-z][A-Za-z0-9_]*``. Other shapes are rejected with a
|
||||
warning log and the default is returned.
|
||||
- **Dict-keyed segments** — segments that resolve via dict lookup because the
|
||||
parent is a ``dict`` — may use arbitrary non-empty string keys (e.g. UUIDs
|
||||
or hyphenated identifiers like ``System.conversations.<uuid>.messages``).
|
||||
"""
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_framework_declarative._workflows import DeclarativeWorkflowState
|
||||
|
||||
try:
|
||||
import powerfx # noqa: F401
|
||||
|
||||
_powerfx_available = True
|
||||
except (ImportError, RuntimeError):
|
||||
_powerfx_available = False
|
||||
|
||||
_requires_powerfx = pytest.mark.skipif(not _powerfx_available, reason="PowerFx engine not available")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_state() -> MagicMock:
|
||||
"""In-memory mock for the underlying State."""
|
||||
ms = MagicMock()
|
||||
ms._data = {}
|
||||
|
||||
def get(key: str, default: Any = None) -> Any:
|
||||
return ms._data.get(key, default)
|
||||
|
||||
def set_(key: str, value: Any) -> None:
|
||||
ms._data[key] = value
|
||||
|
||||
def has(key: str) -> bool:
|
||||
return key in ms._data
|
||||
|
||||
def delete(key: str) -> None:
|
||||
ms._data.pop(key, None)
|
||||
|
||||
ms.get = MagicMock(side_effect=get)
|
||||
ms.set = MagicMock(side_effect=set_)
|
||||
ms.has = MagicMock(side_effect=has)
|
||||
ms.delete = MagicMock(side_effect=delete)
|
||||
return ms
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def state(mock_state: MagicMock) -> DeclarativeWorkflowState:
|
||||
s = DeclarativeWorkflowState(mock_state)
|
||||
s.initialize()
|
||||
return s
|
||||
|
||||
|
||||
@dataclass
|
||||
class _PlainObj:
|
||||
"""Non-dict object so ``get`` falls through to attribute access."""
|
||||
|
||||
text: str = "hi"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get(): invalid paths return default
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetRejectsInvalidPaths:
|
||||
def test_rejects_dunder_segment_via_attribute_access(self, state: DeclarativeWorkflowState) -> None:
|
||||
state.set("Local.obj", _PlainObj())
|
||||
assert state.get("Local.obj.__class__") is None
|
||||
assert state.get("Local.obj.__class__", default="DEF") == "DEF"
|
||||
|
||||
def test_rejects_full_env_exfil_chain(self, state: DeclarativeWorkflowState, monkeypatch) -> None:
|
||||
sentinel = "agent-framework-path-safety-sentinel"
|
||||
monkeypatch.setenv("AF_PATH_SAFETY_SENTINEL", sentinel)
|
||||
state.set("Local.obj", _PlainObj())
|
||||
|
||||
result = state.get("Local.obj.__class__.__init__.__globals__.os.environ")
|
||||
|
||||
assert result is None
|
||||
assert sentinel not in str(result)
|
||||
|
||||
def test_rejects_leading_underscore_via_attribute_access(self, state: DeclarativeWorkflowState) -> None:
|
||||
state.set("Local.obj", _PlainObj())
|
||||
assert state.get("Local.obj._private") is None
|
||||
|
||||
def test_rejects_invalid_chars_via_attribute_access(self, state: DeclarativeWorkflowState) -> None:
|
||||
state.set("Local.obj", _PlainObj())
|
||||
assert state.get("Local.obj.text bar") is None
|
||||
assert state.get("Local.obj.text-bar") is None
|
||||
|
||||
def test_rejects_empty_path_and_empty_segments(self, state: DeclarativeWorkflowState) -> None:
|
||||
assert state.get("") is None
|
||||
assert state.get(".") is None
|
||||
assert state.get("Local.") is None
|
||||
assert state.get(".Local") is None
|
||||
|
||||
def test_warning_logged_on_rejected_attribute_segment(
|
||||
self,
|
||||
state: DeclarativeWorkflowState,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
state.set("Local.obj", _PlainObj())
|
||||
with caplog.at_level(logging.WARNING, logger="agent_framework_declarative._workflows._declarative_base"):
|
||||
state.get("Local.obj.__class__")
|
||||
assert any("rejecting attribute segment" in r.message for r in caplog.records)
|
||||
|
||||
def test_dict_keyed_dunder_is_not_attribute_access(self, state: DeclarativeWorkflowState) -> None:
|
||||
"""A literal dunder dict key is harmless because dict lookup never reaches getattr."""
|
||||
state.set("Local.bag", {"__class__": "harmless-string"})
|
||||
assert state.get("Local.bag.__class__") == "harmless-string"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get(): legitimate paths continue to work
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetAllowsValidPaths:
|
||||
def test_underscore_inside_identifier(self, state: DeclarativeWorkflowState) -> None:
|
||||
state.set("Local.user_input", "ok")
|
||||
assert state.get("Local.user_input") == "ok"
|
||||
|
||||
def test_mixed_case_identifiers(self, state: DeclarativeWorkflowState) -> None:
|
||||
state.set("Local.UserInput", "u1")
|
||||
state.set("Local.userInput", "u2")
|
||||
assert state.get("Local.UserInput") == "u1"
|
||||
assert state.get("Local.userInput") == "u2"
|
||||
|
||||
def test_object_attribute_traversal_still_works(self, state: DeclarativeWorkflowState) -> None:
|
||||
state.set("Local.msg", _PlainObj(text="hello"))
|
||||
assert state.get("Local.msg.text") == "hello"
|
||||
|
||||
def test_nested_dict_traversal_still_works(self, state: DeclarativeWorkflowState) -> None:
|
||||
state.set("Local.params", {"team": {"name": "alpha"}})
|
||||
assert state.get("Local.params.team.name") == "alpha"
|
||||
|
||||
def test_uuid_and_hyphenated_dict_keys_are_allowed(self, state: DeclarativeWorkflowState) -> None:
|
||||
"""Conversation-id style paths use arbitrary dict keys (UUIDs / hyphens)."""
|
||||
conv_id = "eb815014-06f1-4db6-b7c1-304ea135424f"
|
||||
state.set(f"System.conversations.{conv_id}.messages", ["m1", "m2"])
|
||||
assert state.get(f"System.conversations.{conv_id}.messages") == ["m1", "m2"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# set() / append(): dict-keyed operations accept arbitrary string keys
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSetAndAppend:
|
||||
def test_set_allows_underscore_inside_identifier(self, state: DeclarativeWorkflowState) -> None:
|
||||
state.set("Local.user_input", "ok")
|
||||
assert state.get("Local.user_input") == "ok"
|
||||
|
||||
def test_set_allows_uuid_and_hyphenated_dict_keys(self, state: DeclarativeWorkflowState) -> None:
|
||||
conv_id = "conv-test-1"
|
||||
state.set(f"System.conversations.{conv_id}.messages", [])
|
||||
assert state.get(f"System.conversations.{conv_id}.messages") == []
|
||||
|
||||
def test_append_allows_uuid_and_hyphenated_dict_keys(self, state: DeclarativeWorkflowState) -> None:
|
||||
conv_id = "conv-42"
|
||||
state.append(f"System.conversations.{conv_id}.messages", {"role": "user", "text": "hi"})
|
||||
msgs = state.get(f"System.conversations.{conv_id}.messages")
|
||||
assert msgs == [{"role": "user", "text": "hi"}]
|
||||
|
||||
def test_workflow_inputs_still_read_only(self, state: DeclarativeWorkflowState) -> None:
|
||||
with pytest.raises(ValueError, match="read-only"):
|
||||
state.set("Workflow.Inputs.x", 1)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# set() / append(): malformed paths (empty segments) raise ValueError
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSetRejectsInvalidPaths:
|
||||
@pytest.mark.parametrize("bad_path", ["", "Local.", "Local..foo", ".Local"])
|
||||
def test_set_rejects_empty_segment(self, state: DeclarativeWorkflowState, bad_path: str) -> None:
|
||||
with pytest.raises(ValueError, match="empty segments are not allowed"):
|
||||
state.set(bad_path, "x")
|
||||
|
||||
@pytest.mark.parametrize("bad_path", ["", "Local.", "Local..foo", ".Local"])
|
||||
def test_append_rejects_empty_segment(self, state: DeclarativeWorkflowState, bad_path: str) -> None:
|
||||
with pytest.raises(ValueError, match="empty segments are not allowed"):
|
||||
state.append(bad_path, "x")
|
||||
|
||||
def test_set_rejection_makes_no_partial_write(self, state: DeclarativeWorkflowState) -> None:
|
||||
"""Rejected set() must not create an unreachable entry in the state."""
|
||||
state.set("Local.user_input", "pre")
|
||||
with pytest.raises(ValueError):
|
||||
state.set("Local.", "value")
|
||||
local = state.get_state_data().get("Local", {})
|
||||
assert "" not in local
|
||||
assert local == {"user_input": "pre"}
|
||||
assert state.get("Local.") is None
|
||||
assert state.get("Local.user_input") == "pre"
|
||||
|
||||
def test_append_rejection_makes_no_partial_write(self, state: DeclarativeWorkflowState) -> None:
|
||||
"""Rejected append() must not create an unreachable entry in the state."""
|
||||
state.set("Local.items", ["a"])
|
||||
with pytest.raises(ValueError):
|
||||
state.append("Local.", "value")
|
||||
local = state.get_state_data().get("Local", {})
|
||||
assert "" not in local
|
||||
assert local == {"items": ["a"]}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# interpolate_string(): permissive matcher; get() enforces safety
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestInterpolateString:
|
||||
def test_ignores_dunder_payload(self, state: DeclarativeWorkflowState, monkeypatch) -> None:
|
||||
sentinel = "agent-framework-interp-sentinel"
|
||||
monkeypatch.setenv("AF_INTERP_SENTINEL", sentinel)
|
||||
state.set("Local.obj", _PlainObj())
|
||||
|
||||
out = state.interpolate_string("X={Local.obj.__class__.__init__.__globals__.os.environ}")
|
||||
|
||||
assert sentinel not in out
|
||||
assert out == "X="
|
||||
|
||||
def test_unknown_path_reduces_to_empty(self, state: DeclarativeWorkflowState) -> None:
|
||||
assert state.interpolate_string("v={Local._private}") == "v="
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"literal",
|
||||
["{foo-bar}", "{Ctrl+C}", "{not:a:path}", "{Local.}", "{}"],
|
||||
)
|
||||
def test_non_state_braced_tokens_left_literal(self, state: DeclarativeWorkflowState, literal: str) -> None:
|
||||
assert state.interpolate_string(f"v={literal}") == f"v={literal}"
|
||||
|
||||
def test_allows_underscore_inside_identifier(self, state: DeclarativeWorkflowState) -> None:
|
||||
state.set("Local.user_input", "hello")
|
||||
assert state.interpolate_string("v={Local.user_input}") == "v=hello"
|
||||
|
||||
def test_resolves_nested_dict_path(self, state: DeclarativeWorkflowState) -> None:
|
||||
state.set("Local.params", {"team": "alpha"})
|
||||
assert state.interpolate_string("team={Local.params.team}") == "team=alpha"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("key", "value"),
|
||||
[
|
||||
("_id", "abc123"),
|
||||
("1", "one"),
|
||||
("2025", "year-bucket"),
|
||||
],
|
||||
)
|
||||
def test_resolves_dict_keyed_segments(self, state: DeclarativeWorkflowState, key: str, value: str) -> None:
|
||||
state.set("Local.bag", {key: value})
|
||||
assert state.interpolate_string(f"v={{Local.bag.{key}}}") == f"v={value}"
|
||||
|
||||
def test_resolves_uuid_conversation_key(self, state: DeclarativeWorkflowState) -> None:
|
||||
conv_id = "eb815014-06f1-4db6-b7c1-304ea135424f"
|
||||
state.set(f"System.conversations.{conv_id}.messages", ["hello"])
|
||||
out = state.interpolate_string(f"m={{System.conversations.{conv_id}.messages}}")
|
||||
assert out == "m=['hello']"
|
||||
|
||||
def test_end_to_end_send_activity_payload_neutralized(
|
||||
self,
|
||||
state: DeclarativeWorkflowState,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
sentinel = "agent-framework-e2e-sentinel"
|
||||
monkeypatch.setenv("AF_E2E_SENTINEL", sentinel)
|
||||
state.set("Local.toolResult", _PlainObj())
|
||||
|
||||
payload = "{Local.toolResult.__class__.__init__.__globals__.os.environ}"
|
||||
evaluated = state.eval_if_expression(payload)
|
||||
rendered = state.interpolate_string(evaluated) if isinstance(evaluated, str) else str(evaluated)
|
||||
|
||||
assert sentinel not in rendered
|
||||
assert rendered == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Regressions: PowerFx and internal temp-variable handling still work
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@_requires_powerfx
|
||||
class TestPowerFxStillWorks:
|
||||
def test_simple_powerfx_expression_evaluates(self, state: DeclarativeWorkflowState) -> None:
|
||||
state.set("Local.x", 6)
|
||||
state.set("Local.y", 7)
|
||||
assert state.eval("=Local.x * Local.y") == 42
|
||||
|
||||
def test_internal_temp_message_text_still_works(self, state: DeclarativeWorkflowState) -> None:
|
||||
"""Long MessageText() results round-trip and the temp key is removed after eval."""
|
||||
long_text = "A" * 600
|
||||
state.set(
|
||||
"Local.Messages",
|
||||
[{"text": long_text, "contents": [{"type": "text", "text": long_text}]}],
|
||||
)
|
||||
|
||||
result = state.eval("=Upper(MessageText(Local.Messages))")
|
||||
assert result == "A" * 600
|
||||
|
||||
local = state.get_state_data().get("Local", {})
|
||||
remaining = sorted(k for k in local if k.startswith("_TempMessageText"))
|
||||
assert not remaining, f"Temporary keys remain in Local: {remaining}"
|
||||
|
||||
def test_message_text_eval_preserves_user_temp_value(self, state: DeclarativeWorkflowState) -> None:
|
||||
"""User state at the temp key path survives a long MessageText eval."""
|
||||
long_text = "A" * 600
|
||||
state.set("Local._TempMessageText0", "user-important-value")
|
||||
state.set(
|
||||
"Local.Messages",
|
||||
[{"text": long_text, "contents": [{"type": "text", "text": long_text}]}],
|
||||
)
|
||||
|
||||
result = state.eval("=Upper(MessageText(Local.Messages))")
|
||||
assert result == "A" * 600
|
||||
assert state.get("Local._TempMessageText0") == "user-important-value"
|
||||
|
||||
def test_message_text_eval_cleans_up_on_powerfx_failure(
|
||||
self,
|
||||
state: DeclarativeWorkflowState,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
"""Temp key is removed even when PowerFx evaluation raises."""
|
||||
from agent_framework_declarative._workflows import _declarative_base as base
|
||||
|
||||
class _FailingEngine:
|
||||
def eval(self, *args: Any, **kwargs: Any) -> Any:
|
||||
raise RuntimeError("boom")
|
||||
|
||||
monkeypatch.setattr(base, "Engine", _FailingEngine)
|
||||
|
||||
long_text = "A" * 600
|
||||
state.set(
|
||||
"Local.Messages",
|
||||
[{"text": long_text, "contents": [{"type": "text", "text": long_text}]}],
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="boom"):
|
||||
state.eval("=Upper(MessageText(Local.Messages))")
|
||||
|
||||
local = state.get_state_data().get("Local", {})
|
||||
remaining = sorted(k for k in local if k.startswith("_TempMessageText"))
|
||||
assert not remaining, f"Temporary keys remain in Local after PowerFx failure: {remaining}"
|
||||
@@ -2765,7 +2765,7 @@ class TestLongMessageTextHandling:
|
||||
assert temp_var is None
|
||||
|
||||
async def test_long_message_text_stored_in_temp_variable(self, mock_state):
|
||||
"""Test that long MessageText results are stored in temp variables."""
|
||||
"""Long MessageText results round-trip and the temp key is removed after eval."""
|
||||
state = DeclarativeWorkflowState(mock_state)
|
||||
state.initialize()
|
||||
|
||||
@@ -2777,9 +2777,9 @@ class TestLongMessageTextHandling:
|
||||
result = state.eval("=Upper(MessageText(Local.Messages))")
|
||||
assert result == "A" * 600 # Upper on 'A' is still 'A'
|
||||
|
||||
# A temp variable should have been created
|
||||
temp_var = state.get("Local._TempMessageText0")
|
||||
assert temp_var == long_text
|
||||
local = state.get_state_data().get("Local", {})
|
||||
remaining = sorted(k for k in local if k.startswith("_TempMessageText"))
|
||||
assert not remaining, f"Temporary keys remain in Local: {remaining}"
|
||||
|
||||
async def test_find_with_long_message_text(self, mock_state):
|
||||
"""Test Find function works with long MessageText stored in temp variable."""
|
||||
|
||||
@@ -198,12 +198,7 @@ class TestBeforeRun:
|
||||
"""OSS client with all scoping parameters passes them as isolated concurrent kwargs."""
|
||||
mock_oss_mem0_client.search.return_value = []
|
||||
|
||||
provider = Mem0ContextProvider(
|
||||
source_id="mem0",
|
||||
mem0_client=mock_oss_mem0_client,
|
||||
user_id="u1",
|
||||
agent_id="a1"
|
||||
)
|
||||
provider = Mem0ContextProvider(source_id="mem0", mem0_client=mock_oss_mem0_client, user_id="u1", agent_id="a1")
|
||||
|
||||
mock_context = MagicMock(spec=SessionContext)
|
||||
mock_msg = MagicMock()
|
||||
|
||||
@@ -45,13 +45,23 @@ python samples/02-agents/harness/harness_research.py
|
||||
|
||||
### Minimal Setup
|
||||
|
||||
`create_harness_agent` requires only a chat client and token budget parameters:
|
||||
`create_harness_agent` requires only a chat client:
|
||||
|
||||
```python
|
||||
from agent_framework import create_harness_agent
|
||||
from agent_framework.foundry import FoundryChatClient
|
||||
from azure.identity import AzureCliCredential
|
||||
|
||||
agent = create_harness_agent(
|
||||
client=FoundryChatClient(credential=AzureCliCredential()),
|
||||
)
|
||||
```
|
||||
|
||||
### With Compaction
|
||||
|
||||
Provide token budget parameters to enable automatic context-window compaction:
|
||||
|
||||
```python
|
||||
agent = create_harness_agent(
|
||||
client=FoundryChatClient(credential=AzureCliCredential()),
|
||||
max_context_window_tokens=128_000,
|
||||
@@ -59,7 +69,7 @@ agent = create_harness_agent(
|
||||
)
|
||||
```
|
||||
|
||||
### Customization
|
||||
### Further Customization
|
||||
|
||||
Disable or customize any feature:
|
||||
|
||||
|
||||
@@ -313,9 +313,7 @@ class HarnessAgentRunner:
|
||||
"""
|
||||
actions: list[FollowUpAction] = []
|
||||
for observer in self._observers:
|
||||
observer_actions = await observer.on_stream_complete(
|
||||
self._ux, self._agent, session
|
||||
)
|
||||
observer_actions = await observer.on_stream_complete(self._ux, self._agent, session)
|
||||
if observer_actions:
|
||||
actions.extend(observer_actions)
|
||||
return actions
|
||||
|
||||
@@ -182,18 +182,12 @@ class HarnessApp(App[None]):
|
||||
if command_handlers is None:
|
||||
from .commands import build_default_command_handlers
|
||||
|
||||
self._command_handlers = build_default_command_handlers(
|
||||
agent, mode_colors=mode_colors
|
||||
)
|
||||
self._command_handlers = build_default_command_handlers(agent, mode_colors=mode_colors)
|
||||
else:
|
||||
self._command_handlers = command_handlers
|
||||
|
||||
# Compute help text from command handlers
|
||||
help_parts = [
|
||||
h.get_help_text()
|
||||
for h in self._command_handlers
|
||||
if h.get_help_text() is not None
|
||||
]
|
||||
help_parts = [h.get_help_text() for h in self._command_handlers if h.get_help_text() is not None]
|
||||
help_text = ", ".join(help_parts) if help_parts else None
|
||||
|
||||
# State and driver
|
||||
|
||||
@@ -45,9 +45,7 @@ class TodoCommandHandler(CommandHandler):
|
||||
ux.append_info_line("TodoProvider is not available.")
|
||||
return True
|
||||
|
||||
todos = await self._todo_provider.store.load_items(
|
||||
session, source_id=self._todo_provider.source_id
|
||||
)
|
||||
todos = await self._todo_provider.store.load_items(session, source_id=self._todo_provider.source_id)
|
||||
|
||||
if not todos:
|
||||
ux.append_info_line("No todos yet.")
|
||||
|
||||
@@ -72,7 +72,7 @@ class HarnessScrollPanel(RichLog):
|
||||
|
||||
# Truncate lines back to where streaming started
|
||||
if len(self.lines) > self._streaming_line_start:
|
||||
del self.lines[self._streaming_line_start:]
|
||||
del self.lines[self._streaming_line_start :]
|
||||
from textual.geometry import Size
|
||||
|
||||
self.virtual_size = Size(self._widest_line_width, len(self.lines))
|
||||
|
||||
@@ -41,8 +41,7 @@ class PlanningQuestion(BaseModel):
|
||||
choices: list[str] | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"For clarifications, this has a list of options that the user can "
|
||||
"choose from. null for approvals."
|
||||
"For clarifications, this has a list of options that the user can choose from. null for approvals."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ The Model Context Protocol (MCP) is an open standard for connecting AI agents to
|
||||
| **API Key Authentication** | [`mcp_api_key_auth.py`](mcp_api_key_auth.py) | Demonstrates API key authentication with MCP servers using `header_provider`, runtime invocation kwargs, and a command-line API key argument |
|
||||
| **GitHub Integration with PAT** | [`mcp_github_pat.py`](mcp_github_pat.py) | Demonstrates connecting to GitHub's MCP server using Personal Access Token (PAT) authentication |
|
||||
| **Long-Running Task** | [`mcp_long_running_task.py`](mcp_long_running_task.py) | Demonstrates transparent SEP-2663 long-running task handling for MCP tools that advertise `taskSupport=required`. Self-spawns a stdio MCP child server |
|
||||
| **Sampling Approval** | [`mcp_sampling_approval.py`](mcp_sampling_approval.py) | Demonstrates gating server-initiated `sampling/createMessage` requests with a `sampling_approval_callback`, plus the `sampling_max_tokens` and `sampling_max_requests` guardrails. MCP sampling is denied by default |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import asyncio
|
||||
|
||||
from agent_framework import Agent, MCPStreamableHTTPTool
|
||||
from agent_framework.openai import OpenAIChatClient
|
||||
from dotenv import load_dotenv
|
||||
from mcp import types
|
||||
|
||||
# Load environment variables from .env file
|
||||
load_dotenv()
|
||||
|
||||
"""
|
||||
MCP Sampling Approval Example
|
||||
|
||||
MCP servers can send the client a ``sampling/createMessage`` request, asking the
|
||||
client to run an LLM completion on the server's behalf. Because remote MCP
|
||||
servers are untrusted third parties, forwarding these server-controlled prompts
|
||||
to your chat client without review is a confused-deputy risk: a malicious server
|
||||
could exfiltrate context, force tool calls, or burn through your token budget.
|
||||
|
||||
For that reason Agent Framework **denies MCP sampling by default**. To allow it,
|
||||
pass a ``sampling_approval_callback`` to the MCP tool. The callback receives the
|
||||
raw ``CreateMessageRequestParams`` and returns ``True`` to approve or ``False``
|
||||
to deny. It may be synchronous or asynchronous, so you can implement a
|
||||
human-in-the-loop prompt, a policy check, or an audit log.
|
||||
|
||||
Two further guardrails apply to approved requests:
|
||||
- ``sampling_max_tokens`` caps the server-requested ``maxTokens``.
|
||||
- ``sampling_max_requests`` limits how many sampling requests a single session
|
||||
may make.
|
||||
|
||||
To restore the legacy "always approve" behavior (only do this for servers you
|
||||
trust), pass ``sampling_approval_callback=lambda params: True``.
|
||||
"""
|
||||
|
||||
|
||||
async def approve_sampling(params: types.CreateMessageRequestParams) -> bool:
|
||||
"""Human-in-the-loop approval gate for server-initiated sampling.
|
||||
|
||||
Shows the server-supplied system prompt and messages, then asks the user to
|
||||
approve or deny. Returning ``False`` rejects the request.
|
||||
"""
|
||||
print("\n--- MCP server requested a sampling/createMessage ---")
|
||||
if params.systemPrompt:
|
||||
print(f"System prompt: {params.systemPrompt}")
|
||||
for message in params.messages:
|
||||
text = getattr(message.content, "text", message.content)
|
||||
print(f"{message.role}: {text}")
|
||||
answer = await asyncio.to_thread(input, "Approve this sampling request? [y/N]: ")
|
||||
return answer.strip().lower() in {"y", "yes"}
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
"""Run an agent against an MCP server with a sampling approval gate."""
|
||||
async with Agent(
|
||||
client=OpenAIChatClient(),
|
||||
name="Agent",
|
||||
instructions="You are a helpful assistant. Use your MCP tool when answering the user's question.",
|
||||
tools=MCPStreamableHTTPTool(
|
||||
name="MCP tool",
|
||||
description="MCP tool description.",
|
||||
url="<your mcp server url>",
|
||||
# Passing ``client`` enables sampling; the approval callback gates it.
|
||||
client=OpenAIChatClient(),
|
||||
sampling_approval_callback=approve_sampling,
|
||||
sampling_max_tokens=2048,
|
||||
sampling_max_requests=5,
|
||||
),
|
||||
) as agent:
|
||||
query = "Use your MCP tool to help answer this question."
|
||||
print(f"User: {query}")
|
||||
result = await agent.run(query)
|
||||
print(f"Agent: {result.text}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
Generated
+27
-122
@@ -1451,30 +1451,30 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "boto3"
|
||||
version = "1.43.1"
|
||||
version = "1.42.59"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "botocore", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "jmespath", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "s3transfer", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/98/36/028c12ed6ed85009a21b5472eb76c27f9b0341c6986f06f83475b40aaf51/boto3-1.43.1.tar.gz", hash = "sha256:9e4f85a7884797ff0f52c257094730ed228aaa07fa8134775ff8f86909cf4f2a", size = 113175, upload-time = "2026-04-30T20:27:04.569Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b0/4e/499cb52aaee9468c346bcc1158965e24e72b4e2a20052725b680e0ac949b/boto3-1.42.59.tar.gz", hash = "sha256:6c4a14a4eb37b58a9048901bdeefbe1c529638b73e8f55413319a25f010ca211", size = 112725, upload-time = "2026-02-27T20:25:33.228Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/d1/b8b2d5420c51cd8f7ec044ceecbf24b060156680b26519e1d482e160c3c8/boto3-1.43.1-py3-none-any.whl", hash = "sha256:3840bf0345b9aefcc5915176a19d227f63cfba7778c65e6e52d61c6ea0a10fdc", size = 140498, upload-time = "2026-04-30T20:27:01.791Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/c0/22d868b9408dc5a33935a72896ec8d638b2766c459668d1b37c3e5ac2066/boto3-1.42.59-py3-none-any.whl", hash = "sha256:7a66e3e8e2087ea4403e135e9de592e6d63fc9a91080d8dac415bb74df873a72", size = 140557, upload-time = "2026-02-27T20:25:31.774Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "botocore"
|
||||
version = "1.43.26"
|
||||
version = "1.42.81"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "jmespath", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "python-dateutil", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "urllib3", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/76/ba/aa37cd4e72aa8b70fdb93f47129ef3b79887edcfb343bd41df2783a72f12/botocore-1.43.26.tar.gz", hash = "sha256:fd9280ac868194afcee59c7def62e0031fb4b599f7ef85360d30c7e42357c1dc", size = 15497173, upload-time = "2026-06-09T19:34:07.37Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fa/5f/b0bb9a8768398fb131e1fe722c9cc5b18f74d21ca1970efe8576912b2c6e/botocore-1.42.81.tar.gz", hash = "sha256:48e6f6f52de1cc107a34810309b8ca998ea9bb719a3fe4c06f903a604b3138cb", size = 15129980, upload-time = "2026-04-01T19:35:23.439Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/be/e6/5a5ec1033613e7812e5b19ec8c2a1889834fde336d8812d53019eac6e04a/botocore-1.43.26-py3-none-any.whl", hash = "sha256:eeb92265bae289555182a46341c998a656ab49c0dbdb762c65b30fe354fcc9e8", size = 15183593, upload-time = "2026-06-09T19:34:03.012Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/33/c7a01649a6cb7219b233d2ed071ab925e52cdb64e15ce935024c0007376f/botocore-1.42.81-py3-none-any.whl", hash = "sha256:bcef8c93c20ebeba95e4f8b9edfbffbc78a0e11235425a92ee32e48fd8e03c37", size = 14807198, upload-time = "2026-04-01T19:35:20.437Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2692,99 +2692,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/82/fcb6520612bec0c39b973a6c0954b6a0d948aadfe8f7e9487f60ceb8bfa6/googleapis_common_protos-1.73.1-py3-none-any.whl", hash = "sha256:e51f09eb0a43a8602f5a915870972e6b4a394088415c79d79605a46d8e826ee8", size = 297556, upload-time = "2026-03-26T22:15:58.455Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "granian"
|
||||
version = "2.5.7"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/db/b1/100c5add0409559ddbbecca5835c17217b7a2e026eff999bfa359a630686/granian-2.5.7.tar.gz", hash = "sha256:4702a7bcc736454803426bd2c4e7a374739ae1e4b11d27bcdc49b691d316fa0c", size = 112206, upload-time = "2025-11-05T12:18:29.258Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/6f/7719fc97aa081915024939f0d35fdae57dfd3d7214f7ef4a7fa664abbbc3/granian-2.5.7-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:7d84a254e9c88da874ba349f7892278a871acc391ab6af21cc32f58d27cd50a9", size = 2854526, upload-time = "2025-11-05T12:15:29.721Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/cd/af33b780602f962c282ba3341131f7ee3b224a6c856a9fb11a017750a48f/granian-2.5.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8857d5a6ed94ea64d6b92d1d5fa8f7c1676bbecd71e6ca3d71fcd7118448af1d", size = 2537151, upload-time = "2025-11-05T12:15:31.659Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/58/1a0d529d3d3ddc11b2b292b8f2a7566812d8691de7b1fc8ea5c8f36fd81a/granian-2.5.7-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9914dfc93f04a53a92d8cfdb059c11d620ff83e9326a99880491a9c5bc5940ef", size = 3017277, upload-time = "2025-11-05T12:15:33.42Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/78/2a3c198ee379392d9998e4ff0cfd9ffa95b2d2c683bd15a7266a09325d43/granian-2.5.7-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:24c972fe009ca3a08fd7fb182e07fcb16bffe49c87b1c3489a6986c9e9248dc1", size = 2859098, upload-time = "2025-11-05T12:15:35.15Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/44/7b9fba226083170e9ba221b23ab29d7ffcb761b1ef2b6ed6dac2081bc7fe/granian-2.5.7-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:034df207e62f104d39db479b693e03072c7eb8e202493cdf58948ff83e753cca", size = 3119567, upload-time = "2025-11-05T12:15:36.674Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/76/f1e348991c031a50d30d3ab0625fec3b7e811092cdb0d1e996885abf1605/granian-2.5.7-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:0719052a27caca73bf4000ccdb0339a9d6705e7a4b6613b9fa88ba27c72ba659", size = 2901389, upload-time = "2025-11-05T12:15:39.557Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/69/71b3d7d90d56fda5617fd98838ac481756ad64f76c1fc1b5e21c43a51f15/granian-2.5.7-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:be5b9224ec2583ea3b6ca90788b7f59253b6e07fcf817d14c205e6611faaf2be", size = 2989856, upload-time = "2025-11-05T12:15:41.001Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/42/603db3d0ede778adc979c6acc1eaafa5c670c795f5e0e14feb07772ed197/granian-2.5.7-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:ff246af31840369a1d06030f4d291c6a93841f68ee1f836036bce6625ae73b30", size = 3147378, upload-time = "2025-11-05T12:15:42.432Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/b5/cc557e30ba23c2934c33935768dd0233ef7a10b1e8c81dbbc63d5e2562b5/granian-2.5.7-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:cf79375e37a63217f9c1dc4ad15200bc5a89860b321ca30d8a5086a6ea1202e4", size = 3210930, upload-time = "2025-11-05T12:15:45.263Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/67/ba90520cafcd13b5c76d147d713556b9eef877ca001f9ccf44d5443738b6/granian-2.5.7-cp310-cp310-win_amd64.whl", hash = "sha256:b4269a390054c0f71d9ce9d7c75ce2da0c59e78cb522016eb2f5a506c3eb6573", size = 2176887, upload-time = "2025-11-05T12:15:46.615Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/21/da3ade91b49ae99146daac6426701cc25b2c5f1413b6c8cb1cc048877036/granian-2.5.7-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7aa90dcda1fbf03604e229465380138954d9c000eca2947a94dcfbd765414d32", size = 2854652, upload-time = "2025-11-05T12:15:48.342Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/67/a6fa402ca5ebddebec5d46dacf646ce073872e5251915a725f6abf2a23bb/granian-2.5.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:da4f27323be1188f9e325711016ee108840e14a5971bb4b4d15b65b2d1b00a2d", size = 2537539, upload-time = "2025-11-05T12:15:50.136Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/70/accb5afd83ef785bd9e32067a13547c51cb0139076a8f2857d6d436773df/granian-2.5.7-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8ca5b7028b6ebafce30419ddb6ee7fbfb236fdd0da89427811324ddd38c7d314", size = 3017554, upload-time = "2025-11-05T12:15:52.962Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/45/98356af5f36af2b6b47a91fef0d326c275e508bf4bcf0c08bd35ed314db8/granian-2.5.7-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b83e95b18be5dfa92296bc8acfeb353488123399c90cc5f0eccf451e88bc4caf", size = 2859127, upload-time = "2025-11-05T12:15:54.49Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/7a/04d3ec13b197509c40340ec80414fbbc2b0913f6e1a18c3987cc608c8571/granian-2.5.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9aad9e920441232a7b8ad33bef7f04aae986e0e386ab7f13312477c3ea2c85df", size = 3119494, upload-time = "2025-11-05T12:15:56.324Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/5d/1a82a596725824f6e76b8f7b853ceb464cd0334b2b8143c278aa46f23b6d/granian-2.5.7-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:777d35961d5139d203cf54d872ad5979b171e6496a471a5bcb8032f4471bdec6", size = 2901511, upload-time = "2025-11-05T12:15:58.7Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/45/b53d6d7df5cd35c3b8bb329f5ee1c7b31ead7a61a6f2046f6562028d7e1b/granian-2.5.7-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ae72c7ba1e8f35d3021dafb2ba6c4ef89f93f877218f8c6ed1cb672145cd81ad", size = 2989828, upload-time = "2025-11-05T12:16:00.341Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/80/bb57b0fa24fcd518cd64442249459bd214ab1ec5f32590fd30389944261c/granian-2.5.7-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:3764d87edd3fddaf557dce32be396a2a56dfc5b9ad2989b1f98952983ae4a21c", size = 3147694, upload-time = "2025-11-05T12:16:01.826Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/00/f8747aaf8dcd488e4462db89f7273dd9ae702fd17a58d72193b48eff0470/granian-2.5.7-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f5e21bbf1daebb0219253576cac4e5edc8fa8356ad85d66577c4f3ea2d5c6e3c", size = 3211169, upload-time = "2025-11-05T12:16:03.308Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/69/8593d539898a870692cad447d22c2c4cc34566ad9070040ca216db6ac184/granian-2.5.7-cp311-cp311-win_amd64.whl", hash = "sha256:d210dd98852825c8a49036a6ec23cdfaa7689d1cb12ddc651c6466b412047349", size = 2176921, upload-time = "2025-11-05T12:16:04.63Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/cf/f76d05e950f76924ffb6c5212561be4dd93fa569518869cc1233a0c77613/granian-2.5.7-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:41e3a293ac23c76d18628d1bd8376ce3230fb3afe3cf71126b8885e8da4e40c4", size = 2850787, upload-time = "2025-11-05T12:16:06.028Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/d7/6972aa8c38d26b4cf9f35bcc9b7d3a26a3aa930e612d5913d8f4181331a1/granian-2.5.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8b345b539bcbe6dedf8a9323b0c960530cb1fb2cfb887139e6ae9513b6c04d8c", size = 2529552, upload-time = "2025-11-05T12:16:07.389Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/b4/cd5958b6af674a32296a0fef73fb499c2bf2874025062323f5dbc838f4fc/granian-2.5.7-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:12e4d7ba8e3223e2bf974860a59c29b06fa805a98ad4304be4e77180d3a28f55", size = 3009131, upload-time = "2025-11-05T12:16:08.759Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/69/f3828de736c2802fd7fcac0bb1a0387b3332d432f0eeacb8116094926f06/granian-2.5.7-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e727d3518f038b64cb0352b34f43b387aafe5eb12b6c4b57ef598b811e40d4ed", size = 2852544, upload-time = "2025-11-05T12:16:10.22Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/c3/b8c65cf86d473b6e99e6d985c678cb192c9b9776a966a2f4b009696bb650/granian-2.5.7-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59fe2b352a828a2b04bcfd105e623d66786f217759d2d6245651a7b81e4ac294", size = 3131904, upload-time = "2025-11-05T12:16:13.249Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/7e/b60421bddf187ab2a46682423e4a94b2b22a6ddff6842bf9ca2194e62ac2/granian-2.5.7-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:ec5fb593c2d436a323e711010e79718e6d5d1491d0d660fb7c9d97f7e5900830", size = 2908851, upload-time = "2025-11-05T12:16:15.305Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/cf/3f2426e19dc955a74dc94a5a47c4170e68acb060c541ac080f71a9d55d5d/granian-2.5.7-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:48fbc25f3717d01e11547afe0e9cdf9d7c41c9f316b9623a40c22ea6b2128d36", size = 2993270, upload-time = "2025-11-05T12:16:17.133Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/2e/67e1e05ee0d503cc6e9fe53b03f69eb2f267a589d7b40873d120c417385f/granian-2.5.7-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:770935fec3374b814d21c01508c0697842d7c3750731a8ea129738b537ac594c", size = 3134662, upload-time = "2025-11-05T12:16:18.598Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/d5/9d3242bbd911434c4f3d4f14c48e73774a8ddb591e0f975eaeeaef1d5081/granian-2.5.7-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5db2600c92f74da74f624d2fdb01afe9e9365b50bd4e695a78e54961dc132f1b", size = 3220446, upload-time = "2025-11-05T12:16:20.598Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/27/b2baa0443a42d8eb59f3dfbe8186e8c80a090655584af4611f22f1592d7a/granian-2.5.7-cp312-cp312-win_amd64.whl", hash = "sha256:bc368bdeb21646a965adf9f43dd2f4a770647e50318ba1b7cf387d4916ed7e69", size = 2179465, upload-time = "2025-11-05T12:16:22.031Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/ec/bf1b7eefe824630d1d3ae9a8af397d823f2339d3adec71e9ee49d667409c/granian-2.5.7-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:fafb9c17def635bb0a5e20e145601598a6767b879bc2501663dbb45a57d1bc2e", size = 2850581, upload-time = "2025-11-05T12:16:23.516Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/f7/5172daf1968c3a2337c51c50f4a3013aaab564d012d3a79e8390cc66403b/granian-2.5.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9616a197eba637d59242661be8a46127c3f79f7c9bbfa44c0ea8c8c790a11d5e", size = 2529452, upload-time = "2025-11-05T12:16:25.088Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/10/4344ccacc3f8dea973d630306491de43fbd4a0248e3f7cc9ff09ed5cc524/granian-2.5.7-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cfd7a09d5eb00a271ec79e3e0bbf069aa62ce376b64825bdeacb668d2b2a4041", size = 3008798, upload-time = "2025-11-05T12:16:26.584Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/33/638cf8c7f23ab905d3f6a371b5f87d03fd611678424223a0f1d0f7766cc7/granian-2.5.7-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1438a82264690fce6e82de66a95c77f5b0a5c33b93269eb85fc69ce0112c12d5", size = 2852309, upload-time = "2025-11-05T12:16:28.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/42/6ec25d37ffc1f08679e6b325e9f9ac199ba5def948904c9205cd34fbfe6b/granian-2.5.7-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b3573121da77aac1af64cf90a88f29b2daecbf92458beec187421a382039f366", size = 3131335, upload-time = "2025-11-05T12:16:29.588Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/1e/db85dac58d84d3e50e427fe5b60b4f8e8a561d9784971fa3b2879198ad88/granian-2.5.7-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:34cdb82024efbcc9de01c7505213be17e4ba5e7a3acabe74ecd93ba31de7673e", size = 2908705, upload-time = "2025-11-05T12:16:31.049Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/25/a38fd12e1661bbd8535203a8b61240feac7b6b96726bff4de23b0078ab9f/granian-2.5.7-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:572451e94de69df228e4314cb91a50dee1565c4a53d33ffac5936c6ec9c5aba2", size = 2993118, upload-time = "2025-11-05T12:16:32.767Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/cd/852913a0fc30efc24495453c0f973dd74ef13aa0561afb352afa4b6ecbc2/granian-2.5.7-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6e1679a4b102511b483774397134d244108851ae7a1e8bef09a8ef927ab4d370", size = 3134260, upload-time = "2025-11-05T12:16:34.552Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/64/0dff100ce1e43c700918b39656cc000b1163c144eac3a12563a5f692dcd1/granian-2.5.7-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:285be70dcf3c70121afec03e691596db94bd786f9bebc229e9e0319686857d82", size = 3219987, upload-time = "2025-11-05T12:16:36.43Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/ab/e66cf9bf57800dd7c2a2a4b8f23124603fce561a65a176f4cf3794a85b92/granian-2.5.7-cp313-cp313-win_amd64.whl", hash = "sha256:1273c9b1d38d19bcdd550a9a846d07112e541cfa1f99be04fbb926f2a003df3d", size = 2179201, upload-time = "2025-11-05T12:16:37.869Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/0e/feca4a20e7b9e7de0e58103278c6581ebf3d5c1b972ed1c2dcfd25741f15/granian-2.5.7-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:75b9798bc13baa76e35165e5a778cd58a7258d5a2112ed6ef84ef84874244856", size = 2776744, upload-time = "2025-11-05T12:16:41.969Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/fe/65ca38ba9b9f4805495d96ed7b774dfd300f7c944f088db39c676c16501e/granian-2.5.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4cb8247728680ca308b7dc41a6d27582b78e15e902377e89000711f1126524dd", size = 2465942, upload-time = "2025-11-05T12:16:43.762Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/d1/b9dea32fbafabe5c7b049fb0209149a37c6b8468c698d066448cbe88dc85/granian-2.5.7-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64348b83f1ad2f7a29df7932dc518ad669cb61a08a9cde02ca8ede8e9b110506", size = 3015413, upload-time = "2025-11-05T12:16:45.265Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/9e/d29485ab18896e4d911e33b006af7a9b7098316a78938d6b7455c523fea5/granian-2.5.7-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:e2292d4a4661c79d471fa0ff6fe640018c923b6a6dd1bb5383b368b3d5ec2a0c", size = 2783371, upload-time = "2025-11-05T12:16:46.762Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/cd/58c67dc191caeecbbb15ee39d433136dd064c13778b4551661bd902b5a78/granian-2.5.7-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:45903d2f2f88a9cd4a7d0b8ec329db1fb2d9e15bf38153087a3b217b9cdb0046", size = 2979946, upload-time = "2025-11-05T12:16:48.255Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/0b/04e4977df3ef7607a8b6625caed7cac107a049120d2452c33392d4544875/granian-2.5.7-cp313-cp313t-musllinux_1_1_armv7l.whl", hash = "sha256:106e8988e42e527c18b763be5faae7e8f602caac6cb93657793638fc9ab41c98", size = 3123177, upload-time = "2025-11-05T12:16:49.724Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/89/4e10e18fc107e5929143a06d9257646963cf5621c928b3d2774e5a85652a/granian-2.5.7-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:711632e602c4ea08b827bf6095c2c6fbe6005c7a05f142ae2b4d9e1d45cefbd9", size = 3211773, upload-time = "2025-11-05T12:16:51.438Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/81/94e416056d8b4b1cd09cc8065a1e240b0af99f21301c209571530cd83dd0/granian-2.5.7-cp313-cp313t-win_amd64.whl", hash = "sha256:1c571733aa0fdb6755be9ffb3cd728ef965ae565ba896e407d6019bad929d7bb", size = 2174154, upload-time = "2025-11-05T12:16:53.411Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/89/207ebcbd084ed992ecb3739376fd292e6a5bf6ae80b35f06e4f382e1f193/granian-2.5.7-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:74ad35feeafc12efdc27d59a393f8b95235095c4e46c8b8dd6d50ee9e928118d", size = 2834664, upload-time = "2025-11-05T12:16:54.921Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/4b/f941c645d5e3ab495f0cb056abebdb16fb761f713c35a830521f4531674b/granian-2.5.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:875f5cc36b960039bfc99a37af32ad98b3abe753a6de92a9f91268c16bfeb192", size = 2510662, upload-time = "2025-11-05T12:16:56.366Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/14/af9bbf26389f6d0cbdd7445cc969da50965363b2c9635acdae08eb4f2d9b/granian-2.5.7-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:478123ee817f742a6f67050ae4de46bc807c874e397a379cf9fb9ed68b66d7ad", size = 3003249, upload-time = "2025-11-05T12:16:58.015Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/0e/4fa5d4317ff88eab5d061cb45339fdf09a044ae9c7b2496b81c2de5bc2c6/granian-2.5.7-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7d0d0530960250ac9b78494999f2687c627ac5060013e4c63856afb493c2518", size = 2844121, upload-time = "2025-11-05T12:16:59.679Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/05/977fcfe66c9ecd72da47e5185bcd78150efcb5d3bca1ba77860fe8f7bad7/granian-2.5.7-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50cf8cb02253bfc42ee1bb6c5912507f83bea0a39c3d8a09988939407e08787b", size = 3125524, upload-time = "2025-11-05T12:17:02.244Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/c0/fd4d0b455d34c493cfbc6f450e0005206ab41a68f65f16f89e9ae84669ed/granian-2.5.7-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:78015fcb4d055e0eb2454d07f167ca2aa9f48609f90484750b99ca9b719701c4", size = 2902047, upload-time = "2025-11-05T12:17:04.162Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/55/13d53add16a349b5c9384afac14b519a54b7fa4bf73540338296f0963ee7/granian-2.5.7-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bd254e68cc8471b725aa6610b68a5e004aa92b8db53c0d01c408bef8bc9cdcb4", size = 2988366, upload-time = "2025-11-05T12:17:05.806Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/b3/addad51cef2472105b664b608a2b8eccc5691d08c532862cd21b52023661/granian-2.5.7-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:028480ddef683df00064664e7bf58358650722dfa40c2a6dcbf50b3d1996dbb0", size = 3128826, upload-time = "2025-11-05T12:17:07.346Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/2c/ceab57671c7ade9305ed9e86471507b7721e92435509bb3ecab7e1c28fa8/granian-2.5.7-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:b42a254b2884b3060dcafc49dee477f3f6e8c63c567f179dbec7853d6739f124", size = 3212960, upload-time = "2025-11-05T12:17:09.045Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/5b/5458d995ed5a1fe4a7aa1e2587f550c00ec80d373531e270080e4d5e1ca5/granian-2.5.7-cp314-cp314-win_amd64.whl", hash = "sha256:8f6466077c76d92f8926885280166e6874640bbab11ce10c4a3b04c0ee182ac6", size = 2168248, upload-time = "2025-11-05T12:17:10.47Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/6d/3c6fdf84e9de25e0023302d5efd98d70fd6147cae98453591a317539bba6/granian-2.5.7-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:6fc06ac1c147e2f01639aa5c7c0f9553f8c6b283665d13d5527a051e917db150", size = 2763007, upload-time = "2025-11-05T12:17:12Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/92/3fc35058908d1ecb3cb556de729e6f5853e888ac7022a141885f6a3079a5/granian-2.5.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:dec92e09f512aaf532bb75de69b858958113efe52b16a9c5ef19d64063b4956c", size = 2448084, upload-time = "2025-11-05T12:17:13.74Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/82/3fc67aa247dcac09c948ae8a3dc02568d4eb8135f9938594ee5d2ba25a4f/granian-2.5.7-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:01e18c9c63b89370e42d65bc4eccec349d0b676ee69ccbcbbf9bedf606ded129", size = 3008404, upload-time = "2025-11-05T12:17:15.227Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/4c/11f293a60892df7cfdcbb1648ddc31e9d4471b52843e4e838a2a58773fff/granian-2.5.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:035e3b145827a12fb25de5b5122a11d9dad93a943e2251d83ee593b28b0397dc", size = 2781744, upload-time = "2025-11-05T12:17:17.875Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/a0/d4f0063938431201fc7884c7e7bfc5488e3de09957cce37090af9131b7f4/granian-2.5.7-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:21278e2862d7e52996b03260a2a65288c731474c71a6d8311ef78025696b883d", size = 2977678, upload-time = "2025-11-05T12:17:19.706Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/85/327e15e9e96eb35fcca3fbd9848df6bc180f7fb04c9116e22d3c10ada98e/granian-2.5.7-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:fd6a7645117034753ec91e667316e93f3d0325f79462979af3e2e316278ae235", size = 3116889, upload-time = "2025-11-05T12:17:21.906Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/5c/67224ee8fa71ee3748d931c34cf6f85e30c77b2a3ac0b1ca70c640b37d10/granian-2.5.7-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:133d3453d29c5a22648c879d078d097a4ea74b8f84c530084c32debdfdd9d5fd", size = 3203908, upload-time = "2025-11-05T12:17:23.537Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/e0/df08a75311c8d9505dc4f381a4a21bbfeed58b8c8f6d7c3a34b049ad9c34/granian-2.5.7-cp314-cp314t-win_amd64.whl", hash = "sha256:ab8f0f4f22d2efcce194f5b1d66beef2ba3d4bcd18f9afd6b749afa48fdb9a7d", size = 2161670, upload-time = "2025-11-05T12:17:25.504Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/25/2a4112983df5ce0ec8407121ad72c17d27ebfad57085749b8e4164d69e63/granian-2.5.7-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:cdae1c86357bfe895ffd0065c0403913bc008f752e2f77ab363d4e3b4276009b", size = 2838744, upload-time = "2025-11-05T12:17:45.904Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/0a/eb0c5b71355e8f99b89dc335f16cd5108763c554e96a2aae5e7162ef4997/granian-2.5.7-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:bc1d8aaf5bfc5fc9f8f590a42e9f88a43d19ad71f670c6969fa791b52ce1f5ec", size = 2538706, upload-time = "2025-11-05T12:17:47.471Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/9c/4c592c5a813a921033a37a0f003278b1f772a6c9abd16f821bcb119151f0/granian-2.5.7-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:288b62c19aea5b162d27e229469b6307a78cb272aa8fcc296dbfca9fbbda4d8f", size = 3117369, upload-time = "2025-11-05T12:17:49.172Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/35/96af9f0995a7c45f0cd31261ab6284e5d6028afa17c6fcfe757cccb0afb5/granian-2.5.7-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:66c3d2619dc5e845d658cf3ed4f7370f83d5323a85ff8338e7c7a27d9a333841", size = 2904972, upload-time = "2025-11-05T12:17:50.863Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/93/45c253983c2001f534ba2c7bc1e53718fc8cecf196b1e1a0469d5874ae54/granian-2.5.7-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:323e35d5d5054d2568fc824798471e7d33314f47aebd556c4fbf4894e539347d", size = 2991986, upload-time = "2025-11-05T12:17:52.602Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/77/c03e60c7bed386ab16cf15b317dea7f95dde5095af6e17cbd657cd82c21b/granian-2.5.7-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:026ef2588a2b991b250768bf47538fd5fd864549535f885239b6908b214299c4", size = 3163649, upload-time = "2025-11-05T12:17:54.402Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/c9/2bce3db4e3da8d3a697c363c8f699b71f05b7f7a0458e1ba345eaea53fcd/granian-2.5.7-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:4717a62c0a1b79372c495b99ade18bfc3c4a365242bf75770c96a4767a9bcf66", size = 3201886, upload-time = "2025-11-05T12:17:56.553Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/66/997ebfd8cc4a0640befb970bc846a76437d1f0b55dff179e69f29fa4615b/granian-2.5.7-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:4b57ae0a2e1dbc7a248e3c08440b490b3f247e7e4f997faa72e82f5a89d0ea4c", size = 2175219, upload-time = "2025-11-05T12:17:58.126Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/0f/da2588ac78254a4d0be90a6f733d0bb7dd1edb78a10d9e59fa9837687e94/granian-2.5.7-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:bee545c9b9e38eabcdd675e3fec1a2112b8193dc864739952b9de8131433a31c", size = 2838886, upload-time = "2025-11-05T12:17:59.809Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/34/75def8343534e9d48362c43c3cbd06242a2d7804fbfbc824c8aa9fb75a30/granian-2.5.7-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:73c76c0f1ee46506224e92df193b4d271ea89f0d82cd69301784ca85bc1db515", size = 2538597, upload-time = "2025-11-05T12:18:01.496Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/5d/d828d97aad050cfc5b18a0163b532c289a35ad214e31f5a129695b2b4cae/granian-2.5.7-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68879c27aed972f647a8e8ef37f9046f71d7507dc9b3ceffa97d2fbffe6a16c8", size = 3117570, upload-time = "2025-11-05T12:18:03.818Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/57/b8380f3d6b6dcdcd454d720cf11dbecb0e2071a870f44eb834011f14b573/granian-2.5.7-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:ea9cbdfbd750813866dcc9c020018e5f20a57a4e3a83bd049ccc1f6da0559b75", size = 2905089, upload-time = "2025-11-05T12:18:05.567Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/e9/04a7c3b83650afc4a4ad82b67e6306d99f80ac1a6aacb3a8ba182f7359d6/granian-2.5.7-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:d142ff5ee6027515370e56f95d179ec3e81bd265d5b4958de2b19adcdf34887d", size = 2991867, upload-time = "2025-11-05T12:18:07.223Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/bf/a1cdbff73cbac4fddf817d06c13ce6cdc75c22d6da1b257e3563fea4c3c5/granian-2.5.7-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:222f0fb1688a62ca23cb3da974cefa69e7fdc40fd548d1ae87a953225e1d1cbb", size = 3164141, upload-time = "2025-11-05T12:18:09.267Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/cc/35c6a55ac2c211e86a9f0c728eb81b6ad19f05a3055d79c6f11a1b71f5d5/granian-2.5.7-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:40494c6cda1ad881ae07efbb2dc4a1ca8f12d5c6cf28d1ab8b0f2db13826617b", size = 3201599, upload-time = "2025-11-05T12:18:10.962Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/0a/5a95a3889532bc5a5f652cdc78dae8ffa16d4228b4d35256a98be89e33ef/granian-2.5.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c3942d08af2c8b67d0ef569b6c567284433ebf09b4af3ea68388abb7caccad2b", size = 2175240, upload-time = "2025-11-05T12:18:12.956Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "graphviz"
|
||||
version = "0.21"
|
||||
@@ -3630,7 +3537,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "litellm"
|
||||
version = "1.87.1"
|
||||
version = "1.83.14"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -3646,9 +3553,9 @@ dependencies = [
|
||||
{ name = "tiktoken", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "tokenizers", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5d/e5/d0ac1c8f55e2c8d8799589e831bef0d450e69e02ecb511901ffc8de054d9/litellm-1.87.1.tar.gz", hash = "sha256:70ac9d6b25f56ad30de6ff95d26fac3b3fc697a95da582b6072d25d8dc73d493", size = 15455709, upload-time = "2026-06-04T16:23:23.339Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8d/7c/c095649380adc96c8630273c1768c2ad1e74aa2ee1dd8dd05d218a60569f/litellm-1.83.14.tar.gz", hash = "sha256:24aef9b47cdc424c833e32f3727f411741c690832cd1fe4405e0077144fe09c9", size = 14836599, upload-time = "2026-04-26T03:16:10.176Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/18/8275c95ef09e81ab0c01a162c7b780ce3fbc49066b5d532c6b6ab3dc0118/litellm-1.87.1-py3-none-any.whl", hash = "sha256:dd4e00278cdb846d52e99a09d732575a897273540b54eb044247ecbc0d98f67c", size = 17105482, upload-time = "2026-06-04T16:23:20.769Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/5c/1b5691575420135e90578543b2bf219497caa33cfd0af64cb38f30288450/litellm-1.83.14-py3-none-any.whl", hash = "sha256:92b11ba2a32cf80707ddf388d18526696c7999a21b418c5e3b6eda1243d2cfdb", size = 16457054, upload-time = "2026-04-26T03:16:05.72Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
@@ -3661,14 +3568,12 @@ proxy = [
|
||||
{ name = "cryptography", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "fastapi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "fastapi-sso", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "granian", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "gunicorn", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "litellm-enterprise", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "litellm-proxy-extras", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "mcp", extra = ["ws"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "orjson", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "polars", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "pydantic-settings", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "pyjwt", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "pynacl", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "pyroscope-io", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
||||
@@ -3685,20 +3590,20 @@ proxy = [
|
||||
|
||||
[[package]]
|
||||
name = "litellm-enterprise"
|
||||
version = "0.1.41"
|
||||
version = "0.1.39"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6a/07/73412b99c6065ae49a5e87b5f5810b94c1743d7cd41d3a701ebf2c0a64d2/litellm_enterprise-0.1.41.tar.gz", hash = "sha256:3bbf37b6e997e28f9a39489ba532ac98f19b5176180ce091c04156ce3f048d54", size = 70437, upload-time = "2026-05-17T02:05:49.282Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3d/0b/79fb68abf7c787d951dd367f662c52b922278548f244f5d36e623cdb2161/litellm_enterprise-0.1.39.tar.gz", hash = "sha256:434e2c15280218bb9224adbbac878bcffe0b8a75b0b46deeb0b90bc4f2e2152b", size = 69465, upload-time = "2026-04-26T03:09:36.828Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/65/16/284b7304dbf6eea7fe79352ca808f310b7e724648e5f3cf7c13a7a54d682/litellm_enterprise-0.1.41-py3-none-any.whl", hash = "sha256:7b31fd807dee8e1900fd15d8344e4509b6aaf05e10a211fc91c30a95e227685f", size = 137669, upload-time = "2026-05-17T02:05:48.24Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/b0/30df9b36366559efd9c1fae39c67856481c7418056eb2196266bda605bc8/litellm_enterprise-0.1.39-py3-none-any.whl", hash = "sha256:e5f48745fb127dc4f72fd1fa7cdeba0ddd4066dc5f0d9e8e87eea4e4571d42b3", size = 136645, upload-time = "2026-04-26T03:09:35.492Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "litellm-proxy-extras"
|
||||
version = "0.4.73"
|
||||
version = "0.4.69"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/de/37/bed736f8a623b7891e9ff272fd60c2f08fc4a8fed372885f5df9ec09b769/litellm_proxy_extras-0.4.73.tar.gz", hash = "sha256:d4fb1238fb56cdaa21aef6b1d7683c2c0fe3a148ecd423f8bf4cef3c3d07bd36", size = 43599, upload-time = "2026-05-20T00:00:05.495Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0b/e8/0176368d64ffaaf7ff7da07a7833ef05cd92484cf21167a9291cb311568f/litellm_proxy_extras-0.4.69.tar.gz", hash = "sha256:8c24a01a4dffb137e95c709a47ab68053591ccdf7d78a038c57348f5b2ab990d", size = 41220, upload-time = "2026-04-26T03:12:12.122Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/64/7e85f5f47495ebb0bb5f30a4f4b54b64277a40a14be79c34052df97ac7ab/litellm_proxy_extras-0.4.73-py3-none-any.whl", hash = "sha256:a4f460d15dd01a095dadb26f7660a259fa2a8757a9e27dee68c159a872b8db7e", size = 118593, upload-time = "2026-05-20T00:00:04.017Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/58/165a96b061fa90824ffbce13191262d4a0089510284a973805e5854e2c03/litellm_proxy_extras-0.4.69-py3-none-any.whl", hash = "sha256:4aee8dab05d1a6f91ba89da729d241122eaad4cbe64f39b19ea6a855543146c4", size = 113230, upload-time = "2026-04-26T03:12:10.731Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4544,7 +4449,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "openai"
|
||||
version = "2.41.0"
|
||||
version = "2.24.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -4556,9 +4461,9 @@ dependencies = [
|
||||
{ name = "tqdm", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3c/a6/5815fe2e2aca74b36c650d1bd43b69827cee568073d0d2d9b6fc5aaac80c/openai-2.41.0.tar.gz", hash = "sha256:db5c362acd6604b84f076abbefa66826ea4b46ecba2954ed866e6a149a1352c0", size = 783525, upload-time = "2026-06-03T22:39:40.719Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/55/13/17e87641b89b74552ed408a92b231283786523edddc95f3545809fab673c/openai-2.24.0.tar.gz", hash = "sha256:1e5769f540dbd01cb33bc4716a23e67b9d695161a734aff9c5f925e2bf99a673", size = 658717, upload-time = "2026-02-24T20:02:07.958Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/be/51/d82bb424e8aa372190c5233253a2ceb399a778747d18b42cff487411e663/openai-2.41.0-py3-none-any.whl", hash = "sha256:20cc7952e8501c7e5773dd2ef7be437bae9cb549044902e1041a83a54516e375", size = 1353378, upload-time = "2026-06-03T22:39:38.964Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/30/844dc675ee6902579b8eef01ed23917cc9319a1c9c0c14ec6e39340c96d0/openai-2.24.0-py3-none-any.whl", hash = "sha256:fed30480d7d6c884303287bde864980a4b137b60553ffbcf9ab4a233b7a73d94", size = 1120122, upload-time = "2026-02-24T20:02:05.669Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5964,16 +5869,16 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-settings"
|
||||
version = "2.14.1"
|
||||
version = "2.13.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "python-dotenv", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "typing-inspection", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/07/60/1d1e59c9c90d54591469ada7d268251f71c24bdb765f1a8a832cee8c6653/pydantic_settings-2.14.1.tar.gz", hash = "sha256:e874d3bec7e787b0c9958277956ed9b4dd5de6a80e162188fdaff7c5e26fd5fa", size = 235551, upload-time = "2026-05-08T13:40:06.542Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/8d/f1af3832f5e6eb13ba94ee809e72b8ecb5eef226d27ee0bef7d963d943c7/pydantic_settings-2.14.1-py3-none-any.whl", hash = "sha256:6e3c7edfd8277687cdc598f56e5cff0e9bfff0910a3749deaa8d4401c3a2b9de", size = 60964, upload-time = "2026-05-08T13:40:04.958Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6181,11 +6086,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "python-multipart"
|
||||
version = "0.0.27"
|
||||
version = "0.0.26"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/69/9b/f23807317a113dc36e74e75eb265a02dd1a4d9082abc3c1064acd22997c4/python_multipart-0.0.27.tar.gz", hash = "sha256:9870a6a8c5a20a5bf4f07c017bd1489006ff8836cff097b6933355ee2b49b602", size = 44043, upload-time = "2026-04-27T10:51:26.649Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/88/71/b145a380824a960ebd60e1014256dbb7d2253f2316ff2d73dfd8928ec2c3/python_multipart-0.0.26.tar.gz", hash = "sha256:08fadc45918cd615e26846437f50c5d6d23304da32c341f289a617127b081f17", size = 43501, upload-time = "2026-04-10T14:09:59.473Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/99/78/4126abcbdbd3c559d43e0db7f7b9173fc6befe45d39a2856cc0b8ec2a5a6/python_multipart-0.0.27-py3-none-any.whl", hash = "sha256:6fccfad17a27334bd0193681b369f476eda3409f17381a2d65aa7df3f7275645", size = 29254, upload-time = "2026-04-27T10:51:24.997Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/22/f1925cdda983ab66fc8ec6ec8014b959262747e58bdca26a4e3d1da29d56/python_multipart-0.0.26-py3-none-any.whl", hash = "sha256:c0b169f8c4484c13b0dcf2ef0ec3a4adb255c4b7d18d8e420477d2b1dd03f185", size = 28847, upload-time = "2026-04-10T14:09:58.131Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6704,14 +6609,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "s3transfer"
|
||||
version = "0.17.1"
|
||||
version = "0.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "botocore", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/11/b3/bcdc2f58fa92592db511beda154c2c08d28f21f6c4637f06a42a24b10c21/s3transfer-0.17.1.tar.gz", hash = "sha256:042dd5e3b1b512355e35a23f0223e426b7042e80b97830ea2680ddce327fc45e", size = 159439, upload-time = "2026-05-26T19:45:01.714Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/05/04/74127fc843314818edfa81b5540e26dd537353b123a4edc563109d8f17dd/s3transfer-0.16.0.tar.gz", hash = "sha256:8e990f13268025792229cd52fa10cb7163744bf56e719e0b9cb925ab79abf920", size = 153827, upload-time = "2025-12-01T02:30:59.114Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/85/dd/904873250a6554fbae40cddbf9198e3cc37a2f1319d5e1a5ce82fe269c17/s3transfer-0.17.1-py3-none-any.whl", hash = "sha256:5b9827d1044159bbb01b86ef8902760ea39281927f5de31de75e1d657177bf4c", size = 88264, upload-time = "2026-05-26T19:45:00.452Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl", hash = "sha256:18e25d66fed509e3868dc1572b3f427ff947dd2c56f844a5bf09481ad3f3b2fe", size = 86830, upload-time = "2025-12-01T02:30:57.729Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user