mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
745f9a7178 | ||
|
|
ea0b3c1210 | ||
|
|
92cd194122 | ||
|
|
55e665ade0 | ||
|
|
fed40ca1b2 | ||
|
|
dca9dc081b | ||
|
|
3c91ba4050 | ||
|
|
7999bf3c2d | ||
|
|
ccf22ac963 |
@@ -6,12 +6,8 @@
|
||||
[](https://learn.microsoft.com/en-us/agent-framework/)
|
||||
[](https://pypi.org/project/agent-framework/)
|
||||
[](https://www.nuget.org/profiles/MicrosoftAgentFramework/)
|
||||
[](https://github.com/microsoft/agent-framework/stargazers)
|
||||
|
||||
|
||||
Microsoft Agent Framework (MAF) is an open, multi-language framework for building **production-grade AI agents and multi-agent workflows** in **.NET and Python**.
|
||||
|
||||
Microsoft Agent Framework is built for teams taking agents from prototype to production. It provides a consistent foundation for building, orchestrating, and operating agent systems across Python and .NET, while keeping architecture choices open as requirements evolve, and supports a broad ecosystem including Microsoft Foundry, Azure OpenAI, OpenAI, and the GitHub Copilot SDK, with samples and hosting patterns for both local development and cloud deployment.
|
||||
Welcome to Microsoft's comprehensive multi-language framework for building, orchestrating, and deploying AI agents with support for both .NET and Python implementations. This framework provides everything from simple chat agents to complex multi-agent workflows with graph-based orchestration.
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.youtube.com/watch?v=AAgdMhftj8w" title="Watch the full Agent Framework introduction (30 min)">
|
||||
@@ -25,54 +21,10 @@ Microsoft Agent Framework is built for teams taking agents from prototype to pro
|
||||
</a>
|
||||
</p>
|
||||
|
||||
## Is this the right framework for you?
|
||||
## đź“‹ Getting Started
|
||||
|
||||
MAF is a strong fit if you:
|
||||
- are building agents and workflows you expect to run in production,
|
||||
- need orchestration beyond a single prompt or stateless chat loop,
|
||||
- want graph-based patterns such as sequential, concurrent, handoff, and group collaboration,
|
||||
- care about durability, restartability, observability, governance, or human-in-the-loop control,
|
||||
- need provider flexibility so your architecture can evolve without major rewrites.
|
||||
### 📦 Installation
|
||||
|
||||
## Key Features
|
||||
Explore new MAF capabilities and real implementation patterns on the [official blog](https://devblogs.microsoft.com/agent-framework/).
|
||||
|
||||
- **Python and C#/.NET Support**: Full framework support for both Python and C#/.NET implementations with consistent APIs
|
||||
- [Python packages](./python/packages/) | [.NET source](./dotnet/src/)
|
||||
- **Multiple Agent Provider Support**: Support for various LLM providers with more being added continuously
|
||||
- [Python examples](./python/samples/02-agents/providers/) | [.NET examples](./dotnet/samples/02-agents/AgentProviders/)
|
||||
- **Middleware**: Flexible middleware system for request/response processing, exception handling, and custom pipelines
|
||||
- [Python middleware](./python/samples/02-agents/middleware/) | [.NET middleware](./dotnet/samples/02-agents/Agents/Agent_Step11_Middleware/)
|
||||
- **Orchestration Patterns & Workflows**: Build multi-agent systems with graph-based workflows supporting sequential, concurrent, handoff, and group collaboration patterns; includes checkpointing, streaming, human-in-the-loop, and time-travel
|
||||
- [Python workflows](./python/samples/03-workflows/) | [.NET workflows](./dotnet/samples/03-workflows/)
|
||||
- **Foundry Hosted Agents (new)**: Deploy and host your agents to Foundry-hosted infrastructure with just 2 additional lines of code
|
||||
- [Python samples](./python/samples/04-hosting/foundry-hosted-agents/) | [.NET samples](./dotnet/samples/04-hosting/FoundryHostedAgents/)
|
||||
- **Observability**: Built-in OpenTelemetry integration for distributed tracing, monitoring, and debugging
|
||||
- [Python observability](./python/samples/02-agents/observability/) | [.NET telemetry](./dotnet/samples/02-agents/AgentOpenTelemetry/)
|
||||
- **Declarative Agents**: Define agents using YAML for faster setup and versioning
|
||||
- [Declarative agent samples](./declarative-agents/)
|
||||
- **Agent Skills**: Build domain-specific knowledge bases from multiple sources—files, inline code, class libraries—for agents to discover and use
|
||||
- [Skills design](./docs/decisions/0021-agent-skills-design.md)
|
||||
- **AF Labs**: Experimental packages for cutting-edge features including benchmarking, reinforcement learning, and research initiatives
|
||||
- [Labs directory](./python/packages/lab/)
|
||||
- **DevUI**: Interactive developer UI for agent development, testing, and debugging workflows
|
||||
- [See the DevUI in action](https://www.youtube.com/watch?v=mOAaGY4WPvc)
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Getting Started](#getting-started)
|
||||
- [Installation](#installation)
|
||||
- [Learning Resources](#learning-resources)
|
||||
- [Quickstart](#quickstart)
|
||||
- [Basic Agent - Python](#basic-agent---python)
|
||||
- [Basic Agent - .NET](#basic-agent---net)
|
||||
- [More Examples & Samples](#more-examples--samples)
|
||||
- [Community & Feedback](#community--feedback)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [Contributor Resources](#contributor-resources)
|
||||
|
||||
## Getting Started
|
||||
### Installation
|
||||
Python
|
||||
|
||||
```bash
|
||||
@@ -85,13 +37,9 @@ pip install agent-framework
|
||||
|
||||
```bash
|
||||
dotnet add package Microsoft.Agents.AI
|
||||
# For Foundry integration (used in the .NET quickstart below):
|
||||
dotnet add package Microsoft.Agents.AI.Foundry
|
||||
dotnet add package Azure.AI.Projects
|
||||
dotnet add package Azure.Identity
|
||||
```
|
||||
|
||||
### Learning Resources
|
||||
### 📚 Documentation
|
||||
|
||||
- **[Overview](https://learn.microsoft.com/agent-framework/overview/agent-framework-overview)** - High level overview of the framework
|
||||
- **[Quick Start](https://learn.microsoft.com/agent-framework/tutorials/quick-start)** - Get started with a simple agent
|
||||
@@ -100,9 +48,44 @@ dotnet add package Azure.Identity
|
||||
- **[Migration from Semantic Kernel](https://learn.microsoft.com/en-us/agent-framework/migration-guide/from-semantic-kernel)** - Guide to migrate from Semantic Kernel
|
||||
- **[Migration from AutoGen](https://learn.microsoft.com/en-us/agent-framework/migration-guide/from-autogen)** - Guide to migrate from AutoGen
|
||||
|
||||
### Quickstart
|
||||
Still have questions? Join our [weekly office hours](./COMMUNITY.md#public-community-office-hours) or ask questions in our [Discord channel](https://discord.gg/b5zjErwbQM) to get help from the team and other users.
|
||||
|
||||
#### Basic Agent - Python
|
||||
### ✨ **Highlights**
|
||||
|
||||
- **Graph-based Workflows**: Connect agents and deterministic functions using data flows with streaming, checkpointing, human-in-the-loop, and time-travel capabilities
|
||||
- [Python workflows](./python/samples/03-workflows/) | [.NET workflows](./dotnet/samples/03-workflows/)
|
||||
- **AF Labs**: Experimental packages for cutting-edge features including benchmarking, reinforcement learning, and research initiatives
|
||||
- [Labs directory](./python/packages/lab/)
|
||||
- **DevUI**: Interactive developer UI for agent development, testing, and debugging workflows
|
||||
- [DevUI package](./python/packages/devui/)
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.youtube.com/watch?v=mOAaGY4WPvc">
|
||||
<img src="https://img.youtube.com/vi/mOAaGY4WPvc/hqdefault.jpg" alt="See the DevUI in action" width="480">
|
||||
</a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://www.youtube.com/watch?v=mOAaGY4WPvc">
|
||||
See the DevUI in action (1 min)
|
||||
</a>
|
||||
</p>
|
||||
|
||||
- **Python and C#/.NET Support**: Full framework support for both Python and C#/.NET implementations with consistent APIs
|
||||
- [Python packages](./python/packages/) | [.NET source](./dotnet/src/)
|
||||
- **Observability**: Built-in OpenTelemetry integration for distributed tracing, monitoring, and debugging
|
||||
- [Python observability](./python/samples/02-agents/observability/) | [.NET telemetry](./dotnet/samples/02-agents/AgentOpenTelemetry/)
|
||||
- **Multiple Agent Provider Support**: Support for various LLM providers with more being added continuously
|
||||
- [Python examples](./python/samples/02-agents/providers/) | [.NET examples](./dotnet/samples/02-agents/AgentProviders/)
|
||||
- **Middleware**: Flexible middleware system for request/response processing, exception handling, and custom pipelines
|
||||
- [Python middleware](./python/samples/02-agents/middleware/) | [.NET middleware](./dotnet/samples/02-agents/Agents/Agent_Step11_Middleware/)
|
||||
|
||||
### đź’¬ **We want your feedback!**
|
||||
|
||||
- For bugs, please file a [GitHub issue](https://github.com/microsoft/agent-framework/issues).
|
||||
|
||||
## Quickstart
|
||||
|
||||
### Basic Agent - Python
|
||||
|
||||
Create a simple Azure Responses Agent that writes a haiku about the Microsoft Agent Framework
|
||||
|
||||
@@ -126,7 +109,7 @@ async def main():
|
||||
# project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
|
||||
# model=os.environ["FOUNDRY_MODEL_DEPLOYMENT_NAME"],
|
||||
),
|
||||
name="HaikuAgent",
|
||||
name="HaikuBot",
|
||||
instructions="You are an upbeat assistant that writes beautifully.",
|
||||
)
|
||||
|
||||
@@ -136,24 +119,40 @@ if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
#### Basic Agent - .NET
|
||||
Create a simple Agent, using Microsoft Foundry that writes a haiku about the Microsoft Agent Framework
|
||||
### Basic Agent - .NET
|
||||
Create a simple Agent, using Microsoft Foundry with token-based auth, that writes a haiku about the Microsoft Agent Framework
|
||||
|
||||
```c#
|
||||
// This sample shows how to create and run a basic agent with AIProjectClient.AsAIAgent(...).
|
||||
|
||||
// dotnet add package Microsoft.Agents.AI.Foundry
|
||||
// Use `az login` to authenticate with Azure CLI
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Identity;
|
||||
using System;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
|
||||
string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
|
||||
string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini";
|
||||
var endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4-mini";
|
||||
|
||||
AIAgent agent =
|
||||
new AIProjectClient(new Uri(endpoint), new DefaultAzureCredential())
|
||||
.AsAIAgent(model: deploymentName, instructions: "You are an upbeat assistant that writes beautifully.", name: "HaikuAgent");
|
||||
var agent = new AIProjectClient(new Uri(endpoint), new DefaultAzureCredential())
|
||||
.AsAIAgent(model: deploymentName, name: "HaikuBot", instructions: "You are an upbeat assistant that writes beautifully.");
|
||||
|
||||
Console.WriteLine(await agent.RunAsync("Write a haiku about Microsoft Agent Framework."));
|
||||
```
|
||||
|
||||
Create a simple Agent, using OpenAI Responses, that writes a haiku about the Microsoft Agent Framework
|
||||
|
||||
```c#
|
||||
// dotnet add package Microsoft.Agents.AI.OpenAI
|
||||
using System;
|
||||
using OpenAI;
|
||||
using OpenAI.Responses;
|
||||
|
||||
// Replace the <apikey> with your OpenAI API key.
|
||||
var agent = new OpenAIClient("<apikey>")
|
||||
.GetResponsesClient()
|
||||
.AsAIAgent(model: "gpt-5.4-mini", name: "HaikuBot", instructions: "You are an upbeat assistant that writes beautifully.");
|
||||
|
||||
// Once you have the agent, you can invoke it like any other AIAgent.
|
||||
Console.WriteLine(await agent.RunAsync("Write a haiku about Microsoft Agent Framework."));
|
||||
```
|
||||
|
||||
@@ -176,12 +175,6 @@ Console.WriteLine(await agent.RunAsync("Write a haiku about Microsoft Agent Fram
|
||||
- [Hosting](./dotnet/samples/04-hosting): A2A, Durable Agents, Durable Workflows
|
||||
- [End-to-End](./dotnet/samples/05-end-to-end): full applications and demos
|
||||
|
||||
## Community & Feedback
|
||||
|
||||
- **Found a bug?** File a [GitHub issue](https://github.com/microsoft/agent-framework/issues) to help us improve.
|
||||
- **Enjoying MAF?** [](https://github.com/microsoft/agent-framework) to show your support and help others discover the project.
|
||||
- **Have questions?** Join our [Discord](https://discord.gg/b5zjErwbQM) or visit [weekly office hours](./COMMUNITY.md#public-community-office-hours).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Authentication
|
||||
@@ -194,7 +187,16 @@ Console.WriteLine(await agent.RunAsync("Write a haiku about Microsoft Agent Fram
|
||||
> **Tip:** `DefaultAzureCredential` is convenient for development but in production, consider using a specific credential (e.g., `ManagedIdentityCredential`) to avoid latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
|
||||
|
||||
### Environment Variables
|
||||
For environment variable configuration specific to each sample, refer to the README in the sample directory ([Python samples](./python/samples/) | [.NET samples](./dotnet/samples/)).
|
||||
|
||||
The samples typically read configuration from environment variables. Common required variables:
|
||||
|
||||
| Variable | Used by | Purpose |
|
||||
|----------|---------|---------|
|
||||
| `AZURE_OPENAI_ENDPOINT` | Azure OpenAI samples | Your Azure OpenAI resource URL |
|
||||
| `AZURE_OPENAI_DEPLOYMENT_NAME` | Azure OpenAI samples | Model deployment name (e.g. `gpt-4o-mini`) |
|
||||
| `AZURE_AI_PROJECT_ENDPOINT` | Microsoft Foundry samples | Your Microsoft Foundry project endpoint |
|
||||
| `AZURE_AI_MODEL_DEPLOYMENT_NAME` | Microsoft Foundry samples | Model deployment name |
|
||||
| `OPENAI_API_KEY` | OpenAI (non-Azure) samples | Your OpenAI platform API key |
|
||||
|
||||
## Contributor Resources
|
||||
|
||||
|
||||
@@ -109,8 +109,6 @@
|
||||
<PackageVersion Include="A2A.AspNetCore" Version="1.0.0-preview2" />
|
||||
<!-- MCP -->
|
||||
<PackageVersion Include="ModelContextProtocol" Version="1.1.0" />
|
||||
<!-- Hyperlight -->
|
||||
<PackageVersion Include="Hyperlight.HyperlightSandbox.Api" Version="0.4.0" />
|
||||
<!-- Inference SDKs -->
|
||||
<PackageVersion Include="Microsoft.ML.OnnxRuntimeGenAI" Version="0.10.0" />
|
||||
<PackageVersion Include="Microsoft.ML.Tokenizers" Version="2.0.0" />
|
||||
|
||||
@@ -175,12 +175,6 @@
|
||||
<Project Path="samples/02-agents/Evaluation/Evaluation_Multimodal/Evaluation_Multimodal.csproj" />
|
||||
<Project Path="samples/02-agents/Evaluation/Evaluation_SimpleEval/Evaluation_SimpleEval.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/02-agents/AgentWithCodeAct/">
|
||||
<File Path="samples/02-agents/AgentWithCodeAct/README.md" />
|
||||
<Project Path="samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step01_Interpreter/AgentWithCodeAct_Step01_Interpreter.csproj" />
|
||||
<Project Path="samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step02_ToolEnabled/AgentWithCodeAct_Step02_ToolEnabled.csproj" />
|
||||
<Project Path="samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step03_ManualWiring/AgentWithCodeAct_Step03_ManualWiring.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/02-agents/AgentWithMemory/">
|
||||
<File Path="samples/02-agents/AgentWithMemory/README.md" />
|
||||
<Project Path="samples/02-agents/AgentWithMemory/AgentWithMemory_Step01_ChatHistoryMemory/AgentWithMemory_Step01_ChatHistoryMemory.csproj" />
|
||||
@@ -541,16 +535,6 @@
|
||||
<Folder Name="/Solution Items/src/Shared/StructuredOutput/">
|
||||
<File Path="src/Shared/StructuredOutput/StructuredOutputSchemaUtilities.cs" />
|
||||
</Folder>
|
||||
<Folder Name="/Solution Items/src/Shared/Workflows/" />
|
||||
<Folder Name="/Solution Items/src/Shared/Workflows/Execution/">
|
||||
<File Path="src/Shared/Workflows/Execution/README.md" />
|
||||
<File Path="src/Shared/Workflows/Execution/WorkflowFactory.cs" />
|
||||
<File Path="src/Shared/Workflows/Execution/WorkflowRunner.cs" />
|
||||
</Folder>
|
||||
<Folder Name="/Solution Items/src/Shared/Workflows/Settings/">
|
||||
<File Path="src/Shared/Workflows/Settings/Application.cs" />
|
||||
<File Path="src/Shared/Workflows/Settings/README.md" />
|
||||
</Folder>
|
||||
<Folder Name="/Solution Items/tests/">
|
||||
<File Path="tests/.editorconfig" />
|
||||
<File Path="tests/Directory.Build.props" />
|
||||
@@ -576,7 +560,6 @@
|
||||
<Project Path="src/Microsoft.Agents.AI.Hosting.AzureFunctions/Microsoft.Agents.AI.Hosting.AzureFunctions.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Hosting.OpenAI/Microsoft.Agents.AI.Hosting.OpenAI.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Hosting/Microsoft.Agents.AI.Hosting.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Hyperlight/Microsoft.Agents.AI.Hyperlight.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Mem0/Microsoft.Agents.AI.Mem0.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.OpenAI/Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Purview/Microsoft.Agents.AI.Purview.csproj" />
|
||||
@@ -598,7 +581,6 @@
|
||||
<Project Path="tests/Microsoft.Agents.AI.GitHub.Copilot.IntegrationTests/Microsoft.Agents.AI.GitHub.Copilot.IntegrationTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.IntegrationTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests/Microsoft.Agents.AI.Hosting.AzureFunctions.IntegrationTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Hyperlight.IntegrationTests/Microsoft.Agents.AI.Hyperlight.IntegrationTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Mem0.IntegrationTests/Microsoft.Agents.AI.Mem0.IntegrationTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests/Microsoft.Agents.AI.Workflows.Declarative.IntegrationTests.csproj" />
|
||||
<Project Path="tests/OpenAIAssistant.IntegrationTests/OpenAIAssistant.IntegrationTests.csproj" />
|
||||
@@ -624,7 +606,6 @@
|
||||
<Project Path="tests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests/Microsoft.Agents.AI.Hosting.AzureFunctions.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Hosting.UnitTests/Microsoft.Agents.AI.Hosting.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Hyperlight.UnitTests/Microsoft.Agents.AI.Hyperlight.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Mem0.UnitTests/Microsoft.Agents.AI.Mem0.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.OpenAI.UnitTests/Microsoft.Agents.AI.OpenAI.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Purview.UnitTests/Microsoft.Agents.AI.Purview.UnitTests.csproj" />
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<!-- Central version prefix - applies to all nuget packages. -->
|
||||
<VersionPrefix>1.4.0</VersionPrefix>
|
||||
<VersionPrefix>1.3.0</VersionPrefix>
|
||||
<RCNumber>1</RCNumber>
|
||||
<DateSuffix>260505</DateSuffix>
|
||||
<DateSuffix>260423</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.4.0</GitTag>
|
||||
<GitTag>1.3.0</GitTag>
|
||||
|
||||
<Configurations>Debug;Release;Publish</Configurations>
|
||||
<IsPackable>true</IsPackable>
|
||||
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hyperlight\Microsoft.Agents.AI.Hyperlight.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
-30
@@ -1,30 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample shows how to use HyperlightCodeActProvider as a sandboxed Python
|
||||
// code interpreter: the model can write and execute arbitrary Python code to
|
||||
// answer quantitative questions without calling any additional tools.
|
||||
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Hyperlight;
|
||||
using OpenAI.Chat;
|
||||
|
||||
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini";
|
||||
var guestPath = Environment.GetEnvironmentVariable("HYPERLIGHT_PYTHON_GUEST_PATH") ?? throw new InvalidOperationException("HYPERLIGHT_PYTHON_GUEST_PATH is not set.");
|
||||
|
||||
using var codeAct = new HyperlightCodeActProvider(HyperlightCodeActProviderOptions.CreateForWasm(guestPath));
|
||||
|
||||
AIAgent agent = new AzureOpenAIClient(
|
||||
new Uri(endpoint),
|
||||
new DefaultAzureCredential())
|
||||
.GetChatClient(deploymentName)
|
||||
.AsAIAgent(new ChatClientAgentOptions()
|
||||
{
|
||||
ChatOptions = new() { Instructions = "You are a helpful assistant. When the user asks something quantitative, write Python and call `execute_code` instead of guessing." },
|
||||
AIContextProviders = [codeAct],
|
||||
});
|
||||
|
||||
Console.WriteLine(await agent.RunAsync("What is the 20th Fibonacci number?"));
|
||||
Console.WriteLine(await agent.RunAsync("Compute the mean and standard deviation of [1, 4, 9, 16, 25, 36]."));
|
||||
-35
@@ -1,35 +0,0 @@
|
||||
# AgentWithCodeAct_Step01_Interpreter
|
||||
|
||||
A minimal CodeAct sample. The agent uses `HyperlightCodeActProvider` as a
|
||||
sandboxed Python interpreter: when the user asks something quantitative, the
|
||||
model writes Python and invokes the `execute_code` tool rather than answering
|
||||
from memory.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Variable | Description |
|
||||
|--------------------------------|-------------------------------------------------------------------------------------------|
|
||||
| `AZURE_OPENAI_ENDPOINT` | Azure OpenAI endpoint. Required. |
|
||||
| `AZURE_OPENAI_DEPLOYMENT_NAME` | Azure OpenAI deployment. Defaults to `gpt-5.4-mini`. |
|
||||
| `HYPERLIGHT_PYTHON_GUEST_PATH` | Absolute path to the Hyperlight Python guest module (`.wasm` or `.aot` file). Required. |
|
||||
|
||||
Authentication uses `DefaultAzureCredential`.
|
||||
|
||||
## Getting the guest module
|
||||
|
||||
The Python guest module is built from the
|
||||
[hyperlight-dev/hyperlight-sandbox](https://github.com/hyperlight-dev/hyperlight-sandbox)
|
||||
repository — see its README for the exact `cargo`/`just` invocations and
|
||||
the location of the resulting `.wasm` / `.aot` file. Set
|
||||
`HYPERLIGHT_PYTHON_GUEST_PATH` to the absolute path of that artifact
|
||||
before running the sample.
|
||||
|
||||
Hyperlight requires a hardware virtualization back end on the host:
|
||||
KVM on Linux or WHP (Windows Hypervisor Platform) on Windows.
|
||||
|
||||
## Run
|
||||
|
||||
```shell
|
||||
cd AgentWithCodeAct_Step01_Interpreter
|
||||
dotnet run
|
||||
```
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hyperlight\Microsoft.Agents.AI.Hyperlight.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
-52
@@ -1,52 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample shows how to use HyperlightCodeActProvider with provider-owned
|
||||
// tools (exposed inside the sandbox via `call_tool(...)`). The model can
|
||||
// orchestrate those tools in a single Python block, reducing round-trips. A
|
||||
// sensitive tool (`send_email`) is additionally wrapped in
|
||||
// ApprovalRequiredAIFunction so any code that reaches it requires user approval
|
||||
// for the entire execute_code invocation.
|
||||
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Hyperlight;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI.Chat;
|
||||
|
||||
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini";
|
||||
var guestPath = Environment.GetEnvironmentVariable("HYPERLIGHT_PYTHON_GUEST_PATH") ?? throw new InvalidOperationException("HYPERLIGHT_PYTHON_GUEST_PATH is not set.");
|
||||
|
||||
AIFunction fetchDocs = AIFunctionFactory.Create(
|
||||
(string topic) => $"Docs for {topic}: (...)",
|
||||
name: "fetch_docs",
|
||||
description: "Fetch documentation for a given topic.");
|
||||
|
||||
AIFunction queryData = AIFunctionFactory.Create(
|
||||
(string query) => $"Rows for `{query}`: []",
|
||||
name: "query_data",
|
||||
description: "Run a read-only SQL-like query against the sample store.");
|
||||
|
||||
AIFunction sendEmail = new ApprovalRequiredAIFunction(
|
||||
AIFunctionFactory.Create(
|
||||
(string to, string subject) => $"Sent '{subject}' to {to}.",
|
||||
name: "send_email",
|
||||
description: "Send an email on behalf of the user."));
|
||||
|
||||
var options = HyperlightCodeActProviderOptions.CreateForWasm(guestPath);
|
||||
options.Tools = [fetchDocs, queryData, sendEmail];
|
||||
|
||||
using var codeAct = new HyperlightCodeActProvider(options);
|
||||
|
||||
AIAgent agent = new AzureOpenAIClient(
|
||||
new Uri(endpoint),
|
||||
new DefaultAzureCredential())
|
||||
.GetChatClient(deploymentName)
|
||||
.AsAIAgent(new ChatClientAgentOptions()
|
||||
{
|
||||
ChatOptions = new() { Instructions = "You are a helpful assistant. Prefer orchestrating your work in a single `execute_code` block using `call_tool(...)` over issuing many direct tool calls." },
|
||||
AIContextProviders = [codeAct],
|
||||
});
|
||||
|
||||
Console.WriteLine(await agent.RunAsync("Look up docs on 'retries' and query the 'orders' table, then summarize."));
|
||||
-34
@@ -1,34 +0,0 @@
|
||||
# AgentWithCodeAct_Step02_ToolEnabled
|
||||
|
||||
Demonstrates adding provider-owned tools to `HyperlightCodeActProvider`. Those
|
||||
tools are **only** available to code running inside the sandbox via
|
||||
`call_tool("<name>", ...)` — they are never exposed to the model as direct
|
||||
tools. This lets the model orchestrate multiple tool calls in a single Python
|
||||
block.
|
||||
|
||||
One tool (`send_email`) is wrapped in `ApprovalRequiredAIFunction`, which causes
|
||||
the entire `execute_code` invocation to require user approval when that tool
|
||||
is configured.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Variable | Description |
|
||||
|--------------------------------|-------------------------------------------------------------------------------------------|
|
||||
| `AZURE_OPENAI_ENDPOINT` | Azure OpenAI endpoint. Required. |
|
||||
| `AZURE_OPENAI_DEPLOYMENT_NAME` | Azure OpenAI deployment. Defaults to `gpt-5.4-mini`. |
|
||||
| `HYPERLIGHT_PYTHON_GUEST_PATH` | Absolute path to the Hyperlight Python guest module (`.wasm` or `.aot` file). Required. |
|
||||
|
||||
## Run
|
||||
|
||||
```shell
|
||||
cd AgentWithCodeAct_Step02_ToolEnabled
|
||||
dotnet run
|
||||
```
|
||||
|
||||
## Planned follow-up
|
||||
|
||||
A more realistic "upload a file (e.g. an Excel workbook), have the agent
|
||||
analyze it with code" sample is planned as a separate step that will use
|
||||
`HostInputDirectory` together with a guest tool capable of reading the
|
||||
uploaded file. It will be added in a follow-up PR once the corresponding
|
||||
guest module support is in place.
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hyperlight\Microsoft.Agents.AI.Hyperlight.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
-40
@@ -1,40 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample shows how to wire up CodeAct manually using
|
||||
// HyperlightExecuteCodeFunction rather than the AIContextProvider. Use this
|
||||
// when you want a fixed tool surface for the agent's lifetime and don't need
|
||||
// the per-run snapshot/registry semantics of HyperlightCodeActProvider.
|
||||
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Hyperlight;
|
||||
using Microsoft.Extensions.AI;
|
||||
using OpenAI.Chat;
|
||||
|
||||
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini";
|
||||
var guestPath = Environment.GetEnvironmentVariable("HYPERLIGHT_PYTHON_GUEST_PATH") ?? throw new InvalidOperationException("HYPERLIGHT_PYTHON_GUEST_PATH is not set.");
|
||||
|
||||
AIFunction calculate = AIFunctionFactory.Create(
|
||||
(double a, double b) => a * b,
|
||||
name: "multiply",
|
||||
description: "Multiply two numbers.");
|
||||
|
||||
var options = HyperlightCodeActProviderOptions.CreateForWasm(guestPath);
|
||||
options.Tools = [calculate];
|
||||
|
||||
using var executeCode = new HyperlightExecuteCodeFunction(options);
|
||||
|
||||
var instructions =
|
||||
"You are a helpful assistant. When math is involved, solve it by writing Python "
|
||||
+ "and calling `execute_code` instead of computing values yourself.\n\n"
|
||||
+ executeCode.BuildInstructions(toolsVisibleToModel: false);
|
||||
|
||||
AIAgent agent = new AzureOpenAIClient(
|
||||
new Uri(endpoint),
|
||||
new DefaultAzureCredential())
|
||||
.GetChatClient(deploymentName)
|
||||
.AsAIAgent(instructions: instructions, tools: [executeCode]);
|
||||
|
||||
Console.WriteLine(await agent.RunAsync("What is 12.3 * 4.5? Use the multiply tool from within `execute_code`."));
|
||||
-21
@@ -1,21 +0,0 @@
|
||||
# AgentWithCodeAct_Step03_ManualWiring
|
||||
|
||||
Shows how to wire CodeAct manually using `HyperlightExecuteCodeFunction` as a
|
||||
direct agent tool instead of via an `AIContextProvider`. This is useful when
|
||||
the sandbox's tool surface and capabilities are fixed for the agent's
|
||||
lifetime, avoiding per-run snapshot/restore of the provider registry.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Variable | Description |
|
||||
|--------------------------------|-------------------------------------------------------------------------------------------|
|
||||
| `AZURE_OPENAI_ENDPOINT` | Azure OpenAI endpoint. Required. |
|
||||
| `AZURE_OPENAI_DEPLOYMENT_NAME` | Azure OpenAI deployment. Defaults to `gpt-5.4-mini`. |
|
||||
| `HYPERLIGHT_PYTHON_GUEST_PATH` | Absolute path to the Hyperlight Python guest module (`.wasm` or `.aot` file). Required. |
|
||||
|
||||
## Run
|
||||
|
||||
```shell
|
||||
cd AgentWithCodeAct_Step03_ManualWiring
|
||||
dotnet run
|
||||
```
|
||||
@@ -1,16 +0,0 @@
|
||||
# Agent Framework CodeAct (Hyperlight) Samples
|
||||
|
||||
These samples show how to enable an agent to write and execute code in a
|
||||
Hyperlight-backed sandbox via the CodeAct pattern. Guest code can be pure
|
||||
Python (interpreter mode) or orchestrate host-provided tools through
|
||||
`call_tool(...)` — all inside a secure sandbox with opt-in filesystem and
|
||||
network access.
|
||||
|
||||
|Sample|Description|
|
||||
|---|---|
|
||||
|[Code interpreter](./AgentWithCodeAct_Step01_Interpreter/)|Uses `HyperlightCodeActProvider` as a sandboxed Python interpreter with no host tools.|
|
||||
|[Tool-enabled CodeAct](./AgentWithCodeAct_Step02_ToolEnabled/)|Registers provider-owned tools that guest code can orchestrate via `call_tool(...)`, with an approval-required tool for sensitive actions.|
|
||||
|[Manual wiring](./AgentWithCodeAct_Step03_ManualWiring/)|Uses `HyperlightExecuteCodeFunction` directly as an agent tool when the sandbox configuration is fixed.|
|
||||
|
||||
All samples require a Hyperlight Python guest module. Set
|
||||
`HYPERLIGHT_PYTHON_GUEST_PATH` to its absolute path before running.
|
||||
@@ -11,7 +11,6 @@ The getting started samples demonstrate the fundamental concepts and functionali
|
||||
| [Agent Providers](./AgentProviders/README.md) | Getting started with creating agents using various providers |
|
||||
| [Agents With Retrieval Augmented Generation (RAG)](./AgentWithRAG/README.md) | Adding Retrieval Augmented Generation (RAG) capabilities to your agents |
|
||||
| [Agents With Memory](./AgentWithMemory/README.md) | Adding memory capabilities to your agents |
|
||||
| [Agents With CodeAct (Hyperlight)](./AgentWithCodeAct/README.md) | Enabling sandboxed code execution (CodeAct) for your agents via Hyperlight |
|
||||
| [Agent Open Telemetry](./AgentOpenTelemetry/README.md) | Getting started with OpenTelemetry for agents |
|
||||
| [Agent With OpenAI exchange types](./AgentWithOpenAI/README.md) | Using OpenAI exchange types with agents |
|
||||
| [Agent With Anthropic](./AgentWithAnthropic/README.md) | Getting started with agents using Anthropic Claude |
|
||||
|
||||
@@ -19,7 +19,8 @@ namespace Azure.AI.Projects;
|
||||
/// Foundry toolbox definitions as server-side tools.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Provides a single call on the project client to retrieve tools ready for use
|
||||
/// These extensions mirror Python's <c>FoundryChatClient.get_toolbox()</c> pattern,
|
||||
/// allowing a single call on the project client to retrieve tools ready for use
|
||||
/// with <c>AsAIAgent(model, instructions, tools: ...)</c>.
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
|
||||
|
||||
@@ -77,31 +77,23 @@ public class AgentFrameworkResponseHandler : ResponseHandler
|
||||
// 4. Convert input: history + current input → ChatMessage[]
|
||||
var messages = new List<ChatMessage>();
|
||||
|
||||
// Load conversation history only for fresh sessions. When a session already exists
|
||||
// (e.g. resuming a workflow paused at an external-input port), the workflow's
|
||||
// checkpointed state already contains the prior turns' messages — replaying history
|
||||
// would re-drive completed actions and break HITL resume semantics.
|
||||
var isResume = !string.IsNullOrWhiteSpace(sessionConversationId)
|
||||
&& session?.StateBag?.Count > 0;
|
||||
if (!isResume)
|
||||
// Load conversation history if available
|
||||
var history = await context.GetHistoryAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (history.Count > 0)
|
||||
{
|
||||
var history = await context.GetHistoryAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (history.Count > 0)
|
||||
{
|
||||
messages.AddRange(InputConverter.ConvertOutputItemsToMessages(history, session?.StateBag));
|
||||
}
|
||||
messages.AddRange(InputConverter.ConvertOutputItemsToMessages(history));
|
||||
}
|
||||
|
||||
// Load and convert current input items
|
||||
var inputItems = await context.GetInputItemsAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
if (inputItems.Count > 0)
|
||||
{
|
||||
messages.AddRange(InputConverter.ConvertItemsToMessages(inputItems, session?.StateBag));
|
||||
messages.AddRange(InputConverter.ConvertItemsToMessages(inputItems));
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fall back to raw request input
|
||||
messages.AddRange(InputConverter.ConvertInputToMessages(request, session?.StateBag));
|
||||
messages.AddRange(InputConverter.ConvertInputToMessages(request));
|
||||
}
|
||||
|
||||
// 5. Build chat options
|
||||
@@ -199,7 +191,6 @@ public class AgentFrameworkResponseHandler : ResponseHandler
|
||||
var enumerator = OutputConverter.ConvertUpdatesToEventsAsync(
|
||||
agent.RunStreamingAsync(messages, session, options: options, cancellationToken: consentCts.Token),
|
||||
stream,
|
||||
session?.StateBag,
|
||||
cancellationToken).GetAsyncEnumerator(cancellationToken);
|
||||
try
|
||||
{
|
||||
|
||||
@@ -1,261 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Buffers;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Shared.DiagnosticIds;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Provides a file-system backed implementation of <see cref="AgentSessionStore"/> that persists
|
||||
/// the agent-framework's serialized <see cref="AgentSession"/> state for each (agent, conversation)
|
||||
/// pair to disk. This complements Foundry storage (which owns conversation messages, agent
|
||||
/// definitions, and threads) — it is not a replacement for it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The session JSON stored here is the AF runtime's own state (workflow checkpoint manager,
|
||||
/// pending external requests, internal port state) that is required to resume an
|
||||
/// <see cref="AgentSession"/> across HTTP requests or process restarts but is not part of
|
||||
/// Foundry's data model.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// When running in a Foundry hosted environment, sessions are stored under the well-known
|
||||
/// <c>/.checkpoints</c> path; locally, they fall under <c>{cwd}/.checkpoints</c>. The session
|
||||
/// JSON produced when the agent serializes the session already contains the workflow's
|
||||
/// in-memory checkpoint manager state, so a single file per (agent, conversation) pair is
|
||||
/// sufficient to resume long-running workflows across process restarts.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Files are written atomically via a temp-file + <see cref="File.Move(string, string, bool)"/>
|
||||
/// rename so a partially-written file cannot be observed by a concurrent reader.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
|
||||
public sealed class FileSystemAgentSessionStore : AgentSessionStore
|
||||
{
|
||||
/// <summary>
|
||||
/// The well-known absolute path used when running inside a Foundry hosted environment.
|
||||
/// </summary>
|
||||
public const string HostedCheckpointDirectory = "/.checkpoints";
|
||||
|
||||
/// <summary>
|
||||
/// The directory name used under the current working directory when running locally.
|
||||
/// </summary>
|
||||
public const string LocalCheckpointDirectoryName = ".checkpoints";
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FileSystemAgentSessionStore"/> class
|
||||
/// that stores serialized sessions under <paramref name="rootDirectory"/>.
|
||||
/// </summary>
|
||||
/// <param name="rootDirectory">
|
||||
/// The absolute or relative directory where session files will be written.
|
||||
/// The directory is created on first write if it does not already exist.
|
||||
/// </param>
|
||||
public FileSystemAgentSessionStore(string rootDirectory)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(rootDirectory);
|
||||
this.RootDirectory = Path.GetFullPath(rootDirectory);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the root directory under which session files are written.
|
||||
/// </summary>
|
||||
public string RootDirectory { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a <see cref="FileSystemAgentSessionStore"/> rooted at the default location:
|
||||
/// <see cref="HostedCheckpointDirectory"/> when running in a Foundry hosted environment,
|
||||
/// otherwise <see cref="LocalCheckpointDirectoryName"/> under the current working directory.
|
||||
/// </summary>
|
||||
/// <returns>A new <see cref="FileSystemAgentSessionStore"/> instance.</returns>
|
||||
public static FileSystemAgentSessionStore CreateDefault()
|
||||
{
|
||||
string root = FoundryEnvironment.IsHosted
|
||||
? HostedCheckpointDirectory
|
||||
: Path.Combine(Environment.CurrentDirectory, LocalCheckpointDirectoryName);
|
||||
return new FileSystemAgentSessionStore(root);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async ValueTask SaveSessionAsync(AIAgent agent, string conversationId, AgentSession session, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(agent);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(conversationId);
|
||||
ArgumentNullException.ThrowIfNull(session);
|
||||
|
||||
JsonElement serialized = await agent.SerializeSessionAsync(session, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
|
||||
Directory.CreateDirectory(this.RootDirectory);
|
||||
|
||||
string path = this.GetSessionPath(agent, conversationId);
|
||||
string? parentDir = Path.GetDirectoryName(path);
|
||||
if (!string.IsNullOrEmpty(parentDir))
|
||||
{
|
||||
Directory.CreateDirectory(parentDir);
|
||||
}
|
||||
|
||||
// Each save writes to its own temp file before atomically renaming over the
|
||||
// destination. Last writer wins for the final file, but no reader can observe
|
||||
// a torn or partially-written JSON document.
|
||||
string tempPath = $"{path}.{Guid.NewGuid():N}.tmp";
|
||||
|
||||
try
|
||||
{
|
||||
using (FileStream stream = new(tempPath, FileMode.Create, FileAccess.Write, FileShare.None))
|
||||
using (Utf8JsonWriter writer = new(stream))
|
||||
{
|
||||
serialized.WriteTo(writer);
|
||||
}
|
||||
|
||||
File.Move(tempPath, path, overwrite: true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
try { File.Delete(tempPath); } catch { /* best-effort cleanup */ }
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async ValueTask<AgentSession> GetSessionAsync(AIAgent agent, string conversationId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(agent);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(conversationId);
|
||||
|
||||
string path = this.GetSessionPath(agent, conversationId);
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
byte[] bytes = await File.ReadAllBytesAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
if (bytes.Length == 0)
|
||||
{
|
||||
return await agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Parse and clone so the document buffer can be released.
|
||||
using JsonDocument document = JsonDocument.Parse(bytes);
|
||||
JsonElement element = document.RootElement.Clone();
|
||||
return await agent.DeserializeSessionAsync(element, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private string GetSessionPath(AIAgent agent, string conversationId)
|
||||
{
|
||||
// When agent.Name is set we bucket sessions into a per-agent subdirectory so
|
||||
// multiple keyed agents sharing a single in-process default store cannot
|
||||
// collide on the same conversationId. agent.Id is intentionally NOT used
|
||||
// because it is regenerated on every startup for in-memory-defined agents.
|
||||
string fileName = $"{Sanitize(conversationId)}.json";
|
||||
if (string.IsNullOrEmpty(agent.Name))
|
||||
{
|
||||
return Path.Combine(this.RootDirectory, fileName);
|
||||
}
|
||||
|
||||
string agentDir = Path.Combine(this.RootDirectory, Sanitize(agent.Name!));
|
||||
return Path.Combine(agentDir, fileName);
|
||||
}
|
||||
|
||||
private static string Sanitize(string value)
|
||||
{
|
||||
// Percent-encode every character that is invalid in a filename, plus '%' itself
|
||||
// so the encoding is unambiguous. This is reversible and avoids the collision
|
||||
// hazard of a lossy character substitution (e.g. "foo/bar" and "foo_bar" sharing
|
||||
// a sanitized name).
|
||||
char[] invalid = Path.GetInvalidFileNameChars();
|
||||
|
||||
int encodedLength = ComputeEncodedLength(value, invalid);
|
||||
|
||||
// stackalloc is bounded so an externally-controlled length cannot crash the
|
||||
// hosting process with StackOverflowException.
|
||||
const int StackLimit = 512;
|
||||
string sanitized;
|
||||
if (encodedLength <= StackLimit)
|
||||
{
|
||||
Span<char> buffer = stackalloc char[encodedLength];
|
||||
SanitizeCore(value, invalid, buffer);
|
||||
sanitized = new string(buffer);
|
||||
}
|
||||
else
|
||||
{
|
||||
char[] rented = ArrayPool<char>.Shared.Rent(encodedLength);
|
||||
try
|
||||
{
|
||||
Span<char> buffer = rented.AsSpan(0, encodedLength);
|
||||
SanitizeCore(value, invalid, buffer);
|
||||
sanitized = new string(buffer);
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<char>.Shared.Return(rented);
|
||||
}
|
||||
}
|
||||
|
||||
// '.' and '..' are valid filename characters but resolve to current/parent
|
||||
// directory when used as a bare path component. Windows additionally strips
|
||||
// trailing dots from filenames, so a segment like "..." would survive on disk
|
||||
// as "" and a partial-encode like "%2E.." would survive as "%2E". Encode every
|
||||
// dot in any all-dot segment so the result has no special meaning to the OS.
|
||||
if (sanitized.Length > 0 && IsAllDots(sanitized))
|
||||
{
|
||||
return string.Concat(Enumerable.Repeat("%2E", sanitized.Length));
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
private static int ComputeEncodedLength(string value, char[] invalid)
|
||||
{
|
||||
int extra = 0;
|
||||
for (int i = 0; i < value.Length; i++)
|
||||
{
|
||||
char c = value[i];
|
||||
if (c == '%' || Array.IndexOf(invalid, c) >= 0)
|
||||
{
|
||||
extra += 2; // 1 char ('%' or invalid) becomes 3 chars ("%XX")
|
||||
}
|
||||
}
|
||||
return value.Length + extra;
|
||||
}
|
||||
|
||||
private static bool IsAllDots(string value)
|
||||
{
|
||||
for (int i = 0; i < value.Length; i++)
|
||||
{
|
||||
if (value[i] != '.')
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void SanitizeCore(string value, char[] invalid, Span<char> buffer)
|
||||
{
|
||||
int j = 0;
|
||||
for (int i = 0; i < value.Length; i++)
|
||||
{
|
||||
char c = value[i];
|
||||
if (c == '%' || Array.IndexOf(invalid, c) >= 0)
|
||||
{
|
||||
buffer[j++] = '%';
|
||||
buffer[j++] = HexChar((c >> 4) & 0xF);
|
||||
buffer[j++] = HexChar(c & 0xF);
|
||||
}
|
||||
else
|
||||
{
|
||||
buffer[j++] = c;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static char HexChar(int n) => (char)(n < 10 ? '0' + n : 'A' + n - 10);
|
||||
}
|
||||
@@ -32,6 +32,9 @@ namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
/// they are sent as server-side tool definitions in the Responses API request. The Foundry platform
|
||||
/// handles tool execution — the agent process does not invoke tools locally.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This is the dotnet equivalent of Python's <c>FoundryChatClient.get_toolbox()</c> pattern.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(DiagnosticIds.Experiments.AIOpenAIResponses)]
|
||||
public static class FoundryToolbox
|
||||
|
||||
@@ -3,12 +3,10 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Azure.AI.AgentServer.Responses.Models;
|
||||
using Microsoft.Extensions.AI;
|
||||
using MeaiTextContent = Microsoft.Extensions.AI.TextContent;
|
||||
using SdkTextContent = Azure.AI.AgentServer.Responses.Models.TextContent;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
@@ -21,15 +19,14 @@ internal static class InputConverter
|
||||
/// Converts the SDK <see cref="CreateResponse"/> request input items into a list of <see cref="ChatMessage"/>.
|
||||
/// </summary>
|
||||
/// <param name="request">The create response request from the SDK.</param>
|
||||
/// <param name="stateBag">Optional session state bag carrying the tool-approval id mapping.</param>
|
||||
/// <returns>A list of chat messages representing the request input.</returns>
|
||||
public static List<ChatMessage> ConvertInputToMessages(CreateResponse request, AgentSessionStateBag? stateBag = null)
|
||||
public static List<ChatMessage> ConvertInputToMessages(CreateResponse request)
|
||||
{
|
||||
var messages = new List<ChatMessage>();
|
||||
|
||||
foreach (var item in request.GetInputExpanded())
|
||||
{
|
||||
var message = ConvertInputItemToMessage(item, stateBag);
|
||||
var message = ConvertInputItemToMessage(item);
|
||||
if (message is not null)
|
||||
{
|
||||
messages.Add(message);
|
||||
@@ -43,15 +40,14 @@ internal static class InputConverter
|
||||
/// Converts resolved SDK <see cref="Item"/> input items into <see cref="ChatMessage"/> instances.
|
||||
/// </summary>
|
||||
/// <param name="items">The resolved input items from the SDK context.</param>
|
||||
/// <param name="stateBag">Optional session state bag carrying the tool-approval id mapping.</param>
|
||||
/// <returns>A list of chat messages.</returns>
|
||||
public static List<ChatMessage> ConvertItemsToMessages(IReadOnlyList<Item> items, AgentSessionStateBag? stateBag = null)
|
||||
public static List<ChatMessage> ConvertItemsToMessages(IReadOnlyList<Item> items)
|
||||
{
|
||||
var messages = new List<ChatMessage>();
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
var message = ConvertInputItemToMessage(item, stateBag);
|
||||
var message = ConvertInputItemToMessage(item);
|
||||
if (message is not null)
|
||||
{
|
||||
messages.Add(message);
|
||||
@@ -65,15 +61,14 @@ internal static class InputConverter
|
||||
/// Converts resolved SDK <see cref="OutputItem"/> history/input items into <see cref="ChatMessage"/> instances.
|
||||
/// </summary>
|
||||
/// <param name="items">The resolved output items from the SDK context.</param>
|
||||
/// <param name="stateBag">Optional session state bag carrying the tool-approval id mapping.</param>
|
||||
/// <returns>A list of chat messages.</returns>
|
||||
public static List<ChatMessage> ConvertOutputItemsToMessages(IReadOnlyList<OutputItem> items, AgentSessionStateBag? stateBag = null)
|
||||
public static List<ChatMessage> ConvertOutputItemsToMessages(IReadOnlyList<OutputItem> items)
|
||||
{
|
||||
var messages = new List<ChatMessage>();
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
var message = ConvertOutputItemToMessage(item, stateBag);
|
||||
var message = ConvertOutputItemToMessage(item);
|
||||
if (message is not null)
|
||||
{
|
||||
messages.Add(message);
|
||||
@@ -133,15 +128,13 @@ internal static class InputConverter
|
||||
return markers;
|
||||
}
|
||||
|
||||
private static ChatMessage? ConvertInputItemToMessage(Item item, AgentSessionStateBag? stateBag)
|
||||
private static ChatMessage? ConvertInputItemToMessage(Item item)
|
||||
{
|
||||
return item switch
|
||||
{
|
||||
ItemMessage msg => ConvertItemMessage(msg),
|
||||
FunctionCallOutputItemParam funcOutput => ConvertFunctionCallOutput(funcOutput),
|
||||
ItemFunctionToolCall funcCall => ConvertItemFunctionToolCall(funcCall),
|
||||
ItemMcpApprovalRequest approvalRequest => ConvertMcpApprovalRequest(approvalRequest.Id, approvalRequest.Name, approvalRequest.Arguments),
|
||||
MCPApprovalResponse approvalResponse => ConvertMcpApprovalResponse(approvalResponse.ApprovalRequestId, approvalResponse.Approve, stateBag),
|
||||
ItemReferenceParam => null,
|
||||
_ => null
|
||||
};
|
||||
@@ -159,23 +152,43 @@ internal static class InputConverter
|
||||
case MessageContentInputTextContent textContent:
|
||||
contents.Add(new MeaiTextContent(textContent.Text));
|
||||
break;
|
||||
case SdkTextContent textContent:
|
||||
contents.Add(new MeaiTextContent(textContent.Text));
|
||||
break;
|
||||
case SummaryTextContent summary:
|
||||
contents.Add(new MeaiTextContent(summary.Text));
|
||||
break;
|
||||
case MessageContentReasoningTextContent reasoning:
|
||||
contents.Add(new TextReasoningContent(reasoning.Text));
|
||||
break;
|
||||
case MessageContentInputImageContent imageContent:
|
||||
AppendImageContent(contents, imageContent.ImageUrl, imageContent.FileId);
|
||||
if (imageContent.ImageUrl is not null)
|
||||
{
|
||||
var url = imageContent.ImageUrl.ToString();
|
||||
if (url.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
contents.Add(new DataContent(url, "image/*"));
|
||||
}
|
||||
else
|
||||
{
|
||||
contents.Add(new UriContent(imageContent.ImageUrl, "image/*"));
|
||||
}
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(imageContent.FileId))
|
||||
{
|
||||
contents.Add(new HostedFileContent(imageContent.FileId));
|
||||
}
|
||||
|
||||
break;
|
||||
case MessageContentInputFileContent fileContent:
|
||||
AppendFileContent(contents, fileContent.FileUrl, fileContent.FileData, fileContent.FileId, fileContent.Filename);
|
||||
break;
|
||||
case ComputerScreenshotContent screenshot:
|
||||
AppendImageContent(contents, screenshot.ImageUrl, screenshot.FileId);
|
||||
if (fileContent.FileUrl is not null)
|
||||
{
|
||||
contents.Add(new UriContent(fileContent.FileUrl, "application/octet-stream"));
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(fileContent.FileData))
|
||||
{
|
||||
contents.Add(new DataContent(fileContent.FileData, "application/octet-stream"));
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(fileContent.FileId))
|
||||
{
|
||||
contents.Add(new HostedFileContent(fileContent.FileId));
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(fileContent.Filename))
|
||||
{
|
||||
contents.Add(new MeaiTextContent($"[File: {fileContent.Filename}]"));
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -218,63 +231,13 @@ internal static class InputConverter
|
||||
[new FunctionCallContent(funcCall.CallId, funcCall.Name, arguments)]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts an inbound <c>mcp_approval_request</c> wire item (from history replay
|
||||
/// or fresh-input) to a <see cref="ToolApprovalRequestContent"/> wrapping a
|
||||
/// <see cref="FunctionCallContent"/>.
|
||||
/// </summary>
|
||||
private static ChatMessage ConvertMcpApprovalRequest(string id, string name, string? arguments)
|
||||
{
|
||||
var functionCall = new FunctionCallContent(id, name, ParseFunctionArgumentsObject(arguments));
|
||||
return new ChatMessage(
|
||||
ChatRole.Assistant,
|
||||
[new ToolApprovalRequestContent(id, functionCall)]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts an inbound <c>mcp_approval_response</c> wire item to a
|
||||
/// <see cref="ToolApprovalResponseContent"/>. Looks up the original AF request id
|
||||
/// via <see cref="ToolApprovalIdMap"/>; falls back to the wire id when the mapping
|
||||
/// is unavailable. Carries a placeholder <see cref="FunctionCallContent"/> because
|
||||
/// the original tool-call details are not echoed by clients in the response item.
|
||||
/// </summary>
|
||||
private static ChatMessage ConvertMcpApprovalResponse(string approvalRequestId, bool approve, AgentSessionStateBag? stateBag)
|
||||
{
|
||||
var afRequestId = ToolApprovalIdMap.Resolve(stateBag, approvalRequestId);
|
||||
var placeholderFunctionCall = new FunctionCallContent(afRequestId, "mcp_approval");
|
||||
return new ChatMessage(
|
||||
ChatRole.User,
|
||||
[new ToolApprovalResponseContent(afRequestId, approve, placeholderFunctionCall)]);
|
||||
}
|
||||
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing tool-call arguments from SDK input.")]
|
||||
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing tool-call arguments from SDK input.")]
|
||||
private static Dictionary<string, object?>? ParseFunctionArgumentsObject(string? arguments)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(arguments))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<Dictionary<string, object?>>(arguments);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return new Dictionary<string, object?> { ["_raw"] = arguments };
|
||||
}
|
||||
}
|
||||
|
||||
private static ChatMessage? ConvertOutputItemToMessage(OutputItem item, AgentSessionStateBag? stateBag)
|
||||
private static ChatMessage? ConvertOutputItemToMessage(OutputItem item)
|
||||
{
|
||||
return item switch
|
||||
{
|
||||
OutputItemMessage msg => ConvertOutputItemMessageToChat(msg),
|
||||
OutputItemFunctionToolCall funcCall => ConvertOutputItemFunctionCall(funcCall),
|
||||
OutputItemFunctionToolCallOutput funcOutput => ConvertFunctionToolCallOutput(funcOutput),
|
||||
OutputItemMcpApprovalRequest approvalRequest => ConvertMcpApprovalRequest(approvalRequest.Id, approvalRequest.Name, approvalRequest.Arguments),
|
||||
OutputItemMcpApprovalResponseResource approvalResponse => ConvertMcpApprovalResponse(approvalResponse.ApprovalRequestId, approvalResponse.Approve, stateBag),
|
||||
OutputItemReasoningItem => null,
|
||||
_ => null
|
||||
};
|
||||
@@ -295,26 +258,46 @@ internal static class InputConverter
|
||||
case MessageContentOutputTextContent textContent:
|
||||
contents.Add(new MeaiTextContent(textContent.Text));
|
||||
break;
|
||||
case SdkTextContent textContent:
|
||||
contents.Add(new MeaiTextContent(textContent.Text));
|
||||
break;
|
||||
case SummaryTextContent summary:
|
||||
contents.Add(new MeaiTextContent(summary.Text));
|
||||
break;
|
||||
case MessageContentReasoningTextContent reasoning:
|
||||
contents.Add(new TextReasoningContent(reasoning.Text));
|
||||
break;
|
||||
case MessageContentRefusalContent refusal:
|
||||
contents.Add(new MeaiTextContent($"[Refusal: {refusal.Refusal}]"));
|
||||
break;
|
||||
case MessageContentInputImageContent imageContent:
|
||||
AppendImageContent(contents, imageContent.ImageUrl, imageContent.FileId);
|
||||
if (imageContent.ImageUrl is not null)
|
||||
{
|
||||
var url = imageContent.ImageUrl.ToString();
|
||||
if (url.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
contents.Add(new DataContent(url, "image/*"));
|
||||
}
|
||||
else
|
||||
{
|
||||
contents.Add(new UriContent(imageContent.ImageUrl, "image/*"));
|
||||
}
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(imageContent.FileId))
|
||||
{
|
||||
contents.Add(new HostedFileContent(imageContent.FileId));
|
||||
}
|
||||
|
||||
break;
|
||||
case MessageContentInputFileContent fileContent:
|
||||
AppendFileContent(contents, fileContent.FileUrl, fileContent.FileData, fileContent.FileId, fileContent.Filename);
|
||||
break;
|
||||
case ComputerScreenshotContent screenshot:
|
||||
AppendImageContent(contents, screenshot.ImageUrl, screenshot.FileId);
|
||||
if (fileContent.FileUrl is not null)
|
||||
{
|
||||
contents.Add(new UriContent(fileContent.FileUrl, "application/octet-stream"));
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(fileContent.FileData))
|
||||
{
|
||||
contents.Add(new DataContent(fileContent.FileData, "application/octet-stream"));
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(fileContent.FileId))
|
||||
{
|
||||
contents.Add(new HostedFileContent(fileContent.FileId));
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(fileContent.Filename))
|
||||
{
|
||||
contents.Add(new MeaiTextContent($"[File: {fileContent.Filename}]"));
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -327,127 +310,6 @@ internal static class InputConverter
|
||||
return new ChatMessage(role, contents);
|
||||
}
|
||||
|
||||
private static void AppendImageContent(List<AIContent> contents, Uri? imageUrl, string? fileId)
|
||||
{
|
||||
if (imageUrl is not null)
|
||||
{
|
||||
var url = imageUrl.ToString();
|
||||
if (url.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
contents.Add(new DataContent(url, "image/*"));
|
||||
}
|
||||
else
|
||||
{
|
||||
contents.Add(new UriContent(imageUrl, "image/*"));
|
||||
}
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(fileId))
|
||||
{
|
||||
contents.Add(new HostedFileContent(fileId));
|
||||
}
|
||||
}
|
||||
|
||||
private static void AppendFileContent(List<AIContent> contents, Uri? fileUrl, string? fileData, string? fileId, string? filename)
|
||||
{
|
||||
if (fileUrl is not null)
|
||||
{
|
||||
var content = new UriContent(fileUrl, "application/octet-stream");
|
||||
if (!string.IsNullOrEmpty(filename))
|
||||
{
|
||||
content.AdditionalProperties = new AdditionalPropertiesDictionary { ["filename"] = filename };
|
||||
}
|
||||
contents.Add(content);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(fileData))
|
||||
{
|
||||
// If the data URI carries text/* content, decode it inline as TextContent so
|
||||
// {System.LastMessageText} (and other text-only consumers) sees the file's
|
||||
// body rather than an opaque blob.
|
||||
if (TryDecodeTextDataUri(fileData, filename, out var decodedText))
|
||||
{
|
||||
contents.Add(new MeaiTextContent(decodedText));
|
||||
}
|
||||
else
|
||||
{
|
||||
var dataContent = new DataContent(fileData, "application/octet-stream");
|
||||
if (!string.IsNullOrEmpty(filename))
|
||||
{
|
||||
dataContent.AdditionalProperties = new AdditionalPropertiesDictionary { ["filename"] = filename };
|
||||
}
|
||||
contents.Add(dataContent);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(fileId))
|
||||
{
|
||||
var hosted = new HostedFileContent(fileId);
|
||||
if (!string.IsNullOrEmpty(filename))
|
||||
{
|
||||
hosted.AdditionalProperties = new AdditionalPropertiesDictionary { ["filename"] = filename };
|
||||
}
|
||||
contents.Add(hosted);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(filename))
|
||||
{
|
||||
contents.Add(new MeaiTextContent($"[File: {filename}]"));
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryDecodeTextDataUri(string dataUri, string? filename, out string text)
|
||||
{
|
||||
// Cap the encoded payload so an oversized client-supplied data URI cannot
|
||||
// trigger an unbounded allocation in Convert.FromBase64String. 16 MiB
|
||||
// encoded → ~12 MiB decoded, well above any realistic text/* file we'd
|
||||
// want to inline as content while still bounding the worst case.
|
||||
const int MaxEncodedLength = 16 * 1024 * 1024;
|
||||
|
||||
text = string.Empty;
|
||||
if (!dataUri.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
const string Marker = ";base64,";
|
||||
int markerIndex = dataUri.IndexOf(Marker, StringComparison.OrdinalIgnoreCase);
|
||||
if (markerIndex < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string mediaType = dataUri.Substring("data:".Length, markerIndex - "data:".Length);
|
||||
if (!mediaType.StartsWith("text/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string encoded = dataUri.Substring(markerIndex + Marker.Length);
|
||||
if (encoded.Length > MaxEncodedLength)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
byte[] bytes = Convert.FromBase64String(encoded);
|
||||
string decoded = Encoding.UTF8.GetString(bytes);
|
||||
text = string.IsNullOrEmpty(filename) ? decoded : $"[File: {filename}]\n{decoded}";
|
||||
return true;
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (DecoderFallbackException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing function call arguments from SDK output history.")]
|
||||
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing function call arguments from SDK output history.")]
|
||||
private static ChatMessage ConvertOutputItemFunctionCall(OutputItemFunctionToolCall funcCall)
|
||||
|
||||
@@ -30,7 +30,6 @@ internal static class OutputConverter
|
||||
/// </summary>
|
||||
/// <param name="updates">The agent response updates to convert.</param>
|
||||
/// <param name="stream">The SDK event stream builder.</param>
|
||||
/// <param name="stateBag">Optional session state bag used to persist tool-approval id mappings across turns.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>An async enumerable of SDK response stream events (excluding lifecycle events).</returns>
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Serializing function call arguments dictionary.")]
|
||||
@@ -38,7 +37,6 @@ internal static class OutputConverter
|
||||
public static async IAsyncEnumerable<ResponseStreamEvent> ConvertUpdatesToEventsAsync(
|
||||
IAsyncEnumerable<AgentResponseUpdate> updates,
|
||||
ResponseEventStream stream,
|
||||
AgentSessionStateBag? stateBag = null,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
ResponseUsage? accumulatedUsage = null;
|
||||
@@ -53,11 +51,8 @@ internal static class OutputConverter
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
// Handle workflow events from RawRepresentation.
|
||||
// If the update also carries Contents (e.g. WorkflowSession unwrapped a
|
||||
// WorkflowErrorEvent or ExecutorFailedEvent into an ErrorContent payload),
|
||||
// fall through to the content-processing path below so those are emitted.
|
||||
if (update.RawRepresentation is WorkflowEvent workflowEvent && update.Contents.Count == 0)
|
||||
// Handle workflow events from RawRepresentation
|
||||
if (update.RawRepresentation is WorkflowEvent workflowEvent)
|
||||
{
|
||||
// Close any open message builder before emitting workflow items
|
||||
foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText))
|
||||
@@ -171,54 +166,6 @@ internal static class OutputConverter
|
||||
break;
|
||||
}
|
||||
|
||||
case ToolApprovalRequestContent approvalRequest when approvalRequest.ToolCall is FunctionCallContent approvalFunctionCall:
|
||||
{
|
||||
foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText))
|
||||
{
|
||||
yield return evt;
|
||||
}
|
||||
|
||||
currentTextBuilder = null;
|
||||
currentMessageBuilder = null;
|
||||
accumulatedText = null;
|
||||
previousMessageId = null;
|
||||
|
||||
// The Responses API only standardizes the MCP-flavored approval primitive.
|
||||
// We emit the AF tool-approval request as `mcp_approval_request` with
|
||||
// server_label="agent_framework" — declaring the AF runtime as the virtual
|
||||
// server holding this call. The SDK requires a strict {prefix}_{50hex}
|
||||
// wire-id format, so we hash the AF RequestId and persist the
|
||||
// wireId↔afRequestId mapping in the session state bag for later lookup
|
||||
// when the matching `mcp_approval_response` arrives on a subsequent turn.
|
||||
var wireId = ToolApprovalIdMap.ComputeWireId(approvalRequest.RequestId);
|
||||
ToolApprovalIdMap.Record(stateBag, wireId, approvalRequest.RequestId);
|
||||
|
||||
var approvalArguments = approvalFunctionCall.Arguments is not null
|
||||
? JsonSerializer.Serialize(approvalFunctionCall.Arguments)
|
||||
: "{}";
|
||||
|
||||
var approvalItem = new OutputItemMcpApprovalRequest(
|
||||
wireId,
|
||||
"agent_framework",
|
||||
approvalFunctionCall.Name,
|
||||
approvalArguments);
|
||||
|
||||
var approvalBuilder = stream.AddOutputItem<OutputItemMcpApprovalRequest>(wireId);
|
||||
yield return approvalBuilder.EmitAdded(approvalItem);
|
||||
yield return approvalBuilder.EmitDone(approvalItem);
|
||||
break;
|
||||
}
|
||||
|
||||
case ToolApprovalRequestContent:
|
||||
// Approval requests must wrap a FunctionCallContent (handled above).
|
||||
// Any other shape has no representation in the Responses wire format.
|
||||
break;
|
||||
|
||||
case ToolApprovalResponseContent:
|
||||
// Approval responses originate from the client and travel inbound; the
|
||||
// workflow does not re-emit them. Skip silently if encountered.
|
||||
break;
|
||||
|
||||
case UsageContent usageContent when usageContent.Details is not null:
|
||||
{
|
||||
accumulatedUsage = ConvertUsage(usageContent.Details, accumulatedUsage);
|
||||
|
||||
@@ -49,7 +49,7 @@ public static class FoundryHostingExtensions
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
services.AddResponsesServer();
|
||||
services.TryAddSingleton<AgentSessionStore>(_ => FileSystemAgentSessionStore.CreateDefault());
|
||||
services.TryAddSingleton<AgentSessionStore, InMemoryAgentSessionStore>();
|
||||
services.TryAddSingleton<ResponseHandler, AgentFrameworkResponseHandler>();
|
||||
return services;
|
||||
}
|
||||
@@ -76,7 +76,7 @@ public static class FoundryHostingExtensions
|
||||
/// </remarks>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <param name="agent">The agent instance to register.</param>
|
||||
/// <param name="agentSessionStore">The agent session store to use for managing agent sessions server-side. If null, a file-system session store is used, rooted at <c>/.checkpoints</c> when running in a Foundry hosted environment and <c>{cwd}/.checkpoints</c> locally.</param>
|
||||
/// <param name="agentSessionStore">The agent session store to use for managing agent sessions server-side. If null, an in-memory session store will be used.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddFoundryResponses(this IServiceCollection services, AIAgent agent, AgentSessionStore? agentSessionStore = null)
|
||||
{
|
||||
@@ -84,7 +84,7 @@ public static class FoundryHostingExtensions
|
||||
ArgumentNullException.ThrowIfNull(agent);
|
||||
|
||||
services.AddResponsesServer();
|
||||
agentSessionStore ??= FileSystemAgentSessionStore.CreateDefault();
|
||||
agentSessionStore ??= new InMemoryAgentSessionStore();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(agent.Name))
|
||||
{
|
||||
@@ -185,6 +185,8 @@ public static class FoundryHostingExtensions
|
||||
|
||||
/// <summary>
|
||||
/// The ActivitySource name for the Responses hosting pipeline.
|
||||
/// Matches the value previously exposed by <c>AgentHostTelemetry.ResponsesSourceName</c>
|
||||
/// in <c>Azure.AI.AgentServer.Core</c>.
|
||||
/// </summary>
|
||||
private const string ResponsesSourceName = "Azure.AI.AgentServer.Responses";
|
||||
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Helper for translating between agent-framework tool-approval request ids and the
|
||||
/// strict-format wire ids required by the Responses Server SDK <c>mcp_approval_request</c>
|
||||
/// item type. The mapping is persisted in <see cref="AgentSessionStateBag"/> so an
|
||||
/// approval request emitted on one HTTP turn can be matched to the response posted
|
||||
/// back on the next turn.
|
||||
/// </summary>
|
||||
internal static class ToolApprovalIdMap
|
||||
{
|
||||
/// <summary>
|
||||
/// State-bag key used to store the wire-id ↔ AF-request-id mapping.
|
||||
/// </summary>
|
||||
public const string StateBagKey = "Microsoft.Agents.AI.Foundry.Hosting.ToolApprovalIdMap";
|
||||
|
||||
/// <summary>
|
||||
/// SDK item-id format constraints: <c>{prefix}_{50_or_48_chars}</c>. We use the
|
||||
/// canonical <c>mcpr_</c> prefix and a SHA-256 truncated to 50 hex chars (25 bytes)
|
||||
/// for deterministic, format-safe wire ids.
|
||||
/// </summary>
|
||||
public static string ComputeWireId(string afRequestId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(afRequestId);
|
||||
|
||||
#if NET10_0_OR_GREATER
|
||||
Span<byte> hash = stackalloc byte[32];
|
||||
SHA256.HashData(Encoding.UTF8.GetBytes(afRequestId), hash);
|
||||
#else
|
||||
byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(afRequestId));
|
||||
#endif
|
||||
// 25 bytes = 50 hex chars (matches SDK body length 50).
|
||||
return "mcpr_" + Convert.ToHexString(hash).AsSpan(0, 50).ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records the wire-id → AF-request-id mapping in the supplied state bag.
|
||||
/// </summary>
|
||||
public static void Record(AgentSessionStateBag? stateBag, string wireId, string afRequestId)
|
||||
{
|
||||
if (stateBag is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var map = stateBag.GetValue<Dictionary<string, string>>(StateBagKey)
|
||||
?? new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
map[wireId] = afRequestId;
|
||||
stateBag.SetValue(StateBagKey, map);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up the AF request id for a given wire id. Returns the wire id verbatim
|
||||
/// when no mapping is present (best-effort fallback that keeps converters total).
|
||||
/// </summary>
|
||||
public static string Resolve(AgentSessionStateBag? stateBag, string wireId)
|
||||
{
|
||||
if (stateBag?.GetValue<Dictionary<string, string>>(StateBagKey) is { } map
|
||||
&& map.TryGetValue(wireId, out var afRequestId))
|
||||
{
|
||||
return afRequestId;
|
||||
}
|
||||
|
||||
return wireId;
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hyperlight;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a single entry in the outbound network allow-list applied to the
|
||||
/// Hyperlight sandbox.
|
||||
/// </summary>
|
||||
public sealed class AllowedDomain
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AllowedDomain"/> class.
|
||||
/// </summary>
|
||||
/// <param name="target">URL or domain to allow, for example <c>"https://api.github.com"</c>.</param>
|
||||
/// <param name="methods">
|
||||
/// Optional list of HTTP methods to allow (for example <c>["GET", "POST"]</c>).
|
||||
/// When <see langword="null"/>, all methods supported by the backend are allowed.
|
||||
/// </param>
|
||||
public AllowedDomain(string target, IReadOnlyList<string>? methods = null)
|
||||
{
|
||||
this.Target = target;
|
||||
this.Methods = methods;
|
||||
}
|
||||
|
||||
/// <summary>Gets the URL or domain to allow.</summary>
|
||||
public string Target { get; }
|
||||
|
||||
/// <summary>Gets the optional list of HTTP methods to allow.</summary>
|
||||
public IReadOnlyList<string>? Methods { get; }
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hyperlight;
|
||||
|
||||
/// <summary>
|
||||
/// Controls the approval behavior for the <c>execute_code</c> tool exposed by
|
||||
/// <see cref="HyperlightCodeActProvider"/> and <see cref="HyperlightExecuteCodeFunction"/>.
|
||||
/// </summary>
|
||||
public enum CodeActApprovalMode
|
||||
{
|
||||
/// <summary>
|
||||
/// <c>execute_code</c> always requires user approval before invocation.
|
||||
/// </summary>
|
||||
AlwaysRequire,
|
||||
|
||||
/// <summary>
|
||||
/// Approval is derived from the provider-owned CodeAct tool registry.
|
||||
/// If any configured tool is an
|
||||
/// <see cref="ApprovalRequiredAIFunction"/>,
|
||||
/// <c>execute_code</c> also requires approval. Otherwise it does not.
|
||||
/// </summary>
|
||||
NeverRequire,
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
namespace Microsoft.Agents.AI.Hyperlight;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a host-to-sandbox file mount configuration used by
|
||||
/// <see cref="HyperlightCodeActProvider"/>.
|
||||
/// </summary>
|
||||
public sealed class FileMount
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FileMount"/> class.
|
||||
/// </summary>
|
||||
/// <param name="hostPath">Absolute or relative path on the host filesystem to mount into the sandbox.</param>
|
||||
/// <param name="mountPath">
|
||||
/// Path inside the sandbox the host path is exposed at (for example <c>"/input/data.csv"</c>).
|
||||
/// </param>
|
||||
public FileMount(string hostPath, string mountPath)
|
||||
{
|
||||
this.HostPath = hostPath;
|
||||
this.MountPath = mountPath;
|
||||
}
|
||||
|
||||
/// <summary>Gets the path on the host filesystem that is mounted into the sandbox.</summary>
|
||||
public string HostPath { get; }
|
||||
|
||||
/// <summary>Gets the path inside the sandbox at which the host path is exposed.</summary>
|
||||
public string MountPath { get; }
|
||||
}
|
||||
@@ -1,324 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Hyperlight.Internal;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hyperlight;
|
||||
|
||||
/// <summary>
|
||||
/// An <see cref="AIContextProvider"/> that enables CodeAct execution through a
|
||||
/// Hyperlight-backed sandbox.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The provider injects an <c>execute_code</c> tool into the model-facing tool
|
||||
/// surface and contributes a short CodeAct guidance block through
|
||||
/// <see cref="AIContext.Instructions"/>. Guest code executed via
|
||||
/// <c>execute_code</c> runs in an isolated Hyperlight sandbox with
|
||||
/// snapshot/restore for clean state per invocation.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// If no CodeAct-managed tools are configured the provider behaves as a code
|
||||
/// interpreter. If one or more tools are configured they are exposed to guest
|
||||
/// code via <c>call_tool(...)</c> but not to the model directly.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Only a single <see cref="HyperlightCodeActProvider"/> may be attached to a
|
||||
/// given agent. <see cref="StateKeys"/> returns a fixed value so
|
||||
/// <c>ChatClientAgent</c>'s state-key uniqueness validation rejects duplicate
|
||||
/// registrations.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Security considerations:</strong> guest code runs with only the
|
||||
/// capabilities explicitly configured on this provider (file mounts, allowed
|
||||
/// outbound domains). Callers should configure the smallest capability set
|
||||
/// sufficient for the task and consider using
|
||||
/// <see cref="CodeActApprovalMode.AlwaysRequire"/> when guest code can reach
|
||||
/// sensitive resources.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class HyperlightCodeActProvider : AIContextProvider, IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Fixed state key used to enforce a single provider-per-agent.
|
||||
/// </summary>
|
||||
internal const string FixedStateKey = "HyperlightCodeActProvider";
|
||||
|
||||
private static readonly IReadOnlyList<string> s_stateKeys = [FixedStateKey];
|
||||
|
||||
private readonly object _gate = new();
|
||||
private readonly HyperlightCodeActProviderOptions _options;
|
||||
private readonly SandboxExecutor _executor;
|
||||
|
||||
private readonly Dictionary<string, AIFunction> _tools = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, FileMount> _fileMounts = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, AllowedDomain> _allowedDomains = new(StringComparer.Ordinal);
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="HyperlightCodeActProvider"/> class.
|
||||
/// </summary>
|
||||
/// <param name="options">
|
||||
/// Optional configuration options for the provider. When <see langword="null"/> the provider
|
||||
/// uses the defaults of <see cref="HyperlightCodeActProviderOptions"/> (the
|
||||
/// <see cref="HyperlightSandbox.Api.SandboxBackend.JavaScript"/> backend with no tools, mounts, or allow-list entries).
|
||||
/// Use <see cref="HyperlightCodeActProviderOptions.CreateForWasm(string)"/> to target a Wasm
|
||||
/// guest module instead.
|
||||
/// </param>
|
||||
public HyperlightCodeActProvider(HyperlightCodeActProviderOptions? options = null)
|
||||
{
|
||||
this._options = options ?? new HyperlightCodeActProviderOptions();
|
||||
this._executor = new SandboxExecutor(this._options);
|
||||
|
||||
if (this._options.Tools is not null)
|
||||
{
|
||||
foreach (var tool in this._options.Tools.Where(t => t is not null))
|
||||
{
|
||||
this._tools[tool.Name] = tool;
|
||||
}
|
||||
}
|
||||
|
||||
if (this._options.FileMounts is not null)
|
||||
{
|
||||
foreach (var mount in this._options.FileMounts.Where(m => m is not null))
|
||||
{
|
||||
this._fileMounts[mount.MountPath] = mount;
|
||||
}
|
||||
}
|
||||
|
||||
if (this._options.AllowedDomains is not null)
|
||||
{
|
||||
foreach (var domain in this._options.AllowedDomains.Where(d => d is not null))
|
||||
{
|
||||
this._allowedDomains[domain.Target] = domain;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override IReadOnlyList<string> StateKeys => s_stateKeys;
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Tool registry
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
/// <summary>Adds tools to the provider-owned CodeAct tool registry. Tools with a duplicate name replace the existing registration.</summary>
|
||||
/// <param name="tools">The tools to add.</param>
|
||||
public void AddTools(params AIFunction[] tools)
|
||||
{
|
||||
_ = Throw.IfNull(tools);
|
||||
lock (this._gate)
|
||||
{
|
||||
this.ThrowIfDisposed();
|
||||
foreach (var tool in tools.Where(t => t is not null))
|
||||
{
|
||||
this._tools[tool.Name] = tool;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Returns the current CodeAct-managed tools.</summary>
|
||||
public IReadOnlyList<AIFunction> GetTools()
|
||||
{
|
||||
lock (this._gate)
|
||||
{
|
||||
return this._tools.Values.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Removes tools by name from the CodeAct tool registry.</summary>
|
||||
/// <param name="names">The names of the tools to remove.</param>
|
||||
public void RemoveTools(params string[] names)
|
||||
{
|
||||
_ = Throw.IfNull(names);
|
||||
lock (this._gate)
|
||||
{
|
||||
foreach (var name in names.Where(n => n is not null))
|
||||
{
|
||||
_ = this._tools.Remove(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Removes all CodeAct-managed tools.</summary>
|
||||
public void ClearTools()
|
||||
{
|
||||
lock (this._gate)
|
||||
{
|
||||
this._tools.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// File mounts
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
/// <summary>Adds file mount configurations. Mounts with a duplicate mount path replace the existing entry.</summary>
|
||||
/// <param name="mounts">The mount configurations to add.</param>
|
||||
public void AddFileMounts(params FileMount[] mounts)
|
||||
{
|
||||
_ = Throw.IfNull(mounts);
|
||||
lock (this._gate)
|
||||
{
|
||||
foreach (var mount in mounts.Where(m => m is not null))
|
||||
{
|
||||
this._fileMounts[mount.MountPath] = mount;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Returns the current file mount configurations.</summary>
|
||||
public IReadOnlyList<FileMount> GetFileMounts()
|
||||
{
|
||||
lock (this._gate)
|
||||
{
|
||||
return this._fileMounts.Values.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Removes file mounts by sandbox mount path.</summary>
|
||||
/// <param name="mountPaths">The mount paths to remove.</param>
|
||||
public void RemoveFileMounts(params string[] mountPaths)
|
||||
{
|
||||
_ = Throw.IfNull(mountPaths);
|
||||
lock (this._gate)
|
||||
{
|
||||
foreach (var path in mountPaths.Where(p => p is not null))
|
||||
{
|
||||
_ = this._fileMounts.Remove(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Removes all file mount configurations.</summary>
|
||||
public void ClearFileMounts()
|
||||
{
|
||||
lock (this._gate)
|
||||
{
|
||||
this._fileMounts.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Network allow-list
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
/// <summary>Adds outbound network allow-list entries. Entries with a duplicate target replace the existing entry.</summary>
|
||||
/// <param name="domains">The allow-list entries to add.</param>
|
||||
public void AddAllowedDomains(params AllowedDomain[] domains)
|
||||
{
|
||||
_ = Throw.IfNull(domains);
|
||||
lock (this._gate)
|
||||
{
|
||||
foreach (var domain in domains.Where(d => d is not null))
|
||||
{
|
||||
this._allowedDomains[domain.Target] = domain;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Returns the current outbound allow-list entries.</summary>
|
||||
public IReadOnlyList<AllowedDomain> GetAllowedDomains()
|
||||
{
|
||||
lock (this._gate)
|
||||
{
|
||||
return this._allowedDomains.Values.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Removes allow-list entries by target.</summary>
|
||||
/// <param name="targets">The targets to remove.</param>
|
||||
public void RemoveAllowedDomains(params string[] targets)
|
||||
{
|
||||
_ = Throw.IfNull(targets);
|
||||
lock (this._gate)
|
||||
{
|
||||
foreach (var target in targets.Where(t => t is not null))
|
||||
{
|
||||
_ = this._allowedDomains.Remove(target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Removes all outbound allow-list entries.</summary>
|
||||
public void ClearAllowedDomains()
|
||||
{
|
||||
lock (this._gate)
|
||||
{
|
||||
this._allowedDomains.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// AIContextProvider implementation
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override ValueTask<AIContext> ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_ = Throw.IfNull(context);
|
||||
|
||||
SandboxExecutor.RunSnapshot snapshot;
|
||||
lock (this._gate)
|
||||
{
|
||||
this.ThrowIfDisposed();
|
||||
snapshot = new SandboxExecutor.RunSnapshot(
|
||||
this._tools.Values.ToList(),
|
||||
this._fileMounts.Values.ToList(),
|
||||
this._allowedDomains.Values.ToList(),
|
||||
this._options.HostInputDirectory);
|
||||
}
|
||||
|
||||
var approvalRequired = ComputeApprovalRequired(this._options.ApprovalMode, snapshot.Tools);
|
||||
|
||||
var description = InstructionBuilder.BuildExecuteCodeDescription(
|
||||
snapshot.Tools,
|
||||
snapshot.FileMounts,
|
||||
snapshot.AllowedDomains,
|
||||
hasHostInputDirectory: !string.IsNullOrEmpty(snapshot.HostInputDirectory));
|
||||
|
||||
AIFunction executeCode = new ExecuteCodeFunction(this._executor, snapshot, description);
|
||||
if (approvalRequired)
|
||||
{
|
||||
executeCode = new ApprovalRequiredAIFunction(executeCode);
|
||||
}
|
||||
|
||||
var instructions = InstructionBuilder.BuildContextInstructions(toolsVisibleToModel: false);
|
||||
|
||||
var result = new AIContext
|
||||
{
|
||||
Instructions = instructions,
|
||||
Tools = [executeCode],
|
||||
};
|
||||
|
||||
return new ValueTask<AIContext>(result);
|
||||
}
|
||||
|
||||
internal static bool ComputeApprovalRequired(CodeActApprovalMode mode, IReadOnlyList<AIFunction> tools) =>
|
||||
mode == CodeActApprovalMode.AlwaysRequire
|
||||
|| tools.Any(t => t.GetService<ApprovalRequiredAIFunction>() is not null);
|
||||
|
||||
private void ThrowIfDisposed() => ObjectDisposedException.ThrowIf(this._disposed, this);
|
||||
|
||||
/// <summary>Releases the underlying sandbox and associated native resources.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
lock (this._gate)
|
||||
{
|
||||
if (this._disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
this._disposed = true;
|
||||
}
|
||||
|
||||
this._executor.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using HyperlightSandbox.Api;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hyperlight;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for <see cref="HyperlightCodeActProvider"/> and
|
||||
/// <see cref="HyperlightExecuteCodeFunction"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Use the <see cref="CreateForWasm(string)"/> and <see cref="CreateForJavaScript()"/>
|
||||
/// factory methods to construct an instance with the desired sandbox backend.
|
||||
/// The parameterless constructor is equivalent to <see cref="CreateForJavaScript()"/>.
|
||||
/// </remarks>
|
||||
public sealed class HyperlightCodeActProviderOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance configured for the JavaScript backend.
|
||||
/// Equivalent to <see cref="CreateForJavaScript()"/>.
|
||||
/// </summary>
|
||||
public HyperlightCodeActProviderOptions()
|
||||
: this(SandboxBackend.JavaScript, modulePath: null)
|
||||
{
|
||||
}
|
||||
|
||||
private HyperlightCodeActProviderOptions(SandboxBackend backend, string? modulePath)
|
||||
{
|
||||
this.Backend = backend;
|
||||
this.ModulePath = modulePath;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates options targeting the <see cref="SandboxBackend.Wasm"/> backend.
|
||||
/// </summary>
|
||||
/// <param name="modulePath">Path to the guest module (<c>.wasm</c> or <c>.aot</c> file).</param>
|
||||
public static HyperlightCodeActProviderOptions CreateForWasm(string modulePath)
|
||||
=> new(SandboxBackend.Wasm, Throw.IfNullOrWhitespace(modulePath));
|
||||
|
||||
/// <summary>
|
||||
/// Creates options targeting the <see cref="SandboxBackend.JavaScript"/> backend.
|
||||
/// </summary>
|
||||
public static HyperlightCodeActProviderOptions CreateForJavaScript()
|
||||
=> new(SandboxBackend.JavaScript, modulePath: null);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the Hyperlight sandbox backend this options instance is configured for.
|
||||
/// </summary>
|
||||
public SandboxBackend Backend { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the path to the guest module. Set when the options were created via
|
||||
/// <see cref="CreateForWasm(string)"/>; <see langword="null"/> otherwise.
|
||||
/// </summary>
|
||||
public string? ModulePath { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the guest heap size. Accepts human-readable strings such as
|
||||
/// <c>"50Mi"</c> or <c>"2Gi"</c>. When <see langword="null"/> the backend default is used.
|
||||
/// </summary>
|
||||
public string? HeapSize { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the guest stack size. Accepts human-readable strings such as
|
||||
/// <c>"35Mi"</c>. When <see langword="null"/> the backend default is used.
|
||||
/// </summary>
|
||||
public string? StackSize { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the initial set of provider-owned CodeAct tools made available
|
||||
/// inside the sandbox via <c>call_tool(...)</c>.
|
||||
/// </summary>
|
||||
public IEnumerable<AIFunction>? Tools { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the default approval mode for <c>execute_code</c>.
|
||||
/// Defaults to <see cref="CodeActApprovalMode.NeverRequire"/>.
|
||||
/// </summary>
|
||||
public CodeActApprovalMode ApprovalMode { get; set; } = CodeActApprovalMode.NeverRequire;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets an optional host directory exposed to the sandbox as its
|
||||
/// <c>/input</c> directory.
|
||||
/// </summary>
|
||||
public string? HostInputDirectory { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the initial set of file mount configurations.
|
||||
/// </summary>
|
||||
public IEnumerable<FileMount>? FileMounts { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the initial outbound network allow-list entries.
|
||||
/// </summary>
|
||||
public IEnumerable<AllowedDomain>? AllowedDomains { get; set; }
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Hyperlight.Internal;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hyperlight;
|
||||
|
||||
/// <summary>
|
||||
/// Standalone <c>execute_code</c> <see cref="AIFunction"/> backed by a
|
||||
/// Hyperlight sandbox. Use this for manual/static wiring when an
|
||||
/// <see cref="AIContextProvider"/> lifecycle is not needed — for example
|
||||
/// when the tool registry and capability configuration are fixed for the
|
||||
/// lifetime of the agent.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Unlike <see cref="HyperlightCodeActProvider"/>, this type does not hook
|
||||
/// into the <see cref="AIContextProvider"/> pipeline. It captures a single
|
||||
/// snapshot of the provided <see cref="HyperlightCodeActProviderOptions"/>
|
||||
/// at construction time and reuses it for the lifetime of the instance.
|
||||
/// The instance can be passed directly anywhere an <see cref="AIFunction"/>
|
||||
/// is accepted; when the configuration requires approval (per
|
||||
/// <see cref="HyperlightCodeActProviderOptions.ApprovalMode"/> or because a
|
||||
/// configured tool is itself an <see cref="ApprovalRequiredAIFunction"/>),
|
||||
/// the instance surfaces an <see cref="ApprovalRequiredAIFunction"/> via
|
||||
/// <see cref="AITool.GetService(Type, object?)"/>, which is how the rest of
|
||||
/// the framework discovers approval requirements.
|
||||
/// </remarks>
|
||||
public sealed class HyperlightExecuteCodeFunction : AIFunction, IDisposable
|
||||
{
|
||||
private const string ExecuteCodeName = "execute_code";
|
||||
|
||||
private static readonly JsonElement s_schema = JsonDocument.Parse(
|
||||
"""
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "string",
|
||||
"description": "Code to execute using the provider's configured backend/runtime behavior."
|
||||
}
|
||||
},
|
||||
"required": ["code"]
|
||||
}
|
||||
""").RootElement;
|
||||
|
||||
private readonly SandboxExecutor _executor;
|
||||
private readonly SandboxExecutor.RunSnapshot _snapshot;
|
||||
private readonly string _description;
|
||||
private readonly bool _approvalRequired;
|
||||
private ApprovalRequiredAIFunction? _approvalProxy;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="HyperlightExecuteCodeFunction"/> class.
|
||||
/// </summary>
|
||||
/// <param name="options">
|
||||
/// Optional configuration options. When <see langword="null"/> the defaults of
|
||||
/// <see cref="HyperlightCodeActProviderOptions"/> are used.
|
||||
/// </param>
|
||||
public HyperlightExecuteCodeFunction(HyperlightCodeActProviderOptions? options = null)
|
||||
{
|
||||
var effective = options ?? new HyperlightCodeActProviderOptions();
|
||||
this._executor = new SandboxExecutor(effective);
|
||||
|
||||
var tools = (effective.Tools?.Where(t => t is not null) ?? []).ToList();
|
||||
var fileMounts = (effective.FileMounts?.Where(m => m is not null) ?? []).ToList();
|
||||
var allowedDomains = (effective.AllowedDomains?.Where(d => d is not null) ?? []).ToList();
|
||||
|
||||
this._snapshot = new SandboxExecutor.RunSnapshot(tools, fileMounts, allowedDomains, effective.HostInputDirectory);
|
||||
|
||||
this._description = InstructionBuilder.BuildExecuteCodeDescription(
|
||||
this._snapshot.Tools,
|
||||
this._snapshot.FileMounts,
|
||||
this._snapshot.AllowedDomains,
|
||||
hasHostInputDirectory: !string.IsNullOrEmpty(this._snapshot.HostInputDirectory));
|
||||
|
||||
this._approvalRequired = HyperlightCodeActProvider.ComputeApprovalRequired(effective.ApprovalMode, this._snapshot.Tools);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Name => ExecuteCodeName;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Description => this._description;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override JsonElement JsonSchema => s_schema;
|
||||
|
||||
/// <summary>
|
||||
/// Builds a CodeAct instruction string describing the available tools and capabilities.
|
||||
/// </summary>
|
||||
/// <param name="toolsVisibleToModel">
|
||||
/// When <see langword="false"/>, the instructions assume tools are only accessible
|
||||
/// through CodeAct (via <c>call_tool</c>). When <see langword="true"/>, the instructions
|
||||
/// are abbreviated for cases where the same tools are already visible to the model as
|
||||
/// direct agent tools.
|
||||
/// </param>
|
||||
public string BuildInstructions(bool toolsVisibleToModel = false)
|
||||
{
|
||||
this.ThrowIfDisposed();
|
||||
return InstructionBuilder.BuildContextInstructions(toolsVisibleToModel);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override object? GetService(Type serviceType, object? serviceKey = null)
|
||||
{
|
||||
if (serviceKey is null
|
||||
&& this._approvalRequired
|
||||
&& serviceType == typeof(ApprovalRequiredAIFunction))
|
||||
{
|
||||
return this._approvalProxy ??= new ApprovalRequiredAIFunction(this);
|
||||
}
|
||||
|
||||
return base.GetService(serviceType, serviceKey);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async ValueTask<object?> InvokeCoreAsync(
|
||||
AIFunctionArguments arguments,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
this.ThrowIfDisposed();
|
||||
|
||||
if (arguments is null || !arguments.TryGetValue("code", out var codeObj) || codeObj is null)
|
||||
{
|
||||
throw new ArgumentException("Missing required parameter 'code'.", nameof(arguments));
|
||||
}
|
||||
|
||||
var code = codeObj switch
|
||||
{
|
||||
string s => s,
|
||||
JsonElement { ValueKind: JsonValueKind.String } el => el.GetString() ?? string.Empty,
|
||||
_ => codeObj.ToString() ?? string.Empty,
|
||||
};
|
||||
|
||||
if (string.IsNullOrWhiteSpace(code))
|
||||
{
|
||||
throw new ArgumentException("Parameter 'code' must not be empty.", nameof(arguments));
|
||||
}
|
||||
|
||||
return await this._executor.ExecuteAsync(this._snapshot, code, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private void ThrowIfDisposed() => ObjectDisposedException.ThrowIf(this._disposed, this);
|
||||
|
||||
/// <summary>Releases the underlying sandbox and associated native resources.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (this._disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
this._disposed = true;
|
||||
this._executor.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hyperlight.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Run-scoped <see cref="AIFunction"/> that exposes <c>execute_code</c>
|
||||
/// to the model. The function closes over an immutable
|
||||
/// <see cref="SandboxExecutor.RunSnapshot"/> captured at the start of the
|
||||
/// agent invocation, so subsequent CRUD mutations on the provider do not
|
||||
/// affect an in-flight run.
|
||||
/// </summary>
|
||||
internal sealed class ExecuteCodeFunction : AIFunction
|
||||
{
|
||||
private const string ExecuteCodeName = "execute_code";
|
||||
|
||||
private static readonly JsonElement s_schema = JsonDocument.Parse(
|
||||
"""
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "string",
|
||||
"description": "Code to execute using the provider's configured backend/runtime behavior."
|
||||
}
|
||||
},
|
||||
"required": ["code"]
|
||||
}
|
||||
""").RootElement;
|
||||
|
||||
private readonly SandboxExecutor _executor;
|
||||
private readonly SandboxExecutor.RunSnapshot _snapshot;
|
||||
private readonly string _description;
|
||||
|
||||
public ExecuteCodeFunction(
|
||||
SandboxExecutor executor,
|
||||
SandboxExecutor.RunSnapshot snapshot,
|
||||
string description)
|
||||
{
|
||||
this._executor = executor;
|
||||
this._snapshot = snapshot;
|
||||
this._description = description;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Name => ExecuteCodeName;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Description => this._description;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override JsonElement JsonSchema => s_schema;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async ValueTask<object?> InvokeCoreAsync(
|
||||
AIFunctionArguments arguments,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (arguments is null || !arguments.TryGetValue("code", out var codeObj) || codeObj is null)
|
||||
{
|
||||
throw new ArgumentException("Missing required parameter 'code'.", nameof(arguments));
|
||||
}
|
||||
|
||||
var code = codeObj switch
|
||||
{
|
||||
string s => s,
|
||||
JsonElement { ValueKind: JsonValueKind.String } el => el.GetString() ?? string.Empty,
|
||||
_ => codeObj.ToString() ?? string.Empty,
|
||||
};
|
||||
|
||||
if (string.IsNullOrWhiteSpace(code))
|
||||
{
|
||||
throw new ArgumentException("Parameter 'code' must not be empty.", nameof(arguments));
|
||||
}
|
||||
|
||||
return await this._executor.ExecuteAsync(this._snapshot, code, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hyperlight.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Source-generated JSON context for the well-known envelope shapes the Hyperlight
|
||||
/// integration serializes (the execute_code result payload and the tool error payload).
|
||||
/// User-supplied tool results are serialized via AIJsonUtilities.DefaultOptions instead
|
||||
/// because their types cannot be statically known at compile time.
|
||||
/// </summary>
|
||||
[JsonSourceGenerationOptions(JsonSerializerDefaults.General)]
|
||||
[JsonSerializable(typeof(HyperlightExecutionResult))]
|
||||
[JsonSerializable(typeof(HyperlightToolError))]
|
||||
internal sealed partial class HyperlightJsonContext : JsonSerializerContext;
|
||||
|
||||
internal sealed record HyperlightExecutionResult(
|
||||
[property: JsonPropertyName("stdout")] string Stdout,
|
||||
[property: JsonPropertyName("stderr")] string Stderr,
|
||||
[property: JsonPropertyName("exit_code")] int ExitCode,
|
||||
[property: JsonPropertyName("success")] bool Success);
|
||||
|
||||
internal sealed record HyperlightToolError(
|
||||
[property: JsonPropertyName("error")] string Error);
|
||||
@@ -1,117 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hyperlight.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the CodeAct guidance strings returned through
|
||||
/// <see cref="AIContext.Instructions"/> and the <c>execute_code</c>
|
||||
/// function description.
|
||||
/// </summary>
|
||||
internal static class InstructionBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds the short CodeAct guidance block that is merged into the
|
||||
/// agent's instructions for the current invocation.
|
||||
/// </summary>
|
||||
public static string BuildContextInstructions(bool toolsVisibleToModel)
|
||||
{
|
||||
if (toolsVisibleToModel)
|
||||
{
|
||||
return
|
||||
"You can execute code in a secure sandbox by calling the `execute_code` tool. "
|
||||
+ "Use it for calculations, data analysis, and anything that benefits from running code. "
|
||||
+ "State does not persist between calls; pass any required values in the code you execute.";
|
||||
}
|
||||
|
||||
return
|
||||
"You can execute code in a secure sandbox by calling the `execute_code` tool. "
|
||||
+ "Any tools listed in the tool's description are only accessible from within the sandbox "
|
||||
+ "via `call_tool(\"<name>\", ...)` — they cannot be invoked directly. "
|
||||
+ "State does not persist between calls; pass any required values in the code you execute.";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the detailed description attached to the run-scoped
|
||||
/// <c>execute_code</c> <see cref="AIFunction"/>. This includes the
|
||||
/// available <c>call_tool</c> signatures and a capability summary.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Host-side filesystem paths are intentionally omitted from the
|
||||
/// description — only sandbox-visible mount paths are exposed to the
|
||||
/// model.
|
||||
/// </remarks>
|
||||
public static string BuildExecuteCodeDescription(
|
||||
IReadOnlyList<AIFunction> tools,
|
||||
IReadOnlyList<FileMount> fileMounts,
|
||||
IReadOnlyList<AllowedDomain> allowedDomains,
|
||||
bool hasHostInputDirectory)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append("Executes code in a secure Hyperlight sandbox. ");
|
||||
sb.Append("Pass the full source to execute via the `code` parameter. ");
|
||||
sb.Append("Returns a JSON string with `stdout`, `stderr`, `exit_code`, and `success` fields.");
|
||||
|
||||
if (tools.Count > 0)
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("The following host tools are available inside the sandbox via `call_tool(\"<name>\", **kwargs)`:");
|
||||
foreach (var tool in tools)
|
||||
{
|
||||
sb.Append("- `");
|
||||
sb.Append(tool.Name);
|
||||
sb.Append('`');
|
||||
if (!string.IsNullOrWhiteSpace(tool.Description))
|
||||
{
|
||||
sb.Append(": ");
|
||||
sb.Append(tool.Description);
|
||||
}
|
||||
|
||||
sb.AppendLine();
|
||||
}
|
||||
}
|
||||
|
||||
if (hasHostInputDirectory || fileMounts.Count > 0)
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("Filesystem access:");
|
||||
if (hasHostInputDirectory)
|
||||
{
|
||||
sb.AppendLine("- Host input directory mounted read-only at `/input`.");
|
||||
}
|
||||
|
||||
foreach (var mount in fileMounts)
|
||||
{
|
||||
sb.Append("- `");
|
||||
sb.Append(mount.MountPath);
|
||||
sb.AppendLine("`");
|
||||
}
|
||||
}
|
||||
|
||||
if (allowedDomains.Count > 0)
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("Outbound network access is restricted to the following targets:");
|
||||
foreach (var domain in allowedDomains)
|
||||
{
|
||||
sb.Append("- `");
|
||||
sb.Append(domain.Target);
|
||||
sb.Append('`');
|
||||
if (domain.Methods is { Count: > 0 })
|
||||
{
|
||||
sb.Append(" [");
|
||||
sb.Append(string.Join(", ", domain.Methods));
|
||||
sb.Append(']');
|
||||
}
|
||||
|
||||
sb.AppendLine();
|
||||
}
|
||||
}
|
||||
|
||||
return sb.ToString().TrimEnd();
|
||||
}
|
||||
}
|
||||
@@ -1,243 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using HyperlightSandbox.Api;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hyperlight.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Captures a per-run snapshot of the provider state and owns the
|
||||
/// lifecycle of the underlying <see cref="Sandbox"/>. A single
|
||||
/// <see cref="SandboxExecutor"/> is shared across runs and serializes
|
||||
/// execution via snapshot/restore.
|
||||
/// </summary>
|
||||
internal sealed class SandboxExecutor : IDisposable
|
||||
{
|
||||
private readonly HyperlightCodeActProviderOptions _options;
|
||||
private readonly SemaphoreSlim _executionLock = new(1, 1);
|
||||
|
||||
private Sandbox? _sandbox;
|
||||
private SandboxSnapshot? _warmSnapshot;
|
||||
private string? _lastConfigFingerprint;
|
||||
private bool _disposed;
|
||||
|
||||
public SandboxExecutor(HyperlightCodeActProviderOptions options)
|
||||
{
|
||||
this._options = options;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Immutable snapshot of provider state at the start of a run.
|
||||
/// Used to build a run-scoped <c>execute_code</c> function that is
|
||||
/// independent of subsequent CRUD mutations.
|
||||
/// </summary>
|
||||
internal sealed class RunSnapshot
|
||||
{
|
||||
public RunSnapshot(
|
||||
IReadOnlyList<AIFunction> tools,
|
||||
IReadOnlyList<FileMount> fileMounts,
|
||||
IReadOnlyList<AllowedDomain> allowedDomains,
|
||||
string? hostInputDirectory)
|
||||
{
|
||||
this.Tools = tools;
|
||||
this.FileMounts = fileMounts;
|
||||
this.AllowedDomains = allowedDomains;
|
||||
this.HostInputDirectory = hostInputDirectory;
|
||||
this.ConfigFingerprint = ComputeFingerprint(tools, fileMounts, allowedDomains, hostInputDirectory);
|
||||
}
|
||||
|
||||
public IReadOnlyList<AIFunction> Tools { get; }
|
||||
|
||||
public IReadOnlyList<FileMount> FileMounts { get; }
|
||||
|
||||
public IReadOnlyList<AllowedDomain> AllowedDomains { get; }
|
||||
|
||||
public string? HostInputDirectory { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Stable fingerprint of the configuration that materially affects how
|
||||
/// the sandbox must be built. Used by <see cref="SandboxExecutor"/> to
|
||||
/// decide whether a previously-built sandbox can be reused or must be
|
||||
/// rebuilt because tools / mounts / allow-list entries have changed.
|
||||
/// </summary>
|
||||
public string ConfigFingerprint { get; }
|
||||
|
||||
internal static string ComputeFingerprint(
|
||||
IReadOnlyList<AIFunction> tools,
|
||||
IReadOnlyList<FileMount> fileMounts,
|
||||
IReadOnlyList<AllowedDomain> allowedDomains,
|
||||
string? hostInputDirectory)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.Append("tools=");
|
||||
foreach (var name in tools.Select(t => t.Name).OrderBy(n => n, StringComparer.Ordinal))
|
||||
{
|
||||
sb.Append(name).Append('|');
|
||||
}
|
||||
|
||||
sb.Append(";mounts=");
|
||||
foreach (var m in fileMounts
|
||||
.Select(m => m.MountPath + "->" + m.HostPath)
|
||||
.OrderBy(s => s, StringComparer.Ordinal))
|
||||
{
|
||||
sb.Append(m).Append('|');
|
||||
}
|
||||
|
||||
sb.Append(";allow=");
|
||||
foreach (var d in allowedDomains
|
||||
.Select(d => d.Target + "/" + (d.Methods is null ? "*" : string.Join(",", d.Methods)))
|
||||
.OrderBy(s => s, StringComparer.Ordinal))
|
||||
{
|
||||
sb.Append(d).Append('|');
|
||||
}
|
||||
|
||||
sb.Append(";input=").Append(hostInputDirectory ?? string.Empty);
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes <paramref name="code"/> inside the sandbox using the
|
||||
/// captured <paramref name="snapshot"/>. Builds (or rebuilds) the
|
||||
/// sandbox lazily when the snapshot's configuration fingerprint
|
||||
/// differs from the previously-used one.
|
||||
/// </summary>
|
||||
public async Task<string> ExecuteAsync(RunSnapshot snapshot, string code, CancellationToken cancellationToken)
|
||||
{
|
||||
await this._executionLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
this.EnsureInitialized(snapshot);
|
||||
|
||||
if (this._warmSnapshot is not null)
|
||||
{
|
||||
this._sandbox!.Restore(this._warmSnapshot);
|
||||
}
|
||||
|
||||
ExecutionResult result;
|
||||
try
|
||||
{
|
||||
result = this._sandbox!.Run(code);
|
||||
}
|
||||
#pragma warning disable CA1031 // Surface sandbox execution failures as structured JSON rather than propagating.
|
||||
catch (Exception ex)
|
||||
#pragma warning restore CA1031
|
||||
{
|
||||
return BuildErrorResult(ex.Message);
|
||||
}
|
||||
|
||||
return BuildResult(result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
this._executionLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureInitialized(RunSnapshot snapshot)
|
||||
{
|
||||
if (this._sandbox is not null && string.Equals(this._lastConfigFingerprint, snapshot.ConfigFingerprint, StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Configuration changed (or first run) — dispose the previous sandbox
|
||||
// so the new one picks up the new tool/mount/allow-list set.
|
||||
this._warmSnapshot?.Dispose();
|
||||
this._sandbox?.Dispose();
|
||||
this._warmSnapshot = null;
|
||||
this._sandbox = null;
|
||||
|
||||
this.BuildAndWarmUp(snapshot);
|
||||
}
|
||||
|
||||
private void BuildAndWarmUp(RunSnapshot snapshot)
|
||||
{
|
||||
var builder = new SandboxBuilder()
|
||||
.WithBackend(this._options.Backend);
|
||||
|
||||
if (!string.IsNullOrEmpty(this._options.ModulePath))
|
||||
{
|
||||
builder = builder.WithModulePath(this._options.ModulePath!);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(this._options.HeapSize))
|
||||
{
|
||||
builder = builder.WithHeapSize(this._options.HeapSize!);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(this._options.StackSize))
|
||||
{
|
||||
builder = builder.WithStackSize(this._options.StackSize!);
|
||||
}
|
||||
|
||||
var hostInput = snapshot.HostInputDirectory;
|
||||
if (!string.IsNullOrEmpty(hostInput))
|
||||
{
|
||||
builder = builder.WithInputDir(hostInput!);
|
||||
}
|
||||
|
||||
// The Hyperlight .NET SDK currently exposes only a single input + output + temp-output
|
||||
// surface; per-mount configuration (`FileMount`) is captured in the execute_code
|
||||
// description so the model is aware of the layout, and will be wired to a richer
|
||||
// mount API once the SDK exposes one.
|
||||
if (snapshot.FileMounts.Count > 0 || !string.IsNullOrEmpty(hostInput))
|
||||
{
|
||||
builder = builder.WithTempOutput();
|
||||
}
|
||||
|
||||
var sandbox = builder.Build();
|
||||
|
||||
// Tools must be registered before the first Run() call.
|
||||
ToolBridge.RegisterAll(sandbox, snapshot.Tools);
|
||||
|
||||
foreach (var allowedDomain in snapshot.AllowedDomains)
|
||||
{
|
||||
sandbox.AllowDomain(allowedDomain.Target, allowedDomain.Methods);
|
||||
}
|
||||
|
||||
// Warm-up run to trigger lazy initialization, then capture a clean snapshot
|
||||
// that is restored before every subsequent user invocation.
|
||||
// Backend-specific no-op used to trigger lazy guest runtime initialization
|
||||
// before the warm snapshot is captured. Matches the values used by the
|
||||
// upstream HyperlightSandbox.Extensions.AI CodeExecutionTool reference.
|
||||
_ = sandbox.Run(this._options.Backend == SandboxBackend.JavaScript ? "void 0;" : "None");
|
||||
this._warmSnapshot = sandbox.Snapshot();
|
||||
this._sandbox = sandbox;
|
||||
this._lastConfigFingerprint = snapshot.ConfigFingerprint;
|
||||
}
|
||||
|
||||
private static string BuildResult(ExecutionResult result) =>
|
||||
JsonSerializer.Serialize(
|
||||
new HyperlightExecutionResult(
|
||||
result.Stdout ?? string.Empty,
|
||||
result.Stderr ?? string.Empty,
|
||||
result.ExitCode,
|
||||
result.ExitCode == 0),
|
||||
HyperlightJsonContext.Default.HyperlightExecutionResult);
|
||||
|
||||
private static string BuildErrorResult(string message) =>
|
||||
JsonSerializer.Serialize(
|
||||
new HyperlightExecutionResult(string.Empty, message, -1, false),
|
||||
HyperlightJsonContext.Default.HyperlightExecutionResult);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (this._disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
this._disposed = true;
|
||||
this._warmSnapshot?.Dispose();
|
||||
this._sandbox?.Dispose();
|
||||
this._executionLock.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Threading.Tasks;
|
||||
using HyperlightSandbox.Api;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hyperlight.Internal;
|
||||
|
||||
/// <summary>
|
||||
/// Bridges an <see cref="AIFunction"/> to the
|
||||
/// <see cref="Sandbox.RegisterToolAsync(string, Func{string, Task{string}})"/>
|
||||
/// overload so the guest can invoke .NET tools via <c>call_tool(...)</c>.
|
||||
/// </summary>
|
||||
internal static class ToolBridge
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers every <paramref name="tools"/> entry against the provided
|
||||
/// <paramref name="sandbox"/> as a raw JSON-in / JSON-out async tool.
|
||||
/// </summary>
|
||||
public static void RegisterAll(Sandbox sandbox, IReadOnlyList<AIFunction> tools)
|
||||
{
|
||||
foreach (var tool in tools)
|
||||
{
|
||||
RegisterOne(sandbox, tool);
|
||||
}
|
||||
}
|
||||
|
||||
private static void RegisterOne(Sandbox sandbox, AIFunction tool)
|
||||
=> sandbox.RegisterToolAsync(
|
||||
tool.Name,
|
||||
async (string argsJson) => await InvokeAsync(tool, argsJson).ConfigureAwait(false));
|
||||
|
||||
internal static async Task<string> InvokeAsync(AIFunction tool, string argsJson)
|
||||
{
|
||||
try
|
||||
{
|
||||
var arguments = ParseArguments(argsJson);
|
||||
var result = await tool.InvokeAsync(new AIFunctionArguments(arguments)).ConfigureAwait(false);
|
||||
return SerializeResult(result);
|
||||
}
|
||||
#pragma warning disable CA1031 // Catch all: we must surface every failure as a JSON error to the guest rather than crash the FFI boundary.
|
||||
catch (Exception ex)
|
||||
#pragma warning restore CA1031
|
||||
{
|
||||
return JsonSerializer.Serialize(new HyperlightToolError(ex.Message), HyperlightJsonContext.Default.HyperlightToolError);
|
||||
}
|
||||
}
|
||||
|
||||
internal static IDictionary<string, object?> ParseArguments(string argsJson)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(argsJson))
|
||||
{
|
||||
return new Dictionary<string, object?>(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
// Use JsonNode.Parse instead of JsonSerializer.Deserialize<Dictionary<...>>
|
||||
// so the bridge stays NativeAOT-compatible (the typed Deserialize overload
|
||||
// requires reflection-based metadata for object-typed values).
|
||||
var node = JsonNode.Parse(argsJson);
|
||||
if (node is not JsonObject obj)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"Tool arguments must be a JSON object.",
|
||||
nameof(argsJson));
|
||||
}
|
||||
|
||||
var result = new Dictionary<string, object?>(StringComparer.Ordinal);
|
||||
foreach (var kvp in obj)
|
||||
{
|
||||
result[kvp.Key] = kvp.Value;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string SerializeResult(object? result)
|
||||
{
|
||||
if (result is null)
|
||||
{
|
||||
return "null";
|
||||
}
|
||||
|
||||
// Tool results are arbitrary user types — defer to AIJsonUtilities so that
|
||||
// the same trim/AOT-friendly serializer chain used elsewhere in the framework
|
||||
// is applied here. The inputs are produced by user-supplied AIFunctions and
|
||||
// therefore cannot be modeled in our own JsonSerializerContext.
|
||||
var typeInfo = AIJsonUtilities.DefaultOptions.GetTypeInfo(result.GetType());
|
||||
return JsonSerializer.Serialize(result, typeInfo);
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<VersionSuffix>preview</VersionSuffix>
|
||||
<TargetFrameworks>net10.0;net9.0;net8.0</TargetFrameworks>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<InjectSharedThrow>true</InjectSharedThrow>
|
||||
</PropertyGroup>
|
||||
|
||||
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Hyperlight.HyperlightSandbox.Api" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI.Abstractions\Microsoft.Agents.AI.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<!-- NuGet Package Settings -->
|
||||
<Title>Microsoft Agent Framework - Hyperlight CodeAct integration</Title>
|
||||
<Description>Provides Hyperlight-backed CodeAct (sandboxed code execution) integration for Microsoft Agent Framework.</Description>
|
||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="README.md" Pack="true" PackagePath="/" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="Microsoft.Agents.AI.Hyperlight.UnitTests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,41 +0,0 @@
|
||||
# Microsoft.Agents.AI.Hyperlight
|
||||
|
||||
First-class [CodeAct](../../../docs/decisions/0024-codeact-integration.md)
|
||||
support for the Microsoft Agent Framework, backed by the
|
||||
[Hyperlight](https://github.com/hyperlight-dev/hyperlight) VM-isolated sandbox.
|
||||
|
||||
The package exposes two entry points:
|
||||
|
||||
* **`HyperlightCodeActProvider`** — an `AIContextProvider` that injects an
|
||||
`execute_code` tool and CodeAct guidance into every agent invocation. Only
|
||||
one `HyperlightCodeActProvider` may be attached to a given agent; it
|
||||
enforces this through a fixed `StateKeys` value so `ChatClientAgent`'s
|
||||
state-key uniqueness validation rejects duplicate registrations.
|
||||
* **`HyperlightExecuteCodeFunction`** — a standalone `AIFunction` for
|
||||
static/manual wiring when the sandbox configuration is fixed for the
|
||||
agent's lifetime.
|
||||
|
||||
Both surfaces support:
|
||||
|
||||
* Provider-owned tools exposed inside the sandbox via `call_tool(...)`
|
||||
(multiple allowed).
|
||||
* Opt-in filesystem mounts and outbound network allow-list.
|
||||
* `CodeActApprovalMode` control: `NeverRequire` (default; approval propagates
|
||||
from tools wrapped in `ApprovalRequiredAIFunction`) and `AlwaysRequire`.
|
||||
* Snapshot/restore per run so the guest starts from a known clean state
|
||||
every invocation.
|
||||
|
||||
## Requirements
|
||||
|
||||
* The `Hyperlight.HyperlightSandbox.Api` NuGet package, published from the
|
||||
`src/sdk/dotnet` SDK in [hyperlight-dev/hyperlight-sandbox](https://github.com/hyperlight-dev/hyperlight-sandbox)
|
||||
(the .NET API was added in [PR #46](https://github.com/hyperlight-dev/hyperlight-sandbox/pull/46),
|
||||
now merged). Until the package is published to nuget.org the project
|
||||
restore will fail; this project is intentionally `IsPackable=false` in
|
||||
the meantime.
|
||||
* A Hyperlight Python guest module when using `SandboxBackend.Wasm`.
|
||||
|
||||
## Status
|
||||
|
||||
Preview. API may change until the underlying Hyperlight SDK reaches a stable
|
||||
release.
|
||||
@@ -56,13 +56,6 @@ public static class DeclarativeWorkflowBuilder
|
||||
/// <param name="options">Configuration options for workflow execution.</param>
|
||||
/// <param name="inputTransform">An optional function to transform the input message into a <see cref="ChatMessage"/>.</param>
|
||||
/// <returns>The <see cref="Workflow"/> that corresponds with the YAML object model.</returns>
|
||||
/// <remarks>
|
||||
/// The returned workflow's root executor accepts <typeparamref name="TInput"/>,
|
||||
/// <see cref="ChatMessage"/>, <see cref="System.Collections.Generic.IEnumerable{T}"/> of
|
||||
/// <see cref="ChatMessage"/>, <see cref="string"/>, and <see cref="TurnToken"/>. This
|
||||
/// makes the workflow usable both for direct invocation and for hosting via
|
||||
/// <see cref="WorkflowHostingExtensions.AsAIAgent(Workflow, string?, string?, string?, IWorkflowExecutionEnvironment?, bool, bool)"/>.
|
||||
/// </remarks>
|
||||
public static Workflow Build<TInput>(
|
||||
TextReader yamlReader,
|
||||
DeclarativeWorkflowOptions options,
|
||||
|
||||
+1
-45
@@ -1,6 +1,5 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
@@ -9,7 +8,7 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.Events;
|
||||
/// <summary>
|
||||
/// Represents a request for external input.
|
||||
/// </summary>
|
||||
public sealed class ExternalInputRequest : IExternalRequestEnvelope
|
||||
public sealed class ExternalInputRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// The source message that triggered the request for external input.
|
||||
@@ -31,47 +30,4 @@ public sealed class ExternalInputRequest : IExternalRequestEnvelope
|
||||
{
|
||||
this.AgentResponse = new AgentResponse(new ChatMessage(ChatRole.User, text));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Prefers <see cref="ToolApprovalRequestContent"/> (when the workflow declared
|
||||
/// <c>requireApproval: true</c>) over <see cref="FunctionCallContent"/> so that
|
||||
/// hosts which speak the approval protocol see the approval-bearing content.
|
||||
/// </remarks>
|
||||
AIContent? IExternalRequestEnvelope.GetInnerRequestContent()
|
||||
{
|
||||
IList<ChatMessage>? messages = this.AgentResponse?.Messages;
|
||||
if (messages is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (ChatMessage message in messages)
|
||||
{
|
||||
foreach (AIContent content in message.Contents)
|
||||
{
|
||||
if (content is ToolApprovalRequestContent toolApprovalRequest)
|
||||
{
|
||||
return toolApprovalRequest;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (ChatMessage message in messages)
|
||||
{
|
||||
foreach (AIContent content in message.Contents)
|
||||
{
|
||||
if (content is FunctionCallContent functionCall)
|
||||
{
|
||||
return functionCall;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
object IExternalRequestEnvelope.CreateResponse(IList<ChatMessage> messages)
|
||||
=> new ExternalInputResponse(messages);
|
||||
}
|
||||
|
||||
+6
-139
@@ -1,7 +1,6 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.Extensions;
|
||||
@@ -14,24 +13,6 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.Interpreter;
|
||||
/// <summary>
|
||||
/// The root executor for a declarative workflow.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// In addition to the strongly-typed <typeparamref name="TInput"/> route inherited from
|
||||
/// <see cref="Executor{TInput}"/>, this executor also accepts <see cref="string"/>,
|
||||
/// <see cref="ChatMessage"/>, <see cref="IEnumerable{T}"/> of <see cref="ChatMessage"/>,
|
||||
/// <see cref="ChatMessage"/><c>[]</c>, and <see cref="TurnToken"/> so that the workflow
|
||||
/// satisfies <see cref="ChatProtocolExtensions.IsChatProtocol"/>. This makes the workflow
|
||||
/// usable both for direct <c>Run.SendMessageAsync(input)</c> invocations and for hosting
|
||||
/// via <see cref="WorkflowHostingExtensions.AsAIAgent(Workflow, string?, string?, string?, IWorkflowExecutionEnvironment?, bool, bool)"/>.
|
||||
///
|
||||
/// <para>
|
||||
/// Each non-<see cref="TurnToken"/> input drives the declarative graph forward
|
||||
/// immediately. The host's <see cref="TurnToken"/> arrives after the message batch and
|
||||
/// is treated as a no-op because the inbound message has already been processed.
|
||||
/// External responses (HITL function results) bypass the start executor entirely
|
||||
/// (they are routed via <c>WorkflowSession.SendResponseAsync</c> to request-info
|
||||
/// executors), so the start executor only ever sees a single inbound batch per turn.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class DeclarativeWorkflowExecutor<TInput>(
|
||||
string workflowId,
|
||||
DeclarativeWorkflowOptions options,
|
||||
@@ -45,143 +26,29 @@ internal sealed class DeclarativeWorkflowExecutor<TInput>(
|
||||
return default;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
[SendsMessage(typeof(ActionExecutorResult))]
|
||||
public override ValueTask HandleAsync(TInput message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ChatMessage input = inputTransform.Invoke(message);
|
||||
return this.AdvanceAsync(input, context, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
|
||||
{
|
||||
// Inherit the TInput route + method/class attributes (e.g. SendsMessage on HandleAsync).
|
||||
ProtocolBuilder result = base.ConfigureProtocol(protocolBuilder);
|
||||
|
||||
// Add the chat-protocol input shapes so the workflow satisfies IsChatProtocol
|
||||
// and can be hosted via AsAIAgent. Skip any shape that already matches TInput
|
||||
// (the inherited route handles that case via inputTransform).
|
||||
return result.ConfigureRoutes(this.ConfigureChatProtocolRoutes)
|
||||
.SendsMessage<ActionExecutorResult>();
|
||||
}
|
||||
|
||||
private void ConfigureChatProtocolRoutes(RouteBuilder routeBuilder)
|
||||
{
|
||||
Type tInput = typeof(TInput);
|
||||
|
||||
// Skip an exact-type match because RouteBuilder.AddHandler throws on duplicate
|
||||
// registrations for the same message type. Equality (not IsAssignableFrom) is
|
||||
// also what ChatProtocolExtensions.IsChatProtocol checks, so always registering
|
||||
// IEnumerable<ChatMessage> when TInput is broader (e.g. object) keeps the
|
||||
// workflow chat-protocol-compliant.
|
||||
if (tInput != typeof(string))
|
||||
{
|
||||
routeBuilder.AddHandler<string>(this.HandleStringAsync);
|
||||
}
|
||||
|
||||
if (tInput != typeof(ChatMessage))
|
||||
{
|
||||
routeBuilder.AddHandler<ChatMessage>(this.HandleChatMessageAsync);
|
||||
}
|
||||
|
||||
if (tInput != typeof(IEnumerable<ChatMessage>))
|
||||
{
|
||||
routeBuilder.AddHandler<IEnumerable<ChatMessage>>(this.HandleChatMessagesAsync);
|
||||
}
|
||||
|
||||
if (tInput != typeof(ChatMessage[]))
|
||||
{
|
||||
routeBuilder.AddHandler<ChatMessage[]>(this.HandleChatMessageArrayAsync);
|
||||
}
|
||||
|
||||
if (tInput != typeof(TurnToken))
|
||||
{
|
||||
routeBuilder.AddHandler<TurnToken>(this.HandleTurnTokenAsync);
|
||||
}
|
||||
}
|
||||
|
||||
private ValueTask HandleStringAsync(string message, IWorkflowContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
return this.AdvanceAsync(new ChatMessage(ChatRole.User, message), context, cancellationToken);
|
||||
}
|
||||
|
||||
private ValueTask HandleChatMessageAsync(ChatMessage message, IWorkflowContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
return this.AdvanceAsync(message, context, cancellationToken);
|
||||
}
|
||||
private async ValueTask HandleChatMessagesAsync(IEnumerable<ChatMessage> messages, IWorkflowContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
var list = messages as IList<ChatMessage> ?? new List<ChatMessage>(messages);
|
||||
if (list.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < list.Count; i++)
|
||||
{
|
||||
await this.AdvanceAsync(list[i], context, cancellationToken, finalizeTurn: i == list.Count - 1).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask HandleChatMessageArrayAsync(ChatMessage[] messages, IWorkflowContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
if (messages.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < messages.Length; i++)
|
||||
{
|
||||
await this.AdvanceAsync(messages[i], context, cancellationToken, finalizeTurn: i == messages.Length - 1).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
// The host sends a TurnToken after the message batch; the message has already
|
||||
// driven the graph forward, so we treat the token as a no-op here.
|
||||
private ValueTask HandleTurnTokenAsync(TurnToken token, IWorkflowContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
return default;
|
||||
}
|
||||
|
||||
private async ValueTask AdvanceAsync(ChatMessage input, IWorkflowContext context, CancellationToken cancellationToken, bool finalizeTurn = true)
|
||||
public override async ValueTask HandleAsync(TInput message, IWorkflowContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// No state to restore if we're starting from the beginning.
|
||||
state.SetInitialized();
|
||||
|
||||
DeclarativeWorkflowContext declarativeContext = new(context, state);
|
||||
ChatMessage input = inputTransform.Invoke(message);
|
||||
|
||||
// Conversation id resolution prefers state already persisted by a prior turn,
|
||||
// so multi-turn invocations reuse the same backend conversation rather than
|
||||
// creating a fresh one each turn.
|
||||
string? conversationId = declarativeContext.GetWorkflowConversation();
|
||||
if (string.IsNullOrWhiteSpace(conversationId))
|
||||
{
|
||||
conversationId = options.ConversationId;
|
||||
}
|
||||
|
||||
bool conversationCreated = false;
|
||||
string? conversationId = options.ConversationId;
|
||||
if (string.IsNullOrWhiteSpace(conversationId))
|
||||
{
|
||||
conversationId = await options.AgentProvider.CreateConversationAsync(cancellationToken).ConfigureAwait(false);
|
||||
conversationCreated = true;
|
||||
}
|
||||
await declarativeContext.QueueConversationUpdateAsync(conversationId, isExternal: true, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (conversationCreated || !string.Equals(declarativeContext.GetWorkflowConversation(), conversationId, StringComparison.Ordinal))
|
||||
{
|
||||
await declarativeContext.QueueConversationUpdateAsync(conversationId!, isExternal: true, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
ChatMessage inputMessage = await options.AgentProvider.CreateMessageAsync(conversationId!, input, cancellationToken).ConfigureAwait(false);
|
||||
ChatMessage inputMessage = await options.AgentProvider.CreateMessageAsync(conversationId, input, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Use the original input for System.LastMessage to ensure Text is preserved (the
|
||||
// service may strip text on round-trip), but substitute server-side media references
|
||||
// (e.g., HostedFileContent) so subsequent actions don't re-upload large blobs.
|
||||
await declarativeContext.SetLastMessageAsync(input.MergeForLastMessage(inputMessage)).ConfigureAwait(false);
|
||||
|
||||
if (finalizeTurn)
|
||||
{
|
||||
await context.SendResultMessageAsync(this.Id, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
await context.SendResultMessageAsync(this.Id, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
-9
@@ -6,7 +6,6 @@ using Microsoft.Agents.AI.Workflows.Declarative.Extensions;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.Interpreter;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
|
||||
using Microsoft.Agents.ObjectModel;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
|
||||
|
||||
@@ -20,14 +19,6 @@ internal sealed class SendActivityExecutor(SendActivity model, WorkflowFormulaSt
|
||||
string activityText = this.Engine.Format(messageActivity.Text).Trim();
|
||||
|
||||
await context.AddEventAsync(new MessageActivityEvent(activityText.Trim()), cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Route through YieldOutputAsync so the activity participates in the workflow's
|
||||
// output-filter pipeline. The runner currently special-cases AgentResponse to
|
||||
// produce an AgentResponseEvent identical to the one we'd build by hand, so this
|
||||
// is behavior-preserving today and forward-compatible if filtering is ever
|
||||
// applied to agent responses.
|
||||
AgentResponse response = new([new ChatMessage(ChatRole.Assistant, activityText)]);
|
||||
await context.YieldOutputAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return default;
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows;
|
||||
|
||||
/// <summary>
|
||||
/// Optional interface implemented by request payload types that wrap underlying
|
||||
/// AI content (such as <see cref="FunctionCallContent"/> or
|
||||
/// <see cref="ToolApprovalRequestContent"/>) and define a paired response envelope.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This abstraction allows higher-level layers (e.g., declarative workflows) to define
|
||||
/// their own request/response envelope types while still allowing
|
||||
/// <c>WorkflowSession</c> to surface the inner content to hosts on the request side
|
||||
/// and to wrap incoming responses back into the envelope on the response side -
|
||||
/// without the runtime taking a reference back to the higher-level layer.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// When an <c>ExternalRequest.Data</c> payload implements this interface, the
|
||||
/// runtime uses <see cref="GetInnerRequestContent"/> to drive wire serialization
|
||||
/// for hosts (so a host receives a normal <see cref="FunctionCallContent"/> or
|
||||
/// <see cref="ToolApprovalRequestContent"/>), and uses <see cref="CreateResponse"/>
|
||||
/// to wrap the host's response payload back into the envelope expected by the
|
||||
/// workflow's request port.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public interface IExternalRequestEnvelope
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the inner AI content that should be delivered to the host on the wire.
|
||||
/// Typically a <see cref="FunctionCallContent"/> or <see cref="ToolApprovalRequestContent"/>.
|
||||
/// </summary>
|
||||
/// <returns>The inner content, or <c>null</c> if no suitable inner content is available.</returns>
|
||||
AIContent? GetInnerRequestContent();
|
||||
|
||||
/// <summary>
|
||||
/// Wraps the supplied response messages into the envelope's matching response type
|
||||
/// for delivery to the workflow's request port.
|
||||
/// </summary>
|
||||
/// <param name="messages">The response messages, typically containing a
|
||||
/// <see cref="FunctionResultContent"/> and/or <see cref="ToolApprovalResponseContent"/>.</param>
|
||||
/// <returns>An instance of the envelope's response type wrapping <paramref name="messages"/>.</returns>
|
||||
object CreateResponse(IList<ChatMessage> messages);
|
||||
}
|
||||
@@ -287,93 +287,24 @@ internal sealed class WorkflowSession : AgentSession
|
||||
hasMatchedResponseForStartExecutor);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the concrete request payload type from <see cref="RequestPortInfo.RequestType"/>
|
||||
/// and returns it as an <see cref="IExternalRequestEnvelope"/> if the type implements that
|
||||
/// abstraction. Resolving via the concrete <see cref="TypeId"/> (rather than asking the
|
||||
/// PortableValue to deserialize directly to <see cref="IExternalRequestEnvelope"/>) is
|
||||
/// required because checkpointed payloads round-trip as JSON which cannot be deserialized
|
||||
/// to an interface; the concrete type populates the deserialization cache so subsequent
|
||||
/// interface assignment succeeds.
|
||||
/// </summary>
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2057:Unrecognized value passed to the parameter of method", Justification = "Higher-layer envelope types are explicitly preserved by the package that defines them.")]
|
||||
private static bool TryGetRequestEnvelope(ExternalRequest request, [NotNullWhen(true)] out IExternalRequestEnvelope? envelope)
|
||||
{
|
||||
envelope = null;
|
||||
|
||||
TypeId requestType = request.PortInfo.RequestType;
|
||||
Type? concreteType = Type.GetType($"{requestType.TypeName}, {requestType.AssemblyName}", throwOnError: false);
|
||||
if (concreteType is null || !typeof(IExternalRequestEnvelope).IsAssignableFrom(concreteType))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!request.TryGetDataAs(concreteType, out object? data) || data is not IExternalRequestEnvelope env)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
envelope = env;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates the workflow-facing request content surfaced in response updates.
|
||||
/// </summary>
|
||||
private static AIContent CreateRequestContentForDelivery(ExternalRequest request)
|
||||
private static AIContent CreateRequestContentForDelivery(ExternalRequest request) => request switch
|
||||
{
|
||||
// If the request payload is a higher-layer envelope (e.g., a declarative
|
||||
// ExternalInputRequest), surface its inner FCC/TARC to the host on the wire.
|
||||
if (TryGetRequestEnvelope(request, out IExternalRequestEnvelope? envelope))
|
||||
{
|
||||
AIContent? inner = envelope.GetInnerRequestContent();
|
||||
if (inner is ToolApprovalRequestContent toolApprovalRequest)
|
||||
{
|
||||
return CloneToolApprovalRequestContent(toolApprovalRequest, request.RequestId);
|
||||
}
|
||||
if (inner is FunctionCallContent functionCall)
|
||||
{
|
||||
return CloneFunctionCallContent(functionCall, request.RequestId);
|
||||
}
|
||||
}
|
||||
|
||||
return request switch
|
||||
{
|
||||
ExternalRequest externalRequest when externalRequest.TryGetDataAs(out FunctionCallContent? functionCallContent)
|
||||
=> CloneFunctionCallContent(functionCallContent, externalRequest.RequestId),
|
||||
ExternalRequest externalRequest when externalRequest.TryGetDataAs(out ToolApprovalRequestContent? toolApprovalRequestContent)
|
||||
=> CloneToolApprovalRequestContent(toolApprovalRequestContent, externalRequest.RequestId),
|
||||
ExternalRequest externalRequest
|
||||
=> externalRequest.ToFunctionCall(),
|
||||
};
|
||||
}
|
||||
ExternalRequest externalRequest when externalRequest.TryGetDataAs(out FunctionCallContent? functionCallContent)
|
||||
=> CloneFunctionCallContent(functionCallContent, externalRequest.RequestId),
|
||||
ExternalRequest externalRequest when externalRequest.TryGetDataAs(out ToolApprovalRequestContent? toolApprovalRequestContent)
|
||||
=> CloneToolApprovalRequestContent(toolApprovalRequestContent, externalRequest.RequestId),
|
||||
ExternalRequest externalRequest
|
||||
=> externalRequest.ToFunctionCall(),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Rewrites workflow-facing response content back to the original agent-owned content ID.
|
||||
/// </summary>
|
||||
private static object NormalizeResponseContentForDelivery(AIContent content, ExternalRequest request)
|
||||
{
|
||||
// If the request payload is a higher-layer envelope, recover the original
|
||||
// CallId/RequestId from the inner content and ask the envelope to wrap the
|
||||
// response back into its paired response type for delivery to the request port.
|
||||
if (TryGetRequestEnvelope(request, out IExternalRequestEnvelope? envelope))
|
||||
{
|
||||
AIContent? inner = envelope.GetInnerRequestContent();
|
||||
AIContent payload = (content, inner) switch
|
||||
{
|
||||
(FunctionResultContent functionResult, FunctionCallContent functionCall)
|
||||
=> CloneFunctionResultContent(functionResult, functionCall.CallId),
|
||||
(FunctionResultContent functionResult, ToolApprovalRequestContent toolApprovalRequest)
|
||||
=> CloneFunctionResultContent(functionResult, toolApprovalRequest.ToolCall.CallId),
|
||||
(ToolApprovalResponseContent toolApprovalResponse, ToolApprovalRequestContent toolApprovalRequest)
|
||||
=> CloneToolApprovalResponseContent(toolApprovalResponse, toolApprovalRequest.RequestId),
|
||||
_ => content,
|
||||
};
|
||||
|
||||
ChatMessage message = new(ChatRole.Tool, [payload]);
|
||||
return envelope.CreateResponse([message]);
|
||||
}
|
||||
|
||||
switch (content)
|
||||
{
|
||||
// If we got a FRC, and were expecting a FRC (because the request started out as a FCC, rather than getting converted to
|
||||
@@ -496,41 +427,10 @@ internal sealed class WorkflowSession : AgentSession
|
||||
|
||||
break;
|
||||
|
||||
case ExecutorFailedEvent executorFailed:
|
||||
// Mirror WorkflowErrorEvent: never expose internal workflow graph
|
||||
// identifiers (executor ID) to the client. Surface the exception
|
||||
// message only when the host opts in via _includeExceptionDetails.
|
||||
Exception? executorException = executorFailed.Data;
|
||||
while (executorException is { InnerException: not null }
|
||||
&& (executorException is TargetInvocationException
|
||||
|| executorException.GetType().Name == "DeclarativeActionException"))
|
||||
{
|
||||
executorException = executorException.InnerException;
|
||||
}
|
||||
|
||||
string executorMessage = this._includeExceptionDetails && executorException != null
|
||||
? executorException.Message
|
||||
: "An error occurred while executing the workflow.";
|
||||
|
||||
yield return this.CreateUpdate(this.LastResponseId, evt, new ErrorContent(executorMessage));
|
||||
break;
|
||||
|
||||
case SuperStepCompletedEvent stepCompleted:
|
||||
this.LastCheckpoint = stepCompleted.CompletionInfo?.Checkpoint;
|
||||
goto default;
|
||||
|
||||
case AgentResponseEvent agentResponse:
|
||||
if (!this._includeWorkflowOutputsInResponse)
|
||||
{
|
||||
goto default;
|
||||
}
|
||||
|
||||
foreach (ChatMessage message in agentResponse.Response.Messages)
|
||||
{
|
||||
yield return this.CreateUpdate(this.LastResponseId, evt, message);
|
||||
}
|
||||
break;
|
||||
|
||||
case WorkflowOutputEvent output:
|
||||
IEnumerable<ChatMessage>? updateMessages = output.Data switch
|
||||
{
|
||||
|
||||
-302
@@ -1,302 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.UnitTests.Hosting;
|
||||
|
||||
public sealed class FileSystemAgentSessionStoreTests : IDisposable
|
||||
{
|
||||
private readonly string _root;
|
||||
|
||||
public FileSystemAgentSessionStoreTests()
|
||||
{
|
||||
this._root = Path.Combine(Path.GetTempPath(), "fs-session-store-tests-" + Guid.NewGuid().ToString("N"));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(this._root))
|
||||
{
|
||||
Directory.Delete(this._root, recursive: true);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// best-effort cleanup
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ResolvesRootDirectoryToFullPath()
|
||||
{
|
||||
var store = new FileSystemAgentSessionStore(this._root);
|
||||
Assert.Equal(Path.GetFullPath(this._root), store.RootDirectory);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NullOrWhitespaceRoot_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() => new FileSystemAgentSessionStore(null!));
|
||||
Assert.Throws<ArgumentException>(() => new FileSystemAgentSessionStore(""));
|
||||
Assert.Throws<ArgumentException>(() => new FileSystemAgentSessionStore(" "));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSessionAsync_NoFileOnDisk_ReturnsFreshSessionFromAgentAsync()
|
||||
{
|
||||
var store = new FileSystemAgentSessionStore(this._root);
|
||||
var agent = new TestAgent();
|
||||
|
||||
var session = await store.GetSessionAsync(agent, "conv-1");
|
||||
|
||||
Assert.NotNull(session);
|
||||
Assert.Equal(1, agent.CreateCalls);
|
||||
Assert.Equal(0, agent.DeserializeCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSessionAsync_EmptyFileOnDisk_ReturnsFreshSessionAsync()
|
||||
{
|
||||
var store = new FileSystemAgentSessionStore(this._root);
|
||||
Directory.CreateDirectory(store.RootDirectory);
|
||||
File.WriteAllText(Path.Combine(store.RootDirectory, "conv-empty.json"), string.Empty);
|
||||
|
||||
var agent = new TestAgent();
|
||||
var session = await store.GetSessionAsync(agent, "conv-empty");
|
||||
|
||||
Assert.NotNull(session);
|
||||
Assert.Equal(1, agent.CreateCalls);
|
||||
Assert.Equal(0, agent.DeserializeCalls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveSessionAsync_CreatesRootDirectoryIfMissingAsync()
|
||||
{
|
||||
var nested = Path.Combine(this._root, "nested", "deeper");
|
||||
var store = new FileSystemAgentSessionStore(nested);
|
||||
Assert.False(Directory.Exists(nested));
|
||||
|
||||
var agent = new TestAgent("{\"workflow\":\"x\"}");
|
||||
await store.SaveSessionAsync(agent, "conv-2", NewSession());
|
||||
|
||||
Assert.True(Directory.Exists(nested));
|
||||
Assert.True(File.Exists(Path.Combine(nested, "conv-2.json")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveSessionAsync_ThenGetSessionAsync_RoundTripsViaAgentSerializerAsync()
|
||||
{
|
||||
var store = new FileSystemAgentSessionStore(this._root);
|
||||
var agent = new TestAgent("{\"foo\":42}");
|
||||
|
||||
await store.SaveSessionAsync(agent, "round-trip", NewSession());
|
||||
await store.GetSessionAsync(agent, "round-trip");
|
||||
|
||||
Assert.Equal(1, agent.SerializeCalls);
|
||||
Assert.Equal(1, agent.DeserializeCalls);
|
||||
Assert.NotNull(agent.LastDeserialized);
|
||||
Assert.Equal(JsonValueKind.Object, agent.LastDeserialized!.Value.ValueKind);
|
||||
Assert.Equal(42, agent.LastDeserialized!.Value.GetProperty("foo").GetInt32());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveSessionAsync_TwoAgentsSameConversationId_DoNotCollideAsync()
|
||||
{
|
||||
var store = new FileSystemAgentSessionStore(this._root);
|
||||
var agentA = new TestAgent("{\"who\":\"a\"}", name: "AgentA");
|
||||
var agentB = new TestAgent("{\"who\":\"b\"}", name: "AgentB");
|
||||
|
||||
await store.SaveSessionAsync(agentA, "shared-conv", NewSession());
|
||||
await store.SaveSessionAsync(agentB, "shared-conv", NewSession());
|
||||
|
||||
// Agents with distinct Names get distinct subdirectories so neither overwrites the other.
|
||||
var pathA = Path.Combine(store.RootDirectory, "AgentA", "shared-conv.json");
|
||||
var pathB = Path.Combine(store.RootDirectory, "AgentB", "shared-conv.json");
|
||||
Assert.True(File.Exists(pathA));
|
||||
Assert.True(File.Exists(pathB));
|
||||
Assert.Contains("\"a\"", File.ReadAllText(pathA), StringComparison.Ordinal);
|
||||
Assert.Contains("\"b\"", File.ReadAllText(pathB), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveSessionAsync_LongConversationId_DoesNotStackOverflowAsync()
|
||||
{
|
||||
// Keep the value < typical OS file-name limits (~255 chars) so the file write
|
||||
// succeeds, but long enough to force Sanitize past its small-input fast path.
|
||||
var store = new FileSystemAgentSessionStore(this._root);
|
||||
var conversationId = new string('a', 200);
|
||||
var agent = new TestAgent();
|
||||
|
||||
await store.SaveSessionAsync(agent, conversationId, NewSession());
|
||||
|
||||
var files = Directory.GetFiles(store.RootDirectory, "*.json");
|
||||
Assert.Single(files);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveSessionAsync_SanitizesInvalidPathCharactersAsync()
|
||||
{
|
||||
var store = new FileSystemAgentSessionStore(this._root);
|
||||
var agent = new TestAgent();
|
||||
|
||||
// Pick an invalid filename char for the current OS. The set differs by platform
|
||||
// (e.g. '?' is invalid on Windows but not on Linux), so we must select dynamically.
|
||||
var invalidChars = Path.GetInvalidFileNameChars();
|
||||
Assert.NotEmpty(invalidChars);
|
||||
char invalid = invalidChars[0];
|
||||
// Avoid NUL specifically because some shells/loggers handle it oddly; prefer
|
||||
// the next character if available.
|
||||
if (invalid == '\0' && invalidChars.Length > 1)
|
||||
{
|
||||
invalid = invalidChars[1];
|
||||
}
|
||||
|
||||
var conversationId = $"id-with{invalid}invalid-chars";
|
||||
|
||||
await store.SaveSessionAsync(agent, conversationId, NewSession());
|
||||
|
||||
var files = Directory.GetFiles(store.RootDirectory, "*.json");
|
||||
Assert.Single(files);
|
||||
var fileName = Path.GetFileName(files[0]);
|
||||
Assert.DoesNotContain(invalid.ToString(), fileName, StringComparison.Ordinal);
|
||||
Assert.Contains("id-with", fileName, StringComparison.Ordinal);
|
||||
Assert.Contains("invalid-chars", fileName, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveSessionAsync_ConcurrentSavesOnSameConversation_DoNotCollideOnTempFileAsync()
|
||||
{
|
||||
var store = new FileSystemAgentSessionStore(this._root);
|
||||
var agent = new TestAgent("{\"x\":1}");
|
||||
|
||||
// Fan out N concurrent saves; with a fixed temp filename ("path.tmp") this would
|
||||
// race on FileMode.Create / Move. Verify they all complete successfully.
|
||||
var tasks = new List<Task>();
|
||||
for (int i = 0; i < 16; i++)
|
||||
{
|
||||
tasks.Add(store.SaveSessionAsync(agent, "concurrent", NewSession()).AsTask());
|
||||
}
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
|
||||
Assert.True(File.Exists(Path.Combine(store.RootDirectory, "concurrent.json")));
|
||||
var leftoverTempFiles = Directory.GetFiles(store.RootDirectory, "*.tmp");
|
||||
Assert.Empty(leftoverTempFiles);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(".")]
|
||||
[InlineData("..")]
|
||||
[InlineData("...")]
|
||||
public async Task SaveSessionAsync_AgentNameIsDotSegment_DoesNotEscapeRootAsync(string agentName)
|
||||
{
|
||||
var store = new FileSystemAgentSessionStore(this._root);
|
||||
var agent = new TestAgent(name: agentName);
|
||||
|
||||
await store.SaveSessionAsync(agent, "conv-dots", NewSession());
|
||||
|
||||
// The session file must land inside RootDirectory, not in (or above) it as a sibling.
|
||||
var allFiles = Directory.GetFiles(store.RootDirectory, "*.json", SearchOption.AllDirectories);
|
||||
Assert.Single(allFiles);
|
||||
var fullPath = Path.GetFullPath(allFiles[0]);
|
||||
Assert.StartsWith(Path.GetFullPath(this._root) + Path.DirectorySeparatorChar, fullPath, StringComparison.Ordinal);
|
||||
|
||||
// The bucket directory name must not be a navigable dot-segment. After
|
||||
// percent-encoding every dot in an all-dot segment, names like ".", "..", and
|
||||
// "..." become "%2E", "%2E%2E", "%2E%2E%2E" — distinct, OS-neutral filenames.
|
||||
var bucketName = Path.GetFileName(Path.GetDirectoryName(fullPath)!);
|
||||
Assert.NotEmpty(bucketName);
|
||||
Assert.NotEqual(".", bucketName);
|
||||
Assert.NotEqual("..", bucketName);
|
||||
Assert.DoesNotContain(bucketName, c => c == '.');
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveSessionAsync_DistinctNamesWithInvalidChars_ProduceDistinctFilesAsync()
|
||||
{
|
||||
// Percent-encoding must keep otherwise-colliding inputs distinct: under the
|
||||
// earlier underscore-substitution scheme, "foo/bar" and "foo_bar" both sanitized
|
||||
// to "foo_bar" and would have shared a session bucket on disk.
|
||||
var store = new FileSystemAgentSessionStore(this._root);
|
||||
var agentSlash = new TestAgent(name: "foo/bar");
|
||||
var agentUnderscore = new TestAgent(name: "foo_bar");
|
||||
|
||||
await store.SaveSessionAsync(agentSlash, "conv-1", NewSession());
|
||||
await store.SaveSessionAsync(agentUnderscore, "conv-1", NewSession());
|
||||
|
||||
var bucketDirs = Directory.GetDirectories(store.RootDirectory);
|
||||
Assert.Equal(2, bucketDirs.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetSessionAsync_NoExistingFile_DoesNotCreateAgentDirectoryAsync()
|
||||
{
|
||||
// Read operations must not have side effects on the file system.
|
||||
var store = new FileSystemAgentSessionStore(this._root);
|
||||
var agent = new TestAgent(name: "agent-with-bucket");
|
||||
|
||||
var session = await store.GetSessionAsync(agent, "missing-id");
|
||||
|
||||
Assert.NotNull(session);
|
||||
Assert.False(Directory.Exists(this._root), "Read miss must not create the root directory.");
|
||||
}
|
||||
|
||||
private static TestSession NewSession() => new();
|
||||
|
||||
private sealed class TestSession : AgentSession
|
||||
{
|
||||
}
|
||||
|
||||
private sealed class TestAgent : AIAgent
|
||||
{
|
||||
private readonly string _serializedJson;
|
||||
private readonly string? _name;
|
||||
|
||||
public TestAgent(string serializedJson = "{}", string? name = null)
|
||||
{
|
||||
this._serializedJson = serializedJson;
|
||||
this._name = name;
|
||||
}
|
||||
|
||||
public override string? Name => this._name;
|
||||
|
||||
public int CreateCalls { get; private set; }
|
||||
public int SerializeCalls { get; private set; }
|
||||
public int DeserializeCalls { get; private set; }
|
||||
public JsonElement? LastDeserialized { get; private set; }
|
||||
|
||||
protected override ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
this.CreateCalls++;
|
||||
return new ValueTask<AgentSession>(NewSession());
|
||||
}
|
||||
|
||||
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
this.SerializeCalls++;
|
||||
using var doc = JsonDocument.Parse(this._serializedJson);
|
||||
return new ValueTask<JsonElement>(doc.RootElement.Clone());
|
||||
}
|
||||
|
||||
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
this.DeserializeCalls++;
|
||||
this.LastDeserialized = serializedState.Clone();
|
||||
return new ValueTask<AgentSession>(NewSession());
|
||||
}
|
||||
|
||||
protected override Task<AgentResponse> RunCoreAsync(IEnumerable<Extensions.AI.ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
protected override IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(IEnumerable<Extensions.AI.ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
@@ -757,467 +757,4 @@ public class InputConverterTests
|
||||
Assert.Equal("box-b", markers[1].Name);
|
||||
Assert.Equal("2025-01", markers[1].Version);
|
||||
}
|
||||
|
||||
// === Tool-approval (HITL) wire-format coverage ===
|
||||
|
||||
[Fact]
|
||||
public void ConvertItemsToMessages_McpApprovalRequest_ProducesToolApprovalRequest()
|
||||
{
|
||||
var item = new ItemMcpApprovalRequest(
|
||||
id: "mcpr_" + new string('a', 50),
|
||||
serverLabel: "agent_framework",
|
||||
name: "get_weather",
|
||||
arguments: "{\"city\":\"Seattle\"}");
|
||||
|
||||
var messages = InputConverter.ConvertItemsToMessages([item]);
|
||||
|
||||
var content = Assert.IsType<ToolApprovalRequestContent>(Assert.Single(messages[0].Contents));
|
||||
Assert.Equal(item.Id, content.RequestId);
|
||||
var fc = Assert.IsType<FunctionCallContent>(content.ToolCall);
|
||||
Assert.Equal("get_weather", fc.Name);
|
||||
Assert.NotNull(fc.Arguments);
|
||||
Assert.Equal("Seattle", fc.Arguments!["city"]?.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertItemsToMessages_McpApprovalResponse_ProducesToolApprovalResponse_FallsBackToWireIdWhenNoMapping()
|
||||
{
|
||||
var wireId = "mcpr_" + new string('a', 50);
|
||||
var item = new MCPApprovalResponse(approvalRequestId: wireId, approve: true);
|
||||
|
||||
var messages = InputConverter.ConvertItemsToMessages([item]);
|
||||
|
||||
var content = Assert.IsType<ToolApprovalResponseContent>(Assert.Single(messages[0].Contents));
|
||||
Assert.Equal(wireId, content.RequestId);
|
||||
Assert.True(content.Approved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertItemsToMessages_McpApprovalResponse_ResolvesAfRequestIdFromStateBag()
|
||||
{
|
||||
const string AfRequestId = "af_request_xyz";
|
||||
var wireId = ToolApprovalIdMap.ComputeWireId(AfRequestId);
|
||||
var stateBag = new AgentSessionStateBag();
|
||||
ToolApprovalIdMap.Record(stateBag, wireId, AfRequestId);
|
||||
|
||||
var item = new MCPApprovalResponse(approvalRequestId: wireId, approve: false);
|
||||
|
||||
var messages = InputConverter.ConvertItemsToMessages([item], stateBag);
|
||||
|
||||
var content = Assert.IsType<ToolApprovalResponseContent>(Assert.Single(messages[0].Contents));
|
||||
Assert.Equal(AfRequestId, content.RequestId);
|
||||
Assert.False(content.Approved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertOutputItemsToMessages_McpApprovalRequest_ProducesToolApprovalRequest()
|
||||
{
|
||||
var item = new OutputItemMcpApprovalRequest(
|
||||
id: "mcpr_" + new string('b', 50),
|
||||
serverLabel: "agent_framework",
|
||||
name: "delete_file",
|
||||
arguments: "{}");
|
||||
|
||||
var messages = InputConverter.ConvertOutputItemsToMessages([item]);
|
||||
|
||||
var content = Assert.IsType<ToolApprovalRequestContent>(Assert.Single(messages[0].Contents));
|
||||
Assert.Equal(item.Id, content.RequestId);
|
||||
Assert.Equal("delete_file", Assert.IsType<FunctionCallContent>(content.ToolCall).Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertOutputItemsToMessages_McpApprovalResponse_ProducesToolApprovalResponse()
|
||||
{
|
||||
const string AfRequestId = "af_request_history";
|
||||
var wireId = ToolApprovalIdMap.ComputeWireId(AfRequestId);
|
||||
var stateBag = new AgentSessionStateBag();
|
||||
ToolApprovalIdMap.Record(stateBag, wireId, AfRequestId);
|
||||
|
||||
var item = new OutputItemMcpApprovalResponseResource(
|
||||
id: "ar_history_id",
|
||||
approvalRequestId: wireId,
|
||||
approve: true);
|
||||
|
||||
var messages = InputConverter.ConvertOutputItemsToMessages([item], stateBag);
|
||||
|
||||
var content = Assert.IsType<ToolApprovalResponseContent>(Assert.Single(messages[0].Contents));
|
||||
Assert.Equal(AfRequestId, content.RequestId);
|
||||
Assert.True(content.Approved);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertItemsToMessages_McpApprovalRequest_MalformedArguments_PreservesRaw()
|
||||
{
|
||||
var item = new ItemMcpApprovalRequest(
|
||||
id: "mcpr_" + new string('c', 50),
|
||||
serverLabel: "agent_framework",
|
||||
name: "noisy",
|
||||
arguments: "not valid json");
|
||||
|
||||
var messages = InputConverter.ConvertItemsToMessages([item]);
|
||||
|
||||
var content = Assert.IsType<ToolApprovalRequestContent>(Assert.Single(messages[0].Contents));
|
||||
var fc = Assert.IsType<FunctionCallContent>(content.ToolCall);
|
||||
Assert.NotNull(fc.Arguments);
|
||||
Assert.Equal("not valid json", fc.Arguments!["_raw"]?.ToString());
|
||||
}
|
||||
|
||||
// ── input_file data-URI decoding (TryDecodeTextDataUri) ──
|
||||
|
||||
[Fact]
|
||||
public void ConvertInputToMessages_FileContentWithTextDataUri_DecodesToTextContent()
|
||||
{
|
||||
var encoded = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("hello world"));
|
||||
var input = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
type = "message",
|
||||
id = "msg_text_uri",
|
||||
status = "completed",
|
||||
role = "user",
|
||||
content = new[] { new { type = "input_file", file_data = $"data:text/plain;base64,{encoded}" } }
|
||||
}
|
||||
};
|
||||
|
||||
var request = new CreateResponse();
|
||||
request.Input = BinaryData.FromObjectAsJson(input);
|
||||
|
||||
var messages = InputConverter.ConvertInputToMessages(request);
|
||||
|
||||
var text = Assert.IsType<MeaiTextContent>(Assert.Single(messages[0].Contents));
|
||||
Assert.Equal("hello world", text.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertInputToMessages_FileContentWithTextDataUriAndFilename_PrefixesFilenameInDecodedText()
|
||||
{
|
||||
var encoded = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("body"));
|
||||
var input = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
type = "message",
|
||||
id = "msg_text_uri_name",
|
||||
status = "completed",
|
||||
role = "user",
|
||||
content = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
type = "input_file",
|
||||
filename = "notes.txt",
|
||||
file_data = $"data:text/plain;base64,{encoded}"
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var request = new CreateResponse();
|
||||
request.Input = BinaryData.FromObjectAsJson(input);
|
||||
|
||||
var messages = InputConverter.ConvertInputToMessages(request);
|
||||
|
||||
var text = Assert.IsType<MeaiTextContent>(Assert.Single(messages[0].Contents));
|
||||
Assert.StartsWith("[File: notes.txt]", text.Text, StringComparison.Ordinal);
|
||||
Assert.Contains("body", text.Text, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertInputToMessages_FileContentWithNonTextDataUri_RemainsDataContent()
|
||||
{
|
||||
// image/png data URIs must NOT be decoded as text — only text/* is decoded inline.
|
||||
var input = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
type = "message",
|
||||
id = "msg_image_uri",
|
||||
status = "completed",
|
||||
role = "user",
|
||||
content = new[]
|
||||
{
|
||||
new { type = "input_file", file_data = "data:image/png;base64,iVBORw0KGgo=" }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var request = new CreateResponse();
|
||||
request.Input = BinaryData.FromObjectAsJson(input);
|
||||
|
||||
var messages = InputConverter.ConvertInputToMessages(request);
|
||||
|
||||
Assert.IsType<DataContent>(Assert.Single(messages[0].Contents));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertInputToMessages_FileContentWithMalformedDataUri_FallsBackToDataContent()
|
||||
{
|
||||
// Missing ;base64, marker — TryDecodeTextDataUri should return false and the
|
||||
// original payload survives as DataContent.
|
||||
var input = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
type = "message",
|
||||
id = "msg_bad_uri",
|
||||
status = "completed",
|
||||
role = "user",
|
||||
content = new[]
|
||||
{
|
||||
new { type = "input_file", file_data = "data:text/plain,not-base64-payload" }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var request = new CreateResponse();
|
||||
request.Input = BinaryData.FromObjectAsJson(input);
|
||||
|
||||
var messages = InputConverter.ConvertInputToMessages(request);
|
||||
|
||||
Assert.IsType<DataContent>(Assert.Single(messages[0].Contents));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertInputToMessages_FileContentWithFileUrlAndFilename_PropagatesFilename()
|
||||
{
|
||||
var input = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
type = "message",
|
||||
id = "msg_url_name",
|
||||
status = "completed",
|
||||
role = "user",
|
||||
content = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
type = "input_file",
|
||||
file_url = "https://example.com/doc.pdf",
|
||||
filename = "doc.pdf"
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var request = new CreateResponse();
|
||||
request.Input = BinaryData.FromObjectAsJson(input);
|
||||
|
||||
var messages = InputConverter.ConvertInputToMessages(request);
|
||||
|
||||
var uri = Assert.IsType<UriContent>(Assert.Single(messages[0].Contents));
|
||||
Assert.NotNull(uri.AdditionalProperties);
|
||||
Assert.Equal("doc.pdf", uri.AdditionalProperties!["filename"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertInputToMessages_FileContentWithFileIdAndFilename_PropagatesFilename()
|
||||
{
|
||||
var input = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
type = "message",
|
||||
id = "msg_id_name",
|
||||
status = "completed",
|
||||
role = "user",
|
||||
content = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
type = "input_file",
|
||||
file_id = "file_abc123",
|
||||
filename = "doc.pdf"
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var request = new CreateResponse();
|
||||
request.Input = BinaryData.FromObjectAsJson(input);
|
||||
|
||||
var messages = InputConverter.ConvertInputToMessages(request);
|
||||
|
||||
var hosted = Assert.IsType<HostedFileContent>(Assert.Single(messages[0].Contents));
|
||||
Assert.NotNull(hosted.AdditionalProperties);
|
||||
Assert.Equal("doc.pdf", hosted.AdditionalProperties!["filename"]);
|
||||
}
|
||||
|
||||
// ── C2: SDK content types passing through ItemMessage / OutputItemMessage ──
|
||||
|
||||
[Fact]
|
||||
public void ConvertItemsToMessages_SdkTextContent_ProducesTextContent()
|
||||
{
|
||||
var msg = new ItemMessage(
|
||||
MessageRole.User,
|
||||
new MessageContent[] { new Azure.AI.AgentServer.Responses.Models.TextContent("plain text") });
|
||||
|
||||
var messages = InputConverter.ConvertItemsToMessages([msg]);
|
||||
|
||||
var text = Assert.IsType<MeaiTextContent>(Assert.Single(messages[0].Contents));
|
||||
Assert.Equal("plain text", text.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertItemsToMessages_SummaryTextContent_ProducesTextContent()
|
||||
{
|
||||
var msg = new ItemMessage(
|
||||
MessageRole.Assistant,
|
||||
new MessageContent[] { new SummaryTextContent("a summary") });
|
||||
|
||||
var messages = InputConverter.ConvertItemsToMessages([msg]);
|
||||
|
||||
var text = Assert.IsType<MeaiTextContent>(Assert.Single(messages[0].Contents));
|
||||
Assert.Equal("a summary", text.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertItemsToMessages_ReasoningTextContent_ProducesTextReasoningContent()
|
||||
{
|
||||
var msg = new ItemMessage(
|
||||
MessageRole.Assistant,
|
||||
new MessageContent[] { new MessageContentReasoningTextContent("internal reasoning") });
|
||||
|
||||
var messages = InputConverter.ConvertItemsToMessages([msg]);
|
||||
|
||||
var reasoning = Assert.IsType<TextReasoningContent>(Assert.Single(messages[0].Contents));
|
||||
Assert.Equal("internal reasoning", reasoning.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertItemsToMessages_ComputerScreenshotContent_HttpUrl_ProducesUriContent()
|
||||
{
|
||||
var screenshot = new ComputerScreenshotContent(
|
||||
imageUrl: new Uri("https://example.com/screen.png"),
|
||||
fileId: null!,
|
||||
detail: default);
|
||||
var msg = new ItemMessage(MessageRole.User, new MessageContent[] { screenshot });
|
||||
|
||||
var messages = InputConverter.ConvertItemsToMessages([msg]);
|
||||
|
||||
var uri = Assert.IsType<UriContent>(Assert.Single(messages[0].Contents));
|
||||
Assert.Equal("https://example.com/screen.png", uri.Uri.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertItemsToMessages_ComputerScreenshotContent_DataUri_ProducesDataContent()
|
||||
{
|
||||
var screenshot = new ComputerScreenshotContent(
|
||||
imageUrl: new Uri("data:image/png;base64,iVBORw0KGgo="),
|
||||
fileId: null!,
|
||||
detail: default);
|
||||
var msg = new ItemMessage(MessageRole.User, new MessageContent[] { screenshot });
|
||||
|
||||
var messages = InputConverter.ConvertItemsToMessages([msg]);
|
||||
|
||||
var data = Assert.IsType<DataContent>(Assert.Single(messages[0].Contents));
|
||||
Assert.StartsWith("data:image", data.Uri);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertOutputItemsToMessages_SummaryTextContent_ProducesTextContent()
|
||||
{
|
||||
var outputMsg = new OutputItemMessage(
|
||||
id: "out_summary",
|
||||
role: MessageRole.Assistant,
|
||||
content: new MessageContent[] { new SummaryTextContent("output summary") },
|
||||
status: MessageStatus.Completed);
|
||||
|
||||
var messages = InputConverter.ConvertOutputItemsToMessages([outputMsg]);
|
||||
|
||||
var text = Assert.IsType<MeaiTextContent>(Assert.Single(messages[0].Contents));
|
||||
Assert.Equal("output summary", text.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertOutputItemsToMessages_ReasoningTextContent_ProducesTextReasoningContent()
|
||||
{
|
||||
var outputMsg = new OutputItemMessage(
|
||||
id: "out_reasoning",
|
||||
role: MessageRole.Assistant,
|
||||
content: new MessageContent[] { new MessageContentReasoningTextContent("output reasoning") },
|
||||
status: MessageStatus.Completed);
|
||||
|
||||
var messages = InputConverter.ConvertOutputItemsToMessages([outputMsg]);
|
||||
|
||||
var reasoning = Assert.IsType<TextReasoningContent>(Assert.Single(messages[0].Contents));
|
||||
Assert.Equal("output reasoning", reasoning.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertOutputItemsToMessages_ComputerScreenshotContent_ProducesUriContent()
|
||||
{
|
||||
var screenshot = new ComputerScreenshotContent(
|
||||
imageUrl: new Uri("https://example.com/output-screen.png"),
|
||||
fileId: null!,
|
||||
detail: default);
|
||||
var outputMsg = new OutputItemMessage(
|
||||
id: "out_screenshot",
|
||||
role: MessageRole.Assistant,
|
||||
content: new MessageContent[] { screenshot },
|
||||
status: MessageStatus.Completed);
|
||||
|
||||
var messages = InputConverter.ConvertOutputItemsToMessages([outputMsg]);
|
||||
|
||||
var uri = Assert.IsType<UriContent>(Assert.Single(messages[0].Contents));
|
||||
Assert.Equal("https://example.com/output-screen.png", uri.Uri.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertOutputItemsToMessages_SdkTextContent_ProducesTextContent()
|
||||
{
|
||||
var outputMsg = new OutputItemMessage(
|
||||
id: "out_text",
|
||||
role: MessageRole.Assistant,
|
||||
content: new MessageContent[] { new Azure.AI.AgentServer.Responses.Models.TextContent("sdk text") },
|
||||
status: MessageStatus.Completed);
|
||||
|
||||
var messages = InputConverter.ConvertOutputItemsToMessages([outputMsg]);
|
||||
|
||||
var text = Assert.IsType<MeaiTextContent>(Assert.Single(messages[0].Contents));
|
||||
Assert.Equal("sdk text", text.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertInputToMessages_OversizedTextDataUri_FallsBackToDataContent()
|
||||
{
|
||||
// The decoder must reject oversized base64 payloads so a malicious or
|
||||
// misconfigured client cannot trigger a multi-megabyte allocation.
|
||||
// We construct a base64 payload whose encoded length exceeds the 16 MiB cap
|
||||
// (using a tiny but valid base64 unit repeated to keep the test fast).
|
||||
const int OverLimit = (16 * 1024 * 1024) + 4;
|
||||
var encoded = new string('A', OverLimit);
|
||||
var dataUri = "data:text/plain;base64," + encoded;
|
||||
|
||||
var input = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
type = "message",
|
||||
id = "msg_oversize",
|
||||
status = "completed",
|
||||
role = "user",
|
||||
content = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
type = "input_file",
|
||||
file_data = dataUri,
|
||||
filename = "huge.txt",
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var request = new CreateResponse();
|
||||
request.Input = BinaryData.FromObjectAsJson(input);
|
||||
|
||||
var messages = InputConverter.ConvertInputToMessages(request);
|
||||
|
||||
// Should NOT have decoded into a TextContent (which would have allocated).
|
||||
Assert.DoesNotContain(messages[0].Contents, c => c is MeaiTextContent t && t.Text.Length > 1024);
|
||||
// Should have fallen back to DataContent (carrying the original opaque blob).
|
||||
Assert.Contains(messages[0].Contents, c => c is DataContent);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-128
@@ -204,7 +204,7 @@ public class OutputConverterTests
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(async () =>
|
||||
{
|
||||
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(updates, stream, cancellationToken: cts.Token))
|
||||
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(updates, stream, cts.Token))
|
||||
{
|
||||
// Should throw before yielding
|
||||
}
|
||||
@@ -1068,133 +1068,6 @@ public class OutputConverterTests
|
||||
Assert.IsType<ResponseCompletedEvent>(events[0]);
|
||||
}
|
||||
|
||||
// === Tool-approval (HITL) wire-format coverage ===
|
||||
|
||||
[Fact]
|
||||
public async Task ConvertUpdatesToEventsAsync_ToolApprovalRequest_EmitsMcpApprovalRequestAsync()
|
||||
{
|
||||
var (stream, _) = CreateTestStream();
|
||||
var stateBag = new AgentSessionStateBag();
|
||||
const string AfRequestId = "af_request_abc";
|
||||
var functionCall = new FunctionCallContent("call_1", "delete_resource",
|
||||
new Dictionary<string, object?> { ["target"] = "db" });
|
||||
var approval = new ToolApprovalRequestContent(AfRequestId, functionCall);
|
||||
|
||||
var update = new AgentResponseUpdate { Contents = [approval] };
|
||||
|
||||
var events = new List<ResponseStreamEvent>();
|
||||
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream, stateBag))
|
||||
{
|
||||
events.Add(evt);
|
||||
}
|
||||
|
||||
var added = Assert.Single(events.OfType<ResponseOutputItemAddedEvent>());
|
||||
var item = Assert.IsType<OutputItemMcpApprovalRequest>(added.Item);
|
||||
Assert.Equal("agent_framework", item.ServerLabel);
|
||||
Assert.Equal("delete_resource", item.Name);
|
||||
Assert.Contains("\"target\":\"db\"", item.Arguments);
|
||||
Assert.StartsWith("mcpr_", item.Id);
|
||||
|
||||
// Mapping persisted to state bag.
|
||||
Assert.Equal(AfRequestId, ToolApprovalIdMap.Resolve(stateBag, item.Id));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConvertUpdatesToEventsAsync_ToolApprovalRequest_NonFunctionToolCall_SkippedAsync()
|
||||
{
|
||||
// ToolCall implementations that aren't FunctionCallContent (e.g. raw MCP calls)
|
||||
// are intentionally NOT emitted — mirrors the OpenAI Hosting layer's behavior.
|
||||
var (stream, _) = CreateTestStream();
|
||||
var unknownTool = new RawToolCallContent("call_x");
|
||||
var approval = new ToolApprovalRequestContent("af_x", unknownTool);
|
||||
|
||||
var update = new AgentResponseUpdate { Contents = [approval] };
|
||||
|
||||
var events = new List<ResponseStreamEvent>();
|
||||
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream))
|
||||
{
|
||||
events.Add(evt);
|
||||
}
|
||||
|
||||
Assert.DoesNotContain(events.OfType<ResponseOutputItemAddedEvent>(),
|
||||
e => e.Item is OutputItemMcpApprovalRequest);
|
||||
|
||||
// Defense in depth: only the terminal ResponseCompletedEvent should be emitted.
|
||||
// No spurious output-item-added/output-item-done events should leak for the
|
||||
// unsupported tool-call shape.
|
||||
Assert.Single(events);
|
||||
Assert.IsType<ResponseCompletedEvent>(events[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConvertUpdatesToEventsAsync_ToolApprovalResponse_NotReEmittedAsync()
|
||||
{
|
||||
var (stream, _) = CreateTestStream();
|
||||
var fc = new FunctionCallContent("call_1", "noop");
|
||||
var response = new ToolApprovalResponseContent("af_x", true, fc);
|
||||
|
||||
var update = new AgentResponseUpdate { Contents = [response] };
|
||||
|
||||
var events = new List<ResponseStreamEvent>();
|
||||
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream))
|
||||
{
|
||||
events.Add(evt);
|
||||
}
|
||||
|
||||
// Approval responses are inbound-only; output side should silently drop them
|
||||
// and emit only the terminal completed event.
|
||||
Assert.Single(events);
|
||||
Assert.IsType<ResponseCompletedEvent>(events[0]);
|
||||
}
|
||||
|
||||
// D1: WorkflowEvent in RawRepresentation but Contents is non-empty → fall through to content path.
|
||||
[Fact]
|
||||
public async Task ConvertUpdatesToEventsAsync_WorkflowEventWithTextContent_FlowsThroughContentPathAsync()
|
||||
{
|
||||
var (stream, _) = CreateTestStream();
|
||||
var update = new AgentResponseUpdate
|
||||
{
|
||||
MessageId = "msg_workflow_text",
|
||||
RawRepresentation = new ExecutorInvokedEvent("exec_x", "invoked"),
|
||||
Contents = [new MeaiTextContent("payload from workflow event")],
|
||||
};
|
||||
|
||||
var events = new List<ResponseStreamEvent>();
|
||||
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream))
|
||||
{
|
||||
events.Add(evt);
|
||||
}
|
||||
|
||||
// Content path must have been taken: a text-delta event must be emitted from the payload.
|
||||
Assert.Contains(events, e => e is ResponseTextDeltaEvent);
|
||||
Assert.IsType<ResponseCompletedEvent>(events[^1]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConvertUpdatesToEventsAsync_WorkflowEventWithErrorContent_EmitsFailedAsync()
|
||||
{
|
||||
var (stream, _) = CreateTestStream();
|
||||
var update = new AgentResponseUpdate
|
||||
{
|
||||
RawRepresentation = new ExecutorFailedEvent("exec_y", new InvalidOperationException("boom")),
|
||||
Contents = [new ErrorContent("boom")],
|
||||
};
|
||||
|
||||
var events = new List<ResponseStreamEvent>();
|
||||
await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync(ToAsync(new[] { update }), stream))
|
||||
{
|
||||
events.Add(evt);
|
||||
}
|
||||
|
||||
// ErrorContent should drive a failed event rather than being swallowed by the workflow branch.
|
||||
Assert.Contains(events, e => e is ResponseFailedEvent);
|
||||
}
|
||||
|
||||
private sealed class RawToolCallContent : ToolCallContent
|
||||
{
|
||||
public RawToolCallContent(string callId) : base(callId) { }
|
||||
}
|
||||
|
||||
private static async IAsyncEnumerable<T> ToAsync<T>(IEnumerable<T> source)
|
||||
{
|
||||
foreach (var item in source)
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hyperlight.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Integration tests that exercise a real Hyperlight sandbox. Gated by the
|
||||
/// <c>HYPERLIGHT_PYTHON_GUEST_PATH</c> environment variable: when not set these
|
||||
/// tests are skipped.
|
||||
/// </summary>
|
||||
public sealed class CodeActEndToEndTests
|
||||
{
|
||||
private static readonly AIAgent s_mockAgent = new Mock<AIAgent>().Object;
|
||||
|
||||
private static string? GuestPath => Environment.GetEnvironmentVariable("HYPERLIGHT_PYTHON_GUEST_PATH");
|
||||
|
||||
private static string SkipReason => "HYPERLIGHT_PYTHON_GUEST_PATH is not set; skipping hyperlight integration test.";
|
||||
|
||||
[Fact]
|
||||
public async Task ExecuteCode_PythonPrint_ReturnsStdoutAsync()
|
||||
{
|
||||
// Skip if no guest available.
|
||||
if (string.IsNullOrWhiteSpace(GuestPath))
|
||||
{
|
||||
Assert.Skip(SkipReason);
|
||||
return;
|
||||
}
|
||||
|
||||
// Arrange
|
||||
using var provider = new HyperlightCodeActProvider(
|
||||
HyperlightCodeActProviderOptions.CreateForWasm(GuestPath!));
|
||||
|
||||
var context = await provider.InvokingAsync(
|
||||
new AIContextProvider.InvokingContext(s_mockAgent, session: null, new AIContext()));
|
||||
|
||||
var executeCode = Assert.IsAssignableFrom<AIFunction>(context.Tools!.First());
|
||||
|
||||
// Act
|
||||
var rawResult = await executeCode.InvokeAsync(
|
||||
new AIFunctionArguments(new System.Collections.Generic.Dictionary<string, object?>
|
||||
{
|
||||
["code"] = "print(\"hi\")",
|
||||
}));
|
||||
|
||||
// Assert
|
||||
var json = rawResult?.ToString();
|
||||
Assert.False(string.IsNullOrWhiteSpace(json));
|
||||
using var doc = JsonDocument.Parse(json!);
|
||||
Assert.True(doc.RootElement.GetProperty("success").GetBoolean());
|
||||
Assert.Contains("hi", doc.RootElement.GetProperty("stdout").GetString()!);
|
||||
Assert.Equal(0, doc.RootElement.GetProperty("exit_code").GetInt32());
|
||||
}
|
||||
}
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Hyperlight\Microsoft.Agents.AI.Hyperlight.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,62 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hyperlight.UnitTests;
|
||||
|
||||
public sealed class ApprovalComputationTests
|
||||
{
|
||||
[Fact]
|
||||
public void AlwaysRequire_ReturnsTrueWithNoTools()
|
||||
{
|
||||
// Act / Assert
|
||||
Assert.True(HyperlightCodeActProvider.ComputeApprovalRequired(
|
||||
CodeActApprovalMode.AlwaysRequire,
|
||||
tools: []));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AlwaysRequire_ReturnsTrueEvenWithoutApprovalTool()
|
||||
{
|
||||
// Arrange
|
||||
var tool = AIFunctionFactory.Create(() => "ok", name: "t");
|
||||
|
||||
// Act / Assert
|
||||
Assert.True(HyperlightCodeActProvider.ComputeApprovalRequired(
|
||||
CodeActApprovalMode.AlwaysRequire,
|
||||
tools: [tool]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NeverRequire_NoTools_ReturnsFalse()
|
||||
{
|
||||
Assert.False(HyperlightCodeActProvider.ComputeApprovalRequired(
|
||||
CodeActApprovalMode.NeverRequire,
|
||||
tools: []));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NeverRequire_NoApprovalRequiredTool_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var tool = AIFunctionFactory.Create(() => "ok", name: "t");
|
||||
|
||||
// Act / Assert
|
||||
Assert.False(HyperlightCodeActProvider.ComputeApprovalRequired(
|
||||
CodeActApprovalMode.NeverRequire,
|
||||
tools: [tool]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NeverRequire_WithApprovalRequiredTool_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
var tool = AIFunctionFactory.Create(() => "ok", name: "t");
|
||||
var wrapped = new ApprovalRequiredAIFunction(tool);
|
||||
|
||||
// Act / Assert
|
||||
Assert.True(HyperlightCodeActProvider.ComputeApprovalRequired(
|
||||
CodeActApprovalMode.NeverRequire,
|
||||
tools: [wrapped]));
|
||||
}
|
||||
}
|
||||
-173
@@ -1,173 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hyperlight.UnitTests;
|
||||
|
||||
public sealed class HyperlightCodeActProviderTests
|
||||
{
|
||||
[Fact]
|
||||
public void Ctor_NullOptions_UsesDefaults()
|
||||
{
|
||||
// Act
|
||||
using var provider = new HyperlightCodeActProvider();
|
||||
|
||||
// Assert
|
||||
Assert.Empty(provider.GetTools());
|
||||
Assert.Empty(provider.GetFileMounts());
|
||||
Assert.Empty(provider.GetAllowedDomains());
|
||||
Assert.Equal([HyperlightCodeActProvider.FixedStateKey], provider.StateKeys);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void StateKeys_IsFixedSingleKey()
|
||||
{
|
||||
// Arrange
|
||||
using var provider = new HyperlightCodeActProvider(new HyperlightCodeActProviderOptions());
|
||||
|
||||
// Act / Assert
|
||||
Assert.Equal([HyperlightCodeActProvider.FixedStateKey], provider.StateKeys);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tools_Crud_AddReplacesByName()
|
||||
{
|
||||
// Arrange
|
||||
using var provider = new HyperlightCodeActProvider(new HyperlightCodeActProviderOptions());
|
||||
var first = AIFunctionFactory.Create(() => "a", name: "t");
|
||||
var replacement = AIFunctionFactory.Create(() => "b", name: "t");
|
||||
|
||||
// Act
|
||||
provider.AddTools(first);
|
||||
provider.AddTools(replacement);
|
||||
|
||||
// Assert
|
||||
var tools = provider.GetTools();
|
||||
Assert.Single(tools);
|
||||
Assert.Same(replacement, tools[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tools_RemoveAndClear_Work()
|
||||
{
|
||||
// Arrange
|
||||
using var provider = new HyperlightCodeActProvider(new HyperlightCodeActProviderOptions());
|
||||
provider.AddTools(
|
||||
AIFunctionFactory.Create(() => "a", name: "a"),
|
||||
AIFunctionFactory.Create(() => "b", name: "b"));
|
||||
|
||||
// Act
|
||||
provider.RemoveTools("a");
|
||||
|
||||
// Assert
|
||||
Assert.Single(provider.GetTools());
|
||||
Assert.Equal("b", provider.GetTools()[0].Name);
|
||||
|
||||
// Act
|
||||
provider.ClearTools();
|
||||
|
||||
// Assert
|
||||
Assert.Empty(provider.GetTools());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FileMounts_Crud_ReplaceByMountPath()
|
||||
{
|
||||
// Arrange
|
||||
using var provider = new HyperlightCodeActProvider(new HyperlightCodeActProviderOptions());
|
||||
var m1 = new FileMount("/host/a", "/input/a");
|
||||
var m2 = new FileMount("/host/a-new", "/input/a");
|
||||
var m3 = new FileMount("/host/b", "/input/b");
|
||||
|
||||
// Act
|
||||
provider.AddFileMounts(m1, m3);
|
||||
provider.AddFileMounts(m2);
|
||||
|
||||
// Assert
|
||||
var mounts = provider.GetFileMounts().OrderBy(m => m.MountPath).ToArray();
|
||||
Assert.Equal(2, mounts.Length);
|
||||
Assert.Same(m2, mounts[0]);
|
||||
Assert.Same(m3, mounts[1]);
|
||||
|
||||
// Act
|
||||
provider.RemoveFileMounts("/input/a");
|
||||
|
||||
// Assert
|
||||
Assert.Single(provider.GetFileMounts());
|
||||
|
||||
// Act
|
||||
provider.ClearFileMounts();
|
||||
|
||||
// Assert
|
||||
Assert.Empty(provider.GetFileMounts());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllowedDomains_Crud_ReplaceByTarget()
|
||||
{
|
||||
// Arrange
|
||||
using var provider = new HyperlightCodeActProvider(new HyperlightCodeActProviderOptions());
|
||||
var d1 = new AllowedDomain("https://a", ["GET"]);
|
||||
var d2 = new AllowedDomain("https://a", ["POST"]);
|
||||
var d3 = new AllowedDomain("https://b");
|
||||
|
||||
// Act
|
||||
provider.AddAllowedDomains(d1, d3);
|
||||
provider.AddAllowedDomains(d2);
|
||||
|
||||
// Assert
|
||||
var domains = provider.GetAllowedDomains().OrderBy(d => d.Target).ToArray();
|
||||
Assert.Equal(2, domains.Length);
|
||||
Assert.Same(d2, domains[0]);
|
||||
Assert.Same(d3, domains[1]);
|
||||
|
||||
// Act
|
||||
provider.RemoveAllowedDomains("https://a");
|
||||
|
||||
// Assert
|
||||
Assert.Single(provider.GetAllowedDomains());
|
||||
|
||||
// Act
|
||||
provider.ClearAllowedDomains();
|
||||
|
||||
// Assert
|
||||
Assert.Empty(provider.GetAllowedDomains());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ctor_SeedsFromOptions()
|
||||
{
|
||||
// Arrange
|
||||
var tool = AIFunctionFactory.Create(() => "x", name: "x");
|
||||
var options = new HyperlightCodeActProviderOptions
|
||||
{
|
||||
Tools = new[] { tool },
|
||||
FileMounts = new[] { new FileMount("/h", "/m") },
|
||||
AllowedDomains = new[] { new AllowedDomain("https://a") },
|
||||
};
|
||||
|
||||
// Act
|
||||
using var provider = new HyperlightCodeActProvider(options);
|
||||
|
||||
// Assert
|
||||
Assert.Single(provider.GetTools());
|
||||
Assert.Single(provider.GetFileMounts());
|
||||
Assert.Single(provider.GetAllowedDomains());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_IsIdempotentAndBlocksFurtherAddTools()
|
||||
{
|
||||
// Arrange
|
||||
var provider = new HyperlightCodeActProvider(new HyperlightCodeActProviderOptions());
|
||||
var tool = AIFunctionFactory.Create(() => "x", name: "x");
|
||||
|
||||
// Act
|
||||
provider.Dispose();
|
||||
provider.Dispose();
|
||||
|
||||
// Assert
|
||||
Assert.Throws<System.ObjectDisposedException>(() => provider.AddTools(tool));
|
||||
}
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.Agents.AI.Hyperlight.Internal;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hyperlight.UnitTests;
|
||||
|
||||
public sealed class InstructionBuilderTests
|
||||
{
|
||||
[Fact]
|
||||
public void BuildContextInstructions_HiddenTools_MentionsCallTool()
|
||||
{
|
||||
// Act
|
||||
var text = InstructionBuilder.BuildContextInstructions(toolsVisibleToModel: false);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("execute_code", text);
|
||||
Assert.Contains("call_tool", text);
|
||||
// Backend-agnostic: don't mention a specific language.
|
||||
Assert.DoesNotContain("Python", text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildContextInstructions_VisibleTools_OmitsCallTool()
|
||||
{
|
||||
// Act
|
||||
var text = InstructionBuilder.BuildContextInstructions(toolsVisibleToModel: true);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("execute_code", text);
|
||||
Assert.DoesNotContain("call_tool", text);
|
||||
Assert.DoesNotContain("Python", text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildExecuteCodeDescription_WithNoExtras_ReturnsBaseBlurbOnly()
|
||||
{
|
||||
// Act
|
||||
var text = InstructionBuilder.BuildExecuteCodeDescription(
|
||||
tools: [],
|
||||
fileMounts: [],
|
||||
allowedDomains: [],
|
||||
hasHostInputDirectory: false);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("Executes code", text);
|
||||
Assert.DoesNotContain("call_tool", text);
|
||||
Assert.DoesNotContain("Filesystem access", text);
|
||||
Assert.DoesNotContain("Outbound network access", text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildExecuteCodeDescription_WithTools_IncludesToolNames()
|
||||
{
|
||||
// Arrange
|
||||
var tool = AIFunctionFactory.Create(() => "ok", name: "fetch_docs", description: "fetch docs");
|
||||
|
||||
// Act
|
||||
var text = InstructionBuilder.BuildExecuteCodeDescription(
|
||||
tools: [tool],
|
||||
fileMounts: [],
|
||||
allowedDomains: [],
|
||||
hasHostInputDirectory: false);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("call_tool", text);
|
||||
Assert.Contains("fetch_docs", text);
|
||||
Assert.Contains("fetch docs", text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildExecuteCodeDescription_WithFilesystem_IncludesSandboxPathsOnly()
|
||||
{
|
||||
// Act
|
||||
var text = InstructionBuilder.BuildExecuteCodeDescription(
|
||||
tools: [],
|
||||
fileMounts: [new FileMount("/host/data.csv", "/input/data.csv")],
|
||||
allowedDomains: [],
|
||||
hasHostInputDirectory: true);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("Filesystem access", text);
|
||||
Assert.Contains("/input", text);
|
||||
Assert.Contains("/input/data.csv", text);
|
||||
|
||||
// Host paths must not leak to the model.
|
||||
Assert.DoesNotContain("/host/workspace", text);
|
||||
Assert.DoesNotContain("/host/data.csv", text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildExecuteCodeDescription_WithAllowedDomains_IncludesNetworkSection()
|
||||
{
|
||||
// Act
|
||||
var text = InstructionBuilder.BuildExecuteCodeDescription(
|
||||
tools: [],
|
||||
fileMounts: [],
|
||||
allowedDomains: [new AllowedDomain("https://api.github.com", new List<string> { "GET", "POST" })],
|
||||
hasHostInputDirectory: false);
|
||||
|
||||
// Assert
|
||||
Assert.Contains("Outbound network access", text);
|
||||
Assert.Contains("api.github.com", text);
|
||||
Assert.Contains("GET", text);
|
||||
Assert.Contains("POST", text);
|
||||
}
|
||||
}
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net8.0'))">
|
||||
<JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Hyperlight\Microsoft.Agents.AI.Hyperlight.csproj" />
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,85 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hyperlight.UnitTests;
|
||||
|
||||
public sealed class ProvideAIContextTests
|
||||
{
|
||||
private static readonly AIAgent s_mockAgent = new Mock<AIAgent>().Object;
|
||||
|
||||
private static AIContextProvider.InvokingContext NewInvokingContext() => new(s_mockAgent, session: null, new AIContext());
|
||||
|
||||
[Fact]
|
||||
public async Task ProvideAIContextAsync_ReturnsExecuteCodeToolAndInstructionsAsync()
|
||||
{
|
||||
// Arrange
|
||||
using var provider = new HyperlightCodeActProvider(new HyperlightCodeActProviderOptions());
|
||||
|
||||
// Act
|
||||
var context = await provider.InvokingAsync(NewInvokingContext());
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(context);
|
||||
Assert.NotNull(context!.Tools);
|
||||
var tools = context.Tools!.ToList();
|
||||
Assert.Single(tools);
|
||||
var function = Assert.IsAssignableFrom<AIFunction>(tools[0]);
|
||||
Assert.Equal("execute_code", function.Name);
|
||||
Assert.False(string.IsNullOrWhiteSpace(context.Instructions));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProvideAIContextAsync_AlwaysRequire_WrapsInApprovalRequiredAsync()
|
||||
{
|
||||
// Arrange
|
||||
using var provider = new HyperlightCodeActProvider(new HyperlightCodeActProviderOptions
|
||||
{
|
||||
ApprovalMode = CodeActApprovalMode.AlwaysRequire,
|
||||
});
|
||||
|
||||
// Act
|
||||
var context = await provider.InvokingAsync(NewInvokingContext());
|
||||
|
||||
// Assert
|
||||
_ = Assert.IsType<ApprovalRequiredAIFunction>(context!.Tools!.First());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProvideAIContextAsync_NeverRequireWithApprovalTool_WrapsInApprovalRequiredAsync()
|
||||
{
|
||||
// Arrange
|
||||
var inner = AIFunctionFactory.Create(() => "ok", name: "t");
|
||||
using var provider = new HyperlightCodeActProvider(new HyperlightCodeActProviderOptions
|
||||
{
|
||||
ApprovalMode = CodeActApprovalMode.NeverRequire,
|
||||
Tools = [new ApprovalRequiredAIFunction(inner)],
|
||||
});
|
||||
|
||||
// Act
|
||||
var context = await provider.InvokingAsync(NewInvokingContext());
|
||||
|
||||
// Assert
|
||||
_ = Assert.IsType<ApprovalRequiredAIFunction>(context!.Tools!.First());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProvideAIContextAsync_CapturesSnapshot_MutationsAfterDoNotAffectDescriptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
using var provider = new HyperlightCodeActProvider(new HyperlightCodeActProviderOptions());
|
||||
provider.AddTools(AIFunctionFactory.Create(() => "one", name: "first_tool"));
|
||||
|
||||
// Act
|
||||
var context = await provider.InvokingAsync(NewInvokingContext());
|
||||
provider.AddTools(AIFunctionFactory.Create(() => "two", name: "second_tool"));
|
||||
|
||||
// Assert — the returned execute_code description must reflect the first snapshot only.
|
||||
var function = Assert.IsAssignableFrom<AIFunction>(context!.Tools!.First());
|
||||
Assert.Contains("first_tool", function.Description);
|
||||
Assert.DoesNotContain("second_tool", function.Description);
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI.Hyperlight.Internal;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hyperlight.UnitTests;
|
||||
|
||||
public sealed class SandboxExecutorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Fingerprint_DifferentToolSets_DifferentFingerprints()
|
||||
{
|
||||
// Arrange
|
||||
var t1 = AIFunctionFactory.Create(() => "a", name: "a");
|
||||
var t2 = AIFunctionFactory.Create(() => "b", name: "b");
|
||||
|
||||
// Act
|
||||
var fpA = SandboxExecutor.RunSnapshot.ComputeFingerprint([t1], [], [], hostInputDirectory: null);
|
||||
var fpAB = SandboxExecutor.RunSnapshot.ComputeFingerprint([t1, t2], [], [], hostInputDirectory: null);
|
||||
|
||||
// Assert
|
||||
Assert.NotEqual(fpA, fpAB);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fingerprint_OrderInsensitive_OnTools()
|
||||
{
|
||||
// Arrange
|
||||
var t1 = AIFunctionFactory.Create(() => "a", name: "a");
|
||||
var t2 = AIFunctionFactory.Create(() => "b", name: "b");
|
||||
|
||||
// Act
|
||||
var fp1 = SandboxExecutor.RunSnapshot.ComputeFingerprint([t1, t2], [], [], hostInputDirectory: null);
|
||||
var fp2 = SandboxExecutor.RunSnapshot.ComputeFingerprint([t2, t1], [], [], hostInputDirectory: null);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(fp1, fp2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fingerprint_DifferentMounts_DifferentFingerprints()
|
||||
{
|
||||
// Act
|
||||
var fpEmpty = SandboxExecutor.RunSnapshot.ComputeFingerprint([], [], [], hostInputDirectory: null);
|
||||
var fpMount = SandboxExecutor.RunSnapshot.ComputeFingerprint(
|
||||
[],
|
||||
[new FileMount("/host/a", "/input/a")],
|
||||
[],
|
||||
hostInputDirectory: null);
|
||||
|
||||
// Assert
|
||||
Assert.NotEqual(fpEmpty, fpMount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fingerprint_DifferentAllowedDomains_DifferentFingerprints()
|
||||
{
|
||||
// Act
|
||||
var fp1 = SandboxExecutor.RunSnapshot.ComputeFingerprint(
|
||||
[],
|
||||
[],
|
||||
[new AllowedDomain("https://a")],
|
||||
hostInputDirectory: null);
|
||||
var fp2 = SandboxExecutor.RunSnapshot.ComputeFingerprint(
|
||||
[],
|
||||
[],
|
||||
[new AllowedDomain("https://b")],
|
||||
hostInputDirectory: null);
|
||||
|
||||
// Assert
|
||||
Assert.NotEqual(fp1, fp2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Fingerprint_DifferentHostInputDirectory_DifferentFingerprints()
|
||||
{
|
||||
// Act
|
||||
var fpNone = SandboxExecutor.RunSnapshot.ComputeFingerprint([], [], [], hostInputDirectory: null);
|
||||
var fpDir = SandboxExecutor.RunSnapshot.ComputeFingerprint([], [], [], hostInputDirectory: "/tmp/work");
|
||||
|
||||
// Assert
|
||||
Assert.NotEqual(fpNone, fpDir);
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Hyperlight.Internal;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hyperlight.UnitTests;
|
||||
|
||||
public sealed class ToolBridgeTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task InvokeAsync_PassesArgumentsAndReturnsSerializedResultAsync()
|
||||
{
|
||||
// Arrange
|
||||
static string Echo(string value) => $"echo:{value}";
|
||||
var tool = AIFunctionFactory.Create(Echo, name: "echo");
|
||||
|
||||
// Act
|
||||
var result = await ToolBridge.InvokeAsync(tool, """{"value":"hello"}""");
|
||||
|
||||
// Assert — AIFunction.InvokeAsync returns the string; ToolBridge JSON-encodes it.
|
||||
Assert.Equal("\"echo:hello\"", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_ReturnsErrorJsonOnExceptionAsync()
|
||||
{
|
||||
// Arrange
|
||||
static int Boom() => throw new InvalidOperationException("nope");
|
||||
var tool = AIFunctionFactory.Create(Boom, name: "boom");
|
||||
|
||||
// Act
|
||||
var result = await ToolBridge.InvokeAsync(tool, "{}");
|
||||
|
||||
// Assert
|
||||
using var doc = JsonDocument.Parse(result);
|
||||
Assert.True(doc.RootElement.TryGetProperty("error", out var err));
|
||||
Assert.Contains("nope", err.GetString()!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_EmptyArguments_InvokesToolWithNoArgsAsync()
|
||||
{
|
||||
// Arrange
|
||||
static string Hi() => "hi";
|
||||
var tool = AIFunctionFactory.Create(Hi, name: "hi");
|
||||
|
||||
// Act
|
||||
var result = await ToolBridge.InvokeAsync(tool, string.Empty);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("\"hi\"", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InvokeAsync_NonObjectJson_ReturnsErrorAsync()
|
||||
{
|
||||
// Arrange
|
||||
static string Hi() => "hi";
|
||||
var tool = AIFunctionFactory.Create(Hi, name: "hi");
|
||||
|
||||
// Act
|
||||
var result = await ToolBridge.InvokeAsync(tool, "[1, 2, 3]");
|
||||
|
||||
// Assert
|
||||
using var doc = JsonDocument.Parse(result);
|
||||
Assert.True(doc.RootElement.TryGetProperty("error", out _));
|
||||
}
|
||||
}
|
||||
-1
@@ -11,7 +11,6 @@
|
||||
"min_action_count": 8,
|
||||
"min_message_count": 1,
|
||||
"min_response_count": 1,
|
||||
"max_response_count": 4,
|
||||
"actions": {
|
||||
"start": [
|
||||
"conversation_create1",
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@
|
||||
"min_action_count": 6,
|
||||
"max_action_count": -1,
|
||||
"min_response_count": 2,
|
||||
"max_response_count": 9,
|
||||
"max_response_count": 8,
|
||||
"min_message_count": 4,
|
||||
"max_message_count": -1,
|
||||
"actions": {
|
||||
|
||||
+1
-4
@@ -9,10 +9,7 @@
|
||||
"validation": {
|
||||
"conversation_count": 1,
|
||||
"min_action_count": 3,
|
||||
"min_message_count": 0,
|
||||
"max_message_count": 0,
|
||||
"min_response_count": 1,
|
||||
"max_response_count": 1,
|
||||
"min_response_count": 0,
|
||||
"actions": {
|
||||
"start": [
|
||||
"set_user_input",
|
||||
|
||||
-10
@@ -1,10 +1,8 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
|
||||
using Microsoft.Agents.ObjectModel;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel;
|
||||
|
||||
@@ -29,14 +27,6 @@ public sealed class SendActivityExecutorTest(ITestOutputHelper output) : Workflo
|
||||
// Assert
|
||||
VerifyModel(model, action);
|
||||
Assert.Contains(events, e => e is MessageActivityEvent);
|
||||
|
||||
// The executor must also emit an AgentResponseEvent carrying the activity text
|
||||
// so workflow consumers (hosting runtime, UIs) can surface it as an agent turn.
|
||||
AgentResponseEvent agentEvent = Assert.Single(events.OfType<AgentResponseEvent>());
|
||||
Assert.Equal(action.Id, agentEvent.ExecutorId);
|
||||
ChatMessage message = Assert.Single(agentEvent.Response.Messages);
|
||||
Assert.Equal(ChatRole.Assistant, message.Role);
|
||||
Assert.Equal("Test activity message", message.Text);
|
||||
}
|
||||
|
||||
private SendActivity CreateModel(string displayName, string activityMessage, string? summary = null)
|
||||
|
||||
@@ -34,7 +34,7 @@ Status is grouped into these buckets:
|
||||
| `agent-framework-foundry-local` | `python/packages/foundry_local` | `beta` |
|
||||
| `agent-framework-gemini` | `python/packages/gemini` | `alpha` |
|
||||
| `agent-framework-github-copilot` | `python/packages/github_copilot` | `beta` |
|
||||
| `agent-framework-hyperlight` | `python/packages/hyperlight` | `beta` |
|
||||
| `agent-framework-hyperlight` | `python/packages/hyperlight` | `alpha` |
|
||||
| `agent-framework-lab` | `python/packages/lab` | `beta` |
|
||||
| `agent-framework-mem0` | `python/packages/mem0` | `beta` |
|
||||
| `agent-framework-ollama` | `python/packages/ollama` | `beta` |
|
||||
|
||||
@@ -79,29 +79,6 @@ from ._evaluation import (
|
||||
tool_calls_present,
|
||||
)
|
||||
from ._feature_stage import ExperimentalFeature, ReleaseCandidateFeature
|
||||
from ._harness._memory import (
|
||||
DEFAULT_MEMORY_SOURCE_ID,
|
||||
MemoryContextProvider,
|
||||
MemoryFileStore,
|
||||
MemoryIndexEntry,
|
||||
MemoryStore,
|
||||
MemoryTopicRecord,
|
||||
)
|
||||
from ._harness._mode import (
|
||||
DEFAULT_MODE_SOURCE_ID,
|
||||
AgentModeProvider,
|
||||
get_agent_mode,
|
||||
set_agent_mode,
|
||||
)
|
||||
from ._harness._todo import (
|
||||
DEFAULT_TODO_SOURCE_ID,
|
||||
TodoFileStore,
|
||||
TodoInput,
|
||||
TodoItem,
|
||||
TodoProvider,
|
||||
TodoSessionStore,
|
||||
TodoStore,
|
||||
)
|
||||
from ._mcp import MCPStdioTool, MCPStreamableHTTPTool, MCPWebsocketTool
|
||||
from ._middleware import (
|
||||
AgentContext,
|
||||
@@ -284,9 +261,6 @@ __all__ = [
|
||||
"APP_INFO",
|
||||
"COMPACTION_STATE_KEY",
|
||||
"DEFAULT_MAX_ITERATIONS",
|
||||
"DEFAULT_MEMORY_SOURCE_ID",
|
||||
"DEFAULT_MODE_SOURCE_ID",
|
||||
"DEFAULT_TODO_SOURCE_ID",
|
||||
"EXCLUDED_KEY",
|
||||
"EXCLUDE_REASON_KEY",
|
||||
"GROUP_ANNOTATION_KEY",
|
||||
@@ -311,7 +285,6 @@ __all__ = [
|
||||
"AgentMiddleware",
|
||||
"AgentMiddlewareLayer",
|
||||
"AgentMiddlewareTypes",
|
||||
"AgentModeProvider",
|
||||
"AgentResponse",
|
||||
"AgentResponseUpdate",
|
||||
"AgentRunInputs",
|
||||
@@ -382,11 +355,6 @@ __all__ = [
|
||||
"MCPStdioTool",
|
||||
"MCPStreamableHTTPTool",
|
||||
"MCPWebsocketTool",
|
||||
"MemoryContextProvider",
|
||||
"MemoryFileStore",
|
||||
"MemoryIndexEntry",
|
||||
"MemoryStore",
|
||||
"MemoryTopicRecord",
|
||||
"Message",
|
||||
"MiddlewareException",
|
||||
"MiddlewareTermination",
|
||||
@@ -428,12 +396,6 @@ __all__ = [
|
||||
"SwitchCaseEdgeGroupCase",
|
||||
"SwitchCaseEdgeGroupDefault",
|
||||
"TextSpanRegion",
|
||||
"TodoFileStore",
|
||||
"TodoInput",
|
||||
"TodoItem",
|
||||
"TodoProvider",
|
||||
"TodoSessionStore",
|
||||
"TodoStore",
|
||||
"TokenBudgetComposedStrategy",
|
||||
"TokenizerProtocol",
|
||||
"ToolMode",
|
||||
@@ -477,7 +439,6 @@ __all__ = [
|
||||
"evaluator",
|
||||
"executor",
|
||||
"function_middleware",
|
||||
"get_agent_mode",
|
||||
"get_run_context",
|
||||
"handler",
|
||||
"included_messages",
|
||||
@@ -494,7 +455,6 @@ __all__ = [
|
||||
"register_state_type",
|
||||
"resolve_agent_id",
|
||||
"response_handler",
|
||||
"set_agent_mode",
|
||||
"step",
|
||||
"tool",
|
||||
"tool_call_args_match",
|
||||
|
||||
@@ -49,7 +49,6 @@ class ExperimentalFeature(str, Enum):
|
||||
EVALS = "EVALS"
|
||||
FILE_HISTORY = "FILE_HISTORY"
|
||||
FUNCTIONAL_WORKFLOWS = "FUNCTIONAL_WORKFLOWS"
|
||||
HARNESS = "HARNESS"
|
||||
SKILLS = "SKILLS"
|
||||
TOOLBOXES = "TOOLBOXES"
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,262 +0,0 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import Any, cast
|
||||
|
||||
from .._feature_stage import ExperimentalFeature, experimental
|
||||
from .._sessions import AgentSession, ContextProvider, SessionContext
|
||||
from .._tools import tool
|
||||
|
||||
DEFAULT_MODE_SOURCE_ID = "agent_mode"
|
||||
DEFAULT_MODE_INSTRUCTIONS = (
|
||||
"## Agent Mode\n\n"
|
||||
"You can operate in different modes. Depending on the mode you are in, "
|
||||
"you will be required to follow different processes.\n\n"
|
||||
"Use the get_mode tool to check your current operating mode.\n"
|
||||
"Use the set_mode tool to switch between modes as your work progresses. "
|
||||
"Only use set_mode if the user explicitly instructs/allows you to change modes.\n\n"
|
||||
"{available_modes}\n"
|
||||
"\n"
|
||||
"You are currently operating in the {current_mode} mode.\n"
|
||||
)
|
||||
DEFAULT_MODE_DESCRIPTIONS: dict[str, str] = {
|
||||
"plan": (
|
||||
"Use this mode when analyzing requirements, breaking down tasks, and creating plans. "
|
||||
"This is the interactive mode — ask clarifying questions, discuss options, and get user approval before "
|
||||
"proceeding."
|
||||
),
|
||||
"execute": (
|
||||
"Use this mode when carrying out approved plans. Work autonomously using your best judgement — do not ask "
|
||||
"the user questions or wait for feedback. Make reasonable decisions on your own so that there is a complete, "
|
||||
"useful result when the user returns. If you encounter ambiguity, choose the most reasonable option and note "
|
||||
"your choice."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _get_mode_state(session: AgentSession, *, source_id: str) -> dict[str, Any]:
|
||||
"""Return the mutable session state used by the mode provider."""
|
||||
provider_state = session.state.get(source_id)
|
||||
if isinstance(provider_state, dict):
|
||||
return cast(dict[str, Any], provider_state)
|
||||
if provider_state is not None:
|
||||
raise TypeError(
|
||||
f"Session state for source_id {source_id!r} must be a dict, got {type(provider_state).__name__}."
|
||||
)
|
||||
state: dict[str, Any] = {}
|
||||
session.state[source_id] = state
|
||||
return state
|
||||
|
||||
|
||||
def _normalize_available_modes(available_modes: Sequence[str]) -> dict[str, str]:
|
||||
"""Return normalized mode names mapped to display names."""
|
||||
normalized_modes: dict[str, str] = {}
|
||||
for mode in available_modes:
|
||||
display_mode = mode.strip()
|
||||
normalized_mode = display_mode.lower()
|
||||
if normalized_mode in normalized_modes:
|
||||
raise ValueError(f"Duplicate mode configured: {mode}.")
|
||||
normalized_modes[normalized_mode] = display_mode
|
||||
return normalized_modes
|
||||
|
||||
|
||||
def _normalize_mode(mode: str, *, available_modes: Mapping[str, str]) -> str:
|
||||
"""Validate and normalize a mode string."""
|
||||
normalized = mode.strip().lower()
|
||||
if normalized not in available_modes:
|
||||
supported_modes = ", ".join(repr(item) for item in available_modes.values())
|
||||
raise ValueError(f"Invalid mode: {mode}. Supported modes are {supported_modes}.")
|
||||
return normalized
|
||||
|
||||
|
||||
def _resolve_default_mode(default_mode: str | None, *, available_modes: Mapping[str, str]) -> str:
|
||||
"""Resolve the default mode, falling back to the first configured mode when omitted."""
|
||||
if default_mode is None:
|
||||
return next(iter(available_modes))
|
||||
return _normalize_mode(default_mode, available_modes=available_modes)
|
||||
|
||||
|
||||
@experimental(feature_id=ExperimentalFeature.HARNESS)
|
||||
def get_agent_mode(
|
||||
session: AgentSession,
|
||||
*,
|
||||
source_id: str = DEFAULT_MODE_SOURCE_ID,
|
||||
default_mode: str | None = None,
|
||||
available_modes: Sequence[str] | None = None,
|
||||
) -> str:
|
||||
"""Get the current operating mode from session state.
|
||||
|
||||
Args:
|
||||
session: The agent session to read the mode from.
|
||||
|
||||
Keyword Args:
|
||||
source_id: Unique source ID for the provider state.
|
||||
default_mode: Initial mode used when no mode is stored yet. When omitted, the first entry of
|
||||
``available_modes`` is used.
|
||||
available_modes: Supported modes to validate against. Defaults to the built-in modes.
|
||||
|
||||
Returns:
|
||||
The current mode string.
|
||||
"""
|
||||
normalized_modes = _normalize_available_modes(tuple(available_modes or DEFAULT_MODE_DESCRIPTIONS))
|
||||
normalized_default_mode = _resolve_default_mode(default_mode, available_modes=normalized_modes)
|
||||
provider_state = _get_mode_state(session, source_id=source_id)
|
||||
current_mode = provider_state.get("current_mode")
|
||||
if isinstance(current_mode, str):
|
||||
try:
|
||||
return _normalize_mode(current_mode, available_modes=normalized_modes)
|
||||
except ValueError:
|
||||
# Stored mode is no longer in the configured set (e.g. available_modes was reconfigured).
|
||||
# Fall through and reset to the default mode.
|
||||
pass
|
||||
provider_state["current_mode"] = normalized_default_mode
|
||||
return normalized_default_mode
|
||||
|
||||
|
||||
@experimental(feature_id=ExperimentalFeature.HARNESS)
|
||||
def set_agent_mode(
|
||||
session: AgentSession,
|
||||
mode: str,
|
||||
*,
|
||||
source_id: str = DEFAULT_MODE_SOURCE_ID,
|
||||
available_modes: Sequence[str] | None = None,
|
||||
) -> str:
|
||||
"""Set the current operating mode in session state.
|
||||
|
||||
Args:
|
||||
session: The agent session to update the mode in.
|
||||
mode: The new mode to set.
|
||||
|
||||
Keyword Args:
|
||||
source_id: Unique source ID for the provider state.
|
||||
available_modes: Supported modes to validate against. Defaults to the built-in modes.
|
||||
|
||||
Returns:
|
||||
The normalized mode string that was stored.
|
||||
|
||||
Raises:
|
||||
ValueError: The requested mode is not configured.
|
||||
"""
|
||||
normalized_modes = _normalize_available_modes(tuple(available_modes or DEFAULT_MODE_DESCRIPTIONS))
|
||||
normalized_mode = _normalize_mode(mode, available_modes=normalized_modes)
|
||||
provider_state = _get_mode_state(session, source_id=source_id)
|
||||
provider_state["current_mode"] = normalized_mode
|
||||
return normalized_mode
|
||||
|
||||
|
||||
@experimental(feature_id=ExperimentalFeature.HARNESS)
|
||||
class AgentModeProvider(ContextProvider):
|
||||
"""Track the agent's operating mode in session state and provide mode tools.
|
||||
|
||||
The ``AgentModeProvider`` enables agents to operate in distinct modes during long-running complex tasks.
|
||||
The current mode is persisted in the ``AgentSession`` state and is included in the instructions provided to the
|
||||
agent on each invocation.
|
||||
|
||||
The set of available modes is configurable with ``mode_descriptions``. By default, two modes are provided:
|
||||
``"plan"`` (interactive planning) and ``"execute"`` (autonomous execution).
|
||||
|
||||
This provider exposes the following tools to the agent:
|
||||
- ``set_mode``: Switch the agent's operating mode.
|
||||
- ``get_mode``: Retrieve the agent's current operating mode.
|
||||
|
||||
Public helper functions ``get_agent_mode`` and ``set_agent_mode`` allow external code to programmatically read
|
||||
and change the mode.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
source_id: str = DEFAULT_MODE_SOURCE_ID,
|
||||
*,
|
||||
default_mode: str | None = None,
|
||||
mode_descriptions: Mapping[str, str] | None = None,
|
||||
instructions: str | None = None,
|
||||
) -> None:
|
||||
"""Initialize a new agent mode provider.
|
||||
|
||||
Args:
|
||||
source_id: Unique source ID for the provider.
|
||||
|
||||
Keyword Args:
|
||||
default_mode: Initial mode used when no mode is stored yet. When omitted, the first entry of
|
||||
``mode_descriptions`` is used.
|
||||
mode_descriptions: Mapping of supported modes to descriptions of when and how to use each mode.
|
||||
instructions: Custom instructions for using the mode tools. The instructions can contain an
|
||||
``{available_modes}`` placeholder for the configured list of modes and a ``{current_mode}`` placeholder
|
||||
for the currently active mode. When omitted, the provider uses a default set of instructions.
|
||||
|
||||
Raises:
|
||||
ValueError: No modes are configured, or the default mode is not configured.
|
||||
"""
|
||||
super().__init__(source_id)
|
||||
mode_descriptions = dict(DEFAULT_MODE_DESCRIPTIONS if mode_descriptions is None else mode_descriptions)
|
||||
self._mode_display_names = _normalize_available_modes(tuple(mode_descriptions))
|
||||
if not self._mode_display_names:
|
||||
raise ValueError("mode_descriptions must contain at least one mode.")
|
||||
self.mode_descriptions = {mode.strip().lower(): description for mode, description in mode_descriptions.items()}
|
||||
self.available_modes = tuple(self._mode_display_names)
|
||||
self.default_mode = _resolve_default_mode(default_mode, available_modes=self._mode_display_names)
|
||||
self.instructions = instructions
|
||||
|
||||
def _build_instructions(self, current_mode: str) -> str:
|
||||
"""Build the mode guidance injected for the current session."""
|
||||
mode_lines = "".join(
|
||||
f'- "{self._mode_display_names[mode]}": {description}\n'
|
||||
for mode, description in self.mode_descriptions.items()
|
||||
)
|
||||
instructions = self.instructions or DEFAULT_MODE_INSTRUCTIONS
|
||||
return instructions.replace("{available_modes}", mode_lines).replace("{current_mode}", current_mode)
|
||||
|
||||
async def before_run(
|
||||
self,
|
||||
*,
|
||||
agent: Any,
|
||||
session: AgentSession,
|
||||
context: SessionContext,
|
||||
state: dict[str, Any],
|
||||
) -> None:
|
||||
"""Inject mode tools and instructions before the model runs.
|
||||
|
||||
Args:
|
||||
agent: The agent being invoked.
|
||||
session: The agent session whose state stores the current mode.
|
||||
context: The session context to receive instructions and tools.
|
||||
state: Per-provider invocation state.
|
||||
"""
|
||||
del agent, state
|
||||
current_mode = get_agent_mode(
|
||||
session,
|
||||
source_id=self.source_id,
|
||||
default_mode=self.default_mode,
|
||||
available_modes=self.available_modes,
|
||||
)
|
||||
|
||||
@tool(name="set_mode", approval_mode="never_require")
|
||||
def set_mode(mode: str) -> str:
|
||||
"""Switch the agent's operating mode."""
|
||||
normalized_mode = set_agent_mode(
|
||||
session,
|
||||
mode,
|
||||
source_id=self.source_id,
|
||||
available_modes=self.available_modes,
|
||||
)
|
||||
return json.dumps({"mode": normalized_mode, "message": f"Mode changed to '{normalized_mode}'."})
|
||||
|
||||
@tool(name="get_mode", approval_mode="never_require")
|
||||
def get_mode() -> str:
|
||||
"""Get the agent's current operating mode."""
|
||||
current_mode_value = get_agent_mode(
|
||||
session,
|
||||
source_id=self.source_id,
|
||||
default_mode=self.default_mode,
|
||||
available_modes=self.available_modes,
|
||||
)
|
||||
return json.dumps({"mode": current_mode_value})
|
||||
|
||||
context.extend_instructions(
|
||||
self.source_id,
|
||||
[self._build_instructions(current_mode)],
|
||||
)
|
||||
context.extend_tools(self.source_id, [set_mode, get_mode])
|
||||
@@ -1,549 +0,0 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import weakref
|
||||
from abc import ABC, abstractmethod
|
||||
from base64 import urlsafe_b64encode
|
||||
from collections.abc import Mapping, MutableMapping
|
||||
from pathlib import Path
|
||||
from typing import Any, ClassVar, cast
|
||||
|
||||
from .._feature_stage import ExperimentalFeature, experimental
|
||||
from .._serialization import SerializationMixin
|
||||
from .._sessions import AgentSession, ContextProvider, SessionContext
|
||||
from .._tools import tool
|
||||
from .._types import Message
|
||||
|
||||
DEFAULT_TODO_SOURCE_ID = "todo"
|
||||
DEFAULT_TODO_INSTRUCTIONS = (
|
||||
"## Todo Items\n\n"
|
||||
"You have access to a todo list for tracking work items.\n"
|
||||
"While planning, make sure that you break down complex tasks into manageable todo items "
|
||||
"and add them to the list.\n"
|
||||
"Ask questions from the user where clarification is needed to create effective todos.\n"
|
||||
"If the user provides feedback on your plan, adjust your todos accordingly by adding new items "
|
||||
"or removing irrelevant ones.\n"
|
||||
"During execution, use the todo list to keep track of what needs to be done, "
|
||||
"mark items as complete when finished, and remove any items that are no longer needed.\n"
|
||||
"When a user changes the topic or changes their mind, ensure that you update the todo list accordingly "
|
||||
"by removing irrelevant items or adding new ones as needed.\n\n"
|
||||
"Use these tools to manage your tasks:\n"
|
||||
"- Use add_todos to break down complex work into trackable items (supports adding one or many at once).\n"
|
||||
"- Use complete_todos to mark items as done when finished (supports one or many at once).\n"
|
||||
"- Use get_remaining_todos to check what work is still pending.\n"
|
||||
"- Use get_all_todos to review the full list including completed items.\n"
|
||||
"- Use remove_todos to remove items that are no longer needed (supports one or many at once)."
|
||||
)
|
||||
|
||||
|
||||
@experimental(feature_id=ExperimentalFeature.HARNESS)
|
||||
class TodoItem(SerializationMixin):
|
||||
"""Represent one todo item tracked for the current session."""
|
||||
|
||||
id: int
|
||||
title: str
|
||||
description: str | None
|
||||
is_complete: bool
|
||||
__slots__ = ("description", "id", "is_complete", "title")
|
||||
|
||||
def __init__(self, id: int, title: str, description: str | None = None, is_complete: bool = False) -> None:
|
||||
"""Initialize one todo item."""
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.description = description
|
||||
self.is_complete = is_complete
|
||||
|
||||
def to_dict(self, *, exclude: set[str] | None = None, exclude_none: bool = True) -> dict[str, Any]:
|
||||
"""Serialize the todo item for persistence."""
|
||||
del exclude
|
||||
payload = {
|
||||
"id": self.id,
|
||||
"title": self.title,
|
||||
"description": self.description,
|
||||
"is_complete": self.is_complete,
|
||||
}
|
||||
return {key: value for key, value in payload.items() if value is not None or not exclude_none}
|
||||
|
||||
@classmethod
|
||||
def from_dict(
|
||||
cls, raw_item: MutableMapping[str, Any], /, *, dependencies: MutableMapping[str, Any] | None = None
|
||||
) -> TodoItem:
|
||||
"""Parse one todo item loaded from storage."""
|
||||
del dependencies
|
||||
item_id = raw_item.get("id")
|
||||
title = raw_item.get("title")
|
||||
description = raw_item.get("description")
|
||||
is_complete = raw_item.get("is_complete", False)
|
||||
if not isinstance(item_id, int):
|
||||
raise ValueError("Todo item id must be an integer.")
|
||||
if not isinstance(title, str) or not title.strip():
|
||||
raise ValueError("Todo item title must be a non-empty string.")
|
||||
if description is not None and not isinstance(description, str):
|
||||
raise ValueError("Todo item description must be a string or null.")
|
||||
if not isinstance(is_complete, bool):
|
||||
raise ValueError("Todo item is_complete must be a boolean.")
|
||||
return cls(id=item_id, title=title, description=description, is_complete=is_complete)
|
||||
|
||||
def __eq__(self, other: object) -> bool:
|
||||
"""Return whether two todo items have the same values."""
|
||||
return isinstance(other, TodoItem) and self.to_dict() == other.to_dict()
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Return a helpful debug representation."""
|
||||
return (
|
||||
"TodoItem("
|
||||
f"id={self.id!r}, title={self.title!r}, description={self.description!r}, is_complete={self.is_complete!r})"
|
||||
)
|
||||
|
||||
|
||||
@experimental(feature_id=ExperimentalFeature.HARNESS)
|
||||
class TodoInput(SerializationMixin):
|
||||
"""Describe one todo item to create."""
|
||||
|
||||
title: str
|
||||
description: str | None
|
||||
__slots__ = ("description", "title")
|
||||
|
||||
def __init__(self, title: str, description: str | None = None) -> None:
|
||||
"""Initialize one todo input."""
|
||||
normalized_title = title.strip()
|
||||
if not normalized_title:
|
||||
raise ValueError("Todo input title must be a non-empty string.")
|
||||
if description is not None and not isinstance(description, str):
|
||||
raise ValueError("Todo input description must be a string or null.")
|
||||
self.title = normalized_title
|
||||
self.description = description
|
||||
|
||||
def to_dict(self, *, exclude: set[str] | None = None, exclude_none: bool = True) -> dict[str, Any]:
|
||||
"""Serialize the todo input."""
|
||||
del exclude
|
||||
payload = {"title": self.title, "description": self.description}
|
||||
return {key: value for key, value in payload.items() if value is not None or not exclude_none}
|
||||
|
||||
@classmethod
|
||||
def from_dict(
|
||||
cls, raw_todo: MutableMapping[str, Any], /, *, dependencies: MutableMapping[str, Any] | None = None
|
||||
) -> TodoInput:
|
||||
"""Parse one todo input loaded from tool arguments."""
|
||||
del dependencies
|
||||
title = raw_todo.get("title")
|
||||
description = raw_todo.get("description")
|
||||
if not isinstance(title, str):
|
||||
raise ValueError("Todo input title must be a string.")
|
||||
return cls(title=title, description=description)
|
||||
|
||||
|
||||
def _parse_todo_items(items_payload: list[Any], *, source_description: str) -> list[TodoItem]:
|
||||
"""Parse persisted todo item payloads with clear corruption errors."""
|
||||
items: list[TodoItem] = []
|
||||
for index, item in enumerate(items_payload):
|
||||
if not isinstance(item, Mapping):
|
||||
raise ValueError(
|
||||
f"Todo item at index {index} in {source_description} must be a mapping; got {type(item).__name__}."
|
||||
)
|
||||
items.append(TodoItem.from_dict(dict(cast(Mapping[str, Any], item))))
|
||||
return items
|
||||
|
||||
|
||||
def _coerce_todo_input(todo: TodoInput | dict[str, Any] | Any) -> TodoInput:
|
||||
"""Normalize tool-provided todo input into a TodoInput model."""
|
||||
if isinstance(todo, TodoInput):
|
||||
return todo
|
||||
if isinstance(todo, MutableMapping):
|
||||
return TodoInput.from_dict(cast(MutableMapping[str, Any], todo))
|
||||
raise ValueError("Todo input must be a TodoInput instance or JSON object.")
|
||||
|
||||
|
||||
def _safe_next_id(items: list[TodoItem], next_id: int) -> int:
|
||||
"""Clamp ``next_id`` so it cannot collide with any persisted item id."""
|
||||
return max(next_id, max((item.id for item in items), default=0) + 1)
|
||||
|
||||
|
||||
@experimental(feature_id=ExperimentalFeature.HARNESS)
|
||||
class TodoStore(ABC):
|
||||
"""Abstract backing store for session todo items."""
|
||||
|
||||
@abstractmethod
|
||||
async def load_state(self, session: AgentSession, *, source_id: str) -> tuple[list[TodoItem], int]:
|
||||
"""Load persisted todo items and the next available ID."""
|
||||
|
||||
@abstractmethod
|
||||
async def save_state(self, session: AgentSession, items: list[TodoItem], *, next_id: int, source_id: str) -> None:
|
||||
"""Persist todo items and the next available ID."""
|
||||
|
||||
async def load_items(self, session: AgentSession, *, source_id: str) -> list[TodoItem]:
|
||||
"""Load todo items for one session."""
|
||||
items, _ = await self.load_state(session, source_id=source_id)
|
||||
return items
|
||||
|
||||
|
||||
@experimental(feature_id=ExperimentalFeature.HARNESS)
|
||||
class TodoSessionStore(TodoStore):
|
||||
"""Store todo state inside ``AgentSession.state``."""
|
||||
|
||||
async def load_state(self, session: AgentSession, *, source_id: str) -> tuple[list[TodoItem], int]:
|
||||
"""Load todo state from session state."""
|
||||
provider_state_value = session.state.get(source_id)
|
||||
if provider_state_value is None:
|
||||
provider_state: dict[str, Any] = {}
|
||||
session.state[source_id] = provider_state
|
||||
elif isinstance(provider_state_value, dict):
|
||||
provider_state = cast(dict[str, Any], provider_state_value)
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Session state for source_id {source_id!r} must be a dict; got {type(provider_state_value).__name__}."
|
||||
)
|
||||
|
||||
raw_items = provider_state.get("items", [])
|
||||
if not isinstance(raw_items, list):
|
||||
raise ValueError(
|
||||
f"Session state for source_id {source_id!r} has a non-list 'items' field; "
|
||||
f"got {type(raw_items).__name__}."
|
||||
)
|
||||
raw_next_id = provider_state.get("next_id", 1)
|
||||
if not isinstance(raw_next_id, int):
|
||||
raise ValueError(
|
||||
f"Session state for source_id {source_id!r} has a non-integer 'next_id' field; "
|
||||
f"got {type(raw_next_id).__name__}."
|
||||
)
|
||||
items_payload: list[Any] = cast(Any, raw_items)
|
||||
items = _parse_todo_items(items_payload, source_description="session todo state")
|
||||
return items, _safe_next_id(items, raw_next_id)
|
||||
|
||||
async def save_state(self, session: AgentSession, items: list[TodoItem], *, next_id: int, source_id: str) -> None:
|
||||
"""Persist todo state back into session state."""
|
||||
provider_state_value = session.state.get(source_id)
|
||||
provider_state = cast(dict[str, Any], provider_state_value) if isinstance(provider_state_value, dict) else {}
|
||||
if not isinstance(provider_state_value, dict):
|
||||
session.state[source_id] = provider_state
|
||||
provider_state["items"] = [item.to_dict(exclude_none=False) for item in items]
|
||||
provider_state["next_id"] = _safe_next_id(items, next_id)
|
||||
|
||||
|
||||
@experimental(feature_id=ExperimentalFeature.HARNESS)
|
||||
class TodoFileStore(TodoStore):
|
||||
"""Store todo state in one JSON file per session and source ID."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_path: str | Path,
|
||||
*,
|
||||
kind: str = "todos",
|
||||
owner_prefix: str = "",
|
||||
owner_state_key: str | None = None,
|
||||
state_filename: str = "todos.json",
|
||||
) -> None:
|
||||
"""Initialize the file-backed todo store.
|
||||
|
||||
Args:
|
||||
base_path: Root storage directory.
|
||||
|
||||
Keyword Args:
|
||||
kind: Storage bucket name under each owner directory.
|
||||
owner_prefix: Optional prefix applied to the resolved owner ID.
|
||||
owner_state_key: Session-state key holding the logical owner ID.
|
||||
state_filename: File name used for the persisted todo state.
|
||||
"""
|
||||
self.base_path = Path(base_path)
|
||||
self.kind = kind
|
||||
self.owner_prefix = owner_prefix
|
||||
self.owner_state_key = owner_state_key
|
||||
self.state_filename = state_filename
|
||||
self._base_root = self.base_path.resolve()
|
||||
|
||||
_ENCODED_SEGMENT_PREFIX: ClassVar[str] = "~todo-"
|
||||
_WINDOWS_RESERVED_FILE_STEMS: ClassVar[frozenset[str]] = frozenset({
|
||||
"CON",
|
||||
"PRN",
|
||||
"AUX",
|
||||
"NUL",
|
||||
"COM1",
|
||||
"COM2",
|
||||
"COM3",
|
||||
"COM4",
|
||||
"COM5",
|
||||
"COM6",
|
||||
"COM7",
|
||||
"COM8",
|
||||
"COM9",
|
||||
"LPT1",
|
||||
"LPT2",
|
||||
"LPT3",
|
||||
"LPT4",
|
||||
"LPT5",
|
||||
"LPT6",
|
||||
"LPT7",
|
||||
"LPT8",
|
||||
"LPT9",
|
||||
})
|
||||
|
||||
def _get_state_path(self, session: AgentSession, *, source_id: str) -> Path:
|
||||
"""Return the JSON file path for one session and source ID."""
|
||||
session_directory = self.base_path
|
||||
if self.owner_state_key is not None:
|
||||
owner_value = session.state.get(self.owner_state_key)
|
||||
if owner_value is None:
|
||||
raise RuntimeError(
|
||||
f"TodoFileStore requires session.state[{self.owner_state_key!r}] to be set for file-backed storage."
|
||||
)
|
||||
owner_segment = self._path_segment(owner_value, label="owner")
|
||||
session_directory = session_directory / f"{self.owner_prefix}{owner_segment}" / self.kind
|
||||
session_directory = session_directory / self._path_segment(
|
||||
session.session_id, label="session_id", reject_path_separators=True
|
||||
)
|
||||
state_path = (session_directory / self._state_filename(source_id)).resolve()
|
||||
if not state_path.is_relative_to(self._base_root):
|
||||
raise ValueError(f"Todo file path escaped base directory for session_id {session.session_id!r}.")
|
||||
return state_path
|
||||
|
||||
@classmethod
|
||||
def _path_segment(cls, value: object, *, label: str, reject_path_separators: bool = False) -> str:
|
||||
"""Return a filesystem-safe path segment for user-controlled state values."""
|
||||
raw_value = str(value)
|
||||
if reject_path_separators and ("/" in raw_value or "\\" in raw_value):
|
||||
raise ValueError(f"TodoFileStore {label} must not contain path separators: {raw_value!r}")
|
||||
if cls._is_literal_path_segment_safe(raw_value):
|
||||
return raw_value
|
||||
encoded_value = urlsafe_b64encode(raw_value.encode("utf-8")).decode("ascii").rstrip("=")
|
||||
return f"{cls._ENCODED_SEGMENT_PREFIX}{encoded_value or label}"
|
||||
|
||||
@classmethod
|
||||
def _is_literal_path_segment_safe(cls, value: str) -> bool:
|
||||
"""Return whether a value can be used directly as one path segment."""
|
||||
if (
|
||||
not value
|
||||
or value.startswith(".")
|
||||
or value.endswith((" ", "."))
|
||||
or value.upper() in cls._WINDOWS_RESERVED_FILE_STEMS
|
||||
):
|
||||
return False
|
||||
if any(ord(character) < 32 for character in value):
|
||||
return False
|
||||
return all(character.isalnum() or character in "._-" for character in value)
|
||||
|
||||
def _state_filename(self, source_id: str) -> str:
|
||||
"""Return a source-specific JSON state filename."""
|
||||
state_path = Path(self.state_filename)
|
||||
source_segment = self._path_segment(source_id, label="source_id")
|
||||
if state_path.suffix:
|
||||
return f"{state_path.stem}.{source_segment}{state_path.suffix}"
|
||||
return f"{state_path.name}.{source_segment}.json"
|
||||
|
||||
async def load_state(self, session: AgentSession, *, source_id: str) -> tuple[list[TodoItem], int]:
|
||||
"""Load todo state from disk."""
|
||||
state_path = self._get_state_path(session, source_id=source_id)
|
||||
return await asyncio.to_thread(self._load_state_sync, state_path)
|
||||
|
||||
@staticmethod
|
||||
def _load_state_sync(state_path: Path) -> tuple[list[TodoItem], int]:
|
||||
"""Synchronous helper that performs the disk I/O for ``load_state``."""
|
||||
if not state_path.exists():
|
||||
return [], 1
|
||||
payload = cast(dict[str, Any], json.loads(state_path.read_text(encoding="utf-8")))
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError(f"Todo file {state_path} must contain a JSON object.")
|
||||
raw_items = payload.get("items", [])
|
||||
raw_next_id = payload.get("next_id", 1)
|
||||
if not isinstance(raw_items, list):
|
||||
raise ValueError(f"Todo file {state_path} has a non-list 'items' field.")
|
||||
if not isinstance(raw_next_id, int):
|
||||
raise ValueError(f"Todo file {state_path} has a non-integer 'next_id' field.")
|
||||
items_payload: list[Any] = cast(Any, raw_items)
|
||||
items = _parse_todo_items(items_payload, source_description=f"todo file {state_path}")
|
||||
return items, _safe_next_id(items, raw_next_id)
|
||||
|
||||
async def save_state(self, session: AgentSession, items: list[TodoItem], *, next_id: int, source_id: str) -> None:
|
||||
"""Persist todo state to disk."""
|
||||
state_path = self._get_state_path(session, source_id=source_id)
|
||||
payload = (
|
||||
json.dumps({
|
||||
"items": [item.to_dict(exclude_none=False) for item in items],
|
||||
"next_id": _safe_next_id(items, next_id),
|
||||
})
|
||||
+ "\n"
|
||||
)
|
||||
await asyncio.to_thread(self._save_state_sync, state_path, payload)
|
||||
|
||||
@staticmethod
|
||||
def _save_state_sync(state_path: Path, payload: str) -> None:
|
||||
"""Synchronous helper that atomically writes the JSON state file."""
|
||||
state_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Write to a sibling temp file then atomically replace, so a crash mid-write cannot leave
|
||||
# a truncated state file that breaks every subsequent tool call.
|
||||
temp_path = state_path.with_name(f"{state_path.name}.tmp.{os.getpid()}")
|
||||
try:
|
||||
temp_path.write_text(payload, encoding="utf-8")
|
||||
os.replace(temp_path, state_path)
|
||||
finally:
|
||||
if temp_path.exists():
|
||||
temp_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
@experimental(feature_id=ExperimentalFeature.HARNESS)
|
||||
class TodoProvider(ContextProvider):
|
||||
"""Provide todo management tools and instructions to an agent.
|
||||
|
||||
The ``TodoProvider`` enables agents to create, complete, remove, and query todo items as part of their planning
|
||||
and execution workflow. Todo state is stored in the configured ``TodoStore`` and persists across agent invocations
|
||||
within the same session. By default, state is stored in ``AgentSession.state`` with ``TodoSessionStore``; callers
|
||||
can provide ``TodoFileStore`` or another store implementation for file-backed or custom persistence.
|
||||
|
||||
This provider exposes the following tools to the agent:
|
||||
- ``add_todos``: Add one or more todo items, each with a title and optional description.
|
||||
- ``complete_todos``: Mark one or more todo items as complete by their IDs.
|
||||
- ``remove_todos``: Remove one or more todo items by their IDs.
|
||||
- ``get_remaining_todos``: Retrieve only incomplete todo items.
|
||||
- ``get_all_todos``: Retrieve all todo items, complete and incomplete.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
source_id: str = DEFAULT_TODO_SOURCE_ID,
|
||||
*,
|
||||
instructions: str | None = None,
|
||||
store: TodoStore | None = None,
|
||||
) -> None:
|
||||
"""Initialize the todo provider.
|
||||
|
||||
Args:
|
||||
source_id: Unique source ID for the provider.
|
||||
|
||||
Keyword Args:
|
||||
instructions: Optional instruction override.
|
||||
store: Optional todo store override.
|
||||
"""
|
||||
super().__init__(source_id)
|
||||
self.instructions = instructions or DEFAULT_TODO_INSTRUCTIONS
|
||||
self.store = store or TodoSessionStore()
|
||||
# WeakKeyDictionary so per-session locks are evicted automatically when the session is GC'd
|
||||
# rather than accumulating in long-running services that create many sessions.
|
||||
self._mutation_locks: weakref.WeakKeyDictionary[AgentSession, asyncio.Lock] = weakref.WeakKeyDictionary()
|
||||
|
||||
def _mutation_lock(self, session: AgentSession) -> asyncio.Lock:
|
||||
"""Return the per-session lock for read-modify-write todo operations."""
|
||||
lock = self._mutation_locks.get(session)
|
||||
if lock is None:
|
||||
lock = asyncio.Lock()
|
||||
self._mutation_locks[session] = lock
|
||||
return lock
|
||||
|
||||
async def before_run(
|
||||
self,
|
||||
*,
|
||||
agent: Any,
|
||||
session: AgentSession,
|
||||
context: SessionContext,
|
||||
state: dict[str, Any],
|
||||
) -> None:
|
||||
"""Inject todo tools and instructions before the model runs."""
|
||||
del agent, state
|
||||
|
||||
@tool(name="add_todos", approval_mode="never_require")
|
||||
async def add_todos(todos: list[dict[str, Any]]) -> str:
|
||||
"""Add one or more todo items for the current session."""
|
||||
if not todos:
|
||||
raise ValueError("todos must contain at least one item.")
|
||||
|
||||
async with self._mutation_lock(session):
|
||||
existing_items, next_id = await self.store.load_state(session, source_id=self.source_id)
|
||||
created_items: list[TodoItem] = []
|
||||
for raw_todo in todos:
|
||||
todo = _coerce_todo_input(raw_todo)
|
||||
created_item = TodoItem(
|
||||
id=next_id,
|
||||
title=todo.title,
|
||||
description=todo.description.strip() if todo.description is not None else None,
|
||||
)
|
||||
existing_items.append(created_item)
|
||||
created_items.append(created_item)
|
||||
next_id += 1
|
||||
|
||||
await self.store.save_state(session, existing_items, next_id=next_id, source_id=self.source_id)
|
||||
return json.dumps([item.to_dict(exclude_none=False) for item in created_items])
|
||||
|
||||
@tool(name="complete_todos", approval_mode="never_require")
|
||||
async def complete_todos(ids: list[int]) -> str:
|
||||
"""Mark one or more todo items as complete by ID."""
|
||||
if not ids:
|
||||
raise ValueError("ids must contain at least one todo ID.")
|
||||
|
||||
async with self._mutation_lock(session):
|
||||
items, next_id = await self.store.load_state(session, source_id=self.source_id)
|
||||
id_set = set(ids)
|
||||
completed_count = 0
|
||||
updated_items: list[TodoItem] = []
|
||||
for item in items:
|
||||
if not item.is_complete and item.id in id_set:
|
||||
updated_items.append(
|
||||
TodoItem(
|
||||
id=item.id,
|
||||
title=item.title,
|
||||
description=item.description,
|
||||
is_complete=True,
|
||||
)
|
||||
)
|
||||
completed_count += 1
|
||||
else:
|
||||
updated_items.append(item)
|
||||
|
||||
if completed_count:
|
||||
await self.store.save_state(session, updated_items, next_id=next_id, source_id=self.source_id)
|
||||
return json.dumps({"completed": completed_count})
|
||||
|
||||
@tool(name="remove_todos", approval_mode="never_require")
|
||||
async def remove_todos(ids: list[int]) -> str:
|
||||
"""Remove one or more todo items by ID."""
|
||||
if not ids:
|
||||
raise ValueError("ids must contain at least one todo ID.")
|
||||
|
||||
async with self._mutation_lock(session):
|
||||
items, next_id = await self.store.load_state(session, source_id=self.source_id)
|
||||
remaining_items = [item for item in items if item.id not in set(ids)]
|
||||
removed_count = len(items) - len(remaining_items)
|
||||
if removed_count:
|
||||
await self.store.save_state(session, remaining_items, next_id=next_id, source_id=self.source_id)
|
||||
return json.dumps({"removed": removed_count})
|
||||
|
||||
@tool(name="get_remaining_todos", approval_mode="never_require")
|
||||
async def get_remaining_todos() -> str:
|
||||
"""Retrieve only incomplete todo items for the current session."""
|
||||
items = [
|
||||
item for item in await self.store.load_items(session, source_id=self.source_id) if not item.is_complete
|
||||
]
|
||||
return json.dumps([item.to_dict(exclude_none=False) for item in items])
|
||||
|
||||
@tool(name="get_all_todos", approval_mode="never_require")
|
||||
async def get_all_todos() -> str:
|
||||
"""Retrieve all todo items for the current session."""
|
||||
items = await self.store.load_items(session, source_id=self.source_id)
|
||||
return json.dumps([item.to_dict(exclude_none=False) for item in items])
|
||||
|
||||
context.extend_instructions(self.source_id, [self.instructions])
|
||||
context.extend_tools(
|
||||
self.source_id,
|
||||
[add_todos, complete_todos, remove_todos, get_remaining_todos, get_all_todos],
|
||||
)
|
||||
current_items = await self.store.load_items(session, source_id=self.source_id)
|
||||
context.extend_messages(
|
||||
self.source_id,
|
||||
[
|
||||
Message(
|
||||
role="user",
|
||||
contents=[
|
||||
"### Current todo list\n"
|
||||
+ (
|
||||
"\n".join(
|
||||
f"- {item.id} [{'done' if item.is_complete else 'open'}] {item.title}"
|
||||
+ (f": {item.description}" if item.description else "")
|
||||
for item in current_items
|
||||
)
|
||||
or "- none yet"
|
||||
)
|
||||
],
|
||||
)
|
||||
],
|
||||
)
|
||||
@@ -22,7 +22,6 @@ import weakref
|
||||
from abc import abstractmethod
|
||||
from base64 import urlsafe_b64encode
|
||||
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, TypeAlias, TypeGuard, cast
|
||||
|
||||
@@ -95,7 +94,7 @@ def _serialize_value(value: Any) -> Any:
|
||||
if hasattr(value, "to_dict") and callable(value.to_dict):
|
||||
return value.to_dict() # pyright: ignore[reportUnknownMemberType]
|
||||
# Pydantic BaseModel support — import lazily to avoid hard dep at module level
|
||||
with suppress(ImportError):
|
||||
try:
|
||||
from pydantic import BaseModel
|
||||
|
||||
if isinstance(value, BaseModel):
|
||||
@@ -105,6 +104,8 @@ def _serialize_value(value: Any) -> Any:
|
||||
# Auto-register for round-trip deserialization
|
||||
_STATE_TYPE_REGISTRY.setdefault(type_id, value.__class__)
|
||||
return data
|
||||
except ImportError:
|
||||
pass
|
||||
if isinstance(value, list):
|
||||
return [_serialize_value(item) for item in value] # pyright: ignore[reportUnknownVariableType]
|
||||
if isinstance(value, dict):
|
||||
@@ -121,12 +122,14 @@ def _deserialize_value(value: Any) -> Any:
|
||||
if hasattr(cls, "from_dict"):
|
||||
return cls.from_dict(value) # type: ignore[union-attr]
|
||||
# Pydantic BaseModel support
|
||||
with suppress(ImportError):
|
||||
try:
|
||||
from pydantic import BaseModel
|
||||
|
||||
if issubclass(cls, BaseModel):
|
||||
data: dict[str, Any] = {str(k): v for k, v in value.items() if k != "type"} # pyright: ignore[reportUnknownVariableType, reportUnknownArgumentType]
|
||||
return cls.model_validate(data)
|
||||
except ImportError:
|
||||
pass
|
||||
if isinstance(value, list):
|
||||
return [_deserialize_value(item) for item in value] # pyright: ignore[reportUnknownVariableType]
|
||||
if isinstance(value, dict):
|
||||
|
||||
@@ -25,13 +25,20 @@ _IMPORTS = [
|
||||
"DeclarativeLoaderError",
|
||||
"DeclarativeWorkflowError",
|
||||
"DefaultHttpRequestHandler",
|
||||
"DefaultMCPToolHandler",
|
||||
"ExternalInputRequest",
|
||||
"ExternalInputResponse",
|
||||
"HttpRequestHandler",
|
||||
"HttpRequestInfo",
|
||||
"HttpRequestResult",
|
||||
"MCPToolApprovalRequest",
|
||||
"MCPToolHandler",
|
||||
"MCPToolInvocation",
|
||||
"MCPToolResult",
|
||||
"ProviderLookupError",
|
||||
"ProviderTypeMapping",
|
||||
"ToolApprovalRequest",
|
||||
"ToolApprovalResponse",
|
||||
"WorkflowFactory",
|
||||
"WorkflowState",
|
||||
]
|
||||
|
||||
@@ -8,13 +8,20 @@ from agent_framework_declarative import (
|
||||
DeclarativeLoaderError,
|
||||
DeclarativeWorkflowError,
|
||||
DefaultHttpRequestHandler,
|
||||
DefaultMCPToolHandler,
|
||||
ExternalInputRequest,
|
||||
ExternalInputResponse,
|
||||
HttpRequestHandler,
|
||||
HttpRequestInfo,
|
||||
HttpRequestResult,
|
||||
MCPToolApprovalRequest,
|
||||
MCPToolHandler,
|
||||
MCPToolInvocation,
|
||||
MCPToolResult,
|
||||
ProviderLookupError,
|
||||
ProviderTypeMapping,
|
||||
ToolApprovalRequest,
|
||||
ToolApprovalResponse,
|
||||
WorkflowFactory,
|
||||
WorkflowState,
|
||||
)
|
||||
@@ -27,13 +34,20 @@ __all__ = [
|
||||
"DeclarativeLoaderError",
|
||||
"DeclarativeWorkflowError",
|
||||
"DefaultHttpRequestHandler",
|
||||
"DefaultMCPToolHandler",
|
||||
"ExternalInputRequest",
|
||||
"ExternalInputResponse",
|
||||
"HttpRequestHandler",
|
||||
"HttpRequestInfo",
|
||||
"HttpRequestResult",
|
||||
"MCPToolApprovalRequest",
|
||||
"MCPToolHandler",
|
||||
"MCPToolInvocation",
|
||||
"MCPToolResult",
|
||||
"ProviderLookupError",
|
||||
"ProviderTypeMapping",
|
||||
"ToolApprovalRequest",
|
||||
"ToolApprovalResponse",
|
||||
"WorkflowFactory",
|
||||
"WorkflowState",
|
||||
]
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Hyperlight CodeAct namespace for optional Agent Framework connectors.
|
||||
|
||||
This module lazily re-exports objects from ``agent-framework-hyperlight``.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
from typing import Any
|
||||
|
||||
_IMPORTS: dict[str, tuple[str, str]] = {
|
||||
"AllowedDomain": ("agent_framework_hyperlight", "agent-framework-hyperlight"),
|
||||
"AllowedDomainInput": ("agent_framework_hyperlight", "agent-framework-hyperlight"),
|
||||
"FileMount": ("agent_framework_hyperlight", "agent-framework-hyperlight"),
|
||||
"FileMountInput": ("agent_framework_hyperlight", "agent-framework-hyperlight"),
|
||||
"HyperlightCodeActProvider": ("agent_framework_hyperlight", "agent-framework-hyperlight"),
|
||||
"HyperlightExecuteCodeTool": ("agent_framework_hyperlight", "agent-framework-hyperlight"),
|
||||
}
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
if name in _IMPORTS:
|
||||
import_path, package_name = _IMPORTS[name]
|
||||
try:
|
||||
return getattr(importlib.import_module(import_path), name)
|
||||
except ModuleNotFoundError as exc:
|
||||
raise ModuleNotFoundError(
|
||||
f"The package {package_name} is required to use `{name}`. "
|
||||
f"Please use `pip install {package_name}`, or update your requirements.txt or pyproject.toml file."
|
||||
) from exc
|
||||
raise AttributeError(f"Module `hyperlight` has no attribute {name}.")
|
||||
|
||||
|
||||
def __dir__() -> list[str]:
|
||||
return list(_IMPORTS.keys())
|
||||
@@ -48,7 +48,6 @@ all = [
|
||||
"agent-framework-foundry",
|
||||
"agent-framework-foundry-local",
|
||||
"agent-framework-github-copilot; python_version >= '3.11'",
|
||||
"agent-framework-hyperlight; ((sys_platform == 'linux' and platform_machine == 'x86_64') or (sys_platform == 'win32' and platform_machine == 'AMD64')) and python_version < '3.14'",
|
||||
"agent-framework-lab",
|
||||
"agent-framework-mem0",
|
||||
"agent-framework-ollama",
|
||||
|
||||
@@ -1,770 +0,0 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from collections.abc import Mapping, Sequence
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_framework import (
|
||||
DEFAULT_MEMORY_SOURCE_ID,
|
||||
Agent,
|
||||
AgentSession,
|
||||
ChatResponse,
|
||||
Content,
|
||||
ExperimentalFeature,
|
||||
FileHistoryProvider,
|
||||
MemoryContextProvider,
|
||||
MemoryFileStore,
|
||||
MemoryIndexEntry,
|
||||
MemoryStore,
|
||||
MemoryTopicRecord,
|
||||
Message,
|
||||
)
|
||||
|
||||
|
||||
def _tool_by_name(tools: list[object], name: str) -> object:
|
||||
"""Return the tool with the requested name from a prepared tool list."""
|
||||
for tool in tools:
|
||||
if getattr(tool, "name", None) == name:
|
||||
return tool
|
||||
raise AssertionError(f"Tool {name!r} was not found.")
|
||||
|
||||
|
||||
class _MemoryHarnessClient:
|
||||
"""Deterministic chat client used by the memory harness tests."""
|
||||
|
||||
additional_properties: dict[str, Any]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
extraction_payload: dict[str, Any] | None = None,
|
||||
consolidation_payload: dict[str, Any] | None = None,
|
||||
default_text: str = "Assistant reply.",
|
||||
) -> None:
|
||||
self.additional_properties = {}
|
||||
self.extraction_payload = extraction_payload or {
|
||||
"memories": [
|
||||
{
|
||||
"topic": "preferences",
|
||||
"memory": "Prefers concise answers.",
|
||||
}
|
||||
]
|
||||
}
|
||||
self.consolidation_payload = consolidation_payload or {
|
||||
"summary": "Prefers concise answers.",
|
||||
"memories": ["Prefers concise answers."],
|
||||
}
|
||||
self.default_text = default_text
|
||||
self.calls: list[str] = []
|
||||
|
||||
async def get_response(
|
||||
self,
|
||||
messages: Sequence[Message],
|
||||
*,
|
||||
stream: bool = False,
|
||||
options: Mapping[str, Any] | None = None,
|
||||
compaction_strategy: object | None = None,
|
||||
tokenizer: object | None = None,
|
||||
function_invocation_kwargs: Mapping[str, Any] | None = None,
|
||||
client_kwargs: Mapping[str, Any] | None = None,
|
||||
) -> ChatResponse[Any]:
|
||||
del options, compaction_strategy, tokenizer, function_invocation_kwargs, client_kwargs
|
||||
assert not stream
|
||||
system_text = messages[0].text if messages and messages[0].role == "system" else ""
|
||||
if "extract durable memory candidates" in system_text.lower():
|
||||
self.calls.append("extract")
|
||||
return ChatResponse(messages=[Message(role="assistant", contents=[json.dumps(self.extraction_payload)])])
|
||||
if "consolidate one topic memory file" in system_text.lower():
|
||||
self.calls.append("consolidate")
|
||||
return ChatResponse(messages=[Message(role="assistant", contents=[json.dumps(self.consolidation_payload)])])
|
||||
self.calls.append("agent")
|
||||
return ChatResponse(messages=[Message(role="assistant", contents=[self.default_text])])
|
||||
|
||||
|
||||
def test_memory_index_entry_round_trips_and_trims_pointer_lines() -> None:
|
||||
"""Memory index entries should preserve value equality and trim pointer lines."""
|
||||
raw_entry = {
|
||||
"topic": "Architecture Decisions",
|
||||
"slug": "architecture-decisions",
|
||||
"summary": (
|
||||
"PostgreSQL was chosen because it keeps the relational model while supporting flexible JSONB fields."
|
||||
),
|
||||
"updated_at": "2026-04-21T10:00:00+00:00",
|
||||
}
|
||||
|
||||
entry = MemoryIndexEntry.from_dict(raw_entry)
|
||||
|
||||
assert entry == MemoryIndexEntry(**raw_entry)
|
||||
assert entry.to_dict() == raw_entry
|
||||
assert len(entry.to_pointer_line(max_length=80)) <= 80
|
||||
assert "MemoryIndexEntry(" in repr(entry)
|
||||
|
||||
|
||||
def test_memory_topic_record_round_trips_through_dict_and_markdown() -> None:
|
||||
"""Topic memory records should preserve their structured content and markdown form."""
|
||||
raw_record = {
|
||||
"topic": "preferences",
|
||||
"slug": "preferences",
|
||||
"summary": "Prefers concise answers.",
|
||||
"memories": ["Prefers concise answers.", "Prefers aisle seats."],
|
||||
"updated_at": "2026-04-21T10:05:00+00:00",
|
||||
"session_ids": ["session-1", "session-2"],
|
||||
}
|
||||
|
||||
record = MemoryTopicRecord.from_dict(raw_record)
|
||||
reparsed_record = MemoryTopicRecord.from_markdown(record.to_markdown())
|
||||
|
||||
assert record == MemoryTopicRecord(**raw_record)
|
||||
assert record.to_dict() == raw_record
|
||||
assert reparsed_record == record
|
||||
assert "MemoryTopicRecord(" in repr(record)
|
||||
|
||||
|
||||
async def test_memory_file_store_writes_topics_index_state_and_transcripts(tmp_path) -> None:
|
||||
"""The file-backed memory store should manage topics, ``MEMORY.md``, state, and transcript search."""
|
||||
store = MemoryFileStore(
|
||||
tmp_path,
|
||||
kind="memories",
|
||||
owner_prefix="user_",
|
||||
owner_state_key="owner_id",
|
||||
dumps=lambda value: json.dumps(value, separators=(",", ":"), sort_keys=True),
|
||||
loads=json.loads,
|
||||
)
|
||||
session = AgentSession(session_id="session-1")
|
||||
session.state["owner_id"] = "alice"
|
||||
updated_at = datetime(2026, 4, 21, tzinfo=timezone.utc).replace(microsecond=0).isoformat()
|
||||
|
||||
preferences_record = MemoryTopicRecord(
|
||||
topic="preferences",
|
||||
summary="Prefers concise answers.",
|
||||
memories=["Prefers concise answers.", "Prefers aisle seats."],
|
||||
updated_at=updated_at,
|
||||
session_ids=["session-1"],
|
||||
)
|
||||
travel_record = MemoryTopicRecord(
|
||||
topic="travel",
|
||||
summary="Planning a Norway trip.",
|
||||
memories=["Visit Oslo in June."],
|
||||
updated_at=updated_at,
|
||||
session_ids=["session-1"],
|
||||
)
|
||||
|
||||
store.write_topic(session, preferences_record, source_id=DEFAULT_MEMORY_SOURCE_ID)
|
||||
store.write_topic(session, travel_record, source_id=DEFAULT_MEMORY_SOURCE_ID)
|
||||
entries = store.rebuild_index(
|
||||
session,
|
||||
source_id=DEFAULT_MEMORY_SOURCE_ID,
|
||||
line_limit=200,
|
||||
line_length=150,
|
||||
)
|
||||
|
||||
assert [entry.topic for entry in entries] == ["preferences", "travel"]
|
||||
assert "preferences" in store.get_index_text(
|
||||
session,
|
||||
source_id=DEFAULT_MEMORY_SOURCE_ID,
|
||||
line_limit=200,
|
||||
line_length=150,
|
||||
)
|
||||
|
||||
assert store.read_state(session, source_id=DEFAULT_MEMORY_SOURCE_ID) == {
|
||||
"last_consolidated_at": None,
|
||||
"sessions_since_consolidation": [],
|
||||
}
|
||||
store.write_state(
|
||||
session,
|
||||
{
|
||||
"last_consolidated_at": updated_at,
|
||||
"sessions_since_consolidation": ["session-1"],
|
||||
},
|
||||
source_id=DEFAULT_MEMORY_SOURCE_ID,
|
||||
)
|
||||
assert store.read_state(
|
||||
session,
|
||||
source_id=DEFAULT_MEMORY_SOURCE_ID,
|
||||
)["sessions_since_consolidation"] == ["session-1"]
|
||||
|
||||
history_provider = FileHistoryProvider(
|
||||
store.get_transcripts_directory(session, source_id=DEFAULT_MEMORY_SOURCE_ID),
|
||||
dumps=lambda value: json.dumps(value, separators=(",", ":"), sort_keys=True),
|
||||
loads=json.loads,
|
||||
)
|
||||
await history_provider.save_messages(
|
||||
session.session_id,
|
||||
[
|
||||
Message(role="user", contents=["I prefer aisle seats."]),
|
||||
Message(role="assistant", contents=["Recorded."]),
|
||||
],
|
||||
)
|
||||
|
||||
assert store.search_transcripts(session, source_id=DEFAULT_MEMORY_SOURCE_ID, query="aisle") == [
|
||||
{
|
||||
"session_id": "session-1",
|
||||
"line_number": 1,
|
||||
"role": "user",
|
||||
"text": "I prefer aisle seats.",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_memory_file_store_rejects_owner_path_traversal(tmp_path) -> None:
|
||||
"""Owner IDs with path traversal segments should not escape ``base_path``."""
|
||||
session = AgentSession(session_id="session-1")
|
||||
session.state["owner_id"] = "../escape"
|
||||
store = MemoryFileStore(tmp_path, owner_state_key="owner_id")
|
||||
record = MemoryTopicRecord(
|
||||
topic="preferences",
|
||||
summary="Prefers concise answers.",
|
||||
memories=["Prefers concise answers."],
|
||||
updated_at=datetime(2026, 4, 21, tzinfo=timezone.utc).replace(microsecond=0).isoformat(),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="path traversal"):
|
||||
store.write_topic(session, record, source_id=DEFAULT_MEMORY_SOURCE_ID)
|
||||
|
||||
assert not (tmp_path.parent / "escape").exists()
|
||||
|
||||
|
||||
async def test_memory_file_store_namespaces_topics_state_and_transcripts_by_source_id(tmp_path) -> None:
|
||||
"""Providers sharing one file store should not collide when they use different source IDs."""
|
||||
session = AgentSession(session_id="session-1")
|
||||
session.state["owner_id"] = "alice"
|
||||
store = MemoryFileStore(
|
||||
tmp_path,
|
||||
owner_state_key="owner_id",
|
||||
dumps=lambda value: json.dumps(value, separators=(",", ":"), sort_keys=True),
|
||||
loads=json.loads,
|
||||
)
|
||||
updated_at = datetime(2026, 4, 21, tzinfo=timezone.utc).replace(microsecond=0).isoformat()
|
||||
|
||||
store.write_topic(
|
||||
session,
|
||||
MemoryTopicRecord(
|
||||
topic="preferences",
|
||||
summary="Source A summary.",
|
||||
memories=["Source A memory."],
|
||||
updated_at=updated_at,
|
||||
),
|
||||
source_id="source-a",
|
||||
)
|
||||
store.write_topic(
|
||||
session,
|
||||
MemoryTopicRecord(
|
||||
topic="preferences",
|
||||
summary="Source B summary.",
|
||||
memories=["Source B memory."],
|
||||
updated_at=updated_at,
|
||||
),
|
||||
source_id="source-b",
|
||||
)
|
||||
store.write_state(
|
||||
session, {"last_consolidated_at": updated_at, "sessions_since_consolidation": ["a"]}, source_id="source-a"
|
||||
)
|
||||
store.write_state(
|
||||
session, {"last_consolidated_at": None, "sessions_since_consolidation": ["b"]}, source_id="source-b"
|
||||
)
|
||||
|
||||
await FileHistoryProvider(store.get_transcripts_directory(session, source_id="source-a")).save_messages(
|
||||
"session-1", [Message(role="user", contents=["Source A transcript."])]
|
||||
)
|
||||
await FileHistoryProvider(store.get_transcripts_directory(session, source_id="source-b")).save_messages(
|
||||
"session-1", [Message(role="user", contents=["Source B transcript."])]
|
||||
)
|
||||
|
||||
assert store.get_topic(session, source_id="source-a", topic="preferences").memories == ["Source A memory."]
|
||||
assert store.get_topic(session, source_id="source-b", topic="preferences").memories == ["Source B memory."]
|
||||
assert store.read_state(session, source_id="source-a")["sessions_since_consolidation"] == ["a"]
|
||||
assert store.read_state(session, source_id="source-b")["sessions_since_consolidation"] == ["b"]
|
||||
assert (
|
||||
store.search_transcripts(session, source_id="source-a", query="transcript")[0]["text"] == "Source A transcript."
|
||||
)
|
||||
assert (
|
||||
store.search_transcripts(session, source_id="source-b", query="transcript")[0]["text"] == "Source B transcript."
|
||||
)
|
||||
|
||||
|
||||
async def test_memory_context_provider_does_not_rewrite_unchanged_index(tmp_path) -> None:
|
||||
"""A second before-run pass with unchanged memories should preserve ``MEMORY.md`` mtime."""
|
||||
session = AgentSession(session_id="session-1")
|
||||
session.state["owner_id"] = "alice"
|
||||
store = MemoryFileStore(tmp_path, owner_state_key="owner_id")
|
||||
agent = Agent(
|
||||
client=_MemoryHarnessClient(),
|
||||
context_providers=[MemoryContextProvider(store=store)],
|
||||
default_options={"store": False},
|
||||
)
|
||||
|
||||
await agent._prepare_session_and_messages( # type: ignore[reportPrivateUsage]
|
||||
session=session,
|
||||
input_messages=[Message(role="user", contents=["Current question"])],
|
||||
)
|
||||
index_path = next(tmp_path.rglob("MEMORY.md"))
|
||||
first_mtime_ns = index_path.stat().st_mtime_ns
|
||||
|
||||
await agent._prepare_session_and_messages( # type: ignore[reportPrivateUsage]
|
||||
session=session,
|
||||
input_messages=[Message(role="user", contents=["Current question"])],
|
||||
)
|
||||
|
||||
assert index_path.stat().st_mtime_ns == first_mtime_ns
|
||||
|
||||
|
||||
async def test_memory_context_provider_tools_and_automation(tmp_path) -> None:
|
||||
"""The memory provider should expose tools and automate extraction plus consolidation."""
|
||||
session = AgentSession(session_id="session-1")
|
||||
session.state["owner_id"] = "alice"
|
||||
store = MemoryFileStore(
|
||||
tmp_path,
|
||||
kind="memories",
|
||||
owner_prefix="user_",
|
||||
owner_state_key="owner_id",
|
||||
dumps=lambda value: json.dumps(value, separators=(",", ":"), sort_keys=True),
|
||||
loads=json.loads,
|
||||
)
|
||||
provider = MemoryContextProvider(
|
||||
store=store,
|
||||
consolidation_min_sessions=1,
|
||||
consolidation_interval=timedelta(0),
|
||||
)
|
||||
agent = Agent(
|
||||
client=_MemoryHarnessClient(),
|
||||
context_providers=[provider],
|
||||
default_options={"store": False},
|
||||
)
|
||||
|
||||
_, options = await agent._prepare_session_and_messages( # type: ignore[reportPrivateUsage]
|
||||
session=session,
|
||||
input_messages=[Message(role="user", contents=["Remember this."])],
|
||||
)
|
||||
tools = options["tools"]
|
||||
assert isinstance(tools, list)
|
||||
|
||||
write_memory = _tool_by_name(tools, "write_memory")
|
||||
list_memory_topics = _tool_by_name(tools, "list_memory_topics")
|
||||
search_memory_transcripts = _tool_by_name(tools, "search_memory_transcripts")
|
||||
consolidate_memories = _tool_by_name(tools, "consolidate_memories")
|
||||
|
||||
write_result = await write_memory.invoke(arguments={"topic": "travel", "memory": "Visit Oslo in June."})
|
||||
created_topic = json.loads(write_result[0].text)
|
||||
assert created_topic["topic"] == "travel"
|
||||
|
||||
list_result = await list_memory_topics.invoke()
|
||||
assert [entry["topic"] for entry in json.loads(list_result[0].text)] == ["travel"]
|
||||
|
||||
await agent.run("Please remember that I prefer concise answers.", session=session)
|
||||
|
||||
serialized_session = session.to_dict()
|
||||
assert serialized_session["state"][DEFAULT_MEMORY_SOURCE_ID] == {"owner_id": "alice"}
|
||||
|
||||
preferences_topic = store.get_topic(session, source_id=DEFAULT_MEMORY_SOURCE_ID, topic="preferences")
|
||||
assert preferences_topic.summary == "Prefers concise answers."
|
||||
assert preferences_topic.memories == ["Prefers concise answers."]
|
||||
|
||||
transcript_search_result = await search_memory_transcripts.invoke(arguments={"query": "concise", "limit": 5})
|
||||
search_payload = json.loads(transcript_search_result[0].text)
|
||||
assert search_payload[0]["role"] == "user"
|
||||
assert "concise answers" in search_payload[0]["text"]
|
||||
|
||||
consolidate_result = await consolidate_memories.invoke()
|
||||
assert json.loads(consolidate_result[0].text)["consolidated_topics"] >= 1
|
||||
|
||||
|
||||
async def test_memory_context_provider_injects_recent_turns(tmp_path) -> None:
|
||||
"""The memory provider should inject only the configured recent transcript turns."""
|
||||
session = AgentSession(session_id="session-1")
|
||||
session.state["owner_id"] = "alice"
|
||||
store = MemoryFileStore(
|
||||
tmp_path,
|
||||
kind="memories",
|
||||
owner_prefix="user_",
|
||||
owner_state_key="owner_id",
|
||||
dumps=lambda value: json.dumps(value, separators=(",", ":"), sort_keys=True),
|
||||
loads=json.loads,
|
||||
)
|
||||
provider = MemoryContextProvider(store=store, recent_turns=2)
|
||||
provider_state = store.export_provider_state(session)
|
||||
await provider.save_messages(
|
||||
session.session_id,
|
||||
[
|
||||
Message(role="user", contents=["First question"]),
|
||||
Message(role="assistant", contents=["First answer"]),
|
||||
Message(role="user", contents=["Second question"]),
|
||||
Message(role="assistant", contents=["Second answer"]),
|
||||
Message(role="user", contents=["Third question"]),
|
||||
Message(role="assistant", contents=["Third answer"]),
|
||||
],
|
||||
state=provider_state,
|
||||
)
|
||||
agent = Agent(
|
||||
client=_MemoryHarnessClient(),
|
||||
context_providers=[provider],
|
||||
default_options={"store": False},
|
||||
)
|
||||
|
||||
session_context, _ = await agent._prepare_session_and_messages( # type: ignore[reportPrivateUsage]
|
||||
session=session,
|
||||
input_messages=[Message(role="user", contents=["Current question"])],
|
||||
)
|
||||
prepared_messages = session_context.get_messages(include_input=True)
|
||||
|
||||
assert [message.text for message in prepared_messages[:4]] == [
|
||||
"Second question",
|
||||
"Second answer",
|
||||
"Third question",
|
||||
"Third answer",
|
||||
]
|
||||
assert "First question" not in [message.text for message in prepared_messages]
|
||||
assert "### MEMORY.md" in prepared_messages[4].text
|
||||
assert prepared_messages[-1].text == "Current question"
|
||||
|
||||
|
||||
async def test_memory_context_provider_recent_turns_can_skip_tool_call_groups(tmp_path) -> None:
|
||||
"""Recent-turn loading should follow compaction grouping and optionally skip tool-call groups."""
|
||||
session = AgentSession(session_id="session-1")
|
||||
session.state["owner_id"] = "alice"
|
||||
store = MemoryFileStore(
|
||||
tmp_path,
|
||||
kind="memories",
|
||||
owner_prefix="user_",
|
||||
owner_state_key="owner_id",
|
||||
dumps=lambda value: json.dumps(value, separators=(",", ":"), sort_keys=True),
|
||||
loads=json.loads,
|
||||
)
|
||||
provider_state = store.export_provider_state(session)
|
||||
await MemoryContextProvider(store=store).save_messages(
|
||||
session.session_id,
|
||||
[
|
||||
Message(role="user", contents=["First question"]),
|
||||
Message(role="assistant", contents=["First answer"]),
|
||||
Message(role="user", contents=["Second question"]),
|
||||
Message(role="assistant", contents=[Content.from_text_reasoning(text="Let me check that.")]),
|
||||
Message(
|
||||
role="assistant",
|
||||
contents=[
|
||||
Content.from_function_call(call_id="call-1", name="lookup_answer", arguments='{"topic":"second"}')
|
||||
],
|
||||
),
|
||||
Message(role="tool", contents=[Content.from_function_result(call_id="call-1", result="Tool result")]),
|
||||
Message(role="assistant", contents=["Second final answer"]),
|
||||
Message(role="user", contents=["Third question"]),
|
||||
Message(role="assistant", contents=["Third answer"]),
|
||||
],
|
||||
state=provider_state,
|
||||
)
|
||||
with_tools_agent = Agent(
|
||||
client=_MemoryHarnessClient(),
|
||||
context_providers=[MemoryContextProvider(store=store, recent_turns=2, load_tool_turns=True)],
|
||||
default_options={"store": False},
|
||||
)
|
||||
without_tools_agent = Agent(
|
||||
client=_MemoryHarnessClient(),
|
||||
context_providers=[MemoryContextProvider(store=store, recent_turns=2, load_tool_turns=False)],
|
||||
default_options={"store": False},
|
||||
)
|
||||
|
||||
with_tools_context, _ = await with_tools_agent._prepare_session_and_messages( # type: ignore[reportPrivateUsage]
|
||||
session=session,
|
||||
input_messages=[Message(role="user", contents=["Current question"])],
|
||||
)
|
||||
without_tools_context, _ = await without_tools_agent._prepare_session_and_messages( # type: ignore[reportPrivateUsage]
|
||||
session=session,
|
||||
input_messages=[Message(role="user", contents=["Current question"])],
|
||||
)
|
||||
with_tools_messages = with_tools_context.get_messages(include_input=True)
|
||||
without_tools_messages = without_tools_context.get_messages(include_input=True)
|
||||
|
||||
assert [message.text for message in without_tools_messages[:4]] == [
|
||||
"Second question",
|
||||
"Second final answer",
|
||||
"Third question",
|
||||
"Third answer",
|
||||
]
|
||||
assert not any(message.role == "tool" for message in without_tools_messages)
|
||||
assert not any(
|
||||
any(content.type == "function_call" for content in message.contents) for message in without_tools_messages
|
||||
)
|
||||
assert not any(
|
||||
any(content.type == "text_reasoning" for content in message.contents) for message in without_tools_messages
|
||||
)
|
||||
|
||||
assert with_tools_messages[0].text == "Second question"
|
||||
assert with_tools_messages[1].contents[0].type == "text_reasoning"
|
||||
assert with_tools_messages[2].contents[0].type == "function_call"
|
||||
assert with_tools_messages[3].role == "tool"
|
||||
assert with_tools_messages[3].contents[0].type == "function_result"
|
||||
assert with_tools_messages[4].text == "Second final answer"
|
||||
|
||||
|
||||
async def test_memory_context_provider_uses_explicit_consolidation_client(tmp_path) -> None:
|
||||
"""The memory provider should use the explicit consolidation client when one is configured."""
|
||||
session = AgentSession(session_id="session-1")
|
||||
session.state["owner_id"] = "alice"
|
||||
store = MemoryFileStore(
|
||||
tmp_path,
|
||||
kind="memories",
|
||||
owner_prefix="user_",
|
||||
owner_state_key="owner_id",
|
||||
dumps=lambda value: json.dumps(value, separators=(",", ":"), sort_keys=True),
|
||||
loads=json.loads,
|
||||
)
|
||||
main_client = _MemoryHarnessClient()
|
||||
consolidation_client = _MemoryHarnessClient(
|
||||
consolidation_payload={
|
||||
"summary": "Consolidated by the cheaper client.",
|
||||
"memories": ["Visit Oslo in June."],
|
||||
}
|
||||
)
|
||||
provider = MemoryContextProvider(
|
||||
store=store,
|
||||
consolidation_client=consolidation_client,
|
||||
)
|
||||
agent = Agent(
|
||||
client=main_client,
|
||||
context_providers=[provider],
|
||||
default_options={"store": False},
|
||||
)
|
||||
|
||||
_, options = await agent._prepare_session_and_messages( # type: ignore[reportPrivateUsage]
|
||||
session=session,
|
||||
input_messages=[Message(role="user", contents=["Remember this."])],
|
||||
)
|
||||
tools = options["tools"]
|
||||
assert isinstance(tools, list)
|
||||
|
||||
write_memory = _tool_by_name(tools, "write_memory")
|
||||
consolidate_memories = _tool_by_name(tools, "consolidate_memories")
|
||||
|
||||
await write_memory.invoke(arguments={"topic": "travel", "memory": "Visit Oslo in June."})
|
||||
await consolidate_memories.invoke()
|
||||
|
||||
travel_topic = store.get_topic(session, source_id=DEFAULT_MEMORY_SOURCE_ID, topic="travel")
|
||||
assert travel_topic.summary == "Consolidated by the cheaper client."
|
||||
assert main_client.calls == []
|
||||
assert consolidation_client.calls == ["consolidate"]
|
||||
|
||||
|
||||
async def test_memory_context_provider_preserves_concurrent_writes_to_same_topic(tmp_path) -> None:
|
||||
"""Concurrent writes to one topic should preserve every memory line."""
|
||||
session = AgentSession(session_id="session-1")
|
||||
session.state["owner_id"] = "alice"
|
||||
store = MemoryFileStore(tmp_path, owner_state_key="owner_id")
|
||||
provider = MemoryContextProvider(store=store)
|
||||
agent = Agent(client=_MemoryHarnessClient(), context_providers=[provider], default_options={"store": False})
|
||||
|
||||
_, options = await agent._prepare_session_and_messages( # type: ignore[reportPrivateUsage]
|
||||
session=session,
|
||||
input_messages=[Message(role="user", contents=["Remember these."])],
|
||||
)
|
||||
tools = options["tools"]
|
||||
assert isinstance(tools, list)
|
||||
write_memory = _tool_by_name(tools, "write_memory")
|
||||
memories = [f"Concurrent memory {index}." for index in range(20)]
|
||||
|
||||
await asyncio.gather(
|
||||
*(write_memory.invoke(arguments={"topic": "preferences", "memory": memory}) for memory in memories)
|
||||
)
|
||||
|
||||
topic = store.get_topic(session, source_id=DEFAULT_MEMORY_SOURCE_ID, topic="preferences")
|
||||
assert sorted(topic.memories) == sorted(memories)
|
||||
|
||||
|
||||
def test_memory_harness_classes_are_marked_experimental() -> None:
|
||||
"""Memory harness public classes should expose HARNESS experimental metadata."""
|
||||
assert MemoryIndexEntry.__feature_id__ == ExperimentalFeature.HARNESS.value
|
||||
assert MemoryTopicRecord.__feature_id__ == ExperimentalFeature.HARNESS.value
|
||||
assert MemoryStore.__feature_id__ == ExperimentalFeature.HARNESS.value
|
||||
assert MemoryFileStore.__feature_id__ == ExperimentalFeature.HARNESS.value
|
||||
assert MemoryContextProvider.__feature_id__ == ExperimentalFeature.HARNESS.value
|
||||
assert ".. warning:: Experimental" in MemoryContextProvider.__doc__
|
||||
|
||||
|
||||
def test_memory_topic_record_round_trips_when_text_contains_section_markers() -> None:
|
||||
"""Embedded ``## Summary``/``## Memories`` markers must not be re-interpreted as headings."""
|
||||
record = MemoryTopicRecord(
|
||||
topic="weird",
|
||||
summary="Multi line summary.\n## Summary\nstill summary",
|
||||
memories=[
|
||||
"## Memories pretend",
|
||||
"Real memory.",
|
||||
" ## Memories nested",
|
||||
],
|
||||
updated_at="2026-04-21T10:00:00+00:00",
|
||||
session_ids=["session-1"],
|
||||
)
|
||||
|
||||
reparsed = MemoryTopicRecord.from_markdown(record.to_markdown())
|
||||
|
||||
assert reparsed.summary == record.summary
|
||||
assert reparsed.memories == record.memories
|
||||
|
||||
|
||||
async def test_memory_file_store_atomic_write_preserves_prior_topic_on_failure(tmp_path, monkeypatch) -> None:
|
||||
"""If ``os.replace`` fails mid-write, the previous topic file must remain intact."""
|
||||
from agent_framework._harness import _memory as memory_module
|
||||
|
||||
store = MemoryFileStore(tmp_path, owner_state_key="owner_id")
|
||||
session = AgentSession(session_id="session-1")
|
||||
session.state["owner_id"] = "alice"
|
||||
original = MemoryTopicRecord(
|
||||
topic="preferences",
|
||||
summary="Prefers concise answers.",
|
||||
memories=["Prefers concise answers."],
|
||||
updated_at="2026-04-21T10:00:00+00:00",
|
||||
session_ids=["session-1"],
|
||||
)
|
||||
store.write_topic(session, original, source_id=DEFAULT_MEMORY_SOURCE_ID)
|
||||
|
||||
real_replace = memory_module.os.replace
|
||||
|
||||
def _boom(*args: object, **kwargs: object) -> None:
|
||||
raise OSError("simulated disk-full")
|
||||
|
||||
monkeypatch.setattr(memory_module.os, "replace", _boom)
|
||||
with pytest.raises(OSError, match="simulated disk-full"):
|
||||
store.write_topic(
|
||||
session,
|
||||
MemoryTopicRecord(
|
||||
topic="preferences",
|
||||
summary="Updated.",
|
||||
memories=["Updated."],
|
||||
updated_at="2026-04-21T11:00:00+00:00",
|
||||
session_ids=["session-1"],
|
||||
),
|
||||
source_id=DEFAULT_MEMORY_SOURCE_ID,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(memory_module.os, "replace", real_replace)
|
||||
surviving = store.get_topic(session, source_id=DEFAULT_MEMORY_SOURCE_ID, topic="preferences")
|
||||
assert surviving.summary == "Prefers concise answers."
|
||||
# Temp file should not be left behind.
|
||||
topics_dir = surviving_dir = tmp_path
|
||||
leftover = [path for path in topics_dir.rglob("*.tmp.*")]
|
||||
assert leftover == []
|
||||
del surviving_dir
|
||||
|
||||
|
||||
async def test_memory_file_store_does_not_mkdir_on_pure_read_paths(tmp_path) -> None:
|
||||
"""List/read calls on a never-written session should not create any directories."""
|
||||
store = MemoryFileStore(tmp_path, owner_state_key="owner_id")
|
||||
session = AgentSession(session_id="session-1")
|
||||
session.state["owner_id"] = "alice"
|
||||
|
||||
assert store.list_topics(session, source_id=DEFAULT_MEMORY_SOURCE_ID) == []
|
||||
assert store.read_state(session, source_id=DEFAULT_MEMORY_SOURCE_ID) == {
|
||||
"last_consolidated_at": None,
|
||||
"sessions_since_consolidation": [],
|
||||
}
|
||||
assert store.search_transcripts(session, source_id=DEFAULT_MEMORY_SOURCE_ID, query="anything") == []
|
||||
|
||||
# tmp_path itself was passed in by pytest so it exists; assert no children were created.
|
||||
assert list(tmp_path.iterdir()) == []
|
||||
|
||||
|
||||
class _RaisingMemoryClient:
|
||||
"""Chat client that raises a transient error for every consolidation request."""
|
||||
|
||||
additional_properties: dict[str, Any]
|
||||
|
||||
def __init__(self) -> None:
|
||||
from agent_framework.exceptions import ChatClientException
|
||||
|
||||
self.additional_properties = {}
|
||||
self.error_class = ChatClientException
|
||||
self.calls: list[str] = []
|
||||
|
||||
async def get_response(
|
||||
self,
|
||||
messages: Sequence[Message],
|
||||
*,
|
||||
stream: bool = False,
|
||||
options: Mapping[str, Any] | None = None,
|
||||
compaction_strategy: object | None = None,
|
||||
tokenizer: object | None = None,
|
||||
function_invocation_kwargs: Mapping[str, Any] | None = None,
|
||||
client_kwargs: Mapping[str, Any] | None = None,
|
||||
) -> ChatResponse[Any]:
|
||||
del messages, stream, options, compaction_strategy, tokenizer
|
||||
del function_invocation_kwargs, client_kwargs
|
||||
self.calls.append("call")
|
||||
raise self.error_class("simulated transient failure")
|
||||
|
||||
|
||||
class _ProgrammerErrorMemoryClient:
|
||||
"""Chat client whose ``get_response`` raises a non-transient programmer error."""
|
||||
|
||||
additional_properties: dict[str, Any]
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.additional_properties = {}
|
||||
|
||||
async def get_response(self, *args: object, **kwargs: object) -> ChatResponse[Any]:
|
||||
del args, kwargs
|
||||
raise AttributeError("misconfigured client")
|
||||
|
||||
|
||||
async def test_memory_consolidation_transient_failure_preserves_state(tmp_path) -> None:
|
||||
"""A transient consolidation failure must not advance the maintenance window."""
|
||||
session = AgentSession(session_id="session-1")
|
||||
session.state["owner_id"] = "alice"
|
||||
store = MemoryFileStore(tmp_path, owner_state_key="owner_id")
|
||||
raising_client = _RaisingMemoryClient()
|
||||
provider = MemoryContextProvider(store=store, consolidation_client=raising_client)
|
||||
pre_state = {
|
||||
"last_consolidated_at": "2026-04-20T09:00:00+00:00",
|
||||
"sessions_since_consolidation": ["queued-session"],
|
||||
}
|
||||
store.write_state(session, pre_state, source_id=DEFAULT_MEMORY_SOURCE_ID)
|
||||
store.write_topic(
|
||||
session,
|
||||
MemoryTopicRecord(
|
||||
topic="preferences",
|
||||
summary="Prefers concise answers.",
|
||||
memories=["Prefers concise answers."],
|
||||
updated_at="2026-04-21T10:00:00+00:00",
|
||||
session_ids=["session-1"],
|
||||
),
|
||||
source_id=DEFAULT_MEMORY_SOURCE_ID,
|
||||
)
|
||||
|
||||
consolidated_count = await provider._run_consolidation( # type: ignore[reportPrivateUsage]
|
||||
client=raising_client,
|
||||
session=session,
|
||||
force=True,
|
||||
now=datetime(2026, 4, 22, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
assert consolidated_count == 0
|
||||
assert raising_client.calls == ["call"]
|
||||
assert store.read_state(session, source_id=DEFAULT_MEMORY_SOURCE_ID) == pre_state
|
||||
surviving = store.get_topic(session, source_id=DEFAULT_MEMORY_SOURCE_ID, topic="preferences")
|
||||
assert surviving.summary == "Prefers concise answers."
|
||||
|
||||
|
||||
async def test_memory_extraction_propagates_programmer_errors(tmp_path) -> None:
|
||||
"""Non-transient errors from the chat client must surface so misconfigurations fail loudly."""
|
||||
session = AgentSession(session_id="session-1")
|
||||
session.state["owner_id"] = "alice"
|
||||
store = MemoryFileStore(tmp_path, owner_state_key="owner_id")
|
||||
provider = MemoryContextProvider(store=store)
|
||||
bad_client = _ProgrammerErrorMemoryClient()
|
||||
|
||||
from agent_framework import AgentResponse
|
||||
from agent_framework._sessions import SessionContext
|
||||
|
||||
context = SessionContext(
|
||||
input_messages=[Message(role="user", contents=["q"])],
|
||||
)
|
||||
context._response = AgentResponse(messages=[Message(role="assistant", contents=["a"])]) # type: ignore[reportPrivateUsage]
|
||||
|
||||
with pytest.raises(AttributeError, match="misconfigured client"):
|
||||
await provider._extract_memories( # type: ignore[reportPrivateUsage]
|
||||
client=bad_client,
|
||||
session=session,
|
||||
context=context,
|
||||
now=datetime(2026, 4, 22, tzinfo=timezone.utc),
|
||||
)
|
||||
@@ -1,191 +0,0 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_framework import (
|
||||
DEFAULT_MODE_SOURCE_ID,
|
||||
Agent,
|
||||
AgentModeProvider,
|
||||
AgentSession,
|
||||
ExperimentalFeature,
|
||||
Message,
|
||||
SupportsChatGetResponse,
|
||||
get_agent_mode,
|
||||
set_agent_mode,
|
||||
)
|
||||
|
||||
|
||||
def _tool_by_name(tools: list[object], name: str) -> object:
|
||||
"""Return the tool with the requested name from a prepared tool list."""
|
||||
for tool in tools:
|
||||
if getattr(tool, "name", None) == name:
|
||||
return tool
|
||||
raise AssertionError(f"Tool {name!r} was not found.")
|
||||
|
||||
|
||||
def test_get_and_set_agent_mode_manage_session_state() -> None:
|
||||
"""Mode helpers should initialize session state, normalize values, and validate modes."""
|
||||
session = AgentSession(session_id="session-1")
|
||||
|
||||
assert get_agent_mode(session) == "plan"
|
||||
assert session.state[DEFAULT_MODE_SOURCE_ID] == {"current_mode": "plan"}
|
||||
assert set_agent_mode(session, " execute ") == "execute"
|
||||
assert get_agent_mode(session) == "execute"
|
||||
|
||||
custom_session = AgentSession(session_id="session-2")
|
||||
assert (
|
||||
get_agent_mode(
|
||||
custom_session,
|
||||
default_mode="draft",
|
||||
available_modes=("draft", "final"),
|
||||
)
|
||||
== "draft"
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid mode"):
|
||||
set_agent_mode(session, "ship")
|
||||
|
||||
|
||||
def test_agent_mode_helpers_reject_non_dict_provider_state() -> None:
|
||||
"""Mode helpers should not overwrite unrelated non-dict session state."""
|
||||
session = AgentSession(session_id="session-1")
|
||||
session.state[DEFAULT_MODE_SOURCE_ID] = "unrelated state"
|
||||
|
||||
with pytest.raises(TypeError, match="source_id 'agent_mode'.*str"):
|
||||
get_agent_mode(session)
|
||||
|
||||
assert session.state[DEFAULT_MODE_SOURCE_ID] == "unrelated state"
|
||||
|
||||
|
||||
def test_agent_mode_context_provider_validates_configuration_and_is_experimental() -> None:
|
||||
"""Mode provider should validate configuration and expose HARNESS experimental metadata."""
|
||||
with pytest.raises(ValueError, match="at least one mode"):
|
||||
AgentModeProvider(mode_descriptions={})
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid mode"):
|
||||
AgentModeProvider(default_mode="ship")
|
||||
|
||||
assert AgentModeProvider.__feature_id__ == ExperimentalFeature.HARNESS.value
|
||||
assert get_agent_mode.__feature_id__ == ExperimentalFeature.HARNESS.value
|
||||
assert set_agent_mode.__feature_id__ == ExperimentalFeature.HARNESS.value
|
||||
assert ".. warning:: Experimental" in AgentModeProvider.__doc__
|
||||
assert get_agent_mode.__doc__ is not None
|
||||
assert ".. warning:: Experimental" in get_agent_mode.__doc__
|
||||
assert set_agent_mode.__doc__ is not None
|
||||
assert ".. warning:: Experimental" in set_agent_mode.__doc__
|
||||
|
||||
|
||||
async def test_agent_mode_context_provider_normalizes_custom_modes(
|
||||
chat_client_base: SupportsChatGetResponse,
|
||||
) -> None:
|
||||
"""Mode provider should accept differently-cased custom modes and display configured names."""
|
||||
session = AgentSession(session_id="session-1")
|
||||
provider = AgentModeProvider(
|
||||
default_mode="Draft", mode_descriptions={"Draft": "Draft it.", "Final": "Finalize it."}
|
||||
)
|
||||
agent = Agent(client=chat_client_base, context_providers=[provider])
|
||||
|
||||
_, options = await agent._prepare_session_and_messages( # type: ignore[reportPrivateUsage]
|
||||
session=session,
|
||||
input_messages=[Message(role="user", contents=["Start drafting"])],
|
||||
)
|
||||
instructions = options["instructions"]
|
||||
assert isinstance(instructions, str)
|
||||
assert '"Draft": Draft it.' in instructions
|
||||
assert '"Final": Finalize it.' in instructions
|
||||
assert "You are currently operating in the draft mode." in instructions
|
||||
|
||||
assert (
|
||||
get_agent_mode(session, source_id=provider.source_id, default_mode="Draft", available_modes=("Draft", "Final"))
|
||||
== "draft"
|
||||
)
|
||||
assert set_agent_mode(session, "draft", source_id=provider.source_id, available_modes=("Draft", "Final")) == "draft"
|
||||
assert (
|
||||
get_agent_mode(session, source_id=provider.source_id, default_mode="Draft", available_modes=("Draft", "Final"))
|
||||
== "draft"
|
||||
)
|
||||
|
||||
|
||||
async def test_agent_mode_context_provider_serializes_tool_outputs_as_json(
|
||||
chat_client_base: SupportsChatGetResponse,
|
||||
) -> None:
|
||||
"""Mode tools should serialize JSON correctly for mode names with quotes."""
|
||||
session = AgentSession(session_id="session-1")
|
||||
mode_name = 'edit "preview"'
|
||||
provider = AgentModeProvider(default_mode=mode_name, mode_descriptions={mode_name: "Preview edits."})
|
||||
agent = Agent(client=chat_client_base, context_providers=[provider])
|
||||
|
||||
_, options = await agent._prepare_session_and_messages( # type: ignore[reportPrivateUsage]
|
||||
session=session,
|
||||
input_messages=[Message(role="user", contents=["Preview edits"])],
|
||||
)
|
||||
tools = options["tools"]
|
||||
assert isinstance(tools, list)
|
||||
get_mode_tool = _tool_by_name(tools, "get_mode")
|
||||
set_mode_tool = _tool_by_name(tools, "set_mode")
|
||||
|
||||
initial_mode = await get_mode_tool.invoke()
|
||||
assert json.loads(initial_mode[0].text) == {"mode": mode_name}
|
||||
|
||||
set_result = await set_mode_tool.invoke(arguments={"mode": mode_name})
|
||||
assert json.loads(set_result[0].text) == {"mode": mode_name, "message": f"Mode changed to '{mode_name}'."}
|
||||
|
||||
|
||||
async def test_agent_mode_context_provider_updates_agent_mode(
|
||||
chat_client_base: SupportsChatGetResponse,
|
||||
) -> None:
|
||||
"""Mode provider tools should read and write session-backed mode state."""
|
||||
session = AgentSession(session_id="session-1")
|
||||
provider = AgentModeProvider()
|
||||
agent = Agent(client=chat_client_base, context_providers=[provider])
|
||||
|
||||
_, options = await agent._prepare_session_and_messages( # type: ignore[reportPrivateUsage]
|
||||
session=session,
|
||||
input_messages=[Message(role="user", contents=["Start planning"])],
|
||||
)
|
||||
tools = options["tools"]
|
||||
assert isinstance(tools, list)
|
||||
instructions = options["instructions"]
|
||||
assert isinstance(instructions, str)
|
||||
assert "## Agent Mode" in instructions
|
||||
assert "Use the set_mode tool to switch between modes as your work progresses." in instructions
|
||||
assert "ask clarifying questions, discuss options, and get user approval before proceeding" in instructions
|
||||
assert "If you encounter ambiguity, choose the most reasonable option and note your choice" in instructions
|
||||
assert "You are currently operating in the plan mode." in instructions
|
||||
|
||||
get_mode_tool = _tool_by_name(tools, "get_mode")
|
||||
set_mode_tool = _tool_by_name(tools, "set_mode")
|
||||
|
||||
initial_mode = await get_mode_tool.invoke()
|
||||
assert json.loads(initial_mode[0].text) == {"mode": "plan"}
|
||||
|
||||
set_result = await set_mode_tool.invoke(arguments={"mode": "execute"})
|
||||
assert json.loads(set_result[0].text) == {"mode": "execute", "message": "Mode changed to 'execute'."}
|
||||
assert get_agent_mode(session, source_id=provider.source_id) == "execute"
|
||||
assert set_agent_mode(session, "plan", source_id=provider.source_id) == "plan"
|
||||
|
||||
|
||||
def test_default_mode_falls_back_to_first_available_mode() -> None:
|
||||
"""When ``default_mode`` is omitted, helpers and provider should use the first configured mode."""
|
||||
session = AgentSession(session_id="session-1")
|
||||
|
||||
assert get_agent_mode(session, available_modes=("draft", "final")) == "draft"
|
||||
|
||||
provider = AgentModeProvider(mode_descriptions={"Draft": "Draft it.", "Final": "Finalize it."})
|
||||
assert provider.default_mode == "draft"
|
||||
|
||||
|
||||
def test_get_agent_mode_falls_back_when_stored_mode_not_in_available_modes() -> None:
|
||||
"""A previously persisted mode that is no longer configured should be reset to the default."""
|
||||
session = AgentSession(session_id="session-1")
|
||||
set_agent_mode(session, "execute")
|
||||
assert session.state[DEFAULT_MODE_SOURCE_ID]["current_mode"] == "execute"
|
||||
|
||||
# Reconfigure with a smaller mode set that no longer includes "execute".
|
||||
current = get_agent_mode(session, default_mode="draft", available_modes=("draft", "final"))
|
||||
assert current == "draft"
|
||||
assert session.state[DEFAULT_MODE_SOURCE_ID]["current_mode"] == "draft"
|
||||
@@ -1,377 +0,0 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from agent_framework import (
|
||||
Agent,
|
||||
AgentSession,
|
||||
ExperimentalFeature,
|
||||
Message,
|
||||
SupportsChatGetResponse,
|
||||
TodoFileStore,
|
||||
TodoInput,
|
||||
TodoItem,
|
||||
TodoProvider,
|
||||
TodoSessionStore,
|
||||
TodoStore,
|
||||
)
|
||||
|
||||
|
||||
def _tool_by_name(tools: list[object], name: str) -> object:
|
||||
"""Return the tool with the requested name from a prepared tool list."""
|
||||
for tool in tools:
|
||||
if getattr(tool, "name", None) == name:
|
||||
return tool
|
||||
raise AssertionError(f"Tool {name!r} was not found.")
|
||||
|
||||
|
||||
def test_todo_item_round_trips_with_value_equality() -> None:
|
||||
"""Todo items should support value equality and JSON serialization."""
|
||||
raw_item = {
|
||||
"id": 1,
|
||||
"title": "Write tests",
|
||||
"description": "Cover the harness",
|
||||
"is_complete": False,
|
||||
}
|
||||
|
||||
item = TodoItem.from_dict(raw_item)
|
||||
|
||||
assert item == TodoItem(**raw_item)
|
||||
assert item.to_dict() == raw_item
|
||||
assert json.loads(item.to_json()) == raw_item
|
||||
assert "TodoItem(" in repr(item)
|
||||
|
||||
|
||||
def test_todo_input_round_trips_and_validates() -> None:
|
||||
"""Todo input should trim titles and reject invalid payloads."""
|
||||
todo_input = TodoInput.from_dict({"title": " Write tests ", "description": "Cover the harness"})
|
||||
|
||||
assert todo_input.title == "Write tests"
|
||||
assert todo_input.to_dict() == {"title": "Write tests", "description": "Cover the harness"}
|
||||
assert json.loads(todo_input.to_json()) == {"title": "Write tests", "description": "Cover the harness"}
|
||||
|
||||
with pytest.raises(ValueError, match="non-empty string"):
|
||||
TodoInput(title=" ")
|
||||
|
||||
with pytest.raises(ValueError, match="description must be a string or null"):
|
||||
TodoInput.from_dict({"title": "Write tests", "description": 123})
|
||||
|
||||
|
||||
async def test_todo_session_store_initializes_and_round_trips_state() -> None:
|
||||
"""Session-backed todo storage should initialize and persist todo state."""
|
||||
session = AgentSession(session_id="session-1")
|
||||
store = TodoSessionStore()
|
||||
|
||||
items, next_id = await store.load_state(session, source_id="todo")
|
||||
assert items == []
|
||||
assert next_id == 1
|
||||
assert session.state["todo"] == {}
|
||||
|
||||
todo_item = TodoItem(id=1, title="Ship feature", description="Use session storage")
|
||||
await store.save_state(session, [todo_item], next_id=2, source_id="todo")
|
||||
|
||||
loaded_items, loaded_next_id = await store.load_state(session, source_id="todo")
|
||||
assert loaded_items == [todo_item]
|
||||
assert loaded_next_id == 2
|
||||
assert await store.load_items(session, source_id="todo") == [todo_item]
|
||||
|
||||
|
||||
async def test_todo_file_store_round_trips_state(tmp_path: Path) -> None:
|
||||
"""Todo file storage should persist one JSON state file per owner and session."""
|
||||
session = AgentSession(session_id="session-1")
|
||||
session.state["owner_id"] = "alice"
|
||||
store = TodoFileStore(
|
||||
tmp_path,
|
||||
kind="todos",
|
||||
owner_prefix="user_",
|
||||
owner_state_key="owner_id",
|
||||
)
|
||||
|
||||
await store.save_state(
|
||||
session,
|
||||
[TodoItem(id=1, title="Ship feature", description="Use file storage")],
|
||||
next_id=2,
|
||||
source_id="todo",
|
||||
)
|
||||
|
||||
items, next_id = await store.load_state(session, source_id="todo")
|
||||
assert items == [TodoItem(id=1, title="Ship feature", description="Use file storage", is_complete=False)]
|
||||
assert next_id == 2
|
||||
|
||||
state_path = tmp_path / "user_alice" / "todos" / "session-1" / "todos.todo.json"
|
||||
assert state_path.exists()
|
||||
assert json.loads(state_path.read_text(encoding="utf-8")) == {
|
||||
"items": [{"id": 1, "title": "Ship feature", "description": "Use file storage", "is_complete": False}],
|
||||
"next_id": 2,
|
||||
}
|
||||
|
||||
with pytest.raises(RuntimeError, match="owner_id"):
|
||||
await store.load_state(AgentSession(session_id="missing-owner"), source_id="todo")
|
||||
|
||||
|
||||
async def test_todo_file_store_load_does_not_create_directories(tmp_path: Path) -> None:
|
||||
"""Loading from a never-written session must not create empty directories on disk."""
|
||||
session = AgentSession(session_id="session-1")
|
||||
store = TodoFileStore(tmp_path)
|
||||
|
||||
items, next_id = await store.load_state(session, source_id="todo")
|
||||
assert items == []
|
||||
assert next_id == 1
|
||||
assert list(tmp_path.iterdir()) == [] # noqa: ASYNC240
|
||||
|
||||
|
||||
async def test_todo_file_store_writes_state_atomically(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""A crash between writing the temp file and renaming must not corrupt existing state."""
|
||||
session = AgentSession(session_id="session-1")
|
||||
store = TodoFileStore(tmp_path)
|
||||
|
||||
await store.save_state(session, [TodoItem(id=1, title="Initial")], next_id=2, source_id="todo")
|
||||
state_path = tmp_path / "session-1" / "todos.todo.json"
|
||||
original_contents = state_path.read_text(encoding="utf-8")
|
||||
|
||||
def _boom(*args: object, **kwargs: object) -> None:
|
||||
raise OSError("disk full")
|
||||
|
||||
monkeypatch.setattr(os, "replace", _boom)
|
||||
|
||||
with pytest.raises(OSError, match="disk full"):
|
||||
await store.save_state(session, [TodoItem(id=2, title="Replacement")], next_id=3, source_id="todo")
|
||||
|
||||
# Original file is untouched, no temp leftovers.
|
||||
assert state_path.read_text(encoding="utf-8") == original_contents
|
||||
assert sorted(p.name for p in state_path.parent.iterdir()) == [state_path.name]
|
||||
|
||||
|
||||
async def test_todo_session_store_rejects_non_mapping_items() -> None:
|
||||
"""Session-backed todo storage should report malformed item entries clearly."""
|
||||
session = AgentSession(session_id="session-1")
|
||||
session.state["todo"] = {"items": [{"id": 1, "title": "Good"}, "bad"], "next_id": 2}
|
||||
store = TodoSessionStore()
|
||||
|
||||
with pytest.raises(ValueError, match="index 1.*str"):
|
||||
await store.load_state(session, source_id="todo")
|
||||
|
||||
|
||||
async def test_todo_session_store_rejects_malformed_state_types() -> None:
|
||||
"""Session-backed todo storage should raise for malformed top-level state, mirroring TodoFileStore."""
|
||||
session = AgentSession(session_id="session-1")
|
||||
session.state["todo"] = "not a dict"
|
||||
store = TodoSessionStore()
|
||||
|
||||
with pytest.raises(ValueError, match="must be a dict"):
|
||||
await store.load_state(session, source_id="todo")
|
||||
|
||||
session.state["todo"] = {"items": "not a list", "next_id": 1}
|
||||
with pytest.raises(ValueError, match="non-list 'items'"):
|
||||
await store.load_state(session, source_id="todo")
|
||||
|
||||
session.state["todo"] = {"items": [], "next_id": "1"}
|
||||
with pytest.raises(ValueError, match="non-integer 'next_id'"):
|
||||
await store.load_state(session, source_id="todo")
|
||||
|
||||
|
||||
async def test_todo_stores_clamp_next_id_to_avoid_collisions(tmp_path: Path) -> None:
|
||||
"""Both stores should clamp ``next_id`` to ``max(item.id) + 1`` to prevent ID collisions."""
|
||||
session_a = AgentSession(session_id="session-a")
|
||||
session_a.state["todo"] = {"items": [{"id": 5, "title": "Seeded"}], "next_id": 1}
|
||||
|
||||
session_store = TodoSessionStore()
|
||||
items, next_id = await session_store.load_state(session_a, source_id="todo")
|
||||
assert next_id == 6 # clamped over the stored next_id of 1
|
||||
assert items == [TodoItem(id=5, title="Seeded")]
|
||||
|
||||
session_b = AgentSession(session_id="session-b")
|
||||
file_store = TodoFileStore(tmp_path)
|
||||
state_path = tmp_path / "session-b" / "todos.todo.json"
|
||||
state_path.parent.mkdir(parents=True)
|
||||
state_path.write_text(json.dumps({"items": [{"id": 7, "title": "Seeded"}], "next_id": 1}) + "\n", encoding="utf-8")
|
||||
items, next_id = await file_store.load_state(session_b, source_id="todo")
|
||||
assert next_id == 8
|
||||
assert items == [TodoItem(id=7, title="Seeded")]
|
||||
|
||||
|
||||
async def test_todo_provider_evicts_locks_when_session_is_garbage_collected() -> None:
|
||||
"""The provider should not retain mutation locks for sessions that have been GC'd."""
|
||||
import gc
|
||||
|
||||
provider = TodoProvider()
|
||||
session = AgentSession(session_id="session-1")
|
||||
provider._mutation_lock(session) # type: ignore[reportPrivateUsage]
|
||||
assert len(provider._mutation_locks) == 1 # type: ignore[reportPrivateUsage]
|
||||
|
||||
del session
|
||||
gc.collect()
|
||||
assert len(provider._mutation_locks) == 0 # type: ignore[reportPrivateUsage]
|
||||
|
||||
|
||||
async def test_todo_file_store_rejects_session_path_traversal(tmp_path: Path) -> None:
|
||||
"""File-backed todo storage should not write outside its base path for malicious session IDs."""
|
||||
session = AgentSession(session_id="../escape")
|
||||
store = TodoFileStore(tmp_path)
|
||||
|
||||
with pytest.raises(ValueError, match="session_id.*path separators"):
|
||||
await store.save_state(session, [TodoItem(id=1, title="Escape")], next_id=2, source_id="todo")
|
||||
|
||||
assert list(tmp_path.rglob("*")) == [] # noqa: ASYNC240
|
||||
|
||||
|
||||
async def test_todo_file_store_namespaces_state_by_source_id(tmp_path: Path) -> None:
|
||||
"""File-backed todo storage should isolate providers that share a session."""
|
||||
session = AgentSession(session_id="session-1")
|
||||
store = TodoFileStore(tmp_path)
|
||||
|
||||
await store.save_state(session, [TodoItem(id=1, title="First source")], next_id=2, source_id="first")
|
||||
await store.save_state(session, [TodoItem(id=1, title="Second source")], next_id=2, source_id="second")
|
||||
|
||||
first_items, _ = await store.load_state(session, source_id="first")
|
||||
second_items, _ = await store.load_state(session, source_id="second")
|
||||
|
||||
assert first_items == [TodoItem(id=1, title="First source")]
|
||||
assert second_items == [TodoItem(id=1, title="Second source")]
|
||||
assert (tmp_path / "session-1" / "todos.first.json").exists()
|
||||
assert (tmp_path / "session-1" / "todos.second.json").exists()
|
||||
|
||||
|
||||
async def test_todo_provider_runs_with_file_store(tmp_path: Path, chat_client_base: SupportsChatGetResponse) -> None:
|
||||
"""The provider should drive the full add/list flow when backed by ``TodoFileStore``."""
|
||||
session = AgentSession(session_id="session-1")
|
||||
provider = TodoProvider(store=TodoFileStore(tmp_path))
|
||||
agent = Agent(client=chat_client_base, context_providers=[provider])
|
||||
|
||||
_, options = await agent._prepare_session_and_messages( # type: ignore[reportPrivateUsage]
|
||||
session=session,
|
||||
input_messages=[Message(role="user", contents=["Track this work"])],
|
||||
)
|
||||
tools = options["tools"]
|
||||
assert isinstance(tools, list)
|
||||
|
||||
add_todos = _tool_by_name(tools, "add_todos")
|
||||
get_all_todos = _tool_by_name(tools, "get_all_todos")
|
||||
|
||||
await add_todos.invoke(arguments={"todos": [{"title": "Persist me"}]})
|
||||
state_path = tmp_path / "session-1" / "todos.todo.json"
|
||||
assert state_path.exists()
|
||||
persisted = json.loads(state_path.read_text(encoding="utf-8"))
|
||||
assert persisted["items"] == [{"id": 1, "title": "Persist me", "description": None, "is_complete": False}]
|
||||
assert persisted["next_id"] == 2
|
||||
|
||||
get_all_result = await get_all_todos.invoke()
|
||||
assert json.loads(get_all_result[0].text) == [
|
||||
{"id": 1, "title": "Persist me", "description": None, "is_complete": False}
|
||||
]
|
||||
|
||||
|
||||
async def test_todo_provider_tools_manage_session_state(
|
||||
chat_client_base: SupportsChatGetResponse,
|
||||
) -> None:
|
||||
"""Todo provider tools should add, complete, remove, and list session-backed todos."""
|
||||
session = AgentSession(session_id="session-1")
|
||||
provider = TodoProvider()
|
||||
agent = Agent(client=chat_client_base, context_providers=[provider])
|
||||
|
||||
_, options = await agent._prepare_session_and_messages( # type: ignore[reportPrivateUsage]
|
||||
session=session,
|
||||
input_messages=[Message(role="user", contents=["Track this work"])],
|
||||
)
|
||||
tools = options["tools"]
|
||||
assert isinstance(tools, list)
|
||||
|
||||
add_todos = _tool_by_name(tools, "add_todos")
|
||||
complete_todos = _tool_by_name(tools, "complete_todos")
|
||||
remove_todos = _tool_by_name(tools, "remove_todos")
|
||||
get_remaining_todos = _tool_by_name(tools, "get_remaining_todos")
|
||||
get_all_todos = _tool_by_name(tools, "get_all_todos")
|
||||
|
||||
add_result = await add_todos.invoke(
|
||||
arguments={
|
||||
"todos": [
|
||||
{"title": " Write tests ", "description": " Cover stores "},
|
||||
{"title": "Ship feature"},
|
||||
]
|
||||
}
|
||||
)
|
||||
assert json.loads(add_result[0].text) == [
|
||||
{"id": 1, "title": "Write tests", "description": "Cover stores", "is_complete": False},
|
||||
{"id": 2, "title": "Ship feature", "description": None, "is_complete": False},
|
||||
]
|
||||
|
||||
complete_result = await complete_todos.invoke(arguments={"ids": [1]})
|
||||
assert json.loads(complete_result[0].text) == {"completed": 1}
|
||||
|
||||
remaining_result = await get_remaining_todos.invoke()
|
||||
assert json.loads(remaining_result[0].text) == [
|
||||
{"id": 2, "title": "Ship feature", "description": None, "is_complete": False}
|
||||
]
|
||||
|
||||
remove_result = await remove_todos.invoke(arguments={"ids": [2]})
|
||||
assert json.loads(remove_result[0].text) == {"removed": 1}
|
||||
|
||||
get_all_result = await get_all_todos.invoke()
|
||||
assert json.loads(get_all_result[0].text) == [
|
||||
{"id": 1, "title": "Write tests", "description": "Cover stores", "is_complete": True}
|
||||
]
|
||||
|
||||
|
||||
async def test_todo_provider_serializes_concurrent_mutations(
|
||||
chat_client_base: SupportsChatGetResponse,
|
||||
) -> None:
|
||||
"""Concurrent todo mutations should not duplicate IDs or lose updates."""
|
||||
session = AgentSession(session_id="session-1")
|
||||
provider = TodoProvider()
|
||||
agent = Agent(client=chat_client_base, context_providers=[provider])
|
||||
|
||||
_, options = await agent._prepare_session_and_messages( # type: ignore[reportPrivateUsage]
|
||||
session=session,
|
||||
input_messages=[Message(role="user", contents=["Track this work"])],
|
||||
)
|
||||
tools = options["tools"]
|
||||
assert isinstance(tools, list)
|
||||
|
||||
add_todos = _tool_by_name(tools, "add_todos")
|
||||
complete_todos = _tool_by_name(tools, "complete_todos")
|
||||
get_all_todos = _tool_by_name(tools, "get_all_todos")
|
||||
|
||||
await add_todos.invoke(arguments={"todos": [{"title": f"Existing {index}"} for index in range(1, 6)]})
|
||||
|
||||
await asyncio.gather(
|
||||
add_todos.invoke(arguments={"todos": [{"title": "Add A1"}, {"title": "Add A2"}]}),
|
||||
add_todos.invoke(arguments={"todos": [{"title": "Add B1"}, {"title": "Add B2"}]}),
|
||||
complete_todos.invoke(arguments={"ids": [1, 2, 3, 4, 5]}),
|
||||
)
|
||||
|
||||
get_all_result = await get_all_todos.invoke()
|
||||
payload = json.loads(get_all_result[0].text)
|
||||
ids = [item["id"] for item in payload]
|
||||
|
||||
assert sorted(ids) == list(range(1, 10))
|
||||
assert len(ids) == len(set(ids))
|
||||
assert {item["title"] for item in payload} == {
|
||||
"Existing 1",
|
||||
"Existing 2",
|
||||
"Existing 3",
|
||||
"Existing 4",
|
||||
"Existing 5",
|
||||
"Add A1",
|
||||
"Add A2",
|
||||
"Add B1",
|
||||
"Add B2",
|
||||
}
|
||||
assert {item["id"] for item in payload if item["is_complete"]} == {1, 2, 3, 4, 5}
|
||||
|
||||
|
||||
def test_todo_harness_classes_are_marked_experimental() -> None:
|
||||
"""Todo harness public classes should expose HARNESS experimental metadata."""
|
||||
assert TodoStore.__feature_id__ == ExperimentalFeature.HARNESS.value
|
||||
assert TodoItem.__feature_id__ == ExperimentalFeature.HARNESS.value
|
||||
assert TodoInput.__feature_id__ == ExperimentalFeature.HARNESS.value
|
||||
assert TodoSessionStore.__feature_id__ == ExperimentalFeature.HARNESS.value
|
||||
assert TodoFileStore.__feature_id__ == ExperimentalFeature.HARNESS.value
|
||||
assert TodoProvider.__feature_id__ == ExperimentalFeature.HARNESS.value
|
||||
assert ".. warning:: Experimental" in TodoProvider.__doc__
|
||||
@@ -1,42 +0,0 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
import sys
|
||||
from types import ModuleType
|
||||
|
||||
import pytest
|
||||
|
||||
import agent_framework.hyperlight as hyperlight
|
||||
|
||||
|
||||
def test_hyperlight_namespace_dir_lists_lazy_exports() -> None:
|
||||
names = dir(hyperlight)
|
||||
for expected in (
|
||||
"AllowedDomain",
|
||||
"AllowedDomainInput",
|
||||
"FileMount",
|
||||
"FileMountInput",
|
||||
"HyperlightCodeActProvider",
|
||||
"HyperlightExecuteCodeTool",
|
||||
):
|
||||
assert expected in names
|
||||
|
||||
|
||||
def test_hyperlight_namespace_lazy_loads_known_attribute(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
sentinel = object()
|
||||
fake_module = ModuleType("agent_framework_hyperlight")
|
||||
fake_module.HyperlightCodeActProvider = sentinel # type: ignore[attr-defined]
|
||||
monkeypatch.setitem(sys.modules, "agent_framework_hyperlight", fake_module)
|
||||
|
||||
assert hyperlight.HyperlightCodeActProvider is sentinel
|
||||
|
||||
|
||||
def test_hyperlight_namespace_unknown_attribute_raises_attribute_error() -> None:
|
||||
with pytest.raises(AttributeError, match="Module `hyperlight` has no attribute DoesNotExist."):
|
||||
_ = hyperlight.DoesNotExist # type: ignore[attr-defined]
|
||||
|
||||
|
||||
def test_hyperlight_namespace_missing_package_raises_helpful_error(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setitem(sys.modules, "agent_framework_hyperlight", None)
|
||||
|
||||
with pytest.raises(ModuleNotFoundError, match="agent-framework-hyperlight"):
|
||||
_ = hyperlight.HyperlightCodeActProvider
|
||||
@@ -9,6 +9,7 @@ YAML/JSON-based declarative agent and workflow definitions.
|
||||
- **`WorkflowState`** - State management for declarative workflows
|
||||
- **`ProviderTypeMapping`** - Maps provider types to implementations
|
||||
- **`HttpRequestHandler`** / **`DefaultHttpRequestHandler`** - Pluggable HTTP transport for the `HttpRequestAction` declarative action (configured via `WorkflowFactory(http_request_handler=...)`)
|
||||
- **`MCPToolHandler`** / **`DefaultMCPToolHandler`** - Pluggable MCP transport for the `InvokeMcpTool` declarative action (configured via `WorkflowFactory(mcp_tool_handler=...)`)
|
||||
- **`DeclarativeLoaderError`** / **`ProviderLookupError`** / **`DeclarativeWorkflowError`** / **`DeclarativeActionError`** - Error types
|
||||
|
||||
## External Input Handling
|
||||
|
||||
@@ -9,11 +9,18 @@ from ._workflows import (
|
||||
DeclarativeActionError,
|
||||
DeclarativeWorkflowError,
|
||||
DefaultHttpRequestHandler,
|
||||
DefaultMCPToolHandler,
|
||||
ExternalInputRequest,
|
||||
ExternalInputResponse,
|
||||
HttpRequestHandler,
|
||||
HttpRequestInfo,
|
||||
HttpRequestResult,
|
||||
MCPToolApprovalRequest,
|
||||
MCPToolHandler,
|
||||
MCPToolInvocation,
|
||||
MCPToolResult,
|
||||
ToolApprovalRequest,
|
||||
ToolApprovalResponse,
|
||||
WorkflowFactory,
|
||||
WorkflowState,
|
||||
)
|
||||
@@ -31,13 +38,20 @@ __all__ = [
|
||||
"DeclarativeLoaderError",
|
||||
"DeclarativeWorkflowError",
|
||||
"DefaultHttpRequestHandler",
|
||||
"DefaultMCPToolHandler",
|
||||
"ExternalInputRequest",
|
||||
"ExternalInputResponse",
|
||||
"HttpRequestHandler",
|
||||
"HttpRequestInfo",
|
||||
"HttpRequestResult",
|
||||
"MCPToolApprovalRequest",
|
||||
"MCPToolHandler",
|
||||
"MCPToolInvocation",
|
||||
"MCPToolResult",
|
||||
"ProviderLookupError",
|
||||
"ProviderTypeMapping",
|
||||
"ToolApprovalRequest",
|
||||
"ToolApprovalResponse",
|
||||
"WorkflowFactory",
|
||||
"WorkflowState",
|
||||
"__version__",
|
||||
|
||||
@@ -72,6 +72,11 @@ from ._executors_http import (
|
||||
HTTP_ACTION_EXECUTORS,
|
||||
HttpRequestActionExecutor,
|
||||
)
|
||||
from ._executors_mcp import (
|
||||
MCP_ACTION_EXECUTORS,
|
||||
InvokeMcpToolActionExecutor,
|
||||
MCPToolApprovalRequest,
|
||||
)
|
||||
from ._executors_tools import (
|
||||
FUNCTION_TOOL_REGISTRY_KEY,
|
||||
TOOL_ACTION_EXECUTORS,
|
||||
@@ -90,6 +95,12 @@ from ._http_handler import (
|
||||
HttpRequestInfo,
|
||||
HttpRequestResult,
|
||||
)
|
||||
from ._mcp_handler import (
|
||||
DefaultMCPToolHandler,
|
||||
MCPToolHandler,
|
||||
MCPToolInvocation,
|
||||
MCPToolResult,
|
||||
)
|
||||
from ._state import WorkflowState
|
||||
|
||||
__all__ = [
|
||||
@@ -102,6 +113,7 @@ __all__ = [
|
||||
"EXTERNAL_INPUT_EXECUTORS",
|
||||
"FUNCTION_TOOL_REGISTRY_KEY",
|
||||
"HTTP_ACTION_EXECUTORS",
|
||||
"MCP_ACTION_EXECUTORS",
|
||||
"TOOL_ACTION_EXECUTORS",
|
||||
"TOOL_APPROVAL_STATE_KEY",
|
||||
"TOOL_REGISTRY_KEY",
|
||||
@@ -126,6 +138,7 @@ __all__ = [
|
||||
"DeclarativeWorkflowError",
|
||||
"DeclarativeWorkflowState",
|
||||
"DefaultHttpRequestHandler",
|
||||
"DefaultMCPToolHandler",
|
||||
"EmitEventExecutor",
|
||||
"EndConversationExecutor",
|
||||
"EndWorkflowExecutor",
|
||||
@@ -140,9 +153,14 @@ __all__ = [
|
||||
"HttpRequestResult",
|
||||
"InvokeAzureAgentExecutor",
|
||||
"InvokeFunctionToolExecutor",
|
||||
"InvokeMcpToolActionExecutor",
|
||||
"JoinExecutor",
|
||||
"LoopControl",
|
||||
"LoopIterationResult",
|
||||
"MCPToolApprovalRequest",
|
||||
"MCPToolHandler",
|
||||
"MCPToolInvocation",
|
||||
"MCPToolResult",
|
||||
"QuestionExecutor",
|
||||
"RequestExternalInputExecutor",
|
||||
"ResetVariableExecutor",
|
||||
|
||||
+22
@@ -41,8 +41,10 @@ from ._executors_control_flow import (
|
||||
)
|
||||
from ._executors_external_input import EXTERNAL_INPUT_EXECUTORS
|
||||
from ._executors_http import HTTP_ACTION_EXECUTORS, HttpRequestActionExecutor
|
||||
from ._executors_mcp import MCP_ACTION_EXECUTORS, InvokeMcpToolActionExecutor
|
||||
from ._executors_tools import TOOL_ACTION_EXECUTORS, InvokeFunctionToolExecutor
|
||||
from ._http_handler import HttpRequestHandler
|
||||
from ._mcp_handler import MCPToolHandler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -55,6 +57,7 @@ ALL_ACTION_EXECUTORS = {
|
||||
**EXTERNAL_INPUT_EXECUTORS,
|
||||
**TOOL_ACTION_EXECUTORS,
|
||||
**HTTP_ACTION_EXECUTORS,
|
||||
**MCP_ACTION_EXECUTORS,
|
||||
}
|
||||
|
||||
# Action kinds that terminate control flow (no fall-through to successor)
|
||||
@@ -90,6 +93,7 @@ ACTION_REQUIRED_FIELDS: dict[str, list[str]] = {
|
||||
"EmitEvent": ["event"],
|
||||
"InvokeFunctionTool": ["functionName"],
|
||||
"HttpRequestAction": ["url"],
|
||||
"InvokeMcpTool": ["serverUrl", "toolName"],
|
||||
}
|
||||
|
||||
# Alternate field names that satisfy required field requirements
|
||||
@@ -135,6 +139,7 @@ class DeclarativeWorkflowBuilder:
|
||||
validate: bool = True,
|
||||
max_iterations: int | None = None,
|
||||
http_request_handler: HttpRequestHandler | None = None,
|
||||
mcp_tool_handler: MCPToolHandler | None = None,
|
||||
):
|
||||
"""Initialize the builder.
|
||||
|
||||
@@ -150,6 +155,9 @@ class DeclarativeWorkflowBuilder:
|
||||
http_request_handler: Handler used to dispatch HttpRequestAction requests.
|
||||
Must be supplied when the workflow contains any HttpRequestAction;
|
||||
otherwise build raises ``DeclarativeWorkflowError``.
|
||||
mcp_tool_handler: Handler used to dispatch InvokeMcpTool calls.
|
||||
Must be supplied when the workflow contains any InvokeMcpTool;
|
||||
otherwise build raises ``DeclarativeWorkflowError``.
|
||||
"""
|
||||
self._yaml_def = yaml_definition
|
||||
self._workflow_id = workflow_id or yaml_definition.get("name", "declarative_workflow")
|
||||
@@ -162,6 +170,7 @@ class DeclarativeWorkflowBuilder:
|
||||
self._validate = validate
|
||||
self._seen_explicit_ids: set[str] = set() # Track explicit IDs for duplicate detection
|
||||
self._http_request_handler = http_request_handler
|
||||
self._mcp_tool_handler = mcp_tool_handler
|
||||
# Resolve max_iterations: explicit arg > YAML maxTurns > core default
|
||||
resolved = max_iterations if max_iterations is not None else yaml_definition.get("maxTurns")
|
||||
if resolved is not None and (not isinstance(resolved, int) or resolved <= 0):
|
||||
@@ -481,6 +490,19 @@ class DeclarativeWorkflowBuilder:
|
||||
id=action_id,
|
||||
http_request_handler=self._http_request_handler,
|
||||
)
|
||||
elif kind == "InvokeMcpTool":
|
||||
if self._mcp_tool_handler is None:
|
||||
raise DeclarativeWorkflowError(
|
||||
f"Workflow defines InvokeMcpTool '{action_id}' but no "
|
||||
"mcp_tool_handler was supplied to WorkflowFactory. Pass "
|
||||
"mcp_tool_handler=DefaultMCPToolHandler() (or a custom "
|
||||
"implementation) to enable MCP tool invocations."
|
||||
)
|
||||
executor = InvokeMcpToolActionExecutor(
|
||||
action_def,
|
||||
id=action_id,
|
||||
mcp_tool_handler=self._mcp_tool_handler,
|
||||
)
|
||||
else:
|
||||
executor = executor_class(action_def, id=action_id)
|
||||
self._executors[action_id] = executor
|
||||
|
||||
@@ -0,0 +1,614 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Executor for the ``InvokeMcpTool`` declarative action.
|
||||
|
||||
Mirrors the .NET ``InvokeMcpToolExecutor``: dispatches an MCP tool call through
|
||||
the configured :class:`MCPToolHandler`, parses tool outputs, and routes
|
||||
results to the configured ``output.{result, messages, autoSend}`` paths and
|
||||
optional conversation history. Supports a human-in-loop approval flow via
|
||||
``ctx.request_info()`` / :func:`@response_handler` for ``requireApproval=true``.
|
||||
|
||||
Security notes:
|
||||
|
||||
- The executor never echoes header VALUES (auth tokens, API keys) into the
|
||||
approval request — only header NAMES are surfaced to the caller. This
|
||||
matches the security posture of :mod:`._executors_http` (which never logs
|
||||
request headers either) and prevents secrets from leaking through workflow
|
||||
events that are typically observable to operators / UIs.
|
||||
- ``_MCPToolApprovalState`` snapshots the EVALUATED values for non-secret
|
||||
fields (server URL, tool name, arguments) at approval-request time so that
|
||||
subsequent state mutations cannot make the executor "approve X then call
|
||||
Y". Headers are stored as the raw expression strings (not evaluated values)
|
||||
so secrets are not persisted in the workflow's checkpoint state. They are
|
||||
re-evaluated on resume.
|
||||
- Tool outputs flow back into agent conversations through ``conversationId``
|
||||
and through Tool-role messages emitted to ``output.messages``. They share
|
||||
the same prompt-injection risk surface as ``HttpRequestAction``: workflow
|
||||
authors must trust the MCP server they invoke.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from agent_framework import (
|
||||
Content,
|
||||
Message,
|
||||
WorkflowContext,
|
||||
handler,
|
||||
response_handler,
|
||||
)
|
||||
from agent_framework.exceptions import ToolExecutionException
|
||||
|
||||
from ._declarative_base import (
|
||||
ActionComplete,
|
||||
DeclarativeActionExecutor,
|
||||
DeclarativeWorkflowState,
|
||||
)
|
||||
from ._executors_tools import ToolApprovalResponse
|
||||
from ._mcp_handler import MCPToolHandler, MCPToolInvocation, MCPToolResult
|
||||
|
||||
__all__ = [
|
||||
"MCP_ACTION_EXECUTORS",
|
||||
"InvokeMcpToolActionExecutor",
|
||||
"MCPToolApprovalRequest",
|
||||
]
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_MCP_APPROVAL_STATE_KEY = "_mcp_tool_approval_state"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Request / state types
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class MCPToolApprovalRequest:
|
||||
"""Approval request emitted before invoking an MCP tool.
|
||||
|
||||
Mirrors :class:`agent_framework_declarative.ToolApprovalRequest` but for
|
||||
MCP-style invocations. Only header NAMES are surfaced — header values are
|
||||
intentionally omitted because they typically carry authentication
|
||||
secrets.
|
||||
|
||||
Attributes:
|
||||
request_id: Unique identifier for this approval request. Matches the
|
||||
id workflow event-emitters use.
|
||||
tool_name: Evaluated name of the tool to be invoked.
|
||||
server_url: Evaluated MCP server URL.
|
||||
server_label: Optional human-readable label for diagnostics.
|
||||
arguments: Evaluated arguments to be forwarded to the tool.
|
||||
header_names: Sorted list of outbound header names (no values). Empty
|
||||
when no headers are configured.
|
||||
"""
|
||||
|
||||
request_id: str
|
||||
tool_name: str
|
||||
server_url: str
|
||||
server_label: str | None
|
||||
arguments: dict[str, Any]
|
||||
header_names: list[str] = field(default_factory=lambda: [])
|
||||
|
||||
|
||||
@dataclass
|
||||
class _MCPToolApprovalState:
|
||||
"""Internal state saved during the approval yield for resumption.
|
||||
|
||||
Stores **evaluated** values for non-secret fields to prevent
|
||||
"approve X / execute Y" attacks. Stores the raw expression string for
|
||||
``headers`` so that secret values are NOT persisted in checkpoint state;
|
||||
the expressions are re-evaluated against current state on resume.
|
||||
"""
|
||||
|
||||
server_url: str
|
||||
tool_name: str
|
||||
server_label: str | None
|
||||
arguments: dict[str, Any]
|
||||
connection_name: str | None
|
||||
headers_def: Any
|
||||
auto_send: bool
|
||||
conversation_id_expr: str | None
|
||||
output_messages_path: str | None
|
||||
output_result_path: str | None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _get_messages_path(state: DeclarativeWorkflowState, conversation_id_expr: str | None) -> str | None:
|
||||
"""Return the configured conversation messages path, if any.
|
||||
|
||||
Returns ``System.conversations.{evaluated_id}.messages`` when a
|
||||
``conversation_id_expr`` is configured and evaluates to a non-empty value.
|
||||
Returns ``None`` when no conversation id expression is configured or when
|
||||
the expression evaluates to ``None`` or an empty string (mirrors .NET
|
||||
``GetConversationId`` behaviour).
|
||||
"""
|
||||
if not conversation_id_expr:
|
||||
return None
|
||||
evaluated = state.eval_if_expression(conversation_id_expr)
|
||||
if evaluated is None or (isinstance(evaluated, str) and not evaluated):
|
||||
return None
|
||||
return f"System.conversations.{evaluated}.messages"
|
||||
|
||||
|
||||
def _get_output_path(action_def: Mapping[str, Any], key: str) -> str | None:
|
||||
"""Extract a state path from ``output.{key}`` field.
|
||||
|
||||
Supports two YAML shapes:
|
||||
|
||||
- ``output: { result: Local.MyVar }`` — plain string.
|
||||
- ``output: { result: { path: Local.MyVar } }`` — object form.
|
||||
"""
|
||||
output: Any = action_def.get("output")
|
||||
if not isinstance(output, Mapping):
|
||||
return None
|
||||
value: Any = output.get(key) # type: ignore[reportUnknownMemberType]
|
||||
if isinstance(value, str):
|
||||
return value or None
|
||||
if isinstance(value, Mapping):
|
||||
path: Any = value.get("path") # type: ignore[reportUnknownMemberType]
|
||||
return path if isinstance(path, str) and path else None
|
||||
return None
|
||||
|
||||
|
||||
def _format_outputs_for_send(parsed_results: list[Any]) -> str:
|
||||
"""Render parsed MCP outputs to a string for ``ctx.yield_output(...)``.
|
||||
|
||||
- Empty list → ``""``.
|
||||
- All-string list → newline-joined.
|
||||
- Single element (any type — scalar, dict, list) → JSON-dumped element.
|
||||
This avoids surprising ``"[42]"`` / ``"[true]"`` / ``"[null]"`` when
|
||||
an MCP tool returns a single scalar JSON value.
|
||||
- Multi-element non-string list → JSON-dump the whole list.
|
||||
"""
|
||||
if not parsed_results:
|
||||
return ""
|
||||
if all(isinstance(item, str) for item in parsed_results):
|
||||
return "\n".join(parsed_results) # type: ignore[arg-type]
|
||||
if len(parsed_results) == 1:
|
||||
return json.dumps(parsed_results[0], ensure_ascii=False)
|
||||
return json.dumps(parsed_results, ensure_ascii=False)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Executor
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class InvokeMcpToolActionExecutor(DeclarativeActionExecutor):
|
||||
"""Executor for the ``InvokeMcpTool`` declarative action.
|
||||
|
||||
Dispatches through the supplied :class:`MCPToolHandler` and:
|
||||
|
||||
- Evaluates ``serverUrl`` / ``toolName`` / ``serverLabel`` / ``arguments``
|
||||
/ ``headers`` / ``connection.name`` from the action definition.
|
||||
- When ``requireApproval=true``: emits a :class:`MCPToolApprovalRequest`
|
||||
via ``ctx.request_info()`` and yields. On resume, the response is
|
||||
checked; on rejection, ``output.result`` is set to ``"Error: ..."`` and
|
||||
no tool call is made.
|
||||
- On success: parses each :class:`agent_framework.Content` output (text →
|
||||
JSON-first / data / uri → URI string) and assigns the parsed list to
|
||||
``output.result``. Builds a single Tool-role :class:`Message`
|
||||
containing all output contents and assigns it to ``output.messages``.
|
||||
When ``output.autoSend`` is true (default), emits the rendered string
|
||||
via ``ctx.yield_output(...)``. When ``conversationId`` is configured,
|
||||
appends an Assistant-role :class:`Message` with the same contents to
|
||||
``System.conversations.{id}.messages``.
|
||||
- On error returned by the handler (``is_error=True``): assigns
|
||||
``"Error: <message>"`` to ``output.result`` and completes normally
|
||||
(parity with .NET ``AssignErrorAsync``).
|
||||
|
||||
.. note::
|
||||
|
||||
``output.messages`` receives a SINGLE Tool-role :class:`Message`
|
||||
(containing the full tool output as ``contents``), unlike
|
||||
:class:`agent_framework_declarative.InvokeFunctionToolExecutor` which
|
||||
writes a list of two messages (assistant call + tool result). This
|
||||
matches the .NET ``InvokeMcpToolExecutor`` output contract.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
action_def: dict[str, Any],
|
||||
*,
|
||||
id: str | None = None,
|
||||
mcp_tool_handler: MCPToolHandler,
|
||||
) -> None:
|
||||
"""Create an MCP tool action executor.
|
||||
|
||||
Args:
|
||||
action_def: Parsed ``InvokeMcpTool`` YAML dict.
|
||||
id: Optional executor id (defaults to action id or generated).
|
||||
mcp_tool_handler: Handler used to dispatch MCP tool calls.
|
||||
Required: the builder enforces presence at workflow-build
|
||||
time.
|
||||
"""
|
||||
super().__init__(action_def, id=id)
|
||||
self._mcp_tool_handler = mcp_tool_handler
|
||||
|
||||
# ----- Main handler --------------------------------------------------------
|
||||
|
||||
@handler
|
||||
async def handle_action(
|
||||
self,
|
||||
trigger: Any,
|
||||
ctx: WorkflowContext[ActionComplete, str],
|
||||
) -> None:
|
||||
"""Execute the MCP tool action."""
|
||||
state = await self._ensure_state_initialized(ctx, trigger)
|
||||
|
||||
server_url = self._get_server_url(state)
|
||||
tool_name = self._get_tool_name(state)
|
||||
server_label = self._get_server_label(state)
|
||||
arguments = self._get_arguments(state)
|
||||
headers = self._get_headers(state)
|
||||
connection_name = self._get_connection_name(state)
|
||||
require_approval = self._get_require_approval(state)
|
||||
auto_send = self._get_auto_send(state)
|
||||
conversation_id_expr = self._action_def.get("conversationId")
|
||||
output_messages_path = _get_output_path(self._action_def, "messages")
|
||||
output_result_path = _get_output_path(self._action_def, "result")
|
||||
|
||||
if require_approval:
|
||||
request_id = str(uuid.uuid4())
|
||||
approval_state = _MCPToolApprovalState(
|
||||
server_url=server_url,
|
||||
tool_name=tool_name,
|
||||
server_label=server_label,
|
||||
arguments=arguments,
|
||||
connection_name=connection_name,
|
||||
headers_def=self._action_def.get("headers"),
|
||||
auto_send=auto_send,
|
||||
conversation_id_expr=conversation_id_expr if isinstance(conversation_id_expr, str) else None,
|
||||
output_messages_path=output_messages_path,
|
||||
output_result_path=output_result_path,
|
||||
)
|
||||
ctx.state.set(self._approval_key(), approval_state)
|
||||
|
||||
request = MCPToolApprovalRequest(
|
||||
request_id=request_id,
|
||||
tool_name=tool_name,
|
||||
server_url=server_url,
|
||||
server_label=server_label,
|
||||
arguments=arguments,
|
||||
header_names=sorted(headers.keys()),
|
||||
)
|
||||
logger.info(
|
||||
"%s: requesting approval for MCP tool '%s' on '%s'",
|
||||
self.__class__.__name__,
|
||||
tool_name,
|
||||
server_url,
|
||||
)
|
||||
await ctx.request_info(request, ToolApprovalResponse, request_id=request_id)
|
||||
# Workflow yields here — resume in handle_approval_response.
|
||||
return
|
||||
|
||||
# No approval required - invoke directly.
|
||||
invocation = MCPToolInvocation(
|
||||
server_url=server_url,
|
||||
tool_name=tool_name,
|
||||
server_label=server_label,
|
||||
arguments=arguments,
|
||||
headers=headers,
|
||||
connection_name=connection_name,
|
||||
)
|
||||
result = await self._invoke_with_narrow_catch(invocation)
|
||||
await self._process_result(
|
||||
ctx=ctx,
|
||||
state=state,
|
||||
result=result,
|
||||
auto_send=auto_send,
|
||||
conversation_id_expr=conversation_id_expr if isinstance(conversation_id_expr, str) else None,
|
||||
output_messages_path=output_messages_path,
|
||||
output_result_path=output_result_path,
|
||||
)
|
||||
await ctx.send_message(ActionComplete())
|
||||
|
||||
# ----- Approval response handler ------------------------------------------
|
||||
|
||||
@response_handler
|
||||
async def handle_approval_response(
|
||||
self,
|
||||
original_request: MCPToolApprovalRequest,
|
||||
response: ToolApprovalResponse,
|
||||
ctx: WorkflowContext[ActionComplete, str],
|
||||
) -> None:
|
||||
"""Resume after the workflow yielded for an approval request."""
|
||||
state = self._get_state(ctx.state)
|
||||
approval_key = self._approval_key()
|
||||
|
||||
try:
|
||||
approval_state: _MCPToolApprovalState = ctx.state.get(approval_key)
|
||||
except KeyError:
|
||||
logger.error("%s: approval state missing for executor '%s'", self.__class__.__name__, self.id)
|
||||
await ctx.send_message(ActionComplete())
|
||||
return
|
||||
try:
|
||||
ctx.state.delete(approval_key)
|
||||
except KeyError:
|
||||
logger.warning("%s: approval state already deleted for '%s'", self.__class__.__name__, self.id)
|
||||
|
||||
if not response.approved:
|
||||
logger.info(
|
||||
"%s: MCP tool '%s' rejected: %s",
|
||||
self.__class__.__name__,
|
||||
approval_state.tool_name,
|
||||
response.reason,
|
||||
)
|
||||
self._assign_error(
|
||||
state, approval_state.output_result_path, "MCP tool invocation was not approved by user."
|
||||
)
|
||||
await ctx.send_message(ActionComplete())
|
||||
return
|
||||
|
||||
# Approved — re-evaluate headers (not stored at approval time for security).
|
||||
headers = self._evaluate_headers(state, approval_state.headers_def)
|
||||
|
||||
invocation = MCPToolInvocation(
|
||||
server_url=approval_state.server_url,
|
||||
tool_name=approval_state.tool_name,
|
||||
server_label=approval_state.server_label,
|
||||
arguments=approval_state.arguments,
|
||||
headers=headers,
|
||||
connection_name=approval_state.connection_name,
|
||||
)
|
||||
result = await self._invoke_with_narrow_catch(invocation)
|
||||
await self._process_result(
|
||||
ctx=ctx,
|
||||
state=state,
|
||||
result=result,
|
||||
auto_send=approval_state.auto_send,
|
||||
conversation_id_expr=approval_state.conversation_id_expr,
|
||||
output_messages_path=approval_state.output_messages_path,
|
||||
output_result_path=approval_state.output_result_path,
|
||||
)
|
||||
await ctx.send_message(ActionComplete())
|
||||
|
||||
# ----- Field resolution ----------------------------------------------------
|
||||
|
||||
def _get_server_url(self, state: DeclarativeWorkflowState) -> str:
|
||||
raw = self._action_def.get("serverUrl")
|
||||
if raw is None:
|
||||
raise ValueError("InvokeMcpTool requires a 'serverUrl' field.")
|
||||
evaluated = state.eval_if_expression(raw)
|
||||
if not isinstance(evaluated, str) or not evaluated:
|
||||
raise ValueError("InvokeMcpTool 'serverUrl' evaluated to an empty value.")
|
||||
return evaluated
|
||||
|
||||
def _get_tool_name(self, state: DeclarativeWorkflowState) -> str:
|
||||
raw = self._action_def.get("toolName")
|
||||
if raw is None:
|
||||
raise ValueError("InvokeMcpTool requires a 'toolName' field.")
|
||||
evaluated = state.eval_if_expression(raw)
|
||||
if not isinstance(evaluated, str) or not evaluated:
|
||||
raise ValueError("InvokeMcpTool 'toolName' evaluated to an empty value.")
|
||||
return evaluated
|
||||
|
||||
def _get_server_label(self, state: DeclarativeWorkflowState) -> str | None:
|
||||
raw = self._action_def.get("serverLabel")
|
||||
if raw is None:
|
||||
return None
|
||||
evaluated = state.eval_if_expression(raw)
|
||||
if evaluated is None:
|
||||
return None
|
||||
text = str(evaluated)
|
||||
return text or None
|
||||
|
||||
def _get_arguments(self, state: DeclarativeWorkflowState) -> dict[str, Any]:
|
||||
"""Evaluate ``arguments`` map. Preserves ``None`` values (parity with .NET)."""
|
||||
raw = self._action_def.get("arguments")
|
||||
if raw is None:
|
||||
return {}
|
||||
if not isinstance(raw, Mapping) or not raw:
|
||||
return {}
|
||||
result: dict[str, Any] = {}
|
||||
for key, value in raw.items(): # type: ignore[reportUnknownVariableType]
|
||||
if not isinstance(key, str) or not key:
|
||||
continue
|
||||
result[key] = state.eval_if_expression(value)
|
||||
return result
|
||||
|
||||
def _get_headers(self, state: DeclarativeWorkflowState) -> dict[str, str]:
|
||||
return self._evaluate_headers(state, self._action_def.get("headers"))
|
||||
|
||||
@staticmethod
|
||||
def _evaluate_headers(state: DeclarativeWorkflowState, headers_def: Any) -> dict[str, str]:
|
||||
"""Evaluate the ``headers`` map. Empty string values are skipped."""
|
||||
if not isinstance(headers_def, Mapping) or not headers_def:
|
||||
return {}
|
||||
result: dict[str, str] = {}
|
||||
for key, value in headers_def.items(): # type: ignore[reportUnknownVariableType]
|
||||
if not isinstance(key, str) or not key:
|
||||
continue
|
||||
evaluated = state.eval_if_expression(value)
|
||||
if evaluated is None:
|
||||
continue
|
||||
text = str(evaluated)
|
||||
if not text:
|
||||
continue
|
||||
result[key] = text
|
||||
return result
|
||||
|
||||
def _get_connection_name(self, state: DeclarativeWorkflowState) -> str | None:
|
||||
connection = self._action_def.get("connection")
|
||||
if not isinstance(connection, Mapping):
|
||||
return None
|
||||
name_expr: Any = connection.get("name") # type: ignore[reportUnknownMemberType]
|
||||
if name_expr is None:
|
||||
return None
|
||||
evaluated = state.eval_if_expression(name_expr)
|
||||
if evaluated is None:
|
||||
return None
|
||||
text = str(evaluated)
|
||||
return text or None
|
||||
|
||||
def _get_require_approval(self, state: DeclarativeWorkflowState) -> bool:
|
||||
raw = self._action_def.get("requireApproval")
|
||||
if raw is None:
|
||||
return False
|
||||
evaluated = state.eval_if_expression(raw)
|
||||
if isinstance(evaluated, bool):
|
||||
return evaluated
|
||||
if isinstance(evaluated, str):
|
||||
return evaluated.strip().lower() in {"true", "1", "yes"}
|
||||
return bool(evaluated)
|
||||
|
||||
def _get_auto_send(self, state: DeclarativeWorkflowState) -> bool:
|
||||
output: Any = self._action_def.get("output")
|
||||
if not isinstance(output, Mapping):
|
||||
return True
|
||||
raw: Any = output.get("autoSend") # type: ignore[reportUnknownMemberType]
|
||||
if raw is None:
|
||||
return True
|
||||
evaluated = state.eval_if_expression(raw)
|
||||
if isinstance(evaluated, bool):
|
||||
return evaluated
|
||||
if isinstance(evaluated, str):
|
||||
return evaluated.strip().lower() in {"true", "1", "yes"}
|
||||
return bool(evaluated)
|
||||
|
||||
# ----- Invocation + error handling ----------------------------------------
|
||||
|
||||
async def _invoke_with_narrow_catch(self, invocation: MCPToolInvocation) -> MCPToolResult:
|
||||
"""Invoke the handler with a narrow exception catch.
|
||||
|
||||
Only known transport / tool exceptions are normalised to an error
|
||||
result. Programmer bugs (TypeError, ValueError from misuse, etc.)
|
||||
propagate so they fail loudly.
|
||||
|
||||
``asyncio.CancelledError`` is a ``BaseException``, not ``Exception``,
|
||||
so it is not caught here and propagates unchanged for workflow
|
||||
cancellation.
|
||||
"""
|
||||
try:
|
||||
return await self._mcp_tool_handler.invoke_tool(invocation)
|
||||
except ToolExecutionException as exc:
|
||||
message = str(exc) or type(exc).__name__
|
||||
return MCPToolResult(
|
||||
outputs=[Content.from_text(f"Error: {message}")],
|
||||
is_error=True,
|
||||
error_message=message,
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
message = f"{type(exc).__name__}: {exc}" if str(exc) else type(exc).__name__
|
||||
return MCPToolResult(
|
||||
outputs=[Content.from_text(f"Error: {message}")],
|
||||
is_error=True,
|
||||
error_message=message,
|
||||
)
|
||||
except Exception as exc:
|
||||
try:
|
||||
from mcp.shared.exceptions import McpError
|
||||
except ImportError: # pragma: no cover - mcp is a hard dep
|
||||
raise
|
||||
if isinstance(exc, McpError):
|
||||
message = str(exc) or type(exc).__name__
|
||||
return MCPToolResult(
|
||||
outputs=[Content.from_text(f"Error: {message}")],
|
||||
is_error=True,
|
||||
error_message=message,
|
||||
)
|
||||
raise
|
||||
|
||||
# ----- Result handling -----------------------------------------------------
|
||||
|
||||
async def _process_result(
|
||||
self,
|
||||
*,
|
||||
ctx: WorkflowContext[ActionComplete, str],
|
||||
state: DeclarativeWorkflowState,
|
||||
result: MCPToolResult,
|
||||
auto_send: bool,
|
||||
conversation_id_expr: str | None,
|
||||
output_messages_path: str | None,
|
||||
output_result_path: str | None,
|
||||
) -> None:
|
||||
"""Apply ``result`` to workflow state per the configured output paths."""
|
||||
if result.is_error:
|
||||
# Error path mirrors .NET ``AssignErrorAsync`` — only the result
|
||||
# path is touched; messages / autoSend / conversation are not.
|
||||
self._assign_error(
|
||||
state,
|
||||
output_result_path,
|
||||
result.error_message or "MCP tool invocation failed.",
|
||||
)
|
||||
return
|
||||
|
||||
parsed_results = _parse_outputs(result.outputs)
|
||||
if output_result_path is not None and parsed_results:
|
||||
state.set(output_result_path, parsed_results)
|
||||
|
||||
# Single Tool-role message (matches .NET line 178 contract). Differs
|
||||
# from InvokeFunctionTool's two-message [assistant call, tool result]
|
||||
# convention.
|
||||
tool_message = Message(role="tool", contents=list(result.outputs))
|
||||
if output_messages_path is not None:
|
||||
state.set(output_messages_path, tool_message)
|
||||
|
||||
if auto_send and parsed_results:
|
||||
await ctx.yield_output(_format_outputs_for_send(parsed_results))
|
||||
|
||||
if conversation_id_expr:
|
||||
messages_path = _get_messages_path(state, conversation_id_expr)
|
||||
if messages_path is not None:
|
||||
# Mirrors .NET: conversation gets ASSISTANT-role message with
|
||||
# the same outputs (so chat history reads it as the agent's
|
||||
# contribution).
|
||||
assistant_message = Message(role="assistant", contents=list(result.outputs))
|
||||
state.append(messages_path, assistant_message)
|
||||
|
||||
@staticmethod
|
||||
def _assign_error(
|
||||
state: DeclarativeWorkflowState,
|
||||
output_result_path: str | None,
|
||||
error_message: str,
|
||||
) -> None:
|
||||
"""Mirror .NET ``AssignErrorAsync``: store ``"Error: <msg>"`` at the result path."""
|
||||
if output_result_path is None:
|
||||
return
|
||||
state.set(output_result_path, f"Error: {error_message}")
|
||||
|
||||
def _approval_key(self) -> str:
|
||||
return f"{_MCP_APPROVAL_STATE_KEY}_{self.id}"
|
||||
|
||||
|
||||
def _parse_outputs(outputs: list[Content]) -> list[Any]:
|
||||
"""Parse :class:`Content` outputs into Python values for ``output.result``.
|
||||
|
||||
Mirrors .NET ``AssignResultAsync``:
|
||||
|
||||
- ``TextContent`` → JSON-parse text; on failure use the raw text.
|
||||
- ``DataContent`` / ``UriContent`` → ``content.uri``.
|
||||
- Other content kinds → ``str(content)``.
|
||||
"""
|
||||
parsed: list[Any] = []
|
||||
for content in outputs:
|
||||
kind = getattr(content, "type", None)
|
||||
if kind == "text":
|
||||
text_value = getattr(content, "text", None)
|
||||
text_str = "" if text_value is None else str(text_value)
|
||||
try:
|
||||
parsed.append(json.loads(text_str))
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
parsed.append(text_str)
|
||||
continue
|
||||
if kind in ("data", "uri"):
|
||||
uri_value = getattr(content, "uri", None)
|
||||
parsed.append("" if uri_value is None else str(uri_value))
|
||||
continue
|
||||
parsed.append(str(content))
|
||||
return parsed
|
||||
|
||||
|
||||
MCP_ACTION_EXECUTORS: dict[str, type[DeclarativeActionExecutor]] = {
|
||||
"InvokeMcpTool": InvokeMcpToolActionExecutor,
|
||||
}
|
||||
@@ -29,6 +29,7 @@ from .._loader import AgentFactory
|
||||
from ._declarative_builder import DeclarativeWorkflowBuilder
|
||||
from ._errors import DeclarativeWorkflowError
|
||||
from ._http_handler import HttpRequestHandler
|
||||
from ._mcp_handler import MCPToolHandler
|
||||
|
||||
logger = logging.getLogger("agent_framework.declarative")
|
||||
|
||||
@@ -91,6 +92,7 @@ class WorkflowFactory:
|
||||
checkpoint_storage: CheckpointStorage | None = None,
|
||||
max_iterations: int | None = None,
|
||||
http_request_handler: HttpRequestHandler | None = None,
|
||||
mcp_tool_handler: MCPToolHandler | None = None,
|
||||
) -> None:
|
||||
"""Initialize the workflow factory.
|
||||
|
||||
@@ -110,6 +112,13 @@ class WorkflowFactory:
|
||||
otherwise. Use :class:`agent_framework.declarative.DefaultHttpRequestHandler`
|
||||
for a no-policy ``httpx``-based default, or supply your own implementation
|
||||
to enforce SSRF guards, allowlisting, or auth resolution.
|
||||
mcp_tool_handler: Optional handler used to dispatch MCP tool calls for
|
||||
``InvokeMcpTool``. Required if the workflow contains any
|
||||
``InvokeMcpTool``; build will fail with :class:`DeclarativeWorkflowError`
|
||||
otherwise. Use :class:`agent_framework.declarative.DefaultMCPToolHandler`
|
||||
for a default backed by :class:`agent_framework.MCPStreamableHTTPTool`,
|
||||
or supply your own implementation to enforce SSRF guards, allowlisting,
|
||||
or auth/connection resolution.
|
||||
|
||||
Examples:
|
||||
.. code-block:: python
|
||||
@@ -150,6 +159,7 @@ class WorkflowFactory:
|
||||
self._checkpoint_storage = checkpoint_storage
|
||||
self._max_iterations = max_iterations
|
||||
self._http_request_handler = http_request_handler
|
||||
self._mcp_tool_handler = mcp_tool_handler
|
||||
|
||||
def create_workflow_from_yaml_path(
|
||||
self,
|
||||
@@ -394,6 +404,7 @@ class WorkflowFactory:
|
||||
checkpoint_storage=self._checkpoint_storage,
|
||||
max_iterations=self._max_iterations,
|
||||
http_request_handler=self._http_request_handler,
|
||||
mcp_tool_handler=self._mcp_tool_handler,
|
||||
)
|
||||
workflow = graph_builder.build()
|
||||
except ValueError as e:
|
||||
|
||||
@@ -0,0 +1,494 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""MCP tool handler abstraction for declarative workflows.
|
||||
|
||||
Mirrors the .NET ``IMcpToolHandler`` / ``DefaultMcpToolHandler`` pair from
|
||||
``Microsoft.Agents.AI.Workflows.Declarative.Mcp``. Provides:
|
||||
|
||||
- :class:`MCPToolInvocation` — request input data passed from the executor.
|
||||
- :class:`MCPToolResult` — response data returned to the executor.
|
||||
- :class:`MCPToolHandler` — :class:`typing.Protocol` callers implement to plug
|
||||
in custom transports (e.g. with allowlisting, Foundry connection resolution,
|
||||
per-server auth, etc.).
|
||||
- :class:`DefaultMCPToolHandler` — production-grade default backed by
|
||||
:class:`agent_framework.MCPStreamableHTTPTool`.
|
||||
|
||||
Security note: :class:`DefaultMCPToolHandler` performs **no** URL filtering or
|
||||
SSRF protection. Production deployments should supply a custom handler that
|
||||
enforces an allowlist or DNS-rebinding-resistant policy. This split mirrors the
|
||||
.NET design.
|
||||
|
||||
Prompt-injection note: MCP tool outputs flow back into agent conversations
|
||||
(via ``conversationId`` and Tool-role messages emitted by the executor) so
|
||||
they share the same risk surface as ``HttpRequestAction``. Workflow authors
|
||||
must trust the MCP server they invoke.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from collections import OrderedDict
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any, Protocol, cast, runtime_checkable
|
||||
|
||||
import httpx
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from agent_framework import Content
|
||||
|
||||
__all__ = [
|
||||
"ClientProvider",
|
||||
"DefaultMCPToolHandler",
|
||||
"MCPToolHandler",
|
||||
"MCPToolInvocation",
|
||||
"MCPToolResult",
|
||||
]
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_CACHE_MAX_SIZE = 32
|
||||
|
||||
|
||||
@dataclass
|
||||
class MCPToolInvocation:
|
||||
"""Description of an MCP tool call to be dispatched by a :class:`MCPToolHandler`.
|
||||
|
||||
Mirrors the input parameters of the .NET ``IMcpToolHandler.InvokeToolAsync``
|
||||
method. Field semantics:
|
||||
|
||||
- ``server_url``: Absolute URL of the MCP server. Already evaluated from
|
||||
the YAML expression.
|
||||
- ``server_label``: Optional human-readable label used for diagnostics
|
||||
and as the underlying ``MCPStreamableHTTPTool`` name.
|
||||
- ``tool_name``: Name of the tool to invoke on the MCP server.
|
||||
- ``arguments``: Tool arguments. Already evaluated; values may be any
|
||||
JSON-serialisable Python object (str, int, bool, dict, list, None).
|
||||
- ``headers``: Outbound HTTP headers (e.g. authentication). Empty values
|
||||
are skipped by the executor before construction.
|
||||
- ``connection_name``: Optional Foundry connection name forwarded for
|
||||
handlers that resolve auth/credentials by connection. The default
|
||||
handler does not consume this field.
|
||||
"""
|
||||
|
||||
server_url: str
|
||||
tool_name: str
|
||||
server_label: str | None = None
|
||||
arguments: dict[str, Any] = field(default_factory=dict) # type: ignore[reportUnknownVariableType]
|
||||
headers: dict[str, str] = field(default_factory=dict) # type: ignore[reportUnknownVariableType]
|
||||
connection_name: str | None = None
|
||||
|
||||
|
||||
def _empty_outputs() -> list[Any]:
|
||||
"""Default factory for ``MCPToolResult.outputs``.
|
||||
|
||||
Typed as ``list[Any]`` here to keep the dataclass field's runtime
|
||||
factory simple; the public type on :class:`MCPToolResult` is
|
||||
``list[Content]``.
|
||||
"""
|
||||
return []
|
||||
|
||||
|
||||
@dataclass
|
||||
class MCPToolResult:
|
||||
"""Response returned by an :class:`MCPToolHandler`.
|
||||
|
||||
Mirrors the .NET ``McpServerToolResultContent`` shape. ``outputs`` is a
|
||||
list of :class:`agent_framework.Content` items as parsed by the MCP
|
||||
transport (TextContent / DataContent / UriContent / etc.).
|
||||
|
||||
On error, ``is_error`` is ``True``, ``error_message`` carries a human
|
||||
readable description, and ``outputs`` typically contains a single
|
||||
``Content.from_text("Error: ...")`` entry for downstream display.
|
||||
"""
|
||||
|
||||
outputs: list[Content] = field(default_factory=_empty_outputs)
|
||||
is_error: bool = False
|
||||
error_message: str | None = None
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class MCPToolHandler(Protocol):
|
||||
"""Protocol for MCP tool handlers used by ``InvokeMcpTool``.
|
||||
|
||||
Mirrors :class:`HttpRequestHandler` — declares ONLY the invocation method.
|
||||
Lifecycle methods (``aclose`` / ``__aenter__`` / ``__aexit__``) are NOT
|
||||
part of the Protocol; concrete implementations may add them as
|
||||
appropriate.
|
||||
|
||||
Implementations must be safe to call concurrently from multiple workflow
|
||||
runs. Implementations are responsible for any URL allowlisting, SSRF
|
||||
guards, retry policies, auth resolution, and other policies the workflow
|
||||
author wants applied.
|
||||
"""
|
||||
|
||||
async def invoke_tool(self, invocation: MCPToolInvocation) -> MCPToolResult:
|
||||
"""Dispatch ``invocation`` and return the result.
|
||||
|
||||
Args:
|
||||
invocation: Description of the MCP tool call to perform.
|
||||
|
||||
Returns:
|
||||
The :class:`MCPToolResult` carrying the parsed outputs (or an
|
||||
error flag if the tool raised). Implementations SHOULD return a
|
||||
result with ``is_error=True`` rather than raising for transport
|
||||
or tool-level failures, so the workflow can store the message in
|
||||
``output.result`` (matching .NET ``AssignErrorAsync`` behaviour).
|
||||
They MAY raise on unexpected programming errors — these will be
|
||||
propagated unchanged by the executor so they fail loudly.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
ClientProvider = Callable[[MCPToolInvocation], Awaitable["httpx.AsyncClient | None"]]
|
||||
|
||||
|
||||
@dataclass
|
||||
class _CacheEntry:
|
||||
"""Internal record stored in the LRU cache."""
|
||||
|
||||
tool: Any # MCPStreamableHTTPTool — typed Any to avoid import at module load
|
||||
owned_httpx_client: httpx.AsyncClient | None
|
||||
|
||||
|
||||
class DefaultMCPToolHandler:
|
||||
"""Default :class:`MCPToolHandler` backed by :class:`agent_framework.MCPStreamableHTTPTool`.
|
||||
|
||||
Caches one :class:`agent_framework.MCPStreamableHTTPTool` instance per
|
||||
``(server_url, server_label, connection_name, headers_hash)`` in a
|
||||
bounded LRU. The cache prevents re-establishing an MCP session for every
|
||||
invocation while ensuring different header sets (auth tokens) cannot
|
||||
share a session — matches the .NET design intent while bounding
|
||||
cardinality. ``server_label`` and ``connection_name`` participate in
|
||||
the key so that callers using ``client_provider`` to dispatch on those
|
||||
fields receive a fresh client per logical connection (see below).
|
||||
Header *names* are lower-cased inside the hash payload only — the
|
||||
headers passed on the wire keep the caller's original casing — so two
|
||||
YAML actions that spell ``Authorization`` differently still share a
|
||||
cache entry.
|
||||
|
||||
Construction modes:
|
||||
|
||||
1. ``DefaultMCPToolHandler()`` — owns its own ``httpx.AsyncClient``
|
||||
instances created lazily per cache entry. Closed by :meth:`aclose`.
|
||||
2. ``DefaultMCPToolHandler(client_provider=cb)`` — per-server client
|
||||
lookup (parity with .NET ``httpClientProvider`` callback). The
|
||||
callback receives the full :class:`MCPToolInvocation` so it can
|
||||
dispatch on ``server_url`` / ``connection_name`` / ``server_label``.
|
||||
Returning ``None`` falls back to an internally-created client. Caller
|
||||
supplied clients are NOT closed by :meth:`aclose`.
|
||||
|
||||
.. warning::
|
||||
|
||||
This handler performs **no** URL filtering or SSRF protection. Wrap
|
||||
or replace it with a custom handler in production deployments.
|
||||
|
||||
Args:
|
||||
client_provider: Optional per-server ``httpx.AsyncClient`` provider.
|
||||
cache_max_size: Maximum number of cached MCP clients. When exceeded,
|
||||
the least-recently-used entry is evicted and its client closed
|
||||
(only owned clients are closed; caller-supplied ones are not).
|
||||
Defaults to ``32``.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
client_provider: ClientProvider | None = None,
|
||||
cache_max_size: int = _DEFAULT_CACHE_MAX_SIZE,
|
||||
) -> None:
|
||||
if cache_max_size <= 0:
|
||||
raise ValueError(f"cache_max_size must be positive, got {cache_max_size}")
|
||||
self._client_provider = client_provider
|
||||
self._cache_max_size = cache_max_size
|
||||
self._cache: OrderedDict[tuple[str, str, str, str], _CacheEntry] = OrderedDict()
|
||||
# Outer lock guards the cache + in-flight-future map only — never
|
||||
# held across network I/O.
|
||||
self._cache_lock = asyncio.Lock()
|
||||
# Per-key in-flight futures: while one task is connecting, other
|
||||
# tasks awaiting the same key will await the same future and share
|
||||
# the resulting cache entry.
|
||||
self._inflight: dict[tuple[str, str, str, str], asyncio.Future[_CacheEntry]] = {}
|
||||
# Set by ``aclose`` to prevent post-close cache insertions and to
|
||||
# reject new ``invoke_tool`` calls. Once set, never cleared.
|
||||
self._closed = False
|
||||
|
||||
async def invoke_tool(self, invocation: MCPToolInvocation) -> MCPToolResult:
|
||||
"""Invoke ``invocation.tool_name`` on the cached MCP client for the server."""
|
||||
from agent_framework import Content
|
||||
from agent_framework.exceptions import ToolExecutionException
|
||||
|
||||
try:
|
||||
entry = await self._get_or_create_entry(invocation)
|
||||
except Exception as exc:
|
||||
# Connect / cache lookup failures surface as tool errors so the
|
||||
# workflow can store them at output.result without crashing.
|
||||
logger.warning(
|
||||
"DefaultMCPToolHandler: failed to obtain MCP client for url=%s tool=%s: %s",
|
||||
invocation.server_url,
|
||||
invocation.tool_name,
|
||||
exc,
|
||||
)
|
||||
message = f"Failed to connect to MCP server: {type(exc).__name__}: {exc}".rstrip(": ")
|
||||
return MCPToolResult(
|
||||
outputs=[Content.from_text(f"Error: {message}")],
|
||||
is_error=True,
|
||||
error_message=message,
|
||||
)
|
||||
|
||||
try:
|
||||
raw = await entry.tool.call_tool(invocation.tool_name, **invocation.arguments)
|
||||
except ToolExecutionException as exc:
|
||||
logger.info(
|
||||
"DefaultMCPToolHandler: tool '%s' on '%s' raised ToolExecutionException",
|
||||
invocation.tool_name,
|
||||
invocation.server_url,
|
||||
)
|
||||
message = str(exc) or type(exc).__name__
|
||||
return MCPToolResult(
|
||||
outputs=[Content.from_text(f"Error: {message}")],
|
||||
is_error=True,
|
||||
error_message=message,
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
message = f"{type(exc).__name__}: {exc}" if str(exc) else type(exc).__name__
|
||||
return MCPToolResult(
|
||||
outputs=[Content.from_text(f"Error: {message}")],
|
||||
is_error=True,
|
||||
error_message=message,
|
||||
)
|
||||
except Exception as exc:
|
||||
# Be defensive about MCP errors that may bubble up without being
|
||||
# wrapped in ToolExecutionException by custom parsers.
|
||||
try:
|
||||
from mcp.shared.exceptions import McpError
|
||||
except ImportError: # pragma: no cover - mcp is a hard dep but stay defensive
|
||||
raise
|
||||
if isinstance(exc, McpError):
|
||||
message = str(exc) or type(exc).__name__
|
||||
return MCPToolResult(
|
||||
outputs=[Content.from_text(f"Error: {message}")],
|
||||
is_error=True,
|
||||
error_message=message,
|
||||
)
|
||||
raise
|
||||
|
||||
# Defensive normalisation: call_tool is typed ``str | list[Content]``.
|
||||
# Default parser returns list, but custom parse_tool_results may return str.
|
||||
if isinstance(raw, str):
|
||||
outputs: list[Content] = [Content.from_text(raw)]
|
||||
else:
|
||||
outputs = list(raw)
|
||||
return MCPToolResult(outputs=outputs)
|
||||
|
||||
async def aclose(self) -> None:
|
||||
"""Close all cached MCP clients and the owned httpx clients.
|
||||
|
||||
Caller-supplied :class:`httpx.AsyncClient` instances (returned by the
|
||||
``client_provider`` callback) are NOT closed.
|
||||
|
||||
Idempotent — a second call returns immediately. Drains any in-flight
|
||||
``_create_entry`` tasks before returning so their resources are
|
||||
cleaned up; the in-flight tasks see ``self._closed`` in phase 3 of
|
||||
:meth:`_get_or_create_entry`, close their own entry, and resolve
|
||||
their future with ``RuntimeError("DefaultMCPToolHandler is closed")``.
|
||||
"""
|
||||
async with self._cache_lock:
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
entries = list(self._cache.values())
|
||||
self._cache.clear()
|
||||
inflight_futures = list(self._inflight.values())
|
||||
|
||||
# Wait for in-flight creations to finish their self-cleanup. Each
|
||||
# in-flight task self-closes its entry under the closed-flag branch
|
||||
# in phase 3 and resolves its future with ``RuntimeError``; we
|
||||
# swallow it here because the failure is expected at shutdown.
|
||||
for fut in inflight_futures:
|
||||
try:
|
||||
await fut
|
||||
except BaseException:
|
||||
logger.debug("DefaultMCPToolHandler: in-flight future raised during aclose", exc_info=True)
|
||||
continue
|
||||
|
||||
for entry in entries:
|
||||
await self._close_entry(entry)
|
||||
|
||||
async def __aenter__(self) -> DefaultMCPToolHandler:
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
|
||||
await self.aclose()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _get_or_create_entry(self, invocation: MCPToolInvocation) -> _CacheEntry:
|
||||
"""Look up (or create) the cached MCP client for this invocation."""
|
||||
key = self._cache_key(
|
||||
invocation.server_url,
|
||||
invocation.server_label,
|
||||
invocation.connection_name,
|
||||
invocation.headers,
|
||||
)
|
||||
|
||||
# Phase 1: check the cache and either claim creation or wait for an
|
||||
# already in-flight creation.
|
||||
creating = False
|
||||
async with self._cache_lock:
|
||||
if self._closed:
|
||||
raise RuntimeError("DefaultMCPToolHandler is closed")
|
||||
existing = self._cache.get(key)
|
||||
if existing is not None:
|
||||
self._cache.move_to_end(key)
|
||||
return existing
|
||||
inflight = self._inflight.get(key)
|
||||
if inflight is None:
|
||||
inflight = asyncio.get_running_loop().create_future()
|
||||
self._inflight[key] = inflight
|
||||
creating = True
|
||||
|
||||
if not creating:
|
||||
return await inflight
|
||||
|
||||
# Phase 2: we own creation. Build the entry outside the lock.
|
||||
try:
|
||||
entry = await self._create_entry(invocation)
|
||||
except BaseException as exc:
|
||||
async with self._cache_lock:
|
||||
self._inflight.pop(key, None)
|
||||
if not inflight.done():
|
||||
inflight.set_exception(exc if isinstance(exc, BaseException) else RuntimeError(str(exc)))
|
||||
# Mark the exception retrieved to suppress noisy "Future exception
|
||||
# was never retrieved" warnings when there are no other awaiters
|
||||
# (other awaiters still see the exception through their ``await``).
|
||||
inflight.exception()
|
||||
raise
|
||||
|
||||
# Phase 3: insert with LRU eviction; resolve the in-flight future.
|
||||
# If ``aclose`` ran while we were connecting, ``_closed`` is now
|
||||
# True; don't insert into the cache (it has been drained), close
|
||||
# the just-built entry, and surface the closed-handler error to
|
||||
# all awaiters of the future.
|
||||
evicted: _CacheEntry | None = None
|
||||
duplicate: _CacheEntry | None = None
|
||||
handler_closed = False
|
||||
async with self._cache_lock:
|
||||
self._inflight.pop(key, None)
|
||||
if self._closed:
|
||||
handler_closed = True
|
||||
else:
|
||||
existing = self._cache.get(key)
|
||||
if existing is not None:
|
||||
# Another writer beat us; prefer the existing entry and
|
||||
# discard ours after the lock is released.
|
||||
self._cache.move_to_end(key)
|
||||
duplicate = entry
|
||||
entry = existing
|
||||
else:
|
||||
self._cache[key] = entry
|
||||
self._cache.move_to_end(key)
|
||||
if len(self._cache) > self._cache_max_size:
|
||||
_evicted_key, evicted = self._cache.popitem(last=False)
|
||||
if not inflight.done():
|
||||
inflight.set_result(entry)
|
||||
|
||||
if handler_closed:
|
||||
# Close our orphaned entry; resolve the future with a clear
|
||||
# error so the caller (and any other awaiters) surface a
|
||||
# consistent "handler is closed" failure rather than receiving
|
||||
# an entry we are about to close behind their back.
|
||||
await self._close_entry(entry)
|
||||
err = RuntimeError("DefaultMCPToolHandler is closed")
|
||||
if not inflight.done():
|
||||
inflight.set_exception(err)
|
||||
inflight.exception()
|
||||
raise err
|
||||
if duplicate is not None:
|
||||
await self._close_entry(duplicate)
|
||||
if evicted is not None:
|
||||
await self._close_entry(evicted)
|
||||
return entry
|
||||
|
||||
async def _create_entry(self, invocation: MCPToolInvocation) -> _CacheEntry:
|
||||
"""Construct (and connect) a fresh MCP client for ``invocation``."""
|
||||
from agent_framework import MCPStreamableHTTPTool
|
||||
|
||||
provided_client: httpx.AsyncClient | None = None
|
||||
if self._client_provider is not None:
|
||||
provided_client = await self._client_provider(invocation)
|
||||
# Capture headers for this cache entry so the header_provider closure
|
||||
# always returns the same set, regardless of the runtime kwargs.
|
||||
captured_headers = dict(invocation.headers)
|
||||
|
||||
def _header_provider(_kwargs: dict[str, Any]) -> dict[str, str]:
|
||||
return captured_headers
|
||||
|
||||
tool: Any = MCPStreamableHTTPTool(
|
||||
name=invocation.server_label or "McpClient",
|
||||
url=invocation.server_url,
|
||||
load_prompts=False,
|
||||
http_client=provided_client,
|
||||
header_provider=_header_provider if captured_headers else None,
|
||||
)
|
||||
try:
|
||||
await tool.connect()
|
||||
except BaseException:
|
||||
try:
|
||||
await tool.close()
|
||||
except Exception: # pragma: no cover - best effort
|
||||
logger.debug("DefaultMCPToolHandler: error closing tool after failed connect", exc_info=True)
|
||||
raise
|
||||
|
||||
# ``MCPStreamableHTTPTool.get_mcp_client`` lazily creates an
|
||||
# ``httpx.AsyncClient`` when no caller client was provided AND a
|
||||
# ``header_provider`` was set. We treat any client allocated this
|
||||
# way as owned (closed by the handler). When the caller supplies
|
||||
# one, we never close it.
|
||||
owned_client: httpx.AsyncClient | None = None
|
||||
if provided_client is None:
|
||||
owned_client = cast("httpx.AsyncClient | None", getattr(tool, "_httpx_client", None))
|
||||
return _CacheEntry(tool=tool, owned_httpx_client=owned_client)
|
||||
|
||||
async def _close_entry(self, entry: _CacheEntry) -> None:
|
||||
"""Close the MCP tool and any owned httpx client."""
|
||||
try:
|
||||
await entry.tool.close()
|
||||
except Exception: # pragma: no cover - best effort
|
||||
logger.debug("DefaultMCPToolHandler: error closing MCP tool", exc_info=True)
|
||||
if entry.owned_httpx_client is not None:
|
||||
try:
|
||||
await entry.owned_httpx_client.aclose()
|
||||
except Exception: # pragma: no cover - best effort
|
||||
logger.debug("DefaultMCPToolHandler: error closing owned httpx client", exc_info=True)
|
||||
|
||||
@staticmethod
|
||||
def _cache_key(
|
||||
server_url: str,
|
||||
server_label: str | None,
|
||||
connection_name: str | None,
|
||||
headers: dict[str, str] | None,
|
||||
) -> tuple[str, str, str, str]:
|
||||
"""Build an order-independent cache key for the invocation identity.
|
||||
|
||||
The key includes ``server_label`` and ``connection_name`` so that
|
||||
callers using ``client_provider`` to dispatch on those fields
|
||||
receive a fresh client per logical connection (matches the
|
||||
documented dispatch contract).
|
||||
|
||||
Header *names* are lower-cased inside the hash payload only so
|
||||
that ``Authorization`` and ``authorization`` map to the same
|
||||
cache entry. Header values remain case-sensitive (per RFC 7235).
|
||||
"""
|
||||
if not headers:
|
||||
headers_hash = "0"
|
||||
else:
|
||||
normalized = sorted((k.lower(), v) for k, v in headers.items())
|
||||
payload = json.dumps(normalized, ensure_ascii=False)
|
||||
headers_hash = hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
||||
return (server_url, server_label or "", connection_name or "", headers_hash)
|
||||
@@ -0,0 +1,543 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Tests for ``DefaultMCPToolHandler``.
|
||||
|
||||
These tests exercise the real handler against a fake ``MCPStreamableHTTPTool``
|
||||
(no real MCP server, no real network) to cover the parts of the handler not
|
||||
exercisable through the executor stub: cache hit/miss/eviction, concurrent
|
||||
connect via in-flight futures, header isolation across cache keys,
|
||||
string-result normalisation, ``load_prompts=False`` verification, and
|
||||
owned-vs-caller httpx close semantics.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from agent_framework import Content
|
||||
from agent_framework.exceptions import ToolExecutionException
|
||||
|
||||
from agent_framework_declarative._workflows._mcp_handler import (
|
||||
DefaultMCPToolHandler,
|
||||
MCPToolInvocation,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
sys.version_info >= (3, 14),
|
||||
reason="Skipped on Python 3.14+ to keep parity with rest of declarative suite",
|
||||
)
|
||||
|
||||
|
||||
class FakeTool:
|
||||
"""Stand-in for ``MCPStreamableHTTPTool``.
|
||||
|
||||
Records constructor kwargs, tracks connect/close lifecycle, and dispatches
|
||||
``call_tool`` to a per-instance handler.
|
||||
"""
|
||||
|
||||
instances: list[FakeTool] = []
|
||||
|
||||
def __init__(self, **kwargs: Any) -> None:
|
||||
self.kwargs = kwargs
|
||||
self.connect_count = 0
|
||||
self.close_count = 0
|
||||
self.connect_delay: float = 0.0
|
||||
self.connect_error: BaseException | None = None
|
||||
self.call_handler: Any = lambda **_a: [Content.from_text("ok")]
|
||||
self._httpx_client: httpx.AsyncClient | None = None
|
||||
# Mimic MCPStreamableHTTPTool: when no caller client AND header_provider
|
||||
# is set, lazily allocate an owned httpx client during connect.
|
||||
FakeTool.instances.append(self)
|
||||
|
||||
async def connect(self) -> None:
|
||||
if self.connect_delay:
|
||||
await asyncio.sleep(self.connect_delay)
|
||||
if self.connect_error is not None:
|
||||
raise self.connect_error
|
||||
self.connect_count += 1
|
||||
# Mimic lazy httpx allocation when no client provided AND header_provider set.
|
||||
if self.kwargs.get("http_client") is None and self.kwargs.get("header_provider") is not None:
|
||||
self._httpx_client = httpx.AsyncClient()
|
||||
|
||||
async def close(self) -> None:
|
||||
self.close_count += 1
|
||||
|
||||
async def call_tool(self, tool_name: str, **arguments: Any) -> Any:
|
||||
return self.call_handler(tool_name=tool_name, **arguments)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_fake_instances() -> None:
|
||||
FakeTool.instances.clear()
|
||||
|
||||
|
||||
def _patch_tool() -> Any:
|
||||
"""Patch the lazy import inside ``_create_entry`` to substitute FakeTool."""
|
||||
import agent_framework
|
||||
|
||||
return patch.object(agent_framework, "MCPStreamableHTTPTool", FakeTool)
|
||||
|
||||
|
||||
def _invocation(
|
||||
*, server_url: str = "https://mcp.example/api", tool_name: str = "search", **overrides: Any
|
||||
) -> MCPToolInvocation:
|
||||
return MCPToolInvocation(
|
||||
server_url=server_url,
|
||||
tool_name=tool_name,
|
||||
**overrides,
|
||||
)
|
||||
|
||||
|
||||
# ---------- Construction ---------------------------------------------------
|
||||
|
||||
|
||||
class TestConstruction:
|
||||
def test_invalid_cache_size_raises(self) -> None:
|
||||
with pytest.raises(ValueError):
|
||||
DefaultMCPToolHandler(cache_max_size=0)
|
||||
with pytest.raises(ValueError):
|
||||
DefaultMCPToolHandler(cache_max_size=-3)
|
||||
|
||||
|
||||
# ---------- Tool kwargs ----------------------------------------------------
|
||||
|
||||
|
||||
class TestToolKwargs:
|
||||
@pytest.mark.asyncio
|
||||
async def test_load_prompts_false_passed_to_tool(self) -> None:
|
||||
handler = DefaultMCPToolHandler()
|
||||
with _patch_tool():
|
||||
await handler.invoke_tool(_invocation())
|
||||
assert len(FakeTool.instances) == 1
|
||||
assert FakeTool.instances[0].kwargs["load_prompts"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_server_label_used_as_tool_name(self) -> None:
|
||||
handler = DefaultMCPToolHandler()
|
||||
with _patch_tool():
|
||||
await handler.invoke_tool(_invocation(server_label="MyMcp"))
|
||||
assert FakeTool.instances[0].kwargs["name"] == "MyMcp"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_tool_name_when_no_label(self) -> None:
|
||||
handler = DefaultMCPToolHandler()
|
||||
with _patch_tool():
|
||||
await handler.invoke_tool(_invocation(server_label=None))
|
||||
assert FakeTool.instances[0].kwargs["name"] == "McpClient"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_header_provider_when_no_headers(self) -> None:
|
||||
handler = DefaultMCPToolHandler()
|
||||
with _patch_tool():
|
||||
await handler.invoke_tool(_invocation(headers={}))
|
||||
assert FakeTool.instances[0].kwargs["header_provider"] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_header_provider_returns_captured_headers(self) -> None:
|
||||
handler = DefaultMCPToolHandler()
|
||||
with _patch_tool():
|
||||
await handler.invoke_tool(_invocation(headers={"Authorization": "Bearer T"}))
|
||||
provider = FakeTool.instances[0].kwargs["header_provider"]
|
||||
assert provider({}) == {"Authorization": "Bearer T"}
|
||||
# Even if runtime kwargs change, captured headers stay the same.
|
||||
assert provider({"foo": "bar"}) == {"Authorization": "Bearer T"}
|
||||
|
||||
|
||||
# ---------- Cache behaviour ------------------------------------------------
|
||||
|
||||
|
||||
class TestCache:
|
||||
@pytest.mark.asyncio
|
||||
async def test_same_url_and_headers_hit_cache(self) -> None:
|
||||
handler = DefaultMCPToolHandler()
|
||||
with _patch_tool():
|
||||
await handler.invoke_tool(_invocation(headers={"X": "1"}))
|
||||
await handler.invoke_tool(_invocation(headers={"X": "1"}))
|
||||
# One tool created, connect called once.
|
||||
assert len(FakeTool.instances) == 1
|
||||
assert FakeTool.instances[0].connect_count == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_different_headers_create_separate_entries(self) -> None:
|
||||
handler = DefaultMCPToolHandler()
|
||||
with _patch_tool():
|
||||
await handler.invoke_tool(_invocation(headers={"Authorization": "tk-A"}))
|
||||
await handler.invoke_tool(_invocation(headers={"Authorization": "tk-B"}))
|
||||
assert len(FakeTool.instances) == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_different_urls_create_separate_entries(self) -> None:
|
||||
handler = DefaultMCPToolHandler()
|
||||
with _patch_tool():
|
||||
await handler.invoke_tool(_invocation(server_url="https://mcp.a/api"))
|
||||
await handler.invoke_tool(_invocation(server_url="https://mcp.b/api"))
|
||||
assert len(FakeTool.instances) == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lru_eviction_closes_old_entry(self) -> None:
|
||||
handler = DefaultMCPToolHandler(cache_max_size=2)
|
||||
with _patch_tool():
|
||||
await handler.invoke_tool(_invocation(server_url="https://a/"))
|
||||
await handler.invoke_tool(_invocation(server_url="https://b/"))
|
||||
# Inserting a third evicts the LRU entry (the first one).
|
||||
await handler.invoke_tool(_invocation(server_url="https://c/"))
|
||||
assert len(FakeTool.instances) == 3
|
||||
# First instance (https://a/) was evicted → close() called.
|
||||
assert FakeTool.instances[0].kwargs["url"] == "https://a/"
|
||||
assert FakeTool.instances[0].close_count == 1
|
||||
# Other two remain in cache → not closed.
|
||||
assert FakeTool.instances[1].close_count == 0
|
||||
assert FakeTool.instances[2].close_count == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_repeated_use_keeps_lru_alive(self) -> None:
|
||||
handler = DefaultMCPToolHandler(cache_max_size=2)
|
||||
with _patch_tool():
|
||||
await handler.invoke_tool(_invocation(server_url="https://a/"))
|
||||
await handler.invoke_tool(_invocation(server_url="https://b/"))
|
||||
# Touch a → b becomes LRU.
|
||||
await handler.invoke_tool(_invocation(server_url="https://a/"))
|
||||
# Insert c → b is evicted.
|
||||
await handler.invoke_tool(_invocation(server_url="https://c/"))
|
||||
# b was evicted.
|
||||
b = FakeTool.instances[1]
|
||||
assert b.kwargs["url"] == "https://b/"
|
||||
assert b.close_count == 1
|
||||
# a survived.
|
||||
a = FakeTool.instances[0]
|
||||
assert a.kwargs["url"] == "https://a/"
|
||||
assert a.close_count == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_connect_shares_one_entry(self) -> None:
|
||||
"""Multiple concurrent invocations with the same key must share one tool."""
|
||||
handler = DefaultMCPToolHandler()
|
||||
|
||||
# Slow down connect so concurrency window is observable.
|
||||
original_connect = FakeTool.connect
|
||||
|
||||
async def slow_connect(self: FakeTool) -> None:
|
||||
self.connect_delay = 0.05
|
||||
await original_connect(self)
|
||||
|
||||
with _patch_tool(), patch.object(FakeTool, "connect", slow_connect):
|
||||
results = await asyncio.gather(
|
||||
handler.invoke_tool(_invocation(headers={"X": "1"})),
|
||||
handler.invoke_tool(_invocation(headers={"X": "1"})),
|
||||
handler.invoke_tool(_invocation(headers={"X": "1"})),
|
||||
handler.invoke_tool(_invocation(headers={"X": "1"})),
|
||||
)
|
||||
assert all(not r.is_error for r in results)
|
||||
# Only one tool was created and connected, despite 4 concurrent calls.
|
||||
assert len(FakeTool.instances) == 1
|
||||
assert FakeTool.instances[0].connect_count == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_different_connection_names_create_separate_entries(self) -> None:
|
||||
"""Same URL/headers but different ``connection_name`` must dispatch separately."""
|
||||
handler = DefaultMCPToolHandler()
|
||||
with _patch_tool():
|
||||
await handler.invoke_tool(_invocation(connection_name="conn-A"))
|
||||
await handler.invoke_tool(_invocation(connection_name="conn-B"))
|
||||
assert len(FakeTool.instances) == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_different_server_labels_create_separate_entries(self) -> None:
|
||||
"""Same URL/headers but different ``server_label`` must dispatch separately."""
|
||||
handler = DefaultMCPToolHandler()
|
||||
with _patch_tool():
|
||||
await handler.invoke_tool(_invocation(server_label="LabelA"))
|
||||
await handler.invoke_tool(_invocation(server_label="LabelB"))
|
||||
assert len(FakeTool.instances) == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_identity_match_hits_cache(self) -> None:
|
||||
"""All four identity components match → single cached entry."""
|
||||
handler = DefaultMCPToolHandler()
|
||||
with _patch_tool():
|
||||
await handler.invoke_tool(_invocation(server_label="Lbl", connection_name="C", headers={"X": "1"}))
|
||||
await handler.invoke_tool(_invocation(server_label="Lbl", connection_name="C", headers={"X": "1"}))
|
||||
assert len(FakeTool.instances) == 1
|
||||
assert FakeTool.instances[0].connect_count == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_header_name_case_collapses_to_one_cache_entry(self) -> None:
|
||||
"""Header name spelling differences (case-only) must share a cache entry."""
|
||||
handler = DefaultMCPToolHandler()
|
||||
with _patch_tool():
|
||||
await handler.invoke_tool(_invocation(headers={"Authorization": "tk"}))
|
||||
await handler.invoke_tool(_invocation(headers={"authorization": "tk"}))
|
||||
await handler.invoke_tool(_invocation(headers={"AUTHORIZATION": "tk"}))
|
||||
assert len(FakeTool.instances) == 1
|
||||
assert FakeTool.instances[0].connect_count == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_header_value_case_does_not_collapse(self) -> None:
|
||||
"""Header *values* remain case-sensitive (different tokens → different sessions)."""
|
||||
handler = DefaultMCPToolHandler()
|
||||
with _patch_tool():
|
||||
await handler.invoke_tool(_invocation(headers={"Authorization": "Bearer-A"}))
|
||||
await handler.invoke_tool(_invocation(headers={"Authorization": "bearer-a"}))
|
||||
assert len(FakeTool.instances) == 2
|
||||
|
||||
|
||||
# ---------- Aclose semantics ----------------------------------------------
|
||||
|
||||
|
||||
class TestAclose:
|
||||
@pytest.mark.asyncio
|
||||
async def test_aclose_closes_owned_clients(self) -> None:
|
||||
handler = DefaultMCPToolHandler()
|
||||
with _patch_tool():
|
||||
await handler.invoke_tool(_invocation(headers={"X": "1"}))
|
||||
tool = FakeTool.instances[0]
|
||||
owned = tool._httpx_client
|
||||
assert owned is not None
|
||||
await handler.aclose()
|
||||
assert tool.close_count == 1
|
||||
assert owned.is_closed
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aclose_does_not_close_caller_supplied_client(self) -> None:
|
||||
caller_client = httpx.AsyncClient()
|
||||
|
||||
async def provider(_inv: MCPToolInvocation) -> httpx.AsyncClient:
|
||||
return caller_client
|
||||
|
||||
handler = DefaultMCPToolHandler(client_provider=provider)
|
||||
try:
|
||||
with _patch_tool():
|
||||
await handler.invoke_tool(_invocation(headers={"X": "1"}))
|
||||
await handler.aclose()
|
||||
assert FakeTool.instances[0].close_count == 1
|
||||
# Caller client must still be usable.
|
||||
assert not caller_client.is_closed
|
||||
finally:
|
||||
await caller_client.aclose()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_context_manager(self) -> None:
|
||||
with _patch_tool():
|
||||
async with DefaultMCPToolHandler() as handler:
|
||||
await handler.invoke_tool(_invocation())
|
||||
tool = FakeTool.instances[0]
|
||||
assert tool.close_count == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aclose_is_idempotent(self) -> None:
|
||||
"""A second ``aclose`` is a no-op (no exception, no double-close)."""
|
||||
handler = DefaultMCPToolHandler()
|
||||
with _patch_tool():
|
||||
await handler.invoke_tool(_invocation(headers={"X": "1"}))
|
||||
await handler.aclose()
|
||||
await handler.aclose()
|
||||
assert FakeTool.instances[0].close_count == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invoke_after_close_returns_error_result(self) -> None:
|
||||
"""Post-close ``invoke_tool`` surfaces a tool error rather than crashing."""
|
||||
handler = DefaultMCPToolHandler()
|
||||
with _patch_tool():
|
||||
await handler.aclose()
|
||||
result = await handler.invoke_tool(_invocation())
|
||||
assert result.is_error is True
|
||||
assert "closed" in (result.error_message or "").lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aclose_drains_inflight_creation(self) -> None:
|
||||
"""An in-flight ``_create_entry`` must not leak when ``aclose`` races with it.
|
||||
|
||||
Reproduces the race described in PR #5630 review-comment 3:
|
||||
task A claims an inflight future and starts a slow connect; task B
|
||||
runs ``aclose``; task A must self-clean (close its tool + httpx
|
||||
client) and surface a closed-handler error rather than orphaning
|
||||
the entry.
|
||||
"""
|
||||
handler = DefaultMCPToolHandler()
|
||||
connect_started = asyncio.Event()
|
||||
release_connect = asyncio.Event()
|
||||
original_connect = FakeTool.connect
|
||||
|
||||
async def gated_connect(self: FakeTool) -> None:
|
||||
connect_started.set()
|
||||
await release_connect.wait()
|
||||
await original_connect(self)
|
||||
|
||||
with _patch_tool(), patch.object(FakeTool, "connect", gated_connect):
|
||||
invoke_task = asyncio.create_task(handler.invoke_tool(_invocation(headers={"X": "1"})))
|
||||
# Wait until task A is mid-connect.
|
||||
await connect_started.wait()
|
||||
# Race: kick off aclose. It must wait for the in-flight task.
|
||||
close_task = asyncio.create_task(handler.aclose())
|
||||
# Yield once to ensure aclose has set _closed and is awaiting.
|
||||
await asyncio.sleep(0)
|
||||
# Allow the connect to complete; phase 3 sees _closed and self-cleans.
|
||||
release_connect.set()
|
||||
result = await invoke_task
|
||||
await close_task
|
||||
|
||||
# Entry was created and then closed by the in-flight task itself.
|
||||
assert len(FakeTool.instances) == 1
|
||||
assert FakeTool.instances[0].close_count == 1
|
||||
# The originating invocation surfaces a closed-handler error.
|
||||
assert result.is_error is True
|
||||
assert "closed" in (result.error_message or "").lower()
|
||||
|
||||
|
||||
# ---------- Result normalisation ------------------------------------------
|
||||
|
||||
|
||||
class TestResultNormalisation:
|
||||
@pytest.mark.asyncio
|
||||
async def test_string_result_wrapped_in_text_content(self) -> None:
|
||||
handler = DefaultMCPToolHandler()
|
||||
with _patch_tool():
|
||||
inv = _invocation()
|
||||
result = await handler.invoke_tool(inv)
|
||||
# The fake's default already returns a list; replace handler for this test.
|
||||
FakeTool.instances[0].call_handler = lambda **_a: "raw string body"
|
||||
result = await handler.invoke_tool(inv)
|
||||
assert result.is_error is False
|
||||
assert len(result.outputs) == 1
|
||||
assert result.outputs[0].text == "raw string body" # type: ignore[reportAttributeAccessIssue]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_result_passed_through(self) -> None:
|
||||
handler = DefaultMCPToolHandler()
|
||||
custom = [Content.from_text("a"), Content.from_text("b")]
|
||||
with _patch_tool():
|
||||
inv = _invocation()
|
||||
await handler.invoke_tool(inv)
|
||||
FakeTool.instances[0].call_handler = lambda **_a: custom
|
||||
result = await handler.invoke_tool(inv)
|
||||
assert result.is_error is False
|
||||
assert len(result.outputs) == 2
|
||||
|
||||
|
||||
# ---------- Error mapping --------------------------------------------------
|
||||
|
||||
|
||||
class TestErrorMapping:
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_execution_exception_returns_error_result(self) -> None:
|
||||
handler = DefaultMCPToolHandler()
|
||||
|
||||
def boom(**_a: Any) -> Any:
|
||||
raise ToolExecutionException("server says no")
|
||||
|
||||
with _patch_tool():
|
||||
inv = _invocation()
|
||||
await handler.invoke_tool(inv)
|
||||
FakeTool.instances[0].call_handler = boom
|
||||
result = await handler.invoke_tool(inv)
|
||||
assert result.is_error is True
|
||||
assert result.error_message == "server says no"
|
||||
assert result.outputs[0].text.startswith("Error:") # type: ignore[reportAttributeAccessIssue]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_httpx_error_returns_error_result(self) -> None:
|
||||
handler = DefaultMCPToolHandler()
|
||||
|
||||
def boom(**_a: Any) -> Any:
|
||||
raise httpx.ConnectError("dns failure")
|
||||
|
||||
with _patch_tool():
|
||||
inv = _invocation()
|
||||
await handler.invoke_tool(inv)
|
||||
FakeTool.instances[0].call_handler = boom
|
||||
result = await handler.invoke_tool(inv)
|
||||
assert result.is_error is True
|
||||
assert "dns failure" in (result.error_message or "")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unexpected_exception_propagates(self) -> None:
|
||||
"""RuntimeError (not in the narrow catch list) must propagate."""
|
||||
handler = DefaultMCPToolHandler()
|
||||
|
||||
def boom(**_a: Any) -> Any:
|
||||
raise RuntimeError("programmer error")
|
||||
|
||||
with _patch_tool():
|
||||
inv = _invocation()
|
||||
await handler.invoke_tool(inv)
|
||||
FakeTool.instances[0].call_handler = boom
|
||||
with pytest.raises(RuntimeError, match="programmer error"):
|
||||
await handler.invoke_tool(inv)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connect_failure_returns_error_result(self) -> None:
|
||||
handler = DefaultMCPToolHandler()
|
||||
with (
|
||||
_patch_tool(),
|
||||
patch.object(
|
||||
FakeTool,
|
||||
"connect",
|
||||
lambda self: (_ for _ in ()).throw(httpx.ConnectError("server down")),
|
||||
),
|
||||
):
|
||||
result = await handler.invoke_tool(_invocation())
|
||||
assert result.is_error is True
|
||||
assert result.outputs[0].text.startswith("Error:") # type: ignore[reportAttributeAccessIssue]
|
||||
# Failed connect must clear in-flight + cache entries.
|
||||
assert handler._inflight == {}
|
||||
assert len(handler._cache) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancelled_error_propagates(self) -> None:
|
||||
"""asyncio.CancelledError is BaseException, must NOT be swallowed."""
|
||||
handler = DefaultMCPToolHandler()
|
||||
|
||||
def boom(**_a: Any) -> Any:
|
||||
raise asyncio.CancelledError
|
||||
|
||||
with _patch_tool():
|
||||
inv = _invocation()
|
||||
await handler.invoke_tool(inv)
|
||||
FakeTool.instances[0].call_handler = boom
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await handler.invoke_tool(inv)
|
||||
|
||||
|
||||
# ---------- Cache key isolation -------------------------------------------
|
||||
|
||||
|
||||
class TestCacheKey:
|
||||
def test_key_order_independent(self) -> None:
|
||||
k1 = DefaultMCPToolHandler._cache_key("https://x/", None, None, {"A": "1", "B": "2"})
|
||||
k2 = DefaultMCPToolHandler._cache_key("https://x/", None, None, {"B": "2", "A": "1"})
|
||||
assert k1 == k2
|
||||
|
||||
def test_key_distinguishes_values(self) -> None:
|
||||
k1 = DefaultMCPToolHandler._cache_key("https://x/", None, None, {"A": "1"})
|
||||
k2 = DefaultMCPToolHandler._cache_key("https://x/", None, None, {"A": "2"})
|
||||
assert k1 != k2
|
||||
|
||||
def test_empty_headers_use_fixed_hash(self) -> None:
|
||||
k1 = DefaultMCPToolHandler._cache_key("https://x/", None, None, None)
|
||||
k2 = DefaultMCPToolHandler._cache_key("https://x/", None, None, {})
|
||||
assert k1 == k2
|
||||
|
||||
def test_key_distinguishes_connection_name(self) -> None:
|
||||
k1 = DefaultMCPToolHandler._cache_key("https://x/", None, "conn-A", None)
|
||||
k2 = DefaultMCPToolHandler._cache_key("https://x/", None, "conn-B", None)
|
||||
assert k1 != k2
|
||||
|
||||
def test_key_distinguishes_server_label(self) -> None:
|
||||
k1 = DefaultMCPToolHandler._cache_key("https://x/", "Lbl-A", None, None)
|
||||
k2 = DefaultMCPToolHandler._cache_key("https://x/", "Lbl-B", None, None)
|
||||
assert k1 != k2
|
||||
|
||||
def test_key_collapses_header_name_case(self) -> None:
|
||||
k1 = DefaultMCPToolHandler._cache_key("https://x/", None, None, {"Authorization": "tk"})
|
||||
k2 = DefaultMCPToolHandler._cache_key("https://x/", None, None, {"authorization": "tk"})
|
||||
assert k1 == k2
|
||||
|
||||
def test_key_keeps_header_value_case(self) -> None:
|
||||
k1 = DefaultMCPToolHandler._cache_key("https://x/", None, None, {"X": "Bearer-A"})
|
||||
k2 = DefaultMCPToolHandler._cache_key("https://x/", None, None, {"X": "bearer-a"})
|
||||
assert k1 != k2
|
||||
@@ -0,0 +1,664 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Tests for ``InvokeMcpToolActionExecutor``.
|
||||
|
||||
Use a stub :class:`MCPToolHandler` that returns canned :class:`MCPToolResult`s.
|
||||
No real MCP server or network is exercised. See
|
||||
``test_default_mcp_tool_handler.py`` for tests that exercise the real
|
||||
``DefaultMCPToolHandler`` against a mocked ``MCPStreamableHTTPTool``.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
try:
|
||||
import powerfx # noqa: F401
|
||||
|
||||
_powerfx_available = True
|
||||
except (ImportError, RuntimeError):
|
||||
_powerfx_available = False
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not _powerfx_available or sys.version_info >= (3, 14),
|
||||
reason="PowerFx engine not available (requires dotnet runtime)",
|
||||
)
|
||||
|
||||
from agent_framework import Content, Message # noqa: E402
|
||||
from agent_framework.exceptions import ToolExecutionException # noqa: E402
|
||||
|
||||
from agent_framework_declarative._workflows import ( # noqa: E402
|
||||
DECLARATIVE_STATE_KEY,
|
||||
DeclarativeWorkflowError,
|
||||
MCPToolHandler,
|
||||
MCPToolInvocation,
|
||||
MCPToolResult,
|
||||
WorkflowFactory,
|
||||
)
|
||||
|
||||
|
||||
class StubMcpHandler:
|
||||
"""Test stub recording the last call and returning a canned result."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
result: MCPToolResult | None = None,
|
||||
*,
|
||||
raise_exc: BaseException | None = None,
|
||||
) -> None:
|
||||
self.result = result
|
||||
self.raise_exc = raise_exc
|
||||
self.last_invocation: MCPToolInvocation | None = None
|
||||
self.invocations: list[MCPToolInvocation] = []
|
||||
self.call_count = 0
|
||||
|
||||
async def invoke_tool(self, invocation: MCPToolInvocation) -> MCPToolResult:
|
||||
self.call_count += 1
|
||||
self.last_invocation = invocation
|
||||
self.invocations.append(invocation)
|
||||
if self.raise_exc is not None:
|
||||
raise self.raise_exc
|
||||
assert self.result is not None
|
||||
return self.result
|
||||
|
||||
|
||||
def _ok(outputs: list[Content] | None = None) -> MCPToolResult:
|
||||
return MCPToolResult(outputs=outputs or [Content.from_text("hello")])
|
||||
|
||||
|
||||
def _err(message: str = "boom") -> MCPToolResult:
|
||||
return MCPToolResult(
|
||||
outputs=[Content.from_text(f"Error: {message}")],
|
||||
is_error=True,
|
||||
error_message=message,
|
||||
)
|
||||
|
||||
|
||||
def _action(
|
||||
*,
|
||||
server_url: str = "https://mcp.example/api",
|
||||
tool_name: str = "search",
|
||||
server_label: str | None = None,
|
||||
arguments: dict[str, Any] | None = None,
|
||||
headers: dict[str, Any] | None = None,
|
||||
require_approval: Any = None,
|
||||
connection: dict[str, Any] | None = None,
|
||||
conversation_id: str | None = None,
|
||||
output: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
action: dict[str, Any] = {
|
||||
"kind": "InvokeMcpTool",
|
||||
"id": "mcp_action",
|
||||
"serverUrl": server_url,
|
||||
"toolName": tool_name,
|
||||
}
|
||||
if server_label is not None:
|
||||
action["serverLabel"] = server_label
|
||||
if arguments is not None:
|
||||
action["arguments"] = arguments
|
||||
if headers is not None:
|
||||
action["headers"] = headers
|
||||
if require_approval is not None:
|
||||
action["requireApproval"] = require_approval
|
||||
if connection is not None:
|
||||
action["connection"] = connection
|
||||
if conversation_id is not None:
|
||||
action["conversationId"] = conversation_id
|
||||
if output is not None:
|
||||
action["output"] = output
|
||||
return action
|
||||
|
||||
|
||||
def _yaml(action: dict[str, Any]) -> dict[str, Any]:
|
||||
return {"name": "mcp_test", "actions": [action]}
|
||||
|
||||
|
||||
# ---------- Builder enforcement --------------------------------------------
|
||||
|
||||
|
||||
class TestBuilderEnforcement:
|
||||
def test_missing_handler_raises_at_build_time(self) -> None:
|
||||
factory = WorkflowFactory()
|
||||
with pytest.raises(DeclarativeWorkflowError) as excinfo:
|
||||
factory.create_workflow_from_definition(_yaml(_action()))
|
||||
assert "InvokeMcpTool" in str(excinfo.value)
|
||||
assert "mcp_tool_handler" in str(excinfo.value)
|
||||
|
||||
def test_missing_server_url_fails_validation(self) -> None:
|
||||
handler = StubMcpHandler(_ok())
|
||||
factory = WorkflowFactory(mcp_tool_handler=handler)
|
||||
action = _action()
|
||||
del action["serverUrl"]
|
||||
with pytest.raises(Exception) as excinfo:
|
||||
factory.create_workflow_from_definition(_yaml(action))
|
||||
assert "serverUrl" in str(excinfo.value)
|
||||
|
||||
def test_missing_tool_name_fails_validation(self) -> None:
|
||||
handler = StubMcpHandler(_ok())
|
||||
factory = WorkflowFactory(mcp_tool_handler=handler)
|
||||
action = _action()
|
||||
del action["toolName"]
|
||||
with pytest.raises(Exception) as excinfo:
|
||||
factory.create_workflow_from_definition(_yaml(action))
|
||||
assert "toolName" in str(excinfo.value)
|
||||
|
||||
|
||||
# ---------- Field forwarding ----------------------------------------------
|
||||
|
||||
|
||||
class TestFieldForwarding:
|
||||
@pytest.mark.asyncio
|
||||
async def test_basic_invocation_forwards_required_fields(self) -> None:
|
||||
handler = StubMcpHandler(_ok())
|
||||
factory = WorkflowFactory(mcp_tool_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action()))
|
||||
await workflow.run({})
|
||||
assert handler.call_count == 1
|
||||
inv = handler.last_invocation
|
||||
assert inv is not None
|
||||
assert inv.server_url == "https://mcp.example/api"
|
||||
assert inv.tool_name == "search"
|
||||
assert inv.server_label is None
|
||||
assert inv.headers == {}
|
||||
assert inv.arguments == {}
|
||||
assert inv.connection_name is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_arguments_evaluated_and_preserves_none(self) -> None:
|
||||
handler = StubMcpHandler(_ok())
|
||||
factory = WorkflowFactory(mcp_tool_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(
|
||||
_yaml(
|
||||
_action(
|
||||
arguments={
|
||||
"query": "weather today",
|
||||
"limit": 5,
|
||||
"fresh": True,
|
||||
"missing": None,
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
await workflow.run({})
|
||||
inv = handler.last_invocation
|
||||
assert inv is not None
|
||||
# ``None`` is preserved (parity with .NET) — caller decides.
|
||||
assert inv.arguments == {
|
||||
"query": "weather today",
|
||||
"limit": 5,
|
||||
"fresh": True,
|
||||
"missing": None,
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_headers_drop_empty_values(self) -> None:
|
||||
handler = StubMcpHandler(_ok())
|
||||
factory = WorkflowFactory(mcp_tool_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(
|
||||
_yaml(
|
||||
_action(
|
||||
headers={
|
||||
"Authorization": "Bearer token-123",
|
||||
"X-Trace": "trace-id",
|
||||
"X-Empty": "",
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
await workflow.run({})
|
||||
inv = handler.last_invocation
|
||||
assert inv is not None
|
||||
assert inv.headers == {
|
||||
"Authorization": "Bearer token-123",
|
||||
"X-Trace": "trace-id",
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_server_label_and_connection_name_forwarded(self) -> None:
|
||||
handler = StubMcpHandler(_ok())
|
||||
factory = WorkflowFactory(mcp_tool_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(
|
||||
_yaml(
|
||||
_action(
|
||||
server_label="docs-mcp",
|
||||
connection={"name": "azure-conn"},
|
||||
)
|
||||
)
|
||||
)
|
||||
await workflow.run({})
|
||||
inv = handler.last_invocation
|
||||
assert inv is not None
|
||||
assert inv.server_label == "docs-mcp"
|
||||
assert inv.connection_name == "azure-conn"
|
||||
|
||||
|
||||
# ---------- Output handling ------------------------------------------------
|
||||
|
||||
|
||||
class TestOutput:
|
||||
@pytest.mark.asyncio
|
||||
async def test_output_result_parses_json_text(self) -> None:
|
||||
handler = StubMcpHandler(_ok([Content.from_text('{"k":"v","n":1}')]))
|
||||
factory = WorkflowFactory(mcp_tool_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action(output={"result": "Local.Result"})))
|
||||
await workflow.run({})
|
||||
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
assert decl["Local"]["Result"] == [{"k": "v", "n": 1}]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_output_result_falls_back_to_raw_text(self) -> None:
|
||||
handler = StubMcpHandler(_ok([Content.from_text("plain text not json")]))
|
||||
factory = WorkflowFactory(mcp_tool_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action(output={"result": "Local.Result"})))
|
||||
await workflow.run({})
|
||||
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
assert decl["Local"]["Result"] == ["plain text not json"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_output_messages_writes_single_tool_role_message(self) -> None:
|
||||
handler = StubMcpHandler(_ok([Content.from_text("hi"), Content.from_text("there")]))
|
||||
factory = WorkflowFactory(mcp_tool_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action(output={"messages": "Local.Messages"})))
|
||||
await workflow.run({})
|
||||
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
msg = decl["Local"]["Messages"]
|
||||
# Single Tool-role message containing both contents (parity with .NET).
|
||||
assert isinstance(msg, Message)
|
||||
assert str(msg.role).lower() == "tool"
|
||||
assert len(msg.contents) == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_uri_content_serialised_as_uri_string(self) -> None:
|
||||
uri_content = Content.from_uri("https://example.com/file.txt", media_type="text/plain")
|
||||
handler = StubMcpHandler(_ok([uri_content]))
|
||||
factory = WorkflowFactory(mcp_tool_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action(output={"result": "Local.Result"})))
|
||||
await workflow.run({})
|
||||
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
assert decl["Local"]["Result"] == ["https://example.com/file.txt"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_output_path_object_form(self) -> None:
|
||||
handler = StubMcpHandler(_ok([Content.from_text("ok")]))
|
||||
factory = WorkflowFactory(mcp_tool_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action(output={"result": {"path": "Local.Result"}})))
|
||||
await workflow.run({})
|
||||
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
assert decl["Local"]["Result"] == ["ok"]
|
||||
|
||||
|
||||
# ---------- Conversation append --------------------------------------------
|
||||
|
||||
|
||||
class TestConversation:
|
||||
@pytest.mark.asyncio
|
||||
async def test_conversation_id_appends_assistant_message(self) -> None:
|
||||
handler = StubMcpHandler(_ok([Content.from_text("answer")]))
|
||||
factory = WorkflowFactory(mcp_tool_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(
|
||||
_yaml(
|
||||
_action(
|
||||
conversation_id="conv-42",
|
||||
output={"result": "Local.Result"},
|
||||
)
|
||||
)
|
||||
)
|
||||
await workflow.run({})
|
||||
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
conv = decl["System"]["conversations"]["conv-42"]
|
||||
msgs = conv["messages"] if isinstance(conv, dict) else conv.messages
|
||||
assert len(msgs) == 1
|
||||
appended = msgs[0]
|
||||
assert str(appended.role).lower() == "assistant"
|
||||
# Same contents as the tool output.
|
||||
assert len(appended.contents) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_conversation_id_does_not_append(self) -> None:
|
||||
handler = StubMcpHandler(_ok([Content.from_text("answer")]))
|
||||
factory = WorkflowFactory(mcp_tool_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(
|
||||
_yaml(
|
||||
_action(
|
||||
conversation_id="",
|
||||
output={"result": "Local.Result"},
|
||||
)
|
||||
)
|
||||
)
|
||||
await workflow.run({})
|
||||
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
# Empty conversation id must not produce a `""` entry under System.conversations.
|
||||
conversations = decl.get("System", {}).get("conversations", {})
|
||||
assert "" not in conversations
|
||||
|
||||
|
||||
# ---------- Approval flow --------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_state(): # type: ignore[no-untyped-def]
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
state = MagicMock()
|
||||
state._data = {}
|
||||
|
||||
def _get(key: str, default: Any = None) -> Any:
|
||||
if key not in state._data:
|
||||
if default is not None:
|
||||
return default
|
||||
raise KeyError(key)
|
||||
return state._data[key]
|
||||
|
||||
def _set(key: str, value: Any) -> None:
|
||||
state._data[key] = value
|
||||
|
||||
def _delete(key: str) -> None:
|
||||
if key in state._data:
|
||||
del state._data[key]
|
||||
else:
|
||||
raise KeyError(key)
|
||||
|
||||
state.get = MagicMock(side_effect=_get)
|
||||
state.set = MagicMock(side_effect=_set)
|
||||
state.delete = MagicMock(side_effect=_delete)
|
||||
return state
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_context(mock_state): # type: ignore[no-untyped-def]
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
ctx = MagicMock()
|
||||
ctx.state = mock_state
|
||||
ctx.send_message = AsyncMock()
|
||||
ctx.yield_output = AsyncMock()
|
||||
ctx.request_info = AsyncMock()
|
||||
return ctx
|
||||
|
||||
|
||||
def _seed_state(mock_state) -> None: # type: ignore[no-untyped-def]
|
||||
"""Pre-seed the declarative state container as the executors expect."""
|
||||
from agent_framework_declarative._workflows import DECLARATIVE_STATE_KEY
|
||||
|
||||
mock_state._data[DECLARATIVE_STATE_KEY] = {
|
||||
"Local": {},
|
||||
"Custom": {},
|
||||
"Workflow": {},
|
||||
"System": {
|
||||
"ConversationId": "00000000-0000-0000-0000-000000000000",
|
||||
"LastMessage": {"Id": "", "Text": ""},
|
||||
"LastMessageText": "",
|
||||
"LastMessageId": "",
|
||||
},
|
||||
"Agent": {},
|
||||
"Conversation": {"messages": [], "history": []},
|
||||
"Inputs": {},
|
||||
}
|
||||
|
||||
|
||||
class TestApprovalFlow:
|
||||
@pytest.mark.asyncio
|
||||
async def test_approval_required_emits_request_and_yields(self, mock_state, mock_context) -> None: # type: ignore[no-untyped-def]
|
||||
from agent_framework_declarative._workflows._declarative_base import ActionTrigger
|
||||
from agent_framework_declarative._workflows._executors_mcp import (
|
||||
_MCP_APPROVAL_STATE_KEY,
|
||||
InvokeMcpToolActionExecutor,
|
||||
MCPToolApprovalRequest,
|
||||
)
|
||||
|
||||
_seed_state(mock_state)
|
||||
handler = StubMcpHandler(_ok())
|
||||
executor = InvokeMcpToolActionExecutor(
|
||||
_action(
|
||||
require_approval=True,
|
||||
arguments={"q": "x"},
|
||||
headers={"Authorization": "Bearer SECRET"},
|
||||
output={"result": "Local.Result"},
|
||||
),
|
||||
mcp_tool_handler=handler,
|
||||
)
|
||||
await executor.handle_action(ActionTrigger(), mock_context)
|
||||
|
||||
# Approval request emitted.
|
||||
mock_context.request_info.assert_called_once()
|
||||
request = mock_context.request_info.call_args[0][0]
|
||||
assert isinstance(request, MCPToolApprovalRequest)
|
||||
assert request.tool_name == "search"
|
||||
assert request.arguments == {"q": "x"}
|
||||
assert request.header_names == ["Authorization"]
|
||||
|
||||
# NEVER expose the actual auth token in any field of the approval payload.
|
||||
for value in request.__dict__.values():
|
||||
assert "SECRET" not in str(value)
|
||||
|
||||
# Workflow should yield (no ActionComplete sent yet).
|
||||
mock_context.send_message.assert_not_called()
|
||||
|
||||
# Handler not invoked yet.
|
||||
assert handler.call_count == 0
|
||||
|
||||
# Approval state stored.
|
||||
approval_key = f"{_MCP_APPROVAL_STATE_KEY}_mcp_action"
|
||||
assert approval_key in mock_state._data
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approval_response_approved_invokes_handler(self, mock_state, mock_context) -> None: # type: ignore[no-untyped-def]
|
||||
from agent_framework_declarative._workflows import ActionComplete, ToolApprovalResponse
|
||||
from agent_framework_declarative._workflows._executors_mcp import (
|
||||
_MCP_APPROVAL_STATE_KEY,
|
||||
InvokeMcpToolActionExecutor,
|
||||
MCPToolApprovalRequest,
|
||||
_MCPToolApprovalState,
|
||||
)
|
||||
|
||||
_seed_state(mock_state)
|
||||
handler = StubMcpHandler(_ok([Content.from_text('{"ok":true}')]))
|
||||
executor = InvokeMcpToolActionExecutor(
|
||||
_action(
|
||||
require_approval=True,
|
||||
output={"result": "Local.Result"},
|
||||
),
|
||||
mcp_tool_handler=handler,
|
||||
)
|
||||
# Pre-populate approval state.
|
||||
approval_key = f"{_MCP_APPROVAL_STATE_KEY}_mcp_action"
|
||||
mock_state._data[approval_key] = _MCPToolApprovalState(
|
||||
server_url="https://mcp.example/api",
|
||||
tool_name="search",
|
||||
server_label=None,
|
||||
arguments={"q": "x"},
|
||||
connection_name=None,
|
||||
headers_def={"Authorization": "Bearer tk"},
|
||||
auto_send=False,
|
||||
conversation_id_expr=None,
|
||||
output_messages_path=None,
|
||||
output_result_path="Local.Result",
|
||||
)
|
||||
await executor.handle_approval_response(
|
||||
MCPToolApprovalRequest(
|
||||
request_id="req-1",
|
||||
tool_name="search",
|
||||
server_url="https://mcp.example/api",
|
||||
server_label=None,
|
||||
arguments={"q": "x"},
|
||||
),
|
||||
ToolApprovalResponse(approved=True),
|
||||
mock_context,
|
||||
)
|
||||
|
||||
assert handler.call_count == 1
|
||||
inv = handler.last_invocation
|
||||
assert inv is not None
|
||||
# Headers are re-evaluated from headers_def.
|
||||
assert inv.headers == {"Authorization": "Bearer tk"}
|
||||
# Approval state was cleaned up.
|
||||
assert approval_key not in mock_state._data
|
||||
# ActionComplete was sent.
|
||||
mock_context.send_message.assert_called_once()
|
||||
sent = mock_context.send_message.call_args[0][0]
|
||||
assert isinstance(sent, ActionComplete)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approval_response_rejected_assigns_error(self, mock_state, mock_context) -> None: # type: ignore[no-untyped-def]
|
||||
from agent_framework_declarative._workflows import ToolApprovalResponse
|
||||
from agent_framework_declarative._workflows._executors_mcp import (
|
||||
_MCP_APPROVAL_STATE_KEY,
|
||||
InvokeMcpToolActionExecutor,
|
||||
MCPToolApprovalRequest,
|
||||
_MCPToolApprovalState,
|
||||
)
|
||||
|
||||
_seed_state(mock_state)
|
||||
handler = StubMcpHandler(_ok())
|
||||
executor = InvokeMcpToolActionExecutor(
|
||||
_action(
|
||||
require_approval=True,
|
||||
output={"result": "Local.Result"},
|
||||
),
|
||||
mcp_tool_handler=handler,
|
||||
)
|
||||
approval_key = f"{_MCP_APPROVAL_STATE_KEY}_mcp_action"
|
||||
mock_state._data[approval_key] = _MCPToolApprovalState(
|
||||
server_url="https://mcp.example/api",
|
||||
tool_name="search",
|
||||
server_label=None,
|
||||
arguments={},
|
||||
connection_name=None,
|
||||
headers_def=None,
|
||||
auto_send=True,
|
||||
conversation_id_expr=None,
|
||||
output_messages_path=None,
|
||||
output_result_path="Local.Result",
|
||||
)
|
||||
await executor.handle_approval_response(
|
||||
MCPToolApprovalRequest(
|
||||
request_id="req-2",
|
||||
tool_name="search",
|
||||
server_url="https://mcp.example/api",
|
||||
server_label=None,
|
||||
arguments={},
|
||||
),
|
||||
ToolApprovalResponse(approved=False, reason="not authorized"),
|
||||
mock_context,
|
||||
)
|
||||
|
||||
assert handler.call_count == 0
|
||||
# Error string assigned at output.result.
|
||||
from agent_framework_declarative._workflows import DECLARATIVE_STATE_KEY
|
||||
|
||||
result = mock_state._data[DECLARATIVE_STATE_KEY]["Local"]["Result"]
|
||||
assert result == "Error: MCP tool invocation was not approved by user."
|
||||
|
||||
|
||||
# ---------- Error handling -------------------------------------------------
|
||||
|
||||
|
||||
class TestErrorHandling:
|
||||
@pytest.mark.asyncio
|
||||
async def test_handler_returns_error_result_assigns_error_string(self) -> None:
|
||||
handler = StubMcpHandler(_err("server down"))
|
||||
factory = WorkflowFactory(mcp_tool_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action(output={"result": "Local.Result"})))
|
||||
await workflow.run({})
|
||||
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
assert decl["Local"]["Result"] == "Error: server down"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_execution_exception_becomes_error_result(self) -> None:
|
||||
handler = StubMcpHandler(raise_exc=ToolExecutionException("invalid arguments"))
|
||||
factory = WorkflowFactory(mcp_tool_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action(output={"result": "Local.Result"})))
|
||||
await workflow.run({})
|
||||
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
assert decl["Local"]["Result"] == "Error: invalid arguments"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_httpx_error_becomes_error_result(self) -> None:
|
||||
handler = StubMcpHandler(raise_exc=httpx.ConnectError("dns fail"))
|
||||
factory = WorkflowFactory(mcp_tool_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action(output={"result": "Local.Result"})))
|
||||
await workflow.run({})
|
||||
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
|
||||
result = decl["Local"]["Result"]
|
||||
assert isinstance(result, str)
|
||||
assert result.startswith("Error:")
|
||||
assert "ConnectError" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unexpected_exception_propagates(self) -> None:
|
||||
"""Programmer bugs (TypeError etc.) must NOT be swallowed."""
|
||||
handler = StubMcpHandler(raise_exc=TypeError("bad type"))
|
||||
factory = WorkflowFactory(mcp_tool_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action()))
|
||||
with pytest.raises(Exception) as excinfo:
|
||||
await workflow.run({})
|
||||
# Either the TypeError reaches us or it gets wrapped by the runner —
|
||||
# either way the message must surface.
|
||||
assert "bad type" in str(excinfo.value)
|
||||
|
||||
|
||||
# ---------- autoSend -------------------------------------------------------
|
||||
|
||||
|
||||
class TestAutoSend:
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_send_default_true_yields_output(self) -> None:
|
||||
handler = StubMcpHandler(_ok([Content.from_text("hello")]))
|
||||
factory = WorkflowFactory(mcp_tool_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action()))
|
||||
events = await workflow.run({})
|
||||
outputs = events.get_outputs()
|
||||
assert len(outputs) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_send_false_suppresses_yield(self) -> None:
|
||||
handler = StubMcpHandler(_ok([Content.from_text("hello")]))
|
||||
factory = WorkflowFactory(mcp_tool_handler=handler)
|
||||
workflow = factory.create_workflow_from_definition(_yaml(_action(output={"autoSend": False})))
|
||||
events = await workflow.run({})
|
||||
outputs = events.get_outputs()
|
||||
assert outputs == []
|
||||
|
||||
|
||||
# ---------- Protocol structure --------------------------------------------
|
||||
|
||||
|
||||
class TestProtocol:
|
||||
def test_stub_handler_satisfies_protocol(self) -> None:
|
||||
handler = StubMcpHandler(_ok())
|
||||
assert isinstance(handler, MCPToolHandler)
|
||||
|
||||
|
||||
# ---------- _format_outputs_for_send --------------------------------------
|
||||
|
||||
|
||||
class TestFormatOutputsForSend:
|
||||
"""Direct tests for the auto-send rendering helper.
|
||||
|
||||
Regression for PR #5630 review-comment 4: a single scalar JSON value
|
||||
must render bare (e.g. ``"42"``) rather than wrapped (``"[42]"``).
|
||||
"""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("parsed", "expected"),
|
||||
[
|
||||
([], ""),
|
||||
(["hello"], "hello"),
|
||||
(["a", "b"], "a\nb"),
|
||||
([42], "42"),
|
||||
([3.14], "3.14"),
|
||||
([True], "true"),
|
||||
([False], "false"),
|
||||
([None], "null"),
|
||||
([{"k": "v"}], '{"k": "v"}'),
|
||||
([[1, 2]], "[1, 2]"),
|
||||
(["hello", 42], '["hello", 42]'),
|
||||
([{"a": 1}, {"b": 2}], '[{"a": 1}, {"b": 2}]'),
|
||||
],
|
||||
)
|
||||
def test_format_outputs_for_send(self, parsed: list[Any], expected: str) -> None:
|
||||
from agent_framework_declarative._workflows._executors_mcp import _format_outputs_for_send
|
||||
|
||||
assert _format_outputs_for_send(parsed) == expected
|
||||
@@ -1056,7 +1056,6 @@ class MessageMapper:
|
||||
output_index=context["output_index"],
|
||||
sequence_number=self._next_sequence(context),
|
||||
item=executor_item,
|
||||
created_at=float(time.time()),
|
||||
)
|
||||
]
|
||||
|
||||
@@ -1089,7 +1088,6 @@ class MessageMapper:
|
||||
output_index=context.get("output_index", 0),
|
||||
sequence_number=self._next_sequence(context),
|
||||
item=executor_item,
|
||||
created_at=float(time.time()),
|
||||
)
|
||||
]
|
||||
|
||||
@@ -1123,7 +1121,6 @@ class MessageMapper:
|
||||
output_index=context.get("output_index", 0),
|
||||
sequence_number=self._next_sequence(context),
|
||||
item=executor_item,
|
||||
created_at=float(time.time()),
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@@ -64,7 +64,6 @@ class CustomResponseOutputItemAddedEvent(BaseModel):
|
||||
output_index: int
|
||||
sequence_number: int
|
||||
item: dict[str, Any] | ExecutorActionItem | Any # Flexible item type
|
||||
created_at: float | None = None # Unix timestamp; used by frontend for accurate workflow timings
|
||||
|
||||
|
||||
class CustomResponseOutputItemDoneEvent(BaseModel):
|
||||
@@ -78,7 +77,6 @@ class CustomResponseOutputItemDoneEvent(BaseModel):
|
||||
output_index: int
|
||||
sequence_number: int
|
||||
item: dict[str, Any] | ExecutorActionItem | Any # Flexible item type
|
||||
created_at: float | None = None # Unix timestamp; used by frontend for accurate workflow timings
|
||||
|
||||
|
||||
class ResponseWorkflowEventComplete(BaseModel):
|
||||
|
||||
+2
-4
@@ -356,10 +356,8 @@ export function ExecutionTimeline({
|
||||
const runNumber = (runCount.get(executorId) || 0) + 1;
|
||||
runCount.set(executorId, runNumber);
|
||||
|
||||
// Create synthetic item ID using the run counter for guaranteed uniqueness.
|
||||
// Using uiTimestamp here caused collisions when the same executor ran
|
||||
// twice within the same second (both fallback entries would share an ID).
|
||||
const syntheticItemId = `fallback_${executorId}_run${runNumber}`;
|
||||
// Create synthetic item ID for fallback format (no real item.id from backend)
|
||||
const syntheticItemId = `fallback_${executorId}_${uiTimestamp}`;
|
||||
|
||||
runs.push({
|
||||
executorId,
|
||||
|
||||
@@ -576,37 +576,17 @@ export function WorkflowView({
|
||||
openAIEvent.type === "response.workflow_event.complete" // Fallback variant
|
||||
) {
|
||||
setOpenAIEvents((prev) => {
|
||||
// Derive a server-side timestamp from the event, in priority order:
|
||||
// 1. top-level created_at (custom output-item events)
|
||||
// 2. response.created_at (response.created / lifecycle events)
|
||||
// 3. data.timestamp (response.workflow_event.completed ISO string)
|
||||
// Fall back to a synthesized timestamp only when none is present.
|
||||
const anyEvent = openAIEvent as Record<string, unknown>;
|
||||
const eventTimestamp: number | undefined =
|
||||
typeof anyEvent["created_at"] === "number" && anyEvent["created_at"]
|
||||
? (anyEvent["created_at"] as number)
|
||||
: typeof (anyEvent["response"] as Record<string, unknown> | undefined)?.["created_at"] === "number"
|
||||
? ((anyEvent["response"] as Record<string, number>)["created_at"] as number)
|
||||
: (() => {
|
||||
const ts = (anyEvent["data"] as Record<string, unknown> | undefined)?.["timestamp"];
|
||||
if (typeof ts !== "string") return undefined;
|
||||
const ms = new Date(ts).getTime();
|
||||
// Guard against NaN: Python isoformat() emits microseconds without Z,
|
||||
// which some JS engines cannot parse. Number.isFinite rejects NaN.
|
||||
return Number.isFinite(ms) ? ms / 1000 : undefined;
|
||||
})();
|
||||
// Generate unique timestamp for each event
|
||||
const baseTimestamp = Math.floor(Date.now() / 1000);
|
||||
const lastTimestamp =
|
||||
prev.length > 0
|
||||
? (prev[prev.length - 1] as { _uiTimestamp?: number })
|
||||
._uiTimestamp || 0
|
||||
: 0;
|
||||
// When we have a real server timestamp clamp to lastTimestamp (no +1s gap).
|
||||
// When synthesizing, keep the +1 s gap so ordering is always monotonic.
|
||||
const uniqueTimestamp =
|
||||
eventTimestamp !== undefined
|
||||
? Math.max(eventTimestamp, lastTimestamp)
|
||||
: Math.max(baseTimestamp, lastTimestamp + 1);
|
||||
const uniqueTimestamp = Math.max(
|
||||
baseTimestamp,
|
||||
lastTimestamp + 1
|
||||
);
|
||||
|
||||
return [
|
||||
...prev,
|
||||
@@ -1012,37 +992,14 @@ export function WorkflowView({
|
||||
openAIEvent.type === "response.workflow_event.completed"
|
||||
) {
|
||||
setOpenAIEvents((prev) => {
|
||||
// Derive a server-side timestamp from the event, in priority order:
|
||||
// 1. top-level created_at (custom output-item events)
|
||||
// 2. response.created_at (response.created / lifecycle events)
|
||||
// 3. data.timestamp (response.workflow_event.completed ISO string)
|
||||
// Fall back to a synthesized timestamp only when none is present.
|
||||
const anyEvent = openAIEvent as Record<string, unknown>;
|
||||
const eventTimestamp: number | undefined =
|
||||
typeof anyEvent["created_at"] === "number" && anyEvent["created_at"]
|
||||
? (anyEvent["created_at"] as number)
|
||||
: typeof (anyEvent["response"] as Record<string, unknown> | undefined)?.["created_at"] === "number"
|
||||
? ((anyEvent["response"] as Record<string, number>)["created_at"] as number)
|
||||
: (() => {
|
||||
const ts = (anyEvent["data"] as Record<string, unknown> | undefined)?.["timestamp"];
|
||||
if (typeof ts !== "string") return undefined;
|
||||
const ms = new Date(ts).getTime();
|
||||
// Guard against NaN: Python isoformat() emits microseconds without Z,
|
||||
// which some JS engines cannot parse. Number.isFinite rejects NaN.
|
||||
return Number.isFinite(ms) ? ms / 1000 : undefined;
|
||||
})();
|
||||
// Generate unique timestamp for each event
|
||||
const baseTimestamp = Math.floor(Date.now() / 1000);
|
||||
const lastTimestamp =
|
||||
prev.length > 0
|
||||
? (prev[prev.length - 1] as { _uiTimestamp?: number })
|
||||
._uiTimestamp || 0
|
||||
: 0;
|
||||
// When we have a real server timestamp clamp to lastTimestamp (no +1s gap).
|
||||
// When synthesizing, keep the +1 s gap so ordering is always monotonic.
|
||||
const uniqueTimestamp =
|
||||
eventTimestamp !== undefined
|
||||
? Math.max(eventTimestamp, lastTimestamp)
|
||||
: Math.max(baseTimestamp, lastTimestamp + 1);
|
||||
const uniqueTimestamp = Math.max(baseTimestamp, lastTimestamp + 1);
|
||||
|
||||
return [
|
||||
...prev,
|
||||
|
||||
@@ -391,94 +391,6 @@ async def test_executor_failed_event(mapper: MessageMapper, test_request: AgentF
|
||||
assert "Executor failed" in str(item["error"])
|
||||
|
||||
|
||||
async def test_executor_events_carry_created_at_timestamp(
|
||||
mapper: MessageMapper, test_request: AgentFrameworkRequest
|
||||
) -> None:
|
||||
"""REGRESSION TEST: Executor mapped events must include a created_at timestamp.
|
||||
|
||||
Without created_at, the frontend synthesizes timestamps using
|
||||
Math.max(baseTimestamp, lastTimestamp + 1) with second precision, forcing
|
||||
a minimum 1-second gap between sequential events regardless of their actual
|
||||
elapsed time. This makes instant workflows appear to take multiple seconds
|
||||
in the DevUI timeline.
|
||||
"""
|
||||
invoke_event = create_executor_invoked_event(executor_id="exec_ts")
|
||||
complete_event = create_executor_completed_event(executor_id="exec_ts")
|
||||
fail_event = create_executor_failed_event(executor_id="exec_ts_fail")
|
||||
|
||||
invoked_results = await mapper.convert_event(invoke_event, test_request)
|
||||
completed_results = await mapper.convert_event(complete_event, test_request)
|
||||
|
||||
# Set up a separate context for the failed path
|
||||
mapper2 = MessageMapper()
|
||||
await mapper2.convert_event(create_executor_invoked_event(executor_id="exec_ts_fail"), test_request)
|
||||
failed_results = await mapper2.convert_event(fail_event, test_request)
|
||||
|
||||
for label, results in [
|
||||
("executor_invoked", invoked_results),
|
||||
("executor_completed", completed_results),
|
||||
("executor_failed", failed_results),
|
||||
]:
|
||||
assert results, f"mapper.convert_event should return events for {label}"
|
||||
for event in results:
|
||||
assert getattr(event, "created_at", None) is not None, (
|
||||
f"{label} mapped event {type(event).__name__} is missing 'created_at'. "
|
||||
"The frontend relies on this field for accurate workflow timeline timings."
|
||||
)
|
||||
assert event.created_at > 0, (
|
||||
f"{label} mapped event {type(event).__name__} has a non-positive "
|
||||
f"created_at value ({event.created_at!r}); expected a valid Unix timestamp."
|
||||
)
|
||||
|
||||
|
||||
def test_custom_output_item_event_models_have_created_at_field() -> None:
|
||||
"""MODEL TEST: CustomResponseOutputItemAddedEvent and Done must declare created_at.
|
||||
|
||||
This guards against accidentally removing the field from the model definition.
|
||||
A missing field causes a downstream ValidationError instead of a clear test failure.
|
||||
"""
|
||||
from agent_framework_devui.models._openai_custom import (
|
||||
CustomResponseOutputItemAddedEvent,
|
||||
CustomResponseOutputItemDoneEvent,
|
||||
)
|
||||
|
||||
assert "created_at" in CustomResponseOutputItemAddedEvent.model_fields, (
|
||||
"CustomResponseOutputItemAddedEvent is missing 'created_at' in model_fields. "
|
||||
"The frontend uses this field for accurate workflow timeline timings."
|
||||
)
|
||||
assert "created_at" in CustomResponseOutputItemDoneEvent.model_fields, (
|
||||
"CustomResponseOutputItemDoneEvent is missing 'created_at' in model_fields. "
|
||||
"The frontend uses this field for accurate workflow timeline timings."
|
||||
)
|
||||
|
||||
|
||||
async def test_executor_completed_maps_to_output_item_done_event(
|
||||
mapper: MessageMapper, test_request: AgentFrameworkRequest
|
||||
) -> None:
|
||||
"""Test executor_completed events are mapped to CustomResponseOutputItemDoneEvent.
|
||||
|
||||
Ensures executor_completed does not fall through to the legacy
|
||||
ResponseWorkflowEventComplete path, which lacks a top-level created_at field.
|
||||
"""
|
||||
from agent_framework_devui.models._openai_custom import ResponseWorkflowEventComplete
|
||||
|
||||
invoke_event = create_executor_invoked_event(executor_id="exec_output_item")
|
||||
await mapper.convert_event(invoke_event, test_request)
|
||||
|
||||
complete_event = create_executor_completed_event(executor_id="exec_output_item")
|
||||
results = await mapper.convert_event(complete_event, test_request)
|
||||
|
||||
assert results, "mapper.convert_event should return events for executor_completed"
|
||||
|
||||
workflow_events = [r for r in results if isinstance(r, ResponseWorkflowEventComplete)]
|
||||
assert not workflow_events, (
|
||||
"executor_completed should map to CustomResponseOutputItemDoneEvent, not ResponseWorkflowEventComplete."
|
||||
)
|
||||
|
||||
output_item_done = [r for r in results if r.type == "response.output_item.done"]
|
||||
assert output_item_done, f"Expected at least one response.output_item.done event; got: {[r.type for r in results]}"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Workflow Lifecycle Event Tests
|
||||
# =============================================================================
|
||||
|
||||
@@ -135,18 +135,6 @@ def _uses_foundry_agent_session(conversation_id: Any) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def _build_agent_reference(agent_name: str, agent_version: str | None) -> dict[str, str]:
|
||||
"""Build the Responses API ``agent_reference`` payload for non-preview Foundry agent calls.
|
||||
|
||||
Used for both Prompt Agents and HostedAgents on the ``allow_preview=False`` code path —
|
||||
the preview branch instead injects identity via ``project_client.get_openai_client(agent_name=...)``.
|
||||
"""
|
||||
ref: dict[str, str] = {"name": agent_name, "type": "agent_reference"}
|
||||
if agent_version:
|
||||
ref["version"] = agent_version
|
||||
return ref
|
||||
|
||||
|
||||
class RawFoundryAgentChatClient( # type: ignore[misc]
|
||||
RawOpenAIChatClient[FoundryAgentOptionsT],
|
||||
Generic[FoundryAgentOptionsT],
|
||||
@@ -354,12 +342,6 @@ class RawFoundryAgentChatClient( # type: ignore[misc]
|
||||
run_options.pop("previous_response_id", None)
|
||||
run_options.pop("conversation", None)
|
||||
extra_body["agent_session_id"] = conversation_id
|
||||
# Non-preview Prompt/Hosted Agent calls need agent_reference in the request body to
|
||||
# tell the Responses API which Foundry agent (and version) is in use, since ``model``
|
||||
# is stripped below. The preview path injects the reference via the OpenAI client kwarg
|
||||
# ``agent_name`` instead, so skip there. See issue #5582.
|
||||
if not self.allow_preview:
|
||||
extra_body.setdefault("agent_reference", _build_agent_reference(self.agent_name, self.agent_version))
|
||||
if extra_body:
|
||||
run_options["extra_body"] = extra_body
|
||||
|
||||
|
||||
@@ -196,10 +196,7 @@ async def test_raw_foundry_agent_chat_client_prepare_options_accepts_function_to
|
||||
options={"tools": [my_func]},
|
||||
)
|
||||
|
||||
# agent_reference is required so the Responses API can resolve model server-side; see #5582.
|
||||
assert result == {
|
||||
"extra_body": {"agent_reference": {"name": "test-agent", "type": "agent_reference"}},
|
||||
}
|
||||
assert result == {}
|
||||
|
||||
|
||||
async def test_raw_foundry_agent_chat_client_prepare_options_strips_client_side_fields() -> None:
|
||||
@@ -239,128 +236,7 @@ async def test_raw_foundry_agent_chat_client_prepare_options_strips_client_side_
|
||||
assert "tools" not in result
|
||||
assert "tool_choice" not in result
|
||||
assert "parallel_tool_calls" not in result
|
||||
# agent_reference is required so the Responses API can resolve model server-side; see #5582.
|
||||
assert result == {
|
||||
"extra_body": {"agent_reference": {"name": "test-agent", "type": "agent_reference"}},
|
||||
}
|
||||
|
||||
|
||||
async def test_raw_foundry_agent_chat_client_prepare_options_injects_agent_reference_first_turn() -> None:
|
||||
"""First-turn (no conversation_id) Prompt Agent calls must carry agent_reference in extra_body.
|
||||
|
||||
Regression test for https://github.com/microsoft/agent-framework/issues/5582 — without this
|
||||
the Responses API rejects with "Missing required parameter: 'model'", because both ``model``
|
||||
and ``agent_reference`` are absent from the request body.
|
||||
"""
|
||||
|
||||
mock_project = MagicMock()
|
||||
mock_project.get_openai_client.return_value = MagicMock()
|
||||
|
||||
client = RawFoundryAgentChatClient(
|
||||
project_client=mock_project,
|
||||
agent_name="test-agent",
|
||||
agent_version="2",
|
||||
)
|
||||
|
||||
with patch(
|
||||
"agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options",
|
||||
new_callable=AsyncMock,
|
||||
return_value={"model": "gpt-4.1"},
|
||||
):
|
||||
result = await client._prepare_options(
|
||||
messages=[Message(role="user", contents="hi")],
|
||||
options={},
|
||||
)
|
||||
|
||||
assert "model" not in result
|
||||
assert result["extra_body"] == {
|
||||
"agent_reference": {"name": "test-agent", "type": "agent_reference", "version": "2"},
|
||||
}
|
||||
|
||||
|
||||
async def test_raw_foundry_agent_chat_client_prepare_options_agent_reference_omits_version_when_unset() -> None:
|
||||
"""When agent_version is unset, agent_reference should omit the version key entirely."""
|
||||
|
||||
mock_project = MagicMock()
|
||||
mock_project.get_openai_client.return_value = MagicMock()
|
||||
|
||||
client = RawFoundryAgentChatClient(
|
||||
project_client=mock_project,
|
||||
agent_name="hosted-agent",
|
||||
)
|
||||
|
||||
with patch(
|
||||
"agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options",
|
||||
new_callable=AsyncMock,
|
||||
return_value={"model": "gpt-4.1"},
|
||||
):
|
||||
result = await client._prepare_options(
|
||||
messages=[Message(role="user", contents="hi")],
|
||||
options={},
|
||||
)
|
||||
|
||||
assert result["extra_body"] == {
|
||||
"agent_reference": {"name": "hosted-agent", "type": "agent_reference"},
|
||||
}
|
||||
|
||||
|
||||
async def test_raw_foundry_agent_chat_client_prepare_options_skips_agent_reference_when_allow_preview() -> None:
|
||||
"""Hosted-agent (allow_preview=True) requests must NOT add agent_reference in the body.
|
||||
|
||||
The preview path injects the agent identity via ``project_client.get_openai_client(agent_name=...)``
|
||||
at the SDK wrapper level. Adding it again in extra_body would either duplicate or conflict
|
||||
with the wrapper's injection. Keep this gate aligned with the constructor branch in
|
||||
``RawFoundryAgentChatClient.__init__``.
|
||||
"""
|
||||
|
||||
mock_project = MagicMock()
|
||||
mock_project.get_openai_client.return_value = MagicMock()
|
||||
|
||||
client = RawFoundryAgentChatClient(
|
||||
project_client=mock_project,
|
||||
agent_name="hosted-agent",
|
||||
agent_version="3",
|
||||
allow_preview=True,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options",
|
||||
new_callable=AsyncMock,
|
||||
return_value={"model": "gpt-4.1"},
|
||||
):
|
||||
result = await client._prepare_options(
|
||||
messages=[Message(role="user", contents="hi")],
|
||||
options={},
|
||||
)
|
||||
|
||||
assert "model" not in result
|
||||
# No extra_body at all is the cleanest signal — agent_reference must not be injected here.
|
||||
assert "extra_body" not in result
|
||||
|
||||
|
||||
async def test_raw_foundry_agent_chat_client_prepare_options_respects_caller_agent_reference() -> None:
|
||||
"""A caller-supplied extra_body['agent_reference'] should not be overwritten."""
|
||||
|
||||
mock_project = MagicMock()
|
||||
mock_project.get_openai_client.return_value = MagicMock()
|
||||
|
||||
client = RawFoundryAgentChatClient(
|
||||
project_client=mock_project,
|
||||
agent_name="default-agent",
|
||||
)
|
||||
|
||||
caller_reference = {"name": "override-agent", "type": "agent_reference", "version": "5"}
|
||||
with patch(
|
||||
"agent_framework_openai._chat_client.RawOpenAIChatClient._prepare_options",
|
||||
new_callable=AsyncMock,
|
||||
return_value={"model": "gpt-4.1", "extra_body": {"agent_reference": caller_reference}},
|
||||
):
|
||||
result = await client._prepare_options(
|
||||
messages=[Message(role="user", contents="hi")],
|
||||
options={"extra_body": {"agent_reference": caller_reference}},
|
||||
)
|
||||
|
||||
assert result["extra_body"]["agent_reference"] == caller_reference
|
||||
assert result == {}
|
||||
|
||||
|
||||
async def test_raw_foundry_agent_chat_client_prepare_options_maps_agent_session_id_to_extra_body() -> None:
|
||||
@@ -391,7 +267,6 @@ async def test_raw_foundry_agent_chat_client_prepare_options_maps_agent_session_
|
||||
assert result["extra_body"] == {
|
||||
"custom": "value",
|
||||
"agent_session_id": "agent-session-123",
|
||||
"agent_reference": {"name": "test-agent", "type": "agent_reference"},
|
||||
}
|
||||
assert "previous_response_id" not in result
|
||||
assert "conversation" not in result
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# agent-framework-hyperlight
|
||||
|
||||
Hyperlight-backed CodeAct integrations for Microsoft Agent Framework.
|
||||
Alpha Hyperlight-backed CodeAct integrations for Microsoft Agent Framework.
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -121,9 +121,8 @@ codeact = HyperlightCodeActProvider(
|
||||
## Notes
|
||||
|
||||
- This package is intentionally separate from `agent-framework-core` so CodeAct
|
||||
usage and installation remain optional. With `agent-framework-core[all]` (or
|
||||
the meta `agent-framework`) installed it is also reachable through the
|
||||
lazy-loading namespace `agent_framework.hyperlight`.
|
||||
usage and installation remain optional.
|
||||
- Alpha-package samples live under `packages/hyperlight/samples/`.
|
||||
- `file_mounts` accepts a single string shorthand, an explicit `(host_path,
|
||||
mount_path)` pair, or a `FileMount` named tuple. The host-side path in the
|
||||
explicit forms may be a `str` or `Path`. Use the explicit two-value form when
|
||||
|
||||
@@ -8,10 +8,10 @@ import shutil
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable, Sequence
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from concurrent.futures import Future, ThreadPoolExecutor
|
||||
from contextlib import suppress
|
||||
from copy import copy
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path, PurePosixPath
|
||||
from tempfile import TemporaryDirectory
|
||||
from typing import Any, Protocol, TypeGuard, TypeVar, cast
|
||||
@@ -92,208 +92,39 @@ _T = TypeVar("_T")
|
||||
|
||||
|
||||
class _SandboxWorker:
|
||||
"""Thread-confined actor that owns a sandbox + snapshot.
|
||||
"""Single-threaded executor that confines all sandbox operations to one OS thread.
|
||||
|
||||
The Hyperlight ``WasmSandbox`` is declared ``unsendable`` in PyO3: it can only be
|
||||
accessed *and dropped* from the OS thread that created it. Touching or
|
||||
releasing it on any other thread triggers a Rust panic
|
||||
(``"_native_wasm::WasmSandbox is unsendable, but is being dropped on another thread"``)
|
||||
that cannot be caught from Python.
|
||||
|
||||
To make this guarantee airtight, this class is an actor: the underlying
|
||||
sandbox and snapshot are stored ONLY as worker-local state and are never
|
||||
exposed to or returned to other threads. Public methods submit closures to
|
||||
the dedicated single-thread executor and return only sendable results.
|
||||
Because no caller can ever obtain a strong reference to the unsendable
|
||||
objects, no caller can ever cause them to be dropped on the wrong thread.
|
||||
|
||||
Exception isolation: exceptions raised inside worker closures carry a
|
||||
``__traceback__`` whose frames retain references to local variables --
|
||||
including PyO3 unsendable sandbox/native_result objects. Letting such an
|
||||
exception propagate to the calling thread would defeat the actor model:
|
||||
when the calling thread GCs the exception, the traceback's frame locals
|
||||
are dropped on the wrong thread and PyO3 panics. To prevent this, every
|
||||
exception raised inside a worker closure is caught on the worker, the
|
||||
traceback is dropped while still on the worker thread, and a sanitized
|
||||
copy (preserving message and exception type) is re-raised on the caller.
|
||||
The Hyperlight ``WasmSandbox`` is declared ``unsendable`` in PyO3, meaning it can only be
|
||||
accessed from the OS thread that created it; touching it from any other thread triggers a
|
||||
Rust panic that cannot be caught from Python. Every cached :class:`_SandboxEntry` therefore
|
||||
owns its own ``_SandboxWorker``, and *all* lifecycle and execution calls against the
|
||||
underlying sandbox object must be routed through :meth:`submit`/:meth:`run`.
|
||||
"""
|
||||
|
||||
__slots__ = ("_executor", "_initialized", "_sandbox", "_snapshot")
|
||||
__slots__ = ("_executor",)
|
||||
|
||||
def __init__(self, *, name: str = "hl-sandbox") -> None:
|
||||
self._executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix=name)
|
||||
# _sandbox/_snapshot are accessed/mutated ONLY from worker-side closures.
|
||||
self._sandbox: Any = None
|
||||
self._snapshot: Any = None
|
||||
self._initialized = False
|
||||
|
||||
def _run_on_worker(self, fn: Callable[[], _T]) -> _T:
|
||||
"""Run ``fn`` on the worker thread; sanitize any exception's traceback there.
|
||||
def submit(self, fn: Callable[..., _T], /, *args: Any, **kwargs: Any) -> Future[_T]:
|
||||
return self._executor.submit(fn, *args, **kwargs)
|
||||
|
||||
If ``fn`` raises, the exception's ``__traceback__`` is dropped on the worker
|
||||
thread (so any PyO3 unsendable locals captured in frame locals are released
|
||||
on the owner thread) and a fresh exception of the same type is raised on
|
||||
the caller's thread carrying only the original message.
|
||||
"""
|
||||
def run(self, fn: Callable[..., _T], /, *args: Any, **kwargs: Any) -> _T:
|
||||
return self._executor.submit(fn, *args, **kwargs).result()
|
||||
|
||||
def _wrapped() -> tuple[bool, Any]:
|
||||
try:
|
||||
return True, fn()
|
||||
except BaseException as exc:
|
||||
exc_type = type(exc)
|
||||
# Capture args (usually (message,)) so the re-raised exception keeps the
|
||||
# original shape for types whose constructor doesn't accept a single str.
|
||||
# Coerce each arg to ``str`` on the worker thread: if a caller-supplied
|
||||
# callback (or an underlying SDK) constructed the exception with a PyO3
|
||||
# unsendable object in args, forwarding it as-is would re-introduce the
|
||||
# same cross-thread Drop hazard the traceback nulling avoids. Strings
|
||||
# are always sendable. Fall back to the str() form if args is empty.
|
||||
exc_args: tuple[str, ...] = tuple(str(a) for a in exc.args) if exc.args else (str(exc),)
|
||||
# Drop the traceback on the worker thread so frame locals (which
|
||||
# may include PyO3 unsendable objects) are released here, not on
|
||||
# the caller thread that will receive the wrapped exception.
|
||||
exc.__traceback__ = None
|
||||
del exc
|
||||
return False, (exc_type, exc_args)
|
||||
|
||||
ok, payload = self._executor.submit(_wrapped).result()
|
||||
if ok:
|
||||
return cast(_T, payload)
|
||||
exc_type, exc_args = cast(tuple[type[BaseException], tuple[str, ...]], payload)
|
||||
# Re-raise a fresh instance with no chained traceback frames from the worker.
|
||||
# If the exception type's constructor rejects the captured args (rare), fall
|
||||
# back to a RuntimeError carrying the string form so we never lose the signal.
|
||||
try:
|
||||
raise exc_type(*exc_args)
|
||||
except TypeError:
|
||||
raise RuntimeError(f"{exc_type.__name__}: {exc_args}") from None
|
||||
|
||||
def initialize(self, build_fn: Callable[[], tuple[Any, Any]]) -> None:
|
||||
"""Build and install the sandbox+snapshot on the worker thread.
|
||||
|
||||
``build_fn`` is invoked with no arguments on the worker thread. It must
|
||||
return ``(sandbox, snapshot)``. Both references are retained as worker-
|
||||
local attributes; they do not escape this thread.
|
||||
"""
|
||||
|
||||
def _init_on_worker() -> None:
|
||||
sandbox, snapshot = build_fn()
|
||||
self._sandbox = sandbox
|
||||
self._snapshot = snapshot
|
||||
self._initialized = True
|
||||
# Locals fall out of scope on the worker thread; the worker-local
|
||||
# attributes hold the only strong refs from now on.
|
||||
|
||||
self._run_on_worker(_init_on_worker)
|
||||
|
||||
def execute(
|
||||
self,
|
||||
*,
|
||||
code: str,
|
||||
output_dir: TemporaryDirectory[str] | None,
|
||||
build_contents: Callable[..., list[Content]],
|
||||
) -> list[Content]:
|
||||
"""Restore + run + build sendable contents — all on the worker thread.
|
||||
|
||||
Returns a plain ``list[Content]`` whose elements never carry strong
|
||||
references to the underlying sandbox or snapshot.
|
||||
"""
|
||||
|
||||
def _on_worker() -> list[Content]:
|
||||
sandbox = self._sandbox
|
||||
snapshot = self._snapshot
|
||||
sandbox.restore(snapshot)
|
||||
_clear_directory(output_dir)
|
||||
result = sandbox.run(code=code)
|
||||
try:
|
||||
return build_contents(
|
||||
result=result,
|
||||
sandbox=sandbox,
|
||||
output_dir=output_dir,
|
||||
code=code,
|
||||
)
|
||||
finally:
|
||||
# ``result`` may carry a back-reference to the sandbox. Force its
|
||||
# final dec_ref on this thread so Drop runs here, not on whatever
|
||||
# thread later GCs the ``Content`` list.
|
||||
del result
|
||||
|
||||
return self._run_on_worker(_on_worker)
|
||||
|
||||
def is_alive(self) -> bool:
|
||||
"""Return ``True`` while the worker thread can still accept new submissions.
|
||||
|
||||
Useful for tests/observability; returns ``False`` after ``dispose()``.
|
||||
"""
|
||||
try:
|
||||
self._executor.submit(lambda: None).result(timeout=1.0)
|
||||
except RuntimeError:
|
||||
return False
|
||||
return True
|
||||
|
||||
def dispose(self) -> None:
|
||||
"""Release the sandbox+snapshot on the owner worker thread, then shut down.
|
||||
|
||||
Safe to call multiple times. After ``dispose`` returns, the sandbox/
|
||||
snapshot are guaranteed to have been released on the worker thread; any
|
||||
remaining references held elsewhere have already been impossible (they
|
||||
never leaked out of this object).
|
||||
"""
|
||||
|
||||
def _dispose_on_worker() -> None:
|
||||
sandbox = self._sandbox
|
||||
snapshot = self._snapshot
|
||||
self._sandbox = None
|
||||
self._snapshot = None
|
||||
close_hook = (
|
||||
(getattr(sandbox, "close", None) or getattr(sandbox, "shutdown", None)) if sandbox is not None else None
|
||||
)
|
||||
if callable(close_hook):
|
||||
with suppress(Exception):
|
||||
close_hook()
|
||||
# ``sandbox`` and ``snapshot`` are local on the worker thread and
|
||||
# will be dec_ref'd here when this frame returns -> Drop on worker.
|
||||
del sandbox, snapshot
|
||||
|
||||
if self._initialized:
|
||||
try:
|
||||
# Use the bare executor here -- _dispose_on_worker swallows its
|
||||
# own errors and never raises, so traceback sanitization is not
|
||||
# needed and we want dispose to remain robust during teardown.
|
||||
self._executor.submit(_dispose_on_worker).result()
|
||||
except RuntimeError:
|
||||
# Worker already shut down; sandbox/snapshot will leak rather
|
||||
# than panic on the wrong thread. This is the safest fallback.
|
||||
pass
|
||||
finally:
|
||||
self._initialized = False
|
||||
# Do not block on shutdown; stop accepting new tasks, but allow any
|
||||
# already-queued task (including the dispose closure above) to finish.
|
||||
def shutdown(self) -> None:
|
||||
# Do not block on shutdown; stop accepting new tasks, but allow the currently running
|
||||
# task and any already-queued tasks to finish before the worker thread exits.
|
||||
self._executor.shutdown(wait=False, cancel_futures=False)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _SandboxEntry:
|
||||
"""Per-config cached sandbox handle.
|
||||
|
||||
The unsendable sandbox/snapshot live inside ``worker`` and never appear as
|
||||
Python attributes on this object. Anything stored here is sendable and
|
||||
safe to GC on any thread.
|
||||
"""
|
||||
|
||||
worker: _SandboxWorker
|
||||
sandbox: Any
|
||||
snapshot: Any
|
||||
input_dir: TemporaryDirectory[str] | None
|
||||
output_dir: TemporaryDirectory[str] | None
|
||||
|
||||
def dispose(self) -> None:
|
||||
"""Release the sandbox+snapshot on the worker thread and clean up temp dirs."""
|
||||
self.worker.dispose()
|
||||
for tmp_dir in (self.input_dir, self.output_dir):
|
||||
if tmp_dir is not None:
|
||||
with suppress(Exception):
|
||||
tmp_dir.cleanup()
|
||||
self.input_dir = None
|
||||
self.output_dir = None
|
||||
worker: _SandboxWorker = field(default_factory=_SandboxWorker)
|
||||
|
||||
|
||||
def _load_sandbox_class() -> type[Any]:
|
||||
@@ -601,23 +432,6 @@ def _parse_output_files(
|
||||
return []
|
||||
|
||||
|
||||
def _result_snapshot(result: Any) -> dict[str, Any]:
|
||||
"""Return a sendable plain-dict snapshot of a sandbox.run() result.
|
||||
|
||||
The Hyperlight ``WasmSandbox.run()`` return value is a PyO3 ``unsendable`` object that
|
||||
can carry a back-reference to the sandbox itself. Storing it on
|
||||
``Content.raw_representation`` lets it ride out of the owner thread and be garbage
|
||||
collected elsewhere, which trips the PyO3 ``Drop`` panic. Build a thread-safe summary
|
||||
of the fields we actually surface and forward that instead, so the original result can
|
||||
be released on the worker thread that produced it.
|
||||
"""
|
||||
return {
|
||||
"success": bool(getattr(result, "success", False)),
|
||||
"stdout": str(getattr(result, "stdout", "") or ""),
|
||||
"stderr": str(getattr(result, "stderr", "") or ""),
|
||||
}
|
||||
|
||||
|
||||
def _build_execution_contents(
|
||||
*,
|
||||
result: Any,
|
||||
@@ -628,11 +442,10 @@ def _build_execution_contents(
|
||||
success = bool(getattr(result, "success", False))
|
||||
stdout = str(getattr(result, "stdout", "") or "").replace("\r\n", "\n") or None
|
||||
stderr = str(getattr(result, "stderr", "") or "").replace("\r\n", "\n") or None
|
||||
snapshot = _result_snapshot(result)
|
||||
outputs: list[Content] = []
|
||||
|
||||
if stdout is not None:
|
||||
outputs.append(Content.from_text(stdout, raw_representation=snapshot))
|
||||
outputs.append(Content.from_text(stdout, raw_representation=result))
|
||||
|
||||
outputs.extend(
|
||||
_parse_output_files(
|
||||
@@ -644,7 +457,7 @@ def _build_execution_contents(
|
||||
|
||||
if success:
|
||||
if stderr is not None:
|
||||
outputs.append(Content.from_text(stderr, raw_representation=snapshot))
|
||||
outputs.append(Content.from_text(stderr, raw_representation=result))
|
||||
if not outputs:
|
||||
outputs.append(Content.from_text("Code executed successfully without output."))
|
||||
return outputs
|
||||
@@ -654,7 +467,7 @@ def _build_execution_contents(
|
||||
Content.from_error(
|
||||
message="Execution error",
|
||||
error_details=error_details,
|
||||
raw_representation=snapshot,
|
||||
raw_representation=result,
|
||||
)
|
||||
)
|
||||
return outputs
|
||||
@@ -720,14 +533,21 @@ class _SandboxRegistry(SandboxRuntime):
|
||||
Entries are keyed by ``config.cache_key()``. All operations against the underlying
|
||||
sandbox object are routed through the entry's dedicated single-threaded worker, which
|
||||
both serializes concurrent callers and satisfies the PyO3 ``unsendable`` invariant
|
||||
that the sandbox can only be touched from the thread that created it. The unsendable
|
||||
objects never escape the worker; this method returns only sendable plain Python data.
|
||||
that the sandbox can only be touched from the thread that created it.
|
||||
"""
|
||||
entry = self._get_or_create_entry(config)
|
||||
return entry.worker.execute(
|
||||
code=code,
|
||||
return entry.worker.run(self._run_on_worker, entry, code)
|
||||
|
||||
@staticmethod
|
||||
def _run_on_worker(entry: _SandboxEntry, code: str) -> list[Content]:
|
||||
entry.sandbox.restore(entry.snapshot)
|
||||
_clear_directory(entry.output_dir)
|
||||
result = entry.sandbox.run(code=code)
|
||||
return _build_execution_contents(
|
||||
result=result,
|
||||
sandbox=entry.sandbox,
|
||||
output_dir=entry.output_dir,
|
||||
build_contents=_build_execution_contents,
|
||||
code=code,
|
||||
)
|
||||
|
||||
def _get_or_create_entry(self, config: _RunConfig) -> _SandboxEntry:
|
||||
@@ -742,19 +562,22 @@ class _SandboxRegistry(SandboxRuntime):
|
||||
def close(self) -> None:
|
||||
"""Shut down all per-entry worker threads and release per-entry resources.
|
||||
|
||||
Safe to call multiple times. Each entry's sandbox/snapshot is disposed on the
|
||||
worker thread that created it to honor the PyO3 ``unsendable`` invariant.
|
||||
Safe to call multiple times. Runs any sandbox close hook on the entry's
|
||||
own worker thread to honor the PyO3 ``unsendable`` invariant.
|
||||
"""
|
||||
with self._entries_lock:
|
||||
entries = list(self._entries.values())
|
||||
self._entries.clear()
|
||||
try:
|
||||
for entry in entries:
|
||||
entry.dispose()
|
||||
finally:
|
||||
# Drop our local strong references; entries' own refs to sandbox/snapshot
|
||||
# were already moved into the per-worker disposal closure inside dispose().
|
||||
del entries
|
||||
for entry in entries:
|
||||
close_hook = getattr(entry.sandbox, "close", None) or getattr(entry.sandbox, "shutdown", None)
|
||||
if callable(close_hook):
|
||||
with suppress(Exception):
|
||||
entry.worker.run(close_hook)
|
||||
entry.worker.shutdown()
|
||||
for tmp_dir in (entry.input_dir, entry.output_dir):
|
||||
if tmp_dir is not None:
|
||||
with suppress(Exception):
|
||||
tmp_dir.cleanup()
|
||||
|
||||
def _create_entry(self, config: _RunConfig) -> _SandboxEntry:
|
||||
input_dir_handle = TemporaryDirectory() if config.filesystem_enabled else None
|
||||
@@ -794,6 +617,8 @@ class _SandboxRegistry(SandboxRuntime):
|
||||
methods=list(allowed_domain.methods) if allowed_domain.methods is not None else None,
|
||||
)
|
||||
|
||||
worker = _SandboxWorker()
|
||||
|
||||
def _build_sandbox() -> tuple[Any, Any]:
|
||||
sandbox = _create_sandbox()
|
||||
_configure_sandbox(sandbox=sandbox, expand_missing_scheme=False)
|
||||
@@ -811,17 +636,18 @@ class _SandboxRegistry(SandboxRuntime):
|
||||
snapshot = sandbox.snapshot()
|
||||
return sandbox, snapshot
|
||||
|
||||
worker = _SandboxWorker()
|
||||
try:
|
||||
worker.initialize(_build_sandbox)
|
||||
sandbox, snapshot = worker.run(_build_sandbox)
|
||||
except BaseException:
|
||||
worker.dispose()
|
||||
worker.shutdown()
|
||||
raise
|
||||
|
||||
return _SandboxEntry(
|
||||
worker=worker,
|
||||
sandbox=sandbox,
|
||||
snapshot=snapshot,
|
||||
input_dir=input_dir_handle,
|
||||
output_dir=output_dir_handle,
|
||||
worker=worker,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "Hyperlight CodeAct integrations for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.0.0b260501"
|
||||
version = "1.0.0a260429"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
@@ -12,7 +12,7 @@ urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=ta
|
||||
urls.issues = "https://github.com/microsoft/agent-framework/issues"
|
||||
classifiers = [
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Development Status :: 4 - Beta",
|
||||
"Development Status :: 3 - Alpha",
|
||||
"Intended Audience :: Developers",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
@@ -23,9 +23,9 @@ classifiers = [
|
||||
]
|
||||
dependencies = [
|
||||
"agent-framework-core>=1.2.2,<2",
|
||||
"hyperlight-sandbox>=0.4.0,<0.5",
|
||||
"hyperlight-sandbox-backend-wasm>=0.4.0,<0.5 ; ((sys_platform == 'linux' and platform_machine == 'x86_64') or (sys_platform == 'win32' and platform_machine == 'AMD64')) and python_version < '3.14'",
|
||||
"hyperlight-sandbox-python-guest>=0.4.0,<0.5",
|
||||
"hyperlight-sandbox>=0.3.0,<0.4",
|
||||
"hyperlight-sandbox-backend-wasm>=0.3.0,<0.4 ; ((sys_platform == 'linux' and platform_machine == 'x86_64') or (sys_platform == 'win32' and platform_machine == 'AMD64')) and python_version < '3.14'",
|
||||
"hyperlight-sandbox-python-guest>=0.3.0,<0.4",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
@@ -53,6 +53,7 @@ markers = [
|
||||
extend = "../../pyproject.toml"
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"samples/**" = ["INP", "T201"]
|
||||
"tests/**" = ["D", "INP", "TD", "ERA001", "RUF", "S"]
|
||||
|
||||
[tool.coverage.run]
|
||||
@@ -81,7 +82,7 @@ disallow_untyped_decorators = true
|
||||
|
||||
[tool.bandit]
|
||||
targets = ["agent_framework_hyperlight"]
|
||||
exclude_dirs = ["tests"]
|
||||
exclude_dirs = ["tests", "samples"]
|
||||
|
||||
[tool.poe]
|
||||
executor.type = "uv"
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# Hyperlight CodeAct samples
|
||||
|
||||
These samples demonstrate the alpha `agent-framework-hyperlight` package.
|
||||
|
||||
## When to use which pattern
|
||||
|
||||
- **Provider pattern** (`codeact_context_provider.py`): Use when the tool
|
||||
registry, file mounts, or network allow-list may change between runs, or when
|
||||
you want the provider to manage CodeAct instructions and approval computation
|
||||
automatically on every invocation. This is the recommended default for
|
||||
production agents that need dynamic capability management or concurrent runs
|
||||
sharing one provider.
|
||||
|
||||
- **Manual static wiring** (`codeact_manual_wiring.py`): Use when the sandbox
|
||||
tool set and capabilities are fixed for the agent's lifetime. This pattern
|
||||
builds instructions once, passes `execute_code` alongside direct tools in
|
||||
`tools=`, and skips the per-run provider lifecycle entirely. Simpler setup,
|
||||
but changes to the tool registry after construction will not update the
|
||||
agent's instructions automatically.
|
||||
|
||||
- **Standalone tool** (`codeact_tool.py`): Use for the simplest integration
|
||||
where `execute_code` is added directly to the agent tool list. The tool's own
|
||||
description advertises `call_tool(...)` and the registered sandbox tools, so
|
||||
no extra agent instructions are needed. Best for quick prototyping or when
|
||||
CodeAct is just another tool alongside the agent's direct tools.
|
||||
|
||||
## Samples
|
||||
|
||||
- `codeact_context_provider.py` shows the provider-owned CodeAct model where the
|
||||
agent only sees `execute_code` and sandbox tools are owned by
|
||||
`HyperlightCodeActProvider`.
|
||||
- `codeact_manual_wiring.py` shows static wiring where `HyperlightExecuteCodeTool`
|
||||
and its instructions are passed directly to the `Agent` constructor.
|
||||
- `codeact_tool.py` shows the standalone `HyperlightExecuteCodeTool` surface
|
||||
where `execute_code` is added directly to the agent tool list.
|
||||
|
||||
Run the samples from the repository after installing the workspace dependencies:
|
||||
|
||||
```bash
|
||||
uv run --directory packages/hyperlight python samples/codeact_context_provider.py
|
||||
uv run --directory packages/hyperlight python samples/codeact_manual_wiring.py
|
||||
uv run --directory packages/hyperlight python samples/codeact_tool.py
|
||||
```
|
||||
@@ -0,0 +1,253 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
"""Benchmark CodeAct vs. traditional tool-calling for a multi-tool-call task.
|
||||
|
||||
This sample runs the same prompt against the same FoundryChatClient twice:
|
||||
|
||||
1. **Traditional tool-calling**: the five business tools are passed directly to
|
||||
the agent, so the model calls each tool individually via the LLM tool-call
|
||||
interface.
|
||||
2. **CodeAct**: the same tools are registered on a HyperlightCodeActProvider
|
||||
and the model sees a single ``execute_code`` tool that calls them from
|
||||
inside the Hyperlight sandbox via ``call_tool(...)``.
|
||||
|
||||
The task (computing grand totals per user) naturally requires many tool calls
|
||||
to complete. At the end, the sample prints elapsed time and token usage for
|
||||
each run so the two approaches can be compared.
|
||||
|
||||
Run with:
|
||||
cd python
|
||||
uv run --directory packages/hyperlight python samples/codeact_benchmark.py
|
||||
|
||||
Required environment variables (loaded from ``.env`` if present):
|
||||
FOUNDRY_PROJECT_ENDPOINT
|
||||
FOUNDRY_MODEL
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import time
|
||||
from typing import Annotated, Any, Literal
|
||||
|
||||
from agent_framework import Agent, AgentResponse, UsageDetails
|
||||
from agent_framework.foundry import FoundryChatClient
|
||||
from azure.identity import AzureCliCredential
|
||||
from dotenv import load_dotenv
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from agent_framework_hyperlight import HyperlightCodeActProvider
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
# 1. Deterministic "business" data and tools.
|
||||
|
||||
_USERS: list[dict[str, Any]] = [
|
||||
{"id": 1, "name": "Alice", "region": "EU", "tier": "gold"},
|
||||
{"id": 2, "name": "Bob", "region": "US", "tier": "silver"},
|
||||
{"id": 3, "name": "Charlie", "region": "US", "tier": "gold"},
|
||||
{"id": 4, "name": "Diana", "region": "APAC", "tier": "bronze"},
|
||||
{"id": 5, "name": "Evan", "region": "EU", "tier": "silver"},
|
||||
{"id": 6, "name": "Fiona", "region": "US", "tier": "gold"},
|
||||
{"id": 7, "name": "George", "region": "APAC", "tier": "gold"},
|
||||
{"id": 8, "name": "Hana", "region": "EU", "tier": "bronze"},
|
||||
]
|
||||
|
||||
_ORDERS: dict[int, list[dict[str, Any]]] = {
|
||||
1: [{"product": "Widget", "qty": 3, "unit_price": 9.99}, {"product": "Gadget", "qty": 1, "unit_price": 19.99}],
|
||||
2: [{"product": "Widget", "qty": 1, "unit_price": 9.99}],
|
||||
3: [{"product": "Gadget", "qty": 2, "unit_price": 19.99}, {"product": "Thingamajig", "qty": 4, "unit_price": 4.50}],
|
||||
4: [{"product": "Widget", "qty": 10, "unit_price": 9.99}],
|
||||
5: [{"product": "Gadget", "qty": 1, "unit_price": 19.99}],
|
||||
6: [{"product": "Widget", "qty": 2, "unit_price": 9.99}, {"product": "Thingamajig", "qty": 5, "unit_price": 4.50}],
|
||||
7: [{"product": "Gadget", "qty": 3, "unit_price": 19.99}],
|
||||
8: [{"product": "Thingamajig", "qty": 2, "unit_price": 4.50}],
|
||||
}
|
||||
|
||||
_DISCOUNTS: dict[str, float] = {"gold": 0.20, "silver": 0.10, "bronze": 0.05}
|
||||
_TAX_RATES: dict[str, float] = {"EU": 0.21, "US": 0.08, "APAC": 0.10}
|
||||
|
||||
|
||||
def list_users() -> list[dict[str, Any]]:
|
||||
"""Return all users as a list of dictionaries.
|
||||
|
||||
Each entry has keys: id (int), name (str), region (str), tier (str).
|
||||
"""
|
||||
return _USERS
|
||||
|
||||
|
||||
def get_orders_for_user(
|
||||
user_id: Annotated[int, "The user id whose orders to retrieve."],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return the user's orders as a list of dictionaries.
|
||||
|
||||
Each entry has keys: product (str), qty (int), unit_price (float).
|
||||
"""
|
||||
return _ORDERS.get(user_id, [])
|
||||
|
||||
|
||||
def get_discount_rate(
|
||||
tier: Annotated[Literal["gold", "silver", "bronze"], "The customer tier."],
|
||||
) -> float:
|
||||
"""Return the discount rate as a float fraction (e.g. 0.2 for 20%)."""
|
||||
return _DISCOUNTS[tier]
|
||||
|
||||
|
||||
def get_tax_rate(
|
||||
region: Annotated[Literal["EU", "US", "APAC"], "The region code."],
|
||||
) -> float:
|
||||
"""Return the tax rate as a float fraction (e.g. 0.21 for 21%)."""
|
||||
return _TAX_RATES[region]
|
||||
|
||||
|
||||
def compute_line_total(
|
||||
qty: Annotated[int, "Line item quantity."],
|
||||
unit_price: Annotated[float, "Line item unit price."],
|
||||
discount_rate: Annotated[float, "Discount rate as a fraction (e.g. 0.2 for 20%)."],
|
||||
tax_rate: Annotated[float, "Tax rate as a fraction (e.g. 0.21 for 21%)."],
|
||||
) -> float:
|
||||
"""Compute a single order line total.
|
||||
|
||||
Formula: qty * unit_price * (1 - discount_rate) * (1 + tax_rate), rounded to 2 decimals.
|
||||
"""
|
||||
subtotal = qty * unit_price
|
||||
discounted = subtotal * (1.0 - discount_rate)
|
||||
return round(discounted * (1.0 + tax_rate), 2)
|
||||
|
||||
|
||||
TOOLS = [list_users, get_orders_for_user, get_discount_rate, get_tax_rate, compute_line_total]
|
||||
|
||||
|
||||
# 2. Structured output schema shared between both runs.
|
||||
|
||||
|
||||
class UserTotal(BaseModel):
|
||||
"""A user's grand total of all their orders."""
|
||||
|
||||
user_id: int = Field(description="The user's id.")
|
||||
name: str = Field(description="The user's display name.")
|
||||
grand_total: float = Field(description="Sum of all line totals, rounded to 2 decimals.")
|
||||
|
||||
|
||||
class UserGrandTotals(BaseModel):
|
||||
"""Structured output schema for both runs."""
|
||||
|
||||
results: list[UserTotal] = Field(description="One entry per user, sorted by grand_total descending.")
|
||||
|
||||
|
||||
INSTRUCTIONS = "You are a careful assistant. Use the provided tools for every lookup and computation."
|
||||
|
||||
BENCHMARK_PROMPT = (
|
||||
"For every user in our system (there are 8 of them), compute the grand total of all their orders. "
|
||||
"Use the compute_line_total tool for each user's orders, after looking up the relevant discount and "
|
||||
"tax rates for that user. "
|
||||
"Use the provided tools for EVERY data lookup (users, orders, discount rates, tax rates) and for EVERY "
|
||||
"line-total computation via compute_line_total — do not invent values or hardcode any numbers. "
|
||||
"The total per order item should apply the discount first and then the tax "
|
||||
"(e.g. total = qty * unit_price * (1-discount) * (1+tax)). "
|
||||
"Return one entry per user, sorted by grand_total descending."
|
||||
)
|
||||
|
||||
|
||||
def get_client() -> FoundryChatClient:
|
||||
"""Create a FoundryChatClient from environment variables."""
|
||||
return FoundryChatClient(
|
||||
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
|
||||
model=os.environ["FOUNDRY_MODEL"],
|
||||
credential=AzureCliCredential(),
|
||||
)
|
||||
|
||||
|
||||
# 3. Two runners that share the same tools, prompt, and structured output schema.
|
||||
|
||||
|
||||
async def _run_traditional() -> tuple[float, AgentResponse]:
|
||||
agent = Agent(
|
||||
client=get_client(),
|
||||
name="TraditionalAgent",
|
||||
instructions=INSTRUCTIONS,
|
||||
tools=TOOLS,
|
||||
default_options={"response_format": UserGrandTotals},
|
||||
)
|
||||
start = time.perf_counter()
|
||||
result = await agent.run(BENCHMARK_PROMPT)
|
||||
elapsed = time.perf_counter() - start
|
||||
return elapsed, result
|
||||
|
||||
|
||||
async def _run_codeact() -> tuple[float, AgentResponse]:
|
||||
codeact = HyperlightCodeActProvider(
|
||||
tools=TOOLS,
|
||||
approval_mode="never_require",
|
||||
)
|
||||
agent = Agent(
|
||||
client=get_client(),
|
||||
name="CodeActAgent",
|
||||
instructions=INSTRUCTIONS,
|
||||
context_providers=[codeact],
|
||||
default_options={"response_format": UserGrandTotals},
|
||||
)
|
||||
start = time.perf_counter()
|
||||
result = await agent.run(BENCHMARK_PROMPT)
|
||||
elapsed = time.perf_counter() - start
|
||||
return elapsed, result
|
||||
|
||||
|
||||
# 4. Report results side by side.
|
||||
|
||||
|
||||
def _print_section(title: str) -> None:
|
||||
bar = "=" * 70
|
||||
print(f"\n{bar}\n{title}\n{bar}")
|
||||
|
||||
|
||||
def _format_usage(usage: UsageDetails | None) -> str:
|
||||
if usage is None:
|
||||
return "usage=<none>"
|
||||
return (
|
||||
f"input={usage.get('input_token_count') or 0:>6} "
|
||||
f"output={usage.get('output_token_count') or 0:>6} "
|
||||
f"total={usage.get('total_token_count') or 0:>6}"
|
||||
)
|
||||
|
||||
|
||||
def _print_results(result: AgentResponse) -> None:
|
||||
if result.value is not None:
|
||||
for row in result.value.results:
|
||||
print(f" user_id={row.user_id:>2} name={row.name:<8} grand_total={row.grand_total:>8.2f}")
|
||||
else:
|
||||
print(result.text)
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
"""Run the benchmark and print a comparison."""
|
||||
trad_time, trad_result = await _run_traditional()
|
||||
code_time, code_result = await _run_codeact()
|
||||
|
||||
_print_section("Traditional tool-calling")
|
||||
print(f"time={trad_time:7.2f}s {_format_usage(trad_result.usage_details)}")
|
||||
_print_results(trad_result)
|
||||
|
||||
_print_section("CodeAct (HyperlightCodeActProvider)")
|
||||
print(f"time={code_time:7.2f}s {_format_usage(code_result.usage_details)}")
|
||||
_print_results(code_result)
|
||||
|
||||
_print_section("Comparison")
|
||||
trad_total = (trad_result.usage_details or {}).get("total_token_count") or 0
|
||||
code_total = (code_result.usage_details or {}).get("total_token_count") or 0
|
||||
|
||||
def pct(new: float, old: float) -> str:
|
||||
if old == 0:
|
||||
return "n/a"
|
||||
delta = (new - old) / old * 100
|
||||
sign = "+" if delta >= 0 else ""
|
||||
return f"{sign}{delta:.1f}%"
|
||||
|
||||
print(f"time : traditional={trad_time:7.2f}s codeact={code_time:7.2f}s delta={pct(code_time, trad_time)}")
|
||||
print(f"tokens : traditional={trad_total:7d} codeact={code_total:7d} delta={pct(code_total, trad_total)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
+2
-1
@@ -10,10 +10,11 @@ from typing import Annotated, Any, Literal
|
||||
|
||||
from agent_framework import Agent, FunctionInvocationContext, function_middleware, tool
|
||||
from agent_framework.foundry import FoundryChatClient
|
||||
from agent_framework.hyperlight import HyperlightCodeActProvider
|
||||
from azure.identity import AzureCliCredential
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from agent_framework_hyperlight import HyperlightCodeActProvider
|
||||
|
||||
"""This sample demonstrates the provider-owned Hyperlight CodeAct flow.
|
||||
|
||||
The sample keeps `compute` and `fetch_data` off the direct agent tool surface and
|
||||
+2
-1
@@ -8,10 +8,11 @@ from typing import Annotated, Any, Literal
|
||||
|
||||
from agent_framework import Agent, tool
|
||||
from agent_framework.foundry import FoundryChatClient
|
||||
from agent_framework.hyperlight import HyperlightExecuteCodeTool
|
||||
from azure.identity import AzureCliCredential
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from agent_framework_hyperlight import HyperlightExecuteCodeTool
|
||||
|
||||
"""This sample demonstrates manual static wiring of CodeAct without a provider.
|
||||
|
||||
Instead of using `HyperlightCodeActProvider` with `context_providers=`, this
|
||||
+2
-1
@@ -8,10 +8,11 @@ from typing import Annotated, Any, Literal
|
||||
|
||||
from agent_framework import Agent, tool
|
||||
from agent_framework.foundry import FoundryChatClient
|
||||
from agent_framework.hyperlight import HyperlightExecuteCodeTool
|
||||
from azure.identity import AzureCliCredential
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from agent_framework_hyperlight import HyperlightExecuteCodeTool
|
||||
|
||||
"""This sample demonstrates the standalone Hyperlight execute_code tool.
|
||||
|
||||
The sample adds `HyperlightExecuteCodeTool` directly to the agent. The tool's
|
||||
@@ -3,9 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import dataclasses
|
||||
import gc
|
||||
import importlib.metadata
|
||||
import importlib.util
|
||||
import inspect
|
||||
@@ -1045,8 +1042,9 @@ def test_sandbox_registry_close_shuts_down_workers(monkeypatch: pytest.MonkeyPat
|
||||
registry.close()
|
||||
|
||||
assert registry._entries == {}
|
||||
# After shutdown, the worker must report itself as no longer accepting work.
|
||||
assert worker.is_alive() is False
|
||||
# Submitting after shutdown must fail; this proves the executor was actually torn down.
|
||||
with pytest.raises(RuntimeError):
|
||||
worker.submit(lambda: None)
|
||||
|
||||
|
||||
def test_sandbox_registry_close_releases_per_entry_resources(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
@@ -1127,243 +1125,3 @@ async def test_make_sandbox_callback_propagates_exceptions() -> None:
|
||||
callback = execute_code_module._make_sandbox_callback(boom)
|
||||
with pytest.raises(RuntimeError, match="nope"):
|
||||
callback(x=1)
|
||||
|
||||
|
||||
class _OwnerThreadTrackedResult:
|
||||
"""Fake sandbox.run() return value that mirrors a PyO3 ``unsendable`` object's Drop.
|
||||
|
||||
Records (rather than panics, since CPython swallows __del__ exceptions) the OS thread
|
||||
that finalized the object, so tests can assert it was dropped on the sandbox's owner
|
||||
thread and not on whatever thread happened to GC it.
|
||||
"""
|
||||
|
||||
drop_thread_violations: list[str] = []
|
||||
|
||||
def __init__(self, *, owner_thread: int, success: bool = True, stdout: str = "", stderr: str = "") -> None:
|
||||
self._owner_thread = owner_thread
|
||||
self.success = success
|
||||
self.stdout = stdout
|
||||
self.stderr = stderr
|
||||
|
||||
def __del__(self) -> None:
|
||||
ident = threading.get_ident()
|
||||
if ident != self._owner_thread:
|
||||
type(self).drop_thread_violations.append(
|
||||
f"_OwnerThreadTrackedResult dropped on thread {ident}, owner was {self._owner_thread}"
|
||||
)
|
||||
|
||||
|
||||
class _ResultDropTrackingFakeSandbox(_FakeSandbox):
|
||||
"""Fake sandbox whose ``run()`` returns an owner-thread-tracking result."""
|
||||
|
||||
def __init__(self, **kwargs: Any) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self._owner_thread = threading.get_ident()
|
||||
|
||||
def run(self, code: str) -> Any:
|
||||
del code
|
||||
# Real Hyperlight runs almost always have non-empty stdout (the executed Python
|
||||
# ``print`` output); that is the path where _build_execution_contents attaches
|
||||
# raw_representation=result and the unsendable object escapes the worker thread.
|
||||
return _OwnerThreadTrackedResult(owner_thread=self._owner_thread, success=True, stdout="hello\n")
|
||||
|
||||
|
||||
def test_sandbox_run_result_is_finalized_on_owner_thread(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Regression: the object returned by ``sandbox.run`` must not escape its owner thread.
|
||||
|
||||
The Hyperlight ``WasmSandbox`` is unsendable; the value its ``run()`` returns can carry
|
||||
a back-reference to the sandbox and is itself unsendable. Attaching it to
|
||||
``Content.raw_representation`` lets it ride out of the worker thread and be garbage
|
||||
collected on whichever thread the asyncio loop / agent state ends up on, which trips
|
||||
the PyO3 ``Drop`` panic. Drop must happen on the worker thread that ran ``run()``.
|
||||
"""
|
||||
_OwnerThreadTrackedResult.drop_thread_violations.clear()
|
||||
_FakeSandbox.instances.clear()
|
||||
monkeypatch.setattr(execute_code_module, "_load_sandbox_class", lambda: _ResultDropTrackingFakeSandbox)
|
||||
|
||||
execute_code = HyperlightExecuteCodeTool()
|
||||
|
||||
def _drive() -> None:
|
||||
# Run the whole invocation inside a helper frame so every local
|
||||
# reference (contents, awaitable, asyncio frames) dies when the
|
||||
# function returns. Anything still pinning the result is the bug.
|
||||
contents = asyncio.run(execute_code.invoke(arguments={"code": "None"}))
|
||||
assert contents and contents[0].type == "text"
|
||||
|
||||
_drive()
|
||||
for _ in range(3):
|
||||
gc.collect()
|
||||
|
||||
assert _OwnerThreadTrackedResult.drop_thread_violations == []
|
||||
|
||||
|
||||
def test_sandbox_is_finalized_on_owner_thread_after_registry_close(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Regression: dropping the sandbox object itself must occur on its owner thread.
|
||||
|
||||
``_SandboxRegistry.close()`` previously held entries in a local list whose lifetime
|
||||
extended onto the caller's thread. When that list went out of scope the unsendable
|
||||
sandbox was finalized on the caller's thread, panicking PyO3 with
|
||||
"WasmSandbox is unsendable, but is being dropped by another thread".
|
||||
"""
|
||||
drop_violations: list[str] = []
|
||||
|
||||
class _OwnerDropFakeSandbox(_FakeSandbox):
|
||||
def __init__(self, **kwargs: Any) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self._owner_thread = threading.get_ident()
|
||||
# Do not pin ourselves on the class-level instances list; we want the
|
||||
# registry/entry to hold the only strong reference so that dispose-time
|
||||
# drop is what determines the finalizer thread.
|
||||
_FakeSandbox.instances.remove(self)
|
||||
|
||||
def __del__(self) -> None:
|
||||
ident = threading.get_ident()
|
||||
if ident != self._owner_thread:
|
||||
drop_violations.append(f"sandbox dropped on thread {ident}, owner was {self._owner_thread}")
|
||||
|
||||
monkeypatch.setattr(execute_code_module, "_load_sandbox_class", lambda: _OwnerDropFakeSandbox)
|
||||
|
||||
registry = execute_code_module._SandboxRegistry()
|
||||
execute_code = HyperlightExecuteCodeTool(_registry=registry)
|
||||
asyncio.run(execute_code.invoke(arguments={"code": "None"}))
|
||||
|
||||
registry.close()
|
||||
|
||||
# Release the registry/tool references and force a GC. With the fix in place the
|
||||
# sandbox is already disposed on the worker thread inside close(); dropping these
|
||||
# local references must not trigger a wrong-thread __del__ now.
|
||||
del registry
|
||||
del execute_code
|
||||
for _ in range(3):
|
||||
gc.collect()
|
||||
|
||||
assert drop_violations == [], f"sandbox was dropped off-thread despite registry close: {drop_violations}"
|
||||
|
||||
|
||||
def test_worker_failure_does_not_leak_unsendable_via_exception_traceback(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Regression: an exception raised inside a worker closure must not leak unsendable refs.
|
||||
|
||||
Production failure mode: ``_build_sandbox`` (or ``sandbox.run``) raises on the
|
||||
worker thread. ``concurrent.futures`` propagates the exception via
|
||||
``Future.result()`` to the caller's thread. Python's exception object retains
|
||||
``__traceback__`` whose frames reference local variables -- including the
|
||||
partially-built PyO3 unsendable sandbox. When the caller's thread eventually
|
||||
GCs the exception, those locals are dec_ref'd on the wrong thread and PyO3
|
||||
panics with
|
||||
``_native_wasm::WasmSandbox is unsendable, but is being dropped on another thread``.
|
||||
|
||||
The fix routes every worker closure through ``_run_on_worker``, which catches
|
||||
the exception on the worker thread, drops its traceback there, and re-raises
|
||||
a fresh exception on the caller side carrying only the message.
|
||||
"""
|
||||
drop_violations: list[str] = []
|
||||
|
||||
class _RaisingFakeSandbox(_FakeSandbox):
|
||||
def __init__(self, **kwargs: Any) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self._owner_thread = threading.get_ident()
|
||||
_FakeSandbox.instances.remove(self)
|
||||
# Simulate production bug: build raises while ``self`` is alive in
|
||||
# the calling frame's locals -- the exception traceback will retain
|
||||
# a reference to this object.
|
||||
raise RuntimeError("simulated build failure with unsendable in frame locals")
|
||||
|
||||
def __del__(self) -> None:
|
||||
ident = threading.get_ident()
|
||||
if ident != self._owner_thread:
|
||||
drop_violations.append(f"sandbox dropped on thread {ident}, owner was {self._owner_thread}")
|
||||
|
||||
monkeypatch.setattr(execute_code_module, "_load_sandbox_class", lambda: _RaisingFakeSandbox)
|
||||
|
||||
registry = execute_code_module._SandboxRegistry()
|
||||
execute_code = HyperlightExecuteCodeTool(_registry=registry)
|
||||
|
||||
async def _drive(tool: HyperlightExecuteCodeTool) -> None:
|
||||
for _ in range(4):
|
||||
with contextlib.suppress(Exception):
|
||||
await tool.invoke(arguments={"code": "None"})
|
||||
|
||||
asyncio.run(_drive(execute_code))
|
||||
registry.close()
|
||||
|
||||
del registry
|
||||
del execute_code
|
||||
for _ in range(5):
|
||||
gc.collect()
|
||||
|
||||
assert drop_violations == [], (
|
||||
f"sandbox dropped off-thread despite worker raising on the owner thread: {drop_violations}"
|
||||
)
|
||||
|
||||
|
||||
def test_sandbox_entry_does_not_expose_unsendable_attributes() -> None:
|
||||
"""Architectural regression: the entry must not hold sandbox/snapshot as attributes.
|
||||
|
||||
The unsendable PyO3 sandbox/snapshot must live ONLY inside the per-entry worker
|
||||
thread, accessible only via worker-submitted closures. Any direct ``entry.sandbox``
|
||||
or ``entry.snapshot`` attribute would let callers obtain a strong reference that
|
||||
can be released on a non-owner thread, triggering PyO3's unsendable Drop panic
|
||||
(the production bug we are fixing).
|
||||
"""
|
||||
fields = {f.name for f in dataclasses.fields(execute_code_module._SandboxEntry)}
|
||||
assert "sandbox" not in fields, "_SandboxEntry must not expose `sandbox` directly"
|
||||
assert "snapshot" not in fields, "_SandboxEntry must not expose `snapshot` directly"
|
||||
# Whatever attributes remain must be sendable / safe to GC on any thread.
|
||||
assert fields <= {"worker", "input_dir", "output_dir"}
|
||||
|
||||
|
||||
def test_sandbox_survives_external_thread_holding_stale_reference(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Regression: stale refs held by external executors must not cause wrong-thread Drop.
|
||||
|
||||
Production traceback was ``concurrent.futures.thread._worker:95 del work_item`` on
|
||||
``asyncio_0`` -- an external ``ThreadPoolExecutor`` whose ``_WorkItem`` transitively
|
||||
held a strong reference to the sandbox via ``self._registry.execute``. When that
|
||||
work_item was deleted on the external worker thread, the sandbox's refcount could
|
||||
reach zero there, panicking PyO3.
|
||||
|
||||
With the actor-model refactor, ``HyperlightExecuteCodeTool._run_code`` runs the
|
||||
sandbox call via ``asyncio.to_thread(self._registry.execute, ...)`` which creates
|
||||
an external work_item containing ``self._registry.execute`` -- but that reference
|
||||
transitively holds only the registry, not the sandbox. The sandbox lives entirely
|
||||
inside the per-entry ``_SandboxWorker`` and never escapes; so when the external
|
||||
work_item is deleted on a non-owner thread, the sandbox's refcount cannot reach
|
||||
zero there.
|
||||
"""
|
||||
drop_violations: list[str] = []
|
||||
|
||||
class _OwnerDropFakeSandbox(_FakeSandbox):
|
||||
def __init__(self, **kwargs: Any) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self._owner_thread = threading.get_ident()
|
||||
_FakeSandbox.instances.remove(self)
|
||||
|
||||
def __del__(self) -> None:
|
||||
ident = threading.get_ident()
|
||||
if ident != self._owner_thread:
|
||||
drop_violations.append(f"sandbox dropped on thread {ident}, owner was {self._owner_thread}")
|
||||
|
||||
monkeypatch.setattr(execute_code_module, "_load_sandbox_class", lambda: _OwnerDropFakeSandbox)
|
||||
|
||||
registry = execute_code_module._SandboxRegistry()
|
||||
execute_code = HyperlightExecuteCodeTool(_registry=registry)
|
||||
|
||||
async def _drive_many(tool: HyperlightExecuteCodeTool) -> None:
|
||||
# Many concurrent invocations push work_items into asyncio's default executor;
|
||||
# each work_item's args transitively reference the registry. If the registry
|
||||
# were the sandbox holder, the work_items' deletion on asyncio_0/asyncio_1 etc.
|
||||
# could trigger a wrong-thread Drop -- which is exactly the production bug.
|
||||
await asyncio.gather(*[tool.invoke(arguments={"code": "None"}) for _ in range(8)])
|
||||
|
||||
asyncio.run(_drive_many(execute_code))
|
||||
registry.close()
|
||||
|
||||
del registry
|
||||
del execute_code
|
||||
for _ in range(5):
|
||||
gc.collect()
|
||||
|
||||
assert drop_violations == []
|
||||
|
||||
@@ -204,11 +204,6 @@ class OpenAIChatOptions(ChatOptions[ResponseFormatT], Generic[ResponseFormatT],
|
||||
"""Configuration for reasoning models (gpt-5, o-series).
|
||||
See: https://platform.openai.com/docs/guides/reasoning"""
|
||||
|
||||
verbosity: Literal["low", "medium", "high"]
|
||||
"""Output verbosity for GPT-5 family models. Lower values yield shorter responses.
|
||||
Translated to ``text.verbosity`` when sent to the Responses API.
|
||||
See: https://developers.openai.com/cookbook/examples/gpt-5/gpt-5_new_params_and_tools#1-verbosity-parameter"""
|
||||
|
||||
safety_identifier: str
|
||||
"""A stable identifier for detecting policy violations.
|
||||
Recommend hashing username/email to avoid sending identifying info."""
|
||||
@@ -667,16 +662,7 @@ class RawOpenAIChatClient( # type: ignore[misc]
|
||||
response = await client.responses.retrieve(continuation_token["response_id"])
|
||||
except Exception as ex:
|
||||
self._handle_request_error(ex)
|
||||
chat_response = self._parse_response_from_openai(response, options=validated_options)
|
||||
# Once the background response completes, drop the continuation_token from
|
||||
# the caller's options dict. FunctionInvocationLayer reuses the same dict
|
||||
# across tool-loop iterations, so leaving it in place makes the next iteration
|
||||
# retrieve the same completed response again instead of POSTing tool results
|
||||
# (issue #5394). Keep `background` so subsequent iterations still create
|
||||
# background responses.
|
||||
if chat_response.continuation_token is None and isinstance(options, dict):
|
||||
options.pop("continuation_token", None)
|
||||
return chat_response
|
||||
return self._parse_response_from_openai(response, options=validated_options)
|
||||
client, run_options, validated_options = await self._prepare_request(messages, options)
|
||||
try:
|
||||
if "text_format" in run_options:
|
||||
@@ -1336,11 +1322,6 @@ class RawOpenAIChatClient( # type: ignore[misc]
|
||||
response_format, text_config = self._prepare_response_and_text_format(
|
||||
response_format=response_format, text_config=text_config
|
||||
)
|
||||
# The Responses API nests verbosity under ``text.verbosity``; surface it as a
|
||||
# top-level option for parity with ``reasoning`` and translate here.
|
||||
if (verbosity := run_options.pop("verbosity", None)) is not None:
|
||||
text_config = dict(text_config) if text_config else {}
|
||||
text_config["verbosity"] = verbosity
|
||||
if text_config:
|
||||
run_options["text"] = text_config
|
||||
if response_format:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user