Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
11 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ public sealed partial class PermissionAwareToolExecutor
private readonly MiddlewarePipeline<ToolExecutionContext> _pipeline;
private PermissionMode _currentAgentMode = PermissionMode.Auto;

/// <summary>
/// 工具执行完成事件 — 无论成功或失败都会触发,用于遥测和诊断
/// </summary>
public event EventHandler<ToolExecutionCompletedEventArgs>? ToolExecutionCompleted;

public PermissionMode CurrentAgentMode
{
get => _currentAgentMode;
Expand Down Expand Up @@ -76,11 +81,14 @@ public async Task<ToolResult> 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)
{
Expand All @@ -93,6 +101,7 @@ public async Task<ToolResult> 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)
Expand All @@ -105,7 +114,9 @@ public async Task<ToolResult> 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;
}
}

Expand All @@ -132,4 +143,41 @@ private static ToolResult CreateErrorResult(string errorMessage)
IsError = true
};
}

private void RaiseToolExecutionCompleted(
string toolName,
ToolResult? result,
Dictionary<string, JsonElement> 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
});
}
}

/// <summary>
/// 工具执行完成事件参数
/// </summary>
public sealed class ToolExecutionCompletedEventArgs : EventArgs
{
/// <summary>工具名称</summary>
public required string ToolName { get; init; }

/// <summary>是否执行错误</summary>
public required bool IsError { get; init; }

/// <summary>错误消息(成功时为 null)</summary>
public string? ErrorMessage { get; init; }

/// <summary>执行耗时</summary>
public TimeSpan Duration { get; init; }

/// <summary>工具参数</summary>
public Dictionary<string, JsonElement>? Arguments { get; init; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, List<DiagnosticEvent>> _recentApiErrorsByPatient = new();
private readonly Dictionary<(string PatientId, string ToolName), int> _toolErrorsByPatientAndTool = new();
private readonly Dictionary<string, double> _lastTokenUsageRatioByPatient = new();
private readonly List<DiagnosticReport> _reports = [];
private readonly object _lock = new();
Expand Down Expand Up @@ -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
};

Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -113,6 +121,7 @@ public void Reset()
_loopCountBySession.Clear();
_permissionDeniedByTool.Clear();
_recentApiErrorsByPatient.Clear();
_toolErrorsByPatientAndTool.Clear();
_lastTokenUsageRatioByPatient.Clear();
_reports.Clear();
}
Expand All @@ -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);
}
Expand Down Expand Up @@ -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}"
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -137,7 +132,8 @@ private async Task<HotFixResult> 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(
Expand Down Expand Up @@ -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<bool> VerifyConfigChangeAsync(string filePath, CancellationToken ct)
{
const int maxRetries = 5;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> { ["tool"] = "Bash" });
var evt2 = CreateEvent("tool_error", properties: new Dictionary<string, string> { ["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<string, string> { ["tool"] = "Bash" });
var evt2 = CreateEvent("tool_error", properties: new Dictionary<string, string> { ["tool"] = "Bash" });
var evt3 = CreateEvent("tool_error", properties: new Dictionary<string, string> { ["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<string, string> { ["tool"] = "Bash" });
var evt2 = CreateEvent("tool_error", properties: new Dictionary<string, string> { ["tool"] = "Read" });
var evt3 = CreateEvent("tool_error", properties: new Dictionary<string, string> { ["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<string, string> { ["tool"] = "Bash" });
var evt2 = CreateEvent("tool_error", patientId: "p2", properties: new Dictionary<string, string> { ["tool"] = "Bash" });
var evt3 = CreateEvent("tool_error", patientId: "p1", properties: new Dictionary<string, string> { ["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<string, string>? properties = null)
{
return new DiagnosticEvent
{
EventType = eventType,
SessionId = sessionId,
PatientId = patientId ?? string.Empty,
Properties = properties ?? new Dictionary<string, string>()
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> { ["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<string, string> { ["tool"] = "Read" }
}
]);

var action = engine.ChooseAction(report);

Assert.Equal(HotFixActionType.ConfigChange, action.ActionType);
Assert.Null(action.PatchedContent);
}

[Fact]
public void ChooseAction_CompactContext_SetsCompactCommand()
{
Expand Down
5 changes: 4 additions & 1 deletion sdk/Abstractions/07-agents/Agent/Doctor/DiagnosticModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,10 @@ public enum DiagnosticRuleId
[EnumValue("D004")] ProcessHung,

/// <summary>API 错误:API 调用连续失败 ≥ 3次</summary>
[EnumValue("D005")] ApiError
[EnumValue("D005")] ApiError,

/// <summary>工具执行错误:同一工具连续失败 ≥ 3次</summary>
[EnumValue("D006")] ToolExecutionError
}

/// <summary>
Expand Down
Loading
Loading