Compare commits

..
Author SHA1 Message Date
7d4c3723a7 Python: add test for empty-message pruning in approval result replacement (#5617)
Adds test coverage for the second-pass logic in
`_replace_approval_contents_with_results` that removes messages whose
`contents` list becomes empty after first-pass content removal.

Addresses review comment on PR #5331:
https://github.com/microsoft/agent-framework/pull/5331#discussion_r3129039445

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-04 10:19:45 +02:00
shrutitopleandeavanvalkenburg 9711562c9e Python: Address PR 5331 comments and track sesssion while calling Agent in email_security_example (#5446)
* Address PR review: fix paths and update FIDES implementation

* Address PR comments and add session tracking in email example in samples

* Fix session creation and resolve merge conflict in docstring example

* Resolve merge conflict in docstring example
2026-05-04 10:00:41 +02:00
14d779c0fb Python: updated import naming and comment from review (#5421)
* updated import naming and comment from review

* Add approval replay None call-id test

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-04 09:58:52 +02:00
shrutitopleandeavanvalkenburg 2607ba1b36 Address PR review: fix paths and update FIDES implementation (#5352) 2026-05-04 09:58:09 +02:00
912961b10c Python: follow up FIDES security flow (#5330)
* Python: follow up FIDES security flow

Refine the secure approval path, mark the security classes with the FIDES experimental feature label, and clean up the related docs/tests. Also fix workspace-level validation regressions uncovered while running the full Python check suite.

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

* Python: remove FIDES GitHub MCP sample

Drop the GitHub MCP security sample from the FIDES follow-up branch while keeping the remaining security docs and samples intact.

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-04 09:58:09 +02:00
8a08776a32 Python: Information-flow control based prompt injection defense (#5024)
* fides integration

* documentation

* documentation

* documentation

* human-approval on policy violation

* numenous hyena 'works'

* IFC based implementation

* minor edits in documentation

* rebasing the branch and running the email example

* Add security tests for IFC middleware

* Fix Role.TOOL NameError in approval handling

* tiered labelling scheme

* 3 tier labelling scheme in middleware

* Adapt security middleware to list[Content] tool results

* Refactor SecureAgentConfig as context provider and address Copilot review comments

* Update FIDES docs to reflect context provider pattern and update code for ContextProvider rename

* Fix security examples: use OpenAIChatClient instead of non-existent AzureOpenAIChatClient

* Address PR review: consolidate security modules, remove ContentLineage, update docs

* remove unrelated files

* remove comment from _tools.py and rename decision file

* Fix CI failures: Bandit B110, broken md links, hosted approval passthrough

* apply template to decision doc 0024

* minor fixes to decision doc 0024

---------

Co-authored-by: Aashish <t-akolluri@microsoft.com>
2026-05-04 09:57:37 +02:00
135 changed files with 8590 additions and 9764 deletions
+79 -77
View File
@@ -6,12 +6,8 @@
[![MS Learn Documentation](https://img.shields.io/badge/MS%20Learn-Documentation-blue)](https://learn.microsoft.com/en-us/agent-framework/)
[![PyPI](https://img.shields.io/pypi/v/agent-framework)](https://pypi.org/project/agent-framework/)
[![NuGet](https://img.shields.io/nuget/v/Microsoft.Agents.AI)](https://www.nuget.org/profiles/MicrosoftAgentFramework/)
[![GitHub stars](https://img.shields.io/github/stars/microsoft/agent-framework?style=social)](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?** [![GitHub stars](https://img.shields.io/badge/Star-us%20on%20GitHub-yellow)](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
@@ -0,0 +1,142 @@
---
status: proposed
contact: shruti
date: 2026-01-14
deciders: {}
consulted: {}
informed: {}
---
# FIDES - Deterministic Prompt Injection Defense [Costa et al., 2025]
## Context and Problem Statement
AI agents are vulnerable to prompt injection attacks where malicious instructions embedded in external content (e.g., API responses, user input) can manipulate agent behavior. Traditional defenses rely on heuristics and prompt engineering, which are not deterministic and can be bypassed.
We need a systematic, deterministic defense mechanism that prevents untrusted content from influencing agent behavior, provides verifiable security guarantees, maintains audit trails for compliance, and integrates seamlessly with the existing agent framework.
## Decision Drivers
- Agents must not execute actions influenced by untrusted external content (prompt injection defense).
- The solution must provide deterministic, verifiable security guarantees — not heuristic-based.
- The solution must maintain audit trails for compliance and security reviews.
- The solution must integrate non-invasively with the existing middleware pipeline.
- The solution must be opt-in and backwards compatible with existing agents.
- Developer experience must remain simple with a clear security model.
## Considered Options
- Information-flow control with label-based middleware (FIDES)
- Prompt engineering defense
- Content sanitization
- Separate agent instances
- Runtime monitoring only
## Decision Outcome
Chosen option: "Information-flow control with label-based middleware (FIDES)", because it is the only option that provides deterministic, formally verifiable security guarantees while integrating non-invasively with the existing middleware pipeline and remaining fully backwards compatible.
FIDES (Flow Integrity Deterministic Enforcement System) is a label-based security system with four core components:
1. **Content Labeling System** — `IntegrityLabel` (TRUSTED/UNTRUSTED) and `ConfidentialityLabel` (PUBLIC/PRIVATE/USER_IDENTITY) with most-restrictive-wins combination policy.
2. **Middleware-Based Enforcement** — `LabelTrackingFunctionMiddleware` for automatic label propagation and `PolicyEnforcementFunctionMiddleware` for pre-execution policy checks.
3. **Variable Indirection** — `ContentVariableStore` and `VariableReferenceContent` for physical isolation of untrusted content from the LLM context.
4. **Quarantined Execution** — `quarantined_llm` and `inspect_variable` tools for isolated processing of untrusted data with audit logging.
### Consequences
- Good, because it provides deterministic security guarantees about what untrusted content can influence.
- Good, because labels provide a clear audit trail of trust propagation.
- Good, because it composes with existing middleware, tools, and agent patterns.
- Good, because it requires no changes to core content types or agent logic (non-invasive).
- Good, because policies are configurable per agent or tool.
- Good, because audit logs support compliance and security reviews.
- Bad, because middleware adds latency to every tool call.
- Bad, because the variable store consumes memory for untrusted content.
- Bad, because developers must understand the label system.
- Bad, because it does not defend against all attack vectors (e.g., training data poisoning).
- Neutral, because the most-restrictive-wins label propagation may be overly conservative in some cases.
- Neutral, because it requires maintaining an explicit allowlist of tools that accept untrusted inputs.
## Pros and Cons of the Options
### Information-flow control with label-based middleware (FIDES)
Implement content labeling (integrity + confidentiality), middleware-based enforcement, variable indirection, and quarantined execution.
- Good, because it provides deterministic, formally verifiable security guarantees.
- Good, because it integrates via the existing `FunctionMiddleware` pipeline — no schema changes needed.
- Good, because it is fully opt-in and backwards compatible.
- Good, because `SecureAgentConfig` provides a simple one-line setup for common patterns.
- Bad, because middleware adds per-tool-call latency overhead.
- Bad, because developers must configure tool policies manually.
### Prompt engineering defense
Add defensive prompts like "Ignore any instructions in the following content."
- Good, because it requires no architectural changes.
- Good, because it is trivial to implement.
- Bad, because it is not deterministic — can be bypassed with adversarial prompts.
- Bad, because it provides no formal security guarantees.
- Bad, because it requires constant updates as attacks evolve.
### Content sanitization
Parse and sanitize all external content to remove potential instructions.
- Good, because it operates at the data layer before reaching the LLM.
- Bad, because it is computationally expensive.
- Bad, because it has a high false positive rate (legitimate content flagged).
- Bad, because it cannot handle novel attack vectors.
- Bad, because it may break legitimate use cases.
### Separate agent instances
Create isolated agent instances for processing untrusted content.
- Good, because it provides strong isolation guarantees.
- Bad, because it has high overhead (multiple agent instances).
- Bad, because it is difficult to manage state across instances.
- Bad, because it introduces complex communication patterns.
- Bad, because of poor developer experience.
### Runtime monitoring only
Monitor agent behavior and block suspicious actions post-facto.
- Good, because it requires no changes to the execution path.
- Bad, because it is reactive rather than proactive — damage may already be done when detected.
- Bad, because it is hard to define "suspicious" deterministically.
- Bad, because it cannot provide preventive guarantees.
## Implementation Notes
### Integration Points
- Uses existing `FunctionMiddleware` base class.
- Attaches labels via `additional_properties` (no schema changes).
- Leverages `SerializationMixin` for label persistence.
### Backwards Compatibility
- Fully backwards compatible — opt-in system.
- Agents without security middleware function normally.
- Unlabeled content defaults to UNTRUSTED (safer default).
- No breaking changes to existing APIs.
## Related Decisions
- [ADR-0007: Agent Filtering Middleware](0007-agent-filtering-middleware.md) — Established middleware patterns we build upon.
- [ADR-0006: User Approval](0006-userapproval.md) — Human-in-the-loop pattern we reference.
## References
- [Securing AI Agents with Information-Flow Control (Costa et al., 2025)](https://arxiv.org/abs/2505.23643)
- [Prompt Injection Attack Examples](https://simonwillison.net/2023/Apr/14/worst-that-can-happen/)
- [Information Flow Control](https://en.wikipedia.org/wiki/Information_flow_(information_theory))
- [Taint Analysis](https://en.wikipedia.org/wiki/Taint_checking)
- [Defense in Depth](https://en.wikipedia.org/wiki/Defense_in_depth_(computing))
- [ ] Performance Benchmarks
- [ ] User Acceptance Testing
@@ -0,0 +1,352 @@
# FIDES Implementation Summary
## Overview
**FIDES** is a comprehensive deterministic prompt injection defense system for the agent framework. The implementation provides label-based security mechanisms to defend against prompt injection attacks by tracking integrity and confidentiality of content throughout agent execution.
**🚀 Key Features:**
- **Context Provider Pattern** - `SecureAgentConfig` extends `ContextProvider`, injecting tools, instructions, and middleware automatically
- **Automatic Variable Hiding** - UNTRUSTED content is automatically hidden without requiring manual intervention
- **Per-Item Embedded Labels** - Tools return `list[Content]` with `Content.from_text()` for proper label propagation
- **SecureAgentConfig** - One-line secure agent configuration via `context_providers=[config]`
- **Data Exfiltration Prevention** - `max_allowed_confidentiality` prevents sensitive data leakage
- **Message-Level Label Tracking** (Phase 1) - Track labels on every message in the conversation
## Architecture Components
The FIDES defense system consists of seven main components:
1. **Content Labeling Infrastructure** - Labels for tracking integrity and confidentiality
2. **Label Tracking Middleware** - Automatically assigns, propagates labels, and hides untrusted content
3. **Per-Item Embedded Labels** - Tools can return mixed-trust data with per-item security labels
4. **Policy Enforcement Middleware** - Blocks tool calls that violate security policies
5. **Security Tools** - Specialized tools for safe handling of untrusted content (`quarantined_llm`, `inspect_variable`)
6. **SecureAgentConfig** - Context provider for easy secure agent configuration
7. **Message-Level Label Tracking** - Track labels on every message in the conversation (Phase 1)
## Implementation Details
### Files Created
1. **`python/packages/core/agent_framework/security.py`** (~2950 lines — all security primitives, middleware, tools, and configuration in a single public module)
- `IntegrityLabel` enum (TRUSTED/UNTRUSTED)
- `ConfidentialityLabel` enum (PUBLIC/PRIVATE/USER_IDENTITY)
- `ContentLabel` class with serialization support
- `combine_labels()` function for label composition
- `ContentVariableStore` for client-side content storage
- `VariableReferenceContent` for variable indirection
- `LabeledMessage` class (inherits from `Message`) for message-level tracking
- `check_confidentiality_allowed()` helper for data exfiltration prevention
- `LabelTrackingFunctionMiddleware` - Tracks and propagates security labels
- `PolicyEnforcementFunctionMiddleware` - Enforces security policies
- `SecureAgentConfig` extends `ContextProvider` - automatic secure agent configuration
- `quarantined_llm()` - Isolated LLM calls with labeled data
- `inspect_variable()` - Controlled variable content inspection
- `store_untrusted_content()` - Helper for manual variable indirection (legacy)
- `get_security_tools()` - Returns list of security tools
- `SECURITY_TOOL_INSTRUCTIONS` - Detailed guidance for agents
2. **`FIDES_DEVELOPER_GUIDE.md`** (~1250 lines)
- Located at `python/samples/02-agents/security/FIDES_DEVELOPER_GUIDE.md`
- Complete documentation of the FIDES security system
- Architecture overview and design rationale
- Usage examples (6+ comprehensive scenarios)
- Best practices and configuration options
- API reference with full parameter documentation
- Data exfiltration prevention documentation
3. **`python/packages/core/tests/test_security.py`** (~800+ lines)
- Unit tests for ContentLabel and label operations
- Tests for ContentVariableStore functionality
- Tests for VariableReferenceContent
- Middleware behavior tests (label tracking and policy enforcement)
- Automatic hiding tests
- Per-item embedded label tests
- Context label tracking tests
- Message-level tracking tests (Phase 1)
- Data exfiltration prevention tests
4. **`docs/decisions/0024-prompt-injection-defense.md`**
- Architecture Decision Record (ADR)
- Design rationale and alternatives considered
- Security properties and guarantees
5. **`python/samples/02-agents/security/README.md`**
- Sample-focused entry point for the two runnable FIDES security samples
- Prerequisites, run commands, and links to the developer guide for deeper details
### Files Modified
1. **`python/packages/core/agent_framework/__init__.py`**
- Removed root-level security exports so `agent_framework.security` is the canonical import surface
## Core Features
### 1. Content Labeling Infrastructure
- **IntegrityLabel**: TRUSTED (user input) vs UNTRUSTED (AI-generated, external)
- **ConfidentialityLabel**: PUBLIC, PRIVATE, USER_IDENTITY
- **Label Combination**: Most restrictive policy (UNTRUSTED + metadata merging)
- **Serialization**: Full support for `to_dict()` and `from_dict()`
### 2. Per-Item Embedded Labels
Tools returning mixed-trust data embed labels on individual items using `Content.from_text()`:
```python
import json
from agent_framework import Content, tool
@tool(description="Fetch emails from inbox")
async def fetch_emails(count: int = 5) -> list[Content]:
return [
Content.from_text(
json.dumps({
"id": email["id"],
"body": email["body"],
}),
additional_properties={
"security_label": {
"integrity": "trusted" if email["internal"] else "untrusted",
"confidentiality": "private",
}
),
)
for email in emails
]
```
These embedded labels are automatically consumed by `LabelTrackingFunctionMiddleware`, which:
- Extracts the `security_label` from `additional_properties`
- Uses the embedded label as the highest-priority source for that item
- Automatically hides UNTRUSTED items in the variable store
- Replaces hidden items with `VariableReferenceContent` in the LLM context
- Preserves TRUSTED items visible to the LLM without tainting the context label
This enables tools to return mixed-trust data where some items (internal emails) remain visible while untrusted items (external emails) are automatically hidden without manual intervention.
},
)
for email in emails
]
```
### 3. Automatic Variable Hiding
This feature automatically hides any UNTRUSTED content returned by tools while keeping the hiding logic transparent to the developer. Developers do not need to manually call `store_untrusted_content()`. This allows the LLM /agent's context to remain clean and secure. Key aspects include:
- **Automatic Detection**: Middleware checks integrity label after each tool call
- **Automatic Storage**: UNTRUSTED results/items stored in variable store
- **Transparent Replacement**: LLM context receives `VariableReferenceContent`
- **Context Label Protection**: Hidden content does NOT taint context label
### 4. Context Label Tracking
- Context label starts as TRUSTED + PUBLIC
- Gets updated (tainted) when non-hidden untrusted content enters context
- Policy enforcement uses context label for validation
- Provides `get_context_label()` and `reset_context_label()` methods
### 5. Data Exfiltration Prevention
Tools declare `max_allowed_confidentiality` to prevent sensitive data leakage:
```python
@tool(
description="Post to public Slack channel",
additional_properties={
"max_allowed_confidentiality": "public", # Blocks PRIVATE data
}
)
async def post_to_slack(channel: str, message: str) -> dict:
return {"status": "posted"}
```
### 6. SecureAgentConfig (Context Provider)
SecureAgentConfig extends `ContextProvider` for automatic secure agent configuration:
```python
config = SecureAgentConfig(
auto_hide_untrusted=True,
allow_untrusted_tools={"search_web", "fetch_data"},
block_on_violation=True,
quarantine_chat_client=quarantine_client, # Optional: real LLM for quarantine
)
# Context provider injects tools, instructions, and middleware automatically
agent = Agent(
client=client,
name="secure_assistant",
instructions="You are a helpful assistant.",
tools=[my_tool],
context_providers=[config], # That's it!
)
```
## Security Properties
### Deterministic Defense
1. **Tiered label propagation**: Every tool result receives a label via 3-tier priority (embedded > source_integrity > input labels join)
2. **Context tracking**: Cumulative security state tracked across turns
3. **Policy enforcement**: Violations blocked before execution
4. **Content isolation**: Untrusted content stored as variables
5. **Taint propagation**: Once context becomes UNTRUSTED, it stays UNTRUSTED
6. **Data exfiltration prevention**: `max_allowed_confidentiality` gates output destinations
7. **Audit trail**: All security events logged
8. **No runtime guessing**: Deterministic label assignment
### Attack Prevention
- **Direct prompt injection**: Variables hide actual content from LLM
- **Indirect prompt injection**: Labels track untrusted AI-generated calls
- **Privilege escalation**: Policy blocks untrusted calls to privileged tools
- **Data exfiltration**: Confidentiality labels + `max_allowed_confidentiality` enforced
- **Tool misuse**: Only whitelisted tools accept untrusted inputs
## Configuration Options
### LabelTrackingFunctionMiddleware
- `default_integrity`: Default label for unknown sources
- `default_confidentiality`: Default confidentiality level
- `auto_hide_untrusted`: Enable automatic variable hiding (default: True)
- `hide_threshold`: Integrity level at which hiding occurs (default: UNTRUSTED)
### PolicyEnforcementFunctionMiddleware
- `allow_untrusted_tools`: Set of tools accepting untrusted inputs
- `block_on_violation`: Block vs warn on violations
- `enable_audit_log`: Enable/disable audit logging
### Tool Metadata (via `additional_properties`)
- `confidentiality`: Tool's output confidentiality level
- `source_integrity`: Fallback integrity for unlabeled results (data-producing tools only)
- `accepts_untrusted`: Explicit untrusted input permission
- `max_allowed_confidentiality`: Maximum allowed input confidentiality (for sink tools)
- `requires_approval`: Human-in-the-loop requirement
## Usage Pattern
### Recommended: SecureAgentConfig as Context Provider
```python
from agent_framework.security import SecureAgentConfig
config = SecureAgentConfig(
auto_hide_untrusted=True,
allow_untrusted_tools={"search_web"},
block_on_violation=True,
)
# Context provider injects everything automatically
agent = Agent(
client=client,
name="secure_assistant",
instructions="You are a helpful assistant.",
tools=[search_web],
context_providers=[config], # Tools, instructions, and middleware injected via before_run()
)
```
### Processing Hidden Content with quarantined_llm
```python
from agent_framework.security import quarantined_llm
# Agent automatically uses quarantined_llm with variable_ids
result = await quarantined_llm(
prompt="Summarize this data",
variable_ids=["var_abc123"] # Reference hidden content by ID
)
```
## Testing
Comprehensive test suite with:
- 115+ unit tests covering all components
- Label creation, serialization, combination
- Variable store operations
- Middleware behavior (tracking and enforcement)
- Automatic hiding with per-item labels
- Context label tracking
- Message-level tracking (Phase 1)
- Data exfiltration prevention
- Policy violation scenarios
- Audit log verification
Run tests:
```bash
cd python/packages/core && ../../.venv/bin/pytest tests/test_security.py -v
```
## Code Statistics
- **Total lines**: ~2,950+ lines (single `security.py` module)
- **New modules**: 1 (`security.py` — consolidated from 3 original modules)
- **Total tests**: 115+ unit tests
- **Documentation**: 1,250+ lines in developer guide
- **Examples**: 6+ comprehensive scenarios
## Deliverables Checklist
### Core Implementation
âś… ContentLabel infrastructure with integrity and confidentiality
âś… ContentVariableStore for variable indirection
âś… VariableReferenceContent for safe context references
âś… LabelTrackingFunctionMiddleware for automatic labeling
âś… PolicyEnforcementFunctionMiddleware for policy enforcement
âś… quarantined_llm tool for isolated processing
âś… inspect_variable tool for controlled content access
âś… store_untrusted_content helper for manual variable indirection
### Automatic Hiding Enhancement
âś… Auto-hide UNTRUSTED content with `auto_hide_untrusted` flag
âś… Per-middleware ContentVariableStore instances
âś… Thread-local storage for middleware access from tools
âś… Automatic UNTRUSTED content replacement
### Per-Item Embedded Labels
âś… Support for `additional_properties.security_label` on individual items
âś… Mixed-trust data handling (hide untrusted, keep trusted visible)
âś… Fallback to `source_integrity` for unlabeled items
### Context Label Tracking
âś… Cumulative context label tracking across turns
âś… Hidden content does NOT taint context
âś… `get_context_label()` and `reset_context_label()` methods
âś… Policy enforcement uses context label
### Data Exfiltration Prevention
âś… `max_allowed_confidentiality` tool property
âś… `check_confidentiality_allowed()` helper function
âś… Policy enforcement validates confidentiality flow
### SecureAgentConfig
âś… Context provider pattern with `ContextProvider` base class
âś… `before_run()` hook for automatic injection of tools, instructions, and middleware
âś… One-line secure agent configuration via `context_providers=[config]`
âś… `get_tools()`, `get_instructions()`, `get_middleware()` methods (for manual use)
âś… `quarantine_chat_client` support for real LLM calls
âś… `SECURITY_TOOL_INSTRUCTIONS` constant
### Documentation & Testing
âś… Complete FIDES Developer Guide (~1250 lines)
âś… Architecture Decision Record (ADR)
âś… Quick Start Guide
âś… Comprehensive test suite (115+ tests)
âś… Example code with 6+ scenarios
âś… 3 complete security examples (email, repo confidentiality, GitHub MCP labels)
## Summary
**FIDES** provides a comprehensive, deterministic defense against prompt injection attacks with:
- **Zero-effort protection**: Automatic variable hiding for developers
- **Context provider pattern**: `SecureAgentConfig` extends `ContextProvider` for automatic setup
- **Granular control**: Per-item embedded labels via `Content.from_text()` for mixed-trust data
- **Easy configuration**: `SecureAgentConfig` for one-line setup
- **Data safety**: Exfiltration prevention via confidentiality gates
- **Full traceability**: Message-level label tracking
- **Complete auditability**: All security events logged
The system ensures that untrusted content never directly reaches the LLM context and that all tool calls are policy-checked based on the cumulative security state before execution.
-2
View File
@@ -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" />
-19
View File
@@ -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" />
+3 -3
View File
@@ -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>
@@ -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>
@@ -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]."));
@@ -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
```
@@ -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>
@@ -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."));
@@ -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.
@@ -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>
@@ -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`."));
@@ -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.
-1
View File
@@ -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,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);
}
@@ -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);
}
}
@@ -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
{
@@ -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);
}
}
@@ -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());
}
}
@@ -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]));
}
}
@@ -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);
}
}
@@ -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 _));
}
}
@@ -11,7 +11,6 @@
"min_action_count": 8,
"min_message_count": 1,
"min_response_count": 1,
"max_response_count": 4,
"actions": {
"start": [
"conversation_create1",
@@ -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": {
@@ -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",
@@ -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)
+1 -1
View File
@@ -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` |
+1
View File
@@ -7,6 +7,7 @@ The foundation package containing all core abstractions, types, and built-in Ope
```
agent_framework/
├── __init__.py # Public API exports
├── security.py # Public security primitives, middleware, and tools
├── _agents.py # Agent implementations
├── _clients.py # Chat client base classes and protocols
├── _types.py # Core types (Message, ChatResponse, Content, etc.)
@@ -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",
@@ -48,8 +48,8 @@ class ExperimentalFeature(str, Enum):
EVALS = "EVALS"
FILE_HISTORY = "FILE_HISTORY"
FIDES = "FIDES"
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):
+105 -28
View File
@@ -1448,6 +1448,8 @@ async def _auto_invoke_function(
# non-declaration-only functions.
tool: FunctionTool | None = None
approval_response: Content | None = None
if function_call_content.type == "function_call":
tool = tool_map.get(function_call_content.name) # type: ignore[arg-type]
# Tool should exist because _try_execute_function_calls validates this
@@ -1462,14 +1464,20 @@ async def _auto_invoke_function(
else:
# Note: Unapproved tools (approved=False) are handled in _replace_approval_contents_with_results
# and never reach this function, so we only handle approved=True cases here.
inner_call = function_call_content.function_call # type: ignore[attr-defined]
if inner_call.type != "function_call": # type: ignore[union-attr]
approved_function_call = function_call_content.function_call # type: ignore[attr-defined]
if (
approved_function_call is None
or approved_function_call.type != "function_call"
or approved_function_call.name is None
):
return function_call_content
tool = tool_map.get(inner_call.name) # type: ignore[attr-defined, union-attr, arg-type]
tool = tool_map.get(approved_function_call.name)
if tool is None:
# we assume it is a hosted tool
return function_call_content
function_call_content = inner_call # type: ignore[assignment]
approval_response = function_call_content
function_call_content = approved_function_call
parsed_args: dict[str, Any] = dict(function_call_content.parse_arguments() or {})
@@ -1546,32 +1554,56 @@ async def _auto_invoke_function(
kwargs=runtime_kwargs.copy(),
)
call_id = function_call_content.call_id
if call_id is None:
raise KeyError(f'Function "{function_call_content.name}" is missing call_id.')
# Always pass call_id to middleware for policy violation approval flow
middleware_context.metadata["call_id"] = call_id
# Pass through the original approval response so middleware can decide whether
# this replay corresponds to a middleware-specific approval flow.
if approval_response is not None:
middleware_context.metadata["approval_response"] = approval_response
async def final_function_handler(context_obj: Any) -> Any:
return await tool.invoke(
arguments=context_obj.arguments,
context=context_obj,
tool_call_id=function_call_content.call_id,
tool_call_id=call_id,
)
from ._middleware import MiddlewareTermination
# MiddlewareTermination bubbles up to signal loop termination
try:
function_result = await middleware_pipeline.execute(middleware_context, final_function_handler)
return Content.from_function_result(
call_id=function_call_content.call_id, # type: ignore[arg-type]
result=function_result,
additional_properties=function_call_content.additional_properties,
function_result = await middleware_pipeline.execute(
context=middleware_context,
final_handler=final_function_handler,
)
# Pass through function_approval_request directly (e.g., from security middleware)
if isinstance(function_result, Content) and function_result.type == "function_approval_request":
return function_result
return Content.from_function_result(call_id=call_id, result=function_result)
except MiddlewareTermination as term_exc:
# Re-raise to signal loop termination, but first capture any result set by middleware
if middleware_context.result is not None:
# Store result in exception for caller to extract
term_exc.result = Content.from_function_result(
call_id=function_call_content.call_id, # type: ignore[arg-type]
result=middleware_context.result,
additional_properties=function_call_content.additional_properties,
)
# Pass through function_approval_request directly (e.g., from security policy middleware)
# so the approval flow in _handle_function_call_results activates correctly.
if (
isinstance(middleware_context.result, Content)
and middleware_context.result.type == "function_approval_request"
):
term_exc.result = middleware_context.result
else:
# Store result in exception for caller to extract
term_exc.result = Content.from_function_result(
call_id=call_id,
result=middleware_context.result,
additional_properties=function_call_content.additional_properties,
)
raise
except UserInputRequiredException:
raise
@@ -1877,12 +1909,24 @@ def _replace_approval_contents_with_results(
fcc_todo: dict[str, Content],
approved_function_results: list[Content],
) -> None:
"""Replace approval request/response contents with function call/result contents in-place."""
"""Replace approval request/response contents with function call/result contents in-place.
Also replaces placeholder tool results (marked with [APPROVAL_PENDING]) with actual results.
"""
from ._types import (
Content,
)
result_idx = 0
# Match results back to approvals by actual call_id instead of relying on
# approval/result iteration order.
result_by_call_id: dict[str, Content] = {}
for approved_result in approved_function_results:
if approved_result.call_id is not None and approved_result.call_id not in result_by_call_id:
result_by_call_id[approved_result.call_id] = approved_result
# Track which call_ids had their placeholders replaced
placeholders_replaced: set[str] = set()
for msg in messages:
# First pass - collect existing function call IDs to avoid duplicates
existing_call_ids = {
@@ -1900,22 +1944,31 @@ def _replace_approval_contents_with_results(
if _is_hosted_tool_approval(content):
continue
# Don't add the function call if it already exists (would create duplicate)
if content.function_call.call_id in existing_call_ids: # type: ignore[attr-defined, union-attr, operator]
if content.function_call is not None and content.function_call.call_id in existing_call_ids:
# Just mark for removal - the function call already exists
contents_to_remove.append(content_idx)
else:
elif content.function_call is not None:
# Put back the function call content only if it doesn't exist
msg.contents[content_idx] = content.function_call # type: ignore[attr-defined, assignment]
msg.contents[content_idx] = content.function_call
elif content.type == "function_approval_response":
# Skip hosted tool approvals — they must pass through to the API unchanged
if _is_hosted_tool_approval(content):
continue
if content.approved and content.id in fcc_todo: # type: ignore[attr-defined]
# Replace with the corresponding result
if result_idx < len(approved_function_results):
msg.contents[content_idx] = approved_function_results[result_idx]
result_idx += 1
msg.role = "tool"
if content.function_call is None or content.function_call.call_id is None:
continue
call_id = content.function_call.call_id
if content.approved and content.id in fcc_todo:
# Check if we already replaced a placeholder for this call_id
if call_id in placeholders_replaced:
# Placeholder was replaced - just remove the approval response
contents_to_remove.append(content_idx)
else:
# No placeholder - replace approval response with result directly
# This handles the original approval_mode="always_require" case
replacement_result = result_by_call_id.get(call_id)
if replacement_result is not None:
msg.contents[content_idx] = replacement_result
msg.role = "tool"
else:
# Create a "not approved" result for rejected calls
# Use function_call.call_id (the function's ID), not content.id (approval's ID)
@@ -1924,11 +1977,31 @@ def _replace_approval_contents_with_results(
result="Error: Tool call invocation was rejected by user.",
)
msg.role = "tool"
elif content.type == "function_result":
# Check if this is a placeholder result that should be replaced
if (
hasattr(content, "result")
and isinstance(content.result, str)
and "[APPROVAL_PENDING]" in content.result
and content.call_id in result_by_call_id
):
# Replace placeholder with actual result
msg.contents[content_idx] = result_by_call_id[content.call_id]
placeholders_replaced.add(content.call_id)
# Remove approval requests that were duplicates (in reverse order to preserve indices)
# Remove contents marked for removal (in reverse order to preserve indices)
for idx in reversed(contents_to_remove):
msg.contents.pop(idx)
# Second pass: Remove messages that are now empty after content removal
# We need to iterate in reverse to safely remove by index
messages_to_remove: list[int] = []
for msg_idx, msg in enumerate(messages):
if not msg.contents:
messages_to_remove.append(msg_idx)
for msg_idx in reversed(messages_to_remove):
messages.pop(msg_idx)
def _get_result_hooks_from_stream(stream: Any) -> list[Callable[[Any], Any]]:
inner_stream = getattr(stream, "_inner_stream", None)
@@ -2595,3 +2668,7 @@ class FunctionInvocationLayer(Generic[OptionsCoT]):
return ChatResponse.from_updates(updates, output_format_type=response_format)
return ResponseStream(_stream(), finalizer=_finalize)
# Alias for the @tool decorator, used by security tools and samples
ai_function = tool
@@ -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())
@@ -2121,7 +2121,7 @@ def _get_response_attributes(
finish_reason = (
getattr(response.raw_representation, "finish_reason", None) if response.raw_representation else None
)
if finish_reason:
if isinstance(finish_reason, str) and finish_reason:
attributes[OtelAttr.FINISH_REASONS] = json.dumps([finish_reason])
if model := getattr(response, "model", None):
attributes[OtelAttr.RESPONSE_MODEL] = model
File diff suppressed because it is too large Load Diff
-1
View File
@@ -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",
@@ -37,6 +37,18 @@ def _group_id(message: Message) -> str | None:
return value if isinstance(value, str) else None
def _build_approved_tool_roundtrip(
*,
call_id: str,
approval_id: str,
tool_name: str,
) -> tuple[Content, Content, Content]:
function_call = Content.from_function_call(call_id=call_id, name=tool_name, arguments="{}")
approval_request = Content.from_function_approval_request(id=approval_id, function_call=function_call)
approval_response = approval_request.to_function_approval_response(approved=True)
return function_call, approval_request, approval_response
async def test_base_client_with_function_calling(chat_client_base: SupportsChatGetResponse):
exec_counter = 0
@@ -2008,6 +2020,162 @@ def test_is_hosted_tool_approval_without_server_label():
assert _is_hosted_tool_approval("not a content") is False
def test_replace_approval_contents_with_results_uses_result_call_ids_without_placeholders() -> None:
from agent_framework._tools import _collect_approval_responses, _replace_approval_contents_with_results
call_one, request_one, response_one = _build_approved_tool_roundtrip(
call_id="call_1", approval_id="approval_1", tool_name="first_tool"
)
call_two, request_two, response_two = _build_approved_tool_roundtrip(
call_id="call_2", approval_id="approval_2", tool_name="second_tool"
)
messages = [
Message(role="assistant", contents=[call_one, request_one, call_two, request_two]),
Message(role="user", contents=[response_one, response_two]),
]
_replace_approval_contents_with_results(
messages,
_collect_approval_responses(messages),
[
Content.from_function_result(call_id="call_2", result="second result"),
Content.from_function_result(call_id="call_1", result="first result"),
],
)
assert len(messages) == 2
assert messages[0].contents == [call_one, call_two]
assert messages[1].role == "tool"
assert [(content.call_id, content.result) for content in messages[1].contents] == [
("call_1", "first result"),
("call_2", "second result"),
]
def test_replace_approval_contents_with_results_uses_result_call_ids_for_placeholders() -> None:
from agent_framework._tools import _collect_approval_responses, _replace_approval_contents_with_results
call_one, request_one, response_one = _build_approved_tool_roundtrip(
call_id="call_1", approval_id="approval_1", tool_name="first_tool"
)
call_two, request_two, response_two = _build_approved_tool_roundtrip(
call_id="call_2", approval_id="approval_2", tool_name="second_tool"
)
messages = [
Message(role="assistant", contents=[call_one, request_one, call_two, request_two]),
Message(
role="tool",
contents=[
Content.from_function_result(call_id="call_1", result="[APPROVAL_PENDING] first placeholder"),
Content.from_function_result(call_id="call_2", result="[APPROVAL_PENDING] second placeholder"),
],
),
Message(role="user", contents=[response_one, response_two]),
]
_replace_approval_contents_with_results(
messages,
_collect_approval_responses(messages),
[
Content.from_function_result(call_id="call_2", result="second result"),
Content.from_function_result(call_id="call_1", result="first result"),
],
)
assert len(messages) == 2
assert messages[0].contents == [call_one, call_two]
assert [(content.call_id, content.result) for content in messages[1].contents] == [
("call_1", "first result"),
("call_2", "second result"),
]
def test_replace_approval_contents_with_results_skips_results_without_call_id() -> None:
from agent_framework._tools import _collect_approval_responses, _replace_approval_contents_with_results
call_one, request_one, response_one = _build_approved_tool_roundtrip(
call_id="call_1", approval_id="approval_1", tool_name="first_tool"
)
messages = [
Message(role="assistant", contents=[call_one, request_one]),
Message(
role="tool",
contents=[Content.from_function_result(call_id="call_1", result="[APPROVAL_PENDING] placeholder")],
),
Message(role="user", contents=[response_one]),
]
_replace_approval_contents_with_results(
messages,
_collect_approval_responses(messages),
[
Content.from_function_result(call_id=None, result="ignored result"),
Content.from_function_result(call_id="call_1", result="first result"),
],
)
assert len(messages) == 2
assert messages[0].contents == [call_one]
assert [(content.call_id, content.result) for content in messages[1].contents] == [("call_1", "first result")]
def test_replace_approval_contents_with_results_prunes_emptied_messages() -> None:
"""Messages whose contents are fully consumed during the first pass should be removed.
When approval responses are paired with placeholder results, the responses are marked
for removal in the first pass. If a message contained only such responses, it ends up
with an empty `contents` list and the second pass should drop it from `messages`.
"""
from agent_framework._tools import _collect_approval_responses, _replace_approval_contents_with_results
call_one, request_one, response_one = _build_approved_tool_roundtrip(
call_id="call_1", approval_id="approval_1", tool_name="first_tool"
)
call_two, request_two, response_two = _build_approved_tool_roundtrip(
call_id="call_2", approval_id="approval_2", tool_name="second_tool"
)
messages = [
Message(role="assistant", contents=[call_one, request_one, call_two, request_two]),
Message(
role="tool",
contents=[
Content.from_function_result(call_id="call_1", result="[APPROVAL_PENDING] first placeholder"),
Content.from_function_result(call_id="call_2", result="[APPROVAL_PENDING] second placeholder"),
],
),
# This user message holds only approval_responses whose placeholders are replaced
# in the tool message above, so every content here is marked for removal and the
# message itself becomes empty -> it must be pruned by the second pass.
Message(role="user", contents=[response_one, response_two]),
]
_replace_approval_contents_with_results(
messages,
_collect_approval_responses(messages),
[
Content.from_function_result(call_id="call_1", result="first result"),
Content.from_function_result(call_id="call_2", result="second result"),
],
)
# The now-empty user message should have been pruned, leaving just the assistant
# message and the tool message with the resolved results.
assert len(messages) == 2
assert messages[0].role == "assistant"
assert messages[0].contents == [call_one, call_two]
assert messages[1].role == "tool"
assert [(content.call_id, content.result) for content in messages[1].contents] == [
("call_1", "first result"),
("call_2", "second result"),
]
# Sanity-check: no leftover empty messages.
assert all(msg.contents for msg in messages)
async def test_mixed_local_and_hosted_approval_flow(chat_client_base: SupportsChatGetResponse):
"""Test that mixed local + hosted MCP approvals are handled correctly.
@@ -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
File diff suppressed because it is too large Load Diff
@@ -744,6 +744,15 @@ class AgentFrameworkExecutor:
)
continue
# Extract policy_violation info if present (from security middleware)
policy_violation_data = content_dict.get("policy_violation")
approval_additional_props: dict[str, Any] | None = None
if isinstance(policy_violation_data, dict):
approval_additional_props = {
"policy_violation": True,
**policy_violation_data,
}
# Reconstruct function_call from server-stored data
function_call = Content.from_function_call(
call_id=stored_fc["call_id"],
@@ -756,14 +765,16 @@ class AgentFrameworkExecutor:
approved,
id=request_id,
function_call=function_call,
additional_properties=approval_additional_props,
)
contents.append(approval_response)
logger.info(
"Validated FunctionApprovalResponseContent: id=%s, "
"approved=%s, function=%s",
"approved=%s, function=%s, policy_violation=%s",
request_id,
approved,
stored_fc["name"],
approval_additional_props is not None,
)
except ImportError:
logger.warning(
@@ -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()),
)
]
@@ -1747,7 +1744,7 @@ class MessageMapper:
# Fallback to direct access if parse_arguments doesn't exist
arguments = getattr(content.function_call, "arguments", {})
return {
result = {
"type": "response.function_approval.requested",
"request_id": getattr(content, "id", "unknown"),
"function_call": {
@@ -1760,6 +1757,17 @@ class MessageMapper:
"sequence_number": self._next_sequence(context),
}
# Include policy violation details if present (from security middleware)
additional_props = cast(dict[str, Any] | None, getattr(content, "additional_properties", None))
if additional_props and isinstance(additional_props, dict) and additional_props.get("policy_violation"):
result["policy_violation"] = {
"reason": additional_props.get("reason", "Policy violation detected"),
"violation_type": additional_props.get("violation_type"),
"context_label": additional_props.get("context_label"),
}
return result
async def _map_approval_response_content(self, content: Any, context: dict[str, Any]) -> dict[str, Any]:
"""Map FunctionApprovalResponseContent to custom event."""
return {
@@ -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):
@@ -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
+3 -4
View File
@@ -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,
)
+7 -6
View File
@@ -4,7 +4,7 @@ description = "Hyperlight CodeAct integrations for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.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())
@@ -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
@@ -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
@@ -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:
@@ -145,9 +145,6 @@ class OpenAIChatCompletionOptions(ChatOptions[ResponseModelT], Generic[ResponseM
logprobs: bool
top_logprobs: int
prediction: Prediction
verbosity: Literal["low", "medium", "high"]
"""Output verbosity for GPT-5 family models. Lower values yield shorter responses.
See: https://developers.openai.com/cookbook/examples/gpt-5/gpt-5_new_params_and_tools#1-verbosity-parameter"""
OpenAIChatCompletionOptionsT = TypeVar(
@@ -343,76 +343,6 @@ async def test_get_response_with_all_parameters() -> None:
assert run_options["input"][1]["content"][0]["text"] == "Test message"
def test_openai_chat_options_declares_verbosity_field() -> None:
"""OpenAIChatOptions declares verbosity as a typed Literal field."""
from typing import get_args, get_type_hints
from agent_framework_openai import OpenAIChatOptions
annotations = get_type_hints(OpenAIChatOptions)
assert "verbosity" in annotations
assert {"low", "medium", "high"} <= set(get_args(annotations["verbosity"]))
async def test_verbosity_option_translates_to_text_field() -> None:
"""Top-level verbosity is translated to text.verbosity for the Responses API."""
client = OpenAIChatClient(model="test-model", api_key="test-key")
_, run_options, _ = await client._prepare_request(
messages=[Message(role="user", contents=["Test message"])],
options={"verbosity": "low"},
)
assert "verbosity" not in run_options
assert run_options["text"] == {"verbosity": "low"}
async def test_verbosity_option_merges_with_response_format() -> None:
"""Verbosity merges into text config alongside response_format-derived format."""
client = OpenAIChatClient(model="test-model", api_key="test-key")
_, run_options, _ = await client._prepare_request(
messages=[Message(role="user", contents=["Test message"])],
options={
"verbosity": "high",
"response_format": OutputStruct,
},
)
assert "verbosity" not in run_options
assert run_options["text"]["verbosity"] == "high"
assert run_options["text_format"] is OutputStruct
async def test_verbosity_option_top_level_overrides_nested_text_verbosity() -> None:
"""When both top-level and text['verbosity'] are set, the top-level value wins."""
client = OpenAIChatClient(model="test-model", api_key="test-key")
_, run_options, _ = await client._prepare_request(
messages=[Message(role="user", contents=["Test message"])],
options={
"verbosity": "high",
"text": {"verbosity": "low"},
},
)
assert "verbosity" not in run_options
assert run_options["text"]["verbosity"] == "high"
async def test_verbosity_option_merges_with_explicit_text_config() -> None:
"""Verbosity merges into a user-provided text config without overwriting other keys."""
client = OpenAIChatClient(model="test-model", api_key="test-key")
_, run_options, _ = await client._prepare_request(
messages=[Message(role="user", contents=["Test message"])],
options={
"verbosity": "medium",
"text": {"format": {"type": "text"}},
},
)
assert "verbosity" not in run_options
assert run_options["text"]["verbosity"] == "medium"
assert run_options["text"]["format"] == {"type": "text"}
@pytest.mark.asyncio
async def test_web_search_tool_with_location() -> None:
"""Test web search tool with location parameters."""

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