Summary
POST /api/v1/execute/{target} returns HTTP 502 Bad Gateway for every failed execution
that took the async-acknowledgement lane, including a client-input rejection the agent
raised on purpose. The synchronous lane, for the identical failure, correctly returns the
agent's 4xx. So the status a caller sees is decided by an internal transport detail rather
than by what went wrong, and a client that keys on status — the normal thing to do — reads
its own bad request as an upstream outage.
Reproduction (measured 2026-08-02, control plane 1.0.0, Python SDK 0.1.91)
A reasoner that validates its own input and raises on a bad field:
POST /api/v1/execute/hga-reasoner.reason
{"input":{"text":"probe","rules":[{"about":"http://x/A","classified_as":"http://x/B","not_a_field":1}]}}
HTTP/1.1 502 Bad Gateway
{"execution_id":"exec_20260802_141424_2b7kcbrl","run_id":"run_20260802_141424_9889tyoc",
"status":"failed",
"error_message":"invalid_input: RuleSpec rejected 1 field in `rules[0]` — not_a_field: Extra inputs are not permitted",
"duration_ms":0,"finished_at":"2026-08-02T14:14:24Z"}
The same node, refused by the SDK's own pre-dispatch field check, returns the right status:
POST /api/v1/execute/hga-reasoner.reason {"input":{}}
HTTP/1.1 422 Unprocessable Entity
{"error":"agent error (422): {\"detail\":\"Missing required field: text\"}",
"error_category":"agent_error","error_details":{"detail":"Missing required field: text"},
"status":"failed"}
Both are client-input errors against a perfectly healthy upstream. Only the second one says so.
Root cause
Three things compose. All line numbers are main as of this report.
1. The async-completion branch does not classify at all.
control-plane/internal/handlers/execute.go:276-294 — after an agent answers 202 and the
callback lands, any ExecutionStatusFailed is written out with a hardcoded status:
if exec.Status == types.ExecutionStatusFailed {
...
ctx.JSON(http.StatusBadGateway, response) // execute.go:293
return
}
Its sibling, writeExecutionError (execute.go:1979-1984), gets this right for the
synchronous lane and even says so in a comment:
// Propagate 4xx status codes from the agent (client-facing errors);
// use 502 Bad Gateway for 5xx (upstream server failure).
httpStatus := http.StatusBadGateway
if ce.statusCode >= 400 && ce.statusCode < 500 {
httpStatus = ce.statusCode
}
2. The async lane cannot carry a status even if the branch wanted one.
sdk/python/agentfield/agent.py:2475-2486 builds the failure callback from
str(exc) + getattr(exc, "error_details", None). An HTTPException(status_code=422)
raised inside the reasoner is flattened to its str(); the code is gone before the
callback is posted. (The agent takes this lane whenever X-Execution-ID is present and
agentfield_server is set — agent.py:1932 — which is every control-plane-routed call.)
3. error_details is dropped by the callback handler.
The SDK sends it; executionStatusUpdateRequest (execute.go:116-124) has no field for
it, so it is silently discarded. Only result survives a failed callback
(execute.go:498-504,556-559), which is why ReasonerFailed (#697) works and the generic
path does not.
The net effect: a reasoner has no way to return a client-facing status through the
async lane. The one place that classifies is the pre-dispatch check at agent.py:1886,
which by construction only ever sees missing/None top-level fields — anything a reasoner
validates itself is already past it. Notably _execute_reasoner_endpoint at
agent.py:2265-2270 converts a Pydantic ValidationError into _HandlerInputError
with a comment saying the intent is a safe 422, and then raises it at a point where the
only except _HandlerInputError block (agent.py:1881) has already been passed.
Impact
For any agent that validates its own input — which is the recommended shape, since the
runtime's pre-dispatch check only covers top-level presence — every contract rejection is
indistinguishable from an outage at the status layer. Concretely, on a schema change the
consumer's UI says "the agent is unavailable" instead of "this request uses a field the
agent no longer accepts." The first sends someone to check pods; the second sends them to
the request.
Suggested fix
Smallest change that fixes the observable behaviour: give the async-completion branch the
same classification the sync branch already has, sourced from the callback.
- Add
error_status (or reuse error_details) to executionStatusUpdateRequest and
persist it on the execution record.
- In the SDK's
_execute_async_with_callback failure path, populate it from
HTTPException.status_code / ExecuteError.status_code when the raised exception
carries one.
- At
execute.go:276-294, use that status when it is 4xx, keeping 502 as the default.
A useful independent improvement: restoring error_details to the callback struct would
let any agent return a structured, machine-readable refusal body without having to raise
ReasonerFailed purely for its result side-effect.
Happy to open a PR for any part of this if the shape above is agreeable.
Summary
POST /api/v1/execute/{target}returns HTTP 502 Bad Gateway for every failed executionthat took the async-acknowledgement lane, including a client-input rejection the agent
raised on purpose. The synchronous lane, for the identical failure, correctly returns the
agent's 4xx. So the status a caller sees is decided by an internal transport detail rather
than by what went wrong, and a client that keys on status — the normal thing to do — reads
its own bad request as an upstream outage.
Reproduction (measured 2026-08-02, control plane
1.0.0, Python SDK0.1.91)A reasoner that validates its own input and raises on a bad field:
The same node, refused by the SDK's own pre-dispatch field check, returns the right status:
Both are client-input errors against a perfectly healthy upstream. Only the second one says so.
Root cause
Three things compose. All line numbers are
mainas of this report.1. The async-completion branch does not classify at all.
control-plane/internal/handlers/execute.go:276-294— after an agent answers202and thecallback lands, any
ExecutionStatusFailedis written out with a hardcoded status:Its sibling,
writeExecutionError(execute.go:1979-1984), gets this right for thesynchronous lane and even says so in a comment:
2. The async lane cannot carry a status even if the branch wanted one.
sdk/python/agentfield/agent.py:2475-2486builds the failure callback fromstr(exc)+getattr(exc, "error_details", None). AnHTTPException(status_code=422)raised inside the reasoner is flattened to its
str(); the code is gone before thecallback is posted. (The agent takes this lane whenever
X-Execution-IDis present andagentfield_serveris set —agent.py:1932— which is every control-plane-routed call.)3.
error_detailsis dropped by the callback handler.The SDK sends it;
executionStatusUpdateRequest(execute.go:116-124) has no field forit, so it is silently discarded. Only
resultsurvives a failed callback(
execute.go:498-504,556-559), which is whyReasonerFailed(#697) works and the genericpath does not.
The net effect: a reasoner has no way to return a client-facing status through the
async lane. The one place that classifies is the pre-dispatch check at
agent.py:1886,which by construction only ever sees missing/None top-level fields — anything a reasoner
validates itself is already past it. Notably
_execute_reasoner_endpointatagent.py:2265-2270converts a PydanticValidationErrorinto_HandlerInputErrorwith a comment saying the intent is a safe 422, and then raises it at a point where the
only
except _HandlerInputErrorblock (agent.py:1881) has already been passed.Impact
For any agent that validates its own input — which is the recommended shape, since the
runtime's pre-dispatch check only covers top-level presence — every contract rejection is
indistinguishable from an outage at the status layer. Concretely, on a schema change the
consumer's UI says "the agent is unavailable" instead of "this request uses a field the
agent no longer accepts." The first sends someone to check pods; the second sends them to
the request.
Suggested fix
Smallest change that fixes the observable behaviour: give the async-completion branch the
same classification the sync branch already has, sourced from the callback.
error_status(or reuseerror_details) toexecutionStatusUpdateRequestandpersist it on the execution record.
_execute_async_with_callbackfailure path, populate it fromHTTPException.status_code/ExecuteError.status_codewhen the raised exceptioncarries one.
execute.go:276-294, use that status when it is 4xx, keeping 502 as the default.A useful independent improvement: restoring
error_detailsto the callback struct wouldlet any agent return a structured, machine-readable refusal body without having to raise
ReasonerFailedpurely for itsresultside-effect.Happy to open a PR for any part of this if the shape above is agreeable.