Skip to content

Agent loop reliability: UIA3 migration, See→Think→Act hardening, and richer agent diagnostics - #16

Merged
Donsezan merged 16 commits into
mainfrom
features/agent-loop-reliability
Jun 13, 2026
Merged

Donsezan merged 16 commits into
mainfrom
features/agent-loop-reliability

Conversation

@Donsezan

Copy link
Copy Markdown
Owner

This branch makes the gRPC UI Automation service substantially more reliable for LLM-driven See → Think → Act loops. The headline change is migrating the engine from the deprecated managed UIA2 (System.Windows.Automation) to UIA3 via FlaUI 5.0, alongside a set of features that remove the slow, token-hungry client-side polling patterns agents previously had to use and make element addressing, input injection, and process resolution far more robust.

+3468 / −444 across 44 files, including a new live UI smoke test suite and a CI job to run it.

NIk and others added 16 commits June 12, 2026 02:04
- document UiaExecutor single MTA worker + bounded queue
- document Features:AppStructure tuning and CacheRequest batching
- add Session 0 guidance, Skill project row, broadened test coverage
- fix stale claims (token-auth startup warning, per-project .sln layout)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ment

Server-side polling wait replaces the client-side find/retry loop that cost
an LLM round trip per probe. The wait loop runs OFF the single UIA worker:
each probe is enqueued as its own short work item with Task.Delay between
probes on the request thread, so long waits never starve other RPCs.
ResourceExhausted during a probe counts as a miss, not a failure.

Timing is normalized by WaitPolicy (default 10s/250ms, caps 120s / 50ms-5s)
with unit tests, plus service-level timeout/immediate-hit tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Packaged apps launched via an alias (calc) return a launcher PID that exits
within a second, making every PID-addressed follow-up fail. OpenApp now
snapshots visible top-level windows (EnumWindows, no UIA) before launching;
when the launched process dies inside the 500 ms grace window it polls up to
3 s for a newly-appeared window and returns its owner PID instead. Classic
Win32 apps keep the fast path. Best-effort: ambiguous candidates resolve to
the most recently started process, and an unresolvable exit is flagged in
the response message.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nWithStructure

New proto StructureOptions (max_depth, max_nodes, include_offscreen,
scope_runtime_id, diff_mode) on AppStructureRequest and PerformActionRequest.
The agent knows when it only needs a shallow or scoped view: per-request
overrides merge over Features:AppStructure defaults (AppStructureOptions.MergeWith,
unit-tested), and scope_runtime_id builds the tree under a known element
instead of the whole window - both calls and the response shrink accordingly,
which matters for huge dynamic UIs. Exposed via MCP tool parameters and SDK
overloads. diff_mode is wired in the proto and implemented in a follow-up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replaces the fixed Thread.Sleep(200) with quiescence detection: subscribe to
structure-changed and Name/Value property-changed events under the window,
read the refreshed tree once no event arrives for SettleQuietMs (default 150),
hard-capped at SettleMaxMs (default 1000) so continuously-updating apps
(live grids/tickers) never stall the call. Falls back to the legacy fixed
delay when a provider rejects subtree subscriptions. Both knobs configurable
via Features:AppStructure. QuiescenceTracker timestamp math is unit-tested.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
StructureDiff compares the fresh build against the previous snapshot of the
same root (matched by RuntimeId) and emits flat Added/Changed/Removed entries
with a Parent id for orientation; reparenting and visibility/clickability
flips count as changes. StructureSnapshotStore keeps the latest tree per
window root (PID-tagged, evicted via ClearCache alongside the element cache);
every build refreshes the base, so See followed by diff-mode actions works
naturally. First diff call without a base returns the full tree with a note.
For a one-label change in a 2000-node app this cuts the response by orders
of magnitude - the difference that makes the loop usable on large dynamic
UIs (e.g. trading/portfolio systems). Exposed as diffMode on the MCP
get_app_structure / perform_action_with_structure tools.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… MCP channels

UiAutomationDriver now applies a per-call deadline (default 30s, configurable
via the new defaultTimeout ctor parameter) so a hung server fails the call
with DeadlineExceeded instead of blocking a test suite forever, and every
public async method accepts an optional CancellationToken. WaitForElementAsync
extends its deadline to the server-side wait budget plus headroom.

