mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.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:
committed by
GitHub
Unverified
parent
d7ca9c8f16
commit
e7dc3b91f1
@@ -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" />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
+22
@@ -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>
|
||||
+30
@@ -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]."));
|
||||
+35
@@ -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
|
||||
```
|
||||
+22
@@ -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>
|
||||
+52
@@ -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."));
|
||||
+34
@@ -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.
|
||||
+22
@@ -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>
|
||||
+40
@@ -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`."));
|
||||
+21
@@ -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.
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
+11
@@ -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]));
|
||||
}
|
||||
}
|
||||
+173
@@ -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);
|
||||
}
|
||||
}
|
||||
+16
@@ -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 _));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user