135 lines
4.1 KiB
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
|
|
}
|