diff --git a/pkg/agent/context.go b/pkg/agent/context.go index 5a84c45e2..eba516c39 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -222,13 +222,10 @@ func (cb *ContextBuilder) InvalidateCache() { // invalidation (bootstrap files + memory). Skill roots are handled separately // because they require both directory-level and recursive file-level checks. func (cb *ContextBuilder) sourcePaths() []string { - return []string{ - filepath.Join(cb.workspace, "AGENTS.md"), - filepath.Join(cb.workspace, "SOUL.md"), - filepath.Join(cb.workspace, "USER.md"), - filepath.Join(cb.workspace, "IDENTITY.md"), - filepath.Join(cb.workspace, "memory", "MEMORY.md"), - } + agentDefinition := cb.LoadAgentDefinition() + paths := agentDefinition.trackedPaths(cb.workspace) + paths = append(paths, filepath.Join(cb.workspace, "memory", "MEMORY.md")) + return uniquePaths(paths) } // skillRoots returns all skill root directories that can affect @@ -432,18 +429,29 @@ func skillFilesChangedSince(skillRoots []string, filesAtCache map[string]time.Ti } func (cb *ContextBuilder) LoadBootstrapFiles() string { - bootstrapFiles := []string{ - "AGENTS.md", - "SOUL.md", - "USER.md", - "IDENTITY.md", + var sb strings.Builder + + agentDefinition := cb.LoadAgentDefinition() + if agentDefinition.Agent != nil { + label := string(agentDefinition.Source) + if label == "" { + label = relativeWorkspacePath(cb.workspace, agentDefinition.Agent.Path) + } + fmt.Fprintf(&sb, "## %s\n\n%s\n\n", label, agentDefinition.Agent.Body) + } + if agentDefinition.Soul != nil { + fmt.Fprintf( + &sb, + "## %s\n\n%s\n\n", + relativeWorkspacePath(cb.workspace, agentDefinition.Soul.Path), + agentDefinition.Soul.Content, + ) } - var sb strings.Builder - for _, filename := range bootstrapFiles { - filePath := filepath.Join(cb.workspace, filename) + if agentDefinition.Source != AgentDefinitionSourceAgent { + filePath := filepath.Join(cb.workspace, "IDENTITY.md") if data, err := os.ReadFile(filePath); err == nil { - fmt.Fprintf(&sb, "## %s\n\n%s\n\n", filename, data) + fmt.Fprintf(&sb, "## %s\n\n%s\n\n", "IDENTITY.md", data) } } diff --git a/pkg/agent/context_cache_test.go b/pkg/agent/context_cache_test.go index 707510820..1f9423a3a 100644 --- a/pkg/agent/context_cache_test.go +++ b/pkg/agent/context_cache_test.go @@ -37,7 +37,7 @@ func setupWorkspace(t *testing.T, files map[string]string) string { // Codex (only reads last system message as instructions). func TestSingleSystemMessage(t *testing.T) { tmpDir := setupWorkspace(t, map[string]string{ - "IDENTITY.md": "# Identity\nTest agent.", + "AGENT.md": "# Agent\nTest agent.", }) defer os.RemoveAll(tmpDir) @@ -140,10 +140,10 @@ func TestMtimeAutoInvalidation(t *testing.T) { }{ { name: "bootstrap file change", - file: "IDENTITY.md", - contentV1: "# Original Identity", - contentV2: "# Updated Identity", - checkField: "Updated Identity", + file: "AGENT.md", + contentV1: "# Original Agent", + contentV2: "# Updated Agent", + checkField: "Updated Agent", }, { name: "memory file change", @@ -218,7 +218,7 @@ func TestMtimeAutoInvalidation(t *testing.T) { // even when source files haven't changed (useful for tests and reload commands). func TestExplicitInvalidateCache(t *testing.T) { tmpDir := setupWorkspace(t, map[string]string{ - "IDENTITY.md": "# Test Identity", + "AGENT.md": "# Test Agent", }) defer os.RemoveAll(tmpDir) @@ -245,8 +245,8 @@ func TestExplicitInvalidateCache(t *testing.T) { // when no files change (regression test for issue #607). func TestCacheStability(t *testing.T) { tmpDir := setupWorkspace(t, map[string]string{ - "IDENTITY.md": "# Identity\nContent", - "SOUL.md": "# Soul\nContent", + "AGENT.md": "# Agent\nContent", + "SOUL.md": "# Soul\nContent", }) defer os.RemoveAll(tmpDir) @@ -545,7 +545,7 @@ description: delete-me-v1 // Run with: go test -race ./pkg/agent/ -run TestConcurrentBuildSystemPromptWithCache func TestConcurrentBuildSystemPromptWithCache(t *testing.T) { tmpDir := setupWorkspace(t, map[string]string{ - "IDENTITY.md": "# Identity\nConcurrency test agent.", + "AGENT.md": "# Agent\nConcurrency test agent.", "SOUL.md": "# Soul\nBe helpful.", "memory/MEMORY.md": "# Memory\nUser prefers Go.", "skills/demo/SKILL.md": "---\nname: demo\ndescription: \"demo skill\"\n---\n# Demo", @@ -652,7 +652,7 @@ func BenchmarkBuildMessagesWithCache(b *testing.B) { os.MkdirAll(filepath.Join(tmpDir, "memory"), 0o755) os.MkdirAll(filepath.Join(tmpDir, "skills"), 0o755) - for _, name := range []string{"IDENTITY.md", "SOUL.md", "USER.md"} { + for _, name := range []string{"AGENT.md", "SOUL.md"} { os.WriteFile(filepath.Join(tmpDir, name), []byte(strings.Repeat("Content.\n", 10)), 0o644) } diff --git a/pkg/agent/definition_test.go b/pkg/agent/definition_test.go new file mode 100644 index 000000000..e643dc047 --- /dev/null +++ b/pkg/agent/definition_test.go @@ -0,0 +1,189 @@ +package agent + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestLoadAgentDefinitionParsesFrontmatterAndSoul(t *testing.T) { + tmpDir := setupWorkspace(t, map[string]string{ + "AGENT.md": `--- +name: pico +description: Structured agent +model: claude-3-7-sonnet +tools: + - shell + - search +maxTurns: 8 +skills: + - review + - search-docs +mcpServers: + - github +metadata: + mode: strict +--- +# Agent + +Act directly and use tools first. +`, + "SOUL.md": "# Soul\nStay precise.", + }) + defer cleanupWorkspace(t, tmpDir) + + cb := NewContextBuilder(tmpDir) + definition := cb.LoadAgentDefinition() + + if definition.Source != AgentDefinitionSourceAgent { + t.Fatalf("expected source %q, got %q", AgentDefinitionSourceAgent, definition.Source) + } + if definition.Agent == nil { + t.Fatal("expected AGENT.md definition to be loaded") + } + if definition.Agent.Body == "" || !strings.Contains(definition.Agent.Body, "Act directly") { + t.Fatalf("expected AGENT.md body to be preserved, got %q", definition.Agent.Body) + } + if definition.Agent.Frontmatter.Name != "pico" { + t.Fatalf("expected name to be parsed, got %q", definition.Agent.Frontmatter.Name) + } + if definition.Agent.Frontmatter.Model != "claude-3-7-sonnet" { + t.Fatalf("expected model to be parsed, got %q", definition.Agent.Frontmatter.Model) + } + if len(definition.Agent.Frontmatter.Tools) != 2 { + t.Fatalf("expected tools to be parsed, got %v", definition.Agent.Frontmatter.Tools) + } + if definition.Agent.Frontmatter.MaxTurns == nil || *definition.Agent.Frontmatter.MaxTurns != 8 { + t.Fatalf("expected maxTurns to be parsed, got %v", definition.Agent.Frontmatter.MaxTurns) + } + if len(definition.Agent.Frontmatter.Skills) != 2 { + t.Fatalf("expected skills to be parsed, got %v", definition.Agent.Frontmatter.Skills) + } + if len(definition.Agent.Frontmatter.MCPServers) != 1 || definition.Agent.Frontmatter.MCPServers[0] != "github" { + t.Fatalf("expected mcpServers to be parsed, got %v", definition.Agent.Frontmatter.MCPServers) + } + if definition.Agent.Frontmatter.Fields["metadata"] == nil { + t.Fatal("expected arbitrary frontmatter fields to remain available") + } + + if definition.Soul == nil { + t.Fatal("expected SOUL.md to be loaded") + } + if !strings.Contains(definition.Soul.Content, "Stay precise") { + t.Fatalf("expected soul content to be loaded, got %q", definition.Soul.Content) + } + if definition.Soul.Path != filepath.Join(tmpDir, "SOUL.md") { + t.Fatalf("expected default SOUL.md path, got %q", definition.Soul.Path) + } +} + +func TestLoadAgentDefinitionFallsBackToLegacyAgentsMarkdown(t *testing.T) { + tmpDir := setupWorkspace(t, map[string]string{ + "AGENTS.md": "# Legacy Agent\nKeep compatibility.", + "SOUL.md": "# Soul\nLegacy soul.", + }) + defer cleanupWorkspace(t, tmpDir) + + cb := NewContextBuilder(tmpDir) + definition := cb.LoadAgentDefinition() + + if definition.Source != AgentDefinitionSourceAgents { + t.Fatalf("expected source %q, got %q", AgentDefinitionSourceAgents, definition.Source) + } + if definition.Agent == nil { + t.Fatal("expected AGENTS.md to be loaded") + } + if definition.Agent.RawFrontmatter != "" { + t.Fatalf("legacy AGENTS.md should not have frontmatter, got %q", definition.Agent.RawFrontmatter) + } + if !strings.Contains(definition.Agent.Body, "Keep compatibility") { + t.Fatalf("expected legacy body to be preserved, got %q", definition.Agent.Body) + } + if definition.Soul == nil || !strings.Contains(definition.Soul.Content, "Legacy soul") { + t.Fatal("expected default SOUL.md to be loaded for legacy format") + } +} + +func TestLoadBootstrapFilesUsesAgentBodyNotFrontmatter(t *testing.T) { + tmpDir := setupWorkspace(t, map[string]string{ + "AGENT.md": `--- +name: pico +model: codex-mini +--- +# Agent + +Follow the body prompt. +`, + "SOUL.md": "# Soul\nSpeak plainly.", + "IDENTITY.md": "# Identity\nWorkspace identity.", + }) + defer cleanupWorkspace(t, tmpDir) + + cb := NewContextBuilder(tmpDir) + bootstrap := cb.LoadBootstrapFiles() + + if !strings.Contains(bootstrap, "Follow the body prompt") { + t.Fatalf("expected AGENT.md body in bootstrap, got %q", bootstrap) + } + if !strings.Contains(bootstrap, "Speak plainly") { + t.Fatalf("expected resolved soul content in bootstrap, got %q", bootstrap) + } + if strings.Contains(bootstrap, "name: pico") { + t.Fatalf("bootstrap should not expose raw frontmatter, got %q", bootstrap) + } + if strings.Contains(bootstrap, "model: codex-mini") { + t.Fatalf("bootstrap should not expose raw frontmatter, got %q", bootstrap) + } + if !strings.Contains(bootstrap, "SOUL.md") { + t.Fatalf("expected bootstrap to label SOUL.md, got %q", bootstrap) + } + if strings.Contains(bootstrap, "Workspace identity") { + t.Fatalf("structured bootstrap should ignore IDENTITY.md, got %q", bootstrap) + } +} + +func TestStructuredAgentIgnoresIdentityChanges(t *testing.T) { + tmpDir := setupWorkspace(t, map[string]string{ + "AGENT.md": "# Agent\nFollow the new structure.", + "SOUL.md": "# Soul\nVersion one.", + "IDENTITY.md": "# Identity\nLegacy identity.", + }) + defer cleanupWorkspace(t, tmpDir) + + cb := NewContextBuilder(tmpDir) + + promptV1 := cb.BuildSystemPromptWithCache() + if strings.Contains(promptV1, "Legacy identity") { + t.Fatalf("structured prompt should not include IDENTITY.md, got %q", promptV1) + } + + identityPath := filepath.Join(tmpDir, "IDENTITY.md") + if err := os.WriteFile(identityPath, []byte("# Identity\nVersion two."), 0o644); err != nil { + t.Fatal(err) + } + future := time.Now().Add(2 * time.Second) + if err := os.Chtimes(identityPath, future, future); err != nil { + t.Fatal(err) + } + + cb.systemPromptMutex.RLock() + changed := cb.sourceFilesChangedLocked() + cb.systemPromptMutex.RUnlock() + if changed { + t.Fatal("IDENTITY.md should not invalidate cache for structured agent definitions") + } + + promptV2 := cb.BuildSystemPromptWithCache() + if promptV1 != promptV2 { + t.Fatal("structured prompt should remain stable after IDENTITY.md changes") + } +} + +func cleanupWorkspace(t *testing.T, path string) { + t.Helper() + if err := os.RemoveAll(path); err != nil { + t.Fatalf("failed to clean up workspace %s: %v", path, err) + } +}