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
8 changes: 4 additions & 4 deletions cmd/obol/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ func agentCommand(cfg *config.Config) *cli.Command {
ArgsUsage: "[name]",
Description: `With a positional name and CRD-path flags (--model, --skills,
--objective, --create-wallet) this declares an Agent custom resource and
seeds soul.md + the per-agent skills dir on the host.
seeds SOUL.md + the per-agent skills dir on the host.

Without a positional name, falls back to the legacy host-rendered
Hermes/OpenClaw onboard flow used by the master agent.`,
Expand Down Expand Up @@ -91,7 +91,7 @@ Hermes/OpenClaw onboard flow used by the master agent.`,
},
&cli.StringFlag{
Name: "objective",
Usage: "Operator objective text substituted into soul.md (CRD path)",
Usage: "Operator objective text substituted into SOUL.md (CRD path)",
},
&cli.BoolFlag{
Name: "create-wallet",
Expand Down Expand Up @@ -346,7 +346,7 @@ Examples:
}
} else if objectiveChanged {
if _, err := agentcrd.WriteSoul(cfg, name, stringValueFromAny(spec["objective"]), true); err != nil {
return fmt.Errorf("sync host soul.md: %w", err)
return fmt.Errorf("sync host SOUL.md: %w", err)
}
}

Expand Down Expand Up @@ -913,7 +913,7 @@ func listCRDAgents(cfg *config.Config) ([]agentListItem, error) {
}

// deleteCRDAgent removes the Agent CR and its host-side data directory
// (skills + soul.md). Used by `obol agent delete <name>` when the
// (skills + SOUL.md). Used by `obol agent delete <name>` when the
// argument matches a CRD-declared agent. Idempotent: missing cluster,
// missing CR, and missing host dir are all treated as "already gone".
//
Expand Down
4 changes: 2 additions & 2 deletions cmd/obol/agent_crd.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,9 +88,9 @@ func createCRDAgent(cfg *config.Config, u *ui.UI, opts createCRDAgentOptions) er
return fmt.Errorf("seed host files: %w", err)
}
if soulWritten {
u.Successf("soul.md seeded at %s", agentcrd.HostSoulPath(cfg, opts.Name))
u.Successf("SOUL.md seeded at %s", agentcrd.HostSoulPath(cfg, opts.Name))
} else {
u.Dim(fmt.Sprintf("soul.md already exists at %s, leaving as-is", agentcrd.HostSoulPath(cfg, opts.Name)))
u.Dim(fmt.Sprintf("SOUL.md already exists at %s, leaving as-is", agentcrd.HostSoulPath(cfg, opts.Name)))
}
if len(skills) > 0 {
u.Successf("Skills written: %s", strings.Join(skills, ", "))
Expand Down
54 changes: 52 additions & 2 deletions cmd/obol/sell_agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/ObolNetwork/obol-stack/internal/hermes"
"github.com/ObolNetwork/obol-stack/internal/kubectl"
"github.com/ObolNetwork/obol-stack/internal/model"
"github.com/ObolNetwork/obol-stack/internal/monetizeapi"
"github.com/ObolNetwork/obol-stack/internal/schemas"
"github.com/ObolNetwork/obol-stack/internal/tunnel"
"github.com/ObolNetwork/obol-stack/internal/ui"
Expand Down Expand Up @@ -209,11 +210,16 @@ Examples:
for i, s := range agent.Skills {
skills[i] = s
}
symbol := assetTerms.Symbol
if symbol == "" {
symbol = strings.ToUpper(tokenName)
}
spec["registration"] = map[string]any{
"enabled": true,
"name": regName,
"description": regDesc,
"skills": skills,
"metadata": agentOfferRegistrationMetadata(agent, price, symbol, chain),
}
}

Expand Down Expand Up @@ -280,7 +286,7 @@ func runAgentBackedDemo(
return fmt.Errorf("derived agent name %q is invalid: %w", agentName, err)
}

// 1. Seed host-side files (skills + soul.md) and apply the Agent CR.
// 1. Seed host-side files (skills + SOUL.md) and apply the Agent CR.
// Idempotent — re-running `obol sell demo quant` after a previous
// run is a no-op for the agent if it already exists. A CR that is
// mid-deletion (DeletionTimestamp set, finalizer still draining)
Expand All @@ -302,7 +308,7 @@ func runAgentBackedDemo(
return fmt.Errorf("seed agent host files: %w", seedErr)
}
if soulWritten {
u.Successf("soul.md seeded at %s", agentcrd.HostSoulPath(cfg, agentName))
u.Successf("SOUL.md seeded at %s", agentcrd.HostSoulPath(cfg, agentName))
}
// Namespace must exist before the Agent CR can land; controller-
// side namespace creation is part of provisioning, which doesn't
Expand Down Expand Up @@ -347,6 +353,10 @@ func runAgentBackedDemo(
// 2. Build and apply the agent-typed ServiceOffer.
register := cmd.Bool("register")
offerNs := agentcrd.Namespace(agentName)
agentForMetadata, _ := getAgentRefForSale(cfg, agentName)
if agentForMetadata == nil {
agentForMetadata = &agentRefForSale{Name: agentName, Namespace: offerNs, Runtime: monetizeapi.AgentRuntimeHermes}
}

payment := map[string]any{
"scheme": "exact",
Expand Down Expand Up @@ -379,6 +389,7 @@ func runAgentBackedDemo(
"name": name,
"description": spec.Description,
"skills": skillsAny,
"metadata": agentOfferRegistrationMetadata(agentForMetadata, price, symbol, chain),
}
}

Expand Down Expand Up @@ -473,6 +484,8 @@ type agentRefForSale struct {
Name string
Namespace string
WalletAddress string
Runtime string
Model string
Objective string
Skills []string
}
Expand Down Expand Up @@ -530,21 +543,58 @@ func decodeAgentJSON(raw string) (*agentRefForSale, error) {
Namespace string `json:"namespace"`
} `json:"metadata"`
Spec struct {
Runtime string `json:"runtime"`
Model string `json:"model"`
Skills []string `json:"skills"`
Objective string `json:"objective"`
} `json:"spec"`
Status struct {
WalletAddress string `json:"walletAddress"`
PinnedModel string `json:"pinnedModel"`
} `json:"status"`
}
if err := json.Unmarshal([]byte(raw), &doc); err != nil {
return nil, err
}
model := strings.TrimSpace(doc.Spec.Model)
if model == "" {
model = strings.TrimSpace(doc.Status.PinnedModel)
}
runtime := strings.TrimSpace(doc.Spec.Runtime)
if runtime == "" {
runtime = monetizeapi.AgentRuntimeHermes
}
return &agentRefForSale{
Name: doc.Metadata.Name,
Namespace: doc.Metadata.Namespace,
WalletAddress: doc.Status.WalletAddress,
Runtime: runtime,
Model: model,
Objective: doc.Spec.Objective,
Skills: append([]string(nil), doc.Spec.Skills...),
}, nil
}

func agentOfferRegistrationMetadata(agent *agentRefForSale, price, symbol, chain string) map[string]string {
metadata := map[string]string{
"pricingUnit": "agent-turn",
}
if price = strings.TrimSpace(price); price != "" {
metadata["x402Price"] = price
}
if symbol = strings.TrimSpace(symbol); symbol != "" {
metadata["x402Asset"] = strings.ToUpper(symbol)
}
if chain = strings.TrimSpace(chain); chain != "" {
metadata["x402Network"] = chain
}
runtime := monetizeapi.AgentRuntimeHermes
if agent != nil && strings.TrimSpace(agent.Runtime) != "" {
runtime = strings.TrimSpace(agent.Runtime)
}
metadata["runtime"] = runtime
if agent != nil && strings.TrimSpace(agent.Model) != "" {
metadata["model"] = strings.TrimSpace(agent.Model)
}
return metadata
}
58 changes: 58 additions & 0 deletions cmd/obol/sell_agent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@ func TestDecodeAgentJSON_FullDocument(t *testing.T) {
if got.WalletAddress != "0xabcdef0123456789abcdef0123456789abcdef01" {
t.Errorf("walletAddress = %q", got.WalletAddress)
}
if got.Runtime != "hermes" {
t.Errorf("runtime = %q", got.Runtime)
}
if got.Model != "qwen3.5:9b" {
t.Errorf("model = %q", got.Model)
}
if len(got.Skills) != 2 || got.Skills[0] != "addresses" {
t.Errorf("skills = %v", got.Skills)
}
Expand All @@ -60,6 +66,24 @@ func TestDecodeAgentJSON_StatusFieldsAreOptional(t *testing.T) {
if got.Objective != "" {
t.Errorf("expected empty objective, got %q", got.Objective)
}
if got.Runtime != "hermes" {
t.Errorf("runtime default = %q, want hermes", got.Runtime)
}
}

func TestDecodeAgentJSON_ModelFallsBackToStatusPinnedModel(t *testing.T) {
raw := `{
"metadata": {"name": "quant", "namespace": "agent-quant"},
"spec": {"skills": ["addresses"]},
"status": {"pinnedModel": "paid/aeon"}
}`
got, err := decodeAgentJSON(raw)
if err != nil {
t.Fatalf("decodeAgentJSON: %v", err)
}
if got.Model != "paid/aeon" {
t.Errorf("model = %q, want paid/aeon", got.Model)
}
}

func TestDecodeAgentJSON_RejectsGarbage(t *testing.T) {
Expand Down Expand Up @@ -121,6 +145,40 @@ func TestPickAgentDefault(t *testing.T) {
}
}

func TestAgentOfferRegistrationMetadata_AdvertisesRuntimeModelAndPrice(t *testing.T) {
got := agentOfferRegistrationMetadata(&agentRefForSale{
Runtime: "hermes",
Model: "qwen3.5:9b",
}, "10", "OBOL", "ethereum")

want := map[string]string{
"runtime": "hermes",
"model": "qwen3.5:9b",
"pricingUnit": "agent-turn",
"x402Price": "10",
"x402Asset": "OBOL",
"x402Network": "ethereum",
}
for k, v := range want {
if got[k] != v {
t.Errorf("metadata[%s] = %q, want %q (full=%v)", k, got[k], v, got)
}
}
}

func TestAgentOfferRegistrationMetadata_DefaultsRuntimeHermes(t *testing.T) {
got := agentOfferRegistrationMetadata(nil, "0.001", "usdc", "base-sepolia")
if got["runtime"] != "hermes" {
t.Errorf("runtime = %q, want hermes", got["runtime"])
}
if got["x402Asset"] != "USDC" {
t.Errorf("x402Asset = %q, want USDC", got["x402Asset"])
}
if _, ok := got["model"]; ok {
t.Errorf("model should be omitted when unknown: %v", got)
}
}

func TestSellAgentCommand_FlagShape(t *testing.T) {
cfg := newTestConfig(t)
cmd := sellCommand(cfg)
Expand Down
10 changes: 5 additions & 5 deletions flows/flow-16-sell-agent.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
# Flow 16: Sell Agent — agent-backed ServiceOffer metadata smoke.
#
# Steps:
# 1. Declare an Agent CRD via `obol agent new <name>` (host seeds soul.md
# 1. Declare an Agent CRD via `obol agent new <name>` (host seeds SOUL.md
# + skills, applies the CR; controller provisions Hermes + optional
# remote-signer in the agent's namespace)
# 2. Gate it with `obol sell agent <name>` (creates a ServiceOffer of
Expand Down Expand Up @@ -35,12 +35,12 @@ else
fi

# §1.1: Host-side seed landed
step "Host data dir contains soul.md and skills"
step "Host data dir contains SOUL.md and skills"
host_root="${OBOL_DATA_DIR}/${AGENT_NS}/hermes-data/.hermes"
if [ -f "$host_root/soul.md" ]; then
pass "soul.md seeded at $host_root/soul.md"
if [ -f "$host_root/SOUL.md" ]; then
pass "SOUL.md seeded at $host_root/SOUL.md"
else
fail "soul.md missing at $host_root/soul.md"
fail "SOUL.md missing at $host_root/SOUL.md"
fi
expected=$(echo "$AGENT_SKILLS" | tr ',' '\n' | sort | tr '\n' ',' | sed 's/,$//')
got=$(ls "$host_root/obol-skills" 2>/dev/null | sort | tr '\n' ',' | sed 's/,$//' || true)
Expand Down
Loading
Loading