feat(agent): restore workspace USER.md context
This commit is contained in:
parent
e5974ab804
commit
5529e3962d
5 changed files with 175 additions and 3 deletions
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
21
workspace/USER.md
Normal 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
|
||||||
Loading…
Add table
Reference in a new issue