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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions TelegramSearchBot.Common/Model/AI/LlmAgentContracts.cs
Original file line number Diff line number Diff line change
Expand Up @@ -181,15 +181,37 @@ public sealed class SubAgentTaskResult {
public DateTime CompletedAtUtc { get; set; } = DateTime.UtcNow;
}

/// <summary>
/// Serializable tool definition for exporting main-process tool descriptions to Redis,
/// so that agent processes can register them as proxy tools.
/// </summary>
public sealed class ProxyToolDefinition {
public string Name { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public List<ProxyToolParameter> Parameters { get; set; } = [];
}

public sealed class ProxyToolParameter {
public string Name { get; set; } = string.Empty;
public string Type { get; set; } = "string";
public string Description { get; set; } = string.Empty;
public bool Required { get; set; }
}

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

public static string AgentTaskState(string taskId) => $"AGENT_TASK:{taskId}";
public static string AgentSnapshot(string taskId) => $"AGENT_SNAPSHOT:{taskId}";
public static string AgentTerminal(string taskId) => $"AGENT_TERMINAL:{taskId}";
[Obsolete("Use AgentSnapshot/AgentTerminal instead")]
public static string AgentChunks(string taskId) => $"AGENT_CHUNKS:{taskId}";
[Obsolete("Use AgentSnapshot/AgentTerminal instead")]
public static string AgentChunkIndex(string taskId) => $"AGENT_CHUNK_INDEX:{taskId}";
public static string AgentSession(long chatId) => $"AGENT_SESSION:{chatId}";
public static string AgentControl(long chatId) => $"AGENT_CONTROL:{chatId}";
Expand Down
43 changes: 36 additions & 7 deletions TelegramSearchBot.LLM.Test/Service/AI/LLM/GarnetClientTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,30 +15,59 @@ public GarnetClientTests() {
}

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

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

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

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

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

_dbMock.Setup(d => d.StringSetAsync(
It.IsAny<RedisKey>(),
It.IsAny<RedisValue>(),
It.IsAny<Expiration>(),
It.IsAny<ValueCondition>(),
It.IsAny<CommandFlags>()))
.Callback<RedisKey, RedisValue, Expiration, ValueCondition, CommandFlags>((key, value, _, _, _) => {
capturedKey = key;
capturedValue = value;
})
.ReturnsAsync(true);

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

Assert.Equal(LlmAgentRedisKeys.AgentTerminal("task-1"), capturedKey.ToString());
Assert.Contains("\"Type\":1", capturedValue.ToString()); // AgentChunkType.Done = 1
}
}
}
170 changes: 166 additions & 4 deletions TelegramSearchBot.LLM/Service/AI/LLM/McpToolHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
using Newtonsoft.Json;
using TelegramSearchBot.Attributes;
using TelegramSearchBot.Model;
using TelegramSearchBot.Model.AI;
using TelegramSearchBot.Model.Tools;

namespace TelegramSearchBot.Service.AI.LLM {
Expand All @@ -23,11 +24,14 @@
public static class McpToolHelper {
private static readonly ConcurrentDictionary<string, (MethodInfo Method, Type OwningType)> ToolRegistry = new ConcurrentDictionary<string, (MethodInfo, Type)>();
private static readonly ConcurrentDictionary<string, ExternalToolInfo> ExternalToolRegistry = new ConcurrentDictionary<string, ExternalToolInfo>();
private static readonly ConcurrentDictionary<string, ProxyToolEntry> ProxyToolRegistry = new ConcurrentDictionary<string, ProxyToolEntry>(StringComparer.OrdinalIgnoreCase);
private static Func<string, string, Dictionary<string, string>, Task<string>> _externalToolExecutor;
private static Func<string, Dictionary<string, string>, Task<string>> _proxyToolExecutor;
private static IServiceProvider _sServiceProvider;
private static ILogger _sLogger;
private static string _sCachedToolsXml;
private static string _sCachedExternalToolsXml;
private static string _sCachedProxyToolsXml;
private static bool _sIsInitialized = false;
private static readonly object _initializationLock = new object();

Expand All @@ -48,6 +52,16 @@
public bool Required { get; set; }
}

/// <summary>
/// Represents a tool that is executed remotely via a proxy (e.g., agent → main process IPC).
/// Unlike ExternalToolInfo, proxy tools preserve their original name without prefixing.
/// </summary>
internal class ProxyToolEntry {
public string Name { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public List<ExternalToolParameter> Parameters { get; set; } = new();
}

private static void Initialize(IServiceProvider serviceProvider, ILogger logger) {
_sServiceProvider = serviceProvider;
_sLogger = logger;
Expand Down Expand Up @@ -109,14 +123,14 @@
.SelectMany(a => a.GetTypes())
.SelectMany(type => type.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static))
.Where(m => m.GetCustomAttribute<BuiltInToolAttribute>() != null ||
m.GetCustomAttribute<McpToolAttribute>() != null)

Check warning on line 126 in TelegramSearchBot.LLM/Service/AI/LLM/McpToolHelper.cs

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest)

'McpToolAttribute' is obsolete: 'Use BuiltInToolAttribute instead. This attribute will be removed in a future version.'
.ToList();

var sb = new StringBuilder();
foreach (var method in methods) {
// Prefer BuiltInToolAttribute, fall back to McpToolAttribute
var builtInAttr = method.GetCustomAttribute<BuiltInToolAttribute>();
var mcpAttr = method.GetCustomAttribute<McpToolAttribute>();

Check warning on line 133 in TelegramSearchBot.LLM/Service/AI/LLM/McpToolHelper.cs

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest)

'McpToolAttribute' is obsolete: 'Use BuiltInToolAttribute instead. This attribute will be removed in a future version.'
var description = builtInAttr?.Description ?? mcpAttr?.Description;
var nameOverride = builtInAttr?.Name ?? mcpAttr?.Name;
if (description == null) continue;
Expand Down Expand Up @@ -227,6 +241,32 @@
tools.Add(OpenAI.Chat.ChatTool.CreateFunctionTool(qualifiedName, description, BinaryData.FromString(parametersJson)));
}

