Skip to content

Commit e9be765

Browse files
committed
feat(agent): add forward-compatible response parsing
1 parent f49d256 commit e9be765

12 files changed

Lines changed: 792 additions & 15 deletions

File tree

‎README.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,10 @@ its Actions can advertise enrollment, payment, and trust protocols, but ODP does
252252
credentials, invoke Actions, submit payments, or implement trust protocols. Applications compose
253253
the appropriate protocol clients around an Action resolved through ODP.
254254

255+
`parse_service_document` is the strict current-version Service parser. Agent inspection and
256+
Directory results filter unrecognized enrollment, payment, and trust descriptors while retaining
257+
strict validation for recognized descriptors.
258+
255259
## Errors and validation
256260

257261
Each role exposes typed errors:

‎scripts/conformance_adapter.py‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,20 +17,26 @@
1717
Page,
1818
derive_service_origin,
1919
is_local_resource_identifier,
20+
parse_agent_service_document,
2021
parse_collection,
22+
parse_collection_page,
2123
parse_collection_search_request,
2224
parse_filter_definition,
25+
parse_filter_definition_page,
2326
parse_offering,
2427
parse_offering_page,
2528
parse_offering_search_request,
29+
parse_problem_details,
2630
parse_problem_response,
2731
parse_resource_identity,
2832
parse_service_document,
2933
parse_sort_definition,
34+
parse_sort_definition_page,
3035
resolve_continuation,
3136
resolve_resource_reference,
3237
validate_value,
3338
)
39+
from offering_protocol.core.validation import _normalize_agent_response
3440
from offering_protocol.directory.transport import HttpRequest, HttpResponse
3541
from offering_protocol.service import CatalogRequest, Request, ServiceBuilder
3642

