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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,9 @@ Writes (register, attest) are free. Reads that rank or score agents are metered
quote units (1 credit corresponds to $0.001 when deriving the real payment price).
Current prices and enforcement are in the live manifest. Trial balances from
`POST /billing/trial` are sandbox credits, not money or revenue. Real paid reads
use x402; follow the current challenge before signing.
use x402; follow the current challenge before signing. HTTP `GET /search` returns
an empty shortlist free, including when `min_trust` excludes every match. Only a
nonempty search result is metered.

**Is it actually live?**
Yes — `curl https://agent-guild-5d5r.onrender.com/health`. The browser prototype in
Expand Down
13 changes: 12 additions & 1 deletion live/guild/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -3849,7 +3849,10 @@ def search(
preq = payments.search_request(capability, limit, min_trust)
_recover_http_paid_result(preq)
dem = _record_http_demand(request, capability, x_api_key)
facts = _meter_with_demand(preq, x_api_key, response, dem)
# Build the exact result before payment. A capability can have no supply,
# or min_trust can exclude every match; neither case earns a lookup fee.
# Recovery stays first so an existing purchase returns its original bytes
# even if the registry has changed since it was paid for.
scores = store.reputation()
items: list[SearchResultItem] = []
for a in store.agents.values():
Expand All @@ -3867,6 +3870,14 @@ def search(
))
items.sort(key=lambda x: x.trust, reverse=True)
top = items[:limit]
if not top:
response.headers["X-Guild-Cost"] = "0"
store.record_event(x_api_key, "search_empty", ua=_ua.get(),
endpoint="search", transport="http",
capability=capability, min_trust=min_trust,
price_credits=0, paid=False)
return SearchResponse(capability=capability, count=0, results=[])
facts = _meter_with_demand(preq, x_api_key, response, dem)
# remember what we recommended, so a later hire can be attributed to it.
if x_api_key:
store.note_recommendations(x_api_key, [r.id for r in top])
Expand Down
23 changes: 23 additions & 0 deletions live/guild/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,26 @@ def fresh_scout_demand(monkeypatch):
prior = {row["capability"] for row in original()}
monkeypatch.setattr(store, "demand_feed_entries", lambda: [
row for row in original() if row["capability"] not in prior])


@pytest.fixture()
def search_payment_supply(monkeypatch):
"""Payment-protocol tests buy a nonempty shortlist, not an empty result.

Keep this supplier request-local and out of durable discovery/identity
tables. These tests exercise payment mechanics; registration and empty
registries are covered with real isolated stores in test_empty_search.
"""
from app.main import store
agents = dict(store.agents)
agents["payment-fixture-supplier"] = {
"id": "payment-fixture-supplier", "did": "did:key:test-payment-supplier",
"name": "Payment test supplier",
"capabilities": ["anything", "x", "code-review", "fact-check",
"translation", "different-capability"],
"metadata": {}, "seed": False,
}
monkeypatch.setattr(store, "agents", agents)
store._rep_cache = None
yield
store._rep_cache = None
1 change: 1 addition & 0 deletions live/guild/tests/test_best_agent_paid_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ def test_http_check_records_a_completion_with_settlement_facts(store, client):


