Files
doormile_backend/internal/ai/registry/rules.go

135 lines
4.1 KiB
Go

package registry
import (
"errors"
"fmt"
"regexp"
"strings"
"unicode"
"doormile/models"
)
// ErrNotFound is returned when the agent or skill named by a request does not exist.
var ErrNotFound = errors.New("not found")
// ValidationError is a request the registry refuses. Its message is written for
// the operator and is safe to return as-is.
type ValidationError struct{ Msg string }
func (e *ValidationError) Error() string { return e.Msg }
func invalid(format string, args ...any) error {
return &ValidationError{Msg: fmt.Sprintf(format, args...)}
}
// Actor is who is making a change. The email is kept with the id because an
// admin login without an appusers row carries user id 0, and an audit that
// says "user 0" answers nothing.
type Actor struct {
UserID int
Email string
}
// SkillPatch is what an operator may change on a skill.
type SkillPatch struct {
Enabled *bool `json:"enabled"`
Thresholds map[string]any `json:"thresholds"`
}
// AgentPatch is what an operator may change on an agent.
//
// Confirm must repeat the agent id to switch autonomy ON. Autonomy lets an
// agent reassign riders or message customers with no human in the loop; a
// stray click or a replayed request must not be enough to turn that on.
type AgentPatch struct {
Autonomous *bool `json:"autonomous"`
Model *string `json:"model"`
Confirm string `json:"confirm"`
}
// NewSkill is an operator-created skill. It may only use tools that exist.
type NewSkill struct {
Agentid string `json:"agentid"`
Title string `json:"title"`
Category string `json:"category"`
Description string `json:"description"`
Sampleprompt string `json:"sampleprompt"`
Tools []string `json:"tools"`
}
var modelID = regexp.MustCompile(`^claude-[a-z0-9][a-z0-9.-]{0,56}$`)
// CheckAgentPatch validates a patch against the agent it targets. Pure, so the
// rules are tested without a database.
func CheckAgentPatch(agent models.AIAgent, p AgentPatch) error {
if p.Autonomous == nil && p.Model == nil {
return invalid("nothing to change: send autonomous and/or model")
}
if p.Autonomous != nil {
if !agent.Hasautonomygate {
return invalid("%s has no autonomy gate; only agents that can act on their own can be switched", agent.Agentid)
}
if *p.Autonomous && !agent.Autonomous && p.Confirm != agent.Agentid {
return invalid("switching %s to autonomous needs confirm set to %q", agent.Agentid, agent.Agentid)
}
}
if p.Model != nil && *p.Model != "" && !modelID.MatchString(*p.Model) {
return invalid("model must be a Claude model id such as claude-sonnet-5-5, or empty for the engine default")
}
if p.Model != nil && agent.Runtime != RuntimeEngine {
return invalid("%s runs in the console; it has no model setting", agent.Agentid)
}
return nil
}
// CheckNewSkill validates the shape of a new skill. Whether the agent and tools
// exist is checked against the database by CreateSkill.
func CheckNewSkill(n NewSkill) error {
title := strings.TrimSpace(n.Title)
switch {
case n.Agentid == "":
return invalid("agentid is required")
case title == "":
return invalid("title is required")
case len(title) > 120:
return invalid("title must be 120 characters or fewer")
case len(n.Tools) == 0:
return invalid("a skill needs at least one tool")
case len(n.Tools) > 20:
return invalid("a skill may use at most 20 tools")
}
seen := map[string]bool{}
for _, t := range n.Tools {
if seen[t] {
return invalid("tool %s is listed twice", t)
}
seen[t] = true
}
return nil
}
// customSkillID derives a stable id from a title: "custom_" plus a lowercase
// slug. CreateSkill appends a counter if it is taken.
func customSkillID(title string) string {
var b strings.Builder
lastUnderscore := false
for _, r := range strings.ToLower(strings.TrimSpace(title)) {
if unicode.IsLetter(r) && r < unicode.MaxASCII || unicode.IsDigit(r) {
b.WriteRune(r)
lastUnderscore = false
} else if !lastUnderscore && b.Len() > 0 {
b.WriteByte('_')
lastUnderscore = true
}
}
slug := strings.Trim(b.String(), "_")
if slug == "" {
slug = "skill"
}
if len(slug) > 48 {
slug = strings.Trim(slug[:48], "_")
}
return "custom_" + slug
}