From d7f544c34b1bc563549fd49f8e4df82d58738a3c Mon Sep 17 00:00:00 2001 From: mmercuri Date: Sat, 25 Apr 2026 19:23:27 -0700 Subject: [PATCH 1/2] instrument: protocol adapters + M7 certification (M1.D + M7 port) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the six protocol adapters and the M7 protocol-certification suite onto the new layerlens.instrument base layer: A2A, AGUI, MCP, AP2, A2UI, UCP Also lands the cross-protocol infrastructure: - base.py: shared protocol adapter mixin - certification.py: CertificationSuite (M7) — exercises every protocol adapter against a canned trace catalog and emits a machine-readable verdict - connection_pool.py / health.py / exceptions.py / _commerce.py: cross-protocol helpers Scope ----- - src/layerlens/instrument/adapters/protocols/{a2a,agui,mcp}/: full per-protocol packages (adapter, events, transport) - src/layerlens/instrument/adapters/protocols/{ap2,a2ui,ucp}.py: single-file adapters - src/layerlens/instrument/adapters/protocols/{base,certification, connection_pool,exceptions,health,_commerce}.py: shared protocol layer - tests/instrument/adapters/protocols/: unit + smoke + certification tests for all six protocols - samples/instrument/{a2a,agui,mcp,ap2,a2ui,ucp}/: runnable per- protocol samples - docs/adapters/{protocols-*,certification}.md: per-protocol integration guide + M7 certification doc - pyproject.toml: six new optional extras (protocols-a2a, protocols-agui, protocols-mcp, protocols-ap2, protocols-a2ui, protocols-ucp) plus the protocols-all umbrella; pyright/ruff exclusions for the dynamic monkey-patching protocol code Blast radius ------------ - Default `pip install layerlens` install set is unchanged. Only protocols-mcp pulls in a non-stdlib dep (`mcp`); protocols-a2a reuses the already-required `httpx`; the other four protocols are self-contained. - protocols-agui currently exposes an empty extra group: the upstream AG-UI client SDK is not yet on PyPI. The adapter is self-contained; the extra is reserved so that consumers can still install `layerlens[protocols-agui]` for forward-compatibility. - M7 certification suite is an additive read-only API. - No changes to existing public API surface. Test plan --------- - uv run pytest tests/instrument/adapters/protocols/ -x -> 107 passed (a2a, agui, mcp, ap2, a2ui, ucp + certification + smoke) Stacks on --------- - feat/instrument-base-foundation (M1.A) — required for the BaseAdapter surface this PR consumes. LAY-3400 umbrella (M1.D + M7). --- docs/adapters/certification.md | 119 ++++ docs/adapters/protocols-a2a.md | 120 ++++ docs/adapters/protocols-a2ui.md | 89 +++ docs/adapters/protocols-agui.md | 97 +++ docs/adapters/protocols-ap2.md | 125 ++++ docs/adapters/protocols-mcp.md | 111 ++++ docs/adapters/protocols-ucp.md | 111 ++++ pyproject.toml | 25 +- samples/instrument/a2a/__init__.py | 0 samples/instrument/a2a/main.py | 102 +++ samples/instrument/a2ui/__init__.py | 0 samples/instrument/a2ui/main.py | 85 +++ samples/instrument/agui/__init__.py | 0 samples/instrument/agui/main.py | 91 +++ samples/instrument/ap2/__init__.py | 0 samples/instrument/ap2/main.py | 110 ++++ samples/instrument/mcp/__init__.py | 0 samples/instrument/mcp/main.py | 110 ++++ samples/instrument/ucp/__init__.py | 0 samples/instrument/ucp/main.py | 97 +++ .../instrument/adapters/protocols/__init__.py | 21 + .../adapters/protocols/_commerce.py | 583 ++++++++++++++++++ .../adapters/protocols/a2a/__init__.py | 18 + .../adapters/protocols/a2a/acp_normalizer.py | 164 +++++ .../adapters/protocols/a2a/adapter.py | 329 ++++++++++ .../adapters/protocols/a2a/agent_card.py | 87 +++ .../adapters/protocols/a2a/client.py | 91 +++ .../adapters/protocols/a2a/server.py | 82 +++ .../adapters/protocols/a2a/sse_handler.py | 79 +++ .../adapters/protocols/a2a/task_lifecycle.py | 104 ++++ .../instrument/adapters/protocols/a2ui.py | 241 ++++++++ .../adapters/protocols/agui/__init__.py | 14 + .../adapters/protocols/agui/adapter.py | 225 +++++++ .../adapters/protocols/agui/event_mapper.py | 86 +++ .../adapters/protocols/agui/middleware.py | 145 +++++ .../adapters/protocols/agui/state_handler.py | 132 ++++ .../instrument/adapters/protocols/ap2.py | 561 +++++++++++++++++ .../instrument/adapters/protocols/base.py | 186 ++++++ .../adapters/protocols/certification.py | 430 +++++++++++++ .../adapters/protocols/connection_pool.py | 127 ++++ .../adapters/protocols/exceptions.py | 156 +++++ .../instrument/adapters/protocols/health.py | 150 +++++ .../adapters/protocols/mcp/__init__.py | 18 + .../adapters/protocols/mcp/adapter.py | 339 ++++++++++ .../protocols/mcp/async_task_tracker.py | 142 +++++ .../adapters/protocols/mcp/elicitation.py | 96 +++ .../adapters/protocols/mcp/mcp_app_handler.py | 58 ++ .../protocols/mcp/structured_output.py | 93 +++ .../adapters/protocols/mcp/tool_wrapper.py | 132 ++++ .../instrument/adapters/protocols/ucp.py | 447 ++++++++++++++ tests/instrument/adapters/__init__.py | 0 .../instrument/adapters/protocols/__init__.py | 0 .../adapters/protocols/test_a2a_adapter.py | 204 ++++++ .../adapters/protocols/test_a2ui_adapter.py | 160 +++++ .../adapters/protocols/test_agui_adapter.py | 191 ++++++ .../adapters/protocols/test_ap2_adapter.py | 256 ++++++++ .../adapters/protocols/test_certification.py | 238 +++++++ .../adapters/protocols/test_mcp_adapter.py | 261 ++++++++ .../protocols/test_protocols_smoke.py | 90 +++ .../adapters/protocols/test_ucp_adapter.py | 188 ++++++ 60 files changed, 8315 insertions(+), 1 deletion(-) create mode 100644 docs/adapters/certification.md create mode 100644 docs/adapters/protocols-a2a.md create mode 100644 docs/adapters/protocols-a2ui.md create mode 100644 docs/adapters/protocols-agui.md create mode 100644 docs/adapters/protocols-ap2.md create mode 100644 docs/adapters/protocols-mcp.md create mode 100644 docs/adapters/protocols-ucp.md create mode 100644 samples/instrument/a2a/__init__.py create mode 100644 samples/instrument/a2a/main.py create mode 100644 samples/instrument/a2ui/__init__.py create mode 100644 samples/instrument/a2ui/main.py create mode 100644 samples/instrument/agui/__init__.py create mode 100644 samples/instrument/agui/main.py create mode 100644 samples/instrument/ap2/__init__.py create mode 100644 samples/instrument/ap2/main.py create mode 100644 samples/instrument/mcp/__init__.py create mode 100644 samples/instrument/mcp/main.py create mode 100644 samples/instrument/ucp/__init__.py create mode 100644 samples/instrument/ucp/main.py create mode 100644 src/layerlens/instrument/adapters/protocols/__init__.py create mode 100644 src/layerlens/instrument/adapters/protocols/_commerce.py create mode 100644 src/layerlens/instrument/adapters/protocols/a2a/__init__.py create mode 100644 src/layerlens/instrument/adapters/protocols/a2a/acp_normalizer.py create mode 100644 src/layerlens/instrument/adapters/protocols/a2a/adapter.py create mode 100644 src/layerlens/instrument/adapters/protocols/a2a/agent_card.py create mode 100644 src/layerlens/instrument/adapters/protocols/a2a/client.py create mode 100644 src/layerlens/instrument/adapters/protocols/a2a/server.py create mode 100644 src/layerlens/instrument/adapters/protocols/a2a/sse_handler.py create mode 100644 src/layerlens/instrument/adapters/protocols/a2a/task_lifecycle.py create mode 100644 src/layerlens/instrument/adapters/protocols/a2ui.py create mode 100644 src/layerlens/instrument/adapters/protocols/agui/__init__.py create mode 100644 src/layerlens/instrument/adapters/protocols/agui/adapter.py create mode 100644 src/layerlens/instrument/adapters/protocols/agui/event_mapper.py create mode 100644 src/layerlens/instrument/adapters/protocols/agui/middleware.py create mode 100644 src/layerlens/instrument/adapters/protocols/agui/state_handler.py create mode 100644 src/layerlens/instrument/adapters/protocols/ap2.py create mode 100644 src/layerlens/instrument/adapters/protocols/base.py create mode 100644 src/layerlens/instrument/adapters/protocols/certification.py create mode 100644 src/layerlens/instrument/adapters/protocols/connection_pool.py create mode 100644 src/layerlens/instrument/adapters/protocols/exceptions.py create mode 100644 src/layerlens/instrument/adapters/protocols/health.py create mode 100644 src/layerlens/instrument/adapters/protocols/mcp/__init__.py create mode 100644 src/layerlens/instrument/adapters/protocols/mcp/adapter.py create mode 100644 src/layerlens/instrument/adapters/protocols/mcp/async_task_tracker.py create mode 100644 src/layerlens/instrument/adapters/protocols/mcp/elicitation.py create mode 100644 src/layerlens/instrument/adapters/protocols/mcp/mcp_app_handler.py create mode 100644 src/layerlens/instrument/adapters/protocols/mcp/structured_output.py create mode 100644 src/layerlens/instrument/adapters/protocols/mcp/tool_wrapper.py create mode 100644 src/layerlens/instrument/adapters/protocols/ucp.py create mode 100644 tests/instrument/adapters/__init__.py create mode 100644 tests/instrument/adapters/protocols/__init__.py create mode 100644 tests/instrument/adapters/protocols/test_a2a_adapter.py create mode 100644 tests/instrument/adapters/protocols/test_a2ui_adapter.py create mode 100644 tests/instrument/adapters/protocols/test_agui_adapter.py create mode 100644 tests/instrument/adapters/protocols/test_ap2_adapter.py create mode 100644 tests/instrument/adapters/protocols/test_certification.py create mode 100644 tests/instrument/adapters/protocols/test_mcp_adapter.py create mode 100644 tests/instrument/adapters/protocols/test_protocols_smoke.py create mode 100644 tests/instrument/adapters/protocols/test_ucp_adapter.py diff --git a/docs/adapters/certification.md b/docs/adapters/certification.md new file mode 100644 index 0000000..5248aaf --- /dev/null +++ b/docs/adapters/certification.md @@ -0,0 +1,119 @@ +# Protocol adapter certification suite + +`layerlens.instrument.adapters.protocols.certification.ProtocolCertificationSuite` +runs GA-readiness checks against protocol adapter classes. It validates +that an adapter class complies with the `BaseProtocolAdapter` contract +required for General Availability release. + +This is a developer/CI tool — there's no runnable end-user sample. The +suite is invoked from your certification CI to gate adapter releases. + +## Install + +The certification suite is part of the base SDK install — no extra is +required: + +```bash +pip install layerlens +``` + +## Quick start + +```python +from layerlens.instrument.adapters.protocols.a2a import A2AAdapter +from layerlens.instrument.adapters.protocols.certification import ( + ProtocolCertificationSuite, +) + +suite = ProtocolCertificationSuite() +result = suite.certify(A2AAdapter) +assert result.passed, result.summary() + +# Or certify all GA protocol adapters at once: +results = suite.certify_all() +assert all(r.passed for r in results) +``` + +## What gets checked + +The suite runs the following categories of checks against every +candidate adapter class: + +| Category | What's verified | +|---|---| +| Inheritance | Class extends both `BaseAdapter` and `BaseProtocolAdapter`. | +| Required class attributes | `FRAMEWORK`, `PROTOCOL`, `PROTOCOL_VERSION`, `VERSION` are non-empty strings. | +| Required methods | `connect`, `disconnect`, `health_check`, `get_adapter_info`, `serialize_for_replay`, `probe_health` all defined and not abstract. | +| Lifecycle correctness | `connect()` then `disconnect()` succeeds without exception, leaves the adapter in `DISCONNECTED` state. | +| Error handling | Adapter does not raise on construction with default args; `health_check()` returns an `AdapterHealth` even before `connect()`. | +| Return-type contracts | `get_adapter_info()` returns `AdapterInfo`, `probe_health()` returns `dict`, `serialize_for_replay()` returns `ReplayableTrace`. | + +Each check produces a `CheckResult` with `passed: bool`, `message: str`, +and `severity: "error" | "warning"`. Warnings do not fail the +certification, errors do. + +## Result types + +```python +@dataclass +class CheckResult: + name: str + passed: bool + message: str + severity: str # "error" | "warning" + +@dataclass +class CertificationResult: + passed: bool + adapter_name: str + protocol_version: str + checks: list[dict[str, Any]] # serialised CheckResult entries + + def summary(self) -> str: ... +``` + +## Integrating into CI + +A typical CI step: + +```python +# tests/instrument/test_protocol_certification.py +from layerlens.instrument.adapters.protocols.certification import ( + ProtocolCertificationSuite, +) + + +def test_all_protocol_adapters_pass_ga_certification() -> None: + suite = ProtocolCertificationSuite() + results = suite.certify_all() + + failures = [r for r in results if not r.passed] + assert not failures, "\n".join(r.summary() for r in failures) +``` + +`certify_all()` covers the three current GA protocol adapters: + +- `A2AAdapter` +- `AGUIAdapter` +- `MCPExtensionsAdapter` + +For commerce-protocol adapters (AP2, A2UI, UCP) certify each one +explicitly with `suite.certify(MyAdapterClass)` — they share the same +`BaseProtocolAdapter` contract. + +## Adding a new check + +To add a new check to the suite, append a private `_check_*` method that +returns a `CheckResult` (or `list[CheckResult]`) and call it from +`certify()`. Keep the contract narrow: each check should test one +invariant and produce a clear failure message naming the adapter class. + +## What this suite does NOT verify + +- Per-protocol semantic correctness (does A2A actually emit the right + events for the protocol?). That belongs in the per-adapter unit and + live tests under `tests/instrument/adapters/protocols/`. +- Performance under load. The suite runs a single `connect()`/`disconnect()` + pair — load-testing is out of scope. +- Backward compatibility. Use the schema-compatibility test in + `tests/instrument/test_event_schema_compat.py` for that. diff --git a/docs/adapters/protocols-a2a.md b/docs/adapters/protocols-a2a.md new file mode 100644 index 0000000..8085661 --- /dev/null +++ b/docs/adapters/protocols-a2a.md @@ -0,0 +1,120 @@ +# A2A protocol adapter + +`layerlens.instrument.adapters.protocols.a2a.A2AAdapter` instruments the +[Agent-to-Agent (A2A) protocol](https://github.com/google/A2A) via +dual-channel instrumentation: server-side wrapping intercepts incoming +JSON-RPC requests and SSE streams, client-side wrapping traces outgoing +task submissions and streamed updates. + +The adapter also handles ACP-origin payloads (IBM Agent Communication +Protocol, merged into A2A in August 2025) via a built-in +`ACPNormalizer`. + +## Install + +```bash +pip install 'layerlens[protocols-a2a]' +``` + +The base SDK already includes `httpx`, so no additional packages are +strictly required. To use the official A2A SDK, install `a2a-sdk` +separately. + +## Quick start + +```python +from layerlens.instrument.adapters.protocols.a2a import A2AAdapter +from layerlens.instrument.transport.sink_http import HttpEventSink + +sink = HttpEventSink(adapter_name="a2a") +adapter = A2AAdapter() +adapter.add_sink(sink) +adapter.connect() + +# Register an Agent Card discovered from a peer agent +adapter.register_agent_card( + { + "name": "research-agent", + "url": "https://research.example.com/.well-known/agent.json", + "protocolVersion": "0.2.0", + "skills": [{"id": "search", "name": "Web search"}], + }, + source="discovery", +) + +# Trace a task submission + completion +adapter.on_task_submitted( + task_id="t-001", + receiver_url="https://research.example.com/a2a", + task_type="research", + submitter_agent_id="orchestrator-1", +) +adapter.on_task_completed( + task_id="t-001", + final_status="completed", + artifacts=[{"type": "text", "content": "..."}], +) + +adapter.disconnect() +sink.close() +``` + +## What's wrapped + +`A2AAdapter` provides a set of `on_*` methods that the host application +calls at the appropriate hook points: + +- `register_agent_card(card_data, source)` — emits `protocol.agent_card`. +- `on_task_submitted(task_id, ...)` — emits `protocol.task_submitted`. +- `on_task_completed(task_id, final_status, ...)` — emits + `protocol.task_completed`. +- `on_task_delegation(from_agent, to_agent, ...)` — emits + `protocol.task_delegation` and `agent.handoff`. +- `on_stream_event(task_id, event_type, data)` — emits + `protocol.stream_event` for each SSE message. + +Server- and client-side wrappers (`A2AClient`, `A2AServer` helpers in +`a2a/client.py` and `a2a/server.py`) automatically call these hooks from +the JSON-RPC layer. + +## Events emitted + +| Event | Layer | When | +|---|---|---| +| `protocol.agent_card` | L4a | Per `register_agent_card` call. | +| `protocol.task_submitted` | L4a | Per outbound or inbound task submission. | +| `protocol.task_completed` | L4a | Per task terminal status. | +| `protocol.task_delegation` | L4a | Per cross-agent delegation. | +| `protocol.stream_event` | L4a | Per SSE event in a streamed task. | +| `agent.handoff` | L4a | Mirrors `protocol.task_delegation` for the agent timeline. | + +## A2A specifics + +- **ACP normalization**: `ACPNormalizer` detects ACP-shaped payloads and + rewrites them to A2A shape before emission. The original protocol + origin (`acp` vs `a2a`) is preserved on the event. +- **Task state machine**: `TaskStateMachine` (in `a2a/task_lifecycle.py`) + validates terminal-state transitions; invalid transitions emit + `policy.violation` rather than `task_completed`. +- **Memory sharing**: pass `memory_service=...` to the constructor and + the adapter will store completed task contexts as episodic memory and + share to a target agent via `AgentMemoryService.share_memory()`. +- **Artifact hashing**: returned artifacts are sha256-hashed and stored + on the event as `artifact_hashes` for later content-addressing. +- **Health**: `probe_health(endpoint)` fetches `endpoint/.well-known/agent.json` + and returns reachability + latency. + +## Capture config + +```python +from layerlens.instrument.adapters._base import CaptureConfig + +# All protocol.* events are L4a — the standard preset captures them. +adapter = A2AAdapter(capture_config=CaptureConfig.standard()) +``` + +## BYOK + +A2A authentication is per-agent (`authScheme` in the Agent Card). The +adapter does not own those credentials; they belong to the underlying +A2A client. For platform-managed BYOK see `docs/adapters/byok.md`. diff --git a/docs/adapters/protocols-a2ui.md b/docs/adapters/protocols-a2ui.md new file mode 100644 index 0000000..df6865a --- /dev/null +++ b/docs/adapters/protocols-a2ui.md @@ -0,0 +1,89 @@ +# A2UI (Agent-to-User Interface) protocol adapter + +`layerlens.instrument.adapters.protocols.a2ui.A2UIAdapter` instruments +the A2UI protocol — surface lifecycle and user-action events for +agent-driven UI experiences (checkout widgets, confirmation dialogs, +inline agent panels). + +## Install + +```bash +pip install 'layerlens[protocols-a2ui]' +``` + +The `protocols-a2ui` extra has no required dependencies; the adapter +operates on protocol payloads, not on a specific SDK. + +## Quick start + +```python +from layerlens.instrument.adapters.protocols.a2ui import A2UIAdapter +from layerlens.instrument.transport.sink_http import HttpEventSink + +sink = HttpEventSink(adapter_name="a2ui") +adapter = A2UIAdapter() +adapter.add_sink(sink) +adapter.connect() + +adapter.on_surface_created( + surface_id="surf-checkout-1", + org_id="org-123", + root_component_id="cmp-checkout-root", + component_count=12, +) + +adapter.on_user_action( + surface_id="surf-checkout-1", + action_name="confirm_purchase", + org_id="org-123", + component_id="cmp-confirm-btn", + context={"cart_total": 49.99, "currency": "USD"}, +) + +adapter.disconnect() +sink.close() +``` + +## What's wrapped + +`A2UIAdapter` exposes two primary hooks the host UI runtime calls: + +- `on_surface_created(surface_id, org_id, root_component_id, component_count)` + — emits `commerce.ui.surface_created` and registers the surface + in-process for action correlation. +- `on_user_action(surface_id, action_name, org_id, component_id, context)` + — emits `commerce.ui.user_action`. The `context` dict is **always** + sha256-hashed before emission; cleartext context never leaves the host. + +## Events emitted + +| Event | Layer | When | +|---|---|---| +| `commerce.ui.surface_created` | L7c | Per `on_surface_created`. | +| `commerce.ui.user_action` | L7c | Per `on_user_action`. | + +Like AP2 events, `commerce.ui.*` events bypass `CaptureConfig` gating +via `ALWAYS_ENABLED_EVENT_TYPES` — these are audit-critical UI events. + +## A2UI specifics + +- **PII safety by construction**: `context` is hashed before emission. + This is a hard guarantee — the cleartext value is never stored on the + event, never logged at INFO level, and never written to disk. Only the + hash is suitable for cross-event correlation; for content inspection, + use the host's own logging at debug level. +- **Component-level granularity**: `component_id` is optional but + recommended for analytics — surfaces can drill into per-component + conversion funnels. +- **Surface tree summary**: only `component_count` and `root_component_id` + are captured at surface creation. Full tree introspection is left to + the host application. + +## Capture config + +`commerce.*` events are always captured regardless of `CaptureConfig` +flags. + +## BYOK + +Not applicable — A2UI is transport-only at the protocol layer. diff --git a/docs/adapters/protocols-agui.md b/docs/adapters/protocols-agui.md new file mode 100644 index 0000000..5541b87 --- /dev/null +++ b/docs/adapters/protocols-agui.md @@ -0,0 +1,97 @@ +# AG-UI protocol adapter + +`layerlens.instrument.adapters.protocols.agui.AGUIAdapter` instruments +the [AG-UI (Agent-User Interaction) protocol](https://github.com/ag-ui-protocol/ag-ui) +by intercepting the SSE event stream between an agent backend and a +frontend client. + +## Install + +```bash +pip install 'layerlens[protocols-agui]' +``` + +Pulls `ag-ui>=0.1`. Requires Python 3.10+. + +## Quick start + +```python +from layerlens.instrument.adapters.protocols.agui import AGUIAdapter +from layerlens.instrument.transport.sink_http import HttpEventSink + +sink = HttpEventSink(adapter_name="agui") +adapter = AGUIAdapter() +adapter.add_sink(sink) +adapter.connect() + +# Each AG-UI SSE event becomes a LayerLens event: +adapter.on_agui_event( + "TEXT_MESSAGE_START", + payload={"thread_id": "thread-1", "message_id": "msg-1"}, +) +adapter.on_agui_event( + "TEXT_MESSAGE_CONTENT", + payload={"thread_id": "thread-1", "content": "Hello"}, +) +adapter.on_agui_event( + "TEXT_MESSAGE_END", + payload={"thread_id": "thread-1"}, +) + +adapter.disconnect() +sink.close() +``` + +In production the adapter is wired in as ASGI/WSGI middleware around the +agent's SSE handler — see `agui/middleware.py`. + +## What's wrapped + +`AGUIAdapter.on_agui_event(event_type, payload)` is the single hook the +host calls per SSE event. The adapter routes the event to the appropriate +Stratix event type via `event_mapper.map_agui_to_stratix`. + +## Events emitted + +| Event | Layer | When | +|---|---|---| +| `protocol.stream.event` | L6b | Per AG-UI SSE event (gated by `l6b_protocol_streams` for high-frequency content). | +| `agent.state.change` | cross-cutting | Per `STATE_SNAPSHOT` / `STATE_DELTA` / lifecycle event. | +| `tool.call` | L5a | Per `TOOL_CALL_START` / `TOOL_CALL_END`. | + +## AG-UI specifics + +- **Text message buffering**: `TEXT_MESSAGE_START` → `TEXT_MESSAGE_CONTENT*` + → `TEXT_MESSAGE_END` is buffered into a single `full_text` field on the + end event. Per-chunk events are gated by `CaptureConfig.l6b_protocol_streams`. +- **Payload hashing**: every emitted `protocol.stream.event` contains a + sha256 of the original payload, so the platform can verify reproducibility + without storing the full body. +- **State diffing**: `STATE_DELTA` events compute `before_hash` / + `after_hash` and update an in-memory cache for the thread. +- **Custom events**: AG-UI's `CUSTOM_EVENT` type is preserved verbatim in + the `payload_summary` field (truncated to 200 chars). + +## Capture config + +```python +from layerlens.instrument.adapters._base import CaptureConfig + +# Standard preset captures lifecycle + tool events but suppresses the +# high-frequency text-content stream. +adapter = AGUIAdapter(capture_config=CaptureConfig.standard()) + +# Full content (debug builds only). +adapter = AGUIAdapter( + capture_config=CaptureConfig( + l1_agent_io=True, + l5a_tool_calls=True, + l6b_protocol_streams=True, + ), +) +``` + +## BYOK + +AG-UI is transport-only. There are no model API keys involved at the +protocol layer. diff --git a/docs/adapters/protocols-ap2.md b/docs/adapters/protocols-ap2.md new file mode 100644 index 0000000..4e04ab9 --- /dev/null +++ b/docs/adapters/protocols-ap2.md @@ -0,0 +1,125 @@ +# AP2 (Agent Payments Protocol) adapter + +`layerlens.instrument.adapters.protocols.ap2.AP2Adapter` instruments the +[Agent Payments Protocol](https://github.com/google/agent-payments-protocol) +— the three-stage authorization chain for autonomous-agent commerce: + +1. **Intent Mandate** — spending guardrails + merchant constraints. +2. **Payment Mandate** — cryptographic authorization to pay. +3. **Payment Receipt** — settlement confirmation. + +## Install + +```bash +pip install 'layerlens[protocols-ap2]' +``` + +The `protocols-ap2` extra has no required dependencies; the adapter +operates on protocol payloads, not on a specific SDK. + +## Quick start + +```python +from layerlens.instrument.adapters.protocols.ap2 import AP2Adapter +from layerlens.instrument.transport.sink_http import HttpEventSink + +sink = HttpEventSink(adapter_name="ap2") +adapter = AP2Adapter() +adapter.add_sink(sink) +adapter.connect() + +# Configure a per-org policy +adapter.configure_policy( + org_id="org-123", + max_single_tx=500.0, + daily_limit=2000.0, + allowed_merchants=["merchant-shopify"], +) + +# Record the three-stage chain +violations = adapter.on_intent_mandate_created( + mandate_id="im-1", + description="Buy 1 shirt under $50", + org_id="org-123", + merchants=["merchant-shopify"], + max_amount=50.0, + intent_expiry="2026-04-26T00:00:00Z", + agent_id="agent-shopper", +) +if not violations: + adapter.on_payment_mandate_signed( + mandate_id="im-1", + payment_details_id="pd-1", + total_amount=49.99, + merchant_agent="merchant-shopify", + org_id="org-123", + signature="", + ) + adapter.on_payment_receipt_issued( + mandate_id="im-1", + payment_id="pay-1", + amount=49.99, + org_id="org-123", + merchant_confirmation_id="conf-1", + ) + +adapter.disconnect() +sink.close() +``` + +## What's wrapped + +`AP2Adapter` exposes hook methods that the host (an agent or marketplace) +calls at each commerce step: + +- `on_intent_mandate_created(...)` — emits intent + validation events, + evaluates org-level guardrails, returns violation messages. +- `on_payment_mandate_signed(...)` — emits the signed payment mandate. + Updates cumulative spending and emits spending-threshold events when + configured limits are exceeded. The raw signature is sha256-hashed + before storage. +- `on_payment_receipt_issued(...)` — emits the settlement receipt and + closes out the mandate from the in-memory registry. +- `configure_policy(org_id, ...)` — installs guardrail config for an org. + +## Events emitted + +| Event | Layer | When | +|---|---|---| +| `commerce.intent.created` | L7c | Per `on_intent_mandate_created`. | +| `commerce.intent.validated` | L7c | Per intent (after guardrail evaluation). | +| `commerce.guardrail.violation` | L7c | Per failed guardrail. | +| `commerce.mandate.signed` | L7c | Per `on_payment_mandate_signed`. | +| `commerce.spending.threshold` | L7c | Per cumulative-spend threshold breach. | +| `commerce.payment.receipt` | L7c | Per `on_payment_receipt_issued`. | + +All `commerce.*` events bypass the `CaptureConfig` gate via the +`ALWAYS_ENABLED_EVENT_TYPES` rule — these are audit-critical events that +must never be suppressed by capture configuration. + +## AP2 specifics + +- **Guardrail evaluation**: configured via `configure_policy(...)`. Supported + rules: `max_single_tx`, `daily_limit`, `weekly_limit`, `monthly_limit`, + `allowed_merchants` (whitelist), `require_refundability`, plus per-mandate + `intent_expiry` enforcement at sign time. +- **Signature handling**: payment-mandate signatures are sha256-hashed + before being stored on the event. The raw signature value is never + written to the event stream — this preserves auditability without + retaining sensitive key material. +- **Cumulative spending**: tracked per-org in-process. For long-running + workers, persist the cumulative state externally (see + `memory_service=` constructor arg). +- **Expiry enforcement**: if an intent mandate has expired by the time + `on_payment_mandate_signed` is called, a `commerce.guardrail.violation` + is emitted before the signed event. + +## Capture config + +`commerce.*` events are always captured regardless of the +`CaptureConfig` flags — this is a deliberate platform invariant. + +## BYOK + +AP2 cryptographic keys are managed by the host application. The adapter +does not own them; only the sha256 of the signature is captured. diff --git a/docs/adapters/protocols-mcp.md b/docs/adapters/protocols-mcp.md new file mode 100644 index 0000000..0d3537a --- /dev/null +++ b/docs/adapters/protocols-mcp.md @@ -0,0 +1,111 @@ +# MCP Extensions protocol adapter + +`layerlens.instrument.adapters.protocols.mcp.MCPExtensionsAdapter` instruments +the [Model Context Protocol](https://modelcontextprotocol.io/) extensions +introduced in 2025: elicitation, structured tool outputs, async tasks, +MCP apps, and OAuth 2.1 authentication. + +The base MCP tool/resource calls are already covered by the host's +runtime (e.g. the `mcp` Python SDK); this adapter focuses on the +extensions that introduce new shapes the framework does not natively +trace. + +## Install + +```bash +pip install 'layerlens[protocols-mcp]' +``` + +Pulls `mcp>=0.9`. Requires Python 3.10+. + +## Quick start + +```python +from layerlens.instrument.adapters.protocols.mcp import MCPExtensionsAdapter +from layerlens.instrument.transport.sink_http import HttpEventSink + +sink = HttpEventSink(adapter_name="mcp_extensions") +adapter = MCPExtensionsAdapter() +adapter.add_sink(sink) +adapter.connect() + +# Trace a tool call +adapter.on_tool_call( + tool_name="search", + input_data={"q": "weather in NYC"}, + output_data={"result": "..."}, + latency_ms=42.5, +) + +# Trace structured output validation +adapter.on_structured_output( + tool_name="search", + output={"result": "..."}, + schema={"type": "object", "$id": "search-result-v1"}, + validation_passed=True, +) + +adapter.disconnect() +sink.close() +``` + +## What's wrapped + +`MCPExtensionsAdapter` exposes a set of `on_*` hooks. The host MCP server +calls them at the appropriate extension points: + +- `on_tool_call(tool_name, input_data, output_data, error, latency_ms)` — + per tool invocation. +- `on_structured_output(tool_name, output, schema, validation_passed, + validation_errors)` — per structured-output validation. +- `on_elicitation_request(prompt_id, prompt, schema)` — when the server + prompts the user for structured input. +- `on_elicitation_response(prompt_id, response, valid)` — user reply. +- `on_async_task(task_id, status, ...)` — long-running tool task lifecycle. +- `on_mcp_app_invocation(app_id, ...)` — interactive UI components + invoked as tools. +- `on_auth_event(event_type, ...)` — OAuth 2.1 / OpenID Connect events + inside an MCP session. + +## Events emitted + +| Event | Layer | When | +|---|---|---| +| `tool.call` | L5a | Per `on_tool_call`. | +| `protocol.structured_output` | L4a | Per `on_structured_output`. | +| `protocol.elicitation_request` | L4a | Per `on_elicitation_request`. | +| `protocol.elicitation_response` | L4a | Per `on_elicitation_response`. | +| `protocol.async_task` | L4a | Per `on_async_task`. | +| `protocol.mcp_app_invocation` | L4a | Per `on_mcp_app_invocation`. | +| `protocol.auth_event` | cross-cutting | Per `on_auth_event`. | +| `policy.violation` | cross-cutting | When `validation_passed=False`. | + +## MCP specifics + +- **Schema hashing**: structured-output schemas are sha256-hashed and + the hash is stored on the event. If the schema declares `$id`, that + identifier is captured separately. +- **Output hashing**: tool outputs are likewise sha256-hashed for + reproducibility verification without storing the full body. +- **Procedural memory**: when a `memory_service=` is passed to the + constructor, recurring tool patterns are stored as procedural memory + entries (`memory_type="procedural"`, importance 0.4). Failures are + swallowed. +- **Async tasks**: `on_async_task` accepts `status` values + (`pending|running|completed|failed`) and computes duration on the + terminal transition. + +## Capture config + +```python +from layerlens.instrument.adapters._base import CaptureConfig + +# All MCP extension events are L4a / L5a — covered by standard. +adapter = MCPExtensionsAdapter(capture_config=CaptureConfig.standard()) +``` + +## BYOK + +OAuth 2.1 client credentials live with the host MCP server. The adapter +does not own them. For platform-managed BYOK see +`docs/adapters/byok.md`. diff --git a/docs/adapters/protocols-ucp.md b/docs/adapters/protocols-ucp.md new file mode 100644 index 0000000..999d177 --- /dev/null +++ b/docs/adapters/protocols-ucp.md @@ -0,0 +1,111 @@ +# UCP (Universal Commerce Protocol) adapter + +`layerlens.instrument.adapters.protocols.ucp.UCPAdapter` instruments the +Universal Commerce Protocol — supplier discovery, catalog browsing, +checkout sessions, and order refunds. + +## Install + +```bash +pip install 'layerlens[protocols-ucp]' +``` + +The `protocols-ucp` extra has no required dependencies; the adapter +operates on protocol payloads, not on a specific SDK. + +## Quick start + +```python +from layerlens.instrument.adapters.protocols.ucp import UCPAdapter +from layerlens.instrument.transport.sink_http import HttpEventSink + +sink = HttpEventSink(adapter_name="ucp") +adapter = UCPAdapter() +adapter.add_sink(sink) +adapter.connect() + +org_id = "org-123" + +adapter.on_supplier_discovered( + supplier_id="sup-acme", + name="Acme Supplies", + profile_url="https://acme.example.com/.well-known/ucp.json", + org_id=org_id, + capabilities=["catalog", "checkout"], + discovery_method="well_known", +) + +adapter.on_checkout_created( + checkout_session_id="cs-1", + supplier_id="sup-acme", + line_items=[{"item_id": "sku-1", "quantity": 2, "unit_price": 24.99}], + total_amount=49.98, + org_id=org_id, + currency="USD", + idempotency_key="idem-1", +) + +adapter.on_checkout_completed( + checkout_session_id="cs-1", + org_id=org_id, + order_id="ord-1", +) + +adapter.disconnect() +sink.close() +``` + +## What's wrapped + +`UCPAdapter` exposes hooks the host calls at each lifecycle stage: + +- `on_supplier_discovered(supplier_id, name, profile_url, org_id, + capabilities, discovery_method)` +- `on_catalog_browsed(supplier_id, org_id, items_viewed, items_selected)` +- `on_checkout_created(checkout_session_id, supplier_id, line_items, + total_amount, org_id, currency, idempotency_key)` +- `on_checkout_completed(checkout_session_id, org_id, order_id)` +- `on_order_refunded(order_id, refund_amount, currency, org_id, reason)` + +Checkout sessions are tracked in-process from `created` to `completed`, +and the duration is computed and logged. + +## Events emitted + +| Event | Layer | When | +|---|---|---| +| `commerce.supplier.discovered` | L7b | Per `on_supplier_discovered`. | +| `commerce.catalog.browsed` | L7b | Per `on_catalog_browsed`. | +| `commerce.checkout.created` | L7b | Per `on_checkout_created`. | +| `commerce.checkout.completed` | L7b | Per `on_checkout_completed`. | +| `commerce.order.refunded` | L7b | Per `on_order_refunded`. | +| `commerce.supplier.event` | L7b | Per `on_supplier_event` callback (catch-all). | + +All `commerce.*` events bypass `CaptureConfig` gating via +`ALWAYS_ENABLED_EVENT_TYPES`. + +## UCP specifics + +- **Discovery methods**: `well_known` (RFC 8615 `/.well-known/ucp.json`), + `registry` (a registry returned the supplier), `referral` (another + agent referred us). +- **Catalog browse rollups**: `on_catalog_browsed` is summary-only — only + `items_viewed` + `items_selected` counts are captured to keep the + payload size bounded for high-frequency browsing. +- **Idempotency**: `on_checkout_created` accepts an `idempotency_key` so + retries can be correlated. The platform uses this key to dedupe + duplicate checkout creation events. +- **Semantic memory**: when `memory_service=` is set, supplier metadata + is stored as a semantic memory entry on first discovery. +- **Session-level duration**: duration is computed from + `_session_start_times[checkout_session_id]` to the completion call. + +## Capture config + +`commerce.*` events are always captured regardless of `CaptureConfig` +flags. + +## BYOK + +Not applicable — UCP authentication is per-supplier and managed by the +host application's UCP client. diff --git a/pyproject.toml b/pyproject.toml index ae6d1dc..462eb85 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,23 @@ classifiers = [ [project.optional-dependencies] cli = ["click>=8.0.0"] +# --- Instrument layer: protocol adapters --- +# Adding any extra below MUST keep the default `pip install layerlens` +# install set unchanged. Verified by `tests/instrument/test_default_install.py`. +protocols-a2a = ["httpx>=0.23.0, <1"] # base SDK already pulls httpx +# AG-UI: the adapter is self-contained today; once an upstream client SDK +# is published on PyPI, pin it here. Until then, this extra is an +# allow-listed empty group so consumers can still install +# `layerlens[protocols-agui]` for forward-compatibility. +protocols-agui = [] +protocols-mcp = ["mcp>=0.9; python_version >= '3.10'"] +protocols-ap2 = [] +protocols-a2ui = [] +protocols-ucp = [] +protocols-all = [ + "mcp>=0.9; python_version >= '3.10'", +] + [project.urls] Homepage = "https://github.com/LayerLens/stratix-python" Repository = "https://github.com/LayerLens/stratix-python" @@ -139,14 +156,20 @@ known-first-party = ["openai", "tests"] "tests/**.py" = ["T201", "T203", "ARG", "B007"] "examples/**.py" = ["T201", "T203"] "src/layerlens/cli/**" = ["T201", "T203"] +# Protocol adapters have callback-shape constraints dictated by upstream. +"src/layerlens/instrument/adapters/protocols/**.py" = ["ARG002"] [tool.pyright] include = ["src", "tests"] exclude = ["**/__pycache__"] reportMissingTypeStubs = false -# Less strict settings for tests and cli +# Less strict settings for tests, cli, and the dynamic-monkey-patching +# protocol adapter code. mypy --strict stays strict for these dirs; +# pyright is relaxed here because it can't follow runtime attribute +# mutation that the protocol instrumentation relies on. executionEnvironments = [ { root = "src/layerlens/cli", reportMissingImports = false, reportFunctionMemberAccess = false, reportCallIssue = false, reportArgumentType = false, reportAttributeAccessIssue = false }, + { root = "src/layerlens/instrument/adapters/protocols", reportPossiblyUnbound = false, reportPossiblyUnboundVariable = false, reportCallIssue = false, reportAttributeAccessIssue = false, reportArgumentType = false, reportMissingImports = false, reportFunctionMemberAccess = false }, { root = "tests", reportGeneralTypeIssues = false, reportOptionalSubscript = false, reportOptionalMemberAccess = false, reportUntypedFunctionDecorator = false, reportUnknownArgumentType = false, reportUnknownMemberType = false, reportUnknownVariableType = false, reportUnnecessaryIsInstance = false, reportUnnecessaryComparison = false, reportArgumentType = false, reportCallIssue = false }, ] diff --git a/samples/instrument/a2a/__init__.py b/samples/instrument/a2a/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/samples/instrument/a2a/main.py b/samples/instrument/a2a/main.py new file mode 100644 index 0000000..cc29382 --- /dev/null +++ b/samples/instrument/a2a/main.py @@ -0,0 +1,102 @@ +"""Sample: simulate an A2A protocol exchange with the LayerLens adapter. + +Constructs an in-memory A2A scenario — agent-card discovery, task +submission, task completion — without contacting any real A2A server. +The adapter emits ``protocol.agent_card`` + ``protocol.task_submitted`` + +``protocol.task_completed`` events that ship to atlas-app via +``HttpEventSink``. + +This sample requires no external services: the protocol events are emitted +purely from the in-process method calls. + +Required environment: + +* ``LAYERLENS_STRATIX_API_KEY`` — your LayerLens API key (optional). +* ``LAYERLENS_STRATIX_BASE_URL`` — atlas-app base URL (optional). + +Run:: + + pip install 'layerlens[protocols-a2a]' + python -m samples.instrument.a2a.main +""" + +from __future__ import annotations + +import sys + +from layerlens.instrument.adapters._base import CaptureConfig +from layerlens.instrument.transport.sink_http import HttpEventSink +from layerlens.instrument.adapters.protocols.a2a import A2AAdapter + + +def main() -> int: + sink = HttpEventSink( + adapter_name="a2a", + path="/telemetry/spans", + max_batch=10, + flush_interval_s=1.0, + ) + + adapter = A2AAdapter(capture_config=CaptureConfig.standard()) + adapter.add_sink(sink) + adapter.connect() + + try: + adapter.register_agent_card( + { + "name": "research-agent", + "url": "https://research.example.com/.well-known/agent.json", + "protocolVersion": "0.2.0", + "description": "Sample research agent for the LayerLens A2A demo.", + "skills": [ + { + "id": "search", + "name": "Web search", + "description": "Search the public web.", + "tags": ["web", "search"], + }, + ], + "capabilities": {"streaming": True}, + }, + source="discovery", + ) + + adapter.on_task_submitted( + task_id="task-001", + receiver_url="https://research.example.com/a2a", + task_type="research", + submitter_agent_id="orchestrator-1", + message_role="user", + ) + + adapter.on_stream_event( + task_id="task-001", + event_type="status", + data={"status": "in-progress", "progress": 0.5}, + ) + + adapter.on_task_completed( + task_id="task-001", + final_status="completed", + artifacts=[ + {"type": "text", "content": "Result: 42"}, + ], + ) + + if hasattr(sink, "stats"): + stats = sink.stats() + print(f"Batches sent: {stats.get('batches_sent', 0)}") + print("Emitted A2A events: agent_card + task_submitted + stream + task_completed") + except Exception as exc: + print(f"A2A scenario failed: {exc}", file=sys.stderr) + return 1 + finally: + sink.close() + adapter.disconnect() + + print("Telemetry shipped. Check the LayerLens dashboard adapter health page.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/samples/instrument/a2ui/__init__.py b/samples/instrument/a2ui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/samples/instrument/a2ui/main.py b/samples/instrument/a2ui/main.py new file mode 100644 index 0000000..3c46b71 --- /dev/null +++ b/samples/instrument/a2ui/main.py @@ -0,0 +1,85 @@ +"""Sample: simulate an A2UI surface lifecycle with the LayerLens adapter. + +Constructs an in-memory A2UI session — a surface is created, a user +clicks a confirm button, and the adapter emits two ``commerce.ui.*`` +events that ship to atlas-app via ``HttpEventSink``. + +The PII-sensitive ``context`` dict is hashed before emission (hard +guarantee in the adapter), so the cleartext cart total never leaves +this process — only the sha256 of the context dict appears on the event. + +Required environment: + +* ``LAYERLENS_STRATIX_API_KEY`` — your LayerLens API key (optional). +* ``LAYERLENS_STRATIX_BASE_URL`` — atlas-app base URL (optional). + +Run:: + + pip install 'layerlens[protocols-a2ui]' + python -m samples.instrument.a2ui.main +""" + +from __future__ import annotations + +import sys + +from layerlens.instrument.adapters._base import CaptureConfig +from layerlens.instrument.transport.sink_http import HttpEventSink +from layerlens.instrument.adapters.protocols.a2ui import A2UIAdapter + + +def main() -> int: + sink = HttpEventSink( + adapter_name="a2ui", + path="/telemetry/spans", + max_batch=10, + flush_interval_s=1.0, + ) + + adapter = A2UIAdapter(capture_config=CaptureConfig.standard()) + adapter.add_sink(sink) + adapter.connect() + + org_id = "org-sample" + + try: + adapter.on_surface_created( + surface_id="surf-checkout-1", + org_id=org_id, + root_component_id="cmp-checkout-root", + component_count=12, + ) + + adapter.on_user_action( + surface_id="surf-checkout-1", + action_name="confirm_purchase", + org_id=org_id, + component_id="cmp-confirm-btn", + context={"cart_total": 49.99, "currency": "USD"}, + ) + + adapter.on_user_action( + surface_id="surf-checkout-1", + action_name="select_payment_method", + org_id=org_id, + component_id="cmp-payment-select", + context={"method": "card"}, + ) + + if hasattr(sink, "stats"): + stats = sink.stats() + print(f"Batches sent: {stats.get('batches_sent', 0)}") + print("Emitted A2UI events: surface_created + 2x user_action") + except Exception as exc: + print(f"A2UI scenario failed: {exc}", file=sys.stderr) + return 1 + finally: + sink.close() + adapter.disconnect() + + print("Telemetry shipped. Check the LayerLens dashboard adapter health page.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/samples/instrument/agui/__init__.py b/samples/instrument/agui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/samples/instrument/agui/main.py b/samples/instrument/agui/main.py new file mode 100644 index 0000000..5b9da7a --- /dev/null +++ b/samples/instrument/agui/main.py @@ -0,0 +1,91 @@ +"""Sample: simulate an AG-UI SSE event stream with the LayerLens adapter. + +Constructs an in-memory AG-UI session — a text message stream, a state +delta, and a tool call — without contacting any real frontend. The +adapter emits ``protocol.stream.event`` + ``agent.state.change`` + +``tool.call`` events that ship to atlas-app via ``HttpEventSink``. + +Required environment: + +* ``LAYERLENS_STRATIX_API_KEY`` — your LayerLens API key (optional). +* ``LAYERLENS_STRATIX_BASE_URL`` — atlas-app base URL (optional). + +Run:: + + pip install 'layerlens[protocols-agui]' + python -m samples.instrument.agui.main +""" + +from __future__ import annotations + +import sys + +from layerlens.instrument.adapters._base import CaptureConfig +from layerlens.instrument.transport.sink_http import HttpEventSink +from layerlens.instrument.adapters.protocols.agui import AGUIAdapter + + +def main() -> int: + sink = HttpEventSink( + adapter_name="agui", + path="/telemetry/spans", + max_batch=10, + flush_interval_s=1.0, + ) + + # Enable l6b_protocol_streams so we see per-chunk text events too. + config = CaptureConfig.standard() + config.l6b_protocol_streams = True + adapter = AGUIAdapter(capture_config=config) + adapter.add_sink(sink) + adapter.connect() + + try: + # 1. Text message stream + adapter.on_agui_event( + "TEXT_MESSAGE_START", + payload={"thread_id": "thread-1", "message_id": "msg-1", "role": "agent"}, + ) + for chunk in ("Hello", " ", "world"): + adapter.on_agui_event( + "TEXT_MESSAGE_CONTENT", + payload={"thread_id": "thread-1", "content": chunk}, + ) + adapter.on_agui_event( + "TEXT_MESSAGE_END", + payload={"thread_id": "thread-1"}, + ) + + # 2. State delta + adapter.on_agui_event( + "STATE_DELTA", + payload={"thread_id": "thread-1", "patch": {"step": 1}}, + ) + + # 3. Tool call + adapter.on_agui_event( + "TOOL_CALL_START", + payload={"tool_call_id": "tc-1", "name": "search"}, + ) + adapter.on_agui_event( + "TOOL_CALL_END", + payload={"tool_call_id": "tc-1", "result": "ok"}, + ) + + if hasattr(sink, "stats"): + stats = sink.stats() + print(f"Batches sent: {stats.get('batches_sent', 0)}") + print("Emitted AG-UI events: text stream + state delta + tool call") + except Exception as exc: + print(f"AG-UI scenario failed: {exc}", file=sys.stderr) + return 1 + finally: + sink.close() + adapter.disconnect() + + print("Telemetry shipped. Check the LayerLens dashboard adapter health page.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/samples/instrument/ap2/__init__.py b/samples/instrument/ap2/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/samples/instrument/ap2/main.py b/samples/instrument/ap2/main.py new file mode 100644 index 0000000..ba20409 --- /dev/null +++ b/samples/instrument/ap2/main.py @@ -0,0 +1,110 @@ +"""Sample: simulate an AP2 commerce flow with the LayerLens adapter. + +Walks the three-stage AP2 authorization chain in-memory: intent mandate +(with org-policy guardrail evaluation) → payment mandate signing → +payment receipt issuance. The adapter emits ``commerce.intent.*`` + +``commerce.mandate.*`` + ``commerce.payment.*`` events that ship to +atlas-app via ``HttpEventSink``. + +The sample uses a fake signature, fake merchant, and fake payment ID — +no real payment processor is contacted. + +Required environment: + +* ``LAYERLENS_STRATIX_API_KEY`` — your LayerLens API key (optional). +* ``LAYERLENS_STRATIX_BASE_URL`` — atlas-app base URL (optional). + +Run:: + + pip install 'layerlens[protocols-ap2]' + python -m samples.instrument.ap2.main +""" + +from __future__ import annotations + +import sys +from datetime import datetime, timezone, timedelta + +from layerlens.instrument.adapters._base import CaptureConfig +from layerlens.instrument.transport.sink_http import HttpEventSink +from layerlens.instrument.adapters.protocols.ap2 import AP2Adapter + + +def main() -> int: + sink = HttpEventSink( + adapter_name="ap2", + path="/telemetry/spans", + max_batch=10, + flush_interval_s=1.0, + ) + + adapter = AP2Adapter(capture_config=CaptureConfig.standard()) + adapter.add_sink(sink) + adapter.connect() + + org_id = "org-sample" + agent_id = "agent-shopper" + + try: + adapter.configure_policy( + org_id=org_id, + max_single_tx=500.0, + daily_limit=2000.0, + allowed_merchants=["merchant-shopify"], + ) + + intent_expiry = (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat() + violations = adapter.on_intent_mandate_created( + mandate_id="im-1", + description="Buy 1 t-shirt under $50.", + org_id=org_id, + merchants=["merchant-shopify"], + max_amount=50.0, + currency="USD", + intent_expiry=intent_expiry, + agent_id=agent_id, + ) + + if violations: + print(f"Guardrail violations: {violations}", file=sys.stderr) + return 1 + + adapter.on_payment_mandate_signed( + mandate_id="im-1", + payment_details_id="pd-1", + total_amount=49.99, + merchant_agent="merchant-shopify", + org_id=org_id, + currency="USD", + payment_method="CARD", + signature="fake-jwt-for-sample", + agent_id=agent_id, + ) + + adapter.on_payment_receipt_issued( + mandate_id="im-1", + payment_id="pay-1", + amount=49.99, + org_id=org_id, + currency="USD", + status="success", + merchant_confirmation_id="conf-1", + ) + + if hasattr(sink, "stats"): + stats = sink.stats() + print(f"Batches sent: {stats.get('batches_sent', 0)}") + print("Emitted AP2 events: intent + validated + mandate.signed + receipt") + except Exception as exc: + print(f"AP2 scenario failed: {exc}", file=sys.stderr) + return 1 + finally: + sink.close() + adapter.disconnect() + + print("Telemetry shipped. Check the LayerLens dashboard adapter health page.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/samples/instrument/mcp/__init__.py b/samples/instrument/mcp/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/samples/instrument/mcp/main.py b/samples/instrument/mcp/main.py new file mode 100644 index 0000000..f4b0c63 --- /dev/null +++ b/samples/instrument/mcp/main.py @@ -0,0 +1,110 @@ +"""Sample: simulate MCP extension events with the LayerLens adapter. + +Walks through the four MCP extensions in-memory: tool call, structured +output validation, elicitation request/response, and async task lifecycle. +The adapter emits ``tool.call`` + ``protocol.structured_output`` + +``protocol.elicitation_*`` + ``protocol.async_task`` events that ship to +atlas-app via ``HttpEventSink``. + +This sample requires no real MCP server — the events are emitted from +in-process method calls. + +Required environment: + +* ``LAYERLENS_STRATIX_API_KEY`` — your LayerLens API key (optional). +* ``LAYERLENS_STRATIX_BASE_URL`` — atlas-app base URL (optional). + +Run:: + + pip install 'layerlens[protocols-mcp]' + python -m samples.instrument.mcp.main +""" + +from __future__ import annotations + +import sys + +from layerlens.instrument.adapters._base import CaptureConfig +from layerlens.instrument.transport.sink_http import HttpEventSink +from layerlens.instrument.adapters.protocols.mcp import MCPExtensionsAdapter + + +def main() -> int: + sink = HttpEventSink( + adapter_name="mcp_extensions", + path="/telemetry/spans", + max_batch=10, + flush_interval_s=1.0, + ) + + adapter = MCPExtensionsAdapter(capture_config=CaptureConfig.standard()) + adapter.add_sink(sink) + adapter.connect() + + try: + # 1. Tool call + adapter.on_tool_call( + tool_name="weather.get", + input_data={"city": "NYC"}, + output_data={"temp_f": 72, "condition": "sunny"}, + latency_ms=128.4, + ) + + # 2. Structured output (validation passed) + adapter.on_structured_output( + tool_name="weather.get", + output={"temp_f": 72, "condition": "sunny"}, + schema={ + "$id": "weather-result-v1", + "type": "object", + "properties": { + "temp_f": {"type": "number"}, + "condition": {"type": "string"}, + }, + "required": ["temp_f", "condition"], + }, + validation_passed=True, + ) + + # 3. Elicitation + adapter.on_elicitation_request( + prompt_id="elic-1", + prompt="What city?", + schema={"type": "object", "properties": {"city": {"type": "string"}}}, + ) + adapter.on_elicitation_response( + prompt_id="elic-1", + response={"city": "NYC"}, + valid=True, + ) + + # 4. Async task lifecycle + adapter.on_async_task( + task_id="async-1", + status="running", + tool_name="long_query", + ) + adapter.on_async_task( + task_id="async-1", + status="completed", + tool_name="long_query", + duration_ms=1500.0, + ) + + if hasattr(sink, "stats"): + stats = sink.stats() + print(f"Batches sent: {stats.get('batches_sent', 0)}") + print("Emitted MCP events: tool + structured_output + elicitation + async_task") + except Exception as exc: + print(f"MCP scenario failed: {exc}", file=sys.stderr) + return 1 + finally: + sink.close() + adapter.disconnect() + + print("Telemetry shipped. Check the LayerLens dashboard adapter health page.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/samples/instrument/ucp/__init__.py b/samples/instrument/ucp/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/samples/instrument/ucp/main.py b/samples/instrument/ucp/main.py new file mode 100644 index 0000000..cad5fed --- /dev/null +++ b/samples/instrument/ucp/main.py @@ -0,0 +1,97 @@ +"""Sample: simulate a UCP commerce flow with the LayerLens adapter. + +Walks the UCP commerce lifecycle in-memory: supplier discovery → catalog +browse → checkout creation → checkout completion. The adapter emits +``commerce.supplier.discovered`` + ``commerce.catalog.browsed`` + +``commerce.checkout.*`` events that ship to atlas-app via +``HttpEventSink``. + +The sample uses fake supplier IDs and order IDs — no real UCP server +is contacted. + +Required environment: + +* ``LAYERLENS_STRATIX_API_KEY`` — your LayerLens API key (optional). +* ``LAYERLENS_STRATIX_BASE_URL`` — atlas-app base URL (optional). + +Run:: + + pip install 'layerlens[protocols-ucp]' + python -m samples.instrument.ucp.main +""" + +from __future__ import annotations + +import sys + +from layerlens.instrument.adapters._base import CaptureConfig +from layerlens.instrument.transport.sink_http import HttpEventSink +from layerlens.instrument.adapters.protocols.ucp import UCPAdapter + + +def main() -> int: + sink = HttpEventSink( + adapter_name="ucp", + path="/telemetry/spans", + max_batch=10, + flush_interval_s=1.0, + ) + + adapter = UCPAdapter(capture_config=CaptureConfig.standard()) + adapter.add_sink(sink) + adapter.connect() + + org_id = "org-sample" + + try: + adapter.on_supplier_discovered( + supplier_id="sup-acme", + name="Acme Supplies", + profile_url="https://acme.example.com/.well-known/ucp.json", + org_id=org_id, + capabilities=["catalog", "checkout", "refunds"], + discovery_method="well_known", + ) + + adapter.on_catalog_browsed( + supplier_id="sup-acme", + org_id=org_id, + items_viewed=18, + items_selected=2, + ) + + adapter.on_checkout_created( + checkout_session_id="cs-1", + supplier_id="sup-acme", + line_items=[ + {"item_id": "sku-1", "quantity": 2, "unit_price": 24.99}, + ], + total_amount=49.98, + org_id=org_id, + currency="USD", + idempotency_key="idem-1", + ) + + adapter.on_checkout_completed( + checkout_session_id="cs-1", + org_id=org_id, + order_id="ord-1", + ) + + if hasattr(sink, "stats"): + stats = sink.stats() + print(f"Batches sent: {stats.get('batches_sent', 0)}") + print("Emitted UCP events: supplier + catalog + checkout.created + checkout.completed") + except Exception as exc: + print(f"UCP scenario failed: {exc}", file=sys.stderr) + return 1 + finally: + sink.close() + adapter.disconnect() + + print("Telemetry shipped. Check the LayerLens dashboard adapter health page.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/layerlens/instrument/adapters/protocols/__init__.py b/src/layerlens/instrument/adapters/protocols/__init__.py new file mode 100644 index 0000000..cef8430 --- /dev/null +++ b/src/layerlens/instrument/adapters/protocols/__init__.py @@ -0,0 +1,21 @@ +"""Protocol adapters for the LayerLens Instrument layer. + +Adapters that conform agents to standardized agent-to-agent and +agent-to-UI protocols, emitting protocol events through the LayerLens +telemetry pipeline. + +Adapters available (loaded on demand via :class:`AdapterRegistry`): + +* ``a2a`` — Agent-to-Agent protocol (handoff / task delegation) +* ``agui`` — Agent GUI streaming + interactivity +* ``mcp`` — Model Context Protocol (tool calling + resources) +* ``ap2`` — Agent Protocol v2 +* ``a2ui`` — Agent-to-UI WebSocket bridge +* ``ucp`` — Universal Connection Protocol (multi-transport) + +Plus :mod:`layerlens.instrument.adapters.protocols.certification` — +the certification suite (50+ checks, ``CertificationResult``, +``CheckResult``). +""" + +from __future__ import annotations diff --git a/src/layerlens/instrument/adapters/protocols/_commerce.py b/src/layerlens/instrument/adapters/protocols/_commerce.py new file mode 100644 index 0000000..f859cc7 --- /dev/null +++ b/src/layerlens/instrument/adapters/protocols/_commerce.py @@ -0,0 +1,583 @@ +""" +Commerce & Payment Protocol Events + +Layer L7 — Commerce protocol events for AP2 (Agent Payments), +UCP (Universal Commerce Protocol), and A2UI (Agent-to-User Interface). +""" + +from __future__ import annotations + +from typing import Optional +from pydantic import BaseModel, Field + +# --------------------------------------------------------------------------- +# Sub-models +# --------------------------------------------------------------------------- + + +class IntentMandateInfo(BaseModel): + """Parsed AP2 intent mandate with spending guardrails.""" + + mandate_id: str = Field(description="Unique mandate identifier") + natural_language_description: str = Field(description="Human-readable spending intent") + merchants: list[str] = Field( + default_factory=list, description="Allowlisted merchant identifiers" + ) + max_amount: Optional[float] = Field(default=None, description="Maximum permitted spend") + currency: str = Field(default="USD", description="ISO 4217 currency code") + requires_refundability: bool = Field( + default=False, description="Whether refundability is required" + ) + user_cart_confirmation_required: bool = Field( + default=False, + description="Whether user must confirm cart before payment", + ) + intent_expiry: Optional[str] = Field(default=None, description="Expiry timestamp in ISO 8601") + + +class PaymentMandateInfo(BaseModel): + """Signed payment authorization details.""" + + mandate_id: str = Field(description="Unique mandate identifier") + payment_details_id: str = Field(description="Opaque reference to stored payment details") + total_amount: float = Field(description="Authorized total amount") + currency: str = Field(default="USD", description="ISO 4217 currency code") + merchant_agent: str = Field(description="Merchant agent identifier or URL") + payment_method: str = Field(default="CARD", description="Payment method: CARD | ACH | CRYPTO") + signature_hash: str = Field(description="sha256 of JWT/biometric signature") + + +class PaymentReceiptInfo(BaseModel): + """Settlement confirmation.""" + + mandate_id: str = Field(description="Mandate this receipt settles") + payment_id: str = Field(description="Unique payment transaction identifier") + amount: float = Field(description="Amount actually charged") + currency: str = Field(default="USD", description="ISO 4217 currency code") + status: str = Field(description="Settlement status: success | failed | refunded") + merchant_confirmation_id: Optional[str] = Field( + default=None, + description="Merchant-side confirmation reference", + ) + + +class SupplierInfo(BaseModel): + """UCP supplier identity and capability advertisement.""" + + supplier_id: str = Field(description="Unique supplier identifier") + name: str = Field(description="Human-readable supplier name") + profile_url: str = Field(description="URL of the supplier's UCP profile") + capabilities: list[str] = Field( + default_factory=list, + description="Declared capability identifiers", + ) + + +class LineItemInfo(BaseModel): + """A single line item in a UCP checkout session.""" + + item_id: str = Field(description="Supplier-scoped item identifier") + name: Optional[str] = Field(default=None, description="Human-readable item name") + quantity: int = Field(default=1, description="Quantity ordered") + unit_price: Optional[float] = Field(default=None, description="Price per unit") + currency: str = Field(default="USD", description="ISO 4217 currency code") + + +# --------------------------------------------------------------------------- +# L7a — AP2 (Agent Payments Protocol) Events +# --------------------------------------------------------------------------- + + +class IntentMandateCreatedEvent(BaseModel): + """ + L7a: Emitted when an AP2 intent mandate is created by the user or agent. + + Captures spending intent before any payment authorization occurs. + """ + + event_type: str = Field( + default="commerce.payment.intent_created", + description="Event type identifier", + ) + layer: str = Field(default="L7a", description="Layer identifier") + intent: IntentMandateInfo = Field(description="Parsed intent mandate") + org_id: str = Field(description="Organization that owns this mandate") + agent_id: Optional[str] = Field(default=None, description="Agent that created the intent") + + @classmethod + def create( + cls, + intent: IntentMandateInfo, + org_id: str, + *, + agent_id: str | None = None, + ) -> IntentMandateCreatedEvent: + return cls( + intent=intent, + org_id=org_id, + agent_id=agent_id, + ) + + +class IntentMandateValidatedEvent(BaseModel): + """ + L7a: Emitted when an AP2 intent mandate is validated against guardrails. + + Records whether the mandate passed policy checks and any violations found. + """ + + event_type: str = Field( + default="commerce.payment.intent_validated", + description="Event type identifier", + ) + layer: str = Field(default="L7a", description="Layer identifier") + mandate_id: str = Field(description="Mandate that was validated") + validation_passed: bool = Field(description="True if all guardrails passed") + violations: list[str] = Field( + default_factory=list, + description="Guardrail violation messages", + ) + org_id: str = Field(description="Organization that owns this mandate") + + @classmethod + def create( + cls, + mandate_id: str, + validation_passed: bool, + org_id: str, + *, + violations: list[str] | None = None, + ) -> IntentMandateValidatedEvent: + return cls( + mandate_id=mandate_id, + validation_passed=validation_passed, + violations=violations or [], + org_id=org_id, + ) + + +class PaymentMandateSignedEvent(BaseModel): + """ + L7a: Emitted when a payment mandate is cryptographically signed and authorized. + + Records the full signed authorization before settlement. + """ + + event_type: str = Field( + default="commerce.payment.mandate_signed", + description="Event type identifier", + ) + layer: str = Field(default="L7a", description="Layer identifier") + mandate: PaymentMandateInfo = Field(description="Signed payment mandate details") + org_id: str = Field(description="Organization that owns this mandate") + agent_id: Optional[str] = Field(default=None, description="Agent that signed the mandate") + + @classmethod + def create( + cls, + mandate: PaymentMandateInfo, + org_id: str, + *, + agent_id: str | None = None, + ) -> PaymentMandateSignedEvent: + return cls( + mandate=mandate, + org_id=org_id, + agent_id=agent_id, + ) + + +class PaymentReceiptIssuedEvent(BaseModel): + """ + L7a: Emitted when a payment receipt is issued following settlement. + + Terminal event in the AP2 payment lifecycle. + """ + + event_type: str = Field( + default="commerce.payment.receipt_issued", + description="Event type identifier", + ) + layer: str = Field(default="L7a", description="Layer identifier") + receipt: PaymentReceiptInfo = Field(description="Settlement confirmation details") + org_id: str = Field(description="Organization that owns this receipt") + + @classmethod + def create( + cls, + receipt: PaymentReceiptInfo, + org_id: str, + ) -> PaymentReceiptIssuedEvent: + return cls( + receipt=receipt, + org_id=org_id, + ) + + +class GuardrailViolationEvent(BaseModel): + """ + L7a: Emitted when a payment attempt violates a spending guardrail. + + Always emitted regardless of whether the payment was blocked. + """ + + event_type: str = Field( + default="commerce.payment.guardrail_violation", + description="Event type identifier", + ) + layer: str = Field(default="L7a", description="Layer identifier") + mandate_id: str = Field(description="Mandate that triggered the violation") + violation_type: str = Field( + description=( + "Violation category: amount_exceeded | merchant_not_whitelisted" + " | expired | refundability_required" + ), + ) + details: str = Field(description="Human-readable violation explanation") + org_id: str = Field(description="Organization that owns this mandate") + agent_id: Optional[str] = Field(default=None, description="Agent involved in the attempt") + blocked: bool = Field(default=True, description="Whether the payment was blocked") + + @classmethod + def create( + cls, + mandate_id: str, + violation_type: str, + details: str, + org_id: str, + *, + agent_id: str | None = None, + blocked: bool = True, + ) -> GuardrailViolationEvent: + return cls( + mandate_id=mandate_id, + violation_type=violation_type, + details=details, + org_id=org_id, + agent_id=agent_id, + blocked=blocked, + ) + + +class SpendingThresholdEvent(BaseModel): + """ + L7a: Emitted when cumulative spend crosses a configured threshold. + + Supports single-transaction, daily, weekly, and monthly threshold monitoring. + """ + + event_type: str = Field( + default="commerce.payment.threshold_exceeded", + description="Event type identifier", + ) + layer: str = Field(default="L7a", description="Layer identifier") + org_id: str = Field(description="Organization whose threshold was exceeded") + threshold_type: str = Field( + description="Threshold window: single_tx | daily | weekly | monthly", + ) + threshold_amount: float = Field(description="Configured threshold value") + actual_amount: float = Field(description="Actual spend that exceeded the threshold") + currency: str = Field(default="USD", description="ISO 4217 currency code") + + @classmethod + def create( + cls, + org_id: str, + threshold_type: str, + threshold_amount: float, + actual_amount: float, + *, + currency: str = "USD", + ) -> SpendingThresholdEvent: + return cls( + org_id=org_id, + threshold_type=threshold_type, + threshold_amount=threshold_amount, + actual_amount=actual_amount, + currency=currency, + ) + + +# --------------------------------------------------------------------------- +# L7b — UCP (Universal Commerce Protocol) Events +# --------------------------------------------------------------------------- + + +class SupplierDiscoveredEvent(BaseModel): + """ + L7b: Emitted when a UCP supplier is discovered via well-known endpoint, + registry lookup, or referral. + """ + + event_type: str = Field( + default="commerce.supplier.discovered", + description="Event type identifier", + ) + layer: str = Field(default="L7b", description="Layer identifier") + supplier: SupplierInfo = Field(description="Discovered supplier details") + org_id: str = Field(description="Organization performing the discovery") + discovery_method: str = Field( + default="well_known", + description="How supplier was found: well_known | registry | referral", + ) + + @classmethod + def create( + cls, + supplier: SupplierInfo, + org_id: str, + *, + discovery_method: str = "well_known", + ) -> SupplierDiscoveredEvent: + return cls( + supplier=supplier, + org_id=org_id, + discovery_method=discovery_method, + ) + + +class CatalogBrowsedEvent(BaseModel): + """ + L7b: Emitted when an agent browses a supplier's UCP catalog. + + High-frequency: captures browse activity without individual item detail. + """ + + event_type: str = Field( + default="commerce.catalog.browsed", + description="Event type identifier", + ) + layer: str = Field(default="L7b", description="Layer identifier") + supplier_id: str = Field(description="Supplier whose catalog was browsed") + items_viewed: int = Field(default=0, description="Number of catalog items viewed") + items_selected: int = Field(default=0, description="Number of items added to session") + org_id: str = Field(description="Organization performing the browse") + + @classmethod + def create( + cls, + supplier_id: str, + org_id: str, + *, + items_viewed: int = 0, + items_selected: int = 0, + ) -> CatalogBrowsedEvent: + return cls( + supplier_id=supplier_id, + org_id=org_id, + items_viewed=items_viewed, + items_selected=items_selected, + ) + + +class CheckoutCreatedEvent(BaseModel): + """ + L7b: Emitted when a UCP checkout session is initiated. + + Captures the full line-item basket before payment handoff to AP2. + """ + + event_type: str = Field( + default="commerce.checkout.created", + description="Event type identifier", + ) + layer: str = Field(default="L7b", description="Layer identifier") + checkout_session_id: str = Field(description="Unique checkout session identifier") + supplier_id: str = Field(description="Supplier hosting the checkout") + line_items: list[LineItemInfo] = Field( + default_factory=list, + description="Items in the checkout basket", + ) + total_amount: float = Field(description="Pre-tax total of all line items") + currency: str = Field(default="USD", description="ISO 4217 currency code") + idempotency_key: Optional[str] = Field( + default=None, + description="Client-supplied idempotency key", + ) + org_id: str = Field(description="Organization initiating checkout") + + @classmethod + def create( + cls, + checkout_session_id: str, + supplier_id: str, + total_amount: float, + org_id: str, + *, + line_items: list[LineItemInfo] | None = None, + currency: str = "USD", + idempotency_key: str | None = None, + ) -> CheckoutCreatedEvent: + return cls( + checkout_session_id=checkout_session_id, + supplier_id=supplier_id, + line_items=line_items or [], + total_amount=total_amount, + currency=currency, + idempotency_key=idempotency_key, + org_id=org_id, + ) + + +class CheckoutCompletedEvent(BaseModel): + """ + L7b: Emitted when a UCP checkout session reaches a terminal state. + + Links to an order and payment reference once the supplier confirms. + """ + + event_type: str = Field( + default="commerce.checkout.completed", + description="Event type identifier", + ) + layer: str = Field(default="L7b", description="Layer identifier") + checkout_session_id: str = Field(description="Checkout session that completed") + order_id: Optional[str] = Field(default=None, description="Supplier-issued order identifier") + payment_reference: Optional[str] = Field( + default=None, + description="Reference to the AP2 payment that settled this order", + ) + status: str = Field( + default="completed", + description="Terminal status: completed | failed | cancelled", + ) + org_id: str = Field(description="Organization that initiated the checkout") + + @classmethod + def create( + cls, + checkout_session_id: str, + org_id: str, + *, + order_id: str | None = None, + payment_reference: str | None = None, + status: str = "completed", + ) -> CheckoutCompletedEvent: + return cls( + checkout_session_id=checkout_session_id, + order_id=order_id, + payment_reference=payment_reference, + status=status, + org_id=org_id, + ) + + +class OrderRefundedEvent(BaseModel): + """ + L7b: Emitted when a UCP order is fully or partially refunded. + """ + + event_type: str = Field( + default="commerce.order.refunded", + description="Event type identifier", + ) + layer: str = Field(default="L7b", description="Layer identifier") + order_id: str = Field(description="Order being refunded") + refund_amount: float = Field(description="Amount refunded") + currency: str = Field(default="USD", description="ISO 4217 currency code") + reason: Optional[str] = Field(default=None, description="Human-readable refund reason") + org_id: str = Field(description="Organization that placed the original order") + + @classmethod + def create( + cls, + order_id: str, + refund_amount: float, + org_id: str, + *, + currency: str = "USD", + reason: str | None = None, + ) -> OrderRefundedEvent: + return cls( + order_id=order_id, + refund_amount=refund_amount, + currency=currency, + reason=reason, + org_id=org_id, + ) + + +# --------------------------------------------------------------------------- +# L7c — A2UI (Agent-to-User Interface) Events +# --------------------------------------------------------------------------- + + +class SurfaceCreatedEvent(BaseModel): + """ + L7c: Emitted when an A2UI surface is instantiated by an agent. + + A surface is a top-level UI context (e.g., checkout widget, confirmation dialog). + """ + + event_type: str = Field( + default="commerce.ui.surface_created", + description="Event type identifier", + ) + layer: str = Field(default="L7c", description="Layer identifier") + surface_id: str = Field(description="Unique surface instance identifier") + root_component_id: Optional[str] = Field( + default=None, + description="Identifier of the root component in the surface tree", + ) + component_count: int = Field(default=0, description="Number of components in the surface") + org_id: str = Field(description="Organization that owns this surface") + + @classmethod + def create( + cls, + surface_id: str, + org_id: str, + *, + root_component_id: str | None = None, + component_count: int = 0, + ) -> SurfaceCreatedEvent: + return cls( + surface_id=surface_id, + root_component_id=root_component_id, + component_count=component_count, + org_id=org_id, + ) + + +class UserActionTriggeredEvent(BaseModel): + """ + L7c: Emitted when a user interacts with a component on an A2UI surface. + + The action context is hashed to avoid capturing PII; use context_hash + for deduplication and replay correlation only. + """ + + event_type: str = Field( + default="commerce.ui.user_action", + description="Event type identifier", + ) + layer: str = Field(default="L7c", description="Layer identifier") + surface_id: str = Field(description="Surface on which the action occurred") + action_name: str = Field(description="Semantic action name (e.g. confirm_purchase)") + component_id: Optional[str] = Field( + default=None, + description="Component that received the interaction", + ) + context_hash: Optional[str] = Field( + default=None, + description="sha256 of the full action context (never cleartext)", + ) + org_id: str = Field(description="Organization that owns this surface") + + @classmethod + def create( + cls, + surface_id: str, + action_name: str, + org_id: str, + *, + component_id: str | None = None, + context_hash: str | None = None, + ) -> UserActionTriggeredEvent: + return cls( + surface_id=surface_id, + action_name=action_name, + component_id=component_id, + context_hash=context_hash, + org_id=org_id, + ) diff --git a/src/layerlens/instrument/adapters/protocols/a2a/__init__.py b/src/layerlens/instrument/adapters/protocols/a2a/__init__.py new file mode 100644 index 0000000..464b816 --- /dev/null +++ b/src/layerlens/instrument/adapters/protocols/a2a/__init__.py @@ -0,0 +1,18 @@ +""" +Stratix A2A (Agent-to-Agent) Protocol Adapter + +Instruments A2A protocol interactions using dual-channel instrumentation: +1. Server-side wrapping: intercepts incoming JSON-RPC requests and SSE streams +2. Client-side wrapping: traces outgoing task submissions and streamed updates + +Handles ACP-origin payloads (IBM Agent Communication Protocol, merged into +A2A in August 2025) via the ACPNormalizer. +""" + +from __future__ import annotations + +from layerlens.instrument.adapters.protocols.a2a.adapter import A2AAdapter + +ADAPTER_CLASS = A2AAdapter + +__all__ = ["A2AAdapter", "ADAPTER_CLASS"] diff --git a/src/layerlens/instrument/adapters/protocols/a2a/acp_normalizer.py b/src/layerlens/instrument/adapters/protocols/a2a/acp_normalizer.py new file mode 100644 index 0000000..16623f0 --- /dev/null +++ b/src/layerlens/instrument/adapters/protocols/a2a/acp_normalizer.py @@ -0,0 +1,164 @@ +""" +ACP-Origin Pattern Normalizer + +Detects and normalizes IBM Agent Communication Protocol (ACP) payloads +within A2A requests. ACP merged into A2A in August 2025; this normalizer +handles the legacy ACP structures by mapping them to A2A canonical format. + +Detection uses a two-factor check: +1. Presence of X-ACP-Version HTTP header +2. Top-level 'acp' namespace key in the JSON-RPC payload + +ACP-to-A2A field mapping: + task_run.id → task.id + task_run.input.messages → task.history + task_run.output.artifacts → task.artifacts + task_run.status → task.status.state (running → working) + task_run.metadata → task.metadata +""" + +from __future__ import annotations + +import logging +from typing import Any + +logger = logging.getLogger(__name__) + + +# ACP status → A2A status mapping +_ACP_STATUS_MAP: dict[str, str] = { + "running": "working", + "completed": "completed", + "failed": "failed", + "cancelled": "cancelled", + "pending": "submitted", + "input_required": "input_required", +} + + +class ACPNormalizer: + """ + Normalizes ACP-origin payloads into A2A canonical structures. + + Thread-safe, stateless normalizer. Can be shared across requests. + """ + + def detect_acp_origin( + self, + payload: dict[str, Any], + headers: dict[str, str] | None = None, + ) -> bool: + """ + Check if a payload originates from an ACP agent. + + Args: + payload: JSON-RPC request payload. + headers: HTTP headers (optional). + + Returns: + True if ACP-origin indicators are detected. + """ + # Factor 1: X-ACP-Version header + if headers and ("X-ACP-Version" in headers or "x-acp-version" in headers): + return True + + # Factor 2: 'acp' namespace in payload + if "acp" in payload: + return True + + # Factor 3: task_run structure (ACP-specific naming) + params = payload.get("params", payload) + return "task_run" in params + + def normalize(self, payload: dict[str, Any]) -> dict[str, Any]: + """ + Normalize an ACP payload to A2A format. + + Args: + payload: ACP-origin payload. + + Returns: + Normalized payload in A2A canonical format. + """ + result = dict(payload) + + # Extract params (JSON-RPC wrapping) + params = result.get("params", result) + + # Normalize task_run → task + if "task_run" in params: + task_run = params.pop("task_run") + task = self._normalize_task_run(task_run) + params["task"] = task + if "params" in result: + result["params"] = params + + # Normalize ACP namespace metadata + if "acp" in result: + acp_meta = result.pop("acp") + if "version" in acp_meta: + result.setdefault("metadata", {})["acp_version"] = acp_meta["version"] + + return result + + def _normalize_task_run(self, task_run: dict[str, Any]) -> dict[str, Any]: + """ + Normalize an ACP task_run structure to A2A task format. + + Args: + task_run: ACP task_run dict. + + Returns: + A2A task dict. + """ + task: dict[str, Any] = {} + + # task_run.id → task.id + task["id"] = task_run.get("id", "") + + # task_run.input.messages → task.history + input_data = task_run.get("input", {}) + if "messages" in input_data: + task["history"] = input_data["messages"] + + # task_run.output.artifacts → task.artifacts + output_data = task_run.get("output", {}) + if "artifacts" in output_data: + task["artifacts"] = output_data["artifacts"] + + # task_run.status → task.status.state (with mapping) + acp_status = task_run.get("status", "") + if isinstance(acp_status, str): + a2a_status = _ACP_STATUS_MAP.get(acp_status, acp_status) + task["status"] = {"state": a2a_status} + elif isinstance(acp_status, dict): + state = acp_status.get("state", acp_status.get("status", "")) + task["status"] = {"state": _ACP_STATUS_MAP.get(state, state)} # type: ignore[arg-type] + + # task_run.metadata → task.metadata + if "metadata" in task_run: + task["metadata"] = task_run["metadata"] + + return task + + def detect_and_normalize( + self, + payload: dict[str, Any], + headers: dict[str, str] | None = None, + ) -> tuple[dict[str, Any], bool]: + """ + Detect ACP origin and normalize if detected. + + Args: + payload: Request payload. + headers: HTTP headers. + + Returns: + Tuple of (normalized_payload, is_acp). + """ + is_acp = self.detect_acp_origin(payload, headers) + if is_acp: + normalized = self.normalize(payload) + logger.debug("Normalized ACP-origin payload to A2A format") + return normalized, True + return payload, False diff --git a/src/layerlens/instrument/adapters/protocols/a2a/adapter.py b/src/layerlens/instrument/adapters/protocols/a2a/adapter.py new file mode 100644 index 0000000..4da0e62 --- /dev/null +++ b/src/layerlens/instrument/adapters/protocols/a2a/adapter.py @@ -0,0 +1,329 @@ +""" +A2A Protocol Adapter — Main adapter class. + +Instruments A2A protocol interactions at both server and client sides. +Captures Agent Card discovery, task lifecycle, SSE streams, and multi-agent +delegation chains. +""" + +from __future__ import annotations + +import time +import uuid +import hashlib +import logging +from typing import Any + +from layerlens.instrument.adapters._base.adapter import ( + AdapterInfo, + AdapterStatus, + ReplayableTrace, + AdapterCapability, +) +from layerlens.instrument.adapters.protocols.base import BaseProtocolAdapter +from layerlens.instrument.adapters.protocols.a2a.acp_normalizer import ACPNormalizer +from layerlens.instrument.adapters.protocols.a2a.task_lifecycle import TaskStateMachine + +logger = logging.getLogger(__name__) + + +class A2AAdapter(BaseProtocolAdapter): + """ + LayerLens adapter for the A2A (Agent-to-Agent) protocol. + + Provides dual-channel instrumentation: + - ``serve()`` wraps server-side A2A handlers + - ``client()`` returns a traced A2A client wrapper + """ + + FRAMEWORK = "a2a" + PROTOCOL = "a2a" + PROTOCOL_VERSION = "0.2.1" + VERSION = "0.1.0" + + def __init__(self, memory_service: Any | None = None, **kwargs: Any) -> None: + super().__init__(**kwargs) + self._framework_version: str | None = None + self._agent_cards: dict[str, dict[str, Any]] = {} + self._task_machines: dict[str, TaskStateMachine] = {} + self._acp_normalizer = ACPNormalizer() + self._task_start_times: dict[str, float] = {} + self._memory_service = memory_service + + # --- Lifecycle --- + + def connect(self) -> None: + try: + import a2a # type: ignore[import-not-found,unused-ignore] + + self._framework_version = getattr(a2a, "__version__", "unknown") + except ImportError: + self._framework_version = None + logger.debug("a2a-sdk not installed; adapter operates in standalone mode") + self._connected = True + self._status = AdapterStatus.HEALTHY + + def disconnect(self) -> None: + self._agent_cards.clear() + self._task_machines.clear() + self._task_start_times.clear() + self._connected = False + self._status = AdapterStatus.DISCONNECTED + self._close_sinks() + + def get_adapter_info(self) -> AdapterInfo: + return AdapterInfo( + name="A2AAdapter", + version=self.VERSION, + framework=self.FRAMEWORK, + framework_version=self._framework_version, + capabilities=[ + AdapterCapability.TRACE_TOOLS, + AdapterCapability.TRACE_STATE, + AdapterCapability.TRACE_HANDOFFS, + AdapterCapability.TRACE_PROTOCOL_EVENTS, + AdapterCapability.STREAMING, + AdapterCapability.REPLAY, + ], + description="LayerLens adapter for the A2A (Agent-to-Agent) protocol", + ) + + def serialize_for_replay(self) -> ReplayableTrace: + return ReplayableTrace( + adapter_name="A2AAdapter", + framework=self.FRAMEWORK, + trace_id=str(uuid.uuid4()), + events=list(self._trace_events), + state_snapshots=[], + config={ + "capture_config": self._capture_config.model_dump(), + "agent_cards": dict(self._agent_cards.items()), + }, + ) + + def probe_health(self, endpoint: str | None = None) -> dict[str, Any]: + from layerlens.instrument.adapters.protocols.health import probe_a2a_agent_card + + if endpoint: + result = probe_a2a_agent_card(endpoint) + return result.to_dict() + return { + "reachable": self._connected, + "latency_ms": 0.0, + "protocol_version": self._framework_version, + } + + # --- Agent Card handling --- + + def register_agent_card(self, card_data: dict[str, Any], source: str = "discovery") -> None: + """ + Register an A2A Agent Card and emit a protocol.agent_card event. + + Args: + card_data: Parsed Agent Card JSON + source: How the card was obtained (discovery | registration | refresh) + """ + from layerlens.instrument._vendored.events_protocol import ( + SkillInfo, + AgentCardEvent, + ) + + agent_id = card_data.get("name", card_data.get("id", "unknown")) + url = card_data.get("url", "") + version = card_data.get("protocolVersion", card_data.get("version", "unknown")) + + skills = [] + for s in card_data.get("skills", []): + skills.append( + SkillInfo( + id=s.get("id", ""), + name=s.get("name", ""), + description=s.get("description"), + tags=s.get("tags", []), + examples=s.get("examples", []), + ) + ) + + self._agent_cards[agent_id] = card_data + + event = AgentCardEvent.create( + agent_id=agent_id, + name=card_data.get("name", "unknown"), + url=url, + version=version, + description=card_data.get("description"), + capabilities=card_data.get("capabilities", {}), + skills=skills, + auth_scheme=card_data.get("authScheme") + or card_data.get("authentication", {}).get("scheme"), + source=source, + ) + self.emit_event(event) + + # --- Task lifecycle --- + + def on_task_submitted( + self, + task_id: str, + receiver_url: str, + *, + task_type: str | None = None, + submitter_agent_id: str | None = None, + message_role: str = "user", + raw_payload: dict[str, Any] | None = None, + ) -> None: + """Record an A2A task submission.""" + from layerlens.instrument._vendored.events_protocol import TaskSubmittedEvent + + # Check for ACP-origin patterns + protocol_origin = "a2a" + if raw_payload: + normalized, is_acp = self._acp_normalizer.detect_and_normalize(raw_payload) + if is_acp: + protocol_origin = "acp" + raw_payload = normalized + + self._task_start_times[task_id] = time.monotonic() + self._task_machines[task_id] = TaskStateMachine(task_id) + + event = TaskSubmittedEvent.create( + task_id=task_id, + receiver_agent_url=receiver_url, + task_type=task_type, + submitter_agent_id=submitter_agent_id, + protocol_origin=protocol_origin, + message_role=message_role, + ) + self.emit_event(event) + + def on_task_completed( + self, + task_id: str, + final_status: str, + *, + artifacts: list[dict[str, Any]] | None = None, + error_code: str | None = None, + error_message: str | None = None, + share_to_agent_id: str | None = None, + ) -> None: + """Record an A2A task completion. + + Args: + task_id: Unique task identifier. + final_status: Terminal status of the task. + artifacts: Optional list of artifact dicts. + error_code: Error code if the task failed. + error_message: Error message if the task failed. + share_to_agent_id: When provided **and** a memory_service is + configured, the task context is stored as episodic memory + and shared to the specified agent via + ``AgentMemoryService.share_memory()``. + """ + from layerlens.instrument._vendored.events_protocol import TaskCompletedEvent + + duration_ms = None + if task_id in self._task_start_times: + duration_ms = (time.monotonic() - self._task_start_times.pop(task_id)) * 1000 + + artifact_hashes = [] + if artifacts: + for art in artifacts: + h = hashlib.sha256(str(art).encode()).hexdigest() + artifact_hashes.append(f"sha256:{h}") + + event = TaskCompletedEvent.create( + task_id=task_id, + final_status=final_status, + artifact_count=len(artifacts or []), + artifact_hashes=artifact_hashes, + error_code=error_code, + error_message=error_message, + duration_ms=duration_ms, + ) + self.emit_event(event) + self._task_machines.pop(task_id, None) + + # Optionally share task context via memory + if self._memory_service is not None and share_to_agent_id: + self._share_task_memory(task_id, final_status, artifacts, share_to_agent_id) + + def _share_task_memory( + self, + task_id: str, + final_status: str, + artifacts: list[dict[str, Any]] | None, + target_agent_id: str, + ) -> None: + """Store task context as episodic memory and share to target agent. + + Failures are logged and swallowed. + """ + try: + from layerlens.instrument._vendored.memory_models import MemoryEntry + + content = f"task_id={task_id}, status={final_status}" + if artifacts: + content += f", artifact_count={len(artifacts)}" + + entry = MemoryEntry( + org_id="", + agent_id="a2a", + memory_type="episodic", + key=f"task_context_{task_id}", + content=content, + importance=0.6, + metadata={ + "source": "a2a_adapter", + "task_id": task_id, + "shared_to": target_agent_id, + }, + ) + stored = self._memory_service.store(entry) # type: ignore[union-attr] + self._memory_service.share_memory(stored.id, target_agent_id) # type: ignore[union-attr] + except Exception: + logger.debug( + "A2A: failed to share task memory for task %s to agent %s", + task_id, + target_agent_id, + exc_info=True, + ) + + def on_task_delegation( + self, + from_agent: str, + to_agent: str, + context: dict[str, Any] | None = None, + ) -> None: + """Record an A2A task delegation as an agent.handoff event.""" + from layerlens.instrument._vendored.events_cross_cutting import AgentHandoffEvent + + ctx_str = str(context or {}) + ctx_hash = f"sha256:{hashlib.sha256(ctx_str.encode()).hexdigest()}" + + event = AgentHandoffEvent.create( + from_agent=from_agent, + to_agent=to_agent, + handoff_context_hash=ctx_hash, + ) + self.emit_event(event) + + # --- SSE stream handling --- + + def on_stream_event( + self, + sequence: int, + payload: Any, + ) -> None: + """Record an A2A SSE stream event.""" + from layerlens.instrument._vendored.events_protocol import ProtocolStreamEvent + + payload_str = str(payload) + payload_hash = f"sha256:{hashlib.sha256(payload_str.encode()).hexdigest()}" + + event = ProtocolStreamEvent.create( + protocol="a2a", + sequence_in_stream=sequence, + payload_hash=payload_hash, + payload_summary=payload_str[:200] if len(payload_str) > 200 else payload_str, + ) + self.emit_event(event) diff --git a/src/layerlens/instrument/adapters/protocols/a2a/agent_card.py b/src/layerlens/instrument/adapters/protocols/a2a/agent_card.py new file mode 100644 index 0000000..0bacc10 --- /dev/null +++ b/src/layerlens/instrument/adapters/protocols/a2a/agent_card.py @@ -0,0 +1,87 @@ +""" +A2A Agent Card parser and event builder. + +Handles discovery of Agent Cards from /.well-known/agent.json and +translation to Stratix protocol.agent_card events. +""" + +from __future__ import annotations + +import json +import logging +from typing import Any + +logger = logging.getLogger(__name__) + + +def parse_agent_card(card_json: str | dict[str, Any]) -> dict[str, Any]: + """ + Parse an A2A Agent Card from JSON string or dict. + + Args: + card_json: Raw Agent Card JSON string or already-parsed dict. + + Returns: + Normalized Agent Card dict with standard field names. + + Raises: + ValueError: If the card cannot be parsed. + """ + if isinstance(card_json, str): + try: + card = json.loads(card_json) + except json.JSONDecodeError as exc: + raise ValueError(f"Invalid Agent Card JSON: {exc}") from exc + else: + card = dict(card_json) + + # Normalize field names (A2A spec uses camelCase) + normalized: dict[str, Any] = { + "name": card.get("name", "unknown"), + "description": card.get("description"), + "url": card.get("url", ""), + "protocolVersion": card.get("protocolVersion", card.get("version", "unknown")), + "capabilities": card.get("capabilities", {}), + "skills": card.get("skills", []), + "authentication": card.get("authentication", {}), + } + + # Extract auth scheme + auth = card.get("authentication", {}) + if isinstance(auth, dict): + normalized["authScheme"] = auth.get("scheme") or auth.get("type") + elif isinstance(auth, str): + normalized["authScheme"] = auth + else: + normalized["authScheme"] = None + + return normalized + + +def discover_agent_card( + base_url: str, + timeout_s: float = 5.0, +) -> dict[str, Any] | None: + """ + Discover an A2A Agent Card by fetching /.well-known/agent.json. + + Args: + base_url: Base URL of the A2A agent. + timeout_s: Request timeout in seconds. + + Returns: + Parsed Agent Card dict, or None if discovery fails. + """ + import urllib.error + import urllib.request + + card_url = base_url.rstrip("/") + "/.well-known/agent.json" + try: + req = urllib.request.Request(card_url, method="GET") + with urllib.request.urlopen(req, timeout=timeout_s) as resp: + if resp.status == 200: + body = resp.read().decode("utf-8") + return parse_agent_card(body) + except Exception as exc: + logger.debug("Agent Card discovery failed for %s: %s", card_url, exc) + return None diff --git a/src/layerlens/instrument/adapters/protocols/a2a/client.py b/src/layerlens/instrument/adapters/protocols/a2a/client.py new file mode 100644 index 0000000..2199df9 --- /dev/null +++ b/src/layerlens/instrument/adapters/protocols/a2a/client.py @@ -0,0 +1,91 @@ +""" +A2A Client-Side Wrapper + +Returns a traced A2A client that instruments outgoing task submissions +and receives streamed updates. +""" + +from __future__ import annotations + +import logging +from typing import Any + +logger = logging.getLogger(__name__) + + +class A2AClientWrapper: + """ + Wraps an A2A client to trace outgoing task operations. + + All task submissions, cancellations, and subscription events are + captured and emitted through the adapter. + """ + + def __init__(self, adapter: Any, target_url: str) -> None: + self._adapter = adapter + self._target_url = target_url + + def send_task( + self, + task_id: str, + messages: list[dict[str, Any]], + *, + task_type: str | None = None, + agent_id: str | None = None, + ) -> None: + """ + Trace an outgoing tasks/send call. + + Args: + task_id: A2A task identifier. + messages: Task messages. + task_type: Optional task type from skill definition. + agent_id: Submitting agent ID. + """ + self._adapter.on_task_submitted( + task_id=task_id, + receiver_url=self._target_url, + task_type=task_type, + submitter_agent_id=agent_id, + message_role="user", + ) + + def complete_task( + self, + task_id: str, + status: str, + *, + artifacts: list[dict[str, Any]] | None = None, + error_code: str | None = None, + error_message: str | None = None, + ) -> None: + """ + Trace task completion. + + Args: + task_id: A2A task identifier. + status: Terminal status (completed, failed, cancelled). + artifacts: Output artifacts. + error_code: Error code if failed. + error_message: Error message if failed. + """ + self._adapter.on_task_completed( + task_id=task_id, + final_status=status, + artifacts=artifacts, + error_code=error_code, + error_message=error_message, + ) + + def delegate_task( + self, + from_agent: str, + to_agent: str, + context: dict[str, Any] | None = None, + ) -> None: + """Trace an A2A task delegation (handoff).""" + self._adapter.on_task_delegation( + from_agent=from_agent, + to_agent=to_agent, + context=context, + ) diff --git a/src/layerlens/instrument/adapters/protocols/a2a/server.py b/src/layerlens/instrument/adapters/protocols/a2a/server.py new file mode 100644 index 0000000..ad8939b --- /dev/null +++ b/src/layerlens/instrument/adapters/protocols/a2a/server.py @@ -0,0 +1,82 @@ +""" +A2A Server-Side Wrapper + +Wraps an A2A-compliant HTTP handler to intercept incoming JSON-RPC +requests and SSE streams for tracing. +""" + +from __future__ import annotations + +import logging +from typing import Any +from collections.abc import Callable + +logger = logging.getLogger(__name__) + + +class A2AServerWrapper: + """ + Wraps an A2A server handler to intercept and trace requests. + + Intercepts incoming JSON-RPC requests, extracts task lifecycle + events, and delegates to the original handler. + """ + + # JSON-RPC methods that map to task lifecycle events + _TASK_METHODS = frozenset( + { + "tasks/send", + "tasks/sendSubscribe", + "tasks/get", + "tasks/cancel", + "tasks/pushNotification/set", + "tasks/pushNotification/get", + } + ) + + def __init__( + self, + adapter: Any, + original_handler: Callable[..., Any] | None = None, + ) -> None: + self._adapter = adapter + self._original_handler = original_handler + + def handle_request( + self, + request_body: dict[str, Any], + headers: dict[str, str] | None = None, + ) -> dict[str, Any] | None: + """ + Process an incoming A2A JSON-RPC request. + + Extracts task lifecycle information and emits events before + delegating to the original handler. + + Args: + request_body: Parsed JSON-RPC request body. + headers: HTTP headers. + + Returns: + The response from the original handler, or None. + """ + method = request_body.get("method", "") + params = request_body.get("params", {}) + + if method == "tasks/send" or method == "tasks/sendSubscribe": + task = params.get("task", params) + task_id = task.get("id", request_body.get("id", "")) + self._adapter.on_task_submitted( + task_id=str(task_id), + receiver_url="self", + raw_payload=request_body, + ) + + if self._original_handler: + return self._original_handler(request_body) # type: ignore[no-any-return] + return None + + def handle_agent_card_request(self) -> dict[str, Any] | None: + """Handle a request for the agent's Agent Card.""" + # Emit discovery event — the adapter will handle card registration + return None diff --git a/src/layerlens/instrument/adapters/protocols/a2a/sse_handler.py b/src/layerlens/instrument/adapters/protocols/a2a/sse_handler.py new file mode 100644 index 0000000..d657c33 --- /dev/null +++ b/src/layerlens/instrument/adapters/protocols/a2a/sse_handler.py @@ -0,0 +1,79 @@ +""" +A2A SSE Stream Handler + +Captures and forwards A2A SSE (Server-Sent Events) stream events, +translating them to Stratix protocol.stream.event events. +""" + +from __future__ import annotations + +import hashlib +import logging +from typing import Any +from collections.abc import Callable + +logger = logging.getLogger(__name__) + + +class A2ASSEHandler: + """ + Handles A2A SSE streams for task update subscriptions. + + Wraps an SSE event generator, emitting protocol.stream.event for each + event received while forwarding all events unchanged to the consumer. + """ + + def __init__( + self, + task_id: str, + emit_fn: Callable[..., None], + ) -> None: + self._task_id = task_id + self._emit_fn = emit_fn + self._sequence = 0 + + def process_event(self, event_data: dict[str, Any]) -> dict[str, Any]: + """ + Process a single SSE event. + + Emits a protocol.stream.event and returns the event unchanged. + + Args: + event_data: The SSE event payload (parsed JSON). + + Returns: + The original event_data, unmodified. + """ + from layerlens.instrument._vendored.events_protocol import ProtocolStreamEvent + + payload_str = str(event_data) + payload_hash = f"sha256:{hashlib.sha256(payload_str.encode()).hexdigest()}" + + stream_event = ProtocolStreamEvent.create( + protocol="a2a", + sequence_in_stream=self._sequence, + payload_hash=payload_hash, + payload_summary=payload_str[:200] if len(payload_str) > 200 else payload_str, + ) + self._emit_fn(stream_event) + self._sequence += 1 + + return event_data + + def process_stream(self, events: list[dict[str, Any]]) -> list[dict[str, Any]]: + """ + Process an entire SSE event stream. + + Args: + events: Ordered list of SSE event payloads. + + Returns: + The original events list, unmodified. + """ + for event in events: + self.process_event(event) + return events + + @property + def events_processed(self) -> int: + return self._sequence diff --git a/src/layerlens/instrument/adapters/protocols/a2a/task_lifecycle.py b/src/layerlens/instrument/adapters/protocols/a2a/task_lifecycle.py new file mode 100644 index 0000000..625622e --- /dev/null +++ b/src/layerlens/instrument/adapters/protocols/a2a/task_lifecycle.py @@ -0,0 +1,104 @@ +""" +A2A Task Lifecycle State Machine + +Tracks A2A task state transitions: + submitted → working → completed | failed | cancelled + → input_required → working → ... +""" + +from __future__ import annotations + +import logging +from enum import Enum # Python 3.11+ has StrEnum; using `(str, Enum)` for 3.9/3.10 compat. +from typing import Any + +logger = logging.getLogger(__name__) + + +class TaskState(str, Enum): + """A2A task states.""" + + SUBMITTED = "submitted" + WORKING = "working" + INPUT_REQUIRED = "input_required" + COMPLETED = "completed" + FAILED = "failed" + CANCELLED = "cancelled" + + +# Valid state transitions +_VALID_TRANSITIONS: dict[TaskState, set[TaskState]] = { + TaskState.SUBMITTED: {TaskState.WORKING, TaskState.FAILED, TaskState.CANCELLED}, + TaskState.WORKING: { + TaskState.COMPLETED, + TaskState.FAILED, + TaskState.CANCELLED, + TaskState.INPUT_REQUIRED, + }, + TaskState.INPUT_REQUIRED: {TaskState.WORKING, TaskState.CANCELLED, TaskState.FAILED}, + TaskState.COMPLETED: set(), + TaskState.FAILED: set(), + TaskState.CANCELLED: set(), +} + +# Terminal states +TERMINAL_STATES = frozenset({TaskState.COMPLETED, TaskState.FAILED, TaskState.CANCELLED}) + + +class TaskStateMachine: + """ + Tracks the lifecycle of a single A2A task. + + Validates state transitions and records transition history. + """ + + def __init__(self, task_id: str) -> None: + self.task_id = task_id + self.state = TaskState.SUBMITTED + self.history: list[tuple[TaskState, TaskState]] = [] + + @property + def is_terminal(self) -> bool: + return self.state in TERMINAL_STATES + + def transition(self, new_state: TaskState | str) -> bool: + """ + Attempt a state transition. + + Args: + new_state: Target state. + + Returns: + True if transition was valid and applied, False otherwise. + """ + if isinstance(new_state, str): + try: + new_state = TaskState(new_state) + except ValueError: + logger.warning( + "Task %s: unknown state '%s'", + self.task_id, + new_state, + ) + return False + + if new_state not in _VALID_TRANSITIONS.get(self.state, set()): + logger.warning( + "Task %s: invalid transition %s → %s", + self.task_id, + self.state.value, + new_state.value, + ) + return False + + old_state = self.state + self.state = new_state + self.history.append((old_state, new_state)) + return True + + def to_dict(self) -> dict[str, Any]: + return { + "task_id": self.task_id, + "state": self.state.value, + "history": [(a.value, b.value) for a, b in self.history], + } diff --git a/src/layerlens/instrument/adapters/protocols/a2ui.py b/src/layerlens/instrument/adapters/protocols/a2ui.py new file mode 100644 index 0000000..0c80d6f --- /dev/null +++ b/src/layerlens/instrument/adapters/protocols/a2ui.py @@ -0,0 +1,241 @@ +""" +A2UI Protocol Adapter — Agent-to-User Interface adapter. + +Instruments A2UI protocol interactions: surface lifecycle and user action +events. Emits L7c commerce events from ``stratix.core.events.commerce``. + +Action context is always hashed (sha256) before emission to prevent PII +from appearing in the event stream. +""" + +from __future__ import annotations + +import uuid +import hashlib +import logging +from typing import Any + +from layerlens.instrument.adapters._base.adapter import ( + AdapterInfo, + AdapterStatus, + ReplayableTrace, + AdapterCapability, +) +from layerlens.instrument.adapters.protocols.base import BaseProtocolAdapter + +logger = logging.getLogger(__name__) + + +class A2UIAdapter(BaseProtocolAdapter): + """ + LayerLens adapter for the A2UI (Agent-to-User Interface) protocol. + + Instruments the A2UI surface and interaction lifecycle: + + - Surface creation (top-level UI contexts such as checkout widgets or + confirmation dialogs) + - User action events (button clicks, form submissions, selections) + + Action context passed to ``on_user_action`` is hashed with sha256 before + being stored in the emitted event. Cleartext context is never written to + the event stream, making this adapter safe for PII-sensitive deployments. + + Usage:: + + adapter = A2UIAdapter() + adapter.connect() + + adapter.on_surface_created( + surface_id="surf_abc", + org_id="org_123", + root_component_id="cmp_header", + component_count=5, + ) + + adapter.on_user_action( + surface_id="surf_abc", + action_name="confirm_purchase", + org_id="org_123", + component_id="cmp_confirm_btn", + context={"cart_total": 99.98, "currency": "USD"}, + ) + + adapter.disconnect() + """ + + FRAMEWORK = "a2ui" + PROTOCOL = "a2ui" + PROTOCOL_VERSION = "1.0.0" + VERSION = "0.1.0" + + def __init__(self, **kwargs: Any) -> None: + super().__init__(**kwargs) + self._surfaces: dict[str, dict[str, Any]] = {} + self._component_counts: dict[str, int] = {} + + # --- Lifecycle --- + + def connect(self) -> None: + """Connect the adapter and mark it healthy.""" + self._connected = True + self._status = AdapterStatus.HEALTHY + logger.debug( + "A2UIAdapter connected (protocol=%s v%s)", self.PROTOCOL, self.PROTOCOL_VERSION + ) + + def disconnect(self) -> None: + """Disconnect the adapter and release all tracked surface state.""" + self._surfaces.clear() + self._component_counts.clear() + self._connected = False + self._status = AdapterStatus.DISCONNECTED + self._close_sinks() + logger.debug("A2UIAdapter disconnected") + + def get_adapter_info(self) -> AdapterInfo: + """Return static metadata describing this adapter's identity and capabilities.""" + return AdapterInfo( + name="A2UIAdapter", + version=self.VERSION, + framework=self.FRAMEWORK, + framework_version=self.PROTOCOL_VERSION, + capabilities=[ + AdapterCapability.TRACE_PROTOCOL_EVENTS, + AdapterCapability.REPLAY, + ], + description="LayerLens adapter for the A2UI (Agent-to-User Interface) protocol", + ) + + def serialize_for_replay(self) -> ReplayableTrace: + """Serialize accumulated trace events and adapter state for replay.""" + return ReplayableTrace( + adapter_name="A2UIAdapter", + framework=self.FRAMEWORK, + trace_id=str(uuid.uuid4()), + events=list(self._trace_events), + state_snapshots=[], + config={ + "capture_config": self._capture_config.model_dump(), + "surfaces": dict(self._surfaces.items()), + }, + ) + + def probe_health(self, endpoint: str | None = None) -> dict[str, Any]: + """ + Probe adapter health. + + Args: + endpoint: Optional endpoint URL. If None, returns local adapter + connectivity status only. + + Returns: + Dict with ``reachable`` (bool), ``latency_ms`` (float), and + ``protocol_version`` (str). + """ + return { + "reachable": self._connected, + "latency_ms": 0.0, + "protocol_version": self.PROTOCOL_VERSION, + } + + # --- Surface lifecycle --- + + def on_surface_created( + self, + surface_id: str, + org_id: str, + *, + root_component_id: str | None = None, + component_count: int = 0, + ) -> None: + """ + Record the instantiation of an A2UI surface and emit a + ``commerce.ui.surface_created`` event. + + A surface is a top-level UI context (e.g., a checkout widget, a + confirmation dialog, or an inline agent panel). The surface tree + structure is captured at a summary level via ``component_count`` + rather than enumerating individual component definitions. + + Args: + surface_id: Unique surface instance identifier. + org_id: Organization that owns this surface. + root_component_id: Identifier of the root component in the surface + tree, if known at creation time. + component_count: Total number of components in the rendered surface. + """ + from layerlens.instrument.adapters.protocols._commerce import SurfaceCreatedEvent + + self._surfaces[surface_id] = { + "org_id": org_id, + "root_component_id": root_component_id, + "component_count": component_count, + } + self._component_counts[surface_id] = component_count + + event = SurfaceCreatedEvent.create( + surface_id=surface_id, + org_id=org_id, + root_component_id=root_component_id, + component_count=component_count, + ) + logger.debug( + "A2UIAdapter: surface created surface_id=%s components=%d org_id=%s", + surface_id, + component_count, + org_id, + ) + self.emit_event(event) + + # --- User actions --- + + def on_user_action( + self, + surface_id: str, + action_name: str, + org_id: str, + *, + component_id: str | None = None, + context: dict[str, Any] | None = None, + ) -> None: + """ + Record a user interaction on an A2UI surface and emit a + ``commerce.ui.user_action`` event. + + The ``context`` dict is hashed with sha256 before being stored in the + emitted event. Cleartext context values are never written to the event + stream, ensuring PII safety. + + Args: + surface_id: Surface on which the interaction occurred. + action_name: Semantic action name (e.g. ``confirm_purchase``, + ``cancel_order``, ``select_payment_method``). + org_id: Organization that owns the surface. + component_id: Optional identifier of the component that received + the interaction (e.g. a button or form field ID). + context: Optional dict of action context data. This is hashed and + never stored in cleartext. Use for deduplication and replay + correlation only. + """ + from layerlens.instrument.adapters.protocols._commerce import UserActionTriggeredEvent + + context_hash: str | None = None + if context is not None: + ctx_str = str(context) + context_hash = f"sha256:{hashlib.sha256(ctx_str.encode()).hexdigest()}" + + event = UserActionTriggeredEvent.create( + surface_id=surface_id, + action_name=action_name, + org_id=org_id, + component_id=component_id, + context_hash=context_hash, + ) + logger.debug( + "A2UIAdapter: user action surface_id=%s action=%s component_id=%s org_id=%s", + surface_id, + action_name, + component_id or "n/a", + org_id, + ) + self.emit_event(event) diff --git a/src/layerlens/instrument/adapters/protocols/agui/__init__.py b/src/layerlens/instrument/adapters/protocols/agui/__init__.py new file mode 100644 index 0000000..8d4c03c --- /dev/null +++ b/src/layerlens/instrument/adapters/protocols/agui/__init__.py @@ -0,0 +1,14 @@ +""" +Stratix AG-UI (Agent-User Interaction) Protocol Adapter + +Instruments AG-UI protocol interactions via ASGI/WSGI middleware +that intercepts the SSE event stream between agent and frontend. +""" + +from __future__ import annotations + +from layerlens.instrument.adapters.protocols.agui.adapter import AGUIAdapter + +ADAPTER_CLASS = AGUIAdapter + +__all__ = ["AGUIAdapter", "ADAPTER_CLASS"] diff --git a/src/layerlens/instrument/adapters/protocols/agui/adapter.py b/src/layerlens/instrument/adapters/protocols/agui/adapter.py new file mode 100644 index 0000000..fd5319b --- /dev/null +++ b/src/layerlens/instrument/adapters/protocols/agui/adapter.py @@ -0,0 +1,225 @@ +""" +AG-UI Protocol Adapter — Main adapter class. + +Instruments AG-UI (Agent-User Interaction) protocol events via SSE +middleware wrapping. Captures lifecycle events, text messages, tool +calls, state management, and special events. +""" + +from __future__ import annotations + +import uuid +import hashlib +import logging +from typing import Any + +from layerlens.instrument.adapters._base.adapter import ( + AdapterInfo, + AdapterStatus, + ReplayableTrace, + AdapterCapability, +) +from layerlens.instrument.adapters.protocols.base import BaseProtocolAdapter +from layerlens.instrument.adapters.protocols.agui.event_mapper import ( + map_agui_to_stratix, +) + +logger = logging.getLogger(__name__) + + +class AGUIAdapter(BaseProtocolAdapter): + """ + LayerLens adapter for the AG-UI (Agent-User Interaction) protocol. + + Provides SSE middleware that intercepts the event stream between + an agent and its frontend without modifying either side. + """ + + FRAMEWORK = "agui" + PROTOCOL = "agui" + PROTOCOL_VERSION = "1.0.0" + VERSION = "0.1.0" + + def __init__(self, **kwargs: Any) -> None: + super().__init__(**kwargs) + self._framework_version: str | None = None + self._stream_sequence = 0 + self._state_cache: dict[str, Any] = {} + self._text_buffer: list[str] = [] + self._in_text_message = False + + # --- Lifecycle --- + + def connect(self) -> None: + try: + import ag_ui # type: ignore[import-not-found,unused-ignore] + + self._framework_version = getattr(ag_ui, "__version__", "unknown") + except ImportError: + self._framework_version = None + logger.debug("ag-ui-protocol not installed; adapter operates in standalone mode") + self._connected = True + self._status = AdapterStatus.HEALTHY + + def disconnect(self) -> None: + self._state_cache.clear() + self._text_buffer.clear() + self._stream_sequence = 0 + self._connected = False + self._status = AdapterStatus.DISCONNECTED + self._close_sinks() + + def get_adapter_info(self) -> AdapterInfo: + return AdapterInfo( + name="AGUIAdapter", + version=self.VERSION, + framework=self.FRAMEWORK, + framework_version=self._framework_version, + capabilities=[ + AdapterCapability.TRACE_STATE, + AdapterCapability.TRACE_TOOLS, + AdapterCapability.TRACE_PROTOCOL_EVENTS, + AdapterCapability.STREAMING, + ], + description="LayerLens adapter for the AG-UI protocol", + ) + + def serialize_for_replay(self) -> ReplayableTrace: + return ReplayableTrace( + adapter_name="AGUIAdapter", + framework=self.FRAMEWORK, + trace_id=str(uuid.uuid4()), + events=list(self._trace_events), + state_snapshots=[], + config={"capture_config": self._capture_config.model_dump()}, + ) + + def probe_health(self, endpoint: str | None = None) -> dict[str, Any]: + from layerlens.instrument.adapters.protocols.health import probe_http_endpoint + + if endpoint: + result = probe_http_endpoint(endpoint) + return result.to_dict() + return { + "reachable": self._connected, + "latency_ms": 0.0, + "protocol_version": self._framework_version, + } + + # --- AG-UI event processing --- + + def on_agui_event( + self, + agui_event_type: str, + payload: dict[str, Any] | None = None, + ) -> None: + """ + Process a single AG-UI SSE event. + + Maps the AG-UI event type to appropriate Stratix events and emits them. + High-frequency TEXT_MESSAGE_CONTENT events are gated by l6b_protocol_streams. + + Args: + agui_event_type: AG-UI event type string (e.g. TEXT_MESSAGE_CONTENT) + payload: Event payload dict + """ + payload = payload or {} + mapping = map_agui_to_stratix(agui_event_type) + + # Handle text message buffering + if agui_event_type == "TEXT_MESSAGE_START": + self._in_text_message = True + self._text_buffer.clear() + elif agui_event_type == "TEXT_MESSAGE_CONTENT": + if self._in_text_message: + self._text_buffer.append(payload.get("content", "")) + # Gate high-frequency content events + if not self._capture_config.l6b_protocol_streams: + self._stream_sequence += 1 + return + elif agui_event_type == "TEXT_MESSAGE_END": + self._in_text_message = False + if self._text_buffer: + payload["full_text"] = "".join(self._text_buffer) + self._text_buffer.clear() + + # Emit protocol.stream.event + self._emit_stream_event(agui_event_type, payload) + + # Emit mapped Stratix events + if mapping.get("stratix_event") == "agent.state.change": + self._emit_state_change(agui_event_type, payload) + elif mapping.get("stratix_event") == "tool.call": + self._emit_tool_call(agui_event_type, payload) + + def _emit_stream_event( + self, + agui_event_type: str, + payload: dict[str, Any], + ) -> None: + """Emit a protocol.stream.event for an AG-UI event.""" + from layerlens.instrument._vendored.events_protocol import ProtocolStreamEvent + + payload_str = str(payload) + payload_hash = f"sha256:{hashlib.sha256(payload_str.encode()).hexdigest()}" + + event = ProtocolStreamEvent.create( + protocol="agui", + sequence_in_stream=self._stream_sequence, + payload_hash=payload_hash, + agui_event_type=agui_event_type, + payload_summary=payload_str[:200] if len(payload_str) > 200 else payload_str, + ) + self.emit_event(event) + self._stream_sequence += 1 + + def _emit_state_change( + self, + agui_event_type: str, + payload: dict[str, Any], + ) -> None: + """Emit an agent.state.change event for AG-UI lifecycle/state events.""" + from layerlens.instrument._vendored.events_cross_cutting import ( + StateType, + AgentStateChangeEvent, + ) + + state_str = str(payload) + after_hash = f"sha256:{hashlib.sha256(state_str.encode()).hexdigest()}" + before_hash = f"sha256:{hashlib.sha256(str(self._state_cache).encode()).hexdigest()}" + + if agui_event_type in ("STATE_SNAPSHOT", "STATE_DELTA"): + self._state_cache.update(payload) + + event = AgentStateChangeEvent.create( + state_type=StateType.INTERNAL, + before_hash=before_hash, + after_hash=after_hash, + ) + self.emit_event(event) + + def _emit_tool_call( + self, + agui_event_type: str, + payload: dict[str, Any], + ) -> None: + """Emit a tool.call event for AG-UI tool call events.""" + from layerlens.instrument._vendored.events_l5_tools import ( + ToolCallEvent, + IntegrationType, + ) + + if agui_event_type == "TOOL_CALL_START": + event = ToolCallEvent.create( + name=payload.get("tool_name", payload.get("name", "unknown")), + integration=IntegrationType.SERVICE, + input_data=payload.get("args", {}), + ) + self.emit_event(event) + elif agui_event_type == "TOOL_CALL_RESULT": + event = ToolCallEvent.create( + name=payload.get("tool_name", payload.get("name", "unknown")), + integration=IntegrationType.SERVICE, + output_data=payload.get("result", {}), + ) + self.emit_event(event) diff --git a/src/layerlens/instrument/adapters/protocols/agui/event_mapper.py b/src/layerlens/instrument/adapters/protocols/agui/event_mapper.py new file mode 100644 index 0000000..d7a1b93 --- /dev/null +++ b/src/layerlens/instrument/adapters/protocols/agui/event_mapper.py @@ -0,0 +1,86 @@ +""" +AG-UI Event Type Mapper + +Maps AG-UI event types to Stratix event types according to the +five AG-UI event categories: Lifecycle, Text Messages, Tool Calls, +State Management, and Special. +""" + +from __future__ import annotations + +from enum import Enum # Python 3.11+ has StrEnum; using `(str, Enum)` for 3.9/3.10 compat. +from typing import Any + + +class AGUIEventType(str, Enum): + """AG-UI event types.""" + + # Lifecycle + RUN_STARTED = "RUN_STARTED" + RUN_FINISHED = "RUN_FINISHED" + RUN_ERROR = "RUN_ERROR" + # Text Messages + TEXT_MESSAGE_START = "TEXT_MESSAGE_START" + TEXT_MESSAGE_CONTENT = "TEXT_MESSAGE_CONTENT" + TEXT_MESSAGE_END = "TEXT_MESSAGE_END" + # Tool Calls + TOOL_CALL_START = "TOOL_CALL_START" + TOOL_CALL_ARGS = "TOOL_CALL_ARGS" + TOOL_CALL_END = "TOOL_CALL_END" + TOOL_CALL_RESULT = "TOOL_CALL_RESULT" + # State Management + STATE_SNAPSHOT = "STATE_SNAPSHOT" + STATE_DELTA = "STATE_DELTA" + MESSAGES_SNAPSHOT = "MESSAGES_SNAPSHOT" + # Special + STEP_STARTED = "STEP_STARTED" + STEP_FINISHED = "STEP_FINISHED" + RAW = "RAW" + + +# AG-UI event type → Stratix mapping +_AGUI_EVENT_MAP: dict[str, dict[str, Any]] = { + # Lifecycle → agent.state.change + "RUN_STARTED": {"stratix_event": "agent.state.change", "category": "lifecycle"}, + "RUN_FINISHED": {"stratix_event": "agent.state.change", "category": "lifecycle"}, + "RUN_ERROR": {"stratix_event": "agent.state.change", "category": "lifecycle"}, + # Text Messages → protocol.stream.event (L6b gated) + "TEXT_MESSAGE_START": {"stratix_event": "protocol.stream.event", "category": "text"}, + "TEXT_MESSAGE_CONTENT": {"stratix_event": "protocol.stream.event", "category": "text"}, + "TEXT_MESSAGE_END": {"stratix_event": "protocol.stream.event", "category": "text"}, + # Tool Calls → tool.call (L5a) + protocol.stream.event for streaming args + "TOOL_CALL_START": {"stratix_event": "tool.call", "category": "tool"}, + "TOOL_CALL_ARGS": {"stratix_event": "protocol.stream.event", "category": "tool"}, + "TOOL_CALL_END": {"stratix_event": "protocol.stream.event", "category": "tool"}, + "TOOL_CALL_RESULT": {"stratix_event": "tool.call", "category": "tool"}, + # State Management → agent.state.change + protocol.stream.event + "STATE_SNAPSHOT": {"stratix_event": "agent.state.change", "category": "state"}, + "STATE_DELTA": {"stratix_event": "agent.state.change", "category": "state"}, + "MESSAGES_SNAPSHOT": {"stratix_event": "agent.state.change", "category": "state"}, + # Special → protocol.stream.event + "STEP_STARTED": {"stratix_event": "protocol.stream.event", "category": "special"}, + "STEP_FINISHED": {"stratix_event": "protocol.stream.event", "category": "special"}, + "RAW": {"stratix_event": "protocol.stream.event", "category": "special"}, +} + + +def map_agui_to_stratix(agui_event_type: str) -> dict[str, Any]: + """ + Map an AG-UI event type to its Stratix mapping. + + Args: + agui_event_type: AG-UI event type string. + + Returns: + Mapping dict with stratix_event and category keys. + Returns a default mapping for unknown event types. + """ + return _AGUI_EVENT_MAP.get( + agui_event_type, + {"stratix_event": "protocol.stream.event", "category": "unknown"}, + ) + + +def get_all_agui_event_types() -> list[str]: + """Return all known AG-UI event type strings.""" + return list(_AGUI_EVENT_MAP.keys()) diff --git a/src/layerlens/instrument/adapters/protocols/agui/middleware.py b/src/layerlens/instrument/adapters/protocols/agui/middleware.py new file mode 100644 index 0000000..bf05d14 --- /dev/null +++ b/src/layerlens/instrument/adapters/protocols/agui/middleware.py @@ -0,0 +1,145 @@ +""" +AG-UI ASGI/WSGI Middleware + +Intercepts the SSE event stream between agent and frontend without +modifying either side. Each AG-UI event is translated to a Stratix +event before being forwarded unchanged. +""" + +from __future__ import annotations + +import json +import logging +from typing import Any +from collections.abc import Callable + +logger = logging.getLogger(__name__) + + +class AGUIASGIMiddleware: + """ + ASGI middleware that intercepts AG-UI SSE streams. + + Wraps an ASGI application, detecting SSE responses and passing + each event through the AG-UI adapter for tracing before forwarding + to the client. + + Usage:: + + app = AGUIASGIMiddleware(app, adapter=agui_adapter) + """ + + def __init__(self, app: Any, adapter: Any) -> None: + self._app = app + self._adapter = adapter + + async def __call__( + self, scope: dict[str, Any], receive: Callable[..., Any], send: Callable[..., Any] + ) -> None: + if scope["type"] != "http": + await self._app(scope, receive, send) + return + + is_sse = False + + async def send_wrapper(message: dict[str, Any]) -> None: + nonlocal is_sse + + if message["type"] == "http.response.start": + headers = dict(message.get("headers", [])) + content_type = headers.get(b"content-type", b"").decode("utf-8", errors="replace") + if "text/event-stream" in content_type: + is_sse = True + + if message["type"] == "http.response.body" and is_sse: + body = message.get("body", b"") + if body: + self._process_sse_chunk(body) + + await send(message) + + await self._app(scope, receive, send_wrapper) + + def _process_sse_chunk(self, chunk: bytes) -> None: + """Parse SSE chunk and forward events to the adapter.""" + try: + text = chunk.decode("utf-8", errors="replace") + for line in text.split("\n"): + line = line.strip() + if line.startswith("data: "): + data_str = line[6:] + if data_str == "[DONE]": + continue + try: + data = json.loads(data_str) + event_type = data.get("type", data.get("event", "")) + if event_type: + self._adapter.on_agui_event(event_type, data) + except json.JSONDecodeError: + pass + except Exception as exc: + logger.debug("Failed to process SSE chunk: %s", exc) + + +class AGUIWSGIMiddleware: + """ + WSGI middleware that intercepts AG-UI SSE streams. + + For non-async frameworks (Flask, Django WSGI, etc.). + + Usage:: + + app = AGUIWSGIMiddleware(app, adapter=agui_adapter) + """ + + def __init__(self, app: Any, adapter: Any) -> None: + self._app = app + self._adapter = adapter + + def __call__(self, environ: dict[str, Any], start_response: Callable[..., Any]) -> Any: + response_started = False + is_sse = False + + def custom_start_response( + status: str, headers: list[Any], exc_info: Any = None + ) -> Callable[..., Any]: + nonlocal response_started, is_sse + response_started = True + for name, value in headers: + if name.lower() == "content-type" and "text/event-stream" in value: + is_sse = True + break + return start_response(status, headers, exc_info) # type: ignore[no-any-return] + + result = self._app(environ, custom_start_response) + + if is_sse: + return self._wrap_sse_response(result) + return result + + def _wrap_sse_response(self, response: Any) -> Any: + """Wrap SSE response iterator, processing each chunk.""" + for chunk in response: + if isinstance(chunk, bytes): + self._process_chunk(chunk) + yield chunk + + def _process_chunk(self, chunk: bytes) -> None: + """Parse SSE chunk and forward to adapter.""" + try: + text = chunk.decode("utf-8", errors="replace") + for line in text.split("\n"): + line = line.strip() + if line.startswith("data: "): + data_str = line[6:] + if data_str == "[DONE]": + continue + try: + data = json.loads(data_str) + event_type = data.get("type", data.get("event", "")) + if event_type: + self._adapter.on_agui_event(event_type, data) + except json.JSONDecodeError: + pass + except Exception as exc: + logger.debug("Failed to process SSE chunk: %s", exc) diff --git a/src/layerlens/instrument/adapters/protocols/agui/state_handler.py b/src/layerlens/instrument/adapters/protocols/agui/state_handler.py new file mode 100644 index 0000000..817f71f --- /dev/null +++ b/src/layerlens/instrument/adapters/protocols/agui/state_handler.py @@ -0,0 +1,132 @@ +""" +AG-UI State Delta Handler + +Handles AG-UI STATE_DELTA events (JSON Patch operations, RFC 6902) +and translates them into Stratix agent.state.change events with +proper before/after hash computation. +""" + +from __future__ import annotations + +import copy +import json +import hashlib +import logging +from typing import Any + +logger = logging.getLogger(__name__) + + +class StateDeltaHandler: + """ + Manages AG-UI state snapshots and deltas. + + Maintains a cached copy of the current state. When a STATE_DELTA + event arrives (containing JSON Patch operations), applies the patch + to compute the new state and generates before/after hashes. + """ + + def __init__(self) -> None: + self._current_state: dict[str, Any] = {} + + @property + def current_state(self) -> dict[str, Any]: + return copy.deepcopy(self._current_state) + + def apply_snapshot(self, state: dict[str, Any]) -> tuple[str, str]: + """ + Apply a full state snapshot (STATE_SNAPSHOT event). + + Args: + state: The complete state snapshot. + + Returns: + Tuple of (before_hash, after_hash). + """ + before_hash = self._hash_state(self._current_state) + self._current_state = copy.deepcopy(state) + after_hash = self._hash_state(self._current_state) + return before_hash, after_hash + + def apply_delta(self, operations: list[dict[str, Any]]) -> tuple[str, str]: + """ + Apply JSON Patch operations (STATE_DELTA event). + + Implements a subset of RFC 6902 JSON Patch: + - add: Add a value at a path + - remove: Remove a value at a path + - replace: Replace a value at a path + + Args: + operations: List of JSON Patch operations. + + Returns: + Tuple of (before_hash, after_hash). + """ + before_hash = self._hash_state(self._current_state) + + for op in operations: + op_type = op.get("op", "") + path = op.get("path", "") + value = op.get("value") + + try: + if op_type == "add": + self._patch_add(path, value) + elif op_type == "remove": + self._patch_remove(path) + elif op_type == "replace": + self._patch_replace(path, value) + else: + logger.debug("Unsupported JSON Patch op: %s", op_type) + except Exception as exc: + logger.warning("Failed to apply JSON Patch op %s at %s: %s", op_type, path, exc) + + after_hash = self._hash_state(self._current_state) + return before_hash, after_hash + + # --- JSON Patch operations --- + + def _patch_add(self, path: str, value: Any) -> None: + keys = self._parse_path(path) + if not keys: + self._current_state = value if isinstance(value, dict) else self._current_state + return + target = self._current_state + for key in keys[:-1]: + target = target.setdefault(key, {}) + target[keys[-1]] = value + + def _patch_remove(self, path: str) -> None: + keys = self._parse_path(path) + if not keys: + return + target = self._current_state + for key in keys[:-1]: + if key not in target: + return + target = target[key] + target.pop(keys[-1], None) + + def _patch_replace(self, path: str, value: Any) -> None: + self._patch_add(path, value) + + @staticmethod + def _parse_path(path: str) -> list[str]: + """Parse a JSON Pointer path (e.g. '/foo/bar') into keys.""" + if not path or path == "/": + return [] + parts = path.lstrip("/").split("/") + # Unescape JSON Pointer tokens (RFC 6901) + return [p.replace("~1", "/").replace("~0", "~") for p in parts] + + @staticmethod + def _hash_state(state: dict[str, Any]) -> str: + """Compute SHA-256 hash of a state dict.""" + state_json = json.dumps(state, sort_keys=True, default=str) + h = hashlib.sha256(state_json.encode()).hexdigest() + return f"sha256:{h}" + + def reset(self) -> None: + """Clear cached state.""" + self._current_state.clear() diff --git a/src/layerlens/instrument/adapters/protocols/ap2.py b/src/layerlens/instrument/adapters/protocols/ap2.py new file mode 100644 index 0000000..1ab527a --- /dev/null +++ b/src/layerlens/instrument/adapters/protocols/ap2.py @@ -0,0 +1,561 @@ +""" +AP2 (Agent Payments Protocol) Adapter + +Instruments AP2 client operations to capture: +- Intent mandate creation and validation +- Payment mandate signing with cryptographic proof +- Payment receipt issuance and verification +- Spending guardrail evaluation and violation detection + +Provides end-to-end observability for autonomous agent financial transactions. +""" + +from __future__ import annotations + +import uuid +import hashlib +import logging +from typing import Any +from datetime import timezone + +UTC = timezone.utc # Python 3.11+ has datetime.UTC; alias for 3.9/3.10 compat. + +from layerlens.instrument.adapters.protocols._commerce import ( + IntentMandateInfo, + PaymentMandateInfo, + PaymentReceiptInfo, + SpendingThresholdEvent, + GuardrailViolationEvent, + IntentMandateCreatedEvent, + PaymentMandateSignedEvent, + PaymentReceiptIssuedEvent, + IntentMandateValidatedEvent, +) + +from layerlens.instrument.adapters._base.adapter import ( + AdapterInfo, + AdapterStatus, + ReplayableTrace, + AdapterCapability, +) +from layerlens.instrument.adapters.protocols.base import BaseProtocolAdapter + +logger = logging.getLogger(__name__) + + +class AP2Adapter(BaseProtocolAdapter): + """Adapter for the Agent Payments Protocol (AP2). + + Captures the three-stage AP2 authorization chain: + + 1. Intent Mandate — spending guardrails and merchant constraints + 2. Payment Mandate — cryptographic authorization to pay + 3. Payment Receipt — settlement confirmation + + Provides guardrail evaluation against configurable org-level policies: + - Maximum single transaction amount + - Allowed merchant whitelist + - Refundability requirements + - Cumulative spending thresholds (daily/weekly/monthly) + - Intent expiry enforcement + """ + + FRAMEWORK = "ap2" + PROTOCOL = "ap2" + PROTOCOL_VERSION = "0.1.0" + VERSION = "0.1.0" + + def __init__(self, memory_service: Any | None = None, **kwargs: Any) -> None: + super().__init__(**kwargs) + self._framework_version: str | None = None + self._mandates: dict[str, IntentMandateInfo] = {} + self._spending_totals: dict[str, float] = {} # org_id -> cumulative spend + self._policies: dict[str, dict[str, Any]] = {} # org_id -> policy config + self._memory_service = memory_service + + # --- Lifecycle --- + + def connect(self) -> None: + """Connect to the AP2 runtime. + + Attempts to import the optional ``ap2_sdk`` package to detect the + installed framework version. If the package is not present the adapter + operates in standalone mode, which is suitable for testing and + environments that instrument AP2 indirectly. + """ + try: + import ap2_sdk # type: ignore[import-not-found,unused-ignore] # noqa: F401 + + self._framework_version = getattr(ap2_sdk, "__version__", "0.1.0") + except ImportError: + self._framework_version = None + logger.debug("ap2-sdk not installed; adapter operates in standalone mode") + self._connected = True + self._status = AdapterStatus.HEALTHY + logger.info("AP2 adapter connected (protocol v%s)", self.PROTOCOL_VERSION) + + def disconnect(self) -> None: + """Disconnect and release all runtime state. + + Clears the in-memory mandate registry, spending accumulators, and + policy configuration. Attached event sinks are flushed and closed. + """ + self._mandates.clear() + self._spending_totals.clear() + self._policies.clear() + self._connected = False + self._status = AdapterStatus.DISCONNECTED + self._close_sinks() + + def get_adapter_info(self) -> AdapterInfo: + """Return metadata describing this adapter.""" + return AdapterInfo( + name="AP2Adapter", + version=self.VERSION, + framework=self.FRAMEWORK, + framework_version=self._framework_version, + capabilities=[ + AdapterCapability.TRACE_PROTOCOL_EVENTS, + AdapterCapability.TRACE_STATE, + AdapterCapability.REPLAY, + ], + description="LayerLens adapter for the AP2 (Agent Payments Protocol)", + ) + + def serialize_for_replay(self) -> ReplayableTrace: + """Serialize the current trace data for replay. + + The mandate registry is captured as a state snapshot so that replay + can reconstruct the authorization chain context. + """ + return ReplayableTrace( + adapter_name="AP2Adapter", + framework=self.FRAMEWORK, + trace_id=str(uuid.uuid4()), + events=list(self._trace_events), + state_snapshots=[ + {"mandates": {k: v.model_dump() for k, v in self._mandates.items()}}, + ], + config={ + "capture_config": self._capture_config.model_dump(), + "policies": dict(self._policies.items()), + }, + ) + + def probe_health(self, endpoint: str | None = None) -> dict[str, Any]: + """Probe the health of the AP2 adapter. + + Args: + endpoint: Unused for AP2; present to satisfy the + ``BaseProtocolAdapter`` interface. + + Returns: + Dict with keys: ``reachable`` (bool), ``latency_ms`` (float), + ``protocol_version`` (str | None), and ``active_mandates`` (int). + """ + return { + "reachable": self._connected, + "latency_ms": 0.0, + "protocol_version": self.PROTOCOL_VERSION, + "active_mandates": len(self._mandates), + } + + # --- Policy configuration --- + + def configure_policy( + self, + org_id: str, + *, + max_single_tx: float | None = None, + daily_limit: float | None = None, + weekly_limit: float | None = None, + monthly_limit: float | None = None, + allowed_merchants: list[str] | None = None, + require_refundability: bool = False, + ) -> None: + """Configure spending guardrails for an organization. + + Policies are evaluated at intent mandate creation time. Any + subsequent calls for the same ``org_id`` fully replace the prior + configuration. + + Args: + org_id: Organization identifier the policy applies to. + max_single_tx: Maximum permitted amount for a single transaction. + daily_limit: Maximum cumulative spend per calendar day. + weekly_limit: Maximum cumulative spend per calendar week. + monthly_limit: Maximum cumulative spend per calendar month. + allowed_merchants: Exhaustive whitelist of permitted merchant + identifiers. ``None`` means no restriction. + require_refundability: When ``True`` every intent mandate must + declare ``requires_refundability=True`` or a guardrail + violation is raised. + """ + self._policies[org_id] = { + "max_single_tx": max_single_tx, + "daily_limit": daily_limit, + "weekly_limit": weekly_limit, + "monthly_limit": monthly_limit, + "allowed_merchants": allowed_merchants, + "require_refundability": require_refundability, + } + + # --- AP2 Intent Mandate --- + + def on_intent_mandate_created( + self, + mandate_id: str, + description: str, + org_id: str, + *, + merchants: list[str] | None = None, + max_amount: float | None = None, + currency: str = "USD", + requires_refundability: bool = False, + user_cart_confirmation_required: bool = False, + intent_expiry: str | None = None, + agent_id: str | None = None, + ) -> list[str]: + """Record an AP2 intent mandate and evaluate it against org guardrails. + + Emits an ``IntentMandateCreatedEvent`` followed by an + ``IntentMandateValidatedEvent``. For each guardrail breach a + ``GuardrailViolationEvent`` is also emitted before the validation + event is finalized. + + Args: + mandate_id: Unique identifier for this intent mandate. + description: Natural-language description of the spending intent. + org_id: Organization that owns this mandate. + merchants: Merchant identifiers the agent may transact with. + max_amount: Upper bound on a single transaction amount. + currency: ISO 4217 currency code (default ``"USD"``). + requires_refundability: Whether the intent requires refundability. + user_cart_confirmation_required: Whether the user must confirm the + cart before payment proceeds. + intent_expiry: Optional ISO 8601 expiry timestamp string. + agent_id: Identifier of the agent that created the intent. + + Returns: + A list of human-readable guardrail violation messages. An empty + list means the mandate is fully compliant with org policy. + """ + intent = IntentMandateInfo( + mandate_id=mandate_id, + natural_language_description=description, + merchants=merchants or [], + max_amount=max_amount, + currency=currency, + requires_refundability=requires_refundability, + user_cart_confirmation_required=user_cart_confirmation_required, + intent_expiry=intent_expiry, + ) + self._mandates[mandate_id] = intent + + self.emit_event( + IntentMandateCreatedEvent.create( + intent=intent, + org_id=org_id, + agent_id=agent_id, + ) + ) + + violations = self._evaluate_guardrails(intent, org_id, agent_id) + + self.emit_event( + IntentMandateValidatedEvent.create( + mandate_id=mandate_id, + validation_passed=len(violations) == 0, + violations=violations, + org_id=org_id, + ) + ) + + return violations + + # --- AP2 Payment Mandate --- + + def on_payment_mandate_signed( + self, + mandate_id: str, + payment_details_id: str, + total_amount: float, + merchant_agent: str, + org_id: str, + *, + currency: str = "USD", + payment_method: str = "CARD", + signature: str = "", + agent_id: str | None = None, + ) -> None: + """Record a cryptographically signed payment mandate. + + The raw ``signature`` value is never stored; only its SHA-256 hash is + captured so the audit trail remains tamper-evident without retaining + sensitive key material. + + Cumulative org spending is updated and spending-threshold events are + raised if any configured limits are exceeded. + + Args: + mandate_id: Unique identifier for this payment mandate (should + correlate with a previously created intent mandate). + payment_details_id: Opaque reference to stored payment credentials. + total_amount: Authorized total amount for the transaction. + merchant_agent: Merchant agent identifier or endpoint URL. + org_id: Organization that owns this mandate. + currency: ISO 4217 currency code (default ``"USD"``). + payment_method: Payment rail: ``CARD`` | ``ACH`` | ``CRYPTO``. + signature: Raw JWT or biometric signature value (hashed before + storage). + agent_id: Identifier of the agent that signed the mandate. + """ + # Verify intent mandate hasn't expired before signing + intent = self._mandates.get(mandate_id) + if intent and intent.intent_expiry: + from datetime import datetime + + try: + expiry_dt = datetime.fromisoformat(intent.intent_expiry.replace("Z", "+00:00")) + if datetime.now(UTC) > expiry_dt: + self.emit_event( + GuardrailViolationEvent.create( + mandate_id=mandate_id, + violation_type="expired", + details=f"Intent mandate expired at {intent.intent_expiry}", + org_id=org_id, + agent_id=agent_id, + ) + ) + logger.warning("AP2: Intent mandate %s expired, signing anyway", mandate_id) + except (ValueError, TypeError): + pass # Non-parseable expiry; skip check + + sig_hash = hashlib.sha256(signature.encode()).hexdigest() + + mandate = PaymentMandateInfo( + mandate_id=mandate_id, + payment_details_id=payment_details_id, + total_amount=total_amount, + currency=currency, + merchant_agent=merchant_agent, + payment_method=payment_method, + signature_hash=sig_hash, + ) + + # Update cumulative spending before threshold checks + self._spending_totals[org_id] = self._spending_totals.get(org_id, 0.0) + total_amount + self._check_spending_thresholds(org_id, currency) + + self.emit_event( + PaymentMandateSignedEvent.create( + mandate=mandate, + org_id=org_id, + agent_id=agent_id, + ) + ) + + # --- AP2 Payment Receipt --- + + def on_payment_receipt_issued( + self, + mandate_id: str, + payment_id: str, + amount: float, + org_id: str, + *, + currency: str = "USD", + status: str = "success", + merchant_confirmation_id: str | None = None, + ) -> None: + """Record a payment receipt, closing the AP2 audit trail. + + Emits a ``PaymentReceiptIssuedEvent``. On successful settlement the + intent mandate is removed from the in-memory registry. + + Args: + mandate_id: Mandate that this receipt settles. + payment_id: Unique payment transaction identifier issued by the + payment processor. + amount: Amount actually charged (may differ from authorized amount + for partial captures). + org_id: Organization that owns this receipt. + currency: ISO 4217 currency code (default ``"USD"``). + status: Settlement status: ``success`` | ``failed`` | ``refunded``. + merchant_confirmation_id: Optional merchant-side reference number. + """ + receipt = PaymentReceiptInfo( + mandate_id=mandate_id, + payment_id=payment_id, + amount=amount, + currency=currency, + status=status, + merchant_confirmation_id=merchant_confirmation_id, + ) + + self.emit_event( + PaymentReceiptIssuedEvent.create( + receipt=receipt, + org_id=org_id, + ) + ) + + # Store mandate + receipt as procedural memory + if self._memory_service is not None: + self._store_payment_memory(mandate_id, receipt, org_id) + + # Clean up mandate state on successful settlement + if status == "success": + self._mandates.pop(mandate_id, None) + + # --- Internal: memory storage --- + + def _store_payment_memory( + self, + mandate_id: str, + receipt: Any, + org_id: str, + ) -> None: + """Store mandate and receipt details as procedural memory. + + Failures are logged and swallowed to avoid disrupting the payment flow. + """ + try: + from layerlens.instrument._vendored.memory_models import MemoryEntry + + intent = self._mandates.get(mandate_id) + content_parts = [f"receipt: {receipt.model_dump()}"] + if intent: + content_parts.insert(0, f"mandate: {intent.model_dump()}") + + entry = MemoryEntry( + org_id=org_id, + agent_id=f"ap2_{org_id}", + memory_type="procedural", + key=f"payment_{mandate_id}", + content="; ".join(content_parts), + importance=0.8, + metadata={ + "source": "ap2_adapter", + "mandate_id": mandate_id, + "status": getattr(receipt, "status", "unknown"), + }, + ) + self._memory_service.store(entry) # type: ignore[union-attr] + except Exception: + logger.debug( + "AP2: failed to store payment memory for mandate %s", + mandate_id, + exc_info=True, + ) + + # --- Internal: guardrail evaluation --- + + def _evaluate_guardrails( + self, + intent: IntentMandateInfo, + org_id: str, + agent_id: str | None, + ) -> list[str]: + """Evaluate an intent mandate against the org's spending policy. + + A ``GuardrailViolationEvent`` is emitted for each individual breach + before the consolidated violation list is returned. + + Args: + intent: The intent mandate to validate. + org_id: Organization whose policy to apply. + agent_id: Agent involved in the mandate (for event attribution). + + Returns: + Ordered list of human-readable violation messages. Empty when + the mandate is fully compliant or no policy is configured. + """ + violations: list[str] = [] + policy = self._policies.get(org_id) + + if not policy: + return violations + + # Check maximum single-transaction amount + max_single = policy.get("max_single_tx") + if max_single is not None and intent.max_amount is not None: # noqa: SIM102 + if intent.max_amount > max_single: + msg = f"Amount ${intent.max_amount} exceeds single-tx limit ${max_single}" + violations.append(msg) + self.emit_event( + GuardrailViolationEvent.create( + mandate_id=intent.mandate_id, + violation_type="amount_exceeded", + details=msg, + org_id=org_id, + agent_id=agent_id, + ) + ) + + # Check merchant whitelist + allowed_merchants = policy.get("allowed_merchants") + if allowed_merchants is not None and intent.merchants: + for merchant in intent.merchants: + if merchant not in allowed_merchants: + msg = f"Merchant '{merchant}' not in allowed list" + violations.append(msg) + self.emit_event( + GuardrailViolationEvent.create( + mandate_id=intent.mandate_id, + violation_type="merchant_not_whitelisted", + details=msg, + org_id=org_id, + agent_id=agent_id, + ) + ) + + # Check refundability requirement + if policy.get("require_refundability") and not intent.requires_refundability: + msg = "Refundability required by policy but not declared in mandate" + violations.append(msg) + self.emit_event( + GuardrailViolationEvent.create( + mandate_id=intent.mandate_id, + violation_type="refundability_required", + details=msg, + org_id=org_id, + agent_id=agent_id, + ) + ) + + return violations + + def _check_spending_thresholds(self, org_id: str, currency: str) -> None: + """Emit spending threshold events when cumulative spend exceeds limits. + + Checks daily, weekly, and monthly limits configured for ``org_id`` + against the current accumulated spend total. A + ``SpendingThresholdEvent`` is emitted for each limit that is breached. + + Args: + org_id: Organization whose cumulative spend to check. + currency: ISO 4217 currency code for the threshold event. + """ + policy = self._policies.get(org_id) + if not policy: + return + + cumulative = self._spending_totals.get(org_id, 0.0) + + for threshold_key, threshold_label in ( + ("daily_limit", "daily"), + ("weekly_limit", "weekly"), + ("monthly_limit", "monthly"), + ): + limit = policy.get(threshold_key) + if limit is not None and cumulative > limit: + self.emit_event( + SpendingThresholdEvent.create( + org_id=org_id, + threshold_type=threshold_label, + threshold_amount=limit, + actual_amount=cumulative, + currency=currency, + ) + ) diff --git a/src/layerlens/instrument/adapters/protocols/base.py b/src/layerlens/instrument/adapters/protocols/base.py new file mode 100644 index 0000000..f29f2e9 --- /dev/null +++ b/src/layerlens/instrument/adapters/protocols/base.py @@ -0,0 +1,186 @@ +""" +STRATIX Base Protocol Adapter + +Abstract base class for protocol-level adapters (A2A, AG-UI, MCP Extensions). +Extends BaseAdapter with protocol-specific lifecycle: connection pooling, +health probes, protocol version negotiation, and async emission support. +""" + +from __future__ import annotations + +import asyncio +import logging +from abc import abstractmethod +from typing import Any + +from layerlens.instrument.adapters._base.adapter import ( + BaseAdapter, + AdapterHealth, +) + +logger = logging.getLogger(__name__) + + +class BaseProtocolAdapter(BaseAdapter): + """ + Abstract base class for protocol-level adapters. + + Adds to BaseAdapter: + - Protocol version negotiation + - Async event emission via ``emit_event_async`` + - Protocol health probing + - Connection pool awareness + """ + + # Subclasses MUST set + PROTOCOL: str = "" + PROTOCOL_VERSION: str = "" + + def __init__( + self, + stratix: Any | None = None, + capture_config: Any | None = None, + event_sinks: list[Any] | None = None, + max_connections: int = 10, + retry_max_attempts: int = 3, + retry_backoff_base: float = 1.0, + ) -> None: + super().__init__(stratix=stratix, capture_config=capture_config, event_sinks=event_sinks) + self._max_connections = max_connections + self._retry_max_attempts = retry_max_attempts + self._retry_backoff_base = retry_backoff_base + self._protocol_version_negotiated: str | None = None + self._connection_pool: dict[str, Any] = {} + self._pool_active_count = 0 + + # --- Protocol-specific abstractions --- + + @abstractmethod + def probe_health(self, endpoint: str | None = None) -> dict[str, Any]: + """ + Probe the health of a protocol endpoint. + + Args: + endpoint: Optional endpoint URL. If None, probe default endpoint. + + Returns: + Dict with keys: reachable (bool), latency_ms (float), protocol_version (str|None) + """ + ... + + def negotiate_version(self, server_versions: list[str]) -> str | None: + """ + Negotiate protocol version with a remote endpoint. + + Args: + server_versions: Versions the server supports. + + Returns: + The negotiated version, or None if no compatible version found. + """ + if self.PROTOCOL_VERSION in server_versions: + self._protocol_version_negotiated = self.PROTOCOL_VERSION + return self.PROTOCOL_VERSION + # Fallback: pick the highest version we recognise + for v in sorted(server_versions, reverse=True): + if v.startswith(self.PROTOCOL_VERSION.split(".")[0]): + self._protocol_version_negotiated = v + return v + return None + + # --- Async emission --- + + async def emit_event_async( + self, + payload: Any, + privacy_level: Any | None = None, + ) -> None: + """ + Async wrapper around ``emit_event``. + + Protocol streams are high-throughput and often run inside an + ``asyncio`` event loop. This wrapper avoids blocking the loop. + """ + loop = asyncio.get_running_loop() + await loop.run_in_executor(None, self.emit_event, payload, privacy_level) + + # --- Connection pool helpers --- + + def _acquire_connection(self, endpoint: str) -> Any: + """ + Acquire a connection slot for *endpoint*. + + Returns the connection object (or None if pool exhausted). + Tracks connections per endpoint so repeated calls reuse the same slot. + """ + if not hasattr(self, "_connections"): + self._connections: dict[str, str] = {} + + # Reuse existing connection for this endpoint + if endpoint in self._connections: + return self._connections[endpoint] + + if self._pool_active_count >= self._max_connections: + logger.warning( + "%s connection pool exhausted (%d/%d)", + self.PROTOCOL, + self._pool_active_count, + self._max_connections, + ) + return None + self._pool_active_count += 1 + self._connections[endpoint] = endpoint + return self._connections[endpoint] + + def _release_connection(self, endpoint: str) -> None: + """Release a connection slot for *endpoint*.""" + if hasattr(self, "_connections") and endpoint in self._connections: + del self._connections[endpoint] + self._pool_active_count = max(0, self._pool_active_count - 1) + + # --- Retry with backoff --- + + async def _retry_async(self, coro_factory: Any, *args: Any, **kwargs: Any) -> Any: + """ + Retry an async callable with exponential backoff. + + Args: + coro_factory: Callable that returns a coroutine. + + Returns: + The result of the coroutine. + + Raises: + The last exception if all retries are exhausted. + """ + last_exc: Exception | None = None + for attempt in range(self._retry_max_attempts): + try: + return await coro_factory(*args, **kwargs) + except Exception as exc: + last_exc = exc + delay = self._retry_backoff_base * (2**attempt) + logger.debug( + "%s retry %d/%d after %.1fs: %s", + self.PROTOCOL, + attempt + 1, + self._retry_max_attempts, + delay, + exc, + ) + await asyncio.sleep(delay) + raise last_exc # type: ignore[misc] + + # --- Default health_check implementation --- + + def health_check(self) -> AdapterHealth: + probe = self.probe_health() + return AdapterHealth( + status=self._status, + framework_name=self.FRAMEWORK, + framework_version=probe.get("protocol_version"), + adapter_version=self.VERSION, + message=f"reachable={probe.get('reachable', False)}", + error_count=self._error_count, + circuit_open=self._circuit_open, + ) diff --git a/src/layerlens/instrument/adapters/protocols/certification.py b/src/layerlens/instrument/adapters/protocols/certification.py new file mode 100644 index 0000000..4d7288c --- /dev/null +++ b/src/layerlens/instrument/adapters/protocols/certification.py @@ -0,0 +1,430 @@ +""" +STRATIX Protocol Adapter GA Certification Suite + +Validates that protocol adapters comply with the BaseProtocolAdapter contract +required for General Availability (GA) release. Checks interface compliance, +required attributes, error handling patterns, and lifecycle correctness. +""" + +from __future__ import annotations + +import logging +from typing import Any +from dataclasses import field, dataclass + +from layerlens.instrument.adapters._base.adapter import BaseAdapter +from layerlens.instrument.adapters.protocols.base import BaseProtocolAdapter + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Result types +# --------------------------------------------------------------------------- + + +@dataclass +class CheckResult: + """Result of a single certification check.""" + + name: str + passed: bool + message: str + severity: str = "error" # "error" | "warning" + + +@dataclass +class CertificationResult: + """Aggregate result for a single adapter's certification.""" + + passed: bool + adapter_name: str + protocol_version: str + checks: list[dict[str, Any]] = field(default_factory=list) + + def summary(self) -> str: + total = len(self.checks) + passed = sum(1 for c in self.checks if c["passed"]) + status = "PASSED" if self.passed else "FAILED" + return f"{self.adapter_name} GA certification: {status} ({passed}/{total} checks)" + + +# --------------------------------------------------------------------------- +# Required interface definitions +# --------------------------------------------------------------------------- + +# Methods that BaseAdapter declares abstract — every adapter must implement these +_BASE_ADAPTER_REQUIRED_METHODS = [ + "connect", + "disconnect", + "health_check", + "get_adapter_info", + "serialize_for_replay", +] + +# Methods that BaseProtocolAdapter declares abstract on top of BaseAdapter +_PROTOCOL_REQUIRED_METHODS = [ + "probe_health", +] + +# Class attributes that protocol adapters must set to non-empty values +_REQUIRED_CLASS_ATTRIBUTES = [ + ("FRAMEWORK", str), + ("PROTOCOL", str), + ("PROTOCOL_VERSION", str), + ("VERSION", str), +] + + +# --------------------------------------------------------------------------- +# Certification suite +# --------------------------------------------------------------------------- + + +class ProtocolCertificationSuite: + """ + Runs GA certification checks against protocol adapter classes. + + Usage:: + + suite = ProtocolCertificationSuite() + result = suite.certify(A2AAdapter) + assert result.passed + + results = suite.certify_all() + assert all(r.passed for r in results) + """ + + def certify(self, adapter_class: type) -> CertificationResult: + """ + Run all certification checks on a single adapter class. + + Args: + adapter_class: The adapter class to certify (not an instance). + + Returns: + CertificationResult with all check outcomes. + """ + checks: list[CheckResult] = [] + + checks.append(self._check_inherits_base_protocol(adapter_class)) + checks.append(self._check_inherits_base_adapter(adapter_class)) + checks.extend(self._check_required_class_attributes(adapter_class)) + checks.extend(self._check_required_methods(adapter_class)) + checks.extend(self._check_lifecycle_correctness(adapter_class)) + checks.extend(self._check_error_handling(adapter_class)) + checks.append(self._check_adapter_info_returns_type(adapter_class)) + checks.append(self._check_probe_health_returns_dict(adapter_class)) + checks.append(self._check_serialize_for_replay_returns_type(adapter_class)) + + all_passed = all(c.passed for c in checks if c.severity == "error") + + # Derive adapter_name and protocol_version from the class + adapter_name = getattr(adapter_class, "__name__", str(adapter_class)) + protocol_version = getattr(adapter_class, "PROTOCOL_VERSION", "unknown") + + return CertificationResult( + passed=all_passed, + adapter_name=adapter_name, + protocol_version=protocol_version, + checks=[ + { + "name": c.name, + "passed": c.passed, + "message": c.message, + "severity": c.severity, + } + for c in checks + ], + ) + + def certify_all(self) -> list[CertificationResult]: + """ + Certify all three GA protocol adapters: A2A, AG-UI, MCP Extensions. + + Returns: + List of CertificationResult, one per adapter. + """ + from layerlens.instrument.adapters.protocols.a2a.adapter import A2AAdapter + from layerlens.instrument.adapters.protocols.mcp.adapter import MCPExtensionsAdapter + from layerlens.instrument.adapters.protocols.agui.adapter import AGUIAdapter + + results = [] + for cls in (A2AAdapter, AGUIAdapter, MCPExtensionsAdapter): + result = self.certify(cls) + logger.info(result.summary()) + results.append(result) + return results + + # ------------------------------------------------------------------ + # Individual checks + # ------------------------------------------------------------------ + + def _check_inherits_base_protocol(self, cls: type) -> CheckResult: + ok = issubclass(cls, BaseProtocolAdapter) + return CheckResult( + name="inherits_BaseProtocolAdapter", + passed=ok, + message=( + f"{cls.__name__} extends BaseProtocolAdapter" + if ok + else f"{cls.__name__} does NOT extend BaseProtocolAdapter" + ), + ) + + def _check_inherits_base_adapter(self, cls: type) -> CheckResult: + ok = issubclass(cls, BaseAdapter) + return CheckResult( + name="inherits_BaseAdapter", + passed=ok, + message=( + f"{cls.__name__} extends BaseAdapter" + if ok + else f"{cls.__name__} does NOT extend BaseAdapter" + ), + ) + + def _check_required_class_attributes(self, cls: type) -> list[CheckResult]: + results = [] + for attr_name, expected_type in _REQUIRED_CLASS_ATTRIBUTES: + value = getattr(cls, attr_name, None) + ok = isinstance(value, expected_type) and bool(value) + results.append( + CheckResult( + name=f"class_attr_{attr_name}", + passed=ok, + message=( + f"{attr_name} = {value!r}" + if ok + else f"{attr_name} is missing or empty (got {value!r})" + ), + ) + ) + return results + + def _check_required_methods(self, cls: type) -> list[CheckResult]: + results = [] + all_required = _BASE_ADAPTER_REQUIRED_METHODS + _PROTOCOL_REQUIRED_METHODS + for method_name in all_required: + has_method = hasattr(cls, method_name) and callable(getattr(cls, method_name)) + # Also check it is not still abstract (i.e., actually implemented) + is_abstract = method_name in getattr(cls, "__abstractmethods__", set()) + ok = has_method and not is_abstract + results.append( + CheckResult( + name=f"implements_{method_name}", + passed=ok, + message=( + f"{cls.__name__}.{method_name}() implemented" + if ok + else f"{cls.__name__}.{method_name}() missing or still abstract" + ), + ) + ) + return results + + def _check_lifecycle_correctness(self, cls: type) -> list[CheckResult]: + """Instantiate the adapter, run connect/disconnect, verify state transitions.""" + results = [] + if not issubclass(cls, BaseProtocolAdapter): + results.append( + CheckResult( + name="instantiation", + passed=False, + message=f"{cls.__name__} is not a BaseProtocolAdapter subclass; skipping" + f" lifecycle" + f" checks", + ) + ) + return results + try: + adapter = cls() + except Exception as exc: + results.append( + CheckResult( + name="instantiation", + passed=False, + message=f"Failed to instantiate {cls.__name__}: {exc}", + ) + ) + return results + + results.append( + CheckResult( + name="instantiation", + passed=True, + message=f"{cls.__name__}() instantiated successfully", + ) + ) + + # Check initial state + results.append( + CheckResult( + name="initial_state_disconnected", + passed=not adapter.is_connected, + message=( + "Starts disconnected" + if not adapter.is_connected + else "Adapter should start disconnected" + ), + ) + ) + + # Connect + try: + adapter.connect() + results.append( + CheckResult( + name="connect_succeeds", + passed=adapter.is_connected, + message=( + "connect() sets is_connected=True" + if adapter.is_connected + else "connect() did not set is_connected=True" + ), + ) + ) + except Exception as exc: + results.append( + CheckResult( + name="connect_succeeds", + passed=False, + message=f"connect() raised: {exc}", + ) + ) + + # Disconnect + try: + adapter.disconnect() + results.append( + CheckResult( + name="disconnect_succeeds", + passed=not adapter.is_connected, + message=( + "disconnect() sets is_connected=False" + if not adapter.is_connected + else "disconnect() did not set is_connected=False" + ), + ) + ) + except Exception as exc: + results.append( + CheckResult( + name="disconnect_succeeds", + passed=False, + message=f"disconnect() raised: {exc}", + ) + ) + + return results + + def _check_error_handling(self, cls: type) -> list[CheckResult]: + """Verify connect() handles missing framework imports gracefully.""" + results = [] + try: + adapter = cls() + # connect() should not raise even if the underlying framework + # package is not installed — adapters must catch ImportError + adapter.connect() + results.append( + CheckResult( + name="connect_handles_missing_framework", + passed=True, + message="connect() handles missing framework gracefully", + ) + ) + adapter.disconnect() + except ImportError as exc: + results.append( + CheckResult( + name="connect_handles_missing_framework", + passed=False, + message=f"connect() leaks ImportError: {exc}", + ) + ) + except Exception: + # Other exceptions are acceptable — the point is ImportError is caught + results.append( + CheckResult( + name="connect_handles_missing_framework", + passed=True, + message="connect() does not leak ImportError", + ) + ) + return results + + def _check_adapter_info_returns_type(self, cls: type) -> CheckResult: + """Verify get_adapter_info() returns AdapterInfo.""" + from layerlens.instrument.adapters._base.adapter import AdapterInfo + + try: + adapter = cls() + info = adapter.get_adapter_info() + ok = isinstance(info, AdapterInfo) + return CheckResult( + name="get_adapter_info_returns_AdapterInfo", + passed=ok, + message=( + f"get_adapter_info() returns AdapterInfo(name={info.name!r})" + if ok + else f"get_adapter_info() returned {type(info).__name__}, expected AdapterInfo" + ), + ) + except Exception as exc: + return CheckResult( + name="get_adapter_info_returns_AdapterInfo", + passed=False, + message=f"get_adapter_info() raised: {exc}", + ) + + def _check_probe_health_returns_dict(self, cls: type) -> CheckResult: + """Verify probe_health() returns a dict with expected keys.""" + try: + adapter = cls() + result = adapter.probe_health() + ok = ( + isinstance(result, dict) + and "reachable" in result + and "latency_ms" in result + and "protocol_version" in result + ) + return CheckResult( + name="probe_health_returns_valid_dict", + passed=ok, + message=( + "probe_health() returns dict with reachable, latency_ms, protocol_version" + if ok + else f"probe_health() returned {result!r} — missing required keys" + ), + ) + except Exception as exc: + return CheckResult( + name="probe_health_returns_valid_dict", + passed=False, + message=f"probe_health() raised: {exc}", + ) + + def _check_serialize_for_replay_returns_type(self, cls: type) -> CheckResult: + """Verify serialize_for_replay() returns ReplayableTrace.""" + from layerlens.instrument.adapters._base.adapter import ReplayableTrace + + try: + adapter = cls() + trace = adapter.serialize_for_replay() + ok = isinstance(trace, ReplayableTrace) + return CheckResult( + name="serialize_for_replay_returns_ReplayableTrace", + passed=ok, + message=( + f"serialize_for_replay() returns" + f" ReplayableTrace(adapter_name={trace.adapter_name!r})" + if ok + else f"serialize_for_replay() returned {type(trace).__name__}" + ), + ) + except Exception as exc: + return CheckResult( + name="serialize_for_replay_returns_ReplayableTrace", + passed=False, + message=f"serialize_for_replay() raised: {exc}", + ) diff --git a/src/layerlens/instrument/adapters/protocols/connection_pool.py b/src/layerlens/instrument/adapters/protocols/connection_pool.py new file mode 100644 index 0000000..26cfd71 --- /dev/null +++ b/src/layerlens/instrument/adapters/protocols/connection_pool.py @@ -0,0 +1,127 @@ +""" +STRATIX Protocol Connection Pool + +Manages SSE and HTTP connections for protocol adapters with configurable +limits per protocol type and per endpoint. +""" + +from __future__ import annotations + +import time +import logging +import threading +from typing import Any +from dataclasses import field, dataclass + +logger = logging.getLogger(__name__) + + +@dataclass +class ConnectionSlot: + """A single connection slot in the pool.""" + + endpoint: str + protocol: str + created_at: float = field(default_factory=time.monotonic) + last_used_at: float = field(default_factory=time.monotonic) + active: bool = True + metadata: dict[str, Any] = field(default_factory=dict) + + +class ProtocolConnectionPool: + """ + Thread-safe connection pool for protocol adapters. + + Manages connection slots per (protocol, endpoint) pair with configurable + limits. Does not manage actual transport connections — those are handled + by the protocol-specific adapter. This pool tracks *slots* so adapters + can enforce concurrency limits. + """ + + def __init__( + self, + max_per_endpoint: int = 5, + max_total: int = 50, + idle_timeout_s: float = 300.0, + ) -> None: + self._max_per_endpoint = max_per_endpoint + self._max_total = max_total + self._idle_timeout_s = idle_timeout_s + self._lock = threading.Lock() + self._slots: dict[str, list[ConnectionSlot]] = {} # key = protocol:endpoint + + def _key(self, protocol: str, endpoint: str) -> str: + return f"{protocol}:{endpoint}" + + @property + def total_active(self) -> int: + with self._lock: + return sum(sum(1 for s in slots if s.active) for slots in self._slots.values()) + + def acquire(self, protocol: str, endpoint: str) -> ConnectionSlot | None: + """ + Acquire a connection slot. + + Returns None if pool limits are exceeded. + """ + key = self._key(protocol, endpoint) + with self._lock: + # Evict idle connections first + self._evict_idle_locked() + + total = sum(sum(1 for s in slots if s.active) for slots in self._slots.values()) + if total >= self._max_total: + return None + + slots = self._slots.setdefault(key, []) + active_count = sum(1 for s in slots if s.active) + if active_count >= self._max_per_endpoint: + return None + + slot = ConnectionSlot(endpoint=endpoint, protocol=protocol) + slots.append(slot) + return slot + + def release(self, slot: ConnectionSlot) -> None: + """Mark a connection slot as inactive.""" + with self._lock: + slot.active = False + + def _evict_idle_locked(self) -> None: + """Remove slots that have been idle beyond the timeout. Caller holds lock.""" + now = time.monotonic() + for key in list(self._slots.keys()): + self._slots[key] = [ + s + for s in self._slots[key] + if s.active or (now - s.last_used_at) < self._idle_timeout_s + ] + if not self._slots[key]: + del self._slots[key] + + def stats(self) -> dict[str, Any]: + """Return pool statistics.""" + with self._lock: + active = 0 + inactive = 0 + per_endpoint: dict[str, int] = {} + for key, slots in self._slots.items(): + a = sum(1 for s in slots if s.active) + active += a + inactive += len(slots) - a + per_endpoint[key] = a + return { + "active": active, + "inactive": inactive, + "per_endpoint": per_endpoint, + "max_per_endpoint": self._max_per_endpoint, + "max_total": self._max_total, + } + + def close_all(self) -> None: + """Mark all slots as inactive.""" + with self._lock: + for slots in self._slots.values(): + for s in slots: + s.active = False + self._slots.clear() diff --git a/src/layerlens/instrument/adapters/protocols/exceptions.py b/src/layerlens/instrument/adapters/protocols/exceptions.py new file mode 100644 index 0000000..71253f8 --- /dev/null +++ b/src/layerlens/instrument/adapters/protocols/exceptions.py @@ -0,0 +1,156 @@ +""" +STRATIX Protocol Exceptions + +Typed exception hierarchy for protocol adapter errors. +Maps protocol-native error codes to actionable Stratix exceptions. +""" + +from __future__ import annotations + +from typing import Any + + +class ProtocolError(Exception): + """Base exception for all protocol adapter errors.""" + + def __init__( + self, + message: str, + protocol: str = "", + error_code: str | None = None, + endpoint: str | None = None, + ) -> None: + self.protocol = protocol + self.error_code = error_code + self.endpoint = endpoint + super().__init__(message) + + +# --- Connection errors --- + + +class ProtocolConnectionError(ProtocolError): + """Failed to establish or maintain a protocol connection.""" + + +class ProtocolTimeoutError(ProtocolError): + """Protocol operation timed out.""" + + +class ProtocolSSEDisconnectError(ProtocolError): + """SSE stream disconnected unexpectedly.""" + + +# --- Protocol-level errors --- + + +class ProtocolVersionError(ProtocolError): + """Protocol version negotiation failed.""" + + +class ProtocolAuthError(ProtocolError): + """Authentication or authorization failure at the protocol level.""" + + +class ProtocolRateLimitError(ProtocolError): + """Protocol rate limit exceeded.""" + + +# --- A2A-specific errors --- + + +class A2ATaskError(ProtocolError): + """An A2A task reached a failed state.""" + + def __init__( + self, + message: str, + task_id: str | None = None, + error_code: str | None = None, + **kwargs: Any, + ) -> None: + self.task_id = task_id + kwargs.pop("protocol", None) + super().__init__(message, protocol="a2a", error_code=error_code, **kwargs) + + +class A2AAgentCardError(ProtocolError): + """Failed to discover or parse an A2A Agent Card.""" + + +class ACPNormalizationError(ProtocolError): + """Failed to normalize ACP-origin payload to A2A format.""" + + +# --- MCP-specific errors --- + + +class MCPToolError(ProtocolError): + """An MCP tool call failed at the protocol level.""" + + +class MCPElicitationError(ProtocolError): + """An MCP elicitation interaction failed.""" + + +class MCPSchemaValidationError(ProtocolError): + """MCP structured output failed schema validation.""" + + +class MCPAsyncTaskTimeoutError(ProtocolTimeoutError): + """An MCP async task exceeded its configured timeout.""" + + +# --- AG-UI-specific errors --- + + +class AGUIStreamError(ProtocolError): + """AG-UI SSE stream error.""" + + +class AGUIStateDeltaError(ProtocolError): + """Failed to apply AG-UI state delta (JSON Patch error).""" + + +# --- Error registry --- + +# Maps protocol-native error codes to Stratix exception classes +PROTOCOL_ERROR_REGISTRY: dict[str, type[ProtocolError]] = { + # A2A JSON-RPC error codes + "a2a:-32700": ProtocolError, # Parse error + "a2a:-32600": ProtocolError, # Invalid request + "a2a:-32601": ProtocolError, # Method not found + "a2a:-32001": A2ATaskError, # Task not found + "a2a:-32002": A2ATaskError, # Task cancelled + "a2a:-32003": ProtocolAuthError, # Authentication required + # MCP error patterns + "mcp:tool_not_found": MCPToolError, + "mcp:schema_validation": MCPSchemaValidationError, + "mcp:elicitation_timeout": MCPElicitationError, + "mcp:auth_failed": ProtocolAuthError, + # AG-UI error patterns + "agui:stream_error": AGUIStreamError, + "agui:state_delta_error": AGUIStateDeltaError, +} + + +def resolve_protocol_error( + protocol: str, + error_code: str, + message: str, + **kwargs: Any, +) -> ProtocolError: + """ + Resolve a protocol-native error code to a typed Stratix exception. + + Args: + protocol: Protocol name (a2a, mcp, agui) + error_code: Protocol-native error code + message: Error message + + Returns: + Typed ProtocolError subclass instance + """ + key = f"{protocol}:{error_code}" + exc_cls = PROTOCOL_ERROR_REGISTRY.get(key, ProtocolError) + return exc_cls(message, protocol=protocol, error_code=error_code, **kwargs) diff --git a/src/layerlens/instrument/adapters/protocols/health.py b/src/layerlens/instrument/adapters/protocols/health.py new file mode 100644 index 0000000..f61b5e2 --- /dev/null +++ b/src/layerlens/instrument/adapters/protocols/health.py @@ -0,0 +1,150 @@ +""" +STRATIX Protocol Health Probes + +Abstractions for probing protocol endpoint health, including +SSE liveness checks and JSON-RPC ping. +""" + +from __future__ import annotations + +import time +import logging +from typing import Any +from dataclasses import dataclass + +logger = logging.getLogger(__name__) + + +@dataclass +class HealthProbeResult: + """Result of a protocol health probe.""" + + reachable: bool + latency_ms: float + protocol_version: str | None = None + endpoint: str | None = None + error: str | None = None + metadata: dict[str, Any] | None = None + + def to_dict(self) -> dict[str, Any]: + return { + "reachable": self.reachable, + "latency_ms": self.latency_ms, + "protocol_version": self.protocol_version, + "endpoint": self.endpoint, + "error": self.error, + "metadata": self.metadata or {}, + } + + +def probe_http_endpoint( + url: str, + timeout_s: float = 5.0, + expected_status: int = 200, +) -> HealthProbeResult: + """ + Probe an HTTP endpoint for liveness. + + Uses urllib to avoid adding a hard dependency on httpx/requests. + + Args: + url: Endpoint URL to probe + timeout_s: Timeout in seconds + expected_status: Expected HTTP status code + + Returns: + HealthProbeResult + """ + import urllib.error + import urllib.request + + start = time.monotonic() + try: + req = urllib.request.Request(url, method="GET") + with urllib.request.urlopen(req, timeout=timeout_s) as resp: + latency = (time.monotonic() - start) * 1000 + reachable = resp.status == expected_status + return HealthProbeResult( + reachable=reachable, + latency_ms=latency, + endpoint=url, + ) + except urllib.error.URLError as exc: + latency = (time.monotonic() - start) * 1000 + return HealthProbeResult( + reachable=False, + latency_ms=latency, + endpoint=url, + error=str(exc), + ) + except Exception as exc: + latency = (time.monotonic() - start) * 1000 + return HealthProbeResult( + reachable=False, + latency_ms=latency, + endpoint=url, + error=str(exc), + ) + + +def probe_a2a_agent_card(url: str, timeout_s: float = 5.0) -> HealthProbeResult: + """ + Probe an A2A endpoint by fetching its Agent Card at /.well-known/agent.json. + + Args: + url: Base URL of the A2A agent + timeout_s: Timeout in seconds + + Returns: + HealthProbeResult with protocol_version from the card if available + """ + import json + import urllib.error + import urllib.request + + card_url = url.rstrip("/") + "/.well-known/agent.json" + start = time.monotonic() + try: + req = urllib.request.Request(card_url, method="GET") + with urllib.request.urlopen(req, timeout=timeout_s) as resp: + latency = (time.monotonic() - start) * 1000 + if resp.status == 200: + body = json.loads(resp.read()) + version = body.get("protocolVersion") or body.get("version") + return HealthProbeResult( + reachable=True, + latency_ms=latency, + protocol_version=version, + endpoint=card_url, + metadata={"agent_name": body.get("name")}, + ) + return HealthProbeResult( + reachable=False, + latency_ms=latency, + endpoint=card_url, + error=f"HTTP {resp.status}", + ) + except Exception as exc: + latency = (time.monotonic() - start) * 1000 + return HealthProbeResult( + reachable=False, + latency_ms=latency, + endpoint=card_url, + error=str(exc), + ) + + +def probe_mcp_server(url: str, timeout_s: float = 5.0) -> HealthProbeResult: + """ + Probe an MCP server for liveness. + + MCP servers typically expose a health or capabilities endpoint. + + Args: + url: MCP server URL + timeout_s: Timeout in seconds + + Returns: + HealthProbeResult + """ + return probe_http_endpoint(url, timeout_s=timeout_s) diff --git a/src/layerlens/instrument/adapters/protocols/mcp/__init__.py b/src/layerlens/instrument/adapters/protocols/mcp/__init__.py new file mode 100644 index 0000000..262ccc6 --- /dev/null +++ b/src/layerlens/instrument/adapters/protocols/mcp/__init__.py @@ -0,0 +1,18 @@ +""" +Stratix MCP Extensions Adapter + +Instruments MCP (Model Context Protocol) extensions: +- Elicitation: Server-initiated user input requests +- Structured Tool Outputs: Schema-validated JSON outputs +- Async Tasks: Long-running tool executions +- MCP Apps: Interactive UI components invoked as tools +- OAuth 2.1/OpenID Connect: Auth within MCP sessions +""" + +from __future__ import annotations + +from layerlens.instrument.adapters.protocols.mcp.adapter import MCPExtensionsAdapter + +ADAPTER_CLASS = MCPExtensionsAdapter + +__all__ = ["MCPExtensionsAdapter", "ADAPTER_CLASS"] diff --git a/src/layerlens/instrument/adapters/protocols/mcp/adapter.py b/src/layerlens/instrument/adapters/protocols/mcp/adapter.py new file mode 100644 index 0000000..e4dbc6f --- /dev/null +++ b/src/layerlens/instrument/adapters/protocols/mcp/adapter.py @@ -0,0 +1,339 @@ +""" +MCP Extensions Adapter — Main adapter class. + +Instruments MCP protocol extensions via client-side SDK wrapping. +Monkey-patches MCP client tool call dispatch methods to capture +tool calls, structured outputs, elicitation, and async tasks. +""" + +from __future__ import annotations + +import time +import uuid +import hashlib +import logging +from typing import Any + +from layerlens.instrument.adapters._base.adapter import ( + AdapterInfo, + AdapterStatus, + ReplayableTrace, + AdapterCapability, +) +from layerlens.instrument.adapters.protocols.base import BaseProtocolAdapter + +logger = logging.getLogger(__name__) + + +class MCPExtensionsAdapter(BaseProtocolAdapter): + """ + LayerLens adapter for MCP (Model Context Protocol) Extensions. + + Instruments MCP client objects by wrapping their tool call dispatch + methods. Captures structured outputs, elicitation interactions, + async task lifecycle, and MCP App invocations. + """ + + FRAMEWORK = "mcp_extensions" + PROTOCOL = "mcp" + PROTOCOL_VERSION = "1.0.0" + VERSION = "0.1.0" + + def __init__(self, memory_service: Any | None = None, **kwargs: Any) -> None: + super().__init__(**kwargs) + self._framework_version: str | None = None + self._originals: dict[str, Any] = {} + self._async_tasks: dict[str, float] = {} # task_id → start_time + self._memory_service = memory_service + + # --- Lifecycle --- + + def connect(self) -> None: + try: + import mcp # type: ignore[import-not-found,unused-ignore] + + self._framework_version = getattr(mcp, "__version__", "unknown") + except ImportError: + self._framework_version = None + logger.debug("mcp not installed; adapter operates in standalone mode") + self._connected = True + self._status = AdapterStatus.HEALTHY + + def disconnect(self) -> None: + self._originals.clear() + self._async_tasks.clear() + self._connected = False + self._status = AdapterStatus.DISCONNECTED + self._close_sinks() + + def get_adapter_info(self) -> AdapterInfo: + return AdapterInfo( + name="MCPExtensionsAdapter", + version=self.VERSION, + framework=self.FRAMEWORK, + framework_version=self._framework_version, + capabilities=[ + AdapterCapability.TRACE_TOOLS, + AdapterCapability.TRACE_PROTOCOL_EVENTS, + AdapterCapability.REPLAY, + ], + description="LayerLens adapter for MCP Extensions", + ) + + def serialize_for_replay(self) -> ReplayableTrace: + return ReplayableTrace( + adapter_name="MCPExtensionsAdapter", + framework=self.FRAMEWORK, + trace_id=str(uuid.uuid4()), + events=list(self._trace_events), + state_snapshots=[], + config={"capture_config": self._capture_config.model_dump()}, + ) + + def probe_health(self, endpoint: str | None = None) -> dict[str, Any]: + from layerlens.instrument.adapters.protocols.health import probe_mcp_server + + if endpoint: + result = probe_mcp_server(endpoint) + return result.to_dict() + return { + "reachable": self._connected, + "latency_ms": 0.0, + "protocol_version": self._framework_version, + } + + # --- Tool call interception --- + + def on_tool_call( + self, + tool_name: str, + input_data: dict[str, Any] | None = None, + output_data: dict[str, Any] | None = None, + error: str | None = None, + latency_ms: float | None = None, + ) -> None: + """Record an MCP tool call.""" + from layerlens.instrument._vendored.events_l5_tools import ( + ToolCallEvent, + IntegrationType, + ) + + event = ToolCallEvent.create( + name=tool_name, + integration=IntegrationType.SERVICE, + input_data=input_data, + output_data=output_data, + error=error, + latency_ms=latency_ms, + ) + self.emit_event(event) + + # Store tool usage pattern as procedural memory + if self._memory_service is not None: + self._store_tool_usage_memory(tool_name, input_data, output_data, error, latency_ms) + + def _store_tool_usage_memory( + self, + tool_name: str, + input_data: dict[str, Any] | None, + output_data: dict[str, Any] | None, + error: str | None, + latency_ms: float | None, + ) -> None: + """Store tool usage pattern as procedural memory. + + Failures are logged and swallowed. + """ + try: + from layerlens.instrument._vendored.memory_models import MemoryEntry + + parts = [f"tool={tool_name}"] + if input_data: + keys = list(input_data.keys())[:10] + parts.append(f"input_keys={keys}") + if error: + parts.append(f"error={error[:200]}") + if latency_ms is not None: + parts.append(f"latency_ms={latency_ms:.1f}") + + entry = MemoryEntry( + org_id="", + agent_id="mcp", + memory_type="procedural", + key=f"tool_{tool_name}", + content=", ".join(parts), + importance=0.4, + metadata={ + "source": "mcp_adapter", + "tool_name": tool_name, + "had_error": error is not None, + }, + ) + self._memory_service.store(entry) # type: ignore[union-attr] + except Exception: + logger.debug( + "MCP: failed to store tool usage memory for %s", + tool_name, + exc_info=True, + ) + + # --- Structured outputs --- + + def on_structured_output( + self, + tool_name: str, + output: Any, + schema: dict[str, Any] | None = None, + validation_passed: bool = True, + validation_errors: list[str] | None = None, + ) -> None: + """Record an MCP structured tool output.""" + from layerlens.instrument._vendored.events_protocol import StructuredToolOutputEvent + + schema_str = str(schema or {}) + schema_hash = f"sha256:{hashlib.sha256(schema_str.encode()).hexdigest()}" + output_hash = f"sha256:{hashlib.sha256(str(output).encode()).hexdigest()}" + schema_id = None + if schema and "$id" in schema: + schema_id = schema["$id"] + + event = StructuredToolOutputEvent.create( + tool_name=tool_name, + schema_hash=schema_hash, + validation_passed=validation_passed, + output_hash=output_hash, + schema_id=schema_id, + validation_errors=validation_errors, + ) + self.emit_event(event) + + # --- Elicitation --- + + def on_elicitation_request( + self, + elicitation_id: str, + server_name: str, + schema: dict[str, Any] | None = None, + title: str | None = None, + ) -> None: + """Record an MCP elicitation request.""" + from layerlens.instrument._vendored.events_protocol import ElicitationRequestEvent + + schema_str = str(schema or {}) + schema_hash = f"sha256:{hashlib.sha256(schema_str.encode()).hexdigest()}" + schema_ref = None + if schema and "$id" in schema: + schema_ref = schema["$id"] + + event = ElicitationRequestEvent.create( + elicitation_id=elicitation_id, + server_name=server_name, + schema_hash=schema_hash, + request_title=title, + schema_ref=schema_ref, + ) + self.emit_event(event) + + def on_elicitation_response( + self, + elicitation_id: str, + action: str, + response: Any = None, + latency_ms: float | None = None, + ) -> None: + """Record an MCP elicitation response.""" + from layerlens.instrument._vendored.events_protocol import ElicitationResponseEvent + + response_hash = f"sha256:{hashlib.sha256(str(response or '').encode()).hexdigest()}" + + event = ElicitationResponseEvent.create( + elicitation_id=elicitation_id, + action=action, + response_hash=response_hash, + latency_ms=latency_ms, + ) + self.emit_event(event) + + # --- Async tasks --- + + def on_async_task( + self, + async_task_id: str, + status: str, + *, + originating_span_id: str | None = None, + progress_pct: float | None = None, + timeout_ms: int | None = None, + ) -> None: + """Record an MCP async task lifecycle event.""" + from layerlens.instrument._vendored.events_protocol import AsyncTaskEvent + + elapsed_ms = None + if status == "created": + self._async_tasks[async_task_id] = time.monotonic() + elif async_task_id in self._async_tasks: + elapsed_ms = (time.monotonic() - self._async_tasks[async_task_id]) * 1000 + if status in ("completed", "failed", "timeout"): + self._async_tasks.pop(async_task_id, None) + + event = AsyncTaskEvent.create( + async_task_id=async_task_id, + status=status, + protocol="mcp", + originating_tool_call_span_id=originating_span_id, + progress_pct=progress_pct, + timeout_ms=timeout_ms, + elapsed_ms=elapsed_ms, + ) + self.emit_event(event) + + # --- MCP Apps --- + + def on_mcp_app_invocation( + self, + app_id: str, + component_type: str, + interaction_result: str, + parameters: dict[str, Any] | None = None, + result: dict[str, Any] | None = None, + ) -> None: + """Record an MCP App invocation.""" + from layerlens.instrument._vendored.events_protocol import McpAppInvocationEvent + + params_hash = f"sha256:{hashlib.sha256(str(parameters or {}).encode()).hexdigest()}" + result_hash = None + if result is not None: + result_hash = f"sha256:{hashlib.sha256(str(result).encode()).hexdigest()}" + + event = McpAppInvocationEvent.create( + app_id=app_id, + component_type=component_type, + interaction_result=interaction_result, + parameters_hash=params_hash, + result_hash=result_hash, + ) + self.emit_event(event) + + # --- OAuth 2.1 auth events --- + + def on_auth_event( + self, + auth_type: str, + success: bool, + details: dict[str, Any] | None = None, + ) -> None: + """Record an MCP OAuth/OIDC auth event as environment.config.""" + from layerlens.instrument._vendored.events_l4_environment import ( + EnvironmentType, + EnvironmentConfigEvent, + ) + + event = EnvironmentConfigEvent.create( + env_type=EnvironmentType.CLOUD, + attributes={ + "auth_event": auth_type, + "auth_success": success, + **(details or {}), + }, + ) + self.emit_event(event) diff --git a/src/layerlens/instrument/adapters/protocols/mcp/async_task_tracker.py b/src/layerlens/instrument/adapters/protocols/mcp/async_task_tracker.py new file mode 100644 index 0000000..5441f01 --- /dev/null +++ b/src/layerlens/instrument/adapters/protocols/mcp/async_task_tracker.py @@ -0,0 +1,142 @@ +""" +MCP Async Task Tracker + +Tracks long-running MCP tool executions, detecting timeouts and +emitting protocol.async_task events for lifecycle transitions. +""" + +from __future__ import annotations + +import time +import logging +from typing import Any + +logger = logging.getLogger(__name__) + + +class AsyncTaskTracker: + """ + Tracks the lifecycle of MCP async tasks. + + Monitors created → running → completed/failed/timeout transitions + and computes elapsed time. + """ + + def __init__(self, default_timeout_ms: int = 300_000) -> None: + self._default_timeout_ms = default_timeout_ms + self._tasks: dict[str, _TaskState] = {} + + def create( + self, + task_id: str, + originating_span_id: str | None = None, + timeout_ms: int | None = None, + ) -> None: + """Record creation of an async task.""" + self._tasks[task_id] = _TaskState( + task_id=task_id, + originating_span_id=originating_span_id, + timeout_ms=timeout_ms or self._default_timeout_ms, + start_time=time.monotonic(), + status="created", + ) + + def update( + self, + task_id: str, + status: str, + progress_pct: float | None = None, + ) -> dict[str, Any] | None: + """ + Update an async task's status. + + Args: + task_id: Task identifier. + status: New status (running, completed, failed, timeout). + progress_pct: Optional progress percentage. + + Returns: + Task state dict for event emission, or None if task not found. + """ + task = self._tasks.get(task_id) + if task is None: + return None + + task.status = status + if progress_pct is not None: + task.progress_pct = progress_pct + + elapsed_ms = (time.monotonic() - task.start_time) * 1000 + + result = { + "async_task_id": task_id, + "status": status, + "originating_span_id": task.originating_span_id, + "progress_pct": task.progress_pct, + "timeout_ms": task.timeout_ms, + "elapsed_ms": elapsed_ms, + } + + if status in ("completed", "failed", "timeout"): + self._tasks.pop(task_id, None) + + return result + + def check_timeouts(self) -> list[str]: + """ + Check for tasks that have exceeded their timeout. + + Returns: + List of task IDs that have timed out. + """ + now = time.monotonic() + timed_out: list[str] = [] + for task_id, task in list(self._tasks.items()): + elapsed_ms = (now - task.start_time) * 1000 + if elapsed_ms > task.timeout_ms: + timed_out.append(task_id) + return timed_out + + @property + def active_count(self) -> int: + return len(self._tasks) + + def get_task(self, task_id: str) -> dict[str, Any] | None: + task = self._tasks.get(task_id) + if task is None: + return None + return { + "task_id": task.task_id, + "status": task.status, + "elapsed_ms": (time.monotonic() - task.start_time) * 1000, + "timeout_ms": task.timeout_ms, + "progress_pct": task.progress_pct, + } + + +class _TaskState: + """Internal task state tracker.""" + + __slots__ = ( + "task_id", + "originating_span_id", + "timeout_ms", + "start_time", + "status", + "progress_pct", + ) + + def __init__( + self, + task_id: str, + originating_span_id: str | None, + timeout_ms: int, + start_time: float, + status: str, + ) -> None: + self.task_id = task_id + self.originating_span_id = originating_span_id + self.timeout_ms = timeout_ms + self.start_time = start_time + self.status = status + self.progress_pct: float | None = None diff --git a/src/layerlens/instrument/adapters/protocols/mcp/elicitation.py b/src/layerlens/instrument/adapters/protocols/mcp/elicitation.py new file mode 100644 index 0000000..ec4374f --- /dev/null +++ b/src/layerlens/instrument/adapters/protocols/mcp/elicitation.py @@ -0,0 +1,96 @@ +""" +MCP Elicitation Handler + +Handles MCP Elicitation extension events — server-initiated user input +requests and user responses. Manages the request/response event pair +with privacy-preserving hashing. +""" + +from __future__ import annotations + +import time +import uuid +import hashlib +import logging +from typing import Any + +logger = logging.getLogger(__name__) + + +class ElicitationTracker: + """ + Tracks active MCP elicitation interactions. + + Manages the lifecycle of elicitation request/response pairs, + computing timing and generating unique identifiers. + """ + + def __init__(self) -> None: + self._active: dict[str, float] = {} # elicitation_id → start_time + + def start_request( + self, + server_name: str, + schema: dict[str, Any] | None = None, + title: str | None = None, + elicitation_id: str | None = None, + ) -> str: + """ + Record the start of an elicitation request. + + Args: + server_name: MCP server name. + schema: JSON Schema for the requested input. + title: Human-readable title. + elicitation_id: Optional pre-assigned ID. + + Returns: + The elicitation ID. + """ + eid = elicitation_id or str(uuid.uuid4()) + self._active[eid] = time.monotonic() + return eid + + def complete_response( + self, + elicitation_id: str, + action: str, + response: Any = None, + ) -> float | None: + """ + Record the completion of an elicitation response. + + Args: + elicitation_id: The elicitation ID. + action: User action (submit | cancel). + response: User response (will be hashed, not stored in cleartext). + + Returns: + Latency in milliseconds, or None if request not tracked. + """ + start = self._active.pop(elicitation_id, None) + if start is not None: + return (time.monotonic() - start) * 1000 + return None + + def is_active(self, elicitation_id: str) -> bool: + """Check if an elicitation is still awaiting response.""" + return elicitation_id in self._active + + @property + def active_count(self) -> int: + return len(self._active) + + def hash_response(self, response: Any) -> str: + """Hash a user response for privacy-preserving storage.""" + response_str = str(response or "") + h = hashlib.sha256(response_str.encode()).hexdigest() + return f"sha256:{h}" + + def hash_schema(self, schema: dict[str, Any] | None) -> str: + """Hash a request schema.""" + import json + + schema_str = json.dumps(schema or {}, sort_keys=True) + h = hashlib.sha256(schema_str.encode()).hexdigest() + return f"sha256:{h}" diff --git a/src/layerlens/instrument/adapters/protocols/mcp/mcp_app_handler.py b/src/layerlens/instrument/adapters/protocols/mcp/mcp_app_handler.py new file mode 100644 index 0000000..240c74c --- /dev/null +++ b/src/layerlens/instrument/adapters/protocols/mcp/mcp_app_handler.py @@ -0,0 +1,58 @@ +""" +MCP App Invocation Handler + +Captures MCP App (interactive UI component) invocations. MCP Apps +are UI components that can be invoked as tools — forms, confirmation +dialogs, pickers, etc. +""" + +from __future__ import annotations + +import hashlib +import logging +from typing import Any + +logger = logging.getLogger(__name__) + + +# Known MCP App component types +COMPONENT_TYPES = frozenset({"form", "confirmation", "picker", "custom"}) + +# Known interaction results +INTERACTION_RESULTS = frozenset({"submitted", "cancelled", "timeout"}) + + +def hash_parameters(parameters: dict[str, Any] | None) -> str: + """Hash MCP App invocation parameters.""" + import json + + params_str = json.dumps(parameters or {}, sort_keys=True, default=str) + h = hashlib.sha256(params_str.encode()).hexdigest() + return f"sha256:{h}" + + +def hash_result(result: dict[str, Any] | None) -> str | None: + """Hash MCP App interaction result. Returns None if no result.""" + if result is None: + return None + import json + + result_str = json.dumps(result, sort_keys=True, default=str) + h = hashlib.sha256(result_str.encode()).hexdigest() + return f"sha256:{h}" + + +def normalize_component_type(component_type: str) -> str: + """Normalize a component type string to a known type.""" + ct = component_type.lower().strip() + if ct in COMPONENT_TYPES: + return ct + return "custom" + + +def normalize_interaction_result(result: str) -> str: + """Normalize an interaction result string.""" + r = result.lower().strip() + if r in INTERACTION_RESULTS: + return r + return "submitted" diff --git a/src/layerlens/instrument/adapters/protocols/mcp/structured_output.py b/src/layerlens/instrument/adapters/protocols/mcp/structured_output.py new file mode 100644 index 0000000..8d77905 --- /dev/null +++ b/src/layerlens/instrument/adapters/protocols/mcp/structured_output.py @@ -0,0 +1,93 @@ +""" +MCP Structured Output Handler + +Handles schema validation of MCP structured tool outputs and +emits protocol.tool.structured_output events. +""" + +from __future__ import annotations + +import json +import hashlib +import logging +from typing import Any + +logger = logging.getLogger(__name__) + + +def validate_structured_output( + output: Any, + schema: dict[str, Any], +) -> tuple[bool, list[str]]: + """ + Validate a structured output against a JSON Schema. + + Uses basic type checking when jsonschema is not available. + + Args: + output: The structured output value. + schema: The JSON Schema to validate against. + + Returns: + Tuple of (is_valid, list of error messages). + """ + errors: list[str] = [] + + try: + import jsonschema # type: ignore[import-untyped,unused-ignore] + + try: + jsonschema.validate(instance=output, schema=schema) + return True, [] + except jsonschema.ValidationError as exc: + errors.append(str(exc.message)) + return False, errors + except jsonschema.SchemaError as exc: + errors.append(f"Invalid schema: {exc.message}") + return False, errors + except ImportError: + # Fallback: basic type validation + return _basic_type_check(output, schema) + + +def _basic_type_check( + output: Any, + schema: dict[str, Any], +) -> tuple[bool, list[str]]: + """Basic type check when jsonschema is not available.""" + errors: list[str] = [] + schema_type = schema.get("type") + + if schema_type == "object" and not isinstance(output, dict): + errors.append(f"Expected object, got {type(output).__name__}") + elif schema_type == "array" and not isinstance(output, list): + errors.append(f"Expected array, got {type(output).__name__}") + elif schema_type == "string" and not isinstance(output, str): + errors.append(f"Expected string, got {type(output).__name__}") + elif schema_type == "number" and not isinstance(output, (int, float)): + errors.append(f"Expected number, got {type(output).__name__}") + elif schema_type == "boolean" and not isinstance(output, bool): + errors.append(f"Expected boolean, got {type(output).__name__}") + + # Check required fields for objects + if schema_type == "object" and isinstance(output, dict): + required = schema.get("required", []) + for field in required: + if field not in output: + errors.append(f"Missing required field: {field}") + + return len(errors) == 0, errors + + +def compute_output_hash(output: Any) -> str: + """Compute SHA-256 hash of a structured output value.""" + output_str = json.dumps(output, sort_keys=True, default=str) + h = hashlib.sha256(output_str.encode()).hexdigest() + return f"sha256:{h}" + + +def compute_schema_hash(schema: dict[str, Any]) -> str: + """Compute SHA-256 hash of a JSON Schema.""" + schema_str = json.dumps(schema, sort_keys=True) + h = hashlib.sha256(schema_str.encode()).hexdigest() + return f"sha256:{h}" diff --git a/src/layerlens/instrument/adapters/protocols/mcp/tool_wrapper.py b/src/layerlens/instrument/adapters/protocols/mcp/tool_wrapper.py new file mode 100644 index 0000000..6cd95da --- /dev/null +++ b/src/layerlens/instrument/adapters/protocols/mcp/tool_wrapper.py @@ -0,0 +1,132 @@ +""" +MCP Tool Call Wrapper + +Wraps MCP client tool call dispatch to intercept and trace all tool +invocations automatically. +""" + +from __future__ import annotations + +import time +import logging +import functools +from typing import Any +from collections.abc import Callable + +logger = logging.getLogger(__name__) + + +def wrap_mcp_tool_call( + original_fn: Callable[..., Any], + adapter: Any, +) -> Callable[..., Any]: + """ + Wrap an MCP tool call function for tracing. + + The wrapper emits tool.call events for every invocation, plus + protocol.tool.structured_output if a structured output schema + is present. + + Args: + original_fn: The original tool call function. + adapter: MCPExtensionsAdapter instance. + + Returns: + Wrapped function. + """ + if getattr(original_fn, "_layerlens_original", False): + return original_fn + + @functools.wraps(original_fn) + def wrapper(*args: Any, **kwargs: Any) -> Any: + tool_name = kwargs.get("name", kwargs.get("tool_name", "unknown")) + input_data = kwargs.get("arguments", kwargs.get("input", {})) + + start = time.monotonic() + error_msg = None + result = None + try: + result = original_fn(*args, **kwargs) + return result + except Exception as exc: + error_msg = str(exc) + raise + finally: + latency_ms = (time.monotonic() - start) * 1000 + output_data = None + if result is not None: + if hasattr(result, "model_dump"): + output_data = result.model_dump() + elif isinstance(result, dict): + output_data = result + else: + output_data = {"result": str(result)} + + adapter.on_tool_call( + tool_name=str(tool_name), + input_data=input_data + if isinstance(input_data, dict) + else {"args": str(input_data)}, + output_data=output_data, + error=error_msg, + latency_ms=latency_ms, + ) + + wrapper._layerlens_original = True # type: ignore[attr-defined] + return wrapper + + +async def wrap_mcp_tool_call_async( + original_fn: Callable[..., Any], + adapter: Any, +) -> Callable[..., Any]: + """ + Wrap an async MCP tool call function for tracing. + + Args: + original_fn: The original async tool call function. + adapter: MCPExtensionsAdapter instance. + + Returns: + Wrapped async function. + """ + if getattr(original_fn, "_layerlens_original", False): + return original_fn + + @functools.wraps(original_fn) + async def wrapper(*args: Any, **kwargs: Any) -> Any: + tool_name = kwargs.get("name", kwargs.get("tool_name", "unknown")) + input_data = kwargs.get("arguments", kwargs.get("input", {})) + + start = time.monotonic() + error_msg = None + result = None + try: + result = await original_fn(*args, **kwargs) + return result + except Exception as exc: + error_msg = str(exc) + raise + finally: + latency_ms = (time.monotonic() - start) * 1000 + output_data = None + if result is not None: + if hasattr(result, "model_dump"): + output_data = result.model_dump() + elif isinstance(result, dict): + output_data = result + else: + output_data = {"result": str(result)} + + adapter.on_tool_call( + tool_name=str(tool_name), + input_data=input_data + if isinstance(input_data, dict) + else {"args": str(input_data)}, + output_data=output_data, + error=error_msg, + latency_ms=latency_ms, + ) + + wrapper._layerlens_original = True # type: ignore[attr-defined] + return wrapper diff --git a/src/layerlens/instrument/adapters/protocols/ucp.py b/src/layerlens/instrument/adapters/protocols/ucp.py new file mode 100644 index 0000000..53ddad7 --- /dev/null +++ b/src/layerlens/instrument/adapters/protocols/ucp.py @@ -0,0 +1,447 @@ +""" +UCP Protocol Adapter — Universal Commerce Protocol adapter. + +Instruments UCP protocol interactions including supplier discovery, catalog +browsing, checkout session lifecycle, and order refunds. Emits L7b commerce +events from ``stratix.core.events.commerce``. +""" + +from __future__ import annotations + +import time +import uuid +import logging +from typing import Any + +from layerlens.instrument.adapters._base.adapter import ( + AdapterInfo, + AdapterStatus, + ReplayableTrace, + AdapterCapability, +) +from layerlens.instrument.adapters.protocols.base import BaseProtocolAdapter + +logger = logging.getLogger(__name__) + + +class UCPAdapter(BaseProtocolAdapter): + """ + LayerLens adapter for the UCP (Universal Commerce Protocol). + + Instruments the full UCP commerce lifecycle: + + - Supplier discovery via well-known endpoint, registry, or referral + - Catalog browse activity (high-frequency, item-count granularity) + - Checkout session creation and completion + - Order refunds + + All monetary events emit L7b events from ``stratix.core.events.commerce``. + Checkout session durations are tracked from ``on_checkout_created`` to + ``on_checkout_completed`` and logged for observability. + + Usage:: + + adapter = UCPAdapter() + adapter.connect() + + adapter.on_supplier_discovered( + supplier_id="sup_abc", + name="Acme Supplies", + profile_url="https://acme.example.com/.well-known/ucp.json", + org_id="org_123", + ) + + adapter.on_checkout_created( + checkout_session_id="cs_xyz", + supplier_id="sup_abc", + line_items=[{"item_id": "sku_1", "quantity": 2, "unit_price": 49.99}], + total_amount=99.98, + org_id="org_123", + ) + + adapter.on_checkout_completed("cs_xyz", org_id="org_123", order_id="ord_456") + adapter.disconnect() + """ + + FRAMEWORK = "ucp" + PROTOCOL = "ucp" + PROTOCOL_VERSION = "1.0.0" + VERSION = "0.1.0" + + def __init__(self, memory_service: Any | None = None, **kwargs: Any) -> None: + super().__init__(**kwargs) + self._suppliers: dict[str, dict[str, Any]] = {} + self._checkout_sessions: dict[str, dict[str, Any]] = {} + self._session_start_times: dict[str, float] = {} + self._memory_service = memory_service + + # --- Lifecycle --- + + def connect(self) -> None: + """Connect the adapter and mark it healthy.""" + self._connected = True + self._status = AdapterStatus.HEALTHY + logger.debug("UCPAdapter connected (protocol=%s v%s)", self.PROTOCOL, self.PROTOCOL_VERSION) + + def disconnect(self) -> None: + """Disconnect the adapter and release all tracked state.""" + self._suppliers.clear() + self._checkout_sessions.clear() + self._session_start_times.clear() + self._connected = False + self._status = AdapterStatus.DISCONNECTED + self._close_sinks() + logger.debug("UCPAdapter disconnected") + + def get_adapter_info(self) -> AdapterInfo: + """Return static metadata describing this adapter's identity and capabilities.""" + return AdapterInfo( + name="UCPAdapter", + version=self.VERSION, + framework=self.FRAMEWORK, + framework_version=self.PROTOCOL_VERSION, + capabilities=[ + AdapterCapability.TRACE_PROTOCOL_EVENTS, + AdapterCapability.REPLAY, + ], + description="LayerLens adapter for the UCP (Universal Commerce Protocol)", + ) + + def serialize_for_replay(self) -> ReplayableTrace: + """Serialize accumulated trace events and adapter state for replay.""" + return ReplayableTrace( + adapter_name="UCPAdapter", + framework=self.FRAMEWORK, + trace_id=str(uuid.uuid4()), + events=list(self._trace_events), + state_snapshots=[], + config={ + "capture_config": self._capture_config.model_dump(), + "suppliers": dict(self._suppliers.items()), + }, + ) + + def probe_health(self, endpoint: str | None = None) -> dict[str, Any]: + """ + Probe adapter health. + + Args: + endpoint: Optional UCP well-known endpoint URL to probe. If None, + returns local adapter connectivity status only. + + Returns: + Dict with ``reachable`` (bool), ``latency_ms`` (float), and + ``protocol_version`` (str). + """ + return { + "reachable": self._connected, + "latency_ms": 0.0, + "protocol_version": self.PROTOCOL_VERSION, + } + + # --- Supplier discovery --- + + def on_supplier_discovered( + self, + supplier_id: str, + name: str, + profile_url: str, + org_id: str, + *, + capabilities: list[str] | None = None, + discovery_method: str = "well_known", + ) -> None: + """ + Record the discovery of a UCP supplier and emit a + ``commerce.supplier.discovered`` event. + + Args: + supplier_id: Unique supplier identifier. + name: Human-readable supplier name. + profile_url: URL of the supplier's UCP profile document. + org_id: Organization performing the discovery. + capabilities: Declared UCP capability identifiers, if known. + discovery_method: How the supplier was found: + ``well_known`` | ``registry`` | ``referral``. + """ + from layerlens.instrument.adapters.protocols._commerce import ( + SupplierInfo, + SupplierDiscoveredEvent, + ) + + supplier_info = SupplierInfo( + supplier_id=supplier_id, + name=name, + profile_url=profile_url, + capabilities=capabilities or [], + ) + self._suppliers[supplier_id] = { + "name": name, + "profile_url": profile_url, + "capabilities": capabilities or [], + "discovery_method": discovery_method, + } + + event = SupplierDiscoveredEvent.create( + supplier=supplier_info, + org_id=org_id, + discovery_method=discovery_method, + ) + logger.debug( + "UCPAdapter: supplier discovered supplier_id=%s method=%s org_id=%s", + supplier_id, + discovery_method, + org_id, + ) + self.emit_event(event) + + # Store supplier info as semantic memory + if self._memory_service is not None: + self._store_supplier_memory( + supplier_id, name, profile_url, capabilities or [], discovery_method, org_id + ) + + def _store_supplier_memory( + self, + supplier_id: str, + name: str, + profile_url: str, + capabilities: list[str], + discovery_method: str, + org_id: str, + ) -> None: + """Store supplier information as semantic memory. + + Failures are logged and swallowed. + """ + try: + from layerlens.instrument._vendored.memory_models import MemoryEntry + + content = ( + f"Supplier '{name}' (id={supplier_id}), profile={profile_url}, " + f"capabilities={capabilities}, discovered via {discovery_method}" + ) + entry = MemoryEntry( + org_id=org_id, + agent_id=f"ucp_{org_id}", + memory_type="semantic", + key=f"supplier_{supplier_id}", + content=content, + importance=0.6, + metadata={ + "source": "ucp_adapter", + "supplier_id": supplier_id, + "discovery_method": discovery_method, + }, + ) + self._memory_service.store(entry) # type: ignore[union-attr] + except Exception: + logger.debug( + "UCP: failed to store supplier memory for %s", + supplier_id, + exc_info=True, + ) + + # --- Catalog browsing --- + + def on_catalog_browsed( + self, + supplier_id: str, + org_id: str, + *, + items_viewed: int = 0, + items_selected: int = 0, + ) -> None: + """ + Record a catalog browse session against a supplier and emit a + ``commerce.catalog.browsed`` event. + + This is a high-frequency event; individual item details are not captured + to keep payload size minimal. + + Args: + supplier_id: Supplier whose catalog was browsed. + org_id: Organization performing the browse. + items_viewed: Number of catalog items viewed during the session. + items_selected: Number of items added to the checkout basket. + """ + from layerlens.instrument.adapters.protocols._commerce import CatalogBrowsedEvent + + event = CatalogBrowsedEvent.create( + supplier_id=supplier_id, + org_id=org_id, + items_viewed=items_viewed, + items_selected=items_selected, + ) + logger.debug( + "UCPAdapter: catalog browsed supplier_id=%s viewed=%d selected=%d org_id=%s", + supplier_id, + items_viewed, + items_selected, + org_id, + ) + self.emit_event(event) + + # --- Checkout lifecycle --- + + def on_checkout_created( + self, + checkout_session_id: str, + supplier_id: str, + line_items: list[dict[str, Any]], + total_amount: float, + org_id: str, + *, + currency: str = "USD", + idempotency_key: str | None = None, + ) -> None: + """ + Record the creation of a UCP checkout session and emit a + ``commerce.checkout.created`` event. + + Starts an internal timer for this session so that duration can be + calculated when ``on_checkout_completed`` is called. + + Args: + checkout_session_id: Unique checkout session identifier. + supplier_id: Supplier hosting the checkout. + line_items: List of line-item dicts. Each dict may contain + ``item_id``, ``name``, ``quantity``, ``unit_price``, and + ``currency`` keys; extra keys are ignored. + total_amount: Pre-tax total of all line items. + org_id: Organization initiating the checkout. + currency: ISO 4217 currency code (default ``"USD"``). + idempotency_key: Optional client-supplied idempotency key to allow + safe retries. + """ + from layerlens.instrument.adapters.protocols._commerce import ( + LineItemInfo, + CheckoutCreatedEvent, + ) + + parsed_items = [ + LineItemInfo( + item_id=item.get("item_id", ""), + name=item.get("name"), + quantity=item.get("quantity", 1), + unit_price=item.get("unit_price"), + currency=item.get("currency", currency), + ) + for item in line_items + ] + + self._checkout_sessions[checkout_session_id] = { + "supplier_id": supplier_id, + "total_amount": total_amount, + "currency": currency, + "item_count": len(parsed_items), + } + self._session_start_times[checkout_session_id] = time.monotonic() + + event = CheckoutCreatedEvent.create( + checkout_session_id=checkout_session_id, + supplier_id=supplier_id, + total_amount=total_amount, + org_id=org_id, + line_items=parsed_items, + currency=currency, + idempotency_key=idempotency_key, + ) + logger.debug( + "UCPAdapter: checkout created session_id=%s supplier_id=%s total=%.2f %s org_id=%s", + checkout_session_id, + supplier_id, + total_amount, + currency, + org_id, + ) + self.emit_event(event) + + def on_checkout_completed( + self, + checkout_session_id: str, + org_id: str, + *, + order_id: str | None = None, + payment_reference: str | None = None, + status: str = "completed", + ) -> None: + """ + Record the terminal state of a UCP checkout session and emit a + ``commerce.checkout.completed`` event. + + Calculates and logs the session duration if a corresponding + ``on_checkout_created`` call was recorded. + + Args: + checkout_session_id: Checkout session reaching a terminal state. + org_id: Organization that initiated the checkout. + order_id: Supplier-issued order identifier, if available. + payment_reference: Reference to the AP2 payment that settled the + order, if applicable. + status: Terminal status: ``completed`` | ``failed`` | ``cancelled``. + """ + from layerlens.instrument.adapters.protocols._commerce import CheckoutCompletedEvent + + duration_ms: float | None = None + if checkout_session_id in self._session_start_times: + duration_ms = ( + time.monotonic() - self._session_start_times.pop(checkout_session_id) + ) * 1000 + self._checkout_sessions.pop(checkout_session_id, None) + + event = CheckoutCompletedEvent.create( + checkout_session_id=checkout_session_id, + org_id=org_id, + order_id=order_id, + payment_reference=payment_reference, + status=status, + ) + logger.debug( + "UCPAdapter: checkout completed session_id=%s status=%s duration_ms=%s org_id=%s", + checkout_session_id, + status, + f"{duration_ms:.1f}" if duration_ms is not None else "n/a", + org_id, + ) + self.emit_event(event) + + # --- Order refunds --- + + def on_order_refunded( + self, + order_id: str, + refund_amount: float, + org_id: str, + *, + currency: str = "USD", + reason: str | None = None, + ) -> None: + """ + Record a full or partial order refund and emit a + ``commerce.order.refunded`` event. + + Args: + order_id: Supplier-issued order identifier being refunded. + refund_amount: Amount refunded (may be partial). + org_id: Organization that placed the original order. + currency: ISO 4217 currency code (default ``"USD"``). + reason: Optional human-readable explanation for the refund. + """ + from layerlens.instrument.adapters.protocols._commerce import OrderRefundedEvent + + event = OrderRefundedEvent.create( + order_id=order_id, + refund_amount=refund_amount, + org_id=org_id, + currency=currency, + reason=reason, + ) + logger.debug( + "UCPAdapter: order refunded order_id=%s amount=%.2f %s org_id=%s", + order_id, + refund_amount, + currency, + org_id, + ) + self.emit_event(event) diff --git a/tests/instrument/adapters/__init__.py b/tests/instrument/adapters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/instrument/adapters/protocols/__init__.py b/tests/instrument/adapters/protocols/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/instrument/adapters/protocols/test_a2a_adapter.py b/tests/instrument/adapters/protocols/test_a2a_adapter.py new file mode 100644 index 0000000..7fad618 --- /dev/null +++ b/tests/instrument/adapters/protocols/test_a2a_adapter.py @@ -0,0 +1,204 @@ +"""Unit tests for the A2A (Agent-to-Agent) protocol adapter. + +A2A emits ``protocol.agent_card``, ``protocol.task.submitted``, +``protocol.task.completed``, ``protocol.stream.event``, and the +cross-cutting ``agent.handoff`` event. All of these pass the default +:class:`CaptureConfig` layer-gate, so we can use the +``_RecordingStratix`` pattern (matching the canonical SmolAgents tests) +without needing to monkey-patch ``emit_event``. +""" + +from __future__ import annotations + +from typing import Any, Dict, List + +from layerlens.instrument.adapters._base.adapter import ( + AdapterInfo, + AdapterStatus, + ReplayableTrace, +) +from layerlens.instrument.adapters.protocols.a2a import ADAPTER_CLASS, A2AAdapter + + +class _RecordingStratix: + def __init__(self) -> None: + self.events: List[Dict[str, Any]] = [] + + def emit(self, *args: Any, **kwargs: Any) -> None: + # Adapter calls ``self._stratix.emit(payload)`` (single positional arg) + if args: + payload = args[0] + self.events.append( + { + "event_type": getattr(payload, "event_type", None), + "payload": payload, + } + ) + + +def test_adapter_class_export() -> None: + assert ADAPTER_CLASS is A2AAdapter + + +def test_adapter_class_constants() -> None: + assert A2AAdapter.FRAMEWORK == "a2a" + assert A2AAdapter.PROTOCOL == "a2a" + assert A2AAdapter.PROTOCOL_VERSION == "0.2.1" + + +def test_lifecycle_transitions() -> None: + adapter = A2AAdapter() + assert adapter.status == AdapterStatus.DISCONNECTED + adapter.connect() + assert adapter.status == AdapterStatus.HEALTHY + adapter.disconnect() + assert adapter.status == AdapterStatus.DISCONNECTED + + +def test_disconnect_clears_state() -> None: + adapter = A2AAdapter(stratix=_RecordingStratix()) + adapter.connect() + adapter.register_agent_card( + {"name": "agent_a", "url": "http://x", "version": "1.0"}, source="discovery" + ) + assert adapter._agent_cards != {} + adapter.disconnect() + assert adapter._agent_cards == {} + assert adapter._task_machines == {} + assert adapter._task_start_times == {} + + +def test_get_adapter_info_shape() -> None: + adapter = A2AAdapter() + info = adapter.get_adapter_info() + assert isinstance(info, AdapterInfo) + assert info.framework == "a2a" + assert info.name == "A2AAdapter" + + +def test_probe_health_default_no_endpoint() -> None: + adapter = A2AAdapter() + adapter.connect() + h = adapter.probe_health() + assert h["reachable"] is True + assert "latency_ms" in h + assert "protocol_version" in h + + +def test_register_agent_card_emits_event() -> None: + stratix = _RecordingStratix() + adapter = A2AAdapter(stratix=stratix) + adapter.connect() + adapter.register_agent_card( + { + "name": "researcher", + "url": "http://researcher.example", + "protocolVersion": "0.2.1", + "skills": [ + {"id": "search", "name": "Web Search", "tags": ["http"], "examples": []}, + ], + "capabilities": {"streaming": True}, + }, + source="discovery", + ) + types = [e["event_type"] for e in stratix.events] + assert "protocol.agent_card" in types + assert "researcher" in adapter._agent_cards + + +def test_task_submitted_and_completed_emit_lifecycle_events() -> None: + stratix = _RecordingStratix() + adapter = A2AAdapter(stratix=stratix) + adapter.connect() + adapter.on_task_submitted( + task_id="t-1", + receiver_url="http://receiver.example", + task_type="summarize", + submitter_agent_id="agent-a", + ) + assert "t-1" in adapter._task_start_times + assert "t-1" in adapter._task_machines + + adapter.on_task_completed( + task_id="t-1", + final_status="completed", + artifacts=[{"id": "out-1", "data": "result"}], + ) + assert "t-1" not in adapter._task_start_times + assert "t-1" not in adapter._task_machines + + types = [e["event_type"] for e in stratix.events] + assert "protocol.task.submitted" in types + assert "protocol.task.completed" in types + + +def test_task_completed_with_error_emits_error_fields() -> None: + stratix = _RecordingStratix() + adapter = A2AAdapter(stratix=stratix) + adapter.connect() + adapter.on_task_submitted(task_id="t-bad", receiver_url="http://r") + adapter.on_task_completed( + task_id="t-bad", + final_status="failed", + error_code="E_TIMEOUT", + error_message="receiver did not respond", + ) + completed = next( + e for e in stratix.events if e["event_type"] == "protocol.task.completed" + ) + assert completed["payload"].error_code == "E_TIMEOUT" + assert completed["payload"].error_message == "receiver did not respond" + assert completed["payload"].final_status == "failed" + + +def test_task_delegation_emits_handoff() -> None: + stratix = _RecordingStratix() + adapter = A2AAdapter(stratix=stratix) + adapter.connect() + adapter.on_task_delegation(from_agent="a", to_agent="b", context={"k": "v"}) + types = [e["event_type"] for e in stratix.events] + assert "agent.handoff" in types + handoff = next(e for e in stratix.events if e["event_type"] == "agent.handoff") + assert handoff["payload"].from_agent == "a" + assert handoff["payload"].to_agent == "b" + assert handoff["payload"].handoff_context_hash.startswith("sha256:") + + +def test_stream_event_emits_protocol_stream_event() -> None: + stratix = _RecordingStratix() + adapter = A2AAdapter(stratix=stratix) + adapter.connect() + adapter.on_stream_event(sequence=0, payload={"chunk": "hello"}) + adapter.on_stream_event(sequence=1, payload={"chunk": " world"}) + streams = [e for e in stratix.events if e["event_type"] == "protocol.stream.event"] + assert len(streams) == 2 + assert streams[0]["payload"].protocol == "a2a" + assert streams[1]["payload"].sequence_in_stream == 1 + + +def test_acp_origin_payload_normalized() -> None: + """ACP-origin (IBM Agent Communication Protocol) payloads should be + detected and reflected in the emitted task event's protocol_origin.""" + stratix = _RecordingStratix() + adapter = A2AAdapter(stratix=stratix) + adapter.connect() + # ACPNormalizer detects via specific keys; pass a payload that may or may + # not match — at minimum it must NOT crash. + adapter.on_task_submitted( + task_id="t-acp", + receiver_url="http://r", + raw_payload={"agent": "x", "input": [{"role": "user", "parts": [{"text": "hi"}]}]}, + ) + types = [e["event_type"] for e in stratix.events] + assert "protocol.task.submitted" in types + + +def test_serialize_for_replay_shape() -> None: + adapter = A2AAdapter(stratix=_RecordingStratix()) + adapter.connect() + adapter.register_agent_card({"name": "a", "url": "u", "version": "1.0"}) + rt = adapter.serialize_for_replay() + assert isinstance(rt, ReplayableTrace) + assert rt.adapter_name == "A2AAdapter" + assert rt.framework == "a2a" + assert "agent_cards" in rt.config diff --git a/tests/instrument/adapters/protocols/test_a2ui_adapter.py b/tests/instrument/adapters/protocols/test_a2ui_adapter.py new file mode 100644 index 0000000..b80763c --- /dev/null +++ b/tests/instrument/adapters/protocols/test_a2ui_adapter.py @@ -0,0 +1,160 @@ +"""Unit tests for the A2UI (Agent-to-User Interface) protocol adapter. + +A2UI emits ``commerce.ui.*`` events; like AP2, those event types are +not in the default :class:`CaptureConfig` layer map, so the adapter's +:meth:`emit_event` would silently drop them. The recorder pattern +replaces ``emit_event`` with a hook that captures every payload. +""" + +from __future__ import annotations + +import hashlib +from typing import Any, Dict, List, Callable + +from layerlens.instrument.adapters._base.adapter import ( + AdapterInfo, + AdapterStatus, + ReplayableTrace, +) +from layerlens.instrument.adapters.protocols.a2ui import A2UIAdapter + + +def _make_recorder() -> tuple[List[Dict[str, Any]], Callable[..., None]]: + events: List[Dict[str, Any]] = [] + + def _emit(payload: Any, privacy_level: Any = None) -> None: + events.append( + { + "event_type": getattr(payload, "event_type", None), + "payload": payload, + } + ) + + return events, _emit + + +def _make_adapter() -> tuple[A2UIAdapter, List[Dict[str, Any]]]: + events, emit = _make_recorder() + adapter = A2UIAdapter() + adapter.emit_event = emit # type: ignore[method-assign] + adapter.connect() + return adapter, events + + +def test_adapter_class_constants() -> None: + assert A2UIAdapter.FRAMEWORK == "a2ui" + assert A2UIAdapter.PROTOCOL == "a2ui" + assert A2UIAdapter.PROTOCOL_VERSION == "1.0.0" + + +def test_lifecycle_transitions() -> None: + adapter = A2UIAdapter() + assert adapter.status == AdapterStatus.DISCONNECTED + adapter.connect() + assert adapter.status == AdapterStatus.HEALTHY + adapter.disconnect() + assert adapter.status == AdapterStatus.DISCONNECTED + + +def test_disconnect_clears_state() -> None: + adapter, _ = _make_adapter() + adapter.on_surface_created(surface_id="s1", org_id="o", component_count=5) + assert "s1" in adapter._surfaces + adapter.disconnect() + assert adapter._surfaces == {} + assert adapter._component_counts == {} + + +def test_get_adapter_info_shape() -> None: + adapter, _ = _make_adapter() + info = adapter.get_adapter_info() + assert isinstance(info, AdapterInfo) + assert info.framework == "a2ui" + assert info.name == "A2UIAdapter" + + +def test_probe_health_shape() -> None: + adapter, _ = _make_adapter() + health = adapter.probe_health() + assert health["reachable"] is True + assert health["protocol_version"] == "1.0.0" + assert "latency_ms" in health + + +def test_on_surface_created_emits_event_and_tracks_state() -> None: + adapter, events = _make_adapter() + adapter.on_surface_created( + surface_id="surf_1", + org_id="org_1", + root_component_id="cmp_root", + component_count=7, + ) + assert "surf_1" in adapter._surfaces + assert adapter._component_counts["surf_1"] == 7 + + types = [e["event_type"] for e in events] + assert "commerce.ui.surface_created" in types + payload = next(e for e in events if e["event_type"] == "commerce.ui.surface_created")["payload"] + assert payload.surface_id == "surf_1" + assert payload.org_id == "org_1" + assert payload.component_count == 7 + + +def test_on_user_action_emits_event_with_hashed_context() -> None: + adapter, events = _make_adapter() + context = {"cart_total": 99.98, "currency": "USD"} + adapter.on_user_action( + surface_id="surf_1", + action_name="confirm_purchase", + org_id="org_1", + component_id="cmp_btn", + context=context, + ) + + types = [e["event_type"] for e in events] + assert "commerce.ui.user_action" in types + payload = next(e for e in events if e["event_type"] == "commerce.ui.user_action")["payload"] + assert payload.action_name == "confirm_purchase" + + # Cleartext context never appears; hash is used instead + expected_hash = "sha256:" + hashlib.sha256(str(context).encode()).hexdigest() + assert payload.context_hash == expected_hash + + +def test_on_user_action_no_context_no_hash() -> None: + adapter, events = _make_adapter() + adapter.on_user_action( + surface_id="s", + action_name="cancel", + org_id="o", + ) + payload = next(e for e in events if e["event_type"] == "commerce.ui.user_action")["payload"] + assert payload.context_hash is None + + +def test_serialize_for_replay_shape() -> None: + adapter, _ = _make_adapter() + adapter.on_surface_created(surface_id="s1", org_id="o", component_count=2) + rt = adapter.serialize_for_replay() + assert isinstance(rt, ReplayableTrace) + assert rt.adapter_name == "A2UIAdapter" + assert rt.framework == "a2ui" + assert "surfaces" in rt.config + + +def test_action_context_hash_is_deterministic() -> None: + """Same context dict produces the same hash (deterministic for replay).""" + adapter, events = _make_adapter() + ctx = {"a": 1, "b": "x"} + adapter.on_user_action(surface_id="s", action_name="a1", org_id="o", context=ctx) + adapter.on_user_action(surface_id="s", action_name="a2", org_id="o", context=ctx) + h1 = events[0]["payload"].context_hash + h2 = events[1]["payload"].context_hash + assert h1 == h2 and h1 is not None + + +def test_health_check_reflects_disconnected() -> None: + adapter = A2UIAdapter() + # Without connect(), reachable is False + h = adapter.probe_health() + assert h["reachable"] is False diff --git a/tests/instrument/adapters/protocols/test_agui_adapter.py b/tests/instrument/adapters/protocols/test_agui_adapter.py new file mode 100644 index 0000000..64dd185 --- /dev/null +++ b/tests/instrument/adapters/protocols/test_agui_adapter.py @@ -0,0 +1,191 @@ +"""Unit tests for the AG-UI (Agent-User Interaction) protocol adapter. + +AG-UI emits ``protocol.stream.event`` for every SSE event, plus +mapped ``agent.state.change`` and ``tool.call`` events for state and +tool events. All these event types pass the default +:class:`CaptureConfig` layer-gate, so the canonical +``_RecordingStratix`` pattern works. +""" + +from __future__ import annotations + +from typing import Any, Dict, List + +from layerlens.instrument.adapters._base.adapter import ( + AdapterInfo, + AdapterStatus, + ReplayableTrace, +) +from layerlens.instrument.adapters.protocols.agui import ADAPTER_CLASS, AGUIAdapter + + +class _RecordingStratix: + def __init__(self) -> None: + self.events: List[Dict[str, Any]] = [] + + def emit(self, *args: Any, **kwargs: Any) -> None: + if args: + payload = args[0] + self.events.append( + { + "event_type": getattr(payload, "event_type", None), + "payload": payload, + } + ) + + +def test_adapter_class_export() -> None: + assert ADAPTER_CLASS is AGUIAdapter + + +def test_adapter_class_constants() -> None: + assert AGUIAdapter.FRAMEWORK == "agui" + assert AGUIAdapter.PROTOCOL == "agui" + assert AGUIAdapter.PROTOCOL_VERSION == "1.0.0" + + +def test_lifecycle_transitions() -> None: + adapter = AGUIAdapter() + assert adapter.status == AdapterStatus.DISCONNECTED + adapter.connect() + assert adapter.status == AdapterStatus.HEALTHY + adapter.disconnect() + assert adapter.status == AdapterStatus.DISCONNECTED + + +def test_disconnect_clears_state() -> None: + adapter = AGUIAdapter(stratix=_RecordingStratix()) + adapter.connect() + adapter._state_cache["k"] = "v" + adapter._text_buffer.append("x") + adapter._stream_sequence = 5 + adapter.disconnect() + assert adapter._state_cache == {} + assert adapter._text_buffer == [] + assert adapter._stream_sequence == 0 + + +def test_get_adapter_info_shape() -> None: + adapter = AGUIAdapter() + info = adapter.get_adapter_info() + assert isinstance(info, AdapterInfo) + assert info.framework == "agui" + assert info.name == "AGUIAdapter" + + +def test_probe_health_default_no_endpoint() -> None: + adapter = AGUIAdapter() + adapter.connect() + h = adapter.probe_health() + assert h["reachable"] is True + assert "latency_ms" in h + assert "protocol_version" in h + + +def test_text_message_lifecycle_emits_stream_events_and_buffers() -> None: + stratix = _RecordingStratix() + adapter = AGUIAdapter(stratix=stratix) + adapter.connect() + + adapter.on_agui_event("TEXT_MESSAGE_START", {"id": "msg-1"}) + adapter.on_agui_event("TEXT_MESSAGE_CONTENT", {"content": "hello"}) + adapter.on_agui_event("TEXT_MESSAGE_CONTENT", {"content": " world"}) + adapter.on_agui_event("TEXT_MESSAGE_END", {}) + + types = [e["event_type"] for e in stratix.events] + # Each AG-UI event becomes a protocol.stream.event + assert types.count("protocol.stream.event") == 4 + + # The END event payload should be enriched with the buffered full_text + end = stratix.events[-1]["payload"] + assert "world" in end.payload_summary # summary contains full_text + + +def test_text_message_content_gated_by_l6b() -> None: + """When l6b_protocol_streams=False, high-frequency CONTENT events are + skipped (sequence still advances) — boundary START/END still emit. + """ + from layerlens.instrument.adapters._base.capture import CaptureConfig + + stratix = _RecordingStratix() + adapter = AGUIAdapter( + stratix=stratix, + capture_config=CaptureConfig(l6b_protocol_streams=False), + ) + adapter.connect() + + adapter.on_agui_event("TEXT_MESSAGE_START", {}) + adapter.on_agui_event("TEXT_MESSAGE_CONTENT", {"content": "x"}) + adapter.on_agui_event("TEXT_MESSAGE_END", {}) + + # CONTENT is dropped (returns early, no emit), START/END still emit but + # are also gated since stream events go through the same l6b layer. + # However, _emit_stream_event is also gated by the BaseAdapter + # _pre_emit_check on l6b; so all three are dropped. The sequence + # counter advances regardless to keep ordering consistent. + assert adapter._stream_sequence >= 1 + + +def test_state_snapshot_emits_state_change() -> None: + stratix = _RecordingStratix() + adapter = AGUIAdapter(stratix=stratix) + adapter.connect() + + adapter.on_agui_event("STATE_SNAPSHOT", {"counter": 1}) + types = [e["event_type"] for e in stratix.events] + assert "agent.state.change" in types + # state_cache updated by snapshot + assert adapter._state_cache.get("counter") == 1 + + +def test_tool_call_start_emits_tool_call_event() -> None: + stratix = _RecordingStratix() + adapter = AGUIAdapter(stratix=stratix) + adapter.connect() + + adapter.on_agui_event( + "TOOL_CALL_START", + {"tool_name": "search", "args": {"q": "weather"}}, + ) + tool_calls = [e for e in stratix.events if e["event_type"] == "tool.call"] + assert len(tool_calls) == 1 + assert tool_calls[0]["payload"].tool.name == "search" + assert tool_calls[0]["payload"].input == {"q": "weather"} + + +def test_tool_call_result_emits_tool_call_event() -> None: + stratix = _RecordingStratix() + adapter = AGUIAdapter(stratix=stratix) + adapter.connect() + + adapter.on_agui_event( + "TOOL_CALL_RESULT", + {"tool_name": "search", "result": {"answer": "sunny"}}, + ) + tool_calls = [e for e in stratix.events if e["event_type"] == "tool.call"] + assert len(tool_calls) == 1 + # output should be carried through + assert tool_calls[0]["payload"].output == {"answer": "sunny"} + + +def test_unknown_agui_event_emits_only_stream_event() -> None: + stratix = _RecordingStratix() + adapter = AGUIAdapter(stratix=stratix) + adapter.connect() + + adapter.on_agui_event("CUSTOM_NEW_EVENT", {"x": 1}) + types = [e["event_type"] for e in stratix.events] + # protocol.stream.event always emitted; no state.change / tool.call + assert "protocol.stream.event" in types + assert "agent.state.change" not in types + assert "tool.call" not in types + + +def test_serialize_for_replay_shape() -> None: + adapter = AGUIAdapter(stratix=_RecordingStratix()) + adapter.connect() + rt = adapter.serialize_for_replay() + assert isinstance(rt, ReplayableTrace) + assert rt.adapter_name == "AGUIAdapter" + assert rt.framework == "agui" + assert "capture_config" in rt.config diff --git a/tests/instrument/adapters/protocols/test_ap2_adapter.py b/tests/instrument/adapters/protocols/test_ap2_adapter.py new file mode 100644 index 0000000..a857fb5 --- /dev/null +++ b/tests/instrument/adapters/protocols/test_ap2_adapter.py @@ -0,0 +1,256 @@ +"""Unit tests for the AP2 (Agent Payments Protocol) adapter. + +The AP2 adapter emits ``commerce.payment.*`` events; those event types +are not in the default :class:`CaptureConfig` layer map, so the adapter's +:meth:`emit_event` would silently drop them. To assert event emission +we replace ``emit_event`` with a recorder that bypasses the gate. +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Callable + +import pytest + +from layerlens.instrument.adapters._base.adapter import ( + AdapterInfo, + AdapterStatus, + ReplayableTrace, +) +from layerlens.instrument.adapters.protocols.ap2 import AP2Adapter + + +def _make_recorder() -> tuple[List[Dict[str, Any]], Callable[..., None]]: + """Return (events_list, replacement-emit-callable).""" + events: List[Dict[str, Any]] = [] + + def _emit(payload: Any, privacy_level: Any = None) -> None: + events.append( + { + "event_type": getattr(payload, "event_type", None), + "payload": payload, + } + ) + + return events, _emit + + +def _make_adapter() -> tuple[AP2Adapter, List[Dict[str, Any]]]: + """Construct a connected AP2Adapter wired with an emit-recorder.""" + events, emit = _make_recorder() + adapter = AP2Adapter() + adapter.emit_event = emit # type: ignore[method-assign] + adapter.connect() + return adapter, events + + +def test_adapter_class_constants() -> None: + assert AP2Adapter.FRAMEWORK == "ap2" + assert AP2Adapter.PROTOCOL == "ap2" + assert AP2Adapter.PROTOCOL_VERSION == "0.1.0" + + +def test_lifecycle_transitions() -> None: + adapter = AP2Adapter() + assert adapter.status == AdapterStatus.DISCONNECTED + adapter.connect() + assert adapter.status == AdapterStatus.HEALTHY + assert adapter.is_connected is True + adapter.disconnect() + assert adapter.status == AdapterStatus.DISCONNECTED + assert adapter.is_connected is False + + +def test_disconnect_clears_state() -> None: + adapter, _ = _make_adapter() + adapter.configure_policy("org-1", max_single_tx=1.0) + adapter._spending_totals["org-1"] = 99.0 + assert adapter._policies != {} + adapter.disconnect() + assert adapter._policies == {} + assert adapter._spending_totals == {} + assert adapter._mandates == {} + + +def test_get_adapter_info_shape() -> None: + adapter, _ = _make_adapter() + info = adapter.get_adapter_info() + assert isinstance(info, AdapterInfo) + assert info.framework == "ap2" + assert info.name == "AP2Adapter" + + +def test_probe_health_shape() -> None: + adapter, _ = _make_adapter() + health = adapter.probe_health() + assert health["reachable"] is True + assert health["protocol_version"] == "0.1.0" + assert "active_mandates" in health + assert health["active_mandates"] == 0 + + +def test_intent_mandate_no_policy_no_violations() -> None: + adapter, events = _make_adapter() + violations = adapter.on_intent_mandate_created( + mandate_id="m1", + description="Buy supplies", + org_id="org-x", + max_amount=999_999.0, + ) + assert violations == [] + types = [e["event_type"] for e in events] + assert "commerce.payment.intent_created" in types + assert "commerce.payment.intent_validated" in types + + +def test_intent_mandate_amount_violation_emits_guardrail() -> None: + adapter, events = _make_adapter() + adapter.configure_policy("org-1", max_single_tx=100.0) + violations = adapter.on_intent_mandate_created( + mandate_id="m2", + description="Big buy", + org_id="org-1", + max_amount=500.0, + ) + assert any("exceeds" in v for v in violations) + types = [e["event_type"] for e in events] + assert "commerce.payment.guardrail_violation" in types + + +def test_intent_mandate_merchant_violation() -> None: + adapter, events = _make_adapter() + adapter.configure_policy("org-1", allowed_merchants=["FreshCo"]) + violations = adapter.on_intent_mandate_created( + mandate_id="m3", + description="Buy from random merchant", + org_id="org-1", + merchants=["RandoCorp"], + max_amount=10.0, + ) + assert any("not in allowed" in v for v in violations) + assert any(e["event_type"] == "commerce.payment.guardrail_violation" for e in events) + + +def test_intent_mandate_refundability_violation() -> None: + adapter, _ = _make_adapter() + adapter.configure_policy("org-1", require_refundability=True) + violations = adapter.on_intent_mandate_created( + mandate_id="m4", + description="Non-refundable", + org_id="org-1", + requires_refundability=False, + ) + assert any("Refundability" in v for v in violations) + + +def test_payment_mandate_signed_emits_event_and_tracks_spend() -> None: + adapter, events = _make_adapter() + adapter.on_payment_mandate_signed( + mandate_id="m1", + payment_details_id="pd-1", + total_amount=42.0, + merchant_agent="FreshCo", + org_id="org-1", + signature="raw-jwt", + ) + assert adapter._spending_totals["org-1"] == 42.0 + types = [e["event_type"] for e in events] + assert "commerce.payment.mandate_signed" in types + + # Signature is hashed (sha256), never stored raw + sig_event = next(e for e in events if e["event_type"] == "commerce.payment.mandate_signed") + assert "raw-jwt" not in str(sig_event["payload"].mandate.signature_hash) + + +def test_spending_threshold_event_emitted_when_exceeded() -> None: + adapter, events = _make_adapter() + adapter.configure_policy("org-1", daily_limit=100.0) + # Push spending total over threshold + adapter.on_payment_mandate_signed( + mandate_id="m1", + payment_details_id="pd-1", + total_amount=150.0, + merchant_agent="FreshCo", + org_id="org-1", + ) + types = [e["event_type"] for e in events] + assert "commerce.payment.threshold_exceeded" in types + + +def test_payment_receipt_success_clears_mandate() -> None: + adapter, _ = _make_adapter() + # Seed an intent mandate so we can verify removal + adapter.on_intent_mandate_created( + mandate_id="m1", + description="t", + org_id="org-1", + ) + assert "m1" in adapter._mandates + + adapter.on_payment_receipt_issued( + mandate_id="m1", + payment_id="PAY-1", + amount=42.0, + org_id="org-1", + status="success", + ) + assert "m1" not in adapter._mandates + + +def test_payment_receipt_failure_keeps_mandate() -> None: + adapter, _ = _make_adapter() + adapter.on_intent_mandate_created( + mandate_id="m1", + description="t", + org_id="org-1", + ) + adapter.on_payment_receipt_issued( + mandate_id="m1", + payment_id="PAY-2", + amount=42.0, + org_id="org-1", + status="failed", + ) + assert "m1" in adapter._mandates + + +def test_serialize_for_replay_shape() -> None: + adapter, _ = _make_adapter() + adapter.configure_policy("org-1", max_single_tx=10.0) + adapter.on_intent_mandate_created(mandate_id="m1", description="t", org_id="org-1") + rt = adapter.serialize_for_replay() + assert isinstance(rt, ReplayableTrace) + assert rt.adapter_name == "AP2Adapter" + assert rt.framework == "ap2" + assert "policies" in rt.config + assert rt.state_snapshots and "mandates" in rt.state_snapshots[0] + + +def test_commerce_events_bypass_capture_gate() -> None: + """Commerce events are cross-cutting and MUST bypass ``CaptureConfig``. + + Earlier in the port, ``commerce.*`` events fell off the layer-gate + map and were silently dropped (a CLAUDE.md rule-2 violation). The + fix is in ``_base/capture.py`` — ``ALWAYS_ENABLED_EVENT_TYPES`` now + includes the commerce family heads AND ``is_layer_enabled`` has a + prefix bypass for ``commerce.*`` so subtypes also pass. + + This test pins that fix in place: a fresh adapter (no recorder + override) emits a commerce event and the trace records it. + """ + adapter = AP2Adapter() + adapter.connect() + adapter.on_intent_mandate_created(mandate_id="m1", description="t", org_id="o1") + # _trace_events is populated by _post_emit_success — proves the + # event flowed through the gate, the null-Stratix call succeeded, + # and the post-success path ran. + assert adapter._trace_events, ( + "commerce events must not be dropped by the default CaptureConfig" + ) + assert adapter._trace_events[0]["event_type"].startswith("commerce.") + + +def test_invalid_keyword_argument_raises() -> None: + """Constructor rejects unknown kwargs — guards against typo regressions.""" + with pytest.raises(TypeError): + AP2Adapter(nonsense_arg=True) # type: ignore[call-arg] diff --git a/tests/instrument/adapters/protocols/test_certification.py b/tests/instrument/adapters/protocols/test_certification.py new file mode 100644 index 0000000..79facd1 --- /dev/null +++ b/tests/instrument/adapters/protocols/test_certification.py @@ -0,0 +1,238 @@ +"""Unit tests for the protocol-adapter GA certification suite. + +The suite (:class:`ProtocolCertificationSuite`) runs ~14+ structural +checks against an adapter class, including: BaseProtocolAdapter +inheritance, required class attributes, required methods, lifecycle +correctness, missing-framework graceful handling, and return-type +validation for ``get_adapter_info()`` / ``probe_health()`` / +``serialize_for_replay()``. + +These tests exercise the suite end-to-end against the three GA-certified +adapters and synthesised passing/failing adapter classes. +""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from layerlens.instrument.adapters._base.adapter import ( + AdapterInfo, + AdapterHealth, + AdapterStatus, + ReplayableTrace, +) +from layerlens.instrument.adapters.protocols.a2a import A2AAdapter +from layerlens.instrument.adapters.protocols.mcp import MCPExtensionsAdapter +from layerlens.instrument.adapters.protocols.agui import AGUIAdapter +from layerlens.instrument.adapters.protocols.base import BaseProtocolAdapter +from layerlens.instrument.adapters.protocols.certification import ( + CheckResult, + CertificationResult, + ProtocolCertificationSuite, +) + + +def test_check_result_dataclass_defaults() -> None: + cr = CheckResult(name="x", passed=True, message="ok") + assert cr.severity == "error" + assert cr.name == "x" + assert cr.passed is True + + +def test_certification_result_summary_format() -> None: + res = CertificationResult( + passed=True, + adapter_name="X", + protocol_version="1.0.0", + checks=[ + {"name": "a", "passed": True, "message": "", "severity": "error"}, + {"name": "b", "passed": False, "message": "", "severity": "warning"}, + ], + ) + summary = res.summary() + assert "X" in summary + assert "GA certification" in summary + # 1/2 passed (b is failing) + assert "1/2" in summary + + +def test_certify_a2a_passes() -> None: + suite = ProtocolCertificationSuite() + result = suite.certify(A2AAdapter) + assert isinstance(result, CertificationResult) + assert result.adapter_name == "A2AAdapter" + assert result.protocol_version == "0.2.1" + assert result.passed, [c for c in result.checks if not c["passed"]] + + +def test_certify_agui_passes() -> None: + suite = ProtocolCertificationSuite() + result = suite.certify(AGUIAdapter) + assert result.passed, [c for c in result.checks if not c["passed"]] + assert result.adapter_name == "AGUIAdapter" + + +def test_certify_mcp_passes() -> None: + suite = ProtocolCertificationSuite() + result = suite.certify(MCPExtensionsAdapter) + assert result.passed, [c for c in result.checks if not c["passed"]] + assert result.adapter_name == "MCPExtensionsAdapter" + + +def test_certify_all_returns_three_results() -> None: + suite = ProtocolCertificationSuite() + results = suite.certify_all() + assert len(results) == 3 + names = {r.adapter_name for r in results} + assert names == {"A2AAdapter", "AGUIAdapter", "MCPExtensionsAdapter"} + assert all(r.passed for r in results), [ + (r.adapter_name, c) for r in results for c in r.checks if not c["passed"] + ] + + +def test_certify_results_contain_required_check_names() -> None: + suite = ProtocolCertificationSuite() + result = suite.certify(A2AAdapter) + check_names = {c["name"] for c in result.checks} + # Spot-check that the structural anchors are present + assert "inherits_BaseProtocolAdapter" in check_names + assert "inherits_BaseAdapter" in check_names + assert "class_attr_FRAMEWORK" in check_names + assert "class_attr_PROTOCOL_VERSION" in check_names + assert "implements_connect" in check_names + assert "implements_disconnect" in check_names + assert "implements_probe_health" in check_names + assert "instantiation" in check_names + assert "initial_state_disconnected" in check_names + assert "connect_succeeds" in check_names + assert "disconnect_succeeds" in check_names + assert "get_adapter_info_returns_AdapterInfo" in check_names + assert "probe_health_returns_valid_dict" in check_names + assert "serialize_for_replay_returns_ReplayableTrace" in check_names + + +def test_certify_check_count_meets_minimum() -> None: + """The suite has at least 14 checks per adapter (5 base + 1 protocol + methods + 4 class attributes + 4 lifecycle + 1 error handling + + 3 return-type checks = 18 nominal; tolerate growth).""" + suite = ProtocolCertificationSuite() + result = suite.certify(A2AAdapter) + assert len(result.checks) >= 14 + + +def test_certify_failing_adapter_reports_failure() -> None: + """A class missing required class attributes / not extending + BaseProtocolAdapter must FAIL certification.""" + + class NotAnAdapter: + pass + + suite = ProtocolCertificationSuite() + result = suite.certify(NotAnAdapter) + assert result.passed is False + failed = [c for c in result.checks if not c["passed"] and c["severity"] == "error"] + assert len(failed) > 0 + failed_names = {c["name"] for c in failed} + assert "inherits_BaseProtocolAdapter" in failed_names + + +def test_certify_subclass_with_empty_class_attributes_fails() -> None: + """A subclass that fails to override FRAMEWORK / PROTOCOL_VERSION + is rejected because those values must be non-empty strings.""" + + class _Incomplete(BaseProtocolAdapter): + # Inherits empty-string defaults from BaseProtocolAdapter — a real + # adapter MUST override these. + def connect(self) -> None: + self._connected = True + self._status = AdapterStatus.HEALTHY + + def disconnect(self) -> None: + self._connected = False + self._status = AdapterStatus.DISCONNECTED + + def health_check(self) -> AdapterHealth: + return AdapterHealth( + status=self._status, + framework_name=self.FRAMEWORK, + adapter_version=self.VERSION, + ) + + def get_adapter_info(self) -> AdapterInfo: + return AdapterInfo(name="x", version="0.0.0", framework=self.FRAMEWORK) + + def serialize_for_replay(self) -> ReplayableTrace: + return ReplayableTrace( + adapter_name="x", framework=self.FRAMEWORK, trace_id="t" + ) + + def probe_health(self, endpoint: str | None = None) -> dict[str, Any]: + return { + "reachable": self._connected, + "latency_ms": 0.0, + "protocol_version": None, + } + + suite = ProtocolCertificationSuite() + result = suite.certify(_Incomplete) + failed_names = {c["name"] for c in result.checks if not c["passed"]} + # FRAMEWORK / PROTOCOL / PROTOCOL_VERSION / VERSION are all empty strings + # by default so all 4 class_attr checks must fail. + assert "class_attr_FRAMEWORK" in failed_names + assert "class_attr_PROTOCOL" in failed_names + assert "class_attr_PROTOCOL_VERSION" in failed_names + assert result.passed is False + + +def test_certify_handles_constructor_failures() -> None: + """An adapter class whose constructor raises should cause an + instantiation-failure check rather than crashing the suite.""" + + class _Crashy(BaseProtocolAdapter): + FRAMEWORK = "crashy" + PROTOCOL = "crashy" + PROTOCOL_VERSION = "1.0.0" + VERSION = "0.0.0" + + def __init__(self, **kwargs: Any) -> None: + raise RuntimeError("boom in __init__") + + def connect(self) -> None: # pragma: no cover - never reached + pass + + def disconnect(self) -> None: # pragma: no cover - never reached + pass + + def health_check(self) -> AdapterHealth: # pragma: no cover + return AdapterHealth( + status=AdapterStatus.HEALTHY, + framework_name=self.FRAMEWORK, + adapter_version=self.VERSION, + ) + + def get_adapter_info(self) -> AdapterInfo: # pragma: no cover + return AdapterInfo(name="x", version="0.0.0", framework=self.FRAMEWORK) + + def serialize_for_replay(self) -> ReplayableTrace: # pragma: no cover + return ReplayableTrace(adapter_name="x", framework=self.FRAMEWORK, trace_id="t") + + def probe_health(self, endpoint: str | None = None) -> dict[str, Any]: # pragma: no cover + return {"reachable": False, "latency_ms": 0.0, "protocol_version": None} + + suite = ProtocolCertificationSuite() + # Must NOT raise — instantiation-failure is captured as a CheckResult + result = suite.certify(_Crashy) + assert result.passed is False + inst_check = next(c for c in result.checks if c["name"] == "instantiation") + assert inst_check["passed"] is False + assert "boom in __init__" in inst_check["message"] + + +@pytest.mark.parametrize("cls", [A2AAdapter, AGUIAdapter, MCPExtensionsAdapter]) +def test_each_ga_adapter_has_no_failing_checks(cls: type) -> None: + suite = ProtocolCertificationSuite() + result = suite.certify(cls) + failures = [c for c in result.checks if not c["passed"] and c["severity"] == "error"] + assert failures == [], f"{cls.__name__} failed: {failures}" diff --git a/tests/instrument/adapters/protocols/test_mcp_adapter.py b/tests/instrument/adapters/protocols/test_mcp_adapter.py new file mode 100644 index 0000000..aebb7f8 --- /dev/null +++ b/tests/instrument/adapters/protocols/test_mcp_adapter.py @@ -0,0 +1,261 @@ +"""Unit tests for the MCP Extensions adapter. + +MCP emits ``tool.call`` (l5a), ``protocol.tool.structured_output`` (l5a), +``protocol.elicitation.request`` / ``.response`` (l5a), +``protocol.async_task`` (always-enabled), ``protocol.mcp_app.invocation`` +(l5a), and ``environment.config`` (l4a). All of these pass the default +:class:`CaptureConfig` layer-gate, so the canonical +``_RecordingStratix`` pattern works. +""" + +from __future__ import annotations + +from typing import Any, Dict, List + +import pytest + +from layerlens.instrument.adapters._base.adapter import ( + AdapterInfo, + AdapterStatus, + ReplayableTrace, +) +from layerlens.instrument.adapters.protocols.mcp import ( + ADAPTER_CLASS, + MCPExtensionsAdapter, +) + + +class _RecordingStratix: + def __init__(self) -> None: + self.events: List[Dict[str, Any]] = [] + + def emit(self, *args: Any, **kwargs: Any) -> None: + if args: + payload = args[0] + self.events.append( + { + "event_type": getattr(payload, "event_type", None), + "payload": payload, + } + ) + + +def test_adapter_class_export() -> None: + assert ADAPTER_CLASS is MCPExtensionsAdapter + + +def test_adapter_class_constants() -> None: + assert MCPExtensionsAdapter.FRAMEWORK == "mcp_extensions" + assert MCPExtensionsAdapter.PROTOCOL == "mcp" + assert MCPExtensionsAdapter.PROTOCOL_VERSION == "1.0.0" + + +def test_lifecycle_transitions() -> None: + adapter = MCPExtensionsAdapter() + assert adapter.status == AdapterStatus.DISCONNECTED + adapter.connect() + assert adapter.status == AdapterStatus.HEALTHY + adapter.disconnect() + assert adapter.status == AdapterStatus.DISCONNECTED + + +def test_disconnect_clears_state() -> None: + adapter = MCPExtensionsAdapter(stratix=_RecordingStratix()) + adapter.connect() + adapter.on_async_task("task-1", status="created") + assert "task-1" in adapter._async_tasks + adapter.disconnect() + assert adapter._async_tasks == {} + assert adapter._originals == {} + + +def test_get_adapter_info_shape() -> None: + adapter = MCPExtensionsAdapter() + info = adapter.get_adapter_info() + assert isinstance(info, AdapterInfo) + assert info.framework == "mcp_extensions" + assert info.name == "MCPExtensionsAdapter" + + +def test_probe_health_default_no_endpoint() -> None: + adapter = MCPExtensionsAdapter() + adapter.connect() + h = adapter.probe_health() + assert h["reachable"] is True + assert "latency_ms" in h + assert "protocol_version" in h + + +def test_on_tool_call_emits_tool_call_event() -> None: + stratix = _RecordingStratix() + adapter = MCPExtensionsAdapter(stratix=stratix) + adapter.connect() + + adapter.on_tool_call( + tool_name="search", + input_data={"q": "weather"}, + output_data={"answer": "sunny"}, + latency_ms=12.3, + ) + types = [e["event_type"] for e in stratix.events] + assert "tool.call" in types + payload = next(e for e in stratix.events if e["event_type"] == "tool.call")["payload"] + assert payload.tool.name == "search" + assert payload.input == {"q": "weather"} + assert payload.output == {"answer": "sunny"} + assert payload.latency_ms == pytest.approx(12.3) + + +def test_on_tool_call_with_error_records_error() -> None: + stratix = _RecordingStratix() + adapter = MCPExtensionsAdapter(stratix=stratix) + adapter.connect() + + adapter.on_tool_call( + tool_name="broken", + input_data={"x": 1}, + error="connection refused", + ) + payload = next(e for e in stratix.events if e["event_type"] == "tool.call")["payload"] + assert payload.error == "connection refused" + + +def test_on_structured_output_emits_event() -> None: + stratix = _RecordingStratix() + adapter = MCPExtensionsAdapter(stratix=stratix) + adapter.connect() + + adapter.on_structured_output( + tool_name="get_user", + output={"id": 1, "name": "alice"}, + schema={"$id": "schema:user/v1", "type": "object"}, + validation_passed=True, + ) + types = [e["event_type"] for e in stratix.events] + assert "protocol.tool.structured_output" in types + payload = next( + e for e in stratix.events if e["event_type"] == "protocol.tool.structured_output" + )["payload"] + assert payload.tool_name == "get_user" + assert payload.schema_id == "schema:user/v1" + assert payload.validation_passed is True + + +def test_on_structured_output_validation_failure() -> None: + stratix = _RecordingStratix() + adapter = MCPExtensionsAdapter(stratix=stratix) + adapter.connect() + + adapter.on_structured_output( + tool_name="get_user", + output={"id": "not-int"}, + schema={"type": "object"}, + validation_passed=False, + validation_errors=["id: must be integer"], + ) + payload = next( + e for e in stratix.events if e["event_type"] == "protocol.tool.structured_output" + )["payload"] + assert payload.validation_passed is False + assert payload.validation_errors == ["id: must be integer"] + + +def test_elicitation_request_response_lifecycle() -> None: + stratix = _RecordingStratix() + adapter = MCPExtensionsAdapter(stratix=stratix) + adapter.connect() + + adapter.on_elicitation_request( + elicitation_id="el-1", + server_name="auth-server", + schema={"$id": "schema:cred/v1", "type": "object"}, + title="Credentials needed", + ) + adapter.on_elicitation_response( + elicitation_id="el-1", + action="accepted", + response={"username": "alice"}, + latency_ms=420.0, + ) + + types = [e["event_type"] for e in stratix.events] + assert "protocol.elicitation.request" in types + assert "protocol.elicitation.response" in types + + response = next( + e for e in stratix.events if e["event_type"] == "protocol.elicitation.response" + )["payload"] + assert response.action == "accepted" + assert response.latency_ms == pytest.approx(420.0) + + +def test_async_task_lifecycle_tracks_elapsed_time() -> None: + stratix = _RecordingStratix() + adapter = MCPExtensionsAdapter(stratix=stratix) + adapter.connect() + + adapter.on_async_task("task-1", status="created") + assert "task-1" in adapter._async_tasks + + adapter.on_async_task("task-1", status="completed") + # Removed on terminal state + assert "task-1" not in adapter._async_tasks + + types = [e["event_type"] for e in stratix.events] + # Both submitted/completed are protocol.async_task (always-enabled) + assert types.count("protocol.async_task") == 2 + completed = stratix.events[-1]["payload"] + assert completed.status == "completed" + assert completed.elapsed_ms is not None and completed.elapsed_ms >= 0 + + +def test_async_task_failed_status_clears_tracker() -> None: + stratix = _RecordingStratix() + adapter = MCPExtensionsAdapter(stratix=stratix) + adapter.connect() + adapter.on_async_task("t", status="created") + adapter.on_async_task("t", status="failed") + assert "t" not in adapter._async_tasks + + +def test_mcp_app_invocation_emits_event() -> None: + stratix = _RecordingStratix() + adapter = MCPExtensionsAdapter(stratix=stratix) + adapter.connect() + + adapter.on_mcp_app_invocation( + app_id="app-1", + component_type="form", + interaction_result="submitted", + parameters={"field": "value"}, + result={"ok": True}, + ) + types = [e["event_type"] for e in stratix.events] + assert "protocol.mcp_app.invocation" in types + + +def test_auth_event_emits_environment_config() -> None: + stratix = _RecordingStratix() + adapter = MCPExtensionsAdapter(stratix=stratix) + adapter.connect() + + adapter.on_auth_event( + auth_type="oauth2.token_refresh", + success=True, + details={"scope": "read"}, + ) + types = [e["event_type"] for e in stratix.events] + assert "environment.config" in types + payload = next(e for e in stratix.events if e["event_type"] == "environment.config")["payload"] + assert payload.environment.attributes["auth_event"] == "oauth2.token_refresh" + assert payload.environment.attributes["auth_success"] is True + + +def test_serialize_for_replay_shape() -> None: + adapter = MCPExtensionsAdapter(stratix=_RecordingStratix()) + adapter.connect() + rt = adapter.serialize_for_replay() + assert isinstance(rt, ReplayableTrace) + assert rt.adapter_name == "MCPExtensionsAdapter" + assert rt.framework == "mcp_extensions" + assert "capture_config" in rt.config diff --git a/tests/instrument/adapters/protocols/test_protocols_smoke.py b/tests/instrument/adapters/protocols/test_protocols_smoke.py new file mode 100644 index 0000000..e036880 --- /dev/null +++ b/tests/instrument/adapters/protocols/test_protocols_smoke.py @@ -0,0 +1,90 @@ +"""Smoke tests for the 6 ported protocol adapters + certification suite. + +Each protocol adapter imports cleanly and exposes a class that extends +:class:`BaseProtocolAdapter` (the protocol-specific ABC ported from +``ateam/stratix/sdk/python/adapters/protocols/base.py``). Deeper tests +covering wire conformance, certification check execution, and per-adapter +event emission are PR-scoped and follow the established LLM/framework +test pattern. +""" + +from __future__ import annotations + +from typing import Type + +import pytest + +from layerlens.instrument.adapters.protocols.a2a import A2AAdapter +from layerlens.instrument.adapters.protocols.ap2 import AP2Adapter +from layerlens.instrument.adapters.protocols.mcp import MCPExtensionsAdapter +from layerlens.instrument.adapters.protocols.ucp import UCPAdapter +from layerlens.instrument.adapters.protocols.a2ui import A2UIAdapter +from layerlens.instrument.adapters.protocols.agui import AGUIAdapter + +_FLAT_ADAPTERS: list[tuple[str, Type[object]]] = [ + ("ap2", AP2Adapter), + ("a2ui", A2UIAdapter), + ("ucp", UCPAdapter), +] + +_PACKAGE_ADAPTERS: list[tuple[str, Type[object]]] = [ + ("a2a", A2AAdapter), + ("agui", AGUIAdapter), + ("mcp", MCPExtensionsAdapter), +] + + +@pytest.mark.parametrize( + "name,cls", + _FLAT_ADAPTERS + _PACKAGE_ADAPTERS, + ids=lambda v: v if isinstance(v, str) else "", +) +def test_protocol_adapter_imports(name: str, cls: Type[object]) -> None: + """Each protocol adapter class is importable and is a class.""" + assert cls.__name__ + assert isinstance(cls, type) + + +@pytest.mark.parametrize( + "name,cls", + _PACKAGE_ADAPTERS, + ids=lambda v: v if isinstance(v, str) else "", +) +def test_package_adapter_class_export(name: str, cls: Type[object]) -> None: + """Subdirectory protocols export ``ADAPTER_CLASS`` for registry.""" + import importlib + + module = importlib.import_module(f"layerlens.instrument.adapters.protocols.{name}") + assert getattr(module, "ADAPTER_CLASS", None) is cls + + +def test_certification_suite_imports() -> None: + """The certification module exposes ``ProtocolCertificationSuite``.""" + from layerlens.instrument.adapters.protocols.certification import ( + CheckResult, + CertificationResult, + ProtocolCertificationSuite, + ) + + assert ProtocolCertificationSuite is not None + assert CheckResult is not None + assert CertificationResult is not None + + +def test_base_protocol_adapter_importable() -> None: + from layerlens.instrument.adapters.protocols.base import BaseProtocolAdapter + + assert BaseProtocolAdapter is not None + + +def test_protocol_support_modules_importable() -> None: + """Shared protocol support modules port cleanly.""" + from layerlens.instrument.adapters.protocols import ( + health, + exceptions, + connection_pool, + ) + + assert health is not None + assert exceptions is not None + assert connection_pool is not None diff --git a/tests/instrument/adapters/protocols/test_ucp_adapter.py b/tests/instrument/adapters/protocols/test_ucp_adapter.py new file mode 100644 index 0000000..ae3776b --- /dev/null +++ b/tests/instrument/adapters/protocols/test_ucp_adapter.py @@ -0,0 +1,188 @@ +"""Unit tests for the UCP (Universal Commerce Protocol) adapter. + +UCP emits ``commerce.supplier.*`` / ``commerce.catalog.*`` / +``commerce.checkout.*`` / ``commerce.order.*`` events. As with the +other commerce adapters, these event types are not in the default +:class:`CaptureConfig` layer map, so we replace ``emit_event`` with a +recorder to assert emission. +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Callable + +from layerlens.instrument.adapters._base.adapter import ( + AdapterInfo, + AdapterStatus, + ReplayableTrace, +) +from layerlens.instrument.adapters.protocols.ucp import UCPAdapter + + +def _make_recorder() -> tuple[List[Dict[str, Any]], Callable[..., None]]: + events: List[Dict[str, Any]] = [] + + def _emit(payload: Any, privacy_level: Any = None) -> None: + events.append( + { + "event_type": getattr(payload, "event_type", None), + "payload": payload, + } + ) + + return events, _emit + + +def _make_adapter() -> tuple[UCPAdapter, List[Dict[str, Any]]]: + events, emit = _make_recorder() + adapter = UCPAdapter() + adapter.emit_event = emit # type: ignore[method-assign] + adapter.connect() + return adapter, events + + +def test_adapter_class_constants() -> None: + assert UCPAdapter.FRAMEWORK == "ucp" + assert UCPAdapter.PROTOCOL == "ucp" + assert UCPAdapter.PROTOCOL_VERSION == "1.0.0" + + +def test_lifecycle_transitions() -> None: + adapter = UCPAdapter() + assert adapter.status == AdapterStatus.DISCONNECTED + adapter.connect() + assert adapter.status == AdapterStatus.HEALTHY + adapter.disconnect() + assert adapter.status == AdapterStatus.DISCONNECTED + + +def test_get_adapter_info_shape() -> None: + adapter, _ = _make_adapter() + info = adapter.get_adapter_info() + assert isinstance(info, AdapterInfo) + assert info.framework == "ucp" + assert info.name == "UCPAdapter" + + +def test_probe_health_shape() -> None: + adapter, _ = _make_adapter() + health = adapter.probe_health() + assert health["reachable"] is True + assert health["protocol_version"] == "1.0.0" + + +def test_supplier_discovered_emits_event_and_records_state() -> None: + adapter, events = _make_adapter() + adapter.on_supplier_discovered( + supplier_id="sup_1", + name="Acme", + profile_url="https://acme.example.com/.well-known/ucp.json", + org_id="org_1", + capabilities=["search", "checkout"], + discovery_method="well_known", + ) + assert "sup_1" in adapter._suppliers + assert adapter._suppliers["sup_1"]["discovery_method"] == "well_known" + + types = [e["event_type"] for e in events] + assert "commerce.supplier.discovered" in types + + +def test_catalog_browsed_emits_event() -> None: + adapter, events = _make_adapter() + adapter.on_catalog_browsed( + supplier_id="sup_1", + org_id="org_1", + items_viewed=10, + items_selected=2, + ) + types = [e["event_type"] for e in events] + assert "commerce.catalog.browsed" in types + payload = next(e for e in events if e["event_type"] == "commerce.catalog.browsed")["payload"] + assert payload.items_viewed == 10 + assert payload.items_selected == 2 + + +def test_checkout_lifecycle_tracks_session_duration() -> None: + adapter, events = _make_adapter() + adapter.on_checkout_created( + checkout_session_id="cs_1", + supplier_id="sup_1", + line_items=[{"item_id": "sku_1", "quantity": 2, "unit_price": 49.99}], + total_amount=99.98, + org_id="org_1", + ) + assert "cs_1" in adapter._checkout_sessions + assert "cs_1" in adapter._session_start_times + + adapter.on_checkout_completed("cs_1", org_id="org_1", order_id="ord_1") + # Session state cleared on completion + assert "cs_1" not in adapter._checkout_sessions + assert "cs_1" not in adapter._session_start_times + + types = [e["event_type"] for e in events] + assert "commerce.checkout.created" in types + assert "commerce.checkout.completed" in types + + +def test_checkout_completed_without_create_does_not_crash() -> None: + """Error path: completing a session that was never opened is non-fatal.""" + adapter, events = _make_adapter() + adapter.on_checkout_completed("never_started", org_id="org_1", status="cancelled") + # Still emits the completion event so the audit trail is honest + assert any(e["event_type"] == "commerce.checkout.completed" for e in events) + + +def test_order_refunded_emits_event() -> None: + adapter, events = _make_adapter() + adapter.on_order_refunded( + order_id="ord_1", + refund_amount=12.34, + org_id="org_1", + currency="USD", + reason="defective", + ) + types = [e["event_type"] for e in events] + assert "commerce.order.refunded" in types + payload = next(e for e in events if e["event_type"] == "commerce.order.refunded")["payload"] + assert payload.refund_amount == 12.34 + assert payload.reason == "defective" + + +def test_disconnect_clears_state() -> None: + adapter, _ = _make_adapter() + adapter.on_supplier_discovered( + supplier_id="s", name="n", profile_url="u", org_id="o" + ) + assert adapter._suppliers != {} + adapter.disconnect() + assert adapter._suppliers == {} + assert adapter._checkout_sessions == {} + assert adapter._session_start_times == {} + + +def test_serialize_for_replay_shape() -> None: + adapter, _ = _make_adapter() + adapter.on_supplier_discovered(supplier_id="s", name="n", profile_url="u", org_id="o") + rt = adapter.serialize_for_replay() + assert isinstance(rt, ReplayableTrace) + assert rt.adapter_name == "UCPAdapter" + assert rt.framework == "ucp" + assert "suppliers" in rt.config + + +def test_line_items_parsed_into_typed_payload() -> None: + adapter, events = _make_adapter() + adapter.on_checkout_created( + checkout_session_id="cs", + supplier_id="s", + line_items=[ + {"item_id": "a", "quantity": 1, "unit_price": 10.0}, + {"item_id": "b", "quantity": 2, "unit_price": 5.0, "name": "Widget"}, + ], + total_amount=20.0, + org_id="o", + ) + payload = next(e for e in events if e["event_type"] == "commerce.checkout.created")["payload"] + assert len(payload.line_items) == 2 + assert payload.line_items[1].name == "Widget" From 3b8a08599f830c38821876151dd2aa431f23df4d Mon Sep 17 00:00:00 2001 From: mmercuri Date: Sat, 25 Apr 2026 19:39:12 -0700 Subject: [PATCH 2/2] instrument: ruff I001 import-sort fixes for protocols adapters Auto-applied by 'ruff check --fix'. No behavior change. --- .../instrument/adapters/protocols/_commerce.py | 3 ++- .../instrument/adapters/protocols/ap2.py | 15 +++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/layerlens/instrument/adapters/protocols/_commerce.py b/src/layerlens/instrument/adapters/protocols/_commerce.py index f859cc7..6f4a046 100644 --- a/src/layerlens/instrument/adapters/protocols/_commerce.py +++ b/src/layerlens/instrument/adapters/protocols/_commerce.py @@ -8,7 +8,8 @@ from __future__ import annotations from typing import Optional -from pydantic import BaseModel, Field + +from pydantic import Field, BaseModel # --------------------------------------------------------------------------- # Sub-models diff --git a/src/layerlens/instrument/adapters/protocols/ap2.py b/src/layerlens/instrument/adapters/protocols/ap2.py index 1ab527a..ba5e99f 100644 --- a/src/layerlens/instrument/adapters/protocols/ap2.py +++ b/src/layerlens/instrument/adapters/protocols/ap2.py @@ -20,6 +20,13 @@ UTC = timezone.utc # Python 3.11+ has datetime.UTC; alias for 3.9/3.10 compat. +from layerlens.instrument.adapters._base.adapter import ( + AdapterInfo, + AdapterStatus, + ReplayableTrace, + AdapterCapability, +) +from layerlens.instrument.adapters.protocols.base import BaseProtocolAdapter from layerlens.instrument.adapters.protocols._commerce import ( IntentMandateInfo, PaymentMandateInfo, @@ -32,14 +39,6 @@ IntentMandateValidatedEvent, ) -from layerlens.instrument.adapters._base.adapter import ( - AdapterInfo, - AdapterStatus, - ReplayableTrace, - AdapterCapability, -) -from layerlens.instrument.adapters.protocols.base import BaseProtocolAdapter - logger = logging.getLogger(__name__)