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
18 changes: 18 additions & 0 deletions Docs/README_MCP.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,10 +131,28 @@ TelegramSearchBot 内置了以下工具,通过 `BuiltInToolAttribute` 标记

```json
{
"EnableLLMAgentProcess": false,
"AgentHeartbeatIntervalSeconds": 10,
"AgentHeartbeatTimeoutSeconds": 60,
"AgentChunkPollingIntervalMilliseconds": 200,
"AgentIdleTimeoutMinutes": 15,
"MaxConcurrentAgents": 8,
"AgentTaskTimeoutSeconds": 300,
"AgentShutdownGracePeriodSeconds": 15,
"AgentMaxRecoveryAttempts": 2,
"AgentQueueBacklogWarningThreshold": 20,
"AgentProcessMemoryLimitMb": 256,
"MaxToolCycles": 25
}
```

- `EnableLLMAgentProcess=true` 时,LLM 对话循环会迁移到独立 Agent 进程,主进程仅负责 Telegram 收发、任务队列和流式转发。
- `AgentHeartbeatIntervalSeconds` / `AgentHeartbeatTimeoutSeconds` 控制主进程对 Agent 存活状态的检测。
- `AgentChunkPollingIntervalMilliseconds` 控制主进程从 Garnet 轮询流式输出块的频率。
- `AgentIdleTimeoutMinutes` / `AgentShutdownGracePeriodSeconds` 控制 Agent 的空闲回收和优雅停机窗口。
- `MaxConcurrentAgents` / `AgentProcessMemoryLimitMb` 用于约束 Agent 并发数量和内存占用。
- `AgentTaskTimeoutSeconds` / `AgentMaxRecoveryAttempts` 控制任务超时后的重试和死信恢复策略。

## 五、安全考虑

### 5.1 管理员专用工具
Expand Down
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,17 @@
"EnableVideoASR": false,
"EnableOpenAI": false,
"OpenAIModelName": "gpt-4o",
"EnableLLMAgentProcess": false,
"AgentHeartbeatIntervalSeconds": 10,
"AgentHeartbeatTimeoutSeconds": 60,
"AgentChunkPollingIntervalMilliseconds": 200,
"AgentIdleTimeoutMinutes": 15,
"MaxConcurrentAgents": 8,
"AgentTaskTimeoutSeconds": 300,
"AgentShutdownGracePeriodSeconds": 15,
"AgentMaxRecoveryAttempts": 2,
"AgentQueueBacklogWarningThreshold": 20,
"AgentProcessMemoryLimitMb": 256,
"MaxToolCycles": 25,
"OLTPAuth": "",
"OLTPAuthUrl": "",
Expand All @@ -79,8 +90,21 @@
- `OllamaModelName`: 本地模型名称(默认"qwen2.5:72b-instruct-q2_K")
- `EnableOpenAI`: 是否启用OpenAI(默认false)
- `OpenAIModelName`: OpenAI模型名称(默认"gpt-4o")
- `EnableLLMAgentProcess`: 是否启用独立 LLM Agent 进程模式(默认false)
- `AgentHeartbeatIntervalSeconds`: Agent 心跳上报间隔(默认10秒)
- `AgentHeartbeatTimeoutSeconds`: 主进程判定 Agent 失活的超时时间(默认60秒)
- `AgentChunkPollingIntervalMilliseconds`: 主进程轮询流式输出块的间隔(默认200毫秒)
- `AgentIdleTimeoutMinutes`: Agent 空闲超时时间(默认15分钟)
- `MaxConcurrentAgents`: 同时允许的 Agent 进程数上限(默认8)
- `AgentTaskTimeoutSeconds`: 单个 Agent 任务无进展时的超时时间(默认300秒)
- `AgentShutdownGracePeriodSeconds`: Agent 收到停机请求后的优雅退出等待时间(默认15秒)
- `AgentMaxRecoveryAttempts`: Agent 崩溃或超时后的最大恢复重试次数(默认2)
- `AgentQueueBacklogWarningThreshold`: Agent 任务队列告警阈值(默认20)
- `AgentProcessMemoryLimitMb`: Agent 进程工作集上限(默认256MB)
- `MaxToolCycles`: LLM工具调用最大迭代次数(默认25),防止无限循环

启用 `EnableLLMAgentProcess=true` 后,主进程会负责任务排队、Telegram 发消息和流式转发;独立 Agent 进程负责执行 LLM 循环、本地工具和故障恢复。主进程会在 Agent 心跳超时、任务超时或配置切换时执行恢复、重试、死信投递和优雅停机。

