Skip to content

feat(ui): route email chat through the sidecar /v1/email/query loop (V2-10)#2136

Merged
itomek merged 18 commits into
mainfrom
tmi/issue-2109-query-relay
Jul 16, 2026
Merged

feat(ui): route email chat through the sidecar /v1/email/query loop (V2-10)#2136
itomek merged 18 commits into
mainfrom
tmi/issue-2109-query-relay

Conversation

@itomek

@itomek itomek commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator

Closes #2109 (epic #2014, breakdown item V2-10).

Before: the Agent UI's email chat ran a reduced in-process tool loop (EmailProxyAgent — prescan/search/calendar-list/archive/undo only), so the agent's advanced tier was unreachable from chat: "which emails am I waiting on a reply for?" improvised a keyword search and answered wrong (the A1 finding from the v2 E2E round), and confirmation requests were invisible. After: email chat relays the sidecar's full /v1/email/query canonical SSE loop into the V2-9 render-card pipeline — the real check_followups (and every other advanced tool) now runs from chat, needs_confirmation renders as a visible approval card per the stateless D1 model, and EmailProxyAgent plus the #1000 fence-injection hack are fully retired.

Evidence (real-world golden path on an AMD Ryzen AI (Strix Halo) machine, Lemonade 11.0.0, Gemma-4-E4B, live Outlook mailbox)

Step Proof
Pre-scan → render card from tool_result.render through the relay ("0 surfaced · 25 informational") prescan card
A1 closure: follow-ups ask invokes the real check_followups tool (visible in agent activity) instead of a keyword-search improvisation followups tool
Destructive ask → needs_confirmation card (action name + literal summary, no approve button per stub contract 2.4) → terminal D1 refusal; no send occurred (server log has zero send lines; the stub never executes gated tools) confirmation card
Cancel mid-stream: forced socket close lands the same second as the click (relay.py log: "stream closed for cancel … Response ended prematurely"), UI returns to ready cancel
Fail-loud UX for a revoked Google connection (actionable copy, chat keeps working via the Microsoft mailbox) revoked grant

The confirmation card also re-renders from persisted history after a full page reload (generic render/data card persistence).

Tested by driving the built UI in a real browser against the branch running on the AMD machine (frontend dist built at the branch tip, md5-verified transfer); each screenshot was captured at the moment of the step.

Eval (issue AC: "email eval re-run vs baseline")

