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/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
+ ]