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