-
Notifications
You must be signed in to change notification settings - Fork 103
Expand file tree
/
Copy pathChatCommandProcessor.cs
More file actions
430 lines (376 loc) · 18.9 KB
/
Copy pathChatCommandProcessor.cs
File metadata and controls
430 lines (376 loc) · 18.9 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
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
using System.Collections.Concurrent;
using System.Collections.Frozen;
using System.Globalization;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using OpenClaw.Core.Abstractions;
using OpenClaw.Core.Models;
using OpenClaw.Core.Models.Goal;
using OpenClaw.Core.Observability;
using OpenClaw.Core.Sessions;
using OpenClaw.Core.Loops;
namespace OpenClaw.Core.Pipeline;
public enum DynamicCommandRegistrationResult
{
Registered,
ReservedBuiltIn,
Duplicate
}
public sealed class ChatCommandProcessor
{
private static readonly FrozenSet<string> BuiltInCommands = new[]
{
"/status",
"/new",
"/reset",
"/model",
"/usage",
"/think",
"/compact",
"/concise",
"/verbose",
"/goal",
"/loop",
"/stop",
"/cancel",
"/abort",
"/help"
}.ToFrozenSet(StringComparer.OrdinalIgnoreCase);
private readonly SessionManager _sessionManager;
private readonly ProviderUsageTracker? _providerUsage;
private readonly IGoalService? _goalService;
private readonly ConcurrentDictionary<string, Func<string, CancellationToken, Task<string>>> _dynamicCommands = new(StringComparer.OrdinalIgnoreCase);
private Func<Session, CancellationToken, Task<int>>? _compactCallback;
private Func<Session, string, CancellationToken, Task<string?>>? _loopCallback;
public ChatCommandProcessor(SessionManager sessionManager, ProviderUsageTracker? providerUsage = null, IGoalService? goalService = null)
{
_sessionManager = sessionManager;
_providerUsage = providerUsage;
_goalService = goalService;
}
/// <summary>
/// Sets the callback for LLM-powered history compaction (injected from gateway setup).
/// </summary>
public void SetCompactCallback(Func<Session, CancellationToken, Task<int>> callback)
=> _compactCallback = callback;
/// <summary>
/// Sets the callback for /loop command handling (injected from gateway setup).
/// The callback receives (session, fullText, ct) and returns the response text.
/// </summary>
public void SetLoopCallback(Func<Session, string, CancellationToken, Task<string?>> callback)
=> _loopCallback = callback;
/// <summary>
/// Registers a dynamic command handler (e.g. from a plugin).
/// </summary>
public DynamicCommandRegistrationResult RegisterDynamic(string command, Func<string, CancellationToken, Task<string>> handler)
{
var key = command.StartsWith('/') ? command : "/" + command;
if (BuiltInCommands.Contains(key))
return DynamicCommandRegistrationResult.ReservedBuiltIn;
return _dynamicCommands.TryAdd(key, handler)
? DynamicCommandRegistrationResult.Registered
: DynamicCommandRegistrationResult.Duplicate;
}
/// <summary>
/// Processes chat commands (starting with /).
/// Returns true if a command was handled (and thus the pipeline should short-circuit the LLM).
/// </summary>
public async Task<(bool Handled, string? Response)> TryProcessCommandAsync(
Session session, string text, CancellationToken ct, bool sessionLockHeld = false)
{
if (string.IsNullOrWhiteSpace(text) || !text.StartsWith('/'))
return (false, null);
var parts = text.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
var command = parts[0].ToLowerInvariant();
var args = parts.Length > 1 ? parts[1].Trim() : "";
switch (command)
{
case "/status":
var activeModel = session.ModelOverride ?? "default";
var (statusCacheRead, statusCacheWrite) = GetCacheTotals(session);
return (true, $"Session info:\n- Active Model: {activeModel}\n- Turn Count: {session.History.Count}\n- Token Usage: {session.TotalInputTokens} in / {session.TotalOutputTokens} out\n- Prompt Cache: {statusCacheRead} read / {statusCacheWrite} write");
case "/new":
case "/reset":
session.History.Clear();
session.TotalInputTokens = 0;
session.TotalOutputTokens = 0;
_goalService?.ClearGoal(session.Id);
await _sessionManager.PersistAsync(session, ct, sessionLockHeld);
return (true, "Session history has been reset. Starting fresh!");
case "/model":
if (string.IsNullOrWhiteSpace(args))
return (true, $"Current model override: {session.ModelOverride ?? "none (using default)"}\nUsage: /model <model-name> or /model reset");
if (args.Equals("reset", StringComparison.OrdinalIgnoreCase) || args.Equals("clear", StringComparison.OrdinalIgnoreCase))
{
session.ModelOverride = null;
await _sessionManager.PersistAsync(session, ct, sessionLockHeld);
return (true, "Model override cleared. Back to default.");
}
session.ModelOverride = args;
await _sessionManager.PersistAsync(session, ct, sessionLockHeld);
return (true, $"Model override set to: {args}");
case "/usage":
var (usageCacheRead, usageCacheWrite) = GetCacheTotals(session);
return (true, $"Total Token Usage in this session:\n- Input: {session.TotalInputTokens}\n- Output: {session.TotalOutputTokens}\n- Sum: {session.GetTotalTokens()}\n- Prompt Cache Read: {usageCacheRead}\n- Prompt Cache Write: {usageCacheWrite}");
case "/think":
if (string.IsNullOrWhiteSpace(args))
return (true, $"Current reasoning effort: {session.ReasoningEffort ?? "default"}\nUsage: /think off|low|medium|high");
var level = args.ToLowerInvariant();
if (level is "off" or "low" or "medium" or "high")
{
session.ReasoningEffort = level == "off" ? null : level;
await _sessionManager.PersistAsync(session, ct, sessionLockHeld);
return (true, level == "off"
? "Extended thinking disabled."
: $"Reasoning effort set to: {level}");
}
return (true, "Invalid level. Use: /think off|low|medium|high");
case "/compact":
if (session.History.Count <= 2)
return (true, "Nothing to compact — session has 2 or fewer turns.");
var turnsBefore = session.History.Count;
if (_compactCallback is not null)
{
var remainingTurns = await _compactCallback(session, ct);
await _sessionManager.PersistAsync(session, ct, sessionLockHeld);
return (true, $"Compacted: {turnsBefore} turns → {remainingTurns} turns remaining.");
}
// Fallback: simple trim keeping last 10 turns
var keepRecent = Math.Min(10, session.History.Count);
var removeCount = session.History.Count - keepRecent;
if (removeCount > 0)
session.History.RemoveRange(0, removeCount);
await _sessionManager.PersistAsync(session, ct, sessionLockHeld);
return (true, $"Trimmed: {turnsBefore} turns → {session.History.Count} turns (kept last {keepRecent}).");
case "/verbose":
if (string.IsNullOrWhiteSpace(args))
return (true, $"Verbose mode: {(session.VerboseMode ? "on" : "off")}\nUsage: /verbose on|off");
if (args.Equals("on", StringComparison.OrdinalIgnoreCase))
{
session.VerboseMode = true;
await _sessionManager.PersistAsync(session, ct, sessionLockHeld);
return (true, "Verbose mode enabled. Tool calls and token counts will be shown.");
}
if (args.Equals("off", StringComparison.OrdinalIgnoreCase))
{
session.VerboseMode = false;
await _sessionManager.PersistAsync(session, ct, sessionLockHeld);
return (true, "Verbose mode disabled.");
}
return (true, "Usage: /verbose on|off");
case "/concise":
if (string.IsNullOrWhiteSpace(args))
{
var currentMode = session.ResponseMode switch
{
SessionResponseModes.ConciseOps => "on",
SessionResponseModes.Full => "off",
_ => "auto"
};
return (true, $"Concise mode: {currentMode}\nUsage: /concise on|off|auto");
}
if (args.Equals("on", StringComparison.OrdinalIgnoreCase))
{
session.ResponseMode = SessionResponseModes.ConciseOps;
await _sessionManager.PersistAsync(session, ct, sessionLockHeld);
return (true, "Concise operational mode enabled.");
}
if (args.Equals("off", StringComparison.OrdinalIgnoreCase))
{
session.ResponseMode = SessionResponseModes.Full;
await _sessionManager.PersistAsync(session, ct, sessionLockHeld);
return (true, "Concise operational mode disabled for this session.");
}
if (args.Equals("auto", StringComparison.OrdinalIgnoreCase))
{
session.ResponseMode = SessionResponseModes.Default;
await _sessionManager.PersistAsync(session, ct, sessionLockHeld);
return (true, "Concise mode reset to automatic behavior.");
}
return (true, "Usage: /concise on|off|auto");
case "/help":
return (true, "Available commands:\n/status - Show session details\n/new (or /reset) - Clear conversation history\n/model <name> - Override the LLM model for this session\n/model reset - Clear model override\n/usage - Show token counts\n/think <level> - Set reasoning effort (off/low/medium/high)\n/compact - Compact conversation history\n/concise on|off|auto - Control concise operational responses\n/verbose on|off - Toggle verbose output\n/goal <action> - Manage session goals (start/set/create/pause/resume/complete/done/block/blocked/clear/status)\n/goal start <objective> +500k - Start a goal with a token budget\n/goal start <objective> spend 1.5m tokens - Start a goal with a budget phrase\n/help - Show this message");
case "/loop":
if (_loopCallback is null)
return (true, "Loop scheduling is not available in this configuration.");
var loopResult = await _loopCallback(session, text, ct);
return (true, loopResult);
case "/goal":
return (true, await HandleGoalCommandAsync(session, args, ct));
case "/stop":
case "/cancel":
case "/abort":
return (true, "There is no active execution to stop.");
default:
if (_dynamicCommands.TryGetValue(command, out var dynamicHandler))
{
var dynamicResult = await dynamicHandler(args, ct);
return (true, dynamicResult);
}
// Not a recognized command — assume it might be normal user text that just starts with a slash
return (false, null);
}
}
private (long CacheReadTokens, long CacheWriteTokens) GetCacheTotals(Session session)
{
if (session.TotalCacheReadTokens > 0 || session.TotalCacheWriteTokens > 0)
return (session.TotalCacheReadTokens, session.TotalCacheWriteTokens);
return _providerUsage?.GetLatestSessionCacheTotals(session.Id) ?? (0, 0);
}
/// <summary>
/// Handles the /goal command — full goal lifecycle management.
/// CLI commands support start, pause, resume, complete, clear, and status.
/// </summary>
private async Task<string> HandleGoalCommandAsync(Session session, string args, CancellationToken ct)
{
if (_goalService is null)
return "Goal system is not available. Start the gateway with goal support enabled.";
var parts = string.IsNullOrWhiteSpace(args) ? [] : args.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries);
var subcommand = parts.Length > 0 ? parts[0].ToLowerInvariant() : "status";
var subargs = parts.Length > 1 ? parts[1] : "";
switch (subcommand)
{
case "start":
case "set":
case "create":
{
// Parse objective and optional budget from the arguments
// Format: /goal start <objective> or /goal start <objective> +<budget>
var remaining = subargs;
long budget = 0;
// Check for +N token budget suffix
var budgetMatch = Regex.Match(remaining, @"\+(?<budget>\d+(?:\.\d+)?)(?<suffix>[kKmM])?\s*$");
if (budgetMatch.Success)
{
var budgetVal = double.Parse(budgetMatch.Groups["budget"].Value, CultureInfo.InvariantCulture);
var multiplier = budgetMatch.Groups["suffix"].Value.ToLowerInvariant() switch
{
"k" => 1_000,
"m" => 1_000_000,
_ => 1,
};
budget = (long)(budgetVal * multiplier);
remaining = remaining[..budgetMatch.Index].TrimEnd();
}
// Check for "spend N tokens" syntax
var spendMatch = Regex.Match(remaining, @"spend\s+(?<budget>\d+(?:\.\d+)?)\s*(?<suffix>[kKmM])?\s*tokens?\s*$", RegexOptions.IgnoreCase);
if (spendMatch.Success)
{
var budgetVal = double.Parse(spendMatch.Groups["budget"].Value, CultureInfo.InvariantCulture);
var multiplier = spendMatch.Groups["suffix"].Value.ToLowerInvariant() switch
{
"k" => 1_000,
"m" => 1_000_000,
_ => 1,
};
budget = (long)(budgetVal * multiplier);
remaining = remaining[..spendMatch.Index].TrimEnd();
}
var objective = remaining.Trim();
if (string.IsNullOrWhiteSpace(objective))
return "Usage: /goal start <objective> [+<budget>]\nExample: /goal start fix auth bug +500k";
// /new and /reset clear goals — enforce single-goal constraint
var existing = _goalService.GetGoal(session.Id);
if (existing is not null)
return $"A goal already exists: \"{existing.Objective}\"\nClear it with /goal clear first.";
try
{
var goal = _goalService.CreateGoal(session.Id, objective, budget, session.GetTotalTokens());
var budgetInfo = budget > 0 ? $" with budget {budget}" : " (no budget limit)";
return $"Goal created: \"{goal.Objective}\"{budgetInfo}";
}
catch (ArgumentException ex)
{
return $"Error: {ex.Message}";
}
catch (InvalidOperationException ex)
{
return $"Error: {ex.Message}";
}
}
case "pause":
{
var goal = _goalService.GetGoal(session.Id);
if (goal is null) return "No active goal to pause.";
try
{
_goalService.UpdateStatus(session.Id, GoalStatus.Paused, subargs);
return "Goal paused. Resume with /goal resume.";
}
catch (InvalidOperationException ex)
{
return $"Error: {ex.Message}";
}
}
case "resume":
{
var goal = _goalService.GetGoal(session.Id);
if (goal is null) return "No goal to resume.";
if (goal.Status.IsPursuable()) return "Goal is already active.";
if (goal.Status.IsTerminal())
return "Cannot resume a completed goal. Start a new one with /goal start.";
try
{
_goalService.UpdateStatus(session.Id, GoalStatus.Active, subargs);
return "Goal resumed.";
}
catch (InvalidOperationException ex)
{
return $"Error: {ex.Message}";
}
}
case "complete":
case "done":
{
var goal = _goalService.GetGoal(session.Id);
if (goal is null) return "No active goal.";
try
{
_goalService.UpdateStatus(session.Id, GoalStatus.Complete, subargs);
return "Goal marked as complete!";
}
catch (InvalidOperationException ex)
{
return $"Error: {ex.Message}";
}
}
case "block":
case "blocked":
{
var goal = _goalService.GetGoal(session.Id);
if (goal is null) return "No active goal.";
try
{
_goalService.UpdateStatus(session.Id, GoalStatus.Blocked, subargs);
return "Goal marked as blocked. Resume with /goal resume.";
}
catch (InvalidOperationException ex)
{
return $"Error: {ex.Message}";
}
}
case "clear":
{
_goalService.ClearGoal(session.Id);
return "Goal cleared.";
}
case "status":
default:
{
var statusGoal = _goalService.GetGoal(session.Id);
if (statusGoal is null)
return "No active goal. Use /goal start <objective> to create one.";
var result = $"Goal Status: {statusGoal.Status.ToDisplayName()}\n" +
$"Objective: {statusGoal.Objective}\n" +
$"Tokens Used: {statusGoal.TokensUsed}";
if (statusGoal.TokenBudget > 0)
result += $"\nBudget: {statusGoal.TokenBudget} (Remaining: {statusGoal.RemainingBudget})";
if (!string.IsNullOrEmpty(statusGoal.StatusNote))
result += $"\nNote: {statusGoal.StatusNote}";
result += $"\nContinuations: {statusGoal.ContinuationCount}/{SessionGoal.MaxContinuationsPerTurn}";
return result;
}
}
}
}