- **日志推送**:
- `OLTPAuth`: OLTP日志推送认证密钥
- `OLTPAuthUrl`: OLTP日志推送URL
Expand Down
33 changes: 33 additions & 0 deletions TelegramSearchBot.Common/Env.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,17 @@ static Env() {
BraveApiKey = config.BraveApiKey;
EnableAccounting = config.EnableAccounting;
MaxToolCycles = config.MaxToolCycles;
EnableLLMAgentProcess = config.EnableLLMAgentProcess;
AgentHeartbeatIntervalSeconds = config.AgentHeartbeatIntervalSeconds;
AgentHeartbeatTimeoutSeconds = config.AgentHeartbeatTimeoutSeconds;
AgentChunkPollingIntervalMilliseconds = config.AgentChunkPollingIntervalMilliseconds;
AgentIdleTimeoutMinutes = config.AgentIdleTimeoutMinutes;
MaxConcurrentAgents = config.MaxConcurrentAgents;
AgentTaskTimeoutSeconds = config.AgentTaskTimeoutSeconds;
AgentShutdownGracePeriodSeconds = config.AgentShutdownGracePeriodSeconds;
AgentMaxRecoveryAttempts = config.AgentMaxRecoveryAttempts;
AgentQueueBacklogWarningThreshold = config.AgentQueueBacklogWarningThreshold;
AgentProcessMemoryLimitMb = config.AgentProcessMemoryLimitMb;
} catch {
}

