From de65c30d513055007b80efccc502b6d02a6f5c02 Mon Sep 17 00:00:00 2001 From: ModerRAS Date: Mon, 1 Jun 2026 07:17:02 +0800 Subject: [PATCH 1/3] Add rmux pi coding agent tool --- .gitignore | 5 +- Docs/README_CodingAgentTool.md | 70 + Docs/README_MCP.md | 10 + TelegramSearchBot.Common/Env.cs | 105 ++ .../Model/AI/LlmAgentContracts.cs | 53 + .../Service/Tools/CodingAgentJobService.cs | 113 ++ .../Service/Tools/CodingAgentPathPolicy.cs | 107 ++ .../Service/Tools/CodingAgentToolService.cs | 150 ++ TelegramSearchBot.RmuxSidecar/Cargo.lock | 1349 +++++++++++++++++ TelegramSearchBot.RmuxSidecar/Cargo.toml | 21 + TelegramSearchBot.RmuxSidecar/README.md | 20 + TelegramSearchBot.RmuxSidecar/src/main.rs | 820 ++++++++++ .../AI/LLM/CodingAgentToolServiceTests.cs | 118 ++ .../AI/LLM/InMemoryRedisTestHarness.cs | 30 + .../Extension/ServiceCollectionExtension.cs | 2 + .../AI/LLM/CodingAgentReportConsumer.cs | 295 ++++ .../Service/AI/LLM/LLMTaskQueueService.cs | 44 + .../AI/LLM/RmuxSidecarLauncherService.cs | 154 ++ 18 files changed, 3465 insertions(+), 1 deletion(-) create mode 100644 Docs/README_CodingAgentTool.md create mode 100644 TelegramSearchBot.LLM/Service/Tools/CodingAgentJobService.cs create mode 100644 TelegramSearchBot.LLM/Service/Tools/CodingAgentPathPolicy.cs create mode 100644 TelegramSearchBot.LLM/Service/Tools/CodingAgentToolService.cs create mode 100644 TelegramSearchBot.RmuxSidecar/Cargo.lock create mode 100644 TelegramSearchBot.RmuxSidecar/Cargo.toml create mode 100644 TelegramSearchBot.RmuxSidecar/README.md create mode 100644 TelegramSearchBot.RmuxSidecar/src/main.rs create mode 100644 TelegramSearchBot.Test/Service/AI/LLM/CodingAgentToolServiceTests.cs create mode 100644 TelegramSearchBot/Service/AI/LLM/CodingAgentReportConsumer.cs create mode 100644 TelegramSearchBot/Service/AI/LLM/RmuxSidecarLauncherService.cs diff --git a/.gitignore b/.gitignore index 7e765dc4..7c973f75 100644 --- a/.gitignore +++ b/.gitignore @@ -360,6 +360,9 @@ example/ # Moder.Update Rust build artifacts external/moder-update/**/target/ +# Rust sidecar build artifacts +/TelegramSearchBot.RmuxSidecar/target/ + # Moder.Update C# build artifacts external/moder-update/**/bin/ -external/moder-update/**/obj/ \ No newline at end of file +external/moder-update/**/obj/ diff --git a/Docs/README_CodingAgentTool.md b/Docs/README_CodingAgentTool.md new file mode 100644 index 00000000..9c2d9574 --- /dev/null +++ b/Docs/README_CodingAgentTool.md @@ -0,0 +1,70 @@ +# Coding Agent Tool / 编码 Agent 工具 + +## Overview / 概览 + +`run_coding_agent` starts a background `pi --mode rpc` coding job from the LLM tool loop. The tool returns immediately with a job id. A Rust sidecar consumes the job from Garnet/Redis, creates rmux observer sessions for logs, runs pi, then pushes a report back to the bot. The main process posts the report to Telegram and automatically resumes the LLM loop with that report. + +`run_coding_agent` 会从 LLM 工具循环里启动一个后台 `pi --mode rpc` 编码任务。工具会立即返回 job id。Rust sidecar 从 Garnet/Redis 消费任务,用 rmux 创建日志观察会话,运行 pi,然后把报告推回 bot。主进程会把报告发到 Telegram,并基于报告自动续跑 LLM。 + +## Architecture / 架构 + +1. LLM calls `run_coding_agent(prompt, workingDirectory, ...)`. +2. Main process validates the chat whitelist and workspace path guardrails. +3. Main process writes `CodingAgentJobRequest` to `CODING_AGENT_JOBS`. +4. `telegramsearchbot-rmux-sidecar` runs pi in RPC mode and keeps an rmux log session available for inspection. +5. Sidecar writes `CodingAgentJobReport` to `CODING_AGENT_REPORTS`. +6. Main process sends a Telegram report and resumes the LLM task automatically. + +1. LLM 调用 `run_coding_agent(prompt, workingDirectory, ...)`。 +2. 主进程校验群白名单和工作目录护栏。 +3. 主进程把 `CodingAgentJobRequest` 写入 `CODING_AGENT_JOBS`。 +4. `telegramsearchbot-rmux-sidecar` 以 RPC 模式运行 pi,并创建可检查的 rmux 日志会话。 +5. Sidecar 把 `CodingAgentJobReport` 写入 `CODING_AGENT_REPORTS`。 +6. 主进程发送 Telegram 报告并自动恢复 LLM 任务。 + +## Config / 配置 + +Add these fields to `%LOCALAPPDATA%/TelegramSearchBot/Config.json`: + +```json +{ + "EnableCodingAgentTool": true, + "CodingAgentAllowedGroupIds": [-1001234567890], + "CodingAgentDefaultTimeoutMinutes": 60, + "CodingAgentMaxConcurrentJobs": 2, + "CodingAgentMaxAutoResumeContinuations": 4, + "CodingAgentPiCommand": "pi", + "CodingAgentSidecarCommand": "telegramsearchbot-rmux-sidecar", + "CodingAgentDeniedPathPrefixes": [] +} +``` + +- `EnableCodingAgentTool`: disabled by default. / 默认关闭。 +- `CodingAgentAllowedGroupIds`: explicit Telegram group whitelist. Empty means no group can use it. / Telegram 群白名单;空列表表示没有群可用。 +- `CodingAgentDeniedPathPrefixes`: extra denied path prefixes. Built-in defaults deny app config/secrets and OS directories. / 额外拒绝路径前缀;内置默认会拒绝应用配置/密钥目录和系统目录。 +- `CodingAgentSidecarCommand`: sidecar binary path or command name. / sidecar 二进制路径或命令名。 + +Build the sidecar: + +```powershell +cargo build --manifest-path TelegramSearchBot.RmuxSidecar/Cargo.toml --release +``` + +Put the resulting `telegramsearchbot-rmux-sidecar.exe` on `PATH`, next to the bot executable, or set `CodingAgentSidecarCommand` to its full path. + +把生成的 `telegramsearchbot-rmux-sidecar.exe` 放到 `PATH`、bot 可执行文件旁边,或把 `CodingAgentSidecarCommand` 设置为完整路径。 + +## Multi-Agent / 多 Agent + +The optional `agents` parameter accepts JSON: + +```json +[ + { "name": "implementer", "prompt": "Implement the requested change." }, + { "name": "reviewer", "role": "Review the change and report risks." } +] +``` + +Agents run sequentially in the same workspace to avoid conflicting edits. Each agent gets its own pi session directory and rmux observer session. + +多 agent 会在同一个工作目录里顺序运行,避免并发修改互相冲突。每个 agent 都有独立的 pi session 目录和 rmux 观察会话。 diff --git a/Docs/README_MCP.md b/Docs/README_MCP.md index ad0aa654..53eca2f6 100644 --- a/Docs/README_MCP.md +++ b/Docs/README_MCP.md @@ -92,6 +92,16 @@ API 地址和 API Key 来自该模型关联的 LLM 渠道,因此可通过 `新 - 本地 Bot API(内置或外部): 最大 2GB - 云端API: 最大 50MB +### 2.2.3 Coding Agent 工具 / Coding Agent Tools + +| 工具名称 | 描述 | 参数 | +|---------|------|------| +| `run_coding_agent` | 启动后台 `pi --mode rpc` 编码任务,任务由 rmux sidecar 管理;工具立即返回 job id,任务完成后会发回报告并自动续跑 LLM。 / Starts a background `pi --mode rpc` coding job managed by the rmux sidecar; returns a job id immediately, then posts a report and resumes the LLM loop when done. | `prompt`, `workingDirectory`, `agents?`, `timeoutMinutes?`, `provider?`, `model?`, `tools?` | +| `get_coding_agent_job` | 查询后台编码任务状态。 / Gets background coding job status. | `jobId` | +| `cancel_coding_agent_job` | 请求取消后台编码任务。 / Requests cancellation for a background coding job. | `jobId`, `reason?` | + +详细配置见 [README_CodingAgentTool.md](README_CodingAgentTool.md)。 + ### 2.3 搜索工具 | 工具名称 | 描述 | 参数 | diff --git a/TelegramSearchBot.Common/Env.cs b/TelegramSearchBot.Common/Env.cs index 29b17205..e5cadcd9 100644 --- a/TelegramSearchBot.Common/Env.cs +++ b/TelegramSearchBot.Common/Env.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Text; using Newtonsoft.Json; using Serilog.Events; @@ -79,6 +80,18 @@ static Env() { SandboxieGlobalReadPaths = config.SandboxieGlobalReadPaths ?? new List(); SandboxieGlobalClosedPaths = config.SandboxieGlobalClosedPaths ?? new List(); SandboxieToolTimeoutSeconds = Math.Clamp(config.SandboxieToolTimeoutSeconds, 5, 3600); + EnableCodingAgentTool = config.EnableCodingAgentTool; + CodingAgentAllowedGroupIds = config.CodingAgentAllowedGroupIds ?? new List(); + CodingAgentDeniedPathPrefixes = ResolveCodingAgentDeniedPathPrefixes(config.CodingAgentDeniedPathPrefixes); + CodingAgentDefaultTimeoutMinutes = Math.Clamp(config.CodingAgentDefaultTimeoutMinutes, 1, 1440); + CodingAgentMaxConcurrentJobs = Math.Clamp(config.CodingAgentMaxConcurrentJobs, 1, 64); + CodingAgentMaxAutoResumeContinuations = Math.Clamp(config.CodingAgentMaxAutoResumeContinuations, 0, 16); + CodingAgentPiCommand = string.IsNullOrWhiteSpace(config.CodingAgentPiCommand) + ? "pi" + : config.CodingAgentPiCommand.Trim(); + CodingAgentSidecarCommand = string.IsNullOrWhiteSpace(config.CodingAgentSidecarCommand) + ? "telegramsearchbot-rmux-sidecar" + : config.CodingAgentSidecarCommand.Trim(); } public static BotApiEndpointSettings ResolveBotApiEndpoint(Config config) { @@ -167,6 +180,14 @@ private static string NormalizeBaseUrl(string? baseUrl, string fallback) { public static List SandboxieGlobalReadPaths { get; set; } = new List(); public static List SandboxieGlobalClosedPaths { get; set; } = new List(); public static int SandboxieToolTimeoutSeconds { get; set; } = 120; + public static bool EnableCodingAgentTool { get; set; } = false; + public static List CodingAgentAllowedGroupIds { get; set; } = new List(); + public static List CodingAgentDeniedPathPrefixes { get; set; } = new List(); + public static int CodingAgentDefaultTimeoutMinutes { get; set; } = 60; + public static int CodingAgentMaxConcurrentJobs { get; set; } = 2; + public static int CodingAgentMaxAutoResumeContinuations { get; set; } = 4; + public static string CodingAgentPiCommand { get; set; } = "pi"; + public static string CodingAgentSidecarCommand { get; set; } = "telegramsearchbot-rmux-sidecar"; public static Dictionary Configuration { get; set; } = new Dictionary(); @@ -195,6 +216,82 @@ public static LogEventLevel ResolveSerilogMinimumLevel(string? logLevel) { return LogEventLevel.Verbose; } + + private static List ResolveCodingAgentDeniedPathPrefixes(List? configuredPrefixes) { + return GetDefaultCodingAgentDeniedPathPrefixes() + .Concat(configuredPrefixes ?? new List()) + .Where(prefix => !string.IsNullOrWhiteSpace(prefix)) + .Select(prefix => NormalizeDeniedPathPrefix(prefix)) + .Where(prefix => !string.IsNullOrWhiteSpace(prefix)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + private static IEnumerable GetDefaultCodingAgentDeniedPathPrefixes() { + yield return WorkDir; + + var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + if (!string.IsNullOrWhiteSpace(userProfile)) { + yield return Path.Combine(userProfile, ".ssh"); + yield return Path.Combine(userProfile, ".gnupg"); + } + + var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData); + if (!string.IsNullOrWhiteSpace(appData)) { + yield return appData; + } + + var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + if (!string.IsNullOrWhiteSpace(localAppData)) { + yield return Path.Combine(localAppData, "TelegramSearchBot"); + } + + if (OperatingSystem.IsWindows()) { + var windows = Environment.GetFolderPath(Environment.SpecialFolder.Windows); + if (!string.IsNullOrWhiteSpace(windows)) { + yield return windows; + } + + var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); + if (!string.IsNullOrWhiteSpace(programFiles)) { + yield return programFiles; + } + + var programFilesX86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86); + if (!string.IsNullOrWhiteSpace(programFilesX86)) { + yield return programFilesX86; + } + } else { + yield return "/bin"; + yield return "/boot"; + yield return "/dev"; + yield return "/etc"; + yield return "/proc"; + yield return "/root"; + yield return "/sbin"; + yield return "/sys"; + yield return "/usr/bin"; + yield return "/usr/sbin"; + yield return "/var"; + } + } + + private static string NormalizeDeniedPathPrefix(string prefix) { + try { + var expanded = prefix.Trim(); + if (expanded == "~") { + expanded = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + } else if (expanded.StartsWith("~/", StringComparison.Ordinal) || expanded.StartsWith("~\\", StringComparison.Ordinal)) { + expanded = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + expanded[2..]); + } + + return Path.GetFullPath(expanded).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + } catch { + return string.Empty; + } + } } public class Config { public string BaseUrl { get; set; } = "https://api.telegram.org"; @@ -246,6 +343,14 @@ public class Config { public List SandboxieGlobalReadPaths { get; set; } = new List(); public List SandboxieGlobalClosedPaths { get; set; } = new List(); public int SandboxieToolTimeoutSeconds { get; set; } = 120; + public bool EnableCodingAgentTool { get; set; } = false; + public List CodingAgentAllowedGroupIds { get; set; } = new List(); + public List CodingAgentDeniedPathPrefixes { get; set; } = new List(); + public int CodingAgentDefaultTimeoutMinutes { get; set; } = 60; + public int CodingAgentMaxConcurrentJobs { get; set; } = 2; + public int CodingAgentMaxAutoResumeContinuations { get; set; } = 4; + public string CodingAgentPiCommand { get; set; } = "pi"; + public string CodingAgentSidecarCommand { get; set; } = "telegramsearchbot-rmux-sidecar"; } public sealed record BotApiEndpointSettings(string BaseUrl, bool IsLocalApi, string ExternalLocalBotApiBaseUrl); diff --git a/TelegramSearchBot.Common/Model/AI/LlmAgentContracts.cs b/TelegramSearchBot.Common/Model/AI/LlmAgentContracts.cs index 71645794..e9b1cc2e 100644 --- a/TelegramSearchBot.Common/Model/AI/LlmAgentContracts.cs +++ b/TelegramSearchBot.Common/Model/AI/LlmAgentContracts.cs @@ -23,6 +23,16 @@ public enum AgentChunkType { IterationLimitReached = 3 } + public enum CodingAgentJobStatus { + Pending = 0, + Running = 1, + Completed = 2, + Failed = 3, + Cancelling = 4, + Cancelled = 5, + TimedOut = 6 + } + public sealed class AgentUserSnapshot { public long UserId { get; set; } public string FirstName { get; set; } = string.Empty; @@ -86,6 +96,44 @@ public sealed class AgentExecutionTask { public int RecoveryAttempt { get; set; } } + public sealed class CodingAgentJobRequest { + public string JobId { get; set; } = Guid.NewGuid().ToString("N"); + public long ChatId { get; set; } + public long UserId { get; set; } + public long MessageId { get; set; } + public string Prompt { get; set; } = string.Empty; + public string WorkingDirectory { get; set; } = string.Empty; + public string AgentsJson { get; set; } = string.Empty; + public int TimeoutMinutes { get; set; } + public string Provider { get; set; } = string.Empty; + public string Model { get; set; } = string.Empty; + public string Tools { get; set; } = string.Empty; + public DateTime CreatedAtUtc { get; set; } = DateTime.UtcNow; + } + + public sealed class CodingAgentJobReport { + public string JobId { get; set; } = string.Empty; + public CodingAgentJobStatus Status { get; set; } = CodingAgentJobStatus.Pending; + public long ChatId { get; set; } + public long UserId { get; set; } + public long MessageId { get; set; } + public string Prompt { get; set; } = string.Empty; + public string WorkingDirectory { get; set; } = string.Empty; + public string Summary { get; set; } = string.Empty; + public string Output { get; set; } = string.Empty; + public string ErrorMessage { get; set; } = string.Empty; + public string LogPath { get; set; } = string.Empty; + public DateTime StartedAtUtc { get; set; } = DateTime.MinValue; + public DateTime CompletedAtUtc { get; set; } = DateTime.UtcNow; + } + + public sealed class CodingAgentControlCommand { + public string JobId { get; set; } = string.Empty; + public string Action { get; set; } = string.Empty; + public string Reason { get; set; } = string.Empty; + public DateTime RequestedAtUtc { get; set; } = DateTime.UtcNow; + } + public sealed class AgentStreamChunk { public string TaskId { get; set; } = string.Empty; public AgentChunkType Type { get; set; } = AgentChunkType.Snapshot; @@ -225,6 +273,9 @@ public static class LlmAgentRedisKeys { public const string SubAgentTaskQueue = "SUBAGENT_TASKS"; public const string AgentToolDefs = "AGENT_TOOL_DEFS"; public const string AgentChatBatchDueSet = "AGENT_CHAT_BATCH_DUE"; + public const string CodingAgentJobQueue = "CODING_AGENT_JOBS"; + public const string CodingAgentReportQueue = "CODING_AGENT_REPORTS"; + public const string CodingAgentActiveJobSet = "CODING_AGENT_ACTIVE_JOBS"; public static string AgentTaskState(string taskId) => $"AGENT_TASK:{taskId}"; public static string AgentSnapshot(string taskId) => $"AGENT_SNAPSHOT:{taskId}"; @@ -248,5 +299,7 @@ public static class LlmAgentRedisKeys { public static string AgentChatBatchMeta(long chatId) => $"AGENT_CHAT_BATCH:{chatId}:META"; public static string AgentChatBatchLock(long chatId) => $"AGENT_CHAT_BATCH:{chatId}:LOCK"; public static string AgentChatConfigWarning(long chatId, string warningType) => $"AGENT_CHAT_WARNING:{chatId}:{warningType}"; + public static string CodingAgentJobState(string jobId) => $"CODING_AGENT_JOB:{jobId}"; + public static string CodingAgentControl(string jobId) => $"CODING_AGENT_CONTROL:{jobId}"; } } diff --git a/TelegramSearchBot.LLM/Service/Tools/CodingAgentJobService.cs b/TelegramSearchBot.LLM/Service/Tools/CodingAgentJobService.cs new file mode 100644 index 00000000..31078c6e --- /dev/null +++ b/TelegramSearchBot.LLM/Service/Tools/CodingAgentJobService.cs @@ -0,0 +1,113 @@ +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using StackExchange.Redis; +using TelegramSearchBot.Attributes; +using TelegramSearchBot.Common; +using TelegramSearchBot.Interface; +using TelegramSearchBot.Model.AI; + +namespace TelegramSearchBot.Service.Tools { + [Injectable(Microsoft.Extensions.DependencyInjection.ServiceLifetime.Singleton)] + public sealed class CodingAgentJobService : IService { + private static readonly TimeSpan StateTtl = TimeSpan.FromDays(7); + private readonly IConnectionMultiplexer _redis; + private readonly ILogger _logger; + + public CodingAgentJobService(IConnectionMultiplexer redis, ILogger logger) { + _redis = redis; + _logger = logger; + } + + public string ServiceName => nameof(CodingAgentJobService); + + public async Task EnqueueAsync(CodingAgentJobRequest request) { + ArgumentNullException.ThrowIfNull(request); + if (string.IsNullOrWhiteSpace(request.JobId)) { + request.JobId = Guid.NewGuid().ToString("N"); + } + + var db = _redis.GetDatabase(); + var activeCount = await db.SetLengthAsync(LlmAgentRedisKeys.CodingAgentActiveJobSet); + if (activeCount >= Env.CodingAgentMaxConcurrentJobs) { + throw new InvalidOperationException($"Coding agent job limit reached: {activeCount}/{Env.CodingAgentMaxConcurrentJobs}."); + } + + request.CreatedAtUtc = DateTime.UtcNow; + var payload = JsonConvert.SerializeObject(request); + var stateKey = LlmAgentRedisKeys.CodingAgentJobState(request.JobId); + + try { + await db.HashSetAsync(stateKey, [ + new HashEntry("status", CodingAgentJobStatus.Pending.ToString()), + new HashEntry("chatId", request.ChatId), + new HashEntry("userId", request.UserId), + new HashEntry("messageId", request.MessageId), + new HashEntry("workingDirectory", request.WorkingDirectory), + new HashEntry("createdAtUtc", request.CreatedAtUtc.ToString("O")), + new HashEntry("updatedAtUtc", DateTime.UtcNow.ToString("O")), + new HashEntry("payload", payload), + new HashEntry("summary", string.Empty), + new HashEntry("error", string.Empty), + new HashEntry("logPath", string.Empty) + ]); + await db.KeyExpireAsync(stateKey, StateTtl); + await db.SetAddAsync(LlmAgentRedisKeys.CodingAgentActiveJobSet, request.JobId); + await db.ListLeftPushAsync(LlmAgentRedisKeys.CodingAgentJobQueue, payload); + return request; + } catch { + await db.SetRemoveAsync(LlmAgentRedisKeys.CodingAgentActiveJobSet, request.JobId); + throw; + } + } + + public async Task> GetStateAsync(string jobId) { + if (string.IsNullOrWhiteSpace(jobId)) { + return new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + var entries = await _redis.GetDatabase().HashGetAllAsync(LlmAgentRedisKeys.CodingAgentJobState(jobId.Trim())); + return entries.ToDictionary( + entry => entry.Name.ToString(), + entry => entry.Value.ToString(), + StringComparer.OrdinalIgnoreCase); + } + + public async Task RequestCancelAsync(string jobId, long chatId, long userId, string reason) { + var state = await GetStateAsync(jobId); + if (state.Count == 0) { + return false; + } + + if (state.TryGetValue("chatId", out var storedChatId) && + long.TryParse(storedChatId, out var parsedChatId) && + parsedChatId != chatId) { + _logger.LogWarning( + "Coding agent cancel rejected because chat does not match. JobId={JobId}, RequestChatId={RequestChatId}, StoredChatId={StoredChatId}", + jobId, + chatId, + parsedChatId); + return false; + } + + var command = new CodingAgentControlCommand { + JobId = jobId.Trim(), + Action = "cancel", + Reason = string.IsNullOrWhiteSpace(reason) ? "requested by Telegram tool caller" : reason, + RequestedAtUtc = DateTime.UtcNow + }; + + var db = _redis.GetDatabase(); + await db.StringSetAsync( + LlmAgentRedisKeys.CodingAgentControl(command.JobId), + JsonConvert.SerializeObject(command), + StateTtl); + await db.HashSetAsync(LlmAgentRedisKeys.CodingAgentJobState(command.JobId), [ + new HashEntry("status", CodingAgentJobStatus.Cancelling.ToString()), + new HashEntry("cancelRequestedByUserId", userId), + new HashEntry("cancelReason", command.Reason), + new HashEntry("updatedAtUtc", DateTime.UtcNow.ToString("O")) + ]); + return true; + } + } +} diff --git a/TelegramSearchBot.LLM/Service/Tools/CodingAgentPathPolicy.cs b/TelegramSearchBot.LLM/Service/Tools/CodingAgentPathPolicy.cs new file mode 100644 index 00000000..dbee5c7b --- /dev/null +++ b/TelegramSearchBot.LLM/Service/Tools/CodingAgentPathPolicy.cs @@ -0,0 +1,107 @@ +using Microsoft.Extensions.Logging; +using TelegramSearchBot.Attributes; +using TelegramSearchBot.Common; +using TelegramSearchBot.Interface; + +namespace TelegramSearchBot.Service.Tools { + public sealed record CodingAgentWorkspaceValidationResult(bool IsValid, string FullPath, string ErrorMessage); + + [Injectable(Microsoft.Extensions.DependencyInjection.ServiceLifetime.Singleton)] + public sealed class CodingAgentPathPolicy : IService { + private readonly ILogger _logger; + + public CodingAgentPathPolicy(ILogger logger) { + _logger = logger; + } + + public string ServiceName => nameof(CodingAgentPathPolicy); + + public CodingAgentWorkspaceValidationResult ValidateWorkspace(string workingDirectory) { + if (string.IsNullOrWhiteSpace(workingDirectory)) { + return new CodingAgentWorkspaceValidationResult(false, string.Empty, "workingDirectory is required."); + } + + string fullPath; + try { + fullPath = NormalizePath(workingDirectory); + } catch (Exception ex) { + _logger.LogWarning(ex, "Invalid coding agent working directory: {WorkingDirectory}", workingDirectory); + return new CodingAgentWorkspaceValidationResult(false, string.Empty, $"Invalid path: {ex.Message}"); + } + + if (!Directory.Exists(fullPath)) { + return new CodingAgentWorkspaceValidationResult(false, fullPath, $"Directory does not exist: {fullPath}"); + } + + if (IsRootPath(fullPath)) { + return new CodingAgentWorkspaceValidationResult(false, fullPath, "Refusing to run a coding agent at a filesystem root."); + } + + foreach (var deniedPrefix in Env.CodingAgentDeniedPathPrefixes) { + if (IsSameOrChildPath(fullPath, deniedPrefix)) { + return new CodingAgentWorkspaceValidationResult( + false, + fullPath, + $"Path is denied by CodingAgentDeniedPathPrefixes: {deniedPrefix}"); + } + } + + return new CodingAgentWorkspaceValidationResult(true, fullPath, string.Empty); + } + + private static string NormalizePath(string path) { + var expanded = Environment.ExpandEnvironmentVariables(path.Trim()); + if (expanded == "~") { + expanded = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + } else if (expanded.StartsWith("~/", StringComparison.Ordinal) || expanded.StartsWith("~\\", StringComparison.Ordinal)) { + expanded = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + expanded[2..]); + } + + return Path.GetFullPath(expanded).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + } + + private static bool IsRootPath(string fullPath) { + var root = Path.GetPathRoot(fullPath); + return !string.IsNullOrWhiteSpace(root) && + string.Equals( + fullPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), + root.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), + GetPathComparison()); + } + + private static bool IsSameOrChildPath(string fullPath, string prefix) { + if (string.IsNullOrWhiteSpace(prefix)) { + return false; + } + + string normalizedPrefix; + try { + normalizedPrefix = Path.GetFullPath(prefix).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + } catch { + return false; + } + + if (string.Equals(fullPath, normalizedPrefix, GetPathComparison())) { + return true; + } + + var prefixWithSeparator = normalizedPrefix + Path.DirectorySeparatorChar; + if (fullPath.StartsWith(prefixWithSeparator, GetPathComparison())) { + return true; + } + + if (Path.AltDirectorySeparatorChar == Path.DirectorySeparatorChar) { + return false; + } + + var altPrefixWithSeparator = normalizedPrefix + Path.AltDirectorySeparatorChar; + return fullPath.StartsWith(altPrefixWithSeparator, GetPathComparison()); + } + + private static StringComparison GetPathComparison() { + return OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + } + } +} diff --git a/TelegramSearchBot.LLM/Service/Tools/CodingAgentToolService.cs b/TelegramSearchBot.LLM/Service/Tools/CodingAgentToolService.cs new file mode 100644 index 00000000..3241ac1e --- /dev/null +++ b/TelegramSearchBot.LLM/Service/Tools/CodingAgentToolService.cs @@ -0,0 +1,150 @@ +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using TelegramSearchBot.Attributes; +using TelegramSearchBot.Common; +using TelegramSearchBot.Interface; +using TelegramSearchBot.Model; +using TelegramSearchBot.Model.AI; + +namespace TelegramSearchBot.Service.Tools { + [Injectable(Microsoft.Extensions.DependencyInjection.ServiceLifetime.Transient)] + public sealed class CodingAgentToolService : IService { + private readonly CodingAgentPathPolicy _pathPolicy; + private readonly CodingAgentJobService _jobService; + private readonly ILogger _logger; + + public CodingAgentToolService( + CodingAgentPathPolicy pathPolicy, + CodingAgentJobService jobService, + ILogger logger) { + _pathPolicy = pathPolicy; + _jobService = jobService; + _logger = logger; + } + + public string ServiceName => nameof(CodingAgentToolService); + + [BuiltInTool( + "Start a background pi coding agent job in an rmux-observable workspace. Returns immediately with a job id; the final report is posted back to Telegram and the LLM loop is resumed automatically. / 启动后台 pi coding agent 任务,立即返回 job id;结束后会把报告发回 Telegram 并自动续跑 LLM。", + Name = "run_coding_agent")] + public async Task RunCodingAgentAsync( + [BuiltInParameter("Coding task prompt for pi. / 交给 pi 的编码任务说明。")] string prompt, + [BuiltInParameter("Existing workspace directory where pi should run. / pi 运行的现有工作目录。")] string workingDirectory, + ToolContext toolContext, + [BuiltInParameter("Optional JSON array of agents, e.g. [{\"name\":\"reviewer\",\"prompt\":\"review the change\"}]. / 可选 multi-agent JSON 配置。", IsRequired = false)] string agents = "", + [BuiltInParameter("Optional timeout in minutes. / 可选超时时间(分钟)。", IsRequired = false)] int? timeoutMinutes = null, + [BuiltInParameter("Optional pi provider name. / 可选 pi provider。", IsRequired = false)] string provider = "", + [BuiltInParameter("Optional pi model pattern or id. / 可选 pi model。", IsRequired = false)] string model = "", + [BuiltInParameter("Optional pi tools option value. / 可选 pi tools 参数。", IsRequired = false)] string tools = "") { + var accessError = ValidateToolAccess(toolContext); + if (!string.IsNullOrWhiteSpace(accessError)) { + return accessError; + } + + if (string.IsNullOrWhiteSpace(prompt)) { + return "Error: prompt is required. / 错误:prompt 不能为空。"; + } + + var workspace = _pathPolicy.ValidateWorkspace(workingDirectory); + if (!workspace.IsValid) { + return $"Error: invalid workingDirectory. / 错误:workingDirectory 不可用。\n{workspace.ErrorMessage}"; + } + + var effectiveTimeout = timeoutMinutes.HasValue && timeoutMinutes.Value > 0 + ? Math.Clamp(timeoutMinutes.Value, 1, 1440) + : Env.CodingAgentDefaultTimeoutMinutes; + + try { + var request = await _jobService.EnqueueAsync(new CodingAgentJobRequest { + JobId = $"ca_{Guid.NewGuid():N}", + ChatId = toolContext.ChatId, + UserId = toolContext.UserId, + MessageId = toolContext.MessageId, + Prompt = prompt.Trim(), + WorkingDirectory = workspace.FullPath, + AgentsJson = agents?.Trim() ?? string.Empty, + TimeoutMinutes = effectiveTimeout, + Provider = provider?.Trim() ?? string.Empty, + Model = model?.Trim() ?? string.Empty, + Tools = tools?.Trim() ?? string.Empty + }); + + return JsonConvert.SerializeObject(new { + jobId = request.JobId, + status = CodingAgentJobStatus.Pending.ToString(), + workingDirectory = request.WorkingDirectory, + timeoutMinutes = request.TimeoutMinutes, + message = "已排队并在后台运行。Queued; the report will be posted back and the LLM loop will resume automatically." + }, Formatting.Indented); + } catch (Exception ex) { + _logger.LogError(ex, "Failed to enqueue coding agent job. ChatId={ChatId}, UserId={UserId}", toolContext.ChatId, toolContext.UserId); + return $"Error: failed to enqueue coding agent job. / 错误:无法排队 coding agent 任务。\n{ex.GetLogSummary()}"; + } + } + + [BuiltInTool("Get status for a background pi coding agent job. / 查询后台 pi coding agent 任务状态。", Name = "get_coding_agent_job")] + public async Task GetCodingAgentJobAsync( + [BuiltInParameter("Coding agent job id returned by run_coding_agent. / run_coding_agent 返回的 job id。")] string jobId, + ToolContext toolContext) { + var accessError = ValidateToolAccess(toolContext); + if (!string.IsNullOrWhiteSpace(accessError)) { + return accessError; + } + + var state = await _jobService.GetStateAsync(jobId); + if (state.Count == 0) { + return "Error: job not found. / 错误:找不到该 job。"; + } + + if (!IsSameChat(state, toolContext.ChatId)) { + return "Error: job belongs to a different chat. / 错误:该 job 属于其他群聊。"; + } + + return JsonConvert.SerializeObject(state, Formatting.Indented); + } + + [BuiltInTool("Request cancellation for a background pi coding agent job. / 请求取消后台 pi coding agent 任务。", Name = "cancel_coding_agent_job")] + public async Task CancelCodingAgentJobAsync( + [BuiltInParameter("Coding agent job id returned by run_coding_agent. / run_coding_agent 返回的 job id。")] string jobId, + ToolContext toolContext, + [BuiltInParameter("Optional cancellation reason. / 可选取消原因。", IsRequired = false)] string reason = "") { + var accessError = ValidateToolAccess(toolContext); + if (!string.IsNullOrWhiteSpace(accessError)) { + return accessError; + } + + var cancelled = await _jobService.RequestCancelAsync(jobId, toolContext.ChatId, toolContext.UserId, reason); + if (!cancelled) { + return "Error: job not found or cannot be cancelled from this chat. / 错误:找不到 job,或当前群聊无权取消。"; + } + + return JsonConvert.SerializeObject(new { + jobId = jobId.Trim(), + status = CodingAgentJobStatus.Cancelling.ToString(), + message = "Cancellation requested. / 已请求取消。" + }, Formatting.Indented); + } + + private static string ValidateToolAccess(ToolContext toolContext) { + if (!Env.EnableCodingAgentTool) { + return "Error: coding agent tool is disabled. / 错误:coding agent 工具未启用。"; + } + + if (toolContext == null || toolContext.ChatId == 0) { + return "Error: coding agent tool requires Telegram chat context. / 错误:coding agent 工具需要 Telegram 群聊上下文。"; + } + + if (!Env.CodingAgentAllowedGroupIds.Contains(toolContext.ChatId)) { + return "Error: this chat is not whitelisted for coding agent jobs. / 错误:当前群聊不在 coding agent 白名单内。"; + } + + return string.Empty; + } + + private static bool IsSameChat(IReadOnlyDictionary state, long chatId) { + return state.TryGetValue("chatId", out var storedChatId) && + long.TryParse(storedChatId, out var parsedChatId) && + parsedChatId == chatId; + } + } +} diff --git a/TelegramSearchBot.RmuxSidecar/Cargo.lock b/TelegramSearchBot.RmuxSidecar/Cargo.lock new file mode 100644 index 00000000..797d8bf0 --- /dev/null +++ b/TelegramSearchBot.RmuxSidecar/Cargo.lock @@ -0,0 +1,1349 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arc-swap" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" +dependencies = [ + "rustversion", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "num-traits", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "futures-core", + "memchr", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-sink", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "log" +version = "0.4.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "redis" +version = "0.27.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09d8f99a4090c89cc489a94833c901ead69bfbf3877b4867d5482e321ee875bc" +dependencies = [ + "arc-swap", + "async-trait", + "bytes", + "combine", + "futures-util", + "itertools", + "itoa", + "num-bigint", + "percent-encoding", + "pin-project-lite", + "ryu", + "sha1_smol", + "socket2 0.5.10", + "tokio", + "tokio-util", + "url", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rmux-ipc" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fad9f6a295f8dd2e124d18284985d5307861cf66629552a2d2b56cac3a9fbdc" +dependencies = [ + "libc", + "rmux-os", + "rustix", + "tokio", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "rmux-os" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fca6ded314a09605ca551c385e2be3ac67af7751c16d48f6c3332fe7b738a09" +dependencies = [ + "libc", + "rmux-types", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "rmux-proto" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24732ca60f7a674e1c9fa5eb11aa7d70958db894c5216ab72bbeb0d8b71ccbcc" +dependencies = [ + "bincode", + "rmux-types", + "serde", + "thiserror", +] + +[[package]] +name = "rmux-sdk" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "081a9824727a8e487beb6ca74d301be5e73acf71d1ff58d44f9ce8bfa1b485f4" +dependencies = [ + "libc", + "rmux-ipc", + "rmux-os", + "rmux-proto", + "rustix", + "serde", + "serde_json", + "tokio", + "windows-sys 0.61.2", +] + +[[package]] +name = "rmux-types" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6475eee45f7a1c6ac690634f8ac4dc9c3cc63dd0ba610b670f53e6972b510aa" +dependencies = [ + "serde", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "telegramsearchbot-rmux-sidecar" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "redis", + "rmux-sdk", + "serde", + "serde_json", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.4", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/TelegramSearchBot.RmuxSidecar/Cargo.toml b/TelegramSearchBot.RmuxSidecar/Cargo.toml new file mode 100644 index 00000000..cec4b1c1 --- /dev/null +++ b/TelegramSearchBot.RmuxSidecar/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "telegramsearchbot-rmux-sidecar" +version = "0.1.0" +edition = "2021" +publish = false + +[[bin]] +name = "telegramsearchbot-rmux-sidecar" +path = "src/main.rs" + +[dependencies] +anyhow = "1" +chrono = { version = "0.4", default-features = false, features = ["clock", "std"] } +clap = { version = "4", features = ["derive"] } +redis = { version = "0.27", features = ["tokio-comp"] } +rmux-sdk = "0.3.1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["fs", "io-util", "macros", "process", "rt-multi-thread", "sync", "time"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } diff --git a/TelegramSearchBot.RmuxSidecar/README.md b/TelegramSearchBot.RmuxSidecar/README.md new file mode 100644 index 00000000..05b602f1 --- /dev/null +++ b/TelegramSearchBot.RmuxSidecar/README.md @@ -0,0 +1,20 @@ +# TelegramSearchBot rmux/pi sidecar + +中文:这个 sidecar 从 TelegramSearchBot 的 Garnet/Redis 队列接收 `run_coding_agent` 任务,在后台调用 `pi --mode rpc`,并用 rmux 创建可观察的日志会话。任务结束后,sidecar 把报告推回 Redis,主进程会发送 Telegram 报告并自动续跑 LLM。 + +English: this sidecar consumes `run_coding_agent` jobs from TelegramSearchBot's Garnet/Redis queue, runs `pi --mode rpc` in the background, and creates rmux log sessions for inspection. When a job finishes, it pushes a report back to Redis so the main process can post the Telegram report and resume the LLM loop. + +Build: + +```powershell +cargo build --manifest-path TelegramSearchBot.RmuxSidecar/Cargo.toml --release +``` + +Config: + +- `EnableCodingAgentTool`: must be `true`. +- `CodingAgentAllowedGroupIds`: explicit Telegram group whitelist. +- `CodingAgentSidecarCommand`: path/name of this binary. Defaults to `telegramsearchbot-rmux-sidecar`. +- `CodingAgentPiCommand`: path/name of `pi`. Defaults to `pi`. + +The sidecar uses rmux for detached observation/log panes and direct pi RPC over stdin/stdout for protocol correctness. diff --git a/TelegramSearchBot.RmuxSidecar/src/main.rs b/TelegramSearchBot.RmuxSidecar/src/main.rs new file mode 100644 index 00000000..ee90821b --- /dev/null +++ b/TelegramSearchBot.RmuxSidecar/src/main.rs @@ -0,0 +1,820 @@ +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::{Context, Result}; +use chrono::{SecondsFormat, Utc}; +use clap::Parser; +use redis::AsyncCommands; +use rmux_sdk::{EnsureSession, Rmux}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::process::Command; +use tokio::sync::Semaphore; +use tracing::{error, info, warn}; + +const JOB_QUEUE: &str = "CODING_AGENT_JOBS"; +const REPORT_QUEUE: &str = "CODING_AGENT_REPORTS"; +const ACTIVE_JOB_SET: &str = "CODING_AGENT_ACTIVE_JOBS"; + +#[derive(Parser, Debug, Clone)] +struct Args { + #[arg(long, default_value = "127.0.0.1:6379")] + redis: String, + + #[arg(long)] + work_dir: PathBuf, + + #[arg(long, default_value = "pi")] + pi_command: String, + + #[arg(long, default_value_t = 2)] + max_concurrent: usize, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "PascalCase")] +struct CodingAgentJobRequest { + job_id: String, + chat_id: i64, + user_id: i64, + message_id: i64, + prompt: String, + working_directory: String, + agents_json: String, + timeout_minutes: i32, + provider: String, + model: String, + tools: String, + created_at_utc: String, +} + +#[derive(Debug, Clone, Copy, Serialize)] +#[allow(dead_code)] +enum CodingAgentJobStatus { + Pending, + Running, + Completed, + Failed, + Cancelling, + Cancelled, + TimedOut, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "PascalCase")] +struct CodingAgentJobReport { + job_id: String, + status: CodingAgentJobStatus, + chat_id: i64, + user_id: i64, + message_id: i64, + prompt: String, + working_directory: String, + summary: String, + output: String, + error_message: String, + log_path: String, + rmux_session_names: Vec, + started_at_utc: String, + completed_at_utc: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "PascalCase")] +struct CodingAgentControlCommand { + #[allow(dead_code)] + job_id: String, + action: String, + #[allow(dead_code)] + reason: String, +} + +#[derive(Debug, Deserialize)] +struct AgentSpec { + name: Option, + prompt: Option, + role: Option, +} + +#[derive(Debug, Clone)] +struct AgentRun { + name: String, + prompt: String, +} + +#[derive(Debug)] +struct AgentRunResult { + status: CodingAgentJobStatus, + text: String, + error: String, + rmux_session_name: Option, +} + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) + .init(); + + let args = Args::parse(); + let max_concurrent = args.max_concurrent.max(1); + tokio::fs::create_dir_all(&args.work_dir) + .await + .with_context(|| format!("failed to create work dir {}", args.work_dir.display()))?; + + let redis_url = format!("redis://{}/", args.redis); + let client = redis::Client::open(redis_url)?; + let semaphore = Arc::new(Semaphore::new(max_concurrent)); + let config = Arc::new(args); + + info!("rmux/pi sidecar started; max_concurrent={max_concurrent}"); + let mut queue_conn = client.get_multiplexed_async_connection().await?; + loop { + let result: Option<(String, String)> = redis::cmd("BRPOP") + .arg(JOB_QUEUE) + .arg(2) + .query_async(&mut queue_conn) + .await?; + + let Some((_, payload)) = result else { + continue; + }; + + let request = match serde_json::from_str::(&payload) { + Ok(request) => request, + Err(err) => { + warn!(error = %err, "failed to deserialize coding agent job"); + continue; + } + }; + + let permit = semaphore.clone().acquire_owned().await?; + let client_for_job = client.clone(); + let config_for_job = config.clone(); + tokio::spawn(async move { + let _permit = permit; + if let Err(err) = process_job(client_for_job, config_for_job, request).await { + error!(error = %err, "coding agent job processing failed"); + } + }); + } +} + +async fn process_job( + client: redis::Client, + config: Arc, + request: CodingAgentJobRequest, +) -> Result<()> { + let started_at = utc_now(); + let log_dir = config.work_dir.join("CodingAgent").join("logs"); + let session_dir = config.work_dir.join("CodingAgent").join("pi_sessions"); + tokio::fs::create_dir_all(&log_dir).await?; + tokio::fs::create_dir_all(&session_dir).await?; + let log_path = log_dir.join(format!("{}.log", request.job_id)); + append_line( + &log_path, + &format!("job {} started at {}", request.job_id, started_at), + ) + .await?; + append_line( + &log_path, + &format!( + "chat={} user={} message={} workspace={} created_at={}", + request.chat_id, + request.user_id, + request.message_id, + request.working_directory, + request.created_at_utc + ), + ) + .await?; + + let mut conn = client.get_multiplexed_async_connection().await?; + update_state( + &mut conn, + &request.job_id, + CodingAgentJobStatus::Running, + &[ + ("startedAtUtc", started_at.as_str()), + ("logPath", &log_path.to_string_lossy()), + ("updatedAtUtc", &utc_now()), + ], + ) + .await?; + + let mut rmux_session_names = Vec::new(); + let mut outputs = Vec::new(); + let mut final_status = CodingAgentJobStatus::Completed; + let mut error_message = String::new(); + let agents = parse_agents(&request); + + for agent in agents { + if is_cancel_requested(&mut conn, &request.job_id).await? { + final_status = CodingAgentJobStatus::Cancelled; + error_message = "Job was cancelled before the next agent started.".to_owned(); + break; + } + + update_state( + &mut conn, + &request.job_id, + CodingAgentJobStatus::Running, + &[ + ("currentAgent", agent.name.as_str()), + ("updatedAtUtc", &utc_now()), + ], + ) + .await?; + + let result = run_agent( + client.clone(), + config.clone(), + &request, + &agent, + &log_path, + &session_dir, + ) + .await; + + match result { + Ok(agent_result) => { + if let Some(session_name) = agent_result.rmux_session_name { + rmux_session_names.push(session_name); + } + outputs.push(format!("## {}\n{}", agent.name, agent_result.text)); + match agent_result.status { + CodingAgentJobStatus::Completed => {} + status => { + final_status = status; + error_message = agent_result.error; + break; + } + } + } + Err(err) => { + final_status = CodingAgentJobStatus::Failed; + error_message = err.to_string(); + outputs.push(format!("## {}\nError: {}", agent.name, error_message)); + break; + } + } + } + + let completed_at = utc_now(); + let output = outputs.join("\n\n"); + let summary = build_summary(final_status, &output, &error_message); + let report = CodingAgentJobReport { + job_id: request.job_id.clone(), + status: final_status, + chat_id: request.chat_id, + user_id: request.user_id, + message_id: request.message_id, + prompt: request.prompt.clone(), + working_directory: request.working_directory.clone(), + summary: summary.clone(), + output, + error_message: error_message.clone(), + log_path: log_path.to_string_lossy().to_string(), + rmux_session_names: rmux_session_names.clone(), + started_at_utc: started_at, + completed_at_utc: completed_at.clone(), + }; + + update_state( + &mut conn, + &request.job_id, + final_status, + &[ + ("summary", summary.as_str()), + ("error", error_message.as_str()), + ("completedAtUtc", completed_at.as_str()), + ( + "rmuxSessionNames", + &serde_json::to_string(&rmux_session_names)?, + ), + ("updatedAtUtc", &utc_now()), + ], + ) + .await?; + + let report_payload = serde_json::to_string(&report)?; + let _: usize = conn.lpush(REPORT_QUEUE, report_payload).await?; + cleanup_job(&mut conn, &request.job_id).await?; + append_line( + &log_path, + &format!("job {} finished with {:?}", request.job_id, final_status), + ) + .await?; + Ok(()) +} + +async fn run_agent( + client: redis::Client, + config: Arc, + request: &CodingAgentJobRequest, + agent: &AgentRun, + log_path: &Path, + session_dir: &Path, +) -> Result { + append_line(log_path, &format!("agent {} starting", agent.name)).await?; + let rmux_session_name = match ensure_rmux_observer( + &request.job_id, + &agent.name, + &request.working_directory, + log_path, + ) + .await + { + Ok(session_name) => Some(session_name), + Err(err) => { + append_line(log_path, &format!("rmux observer failed: {err}")).await?; + None + } + }; + + let mut command = Command::new(&config.pi_command); + command + .arg("--mode") + .arg("rpc") + .args(build_pi_args(request, session_dir, &agent.name)) + .current_dir(&request.working_directory) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()); + + let mut child = command + .spawn() + .with_context(|| format!("failed to spawn pi command '{}'", config.pi_command))?; + let mut stdin = child.stdin.take().context("pi stdin unavailable")?; + let stdout = child.stdout.take().context("pi stdout unavailable")?; + let stderr = child.stderr.take().context("pi stderr unavailable")?; + let stderr_log_path = log_path.to_path_buf(); + let stderr_task = tokio::spawn(async move { + let mut lines = BufReader::new(stderr).lines(); + while let Some(line) = lines.next_line().await? { + append_line(&stderr_log_path, &format!("[pi:stderr] {line}")).await?; + } + Result::<()>::Ok(()) + }); + + let prompt_command = json!({ + "id": format!("{}-{}", request.job_id, agent.name), + "type": "prompt", + "message": agent.prompt, + }); + stdin + .write_all(format!("{}\n", prompt_command).as_bytes()) + .await?; + stdin.flush().await?; + + let mut conn = client.get_multiplexed_async_connection().await?; + let mut lines = BufReader::new(stdout).lines(); + let timeout_minutes = request.timeout_minutes.max(1) as u64; + let timeout = tokio::time::sleep(Duration::from_secs(timeout_minutes * 60)); + tokio::pin!(timeout); + let mut control_interval = tokio::time::interval(Duration::from_secs(2)); + let mut assistant_text = String::new(); + let mut status = CodingAgentJobStatus::Completed; + let mut error = String::new(); + let mut saw_agent_end = false; + + loop { + tokio::select! { + _ = &mut timeout => { + status = CodingAgentJobStatus::TimedOut; + error = format!("pi agent timed out after {timeout_minutes} minutes"); + let _ = child.start_kill(); + break; + } + _ = control_interval.tick() => { + if is_cancel_requested(&mut conn, &request.job_id).await? { + status = CodingAgentJobStatus::Cancelled; + error = "cancel requested".to_owned(); + let _ = child.start_kill(); + break; + } + } + line = lines.next_line() => { + let Some(line) = line? else { + break; + }; + append_line(log_path, &format!("[pi:stdout:{}] {}", agent.name, line)).await?; + match serde_json::from_str::(&line) { + Ok(value) => { + if value.get("type").and_then(Value::as_str) == Some("response") && + value.get("success").and_then(Value::as_bool) == Some(false) { + status = CodingAgentJobStatus::Failed; + error = value.get("error").and_then(Value::as_str).unwrap_or("pi command failed").to_owned(); + break; + } + + if value.get("type").and_then(Value::as_str) == Some("message_update") { + if let Some(delta) = value + .get("assistantMessageEvent") + .and_then(|event| event.get("delta")) + .and_then(Value::as_str) { + assistant_text.push_str(delta); + } + } + + if value.get("type").and_then(Value::as_str) == Some("agent_end") { + saw_agent_end = true; + break; + } + } + Err(err) => { + append_line(log_path, &format!("failed to parse pi JSONL: {err}")).await?; + } + } + } + } + } + + if saw_agent_end { + if let Ok(Some(last_text)) = get_last_assistant_text(&mut stdin, &mut lines, log_path).await + { + if !last_text.trim().is_empty() { + assistant_text = last_text; + } + } + } + + let _ = child.start_kill(); + let exit_status = child.wait().await.ok(); + let _ = stderr_task.await; + if matches!(status, CodingAgentJobStatus::Completed) { + if let Some(exit_status) = exit_status { + if !exit_status.success() && !saw_agent_end { + status = CodingAgentJobStatus::Failed; + error = format!("pi exited with status {exit_status}"); + } + } + } + + if assistant_text.trim().is_empty() && !error.is_empty() { + assistant_text = error.clone(); + } + + append_line( + log_path, + &format!("agent {} finished with {:?}", agent.name, status), + ) + .await?; + Ok(AgentRunResult { + status, + text: assistant_text, + error, + rmux_session_name, + }) +} + +async fn get_last_assistant_text( + stdin: &mut tokio::process::ChildStdin, + lines: &mut tokio::io::Lines>, + log_path: &Path, +) -> Result> { + stdin + .write_all(b"{\"id\":\"get-last-assistant-text\",\"type\":\"get_last_assistant_text\"}\n") + .await?; + stdin.flush().await?; + + let timeout = tokio::time::sleep(Duration::from_secs(5)); + tokio::pin!(timeout); + loop { + tokio::select! { + _ = &mut timeout => return Ok(None), + line = lines.next_line() => { + let Some(line) = line? else { + return Ok(None); + }; + append_line(log_path, &format!("[pi:stdout:last] {line}")).await?; + let value: Value = match serde_json::from_str(&line) { + Ok(value) => value, + Err(_) => continue, + }; + if value.get("type").and_then(Value::as_str) == Some("response") && + value.get("command").and_then(Value::as_str) == Some("get_last_assistant_text") { + return Ok(value + .get("data") + .and_then(|data| data.get("text")) + .and_then(Value::as_str) + .map(ToOwned::to_owned)); + } + } + } + } +} + +async fn ensure_rmux_observer( + job_id: &str, + agent_name: &str, + working_directory: &str, + log_path: &Path, +) -> Result { + let session_name = sanitize_session_name(&format!("tgsb-ca-{job_id}-{agent_name}")); + let rmux = Rmux::builder().connect_or_start().await?; + let argv = tail_log_command(log_path); + rmux.ensure_session( + EnsureSession::try_named(&session_name)? + .create_or_reuse() + .detached(true) + .working_directory(working_directory) + .argv(argv), + ) + .await?; + Ok(session_name) +} + +fn tail_log_command(log_path: &Path) -> Vec { + let path = log_path.to_string_lossy(); + if cfg!(windows) { + let escaped = path.replace('\'', "''"); + vec![ + "powershell.exe".to_owned(), + "-NoLogo".to_owned(), + "-NoProfile".to_owned(), + "-Command".to_owned(), + format!( + "Write-Host 'TelegramSearchBot coding agent log: {escaped}'; if (Test-Path -LiteralPath '{escaped}') {{ Get-Content -LiteralPath '{escaped}' -Wait }} else {{ while ($true) {{ Start-Sleep -Seconds 5 }} }}" + ), + ] + } else { + let escaped = shell_single_quote(&path); + vec![ + "sh".to_owned(), + "-lc".to_owned(), + format!( + "printf '%s\\n' 'TelegramSearchBot coding agent log: {path}'; touch {escaped}; tail -n +1 -f {escaped}" + ), + ] + } +} + +fn build_pi_args( + request: &CodingAgentJobRequest, + session_dir: &Path, + agent_name: &str, +) -> Vec { + let mut args = Vec::new(); + if !request.provider.trim().is_empty() { + args.push("--provider".to_owned()); + args.push(request.provider.trim().to_owned()); + } + if !request.model.trim().is_empty() { + args.push("--model".to_owned()); + args.push(request.model.trim().to_owned()); + } + if !request.tools.trim().is_empty() { + args.push("--tools".to_owned()); + args.push(request.tools.trim().to_owned()); + } + + args.push("--session-dir".to_owned()); + args.push( + session_dir + .join(&request.job_id) + .join(sanitize_session_name(agent_name)) + .to_string_lossy() + .to_string(), + ); + args +} + +fn parse_agents(request: &CodingAgentJobRequest) -> Vec { + let agents_json = request.agents_json.trim(); + if agents_json.is_empty() { + return vec![AgentRun { + name: "primary".to_owned(), + prompt: request.prompt.clone(), + }]; + } + + if let Ok(specs) = serde_json::from_str::>(agents_json) { + let agents: Vec<_> = specs + .into_iter() + .enumerate() + .map(|(index, spec)| agent_from_spec(request, index, spec)) + .collect(); + if !agents.is_empty() { + return agents; + } + } + + if let Ok(spec) = serde_json::from_str::(agents_json) { + return vec![agent_from_spec(request, 0, spec)]; + } + + vec![AgentRun { + name: "primary".to_owned(), + prompt: format!( + "{}\n\nMulti-agent configuration supplied by caller:\n{}", + request.prompt, request.agents_json + ), + }] +} + +fn agent_from_spec(request: &CodingAgentJobRequest, index: usize, spec: AgentSpec) -> AgentRun { + let name = spec + .name + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| format!("agent-{}", index + 1)); + let prompt = match (spec.prompt, spec.role) { + (Some(prompt), _) if !prompt.trim().is_empty() => format!( + "Original task:\n{}\n\nAgent {} task:\n{}", + request.prompt, + name, + prompt.trim() + ), + (_, Some(role)) if !role.trim().is_empty() => format!( + "Original task:\n{}\n\nYou are agent {}. Role:\n{}", + request.prompt, + name, + role.trim() + ), + _ => request.prompt.clone(), + }; + + AgentRun { + name: sanitize_session_name(&name), + prompt, + } +} + +async fn update_state( + conn: &mut redis::aio::MultiplexedConnection, + job_id: &str, + status: CodingAgentJobStatus, + entries: &[(&str, &str)], +) -> Result<()> { + let key = job_state_key(job_id); + let mut values = vec![ + ("status", format!("{:?}", status)), + ("updatedAtUtc", utc_now()), + ]; + values.extend( + entries + .iter() + .map(|(key, value)| (*key, (*value).to_owned())), + ); + let _: () = conn.hset_multiple(key, &values).await?; + Ok(()) +} + +async fn cleanup_job(conn: &mut redis::aio::MultiplexedConnection, job_id: &str) -> Result<()> { + let _: usize = conn.srem(ACTIVE_JOB_SET, job_id).await?; + let _: usize = conn.del(control_key(job_id)).await?; + Ok(()) +} + +async fn is_cancel_requested( + conn: &mut redis::aio::MultiplexedConnection, + job_id: &str, +) -> Result { + let value: Option = conn.get(control_key(job_id)).await?; + let Some(value) = value else { + return Ok(false); + }; + + let command: CodingAgentControlCommand = serde_json::from_str(&value)?; + Ok(command.action.eq_ignore_ascii_case("cancel")) +} + +fn build_summary(status: CodingAgentJobStatus, output: &str, error: &str) -> String { + let mut summary = format!("Status: {:?}", status); + if !error.trim().is_empty() { + summary.push_str("\nError: "); + summary.push_str(error.trim()); + } + if !output.trim().is_empty() { + summary.push_str("\n\n"); + summary.push_str(trim_chars(output.trim(), 4000).as_str()); + } + summary +} + +async fn append_line(path: &Path, line: &str) -> Result<()> { + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + let mut file = tokio::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + .await?; + file.write_all(line.as_bytes()).await?; + file.write_all(b"\n").await?; + Ok(()) +} + +fn job_state_key(job_id: &str) -> String { + format!("CODING_AGENT_JOB:{job_id}") +} + +fn control_key(job_id: &str) -> String { + format!("CODING_AGENT_CONTROL:{job_id}") +} + +fn sanitize_session_name(value: &str) -> String { + let mut sanitized: String = value + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' { + ch + } else { + '-' + } + }) + .collect(); + while sanitized.contains("--") { + sanitized = sanitized.replace("--", "-"); + } + sanitized = sanitized.trim_matches('-').to_owned(); + if sanitized.is_empty() { + sanitized = "agent".to_owned(); + } + if sanitized.len() > 80 { + sanitized.truncate(80); + } + sanitized +} + +fn shell_single_quote(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\"'\"'")) +} + +fn trim_chars(value: &str, max_chars: usize) -> String { + if value.chars().count() <= max_chars { + return value.to_owned(); + } + let mut output: String = value.chars().take(max_chars).collect(); + output.push_str("\n... [truncated]"); + output +} + +fn utc_now() -> String { + Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn request_with_agents(agents_json: &str) -> CodingAgentJobRequest { + CodingAgentJobRequest { + job_id: "ca_test".to_owned(), + chat_id: 1, + user_id: 2, + message_id: 3, + prompt: "fix the bug".to_owned(), + working_directory: ".".to_owned(), + agents_json: agents_json.to_owned(), + timeout_minutes: 5, + provider: String::new(), + model: String::new(), + tools: String::new(), + created_at_utc: String::new(), + } + } + + #[test] + fn parse_agents_defaults_to_primary() { + let agents = parse_agents(&request_with_agents("")); + assert_eq!(agents.len(), 1); + assert_eq!(agents[0].name, "primary"); + assert_eq!(agents[0].prompt, "fix the bug"); + } + + #[test] + fn parse_agents_accepts_json_array() { + let agents = parse_agents(&request_with_agents( + r#"[{"name":"implementer","prompt":"make the change"},{"name":"reviewer","role":"review only"}]"#, + )); + assert_eq!(agents.len(), 2); + assert_eq!(agents[0].name, "implementer"); + assert!(agents[0].prompt.contains("make the change")); + assert_eq!(agents[1].name, "reviewer"); + assert!(agents[1].prompt.contains("review only")); + } + + #[test] + fn sanitize_session_name_removes_unsafe_chars() { + assert_eq!(sanitize_session_name("hello world/../x"), "hello-world-x"); + } + + #[test] + fn build_pi_args_adds_optional_flags_and_session_dir() { + let mut request = request_with_agents(""); + request.provider = "openai".to_owned(); + request.model = "gpt-5-codex".to_owned(); + request.tools = "fs,shell".to_owned(); + let args = build_pi_args(&request, Path::new("/tmp/sessions"), "primary"); + assert!(args.windows(2).any(|pair| pair == ["--provider", "openai"])); + assert!(args + .windows(2) + .any(|pair| pair == ["--model", "gpt-5-codex"])); + assert!(args.windows(2).any(|pair| pair == ["--tools", "fs,shell"])); + assert!(args.iter().any(|value| value.contains("ca_test"))); + } +} diff --git a/TelegramSearchBot.Test/Service/AI/LLM/CodingAgentToolServiceTests.cs b/TelegramSearchBot.Test/Service/AI/LLM/CodingAgentToolServiceTests.cs new file mode 100644 index 00000000..0d372d28 --- /dev/null +++ b/TelegramSearchBot.Test/Service/AI/LLM/CodingAgentToolServiceTests.cs @@ -0,0 +1,118 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Newtonsoft.Json.Linq; +using TelegramSearchBot.Common; +using TelegramSearchBot.Model; +using TelegramSearchBot.Model.AI; +using TelegramSearchBot.Service.Tools; +using Xunit; + +namespace TelegramSearchBot.Test.Service.AI.LLM { + [Collection("AgentEnvSerial")] + public sealed class CodingAgentToolServiceTests { + [Fact] + public void PathPolicy_AcceptsExistingDirectoryAndRejectsDeniedPrefix() { + var originalDeniedPrefixes = Env.CodingAgentDeniedPathPrefixes; + var tempDir = CreateTempDirectory(); + + try { + Env.CodingAgentDeniedPathPrefixes = []; + var policy = new CodingAgentPathPolicy(NullLogger.Instance); + var accepted = policy.ValidateWorkspace(tempDir); + Assert.True(accepted.IsValid); + Assert.Equal(Path.GetFullPath(tempDir).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar), accepted.FullPath); + + Env.CodingAgentDeniedPathPrefixes = [tempDir]; + var rejected = policy.ValidateWorkspace(tempDir); + Assert.False(rejected.IsValid); + Assert.Contains("denied", rejected.ErrorMessage, StringComparison.OrdinalIgnoreCase); + } finally { + Env.CodingAgentDeniedPathPrefixes = originalDeniedPrefixes; + Directory.Delete(tempDir, recursive: true); + } + } + + [Fact] + public async Task JobService_EnqueueAsync_PersistsStateAndQueuesPayload() { + var originalMaxConcurrentJobs = Env.CodingAgentMaxConcurrentJobs; + Env.CodingAgentMaxConcurrentJobs = 2; + + try { + var redis = new InMemoryRedisTestHarness(); + var service = new CodingAgentJobService(redis.Connection.Object, NullLogger.Instance); + + var request = new CodingAgentJobRequest { + JobId = "ca_test", + ChatId = -100, + UserId = 42, + MessageId = 99, + Prompt = "fix it", + WorkingDirectory = "D:\\repo", + TimeoutMinutes = 5 + }; + + await service.EnqueueAsync(request); + + var queuedPayload = redis.PeekFirstListValue(LlmAgentRedisKeys.CodingAgentJobQueue); + Assert.NotNull(queuedPayload); + Assert.Contains("\"JobId\":\"ca_test\"", queuedPayload); + + var state = redis.GetHash(LlmAgentRedisKeys.CodingAgentJobState("ca_test")); + Assert.Equal(CodingAgentJobStatus.Pending.ToString(), state["status"]); + Assert.Equal("-100", state["chatId"]); + Assert.Contains("ca_test", redis.GetSetValues(LlmAgentRedisKeys.CodingAgentActiveJobSet)); + } finally { + Env.CodingAgentMaxConcurrentJobs = originalMaxConcurrentJobs; + } + } + + [Fact] + public async Task RunCodingAgentAsync_RequiresWhitelistAndQueuesAllowedChat() { + var originalEnabled = Env.EnableCodingAgentTool; + var originalAllowedGroups = Env.CodingAgentAllowedGroupIds; + var originalDeniedPrefixes = Env.CodingAgentDeniedPathPrefixes; + var originalMaxConcurrentJobs = Env.CodingAgentMaxConcurrentJobs; + var tempDir = CreateTempDirectory(); + + try { + Env.EnableCodingAgentTool = true; + Env.CodingAgentAllowedGroupIds = [-100]; + Env.CodingAgentDeniedPathPrefixes = []; + Env.CodingAgentMaxConcurrentJobs = 2; + + var redis = new InMemoryRedisTestHarness(); + var jobService = new CodingAgentJobService(redis.Connection.Object, NullLogger.Instance); + var pathPolicy = new CodingAgentPathPolicy(NullLogger.Instance); + var tool = new CodingAgentToolService(pathPolicy, jobService, NullLogger.Instance); + + var denied = await tool.RunCodingAgentAsync( + "fix it", + tempDir, + new ToolContext { ChatId = -200, UserId = 42, MessageId = 99 }); + Assert.Contains("not whitelisted", denied, StringComparison.OrdinalIgnoreCase); + + var allowed = await tool.RunCodingAgentAsync( + "fix it", + tempDir, + new ToolContext { ChatId = -100, UserId = 42, MessageId = 99 }, + timeoutMinutes: 3); + + var json = JObject.Parse(allowed); + Assert.Equal(CodingAgentJobStatus.Pending.ToString(), json["status"]?.ToString()); + Assert.Equal(3, json["timeoutMinutes"]?.Value()); + Assert.Single(redis.GetListValues(LlmAgentRedisKeys.CodingAgentJobQueue)); + } finally { + Env.EnableCodingAgentTool = originalEnabled; + Env.CodingAgentAllowedGroupIds = originalAllowedGroups; + Env.CodingAgentDeniedPathPrefixes = originalDeniedPrefixes; + Env.CodingAgentMaxConcurrentJobs = originalMaxConcurrentJobs; + Directory.Delete(tempDir, recursive: true); + } + } + + private static string CreateTempDirectory() { + var path = Path.Combine(Path.GetTempPath(), $"tgsb-coding-agent-{Guid.NewGuid():N}"); + Directory.CreateDirectory(path); + return path; + } + } +} diff --git a/TelegramSearchBot.Test/Service/AI/LLM/InMemoryRedisTestHarness.cs b/TelegramSearchBot.Test/Service/AI/LLM/InMemoryRedisTestHarness.cs index 7b94779d..0c87459f 100644 --- a/TelegramSearchBot.Test/Service/AI/LLM/InMemoryRedisTestHarness.cs +++ b/TelegramSearchBot.Test/Service/AI/LLM/InMemoryRedisTestHarness.cs @@ -8,6 +8,7 @@ internal sealed class InMemoryRedisTestHarness { private readonly ConcurrentDictionary> _lists = new(StringComparer.OrdinalIgnoreCase); private readonly ConcurrentDictionary> _hashes = new(StringComparer.OrdinalIgnoreCase); private readonly ConcurrentDictionary _strings = new(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary> _sets = new(StringComparer.OrdinalIgnoreCase); public InMemoryRedisTestHarness() { Database = new Mock(MockBehavior.Strict); @@ -87,6 +88,28 @@ public InMemoryRedisTestHarness() { } }); + Database.Setup(d => d.SetAddAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((RedisKey key, RedisValue value, CommandFlags _) => { + lock (_gate) { + var set = _sets.GetOrAdd(key.ToString(), _ => new HashSet(StringComparer.OrdinalIgnoreCase)); + return set.Add(value.ToString()); + } + }); + + Database.Setup(d => d.SetRemoveAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((RedisKey key, RedisValue value, CommandFlags _) => { + lock (_gate) { + return _sets.TryGetValue(key.ToString(), out var set) && set.Remove(value.ToString()); + } + }); + + Database.Setup(d => d.SetLengthAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((RedisKey key, CommandFlags _) => { + lock (_gate) { + return _sets.TryGetValue(key.ToString(), out var set) ? set.Count : 0; + } + }); + Database.Setup(d => d.StringSetAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync((RedisKey key, RedisValue value, TimeSpan? _, When _, CommandFlags _) => { _strings[key.ToString()] = value.ToString(); @@ -121,6 +144,7 @@ public InMemoryRedisTestHarness() { removed |= _strings.TryRemove(key.ToString(), out _); removed |= _lists.TryRemove(key.ToString(), out _); removed |= _hashes.TryRemove(key.ToString(), out _); + removed |= _sets.TryRemove(key.ToString(), out _); return removed; }); @@ -163,6 +187,12 @@ public IReadOnlyDictionary GetHash(string key) { } } + public IReadOnlyCollection GetSetValues(string key) { + lock (_gate) { + return _sets.TryGetValue(key, out var set) ? set.ToList() : []; + } + } + public void SetHash(string key, IDictionary values) { _hashes[key] = new Dictionary(values, StringComparer.OrdinalIgnoreCase); } diff --git a/TelegramSearchBot/Extension/ServiceCollectionExtension.cs b/TelegramSearchBot/Extension/ServiceCollectionExtension.cs index 7ddb7308..46e309dc 100644 --- a/TelegramSearchBot/Extension/ServiceCollectionExtension.cs +++ b/TelegramSearchBot/Extension/ServiceCollectionExtension.cs @@ -79,6 +79,8 @@ public static IServiceCollection AddCoreServices(this IServiceCollection service .AddHostedService(sp => sp.GetRequiredService()) .AddHostedService() .AddHostedService() + .AddHostedService() + .AddHostedService() .AddSingleton>(sp => sp.GetRequiredService().Log) .AddSingleton() .AddSingleton() diff --git a/TelegramSearchBot/Service/AI/LLM/CodingAgentReportConsumer.cs b/TelegramSearchBot/Service/AI/LLM/CodingAgentReportConsumer.cs new file mode 100644 index 00000000..c03336e2 --- /dev/null +++ b/TelegramSearchBot/Service/AI/LLM/CodingAgentReportConsumer.cs @@ -0,0 +1,295 @@ +using System; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Newtonsoft.Json; +using StackExchange.Redis; +using Telegram.Bot; +using TelegramSearchBot.Common; +using TelegramSearchBot.Interface; +using TelegramSearchBot.Interface.AI.LLM; +using TelegramSearchBot.Model.AI; + +namespace TelegramSearchBot.Service.AI.LLM { + public sealed class CodingAgentReportConsumer : BackgroundService { + private readonly IConnectionMultiplexer _redis; + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + private readonly SemaphoreSlim _concurrencyLimiter = new(2, 2); + + public CodingAgentReportConsumer( + IConnectionMultiplexer redis, + IServiceProvider serviceProvider, + ILogger logger) { + _redis = redis; + _serviceProvider = serviceProvider; + _logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) { + if (!Env.EnableCodingAgentTool) { + _logger.LogDebug("Coding agent tool disabled; report consumer will not start."); + return; + } + + while (!stoppingToken.IsCancellationRequested) { + try { + var result = await _redis.GetDatabase().ExecuteAsync("BRPOP", LlmAgentRedisKeys.CodingAgentReportQueue, 2); + if (result.IsNull) { + continue; + } + + var parts = ( RedisResult[] ) result!; + if (parts.Length != 2) { + continue; + } + + var payload = parts[1].ToString(); + if (string.IsNullOrWhiteSpace(payload)) { + continue; + } + + var report = JsonConvert.DeserializeObject(payload); + if (report == null) { + continue; + } + + await _concurrencyLimiter.WaitAsync(stoppingToken); + _ = Task.Run(async () => { + try { + await ProcessReportAsync(report, stoppingToken); + } catch (Exception ex) when (ex is not OperationCanceledException) { + _logger.LogError(ex, "Failed to process coding agent report. JobId={JobId}", report.JobId); + } finally { + _concurrencyLimiter.Release(); + } + }, stoppingToken); + } catch (OperationCanceledException) { + break; + } catch (RedisException ex) { + _logger.LogWarning(ex, "Redis error in CodingAgentReportConsumer, retrying in 1 s."); + await Task.Delay(TimeSpan.FromSeconds(1), stoppingToken); + } + } + } + + private async Task ProcessReportAsync(CodingAgentJobReport report, CancellationToken stoppingToken) { + using var scope = _serviceProvider.CreateScope(); + var sendMessageService = scope.ServiceProvider.GetRequiredService(); + + await SendReportMessageAsync(sendMessageService, report); + + if (!Env.EnableOpenAI) { + _logger.LogInformation("Skipping coding agent LLM continuation because EnableOpenAI is false. JobId={JobId}", report.JobId); + return; + } + + try { + if (Env.EnableLLMAgentProcess) { + await ResumeWithAgentProcessAsync(scope.ServiceProvider, report, stoppingToken); + } else { + await ResumeInProcessAsync(scope.ServiceProvider, report, stoppingToken); + } + } catch (Exception ex) when (ex is not OperationCanceledException) { + _logger.LogError(ex, "Coding agent auto continuation failed. JobId={JobId}", report.JobId); + await SendTextAsync( + sendMessageService, + $"Coding agent report was received, but auto continuation failed:\n{ex.GetLogSummary()}", + report.ChatId, + ToTelegramMessageId(report.MessageId)); + } + } + + private async Task ResumeWithAgentProcessAsync(IServiceProvider scopedProvider, CodingAgentJobReport report, CancellationToken stoppingToken) { + var queueService = scopedProvider.GetRequiredService(); + var sendMessageService = scopedProvider.GetRequiredService(); + var botIdentity = await EnsureBotIdentityAsync(scopedProvider, stoppingToken); + var replyTo = ToTelegramMessageId(report.MessageId); + LlmContinuationSnapshot? continuationSnapshot = null; + + for (var attempt = 0; attempt <= Env.CodingAgentMaxAutoResumeContinuations; attempt++) { + AgentTaskStreamHandle handle; + if (attempt == 0) { + handle = await queueService.EnqueueSyntheticMessageTaskAsync( + report.ChatId, + report.UserId, + report.MessageId, + BuildContinuationPrompt(report), + botIdentity.UserName, + botIdentity.UserId, + report.CompletedAtUtc == DateTime.MinValue ? DateTime.UtcNow : report.CompletedAtUtc, + stoppingToken); + } else if (continuationSnapshot != null) { + handle = await queueService.EnqueueContinuationTaskAsync( + continuationSnapshot, + botIdentity.UserName, + botIdentity.UserId, + stoppingToken); + } else { + return; + } + + await sendMessageService.SendDraftStream( + handle.ReadSnapshotsAsync(stoppingToken), + report.ChatId, + replyTo, + attempt == 0 ? "Coding agent report received; continuing..." : "Continuing coding-agent follow-up...", + stoppingToken); + + var terminalChunk = await handle.Completion.WaitAsync(stoppingToken); + if (terminalChunk.Type == AgentChunkType.Error) { + await SendTextAsync(sendMessageService, $"AI Agent continuation failed: {terminalChunk.ErrorMessage}", report.ChatId, replyTo); + return; + } + + if (terminalChunk.Type == AgentChunkType.IterationLimitReached && terminalChunk.ContinuationSnapshot != null) { + continuationSnapshot = terminalChunk.ContinuationSnapshot; + continue; + } + + return; + } + + await SendTextAsync( + sendMessageService, + $"Coding agent auto continuation stopped after {Env.CodingAgentMaxAutoResumeContinuations} continuation attempts.", + report.ChatId, + replyTo); + } + + private async Task ResumeInProcessAsync(IServiceProvider scopedProvider, CodingAgentJobReport report, CancellationToken stoppingToken) { + var generalLlmService = scopedProvider.GetRequiredService(); + var sendMessageService = scopedProvider.GetRequiredService(); + var replyTo = ToTelegramMessageId(report.MessageId); + var executionContext = new LlmExecutionContext(); + var inputMessage = new Model.Data.Message { + Content = BuildContinuationPrompt(report), + DateTime = DateTime.UtcNow, + FromUserId = report.UserId, + GroupId = report.ChatId, + MessageId = report.MessageId, + ReplyToMessageId = report.MessageId, + Id = -1 + }; + + await sendMessageService.SendDraftStream( + generalLlmService.ExecAsync(inputMessage, report.ChatId, executionContext, stoppingToken), + report.ChatId, + replyTo, + "Coding agent report received; continuing...", + stoppingToken); + + var continuationSnapshot = executionContext.SnapshotData; + for (var attempt = 0; + executionContext.IterationLimitReached && + continuationSnapshot != null && + attempt < Env.CodingAgentMaxAutoResumeContinuations; + attempt++) { + executionContext = new LlmExecutionContext(); + await sendMessageService.SendDraftStream( + generalLlmService.ResumeFromSnapshotAsync(continuationSnapshot, executionContext, stoppingToken), + report.ChatId, + replyTo, + "Continuing coding-agent follow-up...", + stoppingToken); + continuationSnapshot = executionContext.SnapshotData; + } + } + + private static async Task SendReportMessageAsync(ISendMessageService sendMessageService, CodingAgentJobReport report) { + var message = BuildReportMessage(report); + await SendTextAsync(sendMessageService, message, report.ChatId, ToTelegramMessageId(report.MessageId)); + } + + private static string BuildReportMessage(CodingAgentJobReport report) { + var sb = new StringBuilder(); + sb.AppendLine("Coding agent job finished / 编码任务已结束"); + sb.AppendLine($"JobId: {report.JobId}"); + sb.AppendLine($"Status: {report.Status}"); + sb.AppendLine($"Workspace: {report.WorkingDirectory}"); + if (report.RmuxSessionNames.Count > 0) { + sb.AppendLine($"rmux: {string.Join(", ", report.RmuxSessionNames)}"); + } + if (!string.IsNullOrWhiteSpace(report.LogPath)) { + sb.AppendLine($"Log: {report.LogPath}"); + } + if (!string.IsNullOrWhiteSpace(report.Summary)) { + sb.AppendLine(); + sb.AppendLine(TrimForTelegram(report.Summary, 1800)); + } + if (!string.IsNullOrWhiteSpace(report.ErrorMessage)) { + sb.AppendLine(); + sb.AppendLine("Error:"); + sb.AppendLine(TrimForTelegram(report.ErrorMessage, 1200)); + } + return sb.ToString(); + } + + private static string BuildContinuationPrompt(CodingAgentJobReport report) { + var sb = new StringBuilder(); + sb.AppendLine("Background pi coding agent job has finished. Continue the previous Telegram conversation and complete the user's coding request based on this report."); + sb.AppendLine("后台 pi coding agent 已结束。请基于下面报告继续之前的 Telegram 对话,把剩余 agent loop 跑完并给出最终结论。"); + sb.AppendLine(); + sb.AppendLine($"JobId: {report.JobId}"); + sb.AppendLine($"Status: {report.Status}"); + sb.AppendLine($"Workspace: {report.WorkingDirectory}"); + sb.AppendLine($"LogPath: {report.LogPath}"); + if (report.RmuxSessionNames.Count > 0) { + sb.AppendLine($"RmuxSessions: {string.Join(", ", report.RmuxSessionNames)}"); + } + sb.AppendLine(); + sb.AppendLine("Original prompt:"); + sb.AppendLine(report.Prompt); + sb.AppendLine(); + sb.AppendLine("Coding agent summary:"); + sb.AppendLine(string.IsNullOrWhiteSpace(report.Summary) ? "(empty)" : report.Summary); + if (!string.IsNullOrWhiteSpace(report.Output)) { + sb.AppendLine(); + sb.AppendLine("Coding agent output:"); + sb.AppendLine(TrimForTelegram(report.Output, 12000)); + } + if (!string.IsNullOrWhiteSpace(report.ErrorMessage)) { + sb.AppendLine(); + sb.AppendLine("Coding agent error:"); + sb.AppendLine(report.ErrorMessage); + } + return sb.ToString(); + } + + private static async Task EnsureBotIdentityAsync(IServiceProvider scopedProvider, CancellationToken cancellationToken) { + var identityProvider = scopedProvider.GetRequiredService(); + var identity = await identityProvider.GetIdentityAsync(); + if (!string.IsNullOrWhiteSpace(identity.UserName)) { + return identity; + } + + var botClient = scopedProvider.GetRequiredService(); + var me = await botClient.GetMe(cancellationToken); + identityProvider.SetIdentity(me.Id, me.Username ?? string.Empty); + return new BotIdentity(me.Id, me.Username ?? string.Empty); + } + + private static async Task SendTextAsync(ISendMessageService sendMessageService, string text, long chatId, int replyTo) { + if (replyTo > 0) { + await sendMessageService.SplitAndSendTextMessage(text, chatId, replyTo); + } else { + await sendMessageService.SendMessage(text, chatId); + } + } + + private static int ToTelegramMessageId(long messageId) { + return messageId is > 0 and <= int.MaxValue ? ( int ) messageId : 0; + } + + private static string TrimForTelegram(string value, int maxLength) { + if (string.IsNullOrEmpty(value) || value.Length <= maxLength) { + return value ?? string.Empty; + } + + return value[..maxLength] + "\n... [truncated]"; + } + } +} diff --git a/TelegramSearchBot/Service/AI/LLM/LLMTaskQueueService.cs b/TelegramSearchBot/Service/AI/LLM/LLMTaskQueueService.cs index 2ce4f630..c14a64a2 100644 --- a/TelegramSearchBot/Service/AI/LLM/LLMTaskQueueService.cs +++ b/TelegramSearchBot/Service/AI/LLM/LLMTaskQueueService.cs @@ -111,6 +111,50 @@ public async Task EnqueueContinuationTaskAsync( return await EnqueueTaskAsync(task); } + public async Task EnqueueSyntheticMessageTaskAsync( + long chatId, + long userId, + long messageId, + string inputMessage, + string botName, + long botUserId, + DateTime createdAtUtc, + CancellationToken cancellationToken = default) { + if (string.IsNullOrWhiteSpace(inputMessage)) { + throw new ArgumentException("Synthetic LLM input cannot be empty.", nameof(inputMessage)); + } + + var modelName = await _dbContext.GroupSettings.AsNoTracking() + .Where(x => x.GroupId == chatId) + .Select(x => x.LLMModelName) + .FirstOrDefaultAsync(cancellationToken); + + if (string.IsNullOrWhiteSpace(modelName)) { + throw new InvalidOperationException("请先为当前群组设置模型。"); + } + + var channelInfo = await LoadChannelAsync(modelName, null, cancellationToken); + var history = await LoadHistoryAsync(chatId, cancellationToken); + var task = new AgentExecutionTask { + TaskId = Guid.NewGuid().ToString("N"), + Kind = AgentTaskKind.Message, + ChatId = chatId, + UserId = userId, + MessageId = messageId, + BotName = botName, + BotUserId = botUserId, + ModelName = modelName, + InputMessage = inputMessage, + MaxToolCycles = Env.MaxToolCycles, + Channel = channelInfo, + History = history, + CreatedAtUtc = createdAtUtc + }; + + await _agentRegistryService.EnsureAgentAsync(task.ChatId, cancellationToken); + return await EnqueueTaskAsync(task); + } + private async Task EnqueueTaskAsync(AgentExecutionTask task) { var db = _redis.GetDatabase(); var payload = JsonConvert.SerializeObject(task); diff --git a/TelegramSearchBot/Service/AI/LLM/RmuxSidecarLauncherService.cs b/TelegramSearchBot/Service/AI/LLM/RmuxSidecarLauncherService.cs new file mode 100644 index 00000000..6df97033 --- /dev/null +++ b/TelegramSearchBot/Service/AI/LLM/RmuxSidecarLauncherService.cs @@ -0,0 +1,154 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using TelegramSearchBot.Common; + +namespace TelegramSearchBot.Service.AI.LLM { + public sealed class RmuxSidecarLauncherService : IHostedService { + private readonly ILogger _logger; + private Process? _process; + + public RmuxSidecarLauncherService(ILogger logger) { + _logger = logger; + } + + public Task StartAsync(CancellationToken cancellationToken) { + if (!Env.EnableCodingAgentTool) { + _logger.LogDebug("Coding agent tool disabled; rmux sidecar will not start."); + return Task.CompletedTask; + } + + var command = ResolveCommand(Env.CodingAgentSidecarCommand); + if (string.IsNullOrWhiteSpace(command)) { + _logger.LogWarning( + "Coding agent tool is enabled but sidecar command was not found: {Command}. Install/build telegramsearchbot-rmux-sidecar or set CodingAgentSidecarCommand.", + Env.CodingAgentSidecarCommand); + return Task.CompletedTask; + } + + var startInfo = new ProcessStartInfo { + FileName = command, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + startInfo.ArgumentList.Add("--redis"); + startInfo.ArgumentList.Add($"127.0.0.1:{Env.SchedulerPort}"); + startInfo.ArgumentList.Add("--work-dir"); + startInfo.ArgumentList.Add(Env.WorkDir); + startInfo.ArgumentList.Add("--pi-command"); + startInfo.ArgumentList.Add(Env.CodingAgentPiCommand); + startInfo.ArgumentList.Add("--max-concurrent"); + startInfo.ArgumentList.Add(Env.CodingAgentMaxConcurrentJobs.ToString()); + + try { + var process = new Process { + StartInfo = startInfo, + EnableRaisingEvents = true + }; + process.OutputDataReceived += (_, e) => { + if (!string.IsNullOrWhiteSpace(e.Data)) { + _logger.LogInformation("[rmux-sidecar] {Message}", e.Data); + } + }; + process.ErrorDataReceived += (_, e) => { + if (!string.IsNullOrWhiteSpace(e.Data)) { + _logger.LogWarning("[rmux-sidecar] {Message}", e.Data); + } + }; + process.Exited += (_, _) => { + try { + _logger.LogWarning("rmux sidecar exited with code {ExitCode}", process.ExitCode); + } catch { + } + }; + + if (!process.Start()) { + _logger.LogWarning("Failed to start rmux sidecar process."); + return Task.CompletedTask; + } + + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + _process = process; + _logger.LogInformation("rmux sidecar started. Path={Path}, Pid={Pid}", command, process.Id); + } catch (Exception ex) { + _logger.LogWarning(ex, "Failed to start rmux sidecar. Command={Command}", command); + } + + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) { + try { + if (_process is { HasExited: false }) { + _process.Kill(entireProcessTree: true); + } + } catch (Exception ex) { + _logger.LogWarning(ex, "Failed to stop rmux sidecar process."); + } finally { + _process?.Dispose(); + _process = null; + } + + return Task.CompletedTask; + } + + private static string? ResolveCommand(string command) { + if (string.IsNullOrWhiteSpace(command)) { + return null; + } + + var trimmed = command.Trim(); + foreach (var candidate in EnumerateCommandCandidates(trimmed)) { + if (File.Exists(candidate)) { + return candidate; + } + } + + return null; + } + + private static IEnumerable EnumerateCommandCandidates(string command) { + var baseDirectoryCandidate = Path.Combine(AppContext.BaseDirectory, command); + foreach (var candidate in AddPlatformExtensions(baseDirectoryCandidate)) { + yield return candidate; + } + + if (Path.IsPathRooted(command) || + command.Contains(Path.DirectorySeparatorChar) || + command.Contains(Path.AltDirectorySeparatorChar)) { + foreach (var candidate in AddPlatformExtensions(command)) { + yield return candidate; + } + yield break; + } + + var path = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; + foreach (var directory in path.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) { + foreach (var candidate in AddPlatformExtensions(Path.Combine(directory, command))) { + yield return candidate; + } + } + } + + private static IEnumerable AddPlatformExtensions(string path) { + yield return path; + if (!OperatingSystem.IsWindows() || Path.HasExtension(path)) { + yield break; + } + + var pathExt = Environment.GetEnvironmentVariable("PATHEXT") ?? ".EXE;.CMD;.BAT"; + foreach (var extension in pathExt.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) { + yield return path + extension.ToLowerInvariant(); + yield return path + extension.ToUpperInvariant(); + } + } + } +} From 6ae3665d4b4cb0cbaca9666165acd5bb0a76ba64 Mon Sep 17 00:00:00 2001 From: ModerRAS Date: Mon, 1 Jun 2026 14:50:46 +0800 Subject: [PATCH 2/3] Remove rmux dependency from coding agent sidecar --- .gitignore | 4 +- Docs/README_CodingAgentTool.md | 20 +-- Docs/README_MCP.md | 2 +- .../Cargo.lock | 121 +----------------- .../Cargo.toml | 5 +- .../README.md | 18 +++ .../src/main.rs | 79 +----------- TelegramSearchBot.Common/Env.cs | 6 +- .../Service/Tools/CodingAgentToolService.cs | 2 +- TelegramSearchBot.RmuxSidecar/README.md | 20 --- .../Extension/ServiceCollectionExtension.cs | 2 +- .../AI/LLM/CodingAgentReportConsumer.cs | 6 - ...s => CodingAgentSidecarLauncherService.cs} | 24 ++-- 13 files changed, 52 insertions(+), 257 deletions(-) rename {TelegramSearchBot.RmuxSidecar => TelegramSearchBot.CodingAgentSidecar}/Cargo.lock (91%) rename {TelegramSearchBot.RmuxSidecar => TelegramSearchBot.CodingAgentSidecar}/Cargo.toml (85%) create mode 100644 TelegramSearchBot.CodingAgentSidecar/README.md rename {TelegramSearchBot.RmuxSidecar => TelegramSearchBot.CodingAgentSidecar}/src/main.rs (90%) delete mode 100644 TelegramSearchBot.RmuxSidecar/README.md rename TelegramSearchBot/Service/AI/LLM/{RmuxSidecarLauncherService.cs => CodingAgentSidecarLauncherService.cs} (82%) diff --git a/.gitignore b/.gitignore index 7c973f75..3cd3fb13 100644 --- a/.gitignore +++ b/.gitignore @@ -360,8 +360,8 @@ example/ # Moder.Update Rust build artifacts external/moder-update/**/target/ -# Rust sidecar build artifacts -/TelegramSearchBot.RmuxSidecar/target/ +# Coding agent sidecar build artifacts +/TelegramSearchBot.CodingAgentSidecar/target/ # Moder.Update C# build artifacts external/moder-update/**/bin/ diff --git a/Docs/README_CodingAgentTool.md b/Docs/README_CodingAgentTool.md index 9c2d9574..0ca422d7 100644 --- a/Docs/README_CodingAgentTool.md +++ b/Docs/README_CodingAgentTool.md @@ -2,23 +2,23 @@ ## Overview / 概览 -`run_coding_agent` starts a background `pi --mode rpc` coding job from the LLM tool loop. The tool returns immediately with a job id. A Rust sidecar consumes the job from Garnet/Redis, creates rmux observer sessions for logs, runs pi, then pushes a report back to the bot. The main process posts the report to Telegram and automatically resumes the LLM loop with that report. +`run_coding_agent` starts a background `pi --mode rpc` coding job from the LLM tool loop. The tool returns immediately with a job id. A Rust sidecar consumes the job from Garnet/Redis, runs pi through its RPC stdin/stdout protocol, then pushes a report back to the bot. The main process posts the report to Telegram and automatically resumes the LLM loop with that report. -`run_coding_agent` 会从 LLM 工具循环里启动一个后台 `pi --mode rpc` 编码任务。工具会立即返回 job id。Rust sidecar 从 Garnet/Redis 消费任务,用 rmux 创建日志观察会话,运行 pi,然后把报告推回 bot。主进程会把报告发到 Telegram,并基于报告自动续跑 LLM。 +`run_coding_agent` 会从 LLM 工具循环里启动一个后台 `pi --mode rpc` 编码任务。工具会立即返回 job id。Rust sidecar 从 Garnet/Redis 消费任务,通过 pi 的 RPC stdin/stdout 协议运行任务,然后把报告推回 bot。主进程会把报告发到 Telegram,并基于报告自动续跑 LLM。 ## Architecture / 架构 1. LLM calls `run_coding_agent(prompt, workingDirectory, ...)`. 2. Main process validates the chat whitelist and workspace path guardrails. 3. Main process writes `CodingAgentJobRequest` to `CODING_AGENT_JOBS`. -4. `telegramsearchbot-rmux-sidecar` runs pi in RPC mode and keeps an rmux log session available for inspection. +4. `telegramsearchbot-coding-agent-sidecar` runs pi in RPC mode and writes logs under the TelegramSearchBot work directory. 5. Sidecar writes `CodingAgentJobReport` to `CODING_AGENT_REPORTS`. 6. Main process sends a Telegram report and resumes the LLM task automatically. 1. LLM 调用 `run_coding_agent(prompt, workingDirectory, ...)`。 2. 主进程校验群白名单和工作目录护栏。 3. 主进程把 `CodingAgentJobRequest` 写入 `CODING_AGENT_JOBS`。 -4. `telegramsearchbot-rmux-sidecar` 以 RPC 模式运行 pi,并创建可检查的 rmux 日志会话。 +4. `telegramsearchbot-coding-agent-sidecar` 以 RPC 模式运行 pi,并把日志写入 TelegramSearchBot 工作目录。 5. Sidecar 把 `CodingAgentJobReport` 写入 `CODING_AGENT_REPORTS`。 6. 主进程发送 Telegram 报告并自动恢复 LLM 任务。 @@ -34,7 +34,7 @@ Add these fields to `%LOCALAPPDATA%/TelegramSearchBot/Config.json`: "CodingAgentMaxConcurrentJobs": 2, "CodingAgentMaxAutoResumeContinuations": 4, "CodingAgentPiCommand": "pi", - "CodingAgentSidecarCommand": "telegramsearchbot-rmux-sidecar", + "CodingAgentSidecarCommand": "telegramsearchbot-coding-agent-sidecar", "CodingAgentDeniedPathPrefixes": [] } ``` @@ -47,12 +47,12 @@ Add these fields to `%LOCALAPPDATA%/TelegramSearchBot/Config.json`: Build the sidecar: ```powershell -cargo build --manifest-path TelegramSearchBot.RmuxSidecar/Cargo.toml --release +cargo build --manifest-path TelegramSearchBot.CodingAgentSidecar/Cargo.toml --release ``` -Put the resulting `telegramsearchbot-rmux-sidecar.exe` on `PATH`, next to the bot executable, or set `CodingAgentSidecarCommand` to its full path. +Put the resulting `telegramsearchbot-coding-agent-sidecar.exe` on `PATH`, next to the bot executable, or set `CodingAgentSidecarCommand` to its full path. -把生成的 `telegramsearchbot-rmux-sidecar.exe` 放到 `PATH`、bot 可执行文件旁边,或把 `CodingAgentSidecarCommand` 设置为完整路径。 +把生成的 `telegramsearchbot-coding-agent-sidecar.exe` 放到 `PATH`、bot 可执行文件旁边,或把 `CodingAgentSidecarCommand` 设置为完整路径。 ## Multi-Agent / 多 Agent @@ -65,6 +65,6 @@ The optional `agents` parameter accepts JSON: ] ``` -Agents run sequentially in the same workspace to avoid conflicting edits. Each agent gets its own pi session directory and rmux observer session. +Agents run sequentially in the same workspace to avoid conflicting edits. Each agent gets its own pi session directory. -多 agent 会在同一个工作目录里顺序运行,避免并发修改互相冲突。每个 agent 都有独立的 pi session 目录和 rmux 观察会话。 +多 agent 会在同一个工作目录里顺序运行,避免并发修改互相冲突。每个 agent 都有独立的 pi session 目录。 diff --git a/Docs/README_MCP.md b/Docs/README_MCP.md index 53eca2f6..bf812842 100644 --- a/Docs/README_MCP.md +++ b/Docs/README_MCP.md @@ -96,7 +96,7 @@ API 地址和 API Key 来自该模型关联的 LLM 渠道,因此可通过 `新 | 工具名称 | 描述 | 参数 | |---------|------|------| -| `run_coding_agent` | 启动后台 `pi --mode rpc` 编码任务,任务由 rmux sidecar 管理;工具立即返回 job id,任务完成后会发回报告并自动续跑 LLM。 / Starts a background `pi --mode rpc` coding job managed by the rmux sidecar; returns a job id immediately, then posts a report and resumes the LLM loop when done. | `prompt`, `workingDirectory`, `agents?`, `timeoutMinutes?`, `provider?`, `model?`, `tools?` | +| `run_coding_agent` | 启动后台 `pi --mode rpc` 编码任务,任务由 coding-agent sidecar 管理;工具立即返回 job id,任务完成后会发回报告并自动续跑 LLM。 / Starts a background `pi --mode rpc` coding job managed by the coding-agent sidecar; returns a job id immediately, then posts a report and resumes the LLM loop when done. | `prompt`, `workingDirectory`, `agents?`, `timeoutMinutes?`, `provider?`, `model?`, `tools?` | | `get_coding_agent_job` | 查询后台编码任务状态。 / Gets background coding job status. | `jobId` | | `cancel_coding_agent_job` | 请求取消后台编码任务。 / Requests cancellation for a background coding job. | `jobId`, `reason?` | diff --git a/TelegramSearchBot.RmuxSidecar/Cargo.lock b/TelegramSearchBot.CodingAgentSidecar/Cargo.lock similarity index 91% rename from TelegramSearchBot.RmuxSidecar/Cargo.lock rename to TelegramSearchBot.CodingAgentSidecar/Cargo.lock index 797d8bf0..3c280b47 100644 --- a/TelegramSearchBot.RmuxSidecar/Cargo.lock +++ b/TelegramSearchBot.CodingAgentSidecar/Cargo.lock @@ -102,21 +102,6 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" -[[package]] -name = "bincode" -version = "1.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" -dependencies = [ - "serde", -] - -[[package]] -name = "bitflags" -version = "2.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" - [[package]] name = "bumpalo" version = "3.20.3" @@ -473,12 +458,6 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" -[[package]] -name = "linux-raw-sys" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" - [[package]] name = "litemap" version = "0.8.2" @@ -646,83 +625,6 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" -[[package]] -name = "rmux-ipc" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fad9f6a295f8dd2e124d18284985d5307861cf66629552a2d2b56cac3a9fbdc" -dependencies = [ - "libc", - "rmux-os", - "rustix", - "tokio", - "tracing", - "windows-sys 0.61.2", -] - -[[package]] -name = "rmux-os" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fca6ded314a09605ca551c385e2be3ac67af7751c16d48f6c3332fe7b738a09" -dependencies = [ - "libc", - "rmux-types", - "rustix", - "windows-sys 0.61.2", -] - -[[package]] -name = "rmux-proto" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24732ca60f7a674e1c9fa5eb11aa7d70958db894c5216ab72bbeb0d8b71ccbcc" -dependencies = [ - "bincode", - "rmux-types", - "serde", - "thiserror", -] - -[[package]] -name = "rmux-sdk" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "081a9824727a8e487beb6ca74d301be5e73acf71d1ff58d44f9ce8bfa1b485f4" -dependencies = [ - "libc", - "rmux-ipc", - "rmux-os", - "rmux-proto", - "rustix", - "serde", - "serde_json", - "tokio", - "windows-sys 0.61.2", -] - -[[package]] -name = "rmux-types" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6475eee45f7a1c6ac690634f8ac4dc9c3cc63dd0ba610b670f53e6972b510aa" -dependencies = [ - "serde", -] - -[[package]] -name = "rustix" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.61.2", -] - [[package]] name = "rustversion" version = "1.0.22" @@ -876,14 +778,13 @@ dependencies = [ ] [[package]] -name = "telegramsearchbot-rmux-sidecar" +name = "telegramsearchbot-coding-agent-sidecar" version = "0.1.0" dependencies = [ "anyhow", "chrono", "clap", "redis", - "rmux-sdk", "serde", "serde_json", "tokio", @@ -891,26 +792,6 @@ dependencies = [ "tracing-subscriber", ] -[[package]] -name = "thiserror" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" -dependencies = [ - "thiserror-impl", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "thread_local" version = "1.1.9" diff --git a/TelegramSearchBot.RmuxSidecar/Cargo.toml b/TelegramSearchBot.CodingAgentSidecar/Cargo.toml similarity index 85% rename from TelegramSearchBot.RmuxSidecar/Cargo.toml rename to TelegramSearchBot.CodingAgentSidecar/Cargo.toml index cec4b1c1..551ee81c 100644 --- a/TelegramSearchBot.RmuxSidecar/Cargo.toml +++ b/TelegramSearchBot.CodingAgentSidecar/Cargo.toml @@ -1,11 +1,11 @@ [package] -name = "telegramsearchbot-rmux-sidecar" +name = "telegramsearchbot-coding-agent-sidecar" version = "0.1.0" edition = "2021" publish = false [[bin]] -name = "telegramsearchbot-rmux-sidecar" +name = "telegramsearchbot-coding-agent-sidecar" path = "src/main.rs" [dependencies] @@ -13,7 +13,6 @@ anyhow = "1" chrono = { version = "0.4", default-features = false, features = ["clock", "std"] } clap = { version = "4", features = ["derive"] } redis = { version = "0.27", features = ["tokio-comp"] } -rmux-sdk = "0.3.1" serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1", features = ["fs", "io-util", "macros", "process", "rt-multi-thread", "sync", "time"] } diff --git a/TelegramSearchBot.CodingAgentSidecar/README.md b/TelegramSearchBot.CodingAgentSidecar/README.md new file mode 100644 index 00000000..acb9e6dd --- /dev/null +++ b/TelegramSearchBot.CodingAgentSidecar/README.md @@ -0,0 +1,18 @@ +# TelegramSearchBot coding-agent sidecar + +中文:这个 sidecar 从 TelegramSearchBot 的 Garnet/Redis 队列接收 `run_coding_agent` 任务,在后台调用 `pi --mode rpc`,并通过 pi 的 RPC stdin/stdout 协议收集结果。任务结束后,sidecar 把报告推回 Redis,主进程会发送 Telegram 报告并自动续跑 LLM。 + +English: this sidecar consumes `run_coding_agent` jobs from TelegramSearchBot's Garnet/Redis queue, runs `pi --mode rpc` in the background, and collects results through pi's RPC stdin/stdout protocol. When a job finishes, it pushes a report back to Redis so the main process can post the Telegram report and resume the LLM loop. + +Build: + +```powershell +cargo build --manifest-path TelegramSearchBot.CodingAgentSidecar/Cargo.toml --release +``` + +Config: + +- `EnableCodingAgentTool`: must be `true`. +- `CodingAgentAllowedGroupIds`: explicit Telegram group whitelist. +- `CodingAgentSidecarCommand`: path/name of this binary. Defaults to `telegramsearchbot-coding-agent-sidecar`. +- `CodingAgentPiCommand`: path/name of `pi`. Defaults to `pi`. diff --git a/TelegramSearchBot.RmuxSidecar/src/main.rs b/TelegramSearchBot.CodingAgentSidecar/src/main.rs similarity index 90% rename from TelegramSearchBot.RmuxSidecar/src/main.rs rename to TelegramSearchBot.CodingAgentSidecar/src/main.rs index ee90821b..702112de 100644 --- a/TelegramSearchBot.RmuxSidecar/src/main.rs +++ b/TelegramSearchBot.CodingAgentSidecar/src/main.rs @@ -6,7 +6,6 @@ use anyhow::{Context, Result}; use chrono::{SecondsFormat, Utc}; use clap::Parser; use redis::AsyncCommands; -use rmux_sdk::{EnsureSession, Rmux}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; @@ -76,7 +75,6 @@ struct CodingAgentJobReport { output: String, error_message: String, log_path: String, - rmux_session_names: Vec, started_at_utc: String, completed_at_utc: String, } @@ -109,7 +107,6 @@ struct AgentRunResult { status: CodingAgentJobStatus, text: String, error: String, - rmux_session_name: Option, } #[tokio::main] @@ -129,7 +126,7 @@ async fn main() -> Result<()> { let semaphore = Arc::new(Semaphore::new(max_concurrent)); let config = Arc::new(args); - info!("rmux/pi sidecar started; max_concurrent={max_concurrent}"); + info!("coding agent sidecar started; max_concurrent={max_concurrent}"); let mut queue_conn = client.get_multiplexed_async_connection().await?; loop { let result: Option<(String, String)> = redis::cmd("BRPOP") @@ -204,7 +201,6 @@ async fn process_job( ) .await?; - let mut rmux_session_names = Vec::new(); let mut outputs = Vec::new(); let mut final_status = CodingAgentJobStatus::Completed; let mut error_message = String::new(); @@ -240,9 +236,6 @@ async fn process_job( match result { Ok(agent_result) => { - if let Some(session_name) = agent_result.rmux_session_name { - rmux_session_names.push(session_name); - } outputs.push(format!("## {}\n{}", agent.name, agent_result.text)); match agent_result.status { CodingAgentJobStatus::Completed => {} @@ -277,7 +270,6 @@ async fn process_job( output, error_message: error_message.clone(), log_path: log_path.to_string_lossy().to_string(), - rmux_session_names: rmux_session_names.clone(), started_at_utc: started_at, completed_at_utc: completed_at.clone(), }; @@ -290,10 +282,6 @@ async fn process_job( ("summary", summary.as_str()), ("error", error_message.as_str()), ("completedAtUtc", completed_at.as_str()), - ( - "rmuxSessionNames", - &serde_json::to_string(&rmux_session_names)?, - ), ("updatedAtUtc", &utc_now()), ], ) @@ -319,21 +307,6 @@ async fn run_agent( session_dir: &Path, ) -> Result { append_line(log_path, &format!("agent {} starting", agent.name)).await?; - let rmux_session_name = match ensure_rmux_observer( - &request.job_id, - &agent.name, - &request.working_directory, - log_path, - ) - .await - { - Ok(session_name) => Some(session_name), - Err(err) => { - append_line(log_path, &format!("rmux observer failed: {err}")).await?; - None - } - }; - let mut command = Command::new(&config.pi_command); command .arg("--mode") @@ -466,7 +439,6 @@ async fn run_agent( status, text: assistant_text, error, - rmux_session_name, }) } @@ -507,51 +479,6 @@ async fn get_last_assistant_text( } } -async fn ensure_rmux_observer( - job_id: &str, - agent_name: &str, - working_directory: &str, - log_path: &Path, -) -> Result { - let session_name = sanitize_session_name(&format!("tgsb-ca-{job_id}-{agent_name}")); - let rmux = Rmux::builder().connect_or_start().await?; - let argv = tail_log_command(log_path); - rmux.ensure_session( - EnsureSession::try_named(&session_name)? - .create_or_reuse() - .detached(true) - .working_directory(working_directory) - .argv(argv), - ) - .await?; - Ok(session_name) -} - -fn tail_log_command(log_path: &Path) -> Vec { - let path = log_path.to_string_lossy(); - if cfg!(windows) { - let escaped = path.replace('\'', "''"); - vec![ - "powershell.exe".to_owned(), - "-NoLogo".to_owned(), - "-NoProfile".to_owned(), - "-Command".to_owned(), - format!( - "Write-Host 'TelegramSearchBot coding agent log: {escaped}'; if (Test-Path -LiteralPath '{escaped}') {{ Get-Content -LiteralPath '{escaped}' -Wait }} else {{ while ($true) {{ Start-Sleep -Seconds 5 }} }}" - ), - ] - } else { - let escaped = shell_single_quote(&path); - vec![ - "sh".to_owned(), - "-lc".to_owned(), - format!( - "printf '%s\\n' 'TelegramSearchBot coding agent log: {path}'; touch {escaped}; tail -n +1 -f {escaped}" - ), - ] - } -} - fn build_pi_args( request: &CodingAgentJobRequest, session_dir: &Path, @@ -740,10 +667,6 @@ fn sanitize_session_name(value: &str) -> String { sanitized } -fn shell_single_quote(value: &str) -> String { - format!("'{}'", value.replace('\'', "'\"'\"'")) -} - fn trim_chars(value: &str, max_chars: usize) -> String { if value.chars().count() <= max_chars { return value.to_owned(); diff --git a/TelegramSearchBot.Common/Env.cs b/TelegramSearchBot.Common/Env.cs index e5cadcd9..3f74d6f6 100644 --- a/TelegramSearchBot.Common/Env.cs +++ b/TelegramSearchBot.Common/Env.cs @@ -90,7 +90,7 @@ static Env() { ? "pi" : config.CodingAgentPiCommand.Trim(); CodingAgentSidecarCommand = string.IsNullOrWhiteSpace(config.CodingAgentSidecarCommand) - ? "telegramsearchbot-rmux-sidecar" + ? "telegramsearchbot-coding-agent-sidecar" : config.CodingAgentSidecarCommand.Trim(); } @@ -187,7 +187,7 @@ private static string NormalizeBaseUrl(string? baseUrl, string fallback) { public static int CodingAgentMaxConcurrentJobs { get; set; } = 2; public static int CodingAgentMaxAutoResumeContinuations { get; set; } = 4; public static string CodingAgentPiCommand { get; set; } = "pi"; - public static string CodingAgentSidecarCommand { get; set; } = "telegramsearchbot-rmux-sidecar"; + public static string CodingAgentSidecarCommand { get; set; } = "telegramsearchbot-coding-agent-sidecar"; public static Dictionary Configuration { get; set; } = new Dictionary(); @@ -350,7 +350,7 @@ public class Config { public int CodingAgentMaxConcurrentJobs { get; set; } = 2; public int CodingAgentMaxAutoResumeContinuations { get; set; } = 4; public string CodingAgentPiCommand { get; set; } = "pi"; - public string CodingAgentSidecarCommand { get; set; } = "telegramsearchbot-rmux-sidecar"; + public string CodingAgentSidecarCommand { get; set; } = "telegramsearchbot-coding-agent-sidecar"; } public sealed record BotApiEndpointSettings(string BaseUrl, bool IsLocalApi, string ExternalLocalBotApiBaseUrl); diff --git a/TelegramSearchBot.LLM/Service/Tools/CodingAgentToolService.cs b/TelegramSearchBot.LLM/Service/Tools/CodingAgentToolService.cs index 3241ac1e..e900936d 100644 --- a/TelegramSearchBot.LLM/Service/Tools/CodingAgentToolService.cs +++ b/TelegramSearchBot.LLM/Service/Tools/CodingAgentToolService.cs @@ -25,7 +25,7 @@ public CodingAgentToolService( public string ServiceName => nameof(CodingAgentToolService); [BuiltInTool( - "Start a background pi coding agent job in an rmux-observable workspace. Returns immediately with a job id; the final report is posted back to Telegram and the LLM loop is resumed automatically. / 启动后台 pi coding agent 任务,立即返回 job id;结束后会把报告发回 Telegram 并自动续跑 LLM。", + "Start a background pi coding agent job. Returns immediately with a job id; the final report is posted back to Telegram and the LLM loop is resumed automatically. / 启动后台 pi coding agent 任务,立即返回 job id;结束后会把报告发回 Telegram 并自动续跑 LLM。", Name = "run_coding_agent")] public async Task RunCodingAgentAsync( [BuiltInParameter("Coding task prompt for pi. / 交给 pi 的编码任务说明。")] string prompt, diff --git a/TelegramSearchBot.RmuxSidecar/README.md b/TelegramSearchBot.RmuxSidecar/README.md deleted file mode 100644 index 05b602f1..00000000 --- a/TelegramSearchBot.RmuxSidecar/README.md +++ /dev/null @@ -1,20 +0,0 @@ -# TelegramSearchBot rmux/pi sidecar - -中文:这个 sidecar 从 TelegramSearchBot 的 Garnet/Redis 队列接收 `run_coding_agent` 任务,在后台调用 `pi --mode rpc`,并用 rmux 创建可观察的日志会话。任务结束后,sidecar 把报告推回 Redis,主进程会发送 Telegram 报告并自动续跑 LLM。 - -English: this sidecar consumes `run_coding_agent` jobs from TelegramSearchBot's Garnet/Redis queue, runs `pi --mode rpc` in the background, and creates rmux log sessions for inspection. When a job finishes, it pushes a report back to Redis so the main process can post the Telegram report and resume the LLM loop. - -Build: - -```powershell -cargo build --manifest-path TelegramSearchBot.RmuxSidecar/Cargo.toml --release -``` - -Config: - -- `EnableCodingAgentTool`: must be `true`. -- `CodingAgentAllowedGroupIds`: explicit Telegram group whitelist. -- `CodingAgentSidecarCommand`: path/name of this binary. Defaults to `telegramsearchbot-rmux-sidecar`. -- `CodingAgentPiCommand`: path/name of `pi`. Defaults to `pi`. - -The sidecar uses rmux for detached observation/log panes and direct pi RPC over stdin/stdout for protocol correctness. diff --git a/TelegramSearchBot/Extension/ServiceCollectionExtension.cs b/TelegramSearchBot/Extension/ServiceCollectionExtension.cs index 46e309dc..33440f8e 100644 --- a/TelegramSearchBot/Extension/ServiceCollectionExtension.cs +++ b/TelegramSearchBot/Extension/ServiceCollectionExtension.cs @@ -79,7 +79,7 @@ public static IServiceCollection AddCoreServices(this IServiceCollection service .AddHostedService(sp => sp.GetRequiredService()) .AddHostedService() .AddHostedService() - .AddHostedService() + .AddHostedService() .AddHostedService() .AddSingleton>(sp => sp.GetRequiredService().Log) .AddSingleton() diff --git a/TelegramSearchBot/Service/AI/LLM/CodingAgentReportConsumer.cs b/TelegramSearchBot/Service/AI/LLM/CodingAgentReportConsumer.cs index c03336e2..957ffedb 100644 --- a/TelegramSearchBot/Service/AI/LLM/CodingAgentReportConsumer.cs +++ b/TelegramSearchBot/Service/AI/LLM/CodingAgentReportConsumer.cs @@ -210,9 +210,6 @@ private static string BuildReportMessage(CodingAgentJobReport report) { sb.AppendLine($"JobId: {report.JobId}"); sb.AppendLine($"Status: {report.Status}"); sb.AppendLine($"Workspace: {report.WorkingDirectory}"); - if (report.RmuxSessionNames.Count > 0) { - sb.AppendLine($"rmux: {string.Join(", ", report.RmuxSessionNames)}"); - } if (!string.IsNullOrWhiteSpace(report.LogPath)) { sb.AppendLine($"Log: {report.LogPath}"); } @@ -237,9 +234,6 @@ private static string BuildContinuationPrompt(CodingAgentJobReport report) { sb.AppendLine($"Status: {report.Status}"); sb.AppendLine($"Workspace: {report.WorkingDirectory}"); sb.AppendLine($"LogPath: {report.LogPath}"); - if (report.RmuxSessionNames.Count > 0) { - sb.AppendLine($"RmuxSessions: {string.Join(", ", report.RmuxSessionNames)}"); - } sb.AppendLine(); sb.AppendLine("Original prompt:"); sb.AppendLine(report.Prompt); diff --git a/TelegramSearchBot/Service/AI/LLM/RmuxSidecarLauncherService.cs b/TelegramSearchBot/Service/AI/LLM/CodingAgentSidecarLauncherService.cs similarity index 82% rename from TelegramSearchBot/Service/AI/LLM/RmuxSidecarLauncherService.cs rename to TelegramSearchBot/Service/AI/LLM/CodingAgentSidecarLauncherService.cs index 6df97033..f2ddcd0c 100644 --- a/TelegramSearchBot/Service/AI/LLM/RmuxSidecarLauncherService.cs +++ b/TelegramSearchBot/Service/AI/LLM/CodingAgentSidecarLauncherService.cs @@ -9,24 +9,24 @@ using TelegramSearchBot.Common; namespace TelegramSearchBot.Service.AI.LLM { - public sealed class RmuxSidecarLauncherService : IHostedService { - private readonly ILogger _logger; + public sealed class CodingAgentSidecarLauncherService : IHostedService { + private readonly ILogger _logger; private Process? _process; - public RmuxSidecarLauncherService(ILogger logger) { + public CodingAgentSidecarLauncherService(ILogger logger) { _logger = logger; } public Task StartAsync(CancellationToken cancellationToken) { if (!Env.EnableCodingAgentTool) { - _logger.LogDebug("Coding agent tool disabled; rmux sidecar will not start."); + _logger.LogDebug("Coding agent tool disabled; coding agent sidecar will not start."); return Task.CompletedTask; } var command = ResolveCommand(Env.CodingAgentSidecarCommand); if (string.IsNullOrWhiteSpace(command)) { _logger.LogWarning( - "Coding agent tool is enabled but sidecar command was not found: {Command}. Install/build telegramsearchbot-rmux-sidecar or set CodingAgentSidecarCommand.", + "Coding agent tool is enabled but sidecar command was not found: {Command}. Install/build telegramsearchbot-coding-agent-sidecar or set CodingAgentSidecarCommand.", Env.CodingAgentSidecarCommand); return Task.CompletedTask; } @@ -54,32 +54,32 @@ public Task StartAsync(CancellationToken cancellationToken) { }; process.OutputDataReceived += (_, e) => { if (!string.IsNullOrWhiteSpace(e.Data)) { - _logger.LogInformation("[rmux-sidecar] {Message}", e.Data); + _logger.LogInformation("[coding-agent-sidecar] {Message}", e.Data); } }; process.ErrorDataReceived += (_, e) => { if (!string.IsNullOrWhiteSpace(e.Data)) { - _logger.LogWarning("[rmux-sidecar] {Message}", e.Data); + _logger.LogWarning("[coding-agent-sidecar] {Message}", e.Data); } }; process.Exited += (_, _) => { try { - _logger.LogWarning("rmux sidecar exited with code {ExitCode}", process.ExitCode); + _logger.LogWarning("Coding agent sidecar exited with code {ExitCode}", process.ExitCode); } catch { } }; if (!process.Start()) { - _logger.LogWarning("Failed to start rmux sidecar process."); + _logger.LogWarning("Failed to start coding agent sidecar process."); return Task.CompletedTask; } process.BeginOutputReadLine(); process.BeginErrorReadLine(); _process = process; - _logger.LogInformation("rmux sidecar started. Path={Path}, Pid={Pid}", command, process.Id); + _logger.LogInformation("Coding agent sidecar started. Path={Path}, Pid={Pid}", command, process.Id); } catch (Exception ex) { - _logger.LogWarning(ex, "Failed to start rmux sidecar. Command={Command}", command); + _logger.LogWarning(ex, "Failed to start coding agent sidecar. Command={Command}", command); } return Task.CompletedTask; @@ -91,7 +91,7 @@ public Task StopAsync(CancellationToken cancellationToken) { _process.Kill(entireProcessTree: true); } } catch (Exception ex) { - _logger.LogWarning(ex, "Failed to stop rmux sidecar process."); + _logger.LogWarning(ex, "Failed to stop coding agent sidecar process."); } finally { _process?.Dispose(); _process = null; From af2fac8e1d1d16f70539f7eec6abcb8918d35071 Mon Sep 17 00:00:00 2001 From: ModerRAS Date: Mon, 1 Jun 2026 15:00:33 +0800 Subject: [PATCH 3/3] Address coding agent review feedback --- .../src/main.rs | 176 ++++++++++++------ TelegramSearchBot.Common/Env.cs | 6 +- .../Service/Tools/CodingAgentJobService.cs | 82 +++++--- .../Service/Tools/CodingAgentToolService.cs | 2 +- .../AI/LLM/InMemoryRedisTestHarness.cs | 33 ++++ .../AI/LLM/CodingAgentReportConsumer.cs | 35 +++- 6 files changed, 246 insertions(+), 88 deletions(-) diff --git a/TelegramSearchBot.CodingAgentSidecar/src/main.rs b/TelegramSearchBot.CodingAgentSidecar/src/main.rs index 702112de..39e8f00e 100644 --- a/TelegramSearchBot.CodingAgentSidecar/src/main.rs +++ b/TelegramSearchBot.CodingAgentSidecar/src/main.rs @@ -109,6 +109,13 @@ struct AgentRunResult { error: String, } +#[derive(Debug)] +struct JobOutcome { + status: CodingAgentJobStatus, + output: String, + error: String, +} + #[tokio::main] async fn main() -> Result<()> { tracing_subscriber::fmt() @@ -127,13 +134,21 @@ async fn main() -> Result<()> { let config = Arc::new(args); info!("coding agent sidecar started; max_concurrent={max_concurrent}"); - let mut queue_conn = client.get_multiplexed_async_connection().await?; + let mut queue_conn = connect_with_retry(&client).await?; loop { - let result: Option<(String, String)> = redis::cmd("BRPOP") + let result: Option<(String, String)> = match redis::cmd("BRPOP") .arg(JOB_QUEUE) .arg(2) .query_async(&mut queue_conn) - .await?; + .await + { + Ok(result) => result, + Err(err) => { + warn!(error = %err, "BRPOP failed; reconnecting Redis queue connection"); + queue_conn = connect_with_retry(&client).await?; + continue; + } + }; let Some((_, payload)) = result else { continue; @@ -159,6 +174,18 @@ async fn main() -> Result<()> { } } +async fn connect_with_retry(client: &redis::Client) -> Result { + loop { + match client.get_multiplexed_async_connection().await { + Ok(conn) => return Ok(conn), + Err(err) => { + warn!(error = %err, "failed to connect Redis; retrying in 1 s"); + tokio::time::sleep(Duration::from_secs(1)).await; + } + } + } +} + async fn process_job( client: redis::Client, config: Arc, @@ -188,33 +215,108 @@ async fn process_job( ) .await?; + let core_result = { + let mut conn = client.get_multiplexed_async_connection().await?; + update_state( + &mut conn, + &request.job_id, + CodingAgentJobStatus::Running, + &[ + ("startedAtUtc", started_at.as_str()), + ("logPath", &log_path.to_string_lossy()), + ("updatedAtUtc", &utc_now()), + ], + ) + .await?; + run_job_core( + &client, + config, + &request, + &log_path, + &session_dir, + &mut conn, + ) + .await + }; + + let outcome = match core_result { + Ok(outcome) => outcome, + Err(err) => { + let message = err.to_string(); + let _ = append_line(&log_path, &format!("job core failed: {message}")).await; + JobOutcome { + status: CodingAgentJobStatus::Failed, + output: String::new(), + error: message, + } + } + }; + + let completed_at = utc_now(); + let summary = build_summary(outcome.status, &outcome.output, &outcome.error); + let report = CodingAgentJobReport { + job_id: request.job_id.clone(), + status: outcome.status, + chat_id: request.chat_id, + user_id: request.user_id, + message_id: request.message_id, + prompt: request.prompt.clone(), + working_directory: request.working_directory.clone(), + summary: summary.clone(), + output: outcome.output, + error_message: outcome.error.clone(), + log_path: log_path.to_string_lossy().to_string(), + started_at_utc: started_at, + completed_at_utc: completed_at.clone(), + }; + let mut conn = client.get_multiplexed_async_connection().await?; update_state( &mut conn, &request.job_id, - CodingAgentJobStatus::Running, + outcome.status, &[ - ("startedAtUtc", started_at.as_str()), - ("logPath", &log_path.to_string_lossy()), + ("summary", summary.as_str()), + ("error", outcome.error.as_str()), + ("completedAtUtc", completed_at.as_str()), ("updatedAtUtc", &utc_now()), ], ) .await?; + let report_payload = serde_json::to_string(&report)?; + let _: usize = conn.lpush(REPORT_QUEUE, report_payload).await?; + cleanup_job(&mut conn, &request.job_id).await?; + append_line( + &log_path, + &format!("job {} finished with {:?}", request.job_id, outcome.status), + ) + .await?; + Ok(()) +} + +async fn run_job_core( + client: &redis::Client, + config: Arc, + request: &CodingAgentJobRequest, + log_path: &Path, + session_dir: &Path, + conn: &mut redis::aio::MultiplexedConnection, +) -> Result { let mut outputs = Vec::new(); let mut final_status = CodingAgentJobStatus::Completed; let mut error_message = String::new(); - let agents = parse_agents(&request); + let agents = parse_agents(request); for agent in agents { - if is_cancel_requested(&mut conn, &request.job_id).await? { + if is_cancel_requested(conn, &request.job_id).await? { final_status = CodingAgentJobStatus::Cancelled; error_message = "Job was cancelled before the next agent started.".to_owned(); break; } update_state( - &mut conn, + conn, &request.job_id, CodingAgentJobStatus::Running, &[ @@ -227,10 +329,10 @@ async fn process_job( let result = run_agent( client.clone(), config.clone(), - &request, + request, &agent, - &log_path, - &session_dir, + log_path, + session_dir, ) .await; @@ -255,47 +357,11 @@ async fn process_job( } } - let completed_at = utc_now(); - let output = outputs.join("\n\n"); - let summary = build_summary(final_status, &output, &error_message); - let report = CodingAgentJobReport { - job_id: request.job_id.clone(), + Ok(JobOutcome { status: final_status, - chat_id: request.chat_id, - user_id: request.user_id, - message_id: request.message_id, - prompt: request.prompt.clone(), - working_directory: request.working_directory.clone(), - summary: summary.clone(), - output, - error_message: error_message.clone(), - log_path: log_path.to_string_lossy().to_string(), - started_at_utc: started_at, - completed_at_utc: completed_at.clone(), - }; - - update_state( - &mut conn, - &request.job_id, - final_status, - &[ - ("summary", summary.as_str()), - ("error", error_message.as_str()), - ("completedAtUtc", completed_at.as_str()), - ("updatedAtUtc", &utc_now()), - ], - ) - .await?; - - let report_payload = serde_json::to_string(&report)?; - let _: usize = conn.lpush(REPORT_QUEUE, report_payload).await?; - cleanup_job(&mut conn, &request.job_id).await?; - append_line( - &log_path, - &format!("job {} finished with {:?}", request.job_id, final_status), - ) - .await?; - Ok(()) + output: outputs.join("\n\n"), + error: error_message, + }) } async fn run_agent( @@ -630,8 +696,10 @@ async fn append_line(path: &Path, line: &str) -> Result<()> { .append(true) .open(path) .await?; - file.write_all(line.as_bytes()).await?; - file.write_all(b"\n").await?; + let mut buffer = Vec::with_capacity(line.len() + 1); + buffer.extend_from_slice(line.as_bytes()); + buffer.push(b'\n'); + file.write_all(&buffer).await?; Ok(()) } diff --git a/TelegramSearchBot.Common/Env.cs b/TelegramSearchBot.Common/Env.cs index 3f74d6f6..9675a5fe 100644 --- a/TelegramSearchBot.Common/Env.cs +++ b/TelegramSearchBot.Common/Env.cs @@ -223,10 +223,14 @@ private static List ResolveCodingAgentDeniedPathPrefixes(List? c .Where(prefix => !string.IsNullOrWhiteSpace(prefix)) .Select(prefix => NormalizeDeniedPathPrefix(prefix)) .Where(prefix => !string.IsNullOrWhiteSpace(prefix)) - .Distinct(StringComparer.OrdinalIgnoreCase) + .Distinct(GetCodingAgentPathComparer()) .ToList(); } + private static StringComparer GetCodingAgentPathComparer() { + return OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; + } + private static IEnumerable GetDefaultCodingAgentDeniedPathPrefixes() { yield return WorkDir; diff --git a/TelegramSearchBot.LLM/Service/Tools/CodingAgentJobService.cs b/TelegramSearchBot.LLM/Service/Tools/CodingAgentJobService.cs index 31078c6e..d1b4dc2d 100644 --- a/TelegramSearchBot.LLM/Service/Tools/CodingAgentJobService.cs +++ b/TelegramSearchBot.LLM/Service/Tools/CodingAgentJobService.cs @@ -10,6 +10,37 @@ namespace TelegramSearchBot.Service.Tools { [Injectable(Microsoft.Extensions.DependencyInjection.ServiceLifetime.Singleton)] public sealed class CodingAgentJobService : IService { private static readonly TimeSpan StateTtl = TimeSpan.FromDays(7); + private const string EnqueueJobScript = @" +local activeKey = KEYS[1] +local stateKey = KEYS[2] +local queueKey = KEYS[3] +local jobId = ARGV[1] +local maxActive = tonumber(ARGV[2]) +local ttlSeconds = tonumber(ARGV[3]) +local payload = ARGV[4] + +if redis.call('SCARD', activeKey) >= maxActive then + return 0 +end + +redis.call('SADD', activeKey, jobId) +redis.call('HSET', stateKey, + 'status', ARGV[5], + 'chatId', ARGV[6], + 'userId', ARGV[7], + 'messageId', ARGV[8], + 'workingDirectory', ARGV[9], + 'createdAtUtc', ARGV[10], + 'updatedAtUtc', ARGV[11], + 'payload', payload, + 'summary', '', + 'error', '', + 'logPath', '' +) +redis.call('EXPIRE', stateKey, ttlSeconds) +redis.call('LPUSH', queueKey, payload) +return 1 +"; private readonly IConnectionMultiplexer _redis; private readonly ILogger _logger; @@ -27,37 +58,36 @@ public async Task EnqueueAsync(CodingAgentJobRequest requ } var db = _redis.GetDatabase(); - var activeCount = await db.SetLengthAsync(LlmAgentRedisKeys.CodingAgentActiveJobSet); - if (activeCount >= Env.CodingAgentMaxConcurrentJobs) { - throw new InvalidOperationException($"Coding agent job limit reached: {activeCount}/{Env.CodingAgentMaxConcurrentJobs}."); - } - request.CreatedAtUtc = DateTime.UtcNow; var payload = JsonConvert.SerializeObject(request); var stateKey = LlmAgentRedisKeys.CodingAgentJobState(request.JobId); - - try { - await db.HashSetAsync(stateKey, [ - new HashEntry("status", CodingAgentJobStatus.Pending.ToString()), - new HashEntry("chatId", request.ChatId), - new HashEntry("userId", request.UserId), - new HashEntry("messageId", request.MessageId), - new HashEntry("workingDirectory", request.WorkingDirectory), - new HashEntry("createdAtUtc", request.CreatedAtUtc.ToString("O")), - new HashEntry("updatedAtUtc", DateTime.UtcNow.ToString("O")), - new HashEntry("payload", payload), - new HashEntry("summary", string.Empty), - new HashEntry("error", string.Empty), - new HashEntry("logPath", string.Empty) + var updatedAtUtc = DateTime.UtcNow.ToString("O"); + var result = await db.ScriptEvaluateAsync( + EnqueueJobScript, + [ + LlmAgentRedisKeys.CodingAgentActiveJobSet, + stateKey, + LlmAgentRedisKeys.CodingAgentJobQueue + ], + [ + request.JobId, + Env.CodingAgentMaxConcurrentJobs, + ( long ) StateTtl.TotalSeconds, + payload, + CodingAgentJobStatus.Pending.ToString(), + request.ChatId, + request.UserId, + request.MessageId, + request.WorkingDirectory, + request.CreatedAtUtc.ToString("O"), + updatedAtUtc ]); - await db.KeyExpireAsync(stateKey, StateTtl); - await db.SetAddAsync(LlmAgentRedisKeys.CodingAgentActiveJobSet, request.JobId); - await db.ListLeftPushAsync(LlmAgentRedisKeys.CodingAgentJobQueue, payload); - return request; - } catch { - await db.SetRemoveAsync(LlmAgentRedisKeys.CodingAgentActiveJobSet, request.JobId); - throw; + + if (( int ) result != 1) { + throw new InvalidOperationException($"Coding agent job limit reached: {Env.CodingAgentMaxConcurrentJobs}."); } + + return request; } public async Task> GetStateAsync(string jobId) { diff --git a/TelegramSearchBot.LLM/Service/Tools/CodingAgentToolService.cs b/TelegramSearchBot.LLM/Service/Tools/CodingAgentToolService.cs index e900936d..517ac404 100644 --- a/TelegramSearchBot.LLM/Service/Tools/CodingAgentToolService.cs +++ b/TelegramSearchBot.LLM/Service/Tools/CodingAgentToolService.cs @@ -7,7 +7,7 @@ using TelegramSearchBot.Model.AI; namespace TelegramSearchBot.Service.Tools { - [Injectable(Microsoft.Extensions.DependencyInjection.ServiceLifetime.Transient)] + [Injectable(Microsoft.Extensions.DependencyInjection.ServiceLifetime.Singleton)] public sealed class CodingAgentToolService : IService { private readonly CodingAgentPathPolicy _pathPolicy; private readonly CodingAgentJobService _jobService; diff --git a/TelegramSearchBot.Test/Service/AI/LLM/InMemoryRedisTestHarness.cs b/TelegramSearchBot.Test/Service/AI/LLM/InMemoryRedisTestHarness.cs index 0c87459f..75613b03 100644 --- a/TelegramSearchBot.Test/Service/AI/LLM/InMemoryRedisTestHarness.cs +++ b/TelegramSearchBot.Test/Service/AI/LLM/InMemoryRedisTestHarness.cs @@ -138,6 +138,39 @@ public InMemoryRedisTestHarness() { Database.Setup(d => d.StringGetAsync(It.IsAny(), It.IsAny())) .ReturnsAsync((RedisKey key, CommandFlags _) => _strings.TryGetValue(key.ToString(), out var value) ? ( RedisValue ) value : RedisValue.Null); + Database.Setup(d => d.ScriptEvaluateAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((string _, RedisKey[] keys, RedisValue[] values, CommandFlags _) => { + lock (_gate) { + var activeKey = keys[0].ToString(); + var stateKey = keys[1].ToString(); + var queueKey = keys[2].ToString(); + var jobId = values[0].ToString(); + var maxActive = ( int ) values[1]; + var activeSet = _sets.GetOrAdd(activeKey, _ => new HashSet(StringComparer.OrdinalIgnoreCase)); + if (activeSet.Count >= maxActive) { + return RedisResult.Create(( RedisValue ) 0); + } + + activeSet.Add(jobId); + var hash = _hashes.GetOrAdd(stateKey, _ => new Dictionary(StringComparer.OrdinalIgnoreCase)); + hash["status"] = values[4].ToString(); + hash["chatId"] = values[5].ToString(); + hash["userId"] = values[6].ToString(); + hash["messageId"] = values[7].ToString(); + hash["workingDirectory"] = values[8].ToString(); + hash["createdAtUtc"] = values[9].ToString(); + hash["updatedAtUtc"] = values[10].ToString(); + hash["payload"] = values[3].ToString(); + hash["summary"] = string.Empty; + hash["error"] = string.Empty; + hash["logPath"] = string.Empty; + + var list = _lists.GetOrAdd(queueKey, _ => []); + list.Insert(0, values[3].ToString()); + return RedisResult.Create(( RedisValue ) 1); + } + }); + Database.Setup(d => d.KeyDeleteAsync(It.IsAny(), It.IsAny())) .ReturnsAsync((RedisKey key, CommandFlags flags) => { var removed = false; diff --git a/TelegramSearchBot/Service/AI/LLM/CodingAgentReportConsumer.cs b/TelegramSearchBot/Service/AI/LLM/CodingAgentReportConsumer.cs index 957ffedb..a5e4fd3a 100644 --- a/TelegramSearchBot/Service/AI/LLM/CodingAgentReportConsumer.cs +++ b/TelegramSearchBot/Service/AI/LLM/CodingAgentReportConsumer.cs @@ -1,4 +1,5 @@ using System; +using System.IO; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -52,7 +53,14 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) { continue; } - var report = JsonConvert.DeserializeObject(payload); + CodingAgentJobReport report; + try { + report = JsonConvert.DeserializeObject(payload); + } catch (JsonException ex) { + _logger.LogWarning(ex, "Ignoring malformed coding agent report payload."); + continue; + } + if (report == null) { continue; } @@ -72,6 +80,9 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) { } catch (RedisException ex) { _logger.LogWarning(ex, "Redis error in CodingAgentReportConsumer, retrying in 1 s."); await Task.Delay(TimeSpan.FromSeconds(1), stoppingToken); + } catch (Exception ex) when (ex is not OperationCanceledException) { + _logger.LogError(ex, "Unexpected error in CodingAgentReportConsumer, retrying in 1 s."); + await Task.Delay(TimeSpan.FromSeconds(1), stoppingToken); } } } @@ -108,7 +119,7 @@ private async Task ResumeWithAgentProcessAsync(IServiceProvider scopedProvider, var sendMessageService = scopedProvider.GetRequiredService(); var botIdentity = await EnsureBotIdentityAsync(scopedProvider, stoppingToken); var replyTo = ToTelegramMessageId(report.MessageId); - LlmContinuationSnapshot? continuationSnapshot = null; + LlmContinuationSnapshot continuationSnapshot = null; for (var attempt = 0; attempt <= Env.CodingAgentMaxAutoResumeContinuations; attempt++) { AgentTaskStreamHandle handle; @@ -209,9 +220,9 @@ private static string BuildReportMessage(CodingAgentJobReport report) { sb.AppendLine("Coding agent job finished / 编码任务已结束"); sb.AppendLine($"JobId: {report.JobId}"); sb.AppendLine($"Status: {report.Status}"); - sb.AppendLine($"Workspace: {report.WorkingDirectory}"); + sb.AppendLine($"Workspace: {GetPathDisplayName(report.WorkingDirectory)}"); if (!string.IsNullOrWhiteSpace(report.LogPath)) { - sb.AppendLine($"Log: {report.LogPath}"); + sb.AppendLine($"Log: {GetPathDisplayName(report.LogPath)}"); } if (!string.IsNullOrWhiteSpace(report.Summary)) { sb.AppendLine(); @@ -232,8 +243,10 @@ private static string BuildContinuationPrompt(CodingAgentJobReport report) { sb.AppendLine(); sb.AppendLine($"JobId: {report.JobId}"); sb.AppendLine($"Status: {report.Status}"); - sb.AppendLine($"Workspace: {report.WorkingDirectory}"); - sb.AppendLine($"LogPath: {report.LogPath}"); + sb.AppendLine($"Workspace: {GetPathDisplayName(report.WorkingDirectory)}"); + if (!string.IsNullOrWhiteSpace(report.LogPath)) { + sb.AppendLine($"Log: {GetPathDisplayName(report.LogPath)}"); + } sb.AppendLine(); sb.AppendLine("Original prompt:"); sb.AppendLine(report.Prompt); @@ -285,5 +298,15 @@ private static string TrimForTelegram(string value, int maxLength) { return value[..maxLength] + "\n... [truncated]"; } + + private static string GetPathDisplayName(string path) { + if (string.IsNullOrWhiteSpace(path)) { + return "(redacted)"; + } + + var trimmed = path.Trim().TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var displayName = Path.GetFileName(trimmed); + return string.IsNullOrWhiteSpace(displayName) ? "(redacted)" : displayName; + } } }