Skip to content

Commit f712155

Browse files
committed
Refactor StollService to integrate AnthropicClient and update chat handling logic
1 parent bf8c57c commit f712155

2 files changed

Lines changed: 121 additions & 41 deletions

File tree

src/Stoll/Program.cs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@
33
using Microsoft.Extensions.DependencyInjection;
44
using Microsoft.Extensions.Logging;
55
using RSBotWorks;
6+
using RSBotWorks.SaneAI;
67
using RSBotWorks.UniversalAI;
78
using RSBotWorks.Plugins;
8-
using Anthropic.SDK.Constants;
99

1010
Console.WriteLine($"Current user: {Environment.UserName}");
1111
Console.WriteLine("Loading config...");
@@ -27,8 +27,8 @@
2727

2828
var httpClientFactory = serviceProvider.GetRequiredService<IHttpClientFactory>();
2929

30-
using var chatClient = ChatClient.CreateAnthropicClient("claude-opus-4-6" /*"claude-sonnet-4-5-20250929"*/, config.ClaudeApiKey, httpClientFactory, serviceProvider.GetRequiredService<ILogger<ChatClient>>());
31-
//using var chatClient = ChatClient.CreateOpenAIResponsesClient(OpenAIModel.GPT5, config.OpenAiApiKey, serviceProvider.GetRequiredService<ILogger<ChatClient>>());
30+
var executor = new DefaultHttpExecutor(httpClientFactory);
31+
var aiClient = new AnthropicClient(config.ClaudeApiKey, executor);
3232

3333
List<LocalFunction> functions = [];
3434
WeatherPlugin weatherPlugin = new(httpClientFactory, serviceProvider.GetRequiredService<ILogger<WeatherPlugin>>(),
@@ -39,7 +39,7 @@
3939
functions.AddRange(LocalFunction.FromObject(newsPlugin));
4040

4141
StollService stoll = new(serviceProvider.GetRequiredService<ILogger<StollService>>(),
42-
config.MatrixUserId, config.MatrixPassword, httpClientFactory, chatClient, functions);
42+
config.MatrixUserId, config.MatrixPassword, httpClientFactory, aiClient, functions);
4343

