😺

Microsoft Agent Framework の CodeAct を使って JavaScript を実行してみた

に公開

はじめに

Microsoft Agent Framework の CodeAct ( Hyperlight ) を使うと、AIエージェントにサンドボックス化されたコード実行機能を組み込むことができます。

今回は下記の確認を目的に、Microsoft.Agents.AI.Hyperlight を .NET で試してみました。

  • ローカル LLM 環境から Hyperlight を利用できること
  • モデルが JavaScript のコードを発行し、実行結果を受けて最終回答を返せること
  • リクエスト / レスポンス / ツール実行結果を確認すること

CodeAct について

CodeAct はエージェントがコードを書いて Tool Calling で実行し、その実行結果を使ってタスクを進める考え方です。モデルに「次のツールを 1 個だけ選ばせる」方式ではなく、制御フロー、データ変換、ツールオーケストレーションを短いコードにまとめて 1 回の実行として扱えるのが特徴です。

Microsoft Learn の説明では、CodeAct は特に「小さなツール呼び出しを何度もつなぐと、モデルターンが増えて待ち時間とトークン消費がかさむ」のようなケースに適切とありますが計算の実行にも適していると思います。今回のサンプルも、単純な計算タスクをコード実行で解いています。

Hyperlight について

Microsoft.Agents.AI.Hyperlight は、Microsoft Agent Framework で CodeAct を機能として扱うためのパッケージです。単に「コードを実行するツールを追加する」だけではなく、Hyperlight の VM 分離されたサンドボックスを土台にして、エージェントに安全なコード実行能力を組み込むための薄い統合レイヤーとなっています。

主な役割は下記の通りです。

  • Hyperlight: 軽量 VM ベースの分離サンドボックス。ゲストコードをホストから切り離して実行する土台
  • Microsoft.Agents.AI.Hyperlight: そのサンドボックスを Microsoft Agent Framework から使いやすくする .NET 向け統合パッケージ

関係を図にすると次のようになります。

このパッケージには、主に2つの入口があります。

  • HyperlightCodeActProvider: AIContextProvider としてエージェントに登録する方式。各呼び出しに execute_code ツールと CodeAct 用のガイダンスを差し込みます
  • HyperlightExecuteCodeFunction: AIFunction として単体で使う方式。サンドボックスの構成が固定で、手動配線したいときに向いています

今回の確認では、HyperlightCodeActProvider を使用しています。

このパッケージでできることは、単純な JavaScript の実行だけではありません。ドキュメントでは下記のような機能が用意されていると説明されています。

  • サンドボックス内でプロバイダー所有のツールを call_tool(...) として呼び出せる
  • 必要なときだけファイルマウントを追加できる
  • 必要なときだけ外向き通信の許可先を設定できる
  • CodeActApprovalMode で承認ポリシーを切り替えられる
  • 実行ごとに snapshot / restore を行い、毎回クリーンな状態から guest を起動できる

また、下記の承認機能もあります。

  • NeverRequire: 既定値。通常は承認なしで進みますが、ApprovalRequiredAIFunction で包んだ tool の承認は伝播します
  • AlwaysRequire: 常に承認を要求します

今回は動作確認を優先して NeverRequire を使っていますが実運用では AlwaysRequire も検討対象になると思います。

動作イメージ

動作確認環境

  • フレームワーク: net10.0
  • 使用パッケージ:
    • Microsoft.Agents.AI 1.6.1
    • Microsoft.Agents.AI.Hyperlight 1.6.1-preview.260514.1
    • Microsoft.Agents.AI.OpenAI 1.6.1
  • エンドポイント: LM Studio
  • モデル : openai/gpt-oss-20b (今回の LM Studio 設定ではコンテキスト長 14000)

サンプルの全体像

主要コード

Hyperlight を AIエージェント に登録する

using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Hyperlight;
using Microsoft.Extensions.AI;

// JavaScript 用の HyperlightCodeActProviderOptions を作成
var options = HyperlightCodeActProviderOptions.CreateForJavaScript();
// 承認なしで動かす設定にする(今回は検証優先のため。実運用では要検討)
options.ApprovalMode = CodeActApprovalMode.NeverRequire;

// HyperlightCodeActProvider を作成して、AIContextProvider としてエージェントに登録します
using var hyperlightProvider = new HyperlightCodeActProvider(options);

AIAgent agent = loggingChatClient.AsAIAgent(
    new ChatClientAgentOptions
    {
        Name = "HyperlightSampleAgent",
        ChatOptions = new ChatOptions
        {
            Instructions = "You are a helpful coding assistant. When code execution is useful, use the Hyperlight CodeAct capability.",
        },
        AIContextProviders = [hyperlightProvider] // AIContextProvider に HyperlightCodeActProvider を登録
    });

