diff --git a/.gitignore b/.gitignore index 7baa14a..e8c402b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ # Byte-compiled / optimized / DLL files __pycache__/ -*.py[codz] +*.py[cod] *$py.class # C extensions @@ -20,15 +20,11 @@ parts/ sdist/ var/ wheels/ -share/python-wheels/ *.egg-info/ .installed.cfg *.egg -MANIFEST # PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec @@ -46,110 +42,16 @@ htmlcov/ nosetests.xml coverage.xml *.cover -*.py.cover +*.py,cover .hypothesis/ .pytest_cache/ -cover/ # Translations *.mo *.pot -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -# Pipfile.lock - -# UV -# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# uv.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -# poetry.lock -# poetry.toml - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. -# https://pdm-project.org/en/latest/usage/project/#working-with-version-control -# pdm.lock -# pdm.toml -.pdm-python -.pdm-build/ - -# pixi -# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. -# pixi.lock -# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one -# in the .venv directory. It is recommended not to include this directory in version control. -.pixi - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# Redis -*.rdb -*.aof -*.pid - -# RabbitMQ -mnesia/ -rabbitmq/ -rabbitmq-data/ - -# ActiveMQ -activemq-data/ - -# SageMath parsed files -*.sage.py - # Environments .env -.envrc .venv env/ venv/ @@ -157,62 +59,20 @@ ENV/ env.bak/ venv.bak/ -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site +# IDE +.idea/ +.vscode/ +*.swp +*.swo # mypy .mypy_cache/ .dmypy.json dmypy.json -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -# .idea/ - -# Abstra -# Abstra is an AI-powered process automation framework. -# Ignore directories containing user credentials, local state, and settings. -# Learn more at https://abstra.io/docs -.abstra/ - -# Visual Studio Code -# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore -# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore -# and can be added to the global gitignore or merged into this file. However, if you prefer, -# you could uncomment the following to ignore the entire vscode folder -# .vscode/ - -# Ruff stuff: +# ruff .ruff_cache/ -# PyPI configuration file -.pypirc - -# Marimo -marimo/_static/ -marimo/_lsp/ -__marimo__/ - -# Streamlit -.streamlit/secrets.toml - +# OS .DS_Store +Thumbs.db diff --git a/CHANGELOG.md b/CHANGELOG.md index 2882ada..fc6c8d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,46 @@ All notable changes to the OpenIntent SDK will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.14.0] - 2026-02-25 + +### Added + +- **RFC-0022: Federation Protocol** — Complete federation contract specification for cross-server agent coordination. Defines: federation envelope format, agent visibility (public/unlisted/private), peer relationships (peer/upstream/downstream), callbacks with at-least-once delivery, intent authority model, delegation scope with UCAN-style attenuation, governance propagation (strictest-wins), federation attestation (OpenTelemetry conventions), discovery via `/.well-known/openintent-federation.json`, federation-aware leasing, and transport bindings (HTTP REST primary, NATS/gRPC alternatives). +- **RFC-0023: Federation Security** — Authentication, authorization, and verification layer for federation. Defines: server identity via did:web, signed envelopes (HTTP Message Signatures, RFC 9421), delegation tokens (UCAN with attenuation), trust policies (open/allowlist/trustless), agent access policies, signed attestations, and cross-server event log reconciliation via RFC-0019 Merkle primitives. +- **Python SDK Federation Implementation (RFC-0022 & RFC-0023)** — Full 5-layer federation support: + - **Layer 1 — Models** (`openintent/federation/models.py`): `FederationEnvelope`, `FederationCallback`, `FederationPolicy`, `FederationAttestation`, `DelegationScope`, `FederationManifest`, `FederationStatus`, `DispatchResult`, `ReceiveResult`, `FederatedAgent`, `PeerInfo`. Enums: `AgentVisibility`, `PeerRelationship`, `TrustPolicy`, `CallbackEventType`, `DispatchStatus`. All with `to_dict()`/`from_dict()` serialization. + - **Layer 2 — Client methods** (`openintent/client.py`): `federation_status()`, `list_federated_agents()`, `federation_dispatch()`, `federation_receive()`, `send_federation_callback()`, `federation_discover()`. Both sync (`OpenIntentClient`) and async (`AsyncOpenIntentClient`) variants. + - **Layer 3 — Server endpoints** (`openintent/server/federation.py`): FastAPI router with `GET /api/v1/federation/status`, `GET /api/v1/federation/agents`, `POST /api/v1/federation/dispatch`, `POST /api/v1/federation/receive`, `GET /.well-known/openintent-federation.json`, `GET /.well-known/did.json`. SSRF validation on outbound URLs, callback delivery with retry, governance enforcement, idempotency key handling. + - **Layer 4 — Security** (`openintent/federation/security.py`): `ServerIdentity` (Ed25519 key pairs, did:web identifiers, DID document generation), `sign_envelope()`/`verify_envelope_signature()`, `MessageSignature` (RFC 9421 HTTP Message Signatures), `TrustEnforcer` (open/allowlist/trustless policy enforcement), `UCANToken` (delegation token creation, encoding, decoding, attenuation, expiry checks), `resolve_did_web()`, `validate_ssrf()`. + - **Layer 5 — Decorators** (`openintent/federation/decorators.py`): `@Federation` class decorator for server configuration (identity, trust_policy, peers, visibility_default). `federation_visibility` parameter on `@Agent`. `federation_policy` parameter on `@Coordinator`. Lifecycle hooks: `@on_federation_received`, `@on_federation_callback`, `@on_budget_warning`. +- **Federation Dispatch (Express.js)** — 4 REST endpoints for cross-server intent dispatch: `GET /api/v1/federation/status`, `GET /api/v1/federation/agents`, `POST /api/v1/federation/dispatch`, `POST /api/v1/federation/receive`. Federation audit trail with dispatch IDs, provenance in `state._federation`, and RFC-0020 trace propagation. +- **Federation MCP Tools** — 4 new MCP tools: `federation_status` (read), `list_federated_agents` (read), `federation_dispatch` (admin), `federation_receive` (admin). MCP tool surface expanded from 62 to 66 tools; RBAC counts: reader=23, operator=40, admin=66. +- **Agent Lifecycle (RFC-0016)** — Registration with atomic upsert, heartbeat protocol, graceful drain, and status management across 5 REST endpoints. +- **Federation event types** in `EventType` enum: `FEDERATION_DISPATCHED`, `FEDERATION_RECEIVED`, `FEDERATION_CALLBACK`, `FEDERATION_BUDGET_WARNING`, `FEDERATION_COMPLETED`, `FEDERATION_FAILED`. +- **DelegationScope.attenuate()** — Scope narrowing per hop: intersection of permissions, union of denied operations, minimum delegation depth. +- **FederationPolicy.compose_strictest()** — Strictest-wins governance composition: minimum for numerics, OR for booleans, merge for observability. +- **Discovery endpoints** updated: `/.well-known/openintent.json` includes `federation` capability and RFC-0022/0023 in `rfcUrls`. `/.well-known/openintent-compat.json` includes RFC-0022 (full) and RFC-0023 (partial) compliance. +- **Schema: `origin_server_url`** — New field on `agent_records` table marking federated agents. +- **82 federation tests** covering models serialization, security (sign/verify, UCAN, SSRF, trust enforcement), server endpoints, decorators, and integration flows. + +### Fixed + +- **Agent Registration Race Condition** — Replaced two-step check-then-insert with atomic `INSERT ... ON CONFLICT DO UPDATE`. Version increments atomically via SQL expression. +- **Response Field Naming** — All agent endpoints now return `metadata` (not `agent_metadata`) for consistency with the Python SDK. + +### Security + +- **SSRF Protection on Federation** — `origin_server_url` validated at registration and dispatch. Blocks private IPs, metadata endpoints, internal hostnames, and non-HTTP schemes. +- **Federation Timeout** — 10-second timeout on remote dispatch calls. Returns 502 on failure. +- **Loop Prevention** — Cannot dispatch to local agents or receive for federated agents. + +### Changed + +- RFC count increased from 22 to 23. +- All version references updated to 0.14.0 across Python SDK, MCP server, and documentation. + +--- + ## [0.13.5] - 2026-02-14 ### Added @@ -18,6 +58,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Anthropic Streaming Usage (Round 2)** — Fixed `_resolve_usage()` in the Anthropic adapter which short-circuited via `if self._usage is not None: return` when `_consume_events` captured partial usage (e.g. `input_tokens` from `message_start` but `output_tokens` stuck at 0 due to early generator exit before `message_delta`). Now always calls `get_final_message()` on the underlying Anthropic `MessageStream` as the primary authoritative source, falling back to event-captured data only if `get_final_message()` fails. Also calls the raw SDK stream directly to avoid duplicate tool-block processing. +- **LLM Engine Streaming Usage** — Added `_last_stream_usage` to `LLMEngine` and wired usage capture into both `_iter_anthropic_stream` and `_stream_raw_provider` for Anthropic. After text iteration completes, calls `get_final_message()` on the underlying stream to populate usage data, making token counts available even when streaming without the adapter wrapper. - **MCP Startup Tool Count** — Fixed tool count mismatch where RBAC security tiers listed 62 tools but only 58 tool definitions existed. Startup log now correctly reports `tools=62/62` for admin role. - **RBAC Tier Correction** — `operator` role count corrected from 37 to 38 across changelog entries and documentation. diff --git a/docs/api/federation.md b/docs/api/federation.md new file mode 100644 index 0000000..94e80dc --- /dev/null +++ b/docs/api/federation.md @@ -0,0 +1,778 @@ +# Federation API Reference + +Cross-server agent coordination (RFC-0022) with cryptographic security (RFC-0023). + +!!! tip "Quick setup" + Use `@Federation` on your server class and `federation_visibility=` on `@Agent` to get started. See the [Federation Guide](../guide/federation.md) for a walkthrough. + +## Federation Decorator + +### Federation + +`@Federation(server=, identity=, key_path=, visibility_default=, trust_policy=, peers=, server_url=)` — class decorator that configures a server for cross-server federation. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `server` | `object` | `None` | Server instance with `.config` and `.app` attributes; used to auto-derive `server_url` and register federation routes | +| `identity` | `str` | `None` | Override the generated `did:web` identifier | +| `key_path` | `str` | `None` | Path to an Ed25519 private key file; generates a new key pair if omitted | +| `visibility_default` | `str` | `"public"` | Default agent visibility: `"public"`, `"unlisted"`, or `"private"` | +| `trust_policy` | `str` | `"allowlist"` | Trust policy: `"open"`, `"allowlist"`, or `"trustless"` | +| `peers` | `list[str]` | `None` | List of trusted peer server URLs | +| `server_url` | `str` | `None` | Explicit server URL (used when `server` is not provided) | + +The decorator sets the following attributes on the decorated class: + +| Attribute | Type | Description | +|-----------|------|-------------| +| `_federation_configured` | `bool` | Always `True` after decoration | +| `_federation_trust_policy_name` | `str` | The trust policy string | +| `_federation_visibility_default_name` | `str` | The visibility default string | +| `_federation_peer_list` | `list[str]` | Configured peer URLs | + +Instance attributes set in `__init__`: + +| Attribute | Type | Description | +|-----------|------|-------------| +| `self._federation_identity` | `ServerIdentity` | The server's cryptographic identity | +| `self._federation_trust_policy` | `TrustPolicy` | Parsed trust policy enum | +| `self._federation_visibility_default` | `AgentVisibility` | Parsed visibility enum | +| `self._federation_peers` | `list[str]` | Peer URL list | +| `self._federation_server_url` | `str` | Resolved server URL | + +```python +from openintent.federation import Federation + +@Federation( + server_url="https://api.example.com", + trust_policy="allowlist", + peers=["https://partner.example.com"], + visibility_default="public", +) +class MyFederationHub: + pass +``` + +## Lifecycle Decorators + +### on_federation_received + +`@on_federation_received` — marks a method as the handler for incoming federated intents. + +```python +from openintent.federation import on_federation_received + +class MyAgent: + @on_federation_received + async def handle_received(self, envelope): + print(f"Received dispatch {envelope.dispatch_id} from {envelope.source_server}") +``` + +Sets `func._openintent_handler = "federation_received"`. + +### on_federation_callback + +`@on_federation_callback` — marks a method as the handler for federation callback events (state deltas, completions, failures). + +```python +from openintent.federation import on_federation_callback + +class MyAgent: + @on_federation_callback + async def handle_callback(self, callback): + print(f"Callback for dispatch {callback.dispatch_id}: {callback.event_type}") +``` + +Sets `func._openintent_handler = "federation_callback"`. + +### on_budget_warning + +`@on_budget_warning` — marks a method as the handler for budget threshold warnings during federated work. + +```python +from openintent.federation import on_budget_warning + +class MyAgent: + @on_budget_warning + async def handle_budget(self, warning): + print(f"Budget warning: {warning}") +``` + +Sets `func._openintent_handler = "budget_warning"`. + +## Security Classes (RFC-0023) + +### ServerIdentity + +`ServerIdentity(server_url, did=, private_key_bytes=, public_key_bytes=)` — represents a server's cryptographic identity using `did:web` and Ed25519 keys. + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `server_url` | `str` | required | The server's public URL | +| `did` | `str` | `""` | DID identifier; auto-generated as `did:web:{domain}` if empty | +| `private_key_bytes` | `bytes \| None` | `None` | Ed25519 private key (raw 32 bytes) | +| `public_key_bytes` | `bytes \| None` | `None` | Ed25519 public key (raw 32 bytes) | + +#### Class Methods + +| Method | Returns | Description | +|--------|---------|-------------| +| `ServerIdentity.generate(server_url)` | `ServerIdentity` | Generate a new Ed25519 key pair (falls back to HMAC-SHA256 without `cryptography`) | +| `ServerIdentity.from_key_file(server_url, key_path)` | `ServerIdentity` | Load identity from a private key file | + +#### Instance Methods & Properties + +| Method / Property | Returns | Description | +|-------------------|---------|-------------| +| `save_key(key_path)` | `None` | Write the private key bytes to a file | +| `public_key_b64` | `str` | Base64-encoded public key | +| `did_document()` | `dict` | W3C DID Document with `Ed25519VerificationKey2020` | +| `sign(message)` | `str` | Sign bytes and return base64-encoded signature | +| `verify(message, signature_b64)` | `bool` | Verify a base64-encoded signature against this identity | + +```python +from openintent.federation import ServerIdentity + +identity = ServerIdentity.generate("https://api.example.com") +print(identity.did) # "did:web:api.example.com" +print(identity.public_key_b64) # base64 public key + +sig = identity.sign(b"hello") +assert identity.verify(b"hello", sig) +``` + +### TrustEnforcer + +`TrustEnforcer(policy, allowed_peers=)` — enforces trust policies for incoming federation requests. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `policy` | `TrustPolicy` | required | The trust policy to enforce | +| `allowed_peers` | `list[str] \| None` | `None` | List of trusted server URLs or DIDs | + +#### Methods + +| Method | Returns | Description | +|--------|---------|-------------| +| `is_trusted(source_server, source_did=)` | `bool` | Check if a source server is trusted under the current policy | +| `add_peer(peer)` | `None` | Add a server URL or DID to the allow list | +| `remove_peer(peer)` | `None` | Remove a server URL or DID from the allow list | + +Trust policy behavior: + +| Policy | Behavior | +|--------|----------| +| `open` | All servers are trusted | +| `allowlist` | Only servers in `allowed_peers` (by URL or DID) are trusted | +| `trustless` | No servers are trusted | + +```python +from openintent.federation import TrustEnforcer, TrustPolicy + +enforcer = TrustEnforcer( + policy=TrustPolicy.ALLOWLIST, + allowed_peers=["https://partner.example.com"], +) + +assert enforcer.is_trusted("https://partner.example.com") +assert not enforcer.is_trusted("https://unknown.example.com") + +enforcer.add_peer("https://new-partner.example.com") +assert enforcer.is_trusted("https://new-partner.example.com") +``` + +### UCANToken + +`UCANToken(issuer, audience, scope, not_before=, expires_at=, nonce=, proof_chain=)` — UCAN delegation token for capability-based authorization. + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `issuer` | `str` | required | DID of the token issuer | +| `audience` | `str` | required | DID of the token audience (recipient) | +| `scope` | `DelegationScope` | required | Permissions granted by this token | +| `not_before` | `int` | `0` | Unix timestamp; auto-set to `time.time()` if `0` | +| `expires_at` | `int` | `0` | Unix timestamp; auto-set to `not_before + 3600` if `0` | +| `nonce` | `str` | `""` | Random nonce; auto-generated if empty | +| `proof_chain` | `list[str]` | `[]` | Chain of parent UCAN tokens proving delegation authority | + +#### Methods + +| Method | Returns | Description | +|--------|---------|-------------| +| `to_dict()` | `dict` | Serialize to UCAN payload dict (keys: `iss`, `aud`, `scope`, `nbf`, `exp`, `nonce`, `prf`) | +| `UCANToken.from_dict(data)` | `UCANToken` | Deserialize from dict | +| `encode(identity)` | `str` | Encode as a signed JWT-like `header.payload.signature` string | +| `UCANToken.decode(token)` | `UCANToken` | Decode a UCAN token string (does not verify signature) | +| `is_expired()` | `bool` | Check if the token has expired | +| `is_active()` | `bool` | Check if the current time is within `[not_before, expires_at]` | +| `attenuate(audience, child_scope, identity)` | `UCANToken` | Create a child token with attenuated (reduced) permissions | + +```python +from openintent.federation import UCANToken, DelegationScope, ServerIdentity + +identity = ServerIdentity.generate("https://api.example.com") +scope = DelegationScope(permissions=["state.patch", "events.log"]) + +token = UCANToken( + issuer=identity.did, + audience="did:web:partner.example.com", + scope=scope, +) + +encoded = token.encode(identity) +decoded = UCANToken.decode(encoded) +assert decoded.issuer == identity.did +assert decoded.is_active() +``` + +### MessageSignature + +`MessageSignature(key_id, algorithm=, created=, headers=, signature=)` — HTTP Message Signature per RFC 9421. + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `key_id` | `str` | required | DID of the signing server | +| `algorithm` | `str` | `"ed25519"` | Signature algorithm | +| `created` | `int` | `0` | Unix timestamp; auto-set to `time.time()` if `0` | +| `headers` | `list[str]` | `["@method", "@target-uri", "content-type", "content-digest"]` | Signed HTTP components | +| `signature` | `str` | `""` | Base64-encoded signature value | + +#### Methods + +| Method | Returns | Description | +|--------|---------|-------------| +| `MessageSignature.create(identity, method, target_uri, content_type=, body=)` | `MessageSignature` | Create a signature for an HTTP request | +| `to_header()` | `str` | Format as `Signature-Input` header value | +| `signature_header()` | `str` | Format as `Signature` header value | + +```python +from openintent.federation import MessageSignature, ServerIdentity +import json + +identity = ServerIdentity.generate("https://api.example.com") +body = json.dumps({"intent_id": "i-123"}).encode() + +sig = MessageSignature.create( + identity=identity, + method="POST", + target_uri="https://partner.example.com/api/v1/federation/dispatch", + body=body, +) + +print(sig.to_header()) # Signature-Input header +print(sig.signature_header()) # Signature header +``` + +## Envelope Functions + +### sign_envelope + +`sign_envelope(identity, envelope_dict) -> str` — sign a federation envelope dict and return a base64 signature. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `identity` | `ServerIdentity` | The signing server's identity | +| `envelope_dict` | `dict` | The envelope data (the `"signature"` key is excluded from signing) | + +Returns the base64-encoded signature string. + +### verify_envelope_signature + +`verify_envelope_signature(public_key_b64, envelope_dict, signature_b64) -> bool` — verify a federation envelope signature. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `public_key_b64` | `str` | Base64-encoded public key of the signer | +| `envelope_dict` | `dict` | The envelope data to verify | +| `signature_b64` | `str` | The base64-encoded signature | + +Returns `True` if the signature is valid. + +```python +from openintent.federation import sign_envelope, verify_envelope_signature, ServerIdentity + +identity = ServerIdentity.generate("https://api.example.com") + +envelope = { + "dispatch_id": "d-123", + "source_server": "https://api.example.com", + "target_server": "https://partner.example.com", + "intent_id": "i-456", + "intent_title": "Research task", +} + +sig = sign_envelope(identity, envelope) +assert verify_envelope_signature(identity.public_key_b64, envelope, sig) +``` + +## Utility Functions + +### resolve_did_web + +`resolve_did_web(did) -> str` — resolve a `did:web` identifier to the URL of its DID Document. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `did` | `str` | A `did:web:` identifier | + +Returns the HTTPS URL for `/.well-known/did.json`. + +```python +from openintent.federation import resolve_did_web + +url = resolve_did_web("did:web:api.example.com") +# "https://api.example.com/.well-known/did.json" +``` + +### validate_ssrf + +`validate_ssrf(url) -> bool` — validate a URL against SSRF protection rules. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `url` | `str` | The URL to validate | + +Returns `True` if the URL is safe. Blocks: + +- Non-HTTP(S) schemes +- `localhost`, `127.0.0.1`, `0.0.0.0`, `::1` +- Private IP ranges (`10.*`, `172.*`, `192.168.*`) +- Cloud metadata endpoints (`169.254.169.254`, `metadata.google.internal`) +- `.internal` and `.local` domains + +```python +from openintent.federation import validate_ssrf + +assert validate_ssrf("https://partner.example.com") +assert not validate_ssrf("http://localhost:8000") +assert not validate_ssrf("http://169.254.169.254/metadata") +``` + +## Model Classes + +### Enums + +#### AgentVisibility + +Controls whether an agent is discoverable by federated peers. + +| Value | Description | +|-------|-------------| +| `PUBLIC` | Visible to all peers | +| `UNLISTED` | Visible only to known peers | +| `PRIVATE` | Not visible to any peer | + +#### PeerRelationship + +Describes the relationship between two federated servers. + +| Value | Description | +|-------|-------------| +| `PEER` | Equal bidirectional relationship | +| `UPSTREAM` | The peer is an authority / delegator | +| `DOWNSTREAM` | The peer is a delegate / worker | + +#### TrustPolicy + +Determines how incoming federation requests are validated. + +| Value | Description | +|-------|-------------| +| `OPEN` | Accept from any server | +| `ALLOWLIST` | Accept only from explicitly listed peers | +| `TRUSTLESS` | Reject all federation requests | + +#### CallbackEventType + +Types of events sent via federation callbacks. + +| Value | Description | +|-------|-------------| +| `STATE_DELTA` | Partial state update | +| `STATUS_CHANGED` | Intent status changed | +| `ATTESTATION` | Governance attestation | +| `BUDGET_WARNING` | Budget threshold reached | +| `COMPLETED` | Work completed | +| `FAILED` | Work failed | + +#### DispatchStatus + +Status of a dispatched federation request. + +| Value | Description | +|-------|-------------| +| `ACCEPTED` | Dispatch accepted by target | +| `REJECTED` | Dispatch rejected by target | +| `PENDING` | Dispatch in progress | + +### DelegationScope + +`DelegationScope(permissions=, denied_operations=, max_delegation_depth=, expires_at=)` — defines what operations a remote server is allowed to perform. + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `permissions` | `list[str]` | `["state.patch", "events.log"]` | Allowed operations | +| `denied_operations` | `list[str]` | `[]` | Explicitly denied operations | +| `max_delegation_depth` | `int` | `1` | How many times this scope can be re-delegated | +| `expires_at` | `str \| None` | `None` | ISO 8601 expiration timestamp | + +#### Methods + +| Method | Returns | Description | +|--------|---------|-------------| +| `to_dict()` | `dict` | Serialize to dict | +| `DelegationScope.from_dict(data)` | `DelegationScope` | Deserialize from dict | +| `attenuate(child_scope)` | `DelegationScope` | Create a reduced scope (intersection of permissions, union of denials, decremented depth) | + +```python +from openintent.federation import DelegationScope + +parent = DelegationScope( + permissions=["state.patch", "events.log", "cost.report"], + max_delegation_depth=2, +) + +child = DelegationScope( + permissions=["state.patch", "events.log"], + max_delegation_depth=1, +) + +attenuated = parent.attenuate(child) +assert "cost.report" not in attenuated.permissions +assert attenuated.max_delegation_depth == 1 +``` + +### FederationPolicy + +`FederationPolicy(governance=, budget=, observability=)` — policy constraints propagated across federation boundaries. + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `governance` | `dict` | `{}` | Governance constraints (e.g., `require_approval`, `allowed_agents`) | +| `budget` | `dict` | `{}` | Budget constraints (e.g., `max_llm_tokens`, `cost_ceiling_usd`) | +| `observability` | `dict` | `{}` | Observability requirements (e.g., `trace_required`, `log_level`) | + +#### Methods + +| Method | Returns | Description | +|--------|---------|-------------| +| `to_dict()` | `dict` | Serialize to dict | +| `FederationPolicy.from_dict(data)` | `FederationPolicy` | Deserialize from dict | +| `compose_strictest(other)` | `FederationPolicy` | Merge two policies taking the strictest constraint for each key | + +```python +from openintent.federation import FederationPolicy + +local = FederationPolicy( + budget={"max_llm_tokens": 10000, "cost_ceiling_usd": 5.0}, + governance={"require_approval": False}, +) + +remote = FederationPolicy( + budget={"max_llm_tokens": 5000, "cost_ceiling_usd": 10.0}, + governance={"require_approval": True}, +) + +merged = local.compose_strictest(remote) +assert merged.budget["max_llm_tokens"] == 5000 +assert merged.governance["require_approval"] is True +``` + +### FederationEnvelope + +`FederationEnvelope(dispatch_id, source_server, target_server, intent_id, intent_title, ...)` — the wire format for dispatching an intent to a remote server. + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `dispatch_id` | `str` | required | Unique dispatch identifier | +| `source_server` | `str` | required | URL of the originating server | +| `target_server` | `str` | required | URL of the destination server | +| `intent_id` | `str` | required | ID of the intent being dispatched | +| `intent_title` | `str` | required | Title of the intent | +| `intent_description` | `str` | `""` | Description of the intent | +| `intent_state` | `dict` | `{}` | Current intent state | +| `intent_constraints` | `dict` | `{}` | Intent constraints | +| `agent_id` | `str \| None` | `None` | Target agent ID on the remote server | +| `delegation_scope` | `DelegationScope \| None` | `None` | Permissions granted to the remote server | +| `federation_policy` | `FederationPolicy \| None` | `None` | Policy constraints for remote execution | +| `trace_context` | `dict[str, str] \| None` | `None` | Distributed tracing context (RFC-0020) | +| `callback_url` | `str \| None` | `None` | URL for status callbacks | +| `idempotency_key` | `str \| None` | `None` | Idempotency key to prevent duplicate processing | +| `created_at` | `str \| None` | `None` | ISO 8601 creation timestamp | +| `signature` | `str \| None` | `None` | Cryptographic signature (RFC-0023) | + +#### Methods + +| Method | Returns | Description | +|--------|---------|-------------| +| `to_dict()` | `dict` | Serialize to dict (omits `None` fields) | +| `FederationEnvelope.from_dict(data)` | `FederationEnvelope` | Deserialize from dict | + +### FederationCallback + +`FederationCallback(dispatch_id, event_type, state_delta=, attestation=, trace_id=, idempotency_key=, timestamp=)` — callback message sent from a remote server back to the originator. + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `dispatch_id` | `str` | required | The original dispatch ID | +| `event_type` | `CallbackEventType` | required | Type of callback event | +| `state_delta` | `dict` | `{}` | Partial state update | +| `attestation` | `FederationAttestation \| None` | `None` | Governance attestation | +| `trace_id` | `str \| None` | `None` | Distributed trace ID | +| `idempotency_key` | `str \| None` | `None` | Idempotency key | +| `timestamp` | `str \| None` | `None` | ISO 8601 timestamp | + +#### Methods + +| Method | Returns | Description | +|--------|---------|-------------| +| `to_dict()` | `dict` | Serialize to dict | +| `FederationCallback.from_dict(data)` | `FederationCallback` | Deserialize from dict | + +### FederationAttestation + +`FederationAttestation(dispatch_id, governance_compliant=, usage=, trace_references=, timestamp=, signature=)` — proof that remote work was executed within policy constraints. + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `dispatch_id` | `str` | required | The dispatch this attests to | +| `governance_compliant` | `bool` | `True` | Whether governance policies were followed | +| `usage` | `dict` | `{}` | Resource usage (e.g., `{"llm_tokens": 500, "cost_usd": 0.02}`) | +| `trace_references` | `list[str]` | `[]` | Related trace/span IDs | +| `timestamp` | `str \| None` | `None` | ISO 8601 timestamp | +| `signature` | `str \| None` | `None` | Cryptographic signature | + +#### Methods + +| Method | Returns | Description | +|--------|---------|-------------| +| `to_dict()` | `dict` | Serialize to dict | +| `FederationAttestation.from_dict(data)` | `FederationAttestation` | Deserialize from dict | + +### PeerInfo + +`PeerInfo(server_url, server_did=, relationship=, trust_policy=, public_key=)` — metadata about a known federation peer. + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `server_url` | `str` | required | Peer's base URL | +| `server_did` | `str \| None` | `None` | Peer's DID identifier | +| `relationship` | `PeerRelationship` | `PEER` | Relationship type | +| `trust_policy` | `TrustPolicy` | `ALLOWLIST` | Trust policy for this peer | +| `public_key` | `str \| None` | `None` | Peer's public key (base64) | + +#### Methods + +| Method | Returns | Description | +|--------|---------|-------------| +| `to_dict()` | `dict` | Serialize to dict | +| `PeerInfo.from_dict(data)` | `PeerInfo` | Deserialize from dict | + +### FederationManifest + +`FederationManifest(server_did, server_url, ...)` — the discovery document served at `/.well-known/openintent-federation.json`. + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `server_did` | `str` | required | Server's DID identifier | +| `server_url` | `str` | required | Server's base URL | +| `protocol_version` | `str` | `"0.1"` | Federation protocol version | +| `trust_policy` | `TrustPolicy` | `ALLOWLIST` | Server's default trust policy | +| `visibility_default` | `AgentVisibility` | `PUBLIC` | Default agent visibility | +| `supported_rfcs` | `list[str]` | `["RFC-0022", "RFC-0023"]` | Supported RFC list | +| `peers` | `list[str]` | `[]` | Known peer server URLs | +| `public_key` | `str \| None` | `None` | Server's public key (base64) | +| `endpoints` | `dict[str, str]` | see below | Federation endpoint paths | + +Default endpoints: + +```python +{ + "status": "/api/v1/federation/status", + "agents": "/api/v1/federation/agents", + "dispatch": "/api/v1/federation/dispatch", + "receive": "/api/v1/federation/receive", +} +``` + +#### Methods + +| Method | Returns | Description | +|--------|---------|-------------| +| `to_dict()` | `dict` | Serialize to dict | +| `FederationManifest.from_dict(data)` | `FederationManifest` | Deserialize from dict | + +### FederationStatus + +`FederationStatus(enabled=, server_did=, trust_policy=, peer_count=, active_dispatches=, total_dispatches=, total_received=)` — runtime status of the federation subsystem. + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `enabled` | `bool` | `True` | Whether federation is active | +| `server_did` | `str \| None` | `None` | This server's DID | +| `trust_policy` | `TrustPolicy` | `ALLOWLIST` | Active trust policy | +| `peer_count` | `int` | `0` | Number of known peers | +| `active_dispatches` | `int` | `0` | Currently active outbound dispatches | +| `total_dispatches` | `int` | `0` | Total dispatches sent | +| `total_received` | `int` | `0` | Total dispatches received | + +#### Methods + +| Method | Returns | Description | +|--------|---------|-------------| +| `to_dict()` | `dict` | Serialize to dict | +| `FederationStatus.from_dict(data)` | `FederationStatus` | Deserialize from dict | + +### DispatchResult + +`DispatchResult(dispatch_id, status, target_server, message=, remote_intent_id=)` — result of a federation dispatch request. + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `dispatch_id` | `str` | required | Dispatch identifier | +| `status` | `DispatchStatus` | required | Result status | +| `target_server` | `str` | required | Target server URL | +| `message` | `str` | `""` | Human-readable message | +| `remote_intent_id` | `str \| None` | `None` | Intent ID on the remote server | + +#### Methods + +| Method | Returns | Description | +|--------|---------|-------------| +| `to_dict()` | `dict` | Serialize to dict | +| `DispatchResult.from_dict(data)` | `DispatchResult` | Deserialize from dict | + +### ReceiveResult + +`ReceiveResult(dispatch_id, accepted, local_intent_id=, message=)` — result of receiving a federated intent. + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `dispatch_id` | `str` | required | The original dispatch ID | +| `accepted` | `bool` | required | Whether the intent was accepted | +| `local_intent_id` | `str \| None` | `None` | Locally created intent ID | +| `message` | `str` | `""` | Human-readable message | + +#### Methods + +| Method | Returns | Description | +|--------|---------|-------------| +| `to_dict()` | `dict` | Serialize to dict | +| `ReceiveResult.from_dict(data)` | `ReceiveResult` | Deserialize from dict | + +### FederatedAgent + +`FederatedAgent(agent_id, server_url, capabilities=, visibility=, server_did=, status=)` — an agent visible via federation discovery. + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `agent_id` | `str` | required | Agent identifier | +| `server_url` | `str` | required | Server hosting the agent | +| `capabilities` | `list[str]` | `[]` | Agent capabilities | +| `visibility` | `AgentVisibility` | `PUBLIC` | Visibility level | +| `server_did` | `str \| None` | `None` | DID of the hosting server | +| `status` | `str` | `"active"` | Agent status | + +#### Methods + +| Method | Returns | Description | +|--------|---------|-------------| +| `to_dict()` | `dict` | Serialize to dict | +| `FederatedAgent.from_dict(data)` | `FederatedAgent` | Deserialize from dict | + +## Client Methods + +Both `OpenIntentClient` (sync) and `AsyncOpenIntentClient` expose these federation methods: + +### federation_status + +`client.federation_status() -> FederationStatus` — get the federation status of the connected server. + +### list_federated_agents + +`client.list_federated_agents(source_server=) -> list[dict]` — list agents visible via federation. Pass `source_server` to include unlisted agents visible to that peer. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `source_server` | `str \| None` | `None` | Requesting server URL (sent as `X-Source-Server` header) | + +### federation_dispatch + +`client.federation_dispatch(intent_id, target_server, ...) -> DispatchResult` — dispatch an intent to a remote server. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `intent_id` | `str` | required | Intent to dispatch | +| `target_server` | `str` | required | Target server URL | +| `agent_id` | `str \| None` | `None` | Target agent on remote server | +| `delegation_scope` | `dict \| None` | `None` | Delegation scope dict | +| `federation_policy` | `dict \| None` | `None` | Policy constraints dict | +| `callback_url` | `str \| None` | `None` | Callback URL for status updates | +| `trace_context` | `dict[str, str] \| None` | `None` | Distributed tracing context | + +### federation_receive + +`client.federation_receive(dispatch_id, source_server, intent_id, intent_title, ...) -> ReceiveResult` — receive a federated intent from a remote server. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `dispatch_id` | `str` | required | Dispatch identifier | +| `source_server` | `str` | required | Source server URL | +| `intent_id` | `str` | required | Original intent ID | +| `intent_title` | `str` | required | Intent title | +| `intent_description` | `str` | `""` | Intent description | +| `intent_state` | `dict \| None` | `None` | Intent state | +| `agent_id` | `str \| None` | `None` | Target agent ID | +| `delegation_scope` | `dict \| None` | `None` | Delegation scope dict | +| `federation_policy` | `dict \| None` | `None` | Policy constraints dict | +| `callback_url` | `str \| None` | `None` | Callback URL | +| `idempotency_key` | `str \| None` | `None` | Idempotency key | + +### send_federation_callback + +`client.send_federation_callback(callback_url, dispatch_id, event_type, ...) -> dict` — send a callback to the originating server. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `callback_url` | `str` | required | Callback endpoint URL | +| `dispatch_id` | `str` | required | Original dispatch ID | +| `event_type` | `str` | required | Callback event type | +| `state_delta` | `dict \| None` | `None` | Partial state update | +| `attestation` | `dict \| None` | `None` | Governance attestation dict | +| `trace_id` | `str \| None` | `None` | Trace ID | + +### federation_discover + +`client.federation_discover() -> dict` — fetch the federation discovery document from `/.well-known/openintent-federation.json`. + +## Server Endpoints + +The federation router (registered via `@Federation` or `create_federation_router()`) exposes: + +| Method | Endpoint | Description | +|--------|----------|-------------| +| `GET` | `/.well-known/openintent-federation.json` | Federation discovery manifest | +| `GET` | `/.well-known/did.json` | W3C DID Document for server identity | +| `GET` | `/api/v1/federation/status` | Federation runtime status | +| `GET` | `/api/v1/federation/agents` | List federally visible agents | +| `POST` | `/api/v1/federation/dispatch` | Dispatch an intent to a remote server | +| `POST` | `/api/v1/federation/receive` | Receive a dispatched intent from a remote server | + +### Server Internal Classes + +#### FederationState + +Internal state manager for federation. Created as a module-level singleton `_federation_state`. + +| Method | Description | +|--------|-------------| +| `register_agent(agent_id, capabilities=, visibility=, server_url=)` | Register an agent for federation discovery | +| `get_visible_agents(requesting_server=)` | Get agents visible to a requesting server | + +#### configure_federation + +`configure_federation(server_url, server_did=, trust_policy=, visibility_default=, peers=, identity=) -> FederationState` — initialize federation state with identity and trust configuration. + +#### create_federation_router + +`create_federation_router(validate_api_key=) -> APIRouter` — create the FastAPI router with all federation endpoints. diff --git a/docs/changelog.md b/docs/changelog.md index 6deb979..c3367f6 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -5,6 +5,46 @@ All notable changes to the OpenIntent SDK will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.14.0] - 2026-02-25 + +### Added + +- **RFC-0022: Federation Protocol** — Complete federation contract specification for cross-server agent coordination. Defines: federation envelope format, agent visibility (public/unlisted/private), peer relationships (peer/upstream/downstream), callbacks with at-least-once delivery, intent authority model, delegation scope with UCAN-style attenuation, governance propagation (strictest-wins), federation attestation (OpenTelemetry conventions), discovery via `/.well-known/openintent-federation.json`, federation-aware leasing, and transport bindings (HTTP REST primary, NATS/gRPC alternatives). +- **RFC-0023: Federation Security** — Authentication, authorization, and verification layer for federation. Defines: server identity via did:web, signed envelopes (HTTP Message Signatures, RFC 9421), delegation tokens (UCAN with attenuation), trust policies (open/allowlist/trustless), agent access policies, signed attestations, and cross-server event log reconciliation via RFC-0019 Merkle primitives. +- **Python SDK Federation Implementation (RFC-0022 & RFC-0023)** — Full 5-layer federation support: + - **Layer 1 — Models** (`openintent/federation/models.py`): `FederationEnvelope`, `FederationCallback`, `FederationPolicy`, `FederationAttestation`, `DelegationScope`, `FederationManifest`, `FederationStatus`, `DispatchResult`, `ReceiveResult`, `FederatedAgent`, `PeerInfo`. Enums: `AgentVisibility`, `PeerRelationship`, `TrustPolicy`, `CallbackEventType`, `DispatchStatus`. All with `to_dict()`/`from_dict()` serialization. + - **Layer 2 — Client methods** (`openintent/client.py`): `federation_status()`, `list_federated_agents()`, `federation_dispatch()`, `federation_receive()`, `send_federation_callback()`, `federation_discover()`. Both sync (`OpenIntentClient`) and async (`AsyncOpenIntentClient`) variants. + - **Layer 3 — Server endpoints** (`openintent/server/federation.py`): FastAPI router with `GET /api/v1/federation/status`, `GET /api/v1/federation/agents`, `POST /api/v1/federation/dispatch`, `POST /api/v1/federation/receive`, `GET /.well-known/openintent-federation.json`, `GET /.well-known/did.json`. SSRF validation on outbound URLs, callback delivery with retry, governance enforcement, idempotency key handling. + - **Layer 4 — Security** (`openintent/federation/security.py`): `ServerIdentity` (Ed25519 key pairs, did:web identifiers, DID document generation), `sign_envelope()`/`verify_envelope_signature()`, `MessageSignature` (RFC 9421 HTTP Message Signatures), `TrustEnforcer` (open/allowlist/trustless policy enforcement), `UCANToken` (delegation token creation, encoding, decoding, attenuation, expiry checks), `resolve_did_web()`, `validate_ssrf()`. + - **Layer 5 — Decorators** (`openintent/federation/decorators.py`): `@Federation` class decorator for server configuration (identity, trust_policy, peers, visibility_default). `federation_visibility` parameter on `@Agent`. `federation_policy` parameter on `@Coordinator`. Lifecycle hooks: `@on_federation_received`, `@on_federation_callback`, `@on_budget_warning`. +- **Federation Dispatch (Express.js)** — 4 REST endpoints for cross-server intent dispatch: `GET /api/v1/federation/status`, `GET /api/v1/federation/agents`, `POST /api/v1/federation/dispatch`, `POST /api/v1/federation/receive`. Federation audit trail with dispatch IDs, provenance in `state._federation`, and RFC-0020 trace propagation. +- **Federation MCP Tools** — 4 new MCP tools: `federation_status` (read), `list_federated_agents` (read), `federation_dispatch` (admin), `federation_receive` (admin). MCP tool surface expanded from 62 to 66 tools; RBAC counts: reader=23, operator=40, admin=66. +- **Agent Lifecycle (RFC-0016)** — Registration with atomic upsert, heartbeat protocol, graceful drain, and status management across 5 REST endpoints. +- **Federation event types** in `EventType` enum: `FEDERATION_DISPATCHED`, `FEDERATION_RECEIVED`, `FEDERATION_CALLBACK`, `FEDERATION_BUDGET_WARNING`, `FEDERATION_COMPLETED`, `FEDERATION_FAILED`. +- **DelegationScope.attenuate()** — Scope narrowing per hop: intersection of permissions, union of denied operations, minimum delegation depth. +- **FederationPolicy.compose_strictest()** — Strictest-wins governance composition: minimum for numerics, OR for booleans, merge for observability. +- **Discovery endpoints** updated: `/.well-known/openintent.json` includes `federation` capability and RFC-0022/0023 in `rfcUrls`. `/.well-known/openintent-compat.json` includes RFC-0022 (full) and RFC-0023 (partial) compliance. +- **Schema: `origin_server_url`** — New field on `agent_records` table marking federated agents. +- **82 federation tests** covering models serialization, security (sign/verify, UCAN, SSRF, trust enforcement), server endpoints, decorators, and integration flows. + +### Fixed + +- **Agent Registration Race Condition** — Replaced two-step check-then-insert with atomic `INSERT ... ON CONFLICT DO UPDATE`. Version increments atomically via SQL expression. +- **Response Field Naming** — All agent endpoints now return `metadata` (not `agent_metadata`) for consistency with the Python SDK. + +### Security + +- **SSRF Protection on Federation** — `origin_server_url` validated at registration and dispatch. Blocks private IPs, metadata endpoints, internal hostnames, and non-HTTP schemes. +- **Federation Timeout** — 10-second timeout on remote dispatch calls. Returns 502 on failure. +- **Loop Prevention** — Cannot dispatch to local agents or receive for federated agents. + +### Changed + +- RFC count increased from 22 to 23. +- All version references updated to 0.14.0 across Python SDK, MCP server, and documentation. + +--- + ## [0.13.5] - 2026-02-14 ### Added @@ -18,6 +58,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Anthropic Streaming Usage (Round 2)** — Fixed `_resolve_usage()` in the Anthropic adapter which short-circuited via `if self._usage is not None: return` when `_consume_events` captured partial usage (e.g. `input_tokens` from `message_start` but `output_tokens` stuck at 0 due to early generator exit before `message_delta`). Now always calls `get_final_message()` on the underlying Anthropic `MessageStream` as the primary authoritative source, falling back to event-captured data only if `get_final_message()` fails. Also calls the raw SDK stream directly to avoid duplicate tool-block processing. +- **LLM Engine Streaming Usage** — Added `_last_stream_usage` to `LLMEngine` and wired usage capture into both `_iter_anthropic_stream` and `_stream_raw_provider` for Anthropic. After text iteration completes, calls `get_final_message()` on the underlying stream to populate usage data, making token counts available even when streaming without the adapter wrapper. - **MCP Startup Tool Count** — Fixed tool count mismatch where RBAC security tiers listed 62 tools but only 58 tool definitions existed. Startup log now correctly reports `tools=62/62` for admin role. - **RBAC Tier Correction** — `operator` role count corrected from 37 to 38 across changelog entries and documentation. diff --git a/docs/examples/federation.md b/docs/examples/federation.md new file mode 100644 index 0000000..5b4c5d0 --- /dev/null +++ b/docs/examples/federation.md @@ -0,0 +1,284 @@ +# Cross-Server Federation + +Coordinate agents across multiple OpenIntent servers. + +## Basic Dispatch and Receive + +Dispatch an intent from one server to another: + +```python +from openintent import OpenIntentClient + +client = OpenIntentClient(base_url="http://localhost:8000", api_key="dev-key") + +result = client.federation_dispatch( + target_server="https://partner.example.com", + intent_id="intent_01", + intent_title="Analyze Q1 data", + intent_description="Run financial analysis on Q1 dataset", + delegation_scope={ + "permissions": ["state.patch", "events.log"], + "max_delegation_depth": 1, + }, +) + +print(result["dispatch_id"]) # "dispatch_abc123" +print(result["status"]) # "accepted" +``` + +The receiving server processes the envelope automatically: + +```python +from openintent import OpenIntentClient + +remote_client = OpenIntentClient(base_url="https://partner.example.com", api_key="partner-key") + +received = remote_client.federation_receive( + dispatch_id="dispatch_abc123", + source_server="http://localhost:8000", + intent_id="intent_01", + intent_title="Analyze Q1 data", +) + +print(received["accepted"]) # True +print(received["local_intent_id"]) # "intent_remote_42" +``` + +## YAML Federation Workflow + +Define federation peers and trust policies declaratively: + +```yaml +openintent: "1.0" +info: + name: "Federated Research Pipeline" + +federation: + server_did: "did:web:research.example.com" + trust_policy: allowlist + visibility_default: public + peers: + - url: "https://partner-a.example.com" + relationship: peer + trust_policy: allowlist + - url: "https://partner-b.example.com" + relationship: downstream + trust_policy: open + +workflow: + collect: + assign: data-collector + federation_visibility: public + analyze: + assign: remote-analyzer + dispatch_to: "https://partner-a.example.com" + delegation_scope: + permissions: [state.patch, events.log] + max_delegation_depth: 1 + depends_on: [collect] + summarize: + assign: report-writer + depends_on: [analyze] +``` + +## Envelope Signing + +Sign federation envelopes with Ed25519 keys for tamper-proof dispatch: + +```python +from openintent.federation.security import ServerIdentity, sign_envelope, verify_envelope_signature +from openintent.federation.models import FederationEnvelope + +identity = ServerIdentity.generate("https://my-server.example.com") + +envelope = FederationEnvelope( + dispatch_id="dispatch_001", + source_server="https://my-server.example.com", + target_server="https://partner.example.com", + intent_id="intent_01", + intent_title="Analyze dataset", +) + +envelope_dict = envelope.to_dict() +signature = sign_envelope(identity, envelope_dict) +envelope_dict["signature"] = signature + +print(signature) # base64-encoded Ed25519 signature +``` + +Verify on the receiving end: + +```python +public_key_b64 = identity.public_key_b64 + +valid = verify_envelope_signature( + public_key_b64=public_key_b64, + envelope_dict=envelope_dict, + signature_b64=envelope_dict["signature"], +) + +print(valid) # True +``` + +## UCAN Delegation Tokens + +Create capability tokens that scope what remote servers can do: + +```python +from openintent.federation.security import ServerIdentity, UCANToken +from openintent.federation.models import DelegationScope + +identity = ServerIdentity.generate("https://my-server.example.com") + +scope = DelegationScope( + permissions=["state.patch", "events.log"], + denied_operations=["intent.delete"], + max_delegation_depth=2, +) + +token = UCANToken( + issuer=identity.did, + audience="did:web:partner.example.com", + scope=scope, +) + +encoded = token.encode(identity) +print(encoded) # eyJhbGci... + +decoded = UCANToken.decode(encoded) +print(decoded.issuer) # "did:web:my-server.example.com" +print(decoded.audience) # "did:web:partner.example.com" +print(decoded.is_active()) # True +``` + +## Trust Enforcement + +Control which peers can dispatch intents to your server: + +```python +from openintent.federation.security import TrustEnforcer +from openintent.federation.models import TrustPolicy + +enforcer = TrustEnforcer( + policy=TrustPolicy.ALLOWLIST, + allowed_peers=["https://partner-a.example.com", "did:web:partner-b.example.com"], +) + +print(enforcer.is_trusted("https://partner-a.example.com")) # True +print(enforcer.is_trusted("https://unknown.example.com")) # False +print(enforcer.is_trusted("https://x.com", "did:web:partner-b.example.com")) # True + +enforcer.add_peer("https://new-partner.example.com") +print(enforcer.is_trusted("https://new-partner.example.com")) # True +``` + +Open trust accepts all peers: + +```python +open_enforcer = TrustEnforcer(policy=TrustPolicy.OPEN) +print(open_enforcer.is_trusted("https://anyone.example.com")) # True +``` + +Trustless mode rejects everything: + +```python +strict_enforcer = TrustEnforcer(policy=TrustPolicy.TRUSTLESS) +print(strict_enforcer.is_trusted("https://partner-a.example.com")) # False +``` + +## Multi-Hop Delegation + +Attenuate UCAN tokens when re-delegating to a third server: + +```python +from openintent.federation.security import ServerIdentity, UCANToken +from openintent.federation.models import DelegationScope + +server_a = ServerIdentity.generate("https://server-a.example.com") +server_b = ServerIdentity.generate("https://server-b.example.com") + +root_scope = DelegationScope( + permissions=["state.patch", "events.log", "intent.create"], + max_delegation_depth=3, +) + +root_token = UCANToken( + issuer=server_a.did, + audience=server_b.did, + scope=root_scope, +) + +child_scope = DelegationScope( + permissions=["state.patch", "events.log"], + max_delegation_depth=2, +) + +child_token = root_token.attenuate( + audience="did:web:server-c.example.com", + child_scope=child_scope, + identity=server_a, +) + +print(child_token.issuer) # server_b's DID +print(child_token.audience) # "did:web:server-c.example.com" +print(child_token.scope.permissions) # ["events.log", "state.patch"] +print(child_token.scope.max_delegation_depth) # 2 +print(len(child_token.proof_chain)) # 1 (parent token) +``` + +## Federation Decorator + +Configure a server class for federation with a single decorator: + +```python +from openintent.federation.decorators import Federation, on_federation_received, on_federation_callback +from openintent.agents import Agent, on_assignment + +@Federation( + server_url="https://my-server.example.com", + trust_policy="allowlist", + visibility_default="public", + peers=["https://partner.example.com"], +) +class MyFederatedServer: + pass + +server = MyFederatedServer() +print(server._federation_identity.did) # "did:web:my-server.example.com" +``` + +Handle incoming federated work and callbacks: + +```python +@Agent("federated-worker", federation_visibility="public") +class FederatedWorker: + @on_assignment + async def handle(self, intent): + return {"result": "done"} + + @on_federation_received + async def on_received(self, envelope): + print(f"Received dispatch {envelope['dispatch_id']} from {envelope['source_server']}") + + @on_federation_callback + async def on_callback(self, callback): + print(f"Callback for {callback['dispatch_id']}: {callback['event_type']}") + +FederatedWorker.run() +``` + +## Discovery Manifest + +Fetch the well-known federation manifest from any peer: + +```python +import httpx + +response = httpx.get("https://partner.example.com/.well-known/openintent-federation.json") +manifest = response.json() + +print(manifest["server_did"]) # "did:web:partner.example.com" +print(manifest["trust_policy"]) # "allowlist" +print(manifest["supported_rfcs"]) # ["RFC-0022", "RFC-0023"] +print(manifest["endpoints"]["dispatch"]) # "/api/v1/federation/dispatch" +``` diff --git a/docs/guide/federation.md b/docs/guide/federation.md new file mode 100644 index 0000000..8f12e4f --- /dev/null +++ b/docs/guide/federation.md @@ -0,0 +1,620 @@ +--- +title: Cross-Server Federation +--- + +# Cross-Server Federation + +Federation lets agents on one OpenIntent server dispatch work to agents on another. Instead of building monolithic multi-agent systems, you split capabilities across servers and let them coordinate through signed envelopes, scoped delegation, and trust policies. Two RFCs define the protocol: **RFC-0022** (federation mechanics) and **RFC-0023** (federation security). + +--- + +## Quick Start + +Two servers: `analytics.example.com` runs a data-processing agent, `research.example.com` runs a research agent that needs data processed remotely. + +### 1. YAML Workflow with Federation + +```yaml +openintent: "1.0" +info: + name: "Federated Research Pipeline" + +federation: + peers: + - url: https://analytics.example.com + relationship: downstream + trust_policy: allowlist + visibility: public + trust_policy: allowlist + +workflow: + collect: + assign: researcher + analyze: + assign: federation://analytics.example.com/data-processor + depends_on: [collect] + delegation_scope: + permissions: [state.patch, events.log] + max_delegation_depth: 1 +``` + +### 2. Python — Configure Federation + +```python +from openintent.federation import Federation +from openintent.federation.security import ServerIdentity + +@Federation( + server_url="https://research.example.com", + identity="did:web:research.example.com", + trust_policy="allowlist", + peers=["https://analytics.example.com"], + visibility_default="public", +) +class MyFederatedServer: + pass +``` + +### 3. Dispatch an Intent + +```python +from openintent import Client + +client = Client("https://research.example.com") + +result = client.federation_dispatch( + intent_id="intent-001", + target_server="https://analytics.example.com", + agent_id="data-processor", + delegation_scope={ + "permissions": ["state.patch", "events.log"], + "max_delegation_depth": 1, + }, + callback_url="https://research.example.com/api/v1/federation/callback", +) + +print(result["dispatch_id"]) # UUID tracking the dispatch +print(result["status"]) # "accepted" +``` + +!!! tip "Zero ceremony" + The `@Federation` decorator handles identity generation, trust enforcement, and endpoint registration. Your agents don't need to know they're federated — the framework routes dispatched intents transparently. + +--- + +## Agent Visibility + +Every agent registered on a federated server has a visibility level that controls whether remote servers can discover it: + +| Visibility | Discovery behavior | +|------------|-------------------| +| `public` | Listed in `GET /api/v1/federation/agents` for all callers. | +| `unlisted` | Only listed for known peers (servers in the peer list). | +| `private` | Never listed. Can still receive dispatched work if addressed directly. | + +Set visibility per agent: + +```python +from openintent.agents import Agent, on_assignment + +@Agent("data-processor", federation_visibility="public") +class DataProcessor: + @on_assignment + async def handle(self, intent): + return {"processed": True} +``` + +Or set a server-wide default: + +```python +@Federation( + server_url="https://analytics.example.com", + visibility_default="unlisted", + trust_policy="allowlist", + peers=["https://research.example.com"], +) +class AnalyticsServer: + pass +``` + +--- + +## Peer Relationships + +Peers are remote servers your server trusts to send or receive federated work. Each peer has a **relationship** that describes the direction of trust: + +| Relationship | Description | +|-------------|-------------| +| `peer` | Bidirectional — both servers can dispatch to each other. | +| `upstream` | The remote server sends work to you. | +| `downstream` | You send work to the remote server. | + +Configure peers in YAML: + +```yaml +federation: + peers: + - url: https://analytics.example.com + relationship: downstream + trust_policy: allowlist + - url: https://ingest.example.com + relationship: upstream + trust_policy: allowlist +``` + +Or programmatically: + +```python +from openintent.federation.models import PeerInfo, PeerRelationship, TrustPolicy + +peer = PeerInfo( + server_url="https://analytics.example.com", + relationship=PeerRelationship.DOWNSTREAM, + trust_policy=TrustPolicy.ALLOWLIST, +) +``` + +--- + +## Delegation Scope + +When you dispatch an intent to a remote server, you control what the remote agent is allowed to do via a **DelegationScope**. This prevents a remote server from escalating its own permissions. + +```python +from openintent.federation.models import DelegationScope + +scope = DelegationScope( + permissions=["state.patch", "events.log"], + denied_operations=["intent.delete"], + max_delegation_depth=1, + expires_at="2026-12-31T23:59:59Z", +) +``` + +**Key fields:** + +| Field | Description | +|-------|-------------| +| `permissions` | Operations the remote agent is allowed to perform. | +| `denied_operations` | Explicit deny list — overrides permissions. | +| `max_delegation_depth` | How many hops the remote server can re-delegate (0 = no re-delegation). | +| `expires_at` | ISO 8601 timestamp after which the scope is invalid. | + +### Scope Attenuation + +When a remote server re-delegates to a third server, the scope is **attenuated**: permissions are intersected, denied operations are merged, and delegation depth decreases. + +```python +parent_scope = DelegationScope( + permissions=["state.patch", "events.log", "intent.read"], + max_delegation_depth=2, +) + +child_scope = DelegationScope( + permissions=["state.patch", "events.log"], + denied_operations=["intent.delete"], + max_delegation_depth=1, +) + +attenuated = parent_scope.attenuate(child_scope) +# attenuated.permissions = ["events.log", "state.patch"] +# attenuated.denied_operations = ["intent.delete"] +# attenuated.max_delegation_depth = 1 +``` + +!!! warning "Attenuation is monotonic" + A child scope can never have more permissions than its parent. The `attenuate()` method enforces this by intersecting permission sets and taking the minimum delegation depth. + +--- + +## Governance Propagation + +Federation policies let the originating server enforce governance, budget, and observability rules on the remote server: + +```python +from openintent.federation.models import FederationPolicy + +policy = FederationPolicy( + governance={ + "require_human_approval": True, + "max_autonomous_steps": 5, + }, + budget={ + "max_llm_tokens": 10000, + "cost_ceiling_usd": 1.50, + }, + observability={ + "require_trace_propagation": True, + "log_level": "info", + }, +) +``` + +When two policies meet (e.g., the dispatcher's policy and the receiver's local policy), they compose using **strictest-wins**: + +```python +local_policy = FederationPolicy( + governance={"require_human_approval": False, "max_autonomous_steps": 10}, + budget={"max_llm_tokens": 50000}, +) + +composed = local_policy.compose_strictest(policy) +# composed.governance["require_human_approval"] = True (stricter) +# composed.governance["max_autonomous_steps"] = 5 (lower) +# composed.budget["max_llm_tokens"] = 10000 (lower) +``` + +--- + +## Federation Envelopes + +Every dispatched intent is wrapped in a **FederationEnvelope** — a self-contained message that carries the intent data, delegation scope, policy, and cryptographic signature. + +```python +from openintent.federation.models import FederationEnvelope + +envelope = FederationEnvelope( + dispatch_id="d-123", + source_server="https://research.example.com", + target_server="https://analytics.example.com", + intent_id="intent-001", + intent_title="Process Q1 Data", + intent_description="Run analytics on Q1 sales dataset", + intent_state={"dataset": "q1-sales"}, + delegation_scope=scope, + federation_policy=policy, + trace_context={"trace_id": "abc123", "span_id": "def456"}, + callback_url="https://research.example.com/api/v1/federation/callback", +) + +envelope_dict = envelope.to_dict() +``` + +The envelope is signed before dispatch and verified on receipt. See [Envelope Signing](#envelope-signing) below. + +--- + +## Attestations + +When a remote server completes work, it returns a **FederationAttestation** — a signed record of what happened, including governance compliance and resource usage. + +```python +from openintent.federation.models import FederationAttestation + +attestation = FederationAttestation( + dispatch_id="d-123", + governance_compliant=True, + usage={ + "llm_tokens": 4200, + "cost_usd": 0.35, + "duration_seconds": 12.5, + }, + trace_references=["trace-abc123"], + timestamp="2026-02-01T10:30:00Z", +) +``` + +Attestations are delivered via callbacks and can be cryptographically signed for tamper-evidence. + +--- + +## Callbacks + +The dispatching server can specify a `callback_url` to receive real-time updates about federated work: + +```python +from openintent.federation.models import FederationCallback, CallbackEventType + +callback = FederationCallback( + dispatch_id="d-123", + event_type=CallbackEventType.STATE_DELTA, + state_delta={"progress": 0.75, "phase": "analysis"}, + trace_id="abc123", + idempotency_key="cb-d-123-003", + timestamp="2026-02-01T10:30:00Z", +) +``` + +**Callback event types:** + +| Event Type | Description | +|-----------|-------------| +| `state_delta` | Partial state update from the remote agent. | +| `status_changed` | The remote intent changed status (e.g., assigned, completed). | +| `attestation` | Governance attestation delivered. | +| `budget_warning` | Remote execution approaching budget limits. | +| `completed` | Remote work finished successfully. | +| `failed` | Remote work failed. | + +Handle callbacks in your agent with lifecycle hooks: + +```python +from openintent.federation import on_federation_callback, on_budget_warning + +@Agent("coordinator") +class CoordinatorAgent: + @on_federation_callback + async def handle_callback(self, callback): + if callback.event_type == "completed": + print(f"Remote work done: {callback.state_delta}") + + @on_budget_warning + async def handle_budget(self, callback): + print(f"Budget warning for dispatch {callback.dispatch_id}") +``` + +--- + +## Discovery + +Every federated server exposes a discovery manifest at a well-known URL: + +``` +GET /.well-known/openintent-federation.json +``` + +Response: + +```json +{ + "server_did": "did:web:analytics.example.com", + "server_url": "https://analytics.example.com", + "protocol_version": "0.1", + "trust_policy": "allowlist", + "visibility_default": "public", + "supported_rfcs": ["RFC-0022", "RFC-0023"], + "peers": ["https://research.example.com"], + "public_key": "base64-encoded-ed25519-public-key", + "endpoints": { + "status": "/api/v1/federation/status", + "agents": "/api/v1/federation/agents", + "dispatch": "/api/v1/federation/dispatch", + "receive": "/api/v1/federation/receive" + } +} +``` + +The manifest includes the server's DID, public key, trust policy, and available endpoints. Remote servers use this to validate identity and negotiate trust before dispatching work. + +A DID document is also available at `/.well-known/did.json` for W3C DID resolution. + +--- + +## Envelope Signing + +Envelopes are signed using the server's Ed25519 private key (or HMAC-SHA256 fallback) before dispatch: + +```python +from openintent.federation.security import ServerIdentity, sign_envelope + +identity = ServerIdentity.generate("https://research.example.com") + +envelope_dict = envelope.to_dict() +signature = sign_envelope(identity, envelope_dict) +envelope_dict["signature"] = signature +``` + +The receiving server verifies the signature using the sender's public key (obtained from the discovery manifest or DID document): + +```python +from openintent.federation.security import verify_envelope_signature + +is_valid = verify_envelope_signature( + public_key_b64=sender_public_key, + envelope_dict=received_envelope, + signature_b64=received_envelope["signature"], +) +``` + +Signing uses canonical JSON serialization: keys sorted alphabetically, minimal separators, `signature` field excluded from the signing input. + +--- + +## Trust Policies + +Trust policies control which remote servers are allowed to dispatch work to your server: + +| Policy | Behavior | +|--------|----------| +| `open` | Accept dispatches from any server. | +| `allowlist` | Only accept from servers in the peer list. **Recommended default.** | +| `trustless` | Reject all incoming dispatches. | + +```python +from openintent.federation.security import TrustEnforcer +from openintent.federation.models import TrustPolicy + +enforcer = TrustEnforcer( + policy=TrustPolicy.ALLOWLIST, + allowed_peers=["https://research.example.com", "did:web:research.example.com"], +) + +enforcer.is_trusted("https://research.example.com") # True +enforcer.is_trusted("https://unknown.example.com") # False + +enforcer.add_peer("https://new-partner.example.com") +enforcer.is_trusted("https://new-partner.example.com") # True +``` + +Trust enforcement supports both server URLs and DID identifiers. The `TrustEnforcer` checks both when evaluating incoming dispatches. + +--- + +## UCAN Tokens + +For fine-grained delegation across server boundaries, federation uses **UCAN (User Controlled Authorization Networks)** tokens. UCANs encode delegation scope, expiry, and a proof chain that links back to the original authorizer. + +```python +from openintent.federation.security import UCANToken, ServerIdentity +from openintent.federation.models import DelegationScope + +identity = ServerIdentity.generate("https://research.example.com") + +token = UCANToken( + issuer="did:web:research.example.com", + audience="did:web:analytics.example.com", + scope=DelegationScope( + permissions=["state.patch", "events.log"], + max_delegation_depth=2, + ), +) + +encoded = token.encode(identity) # JWT-like string: header.payload.signature +decoded = UCANToken.decode(encoded) + +print(decoded.is_active()) # True (within time window) +print(decoded.is_expired()) # False +``` + +### Token Attenuation + +A server that received a UCAN can attenuate it and pass a weaker token to a third server: + +```python +child_scope = DelegationScope( + permissions=["state.patch"], + max_delegation_depth=1, +) + +child_token = token.attenuate( + audience="did:web:third-server.example.com", + child_scope=child_scope, + identity=analytics_identity, +) + +# child_token.proof_chain contains the parent token +# child_token.scope.permissions is intersected with parent +``` + +!!! warning "Delegation depth" + If `max_delegation_depth` reaches 0, further attenuation raises `ValueError`. This prevents unbounded delegation chains. + +--- + +## SSRF Protection + +All outbound federation URLs (target servers, callback URLs) are validated against SSRF attacks before any HTTP request is made: + +```python +from openintent.federation.security import validate_ssrf + +validate_ssrf("https://analytics.example.com") # True — public URL +validate_ssrf("http://localhost:8080") # False — blocked +validate_ssrf("http://169.254.169.254") # False — metadata endpoint +validate_ssrf("http://10.0.0.5/internal") # False — private network +``` + +**Blocked patterns:** + +- `localhost`, `127.0.0.1`, `0.0.0.0`, `::1` +- AWS/GCP metadata endpoints (`169.254.169.254`, `metadata.google.internal`) +- Private network ranges (`10.*`, `172.*`, `192.168.*`) +- Internal domains (`*.internal`, `*.local`) + +The server rejects dispatch and callback requests that fail SSRF validation with a `400 Bad Request` response. + +--- + +## HTTP Message Signatures + +For request-level authentication (beyond envelope signing), federation supports HTTP Message Signatures per RFC 9421: + +```python +from openintent.federation.security import MessageSignature, ServerIdentity + +identity = ServerIdentity.generate("https://research.example.com") + +sig = MessageSignature.create( + identity=identity, + method="POST", + target_uri="https://analytics.example.com/api/v1/federation/receive", + content_type="application/json", + body=b'{"dispatch_id": "d-123"}', +) + +headers = { + "Signature-Input": sig.to_header(), + "Signature": sig.signature_header(), +} +``` + +This signs the HTTP method, target URI, content type, and content digest — ensuring the request hasn't been tampered with in transit. + +--- + +## Federation Lifecycle Hooks + +Agents can react to federation events with dedicated lifecycle decorators: + +```python +from openintent.agents import Agent, on_assignment +from openintent.federation import ( + on_federation_received, + on_federation_callback, + on_budget_warning, +) + +@Agent("coordinator") +class FederatedCoordinator: + @on_assignment + async def handle(self, intent): + return {"status": "processing"} + + @on_federation_received + async def on_received(self, envelope): + """Called when this server receives a federated dispatch.""" + print(f"Received dispatch {envelope.dispatch_id} from {envelope.source_server}") + + @on_federation_callback + async def on_callback(self, callback): + """Called when a remote server sends a callback for our dispatch.""" + print(f"Callback: {callback.event_type} for {callback.dispatch_id}") + + @on_budget_warning + async def on_budget(self, callback): + """Called when remote execution approaches budget limits.""" + print(f"Budget warning: {callback.state_delta}") +``` + +--- + +## Best Practices + +**Use `allowlist` trust policy in production.** The `open` policy is convenient for development but should never be used in production. Explicitly list trusted peers. + +**Set delegation depth to 1 unless you need multi-hop.** Each additional hop adds latency, reduces permissions through attenuation, and increases the attack surface. + +**Always set `callback_url` for long-running dispatches.** Without callbacks, you have no visibility into remote execution progress. Use idempotency keys on callbacks to handle retries. + +**Enable trace context propagation.** Pass `trace_context` in dispatch requests so distributed traces span server boundaries. This integrates with [distributed tracing](distributed-tracing.md). + +**Validate SSRF before making any outbound request.** The SDK does this automatically for dispatch and callback URLs, but if you build custom federation logic, always call `validate_ssrf()` first. + +**Sign all envelopes.** Even with `allowlist` trust, envelope signing prevents tampering in transit. The overhead is negligible — Ed25519 signatures take microseconds. + +--- + +## Next Steps + +
+
+
Federation Examples
+

