Semantic Kernel の Process Framework 入門 その 3「型が欲しい」

に公開

前回: Semantic Kernel の Process Framework 入門 その 2

今回は番外編です。私は Semantic Kernel の Process Framework の API が嫌いです。
文字列を使ってイベント名とかを指定したり、イベントの引数は object? で型が無いしの Step で呼び出す関数名も文字列指定出し…。

ということで「その 2」でやった以下のプログラムですが、こんな感じでいたるところに次に呼び出したい処理を文字列で渡したり、イベントを発生させるときのイベント名が文字列だったり、イベントの引数が object? だったりして辛いです。辛いところには「辛い!」とコメントを入れています。

Steps.cs
#pragma warning disable SKEXP0080
using Microsoft.SemanticKernel;

namespace ConsoleApp6;

class Step1 : KernelProcessStep
{
    [KernelFunction]
    public async Task Execute(KernelProcessStepContext context, string input)
    {
        var message = $"Hello {input}";
        if (input == "Kazuki")
        {
            // 辛い!
            await context.EmitEventAsync("Event1", message);
        }
        else
        {
            // 辛い!
            await context.EmitEventAsync("Event2", message);
        }
    }
}

class Step2 : KernelProcessStep
{
    [KernelFunction]
    // 同じ型の引数が複数あると呼び出し側で名前指定しないといけないのも辛い
    public async Task AddExclamation(KernelProcessStepContext context, string input) => 
        // 辛い!
        await context.EmitEventAsync("Appended", $"{input}!");

    [KernelFunction]
    public async Task AddQuestion(KernelProcessStepContext context) => 
        // 辛い!
        await context.EmitEventAsync("Reject", "Kazuki");
}

class Step3 : KernelProcessStep
{
    [KernelFunction]
    public void Execute(string input) => Console.WriteLine(input);
}
Program.cs
#pragma warning disable SKEXP0080
using ConsoleApp6;
using Microsoft.SemanticKernel;


var processBuilder = new ProcessBuilder("Sample");
var step1 = processBuilder.AddStepFromType<Step1>();
var step2 = processBuilder.AddStepFromType<Step2>();
var step3 = processBuilder.AddStepFromType<Step3>();

processBuilder
    // 辛い!
    .OnInputEvent("Start")
    // 辛い!
    .SendEventTo(new ProcessFunctionTargetBuilder(step1));

// 辛い!
step1.OnEvent("Event1")
    // 辛い!
    .SendEventTo(new ProcessFunctionTargetBuilder(step2, nameof(Step2.AddExclamation)));
// 辛い!
step1.OnEvent("Event2")
    // 辛い!
    .SendEventTo(new ProcessFunctionTargetBuilder(step2, nameof(Step2.AddQuestion)));

// 辛い!
step2.OnEvent("Reject")
    // 辛い!
    .SendEventTo(new ProcessFunctionTargetBuilder(step1));
// 辛い!
step2.OnEvent("Appended")
    // 辛い!
    .SendEventTo(new ProcessFunctionTargetBuilder(step3));

var process = processBuilder.Build();

// 辛い!
await process.StartAsync(new Kernel(), new KernelProcessEvent { Id = "Start", Data = "Kazuki" });
// 辛い!
await process.StartAsync(new Kernel(), new KernelProcessEvent { Id = "Start", Data = "Taro" });

これを出来るだけタイプセーフに寄せていきたいと思って以下の拡張メソッド群を作りました。

Extensions.cs
#pragma warning disable SKEXP0080
using Microsoft.SemanticKernel;
using System.Runtime.CompilerServices;

namespace ConsoleApp6;

/// <summary>
/// Step 内で発行するイベント
/// </summary>
/// <typeparam name="T">イベントで渡す型</typeparam>
struct ProcessEvent<T>
{
}

/// <summary>
/// イベントの引数
/// </summary>
record struct ProcessEventArg<T>(T? Value)
{
    public static implicit operator T?(ProcessEventArg<T> arg) => arg.Value;
}

