Files
amcs/internal/store/agent_personas.go
Hein (Warky) e285a03639
Some checks failed
CI / build-and-test (push) Failing after -31m18s
Add schema for agent personas, parts, traits, and character arcs
- Created tables for agent_personas, agent_parts, agent_traits, and character_arcs.
- Established relationships between personas, parts, skills, guardrails, and traits.
- Added arc stages and their corresponding parts, along with a persona_arc table to track current stages.
- Implemented cascading delete rules for referential integrity.
2026-05-05 09:43:14 +02:00

1199 lines
37 KiB
Go

package store
import (
"context"
"fmt"
"strings"
"time"
"github.com/google/uuid"
ext "git.warky.dev/wdevs/amcs/internal/types"
)
// ──────────────────────────────────────────────
// Helpers
// ──────────────────────────────────────────────
func nilToEmptyStrings(s []string) []string {
if s == nil {
return []string{}
}
return s
}
// ──────────────────────────────────────────────
// Personas
// ──────────────────────────────────────────────
func (db *DB) CreatePersona(ctx context.Context, p ext.Persona) (ext.Persona, error) {
if p.Tags == nil {
p.Tags = []string{}
}
row := db.pool.QueryRow(ctx, `
insert into agent_personas (name, description, summary, detail, tags)
values ($1, $2, $3, $4, $5)
returning id, guid, compiled_summary, compiled_detail, compiled_at, created_at, updated_at
`, p.Name, p.Description, p.Summary, p.Detail, p.Tags)
created := p
var id int64
var guid uuid.UUID
var cs, cd string
var compiledAt *time.Time
var createdAt, updatedAt time.Time
if err := row.Scan(&id, &guid, &cs, &cd, &compiledAt, &createdAt, &updatedAt); err != nil {
return ext.Persona{}, fmt.Errorf("create persona: %w", err)
}
created.ID = id
created.GUID = guid
created.CompiledSummary = cs
created.CompiledDetail = cd
created.CompiledAt = compiledAt
created.CreatedAt = createdAt
created.UpdatedAt = updatedAt
return created, nil
}
func (db *DB) UpdatePersona(ctx context.Context, name string, updates map[string]any) (ext.Persona, error) {
if len(updates) == 0 {
return db.GetPersonaByName(ctx, name)
}
allowed := map[string]bool{"name": true, "description": true, "summary": true, "detail": true, "tags": true}
setClauses := []string{}
args := []any{}
for col, val := range updates {
if !allowed[col] {
return ext.Persona{}, fmt.Errorf("unknown field: %s", col)
}
args = append(args, val)
setClauses = append(setClauses, fmt.Sprintf("%s = $%d", col, len(args)))
}
args = append(args, name)
q := fmt.Sprintf(`
update agent_personas set %s, updated_at = now()
where name = $%d
returning id, guid, name, description, summary, detail, compiled_summary, compiled_detail, compiled_at, tags::text[], created_at, updated_at
`, strings.Join(setClauses, ", "), len(args))
row := db.pool.QueryRow(ctx, q, args...)
return scanPersona(row)
}
func (db *DB) DeletePersona(ctx context.Context, name string) error {
tag, err := db.pool.Exec(ctx, `delete from agent_personas where name = $1`, name)
if err != nil {
return fmt.Errorf("delete persona: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("persona not found: %s", name)
}
return nil
}
func (db *DB) ListPersonas(ctx context.Context, tag string) ([]ext.Persona, error) {
q := `select id, guid, name, description, summary, detail, compiled_summary, compiled_detail, compiled_at, tags::text[], created_at, updated_at from agent_personas`
args := []any{}
if t := strings.TrimSpace(tag); t != "" {
args = append(args, t)
q += fmt.Sprintf(" where $%d = any(tags)", len(args))
}
q += " order by name"
rows, err := db.pool.Query(ctx, q, args...)
if err != nil {
return nil, fmt.Errorf("list personas: %w", err)
}
defer rows.Close()
var personas []ext.Persona
for rows.Next() {
p, err := scanPersona(rows)
if err != nil {
return nil, fmt.Errorf("scan persona: %w", err)
}
personas = append(personas, p)
}
return personas, rows.Err()
}
func (db *DB) GetPersonaByName(ctx context.Context, name string) (ext.Persona, error) {
row := db.pool.QueryRow(ctx, `
select id, guid, name, description, summary, detail, compiled_summary, compiled_detail, compiled_at, tags::text[], created_at, updated_at
from agent_personas where name = $1
`, name)
p, err := scanPersona(row)
if err != nil {
return ext.Persona{}, fmt.Errorf("get persona: %w", err)
}
return p, nil
}
// GetPersona assembles the full persona with parts, skills, guardrails, traits,
// and arc state. Assembly priority per part_type: overrides > arc stage > persona base.
func (db *DB) GetPersona(ctx context.Context, name string, detail bool, overrides map[string]string) (ext.PersonaFull, error) {
// 1. Persona metadata
persona, err := db.GetPersonaByName(ctx, name)
if err != nil {
return ext.PersonaFull{}, err
}
// 2. Base persona parts (ordered)
baseParts, err := db.listPersonaParts(ctx, persona.ID)
if err != nil {
return ext.PersonaFull{}, err
}
// 3. Arc state + stage parts
arcState, stageParts, err := db.getArcStateAndParts(ctx, persona.ID)
if err != nil {
return ext.PersonaFull{}, err
}
// 4. Assemble: start with base, override with arc stage parts per type
assembled := assembleParts(baseParts, stageParts, "arc_stage")
// 5. Apply runtime overrides (fetch by name, replace per type)
if len(overrides) > 0 {
overrideParts, err := db.fetchPartsByNames(ctx, overrideValues(overrides))
if err != nil {
return ext.PersonaFull{}, err
}
// Build name→part lookup
byName := make(map[string]rawPart, len(overrideParts))
for _, p := range overrideParts {
byName[p.Name] = p
}
for partType, partName := range overrides {
op, ok := byName[partName]
if !ok {
return ext.PersonaFull{}, fmt.Errorf("override part not found: %s", partName)
}
if op.PartType != partType {
return ext.PersonaFull{}, fmt.Errorf("override part %q has type %q, expected %q", partName, op.PartType, partType)
}
// Remove all parts of this type, then append the override
without := assembled[:0]
for _, ap := range assembled {
if ap.PartType != partType {
without = append(without, ap)
}
}
op.Source = "override"
assembled = append(without, op)
}
}
// 6. Skills, guardrails, traits
skills, err := db.listPersonaSkills(ctx, persona.ID)
if err != nil {
return ext.PersonaFull{}, err
}
guardrails, err := db.listPersonaGuardrails(ctx, persona.ID)
if err != nil {
return ext.PersonaFull{}, err
}
traits, err := db.listPersonaTraits(ctx, persona.ID)
if err != nil {
return ext.PersonaFull{}, err
}
// 7. Build output
body := persona.Summary
if detail && persona.Detail != "" {
body = persona.Detail
}
out := ext.PersonaFull{
Name: persona.Name,
Description: persona.Description,
Body: body,
CompiledSummary: persona.CompiledSummary,
Tags: persona.Tags,
Arc: arcState,
Detail: detail,
}
for _, rp := range assembled {
partBody := rp.Summary
if detail && rp.Content != "" {
partBody = rp.Content
}
out.Parts = append(out.Parts, ext.AssembledPart{
Name: rp.Name,
PartType: rp.PartType,
Description: rp.Description,
Body: partBody,
Tags: rp.Tags,
Source: rp.Source,
})
}
for _, s := range skills {
entry := ext.PersonaSkillEntry{
ID: s.ID,
Name: s.Name,
Description: s.Description,
Tags: s.Tags,
}
if detail {
entry.Content = s.Content
}
out.Skills = append(out.Skills, entry)
}
for _, g := range guardrails {
entry := ext.PersonaGuardrailEntry{
ID: g.ID,
Name: g.Name,
Description: g.Description,
Tags: g.Tags,
}
if detail {
entry.Content = g.Content
entry.Severity = g.Severity
}
out.Guardrails = append(out.Guardrails, entry)
}
for _, t := range traits {
entry := ext.PersonaTraitEntry{
ID: t.ID,
Name: t.Name,
TraitType: t.TraitType,
Description: t.Description,
Tags: t.Tags,
}
if detail {
entry.Instruction = t.Instruction
}
out.Traits = append(out.Traits, entry)
}
if out.Parts == nil {
out.Parts = []ext.AssembledPart{}
}
if out.Skills == nil {
out.Skills = []ext.PersonaSkillEntry{}
}
if out.Guardrails == nil {
out.Guardrails = []ext.PersonaGuardrailEntry{}
}
if out.Traits == nil {
out.Traits = []ext.PersonaTraitEntry{}
}
return out, nil
}
// GetPersonaManifest returns a lightweight structure-only view of the persona.
func (db *DB) GetPersonaManifest(ctx context.Context, name string) (ext.PersonaManifest, error) {
persona, err := db.GetPersonaByName(ctx, name)
if err != nil {
return ext.PersonaManifest{}, err
}
baseParts, err := db.listPersonaParts(ctx, persona.ID)
if err != nil {
return ext.PersonaManifest{}, err
}
arcState, stageParts, err := db.getArcStateAndParts(ctx, persona.ID)
if err != nil {
return ext.PersonaManifest{}, err
}
assembled := assembleParts(baseParts, stageParts, "arc_stage")
skills, err := db.listPersonaSkills(ctx, persona.ID)
if err != nil {
return ext.PersonaManifest{}, err
}
guardrails, err := db.listPersonaGuardrails(ctx, persona.ID)
if err != nil {
return ext.PersonaManifest{}, err
}
traits, err := db.listPersonaTraits(ctx, persona.ID)
if err != nil {
return ext.PersonaManifest{}, err
}
manifest := ext.PersonaManifest{
Name: persona.Name,
Description: persona.Description,
Arc: arcState,
OnDemandTools: []string{
"get_agent_part(name) — load full content of a specific part",
"get_agent_trait(name) — load instruction for a specific trait",
"get_skill(name) — load skill content",
"get_guardrail(name) — load guardrail content",
"compile_persona(name) — load pre-merged compiled_summary if full context needed",
"get_agent_persona(name, detail=true) — load full persona with all content",
},
}
for _, rp := range assembled {
manifest.Parts = append(manifest.Parts, ext.ManifestPart{
Name: rp.Name,
PartType: rp.PartType,
Description: rp.Description,
})
}
for _, s := range skills {
manifest.Skills = append(manifest.Skills, ext.ManifestSkill{
ID: s.ID,
Name: s.Name,
Description: s.Description,
})
}
for _, g := range guardrails {
manifest.Guardrails = append(manifest.Guardrails, ext.ManifestGuardrail{
ID: g.ID,
Name: g.Name,
Description: g.Description,
Severity: g.Severity,
})
}
for _, t := range traits {
manifest.Traits = append(manifest.Traits, ext.ManifestTrait{
Name: t.Name,
TraitType: t.TraitType,
Description: t.Description,
})
}
if manifest.Parts == nil {
manifest.Parts = []ext.ManifestPart{}
}
if manifest.Skills == nil {
manifest.Skills = []ext.ManifestSkill{}
}
if manifest.Guardrails == nil {
manifest.Guardrails = []ext.ManifestGuardrail{}
}
if manifest.Traits == nil {
manifest.Traits = []ext.ManifestTrait{}
}
return manifest, nil
}
// CompilePersona regenerates compiled_summary and compiled_detail from current parts and arc stage.
func (db *DB) CompilePersona(ctx context.Context, name string) (ext.Persona, error) {
persona, err := db.GetPersonaByName(ctx, name)
if err != nil {
return ext.Persona{}, err
}
baseParts, err := db.listPersonaParts(ctx, persona.ID)
if err != nil {
return ext.Persona{}, err
}
_, stageParts, err := db.getArcStateAndParts(ctx, persona.ID)
if err != nil {
return ext.Persona{}, err
}
assembled := assembleParts(baseParts, stageParts, "arc_stage")
summaryParts := []string{persona.Summary}
detailParts := []string{persona.Detail}
for _, rp := range assembled {
if rp.Summary != "" {
summaryParts = append(summaryParts, rp.Summary)
}
if rp.Content != "" {
detailParts = append(detailParts, rp.Content)
}
}
compiledSummary := strings.Join(summaryParts, "\n\n")
compiledDetail := strings.Join(detailParts, "\n\n")
row := db.pool.QueryRow(ctx, `
update agent_personas
set compiled_summary = $1, compiled_detail = $2, compiled_at = now(), updated_at = now()
where id = $3
returning id, guid, name, description, summary, detail, compiled_summary, compiled_detail, compiled_at, tags::text[], created_at, updated_at
`, compiledSummary, compiledDetail, persona.ID)
return scanPersona(row)
}
// ──────────────────────────────────────────────
// Parts
// ──────────────────────────────────────────────
func (db *DB) CreatePart(ctx context.Context, p ext.Part) (ext.Part, error) {
if p.Tags == nil {
p.Tags = []string{}
}
row := db.pool.QueryRow(ctx, `
insert into agent_parts (name, part_type, description, summary, content, tags)
values ($1, $2, $3, $4, $5, $6)
returning id, guid, created_at, updated_at
`, p.Name, p.PartType, p.Description, p.Summary, p.Content, p.Tags)
created := p
var id int64
var guid uuid.UUID
var createdAt, updatedAt time.Time
if err := row.Scan(&id, &guid, &createdAt, &updatedAt); err != nil {
return ext.Part{}, fmt.Errorf("create part: %w", err)
}
created.ID = id
created.GUID = guid
created.CreatedAt = createdAt
created.UpdatedAt = updatedAt
return created, nil
}
func (db *DB) UpdatePart(ctx context.Context, name string, updates map[string]any) (ext.Part, error) {
if len(updates) == 0 {
return db.GetPartByName(ctx, name)
}
allowed := map[string]bool{"name": true, "part_type": true, "description": true, "summary": true, "content": true, "tags": true}
setClauses := []string{}
args := []any{}
for col, val := range updates {
if !allowed[col] {
return ext.Part{}, fmt.Errorf("unknown field: %s", col)
}
args = append(args, val)
setClauses = append(setClauses, fmt.Sprintf("%s = $%d", col, len(args)))
}
args = append(args, name)
q := fmt.Sprintf(`
update agent_parts set %s, updated_at = now()
where name = $%d
returning id, guid, name, part_type, description, summary, content, tags::text[], created_at, updated_at
`, strings.Join(setClauses, ", "), len(args))
row := db.pool.QueryRow(ctx, q, args...)
return scanPart(row)
}
func (db *DB) DeletePart(ctx context.Context, name string) error {
tag, err := db.pool.Exec(ctx, `delete from agent_parts where name = $1`, name)
if err != nil {
return fmt.Errorf("delete part: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("part not found: %s", name)
}
return nil
}
func (db *DB) ListParts(ctx context.Context, partType, tag string) ([]ext.Part, error) {
args := []any{}
conditions := []string{}
if t := strings.TrimSpace(partType); t != "" {
args = append(args, t)
conditions = append(conditions, fmt.Sprintf("part_type = $%d", len(args)))
}
if t := strings.TrimSpace(tag); t != "" {
args = append(args, t)
conditions = append(conditions, fmt.Sprintf("$%d = any(tags)", len(args)))
}
q := `select id, guid, name, part_type, description, summary, content, tags::text[], created_at, updated_at from agent_parts`
if len(conditions) > 0 {
q += " where " + strings.Join(conditions, " and ")
}
q += " order by part_type, name"
rows, err := db.pool.Query(ctx, q, args...)
if err != nil {
return nil, fmt.Errorf("list parts: %w", err)
}
defer rows.Close()
var parts []ext.Part
for rows.Next() {
p, err := scanPart(rows)
if err != nil {
return nil, fmt.Errorf("scan part: %w", err)
}
parts = append(parts, p)
}
return parts, rows.Err()
}
func (db *DB) GetPartByName(ctx context.Context, name string) (ext.Part, error) {
row := db.pool.QueryRow(ctx, `
select id, guid, name, part_type, description, summary, content, tags::text[], created_at, updated_at
from agent_parts where name = $1
`, name)
p, err := scanPart(row)
if err != nil {
return ext.Part{}, fmt.Errorf("get part: %w", err)
}
return p, nil
}
// ──────────────────────────────────────────────
// Persona-Part links
// ──────────────────────────────────────────────
func (db *DB) AddPersonaPart(ctx context.Context, personaName, partName string, order, priority int) error {
persona, err := db.GetPersonaByName(ctx, personaName)
if err != nil {
return err
}
part, err := db.GetPartByName(ctx, partName)
if err != nil {
return err
}
_, err = db.pool.Exec(ctx, `
insert into agent_persona_parts (persona_id, part_id, part_order, priority)
values ($1, $2, $3, $4)
on conflict (persona_id, part_id) do update set part_order = excluded.part_order, priority = excluded.priority
`, persona.ID, part.ID, order, priority)
if err != nil {
return fmt.Errorf("add persona part: %w", err)
}
return nil
}
func (db *DB) RemovePersonaPart(ctx context.Context, personaName, partName string) error {
tag, err := db.pool.Exec(ctx, `
delete from agent_persona_parts
where persona_id = (select id from agent_personas where name = $1)
and part_id = (select id from agent_parts where name = $2)
`, personaName, partName)
if err != nil {
return fmt.Errorf("remove persona part: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("persona-part link not found")
}
return nil
}
// ──────────────────────────────────────────────
// Persona-Skill links
// ──────────────────────────────────────────────
func (db *DB) AddPersonaSkill(ctx context.Context, personaID, skillID int64) error {
_, err := db.pool.Exec(ctx, `
insert into agent_persona_skills (persona_id, skill_id)
values ($1, $2) on conflict do nothing
`, personaID, skillID)
if err != nil {
return fmt.Errorf("add persona skill: %w", err)
}
return nil
}
func (db *DB) RemovePersonaSkill(ctx context.Context, personaID, skillID int64) error {
tag, err := db.pool.Exec(ctx, `
delete from agent_persona_skills where persona_id = $1 and skill_id = $2
`, personaID, skillID)
if err != nil {
return fmt.Errorf("remove persona skill: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("persona-skill link not found")
}
return nil
}
// ──────────────────────────────────────────────
// Persona-Guardrail links
// ──────────────────────────────────────────────
func (db *DB) AddPersonaGuardrail(ctx context.Context, personaID, guardrailID int64) error {
_, err := db.pool.Exec(ctx, `
insert into agent_persona_guardrails (persona_id, guardrail_id)
values ($1, $2) on conflict do nothing
`, personaID, guardrailID)
if err != nil {
return fmt.Errorf("add persona guardrail: %w", err)
}
return nil
}
func (db *DB) RemovePersonaGuardrail(ctx context.Context, personaID, guardrailID int64) error {
tag, err := db.pool.Exec(ctx, `
delete from agent_persona_guardrails where persona_id = $1 and guardrail_id = $2
`, personaID, guardrailID)
if err != nil {
return fmt.Errorf("remove persona guardrail: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("persona-guardrail link not found")
}
return nil
}
// ──────────────────────────────────────────────
// Traits
// ──────────────────────────────────────────────
func (db *DB) CreateTrait(ctx context.Context, t ext.Trait) (ext.Trait, error) {
if t.Tags == nil {
t.Tags = []string{}
}
row := db.pool.QueryRow(ctx, `
insert into agent_traits (name, trait_type, description, instruction, tags)
values ($1, $2, $3, $4, $5)
returning id, guid, created_at, updated_at
`, t.Name, t.TraitType, t.Description, t.Instruction, t.Tags)
created := t
var id int64
var guid uuid.UUID
var createdAt, updatedAt time.Time
if err := row.Scan(&id, &guid, &createdAt, &updatedAt); err != nil {
return ext.Trait{}, fmt.Errorf("create trait: %w", err)
}
created.ID = id
created.GUID = guid
created.CreatedAt = createdAt
created.UpdatedAt = updatedAt
return created, nil
}
func (db *DB) UpdateTrait(ctx context.Context, name string, updates map[string]any) (ext.Trait, error) {
if len(updates) == 0 {
return db.GetTraitByName(ctx, name)
}
allowed := map[string]bool{"name": true, "trait_type": true, "description": true, "instruction": true, "tags": true}
setClauses := []string{}
args := []any{}
for col, val := range updates {
if !allowed[col] {
return ext.Trait{}, fmt.Errorf("unknown field: %s", col)
}
args = append(args, val)
setClauses = append(setClauses, fmt.Sprintf("%s = $%d", col, len(args)))
}
args = append(args, name)
q := fmt.Sprintf(`
update agent_traits set %s, updated_at = now()
where name = $%d
returning id, guid, name, trait_type, description, instruction, tags::text[], created_at, updated_at
`, strings.Join(setClauses, ", "), len(args))
row := db.pool.QueryRow(ctx, q, args...)
return scanTrait(row)
}
func (db *DB) DeleteTrait(ctx context.Context, name string) error {
tag, err := db.pool.Exec(ctx, `delete from agent_traits where name = $1`, name)
if err != nil {
return fmt.Errorf("delete trait: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("trait not found: %s", name)
}
return nil
}
func (db *DB) ListTraits(ctx context.Context, traitType, tag string) ([]ext.Trait, error) {
args := []any{}
conditions := []string{}
if t := strings.TrimSpace(traitType); t != "" {
args = append(args, t)
conditions = append(conditions, fmt.Sprintf("trait_type = $%d", len(args)))
}
if t := strings.TrimSpace(tag); t != "" {
args = append(args, t)
conditions = append(conditions, fmt.Sprintf("$%d = any(tags)", len(args)))
}
q := `select id, guid, name, trait_type, description, instruction, tags::text[], created_at, updated_at from agent_traits`
if len(conditions) > 0 {
q += " where " + strings.Join(conditions, " and ")
}
q += " order by trait_type, name"
rows, err := db.pool.Query(ctx, q, args...)
if err != nil {
return nil, fmt.Errorf("list traits: %w", err)
}
defer rows.Close()
var traits []ext.Trait
for rows.Next() {
t, err := scanTrait(rows)
if err != nil {
return nil, fmt.Errorf("scan trait: %w", err)
}
traits = append(traits, t)
}
return traits, rows.Err()
}
func (db *DB) GetTraitByName(ctx context.Context, name string) (ext.Trait, error) {
row := db.pool.QueryRow(ctx, `
select id, guid, name, trait_type, description, instruction, tags::text[], created_at, updated_at
from agent_traits where name = $1
`, name)
t, err := scanTrait(row)
if err != nil {
return ext.Trait{}, fmt.Errorf("get trait: %w", err)
}
return t, nil
}
func (db *DB) AddPersonaTrait(ctx context.Context, personaID, traitID int64) error {
_, err := db.pool.Exec(ctx, `
insert into agent_persona_traits (persona_id, trait_id)
values ($1, $2) on conflict do nothing
`, personaID, traitID)
if err != nil {
return fmt.Errorf("add persona trait: %w", err)
}
return nil
}
func (db *DB) RemovePersonaTrait(ctx context.Context, personaID, traitID int64) error {
tag, err := db.pool.Exec(ctx, `
delete from agent_persona_traits where persona_id = $1 and trait_id = $2
`, personaID, traitID)
if err != nil {
return fmt.Errorf("remove persona trait: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("persona-trait link not found")
}
return nil
}
// ──────────────────────────────────────────────
// Character Arcs
// ──────────────────────────────────────────────
func (db *DB) CreateCharacterArc(ctx context.Context, arc ext.CharacterArc) (ext.CharacterArc, error) {
row := db.pool.QueryRow(ctx, `
insert into character_arcs (name, description, summary)
values ($1, $2, $3)
returning id, created_at, updated_at
`, arc.Name, arc.Description, arc.Summary)
created := arc
var id int64
var createdAt, updatedAt time.Time
if err := row.Scan(&id, &createdAt, &updatedAt); err != nil {
return ext.CharacterArc{}, fmt.Errorf("create character arc: %w", err)
}
created.ID = id
created.CreatedAt = createdAt
created.UpdatedAt = updatedAt
return created, nil
}
func (db *DB) ListCharacterArcs(ctx context.Context) ([]ext.CharacterArc, error) {
rows, err := db.pool.Query(ctx, `
select id, name, description, summary, created_at, updated_at
from character_arcs order by name
`)
if err != nil {
return nil, fmt.Errorf("list character arcs: %w", err)
}
defer rows.Close()
var arcs []ext.CharacterArc
for rows.Next() {
var a ext.CharacterArc
if err := rows.Scan(&a.ID, &a.Name, &a.Description, &a.Summary, &a.CreatedAt, &a.UpdatedAt); err != nil {
return nil, fmt.Errorf("scan character arc: %w", err)
}
arcs = append(arcs, a)
}
return arcs, rows.Err()
}
func (db *DB) AddArcStage(ctx context.Context, arcName string, stage ext.ArcStage) (ext.ArcStage, error) {
row := db.pool.QueryRow(ctx, `
insert into arc_stages (arc_id, name, stage_order, description, condition)
values ((select id from character_arcs where name = $1), $2, $3, $4, $5)
returning id, arc_id, created_at
`, arcName, stage.Name, stage.StageOrder, stage.Description, stage.Condition)
created := stage
if err := row.Scan(&created.ID, &created.ArcID, &created.CreatedAt); err != nil {
return ext.ArcStage{}, fmt.Errorf("add arc stage: %w", err)
}
return created, nil
}
func (db *DB) AddStagePart(ctx context.Context, stageID int64, partName string) error {
_, err := db.pool.Exec(ctx, `
insert into arc_stage_parts (stage_id, part_id)
values ($1, (select id from agent_parts where name = $2))
on conflict do nothing
`, stageID, partName)
if err != nil {
return fmt.Errorf("add stage part: %w", err)
}
return nil
}
func (db *DB) RemoveStagePart(ctx context.Context, stageID int64, partName string) error {
tag, err := db.pool.Exec(ctx, `
delete from arc_stage_parts
where stage_id = $1 and part_id = (select id from agent_parts where name = $2)
`, stageID, partName)
if err != nil {
return fmt.Errorf("remove stage part: %w", err)
}
if tag.RowsAffected() == 0 {
return fmt.Errorf("stage-part link not found")
}
return nil
}
func (db *DB) AssignPersonaArc(ctx context.Context, personaName, arcName, startStageName string) error {
_, err := db.pool.Exec(ctx, `
insert into persona_arc (persona_id, arc_id, current_stage_id)
values (
(select id from agent_personas where name = $1),
(select id from character_arcs where name = $2),
(select id from arc_stages where arc_id = (select id from character_arcs where name = $2) and name = $3)
)
on conflict (persona_id) do update
set arc_id = excluded.arc_id,
current_stage_id = excluded.current_stage_id,
updated_at = now()
`, personaName, arcName, startStageName)
if err != nil {
return fmt.Errorf("assign persona arc: %w", err)
}
return nil
}
func (db *DB) AdvancePersonaStage(ctx context.Context, personaName string) (ext.ArcStage, error) {
row := db.pool.QueryRow(ctx, `
with current as (
select pa.persona_id, pa.arc_id, pa.current_stage_id, s.stage_order
from persona_arc pa
join arc_stages s on s.id = pa.current_stage_id
where pa.persona_id = (select id from agent_personas where name = $1)
),
next_stage as (
select s.id, s.arc_id, s.name, s.stage_order, s.description, s.condition, s.created_at
from arc_stages s
join current c on c.arc_id = s.arc_id
where s.stage_order > c.stage_order
order by s.stage_order
limit 1
)
update persona_arc pa
set current_stage_id = next_stage.id, updated_at = now()
from next_stage, current
where pa.persona_id = current.persona_id
returning next_stage.id, next_stage.arc_id, next_stage.name, next_stage.stage_order, next_stage.description, next_stage.condition, next_stage.created_at
`, personaName)
var s ext.ArcStage
if err := row.Scan(&s.ID, &s.ArcID, &s.Name, &s.StageOrder, &s.Description, &s.Condition, &s.CreatedAt); err != nil {
return ext.ArcStage{}, fmt.Errorf("advance persona stage: %w", err)
}
return s, nil
}
func (db *DB) ResetPersonaStage(ctx context.Context, personaName string) (ext.ArcStage, error) {
row := db.pool.QueryRow(ctx, `
with first_stage as (
select s.id, s.arc_id, s.name, s.stage_order, s.description, s.condition, s.created_at
from arc_stages s
join persona_arc pa on pa.arc_id = s.arc_id
where pa.persona_id = (select id from agent_personas where name = $1)
order by s.stage_order
limit 1
)
update persona_arc pa
set current_stage_id = first_stage.id, updated_at = now()
from first_stage
where pa.persona_id = (select id from agent_personas where name = $1)
returning first_stage.id, first_stage.arc_id, first_stage.name, first_stage.stage_order, first_stage.description, first_stage.condition, first_stage.created_at
`, personaName)
var s ext.ArcStage
if err := row.Scan(&s.ID, &s.ArcID, &s.Name, &s.StageOrder, &s.Description, &s.Condition, &s.CreatedAt); err != nil {
return ext.ArcStage{}, fmt.Errorf("reset persona stage: %w", err)
}
return s, nil
}
// ──────────────────────────────────────────────
// Internal helpers
// ──────────────────────────────────────────────
type rawPart struct {
ID int64
Name string
PartType string
Description string
Summary string
Content string
Tags []string
Order int
Priority int
Source string
}
func (db *DB) listPersonaParts(ctx context.Context, personaID int64) ([]rawPart, error) {
rows, err := db.pool.Query(ctx, `
select ap.id, ap.name, ap.part_type, ap.description, ap.summary, ap.content, ap.tags::text[],
app.part_order, app.priority
from agent_parts ap
join agent_persona_parts app on app.part_id = ap.id
where app.persona_id = $1
order by app.part_order, ap.part_type, ap.name
`, personaID)
if err != nil {
return nil, fmt.Errorf("list persona parts: %w", err)
}
defer rows.Close()
var parts []rawPart
for rows.Next() {
var rp rawPart
var tags []string
if err := rows.Scan(&rp.ID, &rp.Name, &rp.PartType, &rp.Description, &rp.Summary, &rp.Content, &tags, &rp.Order, &rp.Priority); err != nil {
return nil, fmt.Errorf("scan persona part: %w", err)
}
rp.Tags = nilToEmptyStrings(tags)
rp.Source = "persona"
parts = append(parts, rp)
}
return parts, rows.Err()
}
func (db *DB) getArcStateAndParts(ctx context.Context, personaID int64) (*ext.PersonaArcState, []rawPart, error) {
row := db.pool.QueryRow(ctx, `
select ca.name, s.id, s.name, s.stage_order, s.description, s.condition
from persona_arc pa
join character_arcs ca on ca.id = pa.arc_id
join arc_stages s on s.id = pa.current_stage_id
where pa.persona_id = $1
`, personaID)
var arcName, stageName, stageDesc, stageCond string
var stageID int64
var stageOrder int
if err := row.Scan(&arcName, &stageID, &stageName, &stageOrder, &stageDesc, &stageCond); err != nil {
// No arc assigned — not an error
return nil, nil, nil
}
arcState := &ext.PersonaArcState{
ArcName: arcName,
StageName: stageName,
StageOrder: stageOrder,
Description: stageDesc,
Condition: stageCond,
}
rows, err := db.pool.Query(ctx, `
select ap.id, ap.name, ap.part_type, ap.description, ap.summary, ap.content, ap.tags::text[]
from agent_parts ap
join arc_stage_parts asp on asp.part_id = ap.id
where asp.stage_id = $1
order by ap.part_type, ap.name
`, stageID)
if err != nil {
return nil, nil, fmt.Errorf("list stage parts: %w", err)
}
defer rows.Close()
var parts []rawPart
for rows.Next() {
var rp rawPart
var tags []string
if err := rows.Scan(&rp.ID, &rp.Name, &rp.PartType, &rp.Description, &rp.Summary, &rp.Content, &tags); err != nil {
return nil, nil, fmt.Errorf("scan stage part: %w", err)
}
rp.Tags = nilToEmptyStrings(tags)
rp.Source = "arc_stage"
parts = append(parts, rp)
}
return arcState, parts, rows.Err()
}
// assembleParts merges base parts with override parts. Override parts replace all
// base parts of the same part_type.
func assembleParts(base, overrides []rawPart, overrideSource string) []rawPart {
if len(overrides) == 0 {
return append([]rawPart{}, base...)
}
overrideTypes := make(map[string]bool)
for _, op := range overrides {
overrideTypes[op.PartType] = true
}
result := make([]rawPart, 0, len(base)+len(overrides))
for _, bp := range base {
if !overrideTypes[bp.PartType] {
result = append(result, bp)
}
}
for _, op := range overrides {
op.Source = overrideSource
result = append(result, op)
}
return result
}
func (db *DB) fetchPartsByNames(ctx context.Context, names []string) ([]rawPart, error) {
if len(names) == 0 {
return nil, nil
}
rows, err := db.pool.Query(ctx, `
select id, name, part_type, description, summary, content, tags::text[]
from agent_parts where name = any($1)
`, names)
if err != nil {
return nil, fmt.Errorf("fetch parts by names: %w", err)
}
defer rows.Close()
var parts []rawPart
for rows.Next() {
var rp rawPart
var tags []string
if err := rows.Scan(&rp.ID, &rp.Name, &rp.PartType, &rp.Description, &rp.Summary, &rp.Content, &tags); err != nil {
return nil, fmt.Errorf("scan part by name: %w", err)
}
rp.Tags = nilToEmptyStrings(tags)
parts = append(parts, rp)
}
return parts, rows.Err()
}
func (db *DB) listPersonaSkills(ctx context.Context, personaID int64) ([]AgentSkillRow, error) {
rows, err := db.pool.Query(ctx, `
select s.id, s.name, s.description, s.content, s.tags::text[]
from agent_skills s
join agent_persona_skills aps on aps.skill_id = s.id
where aps.persona_id = $1
order by s.name
`, personaID)
if err != nil {
return nil, fmt.Errorf("list persona skills: %w", err)
}
defer rows.Close()
var skills []AgentSkillRow
for rows.Next() {
var s AgentSkillRow
var tags []string
if err := rows.Scan(&s.ID, &s.Name, &s.Description, &s.Content, &tags); err != nil {
return nil, fmt.Errorf("scan persona skill: %w", err)
}
s.Tags = nilToEmptyStrings(tags)
skills = append(skills, s)
}
return skills, rows.Err()
}
func (db *DB) listPersonaGuardrails(ctx context.Context, personaID int64) ([]AgentGuardrailRow, error) {
rows, err := db.pool.Query(ctx, `
select g.id, g.name, g.description, g.content, g.severity, g.tags::text[]
from agent_guardrails g
join agent_persona_guardrails apg on apg.guardrail_id = g.id
where apg.persona_id = $1
order by g.name
`, personaID)
if err != nil {
return nil, fmt.Errorf("list persona guardrails: %w", err)
}
defer rows.Close()
var gs []AgentGuardrailRow
for rows.Next() {
var g AgentGuardrailRow
var tags []string
if err := rows.Scan(&g.ID, &g.Name, &g.Description, &g.Content, &g.Severity, &tags); err != nil {
return nil, fmt.Errorf("scan persona guardrail: %w", err)
}
g.Tags = nilToEmptyStrings(tags)
gs = append(gs, g)
}
return gs, rows.Err()
}
func (db *DB) listPersonaTraits(ctx context.Context, personaID int64) ([]ext.Trait, error) {
rows, err := db.pool.Query(ctx, `
select t.id, t.guid, t.name, t.trait_type, t.description, t.instruction, t.tags::text[], t.created_at, t.updated_at
from agent_traits t
join agent_persona_traits apt on apt.trait_id = t.id
where apt.persona_id = $1
order by t.trait_type, t.name
`, personaID)
if err != nil {
return nil, fmt.Errorf("list persona traits: %w", err)
}
defer rows.Close()
var traits []ext.Trait
for rows.Next() {
t, err := scanTrait(rows)
if err != nil {
return nil, fmt.Errorf("scan persona trait: %w", err)
}
traits = append(traits, t)
}
return traits, rows.Err()
}
// Minimal row types used internally (avoid importing full ext types for joins)
type AgentSkillRow struct {
ID int64
Name string
Description string
Content string
Tags []string
}
type AgentGuardrailRow struct {
ID int64
Name string
Description string
Content string
Severity string
Tags []string
}
func overrideValues(m map[string]string) []string {
out := make([]string, 0, len(m))
for _, v := range m {
out = append(out, v)
}
return out
}
// ──────────────────────────────────────────────
// Scanners
// ──────────────────────────────────────────────
type personaScanner interface{ Scan(dest ...any) error }
type partScanner interface{ Scan(dest ...any) error }
type traitScanner interface{ Scan(dest ...any) error }
func scanPersona(row personaScanner) (ext.Persona, error) {
var p ext.Persona
var tags []string
if err := row.Scan(&p.ID, &p.GUID, &p.Name, &p.Description, &p.Summary, &p.Detail,
&p.CompiledSummary, &p.CompiledDetail, &p.CompiledAt, &tags, &p.CreatedAt, &p.UpdatedAt); err != nil {
return ext.Persona{}, err
}
p.Tags = nilToEmptyStrings(tags)
return p, nil
}
func scanPart(row partScanner) (ext.Part, error) {
var p ext.Part
var tags []string
if err := row.Scan(&p.ID, &p.GUID, &p.Name, &p.PartType, &p.Description, &p.Summary, &p.Content, &tags, &p.CreatedAt, &p.UpdatedAt); err != nil {
return ext.Part{}, err
}
p.Tags = nilToEmptyStrings(tags)
return p, nil
}
func scanTrait(row traitScanner) (ext.Trait, error) {
var t ext.Trait
var tags []string
if err := row.Scan(&t.ID, &t.GUID, &t.Name, &t.TraitType, &t.Description, &t.Instruction, &tags, &t.CreatedAt, &t.UpdatedAt); err != nil {
return ext.Trait{}, err
}
t.Tags = nilToEmptyStrings(tags)
return t, nil
}