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.
3737func 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.
4951func 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.
5462type 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".
6977func 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.
8492func 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
125189func syncHostSkillsExact (dst string , names []string ) error {
0 commit comments