Skip to content

Commit ebaa4ff

Browse files
LeftTwixWandclaude
andauthored
ApproverAgent authorization loop + ProposeOptions interactive UX (#36)
Replaces three disconnected approval systems (ApprovalGateGrain, UISession.RegisterApproval, orphaned NotificationService.SendApprovalAsync) with a single agent-based primitive: ApproverAgent is a per-user reentrant Orleans grain that decides tool authorization dynamically via its own Fast-tier LLM, stores natural-language policies in durable state, and drives an end-to-end Telegram inline-keyboard flow (ApprovalRequested → Telegram ↔ callback → ResolveApproval). Every tool invocation now flows through a GatedAIFunction wrapper that blocks execution until the Approver allows or denies. There are no hardcoded risk levels, denylists, or timeouts — the LLM is the judge, guided only by its system prompt and stored policies, which are written and removed conversationally via new Thread tools (AddApproverPolicy / RemoveApproverPolicy / ListApproverPolicies). ProposeOptions is a new Thread-level tool whose implementation is ~10 lines: it pushes an OptionsPart onto a per-turn hint list that ResponseStreamer drains and renders as inline-keyboard buttons. The < 200 chars short-circuit in ResponseStreamer is dropped; RichContentParser gains an A)/B) fallback so lettered LLM prose still renders as buttons. Thread.AgentInstructions gains a USER INTERACTION section telling the LLM to always use ProposeOptions instead of inlining choices. Security hardening from the post-implementation review: - [Reentrant] on ApproverAgent so the blocking Authorize TCS can be completed from a concurrent ResolveApproval call on the same grain. - TCS waiter registered before publishing the event to close the race window. - ExtractUserIdFromGrainKey requires a numeric head so non-user grains (CodeOrchestrator, AgentRegistry) don't accidentally bind to a bogus IApprover. - ExtractThreadIdFromGrainKey strips sub-agent interface suffixes so thread-scoped policies actually match sub-agent tool calls. - Approver LLM pulls recent turn snippets from the Thread grain (not the sub-agent) so localized button labels reflect the user's actual language. - DiscoverInterfaceToolsEnabled = false on ApproverAgent prevents self-resolution loops via auto-exposed IApprover methods. - Approval callback ownership check — only the user who owns an approval can resolve it; other users' taps are rejected. - GatedAIFunction redacts api_key / token / password / authorization / bearer strings from the args preview before it leaves the silo for the Approver LLM. Deleted: ApprovalGateGrain, IApprovalGate, ApprovalRequest, ApprovalDecision, PendingApproval, ApprovalResult, UISession.RegisterApproval/ResolveApproval, ApprovalGateTests. The two UISessionTests approval cases are removed; the two Phase2IntegrationTests approval cases are removed; 5 new ApproverAgentTests cover AddPolicy, ListPolicies, RemovePolicy-empty, ResolveApproval no-op, Thread-scoped policy storage. Full verification: build clean (0 warnings, 0 errors), Core.Tests 467 passed, Integration.Tests 7 passed, Aspire AppHost boots all 13 resources Running & Healthy, Telegram logs "Subscribed to ... approval streams". Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 7f15f33 commit ebaa4ff

31 files changed

Lines changed: 1156 additions & 329 deletions

