Skip to content

Commit de0d377

Browse files
franklinicclaude
authored andcommitted
feat(agentic): Phase 3 — local in-process inference engine (#1544)
The flagship local-first differentiator: an IChatClient<T> that runs entirely in-process over AiDotNet's own model — no network, no API key. Same agent code drives it as drives OpenAI/Anthropic. - ICausalLanguageModel<T>: minimal next-token-logits seam the real Transformer (or any model) plugs into; implementations may keep an internal KV-cache. - IGenerationTokenizer (+TokenizerGenerationAdapter over the repo's ITokenizer): encode/decode/EOS seam. - TokenSampler<T>: greedy (temp 0 = argmax) + temperature + top-k + top-p (nucleus), seedable for reproducibility; reads logits via Convert.ToDouble so float/double share one path. - IChatPromptTemplate + ChatMlPromptTemplate (role-tagged, opens the assistant turn). - LocalEngineChatClient<T>: renders prompt -> encodes -> autoregressively samples until EOS or token limit -> decodes; non-streaming + streaming (incremental decode deltas); usage = prompt/generated token counts. Drop-in for agents/supervisor/swarm/memory. (Native tool-calling + structured-output constraints via constrained decoding are a follow-up; tools are ignored this slice.) - 9 tests (green net10.0 + net471) on a deterministic ScriptedCausalModel + WordTokenizer: greedy decode + EOS stop, token-limit -> Length, streaming deltas reconstruct full text, AgentExecutor drop-in, sampler argmax/seed-reproducibility/top-k/top-p, ChatML template. No null-forgiving. Stacked on Phase 2 (#1551) — the integration test shows the local engine driving an AgentExecutor. Part of epic #1544 (Phase 3). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 1250ab0 commit de0d377

10 files changed

Lines changed: 910 additions & 0 deletions
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
using System.Text;
2+
using AiDotNet.Agentic.Models;
3+
4+
// Disambiguate from the legacy AiDotNet.PromptEngineering.Templates.ChatMessage (global using).
5+
using ChatMessage = AiDotNet.Agentic.Models.ChatMessage;
6+
7+
namespace AiDotNet.Agentic.Models.Local;
8+
9+
/// <summary>
10+
/// A ChatML-style <see cref="IChatPromptTemplate"/> that renders each message as
11+
/// <c>&lt;|role|&gt;\n{text}\n</c> and ends with an open <c>&lt;|assistant|&gt;</c> turn for the model to
12+
/// complete. This is a widely-used, model-agnostic default.
13+
/// </summary>
14+
/// <remarks>
15+
/// <para><b>For Beginners:</b> This writes the conversation like a script: each line is tagged with who is
16+
/// speaking (<c>&lt;|user|&gt;</c>, <c>&lt;|assistant|&gt;</c>, ...), and the script stops right where the
17+
/// assistant is about to speak — so the model fills in the assistant's reply.
18+
/// </para>
19+
/// </remarks>
20+
public sealed class ChatMlPromptTemplate : IChatPromptTemplate
21+
{
22+
/// <inheritdoc/>
23+
/// <exception cref="ArgumentNullException">Thrown when <paramref name="messages"/> is <c>null</c>.</exception>
24+
/// <exception cref="ArgumentException">Thrown when <paramref name="messages"/> is empty.</exception>
25+
public string Render(IReadOnlyList<ChatMessage> messages)
26+
{
27+
Guard.NotNull(messages);
28+
if (messages.Count == 0)
29+
{
30+
throw new ArgumentException("The conversation must contain at least one message.", nameof(messages));
31+
}
32+
33+
var builder = new StringBuilder();
34+
foreach (var message in messages)
35+
{
36+
builder.Append("<|").Append(RoleTag(message.Role)).Append("|>\n");
37+
builder.Append(message.Text).Append('\n');
38+
}
39+
40+
builder.Append("<|assistant|>\n");
41+
return builder.ToString();
42+
}
43+
44+
private static string RoleTag(ChatRole role) => role switch
45+
{
46+
ChatRole.System => "system",
47+
ChatRole.User => "user",
48+
ChatRole.Assistant => "assistant",
49+
ChatRole.Tool => "tool",
50+
_ => "user"
51+
};
52+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
using AiDotNet.LinearAlgebra;
2+
3+
namespace AiDotNet.Agentic.Models.Local;
4+
5+
/// <summary>
6+
/// The minimal contract an in-process language model exposes to the local generation engine: given the
7+
/// tokens seen so far, produce the logits for the next token. This is the seam between AiDotNet's own
8+
/// Transformer (or any other model) and <see cref="LocalEngineChatClient{T}"/>.
9+
/// </summary>
10+
/// <typeparam name="T">The tensor element type (e.g., <see cref="float"/> or <see cref="double"/>).</typeparam>
11+
/// <remarks>
12+
/// <para>
13+
/// Keeping the contract this small means the generation loop, sampling, and chat templating are written
14+
/// once and tested independently of any particular model, and the real network is wired in behind this
15+
/// interface. An implementation is free to maintain an internal KV-cache keyed on the growing context so
16+
/// repeated calls stay efficient; callers only ever ask for "the next-token logits given this context".
17+
/// </para>
18+
/// <para><b>For Beginners:</b> A language model, at its heart, answers one question over and over: "given
19+
/// everything so far, how likely is each possible next word-piece?" Those likelihoods (before turning them
20+
/// into probabilities) are called <em>logits</em>. This interface is exactly that one question, so the rest
21+
/// of the engine can focus on <em>choosing</em> the next token and stitching the words back together.
22+
/// </para>
23+
/// </remarks>
24+
public interface ICausalLanguageModel<T>
25+
{
26+
/// <summary>
27+
/// Gets the size of the model's vocabulary (the length of the logits vector returned by
28+
/// <see cref="NextTokenLogits"/>).
29+
/// </summary>
30+
int VocabularySize { get; }
31+
32+
/// <summary>
33+
/// Computes the next-token logits for the given context.
34+
/// </summary>
35+
/// <param name="tokenIds">The token ids of the context so far (prompt plus any tokens already generated). Must be non-empty.</param>
36+
/// <returns>A vector of length <see cref="VocabularySize"/> giving the unnormalized score for each candidate next token.</returns>
37+
Vector<T> NextTokenLogits(IReadOnlyList<int> tokenIds);
38+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
using AiDotNet.Agentic.Models;
2+
3+
// Disambiguate from the legacy AiDotNet.PromptEngineering.Templates.ChatMessage (global using).
4+
using ChatMessage = AiDotNet.Agentic.Models.ChatMessage;
5+
6+
namespace AiDotNet.Agentic.Models.Local;
7+
8+
/// <summary>
9+
/// Renders a chat conversation (a list of <see cref="ChatMessage"/>) into the single prompt string a local
10+
/// language model is fed. Different model families expect different role markers, so this is pluggable.
11+
/// </summary>
12+
/// <remarks>
13+
/// <para><b>For Beginners:</b> Cloud chat APIs accept a list of messages directly, but a raw local model
14+
/// just continues one block of text. This converts the conversation into that block, tagging who said what
15+
/// (system/user/assistant) in the format the model was trained on, and ends with the assistant's turn so the
16+
/// model knows it should reply next.
17+
/// </para>
18+
/// </remarks>
19+
public interface IChatPromptTemplate
20+
{
21+
/// <summary>
22+
/// Renders the conversation into a prompt string.
23+
/// </summary>
24+
/// <param name="messages">The conversation so far. Must be non-empty.</param>
25+
/// <returns>The prompt text to encode and feed to the model.</returns>
26+
string Render(IReadOnlyList<ChatMessage> messages);
27+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
namespace AiDotNet.Agentic.Models.Local;
2+
3+
/// <summary>
4+
/// The minimal tokenizer contract the local generation engine needs: turn text into token ids, turn token
5+
/// ids back into text, and know which token marks end-of-sequence.
6+
/// </summary>
7+
/// <remarks>
8+
/// <para>
9+
/// This is intentionally narrower than the full <see cref="AiDotNet.Tokenization.Interfaces.ITokenizer"/> so
10+
/// the engine stays decoupled and trivially testable. <see cref="TokenizerGenerationAdapter"/> bridges a
11+
/// real repo tokenizer to this seam.
12+
/// </para>
13+
/// <para><b>For Beginners:</b> Models don't read text directly — they read numbers (token ids). This turns
14+
/// your prompt into those numbers, turns the model's numbers back into readable text, and tells the engine
15+
/// the special "stop here" token so it knows when the model is done.
16+
/// </para>
17+
/// </remarks>
18+
public interface IGenerationTokenizer
19+
{
20+
/// <summary>
21+
/// Gets the id of the end-of-sequence token. Generation stops when the model produces it. A negative
22+
/// value means "no EOS token" (generation then stops only at the token limit).
23+
/// </summary>
24+
int EosTokenId { get; }
25+
26+
/// <summary>
27+
/// Encodes text into token ids.
28+
/// </summary>
29+
/// <param name="text">The text to encode.</param>
30+
/// <returns>The token ids.</returns>
31+
IReadOnlyList<int> Encode(string text);
32+
33+
/// <summary>
34+
/// Decodes token ids back into text.
35+
/// </summary>
36+
/// <param name="tokenIds">The token ids to decode.</param>
37+
/// <returns>The decoded text.</returns>
38+
string Decode(IReadOnlyList<int> tokenIds);
39+
}
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
using System.Runtime.CompilerServices;
2+
using AiDotNet.Agentic.Models;
3+
4+
// Disambiguate from the legacy AiDotNet.PromptEngineering.Templates.ChatMessage (global using).
5+
using ChatMessage = AiDotNet.Agentic.Models.ChatMessage;
6+
7+
namespace AiDotNet.Agentic.Models.Local;
8+
9+
/// <summary>
10+
/// An <see cref="IChatClient{T}"/> that runs entirely in-process over an <see cref="ICausalLanguageModel{T}"/>
11+
/// — no network, no API key, no external service. It renders the conversation to a prompt, encodes it,
12+
/// autoregressively samples tokens until the end-of-sequence token or a length limit, and decodes the
13+
/// result. This is the flagship "local-first" capability: the same agent code that drives OpenAI or
14+
/// Anthropic drives AiDotNet's own model.
15+
/// </summary>
16+
/// <typeparam name="T">The tensor element type shared with the model.</typeparam>
17+
/// <remarks>
18+
/// <para>
19+
/// Because it implements <see cref="IChatClient{T}"/>, the local engine is a drop-in for every higher layer
20+
/// (agents, supervisor/swarm, memory). Both non-streaming and streaming generation are supported; streaming
21+
/// decodes incrementally and yields the new text on each step. Native tool-calling and structured-output
22+
/// constraints are not applied by this slice (they require constrained decoding, a follow-up) — supplied
23+
/// tools are ignored and the engine produces plain text.
24+
/// </para>
25+
/// <para><b>For Beginners:</b> This is your own chatbot brain running on your machine. You hand it the
26+
/// conversation; it writes the reply one word-piece at a time until it decides it's done or hits the length
27+
/// cap. Everything else in this library that talks to a "chat model" can talk to this one instead — so you
28+
/// can build agents with no cloud dependency at all.
29+
/// </para>
30+
/// </remarks>
31+
public sealed class LocalEngineChatClient<T> : IChatClient<T>
32+
{
33+
private readonly ICausalLanguageModel<T> _model;
34+
private readonly IGenerationTokenizer _tokenizer;
35+
private readonly IChatPromptTemplate _template;
36+
private readonly LocalEngineOptions _options;
37+
38+
/// <summary>
39+
/// Initializes a new local engine.
40+
/// </summary>
41+
/// <param name="model">The in-process language model that produces next-token logits.</param>
42+
/// <param name="tokenizer">The tokenizer used to encode prompts and decode generated tokens.</param>
43+
/// <param name="template">The chat prompt template. <c>null</c> uses <see cref="ChatMlPromptTemplate"/>.</param>
44+
/// <param name="options">Engine settings. <c>null</c> uses defaults.</param>
45+
/// <exception cref="ArgumentNullException">Thrown when <paramref name="model"/> or <paramref name="tokenizer"/> is <c>null</c>.</exception>
46+
public LocalEngineChatClient(
47+
ICausalLanguageModel<T> model,
48+
IGenerationTokenizer tokenizer,
49+
IChatPromptTemplate? template = null,
50+
LocalEngineOptions? options = null)
51+
{
52+
Guard.NotNull(model);
53+
Guard.NotNull(tokenizer);
54+
_model = model;
55+
_tokenizer = tokenizer;
56+
_template = template ?? new ChatMlPromptTemplate();
57+
_options = options ?? new LocalEngineOptions();
58+
}
59+
60+
/// <inheritdoc/>
61+
public string ModelId =>
62+
_options.ModelId is { } id && id.Trim().Length > 0 ? id : "local";
63+
64+
/// <inheritdoc/>
65+
/// <exception cref="ArgumentNullException">Thrown when <paramref name="messages"/> is <c>null</c>.</exception>
66+
/// <exception cref="ArgumentException">Thrown when <paramref name="messages"/> is empty.</exception>
67+
public Task<ChatResponse> GetResponseAsync(
68+
IReadOnlyList<ChatMessage> messages,
69+
ChatOptions? options = null,
70+
CancellationToken cancellationToken = default)
71+
{
72+
var promptIds = BuildPromptIds(messages);
73+
var sampling = ResolveSampling(options);
74+
var sampler = new TokenSampler<T>(sampling.Seed);
75+
var maxTokens = ResolveMaxTokens(options);
76+
77+
var generated = new List<int>();
78+
var finishReason = RunGeneration(promptIds, maxTokens, sampler, sampling, generated, cancellationToken);
79+
80+
var text = generated.Count > 0 ? _tokenizer.Decode(generated) : string.Empty;
81+
var usage = new ChatUsage(promptIds.Count, generated.Count);
82+
var response = new ChatResponse(ChatMessage.Assistant(text), finishReason, usage, ModelId);
83+
return Task.FromResult(response);
84+
}
85+
86+
/// <inheritdoc/>
87+
public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
88+
IReadOnlyList<ChatMessage> messages,
89+
ChatOptions? options = null,
90+
[EnumeratorCancellation] CancellationToken cancellationToken = default)
91+
{
92+
await Task.CompletedTask.ConfigureAwait(false);
93+
94+
var promptIds = BuildPromptIds(messages);
95+
var sampling = ResolveSampling(options);
96+
var sampler = new TokenSampler<T>(sampling.Seed);
97+
var maxTokens = ResolveMaxTokens(options);
98+
99+
yield return new ChatResponseUpdate(role: ChatRole.Assistant);
100+
101+
var context = new List<int>(promptIds);
102+
var generated = new List<int>();
103+
var previousText = string.Empty;
104+
var finishReason = ChatFinishReason.Length;
105+
106+
for (var step = 0; step < maxTokens; step++)
107+
{
108+
cancellationToken.ThrowIfCancellationRequested();
109+
110+
var logits = _model.NextTokenLogits(context);
111+
var next = sampler.Sample(logits, sampling);
112+
if (next == _tokenizer.EosTokenId)
113+
{
114+
finishReason = ChatFinishReason.Stop;
115+
break;
116+
}
117+
118+
generated.Add(next);
119+
context.Add(next);
120+
121+
var fullText = _tokenizer.Decode(generated);
122+
if (fullText.Length > previousText.Length)
123+
{
124+
var delta = fullText.Substring(previousText.Length);
125+
previousText = fullText;
126+
yield return ChatResponseUpdate.ForText(delta);
127+
}
128+
}
129+
130+
yield return ChatResponseUpdate.ForFinish(finishReason, new ChatUsage(promptIds.Count, generated.Count));
131+
}
132+
133+
private ChatFinishReason RunGeneration(
134+
IReadOnlyList<int> promptIds,
135+
int maxTokens,
136+
TokenSampler<T> sampler,
137+
LocalSamplingOptions sampling,
138+
List<int> generated,
139+
CancellationToken cancellationToken)
140+
{
141+
var context = new List<int>(promptIds);
142+
for (var step = 0; step < maxTokens; step++)
143+
{
144+
cancellationToken.ThrowIfCancellationRequested();
145+
146+
var logits = _model.NextTokenLogits(context);
147+
var next = sampler.Sample(logits, sampling);
148+
if (next == _tokenizer.EosTokenId)
149+
{
150+
return ChatFinishReason.Stop;
151+
}
152+
153+
generated.Add(next);
154+
context.Add(next);
155+
}
156+
157+
return ChatFinishReason.Length;
158+
}
159+
160+
private List<int> BuildPromptIds(IReadOnlyList<ChatMessage> messages)
161+
{
162+
Guard.NotNull(messages);
163+
if (messages.Count == 0)
164+
{
165+
throw new ArgumentException("The conversation must contain at least one message.", nameof(messages));
166+
}
167+
168+
var prompt = _template.Render(messages);
169+
return new List<int>(_tokenizer.Encode(prompt));
170+
}
171+
172+
private int ResolveMaxTokens(ChatOptions? options)
173+
{
174+
if (options?.MaxOutputTokens is { } requested && requested > 0)
175+
{
176+
return requested;
177+
}
178+
179+
return _options.MaxOutputTokens is { } configured && configured > 0
180+
? configured
181+
: LocalEngineOptions.DefaultMaxOutputTokens;
182+
}
183+
184+
private LocalSamplingOptions ResolveSampling(ChatOptions? options)
185+
{
186+
var defaults = _options.Sampling ?? new LocalSamplingOptions();
187+
return new LocalSamplingOptions
188+
{
189+
Temperature = options?.Temperature ?? defaults.Temperature,
190+
TopK = options?.TopK ?? defaults.TopK,
191+
TopP = options?.TopP ?? defaults.TopP,
192+
Seed = options?.Seed ?? defaults.Seed,
193+
};
194+
}
195+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
namespace AiDotNet.Agentic.Models.Local;
2+
3+
/// <summary>
4+
/// Settings for <see cref="LocalEngineChatClient{T}"/>: the reported model id, the default generation length,
5+
/// and the default sampling behavior (overridable per request via <see cref="ChatOptions"/>).
6+
/// </summary>
7+
/// <remarks>
8+
/// <para><b>For Beginners:</b> These are the local model's defaults. The most useful is
9+
/// <see cref="MaxOutputTokens"/> (how long a reply may get before the engine stops). Leave
10+
/// <see cref="Sampling"/> unset for safe, near-greedy behavior, or set it to make replies more creative.
11+
/// </para>
12+
/// </remarks>
13+
public sealed class LocalEngineOptions
14+
{
15+
/// <summary>The default maximum number of tokens generated per reply when none is specified.</summary>
16+
public const int DefaultMaxOutputTokens = 256;
17+
18+
/// <summary>
19+
/// Gets or sets the model id reported by <see cref="LocalEngineChatClient{T}.ModelId"/>. <c>null</c> or
20+
/// empty falls back to <c>"local"</c>.
21+
/// </summary>
22+
public string? ModelId { get; set; }
23+
24+
/// <summary>
25+
/// Gets or sets the default maximum number of tokens to generate per reply. <c>null</c> or a non-positive
26+
/// value uses <see cref="DefaultMaxOutputTokens"/>. A request's <see cref="ChatOptions.MaxOutputTokens"/>
27+
/// overrides this.
28+
/// </summary>
29+
public int? MaxOutputTokens { get; set; }
30+
31+
/// <summary>
32+
/// Gets or sets the default sampling settings. <c>null</c> uses near-greedy defaults. Per-request
33+
/// <see cref="ChatOptions"/> values (temperature, top-k, top-p, seed) override the matching fields.
34+
/// </summary>
35+
public LocalSamplingOptions? Sampling { get; set; }
36+
}

0 commit comments

Comments
 (0)