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/server/app.py
ADDED
|
@@ -0,0 +1,1071 @@
|
|
|
1
|
+
"""WAPServer: capability registry, manifest signing and message dispatch.
|
|
2
|
+
|
|
3
|
+
Business developers expose ordinary Python callables as machine-executable
|
|
4
|
+
capabilities::
|
|
5
|
+
|
|
6
|
+
wap = WAPServer(name="Bakery", domain="bakery.example", private_key=KEY, require_pow=True)
|
|
7
|
+
|
|
8
|
+
@wap.action(name="check_pastry_stock", description="Units on hand for a pastry.")
|
|
9
|
+
async def check_pastry_stock(item: str) -> dict:
|
|
10
|
+
return {"item": item, "in_stock": 12}
|
|
11
|
+
|
|
12
|
+
wap.mount(app) # app is a FastAPI instance
|
|
13
|
+
|
|
14
|
+
Input schemas are generated from the function signature with Pydantic, so the
|
|
15
|
+
manifest always matches what the function actually accepts.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import asyncio
|
|
21
|
+
import inspect
|
|
22
|
+
import logging
|
|
23
|
+
import re
|
|
24
|
+
import sys
|
|
25
|
+
import time
|
|
26
|
+
from collections.abc import AsyncIterator, Callable
|
|
27
|
+
from contextlib import AsyncExitStack, asynccontextmanager
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
from typing import TYPE_CHECKING, Any, Literal, get_type_hints
|
|
30
|
+
|
|
31
|
+
from jsonschema import Draft202012Validator
|
|
32
|
+
from jsonschema.exceptions import SchemaError
|
|
33
|
+
from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError, create_model
|
|
34
|
+
|
|
35
|
+
from ..spec.conversation import (
|
|
36
|
+
ConversationGuard,
|
|
37
|
+
ConversationLimitError,
|
|
38
|
+
ConversationPolicy,
|
|
39
|
+
reply_fingerprint,
|
|
40
|
+
request_fingerprint,
|
|
41
|
+
)
|
|
42
|
+
from ..spec.crypto import Signer, endorsement_payload
|
|
43
|
+
from ..spec.models import (
|
|
44
|
+
CHALLENGE_PATH,
|
|
45
|
+
EFFECT_RANK,
|
|
46
|
+
INTERACT_PATH,
|
|
47
|
+
AgentManifest,
|
|
48
|
+
AgentMessage,
|
|
49
|
+
Capability,
|
|
50
|
+
Challenge,
|
|
51
|
+
ErrorCode,
|
|
52
|
+
RateLimitPolicy,
|
|
53
|
+
Role,
|
|
54
|
+
is_local_authority,
|
|
55
|
+
normalize_authority,
|
|
56
|
+
)
|
|
57
|
+
from ..spec.pow import PowEngine, PowError, PowReplayed
|
|
58
|
+
from ..storage.base import LockTimeout, StateStore
|
|
59
|
+
from ..storage.memory import MemoryStore
|
|
60
|
+
from ..storage.meters import RateLimitDecision
|
|
61
|
+
from .admission import AdaptivePow, AdmissionDecision, AdmissionHook, AdmissionRequest
|
|
62
|
+
from .admission import evaluate as evaluate_admission
|
|
63
|
+
from .rate_limiter import RateLimiter
|
|
64
|
+
from .state import SessionManager, SessionState
|
|
65
|
+
|
|
66
|
+
if TYPE_CHECKING:
|
|
67
|
+
from fastapi import FastAPI
|
|
68
|
+
|
|
69
|
+
logger = logging.getLogger("wap.server")
|
|
70
|
+
|
|
71
|
+
AuthHandler = Callable[[str], Any]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class WAPProtocolError(Exception):
|
|
75
|
+
"""An error that maps directly onto a WAP error response."""
|
|
76
|
+
|
|
77
|
+
def __init__(
|
|
78
|
+
self,
|
|
79
|
+
code: ErrorCode,
|
|
80
|
+
message: str,
|
|
81
|
+
*,
|
|
82
|
+
retry_after: float | None = None,
|
|
83
|
+
details: dict[str, Any] | None = None,
|
|
84
|
+
headers: dict[str, str] | None = None,
|
|
85
|
+
) -> None:
|
|
86
|
+
super().__init__(message)
|
|
87
|
+
self.code = code
|
|
88
|
+
self.message = message
|
|
89
|
+
self.retry_after = retry_after
|
|
90
|
+
self.details = details
|
|
91
|
+
self.headers = headers or {}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass
|
|
95
|
+
class ActionResult:
|
|
96
|
+
"""Explicit result type for actions that want to control both text and data."""
|
|
97
|
+
|
|
98
|
+
content: str = ""
|
|
99
|
+
data: dict[str, Any] | None = None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass
|
|
103
|
+
class ActionContext:
|
|
104
|
+
"""Per-request context injected into actions that declare a parameter of this type."""
|
|
105
|
+
|
|
106
|
+
server: WAPServer
|
|
107
|
+
message: AgentMessage
|
|
108
|
+
session: SessionState
|
|
109
|
+
client_ip: str | None = None
|
|
110
|
+
agent_key: str | None = None
|
|
111
|
+
principal: Any = None
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def session_id(self) -> str:
|
|
115
|
+
return self.session.session_id
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def intent(self) -> str:
|
|
119
|
+
return self.message.content
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def history(self) -> list[AgentMessage]:
|
|
123
|
+
return self.session.history
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def state(self) -> dict[str, Any]:
|
|
127
|
+
return self.session.state
|
|
128
|
+
|
|
129
|
+
async def invoke(self, capability_id: str, payload: dict[str, Any] | None = None) -> ActionResult:
|
|
130
|
+
"""Run another registered capability and collect its full result (for intent routers)."""
|
|
131
|
+
text: list[str] = []
|
|
132
|
+
data: dict[str, Any] = {}
|
|
133
|
+
async for chunk in self.server.run_capability(capability_id, payload or {}, self):
|
|
134
|
+
if isinstance(chunk, str):
|
|
135
|
+
text.append(chunk)
|
|
136
|
+
else:
|
|
137
|
+
data.update(chunk)
|
|
138
|
+
return ActionResult(content="".join(text), data=data or None)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class _InputBase(BaseModel):
|
|
142
|
+
model_config = ConfigDict(extra="forbid")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@dataclass
|
|
146
|
+
class RegisteredAction:
|
|
147
|
+
"""A capability plus the callable that implements it.
|
|
148
|
+
|
|
149
|
+
Two flavours exist: *typed* actions built from a Python signature (validated
|
|
150
|
+
with a generated Pydantic model and called with keyword arguments), and *raw*
|
|
151
|
+
actions registered with an explicit JSON Schema (validated with
|
|
152
|
+
``jsonschema`` and called as ``func(payload, ctx)``), used e.g. to re-publish
|
|
153
|
+
tools from an existing MCP server.
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
func: Callable[..., Any]
|
|
157
|
+
capability: Capability
|
|
158
|
+
input_model: type[BaseModel] | None
|
|
159
|
+
context_param: str | None
|
|
160
|
+
model_param: str | None
|
|
161
|
+
|
|
162
|
+
async def call(self, payload: dict[str, Any], ctx: ActionContext) -> Any:
|
|
163
|
+
if self.input_model is None:
|
|
164
|
+
return await self._call_raw(payload, ctx)
|
|
165
|
+
try:
|
|
166
|
+
validated = self.input_model.model_validate(payload)
|
|
167
|
+
except ValidationError as exc:
|
|
168
|
+
raise WAPProtocolError(
|
|
169
|
+
ErrorCode.VALIDATION_ERROR,
|
|
170
|
+
f"structured_data does not match the input schema of {self.capability.id!r}",
|
|
171
|
+
details={"errors": exc.errors(include_url=False, include_context=False)},
|
|
172
|
+
) from exc
|
|
173
|
+
if self.model_param is not None:
|
|
174
|
+
kwargs: dict[str, Any] = {self.model_param: validated}
|
|
175
|
+
else:
|
|
176
|
+
kwargs = {name: getattr(validated, name) for name in type(validated).model_fields}
|
|
177
|
+
if self.context_param is not None:
|
|
178
|
+
kwargs[self.context_param] = ctx
|
|
179
|
+
if (
|
|
180
|
+
inspect.isasyncgenfunction(self.func)
|
|
181
|
+
or inspect.iscoroutinefunction(self.func)
|
|
182
|
+
or inspect.isgeneratorfunction(self.func)
|
|
183
|
+
):
|
|
184
|
+
return self.func(**kwargs)
|
|
185
|
+
# Plain synchronous callables may block (database drivers, SDK calls): run off-loop.
|
|
186
|
+
return await asyncio.to_thread(self.func, **kwargs)
|
|
187
|
+
|
|
188
|
+
async def _call_raw(self, payload: dict[str, Any], ctx: ActionContext) -> Any:
|
|
189
|
+
validator = Draft202012Validator(self.capability.input_schema)
|
|
190
|
+
errors = sorted(validator.iter_errors(payload), key=lambda e: list(e.absolute_path))
|
|
191
|
+
if errors:
|
|
192
|
+
raise WAPProtocolError(
|
|
193
|
+
ErrorCode.VALIDATION_ERROR,
|
|
194
|
+
f"structured_data does not match the input schema of {self.capability.id!r}",
|
|
195
|
+
details={
|
|
196
|
+
"errors": [
|
|
197
|
+
{"loc": list(e.absolute_path), "msg": e.message, "type": str(e.validator)} for e in errors
|
|
198
|
+
]
|
|
199
|
+
},
|
|
200
|
+
)
|
|
201
|
+
if inspect.iscoroutinefunction(self.func) or inspect.isasyncgenfunction(self.func):
|
|
202
|
+
return self.func(payload, ctx)
|
|
203
|
+
return await asyncio.to_thread(self.func, payload, ctx)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _json_schema_for(annotation: Any) -> dict[str, Any] | None:
|
|
207
|
+
if annotation is inspect.Signature.empty or annotation is None or annotation is type(None):
|
|
208
|
+
return None
|
|
209
|
+
if annotation in (str, ActionResult):
|
|
210
|
+
return None
|
|
211
|
+
try:
|
|
212
|
+
schema = TypeAdapter(annotation).json_schema()
|
|
213
|
+
except Exception: # annotation not representable as JSON Schema
|
|
214
|
+
return None
|
|
215
|
+
return schema if schema.get("type") == "object" or "properties" in schema else None
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _build_action(
|
|
219
|
+
func: Callable[..., Any],
|
|
220
|
+
*,
|
|
221
|
+
capability_id: str,
|
|
222
|
+
title: str | None,
|
|
223
|
+
description: str | None,
|
|
224
|
+
requires_auth: bool,
|
|
225
|
+
effects: str = "write",
|
|
226
|
+
localns: dict[str, Any] | None = None,
|
|
227
|
+
) -> RegisteredAction:
|
|
228
|
+
signature = inspect.signature(func)
|
|
229
|
+
try:
|
|
230
|
+
# localns resolves string annotations naming classes defined in the enclosing
|
|
231
|
+
# function (common with ``from __future__ import annotations``).
|
|
232
|
+
hints = get_type_hints(func, localns=localns, include_extras=True)
|
|
233
|
+
except Exception as exc:
|
|
234
|
+
raise TypeError(f"action {capability_id!r}: cannot resolve type annotations ({exc})") from exc
|
|
235
|
+
fields: dict[str, Any] = {}
|
|
236
|
+
context_param: str | None = None
|
|
237
|
+
model_param: str | None = None
|
|
238
|
+
for param in signature.parameters.values():
|
|
239
|
+
if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
|
|
240
|
+
raise TypeError(f"action {capability_id!r}: *args/**kwargs are not supported")
|
|
241
|
+
annotation = hints.get(param.name, param.annotation)
|
|
242
|
+
if annotation is ActionContext:
|
|
243
|
+
context_param = param.name
|
|
244
|
+
continue
|
|
245
|
+
if annotation is inspect.Parameter.empty:
|
|
246
|
+
annotation = Any
|
|
247
|
+
default = ... if param.default is inspect.Parameter.empty else param.default
|
|
248
|
+
fields[param.name] = (annotation, default)
|
|
249
|
+
|
|
250
|
+
input_model: type[BaseModel]
|
|
251
|
+
model_candidates = [
|
|
252
|
+
name for name, (ann, _) in fields.items() if inspect.isclass(ann) and issubclass(ann, BaseModel)
|
|
253
|
+
]
|
|
254
|
+
if len(fields) == 1 and model_candidates:
|
|
255
|
+
model_param = model_candidates[0]
|
|
256
|
+
input_model = fields[model_param][0]
|
|
257
|
+
else:
|
|
258
|
+
model_name = "".join(part.capitalize() for part in re.split(r"[^a-zA-Z0-9]", capability_id)) + "Input"
|
|
259
|
+
input_model = create_model(model_name, __base__=_InputBase, **fields)
|
|
260
|
+
|
|
261
|
+
input_schema = input_model.model_json_schema()
|
|
262
|
+
input_schema.setdefault("type", "object")
|
|
263
|
+
input_schema.setdefault("properties", {})
|
|
264
|
+
input_schema.pop("title", None)
|
|
265
|
+
|
|
266
|
+
doc = inspect.getdoc(func) or ""
|
|
267
|
+
capability = Capability(
|
|
268
|
+
id=capability_id,
|
|
269
|
+
name=title or capability_id.replace("_", " ").replace(".", " ").title(),
|
|
270
|
+
description=description if description is not None else doc,
|
|
271
|
+
input_schema=input_schema,
|
|
272
|
+
output_schema=_json_schema_for(hints.get("return", signature.return_annotation)),
|
|
273
|
+
requires_auth=requires_auth,
|
|
274
|
+
effects=effects,
|
|
275
|
+
streaming=inspect.isasyncgenfunction(func) or inspect.isgeneratorfunction(func),
|
|
276
|
+
)
|
|
277
|
+
return RegisteredAction(
|
|
278
|
+
func=func, capability=capability, input_model=input_model, context_param=context_param, model_param=model_param
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
_WORD_RE = re.compile(r"[a-z0-9]+")
|
|
283
|
+
_STOPWORDS = frozenset(
|
|
284
|
+
"a an and any are can could do does for from have i is it me my of on or please "
|
|
285
|
+
"the there to what which with you your".split()
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _tokens(text: str) -> set[str]:
|
|
290
|
+
words = set(_WORD_RE.findall(text.lower())) - _STOPWORDS
|
|
291
|
+
return {w[:-1] if len(w) > 3 and w.endswith("s") else w for w in words}
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
class KeywordIntentRouter:
|
|
295
|
+
"""Deterministic fallback router used when no custom intent handler is registered.
|
|
296
|
+
|
|
297
|
+
Scores every capability by lexical overlap between the intent and the
|
|
298
|
+
capability's id, name and description. If the best capability can be
|
|
299
|
+
invoked with the supplied structured data it is executed; otherwise the
|
|
300
|
+
router replies with a clarification describing what is needed.
|
|
301
|
+
"""
|
|
302
|
+
|
|
303
|
+
async def __call__(self, intent: str, ctx: ActionContext) -> ActionResult:
|
|
304
|
+
server = ctx.server
|
|
305
|
+
intent_tokens = _tokens(intent)
|
|
306
|
+
best: tuple[float, RegisteredAction] | None = None
|
|
307
|
+
for action in server.actions.values():
|
|
308
|
+
cap = action.capability
|
|
309
|
+
name_tokens = _tokens(f"{cap.id} {cap.name}")
|
|
310
|
+
desc_tokens = _tokens(cap.description)
|
|
311
|
+
score = 2.0 * len(intent_tokens & name_tokens) + len(intent_tokens & desc_tokens)
|
|
312
|
+
if score > 0 and (best is None or score > best[0]):
|
|
313
|
+
best = (score, action)
|
|
314
|
+
if best is None:
|
|
315
|
+
return ActionResult(
|
|
316
|
+
content=(
|
|
317
|
+
f"{server.name} could not map that request to a capability. "
|
|
318
|
+
f"Available capabilities: {', '.join(server.actions) or 'none'}."
|
|
319
|
+
),
|
|
320
|
+
data={"capabilities": [a.capability.model_dump(mode="json") for a in server.actions.values()]},
|
|
321
|
+
)
|
|
322
|
+
action = best[1]
|
|
323
|
+
payload = ctx.message.structured_data or {}
|
|
324
|
+
required = set(action.capability.input_schema.get("required", []))
|
|
325
|
+
missing = sorted(required - set(payload))
|
|
326
|
+
if missing:
|
|
327
|
+
return ActionResult(
|
|
328
|
+
content=(
|
|
329
|
+
f"I can help with that via '{action.capability.id}', but I need: {', '.join(missing)}. "
|
|
330
|
+
"Resend the request with these fields in structured_data."
|
|
331
|
+
),
|
|
332
|
+
data={
|
|
333
|
+
"needs_input": True,
|
|
334
|
+
"capability_id": action.capability.id,
|
|
335
|
+
"missing": missing,
|
|
336
|
+
"input_schema": action.capability.input_schema,
|
|
337
|
+
},
|
|
338
|
+
)
|
|
339
|
+
if action.capability.requires_auth and ctx.principal is None:
|
|
340
|
+
raise WAPProtocolError(
|
|
341
|
+
ErrorCode.AUTH_REQUIRED, f"capability {action.capability.id!r} requires authorization"
|
|
342
|
+
)
|
|
343
|
+
return await ctx.invoke(action.capability.id, payload)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
Chunk = str | dict[str, Any]
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
async def iterate_result(result: Any, default_content: str = "") -> AsyncIterator[Chunk]:
|
|
350
|
+
"""Normalise any supported action return value into a stream of text/data chunks."""
|
|
351
|
+
if inspect.isawaitable(result):
|
|
352
|
+
result = await result
|
|
353
|
+
if inspect.isasyncgen(result):
|
|
354
|
+
async for item in result:
|
|
355
|
+
async for chunk in iterate_result(item):
|
|
356
|
+
yield chunk
|
|
357
|
+
return
|
|
358
|
+
if inspect.isgenerator(result):
|
|
359
|
+
for item in result:
|
|
360
|
+
async for chunk in iterate_result(item):
|
|
361
|
+
yield chunk
|
|
362
|
+
return
|
|
363
|
+
if result is None:
|
|
364
|
+
if default_content:
|
|
365
|
+
yield default_content
|
|
366
|
+
return
|
|
367
|
+
if isinstance(result, ActionResult):
|
|
368
|
+
if result.data is not None:
|
|
369
|
+
yield dict(result.data)
|
|
370
|
+
if result.content:
|
|
371
|
+
yield result.content
|
|
372
|
+
elif default_content:
|
|
373
|
+
yield default_content
|
|
374
|
+
return
|
|
375
|
+
if isinstance(result, str):
|
|
376
|
+
yield result
|
|
377
|
+
return
|
|
378
|
+
if isinstance(result, BaseModel):
|
|
379
|
+
yield result.model_dump(mode="json")
|
|
380
|
+
if default_content:
|
|
381
|
+
yield default_content
|
|
382
|
+
return
|
|
383
|
+
if isinstance(result, dict):
|
|
384
|
+
yield TypeAdapter(dict[str, Any]).dump_python(result, mode="json")
|
|
385
|
+
if default_content:
|
|
386
|
+
yield default_content
|
|
387
|
+
return
|
|
388
|
+
if isinstance(result, (list, tuple, set, int, float, bool)):
|
|
389
|
+
yield {"result": TypeAdapter(Any).dump_python(result, mode="json")}
|
|
390
|
+
if default_content:
|
|
391
|
+
yield default_content
|
|
392
|
+
return
|
|
393
|
+
raise TypeError(f"unsupported action return type: {type(result).__name__}")
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
Observer = Callable[[str, dict[str, Any]], None]
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
class Turn:
|
|
400
|
+
"""One request/response exchange, from lock to persisted reply.
|
|
401
|
+
|
|
402
|
+
Opening a turn (``await turn.open()``) takes the session lock (shared across
|
|
403
|
+
workers via the store), loads the session, resolves idempotent retries and
|
|
404
|
+
applies the loop guards. Then either :attr:`replay` holds a reply to return
|
|
405
|
+
immediately (an idempotent retry), or the caller dispatches :attr:`ctx` and
|
|
406
|
+
finishes with :meth:`complete` (success) or :meth:`fail` (error). Always call
|
|
407
|
+
:meth:`close` (e.g. in ``finally``) to release the lock.
|
|
408
|
+
"""
|
|
409
|
+
|
|
410
|
+
def __init__(
|
|
411
|
+
self,
|
|
412
|
+
server: WAPServer,
|
|
413
|
+
message: AgentMessage,
|
|
414
|
+
*,
|
|
415
|
+
owner_key: str | None,
|
|
416
|
+
principal_key: str | None,
|
|
417
|
+
client_ip: str | None,
|
|
418
|
+
agent_key: str | None,
|
|
419
|
+
principal: Any,
|
|
420
|
+
tier: str,
|
|
421
|
+
conversation_policy: ConversationPolicy | Literal[False] | None = None,
|
|
422
|
+
) -> None:
|
|
423
|
+
self.server = server
|
|
424
|
+
# A per-request override (from the admission hook) replaces the server's loop policy.
|
|
425
|
+
if conversation_policy is False:
|
|
426
|
+
self.policy: ConversationPolicy | None = None
|
|
427
|
+
else:
|
|
428
|
+
self.policy = conversation_policy or server.conversation_policy
|
|
429
|
+
self.message = message
|
|
430
|
+
self.owner_key = owner_key
|
|
431
|
+
self.principal_key = principal_key
|
|
432
|
+
self.client_ip = client_ip
|
|
433
|
+
self.agent_key = agent_key
|
|
434
|
+
self.principal = principal
|
|
435
|
+
self.tier = tier
|
|
436
|
+
self.ctx: ActionContext | None = None
|
|
437
|
+
self.replay: AgentMessage | None = None
|
|
438
|
+
self.started = time.perf_counter()
|
|
439
|
+
self._stack = AsyncExitStack()
|
|
440
|
+
self._idempotency_key: str | None = None
|
|
441
|
+
self._request_fp = request_fingerprint(message.capability_id, message.structured_data, message.content)
|
|
442
|
+
|
|
443
|
+
async def __aenter__(self) -> Turn:
|
|
444
|
+
await self.open()
|
|
445
|
+
return self
|
|
446
|
+
|
|
447
|
+
async def __aexit__(self, *exc_info: object) -> None:
|
|
448
|
+
await self.close()
|
|
449
|
+
|
|
450
|
+
def _principal_guard_key(self) -> str | None:
|
|
451
|
+
return f"guard:{self.principal_key}" if self.principal_key else None
|
|
452
|
+
|
|
453
|
+
async def open(self) -> None:
|
|
454
|
+
server, message = self.server, self.message
|
|
455
|
+
try:
|
|
456
|
+
await self._stack.enter_async_context(server.sessions.lock(message.session_id))
|
|
457
|
+
except LockTimeout as exc:
|
|
458
|
+
raise WAPProtocolError(
|
|
459
|
+
ErrorCode.SESSION_BUSY,
|
|
460
|
+
"another request for this session is still being processed; retry shortly",
|
|
461
|
+
retry_after=1.0,
|
|
462
|
+
) from exc
|
|
463
|
+
try:
|
|
464
|
+
session = await server.sessions.load(message.session_id, self.owner_key)
|
|
465
|
+
if message.idempotency_key:
|
|
466
|
+
self.replay = await self._resolve_idempotency(message.idempotency_key)
|
|
467
|
+
if self.replay is not None:
|
|
468
|
+
return
|
|
469
|
+
self._check_guards(session, await self._principal_guard())
|
|
470
|
+
self.ctx = ActionContext(
|
|
471
|
+
server=server,
|
|
472
|
+
message=message,
|
|
473
|
+
session=session,
|
|
474
|
+
client_ip=self.client_ip,
|
|
475
|
+
agent_key=self.agent_key,
|
|
476
|
+
principal=self.principal,
|
|
477
|
+
)
|
|
478
|
+
except BaseException:
|
|
479
|
+
await self.fail()
|
|
480
|
+
await self.close()
|
|
481
|
+
raise
|
|
482
|
+
|
|
483
|
+
async def _resolve_idempotency(self, key: str) -> AgentMessage | None:
|
|
484
|
+
scope = self.principal_key or self.owner_key or "anonymous"
|
|
485
|
+
self._idempotency_key = f"idem:{scope}:{key}"
|
|
486
|
+
pending = {"state": "pending", "fp": self._request_fp}
|
|
487
|
+
if await self.server.store.add(self._idempotency_key, pending, ttl=self.server.sessions.lock_timeout):
|
|
488
|
+
return None
|
|
489
|
+
record = await self.server.store.get(self._idempotency_key) or {}
|
|
490
|
+
claimed_key, self._idempotency_key = self._idempotency_key, None # never release someone else's claim
|
|
491
|
+
if record.get("fp") != self._request_fp:
|
|
492
|
+
raise WAPProtocolError(
|
|
493
|
+
ErrorCode.IDEMPOTENCY_CONFLICT,
|
|
494
|
+
"idempotency_key was already used for a different request",
|
|
495
|
+
details={"idempotency_key": key},
|
|
496
|
+
)
|
|
497
|
+
if record.get("state") != "done":
|
|
498
|
+
raise WAPProtocolError(
|
|
499
|
+
ErrorCode.IDEMPOTENCY_CONFLICT,
|
|
500
|
+
"the original request with this idempotency_key is still in progress; retry shortly",
|
|
501
|
+
retry_after=1.0,
|
|
502
|
+
details={"idempotency_key": key},
|
|
503
|
+
)
|
|
504
|
+
self.server.emit("request.idempotent_replay", capability=self.message.capability_id, key=claimed_key)
|
|
505
|
+
return self.server.reply(self.message, record.get("content", ""), record.get("data"))
|
|
506
|
+
|
|
507
|
+
async def _principal_guard(self) -> ConversationGuard | None:
|
|
508
|
+
policy = self.policy
|
|
509
|
+
key = self._principal_guard_key()
|
|
510
|
+
if policy is None or key is None:
|
|
511
|
+
return None
|
|
512
|
+
return ConversationGuard.from_dict(policy.across_sessions(), await self.server.store.get(key))
|
|
513
|
+
|
|
514
|
+
def _session_guard(self, session: SessionState) -> ConversationGuard | None:
|
|
515
|
+
if self.policy is None:
|
|
516
|
+
return None
|
|
517
|
+
if session.guard is None or session.guard.policy != self.policy:
|
|
518
|
+
data = session.guard.to_dict() if session.guard is not None else None
|
|
519
|
+
session.guard = ConversationGuard.from_dict(self.policy, data)
|
|
520
|
+
return session.guard
|
|
521
|
+
|
|
522
|
+
def _check_guards(self, session: SessionState, principal_guard: ConversationGuard | None) -> None:
|
|
523
|
+
for guard in (self._session_guard(session), principal_guard):
|
|
524
|
+
if guard is None:
|
|
525
|
+
continue
|
|
526
|
+
try:
|
|
527
|
+
guard.check(self._request_fp)
|
|
528
|
+
except ConversationLimitError as exc:
|
|
529
|
+
self.server.emit("request.rejected", code=exc.reason, tier=self.tier)
|
|
530
|
+
raise WAPProtocolError(ErrorCode(exc.reason), exc.message, details=exc.details) from exc
|
|
531
|
+
|
|
532
|
+
async def complete(self, content: str, data: dict[str, Any] | None) -> AgentMessage:
|
|
533
|
+
"""Sign the reply and persist session, guards and idempotency record."""
|
|
534
|
+
if self.ctx is None:
|
|
535
|
+
raise RuntimeError("turn was not opened for dispatch")
|
|
536
|
+
server = self.server
|
|
537
|
+
reply = server.reply(self.message, content, data)
|
|
538
|
+
session = self.ctx.session
|
|
539
|
+
reply_fp = reply_fingerprint(reply.content, reply.structured_data)
|
|
540
|
+
session_guard = self._session_guard(session)
|
|
541
|
+
if session_guard is not None:
|
|
542
|
+
session_guard.record(self._request_fp, reply_fp)
|
|
543
|
+
session.history.extend([self.message, reply])
|
|
544
|
+
try:
|
|
545
|
+
await server.sessions.save(session)
|
|
546
|
+
except TypeError as exc:
|
|
547
|
+
raise WAPProtocolError(ErrorCode.INTERNAL_ERROR, str(exc)) from exc
|
|
548
|
+
guard_key = self._principal_guard_key()
|
|
549
|
+
principal_guard = await self._principal_guard()
|
|
550
|
+
if principal_guard is not None and guard_key is not None:
|
|
551
|
+
principal_guard.record(self._request_fp, reply_fp)
|
|
552
|
+
await server.store.set(guard_key, principal_guard.to_dict(), ttl=principal_guard.policy.window_seconds)
|
|
553
|
+
if self._idempotency_key is not None:
|
|
554
|
+
await server.store.set(
|
|
555
|
+
self._idempotency_key,
|
|
556
|
+
{"state": "done", "fp": self._request_fp, "content": reply.content, "data": reply.structured_data},
|
|
557
|
+
ttl=server.idempotency_ttl_seconds,
|
|
558
|
+
)
|
|
559
|
+
self._idempotency_key = None
|
|
560
|
+
server.emit(
|
|
561
|
+
"request.completed",
|
|
562
|
+
capability=self.message.capability_id,
|
|
563
|
+
tier=self.tier,
|
|
564
|
+
duration_ms=round((time.perf_counter() - self.started) * 1000, 3),
|
|
565
|
+
)
|
|
566
|
+
return reply
|
|
567
|
+
|
|
568
|
+
async def fail(self, error: WAPProtocolError | None = None) -> None:
|
|
569
|
+
"""Release an idempotency claim so the client may retry after an error."""
|
|
570
|
+
if self._idempotency_key is not None:
|
|
571
|
+
await self.server.store.delete(self._idempotency_key)
|
|
572
|
+
self._idempotency_key = None
|
|
573
|
+
if error is not None:
|
|
574
|
+
self.server.emit(
|
|
575
|
+
"request.failed", code=error.code.value, capability=self.message.capability_id, tier=self.tier
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
async def close(self) -> None:
|
|
579
|
+
"""Release the session lock (and any unfinished idempotency claim). Safe to call twice."""
|
|
580
|
+
try:
|
|
581
|
+
if self._idempotency_key is not None:
|
|
582
|
+
await self.fail()
|
|
583
|
+
finally:
|
|
584
|
+
await self._stack.aclose()
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
class WAPServer:
|
|
588
|
+
"""A business agent: owns the signing key, capability registry and abuse controls.
|
|
589
|
+
|
|
590
|
+
Every protection is configurable; see ``docs/configuration.md``. The defaults suit a
|
|
591
|
+
single-worker server. For several workers or machines pass a shared ``store``
|
|
592
|
+
(e.g. :class:`~wap.storage.RedisStore`) so limits, replay protection and sessions
|
|
593
|
+
are shared.
|
|
594
|
+
"""
|
|
595
|
+
|
|
596
|
+
def __init__(
|
|
597
|
+
self,
|
|
598
|
+
name: str,
|
|
599
|
+
domain: str,
|
|
600
|
+
private_key: str | None = None,
|
|
601
|
+
require_pow: bool = False,
|
|
602
|
+
*,
|
|
603
|
+
previous_keys: list[str] | None = None,
|
|
604
|
+
description: str = "",
|
|
605
|
+
store: StateStore | None = None,
|
|
606
|
+
pow_difficulty: int = 4,
|
|
607
|
+
pow_ttl_seconds: float = 120.0,
|
|
608
|
+
pow_secret: bytes | None = None,
|
|
609
|
+
adaptive_pow: AdaptivePow | None = None,
|
|
610
|
+
rate_limit: RateLimitPolicy | Literal[False] | None = None,
|
|
611
|
+
ip_rate_limit: RateLimitPolicy | Literal[False] | None = None,
|
|
612
|
+
admission: AdmissionHook | None = None,
|
|
613
|
+
conversation_policy: ConversationPolicy | Literal[False] | None = None,
|
|
614
|
+
base_url: str | None = None,
|
|
615
|
+
manifest_ttl_seconds: float = 3600.0,
|
|
616
|
+
max_clock_skew_seconds: float = 300.0,
|
|
617
|
+
auth_handler: AuthHandler | None = None,
|
|
618
|
+
trust_forwarded_for: bool = False,
|
|
619
|
+
mcp_require_pow: bool | None = None,
|
|
620
|
+
session_ttl_seconds: float = 3600.0,
|
|
621
|
+
session_lock_timeout: float = 120.0,
|
|
622
|
+
session_lock_wait: float = 10.0,
|
|
623
|
+
idempotency_ttl_seconds: float = 86_400.0,
|
|
624
|
+
observer: Observer | None = None,
|
|
625
|
+
) -> None:
|
|
626
|
+
self.name = name
|
|
627
|
+
self.domain = normalize_authority(domain)
|
|
628
|
+
self.description = description
|
|
629
|
+
if private_key is None:
|
|
630
|
+
logger.warning(
|
|
631
|
+
"WAPServer %r started without a private key; using an ephemeral key. "
|
|
632
|
+
"Manifests will change identity on every restart. Run `wap keygen` for a persistent key.",
|
|
633
|
+
name,
|
|
634
|
+
)
|
|
635
|
+
self.signer = Signer(private_key)
|
|
636
|
+
# Keys this domain rotated away from; each endorses the new manifest so that clients
|
|
637
|
+
# which pinned an old key can follow the rotation. Remove them once clients have migrated.
|
|
638
|
+
self.previous_signers = [Signer(k) for k in (previous_keys or [])]
|
|
639
|
+
self.store: StateStore = store or MemoryStore()
|
|
640
|
+
self.require_pow = require_pow
|
|
641
|
+
# Derived from the signing key unless given, so every worker sharing the key
|
|
642
|
+
# accepts every other worker's challenges without extra configuration.
|
|
643
|
+
self.pow = PowEngine(
|
|
644
|
+
difficulty=pow_difficulty,
|
|
645
|
+
ttl_seconds=pow_ttl_seconds,
|
|
646
|
+
secret=pow_secret or self.signer.derive_secret("wap-pow-v1"),
|
|
647
|
+
)
|
|
648
|
+
self.adaptive_pow = adaptive_pow
|
|
649
|
+
# rate_limit: per-agent-key limits (False disables). ip_rate_limit: per-IP limits,
|
|
650
|
+
# defaulting to the same policy (False disables).
|
|
651
|
+
self.rate_limit_policy: RateLimitPolicy | None = (
|
|
652
|
+
None if rate_limit is False else (rate_limit or RateLimitPolicy())
|
|
653
|
+
)
|
|
654
|
+
if ip_rate_limit is False:
|
|
655
|
+
self.ip_rate_limit_policy: RateLimitPolicy | None = None
|
|
656
|
+
else:
|
|
657
|
+
self.ip_rate_limit_policy = ip_rate_limit or self.rate_limit_policy
|
|
658
|
+
self.rate_limiter = RateLimiter(self.rate_limit_policy or RateLimitPolicy(), self.store)
|
|
659
|
+
self.admission = admission
|
|
660
|
+
scheme = "http" if is_local_authority(self.domain) else "https"
|
|
661
|
+
self.base_url = (base_url or f"{scheme}://{self.domain}").rstrip("/")
|
|
662
|
+
self.manifest_ttl_seconds = manifest_ttl_seconds
|
|
663
|
+
self.max_clock_skew_seconds = max_clock_skew_seconds
|
|
664
|
+
self.auth_handler = auth_handler
|
|
665
|
+
self.trust_forwarded_for = trust_forwarded_for
|
|
666
|
+
# None: the MCP endpoint inherits require_pow. False lets generic MCP clients (which cannot
|
|
667
|
+
# solve challenges) call tools, relying on rate limits alone.
|
|
668
|
+
self.mcp_require_pow = mcp_require_pow
|
|
669
|
+
self.mcp_path: str | None = None
|
|
670
|
+
self._mcp_sources: list[Any] = []
|
|
671
|
+
self.actions: dict[str, RegisteredAction] = {}
|
|
672
|
+
# Loop protection is on by default; pass ConversationPolicy(...) to tune it or False to disable.
|
|
673
|
+
self.conversation_policy: ConversationPolicy | None = (
|
|
674
|
+
None if conversation_policy is False else (conversation_policy or ConversationPolicy())
|
|
675
|
+
)
|
|
676
|
+
self.sessions = SessionManager(
|
|
677
|
+
self.store,
|
|
678
|
+
ttl_seconds=session_ttl_seconds,
|
|
679
|
+
conversation_policy=self.conversation_policy,
|
|
680
|
+
lock_timeout=session_lock_timeout,
|
|
681
|
+
lock_wait=session_lock_wait,
|
|
682
|
+
)
|
|
683
|
+
self.idempotency_ttl_seconds = idempotency_ttl_seconds
|
|
684
|
+
self.observer = observer
|
|
685
|
+
self._intent_handler: Callable[[str, ActionContext], Any] = KeywordIntentRouter()
|
|
686
|
+
self._manifest: AgentManifest | None = None
|
|
687
|
+
|
|
688
|
+
def emit(self, event: str, **attributes: Any) -> None:
|
|
689
|
+
"""Report an event to the configured observer (metrics, logs, tracing). Never raises."""
|
|
690
|
+
if self.observer is None:
|
|
691
|
+
return
|
|
692
|
+
try:
|
|
693
|
+
self.observer(event, attributes)
|
|
694
|
+
except Exception: # noqa: BLE001 - observability must not break requests
|
|
695
|
+
logger.exception("observer failed for event %r", event)
|
|
696
|
+
|
|
697
|
+
# ------------------------------------------------------------------ registry
|
|
698
|
+
|
|
699
|
+
@property
|
|
700
|
+
def public_key(self) -> str:
|
|
701
|
+
return self.signer.public_key
|
|
702
|
+
|
|
703
|
+
def action(
|
|
704
|
+
self,
|
|
705
|
+
name: str | None = None,
|
|
706
|
+
description: str | None = None,
|
|
707
|
+
*,
|
|
708
|
+
title: str | None = None,
|
|
709
|
+
requires_auth: bool = False,
|
|
710
|
+
effects: Literal["read", "write", "financial"] = "write",
|
|
711
|
+
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
712
|
+
"""Register a function as a capability. ``name`` becomes the capability id.
|
|
713
|
+
|
|
714
|
+
``effects`` tells clients what calling it does: ``"read"`` (no side effects; safe to
|
|
715
|
+
retry and never needs confirmation), ``"write"`` (changes state, e.g. a booking) or
|
|
716
|
+
``"financial"`` (moves money). The default is the conservative ``"write"``.
|
|
717
|
+
"""
|
|
718
|
+
|
|
719
|
+
caller_locals = dict(sys._getframe(1).f_locals)
|
|
720
|
+
|
|
721
|
+
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
722
|
+
capability_id = name or func.__name__
|
|
723
|
+
if capability_id in self.actions:
|
|
724
|
+
raise ValueError(f"capability {capability_id!r} is already registered")
|
|
725
|
+
if requires_auth and self.auth_handler is None:
|
|
726
|
+
raise ValueError(f"capability {capability_id!r} requires auth but WAPServer has no auth_handler")
|
|
727
|
+
self.actions[capability_id] = _build_action(
|
|
728
|
+
func,
|
|
729
|
+
capability_id=capability_id,
|
|
730
|
+
title=title,
|
|
731
|
+
description=description,
|
|
732
|
+
requires_auth=requires_auth,
|
|
733
|
+
effects=effects,
|
|
734
|
+
localns=caller_locals,
|
|
735
|
+
)
|
|
736
|
+
self._manifest = None
|
|
737
|
+
return func
|
|
738
|
+
|
|
739
|
+
return decorator
|
|
740
|
+
|
|
741
|
+
def add_capability(self, capability: Capability, handler: Callable[[dict[str, Any], ActionContext], Any]) -> None:
|
|
742
|
+
"""Register a capability with an explicit JSON Schema and a ``handler(payload, ctx)`` callable."""
|
|
743
|
+
if capability.id in self.actions:
|
|
744
|
+
raise ValueError(f"capability {capability.id!r} is already registered")
|
|
745
|
+
if capability.requires_auth and self.auth_handler is None:
|
|
746
|
+
raise ValueError(f"capability {capability.id!r} requires auth but WAPServer has no auth_handler")
|
|
747
|
+
try:
|
|
748
|
+
Draft202012Validator.check_schema(capability.input_schema)
|
|
749
|
+
except SchemaError as exc:
|
|
750
|
+
raise ValueError(f"capability {capability.id!r} has an invalid input_schema: {exc.message}") from exc
|
|
751
|
+
self.actions[capability.id] = RegisteredAction(
|
|
752
|
+
func=handler, capability=capability, input_model=None, context_param=None, model_param=None
|
|
753
|
+
)
|
|
754
|
+
self._manifest = None
|
|
755
|
+
|
|
756
|
+
def include_mcp(self, source: Any, *, prefix: str = "", include: set[str] | None = None) -> None:
|
|
757
|
+
"""Re-publish an existing MCP server's tools as WAP capabilities when the app starts.
|
|
758
|
+
|
|
759
|
+
``source`` is anything ``mcp.client.client.Client`` accepts (a server object, a
|
|
760
|
+
streamable-HTTP URL, or ``StdioServerParameters``). See :mod:`wap.server.mcp_import`.
|
|
761
|
+
"""
|
|
762
|
+
from .mcp_import import MCPToolSource
|
|
763
|
+
|
|
764
|
+
self._mcp_sources.append(MCPToolSource(source, prefix=prefix, include=include))
|
|
765
|
+
|
|
766
|
+
async def import_mcp(self, source: Any, *, prefix: str = "", include: set[str] | None = None) -> Any:
|
|
767
|
+
"""Connect to an MCP server now and register its tools. Returns the live ``MCPToolSource``;
|
|
768
|
+
call its ``aclose()`` when done."""
|
|
769
|
+
from .mcp_import import MCPToolSource
|
|
770
|
+
|
|
771
|
+
tool_source = MCPToolSource(source, prefix=prefix, include=include)
|
|
772
|
+
await tool_source.register(self)
|
|
773
|
+
return tool_source
|
|
774
|
+
|
|
775
|
+
def intent(self, func: Callable[[str, ActionContext], Any]) -> Callable[[str, ActionContext], Any]:
|
|
776
|
+
"""Register the handler for free-text intents (no ``capability_id``), e.g. an LLM router."""
|
|
777
|
+
self._intent_handler = func
|
|
778
|
+
return func
|
|
779
|
+
|
|
780
|
+
# ------------------------------------------------------------------ manifest
|
|
781
|
+
|
|
782
|
+
def build_manifest(self, now: float | None = None) -> AgentManifest:
|
|
783
|
+
now = time.time() if now is None else now
|
|
784
|
+
manifest = AgentManifest(
|
|
785
|
+
domain=self.domain,
|
|
786
|
+
name=self.name,
|
|
787
|
+
description=self.description,
|
|
788
|
+
public_key=self.public_key,
|
|
789
|
+
interaction_url=self.base_url + INTERACT_PATH,
|
|
790
|
+
challenge_url=self.base_url + CHALLENGE_PATH if self.require_pow else None,
|
|
791
|
+
mcp_url=self.base_url + self.mcp_path if self.mcp_path else None,
|
|
792
|
+
capabilities=[a.capability for a in self.actions.values()],
|
|
793
|
+
pow_required=self.require_pow,
|
|
794
|
+
pow_difficulty=self.pow.difficulty if self.require_pow else None,
|
|
795
|
+
rate_limit_policy=self.rate_limit_policy.model_dump() if self.rate_limit_policy else {},
|
|
796
|
+
conversation_policy=self.conversation_policy.as_dict() if self.conversation_policy else None,
|
|
797
|
+
issued_at=now,
|
|
798
|
+
expires_at=now + self.manifest_ttl_seconds,
|
|
799
|
+
previous_keys=[s.public_key for s in self.previous_signers],
|
|
800
|
+
)
|
|
801
|
+
if self.previous_signers:
|
|
802
|
+
payload = endorsement_payload(manifest)
|
|
803
|
+
manifest = manifest.model_copy(
|
|
804
|
+
update={"key_endorsements": {s.public_key: s.sign(payload) for s in self.previous_signers}}
|
|
805
|
+
)
|
|
806
|
+
return self.signer.sign_model(manifest)
|
|
807
|
+
|
|
808
|
+
def manifest(self) -> AgentManifest:
|
|
809
|
+
"""The current signed manifest, re-signed once half its lifetime has elapsed."""
|
|
810
|
+
now = time.time()
|
|
811
|
+
current = self._manifest
|
|
812
|
+
if current is None or current.expires_at is None or now >= current.issued_at + self.manifest_ttl_seconds / 2:
|
|
813
|
+
current = self._manifest = self.build_manifest(now)
|
|
814
|
+
return current
|
|
815
|
+
|
|
816
|
+
# ------------------------------------------------------------------ dispatch
|
|
817
|
+
|
|
818
|
+
# ------------------------------------------------------------------ admission
|
|
819
|
+
|
|
820
|
+
@staticmethod
|
|
821
|
+
def _rate_limited(decision: RateLimitDecision) -> WAPProtocolError:
|
|
822
|
+
return WAPProtocolError(
|
|
823
|
+
ErrorCode.RATE_LIMITED,
|
|
824
|
+
f"rate limit exceeded for {decision.scope or 'client'}",
|
|
825
|
+
retry_after=round(decision.retry_after, 3),
|
|
826
|
+
details={"scope": decision.scope, "limit_per_minute": decision.limit},
|
|
827
|
+
headers=decision.headers(),
|
|
828
|
+
)
|
|
829
|
+
|
|
830
|
+
async def limit_ip(self, ip: str | None) -> dict[str, str]:
|
|
831
|
+
"""Per-IP rate limit (cheapest check, first in the pipeline). Returns response headers."""
|
|
832
|
+
policy = self.ip_rate_limit_policy
|
|
833
|
+
if policy is None or not ip or "ip" not in policy.scopes:
|
|
834
|
+
return {}
|
|
835
|
+
decision = await self.rate_limiter.check_keys([("ip", f"ip:{ip}")], policy=policy)
|
|
836
|
+
if not decision.allowed:
|
|
837
|
+
self.emit("request.rejected", code="rate_limited", scope="ip")
|
|
838
|
+
raise self._rate_limited(decision)
|
|
839
|
+
return decision.headers()
|
|
840
|
+
|
|
841
|
+
async def check_replay(self, message: AgentMessage) -> None:
|
|
842
|
+
"""Freshness window and (agent key, message id) de-duplication, shared via the store."""
|
|
843
|
+
now = time.time()
|
|
844
|
+
if abs(now - message.timestamp) > self.max_clock_skew_seconds:
|
|
845
|
+
raise WAPProtocolError(
|
|
846
|
+
ErrorCode.REPLAY_DETECTED,
|
|
847
|
+
f"message timestamp is outside the accepted window (±{int(self.max_clock_skew_seconds)}s)",
|
|
848
|
+
details={"server_time": now},
|
|
849
|
+
)
|
|
850
|
+
key = f"replay:{message.public_key}:{message.message_id}"
|
|
851
|
+
if not await self.store.add(key, 1, ttl=2 * self.max_clock_skew_seconds):
|
|
852
|
+
raise WAPProtocolError(ErrorCode.REPLAY_DETECTED, "message_id has already been processed")
|
|
853
|
+
|
|
854
|
+
async def issue_challenge(self) -> Challenge:
|
|
855
|
+
difficulty = self.pow.difficulty
|
|
856
|
+
if self.adaptive_pow is not None:
|
|
857
|
+
difficulty = await self.adaptive_pow.difficulty(self.store, difficulty, record=True)
|
|
858
|
+
self.emit("pow.issued", difficulty=difficulty)
|
|
859
|
+
return self.pow.issue(difficulty=difficulty)
|
|
860
|
+
|
|
861
|
+
async def verify_pow(self, seed: str | None, nonce: str | None, *, min_difficulty: int | None = None) -> None:
|
|
862
|
+
"""Check a proof-of-work solution and mark its seed spent in the shared store."""
|
|
863
|
+
if seed is None or nonce is None:
|
|
864
|
+
raise WAPProtocolError(
|
|
865
|
+
ErrorCode.POW_REQUIRED,
|
|
866
|
+
"this agent requires proof-of-work; solve the attached challenge and resend",
|
|
867
|
+
details={"challenge": (await self.issue_challenge()).model_dump(mode="json")},
|
|
868
|
+
)
|
|
869
|
+
try:
|
|
870
|
+
result = self.pow.check(seed, nonce, min_difficulty=min_difficulty)
|
|
871
|
+
if not await self.store.add(f"pow:{seed}", 1, ttl=max(1.0, result.expires_at - time.time())):
|
|
872
|
+
raise PowReplayed("challenge has already been used")
|
|
873
|
+
except PowError as exc:
|
|
874
|
+
self.emit("request.rejected", code="pow_invalid", reason=exc.reason)
|
|
875
|
+
raise WAPProtocolError(
|
|
876
|
+
ErrorCode.POW_INVALID,
|
|
877
|
+
f"proof-of-work rejected: {exc}",
|
|
878
|
+
details={"reason": exc.reason, "challenge": (await self.issue_challenge()).model_dump(mode="json")},
|
|
879
|
+
) from exc
|
|
880
|
+
|
|
881
|
+
def pow_required_for(self, transport: str, decision: AdmissionDecision) -> bool:
|
|
882
|
+
if decision.require_pow is not None:
|
|
883
|
+
return decision.require_pow
|
|
884
|
+
if transport == "mcp" and self.mcp_require_pow is not None:
|
|
885
|
+
return self.mcp_require_pow
|
|
886
|
+
return self.require_pow
|
|
887
|
+
|
|
888
|
+
async def admit(
|
|
889
|
+
self,
|
|
890
|
+
request: AdmissionRequest,
|
|
891
|
+
*,
|
|
892
|
+
pow_seed: str | None = None,
|
|
893
|
+
pow_nonce: str | None = None,
|
|
894
|
+
) -> tuple[AdmissionDecision, dict[str, str]]:
|
|
895
|
+
"""Run the admission hook, per-key rate limit and proof-of-work for a verified request."""
|
|
896
|
+
decision = await evaluate_admission(self.admission, request)
|
|
897
|
+
if decision.deny:
|
|
898
|
+
self.emit("request.rejected", code="forbidden", tier=decision.tier)
|
|
899
|
+
raise WAPProtocolError(ErrorCode.FORBIDDEN, decision.deny)
|
|
900
|
+
headers: dict[str, str] = {}
|
|
901
|
+
policy = self.rate_limit_policy if decision.rate_limit is None else (decision.rate_limit or None)
|
|
902
|
+
if policy is not None and request.agent_key and "agent_key" in policy.scopes:
|
|
903
|
+
limit = await self.rate_limiter.check_keys([("agent_key", f"key:{request.agent_key}")], policy=policy)
|
|
904
|
+
if not limit.allowed:
|
|
905
|
+
self.emit("request.rejected", code="rate_limited", scope="agent_key", tier=decision.tier)
|
|
906
|
+
raise self._rate_limited(limit)
|
|
907
|
+
headers = limit.headers()
|
|
908
|
+
if self.pow_required_for(request.transport, decision):
|
|
909
|
+
await self.verify_pow(pow_seed, pow_nonce, min_difficulty=decision.pow_difficulty)
|
|
910
|
+
return decision, headers
|
|
911
|
+
|
|
912
|
+
# ------------------------------------------------------------------ turns
|
|
913
|
+
|
|
914
|
+
def open_turn(
|
|
915
|
+
self,
|
|
916
|
+
message: AgentMessage,
|
|
917
|
+
*,
|
|
918
|
+
owner_key: str | None,
|
|
919
|
+
principal_key: str | None,
|
|
920
|
+
client_ip: str | None = None,
|
|
921
|
+
agent_key: str | None = None,
|
|
922
|
+
principal: Any = None,
|
|
923
|
+
tier: str = "default",
|
|
924
|
+
conversation_policy: ConversationPolicy | Literal[False] | None = None,
|
|
925
|
+
) -> Turn:
|
|
926
|
+
"""Begin one request/response turn (session lock, idempotency, loop guard). See :class:`Turn`."""
|
|
927
|
+
return Turn(
|
|
928
|
+
self,
|
|
929
|
+
message,
|
|
930
|
+
owner_key=owner_key,
|
|
931
|
+
principal_key=principal_key,
|
|
932
|
+
client_ip=client_ip,
|
|
933
|
+
agent_key=agent_key,
|
|
934
|
+
principal=principal,
|
|
935
|
+
tier=tier,
|
|
936
|
+
conversation_policy=conversation_policy,
|
|
937
|
+
)
|
|
938
|
+
|
|
939
|
+
async def authenticate(self, authorization: str | None) -> Any:
|
|
940
|
+
if self.auth_handler is None or not authorization:
|
|
941
|
+
return None
|
|
942
|
+
scheme, _, token = authorization.partition(" ")
|
|
943
|
+
if scheme.lower() != "bearer" or not token:
|
|
944
|
+
raise WAPProtocolError(ErrorCode.AUTH_REQUIRED, "Authorization must use the Bearer scheme")
|
|
945
|
+
principal = self.auth_handler(token.strip())
|
|
946
|
+
if inspect.isawaitable(principal):
|
|
947
|
+
principal = await principal
|
|
948
|
+
if principal is None:
|
|
949
|
+
raise WAPProtocolError(ErrorCode.AUTH_REQUIRED, "bearer token was rejected")
|
|
950
|
+
return principal
|
|
951
|
+
|
|
952
|
+
async def run_capability(
|
|
953
|
+
self, capability_id: str, payload: dict[str, Any], ctx: ActionContext
|
|
954
|
+
) -> AsyncIterator[Chunk]:
|
|
955
|
+
action = self.actions.get(capability_id)
|
|
956
|
+
if action is None:
|
|
957
|
+
raise WAPProtocolError(
|
|
958
|
+
ErrorCode.UNKNOWN_CAPABILITY,
|
|
959
|
+
f"unknown capability {capability_id!r}",
|
|
960
|
+
details={"available": sorted(self.actions)},
|
|
961
|
+
)
|
|
962
|
+
if action.capability.requires_auth and ctx.principal is None:
|
|
963
|
+
raise WAPProtocolError(ErrorCode.AUTH_REQUIRED, f"capability {capability_id!r} requires authorization")
|
|
964
|
+
permitted = ctx.message.max_effects
|
|
965
|
+
effects = action.capability.effects
|
|
966
|
+
if permitted is not None and EFFECT_RANK[effects] > EFFECT_RANK[permitted]:
|
|
967
|
+
raise WAPProtocolError(
|
|
968
|
+
ErrorCode.EFFECTS_NOT_PERMITTED,
|
|
969
|
+
f"{action.capability.name} has {effects!r} effects but this request only permits {permitted!r}; "
|
|
970
|
+
"ask the user to confirm, then call the capability directly",
|
|
971
|
+
details={"capability_id": capability_id, "effects": effects, "payload": payload},
|
|
972
|
+
)
|
|
973
|
+
result = await action.call(payload, ctx)
|
|
974
|
+
async for chunk in iterate_result(result, default_content=f"{action.capability.name} completed."):
|
|
975
|
+
yield chunk
|
|
976
|
+
|
|
977
|
+
async def dispatch(self, ctx: ActionContext) -> AsyncIterator[Chunk]:
|
|
978
|
+
"""Route a verified user-agent message to a capability or the intent handler."""
|
|
979
|
+
message = ctx.message
|
|
980
|
+
try:
|
|
981
|
+
if message.capability_id:
|
|
982
|
+
async for chunk in self.run_capability(message.capability_id, message.structured_data or {}, ctx):
|
|
983
|
+
yield chunk
|
|
984
|
+
else:
|
|
985
|
+
result = self._intent_handler(message.content, ctx)
|
|
986
|
+
async for chunk in iterate_result(result):
|
|
987
|
+
yield chunk
|
|
988
|
+
except WAPProtocolError:
|
|
989
|
+
raise
|
|
990
|
+
except Exception as exc:
|
|
991
|
+
logger.exception("capability %r failed", message.capability_id or "<intent>")
|
|
992
|
+
raise WAPProtocolError(
|
|
993
|
+
ErrorCode.ACTION_FAILED, f"the business agent failed to process the request: {exc}"
|
|
994
|
+
) from exc
|
|
995
|
+
|
|
996
|
+
def reply(self, request: AgentMessage, content: str, data: dict[str, Any] | None) -> AgentMessage:
|
|
997
|
+
"""Build and sign the final business-agent message for ``request``."""
|
|
998
|
+
message = AgentMessage(
|
|
999
|
+
session_id=request.session_id,
|
|
1000
|
+
role=Role.BUSINESS_AGENT,
|
|
1001
|
+
content=content,
|
|
1002
|
+
capability_id=request.capability_id,
|
|
1003
|
+
structured_data=data,
|
|
1004
|
+
in_reply_to=request.message_id,
|
|
1005
|
+
public_key=self.public_key,
|
|
1006
|
+
)
|
|
1007
|
+
return self.signer.sign_model(message)
|
|
1008
|
+
|
|
1009
|
+
# ------------------------------------------------------------------ ASGI
|
|
1010
|
+
|
|
1011
|
+
def mount(self, app: FastAPI, *, mcp: bool | None = None, mcp_path: str = "/mcp") -> FastAPI:
|
|
1012
|
+
"""Attach WAP endpoints to a FastAPI app, plus a standard MCP endpoint at ``mcp_path``.
|
|
1013
|
+
|
|
1014
|
+
``mcp=None`` (default) enables the MCP endpoint when the ``mcp`` package is installed;
|
|
1015
|
+
``True`` requires it; ``False`` disables it.
|
|
1016
|
+
"""
|
|
1017
|
+
from .middleware import inject_routes
|
|
1018
|
+
|
|
1019
|
+
inject_routes(app, self)
|
|
1020
|
+
if self._mcp_sources and not getattr(app.state, "wap_mcp_sources", False):
|
|
1021
|
+
app.state.wap_mcp_sources = True
|
|
1022
|
+
original = app.router.lifespan_context
|
|
1023
|
+
sources = self._mcp_sources
|
|
1024
|
+
|
|
1025
|
+
@asynccontextmanager
|
|
1026
|
+
async def lifespan(asgi_app: Any) -> AsyncIterator[Any]:
|
|
1027
|
+
try:
|
|
1028
|
+
for source in sources:
|
|
1029
|
+
await source.register(self)
|
|
1030
|
+
async with original(asgi_app) as state:
|
|
1031
|
+
yield state
|
|
1032
|
+
finally:
|
|
1033
|
+
for source in sources:
|
|
1034
|
+
await source.aclose()
|
|
1035
|
+
|
|
1036
|
+
app.router.lifespan_context = lifespan
|
|
1037
|
+
if mcp is None:
|
|
1038
|
+
try:
|
|
1039
|
+
import mcp as _mcp_sdk # noqa: F401
|
|
1040
|
+
except ImportError:
|
|
1041
|
+
mcp = False
|
|
1042
|
+
else:
|
|
1043
|
+
mcp = True
|
|
1044
|
+
if mcp and getattr(app.state, "wap_mcp", None) is None:
|
|
1045
|
+
from .mcp_endpoint import mount_mcp
|
|
1046
|
+
|
|
1047
|
+
app.state.wap_mcp = mount_mcp(app, self, mcp_path)
|
|
1048
|
+
self.mcp_path = mcp_path
|
|
1049
|
+
self._manifest = None
|
|
1050
|
+
return app
|
|
1051
|
+
|
|
1052
|
+
def create_app(self, *, mcp: bool | None = None, **fastapi_kwargs: Any) -> FastAPI:
|
|
1053
|
+
"""Create a standalone FastAPI application serving only this agent."""
|
|
1054
|
+
from fastapi import FastAPI
|
|
1055
|
+
|
|
1056
|
+
fastapi_kwargs.setdefault("title", f"{self.name} (WAP/1.0)")
|
|
1057
|
+
return self.mount(FastAPI(**fastapi_kwargs), mcp=mcp)
|
|
1058
|
+
|
|
1059
|
+
|
|
1060
|
+
__all__ = [
|
|
1061
|
+
"ActionContext",
|
|
1062
|
+
"ActionResult",
|
|
1063
|
+
"KeywordIntentRouter",
|
|
1064
|
+
"Observer",
|
|
1065
|
+
"RegisteredAction",
|
|
1066
|
+
"SessionState",
|
|
1067
|
+
"Turn",
|
|
1068
|
+
"WAPProtocolError",
|
|
1069
|
+
"WAPServer",
|
|
1070
|
+
"iterate_result",
|
|
1071
|
+
]
|