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
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Per-IP and per-agent-key rate limiting on top of a pluggable :class:`~wap.storage.StateStore`.
|
|
2
|
+
|
|
3
|
+
Each principal gets a sliding-window counter (sustained rate) and a token bucket
|
|
4
|
+
(bursts); see :mod:`wap.storage.meters`. A request is admitted only if every
|
|
5
|
+
applicable meter admits it, and meters are only charged on admission, so a
|
|
6
|
+
rejected request never consumes budget in another scope.
|
|
7
|
+
|
|
8
|
+
With the default :class:`~wap.storage.MemoryStore` the limits apply per process.
|
|
9
|
+
Pass a shared store (e.g. :class:`~wap.storage.RedisStore`) to enforce them
|
|
10
|
+
across every worker and machine.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import time
|
|
16
|
+
from collections.abc import Callable, Iterable
|
|
17
|
+
|
|
18
|
+
from ..spec.models import RateLimitPolicy
|
|
19
|
+
from ..storage.base import StateStore
|
|
20
|
+
from ..storage.memory import MemoryStore
|
|
21
|
+
from ..storage.meters import RateLimitDecision, SlidingWindowCounter, TokenBucket
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class RateLimiter:
|
|
25
|
+
"""Admits or rejects requests for (scope, principal) pairs under a :class:`RateLimitPolicy`."""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
policy: RateLimitPolicy | None = None,
|
|
30
|
+
store: StateStore | None = None,
|
|
31
|
+
*,
|
|
32
|
+
window_seconds: float = 60.0,
|
|
33
|
+
idle_ttl: float = 600.0,
|
|
34
|
+
max_keys: int = 100_000,
|
|
35
|
+
clock: Callable[[], float] = time.time,
|
|
36
|
+
) -> None:
|
|
37
|
+
self.policy = policy or RateLimitPolicy()
|
|
38
|
+
self.window_seconds = window_seconds
|
|
39
|
+
self.store: StateStore = store or MemoryStore(max_rate_keys=max_keys, rate_idle_ttl=idle_ttl)
|
|
40
|
+
self._clock = clock
|
|
41
|
+
|
|
42
|
+
def keys_for(
|
|
43
|
+
self, ip: str | None, agent_key: str | None, policy: RateLimitPolicy | None = None
|
|
44
|
+
) -> list[tuple[str, str]]:
|
|
45
|
+
policy = policy or self.policy
|
|
46
|
+
keys: list[tuple[str, str]] = []
|
|
47
|
+
if "ip" in policy.scopes and ip:
|
|
48
|
+
keys.append(("ip", f"ip:{ip}"))
|
|
49
|
+
if "agent_key" in policy.scopes and agent_key:
|
|
50
|
+
keys.append(("agent_key", f"key:{agent_key}"))
|
|
51
|
+
return keys
|
|
52
|
+
|
|
53
|
+
async def check(
|
|
54
|
+
self,
|
|
55
|
+
*,
|
|
56
|
+
ip: str | None = None,
|
|
57
|
+
agent_key: str | None = None,
|
|
58
|
+
cost: float = 1.0,
|
|
59
|
+
policy: RateLimitPolicy | None = None,
|
|
60
|
+
) -> RateLimitDecision:
|
|
61
|
+
"""Admit or reject one request for the given principals (optionally under a tier's policy)."""
|
|
62
|
+
return await self.check_keys(self.keys_for(ip, agent_key, policy), cost=cost, policy=policy)
|
|
63
|
+
|
|
64
|
+
async def check_keys(
|
|
65
|
+
self, keys: Iterable[tuple[str, str]], cost: float = 1.0, policy: RateLimitPolicy | None = None
|
|
66
|
+
) -> RateLimitDecision:
|
|
67
|
+
policy = policy or self.policy
|
|
68
|
+
keys = list(keys)
|
|
69
|
+
if not keys:
|
|
70
|
+
limit = policy.requests_per_minute
|
|
71
|
+
return RateLimitDecision(allowed=True, limit=limit, remaining=limit, retry_after=0.0)
|
|
72
|
+
return await self.store.rate_limit(
|
|
73
|
+
keys,
|
|
74
|
+
limit=policy.requests_per_minute,
|
|
75
|
+
window=self.window_seconds,
|
|
76
|
+
burst=policy.burst,
|
|
77
|
+
cost=cost,
|
|
78
|
+
now=self._clock(),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
def reset(self) -> None:
|
|
82
|
+
if isinstance(self.store, MemoryStore):
|
|
83
|
+
self.store.meters.clear()
|
|
84
|
+
|
|
85
|
+
def tracked_keys(self) -> int:
|
|
86
|
+
"""Number of principals tracked in process (in-memory store only)."""
|
|
87
|
+
return len(self.store.meters) if isinstance(self.store, MemoryStore) else 0
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
__all__ = ["RateLimitDecision", "RateLimiter", "SlidingWindowCounter", "TokenBucket"]
|
wap/server/router.py
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
"""HTTP endpoints for WAP/1.0: ``/.well-known/wap.json``, ``/wap/v1/challenge`` and ``/wap/v1/interact``.
|
|
2
|
+
|
|
3
|
+
The interaction endpoint enforces the request pipeline defined in
|
|
4
|
+
``docs/spec_rfc.md`` Section 7.2, in this order:
|
|
5
|
+
|
|
6
|
+
1. per-IP rate limit (cheapest check first; configurable or disabled),
|
|
7
|
+
2. protocol version negotiation,
|
|
8
|
+
3. envelope validation,
|
|
9
|
+
4. Ed25519 signature verification against the sender's declared key,
|
|
10
|
+
5. freshness window and replay detection (shared through the state store),
|
|
11
|
+
6. bearer authentication,
|
|
12
|
+
7. admission hook, per-agent-key rate limit and proof-of-work (only now is a
|
|
13
|
+
challenge consumed),
|
|
14
|
+
8. turn: session lock and ownership, idempotent-retry resolution, loop guards,
|
|
15
|
+
9. dispatch to the capability or intent handler.
|
|
16
|
+
|
|
17
|
+
Nothing expensive (tools, databases, LLMs) runs before step 9.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import re
|
|
24
|
+
import time
|
|
25
|
+
from collections.abc import AsyncIterator
|
|
26
|
+
from typing import TYPE_CHECKING, Any
|
|
27
|
+
|
|
28
|
+
from fastapi import APIRouter, Request
|
|
29
|
+
from fastapi.responses import Response
|
|
30
|
+
from pydantic import ValidationError
|
|
31
|
+
from sse_starlette.sse import EventSourceResponse
|
|
32
|
+
|
|
33
|
+
from ..spec.crypto import canonical_json, verify_model
|
|
34
|
+
from ..spec.models import (
|
|
35
|
+
CHALLENGE_PATH,
|
|
36
|
+
ERROR_STATUS,
|
|
37
|
+
HEADER_SIGNATURE,
|
|
38
|
+
HEADER_VERSION,
|
|
39
|
+
INTERACT_PATH,
|
|
40
|
+
LEGACY_WELL_KNOWN_PATH,
|
|
41
|
+
WAP_VERSION,
|
|
42
|
+
WELL_KNOWN_PATH,
|
|
43
|
+
AgentMessage,
|
|
44
|
+
ErrorCode,
|
|
45
|
+
ErrorResponse,
|
|
46
|
+
Role,
|
|
47
|
+
StreamEventType,
|
|
48
|
+
)
|
|
49
|
+
from .admission import AdmissionRequest
|
|
50
|
+
from .app import Chunk, Turn, WAPProtocolError
|
|
51
|
+
|
|
52
|
+
if TYPE_CHECKING:
|
|
53
|
+
from .app import WAPServer
|
|
54
|
+
|
|
55
|
+
HEADER_IDEMPOTENT_REPLAY = "X-WAP-Idempotent-Replay"
|
|
56
|
+
|
|
57
|
+
MAX_BODY_BYTES = 256 * 1024
|
|
58
|
+
_TOKEN_SPLIT_RE = re.compile(r"(\s+)")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def client_ip(request: Request, trust_forwarded_for: bool) -> str | None:
|
|
62
|
+
if trust_forwarded_for:
|
|
63
|
+
forwarded = request.headers.get("x-forwarded-for")
|
|
64
|
+
if forwarded:
|
|
65
|
+
return forwarded.split(",")[0].strip()
|
|
66
|
+
return request.client.host if request.client else None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def signed_json_response(
|
|
70
|
+
server: WAPServer, payload: Any, status_code: int = 200, headers: dict[str, str] | None = None
|
|
71
|
+
) -> Response:
|
|
72
|
+
"""Serialise ``payload`` canonically and sign the exact body bytes (``X-WAP-Signature``)."""
|
|
73
|
+
body = canonical_json(payload)
|
|
74
|
+
all_headers = {
|
|
75
|
+
HEADER_VERSION: WAP_VERSION,
|
|
76
|
+
HEADER_SIGNATURE: server.signer.sign(body),
|
|
77
|
+
**(headers or {}),
|
|
78
|
+
}
|
|
79
|
+
return Response(content=body, status_code=status_code, media_type="application/json", headers=all_headers)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def error_response(server: WAPServer, error: WAPProtocolError, extra_headers: dict[str, str] | None = None) -> Response:
|
|
83
|
+
payload = ErrorResponse.build(
|
|
84
|
+
error.code, error.message, retry_after=error.retry_after, details=error.details
|
|
85
|
+
).model_dump(mode="json", exclude_none=True)
|
|
86
|
+
headers = {**error.headers, **(extra_headers or {})}
|
|
87
|
+
if error.retry_after is not None and "Retry-After" not in headers:
|
|
88
|
+
headers["Retry-After"] = str(max(1, int(error.retry_after + 0.999)))
|
|
89
|
+
return signed_json_response(server, payload, ERROR_STATUS[error.code], headers)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def manifest_response(server: WAPServer) -> Response:
|
|
93
|
+
manifest = server.manifest()
|
|
94
|
+
max_age = 300
|
|
95
|
+
if manifest.expires_at is not None:
|
|
96
|
+
max_age = max(0, min(max_age, int(manifest.expires_at - time.time())))
|
|
97
|
+
return signed_json_response(
|
|
98
|
+
server,
|
|
99
|
+
manifest.model_dump(mode="json"),
|
|
100
|
+
headers={
|
|
101
|
+
"Cache-Control": f"public, max-age={max_age}",
|
|
102
|
+
"Access-Control-Allow-Origin": "*",
|
|
103
|
+
"ETag": f'"{manifest.signature[:32]}"',
|
|
104
|
+
},
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _split_tokens(text: str, words_per_token: int = 1) -> list[str]:
|
|
109
|
+
parts = [p for p in _TOKEN_SPLIT_RE.split(text) if p]
|
|
110
|
+
tokens: list[str] = []
|
|
111
|
+
buffer = ""
|
|
112
|
+
count = 0
|
|
113
|
+
for part in parts:
|
|
114
|
+
buffer += part
|
|
115
|
+
if not part.isspace():
|
|
116
|
+
count += 1
|
|
117
|
+
if count >= words_per_token:
|
|
118
|
+
tokens.append(buffer)
|
|
119
|
+
buffer, count = "", 0
|
|
120
|
+
if buffer:
|
|
121
|
+
tokens.append(buffer)
|
|
122
|
+
return tokens
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
async def _admit(server: WAPServer, request: Request) -> tuple[Turn, dict[str, str]]:
|
|
126
|
+
"""Run pipeline steps 1-8 and return an opened turn plus response headers."""
|
|
127
|
+
ip = client_ip(request, server.trust_forwarded_for)
|
|
128
|
+
headers = await server.limit_ip(ip)
|
|
129
|
+
|
|
130
|
+
version = request.headers.get(HEADER_VERSION)
|
|
131
|
+
if version is not None and version.split(".")[0] != WAP_VERSION.split(".")[0]:
|
|
132
|
+
raise WAPProtocolError(
|
|
133
|
+
ErrorCode.UNSUPPORTED_VERSION,
|
|
134
|
+
f"unsupported WAP version {version!r}; this agent speaks {WAP_VERSION}",
|
|
135
|
+
details={"supported": [WAP_VERSION]},
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
body = await request.body()
|
|
139
|
+
if len(body) > MAX_BODY_BYTES:
|
|
140
|
+
raise WAPProtocolError(ErrorCode.INVALID_REQUEST, f"request body exceeds {MAX_BODY_BYTES} bytes")
|
|
141
|
+
try:
|
|
142
|
+
message = AgentMessage.model_validate_json(body)
|
|
143
|
+
except ValidationError as exc:
|
|
144
|
+
raise WAPProtocolError(
|
|
145
|
+
ErrorCode.INVALID_REQUEST,
|
|
146
|
+
"request body is not a valid AgentMessage",
|
|
147
|
+
details={"errors": exc.errors(include_url=False, include_context=False, include_input=False)},
|
|
148
|
+
) from exc
|
|
149
|
+
|
|
150
|
+
if message.role is not Role.USER_AGENT:
|
|
151
|
+
raise WAPProtocolError(ErrorCode.INVALID_REQUEST, "requests must carry role 'user_agent'")
|
|
152
|
+
if message.public_key is None:
|
|
153
|
+
raise WAPProtocolError(ErrorCode.INVALID_SIGNATURE, "user_agent messages must declare public_key")
|
|
154
|
+
if not verify_model(message, message.public_key):
|
|
155
|
+
raise WAPProtocolError(ErrorCode.INVALID_SIGNATURE, "message signature does not verify against public_key")
|
|
156
|
+
|
|
157
|
+
await server.check_replay(message)
|
|
158
|
+
principal = await server.authenticate(request.headers.get("authorization"))
|
|
159
|
+
decision, key_headers = await server.admit(
|
|
160
|
+
AdmissionRequest(
|
|
161
|
+
client_ip=ip,
|
|
162
|
+
agent_key=message.public_key,
|
|
163
|
+
principal=principal,
|
|
164
|
+
capability_id=message.capability_id,
|
|
165
|
+
transport="wap",
|
|
166
|
+
headers=dict(request.headers),
|
|
167
|
+
),
|
|
168
|
+
pow_seed=message.pow_seed,
|
|
169
|
+
pow_nonce=message.pow_nonce,
|
|
170
|
+
)
|
|
171
|
+
turn = server.open_turn(
|
|
172
|
+
message,
|
|
173
|
+
owner_key=message.public_key,
|
|
174
|
+
principal_key=f"key:{message.public_key}",
|
|
175
|
+
client_ip=ip,
|
|
176
|
+
agent_key=message.public_key,
|
|
177
|
+
principal=principal,
|
|
178
|
+
tier=decision.tier,
|
|
179
|
+
conversation_policy=decision.conversation_policy,
|
|
180
|
+
)
|
|
181
|
+
await turn.open()
|
|
182
|
+
return turn, {**headers, **key_headers}
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _encode(event: StreamEventType, payload: Any) -> dict[str, str]:
|
|
186
|
+
return {"event": event.value, "data": json.dumps(payload, separators=(",", ":"), ensure_ascii=False)}
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def build_router(server: WAPServer) -> APIRouter:
|
|
190
|
+
router = APIRouter(tags=["WebAgent Protocol"])
|
|
191
|
+
|
|
192
|
+
@router.get(WELL_KNOWN_PATH, include_in_schema=True, summary="WAP discovery manifest (RFC 8615)")
|
|
193
|
+
@router.get(LEGACY_WELL_KNOWN_PATH, include_in_schema=False)
|
|
194
|
+
async def well_known_agent(request: Request) -> Response:
|
|
195
|
+
try:
|
|
196
|
+
headers = await server.limit_ip(client_ip(request, server.trust_forwarded_for))
|
|
197
|
+
except WAPProtocolError as exc:
|
|
198
|
+
return error_response(server, exc)
|
|
199
|
+
response = manifest_response(server)
|
|
200
|
+
response.headers.update(headers)
|
|
201
|
+
return response
|
|
202
|
+
|
|
203
|
+
@router.get(CHALLENGE_PATH, summary="Issue a proof-of-work challenge")
|
|
204
|
+
async def challenge(request: Request) -> Response:
|
|
205
|
+
try:
|
|
206
|
+
headers = await server.limit_ip(client_ip(request, server.trust_forwarded_for))
|
|
207
|
+
except WAPProtocolError as exc:
|
|
208
|
+
return error_response(server, exc)
|
|
209
|
+
if not server.require_pow and server.admission is None:
|
|
210
|
+
return error_response(
|
|
211
|
+
server, WAPProtocolError(ErrorCode.INVALID_REQUEST, "proof-of-work is not enabled on this agent")
|
|
212
|
+
)
|
|
213
|
+
issued = await server.issue_challenge()
|
|
214
|
+
return signed_json_response(
|
|
215
|
+
server, issued.model_dump(mode="json"), headers={"Cache-Control": "no-store", **headers}
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
@router.post(INTERACT_PATH, summary="Send a signed AgentMessage; JSON or SSE reply")
|
|
219
|
+
async def interact(request: Request) -> Response:
|
|
220
|
+
try:
|
|
221
|
+
turn, headers = await _admit(server, request)
|
|
222
|
+
except WAPProtocolError as exc:
|
|
223
|
+
if exc.code is not ErrorCode.RATE_LIMITED:
|
|
224
|
+
server.emit("request.rejected", code=exc.code.value)
|
|
225
|
+
return error_response(server, exc)
|
|
226
|
+
|
|
227
|
+
wants_stream = "text/event-stream" in request.headers.get("accept", "")
|
|
228
|
+
|
|
229
|
+
if turn.replay is not None:
|
|
230
|
+
await turn.close()
|
|
231
|
+
replay = turn.replay
|
|
232
|
+
headers = {**headers, HEADER_IDEMPOTENT_REPLAY: "true"}
|
|
233
|
+
if not wants_stream:
|
|
234
|
+
return signed_json_response(server, replay.model_dump(mode="json"), headers=headers)
|
|
235
|
+
|
|
236
|
+
async def replay_stream() -> AsyncIterator[dict[str, str]]:
|
|
237
|
+
yield _encode(
|
|
238
|
+
StreamEventType.META,
|
|
239
|
+
{
|
|
240
|
+
"session_id": replay.session_id,
|
|
241
|
+
"in_reply_to": replay.in_reply_to,
|
|
242
|
+
"capability_id": replay.capability_id,
|
|
243
|
+
"wap_version": WAP_VERSION,
|
|
244
|
+
"idempotent_replay": True,
|
|
245
|
+
},
|
|
246
|
+
)
|
|
247
|
+
if replay.structured_data:
|
|
248
|
+
yield _encode(StreamEventType.DATA, replay.structured_data)
|
|
249
|
+
for token in _split_tokens(replay.content):
|
|
250
|
+
yield _encode(StreamEventType.TOKEN, {"text": token})
|
|
251
|
+
yield _encode(StreamEventType.MESSAGE, replay.model_dump(mode="json"))
|
|
252
|
+
|
|
253
|
+
return EventSourceResponse(
|
|
254
|
+
replay_stream(), headers={HEADER_VERSION: WAP_VERSION, "Cache-Control": "no-store", **headers}
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
ctx = turn.ctx
|
|
258
|
+
assert ctx is not None
|
|
259
|
+
chunks = server.dispatch(ctx).__aiter__()
|
|
260
|
+
|
|
261
|
+
# Pull the first chunk before committing to a status code, so that
|
|
262
|
+
# validation / unknown-capability / auth errors surface as HTTP errors.
|
|
263
|
+
first: Chunk | None = None
|
|
264
|
+
exhausted = False
|
|
265
|
+
try:
|
|
266
|
+
first = await chunks.__anext__()
|
|
267
|
+
except StopAsyncIteration:
|
|
268
|
+
exhausted = True
|
|
269
|
+
except WAPProtocolError as exc:
|
|
270
|
+
await turn.fail(exc)
|
|
271
|
+
await turn.close()
|
|
272
|
+
return error_response(server, exc, headers)
|
|
273
|
+
|
|
274
|
+
if not wants_stream:
|
|
275
|
+
text: list[str] = []
|
|
276
|
+
data: dict[str, Any] = {}
|
|
277
|
+
|
|
278
|
+
def collect(chunk: Chunk) -> None:
|
|
279
|
+
if isinstance(chunk, str):
|
|
280
|
+
text.append(chunk)
|
|
281
|
+
else:
|
|
282
|
+
data.update(chunk)
|
|
283
|
+
|
|
284
|
+
try:
|
|
285
|
+
if not exhausted:
|
|
286
|
+
collect(first) # type: ignore[arg-type]
|
|
287
|
+
async for chunk in chunks:
|
|
288
|
+
collect(chunk)
|
|
289
|
+
reply = await turn.complete("".join(text), data or None)
|
|
290
|
+
except WAPProtocolError as exc:
|
|
291
|
+
await turn.fail(exc)
|
|
292
|
+
return error_response(server, exc, headers)
|
|
293
|
+
finally:
|
|
294
|
+
await turn.close()
|
|
295
|
+
return signed_json_response(server, reply.model_dump(mode="json"), headers=headers)
|
|
296
|
+
|
|
297
|
+
async def event_stream() -> AsyncIterator[dict[str, str]]:
|
|
298
|
+
text: list[str] = []
|
|
299
|
+
data: dict[str, Any] = {}
|
|
300
|
+
|
|
301
|
+
async def emit(chunk: Chunk) -> AsyncIterator[dict[str, str]]:
|
|
302
|
+
if isinstance(chunk, str):
|
|
303
|
+
text.append(chunk)
|
|
304
|
+
for token in _split_tokens(chunk):
|
|
305
|
+
yield _encode(StreamEventType.TOKEN, {"text": token})
|
|
306
|
+
else:
|
|
307
|
+
data.update(chunk)
|
|
308
|
+
yield _encode(StreamEventType.DATA, chunk)
|
|
309
|
+
|
|
310
|
+
try:
|
|
311
|
+
yield _encode(
|
|
312
|
+
StreamEventType.META,
|
|
313
|
+
{
|
|
314
|
+
"session_id": ctx.session_id,
|
|
315
|
+
"in_reply_to": ctx.message.message_id,
|
|
316
|
+
"capability_id": ctx.message.capability_id,
|
|
317
|
+
"wap_version": WAP_VERSION,
|
|
318
|
+
},
|
|
319
|
+
)
|
|
320
|
+
try:
|
|
321
|
+
if not exhausted:
|
|
322
|
+
async for event in emit(first): # type: ignore[arg-type]
|
|
323
|
+
yield event
|
|
324
|
+
async for chunk in chunks:
|
|
325
|
+
async for event in emit(chunk):
|
|
326
|
+
yield event
|
|
327
|
+
reply = await turn.complete("".join(text), data or None)
|
|
328
|
+
except WAPProtocolError as exc:
|
|
329
|
+
await turn.fail(exc)
|
|
330
|
+
yield _encode(
|
|
331
|
+
StreamEventType.ERROR,
|
|
332
|
+
ErrorResponse.build(exc.code, exc.message, details=exc.details).model_dump(
|
|
333
|
+
mode="json", exclude_none=True
|
|
334
|
+
),
|
|
335
|
+
)
|
|
336
|
+
return
|
|
337
|
+
yield _encode(StreamEventType.MESSAGE, reply.model_dump(mode="json"))
|
|
338
|
+
finally:
|
|
339
|
+
# Also runs if the client disconnects mid-stream (generator closed/cancelled).
|
|
340
|
+
await turn.close()
|
|
341
|
+
|
|
342
|
+
return EventSourceResponse(
|
|
343
|
+
event_stream(),
|
|
344
|
+
headers={HEADER_VERSION: WAP_VERSION, "Cache-Control": "no-store", **headers},
|
|
345
|
+
ping=15,
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
return router
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
__all__ = [
|
|
352
|
+
"HEADER_IDEMPOTENT_REPLAY",
|
|
353
|
+
"build_router",
|
|
354
|
+
"client_ip",
|
|
355
|
+
"error_response",
|
|
356
|
+
"manifest_response",
|
|
357
|
+
"signed_json_response",
|
|
358
|
+
]
|
wap/server/state.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Dialogue sessions persisted in a :class:`~wap.storage.StateStore`.
|
|
2
|
+
|
|
3
|
+
A session is a JSON document holding the conversation history, the
|
|
4
|
+
application's own ``state`` dict (what actions read and write through
|
|
5
|
+
``ctx.state``), the owning agent key and the loop-guard history. It is loaded
|
|
6
|
+
at the start of a turn under a per-session lock and saved at the end, so
|
|
7
|
+
concurrent requests for the same session are serialised across every worker
|
|
8
|
+
sharing the store.
|
|
9
|
+
|
|
10
|
+
Values written to ``ctx.state`` must therefore be JSON-serialisable.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import time
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from ..spec.conversation import ConversationGuard, ConversationPolicy
|
|
21
|
+
from ..spec.models import AgentMessage, ErrorCode
|
|
22
|
+
from ..storage.base import StateStore
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class SessionState:
|
|
27
|
+
session_id: str
|
|
28
|
+
owner_key: str | None
|
|
29
|
+
created_at: float = field(default_factory=time.time)
|
|
30
|
+
last_seen: float = field(default_factory=time.time)
|
|
31
|
+
history: list[AgentMessage] = field(default_factory=list)
|
|
32
|
+
state: dict[str, Any] = field(default_factory=dict)
|
|
33
|
+
guard: ConversationGuard | None = None
|
|
34
|
+
|
|
35
|
+
def to_dict(self) -> dict[str, Any]:
|
|
36
|
+
return {
|
|
37
|
+
"session_id": self.session_id,
|
|
38
|
+
"owner_key": self.owner_key,
|
|
39
|
+
"created_at": self.created_at,
|
|
40
|
+
"last_seen": self.last_seen,
|
|
41
|
+
"history": [m.model_dump(mode="json") for m in self.history],
|
|
42
|
+
"state": self.state,
|
|
43
|
+
"guard": self.guard.to_dict() if self.guard is not None else None,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def from_dict(cls, data: dict[str, Any], policy: ConversationPolicy | None) -> SessionState:
|
|
48
|
+
return cls(
|
|
49
|
+
session_id=data["session_id"],
|
|
50
|
+
owner_key=data.get("owner_key"),
|
|
51
|
+
created_at=float(data.get("created_at", time.time())),
|
|
52
|
+
last_seen=float(data.get("last_seen", time.time())),
|
|
53
|
+
history=[AgentMessage.model_validate(m) for m in data.get("history", [])],
|
|
54
|
+
state=dict(data.get("state") or {}),
|
|
55
|
+
guard=ConversationGuard.from_dict(policy, data.get("guard")) if policy is not None else None,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class SessionManager:
|
|
60
|
+
"""Loads, saves and locks sessions in a shared store."""
|
|
61
|
+
|
|
62
|
+
def __init__(
|
|
63
|
+
self,
|
|
64
|
+
store: StateStore,
|
|
65
|
+
*,
|
|
66
|
+
ttl_seconds: float = 3600.0,
|
|
67
|
+
max_history: int = 100,
|
|
68
|
+
conversation_policy: ConversationPolicy | None = None,
|
|
69
|
+
lock_timeout: float = 120.0,
|
|
70
|
+
lock_wait: float = 10.0,
|
|
71
|
+
) -> None:
|
|
72
|
+
self.store = store
|
|
73
|
+
self.ttl_seconds = ttl_seconds
|
|
74
|
+
self.max_history = max_history
|
|
75
|
+
self.conversation_policy = conversation_policy
|
|
76
|
+
self.lock_timeout = lock_timeout
|
|
77
|
+
self.lock_wait = lock_wait
|
|
78
|
+
|
|
79
|
+
@staticmethod
|
|
80
|
+
def _key(session_id: str) -> str:
|
|
81
|
+
return f"session:{session_id}"
|
|
82
|
+
|
|
83
|
+
def lock(self, session_id: str) -> Any:
|
|
84
|
+
return self.store.lock(self._key(session_id), timeout=self.lock_timeout, wait=self.lock_wait)
|
|
85
|
+
|
|
86
|
+
async def get(self, session_id: str) -> SessionState | None:
|
|
87
|
+
data = await self.store.get(self._key(session_id))
|
|
88
|
+
return None if data is None else SessionState.from_dict(data, self.conversation_policy)
|
|
89
|
+
|
|
90
|
+
async def load(self, session_id: str, owner_key: str | None) -> SessionState:
|
|
91
|
+
"""Return the session, creating it if absent. Raises if another key owns it."""
|
|
92
|
+
from .app import WAPProtocolError
|
|
93
|
+
|
|
94
|
+
session = await self.get(session_id)
|
|
95
|
+
if session is None:
|
|
96
|
+
guard = ConversationGuard(self.conversation_policy) if self.conversation_policy is not None else None
|
|
97
|
+
return SessionState(session_id=session_id, owner_key=owner_key, guard=guard)
|
|
98
|
+
if session.owner_key != owner_key:
|
|
99
|
+
raise WAPProtocolError(ErrorCode.FORBIDDEN, "session belongs to a different agent key")
|
|
100
|
+
return session
|
|
101
|
+
|
|
102
|
+
async def save(self, session: SessionState) -> None:
|
|
103
|
+
session.last_seen = time.time()
|
|
104
|
+
if len(session.history) > self.max_history:
|
|
105
|
+
del session.history[: len(session.history) - self.max_history]
|
|
106
|
+
document = session.to_dict()
|
|
107
|
+
try:
|
|
108
|
+
json.dumps(document)
|
|
109
|
+
except (TypeError, ValueError) as exc:
|
|
110
|
+
raise TypeError(
|
|
111
|
+
f"session {session.session_id!r} state is not JSON-serialisable ({exc}); "
|
|
112
|
+
"store only JSON values in ctx.state"
|
|
113
|
+
) from exc
|
|
114
|
+
await self.store.set(self._key(session.session_id), document, ttl=self.ttl_seconds)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
__all__ = ["SessionManager", "SessionState"]
|
wap/spec/__init__.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""WAP/1.0 specification layer: wire models, signatures and proof-of-work."""
|
|
2
|
+
|
|
3
|
+
from .conversation import ConversationGuard, ConversationLimitError, ConversationPolicy
|
|
4
|
+
from .crypto import (
|
|
5
|
+
KeyFormatError,
|
|
6
|
+
KeyPair,
|
|
7
|
+
Signer,
|
|
8
|
+
canonical_json,
|
|
9
|
+
fingerprint,
|
|
10
|
+
generate_keypair,
|
|
11
|
+
sign_model,
|
|
12
|
+
signing_payload,
|
|
13
|
+
verify_bytes,
|
|
14
|
+
verify_model,
|
|
15
|
+
)
|
|
16
|
+
from .models import (
|
|
17
|
+
CHALLENGE_PATH,
|
|
18
|
+
HEADER_AGENT_KEY,
|
|
19
|
+
HEADER_SIGNATURE,
|
|
20
|
+
HEADER_VERSION,
|
|
21
|
+
INTERACT_PATH,
|
|
22
|
+
WAP_VERSION,
|
|
23
|
+
WELL_KNOWN_PATH,
|
|
24
|
+
AgentManifest,
|
|
25
|
+
AgentMessage,
|
|
26
|
+
Capability,
|
|
27
|
+
Challenge,
|
|
28
|
+
ErrorCode,
|
|
29
|
+
ErrorDetail,
|
|
30
|
+
ErrorResponse,
|
|
31
|
+
RateLimitPolicy,
|
|
32
|
+
Role,
|
|
33
|
+
StreamEventType,
|
|
34
|
+
normalize_authority,
|
|
35
|
+
)
|
|
36
|
+
from .pow import PowEngine, PowError, check_solution, solve, solve_async
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"CHALLENGE_PATH",
|
|
40
|
+
"HEADER_AGENT_KEY",
|
|
41
|
+
"HEADER_SIGNATURE",
|
|
42
|
+
"HEADER_VERSION",
|
|
43
|
+
"INTERACT_PATH",
|
|
44
|
+
"WAP_VERSION",
|
|
45
|
+
"WELL_KNOWN_PATH",
|
|
46
|
+
"AgentManifest",
|
|
47
|
+
"AgentMessage",
|
|
48
|
+
"Capability",
|
|
49
|
+
"Challenge",
|
|
50
|
+
"ConversationGuard",
|
|
51
|
+
"ConversationLimitError",
|
|
52
|
+
"ConversationPolicy",
|
|
53
|
+
"ErrorCode",
|
|
54
|
+
"ErrorDetail",
|
|
55
|
+
"ErrorResponse",
|
|
56
|
+
"KeyFormatError",
|
|
57
|
+
"KeyPair",
|
|
58
|
+
"PowEngine",
|
|
59
|
+
"PowError",
|
|
60
|
+
"RateLimitPolicy",
|
|
61
|
+
"Role",
|
|
62
|
+
"Signer",
|
|
63
|
+
"StreamEventType",
|
|
64
|
+
"canonical_json",
|
|
65
|
+
"check_solution",
|
|
66
|
+
"fingerprint",
|
|
67
|
+
"generate_keypair",
|
|
68
|
+
"normalize_authority",
|
|
69
|
+
"sign_model",
|
|
70
|
+
"signing_payload",
|
|
71
|
+
"solve",
|
|
72
|
+
"solve_async",
|
|
73
|
+
"verify_bytes",
|
|
74
|
+
"verify_model",
|
|
75
|
+
]
|