Expand Down Expand Up @@ -73,6 +84,17 @@ static Env() {
public static string BraveApiKey { get; set; } = null!;
public static bool EnableAccounting { get; set; } = false;
public static int MaxToolCycles { get; set; }
public static bool EnableLLMAgentProcess { get; set; } = false;
public static int AgentHeartbeatIntervalSeconds { get; set; } = 10;
public static int AgentHeartbeatTimeoutSeconds { get; set; } = 60;
public static int AgentChunkPollingIntervalMilliseconds { get; set; } = 200;
public static int AgentIdleTimeoutMinutes { get; set; } = 15;
public static int MaxConcurrentAgents { get; set; } = 8;
public static int AgentTaskTimeoutSeconds { get; set; } = 300;
public static int AgentShutdownGracePeriodSeconds { get; set; } = 15;
public static int AgentMaxRecoveryAttempts { get; set; } = 2;
public static int AgentQueueBacklogWarningThreshold { get; set; } = 20;
public static int AgentProcessMemoryLimitMb { get; set; } = 256;

public static Dictionary<string, string> Configuration { get; set; } = new Dictionary<string, string>();
}
Expand Down Expand Up @@ -100,5 +122,16 @@ public class Config {
public string BraveApiKey { get; set; } = null!;
public bool EnableAccounting { get; set; } = false;
public int MaxToolCycles { get; set; } = 25;
public bool EnableLLMAgentProcess { get; set; } = false;
public int AgentHeartbeatIntervalSeconds { get; set; } = 10;
public int AgentHeartbeatTimeoutSeconds { get; set; } = 60;
public int AgentChunkPollingIntervalMilliseconds { get; set; } = 200;
public int AgentIdleTimeoutMinutes { get; set; } = 15;
public int MaxConcurrentAgents { get; set; } = 8;
public int AgentTaskTimeoutSeconds { get; set; } = 300;
public int AgentShutdownGracePeriodSeconds { get; set; } = 15;
public int AgentMaxRecoveryAttempts { get; set; } = 2;
public int AgentQueueBacklogWarningThreshold { get; set; } = 20;
public int AgentProcessMemoryLimitMb { get; set; } = 256;
}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,3 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace TelegramSearchBot.Model.AI {
public enum LLMProvider {
None,
Expand Down
199 changes: 199 additions & 0 deletions TelegramSearchBot.Common/Model/AI/LlmAgentContracts.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
using System;
using System.Collections.Generic;

namespace TelegramSearchBot.Model.AI {
public enum AgentTaskKind {
Message = 0,
Continuation = 1
}

public enum AgentTaskStatus {
Pending = 0,
Running = 1,
Completed = 2,
Failed = 3,
Recovering = 4,
Cancelled = 5
}

public enum AgentChunkType {
Snapshot = 0,
Done = 1,
Error = 2,
IterationLimitReached = 3
}

public sealed class AgentUserSnapshot {
public long UserId { get; set; }
public string FirstName { get; set; } = string.Empty;
public string LastName { get; set; } = string.Empty;
public string UserName { get; set; } = string.Empty;
public bool? IsPremium { get; set; }
public bool? IsBot { get; set; }
}

public sealed class AgentMessageExtensionSnapshot {
public string Name { get; set; } = string.Empty;
public string Value { get; set; } = string.Empty;
}

public sealed class AgentHistoryMessage {
public long DataId { get; set; }
public DateTime DateTime { get; set; }
public long GroupId { get; set; }
public long MessageId { get; set; }
public long FromUserId { get; set; }
public long ReplyToUserId { get; set; }
public long ReplyToMessageId { get; set; }
public string Content { get; set; } = string.Empty;
public AgentUserSnapshot User { get; set; } = new AgentUserSnapshot();
public List<AgentMessageExtensionSnapshot> Extensions { get; set; } = [];
}

public sealed class AgentModelCapability {
public string Name { get; set; } = string.Empty;
public string Value { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
}

public sealed class AgentChannelConfig {
public int ChannelId { get; set; }
public string Name { get; set; } = string.Empty;
public string Gateway { get; set; } = string.Empty;
public string ApiKey { get; set; } = string.Empty;
public LLMProvider Provider { get; set; }
public int Parallel { get; set; }
public int Priority { get; set; }
public string ModelName { get; set; } = string.Empty;
public List<AgentModelCapability> Capabilities { get; set; } = [];
}

public sealed class AgentExecutionTask {
public string TaskId { get; set; } = Guid.NewGuid().ToString("N");
public AgentTaskKind Kind { get; set; } = AgentTaskKind.Message;
public long ChatId { get; set; }
public long UserId { get; set; }
public long MessageId { get; set; }
public long BotUserId { get; set; }
public string BotName { get; set; } = string.Empty;
public string InputMessage { get; set; } = string.Empty;
public string ModelName { get; set; } = string.Empty;
public int MaxToolCycles { get; set; }
public AgentChannelConfig Channel { get; set; } = new AgentChannelConfig();
public List<AgentHistoryMessage> History { get; set; } = [];
public LlmContinuationSnapshot? ContinuationSnapshot { get; set; }
public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
public int RecoveryAttempt { get; set; }
}

public sealed class AgentStreamChunk {
public string TaskId { get; set; } = string.Empty;
public AgentChunkType Type { get; set; } = AgentChunkType.Snapshot;
public int Sequence { get; set; }
public string Content { get; set; } = string.Empty;
public string ErrorMessage { get; set; } = string.Empty;
public LlmContinuationSnapshot? ContinuationSnapshot { get; set; }
public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
}

public sealed class TelegramAgentToolTask {
public string RequestId { get; set; } = Guid.NewGuid().ToString("N");
public string ToolName { get; set; } = string.Empty;
public Dictionary<string, string> Arguments { get; set; } = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
public long ChatId { get; set; }
public long UserId { get; set; }
public long MessageId { get; set; }
public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
}

public sealed class TelegramAgentToolResult {
public string RequestId { get; set; } = string.Empty;
public bool Success { get; set; }
public string Result { get; set; } = string.Empty;
public string ErrorMessage { get; set; } = string.Empty;
public long TelegramMessageId { get; set; }
public DateTime CompletedAtUtc { get; set; } = DateTime.UtcNow;
}

public sealed class AgentSessionInfo {
public long ChatId { get; set; }
public int ProcessId { get; set; }
public int Port { get; set; }
public string Status { get; set; } = "starting";
public string CurrentTaskId { get; set; } = string.Empty;
public DateTime StartedAtUtc { get; set; } = DateTime.UtcNow;
public DateTime LastHeartbeatUtc { get; set; } = DateTime.UtcNow;
public DateTime LastActiveAtUtc { get; set; } = DateTime.UtcNow;
public DateTime ShutdownRequestedAtUtc { get; set; } = DateTime.MinValue;
public string ErrorMessage { get; set; } = string.Empty;
}

public sealed class AgentControlCommand {
public long ChatId { get; set; }
public string Action { get; set; } = string.Empty;
public string Reason { get; set; } = string.Empty;
public DateTime RequestedAtUtc { get; set; } = DateTime.UtcNow;
}

public sealed class AgentDeadLetterEntry {
public string TaskId { get; set; } = string.Empty;
public long ChatId { get; set; }
public string Reason { get; set; } = string.Empty;
public int RecoveryAttempt { get; set; }
public string Payload { get; set; } = string.Empty;
public string LastContent { get; set; } = string.Empty;
public DateTime FailedAtUtc { get; set; } = DateTime.UtcNow;
}

public sealed class SubAgentTaskEnvelope {
public string RequestId { get; set; } = Guid.NewGuid().ToString("N");
public string Type { get; set; } = "echo";
public string Payload { get; set; } = string.Empty;
public SubAgentMcpExecuteRequest? McpExecute { get; set; }
public SubAgentBackgroundTaskRequest? BackgroundTask { get; set; }
public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow;
}

public sealed class SubAgentMcpExecuteRequest {
public string ServerName { get; set; } = "subagent";
public string Command { get; set; } = string.Empty;
public List<string> Args { get; set; } = [];
public Dictionary<string, string> Env { get; set; } = new(StringComparer.OrdinalIgnoreCase);
public int TimeoutSeconds { get; set; } = 30;
public string ToolName { get; set; } = string.Empty;
public Dictionary<string, object?> Arguments { get; set; } = new(StringComparer.OrdinalIgnoreCase);
}

public sealed class SubAgentBackgroundTaskRequest {
public string Command { get; set; } = string.Empty;
public List<string> Args { get; set; } = [];
public Dictionary<string, string> Env { get; set; } = new(StringComparer.OrdinalIgnoreCase);
public string WorkingDirectory { get; set; } = string.Empty;
public int TimeoutSeconds { get; set; } = 30;
}

public sealed class SubAgentTaskResult {
public string RequestId { get; set; } = string.Empty;
public bool Success { get; set; }
public string Result { get; set; } = string.Empty;
public string ErrorMessage { get; set; } = string.Empty;
public int ExitCode { get; set; }
public DateTime CompletedAtUtc { get; set; } = DateTime.UtcNow;
}

public static class LlmAgentRedisKeys {
public const string AgentTaskQueue = "AGENT_TASKS";
public const string AgentTaskDeadLetterQueue = "AGENT_TASKS:DEAD";
public const string TelegramTaskQueue = "TELEGRAM_TASKS";
public const string ActiveTaskSet = "AGENT_ACTIVE_TASKS";
public const string SubAgentTaskQueue = "SUBAGENT_TASKS";

public static string AgentTaskState(string taskId) => $"AGENT_TASK:{taskId}";
public static string AgentChunks(string taskId) => $"AGENT_CHUNKS:{taskId}";
public static string AgentChunkIndex(string taskId) => $"AGENT_CHUNK_INDEX:{taskId}";
public static string AgentSession(long chatId) => $"AGENT_SESSION:{chatId}";
public static string AgentControl(long chatId) => $"AGENT_CONTROL:{chatId}";
public static string TelegramResult(string requestId) => $"TELEGRAM_RESULT:{requestId}";
public static string SubAgentResult(string requestId) => $"SUBAGENT_RESULT:{requestId}";
}
}
44 changes: 44 additions & 0 deletions TelegramSearchBot.LLM.Test/Service/AI/LLM/GarnetClientTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
using System.Threading.Tasks;
using Moq;
using StackExchange.Redis;
using TelegramSearchBot.LLMAgent.Service;
using TelegramSearchBot.Model.AI;
using Xunit;

namespace TelegramSearchBot.LLM.Test.Service.AI.LLM {
public class GarnetClientTests {
private readonly Mock<IConnectionMultiplexer> _redisMock = new();
private readonly Mock<IDatabase> _dbMock = new();

public GarnetClientTests() {
_redisMock.Setup(r => r.GetDatabase(It.IsAny<int>(), It.IsAny<object>())).Returns(_dbMock.Object);
}

[Fact]
public async Task PublishChunkAsync_WritesSerializedChunkToRedisList() {
RedisKey capturedKey = default;
RedisValue capturedValue = default;

_dbMock.Setup(d => d.ListRightPushAsync(
It.IsAny<RedisKey>(),
It.IsAny<RedisValue>(),
It.IsAny<When>(),
It.IsAny<CommandFlags>()))
.Callback<RedisKey, RedisValue, When, CommandFlags>((key, value, _, _) => {
capturedKey = key;
capturedValue = value;
})
.ReturnsAsync(1);

var client = new GarnetClient(_redisMock.Object);
await client.PublishChunkAsync(new AgentStreamChunk {
TaskId = "task-1",
Type = AgentChunkType.Snapshot,
Content = "hello"
});

Assert.Equal(LlmAgentRedisKeys.AgentChunks("task-1"), capturedKey.ToString());
Assert.Contains("\"Content\":\"hello\"", capturedValue.ToString());
}
}
}
Loading
Loading