Runnable examples for dispatch, receive, signing, and multi-hop delegation.

+ See examples +
+
+
Federation API Reference
+

Complete API docs for all federation classes, decorators, and endpoints.

+ API reference +
+
+
RFC-0022
+

Full specification for cross-server federation protocol.

+ Read the RFC +
+
+
RFC-0023
+

Federation security: identity, signing, UCAN tokens, and trust policies.

+ Read the RFC +
+
diff --git a/docs/overrides/home.html b/docs/overrides/home.html index be2a219..b44b097 100644 --- a/docs/overrides/home.html +++ b/docs/overrides/home.html @@ -24,7 +24,7 @@
-
v0.13.0 — Server-Enforced Governance, Approval Gates & SSE Resume
+
v0.14.0 — Federation Protocol, Security & Python SDK Implementation

Stop Duct-Taping Your Agents Together

OpenIntent is a durable, auditable protocol for multi-agent coordination. Structured intents replace fragile chat chains. Versioned state replaces guesswork. Ship agent systems that actually work in production. @@ -38,7 +38,7 @@

Stop Duct-Taping Your Agents Together

-
21
+
23
RFCs
@@ -46,11 +46,11 @@

Stop Duct-Taping Your Agents Together

LLM Adapters
-
720+
+
800+
Tests
-
v0.13.0
+
v0.14.0
Latest
@@ -97,9 +97,21 @@

