ApproverAgent authorization loop + ProposeOptions interactive UX#36
Merged
Conversation
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>
This was referenced Apr 9, 2026
LeftTwixWand
added a commit
that referenced
this pull request
Apr 9, 2026
Replace discovery-time GatedAIFunction with MAF function-calling middleware,
introduce automatic memory via a QdrantClient-backed MessageAIContextProvider,
delete the over-partitioned memory/preference/knowledge agents, and migrate
all context providers to MAF's AIContextProvider pipeline.
## Authorization
- Delete GatedAIFunction and its per-tool wrap logic.
- Add Agent.Authorization.cs with ToolApprovalMiddleware: registered via
`.AsBuilder().Use(...).Build()` on the AIAgent produced by AsAIAgent.
Denies terminate the call with context.Terminate = true; any grain exception
fails closed as Deny + telemetry (never silently Allow).
- ApproverAgent adds a memo table keyed by SHA-256(toolName|argsJson) fingerprint.
Memo hits return cached Allow decisions without an LLM call. Memo writes only
happen on ResolveApproval with scope Thread/User.
- Approver holds DelayDeactivation(5min) across the await tcs.Task HITL wait
and asserts the pending entry's UserId matches its grain key before resolving.
- Override ResolveApproverGrainKey() => null on Approver so its own LLM calls
bypass the authorization middleware (prevents infinite recursion).
- Simplify ToolAuthorizationRequest to
(AgentId, AgentDisplayName, ToolName, ArgumentsJson, RecentMessages).
Threadid is derived at judgment time from the sub-agent grain key.
- New telemetry counters: ApproverFailures, ApproverDenies, ApproverMemoHits,
ApproverLlmJudgments.
- Remove ToolAuthorizationRequested / ToolAuthorized event constants (only
ToolDenied is published now).
## Memory
- Delete the five over-partitioned memory agents (User/Project/Episode/
Pattern/Code) plus MemoryAgentBase, IMemoryAgent, MemoryEntry, and
MemoryContextProvider. Delete PreferenceAgent / IPreference /
PreferenceRule / PreferenceContextProvider. Delete KnowledgeAgent /
IKnowledge — its typed data folds into memory with optional tags.
- Add src/Core/Memory: MemoryHit, IMemoryLookup, IawMemoryProvider.
IawMemoryProvider : MessageAIContextProvider + IMemoryLookup.
Uses QdrantClient DIRECTLY (same pattern as RAGContextProvider and
Agent.IngestChunksAsync) — no new NuGet packages, no SK adapters.
Per-user collection `user-memory-{userId}` with payload fields
content/userId/threadId/role/createdAtTicks/sourceTelegramMsgId.
- ProvideMessagesAsync injects top-5 relevant memories as a single
ChatRole.System "## Memories" message. StoreAIContextAsync persists
each new request/response message with embedding + metadata.
- LookupOriginAsync searches top-1 for explainability and maps to MemoryHit.
- Add ForwardMessageHint UIPart; Thread adds an Explain tool that calls
IMemoryLookup.LookupOriginAsync, pushes the hint to pending UI hints, and
returns the stored text + timestamp.
- TelegramMessageSender gains ForwardMessageAsync; ResponseStreamer calls it
for every ForwardMessageHint emitted during a turn so the user sees the
original message forwarded back to them.
- Telegram layer stamps `message.MessageId` onto
`ChatMessage.SourceTelegramMsgId` when handing off to Thread; Agent.cs
carries it through `ProduceLlmStreamAsync` by constructing an
M.E.AI ChatMessage with AdditionalProperties["iaw.sourceTelegramMsgId"].
- Register IawMemoryProvider + IMemoryLookup in IAWSiloExtensions, resolve
it in Agent.OnActivateAsync and add to ChatClientAgentOptions.AIContextProviders.
- Remove IUserProfile.RememberFact / RecallFacts (and all callers) — memory
is now a concern of IawMemoryProvider, not UserProfile.
- Wire IMemoryLookup into ExplainabilityAgent alongside Approver policy search.
## Context providers → MAF AIContextProvider
- Delete IAgentContextProvider interface entirely. Single pipeline, no parallel
plumbing.
- Migrate UserContextProvider, RAGContextProvider, AgentRoutingContextProvider,
and PolicyContextProvider to `MessageAIContextProvider`. All read userId /
threadId from `AIAgent.CurrentRunContext?.Session?.StateBag` via a shared
ContextProviderIdentity helper.
- Delete orphan providers (Project, Task, TaskLedger, TaskStream, TaskResult)
that had no wire-up — their tests went with them. EventFlowIntegrationTests
now calls ledger.GetContextBlockAsync directly.
- Delete Agent.GetContextProviders/BuildContextBlock + the instructions
concatenation in StreamResponseCore. Providers are now registered via
ChatClientAgentOptions.AIContextProviders at activation, plus a
`GetAdditionalAIContextProviders()` virtual hook for thread-specific
providers (Thread adds RAG + AgentRouting).
## Identity plumbing
- Agent.OnActivateAsync parses userId/threadId from the grain key and pushes
them to `session.StateBag["iaw.userId"]` / `"iaw.threadId"` right after
CreateSessionAsync, so every provider can read the same identity regardless
of which grain it runs inside.
## Tests
- Rewrite ApproverAgentTests: allow/deny/ask judgment coverage, ask-flow
pending entry, ResolveApproval memo write, second Authorize hits memo
without reissuing ApprovalRequested, ResolveApproval cross-user rejection.
- Remove MemoryAgentBase / MemoryAgent / PreferenceAgent / Explainability /
MemoryEntry / MemoryBase / orphan context-provider tests.
- Trim UserProfileTests (RememberFact/RecallFacts gone) and
ArchitectureGuardV2Tests (memory base check gone).
- EventFlowIntegrationTests exercises ledger.GetContextBlockAsync directly.
Builds cleanly, `dotnet test test/Core.Tests` and
`dotnet test test/Integration.Tests` green. Tested live through the Aspire
MCP: assistant / mcp / devui / telegram rebuilt and run Healthy with the new
wiring. Precursor: #36 (ApproverAgent authorization loop + ProposeOptions UX).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
7 tasks
LeftTwixWand
added a commit
that referenced
this pull request
Apr 20, 2026
* MAF middleware authorization + IawMemoryProvider architectural refactor
Replace discovery-time GatedAIFunction with MAF function-calling middleware,
introduce automatic memory via a QdrantClient-backed MessageAIContextProvider,
delete the over-partitioned memory/preference/knowledge agents, and migrate
all context providers to MAF's AIContextProvider pipeline.
## Authorization
- Delete GatedAIFunction and its per-tool wrap logic.
- Add Agent.Authorization.cs with ToolApprovalMiddleware: registered via
`.AsBuilder().Use(...).Build()` on the AIAgent produced by AsAIAgent.
Denies terminate the call with context.Terminate = true; any grain exception
fails closed as Deny + telemetry (never silently Allow).
- ApproverAgent adds a memo table keyed by SHA-256(toolName|argsJson) fingerprint.
Memo hits return cached Allow decisions without an LLM call. Memo writes only
happen on ResolveApproval with scope Thread/User.
- Approver holds DelayDeactivation(5min) across the await tcs.Task HITL wait
and asserts the pending entry's UserId matches its grain key before resolving.
- Override ResolveApproverGrainKey() => null on Approver so its own LLM calls
bypass the authorization middleware (prevents infinite recursion).
- Simplify ToolAuthorizationRequest to
(AgentId, AgentDisplayName, ToolName, ArgumentsJson, RecentMessages).
Threadid is derived at judgment time from the sub-agent grain key.
- New telemetry counters: ApproverFailures, ApproverDenies, ApproverMemoHits,
ApproverLlmJudgments.
- Remove ToolAuthorizationRequested / ToolAuthorized event constants (only
ToolDenied is published now).
## Memory
- Delete the five over-partitioned memory agents (User/Project/Episode/
Pattern/Code) plus MemoryAgentBase, IMemoryAgent, MemoryEntry, and
MemoryContextProvider. Delete PreferenceAgent / IPreference /
PreferenceRule / PreferenceContextProvider. Delete KnowledgeAgent /
IKnowledge — its typed data folds into memory with optional tags.
- Add src/Core/Memory: MemoryHit, IMemoryLookup, IawMemoryProvider.
IawMemoryProvider : MessageAIContextProvider + IMemoryLookup.
Uses QdrantClient DIRECTLY (same pattern as RAGContextProvider and
Agent.IngestChunksAsync) — no new NuGet packages, no SK adapters.
Per-user collection `user-memory-{userId}` with payload fields
content/userId/threadId/role/createdAtTicks/sourceTelegramMsgId.
- ProvideMessagesAsync injects top-5 relevant memories as a single
ChatRole.System "## Memories" message. StoreAIContextAsync persists
each new request/response message with embedding + metadata.
- LookupOriginAsync searches top-1 for explainability and maps to MemoryHit.
- Add ForwardMessageHint UIPart; Thread adds an Explain tool that calls
IMemoryLookup.LookupOriginAsync, pushes the hint to pending UI hints, and
returns the stored text + timestamp.
- TelegramMessageSender gains ForwardMessageAsync; ResponseStreamer calls it
for every ForwardMessageHint emitted during a turn so the user sees the
original message forwarded back to them.
- Telegram layer stamps `message.MessageId` onto
`ChatMessage.SourceTelegramMsgId` when handing off to Thread; Agent.cs
carries it through `ProduceLlmStreamAsync` by constructing an
M.E.AI ChatMessage with AdditionalProperties["iaw.sourceTelegramMsgId"].
- Register IawMemoryProvider + IMemoryLookup in IAWSiloExtensions, resolve
it in Agent.OnActivateAsync and add to ChatClientAgentOptions.AIContextProviders.
- Remove IUserProfile.RememberFact / RecallFacts (and all callers) — memory
is now a concern of IawMemoryProvider, not UserProfile.
- Wire IMemoryLookup into ExplainabilityAgent alongside Approver policy search.
## Context providers → MAF AIContextProvider
- Delete IAgentContextProvider interface entirely. Single pipeline, no parallel
plumbing.
- Migrate UserContextProvider, RAGContextProvider, AgentRoutingContextProvider,
and PolicyContextProvider to `MessageAIContextProvider`. All read userId /
threadId from `AIAgent.CurrentRunContext?.Session?.StateBag` via a shared
ContextProviderIdentity helper.
- Delete orphan providers (Project, Task, TaskLedger, TaskStream, TaskResult)
that had no wire-up — their tests went with them. EventFlowIntegrationTests
now calls ledger.GetContextBlockAsync directly.
- Delete Agent.GetContextProviders/BuildContextBlock + the instructions
concatenation in StreamResponseCore. Providers are now registered via
ChatClientAgentOptions.AIContextProviders at activation, plus a
`GetAdditionalAIContextProviders()` virtual hook for thread-specific
providers (Thread adds RAG + AgentRouting).
## Identity plumbing
- Agent.OnActivateAsync parses userId/threadId from the grain key and pushes
them to `session.StateBag["iaw.userId"]` / `"iaw.threadId"` right after
CreateSessionAsync, so every provider can read the same identity regardless
of which grain it runs inside.
## Tests
- Rewrite ApproverAgentTests: allow/deny/ask judgment coverage, ask-flow
pending entry, ResolveApproval memo write, second Authorize hits memo
without reissuing ApprovalRequested, ResolveApproval cross-user rejection.
- Remove MemoryAgentBase / MemoryAgent / PreferenceAgent / Explainability /
MemoryEntry / MemoryBase / orphan context-provider tests.
- Trim UserProfileTests (RememberFact/RecallFacts gone) and
ArchitectureGuardV2Tests (memory base check gone).
- EventFlowIntegrationTests exercises ledger.GetContextBlockAsync directly.
Builds cleanly, `dotnet test test/Core.Tests` and
`dotnet test test/Integration.Tests` green. Tested live through the Aspire
MCP: assistant / mcp / devui / telegram rebuilt and run Healthy with the new
wiring. Precursor: #36 (ApproverAgent authorization loop + ProposeOptions UX).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
* CLAUDE.md: add brainstorming & design conversations section
Codifies the working mode for design conversations — sharpening
questions, honest pushback, distinct prototypes, closing the loop into
phase-1 plans. Switch to execution mode only on explicit "go"/"build".
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Replaces three disconnected approval systems with a single agent-based primitive and adds a
ProposeOptionstool that makes Telegram inline keyboards actually render.[Reentrant]Orleans grain backed by a Fast-tier LLM. Decides tool authorization dynamically from a natural-language policy list stored in durable state. No hardcoded risk levels, no hardcoded denylist, no timeouts — the LLM is the judge.approver.Authorize(...)before invocation. Policy/UI tools are exempt so they don't waste LLM turns.ApprovalRequested→ Telegram subscriber renders a localized inline keyboard → user taps →CallbackRouterresolves directly viaIApprover.ResolveApproval→ApprovalResolved→ original message edited.allow_once/allow_thread/allow_user/deny. The Approver LLM produces labels in the user's conversation language (pulled from the Thread grain's history, not the sub-agent's).AddApproverPolicy/RemoveApproverPolicy/ListApproverPoliciestools on Thread that the LLM calls when the user says things like "don't ask me about builds anymore".OptionsPartonto a per-turn hint list.ResponseStreamerdrains it and renders buttons. The< 200 charsshort-circuit is dropped.RichContentParsergains anA)/B)letter fallback.Thread.AgentInstructionsgains aUSER INTERACTIONsection.Deleted
ApprovalGateGrain,IApprovalGate,ApprovalRequest,ApprovalDecision,PendingApproval,ApprovalResult,UISession.RegisterApproval/ResolveApproval,ApprovalGateTests— all replaced by the single ApproverAgent path.Security hardening (post-code-review)
[Reentrant]on ApproverAgent — without this,Authorizeawaiting its TCS would deadlock against theResolveApprovalgrain call that needs to complete it.ExtractUserIdFromGrainKeyrequires a numeric head — non-user grains (CodeOrchestrator, AgentRegistry) no longer bind to bogus Approvers.ExtractThreadIdFromGrainKeystrips sub-agent interface suffixes so Thread-scoped policies actually match sub-agent tool calls.DiscoverInterfaceToolsEnabled = falseon ApproverAgent prevents auto-exposedIApprovermethods from becoming self-callable tools.GatedAIFunction.BuildArgumentsPreviewredactsapi_key/token/password/authorization/Bearer …before sending args to the Approver LLM.Test plan
dotnet build IAW.slnx— 0 warnings, 0 errorsdotnet test test/Core.Tests— 467 passed, 1 skipped (includes 5 new ApproverAgentTests)dotnet test test/Integration.Tests— 7 passedaspire start— all 13 resources Running & HealthySubscribed to notification, job completed, orchestration progress, and approval streamsProposeOptions→ verify inline keyboard rendersallow_thread→ verify policy stored + tool proceedsAddApproverPolicytool is called and persists🤖 Generated with Claude Code