ポイントは CreateForJavaScript() です。これで Hyperlight 側が JavaScript 実行用の CodeAct プロバイダーとして動きます。

今回は検証を優先して承認は NeverRequire に設定しています。

実行結果

今回は簡単な計算シナリオで CodeAct の基本動作を確認します。

計算シナリオ: 1 から 10 までの合計

入力:

JavaScript のコード実行を使って、1 から 10 までの合計を計算し、結果だけ返してください。

期待される流れは下記の通りです。

  1. 最初の応答で finishReason: "tool_calls" が返る
  2. execute_code が呼ばれる
  3. ツール実行結果として stdout: "55" が返る
  4. 再問い合わせのあと、最終応答は 55

実行ログのトレース

以下が各ステップで実際に観測されたトレースです。
(contents の中だけを示しています。)

段階1. 最初の指示

最初の ChatLog Request では、role: user の message にユーザー指示が入って model に渡されます。

[
  {
    "$type": "text",
    "text": "JavaScript のコード実行を使って、1 から 10 までの合計を計算し、結果だけ返してください。"
  }
]

段階2. この実行で生成されたコード

最初の ChatLog Response では、モデルはすぐ答えずに execute_code の function call を返します。ここでエージェント側は finishReason: "tool_calls" を受け取り、sandbox 実行へ進みます。以下は、この実行で観測されたコード例です。

[
  {
    "$type": "functionCall",
    "name": "execute_code",
    "arguments": {
      "code": "const sum = Array.from({length:10}, (_,i)=>i+1).reduce((a,b)=>a+b);\nconsole.log(sum);"
    }
  }
]

段階3. コードの実行結果

Hyperlight sandbox がコードを実行すると、その結果は role: tool の message に functionResult として戻ります。この値が次の呼び出しに tool message として渡されます。

[
  {
    "$type": "functionResult",
    "result": {
      "stdout": "55\n",
      "stderr": "",
      "exit_code": 0,
      "success": true
    }
  }
]

段階4. 最終回答

ツール実行結果を受けたあとの 2 回目の ChatLog Response では、モデルが最終テキスト回答を返します。今回は finishReason: "stop" で、55 だけを返して終了しました。

[
  {
    "$type": "text",
    "text": "55"
  }
]

まとめ

Microsoft.Agents.AI.Hyperlight を使うと、簡単に JavaScript 実行機能付きの AIエージェント を構築できました。また、実行してみると JavaScript のコード生成~実行のリクエストから結果の受け取りまで、モデルとサンドボックスが連携して動いている様子も確認できました。
コンテキスト消費はそれなりにあるので、実際に組み込む場合はマルチエージェント構成にしてコード実行が必要な部分だけを Hyperlight エージェントに任せるのが現実的かなと思います。

ソース

サンプルコード
using Microsoft.Agents.AI.Hyperlight;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI;
using System.ClientModel;
using System.Text;
using System.Text.Json;
using System.Text.Encodings.Web;
using System.Text.Json.Nodes;

Console.InputEncoding = Encoding.UTF8;
Console.OutputEncoding = Encoding.UTF8;

string baseUrl = Environment.GetEnvironmentVariable("OPENAI_BASE_URL") ?? "http://localhost:1234/v1";
string apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY") ?? "sk-dummy";
string model = Environment.GetEnvironmentVariable("OPENAI_MODEL") ?? "openai/gpt-oss-20b";

OpenAIClient openAIClient = new(
    new ApiKeyCredential(apiKey),
    new OpenAIClientOptions
    {
        Endpoint = new Uri(baseUrl)
    });

IChatClient baseChatClient = openAIClient
    .GetChatClient(model)
    .AsIChatClient();

var loggingChatClient = new ConsoleLoggingChatClient(baseChatClient);

var options = HyperlightCodeActProviderOptions.CreateForJavaScript();
options.ApprovalMode = CodeActApprovalMode.NeverRequire;

using var hyperlightProvider = new HyperlightCodeActProvider(options);

AIAgent agent = loggingChatClient.AsAIAgent(
    new ChatClientAgentOptions
    {
        Name = "HyperlightSampleAgent",
        ChatOptions = new ChatOptions
        {
            Instructions = "You are a helpful coding assistant. When code execution is useful, use the Hyperlight CodeAct capability.",
        },
        AIContextProviders = [hyperlightProvider]
    });

AgentSession session = await agent.CreateSessionAsync();

