Skip to content

Commit fc7368d

Browse files
authored
agenthome: unify persona contract in AGENTS.md (#749)
1 parent fb4bbb8 commit fc7368d

File tree

26 files changed

+283
-171
lines changed

26 files changed

+283
-171
lines changed

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ _ = os.Remove(tempFile)
6868
## Agent-Specific Notes
6969

7070
Holon runtime is now centered on `agent_home`:
71-
- persona and role files live under agent home (for example `AGENTS.md`, `ROLE.md`)
71+
- the canonical persona contract lives under agent home in `AGENTS.md` (with `CLAUDE.md` as a compatibility pointer)
7272
- runtime/skill behavior should rely on runtime contract variables and system-recommended directories
7373
- skills should avoid hardcoded Holon-specific filesystem paths and prefer runtime contract environment variables (for example `HOLON_OUTPUT_DIR`, `HOLON_STATE_DIR`, `HOLON_WORKSPACE_DIR`) instead of literal paths
7474

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ Current product split:
1212
## Agent Home Model
1313

1414
`agent_home` is the long-lived identity and state root for an agent instance:
15-
- persona files (`AGENTS.md`, `ROLE.md`, `IDENTITY.md`, `SOUL.md`, `CLAUDE.md`)
15+
- the persona contract (`AGENTS.md`) plus `CLAUDE.md` as a compatibility pointer
1616
- runtime state and caches
1717
- job outputs and other runtime-managed artifacts (which may be associated with per-job workspaces)
1818
- optional runtime configuration

cmd/holon/agent.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -405,7 +405,7 @@ func init() {
405405
agentInitCmd.Flags().StringVar(&agentInitID, "agent-id", "main", "Agent ID (default: main)")
406406
agentInitCmd.Flags().StringVar(&agentInitHome, "agent-home", "", "Agent home directory (overrides --agent-id)")
407407
agentInitCmd.Flags().StringVar(&agentInitTemplate, "template", agenthome.DefaultTemplate, "Persona template: default, github-solver, autonomous")
408-
agentInitCmd.Flags().BoolVar(&agentInitForce, "force", false, "Overwrite existing persona files (AGENTS.md/ROLE.md/IDENTITY.md/SOUL.md/CLAUDE.md)")
408+
agentInitCmd.Flags().BoolVar(&agentInitForce, "force", false, "Overwrite persona files (AGENTS.md/CLAUDE.md) and remove legacy persona files")
409409

410410
agentInstallCmd.Flags().String("name", "", "Alias name for the agent bundle (required)")
411411
_ = agentInstallCmd.MarkFlagRequired("name")

cmd/holon/serve.go

Lines changed: 8 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"syscall"
2121
"time"
2222

23+
"github.com/holon-run/holon/pkg/agenthome"
2324
holonlog "github.com/holon-run/holon/pkg/log"
2425
"github.com/holon-run/holon/pkg/prompt"
2526
"github.com/holon-run/holon/pkg/runtime/docker"
@@ -114,7 +115,7 @@ for local development and testing.`,
114115
if err != nil {
115116
return err
116117
}
117-
roleSource := filepath.Join(agentResolution.AgentHome, "ROLE.md")
118+
roleSource := filepath.Join(agentResolution.AgentHome, "AGENTS.md")
118119

119120
handler, err := newCLIControllerHandler(
120121
serveRepo,
@@ -369,72 +370,24 @@ for local development and testing.`,
369370
}
370371

371372
func loadControllerRole(agentHome string) (string, error) {
372-
rolePath := filepath.Join(agentHome, "ROLE.md")
373-
info, err := os.Stat(rolePath)
373+
persona, err := agenthome.LoadPersona(agentHome)
374374
if err != nil {
375-
return "", fmt.Errorf("failed to stat %s: %w", rolePath, err)
376-
}
377-
if !info.Mode().IsRegular() {
378-
return "", fmt.Errorf("role prompt path is not a regular file: %s", rolePath)
379-
}
380-
data, err := os.ReadFile(rolePath)
381-
if err != nil {
382-
return "", fmt.Errorf("failed to read role prompt %s: %w", rolePath, err)
383-
}
384-
content := strings.TrimSpace(string(data))
385-
if content == "" {
386-
return "", fmt.Errorf("role prompt file is empty: %s (please add a role definition, e.g., '# ROLE: PM')", rolePath)
375+
return "", err
387376
}
388-
return inferControllerRole(content), nil
377+
return controllerRoleLabelForPersona(persona.Role), nil
389378
}
390379

391-
func inferControllerRole(content string) string {
392-
lower := strings.ToLower(content)
393-
if role := inferRoleFromFrontMatter(lower); role != "" {
394-
return role
395-
}
380+
func controllerRoleLabelForPersona(role string) string {
396381
switch {
397-
case strings.Contains(lower, "role: dev"),
398-
strings.Contains(lower, "role dev"),
399-
strings.Contains(lower, "developer"),
400-
strings.Contains(lower, "software engineer"):
382+
case role == "executor", role == "github_solver":
401383
return "dev"
402-
case strings.Contains(lower, "role: pm"),
403-
strings.Contains(lower, "role pm"),
404-
strings.Contains(lower, "product manager"):
384+
case role == "pm", role == "autonomous":
405385
return "pm"
406386
default:
407387
return "pm"
408388
}
409389
}
410390

411-
func inferRoleFromFrontMatter(lower string) string {
412-
trimmed := strings.TrimSpace(lower)
413-
if !strings.HasPrefix(trimmed, "---\n") {
414-
return ""
415-
}
416-
lines := strings.Split(trimmed, "\n")
417-
for i := 1; i < len(lines); i++ {
418-
line := strings.TrimSpace(lines[i])
419-
if line == "---" {
420-
return ""
421-
}
422-
if !strings.HasPrefix(line, "role:") {
423-
continue
424-
}
425-
role := strings.TrimSpace(strings.TrimPrefix(line, "role:"))
426-
switch role {
427-
case "pm", "product-manager", "product_manager":
428-
return "pm"
429-
case "dev", "developer", "engineer":
430-
return "dev"
431-
default:
432-
return ""
433-
}
434-
}
435-
return ""
436-
}
437-
438391
func startServeTickEmitter(ctx context.Context, interval time.Duration, repo string, sink func(context.Context, serve.EventEnvelope) error) {
439392
if interval <= 0 {
440393
return

cmd/holon/serve_test.go

Lines changed: 22 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -495,7 +495,7 @@ func TestBuildServeStartupDiagnostics_SubscriptionRPCOnly(t *testing.T) {
495495
StateDir: "/tmp/agent/state",
496496
Workspace: "/tmp/agent/workspace",
497497
ConfigSource: "/tmp/agent/agent.yaml",
498-
RoleSource: "/tmp/agent/ROLE.md",
498+
RoleSource: "/tmp/agent/AGENTS.md",
499499
RoleInferred: "pm",
500500
ServeInput: "-",
501501
InputMode: "subscription",
@@ -515,7 +515,7 @@ func TestBuildServeStartupDiagnostics_SubscriptionRPCOnly(t *testing.T) {
515515
if diag.SubscriptionReason != "empty_repos" {
516516
t.Fatalf("subscription_reason = %q, want empty_repos", diag.SubscriptionReason)
517517
}
518-
if diag.RoleSource != "/tmp/agent/ROLE.md" {
518+
if diag.RoleSource != "/tmp/agent/AGENTS.md" {
519519
t.Fatalf("role_source = %q", diag.RoleSource)
520520
}
521521
if diag.RoleInferred != "pm" {
@@ -579,7 +579,7 @@ func TestWriteServeStartupDiagnostics(t *testing.T) {
579579
td := t.TempDir()
580580
diag := serveStartupDiagnostics{
581581
AgentID: "main",
582-
RoleSource: filepath.Join(td, "ROLE.md"),
582+
RoleSource: filepath.Join(td, "AGENTS.md"),
583583
RoleInferred: "pm",
584584
Preview: "experimental",
585585
}
@@ -961,20 +961,20 @@ func TestClose_StopsControllerAndRemovesSocket(t *testing.T) {
961961
}
962962
}
963963

964-
func TestInferControllerRole(t *testing.T) {
964+
func TestControllerRoleLabelForPersona(t *testing.T) {
965965
t.Parallel()
966966

967-
if got := inferControllerRole("ROLE: PM\nProduct manager"); got != "pm" {
968-
t.Fatalf("infer pm = %q", got)
967+
tests := map[string]string{
968+
"pm": "pm",
969+
"autonomous": "pm",
970+
"executor": "dev",
971+
"github_solver": "dev",
972+
"unknown": "pm",
969973
}
970-
if got := inferControllerRole("ROLE: DEV\nSoftware engineer"); got != "dev" {
971-
t.Fatalf("infer dev = %q", got)
972-
}
973-
if got := inferControllerRole("unknown"); got != "pm" {
974-
t.Fatalf("infer default = %q", got)
975-
}
976-
if got := inferControllerRole("---\nrole: dev\n---\nbody"); got != "dev" {
977-
t.Fatalf("infer frontmatter dev = %q", got)
974+
for role, want := range tests {
975+
if got := controllerRoleLabelForPersona(role); got != want {
976+
t.Fatalf("controllerRoleLabelForPersona(%q) = %q, want %q", role, got, want)
977+
}
978978
}
979979
}
980980

@@ -1058,9 +1058,10 @@ func TestLoadControllerRole(t *testing.T) {
10581058
t.Parallel()
10591059

10601060
agentHome := t.TempDir()
1061-
rolePath := filepath.Join(agentHome, "ROLE.md")
1062-
if err := os.WriteFile(rolePath, []byte("ROLE: DEV\n"), 0o644); err != nil {
1063-
t.Fatalf("write role: %v", err)
1061+
agentsPath := filepath.Join(agentHome, "AGENTS.md")
1062+
content := "---\npersona_contract: v2\nrole: executor\n---\n# AGENTS.md\nbody\n"
1063+
if err := os.WriteFile(agentsPath, []byte(content), 0o644); err != nil {
1064+
t.Fatalf("write AGENTS.md: %v", err)
10641065
}
10651066
roleLabel, err := loadControllerRole(agentHome)
10661067
if err != nil {
@@ -1075,12 +1076,12 @@ func TestLoadControllerRole_EmptyFile(t *testing.T) {
10751076
t.Parallel()
10761077

10771078
agentHome := t.TempDir()
1078-
rolePath := filepath.Join(agentHome, "ROLE.md")
1079-
if err := os.WriteFile(rolePath, []byte(" \n"), 0o644); err != nil {
1080-
t.Fatalf("write role: %v", err)
1079+
agentsPath := filepath.Join(agentHome, "AGENTS.md")
1080+
if err := os.WriteFile(agentsPath, []byte(" \n"), 0o644); err != nil {
1081+
t.Fatalf("write AGENTS.md: %v", err)
10811082
}
10821083
if _, err := loadControllerRole(agentHome); err == nil {
1083-
t.Fatalf("expected error for empty ROLE.md")
1084+
t.Fatalf("expected error for empty AGENTS.md")
10841085
}
10851086
}
10861087

docs/agent-home-persona-redesign.md

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@ Current `holon agent init` persona files are too thin and inconsistent:
1717
- `github-solver`
1818
- `autonomous`
1919
4. Keep runtime behavior aligned with agent-home model:
20-
- Holon contract explains file responsibilities
21-
- Persona files are read/written by agent directly from `HOLON_AGENT_HOME`
20+
- Holon contract explains the `AGENTS.md` responsibility
21+
- Persona is read/written by agent directly from `HOLON_AGENT_HOME`
2222
- Holon does not inline persona content into the system prompt
2323

2424
## Non-Goals
@@ -31,13 +31,10 @@ Current `holon agent init` persona files are too thin and inconsistent:
3131

3232
### 1. Persona File Set
3333

34-
`holon agent init` and `EnsureLayout*` generate the following files:
34+
`holon agent init` and `EnsureLayout*` generate the following persona files:
3535

36-
- `AGENTS.md` (canonical persona and operating protocol)
36+
- `AGENTS.md` (canonical persona contract, including role front matter and operating protocol)
3737
- `CLAUDE.md` (compatibility pointer to `AGENTS.md`)
38-
- `ROLE.md` (current mission and scope)
39-
- `IDENTITY.md` (identity and collaboration defaults)
40-
- `SOUL.md` (decision principles)
4138

4239
`CLAUDE.md` stays minimal and must not become a second source of truth.
4340

@@ -73,7 +70,7 @@ Refactor `pkg/prompt/assets/contracts/common.md` into explicit sections:
7370
5. Reporting requirements
7471
6. Context handling
7572

76-
The contract should reference `AGENTS.md` (not `AGENT.md`) and reinforce that persona files are loaded from `HOLON_AGENT_HOME`.
73+
The contract should reference `AGENTS.md` (not `AGENT.md`) and reinforce that persona is loaded from `HOLON_AGENT_HOME`.
7774

7875
## Implementation Plan
7976

docs/agent-home-unification.md

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -55,16 +55,13 @@
5555
2. 可演进层(位于 `agent_home`,agent 可改)
5656
- `AGENTS.md`
5757
- `CLAUDE.md`(兼容入口,指向 `AGENTS.md`
58-
- `ROLE.md`
59-
- `IDENTITY.md`
60-
- `SOUL.md`
6158

6259
3. 运行时组装
6360
- 每轮重建 system prompt:
6461
1) immutable contract
6562
2) mode overlay (`run` / `serve`)
6663
3) runtime/tool/event context
67-
4) agent-home persona files
64+
4) agent-home persona contract
6865

6966
### 3.5 workspace 抽象(与 agent_home 解耦)
7067

@@ -110,9 +107,6 @@
110107
agent.yaml
111108
AGENTS.md
112109
CLAUDE.md
113-
ROLE.md
114-
IDENTITY.md
115-
SOUL.md
116110
state/
117111
serve-state.json
118112
goal-state.json
@@ -186,9 +180,6 @@ runtime:
186180
prompt:
187181
persona_files:
188182
- AGENTS.md
189-
- ROLE.md
190-
- IDENTITY.md
191-
- SOUL.md
192183
system_contract: builtin://contracts/serve
193184
event_source:
194185
type: github-webhook
@@ -221,7 +212,7 @@ event_source:
221212
### Phase 2: Prompt Loader 统一
222213

223214
1. 新增统一 prompt 组装器
224-
- 输入:mode + runtime + tools + persona files
215+
- 输入:mode + runtime + tools + persona contract
225216
- 输出:system/user prompt
226217

227218
2. 将 `run/solve/serve` 改为共用同一组装器

docs/architecture-current.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
Holon executes agents in a sandboxed container runtime and centers persistence on `agent_home`.
1212

1313
`agent_home` holds:
14-
- agent identity/persona files (`AGENTS.md`, `ROLE.md`, `IDENTITY.md`, `SOUL.md`, `CLAUDE.md`)
14+
- the canonical persona contract (`AGENTS.md`) and `CLAUDE.md` compatibility pointer
1515
- persistent runtime state
1616
- caches
1717
- long-lived agent/session metadata and optional job input/output history (for example logs, manifests, indexes)

docs/development.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ Use `holon agent …` to inspect and manage agent bundles/aliases:
127127
- `holon agent info default`
128128
- `holon agent init --template <default|github-solver|autonomous> [--force]`
129129

130-
`holon agent init` templates are init-time only. Runtime prompts do not inline persona file bodies from `AGENTS.md/ROLE.md/IDENTITY.md/SOUL.md`; the agent reads them from `HOLON_AGENT_HOME`.
130+
`holon agent init` templates are init-time only. Runtime prompts do not inline persona file bodies; the agent reads `AGENTS.md` from `HOLON_AGENT_HOME`.
131131

132132
Builtin agent resolution notes:
133133
- If no explicit agent is provided, Holon can resolve an agent via `--agent-channel` / `HOLON_AGENT_CHANNEL` (default: `latest`).

docs/serve-webhook.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,8 +85,8 @@ holon serve \
8585
On startup, `holon serve` writes `${state_dir}/serve-startup-diagnostics.json` and logs the same diagnostic snapshot.
8686

8787
Key fields:
88-
- `role_source`: Always `${agent_home}/ROLE.md` (single source of truth)
89-
- `role_inferred`: Role inferred from `ROLE.md` content (`pm`/`dev`)
88+
- `role_source`: Always `${agent_home}/AGENTS.md` (single source of truth)
89+
- `role_inferred`: Controller role derived from `AGENTS.md` front matter (`pm`/`dev`)
9090
- `config_source`: `${agent_home}/agent.yaml`
9191
- `input_mode`: `subscription`, `webhook_legacy`, or `stdin_file`
9292
- `transport_mode`: `gh_forward`, `websocket`, `webhook`, `rpc_only`, or `none`

0 commit comments

Comments
 (0)