webagent-protocol 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
wap/client/session.py ADDED
@@ -0,0 +1,621 @@
1
+ """Async WAP consumer: signed requests, proof-of-work, SSE streaming and multi-turn sessions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import inspect
7
+ import json
8
+ import time
9
+ import uuid
10
+ from collections import OrderedDict
11
+ from collections.abc import AsyncIterator, Awaitable, Callable
12
+ from dataclasses import dataclass, field
13
+ from typing import Any, Literal
14
+
15
+ import httpx
16
+ from httpx_sse import EventSource
17
+ from jsonschema import Draft202012Validator
18
+ from jsonschema.exceptions import SchemaError
19
+ from pydantic import ValidationError
20
+
21
+ from .. import __version__
22
+ from ..spec.conversation import (
23
+ ConversationGuard,
24
+ ConversationLimitError,
25
+ ConversationPolicy,
26
+ reply_fingerprint,
27
+ request_fingerprint,
28
+ )
29
+ from ..spec.crypto import Signer, verify_bytes, verify_model
30
+ from ..spec.models import (
31
+ HEADER_SIGNATURE,
32
+ HEADER_VERSION,
33
+ WAP_VERSION,
34
+ AgentManifest,
35
+ AgentMessage,
36
+ Capability,
37
+ Challenge,
38
+ Role,
39
+ StreamEventType,
40
+ normalize_authority,
41
+ )
42
+ from ..spec.pow import solve_async
43
+ from .exceptions import (
44
+ AuthRequired,
45
+ CapabilityNotFound,
46
+ ConfirmationDeclined,
47
+ ConversationLimitReached,
48
+ ConversationStopped,
49
+ EffectsNotPermitted,
50
+ LoopDetected,
51
+ ProofOfWorkFailed,
52
+ ProtocolError,
53
+ RateLimited,
54
+ SchemaValidationError,
55
+ VerificationFailed,
56
+ )
57
+ from .resolver import ManifestResolver, parse_target
58
+
59
+
60
+ @dataclass
61
+ class StreamEvent:
62
+ """One incremental event from a business agent's reply."""
63
+
64
+ type: StreamEventType
65
+ text: str | None = None
66
+ data: dict[str, Any] | None = None
67
+ message: AgentMessage | None = None
68
+ request: AgentMessage | None = field(default=None, repr=False)
69
+ """For ``MESSAGE`` events: the signed request this reply answers."""
70
+
71
+
72
+ @dataclass(frozen=True)
73
+ class ConfirmationRequest:
74
+ """Passed to a client's ``confirm`` hook before an action with side effects."""
75
+
76
+ domain: str
77
+ business_name: str
78
+ capability: Capability
79
+ payload: dict[str, Any]
80
+
81
+ @property
82
+ def effects(self) -> str:
83
+ return self.capability.effects
84
+
85
+ def summary(self) -> str:
86
+ args = ", ".join(f"{k}={v!r}" for k, v in self.payload.items())
87
+ return f"{self.business_name} ({self.domain}): {self.capability.name}({args}) [{self.effects}]"
88
+
89
+
90
+ ConfirmHook = Callable[[ConfirmationRequest], bool | Awaitable[bool]]
91
+ _TRANSIENT_STATUS = frozenset({502, 503, 504})
92
+
93
+
94
+ @dataclass
95
+ class InteractionResult:
96
+ """The fully collected, signature-verified outcome of one request/response turn."""
97
+
98
+ domain: str
99
+ session_id: str
100
+ text: str
101
+ structured_data: dict[str, Any] | None
102
+ message: AgentMessage
103
+ request: AgentMessage
104
+ verified: bool
105
+ pow_solved: bool
106
+ elapsed_seconds: float
107
+ events: list[StreamEvent] = field(default_factory=list, repr=False)
108
+
109
+ def to_dict(self) -> dict[str, Any]:
110
+ return {
111
+ "domain": self.domain,
112
+ "session_id": self.session_id,
113
+ "capability_id": self.message.capability_id,
114
+ "text": self.text,
115
+ "structured_data": self.structured_data,
116
+ "verified": self.verified,
117
+ "pow_solved": self.pow_solved,
118
+ "message_id": self.message.message_id,
119
+ "signature": self.message.signature,
120
+ "elapsed_seconds": round(self.elapsed_seconds, 4),
121
+ }
122
+
123
+
124
+ def _has_h2() -> bool:
125
+ try:
126
+ import h2 # noqa: F401
127
+ except ImportError:
128
+ return False
129
+ return True
130
+
131
+
132
+ def validate_payload(manifest: AgentManifest, capability_id: str, payload: dict[str, Any]) -> None:
133
+ """Validate ``payload`` against the capability's JSON Schema before sending it."""
134
+ capability = manifest.get_capability(capability_id)
135
+ if capability is None:
136
+ raise CapabilityNotFound(manifest.domain, capability_id, [c.id for c in manifest.capabilities])
137
+ try:
138
+ validator = Draft202012Validator(capability.input_schema)
139
+ except SchemaError as exc:
140
+ raise VerificationFailed(
141
+ manifest.domain, f"capability {capability_id!r} declares an invalid schema: {exc}"
142
+ ) from exc
143
+ errors = sorted(validator.iter_errors(payload), key=lambda e: list(e.absolute_path))
144
+ if errors:
145
+ rendered = [f"{'/'.join(map(str, e.absolute_path)) or '<root>'}: {e.message}" for e in errors]
146
+ raise SchemaValidationError(capability_id, rendered)
147
+
148
+
149
+ class WAPClient:
150
+ """High-level async client for talking to WAP business agents.
151
+
152
+ ``WAPClient`` owns one ``httpx.AsyncClient`` (HTTP/2 when ``h2`` is
153
+ installed), a manifest cache and an Ed25519 *agent key* that signs every
154
+ outgoing message. Use it as an async context manager.
155
+ """
156
+
157
+ def __init__(
158
+ self,
159
+ *,
160
+ agent_key: Signer | str | None = None,
161
+ timeout: float = 30.0,
162
+ http2: bool = True,
163
+ transport: httpx.AsyncBaseTransport | None = None,
164
+ cache_ttl: float = 300.0,
165
+ pinned_keys: dict[str, str] | None = None,
166
+ auth_tokens: dict[str, str] | None = None,
167
+ allow_insecure: bool = False,
168
+ verify_dns: bool = False,
169
+ block_private_networks: bool = False,
170
+ allow_loopback: bool = False,
171
+ trust_on_first_use: bool = False,
172
+ dns_key_policy: str = "off",
173
+ txt_resolver: Any = None,
174
+ validate_payloads: bool = True,
175
+ max_pow_attempts: int = 3,
176
+ user_agent: str | None = None,
177
+ conversation_policy: ConversationPolicy | Literal[False] | None = None,
178
+ confirm: ConfirmHook | None = None,
179
+ confirm_effects: frozenset[str] | set[str] = frozenset({"write", "financial"}),
180
+ max_retries: int = 2,
181
+ retry_backoff: float = 0.25,
182
+ ) -> None:
183
+ self.signer = agent_key if isinstance(agent_key, Signer) else Signer(agent_key)
184
+ self.http = httpx.AsyncClient(
185
+ http2=http2 and transport is None and _has_h2(),
186
+ timeout=httpx.Timeout(timeout, connect=min(timeout, 10.0)),
187
+ transport=transport,
188
+ follow_redirects=False,
189
+ headers={
190
+ "User-Agent": user_agent or f"wap-python/{__version__}",
191
+ HEADER_VERSION: WAP_VERSION,
192
+ },
193
+ )
194
+ self.resolver = ManifestResolver(
195
+ self.http,
196
+ cache_ttl=cache_ttl,
197
+ pinned_keys=pinned_keys,
198
+ allow_insecure=allow_insecure,
199
+ verify_dns=verify_dns,
200
+ block_private_networks=block_private_networks,
201
+ allow_loopback=allow_loopback,
202
+ trust_on_first_use=trust_on_first_use,
203
+ dns_key_policy=dns_key_policy, # type: ignore[arg-type]
204
+ txt_resolver=txt_resolver,
205
+ )
206
+ self.auth_tokens = {normalize_authority(k): v for k, v in (auth_tokens or {}).items()}
207
+ self.allow_insecure = allow_insecure
208
+ self.validate_payloads = validate_payloads
209
+ self.max_pow_attempts = max(1, max_pow_attempts)
210
+ # Loop protection for the user's side: refuse to send a request that would
211
+ # continue a loop, before any network call or proof-of-work.
212
+ self.conversation_policy: ConversationPolicy | None = (
213
+ None if conversation_policy is False else (conversation_policy or ConversationPolicy(max_turns=50))
214
+ )
215
+ self.confirm = confirm
216
+ self.confirm_effects = frozenset(confirm_effects)
217
+ self.max_retries = max(0, max_retries)
218
+ self.retry_backoff = retry_backoff
219
+ self._session_guards: OrderedDict[tuple[str, str], ConversationGuard] = OrderedDict()
220
+ self._domain_guards: dict[str, ConversationGuard] = {}
221
+
222
+ async def __aenter__(self) -> WAPClient:
223
+ return self
224
+
225
+ async def __aexit__(self, *exc_info: object) -> None:
226
+ await self.aclose()
227
+
228
+ async def aclose(self) -> None:
229
+ await self.http.aclose()
230
+
231
+ @property
232
+ def agent_public_key(self) -> str:
233
+ return self.signer.public_key
234
+
235
+ # ------------------------------------------------------------------ discovery
236
+
237
+ async def discover(self, domain: str, *, force_refresh: bool = False) -> AgentManifest:
238
+ """Resolve and verify ``domain``'s manifest (cached)."""
239
+ return await self.resolver.resolve(domain, force_refresh=force_refresh)
240
+
241
+ async def fetch_challenge(self, manifest: AgentManifest) -> Challenge:
242
+ if manifest.challenge_url is None:
243
+ raise ProtocolError("pow_required", f"{manifest.domain} requires proof-of-work but has no challenge_url")
244
+ response = await self.http.get(manifest.challenge_url, headers={"Cache-Control": "no-store"})
245
+ body = await self._check_response(manifest, response)
246
+ try:
247
+ return Challenge.model_validate(body)
248
+ except ValidationError as exc:
249
+ raise ProtocolError("invalid_request", f"malformed challenge from {manifest.domain}: {exc}") from exc
250
+
251
+ # ------------------------------------------------------------------ loop protection
252
+
253
+ def conversation_guards(self, domain: str, session_id: str) -> list[ConversationGuard]:
254
+ """The per-session guard and the per-domain (cross-session) guard for a conversation."""
255
+ if self.conversation_policy is None:
256
+ return []
257
+ key = (domain, session_id)
258
+ guard = self._session_guards.get(key)
259
+ if guard is None:
260
+ guard = self._session_guards[key] = ConversationGuard(self.conversation_policy)
261
+ while len(self._session_guards) > 1_000:
262
+ self._session_guards.popitem(last=False)
263
+ self._session_guards.move_to_end(key)
264
+ domain_guard = self._domain_guards.get(domain)
265
+ if domain_guard is None:
266
+ domain_guard = self._domain_guards[domain] = ConversationGuard(self.conversation_policy.across_sessions())
267
+ return [guard, domain_guard]
268
+
269
+ @staticmethod
270
+ def _stopped(exc: ConversationLimitError) -> ConversationStopped:
271
+ cls = LoopDetected if exc.reason == "loop_detected" else ConversationLimitReached
272
+ return cls(exc.reason, f"stopped locally: {exc.message}", details=exc.details)
273
+
274
+ # ------------------------------------------------------------------ interaction
275
+
276
+ def session(self, domain: str, session_id: str | None = None) -> WAPSession:
277
+ """Open a multi-turn dialogue with ``domain``."""
278
+ return WAPSession(self, domain, session_id)
279
+
280
+ async def query(
281
+ self,
282
+ domain: str,
283
+ intent: str = "",
284
+ capability_id: str | None = None,
285
+ payload: dict[str, Any] | None = None,
286
+ *,
287
+ session_id: str | None = None,
288
+ auth_token: str | None = None,
289
+ stream: bool = True,
290
+ idempotency_key: str | None = None,
291
+ max_effects: str | None = None,
292
+ ) -> AsyncIterator[StreamEvent]:
293
+ """Send one turn to ``domain`` and yield reply events as they arrive.
294
+
295
+ The final event is always ``StreamEventType.MESSAGE`` carrying the
296
+ business agent's signed :class:`AgentMessage`, already verified against
297
+ the manifest's public key.
298
+
299
+ Capabilities that are not read-only get an automatic ``idempotency_key``
300
+ (unless one is given), so the transient-failure retries below can never
301
+ perform the action twice. If a ``confirm`` hook is configured it is called
302
+ before any capability whose ``effects`` are in ``confirm_effects``.
303
+ """
304
+ manifest = await self.discover(domain)
305
+ capability = None
306
+ if capability_id is not None:
307
+ capability = manifest.get_capability(capability_id)
308
+ if capability is None:
309
+ raise CapabilityNotFound(manifest.domain, capability_id, [c.id for c in manifest.capabilities])
310
+ if self.validate_payloads:
311
+ validate_payload(manifest, capability_id, payload or {})
312
+ if not intent and capability_id is None:
313
+ raise ValueError("either an intent or a capability_id is required")
314
+
315
+ session_id = session_id or uuid.uuid4().hex
316
+ request_fp = request_fingerprint(capability_id, payload, intent)
317
+ guards = self.conversation_guards(manifest.domain, session_id)
318
+ for guard in guards:
319
+ try:
320
+ guard.check(request_fp)
321
+ except ConversationLimitError as exc:
322
+ raise self._stopped(exc) from None
323
+
324
+ effects = capability.effects if capability is not None else None
325
+ if capability is not None and self.confirm is not None and effects in self.confirm_effects:
326
+ approval = self.confirm(
327
+ ConfirmationRequest(
328
+ domain=manifest.domain,
329
+ business_name=manifest.name,
330
+ capability=capability,
331
+ payload=dict(payload or {}),
332
+ )
333
+ )
334
+ if inspect.isawaitable(approval):
335
+ approval = await approval
336
+ if not approval:
337
+ raise ConfirmationDeclined(manifest.domain, capability.id)
338
+ if idempotency_key is None and capability is not None and effects != "read":
339
+ idempotency_key = "auto-" + uuid.uuid4().hex
340
+ # Free-text intents may trigger actions, so they are only retried with an idempotency key.
341
+ retryable = effects == "read" or idempotency_key is not None or max_effects == "read"
342
+
343
+ token = auth_token or self.auth_tokens.get(manifest.domain)
344
+ challenge: Challenge | None = None
345
+ pow_attempts = 0
346
+ transient_attempts = 0
347
+ while True:
348
+ if challenge is None and manifest.pow_required:
349
+ challenge = await self.fetch_challenge(manifest)
350
+ pow_seed = pow_nonce = None
351
+ if challenge is not None:
352
+ pow_seed, pow_nonce = challenge.seed, await solve_async(challenge.seed, challenge.difficulty)
353
+ request = self.signer.sign_model(
354
+ AgentMessage(
355
+ session_id=session_id,
356
+ role=Role.USER_AGENT,
357
+ content=intent,
358
+ capability_id=capability_id,
359
+ structured_data=payload,
360
+ idempotency_key=idempotency_key,
361
+ max_effects=max_effects,
362
+ pow_seed=pow_seed,
363
+ pow_nonce=pow_nonce,
364
+ public_key=self.signer.public_key,
365
+ )
366
+ )
367
+ yielded = False
368
+ try:
369
+ async for event in self._send(manifest, request, token=token, stream=stream):
370
+ if event.type is StreamEventType.MESSAGE and event.message is not None:
371
+ reply_fp = reply_fingerprint(event.message.content, event.message.structured_data)
372
+ for guard in guards:
373
+ guard.record(request_fp, reply_fp)
374
+ yielded = True
375
+ yield event
376
+ return
377
+ except (httpx.TransportError, ProtocolError) as exc:
378
+ # Gateway errors without a WAP error body (proxies, load balancers) are transient;
379
+ # a WAP "action_failed" (502) means the business's code failed and is not retried.
380
+ transient = isinstance(exc, httpx.TransportError) or (
381
+ (exc.status_code in _TRANSIENT_STATUS and exc.code == "http_error")
382
+ or exc.code in ("session_busy",)
383
+ or (exc.code == "idempotency_conflict" and exc.retry_after is not None)
384
+ )
385
+ if transient and retryable and not yielded and transient_attempts < self.max_retries:
386
+ transient_attempts += 1
387
+ delay = getattr(exc, "retry_after", None) or self.retry_backoff * 2 ** (transient_attempts - 1)
388
+ await asyncio.sleep(min(delay, 30.0))
389
+ challenge = None # the previous seed may already be spent
390
+ continue
391
+ if isinstance(exc, httpx.TransportError):
392
+ raise ProtocolError("network_error", f"could not reach {manifest.domain}: {exc!s}") from exc
393
+ retry_challenge = exc.details.get("challenge") if exc.code in ("pow_required", "pow_invalid") else None
394
+ pow_attempts += 1
395
+ if retry_challenge is None or pow_attempts >= self.max_pow_attempts:
396
+ # An error is an answer too: resending a request that keeps failing is a loop.
397
+ error_fp = reply_fingerprint(f"error:{exc.code}:{exc.message}", exc.details)
398
+ for guard in guards:
399
+ guard.record(request_fp, error_fp)
400
+ if exc.code in ("pow_required", "pow_invalid"):
401
+ raise ProofOfWorkFailed(
402
+ exc.code, exc.message, status_code=exc.status_code, details=exc.details
403
+ ) from exc
404
+ raise
405
+ challenge = Challenge.model_validate(retry_challenge)
406
+
407
+ async def ask(
408
+ self,
409
+ domain: str,
410
+ intent: str = "",
411
+ capability_id: str | None = None,
412
+ payload: dict[str, Any] | None = None,
413
+ *,
414
+ session_id: str | None = None,
415
+ auth_token: str | None = None,
416
+ stream: bool = True,
417
+ idempotency_key: str | None = None,
418
+ max_effects: str | None = None,
419
+ ) -> InteractionResult:
420
+ """Like :meth:`query` but collects the stream into an :class:`InteractionResult`."""
421
+ started = time.perf_counter()
422
+ events: list[StreamEvent] = []
423
+ async for event in self.query(
424
+ domain,
425
+ intent,
426
+ capability_id,
427
+ payload,
428
+ session_id=session_id,
429
+ auth_token=auth_token,
430
+ stream=stream,
431
+ idempotency_key=idempotency_key,
432
+ max_effects=max_effects,
433
+ ):
434
+ events.append(event)
435
+ last = events[-1]
436
+ if last.message is None or last.request is None:
437
+ raise ProtocolError("invalid_response", "reply stream ended without a verified final message")
438
+ final, request = last.message, last.request
439
+ return InteractionResult(
440
+ domain=parse_target(domain, allow_insecure=True).authority,
441
+ session_id=final.session_id,
442
+ text=final.content,
443
+ structured_data=final.structured_data,
444
+ message=final,
445
+ request=request,
446
+ verified=True,
447
+ pow_solved=request.pow_nonce is not None,
448
+ elapsed_seconds=time.perf_counter() - started,
449
+ events=events,
450
+ )
451
+
452
+ async def invoke(
453
+ self,
454
+ domain: str,
455
+ capability_id: str,
456
+ payload: dict[str, Any] | None = None,
457
+ *,
458
+ intent: str = "",
459
+ session_id: str | None = None,
460
+ auth_token: str | None = None,
461
+ idempotency_key: str | None = None,
462
+ ) -> InteractionResult:
463
+ """Execute a specific capability with structured input (non-streaming)."""
464
+ return await self.ask(
465
+ domain,
466
+ intent,
467
+ capability_id,
468
+ payload or {},
469
+ session_id=session_id,
470
+ auth_token=auth_token,
471
+ stream=False,
472
+ idempotency_key=idempotency_key,
473
+ )
474
+
475
+ # ------------------------------------------------------------------ transport
476
+
477
+ def _raise_error(self, manifest: AgentManifest, status: int, body: Any, headers: httpx.Headers) -> None:
478
+ error = body.get("error") if isinstance(body, dict) else None
479
+ if not isinstance(error, dict):
480
+ raise ProtocolError("http_error", f"HTTP {status} from {manifest.domain}", status_code=status)
481
+ code = str(error.get("code", "http_error"))
482
+ message = str(error.get("message", ""))
483
+ details = error.get("details") or {}
484
+ retry_after = error.get("retry_after")
485
+ if retry_after is None and headers.get("retry-after"):
486
+ try:
487
+ retry_after = float(headers["retry-after"])
488
+ except ValueError:
489
+ retry_after = None
490
+ kwargs: dict[str, Any] = {"status_code": status, "details": details, "retry_after": retry_after}
491
+ if code == "rate_limited":
492
+ raise RateLimited(code, message, **kwargs)
493
+ if code == "auth_required":
494
+ raise AuthRequired(code, message, **kwargs)
495
+ if code == "effects_not_permitted":
496
+ raise EffectsNotPermitted(code, message, **kwargs)
497
+ if code == "loop_detected":
498
+ raise LoopDetected(code, message, **kwargs)
499
+ if code == "conversation_limit":
500
+ raise ConversationLimitReached(code, message, **kwargs)
501
+ raise ProtocolError(code, message, **kwargs)
502
+
503
+ def _verify_body(self, manifest: AgentManifest, body: bytes, headers: httpx.Headers) -> Any:
504
+ signature = headers.get(HEADER_SIGNATURE)
505
+ if signature is not None and not verify_bytes(manifest.public_key, body, signature):
506
+ raise VerificationFailed(manifest.domain, "X-WAP-Signature does not match the response body")
507
+ try:
508
+ return json.loads(body) if body else None
509
+ except ValueError as exc:
510
+ raise ProtocolError("invalid_response", f"{manifest.domain} returned non-JSON content") from exc
511
+
512
+ async def _check_response(self, manifest: AgentManifest, response: httpx.Response) -> Any:
513
+ body = self._verify_body(manifest, response.content, response.headers)
514
+ if response.status_code >= 400:
515
+ self._raise_error(manifest, response.status_code, body, response.headers)
516
+ return body
517
+
518
+ def _verify_reply(self, manifest: AgentManifest, request: AgentMessage, raw: Any) -> AgentMessage:
519
+ try:
520
+ reply = AgentMessage.model_validate(raw)
521
+ except ValidationError as exc:
522
+ raise ProtocolError("invalid_response", f"malformed reply from {manifest.domain}: {exc}") from exc
523
+ if reply.role is not Role.BUSINESS_AGENT:
524
+ raise VerificationFailed(manifest.domain, "reply does not carry role 'business_agent'")
525
+ if not verify_model(reply, manifest.public_key):
526
+ raise VerificationFailed(manifest.domain, "reply signature does not verify against the manifest key")
527
+ if reply.in_reply_to != request.message_id or reply.session_id != request.session_id:
528
+ raise VerificationFailed(
529
+ manifest.domain, "reply is not bound to this request (in_reply_to/session mismatch)"
530
+ )
531
+ return reply
532
+
533
+ async def _send(
534
+ self, manifest: AgentManifest, request: AgentMessage, *, token: str | None, stream: bool
535
+ ) -> AsyncIterator[StreamEvent]:
536
+ headers = {"Content-Type": "application/json", "Accept": "text/event-stream" if stream else "application/json"}
537
+ if token:
538
+ headers["Authorization"] = f"Bearer {token}"
539
+ body = request.model_dump_json().encode("utf-8")
540
+ async with self.http.stream("POST", manifest.interaction_url, content=body, headers=headers) as response:
541
+ content_type = response.headers.get("content-type", "")
542
+ if response.status_code >= 400 or "text/event-stream" not in content_type:
543
+ raw = await response.aread()
544
+ parsed = self._verify_body(manifest, raw, response.headers)
545
+ if response.status_code >= 400:
546
+ self._raise_error(manifest, response.status_code, parsed, response.headers)
547
+ reply = self._verify_reply(manifest, request, parsed)
548
+ if reply.structured_data:
549
+ yield StreamEvent(StreamEventType.DATA, data=reply.structured_data)
550
+ if reply.content:
551
+ yield StreamEvent(StreamEventType.TOKEN, text=reply.content)
552
+ yield StreamEvent(
553
+ StreamEventType.MESSAGE,
554
+ text=reply.content,
555
+ data=reply.structured_data,
556
+ message=reply,
557
+ request=request,
558
+ )
559
+ return
560
+
561
+ async for sse in EventSource(response).aiter_sse():
562
+ try:
563
+ payload = json.loads(sse.data) if sse.data else {}
564
+ except ValueError as exc:
565
+ raise ProtocolError("invalid_response", f"malformed SSE data for event {sse.event!r}") from exc
566
+ if sse.event == StreamEventType.META.value:
567
+ yield StreamEvent(StreamEventType.META, data=payload)
568
+ elif sse.event == StreamEventType.TOKEN.value:
569
+ yield StreamEvent(StreamEventType.TOKEN, text=str(payload.get("text", "")))
570
+ elif sse.event == StreamEventType.DATA.value:
571
+ yield StreamEvent(StreamEventType.DATA, data=payload)
572
+ elif sse.event == StreamEventType.ERROR.value:
573
+ self._raise_error(manifest, 200, payload, response.headers)
574
+ elif sse.event == StreamEventType.MESSAGE.value:
575
+ reply = self._verify_reply(manifest, request, payload)
576
+ yield StreamEvent(
577
+ StreamEventType.MESSAGE,
578
+ text=reply.content,
579
+ data=reply.structured_data,
580
+ message=reply,
581
+ request=request,
582
+ )
583
+ return
584
+ raise ProtocolError("invalid_response", f"{manifest.domain} closed the stream without a final message")
585
+
586
+
587
+ class WAPSession:
588
+ """A multi-turn dialogue with a single business agent, sharing one ``session_id``."""
589
+
590
+ def __init__(self, client: WAPClient, domain: str, session_id: str | None = None) -> None:
591
+ self.client = client
592
+ self.domain = domain
593
+ self.session_id = session_id or uuid.uuid4().hex
594
+ self.history: list[AgentMessage] = []
595
+
596
+ async def stream(
597
+ self, intent: str = "", capability_id: str | None = None, payload: dict[str, Any] | None = None
598
+ ) -> AsyncIterator[StreamEvent]:
599
+ async for event in self.client.query(
600
+ self.domain, intent, capability_id, payload, session_id=self.session_id, stream=True
601
+ ):
602
+ if event.type is StreamEventType.MESSAGE and event.message is not None and event.request is not None:
603
+ self.history.extend([event.request, event.message])
604
+ yield event
605
+
606
+ async def send(
607
+ self, intent: str = "", capability_id: str | None = None, payload: dict[str, Any] | None = None
608
+ ) -> InteractionResult:
609
+ result = await self.client.ask(self.domain, intent, capability_id, payload, session_id=self.session_id)
610
+ self.history.extend([result.request, result.message])
611
+ return result
612
+
613
+ @property
614
+ def last_reply(self) -> AgentMessage | None:
615
+ for message in reversed(self.history):
616
+ if message.role is Role.BUSINESS_AGENT:
617
+ return message
618
+ return None
619
+
620
+
621
+ __all__ = ["InteractionResult", "StreamEvent", "WAPClient", "WAPSession", "validate_payload"]
wap/mcp/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """MCP bridge exposing WAP to Model Context Protocol hosts (``pip install "webagent-protocol[mcp]"``)."""
2
+
3
+ from .bridge import WAPBridge, build_server, main
4
+
5
+ __all__ = ["WAPBridge", "build_server", "main"]