@@ -265,6 +271,25 @@ async def evaluate_case(subject: str, case: dict[str, Any], role: str) -> bool |
265271
succeeds(lambda: parse_collection_search_request(json.dumps(case["request"])))
266272
== case["valid"]
267273
)
274+
if subject == "composition-contract":
275+
if case.get("operation") == "normalize-agent-response" and role == "agent":
276+
normalized = _normalize_agent_response(case["document"], case["kind"])
277+
_parse_agent_response(normalized, case["kind"])
278+
return normalized == case["expected"]
279+
if case.get("operation") == "validate-advertisement":
280+
document = _service_document_with_protocols(case["protocols"])
281+
return succeeds(lambda: parse_service_document(json.dumps(document))) == case["valid"]
282+
if case.get("operation") == "filter-advertisement" and role == "agent":
283+
document = parse_agent_service_document(
284+
json.dumps(_service_document_with_protocols(case["protocols"]))
285+
)
286+
actual = (
287+
document.protocols.model_dump(mode="json", exclude_defaults=True)
288+
if document.protocols is not None
289+
else {}
290+
)
291+
return actual == case["expected"]
292+
return None
268293
if subject == "offering-search-contract" and case.get("operation") == "validate-request":
269294
return (
270295
succeeds(lambda: parse_offering_search_request(json.dumps(case["request"])))
@@ -308,6 +333,40 @@ async def evaluate_case(subject: str, case: dict[str, Any], role: str) -> bool |
308333
return None
309334

310335

336+
def _parse_agent_response(document: dict[str, Any], kind: str) -> object:
337+
encoded = json.dumps(document, separators=(",", ":"))
338+
parsers: dict[str, Callable[[str], object]] = {
339+
"service-document": parse_agent_service_document,
340+
"collection": parse_collection,
341+
"offering": parse_offering,
342+
"collection-page": parse_collection_page,
343+
"offering-page": parse_offering_page,
344+
"filter-page": parse_filter_definition_page,
345+
"sort-page": parse_sort_definition_page,
346+
"problem": parse_problem_details,
347+
}
348+
parser = parsers.get(kind)
349+
if parser is None:
350+
raise ValueError("Unknown Agent response kind")
351+
return parser(encoded)
352+
353+
354+
def _service_document_with_protocols(protocols: object) -> dict[str, object]:
355+
return {
356+
"description": "ODP Python conformance adapter",
357+
"http": {"endpoint_base": "/odp"},
358+
"language": "en",
359+
"localizations": ["en"],
360+
"name": "Conformance Service",
361+
"odp_version": "1.0",
362+
"operations": [
363+
{"authentication": "not-required", "name": "get-offering"},
364+
{"authentication": "not-required", "name": "list-offerings"},
365+
],
366+
"protocols": protocols,
367+
}
368+
369+
311370
async def evaluate(request: dict[str, Any]) -> dict[str, object]:
312371
try:
313372
actual = await evaluate_case(request["vector"]["subject"], request["case"], request["role"])

‎src/offering_protocol/agent/client.py‎

Lines changed: 47 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,18 +21,30 @@
2121
OfferingSearchRequest,
2222
Operation,
2323
Page,
24+
ProblemDetails,
2425
Representation,
2526
ServiceDocument,
2627
build_operation_url,
2728
derive_service_origin,
28-
parse_collection,
29-
parse_collection_page,
30-
parse_offering,
31-
parse_offering_page,
32-
parse_problem_response,
33-
parse_service_document,
29+
parse_agent_service_document,
3430
resolve_continuation,
3531
)
32+
from offering_protocol.core import (
33+
parse_collection as parse_collection_strict,
34+
)
35+
from offering_protocol.core import (
36+
parse_collection_page as parse_collection_page_strict,
37+
)
38+
from offering_protocol.core import (
39+
parse_offering as parse_offering_strict,
40+
)
41+
from offering_protocol.core import (
42+
parse_offering_page as parse_offering_page_strict,
43+
)
44+
from offering_protocol.core import (
45+
parse_problem_response as parse_problem_response_strict,
46+
)
47+
from offering_protocol.core.validation import _normalize_agent_response
3648
from offering_protocol.directory.transport import (
3749
HttpRequest,
3850
HttpResponse,
@@ -136,10 +148,10 @@ async def inspect(self) -> Inspection:
136148
b"",
137149
_MAXIMUM_DOCUMENT_BYTES,
138150
self._cache_fallbacks.service_document,
139-
parse_service_document,
151+
parse_agent_service_document,
140152
)
141153
return Inspection(
142-
document=parse_service_document(response.body),
154+
document=parse_agent_service_document(response.body),
143155
final_url=response.final_url,
144156
freshness=response.freshness,
145157
requested_url=requested_url,
@@ -571,6 +583,33 @@ def _operation_parser(operation: Operation) -> object:
571583
return parse_offering_page
572584

573585

586+
def parse_collection(data: bytes | str) -> Collection:
587+
return parse_collection_strict(_normalize_body(data, "collection"))
588+
589+
590+
def parse_offering(data: bytes | str) -> Offering:
591+
return parse_offering_strict(_normalize_body(data, "offering"))
592+
593+
594+
def parse_collection_page(data: bytes | str) -> Page[Collection]:
595+
return parse_collection_page_strict(_normalize_body(data, "collection-page"))
596+
597+
598+
def parse_offering_page(data: bytes | str) -> OfferingPage[Offering]:
599+
return parse_offering_page_strict(_normalize_body(data, "offering-page"))
600+
601+
602+
def parse_problem_response(data: bytes | str, status: int) -> ProblemDetails:
603+
return parse_problem_response_strict(_normalize_body(data, "problem"), status)
604+
605+
606+
def _normalize_body(data: bytes | str, kind: str) -> str:
607+
raw = json.loads(data)
608+
if not isinstance(raw, dict):
609+
return data.decode() if isinstance(data, bytes) else data
610+
return json.dumps(_normalize_agent_response(raw, kind), separators=(",", ":"))
611+
612+
574613
def _encode(value: object) -> bytes:
575614
if not hasattr(value, "model_dump_json"):
576615
raise TypeError("ODP model is not serializable")

‎src/offering_protocol/agent/details.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ class DiscoveredAction:
5353
http: DiscoveredHttpAction | None
5454
id: str
5555
openapi: DiscoveredOpenApiAction | None
56-
rel: ActionRelation
56+
rel: ActionRelation | str
5757

5858

5959
@dataclass(frozen=True, slots=True)

‎src/offering_protocol/core/__init__.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from offering_protocol.core.validation import (
1717
OdpValidationError,
1818
ValidationIssue,
19+
parse_agent_service_document,
1920
parse_collection,
2021
parse_collection_page,
2122
parse_collection_search_request,
@@ -44,6 +45,7 @@
4445
"is_local_resource_identifier",
4546
"operation_method",
4647
"operation_path",
48+
"parse_agent_service_document",
4749
"parse_collection",
4850
"parse_collection_page",
4951
"parse_collection_search_request",

‎src/offering_protocol/core/models.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -379,7 +379,7 @@ class Action(OdpModel):
379379
http: HttpActionTarget | None = None
380380
id: str
381381
openapi: OpenApiActionTarget | None = None
382-
rel: ActionRelation
382+
rel: ActionRelation | str
383383

384384

385385
class Offering(OdpModel):

0 commit comments

Comments
 (0)