feat: 新增方案 C 的 Handoff 路由宿主,并让抽城市支持自然语言。
把图 JSON 编译成 CreateHandoffBuilderWith + WithHandoff;Agent 固定 Id 以免交接工具名错位;DeepSeek 关闭 thinking,避免多轮丢掉 reasoning_content 导致 400。
This commit is contained in:
@@ -13,22 +13,39 @@ namespace MAF1.Agents.FileCity;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static class FileCityAgent
|
public static class FileCityAgent
|
||||||
{
|
{
|
||||||
|
public const string DefaultDescription = "从文件或用户描述中提取有效城市名。";
|
||||||
|
|
||||||
/// <summary>创建带 ReadTextFile 工具的聊天 Agent。模型必须先读文件,不能凭文件名猜内容。</summary>
|
/// <summary>创建带 ReadTextFile 工具的聊天 Agent。模型必须先读文件,不能凭文件名猜内容。</summary>
|
||||||
public static AIAgent Create(AgentFactory factory)
|
public static AIAgent Create(
|
||||||
|
AgentFactory factory,
|
||||||
|
string name = "FileCityAgent",
|
||||||
|
string? description = null,
|
||||||
|
string? extraInstructions = null)
|
||||||
{
|
{
|
||||||
return factory.CreateAgent(
|
string instructions =
|
||||||
name: "FileCityAgent",
|
|
||||||
instructions:
|
|
||||||
"""
|
"""
|
||||||
你负责读取用户指定的本地文件,并从中提取有效城市名。
|
你负责从用户材料里提取有效城市名。
|
||||||
必须先调用 ReadTextFile 读取文件,不要猜测文件内容。
|
用户可能给你两种输入,你必须自己判断:
|
||||||
|
1) 本地文件路径(如 Data/cities.txt、C:\\a.txt)→ 必须先调用 ReadTextFile 读文件,不要猜内容。
|
||||||
|
2) 自然语言描述或城市名单(如「帮我查成都和杭州的天气」「明天去上海」)→ 不要调用 ReadTextFile,直接从用户消息里提取城市。
|
||||||
|
不要把描述、问句、城市名当成文件路径去读。
|
||||||
有效城市名:现实世界中真实存在的城市(如 成都、北京、Amsterdam)。
|
有效城市名:现实世界中真实存在的城市(如 成都、北京、Amsterdam)。
|
||||||
忽略空行、注释、人名、水果、随意单词等不是城市的内容。
|
忽略空行、注释、人名、水果、随意单词等不是城市的内容。
|
||||||
|
城市可能出现在句子、列表或表格里,都要提取。
|
||||||
只输出一个 JSON 对象,不要 Markdown,不要其它说明:
|
只输出一个 JSON 对象,不要 Markdown,不要其它说明:
|
||||||
{"hasValidCities": true, "cities": ["成都"], "reason": "说明"}
|
{"hasValidCities": true, "cities": ["成都"], "reason": "说明"}
|
||||||
若没有有效城市:{"hasValidCities": false, "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)]);
|
tools: [AIFunctionFactory.Create(FileTools.ReadTextFile)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace MAF1.Agents;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 系统节点类型 id,和网页画布、路由图 JSON 共用,避免各写一份字符串。
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -11,16 +11,30 @@ namespace MAF1.Agents.Weather;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static class WeatherAgent
|
public static class WeatherAgent
|
||||||
{
|
{
|
||||||
|
public const string DefaultDescription = "按城市列表查询天气并汇总。";
|
||||||
|
|
||||||
/// <summary>创建带 GetWeather 工具的 Agent。instructions 要求每个城市都调工具,禁止编造气温。</summary>
|
/// <summary>创建带 GetWeather 工具的 Agent。instructions 要求每个城市都调工具,禁止编造气温。</summary>
|
||||||
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(
|
string instructions =
|
||||||
name: "WeatherAgent",
|
|
||||||
instructions:
|
|
||||||
"""
|
"""
|
||||||
你负责查询天气。用户给出的每个城市都必须调用一次 GetWeather,不要编造天气。
|
你负责查询天气。用户给出的每个城市都必须调用一次 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)]);
|
tools: [AIFunctionFactory.Create(weatherTools.GetWeather)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
-5
@@ -1,12 +1,11 @@
|
|||||||
using MAF1.Decisions;
|
namespace MAF1.Decisions;
|
||||||
|
|
||||||
namespace MAF1.Orchestration;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// CLI 版确认框。回车 / 1 = 结束查询;2 = 再输入城市;也可以直接打「成都」。
|
/// CLI 版确认框。回车 / 1 = 结束查询;2 = 再输入城市;也可以直接打「成都」。
|
||||||
/// ReadLine 和超时赛跑:到期返回 TimedOut,工作流走默认方案。
|
/// ReadLine 和超时赛跑:到期返回 TimedOut,走默认方案。
|
||||||
|
/// MAF1 工作流 CLI 与 MAF1.Route 共用。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal static class ConsoleDecisionPrompt
|
public static class ConsoleDecisionPrompt
|
||||||
{
|
{
|
||||||
public static async Task<DecisionAnswer> WaitAsync(DecisionRequest request)
|
public static async Task<DecisionAnswer> WaitAsync(DecisionRequest request)
|
||||||
{
|
{
|
||||||
@@ -10,9 +10,11 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Compile Include="Agents\AgentStepResult.cs" />
|
<Compile Include="Agents\AgentStepResult.cs" />
|
||||||
|
<Compile Include="Agents\SystemNodeTypes.cs" />
|
||||||
<Compile Include="Agents\FileCity\CityExtraction.cs" />
|
<Compile Include="Agents\FileCity\CityExtraction.cs" />
|
||||||
<Compile Include="Agents\FileCity\FileCityAgent.cs" />
|
<Compile Include="Agents\FileCity\FileCityAgent.cs" />
|
||||||
<Compile Include="Agents\Weather\WeatherAgent.cs" />
|
<Compile Include="Agents\Weather\WeatherAgent.cs" />
|
||||||
|
<Compile Include="Decisions\ConsoleDecisionPrompt.cs" />
|
||||||
<Compile Include="Decisions\DecisionModels.cs" />
|
<Compile Include="Decisions\DecisionModels.cs" />
|
||||||
<Compile Include="PluginContract\PluginManifest.cs" />
|
<Compile Include="PluginContract\PluginManifest.cs" />
|
||||||
<Compile Include="PluginContract\PluginStdio.cs" />
|
<Compile Include="PluginContract\PluginStdio.cs" />
|
||||||
@@ -20,6 +22,7 @@
|
|||||||
<Compile Include="Tools\WeatherOptions.cs" />
|
<Compile Include="Tools\WeatherOptions.cs" />
|
||||||
<Compile Include="Tools\WeatherTools.cs" />
|
<Compile Include="Tools\WeatherTools.cs" />
|
||||||
<Compile Include="Utils\AgentFactory.cs" />
|
<Compile Include="Utils\AgentFactory.cs" />
|
||||||
|
<Compile Include="Utils\DeepSeekThinking.cs" />
|
||||||
<Compile Include="Utils\JsonText.cs" />
|
<Compile Include="Utils\JsonText.cs" />
|
||||||
<Compile Include="Utils\LlmOptions.cs" />
|
<Compile Include="Utils\LlmOptions.cs" />
|
||||||
<Compile Include="Utils\WindowsConsole.cs" />
|
<Compile Include="Utils\WindowsConsole.cs" />
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ namespace MAF1.Utils;
|
|||||||
public sealed class AgentFactory
|
public sealed class AgentFactory
|
||||||
{
|
{
|
||||||
private readonly ChatClient _chatClient;
|
private readonly ChatClient _chatClient;
|
||||||
|
private readonly bool _disableDeepSeekThinking;
|
||||||
|
|
||||||
public AgentFactory(LlmOptions options)
|
public AgentFactory(LlmOptions options)
|
||||||
{
|
{
|
||||||
@@ -33,6 +34,8 @@ public sealed class AgentFactory
|
|||||||
? DefaultModelFor(options.Endpoint)
|
? DefaultModelFor(options.Endpoint)
|
||||||
: options.Model;
|
: options.Model;
|
||||||
|
|
||||||
|
_disableDeepSeekThinking = LooksLikeDeepSeek(options.Endpoint);
|
||||||
|
|
||||||
if (LooksLikeAzureOpenAI(options.Endpoint))
|
if (LooksLikeAzureOpenAI(options.Endpoint))
|
||||||
{
|
{
|
||||||
Console.Error.WriteLine($"使用 Azure OpenAI: {options.Endpoint} 部署: {model}");
|
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}");
|
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)
|
_chatClient = new OpenAIClient(new ApiKeyCredential(options.ApiKey), clientOptions)
|
||||||
.GetChatClient(model);
|
.GetChatClient(model);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>把 ChatClient 包成 Microsoft.Agents.AI 的 AIAgent,并挂上 tools。</summary>
|
/// <summary>把 ChatClient 包成 Microsoft.Agents.AI 的 AIAgent,并挂上 tools。Id 固定为 name,Handoff 工具才是 handoff_to_n1。</summary>
|
||||||
public AIAgent CreateAgent(string name, string instructions, IList<AITool>? tools = null)
|
public AIAgent CreateAgent(string name, string instructions, IList<AITool>? 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>合并配置文件和环境变量。插件进程里环境变量通常由宿主写入。</summary>
|
/// <summary>合并配置文件和环境变量。插件进程里环境变量通常由宿主写入。</summary>
|
||||||
@@ -78,6 +101,10 @@ public sealed class AgentFactory
|
|||||||
return "gpt-4o-mini";
|
return "gpt-4o-mini";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool LooksLikeDeepSeek(string? endpoint)
|
||||||
|
=> !string.IsNullOrWhiteSpace(endpoint)
|
||||||
|
&& endpoint.Contains("deepseek", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
private static bool LooksLikeAzureOpenAI(string? endpoint)
|
private static bool LooksLikeAzureOpenAI(string? endpoint)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(endpoint) || !Uri.TryCreate(endpoint, UriKind.Absolute, out Uri? uri))
|
if (string.IsNullOrWhiteSpace(endpoint) || !Uri.TryCreate(endpoint, UriKind.Absolute, out Uri? uri))
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using System.ClientModel.Primitives;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.Extensions.AI;
|
||||||
|
using OpenAI.Chat;
|
||||||
|
|
||||||
|
namespace MAF1.Utils;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// DeepSeek V4 默认开 thinking。Handoff 多轮带 tools 时必须回传 reasoning_content,
|
||||||
|
/// 客户端会丢掉该字段导致 HTTP 400。请求里关闭 thinking 即可。
|
||||||
|
/// </summary>
|
||||||
|
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<string, string>
|
||||||
|
{
|
||||||
|
["type"] = "disabled",
|
||||||
|
}));
|
||||||
|
#pragma warning restore SCME0001
|
||||||
|
return raw;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
using MAF1.Tools;
|
||||||
|
using MAF1.Utils;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
|
||||||
|
namespace MAF1.Route;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 命令行宿主:读配置和图 JSON,用 MAF WithHandoff 按方案 C 跑路由。
|
||||||
|
/// </summary>
|
||||||
|
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<WeatherOptions>() ?? 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
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
成都
|
||||||
|
阿姆斯特丹
|
||||||
|
北京
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
香蕉
|
||||||
|
hello
|
||||||
|
不是城市
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "n1",
|
||||||
|
"type": "fileCity-system",
|
||||||
|
"title": "抽城市",
|
||||||
|
"config": { "filePath": "Data/cities.txt" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "n2",
|
||||||
|
"type": "weather-system",
|
||||||
|
"title": "查天气"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"edges": []
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 把方案 C 的图编译成 MAF Handoff:节点 = 白名单(主管可第一跳交给其中任何一个);
|
||||||
|
/// 边 = 专家之间允许的交接。主管根据用户原话自己选第一跳,不必先经过拓扑入口。
|
||||||
|
/// </summary>
|
||||||
|
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<string, AIAgent> 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<AIAgent> 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<string> lines =
|
||||||
|
[
|
||||||
|
$"你是交接图上的节点 `{node.Id}`。",
|
||||||
|
"完成自己的工作后:若有下游专家,必须调用框架注入的 handoff 工具把对话交出去;不要只输出结果就结束。",
|
||||||
|
"若没有有效输入(例如抽不到城市),不要交接,用中文说明原因。",
|
||||||
|
"不要向用户叙述交接过程。",
|
||||||
|
];
|
||||||
|
|
||||||
|
if (node.Type == SystemNodeTypes.FileCity)
|
||||||
|
{
|
||||||
|
lines.Add(
|
||||||
|
"""
|
||||||
|
用户输入可能是文件路径,也可能是描述/问句/城市名单。你自己判断:
|
||||||
|
- 像本地路径且需要读内容 → 调用 ReadTextFile。
|
||||||
|
- 像「帮我查成都天气」「明天去上海」→ 不要读文件,从用户消息提取城市。
|
||||||
|
不要把自然语言当成文件路径。
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
|
||||||
|
List<RouteEdge> 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}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 跑 Handoff。工具日志按 callId 去重:同一调用会同时出现在流式事件、完整回复、结束输出里,只打一遍。
|
||||||
|
/// </summary>
|
||||||
|
public static class HandoffRun
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
{
|
||||||
|
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||||
|
};
|
||||||
|
|
||||||
|
public static async Task RunAsync(Workflow workflow, string task)
|
||||||
|
{
|
||||||
|
HashSet<string> printedCalls = new(StringComparer.Ordinal);
|
||||||
|
List<ChatMessage> 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<AIContent> contents, HashSet<string> 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<List<ChatMessage>>() is List<ChatMessage> chat)
|
||||||
|
{
|
||||||
|
return LastAssistantText(chat);
|
||||||
|
}
|
||||||
|
|
||||||
|
return output.As<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatArguments(IDictionary<string, object?>? 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<ChatMessage> 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] + "…";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<RootNamespace>MAF1.Route</RootNamespace>
|
||||||
|
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Agents.AI.Workflows" Version="1.19.0" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.0" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.0" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\MAF1.Core\MAF1.Core.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<None Update="appsettings.json">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</None>
|
||||||
|
<None Update="Data\**\*">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</None>
|
||||||
|
<None Update="Graphs\**\*">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</None>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
using MAF1.Route;
|
||||||
|
using MAF1.Utils;
|
||||||
|
|
||||||
|
WindowsConsole.EnableUtf8();
|
||||||
|
await CliHost.RunAsync(args);
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using MAF1.Agents;
|
||||||
|
|
||||||
|
namespace MAF1.Route;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 方案 C 的图:节点 = 主管能调的专家白名单;边 = 允许的交接。
|
||||||
|
/// 没有边时,名单上的节点可以按任意顺序调用。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class RouteGraph
|
||||||
|
{
|
||||||
|
public List<RouteNode> Nodes { get; set; } = [];
|
||||||
|
public List<RouteEdge> Edges { get; set; } = [];
|
||||||
|
|
||||||
|
public bool HasEdges => Edges.Count > 0;
|
||||||
|
|
||||||
|
/// <summary>主管第一跳只能到这些节点:无边时整张白名单;有边时取没有入边的入口。</summary>
|
||||||
|
public IReadOnlyList<RouteNode> EntryNodes()
|
||||||
|
{
|
||||||
|
if (!HasEdges)
|
||||||
|
{
|
||||||
|
return Nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
HashSet<string> targets = new(Edges.Select(e => e.To), StringComparer.OrdinalIgnoreCase);
|
||||||
|
List<RouteNode> 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<RouteGraph>(json, JsonOptions)
|
||||||
|
?? throw new InvalidOperationException($"无法解析路由图:{path}");
|
||||||
|
graph.Validate();
|
||||||
|
return graph;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Validate()
|
||||||
|
{
|
||||||
|
if (Nodes.Count == 0)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("图里没有任何节点。方案 C 至少要放一个专家到白名单。");
|
||||||
|
}
|
||||||
|
|
||||||
|
HashSet<string> 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<string, string> Config { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class RouteEdge
|
||||||
|
{
|
||||||
|
public string From { get; set; } = "";
|
||||||
|
public string To { get; set; } = "";
|
||||||
|
/// <summary>可选。hasValidCities / !hasValidCities / 空=只要拓扑允许就可以走。</summary>
|
||||||
|
public string? When { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
using MAF1.Tools;
|
||||||
|
using MAF1.Utils;
|
||||||
|
using Microsoft.Agents.AI.Workflows;
|
||||||
|
|
||||||
|
namespace MAF1.Route;
|
||||||
|
|
||||||
|
/// <summary>把图编译成 WithHandoff 工作流并跑一轮。</summary>
|
||||||
|
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}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
using MAF1.Agents;
|
||||||
|
|
||||||
|
namespace MAF1.Route;
|
||||||
|
|
||||||
|
/// <summary>控制台里用户原话。路径还是描述,交给专家自己判断,不在 C# 里写死。</summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||||
|
<assemblyIdentity version="1.0.0.0" name="MAF1.Route"/>
|
||||||
|
<application xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||||
|
<windowsSettings>
|
||||||
|
<activeCodePage xmlns="http://schemas.microsoft.com/SMI/2019/WindowsSettings">UTF-8</activeCodePage>
|
||||||
|
</windowsSettings>
|
||||||
|
</application>
|
||||||
|
</assembly>
|
||||||
@@ -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": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,5 +8,6 @@
|
|||||||
</Folder>
|
</Folder>
|
||||||
<Project Path="MAF1.Core/MAF1.Core.csproj" />
|
<Project Path="MAF1.Core/MAF1.Core.csproj" />
|
||||||
<Project Path="MAF1/MAF1.csproj" />
|
<Project Path="MAF1/MAF1.csproj" />
|
||||||
|
<Project Path="MAF1.Route/MAF1.Route.csproj" />
|
||||||
<Project Path="MAF1.Web/MAF1.Web.csproj" />
|
<Project Path="MAF1.Web/MAF1.Web.csproj" />
|
||||||
</Solution>
|
</Solution>
|
||||||
|
|||||||
@@ -102,6 +102,9 @@ internal static class CliHost
|
|||||||
|
|
||||||
网页编排在另一个项目:
|
网页编排在另一个项目:
|
||||||
dotnet run --project MAF1.Web
|
dotnet run --project MAF1.Web
|
||||||
|
|
||||||
|
方案 C 路由在另一个项目:
|
||||||
|
dotnet run --project MAF1.Route
|
||||||
""");
|
""");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
MAF1 是一个基于 **.NET 10** 和 **Microsoft Agents AI** 的多智能体演示仓库。同一条「读文件抽城市 → 查天气」链路拆成两个可执行项目:
|
MAF1 是一个基于 **.NET 10** 和 **Microsoft Agents AI** 的多智能体演示仓库。同一条「读文件抽城市 → 查天气」链路拆成两个可执行项目:
|
||||||
|
|
||||||
1. **MAF1**:命令行工作流。用 Microsoft Agents AI Workflows 对比「条件写在节点里」和「条件写在边上」。
|
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。
|
内置 Agent 在 `MAF1.Core` 里;网页还可以把同一套能力以 **独立进程插件** 的形式画到画布上。LLM 的 endpoint / API Key / 模型由各宿主注入,**不会**画成节点端口,也不会写进流程图 JSON。
|
||||||
|
|
||||||
@@ -29,13 +30,13 @@ MAF1 是一个基于 **.NET 10** 和 **Microsoft Agents AI** 的多智能体演
|
|||||||
| 部分 | 说明 |
|
| 部分 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 运行时 | .NET 10(`net10.0`) |
|
| 运行时 | .NET 10(`net10.0`) |
|
||||||
| 控制台 | `MAF1`:普通控制台,Microsoft Agents AI Workflows |
|
| 控制台 | `MAF1`:工作流;`MAF1.Route`:方案 C 路由 |
|
||||||
| 网页 | `MAF1.Web`:ASP.NET Core Minimal API + `wwwroot` |
|
| 网页 | `MAF1.Web`:ASP.NET Core Minimal API + `wwwroot` |
|
||||||
| LLM | `Azure.AI.OpenAI` + `Microsoft.Agents.AI.OpenAI`(兼容 OpenAI / Azure OpenAI / DeepSeek 等) |
|
| LLM | `Azure.AI.OpenAI` + `Microsoft.Agents.AI.OpenAI`(兼容 OpenAI / Azure OpenAI / DeepSeek 等) |
|
||||||
| 前端 | `MAF1.Web/wwwroot` 下原生 HTML / CSS / JS,无 npm 依赖 |
|
| 前端 | `MAF1.Web/wwwroot` 下原生 HTML / CSS / JS,无 npm 依赖 |
|
||||||
| 插件 | 独立控制台进程,协议见 `MAF1.Core/PluginContract`,由网页宿主扫描 |
|
| 插件 | 独立控制台进程,协议见 `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/ # 可视化界面
|
│ ├── wwwroot/ # 可视化界面
|
||||||
│ ├── Data/
|
│ ├── Data/
|
||||||
│ └── appsettings.json
|
│ └── appsettings.json
|
||||||
|
├── MAF1.Route/ # 控制台路由(方案 C)
|
||||||
|
│ ├── Graphs/ # 白名单图 JSON(有边 / 无边)
|
||||||
|
│ ├── Data/
|
||||||
|
│ └── appsettings.json
|
||||||
├── MAF1.Core/ # 共享:Agent、Tool、LLM、插件协议
|
├── MAF1.Core/ # 共享:Agent、Tool、LLM、插件协议
|
||||||
└── plugins/
|
└── plugins/
|
||||||
├── file-city/
|
├── file-city/
|
||||||
@@ -167,6 +172,23 @@ dotnet run --project MAF1 -- edge Data/not-cities.txt
|
|||||||
|
|
||||||
输入 `1` 或回车结束;输入 `2` 后再填城市名继续查天气;也可以直接输入 `成都`。等待秒数与网页相同,来自 `Workflow:DecisionTimeoutSeconds`。
|
输入 `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 | 作用 |
|
| 项目 | Profile | 作用 |
|
||||||
@@ -174,12 +196,14 @@ dotnet run --project MAF1 -- edge Data/not-cities.txt
|
|||||||
| `MAF1.Web` | `designer` | 网页编排器 |
|
| `MAF1.Web` | `designer` | 网页编排器 |
|
||||||
| `MAF1` | `node` | CLI,节点内判断 |
|
| `MAF1` | `node` | CLI,节点内判断 |
|
||||||
| `MAF1` | `edge` | 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
|
### Llm
|
||||||
|
|
||||||
@@ -413,7 +437,9 @@ AgentRuntime
|
|||||||
|
|
||||||
`MAF1` → `CliHost` → `CityWeatherWorkflow`(node / edge)→ `WorkflowOrchestration`。缺城市时走 `RequestPort`,与网页同一套默认方案 + 超时。
|
`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`。
|
当前 URL 写死为 `127.0.0.1:5288`。关掉占用进程,或临时改 `MAF1.Web/Program.cs` / `MAF1.Web/Properties/launchSettings.json`。
|
||||||
|
|
||||||
**没有自动化测试 / Docker**
|
**没有自动化测试 / Docker**
|
||||||
仓库目前没有测试项目和容器文件。验证方式:UI 示例图 + CLI `node` / `edge`。
|
仓库目前没有测试项目和容器文件。验证方式:UI 示例图 + CLI `node` / `edge` + `MAF1.Route`。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user