From 5529e3962d005bec7f76ca6a98e59b1b94aa1cce Mon Sep 17 00:00:00 2001 From: Hoshina Date: Wed, 18 Mar 2026 11:15:47 +0800 Subject: [PATCH] feat(agent): restore workspace USER.md context --- cmd/picoclaw/internal/onboard/helpers_test.go | 7 +- pkg/agent/context.go | 3 + pkg/agent/definition.go | 34 +++++- pkg/agent/definition_test.go | 113 ++++++++++++++++++ workspace/USER.md | 21 ++++ 5 files changed, 175 insertions(+), 3 deletions(-) create mode 100644 workspace/USER.md diff --git a/cmd/picoclaw/internal/onboard/helpers_test.go b/cmd/picoclaw/internal/onboard/helpers_test.go index c773ad7c3..23fc97c5a 100644 --- a/cmd/picoclaw/internal/onboard/helpers_test.go +++ b/cmd/picoclaw/internal/onboard/helpers_test.go @@ -23,7 +23,12 @@ func TestCopyEmbeddedToTargetUsesStructuredAgentFiles(t *testing.T) { 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) if _, err := os.Stat(legacyPath); !os.IsNotExist(err) { t.Fatalf("expected legacy file %s to be absent, got err=%v", legacyPath, err) diff --git a/pkg/agent/context.go b/pkg/agent/context.go index eba516c39..cb566f02b 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -447,6 +447,9 @@ func (cb *ContextBuilder) LoadBootstrapFiles() string { 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 { filePath := filepath.Join(cb.workspace, "IDENTITY.md") diff --git a/pkg/agent/definition.go b/pkg/agent/definition.go index e08c181fb..4ed4e844d 100644 --- a/pkg/agent/definition.go +++ b/pkg/agent/definition.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/gomarkdown/markdown/parser" + "github.com/sipeed/picoclaw/pkg/logger" "gopkg.in/yaml.v3" ) @@ -51,11 +52,18 @@ type SoulDefinition struct { 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. type AgentContextDefinition struct { Source AgentDefinitionSource `json:"source,omitempty"` Agent *AgentPromptDefinition `json:"agent,omitempty"` Soul *SoulDefinition `json:"soul,omitempty"` + User *UserDefinition `json:"user,omitempty"` } // LoadAgentDefinition parses the workspace agent bootstrap files. @@ -69,6 +77,7 @@ func (cb *ContextBuilder) LoadAgentDefinition() AgentContextDefinition { func loadAgentDefinition(workspace string) AgentContextDefinition { definition := AgentContextDefinition{} + definition.User = loadUserDefinition(workspace) agentPath := filepath.Join(workspace, string(AgentDefinitionSourceAgent)) if content, err := os.ReadFile(agentPath); err == nil { prompt := parseAgentPromptDefinition(agentPath, string(content)) @@ -111,6 +120,7 @@ func (definition AgentContextDefinition) trackedPaths(workspace string) []string paths := []string{ filepath.Join(workspace, string(AgentDefinitionSourceAgent)), filepath.Join(workspace, "SOUL.md"), + filepath.Join(workspace, "USER.md"), } if definition.Source != AgentDefinitionSourceAgent { paths = append(paths, @@ -121,6 +131,18 @@ func (definition AgentContextDefinition) trackedPaths(workspace string) []string 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 { frontmatter, body := splitAgentFrontmatter(content) return AgentPromptDefinition{ @@ -128,11 +150,11 @@ func parseAgentPromptDefinition(path, content string) AgentPromptDefinition { Raw: content, Body: body, RawFrontmatter: frontmatter, - Frontmatter: parseAgentFrontmatter(frontmatter), + Frontmatter: parseAgentFrontmatter(path, frontmatter), } } -func parseAgentFrontmatter(frontmatter string) AgentFrontmatter { +func parseAgentFrontmatter(path, frontmatter string) AgentFrontmatter { frontmatter = strings.TrimSpace(frontmatter) if frontmatter == "" { return AgentFrontmatter{} @@ -140,6 +162,10 @@ func parseAgentFrontmatter(frontmatter string) AgentFrontmatter { rawFields := make(map[string]any) 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{} } @@ -153,6 +179,10 @@ func parseAgentFrontmatter(frontmatter string) AgentFrontmatter { MCPServers []string `yaml:"mcpServers"` } 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{} } diff --git a/pkg/agent/definition_test.go b/pkg/agent/definition_test.go index e643dc047..5ee996967 100644 --- a/pkg/agent/definition_test.go +++ b/pkg/agent/definition_test.go @@ -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) { tmpDir := setupWorkspace(t, map[string]string{ "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) { tmpDir := setupWorkspace(t, map[string]string{ "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) { t.Helper() if err := os.RemoveAll(path); err != nil { diff --git a/workspace/USER.md b/workspace/USER.md new file mode 100644 index 000000000..9a3419d87 --- /dev/null +++ b/workspace/USER.md @@ -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