Both the SDK channel and the MCP server channel get a gRPC ServiceConfig
retry policy scoped to pre-execution rejections only - ResourceExhausted
(UIA worker queue full) and Unavailable (connection refused/shutdown) - with
exponential backoff. Mid-action status codes are deliberately not retried:
replaying a click or keystroke is never safe.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ctions

- GetServerStatus RPC reports worker queue load/capacity, cache size+mode,
  Windows session id and interactivity, and server version - answered off
  the UIA worker so it works even when the queue is saturated. Surfaced as
  the get_server_status MCP tool (flagged as an error result when the
  server runs in Session 0, where it cannot drive the desktop).
- Standard grpc.health.v1 health service so clients probe readiness instead
  of failing their first real call.
- MCP tools now carry annotations (readOnlyHint on all See/read tools,
  destructiveHint on close_app, idempotentHint on clear_cache) and the MCP
  initialize response ships See->Think->Act usage instructions, so any MCP
  client gets the guidance without loading the Skill file.

Verified via piped initialize+tools/list: 13 tools, annotations and
instructions present. Unit tests for GetServerStatus.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replaces the WinForms SendKeys wrapper with raw SendInput while keeping the
same expression syntax. SendKeysParser (pure, 30+ unit tests) handles named
keys, +^% modifiers, (groups), {LEFT 5} repeats and all brace escapes.
Delivery rules: named keys / chords go out as VK + scan code with the
extended-key flag where required (WinForms, UWP and Chromium apps all accept
this); plain text is injected as KEYEVENTF_UNICODE, keyboard-layout
independent; characters under Ctrl/Alt resolve through VkKeyScan so ^a hits
the real accelerator. Partial injection now raises a descriptive error
instead of vanishing silently.

New WindowFocus.EnsureForeground: SetFocus alone cannot raise a background
window (foreground-lock rules), so targeted send_keys and simulated clicks
first restore + foreground the owning window, using the AttachThreadInput
escape hatch when the lock denies the switch - fixes the classic
"send_keys right after open_app did nothing" failure.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…afe guard, constant-time tokens

- ElementCache.TryRefind / FindByRuntimeId no longer run desktop-wide
  TreeScope.Descendants queries (the most expensive UIA call there is):
  they scan the desktop's direct children for the owning PID's top-level
  windows, then search only those subtrees. On a desktop full of heavy
  apps this turns every stale-handle recovery from "walk everything"
  into "walk one app".
- New ElementCache.SweepDeadProcesses + a 60s background sweeper started
  at server startup: entries and structure snapshots of exited processes
  are evicted instead of lingering until individually touched. Sweeper is
  opt-in so tests with fake PIDs are unaffected.
- InteractionAccessGuard decision cache is now keyed by (PID, process
  start time): Windows recycles PIDs, and a bare-PID cache could hand a
  new process the verdict computed for a dead one. Identity-less lookups
  are validated but never cached.
- TokenAuthInterceptor compares tokens with CryptographicOperations
  .FixedTimeEquals (no early exit) instead of ordinal Contains.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…+ no keystroke leaks

New SmokeTests fixture (Category=Smoke, gated by UIA_SMOKE=1 so plain
`dotnet test` skips it) drives a real Notepad through the full pipeline:
OpenApp -> WaitForElement -> targeted SendInput typing -> read the text
back -> GetAppStructure -> CloseAppByProcessId. A dedicated GitHub Actions
job runs it on windows-latest, whose runners expose an interactive desktop.
Verified green locally against live Notepad.

Hardening found by the smoke run:
- GetProperty("Value"/"Text") now reads text through ValuePattern OR
  TextPattern (Notepad's Win32 Edit supports only the latter; WinForms
  textboxes only the former) before falling back to a raw property read,
  and AutomationMapper.LookupProperty knows "Value". This is the read
  half of SET_VALUE that agents were missing.
- Targeted SendKeys waits 50 ms after SetFocus (focus moves async) and
  then VERIFIES the target window holds the foreground before injecting -
  refusing with a clear error instead of leaking keystrokes into whatever
  app the user is focused on.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…le, UWP PID resolution, SendInput keyboard, cache sweep, SDK deadlines/retry, smoke test
…3 (FlaUI 5.0)