static class StepEventExtensions
{
    public static KernelProcessEvent ToProcessEvent<T>(
        this ProcessEvent<T> stepEvent,
        T? data = default,
        KernelProcessEventVisibility visibility = KernelProcessEventVisibility.Internal,
        [CallerArgumentExpression(nameof(stepEvent))]
        string? expr = null)
    {
        return new KernelProcessEvent
        {
            Id = Utils.GetLastToken(expr),
            Data = new ProcessEventArg<T>(data),
            Visibility = visibility,
        };
    }
}

/// <summary>
/// ProcessStepBuilder のラッパー
/// </summary>
class TypedStep<TStep>(ProcessStepBuilder builder)
    where TStep : KernelProcessStep
{
    /// <summary>
    /// 本来のビルダーにアクセスする口
    /// </summary>
    public ProcessStepBuilder Builder => builder;

    /// <summary>
    /// 本来のビルダーを受け取るパラメーターにそのまま渡せるように暗黙の型変換
    /// </summary>
    public static implicit operator ProcessStepBuilder(TypedStep<TStep> step) => step.Builder;

    // OnEvent, OnFunctionResult, OnFunctionError のラップ
    public ProcessStepEdgeBuilder OnEvent<T>(
        ProcessEvent<T> stepEvent,
        [CallerArgumentExpression(nameof(stepEvent))]
    string? eventId = null) => builder.OnEvent(Utils.GetLastToken(eventId));

    public ProcessStepEdgeBuilder OnFunctionResult(
        Func<TStep, Delegate> functionSelector,
        [CallerArgumentExpression(nameof(functionSelector))]
        string? expr = null) => builder.OnFunctionResult(Utils.GetLastToken(expr));

    public ProcessStepEdgeBuilder OnFunctionError(
        Func<TStep, Delegate> functionSelector,
        [CallerArgumentExpression(nameof(functionSelector))]
        string? expr = null) => builder.OnFunctionError(Utils.GetLastToken(expr));
}



/// <summary>
/// タイプセーフになるべくしたい願望をかなえる拡張メソッド群
/// </summary>
static class Extensions
{
    /// <summary>
    /// OnInputEvent のタイプセーフ版
    /// </summary>
    public static ProcessEdgeBuilder OnInputEvent<T>(
        this ProcessBuilder builder,
        ProcessEvent<T> stepEvent,
        [CallerArgumentExpression(nameof(stepEvent))]
        string? expr = null) =>
        builder.OnInputEvent(Utils.GetLastToken(expr));

    /// <summary>
    /// Step にイベントを送る
    /// </summary>
    public static ProcessStepEdgeBuilder SendEventTo<TStep>(
        this ProcessStepEdgeBuilder builder,
        TypedStep<TStep> step,
        Func<TStep, Delegate> functionSelector,
        string? parameterName = null,
        [CallerArgumentExpression(nameof(functionSelector))]
    string? functionSelectorExpression = null)
        where TStep : KernelProcessStep
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(functionSelectorExpression);
        return builder.SendEventTo(new ProcessFunctionTargetBuilder(step, Utils.GetLastToken(functionSelectorExpression), parameterName));
    }

    /// <summary>
    /// Step を追加
    /// </summary>
    public static TypedStep<TStep> AddStep<TStep>(this ProcessBuilder builder, string? id = null, IReadOnlyList<string>? aliases = null)
        where TStep : KernelProcessStep
    {
        var step = builder.AddStepFromType<TStep>(id, aliases);
        return new(step);
    }

    /// <summary>
    /// イベントを発行する
    /// </summary>
    public static ValueTask EmitEventAsync<T>(
        this KernelProcessStepContext context,
        ProcessEvent<T> stepEvent,
        T? data = default,
        KernelProcessEventVisibility visibility = KernelProcessEventVisibility.Internal,
        [CallerArgumentExpression(nameof(stepEvent))] string? eventId = null)
        where T : class
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(eventId);
        return context.EmitEventAsync(Utils.GetLastToken(eventId), new ProcessEventArg<T>(data), visibility);
    }
}

static class Utils
{
    public static string GetLastToken(string? expr, char separator = '.')
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(expr);

        var lastIndex = expr.LastIndexOf(separator);
        if (lastIndex != -1)
        {
            expr = expr.Substring(lastIndex + 1);
        }

        return expr;
    }
}

