docs/decisions/0023-handlebars-template-engine.md
We want to use Handlebars as a template factory for rendering prompts and planners in the Semantic Kernel. Handlebars provides a simple and expressive syntax for creating dynamic templates with logic and data. However, Handlebars does not have built-in support for some features and scenarios that are relevant for our use cases, such as:
Therefore, we need to extend Handlebars with custom helpers that can address these gaps and provide a consistent and convenient way for prompt and planner engineers to write templates.
First, we will do this by baking in a defined set of custom system helpers for common operations and utilities that are not provided any the built-in Handlebars helpers, which:
Allows us full control over what functionality can be executed by the Handlebars template factory.
Enhances the functionality and usability of the template factory, by providing helpers for common operations and utilities that are not provided by any built-in Handlebars helpers but are commonly hallucinated by the model.
Improves the expressiveness and readability of the rendered template, as the helpers can be used to perform simple or complex logic or transformations on the template data / arguments.
Provides flexibility and convenience for the users, as they can:
to best suits their needs and preferences.
Allows for customization of specific operations or utilities that may have different behavior or requirements, such as handling output types, formats, or errors.
These helpers would handle the evaluation of the arguments, the execution of the operation or utility, and the writing of the result to the template. Examples of such operations are {{concat string1 string2 ...}}, {{equal value1 value2}}, {{json object}}, {{set name=value}}, {{get name}}, {{or condition1 condition2}}, etc.
Secondly, we have to expose the functions that are registered in the Kernel as helpers to the Handlebars template factory. Options for this are detailed below.
We considered the following options for extending Handlebars with kernel functions as custom helpers:
1. Use a single helper for invoking functions from the kernel. This option would use a generic helper, such as {{invoke pluginName-functionName param1=value1 param2=value2 ...}}, to call any function from the kernel and pass parameters to it. The helper would handle the execution of the function, the conversion of the parameters and the result, and the writing of the result to the template.
2. Use a separate helper for each function from the kernel. This option would register a new helper for each function, such as {{pluginName-functionName param1=value1 param2=value2 ...}}, to handle the execution of the function, the conversion of the parameters and the result, and the writing of the result to the template.
Pros:
invoke, needs to be defined and updated.Cons:
Pros:
Cons:
We decided to go with option 2: providing special helpers to invoke any function in the kernel. These helpers will follow the same logic and syntax for each registered function. We believe that this approach, alongside the custom system helpers that will enable special utility logic or behavior, provides the best balance between simplicity, expressiveness, flexibility, and functionality for the Handlebars template factory and our users.
With this approach,
Kernel.RegisterCustomHelpersCallback option that users can set to register custom helpers.KernelArguments object.Options.Categories to an empty array [].We also decided to follow some guidelines and best practices for designing and implementing the helpers, such as:
-" for helpers registered to handle the kernel functions, to distinguish them from each other and from our system or built-in Handlebars helpers.Effectively, there will be four buckets of helpers enabled in the Handlebars Template Engine:
A prototype implementation of a Handlebars prompt template factory with built-in helpers could look something like this:
/// Options for Handlebars helpers (built-in and custom).
public sealed class HandlebarsPromptTemplateOptions : HandlebarsHelpersOptions
{
// Categories tracking built-in system helpers
public enum KernelHelperCategories
{
Prompt,
Plugin,
Context,
String,
...
}
/// Default character to use for delimiting plugin name and function name in a Handlebars template.
public string DefaultNameDelimiter { get; set; } = "-";
/// Delegate for registering custom helpers.
public delegate void RegisterCustomHelpersCallback(IHandlebars handlebarsInstance, KernelArguments executionContext);
/// Callback for registering custom helpers.
public RegisterCustomHelpersCallback? RegisterCustomHelpers { get; set; } = null;
// Pseudocode, some combination of both KernelHelperCategories and the default HandlebarsHelpersOptions.Categories.
public List<Enum> AllCategories = KernelHelperCategories.AddRange(Categories);
}
// Handlebars Prompt Template
internal class HandlebarsPromptTemplate : IPromptTemplate
{
public async Task<string> RenderAsync(Kernel kernel, KernelArguments arguments, CancellationToken cancellationToken = default)
{
arguments ??= new();
var handlebarsInstance = HandlebarsDotNet.Handlebars.Create();
// Add helpers for kernel functions
KernelFunctionHelpers.Register(handlebarsInstance, kernel, arguments, this._options.PrefixSeparator, cancellationToken);
// Add built-in system helpers
KernelSystemHelpers.Register(handlebarsInstance, arguments, this._options);
// Register any custom helpers
if (this._options.RegisterCustomHelpers is not null)
{
this._options.RegisterCustomHelpers(handlebarsInstance, arguments);
}
...
return await Task.FromResult(prompt).ConfigureAwait(true);
}
}
/// Extension class to register Kernel functions as helpers.
public static class KernelFunctionHelpers
{
public static void Register(
IHandlebars handlebarsInstance,
Kernel kernel,
KernelArguments executionContext,
string nameDelimiter,
CancellationToken cancellationToken = default)
{
kernel.Plugins.GetFunctionsMetadata().ToList()
.ForEach(function =>
RegisterFunctionAsHelper(kernel, executionContext, handlebarsInstance, function, nameDelimiter, cancellationToken)
);
}
private static void RegisterFunctionAsHelper(
Kernel kernel,
KernelArguments executionContext,
IHandlebars handlebarsInstance,
KernelFunctionMetadata functionMetadata,
string nameDelimiter,
CancellationToken cancellationToken = default)
{
// Register helper for each function
handlebarsInstance.RegisterHelper(fullyResolvedFunctionName, (in HelperOptions options, in Context context, in Arguments handlebarsArguments) =>
{
// Get parameters from template arguments; check for required parameters + type match
// If HashParameterDictionary
ProcessHashArguments(functionMetadata, executionContext, handlebarsArguments[0] as IDictionary<string, object>, nameDelimiter);
// Else
ProcessPositionalArguments(functionMetadata, executionContext, handlebarsArguments);
KernelFunction function = kernel.Plugins.GetFunction(functionMetadata.PluginName, functionMetadata.Name);
InvokeSKFunction(kernel, function, GetKernelArguments(executionContext), cancellationToken);
});
}
...
}
/// Extension class to register additional helpers as Kernel System helpers.
public static class KernelSystemHelpers
{
public static void Register(IHandlebars handlebarsInstance, KernelArguments arguments, HandlebarsPromptTemplateOptions options)
{
RegisterHandlebarsDotNetHelpers(handlebarsInstance, options);
RegisterSystemHelpers(handlebarsInstance, arguments, options);
}
// Registering all helpers provided by https://github.com/Handlebars-Net/Handlebars.Net.Helpers.
private static void RegisterHandlebarsDotNetHelpers(IHandlebars handlebarsInstance, HandlebarsPromptTemplateOptions helperOptions)
{
HandlebarsHelpers.Register(handlebarsInstance, optionsCallback: options =>
{
...helperOptions
});
}
// Registering all helpers built by the SK team to support the kernel.
private static void RegisterSystemHelpers(
IHandlebars handlebarsInstance, KernelArguments arguments, HandlebarsPromptTemplateOptions helperOptions)
{
// Where each built-in helper will have its own defined class, following the same pattern that is used by Handlebars.Net.Helpers.
// https://github.com/Handlebars-Net/Handlebars.Net.Helpers
if (helperOptions.AllCategories contains helperCategory)
...
KernelPromptHelpers.Register(handlebarsContext);
KernelPluginHelpers.Register(handlebarsContext);
KernelStringHelpers..Register(handlebarsContext);
...
}
}
Note: This is just a prototype implementation for illustration purposes only.
Handlebars supports different object types as variables on render. This opens up the option to use objects outright rather than just strings in semantic functions, i.e., loop over arrays or access properties of complex objects, without serializing or deserializing objects before invocation.