Skip to content

fix(subagents): harden approval lifecycle - #1217

Merged
Aaronontheweb merged 6 commits into
devfrom
fix/1212-subagent-approval-lifecycle
May 28, 2026
Merged

fix(subagents): harden approval lifecycle#1217
Aaronontheweb merged 6 commits into
devfrom
fix/1212-subagent-approval-lifecycle

Conversation

@Aaronontheweb

Copy link
Copy Markdown
Collaborator

Summary

  • Add the redesign-subagent-approval-lifecycle OpenSpec change for Redesign sub-agent approval lifecycle #1212.
  • Fail missing parent approval bridges closed as terminal failed subagent runs without executing gated tools.
  • Harden subagent approval waits for watchdog pause/resume, approve-once isolation, denied/timed-out tool results, cancellation, external stop, parallel waits, and late approval expiry.
  • Update the subagents runbook with user-visible approval behavior.

Validation

  • dotnet test "src/Netclaw.Actors.Tests/Netclaw.Actors.Tests.csproj" --filter "FullyQualifiedName~SubAgentActorTests|FullyQualifiedName~ParentSessionApprovalBridgeTests|FullyQualifiedName~ToolApprovalGateTests"
  • openspec validate "redesign-subagent-approval-lifecycle" --strict
  • dotnet slopwatch analyze
  • pwsh "./scripts/Add-FileHeaders.ps1" -Verify
  • git diff --check

Fixes #1212

@Aaronontheweb Aaronontheweb added the subagents spawn_agent, SubAgentActor, definition loader, discovery context layer, and related features label May 28, 2026
@Aaronontheweb
Aaronontheweb force-pushed the fix/1212-subagent-approval-lifecycle branch from b3af96a to 16998a2 Compare May 28, 2026 19:33
// path arguments the agent originally passed, rather than collapsing to cwd.
IReadOnlyList<ApprovalCandidate> Candidates) : INoSerializationVerificationNeeded;

internal sealed record ToolInteractionRequestDispatch(

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.");

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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()

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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))

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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; }

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@Aaronontheweb Aaronontheweb left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

// 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}");

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

makes sense

// 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)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

signal that we are waiting for a human, not timing out due to LLM inference provider issues

@Aaronontheweb
Aaronontheweb merged commit d50eb9c into dev May 28, 2026
20 checks passed
@Aaronontheweb
Aaronontheweb deleted the fix/1212-subagent-approval-lifecycle branch May 28, 2026 20:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

subagents spawn_agent, SubAgentActor, definition loader, discovery context layer, and related features

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Redesign sub-agent approval lifecycle

1 participant