updates on the ai and agent and all thse things awith onboarding

This commit is contained in:
2026-09-30 14:47:58 +05:30
parent 44ba33eda2
commit 0ac5d3d54f
37 changed files with 7755 additions and 0 deletions

View File

@@ -0,0 +1,480 @@
package registry
import (
"encoding/json"
"errors"
"strings"
"testing"
"doormile/models"
)
// No database here: these pin the seed's integrity and every rule a write must
// pass. Seed/Load/Update against Postgres are not exercised by this file.
func boolp(b bool) *bool { return &b }
func strp(s string) *string { return &s }
func isValidation(err error) bool { var v *ValidationError; return errors.As(err, &v) }
// ── Seed integrity ──────────────────────────────────────────────────────────
func TestSeedIDsAreUnique(t *testing.T) {
seen := map[string]bool{}
for _, a := range SeedAgents {
if seen["agent:"+a.Agentid] {
t.Errorf("agent %s seeded twice", a.Agentid)
}
seen["agent:"+a.Agentid] = true
}
for _, tl := range SeedTools {
if seen["tool:"+tl.Toolname] {
t.Errorf("tool %s seeded twice", tl.Toolname)
}
seen["tool:"+tl.Toolname] = true
}
for _, s := range SeedSkills {
if seen["skill:"+s.Skill.Skillid] {
t.Errorf("skill %s seeded twice", s.Skill.Skillid)
}
seen["skill:"+s.Skill.Skillid] = true
}
}
func TestSeedAgentsUseKnownValues(t *testing.T) {
for _, a := range SeedAgents {
if !validStatuses[a.Status] {
t.Errorf("%s: unknown status %q", a.Agentid, a.Status)
}
if !validRuntimes[a.Runtime] {
t.Errorf("%s: unknown runtime %q", a.Agentid, a.Runtime)
}
if a.Autonomous {
t.Errorf("%s is seeded autonomous; every agent must start with autonomy off", a.Agentid)
}
if a.Name == "" || a.Purpose == "" || a.Classref == "" {
t.Errorf("%s: name, purpose and classref are all required", a.Agentid)
}
}
}
// Autonomy can only be switched on the three agents whose writes AI_engine
// actually gates. A gate on any other agent would be a switch wired to nothing.
func TestOnlyTheThreeGatedAgentsHaveAutonomyGates(t *testing.T) {
want := map[string]bool{"DISPATCH_AGENT": true, "EXCEPTION_AGENT": true, "EXPRESS_DISPATCH_AGENT": true}
for _, a := range SeedAgents {
if a.Hasautonomygate != want[a.Agentid] {
t.Errorf("%s: hasautonomygate = %v, want %v", a.Agentid, a.Hasautonomygate, want[a.Agentid])
}
}
}
// The simulated agents must say so — the console renders this badge, and an
// agent that looks live and is not is the failure this registry exists to end.
func TestSimulatedAgentsAreSeededAsSimulation(t *testing.T) {
for _, id := range []string{"HUB_AGENT", "FLEET_AGENT", "ROUTE_OPTIMIZER"} {
for _, a := range SeedAgents {
if a.Agentid == id && a.Status != StatusSimulation {
t.Errorf("%s status = %q, want simulation", id, a.Status)
}
}
}
}
func TestSeedToolsAreWellFormed(t *testing.T) {
for _, tl := range SeedTools {
if !validKinds[tl.Kind] {
t.Errorf("%s: unknown kind %q", tl.Toolname, tl.Kind)
}
if (tl.Kind == KindWrite || tl.Kind == KindNotify) && !tl.Requiresconfirmation {
t.Errorf("%s is a %s tool but does not require confirmation", tl.Toolname, tl.Kind)
}
if tl.Kind == KindRead && tl.Requiresconfirmation {
t.Errorf("%s is read-only but requires confirmation", tl.Toolname)
}
var schema map[string]any
if err := json.Unmarshal([]byte(tl.Inputschema), &schema); err != nil || schema["type"] != "object" {
t.Errorf("%s: inputschema is not a JSON-schema object: %s", tl.Toolname, tl.Inputschema)
}
if tl.Description == "" || tl.Target == "" || tl.Implementedat == "" {
t.Errorf("%s: description, target and implementedat are all required", tl.Toolname)
}
}
}
func TestEverySkillPointsAtRealAgentsAndTools(t *testing.T) {
agents := map[string]bool{}
for _, a := range SeedAgents {
agents[a.Agentid] = true
}
tools := map[string]bool{}
for _, tl := range SeedTools {
tools[tl.Toolname] = true
}
for _, s := range SeedSkills {
if !agents[s.Skill.Agentid] {
t.Errorf("skill %s belongs to unknown agent %s", s.Skill.Skillid, s.Skill.Agentid)
}
if len(s.Tools) == 0 {
t.Errorf("skill %s has no tools", s.Skill.Skillid)
}
for _, tl := range s.Tools {
if !tools[tl] {
t.Errorf("skill %s uses unknown tool %s", s.Skill.Skillid, tl)
}
}
if s.Skill.Source != SourceEngine && s.Skill.Source != SourceConsole {
t.Errorf("seeded skill %s has source %q; custom is for operator-made skills only", s.Skill.Skillid, s.Skill.Source)
}
}
}
// Every seeded tool is used by some skill — the registry lists capabilities in
// use, not a catalogue of ideas.
func TestEveryToolIsUsedBySomeSkill(t *testing.T) {
used := map[string]bool{}
for _, s := range SeedSkills {
for _, tl := range s.Tools {
used[tl] = true
}
}
for _, tl := range SeedTools {
if !used[tl.Toolname] {
t.Errorf("tool %s is seeded but no skill uses it", tl.Toolname)
}
}
}
func TestSeedThresholdDefaultsAreValid(t *testing.T) {
for _, s := range SeedSkills {
keys := map[string]bool{}
for _, spec := range s.Schema {
if keys[spec.Key] {
t.Errorf("%s: threshold %s declared twice", s.Skill.Skillid, spec.Key)
}
keys[spec.Key] = true
if spec.Min >= spec.Max {
t.Errorf("%s.%s: min %v is not below max %v", s.Skill.Skillid, spec.Key, spec.Min, spec.Max)
}
if err := spec.check(spec.Default); err != nil {
t.Errorf("%s.%s: default is itself invalid: %v", s.Skill.Skillid, spec.Key, err)
}
}
}
}
// Rebalancing has no endpoint behind it. It must never ship switched on.
func TestRebalanceShipsDisabled(t *testing.T) {
for _, s := range SeedSkills {
if s.Skill.Skillid == "dispatch_rebalance" && s.Skill.Enabled {
t.Fatal("dispatch_rebalance is seeded enabled; nothing implements it")
}
}
}
// The ops-layer skills keep the branch's ids and threshold keys so the Phase 3
// port maps one to one. Pin them.
func TestConsoleOpsSkillsKeepBranchIDsAndKeys(t *testing.T) {
want := map[string][]string{
"skill_sla_guardian": {"slaRiskWindowMin", "unassignedAgingMin"},
"skill_doorstep_stall": {"arrivedStalledMin"},
"skill_fleet_balancer": {"riderActiveCap"},
"skill_high_value_cod": {"codRiskThresholdAmount"},
"skill_rider_battery_safety": {"criticalBatteryPercent"},
"skill_hub_congestion": {"hubDwellMinutes"},
"skill_late_dispatch": {"lateDispatchMinutes", "criticalDispatchMinutes"},
"skill_cash_exposure": {"maxCashPerRider", "warningCashPercent"},
}
for _, s := range SeedSkills {
keys, ok := want[s.Skill.Skillid]
if !ok {
continue
}
delete(want, s.Skill.Skillid)
var got []string
for _, spec := range s.Schema {
got = append(got, spec.Key)
}
if strings.Join(got, ",") != strings.Join(keys, ",") {
t.Errorf("%s threshold keys = %v, want %v", s.Skill.Skillid, got, keys)
}
}
for id := range want {
t.Errorf("branch skill %s is missing from the seed", id)
}
}
// ── Thresholds ──────────────────────────────────────────────────────────────
var testSchema = []ThresholdSpec{
{Key: "minutes", Default: 20, Min: 10, Max: 60, Step: 5},
{Key: "confidence", Default: 0.7, Min: 0.5, Max: 1, Step: 0.05},
}
func TestEffectiveThresholdsFillsDefaultsAndDropsStrays(t *testing.T) {
got := EffectiveThresholds(testSchema, map[string]float64{"minutes": 30, "removed": 9, "confidence": 5})
if got["minutes"] != 30 {
t.Errorf("a valid stored value was not kept: %v", got["minutes"])
}
if got["confidence"] != 0.7 {
t.Errorf("an out-of-range stored value was not replaced by the default: %v", got["confidence"])
}
if _, stray := got["removed"]; stray {
t.Error("a key the schema no longer has was kept")
}
}
func TestApplyThresholdPatchAcceptsValidValues(t *testing.T) {
got, err := ApplyThresholdPatch(testSchema, nil, map[string]any{"minutes": 45.0, "confidence": 0.85})
if err != nil {
t.Fatalf("valid patch refused: %v", err)
}
if got["minutes"] != 45 || got["confidence"] != 0.85 {
t.Errorf("patch not applied: %v", got)
}
}
func TestApplyThresholdPatchRefusesBadInput(t *testing.T) {
cases := map[string]map[string]any{
"unknown key": {"minuts": 30.0},
"not a number": {"minutes": "30"},
"a boolean": {"minutes": true},
"below min": {"minutes": 5.0},
"above max": {"minutes": 65.0},
"off the step": {"minutes": 33.0},
"off the fstep": {"confidence": 0.72},
}
for name, patch := range cases {
if _, err := ApplyThresholdPatch(testSchema, nil, patch); !isValidation(err) {
t.Errorf("%s: want a ValidationError, got %v", name, err)
}
}
}
// Every problem is reported at once, so an operator fixes the form in one pass.
func TestApplyThresholdPatchReportsEveryProblem(t *testing.T) {
_, err := ApplyThresholdPatch(testSchema, nil, map[string]any{"minutes": 5.0, "nope": 1.0})
if err == nil || !strings.Contains(err.Error(), "minutes") || !strings.Contains(err.Error(), "nope") {
t.Fatalf("want both problems named, got %v", err)
}
}
// A refused patch changes nothing — the whole set, not the valid half, is rejected.
func TestApplyThresholdPatchIsAllOrNothing(t *testing.T) {
got, err := ApplyThresholdPatch(testSchema, map[string]float64{"minutes": 20}, map[string]any{"minutes": 40.0, "confidence": 9.0})
if err == nil || got != nil {
t.Fatalf("partial patch was applied: %v, %v", got, err)
}
}
func TestParseThresholdsToleratesJunk(t *testing.T) {
for _, raw := range []string{"", "null", "not json", "[]"} {
if got := ParseThresholds(raw); got == nil || len(got) != 0 {
t.Errorf("ParseThresholds(%q) = %v, want an empty map", raw, got)
}
}
if got := ParseSchema("garbage"); got == nil || len(got) != 0 {
t.Errorf("ParseSchema(garbage) = %v, want empty", got)
}
}
// ── Agent patches ───────────────────────────────────────────────────────────
var gated = models.AIAgent{Agentid: "EXCEPTION_AGENT", Runtime: RuntimeEngine, Hasautonomygate: true}
func TestAutonomyOnNeedsTypedConfirmation(t *testing.T) {
if err := CheckAgentPatch(gated, AgentPatch{Autonomous: boolp(true)}); !isValidation(err) {
t.Errorf("autonomy switched on with no confirmation: %v", err)
}
if err := CheckAgentPatch(gated, AgentPatch{Autonomous: boolp(true), Confirm: "exception_agent"}); !isValidation(err) {
t.Errorf("a near-miss confirmation was accepted: %v", err)
}
if err := CheckAgentPatch(gated, AgentPatch{Autonomous: boolp(true), Confirm: "EXCEPTION_AGENT"}); err != nil {
t.Errorf("correct confirmation refused: %v", err)
}
}
// Switching autonomy OFF is always allowed without ceremony — the safe direction.
func TestAutonomyOffNeedsNoConfirmation(t *testing.T) {
on := gated
on.Autonomous = true
if err := CheckAgentPatch(on, AgentPatch{Autonomous: boolp(false)}); err != nil {
t.Errorf("switching autonomy off was refused: %v", err)
}
}
func TestAutonomyRefusedOnUngatedAgent(t *testing.T) {
hub := models.AIAgent{Agentid: "HUB_AGENT", Runtime: RuntimeEngine}
if err := CheckAgentPatch(hub, AgentPatch{Autonomous: boolp(true), Confirm: "HUB_AGENT"}); !isValidation(err) {
t.Errorf("autonomy set on an agent with no gate: %v", err)
}
}
func TestModelIDRules(t *testing.T) {
for _, ok := range []string{"", "claude-sonnet-5-5", "claude-haiku-4-5-20251001", "claude-opus-5-5"} {
if err := CheckAgentPatch(gated, AgentPatch{Model: strp(ok)}); err != nil {
t.Errorf("model %q refused: %v", ok, err)
}
}
for _, bad := range []string{"gpt-4o", "claude-", "Claude-Sonnet", "claude-x; drop table", strings.Repeat("claude-a", 20)} {
if err := CheckAgentPatch(gated, AgentPatch{Model: strp(bad)}); !isValidation(err) {
t.Errorf("model %q accepted", bad)
}
}
console := models.AIAgent{Agentid: "CONSOLE_ASSISTANT", Runtime: RuntimeConsole}
if err := CheckAgentPatch(console, AgentPatch{Model: strp("claude-sonnet-5-5")}); !isValidation(err) {
t.Error("a model was set on a console agent, which has no model setting")
}
}
func TestEmptyAgentPatchRefused(t *testing.T) {
if err := CheckAgentPatch(gated, AgentPatch{}); !isValidation(err) {
t.Error("an empty patch was accepted")
}
}
// ── New skills ──────────────────────────────────────────────────────────────
func TestCheckNewSkill(t *testing.T) {
good := NewSkill{Agentid: "CONSOLE_OPS_AGENT", Title: "Night shift watch", Tools: []string{"lookup_order"}}
if err := CheckNewSkill(good); err != nil {
t.Fatalf("valid skill refused: %v", err)
}
bad := map[string]NewSkill{
"no agent": {Title: "x", Tools: []string{"a"}},
"no title": {Agentid: "A", Title: " ", Tools: []string{"a"}},
"no tools": {Agentid: "A", Title: "x"},
"duplicate tool": {Agentid: "A", Title: "x", Tools: []string{"a", "a"}},
"title too long": {Agentid: "A", Title: strings.Repeat("x", 121), Tools: []string{"a"}},
}
for name, n := range bad {
if err := CheckNewSkill(n); !isValidation(err) {
t.Errorf("%s: accepted", name)
}
}
}
func TestCustomSkillID(t *testing.T) {
cases := map[string]string{
"Night Shift Watch": "custom_night_shift_watch",
" COD > ₹5,000 alerts!! ": "custom_cod_5_000_alerts",
"!!!": "custom_skill",
}
for in, want := range cases {
if got := customSkillID(in); got != want {
t.Errorf("customSkillID(%q) = %q, want %q", in, got, want)
}
}
if got := customSkillID(strings.Repeat("abc ", 40)); len(got) > 64 {
t.Errorf("id %q exceeds the 64-char column", got)
}
}
// ── Build & ETag ────────────────────────────────────────────────────────────
func seededRows() ([]models.AIAgent, []models.AITool, []models.AISkill, []models.AISkillTool) {
var skills []models.AISkill
var links []models.AISkillTool
for _, s := range SeedSkills {
row := s.Skill
row.Thresholdsschema = mustJSON(schemaOrEmpty(s.Schema))
row.Thresholds = mustJSON(DefaultThresholds(s.Schema))
skills = append(skills, row)
for _, tl := range s.Tools {
links = append(links, models.AISkillTool{Skillid: s.Skill.Skillid, Toolname: tl})
}
}
return SeedAgents, SeedTools, skills, links
}
func TestBuildCountsSkillsAndToolsPerAgent(t *testing.T) {
snap := Build(seededRows())
for _, a := range snap.Agents {
if a.Agentid == "EXPRESS_DISPATCH_AGENT" && (a.Skillcount != 1 || a.Toolcount != 4) {
t.Errorf("EXPRESS_DISPATCH_AGENT: %d skills, %d tools; want 1 and 4", a.Skillcount, a.Toolcount)
}
if a.Agentid == "HUB_AGENT" && (a.Skillcount != 0 || a.Toolcount != 0) {
t.Errorf("HUB_AGENT has no skills, got %d/%d", a.Skillcount, a.Toolcount)
}
}
}
// The API must emit thresholds and schemas as JSON values, not as strings of JSON.
func TestSnapshotSerialisesJSONColumnsAsJSON(t *testing.T) {
b, err := json.Marshal(Build(seededRows()))
if err != nil {
t.Fatal(err)
}
var out struct {
Skills []struct {
Skillid string `json:"skillid"`
Thresholds map[string]float64 `json:"thresholds"`
Thresholdsschema []ThresholdSpec `json:"thresholdsschema"`
Tools []string `json:"tools"`
} `json:"skills"`
Tools []struct {
Inputschema map[string]any `json:"inputschema"`
} `json:"tools"`
}
if err := json.Unmarshal(b, &out); err != nil {
t.Fatalf("snapshot JSON does not decode as structured values: %v", err)
}
for _, s := range out.Skills {
if s.Skillid == "skill_cash_exposure" {
if s.Thresholds["maxCashPerRider"] != 10000 || len(s.Thresholdsschema) != 2 || len(s.Tools) != 2 {
t.Errorf("skill_cash_exposure serialised wrongly: %+v", s)
}
}
}
if len(out.Tools) == 0 || out.Tools[0].Inputschema["type"] != "object" {
t.Error("tool inputschema is not emitted as a JSON object")
}
}
func TestETagIsStableAndChangesWithContent(t *testing.T) {
a := ETag(Build(seededRows()))
if a != ETag(Build(seededRows())) {
t.Fatal("ETag differs for identical content")
}
agents, tools, skills, links := seededRows()
skills[0].Enabled = !skills[0].Enabled
if a == ETag(Build(agents, tools, skills, links)) {
t.Fatal("ETag did not change when a skill was toggled")
}
if !strings.HasPrefix(a, `W/"`) {
t.Errorf("ETag %s is not a weak validator", a)
}
}
// These three read fields /admin/bookings rows do not carry (payment amounts,
// battery). Enabled, they would read undefined on every row and report an
// all-clear board. They must ship off, in step with the console's defaults.
func TestSkillsWithNoDataSourceShipDisabled(t *testing.T) {
off := map[string]bool{"skill_high_value_cod": true, "skill_cash_exposure": true, "skill_rider_battery_safety": true}
for _, s := range SeedSkills {
if off[s.Skill.Skillid] {
delete(off, s.Skill.Skillid)
if s.Skill.Enabled {
t.Errorf("%s is seeded enabled but its rows carry no data for its rule", s.Skill.Skillid)
}
if !strings.Contains(s.Skill.Description, "OFF:") {
t.Errorf("%s does not say why it is off", s.Skill.Skillid)
}
}
}
for id := range off {
t.Errorf("%s missing from the seed", id)
}
}
// Only notify_riders has an executor in the console. Every other console write
// must say REVIEW ONLY, or Agent Studio would advertise an action that cannot run.
func TestConsoleWritesWithoutExecutorSayReviewOnly(t *testing.T) {
for _, tl := range SeedTools {
if !strings.HasPrefix(tl.Implementedat, consoleSrc+"lib/assistant/skills/") && !strings.Contains(tl.Implementedat, "(no executor)") {
continue
}
if tl.Kind != KindRead && !strings.HasPrefix(tl.Description, "REVIEW ONLY") {
t.Errorf("%s has no executor but its description does not start with REVIEW ONLY", tl.Toolname)
}
}
}