Core Capabilities

Built-in Server
-

FastAPI server implementing all 21 RFCs. SQLite or PostgreSQL. One command to start.

+

FastAPI server implementing all 23 RFCs. SQLite or PostgreSQL. One command to start.

Learn more
+
+
+
Federation
+

Cross-server agent dispatch with signed envelopes, UCAN delegation, and trust policies.

+ Learn more +
+
+
+
Federation Security
+

Ed25519 server identity, HTTP Message Signatures, UCAN tokens, and trust enforcement.

+ Learn more +
@@ -288,7 +300,7 @@

Protocol Architecture

Complete RFC Coverage

-

21 RFCs implemented. From intents to agent-to-agent messaging, every primitive you need.

+

23 RFCs implemented. From intents to cross-server federation, every primitive you need.

@@ -316,6 +328,8 @@

Complete RFC Coverage

+ +
0019Verifiable Event LogsProposed
0020Distributed TracingProposed
0021Agent-to-Agent MessagingProposed
0022Federation ProtocolProposed
0023Federation SecurityProposed
diff --git a/docs/rfcs/0022-federation-protocol.md b/docs/rfcs/0022-federation-protocol.md new file mode 100644 index 0000000..f12e6b5 --- /dev/null +++ b/docs/rfcs/0022-federation-protocol.md @@ -0,0 +1,161 @@ +# RFC-0022: Federation Protocol + +**Status:** Proposed +**Created:** 2026-02-25 +**Authors:** OpenIntent Contributors +**Requires:** RFC-0001 (Intents), RFC-0003 (Leasing), RFC-0011 (Access Control), RFC-0016 (Agent Lifecycle), RFC-0021 (Messaging) + +--- + +## Abstract + +This RFC defines the federation contract for cross-server agent coordination. It covers how independent OpenIntent servers discover each other, dispatch intents to remote agents, receive results, and maintain governance coherence across organizational boundaries. + +## Motivation + +A single OpenIntent server handles intra-organization coordination well, but production agent systems frequently need to span multiple organizations, cloud regions, or trust boundaries. Without a standard federation mechanism, each deployment invents its own inter-server protocol, leading to incompatible implementations and security gaps. + +Federation addresses three concrete needs: + +1. **Cross-org agent access.** Organization A has a specialized research agent; Organization B needs to use it for a task. There is no standard way to discover, invoke, or pay for that agent's work. + +2. **Geographic distribution.** A global deployment runs servers in three regions. Intents created in one region may need agents running in another. The protocol must route intents to the right server without manual configuration per intent. + +3. **Trust boundaries.** An upstream server dispatches work to a downstream server. The downstream server must enforce its own governance policies while respecting the upstream's constraints. Neither server should blindly trust the other. + +## Specification + +### Federation Envelope + +All cross-server communication uses a `FederationEnvelope`: + +```json +{ + "envelope_id": "fed_abc123", + "source_server": "https://server-a.example.com", + "target_server": "https://server-b.example.com", + "intent_snapshot": { }, + "delegation_scope": { + "permissions": ["read", "write"], + "denied_operations": [], + "max_delegation_depth": 2, + "budget_limit": 100.0 + }, + "callback_url": "https://server-a.example.com/api/v1/federation/callback", + "trace_context": { + "trace_id": "abc123", + "parent_event_id": "evt_456" + }, + "timestamp": "2026-02-25T00:00:00Z", + "signature": null +} +``` + +### Agent Visibility + +Agents declare their federation visibility: + +| Visibility | Behavior | +|---|---| +| `public` | Listed in federation discovery, available to any peer | +| `unlisted` | Not listed in discovery, available if agent ID is known | +| `private` | Not available to federated requests (default) | + +### Peer Relationships + +Servers relate to each other as: + +| Relationship | Direction | Use Case | +|---|---|---| +| `peer` | Bidirectional | Equal partners, mutual dispatch | +| `upstream` | Inbound only | Receives work from this server | +| `downstream` | Outbound only | Sends work to this server | + +### Delegation Scope + +Each envelope carries a `DelegationScope` that narrows per hop: + +- **Permissions:** Intersection with parent scope (can only remove, never add) +- **Denied operations:** Union with parent scope (accumulates restrictions) +- **Max delegation depth:** Decremented per hop, dispatch rejected at zero +- **Budget limit:** Minimum of parent and local limit + +```python +child_scope = parent_scope.attenuate( + permissions=["read"], + denied_operations=["delete"], + max_delegation_depth=1, + budget_limit=50.0 +) +``` + +### Governance Propagation + +When two servers' governance policies overlap, the strictest rule wins: + +- **Booleans:** `require_human_approval = A or B` (if either requires it, the composed policy requires it) +- **Numerics:** `max_cost = min(A, B)` (the lower limit applies) +- **Observability:** Merged (union of required fields) + +```python +composed = FederationPolicy.compose_strictest(local_policy, remote_policy) +``` + +### Federation Callbacks + +After processing a dispatched intent, the receiving server sends a callback: + +```json +{ + "envelope_id": "fed_abc123", + "event_type": "completed", + "intent_id": "intent_789", + "result": { }, + "attestation": { } +} +``` + +Callbacks use at-least-once delivery with idempotency keys. + +### Discovery + +Servers publish federation capabilities at: + +``` +GET /.well-known/openintent-federation.json +``` + +Response: + +```json +{ + "server_id": "server-a", + "server_url": "https://server-a.example.com", + "protocol_version": "0.14.0", + "capabilities": ["dispatch", "receive", "callbacks"], + "public_agents": ["researcher", "summarizer"], + "trust_policy": "allowlist", + "did": "did:web:server-a.example.com" +} +``` + +### REST Endpoints + +| Method | Path | Description | +|---|---|---| +| `GET` | `/api/v1/federation/status` | Server federation status | +| `GET` | `/api/v1/federation/agents` | List federated agents | +| `POST` | `/api/v1/federation/dispatch` | Dispatch intent to remote server | +| `POST` | `/api/v1/federation/receive` | Receive dispatched intent | + +### Federation-Aware Leasing + +Federated intents follow standard leasing (RFC-0003) on the receiving server. The originating server retains intent authority — the receiving server operates on a copy. + +## Security Considerations + +Federation security is defined separately in RFC-0023. Without RFC-0023, federation operates in trusted mode (suitable for intra-org deployments). RFC-0023 is required for cross-org or public federation. + +## SDK Implementation + +See the [Federation Guide](../guide/federation.md) for Python SDK usage and the [Federation API Reference](../api/federation.md) for complete class documentation. diff --git a/docs/rfcs/0023-federation-security.md b/docs/rfcs/0023-federation-security.md new file mode 100644 index 0000000..7925501 --- /dev/null +++ b/docs/rfcs/0023-federation-security.md @@ -0,0 +1,158 @@ +# RFC-0023: Federation Security + +**Status:** Proposed +**Created:** 2026-02-25 +**Authors:** OpenIntent Contributors +**Requires:** RFC-0001 (Intents), RFC-0018 (Cryptographic Identity), RFC-0019 (Verifiable Event Logs), RFC-0022 (Federation Protocol) + +--- + +## Abstract + +This RFC layers authentication, authorization, and verification onto federation (RFC-0022). It defines server identity, signed envelopes, delegation tokens, trust policies, and cross-server event log reconciliation. + +## Motivation + +RFC-0022 defines the federation contract — what data moves between servers and when. It deliberately defers security to this companion RFC so that trusted intra-org deployments can federate without cryptographic overhead. + +Cross-org and public federation require answers to four questions: + +1. **Identity.** How does Server B verify that a request actually came from Server A? +2. **Authorization.** How does Server B verify that Server A is allowed to dispatch this particular intent with these permissions? +3. **Integrity.** How does Server A verify that Server B's results haven't been tampered with? +4. **Delegation.** How does Server B prove to Server C that it's acting on behalf of Server A, with narrowed permissions? + +## Specification + +### Server Identity + +Servers identify themselves using `did:web` decentralized identifiers backed by Ed25519 key pairs: + +```python +from openintent.federation.security import ServerIdentity + +identity = ServerIdentity.generate(server_url="https://server-a.example.com") +# identity.did => "did:web:server-a.example.com" +# identity.public_key => Ed25519 public key bytes +# identity.private_key => Ed25519 private key bytes +``` + +The DID document is published at `/.well-known/did.json`: + +```json +{ + "@context": "https://www.w3.org/ns/did/v1", + "id": "did:web:server-a.example.com", + "verificationMethod": [{ + "id": "did:web:server-a.example.com#key-1", + "type": "Ed25519VerificationKey2020", + "controller": "did:web:server-a.example.com", + "publicKeyMultibase": "z6Mk..." + }], + "authentication": ["did:web:server-a.example.com#key-1"] +} +``` + +### Envelope Signing + +Federation envelopes are signed using Ed25519: + +```python +from openintent.federation.security import sign_envelope, verify_envelope_signature + +signed = sign_envelope(envelope, identity) +# signed.signature is set + +is_valid = verify_envelope_signature(signed, identity.public_key) +``` + +### HTTP Message Signatures (RFC 9421) + +For transport-level authentication, `MessageSignature` implements RFC 9421: + +```python +from openintent.federation.security import MessageSignature + +sig = MessageSignature.create( + method="POST", + url="/api/v1/federation/dispatch", + body=envelope_bytes, + identity=identity +) + +is_valid = MessageSignature.verify(sig, identity.public_key) +``` + +### Trust Policies + +`TrustEnforcer` implements three trust modes: + +| Policy | Behavior | +|---|---| +| `open` | Accept all federation requests | +| `allowlist` | Accept only from listed server DIDs | +| `trustless` | Require valid signature and UCAN delegation chain | + +```python +from openintent.federation.security import TrustEnforcer, TrustPolicy + +enforcer = TrustEnforcer( + policy=TrustPolicy.ALLOWLIST, + allowed_servers=["did:web:server-b.example.com"] +) + +enforcer.enforce(envelope) # raises if not allowed +``` + +### UCAN Delegation Tokens + +UCAN (User Controlled Authorization Networks) tokens encode delegation chains: + +```python +from openintent.federation.security import UCANToken + +token = UCANToken.create( + issuer=identity, + audience="did:web:server-b.example.com", + capabilities=["dispatch", "receive"], + expiry_seconds=3600 +) + +encoded = token.encode() # base64url JWT-style string +decoded = UCANToken.decode(encoded) + +# Attenuate for sub-delegation +narrowed = token.attenuate(capabilities=["receive"]) +``` + +### SSRF Protection + +Outbound federation URLs are validated to prevent Server-Side Request Forgery: + +```python +from openintent.federation.security import validate_ssrf + +validate_ssrf("https://server-b.example.com") # OK +validate_ssrf("http://localhost:8080") # raises ValueError +validate_ssrf("http://169.254.169.254") # raises ValueError (cloud metadata) +``` + +Blocked ranges: localhost, `127.0.0.0/8`, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `169.254.0.0/16`, IPv6 loopback. + +### Cross-Server Event Log Reconciliation + +Federated intents produce events on both the originating and receiving servers. RFC-0019 Merkle primitives allow both servers to verify event log consistency: + +1. Each server maintains its own hash chain for federated events +2. Attestations include the Merkle root at completion time +3. Either server can request an inclusion proof for any event + +## Security Considerations + +- **Key rotation:** `ServerIdentity` supports key rotation by publishing updated DID documents. Old keys remain valid for signature verification during a transition period. +- **Token expiry:** UCAN tokens include mandatory expiry. Expired tokens are rejected regardless of signature validity. +- **HMAC fallback:** For environments without Ed25519 support, HMAC-SHA256 signing is available as a fallback (shared secret required). + +## SDK Implementation + +See the [Federation Guide](../guide/federation.md) for usage examples and the [Federation API Reference](../api/federation.md) for complete class documentation. diff --git a/mcp-server/package.json b/mcp-server/package.json index a26beb6..32f87a4 100644 --- a/mcp-server/package.json +++ b/mcp-server/package.json @@ -1,6 +1,6 @@ { "name": "@openintentai/mcp-server", - "version": "0.13.5", + "version": "0.14.0", "description": "MCP server exposing the OpenIntent Coordination Protocol as MCP tools and resources", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/mcp-server/src/client.ts b/mcp-server/src/client.ts index 71d2ef0..0b0df06 100644 --- a/mcp-server/src/client.ts +++ b/mcp-server/src/client.ts @@ -112,13 +112,13 @@ export class OpenIntentClient { async createIntent(params: { title: string; description?: string; - constraints?: string[]; + constraints?: Record; initial_state?: Record; }): Promise { return this.request("POST", "/api/v1/intents", { title: params.title, description: params.description ?? "", - constraints: params.constraints ?? [], + constraints: params.constraints ?? {}, state: params.initial_state ?? {}, created_by: this.config.server.agent_id, }); @@ -681,6 +681,58 @@ export class OpenIntentClient { }); } + async registerAgent(params: { + agent_id: string; + name?: string; + role_id?: string; + capabilities?: string[]; + capacity?: Record; + endpoint?: string; + heartbeat_config?: Record; + metadata?: Record; + drain_timeout_seconds?: number; + }): Promise { + return this.request("POST", "/api/v1/agents/register", { + agent_id: params.agent_id, + name: params.name, + role_id: params.role_id, + capabilities: params.capabilities ?? [], + capacity: params.capacity, + endpoint: params.endpoint, + heartbeat_config: params.heartbeat_config, + metadata: params.metadata ?? {}, + drain_timeout_seconds: params.drain_timeout_seconds, + }); + } + + async getAgentRecord(params: { + agent_id: string; + }): Promise { + return this.request("GET", `/api/v1/agents/${params.agent_id}/record`); + } + + async listAgents(params: { + status?: string; + role_id?: string; + }): Promise { + const query: Record = {}; + if (params.status) query.status = params.status; + if (params.role_id) query.role_id = params.role_id; + const qs = new URLSearchParams(query).toString(); + return this.request("GET", `/api/v1/agents${qs ? `?${qs}` : ""}`); + } + + async agentHeartbeat(params: { + agent_id: string; + status?: string; + current_load?: number; + }): Promise { + return this.request("POST", `/api/v1/agents/${params.agent_id}/heartbeat`, { + status: params.status ?? "active", + current_load: params.current_load ?? 0, + }); + } + async getHealth(params: { agent_id: string; }): Promise { @@ -694,7 +746,16 @@ export class OpenIntentClient { }): Promise { return this.request("PUT", `/api/v1/agents/${params.agent_id}/status`, { status: params.status, - reason: params.reason ?? "", + reason: params.reason, + }); + } + + async updateAgentStatus(params: { + agent_id: string; + status: string; + }): Promise { + return this.request("PUT", `/api/v1/agents/${params.agent_id}/status`, { + status: params.status, }); } @@ -829,4 +890,40 @@ export class OpenIntentClient { linked_by: this.config.server.agent_id, }); } + + async getFederationStatus(): Promise { + return this.request("GET", "/api/v1/federation/status"); + } + + async listFederatedAgents(): Promise { + return this.request("GET", "/api/v1/federation/agents"); + } + + async federationDispatch(params: { + intent_id: string; + agent_id: string; + trace_id?: string; + }): Promise { + return this.request("POST", "/api/v1/federation/dispatch", { + intent_id: params.intent_id, + agent_id: params.agent_id, + trace_id: params.trace_id, + }); + } + + async federationReceive(params: { + dispatch_id: string; + intent: Record; + agent_id: string; + source_server: string; + trace_id?: string; + }): Promise { + return this.request("POST", "/api/v1/federation/receive", { + dispatch_id: params.dispatch_id, + intent: params.intent, + agent_id: params.agent_id, + source_server: params.source_server, + trace_id: params.trace_id, + }); + } } diff --git a/mcp-server/src/index.ts b/mcp-server/src/index.ts index b7686f1..069135b 100644 --- a/mcp-server/src/index.ts +++ b/mcp-server/src/index.ts @@ -30,7 +30,7 @@ async function main() { const server = new Server( { name: "openintent-mcp", - version: "0.13.5", + version: "0.14.0", }, { capabilities: { diff --git a/mcp-server/src/security.ts b/mcp-server/src/security.ts index de6411b..9f9712b 100644 --- a/mcp-server/src/security.ts +++ b/mcp-server/src/security.ts @@ -120,6 +120,12 @@ export const TOOL_TIERS: Record = { openintent_get_trace: "read", openintent_start_trace: "write", openintent_link_spans: "write", + + // Federation (RFC-0022) + openintent_federation_status: "read", + openintent_list_federated_agents: "read", + openintent_federation_dispatch: "admin", + openintent_federation_receive: "admin", }; const TIER_ORDER: ToolTier[] = ["read", "write", "admin"]; diff --git a/mcp-server/src/tools.ts b/mcp-server/src/tools.ts index f51646d..7a4a5b7 100644 --- a/mcp-server/src/tools.ts +++ b/mcp-server/src/tools.ts @@ -36,9 +36,9 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [ title: { type: "string", description: "Human-readable title for the intent" }, description: { type: "string", description: "Detailed description of the goal" }, constraints: { - type: "array", - items: { type: "string" }, - description: "Optional constraints or rules the intent must satisfy", + type: "object", + description: "Optional constraints object the intent must satisfy (e.g. { rules: [...], deadline: '...' })", + additionalProperties: true, }, initial_state: { type: "object", @@ -1002,6 +1002,92 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [ tier: "admin" as ToolTier, }, + // ── Agent Registration (RFC-0016) ──────────────────────────────── + { + name: "openintent_register_agent", + description: + "Register an agent on the protocol server with capabilities and metadata. " + + "If the agent_id already exists, the registration is updated.", + inputSchema: { + type: "object", + properties: { + agent_id: { type: "string", description: "Unique identifier for the agent" }, + name: { type: "string", description: "Human-readable agent name" }, + role_id: { type: "string", description: "Role identifier (e.g. researcher, summarizer)" }, + capabilities: { + type: "array", + items: { type: "string" }, + description: "List of capability strings the agent advertises", + }, + endpoint: { type: "string", description: "Optional callback endpoint URL" }, + drain_timeout_seconds: { type: "integer", description: "Graceful shutdown timeout in seconds" }, + }, + required: ["agent_id"], + }, + tier: "write" as ToolTier, + }, + { + name: "openintent_get_agent_record", + description: + "Retrieve the registration record for a specific agent by ID.", + inputSchema: { + type: "object", + properties: { + agent_id: { type: "string", description: "The agent to look up" }, + }, + required: ["agent_id"], + }, + tier: "read" as ToolTier, + }, + { + name: "openintent_list_registered_agents", + description: + "List all registered agents, optionally filtered by status or role.", + inputSchema: { + type: "object", + properties: { + status: { type: "string", enum: ["active", "idle", "draining", "offline"], description: "Filter by agent status" }, + role_id: { type: "string", description: "Filter by role identifier" }, + }, + }, + tier: "read" as ToolTier, + }, + { + name: "openintent_agent_heartbeat", + description: + "Send a heartbeat for a registered agent, updating its last-seen timestamp and optionally its status and load.", + inputSchema: { + type: "object", + properties: { + agent_id: { type: "string", description: "The agent sending the heartbeat" }, + status: { type: "string", description: "Current agent status" }, + current_load: { type: "integer", description: "Current workload count" }, + }, + required: ["agent_id"], + }, + tier: "write" as ToolTier, + }, + + { + name: "openintent_update_agent_status", + description: + "Update the lifecycle status of a registered agent (RFC-0016). " + + "Distinct from openintent_set_agent_status which is coordinator-level.", + inputSchema: { + type: "object", + properties: { + agent_id: { type: "string", description: "The agent to update" }, + status: { + type: "string", + enum: ["active", "idle", "draining", "offline"], + description: "New lifecycle status", + }, + }, + required: ["agent_id", "status"], + }, + tier: "write" as ToolTier, + }, + // ── Triggers (RFC-0017) ─────────────────────────────────────────── { name: "openintent_create_trigger", @@ -1224,6 +1310,67 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [ }, tier: "write" as ToolTier, }, + + // ── Federation (RFC-0022) ─────────────────────────────────────────── + { + name: "openintent_federation_status", + description: + "Get the federation status of this server, including how many agents " + + "are federated (registered from remote servers) and their details.", + inputSchema: { + type: "object", + properties: {}, + }, + tier: "read" as ToolTier, + }, + { + name: "openintent_list_federated_agents", + description: + "List all federated agents — agents that were registered from a remote " + + "server and have an origin_server_url indicating where they actually execute.", + inputSchema: { + type: "object", + properties: {}, + }, + tier: "read" as ToolTier, + }, + { + name: "openintent_federation_dispatch", + description: + "Dispatch an intent to a federated agent's origin server. The intent is " + + "forwarded to the remote server where the agent actually executes. The remote " + + "server creates a local copy of the intent and assigns the agent to it. " + + "A federation_dispatched event is logged for audit.", + inputSchema: { + type: "object", + properties: { + intent_id: { type: "string", description: "ID of the intent to dispatch" }, + agent_id: { type: "string", description: "ID of the federated agent" }, + trace_id: { type: "string", description: "Optional trace ID for distributed tracing (RFC-0020)" }, + }, + required: ["intent_id", "agent_id"], + }, + tier: "admin" as ToolTier, + }, + { + name: "openintent_federation_receive", + description: + "Receive a dispatched intent from a remote server. Creates a local intent " + + "copy, assigns the specified agent, and logs a federation_received event. " + + "This endpoint is called by remote servers during federation dispatch.", + inputSchema: { + type: "object", + properties: { + dispatch_id: { type: "string", description: "Unique dispatch identifier from the sending server" }, + intent: { type: "object", description: "Intent payload to create locally" }, + agent_id: { type: "string", description: "Agent ID to assign on this server" }, + source_server: { type: "string", description: "Hostname of the dispatching server" }, + trace_id: { type: "string", description: "Optional trace ID for distributed tracing" }, + }, + required: ["dispatch_id", "intent", "agent_id"], + }, + tier: "admin" as ToolTier, + }, ]; /** @@ -1249,7 +1396,7 @@ export async function handleToolCall( result = await client.createIntent({ title: args.title as string, description: args.description as string | undefined, - constraints: args.constraints as string[] | undefined, + constraints: args.constraints as Record | undefined, initial_state: args.initial_state as Record | undefined, }); break; @@ -1636,6 +1783,46 @@ export async function handleToolCall( }); break; + // ── Agent Registration (RFC-0016) ──────────────────────────── + case "openintent_register_agent": + result = await client.registerAgent({ + agent_id: args.agent_id as string, + name: args.name as string | undefined, + role_id: args.role_id as string | undefined, + capabilities: args.capabilities as string[] | undefined, + endpoint: args.endpoint as string | undefined, + drain_timeout_seconds: args.drain_timeout_seconds as number | undefined, + }); + break; + + case "openintent_get_agent_record": + result = await client.getAgentRecord({ + agent_id: args.agent_id as string, + }); + break; + + case "openintent_list_registered_agents": + result = await client.listAgents({ + status: args.status as string | undefined, + role_id: args.role_id as string | undefined, + }); + break; + + case "openintent_agent_heartbeat": + result = await client.agentHeartbeat({ + agent_id: args.agent_id as string, + status: args.status as string | undefined, + current_load: args.current_load as number | undefined, + }); + break; + + case "openintent_update_agent_status": + result = await client.updateAgentStatus({ + agent_id: args.agent_id as string, + status: args.status as string, + }); + break; + // ── Triggers (RFC-0017) ─────────────────────────────────────── case "openintent_create_trigger": result = await client.createTrigger({ @@ -1732,6 +1919,32 @@ export async function handleToolCall( }); break; + case "openintent_federation_status": + result = await client.getFederationStatus(); + break; + + case "openintent_list_federated_agents": + result = await client.listFederatedAgents(); + break; + + case "openintent_federation_dispatch": + result = await client.federationDispatch({ + intent_id: args.intent_id as string, + agent_id: args.agent_id as string, + trace_id: args.trace_id as string | undefined, + }); + break; + + case "openintent_federation_receive": + result = await client.federationReceive({ + dispatch_id: args.dispatch_id as string, + intent: args.intent as Record, + agent_id: args.agent_id as string, + source_server: args.source_server as string, + trace_id: args.trace_id as string | undefined, + }); + break; + default: return errorResult(`Unknown tool: ${name}`); } diff --git a/mkdocs.yml b/mkdocs.yml index 3369c21..a1f272c 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,5 +1,5 @@ site_name: OpenIntent SDK -site_description: "The Python SDK for structured multi-agent coordination. 21 RFCs. Decorator-first agents. Built-in server. MCP integration." +site_description: "The Python SDK for structured multi-agent coordination. 23 RFCs. Decorator-first agents. Built-in server. Federation. MCP integration." site_url: https://openintent-ai.github.io/openintent/ repo_url: https://github.com/openintent-ai/openintent repo_name: openintent-ai/openintent @@ -127,6 +127,7 @@ nav: - Verifiable Event Logs: guide/verifiable-logs.md - Distributed Tracing: guide/distributed-tracing.md - Agent-to-Agent Messaging: guide/messaging.md + - Federation: guide/federation.md - MCP Integration: guide/mcp.md - YAML Workflows: guide/workflows.md - Agent Abstractions: guide/agents.md @@ -140,6 +141,7 @@ nav: - Agents: api/agents.md - Adapters: api/adapters.md - Server: api/server.md + - Federation: api/federation.md - Exceptions: api/exceptions.md - Specification: - Workflow YAML: spec/workflow-yaml.md @@ -165,6 +167,8 @@ nav: - "0019 \u2014 Verifiable Event Logs": rfcs/0019-verifiable-event-logs.md - "0020 \u2014 Distributed Tracing": rfcs/0020-distributed-tracing.md - "0021 \u2014 Agent-to-Agent Messaging": rfcs/0021-agent-to-agent-messaging.md + - "0022 \u2014 Federation Protocol": rfcs/0022-federation-protocol.md + - "0023 \u2014 Federation Security": rfcs/0023-federation-security.md - Changelog: changelog.md - Examples: - Multi-Agent Workflow: examples/multi-agent.md @@ -188,6 +192,7 @@ nav: - YAML Workflows: examples/yaml-workflows.md - Agent-to-Agent Messaging: examples/messaging.md - MCP Integration: examples/mcp.md + - Federation: examples/federation.md - Server & Deployment: examples/server-deployment.md - Portfolios: examples/portfolios.md @@ -199,10 +204,10 @@ extra: link: https://pypi.org/project/openintent/ version: provider: mike - announcement: "v0.13.0 is here — Server-enforced governance, approval gates, SSE-driven resume, and complete RFC 0001–0021 coverage. Read the changelog →" + announcement: "v0.14.0 is here — Federation protocol (RFC-0022), federation security (RFC-0023), and complete RFC 0001–0023 coverage. Read the changelog →" meta: - name: description - content: "OpenIntent Python SDK — structured multi-agent coordination protocol with decorator-first agents, 21 RFCs, 7 LLM adapters, MCP integration, and built-in FastAPI server." + content: "OpenIntent Python SDK — structured multi-agent coordination protocol with decorator-first agents, 23 RFCs, 7 LLM adapters, federation, MCP integration, and built-in FastAPI server." - name: og:title content: "OpenIntent SDK — Multi-Agent Coordination Protocol" - name: og:description @@ -216,7 +221,7 @@ extra: - name: twitter:title content: "OpenIntent SDK" - name: twitter:description - content: "The Python SDK for structured multi-agent coordination. 21 RFCs. Decorator-first agents. MCP integration. Built-in server." + content: "The Python SDK for structured multi-agent coordination. 23 RFCs. Decorator-first agents. Federation. MCP integration. Built-in server." extra_css: - stylesheets/extra.css diff --git a/openintent/__init__.py b/openintent/__init__.py index 9309244..26e4df1 100644 --- a/openintent/__init__.py +++ b/openintent/__init__.py @@ -47,6 +47,36 @@ OpenIntentError, ValidationError, ) +from .federation import ( + AgentVisibility, + CallbackEventType, + DelegationScope, + DispatchResult, + DispatchStatus, + FederatedAgent, + Federation, + FederationAttestation, + FederationCallback, + FederationEnvelope, + FederationManifest, + FederationPolicy, + FederationStatus, + MessageSignature, + PeerRelationship, + ReceiveResult, + ServerIdentity, + TrustEnforcer, + TrustPolicy, + UCANToken, + on_budget_warning, + on_federation_callback, + on_federation_received, + resolve_did_web, + sign_envelope, + validate_ssrf, + verify_envelope_signature, +) +from .federation.models import PeerInfo as FederationPeerInfo from .llm import LLMConfig, LLMEngine, Tool, ToolDef, define_tool, tool from .mcp import MCPTool, parse_mcp_uri from .models import ( @@ -203,7 +233,7 @@ def get_server() -> tuple[Any, Any, Any]: ) -__version__ = "0.13.5" +__version__ = "0.14.0" __all__ = [ "OpenIntentClient", "AsyncOpenIntentClient", @@ -368,4 +398,33 @@ def get_server() -> tuple[Any, Any, Any]: "tool", "MCPTool", "parse_mcp_uri", + # Federation (RFC-0022 & RFC-0023) + "AgentVisibility", + "CallbackEventType", + "DelegationScope", + "DispatchResult", + "DispatchStatus", + "FederatedAgent", + "Federation", + "FederationAttestation", + "FederationCallback", + "FederationEnvelope", + "FederationManifest", + "FederationPeerInfo", + "FederationPolicy", + "FederationStatus", + "MessageSignature", + "PeerRelationship", + "ReceiveResult", + "ServerIdentity", + "TrustEnforcer", + "TrustPolicy", + "UCANToken", + "on_budget_warning", + "on_federation_callback", + "on_federation_received", + "resolve_did_web", + "sign_envelope", + "validate_ssrf", + "verify_envelope_signature", ] diff --git a/openintent/adapters/anthropic_adapter.py b/openintent/adapters/anthropic_adapter.py index e382d9c..35a2e49 100644 --- a/openintent/adapters/anthropic_adapter.py +++ b/openintent/adapters/anthropic_adapter.py @@ -318,22 +318,33 @@ def __enter__(self) -> "AnthropicStreamWrapper": return self._wrapper def _resolve_usage(self) -> None: - """Ensure usage data is populated, falling back to get_final_message. + """Ensure usage data is populated from the most authoritative source. - If ``_consume_events`` captured usage from streaming events, this - is a no-op. Otherwise it calls the SDK's ``get_final_message()`` - which always carries authoritative usage — even for models that - report usage in non-standard event positions (e.g. extended - thinking models like opus-4). - """ - if self._usage is not None: - return + Always attempts ``get_final_message()`` on the underlying Anthropic + ``MessageStream`` which returns the SDK-accumulated message with + authoritative usage data — even for models with extended thinking + or non-standard event ordering (e.g. opus-4). + + Falls back to whatever ``_consume_events`` captured via its + ``try/finally`` block if ``get_final_message()`` fails (e.g. + the stream was only partially consumed before early exit). + Previous versions short-circuited when ``_usage`` was already set + by ``_consume_events``, but that could leave ``output_tokens`` at + zero when the generator exited before the ``message_delta`` event + arrived. + """ if not self._wrapper: return try: - self._wrapper.get_final_message() + msg = self._wrapper._stream.get_final_message() + usage = getattr(msg, "usage", None) + if usage: + self._usage = { + "input_tokens": getattr(usage, "input_tokens", 0), + "output_tokens": getattr(usage, "output_tokens", 0), + } except Exception: pass diff --git a/openintent/agents.py b/openintent/agents.py index 51c56c7..2bcd7bd 100644 --- a/openintent/agents.py +++ b/openintent/agents.py @@ -1542,6 +1542,7 @@ def Agent( # noqa: N802 - intentionally capitalized as class-like decorator max_tool_rounds: int = 10, planning: bool = False, stream_by_default: bool = False, + federation_visibility: Optional[str] = None, **kwargs: Any, ) -> Callable[[type], type]: """ @@ -1603,6 +1604,7 @@ def new_init( self._config.tools = tools self._config.auto_heartbeat = auto_heartbeat self._governance_policy = governance_policy + self._federation_visibility = federation_visibility if model: _setup_llm_engine( @@ -1735,6 +1737,8 @@ def Coordinator( # noqa: N802 max_tool_rounds: int = 10, planning: bool = True, stream_by_default: bool = False, + federation_visibility: Optional[str] = None, + federation_policy: Optional[dict[str, Any]] = None, **kwargs: Any, ) -> Callable[[type], type]: """ @@ -1797,6 +1801,8 @@ def new_init( self._config.tools = tools self._config.auto_heartbeat = auto_heartbeat self._governance_policy = governance_policy + self._federation_visibility = federation_visibility + self._federation_policy = federation_policy self._agents_list = agents or [] self._strategy = strategy self._guardrails = guardrails or [] diff --git a/openintent/client.py b/openintent/client.py index d7c830d..4bf05db 100644 --- a/openintent/client.py +++ b/openintent/client.py @@ -11,6 +11,7 @@ import httpx if TYPE_CHECKING: + from .federation.models import DispatchResult, FederationStatus, ReceiveResult from .streaming import EventQueue, SSEStream from .exceptions import ( @@ -2984,6 +2985,122 @@ def ask( f"No response received within {timeout}s for message {msg_id}" ) + # ==================== Federation (RFC-0022) ==================== + + def federation_status(self) -> "FederationStatus": + from .federation.models import FederationStatus + + response = self._client.get("/api/v1/federation/status") + data = self._handle_response(response) + return FederationStatus.from_dict(data) + + def list_federated_agents( + self, source_server: Optional[str] = None + ) -> list[dict[str, Any]]: + headers: dict[str, str] = {} + if source_server: + headers["X-Source-Server"] = source_server + response = self._client.get( + "/api/v1/federation/agents", + headers=headers, + ) + data = self._handle_response(response) + agents: list[dict[str, Any]] = data.get("agents", []) + return agents + + def federation_dispatch( + self, + intent_id: str, + target_server: str, + agent_id: Optional[str] = None, + delegation_scope: Optional[dict[str, Any]] = None, + federation_policy: Optional[dict[str, Any]] = None, + callback_url: Optional[str] = None, + trace_context: Optional[dict[str, str]] = None, + ) -> "DispatchResult": + from .federation.models import DispatchResult + + payload: dict[str, Any] = { + "intent_id": intent_id, + "target_server": target_server, + } + if agent_id: + payload["agent_id"] = agent_id + if delegation_scope: + payload["delegation_scope"] = delegation_scope + if federation_policy: + payload["federation_policy"] = federation_policy + if callback_url: + payload["callback_url"] = callback_url + if trace_context: + payload["trace_context"] = trace_context + response = self._client.post("/api/v1/federation/dispatch", json=payload) + data = self._handle_response(response) + return DispatchResult.from_dict(data) + + def federation_receive( + self, + dispatch_id: str, + source_server: str, + intent_id: str, + intent_title: str, + intent_description: str = "", + intent_state: Optional[dict[str, Any]] = None, + agent_id: Optional[str] = None, + delegation_scope: Optional[dict[str, Any]] = None, + federation_policy: Optional[dict[str, Any]] = None, + callback_url: Optional[str] = None, + idempotency_key: Optional[str] = None, + ) -> "ReceiveResult": + from .federation.models import ReceiveResult + + payload: dict[str, Any] = { + "dispatch_id": dispatch_id, + "source_server": source_server, + "intent_id": intent_id, + "intent_title": intent_title, + "intent_description": intent_description, + "intent_state": intent_state or {}, + } + if agent_id: + payload["agent_id"] = agent_id + if delegation_scope: + payload["delegation_scope"] = delegation_scope + if federation_policy: + payload["federation_policy"] = federation_policy + if callback_url: + payload["callback_url"] = callback_url + if idempotency_key: + payload["idempotency_key"] = idempotency_key + response = self._client.post("/api/v1/federation/receive", json=payload) + data = self._handle_response(response) + return ReceiveResult.from_dict(data) + + def send_federation_callback( + self, + callback_url: str, + dispatch_id: str, + event_type: str, + state_delta: Optional[dict[str, Any]] = None, + attestation: Optional[dict[str, Any]] = None, + trace_id: Optional[str] = None, + ) -> dict[str, Any]: + payload: dict[str, Any] = { + "dispatch_id": dispatch_id, + "event_type": event_type, + "state_delta": state_delta or {}, + } + if attestation: + payload["attestation"] = attestation + if trace_id: + payload["trace_id"] = trace_id + response = self._client.post(callback_url, json=payload) + return self._handle_response(response) + + def federation_discover(self) -> dict[str, Any]: + response = self._client.get("/.well-known/openintent-federation.json") + return self._handle_response(response) + def close(self) -> None: """Close the HTTP client connection.""" self._client.close() @@ -4936,6 +5053,122 @@ async def ask( f"No response received within {timeout}s for message {msg_id}" ) + # ==================== Federation (RFC-0022) ==================== + + async def federation_status(self) -> "FederationStatus": + from .federation.models import FederationStatus + + response = await self._client.get("/api/v1/federation/status") + data = self._handle_response(response) + return FederationStatus.from_dict(data) + + async def list_federated_agents( + self, source_server: Optional[str] = None + ) -> list[dict[str, Any]]: + headers: dict[str, str] = {} + if source_server: + headers["X-Source-Server"] = source_server + response = await self._client.get( + "/api/v1/federation/agents", + headers=headers, + ) + data = self._handle_response(response) + agents: list[dict[str, Any]] = data.get("agents", []) + return agents + + async def federation_dispatch( + self, + intent_id: str, + target_server: str, + agent_id: Optional[str] = None, + delegation_scope: Optional[dict[str, Any]] = None, + federation_policy: Optional[dict[str, Any]] = None, + callback_url: Optional[str] = None, + trace_context: Optional[dict[str, str]] = None, + ) -> "DispatchResult": + from .federation.models import DispatchResult + + payload: dict[str, Any] = { + "intent_id": intent_id, + "target_server": target_server, + } + if agent_id: + payload["agent_id"] = agent_id + if delegation_scope: + payload["delegation_scope"] = delegation_scope + if federation_policy: + payload["federation_policy"] = federation_policy + if callback_url: + payload["callback_url"] = callback_url + if trace_context: + payload["trace_context"] = trace_context + response = await self._client.post("/api/v1/federation/dispatch", json=payload) + data = self._handle_response(response) + return DispatchResult.from_dict(data) + + async def federation_receive( + self, + dispatch_id: str, + source_server: str, + intent_id: str, + intent_title: str, + intent_description: str = "", + intent_state: Optional[dict[str, Any]] = None, + agent_id: Optional[str] = None, + delegation_scope: Optional[dict[str, Any]] = None, + federation_policy: Optional[dict[str, Any]] = None, + callback_url: Optional[str] = None, + idempotency_key: Optional[str] = None, + ) -> "ReceiveResult": + from .federation.models import ReceiveResult + + payload: dict[str, Any] = { + "dispatch_id": dispatch_id, + "source_server": source_server, + "intent_id": intent_id, + "intent_title": intent_title, + "intent_description": intent_description, + "intent_state": intent_state or {}, + } + if agent_id: + payload["agent_id"] = agent_id + if delegation_scope: + payload["delegation_scope"] = delegation_scope + if federation_policy: + payload["federation_policy"] = federation_policy + if callback_url: + payload["callback_url"] = callback_url + if idempotency_key: + payload["idempotency_key"] = idempotency_key + response = await self._client.post("/api/v1/federation/receive", json=payload) + data = self._handle_response(response) + return ReceiveResult.from_dict(data) + + async def send_federation_callback( + self, + callback_url: str, + dispatch_id: str, + event_type: str, + state_delta: Optional[dict[str, Any]] = None, + attestation: Optional[dict[str, Any]] = None, + trace_id: Optional[str] = None, + ) -> dict[str, Any]: + payload: dict[str, Any] = { + "dispatch_id": dispatch_id, + "event_type": event_type, + "state_delta": state_delta or {}, + } + if attestation: + payload["attestation"] = attestation + if trace_id: + payload["trace_id"] = trace_id + response = await self._client.post(callback_url, json=payload) + return self._handle_response(response) + + async def federation_discover(self) -> dict[str, Any]: + response = await self._client.get("/.well-known/openintent-federation.json") + return self._handle_response(response) + async def close(self) -> None: """Close the HTTP client connection.""" await self._client.aclose() diff --git a/openintent/federation/__init__.py b/openintent/federation/__init__.py new file mode 100644 index 0000000..94348b0 --- /dev/null +++ b/openintent/federation/__init__.py @@ -0,0 +1,72 @@ +""" +OpenIntent SDK - Federation module (RFC-0022 & RFC-0023). + +Cross-server agent coordination with structured delegation, +governance propagation, and optional cryptographic verification. +""" + +from .decorators import ( + Federation, + on_budget_warning, + on_federation_callback, + on_federation_received, +) +from .models import ( + AgentVisibility, + CallbackEventType, + DelegationScope, + DispatchResult, + DispatchStatus, + FederatedAgent, + FederationAttestation, + FederationCallback, + FederationEnvelope, + FederationManifest, + FederationPolicy, + FederationStatus, + PeerInfo, + PeerRelationship, + ReceiveResult, + TrustPolicy, +) +from .security import ( + MessageSignature, + ServerIdentity, + TrustEnforcer, + UCANToken, + resolve_did_web, + sign_envelope, + validate_ssrf, + verify_envelope_signature, +) + +__all__ = [ + "AgentVisibility", + "CallbackEventType", + "DelegationScope", + "DispatchResult", + "DispatchStatus", + "FederatedAgent", + "Federation", + "FederationAttestation", + "FederationCallback", + "FederationEnvelope", + "FederationManifest", + "FederationPolicy", + "FederationStatus", + "MessageSignature", + "PeerInfo", + "PeerRelationship", + "ReceiveResult", + "ServerIdentity", + "TrustEnforcer", + "TrustPolicy", + "UCANToken", + "on_budget_warning", + "on_federation_callback", + "on_federation_received", + "resolve_did_web", + "sign_envelope", + "validate_ssrf", + "verify_envelope_signature", +] diff --git a/openintent/federation/decorators.py b/openintent/federation/decorators.py new file mode 100644 index 0000000..7746711 --- /dev/null +++ b/openintent/federation/decorators.py @@ -0,0 +1,97 @@ +""" +OpenIntent SDK - Federation decorators (RFC-0022 & RFC-0023). + +Provides @Federation for server configuration, federation_visibility for +@Agent, federation_policy for @Coordinator, and lifecycle hooks for +federated work. +""" + +import logging +from typing import Any, Callable, Optional + +from .models import ( + AgentVisibility, + TrustPolicy, +) +from .security import ServerIdentity + +logger = logging.getLogger("openintent.federation.decorators") + + +def on_federation_received(func: Any) -> Any: + func._openintent_handler = "federation_received" + return func + + +def on_federation_callback(func: Any) -> Any: + func._openintent_handler = "federation_callback" + return func + + +def on_budget_warning(func: Any) -> Any: + func._openintent_handler = "budget_warning" + return func + + +def Federation( # noqa: N802 + server: Any = None, + identity: Optional[str] = None, + key_path: Optional[str] = None, + visibility_default: str = "public", + trust_policy: str = "allowlist", + peers: Optional[list[str]] = None, + server_url: Optional[str] = None, +) -> Callable[[type], type]: + def decorator(cls: type) -> type: + original_init = cls.__init__ if hasattr(cls, "__init__") else None # type: ignore[misc] + + def new_init(self: Any, *args: Any, **kwargs: Any) -> None: + if original_init and original_init is not object.__init__: + original_init(self, *args, **kwargs) + + _server_url = server_url or "" + if server and hasattr(server, "config"): + _server_url = f"http://{server.config.host}:{server.config.port}" + + if key_path: + self._federation_identity = ServerIdentity.from_key_file( + _server_url, key_path + ) + else: + self._federation_identity = ServerIdentity.generate(_server_url) + + if identity: + self._federation_identity.did = identity + + self._federation_trust_policy = TrustPolicy(trust_policy) + self._federation_visibility_default = AgentVisibility(visibility_default) + self._federation_peers = peers or [] + self._federation_server_url = _server_url + + if server and hasattr(server, "app"): + from ..server.federation import configure_federation + + configure_federation( + server_url=_server_url, + server_did=self._federation_identity.did, + trust_policy=self._federation_trust_policy, + visibility_default=self._federation_visibility_default, + peers=self._federation_peers, + identity=self._federation_identity, + ) + + logger.info( + f"Federation configured: did={self._federation_identity.did}, " + f"trust_policy={trust_policy}, peers={len(self._federation_peers)}" + ) + + cls.__init__ = new_init # type: ignore[assignment,misc] + + cls._federation_configured = True # type: ignore[attr-defined] + cls._federation_trust_policy_name = trust_policy # type: ignore[attr-defined] + cls._federation_visibility_default_name = visibility_default # type: ignore[attr-defined] + cls._federation_peer_list = peers or [] # type: ignore[attr-defined] + + return cls + + return decorator diff --git a/openintent/federation/models.py b/openintent/federation/models.py new file mode 100644 index 0000000..a75cbb7 --- /dev/null +++ b/openintent/federation/models.py @@ -0,0 +1,493 @@ +""" +OpenIntent SDK - Federation data models (RFC-0022 & RFC-0023). + +Defines the canonical data structures for cross-server agent coordination: +envelopes, policies, attestations, delegation scopes, and discovery manifests. +""" + +from dataclasses import dataclass, field +from enum import Enum +from typing import Any, Optional + + +class AgentVisibility(str, Enum): + PUBLIC = "public" + UNLISTED = "unlisted" + PRIVATE = "private" + + +class PeerRelationship(str, Enum): + PEER = "peer" + UPSTREAM = "upstream" + DOWNSTREAM = "downstream" + + +class TrustPolicy(str, Enum): + OPEN = "open" + ALLOWLIST = "allowlist" + TRUSTLESS = "trustless" + + +class CallbackEventType(str, Enum): + STATE_DELTA = "state_delta" + STATUS_CHANGED = "status_changed" + ATTESTATION = "attestation" + BUDGET_WARNING = "budget_warning" + COMPLETED = "completed" + FAILED = "failed" + + +class DispatchStatus(str, Enum): + ACCEPTED = "accepted" + REJECTED = "rejected" + PENDING = "pending" + + +@dataclass +class DelegationScope: + permissions: list[str] = field( + default_factory=lambda: ["state.patch", "events.log"] + ) + denied_operations: list[str] = field(default_factory=list) + max_delegation_depth: int = 1 + expires_at: Optional[str] = None + + def to_dict(self) -> dict[str, Any]: + result: dict[str, Any] = { + "permissions": self.permissions, + "denied_operations": self.denied_operations, + "max_delegation_depth": self.max_delegation_depth, + } + if self.expires_at: + result["expires_at"] = self.expires_at + return result + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "DelegationScope": + return cls( + permissions=data.get("permissions", ["state.patch", "events.log"]), + denied_operations=data.get("denied_operations", []), + max_delegation_depth=data.get("max_delegation_depth", 1), + expires_at=data.get("expires_at"), + ) + + def attenuate(self, child_scope: "DelegationScope") -> "DelegationScope": + allowed = set(self.permissions) & set(child_scope.permissions) + denied = list(set(self.denied_operations) | set(child_scope.denied_operations)) + return DelegationScope( + permissions=sorted(allowed), + denied_operations=sorted(denied), + max_delegation_depth=min( + self.max_delegation_depth - 1, + child_scope.max_delegation_depth, + ), + expires_at=self.expires_at, + ) + + +@dataclass +class FederationPolicy: + governance: dict[str, Any] = field(default_factory=dict) + budget: dict[str, Any] = field(default_factory=dict) + observability: dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + return { + "governance": self.governance, + "budget": self.budget, + "observability": self.observability, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "FederationPolicy": + return cls( + governance=data.get("governance", {}), + budget=data.get("budget", {}), + observability=data.get("observability", {}), + ) + + def compose_strictest(self, other: "FederationPolicy") -> "FederationPolicy": + governance = {**self.governance} + for k, v in other.governance.items(): + if k in governance: + if isinstance(v, bool) and isinstance(governance[k], bool): + governance[k] = governance[k] or v + elif isinstance(v, (int, float)) and isinstance( + governance[k], (int, float) + ): + governance[k] = min(governance[k], v) + else: + governance[k] = v + else: + governance[k] = v + + budget = {**self.budget} + for k, v in other.budget.items(): + if k in budget and isinstance(v, (int, float)): + budget[k] = min(budget[k], v) + else: + budget[k] = v + + observability = {**self.observability, **other.observability} + + return FederationPolicy( + governance=governance, + budget=budget, + observability=observability, + ) + + +@dataclass +class FederationAttestation: + dispatch_id: str + governance_compliant: bool = True + usage: dict[str, Any] = field(default_factory=dict) + trace_references: list[str] = field(default_factory=list) + timestamp: Optional[str] = None + signature: Optional[str] = None + + def to_dict(self) -> dict[str, Any]: + result: dict[str, Any] = { + "dispatch_id": self.dispatch_id, + "governance_compliant": self.governance_compliant, + "usage": self.usage, + "trace_references": self.trace_references, + } + if self.timestamp: + result["timestamp"] = self.timestamp + if self.signature: + result["signature"] = self.signature + return result + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "FederationAttestation": + return cls( + dispatch_id=data.get("dispatch_id", ""), + governance_compliant=data.get("governance_compliant", True), + usage=data.get("usage", {}), + trace_references=data.get("trace_references", []), + timestamp=data.get("timestamp"), + signature=data.get("signature"), + ) + + +@dataclass +class FederationEnvelope: + dispatch_id: str + source_server: str + target_server: str + intent_id: str + intent_title: str + intent_description: str = "" + intent_state: dict[str, Any] = field(default_factory=dict) + intent_constraints: dict[str, Any] = field(default_factory=dict) + agent_id: Optional[str] = None + delegation_scope: Optional[DelegationScope] = None + federation_policy: Optional[FederationPolicy] = None + trace_context: Optional[dict[str, str]] = None + callback_url: Optional[str] = None + idempotency_key: Optional[str] = None + created_at: Optional[str] = None + signature: Optional[str] = None + + def to_dict(self) -> dict[str, Any]: + result: dict[str, Any] = { + "dispatch_id": self.dispatch_id, + "source_server": self.source_server, + "target_server": self.target_server, + "intent_id": self.intent_id, + "intent_title": self.intent_title, + "intent_description": self.intent_description, + "intent_state": self.intent_state, + "intent_constraints": self.intent_constraints, + } + if self.agent_id: + result["agent_id"] = self.agent_id + if self.delegation_scope: + result["delegation_scope"] = self.delegation_scope.to_dict() + if self.federation_policy: + result["federation_policy"] = self.federation_policy.to_dict() + if self.trace_context: + result["trace_context"] = self.trace_context + if self.callback_url: + result["callback_url"] = self.callback_url + if self.idempotency_key: + result["idempotency_key"] = self.idempotency_key + if self.created_at: + result["created_at"] = self.created_at + if self.signature: + result["signature"] = self.signature + return result + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "FederationEnvelope": + delegation_scope = None + if data.get("delegation_scope"): + delegation_scope = DelegationScope.from_dict(data["delegation_scope"]) + federation_policy = None + if data.get("federation_policy"): + federation_policy = FederationPolicy.from_dict(data["federation_policy"]) + return cls( + dispatch_id=data.get("dispatch_id", ""), + source_server=data.get("source_server", ""), + target_server=data.get("target_server", ""), + intent_id=data.get("intent_id", ""), + intent_title=data.get("intent_title", ""), + intent_description=data.get("intent_description", ""), + intent_state=data.get("intent_state", {}), + intent_constraints=data.get("intent_constraints", {}), + agent_id=data.get("agent_id"), + delegation_scope=delegation_scope, + federation_policy=federation_policy, + trace_context=data.get("trace_context"), + callback_url=data.get("callback_url"), + idempotency_key=data.get("idempotency_key"), + created_at=data.get("created_at"), + signature=data.get("signature"), + ) + + +@dataclass +class FederationCallback: + dispatch_id: str + event_type: CallbackEventType + state_delta: dict[str, Any] = field(default_factory=dict) + attestation: Optional[FederationAttestation] = None + trace_id: Optional[str] = None + idempotency_key: Optional[str] = None + timestamp: Optional[str] = None + + def to_dict(self) -> dict[str, Any]: + result: dict[str, Any] = { + "dispatch_id": self.dispatch_id, + "event_type": self.event_type.value, + "state_delta": self.state_delta, + } + if self.attestation: + result["attestation"] = self.attestation.to_dict() + if self.trace_id: + result["trace_id"] = self.trace_id + if self.idempotency_key: + result["idempotency_key"] = self.idempotency_key + if self.timestamp: + result["timestamp"] = self.timestamp + return result + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "FederationCallback": + attestation = None + if data.get("attestation"): + attestation = FederationAttestation.from_dict(data["attestation"]) + return cls( + dispatch_id=data.get("dispatch_id", ""), + event_type=CallbackEventType(data.get("event_type", "state_delta")), + state_delta=data.get("state_delta", {}), + attestation=attestation, + trace_id=data.get("trace_id"), + idempotency_key=data.get("idempotency_key"), + timestamp=data.get("timestamp"), + ) + + +@dataclass +class PeerInfo: + server_url: str + server_did: Optional[str] = None + relationship: PeerRelationship = PeerRelationship.PEER + trust_policy: TrustPolicy = TrustPolicy.ALLOWLIST + public_key: Optional[str] = None + + def to_dict(self) -> dict[str, Any]: + result: dict[str, Any] = { + "server_url": self.server_url, + "relationship": self.relationship.value, + "trust_policy": self.trust_policy.value, + } + if self.server_did: + result["server_did"] = self.server_did + if self.public_key: + result["public_key"] = self.public_key + return result + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "PeerInfo": + return cls( + server_url=data.get("server_url", ""), + server_did=data.get("server_did"), + relationship=PeerRelationship(data.get("relationship", "peer")), + trust_policy=TrustPolicy(data.get("trust_policy", "allowlist")), + public_key=data.get("public_key"), + ) + + +@dataclass +class FederationManifest: + server_did: str + server_url: str + protocol_version: str = "0.1" + trust_policy: TrustPolicy = TrustPolicy.ALLOWLIST + visibility_default: AgentVisibility = AgentVisibility.PUBLIC + supported_rfcs: list[str] = field(default_factory=lambda: ["RFC-0022", "RFC-0023"]) + peers: list[str] = field(default_factory=list) + public_key: Optional[str] = None + endpoints: dict[str, str] = field( + default_factory=lambda: { + "status": "/api/v1/federation/status", + "agents": "/api/v1/federation/agents", + "dispatch": "/api/v1/federation/dispatch", + "receive": "/api/v1/federation/receive", + } + ) + + def to_dict(self) -> dict[str, Any]: + result: dict[str, Any] = { + "server_did": self.server_did, + "server_url": self.server_url, + "protocol_version": self.protocol_version, + "trust_policy": self.trust_policy.value, + "visibility_default": self.visibility_default.value, + "supported_rfcs": self.supported_rfcs, + "peers": self.peers, + "endpoints": self.endpoints, + } + if self.public_key: + result["public_key"] = self.public_key + return result + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "FederationManifest": + return cls( + server_did=data.get("server_did", ""), + server_url=data.get("server_url", ""), + protocol_version=data.get("protocol_version", "0.1"), + trust_policy=TrustPolicy(data.get("trust_policy", "allowlist")), + visibility_default=AgentVisibility( + data.get("visibility_default", "public") + ), + supported_rfcs=data.get("supported_rfcs", ["RFC-0022", "RFC-0023"]), + peers=data.get("peers", []), + public_key=data.get("public_key"), + endpoints=data.get("endpoints", {}), + ) + + +@dataclass +class FederationStatus: + enabled: bool = True + server_did: Optional[str] = None + trust_policy: TrustPolicy = TrustPolicy.ALLOWLIST + peer_count: int = 0 + active_dispatches: int = 0 + total_dispatches: int = 0 + total_received: int = 0 + + def to_dict(self) -> dict[str, Any]: + return { + "enabled": self.enabled, + "server_did": self.server_did, + "trust_policy": self.trust_policy.value, + "peer_count": self.peer_count, + "active_dispatches": self.active_dispatches, + "total_dispatches": self.total_dispatches, + "total_received": self.total_received, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "FederationStatus": + return cls( + enabled=data.get("enabled", True), + server_did=data.get("server_did"), + trust_policy=TrustPolicy(data.get("trust_policy", "allowlist")), + peer_count=data.get("peer_count", 0), + active_dispatches=data.get("active_dispatches", 0), + total_dispatches=data.get("total_dispatches", 0), + total_received=data.get("total_received", 0), + ) + + +@dataclass +class DispatchResult: + dispatch_id: str + status: DispatchStatus + target_server: str + message: str = "" + remote_intent_id: Optional[str] = None + + def to_dict(self) -> dict[str, Any]: + result: dict[str, Any] = { + "dispatch_id": self.dispatch_id, + "status": self.status.value, + "target_server": self.target_server, + "message": self.message, + } + if self.remote_intent_id: + result["remote_intent_id"] = self.remote_intent_id + return result + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "DispatchResult": + return cls( + dispatch_id=data.get("dispatch_id", ""), + status=DispatchStatus(data.get("status", "pending")), + target_server=data.get("target_server", ""), + message=data.get("message", ""), + remote_intent_id=data.get("remote_intent_id"), + ) + + +@dataclass +class ReceiveResult: + dispatch_id: str + accepted: bool + local_intent_id: Optional[str] = None + message: str = "" + + def to_dict(self) -> dict[str, Any]: + result: dict[str, Any] = { + "dispatch_id": self.dispatch_id, + "accepted": self.accepted, + "message": self.message, + } + if self.local_intent_id: + result["local_intent_id"] = self.local_intent_id + return result + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "ReceiveResult": + return cls( + dispatch_id=data.get("dispatch_id", ""), + accepted=data.get("accepted", False), + local_intent_id=data.get("local_intent_id"), + message=data.get("message", ""), + ) + + +@dataclass +class FederatedAgent: + agent_id: str + server_url: str + capabilities: list[str] = field(default_factory=list) + visibility: AgentVisibility = AgentVisibility.PUBLIC + server_did: Optional[str] = None + status: str = "active" + + def to_dict(self) -> dict[str, Any]: + return { + "agent_id": self.agent_id, + "server_url": self.server_url, + "capabilities": self.capabilities, + "visibility": self.visibility.value, + "server_did": self.server_did, + "status": self.status, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "FederatedAgent": + return cls( + agent_id=data.get("agent_id", ""), + server_url=data.get("server_url", ""), + capabilities=data.get("capabilities", []), + visibility=AgentVisibility(data.get("visibility", "public")), + server_did=data.get("server_did"), + status=data.get("status", "active"), + ) diff --git a/openintent/federation/security.py b/openintent/federation/security.py new file mode 100644 index 0000000..b8f7535 --- /dev/null +++ b/openintent/federation/security.py @@ -0,0 +1,422 @@ +""" +OpenIntent SDK - Federation security (RFC-0023). + +Server identity (did:web), HTTP Message Signatures (RFC 9421), +UCAN delegation tokens, and trust policy enforcement. +""" + +import base64 +import hashlib +import hmac +import json +import logging +import time +from dataclasses import dataclass, field +from typing import Any, Optional + +from .models import DelegationScope, TrustPolicy + +logger = logging.getLogger("openintent.federation.security") + + +try: + from cryptography.hazmat.primitives.asymmetric.ed25519 import ( + Ed25519PrivateKey, + Ed25519PublicKey, + ) + from cryptography.hazmat.primitives.serialization import ( + Encoding, + NoEncryption, + PrivateFormat, + PublicFormat, + ) + + HAS_CRYPTO = True +except ImportError: + HAS_CRYPTO = False + + +@dataclass +class ServerIdentity: + server_url: str + did: str = "" + private_key_bytes: Optional[bytes] = None + public_key_bytes: Optional[bytes] = None + + def __post_init__(self) -> None: + if not self.did: + domain = ( + self.server_url.replace("https://", "") + .replace("http://", "") + .rstrip("/") + ) + self.did = f"did:web:{domain}" + + @classmethod + def generate(cls, server_url: str) -> "ServerIdentity": + if not HAS_CRYPTO: + return cls._generate_hmac_fallback(server_url) + + private_key = Ed25519PrivateKey.generate() + private_bytes = private_key.private_bytes( + Encoding.Raw, PrivateFormat.Raw, NoEncryption() + ) + public_bytes = private_key.public_key().public_bytes( + Encoding.Raw, PublicFormat.Raw + ) + return cls( + server_url=server_url, + private_key_bytes=private_bytes, + public_key_bytes=public_bytes, + ) + + @classmethod + def _generate_hmac_fallback(cls, server_url: str) -> "ServerIdentity": + import os + + secret = os.urandom(32) + public = hashlib.sha256(secret).digest() + return cls( + server_url=server_url, + private_key_bytes=secret, + public_key_bytes=public, + ) + + @classmethod + def from_key_file(cls, server_url: str, key_path: str) -> "ServerIdentity": + with open(key_path, "rb") as f: + key_data = f.read() + + if HAS_CRYPTO: + private_key = Ed25519PrivateKey.from_private_bytes(key_data[:32]) + public_bytes = private_key.public_key().public_bytes( + Encoding.Raw, PublicFormat.Raw + ) + return cls( + server_url=server_url, + private_key_bytes=key_data[:32], + public_key_bytes=public_bytes, + ) + + return cls( + server_url=server_url, + private_key_bytes=key_data[:32], + public_key_bytes=hashlib.sha256(key_data[:32]).digest(), + ) + + def save_key(self, key_path: str) -> None: + if self.private_key_bytes: + with open(key_path, "wb") as f: + f.write(self.private_key_bytes) + + @property + def public_key_b64(self) -> str: + if self.public_key_bytes: + return base64.b64encode(self.public_key_bytes).decode() + return "" + + def did_document(self) -> dict[str, Any]: + return { + "@context": ["https://www.w3.org/ns/did/v1"], + "id": self.did, + "verificationMethod": [ + { + "id": f"{self.did}#key-1", + "type": "Ed25519VerificationKey2020", + "controller": self.did, + "publicKeyBase64": self.public_key_b64, + } + ], + "authentication": [f"{self.did}#key-1"], + } + + def sign(self, message: bytes) -> str: + if not self.private_key_bytes: + raise ValueError("No private key available for signing") + + if HAS_CRYPTO: + private_key = Ed25519PrivateKey.from_private_bytes(self.private_key_bytes) + signature = private_key.sign(message) + return base64.b64encode(signature).decode() + + signature = hmac.new(self.private_key_bytes, message, hashlib.sha256).digest() + return base64.b64encode(signature).decode() + + def verify(self, message: bytes, signature_b64: str) -> bool: + try: + signature = base64.b64decode(signature_b64) + except Exception: + return False + + if HAS_CRYPTO and self.public_key_bytes and len(self.public_key_bytes) == 32: + try: + public_key = Ed25519PublicKey.from_public_bytes(self.public_key_bytes) + public_key.verify(signature, message) + return True + except Exception: + return False + + if self.private_key_bytes: + expected = hmac.new( + self.private_key_bytes, message, hashlib.sha256 + ).digest() + return hmac.compare_digest(signature, expected) + + return False + + +def sign_envelope(identity: ServerIdentity, envelope_dict: dict[str, Any]) -> str: + signing_data = _canonical_bytes(envelope_dict) + return identity.sign(signing_data) + + +def verify_envelope_signature( + public_key_b64: str, envelope_dict: dict[str, Any], signature_b64: str +) -> bool: + try: + public_key_bytes = base64.b64decode(public_key_b64) + except Exception: + return False + + signing_data = _canonical_bytes(envelope_dict) + + if HAS_CRYPTO and len(public_key_bytes) == 32: + try: + public_key = Ed25519PublicKey.from_public_bytes(public_key_bytes) + signature = base64.b64decode(signature_b64) + public_key.verify(signature, signing_data) + return True + except Exception: + return False + + return False + + +def _canonical_bytes(data: dict[str, Any]) -> bytes: + filtered = {k: v for k, v in sorted(data.items()) if k != "signature"} + return json.dumps(filtered, sort_keys=True, separators=(",", ":")).encode() + + +@dataclass +class MessageSignature: + key_id: str + algorithm: str = "ed25519" + created: int = 0 + headers: list[str] = field( + default_factory=lambda: [ + "@method", + "@target-uri", + "content-type", + "content-digest", + ] + ) + signature: str = "" + + def __post_init__(self) -> None: + if not self.created: + self.created = int(time.time()) + + @classmethod + def create( + cls, + identity: ServerIdentity, + method: str, + target_uri: str, + content_type: str = "application/json", + body: Optional[bytes] = None, + ) -> "MessageSignature": + sig = cls(key_id=identity.did) + + components = [ + f'"@method": {method.upper()}', + f'"@target-uri": {target_uri}', + f'"content-type": {content_type}', + ] + + if body: + digest = hashlib.sha256(body).hexdigest() + components.append(f'"content-digest": sha-256=:{digest}:') + + components.append(f'"@signature-params": ({" ".join(sig.headers)})') + signing_input = "\n".join(components) + sig.signature = identity.sign(signing_input.encode()) + return sig + + def to_header(self) -> str: + params = f'sig1=("{" ".join(self.headers)}");keyid="{self.key_id}";alg="{self.algorithm}";created={self.created}' + return params + + def signature_header(self) -> str: + return f"sig1=:{self.signature}:" + + +class TrustEnforcer: + def __init__( + self, + policy: TrustPolicy, + allowed_peers: Optional[list[str]] = None, + ): + self.policy = policy + self.allowed_peers = set(allowed_peers or []) + + def is_trusted(self, source_server: str, source_did: Optional[str] = None) -> bool: + if self.policy == TrustPolicy.OPEN: + return True + + if self.policy == TrustPolicy.ALLOWLIST: + if source_server in self.allowed_peers: + return True + if source_did and source_did in self.allowed_peers: + return True + return False + + if self.policy == TrustPolicy.TRUSTLESS: + return False + + return False + + def add_peer(self, peer: str) -> None: + self.allowed_peers.add(peer) + + def remove_peer(self, peer: str) -> None: + self.allowed_peers.discard(peer) + + +@dataclass +class UCANToken: + issuer: str + audience: str + scope: DelegationScope + not_before: int = 0 + expires_at: int = 0 + nonce: str = "" + proof_chain: list[str] = field(default_factory=list) + + def __post_init__(self) -> None: + if not self.not_before: + self.not_before = int(time.time()) + if not self.expires_at: + self.expires_at = self.not_before + 3600 + if not self.nonce: + import os + + self.nonce = base64.b64encode(os.urandom(16)).decode() + + def to_dict(self) -> dict[str, Any]: + return { + "iss": self.issuer, + "aud": self.audience, + "scope": self.scope.to_dict(), + "nbf": self.not_before, + "exp": self.expires_at, + "nonce": self.nonce, + "prf": self.proof_chain, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "UCANToken": + return cls( + issuer=data.get("iss", ""), + audience=data.get("aud", ""), + scope=DelegationScope.from_dict(data.get("scope", {})), + not_before=data.get("nbf", 0), + expires_at=data.get("exp", 0), + nonce=data.get("nonce", ""), + proof_chain=data.get("prf", []), + ) + + def encode(self, identity: ServerIdentity) -> str: + header = ( + base64.urlsafe_b64encode( + json.dumps({"alg": "EdDSA", "typ": "UCAN"}).encode() + ) + .decode() + .rstrip("=") + ) + payload = ( + base64.urlsafe_b64encode( + json.dumps(self.to_dict(), sort_keys=True).encode() + ) + .decode() + .rstrip("=") + ) + signing_input = f"{header}.{payload}" + signature = identity.sign(signing_input.encode()) + sig_part = ( + base64.urlsafe_b64encode(base64.b64decode(signature)).decode().rstrip("=") + ) + return f"{header}.{payload}.{sig_part}" + + @classmethod + def decode(cls, token: str) -> "UCANToken": + parts = token.split(".") + if len(parts) != 3: + raise ValueError("Invalid UCAN token format") + padding = 4 - len(parts[1]) % 4 + payload_bytes = base64.urlsafe_b64decode(parts[1] + "=" * padding) + payload = json.loads(payload_bytes) + return cls.from_dict(payload) + + def is_expired(self) -> bool: + return int(time.time()) > self.expires_at + + def is_active(self) -> bool: + now = int(time.time()) + return self.not_before <= now <= self.expires_at + + def attenuate( + self, + audience: str, + child_scope: DelegationScope, + identity: ServerIdentity, + ) -> "UCANToken": + new_scope = self.scope.attenuate(child_scope) + if new_scope.max_delegation_depth < 0: + raise ValueError("Delegation depth exceeded") + parent_token = self.encode(identity) + return UCANToken( + issuer=self.audience, + audience=audience, + scope=new_scope, + expires_at=self.expires_at, + proof_chain=self.proof_chain + [parent_token], + ) + + +def resolve_did_web(did: str) -> str: + if not did.startswith("did:web:"): + raise ValueError(f"Not a did:web identifier: {did}") + domain = did[len("did:web:") :] + domain = domain.replace(":", "/") + return f"https://{domain}/.well-known/did.json" + + +def validate_ssrf(url: str) -> bool: + from urllib.parse import urlparse + + parsed = urlparse(url) + if parsed.scheme not in ("http", "https"): + return False + hostname = parsed.hostname or "" + if not hostname: + return False + blocked = [ + "localhost", + "127.0.0.1", + "0.0.0.0", + "::1", + "[::1]", + "169.254.169.254", + "metadata.google.internal", + ] + if hostname in blocked: + return False + if ( + hostname.startswith("10.") + or hostname.startswith("172.") + or hostname.startswith("192.168.") + ): + return False + if hostname.endswith(".internal") or hostname.endswith(".local"): + return False + return True diff --git a/openintent/llm.py b/openintent/llm.py index 9fad75a..d4ed190 100644 --- a/openintent/llm.py +++ b/openintent/llm.py @@ -690,6 +690,7 @@ def __init__(self, agent: Any, llm_config: LLMConfig) -> None: self._config = llm_config self._conversation_history: list[dict] = [] self._provider = llm_config.provider or _resolve_provider(llm_config.model) + self._last_stream_usage: Optional[dict[str, int]] = None @property def _is_coordinator(self) -> bool: @@ -1225,6 +1226,16 @@ async def _stream_raw_provider(self, call_kwargs: dict) -> AsyncIterator[str]: with client.messages.stream(**call_kwargs) as stream: for text in stream.text_stream: yield text + try: + msg = stream.get_final_message() + usage = getattr(msg, "usage", None) + if usage: + self._last_stream_usage = { + "input_tokens": getattr(usage, "input_tokens", 0), + "output_tokens": getattr(usage, "output_tokens", 0), + } + except Exception: + pass except ImportError: raise ImportError("anthropic package required.") @@ -1261,7 +1272,13 @@ async def _iter_completions_stream(self, stream: Any) -> AsyncIterator[str]: yield text async def _iter_anthropic_stream(self, stream: Any) -> AsyncIterator[str]: - """Iterate an Anthropic-style stream yielding tokens.""" + """Iterate an Anthropic-style stream yielding tokens. + + After text iteration completes, attempts to capture usage data + via ``get_final_message()`` on the underlying stream. This + works for both direct Anthropic ``MessageStream`` objects and + the adapter's ``AnthropicStreamContext`` wrapper. + """ if hasattr(stream, "__enter__"): with stream as s: if hasattr(s, "text_stream"): @@ -1275,11 +1292,34 @@ async def _iter_anthropic_stream(self, stream: Any) -> AsyncIterator[str]: ): if hasattr(event, "delta") and hasattr(event.delta, "text"): yield event.delta.text + try: + final_stream = getattr(s, "_stream", s) + if hasattr(final_stream, "get_final_message"): + msg = final_stream.get_final_message() + usage = getattr(msg, "usage", None) + if usage: + self._last_stream_usage = { + "input_tokens": getattr(usage, "input_tokens", 0), + "output_tokens": getattr(usage, "output_tokens", 0), + } + except Exception: + pass else: for event in stream: if hasattr(event, "type") and event.type == "content_block_delta": if hasattr(event, "delta") and hasattr(event.delta, "text"): yield event.delta.text + try: + if hasattr(stream, "get_final_message"): + msg = stream.get_final_message() + usage = getattr(msg, "usage", None) + if usage: + self._last_stream_usage = { + "input_tokens": getattr(usage, "input_tokens", 0), + "output_tokens": getattr(usage, "output_tokens", 0), + } + except Exception: + pass # ----------------------------------------------------------------------- # Response parsing (provider-agnostic) diff --git a/openintent/models.py b/openintent/models.py index 13cd990..fc04bdd 100644 --- a/openintent/models.py +++ b/openintent/models.py @@ -180,6 +180,14 @@ class EventType(str, Enum): GOVERNANCE_APPROVAL_ENFORCED = "governance.approval_enforced" + # Federation events (RFC-0022) + FEDERATION_DISPATCHED = "federation.dispatched" + FEDERATION_RECEIVED = "federation.received" + FEDERATION_CALLBACK = "federation.callback" + FEDERATION_BUDGET_WARNING = "federation.budget_warning" + FEDERATION_COMPLETED = "federation.completed" + FEDERATION_FAILED = "federation.failed" + # Legacy aliases for backward compatibility CREATED = "intent_created" STATE_UPDATED = "state_patched" diff --git a/openintent/server/app.py b/openintent/server/app.py index 696ac82..5ec48c1 100644 --- a/openintent/server/app.py +++ b/openintent/server/app.py @@ -1186,6 +1186,8 @@ async def discovery(): "/rfc/0009", "/rfc/0010", "/rfc/0011", + "/rfc/0022", + "/rfc/0023", ], "capabilities": [ "intents", @@ -1203,6 +1205,7 @@ async def discovery(): "governance-enforcement", "access-control", "tools", + "federation", ], "openApiUrl": "/openapi.json", } @@ -1222,6 +1225,8 @@ async def compatibility(): "RFC-0009": "full", "RFC-0010": "full", "RFC-0011": "full", + "RFC-0022": "full", + "RFC-0023": "partial", }, "features": { "intents": True, @@ -4796,6 +4801,13 @@ async def update_message_status( return m raise HTTPException(status_code=404, detail="Message not found") + # ==================== Federation (RFC-0022 & RFC-0023) ==================== + + from .federation import create_federation_router + + federation_router = create_federation_router(validate_api_key=validate_api_key) + app.include_router(federation_router) + return app diff --git a/openintent/server/federation.py b/openintent/server/federation.py new file mode 100644 index 0000000..fe23fa8 --- /dev/null +++ b/openintent/server/federation.py @@ -0,0 +1,436 @@ +""" +OpenIntent Server - Federation endpoints (RFC-0022 & RFC-0023). + +Provides cross-server agent coordination: dispatch, receive, callbacks, +agent discovery, and federation status. +""" + +import asyncio +import logging +import uuid +from datetime import datetime, timezone +from typing import Any, Dict, Optional + +from fastapi import APIRouter, Depends, Header, HTTPException, Request +from pydantic import BaseModel + +from ..federation.models import ( + AgentVisibility, + CallbackEventType, + DelegationScope, + FederatedAgent, + FederationCallback, + FederationEnvelope, + FederationManifest, + FederationPolicy, + FederationStatus, + TrustPolicy, +) +from ..federation.security import ( + ServerIdentity, + TrustEnforcer, + sign_envelope, + validate_ssrf, +) + +logger = logging.getLogger("openintent.server.federation") + + +class DispatchRequest(BaseModel): + intent_id: str + target_server: str + agent_id: Optional[str] = None + delegation_scope: Optional[Dict[str, Any]] = None + federation_policy: Optional[Dict[str, Any]] = None + callback_url: Optional[str] = None + trace_context: Optional[Dict[str, str]] = None + + +class ReceiveRequest(BaseModel): + dispatch_id: str + source_server: str + intent_id: str + intent_title: str + intent_description: str = "" + intent_state: Dict[str, Any] = {} + intent_constraints: Dict[str, Any] = {} + agent_id: Optional[str] = None + delegation_scope: Optional[Dict[str, Any]] = None + federation_policy: Optional[Dict[str, Any]] = None + trace_context: Optional[Dict[str, str]] = None + callback_url: Optional[str] = None + idempotency_key: Optional[str] = None + signature: Optional[str] = None + + +class CallbackRequest(BaseModel): + dispatch_id: str + event_type: str + state_delta: Dict[str, Any] = {} + attestation: Optional[Dict[str, Any]] = None + trace_id: Optional[str] = None + idempotency_key: Optional[str] = None + + +class FederationState: + def __init__(self): + self.enabled: bool = False + self.identity: Optional[ServerIdentity] = None + self.trust_enforcer: Optional[TrustEnforcer] = None + self.manifest: Optional[FederationManifest] = None + self.agents: Dict[str, FederatedAgent] = {} + self.peers: Dict[str, Dict[str, Any]] = {} + self.dispatches: Dict[str, Dict[str, Any]] = {} + self.received: Dict[str, Dict[str, Any]] = {} + self.processed_idempotency_keys: set = set() + self.total_dispatches: int = 0 + self.total_received: int = 0 + + def register_agent( + self, + agent_id: str, + capabilities: Optional[list[str]] = None, + visibility: AgentVisibility = AgentVisibility.PUBLIC, + server_url: str = "", + ) -> FederatedAgent: + agent = FederatedAgent( + agent_id=agent_id, + server_url=server_url + or (self.manifest.server_url if self.manifest else ""), + capabilities=capabilities or [], + visibility=visibility, + server_did=self.identity.did if self.identity else None, + ) + self.agents[agent_id] = agent + return agent + + def get_visible_agents( + self, requesting_server: Optional[str] = None + ) -> list[FederatedAgent]: + result = [] + for agent in self.agents.values(): + if agent.visibility == AgentVisibility.PUBLIC: + result.append(agent) + elif agent.visibility == AgentVisibility.UNLISTED and requesting_server: + if requesting_server in self.peers: + result.append(agent) + return result + + +_federation_state = FederationState() + + +def get_federation_state() -> FederationState: + return _federation_state + + +def configure_federation( + server_url: str, + server_did: Optional[str] = None, + trust_policy: TrustPolicy = TrustPolicy.ALLOWLIST, + visibility_default: AgentVisibility = AgentVisibility.PUBLIC, + peers: Optional[list[str]] = None, + identity: Optional[ServerIdentity] = None, +) -> FederationState: + state = get_federation_state() + state.enabled = True + + if identity: + state.identity = identity + else: + state.identity = ServerIdentity.generate(server_url) + + if server_did: + state.identity.did = server_did + + state.trust_enforcer = TrustEnforcer( + policy=trust_policy, + allowed_peers=peers, + ) + + state.manifest = FederationManifest( + server_did=state.identity.did, + server_url=server_url, + trust_policy=trust_policy, + visibility_default=visibility_default, + peers=peers or [], + public_key=state.identity.public_key_b64, + ) + + return state + + +def create_federation_router( + validate_api_key=None, +) -> APIRouter: + router = APIRouter(tags=["federation"]) + + def _get_api_key(x_api_key: str = Header(None)) -> str: + if validate_api_key: + return validate_api_key(x_api_key) + return x_api_key or "" + + @router.get("/.well-known/openintent-federation.json") + async def federation_discovery(): + state = get_federation_state() + if not state.enabled or not state.manifest: + raise HTTPException(status_code=404, detail="Federation not enabled") + return state.manifest.to_dict() + + @router.get("/.well-known/did.json") + async def did_document(): + state = get_federation_state() + if not state.enabled or not state.identity: + raise HTTPException(status_code=404, detail="Federation not enabled") + return state.identity.did_document() + + @router.get("/api/v1/federation/status") + async def federation_status(api_key: str = Depends(_get_api_key)): + state = get_federation_state() + if not state.enabled: + return FederationStatus(enabled=False).to_dict() + + active_dispatches = sum( + 1 for d in state.dispatches.values() if d.get("status") == "active" + ) + + return FederationStatus( + enabled=True, + server_did=state.identity.did if state.identity else None, + trust_policy=( + state.trust_enforcer.policy + if state.trust_enforcer + else TrustPolicy.ALLOWLIST + ), + peer_count=len(state.peers), + active_dispatches=active_dispatches, + total_dispatches=state.total_dispatches, + total_received=state.total_received, + ).to_dict() + + @router.get("/api/v1/federation/agents") + async def federation_agents( + request: Request, + api_key: str = Depends(_get_api_key), + ): + state = get_federation_state() + if not state.enabled: + return {"agents": []} + + requesting_server = request.headers.get("X-Source-Server") + agents = state.get_visible_agents(requesting_server) + return {"agents": [a.to_dict() for a in agents]} + + @router.post("/api/v1/federation/dispatch") + async def federation_dispatch( + body: DispatchRequest, + request: Request, + api_key: str = Depends(_get_api_key), + ): + state = get_federation_state() + if not state.enabled: + raise HTTPException(status_code=400, detail="Federation not enabled") + + if body.callback_url and not validate_ssrf(body.callback_url): + raise HTTPException( + status_code=400, + detail="Invalid callback URL: blocked by SSRF protection", + ) + + if not validate_ssrf(body.target_server): + raise HTTPException( + status_code=400, + detail="Invalid target server URL: blocked by SSRF protection", + ) + + dispatch_id = str(uuid.uuid4()) + + delegation_scope = None + if body.delegation_scope: + delegation_scope = DelegationScope.from_dict(body.delegation_scope) + + federation_policy = None + if body.federation_policy: + federation_policy = FederationPolicy.from_dict(body.federation_policy) + + envelope = FederationEnvelope( + dispatch_id=dispatch_id, + source_server=state.manifest.server_url if state.manifest else "", + target_server=body.target_server, + intent_id=body.intent_id, + intent_title="", + agent_id=body.agent_id, + delegation_scope=delegation_scope, + federation_policy=federation_policy, + trace_context=body.trace_context, + callback_url=body.callback_url, + created_at=datetime.now(timezone.utc).isoformat(), + ) + + envelope_dict = envelope.to_dict() + if state.identity: + envelope_dict["signature"] = sign_envelope(state.identity, envelope_dict) + + state.dispatches[dispatch_id] = { + "envelope": envelope_dict, + "status": "active", + "created_at": datetime.now(timezone.utc).isoformat(), + } + state.total_dispatches += 1 + + asyncio.create_task(_deliver_dispatch(state, body.target_server, envelope_dict)) + + return { + "dispatch_id": dispatch_id, + "status": "accepted", + "target_server": body.target_server, + "message": "Dispatch initiated", + } + + @router.post("/api/v1/federation/receive") + async def federation_receive( + body: ReceiveRequest, + request: Request, + api_key: str = Depends(_get_api_key), + ): + state = get_federation_state() + if not state.enabled: + raise HTTPException(status_code=400, detail="Federation not enabled") + + if state.trust_enforcer and not state.trust_enforcer.is_trusted( + body.source_server + ): + raise HTTPException( + status_code=403, + detail=f"Source server {body.source_server} not trusted", + ) + + if body.idempotency_key: + if body.idempotency_key in state.processed_idempotency_keys: + existing = state.received.get(body.dispatch_id, {}) + return { + "dispatch_id": body.dispatch_id, + "accepted": True, + "local_intent_id": existing.get("local_intent_id"), + "message": "Already processed (idempotent)", + } + state.processed_idempotency_keys.add(body.idempotency_key) + + if body.federation_policy: + policy = FederationPolicy.from_dict(body.federation_policy) + budget = policy.budget + if budget.get("max_llm_tokens") == 0 or budget.get("cost_ceiling_usd") == 0: + return { + "dispatch_id": body.dispatch_id, + "accepted": False, + "message": "Rejected: budget constraints too restrictive", + } + + local_intent_id = f"fed-{body.dispatch_id[:8]}-{str(uuid.uuid4())[:8]}" + + state.received[body.dispatch_id] = { + "dispatch_id": body.dispatch_id, + "source_server": body.source_server, + "local_intent_id": local_intent_id, + "agent_id": body.agent_id, + "delegation_scope": body.delegation_scope, + "federation_policy": body.federation_policy, + "callback_url": body.callback_url, + "created_at": datetime.now(timezone.utc).isoformat(), + } + state.total_received += 1 + + if body.callback_url and validate_ssrf(body.callback_url): + asyncio.create_task( + _send_callback( + body.callback_url, + FederationCallback( + dispatch_id=body.dispatch_id, + event_type=CallbackEventType.STATE_DELTA, + state_delta={"status": "accepted"}, + trace_id=( + body.trace_context.get("trace_id") + if body.trace_context + else None + ), + idempotency_key=f"accept-{body.dispatch_id}", + timestamp=datetime.now(timezone.utc).isoformat(), + ), + state.identity, + ) + ) + + return { + "dispatch_id": body.dispatch_id, + "accepted": True, + "local_intent_id": local_intent_id, + "message": "Intent received and accepted for processing", + } + + return router + + +async def _deliver_dispatch( + state: FederationState, + target_server: str, + envelope_dict: Dict[str, Any], + max_retries: int = 3, +) -> None: + import httpx + + url = f"{target_server.rstrip('/')}/api/v1/federation/receive" + + for attempt in range(max_retries): + try: + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post(url, json=envelope_dict) + if response.status_code < 400: + dispatch_id = envelope_dict.get("dispatch_id", "") + if dispatch_id in state.dispatches: + state.dispatches[dispatch_id]["status"] = "delivered" + logger.info(f"Dispatch {dispatch_id} delivered to {target_server}") + return + else: + logger.warning( + f"Dispatch delivery attempt {attempt + 1} failed: {response.status_code}" + ) + except Exception as e: + logger.warning(f"Dispatch delivery attempt {attempt + 1} failed: {e}") + + if attempt < max_retries - 1: + await asyncio.sleep(2**attempt) + + dispatch_id = envelope_dict.get("dispatch_id", "") + if dispatch_id in state.dispatches: + state.dispatches[dispatch_id]["status"] = "failed" + logger.error(f"Dispatch {dispatch_id} delivery failed after {max_retries} attempts") + + +async def _send_callback( + callback_url: str, + callback: FederationCallback, + identity: Optional[ServerIdentity] = None, + max_retries: int = 3, +) -> None: + import httpx + + payload = callback.to_dict() + if identity: + payload["signature"] = sign_envelope(identity, payload) + + for attempt in range(max_retries): + try: + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post(callback_url, json=payload) + if response.status_code < 400: + logger.info( + f"Callback delivered for dispatch {callback.dispatch_id}" + ) + return + except Exception as e: + logger.warning(f"Callback delivery attempt {attempt + 1} failed: {e}") + + if attempt < max_retries - 1: + await asyncio.sleep(2**attempt) + + logger.error(f"Callback delivery failed for dispatch {callback.dispatch_id}") diff --git a/pyproject.toml b/pyproject.toml index 0846b90..a381d8b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "openintent" -version = "0.13.5" +version = "0.14.0" description = "Python SDK and Server for the OpenIntent Coordination Protocol" readme = "README.md" license = {text = "MIT"}