Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 2 additions & 5 deletions Docs/Build_and_Test_Guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -267,11 +267,8 @@ jobs:

#### 本地调试日志
```bash
# 启用详细日志
dotnet run --project TelegramSearchBot --verbosity detailed

# 使用 Serilog 配置
export SERILOG__MINIMUMLEVEL__DEFAULT=Debug
# Config.json 中默认 LogLevel=Verbose;需要降低噪声时可改为 Information/Warning
dotnet run --project TelegramSearchBot
```

#### 性能分析
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@
"OLTPAuth": "",
"OLTPAuthUrl": "",
"OLTPName": "",
"LogLevel": "Verbose",
"BraveApiKey": "",
"EnableAccounting": false
}
Expand Down Expand Up @@ -120,6 +121,7 @@
- `OLTPAuth`: OLTP日志推送认证密钥
- `OLTPAuthUrl`: OLTP日志推送URL
- `OLTPName`: OLTP日志推送名称
- `LogLevel`: Serilog 最小日志级别,支持 `Verbose`/`Debug`/`Information`/`Warning`/`Error`/`Fatal`,默认 `Verbose`

完整配置参考: [Env.cs](TelegramSearchBot.Common/Env.cs)

Expand Down
13 changes: 13 additions & 0 deletions TelegramSearchBot.Common/Env.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using System.IO;
using System.Text;
using Newtonsoft.Json;
using Serilog.Events;