View File

@@ -0,0 +1,134 @@
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
}

View File

@@ -0,0 +1,304 @@
package registry
import "doormile/models"
// The registry as the code actually stands (verified 2026-09-29). Every entry
// says what the code DOES, not what a doc claims: an agent that only simulates
// is seeded as "simulation", and the console must show that badge. Source of
// this inventory: krow_talent_app/docs/agent-platform-plan.md §2.
//
// Upserted on every boot by Seed. Changing a code-defined field here changes
// it in the database on the next start; operator-owned fields (skill enabled
// and thresholds, agent autonomous and model) are only written on first insert.
//
// Deliberately NOT seeded:
// - AI_engine's ask_question, order_intake_skill and repeat_run_skill —
// registered in core/tool_registry.py but never loaded in production.
// - The four Krow workforce-training tools the console mock carried.
// - ORDER_AGENT's crmbooking calls — that route was renamed to
// expressbooking, so they reach nothing.
// - /internal/agent-decisions — an append-only log the Go side writes, not a
// capability any skill chooses to use.
// Agent statuses and runtimes. The console renders these verbatim.
const (
StatusLive = "live"
StatusPartial = "partial"
StatusSimulation = "simulation"
StatusBroken = "broken"
StatusUnmerged = "unmerged"
StatusRetired = "retired"
RuntimeEngine = "engine"
RuntimeConsole = "console"
KindRead = "read"
KindWrite = "write"
KindNotify = "notify"
// KindEvent is an internal bus event or record: no effect on an order, a
// rider or a customer by itself, so it is never behind confirmation.
KindEvent = "event"
SourceEngine = "engine"
SourceConsole = "console"
SourceCustom = "custom"
)
var (
validStatuses = map[string]bool{StatusLive: true, StatusPartial: true, StatusSimulation: true, StatusBroken: true, StatusUnmerged: true, StatusRetired: true}
validRuntimes = map[string]bool{RuntimeEngine: true, RuntimeConsole: true}
validKinds = map[string]bool{KindRead: true, KindWrite: true, KindNotify: true, KindEvent: true}
)
const consoleSrc = "krow_talent_app/src/"
// SeedAgents — AI_engine's nine, then the console's two.
var SeedAgents = []models.AIAgent{
{Agentid: "JARVIS", Name: "JARVIS", Runtime: RuntimeEngine, Classref: "AI_engine/core/agent.py:196 MasterAgent",
Purpose: "Orchestrator and escalation inbox. Receives EXCEPTION_DETECTED from other agents.",
Wakeon: "NATS logistics.direct.JARVIS", Status: StatusPartial, Sortorder: 10},
{Agentid: "DISPATCH_AGENT", Name: "Dispatch", Runtime: RuntimeEngine, Classref: "AI_engine/agents/dispatch_agent.py:72",
Purpose: "Watches assignment outcomes and flags coverage gaps. Alerts; notifies customers only when autonomous.",
Wakeon: "JetStream booking.assigned, booking.assignment_failed",
Status: StatusLive, Llmdecision: "decide_assignment_failure", Hasautonomygate: true, Sortorder: 20},
{Agentid: "EXCEPTION_AGENT", Name: "Exception", Runtime: RuntimeEngine, Classref: "AI_engine/agents/exception_agent.py:106",
Purpose: "Detects stalled riders and decides the response. Reassigns only when autonomous and confident.",
Wakeon: "TRACKING miler.location.updated, miler.stalled; 60 s database sweep",
Status: StatusLive, Llmdecision: "decide_stall_response", Hasautonomygate: true, Sortorder: 30},
{Agentid: "EXPRESS_DISPATCH_AGENT", Name: "Express Dispatch", Runtime: RuntimeEngine, Classref: "AI_engine/agents/express_dispatch_agent.py:80",
Purpose: "Tenant-scoped batch assignment for DoormileExpress, then road sequencing of each rider's stops.",
Wakeon: "JetStream express.dispatch_requested", Status: StatusLive, Hasautonomygate: true, Sortorder: 40},
{Agentid: "CUSTOMER_AGENT", Name: "Customer", Runtime: RuntimeEngine, Classref: "AI_engine/agents/customer_agent.py:50",
Purpose: "Customer notifications and tracking. Reached only from an autonomous Dispatch agent.",
Wakeon: "Direct task", Status: StatusPartial, Sortorder: 50},
{Agentid: "ORDER_AGENT", Name: "Order", Runtime: RuntimeEngine, Classref: "AI_engine/agents/order_agent.py:15",
Purpose: "Order intake and validation. Not connected: it has no working backend route (/admin/* needs a console JWT, crmbooking is gone), so its backend calls are refused and logged.",
Wakeon: "Direct task (no sender in production)", Status: StatusBroken, Sortorder: 60},
{Agentid: "HUB_AGENT", Name: "Hub", Runtime: RuntimeEngine, Classref: "AI_engine/agents/hub_agent.py:35",
Purpose: "Hub capacity over 8 hard-coded fictional hubs.", Wakeon: "Direct task", Status: StatusSimulation, Sortorder: 70},
{Agentid: "FLEET_AGENT", Name: "Fleet", Runtime: RuntimeEngine, Classref: "AI_engine/agents/fleet_agent.py:34",
Purpose: "An in-memory fleet of 19 fake vehicles.", Wakeon: "Direct task", Status: StatusSimulation, Sortorder: 80},
{Agentid: "ROUTE_OPTIMIZER", Name: "Route Optimizer", Runtime: RuntimeEngine, Classref: "AI_engine/agents/route_optimizer_agent.py:41",
Purpose: "Haversine routing with traffic multipliers. Real sequencing is done by Express Dispatch via routes.workolik.com.",
Wakeon: "Direct task", Status: StatusSimulation, Sortorder: 90},
{Agentid: "CONSOLE_ASSISTANT", Name: "Console Assistant", Runtime: RuntimeConsole, Classref: consoleSrc + "lib/assistant",
Purpose: "The Home chat: orders, bulk upload, assignment, repeat runs. Regex intent catalogue; every write is a proposal the operator confirms.",
Wakeon: "Operator prompt", Status: StatusLive, Sortorder: 100},
{Agentid: "CONSOLE_OPS_AGENT", Name: "Console Ops Agent", Runtime: RuntimeConsole, Classref: consoleSrc + "lib/assistant/agent",
Purpose: "The Exceptions early-warnings banner and the chat's 'what needs attention' briefing: eight rule-based monitoring skills over the booking scan. Nothing runs on its own; an action runs only when an operator clicks it.",
Wakeon: "Exceptions page load, 60 s poll, and the chat briefing", Status: StatusLive, Sortorder: 110},
}
// obj builds a JSON-schema object for a tool's input. Only parameters the code
// provably takes are listed; where the body is not pinned down, the schema is
// left open rather than invented.
func obj(props map[string]map[string]string, required ...string) string {
properties := map[string]any{}
for name, p := range props {
properties[name] = p
}
s := map[string]any{"type": "object", "properties": properties}
if len(required) > 0 {
s["required"] = required
}
return mustJSON(s)
}
var (
integer = func(desc string) map[string]string { return map[string]string{"type": "integer", "description": desc} }
number = func(desc string) map[string]string { return map[string]string{"type": "number", "description": desc} }
str = func(desc string) map[string]string { return map[string]string{"type": "string", "description": desc} }
open = obj(map[string]map[string]string{})
)
// SeedTools — every capability a seeded skill uses, and nothing else.
var SeedTools = []models.AITool{
// AI_engine
{Toolname: "reassign_booking", Kind: KindWrite, Requiresconfirmation: true,
Description: "Reassign a booking whose rider has stalled.", Target: "doormile_backend POST /internal/bookings/:id/reassign",
Implementedat: "AI_engine/agents/exception_agent.py:466", Inputschema: obj(map[string]map[string]string{"booking_id": integer("Booking to reassign")}, "booking_id")},
{Toolname: "notify_customer", Kind: KindNotify, Requiresconfirmation: true,
Description: "Send a customer a delivery update.", Target: "doormile_backend POST /internal/notify",
Implementedat: "AI_engine/agents/exception_agent.py:476, customer_agent.py:349", Inputschema: open},
{Toolname: "list_express_bookings", Kind: KindRead,
Description: "Read the bookings in an express dispatch batch.", Target: "doormile_backend GET /internal/express/bookings",
Implementedat: "AI_engine/agents/express_dispatch_agent.py:351", Inputschema: open},
{Toolname: "list_express_riders", Kind: KindRead,
Description: "Read a tenant's riders available for an express batch.", Target: "doormile_backend GET /internal/express/riders",
Implementedat: "AI_engine/agents/express_dispatch_agent.py:342", Inputschema: open},
{Toolname: "assign_express_batch", Kind: KindWrite, Requiresconfirmation: true,
Description: "Write back the rider assignments decided for an express batch.", Target: "doormile_backend POST /internal/express/assign",
Implementedat: "AI_engine/agents/express_dispatch_agent.py:222", Inputschema: open},
{Toolname: "sequence_stops", Kind: KindRead,
Description: "Order a rider's stops by road (computes, writes nothing).", Target: "routes.workolik.com POST /api/v1/optimization/doormile/sequence",
Implementedat: "AI_engine/agents/express_dispatch_agent.py:308", Inputschema: open},
{Toolname: "get_booking_cache", Kind: KindRead,
Description: "Read a booking from the backend's booking cache.", Target: "doormile_backend GET /bookings/cache/:id",
Implementedat: "AI_engine/agents/customer_agent.py:243", Inputschema: obj(map[string]map[string]string{"booking_id": integer("Booking to read")}, "booking_id")},
{Toolname: "nearby_milers", Kind: KindRead,
Description: "Find riders near a point from live positions.", Target: "Redis GEO milers:locations",
Implementedat: "AI_engine/agents/dispatch_agent.py:170-240",
Inputschema: obj(map[string]map[string]string{
"lat": number("Latitude of the point, decimal degrees"), "lon": number("Longitude of the point, decimal degrees"),
"radius_km": number("Search radius in km (default 5, at most 10)"),
}, "lat", "lon")},
{Toolname: "publish_miler_stalled", Kind: KindEvent,
Description: "Publish a stalled-rider event for other agents.", Target: "NATS miler.stalled",
Implementedat: "AI_engine/agents/exception_agent.py:377", Inputschema: open},
{Toolname: "decide_stall_response", Kind: KindRead,
Description: "Ask the model how to respond to a stalled rider (structured output).", Target: "Claude via AI_engine/core/llm.py",
Implementedat: "AI_engine/core/llm.py:157", Inputschema: open},
{Toolname: "decide_assignment_failure", Kind: KindRead,
Description: "Ask the model why an assignment failed and what to do (structured output).", Target: "Claude via AI_engine/core/llm.py",
Implementedat: "AI_engine/core/llm.py:227", Inputschema: open},
// Console ops layer (krow_talent_app, ported onto main in Phase 3). The
// tool names are the proposal VERBS the findings carry, because that is what
// an operator's click resolves (lib/assistant/agent/actions.js). Only
// notify_riders has an executor; every other write is review-only and the
// console renders it disabled — the descriptions say so.
{Toolname: "scan_bookings", Kind: KindRead,
Description: "Read open and recent bookings (drained page by page) for the rules to evaluate.", Target: "doormile_backend GET /admin/bookings",
Implementedat: consoleSrc + "lib/assistant/scan.js",
Inputschema: obj(map[string]map[string]string{
"status": str("Only bookings in this status, e.g. Created, Miler_Assigned, Picked_Up, Cancelled"),
"limit": integer("How many of the newest bookings (default 20, at most 50)"),
})},
{Toolname: "notify_riders", Kind: KindNotify, Requiresconfirmation: true,
Description: "Message the riders on a finding's orders. Runs only when an operator clicks it; partial success is reported as partial.", Target: "doormile_backend POST /admin/milers/:id/notify",
Implementedat: consoleSrc + "lib/assistant/agent/actions.js", Inputschema: open},
{Toolname: "assign_riders", Kind: KindWrite, Requiresconfirmation: true,
Description: "REVIEW ONLY. Would assign a finding's orders to riders, but POST /hub/bookings/batch-assign accepts hub-staff logins only, so the console cannot run it.", Target: "doormile_backend POST /hub/bookings/batch-assign (hub staff only)",
Implementedat: consoleSrc + "lib/assistant/agent/actions.js (no executor)", Inputschema: open},
{Toolname: "enforce_otp_verification", Kind: KindWrite, Requiresconfirmation: true,
Description: "REVIEW ONLY. Flag a high-value COD order as requiring the receiver's OTP at handover. No executor.", Target: "none yet",
Implementedat: consoleSrc + "lib/assistant/skills/definitions/HighValueCodSkill.js", Inputschema: obj(map[string]map[string]string{"bookingId": integer("Booking to flag")}, "bookingId")},
{Toolname: "alert_low_battery_rider", Kind: KindNotify, Requiresconfirmation: true,
Description: "REVIEW ONLY. Tell a rider on a low battery to charge or report to the nearest hub. No executor.", Target: "doormile_backend POST /admin/milers/:id/notify",
Implementedat: consoleSrc + "lib/assistant/skills/definitions/RiderBatterySafetySkill.js", Inputschema: obj(map[string]map[string]string{"milerId": integer("Rider to alert")}, "milerId")},
{Toolname: "dispatch_hub_idle_parcels", Kind: KindWrite, Requiresconfirmation: true,
Description: "REVIEW ONLY. Send an idle rider to collect parcels dwelling at a hub. No executor.", Target: "none yet",
Implementedat: consoleSrc + "lib/assistant/skills/definitions/HubCongestionSkill.js",
Inputschema: obj(map[string]map[string]string{"hubId": str("Hub where parcels are waiting"), "milerId": integer("Idle rider")}, "hubId", "milerId")},
{Toolname: "trigger_auto_dispatch", Kind: KindWrite, Requiresconfirmation: true,
Description: "REVIEW ONLY. Auto-assign orders that have waited too long for dispatch. No executor.", Target: "none yet",
Implementedat: consoleSrc + "lib/assistant/skills/definitions/LateDispatchSkill.js", Inputschema: open},
{Toolname: "enforce_cash_handoff", Kind: KindWrite, Requiresconfirmation: true,
Description: "REVIEW ONLY. Route a rider carrying too much COD via the nearest hub. No executor.", Target: "none yet",
Implementedat: consoleSrc + "lib/assistant/skills/definitions/CashExposureSkill.js", Inputschema: open},
// Console assistant
{Toolname: "simulate_pricing_quote", Kind: KindRead,
Description: "Quote a delivery from the tenant's pricing row and the routed distance, without booking it.", Target: "doormile_backend GET /admin/pricing + OSRM route",
Implementedat: consoleSrc + "lib/assistant/orderFlow.js", Inputschema: open},
{Toolname: "create_single_order", Kind: KindWrite, Requiresconfirmation: true,
Description: "Create one express booking. Returns a proposal; the operator confirms.", Target: "doormile_backend POST /admin/expressbooking",
Implementedat: consoleSrc + "lib/assistant/orderFlow.js", Inputschema: open},
{Toolname: "rebalance_riders", Kind: KindWrite, Requiresconfirmation: true,
Description: "Move idle riders between zones. NOT IMPLEMENTED: no endpoint does zone rebalancing yet.", Target: "none yet",
Implementedat: "none", Inputschema: open}}
// SeedSkill pairs a skill row with its tool links and threshold schema.
type SeedSkill struct {
Skill models.AISkill
Tools []string
Schema []ThresholdSpec
}
func minutes(key, label string, def, min, max, step float64) ThresholdSpec {
return ThresholdSpec{Key: key, Label: label, Unit: "min", Default: def, Min: min, Max: max, Step: step}
}
// SeedSkills. The console ops skills keep the ids and threshold keys of the
// feat/agentic-ops-layer branch, which were ported onto main one to one (Phase 3).
var SeedSkills = []SeedSkill{
// AI_engine. Thresholds mirror the env knobs the agents read today; the
// engine starts reading them from here in Phase 5.
{Skill: models.AISkill{Skillid: "stall_response", Agentid: "EXCEPTION_AGENT", Title: "Stalled-rider response", Category: "rider_operations", Source: SourceEngine, Enabled: true,
Description: "Detect a rider who has stopped moving, ask the model what to do, and alert or (when autonomous) reassign."},
Tools: []string{"nearby_milers", "decide_stall_response", "reassign_booking", "notify_customer", "publish_miler_stalled"},
Schema: []ThresholdSpec{
minutes("stallMinutes", "Stall threshold", 10, 5, 60, 5),
{Key: "reassignConfidence", Label: "Auto-reassign confidence floor", Default: 0.7, Min: 0.5, Max: 1, Step: 0.05},
}},
{Skill: models.AISkill{Skillid: "assignment_failure_triage", Agentid: "DISPATCH_AGENT", Title: "Assignment-failure triage", Category: "dispatch", Source: SourceEngine, Enabled: true,
Description: "When no rider could be assigned, work out why from nearby supply and raise one alert per gap."},
Tools: []string{"nearby_milers", "decide_assignment_failure", "notify_customer"},
Schema: []ThresholdSpec{
{Key: "realertEvery", Label: "Re-alert after N repeat failures", Unit: "failures", Default: 100, Min: 10, Max: 1000, Step: 10},
}},
{Skill: models.AISkill{Skillid: "express_batch_dispatch", Agentid: "EXPRESS_DISPATCH_AGENT", Title: "Express batch dispatch", Category: "dispatch", Source: SourceEngine, Enabled: true,
Description: "Assign a tenant's express batch to its riders greedily, then sequence each rider's stops by road."},
Tools: []string{"list_express_bookings", "list_express_riders", "sequence_stops", "assign_express_batch"},
Schema: []ThresholdSpec{
{Key: "maxRadiusKm", Label: "Max rider radius", Unit: "km", Default: 30, Min: 5, Max: 60, Step: 1},
{Key: "maxPerRider", Label: "Max stops per rider", Unit: "stops", Default: 5, Min: 1, Max: 10, Step: 1},
{Key: "loadPenaltyKm", Label: "Load penalty per held stop", Unit: "km", Default: 3, Min: 0, Max: 10, Step: 0.5},
}},
{Skill: models.AISkill{Skillid: "customer_notifications", Agentid: "CUSTOMER_AGENT", Title: "Customer notifications", Category: "customer", Source: SourceEngine, Enabled: true,
Description: "Tell a customer what is happening to their delivery."},
Tools: []string{"get_booking_cache", "notify_customer"}},
// Console ops layer (on main since Phase 3). Ids and threshold keys match
// lib/assistant/skills/definitions; defaults are copied from them. Every skill
// reads the booking scan; its other tools are the actions its findings propose.
{Skill: models.AISkill{Skillid: "skill_sla_guardian", Agentid: "CONSOLE_OPS_AGENT", Title: "SLA Breach Guardian", Category: "sla_management", Source: SourceConsole, Enabled: true,
Description: "Flags breached and imminent SLA violations against promised delivery ETAs."},
Tools: []string{"scan_bookings", "notify_riders", "assign_riders"},
Schema: []ThresholdSpec{
minutes("slaRiskWindowMin", "At-risk warning window", 45, 15, 90, 5),
minutes("unassignedAgingMin", "Unassigned aging threshold", 60, 15, 120, 5),
}},
{Skill: models.AISkill{Skillid: "skill_doorstep_stall", Agentid: "CONSOLE_OPS_AGENT", Title: "Doorstep Stall Rescuer", Category: "rider_operations", Source: SourceConsole, Enabled: true,
Description: "Flags riders who marked arrival at the doorstep but have made no progress since."},
Tools: []string{"scan_bookings", "notify_riders"},
Schema: []ThresholdSpec{minutes("arrivedStalledMin", "Doorstep stall timeout", 20, 10, 60, 5)}},
{Skill: models.AISkill{Skillid: "skill_fleet_balancer", Agentid: "CONSOLE_OPS_AGENT", Title: "Fleet Load Balancer", Category: "fleet_optimization", Source: SourceConsole, Enabled: true,
Description: "Flags riders at maximum active capacity while queued work waits."},
Tools: []string{"scan_bookings"}, // flags only; its findings propose no action
Schema: []ThresholdSpec{{Key: "riderActiveCap", Label: "Rider active capacity cap", Unit: "orders", Default: 3, Min: 1, Max: 6, Step: 1}}},
{Skill: models.AISkill{Skillid: "skill_high_value_cod", Agentid: "CONSOLE_OPS_AGENT", Title: "High-Value Cash Guardian", Category: "loss_prevention", Source: SourceConsole,
Enabled: false, // no data: /admin/bookings rows carry no payment amount or mode
Description: "Audits large cash-on-delivery consignments. OFF: the booking rows it reads carry no payment amount or mode (bookingpayments is not preloaded), so enabled it would always report all-clear."},
Tools: []string{"scan_bookings", "enforce_otp_verification"},
Schema: []ThresholdSpec{{Key: "codRiskThresholdAmount", Label: "High-value COD threshold", Unit: "₹", Default: 3000, Min: 1000, Max: 20000, Step: 500}}},
{Skill: models.AISkill{Skillid: "skill_rider_battery_safety", Agentid: "CONSOLE_OPS_AGENT", Title: "Rider Device & SOS Safety", Category: "rider_safety", Source: SourceConsole,
Enabled: false, // no data: battery lives on milerprofiles, not booking rows
Description: "Warns before a rider becomes unreachable on a flat battery. OFF: battery level is on the rider profile, not on the booking rows it reads, so enabled it would always report all-clear."},
Tools: []string{"scan_bookings", "alert_low_battery_rider"},
Schema: []ThresholdSpec{{Key: "criticalBatteryPercent", Label: "Critical battery level", Unit: "%", Default: 15, Min: 5, Max: 30, Step: 5}}},
{Skill: models.AISkill{Skillid: "skill_hub_congestion", Agentid: "CONSOLE_OPS_AGENT", Title: "Hub Congestion Agent", Category: "sla_management", Source: SourceConsole, Enabled: true,
Description: "Detects parcels dwelling at a hub without rider pickup and proposes the nearest idle rider."},
Tools: []string{"scan_bookings", "dispatch_hub_idle_parcels"},
Schema: []ThresholdSpec{minutes("hubDwellMinutes", "Hub dwell threshold", 45, 15, 120, 5)}},
{Skill: models.AISkill{Skillid: "skill_late_dispatch", Agentid: "CONSOLE_OPS_AGENT", Title: "Late Dispatch Agent", Category: "sla_management", Source: SourceConsole, Enabled: true,
Description: "Flags accepted orders still waiting for dispatch and proposes auto-assignment."},
Tools: []string{"scan_bookings", "trigger_auto_dispatch"},
Schema: []ThresholdSpec{
minutes("lateDispatchMinutes", "Dispatch deadline", 30, 10, 90, 5),
minutes("criticalDispatchMinutes", "Critical dispatch deadline", 60, 30, 180, 10),
}},
{Skill: models.AISkill{Skillid: "skill_cash_exposure", Agentid: "CONSOLE_OPS_AGENT", Title: "Cash Exposure Agent", Category: "loss_prevention", Source: SourceConsole,
Enabled: false, // no data: no COD amount per order in /admin/bookings rows
Description: "Tracks COD cash per rider and proposes a hub handoff. OFF: the booking rows it reads carry no COD amount, so enabled it would always report all-clear."},
Tools: []string{"scan_bookings", "enforce_cash_handoff"},
Schema: []ThresholdSpec{
{Key: "maxCashPerRider", Label: "Max safe cash per rider", Unit: "₹", Default: 10000, Min: 2000, Max: 50000, Step: 1000},
{Key: "warningCashPercent", Label: "Warning threshold", Unit: "%", Default: 75, Min: 50, Max: 95, Step: 5},
}},
{Skill: models.AISkill{Skillid: "ops_briefing", Agentid: "CONSOLE_OPS_AGENT", Title: "Ops briefing", Category: "operations", Source: SourceConsole, Enabled: true,
Description: "Answer \"what needs attention right now\" in the chat by running every enabled monitoring skill over the booking scan — the same engine as the Exceptions banner.", Sampleprompt: "What needs attention right now?"},
Tools: []string{"scan_bookings"}},
{Skill: models.AISkill{Skillid: "dispatch_rebalance", Agentid: "CONSOLE_OPS_AGENT", Title: "Dispatch Rebalance & Allocation", Category: "dispatch", Source: SourceConsole,
// Off: rebalance_riders has no implementation behind it.
Enabled: false,
Description: "Move idle riders into zones with a demand spike. Disabled until an endpoint implements it.",
Sampleprompt: "Rebalance available riders into Zone 1 to prevent SLA delays"},
Tools: []string{"rebalance_riders"}},
// Console assistant
{Skill: models.AISkill{Skillid: "order_intake_auto_schedule", Agentid: "CONSOLE_ASSISTANT", Title: "Order Intake & Auto-Schedule", Category: "logistics", Source: SourceConsole, Enabled: true,
Description: "Parse orders from text or a sheet, price them, and create them once the operator confirms.",
Sampleprompt: "Repeat yesterday's orders for Neptune"},
Tools: []string{"create_single_order", "simulate_pricing_quote"}},
}

