11using System . Collections . Concurrent ;
22using System . Collections . Immutable ;
3+ using System . Diagnostics ;
4+ using System . Text . Json ;
35using System . Text . RegularExpressions ;
46using Markdig ;
57using Microsoft . Extensions . Logging ;
68using Microsoft . Extensions . Logging . Abstractions ;
7- using Microsoft . VisualBasic ;
89using RSBotWorks ;
910using RSBotWorks . Plugins ;
11+ using RSBotWorks . SaneAI ;
1012using RSBotWorks . UniversalAI ;
1113using RSMatrix ;
1214using 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