This diff touches zero hub/agents/** files (git diff origin/main..HEAD -- hub/ is empty) — the sidecar loop, prompts, and tools the email scorecard measures are byte-identical. A bounded control run (benchmark leg, committed basis: Gemma-4-E4B, limit 250) scored via gaia.eval.scorecard_gate against the committed SCORECARD.md: result posted in a follow-up comment (run in flight at PR-open time). The committed scorecard is not regenerated.

Tests

  • Unit/integration (Python): 856 passed, 11 skipped (pinned email/UI paths; includes 9 real-server integration tests that previously skipped silently and a new real-socket cancel test)
  • Webui (vitest): 23 files, 180 passed (was 19/157) — includes the frozen ConfirmationCard contract suite (metacharacter-literal rendering, PermissionPrompt isolation, rehydration)
  • Lint: util/lint.py --all all critical checks pass
  • CI pickup: test_unit.yml (paths src/**,tests/**) + test_electron.yml webui vitest job (src/gaia/apps/webui/**) both fire on this diff

Notes for reviewers

  • Generic approve button is deliberately absent: canonical contract 2.4's needs_confirmation carries no structured args and no token (stateless stub, spec §5/Q4); a fresh-/query "approved, do it" would violate D1's approve-what-you-saw invariant. Approve wiring lands with the D1 sign-off / contract 2.5 follow-up.
  • Beyond the planned scope, review surfaced and fixed five defects in the relay/cancel path — the notable one: cross-thread resp.close() cannot interrupt a blocked read (CPython buffered-reader lock); cancel now peels the raw socket and calls shutdown(SHUT_RDWR), proven by a new real-socket integration test and live on hardware.
  • Follow-up issues being filed: (1) empty-answer persistence tail shows misleading "Lemonade" copy and drops steps for card-echo-only and cancelled turns (pre-existing, all agents); (2) check_followups trips Microsoft Graph InefficientFilter on Outlook Sent-folder queries (sidecar-side); (3) sidecar-side actionable error copy for Lemonade-down (the relay now appends a hint at the boundary as mitigation).
  • The evidence/2109-golden-path branch holds the screenshots and should be purged after merge.

itomek added 18 commits July 16, 2026 17:30
Adds failing tests for the not-yet-implemented EmailSidecarProxy.query_stream
(POST /v1/email/query SSE relay) and cancel_query (POST
/v1/email/query/{run_id}/cancel), covering the timeout tuple, non-2xx
SidecarHTTPError boundary, SSE line parsing/ordering, malformed-JSON loud
failure, the cross-thread on_response cancel hook, and 404-is-benign cancel
semantics.
Streams POST /v1/email/query as parsed canonical SSE event dicts (fixed
10s connect timeout, caller-supplied read_timeout spanning the whole
agent-loop run) and adds cancel_query for POST
/v1/email/query/{run_id}/cancel. The on_response hook hands the live
response object to the caller on first iteration so a cancel path on
another thread can force the blocked read to error out by closing it.
Malformed SSE lines raise loudly rather than being swallowed; a 404 on
cancel is benign (the run already finished).
The SSE handler dropped the #1000 fence-injection hack in favor of setting
tool_result.result_data directly (#2109) — TestStructuredRenderInjection
tested the deleted buffering/prepending behavior, so it's replaced with
TestRenderMapResultData, which exercises pretty_print_json's new
_render_card_payload-driven result_data path via the public API and asserts
print_final_answer no longer touches card content at all.
Adds the red-phase TDD contract for gaia.ui.email_sidecar.relay, which does
not exist yet: relay_query's request-body construction, the canonical ->
UI event-shape table (status/token/tool_call/tool_result/needs_confirmation
/final/error), the mutating-tool visibility status line, the render-card
echo-strip, and crash/no-terminal/cancel/timeout control flow — all against
hand-rolled fakes. Also adds an integration harness that boots the real
gaia_agent_email FastAPI app on a real uvicorn server (build_query_agent
seam swapped for scripted fake agents) to prove the pre-scan render card
survives pretty_print_json -> CanonicalTranslator -> real HTTP -> proxy
end to end, plus a real-socket timing test proving the SSE stream isn't
buffered.
…on hack

Adds gaia.ui.email_sidecar.relay.relay_query, translating the sidecar's
seven-event canonical /query vocabulary (status/token/tool_call/
tool_result/needs_confirmation/final/error) into the UI's own SSE
wire, with direct pinned _emit shapes rather than the print_* methods
built for the in-process agent loop. Cancellation registers the live
HTTP response on handler.active_relay_response so a cross-thread
cancel can force a blocked read to error out; every sidecar exception
is caught and translated to a terminal agent_error, never left to
reach the Lemonade retry classifier. Mutating tools that execute
without a confirmation gate (organize/trash/snooze/preference/voice)
get an extra visible status line so persistent changes are never
buried in the collapsed activity panel.

Also fixes sse_handler.py's pretty_print_json to carry a render-map
tool's card payload as result_data directly (SSEOutputHandler is
shared runtime with the sidecar's own /query route, so this closes
the pre_scan data gap for every /query consumer, not just this UI).
This replaces the #1000 fence-injection hack entirely: no more
buffering pending render payloads or prepending fenced JSON blocks
into the final answer text. _RENDER_TOOL_TO_LANG stays as the shared
render-map source; _strip_balanced_json_blobs stays for the relay's
echo-strip of any card JSON the model still pastes into its prose.
Locks down the already-shipped agent_type=email behavior in
_chat_helpers.py/models.py as characterization tests: the non-streaming
400 fail-loud path, the _dispatch_email_query pre-flight gates (sidecar
start, contract-version floor, /query readiness, cancellation), the
streaming branch's early-return source shape, AgentStepResponse's
render/data fields surviving message_to_response's pydantic round-trip,
and an end-to-end relay-fidelity test proving a scripted answer and
render-map card persist byte-identical through the full SSE ->
captured_steps -> DB -> response pipeline.
Retires the in-process agent_type=email tool-calling loop entirely.
The streaming branch is now a self-contained early-exit dispatch
(_dispatch_email_query): it starts the sidecar, gates on a MAJOR.MINOR
contract-version floor (2.4) so a pre-Hub-upgrade binary gets an
actionable error instead of 404ing every turn, waits on the sidecar's
first-turn readiness check, then hands off to relay_query and returns
before the shared agent trunk (which assumes a constructed in-process
agent and would crash on the unbound local). Every manager/proxy
exception is caught at this boundary and turned into a terminal SSE
error, so a sidecar connection problem can never reach the Lemonade
retry classifier and trigger a bogus model reload.

Non-streaming email now fails loud with an HTTP 400 pointing at
stream=true — the sidecar's /query loop has no non-streaming shape,
and there were zero verified callers of the old drained path.

Deletes EmailProxyAgent, proxy_agent.py, and its test file — the
in-process tool registry it depended on is gone, and every tool it
exposed is now served by the sidecar's own agent loop through the
relay.

Cancellation: /api/chat/cancel and the streaming generator's orphan
cleanup both now force-close a live email-relay HTTP response via
SSEOutputHandler.close_active_relay_response(), so a relay parked in a
blocked socket read reacts to cancellation immediately instead of
waiting out its full read_timeout.

AgentStepResponse gains render/data fields so a render-map card
(currently only the pre_scan_inbox -> email_pre_scan card) rehydrates
on session reload instead of being silently dropped by pydantic's
default extra='ignore' — populated in captured_steps only when the
source tool_result actually carried a render kind, keeping today's
summary-only retention for everything else.
cancel_query inherited the general 300s sidecar timeout, so a hung
sidecar made Cancel exactly as slow as not cancelling. The cancel POST
is best-effort cleanup — the forced response close has already unblocked
the relay's reader — so it now uses a dedicated 10s timeout and swallows
transport failures (timeout/connection) with a warning log instead of
letting them escape past relay_query's SidecarError handling into the
generic chat exception classifier.

Test-first: the three new cancel_query tests (short timeout, timeout
swallowed, connection error swallowed) were run red before the fix.
A cancel landing before the relay registered the live response made the
router's forced close a no-op (active_relay_response still None), leaving
the relay parked in a blocking socket read for the full read_timeout.
Two-part fix: query_stream now POSTs and fires on_response eagerly at
call time instead of lazily on first next(), and the relay's registration
hook closes the just-registered response when the cancelled flag already
raced ahead — so every cancel ordering now interrupts the read promptly.

Test-first: the raced-cancel relay test and the eager-registration proxy
test were run red before the fix; the stale test pinning lazy
registration was retired with the behavior it pinned.
…lose

The new real-socket integration test (a local threaded HTTP server
streaming chunked SSE, then parking silently) proved the cancel design's
core assumption wrong: a cross-thread resp.close() does NOT interrupt a
blocked read — CPython's buffered reader holds its lock for the whole
blocking read(), and close() waits on that same lock, so the forced close
itself blocked the full 30s park. In production that meant Cancel against
a silently-hung sidecar hung the /api/chat/cancel route too.

close_active_relay_response now peels the underlying TCP socket out of
the requests response (urllib3 v1/v2 attribute chains) and calls
socket.shutdown(SHUT_RDWR), which bypasses the file-object lock and wakes
the parked recv immediately; the relay's stream generator still closes
the response on its way out. resp.close() remains only as the fallback
for mocked/non-socket responses, keeping existing unit tests honest.

The test drives the PRODUCTION closer cross-thread and bounds both the
closer's own return time (<1s) and the reader's unblock time (<3s).
relay_query called signal_done() itself AND _run_agent's outer finally
fired a second one after the email branch returned — two None sentinels
per turn, violating the queue's exactly-once contract (masked because
the consumer breaks on the first). The relay no longer signals at all:
the turn-level sentinel is owned by _run_agent's outer finally, exactly
as for every other agent branch (and matching _dispatch_email_query's
own pre-flight error paths, which already relied on it).

Test-first: a new _stream_chat_impl-level test drives the REAL
relay_query and asserts no stray sentinel is left in the queue after the
turn (run red before the fix: found 1 stray). The relay-level done_calls
assertions flip to the new never-signals contract; the fidelity test's
scripted relay stops mimicking the old double-signal behavior.
… noise

The streaming consumer's 'answer overrides chunks' rule had an
if-truthy guard, so a final answer that legitimately cleans to empty
(a card-echo-only email response) kept the raw chunk-accumulated JSON
echo as the persisted assistant message. The answer event now assigns
full_response unconditionally: empty is a valid answer.

Test-first: a _stream_chat_impl-level test streams a render-card echo
as tokens plus an echo-only final and asserts the raw JSON never lands
in the DB (run red before the fix: the full echoed card was persisted).
The sidecar's /query error events carry str(exc) verbatim, so the most
common consumer failure — Lemonade Server down — reached the user as a
raw urllib3 repr with no next step, while every other agent branch gets
'is Lemonade Server running?' guidance. The relay now appends (never
replaces) a short actionable hint when the detail matches narrow
connection/timeout-shaped patterns; anything else passes through
untouched. Root fix (actionable copy sidecar-side) ships via the agent
release pipeline and is noted for the PR as a follow-up issue.

Test-first: hint tests use realistic requests/urllib3 reprs (Max retries
exceeded / NewConnectionError / Read timed out), not friendly literals,
plus a no-hint passthrough case; run red before the fix.
Covers the seam's three degenerate paths directly: no active response,
a mocked/non-socket response (clean close() fallback), and a close()
that raises (swallowed, never fatal).
Red-phase specs for Increment 2 (stateless D1): needs_confirmation event
wiring, action+summary card rendering, dismiss-is-local-only behavior,
unknown-action humanization safety, metacharacter-literal summary
rendering (summary embeds email-derived, attacker-influenced content),
history persistence/rehydration through the existing message.cards path,
and isolation from the pre-existing blocking PermissionPrompt flow.

Four files, none of which reference implementation code that exists yet:
- components/render/ConfirmationCard.tsx (component-level contract)
- ChatView's needs_confirmation dispatch + PermissionPrompt isolation
- MessageBubble rehydration of a persisted needs_confirmation card
- the SSE-level AGENT_EVENT_TYPES dispatch contract in services/api.ts

All four fail today for feature-absence reasons; the 157 pre-existing
webui specs are untouched and still pass.
#2109)

Email /query confirmation events were invisible to users — the backend
relay emitted needs_confirmation but the frontend dropped it as an
unknown SSE event type. It now rides the existing agent-event dispatch
(AGENT_EVENT_TYPES + StreamEventType) into the generic #2108 card
mechanism: ChatView appends a {render: 'needs_confirmation', data:
{action, summary}} card (no run_id/confirm_id passthrough), which
renders at stream position, persists onto the finalized message via the
generic Message.cards path, and rehydrates from history.

ConfirmationCard is deliberately non-blocking (stateless D1: the run is
already over server-side): no Approve button — the terminal final answer
carries the deterministic-path guidance — and Dismiss is local component
state only. Rendering safety: the attacker-influenced summary renders as
a plain text node in a <pre> (never markdown/innerHTML), the machine
action name is shown verbatim and visually separated, and the humanized
label derives from action alone. The blocking permission_request /
confirm_id / PermissionPrompt flow is untouched (isolation pinned by
test).

Implements the frozen red-phase contract from the previous commit
untouched: 23 files / 180 vitest tests green (was 4 files red).
…#2109)

Deletes the last half of the #1000 fence hack: the backend fence
injection went earlier on this branch; this removes the frontend parser
— promoteStructuredPayloads/STRUCTURED_PAYLOAD_KINDS (bare-JSON
promotion), STRUCTURED_PAYLOAD_LANGS and its stripBogusCodeFences
bypass, STRUCTURED_FENCE_RE with RenderedContent's ReactMarkdown bypass,
and the pre-override EmailPreScanCard mount. Cards now arrive
exclusively via tool_result.render into the card registry; the only
input the parser still had was pre-cutover session history, which now
degrades to a plain fenced-JSON code block (kept legible by retaining
the payload tags in KNOWN_CODE_LANGS).

The fence characterization tests are rewritten to pin the post-cutover
reality per their own header instructions: fenced payloads render as
code blocks (never a card, never a crash), bare payload JSON stays
text. EmailPreScanCard's stale mounted-via-fence doc comment and the
two doc surfaces claiming fence-parsing 'today' (query-sse-contract
§4.2, agent-ui.mdx card-rendering section) flip to shipped tense.

Gates: EmailProxyAgent/proxy_agent grep across src/+hub/ empty; no new
FastAPI routes in the diff; relay logging carries run_id + exception
text only; 180 vitest + 856 pytest + lint all green.
@itomek

itomek commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator Author

Eval control run complete — gate PASS.

The bounded control (benchmark leg, committed basis: Gemma-4-E4B-it-GGUF, limit 250, 1 experiment) scored via python -m gaia.eval.scorecard_gate against the committed hub/agents/npm/agent-email/SCORECARD.md:

Aggregate: 0.8434 within_one_bucket_accuracy (249 emails judged)
PASS: Scorecard gate passed.
  Candidate v0.3.0: aggregate.value = 84.34 (prior v0.3.0: 83.4)

Within run-to-run noise of the committed baseline, as expected for a diff that touches zero hub/agents/** files. Throughput 56.9 tok/s (bar >=10). The committed SCORECARD.md is untouched. Tested by running gaia eval benchmark over the synthetic corpus (tests/fixtures/email/synthetic_inbox.mbox) against a local Lemonade server, then the standard gen_scorecard + scorecard_gate chain.

@itomek itomek self-assigned this Jul 16, 2026
@itomek
itomek marked this pull request as ready for review July 16, 2026 23:55
@itomek
itomek requested a review from kovtcharov-amd as a code owner July 16, 2026 23:55
@itomek
itomek added this pull request to the merge queue Jul 16, 2026
Merged via the queue into main with commit 47c1142 Jul 16, 2026
70 checks passed
@itomek
itomek deleted the tmi/issue-2109-query-relay branch July 16, 2026 23:56
@github-actions

Copy link
Copy Markdown
Contributor

Verdict: Approve

This PR retires the in-process EmailProxyAgent (and the #1000 fence-injection hack) and routes email chat through the sidecar's canonical /v1/email/query SSE loop, translated back into the UI's own event vocabulary. The upshot for users: the email agent's full advanced-tool tier (e.g. check_followups) now runs from chat instead of an improvised keyword search, needs_confirmation renders as a visible, XSS-safe approval card, and mid-stream cancel actually interrupts a blocked socket read. The change is unusually well-tested — a real-socket cross-thread cancel test, a byte-fidelity end-to-end persistence test, and red-phase TDD contracts for every new surface — and error handling is consistently fail-loud with actionable copy.

No blocking issues found. One thing worth a second look before it bites a non-email agent: the persistence tweak that lets an empty final answer overwrite streamed text was broadened from the email path to every agent type, and only the email case is pinned by a test.

🔍 Technical details

🟡 Important

Empty-answer override now applies to all agent types, tested only for email (src/gaia/ui/_chat_helpers.py:2245)

The persistence guard changed from if answer_content: full_response = answer_content to an unconditional full_response = event.get("content", ""). For email this is correct and covered (test_card_echo_only_answer_never_persists_raw_echoed_json) — a card-echo-only turn should persist empty. But this line runs for every agent type. If any non-email agent ever emits a terminal answer event whose cleaned content is empty while real text was streamed via chunks, that streamed text is now discarded instead of retained (the old guard preserved it). The answer event is authoritative by design (the comment above the block says so), so the risk is low — but there's no test pinning the non-email path against this regression. Worth a one-line characterization test asserting a non-email streaming turn with a normal answer still persists correctly, so a future prompt/cleaning change that empties an answer can't silently blank a persisted message.

🟢 Minor

  • relay.py:_dispatch_one emits {"type": "status", "message": f"[unsupported agent event: {etype}]"} for an unknown canonical event. That's a reasonable fail-loud choice, but the raw bracketed internal string surfaces to the user as a status line. Fine as a backstop; just noting it's user-visible.

Strengths

  • Test quality is the standout. test_cross_thread_close_unblocks_parked_read_promptly_real_socket parks a real HTTP response and proves socket.shutdown() (not resp.close()) is what wakes a blocked reader — bounding both the closer's return time and the unblock latency. test_email_relay_fidelity_end_to_end proves a render-map card survives byte-identical through SSE → captured_steps → DB → pydantic, and folds the retention-cap check (render-less results gain no render/data key) into the same run against the raw dict.
  • Fail-loud throughout. _dispatch_email_query catches every sidecar exception at the boundary and emits a terminal SSE error rather than letting it reach _classify_chat_exception (which would misclassify a sidecar connection error as a retryable Lemonade failure and trigger a bogus reload). The MAJOR.MINOR version floor plus the 404 backstop in the relay is a clean defense-in-depth pair.
  • ConfirmationCard renders attacker-influenced summary as a plain <pre> text node — never through ReactMarkdown or innerHTML — with the machine action name shown verbatim and visually separated (anti-spoof), and the humanized label derived from action alone. The metacharacter-literal tests lock this down.
  • The feat(agents): multi-model parallelism — small classifier + large writer via Lemonade multi-model loading #1000 fence-injection hack and EmailProxyAgent are removed cleanly, with the old characterization tests rewritten to pin the accepted cutover cost (pre-cutover history degrades to a fenced JSON code block, never a card, never a crash) rather than just deleted.

itomek added a commit that referenced this pull request Jul 17, 2026
…ailbox tag

The multi-mailbox consolidation tags items internally, but _run_prescan
strips the tag to satisfy the frozen extra="forbid" contract; the route
docstring and exported spec claimed the tag survives. Also drop the stale
claim that the Agent UI's pre_scan_inbox tool routes through /prescan —
post-#2136 the UI chat runs the agent loop in the sidecar.
@itomek itomek mentioned this pull request Jul 22, 2026
9 tasks
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.

feat(ui): route email chat through the sidecar /v1/email/query loop (V2-10)

2 participants