mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
899558bbfa
* feat(agent): add structured agent definition loader Parse AGENT.md frontmatter into a runtime definition and pair it with SOUL.md while keeping a legacy AGENTS.md fallback for transition. Refs #1218 * 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 * refactor(onboard): switch workspace templates to AGENT.md Replace the legacy AGENTS.md, IDENTITY.md, and USER.md templates with a structured AGENT.md plus SOUL.md, and update the onboard template test to assert the new generated files. Refs #1218 * docs(readme): update workspace layout for AGENT.md Refresh the documented workspace tree across the README translations so onboarding now points to AGENT.md and SOUL.md instead of the retired AGENTS.md, IDENTITY.md, and USER.md files. Refs #1218 * feat(agent): restore workspace USER.md context * docs(readme): document workspace USER.md layout * fix: sort agent definition imports for gci
303 lines
9.5 KiB
Go
303 lines
9.5 KiB
Go
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 TestLoadAgentDefinitionLoadsWorkspaceUserMarkdown(t *testing.T) {
|
|
tmpDir := setupWorkspace(t, map[string]string{
|
|
"AGENT.md": "# Agent\nStructured agent.",
|
|
"USER.md": "# User\nWorkspace preferences.",
|
|
})
|
|
defer cleanupWorkspace(t, tmpDir)
|
|
|
|
cb := NewContextBuilder(tmpDir)
|
|
definition := cb.LoadAgentDefinition()
|
|
|
|
if definition.User == nil {
|
|
t.Fatal("expected USER.md to be loaded")
|
|
}
|
|
if definition.User.Path != filepath.Join(tmpDir, "USER.md") {
|
|
t.Fatalf("expected workspace USER.md path, got %q", definition.User.Path)
|
|
}
|
|
if !strings.Contains(definition.User.Content, "Workspace preferences") {
|
|
t.Fatalf("expected workspace USER.md content, got %q", definition.User.Content)
|
|
}
|
|
}
|
|
|
|
func TestLoadAgentDefinitionInvalidFrontmatterFallsBackToEmptyStructuredFields(t *testing.T) {
|
|
tmpDir := setupWorkspace(t, map[string]string{
|
|
"AGENT.md": `---
|
|
name: pico
|
|
tools:
|
|
- shell
|
|
broken
|
|
---
|
|
# Agent
|
|
|
|
Keep going.
|
|
`,
|
|
})
|
|
defer cleanupWorkspace(t, tmpDir)
|
|
|
|
cb := NewContextBuilder(tmpDir)
|
|
definition := cb.LoadAgentDefinition()
|
|
|
|
if definition.Agent == nil {
|
|
t.Fatal("expected AGENT.md definition to be loaded")
|
|
}
|
|
if !strings.Contains(definition.Agent.Body, "Keep going.") {
|
|
t.Fatalf("expected AGENT.md body to be preserved, got %q", definition.Agent.Body)
|
|
}
|
|
if definition.Agent.Frontmatter.Name != "" ||
|
|
definition.Agent.Frontmatter.Description != "" ||
|
|
definition.Agent.Frontmatter.Model != "" ||
|
|
definition.Agent.Frontmatter.MaxTurns != nil ||
|
|
len(definition.Agent.Frontmatter.Tools) != 0 ||
|
|
len(definition.Agent.Frontmatter.Skills) != 0 ||
|
|
len(definition.Agent.Frontmatter.MCPServers) != 0 ||
|
|
len(definition.Agent.Frontmatter.Fields) != 0 {
|
|
t.Fatalf("expected invalid frontmatter to decode as empty struct, got %+v", definition.Agent.Frontmatter)
|
|
}
|
|
}
|
|
|
|
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 TestLoadBootstrapFilesIncludesWorkspaceUserMarkdown(t *testing.T) {
|
|
tmpDir := setupWorkspace(t, map[string]string{
|
|
"AGENT.md": "# Agent\nFollow the new structure.",
|
|
"SOUL.md": "# Soul\nSpeak plainly.",
|
|
"USER.md": "# User\nShared profile.",
|
|
})
|
|
defer cleanupWorkspace(t, tmpDir)
|
|
|
|
cb := NewContextBuilder(tmpDir)
|
|
bootstrap := cb.LoadBootstrapFiles()
|
|
|
|
if !strings.Contains(bootstrap, "Shared profile") {
|
|
t.Fatalf("expected workspace USER.md in bootstrap, got %q", bootstrap)
|
|
}
|
|
if !strings.Contains(bootstrap, "## USER.md") {
|
|
t.Fatalf("expected USER.md heading in bootstrap, 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 TestStructuredAgentUserChangesInvalidateCache(t *testing.T) {
|
|
tmpDir := setupWorkspace(t, map[string]string{
|
|
"AGENT.md": "# Agent\nFollow the new structure.",
|
|
"SOUL.md": "# Soul\nVersion one.",
|
|
"USER.md": "# User\nInitial workspace preferences.",
|
|
})
|
|
defer cleanupWorkspace(t, tmpDir)
|
|
|
|
cb := NewContextBuilder(tmpDir)
|
|
|
|
promptV1 := cb.BuildSystemPromptWithCache()
|
|
if !strings.Contains(promptV1, "Initial workspace preferences") {
|
|
t.Fatalf("expected workspace USER.md in prompt, got %q", promptV1)
|
|
}
|
|
|
|
userPath := filepath.Join(tmpDir, "USER.md")
|
|
if err := os.WriteFile(userPath, []byte("# User\nUpdated workspace preferences."), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
future := time.Now().Add(2 * time.Second)
|
|
if err := os.Chtimes(userPath, future, future); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
cb.systemPromptMutex.RLock()
|
|
changed := cb.sourceFilesChangedLocked()
|
|
cb.systemPromptMutex.RUnlock()
|
|
if !changed {
|
|
t.Fatal("workspace USER.md changes should invalidate cache")
|
|
}
|
|
|
|
promptV2 := cb.BuildSystemPromptWithCache()
|
|
if !strings.Contains(promptV2, "Updated workspace preferences") {
|
|
t.Fatalf("expected updated workspace USER.md in prompt, got %q", promptV2)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|