fix(subagents): harden approval lifecycle - #1217
Conversation
b3af96a to
16998a2
Compare
| // path arguments the agent originally passed, rather than collapsing to cwd. | ||
| IReadOnlyList<ApprovalCandidate> Candidates) : INoSerializationVerificationNeeded; | ||
|
|
||
| internal sealed record ToolInteractionRequestDispatch( |
There was a problem hiding this comment.
This wrapper separates the approval event from the persistence decision. Parent-session approvals can be journaled and redriven after restart; sub-agent approvals are only valid while the child actor is alive. ToolInteractionRequest stays as the UI/client-facing prompt, while ToolInteractionRequestDispatch tells the session actor whether this specific prompt should become durable approval state.
| var tcs = _pending.GetOrAdd(callId, _ => new TaskCompletionSource<ApprovalDecision>(TaskCreationOptions.RunContinuationsAsynchronously)); | ||
| var tcs = new TaskCompletionSource<ApprovalDecision>(TaskCreationOptions.RunContinuationsAsynchronously); | ||
| if (!_pending.TryAdd(callId, tcs)) | ||
| throw new InvalidOperationException($"Approval wait for call '{callId}' is already pending."); |
There was a problem hiding this comment.
This is a fail-fast guard for call-id collisions. The old GetOrAdd behavior could let two waits share the same approval TCS, meaning one user click could release more than one blocked tool call. Duplicate waits should be treated as a bug, not merged.
| /// Returns false when the prompt is no longer backed by a live wait. | ||
| /// </summary> | ||
| void Complete(ToolCallId callId, ApprovalDecision decision); | ||
| bool TryClaim(ToolCallId callId, out ClaimedApprovalWait wait); |
There was a problem hiding this comment.
TryClaim makes “is this prompt still live?” and “remove it from the pending map” one atomic step. We need that before writing any broader approval grant, otherwise a stale or double-clicked prompt could still persist an approval even though no live tool call is waiting for it.
| var waitTask = _channel.WaitForApprovalAsync(callId, Timeout.InfiniteTimeSpan, ct); | ||
| EnsureAuthorityContext(); | ||
|
|
||
| var parentCallId = CreateParentCallId(callId); |
There was a problem hiding this comment.
Sub-agent tool call IDs are child-local, so two sub-agents can both produce the same call ID. The parent approval channel is shared at the session level, so the approval prompt needs a parent-scoped ID that includes the spawning call and a sequence number.
| return new ToolCallId($"{_approvalScopeId}/subagent-approval/{requestId}/{childCallId.Value}"); | ||
| } | ||
|
|
||
| private void EnsureAuthorityContext() |
There was a problem hiding this comment.
A sub-agent approval has to be bound back to the parent requester. If we do not have enough parent authority context, there is no safe user or principal to authorize against, so this fails closed instead of emitting an approval prompt that might be approved by the wrong person.
| return; | ||
| } | ||
|
|
||
| if (!_approvalChannel.TryClaim(msg.CallId, out var approvalWait)) |
There was a problem hiding this comment.
The live path now validates the response first, then claims the live wait, then persists broader grants. That order is intentional: authorization alone is not enough to write a grant. The prompt also has to still correspond to a currently blocked tool call.
| RequestedAtMs = NowMs() | ||
| }; | ||
|
|
||
| if (!dispatch.PersistApprovalState) |
There was a problem hiding this comment.
PersistApprovalState: false means this pending entry is only in-memory routing state for a live sub-agent prompt. Once the live wait is claimed, we clear it instead of journaling ToolApprovalResolved, because there is no durable child actor or tool batch to redrive after restart.
| PrincipalClassification? requesterPrincipalOverride = null, | ||
| IReadOnlyDictionary<string, ApprovalDecision>? decisionOverride = null) | ||
| IReadOnlyDictionary<string, ApprovalDecision>? decisionOverride = null, | ||
| CancellationToken ct = default) |
There was a problem hiding this comment.
Tool execution now uses a session-owned cancellation token. Previously, direct approval waits used CancellationToken.None, so a session stop, restart, or failed turn could leave a tool task blocked until some external timeout. The session actor now owns cancellation for the whole live tool-execution lifecycle.
| /// human approval prompt. The watchdog resumes on the next non-suspending | ||
| /// activity item or terminal completion. | ||
| /// </summary> | ||
| public bool SuspendsInactivityWatchdog { get; init; } |
There was a problem hiding this comment.
Waiting on a human approval is not a stalled tool. This flag lets the parent streaming watchdog pause while the sub-agent is legitimately blocked on approval, then resume when normal activity or completion comes through.
| } | ||
| catch (ToolApprovalRequiredException) | ||
| { | ||
| throw new ParentApprovalUnavailableException( |
There was a problem hiding this comment.
Missing parent approval plumbing is a wiring/security failure, not a normal tool error. Throwing ParentApprovalUnavailableException fails the sub-agent run loudly instead of returning an Error: tool result that the LLM might treat as recoverable and continue past.
| { | ||
| _executionCts?.Cancel(); | ||
| _externalCts?.Cancel(); | ||
| if (!_completed) |
There was a problem hiding this comment.
If the actor is stopped externally while the parent is waiting on an Ask, the caller still needs a terminal result. The _completed guard prevents double replies when normal completion and PostStop race.
|
|
||
| private ToolCallId CreateParentCallId(ToolCallId childCallId) | ||
| { | ||
| var requestId = Interlocked.Increment(ref _nextApprovalRequestId); |
There was a problem hiding this comment.
This is intentionally not actor-confined state. The bridge is created by the session actor, then used by sub-agent/tool tasks on the thread pool, and multiple child tool calls can request approval concurrently. Interlocked.Increment keeps the parent-visible approval IDs unique without adding an actor round trip just to allocate a sequence number.
| // Child call ids are only unique inside the sub-agent's tool loop. The | ||
| // parent approval channel is session-wide, so include the spawning tool | ||
| // call scope plus a per-bridge sequence before the child-local id. | ||
| return new ToolCallId($"{_approvalScopeId}/subagent-approval/{requestId}/{childCallId.Value}"); |
| // Approval responses are authorized against the parent requester. If we | ||
| // cannot reconstruct that authority context, emitting a prompt would let | ||
| // the channel decide without a safe requester binding. | ||
| if (_requesterPrincipal is null) |
There was a problem hiding this comment.
this can happen in instances like skills that request a specific sub-agent for execution. It's a headless session without a parent, so there is no one to run the approval bridge.
| private void EmitActivity(string phase, bool suspendsInactivityWatchdog = false) | ||
| => _activitySink?.TryWrite(new ToolActivityUpdate(phase) | ||
| { | ||
| SuspendsInactivityWatchdog = suspendsInactivityWatchdog |
There was a problem hiding this comment.
signal that we are waiting for a human, not timing out due to LLM inference provider issues
Summary
redesign-subagent-approval-lifecycleOpenSpec change for Redesign sub-agent approval lifecycle #1212.Validation
dotnet test "src/Netclaw.Actors.Tests/Netclaw.Actors.Tests.csproj" --filter "FullyQualifiedName~SubAgentActorTests|FullyQualifiedName~ParentSessionApprovalBridgeTests|FullyQualifiedName~ToolApprovalGateTests"openspec validate "redesign-subagent-approval-lifecycle" --strictdotnet slopwatch analyzepwsh "./scripts/Add-FileHeaders.ps1" -Verifygit diff --checkFixes #1212