Skip to content

Commit a45f14e

Browse files
committed
Add tool registry, policy evaluator, and inbox triage assistant
Implements the Application layer for AGT-02 (#337): - IAgentPolicyEvaluator interface for tool-use policy checks - TaskdeckToolRegistry: in-memory, thread-safe tool registry - AgentPolicyEvaluator: allowlist enforcement, risk-level review gating, auto-apply OFF by default for all risk levels - TaskdeckToolDefinition: concrete ITaskdeckTool record - InboxTriageAssistant: bounded template that creates proposals from pending inbox items, never directly mutating board state
1 parent e2e496b commit a45f14e

5 files changed

Lines changed: 451 additions & 0 deletions

File tree

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+
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+
}
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
using System.Text.Json;
2+
using Microsoft.Extensions.Logging;
3+
using Taskdeck.Application.DTOs;
4+
using Taskdeck.Application.Interfaces;
5+
using Taskdeck.Domain.Agents;
6+
using Taskdeck.Domain.Common;
7+
using Taskdeck.Domain.Entities;
8+
using Taskdeck.Domain.Enums;
9+
using Taskdeck.Domain.Exceptions;
10+
11+
namespace Taskdeck.Application.Services;
12+
13+
/// <summary>
14+
/// Bounded agent template that triages inbox items into proposals.
15+
/// Never directly mutates board state — all changes are routed through
16+
/// the proposal system and policy evaluator.
17+
/// </summary>
18+
public sealed class InboxTriageAssistant
19+
{
20+
/// <summary>Tool key for the inbox triage tool registered in the tool registry.</summary>
21+
public const string ToolKey = "inbox.triage";
22+
23+
/// <summary>Maximum number of inbox items to gather in a single triage run.</summary>
24+
private const int MaxInboxItemsPerRun = 20;
25+
26+
private readonly IUnitOfWork _unitOfWork;
27+
private readonly IAgentPolicyEvaluator _policyEvaluator;
28+
private readonly IAutomationProposalService _proposalService;
29+
private readonly ILogger<InboxTriageAssistant>? _logger;
30+
31+
public InboxTriageAssistant(
32+
IUnitOfWork unitOfWork,
33+
IAgentPolicyEvaluator policyEvaluator,
34+
IAutomationProposalService proposalService,
35+
ILogger<InboxTriageAssistant>? logger = null)
36+
{
37+
_unitOfWork = unitOfWork;
38+
_policyEvaluator = policyEvaluator;
39+
_proposalService = proposalService;
40+
_logger = logger;
41+
}
42+
43+
/// <summary>
44+
/// Run the inbox triage template for a given agent profile and board.
45+
/// Gathers pending inbox items, evaluates policy, and creates a proposal
46+
/// for triage actions. Returns a failure result if policy denies the action
47+
/// or if no actionable items are found.
48+
/// </summary>
49+
public async Task<Result<InboxTriageResultDto>> RunTriageAsync(
50+
Guid agentProfileId,
51+
Guid userId,
52+
Guid boardId,
53+
CancellationToken cancellationToken = default)
54+
{
55+
if (agentProfileId == Guid.Empty)
56+
return Result.Failure<InboxTriageResultDto>(ErrorCodes.ValidationError, "Agent profile ID is required.");
57+
58+
if (userId == Guid.Empty)
59+
return Result.Failure<InboxTriageResultDto>(ErrorCodes.ValidationError, "User ID is required.");
60+
61+
if (boardId == Guid.Empty)
62+
return Result.Failure<InboxTriageResultDto>(ErrorCodes.ValidationError, "Board ID is required.");
63+
64+
// Evaluate policy before proceeding
65+
var policyDecision = await _policyEvaluator.EvaluateToolUseAsync(
66+
agentProfileId,
67+
ToolKey,
68+
new Dictionary<string, string> { ["boardId"] = boardId.ToString() },
69+
cancellationToken);
70+
71+
if (!policyDecision.Allowed)
72+
{
73+
_logger?.LogInformation(
74+
"Inbox triage denied by policy for profile '{ProfileId}': {Reason}",
75+
agentProfileId, policyDecision.Reason);
76+
return Result.Failure<InboxTriageResultDto>(ErrorCodes.Forbidden, policyDecision.Reason);
77+
}
78+
79+
// Gather inbox context: recent pending items for this user
80+
var pendingItems = (await _unitOfWork.LlmQueue.GetByUserAsync(userId, cancellationToken))
81+
.Where(r => r.Status == RequestStatus.Pending)
82+
.OrderBy(r => r.CreatedAt)
83+
.Take(MaxInboxItemsPerRun)
84+
.ToList();
85+
86+
if (pendingItems.Count == 0)
87+
{
88+
_logger?.LogInformation("Inbox triage found no pending items for user '{UserId}'", userId);
89+
return Result.Failure<InboxTriageResultDto>(
90+
ErrorCodes.NotFound, "No pending inbox items to triage.");
91+
}
92+
93+
// Verify board exists
94+
var board = await _unitOfWork.Boards.GetByIdAsync(boardId, cancellationToken);
95+
if (board is null)
96+
{
97+
return Result.Failure<InboxTriageResultDto>(
98+
ErrorCodes.NotFound, $"Board '{boardId}' not found.");
99+
}
100+
101+
// Get the first column to use as the default target
102+
var columns = (await _unitOfWork.Columns.GetByBoardIdAsync(boardId, cancellationToken))
103+
.OrderBy(c => c.Position)
104+
.ToList();
105+
106+
if (columns.Count == 0)
107+
{
108+
return Result.Failure<InboxTriageResultDto>(
109+
ErrorCodes.NotFound, "Board has no columns to triage into.");
110+
}
111+
112+
var defaultColumnId = columns[0].Id;
113+
114+
// Build proposal operations — one create-card per inbox item
115+
var operations = pendingItems.Select((item, i) =>
116+
{
117+
var parameters = JsonSerializer.Serialize(new
118+
{
119+
title = TruncateTitle(item.Payload),
120+
description = $"Triaged from inbox item {item.Id}",
121+
columnId = defaultColumnId,
122+
boardId
123+
});
124+
125+
return new CreateProposalOperationDto(
126+
Sequence: i,
127+
ActionType: "create",
128+
TargetType: "card",
129+
Parameters: parameters,
130+
IdempotencyKey: $"inbox-triage:{item.Id:N}:{boardId:N}");
131+
}).ToList();
132+
133+
// Create the proposal — never directly mutating the board
134+
var summary = pendingItems.Count == 1
135+
? $"Inbox triage: 1 item for board '{board.Name}'"
136+
: $"Inbox triage: {pendingItems.Count} items for board '{board.Name}'";
137+
138+
var createResult = await _proposalService.CreateProposalAsync(
139+
new CreateProposalDto(
140+
SourceType: ProposalSourceType.Queue,
141+
RequestedByUserId: userId,
142+
Summary: summary,
143+
RiskLevel: RiskLevel.Low,
144+
CorrelationId: Guid.NewGuid().ToString(),
145+
BoardId: boardId,
146+
Operations: operations),
147+
cancellationToken);
148+
149+
if (!createResult.IsSuccess)
150+
{
151+
_logger?.LogWarning(
152+
"Inbox triage proposal creation failed for profile '{ProfileId}': {Error}",
153+
agentProfileId, createResult.ErrorMessage);
154+
return Result.Failure<InboxTriageResultDto>(createResult.ErrorCode, createResult.ErrorMessage);
155+
}
156+
157+
_logger?.LogInformation(
158+
"Inbox triage created proposal '{ProposalId}' with {Count} operations (review required: {Review})",
159+
createResult.Value.Id, operations.Count, policyDecision.RequiresReview);
160+
161+
return Result.Success(new InboxTriageResultDto(
162+
createResult.Value.Id,
163+
operations.Count,
164+
policyDecision.RequiresReview,
165+
policyDecision.Reason));
166+
}
167+
168+
private static string TruncateTitle(string input)
169+
{
170+
const int maxLength = 200;
171+
var firstLine = input.Split('\n', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault() ?? input;
172+
var trimmed = firstLine.Trim();
173+
return trimmed.Length > maxLength ? trimmed[..maxLength].TrimEnd() : trimmed;
174+
}
175+
176+
/// <summary>
177+
/// Returns the built-in tool definition for registration in the tool registry.
178+
/// </summary>
179+
public static ITaskdeckTool GetToolDefinition()
180+
{
181+
return new TaskdeckToolDefinition(
182+
Key: ToolKey,
183+
DisplayName: "Inbox Triage",
184+
Description: "Triages pending inbox items into card proposals for a target board.",
185+
Scope: ToolScope.Inbox,
186+
RiskLevel: ToolRiskLevel.Medium);
187+
}
188+
}
189+
190+
/// <summary>
191+
/// Result DTO for an inbox triage run.
192+
/// </summary>
193+
public record InboxTriageResultDto(
194+
Guid ProposalId,
195+
int ItemsTriaged,
196+
bool RequiresReview,
197+
string PolicyReason);

0 commit comments

Comments
 (0)