Commit 4c991d55 authored by 段廷瑞's avatar 段廷瑞

pref: 智能体缺陷优化

parent 9af54c51
using Newtonsoft.Json.Linq;
using System;
using System.Collections.Generic;
using Tools.MyAgent;
using Tools.MyAgent.Agent;
namespace AgentTest
{
/// <summary>
/// 引擎纯逻辑:token 估算、历史裁剪(token 预算)、工具结果压缩截断。
/// </summary>
public sealed class AgentEngineLogicTests : TestRunner
{
// ── token 估算 ──
[Test]
public void EstimateTokens_Text_CjkAndAscii()
{
// 中文 ~1 token/字;英文 ~4 字符/token
Assert.Equal(7, AgentEngine.EstimateTokens("十个中文字符串"));
Assert.Equal(1, AgentEngine.EstimateTokens("abcd"));
Assert.True(AgentEngine.EstimateTokens("混合 abc 文本") > 0);
Assert.Equal(0, AgentEngine.EstimateTokens(""));
}
[Test]
public void EstimateTokens_Messages_AddsOverhead()
{
var msgs = new JArray
{
new JObject { ["role"] = "system", ["content"] = "你是一个助手" }, // 4(开销) + 6
new JObject { ["role"] = "user", ["content"] = "你好" }, // 4(开销) + 2
};
Assert.Equal(16, AgentEngine.EstimateTokens(msgs));
}
// ── 历史裁剪(token 预算) ──
[Test]
public void DropOldest_UnderBudget_KeepsAll()
{
var list = new JArray
{
new JObject { ["role"] = "system", ["content"] = "s" },
new JObject { ["role"] = "user", ["content"] = "你好" },
new JObject { ["role"] = "assistant", ["content"] = "你好" },
};
Assert.Equal(0, AgentEngine.DropOldestUntilUnder(list, 99999), "预算充足时不应裁剪");
Assert.Equal(3, list.Count);
}
[Test]
public void DropOldest_OverBudget_DropsEarliestPair()
{
var list = new JArray
{
new JObject { ["role"] = "system", ["content"] = "s" },
new JObject { ["role"] = "user", ["content"] = "最早的问题一内容" },
new JObject { ["role"] = "assistant", ["content"] = "最早的答复一内容" },
new JObject { ["role"] = "user", ["content"] = "中间的问题二内容" },
new JObject { ["role"] = "assistant", ["content"] = "中间的答复二内容" },
new JObject { ["role"] = "user", ["content"] = "最近的问题" },
};
int dropped = AgentEngine.DropOldestUntilUnder(list, 8);
Assert.True(dropped >= 2, "超预算应裁剪最早消息对");
Assert.True(list.Count < 6, "应裁剪掉最早消息");
Assert.Equal("user", list[1]["role"]?.Value<string>(), "裁剪后首条应为 user(API 交替要求)");
}
[Test]
public void DropOldest_NeverDropsBelowThree()
{
var list = new JArray
{
new JObject { ["role"] = "system", ["content"] = "s" },
new JObject { ["role"] = "user", ["content"] = "u1" },
new JObject { ["role"] = "assistant", ["content"] = "a1" },
};
AgentEngine.DropOldestUntilUnder(list, 1);
Assert.True(list.Count >= 3, "保底至少保留 system + 2 条");
}
// ── 工具结果压缩截断(#42) ──
[Test]
public void CompressToolResult_Short_Passthrough()
{
string s = "短结果";
Assert.Equal(s, AgentEngine.CompressToolResult(s, "kb_grep"));
}
[Test]
public void CompressToolResult_Long_KeepsHead_WithNotice()
{
var sb = new System.Text.StringBuilder();
int line = 0;
while (sb.Length < 15000)
{
sb.Append($"第{line}行内容:").Append(new string('x', 30)).Append("\r\n");
line++;
}
string result = sb.ToString();
Assert.True(result.Length > 12000, "前置:构造超长结果");
string compressed = AgentEngine.CompressToolResult(result, "kb_grep");
Assert.True(compressed.Length < result.Length, "压缩后应变短");
Assert.True(compressed.Length <= 12000 + 400, "保留头部长度的上限(含提示语)");
Assert.Contains(compressed, "已截断", "应带截断提示");
Assert.Contains(compressed, "kb_read", "应提示可续读");
Assert.Contains(compressed, "第0行内容", "应保留头部连续内容");
Assert.NotContains(compressed, $"第{line - 1}行内容", "尾部内容应被截掉(不再头尾拼接丢中间)");
}
[Test]
public void CompressToolResult_Empty_ReturnsEmpty()
{
Assert.Equal("", AgentEngine.CompressToolResult("", "t"));
Assert.Null(AgentEngine.CompressToolResult(null, "t"));
}
}
}
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" />
<PropertyGroup>
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
<Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
<ProjectGuid>{B4E2F1A0-5C31-4A6B-9D1E-0A7F3C8B2D44}</ProjectGuid>
<OutputType>Exe</OutputType>
<RootNamespace>AgentTest</RootNamespace>
<AssemblyName>AgentTest</AssemblyName>
<TargetFrameworkVersion>v4.8</TargetFrameworkVersion>
<FileAlignment>512</FileAlignment>
<AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects>
<Deterministic>false</Deterministic>
<TargetFrameworkProfile />
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
<PlatformTarget>AnyCPU</PlatformTarget>
<DebugSymbols>true</DebugSymbols>
<DebugType>full</DebugType>
<Optimize>false</Optimize>
<OutputPath>bin\Debug\</OutputPath>
<DefineConstants>DEBUG;TRACE</DefineConstants>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
<Prefer32Bit>false</Prefer32Bit>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
<PlatformTarget>AnyCPU</PlatformTarget>
<DebugType>pdbonly</DebugType>
<Optimize>true</Optimize>
<OutputPath>bin\Release\</OutputPath>
<DefineConstants>TRACE</DefineConstants>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
<Prefer32Bit>false</Prefer32Bit>
</PropertyGroup>
<ItemGroup>
<Reference Include="Newtonsoft.Json, Version=12.0.0.0, Culture=neutral, PublicKeyToken=30ad4fe6b2a6aeed, processorArchitecture=MSIL">
<HintPath>..\packages\Newtonsoft.Json.12.0.1\lib\net45\Newtonsoft.Json.dll</HintPath>
</Reference>
<Reference Include="System" />
<Reference Include="System.Core" />
<Reference Include="System.Drawing" />
<Reference Include="System.Xml.Linq" />
<Reference Include="System.Data" />
<Reference Include="System.Xml" />
</ItemGroup>
<ItemGroup>
<Compile Include="Program.cs" />
<Compile Include="TestUtil.cs" />
<Compile Include="SecretProtectorTests.cs" />
<Compile Include="DbQueryToolTests.cs" />
<Compile Include="AgentEngineLogicTests.cs" />
<Compile Include="FailoverPolicyTests.cs" />
<Compile Include="KbServiceSandboxTests.cs" />
<Compile Include="EmbeddingClientTests.cs" />
<Compile Include="ToolPermissionTests.cs" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Tools\Tools.csproj">
<Project>{C5AB3CE9-C46A-467D-96E4-D80DD3BBB239}</Project>
<Name>Tools</Name>
</ProjectReference>
</ItemGroup>
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
</Project>
using Newtonsoft.Json.Linq;
using System;
using System.Collections.Generic;
using Tools.MyAgent.Agent.Tools;
namespace AgentTest
{
/// <summary>
/// P0 连接串脱敏 + SQL 白名单:db_query 只读校验、敏感信息脱敏、连接占位符解析。
/// </summary>
public sealed class DbQueryToolTests : TestRunner
{
private static ToolContext Ctx()
{
return new ToolContext(null, null)
{
DbConnections = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["erp"] = "Server=10.0.0.1;Database=erp;User Id=sa;Password=SuperSecret!;Trusted_Connection=False",
["local_sqlite"] = @"D:\data\test.db",
},
};
}
// ── SQL 白名单校验 ──
[Test]
public void Validate_Allows_SingleSelect()
{
Assert.Null(DbQueryTool.Validate("sqlserver", "conn", "SELECT * FROM t WHERE id=1"), "普通 SELECT 应通过");
}
[Test]
public void Validate_Rejects_NonSelect()
{
Assert.NotNull(DbQueryTool.Validate("sqlserver", "conn", "UPDATE t SET a=1"), "UPDATE 应拒绝");
Assert.NotNull(DbQueryTool.Validate("sqlserver", "conn", "DELETE FROM t"), "DELETE 应拒绝");
Assert.NotNull(DbQueryTool.Validate("sqlserver", "conn", "INSERT INTO t(a) VALUES(1)"), "INSERT 应拒绝");
Assert.NotNull(DbQueryTool.Validate("sqlserver", "conn", "DROP TABLE t"), "DROP 应拒绝");
}
[Test]
public void Validate_Rejects_MultiStatement()
{
Assert.NotNull(DbQueryTool.Validate("sqlserver", "conn", "SELECT 1; SELECT 2"), "分号多语句应拒绝");
}
[Test]
public void Validate_Rejects_Comments()
{
Assert.NotNull(DbQueryTool.Validate("sqlserver", "conn", "SELECT 1 -- 注释"), "-- 注释应拒绝");
Assert.NotNull(DbQueryTool.Validate("sqlserver", "conn", "SELECT /*x*/ 1"), "块注释应拒绝");
}
[Test]
public void Validate_Rejects_SelectInto()
{
Assert.NotNull(DbQueryTool.Validate("sqlserver", "conn", "SELECT * INTO newt FROM t"), "SELECT INTO 应拒绝");
}
[Test]
public void Validate_Rejects_ExecAndPragma()
{
Assert.NotNull(DbQueryTool.Validate("sqlite", "conn", "SELECT * FROM t; PRAGMA writable_schema"), "PRAGMA 应拒绝");
Assert.NotNull(DbQueryTool.Validate("sqlserver", "conn", "EXEC sp_who"), "EXEC 应拒绝");
}
[Test]
public void Validate_Rejects_UnsupportedType_And_Empty()
{
Assert.NotNull(DbQueryTool.Validate("oracle", "conn", "SELECT 1"), "不支持的数据库类型应拒绝");
Assert.NotNull(DbQueryTool.Validate("sqlserver", "", "SELECT 1"), "空连接串应拒绝");
Assert.NotNull(DbQueryTool.Validate("sqlserver", "conn", ""), "空 SQL 应拒绝");
}
[Test]
public void Validate_Allows_Select_With_Keywords_In_Strings()
{
// 关键字出现在字符串字面量中不应误杀(白名单校验剔除字面量后判定)
Assert.Null(DbQueryTool.Validate("sqlserver", "conn", "SELECT * FROM t WHERE name='update'"));
Assert.Null(DbQueryTool.Validate("sqlserver", "conn", "SELECT * FROM t WHERE name='delete' OR status='begin'"));
// 双引号字面量(MySQL 等方言)同样剔除
Assert.Null(DbQueryTool.Validate("mysql", "conn", "SELECT * FROM t WHERE name=\"update\""));
Assert.Null(DbQueryTool.Validate("mysql", "conn", "SELECT * FROM t WHERE name=\"drop\""));
Assert.NotNull(DbQueryTool.Validate("sqlserver", "conn", "SELECT * FROM t WHERE name='x' AND 1=1 DELETE FROM t"), "字面量外的真实写关键字仍应拒绝");
}
// ── 敏感信息脱敏 ──
[Test]
public void MaskSensitive_Masks_PasswordAndToken()
{
Assert.Equal("Password=***", DbQueryTool.MaskSensitive("Password=abc123"));
Assert.Equal("token=***", DbQueryTool.MaskSensitive("token=xyz"));
Assert.NotContains(DbQueryTool.MaskSensitive("Server=a;Password=hunter2;Database=b"), "hunter2", "密码不得残留");
Assert.NotContains(DbQueryTool.MaskSensitive("Bearer abc.def.ghi"), "abc.def.ghi", "Bearer 令牌不得残留");
}
[Test]
public void MaskSensitive_Empty_ReturnsEmpty()
{
Assert.Equal("", DbQueryTool.MaskSensitive(""));
}
// ── 连接占位符解析(P0) ──
[Test]
public void ResolveConnection_By_ConnectionId()
{
string conn = null, err = null;
var args = new JObject { ["connectionId"] = "erp" };
bool ok = DbQueryTool.ResolveConnection(args, Ctx(), ref conn, ref err);
Assert.True(ok, "按 ID 应解析成功");
Assert.Null(err);
Assert.Contains(conn, "SuperSecret", "预存连接串应完整返回给执行层(仅执行层可见)");
}
[Test]
public void ResolveConnection_IdCaseInsensitive()
{
string conn = null, err = null;
var args = new JObject { ["connectionId"] = "ERP" };
Assert.True(DbQueryTool.ResolveConnection(args, Ctx(), ref conn, ref err), "ID 应大小写不敏感");
}
[Test]
public void ResolveConnection_Missing_Id_Lists_Available()
{
string conn = null, err = null;
var args = new JObject { ["connectionId"] = "not_exist" };
bool ok = DbQueryTool.ResolveConnection(args, Ctx(), ref conn, ref err);
Assert.False(ok, "不存在的 ID 应失败");
Assert.Contains(err, "erp", "错误信息应列出可用 ID");
Assert.Contains(err, "local_sqlite", "错误信息应列出可用 ID");
Assert.NotContains(err, "SuperSecret", "错误信息绝不回显密码");
}
[Test]
public void ResolveConnection_Neither_Given_ReturnsError()
{
string conn = null, err = null;
bool ok = DbQueryTool.ResolveConnection(new JObject(), Ctx(), ref conn, ref err);
Assert.False(ok);
Assert.Contains(err, "connectionId", "应提示使用 connectionId");
Assert.NotContains(err, "SuperSecret", "错误信息绝不回显密码");
}
[Test]
public void ResolveConnection_Inline_Fallback()
{
string conn = null, err = null;
var args = new JObject { ["connection"] = "Data Source=x.db" };
Assert.True(DbQueryTool.ResolveConnection(args, Ctx(), ref conn, ref err), "未给 ID 时应回退到内联连接串");
Assert.Equal("Data Source=x.db", conn);
}
[Test]
public void ResolveConnection_NoStore_ReturnsHint()
{
string conn = null, err = null;
var empty = new ToolContext(null, null);
bool ok = DbQueryTool.ResolveConnection(new JObject(), empty, ref conn, ref err);
Assert.False(ok);
Assert.Contains(err, "未配置", "无预存连接时应提示去设置中预存");
}
}
}
using System;
using Tools.MyAgent.Kb;
namespace AgentTest
{
/// <summary>
/// P1 补测试:EmbeddingClient 向量序列化/反序列化/余弦相似度(纯数学逻辑)与批次参数。
/// </summary>
public sealed class EmbeddingClientTests : TestRunner
{
[Test]
public void Serialize_Deserialize_Roundtrip()
{
var v = new float[] { 1.5f, -2.25f, 0f, 3.125f };
var bytes = EmbeddingClient.Serialize(v);
Assert.NotNull(bytes);
Assert.Equal(v.Length * 4, bytes.Length, "float LE 每元素 4 字节");
var back = EmbeddingClient.Deserialize(bytes);
Assert.NotNull(back);
Assert.Equal(v.Length, back.Length);
for (int i = 0; i < v.Length; i++)
Assert.Equal(v[i], back[i], "往返后数值应一致");
}
[Test]
public void Deserialize_InvalidInput_ReturnsNull()
{
Assert.Null(EmbeddingClient.Deserialize(null));
Assert.Null(EmbeddingClient.Deserialize(new byte[0]));
Assert.Null(EmbeddingClient.Deserialize(new byte[3]), "非 4 倍数长度应返回 null");
}
[Test]
public void Serialize_Null_ReturnsNull()
{
Assert.Null(EmbeddingClient.Serialize(null));
}
[Test]
public void Cosine_Identical_IsOne()
{
var v = new float[] { 1f, 2f, 3f };
Assert.Near(1.0, EmbeddingClient.Cosine(v, v), 1e-9, "相同向量余弦 = 1");
}
[Test]
public void Cosine_Orthogonal_IsZero()
{
var a = new float[] { 1f, 0f };
var b = new float[] { 0f, 1f };
Assert.Near(0.0, EmbeddingClient.Cosine(a, b), 1e-9, "正交向量余弦 = 0");
}
[Test]
public void Cosine_Opposite_IsMinusOne()
{
var a = new float[] { 1f, 2f };
var b = new float[] { -1f, -2f };
Assert.Near(-1.0, EmbeddingClient.Cosine(a, b), 1e-9);
}
[Test]
public void Cosine_ScaleInvariant()
{
var a = new float[] { 1f, 2f, 3f };
var b = new float[] { 10f, 20f, 30f };
Assert.Near(EmbeddingClient.Cosine(a, a), EmbeddingClient.Cosine(a, b), 1e-9, "余弦与模长无关");
}
[Test]
public void Cosine_MismatchedLength_IsZero()
{
Assert.Equal(0.0, EmbeddingClient.Cosine(new float[] { 1f }, new float[] { 1f, 2f }));
Assert.Equal(0.0, EmbeddingClient.Cosine(null, new float[] { 1f }));
}
[Test]
public void BatchDefaults_AreSane()
{
var emb = new EmbeddingClient();
Assert.True(emb.BatchSize > 0 && emb.BatchSize <= 64, "默认批大小应在合理范围(节流)");
Assert.True(emb.BatchDelayMs >= 0, "批间延迟不应为负");
}
// ── IsOllama 端点判定(修复:/v1 后缀的 OpenAI 兼容地址不应走 /api/embed) ──
[Test]
public void IsOllama_Localhost_True()
{
var emb = new EmbeddingClient { BaseUrl = "http://localhost:11434" };
Assert.True(emb.IsOllama, "本地 Ollama 默认地址应判定为 Ollama 端点");
}
[Test]
public void IsOllama_LocalhostWithV1Suffix_False()
{
var emb = new EmbeddingClient { BaseUrl = "http://localhost:11434/v1" };
Assert.False(emb.IsOllama, "Ollama 的 OpenAI 兼容端点(/v1 后缀)应走 /embeddings");
}
[Test]
public void IsOllama_RemoteWithPort11434_True()
{
var emb = new EmbeddingClient { BaseUrl = "http://192.168.1.5:11434" };
Assert.True(emb.IsOllama, "含 11434 端口的远程地址应判定为 Ollama 端点");
}
[Test]
public void IsOllama_OpenAiEndpoint_False()
{
var emb = new EmbeddingClient { BaseUrl = "https://api.openai.com/v1" };
Assert.False(emb.IsOllama, "在线 OpenAI 兼容端点不应判定为 Ollama");
}
}
}
using System;
using Tools.MyAgent.Agent;
namespace AgentTest
{
/// <summary>
/// P1 补测试:FailoverPolicy 故障转移策略——服务故障判定、切换/恢复、配置复位。
/// </summary>
public sealed class FailoverPolicyTests : TestRunner
{
[Test]
public void Disabled_NoFailover()
{
var primary = new FakeClient();
var backup = new FakeClient();
var policy = new FailoverPolicy(primary, backup);
policy.Configure(false, "http://b", "bm", "");
Assert.False(policy.RecordFailure(new FakeHttpException(503)), "未启用时不应切换");
Assert.False(policy.IsActive);
Assert.Same(primary, policy.CurrentClient);
}
[Test]
public void TwoConsecutiveServiceErrors_TriggerFailover()
{
var primary = new FakeClient();
var backup = new FakeClient();
var policy = new FailoverPolicy(primary, backup);
policy.Configure(true, "http://b", "bm", "");
Assert.False(policy.RecordFailure(new FakeHttpException(503)), "第 1 次失败不应切换");
Assert.True(policy.RecordFailure(new FakeHttpException(503)), "连续第 2 次失败应切换");
Assert.True(policy.IsActive);
Assert.Same(backup, policy.CurrentClient, "切换后当前客户端应为备用");
}
[Test]
public void Http429_IsServiceError()
{
Assert.True(FailoverPolicy.IsServiceError(new FakeHttpException(429)), "429 限流应视为服务故障");
}
[Test]
public void Http4xx_NotServiceError()
{
Assert.False(FailoverPolicy.IsServiceError(new FakeHttpException(401)), "401 Key 错误不应触发故障转移");
Assert.False(FailoverPolicy.IsServiceError(new FakeHttpException(400)), "400 参数错误不应触发故障转移");
}
[Test]
public void Http5xx_IsServiceError()
{
Assert.True(FailoverPolicy.IsServiceError(new FakeHttpException(500)));
Assert.True(FailoverPolicy.IsServiceError(new FakeHttpException(502)));
Assert.True(FailoverPolicy.IsServiceError(new FakeHttpException(503)));
}
[Test]
public void MessageBased_Timeout_IsServiceError()
{
Assert.True(FailoverPolicy.IsServiceError(new Exception("操作超时")), "超时应视为服务故障");
Assert.True(FailoverPolicy.IsServiceError(new Exception("The operation has timed out.")));
Assert.True(FailoverPolicy.IsServiceError(new Exception("connection refused")), "连接失败应视为服务故障");
Assert.False(FailoverPolicy.IsServiceError(new Exception("参数错误")), "业务错误不应触发");
}
[Test]
public void NullException_NotServiceError()
{
Assert.False(FailoverPolicy.IsServiceError(null));
}
[Test]
public void NonServiceFailure_ResetsCounter()
{
var primary = new FakeClient();
var backup = new FakeClient();
var policy = new FailoverPolicy(primary, backup);
policy.Configure(true, "http://b", "bm", "");
policy.RecordFailure(new FakeHttpException(503)); // 1 次服务失败
Assert.False(policy.RecordFailure(new Exception("参数错误")), "非服务失败不应切换且应清零计数");
Assert.False(policy.RecordFailure(new FakeHttpException(503)), "计数已清零:再次 1 次失败不应切换");
}
[Test]
public void RecordSuccess_RestoresPrimary()
{
var primary = new FakeClient();
var backup = new FakeClient();
var policy = new FailoverPolicy(primary, backup);
policy.Configure(true, "http://b", "bm", "");
policy.RecordFailure(new FakeHttpException(503));
policy.RecordFailure(new FakeHttpException(503));
Assert.True(policy.IsActive);
policy.RecordSuccess();
Assert.False(policy.IsActive, "成功后应恢复主模型");
Assert.Same(primary, policy.CurrentClient);
}
[Test]
public void Configure_Disabled_AfterFailover_ResetsState()
{
var primary = new FakeClient();
var backup = new FakeClient();
var policy = new FailoverPolicy(primary, backup);
policy.Configure(true, "http://b", "bm", "");
policy.RecordFailure(new FakeHttpException(503));
policy.RecordFailure(new FakeHttpException(503));
Assert.True(policy.IsActive);
policy.Configure(false, "http://b", "bm", "");
Assert.False(policy.IsActive, "禁用后 IsActive 不应残留(避免误报主模型已恢复)");
Assert.Same(primary, policy.CurrentClient);
}
[Test]
public void Configure_AppliesBackupSettings()
{
var backup = new FakeClient();
var policy = new FailoverPolicy(new FakeClient(), backup);
policy.Configure(true, "http://backup:11434/v1", "qwen2.5", "bk-key");
Assert.Equal("http://backup:11434/v1", backup.BaseUrl);
Assert.Equal("qwen2.5", backup.Model);
Assert.Equal("bk-key", backup.ApiKey);
}
[Test]
public void SetPrimary_FollowsWhenNotFailedOver()
{
var primary = new FakeClient();
var backup = new FakeClient();
var policy = new FailoverPolicy(primary, backup);
policy.Configure(true, "http://b", "bm", "");
var newPrimary = new FakeClient();
policy.SetPrimary(newPrimary);
Assert.Same(newPrimary, policy.CurrentClient, "未故障转移时 SetPrimary 应立即生效");
}
[Test]
public void SetPrimary_KeepsBackupWhenFailedOver()
{
var primary = new FakeClient();
var backup = new FakeClient();
var policy = new FailoverPolicy(primary, backup);
policy.Configure(true, "http://b", "bm", "");
policy.RecordFailure(new FakeHttpException(503));
policy.RecordFailure(new FakeHttpException(503));
Assert.True(policy.IsActive);
policy.SetPrimary(new FakeClient());
Assert.Same(backup, policy.CurrentClient, "故障转移期间 SetPrimary 不应打断备用状态");
}
}
}
using System;
using System.IO;
using Tools.MyAgent.Kb;
namespace AgentTest
{
/// <summary>
/// P1 补测试:知识库沙箱路径校验——../ 穿越、绝对路径、越权、reparse point、模糊匹配纯逻辑。
/// </summary>
public sealed class KbServiceSandboxTests : TestRunner
{
private string _root;
private string _outside;
private KbService _kb;
public override void SetUp()
{
_root = Path.Combine(Path.GetTempPath(), "agenttest_kb_" + Guid.NewGuid().ToString("N"));
_outside = Path.Combine(Path.GetTempPath(), "agenttest_out_" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(Path.Combine(_root, "docs", "sub"));
Directory.CreateDirectory(_outside);
File.WriteAllText(Path.Combine(_root, "docs", "a.md"), "a");
File.WriteAllText(Path.Combine(_outside, "secret.txt"), "secret");
_kb = new KbService();
_kb.SetRoot(_root);
}
public override void TearDown()
{
try { Directory.Delete(_root, true); } catch { }
try { Directory.Delete(_outside, true); } catch { }
}
[Test]
public void ResolveSafe_ValidPath()
{
string abs = _kb.ResolveSafe("docs/a.md");
Assert.Equal(Path.Combine(_root, "docs", "a.md"), abs);
}
[Test]
public void ResolveSafe_Traversal_Rejected()
{
Assert.Throws<InvalidOperationException>(() => _kb.ResolveSafe("../outside/secret.txt"), "../ 穿越应拒绝");
Assert.Throws<InvalidOperationException>(() => _kb.ResolveSafe("docs/../../outside/secret.txt"), "深层穿越应拒绝");
}
[Test]
public void ResolveSafe_AbsolutePath_Rejected()
{
Assert.Throws<InvalidOperationException>(() => _kb.ResolveSafe(_outside), "绝对路径应拒绝");
}
[Test]
public void ResolveSafe_Null_MeansRoot()
{
Assert.Equal(KbService.NormPath(_root), _kb.ResolveSafe(null));
}
[Test]
public void ResolveIn_Unconfigured_Throws()
{
Assert.Throws<InvalidOperationException>(() => KbService.ResolveIn("", "x", "msg"));
}
[Test]
public void IsWithin_Boundary()
{
string rootNorm = KbService.NormPath(_root);
Assert.True(KbService.IsWithin(rootNorm, KbService.NormPath(Path.Combine(_root, "docs"))), "子目录应在范围内");
Assert.True(KbService.IsWithin(rootNorm, rootNorm), "根自身应在范围内");
Assert.False(KbService.IsWithin(rootNorm, KbService.NormPath(_outside)), "外部目录不应在范围内");
Assert.False(KbService.IsWithin(rootNorm, KbService.NormPath(_root + "_sibling")), "前缀相似目录不应误判");
}
[Test]
public void HasReparsePointOnPath_PlainPath_False()
{
string target = Path.Combine(_root, "docs", "a.md");
Assert.False(KbService.HasReparsePointOnPath(KbService.NormPath(_root), target), "普通路径不应有 reparse point");
}
[Test]
public void GlobToRegex_Matching()
{
Assert.True(KbService.GlobToRegex("*.md").IsMatch("report.md"), "*.md 应匹配 report.md");
Assert.False(KbService.GlobToRegex("*.md").IsMatch("report.docx"), "*.md 不应匹配 report.docx");
Assert.True(KbService.GlobToRegex("2024*").IsMatch("20240815.xlsx"));
Assert.True(KbService.GlobToRegex("a?c").IsMatch("abc"));
Assert.False(KbService.GlobToRegex("a?c").IsMatch("abbc"));
}
[Test]
public void Levenshtein_Distance()
{
Assert.Equal(0, KbService.Levenshtein("abc", "abc"));
Assert.Equal(1, KbService.Levenshtein("abc", "abd"));
Assert.Equal(3, KbService.Levenshtein("", "abc"));
Assert.Equal(3, KbService.Levenshtein("abc", ""));
Assert.True(KbService.Levenshtein("销售报表", "销售报单") >= 1, "中文差异应产生编辑距离");
}
[Test]
public void FuzzyScore_Ordering()
{
Assert.Equal(1.0, KbService.FuzzyScore("报表", "报表"), "完全相等 = 1.0");
Assert.True(KbService.FuzzyScore("销售报表", "销售报表2024.xlsx") > 0.9, "包含应高分");
Assert.True(KbService.FuzzyScore("报表", "无关文件") < 0.5, "无关应低分");
Assert.Equal(0.0, KbService.FuzzyScore("", "x"));
}
[Test]
public void SafeEnumerateFiles_SkipsMissingAnd_ReturnsAll()
{
int count = 0;
foreach (var f in _kb.SafeEnumerateFiles(_root))
count++;
Assert.Equal(1, count, "临时知识库应枚举到 1 个文件");
}
[Test]
public void SafeEnumerateFiles_Cancellation()
{
using (var cts = new System.Threading.CancellationTokenSource())
{
cts.Cancel();
bool cancelled = false;
try
{
foreach (var f in _kb.SafeEnumerateFiles(_root, cts.Token)) { }
}
catch (OperationCanceledException)
{
cancelled = true;
}
Assert.True(cancelled, "已取消的令牌应使枚举立即终止");
}
}
}
}
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
namespace AgentTest
{
/// <summary>
/// 极简测试运行器(零外部依赖,离线可跑):
/// 扫描 TestRunner 派生类型中带 [Test] 特性的方法,逐个执行并汇总。
/// 退出码 = 失败数(0 = 全部通过),便于 CI/批处理判断。
/// </summary>
public static class Program
{
public static int Main(string[] args)
{
Console.OutputEncoding = System.Text.Encoding.UTF8;
var total = 0;
var passed = 0;
var failed = 0;
var failures = new List<string>();
var runnerTypes = Assembly.GetExecutingAssembly().GetTypes()
.Where(t => typeof(TestRunner).IsAssignableFrom(t) && !t.IsAbstract)
.OrderBy(t => t.Name);
foreach (var type in runnerTypes)
{
var instance = (TestRunner)Activator.CreateInstance(type);
foreach (var method in type.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly))
{
if (method.GetCustomAttributes(typeof(TestAttribute), false).Length == 0)
continue;
total++;
try
{
instance.SetUp();
method.Invoke(instance, null);
passed++;
Console.WriteLine($" ✓ {type.Name}.{method.Name}");
}
catch (TargetInvocationException ex)
{
failed++;
var inner = ex.InnerException ?? ex;
failures.Add($"{type.Name}.{method.Name}: {inner.Message}");
Console.WriteLine($" ✗ {type.Name}.{method.Name} → {inner.Message}");
}
catch (Exception ex)
{
failed++;
failures.Add($"{type.Name}.{method.Name}: {ex.Message}");
Console.WriteLine($" ✗ {type.Name}.{method.Name} → {ex.Message}");
}
finally
{
instance.TearDown();
}
}
}
Console.WriteLine();
Console.WriteLine($"共 {total} 项测试:通过 {passed},失败 {failed}。");
if (failed > 0)
{
Console.WriteLine("失败明细:");
foreach (var f in failures)
Console.WriteLine(" - " + f);
}
return failed;
}
}
[AttributeUsage(AttributeTargets.Method)]
public sealed class TestAttribute : Attribute
{
}
/// <summary>测试用例基类(SetUp 每次用例前执行;TearDown 每次用例后执行)</summary>
public abstract class TestRunner
{
public virtual void SetUp() { }
public virtual void TearDown() { }
}
/// <summary>断言工具(失败抛异常中断当前用例)</summary>
public static class Assert
{
public static void True(bool condition, string message = "条件不成立")
{
if (!condition) throw new Exception("断言失败:" + message);
}
public static void False(bool condition, string message = "条件不应成立")
{
if (condition) throw new Exception("断言失败:" + message);
}
public static void Equal(object expected, object actual, string message = "")
{
bool eq = expected == null
? actual == null
: string.Equals(expected.ToString(), actual?.ToString(), StringComparison.Ordinal);
if (!eq)
throw new Exception($"断言失败:期望 [{expected}],实际 [{actual}]{Msg(message)}");
}
public static void NotNull(object value, string message = "不应为 null")
{
if (value == null) throw new Exception("断言失败:" + message);
}
/// <summary>引用相等断言(Same 语义)</summary>
public static void Same(object expected, object actual, string message = "")
{
if (!ReferenceEquals(expected, actual))
throw new Exception($"断言失败:期望同一实例 {Msg(message)}");
}
public static void Null(object value, string message = "应为 null")
{
if (value != null) throw new Exception($"断言失败:{message}(实际 [{value}])");
}
public static void Contains(string haystack, string needle, string message = "")
{
if (haystack == null || haystack.IndexOf(needle, StringComparison.OrdinalIgnoreCase) < 0)
throw new Exception($"断言失败:未找到子串 [{needle}]{Msg(message)}(原文:{Trunc(haystack)})");
}
public static void NotContains(string haystack, string needle, string message = "")
{
if (haystack != null && haystack.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0)
throw new Exception($"断言失败:不应包含子串 [{needle}]{Msg(message)}");
}
public static void Near(double expected, double actual, double tolerance, string message = "")
{
if (Math.Abs(expected - actual) > tolerance)
throw new Exception($"断言失败:期望 ≈{expected}(容差 {tolerance}),实际 {actual}{Msg(message)}");
}
public static void Throws<TException>(Action action, string message = "") where TException : Exception
{
try
{
action();
}
catch (TException)
{
return;
}
catch (Exception ex)
{
throw new Exception($"断言失败:期望抛 {typeof(TException).Name},实际抛 {ex.GetType().Name}{Msg(message)}");
}
throw new Exception($"断言失败:期望抛 {typeof(TException).Name},但未抛出{Msg(message)}");
}
private static string Msg(string m) => string.IsNullOrEmpty(m) ? "" : ";" + m;
private static string Trunc(string s)
{
if (s == null) return "(null)";
return s.Length <= 80 ? s : s.Substring(0, 80) + "…";
}
}
}
using System;
using Tools.MyAgent.Config;
namespace AgentTest
{
/// <summary>
/// P0 凭据收敛:SecretProtector(DPAPI)加解密、旧版明文兼容、前缀识别。
/// </summary>
public sealed class SecretProtectorTests : TestRunner
{
[Test]
public void Protect_AddsPrefix_And_Unprotect_Roundtrips()
{
string plain = "sk-测试密钥-abc123";
string stored = SecretProtector.Protect(plain);
Assert.True(SecretProtector.IsProtected(stored), "加密结果应带 dpapi: 前缀");
Assert.NotContains(stored, plain, "密文不应包含明文");
Assert.Equal(plain, SecretProtector.Unprotect(stored), "解密应还原原文");
}
[Test]
public void Protect_Empty_ReturnsEmpty()
{
Assert.Equal("", SecretProtector.Protect(""));
Assert.Equal("", SecretProtector.Unprotect(""));
Assert.False(SecretProtector.IsProtected(""), "空串不应视为密文");
}
[Test]
public void Unprotect_Plaintext_Passthrough()
{
// 旧版明文配置兼容:无前缀按明文原样返回
Assert.Equal("legacy-plain", SecretProtector.Unprotect("legacy-plain"));
}
[Test]
public void Unprotect_NonDecryptable_ReturnsEmpty()
{
// 篡改/换机器后无法解密 → 视为未配置(返回空串),不抛异常
string corrupted = SecretProtector.Prefix + "!!!not-base64!!!";
Assert.Equal("", SecretProtector.Unprotect(corrupted));
}
[Test]
public void SamePlain_Encrypts_DifferentlyEachTime()
{
// DPAPI 带熵随机盐:同一明文两次加密密文不同(防密文比对)
string a = SecretProtector.Protect("secret-123");
string b = SecretProtector.Protect("secret-123");
Assert.False(string.Equals(a, b, StringComparison.Ordinal), "两次加密结果不应相同");
Assert.Equal("secret-123", SecretProtector.Unprotect(a));
Assert.Equal("secret-123", SecretProtector.Unprotect(b));
}
[Test]
public void CjkContent_Survives_Roundtrip()
{
// 中文兼容(ECC 文件编码要求):含中文的凭据加解密不损坏
string plain = "共享文件夹密码:测试¥123";
Assert.Equal(plain, SecretProtector.Unprotect(SecretProtector.Protect(plain)));
}
}
}
using Newtonsoft.Json.Linq;
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Tools.MyAgent.Agent;
namespace AgentTest
{
/// <summary>可编程假客户端(FailoverPolicy 测试用):按脚本返回成功/抛指定异常</summary>
internal sealed class FakeClient : IOpenAiChatClient
{
public string BaseUrl { get; set; } = "http://fake";
public string ApiKey { get; set; } = "";
public string Model { get; set; } = "fake-model";
public double Temperature { get; set; } = 0.3;
public int MaxTokens { get; set; } = 8192;
public bool UseSystemProxy { get; set; }
/// <summary>脚本:每次 SendAsync 弹出第一个(成功返回 null 表示成功,否则抛出该异常)</summary>
public Queue<Exception> Script = new Queue<Exception>();
public int SendCalls;
public int CompleteCalls;
public Task<ChatResult> SendAsync(JArray messages, JArray tools, bool enableTools, CancellationToken ct)
{
SendCalls++;
if (Script.Count > 0)
{
var ex = Script.Dequeue();
if (ex != null) throw ex;
}
return Task.FromResult(new ChatResult { Content = "ok" });
}
public Task<string> CompleteAsync(JArray messages, int maxTokens, CancellationToken ct)
{
CompleteCalls++;
return Task.FromResult("ok");
}
public Task<(bool Ok, string Message)> TestConnectionAsync(CancellationToken ct)
=> Task.FromResult((true, "ok"));
public void RebuildHttpClient() { }
public void Cancel() { }
public void Dispose() { }
}
/// <summary>带 HTTP 状态码的异常(模拟 OpenAiCompatibleClient 的网络错误语义)</summary>
internal sealed class FakeHttpException : Exception
{
public FakeHttpException(int statusCode) : base("HTTP " + statusCode)
{
Data["HttpStatusCode"] = statusCode;
}
}
}
using System;
using System.Collections.Generic;
using Tools.MyAgent.Agent.Tools;
namespace AgentTest
{
/// <summary>
/// P3 工具级权限:角色白名单判定——空规则放行、通配符、多角色分隔、大小写不敏感。
/// </summary>
public sealed class ToolPermissionTests : TestRunner
{
private static Dictionary<string, string> Rules(params string[] kv)
{
var d = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
for (int i = 0; i + 1 < kv.Length; i += 2)
d[kv[i]] = kv[i + 1];
return d;
}
[Test]
public void NoRules_AllAllowed()
{
Assert.True(ToolPermission.IsAllowed("db_query", "任何人", null));
Assert.True(ToolPermission.IsAllowed("db_query", "任何人", new Dictionary<string, string>()));
}
[Test]
public void UnlistedTool_Allowed()
{
var rules = Rules("db_query", "管理员");
Assert.True(ToolPermission.IsAllowed("kb_grep", "普通用户", rules), "未配置规则的工具不受限制");
}
[Test]
public void EmptyOrStar_Allowed()
{
var rules = Rules("db_query", "", "module_patch", "*");
Assert.True(ToolPermission.IsAllowed("db_query", "任何人", rules), "空规则 = 不限制");
Assert.True(ToolPermission.IsAllowed("module_patch", "任何人", rules), "* = 不限制");
}
[Test]
public void RoleWhitelist_Matches()
{
var rules = Rules("db_query", "管理员,主管");
Assert.True(ToolPermission.IsAllowed("db_query", "管理员", rules));
Assert.True(ToolPermission.IsAllowed("db_query", "主管", rules));
Assert.False(ToolPermission.IsAllowed("db_query", "普通员工", rules), "白名单外角色应拒绝");
}
[Test]
public void RuleKey_CaseInsensitive()
{
// 配置字典可能为默认比较器(区分大小写):DB_QUERY 规则必须能拦截 db_query 的白名单外角色
var rules = Rules("DB_QUERY", "管理员");
Assert.True(ToolPermission.IsAllowed("db_query", "管理员", rules), "规则键大小写不同也应命中");
Assert.False(ToolPermission.IsAllowed("db_query", "普通用户", rules), "规则键大小写不同时白名单外角色仍应拒绝");
}
[Test]
public void RoleMatch_CaseInsensitive()
{
var rules = Rules("db_query", "Admin");
Assert.True(ToolPermission.IsAllowed("db_query", "admin", rules));
Assert.True(ToolPermission.IsAllowed("db_query", "ADMIN", rules));
}
[Test]
public void ChineseDelimiters_Supported()
{
var rules = Rules("db_query", "管理员;主管、经理");
Assert.True(ToolPermission.IsAllowed("db_query", "主管", rules), "中文分号分隔应生效");
Assert.True(ToolPermission.IsAllowed("db_query", "经理", rules), "顿号分隔应生效");
}
[Test]
public void EmptyRole_WithRestrictions_Denied()
{
var rules = Rules("db_query", "管理员");
Assert.False(ToolPermission.IsAllowed("db_query", "", rules), "有白名单时匿名角色应拒绝");
}
[Test]
public void NullToolName_Denied()
{
Assert.False(ToolPermission.IsAllowed(null, "x", null));
}
[Test]
public void WhitespaceTolerated()
{
var rules = Rules("db_query", " 管理员 , 主管 ");
Assert.True(ToolPermission.IsAllowed("db_query", "主管", rules), "规则值应容忍空格");
}
}
}
......@@ -21,6 +21,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DotNetVersionChecker", "Dot
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WindowsSearch", "WindowsSearch\WindowsSearch.csproj", "{0A591ECF-134D-40FD-A8E4-57D8A70FC309}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AgentTest", "AgentTest\AgentTest.csproj", "{B4E2F1A0-5C31-4A6B-9D1E-0A7F3C8B2D44}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
......@@ -139,6 +141,18 @@ Global
{0A591ECF-134D-40FD-A8E4-57D8A70FC309}.Release|x64.Build.0 = Release|x64
{0A591ECF-134D-40FD-A8E4-57D8A70FC309}.Release|x86.ActiveCfg = Release|x86
{0A591ECF-134D-40FD-A8E4-57D8A70FC309}.Release|x86.Build.0 = Release|x86
{B4E2F1A0-5C31-4A6B-9D1E-0A7F3C8B2D44}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B4E2F1A0-5C31-4A6B-9D1E-0A7F3C8B2D44}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B4E2F1A0-5C31-4A6B-9D1E-0A7F3C8B2D44}.Debug|x64.ActiveCfg = Debug|Any CPU
{B4E2F1A0-5C31-4A6B-9D1E-0A7F3C8B2D44}.Debug|x64.Build.0 = Debug|Any CPU
{B4E2F1A0-5C31-4A6B-9D1E-0A7F3C8B2D44}.Debug|x86.ActiveCfg = Debug|Any CPU
{B4E2F1A0-5C31-4A6B-9D1E-0A7F3C8B2D44}.Debug|x86.Build.0 = Debug|Any CPU
{B4E2F1A0-5C31-4A6B-9D1E-0A7F3C8B2D44}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B4E2F1A0-5C31-4A6B-9D1E-0A7F3C8B2D44}.Release|Any CPU.Build.0 = Release|Any CPU
{B4E2F1A0-5C31-4A6B-9D1E-0A7F3C8B2D44}.Release|x64.ActiveCfg = Release|Any CPU
{B4E2F1A0-5C31-4A6B-9D1E-0A7F3C8B2D44}.Release|x64.Build.0 = Release|Any CPU
{B4E2F1A0-5C31-4A6B-9D1E-0A7F3C8B2D44}.Release|x86.ActiveCfg = Release|Any CPU
{B4E2F1A0-5C31-4A6B-9D1E-0A7F3C8B2D44}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
......
......@@ -105,6 +105,15 @@ namespace Tools.MyAgent.Agent
/// <summary>是否向模型下发可选/高级工具(默认只下发核心工具)</summary>
private bool _includeOptionalTools;
/// <summary>工具级权限(P3):工具名 → 允许角色列表;空 = 全部可用(由 AgentConfig.ToolRoleAllow 注入)</summary>
private Dictionary<string, string> _toolRoleAllow = new Dictionary<string, string>();
/// <summary>当前登录用户名(即角色,P3 工具权限判定用;由宿主注入)</summary>
public string CurrentUser { get; set; } = "";
/// <summary>预存数据库连接串(ID → 连接串,P0 脱敏;由 AgentConfig.DbConnections 解密注入)</summary>
public Dictionary<string, string> DbConnections { get; private set; } = new Dictionary<string, string>();
/// <summary>v2.2 多模态:是否启用图片提问(附图以 image_url 内容块发送)</summary>
private bool _imageInputEnabled;
......@@ -120,6 +129,10 @@ namespace Tools.MyAgent.Agent
/// </summary>
private int _runGeneration;
/// <summary>本轮上下文处理提示(历史省略/压缩);经 OnTextDelta 即时显示,
/// 并在最终回答前拼接保留(修复:此前被回答正文全量替换而丢失)</summary>
private string _contextNotice = "";
public AgentEngine(KbService kb)
{
_kb = kb;
......@@ -155,6 +168,9 @@ namespace Tools.MyAgent.Agent
_prompts.SystemPromptEnabled = cfg.SystemPromptEnabled;
_imageInputEnabled = cfg.EnableImageInput; // v2.2 多模态:图片提问开关
CostEstimator.Configure(cfg.CustomPrices); // R6 费用估算:应用自定义价格表覆盖
// P0 连接串脱敏 / P3 工具权限:预存连接与角色规则随配置注入
DbConnections = cfg.DbConnections ?? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
_toolRoleAllow = cfg.ToolRoleAllow ?? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
// #31 故障转移配置
_failover.Configure(cfg.EnableFailover, cfg.BackupBaseUrl, cfg.BackupModel, cfg.BackupApiKey);
......@@ -172,6 +188,16 @@ namespace Tools.MyAgent.Agent
_includeOptionalTools = enabled;
}
/// <summary>
/// 构建本次下发的工具 Schema:可选工具开关 + 工具级权限过滤(P3,按当前用户角色)。
/// <para>权限不足的工具不下发(模型不可见),执行侧另有兜底校验(纵深防御)。</para>
/// </summary>
private JArray BuildTools()
{
return ToolSchema.Build(_includeOptionalTools, name =>
ToolPermission.IsAllowed(name, CurrentUser, _toolRoleAllow));
}
/// <summary>
/// 运行一轮回答。history 需已包含当前用户问题(含此前对话)。
/// </summary>
......@@ -186,9 +212,10 @@ namespace Tools.MyAgent.Agent
{
_toolTrace.Clear();
_continueRounds = 0;
_contextNotice = "";
_runGeneration++;
var messages = await BuildMessagesAsync(history, _cts.Token).ConfigureAwait(false);
var tools = ToolSchema.Build(_includeOptionalTools);
var tools = BuildTools();
var stopwatch = Stopwatch.StartNew();
int toolRounds = 0;
var finalAnswer = new StringBuilder(); // 跨轮累计的回答正文(UI 事件为全量替换语义)
......@@ -375,6 +402,8 @@ namespace Tools.MyAgent.Agent
if (finalAnswer.Length == 0)
finalAnswer.Append("(模型未返回内容)");
else if (_contextNotice.Length > 0)
finalAnswer.Insert(0, _contextNotice + "\n\n"); // 上下文提示保留在最终回答开头(此前被正文覆盖)
if (IsTruncatedByLength(result))
{
// 达到续写上限仍被截断:明确提示用户,不再静默展示半截回答
......@@ -511,13 +540,19 @@ namespace Tools.MyAgent.Agent
int dropped = DropOldestUntilUnder(list, budget);
if (dropped > 0)
OnTextDelta?.Invoke($"\n\n(对话历史较长,已自动省略最早 {dropped} 条消息以控制上下文)");
{
_contextNotice = $"(对话历史较长,已自动省略最早 {dropped} 条消息以控制上下文)";
OnTextDelta?.Invoke("\n\n" + _contextNotice);
}
if (EstimateTokens(list) > budget)
{
int summarized = await SummarizeEarliestAsync(list, ct).ConfigureAwait(false);
if (summarized > 0)
OnTextDelta?.Invoke($"\n\n(历史已自动压缩为摘要,保留最近 {summarized} 条消息继续回答)");
{
_contextNotice = $"(历史已自动压缩为摘要,保留最近 {summarized} 条消息继续回答)";
OnTextDelta?.Invoke("\n\n" + _contextNotice);
}
}
return list;
}
......@@ -575,10 +610,14 @@ namespace Tools.MyAgent.Agent
return added > 0 ? (JToken)arr : (JToken)text; // 全部图片不可读/超限 → 降级纯文本
}
/// <summary>本地图片 → base64 data URL(原图上限 MaxImageBytes=4MB,避免超出 API 限制);失败返回 null</summary>
/// <summary>
/// 本地图片 → base64 data URL(P2 多模态压缩:最长边超 MaxImageEdge 时先降采样再编码,控制请求体积;
/// 原图文件大小上限 MaxImageBytes 防超 API 限制);失败返回 null。
/// </summary>
private static string ImageToDataUrl(string path)
{
const long MaxImageBytes = 4 * 1024 * 1024;
const int MaxImageEdge = 1024;
if (string.IsNullOrEmpty(path) || !System.IO.File.Exists(path)) return null;
var fi = new System.IO.FileInfo(path);
if (fi.Length <= 0 || fi.Length > MaxImageBytes) return null;
......@@ -587,8 +626,43 @@ namespace Tools.MyAgent.Agent
: ext == ".gif" ? "image/gif"
: ext == ".bmp" ? "image/bmp"
: "image/png";
byte[] bytes = System.IO.File.ReadAllBytes(path);
return "data:" + mime + ";base64," + Convert.ToBase64String(bytes);
try
{
using (var img = System.Drawing.Image.FromFile(path))
{
int longest = Math.Max(img.Width, img.Height);
if (longest > MaxImageEdge)
{
// 降采样:等比缩放到最长边 MaxImageEdge(高质双三次插值),编码为 JPEG 控制体积
double scale = (double)MaxImageEdge / longest;
int nw = Math.Max(1, (int)(img.Width * scale));
int nh = Math.Max(1, (int)(img.Height * scale));
using (var bmp = new System.Drawing.Bitmap(nw, nh))
{
using (var g = System.Drawing.Graphics.FromImage(bmp))
{
g.InterpolationMode = System.Drawing.Drawing2D.InterpolationMode.HighQualityBicubic;
g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.HighQuality;
g.PixelOffsetMode = System.Drawing.Drawing2D.PixelOffsetMode.HighQuality;
g.DrawImage(img, 0, 0, nw, nh);
}
using (var ms = new System.IO.MemoryStream())
{
bmp.Save(ms, System.Drawing.Imaging.ImageFormat.Jpeg);
return "data:image/jpeg;base64," + Convert.ToBase64String(ms.ToArray());
}
}
}
// 原图已足够小:保持原样编码
byte[] bytes = System.IO.File.ReadAllBytes(path);
return "data:" + mime + ";base64," + Convert.ToBase64String(bytes);
}
}
catch
{
return null;
}
}
/// <summary>
......@@ -617,7 +691,7 @@ namespace Tools.MyAgent.Agent
/// 「超预算裁剪最早消息」从未生效;现改为:最早 user 在 index 1 时连同其后一条 assistant 一起移除
/// (保持首条消息为 user 的 API 交替要求),否则移除 [前一 assistant, user] 对。
/// </remarks>
private static int DropOldestUntilUnder(JArray list, int budget)
internal static int DropOldestUntilUnder(JArray list, int budget)
{
int dropped = 0;
while (list.Count > 3 && EstimateTokens(list) > budget)
......@@ -766,41 +840,63 @@ namespace Tools.MyAgent.Agent
#region 工具执行
/// <summary>
/// 在后台线程执行工具,支持:ESC 取消(立即放弃等待)与单工具超时兜底。
/// <para>同步的全盘检索(kb_grep / kb_find)无法硬中断线程,但取消/超时后引擎立即继续,
/// 用户不再有"取消无效"的等待感;残留任务执行完后自然结束,不影响后续回答。</para>
/// 在后台线程执行工具,支持:ESC 取消与单工具超时——通过把「取消令牌 + 超时令牌」传入工具上下文,
/// 让全盘扫描等长操作在枚举循环内协作式终止(P1:超时后真正结束,而非残留后台任务继续跑)。
/// <para>协作式取消之外保留硬超时兑底(ToolTimeoutSeconds + 30s):对不响应取消令牌的极端路径
/// (如超大文件解析)不再无限等待,引擎立即继续,残留任务副作用由代际抑制。</para>
/// </summary>
private async Task<string> ExecuteToolAsync(ApiToolCall tc, CancellationToken ct)
{
int gen = _runGeneration;
try
{
var execTask = Task.Run(() => ExecuteTool(tc, gen));
var timeoutTask = Task.Delay(TimeSpan.FromSeconds(ToolTimeoutSeconds), CancellationToken.None);
var cancelTask = Task.Delay(Timeout.Infinite, ct);
var done = await Task.WhenAny(execTask, timeoutTask, cancelTask).ConfigureAwait(false);
if (done == execTask)
return await execTask.ConfigureAwait(false);
if (ct.IsCancellationRequested)
return "已取消。";
return $"错误:工具 {tc.Name} 执行超时({ToolTimeoutSeconds} 秒),已中止。";
}
catch (OperationCanceledException)
using (var toolCts = CancellationTokenSource.CreateLinkedTokenSource(ct))
{
return "已取消。";
toolCts.CancelAfter(TimeSpan.FromSeconds(ToolTimeoutSeconds)); // 单工具超时 → 触发工具内部取消检查(协作式终止)
// 硬超时兑底:协作式取消未能按时结束(如解析超大文件)时不再无限等待
// (修复:独立 CTS 使 WhenAny 返回后立即取消并释放 150s 定时器,避免每次工具调用残留一个延迟任务)
using (var hardCts = new CancellationTokenSource())
{
var hardTimeout = Task.Delay(TimeSpan.FromSeconds(ToolTimeoutSeconds + 30), hardCts.Token);
try
{
var execTask = Task.Run(() => ExecuteTool(tc, gen, toolCts.Token));
var done = await Task.WhenAny(execTask, hardTimeout).ConfigureAwait(false);
if (done != execTask)
{
AgentLogger.Log($"工具 {tc.Name} 未在协作取消后按时结束,已放弃等待");
return ct.IsCancellationRequested
? "已取消。"
: $"错误:工具 {tc.Name} 执行超时({ToolTimeoutSeconds} 秒),已中止。";
}
return await execTask.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
if (ct.IsCancellationRequested)
return "已取消。";
return $"错误:工具 {tc.Name} 执行超时({ToolTimeoutSeconds} 秒),已中止。";
}
finally
{
hardCts.Cancel(); // 工具已结束:释放硬超时定时器,不再等待 150s
}
}
}
}
private string ExecuteTool(ApiToolCall tc, int gen)
private string ExecuteTool(ApiToolCall tc, int gen, CancellationToken ct)
{
var sw = System.Diagnostics.Stopwatch.StartNew();
try
{
ct.ThrowIfCancellationRequested();
var tool = ToolRegistry.Find(tc.Name);
if (tool == null)
return $"错误:未知工具 {tc.Name}";
// P3 工具级权限(纵深防御):执行侧再校验一次,防止 Schema 过滤被绕过
if (!ToolPermission.IsAllowed(tc.Name, CurrentUser, _toolRoleAllow))
return $"错误:当前用户没有使用 {tc.Name} 的权限(需在设置中为该工具配置角色白名单)。";
JObject args;
try
{
......@@ -811,7 +907,8 @@ namespace Tools.MyAgent.Agent
return $"错误:工具参数解析失败:{tc.Arguments}";
}
OnToolStatus?.Invoke(tool.Describe(args));
string desc = tool.Describe(args); // 只计算一次:状态栏 / 日志 / 轨迹共用
OnToolStatus?.Invoke(desc);
// 知识库检索类工具:先确保知识库可用(连接/范围校验),再执行
if (tool.RequiresKb && (!_kb.ConnectIfNeeded() || !_kb.IsSearchValid))
......@@ -819,11 +916,15 @@ namespace Tools.MyAgent.Agent
// 超时/取消后残留的后台任务:抑制迟到副作用(输出文件误报/轨迹污染)
var ctx = new ToolContext(_kb,
f => { if (gen == System.Threading.Volatile.Read(ref _runGeneration)) OnFileProduced?.Invoke(f); });
f => { if (gen == System.Threading.Volatile.Read(ref _runGeneration)) OnFileProduced?.Invoke(f); })
{
CancelToken = ct,
DbConnections = DbConnections,
};
string result = tool.Execute(args, ctx);
sw.Stop();
// #29 工具日志
AgentLogger.LogTool(tc.Name, tool.Describe(args), sw.ElapsedMilliseconds, SummarizeToolResult(result));
AgentLogger.LogTool(tc.Name, desc, sw.ElapsedMilliseconds, SummarizeToolResult(result));
// #42 结果压缩:超长结果压缩后回填模型,完整结果落盘供溯源
result = CompressToolResult(result, tc.Name);
if (gen == System.Threading.Volatile.Read(ref _runGeneration))
......@@ -833,7 +934,7 @@ namespace Tools.MyAgent.Agent
_toolTrace.Add(new ToolTraceEntry
{
Name = tc.Name,
Args = tool.Describe(args) ?? "",
Args = desc,
DurationMs = sw.ElapsedMilliseconds,
ResultSummary = SummarizeToolResult(result),
});
......@@ -841,6 +942,11 @@ namespace Tools.MyAgent.Agent
}
return result;
}
catch (OperationCanceledException)
{
// 取消/超时:直接向上传播,由 ExecuteToolAsync 返回「已取消/超时」消息(不记录为工具错误)
throw;
}
catch (Exception ex)
{
sw.Stop();
......
......@@ -102,7 +102,7 @@ namespace Tools.MyAgent.Agent
/// <para>D4:带 HTTP 状态码的 4xx(401 Key 错误、400 参数错误等)属于配置/请求问题,
/// 不触发故障转移,直接报错提示用户修正,避免掩盖真实配置错误。</para>
/// </summary>
private static bool IsServiceError(Exception ex)
internal static bool IsServiceError(Exception ex)
{
if (ex == null) return false;
var msg = ex.InnerException?.Message ?? ex.Message ?? "";
......
......@@ -270,7 +270,7 @@ namespace Tools.MyAgent.Agent
{
// 流读取中断(网络抖动):重试完整请求。正文/思维链事件按累计全量下发,重放幂等(赋值而非追加)
if (attempt >= MaxAttempts) throw;
await RetryDelayAsync(attempt, ct).ConfigureAwait(false);
await RetryDelayAsync(attempt, cts.Token).ConfigureAwait(false);
}
}
}
......
using Newtonsoft.Json.Linq;
using System;
using Tools.MyAgent.Agent.Tools;
namespace Tools.MyAgent.Agent
......@@ -12,13 +13,18 @@ namespace Tools.MyAgent.Agent
/// <summary>构建默认工具(核心能力)的 JSON Schema</summary>
public static JArray Build() => Build(false);
/// <summary>构建工具 JSON Schema;includeOptional=true 时包含高级/可选工具</summary>
public static JArray Build(bool includeOptional)
/// <summary>
/// 构建工具 JSON Schema;includeOptional=true 时包含高级/可选工具;
/// allow(P3 工具级权限)非空时仅下发允许当前角色使用的工具。</summary>
public static JArray Build(bool includeOptional, Func<string, bool> allow = null)
{
var tools = includeOptional ? ToolRegistry.All : ToolRegistry.DefaultTools;
var array = new JArray();
foreach (var tool in tools)
{
if (allow != null && !allow(tool.Name)) continue; // 权限不足的工具不下发(模型不可见)
array.Add(Function(tool.Name, tool.Description, tool.ParametersJson));
}
return array;
}
......
......@@ -2,6 +2,7 @@
using System;
using System.Data;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using Tools.MyCommon;
......@@ -18,29 +19,38 @@ namespace Tools.MyAgent.Agent.Tools
{
public string Name => "db_query";
public string Description =>
"对业务数据库执行只读 SQL 查询(仅允许 SELECT 语句,禁止任何写操作),返回前若干行结果。用于用户要求查询/核对业务数据时。连接字符串由用户在对话中提供(sqlserver 示例:Server=主机;Database=库名;User Id=账号;Password=密码;Trusted_Connection=False;sqlite 可只填 .db 文件路径)。";
"对业务数据库执行只读 SQL 查询(仅允许 SELECT 语句,禁止任何写操作),返回前若干行结果。用于用户要求查询/核对业务数据时。" +
"连接字符串两种提供方式:① connectionId 引用设置中预存的连接(推荐,密码不回显,如 sqlserver_erp);" +
"② connection 直接提供连接串(sqlserver 示例:Server=主机;Database=库名;User Id=账号;Password=密码;Trusted_Connection=False;sqlite 可只填 .db 文件路径)。" +
"优先使用 connectionId;连接串/密码一律不得在回答中回显。";
public string ParametersJson => @"{'type':'object',
'properties':{
'type':{'type':'string','enum':['sqlserver','mysql','postgres','sqlite'],'description':'数据库类型'},
'connection':{'type':'string','description':" + ToolJson.Q("连接字符串(用户提供);sqlite 可直接给数据库文件路径") + @"},
'connectionId':{'type':'string','description':" + ToolJson.Q("预存连接 ID(设置中配置,如 sqlserver_erp);指定后无需再传 connection,连接串不回显") + @"},
'connection':{'type':'string','description':" + ToolJson.Q("连接字符串(用户提供,仅当未指定 connectionId 时使用);sqlite 可直接给数据库文件路径") + @"},
'query':{'type':'string','description':" + ToolJson.Q("只读 SELECT 查询语句(单条,不带分号)") + @"},
'maxRows':{'type':'integer','description':'最多返回行数,默认 50,最大 200','minimum':1,'maximum':200}
},
'required':['type','connection','query']}";
'required':['type','query']}";
public bool RequiresKb => false;
public bool IsDefault => false;
public string Describe(JObject args) => $"查询数据库:{args["type"]?.Value<string>()}";
public string Execute(JObject args, ToolContext ctx)
{
string type = (args["type"]?.Value<string>() ?? "").Trim().ToLowerInvariant();
string connection = (args["connection"]?.Value<string>() ?? "").Trim();
string query = args["query"]?.Value<string>() ?? "";
int maxRows = args["maxRows"]?.Value<int>() ?? 50;
if (maxRows < 1) maxRows = 1;
if (maxRows > 200) maxRows = 200;
string error = Validate(type, connection, query);
if (error != null) return error;
// 连接串解析(P0 脱敏):connectionId 优先引用预存配置;两者都缺/无效时给出可用 ID 列表,不回显任何密码
string connection = null;
string error = null;
if (!ResolveConnection(args, ctx, ref connection, ref error))
return error;
string validateError = Validate(type, connection, query);
if (validateError != null) return validateError;
var sw = System.Diagnostics.Stopwatch.StartNew();
string result;
......@@ -55,9 +65,11 @@ namespace Tools.MyAgent.Agent.Tools
AuditQuery(type, query, -1, sw.ElapsedMilliseconds, ex.Message);
// 业务字典(#22):查询报错(如列名/表名错误)时附带 schema 参考,帮助模型修正 SQL
string dict = BusinessDictionaryHint(query);
// 错误信息统一脱敏(数据库驱动异常可能包含连接串成分)
string masked = MaskSensitive(ex.Message);
if (dict != null)
return $"错误:{ex.Message}\n\n{dict}";
return $"错误:{ex.Message}";
return $"错误:{masked}\n\n{dict}";
return $"错误:{masked}";
}
sw.Stop();
int rows = ParseRowCount(result);
......@@ -65,6 +77,47 @@ namespace Tools.MyAgent.Agent.Tools
return result;
}
/// <summary>
/// 解析本次查询使用的连接串(P0 脱敏):优先 connectionId 引用预存配置;
/// 未指定/ID 不存在时回退 connection 参数;两者都缺失时返回可用 ID 列表(不含任何密码)。
/// </summary>
internal static bool ResolveConnection(JObject args, ToolContext ctx, ref string connection, ref string error)
{
error = null;
string id = (args?["connectionId"]?.Value<string>() ?? "").Trim();
string inline = (args?["connection"]?.Value<string>() ?? "").Trim();
if (id.Length > 0)
{
if (ctx?.DbConnections != null
&& ctx.DbConnections.TryGetValue(id, out string stored)
&& !string.IsNullOrEmpty(stored))
{
connection = stored;
return true;
}
error = $"错误:预存连接 {id} 不存在或为空。可用连接 ID:{ListConnectionIds(ctx)}";
return false;
}
if (inline.Length > 0)
{
connection = inline;
return true;
}
error = $"错误:缺少连接信息。请使用 connectionId 引用预存连接(可用 ID:{ListConnectionIds(ctx)}),或直接提供 connection 连接字符串。";
return false;
}
/// <summary>可用预存连接 ID 列表(仅 ID,绝不包含连接串/密码)</summary>
internal static string ListConnectionIds(ToolContext ctx)
{
if (ctx?.DbConnections == null || ctx.DbConnections.Count == 0)
return "(未配置,请在设置中预存连接)";
return string.Join(", ", ctx.DbConnections.Keys.OrderBy(k => k, StringComparer.OrdinalIgnoreCase));
}
// ── #25 审计 ──
private static readonly object AuditLock = new object();
......@@ -98,7 +151,7 @@ namespace Tools.MyAgent.Agent.Tools
}
/// <summary>SQL/敏感信息脱敏(#41):连接串/密码/令牌等不回显</summary>
private static string MaskSensitive(string s)
internal static string MaskSensitive(string s)
{
if (string.IsNullOrEmpty(s)) return s;
return Regex.Replace(s,
......@@ -191,11 +244,15 @@ namespace Tools.MyAgent.Agent.Tools
private static readonly Regex SelectOnly = new Regex(@"^\s*select\b", RegexOptions.IgnoreCase);
private static readonly Regex SelectInto = new Regex(@"\bselect\b[^;]*\binto\b", RegexOptions.IgnoreCase);
/// <summary>字符串字面量(单引号/双引号,含 SQL 标准 '' 转义与 \' / \" 反斜杠转义)——关键字校验前剔除,避免字面量内数据词(如 name='update')误判为写操作</summary>
private static readonly Regex StringLiteral = new Regex(
@"'(?:[^'\\]|\\'|'')*'|""(?:[^""\\]|\\""|"""")*""",
RegexOptions.Compiled);
private static readonly Regex DangerousKeyword = new Regex(
@"\b(insert|update|delete|drop|alter|create|truncate|replace|merge|exec|execute|grant|revoke|attach|detach|vacuum|reindex|call|begin|commit|rollback|savepoint|release|pragma|load_extension|copy)\b",
RegexOptions.IgnoreCase);
private static string Validate(string type, string connection, string query)
internal static string Validate(string type, string connection, string query)
{
if (string.IsNullOrEmpty(type))
return "错误:缺少 type 参数(sqlserver/mysql/postgres/sqlite)。";
......@@ -211,9 +268,11 @@ namespace Tools.MyAgent.Agent.Tools
return "错误:仅允许单条查询,不支持分号分隔的多语句。";
if (query.Contains("--") || query.Contains("/*") || query.Contains("*/"))
return "错误:查询中不允许包含注释(-- 或 /* */)。";
if (SelectInto.IsMatch(query))
// 剔除字符串字面量后再做关键字检查(字面量内出现 update/delete 等词是数据,不是写操作)
string stripped = StringLiteral.Replace(query, "''");
if (SelectInto.IsMatch(stripped))
return "错误:不允许 SELECT INTO(会创建表)。";
if (DangerousKeyword.IsMatch(query))
if (DangerousKeyword.IsMatch(stripped))
return "错误:查询中包含不允许的写操作/管理关键字(仅允许只读 SELECT)。";
return null;
}
......
......@@ -2,12 +2,13 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using Tools.MyAgent.Kb;
namespace Tools.MyAgent.Agent.Tools
{
/// <summary>
/// 工具执行上下文:知识库服务(沙箱校验)与产出文件回调
/// 工具执行上下文:知识库服务(沙箱校验)、产出文件回调、取消令牌与预存数据库连接串(P0 脱敏)
/// </summary>
public sealed class ToolContext
{
......@@ -17,10 +18,19 @@ namespace Tools.MyAgent.Agent.Tools
/// <summary>通知引擎智能体已产出文件(绝对路径),用于回答末尾展示可点击链接</summary>
public Action<string> OnFileProduced { get; }
/// <summary>
/// 取消令牌(P1 扫描可中断):引擎在单工具超时/用户取消时触发;
/// 工具应在长循环(全盘扫描等)中检查并尽早终止。</summary>
public CancellationToken CancelToken { get; set; }
/// <summary>预存数据库连接串(ID → 连接串,已解密明文;来自 AgentConfig.DbConnections)</summary>
public IReadOnlyDictionary<string, string> DbConnections { get; set; }
public ToolContext(KbService kb, Action<string> onFileProduced)
{
Kb = kb;
OnFileProduced = onFileProduced;
CancelToken = CancellationToken.None;
}
/// <summary>沙箱校验解析失败时回退为原路径</summary>
......@@ -31,6 +41,55 @@ namespace Tools.MyAgent.Agent.Tools
}
}
/// <summary>
/// 工具级权限(P3):工具名 → 允许角色列表;空规则 = 全部可用;值空串/"*" = 不限制。
/// <para>纯逻辑、无状态,独立可测;当前角色取登录用户名(LoginInfo.UserName),
/// 规则匹配不分大小写,支持逗号/分号/顿号分隔多个角色。</para>
/// </summary>
public static class ToolPermission
{
/// <summary>规则分隔符(逗号/中文逗号/分号/中文分号/顿号/竖线/空格)</summary>
private static readonly char[] RuleSeparators = { ',', ',', ';', ';', '、', '|', ' ' };
/// <summary>
/// 判断指定角色是否允许使用某工具。
/// <para>allowMap 为 null/空,或未包含该工具名 → 允许(保持现状,不设默认拒绝);
/// 规则值为空串或 "*" → 允许;否则按分隔符拆分后与角色逐项比较(大小写不敏感)。
/// 工具名查找大小写不敏感(配置文件字典可能为默认比较器,避免 DB_QUERY 与 db_query 不匹配导致白名单静默失效)。</para>
/// </summary>
public static bool IsAllowed(string toolName, string role, IReadOnlyDictionary<string, string> allowMap)
{
if (string.IsNullOrEmpty(toolName)) return false;
if (allowMap == null || allowMap.Count == 0) return true;
// 大小写不敏感查找规则(不依赖字典比较器)
string rule = null;
bool found = false;
foreach (var kv in allowMap)
{
if (string.Equals(kv.Key, toolName, StringComparison.OrdinalIgnoreCase))
{
rule = kv.Value;
found = true;
break;
}
}
if (!found) return true;
if (string.IsNullOrWhiteSpace(rule)) return true;
rule = rule.Trim();
if (rule == "*") return true;
var roles = rule.Split(RuleSeparators, StringSplitOptions.RemoveEmptyEntries);
if (roles.Length == 0) return true;
if (string.IsNullOrEmpty(role)) return false;
string current = role.Trim();
foreach (var r in roles)
if (string.Equals(r.Trim(), current, StringComparison.OrdinalIgnoreCase))
return true;
return false;
}
}
/// <summary>
/// 可插拔工具接口:名称 / 描述 / 参数 Schema / 沙箱校验执行。
/// <para>注册表驱动下发(ToolSchema)与执行(AgentEngine),新增工具无需改动引擎。</para>
......
......@@ -23,7 +23,7 @@ namespace Tools.MyAgent.Agent.Tools
public string Describe(JObject args) => $"图像检索:{args["reference"]?.Value<string>()}";
public string Execute(JObject args, ToolContext ctx)
{
return ctx.Kb.ImageSearch(args["reference"]?.Value<string>() ?? "", args["maxResults"]?.Value<int>() ?? 10);
return ctx.Kb.ImageSearch(args["reference"]?.Value<string>() ?? "", args["maxResults"]?.Value<int>() ?? 10, ctx.CancelToken);
}
}
......
......@@ -20,7 +20,7 @@ namespace Tools.MyAgent.Agent.Tools
public string Describe(JObject args) => $"按文件名搜索:{args["pattern"]?.Value<string>()}";
public string Execute(JObject args, ToolContext ctx)
{
return ctx.Kb.Find(args["pattern"]?.Value<string>() ?? "", args["maxResults"]?.Value<int>() ?? 30);
return ctx.Kb.Find(args["pattern"]?.Value<string>() ?? "", args["maxResults"]?.Value<int>() ?? 30, ctx.CancelToken);
}
}
......@@ -41,7 +41,7 @@ namespace Tools.MyAgent.Agent.Tools
public string Describe(JObject args) => $"内容检索:{args["query"]?.Value<string>()}";
public string Execute(JObject args, ToolContext ctx)
{
return ctx.Kb.Grep(args["query"]?.Value<string>() ?? "", args["glob"]?.Value<string>(), args["maxResults"]?.Value<int>() ?? 30);
return ctx.Kb.Grep(args["query"]?.Value<string>() ?? "", args["glob"]?.Value<string>(), args["maxResults"]?.Value<int>() ?? 30, ctx.CancelToken);
}
}
......@@ -60,7 +60,7 @@ namespace Tools.MyAgent.Agent.Tools
public string Describe(JObject args) => $"浏览目录:{args["path"]?.Value<string>() ?? "."}";
public string Execute(JObject args, ToolContext ctx)
{
return ctx.Kb.List(args["path"]?.Value<string>() ?? ".", args["recursive"]?.Value<bool>() ?? false);
return ctx.Kb.List(args["path"]?.Value<string>() ?? ".", args["recursive"]?.Value<bool>() ?? false, ctx.CancelToken);
}
}
......
......@@ -107,5 +107,22 @@
/// <summary>费用估算价格覆盖(R6):模型名(子串匹配)→ "输入价,输出价"(元/百万 token);留空/缺省用内置价格表</summary>
public Dictionary<string, string> CustomPrices { get; set; } = new Dictionary<string, string>();
// ════════════════ 高级项:数据库连接预存(P0 连接串脱敏) ════════════════
/// <summary>
/// 预存数据库连接串(ID → 连接串;sqlite 可填 .db 文件路径)。
/// <para>落盘时经 DPAPI 加密(见 SecretProtector),内存中为明文;db_query 工具按 ID 引用,
/// 聊天中不回显连接串/密码。</para>
/// </summary>
public Dictionary<string, string> DbConnections { get; set; } = new Dictionary<string, string>();
// ════════════════ 高级项:工具级权限(P3,按角色) ════════════════
/// <summary>
/// 工具级权限:工具名 → 允许使用该工具的角色列表(逗号/分号分隔,如 "db_query" = "管理员,主管")。
/// <para>空字典/缺省 = 所有登录用户可用(保持现状);值为空串或 "*" = 不限制。</para>
/// </summary>
public Dictionary<string, string> ToolRoleAllow { get; set; } = new Dictionary<string, string>();
}
}
......@@ -82,11 +82,123 @@ namespace Tools.MyAgent.Forms
_config = config ?? new AgentConfig();
InitializeComponent();
BuildDbConnectionsTab(); // P0:预存数据库连接(db_query 按 ID 引用,聊天不回显密码)
BuildToolPermissionUi(); // P3:工具级权限(按角色)输入区
BindConfig(); // 配置值回填控件(控件创建见 Designer.cs)
ApplyProviderMode(); // 布局联动:BaseUrl 行显隐
ApplyKbMode(); // 布局联动:共享 账号/密码/测试连接 行显隐
}
// ══ P0:预存数据库连接选项卡(程序化创建,避免侵入设计器) ══
private DataGridView dgvDbConn;
/// <summary>
/// 新增「数据库连接」选项卡:ID / 连接串 两列表格,供 db_query 按 ID 引用。
/// <para>连接串仅存内存明文,落盘经 DPAPI 加密(SaveAgentConfig);聊天中只回显 ID。</para>
/// </summary>
private void BuildDbConnectionsTab()
{
var tabDb = new TabPage { Text = "数据库连接", Padding = new Padding(3) };
var grp = new GroupBox
{
Text = "预存连接(db_query 按 ID 引用)",
Location = new Point(8, 12),
Size = new Size(596, 300),
};
dgvDbConn = new DataGridView
{
Location = new Point(16, 32),
Size = new Size(564, 220),
AllowUserToAddRows = true,
AllowUserToDeleteRows = true,
RowHeadersVisible = false,
BackgroundColor = Color.White,
BorderStyle = BorderStyle.FixedSingle,
AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.None,
SelectionMode = DataGridViewSelectionMode.CellSelect,
EditMode = DataGridViewEditMode.EditOnKeystrokeOrF2,
};
dgvDbConn.Columns.Add(new DataGridViewTextBoxColumn { HeaderText = "ID(引用名)", Width = 160, SortMode = DataGridViewColumnSortMode.NotSortable });
dgvDbConn.Columns.Add(new DataGridViewTextBoxColumn { HeaderText = "连接字符串 / sqlite 文件路径", Width = 396, SortMode = DataGridViewColumnSortMode.NotSortable });
var lblHint = new Label
{
Text = "连接串将加密保存,聊天中仅按 ID 引用、不回显密码;sqlite 可只填 .db 文件路径(自动只读连接)。示例 ID:sqlserver_erp、mysql_local。",
Location = new Point(16, 258),
AutoSize = false,
Size = new Size(564, 36),
ForeColor = Color.Gray,
};
grp.Controls.Add(dgvDbConn);
grp.Controls.Add(lblHint);
tabDb.Controls.Add(grp);
tabMain.TabPages.Add(tabDb);
}
// ══ P3:工具级权限(按角色) ══
private TextBox txtToolRoles;
/// <summary>在「其他」页可选能力组追加工具权限输入区(角色白名单,一行一规则)</summary>
private void BuildToolPermissionUi()
{
var lbl = new Label
{
Text = "工具权限(按角色)",
Location = new Point(16, 86),
AutoSize = true,
};
txtToolRoles = new TextBox
{
Location = new Point(16, 108),
Size = new Size(564, 62),
Multiline = true,
AcceptsReturn = true,
ScrollBars = ScrollBars.Vertical,
Font = new Font("Consolas", 9F),
};
var hint = new Label
{
Text = "格式:工具名=允许角色(逗号分隔),一行一个;留空 = 所有用户可用。当前角色 = 登录用户名。\r\n示例:db_query=管理员,主管;module_patch=管理员",
Location = new Point(16, 176),
AutoSize = false,
Size = new Size(564, 34),
ForeColor = Color.Gray,
};
grpOpt.Controls.Add(lbl);
grpOpt.Controls.Add(txtToolRoles);
grpOpt.Controls.Add(hint);
grpOpt.Height = 216;
}
/// <summary>角色规则字典 → 编辑文本(排序保证稳定回显)</summary>
private static string[] ToolRoleRulesToLines(Dictionary<string, string> rules)
{
if (rules == null || rules.Count == 0) return new string[0];
return rules
.OrderBy(kv => kv.Key, StringComparer.OrdinalIgnoreCase)
.Select(kv => $"{kv.Key}={kv.Value}")
.ToArray();
}
/// <summary>编辑文本 → 角色规则字典(忽略空行/非法行)</summary>
private static Dictionary<string, string> ParseToolRoleRules(string text)
{
var rules = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (string.IsNullOrWhiteSpace(text)) return rules;
foreach (var raw in text.Replace("\r\n", "\n").Replace('\r', '\n').Split('\n'))
{
string line = (raw ?? "").Trim();
if (line.Length == 0 || line.StartsWith("#")) continue;
int eq = line.IndexOf('=');
string tool = (eq > 0 ? line.Substring(0, eq) : line).Trim();
string roles = eq > 0 ? line.Substring(eq + 1).Trim() : "";
if (tool.Length > 0)
rules[tool] = roles;
}
return rules;
}
// ══ 配置绑定:把 AgentConfig 值回填到控件(设计器仅设默认值) ══
private void BindConfig()
......@@ -143,6 +255,15 @@ namespace Tools.MyAgent.Forms
chkOpt.Checked = _config.EnableOptionalTools;
chkImageInput.Checked = _config.EnableImageInput; // v2.2 多模态:图片提问开关
// 其他:工具级权限(P3,角色白名单)
txtToolRoles.Lines = ToolRoleRulesToLines(_config.ToolRoleAllow);
// 数据库连接(P0):预存连接串(内存明文态)回填表格
dgvDbConn.Rows.Clear();
if (_config.DbConnections != null)
foreach (var kv in _config.DbConnections.OrderBy(k => k.Key, StringComparer.OrdinalIgnoreCase))
dgvDbConn.Rows.Add(kv.Key, kv.Value);
// 其他:故障转移
chkFail.Checked = _config.EnableFailover;
txtBUrl.Text = _config.BackupBaseUrl;
......@@ -444,6 +565,18 @@ namespace Tools.MyAgent.Forms
// 其他:可选能力
_config.EnableOptionalTools = chkOpt.Checked;
_config.EnableImageInput = chkImageInput.Checked; // v2.2 多模态:图片提问开关
// 其他:工具级权限(P3)
_config.ToolRoleAllow = ParseToolRoleRules(txtToolRoles.Text);
// 数据库连接(P0):表格 → 字典(ID 大小写不敏感,空格清理;空 ID 行忽略)
var conns = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (DataGridViewRow r in dgvDbConn.Rows)
{
if (r.IsNewRow) continue;
string id = (r.Cells[0].Value?.ToString() ?? "").Trim();
if (id.Length == 0) continue;
conns[id] = (r.Cells[1].Value?.ToString() ?? "").Trim();
}
_config.DbConnections = conns;
// 其他:故障转移
_config.EnableFailover = chkFail.Checked;
_config.BackupBaseUrl = txtBUrl.Text.Trim();
......
......@@ -24,17 +24,37 @@ namespace Tools.MyAgent.Kb
public string ApiKey { get; set; } = "";
public bool Enabled { get; set; }
/// <summary>是否 Ollama 端点(按 BaseUrl 判断;Ollama 走 /api/embed 批量接口)</summary>
public bool IsOllama =>
BaseUrl.IndexOf("localhost", StringComparison.OrdinalIgnoreCase) >= 0
|| BaseUrl.IndexOf("127.0.0.1", StringComparison.OrdinalIgnoreCase) >= 0
|| BaseUrl.IndexOf("11434", StringComparison.OrdinalIgnoreCase) >= 0;
/// <summary>
/// 是否 Ollama 端点(按 BaseUrl 判断;Ollama 走 /api/embed 批量接口)。
/// <para>修复:以 /v1 结尾的地址(Ollama 的 OpenAI 兼容端点,如 http://localhost:11434/v1)
/// 应走 /embeddings 而非 /api/embed,否则请求 …/v1/api/embed 返回 404 导致语义检索静默降级。</para>
/// </summary>
public bool IsOllama
{
get
{
var url = BaseUrl ?? "";
if (url.TrimEnd('/').EndsWith("/v1", StringComparison.OrdinalIgnoreCase))
return false; // OpenAI 兼容端点(含 Ollama 的 /v1 别名)
return url.IndexOf("localhost", StringComparison.OrdinalIgnoreCase) >= 0
|| url.IndexOf("127.0.0.1", StringComparison.OrdinalIgnoreCase) >= 0
|| url.IndexOf("11434", StringComparison.OrdinalIgnoreCase) >= 0;
}
}
/// <summary>服务可用性探测结果(避免对不可用服务反复请求)</summary>
private volatile bool _probeFailed;
private DateTime _probeFailedUntil = DateTime.MinValue;
private static readonly TimeSpan ProbeCooldown = TimeSpan.FromSeconds(60);
/// <summary>
/// 索引节流(P2):单批向量化文本条数上限(默认 32,避免建索引期单请求过大打满 Ollama);
/// 超出部分拆分为多批串行发送,批间留出 <see cref="BatchDelayMs"/> 间隔。</summary>
public int BatchSize { get; set; } = 32;
/// <summary>索引节流(P2):批次间间隔毫秒(默认 80,给本地 Ollama 留出推理间隙)</summary>
public int BatchDelayMs { get; set; } = 80;
public EmbeddingClient()
{
_http = new HttpClient
......@@ -51,24 +71,44 @@ namespace Tools.MyAgent.Kb
Enabled = enabled;
}
/// <summary>批量计算文本向量;服务不可用/失败返回 null(保持降级可用)</summary>
/// <summary>
/// 批量计算文本向量(P2 索引节流):按 BatchSize 拆分为多批串行请求,批间延迟 BatchDelayMs;
/// 服务不可用/失败返回 null(保持降级可用)。
/// </summary>
public async Task<List<float[]>> EmbedManyAsync(List<string> texts, CancellationToken ct)
{
if (!Enabled || texts == null || texts.Count == 0) return null;
if (_probeFailed && DateTime.Now < _probeFailedUntil) return null;
try
{
return IsOllama
? await EmbedOllamaAsync(texts, ct).ConfigureAwait(false)
: await EmbedOpenAiAsync(texts, ct).ConfigureAwait(false);
}
catch (Exception)
int batchSize = BatchSize > 0 ? BatchSize : 32;
var result = new List<float[]>(texts.Count);
for (int start = 0; start < texts.Count; start += batchSize)
{
// 不可用:冷却期内不再尝试,返回 null 触发降级
_probeFailed = true;
_probeFailedUntil = DateTime.Now.Add(ProbeCooldown);
return null;
ct.ThrowIfCancellationRequested();
int count = Math.Min(batchSize, texts.Count - start);
var chunk = texts.GetRange(start, count);
List<float[]> batch;
try
{
batch = IsOllama
? await EmbedOllamaAsync(chunk, ct).ConfigureAwait(false)
: await EmbedOpenAiAsync(chunk, ct).ConfigureAwait(false);
}
catch (Exception)
{
// 不可用:冷却期内不再尝试,返回 null 触发降级
_probeFailed = true;
_probeFailedUntil = DateTime.Now.Add(ProbeCooldown);
return null;
}
if (batch == null || batch.Count != count)
return null; // 返回数量不齐视为失败(保持降级语义)
result.AddRange(batch);
// 批间节流:避免建索引期连续请求打满本地 embedding 服务(取消时由 Task.Delay 抛出 OCE 向上传播)
if (start + batchSize < texts.Count)
await Task.Delay(BatchDelayMs, ct).ConfigureAwait(false);
}
return result;
}
/// <summary>单个文本向量(查询用)</summary>
......
......@@ -24,7 +24,8 @@ namespace Tools.MyAgent.Kb
private readonly object _lock = new object();
private SQLiteConnection _conn;
private Timer _sweepTimer;
private readonly EmbeddingClient _emb;
/// <summary>embedding 客户端(可被 KbService 在语义检索配置变更时替换;引用赋值原子,后台线程读取安全)</summary>
private EmbeddingClient _emb;
/// <summary>事件驱动增量更新(P1):单文件变更队列 + 去抖后台处理,避免每次变更全量枚举</summary>
private readonly System.Collections.Concurrent.ConcurrentQueue<string> _pending =
......@@ -67,6 +68,15 @@ namespace Tools.MyAgent.Kb
_dbPath = Path.Combine(indexDir, "kb_" + StableHash(_root) + ".db");
}
/// <summary>
/// 替换 embedding 客户端引用(KbService 语义检索配置变更时调用;null = 禁用向量化)。
/// <para>修复:索引构造时保存的引用在配置禁用/重启用后失效,导致按旧地址/旧模型向量化。</para>
/// </summary>
public void SetEmbeddingClient(EmbeddingClient emb)
{
_emb = emb;
}
/// <summary>启动:打开/建库 + 后台构建(幂等)</summary>
public void Start()
{
......@@ -548,6 +558,8 @@ namespace Tools.MyAgent.Kb
if (!string.IsNullOrEmpty(content))
{
string embedText = content.Length <= MaxEmbedChars ? content : content.Substring(0, MaxEmbedChars);
// 向量化节流由 EmbeddingClient 内部分批 + 批间延迟实现(P2),此处不截断,
// 避免“截断后因 mtime/size 相同被跳过而永不补齐向量”的缺陷
pendingEmbed.Add((rel, embedText));
}
processed++;
......
......@@ -58,10 +58,13 @@ namespace Tools.MyAgent.Kb
if (!enabled)
{
_emb = null;
_index?.SetEmbeddingClient(null); // 索引同步禁用向量化(修复:禁用后索引仍持旧实例继续向量化)
return;
}
if (_emb == null) _emb = new EmbeddingClient();
_emb.Configure(baseUrl, model, apiKey, enabled);
// 修复:索引持有的是构造时传入的引用;重新启用后若不替换,索引会继续用旧地址/旧模型向量化
_index?.SetEmbeddingClient(_emb);
}
/// <summary>知识库近期新增/修改的文件(相对路径列表,7 天内,按时间倒序)</summary>
......@@ -148,6 +151,8 @@ namespace Tools.MyAgent.Kb
EnsureWatcher();
var root = Root;
var searchRoot = SearchRoot;
// P1 扫描可中断:统计扫描带超时取消(60 秒),超时后真正终止后台枚举,避免残留任务占满 IO
var statsCts = new CancellationTokenSource(TimeSpan.FromSeconds(60));
Task.Run(() =>
{
int count = 0;
......@@ -156,7 +161,7 @@ namespace Tools.MyAgent.Kb
var groups = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
try
{
foreach (var file in SafeEnumerateFiles(root))
foreach (var file in SafeEnumerateFiles(root, statsCts.Token))
{
count++;
var ext = Path.GetExtension(file).ToLowerInvariant();
......@@ -169,6 +174,7 @@ namespace Tools.MyAgent.Kb
.Take(6)
.Select(kv => $"{kv.Key}({kv.Value})"));
}
catch (OperationCanceledException) { count = FileCountUnknown; }
catch { count = FileCountUnknown; }
try { digest = BuildStructureDigest(searchRoot); } catch { digest = ""; }
_fileCount = count;
......@@ -176,6 +182,7 @@ namespace Tools.MyAgent.Kb
_structureDigest = digest;
_fileCountStamp = DateTime.Now;
lock (_statsLock) { _statsRunning = false; }
try { statsCts.Dispose(); } catch { }
StatsRefreshed?.Invoke();
});
}
......@@ -525,7 +532,7 @@ namespace Tools.MyAgent.Kb
return abs;
}
private static string ResolveIn(string root, string relPath, string invalidMsg)
internal static string ResolveIn(string root, string relPath, string invalidMsg)
{
if (string.IsNullOrEmpty(root) || !Directory.Exists(root))
throw new InvalidOperationException(invalidMsg);
......@@ -546,7 +553,7 @@ namespace Tools.MyAgent.Kb
}
/// <summary>路径归一化(去掉结尾分隔符;盘符根目录补回分隔符)</summary>
private static string NormPath(string path)
internal static string NormPath(string path)
{
var s = Path.GetFullPath(path).TrimEnd('\\', '/');
if (s.Length == 2 && s[1] == ':')
......@@ -555,7 +562,7 @@ namespace Tools.MyAgent.Kb
}
/// <summary>判断 target 是否位于 rootNorm 之内(含相等)</summary>
private static bool IsWithin(string rootNorm, string target)
internal static bool IsWithin(string rootNorm, string target)
{
return target.Equals(rootNorm, StringComparison.OrdinalIgnoreCase)
|| target.StartsWith(rootNorm + "\\", StringComparison.OrdinalIgnoreCase);
......@@ -564,7 +571,7 @@ namespace Tools.MyAgent.Kb
/// <summary>
/// 检测从根目录到目标路径之间的任一目录/文件是否为 reparse point(junction/符号链接)
/// </summary>
private static bool HasReparsePointOnPath(string root, string target)
internal static bool HasReparsePointOnPath(string root, string target)
{
if (target.Length <= root.Length) return false;
string rel = target.Substring(root.Length).TrimStart('\\', '/');
......@@ -591,25 +598,35 @@ namespace Tools.MyAgent.Kb
/// 递归统计知识库内文件总数(同步全量枚举,供一次性操作使用;
/// 高频 UI 路径请用 EnsureStats + FileCount 的缓存版本)。
/// </summary>
public int CountFiles()
public int CountFiles(CancellationToken ct = default)
{
if (!IsValid) return 0;
try { return Directory.EnumerateFiles(Root, "*", SearchOption.AllDirectories).Count(); }
try
{
int count = 0;
foreach (var _ in SafeEnumerateFiles(Root, ct)) count++;
return count;
}
catch (OperationCanceledException) { return 0; }
catch { return 0; }
}
/// <summary>统计主要文件格式(扩展名 -> 数量,按数量降序)</summary>
public string GetFormatsSummary(int top = 6)
public string GetFormatsSummary(int top = 6, CancellationToken ct = default)
{
if (!IsValid) return "";
var groups = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
foreach (var file in SafeEnumerateFiles(Root))
try
{
var ext = Path.GetExtension(file).ToLowerInvariant();
if (string.IsNullOrEmpty(ext)) ext = "(无扩展名)";
groups.TryGetValue(ext, out var count);
groups[ext] = count + 1;
foreach (var file in SafeEnumerateFiles(Root, ct))
{
var ext = Path.GetExtension(file).ToLowerInvariant();
if (string.IsNullOrEmpty(ext)) ext = "(无扩展名)";
groups.TryGetValue(ext, out var count);
groups[ext] = count + 1;
}
}
catch (OperationCanceledException) { return ""; }
return string.Join(", ", groups
.OrderByDescending(kv => kv.Value)
.Take(top)
......@@ -638,7 +655,12 @@ namespace Tools.MyAgent.Kb
try { files = Directory.GetFiles(dir); }
catch { files = new string[0]; }
foreach (var file in files)
{
// 修复:文件级 reparse point(符号链接文件)同样跳过,防止全盘扫描/索引读取到知识库外部内容
// (kb_read/kb_write 的 ResolveIn 已校验最终文件,但 Grep/Find/索引的枚举路径此前未检查)
if (IsReparsePoint(file)) continue;
yield return file;
}
}
}
......@@ -685,7 +707,7 @@ namespace Tools.MyAgent.Kb
#region kb_list —— 浏览目录结构
/// <summary>列出目录内容(供模型使用;受搜索范围限定)</summary>
public string List(string relPath, bool recursive)
public string List(string relPath, bool recursive, CancellationToken ct = default)
{
try
{
......@@ -698,8 +720,8 @@ namespace Tools.MyAgent.Kb
if (recursive)
{
var subDirs = SafeEnumerateDirs(dir).ToList();
var subFiles = SafeEnumerateFiles(dir).ToList();
var subDirs = SafeEnumerateDirs(dir, ct).ToList();
var subFiles = SafeEnumerateFiles(dir, ct).ToList();
sb.Append($"目录 {display}/ 递归统计:{subFiles.Count} 个文件、{subDirs.Count} 个子目录");
foreach (var d in subDirs)
sb.Append($"\n[目录] {ToRelative(PathBase, d).Replace('\\', '/')}/");
......@@ -736,6 +758,10 @@ namespace Tools.MyAgent.Kb
return Truncate(sb.ToString());
}
catch (OperationCanceledException)
{
throw; // 取消/超时向上传播,由工具层返回「已取消/超时」
}
catch (Exception ex)
{
return $"错误:{ex.Message}";
......@@ -903,7 +929,7 @@ namespace Tools.MyAgent.Kb
private string GrepZeroHit(string query, string glob, int maxResults, CancellationToken ct = default)
{
// ① 语义检索(需 embedding 服务可用且索引含向量)
var sem = SemanticSearch(query, maxResults);
var sem = SemanticSearch(query, maxResults, ct);
if (sem.Count > 0)
{
var lines = sem.Select(h => $"{h.Rel}: {TrimLine(h.Snippet, 120)}");
......@@ -926,13 +952,14 @@ namespace Tools.MyAgent.Kb
/// 语义检索(#9):查询文本向量化后与索引向量做余弦 Top-K。
/// <para>依赖:配置了 embedding 服务且索引已生成向量;任一不可用返回空列表(不影响关键词检索)。</para>
/// </summary>
public List<(string Rel, string Snippet)> SemanticSearch(string query, int maxResults)
public List<(string Rel, string Snippet)> SemanticSearch(string query, int maxResults, CancellationToken ct = default)
{
var result = new List<(string, string)>();
if (!_semanticEnabled || string.IsNullOrWhiteSpace(query)) return result;
try
{
var qv = _emb.EmbedAsync(query, CancellationToken.None).GetAwaiter().GetResult();
ct.ThrowIfCancellationRequested();
var qv = _emb.EmbedAsync(query, ct).GetAwaiter().GetResult();
if (qv == null) return result;
var hits = _index?.SearchSemantic(qv, maxResults);
if (hits == null || hits.Count == 0) return result;
......@@ -944,6 +971,10 @@ namespace Tools.MyAgent.Kb
result.Add((ToRelative(PathBase, abs).Replace('\\', '/'), $"相似度 {h.Score * 100:0}%"));
}
}
catch (OperationCanceledException)
{
throw;
}
catch { }
return result;
}
......@@ -1018,6 +1049,10 @@ namespace Tools.MyAgent.Kb
}
return hits;
}
catch (OperationCanceledException)
{
throw; // 取消/超时向上传播
}
catch
{
return null;
......@@ -1054,7 +1089,7 @@ namespace Tools.MyAgent.Kb
}
/// <summary>模糊相似度:完全相等 1.0 → 包含 0.95/0.9 → 中文二元组重合 0.5~0.95 → 编辑距离归一化</summary>
private static double FuzzyScore(string query, string name)
internal static double FuzzyScore(string query, string name)
{
if (string.IsNullOrEmpty(query) || string.IsNullOrEmpty(name)) return 0;
if (name.Equals(query, StringComparison.OrdinalIgnoreCase)) return 1.0;
......@@ -1092,7 +1127,7 @@ namespace Tools.MyAgent.Kb
}
/// <summary>编辑距离(DP,行滚动数组)</summary>
private static int Levenshtein(string a, string b)
internal static int Levenshtein(string a, string b)
{
if (string.IsNullOrEmpty(a)) return b?.Length ?? 0;
if (string.IsNullOrEmpty(b)) return a.Length;
......@@ -1735,7 +1770,7 @@ namespace Tools.MyAgent.Kb
#region 工具方法
private static Regex GlobToRegex(string glob)
internal static Regex GlobToRegex(string glob)
{
var sb = new StringBuilder("^");
foreach (char c in glob)
......@@ -1866,7 +1901,7 @@ namespace Tools.MyAgent.Kb
/// 图像检索(#38):以参考图为模板,在知识库图片目录中检索相似图,返回相似度 Top-K。
/// <para>复用平台 MyUtils.ImageSearch 感知哈希(aHash/dHash/pHash)能力,不再自带 OpenCV 实现。</para>
/// </summary>
public string ImageSearch(string referencePath, int maxResults)
public string ImageSearch(string referencePath, int maxResults, CancellationToken ct = default)
{
try
{
......@@ -1889,8 +1924,9 @@ namespace Tools.MyAgent.Kb
string refP = Tools.MyUtils.ImageSearch.pHash(refImg);
var candidates = new List<(string Rel, double Score)>();
foreach (var file in SafeEnumerateFiles(SearchRoot))
foreach (var file in SafeEnumerateFiles(SearchRoot, ct))
{
ct.ThrowIfCancellationRequested();
if (!IsImageExtension(Path.GetExtension(file))) continue;
if (string.Equals(Path.GetFullPath(file), Path.GetFullPath(refAbs), StringComparison.OrdinalIgnoreCase)) continue;
try
......@@ -1918,6 +1954,10 @@ namespace Tools.MyAgent.Kb
return sb.ToString();
}
}
catch (OperationCanceledException)
{
throw; // 取消/超时向上传播
}
catch (Exception ex)
{
return $"错误:{ex.Message}";
......
......@@ -83,20 +83,27 @@ namespace Tools.MyAgent
/// 拼装 net use 参数。
/// <para>未提供账号时使用当前 Windows 身份(不传密码,避免交互询问);
/// 提供账号时总带上密码参数(可为空串),避免 net use 交互式提示输入密码。</para>
/// <para>参数内引号转义为 ""(cmd 语法),防止账号/密码含引号时破坏命令行(修复)。</para>
/// </summary>
private static string BuildNetUseArgs(string share, string user, string password)
{
var sb = new StringBuilder();
sb.Append('"').Append(share).Append('"');
sb.Append(Q(share));
if (!string.IsNullOrEmpty(user))
{
sb.Append(" \"").Append(password ?? "").Append('"');
sb.Append(" /user:\"").Append(user).Append('"');
sb.Append(' ').Append(Q(password ?? ""));
sb.Append(" /user:").Append(Q(user));
}
sb.Append(" /persistent:no");
return sb.ToString();
}
/// <summary>cmd 命令行参数加引号(内部引号转义为 "")</summary>
private static string Q(string s)
{
return "\"" + (s ?? "").Replace("\"", "\"\"") + "\"";
}
/// <summary>执行 net use 命令并等待结果(超时自动终止),返回 (退出码, 输出文本[含错误输出])</summary>
private static (int Exit, string Output) RunNetUse(string args, int timeoutMs)
{
......@@ -126,6 +133,8 @@ namespace Tools.MyAgent
try { p.Kill(); } catch { }
return (-2, sb.ToString());
}
p.WaitForExit(); // 修复:等待异步输出流排空(BeginOutputReadLine 是异步的,
// 仅 WaitForExit(ms) 返回后输出可能尚未读完,导致错误详情缺失)
return (p.ExitCode, sb.ToString());
}
}
......
......@@ -144,17 +144,17 @@ namespace Tools.MyAgent.Support
return sb.ToString();
}
/// <summary>配置文件中的 ApiKey 脱敏(仅保留前后几位)</summary>
/// <summary>配置文件中的 ApiKey 脱敏(仅保留前后几位);扩展至全部凭据字段(P0 凭据收敛)</summary>
private static string MaskSecrets(string json)
{
try
{
json = System.Text.RegularExpressions.Regex.Replace(json,
@"(?i)(""ApiKey""|ApiKey|api[_-]?key)\s*[:=]\s*""?([^\""\r\n,}]*)""?",
@"(?i)(""(ApiKey|SharePassword|EmbeddingApiKey|BackupApiKey|Password|Pwd|Secret|Token)""|api[_-]?key|password|pwd|secret)\s*[:=]\s*""?([^\""\r\n,}]*)""?",
match =>
{
string key = match.Groups[1].Value;
string val = match.Groups[2].Value.Trim();
string val = match.Groups[3].Value.Trim();
if (val.Length <= 4) return match.Value;
string masked = val.Substring(0, 2) + "***" + val.Substring(val.Length - 2);
return key + "=\"" + masked + "\"";
......
......@@ -19,5 +19,8 @@ namespace Tools.MyAgent
/// <summary>附图列表(多图,v2.3):一次提问可携带多张图片;优先于 ImagePath(旧会话兼容保留单图字段)</summary>
public List<string> Images { get; set; }
/// <summary>回复末尾用量标注(展示用,不参与模型上下文/历史导出):如「本回复 1284+312 tokens(约 ¥0.42)」</summary>
public string UsageSuffix { get; set; }
}
}
......@@ -80,6 +80,7 @@ namespace Tools.MyAgent
{
return (m.Role ?? "") + "|" + m.Timestamp.Ticks + "|"
+ (m.Content ?? "") + "|" + (m.ThinkingContent ?? "") + "|" + m.IsStreaming
+ "|" + (m.UsageSuffix ?? "") // 用量标注:完成时附加,需纳入缓存键以触发重绘
+ "|" + assistantName + "|" + userName + "|" + detailsExpanded;
}
}
......
......@@ -153,6 +153,16 @@ namespace Tools.MyAgent
sb.Append(@"\par\par ");
}
RenderMarkdown(sb, msg.Content, expandDetails);
// 用量标注(展示用):回答完成后在末尾追加本轮用量(灰色小字)
if (!string.IsNullOrEmpty(msg.UsageSuffix))
{
sb.Append(@"\pard\cf"); sb.Append(ClBq);
sb.Append(@"\fs18 ");
sb.Append(Escape(msg.UsageSuffix));
sb.Append(@"\cf"); sb.Append(ClText);
sb.Append(@"\fs20\par ");
}
}
}
......
......@@ -38,6 +38,9 @@ namespace Tools.MyAgent
/// <summary>v2.3 待发送附图缩略图栏(问答区左下角悬浮;每张图右上角叉号可取消)</summary>
private FlowLayoutPanel _pendingBar;
private readonly List<string> _producedFiles = new List<string>();
/// <summary>本轮回复用量基线快照(发送前记录引擎累计值;回复完成后按增量展示「本回复」用量)</summary>
private int _replyPromptBase, _replyCompletionBase;
private double _replyCostBase;
private Point _mouseDownPos = Point.Empty;
private bool _mouseButtonDown;
private Cursor _defaultChatCursor = Cursors.Arrow; // 问答区不支持输入:默认箭头光标(原 IBeam)
......@@ -136,8 +139,8 @@ namespace Tools.MyAgent
/// <summary>Agent 配置文件:默认 Agent 目录下的 config\AgentConfig.json</summary>
private static string AgentConfigFilePath => Path.Combine(DefaultKbRoot, "config", "AgentConfig.json");
/// <summary>读取 Agent 配置文件(默认 Agent 目录下 config 文件夹);返回时 SharePassword 已解密为明文</summary>
/// <param name="migrated">旧版配置是否需要一次性落盘迁移(明文密码加密 / 自动启用系统提示词)</param>
/// <summary>读取 Agent 配置文件(默认 Agent 目录下 config 文件夹);返回时 SharePassword / ApiKey / EmbeddingApiKey / BackupApiKey / DbConnections 已解密为明文</summary>
/// <param name="migrated">旧版配置是否需要一次性落盘迁移(明文凭据加密 / 自动启用系统提示词)</param>
private AgentConfig LoadAgentConfig(out bool migrated)
{
migrated = false;
......@@ -148,9 +151,12 @@ namespace Tools.MyAgent
string raw = File.ReadAllText(AgentConfigFilePath, System.Text.Encoding.UTF8);
var cfg = Newtonsoft.Json.JsonConvert.DeserializeObject<AgentConfig>(raw);
if (cfg == null) return null;
// 旧版明文密码:先解密标记为待迁移(下次保存时加密落盘)
migrated = !string.IsNullOrEmpty(cfg.SharePassword)
&& !SecretProtector.IsProtected(cfg.SharePassword);
// 旧版明文凭据:先解密标记为待迁移(下次保存时加密落盘)
migrated = (!string.IsNullOrEmpty(cfg.SharePassword) && !SecretProtector.IsProtected(cfg.SharePassword))
|| (!string.IsNullOrEmpty(cfg.ApiKey) && !SecretProtector.IsProtected(cfg.ApiKey))
|| (!string.IsNullOrEmpty(cfg.EmbeddingApiKey) && !SecretProtector.IsProtected(cfg.EmbeddingApiKey))
|| (!string.IsNullOrEmpty(cfg.BackupApiKey) && !SecretProtector.IsProtected(cfg.BackupApiKey))
|| HasPlainConnections(cfg.DbConnections);
// 旧版配置无 SystemPromptEnabled 字段:若已填写自定义提示词则默认启用(行为兼容迁移)
try
{
......@@ -163,6 +169,20 @@ namespace Tools.MyAgent
}
catch { }
cfg.SharePassword = SecretProtector.Unprotect(cfg.SharePassword);
cfg.ApiKey = SecretProtector.Unprotect(cfg.ApiKey);
cfg.EmbeddingApiKey = SecretProtector.Unprotect(cfg.EmbeddingApiKey);
cfg.BackupApiKey = SecretProtector.Unprotect(cfg.BackupApiKey);
if (cfg.DbConnections != null)
{
var plain = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var kv in cfg.DbConnections)
plain[kv.Key] = SecretProtector.Unprotect(kv.Value);
cfg.DbConnections = plain;
}
else
{
cfg.DbConnections = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
}
return cfg;
}
}
......@@ -170,15 +190,29 @@ namespace Tools.MyAgent
return null;
}
/// <summary>保存 Agent 配置文件到默认 Agent 目录下 config 文件夹(共享密码经 DPAPI 加密落盘)</summary>
/// <summary>预存连接串中是否存在未加密明文(用于旧版迁移检测)</summary>
private static bool HasPlainConnections(Dictionary<string, string> conns)
{
if (conns == null) return false;
foreach (var kv in conns)
if (!string.IsNullOrEmpty(kv.Value) && !SecretProtector.IsProtected(kv.Value))
return true;
return false;
}
/// <summary>保存 Agent 配置文件到默认 Agent 目录下 config 文件夹(全部凭据经 DPAPI 加密落盘:共享密码 / ApiKey / EmbeddingApiKey / BackupApiKey / 预存连接串)</summary>
private void SaveAgentConfig(AgentConfig cfg)
{
try
{
Directory.CreateDirectory(Path.Combine(DefaultKbRoot, "config"));
var protectedConns = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (cfg.DbConnections != null)
foreach (var kv in cfg.DbConnections)
protectedConns[kv.Key] = SecretProtector.Protect(kv.Value);
var toSave = new AgentConfig
{
ApiKey = cfg.ApiKey,
ApiKey = SecretProtector.Protect(cfg.ApiKey),
BaseUrl = cfg.BaseUrl,
Model = cfg.Model,
Provider = cfg.Provider,
......@@ -196,14 +230,16 @@ namespace Tools.MyAgent
UseSystemProxy = cfg.UseSystemProxy,
EmbeddingBaseUrl = cfg.EmbeddingBaseUrl,
EmbeddingModel = cfg.EmbeddingModel,
EmbeddingApiKey = cfg.EmbeddingApiKey,
EmbeddingApiKey = SecretProtector.Protect(cfg.EmbeddingApiKey),
EnableSemanticSearch = cfg.EnableSemanticSearch,
EnableFailover = cfg.EnableFailover,
BackupBaseUrl = cfg.BackupBaseUrl,
BackupModel = cfg.BackupModel,
BackupApiKey = cfg.BackupApiKey,
BackupApiKey = SecretProtector.Protect(cfg.BackupApiKey),
EnableOptionalTools = cfg.EnableOptionalTools,
EnableImageInput = cfg.EnableImageInput,
DbConnections = protectedConns,
ToolRoleAllow = cfg.ToolRoleAllow ?? new Dictionary<string, string>(),
};
File.WriteAllText(AgentConfigFilePath, Newtonsoft.Json.JsonConvert.SerializeObject(toSave), System.Text.Encoding.UTF8);
}
......@@ -243,6 +279,8 @@ namespace Tools.MyAgent
{
_engine.Configure(_config);
_engine.SetOptionalTools(_config.EnableOptionalTools);
// 工具级权限(P3):当前登录用户名即角色,用于 db_query/module_patch 等按角色开关
_engine.CurrentUser = string.IsNullOrEmpty(LoginInfo.UserName) ? APP.TEMP_USER_NAME : LoginInfo.UserName;
// X2 插件化:扫描 Agent/plugins 目录加载第三方工具(幂等,失败仅记日志)
Tools.MyAgent.Agent.Tools.ToolRegistry.LoadPlugins(Path.Combine(DefaultKbRoot, "plugins"));
// 语义检索(embedding)配置:随知识库索引构建批量向量化;服务不可用自动降级为纯关键词
......@@ -394,20 +432,14 @@ namespace Tools.MyAgent
string kbText;
if (_kb.IsValid)
{
_kb.EnsureStats();
kbText = $"{Path.GetFileName(_kb.Root.TrimEnd('\\'))}({FormatFileCount(_kb.FileCount)})";
kbText = Path.GetFileName(_kb.Root.TrimEnd('\\')); // 状态栏仅显示知识库名,不拼接文件数量
}
else if (!string.IsNullOrEmpty(_kb.Root))
kbText = $"{Path.GetFileName(_kb.Root.TrimEnd('\\'))}(不可访问)";
else
kbText = "未选知识库";
// D16 用量统计:会话累计 token(prompt+completion),有调用记录时展示
// R6 费用估算:累计费用 > 0 时附带估算金额(按实际服务模型与价格表)
string usage = _engine.TotalPromptTokens + _engine.TotalCompletionTokens > 0
? $" · 本会话 {_engine.TotalPromptTokens}+{_engine.TotalCompletionTokens} tokens"
+ (_engine.TotalCost > 0 ? $"(约 {CostEstimator.Format(_engine.TotalCost)})" : "")
: "";
lblStatus.Text = $"{_config.Model} · {kbText}{usage}";
// 用量展示优化:状态栏不再拼接用量,用量改为附加到每条回复结尾
lblStatus.Text = $"{_config.Model} · {kbText}";
lblStatus.ForeColor = Color.Gray;
}
......@@ -471,6 +503,11 @@ namespace Tools.MyAgent
RefreshChat();
txtInput.Focus();
// 用量快照:记录本轮发送前的累计值,回复完成后按增量展示本轮用量
_replyPromptBase = _engine.TotalPromptTokens;
_replyCompletionBase = _engine.TotalCompletionTokens;
_replyCostBase = _engine.TotalCost;
_sendCts = new CancellationTokenSource();
try
{
......@@ -544,13 +581,29 @@ namespace Tools.MyAgent
last.IsStreaming = false;
if (_producedFiles.Count > 0)
last.Content = AppendOutputFiles(last.Content);
// 用量展示优化:本轮用量附加到回复结尾(相对发送前快照的增量;无调用记录不显示)
string usage = BuildReplyUsageSuffix();
if (usage.Length > 0)
last.UsageSuffix = usage;
}
ResetInputState();
RefreshChat();
UpdateStatusIndicator(); // D16:刷新会话累计用量
UpdateStatusIndicator(); // 刷新状态栏(模型/知识库信息)
SaveDraftSession(); // D12:每轮回答完成即更新草稿(崩溃恢复)
}
/// <summary>构造本轮回复用量标注(相对发送前快照的增量;本轮无调用记录返回空串)</summary>
private string BuildReplyUsageSuffix()
{
int prompt = _engine.TotalPromptTokens - _replyPromptBase;
int completion = _engine.TotalCompletionTokens - _replyCompletionBase;
if (prompt + completion <= 0) return "";
string text = $"本回复 {prompt}+{completion} tokens";
double cost = _engine.TotalCost - _replyCostBase;
if (cost > 0) text += $"(约 {CostEstimator.Format(cost)})";
return text;
}
/// <summary>工具轨迹(#16):回答末尾追加可折叠 details,展示工具调用链(先宽后窄的检索过程)</summary>
private void OnToolTrace(System.Collections.Generic.IReadOnlyList<AgentEngine.ToolTraceEntry> trace)
{
......@@ -587,17 +640,20 @@ namespace Tools.MyAgent
var last = LastAssistantMessage();
if (last != null && last.IsStreaming)
{
// 流式消息尚未成型,直接用错误文本覆盖
// 流式消息尚未成型,直接用错误文本覆盖(用量按增量附加)
last.Content = message;
last.IsStreaming = false;
last.UsageSuffix = BuildReplyUsageSuffix();
}
else
{
_messages.Add(new ChatMessage { Role = "assistant", Content = $"[Error: {message}]" });
var err = new ChatMessage { Role = "assistant", Content = $"[Error: {message}]" };
err.UsageSuffix = BuildReplyUsageSuffix();
_messages.Add(err);
}
ResetInputState();
RefreshChat();
UpdateStatusIndicator(); // D16:错误结束也刷新用量
UpdateStatusIndicator(); // 错误结束也刷新状态栏
SaveDraftSession(); // D12:出错也保留当前对话为草稿
}
......
......@@ -34,3 +34,6 @@ using System.Runtime.InteropServices;
// [assembly: AssemblyVersion("1.0.*")]
[assembly: AssemblyVersion("1.72.*")]
// ECC 测试要求:向智能体单元测试工程开放 internal 成员(沙箱校验/SQL 白名单/压缩截断/故障转移/token 裁剪等纯逻辑)
[assembly: InternalsVisibleTo("AgentTest")]
......@@ -798,7 +798,6 @@
</ItemGroup>
<ItemGroup>
<Content Include="favior.ico" />
<Content Include="prompt_design.md" />
<None Include="Resources\tools_desc.jpg" />
<None Include="Resources\文件夹.png" />
<None Include="Resources\nav_plain_red.png" />
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment