diff --git a/MAF1.Core/Agents/FileCity/FileCityAgent.cs b/MAF1.Core/Agents/FileCity/FileCityAgent.cs index 426f1f3..2003ba2 100644 --- a/MAF1.Core/Agents/FileCity/FileCityAgent.cs +++ b/MAF1.Core/Agents/FileCity/FileCityAgent.cs @@ -13,22 +13,39 @@ namespace MAF1.Agents.FileCity; /// public static class FileCityAgent { + public const string DefaultDescription = "从文件或用户描述中提取有效城市名。"; + /// 创建带 ReadTextFile 工具的聊天 Agent。模型必须先读文件,不能凭文件名猜内容。 - public static AIAgent Create(AgentFactory factory) + public static AIAgent Create( + AgentFactory factory, + string name = "FileCityAgent", + string? description = null, + string? extraInstructions = null) { - return factory.CreateAgent( - name: "FileCityAgent", - instructions: + string instructions = """ - 你负责读取用户指定的本地文件,并从中提取有效城市名。 - 必须先调用 ReadTextFile 读取文件,不要猜测文件内容。 + 你负责从用户材料里提取有效城市名。 + 用户可能给你两种输入,你必须自己判断: + 1) 本地文件路径(如 Data/cities.txt、C:\\a.txt)→ 必须先调用 ReadTextFile 读文件,不要猜内容。 + 2) 自然语言描述或城市名单(如「帮我查成都和杭州的天气」「明天去上海」)→ 不要调用 ReadTextFile,直接从用户消息里提取城市。 + 不要把描述、问句、城市名当成文件路径去读。 有效城市名:现实世界中真实存在的城市(如 成都、北京、Amsterdam)。 忽略空行、注释、人名、水果、随意单词等不是城市的内容。 + 城市可能出现在句子、列表或表格里,都要提取。 只输出一个 JSON 对象,不要 Markdown,不要其它说明: {"hasValidCities": true, "cities": ["成都"], "reason": "说明"} 若没有有效城市:{"hasValidCities": false, "cities": [], "reason": "原因"} - cities 去重,保持文件中的出现顺序。 - """, + cities 去重,保持出现顺序。 + """; + if (!string.IsNullOrWhiteSpace(extraInstructions)) + { + instructions = instructions + "\n" + extraInstructions; + } + + return factory.CreateAgent( + name: name, + instructions: instructions, + description: string.IsNullOrWhiteSpace(description) ? DefaultDescription : description, tools: [AIFunctionFactory.Create(FileTools.ReadTextFile)]); } diff --git a/MAF1.Core/Agents/SystemNodeTypes.cs b/MAF1.Core/Agents/SystemNodeTypes.cs new file mode 100644 index 0000000..56f2d71 --- /dev/null +++ b/MAF1.Core/Agents/SystemNodeTypes.cs @@ -0,0 +1,13 @@ +namespace MAF1.Agents; + +/// +/// 系统节点类型 id,和网页画布、路由图 JSON 共用,避免各写一份字符串。 +/// +public static class SystemNodeTypes +{ + public const string FileCity = "fileCity-system"; + public const string Weather = "weather-system"; + + public static bool IsKnown(string? type) + => type is FileCity or Weather; +} diff --git a/MAF1.Core/Agents/Weather/WeatherAgent.cs b/MAF1.Core/Agents/Weather/WeatherAgent.cs index ad4b146..7fdef77 100644 --- a/MAF1.Core/Agents/Weather/WeatherAgent.cs +++ b/MAF1.Core/Agents/Weather/WeatherAgent.cs @@ -11,16 +11,30 @@ namespace MAF1.Agents.Weather; /// public static class WeatherAgent { + public const string DefaultDescription = "按城市列表查询天气并汇总。"; + /// 创建带 GetWeather 工具的 Agent。instructions 要求每个城市都调工具,禁止编造气温。 - public static AIAgent Create(AgentFactory factory, WeatherTools weatherTools) + public static AIAgent Create( + AgentFactory factory, + WeatherTools weatherTools, + string name = "WeatherAgent", + string? description = null, + string? extraInstructions = null) { - return factory.CreateAgent( - name: "WeatherAgent", - instructions: + string instructions = """ 你负责查询天气。用户给出的每个城市都必须调用一次 GetWeather,不要编造天气。 用中文汇总所有城市的天气。 - """, + """; + if (!string.IsNullOrWhiteSpace(extraInstructions)) + { + instructions = instructions + "\n" + extraInstructions; + } + + return factory.CreateAgent( + name: name, + instructions: instructions, + description: string.IsNullOrWhiteSpace(description) ? DefaultDescription : description, tools: [AIFunctionFactory.Create(weatherTools.GetWeather)]); } diff --git a/MAF1/Orchestration/ConsoleDecisionPrompt.cs b/MAF1.Core/Decisions/ConsoleDecisionPrompt.cs similarity index 94% rename from MAF1/Orchestration/ConsoleDecisionPrompt.cs rename to MAF1.Core/Decisions/ConsoleDecisionPrompt.cs index 3d4e27c..e8413ab 100644 --- a/MAF1/Orchestration/ConsoleDecisionPrompt.cs +++ b/MAF1.Core/Decisions/ConsoleDecisionPrompt.cs @@ -1,12 +1,11 @@ -using MAF1.Decisions; - -namespace MAF1.Orchestration; +namespace MAF1.Decisions; /// /// CLI 版确认框。回车 / 1 = 结束查询;2 = 再输入城市;也可以直接打「成都」。 -/// ReadLine 和超时赛跑:到期返回 TimedOut,工作流走默认方案。 +/// ReadLine 和超时赛跑:到期返回 TimedOut,走默认方案。 +/// MAF1 工作流 CLI 与 MAF1.Route 共用。 /// -internal static class ConsoleDecisionPrompt +public static class ConsoleDecisionPrompt { public static async Task WaitAsync(DecisionRequest request) { diff --git a/MAF1.Core/MAF1.Core.csproj b/MAF1.Core/MAF1.Core.csproj index 63695f0..68e5773 100644 --- a/MAF1.Core/MAF1.Core.csproj +++ b/MAF1.Core/MAF1.Core.csproj @@ -10,9 +10,11 @@ + + @@ -20,6 +22,7 @@ + diff --git a/MAF1.Core/Utils/AgentFactory.cs b/MAF1.Core/Utils/AgentFactory.cs index bd948d1..6817763 100644 --- a/MAF1.Core/Utils/AgentFactory.cs +++ b/MAF1.Core/Utils/AgentFactory.cs @@ -15,6 +15,7 @@ namespace MAF1.Utils; public sealed class AgentFactory { private readonly ChatClient _chatClient; + private readonly bool _disableDeepSeekThinking; public AgentFactory(LlmOptions options) { @@ -33,6 +34,8 @@ public sealed class AgentFactory ? DefaultModelFor(options.Endpoint) : options.Model; + _disableDeepSeekThinking = LooksLikeDeepSeek(options.Endpoint); + if (LooksLikeAzureOpenAI(options.Endpoint)) { Console.Error.WriteLine($"使用 Azure OpenAI: {options.Endpoint} 部署: {model}"); @@ -48,14 +51,34 @@ public sealed class AgentFactory } Console.Error.WriteLine($"使用 OpenAI 兼容接口: {clientOptions.Endpoint?.ToString() ?? "https://api.openai.com/v1"} 模型: {model}"); + if (_disableDeepSeekThinking) + { + Console.Error.WriteLine("已关闭 DeepSeek thinking,避免 Handoff 多轮丢掉 reasoning_content 导致 HTTP 400。"); + } + _chatClient = new OpenAIClient(new ApiKeyCredential(options.ApiKey), clientOptions) .GetChatClient(model); } - /// 把 ChatClient 包成 Microsoft.Agents.AI 的 AIAgent,并挂上 tools。 - public AIAgent CreateAgent(string name, string instructions, IList? tools = null) + /// 把 ChatClient 包成 Microsoft.Agents.AI 的 AIAgent,并挂上 tools。Id 固定为 name,Handoff 工具才是 handoff_to_n1。 + public AIAgent CreateAgent(string name, string instructions, IList? tools = null, string? description = null) { - return _chatClient.AsAIAgent(instructions: instructions, name: name, tools: tools); + ChatOptions chatOptions = new() + { + Instructions = instructions, + Tools = tools, + }; + ChatClientAgentOptions options = new() + { + Id = name, + Name = name, + Description = description, + ChatOptions = chatOptions, + }; + return _chatClient.AsAIAgent(options, clientFactory: inner => + _disableDeepSeekThinking + ? new ConfigureOptionsChatClient(inner, DeepSeekThinking.Disable) + : inner); } /// 合并配置文件和环境变量。插件进程里环境变量通常由宿主写入。 @@ -78,6 +101,10 @@ public sealed class AgentFactory return "gpt-4o-mini"; } + private static bool LooksLikeDeepSeek(string? endpoint) + => !string.IsNullOrWhiteSpace(endpoint) + && endpoint.Contains("deepseek", StringComparison.OrdinalIgnoreCase); + private static bool LooksLikeAzureOpenAI(string? endpoint) { if (string.IsNullOrWhiteSpace(endpoint) || !Uri.TryCreate(endpoint, UriKind.Absolute, out Uri? uri)) diff --git a/MAF1.Core/Utils/DeepSeekThinking.cs b/MAF1.Core/Utils/DeepSeekThinking.cs new file mode 100644 index 0000000..cb0daca --- /dev/null +++ b/MAF1.Core/Utils/DeepSeekThinking.cs @@ -0,0 +1,28 @@ +using System.ClientModel.Primitives; +using System.Text.Json; +using Microsoft.Extensions.AI; +using OpenAI.Chat; + +namespace MAF1.Utils; + +/// +/// DeepSeek V4 默认开 thinking。Handoff 多轮带 tools 时必须回传 reasoning_content, +/// 客户端会丢掉该字段导致 HTTP 400。请求里关闭 thinking 即可。 +/// +internal static class DeepSeekThinking +{ + public static void Disable(ChatOptions options) + { + options.RawRepresentationFactory = _ => + { + ChatCompletionOptions raw = new(); +#pragma warning disable SCME0001 + raw.Patch.Set("$.thinking"u8, BinaryData.FromObjectAsJson(new Dictionary + { + ["type"] = "disabled", + })); +#pragma warning restore SCME0001 + return raw; + }; + } +} diff --git a/MAF1.Route/CliHost.cs b/MAF1.Route/CliHost.cs new file mode 100644 index 0000000..4ad8782 --- /dev/null +++ b/MAF1.Route/CliHost.cs @@ -0,0 +1,84 @@ +using MAF1.Tools; +using MAF1.Utils; +using Microsoft.Extensions.Configuration; + +namespace MAF1.Route; + +/// +/// 命令行宿主:读配置和图 JSON,用 MAF WithHandoff 按方案 C 跑路由。 +/// +internal static class CliHost +{ + public static async Task RunAsync(string[] args) + { + if (args.Length > 0 && args[0] is "-h" or "--help") + { + PrintUsage(); + return; + } + + IConfiguration config = new ConfigurationBuilder() + .SetBasePath(AppContext.BaseDirectory) + .AddJsonFile("appsettings.json", optional: false, reloadOnChange: false) + .AddEnvironmentVariables() + .Build(); + + string graphPath = args.Length > 0 ? args[0] : Path.Combine("Graphs", "city-weather.json"); + string? resolvedGraph = ResolveExisting(graphPath); + if (resolvedGraph is null) + { + Console.WriteLine($"找不到路由图:{graphPath}"); + PrintUsage(); + return; + } + + RouteGraph graph = RouteGraph.LoadFile(resolvedGraph); + Console.WriteLine($"路由图: {resolvedGraph}"); + Console.WriteLine(); + + RouteUserInput input = RouteUserInput.ReadFromConsole(graph); + if (!input.HasRaw) + { + Console.WriteLine("没有输入,已取消。"); + return; + } + + Console.WriteLine($"本次输入: {input.Describe()}"); + Console.WriteLine(); + + AgentFactory factory = new(AgentFactory.Load(config)); + WeatherOptions weatherOptions = config.GetSection("Weather").Get() ?? new WeatherOptions(); + using HttpClient http = WeatherTools.CreateHttpClient(); + WeatherTools weatherTools = new(weatherOptions, http); + Console.WriteLine($"天气数据源: {weatherOptions.Provider}"); + Console.WriteLine(); + + await RouteOrchestrator.RunAsync(factory, weatherTools, graph, input); + } + + private static string? ResolveExisting(string path) + => RouteUserInput.ResolveExisting(path); + + private static void PrintUsage() + { + Console.WriteLine( + """ + 命令行路由(方案 C + MAF WithHandoff): + dotnet run --project MAF1.Route + dotnet run --project MAF1.Route -- Graphs/city-weather.json + dotnet run --project MAF1.Route -- Graphs/nodes-only.json + + 启动后在控制台输入即可,专家自己判断: + Data/cities.txt + 帮我查成都和杭州的天气 + 明天去上海出差 + 直接回车则用图节点 config 里的默认 filePath。 + + city-weather.json:专家之间仍是抽城市→天气;主管若认定用户已给出城市名,可直接交给天气。 + nodes-only.json 无边:主管可把对话直接交给白名单里任一专家。 + + 对照工作流(不是 Handoff): + dotnet run --project MAF1 -- node Data/cities.txt + """); + } +} diff --git a/MAF1.Route/Data/cities.txt b/MAF1.Route/Data/cities.txt new file mode 100644 index 0000000..3233868 --- /dev/null +++ b/MAF1.Route/Data/cities.txt @@ -0,0 +1,3 @@ +成都 +阿姆斯特丹 +北京 diff --git a/MAF1.Route/Data/not-cities.txt b/MAF1.Route/Data/not-cities.txt new file mode 100644 index 0000000..dcd167e --- /dev/null +++ b/MAF1.Route/Data/not-cities.txt @@ -0,0 +1,3 @@ +香蕉 +hello +不是城市 diff --git a/MAF1.Route/Graphs/city-weather.json b/MAF1.Route/Graphs/city-weather.json new file mode 100644 index 0000000..a78c42f --- /dev/null +++ b/MAF1.Route/Graphs/city-weather.json @@ -0,0 +1,22 @@ +{ + "nodes": [ + { + "id": "n1", + "type": "fileCity-system", + "title": "抽城市", + "config": { "filePath": "Data/cities.txt" } + }, + { + "id": "n2", + "type": "weather-system", + "title": "查天气" + } + ], + "edges": [ + { + "from": "n1", + "to": "n2", + "when": "hasValidCities" + } + ] +} diff --git a/MAF1.Route/Graphs/nodes-only.json b/MAF1.Route/Graphs/nodes-only.json new file mode 100644 index 0000000..5ab9b56 --- /dev/null +++ b/MAF1.Route/Graphs/nodes-only.json @@ -0,0 +1,16 @@ +{ + "nodes": [ + { + "id": "n1", + "type": "fileCity-system", + "title": "抽城市", + "config": { "filePath": "Data/cities.txt" } + }, + { + "id": "n2", + "type": "weather-system", + "title": "查天气" + } + ], + "edges": [] +} diff --git a/MAF1.Route/HandoffGraphBuilder.cs b/MAF1.Route/HandoffGraphBuilder.cs new file mode 100644 index 0000000..a8c08b7 --- /dev/null +++ b/MAF1.Route/HandoffGraphBuilder.cs @@ -0,0 +1,177 @@ +using MAF1.Agents; +using MAF1.Agents.FileCity; +using MAF1.Agents.Weather; +using MAF1.Tools; +using MAF1.Utils; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Workflows; + +namespace MAF1.Route; + +/// +/// 把方案 C 的图编译成 MAF Handoff:节点 = 白名单(主管可第一跳交给其中任何一个); +/// 边 = 专家之间允许的交接。主管根据用户原话自己选第一跳,不必先经过拓扑入口。 +/// +public static class HandoffGraphBuilder +{ + public static Workflow Build(AgentFactory factory, WeatherTools weatherTools, RouteGraph graph, RouteUserInput input) + { + AIAgent router = factory.CreateAgent( + name: "router", + description: "路由主管:根据用户任务把对话交接给图上允许的专家,自己不读文件、不查天气。", + instructions: BuildRouterInstructions(graph, input)); + + Dictionary specialists = new(StringComparer.OrdinalIgnoreCase); + foreach (RouteNode node in graph.Nodes) + { + specialists[node.Id] = CreateSpecialist(factory, weatherTools, node, graph, input); + } + + HandoffWorkflowBuilder builder = AgentWorkflowBuilder.CreateHandoffBuilderWith(router); + + foreach (RouteNode node in graph.Nodes) + { + builder.WithHandoff(router, specialists[node.Id], HandoffReason(node, fromRouter: true, when: null)); + } + + foreach (RouteEdge edge in graph.Edges) + { + builder.WithHandoff( + specialists[edge.From], + specialists[edge.To], + HandoffReason(graph.Find(edge.To)!, fromRouter: false, when: edge.When)); + } + + List mustHandoff = [router]; + foreach (RouteNode node in graph.Nodes) + { + if (graph.HasOutgoing(node.Id)) + { + mustHandoff.Add(specialists[node.Id]); + } + } + + return builder + .WithName("Route-Handoff") + .EmitAgentResponseUpdateEvents() + .EmitAgentResponseEvents() + .WithAutonomousMode( + turnLimit: 4, + continuationPrompt: "你还没有完成交接。请立即调用 handoff 工具把对话交给下一位专家,不要自己结束本轮。", + agents: mustHandoff) + .Build(); + } + + private static AIAgent CreateSpecialist( + AgentFactory factory, + WeatherTools weatherTools, + RouteNode node, + RouteGraph graph, + RouteUserInput input) + { + string title = string.IsNullOrWhiteSpace(node.Title) ? node.Type : node.Title; + string extra = BuildSpecialistExtra(node, graph, input); + return node.Type switch + { + SystemNodeTypes.FileCity => FileCityAgent.Create( + factory, + name: node.Id, + description: $"{title}:{FileCityAgent.DefaultDescription}", + extraInstructions: extra), + SystemNodeTypes.Weather => WeatherAgent.Create( + factory, + weatherTools, + name: node.Id, + description: $"{title}:{WeatherAgent.DefaultDescription}", + extraInstructions: extra), + _ => throw new InvalidOperationException($"未知节点类型 {node.Type}"), + }; + } + + private static string BuildRouterInstructions(RouteGraph graph, RouteUserInput input) + { + string entries = string.Join("、", graph.Nodes.Select(n => $"{n.Id}({n.Type})")); + string edges = graph.HasEdges + ? string.Join(";", graph.Edges.Select(e => $"{e.From}->{e.To}" + (string.IsNullOrWhiteSpace(e.When) ? "" : $"[{e.When}]"))) + : "专家之间无边"; + return + $""" + 你是交接图上的主管。禁止自己读文件或查询天气。 + 白名单专家:{entries} + 收到用户任务后,必须立刻 handoff 给最合适的一位,不要经过用不上的专家。 + 判断规则: + - 用户已经给出明确城市名、或只要查天气(如「拉斯维加斯」「帮我查成都天气」)→ 直接交给天气专家,不要先抽城市。 + - 用户给的是文件路径、或一段需要从中识别城市的长文本 → 先交给抽城市专家。 + 专家之间的边:{edges} + 不要向用户叙述交接过程,也不要在未交接时直接结束。 + """; + } + + private static string BuildSpecialistExtra(RouteNode node, RouteGraph graph, RouteUserInput input) + { + List lines = + [ + $"你是交接图上的节点 `{node.Id}`。", + "完成自己的工作后:若有下游专家,必须调用框架注入的 handoff 工具把对话交出去;不要只输出结果就结束。", + "若没有有效输入(例如抽不到城市),不要交接,用中文说明原因。", + "不要向用户叙述交接过程。", + ]; + + if (node.Type == SystemNodeTypes.FileCity) + { + lines.Add( + """ + 用户输入可能是文件路径,也可能是描述/问句/城市名单。你自己判断: + - 像本地路径且需要读内容 → 调用 ReadTextFile。 + - 像「帮我查成都天气」「明天去上海」→ 不要读文件,从用户消息提取城市。 + 不要把自然语言当成文件路径。 + """); + } + + List outgoing = graph.Edges + .Where(e => e.From.Equals(node.Id, StringComparison.OrdinalIgnoreCase)) + .ToList(); + if (outgoing.Count == 0) + { + lines.Add("你没有下游专家。做完后直接回答用户,不要再交接。"); + } + else + { + lines.Add("下游:" + string.Join(";", outgoing.Select(e => + { + RouteNode to = graph.Find(e.To)!; + string when = string.IsNullOrWhiteSpace(e.When) ? "" : $",条件 {e.When}"; + return $"{to.Id}({to.Type}{when})"; + }))); + } + + return string.Join("\n", lines); + } + + private static string HandoffReason(RouteNode target, bool fromRouter, string? when) + { + string title = string.IsNullOrWhiteSpace(target.Title) ? target.Type : target.Title; + string role = target.Type switch + { + SystemNodeTypes.FileCity => FileCityAgent.DefaultDescription, + SystemNodeTypes.Weather => WeatherAgent.DefaultDescription, + _ => target.Type, + }; + string prefix = fromRouter + ? target.Type switch + { + SystemNodeTypes.FileCity => "用户给了文件或需要从文本里识别城市时,交给这位专家。已经是明确城市名、只要查天气时不要交给他。", + SystemNodeTypes.Weather => "用户已经给出城市名或明确要查天气时,直接交给这位专家,不必先抽城市。", + _ => "把用户任务交给这位专家。", + } + : "把对话交给下一位专家。"; + string condition = string.IsNullOrWhiteSpace(when) + ? "" + : when.Equals("hasValidCities", StringComparison.OrdinalIgnoreCase) + ? " 仅当已经抽出有效城市时才交接。" + : when.Equals("!hasValidCities", StringComparison.OrdinalIgnoreCase) + ? " 仅当没有有效城市时才交接。" + : $" 条件:{when}。"; + return $"{prefix} 节点 {target.Id}({title}):{role}{condition}"; + } +} diff --git a/MAF1.Route/HandoffRun.cs b/MAF1.Route/HandoffRun.cs new file mode 100644 index 0000000..63e35cd --- /dev/null +++ b/MAF1.Route/HandoffRun.cs @@ -0,0 +1,165 @@ +using System.Text; +using System.Text.Encodings.Web; +using System.Text.Json; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.AI; + +namespace MAF1.Route; + +/// +/// 跑 Handoff。工具日志按 callId 去重:同一调用会同时出现在流式事件、完整回复、结束输出里,只打一遍。 +/// +public static class HandoffRun +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + }; + + public static async Task RunAsync(Workflow workflow, string task) + { + HashSet printedCalls = new(StringComparer.Ordinal); + List messages = [new(ChatRole.User, task)]; + await using StreamingRun run = await InProcessExecution.RunStreamingAsync(workflow, messages); + await run.TrySendMessageAsync(new TurnToken(emitEvents: true)); + + string? lastExecutorId = null; + string? lastSummary = null; + await foreach (WorkflowEvent evt in run.WatchStreamAsync()) + { + switch (evt) + { + case ExecutorFailedEvent failed: + Console.WriteLine(); + Console.WriteLine($"[失败] {failed.ExecutorId}: {failed.Data}"); + break; + + case AgentResponseUpdateEvent update: + if (update.ExecutorId != lastExecutorId) + { + Console.WriteLine(); + Console.Write($"{Friendly(update.ExecutorId)}: "); + lastExecutorId = update.ExecutorId; + } + + if (!string.IsNullOrEmpty(update.Update.Text)) + { + Console.Write(update.Update.Text); + } + + WriteNewTools(update.ExecutorId, update.Update.Contents, printedCalls); + break; + + case WorkflowOutputEvent output: + string? summary = ReadSummary(output); + if (!string.IsNullOrWhiteSpace(summary) && summary != lastSummary) + { + lastSummary = summary; + Console.WriteLine(); + Console.WriteLine(); + Console.WriteLine($"[最终输出] {Trim(summary)}"); + } + + break; + } + } + + Console.WriteLine(); + Console.WriteLine("======== Handoff 本轮结束 ========"); + } + + private static void WriteNewTools(string executorId, IList contents, HashSet printedCalls) + { + foreach (AIContent content in contents) + { + if (content is FunctionCallContent call) + { + string key = "call:" + (string.IsNullOrWhiteSpace(call.CallId) ? call.Name + FormatArguments(call.Arguments) : call.CallId); + if (!printedCalls.Add(key)) + { + continue; + } + + Console.WriteLine(); + Console.WriteLine($"[工具调用] {Friendly(executorId)} → {call.Name}"); + Console.WriteLine($" 参数: {FormatArguments(call.Arguments)}"); + continue; + } + + if (content is FunctionResultContent result) + { + string key = "result:" + result.CallId; + if (!printedCalls.Add(key)) + { + continue; + } + + Console.WriteLine(); + Console.WriteLine($"[工具返回] {Friendly(executorId)} → {result.CallId}"); + Console.WriteLine($" 结果: {Trim(FormatResult(result.Result))}"); + } + } + } + + private static string? ReadSummary(WorkflowOutputEvent output) + { + if (output.As>() is List chat) + { + return LastAssistantText(chat); + } + + return output.As(); + } + + private static string FormatArguments(IDictionary? arguments) + { + if (arguments is null || arguments.Count == 0) + { + return "{}"; + } + + try + { + return JsonSerializer.Serialize(arguments, JsonOptions); + } + catch + { + return string.Join(", ", arguments.Select(kv => $"{kv.Key}={kv.Value}")); + } + } + + private static string FormatResult(object? result) + => result is null ? "(空)" : result.ToString() ?? "(空)"; + + private static string LastAssistantText(List chat) + { + string text = ""; + foreach (ChatMessage message in chat) + { + if (message.Role == ChatRole.Assistant && !string.IsNullOrWhiteSpace(message.Text)) + { + text = message.Text; + } + } + + return text; + } + + private static string Friendly(string executorId) + { + int us = executorId.IndexOf('_'); + if (us > 0 && executorId[(us + 1)..] == executorId[..us]) + { + return executorId[..us]; + } + + return executorId; + } + + private static string Trim(string text) + { + string oneLine = text.Replace("\r\n", " ").Replace('\n', ' ').Trim(); + return oneLine.Length <= 500 ? oneLine : oneLine[..500] + "…"; + } +} diff --git a/MAF1.Route/MAF1.Route.csproj b/MAF1.Route/MAF1.Route.csproj new file mode 100644 index 0000000..e5db296 --- /dev/null +++ b/MAF1.Route/MAF1.Route.csproj @@ -0,0 +1,35 @@ + + + + Exe + net10.0 + enable + enable + MAF1.Route + app.manifest + + + + + + + + + + + + + + + + PreserveNewest + + + PreserveNewest + + + PreserveNewest + + + + diff --git a/MAF1.Route/Program.cs b/MAF1.Route/Program.cs new file mode 100644 index 0000000..9100fb6 --- /dev/null +++ b/MAF1.Route/Program.cs @@ -0,0 +1,5 @@ +using MAF1.Route; +using MAF1.Utils; + +WindowsConsole.EnableUtf8(); +await CliHost.RunAsync(args); diff --git a/MAF1.Route/Properties/launchSettings.json b/MAF1.Route/Properties/launchSettings.json new file mode 100644 index 0000000..fffb29f --- /dev/null +++ b/MAF1.Route/Properties/launchSettings.json @@ -0,0 +1,22 @@ +{ + "profiles": { + "route": { + "commandName": "Project", + "commandLineArgs": "Graphs/city-weather.json", + "environmentVariables": { + "OPENAI_ENDPOINT": "https://api.deepseek.com", + "OPENAI_CHAT_MODEL": "deepseek-v4-flash", + "OPENAI_API_KEY": "sk-b31330cf3a414d86ab614b9dfb3650c3" + } + }, + "nodes-only": { + "commandName": "Project", + "commandLineArgs": "Graphs/nodes-only.json 请查询成都的天气", + "environmentVariables": { + "OPENAI_ENDPOINT": "https://api.deepseek.com", + "OPENAI_CHAT_MODEL": "deepseek-v4-flash", + "OPENAI_API_KEY": "sk-b31330cf3a414d86ab614b9dfb3650c3" + } + } + } +} diff --git a/MAF1.Route/RouteGraph.cs b/MAF1.Route/RouteGraph.cs new file mode 100644 index 0000000..669a3af --- /dev/null +++ b/MAF1.Route/RouteGraph.cs @@ -0,0 +1,108 @@ +using System.Text.Json; +using MAF1.Agents; + +namespace MAF1.Route; + +/// +/// 方案 C 的图:节点 = 主管能调的专家白名单;边 = 允许的交接。 +/// 没有边时,名单上的节点可以按任意顺序调用。 +/// +public sealed class RouteGraph +{ + public List Nodes { get; set; } = []; + public List Edges { get; set; } = []; + + public bool HasEdges => Edges.Count > 0; + + /// 主管第一跳只能到这些节点:无边时整张白名单;有边时取没有入边的入口。 + public IReadOnlyList EntryNodes() + { + if (!HasEdges) + { + return Nodes; + } + + HashSet targets = new(Edges.Select(e => e.To), StringComparer.OrdinalIgnoreCase); + List sources = Nodes.Where(n => !targets.Contains(n.Id)).ToList(); + return sources.Count > 0 ? sources : Nodes; + } + + public bool HasOutgoing(string nodeId) + => Edges.Any(e => e.From.Equals(nodeId, StringComparison.OrdinalIgnoreCase)); + + public RouteNode? Find(string nodeId) + => Nodes.FirstOrDefault(n => n.Id.Equals(nodeId, StringComparison.OrdinalIgnoreCase)); + + public static JsonSerializerOptions JsonOptions { get; } = new() + { + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true, + }; + + public static RouteGraph LoadFile(string path) + { + string json = File.ReadAllText(path); + RouteGraph graph = JsonSerializer.Deserialize(json, JsonOptions) + ?? throw new InvalidOperationException($"无法解析路由图:{path}"); + graph.Validate(); + return graph; + } + + public void Validate() + { + if (Nodes.Count == 0) + { + throw new InvalidOperationException("图里没有任何节点。方案 C 至少要放一个专家到白名单。"); + } + + HashSet ids = new(StringComparer.OrdinalIgnoreCase); + foreach (RouteNode node in Nodes) + { + if (string.IsNullOrWhiteSpace(node.Id)) + { + throw new InvalidOperationException("节点缺少 id。"); + } + + if (!ids.Add(node.Id)) + { + throw new InvalidOperationException($"节点 id 重复:{node.Id}"); + } + + if (!SystemNodeTypes.IsKnown(node.Type)) + { + throw new InvalidOperationException( + $"节点 `{node.Id}` 的类型 `{node.Type}` 不是系统专家。当前支持 {SystemNodeTypes.FileCity} / {SystemNodeTypes.Weather}。"); + } + } + + foreach (RouteEdge edge in Edges) + { + if (Find(edge.From) is null) + { + throw new InvalidOperationException($"边的 from `{edge.From}` 不在图上。"); + } + + if (Find(edge.To) is null) + { + throw new InvalidOperationException($"边的 to `{edge.To}` 不在图上。"); + } + } + } +} + +public sealed class RouteNode +{ + public string Id { get; set; } = ""; + public string Type { get; set; } = ""; + public string Title { get; set; } = ""; + public Dictionary Config { get; set; } = new(StringComparer.OrdinalIgnoreCase); +} + +public sealed class RouteEdge +{ + public string From { get; set; } = ""; + public string To { get; set; } = ""; + /// 可选。hasValidCities / !hasValidCities / 空=只要拓扑允许就可以走。 + public string? When { get; set; } +} diff --git a/MAF1.Route/RouteOrchestrator.cs b/MAF1.Route/RouteOrchestrator.cs new file mode 100644 index 0000000..e10674b --- /dev/null +++ b/MAF1.Route/RouteOrchestrator.cs @@ -0,0 +1,35 @@ +using MAF1.Tools; +using MAF1.Utils; +using Microsoft.Agents.AI.Workflows; + +namespace MAF1.Route; + +/// 把图编译成 WithHandoff 工作流并跑一轮。 +public static class RouteOrchestrator +{ + public static async Task RunAsync( + AgentFactory factory, + WeatherTools weatherTools, + RouteGraph graph, + RouteUserInput input) + { + string task = input.ToTask(); + Console.WriteLine("模式 route:方案 C + MAF WithHandoff(图 = 白名单,边 = 允许交接)"); + Console.WriteLine($"图节点: {string.Join("、", graph.Nodes.Select(DescribeNode))}"); + Console.WriteLine(graph.HasEdges + ? $"专家之间的边: {string.Join("、", graph.Edges.Select(e => $"{e.From}->{e.To}" + (string.IsNullOrWhiteSpace(e.When) ? "" : $" [{e.When}]")))}" + : "专家之间无边"); + Console.WriteLine($"主管可第一跳: {string.Join("、", graph.Nodes.Select(n => n.Id))}(按用户原话选择,不必先抽城市)"); + Console.WriteLine($"输入: {input.Describe()}"); + Console.WriteLine(); + + Workflow workflow = HandoffGraphBuilder.Build(factory, weatherTools, graph, input); + await HandoffRun.RunAsync(workflow, task); + } + + private static string DescribeNode(RouteNode node) + { + string title = string.IsNullOrWhiteSpace(node.Title) ? node.Type : node.Title; + return $"{node.Id}:{title}"; + } +} diff --git a/MAF1.Route/RouteUserInput.cs b/MAF1.Route/RouteUserInput.cs new file mode 100644 index 0000000..bdf7348 --- /dev/null +++ b/MAF1.Route/RouteUserInput.cs @@ -0,0 +1,97 @@ +using MAF1.Agents; + +namespace MAF1.Route; + +/// 控制台里用户原话。路径还是描述,交给专家自己判断,不在 C# 里写死。 +public sealed class RouteUserInput +{ + public string Raw { get; init; } = ""; + public string? ResolvedFilePath { get; init; } + + public bool HasRaw => !string.IsNullOrWhiteSpace(Raw); + + public string Describe() + { + if (!string.IsNullOrWhiteSpace(ResolvedFilePath)) + { + return $"已解析到文件 {ResolvedFilePath}"; + } + + string preview = Raw.Replace("\r\n", " ").Replace('\n', ' ').Trim(); + if (preview.Length > 80) + { + preview = preview[..80] + "…"; + } + + return $"用户原话「{preview}」"; + } + + public string ToTask() + => $""" + 请根据用户下面这句话完成任务:提取有效城市,并在图允许时查询天气。 + 若这句话是本地文件路径,就读文件;若是描述、问句或城市名单,就直接从这句话提取,不要读文件。 + + 用户输入: + {Raw} + """; + + public static RouteUserInput ReadFromConsole(RouteGraph graph) + { + string? fallback = graph.Nodes + .Select(n => n.Type == SystemNodeTypes.FileCity && n.Config.TryGetValue("filePath", out string? path) ? path : null) + .FirstOrDefault(p => !string.IsNullOrWhiteSpace(p)); + + Console.WriteLine("请输入:本地文件路径,或一段描述/城市名单(例如:帮我查成都和杭州的天气)。"); + if (!string.IsNullOrWhiteSpace(fallback)) + { + Console.WriteLine($"直接回车则使用图里的默认文件:{fallback}"); + } + + Console.Write("> "); + string? line = Console.ReadLine(); + string trimmed = line?.Trim() ?? ""; + if (trimmed.Length == 0) + { + trimmed = fallback ?? ""; + } + + return new RouteUserInput + { + Raw = trimmed, + ResolvedFilePath = ResolveExisting(trimmed), + }; + } + + public static string? ResolveExisting(string path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return null; + } + + try + { + if (path.IndexOfAny(Path.GetInvalidPathChars()) >= 0) + { + return null; + } + } + catch (ArgumentException) + { + return null; + } + + if (File.Exists(path)) + { + return Path.GetFullPath(path); + } + + string fromBase = Path.Combine(AppContext.BaseDirectory, path); + if (File.Exists(fromBase)) + { + return Path.GetFullPath(fromBase); + } + + return null; + } +} diff --git a/MAF1.Route/app.manifest b/MAF1.Route/app.manifest new file mode 100644 index 0000000..df9da9d --- /dev/null +++ b/MAF1.Route/app.manifest @@ -0,0 +1,9 @@ + + + + + + UTF-8 + + + diff --git a/MAF1.Route/appsettings.json b/MAF1.Route/appsettings.json new file mode 100644 index 0000000..59c0d5e --- /dev/null +++ b/MAF1.Route/appsettings.json @@ -0,0 +1,21 @@ +{ + "Llm": { + "ApiKey": "", + "Endpoint": "https://api.deepseek.com", + "Model": "deepseek-v4-flash" + }, + "Workflow": { + "DecisionTimeoutSeconds": 30 + }, + "Weather": { + "Provider": "Wttr", + "Language": "zh", + "Wttr": { + "UrlTemplate": "https://wttr.in/{location}?lang={lang}&format=3" + }, + "OpenWeather": { + "UrlTemplate": "https://api.openweathermap.org/data/2.5/weather?q={location}&appid={apiKey}&units=metric&lang={lang}", + "ApiKey": "" + } + } +} diff --git a/MAF1.slnx b/MAF1.slnx index 1894f29..397a6df 100644 --- a/MAF1.slnx +++ b/MAF1.slnx @@ -8,5 +8,6 @@ + diff --git a/MAF1/CliHost.cs b/MAF1/CliHost.cs index 51c32b7..bb23358 100644 --- a/MAF1/CliHost.cs +++ b/MAF1/CliHost.cs @@ -102,6 +102,9 @@ internal static class CliHost 网页编排在另一个项目: dotnet run --project MAF1.Web + + 方案 C 路由在另一个项目: + dotnet run --project MAF1.Route """); } } diff --git a/README.md b/README.md index 3295a72..ae01097 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,8 @@ MAF1 是一个基于 **.NET 10** 和 **Microsoft Agents AI** 的多智能体演示仓库。同一条「读文件抽城市 → 查天气」链路拆成两个可执行项目: 1. **MAF1**:命令行工作流。用 Microsoft Agents AI Workflows 对比「条件写在节点里」和「条件写在边上」。 -2. **MAF1.Web**:可视化编排器。浏览器里拖节点、连线、配条件,然后一键运行。 +2. **MAF1.Route**:命令行路由。方案 C 的图编译成 `AgentWorkflowBuilder.CreateHandoffBuilderWith` + `WithHandoff`;专家仍是 Core 里的 Agent。 +3. **MAF1.Web**:可视化编排器。浏览器里拖节点、连线、配条件,然后一键运行。 内置 Agent 在 `MAF1.Core` 里;网页还可以把同一套能力以 **独立进程插件** 的形式画到画布上。LLM 的 endpoint / API Key / 模型由各宿主注入,**不会**画成节点端口,也不会写进流程图 JSON。 @@ -29,13 +30,13 @@ MAF1 是一个基于 **.NET 10** 和 **Microsoft Agents AI** 的多智能体演 | 部分 | 说明 | |------|------| | 运行时 | .NET 10(`net10.0`) | -| 控制台 | `MAF1`:普通控制台,Microsoft Agents AI Workflows | +| 控制台 | `MAF1`:工作流;`MAF1.Route`:方案 C 路由 | | 网页 | `MAF1.Web`:ASP.NET Core Minimal API + `wwwroot` | | LLM | `Azure.AI.OpenAI` + `Microsoft.Agents.AI.OpenAI`(兼容 OpenAI / Azure OpenAI / DeepSeek 等) | | 前端 | `MAF1.Web/wwwroot` 下原生 HTML / CSS / JS,无 npm 依赖 | | 插件 | 独立控制台进程,协议见 `MAF1.Core/PluginContract`,由网页宿主扫描 | -解决方案文件:`MAF1.slnx`(`MAF1`、`MAF1.Web`、`MAF1.Core`、两个插件工程)。 +解决方案文件:`MAF1.slnx`(`MAF1`、`MAF1.Route`、`MAF1.Web`、`MAF1.Core`、两个插件工程)。 --- @@ -59,6 +60,10 @@ MAF1 是一个基于 **.NET 10** 和 **Microsoft Agents AI** 的多智能体演 │ ├── wwwroot/ # 可视化界面 │ ├── Data/ │ └── appsettings.json +├── MAF1.Route/ # 控制台路由(方案 C) +│ ├── Graphs/ # 白名单图 JSON(有边 / 无边) +│ ├── Data/ +│ └── appsettings.json ├── MAF1.Core/ # 共享:Agent、Tool、LLM、插件协议 └── plugins/ ├── file-city/ @@ -167,6 +172,23 @@ dotnet run --project MAF1 -- edge Data/not-cities.txt 输入 `1` 或回车结束;输入 `2` 后再填城市名继续查天气;也可以直接输入 `成都`。等待秒数与网页相同,来自 `Workflow:DecisionTimeoutSeconds`。 +### 5. 命令行路由(方案 C) + +`MAF1.Route` 用 Microsoft Agents AI 的 **`WithHandoff`** 跑方案 C:图 JSON 是专家白名单,边是允许交接。主管是入口 Agent,框架给每条边注入 `handoff_to_*` 工具,控制权随对话转交。 + +- **节点** = 白名单。图上没有的类型不能调。 +- **有边** = 只能沿边 `WithHandoff`(`Graphs/city-weather.json`:必须先抽城市再查天气)。 +- **无边** = 主管可交接给名单内任一专家(`Graphs/nodes-only.json`:可以直接查「成都」天气)。 + +抽不到城市时,抽城市专家**不交接**,本轮结束(Handoff 不像工作流 CLI 那样弹确认框)。 + +```bash +dotnet run --project MAF1.Route +dotnet run --project MAF1.Route -- Graphs/city-weather.json +dotnet run --project MAF1.Route -- Graphs/city-weather.json Data/not-cities.txt +dotnet run --project MAF1.Route -- Graphs/nodes-only.json 请查询成都的天气 +``` + 启动配置: | 项目 | Profile | 作用 | @@ -174,12 +196,14 @@ dotnet run --project MAF1 -- edge Data/not-cities.txt | `MAF1.Web` | `designer` | 网页编排器 | | `MAF1` | `node` | CLI,节点内判断 | | `MAF1` | `edge` | CLI,边上判断 | +| `MAF1.Route` | `route` | CLI 路由,有边白名单 | +| `MAF1.Route` | `nodes-only` | CLI 路由,无边自选顺序 | --- ## 配置说明 -`MAF1/appsettings.json` 与 `MAF1.Web/appsettings.json` 会分别复制到各自输出目录。网页项目额外包含 `Plugins` / `Credentials`。主要段落: +`MAF1/appsettings.json`、`MAF1.Route/appsettings.json` 与 `MAF1.Web/appsettings.json` 会分别复制到各自输出目录。网页项目额外包含 `Plugins` / `Credentials`。主要段落: ### Llm @@ -413,7 +437,9 @@ AgentRuntime `MAF1` → `CliHost` → `CityWeatherWorkflow`(node / edge)→ `WorkflowOrchestration`。缺城市时走 `RequestPort`,与网页同一套默认方案 + 超时。 -`MAF1.Core` 被控制台、网页与插件共用,避免两套 Agent 逻辑分叉。 +`MAF1.Route` → `HandoffGraphBuilder`(`CreateHandoffBuilderWith` + `WithHandoff`)→ Core 里的 FileCity / Weather。图 JSON 决定白名单和允许交接;**原来的 workflow CLI 仍在 MAF1 里。** + +`MAF1.Core` 被控制台、路由、网页与插件共用,避免两套 Agent 逻辑分叉。 --- @@ -454,7 +480,7 @@ AgentRuntime 当前 URL 写死为 `127.0.0.1:5288`。关掉占用进程,或临时改 `MAF1.Web/Program.cs` / `MAF1.Web/Properties/launchSettings.json`。 **没有自动化测试 / Docker** -仓库目前没有测试项目和容器文件。验证方式:UI 示例图 + CLI `node` / `edge`。 +仓库目前没有测试项目和容器文件。验证方式:UI 示例图 + CLI `node` / `edge` + `MAF1.Route`。 ---