// Proxy tools (remote tools from main process)
foreach (var (name, entry) in ProxyToolRegistry) {
if (ToolRegistry.ContainsKey(name)) continue; // already registered locally
var properties = new Dictionary<string, object>();
var required = new List<string>();

foreach (var param in entry.Parameters) {
properties[param.Name] = new Dictionary<string, object> {
{ "type", MapExternalTypeToJsonSchema(param.Type) },
{ "description", param.Description }
};
if (param.Required) {
required.Add(param.Name);
}
}

var parametersSchema = new Dictionary<string, object> {
{ "type", "object" },
{ "properties", properties },
{ "required", required }
};

var parametersJson = JsonConvert.SerializeObject(parametersSchema);
tools.Add(OpenAI.Chat.ChatTool.CreateFunctionTool(name, entry.Description, BinaryData.FromString(parametersJson)));
}

return tools;
}

Expand Down Expand Up @@ -269,6 +309,12 @@
toolsSectionBuilder.AppendLine("== 内置工具 (Built-in Tools) ==");
toolsSectionBuilder.AppendLine(builtInToolsXml);

if (!string.IsNullOrWhiteSpace(_sCachedProxyToolsXml)) {
toolsSectionBuilder.AppendLine();
toolsSectionBuilder.AppendLine("== 远程工具 (Remote Tools via IPC) ==");
toolsSectionBuilder.AppendLine(_sCachedProxyToolsXml);
}

if (!string.IsNullOrWhiteSpace(externalToolsXml)) {
toolsSectionBuilder.AppendLine();
toolsSectionBuilder.AppendLine("== 外部MCP工具 (External MCP Tools) ==");
Expand Down Expand Up @@ -645,6 +691,14 @@
}

public static async Task<object> ExecuteRegisteredToolAsync(string toolName, Dictionary<string, string> stringArguments, ToolContext toolContext = null) {
return await ExecuteRegisteredToolAsync(toolName, stringArguments, null, toolContext);
}

/// <summary>
/// Executes a registered tool, optionally using a scoped IServiceProvider for DI resolution.
/// Use the scoped overload when executing tools that require scoped services (e.g., DbContext).
/// </summary>
public static async Task<object> ExecuteRegisteredToolAsync(string toolName, Dictionary<string, string> stringArguments, IServiceProvider scopedProvider, ToolContext toolContext = null) {
// Clean CDATA markers if present and trim values
var cleanedArguments = new Dictionary<string, string>();
foreach (var kvp in stringArguments) {
Expand All @@ -654,6 +708,12 @@
}
stringArguments = cleanedArguments;

// Check if this is a proxy tool (routed to remote process via IPC)
if (ProxyToolRegistry.ContainsKey(toolName) && _proxyToolExecutor != null) {
var proxyResult = await _proxyToolExecutor(toolName, stringArguments);
return proxyResult;
}

// Check if this is an external MCP tool
if (ExternalToolRegistry.TryGetValue(toolName, out var externalTool)) {
return await ExecuteExternalToolAsync(toolName, externalTool, stringArguments);
Expand Down Expand Up @@ -693,10 +753,12 @@
}
}

// Use scoped provider if available, fallback to root provider
var provider = scopedProvider ?? _sServiceProvider;
object instance = null;
if (!method.IsStatic) {
if (_sServiceProvider != null) {
instance = _sServiceProvider.GetService(owningType);
if (provider != null) {
instance = provider.GetService(owningType);
if (instance == null) {
if (owningType.GetConstructor(Type.EmptyTypes) != null)
instance = Activator.CreateInstance(owningType);
Expand Down Expand Up @@ -899,10 +961,10 @@
}

/// <summary>
/// Check if a tool name is registered (either built-in or external).
/// Check if a tool name is registered (built-in, external, or proxy).
/// </summary>
public static bool IsToolRegistered(string toolName) {
return ToolRegistry.ContainsKey(toolName) || ExternalToolRegistry.ContainsKey(toolName);
return ToolRegistry.ContainsKey(toolName) || ExternalToolRegistry.ContainsKey(toolName) || ProxyToolRegistry.ContainsKey(toolName);
}

/// <summary>
Expand Down Expand Up @@ -944,5 +1006,105 @@
return string.Join("\n", result.Content?.Select(c => c.Text ?? "") ?? Enumerable.Empty<string>());
});
}

/// <summary>
/// Exports all registered tool definitions (built-in + external) as serializable ProxyToolDefinition list.
/// Used by the main process to store tool definitions in Redis for agent processes.
/// </summary>
public static List<ProxyToolDefinition> ExportToolDefinitions() {
var result = new List<ProxyToolDefinition>();

// Export built-in tools
foreach (var (toolName, toolInfo) in ToolRegistry) {
var method = toolInfo.Method;
var builtInAttr = method.GetCustomAttribute<BuiltInToolAttribute>();
var mcpAttr = method.GetCustomAttribute<McpToolAttribute>();
var description = builtInAttr?.Description ?? mcpAttr?.Description ?? "";

var parameters = new List<ProxyToolParameter>();
foreach (var param in method.GetParameters()) {
if (param.ParameterType == typeof(ToolContext)) continue;
var builtInParamAttr = param.GetCustomAttribute<BuiltInParameterAttribute>();
var mcpParamAttr = param.GetCustomAttribute<McpParameterAttribute>();
if (builtInParamAttr == null && mcpParamAttr == null) continue;

parameters.Add(new ProxyToolParameter {
Name = param.Name ?? "",
Type = GetSimplifiedTypeName(param.ParameterType),
Description = builtInParamAttr?.Description ?? mcpParamAttr?.Description ?? $"Parameter '{param.Name}'",
Required = builtInParamAttr?.IsRequired ?? mcpParamAttr?.IsRequired ?? (!param.IsOptional && !param.HasDefaultValue)
});
}

result.Add(new ProxyToolDefinition {
Name = toolName,
Description = description,
Parameters = parameters
});
}

// Export external MCP tools
foreach (var (qualifiedName, toolInfo) in ExternalToolRegistry) {
result.Add(new ProxyToolDefinition {
Name = qualifiedName,
Description = $"[MCP Server: {toolInfo.ServerName}] {toolInfo.Description}",
Parameters = toolInfo.Parameters.Select(p => new ProxyToolParameter {
Name = p.Name,
Type = p.Type,
Description = p.Description,
Required = p.Required
}).ToList()
});
}

return result;
}

/// <summary>
/// Registers proxy tools that are executed remotely via IPC (e.g., agent calling main process tools).
/// Unlike RegisterExternalTools, this preserves original tool names without any prefix.
/// Tools already registered locally (in ToolRegistry) are skipped.
/// </summary>
public static void RegisterProxyTools(
List<ProxyToolDefinition> toolDefinitions,
Func<string, Dictionary<string, string>, Task<string>> executor) {
_proxyToolExecutor = executor;
ProxyToolRegistry.Clear();

var sb = new StringBuilder();
int registered = 0;

foreach (var tool in toolDefinitions) {
// Skip tools already registered locally
if (ToolRegistry.ContainsKey(tool.Name)) {
continue;
}

ProxyToolRegistry[tool.Name] = new ProxyToolEntry {
Name = tool.Name,
Description = tool.Description,
Parameters = tool.Parameters.Select(p => new ExternalToolParameter {
Name = p.Name,
Type = p.Type,
Description = p.Description,
Required = p.Required
}).ToList()
};

sb.AppendLine($"- <tool name=\"{tool.Name}\">");
sb.AppendLine($" <description>{tool.Description}</description>");
sb.AppendLine($" <parameters>");
foreach (var param in tool.Parameters) {
sb.AppendLine($" <parameter name=\"{param.Name}\" type=\"{param.Type}\" required=\"{param.Required.ToString().ToLower()}\">{param.Description}</parameter>");
}
sb.AppendLine($" </parameters>");
sb.AppendLine($" </tool>");
registered++;
}

_sCachedProxyToolsXml = sb.ToString();
_sLogger?.LogInformation("Registered {Count} proxy tools (skipped {Skipped} locally available)",
registered, toolDefinitions.Count - registered);
}
}
}
Loading
Loading