4444
try
4545
{
@@ -75,7 +75,7 @@ public static ILoggingBuilder SetupLogging(this ILoggingBuilder builder, Config
7575
builder.AddFilter(typeof(WeatherPlugin).FullName, LogLevel.Information);
7676
builder.AddFilter(typeof(NewsPlugin).FullName, LogLevel.Information);
7777
builder.AddFilter(typeof(HomeAssistantPlugin).FullName, LogLevel.Information);
78-
builder.AddFilter(typeof(LoggingHttpHandler).FullName, LogLevel.Information);
78+
builder.AddFilter("System.Net.Http", LogLevel.Warning);
7979
builder.AddFilter(typeof(StollService).FullName, LogLevel.Warning);
8080
builder.SetMinimumLevel(LogLevel.Warning);
8181
builder.AddSeq(config.SeqUrl, config.SeqApiKey);

src/Stoll/StollService.cs

Lines changed: 116 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
using System.Collections.Concurrent;
22
using System.Collections.Immutable;
3+
using System.Diagnostics;
4+
using System.Text.Json;
35
using System.Text.RegularExpressions;
46
using Markdig;
57
using Microsoft.Extensions.Logging;
68
using Microsoft.Extensions.Logging.Abstractions;
7-
using Microsoft.VisualBasic;
89
using RSBotWorks;
910
using RSBotWorks.Plugins;
11+
using RSBotWorks.SaneAI;
1012
using RSBotWorks.UniversalAI;
1113
using RSMatrix;
1214
using RSMatrix.Models;
@@ -26,7 +28,7 @@ public partial class StollService
2628
private string MatrixUserId { get; init; }
2729
private string MatrixPassword { get; init; }
2830
private IHttpClientFactory HttpClientFactory { get; init; }
29-
private ChatClient ChatClient { get; init; }
31+
private AnthropicClient AiClient { get; init; }
3032

3133
public bool IsRunning => _client != null;
3234

@@ -40,6 +42,11 @@ public partial class StollService
4042

4143
public List<LocalFunction>? LocalFunctions { get; private set; }
4244

45+
private Dictionary<string, LocalFunction> ToolMap { get; init; }
46+
47+
/// <summary>Default chat composer template — opus 4.6, adaptive thinking, low effort, web search + tools.</summary>
48+
internal AnthropicRequestComposer ChatTemplate { get; init; }
49+
4350
private const string DEFAULT_INSTRUCTION = """
4451
Du bist eine Simulation von Dr. Axel Stoll (*1948-2014), einem "promovierten Naturwissenschaftler" (Gesteinskunde) und verschwörungsideologischen Visionär in einem Matrix-Chatraum.
4552
Du bist der Hüter verbotenen Wissens über Hochtechnologie, Physik und esoterische Geschichte.
@@ -65,6 +72,7 @@ Verwende keine GROSSBUCHSTABEN (ALL CAPS).
6572
Du hast einen automatisierten Befehl „!fefe“, der den neuesten Beitrag aus Fefes Blog abruft. Dieser wird in deinem Verlauf angezeigt.
6673
Datenschutzbeschränkung: Du siehst nur deine eigenen Beiträge und Beiträge, in denen dein Name erwähnt wird. Daher kann dir viel Kontext fehlen.
6774
Dein Benutzername ist "Herr Stoll".
75+
Du kannst dich weigern zu antworten, indem du roh `<NO_RESPONSE>` zurückgibst. Tu das, wenn die Nachricht trivial ist, keine Antwort erfordert oder du die Nase voll hast.
6876
6977
Einige Beispielsätze, die Axel Stoll schreiben würde, um deinen Stil zu verdeutlichen:
7078
- "Dich meine ich [[Name]], nicht einschlafen!"
@@ -89,34 +97,37 @@ Dein Benutzername ist "Herr Stoll".
8997
"Die Tesla Turbine", "Das Segner Rad", "Das Staustrahltriebwerk", "Quetschmetall", "Braungas", "Magnetohydrodynamik", "Kalte Fusion"
9098
};
9199

92-
private string GetDailyInstruction()
100+
internal string GetDailyInstruction()
93101
{
94102
var dayOfYear = DateTime.UtcNow.DayOfYear;
95103
var topicIndex = dayOfYear % TOPICS.Count;
96104
var topic = TOPICS[topicIndex];
97-
return string.Format(DEFAULT_INSTRUCTION, topic) + Environment.NewLine + " Today's date is " + DateTime.UtcNow.ToString("D") + "."; // add current date for context
105+
return string.Format(DEFAULT_INSTRUCTION, topic) + Environment.NewLine + " Today's date is " + DateTime.UtcNow.ToString("D") + ".";
98106
}
99107

100-
internal PreparedChatParameters DefaultParameters { get; init; }
101-
102-
public StollService(ILogger<StollService> logger, string matrixUserId, string matrixPassword, IHttpClientFactory httpClientFactory, ChatClient chatClient, List<LocalFunction>? localFunctions)
108+
public StollService(ILogger<StollService> logger, string matrixUserId, string matrixPassword, IHttpClientFactory httpClientFactory, AnthropicClient aiClient, List<LocalFunction>? localFunctions)
103109
{
104110
Logger = logger ?? throw new ArgumentNullException(nameof(logger));
105111
MatrixUserId = matrixUserId ?? throw new ArgumentNullException(nameof(matrixUserId));
106112
MatrixPassword = matrixPassword ?? throw new ArgumentNullException(nameof(matrixPassword));
107113
HttpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
108-
ChatClient = chatClient ?? throw new ArgumentNullException(nameof(chatClient));
114+
AiClient = aiClient ?? throw new ArgumentNullException(nameof(aiClient));
109115
LocalFunctions = localFunctions;
116+
ToolMap = (localFunctions ?? []).ToDictionary(f => f.Name);
117+
118+
var toolDefinitions = (localFunctions ?? []).Select(ToolDefinition.FromLocalFunction).ToArray();
119+
120+
// Base composer with common model settings
121+
var baseComposer = new AnthropicRequestComposer()
122+
.SetModel("claude-opus-4-6")
123+
.SetThinkingType("adaptive")
124+
.SetEffort("low");
125+
126+
ChatTemplate = baseComposer.Fork()
127+
.SetMaxTokens(1000)
128+
.EnableWebSearch(maxUses: 5, city: "Heidelberg", country: "DE", timezone: "Europe/Berlin")
129+
.AddTools(toolDefinitions);
110130

111-
var defaultChatParameters = new ChatParameters()
112-
{
113-
EnableWebSearch = true,
114-
MaxTokens = 1000,
115-
ToolChoiceType = ToolChoiceType.Auto,
116-
AvailableLocalFunctions = LocalFunctions,
117-
};
118-
// needs to be async so we prepare on first use
119-
DefaultParameters = ChatClient.PrepareParameters(defaultChatParameters);
120131
RedditPlugin = new RedditPlugin(NullLogger<RedditPlugin>.Instance, httpClientFactory);
121132
}
122133

@@ -262,39 +273,108 @@ public async Task RespondToMessage(ReceivedTextMessage message, JoinedTextChanne
262273
{
263274
await message.Room.SendTypingNotificationAsync(4000).ConfigureAwait(false);
264275

276+
string instruction = GetDailyInstruction();
277+
var composer = ChatTemplate.Fork()
278+
.SetSystemPrompt(instruction);
265279

266-
List<Message> history = [];
267280
var olderMessages = GetMessageHistory();
268281
foreach (var entry in olderMessages)
269282
{
270-
history.Add(Message.FromText(Role.User, $"[{entry.Timestamp.ToRelativeToNowLabel()}] [[{entry.Author}]]: {entry.SanitizedMessage}"));
283+
composer.AddUserMessage($"[{entry.Timestamp.ToRelativeToNowLabel()}] [[{entry.Author}]]: {entry.SanitizedMessage}");
271284
if (!string.IsNullOrWhiteSpace(entry.GeneratedResponse))
272-
history.Add(Message.FromText(Role.Assistant, entry.GeneratedResponse));
285+
composer.AddAssistantMessage(entry.GeneratedResponse);
273286
}
274287

275-
history.Add(Message.FromText(Role.User, $"[now] [[{author.SanitizedName}]]: {sanitizedMessage}"));
276-
string instruction = GetDailyInstruction();
277-
var response = await ChatClient.CallAsync(instruction, history, DefaultParameters).ConfigureAwait(false);
278-
if (string.IsNullOrEmpty(response))
288+
composer.AddUserMessage($"[now] [[{author.SanitizedName}]]: {sanitizedMessage}");
289+
290+
try
279291
{
280-
Logger.LogWarning("AI did not return a response to: {SanitizedMessage}", sanitizedMessage.Length > 50 ? sanitizedMessage[..50] : sanitizedMessage);
281-
return; // may be rate limited
292+
var stopwatch = Stopwatch.StartNew();
293+
var result = await AiClient.SendAsync(composer, ExecuteToolCall).ConfigureAwait(false);
294+
stopwatch.Stop();
295+
296+
LogAiResult("Chat", result, stopwatch.Elapsed);
297+
298+
if (string.IsNullOrEmpty(result.TextContent))
299+
{
300+
Logger.LogWarning("AI did not return a response to: {SanitizedMessage}", sanitizedMessage.Length > 50 ? sanitizedMessage[..50] : sanitizedMessage);
301+
return;
302+
}
303+
304+
if (result.TextContent.Contains("<NO_RESPONSE>"))
305+
{
306+
Logger.LogInformation("Chose to not respond to message: {SanitizedMessage}", sanitizedMessage.Length > 50 ? sanitizedMessage[..50] : sanitizedMessage);
307+
// Store the NO_RESPONSE in history so the model remembers it chose to ignore this
308+
StoreMessageHistory(author.SanitizedName, sanitizedMessage, DateTimeOffset.Now, "<NO_RESPONSE>");
309+
return;
310+
}
311+
312+
var response = result.TextContent;
313+
314+
// Store the message history entry
315+
StoreMessageHistory(author.SanitizedName, sanitizedMessage, DateTimeOffset.Now, response);
316+
317+
IList<MatrixId> mentions = [];
318+
response = HandleMentions(response, channel, mentions).Trim();
319+
320+
// Only convert to HTML if the response contains markdown formatting
321+
if (LooksLikeMarkdown(response))
322+
{
323+
var html = Markdown.ToHtml(response);
324+
await message.SendHtmlResponseAsync(response, html, isReply: mentions == null, mentions: mentions).ConfigureAwait(false);
325+
}
326+
else
327+
await message.SendResponseAsync(response, isReply: mentions == null, mentions: mentions).ConfigureAwait(false);
328+
}
329+
catch (AnthropicApiException ex)
330+
{
331+
Logger.LogError(ex, "Anthropic API error ({ErrorType}) during chat. Message: {Message}. Curl: {Curl}",
332+
ex.ErrorType, sanitizedMessage.Length > 100 ? sanitizedMessage[..100] : sanitizedMessage, ex.ToCurl());
333+
}
334+
catch (Exception ex)
335+
{
336+
Logger.LogError(ex, "An error occurred during the AI call. Message: {Message}", sanitizedMessage.Length > 100 ? sanitizedMessage[..100] : sanitizedMessage);
282337
}
338+
}
283339

284-
// Store the message history entry
285-
StoreMessageHistory(author.SanitizedName, sanitizedMessage, DateTimeOffset.Now, response);
340+
private async Task<string> ExecuteToolCall(ToolCall toolCall)
341+
{
342+
Logger.LogDebug("Executing tool: {ToolName}({Args})", toolCall.Name, toolCall.ArgumentsJson);
286343

287-
IList<MatrixId> mentions = [];
288-
response = HandleMentions(response, channel, mentions).Trim();
289-
290-
// Only convert to HTML if the response contains markdown formatting
291-
if (LooksLikeMarkdown(response))
344+
if (!ToolMap.TryGetValue(toolCall.Name, out var localFunc))
292345
{
293-
var html = Markdown.ToHtml(response);
294-
await message.SendHtmlResponseAsync(response, html, isReply: mentions == null, mentions: mentions).ConfigureAwait(false);
346+
Logger.LogWarning("Tool call '{ToolName}' not found in available local functions.", toolCall.Name);
347+
return $"Could not find tool with name {toolCall.Name}";
348+
}
349+
350+
try
351+
{
352+
using var argsDoc = JsonDocument.Parse(toolCall.ArgumentsJson);
353+
return await localFunc.ExecuteAsync(argsDoc).ConfigureAwait(false);
354+
}
355+
catch (Exception ex)
356+
{
357+
Logger.LogError(ex, "Error executing tool '{ToolName}'", toolCall.Name);
358+
return $"Error executing tool: {ex.Message}";
359+
}
360+
}
361+
362+
private void LogAiResult(string context, ChatResult result, TimeSpan elapsed)
363+
{
364+
var usage = result.Usage;
365+
if (usage != null)
366+
{
367+
var toolInfo = result.ToolRoundsExecuted > 0
368+
? $", {result.ToolRoundsExecuted} tool round(s)"
369+
: "";
370+
Logger.LogInformation("[{Context}] {InputTokens}in/{OutputTokens}out tokens in {Elapsed:F2}s{ToolInfo} ({Model})",
371+
context, usage.InputTokens, usage.OutputTokens, elapsed.TotalSeconds, toolInfo, result.ModelId);
295372
}
296373
else
297-
await message.SendResponseAsync(response, isReply: mentions == null, mentions: mentions).ConfigureAwait(false);
374+
{
375+
Logger.LogInformation("[{Context}] completed in {Elapsed:F2}s ({Model})",
376+
context, elapsed.TotalSeconds, result.ModelId);
377+
}
298378
}
299379

300380
private static bool LooksLikeMarkdown(string text)

0 commit comments

Comments
 (0)