The deprecated managed UIA2 stack is replaced by FlaUI.UIA3 across the whole
server - better support for modern apps (UWP/WinUI/Chromium), an actively
maintained library, and the native UIA3 COM API underneath. The proto contract,
client SDK and MCP server are untouched: this is an internal engine swap.

- UiaRuntime (new): process-wide UIA3Automation singleton + desktop root +
  stale-element detection (FlaUI ElementNotAvailableException / COM 0x80040201)
- AutomationMapper: FlaUI conditions/scopes/PropertyIds; control-type condition
  values now accept enum names ("Window"), programmatic names
  ("ControlType.Window") and native UIA ids (50032); responses keep emitting
  the UIA2-style "ControlType.X" strings so existing clients/skills see no change
- ElementCache: liveness probe + scoped re-find ported; RuntimeId handles keep
  the same comma-joined format, so cached ids stay wire-compatible
- AppStructureHandler: CacheRequest now stays active for the whole tree build
  (FlaUI routes property reads through the cache while activated); per-property
  fallback defaults instead of cached/live read pairs
- UiSettler: structure/property-changed subscriptions via FlaUI event handlers
  with per-handler unregistration
- WindowFocus.EnsureForeground hardened: UIA2's managed SetFocus raised the
  window via the client-side Win32 proxy as a side effect, UIA3's does not -
  activation now attaches to BOTH input queues (foreground owner + target),
  uses BringWindowToTop, and falls back to the ALT-key foreground-lock release.
  Without this, targeted SendKeys always refused ("could not be brought to the
  foreground") - caught by the live smoke test, fixed and re-verified green
- ReflectionHandler: property/pattern discovery via FlaUI libraries
- csproj: + FlaUI.UIA3 5.0.0, - UseWPF; assembly-level SupportedOSPlatform
  attribute added (GenerateAssemblyInfo=false suppressed the SDK-generated one,
  which made the platform analyzer flag every FlaUI call with CA1416)
- Tests ported (mapper/cache fixtures) + new coverage for control-type value
  mapping, incl. an Enum.TryParse numeric-string pitfall found by the new tests

Verified: 286 unit tests green; live Notepad smoke test (UIA_SMOKE=1) green:
open -> wait -> find edit -> type via SendInput -> read back via TextPattern ->
structure tree -> close. Library/MCP/Client all build against the same proto.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…conventions, foreground activation)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Live Calculator runs exposed three real failure modes in UwpPidResolver,
all reproduced on a Win10 desktop:

1. The fixed 500 ms launcher grace misclassified calc.exe: the alias stub
   stays alive ~1 s before handing off to the Store app and exiting, so
   OpenApp returned the stub's PID, which died moments later. The grace is
   replaced by evidence: alive + owns a new visible window -> classic app
   (returns as soon as the window shows, faster than the old fixed wait);
   alive + window-less past 1.5 s -> background/CLI app; exited -> resolve.
2. The window diff compared owner PIDs, but UWP windows belong to the
   long-lived ApplicationFrameHost whose PID is in the pre-launch snapshot
   too - the new Calculator frame was invisible to the diff. Now diffs by
   window HANDLE and maps new handles to owner PIDs.
3. UWP startup briefly shows the app's CoreWindow as a visible top-level
   window before reparenting it into the frame; sampling once could return
   that transient owner. Resolution now debounces (300 ms resample, prefer
   what persists) and verifies the picked PID is alive.

Resolution budget raised 3 s -> 10 s (cold UWP starts take 5-6 s; the happy
path returns immediately so fast apps never pay it).

New smoke test Calculator_TwoPlusThree_ShowsFive: OpenApp calc (exercises
the resolver) -> GetAppStructure poll -> Invoke num2/plus/num3/equal by
AutomationId (no keyboard, no foreground dependency) -> read CalculatorResults
-> close the real CalculatorApp PID (never ApplicationFrameHost). Ignores
(not fails) when the UWP Calculator is absent, e.g. on CI server images.

Verified live: Notepad + Calculator smokes green; full suite 289/289.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Donsezan
Donsezan merged commit a31bcc0 into main Jun 13, 2026
4 checks passed
@Donsezan
Donsezan deleted the features/agent-loop-reliability branch June 13, 2026 11:58
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