詳しいことは省きますが、これを使うと Step の定義は以下のようになります。EmitEventAsync メソッドの引数に ProcessEvent<T> を渡すことで、イベントの型を指定することが出来ます。これで Step 内で発行するイベントに渡す値の型が決まります。Step 内の KernelFunction のメソッドの引数は Event で渡された引数であることを明示するために ProcessEventArg<T> で明示するようにしています。パラメーター名を文字列で指定するの辛すぎる…。

こんな感じで定義できるようになります。

Steps.cs
#pragma warning disable SKEXP0080
using Microsoft.SemanticKernel;

namespace ConsoleApp6;

// Process の最初のイベントを定義
class ProcessEvents
{
    public static ProcessEvent<string> Start;
}

class Step1 : KernelProcessStep
{
    // イベントはこれで定義
    public static ProcessEvent<string> Event1;
    public static ProcessEvent<string> Event2;
    
    [KernelFunction]
    public async Task Execute(KernelProcessStepContext context, ProcessEventArg<string> input)
    {
        var message = $"Hello {input.Value}";
        if (input == "Kazuki")
        {
            // ここで message が string じゃないとエラーになる
            await context.EmitEventAsync(Event1, message);
        }
        else
        {
            await context.EmitEventAsync(Event2, message);
        }
    }
}

class Step2 : KernelProcessStep
{
    // イベント定義
    public static ProcessEvent<string> Appended;
    public static ProcessEvent<string> Reject;

    [KernelFunction]
    public async Task AddExclamation(KernelProcessStepContext context, ProcessEventArg<string> input) => 
        await context.EmitEventAsync(Appended, $"{input.Value}!");

    [KernelFunction]
    public async Task AddQuestion(KernelProcessStepContext context, ProcessEventArg<string> input) => 
        await context.EmitEventAsync(Reject, "Kazuki");
}

class Step3 : KernelProcessStep
{
    [KernelFunction]
    public void Execute(ProcessEventArg<string> input) => Console.WriteLine(input.Value);
}

そして Program.cs は以下のようになります。

Program.cs
#pragma warning disable SKEXP0080
using ConsoleApp6;
using Microsoft.SemanticKernel;

var processBuilder = new ProcessBuilder("Sample");
// Step の追加を拡張メソッドにしたので戻り値が TypedStep<T> になってる
var step1 = processBuilder.AddStep<Step1>();
var step2 = processBuilder.AddStep<Step2>();
var step3 = processBuilder.AddStep<Step3>();

processBuilder
    // 少なくとも文字列ではない
    .OnInputEvent(ProcessEvents.Start)
    // step1 が TypedStep<Step1> になって
    // 呼び出し先が Execute であることを指定可能。typo は無くなる。
    // だけど Event で渡された値の型と関数の引数の型が会わない可能性がある問題は解決できない
    .SendEventTo(step1, x => x.Execute);

step1
    // OnEvent を Step1 で定義した TypedEvent<T> で指定可能
    .OnEvent(Step1.Event1)
    // 上の呼び出しと同じ
    .SendEventTo(step2, x => x.AddExclamation);
step1
    .OnEvent(Step1.Event2)
    .SendEventTo(step2, x => x.AddQuestion);
step2
    .OnEvent(Step2.Reject)
    .SendEventTo(step1, x => x.Execute);
step2
    .OnEvent(Step2.Appended)
    .SendEventTo(step3, x => x.Execute);

step3
    // OnFunctionResult もタイプセーフで関数名を指定可能
    .OnFunctionResult(x => x.Execute)
    .StopProcess();

var process = processBuilder.Build();
// ここの呼び出しはタイプセーフじゃない…
await using var process1 = await process.StartAsync(
    new(), 
    // 少なくともイベントの ID は文字列指定ではない
    ProcessEvents.Start.ToProcessEvent("Kazuki"));

await using var process2 = await process.StartAsync(
    new(),
    // 少なくともイベントの ID は文字列指定ではない
    ProcessEvents.Start.ToProcessEvent("Taro"));

これくらいインテリセンスに頼って書けるようになるなら個人的には使ってもいいかなぁ。

まとめ

文字列指定とか object? とか辛い。

Microsoft (有志)

Discussion