-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathToolCallTrackingFilter.cs
More file actions
109 lines (91 loc) · 3.86 KB
/
Copy pathToolCallTrackingFilter.cs
File metadata and controls
109 lines (91 loc) · 3.86 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
using System.Diagnostics;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.SemanticKernel;
using OperationsCopilot.Agent.Options;
using OperationsCopilot.Domain.Abstractions;
using OperationsCopilot.Domain.Chat;
namespace OperationsCopilot.Agent.Filters;
/// <summary>
/// Records every kernel function the agent invokes, with its arguments and duration.
/// </summary>
/// <remarks>
/// A filter is the right seam for this: it sees every call the model makes without any tool
/// having to opt in, so the "tools used" list in the response cannot silently drift out of step
/// with what actually ran. A failing tool is recorded and rethrown, so the agent's own error
/// handling still applies.
/// </remarks>
public sealed class ToolCallTrackingFilter(
IToolCallRecorder recorder,
IOptions<CopilotAgentOptions> agentOptions,
ILogger<ToolCallTrackingFilter> logger) : IFunctionInvocationFilter
{
/// <summary>Argument values are truncated in the response; full values stay in the logs.</summary>
private const int MaxArgumentLength = 200;
private readonly CopilotAgentOptions _options = agentOptions.Value;
public async Task OnFunctionInvocationAsync(
FunctionInvocationContext context,
Func<FunctionInvocationContext, Task> next)
{
if (recorder.ToolCalls.Count >= _options.MaxToolCallsPerTurn)
{
// Short-circuit rather than throw: the model gets a plain instruction to stop
// calling tools and answer, so the user still gets a reply built on what was
// gathered instead of an error.
logger.LogWarning(
"Tool call budget of {Budget} reached; refusing {Plugin}.{Function}.",
_options.MaxToolCallsPerTurn,
context.Function.PluginName,
context.Function.Name);
context.Result = new FunctionResult(
context.Function,
"The tool call budget for this turn is exhausted. Answer now using the " +
"information already gathered, and say which part you could not verify.");
return;
}
var stopwatch = Stopwatch.StartNew();
try
{
await next(context);
stopwatch.Stop();
Record(context, stopwatch.ElapsedMilliseconds, succeeded: true, error: null);
logger.LogInformation(
"Tool {Plugin}.{Function} completed in {ElapsedMs}ms.",
context.Function.PluginName,
context.Function.Name,
stopwatch.ElapsedMilliseconds);
}
catch (Exception ex)
{
stopwatch.Stop();
Record(context, stopwatch.ElapsedMilliseconds, succeeded: false, error: ex.Message);
logger.LogError(
ex,
"Tool {Plugin}.{Function} failed after {ElapsedMs}ms.",
context.Function.PluginName,
context.Function.Name,
stopwatch.ElapsedMilliseconds);
throw;
}
}
private void Record(FunctionInvocationContext context, long elapsedMs, bool succeeded, string? error)
=> recorder.RecordToolCall(new ToolInvocation(
context.Function.PluginName ?? "Unknown",
context.Function.Name,
DescribeArguments(context.Arguments),
elapsedMs,
succeeded,
error));
private static Dictionary<string, string?> DescribeArguments(KernelArguments arguments)
{
var described = new Dictionary<string, string?>(StringComparer.Ordinal);
foreach (var (key, value) in arguments)
{
var text = value?.ToString();
described[key] = text is { Length: > MaxArgumentLength }
? text[..MaxArgumentLength] + "…"
: text;
}
return described;
}
}