fix(web): surface connector runtime failures to the chat client - #1919
Conversation
|
Warning You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again! |
|
/gemini review |
|
Warning You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again! |
A ConnectorRuntimeError carries a curated, public-safe message and a details payload, but nothing today turns either into something a chat client can read. Add the two functions that do, plus the type that owns what is allowed onto the wire. connector_runtime_client_message adapts the exception's safe_message and falls back to the fixed task-failure text for anything else, so the boundary stays fail-closed even if a future caller passes an incidental exception despite the function's specific name. connector_runtime_public_error projects the exception onto (code, PublicErrorDetails). PublicErrorDetails holds a single field and normalizes it in __post_init__: a reason that is neither a listed enum value nor "<listed prefix>.<declared key name>" becomes None. Putting the whitelist in the constructor rather than in the projector makes "constructing this type" and "passing the whitelist" the same act, so a direct construction from another module cannot carry free text. It nulls rather than raises because every construction site is on the reporting path of an already-failed task. The type has no connector_ref field. The sink for this projection is broadcast_to_task, whose audience includes anonymous widget and share-link visitors, and the same judgement keeps two runtime reasons off the whitelist: runtime_task_identity_mismatch and runtime_owner_mismatch state the task's ownership and the outcome of an authorization check. The four 503 reasons that are listed only state that a server-side component is unavailable. The question that decides this is written into the class docstring so a later addition has to answer it too. Tests derive the reason surface by AST-scanning src/ for every site that constructs a ConnectorRuntimeError -- both construction forms -- rather than from a list of modules, and assert the whitelist neither misses a raise site nor grows an entry nothing produces.
create_terminal_task_error_event gains two keyword-only parameters. They are written into the frame only when both are supplied, so the four call sites that pass neither produce a byte-identical frame. The details parameter is annotated PublicErrorDetails, which makes mypy refuse a dict at every call site it can see. That is not the whole door: annotations are not enforced at run time, and a caller routing through Any -- a dict decoded from JSON, a **kwargs splat -- type-checks clean and would only fail deep inside the function on a missing to_wire. The first statement in the body names the contract instead. That check reads `type(details) is not PublicErrorDetails` rather than isinstance. A frozen dataclass can be subclassed, and a subclass that overrides to_wire without reading self.reason satisfies both isinstance and mypy while bypassing the whitelist that lives in __post_init__. Only the class itself carries that guarantee. The client-safe AST guard is untouched: the new parameters are keyword-only so the message argument does not move, the new fields are not in the guard's sensitive-field set, and no producer or error-payload sink is added. Both exact baselines still hold at 30 and 52.
A ConnectorRuntimeError subclasses RuntimeError and is not a RequiredMCPUnavailableError, so the terminal settlement's two-way classification sent it down the else branch and every such failure reached the user as "Task execution failed." -- which the chat client then rendered as an unknown error. The exception's own docstring already commits its message and details to being safe for API callers. Add a third branch between the two existing ones; neither of them changes. It settles with the exception's string, marks the history row client-safe, and broadcasts the curated sentence along with the projected code and details. It also logs one structured record naming the code, the reason and the connector. That log reads the raw exc.details rather than the projection: its audience is operators, the connector identity is what makes the record actionable, and it never leaves the server. The broadcast frame carries neither the connector identity nor any reason the whitelist dropped. The log is unconditional -- an observability record that can be switched off is one that is not there when it is needed -- and all three values are short bounded strings.
The error and task_error branches dispatched an assistant message without isResult. The conversation panel renders only user, isResult and system-notice messages, so the bubble was filtered out entirely and the turn fell back to a virtual "unknown error" placeholder until the page reloaded -- the server could say whatever it liked and none of it was shown. The task_completed branch already fixed this the same way; copy that precedent, comment included. Read the frame's code and details. When the code is one of the three that mean a connector is missing a value the user can supply, replace the relayed sentence with wording that names the key, parsed off the reason's last segment. A reason that names no key -- a bare enum value, or one the server whitelist dropped -- gets the keyless wording, and connector_runtime_unavailable keeps the plain failure wording because it reports a component being down, which the user cannot act on. Both are i18n keys with en and zh entries, not hardcoded English. The pair is also kept in a new lastConnectorRuntimeError state field. It holds only what the frame is allowed to carry: the frame has no connector identity, deliberately, because its audience includes anonymous widget and share-link visitors. Anything more specific has to come from the owner-only per-task requirements endpoint.
Two follow-ups on the reason whitelist, both narrowing it. The whitelist no longer lists payload_too_large or encryption_unavailable. Both belong to the runtime value-fill endpoint, which does not exist yet; they were listed ahead of it because the whitelist is the contract that endpoint will be written against. That required an exemption in the test asserting no listed reason is unproducible, and an exemption on that assertion is an allowance with no expiry date -- by the time the raising code lands, the audience judgement behind the entry has to be reconstructed from scratch. Each string now arrives with the site that raises it, and the assertion holds with no exemptions at all. The key half of a prefixed reason is now matched against RUNTIME_SOURCE_KEY_RE, the grammar the connector runtime already exports, rather than a second copy of the same pattern compiled here. The module already imports from that file, so there is no new coupling, and the two definitions can no longer drift apart.
The module docstring described it as fixed fallback strings, which was accurate when the file held two constants and one adapter. It now also owns PublicErrorDetails and the reason allowlist governing what may ride on a task_error frame, so say so.
Three changes to the terminal frame, all in the same direction: this frame is what stands between a failed task and a silent one, so nothing about it should be able to cost the frame itself. The details type check no longer raises. PublicErrorDetails already nulls an unlisted reason rather than raising, and its docstring gives the reason -- every construction site is on the reporting path of an already-failed task. The frame builder sits on that same path, and the one call site passing these arguments evaluates them inside the `except Exception` that only logs "its terminal broadcast failed". A TypeError there meant the task committed FAILED and the client saw nothing at all, which is the exact failure this branch exists to remove: the strict half degraded to a worse outcome than the bug. A rejected value is now dropped, logged at ERROR with its stack, and the frame goes out without it. `code` now passes the same closed set. ConnectorRuntimeError types it as a bare str and assigns it unvalidated, so "only the ten module constants reach here" describes today's raise sites rather than anything the code enforces -- one door locked and the one beside it open. V1ErrorCode is the repository's existing closed set of client-visible codes and carries all ten; it is imported rather than recopied, inside the function because the v1 package's __init__ pulls in routers that import this module. Unknown values follow the same drop-and-log path. The durable half of the settlement is now pinned too. The classification branch writes three things and the tests covered one: deleting `client_history_message_type = CLIENT_SAFE_FAILURE_MESSAGE_TYPE` left every test in the file green while a reloading user dropped back to the generic failure text the live bubble no longer shows. The capture helper hands back the settlement kwargs, and both the curated and the incidental branch assert what they persist.
`_raise_v1_connector_runtime_error` (web/api/v1/tasks.py) already projects this same exception for a client, and it makes the opposite call on both halves: it maps the code through V1ErrorCode with an unknown fallback, and ships `to_public_error()["details"]` whole with `connector_ref` in it. Reading either projector alone, the other looks like a contradiction. It is not: the audiences differ. /v1 answers an API key held by a caller already authorized for the task. This path feeds `broadcast_to_task`, which reaches every connection under the task id, anonymous widget and share-link visitors included -- the fact every other choice here follows from. Say so where someone comparing the two will be standing, and say why they stay two projectors rather than one taking the audience as an argument: output width behind a caller-supplied flag fails open the first time the flag is passed wrong. Also states why the details-shape check exists at all, since __init__ normalizes that attribute: it is a plain public attribute anything can reassign afterwards, and this is the last step before the wire.
…state The dedup key was the server's own sentence, and `_message_for_code` returns one fixed string per error code that does not vary with the missing key. So two turns failing on two different keys inside the 30-second window collapsed into one bubble, and the survivor named whichever key failed first -- one value standing for two facts, the same shape this change set exists to remove, reintroduced a layer up. The comment added alongside it claimed the server message "identifies the failure", which stopped being true the moment the rendered wording began deriving from `reason` instead. `isDuplicateMessage` already takes an `occurrenceIdentity` argument for exactly this. Pass the code and reason. Two tests cover both directions: different keys keep both bubbles, a genuine repeat still collapses. Also drops `lastConnectorRuntimeError`. It was written on every terminal error frame and read by nothing -- the dialog that consumes it belongs to a later PR in this series, so it was a surface with no consumer in the PR that introduced it. The state field, its action type, its reducer case, its TASK_SCOPED_ACTION_TYPES entry and the test probe all go with it. The helper that reads the pair off the frame is renamed `getTaskErrorProjection`: it returns for any frame carrying a code, and the old name promised a connector-specific answer it never checked for. Whether a frame is connector-related is decided by the code, at the one place that asks.
9d2a624 to
abc51dd
Compare
|
/gemini review |
There was a problem hiding this comment.
Code Review
This pull request introduces structured terminal error frames for connector runtime failures, allowing the frontend to display specific missing configuration keys to the user instead of generic error messages. It includes robust backend validation/whitelisting of error reasons, updated frontend error handling and deduplication, and comprehensive tests. The review feedback correctly identifies a potential AttributeError in the backend logging when details is not a dictionary, and points out that the translation files in English and Chinese should use double curly braces for proper react-i18next interpolation.
rogercloud
left a comment
There was a problem hiding this comment.
Summary
This PR adds a connector-runtime error projection that carries a curated code/reason from task settlement to a terminal websocket frame and renders missing-runtime guidance in the React chat. It also persists a safe failure message, adds public-reason filtering, and introduces backend/frontend tests for frame shape, routing, and deduplication. The overall terminal-settlement → wire-frame → frontend-projection path is coherent, but the current implementation has three blocking correctness/security gaps and one non-blocking false-green test.
Blocking: yes — recommended event: REQUEST_CHANGES
Design verdict
Acceptable with reservations. The chokepoint choice is appropriate, but F2 (public audience boundary) and F3 (durable/replay parity) are confirmed design reservations. F1 is a separate event-classification correctness defect; F4 is minor test quality.
Findings
F1 — Nonterminal root errors are marked as final results
frontend/src/contexts/app-context-chat.tsx:5772 · major · Blocking: yes
- Trigger: While a task is
RUNNINGorWAITING_FOR_USER, a supported rooterrorcan be emitted for chat enqueue/policy/permission/validation rejection, pause/resume enqueue or conflict failure, or active-interaction resume rejection. The sharedcase "error": case "task_error"handler accepts these events even though onlytask_erroris terminal. - Impact:
TaskConversationPaneltreats the assistant bubble as the final result and filters it into the result-only view, hiding the live process or waiting UI and presenting a nonterminal rejection as the task's final answer. - Contract/evidence: Root
erroris a mixed personal/control/rejection channel; these producers do not transition the task to a terminal state. The base handler did not setisResult; this PR unconditionally adds it, and the existing test exercises both event types only with a failed task. - Fix direction: Set
isResultonly for an explicitly terminal marker (or the terminaltask_errorcontract), and add coverage for nonterminal chat/pause/resume and waiting-state rejections.
F2 — Arbitrary runtime key names are disclosed to public widget/share audiences
frontend/src/contexts/app-context-chat.tsx:5739 · major · Blocking: yes
- Trigger: An owner publishes a widget/share task selecting a custom API/MCP connector with a required runtime key; a missing value produces
missing_context.<key>, and this branch interpolates the parsed key directly into the user-facing bubble. - Impact: The terminal frame is broadcast to every task connection, including anonymous widget/share visitors, so owner-defined names such as
password,api_key, ortenant_secretdisclose connector configuration metadata. No secret value needs to leak for this confidentiality violation. - Contract/evidence: Connector schema validation only enforces the
[A-Za-z0-9_-]key grammar, while owners can define arbitrary keys and the schema/UI has no public-safe label or confidentiality classification. The new dynamic-prefix allowlist accepts any syntactically valid suffix; existing sensitive-value redaction does not redact key names. The base path used only the generic fallback. - Fix direction: Keep dynamic key names owner-only on public broadcasts, or require and project an explicitly audited public-safe label instead of the schema key.
F3 — Structured connector error projection is lost on durable history replay
src/xagent/web/services/task_orchestrator.py:2069 · major · Blocking: yes
- Trigger: A selected connector is missing required context such as
auth_token. The live branch computes a wire-safe(code, details)projection, but this call attaches those fields only tocreate_terminal_task_error_event; the precedingsettle_task_lease_isolatedcall persists onlyclient_error_messageandclient_message_type. - Impact: Live UI can render key-specific missing-runtime guidance, while reload/history replay reconstructs an assistant message from the durable prose fields and loses the code/reason projection, reverting to the generic failure sentence. This breaks live/replay parity and drops actionable guidance after reload.
- Contract/evidence: Supported task creation stores connector references, and missing required context reaches the connector-runtime failure path.
TaskChatMessagehas no error metadata fields, and the unchanged historical serializer emits only message/content (plus ordinary chat metadata); the base had no equivalent structured connector-runtime branch. No migration or replay projection compensates for the omission. - Fix direction: Persist a wire-safe
(code, reason)projection or the exact public-safe final content with the durable failure, and include it in historical replay; add live-versus-reload parity coverage. Do not persist only the generic safe sentence.
F4 — Interpolation test mock makes key-rendering coverage false-green
frontend/src/contexts/app-context-chat.test.tsx:5723 · minor · Blocking: no
- Trigger: This test file's i18n mock returns the translation key and discards interpolation variables, while the fixture supplies
missing_context.auth_tokenand production callst(..., { key: missingKey }). - Impact: The assertions prove only that the translation-key branch was selected. Removing or renaming the parsed suffix, or omitting
{ key }, still returns the same bare key and passes, so the new key-specific rendering contract can regress unnoticed. - Contract/evidence: The real locale strings interpolate
{key}and the production path passes that variable; variable-aware mocks already exist elsewhere in the test suite. The comment and bare-key assertion explicitly leave the concreteauth_tokenoutput unverified. - Fix direction: Use a variable-aware mock or the real resolver, then assert the rendered content contains
auth_token(or the exact localized output).
Prior finding dispositions
- Details-shape root 3897121320 (reply 3900709422; review summary 5069704805) — DROPPED.
ConnectorRuntimeError.__init__normalizesdetailsto a built-in dict; all production construction sites pass dict literals/local dicts, and no production post-construction reassignment was found. The projector's wrong-shape check is defensive, and test-only tampering is not a supported production trigger. This is not a current finding. - Interpolation-syntax roots 3897121329 and 3897121363 (replies 3900710021 and 3900710294; review summary 5069704805) — DROPPED. The in-repo translations use single-brace interpolation, the English/Chinese locale entries are correct, and there is no
react-i18nextusage to reinterpret them. These prior roots are not duplicated as current findings; F4 is a separate false-green test-mock issue.
Blocking status & recommended decision
Blocking: yes — recommended event: REQUEST_CHANGES
Blocking issues:
frontend/src/contexts/app-context-chat.tsx:5772— major — nonterminal rejection errors become final results and hide live/waiting state[new]frontend/src/contexts/app-context-chat.tsx:5739— major — arbitrary connector key names reach anonymous widget/share visitors[new]src/xagent/web/services/task_orchestrator.py:2069— major — code/reason details are live-only and disappear after history replay[new]
F4 is minor and non-blocking.
…elist
A reason of shape missing_context.<key> carried the connector owner's own
declared field name onto task_error, whose broadcast reaches anonymous
widget and share-link visitors. Collapse _is_public_reason to a plain
membership check against the fixed-string allowlist and drop the prefix
set entirely -- the other five prefixes had no raise site in src/ either.
missing_context.<key> is now dropped whole rather than trimmed to its
prefix, so details comes back {} and code alone survives. The other two
missing-value codes are unaffected: their reasons are the fixed strings
not_provided/store_lost, already on the allowlist.
Two tests that used to compare a PublicErrorDetails instance against
another instance built from the same withheld reason went quietly blind:
both sides null out under __post_init__ and the equality still holds.
Rewritten to assert through to_wire() instead. Also adds an AST-derived
pattern check so a re-added prefix form is caught by shape, not just by
its literal value.
…ed reason The server no longer sends a reason built from the connector owner's declared field name (previous commit), so the client-side key parser has nothing left to read. Drop missingRuntimeKeyFromReason and the keyed translation string, and always use the keyless connectorRuntimeMissing wording for the three missing-value codes. Also switches this test file's i18n mock to the variable-aware form already used across 23 other test files (t returns `key:JSON(vars)` when vars are passed). The old key-only mock could not tell an interpolated call from a bare one, so a future regression that starts passing the key back into the wording would go undetected here. The two dedup tests that asserted on the now-removed keyed wording are dropped; their (code, reason) replacements land separately.
… sentence The prior comment on errorOccurrenceIdentity argued from a scenario that never occurs on this path: two different missing-value codes sharing one dedup key. Each of the three connector-runtime codes maps to its own fixed server sentence, so two different codes never share a key in the first place. The scenario the (code, reason) identity actually guards is one code failing under two different admitted reasons -- a runtime secret that was never provided, then later found lost from its store. Both share the same server sentence and therefore the same base dedup key, and without the reason folded in, the second turn's result bubble would vanish inside the existing dedup window. Replaces the two dedup tests with that scenario and its inverse (a genuine repeat of one failure still collapses to one bubble).
…sult The shared "error" / "task_error" handler flagged every bubble it produced as isResult, but the root "error" type is a mixed channel: a rejected chat message, a rejected pause, or a rejected resume all arrive on it while the viewed task is still RUNNING or WAITING_FOR_USER. Flagging one of those closes the conversation panel's live progress indicator and waiting-answer form for a turn that has not actually ended, and drains the turn's accumulated trace events into the rejection bubble. task_error has no such ambiguity: every frame of that type is emitted only after the row has been committed FAILED (task_orchestrator.py's settled branch, and websocket.py's legacy only_if_running helper, which does not broadcast when its update matches no row), and it is also the only frame carrying the structured code/details pair used by the connector-runtime wording and the dedup identity. Gate both on the frame type instead of treating every "error"/"task_error" frame alike. Replaces the parametrized isResult test (which asserted the same, now-wrong, behavior for both types) with one for task_error, and adds two for the root "error" type on a running and a waiting task.
task_orchestrator.py: the elif branch for ConnectorRuntimeError covers more codes than the three missing-value ones (invalid_runtime_context, connector_runtime_unavailable, and the *_resolution_failed codes also land here), so "the three codes" needs a referent to not read as the whole branch. app-context-chat.test.tsx: the waiting-rejection fixture combines `task` from the resume-refusal path (websocket.py:8935) with `error_code` from the pause-refusal path (websocket.py:8491). Neither path emits both today; note the synthesis so it doesn't read as one producer emitting both fields.
rogercloud
left a comment
There was a problem hiding this comment.
Summary
This PR fixes a real bug: terminal task_error frames for connector-runtime failures (missing secret, missing runtime context, connector unavailable) were previously invisible in the conversation panel because they weren't flagged isResult, so a failed turn appeared to end with no answer. The fix flags terminal task_error frames as isResult (mirroring the existing task_failed trace-event pattern), adds a code/details wire vocabulary with a server-side PublicErrorDetails whitelist so a curated, localized message can reach anonymous widget/share-link visitors without leaking internal exception prose, and changes message deduping to key off (code, reason) instead of the rendered sentence so two distinct failures under one error code aren't collapsed into one bubble.
The core fix is sound and narrowly scoped. However, the surrounding mechanism (a new dataclass, a 12-item reason whitelist, an AST-based self-enforcing bidirectional test, and a parallel code/details wire vocabulary — roughly 361 lines of production code and 1075 lines of tests) is disproportionate to the 3 localized bubble strings and one dedup discriminator it delivers. The codebase already has a same-file precedent for "this bubble is the turn's result, don't collapse it" — the per-request waitingRequestId identity at frontend/src/contexts/app-context-chat.tsx:5575 (base). A turn_id-based occurrence identity would likely have avoided most of the new wire-level details mechanism, since reason is only ever consumed client-side to build the dedup key and is never rendered.
More importantly, three confirmed correctness gaps mean the dedup/rendering logic this PR adds does not fully deliver on its own stated goal (never showing a turn with no result, and never showing "Unknown error" to the audience this mechanism exists for). Details below.
Round 0 — design-level findings (not tied to a single line)
- Disproportionate mechanism for the value delivered. ~361 lines of production code (new
PublicErrorDetailsdataclass, 12-item reason whitelist,code/detailswire fields, three near-identical "project this exception" helper functions) plus ~1075 lines of tests (including a full AST-based bidirectional self-enforcement test) to deliver 3 localized strings and a dedup discriminator. See the Simplification section below for the parts of the test suite that can shrink without losing the safety property. - Missed reuse of an existing pattern.
waitingRequestId(frontend/src/contexts/app-context-chat.tsx:5575, base commit) already gives a per-request unique identity for "this bubble is this turn's result, do not collapse it with a previous turn's bubble." Aturn_id-based occurrence identity for terminaltask_errorframes would generalize that existing pattern instead of introducing a new(code, reason)composite key — and would also close Blockers 1 and 2 below, sinceturn_idis already available at the settlement site (src/xagent/web/services/task_orchestrator.py:2015) but simply isn't threaded onto the wire frame today. - Parallel error-code vocabulary. The new
code/detailsfields (backed byV1ErrorCode) sit alongside the pre-existingerror_code/ClientErrorCodemechanism (src/xagent/web/services/client_error_messages.py:25-46, mirrored in a full translation-key + fallback table in the frontend), which was purpose-built for exactly this need. The new field instead uses a hand-rolled 3-element frontendSetmapped to one i18n string with no fallback table. Not a functional bug, but worth a follow-up to consider consolidating intoClientErrorCode.
Blocking findings (3, all severity: major)
1. Dedup silently drops the whole result bubble on a repeat connector failure within 30s
frontend/src/contexts/app-context-chat.tsx:5754-5762
The occurrence identity for connector-coded task_error frames is `${code}:${reason}`, consumed by isDuplicateMessageForViewedTask (frontend/src/contexts/app-context-chat.tsx:1987-2010) with a 30-second cache window. If the same connector failure (same code + same reason — e.g. a user retries a task while a secret is still missing) happens on two consecutive turns within 30 seconds, the second turn's ADD_MESSAGE dispatch is skipped entirely: no bubble at all, even though the task is FAILED and processing has stopped. This is exactly the "turn ends showing nothing" failure this PR sets out to fix, and it's asserted as intended by the PR's own test (frontend/src/contexts/app-context-chat.test.tsx:5999, "still collapses a repeat of the same code and reason", using two frames timestamped a second apart representing two separately-settled turns). This is a routine retry pattern, not a contrived edge case.
Recommend keying occurrence identity on turn_id (available at src/xagent/web/services/task_orchestrator.py:2015 but not currently threaded onto the wire frame) instead of (code, reason), mirroring the waitingRequestId precedent — or at minimum, never let a dedup match suppress the message dispatch entirely; always render some terminal marker per settled turn.
2. Same dedup gap also swallows a second identical generic (non-connector) failure
frontend/src/contexts/app-context-chat.tsx:5780
isResult: isTerminalErrorFrame is gated purely on message.type === "task_error", independent of whether a code was parsed, so it now applies to the untouched generic "Task execution failed." fallback path too. But errorOccurrenceIdentity is undefined when there's no connector code (line 5754-5756), so dedup falls back to pure message-text keying — unchanged from base. Two consecutive generic failures with identical text within 30s: the first is now shown (newly, thanks to this PR's isResult broadening) but the second is silently collapsed, reproducing the exact bug this PR fixes, for the generic-failure case. This is newly exposed by this PR: on base, no task_error bubble was ever shown at all regardless of dedup, so the collapse was never observable.
Same root cause as #1 — recommend fixing both together by giving every terminal task_error frame a turn_id-based occurrence identity, not just connector-coded ones.
3. "Unknown error" fallback defeats the curated safe-message mechanism for 7 of 10 target error codes on anonymous transports
frontend/src/contexts/app-context-chat.tsx:921-923 and frontend/src/contexts/app-context-chat.tsx:5736-5742
getWebSocketErrorMessage (line 921) keys its behavior on the old error_code field, not the new code field. New terminal task_error frames for connector failures never set error_code (only code — confirmed via src/xagent/web/api/websocket.py:397-411), so on an untrusted transport (trustLegacyErrorProse === false, the case for widget/share-link visitors — see frontend/src/components/widget/public-agent-chat-page.tsx:623 and frontend/src/components/widget/session-agent-chat-page.tsx:261) this function unconditionally returns "Unknown error" for every such frame.
connectorRuntimeBubble (line 5736-5740) only intercepts this for the 3 codes in CONNECTOR_RUNTIME_MISSING_VALUE_CODES (frontend/src/contexts/app-context-chat.tsx:935-939). The connector/runtime error family in V1ErrorCode (src/xagent/web/api/v1/errors.py:105-114) has 10 members (CONNECTOR_NOT_FOUND, INVALID_RUNTIME_CONTEXT, MISSING_RUNTIME_CONTEXT, RUNTIME_CONTEXT_IMMUTABLE, RUNTIME_SECRET_NOT_ALLOWED, RUNTIME_SECRET_UNAVAILABLE, SCHEDULED_SECRET_UNAVAILABLE, CONNECTOR_RUNTIME_UNAVAILABLE, MCP_OAUTH_AUTHORIZATION_FAILED, DELEGATED_AUTHORIZATION_FAILED); only 3 get the curated message, and the other 7 (including connector_runtime_unavailable, this PR's own headline example) fall through to `${errorPrefix} Unknown error` at line 5742, discarding the safe sentence the backend already computed and sent on the same frame, in favor of a useless generic string — for precisely the anonymous audience this mechanism exists to serve. The bubble is still visible (isResult is unconditional), so this is a UX/message-quality gap, not a silent-failure gap like #1/#2, but it defeats the PR's own stated purpose for most of its target code set.
Recommend extending CONNECTOR_RUNTIME_MISSING_VALUE_CODES-style handling (or a simpler "if code is present and recognized as connector-runtime, never fall back to Unknown error") to cover all 10 codes.
Non-blocking findings (grouped)
codevalidation is looser thandetails's.code not in _client_visible_error_codes()(src/xagent/web/api/websocket.py:387) is a bare frozenset membership test with no type/hashability gate, unlikedetails's stricttype(details) is not PublicErrorDetailscheck (line 374). An unhashablecodewould raise an uncaughtTypeErrorinsidecreate_terminal_task_error_event— not reachable today (every call site passesNoneor a known string constant), and even if it fired the blast radius is the same already-tolerated "lost terminal frame + log warning," not a crash escaping further. Minor, latent._client_visible_error_codes()gates against the fullV1ErrorCodeenum (src/xagent/web/api/websocket.py:325-336, ~30 members including billing/tenant/auth codes for the authenticated SDK audience), not a connector-runtime-specific subset — asymmetric with the tightly-curated 12-itemreasonwhitelist. No live leak today (only ~9 connector-specific constants ever reach this path), but nothing prevents a future connector-runtime raise site from passing a non-connector code straight through to anonymous broadcast recipients. Suggest a dedicated closed set for this gate.- Log line skips the dict-type guard its neighbor uses.
src/xagent/web/services/task_orchestrator.py:1994-2001calls.details.get("reason")/.get("connector_ref")directly, unlike the projector two lines above (connector_runtime_public_error,src/xagent/web/services/client_error_messages.py:251) which explicitly checksisinstance(details, dict)with a comment explaining.detailsis a reassignable public attribute. Not reachable today; even if triggered, only the diagnostic log line is lost. Already flagged in a prior review round — still open, not new. - The
client_safe_ast_guardsafety-net test wasn't extended to model the newcode/detailsfields on thetask_errorbuilder; its hardcoded model still only produces{type, message, error}. Confirmed current baselines areproducers == 29,error_payloads == 50(tests/web/api/test_websocket_client_safe_errors.py:193,201) — the PR description's Disclosure 2 cites stale numbers (30/52), worth a one-line fix. Not a live gap:PublicErrorDetails's whitelist and thecodeclosed-set check are separate, functioning controls, and the PR already discloses this scope decision. - Whitelist self-enforcement test has rigor gaps (
tests/web/services/test_client_error_messages.py): 3undeclared_*_keywhitelist members are validated only by regex fallback, not exact raise-site matching; the AST scanner only recognizesast.Name-style calls and literal dictdetails=, missingast.Attributecalls and non-literal values; 5reason=str(exc)call sites (e.g.src/xagent/web/services/connector_runtime.py:824,854,880) are invisible to the scanner (independently safe — the runtime_is_public_reasoncheck still catches them). All confirmed test-rigor weaknesses with no live security impact — the actual runtime gate is separate and sound. - Persisted/replayed history uses raw English
safe_messagewhile the live bubble is localized, so a reload after the fix shows English where the live view showed the localized string. Already raised and acknowledged as a deferred follow-up in a prior round — not new, not blocking. - The resume-background path and legacy
execute_taskcommand path still get the old opaque fallback (nocode/details) sinceConnectorRuntimeErrorisn't aClientVisibleErrorthere.execute_taskis explicitly disclosed as out of scope by the PR; the resume path is not mentioned in the disclosures, though this is pre-existing behavior, not a regression. Worth a follow-up: either extend the fix or add it to the disclosed scope. settlement_errornow storesf"{code}: {safe_message}"(src/xagent/web/services/task_orchestrator.py:1981) intotask.error_message, a deliberate, test-pinned change (comment: "the durable error keeps the code prefix operators grep for"), but other readers oftask.error_messagebeyond the two websocket paths checked (src/xagent/web/api/v1/tasks.py:836,conversation_logs.py:593,a2a_protocol.py:434-435) weren't verified against the new prefixed format. Not a regression, no secrets exposed — worth a follow-up confirmation from the author.connector_runtime_client_messageis a near-verbatim structural clone of the pre-existingrequired_mcp_unavailable_client_message(and a third function,connector_runtime_public_error, uses the same idiom). Confirmed deliberate per each docstring (a recurring fail-closed idiom in this file), not accidental duplication — mention only as a minor simplification-adjacent note.isResult: truedrainsstate.traceEventsinto the message (existing reducer behavior), now also applying, untested, to the new terminal-error case. When two error bubbles both getisResult: truein one task (the same-code-different-reason scenario this PR's dedup fix is designed to preserve), the second bubble's trace ends up empty because the first already drained the accumulator. Not a crash or data loss, but a genuine trace-accounting gap worth a follow-up test.- i18n inconsistency: the English "missing value" bubble string has no call-to-action while the Chinese version adds "please supply it and retry" — misleading for anonymous widget/share-link visitors who can't act on it anyway. Minor wording nit.
- Test-quality notes (all minor):
test_connector_runtime_failure_persists_client_safe_historyasserts on stubbed-writer kwargs rather than a persisted row; no test for non-str/unhashablecode; no orchestrator test for a tampered non-dict.detailsreaching the log line; the manualLEGAL_REASONS-like test list covers 8 of 12 whitelist members (separately covered by the AST bidirectional test); no frontend test forcodenested undermessage.data; no test for the traceEvents-drain interaction noted above.
Simplification opportunities
src/xagent/web/api/websocket.py:325:yagni:@lru_cache(maxsize=1)memoizing a trivial ~10-item frozenset rebuild on every call. Drop the decorator; keep the delayedV1ErrorCodeimport (legitimate circular-import workaround).tests/web/services/test_client_error_messages.py:261:yagni:full-src/-tree AST walk to provePublicErrorDetailsis constructed in exactly one module — the test's own comment admits this is a review-hygiene/ownership-boundary check, not a security boundary (the real guard,type(details) is not PublicErrorDetailsatsrc/xagent/web/api/websocket.py:374, is load-bearing and correctly blocks a demonstrated subclass-based whitelist bypass — do not remove that check). Replace the full-repo AST scan with a lighter lint/import-boundary convention, or drop it.tests/web/services/test_client_error_messages.py:311-421:shrink:~110 lines of AST-based constant-propagation/pattern-derivation machinery to auto-derive which reasons are raised where, cross-checked against the whitelist. The machinery has its own gaps (only resolves simple Name/Constant/IfExp bindings) that already limit its value. A direct, hardcoded raise-site list (mirroringDELIBERATELY_NOT_PUBLIC_REASONSright next to this code) would test the same two-directional invariant in far less code.
net: ~-20 lines possible
Blocking status & recommended decision
Blocking: yes — recommended event: REQUEST_CHANGES
frontend/src/contexts/app-context-chat.tsx:5754-5762— major — a routine same-code-same-reason retry within 30s shows no result bubble at all for the second turn.[new]frontend/src/contexts/app-context-chat.tsx:5780— major — two consecutive generic (non-connector) failures within 30s: the second is silently collapsed, reproducing the bug this PR set out to fix.[new]frontend/src/contexts/app-context-chat.tsx:921-923(and:5736-5742) — major — on anonymous/widget/share-link transports, 7 of 10 connector-runtime error codes render "Unknown error" instead of the curated safe message, defeating the PR's own stated purpose for its primary audience.[new]
…nction These 85 lines derive five display values from one frame, and the four conditions each value needs are today recomputed in place with no shared decision point. Two review rounds have found defects in this handler: R1 found isResult mixed across channels and a keyed bubble; R2 found an untrusted-transport wording gap, a wrong dedup identity axis, and an unwitnessed no-version path. Turning the matrix into one function's explicit return value is the precondition for the next defect being visible instead of re-derived and missed again. Pure on purpose: no dispatch, no refs, nothing outside its arguments, so every cell of the matrix is unit-testable without rendering the provider -- the same shape extractTaskControlEnvelope above already uses. Verified equivalent, not just typed the same: a temporary shim in the test file, built by copying the case block's five expressions verbatim, pinned six cells' expected values before this extraction touched any production code (155 passed). After the extraction, the same six cells against the real function produce the same 155 passed with the same expected values. The shim is not part of this commit.
The 30-second dedup keys on the server sentence, and one sentence covers a whole code -- so two turns failing under the same code shared a key while being two distinct failures, and that bubble is now the turn's result. Keying on the failure's class instead of on the occurrence cannot tell them apart, whichever class you pick: the sentence, the code, or the code and reason together. What identifies the occurrence is already on the frame. broadcast_to_task stamps every frame of this type with the row's run_id and state_version (task_error is in _VERSIONED_TASK_EVENT_TYPES), and state_version is bumped by each control transition that changes (status, control_state) -- a retry takes the lease FAILED -> RUNNING and settles RUNNING -> FAILED, so the second failure is at least two versions on, while one settlement broadcast twice carries one version. The identity is therefore run_id:state_version, read from the envelope the handler already parses before the switch. No wire field is added, no backend line changes, and the earlier (code, reason) identity is removed rather than kept alongside it. A frame that arrives with no version gets no identity and keys on the text alone, which is the behaviour that predates this change: the version gate at the top of the handler drops such a frame once any versioned event has been seen for the task, and when the task has no versioned event on record either, two such frames key on the same text and the second still collapses. The identity is withheld there rather than guessed -- attaching the state tuple needs the row, and a settled FAILED task has one.
…ror table The frame's code, not the relayed sentence, decides the bubble's wording now: the five connector-runtime codes that can reach this frame are listed in the client error-code table this repository already uses for the root error channel's error_code field, each with a translation key and an English fallback. That table is the reason the wording now survives an untrusted transport, where relaying server prose is refused by design (xorbitsai#1938) -- before this, only three codes had curated wording and every other code, connector codes included, read "Unknown error" for an anonymous widget or share-link visitor. Only codes with a producer that can reach this frame today are listed, the same rule the server's reason whitelist already states about itself. Of the other five connector-runtime codes, two have no raise site in this repository at all. The remaining three are raised while a connector-runtime payload is being validated. Nothing that reaches those checks settles a task: a request handler answers the call with an error response (the /v1 task endpoints, and the trigger-config endpoints, which convert the failure into their own service error), and the trigger run-preparation path throws before the task row is created and records the failure on its TriggerRun row. No settled task means no terminal frame. A code the table does not list keeps the generic prefixed wording. This also fixes: the logged-in audience no longer sees a different wording than an untrusted transport gets for connector_runtime_unavailable, since that code now has its own table entry instead of relaying the server's four different English sentences for it. The old single-purpose CONNECTOR_RUNTIME_MISSING_VALUE_CODES set and its one i18n key are gone.
N1: the code passed into create_terminal_task_error_event is typed as a bare str but not enforced at runtime -- ConnectorRuntimeError itself types its code the same way and stores it unvalidated. An unhashable value (a list) would raise inside the frozenset membership test on a path whose whole point is that it never raises; a hashable non-string is simply not a member and already takes the drop path either way. The type check now comes first, the same way the neighboring `details` check already does. N3: the operator log line in task_orchestrator reads the same ConnectorRuntimeError.details attribute the wire projector does, for a different reason (an operator gets the raw connector identity; the wire projector filters it out) -- but it read it without the same shape guard. Guarded now with the same isinstance check before reading, at the cost of three lines. The non-dict branch this adds has no test witness in this repository today: triggering it needs a `details` attribute reassigned to a non-dict shape after construction, a shape no raise site produces, and the existing log-line test only covers the dict-shaped path continuing to log correctly. The follow-up (a test that exercises the non-dict branch) is not done in this PR, per approved scope. S1: dropped the @lru_cache on _client_visible_error_codes -- a ~30-member frozenset is cheaper to rebuild per call than to reason about as a cache. The deferred import and its circular-dependency comment are unchanged. S2: deleted the ownership-boundary test for PublicErrorDetails's single construction site; the wire-safety guarantee it was adjacent to (`type(details) is not PublicErrorDetails` in the frame builder) is untouched. `_python_sources` stays -- the reason-whitelist derivation still uses it. The reason-whitelist AST machinery is kept rather than flattened to a hardcoded list -- it exists to catch a future raise site shaped like today's interpolated ones, which a list can't -- and gets three more assertions: the three regex-admitted `undeclared_*_key` reasons are now derived from the same section-name constants the raise site loops over, rather than only regex-matched (this surfaced a second interpolated pattern the scanner finds, `missing_context.<key>`, which is deliberately ungrounded -- it is assembled from a connector owner's declared key name and is already asserted elsewhere to never reach the wire); the two blind spots in the scanner itself (a `details=` argument that isn't a literal dict, and a construction reached through an attribute rather than a bare name) are each pinned to their real occupancy today (one non-literal site, zero attribute constructions); and the five opaque reason expressions this repository raises are pinned to the one shape they take (`str(exc)`), so a future %-format or .format() shows up as a failure here instead of silently widening what the whitelist has to cover. Also added: a parametrized test pinning three non-string code shapes through the type gate, and a frontend test for the code/details-nested-under-data half of the existing root/data fallback.
… C1/C2 moves
All three comments were carried over verbatim from where the code used to
live and pointed at a fixed position ("above", "below") rather than at what
they meant. Moving the code left the words in place while the thing they
pointed at moved elsewhere in the file, or left a position reference that
still landed on something true but not on what the sentence meant:
- The isResult comment in projectErrorFrameForDisplay said "see ADD_MESSAGE
above", written when this code lived inside the case block far below the
ADD_MESSAGE reducer case. The extraction moved it above that reducer case,
so "above" now points at nothing there; named the reducer case directly
instead of relying on file position.
- The six-cell test's controlEnvelope comment said "the no-version cell
below", but that cell is the array literal above the it.each callback the
comment sits in, not below it.
- The dedup-identity comment said "the version gate above", meaning the
version gate's call site in the handler (described elsewhere in this PR as
"the version gate at the top of the handler", which sits well below this
comment, not above it). "Above" happened to still land on something true --
canAcceptTaskControlVersion's definition is above -- but not on what the
sentence meant; named the function directly instead of relying on
position.
|
This round pushed 5 commits ( Size (D0-1). Current numbers, full branch vs. main: production 483 insertions / 15 deletions across "A self-built compound key" (D0-2). The mechanism itself -- Two wordings, one table (D0-3). Fixed via option A -- see the reply on the "Unknown error" thread. Narrowing the closed set (N2). Deferred, not done in this PR. The closed set is the gate deciding which codes can reach the wire at all; narrowing it to a connector-specific subset means defining that subset and touching the shared Guard baseline numbers (N4). Four blind spots (N5). Addressed with three new assertions -- see the reply on the AST-machinery thread. Replay is English-only (N6). Confirmed, unchanged, no action -- this was already an acknowledged deferred item and PR description Disclosure 6 now has an added paragraph stating it explicitly (the historical replay builder never emits resume /
Structural twin (N9). No action -- already acknowledged as intentional in an earlier pass.
CTA mismatch (N11). Resolved as a side effect of option A: N12(a)(c)(d)(f). Deferred, recorded as follow-ups, not done this round: (a) the existing stub-call test asserts on kwargs passed to the mock rather than a value read back off the row; (c) no orchestrator-level test tampers with N12(b) and N12(e). Both done this round -- see the reply on the type-gate thread (N12(b)); N12(e) is a new frontend test for the code/details-nested-under- "Blocking: yes." All three are fixed, replies posted on each thread with the test and mutation evidence. |
…ime-structured-errors
main's xorbitsai#1912 added resolve_connector_access_or_raise() to connector_team_scope.py, whose generic-exception fallback arm raises ConnectorRuntimeError with reason "connector_access_resolution_failed". This PR's whitelist-coverage test requires every reason a ConnectorRuntimeError construction can produce to be classified in the same change that adds the raise site, so merging main's new raise site into this branch turned that test red until the reason is classified here. Judged against the two questions this whitelist's comment asks: does the reason name who owns the task, or state what an authorization check concluded? Neither. It only reports that resolving connector access failed, the same as the already-whitelisted team_scope_resolution_failed a few lines above -- same module, same 503, same fallback-arm shape.
…ot line Merging main shifted websocket.py's line numbers; replaced the two stale websocket.py:<line> citations with the hosting function's name, matching this PR's existing convention of naming the referent instead of relying on a position that moves.
|
Heads-up on a push during the re-review window — this is not a response to any review comment, and no reviewed logic changed. CI's New head is 0a3c3f9, three commits on top of 2ef06c7:
The PR description's whitelist enumeration was updated to match. |
rogercloud
left a comment
There was a problem hiding this comment.
Summary
ConnectorRuntimeError previously fell into the generic terminal-failure path: its curated public-safe message was discarded for a fixed "Task execution failed." string, and the resulting frame lacked isResult, so the chat UI silently filtered it out and left the turn stuck on a placeholder until reload. This PR classifies the failure at settlement, adds a stable code/details pair the client can render and localize without relaying server prose to an untrusted (possibly anonymous) audience, marks the frame isResult so it renders and ends the turn, and switches terminal-frame dedup to the pre-existing (run_id, state_version) identity.
Blocking: no — recommended event: APPROVE
Design note (Round 0)
Verdict: acceptable with reservations. The diagnosis is correct, the client-side classification/dedup mechanism is sound, and it correctly reuses existing machinery rather than inventing new plumbing. The reason whitelist in PublicErrorDetails (src/xagent/web/services/client_error_messages.py) is a genuinely well-built security boundary: frozen dataclass, __post_init__-only construction, all 13 whitelisted members trace to real raise sites, and the one owner-controlled reason shape (missing_context.<key>) is correctly excluded from the whitelist.
The one substantive reservation: the entire details/PublicErrorDetails/whitelist apparatus (~150 lines of the most safety-critical new code in this PR, plus ~90 lines of comments justifying it) ships a reason field that no client-side consumer reads today. getTaskErrorProjection (frontend/src/contexts/app-context-chat.tsx:938-951) parses details.reason into a projection object, but projectErrorFrameForDisplay (frontend/src/contexts/app-context-chat.tsx:991-1074) never reads it — only .code drives the rendered bubble. History shows the field's one former consumer (the old (code, reason) dedup key) was removed later in this same PR when dedup moved to (run_id, state_version), so the apparatus went from "one consumer" to "zero consumers" during this PR's own review cycle without the escalation being revisited. This isn't a correctness problem — it's real defense-in-depth for the wire boundary, and worth keeping if the "owner-gated per-task requirements endpoint" mentioned in the PR description is a near-term follow-up. But as of this PR, code alone would deliver the same user-visible fix with far less new surface area to review and maintain. Non-blocking; recommend either wiring a real consumer soon or trimming the apparatus until one exists.
Inline findings (all non-blocking)
Posted as separate inline review comments on src/xagent/web/api/websocket.py:424, src/xagent/web/api/websocket.py:345, tests/web/api/client_safe_ast_guard.py / tests/web/api/test_websocket_client_safe_errors.py:179, frontend/src/contexts/app-context-chat.tsx:1072, and src/xagent/web/api/websocket.py:402. Summaries:
-
codeis silently discarded wheneverdetailsfails validation (websocket.py:424) —codeanddetailsare validated independently but written under one combinedif code is not None and details is not None:gate, so a rejecteddetailsalso drops a perfectly validcode— the field the client actually renders. Not reachable via the current production call site (the sole caller always builds a validPublicErrorDetailsalongside a valid code), so latent rather than live, but worth decoupling the two writes and updating the "dropped=details" log line to mentioncodetoo. -
Deferred
V1ErrorCodeimport inside a function documented as "never raises" isn't guarded (websocket.py:345, inside_client_visible_error_codes()) — the sole caller's contract (and its own try/except) explicitly relies on this function never raising, but the deferred import isn't wrapped..v1.errorsis already imported at app-startup (web/app.py), so this is a guaranteed cache hit today, not a realistic failure path — but it's the one line that technically violates the function's own stated invariant. Consider a one-linetry/except ImportErroror a comment noting the reliance on app-boot import ordering. -
Static client-safe AST guard has a blind spot for this PR's new fields —
tests/web/api/client_safe_ast_guard.py'sSENSITIVE_PAYLOAD_FIELDS/SAFE_MESSAGE_BUILDERSand terminal-task-error handling only inspect themessagepositional argument and don't listconnector_runtime_client_message/connector_runtime_public_error;tests/web/api/test_websocket_client_safe_errors.py:179scans onlywebsocket.py, nottask_orchestrator.py, which is where the actualcode=/details=call site lives. A future unsafe caller passing badcode/detailswould be invisible to this static sweep (though still caught at runtime by the builder's own type/membership checks, which are correctly implemented and tested). Worth closing in a follow-up. -
isResult: truenow also applies to cancel and resume-settlementtask_errorframes, drainingstate.traceEventsinto those bubbles, with no dedicated test —frontend/src/contexts/app-context-chat.tsx:1014(isTerminal = message.type === "task_error") and:1072(isResult: isTerminal), feeding thetraceEvents-draining reducer branch around:1385-1391; producers aresrc/xagent/web/services/external_task_cancel.py:404andsrc/xagent/web/api/websocket.py:3007(resume-settlement failure), bothtask_error-typed without acode. Verified not a regression — these frames had noisResulthandling pre-PR either, so they were previously invisible in the panel — but the cancel/resume shapes'isResult/traceEventsinteraction is untested. A related two-terminal-frames-in-one-task trace-accounting concern was already raised and accepted as a non-blocking follow-up in an earlier round. Recommend a follow-up test for these shapes. -
_client_visible_error_codes()validates against the full ~30-memberV1ErrorCodeenum, not just the connector-runtime subset (websocket.py:402) — carried forward from an earlier round's finding "N2" (review body 5085310824, conversation comment 5516598157), which the author acknowledged as real but explicitly deferred this round ("would require touching shared /v1 surface"). It remains a type-membership check rather than an audience/purpose check, inconsistent with the audience-scoping care applied elsewhere in this PR (e.g. thereasonwhitelist). Confirmed intentional viatest_the_closed_set_is_the_v1_one_not_a_copyand not exploitable today (the sole call site only ever passes one of ~10 known connector codes). Flagging again since it was deferred rather than resolved — worth a decision on whether another deferral is fine or a tracking issue is warranted.
Additional minor/nit findings (grouped, non-blocking)
frontend/src/lib/client-errors.tsandfrontend/src/i18n/locales/en.tsindependently duplicate the same English fallback strings for the 5 connector-runtime error codes, with nothing tying them to each other or to the backend'sV1ErrorCodeenum — a future rename/copy-edit could silently drift them apart.tests/web/services/test_client_error_messages.pyhas brittleness/perf nits: a few assertions hardcode exact counts/line numbers (e.g.assert len(opaque) == 5) that will break on unrelated edits, and ~7 tests each re-parse the fullsrc/tree via AST with no caching (functools.lru_cachewould help).- A docstring in
client_error_messages.pystates a stricter reachability rule than what's actually enforced — one cited illustrative reason turned out to be reachable mid-execution too, not only from pre-settlement validation. The rule itself is fine; the example needs a small correction. - Several new comments reference this review's own round/issue numbering (e.g. "I-3b", "I-A") that will be meaningless once this review is forgotten — consider replacing with plain descriptions of the invariant being protected.
getTaskErrorProjectionreadscodeanddetailsvia two independently-chosen fallback chains (data.code ?? root.code,data.details ?? root.details) that could in principle pair mismatched values from different nesting levels. No current producer emits such a shape — latent only.PublicErrorDetails.reasonuses afrozenset[str]+ silent-null-in-__post_init__pattern, while this file's ownClientErrorCodeuses aStrEnum+ exhaustive table — a design-consistency suggestion, not a defect (the frozenset also accommodates a couple of dynamically-derived/opaque reason shapes a strict enum would complicate).
Checked, no issue
- The dual
code/error_codewire-field structure looked like it could cause dedup/render divergence, but both practical failure modes were already found and fixed in earlier rounds (commits4b8b1ebc4,08c37391f) with dedicated regression tests. handle_execute_task'sexcept RuntimeErrorarm still using generic messaging forConnectorRuntimeErroris provably unreachable for this failure class — the exception is fully consumed and classified insidetask_orchestrator's own handler first.
Simplification opportunities
delete: connector_runtime_client_message's fallback: str = CLIENT_SAFE_TASK_FAILURE parameter (src/xagent/web/services/client_error_messages.py) has exactly one caller (task_orchestrator.py:1983), which never overrides the default. Drop the parameter and reference CLIENT_SAFE_TASK_FAILURE directly in the function body.
net: -1 lines possible
Blocking status & recommended decision
Blocking: no. Every finding above — inline and grouped — is confirmed non-blocking: latent-but-unreachable robustness gaps, a testing/tooling coverage gap, an unread-but-safe field, and cosmetic/consistency nits. This is a well-engineered, thoroughly self-reviewed PR: the author already resolved 3 major blockers and multiple minor issues across 18 prior review rounds, and every new concern raised in this pass is non-blocking.
Recommended event: APPROVE.
The terminal task_error frame no longer carries a details object. The server used to project a connector-runtime failure onto a (code, reason) pair, filtering reason through a fixed allowlist before it reached broadcast_to_task -- whose audience includes anonymous widget and share-link visitors. That allowlist has no consumer today: the client only ever read the code, so the whole reason channel was dead weight carrying review risk with nothing on the other end. create_terminal_task_error_event now takes only a code argument. PublicErrorDetails, the reason allowlist, and the two-value projector are gone; connector_runtime_client_code replaces them with a single-purpose projection from exception to code. The frontend projection follows: TaskErrorProjection carries only code, and getTaskErrorProjection no longer reads a details field.
…me codes The terminal task_error frame validated its code argument against V1ErrorCode, the repository's full /v1 error surface -- roughly thirty codes covering everything from rate limiting to workforce archival, most of which have nothing to do with a connector runtime. The frame reaches anonymous widget and share-link visitors, so its own closed set should describe exactly what belongs there, not borrow a much wider one that happens to be a superset. CONNECTOR_RUNTIME_CLIENT_ERROR_CODES replaces the V1ErrorCode lookup with the eight connector-runtime codes this repository actually raises as a ConnectorRuntimeError. The two authorization-outcome codes (mcp_oauth_authorization_failed, delegated_authorization_failed) stay out: nothing raises them today, and each one states the outcome of an authorization check, which this frame must never carry.
create_terminal_task_error_event's own runtime gate only checks the value of a code argument -- whether it is a member of the closed set -- and has no way to tell a curated projection from an incidental string that happens to collide with a real code today. A future call site could pass str(exc) or read .code straight off an exception and this repository would not notice until the wrong fact reached an anonymous visitor. This AST-based test closes that gap statically: every call site under web/ that passes code= must bind it, in the same function, from a direct call to connector_runtime_client_code -- the one projector this repository trusts for this purpose. Today that is exactly one call site, task_orchestrator.py's _runner.
…trace drain Two terminal task_error producers carry no code today, and neither had coverage: external_task_cancel.py's cancellation broadcast (message only) and websocket.py's resume-settlement broadcast, which carries error_code on the root instead. Both still have to make the frame the turn's result on isTerminal alone, and the cancellation path also has to carry forward whatever trace events accumulated on state.traceEvents before the settlement -- the one place that happens, in ADD_MESSAGE's isResult branch. Adds a resume-settlement case to the projectErrorFrameForDisplay table and a transport-level test that seeds two trace events, delivers a cancellation frame, and checks they land on the settling message while state.traceEvents is cleared. Also pins client-errors.ts's fallback strings against the English locale so the two tables cannot drift.
connector_runtime_client_message's fallback parameter has had exactly one caller since it was added, and that caller never overrides the default. The two return sites use CLIENT_SAFE_TASK_FAILURE directly now; required_mcp_unavailable_client_message keeps its own fallback parameter, which does have an overriding caller.
Sweeps the batch for review-round references and stale comments the earlier commits in this sequence left behind. The regex sweep itself found nothing, but a manual read turned up three comments describing a mechanism the batch already removed: one docstring still claimed code was checked against "the same closed set the /v1 surface pins against" after the closed set became a curated subset, and two fixture comments in test_task_orchestrator.py still described a reason value as "public" or "withheld" from the wire after this batch's first commit removed the wire's reason channel entirely.
…op a duplicate frame pin
|
The seven commits after
Verification on this branch: backend |
Summary
ConnectorRuntimeErrorat terminal settlement instead of folding itinto the opaque task-failure fallback
codethroughconnector_runtime_client_code,checked at the frame builder against a closed set of eight connector-runtime
error codes
codeonto thetask_errorframe only when it passes that check,leaving the four existing call sites byte-identical, and drop rather than
raise on an unrecognized value so a terminal frame is never lost
on the server only -- neither reaches the frame
into an "Unknown error" placeholder, and flag it as the turn's result only
for the terminal
task_errortype(translation key plus English fallback) instead of a second vocabulary, so
a code has one wording on every transport
already carries, so two failed turns each keep their own bubble while one
settlement delivered twice still collapses
Problem
When a connector needs a runtime value the task does not have, the turn fails
with a
ConnectorRuntimeError. That exception already carries a curatedpublic-safe sentence and a stable code naming which failure occurred. The
sentence reached nobody, and the code reached nowhere at all. This PR
delivers both to the client; the reason and the connector's identity go to
the operator log, not to the client.
The terminal settlement in
task_orchestratorclassified failures two ways:RequiredMCPUnavailableErrorpassed its own message through, everything elsebecame the fixed
"Task execution failed.".ConnectorRuntimeErrorsubclassesRuntimeError, so it took the second path. The frame then reached the chatclient without an
isResultflag, and the conversation panel renders only user,isResultand system-notice messages -- so the bubble was filtered outcompletely and the turn degraded to a virtual "Unknown error" placeholder that
persisted until the page was reloaded.
Two separate things had to be true for the user to see nothing useful, and both
are fixed here.
Operators had the same gap from the other side: the existing failure log prints
the exception's
__str__, which is"missing_runtime_context: Required connector runtime context is missing."-- thereasonnaming the missing keywas never written down anywhere.
Approach
Two single-purpose projections.
connector_runtime_client_messageadaptsthe exception's
safe_message;connector_runtime_client_codeprojects itonto its
codeattribute. Both have anisinstancegate and fall back to theexisting opaque behaviour for anything else, so neither becomes a generic
exception-to-text escape.
A third branch at the terminal settlement. Inserted between the two existing
ones; neither of them changes. It settles with the exception's string, marks the
history row client-safe, broadcasts the curated sentence, and passes the
projected
codeto the frame builder. The reason and the connector identityare read straight off the raw exception, separately, and go only into a
structured operator log line -- neither reaches the frame.
One optional field on the
task_errorframe, written only when itsurvives validation, so the four call sites that pass no code produce a
byte-identical frame.
codemust be a member ofCONNECTOR_RUNTIME_CLIENT_ERROR_CODES-- the eight connector-runtime codesthis repository actually raises as a
ConnectorRuntimeError, not the full~30-member
/v1error surface. A value failing that check is dropped andlogged rather than raised -- the reasoning is in Disclosure 1.
One structured log record naming the code, the reason and the connector.
The frontend flags the terminal
task_errorframe -- and only that type --as the turn's result. Every frame of this type is broadcast after the row
has already committed FAILED: it comes from the settlement branch, and from
the legacy
only_if_running=Truehelper, which does not broadcast at all whenthe update it is trying to apply matches no row. The root
errortype is amixed channel -- live rejections such as a busy task or a refused resume also
arrive on it, and flagging those as a result would end the turn's live
indicator on a task that is still running. The frame's code, not the relayed
sentence, decides the bubble's wording: codes in the closed set that have a
live producer today are listed in the client error-code table this repository
already uses for the root error channel's
error_codefield, each with atranslation key and an English fallback. A code the table does not list --
whether it is outside the closed set entirely, or inside it but without a
producer yet -- keeps the generic prefixed wording. None of the wording names
a connector or a field.
Deduplication had to move with it. The 30-second dedup keys on the server
sentence, and one sentence covers a whole code -- so two turns failing under
the same code shared a key while being two distinct failures, and that
bubble is now the turn's result. Keying on the failure's class instead of on
the occurrence cannot tell them apart, whichever class you pick: the
sentence or the code. What identifies the occurrence is already on the frame.
broadcast_to_taskstamps every frame of this type with the row'srun_idand
state_version(task_erroris in_VERSIONED_TASK_EVENT_TYPES), andstate_versionis bumped by each control transition that changes(status, control_state)-- a retry takes the leaseFAILED -> RUNNINGand settlesRUNNING -> FAILED, so the second failure is at least two versions on, whileone settlement broadcast twice carries one version. The identity is
therefore
run_id:state_version, read from the envelope the handler alreadyparses before the switch. No wire field is added, no backend line changes.
A frame that arrives with no version gets no identity and keys on the text
alone, which is the behaviour that predates this change: the version gate at
the top of the handler drops such a frame once any versioned event has been
seen for the task, and when the task has no versioned event on record either,
two such frames key on the same text and the second still collapses. The
identity is withheld there rather than guessed -- attaching the state tuple
needs the row, and a settled
FAILEDtask has one.Disclosures
1. The error frame carries no connector identity and no reason -- only a code.
codeis the frame's only structured field. This projection is broadcastthrough
broadcast_to_task, whose audience -- as the client-safe guard's ownsource comment states -- is every connection under the task id, anonymous
widget and share-link visitors included.
There is no
connector_reffield, and noreasonfield at all. A client thatneeds the connector's identity calls the per-task requirements endpoint,
which admits the task's owner -- the user who created the task. The frame's
codeis the whole story everyone gets from this frame, owner included; keynames arrive with the requirements endpoints in the next PR of this series,
which discloses their full audience.
The closed set excludes
mcp_oauth_authorization_failedanddelegated_authorization_failed: nothing raises either as aConnectorRuntimeErrortoday, and each one states the outcome of anauthorization check, which this frame must never carry.
Withholding the reason and the connector identity costs nothing
operationally: the operator log reads the raw exception's
detailsdirectly, so both are still recorded server-side in full; what is lost is
one degree of wording precision in the failure bubble.
2. The closed set is enforced at the frame builder; a static test pins where
codeis allowed to come from.create_terminal_task_error_event's own runtime gate only checks the valueof its
codeargument -- membership inCONNECTOR_RUNTIME_CLIENT_ERROR_CODES-- and has no way to tell a curated projection from an incidental string that
happens to collide with a real code today.
tests/web/api/test_terminal_task_error_frame_origins.pycloses that gap statically: every call site under
web/that passescode=must bind it, in the same function, from a direct call to
connector_runtime_client_code-- the one projector this repository trustsfor this purpose. Today that is exactly one call site,
task_orchestrator.py's_runner.3. This is the second client-visible projection of
ConnectorRuntimeError, andthe two deliberately differ.
_raise_v1_connector_runtime_errorinweb/api/v1/tasks.pyalready projectsthis exception for the SDK surface, and it makes different choices on both
counts: it maps
codethroughV1ErrorCode, falling back toINVALID_RUNTIME_CONTEXTfor anything unrecognized rather than dropping it,and it ships
to_public_error()["details"]whole,connector_refincluded.This frame checks its code against the connector-runtime closed set and drops
anything outside it, and carries no
detailsat all.The split is the audience, and it is the only thing that justifies it.
/v1isreached with an API key by an authenticated SDK developer who is already
authorized for the task; shipping the connector identity to them is that
surface's existing contract. This frame goes through
broadcast_to_task, whichreaches every connection under the task id -- anonymous widget visitors and
share-link visitors included. The same fact drives every other choice in this PR.
/v1is left exactly as it is. Nothing here changes its payload, and the twoare not merged behind one projector taking an audience parameter: a single
function whose output width depends on a caller-supplied flag is the shape
that fails open the first time someone passes the wrong flag. Two
projectors, each with one audience and no branch, is the safer factoring.
connector_runtime_client_code's docstring names the sibling so the nextreader finds it.
4. The live bubble and the replayed transcript line are worded differently,
and that gap is fully accounted for.
The live bubble is localized into the visitor's language and carries the
client's own error prefix. The replayed line, read back after a page reload,
is the fixed English sentence this server writes into history. Neither one
names a field, a connector, or states anything the other side lacks -- the
frame carries no field name to leak in the first place.
Literal equality between the two is not achievable in this repository for any
error: the prefix and the localization both happen on the client, so making
the two sentences byte-identical would mean moving wording and translation to
the server -- and that same
safe_messagestring is also themessagefield/v1returns to SDK callers, so changing it has a second consumer outsidethis PR. What is pinned instead is that the two sides carry the same
information: the frame's
codemaps to exactly the un-interpolatedtranslation key under the variable-aware test mock, the same content a reader
gets from either side.
A reload is the one case where the wording is not recovered. The terminal
failure reaches the client on the live broadcast only: the historical replay
builder emits
task_info, trace events andhistorical_data_complete, andadds a status event only for a task in
PAUSEDorWAITING_FOR_USER--FAILEDis not in that set, so notask_erroris replayed. After a reloadthe visitor learns the task failed from
task_info.status, and the panel'svirtual placeholder bubble stands in for the wording. That asymmetry
predates this PR and is not changed here.
Notes for reviewers
The operator log record in
task_orchestratorreadsdetailsoff the rawexception and guards it with an
isinstancecheck before reading it.ConnectorRuntimeError.__init__runsdict(details or {}), so the attributeis a dict at construction, but it is a plain public attribute that anything
can reassign afterwards, so the log line verifies the shape rather than
assuming it.
The projected
codehas exactly one reader on the client: the bubble'swording. The dedup identity does not read it -- it reads the frame's
state tuple -- and nothing else stores or forwards it.
The two ERROR logs for one failure answer different questions. The new
structured record carries the code, reason and connector as parseable fields; the
pre-existing one carries the traceback. The pre-existing line is left untouched.
The dedup identity is one expression.
errorOccurrenceIdentityand theargument that threads it into
isDuplicateMessageForViewedTaskboth predatethis PR; what this PR settles is which value goes in. It reads
run_idandstate_versionoff the envelope the handler already parses, so there is nonew mechanism to revert -- only that expression, its comment, and the tests
that pin it.
Out of scope
The agent builder's chat WebSocket (
components/build/agent-builder-chat.tsx)is the other
task_errorconsumer in the frontend. It readsdata.message/data.errorand raises a toast; the new field is inert there, so it isunchanged and needs no follow-up.
The Telegram, Feishu and Slack channels are unaffected. They do not go through
the terminal settlement's classification: they run the agent themselves and
project the outcome through
project_execution_result_for_channel, which has itsown hardcoded failure branch. Their users still see
"Task execution failed.",unchanged. Improving that is owned by a different function and is not part of
this change.
Narrowing the terminal-result flag to the
task_errortype also withdraws aside effect the frame carried before this PR. Two exception paths in
websocket.py's execute-task handler (the twosend_personal_messagearms in its exception handling) send the task's initiator apersonal
type: "error"frame; before this narrowing those were flagged asthe turn's result along with everything else on the shared handler, and now
they are not. The task-level broadcast for that same failure goes out as
agent_error, which was never flagged either way, so the panel falls back toits virtual placeholder bubble -- this is the existing behavior on
main,and this PR does not change it.
The resume-background path is unchanged. Two of its arms broadcast a
terminal frame without a code (
client_safe_error_messageonly projects aClientVisibleError, andConnectorRuntimeErroris a plainRuntimeErrorthere), and the arm that broadcasts without settling can emit two failures
at one state version, in which case the second still collapses -- the same
outcome that path has on
main. Extending the classification to it is aseparate change; nothing here regresses it.
getWebSocketErrorMessage's rule is untouched: on a transport that markslegacy prose untrusted it still refuses to render any server sentence.
Nothing in this PR relays that prose to those visitors; the wording they get
is the client's own, selected by code.
Verification
Backend, on this branch:
The 17 skips are postgres-only tests that do not run against this branch's
sqlite test database.
Frontend:
Clean, no errors.