Skip to content

Commit 7fcb295

Browse files
bussyjdOisinKyne
authored andcommitted
feat: add Hermes child agent factory seed path
1 parent 3634778 commit 7fcb295

18 files changed

Lines changed: 1145 additions & 53 deletions

File tree

cmd/obol/agent.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ func agentCommand(cfg *config.Config) *cli.Command {
6060
ArgsUsage: "[name]",
6161
Description: `With a positional name and CRD-path flags (--model, --skills,
6262
--objective, --create-wallet) this declares an Agent custom resource and
63-
seeds soul.md + the per-agent skills dir on the host.
63+
seeds SOUL.md + the per-agent skills dir on the host.
6464
6565
Without a positional name, falls back to the legacy host-rendered
6666
Hermes/OpenClaw onboard flow used by the master agent.`,
@@ -91,7 +91,7 @@ Hermes/OpenClaw onboard flow used by the master agent.`,
9191
},
9292
&cli.StringFlag{
9393
Name: "objective",
94-
Usage: "Operator objective text substituted into soul.md (CRD path)",
94+
Usage: "Operator objective text substituted into SOUL.md (CRD path)",
9595
},
9696
&cli.BoolFlag{
9797
Name: "create-wallet",
@@ -346,7 +346,7 @@ Examples:
346346
}
347347
} else if objectiveChanged {
348348
if _, err := agentcrd.WriteSoul(cfg, name, stringValueFromAny(spec["objective"]), true); err != nil {
349-
return fmt.Errorf("sync host soul.md: %w", err)
349+
return fmt.Errorf("sync host SOUL.md: %w", err)
350350
}
351351
}
352352

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

915915
// deleteCRDAgent removes the Agent CR and its host-side data directory
916-
// (skills + soul.md). Used by `obol agent delete <name>` when the
916+
// (skills + SOUL.md). Used by `obol agent delete <name>` when the
917917
// argument matches a CRD-declared agent. Idempotent: missing cluster,
918918
// missing CR, and missing host dir are all treated as "already gone".
919919
//

cmd/obol/agent_crd.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,9 +88,9 @@ func createCRDAgent(cfg *config.Config, u *ui.UI, opts createCRDAgentOptions) er
8888
return fmt.Errorf("seed host files: %w", err)
8989
}
9090
if soulWritten {
91-
u.Successf("soul.md seeded at %s", agentcrd.HostSoulPath(cfg, opts.Name))
91+
u.Successf("SOUL.md seeded at %s", agentcrd.HostSoulPath(cfg, opts.Name))
9292
} else {
93-
u.Dim(fmt.Sprintf("soul.md already exists at %s, leaving as-is", agentcrd.HostSoulPath(cfg, opts.Name)))
93+
u.Dim(fmt.Sprintf("SOUL.md already exists at %s, leaving as-is", agentcrd.HostSoulPath(cfg, opts.Name)))
9494
}
9595
if len(skills) > 0 {
9696
u.Successf("Skills written: %s", strings.Join(skills, ", "))

cmd/obol/sell_agent.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -280,7 +280,7 @@ func runAgentBackedDemo(
280280
return fmt.Errorf("derived agent name %q is invalid: %w", agentName, err)
281281
}
282282

283-
// 1. Seed host-side files (skills + soul.md) and apply the Agent CR.
283+
// 1. Seed host-side files (skills + SOUL.md) and apply the Agent CR.
284284
// Idempotent — re-running `obol sell demo quant` after a previous
285285
// run is a no-op for the agent if it already exists. A CR that is
286286
// mid-deletion (DeletionTimestamp set, finalizer still draining)
@@ -302,7 +302,7 @@ func runAgentBackedDemo(
302302
return fmt.Errorf("seed agent host files: %w", seedErr)
303303
}
304304
if soulWritten {
305-
u.Successf("soul.md seeded at %s", agentcrd.HostSoulPath(cfg, agentName))
305+
u.Successf("SOUL.md seeded at %s", agentcrd.HostSoulPath(cfg, agentName))
306306
}
307307
// Namespace must exist before the Agent CR can land; controller-
308308
// side namespace creation is part of provisioning, which doesn't

flows/flow-16-sell-agent.sh

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
# Flow 16: Sell Agent — agent-backed ServiceOffer metadata smoke.
33
#
44
# Steps:
5-
# 1. Declare an Agent CRD via `obol agent new <name>` (host seeds soul.md
5+
# 1. Declare an Agent CRD via `obol agent new <name>` (host seeds SOUL.md
66
# + skills, applies the CR; controller provisions Hermes + optional
77
# remote-signer in the agent's namespace)
88
# 2. Gate it with `obol sell agent <name>` (creates a ServiceOffer of
@@ -35,12 +35,12 @@ else
3535
fi
3636

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

internal/agentcrd/agent.go

Lines changed: 85 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// Package agentcrd contains host-side helpers for managing the obol.org/Agent
22
// CRD: building a spec from CLI flags, seeding the per-agent skills dir +
3-
// soul.md on the host (which becomes the data PVC inside the cluster), and
3+
// SOUL.md on the host (which becomes the data PVC inside the cluster), and
44
// thin wrappers around kubectl apply/get/delete. The in-cluster reconciler
55
// in internal/serviceoffercontroller is the source of truth for the
66
// resulting K8s primitives; this package is just the host-side seam.
@@ -33,7 +33,7 @@ func Namespace(name string) string {
3333

3434
// HostHomePath is where the agent's .hermes data lives on the host. The
3535
// cluster mounts this into the Hermes pod via hostPath; writing
36-
// soul.md/skills here puts them inside the pod automatically.
36+
// SOUL.md/skills here puts them inside the pod automatically.
3737
func HostHomePath(cfg *config.Config, name string) string {
3838
desc := agentruntime.Describe(agentruntime.Hermes)
3939
return filepath.Join(cfg.DataDir, Namespace(name), desc.DataPVCName, desc.HomeDir)
@@ -45,26 +45,34 @@ func HostSkillsPath(cfg *config.Config, name string) string {
4545
return filepath.Join(HostHomePath(cfg, name), "obol-skills")
4646
}
4747

48-
// HostSoulPath is where the seeded soul.md lives.
48+
// HostSoulPath is where the seeded Hermes identity file lives. Hermes reads
49+
// uppercase SOUL.md from HERMES_HOME, so keep this path aligned with upstream
50+
// Hermes profile semantics.
4951
func HostSoulPath(cfg *config.Config, name string) string {
52+
return filepath.Join(HostHomePath(cfg, name), "SOUL.md")
53+
}
54+
55+
// HostLegacySoulPath is the pre-profile seed path used before Hermes profile
56+
// casing was aligned. It is read during migration only.
57+
func HostLegacySoulPath(cfg *config.Config, name string) string {
5058
return filepath.Join(HostHomePath(cfg, name), "soul.md")
5159
}
5260

5361
// SeedOptions controls how host-side seed data is written.
5462
type SeedOptions struct {
55-
// OverwriteSoul forces a soul.md rewrite even if one already exists.
63+
// OverwriteSoul forces a SOUL.md rewrite even if one already exists.
5664
// Default false: agent-owned after first reconcile.
5765
OverwriteSoul bool
5866
// ExactSkills removes any previously seeded skill dirs not present in the
5967
// requested set before copying the embedded skill subset.
6068
ExactSkills bool
6169
}
6270

63-
// SeedHostFiles writes the chosen skill subset and seeds soul.md on the host
64-
// data path. soul.md is only written when missing (or when OverwriteSoul is
71+
// SeedHostFiles writes the chosen skill subset and seeds SOUL.md on the host
72+
// data path. SOUL.md is only written when missing (or when OverwriteSoul is
6573
// true).
6674
//
67-
// Returns whether soul.md was written this call so callers can report the
75+
// Returns whether SOUL.md was written this call so callers can report the
6876
// difference between "fresh agent" and "existing agent, skills resynced".
6977
func SeedHostFiles(cfg *config.Config, name string, skills []string, objective string, opts SeedOptions) (soulWritten bool, err error) {
7078
if opts.ExactSkills {
@@ -79,47 +87,103 @@ func SeedHostFiles(cfg *config.Config, name string, skills []string, objective s
7987
return WriteSoul(cfg, name, objective, opts.OverwriteSoul)
8088
}
8189

82-
// WriteSoul renders and writes soul.md for the named agent. When overwrite is
83-
// false, an existing soul.md is preserved and the return value is false.
90+
// WriteSoul renders and writes SOUL.md for the named agent. When overwrite is
91+
// false, an existing SOUL.md is preserved and the return value is false.
8492
func WriteSoul(cfg *config.Config, name, objective string, overwrite bool) (bool, error) {
8593
soulPath := HostSoulPath(cfg, name)
8694
if _, statErr := os.Lstat(soulPath); statErr == nil {
87-
if !overwrite {
95+
if !overwrite && pathHasExactBase(soulPath) {
8896
return false, nil
8997
}
9098
} else if !os.IsNotExist(statErr) {
91-
return false, fmt.Errorf("stat soul.md: %w", statErr)
99+
return false, fmt.Errorf("stat SOUL.md: %w", statErr)
100+
}
101+
102+
if !overwrite {
103+
copied, err := copyLegacySoulIfPresent(cfg, name, soulPath)
104+
if err != nil {
105+
return false, err
106+
}
107+
if copied {
108+
return true, nil
109+
}
92110
}
93111

94112
rendered, err := agentruntime.RenderSoul(objective)
95113
if err != nil {
96114
return false, fmt.Errorf("render soul: %w", err)
97115
}
98-
soulDir := filepath.Dir(soulPath)
116+
if err := writeSoulFileAtomically(soulPath, []byte(rendered)); err != nil {
117+
return false, err
118+
}
119+
return true, nil
120+
}
121+
122+
func pathHasExactBase(path string) bool {
123+
entries, err := os.ReadDir(filepath.Dir(path))
124+
if err != nil {
125+
return true
126+
}
127+
base := filepath.Base(path)
128+
for _, entry := range entries {
129+
if entry.Name() == base {
130+
return true
131+
}
132+
}
133+
return false
134+
}
135+
136+
func copyLegacySoulIfPresent(cfg *config.Config, name, soulPath string) (bool, error) {
137+
legacyPath := HostLegacySoulPath(cfg, name)
138+
if legacyPath == soulPath {
139+
return false, nil
140+
}
141+
info, err := os.Lstat(legacyPath)
142+
if err != nil {
143+
if os.IsNotExist(err) {
144+
return false, nil
145+
}
146+
return false, fmt.Errorf("stat legacy soul.md: %w", err)
147+
}
148+
if !info.Mode().IsRegular() {
149+
return false, nil
150+
}
151+
body, err := os.ReadFile(legacyPath)
152+
if err != nil {
153+
return false, fmt.Errorf("read legacy soul.md: %w", err)
154+
}
155+
if err := writeSoulFileAtomically(soulPath, body); err != nil {
156+
return false, err
157+
}
158+
return true, nil
159+
}
160+
161+
func writeSoulFileAtomically(path string, body []byte) error {
162+
soulDir := filepath.Dir(path)
99163
if err := os.MkdirAll(soulDir, 0o755); err != nil {
100-
return false, fmt.Errorf("create home dir: %w", err)
164+
return fmt.Errorf("create home dir: %w", err)
101165
}
102166
tmp, err := os.CreateTemp(soulDir, ".soul-*.tmp")
103167
if err != nil {
104-
return false, fmt.Errorf("create temp soul.md: %w", err)
168+
return fmt.Errorf("create temp SOUL.md: %w", err)
105169
}
106170
tmpPath := tmp.Name()
107171
defer os.Remove(tmpPath)
108172
if err := tmp.Chmod(0o600); err != nil {
109173
_ = tmp.Close()
110-
return false, fmt.Errorf("chmod temp soul.md: %w", err)
174+
return fmt.Errorf("chmod temp SOUL.md: %w", err)
111175
}
112-
if _, err := tmp.WriteString(rendered); err != nil {
176+
if _, err := tmp.Write(body); err != nil {
113177
_ = tmp.Close()
114-
return false, fmt.Errorf("write temp soul.md: %w", err)
178+
return fmt.Errorf("write temp SOUL.md: %w", err)
115179
}
116180
if err := tmp.Close(); err != nil {
117-
return false, fmt.Errorf("close temp soul.md: %w", err)
181+
return fmt.Errorf("close temp SOUL.md: %w", err)
118182
}
119-
if err := os.Rename(tmpPath, soulPath); err != nil {
120-
return false, fmt.Errorf("install soul.md: %w", err)
183+
if err := os.Rename(tmpPath, path); err != nil {
184+
return fmt.Errorf("install SOUL.md: %w", err)
121185
}
122-
return true, nil
186+
return nil
123187
}
124188

125189
func syncHostSkillsExact(dst string, names []string) error {

internal/agentcrd/agent_test.go

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,7 @@ func TestSeedHostFiles_FreshAgent(t *testing.T) {
133133
soul := HostSoulPath(cfg, "quant")
134134
body, err := os.ReadFile(soul)
135135
if err != nil {
136-
t.Fatalf("read soul.md: %v", err)
136+
t.Fatalf("read SOUL.md: %v", err)
137137
}
138138
if !strings.Contains(string(body), "You are a chain analyst") {
139139
t.Error("rendered soul missing operator objective")
@@ -150,7 +150,7 @@ func TestSeedHostFiles_PreservesExistingSoul(t *testing.T) {
150150
dir := t.TempDir()
151151
cfg := &config.Config{DataDir: dir}
152152

153-
// Pretend the agent has already self-edited its soul.md.
153+
// Pretend the agent has already self-edited its SOUL.md.
154154
if err := os.MkdirAll(filepath.Dir(HostSoulPath(cfg, "quant")), 0o755); err != nil {
155155
t.Fatal(err)
156156
}
@@ -168,15 +168,48 @@ func TestSeedHostFiles_PreservesExistingSoul(t *testing.T) {
168168
t.Fatalf("SeedHostFiles: %v", err)
169169
}
170170
if wrote {
171-
t.Error("expected soulWritten=false because soul.md already exists")
171+
t.Error("expected soulWritten=false because SOUL.md already exists")
172172
}
173173

174174
body, err := os.ReadFile(HostSoulPath(cfg, "quant"))
175175
if err != nil {
176176
t.Fatal(err)
177177
}
178178
if string(body) != string(custom) {
179-
t.Errorf("agent's soul.md was clobbered: got %q", string(body))
179+
t.Errorf("agent's SOUL.md was clobbered: got %q", string(body))
180+
}
181+
}
182+
183+
func TestSeedHostFiles_MigratesLegacyLowercaseSoul(t *testing.T) {
184+
dir := t.TempDir()
185+
cfg := &config.Config{DataDir: dir}
186+
187+
if err := os.MkdirAll(filepath.Dir(HostLegacySoulPath(cfg, "quant")), 0o755); err != nil {
188+
t.Fatal(err)
189+
}
190+
legacy := []byte("# legacy lowercase identity")
191+
if err := os.WriteFile(HostLegacySoulPath(cfg, "quant"), legacy, 0o600); err != nil {
192+
t.Fatal(err)
193+
}
194+
195+
wrote, err := SeedHostFiles(cfg, "quant",
196+
[]string{"addresses"},
197+
"This should not replace legacy identity",
198+
SeedOptions{},
199+
)
200+
if err != nil {
201+
t.Fatalf("SeedHostFiles: %v", err)
202+
}
203+
if !wrote {
204+
t.Error("expected soulWritten=true when migrating legacy soul.md")
205+
}
206+
207+
body, err := os.ReadFile(HostSoulPath(cfg, "quant"))
208+
if err != nil {
209+
t.Fatal(err)
210+
}
211+
if string(body) != string(legacy) {
212+
t.Errorf("legacy soul was not migrated verbatim: %q", string(body))
180213
}
181214
}
182215

@@ -306,7 +339,7 @@ func TestWriteSoul_ReplacesSymlinkWithoutTouchingTarget(t *testing.T) {
306339
if info, err := os.Lstat(HostSoulPath(cfg, "quant")); err != nil {
307340
t.Fatal(err)
308341
} else if info.Mode()&os.ModeSymlink != 0 {
309-
t.Fatal("WriteSoul left soul.md as a symlink instead of atomically replacing it")
342+
t.Fatal("WriteSoul left SOUL.md as a symlink instead of atomically replacing it")
310343
}
311344
}
312345

internal/agentruntime/soul.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import (
1313
// the operator's objective text, which is interpolated at the
1414
// {{ .OperatorObjective }} placeholder.
1515
//
16-
// Lifecycle: written exactly once by the seeder when soul.md does not yet
16+
// Lifecycle: written exactly once by the seeder when SOUL.md does not yet
1717
// exist on the agent's data PVC. After that the agent owns the file and
1818
// can rewrite it freely.
1919
const SoulTemplate = `# You are an Obol Stack sub-agent

internal/agentruntime/soul_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ func TestRenderSoul_TrimsObjectiveWhitespace(t *testing.T) {
3232
}
3333

3434
func TestRenderSoul_EmptyObjectiveRendersTemplate(t *testing.T) {
35-
// Empty objective should still produce a usable soul.md so callers can
35+
// Empty objective should still produce a usable SOUL.md so callers can
3636
// fall back to "you have no specific objective" agents in dev. CRD-level
3737
// validation enforces non-empty in production.
3838
out, err := RenderSoul("")

0 commit comments

Comments
 (0)