Console.WriteLine("Microsoft.Agents.AI.Hyperlight agent sample");
Console.WriteLine($"Endpoint: {baseUrl}");
Console.WriteLine($"Model: {model}");
Console.WriteLine();
Console.Write("Prompt> ");

string prompt = Console.ReadLine() ?? "JavaScriptで1から10までの合計を計算して、結果だけ短く答えてください。";

Console.WriteLine();
Console.WriteLine("[Execution] Starting agent run...");

var response = await agent.RunAsync(prompt, session);

Console.WriteLine();
Console.WriteLine("Response:");
Console.WriteLine(response);

sealed class ConsoleLoggingChatClient : DelegatingChatClient
{
    private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
    {
        WriteIndented = true,
        Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
    };

    public ConsoleLoggingChatClient(IChatClient innerClient)
        : base(innerClient)
    {
    }

    public override async Task<ChatResponse> GetResponseAsync(
        IEnumerable<ChatMessage> messages,
        ChatOptions? options = null,
        CancellationToken cancellationToken = default)
    {
        WritePayload("Request", messages);

        var response = await base.GetResponseAsync(messages, options, cancellationToken);

        WritePayload("Response", response);
        return response;
    }

    private static void WritePayload(string title, object payload)
    {
        Console.WriteLine();
        Console.WriteLine($"[ChatLog] {title}");

        try
        {
            var normalizedPayload = NormalizePayload(payload);
            WriteTraceSummary(normalizedPayload);
            Console.WriteLine(normalizedPayload.ToJsonString(SerializerOptions));
        }
        catch (Exception exception)
        {
            Console.WriteLine($"<serialization failed: {exception.Message}>");
            Console.WriteLine(payload);
        }
    }

    private static void WriteTraceSummary(JsonNode payload)
    {
        var summaries = new List<string>();

        foreach (var message in GetRelevantMessages(payload))
        {
            AddMessageSummaries(message, summaries);
        }

        if (summaries.Count == 0)
        {
            return;
        }

        foreach (var summary in summaries)
        {
            Console.WriteLine(summary);
        }

        Console.WriteLine();
    }

    private static IEnumerable<JsonObject> GetRelevantMessages(JsonNode payload)
    {
        if (payload is JsonArray requestMessages)
        {
            if (requestMessages.Count == 0 || requestMessages[requestMessages.Count - 1] is not JsonObject lastMessage)
            {
                yield break;
            }

            if (requestMessages.Count >= 2 &&
                TryGetValue(lastMessage["role"], out string lastRole) &&
                string.Equals(lastRole, "tool", StringComparison.OrdinalIgnoreCase) &&
                requestMessages[requestMessages.Count - 2] is JsonObject previousMessage &&
                TryGetValue(previousMessage["role"], out string previousRole) &&
                string.Equals(previousRole, "assistant", StringComparison.OrdinalIgnoreCase))
            {
                yield return previousMessage;
            }

            yield return lastMessage;
            yield break;
        }

        if (payload is JsonObject responseObject &&
            responseObject.TryGetPropertyValue("messages", out var messagesNode) &&
            messagesNode is JsonArray responseMessages)
        {
            foreach (var messageNode in responseMessages)
            {
                if (messageNode is JsonObject messageObject)
                {
                    yield return messageObject;
                }
            }
        }
    }

    private static void AddMessageSummaries(JsonObject message, ICollection<string> summaries)
    {
        if (!message.TryGetPropertyValue("contents", out var contentsNode) || contentsNode is not JsonArray contents)
        {
            return;
        }

        foreach (var contentNode in contents)
        {
            if (contentNode is not JsonObject contentObject ||
                !TryGetValue(contentObject["$type"], out string contentType))
            {
                continue;
            }

            switch (contentType)
            {
                case "functionCall":
                    summaries.Add(FormatFunctionCall(contentObject));
                    break;

                case "functionResult":
                    summaries.Add(FormatFunctionResult(contentObject));
                    break;
            }
        }
    }

    private static string FormatFunctionCall(JsonObject contentObject)
    {
        var callId = TryGetValue(contentObject["callId"], out string parsedCallId) ? parsedCallId : "?";
        var name = TryGetValue(contentObject["name"], out string parsedName) ? parsedName : "<unknown>";
        var codePreview = "<none>";

        if (contentObject.TryGetPropertyValue("arguments", out var argumentsNode) &&
            argumentsNode is JsonObject argumentsObject &&
            TryGetValue(argumentsObject["code"], out string code))
        {
            codePreview = SummarizeText(code, 140);
        }

        return $"[Trace] ToolCall callId={callId} name={name} code={codePreview}";
    }