View File

@@ -0,0 +1,389 @@
package registry
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"doormile/models"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// Seed upserts the code-defined registry. Idempotent, and safe to run on every
// boot: code-defined columns are brought in line with the code, operator-owned
// columns (skill enabled/thresholds, agent autonomous/model) are only written
// when the row is first created. Custom skills are never touched.
func Seed(db *gorm.DB) error {
return db.Transaction(func(tx *gorm.DB) error {
for i := range SeedAgents {
a := SeedAgents[i]
if err := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "agentid"}},
DoUpdates: clause.AssignmentColumns([]string{"name", "runtime", "classref", "purpose", "wakeon", "status", "llmdecision", "hasautonomygate", "sortorder"}),
}).Create(&a).Error; err != nil {
return fmt.Errorf("seed agent %s: %w", a.Agentid, err)
}
}
for i := range SeedTools {
t := SeedTools[i]
if err := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "toolname"}},
DoUpdates: clause.AssignmentColumns([]string{"description", "kind", "target", "implementedat", "inputschema", "requiresconfirmation"}),
}).Create(&t).Error; err != nil {
return fmt.Errorf("seed tool %s: %w", t.Toolname, err)
}
}
seededIDs := make([]string, 0, len(SeedSkills))
for _, s := range SeedSkills {
row := s.Skill
row.Thresholdsschema = mustJSON(schemaOrEmpty(s.Schema))
row.Thresholds = mustJSON(DefaultThresholds(s.Schema))
row.Version = 1
// enabled, thresholds and version are written on first insert only;
// they are operator-owned from then on (see DoUpdates).
if err := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "skillid"}},
DoUpdates: clause.AssignmentColumns([]string{"agentid", "title", "category", "description", "sampleprompt", "source", "thresholdsschema"}),
}).Create(&row).Error; err != nil {
return fmt.Errorf("seed skill %s: %w", row.Skillid, err)
}
seededIDs = append(seededIDs, row.Skillid)
}
// Tool links of seeded skills are code-defined: replace them wholesale.
if err := tx.Where("skillid IN ?", seededIDs).Delete(&models.AISkillTool{}).Error; err != nil {
return fmt.Errorf("seed skill tools: %w", err)
}
var links []models.AISkillTool
for _, s := range SeedSkills {
for _, t := range s.Tools {
links = append(links, models.AISkillTool{Skillid: s.Skill.Skillid, Toolname: t})
}
}
if len(links) > 0 {
if err := tx.Create(&links).Error; err != nil {
return fmt.Errorf("seed skill tools: %w", err)
}
}
return nil
})
}
func schemaOrEmpty(s []ThresholdSpec) []ThresholdSpec {
if s == nil {
return []ThresholdSpec{}
}
return s
}
// AgentView is an agent as the API returns it.
type AgentView struct {
models.AIAgent
Skillcount int `json:"skillcount"`
Toolcount int `json:"toolcount"`
}
// ToolView is a tool with its input schema as real JSON.
type ToolView struct {
models.AITool
Inputschema json.RawMessage `json:"inputschema"`
}
// SkillView is a skill with its tools and its EFFECTIVE thresholds: stored
// values where still valid, defaults otherwise.
type SkillView struct {
models.AISkill
Tools []string `json:"tools"`
Thresholds map[string]float64 `json:"thresholds"`
Thresholdsschema []ThresholdSpec `json:"thresholdsschema"`
}
// Snapshot is the whole registry. Small by construction (tens of rows), so it
// is always read whole — a fixed four queries — and filtered in memory.
type Snapshot struct {
Agents []AgentView `json:"agents"`
Skills []SkillView `json:"skills"`
Tools []ToolView `json:"tools"`
}
// Load reads the registry.
func Load(db *gorm.DB) (*Snapshot, error) {
var agents []models.AIAgent
var tools []models.AITool
var skills []models.AISkill
var links []models.AISkillTool
if err := db.Order("sortorder, agentid").Find(&agents).Error; err != nil {
return nil, err
}
if err := db.Order("toolname").Find(&tools).Error; err != nil {
return nil, err
}
if err := db.Order("agentid, skillid").Find(&skills).Error; err != nil {
return nil, err
}
if err := db.Order("skillid, toolname").Find(&links).Error; err != nil {
return nil, err
}
return Build(agents, tools, skills, links), nil
}
// Build assembles a Snapshot from rows. Pure; Load is its only database step.
func Build(agents []models.AIAgent, tools []models.AITool, skills []models.AISkill, links []models.AISkillTool) *Snapshot {
toolsBySkill := map[string][]string{}
for _, l := range links {
toolsBySkill[l.Skillid] = append(toolsBySkill[l.Skillid], l.Toolname)
}
snap := &Snapshot{Agents: []AgentView{}, Skills: []SkillView{}, Tools: []ToolView{}}
skillCount := map[string]int{}
agentTools := map[string]map[string]bool{}
for _, s := range skills {
schema := ParseSchema(s.Thresholdsschema)
ts := toolsBySkill[s.Skillid]
if ts == nil {
ts = []string{}
}
snap.Skills = append(snap.Skills, SkillView{
AISkill: s,
Tools: ts,
Thresholds: EffectiveThresholds(schema, ParseThresholds(s.Thresholds)),
Thresholdsschema: schema,
})
skillCount[s.Agentid]++
if agentTools[s.Agentid] == nil {
agentTools[s.Agentid] = map[string]bool{}
}
for _, t := range ts {
agentTools[s.Agentid][t] = true
}
}
for _, a := range agents {
snap.Agents = append(snap.Agents, AgentView{AIAgent: a, Skillcount: skillCount[a.Agentid], Toolcount: len(agentTools[a.Agentid])})
}
for _, t := range tools {
raw := json.RawMessage(t.Inputschema)
if !json.Valid(raw) {
raw = json.RawMessage(`{"type":"object"}`)
}
snap.Tools = append(snap.Tools, ToolView{AITool: t, Inputschema: raw})
}
return snap
}
// ETag fingerprints a snapshot, so the engine can poll with If-None-Match and
// get a 304 until an operator actually changes something.
func ETag(s *Snapshot) string {
b, _ := json.Marshal(s)
sum := sha256.Sum256(b)
return `W/"` + hex.EncodeToString(sum[:8]) + `"`
}
func audit(tx *gorm.DB, entity, id, field string, oldV, newV any, actor Actor) error {
return tx.Create(&models.AIRegistryAudit{
Entity: entity, Entityid: id, Field: field,
Oldvalue: mustJSON(oldV), Newvalue: mustJSON(newV), Changedby: actor.UserID, Changedbyemail: actor.Email,
}).Error
}
// UpdateSkill applies an operator's patch. Row-locked, audited in the same
// transaction, and a no-op (no version bump, no audit) when nothing changes.
func UpdateSkill(db *gorm.DB, skillID string, p SkillPatch, actor Actor) error {
if p.Enabled == nil && p.Thresholds == nil {
return invalid("nothing to change: send enabled and/or thresholds")
}
return db.Transaction(func(tx *gorm.DB) error {
var s models.AISkill
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("skillid = ?", skillID).First(&s).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrNotFound
}
return err
}
updates := map[string]any{}
if p.Enabled != nil && *p.Enabled != s.Enabled {
if err := audit(tx, "skill", s.Skillid, "enabled", s.Enabled, *p.Enabled, actor); err != nil {
return err
}
updates["enabled"] = *p.Enabled
}
if p.Thresholds != nil {
schema := ParseSchema(s.Thresholdsschema)
current := EffectiveThresholds(schema, ParseThresholds(s.Thresholds))
next, err := ApplyThresholdPatch(schema, current, p.Thresholds)
if err != nil {
return err
}
if mustJSON(next) != mustJSON(current) {
if err := audit(tx, "skill", s.Skillid, "thresholds", current, next, actor); err != nil {
return err
}
updates["thresholds"] = mustJSON(next)
}
}
if len(updates) == 0 {
return nil
}
updates["version"] = gorm.Expr("version + 1")
updates["updatedby"] = actor.UserID
updates["updatedat"] = time.Now()
return tx.Model(&models.AISkill{}).Where("skillid = ?", s.Skillid).Updates(updates).Error
})
}
// UpdateAgent applies an operator's patch to an agent's autonomy or model.
func UpdateAgent(db *gorm.DB, agentID string, p AgentPatch, actor Actor) error {
return db.Transaction(func(tx *gorm.DB) error {
var a models.AIAgent
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("agentid = ?", agentID).First(&a).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrNotFound
}
return err
}
if err := CheckAgentPatch(a, p); err != nil {
return err
}
updates := map[string]any{}
if p.Autonomous != nil && *p.Autonomous != a.Autonomous {
if err := audit(tx, "agent", a.Agentid, "autonomous", a.Autonomous, *p.Autonomous, actor); err != nil {
return err
}
updates["autonomous"] = *p.Autonomous
}
if p.Model != nil && *p.Model != a.Model {
if err := audit(tx, "agent", a.Agentid, "model", a.Model, *p.Model, actor); err != nil {
return err
}
updates["model"] = *p.Model
}
if len(updates) == 0 {
return nil
}
updates["updatedby"] = actor.UserID
updates["updatedat"] = time.Now()
return tx.Model(&models.AIAgent{}).Where("agentid = ?", a.Agentid).Updates(updates).Error
})
}
// CreateSkill registers an operator-made skill on a console agent, built only
// from tools that exist. Returns the new skill id.
//
// Engine agents are refused: AI_engine runs only the skills written in its
// code, so a custom skill attached to one would do nothing while looking live.
func CreateSkill(db *gorm.DB, n NewSkill, actor Actor) (string, error) {
if err := CheckNewSkill(n); err != nil {
return "", err
}
var id string
err := db.Transaction(func(tx *gorm.DB) error {
var a models.AIAgent
if err := tx.Where("agentid = ?", n.Agentid).First(&a).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return invalid("agent %s does not exist", n.Agentid)
}
return err
}
if a.Runtime != RuntimeConsole {
return invalid("%s runs in AI_engine, which only runs the skills in its code; custom skills can be added to console agents only", a.Agentid)
}
var found []string
if err := tx.Model(&models.AITool{}).Where("toolname IN ?", n.Tools).Pluck("toolname", &found).Error; err != nil {
return err
}
if len(found) != len(n.Tools) {
have := map[string]bool{}
for _, f := range found {
have[f] = true
}
var missing []string
for _, t := range n.Tools {
if !have[t] {
missing = append(missing, t)
}
}
return invalid("unknown tools: %s", strings.Join(missing, ", "))
}
base := customSkillID(n.Title)
id = base
for i := 2; ; i++ {
var count int64
if err := tx.Model(&models.AISkill{}).Where("skillid = ?", id).Count(&count).Error; err != nil {
return err
}
if count == 0 {
break
}
if i > 50 {
return invalid("too many skills are already called %q", strings.TrimSpace(n.Title))
}
id = fmt.Sprintf("%s_%d", base, i)
}
uid := actor.UserID
row := models.AISkill{
Skillid: id, Agentid: a.Agentid, Title: strings.TrimSpace(n.Title), Category: strings.TrimSpace(n.Category),
Description: strings.TrimSpace(n.Description), Sampleprompt: strings.TrimSpace(n.Sampleprompt),
Source: SourceCustom, Enabled: true, Thresholds: "{}", Thresholdsschema: "[]", Version: 1, Updatedby: &uid,
}
if err := tx.Create(&row).Error; err != nil {
return err
}
links := make([]models.AISkillTool, 0, len(n.Tools))
for _, t := range n.Tools {
links = append(links, models.AISkillTool{Skillid: id, Toolname: t})
}
if err := tx.Create(&links).Error; err != nil {
return err
}
return audit(tx, "skill", id, "created", nil, map[string]any{"agentid": a.Agentid, "title": row.Title, "tools": n.Tools}, actor)
})
if err != nil {
return "", err
}
return id, nil
}
// AuditView is an audit row with its values as real JSON.
type AuditView struct {
models.AIRegistryAudit
Oldvalue json.RawMessage `json:"oldvalue"`
Newvalue json.RawMessage `json:"newvalue"`
}
// ListAudit returns the most recent registry changes, newest first.
func ListAudit(db *gorm.DB, limit int) ([]AuditView, error) {
if limit <= 0 || limit > 500 {
limit = 100
}
var rows []models.AIRegistryAudit
if err := db.Order("changedat DESC, auditid DESC").Limit(limit).Find(&rows).Error; err != nil {
return nil, err
}
out := make([]AuditView, 0, len(rows))
for _, r := range rows {
out = append(out, AuditView{AIRegistryAudit: r, Oldvalue: rawOrNull(r.Oldvalue), Newvalue: rawOrNull(r.Newvalue)})
}
return out, nil
}
func rawOrNull(s string) json.RawMessage {
if s == "" || !json.Valid([]byte(s)) {
return json.RawMessage("null")
}
return json.RawMessage(s)
}

