Skip to content

Commit 58e7df5

Browse files
authored
Merge pull request #773 from Chris0Jeky/feat/tool-calling-refinements-651
feat: tool-calling Phase 3 — cost tracking, feature flag, token budget
2 parents f3f1450 + 17209b2 commit 58e7df5

File tree

8 files changed

+692
-7
lines changed

8 files changed

+692
-7
lines changed

backend/src/Taskdeck.Api/Extensions/LlmProviderRegistration.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ public static IServiceCollection AddLlmProviders(
2929
return new AbuseDetectionService(settings, state, usageRecords);
3030
});
3131

32+
// Tool-calling feature flag and budget settings
33+
var llmToolCallingSettings = configuration.GetSection("LlmToolCalling").Get<LlmToolCallingSettings>() ?? new LlmToolCallingSettings();
34+
services.AddSingleton(llmToolCallingSettings);
35+
3236
// LLM provider settings and deterministic provider selection policy
3337
var llmProviderSettings = configuration.GetSection("Llm").Get<LlmProviderSettings>() ?? new LlmProviderSettings();
3438
services.AddSingleton(llmProviderSettings);

backend/src/Taskdeck.Api/appsettings.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,10 @@
7373
"XFrameOptions": "DENY",
7474
"ReferrerPolicy": "no-referrer"
7575
},
76+
"LlmToolCalling": {
77+
"Enabled": true,
78+
"MaxToolResultBytes": 8000
79+
},
7680
"GitHubOAuth": {
7781
"ClientId": "",
7882
"ClientSecret": ""

backend/src/Taskdeck.Application/Services/ChatService.cs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ public class ChatService : IChatService
3434
private readonly ILlmKillSwitchService? _killSwitchService;
3535
private readonly IBoardContextBuilder? _boardContextBuilder;
3636
private readonly ToolCallingChatOrchestrator? _toolCallingOrchestrator;
37+
private readonly LlmToolCallingSettings _toolCallingSettings;
3738

3839
public ChatService(
3940
IUnitOfWork unitOfWork,
@@ -46,7 +47,8 @@ public ChatService(
4647
ILlmQuotaService? quotaService = null,
4748
ILlmKillSwitchService? killSwitchService = null,
4849
IBoardContextBuilder? boardContextBuilder = null,
49-
ToolCallingChatOrchestrator? toolCallingOrchestrator = null)
50+
ToolCallingChatOrchestrator? toolCallingOrchestrator = null,
51+
LlmToolCallingSettings? toolCallingSettings = null)
5052
{
5153
_unitOfWork = unitOfWork;
5254
_llmProvider = llmProvider;
@@ -59,6 +61,7 @@ public ChatService(
5961
_killSwitchService = killSwitchService;
6062
_boardContextBuilder = boardContextBuilder;
6163
_toolCallingOrchestrator = toolCallingOrchestrator;
64+
_toolCallingSettings = toolCallingSettings ?? new LlmToolCallingSettings();
6265
}
6366

6467
public async Task<Result<ChatSessionDto>> CreateSessionAsync(Guid userId, CreateChatSessionDto dto, CancellationToken ct = default)
@@ -208,7 +211,9 @@ public async Task<Result<ChatMessageDto>> SendMessageAsync(Guid sessionId, Guid
208211
LlmCompletionResult? reusableNoToolResponse = null;
209212

210213
// Try tool-calling path for board-scoped sessions with orchestrator.
211-
if (_toolCallingOrchestrator != null && session.BoardId.HasValue)
214+
// The feature flag allows disabling the orchestrator without code changes
215+
// (e.g. for cost control). When disabled, falls through to single-turn.
216+
if (_toolCallingOrchestrator != null && _toolCallingSettings.Enabled && session.BoardId.HasValue)
212217
{
213218
var toolChatMessages = session.Messages
214219
.Select(m => new ChatCompletionMessage(m.Role.ToString(), m.Content))
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
namespace Taskdeck.Application.Services;
2+
3+
/// <summary>
4+
/// Configuration for LLM tool-calling behaviour.
5+
/// Bound from the "LlmToolCalling" configuration section.
6+
/// </summary>
7+
public class LlmToolCallingSettings
8+
{
9+
/// <summary>
10+
/// Enables or disables the multi-turn tool-calling orchestrator.
11+
/// When false, <see cref="ChatService"/> falls through to the single-turn
12+
/// <see cref="ILlmProvider.CompleteAsync"/> path for every request.
13+
/// Default is true so existing behaviour is preserved.
14+
/// </summary>
15+
public bool Enabled { get; set; } = true;
16+
17+
/// <summary>
18+
/// Maximum byte length of a single tool result before it is truncated.
19+
/// Keeps oversized responses within the provider's context window.
20+
/// 0 = no truncation limit (not recommended for production).
21+
/// Default is 8 000 bytes (roughly a few thousand tokens depending on content and tokenizer).
22+
/// </summary>
23+
public int MaxToolResultBytes { get; set; } = 8_000;
24+
}

backend/src/Taskdeck.Application/Services/ToolCallingChatOrchestrator.cs

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,17 +36,20 @@ public sealed class ToolCallingChatOrchestrator
3636
private readonly ToolExecutorRegistry _executorRegistry;
3737
private readonly ILogger<ToolCallingChatOrchestrator> _logger;
3838
private readonly IToolStatusNotifier? _statusNotifier;
39+
private readonly LlmToolCallingSettings _settings;
3940

4041
public ToolCallingChatOrchestrator(
4142
ILlmProvider provider,
4243
ToolExecutorRegistry executorRegistry,
4344
ILogger<ToolCallingChatOrchestrator> logger,
44-
IToolStatusNotifier? statusNotifier = null)
45+
IToolStatusNotifier? statusNotifier = null,
46+
LlmToolCallingSettings? settings = null)
4547
{
4648
_provider = provider;
4749
_executorRegistry = executorRegistry;
4850
_logger = logger;
4951
_statusNotifier = statusNotifier;
52+
_settings = settings ?? new LlmToolCallingSettings();
5053
}
5154

5255
/// <summary>
@@ -232,6 +235,11 @@ await _statusNotifier.NotifyToolStatusAsync(
232235
}
233236
}
234237

238+
// Enforce token budget: truncate oversized tool results before they
239+
// are fed back to the LLM. This keeps the conversation within the
240+
// provider's context window even when a tool returns a large payload.
241+
resultContent = TruncateToolResult(resultContent, _settings.MaxToolResultBytes);
242+
235243
results.Add(new ToolCallResult(
236244
toolCall.CallId, toolCall.ToolName, resultContent, isError, toolCall.Arguments));
237245

@@ -409,6 +417,79 @@ private static ToolCallingResult BuildDegradedResult(
409417
DegradedReason: reason ?? "Tool calling is not available; falling back to single-turn.");
410418
}
411419

420+
/// <summary>
421+
/// Truncates a tool result string to the configured byte budget so oversized
422+
/// payloads do not blow out the provider's context window.
423+
/// When <paramref name="maxBytes"/> is 0 or negative, no truncation is applied.
424+
/// A "...(truncated)" marker is appended so the LLM knows the result was cut short.
425+
/// The returned string is always within <paramref name="maxBytes"/> UTF-8 bytes.
426+
/// </summary>
427+
internal static string TruncateToolResult(string content, int maxBytes)
428+
{
429+
if (maxBytes <= 0 || string.IsNullOrEmpty(content))
430+
return content;
431+
432+
var utf8 = System.Text.Encoding.UTF8;
433+
var encoded = utf8.GetByteCount(content);
434+
if (encoded <= maxBytes)
435+
return content;
436+
437+
const string marker = "...(truncated)";
438+
var markerBytes = utf8.GetByteCount(marker);
439+
440+
// If the budget cannot even hold the marker, return as many bytes as fit
441+
// from the marker itself so the result is always <= maxBytes.
442+
if (maxBytes <= markerBytes)
443+
return marker[..FindCharCountFittingBytes(marker, maxBytes, utf8)];
444+
445+
var maxContentBytes = maxBytes - markerBytes;
446+
447+
// Binary search for the longest prefix whose UTF-8 encoding fits the budget.
448+
// Avoids the O(n) worst case of a decrementing walk and makes no heap
449+
// allocations during the search (GetByteCount accepts ReadOnlySpan<char>).
450+
var span = content.AsSpan();
451+
var low = 0;
452+
var high = content.Length;
453+
var best = 0;
454+
while (low <= high)
455+
{
456+
var mid = low + ((high - low) / 2);
457+
if (utf8.GetByteCount(span[..mid]) <= maxContentBytes)
458+
{
459+
best = mid;
460+
low = mid + 1;
461+
}
462+
else
463+
{
464+
high = mid - 1;
465+
}
466+
}
467+
468+
return best > 0 ? content[..best] + marker : marker;
469+
}
470+
471+
private static int FindCharCountFittingBytes(string s, int maxBytes, System.Text.Encoding utf8)
472+
{
473+
var low = 0;
474+
var high = s.Length;
475+
var best = 0;
476+
var span = s.AsSpan();
477+
while (low <= high)
478+
{
479+
var mid = low + ((high - low) / 2);
480+
if (utf8.GetByteCount(span[..mid]) <= maxBytes)
481+
{
482+
best = mid;
483+
low = mid + 1;
484+
}
485+
else
486+
{
487+
high = mid - 1;
488+
}
489+
}
490+
return best;
491+
}
492+
412493
private static string TruncateForLog(string content)
413494
{
414495
const int maxLength = 200;

0 commit comments

Comments
 (0)