feat(persona): add Persona definition, system prompt assembly, and AgentsMD discovery
This commit is contained in:
parent
4b39f52081
commit
3f2dc5ccc0
3 changed files with 219 additions and 0 deletions
42
pkg/persona/loader.go
Normal file
42
pkg/persona/loader.go
Normal 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
109
pkg/persona/persona.go
Normal 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")
|
||||
}
|
||||
68
pkg/persona/persona_test.go
Normal file
68
pkg/persona/persona_test.go
Normal 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)
|
||||
}
|
||||
Loading…
Reference in a new issue