Commit e9ea0397 authored by 段廷瑞's avatar 段廷瑞

增强Tool.Agent日志配置与程序集问题排查能力

新增诊断根目录安全策略、日志检索、配置脱敏、程序集元数据分析和综合排查工具,接入运行时诊断上下文、traceId与宿主目录配置,并补充回归测试和使用文档。
parent 19a69673
...@@ -81,6 +81,7 @@ ...@@ -81,6 +81,7 @@
<Compile Include="TextEncodingTests.cs" /> <Compile Include="TextEncodingTests.cs" />
<Compile Include="MarkdownRendererTests.cs" /> <Compile Include="MarkdownRendererTests.cs" />
<Compile Include="ProductContextTests.cs" /> <Compile Include="ProductContextTests.cs" />
<Compile Include="DiagnosticTests.cs" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\Tools.Agent\Tools.Agent.csproj"> <ProjectReference Include="..\Tools.Agent\Tools.Agent.csproj">
......
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using Newtonsoft.Json.Linq;
using Tools.MyAgent;
using Tools.MyAgent.Agent;
using Tools.MyAgent.Agent.Tools;
using Tools.MyAgent.Diagnostics;
using Tools.MyAgent.Kb;
namespace AgentTest
{
/// <summary>日志、配置、程序集诊断能力和诊断根目录安全边界测试。</summary>
public sealed class DiagnosticTests : TestRunner
{
private string _root;
private string _outside;
private List<DiagnosticRoot> _roots;
public override void SetUp()
{
_root = Path.Combine(Path.GetTempPath(), "agenttest_diag_" + Guid.NewGuid().ToString("N"));
_outside = Path.Combine(Path.GetTempPath(), "agenttest_diag_out_" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(Path.Combine(_root, "logs"));
Directory.CreateDirectory(Path.Combine(_root, "config"));
Directory.CreateDirectory(Path.Combine(_root, "bin"));
Directory.CreateDirectory(_outside);
File.WriteAllText(Path.Combine(_root, "logs", "app.log"),
"2026-09-10 15:42:17.001 INFO start\r\n" +
"2026-09-10 15:42:18.123 ERROR FileNotFoundException: Could not load file or assembly 'Demo.dll'\r\n" +
" at Demo.Start()\r\n" +
"2026-09-10 15:42:19.000 INFO done\r\n");
File.WriteAllText(Path.Combine(_root, "config", "app.config"),
"<?xml version=\"1.0\"?><configuration><connectionStrings><add name=\"db\" connectionString=\"Server=x;Password=secret\" /></connectionStrings><appSettings><add key=\"token\" value=\"abc123\" /></appSettings></configuration>");
_roots = new List<DiagnosticRoot>
{
new DiagnosticRoot { Id = "logs", Path = Path.Combine(_root, "logs"), Kind = "log" },
new DiagnosticRoot { Id = "config", Path = Path.Combine(_root, "config"), Kind = "config" },
new DiagnosticRoot { Id = "bin", Path = Path.Combine(_root, "bin"), Kind = "deployment" },
};
}
public override void TearDown()
{
try { Directory.Delete(_root, true); } catch { }
try { Directory.Delete(_outside, true); } catch { }
}
[Test]
public void RootPolicy_RejectsAbsoluteAndTraversal()
{
DiagnosticRoot root; string full; string display; string error;
Assert.False(DiagnosticRootPolicy.TryResolve(_roots, "logs", _outside, out root, out full, out display, out error));
Assert.False(DiagnosticRootPolicy.TryResolve(_roots, "logs", "../config/app.config", out root, out full, out display, out error));
Assert.Contains(error, "越界");
}
[Test]
public void LogSearch_ReturnsEvidenceLocationAndSignature()
{
var result = LogAnalyzer.Search(_roots, "logs", "FileNotFoundException", false, "", "", 1, 10, CancellationToken.None);
Assert.Equal("ok", result.Status);
Assert.Equal(1, result.Evidence.Count);
Assert.Equal("exception", result.Evidence[0].Kind);
Assert.Equal(1, result.Evidence[0].LineStart);
Assert.Equal(3, result.Evidence[0].LineEnd);
Assert.Contains(result.Evidence[0].Signature, "FileNotFoundException");
Assert.Contains(result.Evidence[0].Source, "logs/app.log");
Assert.True(result.Evidence[0].Hash.Length == 64, "证据应带 SHA256 哈希");
}
[Test]
public void ConfigRead_MasksSecretsAndReportsEvidence()
{
string path = Path.Combine(_root, "config", "app.config");
var result = ConfigAnalyzer.Read(path, "config/app.config", 8000);
string text = result.Evidence[0].Content;
Assert.Equal("ok", result.Status);
Assert.Contains(text, "Server=x");
Assert.Contains(text, "***");
Assert.NotContains(text, "secret");
Assert.NotContains(text, "abc123");
}
[Test]
public void AssemblyInspector_ReadsManagedMetadataWithoutExecutingTypes()
{
string assemblyPath = typeof(AgentEngine).Assembly.Location;
var result = AssemblyInspector.Inspect(assemblyPath, "bin/Tools.Agent.dll", false);
string text = result.Evidence[0].Content;
Assert.Equal("ok", result.Status);
Assert.Contains(text, "Tools.Agent");
Assert.Contains(text, "AssemblyVersion");
Assert.Contains(text, "References");
}
[Test]
public void Runtime_PreservesUnboundDiagnosticRoots()
{
var runtime = new AgentRuntime(new AgentRuntimeOptions { DiagnosticRoots = _roots });
Assert.Equal(3, runtime.DiagnosticRoots.Count);
Assert.Equal("logs", runtime.DiagnosticRoots[0].Id);
using (var engine = runtime.CreateEngine(new KbService()))
Assert.Equal(3, engine.DiagnosticRoots.Count);
}
[Test]
public void DiagnosticTools_AreRegistered()
{
string schema = ToolSchema.Build(true).ToString();
Assert.Contains(schema, "log_search");
Assert.Contains(schema, "config_read");
Assert.Contains(schema, "assembly_manifest");
Assert.Contains(schema, "diagnose_incident");
}
}
}
...@@ -8,6 +8,7 @@ using System.Text; ...@@ -8,6 +8,7 @@ using System.Text;
using System.Threading; using System.Threading;
using System.Windows.Forms; using System.Windows.Forms;
using Tools.MyAgent; using Tools.MyAgent;
using Tools.MyAgent.Diagnostics;
namespace Tools.MyAgent.Host namespace Tools.MyAgent.Host
{ {
...@@ -40,6 +41,7 @@ namespace Tools.MyAgent.Host ...@@ -40,6 +41,7 @@ namespace Tools.MyAgent.Host
product.ProjectRoot = options.ProjectRoot; product.ProjectRoot = options.ProjectRoot;
product.KnowledgeBaseRoot = options.KnowledgeBaseRoot; product.KnowledgeBaseRoot = options.KnowledgeBaseRoot;
product.KnowledgeBaseScope = options.KnowledgeBaseScope; product.KnowledgeBaseScope = options.KnowledgeBaseScope;
product.DiagnosticRoots = options.BuildDiagnosticRoots();
var runtimeOptions = new AgentRuntimeOptions var runtimeOptions = new AgentRuntimeOptions
{ {
...@@ -192,6 +194,9 @@ namespace Tools.MyAgent.Host ...@@ -192,6 +194,9 @@ namespace Tools.MyAgent.Host
public string ProjectRoot = ""; public string ProjectRoot = "";
public string KnowledgeBaseRoot = ""; public string KnowledgeBaseRoot = "";
public string KnowledgeBaseScope = ""; public string KnowledgeBaseScope = "";
public string LogRoot = "";
public string ConfigRoot = "";
public string DeploymentRoot = "";
public string DataRoot; public string DataRoot;
public int ParentPid; public int ParentPid;
public bool ValidateOnly; public bool ValidateOnly;
...@@ -200,7 +205,8 @@ namespace Tools.MyAgent.Host ...@@ -200,7 +205,8 @@ namespace Tools.MyAgent.Host
public static readonly string Usage = public static readonly string Usage =
"Tools.Agent.Host.exe --protocol 1 --product <pdm|erp|mes> --user <user> " "Tools.Agent.Host.exe --protocol 1 --product <pdm|erp|mes> --user <user> "
+ "--data-root <path> [--project-root <path>] [--kb-root <path>] " + "--data-root <path> [--project-root <path>] [--kb-root <path>] "
+ "[--kb-scope <path>] [--parent-pid <pid>]"; + "[--kb-scope <path>] [--log-root <path>] [--config-root <path>] [--deployment-root <path>] "
+ "[--parent-pid <pid>]";
public static bool TryParse(string[] args, out LaunchOptions options, out string error) public static bool TryParse(string[] args, out LaunchOptions options, out string error)
{ {
...@@ -263,6 +269,9 @@ namespace Tools.MyAgent.Host ...@@ -263,6 +269,9 @@ namespace Tools.MyAgent.Host
values.TryGetValue("project-root", out options.ProjectRoot); values.TryGetValue("project-root", out options.ProjectRoot);
values.TryGetValue("kb-root", out options.KnowledgeBaseRoot); values.TryGetValue("kb-root", out options.KnowledgeBaseRoot);
values.TryGetValue("kb-scope", out options.KnowledgeBaseScope); values.TryGetValue("kb-scope", out options.KnowledgeBaseScope);
values.TryGetValue("log-root", out options.LogRoot);
values.TryGetValue("config-root", out options.ConfigRoot);
values.TryGetValue("deployment-root", out options.DeploymentRoot);
if (values.TryGetValue("parent-pid", out value) && !int.TryParse(value, out options.ParentPid)) if (values.TryGetValue("parent-pid", out value) && !int.TryParse(value, out options.ParentPid))
{ {
error = "parent-pid 不是有效整数。"; error = "parent-pid 不是有效整数。";
...@@ -270,6 +279,22 @@ namespace Tools.MyAgent.Host ...@@ -270,6 +279,22 @@ namespace Tools.MyAgent.Host
} }
return true; return true;
} }
public DiagnosticRoot[] BuildDiagnosticRoots()
{
var roots = new List<DiagnosticRoot>();
AddRoot(roots, "source", "source", ProjectRoot);
AddRoot(roots, "logs", "log", LogRoot);
AddRoot(roots, "config", "config", ConfigRoot);
AddRoot(roots, "deployment", "deployment", DeploymentRoot);
return roots.ToArray();
}
private static void AddRoot(List<DiagnosticRoot> roots, string id, string kind, string path)
{
if (string.IsNullOrWhiteSpace(path)) return;
roots.Add(new DiagnosticRoot { Id = id, Kind = kind, Path = Path.GetFullPath(path) });
}
} }
private sealed class ParentMonitor : IDisposable private sealed class ParentMonitor : IDisposable
......
...@@ -9,6 +9,7 @@ using System.Threading; ...@@ -9,6 +9,7 @@ using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Tools.MyAgent.Agent.Tools; using Tools.MyAgent.Agent.Tools;
using Tools.MyAgent.Config; using Tools.MyAgent.Config;
using Tools.MyAgent.Diagnostics;
using Tools.MyAgent.Kb; using Tools.MyAgent.Kb;
using Tools.MyAgent; // AgentLogger(#29) using Tools.MyAgent; // AgentLogger(#29)
...@@ -25,6 +26,7 @@ namespace Tools.MyAgent.Agent ...@@ -25,6 +26,7 @@ namespace Tools.MyAgent.Agent
private readonly PromptBuilder _prompts = new PromptBuilder(); private readonly PromptBuilder _prompts = new PromptBuilder();
private readonly DialectService _dialect; // 内置方言/行业知识(程序目录 Agent/dialect + Agent/industry) private readonly DialectService _dialect; // 内置方言/行业知识(程序目录 Agent/dialect + Agent/industry)
private AgentProductContext _productContext; private AgentProductContext _productContext;
private IReadOnlyList<DiagnosticRoot> _diagnosticRoots = new List<DiagnosticRoot>();
private CancellationTokenSource _cts; private CancellationTokenSource _cts;
private int _disposeStarted; private int _disposeStarted;
...@@ -139,6 +141,7 @@ namespace Tools.MyAgent.Agent ...@@ -139,6 +141,7 @@ namespace Tools.MyAgent.Agent
/// 其迟到副作用(OnFileProduced / 工具轨迹)据此抑制,避免跨轮污染。 /// 其迟到副作用(OnFileProduced / 工具轨迹)据此抑制,避免跨轮污染。
/// </summary> /// </summary>
private int _runGeneration; private int _runGeneration;
private string _diagnosticSessionId = "";
/// <summary>本轮上下文处理提示(历史省略/压缩);经 OnTextDelta 即时显示, /// <summary>本轮上下文处理提示(历史省略/压缩);经 OnTextDelta 即时显示,
/// 并在最终回答前拼接保留(修复:此前被回答正文全量替换而丢失)</summary> /// 并在最终回答前拼接保留(修复:此前被回答正文全量替换而丢失)</summary>
...@@ -153,6 +156,23 @@ namespace Tools.MyAgent.Agent ...@@ -153,6 +156,23 @@ namespace Tools.MyAgent.Agent
} }
/// <summary>创建引擎,并允许宿主指定内置提示词、方言、行业知识和产品上下文所在的 Agent 根目录。</summary> /// <summary>创建引擎,并允许宿主指定内置提示词、方言、行业知识和产品上下文所在的 Agent 根目录。</summary>
public AgentEngine(KbService kb, string agentRoot, AgentProductContext productContext,
IEnumerable<DiagnosticRoot> diagnosticRoots) : this(kb, agentRoot, productContext)
{
_diagnosticRoots = DiagnosticRootPolicy.Normalize(diagnosticRoots);
if (_diagnosticRoots.Count > 0) _prompts.DiagnosticRoots = _diagnosticRoots;
}
/// <summary>当前运行时诊断根目录快照;运行期间不可切换。</summary>
public IReadOnlyList<DiagnosticRoot> DiagnosticRoots
{
get
{
var roots = _diagnosticRoots.Count > 0 ? _diagnosticRoots : (_productContext?.DiagnosticRoots ?? new DiagnosticRoot[0]);
return roots.Select(x => x.Clone()).ToArray();
}
}
public AgentEngine(KbService kb, string agentRoot, AgentProductContext productContext) public AgentEngine(KbService kb, string agentRoot, AgentProductContext productContext)
{ {
_kb = kb ?? throw new ArgumentNullException(nameof(kb)); _kb = kb ?? throw new ArgumentNullException(nameof(kb));
...@@ -162,6 +182,7 @@ namespace Tools.MyAgent.Agent ...@@ -162,6 +182,7 @@ namespace Tools.MyAgent.Agent
? Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Agent") ? Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Agent")
: Path.GetFullPath(agentRoot); : Path.GetFullPath(agentRoot);
_dialect = new DialectService(root); _dialect = new DialectService(root);
_prompts.DiagnosticRoots = DiagnosticRoots;
SetProductContext(productContext); SetProductContext(productContext);
} }
...@@ -171,6 +192,7 @@ namespace Tools.MyAgent.Agent ...@@ -171,6 +192,7 @@ namespace Tools.MyAgent.Agent
if (IsRunning) throw new InvalidOperationException("回答进行中不能切换产品上下文。请先取消当前回答。"); if (IsRunning) throw new InvalidOperationException("回答进行中不能切换产品上下文。请先取消当前回答。");
_productContext = ProductContextValidator.CreateSnapshot(context); _productContext = ProductContextValidator.CreateSnapshot(context);
_prompts.ProductContext = _productContext?.Clone(); _prompts.ProductContext = _productContext?.Clone();
_prompts.DiagnosticRoots = DiagnosticRoots;
_kb.SetProductContext(_productContext); _kb.SetProductContext(_productContext);
_prompts.Dialect = _dialect; _prompts.Dialect = _dialect;
} }
...@@ -253,6 +275,7 @@ namespace Tools.MyAgent.Agent ...@@ -253,6 +275,7 @@ namespace Tools.MyAgent.Agent
_continueRounds = 0; _continueRounds = 0;
_contextNotice = ""; _contextNotice = "";
_runGeneration++; _runGeneration++;
_diagnosticSessionId = Guid.NewGuid().ToString("N");
var messages = await BuildMessagesAsync(history, _cts.Token).ConfigureAwait(false); var messages = await BuildMessagesAsync(history, _cts.Token).ConfigureAwait(false);
var tools = BuildTools(); var tools = BuildTools();
var stopwatch = Stopwatch.StartNew(); var stopwatch = Stopwatch.StartNew();
...@@ -413,7 +436,8 @@ namespace Tools.MyAgent.Agent ...@@ -413,7 +436,8 @@ namespace Tools.MyAgent.Agent
result.PromptTokens, result.CompletionTokens, result.PromptTokens, result.CompletionTokens,
(long)(DateTime.UtcNow - start).TotalMilliseconds, (long)(DateTime.UtcNow - start).TotalMilliseconds,
string.Join(";", result.ToolCalls.Select(tc => tc.Name + "(" + Clip(tc.Arguments, 40) + ")")), string.Join(";", result.ToolCalls.Select(tc => tc.Name + "(" + Clip(tc.Arguments, 40) + ")")),
result.FinishReason); result.FinishReason,
_diagnosticSessionId);
return result; return result;
} }
...@@ -969,11 +993,13 @@ namespace Tools.MyAgent.Agent ...@@ -969,11 +993,13 @@ namespace Tools.MyAgent.Agent
CurrentUser = CurrentUser, CurrentUser = CurrentUser,
Dialect = _dialect, Dialect = _dialect,
ProductContext = _productContext?.Clone(), ProductContext = _productContext?.Clone(),
DiagnosticRoots = DiagnosticRoots,
DiagnosticSessionId = _diagnosticSessionId,
}; };
string result = tool.Execute(args, ctx); string result = tool.Execute(args, ctx);
sw.Stop(); sw.Stop();
// #29 工具日志 // #29 工具日志
AgentLogger.LogTool(tc.Name, desc, sw.ElapsedMilliseconds, SummarizeToolResult(result)); AgentLogger.LogTool(tc.Name, desc, sw.ElapsedMilliseconds, SummarizeToolResult(result), _diagnosticSessionId);
// #42 结果压缩:超长结果压缩后回填模型,完整结果落盘供溯源 // #42 结果压缩:超长结果压缩后回填模型,完整结果落盘供溯源
result = CompressToolResult(result, tc.Name) ?? ""; result = CompressToolResult(result, tc.Name) ?? "";
if (gen == System.Threading.Volatile.Read(ref _runGeneration)) if (gen == System.Threading.Volatile.Read(ref _runGeneration))
......
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Collections.Generic;
using System.Text; using System.Text;
using Tools.MyAgent.Diagnostics;
using Tools.MyAgent.Kb; using Tools.MyAgent.Kb;
namespace Tools.MyAgent.Agent namespace Tools.MyAgent.Agent
...@@ -24,6 +26,9 @@ namespace Tools.MyAgent.Agent ...@@ -24,6 +26,9 @@ namespace Tools.MyAgent.Agent
/// <summary>宿主绑定的产品上下文;由运行时注入,不接受聊天内容修改</summary> /// <summary>宿主绑定的产品上下文;由运行时注入,不接受聊天内容修改</summary>
public AgentProductContext ProductContext { get; set; } public AgentProductContext ProductContext { get; set; }
/// <summary>宿主注入的诊断根目录;只用于显示根目录 ID,不显示完整机器路径。</summary>
public IReadOnlyList<DiagnosticRoot> DiagnosticRoots { get; set; }
public string BuildSystemMessage(KbService kb) public string BuildSystemMessage(KbService kb)
{ {
// B2 提示词外置:静态层优先读 输出目录/prompts/*.md 模板(用户可定制), // B2 提示词外置:静态层优先读 输出目录/prompts/*.md 模板(用户可定制),
...@@ -39,6 +44,8 @@ namespace Tools.MyAgent.Agent ...@@ -39,6 +44,8 @@ namespace Tools.MyAgent.Agent
: BuildProductLayer(); : BuildProductLayer();
sb.AppendLine(productLayer); sb.AppendLine(productLayer);
sb.AppendLine(); sb.AppendLine();
sb.AppendLine(BuildDiagnosticLayer());
sb.AppendLine();
sb.AppendLine(BuildKbLayer(kb, ProductContext)); sb.AppendLine(BuildKbLayer(kb, ProductContext));
sb.AppendLine(); sb.AppendLine();
sb.AppendLine(BuildDialectLayer()); sb.AppendLine(BuildDialectLayer());
...@@ -56,6 +63,26 @@ namespace Tools.MyAgent.Agent ...@@ -56,6 +63,26 @@ namespace Tools.MyAgent.Agent
return sb.ToString(); return sb.ToString();
} }
private string BuildDiagnosticLayer()
{
var sb = new StringBuilder();
sb.AppendLine("【现场问题排查】");
sb.AppendLine("- 涉及报错、启动失败、运行异常、配置或 DLL 问题时,优先使用 log_search 获取带时间戳、行号和上下文的证据。");
sb.AppendLine("- 日志证据不足时使用 config_read 核对配置,再使用 assembly_manifest 核对 DLL 版本、架构、目标框架和引用。");
sb.AppendLine("- 多类证据需要关联时使用 diagnose_incident;不得仅凭单条异常直接认定根因。");
sb.AppendLine("- 诊断工具只允许使用宿主注入的 rootId 和根目录内相对路径,不得要求或猜测任意绝对路径。");
sb.AppendLine("- 回答必须分为:已确认事实、证据、最可能根因、待验证项、最小验证步骤、修复与回滚建议。");
var roots = DiagnosticRoots ?? ProductContext?.DiagnosticRoots;
if (roots != null && roots.Count > 0)
{
sb.AppendLine("可用诊断根目录(仅显示标识,不暴露机器完整路径):");
foreach (var root in roots)
sb.AppendLine($"- {Sanitize(root.Id)}({Sanitize(root.Kind)})");
}
else sb.AppendLine("- 当前未注入日志/配置/部署诊断根目录;只能使用已配置的知识库或通用回答。");
return sb.ToString().TrimEnd();
}
/// <summary>加载提示词模板:存在且非空 → 用文件内容;缺失 → 生成默认模板;异常 → 内置回退</summary> /// <summary>加载提示词模板:存在且非空 → 用文件内容;缺失 → 生成默认模板;异常 → 内置回退</summary>
private static string LoadLayer(string promptsDir, string fileName, string fallback) private static string LoadLayer(string promptsDir, string fileName, string fallback)
{ {
......
using Newtonsoft.Json.Linq;
using System;
using System.Linq;
using Tools.MyAgent.Diagnostics;
namespace Tools.MyAgent.Agent.Tools
{
/// <summary>诊断工具适配器:所有路径均由 ToolContext 的诊断根目录策略解析。</summary>
internal static class DiagnosticToolHelper
{
public static bool Resolve(ToolContext ctx, string rootId, string path, out DiagnosticRoot root,
out string full, out string display, out string error)
{
return DiagnosticRootPolicy.TryResolve(ctx?.DiagnosticRoots, rootId, path, out root, out full, out display, out error);
}
}
internal sealed class LogDiscoverTool : ITool
{
public string Name => "log_discover";
public string Description => "在宿主注入的日志诊断根目录中发现日志文件,支持 glob 文件名筛选;不会访问任意绝对路径。";
public string ParametersJson => @"{'type':'object','properties':{
'rootId':{'type':'string','description':'诊断根目录 ID,通常为 logs'},
'pattern':{'type':'string','description':'文件名 glob,如 *.log 或 *20260910*'},
'maxResults':{'type':'integer','minimum':1,'maximum':100}}
,'required':['rootId']}";
public bool RequiresKb => false;
public bool IsDefault => true;
public string Describe(JObject args) => "发现日志:" + args["rootId"]?.Value<string>();
public string Execute(JObject args, ToolContext ctx)
{
return LogAnalyzer.Discover(ctx.DiagnosticRoots, args["rootId"]?.Value<string>(), args["pattern"]?.Value<string>(),
Clamp(args["maxResults"]?.Value<int>() ?? 30, 1, 100), ctx.CancelToken).ToJson();
}
private static int Clamp(int value, int min, int max) => Math.Max(min, Math.Min(max, value));
}
internal sealed class LogSearchTool : ITool
{
public string Name => "log_search";
public string Description => "在宿主注入的日志目录中按关键词或正则搜索,返回异常上下文、时间戳、文件相对路径、行号、签名和证据哈希。";
public string ParametersJson => @"{'type':'object','properties':{
'rootId':{'type':'string','description':'日志诊断根目录 ID,通常为 logs'},
'query':{'type':'string','description':'关键词或正则表达式'},
'regex':{'type':'boolean','description':'是否按正则匹配,默认 false'},
'from':{'type':'string','description':'起始时间,可为空'},
'to':{'type':'string','description':'结束时间,可为空'},
'contextLines':{'type':'integer','minimum':0,'maximum':20},
'maxResults':{'type':'integer','minimum':1,'maximum':100}}
,'required':['rootId','query']}";
public bool RequiresKb => false;
public bool IsDefault => true;
public string Describe(JObject args) => "日志检索:" + args["query"]?.Value<string>();
public string Execute(JObject args, ToolContext ctx)
{
return LogAnalyzer.Search(ctx.DiagnosticRoots, args["rootId"]?.Value<string>(), args["query"]?.Value<string>(),
args["regex"]?.Value<bool>() ?? false, args["from"]?.Value<string>(), args["to"]?.Value<string>(),
Clamp(args["contextLines"]?.Value<int>() ?? 3, 0, 20), Clamp(args["maxResults"]?.Value<int>() ?? 20, 1, 100), ctx.CancelToken).ToJson();
}
private static int Clamp(int value, int min, int max) => Math.Max(min, Math.Min(max, value));
}
internal sealed class ConfigReadTool : ITool
{
public string Name => "config_read";
public string Description => "读取宿主注入配置根目录内的 JSON/XML/INI/.config 文件,结构化格式化并在返回模型前脱敏密码、Token、连接串等敏感字段。";
public string ParametersJson => @"{'type':'object','properties':{
'rootId':{'type':'string','description':'配置诊断根目录 ID,通常为 config'},
'path':{'type':'string','description':'配置根目录内的相对路径'},
'maxChars':{'type':'integer','minimum':1000,'maximum':12000}}
,'required':['rootId','path']}";
public bool RequiresKb => false;
public bool IsDefault => true;
public string Describe(JObject args) => "读取配置:" + args["path"]?.Value<string>();
public string Execute(JObject args, ToolContext ctx)
{
DiagnosticRoot root; string full, display, error;
if (!DiagnosticToolHelper.Resolve(ctx, args["rootId"]?.Value<string>(), args["path"]?.Value<string>(), out root, out full, out display, out error))
return new DiagnosticResult { Status = "error", Warnings = { error } }.ToJson();
return ConfigAnalyzer.Read(full, display, args["maxChars"]?.Value<int>() ?? 8000).ToJson();
}
}
internal sealed class AssemblyManifestTool : ITool
{
public string Name => "assembly_manifest";
public string Description => "只读分析宿主注入部署或程序集根目录中的 DLL/EXE 元数据、版本、架构、目标框架、引用和公共类型;不执行目标程序集业务代码。";
public string ParametersJson => @"{'type':'object','properties':{
'rootId':{'type':'string','description':'程序集诊断根目录 ID,通常为 deployment'},
'path':{'type':'string','description':'程序集根目录内的相对 DLL/EXE 路径'},
'includeTypes':{'type':'boolean','description':'是否读取公共类型,默认 false'}}
,'required':['rootId','path']}";
public bool RequiresKb => false;
public bool IsDefault => true;
public string Describe(JObject args) => "程序集分析:" + args["path"]?.Value<string>();
public string Execute(JObject args, ToolContext ctx)
{
DiagnosticRoot root; string full, display, error;
if (!DiagnosticToolHelper.Resolve(ctx, args["rootId"]?.Value<string>(), args["path"]?.Value<string>(), out root, out full, out display, out error))
return new DiagnosticResult { Status = "error", Warnings = { error } }.ToJson();
return AssemblyInspector.Inspect(full, display, args["includeTypes"]?.Value<bool>() ?? false).ToJson();
}
}
internal sealed class DiagnoseIncidentTool : ITool
{
public string Name => "diagnose_incident";
public string Description => "固定编排日志、配置和程序集证据的现场问题排查入口。先搜索日志,再读取配置,最后核对日志提到的 DLL;只使用宿主注入的诊断根目录。";
public string ParametersJson => @"{'type':'object','properties':{
'logRootId':{'type':'string','description':'日志根目录 ID,通常为 logs'},
'configRootId':{'type':'string','description':'配置根目录 ID,通常为 config'},
'assemblyRootId':{'type':'string','description':'程序集根目录 ID,通常为 deployment'},
'query':{'type':'string','description':'现象关键词或正则,默认搜索 Exception|Error|失败|错误'},
'configPath':{'type':'string','description':'可选配置文件相对路径'},
'assemblyPath':{'type':'string','description':'可选程序集相对路径'},
'assemblyName':{'type':'string','description':'可选 DLL 名称'},
'maxResults':{'type':'integer','minimum':1,'maximum':50}}
,'required':['logRootId']}";
public bool RequiresKb => false;
public bool IsDefault => false;
public string Describe(JObject args) => "综合排查:" + (args["query"]?.Value<string>() ?? "默认异常关键词");
public string Execute(JObject args, ToolContext ctx)
{
return DiagnosticOrchestrator.Run(ctx.DiagnosticRoots, args["logRootId"]?.Value<string>(), args["configRootId"]?.Value<string>(),
args["assemblyRootId"]?.Value<string>(), args["query"]?.Value<string>(), args["configPath"]?.Value<string>(),
args["assemblyPath"]?.Value<string>(), args["assemblyName"]?.Value<string>(), args["maxResults"]?.Value<int>() ?? 30,
ctx.CancelToken).ToJson();
}
}
}
...@@ -3,6 +3,7 @@ using System; ...@@ -3,6 +3,7 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using Tools.MyAgent.Diagnostics;
using Tools.MyAgent.Kb; using Tools.MyAgent.Kb;
namespace Tools.MyAgent.Agent.Tools namespace Tools.MyAgent.Agent.Tools
...@@ -35,6 +36,12 @@ namespace Tools.MyAgent.Agent.Tools ...@@ -35,6 +36,12 @@ namespace Tools.MyAgent.Agent.Tools
/// <summary>宿主绑定的产品上下文,用于限制 d_* 工具检索范围</summary> /// <summary>宿主绑定的产品上下文,用于限制 d_* 工具检索范围</summary>
public AgentProductContext ProductContext { get; set; } public AgentProductContext ProductContext { get; set; }
/// <summary>宿主注入的诊断根目录;工具只能以 rootId + 相对路径访问。</summary>
public IReadOnlyList<DiagnosticRoot> DiagnosticRoots { get; set; }
/// <summary>诊断会话标识,用于将日志、工具调用和证据关联到同一次排查。</summary>
public string DiagnosticSessionId { get; set; } = "";
public ToolContext(KbService kb, Action<string> onFileProduced) public ToolContext(KbService kb, Action<string> onFileProduced)
{ {
Kb = kb; Kb = kb;
...@@ -172,6 +179,12 @@ namespace Tools.MyAgent.Agent.Tools ...@@ -172,6 +179,12 @@ namespace Tools.MyAgent.Agent.Tools
Register(new DialectListTool()); Register(new DialectListTool());
Register(new DialectGrepTool()); Register(new DialectGrepTool());
Register(new DialectReadTool()); Register(new DialectReadTool());
// 现场问题诊断:日志、配置和程序集证据链
Register(new LogDiscoverTool());
Register(new LogSearchTool());
Register(new ConfigReadTool());
Register(new AssemblyManifestTool());
Register(new DiagnoseIncidentTool());
} }
/// <summary>全部已注册工具(下发顺序)</summary> /// <summary>全部已注册工具(下发顺序)</summary>
......
using System; using System;
using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq;
using Tools.MyAgent.Agent; using Tools.MyAgent.Agent;
using Tools.MyAgent.Diagnostics;
using Tools.MyAgent.Kb; using Tools.MyAgent.Kb;
namespace Tools.MyAgent namespace Tools.MyAgent
...@@ -14,6 +17,9 @@ namespace Tools.MyAgent ...@@ -14,6 +17,9 @@ namespace Tools.MyAgent
/// <summary>当前用户或角色,用于聊天展示、工具权限和数据库审计。</summary> /// <summary>当前用户或角色,用于聊天展示、工具权限和数据库审计。</summary>
public string CurrentUser { get; set; } = "访客"; public string CurrentUser { get; set; } = "访客";
/// <summary>宿主注入的诊断根目录;为空时由产品上下文 ProjectRoot 自动生成 source 根。</summary>
public IReadOnlyList<DiagnosticRoot> DiagnosticRoots { get; set; }
/// <summary>宿主固定的产品上下文;空值表示未绑定产品线。</summary> /// <summary>宿主固定的产品上下文;空值表示未绑定产品线。</summary>
public AgentProductContext ProductContext { get; set; } public AgentProductContext ProductContext { get; set; }
} }
...@@ -23,6 +29,17 @@ namespace Tools.MyAgent ...@@ -23,6 +29,17 @@ namespace Tools.MyAgent
{ {
private readonly AgentRuntimeOptions _options; private readonly AgentRuntimeOptions _options;
private readonly AgentProductContext _productContext; private readonly AgentProductContext _productContext;
private readonly IReadOnlyList<DiagnosticRoot> _diagnosticRoots;
/// <summary>当前运行时诊断根目录快照;返回独立副本,避免外部修改。</summary>
public IReadOnlyList<DiagnosticRoot> DiagnosticRoots
{
get
{
var roots = _diagnosticRoots.Count > 0 ? _diagnosticRoots : (_productContext?.DiagnosticRoots ?? new DiagnosticRoot[0]);
return roots.Select(x => x.Clone()).ToArray();
}
}
/// <summary>当前宿主绑定的产品上下文;返回副本,避免运行期间被外部对象修改。</summary> /// <summary>当前宿主绑定的产品上下文;返回副本,避免运行期间被外部对象修改。</summary>
public AgentProductContext ProductContext => _productContext?.Clone(); public AgentProductContext ProductContext => _productContext?.Clone();
...@@ -33,7 +50,17 @@ namespace Tools.MyAgent ...@@ -33,7 +50,17 @@ namespace Tools.MyAgent
public AgentRuntime(AgentRuntimeOptions options = null) public AgentRuntime(AgentRuntimeOptions options = null)
{ {
_options = options ?? new AgentRuntimeOptions(); _options = options ?? new AgentRuntimeOptions();
_productContext = ProductContextValidator.CreateSnapshot(_options.ProductContext); _diagnosticRoots = DiagnosticRootPolicy.Normalize(_options.DiagnosticRoots);
var context = _options.ProductContext?.Clone();
if (context != null)
{
if (_options.DiagnosticRoots != null)
context.DiagnosticRoots = _options.DiagnosticRoots.Where(x => x != null).Select(x => x.Clone()).ToArray();
else if ((context.DiagnosticRoots == null || context.DiagnosticRoots.Length == 0)
&& !string.IsNullOrEmpty(context.ProjectRoot))
context.DiagnosticRoots = new[] { new DiagnosticRoot { Id = "source", Path = context.ProjectRoot, Kind = "source" } };
}
_productContext = ProductContextValidator.CreateSnapshot(context);
} }
/// <summary>实际数据根目录。</summary> /// <summary>实际数据根目录。</summary>
...@@ -45,7 +72,7 @@ namespace Tools.MyAgent ...@@ -45,7 +72,7 @@ namespace Tools.MyAgent
public wMyAgent CreateChatForm() public wMyAgent CreateChatForm()
{ {
EnsureDefaultResources(); EnsureDefaultResources();
return new wMyAgent(DataRoot, _options.CurrentUser, ProductContext); return new wMyAgent(DataRoot, _options.CurrentUser, ProductContext, _diagnosticRoots);
} }
/// <summary>创建已配置输出目录的知识库服务,供无 UI 宿主自行设置知识库根目录。</summary> /// <summary>创建已配置输出目录的知识库服务,供无 UI 宿主自行设置知识库根目录。</summary>
...@@ -69,7 +96,7 @@ namespace Tools.MyAgent ...@@ -69,7 +96,7 @@ namespace Tools.MyAgent
{ {
if (knowledgeBase == null) throw new ArgumentNullException(nameof(knowledgeBase)); if (knowledgeBase == null) throw new ArgumentNullException(nameof(knowledgeBase));
EnsureDefaultResources(); EnsureDefaultResources();
return new AgentEngine(knowledgeBase, DataRoot, ProductContext) { CurrentUser = _options.CurrentUser ?? "访客" }; return new AgentEngine(knowledgeBase, DataRoot, ProductContext, _diagnosticRoots) { CurrentUser = _options.CurrentUser ?? "访客" };
} }
private void EnsureDefaultResources() private void EnsureDefaultResources()
......
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Runtime.InteropServices;
namespace Tools.MyAgent.Diagnostics
{
/// <summary>只读程序集元数据检查。仅使用 ReflectionOnlyLoad,不执行目标程序集代码。</summary>
public static class AssemblyInspector
{
private const long MaxAssemblyFileSize = 256L * 1024 * 1024;
public static DiagnosticResult Inspect(string path, string source, bool includeTypes)
{
var result = new DiagnosticResult();
if (!File.Exists(path))
{
result.Status = "error";
result.Warnings.Add("程序集文件不存在:" + source);
return result;
}
try
{
if (new FileInfo(path).Length > MaxAssemblyFileSize)
{
result.Status = "error";
result.Warnings.Add("程序集文件超过读取上限(256 MB):" + source);
return result;
}
}
catch (Exception ex)
{
result.Status = "error";
result.Warnings.Add("无法读取程序集文件属性:" + DiagnosticSanitizer.Clip(ex.Message, 160));
return result;
}
var info = new AssemblyInspection { Source = source };
try
{
var fileInfo = new FileInfo(path);
info.FileSize = fileInfo.Length;
info.Sha256 = HashFile(path);
var versionInfo = FileVersionInfo.GetVersionInfo(path);
info.FileVersion = versionInfo.FileVersion ?? "";
info.ProductVersion = versionInfo.ProductVersion ?? "";
}
catch (Exception ex)
{
result.Status = "error";
result.Warnings.Add("程序集文件读取失败:" + DiagnosticSanitizer.Clip(ex.Message, 200));
result.Evidence.Add(new DiagnosticEvidence
{
EvidenceId = "assembly-0001",
Kind = "assembly",
Source = source,
Summary = "程序集文件无法读取。",
Confidence = "unconfirmed"
});
return result;
}
try
{
var name = AssemblyName.GetAssemblyName(path);
info.AssemblyFullName = name.FullName;
info.AssemblyVersion = name.Version?.ToString() ?? "";
info.Culture = name.CultureInfo?.Name ?? "";
Assembly assembly = null;
ResolveEventHandler resolver = (sender, args) => ResolveReflectionAssembly(args, path);
try
{
AppDomain.CurrentDomain.ReflectionOnlyAssemblyResolve += resolver;
assembly = Assembly.ReflectionOnlyLoadFrom(path);
info.RuntimeVersion = assembly.ImageRuntimeVersion ?? "";
PortableExecutableKinds peKind;
ImageFileMachine machine;
assembly.ManifestModule.GetPEKind(out peKind, out machine);
info.Platform = PlatformText(peKind, machine);
info.TargetFramework = TargetFramework(assembly);
foreach (var reference in assembly.GetReferencedAssemblies().OrderBy(x => x.Name, StringComparer.OrdinalIgnoreCase))
info.References.Add(reference.FullName);
if (includeTypes)
{
try
{
foreach (Type type in assembly.GetExportedTypes().Take(200))
info.PublicTypes.Add(type.FullName ?? type.Name);
}
catch (Exception ex) { result.Warnings.Add("公共类型读取不完整:" + DiagnosticSanitizer.Clip(ex.Message, 160)); }
}
}
finally { AppDomain.CurrentDomain.ReflectionOnlyAssemblyResolve -= resolver; }
}
catch (BadImageFormatException) { result.Warnings.Add("文件不是当前环境可读取的 .NET 托管程序集,可能是原生 DLL。"); }
catch (FileLoadException ex) { result.Warnings.Add("程序集元数据加载失败:" + DiagnosticSanitizer.Clip(ex.Message, 200)); }
catch (Exception ex) { result.Warnings.Add("程序集分析失败:" + DiagnosticSanitizer.Clip(ex.Message, 200)); }
result.Evidence.Add(new DiagnosticEvidence
{
EvidenceId = "assembly-0001",
Kind = "assembly",
Source = source,
Summary = info.AssemblyFullName.Length == 0 ? "已读取文件级程序集信息。" : "已读取程序集元数据和引用列表。",
Content = Newtonsoft.Json.JsonConvert.SerializeObject(info, Newtonsoft.Json.Formatting.Indented),
Hash = info.Sha256,
Confidence = "confirmed"
});
return result;
}
private static Assembly ResolveReflectionAssembly(ResolveEventArgs args, string inspectedPath)
{
try
{
string directory = Path.GetDirectoryName(inspectedPath);
string name = new AssemblyName(args.Name).Name + ".dll";
string candidate = Path.Combine(directory ?? "", name);
return File.Exists(candidate) ? Assembly.ReflectionOnlyLoadFrom(candidate) : null;
}
catch { return null; }
}
private static string TargetFramework(Assembly assembly)
{
try
{
var attr = assembly.GetCustomAttributesData().FirstOrDefault(x =>
x.Constructor.DeclaringType != null &&
x.Constructor.DeclaringType.FullName == "System.Runtime.Versioning.TargetFrameworkAttribute");
return attr == null || attr.ConstructorArguments.Count == 0 ? "" : attr.ConstructorArguments[0].Value?.ToString() ?? "";
}
catch { return ""; }
}
private static string PlatformText(PortableExecutableKinds kind, ImageFileMachine machine)
{
if ((kind & PortableExecutableKinds.Required32Bit) != 0) return "x86";
if (machine == ImageFileMachine.AMD64) return "x64";
if (machine == ImageFileMachine.IA64) return "Itanium";
return (kind & PortableExecutableKinds.PE32Plus) != 0 ? "x64/AnyCPU" : "AnyCPU/x86-compatible";
}
private static string HashFile(string path)
{
using (var sha = System.Security.Cryptography.SHA256.Create())
using (var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
{
byte[] hash = sha.ComputeHash(stream);
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
}
}
[Serializable]
private sealed class AssemblyInspection
{
public string Source { get; set; } = "";
public long FileSize { get; set; }
public string Sha256 { get; set; } = "";
public string FileVersion { get; set; } = "";
public string ProductVersion { get; set; } = "";
public string AssemblyFullName { get; set; } = "";
public string AssemblyVersion { get; set; } = "";
public string Culture { get; set; } = "";
public string RuntimeVersion { get; set; } = "";
public string TargetFramework { get; set; } = "";
public string Platform { get; set; } = "";
public List<string> References { get; set; } = new List<string>();
public List<string> PublicTypes { get; set; } = new List<string>();
}
}
}
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Xml;
using Tools.MyAgent.Support;
namespace Tools.MyAgent.Diagnostics
{
/// <summary>JSON/XML/INI 配置读取、脱敏和基础绑定信息提取。</summary>
public static class ConfigAnalyzer
{
private const int MaxOutputChars = 12000;
private const long MaxConfigFileSize = 16L * 1024 * 1024;
public static DiagnosticResult Read(string path, string source, int maxChars)
{
var result = new DiagnosticResult();
if (!File.Exists(path))
{
result.Status = "error";
result.Warnings.Add("配置文件不存在:" + source);
return result;
}
try
{
if (new FileInfo(path).Length > MaxConfigFileSize)
{
result.Status = "error";
result.Warnings.Add("配置文件超过读取上限(16 MB):" + source);
return result;
}
}
catch (Exception ex)
{
result.Status = "error";
result.Warnings.Add("无法读取配置文件属性:" + DiagnosticSanitizer.Clip(ex.Message, 160));
return result;
}
string text = TextEncoding.ReadAllTextAuto(path);
if (text == null)
{
result.Status = "error";
result.Warnings.Add("配置文件无法读取:" + source);
return result;
}
string formatted;
string kind = Path.GetExtension(path).ToLowerInvariant();
try
{
if (kind == ".json") formatted = RedactJson(JToken.Parse(text)).ToString(Newtonsoft.Json.Formatting.Indented);
else if (kind == ".xml" || kind == ".config") formatted = RedactXml(text, result.Warnings);
else formatted = DiagnosticSanitizer.Mask(text);
}
catch (Exception ex)
{
result.Warnings.Add("配置结构解析失败,已按文本读取:" + DiagnosticSanitizer.Clip(ex.Message, 200));
formatted = DiagnosticSanitizer.Mask(text);
}
maxChars = Math.Max(1000, Math.Min(MaxOutputChars, maxChars));
result.Evidence.Add(new DiagnosticEvidence
{
EvidenceId = "config-0001",
Kind = "config",
Source = source,
Summary = "已读取并完成敏感字段脱敏的配置内容。",
Content = DiagnosticSanitizer.Clip(formatted, maxChars),
Hash = DiagnosticSanitizer.Hash(formatted),
Confidence = "confirmed"
});
if (formatted.Length > maxChars) result.Warnings.Add("配置内容过长,返回结果已截断。");
return result;
}
public static IEnumerable<string> FindBindingSummaries(string path)
{
return FindBindingSummaries(LoadXmlFile(path));
}
private static IEnumerable<string> FindBindingSummaries(XmlDocument doc)
{
if (doc == null) yield break;
foreach (XmlNode node in doc.SelectNodes("//*[local-name()='dependentAssembly']"))
{
string name = AttributeOrChild(node, "name", "assemblyIdentity");
string oldVersion = AttributeOrChild(node, "oldVersion", "bindingRedirect");
string newVersion = AttributeOrChild(node, "newVersion", "bindingRedirect");
yield return string.Format("{0}: {1} -> {2}", name, oldVersion, newVersion);
}
}
private static JToken RedactJson(JToken token)
{
if (token is JObject obj)
{
var copy = new JObject();
foreach (var property in obj.Properties())
copy[property.Name] = IsSensitive(property.Name)
? JValue.CreateString(RedactValue(property.Name, property.Value?.ToString()))
: RedactJson(property.Value);
return copy;
}
if (token is JArray array) return new JArray(array.Select(RedactJson));
return token?.DeepClone() ?? JValue.CreateNull();
}
private static string RedactXml(string text, List<string> warnings)
{
var doc = LoadXmlText(text);
if (doc == null) return DiagnosticSanitizer.Mask(text);
RedactXmlNode(doc.DocumentElement);
var settings = new XmlWriterSettings { Indent = true, OmitXmlDeclaration = false, Encoding = new UTF8Encoding(false) };
var sb = new StringBuilder();
using (var writer = XmlWriter.Create(sb, settings)) doc.Save(writer);
foreach (string binding in FindBindingSummaries(doc)) warnings.Add("程序集绑定:" + binding);
return sb.ToString();
}
private static XmlDocument LoadXmlFile(string path)
{
try { return LoadXmlText(TextEncoding.ReadAllTextAuto(path)); } catch { return null; }
}
private static XmlDocument LoadXmlText(string text)
{
if (string.IsNullOrWhiteSpace(text)) return null;
try
{
var settings = new XmlReaderSettings { DtdProcessing = DtdProcessing.Prohibit, XmlResolver = null, MaxCharactersInDocument = 4 * 1024 * 1024 };
var doc = new XmlDocument { XmlResolver = null };
using (var reader = XmlReader.Create(new StringReader(text), settings)) doc.Load(reader);
return doc;
}
catch { return null; }
}
private static void RedactXmlNode(XmlNode node)
{
if (node == null) return;
bool sensitiveContext = IsSensitive(node.Name)
|| (node.Attributes?["name"] != null && IsSensitive(node.Attributes["name"].Value))
|| (node.Attributes?["key"] != null && IsSensitive(node.Attributes["key"].Value));
if (node.Attributes != null)
foreach (XmlAttribute attr in node.Attributes)
if (IsSensitive(attr.Name)) attr.Value = RedactValue(attr.Name, attr.Value);
else if (sensitiveContext && string.Equals(attr.Name, "value", StringComparison.OrdinalIgnoreCase)) attr.Value = "***";
if (sensitiveContext)
{
while (node.HasChildNodes) node.RemoveChild(node.FirstChild);
node.InnerText = "***";
return;
}
foreach (XmlNode child in node.ChildNodes) RedactXmlNode(child);
}
private static string RedactValue(string name, string value)
{
return string.Equals(name, "connectionString", StringComparison.OrdinalIgnoreCase)
? DiagnosticSanitizer.Mask(value ?? "")
: "***";
}
private static string AttributeOrChild(XmlNode parent, string attribute, string childName)
{
var child = parent.SelectSingleNode("*[local-name()='" + childName + "']");
return child?.Attributes?[attribute]?.Value ?? "";
}
private static bool IsSensitive(string name)
{
string value = (name ?? "").ToLowerInvariant();
return value.Contains("password") || value == "pwd" || value == "passwd"
|| value.Contains("token") || value.Contains("apikey") || value.Contains("api-key")
|| value.Contains("secret") || value == "connectionstring";
}
}
}
using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
namespace Tools.MyAgent.Diagnostics
{
/// <summary>进入模型前使用的诊断证据。</summary>
public sealed class DiagnosticEvidence
{
public string EvidenceId { get; set; } = "";
public string Kind { get; set; } = "";
public string Source { get; set; } = "";
public int? LineStart { get; set; }
public int? LineEnd { get; set; }
public string Timestamp { get; set; } = "";
public string Signature { get; set; } = "";
public string Summary { get; set; } = "";
public string Content { get; set; } = "";
public string Hash { get; set; } = "";
public string Confidence { get; set; } = "confirmed";
}
/// <summary>诊断工具统一返回结构。</summary>
public sealed class DiagnosticResult
{
public string Status { get; set; } = "ok";
public List<DiagnosticEvidence> Evidence { get; set; } = new List<DiagnosticEvidence>();
public List<string> Warnings { get; set; } = new List<string>();
public List<string> NextActions { get; set; } = new List<string>();
public string ToJson()
{
return JsonConvert.SerializeObject(this, Formatting.Indented);
}
}
/// <summary>诊断文本脱敏,避免密码、令牌和连接串进入模型上下文。</summary>
public static class DiagnosticSanitizer
{
private static readonly Regex Secret = new Regex(
@"(?i)(password|pwd|passwd|token|apikey|api[_-]?key|secret)(\s*[:=]\s*|\s+)\S+",
RegexOptions.Compiled);
private static readonly Regex Bearer = new Regex(@"(?i)Bearer\s+\S+", RegexOptions.Compiled);
private static readonly Regex ConnectionPassword = new Regex(@"(?i)(Password|Pwd)=([^;\r\n]+)", RegexOptions.Compiled);
public static string Mask(string value)
{
if (string.IsNullOrEmpty(value)) return value;
string result = Secret.Replace(value, "$1$2***");
result = Bearer.Replace(result, "Bearer ***");
result = ConnectionPassword.Replace(result, "$1=***");
return result;
}
public static string Hash(string value)
{
using (var sha = SHA256.Create())
{
byte[] bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(value ?? ""));
var sb = new StringBuilder(bytes.Length * 2);
foreach (byte b in bytes) sb.Append(b.ToString("x2"));
return sb.ToString();
}
}
public static string Clip(string value, int max)
{
value = Mask(value ?? "");
return value.Length <= max ? value : value.Substring(0, max) + "…";
}
}
}
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
namespace Tools.MyAgent.Diagnostics
{
/// <summary>固定顺序编排日志、配置和程序集证据,避免完全依赖模型自由规划。</summary>
public static class DiagnosticOrchestrator
{
private static readonly Regex DllName = new Regex(@"([A-Za-z0-9_.-]+\.dll)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
public static DiagnosticResult Run(IEnumerable<DiagnosticRoot> roots, string logRootId, string configRootId,
string assemblyRootId, string query, string configPath, string assemblyPath, string assemblyName,
int maxResults, System.Threading.CancellationToken ct)
{
var result = new DiagnosticResult();
maxResults = Math.Max(1, Math.Min(50, maxResults));
if (!string.IsNullOrWhiteSpace(logRootId))
{
var logs = LogAnalyzer.Search(roots, logRootId, string.IsNullOrWhiteSpace(query) ? "Exception|Error|失败|错误" : query,
true, "", "", 3, maxResults, ct);
Add(result, logs, "log");
}
if (!string.IsNullOrWhiteSpace(configRootId))
{
DiagnosticRoot root;
string full, display, error;
if (DiagnosticRootPolicy.TryResolve(roots, configRootId, "", out root, out full, out display, out error)
&& Directory.Exists(full))
{
var files = string.IsNullOrWhiteSpace(configPath)
? EnumerateFiles(full, ct).Where(IsConfig).Take(3).ToList()
: ResolveOne(roots, configRootId, configPath, out error);
if (!string.IsNullOrWhiteSpace(configPath) && files.Count == 0 && !string.IsNullOrWhiteSpace(error))
result.Warnings.Add(error);
foreach (string file in files)
{
string rel = Relative(root.Path, file);
Add(result, ConfigAnalyzer.Read(file, configRootId + "/" + rel, 8000), "config");
}
}
else result.Warnings.Add(string.IsNullOrWhiteSpace(error)
? "配置诊断根目录不存在或不可访问:" + configRootId
: error);
}
var names = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
if (!string.IsNullOrWhiteSpace(assemblyName)) names.Add(Path.GetFileName(assemblyName));
foreach (var evidence in result.Evidence)
foreach (Match match in DllName.Matches(evidence.Content + " " + evidence.Summary)) names.Add(match.Groups[1].Value);
if (!string.IsNullOrWhiteSpace(assemblyRootId))
{
DiagnosticRoot root;
string full, display, error;
if (DiagnosticRootPolicy.TryResolve(roots, assemblyRootId, "", out root, out full, out display, out error)
&& Directory.Exists(full))
{
var targets = new List<string>();
if (!string.IsNullOrWhiteSpace(assemblyPath))
{
string resolved = null, resolvedDisplay = null;
DiagnosticRoot resolvedRoot;
if (DiagnosticRootPolicy.TryResolve(roots, assemblyRootId, assemblyPath, out resolvedRoot, out resolved, out resolvedDisplay, out error)
&& File.Exists(resolved)) targets.Add(resolved);
else if (!string.IsNullOrWhiteSpace(error)) result.Warnings.Add(error);
}
else if (names.Count > 0)
{
targets.AddRange(EnumerateFiles(full, ct).Where(x => names.Contains(Path.GetFileName(x))).Take(20));
}
foreach (string target in targets.Distinct(StringComparer.OrdinalIgnoreCase))
{
string rel = Relative(root.Path, target);
Add(result, AssemblyInspector.Inspect(target, assemblyRootId + "/" + rel, false), "assembly");
}
if (targets.Count == 0 && names.Count > 0)
result.Warnings.Add("部署目录中未找到日志提到的程序集:" + string.Join(", ", names));
}
else result.Warnings.Add(string.IsNullOrWhiteSpace(error)
? "程序集诊断根目录不存在或不可访问:" + assemblyRootId
: error);
}
if (result.Evidence.Count == 0) result.Status = "empty";
result.NextActions.Add("确认每条结论均有 EvidenceId、来源路径和行号或文件信息。");
if (result.Evidence.Any(x => x.Kind == "exception")) result.NextActions.Add("优先核对异常签名对应的配置项、程序集版本和部署目录。");
if (result.Warnings.Count > 0) result.NextActions.Add("先处理诊断根目录不可用、文件缺失或读取失败的警告,再下结论。");
return result;
}
private static void Add(DiagnosticResult target, DiagnosticResult source, string prefix)
{
if (source == null) return;
target.Warnings.AddRange(source.Warnings ?? new List<string>());
foreach (var evidence in source.Evidence ?? new List<DiagnosticEvidence>())
{
evidence.EvidenceId = prefix + "-" + (target.Evidence.Count + 1).ToString("D4");
target.Evidence.Add(evidence);
}
}
private static List<string> ResolveOne(IEnumerable<DiagnosticRoot> roots, string rootId, string path, out string error)
{
DiagnosticRoot root; string full, display;
if (!DiagnosticRootPolicy.TryResolve(roots, rootId, path, out root, out full, out display, out error)) return new List<string>();
if (!File.Exists(full))
{
error = "诊断文件不存在:" + display;
return new List<string>();
}
error = null;
return new List<string> { full };
}
private static bool IsConfig(string path)
{
string ext = Path.GetExtension(path);
return ext.Equals(".config", StringComparison.OrdinalIgnoreCase)
|| ext.Equals(".json", StringComparison.OrdinalIgnoreCase)
|| ext.Equals(".xml", StringComparison.OrdinalIgnoreCase);
}
private static IEnumerable<string> EnumerateFiles(string root, System.Threading.CancellationToken ct)
{
var pending = new Stack<string>(); pending.Push(root);
while (pending.Count > 0)
{
ct.ThrowIfCancellationRequested();
string dir = pending.Pop();
string[] files = new string[0], dirs = new string[0];
try { files = Directory.GetFiles(dir); } catch { }
foreach (string file in files) yield return file;
try { dirs = Directory.GetDirectories(dir); } catch { }
foreach (string child in dirs.Reverse())
{
try { if ((File.GetAttributes(child) & FileAttributes.ReparsePoint) == 0) pending.Push(child); } catch { }
}
}
}
private static string Relative(string root, string file)
{
return file.Substring(root.TrimEnd('\\', '/').Length).TrimStart('\\', '/').Replace('\\', '/');
}
}
}
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
namespace Tools.MyAgent.Diagnostics
{
/// <summary>宿主注入的只读诊断根目录。</summary>
public sealed class DiagnosticRoot
{
public string Id { get; set; } = "";
public string Path { get; set; } = "";
public string Kind { get; set; } = "";
public bool ReadOnly { get; set; } = true;
public DiagnosticRoot Clone()
{
return new DiagnosticRoot { Id = Id, Path = Path, Kind = Kind, ReadOnly = ReadOnly };
}
}
/// <summary>诊断根目录解析策略:只允许 rootId + 相对路径,并拒绝穿越与 reparse point。</summary>
public static class DiagnosticRootPolicy
{
public static IReadOnlyList<DiagnosticRoot> Normalize(IEnumerable<DiagnosticRoot> roots)
{
var result = new List<DiagnosticRoot>();
if (roots == null) return result;
foreach (var item in roots)
{
if (item == null) continue;
string id = (item.Id ?? "").Trim();
string path = (item.Path ?? "").Trim();
string kind = (item.Kind ?? "").Trim().ToLowerInvariant();
if (id.Length == 0 || path.Length == 0) throw new ArgumentException("诊断根目录的 Id 和 Path 不能为空。");
if (id.Any(char.IsControl) || id.IndexOfAny(new[] { '\\', '/', ':', ' ' }) >= 0)
throw new ArgumentException("诊断根目录 Id 含有非法字符:" + id);
if (!System.IO.Path.IsPathRooted(path)) throw new ArgumentException("诊断根目录必须是绝对路径:" + id);
string full = NormalizeRoot(System.IO.Path.GetFullPath(path));
if (!result.Any(x => string.Equals(x.Id, id, StringComparison.OrdinalIgnoreCase)))
result.Add(new DiagnosticRoot { Id = id, Path = full, Kind = kind, ReadOnly = true });
}
return result;
}
private static string NormalizeRoot(string path)
{
return path.Length > 3
? path.TrimEnd(System.IO.Path.DirectorySeparatorChar, System.IO.Path.AltDirectorySeparatorChar)
: path;
}
public static bool TryResolve(IEnumerable<DiagnosticRoot> roots, string rootId, string relativePath,
out DiagnosticRoot root, out string fullPath, out string displayPath, out string error)
{
root = null; fullPath = null; displayPath = null; error = null;
string id = (rootId ?? "").Trim();
if (id.Length == 0) { error = "缺少 rootId。"; return false; }
root = (roots ?? Enumerable.Empty<DiagnosticRoot>())
.FirstOrDefault(x => string.Equals(x.Id, id, StringComparison.OrdinalIgnoreCase));
if (root == null) { error = "诊断根目录不存在:" + id + "。可用根目录:" + ListIds(roots); return false; }
string rel = (relativePath ?? "").Trim().Replace('/', System.IO.Path.DirectorySeparatorChar);
if (System.IO.Path.IsPathRooted(rel) || rel.IndexOfAny(new[] { '\0', ':' }) >= 0)
{ error = "诊断路径必须是根目录内的相对路径。"; return false; }
try
{
string combined = string.IsNullOrEmpty(rel) ? root.Path : System.IO.Path.Combine(root.Path, rel);
fullPath = System.IO.Path.GetFullPath(combined);
if (!IsWithin(root.Path, fullPath)) { error = "诊断路径越界,已拒绝访问。"; return false; }
if (HasReparsePoint(root.Path, fullPath)) { error = "诊断路径包含符号链接或 reparse point,已拒绝访问。"; return false; }
displayPath = id + (rel.Length == 0 ? "" : "/" + rel.Replace('\\', '/'));
return true;
}
catch (Exception ex) { error = "诊断路径无效:" + ex.Message; return false; }
}
public static string ListIds(IEnumerable<DiagnosticRoot> roots)
{
var ids = (roots ?? Enumerable.Empty<DiagnosticRoot>()).Select(x => x.Id).Where(x => !string.IsNullOrEmpty(x)).ToArray();
return ids.Length == 0 ? "(未配置)" : string.Join(", ", ids);
}
public static bool IsWithin(string root, string child)
{
string r = Normalize(root), c = Normalize(child);
return c.Equals(r, StringComparison.OrdinalIgnoreCase)
|| c.StartsWith(r + System.IO.Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)
|| c.StartsWith(r + System.IO.Path.AltDirectorySeparatorChar, StringComparison.OrdinalIgnoreCase);
}
private static string Normalize(string path)
{
return NormalizeRoot(System.IO.Path.GetFullPath(path ?? ""));
}
private static bool HasReparsePoint(string root, string path)
{
if (!Directory.Exists(root) && !File.Exists(root)) return false;
string current = Normalize(root);
string target = Normalize(path);
if (File.Exists(current) && string.Equals(current, target, StringComparison.OrdinalIgnoreCase)) return IsReparse(current);
if (IsReparse(current)) return true;
string rest = target.Substring(current.Length).TrimStart('\\', '/');
foreach (string part in rest.Split(new[] { '\\', '/' }, StringSplitOptions.RemoveEmptyEntries))
{
current = System.IO.Path.Combine(current, part);
if (File.Exists(current) || Directory.Exists(current))
if (IsReparse(current)) return true;
}
return false;
}
private static bool IsReparse(string path)
{
try { return (File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0; }
catch { return true; }
}
}
}
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using Tools.MyAgent.Support;
namespace Tools.MyAgent.Diagnostics
{
/// <summary>日志发现、时间范围检索和异常上下文提取。</summary>
public static class LogAnalyzer
{
private const long MaxLogFileSize = 64L * 1024 * 1024;
private static readonly HashSet<string> LogExtensions = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{ ".log", ".txt", ".json", ".xml", ".trace", ".out", ".err" };
private static readonly Regex Timestamp = new Regex(
@"\b(20\d{2}[-/]\d{1,2}[-/]\d{1,2}[ T]\d{1,2}:\d{2}:\d{2}(?:[\.,]\d{1,7})?)",
RegexOptions.Compiled);
private static readonly Regex ExceptionName = new Regex(
@"\b([A-Za-z_][A-Za-z0-9_.]*(?:Exception|Error|Failure))\b", RegexOptions.Compiled);
public static DiagnosticResult Discover(IEnumerable<DiagnosticRoot> roots, string rootId, string pattern, int maxResults, System.Threading.CancellationToken ct)
{
string full, display, error;
DiagnosticRoot root;
if (!DiagnosticRootPolicy.TryResolve(roots, rootId, "", out root, out full, out display, out error))
return Failure(error);
maxResults = Math.Max(1, Math.Min(100, maxResults));
if (!Directory.Exists(full)) return Failure("诊断根目录不存在或不可访问:" + display);
var result = new DiagnosticResult();
Regex matcher = BuildMatcher(pattern);
foreach (string file in EnumerateFiles(full, ct))
{
ct.ThrowIfCancellationRequested();
if (!LogExtensions.Contains(Path.GetExtension(file))) continue;
string rel = Relative(root.Path, file);
if (matcher != null && !matcher.IsMatch(rel)) continue;
FileInfo info;
try { info = new FileInfo(file); }
catch { result.Warnings.Add("无法读取日志文件属性:" + root.Id + "/" + rel); continue; }
if (info.Length > MaxLogFileSize)
{
result.Warnings.Add("日志文件超过读取上限,已跳过:" + root.Id + "/" + rel);
continue;
}
result.Evidence.Add(new DiagnosticEvidence
{
EvidenceId = "log-file-" + (result.Evidence.Count + 1).ToString("D4"),
Kind = "log-file",
Source = root.Id + "/" + rel,
Summary = string.Format("日志文件,大小 {0} 字节,修改时间 {1:yyyy-MM-dd HH:mm:ss}", info.Length, info.LastWriteTime),
Confidence = "confirmed"
});
if (result.Evidence.Count >= maxResults) break;
}
if (result.Evidence.Count == 0) result.Warnings.Add("未发现匹配日志文件。");
return result;
}
public static DiagnosticResult Search(IEnumerable<DiagnosticRoot> roots, string rootId, string query,
bool regexMode, string fromText, string toText, int contextLines, int maxResults, System.Threading.CancellationToken ct)
{
string full, display, error;
DiagnosticRoot root;
if (!DiagnosticRootPolicy.TryResolve(roots, rootId, "", out root, out full, out display, out error))
return Failure(error);
if (!Directory.Exists(full)) return Failure("诊断根目录不存在或不可访问:" + display);
if (string.IsNullOrWhiteSpace(query)) return Failure("缺少日志搜索 query。");
DateTime from, to;
bool hasFrom = DateTime.TryParse(fromText, out from);
bool hasTo = DateTime.TryParse(toText, out to);
var result = new DiagnosticResult();
Regex matcher = BuildQuery(query, regexMode);
contextLines = Math.Max(0, Math.Min(20, contextLines));
maxResults = Math.Max(1, Math.Min(100, maxResults));
foreach (string file in EnumerateFiles(full, ct))
{
ct.ThrowIfCancellationRequested();
if (!LogExtensions.Contains(Path.GetExtension(file))) continue;
string rel = Relative(root.Path, file);
try
{
if (new FileInfo(file).Length > MaxLogFileSize)
{
result.Warnings.Add("日志文件超过读取上限,已跳过:" + root.Id + "/" + rel);
continue;
}
}
catch { result.Warnings.Add("无法读取日志文件属性:" + root.Id + "/" + rel); continue; }
string text = TextEncoding.ReadAllTextAuto(file);
if (text == null) { result.Warnings.Add("无法读取日志:" + root.Id + "/" + Relative(root.Path, file)); continue; }
string[] lines = text.Replace("\r\n", "\n").Replace('\r', '\n').Split('\n');
for (int i = 0; i < lines.Length && result.Evidence.Count < maxResults; i++)
{
string line = lines[i] ?? "";
if (!matcher.IsMatch(line)) continue;
DateTime stamp;
if (!TryTimestamp(line, out stamp)) stamp = File.GetLastWriteTime(file);
if (hasFrom && stamp < from) continue;
if (hasTo && stamp > to) continue;
int start = Math.Max(0, i - contextLines), end = Math.Min(lines.Length - 1, i + contextLines);
string content = string.Join("\n", lines.Skip(start).Take(end - start + 1));
string source = root.Id + "/" + Relative(root.Path, file);
result.Evidence.Add(new DiagnosticEvidence
{
EvidenceId = "log-" + (result.Evidence.Count + 1).ToString("D4"),
Kind = IsException(line) ? "exception" : "log-match",
Source = source,
LineStart = start + 1,
LineEnd = end + 1,
Timestamp = stamp == DateTime.MinValue ? "" : stamp.ToString("yyyy-MM-dd HH:mm:ss.fff"),
Signature = ExtractSignature(content),
Summary = DiagnosticSanitizer.Clip(line.Trim(), 240),
Content = DiagnosticSanitizer.Clip(content, 4000),
Hash = DiagnosticSanitizer.Hash(content),
Confidence = "confirmed"
});
}
if (result.Evidence.Count >= maxResults) break;
}
if (result.Evidence.Count == 0)
{
result.Status = "empty";
result.Warnings.Add("日志中未找到匹配内容。");
}
return result;
}
private static DiagnosticResult Failure(string message)
{
return new DiagnosticResult { Status = "error", Warnings = new List<string> { message ?? "日志诊断失败。" } };
}
private static IEnumerable<string> EnumerateFiles(string root, System.Threading.CancellationToken ct)
{
var pending = new Stack<string>(); pending.Push(root);
while (pending.Count > 0)
{
ct.ThrowIfCancellationRequested();
string dir = pending.Pop();
string[] files = new string[0], dirs = new string[0];
try { files = Directory.GetFiles(dir); } catch { }
foreach (string file in files) yield return file;
try { dirs = Directory.GetDirectories(dir); } catch { }
foreach (string child in dirs.Reverse())
if (!IsReparse(child)) pending.Push(child);
}
}
private static bool IsReparse(string path)
{
try { return (File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0; } catch { return true; }
}
private static string Relative(string root, string file)
{
return file.Substring(root.TrimEnd('\\', '/').Length).TrimStart('\\', '/').Replace('\\', '/');
}
private static Regex BuildMatcher(string pattern)
{
if (string.IsNullOrWhiteSpace(pattern)) return null;
string escaped = Regex.Escape(pattern).Replace("\\*", ".*").Replace("\\?", ".");
return new Regex("^" + escaped + "$", RegexOptions.IgnoreCase);
}
private static Regex BuildQuery(string query, bool regexMode)
{
try { return new Regex(regexMode ? query : Regex.Escape(query), RegexOptions.IgnoreCase, TimeSpan.FromMilliseconds(500)); }
catch { return new Regex(Regex.Escape(query), RegexOptions.IgnoreCase, TimeSpan.FromMilliseconds(500)); }
}
private static bool TryTimestamp(string line, out DateTime value)
{
value = DateTime.MinValue;
var match = Timestamp.Match(line ?? "");
if (!match.Success) return false;
return DateTime.TryParse(match.Groups[1].Value.Replace(',', '.'), out value);
}
private static bool IsException(string line)
{
return ExceptionName.IsMatch(line ?? "") || (line ?? "").IndexOf("stacktrace", StringComparison.OrdinalIgnoreCase) >= 0;
}
private static string ExtractSignature(string content)
{
var match = ExceptionName.Match(content ?? "");
return match.Success ? match.Groups[1].Value : "";
}
}
}
...@@ -2,6 +2,7 @@ using System; ...@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using Tools.MyAgent.Diagnostics;
namespace Tools.MyAgent namespace Tools.MyAgent
{ {
...@@ -26,6 +27,7 @@ namespace Tools.MyAgent ...@@ -26,6 +27,7 @@ namespace Tools.MyAgent
public string IndustryScope { get; set; } = ""; public string IndustryScope { get; set; } = "";
public string[] RelatedDialectScopes { get; set; } = new string[0]; public string[] RelatedDialectScopes { get; set; } = new string[0];
public string ResponseProfileId { get; set; } = ""; public string ResponseProfileId { get; set; } = "";
public DiagnosticRoot[] DiagnosticRoots { get; set; } = new DiagnosticRoot[0];
/// <summary>创建独立快照,防止宿主或调用方修改运行中的产品身份。</summary> /// <summary>创建独立快照,防止宿主或调用方修改运行中的产品身份。</summary>
public AgentProductContext Clone() public AgentProductContext Clone()
...@@ -48,9 +50,15 @@ namespace Tools.MyAgent ...@@ -48,9 +50,15 @@ namespace Tools.MyAgent
IndustryScope = IndustryScope, IndustryScope = IndustryScope,
RelatedDialectScopes = Copy(RelatedDialectScopes), RelatedDialectScopes = Copy(RelatedDialectScopes),
ResponseProfileId = ResponseProfileId, ResponseProfileId = ResponseProfileId,
DiagnosticRoots = CopyRoots(DiagnosticRoots),
}; };
} }
private static DiagnosticRoot[] CopyRoots(DiagnosticRoot[] values)
{
return values == null ? new DiagnosticRoot[0] : values.Where(x => x != null).Select(x => x.Clone()).ToArray();
}
private static string[] Copy(string[] values) private static string[] Copy(string[] values)
{ {
return values == null ? new string[0] : values.ToArray(); return values == null ? new string[0] : values.ToArray();
...@@ -196,6 +204,7 @@ namespace Tools.MyAgent ...@@ -196,6 +204,7 @@ namespace Tools.MyAgent
snapshot.ModulePrefixes = NormalizeList(snapshot.ModulePrefixes, "ModulePrefixes"); snapshot.ModulePrefixes = NormalizeList(snapshot.ModulePrefixes, "ModulePrefixes");
snapshot.BusinessDomains = NormalizeList(snapshot.BusinessDomains, "BusinessDomains"); snapshot.BusinessDomains = NormalizeList(snapshot.BusinessDomains, "BusinessDomains");
snapshot.Technologies = NormalizeList(snapshot.Technologies, "Technologies"); snapshot.Technologies = NormalizeList(snapshot.Technologies, "Technologies");
snapshot.DiagnosticRoots = DiagnosticRootPolicy.Normalize(snapshot.DiagnosticRoots).Select(x => x.Clone()).ToArray();
return snapshot; return snapshot;
} }
......
...@@ -107,7 +107,34 @@ Agent/ ...@@ -107,7 +107,34 @@ Agent/
工具通过 `DbProviderFactory` 创建连接,不再依赖 `Tools.MyData.SmpDB`。宿主可在配置文件中注册同名 provider;缺少 provider 时会返回明确错误,不泄露连接串。 工具通过 `DbProviderFactory` 创建连接,不再依赖 `Tools.MyData.SmpDB`。宿主可在配置文件中注册同名 provider;缺少 provider 时会返回明确错误,不泄露连接串。
## 构建与验证 ## 诊断工具
`Tools.Agent` 支持通过宿主注入只读诊断根目录,对日志、配置和程序集做证据化分析。模型只能传递 `rootId + 相对路径`,不能直接访问任意绝对路径。
```csharp
var runtime = new AgentRuntime(new AgentRuntimeOptions
{
DataRoot = agentDataRoot,
CurrentUser = currentUserName,
DiagnosticRoots = new[]
{
new DiagnosticRoot { Id = "logs", Kind = "log", Path = logRoot },
new DiagnosticRoot { Id = "config", Kind = "config", Path = configRoot },
new DiagnosticRoot { Id = "deployment", Kind = "deployment", Path = binRoot },
},
});
```
可用工具:
- `log_discover`:发现日志文件。
- `log_search`:按关键词/正则、时间范围检索日志,返回时间戳、行号、上下文、异常签名和证据哈希。
- `config_read`:读取 JSON/XML/INI/`.config`,结构化格式化并脱敏密码、Token、连接串。
- `assembly_manifest`:只读读取 DLL/EXE 版本、架构、目标框架、引用和公共类型元数据。
- `diagnose_incident`:按日志 → 配置 → 程序集顺序执行综合排查。
程序集检查使用 .NET Framework 的 `ReflectionOnlyLoadFrom`,不会调用目标程序集的构造函数或业务方法;对于不可信文件仍建议在独立的检查进程中执行。
```bat ```bat
MSBuild.exe Tools.Agent\Tools.Agent.csproj /t:Rebuild /p:Configuration=Release MSBuild.exe Tools.Agent\Tools.Agent.csproj /t:Rebuild /p:Configuration=Release
......
...@@ -33,16 +33,18 @@ namespace Tools.MyAgent ...@@ -33,16 +33,18 @@ namespace Tools.MyAgent
} }
/// <summary>模型调用记录(#29):model / token / 耗时 / 工具序列 / 结果</summary> /// <summary>模型调用记录(#29):model / token / 耗时 / 工具序列 / 结果</summary>
public static void LogCall(string model, int promptTokens, int completionTokens, long elapsedMs, string tools, string note) public static void LogCall(string model, int promptTokens, int completionTokens, long elapsedMs, string tools, string note, string traceId = "")
{ {
string maskedNote = Mask(note ?? ""); string maskedNote = Mask(note ?? "");
Log($"CALL\tmodel={model}\ttokens={promptTokens}+{completionTokens}\telapsed={elapsedMs}ms\ttools=[{Mask(tools ?? "")}]\t{(!string.IsNullOrEmpty(maskedNote) ? "note=" + maskedNote : "")}"); string trace = string.IsNullOrEmpty(traceId) ? "" : "\ttraceId=" + Mask(traceId);
Log($"CALL\tmodel={model}\ttokens={promptTokens}+{completionTokens}\telapsed={elapsedMs}ms\ttools=[{Mask(tools ?? "")}]\t{(!string.IsNullOrEmpty(maskedNote) ? "note=" + maskedNote : "")}{trace}");
} }
/// <summary>工具执行记录</summary> /// <summary>工具执行记录</summary>
public static void LogTool(string name, string args, long elapsedMs, string resultSummary) public static void LogTool(string name, string args, long elapsedMs, string resultSummary, string traceId = "")
{ {
Log($"TOOL\t{name}\targs={Mask(args ?? "")}\telapsed={elapsedMs}ms\tresult={Mask(resultSummary ?? "")}"); string trace = string.IsNullOrEmpty(traceId) ? "" : "\ttraceId=" + Mask(traceId);
Log($"TOOL\t{name}\targs={Mask(args ?? "")}\telapsed={elapsedMs}ms\tresult={Mask(resultSummary ?? "")}{trace}");
} }
/// <summary>敏感信息脱敏(#41):手机号 / 邮箱 / API Key / 密码 / 令牌 / 长串</summary> /// <summary>敏感信息脱敏(#41):手机号 / 邮箱 / API Key / 密码 / 令牌 / 长串</summary>
......
...@@ -110,6 +110,13 @@ ...@@ -110,6 +110,13 @@
<Compile Include="Agent\Tools\DialectTools.cs" /> <Compile Include="Agent\Tools\DialectTools.cs" />
<Compile Include="Agent\Tools\DocExportTool.cs" /> <Compile Include="Agent\Tools\DocExportTool.cs" />
<Compile Include="Agent\Tools\ImageSearchTool.cs" /> <Compile Include="Agent\Tools\ImageSearchTool.cs" />
<Compile Include="Agent\Tools\DiagnosticTools.cs" />
<Compile Include="Diagnostics\DiagnosticRoot.cs" />
<Compile Include="Diagnostics\DiagnosticEvidence.cs" />
<Compile Include="Diagnostics\LogAnalyzer.cs" />
<Compile Include="Diagnostics\ConfigAnalyzer.cs" />
<Compile Include="Diagnostics\AssemblyInspector.cs" />
<Compile Include="Diagnostics\DiagnosticOrchestrator.cs" />
<Compile Include="Config\AgentConfig.cs" /> <Compile Include="Config\AgentConfig.cs" />
<Compile Include="Config\RoleTemplateStore.cs" /> <Compile Include="Config\RoleTemplateStore.cs" />
<Compile Include="Config\SecretProtector.cs" /> <Compile Include="Config\SecretProtector.cs" />
......
...@@ -10,6 +10,7 @@ using System.Web; ...@@ -10,6 +10,7 @@ using System.Web;
using System.Windows.Forms; using System.Windows.Forms;
using Tools.MyAgent.Agent; using Tools.MyAgent.Agent;
using Tools.MyAgent.Config; using Tools.MyAgent.Config;
using Tools.MyAgent.Diagnostics;
using Tools.MyAgent.Kb; using Tools.MyAgent.Kb;
namespace Tools.MyAgent namespace Tools.MyAgent
...@@ -99,6 +100,13 @@ namespace Tools.MyAgent ...@@ -99,6 +100,13 @@ namespace Tools.MyAgent
/// <summary>创建可嵌入的智能体窗体,并允许宿主指定数据目录、当前用户和产品上下文。</summary> /// <summary>创建可嵌入的智能体窗体,并允许宿主指定数据目录、当前用户和产品上下文。</summary>
public wMyAgent(string agentRoot, string currentUser, AgentProductContext productContext) public wMyAgent(string agentRoot, string currentUser, AgentProductContext productContext)
: this(agentRoot, currentUser, productContext, null)
{
}
/// <summary>创建可嵌入的智能体窗体,并允许宿主指定独立诊断根目录。</summary>
public wMyAgent(string agentRoot, string currentUser, AgentProductContext productContext,
IReadOnlyList<DiagnosticRoot> diagnosticRoots)
{ {
_productContext = ProductContextValidator.CreateSnapshot(productContext); _productContext = ProductContextValidator.CreateSnapshot(productContext);
_agentRoot = string.IsNullOrEmpty(agentRoot) _agentRoot = string.IsNullOrEmpty(agentRoot)
...@@ -108,7 +116,7 @@ namespace Tools.MyAgent ...@@ -108,7 +116,7 @@ namespace Tools.MyAgent
InitializeComponent(); InitializeComponent();
CreateChatControl(); CreateChatControl();
CreateCommandAutocomplete(); CreateCommandAutocomplete();
_engine = new AgentEngine(_kb, _agentRoot, _productContext); _engine = new AgentEngine(_kb, _agentRoot, _productContext, diagnosticRoots);
LoadConfig(); LoadConfig();
ApplyConfig(); ApplyConfig();
WireEvents(); WireEvents();
......
using System;
using System.IO;
using System.Windows.Forms; using System.Windows.Forms;
using Tools.MyAgent; using Tools.MyAgent;
using Tools.MyAgent.Diagnostics;
using Tools.MyCommon; using Tools.MyCommon;
namespace Tools namespace Tools
...@@ -13,9 +16,16 @@ namespace Tools ...@@ -13,9 +16,16 @@ namespace Tools
/// </summary> /// </summary>
private void InitMyAgent() private void InitMyAgent()
{ {
string appRoot = AppDomain.CurrentDomain.BaseDirectory;
var runtime = new AgentRuntime(new AgentRuntimeOptions var runtime = new AgentRuntime(new AgentRuntimeOptions
{ {
CurrentUser = string.IsNullOrEmpty(LoginInfo.UserName) ? APP.TEMP_USER_NAME : LoginInfo.UserName, CurrentUser = string.IsNullOrEmpty(LoginInfo.UserName) ? APP.TEMP_USER_NAME : LoginInfo.UserName,
DiagnosticRoots = new[]
{
new DiagnosticRoot { Id = "logs", Kind = "log", Path = Path.Combine(appRoot, "Logs") },
new DiagnosticRoot { Id = "config", Kind = "config", Path = Path.Combine(appRoot, "config") },
new DiagnosticRoot { Id = "deployment", Kind = "deployment", Path = appRoot },
},
}); });
_myAgent = runtime.CreateChatForm(); _myAgent = runtime.CreateChatForm();
_myAgent.TopLevel = false; _myAgent.TopLevel = false;
......
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