Skip to content

feat(agent): signal-based HITL approval gate for client-side tools#112

Closed
runyaga wants to merge 3 commits intosoliplex:mainfrom
runyaga:feat/hitl-tool-approval
Closed

feat(agent): signal-based HITL approval gate for client-side tools#112
runyaga wants to merge 3 commits intosoliplex:mainfrom
runyaga:feat/hitl-tool-approval

Conversation

@runyaga
Copy link
Copy Markdown
Contributor

@runyaga runyaga commented Apr 14, 2026

Summary

Replaces AgentUiDelegate (callback-based, single-tool approval) with
a signal-driven Human-in-the-Loop gate that scales to concurrent tool
calls and integrates cleanly with the existing signals reactive layer.

  • ClientTool.requiresApproval: bool — when true, AgentSession
    suspends execution before the tool executor runs, emits a
    PendingApprovalRequest on a signal, and waits for approveToolCall
    or denyToolCall to resume. Intended for high-risk operations like
    execute_python.

  • ClientTool.platformConsentNote: String? Function()? — optional
    callback for tools that trigger an OS-level permission dialog (e.g.
    clipboard read on web triggers a browser prompt; silent on native).
    Returns a human-readable note; AgentSession emits a
    PlatformConsentNotice event (non-blocking, purely informational).

  • AgentSession gains pendingApproval (ReadonlySignal<PendingApprovalRequest?>),
    approveToolCall(String), and denyToolCall(String). The gate uses a
    Completer<bool> per toolCallId; auto-denies on session cancel.

  • PendingApprovalRequest — new immutable data class
    (toolCallId, toolName, arguments).

  • PlatformConsentNotice / AwaitingApproval — new
    ExecutionEvent subclasses for the consent/approval lifecycle.

  • session_extension.dart: onDispose() renamed to dispose() for
    consistency with Dart disposal conventions.

  • Deleted: AgentUiDelegate (58 lines) and its 457-line test.

Tests

hitl_test.dart — 12 new tests:

  • requiresApproval defaults for all three tool constructors
  • Three approval tier descriptions (agent-gate, OS-gate, ungated)
  • PendingApprovalRequest field values
  • platformConsentNote defaults, non-null return, null return
  • PlatformConsentNotice field values and equality

All 461 existing unit tests continue to pass. DCM clean.

Test plan

  • dart analyze --fatal-infos passes (no warnings)
  • dcm analyze passes (no issues)
  • dart test --exclude-tags integration --reporter failures-only — 461 pass
  • Review tool_registry.dart doc comments for the three approval tiers
  • Review agent_session.dart _awaitApproval for cancel/deny edge cases

🤖 Generated with Claude Code

runyaga and others added 3 commits April 14, 2026 14:18
Replace AgentUiDelegate (callback-based, single-tool) with a
signal-driven Human-in-the-Loop gate that scales to concurrent
tool calls and integrates cleanly with the signals reactive layer.

## What changed

**ClientTool API** (`tool_registry.dart`):
- `requiresApproval: bool` — when true, AgentSession suspends
  execution before the tool executor runs and emits a
  PendingApprovalRequest; the UI must call approveToolCall or
  denyToolCall to resume.
- `platformConsentNote: String? Function()?` — optional callback for
  tools that trigger an OS-level permission dialog (e.g. clipboard
  read on web). Returns a human-readable note; AgentSession emits
  PlatformConsentNotice (non-blocking, informational).

**New types**:
- `PendingApprovalRequest` — immutable data class (toolCallId,
  toolName, arguments) emitted on pendingApproval signal.
- `PlatformConsentNotice` / `AwaitingApproval` — ExecutionEvent
  subclasses for consent/approval lifecycle.

**AgentSession** (`agent_session.dart`):
- `pendingApproval: ReadonlySignal<PendingApprovalRequest?>` — UI
  watches this to render Allow/Deny UI.
- `approveToolCall(String) / denyToolCall(String)` — resolves the
  Completer gating the suspended tool call.
- `_awaitApproval()` — internal gate; stores Completer per
  toolCallId in _pendingApprovals, signals UI, awaits resolution.
  Auto-denies on session cancel.

**Deleted**:
- `AgentUiDelegate` (58 lines) + its 457-line test file.
  Replaced entirely by the signal approach above.

**session_extension.dart**: `onDispose()` → `dispose()` rename for
consistency with Dart disposal conventions.

**Tests** (`hitl_test.dart`): 12 tests covering requiresApproval
defaults, three approval tiers (agent-gate / OS-gate / ungated),
PendingApprovalRequest fields, platformConsentNote callbacks, and
PlatformConsentNotice equality.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The HITL commit renamed SessionExtension.onDispose() to dispose() but
left ScriptEnvironmentExtension and its tests pointing at the old name.
Three targeted fixes to compile cleanly without the scripting refactor:

- script_environment.dart: onDispose() → dispose() in the
  ScriptEnvironmentExtension adapter (keeps it implementing the renamed
  SessionExtension interface).
- agent_session_test.dart: revert 'ScriptEnvironment satisfies
  SessionExtension' back to 'ScriptEnvironmentExtension adapter
  lifecycle' (scripting refactor is a separate PR).
- concurrency_experiments_test.dart: restore ScriptEnvironmentExtension
  wrappers around _FakeBridgeScriptEnvironment at all 6 usage sites
  (fakes implement ScriptEnvironment, not SessionExtension directly).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ExecutionEvent is a sealed class — the switch in ExecutionTracker must
be exhaustive. PlatformConsentNotice (added in the HITL commit) was
missing from the no-op arm.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@runyaga runyaga closed this Apr 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant