Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 7 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
10 changes: 8 additions & 2 deletions src/main/kotlin/agents_engine/a2a/A2AServer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
9 changes: 7 additions & 2 deletions src/main/kotlin/agents_engine/agui/AgUiServer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down
9 changes: 7 additions & 2 deletions src/main/kotlin/agents_engine/nlweb/NlWebServer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down
14 changes: 9 additions & 5 deletions src/main/resources/internals-agent/x402/X402PaymentGate.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.
73 changes: 73 additions & 0 deletions src/test/kotlin/agents_engine/x402/X402ServeIntegrationTest.kt
Original file line number Diff line number Diff line change
@@ -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<String, String>("paid") {
skills { skill<String, String>("answer", "") { implementedBy { "answer: $it" } } }
}

private fun post(url: String, body: String, payment: String? = null): HttpResponse<String> {
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()
}
}
}
Loading