feat(persona): add Persona definition, system prompt assembly, and AgentsMD discovery

This commit is contained in:
Victor Hugo Vargas Servin 2026-06-30 23:53:28 -07:00
parent 4b39f52081
commit 3f2dc5ccc0
3 changed files with 219 additions and 0 deletions

42
pkg/persona/loader.go Normal file
View file

@ -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
}

109
pkg/persona/persona.go Normal file
View file

@ -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")
}

View file

@ -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)
}