namespace TelegramSearchBot.Common {
public static class Env {
Expand Down Expand Up @@ -42,6 +43,7 @@ static Env() {
OLTPAuth = config.OLTPAuth;
OLTPAuthUrl = config.OLTPAuthUrl;
OLTPName = config.OLTPName;
SerilogMinimumLevel = ResolveSerilogMinimumLevel(config.LogLevel);
BraveApiKey = config.BraveApiKey;
EnableAccounting = config.EnableAccounting;
EnableAutoUpdate = config.EnableAutoUpdate;
Expand Down Expand Up @@ -120,6 +122,7 @@ private static string NormalizeBaseUrl(string? baseUrl, string fallback) {
public static string OLTPAuth { get; set; } = null!;
public static string OLTPAuthUrl { get; set; } = null!;
public static string OLTPName { get; set; } = null!;
public static readonly LogEventLevel SerilogMinimumLevel;
public static string BraveApiKey { get; set; } = null!;
public static bool EnableAccounting { get; set; } = false;
public static int MaxToolCycles { get; set; }
Expand Down Expand Up @@ -153,6 +156,15 @@ public static string ResolveUpdateBaseUrl(Config config) {

return NormalizeBaseUrl(uri.ToString(), DefaultUpdateBaseUrl);
}

public static LogEventLevel ResolveSerilogMinimumLevel(string? logLevel) {
if (Enum.TryParse<LogEventLevel>(logLevel, ignoreCase: true, out var parsed) &&
Enum.IsDefined(typeof(LogEventLevel), parsed)) {
return parsed;
}

return LogEventLevel.Verbose;
}
}
public class Config {
public string BaseUrl { get; set; } = "https://api.telegram.org";
Expand All @@ -178,6 +190,7 @@ public class Config {
public string OLTPAuth { get; set; } = null!;
public string OLTPAuthUrl { get; set; } = null!;
public string OLTPName { get; set; } = null!;
public string LogLevel { get; set; } = "Verbose";
public string BraveApiKey { get; set; } = null!;
public bool EnableAccounting { get; set; } = false;
public int MaxToolCycles { get; set; } = 25;
Expand Down
21 changes: 21 additions & 0 deletions TelegramSearchBot.Common/ExceptionExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using System;
using System.Reflection;

namespace TelegramSearchBot.Common {
public static class ExceptionExtensions {
public static Exception GetPrimaryException(this Exception exception) {
ArgumentNullException.ThrowIfNull(exception);

while ((exception is TargetInvocationException || exception is AggregateException) && exception.InnerException != null) {
exception = exception.InnerException;
}

return exception;
}

public static string GetLogSummary(this Exception exception) {
var primaryException = exception.GetPrimaryException();
return $"{primaryException.GetType().Name}: {primaryException.Message}";
}
}
}
4 changes: 2 additions & 2 deletions TelegramSearchBot.Common/Model/AI/LlmContinuationSnapshot.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ public class SerializedChatMessage {
public string Content { get; set; } = null!;

/// <summary>
/// The reasoning content for thinking mode models (e.g., Kimi-thinking-preview, QwQ).
/// This field must be passed back to the API in subsequent requests to avoid HTTP 400 errors.
/// The reasoning content for thinking mode models (e.g., Kimi-thinking-preview, QwQ, DeepSeek reasoning models).
/// Preserve the value as returned by the provider when resuming conversation state.
/// </summary>
public string? ReasoningContent { get; set; }
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
using System.Collections.Generic;
using System.ClientModel;
using System.ClientModel.Primitives;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using OpenAI;
using OpenAI.Chat;
using TelegramSearchBot.Model.AI;
using TelegramSearchBot.Model.Data;
using TelegramSearchBot.Service.AI.LLM;
using Xunit;

Expand Down Expand Up @@ -186,15 +194,15 @@ public void SerializeProviderHistory_WithEmptyReasoningContent_Preserved() {
}

[Fact]
public void DeserializeProviderHistory_AlwaysCallsSetReasoningContent_EvenWithNull() {
public void DeserializeProviderHistory_WithNullReasoningContent_Succeeds() {
// Arrange - serialized message with null ReasoningContent (old snapshot format)
var serialized = new List<SerializedChatMessage> {
new SerializedChatMessage { Role = "system", Content = "System" },
new SerializedChatMessage { Role = "user", Content = "Hello" },
new SerializedChatMessage { Role = "assistant", Content = "Hi", ReasoningContent = null },
};

// Act - deserialize (DeserializeProviderHistory now always calls SetAssistantReasoningContent)
// Act - deserialize without adding an empty reasoning_content patch.
var deserialized = OpenAIService.DeserializeProviderHistory(serialized);

// Assert - deserialization succeeds even with null reasoning content
Expand All @@ -205,6 +213,42 @@ public void DeserializeProviderHistory_AlwaysCallsSetReasoningContent_EvenWithNu
// The key thing: NO exception thrown, deserialization succeeds
}

[Fact]
public void ShouldIncludeEmptyReasoningContent_UsesDeepSeekGatewayOrModel() {
Assert.True(OpenAIService.ShouldIncludeEmptyReasoningContent(
new LLMChannel { Gateway = "https://api.deepseek.com/v1" },
"deepseek-chat"));

Assert.False(OpenAIService.ShouldIncludeEmptyReasoningContent(
new LLMChannel { Gateway = "https://api.minimaxi.com/v1" },
"abab6.5s"));
}

[Fact]
public async Task DeserializeProviderHistory_WithEmptyReasoningContent_ForDeepSeekSerializesOpenAIRequest() {
var serialized = new List<SerializedChatMessage> {
new SerializedChatMessage { Role = "user", Content = "Hi" },
new SerializedChatMessage { Role = "assistant", Content = "Hello", ReasoningContent = "" },
new SerializedChatMessage { Role = "user", Content = "Use the available context." },
};
var history = OpenAIService.DeserializeProviderHistory(serialized, includeEmptyReasoningContent: true);
var handler = new CapturingHandler();
var httpClient = new HttpClient(handler);
var options = new OpenAIClientOptions {
Endpoint = new System.Uri("https://example.test/v1"),
Transport = new HttpClientPipelineTransport(httpClient)
};
var chatClient = new ChatClient("test-model", new ApiKeyCredential("test-key"), options);

await chatClient.CompleteChatAsync(history, new ChatCompletionOptions());

Assert.True(handler.RequestSent);
Assert.Contains("\"reasoning_content\":\"\"", handler.RequestBody);

var reserialized = OpenAIService.SerializeProviderHistory(history);
Assert.Equal("", reserialized[1].ReasoningContent);
}

[Fact]
public void DeserializeProviderHistory_DeepSeekToolCallChain_PreservesAllReasoningContent() {
// Simulate a DeepSeek tool call chain where reasoning_content must be preserved
Expand All @@ -222,5 +266,43 @@ public void DeserializeProviderHistory_DeepSeekToolCallChain_PreservesAllReasoni
Assert.IsType<AssistantChatMessage>(deserialized[2]);
Assert.IsType<AssistantChatMessage>(deserialized[4]);
}

private sealed class CapturingHandler : HttpMessageHandler {
public bool RequestSent { get; private set; }
public string RequestBody { get; private set; } = string.Empty;

protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) {
RequestSent = true;
RequestBody = request.Content is null
? string.Empty
: await request.Content.ReadAsStringAsync(cancellationToken);
var response = new HttpResponseMessage(HttpStatusCode.OK) {
Content = new StringContent("""
{
"id": "chatcmpl-test",
"object": "chat.completion",
"created": 1710000000,
"model": "test-model",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "ok"
},
"finish_reason": "stop"
}
],
"usage": {
"prompt_tokens": 1,
"completion_tokens": 1,
"total_tokens": 2
}
}
""")
};
return response;
}
}
}
}
62 changes: 52 additions & 10 deletions TelegramSearchBot.LLM/Service/AI/LLM/AnthropicService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -591,9 +591,22 @@ private async IAsyncEnumerable<string> ExecWithNativeToolCallingAsync(
assistantContentBlocks.Add(new TextBlockParam(responseText));
}
foreach (var (id, name, inputJson) in toolUseBlocks) {
var parsedInput = string.IsNullOrWhiteSpace(inputJson)
? new Dictionary<string, JsonElement>()
: System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(inputJson);
Dictionary<string, JsonElement> parsedInput;
try {
parsedInput = string.IsNullOrWhiteSpace(inputJson)
? new Dictionary<string, JsonElement>()
: System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(inputJson);
} catch (Exception ex) {
_logger.LogError(
ex,
"{ServiceName}: Failed to deserialize Anthropic native tool input for assistant history. ToolUseId={ToolUseId}, ToolName={ToolName}, InputJson={InputJson}, ErrorSummary={ErrorSummary}",
ServiceName,
id,
name,
inputJson,
ex.GetLogSummary());
parsedInput = new Dictionary<string, JsonElement>();
}
assistantContentBlocks.Add(new ToolUseBlockParam {
ID = id,
Name = name,
Expand All @@ -615,7 +628,16 @@ private async IAsyncEnumerable<string> ExecWithNativeToolCallingAsync(
try {
argsDict = System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(inputJson)
.ToDictionary(kv => kv.Key, kv => kv.Value.ToString());
} catch { }
} catch (Exception ex) {
_logger.LogError(
ex,
"{ServiceName}: Failed to deserialize Anthropic native tool input for display. ToolUseId={ToolUseId}, ToolName={ToolName}, InputJson={InputJson}, ErrorSummary={ErrorSummary}",
ServiceName,
id,
name,
inputJson,
ex.GetLogSummary());
}
}
argsDict ??= new Dictionary<string, string>();
toolNamesBuilder.Append(McpToolHelper.FormatToolCallDisplay(name, argsDict));
Expand All @@ -642,8 +664,15 @@ private async IAsyncEnumerable<string> ExecWithNativeToolCallingAsync(
_logger.LogInformation("{ServiceName}: Tool {ToolName} executed. Result length: {Length}", ServiceName, name, toolResultString.Length);
} catch (Exception ex) {
isError = true;
_logger.LogError(ex, "{ServiceName}: Error executing tool {ToolName}.", ServiceName, name);
toolResultString = $"Error executing tool {name}: {ex.Message}";
_logger.LogError(
ex,
"{ServiceName}: Error executing Anthropic native tool {ToolName}. ToolUseId={ToolUseId}, InputJson={InputJson}, ErrorSummary={ErrorSummary}",
ServiceName,
name,
id,
inputJson,
ex.GetLogSummary());
toolResultString = $"Error executing tool {name}: {ex.GetLogSummary()}";
}

toolResultBlocks.Add(new ToolResultBlockParam(id) {
Expand Down Expand Up @@ -765,8 +794,14 @@ private async IAsyncEnumerable<string> ExecWithXmlToolCallingAsync(
_logger.LogInformation("{ServiceName}: Tool {ToolName} executed. Result: {Result}", ServiceName, parsedToolName, toolResultString);
} catch (Exception ex) {
isError = true;
_logger.LogError(ex, "{ServiceName}: Error executing tool {ToolName}.", ServiceName, parsedToolName);
toolResultString = $"Error executing tool {parsedToolName}: {ex.Message}.";
_logger.LogError(
ex,
"{ServiceName}: Error executing Anthropic XML tool {ToolName}. Arguments={Arguments}, ErrorSummary={ErrorSummary}",
ServiceName,
parsedToolName,
JsonConvert.SerializeObject(toolArguments),
ex.GetLogSummary());
toolResultString = $"Error executing tool {parsedToolName}: {ex.GetLogSummary()}.";
}

string feedbackPrefix = isError ? $"[Tool '{parsedToolName}' Execution Failed. Error: " : $"[Executed Tool '{parsedToolName}'. Result: ";
Expand Down Expand Up @@ -896,8 +931,15 @@ public async IAsyncEnumerable<string> ResumeFromSnapshotAsync(
toolResultString = McpToolHelper.ConvertToolResultToString(toolResultObject);
} catch (Exception ex) {
isError = true;
_logger.LogError(ex, "{ServiceName}: Error executing tool {ToolName} (resume).", ServiceName, parsedToolName);
toolResultString = $"Error executing tool {parsedToolName}: {ex.Message}.";
_logger.LogError(
ex,
"{ServiceName}: Error executing Anthropic XML tool {ToolName} (resume). Arguments={Arguments}, SnapshotId={SnapshotId}, ErrorSummary={ErrorSummary}",
ServiceName,
parsedToolName,
JsonConvert.SerializeObject(firstToolCall.arguments),
snapshot.SnapshotId,
ex.GetLogSummary());
toolResultString = $"Error executing tool {parsedToolName}: {ex.GetLogSummary()}.";
}

string feedbackPrefix = isError ? $"[Tool '{parsedToolName}' Execution Failed. Error: " : $"[Executed Tool '{parsedToolName}'. Result: ";
Expand Down
10 changes: 8 additions & 2 deletions TelegramSearchBot.LLM/Service/AI/LLM/GeminiService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -437,8 +437,14 @@ public async IAsyncEnumerable<string> ExecAsync(
toolResult = McpToolHelper.ConvertToolResultToString(result);
} catch (Exception ex) {
isError = true;
_logger.LogError(ex, "Error executing tool {ToolName}", firstToolCall.toolName);
toolResult = $"Error executing tool {firstToolCall.toolName}: {ex.Message}";
_logger.LogError(
ex,
"{ServiceName}: Error executing Gemini XML tool {ToolName}. Arguments={Arguments}, ErrorSummary={ErrorSummary}",
ServiceName,
firstToolCall.toolName,
JsonConvert.SerializeObject(firstToolCall.arguments),
ex.GetLogSummary());
toolResult = $"Error executing tool {firstToolCall.toolName}: {ex.GetLogSummary()}";
}

string feedback = isError
Expand Down
Loading
Loading