Skip to content

Commit 0e604af

Browse files
authored
Merge pull request #228 from Deep-CodeAI/feat/x402-serve-wiring
feat(#4557): wire X402PaymentGate into the serve surfaces
2 parents 601ec99 + 56990a2 commit 0e604af

7 files changed

Lines changed: 112 additions & 16 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,13 @@ production) verifies + settles on-chain; we only configure a public `payTo`. The
1616
(gating is at the HTTP layer, outside the agent loop). **Fails closed** — missing/invalid payment, settle
1717
failure, or an unreachable facilitator all return `402`, never serving the resource unpaid. Per request: no
1818
`X-PAYMENT``402` with `{x402Version, error, accepts:[requirements]}`; `X-PAYMENT` present → verify → settle
19-
→ set `X-PAYMENT-RESPONSE` → serve. New package `agents_engine.x402` (core, no deps). 5 hermetic tests (fake
20-
facilitator + in-process `HttpServer`). **Buyer-side autonomous payment is deliberately not included** (it
21-
concentrates the irreversible-money risk — gated on scoped session keys with signing kept below the model
22-
layer); first-class serve-surface wiring + an MCP `paidTool()` / `a2a-x402` extension are follow-ups (#4526).
19+
→ set `X-PAYMENT-RESPONSE` → serve. New package `agents_engine.x402` (core, no deps). **Wired into the serve
20+
surfaces** (#4557): pass `payment = gate` to `NlWebServer.from` / `AgUiServer.from` / `A2AServer.from` to gate
21+
the served endpoint (A2A's agent-card discovery stays free; `McpServer` keeps a granular `paidTool()` follow-up
22+
rather than a blanket gate). 7 hermetic tests (fake facilitator + in-process `HttpServer`). **Buyer-side
23+
autonomous payment is deliberately not included** (it concentrates the irreversible-money risk — gated on
24+
scoped session keys with signing kept below the model layer); buyer-side + an MCP `paidTool()` / `a2a-x402`
25+
extension are follow-ups (#4526).
2326

2427
### Added — `AgUiServer`: serve an agent to a frontend over AG-UI (#4523, PRD §12.7)
2528

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,7 @@ These APIs work in `main`, are unit-tested, and are exercised by integration tes
210210
- **NLWeb endpoint tool (`nlwebSearch`)**`tools { +nlwebSearchTool(baseUrl = "https://example.com") }` lets an agent query an [NLWeb](https://github.com/nlweb-ai/NLWeb) endpoint — a website's natural-language interface over its **schema.org**-structured content — and fold the ranked, typed results into context (#4541, PRD §12.9). Like `perplexitySearch` it is `untrustedOutput = true` (fetched web content is treated as data, not instructions). `nlwebSearchOptions`-style args via `NlWebSearchOptions(site = "podcasts", mode = NlWebMode.GENERATE)`. NLWeb endpoints need no API key. (Every NLWeb endpoint is also an MCP server, so an NLWeb `/mcp` URL is equally consumable through the existing MCP client — this tool is the zero-wiring `/ask`-over-HTTP path.)
211211
- **Serve an NLWeb endpoint (`NlWebServer`)**`NlWebServer.from(agent).start()` exposes the NLWeb `POST /ask` contract (`{query, site?, mode}` → ranked schema.org `results[]`), so agents.kt is consumable by NLWeb clients — the **serve** side to `nlwebSearch`'s **consume** side (#4542). Same `from(agent)` shape, loopback-only JDK-`HttpServer` posture, and threat model as `McpServer.from(agent)` / `A2AServer.from(agent)` (`127.0.0.1`, optional bearer, front with a gateway). The query is the agent's input; an `NlWebSearchResult` output is served verbatim (ranked schema.org results), any other output becomes the `summary` answer — back the agent's retrieval with the RAG `EmbeddingStore` seam (`:agents-kt-rag`) or whatever you like.
212212
- **Serve a frontend over AG-UI (`AgUiServer`)**`AgUiServer.from(agent).start()` exposes an agent over the [AG-UI](https://github.com/ag-ui-protocol/ag-ui) protocol — the **agent↔user/frontend** layer (e.g. a CopilotKit React chat), the only interop surface that reaches an end-user UI without us building a frontend (#4523). Not a descriptor exporter — a runtime streaming surface: `POST` a `RunAgentInput` and get an **SSE stream of typed AG-UI events**, bridged live from the agent's `AgentSession` (`Token``TEXT_MESSAGE_*`, `ToolCall*``TOOL_CALL_*`, `Skill*``STEP_*`, wrapped in `RUN_STARTED … RUN_FINISHED`). Same `from(agent)` shape, loopback-only posture, and threat model as the others; hand-rolled SSE, no AG-UI SDK. **agents.kt now serves the agentic web four ways: MCP, A2A, NLWeb, and AG-UI.**
213-
- **Charge for an agent endpoint over x402 (`X402PaymentGate`, experimental)**`X402PaymentGate(requirements, facilitator).gate(handler)` wraps any JDK `HttpHandler` so a resource is served only after a settled stablecoin (USDC) payment over the [x402](https://github.com/x402-foundation/x402) protocol (`402 Payment Required`) — front it on any of the serve surfaces above to let an agent **monetize itself** (#4527). The **safe, seller-side** half: **we hold no key and take no custody** — the buyer signs an EIP-3009 authorization and a *hosted* `FacilitatorClient` verifies + settles on-chain; we only configure a public `payTo`, and the LLM never touches money (gating is at the HTTP layer). **Fails closed** (any failure → `402`, never served unpaid). Buyer-side autonomous payment is deliberately *not* included (it concentrates the irreversible-money risk). New `agents_engine.x402` package, no deps.
213+
- **Charge for an agent endpoint over x402 (`X402PaymentGate`, experimental)** — `X402PaymentGate(requirements, facilitator).gate(handler)` wraps any JDK `HttpHandler` so a resource is served only after a settled stablecoin (USDC) payment over the [x402](https://github.com/x402-foundation/x402) protocol (`402 Payment Required`). Pass it straight to the serve surfaces — `NlWebServer.from(agent, payment = gate)` / `AgUiServer.from(agent, payment = gate)` / `A2AServer.from(agent, payment = gate)` — to let an agent **monetize itself** (#4527/#4557; A2A's agent-card discovery stays free). The **safe, seller-side** half: **we hold no key and take no custody** — the buyer signs an EIP-3009 authorization and a *hosted* `FacilitatorClient` verifies + settles on-chain; we only configure a public `payTo`, and the LLM never touches money (gating is at the HTTP layer). **Fails closed** (any failure → `402`, never served unpaid). Buyer-side autonomous payment is deliberately *not* included (it concentrates the irreversible-money risk). New `agents_engine.x402` package, no deps.
214214
- **AGNTCY interop (OASF record + DIR directory + Identity badge)** — `agent.toOasfRecord(version, authors, locators)` exports an [AGNTCY](https://github.com/agntcy) [OASF](https://github.com/agntcy/oasf) 1.0.0 discovery record (the third exporter beside `agent.json` and the A2A AgentCard; skills carry taxonomy uids via the opt-in `.oasf("agent_orchestration/multi_agent_planning")` annotation against a vendored, drift-checked taxonomy), and `fromOasfRecord(json)` imports + fail-closed-validates it back (#4518/#4519, PRD §12.6). The `:agents-kt-dir` module publishes/discovers records in the AGNTCY **DIR** content-addressed directory over generated grpc-kotlin stubs for three services — `StoreService` (CRUD: `dir.push(agent.toOasfRecord(...))` → CID, `dir.pull(cid)`), `SearchService` (local content search by typed `DirQuery` facet — skill/domain/author/…), and `RoutingService` (`publish`/`routeSearch` for cross-peer network discovery) (#4520). The trust side ships in `:agents-kt-identity`: `IdentityVerifier.verify(compactJws, jwks)` validates an AGNTCY Identity **badge** (a JOSE/JWS-secured W3C Verifiable Credential) against an issuer's `/.well-known/jwks.json`, fail-closed via `nimbus-jose-jwt` (rejects `alg: none`, `HS*` algorithm-confusion, expiry, tamper, wrong/unknown key — #4521). Verify-only; issuance deferred. DIR Routing/Search + OCI referrers are follow-ups under epic #4517.
215215
- **Prompt caching across providers**`agent { caching { enabled = true; cacheSystemPrompt = true; cacheToolDefs = true; cacheConversation = Rolling; ttl = 1.hours; cacheable("doc-id") { ... } } }`. Vendor-neutral DSL drives Anthropic's explicit `cache_control` breakpoints (#2658), OpenAI / DeepSeek automatic prefix caching with a stable `prompt_cache_key` routing hint (#2659 / #2661), Ollama / vLLM / SGLang engine-level KV-cache reuse (no-op hints, #2662), and surfaces cache reads + writes + hit-rate on `TokenUsage` (#2663). A prefix-stability guard (#2657) detects silent cache-busters — timestamps, UUIDs, non-deterministic ordering inside cacheable segments — and warns before you pay for a single non-cached run. Off by default; non-breaking. See [docs/caching.md](docs/caching.md).
216216
- **JSONL audit exporter**`:agents-kt-observability` writes append-only, one-line-per-event audit rows with `requestId`, `sessionId`, `manifestHash`, agent/skill/tool ids, event type, provider, and model; raw arguments/results are omitted by default (#1914). See [docs/observability.md](docs/observability.md).

src/main/kotlin/agents_engine/a2a/A2AServer.kt

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import agents_engine.core.Agent
44
import agents_engine.generation.LenientJsonParser
55
import agents_engine.generation.codec
66
import agents_engine.generation.hasGenerableAnnotation
7+
import agents_engine.x402.X402PaymentGate
78
import com.sun.net.httpserver.HttpExchange
9+
import com.sun.net.httpserver.HttpHandler
810
import com.sun.net.httpserver.HttpServer
911
import java.net.InetSocketAddress
1012
import java.util.UUID
@@ -34,6 +36,7 @@ class A2AServer private constructor(
3436
private val portRequest: Int,
3537
private val basePath: String,
3638
private val bearerToken: String?,
39+
private val payment: X402PaymentGate? = null,
3740
) {
3841
private var http: HttpServer? = null
3942

@@ -48,7 +51,9 @@ class A2AServer private constructor(
4851
respond(exchange, HTTP_OK, A2AJson.encode(agentCard(agent, url)))
4952
}
5053
}
51-
server.createContext(basePath) { exchange -> handleSafely(exchange) { handleRpc(exchange) } }
54+
// The agent-card (discovery) stays free; only the RPC invocation path is payment-gated when set.
55+
val rpc = HttpHandler { exchange -> handleSafely(exchange) { handleRpc(exchange) } }
56+
server.createContext(basePath, payment?.gate(rpc) ?: rpc)
5257
server.executor = null
5358
server.start()
5459
http = server
@@ -176,7 +181,8 @@ class A2AServer private constructor(
176181
port: Int = 0,
177182
basePath: String = "/a2a",
178183
bearerToken: String? = null,
179-
): A2AServer = A2AServer(agent, port, basePath, bearerToken)
184+
payment: X402PaymentGate? = null,
185+
): A2AServer = A2AServer(agent, port, basePath, bearerToken, payment)
180186

181187
private const val HTTP_OK = 200
182188
private const val HTTP_BAD_REQUEST = 400

src/main/kotlin/agents_engine/agui/AgUiServer.kt

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import agents_engine.core.Agent
44
import agents_engine.generation.LenientJsonParser
55
import agents_engine.internal.toJsonString
66
import agents_engine.runtime.events.session
7+
import agents_engine.x402.X402PaymentGate
78
import com.sun.net.httpserver.HttpExchange
9+
import com.sun.net.httpserver.HttpHandler
810
import com.sun.net.httpserver.HttpServer
911
import java.io.OutputStream
1012
import java.net.InetSocketAddress
@@ -36,6 +38,7 @@ class AgUiServer private constructor(
3638
private val portRequest: Int,
3739
private val bearerToken: String?,
3840
private val maxRequestBytes: Int,
41+
private val payment: X402PaymentGate? = null,
3942
) {
4043
private var http: HttpServer? = null
4144

@@ -44,7 +47,8 @@ class AgUiServer private constructor(
4447

4548
fun start(): AgUiServer {
4649
val server = HttpServer.create(InetSocketAddress("127.0.0.1", portRequest), 0)
47-
server.createContext("/agent") { exchange -> handle(exchange) }
50+
val handler = HttpHandler { exchange -> handle(exchange) }
51+
server.createContext("/agent", payment?.gate(handler) ?: handler)
4852
server.executor = null
4953
server.start()
5054
http = server
@@ -141,7 +145,8 @@ class AgUiServer private constructor(
141145
port: Int = 0,
142146
bearerToken: String? = null,
143147
maxRequestBytes: Int = DEFAULT_MAX_REQUEST_BYTES,
144-
): AgUiServer = AgUiServer(agent, port, bearerToken, maxRequestBytes)
148+
payment: X402PaymentGate? = null,
149+
): AgUiServer = AgUiServer(agent, port, bearerToken, maxRequestBytes, payment)
145150

146151
const val DEFAULT_MAX_REQUEST_BYTES: Int = 1 shl 20 // 1 MiB
147152

src/main/kotlin/agents_engine/nlweb/NlWebServer.kt

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ import agents_engine.generation.LenientJsonParser
55
import agents_engine.internal.toJsonString
66
import agents_engine.model.NlWebResult
77
import agents_engine.model.NlWebSearchResult
8+
import agents_engine.x402.X402PaymentGate
89
import com.sun.net.httpserver.HttpExchange
10+
import com.sun.net.httpserver.HttpHandler
911
import com.sun.net.httpserver.HttpServer
1012
import java.net.InetSocketAddress
1113
import java.util.UUID
@@ -33,6 +35,7 @@ class NlWebServer private constructor(
3335
private val portRequest: Int,
3436
private val bearerToken: String?,
3537
private val maxRequestBytes: Int,
38+
private val payment: X402PaymentGate? = null,
3639
) {
3740
private var http: HttpServer? = null
3841

@@ -41,7 +44,8 @@ class NlWebServer private constructor(
4144

4245
fun start(): NlWebServer {
4346
val server = HttpServer.create(InetSocketAddress("127.0.0.1", portRequest), 0)
44-
server.createContext("/ask") { exchange -> handleSafely(exchange) { handleAsk(exchange) } }
47+
val ask = HttpHandler { exchange -> handleSafely(exchange) { handleAsk(exchange) } }
48+
server.createContext("/ask", payment?.gate(ask) ?: ask)
4549
server.executor = null
4650
server.start()
4751
http = server
@@ -111,7 +115,8 @@ class NlWebServer private constructor(
111115
port: Int = 0,
112116
bearerToken: String? = null,
113117
maxRequestBytes: Int = DEFAULT_MAX_REQUEST_BYTES,
114-
): NlWebServer = NlWebServer(agent, port, bearerToken, maxRequestBytes)
118+
payment: X402PaymentGate? = null,
119+
): NlWebServer = NlWebServer(agent, port, bearerToken, maxRequestBytes, payment)
115120

116121
const val DEFAULT_MAX_REQUEST_BYTES: Int = 1 shl 20 // 1 MiB
117122

src/main/resources/internals-agent/x402/X402PaymentGate.md

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,10 @@ val gate = X402PaymentGate(
1414
PaymentRequirements(network = "base", maxAmountRequired = "10000", payTo = "0xSeller", asset = "0xUSDC", resource = "/premium"),
1515
facilitator = HttpFacilitatorClient("https://facilitator.example"),
1616
)
17-
// front any JDK HttpHandler (our serve surfaces are HttpServer-based):
17+
// front any JDK HttpHandler:
1818
httpServer.createContext("/premium", gate.gate(downstreamHandler))
19+
// ...or pass it straight to a serve surface (#4557):
20+
NlWebServer.from(agent, payment = gate).start() // also AgUiServer.from / A2AServer.from
1921
```
2022

2123
## Why this is the *safe* half (the non-negotiables)
@@ -45,7 +47,9 @@ httpServer.createContext("/premium", gate.gate(downstreamHandler))
4547

4648
## Scope / follow-ups (epic #4526)
4749

48-
Seller-side gate only. NOT here: buyer-side autonomous payment (#4528 — scoped ERC-4337 session keys, signing
49-
below the model layer, HITL), and first-class wiring into the serve surfaces / an MCP `paidTool()` wrapper +
50-
the official `a2a-x402` extension (this gate is the reusable foundation they'd build on). Facilitator field
51-
names follow the x402 facilitator REST spec; verify against a live facilitator before production.
50+
Seller-side gate, wired into `NlWebServer`/`AgUiServer`/`A2AServer` via `from(agent, payment = gate)` (#4557
51+
they wrap the invocation handler `payment?.gate(h) ?: h`; A2A's agent-card discovery stays free). NOT here:
52+
buyer-side autonomous payment (#4528 — scoped ERC-4337 session keys, signing below the model layer, HITL); a
53+
granular MCP `paidTool()` wrapper (McpServer keeps per-tool pricing rather than a blanket gate); the official
54+
`a2a-x402` extension. Facilitator field names follow the x402 facilitator REST spec; verify against a live
55+
facilitator before production.
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package agents_engine.x402
2+
3+
import agents_engine.agui.AgUiServer
4+
import agents_engine.core.agent
5+
import agents_engine.core.skill
6+
import agents_engine.nlweb.NlWebServer
7+
import java.net.URI
8+
import java.net.http.HttpClient
9+
import java.net.http.HttpRequest
10+
import java.net.http.HttpResponse
11+
import kotlin.test.Test
12+
import kotlin.test.assertEquals
13+
import kotlin.test.assertTrue
14+
15+
// #4527 — the X402PaymentGate `payment =` wiring on the serve surfaces. Hermetic: a fake facilitator (no
16+
// chain) gates a real agent served over NLWeb and AG-UI. All four serve surfaces wrap their invocation
17+
// handler identically (payment?.gate(handler) ?: handler), so NLWeb + AG-UI cover the pattern.
18+
class X402ServeIntegrationTest {
19+
20+
private val http = HttpClient.newHttpClient()
21+
22+
private val okFacilitator = object : FacilitatorClient {
23+
override fun verify(paymentHeader: String, requirements: PaymentRequirements) =
24+
FacilitatorVerification(isValid = true, payer = "0xPayer")
25+
26+
override fun settle(paymentHeader: String, requirements: PaymentRequirements) =
27+
FacilitatorSettlement(success = true, transaction = "0xTX", network = requirements.network)
28+
}
29+
30+
private fun gate(resource: String) = X402PaymentGate(
31+
PaymentRequirements(
32+
network = "base", maxAmountRequired = "1", payTo = "0xSeller", asset = "0xUSDC", resource = resource,
33+
),
34+
okFacilitator,
35+
)
36+
37+
private fun paidAgent() = agent<String, String>("paid") {
38+
skills { skill<String, String>("answer", "") { implementedBy { "answer: $it" } } }
39+
}
40+
41+
private fun post(url: String, body: String, payment: String? = null): HttpResponse<String> {
42+
val b = HttpRequest.newBuilder().uri(URI.create(url)).POST(HttpRequest.BodyPublishers.ofString(body))
43+
payment?.let { b.header("X-PAYMENT", it) }
44+
return http.send(b.build(), HttpResponse.BodyHandlers.ofString())
45+
}
46+
47+
@Test
48+
fun `NlWebServer gated by x402 demands payment then serves`() {
49+
val server = NlWebServer.from(paidAgent(), payment = gate("/ask")).start()
50+
try {
51+
assertEquals(402, post(server.url, """{"query":"hi"}""").statusCode())
52+
val paid = post(server.url, """{"query":"hi"}""", payment = "dummy")
53+
assertEquals(200, paid.statusCode())
54+
assertTrue("answer: hi" in paid.body(), paid.body())
55+
} finally {
56+
server.stop()
57+
}
58+
}
59+
60+
@Test
61+
fun `AgUiServer gated by x402 demands payment then streams`() {
62+
val server = AgUiServer.from(paidAgent(), payment = gate("/agent")).start()
63+
try {
64+
val body = """{"messages":[{"role":"user","content":"hi"}]}"""
65+
assertEquals(402, post(server.url, body).statusCode())
66+
val paid = post(server.url, body, payment = "dummy")
67+
assertEquals(200, paid.statusCode())
68+
assertTrue("RUN_STARTED" in paid.body(), paid.body())
69+
} finally {
70+
server.stop()
71+
}
72+
}
73+
}

0 commit comments

Comments
 (0)