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
3 changes: 2 additions & 1 deletion live/guild/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -3832,7 +3832,8 @@ def search(
# before settlement when capability is absent.
if not capability:
_probe_challenge_or_none(
payments.search_request("discovery-only", limit, min_trust),
PaidRequest.build("best_agent", "GET", "/search", {
"limit": limit, "min_trust": min_trust}),
x_api_key, discovery_only=True)
raise HTTPException(422, "capability is required")
capability, first_response = _http_objective_first_response(
Expand Down
20 changes: 20 additions & 0 deletions live/guild/app/payments.py
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,26 @@ def __init__(self, preq: PaidRequest, extra: Optional[dict[str, Any]] = None):
self.preq = preq
self.cost = preq.cost
self.model = challenge_model(preq)
if extra and extra.get("discovery_only") is True:
# HTTP clients consume PAYMENT-REQUIRED, often without its JSON
# body. Keep the non-executable warning on that canonical wire
# surface as well; a discovery quote is never a purchase request.
self.model.error = (
"discovery_only: Do not pay this non-executable quote. "
"Supply the required inputs for your task, remove any "
"discovery marker, and request a fresh quote without payment.")
self.model.extensions["agent-guild-discovery"] = {
"info": {"discovery_only": True, "executable": False},
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"discovery_only": {"type": "boolean", "const": True},
"executable": {"type": "boolean", "const": False},
},
"required": ["discovery_only", "executable"],
},
}
self.body = x402.payment_required_body(preq, self.cost, model=self.model)
if extra:
self.body.update(extra)
Expand Down
23 changes: 22 additions & 1 deletion live/guild/app/x402.py
Original file line number Diff line number Diff line change
Expand Up @@ -397,7 +397,9 @@ def discovery_document(resources: list["PaidRequest"]) -> dict[str, Any]:
"resources": urls,
"instructions": (
"Recommended first purchase: GET /search to rank agents before "
"delegation. Probe each exact resource without payment. Its "
"delegation. Choose the capability required by your task; "
"the bare /search URL is non-executable discovery only. "
"Probe each exact resource without payment. Its "
"HTTP 402 and "
"PAYMENT-REQUIRED header are authoritative for the current price, "
"Base-mainnet USDC recipient, method, input schema and output "
Expand Down Expand Up @@ -978,6 +980,11 @@ def _bazaar_body_example(preq: "PaidRequest") -> dict[str, Any]:
def bazaar_extension(preq: "PaidRequest") -> dict[str, Any]:
query = dict(preq.query)
route_key = (preq.operation, preq.path)
search_route = route_key == ("best_agent", "/search")
if search_route and "capability" not in query:
# This is an example for materializing a new request, not a made-up
# capability bound into the discovery resource itself.
query["capability"] = "fact-check"
output: dict[str, Any] = {
"type": "json",
"example": _BAZAAR_ROUTE_OUTPUTS.get(
Expand Down Expand Up @@ -1017,6 +1024,20 @@ def bazaar_extension(preq: "PaidRequest") -> dict[str, Any]:
"additionalProperties": {"type": "string"}},
}
input_required = ["type", "method"]
if search_route:
input_properties["queryParams"].update({
"properties": {
"capability": {
"type": "string", "minLength": 1,
"description": (
"Required capability for your actual task. fact-check "
"is an example only. Request the chosen capability "
"without payment before considering its fresh quote."),
},
},
"required": ["capability"],
})
input_required.append("queryParams")
if body_method:
input_properties.update({
"bodyType": {"type": "string", "const": "json"},
Expand Down
59 changes: 58 additions & 1 deletion live/guild/tests/test_discovery_probe_paid_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
import json
import os
import sys
from urllib.parse import urlparse
from urllib.parse import parse_qs, urlparse

import pytest

Expand Down Expand Up @@ -152,6 +152,7 @@ def event_count(event_type):
assert detail["executable"] is False
challenge = json.loads(base64.b64decode(
response.headers["PAYMENT-REQUIRED"]))
_assert_non_executable_header(challenge)
input_info = challenge["extensions"]["bazaar"]["info"]["input"]
assert input_info["method"] == "POST"
assert input_info["bodyType"] == "json"
Expand Down Expand Up @@ -301,6 +302,10 @@ def test_search_bare_registry_probe_is_non_executable(client, settle_spy):
assert response.headers.get("PAYMENT-REQUIRED")
challenge = json.loads(base64.b64decode(
response.headers["PAYMENT-REQUIRED"]))
_assert_non_executable_header(challenge)
assert "capability" not in parse_qs(urlparse(
challenge["resource"]["url"]).query)
assert "discovery-only" not in json.dumps(challenge)
assert response.json()["accepts"] == challenge["accepts"]
assert response.json()["resource"] == challenge["resource"]
assert response.json()["extensions"] == challenge["extensions"]
Expand All @@ -312,6 +317,58 @@ def test_search_bare_registry_probe_is_non_executable(client, settle_spy):
assert settle_spy == []


def _assert_non_executable_header(challenge):
from jsonschema import Draft202012Validator
from x402.schemas import PaymentRequired

PaymentRequired.model_validate(challenge)
assert challenge["error"].startswith("discovery_only: Do not pay")
extension = challenge["extensions"]["agent-guild-discovery"]
assert extension["info"] == {
"discovery_only": True, "executable": False}
Draft202012Validator(extension["schema"]).validate(extension["info"])


def test_search_discovery_materializes_a_distinct_task_quote(
client, settle_spy, search_payment_supply):
from jsonschema import Draft202012Validator

discovery = client.get("/search", params={"limit": 7, "min_trust": 0})
initial = json.loads(base64.b64decode(
discovery.headers["PAYMENT-REQUIRED"]))
_assert_non_executable_header(initial)
bazaar = initial["extensions"]["bazaar"]
validator = Draft202012Validator(bazaar["schema"])
validator.validate(bazaar["info"])
input_info = bazaar["info"]["input"]
query = dict(input_info["queryParams"])
assert query == {"capability": "fact-check", "limit": "7", "min_trust": "0"}
missing = {"input": {**input_info, "queryParams": {"limit": "7"}}}
assert not validator.is_valid(missing)
empty = {"input": {**input_info, "queryParams": {"capability": ""}}}
assert not validator.is_valid(empty)

# A machine chooses its own supported task, rather than paying a probe.
query["capability"] = "translation"
validator.validate({"input": {**input_info, "queryParams": query}})
task = client.get("/search", params=query)
assert task.status_code == 402
quoted = json.loads(base64.b64decode(task.headers["PAYMENT-REQUIRED"]))
assert "agent-guild-discovery" not in quoted["extensions"]
assert quoted["resource"]["url"] != initial["resource"]["url"]
assert parse_qs(urlparse(quoted["resource"]["url"]).query) == {
key: [value] for key, value in query.items()}
assert quoted["extensions"]["bazaar"]["info"]["input"]["queryParams"] == query

# Even a payment-bearing retry of the incomplete resource cannot settle.
resource = urlparse(initial["resource"]["url"])
retry = client.get(resource.path + "?" + resource.query,
headers={"PAYMENT-SIGNATURE": "AAAA"})
assert retry.status_code == 422
assert "payment-response" not in retry.headers
assert settle_spy == []


@pytest.mark.parametrize(
"method,path", [("GET", "/preflight/deep"),
("POST", "/evidence/bundle")])
Expand Down
2 changes: 1 addition & 1 deletion live/guild/tests/test_x402_discovery_document.py
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ def test_body_bound_discovery_quotes_keep_canonical_prices(monkeypatch):


def test_every_published_product_is_probeable_and_has_its_own_quote(
monkeypatch):
monkeypatch, search_payment_supply):
monkeypatch.setenv("GUILD_X402_ENABLED", "1")
monkeypatch.setenv("GUILD_X402_PAY_TO", PAY_TO)
monkeypatch.setenv("GUILD_BILLING_ENFORCED", "1")
Expand Down
Loading