feat(agent): restore workspace USER.md context

This commit is contained in:
Hoshina 2026-03-18 11:15:47 +08:00
parent e5974ab804
commit 5529e3962d
5 changed files with 175 additions and 3 deletions

View file

@ -23,7 +23,12 @@ func TestCopyEmbeddedToTargetUsesStructuredAgentFiles(t *testing.T) {
t.Fatalf("expected %s to exist: %v", soulPath, err) t.Fatalf("expected %s to exist: %v", soulPath, err)
} }
for _, legacyName := range []string{"AGENTS.md", "IDENTITY.md", "USER.md"} { userPath := filepath.Join(targetDir, "USER.md")
if _, err := os.Stat(userPath); err != nil {
t.Fatalf("expected %s to exist: %v", userPath, err)
}
for _, legacyName := range []string{"AGENTS.md", "IDENTITY.md"} {
legacyPath := filepath.Join(targetDir, legacyName) legacyPath := filepath.Join(targetDir, legacyName)
if _, err := os.Stat(legacyPath); !os.IsNotExist(err) { if _, err := os.Stat(legacyPath); !os.IsNotExist(err) {
t.Fatalf("expected legacy file %s to be absent, got err=%v", legacyPath, err) t.Fatalf("expected legacy file %s to be absent, got err=%v", legacyPath, err)

View file

@ -447,6 +447,9 @@ func (cb *ContextBuilder) LoadBootstrapFiles() string {
agentDefinition.Soul.Content, agentDefinition.Soul.Content,
) )
} }
if agentDefinition.User != nil {
fmt.Fprintf(&sb, "## %s\n\n%s\n\n", "USER.md", agentDefinition.User.Content)
}
if agentDefinition.Source != AgentDefinitionSourceAgent { if agentDefinition.Source != AgentDefinitionSourceAgent {
filePath := filepath.Join(cb.workspace, "IDENTITY.md") filePath := filepath.Join(cb.workspace, "IDENTITY.md")

View file

@ -7,6 +7,7 @@ import (
"strings" "strings"
"github.com/gomarkdown/markdown/parser" "github.com/gomarkdown/markdown/parser"
"github.com/sipeed/picoclaw/pkg/logger"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
@ -51,11 +52,18 @@ type SoulDefinition struct {
Content string `json:"content"` Content string `json:"content"`
} }
// UserDefinition represents the resolved USER.md file linked to the workspace.
type UserDefinition struct {
Path string `json:"path"`
Content string `json:"content"`
}
// AgentContextDefinition captures the workspace agent definition in a runtime-friendly shape. // AgentContextDefinition captures the workspace agent definition in a runtime-friendly shape.
type AgentContextDefinition struct { type AgentContextDefinition struct {
Source AgentDefinitionSource `json:"source,omitempty"` Source AgentDefinitionSource `json:"source,omitempty"`
Agent *AgentPromptDefinition `json:"agent,omitempty"` Agent *AgentPromptDefinition `json:"agent,omitempty"`
Soul *SoulDefinition `json:"soul,omitempty"` Soul *SoulDefinition `json:"soul,omitempty"`
User *UserDefinition `json:"user,omitempty"`
} }
// LoadAgentDefinition parses the workspace agent bootstrap files. // LoadAgentDefinition parses the workspace agent bootstrap files.
@ -69,6 +77,7 @@ func (cb *ContextBuilder) LoadAgentDefinition() AgentContextDefinition {
func loadAgentDefinition(workspace string) AgentContextDefinition { func loadAgentDefinition(workspace string) AgentContextDefinition {
definition := AgentContextDefinition{} definition := AgentContextDefinition{}
definition.User = loadUserDefinition(workspace)
agentPath := filepath.Join(workspace, string(AgentDefinitionSourceAgent)) agentPath := filepath.Join(workspace, string(AgentDefinitionSourceAgent))
if content, err := os.ReadFile(agentPath); err == nil { if content, err := os.ReadFile(agentPath); err == nil {
prompt := parseAgentPromptDefinition(agentPath, string(content)) prompt := parseAgentPromptDefinition(agentPath, string(content))
@ -111,6 +120,7 @@ func (definition AgentContextDefinition) trackedPaths(workspace string) []string
paths := []string{ paths := []string{
filepath.Join(workspace, string(AgentDefinitionSourceAgent)), filepath.Join(workspace, string(AgentDefinitionSourceAgent)),
filepath.Join(workspace, "SOUL.md"), filepath.Join(workspace, "SOUL.md"),
filepath.Join(workspace, "USER.md"),
} }
if definition.Source != AgentDefinitionSourceAgent { if definition.Source != AgentDefinitionSourceAgent {
paths = append(paths, paths = append(paths,
@ -121,6 +131,18 @@ func (definition AgentContextDefinition) trackedPaths(workspace string) []string
return uniquePaths(paths) return uniquePaths(paths)
} }
func loadUserDefinition(workspace string) *UserDefinition {
userPath := filepath.Join(workspace, "USER.md")
if content, err := os.ReadFile(userPath); err == nil {
return &UserDefinition{
Path: userPath,
Content: string(content),
}
}
return nil
}
func parseAgentPromptDefinition(path, content string) AgentPromptDefinition { func parseAgentPromptDefinition(path, content string) AgentPromptDefinition {
frontmatter, body := splitAgentFrontmatter(content) frontmatter, body := splitAgentFrontmatter(content)
return AgentPromptDefinition{ return AgentPromptDefinition{
@ -128,11 +150,11 @@ func parseAgentPromptDefinition(path, content string) AgentPromptDefinition {
Raw: content, Raw: content,
Body: body, Body: body,
RawFrontmatter: frontmatter, RawFrontmatter: frontmatter,
Frontmatter: parseAgentFrontmatter(frontmatter), Frontmatter: parseAgentFrontmatter(path, frontmatter),
} }
} }
func parseAgentFrontmatter(frontmatter string) AgentFrontmatter { func parseAgentFrontmatter(path, frontmatter string) AgentFrontmatter {
frontmatter = strings.TrimSpace(frontmatter) frontmatter = strings.TrimSpace(frontmatter)
if frontmatter == "" { if frontmatter == "" {
return AgentFrontmatter{} return AgentFrontmatter{}
@ -140,6 +162,10 @@ func parseAgentFrontmatter(frontmatter string) AgentFrontmatter {
rawFields := make(map[string]any) rawFields := make(map[string]any)
if err := yaml.Unmarshal([]byte(frontmatter), &rawFields); err != nil { if err := yaml.Unmarshal([]byte(frontmatter), &rawFields); err != nil {
logger.WarnCF("agent", "Failed to parse AGENT.md frontmatter", map[string]any{
"path": path,
"error": err.Error(),
})
return AgentFrontmatter{} return AgentFrontmatter{}
} }
@ -153,6 +179,10 @@ func parseAgentFrontmatter(frontmatter string) AgentFrontmatter {
MCPServers []string `yaml:"mcpServers"` MCPServers []string `yaml:"mcpServers"`
} }
if err := yaml.Unmarshal([]byte(frontmatter), &typed); err != nil { if err := yaml.Unmarshal([]byte(frontmatter), &typed); err != nil {
logger.WarnCF("agent", "Failed to decode AGENT.md frontmatter fields", map[string]any{
"path": path,
"error": err.Error(),
})
return AgentFrontmatter{} return AgentFrontmatter{}
} }

View file

@ -106,6 +106,63 @@ func TestLoadAgentDefinitionFallsBackToLegacyAgentsMarkdown(t *testing.T) {
} }
} }
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) { func TestLoadBootstrapFilesUsesAgentBodyNotFrontmatter(t *testing.T) {
tmpDir := setupWorkspace(t, map[string]string{ tmpDir := setupWorkspace(t, map[string]string{
"AGENT.md": `--- "AGENT.md": `---
@ -144,6 +201,25 @@ Follow the body prompt.
} }
} }
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) { func TestStructuredAgentIgnoresIdentityChanges(t *testing.T) {
tmpDir := setupWorkspace(t, map[string]string{ tmpDir := setupWorkspace(t, map[string]string{
"AGENT.md": "# Agent\nFollow the new structure.", "AGENT.md": "# Agent\nFollow the new structure.",
@ -181,6 +257,43 @@ func TestStructuredAgentIgnoresIdentityChanges(t *testing.T) {
} }
} }
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) { func cleanupWorkspace(t *testing.T, path string) {
t.Helper() t.Helper()
if err := os.RemoveAll(path); err != nil { if err := os.RemoveAll(path); err != nil {

21
workspace/USER.md Normal file
View file

@ -0,0 +1,21 @@
# User
Information about the user goes here.
## Preferences
- Communication style: (casual/formal)
- Timezone: (your timezone)
- Language: (your preferred language)
## Personal Information
- Name: (optional)
- Location: (optional)
- Occupation: (optional)
## Learning Goals
- What the user wants to learn from AI
- Preferred interaction style
- Areas of interest