diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index 56de97dbcb..5ed970004f 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..4e37243cce --- /dev/null +++ b/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step01_Interpreter/AgentWithCodeAct_Step01_Interpreter.csproj @@ -0,0 +1,22 @@ + + + + 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..ed3b1315cf --- /dev/null +++ b/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step01_Interpreter/Program.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample shows how to use HyperlightCodeActProvider as a sandboxed Python +// code interpreter: the model can write and execute arbitrary Python code to +// answer quantitative questions without calling any additional tools. + +using Azure.AI.OpenAI; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Hyperlight; +using OpenAI.Chat; + +var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); +var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; +var guestPath = Environment.GetEnvironmentVariable("HYPERLIGHT_PYTHON_GUEST_PATH") ?? throw new InvalidOperationException("HYPERLIGHT_PYTHON_GUEST_PATH is not set."); + +using var codeAct = new HyperlightCodeActProvider(HyperlightCodeActProviderOptions.CreateForWasm(guestPath)); + +AIAgent agent = new AzureOpenAIClient( + new Uri(endpoint), + new DefaultAzureCredential()) + .GetChatClient(deploymentName) + .AsAIAgent(new ChatClientAgentOptions() + { + ChatOptions = new() { Instructions = "You are a helpful assistant. When the user asks something quantitative, write Python and call `execute_code` instead of guessing." }, + AIContextProviders = [codeAct], + }); + +Console.WriteLine(await agent.RunAsync("What is the 20th Fibonacci number?")); +Console.WriteLine(await agent.RunAsync("Compute the mean and standard deviation of [1, 4, 9, 16, 25, 36].")); 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..ed67c388e6 --- /dev/null +++ b/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step01_Interpreter/README.md @@ -0,0 +1,35 @@ +# AgentWithCodeAct_Step01_Interpreter + +A minimal CodeAct sample. The agent uses `HyperlightCodeActProvider` as a +sandboxed Python interpreter: when the user asks something quantitative, the +model writes Python and invokes the `execute_code` tool rather than answering +from memory. + +## Configuration + +| Variable | Description | +|--------------------------------|-------------------------------------------------------------------------------------------| +| `AZURE_OPENAI_ENDPOINT` | Azure OpenAI endpoint. Required. | +| `AZURE_OPENAI_DEPLOYMENT_NAME` | Azure OpenAI deployment. Defaults to `gpt-5.4-mini`. | +| `HYPERLIGHT_PYTHON_GUEST_PATH` | Absolute path to the Hyperlight Python guest module (`.wasm` or `.aot` file). Required. | + +Authentication uses `DefaultAzureCredential`. + +## Getting the guest module + +The Python guest module is built from the +[hyperlight-dev/hyperlight-sandbox](https://github.com/hyperlight-dev/hyperlight-sandbox) +repository — see its README for the exact `cargo`/`just` invocations and +the location of the resulting `.wasm` / `.aot` file. Set +`HYPERLIGHT_PYTHON_GUEST_PATH` to the absolute path of that artifact +before running the sample. + +Hyperlight requires a hardware virtualization back end on the host: +KVM on Linux or WHP (Windows Hypervisor Platform) on Windows. + +## Run + +```shell +cd AgentWithCodeAct_Step01_Interpreter +dotnet run +``` 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..4e37243cce --- /dev/null +++ b/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step02_ToolEnabled/AgentWithCodeAct_Step02_ToolEnabled.csproj @@ -0,0 +1,22 @@ + + + + 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..3ae1faccf2 --- /dev/null +++ b/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step02_ToolEnabled/Program.cs @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample shows how to use HyperlightCodeActProvider with provider-owned +// tools (exposed inside the sandbox via `call_tool(...)`). The model can +// orchestrate those tools in a single Python block, reducing round-trips. A +// sensitive tool (`send_email`) is additionally wrapped in +// ApprovalRequiredAIFunction so any code that reaches it requires user approval +// for the entire execute_code invocation. + +using Azure.AI.OpenAI; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Hyperlight; +using Microsoft.Extensions.AI; +using OpenAI.Chat; + +var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); +var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; +var guestPath = Environment.GetEnvironmentVariable("HYPERLIGHT_PYTHON_GUEST_PATH") ?? throw new InvalidOperationException("HYPERLIGHT_PYTHON_GUEST_PATH is not set."); + +AIFunction fetchDocs = AIFunctionFactory.Create( + (string topic) => $"Docs for {topic}: (...)", + name: "fetch_docs", + description: "Fetch documentation for a given topic."); + +AIFunction queryData = AIFunctionFactory.Create( + (string query) => $"Rows for `{query}`: []", + name: "query_data", + description: "Run a read-only SQL-like query against the sample store."); + +AIFunction sendEmail = new ApprovalRequiredAIFunction( + AIFunctionFactory.Create( + (string to, string subject) => $"Sent '{subject}' to {to}.", + name: "send_email", + description: "Send an email on behalf of the user.")); + +var options = HyperlightCodeActProviderOptions.CreateForWasm(guestPath); +options.Tools = [fetchDocs, queryData, sendEmail]; + +using var codeAct = new HyperlightCodeActProvider(options); + +AIAgent agent = new AzureOpenAIClient( + new Uri(endpoint), + new DefaultAzureCredential()) + .GetChatClient(deploymentName) + .AsAIAgent(new ChatClientAgentOptions() + { + ChatOptions = new() { Instructions = "You are a helpful assistant. Prefer orchestrating your work in a single `execute_code` block using `call_tool(...)` over issuing many direct tool calls." }, + AIContextProviders = [codeAct], + }); + +Console.WriteLine(await agent.RunAsync("Look up docs on 'retries' and query the 'orders' table, then summarize.")); 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..e60e1caddb --- /dev/null +++ b/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step02_ToolEnabled/README.md @@ -0,0 +1,34 @@ +# AgentWithCodeAct_Step02_ToolEnabled + +Demonstrates adding provider-owned tools to `HyperlightCodeActProvider`. Those +tools are **only** available to code running inside the sandbox via +`call_tool("", ...)` — they are never exposed to the model as direct +tools. This lets the model orchestrate multiple tool calls in a single Python +block. + +One tool (`send_email`) is wrapped in `ApprovalRequiredAIFunction`, which causes +the entire `execute_code` invocation to require user approval when that tool +is configured. + +## Configuration + +| Variable | Description | +|--------------------------------|-------------------------------------------------------------------------------------------| +| `AZURE_OPENAI_ENDPOINT` | Azure OpenAI endpoint. Required. | +| `AZURE_OPENAI_DEPLOYMENT_NAME` | Azure OpenAI deployment. Defaults to `gpt-5.4-mini`. | +| `HYPERLIGHT_PYTHON_GUEST_PATH` | Absolute path to the Hyperlight Python guest module (`.wasm` or `.aot` file). Required. | + +## Run + +```shell +cd AgentWithCodeAct_Step02_ToolEnabled +dotnet run +``` + +## Planned follow-up + +A more realistic "upload a file (e.g. an Excel workbook), have the agent +analyze it with code" sample is planned as a separate step that will use +`HostInputDirectory` together with a guest tool capable of reading the +uploaded file. It will be added in a follow-up PR once the corresponding +guest module support is in place. 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..4e37243cce --- /dev/null +++ b/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step03_ManualWiring/AgentWithCodeAct_Step03_ManualWiring.csproj @@ -0,0 +1,22 @@ + + + + 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..fae83b14fd --- /dev/null +++ b/dotnet/samples/02-agents/AgentWithCodeAct/AgentWithCodeAct_Step03_ManualWiring/Program.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample shows how to wire up CodeAct manually using +// HyperlightExecuteCodeFunction rather than the AIContextProvider. Use this +// when you want a fixed tool surface for the agent's lifetime and don't need +// the per-run snapshot/registry semantics of HyperlightCodeActProvider. + +using Azure.AI.OpenAI; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Hyperlight; +using Microsoft.Extensions.AI; +using OpenAI.Chat; + +var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set."); +var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini"; +var guestPath = Environment.GetEnvironmentVariable("HYPERLIGHT_PYTHON_GUEST_PATH") ?? throw new InvalidOperationException("HYPERLIGHT_PYTHON_GUEST_PATH is not set."); + +AIFunction calculate = AIFunctionFactory.Create( + (double a, double b) => a * b, + name: "multiply", + description: "Multiply two numbers."); + +var options = HyperlightCodeActProviderOptions.CreateForWasm(guestPath); +options.Tools = [calculate]; + +using var executeCode = new HyperlightExecuteCodeFunction(options); + +var instructions = + "You are a helpful assistant. When math is involved, solve it by writing Python " + + "and calling `execute_code` instead of computing values yourself.\n\n" + + executeCode.BuildInstructions(toolsVisibleToModel: false); + +AIAgent agent = new AzureOpenAIClient( + new Uri(endpoint), + new DefaultAzureCredential()) + .GetChatClient(deploymentName) + .AsAIAgent(instructions: instructions, tools: [executeCode]); + +Console.WriteLine(await agent.RunAsync("What is 12.3 * 4.5? Use the multiply tool from within `execute_code`.")); 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..8b8b711b12 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hyperlight/AllowedDomain.cs @@ -0,0 +1,32 @@ +// 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. +/// +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/CodeActApprovalMode.cs b/dotnet/src/Microsoft.Agents.AI.Hyperlight/CodeActApprovalMode.cs new file mode 100644 index 0000000000..05e5f22f11 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hyperlight/CodeActApprovalMode.cs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Extensions.AI; + +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..13ace1f939 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hyperlight/FileMount.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Microsoft.Agents.AI.Hyperlight; + +/// +/// Represents a host-to-sandbox file mount configuration used by +/// . +/// +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 new file mode 100644 index 0000000000..3065a0a893 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hyperlight/HyperlightCodeActProvider.cs @@ -0,0 +1,324 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Hyperlight.Internal; +using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Hyperlight; + +/// +/// 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 (the + /// backend with no tools, mounts, or allow-list entries). + /// Use to target a Wasm + /// guest module instead. + /// + public HyperlightCodeActProvider(HyperlightCodeActProviderOptions? options = null) + { + this._options = options ?? new HyperlightCodeActProviderOptions(); + this._executor = new SandboxExecutor(this._options); + + if (this._options.Tools is not null) + { + foreach (var tool in this._options.Tools.Where(t => t is not null)) + { + this._tools[tool.Name] = tool; + } + } + + if (this._options.FileMounts is not null) + { + foreach (var mount in this._options.FileMounts.Where(m => m is not null)) + { + this._fileMounts[mount.MountPath] = mount; + } + } + + if (this._options.AllowedDomains is not null) + { + foreach (var domain in this._options.AllowedDomains.Where(d => d is not null)) + { + this._allowedDomains[domain.Target] = domain; + } + } + } + + /// + 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.Where(t => t is not null)) + { + this._tools[tool.Name] = tool; + } + } + } + + /// Returns the current CodeAct-managed tools. + public IReadOnlyList GetTools() + { + lock (this._gate) + { + return this._tools.Values.ToList(); + } + } + + /// 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.Where(n => n is not null)) + { + _ = 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.Where(m => m is not null)) + { + this._fileMounts[mount.MountPath] = mount; + } + } + } + + /// Returns the current file mount configurations. + public IReadOnlyList GetFileMounts() + { + lock (this._gate) + { + return this._fileMounts.Values.ToList(); + } + } + + /// 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.Where(p => p is not null)) + { + _ = 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.Where(d => d is not null)) + { + this._allowedDomains[domain.Target] = domain; + } + } + } + + /// Returns the current outbound allow-list entries. + public IReadOnlyList GetAllowedDomains() + { + lock (this._gate) + { + return this._allowedDomains.Values.ToList(); + } + } + + /// 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.Where(t => t is not null)) + { + _ = 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.ToList(), + this._fileMounts.Values.ToList(), + this._allowedDomains.Values.ToList(), + this._options.HostInputDirectory); + } + + var approvalRequired = ComputeApprovalRequired(this._options.ApprovalMode, snapshot.Tools); + + var description = InstructionBuilder.BuildExecuteCodeDescription( + snapshot.Tools, + snapshot.FileMounts, + snapshot.AllowedDomains, + hasHostInputDirectory: !string.IsNullOrEmpty(snapshot.HostInputDirectory)); + + AIFunction executeCode = new ExecuteCodeFunction(this._executor, snapshot, description); + if (approvalRequired) + { + executeCode = new ApprovalRequiredAIFunction(executeCode); + } + + var instructions = InstructionBuilder.BuildContextInstructions(toolsVisibleToModel: false); + + var result = new AIContext + { + Instructions = instructions, + Tools = [executeCode], + }; + + return new ValueTask(result); + } + + internal static bool ComputeApprovalRequired(CodeActApprovalMode mode, IReadOnlyList tools) => + mode == CodeActApprovalMode.AlwaysRequire + || tools.Any(t => t.GetService() is not null); + + private void ThrowIfDisposed() => ObjectDisposedException.ThrowIf(this._disposed, this); + + /// 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..93e5a09c39 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hyperlight/HyperlightCodeActProviderOptions.cs @@ -0,0 +1,99 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using HyperlightSandbox.Api; +using Microsoft.Extensions.AI; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Agents.AI.Hyperlight; + +/// +/// 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 +{ + /// + /// Initializes a new instance configured for the JavaScript backend. + /// Equivalent to . + /// + 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 the path to the guest module. Set when the options were created via + /// ; otherwise. + /// + public string? ModulePath { get; } + + /// + /// 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 host directory exposed to the sandbox as its + /// /input directory. + /// + public string? HostInputDirectory { 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..65c457bf78 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hyperlight/HyperlightExecuteCodeFunction.cs @@ -0,0 +1,162 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Hyperlight.Internal; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Hyperlight; + +/// +/// 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. +/// 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 : AIFunction, IDisposable +{ + private const string ExecuteCodeName = "execute_code"; + + private static readonly JsonElement s_schema = JsonDocument.Parse( + """ + { + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "Code to execute using the provider's configured backend/runtime behavior." + } + }, + "required": ["code"] + } + """).RootElement; + + private readonly SandboxExecutor _executor; + private readonly SandboxExecutor.RunSnapshot _snapshot; + private readonly string _description; + private readonly bool _approvalRequired; + private ApprovalRequiredAIFunction? _approvalProxy; + private bool _disposed; + + /// + /// 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) ?? []).ToList(); + var fileMounts = (effective.FileMounts?.Where(m => m is not null) ?? []).ToList(); + var allowedDomains = (effective.AllowedDomains?.Where(d => d is not null) ?? []).ToList(); + + this._snapshot = new SandboxExecutor.RunSnapshot(tools, fileMounts, allowedDomains, effective.HostInputDirectory); + + this._description = InstructionBuilder.BuildExecuteCodeDescription( + this._snapshot.Tools, + this._snapshot.FileMounts, + this._snapshot.AllowedDomains, + hasHostInputDirectory: !string.IsNullOrEmpty(this._snapshot.HostInputDirectory)); + + this._approvalRequired = HyperlightCodeActProvider.ComputeApprovalRequired(effective.ApprovalMode, this._snapshot.Tools); + } + + /// + 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. + /// + /// + /// 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); + } + + /// + 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. + 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..77f479dc10 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/ExecuteCodeFunction.cs @@ -0,0 +1,83 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Hyperlight.Internal; + +/// +/// 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/HyperlightJsonContext.cs b/dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/HyperlightJsonContext.cs new file mode 100644 index 0000000000..29b8e2d19f --- /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/InstructionBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/InstructionBuilder.cs new file mode 100644 index 0000000000..a4c2a43266 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/InstructionBuilder.cs @@ -0,0 +1,117 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Text; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Hyperlight.Internal; + +/// +/// 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 code in a secure sandbox by calling the `execute_code` tool. " + + "Use it for calculations, data analysis, and anything that benefits from running code. " + + "State does not persist between calls; pass any required values in the code you execute."; + } + + return + "You can execute code in a secure sandbox by calling the `execute_code` tool. " + + "Any tools listed in the tool's description are only accessible from within the sandbox " + + "via `call_tool(\"\", ...)` — 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. + /// + /// + /// 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, + bool hasHostInputDirectory) + { + var sb = new StringBuilder(); + sb.Append("Executes code in a secure Hyperlight sandbox. "); + sb.Append("Pass the full source to execute via the `code` parameter. "); + sb.Append("Returns a JSON string with `stdout`, `stderr`, `exit_code`, and `success` fields."); + + if (tools.Count > 0) + { + sb.AppendLine(); + sb.AppendLine(); + sb.AppendLine("The following host tools are available inside the sandbox via `call_tool(\"\", **kwargs)`:"); + foreach (var tool in tools) + { + sb.Append("- `"); + sb.Append(tool.Name); + sb.Append('`'); + if (!string.IsNullOrWhiteSpace(tool.Description)) + { + sb.Append(": "); + sb.Append(tool.Description); + } + + sb.AppendLine(); + } + } + + if (hasHostInputDirectory || fileMounts.Count > 0) + { + sb.AppendLine(); + sb.AppendLine("Filesystem access:"); + if (hasHostInputDirectory) + { + sb.AppendLine("- Host input directory mounted read-only at `/input`."); + } + + foreach (var mount in fileMounts) + { + sb.Append("- `"); + sb.Append(mount.MountPath); + sb.AppendLine("`"); + } + } + + if (allowedDomains.Count > 0) + { + sb.AppendLine(); + sb.AppendLine("Outbound network access is restricted to the following targets:"); + foreach (var domain in allowedDomains) + { + sb.Append("- `"); + sb.Append(domain.Target); + sb.Append('`'); + if (domain.Methods is { Count: > 0 }) + { + sb.Append(" ["); + sb.Append(string.Join(", ", domain.Methods)); + sb.Append(']'); + } + + sb.AppendLine(); + } + } + + return sb.ToString().TrimEnd(); + } +} 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..0a1e3382e7 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/SandboxExecutor.cs @@ -0,0 +1,243 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using HyperlightSandbox.Api; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Hyperlight.Internal; + +/// +/// 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 string? _lastConfigFingerprint; + private bool _disposed; + + 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 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 . 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) + { + await this._executionLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + this.EnsureInitialized(snapshot); + + if (this._warmSnapshot is not null) + { + this._sandbox!.Restore(this._warmSnapshot); + } + + ExecutionResult result; + try + { + result = this._sandbox!.Run(code); + } +#pragma warning disable CA1031 // Surface sandbox execution failures as structured JSON rather than propagating. + catch (Exception ex) +#pragma warning restore CA1031 + { + return BuildErrorResult(ex.Message); + } + + return BuildResult(result); + } + finally + { + this._executionLock.Release(); + } + } + + private void EnsureInitialized(RunSnapshot snapshot) + { + if (this._sandbox is not null && string.Equals(this._lastConfigFingerprint, snapshot.ConfigFingerprint, StringComparison.Ordinal)) + { + return; + } + + // Configuration changed (or first run) — dispose the previous sandbox + // so the new one picks up the new tool/mount/allow-list set. + this._warmSnapshot?.Dispose(); + this._sandbox?.Dispose(); + this._warmSnapshot = null; + this._sandbox = null; + + this.BuildAndWarmUp(snapshot); + } + + private void BuildAndWarmUp(RunSnapshot snapshot) + { + var builder = new SandboxBuilder() + .WithBackend(this._options.Backend); + + if (!string.IsNullOrEmpty(this._options.ModulePath)) + { + builder = builder.WithModulePath(this._options.ModulePath!); + } + + if (!string.IsNullOrEmpty(this._options.HeapSize)) + { + builder = builder.WithHeapSize(this._options.HeapSize!); + } + + if (!string.IsNullOrEmpty(this._options.StackSize)) + { + builder = builder.WithStackSize(this._options.StackSize!); + } + + var hostInput = snapshot.HostInputDirectory; + if (!string.IsNullOrEmpty(hostInput)) + { + builder = builder.WithInputDir(hostInput!); + } + + // The Hyperlight .NET SDK currently exposes only a single input + output + temp-output + // surface; per-mount configuration (`FileMount`) is captured in the execute_code + // description so the model is aware of the layout, and will be wired to a richer + // mount API once the SDK exposes one. + if (snapshot.FileMounts.Count > 0 || !string.IsNullOrEmpty(hostInput)) + { + builder = builder.WithTempOutput(); + } + + var sandbox = builder.Build(); + + // Tools must be registered before the first Run() call. + ToolBridge.RegisterAll(sandbox, snapshot.Tools); + + foreach (var allowedDomain in snapshot.AllowedDomains) + { + sandbox.AllowDomain(allowedDomain.Target, allowedDomain.Methods); + } + + // Warm-up run to trigger lazy initialization, then capture a clean snapshot + // that is restored before every subsequent user invocation. + // Backend-specific no-op used to trigger lazy guest runtime initialization + // before the warm snapshot is captured. Matches the values used by the + // upstream HyperlightSandbox.Extensions.AI CodeExecutionTool reference. + _ = sandbox.Run(this._options.Backend == SandboxBackend.JavaScript ? "void 0;" : "None"); + this._warmSnapshot = sandbox.Snapshot(); + this._sandbox = sandbox; + this._lastConfigFingerprint = snapshot.ConfigFingerprint; + } + + private static string BuildResult(ExecutionResult result) => + JsonSerializer.Serialize( + new HyperlightExecutionResult( + result.Stdout ?? string.Empty, + result.Stderr ?? string.Empty, + result.ExitCode, + result.ExitCode == 0), + HyperlightJsonContext.Default.HyperlightExecutionResult); + + private static string BuildErrorResult(string message) => + JsonSerializer.Serialize( + new HyperlightExecutionResult(string.Empty, message, -1, false), + HyperlightJsonContext.Default.HyperlightExecutionResult); + + public void Dispose() + { + if (this._disposed) + { + return; + } + + this._disposed = true; + this._warmSnapshot?.Dispose(); + this._sandbox?.Dispose(); + this._executionLock.Dispose(); + } +} 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..b2f735474e --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hyperlight/Internal/ToolBridge.cs @@ -0,0 +1,94 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading.Tasks; +using HyperlightSandbox.Api; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Hyperlight.Internal; + +/// +/// 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) + => sandbox.RegisterToolAsync( + tool.Name, + async (string argsJson) => await InvokeAsync(tool, 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 HyperlightToolError(ex.Message), HyperlightJsonContext.Default.HyperlightToolError); + } + } + + internal static IDictionary ParseArguments(string argsJson) + { + if (string.IsNullOrWhiteSpace(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) + { + 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) + { + 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 new file mode 100644 index 0000000000..a6fd3d9c9e --- /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 + + + + + + + + + + + + + + + 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 new file mode 100644 index 0000000000..5f6efdb0f6 --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Hyperlight/README.md @@ -0,0 +1,41 @@ +# Microsoft.Agents.AI.Hyperlight + +First-class [CodeAct](../../../docs/decisions/0024-codeact-integration.md) +support for the Microsoft Agent Framework, backed by the +[Hyperlight](https://github.com/hyperlight-dev/hyperlight) VM-isolated sandbox. + +The package exposes two entry points: + +* **`HyperlightCodeActProvider`** — an `AIContextProvider` that injects an + `execute_code` tool and CodeAct guidance into every agent invocation. Only + one `HyperlightCodeActProvider` may be attached to a given agent; it + enforces this through a fixed `StateKeys` value so `ChatClientAgent`'s + state-key uniqueness validation rejects duplicate registrations. +* **`HyperlightExecuteCodeFunction`** — a standalone `AIFunction` for + static/manual wiring when the sandbox configuration is fixed for the + agent's lifetime. + +Both surfaces support: + +* Provider-owned tools exposed inside the sandbox via `call_tool(...)` + (multiple allowed). +* Opt-in filesystem mounts and outbound network allow-list. +* `CodeActApprovalMode` control: `NeverRequire` (default; approval propagates + from tools wrapped in `ApprovalRequiredAIFunction`) and `AlwaysRequire`. +* Snapshot/restore per run so the guest starts from a known clean state + every invocation. + +## Requirements + +* The `Hyperlight.HyperlightSandbox.Api` NuGet package, published from the + `src/sdk/dotnet` SDK in [hyperlight-dev/hyperlight-sandbox](https://github.com/hyperlight-dev/hyperlight-sandbox) + (the .NET API was added in [PR #46](https://github.com/hyperlight-dev/hyperlight-sandbox/pull/46), + now merged). Until the package is published to nuget.org the project + restore will fail; this project is intentionally `IsPackable=false` in + the meantime. +* A Hyperlight Python guest module when using `SandboxBackend.Wasm`. + +## Status + +Preview. API may change until the underlying Hyperlight SDK reaches a stable +release. 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..58b9ebceb6 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hyperlight.IntegrationTests/CodeActEndToEndTests.cs @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Moq; + +namespace Microsoft.Agents.AI.Hyperlight.IntegrationTests; + +/// +/// 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( + HyperlightCodeActProviderOptions.CreateForWasm(GuestPath!)); + + var context = await provider.InvokingAsync( + new AIContextProvider.InvokingContext(s_mockAgent, session: null, new AIContext())); + + var executeCode = Assert.IsAssignableFrom(context.Tools!.First()); + + // Act + var rawResult = await executeCode.InvokeAsync( + new AIFunctionArguments(new System.Collections.Generic.Dictionary + { + ["code"] = "print(\"hi\")", + })); + + // Assert + var json = rawResult?.ToString(); + Assert.False(string.IsNullOrWhiteSpace(json)); + using var doc = JsonDocument.Parse(json!); + Assert.True(doc.RootElement.GetProperty("success").GetBoolean()); + Assert.Contains("hi", doc.RootElement.GetProperty("stdout").GetString()!); + Assert.Equal(0, doc.RootElement.GetProperty("exit_code").GetInt32()); + } +} 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..b31ca48650 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hyperlight.IntegrationTests/Microsoft.Agents.AI.Hyperlight.IntegrationTests.csproj @@ -0,0 +1,11 @@ + + + + $(TargetFrameworksCore) + + + + + + + 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..4ca1caafb4 --- /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..eb8d941ea5 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/HyperlightCodeActProviderTests.cs @@ -0,0 +1,173 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Linq; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Hyperlight.UnitTests; + +public sealed class HyperlightCodeActProviderTests +{ + [Fact] + public void Ctor_NullOptions_UsesDefaults() + { + // Act + using var provider = new HyperlightCodeActProvider(); + + // Assert + Assert.Empty(provider.GetTools()); + Assert.Empty(provider.GetFileMounts()); + Assert.Empty(provider.GetAllowedDomains()); + Assert.Equal([HyperlightCodeActProvider.FixedStateKey], provider.StateKeys); + } + + [Fact] + public void StateKeys_IsFixedSingleKey() + { + // Arrange + using var provider = new HyperlightCodeActProvider(new HyperlightCodeActProviderOptions()); + + // Act / Assert + Assert.Equal([HyperlightCodeActProvider.FixedStateKey], provider.StateKeys); + } + + [Fact] + public void Tools_Crud_AddReplacesByName() + { + // Arrange + using var provider = new HyperlightCodeActProvider(new HyperlightCodeActProviderOptions()); + var first = AIFunctionFactory.Create(() => "a", name: "t"); + var replacement = AIFunctionFactory.Create(() => "b", name: "t"); + + // Act + provider.AddTools(first); + provider.AddTools(replacement); + + // Assert + var tools = provider.GetTools(); + Assert.Single(tools); + Assert.Same(replacement, tools[0]); + } + + [Fact] + public void Tools_RemoveAndClear_Work() + { + // Arrange + using var provider = new HyperlightCodeActProvider(new HyperlightCodeActProviderOptions()); + provider.AddTools( + AIFunctionFactory.Create(() => "a", name: "a"), + AIFunctionFactory.Create(() => "b", name: "b")); + + // Act + provider.RemoveTools("a"); + + // Assert + Assert.Single(provider.GetTools()); + Assert.Equal("b", provider.GetTools()[0].Name); + + // Act + provider.ClearTools(); + + // Assert + Assert.Empty(provider.GetTools()); + } + + [Fact] + public void FileMounts_Crud_ReplaceByMountPath() + { + // Arrange + using var provider = new HyperlightCodeActProvider(new HyperlightCodeActProviderOptions()); + var m1 = new FileMount("/host/a", "/input/a"); + var m2 = new FileMount("/host/a-new", "/input/a"); + var m3 = new FileMount("/host/b", "/input/b"); + + // Act + provider.AddFileMounts(m1, m3); + provider.AddFileMounts(m2); + + // Assert + var mounts = provider.GetFileMounts().OrderBy(m => m.MountPath).ToArray(); + Assert.Equal(2, mounts.Length); + Assert.Same(m2, mounts[0]); + Assert.Same(m3, mounts[1]); + + // Act + provider.RemoveFileMounts("/input/a"); + + // Assert + Assert.Single(provider.GetFileMounts()); + + // Act + provider.ClearFileMounts(); + + // Assert + Assert.Empty(provider.GetFileMounts()); + } + + [Fact] + public void AllowedDomains_Crud_ReplaceByTarget() + { + // Arrange + using var provider = new HyperlightCodeActProvider(new HyperlightCodeActProviderOptions()); + var d1 = new AllowedDomain("https://a", ["GET"]); + var d2 = new AllowedDomain("https://a", ["POST"]); + var d3 = new AllowedDomain("https://b"); + + // Act + provider.AddAllowedDomains(d1, d3); + provider.AddAllowedDomains(d2); + + // Assert + var domains = provider.GetAllowedDomains().OrderBy(d => d.Target).ToArray(); + Assert.Equal(2, domains.Length); + Assert.Same(d2, domains[0]); + Assert.Same(d3, domains[1]); + + // Act + provider.RemoveAllowedDomains("https://a"); + + // Assert + Assert.Single(provider.GetAllowedDomains()); + + // Act + provider.ClearAllowedDomains(); + + // Assert + Assert.Empty(provider.GetAllowedDomains()); + } + + [Fact] + public void Ctor_SeedsFromOptions() + { + // Arrange + var tool = AIFunctionFactory.Create(() => "x", name: "x"); + var options = new HyperlightCodeActProviderOptions + { + Tools = new[] { tool }, + FileMounts = new[] { new FileMount("/h", "/m") }, + AllowedDomains = new[] { new AllowedDomain("https://a") }, + }; + + // Act + using var provider = new HyperlightCodeActProvider(options); + + // Assert + Assert.Single(provider.GetTools()); + Assert.Single(provider.GetFileMounts()); + Assert.Single(provider.GetAllowedDomains()); + } + + [Fact] + public void Dispose_IsIdempotentAndBlocksFurtherAddTools() + { + // Arrange + var provider = new HyperlightCodeActProvider(new HyperlightCodeActProviderOptions()); + var tool = AIFunctionFactory.Create(() => "x", name: "x"); + + // Act + provider.Dispose(); + provider.Dispose(); + + // Assert + Assert.Throws(() => 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..deab2b75e9 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/InstructionBuilderTests.cs @@ -0,0 +1,108 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using Microsoft.Agents.AI.Hyperlight.Internal; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Hyperlight.UnitTests; + +public sealed class InstructionBuilderTests +{ + [Fact] + public void BuildContextInstructions_HiddenTools_MentionsCallTool() + { + // Act + var text = InstructionBuilder.BuildContextInstructions(toolsVisibleToModel: false); + + // Assert + Assert.Contains("execute_code", text); + Assert.Contains("call_tool", text); + // Backend-agnostic: don't mention a specific language. + Assert.DoesNotContain("Python", text); + } + + [Fact] + public void BuildContextInstructions_VisibleTools_OmitsCallTool() + { + // Act + var text = InstructionBuilder.BuildContextInstructions(toolsVisibleToModel: true); + + // Assert + Assert.Contains("execute_code", text); + Assert.DoesNotContain("call_tool", text); + Assert.DoesNotContain("Python", text); + } + + [Fact] + public void BuildExecuteCodeDescription_WithNoExtras_ReturnsBaseBlurbOnly() + { + // Act + var text = InstructionBuilder.BuildExecuteCodeDescription( + tools: [], + fileMounts: [], + allowedDomains: [], + hasHostInputDirectory: false); + + // Assert + Assert.Contains("Executes code", text); + Assert.DoesNotContain("call_tool", text); + Assert.DoesNotContain("Filesystem access", text); + Assert.DoesNotContain("Outbound network access", text); + } + + [Fact] + public void BuildExecuteCodeDescription_WithTools_IncludesToolNames() + { + // Arrange + var tool = AIFunctionFactory.Create(() => "ok", name: "fetch_docs", description: "fetch docs"); + + // Act + var text = InstructionBuilder.BuildExecuteCodeDescription( + tools: [tool], + fileMounts: [], + allowedDomains: [], + hasHostInputDirectory: false); + + // Assert + Assert.Contains("call_tool", text); + Assert.Contains("fetch_docs", text); + Assert.Contains("fetch docs", text); + } + + [Fact] + public void BuildExecuteCodeDescription_WithFilesystem_IncludesSandboxPathsOnly() + { + // Act + var text = InstructionBuilder.BuildExecuteCodeDescription( + tools: [], + fileMounts: [new FileMount("/host/data.csv", "/input/data.csv")], + allowedDomains: [], + hasHostInputDirectory: true); + + // Assert + Assert.Contains("Filesystem access", text); + Assert.Contains("/input", text); + Assert.Contains("/input/data.csv", text); + + // Host paths must not leak to the model. + Assert.DoesNotContain("/host/workspace", text); + Assert.DoesNotContain("/host/data.csv", text); + } + + [Fact] + public void BuildExecuteCodeDescription_WithAllowedDomains_IncludesNetworkSection() + { + // Act + var text = InstructionBuilder.BuildExecuteCodeDescription( + tools: [], + fileMounts: [], + allowedDomains: [new AllowedDomain("https://api.github.com", new List { "GET", "POST" })], + hasHostInputDirectory: false); + + // Assert + Assert.Contains("Outbound network access", text); + Assert.Contains("api.github.com", text); + Assert.Contains("GET", text); + Assert.Contains("POST", text); + } +} 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..2a614e49ca --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/Microsoft.Agents.AI.Hyperlight.UnitTests.csproj @@ -0,0 +1,16 @@ + + + + $(TargetFrameworksCore) + + + + 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..d888d41d17 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/ProvideAIContextTests.cs @@ -0,0 +1,85 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.AI; +using Moq; + +namespace Microsoft.Agents.AI.Hyperlight.UnitTests; + +public sealed class ProvideAIContextTests +{ + private static readonly AIAgent s_mockAgent = new Mock().Object; + + private static AIContextProvider.InvokingContext NewInvokingContext() => new(s_mockAgent, session: null, new AIContext()); + + [Fact] + public async Task ProvideAIContextAsync_ReturnsExecuteCodeToolAndInstructionsAsync() + { + // Arrange + using var provider = new HyperlightCodeActProvider(new HyperlightCodeActProviderOptions()); + + // Act + var context = await provider.InvokingAsync(NewInvokingContext()); + + // Assert + Assert.NotNull(context); + Assert.NotNull(context!.Tools); + var tools = context.Tools!.ToList(); + Assert.Single(tools); + var function = Assert.IsAssignableFrom(tools[0]); + Assert.Equal("execute_code", function.Name); + Assert.False(string.IsNullOrWhiteSpace(context.Instructions)); + } + + [Fact] + public async Task ProvideAIContextAsync_AlwaysRequire_WrapsInApprovalRequiredAsync() + { + // Arrange + using var provider = new HyperlightCodeActProvider(new HyperlightCodeActProviderOptions + { + ApprovalMode = CodeActApprovalMode.AlwaysRequire, + }); + + // Act + var context = await provider.InvokingAsync(NewInvokingContext()); + + // Assert + _ = Assert.IsType(context!.Tools!.First()); + } + + [Fact] + public async Task ProvideAIContextAsync_NeverRequireWithApprovalTool_WrapsInApprovalRequiredAsync() + { + // Arrange + var inner = AIFunctionFactory.Create(() => "ok", name: "t"); + using var provider = new HyperlightCodeActProvider(new HyperlightCodeActProviderOptions + { + ApprovalMode = CodeActApprovalMode.NeverRequire, + Tools = [new ApprovalRequiredAIFunction(inner)], + }); + + // Act + var context = await provider.InvokingAsync(NewInvokingContext()); + + // Assert + _ = Assert.IsType(context!.Tools!.First()); + } + + [Fact] + public async Task ProvideAIContextAsync_CapturesSnapshot_MutationsAfterDoNotAffectDescriptionAsync() + { + // Arrange + using var provider = new HyperlightCodeActProvider(new HyperlightCodeActProviderOptions()); + provider.AddTools(AIFunctionFactory.Create(() => "one", name: "first_tool")); + + // Act + var context = await provider.InvokingAsync(NewInvokingContext()); + provider.AddTools(AIFunctionFactory.Create(() => "two", name: "second_tool")); + + // Assert — the returned execute_code description must reflect the first snapshot only. + var function = Assert.IsAssignableFrom(context!.Tools!.First()); + Assert.Contains("first_tool", function.Description); + Assert.DoesNotContain("second_tool", function.Description); + } +} 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..728a977f94 --- /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 new file mode 100644 index 0000000000..9d94d71930 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hyperlight.UnitTests/ToolBridgeTests.cs @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Hyperlight.Internal; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Hyperlight.UnitTests; + +public sealed class ToolBridgeTests +{ + [Fact] + public async Task InvokeAsync_PassesArgumentsAndReturnsSerializedResultAsync() + { + // Arrange + static string Echo(string value) => $"echo:{value}"; + var tool = AIFunctionFactory.Create(Echo, name: "echo"); + + // Act + var result = await ToolBridge.InvokeAsync(tool, """{"value":"hello"}"""); + + // Assert — AIFunction.InvokeAsync returns the string; ToolBridge JSON-encodes it. + Assert.Equal("\"echo:hello\"", result); + } + + [Fact] + public async Task InvokeAsync_ReturnsErrorJsonOnExceptionAsync() + { + // Arrange + static int Boom() => throw new InvalidOperationException("nope"); + var tool = AIFunctionFactory.Create(Boom, name: "boom"); + + // Act + var result = await ToolBridge.InvokeAsync(tool, "{}"); + + // Assert + using var doc = JsonDocument.Parse(result); + Assert.True(doc.RootElement.TryGetProperty("error", out var err)); + Assert.Contains("nope", err.GetString()!); + } + + [Fact] + public async Task InvokeAsync_EmptyArguments_InvokesToolWithNoArgsAsync() + { + // Arrange + static string Hi() => "hi"; + var tool = AIFunctionFactory.Create(Hi, name: "hi"); + + // Act + var result = await ToolBridge.InvokeAsync(tool, string.Empty); + + // Assert + Assert.Equal("\"hi\"", result); + } + + [Fact] + public async Task InvokeAsync_NonObjectJson_ReturnsErrorAsync() + { + // Arrange + static string Hi() => "hi"; + var tool = AIFunctionFactory.Create(Hi, name: "hi"); + + // Act + var result = await ToolBridge.InvokeAsync(tool, "[1, 2, 3]"); + + // Assert + using var doc = JsonDocument.Parse(result); + Assert.True(doc.RootElement.TryGetProperty("error", out _)); + } +}