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
|
// invalidation (bootstrap files + memory). Skill roots are handled separately
|
||||||
// because they require both directory-level and recursive file-level checks.
|
// because they require both directory-level and recursive file-level checks.
|
||||||
func (cb *ContextBuilder) sourcePaths() []string {
|
func (cb *ContextBuilder) sourcePaths() []string {
|
||||||
return []string{
|
agentDefinition := cb.LoadAgentDefinition()
|
||||||
filepath.Join(cb.workspace, "AGENTS.md"),
|
paths := agentDefinition.trackedPaths(cb.workspace)
|
||||||
filepath.Join(cb.workspace, "SOUL.md"),
|
paths = append(paths, filepath.Join(cb.workspace, "memory", "MEMORY.md"))
|
||||||
filepath.Join(cb.workspace, "USER.md"),
|
return uniquePaths(paths)
|
||||||
filepath.Join(cb.workspace, "IDENTITY.md"),
|
|
||||||
filepath.Join(cb.workspace, "memory", "MEMORY.md"),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// skillRoots returns all skill root directories that can affect
|
// 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 {
|
func (cb *ContextBuilder) LoadBootstrapFiles() string {
|
||||||
bootstrapFiles := []string{
|
var sb strings.Builder
|
||||||
"AGENTS.md",
|
|
||||||
"SOUL.md",
|
agentDefinition := cb.LoadAgentDefinition()
|
||||||
"USER.md",
|
if agentDefinition.Agent != nil {
|
||||||
"IDENTITY.md",
|
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
|
if agentDefinition.Source != AgentDefinitionSourceAgent {
|
||||||
for _, filename := range bootstrapFiles {
|
filePath := filepath.Join(cb.workspace, "IDENTITY.md")
|
||||||
filePath := filepath.Join(cb.workspace, filename)
|
|
||||||
if data, err := os.ReadFile(filePath); err == nil {
|
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).
|
// Codex (only reads last system message as instructions).
|
||||||
func TestSingleSystemMessage(t *testing.T) {
|
func TestSingleSystemMessage(t *testing.T) {
|
||||||
tmpDir := setupWorkspace(t, map[string]string{
|
tmpDir := setupWorkspace(t, map[string]string{
|
||||||
"IDENTITY.md": "# Identity\nTest agent.",
|
"AGENT.md": "# Agent\nTest agent.",
|
||||||
})
|
})
|
||||||
defer os.RemoveAll(tmpDir)
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
|
@ -140,10 +140,10 @@ func TestMtimeAutoInvalidation(t *testing.T) {
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "bootstrap file change",
|
name: "bootstrap file change",
|
||||||
file: "IDENTITY.md",
|
file: "AGENT.md",
|
||||||
contentV1: "# Original Identity",
|
contentV1: "# Original Agent",
|
||||||
contentV2: "# Updated Identity",
|
contentV2: "# Updated Agent",
|
||||||
checkField: "Updated Identity",
|
checkField: "Updated Agent",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "memory file change",
|
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).
|
// even when source files haven't changed (useful for tests and reload commands).
|
||||||
func TestExplicitInvalidateCache(t *testing.T) {
|
func TestExplicitInvalidateCache(t *testing.T) {
|
||||||
tmpDir := setupWorkspace(t, map[string]string{
|
tmpDir := setupWorkspace(t, map[string]string{
|
||||||
"IDENTITY.md": "# Test Identity",
|
"AGENT.md": "# Test Agent",
|
||||||
})
|
})
|
||||||
defer os.RemoveAll(tmpDir)
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
|
@ -245,7 +245,7 @@ func TestExplicitInvalidateCache(t *testing.T) {
|
||||||
// when no files change (regression test for issue #607).
|
// when no files change (regression test for issue #607).
|
||||||
func TestCacheStability(t *testing.T) {
|
func TestCacheStability(t *testing.T) {
|
||||||
tmpDir := setupWorkspace(t, map[string]string{
|
tmpDir := setupWorkspace(t, map[string]string{
|
||||||
"IDENTITY.md": "# Identity\nContent",
|
"AGENT.md": "# Agent\nContent",
|
||||||
"SOUL.md": "# Soul\nContent",
|
"SOUL.md": "# Soul\nContent",
|
||||||
})
|
})
|
||||||
defer os.RemoveAll(tmpDir)
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
@ -545,7 +545,7 @@ description: delete-me-v1
|
||||||
// Run with: go test -race ./pkg/agent/ -run TestConcurrentBuildSystemPromptWithCache
|
// Run with: go test -race ./pkg/agent/ -run TestConcurrentBuildSystemPromptWithCache
|
||||||
func TestConcurrentBuildSystemPromptWithCache(t *testing.T) {
|
func TestConcurrentBuildSystemPromptWithCache(t *testing.T) {
|
||||||
tmpDir := setupWorkspace(t, map[string]string{
|
tmpDir := setupWorkspace(t, map[string]string{
|
||||||
"IDENTITY.md": "# Identity\nConcurrency test agent.",
|
"AGENT.md": "# Agent\nConcurrency test agent.",
|
||||||
"SOUL.md": "# Soul\nBe helpful.",
|
"SOUL.md": "# Soul\nBe helpful.",
|
||||||
"memory/MEMORY.md": "# Memory\nUser prefers Go.",
|
"memory/MEMORY.md": "# Memory\nUser prefers Go.",
|
||||||
"skills/demo/SKILL.md": "---\nname: demo\ndescription: \"demo skill\"\n---\n# Demo",
|
"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, "memory"), 0o755)
|
||||||
os.MkdirAll(filepath.Join(tmpDir, "skills"), 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)
|
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