src/Agents/Orchestration/IThread.cs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,25 @@ Specialized agents handle everything. Your job is to delegate.
2727
- NEVER say "I can't do X" if an available agent can. Delegate instead.
2828
- Include the FULL original request with all paths and details when delegating.
2929
- Be concise and direct.
30+
31+
USER INTERACTION:
32+
- When the user needs to make a choice, call the ProposeOptions tool with a short prompt
33+
and up to 8 option labels. The user will see these as buttons next to your reply and
34+
may tap one OR type a custom answer.
35+
- NEVER format choices inline as "A)" / "B)" / "1." / "2." — the UI will not render
36+
them as buttons. Always use the ProposeOptions tool instead.
37+
- When the user asks you to remember a preference about approvals ("don't ask me about
38+
builds anymore", "always allow read-only shell"), call AddApproverPolicy with the
39+
appropriate scope ("Thread" for this conversation, "User" for everywhere).
40+
- When the user asks what you've learned, call ListApproverPolicies and render them in
41+
a human-readable form.
42+
- When the user asks you to forget a learned preference, call RemoveApproverPolicy with
43+
a short description of what to remove.
3044
""";
3145

3246
Task<string?> GetTitle(CancellationToken ct);
3347
Task<List<MediaPart>> GetPendingDeliveries(CancellationToken ct = default);
48+
Task<IReadOnlyList<UIPart>> GetPendingUIHints(CancellationToken ct = default);
3449
Task StartTaskDigestAsync(string taskId, TimeSpan interval, CancellationToken ct = default);
3550
Task StopTaskDigestAsync(string taskId, CancellationToken ct = default);
3651
}

src/Agents/Orchestration/ThreadAgent.cs

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
using Core;
22
using Core.Context;
33
using Core.Contracts;
4+
using Core.Contracts.Security;
45
using Core.Registry;
56
using Core.UI;
7+
using IAW.Agents.Security;
68
using IAW.Core;
79
using Microsoft.Extensions.AI;
810
using Microsoft.Extensions.DependencyInjection;
911
using Microsoft.Extensions.Logging;
1012
using Qdrant.Client;
13+
using System.ComponentModel;
14+
using System.Text;
1115
using System.Text.Json;
1216
using AgentResponse = global::Core.UI.AgentResponse;
1317

@@ -64,10 +68,77 @@ protected override IReadOnlyList<AITool> DefineAdditionalTools()
6468

6569
AIFunctionFactory.Create(OrchestrateAsync, "Orchestrate",
6670
"For complex multi-step tasks requiring coordination across 3+ agents. " +
67-
"NOT needed for single-agent tasks — use SendToAgent instead.")
71+
"NOT needed for single-agent tasks — use SendToAgent instead."),
72+
73+
CreateProposeOptionsTool(),
74+
75+
AIFunctionFactory.Create(AddApproverPolicyAsync, "AddApproverPolicy",
76+
"Teach the Approver a new permission rule in natural language. Use when the user says things like " +
77+
"'don't ask me about builds anymore' or 'always allow git status'. " +
78+
"scope must be 'Thread' (only this conversation), 'User' (all conversations), or 'Once' (single use)."),
79+
80+
AIFunctionFactory.Create(RemoveApproverPolicyAsync, "RemoveApproverPolicy",
81+
"Remove a previously-stored approval policy. Pass a natural-language description of which policy to remove; " +
82+
"the Approver will semantically match it."),
83+
84+
AIFunctionFactory.Create(ListApproverPoliciesAsync, "ListApproverPolicies",
85+
"List all stored approval policies the Approver has learned so far for this user.")
6886
];
6987
}
7088

89+
public Task<IReadOnlyList<UIPart>> GetPendingUIHints(CancellationToken ct = default)
90+
=> Task.FromResult(DrainPendingUIHints());
91+
92+
[Description("Store a natural-language approval policy")]
93+
async Task<string> AddApproverPolicyAsync(
94+
[Description("Scope: Once, Thread, or User")] string scope,
95+
[Description("The policy rule in natural language")] string rule,
96+
CancellationToken ct = default)
97+
{
98+
var userId = ExtractUserId();
99+
if (userId is null) return "No user context available for policy storage.";
100+
101+
var approver = GrainFactory.GetGrain<IApprover>(userId);
102+
return await approver.AddPolicy(scope, this.GetPrimaryKeyString(), rule, ct);
103+
}
104+
105+
[Description("Remove a previously-stored approval policy matched by a natural-language query")]
106+
async Task<string> RemoveApproverPolicyAsync(
107+
[Description("Short description of which policy to remove")] string query,
108+
CancellationToken ct = default)
109+
{
110+
var userId = ExtractUserId();
111+
if (userId is null) return "No user context available.";
112+
113+
var approver = GrainFactory.GetGrain<IApprover>(userId);
114+
return await approver.RemovePolicy(query, ct);
115+
}
116+
117+
[Description("List all approval policies learned for the current user")]
118+
async Task<string> ListApproverPoliciesAsync(CancellationToken ct = default)
119+
{
120+
var userId = ExtractUserId();
121+
if (userId is null) return "No user context available.";
122+
123+
var approver = GrainFactory.GetGrain<IApprover>(userId);
124+
var policies = await approver.ListPolicies(ct);
125+
if (policies.Count == 0)
126+
return "No policies stored yet.";
127+
128+
var sb = new StringBuilder();
129+
foreach (var policy in policies)
130+
sb.AppendLine($"- [{policy.Scope}] {policy.Rule}");
131+
return sb.ToString().TrimEnd();
132+
}
133+
134+
string? ExtractUserId()
135+
{
136+
var key = this.GetPrimaryKeyString();
137+
var slashIndex = key.IndexOf('/');
138+
if (slashIndex > 0) return key[..slashIndex];
139+
return long.TryParse(key, out _) ? key : null;
140+
}
141+
71142
private async Task<string> SendToAgentAsync(string agentName, string request, CancellationToken ct = default)
72143
{
73144
logger.LogInformation("SendToAgent: {Agent} for: {Request}",

0 commit comments

Comments
 (0)