View File

@@ -0,0 +1,280 @@
package registry
import (
"os"
"strings"
"testing"
"doormile/internal/testpg"
"doormile/models"
"gorm.io/gorm"
)
// Integration tests: the real SQL (upsert seed, row locks, jsonb, audit)
// against a real Postgres. Skipped unless REGISTRY_TEST_DSN is set.
//
// The DSN must point at a THROWAWAY database: these tests DROP and recreate
// the five registry tables. Never point it at a shared or production database.
// For example, with a disposable container:
//
// docker run --rm -d --name dm-registry-pg -e POSTGRES_PASSWORD=test -p 55432:5432 postgres:16-alpine
// REGISTRY_TEST_DSN="host=127.0.0.1 port=55432 user=postgres password=test dbname=postgres sslmode=disable" go test ./internal/ai/registry/
func testDB(t *testing.T) *gorm.DB {
t.Helper()
dsn := os.Getenv("REGISTRY_TEST_DSN")
if dsn == "" {
t.Skip("REGISTRY_TEST_DSN not set; skipping Postgres integration test")
}
db := testpg.Open(t, dsn, "airegistry_store_test")
all := []any{&models.AIRegistryAudit{}, &models.AISkillTool{}, &models.AISkill{}, &models.AITool{}, &models.AIAgent{}}
if err := db.Migrator().DropTable(all...); err != nil {
t.Fatalf("drop: %v", err)
}
if err := db.AutoMigrate(all...); err != nil {
t.Fatalf("migrate: %v", err)
}
if err := Seed(db); err != nil {
t.Fatalf("seed: %v", err)
}
return db
}
func skillRow(t *testing.T, db *gorm.DB, id string) models.AISkill {
t.Helper()
var s models.AISkill
if err := db.Where("skillid = ?", id).First(&s).Error; err != nil {
t.Fatalf("read skill %s: %v", id, err)
}
return s
}
func count(t *testing.T, db *gorm.DB, model any) int64 {
t.Helper()
var n int64
if err := db.Model(model).Count(&n).Error; err != nil {
t.Fatal(err)
}
return n
}
func TestPGSeedIsCompleteAndIdempotent(t *testing.T) {
db := testDB(t)
links := 0
for _, s := range SeedSkills {
links += len(s.Tools)
}
for i := 0; i < 2; i++ { // the second pass must change nothing
if i == 1 {
if err := Seed(db); err != nil {
t.Fatalf("second seed: %v", err)
}
}
if n := count(t, db, &models.AIAgent{}); n != int64(len(SeedAgents)) {
t.Errorf("pass %d: %d agents, want %d", i+1, n, len(SeedAgents))
}
if n := count(t, db, &models.AITool{}); n != int64(len(SeedTools)) {
t.Errorf("pass %d: %d tools, want %d", i+1, n, len(SeedTools))
}
if n := count(t, db, &models.AISkill{}); n != int64(len(SeedSkills)) {
t.Errorf("pass %d: %d skills, want %d", i+1, n, len(SeedSkills))
}
if n := count(t, db, &models.AISkillTool{}); n != int64(links) {
t.Errorf("pass %d: %d skill-tool links, want %d", i+1, n, links)
}
}
if n := count(t, db, &models.AIRegistryAudit{}); n != 0 {
t.Errorf("seeding wrote %d audit rows; only operator changes are audited", n)
}
}
// gorm drops a false bool that has a `default:true` tag from an INSERT. The
// seed selects columns explicitly so a skill seeded off really is off.
func TestPGSkillSeededOffStaysOff(t *testing.T) {
db := testDB(t)
if skillRow(t, db, "dispatch_rebalance").Enabled {
t.Fatal("dispatch_rebalance came up enabled in the database")
}
}
func TestPGLoadReturnsTheInventory(t *testing.T) {
db := testDB(t)
snap, err := Load(db)
if err != nil {
t.Fatal(err)
}
if len(snap.Agents) != len(SeedAgents) || len(snap.Skills) != len(SeedSkills) || len(snap.Tools) != len(SeedTools) {
t.Fatalf("snapshot sizes %d/%d/%d", len(snap.Agents), len(snap.Skills), len(snap.Tools))
}
if snap.Agents[0].Agentid != "JARVIS" {
t.Errorf("agents not in sort order: first is %s", snap.Agents[0].Agentid)
}
for _, s := range snap.Skills {
if s.Skillid == "skill_cash_exposure" && s.Thresholds["maxCashPerRider"] != 10000 {
t.Errorf("jsonb thresholds did not round-trip: %v", s.Thresholds)
}
}
}
func TestPGUpdateSkillIsAuditedAndVersioned(t *testing.T) {
db := testDB(t)
if err := UpdateSkill(db, "skill_sla_guardian", SkillPatch{Enabled: boolp(false)}, Actor{UserID: 42, Email: "tester@doormile.test"}); err != nil {
t.Fatal(err)
}
s := skillRow(t, db, "skill_sla_guardian")
if s.Enabled || s.Version != 2 || s.Updatedby == nil || *s.Updatedby != 42 {
t.Fatalf("after disable: enabled=%v version=%d updatedby=%v", s.Enabled, s.Version, s.Updatedby)
}
// The same patch again is a no-op: no version bump, no second audit row.
if err := UpdateSkill(db, "skill_sla_guardian", SkillPatch{Enabled: boolp(false)}, Actor{UserID: 42, Email: "tester@doormile.test"}); err != nil {
t.Fatal(err)
}
if v := skillRow(t, db, "skill_sla_guardian").Version; v != 2 {
t.Errorf("a no-op patch bumped the version to %d", v)
}
if err := UpdateSkill(db, "skill_sla_guardian", SkillPatch{Thresholds: map[string]any{"slaRiskWindowMin": 30.0}}, Actor{UserID: 42, Email: "tester@doormile.test"}); err != nil {
t.Fatal(err)
}
got := ParseThresholds(skillRow(t, db, "skill_sla_guardian").Thresholds)
if got["slaRiskWindowMin"] != 30 || got["unassignedAgingMin"] != 60 {
t.Errorf("thresholds after patch = %v", got)
}
rows, err := ListAudit(db, 10)
if err != nil {
t.Fatal(err)
}
if len(rows) != 2 || rows[0].Field != "thresholds" || rows[1].Field != "enabled" {
t.Fatalf("audit = %+v, want [thresholds, enabled] newest first", rows)
}
if string(rows[1].Oldvalue) != "true" || string(rows[1].Newvalue) != "false" || rows[1].Changedby != 42 || rows[1].Changedbyemail != "tester@doormile.test" {
t.Errorf("enabled audit row = old %s new %s by %d", rows[1].Oldvalue, rows[1].Newvalue, rows[1].Changedby)
}
}
// A refused patch writes nothing — including the valid half of it.
func TestPGRefusedPatchChangesNothing(t *testing.T) {
db := testDB(t)
err := UpdateSkill(db, "skill_sla_guardian", SkillPatch{Enabled: boolp(false), Thresholds: map[string]any{"slaRiskWindowMin": 999.0}}, Actor{UserID: 42, Email: "tester@doormile.test"})
if !isValidation(err) {
t.Fatalf("want a ValidationError, got %v", err)
}
s := skillRow(t, db, "skill_sla_guardian")
if !s.Enabled || s.Version != 1 {
t.Errorf("refused patch leaked: enabled=%v version=%d", s.Enabled, s.Version)
}
if n := count(t, db, &models.AIRegistryAudit{}); n != 0 {
t.Errorf("refused patch wrote %d audit rows", n)
}
}
func TestPGUpdateUnknownSkillIsNotFound(t *testing.T) {
db := testDB(t)
if err := UpdateSkill(db, "no_such_skill", SkillPatch{Enabled: boolp(true)}, Actor{UserID: 1, Email: "tester@doormile.test"}); err != ErrNotFound {
t.Errorf("got %v, want ErrNotFound", err)
}
}
// Re-seeding (every boot) must never undo an operator's decision.
func TestPGReseedKeepsOperatorChanges(t *testing.T) {
db := testDB(t)
if err := UpdateSkill(db, "skill_fleet_balancer", SkillPatch{Enabled: boolp(false), Thresholds: map[string]any{"riderActiveCap": 5.0}}, Actor{UserID: 7, Email: "tester@doormile.test"}); err != nil {
t.Fatal(err)
}
if err := UpdateAgent(db, "EXCEPTION_AGENT", AgentPatch{Model: strp("claude-haiku-4-5-20251001")}, Actor{UserID: 7, Email: "tester@doormile.test"}); err != nil {
t.Fatal(err)
}
if err := Seed(db); err != nil {
t.Fatal(err)
}
s := skillRow(t, db, "skill_fleet_balancer")
if s.Enabled || ParseThresholds(s.Thresholds)["riderActiveCap"] != 5 || s.Version != 2 {
t.Errorf("re-seed reverted the operator's skill change: enabled=%v thresholds=%s version=%d", s.Enabled, s.Thresholds, s.Version)
}
var a models.AIAgent
db.Where("agentid = ?", "EXCEPTION_AGENT").First(&a)
if a.Model != "claude-haiku-4-5-20251001" {
t.Errorf("re-seed reverted the agent model to %q", a.Model)
}
}
func TestPGAutonomyNeedsConfirmationAndIsAudited(t *testing.T) {
db := testDB(t)
if err := UpdateAgent(db, "EXCEPTION_AGENT", AgentPatch{Autonomous: boolp(true)}, Actor{UserID: 1, Email: "tester@doormile.test"}); !isValidation(err) {
t.Fatalf("autonomy switched on without confirmation: %v", err)
}
if err := UpdateAgent(db, "EXCEPTION_AGENT", AgentPatch{Autonomous: boolp(true), Confirm: "EXCEPTION_AGENT"}, Actor{UserID: 1, Email: "tester@doormile.test"}); err != nil {
t.Fatal(err)
}
var a models.AIAgent
db.Where("agentid = ?", "EXCEPTION_AGENT").First(&a)
if !a.Autonomous {
t.Fatal("autonomy was not saved")
}
rows, _ := ListAudit(db, 5)
if len(rows) != 1 || rows[0].Entity != "agent" || rows[0].Field != "autonomous" {
t.Errorf("autonomy change not audited: %+v", rows)
}
if err := UpdateAgent(db, "HUB_AGENT", AgentPatch{Autonomous: boolp(true), Confirm: "HUB_AGENT"}, Actor{UserID: 1, Email: "tester@doormile.test"}); !isValidation(err) {
t.Errorf("autonomy set on an ungated agent: %v", err)
}
}
func TestPGCreateSkill(t *testing.T) {
db := testDB(t)
n := NewSkill{Agentid: "CONSOLE_OPS_AGENT", Title: "Night shift watch", Tools: []string{"scan_bookings", "notify_riders"}}
id, err := CreateSkill(db, n, Actor{UserID: 9, Email: "tester@doormile.test"})
if err != nil || id != "custom_night_shift_watch" {
t.Fatalf("create = %q, %v", id, err)
}
id2, err := CreateSkill(db, n, Actor{UserID: 9, Email: "tester@doormile.test"})
if err != nil || id2 != "custom_night_shift_watch_2" {
t.Fatalf("second create with the same title = %q, %v", id2, err)
}
s := skillRow(t, db, id)
if s.Source != SourceCustom || !s.Enabled {
t.Errorf("custom skill row = %+v", s)
}
if _, err := CreateSkill(db, NewSkill{Agentid: "EXCEPTION_AGENT", Title: "x", Tools: []string{"scan_bookings"}}, Actor{UserID: 9, Email: "tester@doormile.test"}); !isValidation(err) || !strings.Contains(err.Error(), "console agents only") {
t.Errorf("custom skill on an engine agent: %v", err)
}
if _, err := CreateSkill(db, NewSkill{Agentid: "CONSOLE_OPS_AGENT", Title: "x", Tools: []string{"scan_bookings", "launch_rockets"}}, Actor{UserID: 9, Email: "tester@doormile.test"}); !isValidation(err) || !strings.Contains(err.Error(), "launch_rockets") {
t.Errorf("unknown tool not named: %v", err)
}
if _, err := CreateSkill(db, NewSkill{Agentid: "NOBODY", Title: "x", Tools: []string{"scan_bookings"}}, Actor{UserID: 9, Email: "tester@doormile.test"}); !isValidation(err) {
t.Errorf("unknown agent accepted: %v", err)
}
// Custom skills survive a re-seed untouched.
if err := Seed(db); err != nil {
t.Fatal(err)
}
var links int64
db.Model(&models.AISkillTool{}).Where("skillid = ?", id).Count(&links)
if links != 2 {
t.Errorf("re-seed touched a custom skill's tools: %d links", links)
}
}
func TestPGETagMovesOnlyWithRealChanges(t *testing.T) {
db := testDB(t)
before, _ := Load(db)
if err := Seed(db); err != nil {
t.Fatal(err)
}
same, _ := Load(db)
if ETag(before) != ETag(same) {
t.Error("a re-seed with no code change moved the ETag; the engine would refetch on every boot")
}
if err := UpdateSkill(db, "skill_doorstep_stall", SkillPatch{Thresholds: map[string]any{"arrivedStalledMin": 30.0}}, Actor{UserID: 1, Email: "tester@doormile.test"}); err != nil {
t.Fatal(err)
}
after, _ := Load(db)
if ETag(before) == ETag(after) {
t.Error("an operator change did not move the ETag")
}
}

