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
6 changes: 3 additions & 3 deletions internal/agentcrd/agent_contract_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@ import (
// (agentcrd.HostNoBundledSkillsMarkerPath), so Hermes' installer/sync
// skips seeding its ~80 bundled skills;
// (2) the rendered hermes-config ConfigMap in the agent's namespace carries
// the capped knobs: lifetime_seconds: 90, max_turns: 30,
// reasoning_effort: low, and disabled_toolsets {memory, web};
// the capped knobs: lifetime_seconds: 180, max_turns: 30,
// reasoning_effort: low, and disabled_toolsets {memory, web, code_execution};
// (3) a BEHAVIORAL signal that bundled skills were actually skipped — see
// assertBundledSkillsSkippedInPod for why we assert pod filesystem state
// rather than grep a log line.
Expand Down Expand Up @@ -233,7 +233,7 @@ func getHermesConfigYAML(t *testing.T, cfg *config.Config, ns string) string {
func assertHermesConfigCaps(t *testing.T, cfgYAML string) {
t.Helper()
for _, want := range []string{
"lifetime_seconds: 90",
"lifetime_seconds: 180",
"max_turns: 30",
"reasoning_effort: low",
"disabled_toolsets:",
Expand Down
35 changes: 35 additions & 0 deletions internal/embed/infrastructure/base/templates/agent-crd.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,41 @@ spec:
type: object
spec:
properties:
mcpServers:
description: |-
MCPServers registers native MCP servers in the agent's Hermes
config (mcp_servers:). Hermes discovers each server's tools and
exposes them as first-class tools. stdio (command+args) for a
local server, or url for a remote one.
items:
properties:
args:
items:
type: string
maxItems: 64
type: array
command:
maxLength: 512
type: string
env:
additionalProperties:
type: string
type: object
name:
maxLength: 64
pattern: ^[a-z0-9][a-z0-9-]*$
type: string
transport:
maxLength: 16
type: string
url:
maxLength: 512
type: string
required:
- name
type: object
maxItems: 32
type: array
model:
description: |-
LiteLLM model name to pin. Empty = controller picks cluster
Expand Down
6 changes: 3 additions & 3 deletions internal/embed/skills/buy-x402/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ metadata: { "openclaw": { "emoji": "\ud83d\uded2", "requires": { "bins": ["pytho
Purchase access to remote x402-gated services. There are two flows, picked by usage shape:

- **`pay <url>`** — single-shot. Probe the URL, sign **one** payment authorization, attach `X-PAYMENT`, send the request, return the response. Stateless. Use for `type:http` services and any one-off purchase. Max loss = price of one request. Settlement normally lands only after the request succeeds — but a facilitator can submit the settle tx on-chain and *then* fail the request. When that happens the failure report prints `⚠️ SETTLEMENT MAY HAVE COMPLETED ON-CHAIN` with the tx hash: verify with `balance --chain <X>` before retrying (mechanism: docs/observability.md, "Verify settlement against the chain"). Applies to `pay-agent` too.
- **`pay-agent <url> --model <id>`** — single-shot paid **streaming** agent call. Same payment shape as `pay` (one auth, X-PAYMENT, max-loss = price), but POSTs to `<url>/v1/chat/completions` with `stream: true` and forwards every SSE event verbatim to stdout as it arrives. Use this for `type:agent` ServiceOffers when the calling agent wants to consume the response *itself* (memory, tool-call traces, partial results) instead of routing it through LiteLLM as a paid alias. Default HTTP read timeout is **1 hour** — agent calls can legitimately run for many minutes; override with `--timeout <seconds>`.
- **`pay-agent <url>`** — single-shot paid **streaming** agent call. Same payment shape as `pay` (one auth, X-PAYMENT, max-loss = price), but POSTs to `<url>/v1/chat/completions` with `stream: true` and forwards every SSE event verbatim to stdout as it arrives. No `--model`: a `type:agent` offer runs its own model (the request `model` field is ignored), so you only send a prompt. Use this for `type:agent` ServiceOffers when the calling agent wants to consume the response *itself* (memory, tool-call traces, partial results) instead of routing it through LiteLLM as a paid alias. Default HTTP read timeout is **1 hour** — agent calls can legitimately run for many minutes; override with `--timeout <seconds>`.
- **`buy <name>`** — pre-authorize a budget. Sign **N** authorizations up front (the buyer pays nothing yet), declare them in a `PurchaseRequest` CR, let the `x402-buyer` sidecar redeem them transparently as the agent calls the model through LiteLLM at `paid/<remote-model>`. Use for long-running paid inference. Max loss = N × price (only as vouchers are spent); runtime path holds zero signer access.
- **`buy <name> --model <id> --set-default`** — same as `buy` above, then adopt `paid/<remote-model>` as the agent's **own primary model**, in-pod, by itself: an atomic `hermes config set model.default` that Hermes re-reads per request (effective next chat turn, **no restart**, no host-side `obol model prefer`/`obol model sync`). Refuses if the model isn't selectable in LiteLLM. Pair with `--auto-refill` so the primary model doesn't brick when the pre-authorized budget runs out.

Expand Down Expand Up @@ -134,7 +134,7 @@ python3 ${OBOL_SKILLS_DIR:-/data/.openclaw/skills}/buy-x402/scripts/buy.py pay h
# One-shot paid STREAMING agent call (SSE events flushed to stdout as they arrive)
python3 ${OBOL_SKILLS_DIR:-/data/.openclaw/skills}/buy-x402/scripts/buy.py pay-agent \
https://seller.example.com/services/demo-quant \
--model qwen3.5:9b --message 'summarize the latest research on staking'
--message 'summarize the latest research on staking'

# Pay-agent with a full OpenAI-compatible body (stream:true is forced on)
python3 ${OBOL_SKILLS_DIR:-/data/.openclaw/skills}/buy-x402/scripts/buy.py pay-agent \
Expand Down Expand Up @@ -187,7 +187,7 @@ python3 ${OBOL_SKILLS_DIR:-/data/.openclaw/skills}/buy-x402/scripts/buy.py maint
|---------|-------------|
| `probe <url> [--model <id>] [--type http\|inference\|agent] [--method GET\|POST]` | Send request without payment, parse 402 response for pricing |
| `pay <url> [--type http\|inference] [--method GET\|POST] [--data <body>]` | Single-shot paid request: sign 1 auth, attach X-PAYMENT, send |
| `pay-agent <url> --model <id> [--message <text> \| --data <json>] [--timeout <s>]` | Single-shot paid streaming agent call: SSE events flush to stdout as they arrive (default timeout 1h) |
| `pay-agent <url> [--message <text> \| --data <json>] [--timeout <s>]` | Single-shot paid streaming agent call (no `--model` — the agent runs its own): SSE events flush to stdout as they arrive (default timeout 1h) |
| `buy <name> --endpoint <url> --model <id> [--budget N] [--count N]` | Pre-sign auths, create/update `PurchaseRequest`, expose `paid/<model>` |
| `buy <name> --endpoint <url> --model <id> --set-default [--auto-refill]` | As above, then set `paid/<model>` as the agent's own primary model in-pod (no restart, no host CLI) |
| `process <name> \| --all` | Reconcile `autoRefill` policies against live `x402-buyer` status |
Expand Down
27 changes: 12 additions & 15 deletions internal/embed/skills/buy-x402/scripts/buy.py
Original file line number Diff line number Diff line change
Expand Up @@ -2372,7 +2372,7 @@ def cmd_pay(url, method="GET", data=None, kind="http", network=None, timeout=Non
sys.exit(1)


def cmd_pay_agent(url, messages=None, model_id=None, network=None, timeout=None, body=None, token=None, payment_option=None):
def cmd_pay_agent(url, messages=None, network=None, timeout=None, body=None, token=None, payment_option=None):
"""Single-shot paid streaming agent call: probe -> sign one auth -> SSE-stream.

Sibling of `cmd_pay` for `type=agent` ServiceOffers. Differences from
Expand All @@ -2392,9 +2392,10 @@ def cmd_pay_agent(url, messages=None, model_id=None, network=None, timeout=None,
alias.

`body` is an optional JSON-encoded request body. When omitted, `messages`
+ `model_id` are required and a `{model, messages, stream:true}` body is
synthesized. When provided, the body is parsed and `"stream": true` is
forced onto whatever the caller passed.
is required and a `{messages, stream:true}` body is synthesized — NO `model`
field: a type=agent offer runs its own model and ignores any `model` sent.
When provided, the body is parsed and `"stream": true` is forced onto
whatever the caller passed.
"""
if timeout is None or float(timeout) <= 0:
timeout = 3600.0
Expand All @@ -2414,27 +2415,24 @@ def cmd_pay_agent(url, messages=None, model_id=None, network=None, timeout=None,
# Force streaming on. cmd_pay handles non-streaming; cmd_pay_agent
# exists precisely to stream.
parsed_body["stream"] = True
if model_id and not parsed_body.get("model"):
parsed_body["model"] = model_id
else:
if not messages:
print(
"Error: --message (or --data <json>) is required for `pay-agent`.\n"
"Example: pay-agent <url> --model qwen3.5:9b --message 'summarize the docs'",
"Example: pay-agent <url> --message 'summarize the docs'",
file=sys.stderr,
)
sys.exit(1)
if not model_id:
print("Error: --model is required when using --message.", file=sys.stderr)
sys.exit(1)
# type=agent ServiceOffers run their own model — there is nothing to
# select and the agent ignores any `model` field — so pay-agent sends
# only the prompt.
parsed_body = {
"model": model_id,
"messages": [{"role": "user", "content": messages}],
"stream": True,
}

print(f"Probing {url} ...")
pricing = _probe_endpoint(url, model_id=model_id or "test", kind="inference")
pricing = _probe_endpoint(url, model_id="probe", kind="inference")
if not pricing:
print("Failed to get x402 pricing.", file=sys.stderr)
sys.exit(1)
Expand Down Expand Up @@ -2708,7 +2706,7 @@ def usage():
print(" Single-shot paid request (sign 1 auth, attach X-PAYMENT)")
print(" Multi-currency offers: pick which asset/price to pay with")
print(" --token/--network/--payment-option (probe to see options)")
print(" pay-agent <url> --model <id> [--message '<text>' | --data '<json>'] [--timeout <seconds>]")
print(" pay-agent <url> [--message '<text>' | --data '<json>'] [--timeout <seconds>]")
print(" [--token <SYMBOL>] [--network <name>] [--payment-option <N>]")
print(" Single-shot paid streaming agent call (POST /v1/chat/completions,")
print(" stream: true). Each SSE event flushes to stdout so a calling")
Expand Down Expand Up @@ -2788,7 +2786,7 @@ def usage():
positional, opts = parse_flags(rest)
if not positional:
print(
"Usage: pay-agent <url> --model <id> [--message '<text>' | --data '<json>'] "
"Usage: pay-agent <url> [--message '<text>' | --data '<json>'] "
"[--network <name>] [--timeout <seconds>]",
file=sys.stderr,
)
Expand All @@ -2805,7 +2803,6 @@ def usage():
cmd_pay_agent(
positional[0],
messages=opts.get("message"),
model_id=opts.get("model"),
network=opts.get("network"),
timeout=timeout,
body=opts.get("data"),
Expand Down
37 changes: 35 additions & 2 deletions internal/monetizeapi/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -686,6 +686,13 @@ type AgentSpec struct {
// +kubebuilder:validation:MaxLength=4096
Objective string `json:"objective,omitempty"`
Wallet AgentWallet `json:"wallet,omitempty"`
// MCPServers registers native MCP servers in the agent's Hermes config
// (mcp_servers:). Hermes discovers each server's tools and exposes them as
// first-class tools — the harness serializes the args, so the model never
// hand-builds JSON-in-shell. Use a stdio server (command+args) for a local,
// payment-abstracting wrapper, or url for a remote one.
// +kubebuilder:validation:MaxItems=32
MCPServers []AgentMCPServer `json:"mcpServers,omitempty"`
}

type AgentWallet struct {
Expand All @@ -695,6 +702,33 @@ type AgentWallet struct {
Create bool `json:"create,omitempty"`
}

// AgentMCPServer is one entry under Hermes' mcp_servers: config. stdio
// (Command+Args) spawns a local MCP server; URL (+Transport "sse") connects to
// a remote one. Env values may use ${VAR}, which Hermes interpolates from the
// pod environment at load — keep raw secrets out of the CR (reference them as
// ${REMOTE_SIGNER_TOKEN} etc.; Hermes filters the stdio subprocess env, so
// anything the server needs must be listed here).
type AgentMCPServer struct {
// Key under mcp_servers (e.g. "hyperliquid").
// +kubebuilder:validation:Pattern=`^[a-z0-9][a-z0-9-]*$`
// +kubebuilder:validation:MaxLength=64
Name string `json:"name"`
// stdio transport: executable to spawn.
// +kubebuilder:validation:MaxLength=512
Command string `json:"command,omitempty"`
// stdio transport: arguments for Command.
// +kubebuilder:validation:MaxItems=64
Args []string `json:"args,omitempty"`
// http/sse transport: remote MCP server URL.
// +kubebuilder:validation:MaxLength=512
URL string `json:"url,omitempty"`
// Transport override ("sse"); default is Streamable HTTP for a url server.
// +kubebuilder:validation:MaxLength=16
Transport string `json:"transport,omitempty"`
// Environment for a stdio server. Values may use ${VAR} interpolation.
Env map[string]string `json:"env,omitempty"`
}

type AgentStatus struct {
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
// Pending | Provisioning | Ready | Failed
Expand Down Expand Up @@ -765,8 +799,7 @@ type AgentIdentityList struct {
Items []AgentIdentity `json:"items"`
}

type AgentIdentitySpec struct {
}
type AgentIdentitySpec struct{}

type AgentIdentityStatus struct {
// Per-chain ERC-8004 registrations for this identity document.
Expand Down
34 changes: 34 additions & 0 deletions internal/monetizeapi/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading