Skip to content

Commit 491460d

Browse files
AbirAbbasclaude
andauthored
feat(sdk): ReasonerFailed exception so a reasoner can report failure with result (#697)
The async execution handler records an execution as `succeeded` whenever the reasoner returns a value — it never inspects the result. A reasoner whose own payload says `success: False` (e.g. a build that completed zero issues and merged nothing) therefore surfaces as green, which is easy to act on incorrectly. Add `ReasonerFailed`, raised inside a reasoner to report that the work ran but failed. The handler maps it to `status="failed"` while still posting the structured `result`, so the control plane (which stores the result payload regardless of terminal status) keeps the rich outcome — debt, DAG state, any PR opened — instead of just a bare error string. error_details is carried through the existing generic path. Refs Agent-Field/SWE-AF#82 (Gap 2, SDK half). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b9b8577 commit 491460d

4 files changed

Lines changed: 154 additions & 0 deletions

File tree

sdk/python/agentfield/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464
AgentFieldClientError,
6565
ExecutionTimeoutError,
6666
MemoryAccessError,
67+
ReasonerFailed,
6768
RegistrationError,
6869
ValidationError,
6970
)
@@ -158,6 +159,7 @@
158159
"AgentFieldClientError",
159160
"ExecutionTimeoutError",
160161
"MemoryAccessError",
162+
"ReasonerFailed",
161163
"RegistrationError",
162164
"ValidationError",
163165
# Trigger / webhook plugin system

sdk/python/agentfield/agent.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2573,6 +2573,14 @@ async def _watchdog() -> None:
25732573
"execution_id": execution_id,
25742574
"reasoner": reasoner_name,
25752575
}
2576+
# A reasoner that ran, determined its own work failed, and wants its
2577+
# structured outcome preserved raises ReasonerFailed(result=...).
2578+
# Carry that result onto the failed-status payload so the control
2579+
# plane records status=failed WITHOUT discarding the rich result
2580+
# (it stores the result payload regardless of terminal status).
2581+
from .exceptions import ReasonerFailed
2582+
if isinstance(exc, ReasonerFailed) and exc.result is not None:
2583+
payload["result"] = jsonable_encoder(exc.result)
25762584
log_error(f"Execution {execution_id} failed asynchronously: {exc}")
25772585
finally:
25782586
# If we landed here without the reasoner finishing (e.g. our own

sdk/python/agentfield/exceptions.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,44 @@ class ExecutionFailedError(AgentFieldClientError):
3333
pass
3434

3535

36+
class ReasonerFailed(AgentFieldError):
37+
"""Raised *inside* a reasoner to report that the work ran but failed.
38+
39+
Returning a value from a reasoner — even one whose payload says
40+
``success: False`` — makes the async execution handler record the
41+
execution as ``succeeded`` (it only distinguishes "returned" from
42+
"raised", never inspecting the result). A build that completes zero
43+
issues and merges nothing therefore surfaces as green, which is easy to
44+
act on incorrectly.
45+
46+
Raise this when the reasoner has determined its own work failed but you
47+
still want the structured ``result`` preserved on the execution record.
48+
The handler posts ``status="failed"`` to the control plane while also
49+
sending ``result`` (the control plane stores the result payload
50+
regardless of status), so the rich outcome — debt, DAG state, any PR
51+
that was opened — is not lost behind a bare error string.
52+
53+
Args:
54+
message: Human-readable failure summary (becomes the execution error).
55+
result: Optional structured result to preserve on the execution
56+
record (e.g. ``BuildResult.model_dump()``). JSON-encoded by the
57+
handler before it is posted.
58+
error_details: Optional structured error metadata, mirrored onto the
59+
status payload's ``error_details`` field.
60+
"""
61+
62+
def __init__(
63+
self,
64+
message: str,
65+
*,
66+
result: object | None = None,
67+
error_details: object | None = None,
68+
) -> None:
69+
super().__init__(message)
70+
self.result = result
71+
self.error_details = error_details
72+
73+
3674
class ExecutionTimeoutError(AgentFieldError):
3775
"""Execution timed out waiting for completion."""
3876

@@ -80,6 +118,7 @@ class ValidationError(AgentFieldError):
80118
"AgentFieldError",
81119
"AgentFieldClientError",
82120
"ExecutionFailedError",
121+
"ReasonerFailed",
83122
"ExecutionTimeoutError",
84123
"ExecutionCancelledError",
85124
"MemoryAccessError",

sdk/python/tests/test_async_execution.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -976,3 +976,108 @@ async def test_poll_active_executions_still_times_out_without_pause_clock():
976976
assert state.status == ExecutionStatus.TIMEOUT, (
977977
"no pause_clock attached: wallclock overdue check must still fire"
978978
)
979+
980+
981+
@pytest.mark.asyncio
982+
async def test_reasoner_failed_reports_failed_status_and_preserves_result(monkeypatch):
983+
"""A reasoner that raises ReasonerFailed must surface as status=failed
984+
while still carrying its structured result (so the control plane, which
985+
stores the result payload regardless of terminal status, keeps the rich
986+
outcome rather than just a bare error string)."""
987+
from agentfield.exceptions import ReasonerFailed
988+
989+
agent = Agent(
990+
node_id="test-agent", agentfield_server="http://control", auto_register=False
991+
)
992+
993+
@agent.reasoner()
994+
async def build() -> dict:
995+
await asyncio.sleep(0)
996+
raise ReasonerFailed(
997+
"Build failed: 0/3 issues completed, no branches merged",
998+
result={"success": False, "completed_issues": 0, "merged_branches": 0},
999+
error_details={"reason": "empty_build"},
1000+
)
1001+
1002+
recorded = []
1003+
1004+
class DummyResponse:
1005+
status_code = 200
1006+
1007+
def json(self):
1008+
return {}
1009+
1010+
async def fake_request(self, method, url, **kwargs):
1011+
recorded.append({"url": url, "json": kwargs.get("json")})
1012+
return DummyResponse()
1013+
1014+
monkeypatch.setattr(AgentFieldClient, "_async_request", fake_request)
1015+
1016+
async with httpx.AsyncClient(
1017+
transport=httpx.ASGITransport(app=agent), base_url="http://agent"
1018+
) as client:
1019+
response = await client.post(
1020+
"/reasoners/build",
1021+
json={},
1022+
headers={"X-Execution-ID": "exec-fail-1"},
1023+
)
1024+
1025+
assert response.status_code == 202
1026+
await asyncio.sleep(0.1)
1027+
1028+
status_calls = [e for e in recorded if "/executions/" in e["url"]]
1029+
assert status_calls, "expected async status callback"
1030+
payload = status_calls[-1]["json"]
1031+
assert payload["status"] == "failed"
1032+
assert "0/3 issues" in payload["error"]
1033+
assert payload["error_details"] == {"reason": "empty_build"}
1034+
# Structured result preserved alongside the failed status.
1035+
assert payload["result"]["success"] is False
1036+
assert payload["result"]["completed_issues"] == 0
1037+
1038+
1039+
@pytest.mark.asyncio
1040+
async def test_plain_exception_failed_status_has_no_result(monkeypatch):
1041+
"""Regression guard: a generic exception still maps to status=failed with
1042+
no result key — ReasonerFailed's result-preservation must not leak into
1043+
the ordinary failure path."""
1044+
agent = Agent(
1045+
node_id="test-agent", agentfield_server="http://control", auto_register=False
1046+
)
1047+
1048+
@agent.reasoner()
1049+
async def boom() -> dict:
1050+
await asyncio.sleep(0)
1051+
raise RuntimeError("kaboom")
1052+
1053+
recorded = []
1054+
1055+
class DummyResponse:
1056+
status_code = 200
1057+
1058+
def json(self):
1059+
return {}
1060+
1061+
async def fake_request(self, method, url, **kwargs):
1062+
recorded.append({"url": url, "json": kwargs.get("json")})
1063+
return DummyResponse()
1064+
1065+
monkeypatch.setattr(AgentFieldClient, "_async_request", fake_request)
1066+
1067+
async with httpx.AsyncClient(
1068+
transport=httpx.ASGITransport(app=agent), base_url="http://agent"
1069+
) as client:
1070+
response = await client.post(
1071+
"/reasoners/boom",
1072+
json={},
1073+
headers={"X-Execution-ID": "exec-fail-2"},
1074+
)
1075+
1076+
assert response.status_code == 202
1077+
await asyncio.sleep(0.1)
1078+
1079+
status_calls = [e for e in recorded if "/executions/" in e["url"]]
1080+
payload = status_calls[-1]["json"]
1081+
assert payload["status"] == "failed"
1082+
assert payload["error"] == "kaboom"
1083+
assert "result" not in payload

0 commit comments

Comments
 (0)