Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
782e5af
feat(a2a): outbound client speaks 402 — payment outcome + redaction (…
Oct 3, 2026
68fcdd9
feat(a2a): payment-token credential kind on store, settings + MCP (ab…
Oct 3, 2026
51c275b
docs(a2a): outbound 402 payment outcome + credential kind (abilityai/…
Oct 3, 2026
88eb4a1
fix(a2a): MCP test body shape, payment-token wording, codec comment, …
Oct 3, 2026
6023c45
fix(a2a): don't echo a provider's credential_kind into the log (#3185)
trinity-ability Oct 3, 2026
092a651
fix(a2a): log the normalised credential_kind as a constant (#3185)
trinity-ability Oct 3, 2026
a5b1dce
feat(payments): shared paid-turn orchestrator + payments-py 1.18.0 pi…
Oct 3, 2026
922a7dd
feat(a2a): x402 payment gate on the inbound door — metadata-first tok…
Oct 3, 2026
90e8dfc
feat(a2a): priced agent card + docs for the inbound payment gate (abi…
Oct 3, 2026
2d072c3
fix(paid-turn): settle bookkeeping survives level-triggered cancellat…
Oct 3, 2026
ea55b51
fix(paid-turn,nevermined): release the claim on a refused turn, pin t…
Oct 3, 2026
e659ced
fix(a2a,paid-turn): answer a retryable verify as retryable, classify …
Oct 3, 2026
9c3dc62
merge dev into feature/ent679-a2a-payment-gate: #3209 squash-merged, …
Oct 3, 2026
61baff9
fix(tests): pin payments-py's transitive runtime set so CI matches th…
Oct 4, 2026
d046140
fix(tests): loopback A2A overrides the route's actual auth dependency…
Oct 4, 2026
fa046d4
fix(tests): sync-edge fixture models a row the worker has not finishe…
Oct 4, 2026
038445d
fix(a2a,compose): wire the facilitator bounds into compose, state the…
trinity-ability Oct 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -938,3 +938,20 @@ TRINITY_DEFAULT_SYSTEM_MANIFEST=
# than an error, so mounting the directory is required as well — setting this
# alone silently lists nothing.
TRINITY_MANIFESTS_DIR=

# ============================================================
# NEVERMINED x402 FACILITATOR CONCURRENCY (Optional)
# ============================================================
# Every payment verify and settle is an outbound call to the Nevermined
# facilitator that runs on the default thread executor, so a slow facilitator
# would otherwise hold backend threads for the whole fleet — and a priced
# agent's public URL needs no credential to make the backend dial out, so
# per-IP rate limiting alone cannot bound it (the bound has to hold across IPs).
# These two knobs are that bound: a fleet-wide ceiling on concurrent facilitator
# calls, and how long a call waits for a free slot before answering "busy,
# retry" rather than queueing without limit. Fleet-wide, not per agent — the
# thread pool is a platform resource. A refused call burns nothing.
# Defaults below are the code defaults; leave them unless the facilitator is
# demonstrably keeping up with more.
NEVERMINED_MAX_INFLIGHT=8
NEVERMINED_FACILITATOR_WAIT_SECONDS=5.0
6 changes: 6 additions & 0 deletions docker-compose.hosted.yml
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,12 @@ services:
# gap here cannot make the feature unreachable — but the env leg still has to
# be forwarded or an operator setting it in .env silently does nothing.
- A2A_OUTBOUND_ENABLED=${A2A_OUTBOUND_ENABLED:-false}
# Nevermined x402 facilitator concurrency (ent#679). The bound is enforced at
# these defaults whether or not they are wired, so the gap is an inert
# tuning lever rather than a dead feature — but this compose launches
# standalone (no base merge / env_file), so wire them here too (#1056 class).
- NEVERMINED_MAX_INFLIGHT=${NEVERMINED_MAX_INFLIGHT:-8} # concurrent verify/settle calls, fleet-wide
- NEVERMINED_FACILITATOR_WAIT_SECONDS=${NEVERMINED_FACILITATOR_WAIT_SECONDS:-5.0} # wait for a slot before "busy, retry"
# Outbound voice replies via shared TTS (epic #24; Telegram #25). The key
# gates the feature (empty ⇒ adapters deliver text). Prod compose launches
# standalone (no base merge / env_file), so wire it here too (#1056 class).
Expand Down
6 changes: 6 additions & 0 deletions docker-compose.prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,12 @@ services:
# gap here cannot make the feature unreachable — but the env leg still has to
# be forwarded or an operator setting it in .env silently does nothing.
- A2A_OUTBOUND_ENABLED=${A2A_OUTBOUND_ENABLED:-false}
# Nevermined x402 facilitator concurrency (ent#679). The bound is enforced at
# these defaults whether or not they are wired, so the gap is an inert
# tuning lever rather than a dead feature — but prod compose launches
# standalone (no base merge / env_file), so wire them here too (#1056 class).
- NEVERMINED_MAX_INFLIGHT=${NEVERMINED_MAX_INFLIGHT:-8} # concurrent verify/settle calls, fleet-wide
- NEVERMINED_FACILITATOR_WAIT_SECONDS=${NEVERMINED_FACILITATOR_WAIT_SECONDS:-5.0} # wait for a slot before "busy, retry"
# Outbound voice replies via shared TTS (epic #24; Telegram #25). The key
# gates the feature (empty ⇒ adapters deliver text). Prod compose launches
# standalone (no base merge / env_file), so wire it here too (#1056 class).
Expand Down
6 changes: 6 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,12 @@ services:
# gap here cannot make the feature unreachable — but the env leg still has to
# be forwarded or an operator setting it in .env silently does nothing.
- A2A_OUTBOUND_ENABLED=${A2A_OUTBOUND_ENABLED:-false}
# Nevermined x402 facilitator concurrency (ent#679). The bound is enforced at
# these defaults whether or not they are wired, so a gap here is an inert
# tuning lever rather than a dead feature — but this service uses an explicit
# environment list, so forward them or the .env knobs do nothing (#1056 class).
- NEVERMINED_MAX_INFLIGHT=${NEVERMINED_MAX_INFLIGHT:-8} # concurrent verify/settle calls, fleet-wide
- NEVERMINED_FACILITATOR_WAIT_SECONDS=${NEVERMINED_FACILITATOR_WAIT_SECONDS:-5.0} # wait for a slot before "busy, retry"
# Outbound voice replies via shared TTS (epic #24; Telegram #25). The key
# gates the feature (empty ⇒ adapters deliver text). Must reach the
# container or the .env lever is inert (the #1056/#1039 packaging class).
Expand Down
29 changes: 28 additions & 1 deletion docker/backend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,34 @@ RUN pip install --no-cache-dir \
# bump this pin with the rest of the file.
tzdata==2026.3 \
psutil==6.1.1 \
payments-py==1.2.1 \
# payments-py 1.18.0 (abilityai/trinity-enterprise#679, trinity-enterprise#763):
# the A2A x402 flow carries payment IN-BAND in task metadata, which 1.2.1
# cannot read. tests/requirements-test.txt pins the SAME exact version —
# CI previously ran 1.18.0 against a 1.2.1 image, which is the one
# divergence a dependency guard exists to catch
# (tests/unit/test_ent679_payments_pin_parity.py, the #1891 shape).
payments-py==1.18.0 \
# 1.18.0's own runtime dependency set, pinned explicitly rather than left to
# pip. `payments_py.payments` imports the a2a package at module load, so any
# one of these failing to import flips NEVERMINED_AVAILABLE to False and both
# payment doors answer 501 with a green build — the failure mode that makes
# ~15 floating transitive packages unacceptable here. Versions are the ones
# the test venv resolved, so image and CI agree; Dependabot bumps them like
# any other pin. The docs/test extras (black, mkdocs*, mike, pytest-asyncio)
# are runtime deps in the sdist's METADATA, not an authoring choice of ours.
a2a-sdk==0.3.26 \
mcp==1.30.0 \
python-socketio==5.14.3 \
pyjwt==2.14.0 \
jsonschema==4.26.0 \
websocket-client==1.9.2 \
helicone-helpers==1.2.1 \
black==26.5.1 \
mkdocs==1.6.1 \
mkdocs-material==9.7.7 \
"mkdocstrings[python]==0.29.1" \
mike==2.2.0 \
pytest-asyncio==1.4.0 \
Pillow==11.1.0 \
# #1536 report export. Both are pure-Python wheels — no system libraries, so
# the image build is unchanged beyond these two lines (WeasyPrint was
Expand Down
8 changes: 5 additions & 3 deletions docs/memory/architecture/backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
- `reminders.py` - Agent self-reminders: create/list/cancel (self-gated) (#1296) — see [Agent Self-Reminders](execution.md#agent-self-reminders-1296)
- `files.py` - Public download endpoint for outbound agent file sharing (FILES-001)
- `agent_rename.py` - Rename endpoint (RENAME-001)
- `a2a.py` - A2A protocol: the authenticated per-agent card (#737) plus the **inbound server** on a separate prefix-less `a2a_server_router` — public `GET /a2a/{name}/.well-known/agent-card.json` (per-IP rate limited) + `POST /a2a/{name}` JSON-RPC (message/send, message/stream SSE, tasks/get, tasks/cancel). Exposure is opt-in per agent (`agent_ownership.a2a_exposed`, default OFF); non-exposed/inaccessible → uniform 404 (Invariant #8). `messageId` dedup is scoped per (agent, caller principal) — the field is peer-controlled and only unique per-client (ent#157). **Also hosts the OUTBOUND client (#736):** `POST /{name}/a2a/call` + `POST /{name}/a2a/task`, a Trinity agent tasking an EXTERNAL A2A agent. The target is never caller-supplied — it is a name resolved through `services/a2a_outbound.py` — and the routes are thin (auth + HTTP error map + audit); orchestration lives in `services/a2a_outbound_service.py`. Default OFF (`A2A_OUTBOUND_ENABLED`), both routes 404 when off
- `a2a.py` - A2A protocol: the authenticated per-agent card (#737) plus the **inbound server** on a separate prefix-less `a2a_server_router` — public `GET /a2a/{name}/.well-known/agent-card.json` (per-IP rate limited) + `POST /a2a/{name}` JSON-RPC (message/send, message/stream SSE, tasks/get, tasks/cancel). Exposure is opt-in per agent (`agent_ownership.a2a_exposed`, default OFF); non-exposed/inaccessible → uniform 404 (Invariant #8). `messageId` dedup is scoped per (agent, caller principal) — the field is peer-controlled and only unique per-client (ent#157). **Also hosts the OUTBOUND client (#736):** `POST /{name}/a2a/call` + `POST /{name}/a2a/task`, a Trinity agent tasking an EXTERNAL A2A agent. The target is never caller-supplied — it is a name resolved through `services/a2a_outbound.py` — and the routes are thin (auth + HTTP error map + audit); orchestration lives in `services/a2a_outbound_service.py`. Default OFF (`A2A_OUTBOUND_ENABLED`), both routes 404 when off. **The inbound door also takes payment (ent#679):** its principal is OPTIONAL (`get_user_or_anonymous`, which degrades on a 401 but RE-RAISES a 403 so a fenced key cannot become a payer), and the handler branches once — a resolved principal gets today's free path byte-identically, an anonymous caller gets today's 401 unless the agent is BOTH exposed and Nevermined-enabled, in which case it gets the paid door's own 402 with `resource.url` on THIS door. The card producer `_card_with_exposed_skills` also declares the price (`a2a_card_service.with_payment_extension`), so both card surfaces state it and an unpriced agent's card is byte-identical
- `agent_ssh.py` - SSH access endpoint
- `credentials.py` - Credential injection/export/import (CRED-002)
- `chat.py` / `chat/` - Agent chat/activity monitoring
Expand Down Expand Up @@ -91,7 +91,7 @@
*Public Access & Monetization:*
- `public_links.py` - Public agent link management
- `public.py` - Public chat routes; the turn orchestration moved to `services/public_chat_service.py`, which also owns the per-IP/per-token chat caps and the #311 access-gate predicates (#1028). Also Caddy's unauthenticated on-demand-TLS `ask` gate, `GET /api/public/tls-allowed` (#2380)
- `paid.py` - x402 payment-gated chat (NVM-001)
- `paid.py` - x402 payment-gated chat (NVM-001). The 402/403/verify/settle ORCHESTRATION moved to `services/paid_turn_service.py` (ent#679) so the A2A inbound door runs the identical money logic; this router is now the HTTP shape over it (header read, outcome → response map) and its behaviour is unchanged
- `nevermined.py` - Nevermined payment config (NVM-001)
- `slack.py` - Slack integration: OAuth, events, multi-agent channel routing, per-agent binding (SLACK-001/002)
- `telegram.py` - Telegram bot integration: webhook receiver, bot binding, group config (TELEGRAM-001)
Expand Down Expand Up @@ -207,7 +207,9 @@

*Integrations:*
- `slack_service.py` - Slack API client (OAuth, messaging, verification) (SLACK-001)
- `nevermined_payment_service.py` - x402 payment verification and settlement (NVM-001)
- `nevermined_payment_service.py` - x402 payment verification and settlement (NVM-001). Facilitator calls are bounded fleet-wide by `NEVERMINED_MAX_INFLIGHT` (8) with a `NEVERMINED_FACILITATOR_WAIT_SECONDS` (5.0) wait budget, one semaphore per event loop — verify/settle run on the default thread executor, so an unbounded set of them starves the whole fleet's threads, and the caller needs no credential to trigger one. Knobs documented in [nevermined-payments.md](../feature-flows/nevermined-payments.md#configuration-operator-knobs)
- `paid_turn_service.py` - The ONE x402 paid-turn orchestrator, shared by `routers/paid.py` and the A2A inbound door (ent#679 T3): verify → dedup gate → execute → settle, with the #1018 branches (honest `success_unsettled` on a delivered-but-unsettled turn, a replayed unsettled snapshot re-settling and converging, no settle on a failed/cancelled turn) existing exactly once. Takes every collaborator as a PARAMETER and imports none of them — a service that imported the payment service and the execution bridge would be a second router, and the parameter list is what lets the unit suite drive all ten outcomes with plain stand-ins
- `a2a_payment_gate.py` - The A2A-shaped adapter around `paid_turn_service` (ent#679): `is_priced` (exposure ∧ enabled config ∧ key — deliberately NOT the SDK check, since "takes payment" and "can process one now" are different facts owing a 401 and a 501 respectively), metadata-first token extraction with the deprecated `payment-signature` header as fallback, the 402/403 bodies, and the Task a payer gets back with its x402 metadata. Consults the `a2a_gate` allow-list after verify as `x402:{payer}` and **fails CLOSED** there — the inverse of that seam's authenticated-caller bias, because on this path the payment IS the authorization. Nothing here is edition-aware; the path is reachable only with the entitled exposure flag on
- `proactive_message_service.py` - Agent-to-user proactive messaging with rate limiting and audit (#321)
- `channel_completion_report.py` - Reports a delegated/background execution's terminal back to its originating channel chat/thread (ent#224 Slack, ent#265 Telegram, ent#457 Workspace): inherited-context-only (never inline turns), binding-agent consent + delivery, effect-guarded at-most-once. **The Workspace leg's consent is by construction, not by flag** — a portal session belongs to exactly one client, so there is no third party for an `allow_proactive` bit to protect, which is why the recipient is read from the SESSION ROW (the platform's own record of whose chat this is) rather than from the execution's inherited stamp alone; delivery is a persisted assistant message and the sidebar's `last_message_at` is touched like any other writer's. **Durable, not immediately visible** — the Workspace does NOT poll its threads (`refreshThreads()` is event-driven; the only interval is the 20s asks poll on a different surface), so a client sitting on the thread sees the report at their next reload or thread switch; an idle history poll is a tracked follow-up. A report landing mid-turn can also be read AS that turn's answer, since the client detects a reply by an assistant-row count delta and this is a second writer of those rows — the honest fix needs a per-row discriminator the table does not carry. `INLINE_CHANNEL_TRIGGERS` gains `"public"` so a Workspace turn's OWN execution is never reported twice — public links and x402 share that trigger and are unaffected, since they stamp no `source_channel_chat_id` and never reach the gate — see [channel-completion-report.md](../feature-flows/channel-completion-report.md)
- `channel_history.py` - Persists a delivered proactive **group/channel** broadcast into the channel session (#1649), so the agent has a record of its own outreach. Session keys are derived by driving the channel adapter's own `get_session_identifier()` (never re-implemented — that drifts). **Slack = real recall**: channel sessions are thread-scoped, so a broadcast filed at its own `ts` IS the session an in-thread reply resolves to (needs `slack_service.send_message_detailed()` to return the ts). **Telegram = real recall since ent#600**: group sessions are per chat, so a broadcast lands in the session a participant's reply reads — unless the group's context is off, which records nothing; `purge_telegram_group_history` deletes a group's sessions (chat + forum topics) when its context is switched off. See [integrations.md → Telegram group conversation context](integrations.md#telegram-group-conversation-context-ent600). `#903` shared-thread attribution (`sender_email=None`); persist on confirmed delivery only; fail-soft
Expand Down
Loading
Loading