From 3c1ca83dc913887a7b87e0573f4a26ce6a8e40f9 Mon Sep 17 00:00:00 2001 From: "Hein (Warky)" Date: Mon, 30 Mar 2026 23:35:54 +0200 Subject: [PATCH] feat: add agent skills and guardrails functionality - Introduced new tools for managing agent skills and guardrails, including add, remove, and list operations. - Updated README.md to document new commands and usage patterns for skills and guardrails. - Enhanced server configuration to support longer read and write timeouts. - Increased maximum upload size for files to 100 MB and adjusted related configurations. - Created database migrations for agent skills, guardrails, and their associations with projects. - Updated relevant code files to integrate new skills and guardrails into the application logic. --- README.md | 78 +++++ configs/config.example.yaml | 4 +- configs/dev.yaml | 4 +- configs/docker.yaml | 4 +- internal/app/app.go | 1 + internal/app/files.go | 7 +- internal/config/loader.go | 4 +- internal/mcpserver/server.go | 64 ++++ internal/store/skills.go | 294 +++++++++++++++++++ internal/tools/skills.go | 322 +++++++++++++++++++++ internal/types/extensions.go | 29 +- llm/memory.md | 19 +- migrations/017_agent_skills_guardrails.sql | 39 +++ migrations/100_rls_and_grants.sql | 7 + 14 files changed, 862 insertions(+), 14 deletions(-) create mode 100644 internal/store/skills.go create mode 100644 internal/tools/skills.go create mode 100644 migrations/017_agent_skills_guardrails.sql diff --git a/README.md b/README.md index 8d49dc6..7af9004 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,51 @@ A Go MCP server for capturing and retrieving thoughts, memory, and project conte | `backfill_embeddings` | Generate missing embeddings for stored thoughts | | `reparse_thought_metadata` | Re-extract and normalize metadata for stored thoughts | | `retry_failed_metadata` | Retry metadata extraction for thoughts still pending or failed | +| `add_skill` | Store a reusable agent skill (behavioural instruction or capability prompt) | +| `remove_skill` | Delete an agent skill by id | +| `list_skills` | List all agent skills, optionally filtered by tag | +| `add_guardrail` | Store a reusable agent guardrail (constraint or safety rule) | +| `remove_guardrail` | Delete an agent guardrail by id | +| `list_guardrails` | List all agent guardrails, optionally filtered by tag or severity | +| `add_project_skill` | Link an agent skill to a project | +| `remove_project_skill` | Unlink an agent skill from a project | +| `list_project_skills` | List all skills linked to a project | +| `add_project_guardrail` | Link an agent guardrail to a project | +| `remove_project_guardrail` | Unlink an agent guardrail from a project | +| `list_project_guardrails` | List all guardrails linked to a project | + +## Agent Skills and Guardrails + +Skills and guardrails are reusable agent behaviour instructions and constraints that can be attached to projects. + +**At the start of every project session, always call `list_project_skills` and `list_project_guardrails` first.** Use the returned skills and guardrails to guide agent behaviour for that project. Only generate or create new skills/guardrails if none are returned. + +### Skills + +A skill is a reusable behavioural instruction or capability prompt — for example, "always respond in structured markdown" or "break complex tasks into numbered steps before starting". + +```json +{ "name": "structured-output", "description": "Enforce markdown output format", "content": "Always structure responses using markdown headers and bullet points.", "tags": ["formatting"] } +``` + +### Guardrails + +A guardrail is a constraint or safety rule — for example, "never delete files without explicit confirmation" or "do not expose secrets in output". + +```json +{ "name": "no-silent-deletes", "description": "Require confirmation before deletes", "content": "Never delete, drop, or truncate data without first confirming with the user.", "severity": "high", "tags": ["safety"] } +``` + +Severity levels: `low`, `medium`, `high`, `critical`. + +### Project linking + +Link existing skills and guardrails to a project so they are automatically available when that project is active: + +```json +{ "project": "my-project", "skill_id": "" } +{ "project": "my-project", "guardrail_id": "" } +``` ## Configuration @@ -169,6 +214,8 @@ List files for a thought or project with: AMCS also supports direct authenticated HTTP uploads to `/files` for clients that want to stream file bodies instead of base64-encoding them into an MCP tool call. +The Go server caps `/files` uploads at 100 MB per request. Large uploads are still also subject to available memory, Postgres limits, and any reverse proxy or load balancer limits in front of AMCS. + Multipart upload: ```bash @@ -262,6 +309,37 @@ Or add directly to `opencode.json` / `~/.config/opencode/config.json`: } ``` +## Apache Proxy + +If AMCS is deployed behind Apache HTTP Server, configure the proxy explicitly for larger uploads and longer-running requests. + +Example virtual host settings for the current AMCS defaults: + +```apache + + ServerName amcs.example.com + + ProxyPreserveHost On + LimitRequestBody 104857600 + RequestReadTimeout handshake=0 header=20-40,MinRate=500 body=600,MinRate=500 + Timeout 600 + ProxyTimeout 600 + + ProxyPass /mcp http://127.0.0.1:8080/mcp connectiontimeout=30 timeout=600 + ProxyPassReverse /mcp http://127.0.0.1:8080/mcp + + ProxyPass /files http://127.0.0.1:8080/files connectiontimeout=30 timeout=600 + ProxyPassReverse /files http://127.0.0.1:8080/files + +``` + +Recommended Apache settings: + +- `LimitRequestBody 104857600` matches AMCS's 100 MB `/files` upload cap. +- `RequestReadTimeout ... body=600` gives clients up to 10 minutes to send larger request bodies. +- `ProxyTimeout 600` and `ProxyPass ... timeout=600` give Apache enough time to wait for the Go backend. +- If another proxy or load balancer sits in front of Apache, align its size and timeout settings too. + ## Development Run the SQL migrations against a local database with: diff --git a/configs/config.example.yaml b/configs/config.example.yaml index 4b176cc..4888828 100644 --- a/configs/config.example.yaml +++ b/configs/config.example.yaml @@ -1,8 +1,8 @@ server: host: "0.0.0.0" port: 8080 - read_timeout: "15s" - write_timeout: "30s" + read_timeout: "10m" + write_timeout: "10m" idle_timeout: "60s" allowed_origins: - "*" diff --git a/configs/dev.yaml b/configs/dev.yaml index 3e7766c..bd65c25 100644 --- a/configs/dev.yaml +++ b/configs/dev.yaml @@ -1,8 +1,8 @@ server: host: "0.0.0.0" port: 8080 - read_timeout: "15s" - write_timeout: "30s" + read_timeout: "10m" + write_timeout: "10m" idle_timeout: "60s" allowed_origins: - "*" diff --git a/configs/docker.yaml b/configs/docker.yaml index 1cda2be..7255d09 100644 --- a/configs/docker.yaml +++ b/configs/docker.yaml @@ -1,8 +1,8 @@ server: host: "0.0.0.0" port: 8080 - read_timeout: "15s" - write_timeout: "30s" + read_timeout: "10m" + write_timeout: "10m" idle_timeout: "60s" allowed_origins: - "*" diff --git a/internal/app/app.go b/internal/app/app.go index 0fe9425..b811f05 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -173,6 +173,7 @@ func routes(logger *slog.Logger, cfg *config.Config, db *store.DB, provider ai.P Calendar: tools.NewCalendarTool(db), Meals: tools.NewMealsTool(db), CRM: tools.NewCRMTool(db), + Skills: tools.NewSkillsTool(db, activeProjects), } mcpHandler := mcpserver.New(cfg.MCP, toolSet) diff --git a/internal/app/files.go b/internal/app/files.go index 223e9a2..e1b6cd8 100644 --- a/internal/app/files.go +++ b/internal/app/files.go @@ -11,7 +11,10 @@ import ( "git.warky.dev/wdevs/amcs/internal/tools" ) -const maxUploadBytes = 50 << 20 +const ( + maxUploadBytes = 100 << 20 + multipartFormMemory = 32 << 20 +) func fileUploadHandler(files *tools.FilesTool) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -53,7 +56,7 @@ func parseUploadRequest(r *http.Request) (tools.SaveFileDecodedInput, error) { } func parseMultipartUpload(r *http.Request) (tools.SaveFileDecodedInput, error) { - if err := r.ParseMultipartForm(maxUploadBytes); err != nil { + if err := r.ParseMultipartForm(multipartFormMemory); err != nil { return tools.SaveFileDecodedInput{}, err } diff --git a/internal/config/loader.go b/internal/config/loader.go index 00aca67..cef1ce9 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -50,8 +50,8 @@ func defaultConfig() Config { Server: ServerConfig{ Host: "0.0.0.0", Port: 8080, - ReadTimeout: 15 * time.Second, - WriteTimeout: 30 * time.Second, + ReadTimeout: 10 * time.Minute, + WriteTimeout: 10 * time.Minute, IdleTimeout: 60 * time.Second, }, MCP: MCPConfig{ diff --git a/internal/mcpserver/server.go b/internal/mcpserver/server.go index 082f604..579bde7 100644 --- a/internal/mcpserver/server.go +++ b/internal/mcpserver/server.go @@ -33,6 +33,7 @@ type ToolSet struct { Calendar *tools.CalendarTool Meals *tools.MealsTool CRM *tools.CRMTool + Skills *tools.SkillsTool } func New(cfg config.MCPConfig, toolSet ToolSet) http.Handler { @@ -306,6 +307,69 @@ func New(cfg config.MCPConfig, toolSet ToolSet) http.Handler { Description: "Append a stored thought to a contact's notes.", }, toolSet.CRM.LinkThought) + // Agent Skills + addTool(server, &mcp.Tool{ + Name: "add_skill", + Description: "Store a reusable agent skill (behavioural instruction or capability prompt).", + }, toolSet.Skills.AddSkill) + + addTool(server, &mcp.Tool{ + Name: "remove_skill", + Description: "Delete an agent skill by id.", + }, toolSet.Skills.RemoveSkill) + + addTool(server, &mcp.Tool{ + Name: "list_skills", + Description: "List all agent skills, optionally filtered by tag.", + }, toolSet.Skills.ListSkills) + + // Agent Guardrails + addTool(server, &mcp.Tool{ + Name: "add_guardrail", + Description: "Store a reusable agent guardrail (constraint or safety rule).", + }, toolSet.Skills.AddGuardrail) + + addTool(server, &mcp.Tool{ + Name: "remove_guardrail", + Description: "Delete an agent guardrail by id.", + }, toolSet.Skills.RemoveGuardrail) + + addTool(server, &mcp.Tool{ + Name: "list_guardrails", + Description: "List all agent guardrails, optionally filtered by tag or severity.", + }, toolSet.Skills.ListGuardrails) + + // Project Skills & Guardrails + addTool(server, &mcp.Tool{ + Name: "add_project_skill", + Description: "Link an agent skill to a project.", + }, toolSet.Skills.AddProjectSkill) + + addTool(server, &mcp.Tool{ + Name: "remove_project_skill", + Description: "Unlink an agent skill from a project.", + }, toolSet.Skills.RemoveProjectSkill) + + addTool(server, &mcp.Tool{ + Name: "list_project_skills", + Description: "List all skills linked to a project. Call this at the start of a project session to load existing agent behaviour instructions before generating new ones.", + }, toolSet.Skills.ListProjectSkills) + + addTool(server, &mcp.Tool{ + Name: "add_project_guardrail", + Description: "Link an agent guardrail to a project.", + }, toolSet.Skills.AddProjectGuardrail) + + addTool(server, &mcp.Tool{ + Name: "remove_project_guardrail", + Description: "Unlink an agent guardrail from a project.", + }, toolSet.Skills.RemoveProjectGuardrail) + + addTool(server, &mcp.Tool{ + Name: "list_project_guardrails", + Description: "List all guardrails linked to a project. Call this at the start of a project session to load existing agent constraints before generating new ones.", + }, toolSet.Skills.ListProjectGuardrails) + return mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { return server }, &mcp.StreamableHTTPOptions{ diff --git a/internal/store/skills.go b/internal/store/skills.go new file mode 100644 index 0000000..d0ee167 --- /dev/null +++ b/internal/store/skills.go @@ -0,0 +1,294 @@ +package store + +import ( + "context" + "fmt" + "strings" + + "github.com/google/uuid" + + ext "git.warky.dev/wdevs/amcs/internal/types" +) + +// Agent Skills + +func (db *DB) AddSkill(ctx context.Context, skill ext.AgentSkill) (ext.AgentSkill, error) { + if skill.Tags == nil { + skill.Tags = []string{} + } + row := db.pool.QueryRow(ctx, ` + insert into agent_skills (name, description, content, tags) + values ($1, $2, $3, $4) + returning id, created_at, updated_at + `, skill.Name, skill.Description, skill.Content, skill.Tags) + + created := skill + if err := row.Scan(&created.ID, &created.CreatedAt, &created.UpdatedAt); err != nil { + return ext.AgentSkill{}, fmt.Errorf("insert agent skill: %w", err) + } + return created, nil +} + +func (db *DB) RemoveSkill(ctx context.Context, id uuid.UUID) error { + tag, err := db.pool.Exec(ctx, `delete from agent_skills where id = $1`, id) + if err != nil { + return fmt.Errorf("delete agent skill: %w", err) + } + if tag.RowsAffected() == 0 { + return fmt.Errorf("skill not found") + } + return nil +} + +func (db *DB) ListSkills(ctx context.Context, tag string) ([]ext.AgentSkill, error) { + q := `select id, name, description, content, tags, created_at, updated_at from agent_skills` + 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 agent skills: %w", err) + } + defer rows.Close() + + var skills []ext.AgentSkill + for rows.Next() { + var s ext.AgentSkill + var desc *string + if err := rows.Scan(&s.ID, &s.Name, &desc, &s.Content, &s.Tags, &s.CreatedAt, &s.UpdatedAt); err != nil { + return nil, fmt.Errorf("scan agent skill: %w", err) + } + s.Description = strVal(desc) + if s.Tags == nil { + s.Tags = []string{} + } + skills = append(skills, s) + } + return skills, rows.Err() +} + +func (db *DB) GetSkill(ctx context.Context, id uuid.UUID) (ext.AgentSkill, error) { + row := db.pool.QueryRow(ctx, ` + select id, name, description, content, tags, created_at, updated_at + from agent_skills where id = $1 + `, id) + + var s ext.AgentSkill + var desc *string + if err := row.Scan(&s.ID, &s.Name, &desc, &s.Content, &s.Tags, &s.CreatedAt, &s.UpdatedAt); err != nil { + return ext.AgentSkill{}, fmt.Errorf("get agent skill: %w", err) + } + s.Description = strVal(desc) + if s.Tags == nil { + s.Tags = []string{} + } + return s, nil +} + +// Agent Guardrails + +func (db *DB) AddGuardrail(ctx context.Context, g ext.AgentGuardrail) (ext.AgentGuardrail, error) { + if g.Tags == nil { + g.Tags = []string{} + } + if g.Severity == "" { + g.Severity = "medium" + } + row := db.pool.QueryRow(ctx, ` + insert into agent_guardrails (name, description, content, severity, tags) + values ($1, $2, $3, $4, $5) + returning id, created_at, updated_at + `, g.Name, g.Description, g.Content, g.Severity, g.Tags) + + created := g + if err := row.Scan(&created.ID, &created.CreatedAt, &created.UpdatedAt); err != nil { + return ext.AgentGuardrail{}, fmt.Errorf("insert agent guardrail: %w", err) + } + return created, nil +} + +func (db *DB) RemoveGuardrail(ctx context.Context, id uuid.UUID) error { + tag, err := db.pool.Exec(ctx, `delete from agent_guardrails where id = $1`, id) + if err != nil { + return fmt.Errorf("delete agent guardrail: %w", err) + } + if tag.RowsAffected() == 0 { + return fmt.Errorf("guardrail not found") + } + return nil +} + +func (db *DB) ListGuardrails(ctx context.Context, tag, severity string) ([]ext.AgentGuardrail, error) { + args := []any{} + conditions := []string{} + + if t := strings.TrimSpace(tag); t != "" { + args = append(args, t) + conditions = append(conditions, fmt.Sprintf("$%d = any(tags)", len(args))) + } + if s := strings.TrimSpace(severity); s != "" { + args = append(args, s) + conditions = append(conditions, fmt.Sprintf("severity = $%d", len(args))) + } + + q := `select id, name, description, content, severity, tags, created_at, updated_at from agent_guardrails` + if len(conditions) > 0 { + q += " where " + strings.Join(conditions, " and ") + } + q += " order by name" + + rows, err := db.pool.Query(ctx, q, args...) + if err != nil { + return nil, fmt.Errorf("list agent guardrails: %w", err) + } + defer rows.Close() + + var guardrails []ext.AgentGuardrail + for rows.Next() { + var g ext.AgentGuardrail + var desc *string + if err := rows.Scan(&g.ID, &g.Name, &desc, &g.Content, &g.Severity, &g.Tags, &g.CreatedAt, &g.UpdatedAt); err != nil { + return nil, fmt.Errorf("scan agent guardrail: %w", err) + } + g.Description = strVal(desc) + if g.Tags == nil { + g.Tags = []string{} + } + guardrails = append(guardrails, g) + } + return guardrails, rows.Err() +} + +func (db *DB) GetGuardrail(ctx context.Context, id uuid.UUID) (ext.AgentGuardrail, error) { + row := db.pool.QueryRow(ctx, ` + select id, name, description, content, severity, tags, created_at, updated_at + from agent_guardrails where id = $1 + `, id) + + var g ext.AgentGuardrail + var desc *string + if err := row.Scan(&g.ID, &g.Name, &desc, &g.Content, &g.Severity, &g.Tags, &g.CreatedAt, &g.UpdatedAt); err != nil { + return ext.AgentGuardrail{}, fmt.Errorf("get agent guardrail: %w", err) + } + g.Description = strVal(desc) + if g.Tags == nil { + g.Tags = []string{} + } + return g, nil +} + +// Project Skills + +func (db *DB) AddProjectSkill(ctx context.Context, projectID, skillID uuid.UUID) error { + _, err := db.pool.Exec(ctx, ` + insert into project_skills (project_id, skill_id) + values ($1, $2) + on conflict do nothing + `, projectID, skillID) + if err != nil { + return fmt.Errorf("add project skill: %w", err) + } + return nil +} + +func (db *DB) RemoveProjectSkill(ctx context.Context, projectID, skillID uuid.UUID) error { + tag, err := db.pool.Exec(ctx, ` + delete from project_skills where project_id = $1 and skill_id = $2 + `, projectID, skillID) + if err != nil { + return fmt.Errorf("remove project skill: %w", err) + } + if tag.RowsAffected() == 0 { + return fmt.Errorf("project skill link not found") + } + return nil +} + +func (db *DB) ListProjectSkills(ctx context.Context, projectID uuid.UUID) ([]ext.AgentSkill, error) { + rows, err := db.pool.Query(ctx, ` + select s.id, s.name, s.description, s.content, s.tags, s.created_at, s.updated_at + from agent_skills s + join project_skills ps on ps.skill_id = s.id + where ps.project_id = $1 + order by s.name + `, projectID) + if err != nil { + return nil, fmt.Errorf("list project skills: %w", err) + } + defer rows.Close() + + var skills []ext.AgentSkill + for rows.Next() { + var s ext.AgentSkill + var desc *string + if err := rows.Scan(&s.ID, &s.Name, &desc, &s.Content, &s.Tags, &s.CreatedAt, &s.UpdatedAt); err != nil { + return nil, fmt.Errorf("scan project skill: %w", err) + } + s.Description = strVal(desc) + if s.Tags == nil { + s.Tags = []string{} + } + skills = append(skills, s) + } + return skills, rows.Err() +} + +// Project Guardrails + +func (db *DB) AddProjectGuardrail(ctx context.Context, projectID, guardrailID uuid.UUID) error { + _, err := db.pool.Exec(ctx, ` + insert into project_guardrails (project_id, guardrail_id) + values ($1, $2) + on conflict do nothing + `, projectID, guardrailID) + if err != nil { + return fmt.Errorf("add project guardrail: %w", err) + } + return nil +} + +func (db *DB) RemoveProjectGuardrail(ctx context.Context, projectID, guardrailID uuid.UUID) error { + tag, err := db.pool.Exec(ctx, ` + delete from project_guardrails where project_id = $1 and guardrail_id = $2 + `, projectID, guardrailID) + if err != nil { + return fmt.Errorf("remove project guardrail: %w", err) + } + if tag.RowsAffected() == 0 { + return fmt.Errorf("project guardrail link not found") + } + return nil +} + +func (db *DB) ListProjectGuardrails(ctx context.Context, projectID uuid.UUID) ([]ext.AgentGuardrail, error) { + rows, err := db.pool.Query(ctx, ` + select g.id, g.name, g.description, g.content, g.severity, g.tags, g.created_at, g.updated_at + from agent_guardrails g + join project_guardrails pg on pg.guardrail_id = g.id + where pg.project_id = $1 + order by g.name + `, projectID) + if err != nil { + return nil, fmt.Errorf("list project guardrails: %w", err) + } + defer rows.Close() + + var guardrails []ext.AgentGuardrail + for rows.Next() { + var g ext.AgentGuardrail + var desc *string + if err := rows.Scan(&g.ID, &g.Name, &desc, &g.Content, &g.Severity, &g.Tags, &g.CreatedAt, &g.UpdatedAt); err != nil { + return nil, fmt.Errorf("scan project guardrail: %w", err) + } + g.Description = strVal(desc) + if g.Tags == nil { + g.Tags = []string{} + } + guardrails = append(guardrails, g) + } + return guardrails, rows.Err() +} diff --git a/internal/tools/skills.go b/internal/tools/skills.go new file mode 100644 index 0000000..18fee06 --- /dev/null +++ b/internal/tools/skills.go @@ -0,0 +1,322 @@ +package tools + +import ( + "context" + "strings" + + "github.com/google/uuid" + "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 SkillsTool struct { + store *store.DB + sessions *session.ActiveProjects +} + +func NewSkillsTool(db *store.DB, sessions *session.ActiveProjects) *SkillsTool { + return &SkillsTool{store: db, sessions: sessions} +} + +// add_skill + +type AddSkillInput struct { + Name string `json:"name" jsonschema:"unique skill name"` + Description string `json:"description,omitempty" jsonschema:"short description of what the skill does"` + Content string `json:"content" jsonschema:"the full skill instruction or prompt content"` + Tags []string `json:"tags,omitempty" jsonschema:"optional tags for grouping or filtering"` +} + +type AddSkillOutput struct { + Skill ext.AgentSkill `json:"skill"` +} + +func (t *SkillsTool) AddSkill(ctx context.Context, _ *mcp.CallToolRequest, in AddSkillInput) (*mcp.CallToolResult, AddSkillOutput, error) { + if strings.TrimSpace(in.Name) == "" { + return nil, AddSkillOutput{}, errInvalidInput("name is required") + } + if strings.TrimSpace(in.Content) == "" { + return nil, AddSkillOutput{}, errInvalidInput("content is required") + } + if in.Tags == nil { + in.Tags = []string{} + } + skill, err := t.store.AddSkill(ctx, ext.AgentSkill{ + Name: strings.TrimSpace(in.Name), + Description: strings.TrimSpace(in.Description), + Content: strings.TrimSpace(in.Content), + Tags: in.Tags, + }) + if err != nil { + return nil, AddSkillOutput{}, err + } + return nil, AddSkillOutput{Skill: skill}, nil +} + +// remove_skill + +type RemoveSkillInput struct { + ID uuid.UUID `json:"id" jsonschema:"skill id to remove"` +} + +type RemoveSkillOutput struct { + Removed bool `json:"removed"` +} + +func (t *SkillsTool) RemoveSkill(ctx context.Context, _ *mcp.CallToolRequest, in RemoveSkillInput) (*mcp.CallToolResult, RemoveSkillOutput, error) { + if err := t.store.RemoveSkill(ctx, in.ID); err != nil { + return nil, RemoveSkillOutput{}, err + } + return nil, RemoveSkillOutput{Removed: true}, nil +} + +// list_skills + +type ListSkillsInput struct { + Tag string `json:"tag,omitempty" jsonschema:"filter by tag"` +} + +type ListSkillsOutput struct { + Skills []ext.AgentSkill `json:"skills"` +} + +func (t *SkillsTool) ListSkills(ctx context.Context, _ *mcp.CallToolRequest, in ListSkillsInput) (*mcp.CallToolResult, ListSkillsOutput, error) { + skills, err := t.store.ListSkills(ctx, in.Tag) + if err != nil { + return nil, ListSkillsOutput{}, err + } + if skills == nil { + skills = []ext.AgentSkill{} + } + return nil, ListSkillsOutput{Skills: skills}, nil +} + +// add_guardrail + +type AddGuardrailInput struct { + Name string `json:"name" jsonschema:"unique guardrail name"` + Description string `json:"description,omitempty" jsonschema:"short description of what the guardrail enforces"` + Content string `json:"content" jsonschema:"the full guardrail rule or constraint content"` + Severity string `json:"severity,omitempty" jsonschema:"one of: low, medium, high, critical (default medium)"` + Tags []string `json:"tags,omitempty" jsonschema:"optional tags for grouping or filtering"` +} + +type AddGuardrailOutput struct { + Guardrail ext.AgentGuardrail `json:"guardrail"` +} + +func (t *SkillsTool) AddGuardrail(ctx context.Context, _ *mcp.CallToolRequest, in AddGuardrailInput) (*mcp.CallToolResult, AddGuardrailOutput, error) { + if strings.TrimSpace(in.Name) == "" { + return nil, AddGuardrailOutput{}, errInvalidInput("name is required") + } + if strings.TrimSpace(in.Content) == "" { + return nil, AddGuardrailOutput{}, errInvalidInput("content is required") + } + severity := strings.TrimSpace(in.Severity) + if severity == "" { + severity = "medium" + } + switch severity { + case "low", "medium", "high", "critical": + default: + return nil, AddGuardrailOutput{}, errInvalidInput("severity must be one of: low, medium, high, critical") + } + if in.Tags == nil { + in.Tags = []string{} + } + guardrail, err := t.store.AddGuardrail(ctx, ext.AgentGuardrail{ + Name: strings.TrimSpace(in.Name), + Description: strings.TrimSpace(in.Description), + Content: strings.TrimSpace(in.Content), + Severity: severity, + Tags: in.Tags, + }) + if err != nil { + return nil, AddGuardrailOutput{}, err + } + return nil, AddGuardrailOutput{Guardrail: guardrail}, nil +} + +// remove_guardrail + +type RemoveGuardrailInput struct { + ID uuid.UUID `json:"id" jsonschema:"guardrail id to remove"` +} + +type RemoveGuardrailOutput struct { + Removed bool `json:"removed"` +} + +func (t *SkillsTool) RemoveGuardrail(ctx context.Context, _ *mcp.CallToolRequest, in RemoveGuardrailInput) (*mcp.CallToolResult, RemoveGuardrailOutput, error) { + if err := t.store.RemoveGuardrail(ctx, in.ID); err != nil { + return nil, RemoveGuardrailOutput{}, err + } + return nil, RemoveGuardrailOutput{Removed: true}, nil +} + +// list_guardrails + +type ListGuardrailsInput struct { + Tag string `json:"tag,omitempty" jsonschema:"filter by tag"` + Severity string `json:"severity,omitempty" jsonschema:"filter by severity: low, medium, high, or critical"` +} + +type ListGuardrailsOutput struct { + Guardrails []ext.AgentGuardrail `json:"guardrails"` +} + +func (t *SkillsTool) ListGuardrails(ctx context.Context, _ *mcp.CallToolRequest, in ListGuardrailsInput) (*mcp.CallToolResult, ListGuardrailsOutput, error) { + guardrails, err := t.store.ListGuardrails(ctx, in.Tag, in.Severity) + if err != nil { + return nil, ListGuardrailsOutput{}, err + } + if guardrails == nil { + guardrails = []ext.AgentGuardrail{} + } + return nil, ListGuardrailsOutput{Guardrails: guardrails}, nil +} + +// add_project_skill + +type AddProjectSkillInput struct { + Project string `json:"project,omitempty" jsonschema:"project name or id (uses active project if omitted)"` + SkillID uuid.UUID `json:"skill_id" jsonschema:"skill id to link"` +} + +type AddProjectSkillOutput struct { + ProjectID uuid.UUID `json:"project_id"` + SkillID uuid.UUID `json:"skill_id"` +} + +func (t *SkillsTool) AddProjectSkill(ctx context.Context, req *mcp.CallToolRequest, in AddProjectSkillInput) (*mcp.CallToolResult, AddProjectSkillOutput, error) { + project, err := resolveProject(ctx, t.store, t.sessions, req, in.Project, true) + if err != nil { + return nil, AddProjectSkillOutput{}, err + } + if err := t.store.AddProjectSkill(ctx, project.ID, in.SkillID); err != nil { + return nil, AddProjectSkillOutput{}, err + } + return nil, AddProjectSkillOutput{ProjectID: project.ID, SkillID: in.SkillID}, nil +} + +// remove_project_skill + +type RemoveProjectSkillInput struct { + Project string `json:"project,omitempty" jsonschema:"project name or id (uses active project if omitted)"` + SkillID uuid.UUID `json:"skill_id" jsonschema:"skill id to unlink"` +} + +type RemoveProjectSkillOutput struct { + Removed bool `json:"removed"` +} + +func (t *SkillsTool) RemoveProjectSkill(ctx context.Context, req *mcp.CallToolRequest, in RemoveProjectSkillInput) (*mcp.CallToolResult, RemoveProjectSkillOutput, error) { + project, err := resolveProject(ctx, t.store, t.sessions, req, in.Project, true) + if err != nil { + return nil, RemoveProjectSkillOutput{}, err + } + if err := t.store.RemoveProjectSkill(ctx, project.ID, in.SkillID); err != nil { + return nil, RemoveProjectSkillOutput{}, err + } + return nil, RemoveProjectSkillOutput{Removed: true}, nil +} + +// list_project_skills + +type ListProjectSkillsInput struct { + Project string `json:"project,omitempty" jsonschema:"project name or id (uses active project if omitted)"` +} + +type ListProjectSkillsOutput struct { + ProjectID uuid.UUID `json:"project_id"` + Skills []ext.AgentSkill `json:"skills"` +} + +func (t *SkillsTool) ListProjectSkills(ctx context.Context, req *mcp.CallToolRequest, in ListProjectSkillsInput) (*mcp.CallToolResult, ListProjectSkillsOutput, error) { + project, err := resolveProject(ctx, t.store, t.sessions, req, in.Project, true) + if err != nil { + return nil, ListProjectSkillsOutput{}, err + } + skills, err := t.store.ListProjectSkills(ctx, project.ID) + if err != nil { + return nil, ListProjectSkillsOutput{}, err + } + if skills == nil { + skills = []ext.AgentSkill{} + } + return nil, ListProjectSkillsOutput{ProjectID: project.ID, Skills: skills}, nil +} + +// add_project_guardrail + +type AddProjectGuardrailInput struct { + Project string `json:"project,omitempty" jsonschema:"project name or id (uses active project if omitted)"` + GuardrailID uuid.UUID `json:"guardrail_id" jsonschema:"guardrail id to link"` +} + +type AddProjectGuardrailOutput struct { + ProjectID uuid.UUID `json:"project_id"` + GuardrailID uuid.UUID `json:"guardrail_id"` +} + +func (t *SkillsTool) AddProjectGuardrail(ctx context.Context, req *mcp.CallToolRequest, in AddProjectGuardrailInput) (*mcp.CallToolResult, AddProjectGuardrailOutput, error) { + project, err := resolveProject(ctx, t.store, t.sessions, req, in.Project, true) + if err != nil { + return nil, AddProjectGuardrailOutput{}, err + } + if err := t.store.AddProjectGuardrail(ctx, project.ID, in.GuardrailID); err != nil { + return nil, AddProjectGuardrailOutput{}, err + } + return nil, AddProjectGuardrailOutput{ProjectID: project.ID, GuardrailID: in.GuardrailID}, nil +} + +// remove_project_guardrail + +type RemoveProjectGuardrailInput struct { + Project string `json:"project,omitempty" jsonschema:"project name or id (uses active project if omitted)"` + GuardrailID uuid.UUID `json:"guardrail_id" jsonschema:"guardrail id to unlink"` +} + +type RemoveProjectGuardrailOutput struct { + Removed bool `json:"removed"` +} + +func (t *SkillsTool) RemoveProjectGuardrail(ctx context.Context, req *mcp.CallToolRequest, in RemoveProjectGuardrailInput) (*mcp.CallToolResult, RemoveProjectGuardrailOutput, error) { + project, err := resolveProject(ctx, t.store, t.sessions, req, in.Project, true) + if err != nil { + return nil, RemoveProjectGuardrailOutput{}, err + } + if err := t.store.RemoveProjectGuardrail(ctx, project.ID, in.GuardrailID); err != nil { + return nil, RemoveProjectGuardrailOutput{}, err + } + return nil, RemoveProjectGuardrailOutput{Removed: true}, nil +} + +// list_project_guardrails + +type ListProjectGuardrailsInput struct { + Project string `json:"project,omitempty" jsonschema:"project name or id (uses active project if omitted)"` +} + +type ListProjectGuardrailsOutput struct { + ProjectID uuid.UUID `json:"project_id"` + Guardrails []ext.AgentGuardrail `json:"guardrails"` +} + +func (t *SkillsTool) ListProjectGuardrails(ctx context.Context, req *mcp.CallToolRequest, in ListProjectGuardrailsInput) (*mcp.CallToolResult, ListProjectGuardrailsOutput, error) { + project, err := resolveProject(ctx, t.store, t.sessions, req, in.Project, true) + if err != nil { + return nil, ListProjectGuardrailsOutput{}, err + } + guardrails, err := t.store.ListProjectGuardrails(ctx, project.ID) + if err != nil { + return nil, ListProjectGuardrailsOutput{}, err + } + if guardrails == nil { + guardrails = []ext.AgentGuardrail{} + } + return nil, ListProjectGuardrailsOutput{ProjectID: project.ID, Guardrails: guardrails}, nil +} diff --git a/internal/types/extensions.go b/internal/types/extensions.go index 68e4dfb..7c967cd 100644 --- a/internal/types/extensions.go +++ b/internal/types/extensions.go @@ -209,7 +209,30 @@ type Opportunity struct { } type ContactHistory struct { - Contact ProfessionalContact `json:"contact"` - Interactions []ContactInteraction `json:"interactions"` - Opportunities []Opportunity `json:"opportunities"` + Contact ProfessionalContact `json:"contact"` + Interactions []ContactInteraction `json:"interactions"` + Opportunities []Opportunity `json:"opportunities"` +} + +// Agent Skills & Guardrails + +type AgentSkill struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Content string `json:"content"` + Tags []string `json:"tags"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type AgentGuardrail struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Content string `json:"content"` + Severity string `json:"severity"` + Tags []string `json:"tags"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } diff --git a/llm/memory.md b/llm/memory.md index 957fbb0..158f07a 100644 --- a/llm/memory.md +++ b/llm/memory.md @@ -18,6 +18,15 @@ Use AMCS as memory with two scopes: 6. If no strong project match exists, you may use global notebook memory with no project. 7. If multiple projects plausibly match, ask the user before reading or writing project memory. +## Project Session Startup + +At the start of every project session, after setting the active project: + +1. Call `list_project_skills` to load any saved agent behaviour instructions for the project. +2. Call `list_project_guardrails` to load any saved agent constraints for the project. +3. Apply all returned skills and guardrails immediately and for the duration of the session. +4. Only generate or define new skills and guardrails if none are returned. If you do create new ones, store them with `add_skill` or `add_guardrail` and link them to the project with `add_project_skill` or `add_project_guardrail` so they persist for future sessions. + ## Project Memory Rules - Use project memory for code decisions, architecture, TODOs, debugging findings, and context specific to the current repo or workstream. @@ -46,6 +55,14 @@ Use AMCS as memory with two scopes: - Do not read a file just to make it storable; store the file directly and read it only when the file contents are needed for reasoning. - When saving, choose the narrowest correct scope: project if project-specific, global if not. +## Skills and Guardrails + +- **Skills** are reusable agent behaviour instructions (e.g. output formatting rules, reasoning strategies, workflow conventions). +- **Guardrails** are agent constraints and safety rules (e.g. never delete without confirmation, do not expose secrets). Each guardrail has a severity: `low`, `medium`, `high`, or `critical`. +- Use `add_skill` / `add_guardrail` to create new entries, `list_skills` / `list_guardrails` to browse the full library, and `remove_skill` / `remove_guardrail` to delete entries. +- Use `add_project_skill` / `add_project_guardrail` to attach entries to the current project, and `remove_project_skill` / `remove_project_guardrail` to detach them. +- Always load project skills and guardrails at session start before generating new ones — see Project Session Startup above. + ## Short Operational Form -Use AMCS memory in project scope when the current work matches a known project. If no clear project matches, global notebook memory is allowed for non-project-specific information. Store durable notes with `capture_thought`, store supporting binary artifacts with `save_file`, prefer saving a file directly when the artifact itself is what matters, browse stored files with `list_files`, and load them with `load_file` only when their contents are needed. Never store project-specific memory globally when a matching project exists, and never store memory in the wrong project. If project matching is ambiguous, ask the user. +Use AMCS memory in project scope when the current work matches a known project. If no clear project matches, global notebook memory is allowed for non-project-specific information. At the start of every project session call `list_project_skills` and `list_project_guardrails` and apply what is returned; only create new skills or guardrails if none exist. Store durable notes with `capture_thought`, store supporting binary artifacts with `save_file`, prefer saving a file directly when the artifact itself is what matters, browse stored files with `list_files`, and load them with `load_file` only when their contents are needed. Never store project-specific memory globally when a matching project exists, and never store memory in the wrong project. If project matching is ambiguous, ask the user. diff --git a/migrations/017_agent_skills_guardrails.sql b/migrations/017_agent_skills_guardrails.sql new file mode 100644 index 0000000..250cce5 --- /dev/null +++ b/migrations/017_agent_skills_guardrails.sql @@ -0,0 +1,39 @@ +create table if not exists agent_skills ( + id uuid primary key default gen_random_uuid(), + name text not null, + description text not null default '', + content text not null, + tags text[] not null default '{}', + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint agent_skills_name_unique unique (name) +); + +create table if not exists agent_guardrails ( + id uuid primary key default gen_random_uuid(), + name text not null, + description text not null default '', + content text not null, + severity text not null default 'medium' check (severity in ('low', 'medium', 'high', 'critical')), + tags text[] not null default '{}', + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint agent_guardrails_name_unique unique (name) +); + +create table if not exists project_skills ( + project_id uuid not null references projects(guid) on delete cascade, + skill_id uuid not null references agent_skills(id) on delete cascade, + created_at timestamptz not null default now(), + primary key (project_id, skill_id) +); + +create table if not exists project_guardrails ( + project_id uuid not null references projects(guid) on delete cascade, + guardrail_id uuid not null references agent_guardrails(id) on delete cascade, + created_at timestamptz not null default now(), + primary key (project_id, guardrail_id) +); + +create index if not exists project_skills_project_id_idx on project_skills (project_id); +create index if not exists project_guardrails_project_id_idx on project_guardrails (project_id); diff --git a/migrations/100_rls_and_grants.sql b/migrations/100_rls_and_grants.sql index a84be95..f1dabac 100644 --- a/migrations/100_rls_and_grants.sql +++ b/migrations/100_rls_and_grants.sql @@ -28,4 +28,11 @@ GRANT ALL ON TABLE public.professional_contacts TO amcs; GRANT ALL ON TABLE public.contact_interactions TO amcs; GRANT ALL ON TABLE public.opportunities TO amcs; +GRANT ALL ON TABLE public.stored_files TO amcs; +GRANT ALL ON TABLE public.agent_guardrails TO amcs; +GRANT ALL ON TABLE public.agent_skills TO amcs; + +GRANT ALL ON TABLE public.project_skills TO amcs; + +GRANT ALL ON TABLE public.project_guardrails TO amcs; GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO amcs; \ No newline at end of file