.NET: Add Microsoft.Agents.AI.Hyperlight package for CodeAct integration (.NET) (#5329)

* Add Microsoft.Agents.AI.Hyperlight package for CodeAct integration

Introduces a new Microsoft.Agents.AI.Hyperlight package that enables CodeAct-style sandboxed code execution via Hyperlight (hyperlight-sandbox .NET SDK, PR #46) for .NET agents, following the docs/features/code_act/dotnet-implementation.md design and the Python agent_framework_hyperlight reference.

Highlights:
- HyperlightCodeActProvider (AIContextProvider): injects an execute_code tool and CodeAct guidance per invocation; single-instance-per-agent via a fixed StateKeys value; supports multiple provider-owned tools (exposed inside the sandbox via call_tool), file mounts, and an outbound domain allow-list; snapshot/restore per run.
- HyperlightExecuteCodeFunction: standalone AIFunction for manual/static wiring when the sandbox configuration is fixed.
- Approval model via CodeActApprovalMode (AlwaysRequire / NeverRequire) with propagation from ApprovalRequiredAIFunction-wrapped tools.
- Unit tests (instruction builder, tool bridge, approval computation, provider CRUD, ProvideAIContextAsync snapshot isolation and approval wrapping).
- Env-gated integration test (HYPERLIGHT_PYTHON_GUEST_PATH).
- Three samples under samples/02-agents/AgentWithCodeAct (interpreter, tool-enabled, manual wiring).

Build is not yet runnable: requires .NET SDK 10.0.200 and the not-yet-published HyperlightSandbox.Api 0.1.0-preview NuGet package. Package is marked IsPackable=false until the dependency is available.

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

* Address PR #5329 review feedback for Hyperlight CodeAct provider

- A. Build-breakers: drop unused usings, override test TargetFrameworks
  off net472, drop redundant Microsoft.Extensions.AI.Abstractions PackageRef.
- B. API: keep CRUD but rebuild sandbox when config fingerprint changes;
  add HyperlightCodeActProviderOptions.CreateForWasm/CreateForJavaScript
  factory methods (Backend/ModulePath now read-only); rename WorkspaceRoot
  to HostInputDirectory; convert AllowedDomain & FileMount from record to
  sealed class; drop ToolBridge.Unwrap (ApprovalRequiredAIFunction is
  invocable as-is).
- C. ToolBridge: collapse SerializeResult switch; add comment explaining
  AOT-driven choice to keep JsonNode.Parse over typed Deserialize.
- D. InstructionBuilder: drop language-specific 'Python code' phrasing;
  strip host filesystem paths from execute_code description.
- E. Style polish: ternary expression-body for ComputeApprovalRequired,
  .Where(x is not null), .ToList() over .ToArray() in IReadOnlyList
  returns.
- F. Samples: add guest-module / KVM-WHP build instructions to Step01;
  note future Excel-upload sample in Step02.

Also adds SandboxExecutorTests covering the new RunSnapshot.ComputeFingerprint
used for sandbox-rebuild detection.

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

* Align Hyperlight package id and JS warm-up with merged upstream SDK

The .NET SDK in hyperlight-dev/hyperlight-sandbox PR #46 has merged. The
published package id is Hyperlight.HyperlightSandbox.Api (the bare
HyperlightSandbox.Api remains the assembly/namespace) and the reference
CodeExecutionTool uses 'void 0;' as the JavaScript warm-up no-op. Update
the package reference, project comment, README, and SandboxExecutor warm-up
accordingly.

No functional change beyond that — all other public APIs we depend on
(SandboxBuilder.With*, Sandbox.Run/RegisterToolAsync/AllowDomain/Snapshot/
Restore, ExecutionResult, SandboxBackend) match the merged shape.

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

* Bump Hyperlight package to 0.4.0 and fix build/test issues

Hyperlight.HyperlightSandbox.Api 0.4.0 is now published on nuget.org. Bump
the version reference and address the analyzer/runtime issues that surfaced
once restore could complete:

- Add HyperlightJsonContext source-generated JsonSerializerContext for the
  execute_code result + tool error envelopes; route arbitrary AIFunction
  results through AIJsonUtilities.DefaultOptions to keep IsAotCompatible=true.
- Replace explicit ObjectDisposedException throws with
  ObjectDisposedException.ThrowIf (CA1513).
- Use HyperlightSandbox.Api.SandboxBackend in cref docs to disambiguate.
- Update tests to match AIContext.Tools being IEnumerable<AITool>, drop
  ConfigureAwait(false) in xUnit test methods (xUnit1030), use collection
  expressions for AllowedDomain methods.
- Add 'using OpenAI.Chat;' to all three samples so AsAIAgent resolves.
- Verified: dotnet build of all four hyperlight projects + samples succeeds
  on net8/9/10; dotnet test for the unit tests passes 32/32 on net10.0.

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

* Fix CI check failures: file encoding (UTF-8 BOM + LF) and broken markdown link

- Convert all new .cs/.csproj files to UTF-8 with BOM and LF line endings
  to satisfy the dotnet/.editorconfig charset/end_of_line settings
  enforced by check-format.
- Drop unused System.Collections.Generic using in HyperlightCodeActProviderTests.
- Add missing using Microsoft.Extensions.AI in CodeActApprovalMode.cs and
  shorten ApprovalRequiredAIFunction cref (IDE0001).
- Fix broken README link to docs/decisions/0024-codeact-integration.md.

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

* Address PR review: AIFunction inheritance, packaging, GetService approval check

- HyperlightExecuteCodeFunction now inherits AIFunction directly. The
  AsAIFunction() indirection is gone; instances are accepted anywhere an
  AIFunction is. Approval requirement is surfaced via GetService<ApprovalRequiredAIFunction>()
  which lazily exposes a wrapping ApprovalRequiredAIFunction proxy when the
  effective ApprovalMode/tool stack requires it.
- ComputeApprovalRequired now uses GetService<ApprovalRequiredAIFunction>() so
  approval-required tools nested anywhere in the AITool decorator stack are
  detected (not just the top-most class).
- csproj: drop IsPackable=false (ready to release with the published
  Hyperlight.HyperlightSandbox.Api 0.4.0 dependency); add PackageReadmeFile
  and pack README.md at the package root, matching the pattern used by
  Aspire.Hosting.AgentFramework.DevUI / Microsoft.Agents.AI.DurableTask.
- Update Step03 sample and README wording to reflect direct AIFunction usage.

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Eduard van Valkenburg
2026-05-05 14:56:24 +02:00
committed by GitHub
Unverified
parent d7ca9c8f16
commit e7dc3b91f1
35 changed files with 2287 additions and 0 deletions
+2
View File
@@ -109,6 +109,8 @@
<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" />
+9
View File
@@ -175,6 +175,12 @@
<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" />
@@ -560,6 +566,7 @@
<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" />
@@ -581,6 +588,7 @@
<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" />
@@ -606,6 +614,7 @@
<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" />
@@ -0,0 +1,22 @@
<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>
@@ -0,0 +1,30 @@
// 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]."));
@@ -0,0 +1,35 @@
# 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
```
@@ -0,0 +1,22 @@
<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>
@@ -0,0 +1,52 @@
// 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."));
@@ -0,0 +1,34 @@
# 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.
@@ -0,0 +1,22 @@
<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>
@@ -0,0 +1,40 @@
// 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`."));
@@ -0,0 +1,21 @@
# 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
```
@@ -0,0 +1,16 @@
# 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,6 +11,7 @@ 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 |
@@ -0,0 +1,32 @@
// 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; }
}
@@ -0,0 +1,25 @@
// 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,
}
@@ -0,0 +1,29 @@
// 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; }
}
@@ -0,0 +1,324 @@
// 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();
}
}
@@ -0,0 +1,99 @@
// 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; }
}
@@ -0,0 +1,162 @@
// 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();
}
}
@@ -0,0 +1,83 @@
// 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);
}
}
@@ -0,0 +1,26 @@
// 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);
@@ -0,0 +1,117 @@
// 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();
}
}
@@ -0,0 +1,243 @@
// 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();
}
}
@@ -0,0 +1,94 @@
// 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);
}
}
@@ -0,0 +1,37 @@
<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>
@@ -0,0 +1,41 @@
# 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.
@@ -0,0 +1,59 @@
// 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());
}
}
@@ -0,0 +1,11 @@
<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>
@@ -0,0 +1,62 @@
// 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]));
}
}
@@ -0,0 +1,173 @@
// 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));
}
}
@@ -0,0 +1,108 @@
// 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);
}
}
@@ -0,0 +1,16 @@
<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>
@@ -0,0 +1,85 @@
// 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);
}
}
@@ -0,0 +1,84 @@
// 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);
}
}
@@ -0,0 +1,71 @@
// 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 _));
}
}