diff --git a/CHANGELOG.md b/CHANGELOG.md index 8becf7e..2012b0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,10 +16,13 @@ production) verifies + settles on-chain; we only configure a public `payTo`. The (gating is at the HTTP layer, outside the agent loop). **Fails closed** — missing/invalid payment, settle failure, or an unreachable facilitator all return `402`, never serving the resource unpaid. Per request: no `X-PAYMENT` → `402` with `{x402Version, error, accepts:[requirements]}`; `X-PAYMENT` present → verify → settle -→ set `X-PAYMENT-RESPONSE` → serve. New package `agents_engine.x402` (core, no deps). 5 hermetic tests (fake -facilitator + in-process `HttpServer`). **Buyer-side autonomous payment is deliberately not included** (it -concentrates the irreversible-money risk — gated on scoped session keys with signing kept below the model -layer); first-class serve-surface wiring + an MCP `paidTool()` / `a2a-x402` extension are follow-ups (#4526). +→ set `X-PAYMENT-RESPONSE` → serve. New package `agents_engine.x402` (core, no deps). **Wired into the serve +surfaces** (#4557): pass `payment = gate` to `NlWebServer.from` / `AgUiServer.from` / `A2AServer.from` to gate +the served endpoint (A2A's agent-card discovery stays free; `McpServer` keeps a granular `paidTool()` follow-up +rather than a blanket gate). 7 hermetic tests (fake facilitator + in-process `HttpServer`). **Buyer-side +autonomous payment is deliberately not included** (it concentrates the irreversible-money risk — gated on +scoped session keys with signing kept below the model layer); buyer-side + an MCP `paidTool()` / `a2a-x402` +extension are follow-ups (#4526). ### Added — `AgUiServer`: serve an agent to a frontend over AG-UI (#4523, PRD §12.7) diff --git a/README.md b/README.md index 68afa13..b3f00ba 100644 --- a/README.md +++ b/README.md @@ -210,7 +210,7 @@ These APIs work in `main`, are unit-tested, and are exercised by integration tes - **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.) - **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. - **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.** -- **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. +- **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. - **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. - **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). - **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). diff --git a/src/main/kotlin/agents_engine/a2a/A2AServer.kt b/src/main/kotlin/agents_engine/a2a/A2AServer.kt index fc8f967..23cbb6e 100644 --- a/src/main/kotlin/agents_engine/a2a/A2AServer.kt +++ b/src/main/kotlin/agents_engine/a2a/A2AServer.kt @@ -4,7 +4,9 @@ import agents_engine.core.Agent import agents_engine.generation.LenientJsonParser import agents_engine.generation.codec import agents_engine.generation.hasGenerableAnnotation +import agents_engine.x402.X402PaymentGate import com.sun.net.httpserver.HttpExchange +import com.sun.net.httpserver.HttpHandler import com.sun.net.httpserver.HttpServer import java.net.InetSocketAddress import java.util.UUID @@ -34,6 +36,7 @@ class A2AServer private constructor( private val portRequest: Int, private val basePath: String, private val bearerToken: String?, + private val payment: X402PaymentGate? = null, ) { private var http: HttpServer? = null @@ -48,7 +51,9 @@ class A2AServer private constructor( respond(exchange, HTTP_OK, A2AJson.encode(agentCard(agent, url))) } } - server.createContext(basePath) { exchange -> handleSafely(exchange) { handleRpc(exchange) } } + // The agent-card (discovery) stays free; only the RPC invocation path is payment-gated when set. + val rpc = HttpHandler { exchange -> handleSafely(exchange) { handleRpc(exchange) } } + server.createContext(basePath, payment?.gate(rpc) ?: rpc) server.executor = null server.start() http = server @@ -176,7 +181,8 @@ class A2AServer private constructor( port: Int = 0, basePath: String = "/a2a", bearerToken: String? = null, - ): A2AServer = A2AServer(agent, port, basePath, bearerToken) + payment: X402PaymentGate? = null, + ): A2AServer = A2AServer(agent, port, basePath, bearerToken, payment) private const val HTTP_OK = 200 private const val HTTP_BAD_REQUEST = 400 diff --git a/src/main/kotlin/agents_engine/agui/AgUiServer.kt b/src/main/kotlin/agents_engine/agui/AgUiServer.kt index a452801..fc9676b 100644 --- a/src/main/kotlin/agents_engine/agui/AgUiServer.kt +++ b/src/main/kotlin/agents_engine/agui/AgUiServer.kt @@ -4,7 +4,9 @@ import agents_engine.core.Agent import agents_engine.generation.LenientJsonParser import agents_engine.internal.toJsonString import agents_engine.runtime.events.session +import agents_engine.x402.X402PaymentGate import com.sun.net.httpserver.HttpExchange +import com.sun.net.httpserver.HttpHandler import com.sun.net.httpserver.HttpServer import java.io.OutputStream import java.net.InetSocketAddress @@ -36,6 +38,7 @@ class AgUiServer private constructor( private val portRequest: Int, private val bearerToken: String?, private val maxRequestBytes: Int, + private val payment: X402PaymentGate? = null, ) { private var http: HttpServer? = null @@ -44,7 +47,8 @@ class AgUiServer private constructor( fun start(): AgUiServer { val server = HttpServer.create(InetSocketAddress("127.0.0.1", portRequest), 0) - server.createContext("/agent") { exchange -> handle(exchange) } + val handler = HttpHandler { exchange -> handle(exchange) } + server.createContext("/agent", payment?.gate(handler) ?: handler) server.executor = null server.start() http = server @@ -141,7 +145,8 @@ class AgUiServer private constructor( port: Int = 0, bearerToken: String? = null, maxRequestBytes: Int = DEFAULT_MAX_REQUEST_BYTES, - ): AgUiServer = AgUiServer(agent, port, bearerToken, maxRequestBytes) + payment: X402PaymentGate? = null, + ): AgUiServer = AgUiServer(agent, port, bearerToken, maxRequestBytes, payment) const val DEFAULT_MAX_REQUEST_BYTES: Int = 1 shl 20 // 1 MiB diff --git a/src/main/kotlin/agents_engine/nlweb/NlWebServer.kt b/src/main/kotlin/agents_engine/nlweb/NlWebServer.kt index 4b1064d..a491026 100644 --- a/src/main/kotlin/agents_engine/nlweb/NlWebServer.kt +++ b/src/main/kotlin/agents_engine/nlweb/NlWebServer.kt @@ -5,7 +5,9 @@ import agents_engine.generation.LenientJsonParser import agents_engine.internal.toJsonString import agents_engine.model.NlWebResult import agents_engine.model.NlWebSearchResult +import agents_engine.x402.X402PaymentGate import com.sun.net.httpserver.HttpExchange +import com.sun.net.httpserver.HttpHandler import com.sun.net.httpserver.HttpServer import java.net.InetSocketAddress import java.util.UUID @@ -33,6 +35,7 @@ class NlWebServer private constructor( private val portRequest: Int, private val bearerToken: String?, private val maxRequestBytes: Int, + private val payment: X402PaymentGate? = null, ) { private var http: HttpServer? = null @@ -41,7 +44,8 @@ class NlWebServer private constructor( fun start(): NlWebServer { val server = HttpServer.create(InetSocketAddress("127.0.0.1", portRequest), 0) - server.createContext("/ask") { exchange -> handleSafely(exchange) { handleAsk(exchange) } } + val ask = HttpHandler { exchange -> handleSafely(exchange) { handleAsk(exchange) } } + server.createContext("/ask", payment?.gate(ask) ?: ask) server.executor = null server.start() http = server @@ -111,7 +115,8 @@ class NlWebServer private constructor( port: Int = 0, bearerToken: String? = null, maxRequestBytes: Int = DEFAULT_MAX_REQUEST_BYTES, - ): NlWebServer = NlWebServer(agent, port, bearerToken, maxRequestBytes) + payment: X402PaymentGate? = null, + ): NlWebServer = NlWebServer(agent, port, bearerToken, maxRequestBytes, payment) const val DEFAULT_MAX_REQUEST_BYTES: Int = 1 shl 20 // 1 MiB diff --git a/src/main/resources/internals-agent/x402/X402PaymentGate.md b/src/main/resources/internals-agent/x402/X402PaymentGate.md index 84503d2..2a2a7ee 100644 --- a/src/main/resources/internals-agent/x402/X402PaymentGate.md +++ b/src/main/resources/internals-agent/x402/X402PaymentGate.md @@ -14,8 +14,10 @@ val gate = X402PaymentGate( PaymentRequirements(network = "base", maxAmountRequired = "10000", payTo = "0xSeller", asset = "0xUSDC", resource = "/premium"), facilitator = HttpFacilitatorClient("https://facilitator.example"), ) -// front any JDK HttpHandler (our serve surfaces are HttpServer-based): +// front any JDK HttpHandler: httpServer.createContext("/premium", gate.gate(downstreamHandler)) +// ...or pass it straight to a serve surface (#4557): +NlWebServer.from(agent, payment = gate).start() // also AgUiServer.from / A2AServer.from ``` ## Why this is the *safe* half (the non-negotiables) @@ -45,7 +47,9 @@ httpServer.createContext("/premium", gate.gate(downstreamHandler)) ## Scope / follow-ups (epic #4526) -Seller-side gate only. NOT here: buyer-side autonomous payment (#4528 — scoped ERC-4337 session keys, signing -below the model layer, HITL), and first-class wiring into the serve surfaces / an MCP `paidTool()` wrapper + -the official `a2a-x402` extension (this gate is the reusable foundation they'd build on). Facilitator field -names follow the x402 facilitator REST spec; verify against a live facilitator before production. +Seller-side gate, wired into `NlWebServer`/`AgUiServer`/`A2AServer` via `from(agent, payment = gate)` (#4557 — +they wrap the invocation handler `payment?.gate(h) ?: h`; A2A's agent-card discovery stays free). NOT here: +buyer-side autonomous payment (#4528 — scoped ERC-4337 session keys, signing below the model layer, HITL); a +granular MCP `paidTool()` wrapper (McpServer keeps per-tool pricing rather than a blanket gate); the official +`a2a-x402` extension. Facilitator field names follow the x402 facilitator REST spec; verify against a live +facilitator before production. diff --git a/src/test/kotlin/agents_engine/x402/X402ServeIntegrationTest.kt b/src/test/kotlin/agents_engine/x402/X402ServeIntegrationTest.kt new file mode 100644 index 0000000..31910ee --- /dev/null +++ b/src/test/kotlin/agents_engine/x402/X402ServeIntegrationTest.kt @@ -0,0 +1,73 @@ +package agents_engine.x402 + +import agents_engine.agui.AgUiServer +import agents_engine.core.agent +import agents_engine.core.skill +import agents_engine.nlweb.NlWebServer +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +// #4527 — the X402PaymentGate `payment =` wiring on the serve surfaces. Hermetic: a fake facilitator (no +// chain) gates a real agent served over NLWeb and AG-UI. All four serve surfaces wrap their invocation +// handler identically (payment?.gate(handler) ?: handler), so NLWeb + AG-UI cover the pattern. +class X402ServeIntegrationTest { + + private val http = HttpClient.newHttpClient() + + private val okFacilitator = object : FacilitatorClient { + override fun verify(paymentHeader: String, requirements: PaymentRequirements) = + FacilitatorVerification(isValid = true, payer = "0xPayer") + + override fun settle(paymentHeader: String, requirements: PaymentRequirements) = + FacilitatorSettlement(success = true, transaction = "0xTX", network = requirements.network) + } + + private fun gate(resource: String) = X402PaymentGate( + PaymentRequirements( + network = "base", maxAmountRequired = "1", payTo = "0xSeller", asset = "0xUSDC", resource = resource, + ), + okFacilitator, + ) + + private fun paidAgent() = agent("paid") { + skills { skill("answer", "") { implementedBy { "answer: $it" } } } + } + + private fun post(url: String, body: String, payment: String? = null): HttpResponse { + val b = HttpRequest.newBuilder().uri(URI.create(url)).POST(HttpRequest.BodyPublishers.ofString(body)) + payment?.let { b.header("X-PAYMENT", it) } + return http.send(b.build(), HttpResponse.BodyHandlers.ofString()) + } + + @Test + fun `NlWebServer gated by x402 demands payment then serves`() { + val server = NlWebServer.from(paidAgent(), payment = gate("/ask")).start() + try { + assertEquals(402, post(server.url, """{"query":"hi"}""").statusCode()) + val paid = post(server.url, """{"query":"hi"}""", payment = "dummy") + assertEquals(200, paid.statusCode()) + assertTrue("answer: hi" in paid.body(), paid.body()) + } finally { + server.stop() + } + } + + @Test + fun `AgUiServer gated by x402 demands payment then streams`() { + val server = AgUiServer.from(paidAgent(), payment = gate("/agent")).start() + try { + val body = """{"messages":[{"role":"user","content":"hi"}]}""" + assertEquals(402, post(server.url, body).statusCode()) + val paid = post(server.url, body, payment = "dummy") + assertEquals(200, paid.statusCode()) + assertTrue("RUN_STARTED" in paid.body(), paid.body()) + } finally { + server.stop() + } + } +}