diff --git a/.agents/skills/obol-stack-dev/SKILL.md b/.agents/skills/obol-stack-dev/SKILL.md index 4a161a43..c90fbf3f 100644 --- a/.agents/skills/obol-stack-dev/SKILL.md +++ b/.agents/skills/obol-stack-dev/SKILL.md @@ -121,7 +121,7 @@ Known existing-stack migration failures: - `Namespace "hermes-obol-agent" ... exists and cannot be imported into the current release`: the namespace or monetize RBAC predated Helm ownership. Current `obol stack up` adopts known base-owned resources before Helm sync. If doing it manually, label and annotate the existing resource with `app.kubernetes.io/managed-by=Helm`, `meta.helm.sh/release-name=base`, and `meta.helm.sh/release-namespace=kube-system`. - `conflict with "kubectl-patch" ... llm/litellm-config .data.config.yaml`: older writers used a non-Helm field manager for `data.config.yaml`, which conflicts with Helm server-side apply. Current writers use Helm's field manager. During `obol stack up`, the existing LiteLLM config is backed up and previous model entries are merged into the new chart config; if a non-Helm manager is detected, the ConfigMap is deleted before Helm sync so ownership is recreated cleanly. -- `/etc/hosts` updates require interactive sudo. Codex cannot satisfy the password prompt in non-interactive execution; if DNS fails in the browser, run `obol stack up` or `obol hermes sync obol-agent` from a normal terminal, or manually add `127.0.0.1 obol-agent.obol.stack` and flush local DNS. +- `/etc/hosts` updates require interactive sudo. Codex cannot satisfy the password prompt in non-interactive execution; if DNS fails in the browser, run `obol stack up` or `obol agent sync obol-agent` from a normal terminal, or manually add `127.0.0.1 obol-agent.obol.stack` and flush local DNS. ## 4 Inference Paths (All Through LiteLLM) diff --git a/README.md b/README.md index 9695f746..11c7b1c6 100644 --- a/README.md +++ b/README.md @@ -53,8 +53,8 @@ obol stack up obol agent init # Inspect the default Hermes agent -obol hermes list -obol hermes token obol-agent +obol agent list +obol agent auth obol-agent ``` `obol stack up` provisions the default [Hermes Agent](https://github.com/NousResearch/hermes-agent) runtime behind LiteLLM. `obol agent init` applies the controller-based agent capabilities used for monetization and reconciliation. @@ -151,24 +151,24 @@ Hermes is the default AI agent runtime deployed by the stack as `obol-agent`. Op ```bash # Default stack-managed Hermes agent -obol hermes list -obol hermes token obol-agent +obol agent list +obol agent auth obol-agent obol hermes skills list # Create and deploy an additional Hermes instance -obol hermes onboard --id research +obol agent new --id research # Create and deploy an optional OpenClaw instance -obol openclaw onboard +obol agent new --runtime openclaw # List optional OpenClaw instances -obol openclaw list +obol agent list --runtime openclaw # Open the OpenClaw web dashboard obol openclaw dashboard ``` -When only one runtime-specific instance is installed, the instance ID is optional. With multiple instances, specify the name: `obol hermes sync research` or `obol openclaw setup prod`. +Use `obol agent` for Obol-managed lifecycle and auth flows. Use `obol hermes` for native Hermes CLI commands against the default instance, or pass `--agent ` for a non-default Hermes instance. ### Skills diff --git a/cmd/obol/agent.go b/cmd/obol/agent.go new file mode 100644 index 00000000..eb687258 --- /dev/null +++ b/cmd/obol/agent.go @@ -0,0 +1,597 @@ +package main + +import ( + "context" + "errors" + "fmt" + "strings" + + agentmgr "github.com/ObolNetwork/obol-stack/internal/agent" + "github.com/ObolNetwork/obol-stack/internal/agentruntime" + "github.com/ObolNetwork/obol-stack/internal/config" + "github.com/ObolNetwork/obol-stack/internal/hermes" + "github.com/ObolNetwork/obol-stack/internal/kubectl" + "github.com/ObolNetwork/obol-stack/internal/openclaw" + "github.com/ObolNetwork/obol-stack/internal/ui" + "github.com/urfave/cli/v3" +) + +type agentTarget struct { + Runtime agentruntime.Runtime + ID string +} + +type agentListItem struct { + Runtime string `json:"runtime"` + ID string `json:"id"` + Namespace string `json:"namespace"` + URL string `json:"url"` +} + +func agentCommand(cfg *config.Config) *cli.Command { + return &cli.Command{ + Name: "agent", + Usage: "Manage Obol agent instances", + Commands: []*cli.Command{ + { + Name: "init", + Usage: "Initialize the stack-managed Obol Agent", + Action: func(ctx context.Context, cmd *cli.Command) error { + return agentmgr.Init(cfg, getUI(cmd)) + }, + }, + { + Name: "new", + Aliases: []string{"onboard"}, + Usage: "Create and deploy an agent instance", + Flags: []cli.Flag{ + agentRuntimeFlag("hermes"), + &cli.StringFlag{ + Name: "id", + Usage: "Instance ID (defaults to generated petname)", + }, + &cli.BoolFlag{ + Name: "force", + Aliases: []string{"f"}, + Usage: "Overwrite existing instance", + }, + &cli.BoolFlag{ + Name: "no-sync", + Usage: "Only scaffold config, don't deploy to cluster", + }, + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + runtime, err := parseAgentRuntime(cmd.String("runtime")) + if err != nil { + return err + } + + u := getUI(cmd) + switch runtime { + case agentruntime.Hermes: + return hermes.Onboard(cfg, hermes.OnboardOptions{ + ID: cmd.String("id"), + Force: cmd.Bool("force"), + Sync: !cmd.Bool("no-sync"), + }, u) + case agentruntime.OpenClaw: + return openclaw.Onboard(cfg, openclaw.OnboardOptions{ + ID: cmd.String("id"), + Force: cmd.Bool("force"), + Sync: !cmd.Bool("no-sync"), + Interactive: u.IsTTY() && !u.IsJSON(), + }, u) + default: + return fmt.Errorf("unsupported runtime: %s", runtime) + } + }, + }, + { + Name: "sync", + Usage: "Deploy or update an agent instance", + ArgsUsage: "[instance-name]", + Flags: []cli.Flag{agentRuntimeFlag("")}, + Action: func(ctx context.Context, cmd *cli.Command) error { + target, err := resolveAgentTarget(cfg, cmd.String("runtime"), cmd.Args().Slice()) + if err != nil { + return err + } + return syncAgentTarget(cfg, target, getUI(cmd)) + }, + }, + { + Name: "setup", + Usage: "Re-render runtime config from the current LiteLLM inventory", + ArgsUsage: "[instance-name]", + Flags: []cli.Flag{agentRuntimeFlag("")}, + Action: func(ctx context.Context, cmd *cli.Command) error { + target, err := resolveAgentTarget(cfg, cmd.String("runtime"), cmd.Args().Slice()) + if err != nil { + return err + } + return setupAgentTarget(cfg, target, getUI(cmd)) + }, + }, + { + Name: "auth", + Aliases: []string{"token"}, + Usage: "Retrieve or regenerate an agent API token", + ArgsUsage: "[instance-name]", + Flags: []cli.Flag{ + agentRuntimeFlag(""), + &cli.BoolFlag{ + Name: "regenerate", + Usage: "Delete and regenerate the API token (restarts the instance)", + }, + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + target, err := resolveAgentTarget(cfg, cmd.String("runtime"), cmd.Args().Slice()) + if err != nil { + return err + } + + u := getUI(cmd) + if cmd.Bool("regenerate") { + token, err := regenerateAgentToken(cfg, target, u) + if err != nil { + return err + } + u.Print(token) + return nil + } + + return printAgentToken(cfg, target, u) + }, + }, + { + Name: "list", + Usage: "List agent instances", + Flags: []cli.Flag{agentRuntimeFlag("all")}, + Action: func(ctx context.Context, cmd *cli.Command) error { + return listAgentInstances(cfg, cmd.String("runtime"), getUI(cmd)) + }, + }, + { + Name: "delete", + Usage: "Remove an agent instance and its cluster resources", + ArgsUsage: "[instance-name]", + Flags: []cli.Flag{ + agentRuntimeFlag(""), + &cli.BoolFlag{ + Name: "force", + Aliases: []string{"f"}, + Usage: "Skip confirmation prompt", + }, + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + target, err := resolveAgentTarget(cfg, cmd.String("runtime"), cmd.Args().Slice()) + if err != nil { + return err + } + return deleteAgentTarget(cfg, target, cmd.Bool("force"), getUI(cmd)) + }, + }, + agentWalletCommand(cfg), + }, + } +} + +func agentWalletCommand(cfg *config.Config) *cli.Command { + return &cli.Command{ + Name: "wallet", + Usage: "Manage agent wallets", + Commands: []*cli.Command{ + { + Name: "address", + Usage: "Show the wallet address for an agent instance", + ArgsUsage: "[instance-name]", + Flags: []cli.Flag{agentRuntimeFlag("")}, + Action: func(ctx context.Context, cmd *cli.Command) error { + target, err := resolveAgentTarget(cfg, cmd.String("runtime"), cmd.Args().Slice()) + if err != nil { + return err + } + return printAgentWalletAddress(cfg, target, getUI(cmd)) + }, + }, + { + Name: "list", + Usage: "List wallets for agent instances", + ArgsUsage: "[instance-name]", + Flags: []cli.Flag{agentRuntimeFlag("all")}, + Action: func(ctx context.Context, cmd *cli.Command) error { + return listAgentWallets(cfg, cmd.String("runtime"), cmd.Args().Slice(), getUI(cmd)) + }, + }, + { + Name: "backup", + Usage: "Back up wallet keys for an OpenClaw agent instance", + ArgsUsage: "[instance-name]", + Flags: []cli.Flag{ + agentRuntimeFlag(""), + &cli.StringFlag{ + Name: "output", + Usage: "Output file path", + }, + &cli.StringFlag{ + Name: "passphrase", + Usage: "Encryption passphrase (empty string = no encryption)", + }, + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + if err := kubectl.EnsureCluster(cfg); err != nil { + return err + } + + target, err := resolveAgentTarget(cfg, cmd.String("runtime"), cmd.Args().Slice()) + if err != nil { + return err + } + if target.Runtime != agentruntime.OpenClaw { + return errors.New("Hermes wallet backup needs a Hermes-native product decision; use OpenClaw backup only for OpenClaw instances") + } + return openclaw.BackupWalletCmd(cfg, target.ID, openclaw.BackupWalletOptions{ + Output: cmd.String("output"), + Passphrase: cmd.String("passphrase"), + HasPassFlag: cmd.IsSet("passphrase"), + }, getUI(cmd)) + }, + }, + { + Name: "restore", + Usage: "Restore wallet keys for an OpenClaw agent instance", + ArgsUsage: "[instance-name]", + Flags: []cli.Flag{ + agentRuntimeFlag(""), + &cli.StringFlag{ + Name: "input", + Usage: "Backup file path", + Required: true, + }, + &cli.StringFlag{ + Name: "passphrase", + Usage: "Decryption passphrase", + }, + &cli.BoolFlag{ + Name: "force", + Usage: "Overwrite existing wallet", + }, + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + if err := kubectl.EnsureCluster(cfg); err != nil { + return err + } + + target, err := resolveAgentTarget(cfg, cmd.String("runtime"), cmd.Args().Slice()) + if err != nil { + return err + } + if target.Runtime != agentruntime.OpenClaw { + return errors.New("Hermes wallet restore needs a Hermes-native product decision; use OpenClaw restore only for OpenClaw instances") + } + return openclaw.RestoreWalletCmd(cfg, target.ID, openclaw.RestoreWalletOptions{ + Input: cmd.String("input"), + Passphrase: cmd.String("passphrase"), + HasPassFlag: cmd.IsSet("passphrase"), + Force: cmd.Bool("force"), + }, getUI(cmd)) + }, + }, + }, + } +} + +func agentRuntimeFlag(value string) cli.Flag { + return &cli.StringFlag{ + Name: "runtime", + Usage: "Agent runtime: hermes, openclaw, or all", + Value: value, + } +} + +func parseAgentRuntime(value string) (agentruntime.Runtime, error) { + switch strings.ToLower(strings.TrimSpace(value)) { + case "hermes", "herme": + return agentruntime.Hermes, nil + case "openclaw": + return agentruntime.OpenClaw, nil + default: + return "", fmt.Errorf("unsupported agent runtime %q (expected hermes or openclaw)", value) + } +} + +func resolveAgentTarget(cfg *config.Config, runtimeValue string, args []string) (agentTarget, error) { + runtimeValue = strings.TrimSpace(runtimeValue) + if runtimeValue != "" && runtimeValue != "all" { + runtime, err := parseAgentRuntime(runtimeValue) + if err != nil { + return agentTarget{}, err + } + id, err := resolveRuntimeInstance(cfg, runtime, args, true) + if err != nil { + return agentTarget{}, err + } + return agentTarget{Runtime: runtime, ID: id}, nil + } + + return resolveAnyAgentTarget(cfg, args) +} + +func resolveAnyAgentTarget(cfg *config.Config, args []string) (agentTarget, error) { + if len(args) > 0 { + var matches []agentTarget + for _, runtime := range []agentruntime.Runtime{agentruntime.Hermes, agentruntime.OpenClaw} { + ids, err := agentruntime.ListInstanceIDs(cfg, runtime) + if err != nil { + return agentTarget{}, err + } + if containsString(ids, args[0]) { + matches = append(matches, agentTarget{Runtime: runtime, ID: args[0]}) + } + } + + switch len(matches) { + case 1: + return matches[0], nil + case 0: + return agentTarget{}, fmt.Errorf("agent instance %q not found", args[0]) + default: + return agentTarget{}, fmt.Errorf("agent instance %q exists in multiple runtimes; specify --runtime hermes or --runtime openclaw", args[0]) + } + } + + hermesIDs, err := agentruntime.ListInstanceIDs(cfg, agentruntime.Hermes) + if err != nil { + return agentTarget{}, err + } + if containsString(hermesIDs, agentruntime.DefaultInstanceID) { + return agentTarget{Runtime: agentruntime.Hermes, ID: agentruntime.DefaultInstanceID}, nil + } + + var all []agentTarget + for _, id := range hermesIDs { + all = append(all, agentTarget{Runtime: agentruntime.Hermes, ID: id}) + } + openclawIDs, err := agentruntime.ListInstanceIDs(cfg, agentruntime.OpenClaw) + if err != nil { + return agentTarget{}, err + } + for _, id := range openclawIDs { + all = append(all, agentTarget{Runtime: agentruntime.OpenClaw, ID: id}) + } + + switch len(all) { + case 0: + return agentTarget{}, errors.New("no agent instances found — run 'obol agent init' or 'obol agent new' to create one") + case 1: + return all[0], nil + default: + return agentTarget{}, fmt.Errorf("multiple agent instances found, specify one: %s", formatAgentTargets(all)) + } +} + +func resolveRuntimeInstance(cfg *config.Config, runtime agentruntime.Runtime, args []string, preferDefault bool) (string, error) { + ids, err := agentruntime.ListInstanceIDs(cfg, runtime) + if err != nil { + return "", err + } + if len(ids) == 0 { + return "", fmt.Errorf("no %s instances found — run 'obol agent new --runtime %s' to create one", agentruntime.Describe(runtime).DisplayName, runtime) + } + + if len(args) > 0 { + if containsString(ids, args[0]) { + return args[0], nil + } + return "", fmt.Errorf("%s instance %q not found; available: %s", agentruntime.Describe(runtime).DisplayName, args[0], strings.Join(ids, ", ")) + } + + if preferDefault && runtime == agentruntime.Hermes && containsString(ids, agentruntime.DefaultInstanceID) { + return agentruntime.DefaultInstanceID, nil + } + if len(ids) == 1 { + return ids[0], nil + } + return "", fmt.Errorf("multiple %s instances found, specify one: %s", agentruntime.Describe(runtime).DisplayName, strings.Join(ids, ", ")) +} + +func syncAgentTarget(cfg *config.Config, target agentTarget, u *ui.UI) error { + switch target.Runtime { + case agentruntime.Hermes: + return hermes.Sync(cfg, target.ID, u) + case agentruntime.OpenClaw: + return openclaw.Sync(cfg, target.ID, u) + default: + return fmt.Errorf("unsupported runtime: %s", target.Runtime) + } +} + +func setupAgentTarget(cfg *config.Config, target agentTarget, u *ui.UI) error { + switch target.Runtime { + case agentruntime.Hermes: + return hermes.Setup(cfg, target.ID, hermes.SetupOptions{}, u) + case agentruntime.OpenClaw: + return openclaw.Setup(cfg, target.ID, openclaw.SetupOptions{}, u) + default: + return fmt.Errorf("unsupported runtime: %s", target.Runtime) + } +} + +func printAgentToken(cfg *config.Config, target agentTarget, u *ui.UI) error { + switch target.Runtime { + case agentruntime.Hermes: + return hermes.Token(cfg, target.ID, u) + case agentruntime.OpenClaw: + return openclaw.Token(cfg, target.ID, u) + default: + return fmt.Errorf("unsupported runtime: %s", target.Runtime) + } +} + +func regenerateAgentToken(cfg *config.Config, target agentTarget, u *ui.UI) (string, error) { + switch target.Runtime { + case agentruntime.Hermes: + return hermes.RegenerateToken(cfg, target.ID, u) + case agentruntime.OpenClaw: + return openclaw.RegenerateToken(cfg, target.ID, u) + default: + return "", fmt.Errorf("unsupported runtime: %s", target.Runtime) + } +} + +func deleteAgentTarget(cfg *config.Config, target agentTarget, force bool, u *ui.UI) error { + switch target.Runtime { + case agentruntime.Hermes: + return hermes.Delete(cfg, target.ID, force, u) + case agentruntime.OpenClaw: + return openclaw.Delete(cfg, target.ID, force, u) + default: + return fmt.Errorf("unsupported runtime: %s", target.Runtime) + } +} + +func printAgentWalletAddress(cfg *config.Config, target agentTarget, u *ui.UI) error { + var address string + var err error + + switch target.Runtime { + case agentruntime.Hermes: + wallet, walletErr := hermes.ReadWalletMetadata(hermes.DeploymentPath(cfg, target.ID)) + if walletErr != nil { + err = walletErr + } else { + address = wallet.Address + } + case agentruntime.OpenClaw: + wallet, walletErr := openclaw.ReadWalletMetadata(openclaw.DeploymentPath(cfg, target.ID)) + if walletErr != nil { + err = walletErr + } else { + address = wallet.Address + } + default: + err = fmt.Errorf("unsupported runtime: %s", target.Runtime) + } + if err != nil { + return err + } + + u.Print(address) + return nil +} + +func listAgentInstances(cfg *config.Config, runtimeValue string, u *ui.UI) error { + runtimes, err := listRuntimes(runtimeValue) + if err != nil { + return err + } + + var instances []agentListItem + for _, runtime := range runtimes { + ids, err := agentruntime.ListInstanceIDs(cfg, runtime) + if err != nil { + return err + } + for _, id := range ids { + instances = append(instances, agentListItem{ + Runtime: string(runtime), + ID: id, + Namespace: agentruntime.Namespace(runtime, id), + URL: "http://" + agentruntime.Hostname(runtime, id), + }) + } + } + + if u.IsJSON() { + return u.JSON(instances) + } + if len(instances) == 0 { + u.Print("No agent instances installed") + u.Print("\nTo create one: obol agent new") + return nil + } + + u.Info("Agent instances:") + u.Blank() + for _, inst := range instances { + u.Bold(fmt.Sprintf(" %s/%s", inst.Runtime, inst.ID)) + u.Detail(" Namespace", inst.Namespace) + u.Detail(" URL", inst.URL) + u.Blank() + } + u.Printf("Total: %d instance(s)", len(instances)) + return nil +} + +func listAgentWallets(cfg *config.Config, runtimeValue string, args []string, u *ui.UI) error { + runtimeValue = strings.TrimSpace(runtimeValue) + if runtimeValue != "" && runtimeValue != "all" { + runtime, err := parseAgentRuntime(runtimeValue) + if err != nil { + return err + } + + id := "" + if len(args) > 0 { + id, err = resolveRuntimeInstance(cfg, runtime, args, false) + if err != nil { + return err + } + } + return listWalletsForRuntime(cfg, runtime, id, u) + } + + if len(args) > 0 { + target, err := resolveAnyAgentTarget(cfg, args) + if err != nil { + return err + } + return listWalletsForRuntime(cfg, target.Runtime, target.ID, u) + } + + if err := listWalletsForRuntime(cfg, agentruntime.Hermes, "", u); err != nil { + return err + } + return listWalletsForRuntime(cfg, agentruntime.OpenClaw, "", u) +} + +func listWalletsForRuntime(cfg *config.Config, runtime agentruntime.Runtime, id string, u *ui.UI) error { + switch runtime { + case agentruntime.Hermes: + return hermes.ListWallets(cfg, id, u) + case agentruntime.OpenClaw: + return openclaw.ListWallets(cfg, id, u) + default: + return fmt.Errorf("unsupported runtime: %s", runtime) + } +} + +func listRuntimes(runtimeValue string) ([]agentruntime.Runtime, error) { + switch strings.ToLower(strings.TrimSpace(runtimeValue)) { + case "", "all": + return []agentruntime.Runtime{agentruntime.Hermes, agentruntime.OpenClaw}, nil + default: + runtime, err := parseAgentRuntime(runtimeValue) + if err != nil { + return nil, err + } + return []agentruntime.Runtime{runtime}, nil + } +} + +func containsString(values []string, needle string) bool { + for _, value := range values { + if value == needle { + return true + } + } + return false +} + +func formatAgentTargets(targets []agentTarget) string { + var formatted []string + for _, target := range targets { + formatted = append(formatted, fmt.Sprintf("%s/%s", target.Runtime, target.ID)) + } + return strings.Join(formatted, ", ") +} diff --git a/cmd/obol/agent_test.go b/cmd/obol/agent_test.go new file mode 100644 index 00000000..92993a55 --- /dev/null +++ b/cmd/obol/agent_test.go @@ -0,0 +1,273 @@ +package main + +import ( + "os" + "strings" + "testing" + + "github.com/ObolNetwork/obol-stack/internal/agentruntime" + "github.com/ObolNetwork/obol-stack/internal/config" +) + +func TestAgentCommand_Structure(t *testing.T) { + cfg := newTestConfig(t) + cmd := agentCommand(cfg) + + expected := map[string]bool{ + "init": false, + "new": false, + "sync": false, + "setup": false, + "auth": false, + "list": false, + "delete": false, + "wallet": false, + } + + for _, sub := range cmd.Commands { + if _, ok := expected[sub.Name]; ok { + expected[sub.Name] = true + } + } + + for name, found := range expected { + if !found { + t.Errorf("missing agent subcommand %q", name) + } + } +} + +func TestAgentNewCommand_DefaultsToHermes(t *testing.T) { + cfg := newTestConfig(t) + cmd := agentCommand(cfg) + newCmd := findSubcommand(t, cmd, "new") + flags := flagMap(newCmd) + + assertStringDefault(t, flags, "runtime", "hermes") + requireFlags(t, flags, "id", "force", "no-sync") +} + +func TestAgentWalletCommand_Structure(t *testing.T) { + cfg := newTestConfig(t) + cmd := agentCommand(cfg) + wallet := findSubcommand(t, cmd, "wallet") + + expected := map[string]bool{ + "address": false, + "list": false, + "backup": false, + "restore": false, + } + + for _, sub := range wallet.Commands { + if _, ok := expected[sub.Name]; ok { + expected[sub.Name] = true + } + } + + for name, found := range expected { + if !found { + t.Errorf("missing wallet subcommand %q", name) + } + } +} + +func TestResolveAgentTarget(t *testing.T) { + tests := []struct { + name string + instances []agentTarget + runtimeFlag string + args []string + want agentTarget + wantErr string + }{ + { + name: "no instances", + wantErr: "no agent instances found", + }, + { + name: "prefers default Hermes when present", + instances: []agentTarget{ + {Runtime: agentruntime.Hermes, ID: agentruntime.DefaultInstanceID}, + {Runtime: agentruntime.Hermes, ID: "research"}, + {Runtime: agentruntime.OpenClaw, ID: "legacy"}, + }, + want: agentTarget{Runtime: agentruntime.Hermes, ID: agentruntime.DefaultInstanceID}, + }, + { + name: "falls back to single OpenClaw instance", + instances: []agentTarget{ + {Runtime: agentruntime.OpenClaw, ID: "legacy"}, + }, + want: agentTarget{Runtime: agentruntime.OpenClaw, ID: "legacy"}, + }, + { + name: "resolves explicit runtime default Hermes", + instances: []agentTarget{ + {Runtime: agentruntime.Hermes, ID: agentruntime.DefaultInstanceID}, + {Runtime: agentruntime.Hermes, ID: "research"}, + }, + runtimeFlag: "hermes", + want: agentTarget{Runtime: agentruntime.Hermes, ID: agentruntime.DefaultInstanceID}, + }, + { + name: "resolves explicit runtime and instance", + instances: []agentTarget{ + {Runtime: agentruntime.Hermes, ID: agentruntime.DefaultInstanceID}, + {Runtime: agentruntime.Hermes, ID: "research"}, + }, + runtimeFlag: "hermes", + args: []string{"research"}, + want: agentTarget{Runtime: agentruntime.Hermes, ID: "research"}, + }, + { + name: "resolves openclaw by instance name without runtime", + instances: []agentTarget{ + {Runtime: agentruntime.Hermes, ID: agentruntime.DefaultInstanceID}, + {Runtime: agentruntime.OpenClaw, ID: "legacy"}, + }, + args: []string{"legacy"}, + want: agentTarget{Runtime: agentruntime.OpenClaw, ID: "legacy"}, + }, + { + name: "errors on same id across runtimes", + instances: []agentTarget{ + {Runtime: agentruntime.Hermes, ID: "shared"}, + {Runtime: agentruntime.OpenClaw, ID: "shared"}, + }, + args: []string{"shared"}, + wantErr: "exists in multiple runtimes", + }, + { + name: "errors on unknown instance", + instances: []agentTarget{ + {Runtime: agentruntime.Hermes, ID: agentruntime.DefaultInstanceID}, + }, + args: []string{"missing"}, + wantErr: `agent instance "missing" not found`, + }, + { + name: "errors on invalid runtime", + runtimeFlag: "bad", + wantErr: "unsupported agent runtime", + }, + { + name: "errors on multiple non-default instances without selector", + instances: []agentTarget{ + {Runtime: agentruntime.Hermes, ID: "research"}, + {Runtime: agentruntime.OpenClaw, ID: "legacy"}, + }, + wantErr: "multiple agent instances found", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := newTestConfig(t) + for _, instance := range tt.instances { + mkdirAgentInstance(t, cfg, instance.Runtime, instance.ID) + } + + got, err := resolveAgentTarget(cfg, tt.runtimeFlag, tt.args) + if tt.wantErr != "" { + if err == nil { + t.Fatalf("expected error containing %q, got nil", tt.wantErr) + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("error = %q, want substring %q", err.Error(), tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("resolveAgentTarget() error = %v", err) + } + if got != tt.want { + t.Fatalf("resolveAgentTarget() = %#v, want %#v", got, tt.want) + } + }) + } +} + +func TestResolveRuntimeInstance(t *testing.T) { + tests := []struct { + name string + runtime agentruntime.Runtime + instances []string + args []string + preferDefault bool + want string + wantErr string + }{ + { + name: "no instances", + runtime: agentruntime.Hermes, + wantErr: "no Hermes instances found", + }, + { + name: "prefers default Hermes", + runtime: agentruntime.Hermes, + instances: []string{"research", agentruntime.DefaultInstanceID}, + preferDefault: true, + want: agentruntime.DefaultInstanceID, + }, + { + name: "uses single instance", + runtime: agentruntime.OpenClaw, + instances: []string{"legacy"}, + want: "legacy", + }, + { + name: "uses explicit instance", + runtime: agentruntime.Hermes, + instances: []string{"research", agentruntime.DefaultInstanceID}, + args: []string{"research"}, + want: "research", + }, + { + name: "errors on unknown explicit instance", + runtime: agentruntime.Hermes, + instances: []string{agentruntime.DefaultInstanceID}, + args: []string{"missing"}, + wantErr: `Hermes instance "missing" not found`, + }, + { + name: "errors on multiple instances without default preference", + runtime: agentruntime.Hermes, + instances: []string{agentruntime.DefaultInstanceID, "research"}, + wantErr: "multiple Hermes instances found", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := newTestConfig(t) + for _, id := range tt.instances { + mkdirAgentInstance(t, cfg, tt.runtime, id) + } + + got, err := resolveRuntimeInstance(cfg, tt.runtime, tt.args, tt.preferDefault) + if tt.wantErr != "" { + if err == nil { + t.Fatalf("expected error containing %q, got nil", tt.wantErr) + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("error = %q, want substring %q", err.Error(), tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("resolveRuntimeInstance() error = %v", err) + } + if got != tt.want { + t.Fatalf("resolveRuntimeInstance() = %q, want %q", got, tt.want) + } + }) + } +} + +func mkdirAgentInstance(t *testing.T, cfg *config.Config, runtime agentruntime.Runtime, id string) { + t.Helper() + if err := os.MkdirAll(agentruntime.DeploymentPath(cfg, runtime, id), 0o755); err != nil { + t.Fatalf("create %s instance %q: %v", runtime, id, err) + } +} diff --git a/cmd/obol/hermes.go b/cmd/obol/hermes.go index 2a5b6187..9099f275 100644 --- a/cmd/obol/hermes.go +++ b/cmd/obol/hermes.go @@ -2,7 +2,7 @@ package main import ( "context" - "errors" + "fmt" "github.com/ObolNetwork/obol-stack/internal/config" "github.com/ObolNetwork/obol-stack/internal/hermes" @@ -11,200 +11,20 @@ import ( func hermesCommand(cfg *config.Config) *cli.Command { return &cli.Command{ - Name: "hermes", - Aliases: []string{"herme"}, - Usage: "Manage Hermes agent instances", - Commands: []*cli.Command{ - { - Name: "onboard", - Usage: "Create and deploy a Hermes instance", - Flags: []cli.Flag{ - &cli.StringFlag{ - Name: "id", - Usage: "Instance ID (defaults to generated petname)", - }, - &cli.BoolFlag{ - Name: "force", - Aliases: []string{"f"}, - Usage: "Overwrite existing instance", - }, - &cli.BoolFlag{ - Name: "no-sync", - Usage: "Only scaffold config, don't deploy to cluster", - }, - }, - Action: func(ctx context.Context, cmd *cli.Command) error { - return hermes.Onboard(cfg, hermes.OnboardOptions{ - ID: cmd.String("id"), - Force: cmd.Bool("force"), - Sync: !cmd.Bool("no-sync"), - }, getUI(cmd)) - }, - }, - { - Name: "sync", - Usage: "Deploy or update a Hermes instance", - ArgsUsage: "[instance-name]", - Action: func(ctx context.Context, cmd *cli.Command) error { - id, _, err := hermes.ResolveInstance(cfg, cmd.Args().Slice()) - if err != nil { - return err - } - return hermes.Sync(cfg, id, getUI(cmd)) - }, - }, - { - Name: "token", - Usage: "Retrieve or regenerate the Hermes API server token", - ArgsUsage: "[instance-name]", - Flags: []cli.Flag{ - &cli.BoolFlag{ - Name: "regenerate", - Usage: "Delete and regenerate the API server token (restarts the instance)", - }, - }, - Action: func(ctx context.Context, cmd *cli.Command) error { - id, _, err := hermes.ResolveInstance(cfg, cmd.Args().Slice()) - if err != nil { - return err - } - - u := getUI(cmd) - if cmd.Bool("regenerate") { - newToken, err := hermes.RegenerateToken(cfg, id, u) - if err != nil { - return err - } - u.Print(newToken) - return nil - } - - return hermes.Token(cfg, id, u) - }, - }, - { - Name: "list", - Usage: "List Hermes instances", - Action: func(ctx context.Context, cmd *cli.Command) error { - return hermes.List(cfg, getUI(cmd)) - }, - }, - { - Name: "delete", - Usage: "Remove a Hermes instance and its cluster resources", - ArgsUsage: "[instance-name]", - Flags: []cli.Flag{ - &cli.BoolFlag{ - Name: "force", - Aliases: []string{"f"}, - Usage: "Skip confirmation prompt", - }, - }, - Action: func(ctx context.Context, cmd *cli.Command) error { - id, _, err := hermes.ResolveInstance(cfg, cmd.Args().Slice()) - if err != nil { - return err - } - return hermes.Delete(cfg, id, cmd.Bool("force"), getUI(cmd)) - }, - }, - { - Name: "setup", - Usage: "Re-render Hermes config from the current LiteLLM inventory", - ArgsUsage: "[instance-name]", - Action: func(ctx context.Context, cmd *cli.Command) error { - id, _, err := hermes.ResolveInstance(cfg, cmd.Args().Slice()) - if err != nil { - return err - } - return hermes.Setup(cfg, id, hermes.SetupOptions{}, getUI(cmd)) - }, - }, - { - Name: "dashboard", - Usage: "Pending product decision for Hermes-native dashboard behavior", - ArgsUsage: "[instance-name]", - Action: func(ctx context.Context, cmd *cli.Command) error { - return errors.New("Hermes dashboard semantics diverge from OpenClaw; choose a native Hermes dashboard flow or an Obol wrapper before enabling this command") - }, - }, - { - Name: "wallet", - Usage: "Inspect Hermes instance wallets", - Commands: []*cli.Command{ - { - Name: "address", - Usage: "Show the wallet address for a Hermes instance", - ArgsUsage: "[instance-name]", - Action: func(ctx context.Context, cmd *cli.Command) error { - args := cmd.Args().Slice() - - if len(args) == 0 { - addr, err := hermes.ResolveWalletAddress(cfg) - if err != nil { - return err - } - getUI(cmd).Print(addr) - return nil - } - - id, _, err := hermes.ResolveInstance(cfg, args) - if err != nil { - return err - } - - walletInfo, err := hermes.ReadWalletMetadata(hermes.DeploymentPath(cfg, id)) - if err != nil { - return err - } - getUI(cmd).Print(walletInfo.Address) - return nil - }, - }, - { - Name: "list", - Usage: "List wallets for Hermes instances", - ArgsUsage: "[instance-name]", - Action: func(ctx context.Context, cmd *cli.Command) error { - args := cmd.Args().Slice() - - var id string - if len(args) > 0 { - var err error - id, _, err = hermes.ResolveInstance(cfg, args) - if err != nil { - return err - } - } - - return hermes.ListWallets(cfg, id, getUI(cmd)) - }, - }, - }, - }, - { - Name: "skills", - Usage: "Run native Hermes skills commands against a deployed instance", - ArgsUsage: "[instance-name] [-- ]", - SkipFlagParsing: true, - Action: func(ctx context.Context, cmd *cli.Command) error { - id, remaining, err := hermes.ResolveInstance(cfg, cmd.Args().Slice()) - if err != nil { - return err - } - - return hermes.Skills(cfg, id, rawArgsAfterSeparator(remaining)) - }, - }, + Name: "hermes", + Aliases: []string{"herme"}, + Usage: "Run native Hermes CLI against a deployed Hermes instance", + ArgsUsage: "[--agent ] [hermes args...]", + Description: "Passes arguments through to the native Hermes CLI in the selected deployed instance. Defaults to obol-agent when available.", + SkipFlagParsing: true, + HideHelp: true, + Action: func(ctx context.Context, cmd *cli.Command) error { + id, hermesArgs, err := hermes.ResolveCLIInvocation(cfg, cmd.Args().Slice()) + if err != nil { + return fmt.Errorf("%w\n\nUsage:\n obol hermes [--agent ] [hermes args...]\n\nExamples:\n obol hermes chat -q \"hello\"\n obol hermes skills list\n obol hermes --agent research config show", err) + } + + return hermes.CLI(cfg, id, hermesArgs) }, } } - -func rawArgsAfterSeparator(args []string) []string { - for i, arg := range args { - if arg == "--" { - return args[i+1:] - } - } - return args -} diff --git a/cmd/obol/hermes_test.go b/cmd/obol/hermes_test.go index 4883c553..d180d1fa 100644 --- a/cmd/obol/hermes_test.go +++ b/cmd/obol/hermes_test.go @@ -2,41 +2,20 @@ package main import "testing" -func TestHermesCommand_Structure(t *testing.T) { +func TestHermesCommand_IsNativePassthrough(t *testing.T) { cfg := newTestConfig(t) cmd := hermesCommand(cfg) - expected := map[string]bool{ - "onboard": false, - "sync": false, - "token": false, - "list": false, - "delete": false, - "setup": false, - "dashboard": false, - "wallet": false, - "skills": false, + if cmd.Usage != "Run native Hermes CLI against a deployed Hermes instance" { + t.Fatalf("unexpected usage: %q", cmd.Usage) } - - for _, sub := range cmd.Commands { - if _, ok := expected[sub.Name]; ok { - expected[sub.Name] = true - } + if !cmd.SkipFlagParsing { + t.Fatal("Hermes command should pass native Hermes flags through") } - - for name, found := range expected { - if !found { - t.Errorf("missing Hermes subcommand %q", name) - } + if !cmd.HideHelp { + t.Fatal("Hermes command should pass native --help through") } -} - -func TestHermesSkillsCommand_UsesRawFlagParsing(t *testing.T) { - cfg := newTestConfig(t) - cmd := hermesCommand(cfg) - skills := findSubcommand(t, cmd, "skills") - - if !skills.SkipFlagParsing { - t.Fatal("Hermes skills command should pass native Hermes flags through") + if len(cmd.Commands) != 0 { + t.Fatalf("Hermes command should not define Obol-managed subcommands, got %d", len(cmd.Commands)) } } diff --git a/cmd/obol/main.go b/cmd/obol/main.go index b180cc62..714c6334 100644 --- a/cmd/obol/main.go +++ b/cmd/obol/main.go @@ -10,7 +10,6 @@ import ( "runtime/debug" "syscall" - "github.com/ObolNetwork/obol-stack/internal/agent" "github.com/ObolNetwork/obol-stack/internal/app" "github.com/ObolNetwork/obol-stack/internal/config" "github.com/ObolNetwork/obol-stack/internal/kubectl" @@ -42,7 +41,13 @@ COMMANDS: stack down Stop the Obol Stack stack purge Delete stack config (use --force to also delete data) Obol Agent: - agent init Initialize the Obol Agent + agent init Initialize the stack-managed Obol Agent + agent new Create and deploy an agent instance + agent sync Deploy or update an agent instance + agent auth Retrieve or regenerate an agent API token + agent wallet Manage agent wallets + agent list List agent instances + agent delete Remove an agent instance wallet import Import an existing wallet for the Obol Agent Network Management: network list List all networks (local nodes + remote RPCs) @@ -52,14 +57,13 @@ COMMANDS: network status Show eRPC gateway health and upstreams network delete Remove network deployment - Hermes (Default Agent Runtime): - hermes onboard Create and deploy a Hermes instance - hermes setup Re-render Hermes config for a deployed instance - hermes sync Deploy or update a Hermes instance - hermes token Retrieve Hermes API server token - hermes list List Hermes instances - hermes delete Remove instance and cluster resources - hermes wallet Inspect Hermes wallets + Hermes (Default Agent Runtime — these commands passthrough to the hermes CLI): + hermes help List every native Hermes command + hermes skills Manage Hermes skills + hermes chat Chat with the agent + hermes config Inspect or edit Hermes config + hermes dashboard Dashboard controls + (use --agent to target a non-default instance) OpenClaw (Alternate Agent Runtime): openclaw onboard Create and deploy an OpenClaw instance @@ -220,19 +224,7 @@ GLOBAL OPTIONS:{{template "visibleFlagTemplate" .}}{{end}} // ============================================================ // Obol Agent Commands // ============================================================ - { - Name: "agent", - Usage: "Manage Obol Agent", - Commands: []*cli.Command{ - { - Name: "init", - Usage: "Initialize the Obol Agent", - Action: func(ctx context.Context, cmd *cli.Command) error { - return agent.Init(cfg, getUI(cmd)) - }, - }, - }, - }, + agentCommand(cfg), walletCommand(cfg), // ============================================================ // Tunnel Management Commands diff --git a/docs/getting-started.md b/docs/getting-started.md index 8f4a3958..640e7756 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -164,7 +164,7 @@ The default `obol-agent` instance includes: List all agent instances: ```bash -obol hermes list +obol agent list ``` ## Step 5 -- Test Agent Inference @@ -173,16 +173,16 @@ Get the gateway token for your agent instance: ```bash # For the default instance -obol hermes token default +obol agent auth # For obol-agent -obol hermes token obol-agent +obol agent auth obol-agent ``` Test inference through the agent gateway: ```bash -TOKEN=$(obol hermes token default) +TOKEN=$(obol agent auth obol-agent) obol kubectl port-forward -n hermes-obol-agent svc/hermes 8642:8642 & PF_PID=$! diff --git a/flows/flow-04-agent.sh b/flows/flow-04-agent.sh index 081918c2..02286ea5 100755 --- a/flows/flow-04-agent.sh +++ b/flows/flow-04-agent.sh @@ -1,20 +1,20 @@ #!/bin/bash # Flow 04: Agent Init + Inference — getting-started.md §4-5. -# Tests: agent init, hermes list, token, agent gateway inference. +# Tests: agent init, agent list, auth, agent gateway inference. source "$(dirname "$0")/lib.sh" # §4: Deploy AI Agent (idempotent) run_step "obol agent init" "$OBOL" agent init # List agent instances — verify name AND URL are shown (getting-started §4) -run_step_grep "hermes list shows instances" "obol-agent" "$OBOL" hermes list -step "hermes list shows agent URL" -list_out=$("$OBOL" hermes list 2>&1) || true +run_step_grep "agent list shows instances" "obol-agent" "$OBOL" agent list +step "agent list shows agent URL" +list_out=$("$OBOL" agent list 2>&1) || true if echo "$list_out" | grep -q "obol.stack\|URL:"; then url=$(echo "$list_out" | grep -oE 'http://[a-z0-9.-]+' | head -1) - pass "hermes list shows agent URL: $url" + pass "agent list shows agent URL: $url" else - fail "hermes list missing URL — ${list_out:0:200}" + fail "agent list missing URL — ${list_out:0:200}" fi # PR 299 moves monetization reconciliation to serviceoffer-controller. @@ -32,7 +32,7 @@ run_step_grep "serviceoffer-controller running" "Running" \ # §5: Hermes service on port 8642 (getting-started §5 uses port-forward 8642:8642) step "Hermes service on port 8642" -NS=$("$OBOL" hermes list 2>/dev/null | grep -oE 'hermes-[a-z0-9-]+' | head -1 || echo "hermes-obol-agent") +NS=$("$OBOL" agent list --runtime hermes 2>/dev/null | grep -oE 'hermes-[a-z0-9-]+' | head -1 || echo "hermes-obol-agent") oc_port=$("$OBOL" kubectl get svc hermes -n "$NS" \ -o jsonpath='{.spec.ports[0].port}' 2>&1) || true if [ "$oc_port" = "8642" ]; then @@ -41,9 +41,17 @@ else fail "Hermes service port unexpected: $oc_port (expected 8642)" fi +step "Native Hermes CLI passthrough works for default agent" +hermes_version_out=$("$OBOL" hermes --agent obol-agent version 2>&1) || true +if echo "$hermes_version_out" | grep -qi "hermes"; then + pass "Native Hermes CLI passthrough returned version" +else + fail "Native Hermes CLI passthrough failed — ${hermes_version_out:0:200}" +fi + # §5: Test Agent Inference step "Get Hermes API server token" -TOKEN=$("$OBOL" hermes token obol-agent 2>/dev/null || "$OBOL" hermes token default 2>/dev/null || true) +TOKEN=$("$OBOL" agent auth obol-agent 2>/dev/null || "$OBOL" agent auth 2>/dev/null || true) if [ -n "$TOKEN" ]; then pass "Got token: ${TOKEN:0:8}..." else @@ -61,7 +69,7 @@ else fi # Determine the namespace for port-forward -NS=$("$OBOL" hermes list 2>/dev/null | grep -oE 'hermes-[a-z0-9-]+' | head -1 || echo "hermes-obol-agent") +NS=$("$OBOL" agent list --runtime hermes 2>/dev/null | grep -oE 'hermes-[a-z0-9-]+' | head -1 || echo "hermes-obol-agent") step "Agent inference via port-forward" AGENT_PF_PORT="${FLOW04_AGENT_PORT:-$(pick_free_port)}" @@ -134,13 +142,13 @@ cleanup_pid "$PF_PID" # §4: Ethereum signing wallet created by obol agent init (getting-started §4) # "A unique Ethereum signing wallet" is listed as a feature of obol agent init. -step "obol hermes wallet list shows Ethereum address" -wallet_out=$("$OBOL" hermes wallet list obol-agent 2>&1) || true +step "obol agent wallet list shows Ethereum address" +wallet_out=$("$OBOL" agent wallet list obol-agent 2>&1) || true if echo "$wallet_out" | grep -q "0x[0-9a-fA-F]\{40\}\|Address:"; then addr=$(echo "$wallet_out" | grep -oE '0x[0-9a-fA-F]{40}' | head -1) pass "Agent wallet address: $addr" else - fail "hermes wallet list missing address — ${wallet_out:0:200}" + fail "agent wallet list missing address — ${wallet_out:0:200}" fi # §4: Hermes gateway health via HTTPRoute URL (getting-started §4 output shows URL) diff --git a/flows/flow-11-dual-stack.sh b/flows/flow-11-dual-stack.sh index bf6a7e89..d6adf150 100755 --- a/flows/flow-11-dual-stack.sh +++ b/flows/flow-11-dual-stack.sh @@ -465,7 +465,7 @@ preseed_bob_wallet() { if [ ! -f "$deploy_dir/helmfile.yaml" ]; then step "Bob: scaffold default agent before stack up" set +e - onboard_out=$(bob hermes onboard --id obol-agent --no-sync 2>&1) + onboard_out=$(bob agent new --runtime hermes --id obol-agent --no-sync 2>&1) rc=$? set -e echo "$onboard_out" | tail -8 @@ -477,7 +477,7 @@ preseed_bob_wallet() { pass "Bob default agent scaffolded" fi - existing=$(bob hermes wallet address obol-agent 2>/dev/null || true) + existing=$(bob agent wallet address --runtime hermes obol-agent 2>/dev/null || true) if [ "$(lower_addr "$existing")" = "$(lower_addr "$BOB_WALLET")" ]; then pass "Bob wallet preseeded: $existing" return 0 @@ -502,7 +502,7 @@ preseed_bob_wallet() { exit "$rc" fi - existing=$(bob hermes wallet address obol-agent 2>/dev/null || true) + existing=$(bob agent wallet address --runtime hermes obol-agent 2>/dev/null || true) if [ "$(lower_addr "$existing")" != "$(lower_addr "$BOB_WALLET")" ]; then fail "Bob preseeded wallet mismatch — metadata=$existing expected=$BOB_WALLET" emit_metrics @@ -1129,7 +1129,7 @@ fi # ═════════════════════════════════════════════════════════════════ step "Bob: get Hermes API server token" -BOB_TOKEN=$(bob "$BOB_AGENT_RUNTIME" token obol-agent 2>/dev/null || true) +BOB_TOKEN=$(bob agent auth obol-agent 2>/dev/null || true) if [ -z "$BOB_TOKEN" ]; then fail "Could not get Bob's gateway token" emit_metrics; exit 1 diff --git a/internal/agentruntime/runtime.go b/internal/agentruntime/runtime.go index 6fe117a8..f8f2b403 100644 --- a/internal/agentruntime/runtime.go +++ b/internal/agentruntime/runtime.go @@ -185,7 +185,7 @@ func ResolveInstance(cfg *config.Config, runtime Runtime, args []string) (id str switch len(instances) { case 0: - return "", nil, fmt.Errorf("no %s instances found — run 'obol %s onboard' to create one", desc.DisplayName, runtime) + return "", nil, fmt.Errorf("no %s instances found — run 'obol agent new --runtime %s' to create one", desc.DisplayName, runtime) case 1: return instances[0], args, nil default: diff --git a/internal/hermes/hermes.go b/internal/hermes/hermes.go index 28cefe68..53c73e5f 100644 --- a/internal/hermes/hermes.go +++ b/internal/hermes/hermes.go @@ -33,7 +33,7 @@ const ( // renovate: datasource=helm depName=raw registryUrl=https://bedag.github.io/helm-charts/ rawChartVersion = "2.0.2" - defaultImage = "nousresearch/hermes-agent:latest" + defaultImage = "nousresearch/hermes-agent:v2026.4.23" hermesInstallDir = "/data/.hermes/hermes-agent" hermesRepoURL = "https://github.com/NousResearch/hermes-agent.git" hermesBinary = hermesInstallDir + "/venv/bin/hermes" @@ -71,7 +71,7 @@ func DeploymentPath(cfg *config.Config, id string) string { func SetupDefault(cfg *config.Config, u *ui.UI) error { if _, _, err := configuredModels(cfg, u); err != nil { u.Warnf("Skipping default Hermes agent: %v", err) - u.Print(" Run 'obol model setup' to configure LiteLLM, then 'obol hermes onboard obol-agent'.") + u.Print(" Run 'obol model setup' to configure LiteLLM, then 'obol agent init'.") return nil } @@ -193,7 +193,7 @@ func Onboard(cfg *config.Config, opts OnboardOptions, u *ui.UI) error { return Sync(cfg, id, u) } - u.Printf("\nTo deploy: obol hermes sync %s", id) + u.Printf("\nTo deploy: obol agent sync %s", id) return nil } @@ -250,7 +250,7 @@ func Sync(cfg *config.Config, id string, u *ui.UI) error { u.Detail("Dashboard", "http://"+dashboardHostname(id)) u.Blank() u.Dim("[Optional] Retrieve an API server token:") - u.Printf(" obol hermes token %s", id) + u.Printf(" obol agent auth %s", id) u.Blank() u.Dim("[Optional] Port-forward fallback:") u.Printf(" obol kubectl -n %s port-forward svc/%s %d:%d", @@ -289,7 +289,7 @@ func List(cfg *config.Config, u *ui.UI) error { if len(instances) == 0 { u.Print("No Hermes instances installed") - u.Print("\nTo create one: obol hermes onboard") + u.Print("\nTo create one: obol agent new --runtime hermes") return nil } @@ -479,10 +479,94 @@ func Skills(cfg *config.Config, id string, args []string) error { return cliViaKubectlExec(cfg, id, append([]string{"skills"}, args...)) } +func CLI(cfg *config.Config, id string, args []string) error { + return cliViaKubectlExec(cfg, id, args) +} + func ResolveInstance(cfg *config.Config, args []string) (string, []string, error) { return agentruntime.ResolveInstance(cfg, agentruntime.Hermes, args) } +func ResolveCLIInvocation(cfg *config.Config, args []string) (string, []string, error) { + selectedID, hermesArgs, err := splitCLISelection(args) + if err != nil { + return "", nil, err + } + + ids, err := agentruntime.ListInstanceIDs(cfg, agentruntime.Hermes) + if err != nil { + return "", nil, err + } + if len(ids) == 0 { + return "", nil, errors.New("no Hermes instances found — run 'obol agent init' or 'obol agent new --runtime hermes' to create one") + } + + if selectedID != "" { + if containsID(ids, selectedID) { + return selectedID, hermesArgs, nil + } + return "", nil, fmt.Errorf("Hermes instance %q not found; available: %s", selectedID, strings.Join(ids, ", ")) + } + + if containsID(ids, agentruntime.DefaultInstanceID) { + return agentruntime.DefaultInstanceID, hermesArgs, nil + } + if len(ids) == 1 { + return ids[0], hermesArgs, nil + } + + return "", nil, fmt.Errorf("multiple Hermes instances found, specify one with --agent: %s", strings.Join(ids, ", ")) +} + +func splitCLISelection(args []string) (selectedID string, hermesArgs []string, err error) { + for i := 0; i < len(args); i++ { + arg := args[i] + if arg == "--" { + hermesArgs = append(hermesArgs, args[i+1:]...) + return selectedID, hermesArgs, nil + } + + if arg == "--agent" { + if selectedID != "" { + return "", nil, errors.New("--agent specified multiple times") + } + if i+1 >= len(args) { + return "", nil, errors.New("--agent requires an instance name") + } + selectedID = strings.TrimSpace(args[i+1]) + if selectedID == "" { + return "", nil, errors.New("--agent requires an instance name") + } + i++ + continue + } + + if value, ok := strings.CutPrefix(arg, "--agent="); ok { + if selectedID != "" { + return "", nil, errors.New("--agent specified multiple times") + } + selectedID = strings.TrimSpace(value) + if selectedID == "" { + return "", nil, errors.New("--agent requires an instance name") + } + continue + } + + hermesArgs = append(hermesArgs, arg) + } + + return selectedID, hermesArgs, nil +} + +func containsID(ids []string, id string) bool { + for _, candidate := range ids { + if candidate == id { + return true + } + } + return false +} + func cliViaKubectlExec(cfg *config.Config, id string, args []string) error { kubeconfigPath := filepath.Join(cfg.ConfigDir, "kubeconfig.yaml") if _, err := os.Stat(kubeconfigPath); os.IsNotExist(err) { @@ -600,7 +684,7 @@ func writeDeploymentFiles(cfg *config.Config, id, deploymentDir, agentBaseURL st } func generateHelmfile(namespace string) string { - return fmt.Sprintf(`# Managed by obol hermes + return fmt.Sprintf(`# Managed by obol agent repositories: - name: obol @@ -740,10 +824,16 @@ func generateValues(namespace, hostname, dashboardHostname, agentBaseURL, token, git clone --depth 1 "$repo_url" "$install_dir" fi cd "$install_dir" - if [ ! -x "$install_dir/venv/bin/hermes" ]; then + # Reinstall when the venv is missing the hermes binary OR + # when the dashboard's web extra (fastapi/uvicorn) is absent. + # The upstream image installs ".[all]" (which pulls in + # ".[web]"); we re-create the venv from a fresh clone, so + # the extras must be re-requested explicitly here. + if [ ! -x "$install_dir/venv/bin/hermes" ] || \ + ! "$install_dir/venv/bin/python3" -c "import fastapi, uvicorn" >/dev/null 2>&1; then rm -rf "$install_dir/venv" uv venv --python python3 --system-site-packages venv - VIRTUAL_ENV="$install_dir/venv" uv pip install -e "." + VIRTUAL_ENV="$install_dir/venv" uv pip install -e ".[web]" fi if [ -f /data/.hermes/state.db ]; then if ! python3 - <<'PY' diff --git a/internal/hermes/hermes_test.go b/internal/hermes/hermes_test.go index e2a1840e..e9de3c15 100644 --- a/internal/hermes/hermes_test.go +++ b/internal/hermes/hermes_test.go @@ -8,6 +8,7 @@ import ( "strings" "testing" + "github.com/ObolNetwork/obol-stack/internal/agentruntime" "github.com/ObolNetwork/obol-stack/internal/config" "gopkg.in/yaml.v3" ) @@ -146,7 +147,8 @@ func TestGenerateValues_UsesHermesNativeNames(t *testing.T) { `install_dir="/data/.hermes/hermes-agent"`, `repo_url="https://github.com/NousResearch/hermes-agent.git"`, "uv venv --python python3 --system-site-packages venv", - `uv pip install -e "."`, + `uv pip install -e ".[web]"`, + `import fastapi, uvicorn`, `PRAGMA quick_check`, `state-db-corrupt-$ts`, `- "/data/.hermes/hermes-agent/venv/bin/hermes"`, @@ -207,3 +209,135 @@ func TestHermesExecArgs_UsesNativeHermesBinary(t *testing.T) { t.Fatalf("hermesExecArgs() = %#v, want %#v", got, want) } } + +func TestResolveCLIInvocation_DefaultsToObolAgent(t *testing.T) { + cfg := testConfig(t) + mkdirInstance(t, cfg, agentruntime.DefaultInstanceID) + mkdirInstance(t, cfg, "research") + + id, args, err := ResolveCLIInvocation(cfg, []string{"skills", "list"}) + if err != nil { + t.Fatalf("ResolveCLIInvocation() error = %v", err) + } + if id != agentruntime.DefaultInstanceID { + t.Fatalf("id = %q, want %q", id, agentruntime.DefaultInstanceID) + } + if !reflect.DeepEqual(args, []string{"skills", "list"}) { + t.Fatalf("args = %#v", args) + } +} + +func TestResolveCLIInvocation_UsesExplicitAgent(t *testing.T) { + cfg := testConfig(t) + mkdirInstance(t, cfg, agentruntime.DefaultInstanceID) + mkdirInstance(t, cfg, "research") + + id, args, err := ResolveCLIInvocation(cfg, []string{"--agent", "research", "config", "show"}) + if err != nil { + t.Fatalf("ResolveCLIInvocation() error = %v", err) + } + if id != "research" { + t.Fatalf("id = %q, want research", id) + } + if !reflect.DeepEqual(args, []string{"config", "show"}) { + t.Fatalf("args = %#v", args) + } +} + +func TestResolveCLIInvocation(t *testing.T) { + tests := []struct { + name string + instances []string + input []string + wantID string + wantArgs []string + wantErr string + }{ + { + name: "no instances", + input: []string{"version"}, + wantErr: "no Hermes instances found", + }, + { + name: "single instance fallback", + instances: []string{"solo"}, + input: []string{"version"}, + wantID: "solo", + wantArgs: []string{"version"}, + }, + { + name: "multiple non-default instances require selector", + instances: []string{"research", "ops"}, + input: []string{"version"}, + wantErr: "multiple Hermes instances found", + }, + { + name: "explicit agent equals syntax", + instances: []string{agentruntime.DefaultInstanceID, "research"}, + input: []string{"--agent=research", "config", "show"}, + wantID: "research", + wantArgs: []string{"config", "show"}, + }, + { + name: "separator preserves native flags", + instances: []string{agentruntime.DefaultInstanceID}, + input: []string{"--agent", agentruntime.DefaultInstanceID, "--", "--help"}, + wantID: agentruntime.DefaultInstanceID, + wantArgs: []string{"--help"}, + }, + { + name: "missing agent value", + instances: []string{agentruntime.DefaultInstanceID}, + input: []string{"--agent"}, + wantErr: "--agent requires an instance name", + }, + { + name: "duplicate agent selector", + instances: []string{agentruntime.DefaultInstanceID, "research"}, + input: []string{"--agent", agentruntime.DefaultInstanceID, "--agent=research", "version"}, + wantErr: "--agent specified multiple times", + }, + { + name: "unknown explicit agent", + instances: []string{agentruntime.DefaultInstanceID}, + input: []string{"--agent", "missing", "version"}, + wantErr: `Hermes instance "missing" not found`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := testConfig(t) + for _, id := range tt.instances { + mkdirInstance(t, cfg, id) + } + + gotID, gotArgs, err := ResolveCLIInvocation(cfg, tt.input) + if tt.wantErr != "" { + if err == nil { + t.Fatalf("expected error containing %q, got nil", tt.wantErr) + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("error = %q, want substring %q", err.Error(), tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("ResolveCLIInvocation() error = %v", err) + } + if gotID != tt.wantID { + t.Fatalf("id = %q, want %q", gotID, tt.wantID) + } + if !reflect.DeepEqual(gotArgs, tt.wantArgs) { + t.Fatalf("args = %#v, want %#v", gotArgs, tt.wantArgs) + } + }) + } +} + +func mkdirInstance(t *testing.T, cfg *config.Config, id string) { + t.Helper() + if err := os.MkdirAll(DeploymentPath(cfg, id), 0o755); err != nil { + t.Fatalf("create Hermes instance %q: %v", id, err) + } +} diff --git a/internal/hermes/wallet.go b/internal/hermes/wallet.go index d212edf6..936757a0 100644 --- a/internal/hermes/wallet.go +++ b/internal/hermes/wallet.go @@ -233,7 +233,7 @@ func encryptToV3Keystore(privKey, pubKey []byte, password string) ([]byte, strin func generateRemoteSignerValues(wallet *WalletInfo) string { return fmt.Sprintf(`# Remote-signer configuration -# Managed by obol hermes — do not edit manually. +# Managed by obol agent — do not edit manually. keystorePassword: value: %q @@ -280,7 +280,7 @@ func ResolveWalletAddress(cfg *config.Config) (string, error) { switch len(ids) { case 0: - return "", fmt.Errorf("no Hermes instances found — run 'obol hermes onboard' first, or use --wallet") + return "", fmt.Errorf("no Hermes instances found — run 'obol agent new --runtime hermes' first, or use --wallet") case 1: wallet, err := ReadWalletMetadata(DeploymentPath(cfg, ids[0])) if err != nil { @@ -308,7 +308,7 @@ func ResolveInstanceNamespace(cfg *config.Config) (string, error) { switch len(ids) { case 0: - return "", fmt.Errorf("no Hermes instances found — run 'obol hermes onboard' first") + return "", fmt.Errorf("no Hermes instances found — run 'obol agent new --runtime hermes' first") case 1: return agentruntime.Namespace(agentruntime.Hermes, ids[0]), nil default: diff --git a/internal/stack/stack.go b/internal/stack/stack.go index f7b2f99b..08e5b562 100644 --- a/internal/stack/stack.go +++ b/internal/stack/stack.go @@ -432,12 +432,12 @@ func syncDefaults(cfg *config.Config, u *ui.UI, kubeconfigPath string, dataDir s if err := hermes.SetupDefault(cfg, u); err != nil { u.Warnf("Failed to set up default Hermes: %v", err) - u.Dim(" You can manually set up Hermes later with: obol hermes onboard") + u.Dim(" You can manually set up Hermes later with: obol agent new --runtime hermes") } else if walletAddr, walletErr := hermes.ResolveWalletAddress(cfg); walletErr == nil { u.Blank() u.Successf("Default agent wallet: %s", walletAddr) u.Dim(" Fund this wallet for x402 buying or direct on-chain registration.") - u.Dim(" Retrieve later with: obol hermes wallet list obol-agent") + u.Dim(" Retrieve later with: obol agent wallet list obol-agent") } // Apply agent capabilities (RBAC + heartbeat) to the default instance. diff --git a/internal/tunnel/agent.go b/internal/tunnel/agent.go index 35a7d59b..97a95cea 100644 --- a/internal/tunnel/agent.go +++ b/internal/tunnel/agent.go @@ -36,19 +36,19 @@ func SyncAgentBaseURL(cfg *config.Config, tunnelURL string) error { helmfilePath := filepath.Join(deploymentDir, "helmfile.yaml") if _, err := os.Stat(helmfilePath); os.IsNotExist(err) { // Overlay exists but helmfile.yaml is missing — unusual, skip sync. - fmt.Printf("⚠ AGENT_BASE_URL updated in values-hermes.yaml but helmfile.yaml not found; run 'obol hermes sync %s' manually.\n", agentDeploymentID) + fmt.Printf("⚠ AGENT_BASE_URL updated in values-hermes.yaml but helmfile.yaml not found; run 'obol agent sync %s' manually.\n", agentDeploymentID) return nil } kubeconfigPath := filepath.Join(cfg.ConfigDir, "kubeconfig.yaml") if _, err := os.Stat(kubeconfigPath); os.IsNotExist(err) { - fmt.Printf("⚠ AGENT_BASE_URL updated but cluster not running; changes will apply on next 'obol hermes sync %s'.\n", agentDeploymentID) + fmt.Printf("⚠ AGENT_BASE_URL updated but cluster not running; changes will apply on next 'obol agent sync %s'.\n", agentDeploymentID) return nil } helmfileBin := filepath.Join(cfg.BinDir, "helmfile") if _, err := os.Stat(helmfileBin); os.IsNotExist(err) { - fmt.Printf("⚠ helmfile not found at %s; run 'obol hermes sync %s' manually.\n", helmfileBin, agentDeploymentID) + fmt.Printf("⚠ helmfile not found at %s; run 'obol agent sync %s' manually.\n", helmfileBin, agentDeploymentID) return nil }