Skip to content

Commit 6bae07f

Browse files
vvillait88claude
andcommitted
feat(sdk): preserve response-body fields on AgentScoreError + accept identity hints on create_session
Python parity with the same node-sdk additions (commit 75ba394 in node-sdk). Two non-breaking surface extensions: 1. AgentScoreError grows a ``details: dict[str, Any]`` field populated from non-``error`` keys of the response body. Consumers can branch on ``verify_url``, ``linked_wallets``, ``claimed_operator``, ``actual_signer``, ``reasons``, etc. for granular denial recovery — previously the SDK dropped them and only surfaced ``code`` + ``message``. Defaults to ``{}`` so existing constructor calls keep working. 2. ``create_session`` / ``acreate_session`` accept optional ``address`` + ``operator_token`` so a session can be pre-associated with a known wallet or be a KYC refresh for an existing ``opc_...``. The ``/v1/sessions`` API has accepted these all along; the SDK was just not forwarding them. Coverage stays at 97.41% (Tier A bar 95%). 146 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 3517955 commit 6bae07f

4 files changed

Lines changed: 129 additions & 4 deletions

File tree

agentscore/client.py

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -113,11 +113,16 @@ def _handle_response(self, response: httpx.Response) -> Any:
113113
if response.status_code >= 400:
114114
try:
115115
body = response.json()
116-
error = body.get("error", {})
116+
error = body.get("error", {}) if isinstance(body, dict) else {}
117+
# Preserve everything except the parsed `error` block so consumers
118+
# can read verify_url, linked_wallets, reasons, etc. for granular
119+
# denial recovery — mirrors node-sdk's AgentScoreError.details.
120+
details = {k: v for k, v in body.items() if k != "error"} if isinstance(body, dict) else {}
117121
raise AgentScoreError(
118122
code=error.get("code", "unknown_error"),
119123
message=error.get("message", response.text),
120124
status_code=response.status_code,
125+
details=details,
121126
)
122127
except ValueError as err:
123128
raise AgentScoreError(
@@ -171,13 +176,24 @@ def create_session(
171176
self,
172177
context: str | None = None,
173178
product_name: str | None = None,
179+
address: str | None = None,
180+
operator_token: str | None = None,
174181
) -> SessionCreateResponse:
175-
"""Create an assessment session for deferred scoring."""
182+
"""Create an assessment session for deferred scoring.
183+
184+
``address`` pre-associates the session with a known wallet (EVM ``0x...`` or
185+
Solana base58). ``operator_token`` pre-associates with an existing ``opc_...`` —
186+
e.g. refresh KYC for a credential.
187+
"""
176188
body: dict[str, Any] = {}
177189
if context is not None:
178190
body["context"] = context
179191
if product_name is not None:
180192
body["product_name"] = product_name
193+
if address is not None:
194+
body["address"] = address
195+
if operator_token is not None:
196+
body["operator_token"] = operator_token
181197
client = self._get_sync_client()
182198
return self._send_sync(lambda: client.post("/v1/sessions", json=body))
183199

@@ -286,13 +302,24 @@ async def acreate_session(
286302
self,
287303
context: str | None = None,
288304
product_name: str | None = None,
305+
address: str | None = None,
306+
operator_token: str | None = None,
289307
) -> SessionCreateResponse:
290-
"""Create an assessment session for deferred scoring."""
308+
"""Create an assessment session for deferred scoring.
309+
310+
``address`` pre-associates the session with a known wallet (EVM ``0x...`` or
311+
Solana base58). ``operator_token`` pre-associates with an existing ``opc_...`` —
312+
e.g. refresh KYC for a credential.
313+
"""
291314
body: dict[str, Any] = {}
292315
if context is not None:
293316
body["context"] = context
294317
if product_name is not None:
295318
body["product_name"] = product_name
319+
if address is not None:
320+
body["address"] = address
321+
if operator_token is not None:
322+
body["operator_token"] = operator_token
296323
client = self._get_async_client()
297324
return await self._send_async(lambda: client.post("/v1/sessions", json=body))
298325

agentscore/errors.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,23 @@
1+
from typing import Any
2+
3+
14
class AgentScoreError(Exception):
2-
def __init__(self, code: str, message: str, status_code: int):
5+
def __init__(
6+
self,
7+
code: str,
8+
message: str,
9+
status_code: int,
10+
details: dict[str, Any] | None = None,
11+
):
312
super().__init__(message)
413
self.code = code
514
self.status_code = status_code
15+
# Response-body fields beyond `error.{code,message}` — e.g. verify_url,
16+
# linked_wallets, claimed_operator, actual_signer, reasons. Consumers
17+
# branch on these for granular recovery (see the mcp denial-code rendering
18+
# in the node sibling for the canonical use). Defaults to {} so callers
19+
# constructing this error by hand without a body can omit it.
20+
self.details: dict[str, Any] = details or {}
621

722
@property
823
def status(self) -> int:

tests/test_client.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -801,6 +801,57 @@ def test_create_session_raises_on_error():
801801
assert exc_info.value.code == "bad_request"
802802

803803

804+
@respx.mock
805+
def test_create_session_forwards_address_and_operator_token():
806+
"""Pre-association lets a session refresh KYC for an existing identity."""
807+
route = respx.post(f"{BASE_URL}/v1/sessions").mock(return_value=httpx.Response(200, json=SESSION_CREATE_PAYLOAD))
808+
client = AgentScore(api_key=API_KEY)
809+
client.create_session(address="0xabc", operator_token="opc_xyz")
810+
body = json.loads(route.calls.last.request.content)
811+
assert body["address"] == "0xabc"
812+
assert body["operator_token"] == "opc_xyz"
813+
814+
815+
@respx.mock
816+
def test_error_response_populates_details_with_non_error_fields():
817+
"""Non-`error` response-body keys flow into AgentScoreError.details so consumers
818+
can branch on verify_url, linked_wallets, claimed_operator, etc. for granular recovery."""
819+
respx.post(f"{BASE_URL}/v1/assess").mock(
820+
return_value=httpx.Response(
821+
403,
822+
json={
823+
"error": {"code": "wallet_signer_mismatch", "message": "Signer mismatch"},
824+
"claimed_operator": "op_abc",
825+
"actual_signer": "0xdef",
826+
"linked_wallets": ["0xabc", "0xdef"],
827+
},
828+
)
829+
)
830+
client = AgentScore(api_key=API_KEY)
831+
with pytest.raises(AgentScoreError) as exc_info:
832+
client.assess(address="0xabc")
833+
err = exc_info.value
834+
assert err.code == "wallet_signer_mismatch"
835+
assert err.details["claimed_operator"] == "op_abc"
836+
assert err.details["actual_signer"] == "0xdef"
837+
assert err.details["linked_wallets"] == ["0xabc", "0xdef"]
838+
assert "error" not in err.details # the `error` key is parsed into code/message, not echoed
839+
840+
841+
@respx.mock
842+
def test_error_response_with_no_extra_fields_yields_empty_details():
843+
respx.post(f"{BASE_URL}/v1/assess").mock(
844+
return_value=httpx.Response(
845+
500,
846+
json={"error": {"code": "internal_error", "message": "Boom"}},
847+
)
848+
)
849+
client = AgentScore(api_key=API_KEY)
850+
with pytest.raises(AgentScoreError) as exc_info:
851+
client.assess(address="0xabc")
852+
assert exc_info.value.details == {}
853+
854+
804855
# ---------------------------------------------------------------------------
805856
# poll_session
806857
# ---------------------------------------------------------------------------
@@ -1047,6 +1098,18 @@ async def test_acreate_session_with_first_class_fields():
10471098
await client.aclose()
10481099

10491100

1101+
@pytest.mark.asyncio
1102+
@respx.mock
1103+
async def test_acreate_session_forwards_address_and_operator_token():
1104+
route = respx.post(f"{BASE_URL}/v1/sessions").mock(return_value=httpx.Response(200, json=SESSION_CREATE_PAYLOAD))
1105+
client = AgentScore(api_key=API_KEY)
1106+
await client.acreate_session(address="0xabc", operator_token="opc_xyz")
1107+
body = json.loads(route.calls.last.request.content)
1108+
assert body["address"] == "0xabc"
1109+
assert body["operator_token"] == "opc_xyz"
1110+
await client.aclose()
1111+
1112+
10501113
# ---------------------------------------------------------------------------
10511114
# Async: apoll_session
10521115
# ---------------------------------------------------------------------------

tests/test_errors.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,23 @@ def test_unknown_error_code():
3434
err = AgentScoreError(code="unknown_error", message="Something went wrong", status_code=500)
3535
assert err.code == "unknown_error"
3636
assert err.status_code == 500
37+
38+
39+
def test_details_defaults_to_empty_dict_when_omitted():
40+
err = AgentScoreError(code="not_found", message="Not found", status_code=404)
41+
assert err.details == {}
42+
43+
44+
def test_details_preserves_response_body_fields_for_granular_recovery():
45+
err = AgentScoreError(
46+
code="wallet_signer_mismatch",
47+
message="Signer mismatch",
48+
status_code=403,
49+
details={
50+
"claimed_operator": "op_abc",
51+
"actual_signer": "0xdef",
52+
"linked_wallets": ["0xabc", "0xdef"],
53+
},
54+
)
55+
assert err.details["claimed_operator"] == "op_abc"
56+
assert err.details["linked_wallets"] == ["0xabc", "0xdef"]

0 commit comments

Comments
 (0)