    private static string FormatFunctionResult(JsonObject contentObject)
    {
        var callId = TryGetValue(contentObject["callId"], out string parsedCallId) ? parsedCallId : "?";

        if (!contentObject.TryGetPropertyValue("result", out var resultNode) || resultNode is not JsonObject resultObject)
        {
            return $"[Trace] ToolResult callId={callId} result={SummarizeText(resultNode?.ToJsonString(SerializerOptions), 160)}";
        }

        var success = TryGetValue(resultObject["success"], out bool parsedSuccess) && parsedSuccess;
        var exitCode = TryGetValue(resultObject["exit_code"], out int parsedExitCode) ? parsedExitCode : -1;
        var stdout = TryGetValue(resultObject["stdout"], out string parsedStdout) ? parsedStdout : string.Empty;
        var stderr = TryGetValue(resultObject["stderr"], out string parsedStderr) ? parsedStderr : string.Empty;

        if (!success)
        {
            return $"[Trace] ToolFailure callId={callId} exit_code={exitCode} stdout={SummarizeText(stdout, 120)} stderr={SummarizeText(stderr, 160)}";
        }

        if (string.IsNullOrWhiteSpace(stdout) && string.IsNullOrWhiteSpace(stderr))
        {
            return $"[Trace] ToolNoOutput callId={callId} exit_code={exitCode} stdout=<empty> stderr=<empty>";
        }

        return $"[Trace] ToolResult callId={callId} exit_code={exitCode} stdout={SummarizeText(stdout, 120)} stderr={SummarizeText(stderr, 120)}";
    }

    private static JsonNode NormalizePayload(object payload)
    {
        var node = JsonSerializer.SerializeToNode(payload, SerializerOptions)
            ?? throw new InvalidOperationException("The payload could not be converted to a JSON node.");

        NormalizeNode(node);
        return node;
    }

    private static void NormalizeNode(JsonNode? node)
    {
        switch (node)
        {
            case JsonObject jsonObject:
                NormalizeFunctionResult(jsonObject);

                foreach (var property in jsonObject)
                {
                    NormalizeNode(property.Value);
                }

                break;

            case JsonArray jsonArray:
                foreach (var item in jsonArray)
                {
                    NormalizeNode(item);
                }

                break;
        }
    }

    private static void NormalizeFunctionResult(JsonObject jsonObject)
    {
        if (!jsonObject.TryGetPropertyValue("$type", out var typeNode) ||
            typeNode?.GetValue<string>() is not "functionResult" ||
            !jsonObject.TryGetPropertyValue("result", out var resultNode) ||
            resultNode is null)
        {
            return;
        }

        if (!TryGetString(resultNode, out var resultText) || !TryParseJsonNode(resultText, out var parsedResult))
        {
            return;
        }

        NormalizeNode(parsedResult);
        jsonObject["result"] = parsedResult;
    }

    private static bool TryGetString(JsonNode node, out string value)
    {
        try
        {
            value = node.GetValue<string>();
            return true;
        }
        catch (InvalidOperationException)
        {
            value = string.Empty;
            return false;
        }
        catch (FormatException)
        {
            value = string.Empty;
            return false;
        }
    }

    private static bool TryGetValue<T>(JsonNode? node, out T value)
    {
        try
        {
            if (node is null)
            {
                value = default!;
                return false;
            }

            value = node.GetValue<T>();
            return true;
        }
        catch (InvalidOperationException)
        {
            value = default!;
            return false;
        }
        catch (FormatException)
        {
            value = default!;
            return false;
        }
    }

    private static string SummarizeText(string? text, int maxLength)
    {
        if (string.IsNullOrWhiteSpace(text))
        {
            return "<empty>";
        }

        var collapsed = text
            .Replace("\r", " ", StringComparison.Ordinal)
            .Replace("\n", " ", StringComparison.Ordinal)
            .Replace("\t", " ", StringComparison.Ordinal)
            .Trim();

        while (collapsed.Contains("  ", StringComparison.Ordinal))
        {
            collapsed = collapsed.Replace("  ", " ", StringComparison.Ordinal);
        }

        if (collapsed.Length <= maxLength)
        {
            return collapsed;
        }

        return collapsed[..maxLength] + "...";
    }

    private static bool TryParseJsonNode(string json, out JsonNode? parsedNode)
    {
        var trimmed = json.Trim();

        if (trimmed.Length == 0 || (trimmed[0] != '{' && trimmed[0] != '['))
        {
            parsedNode = null;
            return false;
        }

        try
        {
            parsedNode = JsonNode.Parse(trimmed);
            return parsedNode is not null;
        }
        catch (JsonException)
        {
            parsedNode = null;
            return false;
        }
    }
}

参考リンク

Discussion