From 3f2dc5ccc0d3bface8c42be10645458c10a399cd Mon Sep 17 00:00:00 2001 From: Victor Vargas Date: Tue, 30 Jun 2026 23:53:28 -0700 Subject: [PATCH] feat(persona): add Persona definition, system prompt assembly, and AgentsMD discovery --- pkg/persona/loader.go | 42 ++++++++++++++ pkg/persona/persona.go | 109 ++++++++++++++++++++++++++++++++++++ pkg/persona/persona_test.go | 68 ++++++++++++++++++++++ 3 files changed, 219 insertions(+) create mode 100644 pkg/persona/loader.go create mode 100644 pkg/persona/persona.go create mode 100644 pkg/persona/persona_test.go diff --git a/pkg/persona/loader.go b/pkg/persona/loader.go new file mode 100644 index 0000000..2d6b231 --- /dev/null +++ b/pkg/persona/loader.go @@ -0,0 +1,42 @@ +package persona + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +// yamlLoader loads personas from YAML files. +type yamlLoader struct{} + +// NewYAMLLoader returns a Loader that reads from YAML files. +func NewYAMLLoader() Loader { + return &yamlLoader{} +} + +func (l *yamlLoader) Load(ctx context.Context, configPath string) (Persona, error) { + data, err := os.ReadFile(configPath) + if err != nil { + return Persona{}, fmt.Errorf("reading persona file: %w", err) + } + + var p Persona + if err := yaml.Unmarshal(data, &p); err != nil { + return Persona{}, fmt.Errorf("parsing YAML: %w", err) + } + return p, nil +} + +func (l *yamlLoader) Discover(ctx context.Context, workdir string) (Persona, error) { + // Try to load from workdir + path := filepath.Join(workdir, "persona.yaml") + if _, err := os.Stat(path); err == nil { + return l.Load(ctx, path) + } + + // Return default if no persona file found + return DefaultPersona(), nil +} diff --git a/pkg/persona/persona.go b/pkg/persona/persona.go new file mode 100644 index 0000000..d7706f0 --- /dev/null +++ b/pkg/persona/persona.go @@ -0,0 +1,109 @@ +package persona + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/VictorVargas/rony-llm-agent/pkg/llm" +) + +// Persona defines the personality and behavior of the agent. +type Persona struct { + ID string + Name string + Tone string + Style string + Language string + Constraints []string + FewShot []llm.Message +} + +// Loader loads personas from files. +type Loader interface { + Load(ctx context.Context, configPath string) (Persona, error) + Discover(ctx context.Context, workdir string) (Persona, error) +} + +// ErrPersonaNotFound is returned when a persona file is not found. +var ErrPersonaNotFound = fmt.Errorf("persona not found") + +// DefaultPersona returns a basic persona with sensible defaults. +func DefaultPersona() Persona { + return Persona{ + ID: "default", + Name: "Rony", + Tone: "professional and helpful", + Style: "clear and concise", + Language: "en", + } +} + +// AssembleSystemPrompt builds the final system prompt from persona, base prompt, and AGENTS.md. +func AssembleSystemPrompt(p Persona, agentsMD string) string { + var parts []string + + // Base system prompt + parts = append(parts, "You are an AI agent. Be helpful, accurate, and safe.") + + // Persona instructions + if p.Name != "" { + parts = append(parts, fmt.Sprintf("Your name is %s.", p.Name)) + } + if p.Tone != "" { + parts = append(parts, fmt.Sprintf("Use a %s tone.", p.Tone)) + } + if p.Style != "" { + parts = append(parts, fmt.Sprintf("Write in a %s style.", p.Style)) + } + if p.Language != "" { + parts = append(parts, fmt.Sprintf("Respond in %s.", p.Language)) + } + for _, c := range p.Constraints { + parts = append(parts, fmt.Sprintf("CONSTRAINT: %s", c)) + } + + // AGENTS.md content + if agentsMD != "" { + parts = append(parts, "---") + parts = append(parts, "Project instructions:") + parts = append(parts, agentsMD) + } + + return strings.Join(parts, "\n\n") +} + +// discoverAgentsMD walks up the directory tree looking for AGENTS.md files. +func discoverAgentsMD(root string) string { + var parts []string + current := root + + for { + agentsPath := filepath.Join(current, "AGENTS.md") + if _, err := os.Stat(agentsPath); err == nil { + data, err := os.ReadFile(agentsPath) + if err == nil { + parts = append(parts, string(data)) + } + } + + parent := filepath.Dir(current) + if parent == current || parent == "." { + break + } + current = parent + } + + // Also check ~/.config/rony/AGENTS.md + home, err := os.UserHomeDir() + if err == nil { + globalPath := filepath.Join(home, ".config", "rony", "AGENTS.md") + if data, err := os.ReadFile(globalPath); err == nil { + parts = append(parts, string(data)) + } + } + + return strings.Join(parts, "\n\n") +} diff --git a/pkg/persona/persona_test.go b/pkg/persona/persona_test.go new file mode 100644 index 0000000..86a3ffc --- /dev/null +++ b/pkg/persona/persona_test.go @@ -0,0 +1,68 @@ +package persona + +import ( + "context" + "os" + "path/filepath" + "testing" +) + +func TestDefaultPersona(t *testing.T) { + p := DefaultPersona() + if p.Name != "Rony" { + t.Errorf("expected name 'Rony', got %q", p.Name) + } + if p.Tone != "professional and helpful" { + t.Errorf("expected tone 'professional and helpful', got %q", p.Tone) + } +} + +func TestAssembleSystemPrompt(t *testing.T) { + p := Persona{ + Name: "TestAgent", + Tone: "friendly", + } + result := AssembleSystemPrompt(p, "") + if !contains(result, "TestAgent") { + t.Error("expected system prompt to contain persona name") + } + if !contains(result, "friendly") { + t.Error("expected system prompt to contain tone") + } +} + +func TestAssembleSystemPrompt_WithAgentsMD(t *testing.T) { + p := DefaultPersona() + agentsMD := "This is the project instructions." + result := AssembleSystemPrompt(p, agentsMD) + if !contains(result, agentsMD) { + t.Error("expected system prompt to contain AGENTS.md content") + } +} + +func TestDiscoverAgentsMD(t *testing.T) { + tmpDir := t.TempDir() + agentsPath := filepath.Join(tmpDir, "AGENTS.md") + os.WriteFile(agentsPath, []byte("test instructions"), 0644) + + result := discoverAgentsMD(tmpDir) + if !contains(result, "test instructions") { + t.Error("expected discoverAgentsMD to find AGENTS.md") + } +} + +func TestLoader_Discover_NoFile(t *testing.T) { + loader := NewYAMLLoader() + ctx := context.Background() + p, err := loader.Discover(ctx, "/tmp/nonexistent_12345") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if p.Name != "Rony" { + t.Errorf("expected default persona, got %q", p.Name) + } +} + +func contains(haystack, needle string) bool { + return len(haystack) > 0 && len(needle) > 0 && len(haystack) >= len(needle) && haystack[:len(needle)] == needle || len(haystack) > len(needle) && contains(haystack[1:], needle) +}