Skip to content
Open
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
95 changes: 94 additions & 1 deletion cmd/obol/sell.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package main

import (
"bytes"
"context"
"encoding/hex"
"encoding/json"
Expand Down Expand Up @@ -142,6 +143,10 @@ Examples:
Usage: "SHA-256 of model weights for TEE attestation (required with --tee)",
Sources: cli.EnvVars("OBOL_MODEL_HASH"),
},
&cli.StringFlag{
Name: "provenance-file",
Usage: "Path to JSON file with provenance metadata (e.g. autoresearch experiment results)",
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
name := cmd.Args().First()
Expand Down Expand Up @@ -200,6 +205,16 @@ Examples:
TEEType: teeType,
ModelHash: modelHash,
}

if pf := cmd.String("provenance-file"); pf != "" {
prov, err := loadProvenance(pf)
if err != nil {
return fmt.Errorf("load provenance: %w", err)
}
d.Provenance = prov
fmt.Printf("Loaded provenance: %s (metric %s=%s, params %s)\n",
prov.Framework, prov.MetricName, prov.MetricValue, prov.ParamCount)
}
if priceTable.PerMTok != "" {
d.ApproxTokensPerRequest = schemas.ApproxTokensPerRequest
}
Expand Down Expand Up @@ -313,6 +328,14 @@ Examples:
Name: "register-domains",
Usage: "OASF domains for discovery (e.g. technology/artificial_intelligence)",
},
&cli.StringSliceFlag{
Name: "register-metadata",
Usage: "Additional registration metadata as key=value pairs (repeatable, e.g. gpu=A100-80GB)",
},
&cli.StringFlag{
Name: "provenance-file",
Usage: "Path to JSON file with provenance metadata (e.g. autoresearch experiment results)",
},
},
Action: func(ctx context.Context, cmd *cli.Command) error {
if cmd.NArg() == 0 {
Expand Down Expand Up @@ -356,6 +379,35 @@ Examples:
spec["path"] = path
}

if pf := cmd.String("provenance-file"); pf != "" {
prov, err := loadProvenance(pf)
if err != nil {
return fmt.Errorf("load provenance: %w", err)
}
provMap := map[string]interface{}{}
if prov.Framework != "" {
provMap["framework"] = prov.Framework
}
if prov.MetricName != "" {
provMap["metricName"] = prov.MetricName
}
if prov.MetricValue != "" {
provMap["metricValue"] = prov.MetricValue
}
if prov.ExperimentID != "" {
provMap["experimentId"] = prov.ExperimentID
}
if prov.TrainHash != "" {
provMap["trainHash"] = prov.TrainHash
}
if prov.ParamCount != "" {
provMap["paramCount"] = prov.ParamCount
}
spec["provenance"] = provMap
fmt.Printf("Loaded provenance: %s (metric %s=%s, params %s)\n",
prov.Framework, prov.MetricName, prov.MetricValue, prov.ParamCount)
}

if cmd.Bool("register") || cmd.String("register-name") != "" {
reg := map[string]interface{}{
"enabled": cmd.Bool("register"),
Expand All @@ -375,6 +427,13 @@ Examples:
if domains := cmd.StringSlice("register-domains"); len(domains) > 0 {
reg["domains"] = domains
}
if metaPairs := cmd.StringSlice("register-metadata"); len(metaPairs) > 0 {
meta, err := parseMetadataPairs(metaPairs)
if err != nil {
return err
}
reg["metadata"] = meta
}
spec["registration"] = reg
}

Expand Down Expand Up @@ -959,6 +1018,18 @@ func valueOrNone(s string) string {
return s
}

func parseMetadataPairs(values []string) (map[string]string, error) {
meta := make(map[string]string, len(values))
for _, raw := range values {
key, value, ok := strings.Cut(raw, "=")
if !ok || strings.TrimSpace(key) == "" {
return nil, fmt.Errorf("invalid --register-metadata value %q: expected key=value", raw)
}
meta[strings.TrimSpace(key)] = strings.TrimSpace(value)
}
return meta, nil
}

func resolvePriceTable(cmd *cli.Command, allowPerHour bool) (schemas.PriceTable, error) {
perRequest := cmd.String("price")
if perRequest == "" {
Expand All @@ -979,6 +1050,9 @@ func resolvePriceTable(cmd *cli.Command, allowPerHour bool) (schemas.PriceTable,
}
return schemas.PriceTable{PerMTok: perMTok}, nil
case perHour != "":
if _, err := schemas.ApproximateRequestPriceFromPerHour(perHour); err != nil {
return schemas.PriceTable{}, fmt.Errorf("invalid --per-hour value %q: %w", perHour, err)
}
return schemas.PriceTable{PerHour: perHour}, nil
default:
if allowPerHour {
Expand All @@ -999,7 +1073,11 @@ func formatPriceTableSummary(priceTable schemas.PriceTable) string {
schemas.ApproxTokensPerRequest,
)
case priceTable.PerHour != "":
return fmt.Sprintf("%s USDC/hour", priceTable.PerHour)
return fmt.Sprintf("%s USDC/request (approx from %s USDC/hour @ %d min/request)",
priceTable.EffectiveRequestPrice(),
priceTable.PerHour,
schemas.ApproxMinutesPerRequest,
)
default:
return "0 USDC/request"
}
Expand All @@ -1024,6 +1102,21 @@ func formatInferencePriceSummary(d *inference.Deployment) string {
return fmt.Sprintf("%s USDC/request", d.PricePerRequest)
}

// loadProvenance reads a provenance JSON file and returns the parsed struct.
func loadProvenance(path string) (*inference.Provenance, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read %s: %w", path, err)
}
var prov inference.Provenance
dec := json.NewDecoder(bytes.NewReader(data))
dec.DisallowUnknownFields()
if err := dec.Decode(&prov); err != nil {
return nil, fmt.Errorf("parse %s: %w", path, err)
}
return &prov, nil
}

// removePricingRoute removes the x402-verifier pricing route for the given offer.
func removePricingRoute(cfg *config.Config, name string) {
urlPath := fmt.Sprintf("/services/%s", name)
Expand Down
34 changes: 34 additions & 0 deletions internal/embed/infrastructure/base/templates/serviceoffer-crd.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,32 @@ spec:
perEpoch:
type: string
description: "Per-training-epoch price in USDC. Fine-tuning only."
provenance:
type: object
description: >-
Optional provenance metadata for the service. Tracks how the
model or service was produced (e.g. autoresearch experiment data).
Included in the ERC-8004 registration document when present.
properties:
framework:
type: string
description: "Optimization framework (e.g. autoresearch)."
metricName:
type: string
description: "Name of the primary quality metric (e.g. val_bpb)."
default: "val_bpb"
metricValue:
type: string
description: "Primary quality metric value (e.g. 0.9973)."
experimentId:
type: string
description: "Experiment or commit identifier."
trainHash:
type: string
description: "SHA-256 hash of the training code that produced this model."
paramCount:
type: string
description: "Model parameter count (e.g. 50M, 1.3B)."
path:
type: string
description: "URL path prefix for the HTTPRoute, defaults to /services/<name>."
Expand Down Expand Up @@ -201,6 +227,14 @@ spec:
Valid values: reputation, crypto-economic, tee-attestation.
items:
type: string
metadata:
type: object
description: >-
Additional registration metadata published into the generated
agent-registration.json for discovery and ranking (for example:
gpu, framework, best_val_bpb, total_experiments).
additionalProperties:
type: string
status:
type: object
properties:
Expand Down
179 changes: 179 additions & 0 deletions internal/embed/skills/autoresearch-coordinator/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
---
name: autoresearch-coordinator
description: "Coordinate distributed autoresearch experiments across GPU workers discovered via ERC-8004 and paid via x402 micropayments."
metadata: { "openclaw": { "emoji": "\ud83d\udd2c", "requires": { "bins": ["python3", "curl"] } } }
---

# Autoresearch Coordinator

Coordinate distributed autoresearch experiments across GPU workers discovered on-chain via ERC-8004 and paid per-experiment via x402 micropayments. This replaces the Ensue-based shared-memory coordinator from autoresearch-at-home with a fully decentralised discovery and payment loop built on obol-stack primitives.

## When to Use

- Discovering GPU workers advertising `machine_learning/model_optimization` capabilities via 8004scan
- Probing worker endpoints for x402 pricing before submitting experiments
- Submitting `train.py` experiments to remote GPU workers through x402 payment gates
- Running the continuous THINK/CLAIM/RUN/PUBLISH experiment loop
- Viewing the global leaderboard of autoresearch results from worker metadata
- Coordinating multi-worker experiment campaigns

## When NOT to Use

- Selling your own GPU as a worker -- use `autoresearch-worker` (then monetize it with `obol sell http`)
- Buying generic inference (chat completions) -- use `buy-inference`
- Discovering agents without running experiments -- use `discovery`
- Signing transactions directly -- use `ethereum-local-wallet`
- Cluster diagnostics -- use `obol-stack`

## Quick Start

```bash
# Discover available GPU workers on 8004scan
python3 scripts/coordinate.py discover

# Discover with custom limit
python3 scripts/coordinate.py discover --limit 5

# Probe a specific worker for pricing
python3 scripts/coordinate.py probe https://worker.example.com/services/autoresearch-worker

# Submit a single experiment to a worker
python3 scripts/coordinate.py submit https://worker.example.com/services/autoresearch-worker train.py

# Submit with custom config overrides
python3 scripts/coordinate.py submit https://worker.example.com/services/autoresearch-worker train.py \
--config '{"batch_size": 64, "learning_rate": 0.001}'

# View global leaderboard (best val_bpb across all workers)
python3 scripts/coordinate.py leaderboard

# Run continuous experiment loop (discover -> pick -> submit -> publish)
python3 scripts/coordinate.py loop train.py

# Loop with worker preference and max rounds
python3 scripts/coordinate.py loop train.py --prefer https://worker.example.com/services/autoresearch-worker --rounds 10
```

## Commands

| Command | Description |
|---------|-------------|
| `discover [--limit N]` | Query 8004scan for GPU workers with `machine_learning/model_optimization` skill |
| `probe <endpoint>` | Send unauthenticated request to parse 402 pricing from the worker |
| `submit <endpoint> <train.py> [--config JSON]` | Submit experiment with x402 payment (pre-sign ERC-3009, attach X-PAYMENT) |
| `leaderboard [--limit N]` | Query 8004scan for all autoresearch workers, rank by best `val_bpb` |
| `loop <train.py> [--prefer URL] [--rounds N]` | Continuous loop: discover, pick best worker, submit, collect, publish |

## The Experiment Loop

The coordinator implements a THINK/CLAIM/RUN/PUBLISH loop:

1. **THINK** -- Read current `train.py`, review leaderboard results, decide on next experiment variation
2. **CLAIM** -- Discover workers via 8004scan, probe pricing, select best worker (cheapest or preferred)
3. **RUN** -- Submit `train.py` + config to worker via POST `/experiment` through the x402 payment gate
4. **PUBLISH** -- Record result locally with provenance metadata (val_bpb, experiment_id, train.py hash, worker endpoint)

Each step is atomic and idempotent. If a worker fails mid-experiment, the coordinator retries with the next available worker.

## How Discovery Works

Workers register on-chain via ERC-8004 and advertise capabilities through OASF (Open Agent Skills Framework) metadata. The coordinator queries the 8004scan public API:

```
GET https://www.8004scan.io/api/v1/public/agents
?protocol=OASF
&search=machine_learning/model_optimization
&limit=20
```

The API returns agent summary objects. The coordinator then prefers the embedded registration document in `raw_metadata.offchain_content` and falls back to the off-chain registration URI when needed.

From the registration document it extracts:
- service endpoints (where to POST experiments)
- x402 support flag (payment-gated access)
- OASF capability metadata from the `services[]` entry with `name: OASF`
- leaderboard metadata such as `best_val_bpb`

## How Payment Works

Experiment submission uses the same x402 payment flow as `buy-inference`:

1. **Probe** -- Send unauthenticated POST to worker endpoint, receive `402 Payment Required` with pricing
2. **Sign** -- Pre-sign an ERC-3009 `TransferWithAuthorization` voucher via the current remote-signer API (`GET /api/v1/keys`, `POST /api/v1/sign/<address>/typed-data`)
3. **Submit** -- Re-send the POST with the `X-PAYMENT` header containing the signed voucher
4. **Settle** -- Worker's x402 verifier validates payment via the facilitator, forwards request to GPU

Payment is per-experiment (not per-token). The 402 response includes `maxAmountRequired` which is the cost for one experiment run.

## How Results are Published

After collecting a result from a worker, the coordinator stores provenance metadata locally:

```json
{
"experiment_id": "exp-20260312-a1b2c3",
"train_hash": "sha256:abcdef...",
"val_bpb": 1.234,
"worker_endpoint": "https://worker.example.com/services/autoresearch",
"worker_agent_id": 42,
"timestamp": "2026-03-12T10:30:00Z",
"payment_tx": "0xdeadbeef..."
}
```

Results are appended to `$DATA_DIR/autoresearch/results.jsonl` (one JSON object per line).

## Environment Variables

| Variable | Default | Description |
|----------|---------|-------------|
| `SCAN_API_URL` | `https://www.8004scan.io/api/v1/public` | 8004scan public API base URL; the coordinator queries `/agents` under this base |
| `REMOTE_SIGNER_URL` | `http://remote-signer:9000` | Remote-signer REST API for payment signing |
| `ERPC_URL` | `http://erpc.erpc.svc.cluster.local:4000/rpc` | eRPC gateway base URL |
| `ERPC_NETWORK` | `base-sepolia` | Default chain for payment |
| `DATA_DIR` | `/data` | Base directory for result storage |

## Architecture

```
coordinate.py
|
+-- discover: 8004scan API (OASF filter)
| |
| v
| Worker .well-known/agent-registration.json
|
+-- probe: POST /experiment (no payment)
| |
| v
| 402 Payment Required (pricing JSON)
|
+-- submit: ERC-3009 sign via remote-signer
| |
| +-- POST /experiment + X-PAYMENT header
| | |
| | v
| | x402 verifier -> facilitator -> GPU worker
| | |
| | v
| | 200 + experiment result
| |
| v
+-- publish: results.jsonl (local provenance)
```

## Constraints

- **Requires remote-signer** -- must have agent wallet provisioned via `obol openclaw onboard`
- **Requires network access** -- 8004scan API and worker endpoints must be reachable
- **Python stdlib only** -- uses `urllib`; no third-party Python dependencies required
- **Per-experiment payment** -- each submission costs one x402 payment; monitor balance via `buy-inference balance`
- **Worker availability** -- workers may go offline between discovery and submission; coordinator retries automatically
- **Result storage is local** -- provenance metadata stored on-disk, not on-chain (future: IPFS/on-chain attestations)

## References

- `references/coordination-protocol.md` -- Ensue-to-obol mapping, discovery flow, payment flow, leaderboard format
- See also: `discovery` skill for raw ERC-8004 registry queries
- See also: `buy-inference` skill for the x402 buyer sidecar architecture
- See also: `sell` skill for running a GPU worker (sell-side)
Loading
Loading