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/__init__.py +75 -0
- wap/cli/__init__.py +5 -0
- wap/cli/main.py +369 -0
- wap/client/__init__.py +47 -0
- wap/client/dnskey.py +82 -0
- wap/client/exceptions.py +137 -0
- wap/client/resolver.py +361 -0
- wap/client/session.py +621 -0
- wap/mcp/__init__.py +5 -0
- wap/mcp/bridge.py +793 -0
- wap/mcp/safety.py +120 -0
- wap/py.typed +0 -0
- wap/server/__init__.py +29 -0
- wap/server/admission.py +117 -0
- wap/server/app.py +1071 -0
- wap/server/mcp_endpoint.py +305 -0
- wap/server/mcp_import.py +122 -0
- wap/server/middleware.py +111 -0
- wap/server/observability.py +102 -0
- wap/server/rate_limiter.py +90 -0
- wap/server/router.py +358 -0
- wap/server/state.py +117 -0
- wap/spec/__init__.py +75 -0
- wap/spec/conversation.py +231 -0
- wap/spec/crypto.py +216 -0
- wap/spec/jcs.py +132 -0
- wap/spec/models.py +453 -0
- wap/spec/pow.py +230 -0
- wap/storage/__init__.py +30 -0
- wap/storage/base.py +70 -0
- wap/storage/memory.py +115 -0
- wap/storage/meters.py +204 -0
- wap/storage/redis.py +202 -0
- webagent_protocol-0.2.0.dist-info/METADATA +401 -0
- webagent_protocol-0.2.0.dist-info/RECORD +38 -0
- webagent_protocol-0.2.0.dist-info/WHEEL +4 -0
- webagent_protocol-0.2.0.dist-info/entry_points.txt +3 -0
- webagent_protocol-0.2.0.dist-info/licenses/LICENSE +21 -0
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"]
|