def test_http_search_records_a_completion(store, client):
store.register_agent("Search supplier", ["fact-check"], {})
r = client.get("/search", params={"capability": "fact-check"},
headers={"user-agent": EXT_UA})
assert r.status_code == 200, r.text
Expand Down
1 change: 1 addition & 0 deletions live/guild/tests/test_completed_payment_retrieval.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ def test_completed_http_purchase_survives_expiry_restart_and_provider_outage(
if explicit_identifier:
payload = _with_pid(payload, _pid())
headers = {"PAYMENT-SIGNATURE": sig_header(payload)}
store.register_agent("Recovery supplier", ["paid-recovery", "another-task"], {})
url = f"/{path}?capability=paid-recovery"
with TestClient(main.app) as client:
paid = client.get(url, headers=headers)
Expand Down
89 changes: 89 additions & 0 deletions live/guild/tests/test_empty_search.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
"""Empty shortlists never consume money or sandbox credit."""
import pytest
from fastapi.testclient import TestClient
from app import main, payments, state, x402
from app.store import Store
from tests.test_x402_v2 import FakeFacilitator, make_payload, sig_header
from tests.test_payment_recovery_no_identifier import _anchor, _confirm_ok, fac, mainnet_store

@pytest.fixture(params=["json", "sqlite"])
def search_store(request, tmp_path, monkeypatch):
for key, value in {"GUILD_STORE": request.param,
"GUILD_STORE_PATH": str(tmp_path / "guild.sqlite3"),
"GUILD_BILLING_ENFORCED": "1", "GUILD_X402_ENABLED": "1",
"GUILD_X402_NETWORK": "eip155:84532",
"GUILD_X402_PAY_TO": "0x" + "11" * 20}.items():
monkeypatch.setenv(key, value)
s = Store(path=str(tmp_path / "guild.json"))
monkeypatch.setattr(main, "store", s)
monkeypatch.setattr(state, "store", s)
return s

def assert_empty(response, s):
assert response.status_code == 200, response.text
assert response.json() == {"capability": "paid-task", "count": 0, "results": []}
assert response.headers["X-Guild-Cost"] == "0"
assert "PAYMENT-REQUIRED" not in response.headers
assert "PAYMENT-RESPONSE" not in response.headers
assert not any(e["type"] == "best_agent_served" for e in s.events)
assert any(e["type"] == "search_empty" and e["price_credits"] == 0 for e in s.events)
assert any(e["type"] == "capability_demand" for e in s.events)

@pytest.mark.parametrize("credential", ["none", "x402", "malformed", "mpp"])
def test_empty_search_does_not_attempt_settlement(search_store, monkeypatch, credential):
fac = FakeFacilitator()
monkeypatch.setattr(x402, "_facilitator", lambda: fac)
headers = {}
if credential == "x402":
headers = {"PAYMENT-SIGNATURE": sig_header(make_payload(payments.search_request("paid-task")))}
elif credential == "malformed":
headers = {"PAYMENT-SIGNATURE": "malformed-test-only"}
elif credential == "mpp":
monkeypatch.setattr(main.mpp, "enabled", lambda: True)
headers = {"Authorization": "Payment test-only"}
response = TestClient(main.app).get("/search?capability=paid-task", headers=headers)
assert_empty(response, search_store)
assert fac.verify_calls == fac.settle_calls == []
assert not search_store.x402_payment_ids

@pytest.mark.parametrize("filtered", [False, True])
def test_empty_search_preserves_sandbox_balance(search_store, filtered):
if filtered:
search_store.register_agent("Supplier", ["paid-task"], {})
account = search_store.create_account()
before = account["balance"]
response = TestClient(main.app).get("/search?capability=paid-task&min_trust=100", headers={"X-API-Key": account["key"]})
assert_empty(response, search_store)
assert search_store.get_account(account["key"])["balance"] == before

def test_nonempty_search_still_requires_payment_and_charges_once(search_store):
search_store.register_agent("Supplier", ["paid-task"], {})
client = TestClient(main.app)
quote = client.get("/search?capability=paid-task")
assert quote.status_code == 402 and "PAYMENT-REQUIRED" in quote.headers
account = search_store.create_account()
before = account["balance"]
response = client.get("/search?capability=paid-task", headers={"X-API-Key": account["key"]})
cost = payments.search_request("paid-task").cost
assert response.status_code == 200 and response.json()["count"] == 1
assert search_store.get_account(account["key"])["balance"] == before - cost
assert len([e for e in search_store.events if e["type"] == "best_agent_served"]) == 1

def test_paid_recovery_precedes_current_empty_search(mainnet_store, fac, monkeypatch):
search_store = mainnet_store()
monkeypatch.setattr(main, "store", search_store)
_anchor(monkeypatch)
_confirm_ok(monkeypatch)
search_store.register_agent("Supplier", ["paid-task"], {})
payload = make_payload(payments.search_request("paid-task"))
headers = {"PAYMENT-SIGNATURE": sig_header(payload)}
client = TestClient(main.app)
paid = client.get("/search?capability=paid-task", headers=headers)
assert paid.status_code == 200 and paid.json()["count"] == 1
monkeypatch.setattr(search_store, "agents", {})
monkeypatch.setattr(x402.time, "time", lambda: float(payload.payload["authorization"]["validBefore"]) + 3600)
retry = client.get("/search?capability=paid-task", headers=headers)
assert retry.status_code == 200 and retry.content == paid.content
assert retry.headers["PAYMENT-RESPONSE"] == paid.headers["PAYMENT-RESPONSE"]
assert retry.headers["X-Guild-Payment-Idempotent-Replay"] == "true"
assert len(fac.settle_calls) == 1
2 changes: 1 addition & 1 deletion live/guild/tests/test_mpp_discovery_challenge_parity.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@


@pytest.fixture(autouse=True)
def _enforced(monkeypatch):
def _enforced(monkeypatch, search_payment_supply):
monkeypatch.setenv("GUILD_BILLING_ENFORCED", "1")
monkeypatch.setenv("GUILD_X402_ENABLED", "1")
monkeypatch.setenv("GUILD_MPP_ENABLED", "1")
Expand Down
2 changes: 1 addition & 1 deletion live/guild/tests/test_openapi_offers_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@


@pytest.fixture(autouse=True)
def _enforced(monkeypatch):
def _enforced(monkeypatch, search_payment_supply):
monkeypatch.setenv("GUILD_BILLING_ENFORCED", "1")
monkeypatch.setenv("GUILD_X402_ENABLED", "1")
monkeypatch.setenv("GUILD_MPP_ENABLED", "1")
Expand Down
2 changes: 1 addition & 1 deletion live/guild/tests/test_payment_identifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@


@pytest.fixture(autouse=True)
def fac(monkeypatch, tmp_path):
def fac(monkeypatch, tmp_path, 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
1 change: 1 addition & 0 deletions live/guild/tests/test_payment_stage_diagnostics.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ def isolated(request, tmp_path, monkeypatch):
s = Store(path=str(tmp_path / "guild.json"))
for module in (state, main, mcp_server, a2a_x402):
monkeypatch.setattr(module, "store", s)
s.register_agent("Payment supplier", ["anything"], {})
payments._inflight_reset_for_process_restart()
x402.replay_guard._seen.clear()
yield s
Expand Down
2 changes: 1 addition & 1 deletion live/guild/tests/test_x402_cdp_settlement.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ def _fake_secret() -> str:


@pytest.fixture()
def mainnet_env(monkeypatch):
def mainnet_env(monkeypatch, search_payment_supply):
monkeypatch.setenv("GUILD_X402_ENABLED", "1")
monkeypatch.setenv("GUILD_X402_PAY_TO", PAY_TO)
monkeypatch.setenv("GUILD_X402_NETWORK", MAINNET)
Expand Down
3 changes: 3 additions & 0 deletions live/guild/tests/test_x402_trial_cta.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
"""One-variable x402 conversion experiment: quote copy, not capability."""
import base64
import json
import pytest

pytestmark = pytest.mark.usefixtures("search_payment_supply")

from fastapi.testclient import TestClient

Expand Down
2 changes: 1 addition & 1 deletion live/guild/tests/test_x402_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@


@pytest.fixture(autouse=True)
def _x402_env(monkeypatch):
def _x402_env(monkeypatch, search_payment_supply):
monkeypatch.setenv("GUILD_X402_ENABLED", "1")
monkeypatch.setenv("GUILD_X402_PAY_TO", PAY_TO)
monkeypatch.delenv("GUILD_X402_NETWORK", raising=False)
Expand Down
3 changes: 3 additions & 0 deletions live/guild/tests_x402_interop/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,9 @@ def live_stack():
from fake_facilitator import app as facilitator_app
from app.main import app as guild_app

from app.state import store
store.register_agent("Interop supplier", ["interop.test"], {})

facilitator = _ServerThread(facilitator_app, FACILITATOR_PORT)
guild = _ServerThread(guild_app, GUILD_PORT)
facilitator.start()
Expand Down
Loading