diff --git a/components/03-hands/McpToolHandlers/src/McpToolHandlers/Core/Execution/PermissionAwareToolExecutor.cs b/components/03-hands/McpToolHandlers/src/McpToolHandlers/Core/Execution/PermissionAwareToolExecutor.cs index 4fdde1c..2956c86 100644 --- a/components/03-hands/McpToolHandlers/src/McpToolHandlers/Core/Execution/PermissionAwareToolExecutor.cs +++ b/components/03-hands/McpToolHandlers/src/McpToolHandlers/Core/Execution/PermissionAwareToolExecutor.cs @@ -15,6 +15,11 @@ public sealed partial class PermissionAwareToolExecutor private readonly MiddlewarePipeline _pipeline; private PermissionMode _currentAgentMode = PermissionMode.Auto; + /// + /// 工具执行完成事件 — 无论成功或失败都会触发,用于遥测和诊断 + /// + public event EventHandler? ToolExecutionCompleted; + public PermissionMode CurrentAgentMode { get => _currentAgentMode; @@ -76,11 +81,14 @@ public async Task ExecuteAsync( if (context.Result is not null) { + RaiseToolExecutionCompleted(toolName, context.Result, arguments); return context.Result; } _logger.LogError("Tool {ToolName} pipeline completed without result", toolName); - return CreateErrorResult($"Tool '{toolName}' execution produced no result."); + var noResultError = CreateErrorResult($"Tool '{toolName}' execution produced no result."); + RaiseToolExecutionCompleted(toolName, noResultError, arguments); + return noResultError; } catch (OperationCanceledException) { @@ -93,6 +101,7 @@ public async Task ExecuteAsync( _logger.LogWarning(L.T(StringKey.ToolExecPermissionDeniedLog, toolName)); span?.SetStatus(TelemetryStatusCode.Error, "Permission denied"); RecordPermissionDenied(toolName); + RaiseToolExecutionCompleted(toolName, null, arguments, "permission_denied"); throw; } catch (PermissionPendingConfirmationException) @@ -105,7 +114,9 @@ public async Task ExecuteAsync( { _logger.LogError(ex, L.T(StringKey.ToolExecFailedLog, toolName)); span?.RecordException(ex); - return CreateErrorResult($"Error executing tool '{toolName}': {ex.Message}"); + var exceptionError = CreateErrorResult($"Error executing tool '{toolName}': {ex.Message}"); + RaiseToolExecutionCompleted(toolName, exceptionError, arguments, ex.Message); + return exceptionError; } } @@ -132,4 +143,41 @@ private static ToolResult CreateErrorResult(string errorMessage) IsError = true }; } + + private void RaiseToolExecutionCompleted( + string toolName, + ToolResult? result, + Dictionary arguments, + string? errorMessage = null) + { + ToolExecutionCompleted?.Invoke(this, new ToolExecutionCompletedEventArgs + { + ToolName = toolName, + IsError = result?.IsError ?? true, + ErrorMessage = errorMessage ?? result?.Content?.FirstOrDefault(c => c.Type == ToolContentType.Text)?.Text, + Duration = TimeSpan.Zero, + Arguments = arguments + }); + } +} + +/// +/// 工具执行完成事件参数 +/// +public sealed class ToolExecutionCompletedEventArgs : EventArgs +{ + /// 工具名称 + public required string ToolName { get; init; } + + /// 是否执行错误 + public required bool IsError { get; init; } + + /// 错误消息(成功时为 null) + public string? ErrorMessage { get; init; } + + /// 执行耗时 + public TimeSpan Duration { get; init; } + + /// 工具参数 + public Dictionary? Arguments { get; init; } } diff --git a/components/07-agents/Agents/src/Agents/Agents/Doctor/DiagnosticEngine.cs b/components/07-agents/Agents/src/Agents/Agents/Doctor/DiagnosticEngine.cs index 764e76e..eca60a0 100644 --- a/components/07-agents/Agents/src/Agents/Agents/Doctor/DiagnosticEngine.cs +++ b/components/07-agents/Agents/src/Agents/Agents/Doctor/DiagnosticEngine.cs @@ -9,6 +9,7 @@ public sealed class DiagnosticEngine private readonly Dictionary<(string PatientId, string SessionId), int> _loopCountBySession = new(); private readonly Dictionary<(string PatientId, string ToolName), int> _permissionDeniedByTool = new(); private readonly Dictionary> _recentApiErrorsByPatient = new(); + private readonly Dictionary<(string PatientId, string ToolName), int> _toolErrorsByPatientAndTool = new(); private readonly Dictionary _lastTokenUsageRatioByPatient = new(); private readonly List _reports = []; private readonly object _lock = new(); @@ -42,6 +43,7 @@ public DiagnosticEngine() { } "permission_denied" => EvaluatePermissionDenied(evt), "context_overflow" => EvaluateContextOverflow(evt), "api_error" or "api_timeout" => EvaluateApiError(evt), + "tool_error" => EvaluateToolError(evt), _ => null }; @@ -81,6 +83,12 @@ public DiagnosticEngine() { } || rawData.Contains("API错误", StringComparison.OrdinalIgnoreCase)) return "api_error"; + if (rawData.Contains("ToolError", StringComparison.OrdinalIgnoreCase) + || rawData.Contains("tool_error", StringComparison.OrdinalIgnoreCase) + || rawData.Contains("工具执行失败", StringComparison.OrdinalIgnoreCase) + || rawData.Contains("Error executing tool", StringComparison.OrdinalIgnoreCase)) + return "tool_error"; + return null; } @@ -113,6 +121,7 @@ public void Reset() _loopCountBySession.Clear(); _permissionDeniedByTool.Clear(); _recentApiErrorsByPatient.Clear(); + _toolErrorsByPatientAndTool.Clear(); _lastTokenUsageRatioByPatient.Clear(); _reports.Clear(); } @@ -128,6 +137,9 @@ public void ResetPatient(string patientId) var toolKeysToRemove = _permissionDeniedByTool.Keys.Where(k => k.PatientId == patientId).ToList(); foreach (var key in toolKeysToRemove) _permissionDeniedByTool.Remove(key); + var toolErrorKeysToRemove = _toolErrorsByPatientAndTool.Keys.Where(k => k.PatientId == patientId).ToList(); + foreach (var key in toolErrorKeysToRemove) _toolErrorsByPatientAndTool.Remove(key); + _recentApiErrorsByPatient.Remove(patientId); _lastTokenUsageRatioByPatient.Remove(patientId); } @@ -234,4 +246,29 @@ public void ResetPatient(string patientId) SuggestedFixDescription = "检查 API 端点配置、网络连接、API Key 有效性" }; } + + private DiagnosticReport? EvaluateToolError(DiagnosticEvent evt) + { + var toolName = evt.Properties.GetValueOrDefault("tool") ?? evt.Properties.GetValueOrDefault("tool_name") ?? "unknown"; + var key = (evt.PatientId, toolName); + var count = _toolErrorsByPatientAndTool.GetValueOrDefault(key) + 1; + _toolErrorsByPatientAndTool[key] = count; + + if (count < 3) return null; + + var errorMessage = evt.Properties.GetValueOrDefault("error") ?? evt.RawData; + + return new DiagnosticReport + { + RuleId = DiagnosticRuleId.ToolExecutionError, + PatientId = evt.PatientId, + Severity = DiagnosticSeverity.Error, + Description = $"病人 {evt.PatientId} 工具 {toolName} 连续执行失败 {count} 次,可能存在配置或环境问题", + TriggeringEvents = [evt], + SuggestedFixType = HotFixActionType.ConfigChange, + SuggestedFixDescription = string.IsNullOrWhiteSpace(errorMessage) + ? $"检查工具 {toolName} 的配置和依赖" + : $"检查工具 {toolName} 的配置和依赖,错误: {errorMessage}" + }; + } } diff --git a/components/07-agents/Agents/src/Agents/Agents/Doctor/HotFixEngine.cs b/components/07-agents/Agents/src/Agents/Agents/Doctor/HotFixEngine.cs index 39ea16e..f99a90c 100644 --- a/components/07-agents/Agents/src/Agents/Agents/Doctor/HotFixEngine.cs +++ b/components/07-agents/Agents/src/Agents/Agents/Doctor/HotFixEngine.cs @@ -99,12 +99,7 @@ internal HotFixAction ChooseAction(DiagnosticReport report) Description = report.SuggestedFixDescription ?? "修改源码并重编译", TargetFilePath = InferTargetFilePath(report) }, - HotFixActionType.ConfigChange => new HotFixAction - { - ActionType = HotFixActionType.ConfigChange, - Description = report.SuggestedFixDescription ?? "修改配置文件", - TargetFilePath = InferConfigFilePath(report) - }, + HotFixActionType.ConfigChange => BuildConfigChangeAction(report), HotFixActionType.CompactContext => new HotFixAction { ActionType = HotFixActionType.CompactContext, @@ -137,7 +132,8 @@ private async Task ExecuteSourceCodePatchAsync( if (string.IsNullOrWhiteSpace(action.PatchedContent)) { - return new HotFixResult { Success = false, PatientId = patientId, Action = action, Description = "未指定补丁内容,源码修复需要人工介入" }; + DoctorDiag.Write($"[Doctor] 源码修复需要人工介入: {action.TargetFilePath} (病人: {patientId})"); + return new HotFixResult { Success = false, PatientId = patientId, Action = action, Description = $"源码修复需要人工介入,目标文件: {action.TargetFilePath ?? "未知"}" }; } var patchResult = await _patcher.ApplyPatchAsync( @@ -385,6 +381,47 @@ private static HotFixResult CreateNoOpResult(HotFixAction action, string patient return null; } + private static HotFixAction BuildConfigChangeAction(DiagnosticReport report) + { + var targetFilePath = InferConfigFilePath(report); + + if (report.RuleId == DiagnosticRuleId.ToolPermissionDenied) + { + var toolName = report.TriggeringEvents + .Select(e => e.Properties.GetValueOrDefault("tool") ?? e.Properties.GetValueOrDefault("tool_name")) + .FirstOrDefault(t => !string.IsNullOrWhiteSpace(t)) ?? "unknown"; + + return new HotFixAction + { + ActionType = HotFixActionType.ConfigChange, + Description = report.SuggestedFixDescription ?? $"为工具 {toolName} 添加允许权限", + TargetFilePath = targetFilePath, + PatchedContent = $"{{\"permissions\": {{\"allowedTools\": [\"{toolName}\"]}}}}" + }; + } + + if (report.RuleId == DiagnosticRuleId.ToolExecutionError) + { + var toolName = report.TriggeringEvents + .Select(e => e.Properties.GetValueOrDefault("tool") ?? e.Properties.GetValueOrDefault("tool_name")) + .FirstOrDefault(t => !string.IsNullOrWhiteSpace(t)) ?? "unknown"; + + return new HotFixAction + { + ActionType = HotFixActionType.ConfigChange, + Description = report.SuggestedFixDescription ?? $"检查工具 {toolName} 的配置和依赖", + TargetFilePath = targetFilePath + }; + } + + return new HotFixAction + { + ActionType = HotFixActionType.ConfigChange, + Description = report.SuggestedFixDescription ?? "修改配置文件", + TargetFilePath = targetFilePath + }; + } + private async Task VerifyConfigChangeAsync(string filePath, CancellationToken ct) { const int maxRetries = 5; diff --git a/components/07-agents/Agents/tests/Unit/Agents/Doctor/DiagnosticEngineTests.cs b/components/07-agents/Agents/tests/Unit/Agents/Doctor/DiagnosticEngineTests.cs index 448462d..aa9e718 100644 --- a/components/07-agents/Agents/tests/Unit/Agents/Doctor/DiagnosticEngineTests.cs +++ b/components/07-agents/Agents/tests/Unit/Agents/Doctor/DiagnosticEngineTests.cs @@ -307,15 +307,77 @@ public void Evaluate_ContextOverflow_UsesUsageRatioFallback() Assert.Equal(DiagnosticRuleId.ContextOverflow, report.RuleId); } + [Fact] + public void Evaluate_ToolError_LessThan3Times_ReturnsNull() + { + var evt1 = CreateEvent("tool_error", properties: new Dictionary { ["tool"] = "Bash" }); + var evt2 = CreateEvent("tool_error", properties: new Dictionary { ["tool"] = "Bash" }); + + Assert.Null(_engine.Evaluate(evt1)); + Assert.Null(_engine.Evaluate(evt2)); + } + + [Fact] + public void Evaluate_ToolError_3Times_ReturnsD006Report() + { + var evt1 = CreateEvent("tool_error", properties: new Dictionary { ["tool"] = "Bash" }); + var evt2 = CreateEvent("tool_error", properties: new Dictionary { ["tool"] = "Bash" }); + var evt3 = CreateEvent("tool_error", properties: new Dictionary { ["tool"] = "Bash" }); + + _engine.Evaluate(evt1); + _engine.Evaluate(evt2); + var report = _engine.Evaluate(evt3); + + Assert.NotNull(report); + Assert.Equal(DiagnosticRuleId.ToolExecutionError, report.RuleId); + Assert.Equal(DiagnosticSeverity.Error, report.Severity); + Assert.Equal(HotFixActionType.ConfigChange, report.SuggestedFixType); + Assert.Contains("Bash", report.Description); + } + + [Fact] + public void Evaluate_ToolError_DifferentTools_CountedSeparately() + { + var evt1 = CreateEvent("tool_error", properties: new Dictionary { ["tool"] = "Bash" }); + var evt2 = CreateEvent("tool_error", properties: new Dictionary { ["tool"] = "Read" }); + var evt3 = CreateEvent("tool_error", properties: new Dictionary { ["tool"] = "Bash" }); + + Assert.Null(_engine.Evaluate(evt1)); + Assert.Null(_engine.Evaluate(evt2)); + Assert.Null(_engine.Evaluate(evt3)); + } + + [Fact] + public void Evaluate_ToolError_PatientIsolation() + { + var evt1 = CreateEvent("tool_error", patientId: "p1", properties: new Dictionary { ["tool"] = "Bash" }); + var evt2 = CreateEvent("tool_error", patientId: "p2", properties: new Dictionary { ["tool"] = "Bash" }); + var evt3 = CreateEvent("tool_error", patientId: "p1", properties: new Dictionary { ["tool"] = "Bash" }); + + Assert.Null(_engine.Evaluate(evt1)); + Assert.Null(_engine.Evaluate(evt2)); + Assert.Null(_engine.Evaluate(evt3)); + } + + [Fact] + public void ClassifyDiagOutput_ToolErrorKeywords() + { + Assert.Equal("tool_error", DiagnosticEngine.ClassifyDiagOutput("ToolError: something failed")); + Assert.Equal("tool_error", DiagnosticEngine.ClassifyDiagOutput("Error executing tool 'Bash'")); + Assert.Equal("tool_error", DiagnosticEngine.ClassifyDiagOutput("工具执行失败")); + } + private static DiagnosticEvent CreateEvent( string eventType, string? sessionId = null, + string? patientId = null, Dictionary? properties = null) { return new DiagnosticEvent { EventType = eventType, SessionId = sessionId, + PatientId = patientId ?? string.Empty, Properties = properties ?? new Dictionary() }; } diff --git a/components/07-agents/Agents/tests/Unit/Agents/Doctor/HotFixEngineTests.cs b/components/07-agents/Agents/tests/Unit/Agents/Doctor/HotFixEngineTests.cs index ca460db..2adb015 100644 --- a/components/07-agents/Agents/tests/Unit/Agents/Doctor/HotFixEngineTests.cs +++ b/components/07-agents/Agents/tests/Unit/Agents/Doctor/HotFixEngineTests.cs @@ -94,6 +94,47 @@ public void ChooseAction_ConfigChange_SetsSettingsJsonPath() Assert.Equal("settings.json", action.TargetFilePath); } + [Fact] + public void ChooseAction_ConfigChange_ToolPermissionDenied_AutoGeneratesPatchedContent() + { + var engine = CreateEngine(); + var report = CreateReport(DiagnosticRuleId.ToolPermissionDenied, HotFixActionType.ConfigChange, + triggeringEvents: + [ + new DiagnosticEvent + { + EventType = "permission_denied", + Properties = new Dictionary { ["tool"] = "Bash" } + } + ]); + + var action = engine.ChooseAction(report); + + Assert.Equal(HotFixActionType.ConfigChange, action.ActionType); + Assert.NotNull(action.PatchedContent); + Assert.Contains("Bash", action.PatchedContent); + } + + [Fact] + public void ChooseAction_ConfigChange_ToolExecutionError_NoAutoPatchedContent() + { + var engine = CreateEngine(); + var report = CreateReport(DiagnosticRuleId.ToolExecutionError, HotFixActionType.ConfigChange, + triggeringEvents: + [ + new DiagnosticEvent + { + EventType = "tool_error", + Properties = new Dictionary { ["tool"] = "Read" } + } + ]); + + var action = engine.ChooseAction(report); + + Assert.Equal(HotFixActionType.ConfigChange, action.ActionType); + Assert.Null(action.PatchedContent); + } + [Fact] public void ChooseAction_CompactContext_SetsCompactCommand() { diff --git a/sdk/Abstractions/07-agents/Agent/Doctor/DiagnosticModels.cs b/sdk/Abstractions/07-agents/Agent/Doctor/DiagnosticModels.cs index 1ed0ea9..be779c3 100644 --- a/sdk/Abstractions/07-agents/Agent/Doctor/DiagnosticModels.cs +++ b/sdk/Abstractions/07-agents/Agent/Doctor/DiagnosticModels.cs @@ -29,7 +29,10 @@ public enum DiagnosticRuleId [EnumValue("D004")] ProcessHung, /// API 错误:API 调用连续失败 ≥ 3次 - [EnumValue("D005")] ApiError + [EnumValue("D005")] ApiError, + + /// 工具执行错误:同一工具连续失败 ≥ 3次 + [EnumValue("D006")] ToolExecutionError } /// diff --git a/src/JoinCode/Program.cs b/src/JoinCode/Program.cs index 568f01f..f974b4c 100644 --- a/src/JoinCode/Program.cs +++ b/src/JoinCode/Program.cs @@ -71,6 +71,25 @@ static async Task Main(string[] args) await builder.ConfigureModulesAsync(host.Services); + // 3.3 工具执行遥测:订阅 PermissionAwareToolExecutor.ToolExecutionCompleted,转发给医生 + if (doctorClient is not null) + { + var toolExecutor = host.Services.GetService(); + if (toolExecutor is not null) + { + toolExecutor.ToolExecutionCompleted += async (_, e) => + { + try + { + var eventType = e.IsError ? "tool_error" : "tool_success"; + var data = $"{{\"tool\":\"{e.ToolName}\",\"isError\":{e.IsError.ToString().ToLowerInvariant()}}}"; + await doctorClient.SendTextEventAsync(eventType, data).ConfigureAwait(false); + } + catch (Exception ex) { System.Diagnostics.Trace.WriteLine($"[Doctor] 发送工具遥测失败: {ex.Message}"); } + }; + } + } + int exitCode; if (options.IsNonInteractiveMode) exitCode = await Entry.NonInteractiveModeRunner.RunAsync(config, options, host);