feat(db): add project personas and skills tables
CI / build-and-test (push) Failing after 1m52s

* Introduce project_personas table with foreign keys to projects and agent_personas
* Add project_skills table with foreign key to projects and agent_skills
* Include override boolean field in agent_persona_skills and project_skills
* Update schema and migration files to reflect new tables and fields
* Enhance CORS handling to reflect request origin
This commit is contained in:
2026-07-04 23:45:51 +02:00
parent 1adf50e3db
commit c179e014ad
69 changed files with 1329 additions and 184 deletions
+2 -1
View File
@@ -422,6 +422,7 @@ func (t *AgentPersonasTool) RemovePersonaPart(ctx context.Context, _ *mcp.CallTo
type AddPersonaSkillInput struct {
PersonaID int64 `json:"persona_id" jsonschema:"persona id"`
SkillID int64 `json:"skill_id" jsonschema:"agent skill id to link"`
Override bool `json:"override,omitempty" jsonschema:"replace a project skill with the same name during world-model assembly"`
}
type AddPersonaSkillOutput struct {
@@ -430,7 +431,7 @@ type AddPersonaSkillOutput struct {
}
func (t *AgentPersonasTool) AddPersonaSkill(ctx context.Context, _ *mcp.CallToolRequest, in AddPersonaSkillInput) (*mcp.CallToolResult, AddPersonaSkillOutput, error) {
if err := t.store.AddPersonaSkill(ctx, in.PersonaID, in.SkillID); err != nil {
if err := t.store.AddPersonaSkill(ctx, in.PersonaID, in.SkillID, in.Override); err != nil {
return nil, AddPersonaSkillOutput{}, err
}
return nil, AddPersonaSkillOutput{PersonaID: in.PersonaID, SkillID: in.SkillID}, nil
+82
View File
@@ -0,0 +1,82 @@
package tools
import (
"context"
"github.com/modelcontextprotocol/go-sdk/mcp"
"git.warky.dev/wdevs/amcs/internal/session"
"git.warky.dev/wdevs/amcs/internal/store"
ext "git.warky.dev/wdevs/amcs/internal/types"
)
type ProjectPersonasTool struct {
store *store.DB
sessions *session.ActiveProjects
}
func NewProjectPersonasTool(db *store.DB, sessions *session.ActiveProjects) *ProjectPersonasTool {
return &ProjectPersonasTool{store: db, sessions: sessions}
}
type ProjectPersonaInput struct {
Project string `json:"project,omitempty" jsonschema:"project name or id (uses active project if omitted)"`
PersonaID int64 `json:"persona_id" jsonschema:"persona id"`
IsDefault bool `json:"is_default,omitempty" jsonschema:"make this the default persona for the project"`
}
type ProjectPersonaOutput struct {
ProjectID int64 `json:"project_id"`
PersonaID int64 `json:"persona_id"`
}
type ListProjectPersonasInput struct {
Project string `json:"project,omitempty" jsonschema:"project name or id (uses active project if omitted)"`
}
type ListProjectPersonasOutput struct {
ProjectID int64 `json:"project_id"`
Personas []ext.ProjectPersona `json:"personas"`
}
func (t *ProjectPersonasTool) Add(ctx context.Context, req *mcp.CallToolRequest, in ProjectPersonaInput) (*mcp.CallToolResult, ProjectPersonaOutput, error) {
project, err := resolveProject(ctx, t.store, t.sessions, req, in.Project, true)
if err != nil {
return nil, ProjectPersonaOutput{}, err
}
if err := t.store.AddProjectPersona(ctx, project.NumericID, in.PersonaID, in.IsDefault); err != nil {
return nil, ProjectPersonaOutput{}, err
}
return nil, ProjectPersonaOutput{ProjectID: project.NumericID, PersonaID: in.PersonaID}, nil
}
func (t *ProjectPersonasTool) Remove(ctx context.Context, req *mcp.CallToolRequest, in ProjectPersonaInput) (*mcp.CallToolResult, ProjectPersonaOutput, error) {
project, err := resolveProject(ctx, t.store, t.sessions, req, in.Project, true)
if err != nil {
return nil, ProjectPersonaOutput{}, err
}
if err := t.store.RemoveProjectPersona(ctx, project.NumericID, in.PersonaID); err != nil {
return nil, ProjectPersonaOutput{}, err
}
return nil, ProjectPersonaOutput{ProjectID: project.NumericID, PersonaID: in.PersonaID}, nil
}
func (t *ProjectPersonasTool) SetDefault(ctx context.Context, req *mcp.CallToolRequest, in ProjectPersonaInput) (*mcp.CallToolResult, ProjectPersonaOutput, error) {
in.IsDefault = true
return t.Add(ctx, req, in)
}
func (t *ProjectPersonasTool) List(ctx context.Context, req *mcp.CallToolRequest, in ListProjectPersonasInput) (*mcp.CallToolResult, ListProjectPersonasOutput, error) {
project, err := resolveProject(ctx, t.store, t.sessions, req, in.Project, true)
if err != nil {
return nil, ListProjectPersonasOutput{}, err
}
personas, err := t.store.ListProjectPersonas(ctx, project.NumericID)
if err != nil {
return nil, ListProjectPersonasOutput{}, err
}
if personas == nil {
personas = []ext.ProjectPersona{}
}
return nil, ListProjectPersonasOutput{ProjectID: project.NumericID, Personas: personas}, nil
}
+4 -3
View File
@@ -259,8 +259,9 @@ func (t *SkillsTool) GetGuardrail(ctx context.Context, _ *mcp.CallToolRequest, i
// add_project_skill
type AddProjectSkillInput struct {
Project string `json:"project,omitempty" jsonschema:"project name or id (uses active project if omitted)"`
SkillID int64 `json:"skill_id" jsonschema:"skill id to link"`
Project string `json:"project,omitempty" jsonschema:"project name or id (uses active project if omitted)"`
SkillID int64 `json:"skill_id" jsonschema:"skill id to link"`
Override bool `json:"override,omitempty" jsonschema:"replace a lower-precedence skill with the same name during world-model assembly"`
}
type AddProjectSkillOutput struct {
@@ -273,7 +274,7 @@ func (t *SkillsTool) AddProjectSkill(ctx context.Context, req *mcp.CallToolReque
if err != nil {
return nil, AddProjectSkillOutput{}, err
}
if err := t.store.AddProjectSkill(ctx, project.NumericID, in.SkillID); err != nil {
if err := t.store.AddProjectSkill(ctx, project.NumericID, in.SkillID, in.Override); err != nil {
return nil, AddProjectSkillOutput{}, err
}
return nil, AddProjectSkillOutput{ProjectID: project.NumericID, SkillID: in.SkillID}, nil
+183
View File
@@ -0,0 +1,183 @@
package tools
import (
"context"
"strings"
"time"
"github.com/modelcontextprotocol/go-sdk/mcp"
"git.warky.dev/wdevs/amcs/internal/session"
"git.warky.dev/wdevs/amcs/internal/store"
ext "git.warky.dev/wdevs/amcs/internal/types"
)
type WorldModelTool struct {
store *store.DB
sessions *session.ActiveProjects
}
func NewWorldModelTool(db *store.DB, sessions *session.ActiveProjects) *WorldModelTool {
return &WorldModelTool{store: db, sessions: sessions}
}
type BootstrapWorldModelInput struct {
Project string `json:"project" jsonschema:"project name or id; must be explicit for deterministic startup"`
ContextLimit int `json:"context_limit,omitempty" jsonschema:"recent project thoughts to include (default 10, maximum 50)"`
}
type EffectiveSkill struct {
ID int64 `json:"id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Content string `json:"content,omitempty"`
Tags []string `json:"tags"`
Source string `json:"source"`
Override bool `json:"override"`
}
type WorldModelContextItem struct {
ID int64 `json:"id"`
Content string `json:"content"`
Metadata ext.ThoughtMetadata `json:"metadata"`
CreatedAt time.Time `json:"created_at"`
}
type BootstrapWorldModelOutput struct {
Version int `json:"version"`
GeneratedAt time.Time `json:"generated_at"`
Project ext.Project `json:"project"`
Skills []EffectiveSkill `json:"skills"`
Guardrails []ext.AgentGuardrail `json:"guardrails"`
Personas []ext.ProjectPersona `json:"personas"`
PersonaManifests []ext.PersonaManifest `json:"persona_manifests"`
DefaultPersona *ext.PersonaFull `json:"default_persona,omitempty"`
Context []WorldModelContextItem `json:"context"`
}
func (t *WorldModelTool) Bootstrap(ctx context.Context, req *mcp.CallToolRequest, in BootstrapWorldModelInput) (*mcp.CallToolResult, BootstrapWorldModelOutput, error) {
project, err := resolveProject(ctx, t.store, t.sessions, req, in.Project, true)
if err != nil {
return nil, BootstrapWorldModelOutput{}, err
}
if t.sessions != nil && req != nil && req.Session != nil {
t.sessions.Set(req.Session.ID(), project.ID)
}
projectSkills, err := t.store.ListProjectSkills(ctx, project.NumericID)
if err != nil {
return nil, BootstrapWorldModelOutput{}, err
}
guardrails, err := t.store.ListProjectGuardrails(ctx, project.NumericID)
if err != nil {
return nil, BootstrapWorldModelOutput{}, err
}
personas, err := t.store.ListProjectPersonas(ctx, project.NumericID)
if err != nil {
return nil, BootstrapWorldModelOutput{}, err
}
manifests := make([]ext.PersonaManifest, 0, len(personas))
for _, linked := range personas {
manifest, err := t.store.GetPersonaManifest(ctx, linked.Persona.Name)
if err != nil {
return nil, BootstrapWorldModelOutput{}, err
}
manifests = append(manifests, manifest)
}
var defaultPersona *ext.PersonaFull
for _, linked := range personas {
if !linked.IsDefault {
continue
}
loaded, err := t.store.GetPersona(ctx, linked.Persona.Name, true, nil)
if err != nil {
return nil, BootstrapWorldModelOutput{}, err
}
defaultPersona = &loaded
break
}
skills := mergeWorldModelSkills(projectSkills, defaultPersona)
guardrails = mergeWorldModelGuardrails(guardrails, defaultPersona)
limit := in.ContextLimit
if limit <= 0 {
limit = 10
}
if limit > 50 {
limit = 50
}
recent, err := t.store.RecentThoughts(ctx, &project.NumericID, limit, 0)
if err != nil {
return nil, BootstrapWorldModelOutput{}, err
}
contextItems := make([]WorldModelContextItem, 0, len(recent))
for _, thought := range recent {
contextItems = append(contextItems, WorldModelContextItem{ID: thought.ID, Content: thought.Content, Metadata: thought.Metadata, CreatedAt: thought.CreatedAt})
}
if guardrails == nil {
guardrails = []ext.AgentGuardrail{}
}
if personas == nil {
personas = []ext.ProjectPersona{}
}
_ = t.store.TouchProject(ctx, project.NumericID)
return nil, BootstrapWorldModelOutput{
Version: 1, GeneratedAt: time.Now().UTC(), Project: *project, Skills: skills,
Guardrails: guardrails, Personas: personas, PersonaManifests: manifests,
DefaultPersona: defaultPersona, Context: contextItems,
}, nil
}
func mergeWorldModelSkills(projectSkills []ext.AgentSkill, persona *ext.PersonaFull) []EffectiveSkill {
result := make([]EffectiveSkill, 0, len(projectSkills))
index := make(map[string]int, len(projectSkills))
for _, skill := range projectSkills {
key := strings.ToLower(skill.Name)
index[key] = len(result)
result = append(result, EffectiveSkill{ID: skill.ID, Name: skill.Name, Description: skill.Description, Content: skill.Content, Tags: skill.Tags, Source: "project", Override: skill.Override})
}
if persona == nil {
return result
}
for _, skill := range persona.Skills {
key := strings.ToLower(skill.Name)
entry := EffectiveSkill{ID: skill.ID, Name: skill.Name, Description: skill.Description, Content: skill.Content, Tags: skill.Tags, Source: "persona", Override: skill.Override}
if i, ok := index[key]; ok {
if skill.Override {
result[i] = entry
}
continue
}
index[key] = len(result)
result = append(result, entry)
}
return result
}
func mergeWorldModelGuardrails(project []ext.AgentGuardrail, persona *ext.PersonaFull) []ext.AgentGuardrail {
result := append([]ext.AgentGuardrail(nil), project...)
index := make(map[string]int, len(result))
for i, guardrail := range result {
index[strings.ToLower(guardrail.Name)] = i
}
if persona == nil {
return result
}
severity := map[string]int{"low": 1, "medium": 2, "high": 3, "critical": 4}
for _, guardrail := range persona.Guardrails {
key := strings.ToLower(guardrail.Name)
if i, ok := index[key]; ok {
if severity[guardrail.Severity] > severity[result[i].Severity] {
result[i].Severity = guardrail.Severity
result[i].Content = guardrail.Content
}
continue
}
index[key] = len(result)
result = append(result, ext.AgentGuardrail{ID: guardrail.ID, Name: guardrail.Name, Description: guardrail.Description, Content: guardrail.Content, Severity: guardrail.Severity, Tags: guardrail.Tags})
}
return result
}
+52
View File
@@ -0,0 +1,52 @@
package tools
import (
"testing"
ext "git.warky.dev/wdevs/amcs/internal/types"
)
func TestMergeWorldModelSkills(t *testing.T) {
project := []ext.AgentSkill{
{ID: 1, Name: "go", Content: "project", Tags: []string{}},
{ID: 2, Name: "postgres", Content: "database", Tags: []string{}},
}
persona := &ext.PersonaFull{Skills: []ext.PersonaSkillEntry{
{ID: 1, Name: "go", Content: "ignored additive duplicate", Tags: []string{}},
{ID: 3, Name: "Postgres", Content: "persona override", Tags: []string{}, Override: true},
{ID: 4, Name: "writing", Content: "additive", Tags: []string{}},
}}
got := mergeWorldModelSkills(project, persona)
if len(got) != 3 {
t.Fatalf("len(skills) = %d, want 3", len(got))
}
if got[0].Content != "project" || got[0].Source != "project" {
t.Fatalf("additive duplicate replaced project skill: %#v", got[0])
}
if got[1].Content != "persona override" || got[1].Source != "persona" || !got[1].Override {
t.Fatalf("override did not replace project skill: %#v", got[1])
}
if got[2].Name != "writing" || got[2].Source != "persona" {
t.Fatalf("new additive persona skill missing: %#v", got[2])
}
}
func TestMergeWorldModelGuardrailsKeepsStrictest(t *testing.T) {
project := []ext.AgentGuardrail{{ID: 1, Name: "safe", Severity: "high", Content: "project"}}
persona := &ext.PersonaFull{Guardrails: []ext.PersonaGuardrailEntry{
{ID: 2, Name: "Safe", Severity: "low", Content: "weaker"},
{ID: 3, Name: "privacy", Severity: "critical", Content: "new"},
}}
got := mergeWorldModelGuardrails(project, persona)
if len(got) != 2 {
t.Fatalf("len(guardrails) = %d, want 2", len(got))
}
if got[0].Severity != "high" || got[0].Content != "project" {
t.Fatalf("persona weakened project guardrail: %#v", got[0])
}
if got[1].Name != "privacy" || got[1].Severity != "critical" {
t.Fatalf("new persona guardrail missing: %#v", got[1])
}
}