Skip to content

Commit 0f38f82

Browse files
authored
Merge pull request #502 from Chris0Jeky/agt/tool-registry-policy-template
AGT-02: Add tool registry, policy evaluator, and inbox triage template
2 parents e15c206 + e2a5118 commit 0f38f82

14 files changed

Lines changed: 1296 additions & 0 deletions

File tree

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using Taskdeck.Api.Realtime;
22
using Taskdeck.Application.Services;
3+
using Taskdeck.Domain.Agents;
34

45
namespace Taskdeck.Api.Extensions;
56

@@ -51,6 +52,15 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection
5152
services.AddScoped<IBoardRealtimeNotifier, CompositeBoardRealtimeNotifier>();
5253
services.AddSingleton<IBoardPresenceTracker, InMemoryBoardPresenceTracker>();
5354

55+
// Agent tool registry (singleton — populated once at startup, read concurrently)
56+
var toolRegistry = new TaskdeckToolRegistry();
57+
toolRegistry.RegisterTool(InboxTriageAssistant.GetToolDefinition());
58+
services.AddSingleton<ITaskdeckToolRegistry>(toolRegistry);
59+
60+
// Agent policy evaluator and inbox triage assistant
61+
services.AddScoped<IAgentPolicyEvaluator, AgentPolicyEvaluator>();
62+
services.AddScoped<InboxTriageAssistant>();
63+
5464
return services;
5565
}
5666
}
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
using System.Text.Json;
2+
using Microsoft.Extensions.Logging;
3+
using Taskdeck.Application.Interfaces;
4+
using Taskdeck.Domain.Agents;
5+
using Taskdeck.Domain.Enums;
6+
using Taskdeck.Domain.Exceptions;
7+
8+
namespace Taskdeck.Application.Services;
9+
10+
/// <summary>
11+
/// Evaluates agent tool-use requests against the profile's policy configuration
12+
/// and the tool's risk level. Review-first is the default for all risk levels;
13+
/// low-risk auto-apply is OFF unless explicitly opted in via policy.
14+
/// </summary>
15+
public sealed class AgentPolicyEvaluator : IAgentPolicyEvaluator
16+
{
17+
private readonly IUnitOfWork _unitOfWork;
18+
private readonly ITaskdeckToolRegistry _toolRegistry;
19+
private readonly ILogger<AgentPolicyEvaluator>? _logger;
20+
21+
public AgentPolicyEvaluator(
22+
IUnitOfWork unitOfWork,
23+
ITaskdeckToolRegistry toolRegistry,
24+
ILogger<AgentPolicyEvaluator>? logger = null)
25+
{
26+
_unitOfWork = unitOfWork;
27+
_toolRegistry = toolRegistry;
28+
_logger = logger;
29+
}
30+
31+
public async Task<PolicyDecision> EvaluateToolUseAsync(
32+
Guid agentProfileId,
33+
string toolKey,
34+
IDictionary<string, string>? context = null,
35+
CancellationToken cancellationToken = default)
36+
{
37+
if (agentProfileId == Guid.Empty)
38+
{
39+
_logger?.LogWarning("Policy evaluation denied: empty agent profile ID");
40+
return PolicyDecision.Deny("Agent profile ID is required.");
41+
}
42+
43+
if (string.IsNullOrWhiteSpace(toolKey))
44+
{
45+
_logger?.LogWarning("Policy evaluation denied: empty tool key");
46+
return PolicyDecision.Deny("Tool key is required.");
47+
}
48+
49+
// Look up the tool in the registry
50+
var tool = _toolRegistry.GetTool(toolKey);
51+
if (tool is null)
52+
{
53+
_logger?.LogWarning("Policy evaluation denied: tool '{ToolKey}' not found in registry", toolKey);
54+
return PolicyDecision.Deny($"Tool '{toolKey}' is not registered.");
55+
}
56+
57+
// Look up the agent profile
58+
var profile = await _unitOfWork.AgentProfiles.GetByIdAsync(agentProfileId, cancellationToken);
59+
if (profile is null)
60+
{
61+
_logger?.LogWarning("Policy evaluation denied: agent profile '{ProfileId}' not found", agentProfileId);
62+
return PolicyDecision.Deny("Agent profile not found.");
63+
}
64+
65+
if (!profile.IsEnabled)
66+
{
67+
_logger?.LogInformation(
68+
"Policy evaluation denied: agent profile '{ProfileId}' is disabled", agentProfileId);
69+
return PolicyDecision.Deny("Agent profile is disabled.");
70+
}
71+
72+
// Parse the profile's policy JSON
73+
var policy = ParsePolicy(profile.PolicyJson);
74+
75+
// Check tool allowlist: if a non-empty allowlist is defined, the tool must be in it
76+
if (policy.AllowedTools.Count > 0 && !policy.AllowedTools.Contains(toolKey, StringComparer.OrdinalIgnoreCase))
77+
{
78+
_logger?.LogInformation(
79+
"Policy evaluation denied: tool '{ToolKey}' not in allowlist for profile '{ProfileId}'",
80+
toolKey, agentProfileId);
81+
return PolicyDecision.Deny($"Tool '{toolKey}' is not in this agent's allowed tool list.");
82+
}
83+
84+
// Enforce risk-level constraints
85+
// High risk: always requires review, never auto-apply
86+
if (tool.RiskLevel == ToolRiskLevel.High)
87+
{
88+
_logger?.LogInformation(
89+
"Policy evaluation: tool '{ToolKey}' requires review (high risk) for profile '{ProfileId}'",
90+
toolKey, agentProfileId);
91+
return PolicyDecision.AllowWithReview($"High-risk tool '{tool.DisplayName}' requires review before execution.");
92+
}
93+
94+
// Medium risk: always requires review
95+
if (tool.RiskLevel == ToolRiskLevel.Medium)
96+
{
97+
_logger?.LogInformation(
98+
"Policy evaluation: tool '{ToolKey}' requires review (medium risk) for profile '{ProfileId}'",
99+
toolKey, agentProfileId);
100+
return PolicyDecision.AllowWithReview($"Medium-risk tool '{tool.DisplayName}' requires review before execution.");
101+
}
102+
103+
// Low risk: review-first by default; direct apply only if explicitly enabled
104+
if (policy.AutoApplyLowRisk)
105+
{
106+
_logger?.LogInformation(
107+
"Policy evaluation: tool '{ToolKey}' allowed direct (low risk, auto-apply enabled) for profile '{ProfileId}'",
108+
toolKey, agentProfileId);
109+
return PolicyDecision.AllowDirect($"Low-risk tool '{tool.DisplayName}' auto-applied per policy.");
110+
}
111+
112+
_logger?.LogInformation(
113+
"Policy evaluation: tool '{ToolKey}' requires review (low risk, auto-apply off) for profile '{ProfileId}'",
114+
toolKey, agentProfileId);
115+
return PolicyDecision.AllowWithReview($"Low-risk tool '{tool.DisplayName}' requires review (auto-apply is off).");
116+
}
117+
118+
/// <summary>
119+
/// Parse the profile's PolicyJson into a structured policy configuration.
120+
/// Returns safe defaults if the JSON is missing or malformed.
121+
/// </summary>
122+
internal static AgentPolicyConfig ParsePolicy(string? policyJson)
123+
{
124+
if (string.IsNullOrWhiteSpace(policyJson) || policyJson == "{}")
125+
return AgentPolicyConfig.Default;
126+
127+
try
128+
{
129+
using var doc = JsonDocument.Parse(policyJson);
130+
var root = doc.RootElement;
131+
132+
var allowedTools = new List<string>();
133+
if (root.TryGetProperty("allowedTools", out var toolsElement) && toolsElement.ValueKind == JsonValueKind.Array)
134+
{
135+
foreach (var item in toolsElement.EnumerateArray())
136+
{
137+
var value = item.GetString();
138+
if (!string.IsNullOrWhiteSpace(value))
139+
allowedTools.Add(value);
140+
}
141+
}
142+
143+
var autoApplyLowRisk = false;
144+
if (root.TryGetProperty("autoApplyLowRisk", out var autoApplyElement)
145+
&& autoApplyElement.ValueKind == JsonValueKind.True)
146+
{
147+
autoApplyLowRisk = true;
148+
}
149+
150+
return new AgentPolicyConfig(allowedTools, autoApplyLowRisk);
151+
}
152+
catch (JsonException)
153+
{
154+
return AgentPolicyConfig.Default;
155+
}
156+
}
157+
}
158+
159+
/// <summary>
160+
/// Parsed representation of the agent profile's policy configuration.
161+
/// </summary>
162+
internal sealed record AgentPolicyConfig(
163+
IReadOnlyList<string> AllowedTools,
164+
bool AutoApplyLowRisk)
165+
{
166+
public static AgentPolicyConfig Default { get; } = new(Array.Empty<string>(), false);
167+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
using Taskdeck.Domain.Agents;
2+
3+
namespace Taskdeck.Application.Services;
4+
5+
/// <summary>
6+
/// Evaluates whether a given agent profile is allowed to use a specific tool,
7+
/// and under what constraints (review-first, direct apply, or deny).
8+
/// </summary>
9+
public interface IAgentPolicyEvaluator
10+
{
11+
/// <summary>
12+
/// Evaluate whether the agent profile identified by <paramref name="agentProfileId"/>
13+
/// may invoke the tool identified by <paramref name="toolKey"/> in the given context.
14+
/// </summary>
15+
/// <param name="agentProfileId">The agent profile requesting tool use.</param>
16+
/// <param name="toolKey">The tool registry key to evaluate.</param>
17+
/// <param name="context">Optional contextual metadata (e.g. board ID, item count).</param>
18+
/// <param name="cancellationToken">Cancellation token.</param>
19+
/// <returns>A <see cref="PolicyDecision"/> describing whether the action is allowed.</returns>
20+
Task<PolicyDecision> EvaluateToolUseAsync(
21+
Guid agentProfileId,
22+
string toolKey,
23+
IDictionary<string, string>? context = null,
24+
CancellationToken cancellationToken = default);
25+
}

0 commit comments

Comments
 (0)