Running intents from files
This page is the code side of intent files: loading a folder, running an intent by name, and wiring it into an
application. What a file holds, live reload, the audit tags and how to look after the folder are in
Intent files; every field and rule is in the
intent file reference. The types are in the IntentSharp.Catalog package, namespace
IntentSharp.Catalog.
Load a folder and run an intent by name
FileIntentCatalog loads every *.intent.yaml, *.intent.yml and *.intent.json file in a folder, and
IntentRuntime runs its intents through your client:
using var catalog = new FileIntentCatalog("intents");
var runtime = new IntentRuntime(client, catalog);
AIResult<int> urgency = await runtime.RunAsync<int>("Orders.ClassifyUrgency", email);
AIResult<JsonNode> invoice = await runtime.RunAsync("Invoice.Extract", invoiceEmail);
- A relative folder is read from
AppContext.BaseDirectory, the build output, so copy the files there (the quick start shows the project setting). A folder that does not exist throwsDirectoryNotFoundException. - The constructor loads the folder before it returns, so
Currentis ready, and watches the folder to load it again after a change. RunAsync<T>(name, input)returns the result asT.RunAsync(name, input)returns any shape as aJsonNode. The same calls exist as extension methods on the input,RunIntentAsync<T>andRunIntentAsync, which take the runtime and the intent's name.- Each run reads the intent's current definition, so a saved edit applies on the next call. A run that has started finishes with the definition it started with.
- Dispose the catalog to stop watching.
FileIntentCatalogOptions, the second constructor argument:
| Option | Default | What it does |
|---|---|---|
IncludeSubdirectories |
true |
Load, and watch, the files in subfolders too. |
WatchForChanges |
true |
Reload after a file is created, changed, renamed or deleted. When false, only ReloadAsync reloads, which suits tests and read-only folders. |
DebounceInterval |
250 ms | How long the folder must be quiet before a reload starts, so a burst of saves loads once. From zero to one minute. |
What throws before any model call
A run throws, before it calls a model, when the code and the catalog do not fit. These are programming errors; everything the model gets wrong is an issue in an ordinary result.
| Exception | When |
|---|---|
IntentNotFoundException |
No intent has that name. Describe(name) throws it too. |
IntentDisabledException |
The intent has enabled: false. |
IntentShapeMismatchException |
The intent's shape cannot produce the T you asked for. Any shape can be run as JsonNode. |
IntentSharpException |
The intent names a check or a .NET type your application did not register. |
ArgumentException |
The intent names a provider the client does not have. |
runtime.Describe(name) returns a copy of the current definition, which you may change freely; changing it does
not change the catalog. To change an intent, save its file.
Register the checks and types a file may name
A file can name two things that live in your code: checks (validators) and .NET result types (shape.clrType).
It can name only those your application registered:
string[] knownVendors = ["Northwind", "Contoso", "Fabrikam"];
var validators = new IntentValidatorRegistry()
.Add<JsonNode>("Invoices.KnownVendor", invoice =>
knownVendors.Contains((string?)invoice["vendor"])
? []
: [$"vendor must be one of: {string.Join(", ", knownVendors)}."]);
var types = new IntentTypeRegistry()
.Add<OrderRequest>();
var runtime = new IntentRuntime(client, catalog, validators, types, loggers.CreateLogger<IntentRuntime>());
A check runs after the rules, on every reply that parses, and receives the result: a string for a text shape, the .NET value for a
primitive or a .NET type, and a JsonNode for a schema shape. It returns the problems, and an empty list when the
value is acceptable. Problems go back to the model on the next attempt, so write them as instructions ("vendor must
be one of: ..." rather than "invalid vendor"). A check that throws becomes an issue, not an exception.
Add<T> reports a result that is not a T as a problem.
A type is registered by its simple, full or assembly-qualified name; Add(name, type) gives it another name.
Why adding a check or a type needs a release is in Intent files.
With dependency injection
string[] knownVendors = ["Northwind", "Contoso", "Fabrikam"];
services.AddIntentSharp(configuration);
services.AddIntentCatalog("intents", options => options.DebounceInterval = TimeSpan.FromSeconds(1));
services.AddIntentRuntime();
services.AddIntentValidator<JsonNode>("Invoices.KnownVendor", invoice =>
knownVendors.Contains((string?)invoice["vendor"])
? []
: [$"vendor must be one of: {string.Join(", ", knownVendors)}."]);
services.AddIntentType<OrderRequest>();
| Call | Registers |
|---|---|
AddIntentCatalog(folder, configure) |
A FileIntentCatalog over the folder, also as IIntentCatalog, with its options. The first registration wins. |
AddIntentRuntime() |
IntentRuntime, with the check and type registries. It needs an IntentClient, for example from AddIntentSharp. |
AddIntentValidator(name, check), AddIntentValidator<T>(name, check) |
A named check. |
AddIntentType<T>(), AddIntentType(name, type) |
A type clrType may name. |
Every registration is a singleton, and the order of the calls does not matter. Inject IntentRuntime where you run
intents:
/// <summary>A service that runs a catalog intent; the runtime comes from dependency injection.</summary>
public sealed class CatalogOrderTriage(IntentRuntime intents)
{
public async Task<int?> UrgencyAsync(string email, CancellationToken cancellationToken)
{
var result = await intents.RunAsync<int>("Orders.ClassifyUrgency", email, cancellationToken);
return result.Success ? result.Value : null;
}
}
The catalog loads the folder when it is first resolved. Resolve it at startup, so the first request does not wait for it, and log what the load found:
// Resolving the catalog loads the folder, so the first request does not wait for it.
var catalog = services.GetRequiredService<IIntentCatalog>();
foreach (var problem in catalog.Current.Problems)
logger.LogWarning("Intent catalog: {Problem}", problem);
// After every reload, whether or not it had problems.
ChangeToken.OnChange(catalog.GetChangeToken, () =>
logger.LogInformation("Intent catalog at generation {Generation}, {Problems} problems",
catalog.Current.Generation, catalog.Current.Problems.Count));
Reloads report problems; they never throw
A load never throws for a bad file. It reports each problem, with the file and the line when it is known, in
Current.Problems and in a Warning log line, and the intents that loaded stay in force. How a bad save keeps the
last good version is in Intent files.
What Current holds:
| Member | What it is |
|---|---|
Definitions |
The loaded intents by name, ignoring case. TryGet(name, out definition) looks one up. |
Problems |
The problems of the latest load, each a CatalogProblem that prints as file:line: message, or file: message when no line is known. For a missing folder, the file is the folder's full path. |
Generation |
A number that rises with every load, whether or not it had problems. |
LoadedAt |
When the snapshot was made. |
A snapshot never changes: a reload swaps in a new one, so code that holds one sees consistent definitions.
GetChangeToken() fires once when Current is replaced; ChangeToken.OnChange, as in the startup code above,
renews it for you. ReloadAsync() loads the folder now and returns the new snapshot.
A rising Generation does not mean an edit took effect. Check Problems for that.
If the folder is deleted or replaced, or a network share drops, the catalog keeps the definitions it has, reports one problem naming the folder, and loads the folder again when it is back.
Check the files in CI
Load the folder once, without watching, and fail the build when any file has a problem:
/// <summary>Loads every intent file in the folder once and returns 1 when any of them has a problem.</summary>
public static int CheckIntentFiles(string folder)
{
using var catalog = new FileIntentCatalog(folder, new FileIntentCatalogOptions { WatchForChanges = false });
foreach (var problem in catalog.Current.Problems)
Console.Error.WriteLine(problem);
Console.WriteLine($"{catalog.Current.Definitions.Count} intents, {catalog.Current.Problems.Count} problems.");
return catalog.Current.Problems.Count == 0 ? 0 : 1;
}
This catches what a load can see: syntax, unknown fields, a missing intent, a rule that cannot apply to a text
or primitive shape, a bad duration, two intents with one name. The names of checks, .NET types and providers are
checked only when an intent runs (What throws before any model call).
Intents defined in code
InMemoryIntentCatalog holds definitions you create in code, for tests or for intents your program builds:
var catalog = new InMemoryIntentCatalog().Add(new IntentDefinition
{
Name = "Orders.ClassifyUrgency",
Version = 1,
Intent = "Rate the urgency of this order email from 1 (routine) to 5 (critical).",
Shape = new IntentShape { Kind = IntentShapeKind.Primitive, Primitive = "int" },
Rules = new IntentRules { Minimum = 1, Maximum = 5 },
});
var runtime = new IntentRuntime(client, catalog);
Add throws for an invalid definition or a name already present; Replace adds or replaces; Remove removes.
Each change swaps in a new snapshot and fires the change token, as a reload does. Remove returns false and
changes nothing when the name is absent. The definition's properties are the
file's fields.
Without passing the runtime
Inside runtime.AsAmbient(), RunIntentAsync works without the runtime argument:
using var scope = runtime.AsAmbient();
var urgency = await email.RunIntentAsync<int>("Orders.ClassifyUrgency");
The scope follows the same rules as the client's (Collections). With
no scope open, the call throws InvalidOperationException.
Audit
Each run is tagged with intent.name, intent.version and intent.source (the file, or memory for a
definition added in code), plus the file's own
tags, and IntentRuntime writes one Information log line per run with the name, the version, whether it
succeeded and the attempts. Pass a logger to the runtime, or let dependency injection supply one. Where the tags go
is in Intent files; a provider that logs every request sees them on
each request (Seeing every request).
Limits
- The result does not carry the tags.
Describe(name)returns the current definition, which may be newer than the one a finished run used. - A run refused because the intent is disabled writes no log line, since it never starts.
- The rest of the limits of intent files, versions and reload are in Intent files.