refactor(agent): build context from structured agent files
Use AGENT.md and SOUL.md as the structured bootstrap source, ignore IDENTITY.md for structured agents, remove USER.md from the new context flow, and update pkg/agent tests accordingly. Refs #1218
This commit is contained in:
parent
237fb99186
commit
524ec315ff
3 changed files with 223 additions and 26 deletions
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,7 +245,7 @@ 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",
|
||||
"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)
|
||||
}
|
||||
|
||||
|
|
|
|||
189
pkg/agent/definition_test.go
Normal file
189
pkg/agent/definition_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue