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>
|
||||
public static class FileCityAgent
|
||||
{
|
||||
public const string DefaultDescription = "从文件或用户描述中提取有效城市名。";
|
||||
|
||||
/// <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(
|
||||
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)]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
public static class WeatherAgent
|
||||
{
|
||||
public const string DefaultDescription = "按城市列表查询天气并汇总。";
|
||||
|
||||
/// <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(
|
||||
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)]);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
namespace MAF1.Decisions;
|
||||
|
||||
/// <summary>
|
||||
/// CLI 版确认框。回车 / 1 = 结束查询;2 = 再输入城市;也可以直接打「成都」。
|
||||
/// ReadLine 和超时赛跑:到期返回 TimedOut,走默认方案。
|
||||
/// MAF1 工作流 CLI 与 MAF1.Route 共用。
|
||||
/// </summary>
|
||||
public static class ConsoleDecisionPrompt
|
||||
{
|
||||
public static async Task<DecisionAnswer> WaitAsync(DecisionRequest request)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("[需要确认]");
|
||||
Console.WriteLine(request.Prompt);
|
||||
if (!string.IsNullOrWhiteSpace(request.Reason))
|
||||
{
|
||||
Console.WriteLine($"原因:{request.Reason}");
|
||||
}
|
||||
|
||||
Console.WriteLine($"未及时确认将使用默认方案。等待 {request.TimeoutSeconds} 秒。");
|
||||
for (int i = 0; i < request.Options.Count; i++)
|
||||
{
|
||||
DecisionOption option = request.Options[i];
|
||||
string mark = option.IsDefault ? " ← 默认" : "";
|
||||
Console.WriteLine($" {i + 1}) {option.Label}{mark}");
|
||||
if (!string.IsNullOrWhiteSpace(option.Description))
|
||||
{
|
||||
Console.WriteLine($" {option.Description}");
|
||||
}
|
||||
}
|
||||
|
||||
Console.Write("输入序号,或直接回车采用默认:");
|
||||
string? line = await ReadLineOrTimeoutAsync(request.Deadline);
|
||||
if (line is null)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("未及时确认,采用默认方案:结束查询。");
|
||||
return new DecisionAnswer { OptionId = request.DefaultOptionId, TimedOut = true };
|
||||
}
|
||||
|
||||
line = line.Trim();
|
||||
if (line.Length == 0 || line is "1" || line.Equals("stop", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return new DecisionAnswer { OptionId = DecisionOptionIds.Stop };
|
||||
}
|
||||
|
||||
if (line is "2" || line.Equals(DecisionOptionIds.QueryCities, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Console.Write("请输入城市名(例如 成都、北京):");
|
||||
string? cities = await ReadLineOrTimeoutAsync(request.Deadline);
|
||||
if (cities is null)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("未及时输入城市,采用默认方案:结束查询。");
|
||||
return new DecisionAnswer { OptionId = DecisionOptionIds.Stop, TimedOut = true };
|
||||
}
|
||||
|
||||
if (EmptyCityDecision.ParseCities(cities).Count == 0)
|
||||
{
|
||||
Console.WriteLine("没有有效城市名,采用默认方案:结束查询。");
|
||||
return new DecisionAnswer { OptionId = DecisionOptionIds.Stop };
|
||||
}
|
||||
|
||||
return new DecisionAnswer { OptionId = DecisionOptionIds.QueryCities, Text = cities };
|
||||
}
|
||||
|
||||
if (EmptyCityDecision.ParseCities(line).Count > 0)
|
||||
{
|
||||
return new DecisionAnswer { OptionId = DecisionOptionIds.QueryCities, Text = line };
|
||||
}
|
||||
|
||||
Console.WriteLine("无法识别输入,采用默认方案:结束查询。");
|
||||
return new DecisionAnswer { OptionId = DecisionOptionIds.Stop };
|
||||
}
|
||||
|
||||
/// <summary>超时返回 null。注意:超时后后台的 ReadLine 仍可能阻塞,学习项目可接受。</summary>
|
||||
private static async Task<string?> ReadLineOrTimeoutAsync(DateTimeOffset deadline)
|
||||
{
|
||||
TimeSpan remain = deadline - DateTimeOffset.UtcNow;
|
||||
if (remain <= TimeSpan.Zero)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
Task<string?> read = Task.Run(() => Console.ReadLine());
|
||||
Task delay = Task.Delay(remain);
|
||||
Task finished = await Task.WhenAny(read, delay);
|
||||
if (finished == delay)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return await read;
|
||||
}
|
||||
}
|
||||
@@ -10,9 +10,11 @@
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="Agents\AgentStepResult.cs" />
|
||||
<Compile Include="Agents\SystemNodeTypes.cs" />
|
||||
<Compile Include="Agents\FileCity\CityExtraction.cs" />
|
||||
<Compile Include="Agents\FileCity\FileCityAgent.cs" />
|
||||
<Compile Include="Agents\Weather\WeatherAgent.cs" />
|
||||
<Compile Include="Decisions\ConsoleDecisionPrompt.cs" />
|
||||
<Compile Include="Decisions\DecisionModels.cs" />
|
||||
<Compile Include="PluginContract\PluginManifest.cs" />
|
||||
<Compile Include="PluginContract\PluginStdio.cs" />
|
||||
@@ -20,6 +22,7 @@
|
||||
<Compile Include="Tools\WeatherOptions.cs" />
|
||||
<Compile Include="Tools\WeatherTools.cs" />
|
||||
<Compile Include="Utils\AgentFactory.cs" />
|
||||
<Compile Include="Utils\DeepSeekThinking.cs" />
|
||||
<Compile Include="Utils\JsonText.cs" />
|
||||
<Compile Include="Utils\LlmOptions.cs" />
|
||||
<Compile Include="Utils\WindowsConsole.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);
|
||||
}
|
||||
|
||||
/// <summary>把 ChatClient 包成 Microsoft.Agents.AI 的 AIAgent,并挂上 tools。</summary>
|
||||
public AIAgent CreateAgent(string name, string instructions, IList<AITool>? tools = null)
|
||||
/// <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, 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>
|
||||
@@ -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))
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user