feat: RemoteAgent zero-cost path + desktop fixes + Workflow builder rebuild - #140
Merged
Conversation
Board renders a "Run on my machine — your paired runner's CLI does the work
(0 server tokens)" toggle. Ticking it makes BoardApp.RunSession build an
IssueWorkRequest with ProviderOverride = "RemoteAgent", and that session executes
in the Web process (Task.Run -> ScopeFactory.CreateScope). But the Web host loaded
ten module assemblies and RemoteAgent was not among them, and the keyed
ILlmClient under "RemoteAgent" is registered by RemoteAgentModule alone. So the
toggle threw
LLM provider 'RemoteAgent' (resolved to 'RemoteAgent') is not registered.
Built-in: Claude | AzureOpenAI | MAF | RemoteAgent.
the moment a member used it — a shipped control the host rendering it could not
honour. Nothing catches this at compile time: the provider name is a string
crossing a DI seam.
Two changes, both on the Web host:
- Reference AgentOs.Modules.RemoteAgent and add it to AddModulesFromAssemblies,
which registers the keyed provider, the in-process broker, and runner_shell.
- Map /hubs/remote-agent explicitly. The Web deliberately does not call
MapModuleEndpoints (it serves a UI, not the API's REST surface), so it opts in
one endpoint at a time, exactly as PairingEndpoints already does. The hub has to
exist on this origin because IRemoteAgentBroker is in-process with no backplane:
a runner paired to the Api's hub is invisible to the Web's dispatch. The hub
stays anonymous by design — it authenticates the connection itself against the
runner's salted pairing-token hash.
Verification: Release build 0 warnings / 0 errors; 835 tests pass, 14 skipped
(832 before, plus the three added here). On the running Aspire stack,
POST /hubs/remote-agent/negotiate on the Web origin went from 405 to 200 while
the Api's stayed 200, and the OIDC chain still returns 302 with request_uri=urn:.
The new tests pin the seam three ways: the Web assembly must reference
Modules.RemoteAgent, the Web-like module set must resolve "RemoteAgent" through
LlmClientFactory, and a module set without it must still fail loudly.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
RunnerEntity documents "Pending → Paired (first successful connect) → Revoked", but no code ever wrote "Paired". RemoteAgentHub.OnConnectedAsync verified the token, registered the connection with the broker, and left the row alone — so a dev machine that had genuinely paired still read "Pending" in Board's Runners table forever. The UI reported a broken pairing that in fact worked, which is indistinguishable from the failure it looks like. IRunnerDirectory gains MarkPairedAsync, called from the hub only after the presented token has been verified. It bypasses the tenant query filter for the same reason FindForPairingAsync does — the handshake carries no authenticated tenant, the token is the credential — and it leaves a revoked runner untouched. It is passed CancellationToken.None rather than Context.ConnectionAborted: the pairing already happened, and a connection dropping a millisecond later must not roll the fact back. Tracked load + SaveChanges instead of ExecuteUpdate: the connect path touches one row, so the extra round-trip does not matter, and it keeps working on the EF InMemory provider the tests use. Verification: Release build 0 warnings / 0 errors; 838 tests pass, 14 skipped (three added here, covering Pending → Paired with a last-seen stamp, a revoked runner staying revoked, and a runner belonging to another tenant still updating). End to end on the running Aspire stack: the runner reported "connected to https://localhost:5180/hubs/remote-agent" and Board's Runners table flipped from Pending to Paired. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Selecting the paired dev machine as the LLM provider failed at three separate
points. Each one only became reachable once the previous was cleared, so they are
fixed together.
1. Settings → Providers listed Claude / AzureOpenAI / MAF but not RemoteAgent,
even though LlmClientFactory treats it as a first-class keyed provider and it
is the only one that spends no server API tokens. The zero-token path was
reachable only by editing config. Added to the dropdown.
2. RemoteAgentLlmClient took ITenantContext by constructor injection, so resolving
it from the root provider threw "Cannot resolve 'ILlmClient' from root provider
because it requires scoped service 'ITenantContext'" on any host with a scoped
tenant context — which is every real deployment (Keycloak's HttpTenantContext).
The Test-connection probe hit it immediately. It now takes the provider and
resolves the tenant per call inside a fresh scope, the same shape
PooledChatLlmClient already uses; LlmClientScopeValidationTests covers the
pooled clients and now covers this one too.
3. The runner could not launch the CLI on Windows at all. With
UseShellExecute = false, .NET does not apply PATHEXT, and npm installs the
agent as claude.cmd / claude.ps1 — never claude.exe — so ProcessStartInfo
("claude") threw "The system cannot find the file specified" on every dispatch.
The runner now probes PATH with PATHEXT, as a shell would, and leaves rooted or
already-extensioned commands untouched.
Verification: Release build 0 warnings / 0 errors; 839 tests pass, 14 skipped. On
the running Aspire stack the whole chain is live — the runner logs "Execute …
(model=claude-haiku-4-5)" and "cli=claude cmd=claude prompt=stdin" for a probe
issued from the desktop, so Web → hub → runner → local CLI is connected. The CLI
itself then reports "Not logged in · Please run /login", which is a per-machine
credential step, not a code path.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The runner console printed only "ok=False", so CLI-not-on-PATH, CLI-not-logged-in
and a plain non-zero exit were indistinguishable from the one place an operator
actually watches while pairing a machine. It also built its error from stderr
alone, but CLI agents routinely print the reason on stdout and exit non-zero —
claude's "Not logged in · Please run /login" is exactly that — so the server
received a bare "exit 1: " with the one actionable line dropped.
The failure line now carries the error, and the error falls back to stdout when
stderr is empty. On the running stack a probe from the desktop now logs
[agent] -> ddb36bce… ok=False: exit 1: Not logged in · Please run /login
which is what the previous commit's PATH fix made reachable: before it, the CLI
never launched at all.
839 tests pass, 14 skipped; Release build 0 warnings / 0 errors.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nswered
LlmClientFactory appends the keyless Offline client to the END of every failover
chain when Llm:OfflineFallback is on, so a call can "succeed" with canned,
schema-valid text while nothing reached a model. Settings → Providers → Test
connection set _testOk = true unconditionally on any non-throwing call, so
probing an unreachable provider rendered a green "OK · Offline" chip. A
connectivity probe that greenlights an unreachable provider asserts the opposite
of what happened; the failure it hides is less damaging than the false assurance.
The Api's /llm/test had the same bug and additionally reported client.Provider —
the chain's PRIMARY — rather than the provider that actually answered.
OfflineLlmClient.IsSubstituteFor(requested, answered) names the condition once:
Offline answered for a provider that is not Offline. Both hosts gate on it, both
now label the responder from the response, and the failure message says what
happened and why ("Llm:OfflineFallback is on. Nothing reached a real model.")
rather than leaving an operator to infer it from a provider name in a chip.
Asking for Offline and getting Offline stays a success, and failover between two
REAL providers stays a success — only the canned stand-in is a lie.
Release build 0 warnings / 0 errors; 847 tests pass, 14 skipped (six added here).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2 tasks
hoangsnowy
added a commit
that referenced
this pull request
Aug 10, 2026
…140) (#142) * fix(web): Badge each agent with the provider the call actually uses PipelineStudio rendered a per-agent provider chip from a hardcoded map (Requirement/Qa/Aggregate = anthropic, Coding/Testing = azure). A Force provider overrides every agent's own setting — LlmClientFactory resolves the forced key, not Agents:<Stage>:Provider — so while one is active that map does not merely go stale, it contradicts the run. Forcing RemoteAgent still rendered "Azure" next to Coding and Testing, telling the operator their work went to a cloud endpoint when every token had been produced by the CLI on their own machine. On a run whose entire point is "0 server tokens", that is the one label that must not lie. ProvName now returns the effective Force provider when there is one (read from the same LlmConfigView snapshot the "Live · provider" chip and the run itself use), and falls back to the per-agent map otherwise. Verified against the run that exposed it: with Force = RemoteAgent, the pipeline completed as Requirement (7 FR, 2 entities) -> Coding (17 files) -> Testing (21 tests, ~85% cov) -> QA (pass, 0.87) -> Aggregate at 0 tok / $0.0000 / 193.3s, every call dispatched to the paired runner's claude CLI. Release build 0 warnings / 0 errors; 847 tests pass, 14 skipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(web): Badge the Orchestrator row from the effective provider too The leader row's chip was the literal "Claude" in markup, so after the previous commit the five specialist rows correctly read RemoteAgent while the Orchestrator above them still named Anthropic — and the leader row is the first thing a viewer reads. The colour class was also still driven by the hardcoded per-agent map, so a badge could read "RemoteAgent" while tinted as Azure. Both now derive from the effective config: LeaderProv returns the Force provider when one is set, and ProvCss maps any provider name onto the two badge classes that actually exist in app.css (.prov.anthropic / .prov.azure) rather than inventing a class no stylesheet defines — a phantom class renders unstyled, which design-system.md calls out as a past cause of broken windows. Verified on the running Aspire stack with Force = RemoteAgent: all six rows (Orchestrator + the five specialists) read RemoteAgent, matching the "RemoteAgent (forced)" toolbar chip. check-classes.ps1 reports no phantom classes (514 defined, all static .razor tokens resolve). Release build 0 warnings / 0 errors; 847 tests pass, 14 skipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(shell): Minimizing a window no longer destroys the app inside it WindowHost rendered only `!w.Minimized && w.Workspace == ActiveWorkspace`. Blazor disposes a component the moment it leaves the render tree, so minimizing a window — or switching virtual workspace — threw away everything it held: a pipeline run in flight, the story typed into it, the artifacts already produced. The server-side work kept going with nowhere to land. On a desktop metaphor, minimize must hide a window, not kill the app inside it. The stylesheet had assumed this all along: `.appwin.minimized { display: none }` has existed since the chrome was written, and was simply unreachable. WindowHost now renders every open window and lets CSS hide it; AppFrame adds `off-workspace` for a window parked on another workspace, with a matching rule next to the minimized one. Verified on the running Aspire stack: with a probe string typed into the Agents window, clicking Minimize leaves the window mounted (`appwin focused minimized`, `display: none`) with the textarea value intact — where it previously vanished. check-classes.ps1 clean (515 defined, all static .razor tokens resolve); Release build 0 warnings / 0 errors; 847 tests pass, 14 skipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(web): Rebuild the Workflow app as an n8n/Opal-class visual builder The Workflow editor put a 14-chip "Add step" strip across the top, floated the inspector over the node it was editing, and stacked new nodes on top of each other via a modulo formula — it read as a diagram toy, not a builder, and the user could not tell what a run produced without opening the separate Pipeline app. This restructures it into the three-region layout every serious builder (n8n, Google Opal, OpenAI Agent Builder) uses, and surfaces run results in place. Layout (G1): - LEFT — a searchable node library, grouped Agents / Flow / Data / Control, each node a card with icon, name and a one-line hint, colour-coded to the category it will paint on the canvas. Replaces the always-on chip strip (progressive disclosure — the palette is there when you want a node, not in your face). - CENTER — the canvas, with a proper zoom cluster (in / % / out / fit), the minimap, and an empty-state hint when there are no nodes. - RIGHT — a docked, persistent inspector that pushes the canvas rather than covering it; shows an empty-state when nothing is selected. - Toolbar slimmed to workflow-switch, name, live-provider chip, Save, Run, and an overflow "⋯" menu for Duplicate / panel toggle / Delete. Default window grown 1080×660 → 1320×840 so the canvas is actually usable. Run visibility (G3): - Each node carries a live status dot in its head (spinner while running, ✓ done, ✕ failed, – skipped) so progress reads on the card, not only in the log. - The docked inspector gains a Run section: selecting a node shows its status, its metric line (tokens / cost / elapsed) and, on failure, the reason — so "what did this node produce / why did it fail" is answerable without leaving the canvas. RunMessage is captured per node for that. Node insertion no longer stacks: a new node lands to the right of the selected (or rightmost) node on the same row, so it reads as the next step. Verified on the standalone Web with Node Playwright, light + dark: the three regions render correctly, the docked inspector populates on node-select, and a run lights every node's status dot (green ✓) with per-node token/cost meta plus a "RUN · DONE" section in the dock. Release build 0 warnings / 0 errors; check-classes.ps1 clean (553 defined, all static .razor tokens resolve); 847 tests pass, 14 skipped. Pushed the matching preview card to the Claude Design project (ui_kits/agentos/workflow.html). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(llm): Estimate tokens + would-be API cost on the zero-cost RemoteAgent path Running on a paired dev machine spends the member's own flat subscription, so the server cost is genuinely $0 — the whole point of the RemoteAgent provider. But the CLI reports no usage, so every run read "0 tok · $0.0000", which looks broken and hides the one number a demo wants: what this work would have cost on a metered API. Two honest additions, neither of which claims a billed spend: - RemoteAgentLlmClient now estimates token counts from the text it actually sent and received (~4 chars/token, the standard English heuristic) and reports them on the response + span. CostUsd stays 0m — the estimate is deliberately NOT folded into billed cost. "0 tok" becomes a real figure everywhere tokens are shown. - The Agents status bar accumulates, per call, what those tokens WOULD cost priced by the model each agent used (CostCalculator). When nothing was billed but tokens were spent, it shows "$0.0000 cost (≈ $X API)" — the saving, clearly labelled, never as actual spend. For real cloud providers the estimate equals the billed cost, so the parenthetical only appears on the $0 path. Verified on the standalone Web with Node Playwright: a pipeline run shows "1964 tok (1532↑ / 432↓) · $0.0000 cost (≈ $0.0071 API)". Two RemoteAgent tests that pinned the old "0 tokens" contract were updated to the new intent (zero cost, estimated tokens > 0). Release build 0 warnings / 0 errors; check-classes.ps1 clean; 847 tests pass, 14 skipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(web): Command palette + auto-connect for the Workflow builder Building a workflow still meant clicking a node in the rail, then dragging an edge by hand — and there was no keyboard-first way to add a node at all. This adds the two interactions that make n8n / Opal feel fluid. Command palette (⌘K / Ctrl+K, or the toolbar "+ Add"): - A centred quick-add over the canvas. Type to filter every node type by name or hint, ↑/↓ to move the highlight, Enter to add, Esc to close. The global key is a document-level listener (workflow-keys.js) routed back to the component through a JSInvokable; registration is best-effort and CircuitSafe, so a missing script degrades to the "+ Add" button rather than crashing the circuit. Auto-connect: - Adding a node while one is selected now also draws the edge from it to the new node — the "add the next step" gesture — so a chain is one action per step instead of add-then-drag. The edge is written to the graph the same way BuildDiagram materialises persisted ones, so it survives Save/reload. Combined with the earlier no-stack positioning, new nodes land to the right of the anchor and wire up automatically. Verified on the standalone Web with Node Playwright: Ctrl+K opens the palette, typing "eval" narrows it to Evaluator with the keyboard highlight and ↵ hint, and adding a node while Testing Agent was selected wired Testing→Print with a visible edge. Release build 0 warnings / 0 errors; check-classes.ps1 clean (564 defined); 847 tests pass, 14 skipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(web): Real recent-runs list + honest fit-to-view for the Workflow builder Two canvas-polish items from the builder rebuild. Recent runs: - The Recent Runs tab showed a bare counter that reset every time the window closed, with the label "demo — history not persisted". It now records each run (workflow name, time, completed/stopped, a one-line outcome) in the OrchestrationStore, keyed per tenant and bounded to 25. The list is session-scoped and in-memory — it survives closing and reopening the Workflow window, which the old counter did not — and the label says exactly that ("this session — full history lives in the Pipeline run store") rather than pretending to be durable. Each row shows a green/red status dot, the name, the outcome and the timestamp. Fit-to-view: - "Fit" could zoom IN past 2.6× when the diagram library measured a small or just-added bounding box, which looked broken. It is now clamped so fit never exceeds 1:1 — it frames the whole graph or stops at 100%, never zooms in. Verified on the standalone Web with Node Playwright: two runs produce two timestamped rows in Recent Runs (badge "2", green dots), and Fit reframes the full five-node graph at 56% instead of over-zooming. Release build 0 warnings / 0 errors; check-classes.ps1 clean (569 defined); 847 tests pass, 14 skipped. Deferred: canvas undo/redo — the one remaining builder item, held back because a correct command-history stack over the diagram is a larger, riskier change best done on its own. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(web): Undo/redo for the Workflow canvas (⌘Z / ⌘⇧Z) The one deferred builder item. Snapshot-based, not a command stack: each structural edit deep-copies the whole graph (nodes + edges + positions) to JSON and pushes it, so undo is "restore this snapshot + rebuild the canvas" with no per-operation inverse to get wrong — the graph is already the serializable source of truth. Covers the operations that go through the app: add node, delete node, and the auto-connect that rides on an add. A manual canvas edge-draw or node drag folds into the next snapshot rather than being individually undoable, which keeps this free of re-entrant diagram-event coupling. History is bounded to 50. Toolbar gains undo/redo icon buttons (disabled when their stack is empty) and the global ⌘Z / ⌘⇧Z / ⌘Y shortcuts route through workflow-keys.js to OnUndoKey / OnRedoKey. One non-obvious fix this required: the DiagramCanvas was keyed by graph id alone, so restoring the SAME graph after an undo left the canvas bound to the previous BlazorDiagram instance and nothing re-rendered. The key now includes a version counter bumped on every BuildDiagram, so an undo/redo of the current graph forces a fresh canvas. Two undo/redo arrow icons added to the shared Icon component. Verified on the standalone Web with Node Playwright: adding a node takes the graph 5→6, ⌘Z returns it to 5, ⌘⇧Z back to 6 — with the toolbar buttons and minimap reflecting each step. Release build 0 warnings / 0 errors; check-classes.ps1 clean (569 defined); 847 tests pass, 14 skipped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <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.
Change description
Trying to run the 5-agent pipeline on a paired dev machine's CLI — the one provider that spends zero server API tokens — turned out to be impossible on
main. Five defects sat in a chain: each was only reachable once the previous one was cleared, which is why they ship together.Modules.RemoteAgent, but Board renders a "Run on my machine" toggle whose session setsProviderOverride = "RemoteAgent"and executes in the Web processLlmException: LLM provider 'RemoteAgent' … is not registeredthe moment a member used the toggle — a shipped control the host rendering it could not honour/hubs/remote-agentwas mapped only on the ApiPOST /hubs/remote-agent/negotiate→ 405 on the Web origin.IRemoteAgentBrokeris in-process with no backplane, so a runner paired to the Api was invisible to the Web's dispatchRunnerEntitydocumentsPending → Paired (first successful connect), but nothing ever wrote"Paired"RemoteAgentLlmClienttookITenantContextby constructor injectionCannot resolve 'ILlmClient' from root provider because it requires scoped service 'ITenantContext'on every host with a scoped tenant context — i.e. every real deploymentUseShellExecute = false, which does not applyPATHEXT, and npm installs the agent asclaude.cmd/claude.ps1— neverclaude.exeWin32Exception: The system cannot find the file specifiedon every dispatch on WindowsTwo smaller items came out of the same pass:
RemoteAgentwas missing from the Settings → Providers dropdown (so the zero-token path was reachable only by editing config), and the runner logged a bareok=False, building its error from stderr alone while CLI agents print the reason on stdout.Notes on the shape of the fixes
MapModuleEndpoints(). It serves a UI, not the API's REST surface, so it opts in per endpoint exactly asPairingEndpointsalready does. The hub stays anonymous by design — it authenticates the connection against the runner's salted pairing-token hash.MarkPairedAsyncbypasses the tenant query filter for the same reasonFindForPairingAsyncdoes (the handshake carries no authenticated tenant; the token is the credential), leaves a revoked runner untouched, and takesCancellationToken.None— the pairing already happened and a connection dropping a millisecond later must not roll it back.RemoteAgentLlmClientnow resolves the tenant per call inside a fresh scope, the same shapePooledChatLlmClientuses.Type of change
Test plan
dotnet build AgentOs.slnx --configuration Release— 0 warnings, 0 errorsdotnet test AgentOs.slnx --configuration Release— 839 passed, 0 failed, 14 skipped (832 onmain; seven added here)Modules.RemoteAgent; a Web-like module set must resolve"RemoteAgent"throughLlmClientFactory; a set without it must still fail loudly;Pending → Pairedstamps last-seen; a revoked runner stays revoked; a runner from another tenant still updates; and the keyedRemoteAgentclient resolves from the root provider underValidateScopes.End-to-end on the running Aspire stack
Full stack via
dotnet run --project infra/AgentOs.AppHost, real Keycloak login, real Postgres.POST /hubs/remote-agent/negotiate405 → 200 (Api unchanged at 200)/account/login→ 302 withrequest_uri=urn:ietf:…(PAR accepted)[agent] connected to https://localhost:5180/hubs/remote-agentRemoteAgent (paired dev machine CLI — 0 server tokens), saved encrypted per tenant, survives a stack restart[agent] Execute … (model=claude-haiku-4-5)/[agent] cli=claude cmd=claude prompt=stdin[agent] -> … ok=False: exit 1: Not logged in · Please run /loginThat last line is the remaining blocker and it is not a code path: the
claudeCLI on the dev machine has to be signed in once (claude→/login). Before this PR the CLI never launched at all.Checklist
dotnet buildpasses locally in Release modedotnet testpasses🤖 Generated with Claude Code
Follow-on work landed on this branch (same session)
After the RemoteAgent path worked end to end, this branch continued into the demo-blocking desktop bugs and a full Workflow-app rebuild. All verified on the running app (standalone Web via Node Playwright, light + dark; full Aspire stack for the RemoteAgent run).
Desktop / correctness fixes
OfflineLlmClient.IsSubstituteFor; shows Failed + "nothing reached a real model".WindowHostdropped minimized / off-workspace windows from the render tree, so Blazor disposed them (a running pipeline, typed input, produced artifacts — gone). Windows now stay mounted and are hidden with CSS.Workflow app rebuilt as an n8n / Opal-class visual builder
Cumulative test state: Release build 0 warnings / 0 errors;
check-classes.ps1clean; 847 tests pass, 14 skipped. The Workflow preview card was pushed to the Claude Design project (ui_kits/agentos/workflow.html).