From a74fb8b9b827215df4defda91e23302b18418ce1 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Fri, 17 Apr 2026 11:37:21 +0200 Subject: [PATCH 1/6] 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> --- dotnet/Directory.Packages.props | 2 + dotnet/agent-framework-dotnet.slnx | 9 + ...AgentWithCodeAct_Step01_Interpreter.csproj | 23 ++ .../Program.cs | 34 ++ .../README.md | 23 ++ ...AgentWithCodeAct_Step02_ToolEnabled.csproj | 23 ++ .../Program.cs | 54 +++ .../README.md | 26 ++ ...gentWithCodeAct_Step03_ManualWiring.csproj | 23 ++ .../Program.cs | 42 ++ .../README.md | 21 + .../02-agents/AgentWithCodeAct/README.md | 16 + dotnet/samples/02-agents/README.md | 1 + .../AllowedDomain.cs | 18 + .../CodeActApprovalMode.cs | 23 ++ .../FileMount.cs | 16 + .../HyperlightCodeActProvider.cs | 387 ++++++++++++++++++ .../HyperlightCodeActProviderOptions.cs | 67 +++ .../HyperlightExecuteCodeFunction.cs | 109 +++++ .../Internal/ExecuteCodeFunction.cs | 85 ++++ .../Internal/InstructionBuilder.cs | 121 ++++++ .../Internal/SandboxExecutor.cs | 182 ++++++++ .../Internal/ToolBridge.cs | 112 +++++ .../Microsoft.Agents.AI.Hyperlight.csproj | 37 ++ .../Microsoft.Agents.AI.Hyperlight/README.md | 39 ++ .../CodeActEndToEndTests.cs | 62 +++ ...ents.AI.Hyperlight.IntegrationTests.csproj | 7 + .../ApprovalComputationTests.cs | 62 +++ .../HyperlightCodeActProviderTests.cs | 174 ++++++++ .../InstructionBuilderTests.cs | 103 +++++ ...soft.Agents.AI.Hyperlight.UnitTests.csproj | 12 + .../ProvideAIContextTests.cs | 83 ++++ .../ToolBridgeTests.cs | 98 +++++ 33 files changed, 2094 insertions(+) create mode 100644 dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step01_Interpreter/AgentWithCodeAct_Step01_Interpreter.csproj create mode 100644 dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step01_Interpreter/Program.cs create mode 100644 dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step01_Interpreter/README.md create mode 100644 dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step02_ToolEnabled/AgentWithCodeAct_Step02_ToolEnabled.csproj create mode 100644 dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step02_ToolEnabled/Program.cs create mode 100644 dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step02_ToolEnabled/README.md create mode 100644 dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step03_ManualWiring/AgentWithCodeAct_Step03_ManualWiring.csproj create mode 100644 dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step03_ManualWiring/Program.cs create mode 100644 dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step03_ManualWiring/README.md create mode 100644 dotnet/samples/02-agents/AgentWithCodeAct/README.md create mode 100644 dotnet/src/Microsoft.Agents.AI.Hyperlight/AllowedDomain.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hyperlight/CodeActApprovalMode.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hyperlight/FileMount.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hyperlight/HyperlightCodeActProvider.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hyperlight/HyperlightCodeActProviderOptions.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hyperlight/HyperlightExecuteCodeFunction.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/ExecuteCodeFunction.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/InstructionBuilder.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/SandboxExecutor.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/ToolBridge.cs create mode 100644 dotnet/src/Microsoft.Agents.AI.Hyperlight/Microsoft.Agents.AI.Hyperlight.csproj create mode 100644 dotnet/src/Microsoft.Agents.AI.Hyperlight/README.md create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hyperlight.IntegrationTests/CodeActEndToEndTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hyperlight.IntegrationTests/Microsoft.Agents.AI.Hyperlight.IntegrationTests.csproj create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/ApprovalComputationTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/HyperlightCodeActProviderTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/InstructionBuilderTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/Microsoft.Agents.AI.Hyperlight.UnitTests.csproj create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/ProvideAIContextTests.cs create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/ToolBridgeTests.cs diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index 56de97dbcb..0a6a8e2f0d 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -109,6 +109,8 @@ + + diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index 4d7b2a2fc8..897d438c06 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -175,6 +175,12 @@ + + + + + + @@ -560,6 +566,7 @@ + @@ -581,6 +588,7 @@ + @@ -606,6 +614,7 @@ + diff --git a/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step01_Interpreter/AgentWithCodeAct_Step01_Interpreter.csproj b/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step01_Interpreter/AgentWithCodeAct_Step01_Interpreter.csproj new file mode 100644 index 0000000000..aba3436aef --- /dev/null +++ b/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step01_Interpreter/AgentWithCodeAct_Step01_Interpreter.csproj @@ -0,0 +1,23 @@ + + + + Exe + net10.0 + + enable + enable + + + + + + + + + + + + + + + diff --git a/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step01_Interpreter/Program.cs b/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step01_Interpreter/Program.cs new file mode 100644 index 0000000000..f442aeed0f --- /dev/null +++ b/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step01_Interpreter/Program.cs @@ -0,0 +1,34 @@ +// 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 HyperlightSandbox.Api; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Hyperlight; + +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(new HyperlightCodeActProviderOptions +{ + Backend = SandboxBackend.Wasm, + ModulePath = 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].")); diff --git a/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step01_Interpreter/README.md b/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step01_Interpreter/README.md new file mode 100644 index 0000000000..ae319d090d --- /dev/null +++ b/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step01_Interpreter/README.md @@ -0,0 +1,23 @@ +# 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`. + +## Run + +```shell +cd AgentWithCodeAct_Step01_Interpreter +dotnet run +``` diff --git a/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step02_ToolEnabled/AgentWithCodeAct_Step02_ToolEnabled.csproj b/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step02_ToolEnabled/AgentWithCodeAct_Step02_ToolEnabled.csproj new file mode 100644 index 0000000000..aba3436aef --- /dev/null +++ b/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step02_ToolEnabled/AgentWithCodeAct_Step02_ToolEnabled.csproj @@ -0,0 +1,23 @@ + + + + Exe + net10.0 + + enable + enable + + + + + + + + + + + + + + + diff --git a/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step02_ToolEnabled/Program.cs b/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step02_ToolEnabled/Program.cs new file mode 100644 index 0000000000..15624094f2 --- /dev/null +++ b/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step02_ToolEnabled/Program.cs @@ -0,0 +1,54 @@ +// 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 HyperlightSandbox.Api; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Hyperlight; +using Microsoft.Extensions.AI; + +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.")); + +using var codeAct = new HyperlightCodeActProvider(new HyperlightCodeActProviderOptions +{ + Backend = SandboxBackend.Wasm, + ModulePath = guestPath, + Tools = [fetchDocs, queryData, sendEmail], +}); + +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.")); diff --git a/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step02_ToolEnabled/README.md b/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step02_ToolEnabled/README.md new file mode 100644 index 0000000000..451f811a84 --- /dev/null +++ b/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step02_ToolEnabled/README.md @@ -0,0 +1,26 @@ +# AgentWithCodeAct_Step02_ToolEnabled + +Demonstrates adding provider-owned tools to `HyperlightCodeActProvider`. Those +tools are **only** available to code running inside the sandbox via +`call_tool("", ...)` — 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 +``` diff --git a/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step03_ManualWiring/AgentWithCodeAct_Step03_ManualWiring.csproj b/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step03_ManualWiring/AgentWithCodeAct_Step03_ManualWiring.csproj new file mode 100644 index 0000000000..aba3436aef --- /dev/null +++ b/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step03_ManualWiring/AgentWithCodeAct_Step03_ManualWiring.csproj @@ -0,0 +1,23 @@ + + + + Exe + net10.0 + + enable + enable + + + + + + + + + + + + + + + diff --git a/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step03_ManualWiring/Program.cs b/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step03_ManualWiring/Program.cs new file mode 100644 index 0000000000..f474ff6c17 --- /dev/null +++ b/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step03_ManualWiring/Program.cs @@ -0,0 +1,42 @@ +// 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 HyperlightSandbox.Api; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Hyperlight; +using Microsoft.Extensions.AI; + +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."); + +using var executeCode = new HyperlightExecuteCodeFunction(new HyperlightCodeActProviderOptions +{ + Backend = SandboxBackend.Wasm, + ModulePath = guestPath, + Tools = [calculate], +}); + +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.AsAIFunction()]); + +Console.WriteLine(await agent.RunAsync("What is 12.3 * 4.5? Use the multiply tool from within `execute_code`.")); diff --git a/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step03_ManualWiring/README.md b/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step03_ManualWiring/README.md new file mode 100644 index 0000000000..1c6db54930 --- /dev/null +++ b/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step03_ManualWiring/README.md @@ -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 +``` diff --git a/dotnet/samples/02-agents/AgentWithCodeAct/README.md b/dotnet/samples/02-agents/AgentWithCodeAct/README.md new file mode 100644 index 0000000000..7506d0ff5a --- /dev/null +++ b/dotnet/samples/02-agents/AgentWithCodeAct/README.md @@ -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. diff --git a/dotnet/samples/02-agents/README.md b/dotnet/samples/02-agents/README.md index f14387c604..4e072f4b0f 100644 --- a/dotnet/samples/02-agents/README.md +++ b/dotnet/samples/02-agents/README.md @@ -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 | diff --git a/dotnet/src/Microsoft.Agents.AI.Hyperlight/AllowedDomain.cs b/dotnet/src/Microsoft.Agents.AI.Hyperlight/AllowedDomain.cs new file mode 100644 index 0000000000..6d940711a1 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hyperlight/AllowedDomain.cs @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; + +namespace Microsoft.Agents.AI.Hyperlight; + +/// +/// Represents a single entry in the outbound network allow-list applied to the +/// Hyperlight sandbox. +/// +/// +/// URL or domain to allow, for example "https://api.github.com". +/// +/// +/// Optional list of HTTP methods to allow (for example ["GET", "POST"]). +/// When , all methods supported by the backend are allowed. +/// +public sealed record AllowedDomain(string Target, IReadOnlyList? Methods = null); diff --git a/dotnet/src/Microsoft.Agents.AI.Hyperlight/CodeActApprovalMode.cs b/dotnet/src/Microsoft.Agents.AI.Hyperlight/CodeActApprovalMode.cs new file mode 100644 index 0000000000..fdc91cedc7 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hyperlight/CodeActApprovalMode.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Hyperlight; + +/// +/// Controls the approval behavior for the execute_code tool exposed by +/// and . +/// +public enum CodeActApprovalMode +{ + /// + /// execute_code always requires user approval before invocation. + /// + AlwaysRequire, + + /// + /// Approval is derived from the provider-owned CodeAct tool registry. + /// If any configured tool is an + /// , + /// execute_code also requires approval. Otherwise it does not. + /// + NeverRequire, +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hyperlight/FileMount.cs b/dotnet/src/Microsoft.Agents.AI.Hyperlight/FileMount.cs new file mode 100644 index 0000000000..aa52e013e2 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hyperlight/FileMount.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Hyperlight; + +/// +/// Represents a host-to-sandbox file mount configuration used by +/// . +/// +/// +/// Absolute or relative path on the host filesystem to mount into the sandbox. +/// +/// +/// Path inside the sandbox the host path is exposed at (for example +/// "/input/data.csv"). +/// +public sealed record FileMount(string HostPath, string MountPath); diff --git a/dotnet/src/Microsoft.Agents.AI.Hyperlight/HyperlightCodeActProvider.cs b/dotnet/src/Microsoft.Agents.AI.Hyperlight/HyperlightCodeActProvider.cs new file mode 100644 index 0000000000..89a38d2ed6 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hyperlight/HyperlightCodeActProvider.cs @@ -0,0 +1,387 @@ +// 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; + +/// +/// An that enables CodeAct execution through a +/// Hyperlight-backed sandbox. +/// +/// +/// +/// The provider injects an execute_code tool into the model-facing tool +/// surface and contributes a short CodeAct guidance block through +/// . Guest code executed via +/// execute_code runs in an isolated Hyperlight sandbox with +/// snapshot/restore for clean state per invocation. +/// +/// +/// 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 call_tool(...) but not to the model directly. +/// +/// +/// Only a single may be attached to a +/// given agent. returns a fixed value so +/// ChatClientAgent's state-key uniqueness validation rejects duplicate +/// registrations. +/// +/// +/// Security considerations: 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 +/// when guest code can reach +/// sensitive resources. +/// +/// +public sealed class HyperlightCodeActProvider : AIContextProvider, IDisposable +{ + /// + /// Fixed state key used to enforce a single provider-per-agent. + /// + internal const string FixedStateKey = "HyperlightCodeActProvider"; + + private static readonly IReadOnlyList s_stateKeys = [FixedStateKey]; + + private readonly object _gate = new(); + private readonly HyperlightCodeActProviderOptions _options; + private readonly SandboxExecutor _executor; + + private readonly Dictionary _tools = new(StringComparer.Ordinal); + private readonly Dictionary _fileMounts = new(StringComparer.Ordinal); + private readonly Dictionary _allowedDomains = new(StringComparer.Ordinal); + private bool _disposed; + + /// + /// Initializes a new instance of the class. + /// + /// + /// Optional configuration options for the provider. When the provider + /// uses the defaults of — equivalent to a + /// backend with no module, tools, mounts, or allow-list entries. + /// + 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) + { + if (tool is null) + { + continue; + } + + this._tools[tool.Name] = tool; + } + } + + if (this._options.FileMounts is not null) + { + foreach (var mount in this._options.FileMounts) + { + if (mount is null) + { + continue; + } + + this._fileMounts[mount.MountPath] = mount; + } + } + + if (this._options.AllowedDomains is not null) + { + foreach (var domain in this._options.AllowedDomains) + { + if (domain is null) + { + continue; + } + + this._allowedDomains[domain.Target] = domain; + } + } + } + + /// + public override IReadOnlyList StateKeys => s_stateKeys; + + // ------------------------------------------------------------------- + // Tool registry + // ------------------------------------------------------------------- + + /// Adds tools to the provider-owned CodeAct tool registry. Tools with a duplicate name replace the existing registration. + /// The tools to add. + public void AddTools(params AIFunction[] tools) + { + _ = Throw.IfNull(tools); + lock (this._gate) + { + this.ThrowIfDisposed(); + foreach (var tool in tools) + { + if (tool is null) + { + continue; + } + + this._tools[tool.Name] = tool; + } + } + } + + /// Returns the current CodeAct-managed tools. + public IReadOnlyList GetTools() + { + lock (this._gate) + { + return this._tools.Values.ToArray(); + } + } + + /// Removes tools by name from the CodeAct tool registry. + /// The names of the tools to remove. + public void RemoveTools(params string[] names) + { + _ = Throw.IfNull(names); + lock (this._gate) + { + foreach (var name in names) + { + if (name is null) + { + continue; + } + + _ = this._tools.Remove(name); + } + } + } + + /// Removes all CodeAct-managed tools. + public void ClearTools() + { + lock (this._gate) + { + this._tools.Clear(); + } + } + + // ------------------------------------------------------------------- + // File mounts + // ------------------------------------------------------------------- + + /// Adds file mount configurations. Mounts with a duplicate mount path replace the existing entry. + /// The mount configurations to add. + public void AddFileMounts(params FileMount[] mounts) + { + _ = Throw.IfNull(mounts); + lock (this._gate) + { + foreach (var mount in mounts) + { + if (mount is null) + { + continue; + } + + this._fileMounts[mount.MountPath] = mount; + } + } + } + + /// Returns the current file mount configurations. + public IReadOnlyList GetFileMounts() + { + lock (this._gate) + { + return this._fileMounts.Values.ToArray(); + } + } + + /// Removes file mounts by sandbox mount path. + /// The mount paths to remove. + public void RemoveFileMounts(params string[] mountPaths) + { + _ = Throw.IfNull(mountPaths); + lock (this._gate) + { + foreach (var path in mountPaths) + { + if (path is null) + { + continue; + } + + _ = this._fileMounts.Remove(path); + } + } + } + + /// Removes all file mount configurations. + public void ClearFileMounts() + { + lock (this._gate) + { + this._fileMounts.Clear(); + } + } + + // ------------------------------------------------------------------- + // Network allow-list + // ------------------------------------------------------------------- + + /// Adds outbound network allow-list entries. Entries with a duplicate target replace the existing entry. + /// The allow-list entries to add. + public void AddAllowedDomains(params AllowedDomain[] domains) + { + _ = Throw.IfNull(domains); + lock (this._gate) + { + foreach (var domain in domains) + { + if (domain is null) + { + continue; + } + + this._allowedDomains[domain.Target] = domain; + } + } + } + + /// Returns the current outbound allow-list entries. + public IReadOnlyList GetAllowedDomains() + { + lock (this._gate) + { + return this._allowedDomains.Values.ToArray(); + } + } + + /// Removes allow-list entries by target. + /// The targets to remove. + public void RemoveAllowedDomains(params string[] targets) + { + _ = Throw.IfNull(targets); + lock (this._gate) + { + foreach (var target in targets) + { + if (target is null) + { + continue; + } + + _ = this._allowedDomains.Remove(target); + } + } + } + + /// Removes all outbound allow-list entries. + public void ClearAllowedDomains() + { + lock (this._gate) + { + this._allowedDomains.Clear(); + } + } + + // ------------------------------------------------------------------- + // AIContextProvider implementation + // ------------------------------------------------------------------- + + /// + protected override ValueTask ProvideAIContextAsync(InvokingContext context, CancellationToken cancellationToken = default) + { + _ = Throw.IfNull(context); + + SandboxExecutor.RunSnapshot snapshot; + lock (this._gate) + { + this.ThrowIfDisposed(); + snapshot = new SandboxExecutor.RunSnapshot( + this._tools.Values.ToArray(), + this._fileMounts.Values.ToArray(), + this._allowedDomains.Values.ToArray(), + this._options.WorkspaceRoot); + } + + var approvalRequired = ComputeApprovalRequired(this._options.ApprovalMode, snapshot.Tools); + + var description = InstructionBuilder.BuildExecuteCodeDescription( + snapshot.Tools, + snapshot.FileMounts, + snapshot.AllowedDomains, + snapshot.WorkspaceRoot); + + 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(result); + } + + internal static bool ComputeApprovalRequired(CodeActApprovalMode mode, IReadOnlyList tools) + { + if (mode == CodeActApprovalMode.AlwaysRequire) + { + return true; + } + + foreach (var tool in tools) + { + if (tool is ApprovalRequiredAIFunction) + { + return true; + } + } + + return false; + } + + private void ThrowIfDisposed() + { + if (this._disposed) + { + throw new ObjectDisposedException(nameof(HyperlightCodeActProvider)); + } + } + + /// Releases the underlying sandbox and associated native resources. + public void Dispose() + { + lock (this._gate) + { + if (this._disposed) + { + return; + } + + this._disposed = true; + } + + this._executor.Dispose(); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hyperlight/HyperlightCodeActProviderOptions.cs b/dotnet/src/Microsoft.Agents.AI.Hyperlight/HyperlightCodeActProviderOptions.cs new file mode 100644 index 0000000000..e687706f0f --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hyperlight/HyperlightCodeActProviderOptions.cs @@ -0,0 +1,67 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using HyperlightSandbox.Api; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Hyperlight; + +/// +/// Configuration options for and +/// . +/// +public sealed class HyperlightCodeActProviderOptions +{ + /// + /// Gets or sets the Hyperlight sandbox backend to use. + /// Defaults to . + /// + public SandboxBackend Backend { get; set; } = SandboxBackend.Wasm; + + /// + /// Gets or sets the path to the guest module (.wasm or .aot file). + /// Required for the backend; not needed for + /// . + /// + public string? ModulePath { get; set; } + + /// + /// Gets or sets the guest heap size. Accepts human-readable strings such as + /// "50Mi" or "2Gi". When the backend default is used. + /// + public string? HeapSize { get; set; } + + /// + /// Gets or sets the guest stack size. Accepts human-readable strings such as + /// "35Mi". When the backend default is used. + /// + public string? StackSize { get; set; } + + /// + /// Gets or sets the initial set of provider-owned CodeAct tools made available + /// inside the sandbox via call_tool(...). + /// + public IEnumerable? Tools { get; set; } + + /// + /// Gets or sets the default approval mode for execute_code. + /// Defaults to . + /// + public CodeActApprovalMode ApprovalMode { get; set; } = CodeActApprovalMode.NeverRequire; + + /// + /// Gets or sets an optional workspace root directory on the host. + /// When set, it is exposed as the sandbox's /input directory. + /// + public string? WorkspaceRoot { get; set; } + + /// + /// Gets or sets the initial set of file mount configurations. + /// + public IEnumerable? FileMounts { get; set; } + + /// + /// Gets or sets the initial outbound network allow-list entries. + /// + public IEnumerable? AllowedDomains { get; set; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hyperlight/HyperlightExecuteCodeFunction.cs b/dotnet/src/Microsoft.Agents.AI.Hyperlight/HyperlightExecuteCodeFunction.cs new file mode 100644 index 0000000000..52d1e0c595 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hyperlight/HyperlightExecuteCodeFunction.cs @@ -0,0 +1,109 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Agents.AI.Hyperlight.Internal; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Hyperlight; + +/// +/// Standalone execute_code backed by a +/// Hyperlight sandbox. Use this for manual/static wiring when an +/// lifecycle is not needed — for example +/// when the tool registry and capability configuration are fixed for the +/// lifetime of the agent. +/// +/// +/// Unlike , this type does not hook +/// into the pipeline. It captures a single +/// snapshot of the provided +/// at construction time and reuses it for the lifetime of the instance. +/// +public sealed class HyperlightExecuteCodeFunction : IDisposable +{ + private readonly SandboxExecutor _executor; + private readonly SandboxExecutor.RunSnapshot _snapshot; + private readonly AIFunction _function; + private bool _disposed; + + /// + /// Initializes a new instance of the class. + /// + /// + /// Optional configuration options. When the defaults of + /// are used. + /// + 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) ?? []).ToArray(); + var fileMounts = (effective.FileMounts?.Where(m => m is not null) ?? []).ToArray(); + var allowedDomains = (effective.AllowedDomains?.Where(d => d is not null) ?? []).ToArray(); + + this._snapshot = new SandboxExecutor.RunSnapshot(tools, fileMounts, allowedDomains, effective.WorkspaceRoot); + + var description = InstructionBuilder.BuildExecuteCodeDescription( + this._snapshot.Tools, + this._snapshot.FileMounts, + this._snapshot.AllowedDomains, + this._snapshot.WorkspaceRoot); + + AIFunction function = new ExecuteCodeFunction(this._executor, this._snapshot, description); + if (HyperlightCodeActProvider.ComputeApprovalRequired(effective.ApprovalMode, this._snapshot.Tools)) + { + function = new ApprovalRequiredAIFunction(function); + } + + this._function = function; + } + + /// + /// Returns the execute_code function for direct registration on an agent. + /// When approval is required the returned function is wrapped in + /// . + /// + public AIFunction AsAIFunction() + { + this.ThrowIfDisposed(); + return this._function; + } + + /// + /// Builds a CodeAct instruction string describing the available tools and capabilities. + /// + /// + /// When , the instructions assume tools are only accessible + /// through CodeAct (via call_tool). When , the instructions + /// are abbreviated for cases where the same tools are already visible to the model as + /// direct agent tools. + /// + public string BuildInstructions(bool toolsVisibleToModel = false) + { + this.ThrowIfDisposed(); + return InstructionBuilder.BuildContextInstructions(toolsVisibleToModel); + } + + private void ThrowIfDisposed() + { + if (this._disposed) + { + throw new ObjectDisposedException(nameof(HyperlightExecuteCodeFunction)); + } + } + + /// Releases the underlying sandbox and associated native resources. + public void Dispose() + { + if (this._disposed) + { + return; + } + + this._disposed = true; + this._executor.Dispose(); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/ExecuteCodeFunction.cs b/dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/ExecuteCodeFunction.cs new file mode 100644 index 0000000000..35ec49d24a --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/ExecuteCodeFunction.cs @@ -0,0 +1,85 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Hyperlight.Internal; + +/// +/// Run-scoped that exposes execute_code +/// to the model. The function closes over an immutable +/// captured at the start of the +/// agent invocation, so subsequent CRUD mutations on the provider do not +/// affect an in-flight run. +/// +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; + } + + /// + public override string Name => ExecuteCodeName; + + /// + public override string Description => this._description; + + /// + public override JsonElement JsonSchema => s_schema; + + /// + protected override async ValueTask 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); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/InstructionBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/InstructionBuilder.cs new file mode 100644 index 0000000000..2b0da745d9 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/InstructionBuilder.cs @@ -0,0 +1,121 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Hyperlight.Internal; + +/// +/// Builds the CodeAct guidance strings returned through +/// and the execute_code +/// function description. +/// +internal static class InstructionBuilder +{ + /// + /// Builds the short CodeAct guidance block that is merged into the + /// agent's instructions for the current invocation. + /// + public static string BuildContextInstructions(bool toolsVisibleToModel) + { + if (toolsVisibleToModel) + { + return + "You can execute Python 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 Python 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(\"\", ...)` — they cannot be invoked directly. " + + "State does not persist between calls; pass any required values in the code you execute."; + } + + /// + /// Builds the detailed description attached to the run-scoped + /// execute_code . This includes the + /// available call_tool signatures and a capability summary. + /// + public static string BuildExecuteCodeDescription( + IReadOnlyList tools, + IReadOnlyList fileMounts, + IReadOnlyList allowedDomains, + string? workspaceRoot) + { + 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(\"\", **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 (!string.IsNullOrEmpty(workspaceRoot) || fileMounts.Count > 0) + { + sb.AppendLine(); + sb.AppendLine("Filesystem access:"); + if (!string.IsNullOrEmpty(workspaceRoot)) + { + sb.AppendLine( + string.Format( + CultureInfo.InvariantCulture, + "- Workspace directory mounted read-only at `/input` (host: `{0}`).", + workspaceRoot)); + } + + foreach (var mount in fileMounts) + { + sb.AppendLine( + string.Format( + CultureInfo.InvariantCulture, + "- `{0}` (host: `{1}`)", + mount.MountPath, + mount.HostPath)); + } + } + + 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(); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/SandboxExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/SandboxExecutor.cs new file mode 100644 index 0000000000..ca3da2e750 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/SandboxExecutor.cs @@ -0,0 +1,182 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using HyperlightSandbox.Api; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Hyperlight.Internal; + +/// +/// Captures a per-run snapshot of the provider state and owns the +/// lifecycle of the underlying . A single +/// is shared across runs and serializes +/// execution via snapshot/restore. +/// +internal sealed class SandboxExecutor : IDisposable +{ + private readonly HyperlightCodeActProviderOptions _options; + private readonly SemaphoreSlim _executionLock = new(1, 1); + + private Sandbox? _sandbox; + private SandboxSnapshot? _warmSnapshot; + private bool _disposed; + + /// + /// Snapshot of tools captured at the start of a run. This is exposed + /// through so concurrent runs observe a + /// stable view of the provider registry. + /// + public SandboxExecutor(HyperlightCodeActProviderOptions options) + { + this._options = options; + } + + /// + /// Immutable snapshot of provider state at the start of a run. + /// Used to build a run-scoped execute_code function that is + /// independent of subsequent CRUD mutations. + /// + internal sealed record RunSnapshot( + IReadOnlyList Tools, + IReadOnlyList FileMounts, + IReadOnlyList AllowedDomains, + string? WorkspaceRoot); + + /// + /// Executes inside the sandbox using the + /// captured . On first invocation the + /// sandbox is lazily initialized and a clean "warm" snapshot is + /// captured for subsequent restores. + /// + public async Task 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) + { + return; + } + + 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 workspaceRoot = snapshot.WorkspaceRoot; + if (!string.IsNullOrEmpty(workspaceRoot)) + { + builder = builder.WithInputDir(workspaceRoot!); + } + + // 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(workspaceRoot)) + { + 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. + _ = sandbox.Run(this._options.Backend == SandboxBackend.JavaScript ? "undefined" : "None"); + this._warmSnapshot = sandbox.Snapshot(); + this._sandbox = sandbox; + } + + private static string BuildResult(ExecutionResult result) + { + var payload = new Dictionary(StringComparer.Ordinal) + { + ["stdout"] = result.Stdout ?? string.Empty, + ["stderr"] = result.Stderr ?? string.Empty, + ["exit_code"] = result.ExitCode, + ["success"] = result.ExitCode == 0, + }; + + return JsonSerializer.Serialize(payload); + } + + private static string BuildErrorResult(string message) + { + var payload = new Dictionary(StringComparer.Ordinal) + { + ["stdout"] = string.Empty, + ["stderr"] = message, + ["exit_code"] = -1, + ["success"] = false, + }; + + return JsonSerializer.Serialize(payload); + } + + public void Dispose() + { + if (this._disposed) + { + return; + } + + this._disposed = true; + this._warmSnapshot?.Dispose(); + this._sandbox?.Dispose(); + this._executionLock.Dispose(); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/ToolBridge.cs b/dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/ToolBridge.cs new file mode 100644 index 0000000000..3b6edcbc7c --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/ToolBridge.cs @@ -0,0 +1,112 @@ +// 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; + +/// +/// Bridges an to the +/// +/// overload so the guest can invoke .NET tools via call_tool(...). +/// +internal static class ToolBridge +{ + /// + /// Registers every entry against the provided + /// as a raw JSON-in / JSON-out async tool. + /// + public static void RegisterAll(Sandbox sandbox, IReadOnlyList tools) + { + foreach (var tool in tools) + { + RegisterOne(sandbox, tool); + } + } + + private static void RegisterOne(Sandbox sandbox, AIFunction tool) + { + var unwrapped = Unwrap(tool); + sandbox.RegisterToolAsync( + unwrapped.Name, + async (string argsJson) => await InvokeAsync(unwrapped, argsJson).ConfigureAwait(false)); + } + + internal static async Task 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 { error = ex.Message }); + } + } + + internal static IDictionary ParseArguments(string argsJson) + { + if (string.IsNullOrWhiteSpace(argsJson)) + { + return new Dictionary(StringComparer.Ordinal); + } + + 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(StringComparer.Ordinal); + foreach (var kvp in obj) + { + result[kvp.Key] = kvp.Value; + } + + return result; + } + + private static string SerializeResult(object? result) + { + switch (result) + { + case null: + return "null"; + case string s: + return JsonSerializer.Serialize(s); + case JsonElement element: + return element.GetRawText(); + case JsonNode node: + return node.ToJsonString(); + default: + return JsonSerializer.Serialize(result); + } + } + + /// + /// Returns the underlying removing any + /// wrapping. The guest calls + /// tools directly and approval is enforced at the execute_code + /// layer. + /// + internal static AIFunction Unwrap(AIFunction tool) + { + while (tool is ApprovalRequiredAIFunction wrapper) + { + tool = wrapper.InnerFunction; + } + + return tool; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hyperlight/Microsoft.Agents.AI.Hyperlight.csproj b/dotnet/src/Microsoft.Agents.AI.Hyperlight/Microsoft.Agents.AI.Hyperlight.csproj new file mode 100644 index 0000000000..cbc471c250 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hyperlight/Microsoft.Agents.AI.Hyperlight.csproj @@ -0,0 +1,37 @@ + + + + preview + net10.0;net9.0;net8.0 + + + + true + + + + + + false + + + + + + + + + + + + + + Microsoft Agent Framework - Hyperlight CodeAct integration + Provides Hyperlight-backed CodeAct (sandboxed code execution) integration for Microsoft Agent Framework. + + + + + + + diff --git a/dotnet/src/Microsoft.Agents.AI.Hyperlight/README.md b/dotnet/src/Microsoft.Agents.AI.Hyperlight/README.md new file mode 100644 index 0000000000..22e739830f --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hyperlight/README.md @@ -0,0 +1,39 @@ +# Microsoft.Agents.AI.Hyperlight + +First-class [CodeAct](../../../docs/decisions/0024-hyperlight-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` wrapper 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 + +* A published `HyperlightSandbox.Api` NuGet package (`0.1.0-preview` per + [hyperlight-sandbox PR #46](https://github.com/hyperlight-dev/hyperlight-sandbox/pull/46)). + Until this package is available on nuget.org the project restores will + fail; the package is intentionally `IsPackable=false` in this state. +* A Hyperlight Python guest module when using `SandboxBackend.Wasm`. + +## Status + +Preview. API may change until the underlying Hyperlight SDK reaches a stable +release. diff --git a/dotnet/tests/Microsoft.Agents.AI.Hyperlight.IntegrationTests/CodeActEndToEndTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hyperlight.IntegrationTests/CodeActEndToEndTests.cs new file mode 100644 index 0000000000..ceb69dcd72 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hyperlight.IntegrationTests/CodeActEndToEndTests.cs @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Text.Json; +using System.Threading.Tasks; +using HyperlightSandbox.Api; +using Microsoft.Extensions.AI; +using Moq; + +namespace Microsoft.Agents.AI.Hyperlight.IntegrationTests; + +/// +/// Integration tests that exercise a real Hyperlight sandbox. Gated by the +/// HYPERLIGHT_PYTHON_GUEST_PATH environment variable: when not set these +/// tests are skipped. +/// +public sealed class CodeActEndToEndTests +{ + private static readonly AIAgent s_mockAgent = new Mock().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(new HyperlightCodeActProviderOptions + { + Backend = SandboxBackend.Wasm, + ModulePath = GuestPath, + }); + + var context = await provider.InvokingAsync( + new AIContextProvider.InvokingContext(s_mockAgent, session: null, new AIContext())).ConfigureAwait(false); + + var executeCode = Assert.IsAssignableFrom(context.Tools![0]); + + // Act + var rawResult = await executeCode.InvokeAsync( + new AIFunctionArguments(new System.Collections.Generic.Dictionary + { + ["code"] = "print(\"hi\")", + })).ConfigureAwait(false); + + // 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()); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hyperlight.IntegrationTests/Microsoft.Agents.AI.Hyperlight.IntegrationTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Hyperlight.IntegrationTests/Microsoft.Agents.AI.Hyperlight.IntegrationTests.csproj new file mode 100644 index 0000000000..81c8a1171e --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hyperlight.IntegrationTests/Microsoft.Agents.AI.Hyperlight.IntegrationTests.csproj @@ -0,0 +1,7 @@ + + + + + + + diff --git a/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/ApprovalComputationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/ApprovalComputationTests.cs new file mode 100644 index 0000000000..809ead3d15 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/ApprovalComputationTests.cs @@ -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])); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/HyperlightCodeActProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/HyperlightCodeActProviderTests.cs new file mode 100644 index 0000000000..64e4622007 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/HyperlightCodeActProviderTests.cs @@ -0,0 +1,174 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +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", new[] { "GET" }); + var d2 = new AllowedDomain("https://a", new[] { "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(() => provider.AddTools(tool)); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/InstructionBuilderTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/InstructionBuilderTests.cs new file mode 100644 index 0000000000..eec4ea6c23 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/InstructionBuilderTests.cs @@ -0,0 +1,103 @@ +// 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); + } + + [Fact] + public void BuildContextInstructions_VisibleTools_OmitsCallTool() + { + // Act + var text = InstructionBuilder.BuildContextInstructions(toolsVisibleToModel: true); + + // Assert + Assert.Contains("execute_code", text); + Assert.DoesNotContain("call_tool", text); + } + + [Fact] + public void BuildExecuteCodeDescription_WithNoExtras_ReturnsBaseBlurbOnly() + { + // Act + var text = InstructionBuilder.BuildExecuteCodeDescription( + tools: [], + fileMounts: [], + allowedDomains: [], + workspaceRoot: null); + + // 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: [], + workspaceRoot: null); + + // Assert + Assert.Contains("call_tool", text); + Assert.Contains("fetch_docs", text); + Assert.Contains("fetch docs", text); + } + + [Fact] + public void BuildExecuteCodeDescription_WithFilesystem_IncludesFilesystemSection() + { + // Act + var text = InstructionBuilder.BuildExecuteCodeDescription( + tools: [], + fileMounts: [new FileMount("/host/data.csv", "/input/data.csv")], + allowedDomains: [], + workspaceRoot: "/host/workspace"); + + // Assert + Assert.Contains("Filesystem access", text); + Assert.Contains("/input", text); + Assert.Contains("/host/workspace", text); + Assert.Contains("/input/data.csv", text); + Assert.Contains("/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 { "GET", "POST" })], + workspaceRoot: null); + + // Assert + Assert.Contains("Outbound network access", text); + Assert.Contains("api.github.com", text); + Assert.Contains("GET", text); + Assert.Contains("POST", text); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/Microsoft.Agents.AI.Hyperlight.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/Microsoft.Agents.AI.Hyperlight.UnitTests.csproj new file mode 100644 index 0000000000..8ac2a540fc --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/Microsoft.Agents.AI.Hyperlight.UnitTests.csproj @@ -0,0 +1,12 @@ + + + + false + + + + + + + + diff --git a/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/ProvideAIContextTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/ProvideAIContextTests.cs new file mode 100644 index 0000000000..791fa28416 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/ProvideAIContextTests.cs @@ -0,0 +1,83 @@ +// Copyright (c) Microsoft. All rights reserved. + +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().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()).ConfigureAwait(false); + + // Assert + Assert.NotNull(context); + Assert.NotNull(context!.Tools); + Assert.Single(context.Tools!); + var function = Assert.IsAssignableFrom(context.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()).ConfigureAwait(false); + + // Assert + _ = Assert.IsType(context!.Tools![0]); + } + + [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[] { (AIFunction)new ApprovalRequiredAIFunction(inner) }, + }); + + // Act + var context = await provider.InvokingAsync(NewInvokingContext()).ConfigureAwait(false); + + // Assert + _ = Assert.IsType(context!.Tools![0]); + } + + [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()).ConfigureAwait(false); + provider.AddTools(AIFunctionFactory.Create(() => "two", name: "second_tool")); + + // Assert — the returned execute_code description must reflect the first snapshot only. + var function = Assert.IsAssignableFrom(context!.Tools![0]); + Assert.Contains("first_tool", function.Description); + Assert.DoesNotContain("second_tool", function.Description); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/ToolBridgeTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/ToolBridgeTests.cs new file mode 100644 index 0000000000..ae4f328c07 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/ToolBridgeTests.cs @@ -0,0 +1,98 @@ +// 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 _)); + } + + [Fact] + public void Unwrap_ReturnsInnerWhenWrappedInApprovalRequired() + { + // Arrange + var inner = AIFunctionFactory.Create(() => "ok", name: "inner"); + var wrapped = new ApprovalRequiredAIFunction(inner); + + // Act + var actual = ToolBridge.Unwrap(wrapped); + + // Assert + Assert.Same(inner, actual); + } + + [Fact] + public void Unwrap_ReturnsSameInstanceWhenNotWrapped() + { + // Arrange + var tool = AIFunctionFactory.Create(() => "ok", name: "t"); + + // Act + var actual = ToolBridge.Unwrap(tool); + + // Assert + Assert.Same(tool, actual); + } +} From 87320abda18e0f850fed9a44f801aa301febe1df Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Mon, 20 Apr 2026 14:40:34 +0200 Subject: [PATCH 2/6] 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> --- ...AgentWithCodeAct_Step01_Interpreter.csproj | 1 - .../Program.cs | 7 +- .../README.md | 12 ++ ...AgentWithCodeAct_Step02_ToolEnabled.csproj | 1 - .../Program.cs | 11 +- .../README.md | 8 ++ ...gentWithCodeAct_Step03_ManualWiring.csproj | 1 - .../Program.cs | 11 +- .../AllowedDomain.cs | 30 +++-- .../FileMount.cs | 29 +++-- .../HyperlightCodeActProvider.cs | 105 ++++------------- .../HyperlightCodeActProviderOptions.cs | 52 +++++++-- .../HyperlightExecuteCodeFunction.cs | 11 +- .../Internal/ExecuteCodeFunction.cs | 2 - .../Internal/InstructionBuilder.cs | 32 +++--- .../Internal/SandboxExecutor.cs | 106 +++++++++++++++--- .../Internal/ToolBridge.cs | 46 ++------ .../Microsoft.Agents.AI.Hyperlight.csproj | 1 - .../CodeActEndToEndTests.cs | 8 +- ...ents.AI.Hyperlight.IntegrationTests.csproj | 4 + .../InstructionBuilderTests.cs | 19 ++-- ...soft.Agents.AI.Hyperlight.UnitTests.csproj | 4 + .../SandboxExecutorTests.cs | 84 ++++++++++++++ .../ToolBridgeTests.cs | 27 ----- 24 files changed, 359 insertions(+), 253 deletions(-) create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/SandboxExecutorTests.cs diff --git a/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step01_Interpreter/AgentWithCodeAct_Step01_Interpreter.csproj b/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step01_Interpreter/AgentWithCodeAct_Step01_Interpreter.csproj index aba3436aef..c635b62a14 100644 --- a/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step01_Interpreter/AgentWithCodeAct_Step01_Interpreter.csproj +++ b/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step01_Interpreter/AgentWithCodeAct_Step01_Interpreter.csproj @@ -12,7 +12,6 @@ - diff --git a/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step01_Interpreter/Program.cs b/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step01_Interpreter/Program.cs index f442aeed0f..c210325a9b 100644 --- a/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step01_Interpreter/Program.cs +++ b/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step01_Interpreter/Program.cs @@ -6,7 +6,6 @@ using Azure.AI.OpenAI; using Azure.Identity; -using HyperlightSandbox.Api; using Microsoft.Agents.AI; using Microsoft.Agents.AI.Hyperlight; @@ -14,11 +13,7 @@ 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(new HyperlightCodeActProviderOptions -{ - Backend = SandboxBackend.Wasm, - ModulePath = guestPath, -}); +using var codeAct = new HyperlightCodeActProvider(HyperlightCodeActProviderOptions.CreateForWasm(guestPath)); AIAgent agent = new AzureOpenAIClient( new Uri(endpoint), diff --git a/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step01_Interpreter/README.md b/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step01_Interpreter/README.md index ae319d090d..ed67c388e6 100644 --- a/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step01_Interpreter/README.md +++ b/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step01_Interpreter/README.md @@ -15,6 +15,18 @@ from memory. 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 diff --git a/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step02_ToolEnabled/AgentWithCodeAct_Step02_ToolEnabled.csproj b/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step02_ToolEnabled/AgentWithCodeAct_Step02_ToolEnabled.csproj index aba3436aef..c635b62a14 100644 --- a/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step02_ToolEnabled/AgentWithCodeAct_Step02_ToolEnabled.csproj +++ b/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step02_ToolEnabled/AgentWithCodeAct_Step02_ToolEnabled.csproj @@ -12,7 +12,6 @@ - diff --git a/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step02_ToolEnabled/Program.cs b/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step02_ToolEnabled/Program.cs index 15624094f2..590d040bdb 100644 --- a/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step02_ToolEnabled/Program.cs +++ b/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step02_ToolEnabled/Program.cs @@ -9,7 +9,6 @@ using Azure.AI.OpenAI; using Azure.Identity; -using HyperlightSandbox.Api; using Microsoft.Agents.AI; using Microsoft.Agents.AI.Hyperlight; using Microsoft.Extensions.AI; @@ -34,12 +33,10 @@ name: "send_email", description: "Send an email on behalf of the user.")); -using var codeAct = new HyperlightCodeActProvider(new HyperlightCodeActProviderOptions -{ - Backend = SandboxBackend.Wasm, - ModulePath = guestPath, - Tools = [fetchDocs, queryData, sendEmail], -}); +var options = HyperlightCodeActProviderOptions.CreateForWasm(guestPath); +options.Tools = [fetchDocs, queryData, sendEmail]; + +using var codeAct = new HyperlightCodeActProvider(options); AIAgent agent = new AzureOpenAIClient( new Uri(endpoint), diff --git a/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step02_ToolEnabled/README.md b/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step02_ToolEnabled/README.md index 451f811a84..e60e1caddb 100644 --- a/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step02_ToolEnabled/README.md +++ b/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step02_ToolEnabled/README.md @@ -24,3 +24,11 @@ is configured. 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. diff --git a/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step03_ManualWiring/AgentWithCodeAct_Step03_ManualWiring.csproj b/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step03_ManualWiring/AgentWithCodeAct_Step03_ManualWiring.csproj index aba3436aef..c635b62a14 100644 --- a/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step03_ManualWiring/AgentWithCodeAct_Step03_ManualWiring.csproj +++ b/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step03_ManualWiring/AgentWithCodeAct_Step03_ManualWiring.csproj @@ -12,7 +12,6 @@ - diff --git a/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step03_ManualWiring/Program.cs b/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step03_ManualWiring/Program.cs index f474ff6c17..afa868d248 100644 --- a/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step03_ManualWiring/Program.cs +++ b/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step03_ManualWiring/Program.cs @@ -7,7 +7,6 @@ using Azure.AI.OpenAI; using Azure.Identity; -using HyperlightSandbox.Api; using Microsoft.Agents.AI; using Microsoft.Agents.AI.Hyperlight; using Microsoft.Extensions.AI; @@ -21,12 +20,10 @@ name: "multiply", description: "Multiply two numbers."); -using var executeCode = new HyperlightExecuteCodeFunction(new HyperlightCodeActProviderOptions -{ - Backend = SandboxBackend.Wasm, - ModulePath = guestPath, - Tools = [calculate], -}); +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 " diff --git a/dotnet/src/Microsoft.Agents.AI.Hyperlight/AllowedDomain.cs b/dotnet/src/Microsoft.Agents.AI.Hyperlight/AllowedDomain.cs index 6d940711a1..3bff100e5b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hyperlight/AllowedDomain.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hyperlight/AllowedDomain.cs @@ -8,11 +8,25 @@ namespace Microsoft.Agents.AI.Hyperlight; /// Represents a single entry in the outbound network allow-list applied to the /// Hyperlight sandbox. /// -/// -/// URL or domain to allow, for example "https://api.github.com". -/// -/// -/// Optional list of HTTP methods to allow (for example ["GET", "POST"]). -/// When , all methods supported by the backend are allowed. -/// -public sealed record AllowedDomain(string Target, IReadOnlyList? Methods = null); +public sealed class AllowedDomain +{ + /// + /// Initializes a new instance of the class. + /// + /// URL or domain to allow, for example "https://api.github.com". + /// + /// Optional list of HTTP methods to allow (for example ["GET", "POST"]). + /// When , all methods supported by the backend are allowed. + /// + public AllowedDomain(string target, IReadOnlyList? methods = null) + { + this.Target = target; + this.Methods = methods; + } + + /// Gets the URL or domain to allow. + public string Target { get; } + + /// Gets the optional list of HTTP methods to allow. + public IReadOnlyList? Methods { get; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hyperlight/FileMount.cs b/dotnet/src/Microsoft.Agents.AI.Hyperlight/FileMount.cs index aa52e013e2..4fce8420e9 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hyperlight/FileMount.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hyperlight/FileMount.cs @@ -6,11 +6,24 @@ namespace Microsoft.Agents.AI.Hyperlight; /// Represents a host-to-sandbox file mount configuration used by /// . /// -/// -/// Absolute or relative path on the host filesystem to mount into the sandbox. -/// -/// -/// Path inside the sandbox the host path is exposed at (for example -/// "/input/data.csv"). -/// -public sealed record FileMount(string HostPath, string MountPath); +public sealed class FileMount +{ + /// + /// Initializes a new instance of the class. + /// + /// Absolute or relative path on the host filesystem to mount into the sandbox. + /// + /// Path inside the sandbox the host path is exposed at (for example "/input/data.csv"). + /// + public FileMount(string hostPath, string mountPath) + { + this.HostPath = hostPath; + this.MountPath = mountPath; + } + + /// Gets the path on the host filesystem that is mounted into the sandbox. + public string HostPath { get; } + + /// Gets the path inside the sandbox at which the host path is exposed. + public string MountPath { get; } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Hyperlight/HyperlightCodeActProvider.cs b/dotnet/src/Microsoft.Agents.AI.Hyperlight/HyperlightCodeActProvider.cs index 89a38d2ed6..b979c47015 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hyperlight/HyperlightCodeActProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hyperlight/HyperlightCodeActProvider.cs @@ -66,8 +66,10 @@ public sealed class HyperlightCodeActProvider : AIContextProvider, IDisposable /// /// /// Optional configuration options for the provider. When the provider - /// uses the defaults of — equivalent to a - /// backend with no module, tools, mounts, or allow-list entries. + /// uses the defaults of (the + /// backend with no tools, mounts, or allow-list entries). + /// Use to target a Wasm + /// guest module instead. /// public HyperlightCodeActProvider(HyperlightCodeActProviderOptions? options = null) { @@ -76,39 +78,24 @@ public HyperlightCodeActProvider(HyperlightCodeActProviderOptions? options = nul if (this._options.Tools is not null) { - foreach (var tool in this._options.Tools) + foreach (var tool in this._options.Tools.Where(t => t is not null)) { - if (tool is null) - { - continue; - } - this._tools[tool.Name] = tool; } } if (this._options.FileMounts is not null) { - foreach (var mount in this._options.FileMounts) + foreach (var mount in this._options.FileMounts.Where(m => m is not null)) { - if (mount is null) - { - continue; - } - this._fileMounts[mount.MountPath] = mount; } } if (this._options.AllowedDomains is not null) { - foreach (var domain in this._options.AllowedDomains) + foreach (var domain in this._options.AllowedDomains.Where(d => d is not null)) { - if (domain is null) - { - continue; - } - this._allowedDomains[domain.Target] = domain; } } @@ -129,13 +116,8 @@ public void AddTools(params AIFunction[] tools) lock (this._gate) { this.ThrowIfDisposed(); - foreach (var tool in tools) + foreach (var tool in tools.Where(t => t is not null)) { - if (tool is null) - { - continue; - } - this._tools[tool.Name] = tool; } } @@ -146,7 +128,7 @@ public IReadOnlyList GetTools() { lock (this._gate) { - return this._tools.Values.ToArray(); + return this._tools.Values.ToList(); } } @@ -157,13 +139,8 @@ public void RemoveTools(params string[] names) _ = Throw.IfNull(names); lock (this._gate) { - foreach (var name in names) + foreach (var name in names.Where(n => n is not null)) { - if (name is null) - { - continue; - } - _ = this._tools.Remove(name); } } @@ -189,13 +166,8 @@ public void AddFileMounts(params FileMount[] mounts) _ = Throw.IfNull(mounts); lock (this._gate) { - foreach (var mount in mounts) + foreach (var mount in mounts.Where(m => m is not null)) { - if (mount is null) - { - continue; - } - this._fileMounts[mount.MountPath] = mount; } } @@ -206,7 +178,7 @@ public IReadOnlyList GetFileMounts() { lock (this._gate) { - return this._fileMounts.Values.ToArray(); + return this._fileMounts.Values.ToList(); } } @@ -217,13 +189,8 @@ public void RemoveFileMounts(params string[] mountPaths) _ = Throw.IfNull(mountPaths); lock (this._gate) { - foreach (var path in mountPaths) + foreach (var path in mountPaths.Where(p => p is not null)) { - if (path is null) - { - continue; - } - _ = this._fileMounts.Remove(path); } } @@ -249,13 +216,8 @@ public void AddAllowedDomains(params AllowedDomain[] domains) _ = Throw.IfNull(domains); lock (this._gate) { - foreach (var domain in domains) + foreach (var domain in domains.Where(d => d is not null)) { - if (domain is null) - { - continue; - } - this._allowedDomains[domain.Target] = domain; } } @@ -266,7 +228,7 @@ public IReadOnlyList GetAllowedDomains() { lock (this._gate) { - return this._allowedDomains.Values.ToArray(); + return this._allowedDomains.Values.ToList(); } } @@ -277,13 +239,8 @@ public void RemoveAllowedDomains(params string[] targets) _ = Throw.IfNull(targets); lock (this._gate) { - foreach (var target in targets) + foreach (var target in targets.Where(t => t is not null)) { - if (target is null) - { - continue; - } - _ = this._allowedDomains.Remove(target); } } @@ -312,10 +269,10 @@ protected override ValueTask ProvideAIContextAsync(InvokingContext co { this.ThrowIfDisposed(); snapshot = new SandboxExecutor.RunSnapshot( - this._tools.Values.ToArray(), - this._fileMounts.Values.ToArray(), - this._allowedDomains.Values.ToArray(), - this._options.WorkspaceRoot); + this._tools.Values.ToList(), + this._fileMounts.Values.ToList(), + this._allowedDomains.Values.ToList(), + this._options.HostInputDirectory); } var approvalRequired = ComputeApprovalRequired(this._options.ApprovalMode, snapshot.Tools); @@ -324,7 +281,7 @@ protected override ValueTask ProvideAIContextAsync(InvokingContext co snapshot.Tools, snapshot.FileMounts, snapshot.AllowedDomains, - snapshot.WorkspaceRoot); + hasHostInputDirectory: !string.IsNullOrEmpty(snapshot.HostInputDirectory)); AIFunction executeCode = new ExecuteCodeFunction(this._executor, snapshot, description); if (approvalRequired) @@ -343,23 +300,9 @@ protected override ValueTask ProvideAIContextAsync(InvokingContext co return new ValueTask(result); } - internal static bool ComputeApprovalRequired(CodeActApprovalMode mode, IReadOnlyList tools) - { - if (mode == CodeActApprovalMode.AlwaysRequire) - { - return true; - } - - foreach (var tool in tools) - { - if (tool is ApprovalRequiredAIFunction) - { - return true; - } - } - - return false; - } + internal static bool ComputeApprovalRequired(CodeActApprovalMode mode, IReadOnlyList tools) => + mode == CodeActApprovalMode.AlwaysRequire + || tools.Any(t => t is ApprovalRequiredAIFunction); private void ThrowIfDisposed() { diff --git a/dotnet/src/Microsoft.Agents.AI.Hyperlight/HyperlightCodeActProviderOptions.cs b/dotnet/src/Microsoft.Agents.AI.Hyperlight/HyperlightCodeActProviderOptions.cs index e687706f0f..4d1f407ded 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hyperlight/HyperlightCodeActProviderOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hyperlight/HyperlightCodeActProviderOptions.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using HyperlightSandbox.Api; using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI.Hyperlight; @@ -10,20 +11,51 @@ namespace Microsoft.Agents.AI.Hyperlight; /// Configuration options for and /// . /// +/// +/// Use the and +/// factory methods to construct an instance with the desired sandbox backend. +/// The parameterless constructor is equivalent to . +/// public sealed class HyperlightCodeActProviderOptions { /// - /// Gets or sets the Hyperlight sandbox backend to use. - /// Defaults to . + /// Initializes a new instance configured for the JavaScript backend. + /// Equivalent to . /// - public SandboxBackend Backend { get; set; } = SandboxBackend.Wasm; + public HyperlightCodeActProviderOptions() + : this(SandboxBackend.JavaScript, modulePath: null) + { + } + + private HyperlightCodeActProviderOptions(SandboxBackend backend, string? modulePath) + { + this.Backend = backend; + this.ModulePath = modulePath; + } + + /// + /// Creates options targeting the backend. + /// + /// Path to the guest module (.wasm or .aot file). + public static HyperlightCodeActProviderOptions CreateForWasm(string modulePath) + => new(SandboxBackend.Wasm, Throw.IfNullOrWhitespace(modulePath)); + + /// + /// Creates options targeting the backend. + /// + public static HyperlightCodeActProviderOptions CreateForJavaScript() + => new(SandboxBackend.JavaScript, modulePath: null); + + /// + /// Gets the Hyperlight sandbox backend this options instance is configured for. + /// + public SandboxBackend Backend { get; } /// - /// Gets or sets the path to the guest module (.wasm or .aot file). - /// Required for the backend; not needed for - /// . + /// Gets the path to the guest module. Set when the options were created via + /// ; otherwise. /// - public string? ModulePath { get; set; } + public string? ModulePath { get; } /// /// Gets or sets the guest heap size. Accepts human-readable strings such as @@ -50,10 +82,10 @@ public sealed class HyperlightCodeActProviderOptions public CodeActApprovalMode ApprovalMode { get; set; } = CodeActApprovalMode.NeverRequire; /// - /// Gets or sets an optional workspace root directory on the host. - /// When set, it is exposed as the sandbox's /input directory. + /// Gets or sets an optional host directory exposed to the sandbox as its + /// /input directory. /// - public string? WorkspaceRoot { get; set; } + public string? HostInputDirectory { get; set; } /// /// Gets or sets the initial set of file mount configurations. diff --git a/dotnet/src/Microsoft.Agents.AI.Hyperlight/HyperlightExecuteCodeFunction.cs b/dotnet/src/Microsoft.Agents.AI.Hyperlight/HyperlightExecuteCodeFunction.cs index 52d1e0c595..9fa8c16054 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hyperlight/HyperlightExecuteCodeFunction.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hyperlight/HyperlightExecuteCodeFunction.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. using System; -using System.Collections.Generic; using System.Linq; using Microsoft.Agents.AI.Hyperlight.Internal; using Microsoft.Extensions.AI; @@ -40,17 +39,17 @@ public HyperlightExecuteCodeFunction(HyperlightCodeActProviderOptions? options = var effective = options ?? new HyperlightCodeActProviderOptions(); this._executor = new SandboxExecutor(effective); - var tools = (effective.Tools?.Where(t => t is not null) ?? []).ToArray(); - var fileMounts = (effective.FileMounts?.Where(m => m is not null) ?? []).ToArray(); - var allowedDomains = (effective.AllowedDomains?.Where(d => d is not null) ?? []).ToArray(); + 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.WorkspaceRoot); + this._snapshot = new SandboxExecutor.RunSnapshot(tools, fileMounts, allowedDomains, effective.HostInputDirectory); var description = InstructionBuilder.BuildExecuteCodeDescription( this._snapshot.Tools, this._snapshot.FileMounts, this._snapshot.AllowedDomains, - this._snapshot.WorkspaceRoot); + hasHostInputDirectory: !string.IsNullOrEmpty(this._snapshot.HostInputDirectory)); AIFunction function = new ExecuteCodeFunction(this._executor, this._snapshot, description); if (HyperlightCodeActProvider.ComputeApprovalRequired(effective.ApprovalMode, this._snapshot.Tools)) diff --git a/dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/ExecuteCodeFunction.cs b/dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/ExecuteCodeFunction.cs index 35ec49d24a..1e41f80784 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/ExecuteCodeFunction.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/ExecuteCodeFunction.cs @@ -1,8 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. using System; -using System.Collections.Generic; -using System.ComponentModel; using System.Text.Json; using System.Threading; using System.Threading.Tasks; diff --git a/dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/InstructionBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/InstructionBuilder.cs index 2b0da745d9..77086e304e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/InstructionBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/InstructionBuilder.cs @@ -1,8 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. using System.Collections.Generic; -using System.Globalization; -using System.Linq; using System.Text; using Microsoft.Extensions.AI; @@ -24,13 +22,13 @@ public static string BuildContextInstructions(bool toolsVisibleToModel) if (toolsVisibleToModel) { return - "You can execute Python code in a secure sandbox by calling the `execute_code` tool. " + "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 Python code in a secure sandbox by calling the `execute_code` tool. " + "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(\"\", ...)` — they cannot be invoked directly. " + "State does not persist between calls; pass any required values in the code you execute."; @@ -41,11 +39,16 @@ public static string BuildContextInstructions(bool toolsVisibleToModel) /// execute_code . This includes the /// available call_tool signatures and a capability summary. /// + /// + /// Host-side filesystem paths are intentionally omitted from the + /// description — only sandbox-visible mount paths are exposed to the + /// model. + /// public static string BuildExecuteCodeDescription( IReadOnlyList tools, IReadOnlyList fileMounts, IReadOnlyList allowedDomains, - string? workspaceRoot) + bool hasHostInputDirectory) { var sb = new StringBuilder(); sb.Append("Executes code in a secure Hyperlight sandbox. "); @@ -72,27 +75,20 @@ public static string BuildExecuteCodeDescription( } } - if (!string.IsNullOrEmpty(workspaceRoot) || fileMounts.Count > 0) + if (hasHostInputDirectory || fileMounts.Count > 0) { sb.AppendLine(); sb.AppendLine("Filesystem access:"); - if (!string.IsNullOrEmpty(workspaceRoot)) + if (hasHostInputDirectory) { - sb.AppendLine( - string.Format( - CultureInfo.InvariantCulture, - "- Workspace directory mounted read-only at `/input` (host: `{0}`).", - workspaceRoot)); + sb.AppendLine("- Host input directory mounted read-only at `/input`."); } foreach (var mount in fileMounts) { - sb.AppendLine( - string.Format( - CultureInfo.InvariantCulture, - "- `{0}` (host: `{1}`)", - mount.MountPath, - mount.HostPath)); + sb.Append("- `"); + sb.Append(mount.MountPath); + sb.AppendLine("`"); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/SandboxExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/SandboxExecutor.cs index ca3da2e750..cf3871e370 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/SandboxExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/SandboxExecutor.cs @@ -2,6 +2,8 @@ using System; using System.Collections.Generic; +using System.Linq; +using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -23,13 +25,9 @@ internal sealed class SandboxExecutor : IDisposable private Sandbox? _sandbox; private SandboxSnapshot? _warmSnapshot; + private string? _lastConfigFingerprint; private bool _disposed; - /// - /// Snapshot of tools captured at the start of a run. This is exposed - /// through so concurrent runs observe a - /// stable view of the provider registry. - /// public SandboxExecutor(HyperlightCodeActProviderOptions options) { this._options = options; @@ -40,17 +38,76 @@ public SandboxExecutor(HyperlightCodeActProviderOptions options) /// Used to build a run-scoped execute_code function that is /// independent of subsequent CRUD mutations. /// - internal sealed record RunSnapshot( - IReadOnlyList Tools, - IReadOnlyList FileMounts, - IReadOnlyList AllowedDomains, - string? WorkspaceRoot); + internal sealed class RunSnapshot + { + public RunSnapshot( + IReadOnlyList tools, + IReadOnlyList fileMounts, + IReadOnlyList allowedDomains, + string? hostInputDirectory) + { + this.Tools = tools; + this.FileMounts = fileMounts; + this.AllowedDomains = allowedDomains; + this.HostInputDirectory = hostInputDirectory; + this.ConfigFingerprint = ComputeFingerprint(tools, fileMounts, allowedDomains, hostInputDirectory); + } + + public IReadOnlyList Tools { get; } + + public IReadOnlyList FileMounts { get; } + + public IReadOnlyList AllowedDomains { get; } + + public string? HostInputDirectory { get; } + + /// + /// Stable fingerprint of the configuration that materially affects how + /// the sandbox must be built. Used by to + /// decide whether a previously-built sandbox can be reused or must be + /// rebuilt because tools / mounts / allow-list entries have changed. + /// + public string ConfigFingerprint { get; } + + internal static string ComputeFingerprint( + IReadOnlyList tools, + IReadOnlyList fileMounts, + IReadOnlyList 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(); + } + } /// /// Executes inside the sandbox using the - /// captured . On first invocation the - /// sandbox is lazily initialized and a clean "warm" snapshot is - /// captured for subsequent restores. + /// captured . Builds (or rebuilds) the + /// sandbox lazily when the snapshot's configuration fingerprint + /// differs from the previously-used one. /// public async Task ExecuteAsync(RunSnapshot snapshot, string code, CancellationToken cancellationToken) { @@ -86,11 +143,23 @@ public async Task ExecuteAsync(RunSnapshot snapshot, string code, Cancel private void EnsureInitialized(RunSnapshot snapshot) { - if (this._sandbox is not null) + 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); @@ -109,17 +178,17 @@ private void EnsureInitialized(RunSnapshot snapshot) builder = builder.WithStackSize(this._options.StackSize!); } - var workspaceRoot = snapshot.WorkspaceRoot; - if (!string.IsNullOrEmpty(workspaceRoot)) + var hostInput = snapshot.HostInputDirectory; + if (!string.IsNullOrEmpty(hostInput)) { - builder = builder.WithInputDir(workspaceRoot!); + 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(workspaceRoot)) + if (snapshot.FileMounts.Count > 0 || !string.IsNullOrEmpty(hostInput)) { builder = builder.WithTempOutput(); } @@ -139,6 +208,7 @@ private void EnsureInitialized(RunSnapshot snapshot) _ = sandbox.Run(this._options.Backend == SandboxBackend.JavaScript ? "undefined" : "None"); this._warmSnapshot = sandbox.Snapshot(); this._sandbox = sandbox; + this._lastConfigFingerprint = snapshot.ConfigFingerprint; } private static string BuildResult(ExecutionResult result) diff --git a/dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/ToolBridge.cs b/dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/ToolBridge.cs index 3b6edcbc7c..345839efc6 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/ToolBridge.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/ToolBridge.cs @@ -30,12 +30,9 @@ public static void RegisterAll(Sandbox sandbox, IReadOnlyList tools) } private static void RegisterOne(Sandbox sandbox, AIFunction tool) - { - var unwrapped = Unwrap(tool); - sandbox.RegisterToolAsync( - unwrapped.Name, - async (string argsJson) => await InvokeAsync(unwrapped, argsJson).ConfigureAwait(false)); - } + => sandbox.RegisterToolAsync( + tool.Name, + async (string argsJson) => await InvokeAsync(tool, argsJson).ConfigureAwait(false)); internal static async Task InvokeAsync(AIFunction tool, string argsJson) { @@ -60,6 +57,9 @@ internal static async Task InvokeAsync(AIFunction tool, string argsJson) return new Dictionary(StringComparer.Ordinal); } + // Use JsonNode.Parse instead of JsonSerializer.Deserialize> + // 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) { @@ -77,36 +77,6 @@ internal static async Task InvokeAsync(AIFunction tool, string argsJson) return result; } - private static string SerializeResult(object? result) - { - switch (result) - { - case null: - return "null"; - case string s: - return JsonSerializer.Serialize(s); - case JsonElement element: - return element.GetRawText(); - case JsonNode node: - return node.ToJsonString(); - default: - return JsonSerializer.Serialize(result); - } - } - - /// - /// Returns the underlying removing any - /// wrapping. The guest calls - /// tools directly and approval is enforced at the execute_code - /// layer. - /// - internal static AIFunction Unwrap(AIFunction tool) - { - while (tool is ApprovalRequiredAIFunction wrapper) - { - tool = wrapper.InnerFunction; - } - - return tool; - } + private static string SerializeResult(object? result) => + result is null ? "null" : JsonSerializer.Serialize(result); } diff --git a/dotnet/src/Microsoft.Agents.AI.Hyperlight/Microsoft.Agents.AI.Hyperlight.csproj b/dotnet/src/Microsoft.Agents.AI.Hyperlight/Microsoft.Agents.AI.Hyperlight.csproj index cbc471c250..15cf24fe84 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hyperlight/Microsoft.Agents.AI.Hyperlight.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Hyperlight/Microsoft.Agents.AI.Hyperlight.csproj @@ -16,7 +16,6 @@ - diff --git a/dotnet/tests/Microsoft.Agents.AI.Hyperlight.IntegrationTests/CodeActEndToEndTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hyperlight.IntegrationTests/CodeActEndToEndTests.cs index ceb69dcd72..1cb3fdb9a7 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hyperlight.IntegrationTests/CodeActEndToEndTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hyperlight.IntegrationTests/CodeActEndToEndTests.cs @@ -3,7 +3,6 @@ using System; using System.Text.Json; using System.Threading.Tasks; -using HyperlightSandbox.Api; using Microsoft.Extensions.AI; using Moq; @@ -33,11 +32,8 @@ public async Task ExecuteCode_PythonPrint_ReturnsStdoutAsync() } // Arrange - using var provider = new HyperlightCodeActProvider(new HyperlightCodeActProviderOptions - { - Backend = SandboxBackend.Wasm, - ModulePath = GuestPath, - }); + using var provider = new HyperlightCodeActProvider( + HyperlightCodeActProviderOptions.CreateForWasm(GuestPath!)); var context = await provider.InvokingAsync( new AIContextProvider.InvokingContext(s_mockAgent, session: null, new AIContext())).ConfigureAwait(false); diff --git a/dotnet/tests/Microsoft.Agents.AI.Hyperlight.IntegrationTests/Microsoft.Agents.AI.Hyperlight.IntegrationTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Hyperlight.IntegrationTests/Microsoft.Agents.AI.Hyperlight.IntegrationTests.csproj index 81c8a1171e..45a3af1719 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hyperlight.IntegrationTests/Microsoft.Agents.AI.Hyperlight.IntegrationTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.Hyperlight.IntegrationTests/Microsoft.Agents.AI.Hyperlight.IntegrationTests.csproj @@ -1,5 +1,9 @@ + + $(TargetFrameworksCore) + + diff --git a/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/InstructionBuilderTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/InstructionBuilderTests.cs index eec4ea6c23..0539f24542 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/InstructionBuilderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/InstructionBuilderTests.cs @@ -17,6 +17,8 @@ public void BuildContextInstructions_HiddenTools_MentionsCallTool() // Assert Assert.Contains("execute_code", text); Assert.Contains("call_tool", text); + // Backend-agnostic: don't mention a specific language. + Assert.DoesNotContain("Python", text); } [Fact] @@ -28,6 +30,7 @@ public void BuildContextInstructions_VisibleTools_OmitsCallTool() // Assert Assert.Contains("execute_code", text); Assert.DoesNotContain("call_tool", text); + Assert.DoesNotContain("Python", text); } [Fact] @@ -38,7 +41,7 @@ public void BuildExecuteCodeDescription_WithNoExtras_ReturnsBaseBlurbOnly() tools: [], fileMounts: [], allowedDomains: [], - workspaceRoot: null); + hasHostInputDirectory: false); // Assert Assert.Contains("Executes code", text); @@ -58,7 +61,7 @@ public void BuildExecuteCodeDescription_WithTools_IncludesToolNames() tools: [tool], fileMounts: [], allowedDomains: [], - workspaceRoot: null); + hasHostInputDirectory: false); // Assert Assert.Contains("call_tool", text); @@ -67,21 +70,23 @@ public void BuildExecuteCodeDescription_WithTools_IncludesToolNames() } [Fact] - public void BuildExecuteCodeDescription_WithFilesystem_IncludesFilesystemSection() + public void BuildExecuteCodeDescription_WithFilesystem_IncludesSandboxPathsOnly() { // Act var text = InstructionBuilder.BuildExecuteCodeDescription( tools: [], fileMounts: [new FileMount("/host/data.csv", "/input/data.csv")], allowedDomains: [], - workspaceRoot: "/host/workspace"); + hasHostInputDirectory: true); // Assert Assert.Contains("Filesystem access", text); Assert.Contains("/input", text); - Assert.Contains("/host/workspace", text); Assert.Contains("/input/data.csv", text); - Assert.Contains("/host/data.csv", text); + + // Host paths must not leak to the model. + Assert.DoesNotContain("/host/workspace", text); + Assert.DoesNotContain("/host/data.csv", text); } [Fact] @@ -92,7 +97,7 @@ public void BuildExecuteCodeDescription_WithAllowedDomains_IncludesNetworkSectio tools: [], fileMounts: [], allowedDomains: [new AllowedDomain("https://api.github.com", new List { "GET", "POST" })], - workspaceRoot: null); + hasHostInputDirectory: false); // Assert Assert.Contains("Outbound network access", text); diff --git a/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/Microsoft.Agents.AI.Hyperlight.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/Microsoft.Agents.AI.Hyperlight.UnitTests.csproj index 8ac2a540fc..0e31b6ac17 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/Microsoft.Agents.AI.Hyperlight.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/Microsoft.Agents.AI.Hyperlight.UnitTests.csproj @@ -1,5 +1,9 @@ + + $(TargetFrameworksCore) + + false diff --git a/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/SandboxExecutorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/SandboxExecutorTests.cs new file mode 100644 index 0000000000..3d264fc0b8 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/SandboxExecutorTests.cs @@ -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); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/ToolBridgeTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/ToolBridgeTests.cs index ae4f328c07..22fcdf89a4 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/ToolBridgeTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/ToolBridgeTests.cs @@ -68,31 +68,4 @@ public async Task InvokeAsync_NonObjectJson_ReturnsErrorAsync() using var doc = JsonDocument.Parse(result); Assert.True(doc.RootElement.TryGetProperty("error", out _)); } - - [Fact] - public void Unwrap_ReturnsInnerWhenWrappedInApprovalRequired() - { - // Arrange - var inner = AIFunctionFactory.Create(() => "ok", name: "inner"); - var wrapped = new ApprovalRequiredAIFunction(inner); - - // Act - var actual = ToolBridge.Unwrap(wrapped); - - // Assert - Assert.Same(inner, actual); - } - - [Fact] - public void Unwrap_ReturnsSameInstanceWhenNotWrapped() - { - // Arrange - var tool = AIFunctionFactory.Create(() => "ok", name: "t"); - - // Act - var actual = ToolBridge.Unwrap(tool); - - // Assert - Assert.Same(tool, actual); - } } From a5b7448ab5c29c5e53fac70c9c1336abc7518be2 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Fri, 1 May 2026 09:47:05 +0200 Subject: [PATCH 3/6] Align Hyperlight package id and JS warm-up with merged upstream SDK MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- dotnet/Directory.Packages.props | 2 +- .../Internal/SandboxExecutor.cs | 5 ++++- .../Microsoft.Agents.AI.Hyperlight.csproj | 4 ++-- dotnet/src/Microsoft.Agents.AI.Hyperlight/README.md | 10 ++++++---- 4 files changed, 13 insertions(+), 8 deletions(-) diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index 0a6a8e2f0d..68c7df2155 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -110,7 +110,7 @@ - + diff --git a/dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/SandboxExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/SandboxExecutor.cs index cf3871e370..9bbf623d5e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/SandboxExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/SandboxExecutor.cs @@ -205,7 +205,10 @@ private void BuildAndWarmUp(RunSnapshot snapshot) // Warm-up run to trigger lazy initialization, then capture a clean snapshot // that is restored before every subsequent user invocation. - _ = sandbox.Run(this._options.Backend == SandboxBackend.JavaScript ? "undefined" : "None"); + // 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; diff --git a/dotnet/src/Microsoft.Agents.AI.Hyperlight/Microsoft.Agents.AI.Hyperlight.csproj b/dotnet/src/Microsoft.Agents.AI.Hyperlight/Microsoft.Agents.AI.Hyperlight.csproj index 15cf24fe84..97bedc2235 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hyperlight/Microsoft.Agents.AI.Hyperlight.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Hyperlight/Microsoft.Agents.AI.Hyperlight.csproj @@ -11,12 +11,12 @@ - + false - + diff --git a/dotnet/src/Microsoft.Agents.AI.Hyperlight/README.md b/dotnet/src/Microsoft.Agents.AI.Hyperlight/README.md index 22e739830f..559a90f053 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hyperlight/README.md +++ b/dotnet/src/Microsoft.Agents.AI.Hyperlight/README.md @@ -27,10 +27,12 @@ Both surfaces support: ## Requirements -* A published `HyperlightSandbox.Api` NuGet package (`0.1.0-preview` per - [hyperlight-sandbox PR #46](https://github.com/hyperlight-dev/hyperlight-sandbox/pull/46)). - Until this package is available on nuget.org the project restores will - fail; the package is intentionally `IsPackable=false` in this state. +* 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 From 3f3a03a5124e948d994bf1924d9e241816dcdc8f Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Sun, 3 May 2026 09:47:27 +0200 Subject: [PATCH 4/6] 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, 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> --- dotnet/Directory.Packages.props | 2 +- .../Program.cs | 1 + .../Program.cs | 1 + .../Program.cs | 1 + .../HyperlightCodeActProvider.cs | 10 +---- .../HyperlightExecuteCodeFunction.cs | 8 +--- .../Internal/HyperlightJsonContext.cs | 26 +++++++++++++ .../Internal/SandboxExecutor.cs | 38 +++++++------------ .../Internal/ToolBridge.cs | 18 +++++++-- .../Microsoft.Agents.AI.Hyperlight.csproj | 2 +- .../CodeActEndToEndTests.cs | 7 ++-- .../HyperlightCodeActProviderTests.cs | 4 +- .../ProvideAIContextTests.cs | 22 ++++++----- 13 files changed, 80 insertions(+), 60 deletions(-) create mode 100644 dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/HyperlightJsonContext.cs diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index 68c7df2155..5ed970004f 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -110,7 +110,7 @@ - + diff --git a/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step01_Interpreter/Program.cs b/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step01_Interpreter/Program.cs index c210325a9b..b14143b679 100644 --- a/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step01_Interpreter/Program.cs +++ b/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step01_Interpreter/Program.cs @@ -8,6 +8,7 @@ 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"; diff --git a/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step02_ToolEnabled/Program.cs b/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step02_ToolEnabled/Program.cs index 590d040bdb..90fa3af839 100644 --- a/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step02_ToolEnabled/Program.cs +++ b/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step02_ToolEnabled/Program.cs @@ -12,6 +12,7 @@ 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"; diff --git a/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step03_ManualWiring/Program.cs b/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step03_ManualWiring/Program.cs index afa868d248..6333d686cb 100644 --- a/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step03_ManualWiring/Program.cs +++ b/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step03_ManualWiring/Program.cs @@ -10,6 +10,7 @@ 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"; diff --git a/dotnet/src/Microsoft.Agents.AI.Hyperlight/HyperlightCodeActProvider.cs b/dotnet/src/Microsoft.Agents.AI.Hyperlight/HyperlightCodeActProvider.cs index b979c47015..a679c304a6 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hyperlight/HyperlightCodeActProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hyperlight/HyperlightCodeActProvider.cs @@ -67,7 +67,7 @@ public sealed class HyperlightCodeActProvider : AIContextProvider, IDisposable /// /// Optional configuration options for the provider. When the provider /// uses the defaults of (the - /// backend with no tools, mounts, or allow-list entries). + /// backend with no tools, mounts, or allow-list entries). /// Use to target a Wasm /// guest module instead. /// @@ -304,13 +304,7 @@ internal static bool ComputeApprovalRequired(CodeActApprovalMode mode, IReadOnly mode == CodeActApprovalMode.AlwaysRequire || tools.Any(t => t is ApprovalRequiredAIFunction); - private void ThrowIfDisposed() - { - if (this._disposed) - { - throw new ObjectDisposedException(nameof(HyperlightCodeActProvider)); - } - } + private void ThrowIfDisposed() => ObjectDisposedException.ThrowIf(this._disposed, this); /// Releases the underlying sandbox and associated native resources. public void Dispose() diff --git a/dotnet/src/Microsoft.Agents.AI.Hyperlight/HyperlightExecuteCodeFunction.cs b/dotnet/src/Microsoft.Agents.AI.Hyperlight/HyperlightExecuteCodeFunction.cs index 9fa8c16054..0ae528d808 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hyperlight/HyperlightExecuteCodeFunction.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hyperlight/HyperlightExecuteCodeFunction.cs @@ -86,13 +86,7 @@ public string BuildInstructions(bool toolsVisibleToModel = false) return InstructionBuilder.BuildContextInstructions(toolsVisibleToModel); } - private void ThrowIfDisposed() - { - if (this._disposed) - { - throw new ObjectDisposedException(nameof(HyperlightExecuteCodeFunction)); - } - } + private void ThrowIfDisposed() => ObjectDisposedException.ThrowIf(this._disposed, this); /// Releases the underlying sandbox and associated native resources. public void Dispose() diff --git a/dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/HyperlightJsonContext.cs b/dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/HyperlightJsonContext.cs new file mode 100644 index 0000000000..362789352b --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/HyperlightJsonContext.cs @@ -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; + +/// +/// 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. +/// +[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); diff --git a/dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/SandboxExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/SandboxExecutor.cs index 9bbf623d5e..ff07407c81 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/SandboxExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/SandboxExecutor.cs @@ -214,31 +214,19 @@ private void BuildAndWarmUp(RunSnapshot snapshot) this._lastConfigFingerprint = snapshot.ConfigFingerprint; } - private static string BuildResult(ExecutionResult result) - { - var payload = new Dictionary(StringComparer.Ordinal) - { - ["stdout"] = result.Stdout ?? string.Empty, - ["stderr"] = result.Stderr ?? string.Empty, - ["exit_code"] = result.ExitCode, - ["success"] = result.ExitCode == 0, - }; - - return JsonSerializer.Serialize(payload); - } - - private static string BuildErrorResult(string message) - { - var payload = new Dictionary(StringComparer.Ordinal) - { - ["stdout"] = string.Empty, - ["stderr"] = message, - ["exit_code"] = -1, - ["success"] = false, - }; - - return JsonSerializer.Serialize(payload); - } + 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() { diff --git a/dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/ToolBridge.cs b/dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/ToolBridge.cs index 345839efc6..788aaa4480 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/ToolBridge.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/ToolBridge.cs @@ -46,7 +46,7 @@ internal static async Task InvokeAsync(AIFunction tool, string argsJson) catch (Exception ex) #pragma warning restore CA1031 { - return JsonSerializer.Serialize(new { error = ex.Message }); + return JsonSerializer.Serialize(new HyperlightToolError(ex.Message), HyperlightJsonContext.Default.HyperlightToolError); } } @@ -77,6 +77,18 @@ internal static async Task InvokeAsync(AIFunction tool, string argsJson) return result; } - private static string SerializeResult(object? result) => - result is null ? "null" : JsonSerializer.Serialize(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); + } } diff --git a/dotnet/src/Microsoft.Agents.AI.Hyperlight/Microsoft.Agents.AI.Hyperlight.csproj b/dotnet/src/Microsoft.Agents.AI.Hyperlight/Microsoft.Agents.AI.Hyperlight.csproj index 97bedc2235..65a964ecf8 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hyperlight/Microsoft.Agents.AI.Hyperlight.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Hyperlight/Microsoft.Agents.AI.Hyperlight.csproj @@ -11,7 +11,7 @@ - + false diff --git a/dotnet/tests/Microsoft.Agents.AI.Hyperlight.IntegrationTests/CodeActEndToEndTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hyperlight.IntegrationTests/CodeActEndToEndTests.cs index 1cb3fdb9a7..0ed9325b6a 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hyperlight.IntegrationTests/CodeActEndToEndTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hyperlight.IntegrationTests/CodeActEndToEndTests.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Linq; using System.Text.Json; using System.Threading.Tasks; using Microsoft.Extensions.AI; @@ -36,16 +37,16 @@ public async Task ExecuteCode_PythonPrint_ReturnsStdoutAsync() HyperlightCodeActProviderOptions.CreateForWasm(GuestPath!)); var context = await provider.InvokingAsync( - new AIContextProvider.InvokingContext(s_mockAgent, session: null, new AIContext())).ConfigureAwait(false); + new AIContextProvider.InvokingContext(s_mockAgent, session: null, new AIContext())); - var executeCode = Assert.IsAssignableFrom(context.Tools![0]); + var executeCode = Assert.IsAssignableFrom(context.Tools!.First()); // Act var rawResult = await executeCode.InvokeAsync( new AIFunctionArguments(new System.Collections.Generic.Dictionary { ["code"] = "print(\"hi\")", - })).ConfigureAwait(false); + })); // Assert var json = rawResult?.ToString(); diff --git a/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/HyperlightCodeActProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/HyperlightCodeActProviderTests.cs index 64e4622007..37dabfc039 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/HyperlightCodeActProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/HyperlightCodeActProviderTests.cs @@ -109,8 +109,8 @@ public void AllowedDomains_Crud_ReplaceByTarget() { // Arrange using var provider = new HyperlightCodeActProvider(new HyperlightCodeActProviderOptions()); - var d1 = new AllowedDomain("https://a", new[] { "GET" }); - var d2 = new AllowedDomain("https://a", new[] { "POST" }); + var d1 = new AllowedDomain("https://a", ["GET"]); + var d2 = new AllowedDomain("https://a", ["POST"]); var d3 = new AllowedDomain("https://b"); // Act diff --git a/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/ProvideAIContextTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/ProvideAIContextTests.cs index 791fa28416..98766d65be 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/ProvideAIContextTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/ProvideAIContextTests.cs @@ -1,5 +1,6 @@ // Copyright (c) Microsoft. All rights reserved. +using System.Linq; using System.Threading.Tasks; using Microsoft.Extensions.AI; using Moq; @@ -19,13 +20,14 @@ public async Task ProvideAIContextAsync_ReturnsExecuteCodeToolAndInstructionsAsy using var provider = new HyperlightCodeActProvider(new HyperlightCodeActProviderOptions()); // Act - var context = await provider.InvokingAsync(NewInvokingContext()).ConfigureAwait(false); + var context = await provider.InvokingAsync(NewInvokingContext()); // Assert Assert.NotNull(context); Assert.NotNull(context!.Tools); - Assert.Single(context.Tools!); - var function = Assert.IsAssignableFrom(context.Tools![0]); + var tools = context.Tools!.ToList(); + Assert.Single(tools); + var function = Assert.IsAssignableFrom(tools[0]); Assert.Equal("execute_code", function.Name); Assert.False(string.IsNullOrWhiteSpace(context.Instructions)); } @@ -40,10 +42,10 @@ public async Task ProvideAIContextAsync_AlwaysRequire_WrapsInApprovalRequiredAsy }); // Act - var context = await provider.InvokingAsync(NewInvokingContext()).ConfigureAwait(false); + var context = await provider.InvokingAsync(NewInvokingContext()); // Assert - _ = Assert.IsType(context!.Tools![0]); + _ = Assert.IsType(context!.Tools!.First()); } [Fact] @@ -54,14 +56,14 @@ public async Task ProvideAIContextAsync_NeverRequireWithApprovalTool_WrapsInAppr using var provider = new HyperlightCodeActProvider(new HyperlightCodeActProviderOptions { ApprovalMode = CodeActApprovalMode.NeverRequire, - Tools = new[] { (AIFunction)new ApprovalRequiredAIFunction(inner) }, + Tools = [new ApprovalRequiredAIFunction(inner)], }); // Act - var context = await provider.InvokingAsync(NewInvokingContext()).ConfigureAwait(false); + var context = await provider.InvokingAsync(NewInvokingContext()); // Assert - _ = Assert.IsType(context!.Tools![0]); + _ = Assert.IsType(context!.Tools!.First()); } [Fact] @@ -72,11 +74,11 @@ public async Task ProvideAIContextAsync_CapturesSnapshot_MutationsAfterDoNotAffe provider.AddTools(AIFunctionFactory.Create(() => "one", name: "first_tool")); // Act - var context = await provider.InvokingAsync(NewInvokingContext()).ConfigureAwait(false); + 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(context!.Tools![0]); + var function = Assert.IsAssignableFrom(context!.Tools!.First()); Assert.Contains("first_tool", function.Description); Assert.DoesNotContain("second_tool", function.Description); } From 8de14591f309c2671490c043cfae4fe39df68462 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Sun, 3 May 2026 10:38:03 +0200 Subject: [PATCH 5/6] 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> --- .../AgentWithCodeAct_Step01_Interpreter.csproj | 2 +- .../AgentWithCodeAct_Step01_Interpreter/Program.cs | 2 +- .../AgentWithCodeAct_Step02_ToolEnabled.csproj | 2 +- .../AgentWithCodeAct_Step02_ToolEnabled/Program.cs | 2 +- .../AgentWithCodeAct_Step03_ManualWiring.csproj | 2 +- .../AgentWithCodeAct_Step03_ManualWiring/Program.cs | 2 +- dotnet/src/Microsoft.Agents.AI.Hyperlight/AllowedDomain.cs | 2 +- .../Microsoft.Agents.AI.Hyperlight/CodeActApprovalMode.cs | 6 ++++-- dotnet/src/Microsoft.Agents.AI.Hyperlight/FileMount.cs | 2 +- .../HyperlightCodeActProvider.cs | 2 +- .../HyperlightCodeActProviderOptions.cs | 2 +- .../HyperlightExecuteCodeFunction.cs | 2 +- .../Internal/ExecuteCodeFunction.cs | 2 +- .../Internal/HyperlightJsonContext.cs | 2 +- .../Internal/InstructionBuilder.cs | 2 +- .../Internal/SandboxExecutor.cs | 2 +- .../Microsoft.Agents.AI.Hyperlight/Internal/ToolBridge.cs | 2 +- .../Microsoft.Agents.AI.Hyperlight.csproj | 2 +- dotnet/src/Microsoft.Agents.AI.Hyperlight/README.md | 4 ++-- .../CodeActEndToEndTests.cs | 2 +- .../Microsoft.Agents.AI.Hyperlight.IntegrationTests.csproj | 2 +- .../ApprovalComputationTests.cs | 2 +- .../HyperlightCodeActProviderTests.cs | 3 +-- .../InstructionBuilderTests.cs | 2 +- .../Microsoft.Agents.AI.Hyperlight.UnitTests.csproj | 2 +- .../ProvideAIContextTests.cs | 2 +- .../SandboxExecutorTests.cs | 2 +- .../ToolBridgeTests.cs | 2 +- 28 files changed, 32 insertions(+), 31 deletions(-) diff --git a/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step01_Interpreter/AgentWithCodeAct_Step01_Interpreter.csproj b/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step01_Interpreter/AgentWithCodeAct_Step01_Interpreter.csproj index c635b62a14..4e37243cce 100644 --- a/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step01_Interpreter/AgentWithCodeAct_Step01_Interpreter.csproj +++ b/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step01_Interpreter/AgentWithCodeAct_Step01_Interpreter.csproj @@ -1,4 +1,4 @@ - + Exe diff --git a/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step01_Interpreter/Program.cs b/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step01_Interpreter/Program.cs index b14143b679..ed3b1315cf 100644 --- a/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step01_Interpreter/Program.cs +++ b/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step01_Interpreter/Program.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// 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 diff --git a/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step02_ToolEnabled/AgentWithCodeAct_Step02_ToolEnabled.csproj b/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step02_ToolEnabled/AgentWithCodeAct_Step02_ToolEnabled.csproj index c635b62a14..4e37243cce 100644 --- a/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step02_ToolEnabled/AgentWithCodeAct_Step02_ToolEnabled.csproj +++ b/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step02_ToolEnabled/AgentWithCodeAct_Step02_ToolEnabled.csproj @@ -1,4 +1,4 @@ - + Exe diff --git a/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step02_ToolEnabled/Program.cs b/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step02_ToolEnabled/Program.cs index 90fa3af839..3ae1faccf2 100644 --- a/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step02_ToolEnabled/Program.cs +++ b/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step02_ToolEnabled/Program.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// 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 diff --git a/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step03_ManualWiring/AgentWithCodeAct_Step03_ManualWiring.csproj b/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step03_ManualWiring/AgentWithCodeAct_Step03_ManualWiring.csproj index c635b62a14..4e37243cce 100644 --- a/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step03_ManualWiring/AgentWithCodeAct_Step03_ManualWiring.csproj +++ b/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step03_ManualWiring/AgentWithCodeAct_Step03_ManualWiring.csproj @@ -1,4 +1,4 @@ - + Exe diff --git a/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step03_ManualWiring/Program.cs b/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step03_ManualWiring/Program.cs index 6333d686cb..20bacac612 100644 --- a/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step03_ManualWiring/Program.cs +++ b/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step03_ManualWiring/Program.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. // This sample shows how to wire up CodeAct manually using // HyperlightExecuteCodeFunction rather than the AIContextProvider. Use this diff --git a/dotnet/src/Microsoft.Agents.AI.Hyperlight/AllowedDomain.cs b/dotnet/src/Microsoft.Agents.AI.Hyperlight/AllowedDomain.cs index 3bff100e5b..8b8b711b12 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hyperlight/AllowedDomain.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hyperlight/AllowedDomain.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System.Collections.Generic; diff --git a/dotnet/src/Microsoft.Agents.AI.Hyperlight/CodeActApprovalMode.cs b/dotnet/src/Microsoft.Agents.AI.Hyperlight/CodeActApprovalMode.cs index fdc91cedc7..05e5f22f11 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hyperlight/CodeActApprovalMode.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hyperlight/CodeActApprovalMode.cs @@ -1,4 +1,6 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Extensions.AI; namespace Microsoft.Agents.AI.Hyperlight; @@ -16,7 +18,7 @@ public enum CodeActApprovalMode /// /// Approval is derived from the provider-owned CodeAct tool registry. /// If any configured tool is an - /// , + /// , /// execute_code also requires approval. Otherwise it does not. /// NeverRequire, diff --git a/dotnet/src/Microsoft.Agents.AI.Hyperlight/FileMount.cs b/dotnet/src/Microsoft.Agents.AI.Hyperlight/FileMount.cs index 4fce8420e9..13ace1f939 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hyperlight/FileMount.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hyperlight/FileMount.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. namespace Microsoft.Agents.AI.Hyperlight; diff --git a/dotnet/src/Microsoft.Agents.AI.Hyperlight/HyperlightCodeActProvider.cs b/dotnet/src/Microsoft.Agents.AI.Hyperlight/HyperlightCodeActProvider.cs index a679c304a6..f8d5d814d3 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hyperlight/HyperlightCodeActProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hyperlight/HyperlightCodeActProvider.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System; using System.Collections.Generic; diff --git a/dotnet/src/Microsoft.Agents.AI.Hyperlight/HyperlightCodeActProviderOptions.cs b/dotnet/src/Microsoft.Agents.AI.Hyperlight/HyperlightCodeActProviderOptions.cs index 4d1f407ded..93e5a09c39 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hyperlight/HyperlightCodeActProviderOptions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hyperlight/HyperlightCodeActProviderOptions.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System.Collections.Generic; using HyperlightSandbox.Api; diff --git a/dotnet/src/Microsoft.Agents.AI.Hyperlight/HyperlightExecuteCodeFunction.cs b/dotnet/src/Microsoft.Agents.AI.Hyperlight/HyperlightExecuteCodeFunction.cs index 0ae528d808..006ae085ac 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hyperlight/HyperlightExecuteCodeFunction.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hyperlight/HyperlightExecuteCodeFunction.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System; using System.Linq; diff --git a/dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/ExecuteCodeFunction.cs b/dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/ExecuteCodeFunction.cs index 1e41f80784..77f479dc10 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/ExecuteCodeFunction.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/ExecuteCodeFunction.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System; using System.Text.Json; diff --git a/dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/HyperlightJsonContext.cs b/dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/HyperlightJsonContext.cs index 362789352b..29b8e2d19f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/HyperlightJsonContext.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/HyperlightJsonContext.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System.Text.Json; using System.Text.Json.Serialization; diff --git a/dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/InstructionBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/InstructionBuilder.cs index 77086e304e..a4c2a43266 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/InstructionBuilder.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/InstructionBuilder.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System.Collections.Generic; using System.Text; diff --git a/dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/SandboxExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/SandboxExecutor.cs index ff07407c81..0a1e3382e7 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/SandboxExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/SandboxExecutor.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System; using System.Collections.Generic; diff --git a/dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/ToolBridge.cs b/dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/ToolBridge.cs index 788aaa4480..b2f735474e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/ToolBridge.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/ToolBridge.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System; using System.Collections.Generic; diff --git a/dotnet/src/Microsoft.Agents.AI.Hyperlight/Microsoft.Agents.AI.Hyperlight.csproj b/dotnet/src/Microsoft.Agents.AI.Hyperlight/Microsoft.Agents.AI.Hyperlight.csproj index 65a964ecf8..bf54d42072 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hyperlight/Microsoft.Agents.AI.Hyperlight.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Hyperlight/Microsoft.Agents.AI.Hyperlight.csproj @@ -1,4 +1,4 @@ - + preview diff --git a/dotnet/src/Microsoft.Agents.AI.Hyperlight/README.md b/dotnet/src/Microsoft.Agents.AI.Hyperlight/README.md index 559a90f053..aca407f67b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hyperlight/README.md +++ b/dotnet/src/Microsoft.Agents.AI.Hyperlight/README.md @@ -1,6 +1,6 @@ -# Microsoft.Agents.AI.Hyperlight +# Microsoft.Agents.AI.Hyperlight -First-class [CodeAct](../../../docs/decisions/0024-hyperlight-codeact-integration.md) +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. diff --git a/dotnet/tests/Microsoft.Agents.AI.Hyperlight.IntegrationTests/CodeActEndToEndTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hyperlight.IntegrationTests/CodeActEndToEndTests.cs index 0ed9325b6a..58b9ebceb6 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hyperlight.IntegrationTests/CodeActEndToEndTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hyperlight.IntegrationTests/CodeActEndToEndTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System; using System.Linq; diff --git a/dotnet/tests/Microsoft.Agents.AI.Hyperlight.IntegrationTests/Microsoft.Agents.AI.Hyperlight.IntegrationTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Hyperlight.IntegrationTests/Microsoft.Agents.AI.Hyperlight.IntegrationTests.csproj index 45a3af1719..b31ca48650 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hyperlight.IntegrationTests/Microsoft.Agents.AI.Hyperlight.IntegrationTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.Hyperlight.IntegrationTests/Microsoft.Agents.AI.Hyperlight.IntegrationTests.csproj @@ -1,4 +1,4 @@ - + $(TargetFrameworksCore) diff --git a/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/ApprovalComputationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/ApprovalComputationTests.cs index 809ead3d15..4ca1caafb4 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/ApprovalComputationTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/ApprovalComputationTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using Microsoft.Extensions.AI; diff --git a/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/HyperlightCodeActProviderTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/HyperlightCodeActProviderTests.cs index 37dabfc039..eb8d941ea5 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/HyperlightCodeActProviderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/HyperlightCodeActProviderTests.cs @@ -1,6 +1,5 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. -using System.Collections.Generic; using System.Linq; using Microsoft.Extensions.AI; diff --git a/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/InstructionBuilderTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/InstructionBuilderTests.cs index 0539f24542..deab2b75e9 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/InstructionBuilderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/InstructionBuilderTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System.Collections.Generic; using Microsoft.Agents.AI.Hyperlight.Internal; diff --git a/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/Microsoft.Agents.AI.Hyperlight.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/Microsoft.Agents.AI.Hyperlight.UnitTests.csproj index 0e31b6ac17..2a614e49ca 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/Microsoft.Agents.AI.Hyperlight.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/Microsoft.Agents.AI.Hyperlight.UnitTests.csproj @@ -1,4 +1,4 @@ - + $(TargetFrameworksCore) diff --git a/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/ProvideAIContextTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/ProvideAIContextTests.cs index 98766d65be..d888d41d17 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/ProvideAIContextTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/ProvideAIContextTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System.Linq; using System.Threading.Tasks; diff --git a/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/SandboxExecutorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/SandboxExecutorTests.cs index 3d264fc0b8..728a977f94 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/SandboxExecutorTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/SandboxExecutorTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using Microsoft.Agents.AI.Hyperlight.Internal; using Microsoft.Extensions.AI; diff --git a/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/ToolBridgeTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/ToolBridgeTests.cs index 22fcdf89a4..9d94d71930 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/ToolBridgeTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/ToolBridgeTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System; using System.Text.Json; From 5270c37f9581c1db247ef8a00873d334aeecbe56 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Tue, 5 May 2026 13:41:42 +0200 Subject: [PATCH 6/6] 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() which lazily exposes a wrapping ApprovalRequiredAIFunction proxy when the effective ApprovalMode/tool stack requires it. - ComputeApprovalRequired now uses GetService() 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> --- .../Program.cs | 2 +- .../HyperlightCodeActProvider.cs | 2 +- .../HyperlightExecuteCodeFunction.cs | 100 ++++++++++++++---- .../Microsoft.Agents.AI.Hyperlight.csproj | 9 +- .../Microsoft.Agents.AI.Hyperlight/README.md | 2 +- 5 files changed, 88 insertions(+), 27 deletions(-) diff --git a/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step03_ManualWiring/Program.cs b/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step03_ManualWiring/Program.cs index 20bacac612..fae83b14fd 100644 --- a/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step03_ManualWiring/Program.cs +++ b/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step03_ManualWiring/Program.cs @@ -35,6 +35,6 @@ new Uri(endpoint), new DefaultAzureCredential()) .GetChatClient(deploymentName) - .AsAIAgent(instructions: instructions, tools: [executeCode.AsAIFunction()]); + .AsAIAgent(instructions: instructions, tools: [executeCode]); Console.WriteLine(await agent.RunAsync("What is 12.3 * 4.5? Use the multiply tool from within `execute_code`.")); diff --git a/dotnet/src/Microsoft.Agents.AI.Hyperlight/HyperlightCodeActProvider.cs b/dotnet/src/Microsoft.Agents.AI.Hyperlight/HyperlightCodeActProvider.cs index f8d5d814d3..3065a0a893 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hyperlight/HyperlightCodeActProvider.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hyperlight/HyperlightCodeActProvider.cs @@ -302,7 +302,7 @@ protected override ValueTask ProvideAIContextAsync(InvokingContext co internal static bool ComputeApprovalRequired(CodeActApprovalMode mode, IReadOnlyList tools) => mode == CodeActApprovalMode.AlwaysRequire - || tools.Any(t => t is ApprovalRequiredAIFunction); + || tools.Any(t => t.GetService() is not null); private void ThrowIfDisposed() => ObjectDisposedException.ThrowIf(this._disposed, this); diff --git a/dotnet/src/Microsoft.Agents.AI.Hyperlight/HyperlightExecuteCodeFunction.cs b/dotnet/src/Microsoft.Agents.AI.Hyperlight/HyperlightExecuteCodeFunction.cs index 006ae085ac..65c457bf78 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hyperlight/HyperlightExecuteCodeFunction.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hyperlight/HyperlightExecuteCodeFunction.cs @@ -2,6 +2,9 @@ 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; @@ -19,12 +22,37 @@ namespace Microsoft.Agents.AI.Hyperlight; /// into the pipeline. It captures a single /// snapshot of the provided /// at construction time and reuses it for the lifetime of the instance. +/// The instance can be passed directly anywhere an +/// is accepted; when the configuration requires approval (per +/// or because a +/// configured tool is itself an ), +/// the instance surfaces an via +/// , which is how the rest of +/// the framework discovers approval requirements. /// -public sealed class HyperlightExecuteCodeFunction : IDisposable +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 AIFunction _function; + private readonly string _description; + private readonly bool _approvalRequired; + private ApprovalRequiredAIFunction? _approvalProxy; private bool _disposed; /// @@ -45,31 +73,23 @@ public HyperlightExecuteCodeFunction(HyperlightCodeActProviderOptions? options = this._snapshot = new SandboxExecutor.RunSnapshot(tools, fileMounts, allowedDomains, effective.HostInputDirectory); - var description = InstructionBuilder.BuildExecuteCodeDescription( + this._description = InstructionBuilder.BuildExecuteCodeDescription( this._snapshot.Tools, this._snapshot.FileMounts, this._snapshot.AllowedDomains, hasHostInputDirectory: !string.IsNullOrEmpty(this._snapshot.HostInputDirectory)); - AIFunction function = new ExecuteCodeFunction(this._executor, this._snapshot, description); - if (HyperlightCodeActProvider.ComputeApprovalRequired(effective.ApprovalMode, this._snapshot.Tools)) - { - function = new ApprovalRequiredAIFunction(function); - } - - this._function = function; + this._approvalRequired = HyperlightCodeActProvider.ComputeApprovalRequired(effective.ApprovalMode, this._snapshot.Tools); } - /// - /// Returns the execute_code function for direct registration on an agent. - /// When approval is required the returned function is wrapped in - /// . - /// - public AIFunction AsAIFunction() - { - this.ThrowIfDisposed(); - return this._function; - } + /// + public override string Name => ExecuteCodeName; + + /// + public override string Description => this._description; + + /// + public override JsonElement JsonSchema => s_schema; /// /// Builds a CodeAct instruction string describing the available tools and capabilities. @@ -86,6 +106,46 @@ public string BuildInstructions(bool toolsVisibleToModel = false) return InstructionBuilder.BuildContextInstructions(toolsVisibleToModel); } + /// + 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); + } + + /// + protected override async ValueTask 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); /// Releases the underlying sandbox and associated native resources. diff --git a/dotnet/src/Microsoft.Agents.AI.Hyperlight/Microsoft.Agents.AI.Hyperlight.csproj b/dotnet/src/Microsoft.Agents.AI.Hyperlight/Microsoft.Agents.AI.Hyperlight.csproj index bf54d42072..a6fd3d9c9e 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hyperlight/Microsoft.Agents.AI.Hyperlight.csproj +++ b/dotnet/src/Microsoft.Agents.AI.Hyperlight/Microsoft.Agents.AI.Hyperlight.csproj @@ -10,10 +10,6 @@ - - - false - @@ -27,8 +23,13 @@ Microsoft Agent Framework - Hyperlight CodeAct integration Provides Hyperlight-backed CodeAct (sandboxed code execution) integration for Microsoft Agent Framework. + README.md + + + + diff --git a/dotnet/src/Microsoft.Agents.AI.Hyperlight/README.md b/dotnet/src/Microsoft.Agents.AI.Hyperlight/README.md index aca407f67b..5f6efdb0f6 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hyperlight/README.md +++ b/dotnet/src/Microsoft.Agents.AI.Hyperlight/README.md @@ -11,7 +11,7 @@ The package exposes two entry points: 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` wrapper for +* **`HyperlightExecuteCodeFunction`** — a standalone `AIFunction` for static/manual wiring when the sandbox configuration is fixed for the agent's lifetime.