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 }