View File

@@ -0,0 +1,152 @@
package registry
import (
"encoding/json"
"fmt"
"math"
"sort"
"strings"
)
// ThresholdSpec is one tunable number on a skill: its default and the range an
// operator may move it within. The ranges are the product's safety rails — a
// stall timeout of 0 minutes or a cash cap of ₹10 lakh is a typo, not a policy.
type ThresholdSpec struct {
Key string `json:"key"`
Label string `json:"label"`
Unit string `json:"unit,omitempty"`
Default float64 `json:"default"`
Min float64 `json:"min"`
Max float64 `json:"max"`
Step float64 `json:"step"`
}
// onStep reports whether v sits on the spec's step grid, measured from Min.
// Tolerant of float noise: 0.7 must pass a 0.05 step starting at 0.5.
func (s ThresholdSpec) onStep(v float64) bool {
if s.Step <= 0 {
return true
}
n := (v - s.Min) / s.Step
return math.Abs(n-math.Round(n)) < 1e-6
}
func (s ThresholdSpec) check(v float64) error {
if math.IsNaN(v) || math.IsInf(v, 0) {
return fmt.Errorf("%s must be a number", s.Key)
}
if v < s.Min || v > s.Max {
return fmt.Errorf("%s must be between %s and %s", s.Key, fmtNum(s.Min), fmtNum(s.Max))
}
if !s.onStep(v) {
return fmt.Errorf("%s must move in steps of %s from %s", s.Key, fmtNum(s.Step), fmtNum(s.Min))
}
return nil
}
func fmtNum(v float64) string {
return strings.TrimRight(strings.TrimRight(fmt.Sprintf("%.4f", v), "0"), ".")
}
// EffectiveThresholds is what a skill actually runs with: the stored value for
// every key the schema still defines and that is still in range, the default
// for everything else. Keys the schema no longer has are dropped. A schema
// change in code therefore never strands a skill on a value it can no longer
// validate, and never needs a data migration.
func EffectiveThresholds(schema []ThresholdSpec, stored map[string]float64) map[string]float64 {
out := make(map[string]float64, len(schema))
for _, s := range schema {
v, ok := stored[s.Key]
if !ok || s.check(v) != nil {
v = s.Default
}
out[s.Key] = v
}
return out
}
// ApplyThresholdPatch validates an operator's patch against the schema and
// returns the full resulting set. Unknown keys and non-numbers are refused, not
// ignored: a misspelt key silently doing nothing is exactly the failure an
// operator cannot see. All keys are checked before any error is returned, so
// the message lists every problem at once.
func ApplyThresholdPatch(schema []ThresholdSpec, current map[string]float64, patch map[string]any) (map[string]float64, error) {
byKey := make(map[string]ThresholdSpec, len(schema))
for _, s := range schema {
byKey[s.Key] = s
}
next := EffectiveThresholds(schema, current)
var problems []string
keys := make([]string, 0, len(patch))
for k := range patch {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
spec, known := byKey[k]
if !known {
problems = append(problems, fmt.Sprintf("%s is not a threshold of this skill", k))
continue
}
v, isNum := patch[k].(float64)
if !isNum {
problems = append(problems, fmt.Sprintf("%s must be a number", k))
continue
}
if err := spec.check(v); err != nil {
problems = append(problems, err.Error())
continue
}
next[k] = v
}
if len(problems) > 0 {
return nil, &ValidationError{Msg: strings.Join(problems, "; ")}
}
return next, nil
}
// ParseThresholds reads a stored jsonb thresholds document. Empty, null or
// malformed reads as "nothing stored", which EffectiveThresholds turns into
// the defaults — a bad row degrades to defaults rather than failing a read.
func ParseThresholds(raw string) map[string]float64 {
out := map[string]float64{}
if strings.TrimSpace(raw) == "" {
return out
}
_ = json.Unmarshal([]byte(raw), &out)
if out == nil {
out = map[string]float64{}
}
return out
}
// ParseSchema reads a stored thresholds schema. Malformed reads as no schema.
func ParseSchema(raw string) []ThresholdSpec {
var out []ThresholdSpec
if strings.TrimSpace(raw) == "" {
return []ThresholdSpec{}
}
if err := json.Unmarshal([]byte(raw), &out); err != nil || out == nil {
return []ThresholdSpec{}
}
return out
}
// DefaultThresholds is the value set a freshly seeded skill starts with.
func DefaultThresholds(schema []ThresholdSpec) map[string]float64 {
return EffectiveThresholds(schema, nil)
}
func mustJSON(v any) string {
b, err := json.Marshal(v)
if err != nil {
// Only ever called on values built in this package; a failure here is
// a programming error, and the seed tests exercise every one.
panic(err)
}
return string(b)
}