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,238 @@
package playground
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// OpenAICompat drives the playground through any OpenAI-compatible chat
// completions API — Groq by default (https://api.groq.com/openai/v1), or xAI,
// or another provider — with plain net/http, so no SDK dependency is needed.
//
// Configured from PLAYGROUND_LLM_BASE_URL / _API_KEY / _MODEL (see config).
// The model comes from that setting, not from the agent's registry pin: the
// registry holds Claude ids for AI_engine, which this provider cannot serve.
type OpenAICompat struct {
BaseURL string
APIKey string
Model string
HTTP *http.Client
}
// NewOpenAICompat returns a client with a bounded HTTP timeout.
func NewOpenAICompat(baseURL, apiKey, model string) *OpenAICompat {
return &OpenAICompat{
BaseURL: strings.TrimRight(baseURL, "/"),
APIKey: apiKey,
Model: model,
HTTP: &http.Client{Timeout: 90 * time.Second},
}
}
// ModelName is what the trace reports as the model that answered.
func (o *OpenAICompat) ModelName() string { return o.Model }
// ── Wire types (OpenAI chat completions) ────────────────────────────────────
type oaFunctionCall struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
}
type oaToolCall struct {
ID string `json:"id"`
Type string `json:"type"`
Function oaFunctionCall `json:"function"`
}
type oaMessage struct {
Role string `json:"role"`
Content *string `json:"content"`
ToolCalls []oaToolCall `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
}
type oaTool struct {
Type string `json:"type"`
Function struct {
Name string `json:"name"`
Description string `json:"description"`
Parameters json.RawMessage `json:"parameters"`
} `json:"function"`
}
type oaRequest struct {
Model string `json:"model"`
Messages []oaMessage `json:"messages"`
Tools []oaTool `json:"tools,omitempty"`
MaxCompletionTokens int64 `json:"max_completion_tokens,omitempty"`
}
type oaResponse struct {
Choices []struct {
Message oaMessage `json:"message"`
FinishReason string `json:"finish_reason"`
} `json:"choices"`
Usage struct {
PromptTokens int64 `json:"prompt_tokens"`
CompletionTokens int64 `json:"completion_tokens"`
} `json:"usage"`
Error *struct {
Message string `json:"message"`
} `json:"error"`
}
func strp(s string) *string { return &s }
// objectSchema makes sure a tool's parameters are an object schema with a
// properties map — OpenAI-style APIs reject `{"type":"object"}` alone on some
// models, and several registry tools have open schemas.
func objectSchema(raw json.RawMessage) json.RawMessage {
var m map[string]any
if len(raw) == 0 || json.Unmarshal(raw, &m) != nil || m == nil {
m = map[string]any{}
}
m["type"] = "object"
if _, ok := m["properties"]; !ok {
m["properties"] = map[string]any{}
}
b, _ := json.Marshal(m)
return b
}
// toWire converts the playground conversation into chat-completions messages.
func toWire(req Request) oaRequest {
out := oaRequest{MaxCompletionTokens: req.MaxTokens}
if req.System != "" {
out.Messages = append(out.Messages, oaMessage{Role: "system", Content: strp(req.System)})
}
for _, t := range req.Turns {
switch {
case t.Role == "assistant":
msg := oaMessage{Role: "assistant"}
var texts []string
for _, b := range t.Assistant {
switch {
case b.Type == "text" && b.Text != "":
texts = append(texts, b.Text)
case b.Type == "tool_use" && b.ToolUse != nil:
args := string(b.ToolUse.Input)
if args == "" {
args = "{}"
}
msg.ToolCalls = append(msg.ToolCalls, oaToolCall{
ID: b.ToolUse.ID, Type: "function",
Function: oaFunctionCall{Name: b.ToolUse.Name, Arguments: args},
})
}
}
if len(texts) > 0 {
msg.Content = strp(strings.Join(texts, "\n\n"))
}
out.Messages = append(out.Messages, msg)
case len(t.Results) > 0:
for _, r := range t.Results {
out.Messages = append(out.Messages, oaMessage{Role: "tool", ToolCallID: r.ToolUseID, Content: strp(r.Content)})
}
default:
out.Messages = append(out.Messages, oaMessage{Role: "user", Content: strp(t.Text)})
}
}
for _, td := range req.Tools {
var tool oaTool
tool.Type = "function"
tool.Function.Name = td.Name
tool.Function.Description = td.Description
tool.Function.Parameters = objectSchema(td.InputSchema)
out.Tools = append(out.Tools, tool)
}
return out
}
// fromWire converts one chat-completions response into a playground Reply.
func fromWire(resp oaResponse) (Reply, error) {
if len(resp.Choices) == 0 {
return Reply{}, errors.New("model returned no choices")
}
ch := resp.Choices[0]
r := Reply{InputTokens: resp.Usage.PromptTokens, OutputTokens: resp.Usage.CompletionTokens}
if ch.Message.Content != nil && strings.TrimSpace(*ch.Message.Content) != "" {
r.Blocks = append(r.Blocks, Block{Type: "text", Text: *ch.Message.Content})
}
for _, tc := range ch.Message.ToolCalls {
input := json.RawMessage(tc.Function.Arguments)
if !json.Valid(input) {
input = json.RawMessage(`{}`)
}
r.Blocks = append(r.Blocks, Block{Type: "tool_use", ToolUse: &ToolUse{ID: tc.ID, Name: tc.Function.Name, Input: input}})
}
switch ch.FinishReason {
case "tool_calls":
r.StopReason = "tool_use"
case "length":
r.StopReason = "max_tokens"
default:
r.StopReason = "end_turn"
}
// Some providers report "stop" while still returning tool calls; the calls win.
if len(ch.Message.ToolCalls) > 0 {
r.StopReason = "tool_use"
}
return r, nil
}
// Next performs one chat-completions call.
func (o *OpenAICompat) Next(ctx context.Context, req Request) (Reply, error) {
body := toWire(req)
body.Model = o.Model
payload, err := json.Marshal(body)
if err != nil {
return Reply{}, fmt.Errorf("encode request: %w", err)
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, o.BaseURL+"/chat/completions", bytes.NewReader(payload))
if err != nil {
return Reply{}, fmt.Errorf("build request: %w", err)
}
httpReq.Header.Set("Authorization", "Bearer "+o.APIKey)
httpReq.Header.Set("Content-Type", "application/json")
res, err := o.HTTP.Do(httpReq)
if err != nil {
return Reply{}, fmt.Errorf("model request: %w", err)
}
defer res.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(res.Body, 4<<20))
var out oaResponse
_ = json.Unmarshal(raw, &out)
if res.StatusCode/100 != 2 {
msg := strings.TrimSpace(string(raw))
if out.Error != nil && out.Error.Message != "" {
msg = out.Error.Message
}
if len(msg) > 300 {
msg = msg[:300]
}
return Reply{}, &ProviderError{Status: res.StatusCode, Message: msg}
}
return fromWire(out)
}
// ProviderError is a non-2xx answer from the model provider. The status lets
// the controller tell "rate limited" (429, common on free tiers) from a
// genuine failure. The message never contains the API key.
type ProviderError struct {
Status int
Message string
}
func (e *ProviderError) Error() string {
return fmt.Sprintf("model provider answered %d: %s", e.Status, e.Message)
}

View File

@@ -0,0 +1,130 @@
package playground
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// A fake OpenAI-compatible server: first call asks for a tool, second answers.
func fakeProvider(t *testing.T, seen *[]map[string]any) *httptest.Server {
t.Helper()
calls := 0
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/openai/v1/chat/completions" {
t.Errorf("path = %s", r.URL.Path)
}
if r.Header.Get("Authorization") != "Bearer test-key" {
t.Errorf("auth header = %q", r.Header.Get("Authorization"))
}
b, _ := io.ReadAll(r.Body)
var body map[string]any
_ = json.Unmarshal(b, &body)
*seen = append(*seen, body)
calls++
w.Header().Set("Content-Type", "application/json")
if calls == 1 {
_, _ = io.WriteString(w, `{"choices":[{"message":{"role":"assistant","content":null,"tool_calls":[
{"id":"call_1","type":"function","function":{"name":"get_booking_cache","arguments":"{\"booking_id\":5}"}},
{"id":"call_2","type":"function","function":{"name":"reassign_booking","arguments":"not json"}}]},
"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":100,"completion_tokens":20}}`)
return
}
_, _ = io.WriteString(w, `{"choices":[{"message":{"role":"assistant","content":"Proposed a reassign."},"finish_reason":"stop"}],
"usage":{"prompt_tokens":150,"completion_tokens":10}}`)
}))
}
func TestOpenAICompatRunsTheToolLoop(t *testing.T) {
var seen []map[string]any
srv := fakeProvider(t, &seen)
defer srv.Close()
m := NewOpenAICompat(srv.URL+"/openai/v1/", "test-key", "openai/gpt-oss-120b")
plan := mustPlan(t, "EXCEPTION_AGENT", "stall_response")
plan.Model = m.ModelName()
execs := map[string]Executor{
"get_booking_cache": func(context.Context, json.RawMessage) (any, error) {
return map[string]any{"bookingid": 5, "customername": "Ravi"}, nil
},
}
tr, err := Run(context.Background(), m, plan, "booking 5 is stuck", execs)
if err != nil {
t.Fatal(err)
}
if tr.Final != "Proposed a reassign." || tr.Turns != 2 || tr.Inputtokens != 250 || tr.Outputtokens != 30 {
t.Fatalf("trace = %+v", tr)
}
if tr.Model != "openai/gpt-oss-120b" {
t.Fatalf("model = %s", tr.Model)
}
if tr.Steps[0].Outcome != OutcomeExecuted || tr.Steps[1].Outcome != OutcomeProposed || string(tr.Steps[1].Input) != "{}" {
t.Fatalf("steps = %+v", tr.Steps)
}
// First request: system + user, the model from config, tools as functions
// with object schemas.
first := seen[0]
if first["model"] != "openai/gpt-oss-120b" || first["max_completion_tokens"].(float64) != MaxTokens {
t.Fatalf("first request = %v", first)
}
msgs := first["messages"].([]any)
if msgs[0].(map[string]any)["role"] != "system" || msgs[1].(map[string]any)["role"] != "user" {
t.Fatalf("messages = %v", msgs)
}
tool := first["tools"].([]any)[0].(map[string]any)
params := tool["function"].(map[string]any)["parameters"].(map[string]any)
if tool["type"] != "function" || params["type"] != "object" || params["properties"] == nil {
t.Fatalf("tool = %v", tool)
}
// Second request: the assistant's tool_calls echoed, then one tool message
// per call, redacted.
msgs = seen[1]["messages"].([]any)
asst := msgs[2].(map[string]any)
if asst["role"] != "assistant" || len(asst["tool_calls"].([]any)) != 2 {
t.Fatalf("assistant echo = %v", asst)
}
res1, res2 := msgs[3].(map[string]any), msgs[4].(map[string]any)
if res1["role"] != "tool" || res1["tool_call_id"] != "call_1" || res2["tool_call_id"] != "call_2" {
t.Fatalf("tool results = %v %v", res1, res2)
}
if strings.Contains(res1["content"].(string), "Ravi") {
t.Fatal("personal data reached the provider")
}
if !strings.Contains(res2["content"].(string), `"executed":false`) {
t.Fatalf("write tool was not answered as a proposal: %v", res2["content"])
}
}
func TestOpenAICompatReportsProviderErrors(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusTooManyRequests)
_, _ = io.WriteString(w, `{"error":{"message":"Rate limit reached for model"}}`)
}))
defer srv.Close()
_, err := NewOpenAICompat(srv.URL, "secret-key-value", "m").Next(context.Background(), Request{Turns: []Turn{{Role: "user", Text: "hi"}}})
var pe *ProviderError
if !errors.As(err, &pe) || pe.Status != 429 || !strings.Contains(pe.Message, "Rate limit") {
t.Fatalf("err = %v", err)
}
if strings.Contains(err.Error(), "secret-key-value") {
t.Fatal("the API key leaked into the error")
}
}
func TestOpenAICompatStopWithToolCallsStillLoops(t *testing.T) {
r, err := fromWire(oaResponse{Choices: []struct {
Message oaMessage `json:"message"`
FinishReason string `json:"finish_reason"`
}{{Message: oaMessage{ToolCalls: []oaToolCall{{ID: "c", Function: oaFunctionCall{Name: "x", Arguments: "{}"}}}}, FinishReason: "stop"}}})
if err != nil || r.StopReason != "tool_use" {
t.Fatalf("reply = %+v, err = %v", r, err)
}
}

View File

@@ -0,0 +1,341 @@
// Package playground runs one operator prompt through Claude with a registry
// skill's tools, for Agent Studio's Test tab (Phase 6 of
// krow_talent_app/docs/agent-platform-plan.md).
//
// The rules that make it safe to point at production:
// - read tools the backend can serve run for real, and their results are
// redacted (names, phones, addresses, emails, free text) before Claude sees
// them — see redact.go;
// - write, notify and event tools NEVER run: the call is answered with a
// proposal and shown in the trace as "proposed";
// - a tool outside the selected skill is refused;
// - the loop is bounded (MaxTurns) and so is every tool call (ToolTimeout).
//
// Claude is reached through the Model interface, so the loop is tested with a
// fake and the server wires in a real client only when one is configured.
package playground
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"doormile/internal/ai/registry"
)
// DefaultModel is used when the agent has no model pinned in the registry.
const DefaultModel = "claude-opus-5-5"
const (
MaxTurns = 6
MaxTokens = 4096
MaxPromptChars = 2000
ToolTimeout = 5 * time.Second
maxResultBytes = 16 * 1024
)
// Tool kinds, as the registry stores them.
const (
kindRead = "read"
)
// Outcomes of a tool call, as the trace shows them.
const (
OutcomeExecuted = "executed"
OutcomeProposed = "proposed"
OutcomeUnavailable = "unavailable"
OutcomeError = "error"
OutcomeRejected = "rejected"
)
// ── The model boundary ──────────────────────────────────────────────────────
// ToolUse is a tool call Claude asked for.
type ToolUse struct {
ID string
Name string
Input json.RawMessage
}
// Block is one content block of a reply. Text and tool_use are read by the
// loop; anything else (thinking) is carried in Raw and sent back unchanged,
// which the API requires within a tool-use turn.
type Block struct {
Type string
Text string
ToolUse *ToolUse
Raw json.RawMessage
}
// Reply is one model response.
type Reply struct {
Blocks []Block
StopReason string
InputTokens int64
OutputTokens int64
}
// ToolResult answers one ToolUse.
type ToolResult struct {
ToolUseID string
Content string
IsError bool
}
// Turn is one message of the conversation: the user's prompt, an assistant
// reply, or the user turn carrying tool results.
type Turn struct {
Role string // "user" or "assistant"
Text string
Assistant []Block
Results []ToolResult
}
// ToolDef is a tool as offered to the model.
type ToolDef struct {
Name string
Description string
InputSchema json.RawMessage
}
// Request is one model call.
type Request struct {
Model string
System string
MaxTokens int64
Tools []ToolDef
Turns []Turn
}
// Model is the one call the loop needs from Claude.
type Model interface {
Next(ctx context.Context, req Request) (Reply, error)
}
// ── Plan: what a run may use ────────────────────────────────────────────────
// ErrNotFound is returned when the agent or skill does not exist.
var ErrNotFound = errors.New("not found")
// Plan is the resolved agent, skill and tools for one run.
type Plan struct {
AgentID string
AgentName string
SkillID string
Model string
System string
Tools []ToolDef
kinds map[string]string
}
// Prepare resolves a run from the registry. skillID may be empty: the run then
// gets every tool of the agent's enabled skills.
func Prepare(snap *registry.Snapshot, agentID, skillID string) (*Plan, error) {
var agent *registry.AgentView
for i := range snap.Agents {
if snap.Agents[i].Agentid == agentID {
agent = &snap.Agents[i]
}
}
if agent == nil {
return nil, fmt.Errorf("agent %q: %w", agentID, ErrNotFound)
}
toolNames := map[string]bool{}
var skillLines []string
found := skillID == ""
for _, s := range snap.Skills {
if s.Agentid != agentID {
continue
}
if skillID != "" && s.Skillid != skillID {
continue
}
if skillID == "" && !s.Enabled {
continue
}
found = true
skillLines = append(skillLines, fmt.Sprintf("- %s: %s", s.Title, s.Description))
for _, t := range s.Tools {
toolNames[t] = true
}
}
if !found {
return nil, fmt.Errorf("skill %q of agent %q: %w", skillID, agentID, ErrNotFound)
}
plan := &Plan{
AgentID: agent.Agentid, AgentName: agent.Name, SkillID: skillID,
Model: agent.Model, kinds: map[string]string{}, Tools: []ToolDef{},
}
if plan.Model == "" {
plan.Model = DefaultModel
}
for _, t := range snap.Tools {
if !toolNames[t.Toolname] {
continue
}
desc := t.Description
if t.Kind != kindRead {
desc += " [Playground: NOT executed — calling it records a proposal for a human.]"
}
plan.Tools = append(plan.Tools, ToolDef{Name: t.Toolname, Description: desc, InputSchema: t.Inputschema})
plan.kinds[t.Toolname] = t.Kind
}
plan.System = strings.Join([]string{
fmt.Sprintf("You are %s, an agent in Doormile's delivery operations, being tested by an operator in the Agent Studio playground.", agent.Name),
"Purpose: " + agent.Purpose,
"Skills in scope:\n" + strings.Join(skillLines, "\n"),
"Read tools return live data with personal details (names, phones, addresses, notes) removed; do not ask for them.",
"Write, notify and event tools are not executed here: calling one records a proposal for a human to review. Say plainly what you would do and why.",
"If a tool is unavailable, say so rather than guessing its result. Keep the final answer short and concrete.",
}, "\n\n")
return plan, nil
}
// ── The run ─────────────────────────────────────────────────────────────────
// Executor runs one read tool. Its result is redacted before the model sees it.
type Executor func(ctx context.Context, input json.RawMessage) (any, error)
// Step is one line of the trace the console shows.
type Step struct {
Kind string `json:"kind"` // "text" or "tool"
Text string `json:"text,omitempty"`
Tool string `json:"tool,omitempty"`
Toolkind string `json:"toolkind,omitempty"`
Input json.RawMessage `json:"input,omitempty"`
Outcome string `json:"outcome,omitempty"`
Result json.RawMessage `json:"result,omitempty"`
Ms int64 `json:"ms"`
}
// Trace is the whole run.
type Trace struct {
Agentid string `json:"agentid"`
Skillid string `json:"skillid"`
Model string `json:"model"`
Steps []Step `json:"steps"`
Final string `json:"final"`
Stopreason string `json:"stopreason"`
Turns int `json:"turns"`
Inputtokens int64 `json:"inputtokens"`
Outputtokens int64 `json:"outputtokens"`
Ms int64 `json:"ms"`
}
// Run executes the prompt. A model error ends the run with the error; the
// trace so far is still returned so the console can show how far it got.
func Run(ctx context.Context, m Model, plan *Plan, prompt string, execs map[string]Executor) (*Trace, error) {
started := time.Now()
tr := &Trace{Agentid: plan.AgentID, Skillid: plan.SkillID, Model: plan.Model, Steps: []Step{}}
turns := []Turn{{Role: "user", Text: prompt}}
defer func() { tr.Ms = time.Since(started).Milliseconds() }()
for tr.Turns < MaxTurns {
callStarted := time.Now()
reply, err := m.Next(ctx, Request{
Model: plan.Model, System: plan.System, MaxTokens: MaxTokens, Tools: plan.Tools, Turns: turns,
})
tr.Turns++
if err != nil {
tr.Stopreason = "error"
return tr, err
}
tr.Inputtokens += reply.InputTokens
tr.Outputtokens += reply.OutputTokens
tr.Stopreason = reply.StopReason
modelMs := time.Since(callStarted).Milliseconds()
turns = append(turns, Turn{Role: "assistant", Assistant: reply.Blocks})
var texts []string
var results []ToolResult
for _, b := range reply.Blocks {
switch {
case b.Type == "text" && strings.TrimSpace(b.Text) != "":
texts = append(texts, b.Text)
tr.Steps = append(tr.Steps, Step{Kind: "text", Text: b.Text, Ms: modelMs})
modelMs = 0
case b.Type == "tool_use" && b.ToolUse != nil:
step, res := callTool(ctx, plan, execs, *b.ToolUse)
tr.Steps = append(tr.Steps, step)
results = append(results, res)
}
}
if reply.StopReason != "tool_use" || len(results) == 0 {
tr.Final = strings.Join(texts, "\n\n")
return tr, nil
}
turns = append(turns, Turn{Role: "user", Results: results})
}
tr.Stopreason = "max_turns"
return tr, nil
}
func callTool(ctx context.Context, plan *Plan, execs map[string]Executor, use ToolUse) (Step, ToolResult) {
started := time.Now()
input := use.Input
if len(input) == 0 || !json.Valid(input) {
input = json.RawMessage(`{}`)
}
kind, inSkill := plan.kinds[use.Name]
step := Step{Kind: "tool", Tool: use.Name, Toolkind: kind, Input: input}
res := ToolResult{ToolUseID: use.ID}
finish := func(outcome string, payload any, isError bool) (Step, ToolResult) {
body := encode(payload)
step.Outcome, step.Result, step.Ms = outcome, body, time.Since(started).Milliseconds()
res.Content, res.IsError = string(body), isError
return step, res
}
switch {
case !inSkill:
return finish(OutcomeRejected, map[string]string{"error": "This tool is not part of the selected skill."}, true)
case kind != kindRead:
return finish(OutcomeProposed, map[string]any{
"executed": false,
"proposal": map[string]any{"tool": use.Name, "input": input},
"note": "Playground: recorded as a proposal for a human. Nothing was changed.",
}, false)
case execs[use.Name] == nil:
return finish(OutcomeUnavailable, map[string]string{
"error": "This read tool is not available in the playground (it runs inside AI_engine or calls an external service).",
}, true)
}
tctx, cancel := context.WithTimeout(ctx, ToolTimeout)
defer cancel()
out, err := execs[use.Name](tctx, input)
if err != nil {
return finish(OutcomeError, map[string]string{"error": err.Error()}, true)
}
return finish(OutcomeExecuted, Redact(out), false)
}
// encode marshals a tool result, capped so one large read cannot blow the
// context window or the console.
func encode(v any) json.RawMessage {
b, err := json.Marshal(v)
if err != nil {
b, _ = json.Marshal(map[string]string{"error": "result could not be encoded"})
}
if len(b) > maxResultBytes {
b, _ = json.Marshal(map[string]any{
"truncated": true,
"note": fmt.Sprintf("Result was %d bytes; showing the first %d.", len(b), maxResultBytes),
"partial": string(b[:maxResultBytes]),
})
}
return b
}

View File

@@ -0,0 +1,280 @@
package playground
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"doormile/internal/ai/registry"
"doormile/models"
)
// fakeModel replays scripted replies and records every request.
type fakeModel struct {
replies []Reply
err error
reqs []Request
}
func (f *fakeModel) Next(_ context.Context, req Request) (Reply, error) {
f.reqs = append(f.reqs, req)
if f.err != nil {
return Reply{}, f.err
}
if len(f.replies) == 0 {
return Reply{StopReason: "end_turn", Blocks: []Block{{Type: "text", Text: "done"}}}, nil
}
r := f.replies[0]
f.replies = f.replies[1:]
return r, nil
}
func toolCall(id, name, input string) Block {
return Block{Type: "tool_use", ToolUse: &ToolUse{ID: id, Name: name, Input: json.RawMessage(input)}}
}
func testSnapshot() *registry.Snapshot {
agents := []models.AIAgent{
{Agentid: "EXCEPTION_AGENT", Name: "Exception", Purpose: "Handles stalled riders.", Model: "claude-sonnet-5-5"},
{Agentid: "CONSOLE_OPS_AGENT", Name: "Console Ops Agent", Purpose: "Watches the board."},
}
tools := []models.AITool{
{Toolname: "get_booking_cache", Kind: "read", Description: "Read a booking.", Inputschema: `{"type":"object"}`},
{Toolname: "nearby_milers", Kind: "read", Description: "Riders near a point.", Inputschema: `{"type":"object"}`},
{Toolname: "decide_stall_response", Kind: "read", Description: "Engine decision.", Inputschema: `{"type":"object"}`},
{Toolname: "reassign_booking", Kind: "write", Description: "Reassign.", Inputschema: `{"type":"object"}`},
{Toolname: "scan_bookings", Kind: "read", Description: "Scan.", Inputschema: `{"type":"object"}`},
}
skills := []models.AISkill{
{Skillid: "stall_response", Agentid: "EXCEPTION_AGENT", Title: "Stall", Description: "Respond to stalls.", Enabled: true},
{Skillid: "off_skill", Agentid: "EXCEPTION_AGENT", Title: "Off", Enabled: false},
{Skillid: "late_dispatch", Agentid: "CONSOLE_OPS_AGENT", Title: "Late", Enabled: true},
}
links := []models.AISkillTool{
{Skillid: "stall_response", Toolname: "get_booking_cache"},
{Skillid: "stall_response", Toolname: "nearby_milers"},
{Skillid: "stall_response", Toolname: "decide_stall_response"},
{Skillid: "stall_response", Toolname: "reassign_booking"},
{Skillid: "off_skill", Toolname: "scan_bookings"},
{Skillid: "late_dispatch", Toolname: "scan_bookings"},
}
return registry.Build(agents, tools, skills, links)
}
func mustPlan(t *testing.T, agent, skill string) *Plan {
t.Helper()
p, err := Prepare(testSnapshot(), agent, skill)
if err != nil {
t.Fatalf("Prepare: %v", err)
}
return p
}
func toolNames(p *Plan) []string {
var out []string
for _, t := range p.Tools {
out = append(out, t.Name)
}
return out
}
// ── Prepare ─────────────────────────────────────────────────────────────────
func TestPrepareUsesSkillToolsAndPinnedModel(t *testing.T) {
p := mustPlan(t, "EXCEPTION_AGENT", "stall_response")
if p.Model != "claude-sonnet-5-5" {
t.Fatalf("model = %q, want the registry pin", p.Model)
}
got := strings.Join(toolNames(p), ",")
// Registry order (Load sorts by toolname; this fixture is in its own order).
if got != "get_booking_cache,nearby_milers,decide_stall_response,reassign_booking" {
t.Fatalf("tools = %s", got)
}
for _, td := range p.Tools {
marked := strings.Contains(td.Description, "NOT executed")
if (td.Name == "reassign_booking") != marked {
t.Fatalf("%s: write-tool marking = %v", td.Name, marked)
}
}
}
func TestPrepareDefaultsModelAndSkipsDisabledSkillsWhenNoSkillGiven(t *testing.T) {
p := mustPlan(t, "CONSOLE_OPS_AGENT", "")
if p.Model != DefaultModel {
t.Fatalf("model = %q, want %q", p.Model, DefaultModel)
}
p = mustPlan(t, "EXCEPTION_AGENT", "")
for _, n := range toolNames(p) {
if n == "scan_bookings" {
t.Fatal("a disabled skill's tool was offered")
}
}
}
func TestPrepareNotFound(t *testing.T) {
for _, c := range [][2]string{{"NOPE", ""}, {"EXCEPTION_AGENT", "late_dispatch"}, {"EXCEPTION_AGENT", "missing"}} {
if _, err := Prepare(testSnapshot(), c[0], c[1]); !errors.Is(err, ErrNotFound) {
t.Fatalf("%v: err = %v, want ErrNotFound", c, err)
}
}
}
// ── Run ─────────────────────────────────────────────────────────────────────
func TestRunTextOnly(t *testing.T) {
m := &fakeModel{replies: []Reply{{StopReason: "end_turn", InputTokens: 10, OutputTokens: 5,
Blocks: []Block{{Type: "thinking", Raw: json.RawMessage(`{"type":"thinking"}`)}, {Type: "text", Text: "All clear."}}}}}
tr, err := Run(context.Background(), m, mustPlan(t, "EXCEPTION_AGENT", "stall_response"), "status?", nil)
if err != nil {
t.Fatal(err)
}
if tr.Final != "All clear." || tr.Turns != 1 || tr.Inputtokens != 10 || tr.Outputtokens != 5 || tr.Model != "claude-sonnet-5-5" {
t.Fatalf("trace = %+v", tr)
}
if len(tr.Steps) != 1 || tr.Steps[0].Kind != "text" {
t.Fatalf("steps = %+v", tr.Steps)
}
req := m.reqs[0]
if req.Turns[0].Text != "status?" || req.MaxTokens != MaxTokens || len(req.Tools) != 4 || req.System == "" {
t.Fatalf("request = %+v", req)
}
}
func TestRunToolOutcomes(t *testing.T) {
executed := 0
execs := map[string]Executor{
"get_booking_cache": func(_ context.Context, in json.RawMessage) (any, error) {
executed++
return map[string]any{"bookingid": 5, "customername": "Ravi", "notes": "call 9876543210"}, nil
},
// Present but NOT in the skill — must never run.
"scan_bookings": func(context.Context, json.RawMessage) (any, error) {
t.Fatal("a tool outside the skill was executed")
return nil, nil
},
"nearby_milers": func(context.Context, json.RawMessage) (any, error) { return nil, errors.New("positions unavailable") },
}
m := &fakeModel{replies: []Reply{
{StopReason: "tool_use", Blocks: []Block{
toolCall("t1", "get_booking_cache", `{"booking_id":5}`),
toolCall("t2", "reassign_booking", `{"booking_id":5}`),
toolCall("t3", "scan_bookings", `{}`),
toolCall("t4", "decide_stall_response", `{}`),
toolCall("t5", "nearby_milers", `{"lat":11,"lon":77}`),
}},
{StopReason: "end_turn", Blocks: []Block{{Type: "text", Text: "Proposed a reassign."}}},
}}
tr, err := Run(context.Background(), m, mustPlan(t, "EXCEPTION_AGENT", "stall_response"), "booking 5 is stuck", execs)
if err != nil {
t.Fatal(err)
}
want := map[string]string{
"get_booking_cache": OutcomeExecuted, "reassign_booking": OutcomeProposed, "scan_bookings": OutcomeRejected,
"decide_stall_response": OutcomeUnavailable, "nearby_milers": OutcomeError,
}
for _, s := range tr.Steps {
if s.Kind == "tool" && want[s.Tool] != s.Outcome {
t.Fatalf("%s outcome = %s, want %s", s.Tool, s.Outcome, want[s.Tool])
}
}
if executed != 1 {
t.Fatalf("read tool executed %d times", executed)
}
// The second request carries all five results, in order, and redacted.
results := m.reqs[1].Turns[2].Results
if len(results) != 5 || results[0].ToolUseID != "t1" {
t.Fatalf("results = %+v", results)
}
if strings.Contains(results[0].Content, "Ravi") || strings.Contains(results[0].Content, "9876543210") {
t.Fatalf("personal data reached the model: %s", results[0].Content)
}
if !strings.Contains(results[1].Content, `"executed":false`) || results[1].IsError {
t.Fatalf("write tool result = %+v", results[1])
}
for _, i := range []int{2, 3, 4} {
if !results[i].IsError {
t.Fatalf("result %d should be an error: %+v", i, results[i])
}
}
// The assistant turn (with its tool_use blocks) is echoed back before the results.
if m.reqs[1].Turns[1].Role != "assistant" || len(m.reqs[1].Turns[1].Assistant) != 5 {
t.Fatalf("assistant turn not echoed: %+v", m.reqs[1].Turns[1])
}
if tr.Final != "Proposed a reassign." || tr.Turns != 2 {
t.Fatalf("trace = %+v", tr)
}
}
func TestRunStopsAtMaxTurns(t *testing.T) {
var replies []Reply
for i := 0; i < MaxTurns+2; i++ {
replies = append(replies, Reply{StopReason: "tool_use", Blocks: []Block{toolCall("t", "reassign_booking", `{}`)}})
}
m := &fakeModel{replies: replies}
tr, err := Run(context.Background(), m, mustPlan(t, "EXCEPTION_AGENT", "stall_response"), "loop", nil)
if err != nil {
t.Fatal(err)
}
if tr.Turns != MaxTurns || tr.Stopreason != "max_turns" || len(m.reqs) != MaxTurns {
t.Fatalf("turns = %d, stop = %s, calls = %d", tr.Turns, tr.Stopreason, len(m.reqs))
}
}
func TestRunModelError(t *testing.T) {
tr, err := Run(context.Background(), &fakeModel{err: errors.New("overloaded")}, mustPlan(t, "EXCEPTION_AGENT", "stall_response"), "x", nil)
if err == nil || tr == nil || tr.Stopreason != "error" {
t.Fatalf("err = %v, trace = %+v", err, tr)
}
}
func TestInvalidToolInputBecomesEmptyObject(t *testing.T) {
m := &fakeModel{replies: []Reply{{StopReason: "tool_use", Blocks: []Block{toolCall("t", "reassign_booking", `{not json`)}}}}
tr, _ := Run(context.Background(), m, mustPlan(t, "EXCEPTION_AGENT", "stall_response"), "x", nil)
if string(tr.Steps[0].Input) != "{}" {
t.Fatalf("input = %s", tr.Steps[0].Input)
}
}
func TestEncodeCapsLargeResults(t *testing.T) {
b := encode(map[string]string{"blob": strings.Repeat("x", maxResultBytes*2)})
if len(b) > maxResultBytes+1024 || !strings.Contains(string(b), `"truncated":true`) {
t.Fatalf("len = %d", len(b))
}
}
// ── Redact ──────────────────────────────────────────────────────────────────
func TestRedact(t *testing.T) {
in := map[string]any{
"bookingid": 7,
"customerName": "Ravi",
"pickupaddress": "12 MG Road",
"status": "Created",
"createdat_ist": "2026-09-29 12:30",
"deliverycity": "Coimbatore",
"cancelreason": "customer asked",
"nested": []any{map[string]any{"phone": "9876543210", "hub": "call +91 98765 43210 or a@b.co"}},
"missingnote": nil,
}
out := Redact(in).(map[string]any)
for _, k := range []string{"customerName", "pickupaddress", "cancelreason"} {
if out[k] != Redacted {
t.Fatalf("%s = %v, want redacted", k, out[k])
}
}
for k, want := range map[string]any{"status": "Created", "createdat_ist": "2026-09-29 12:30", "deliverycity": "Coimbatore", "bookingid": float64(7)} {
if out[k] != want {
t.Fatalf("%s = %v, want %v (must not be redacted)", k, out[k], want)
}
}
nested := out["nested"].([]any)[0].(map[string]any)
if nested["phone"] != Redacted || strings.ContainsAny(nested["hub"].(string), "@") || strings.Contains(nested["hub"].(string), "98765") {
t.Fatalf("nested = %v", nested)
}
if out["missingnote"] != nil {
t.Fatal("a null personal field should stay null")
}
}

View File

@@ -0,0 +1,78 @@
package playground
import (
"encoding/json"
"regexp"
"strings"
)
// Redacted replaces every value Redact removes.
const Redacted = "[redacted]"
// piiKeys: a field whose name contains one of these is personal or free text
// (free text is where people type phone numbers and addresses).
var piiKeys = []string{
"name", "phone", "mobile", "email", "address", "landmark", "otp", "contact",
"note", "remark", "instruction", "description", "reason", "comment",
}
var (
emailLike = regexp.MustCompile(`[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}`)
// An Indian mobile (10 digits from 6-9, optional +91). Narrow on purpose:
// a looser digit-run pattern also masks dates like "2026-09-29 12".
phoneLike = regexp.MustCompile(`(?:\+?91[\s-]?)?\b[6-9]\d{4}[\s-]?\d{5}\b`)
)
// Redact returns a copy of v, as plain JSON values, with personal data
// removed: values under personal keys are replaced, and any remaining string
// that contains an email or a phone-like number has it masked.
//
// Executors already select only non-personal columns; this is the backstop
// that makes a later column addition safe by default.
func Redact(v any) any {
b, err := json.Marshal(v)
if err != nil {
return nil
}
var generic any
if err := json.Unmarshal(b, &generic); err != nil {
return nil
}
return redactValue(generic)
}
func redactValue(v any) any {
switch t := v.(type) {
case map[string]any:
out := make(map[string]any, len(t))
for k, val := range t {
if isPIIKey(k) && val != nil {
out[k] = Redacted
continue
}
out[k] = redactValue(val)
}
return out
case []any:
out := make([]any, len(t))
for i, val := range t {
out[i] = redactValue(val)
}
return out
case string:
s := emailLike.ReplaceAllString(t, Redacted)
return phoneLike.ReplaceAllString(s, Redacted)
default:
return v
}
}
func isPIIKey(k string) bool {
k = strings.ToLower(k)
for _, p := range piiKeys {
if strings.Contains(k, p) {
return true
}
}
return false
}

View File

@@ -0,0 +1,178 @@
package playground
import (
"context"
"encoding/json"
"errors"
"fmt"
"math"
"strings"
"time"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
// The read tools the backend serves itself. Every other read tool in the
// registry runs inside AI_engine (decide_*), calls an external service
// (sequence_stops, simulate_pricing_quote) or an engine-only internal route
// (list_express_*), and answers "unavailable" in the playground.
//
// Each executor selects named, non-personal columns only — never addresses,
// names, phones or notes — and Redact runs over the result as a backstop.
// Coordinates are rounded to 2 decimals (about 1 km).
const (
scanDefaultLimit = 20
scanMaxLimit = 50
nearbyMaxKm = 10.0
nearbyMaxCount = 20
)
// bookingRow is the only projection of a booking the playground exposes.
type bookingRow struct {
Bookingid int `json:"bookingid"`
Bookingno string `json:"bookingno"`
Tenantid *int `json:"tenantid"`
Status string `json:"status"`
Pickuppincode string `json:"pickuppincode"`
Deliverypincode string `json:"deliverypincode"`
Deliverycity string `json:"deliverycity"`
Pickuplatitude float64 `json:"pickuplat"`
Pickuplongitude float64 `json:"pickuplon"`
Deliverylatitude float64 `json:"deliverylat"`
Deliverylongitude float64 `json:"deliverylon"`
Assignedmileruserid *int `json:"assignedmileruserid"`
Routekm *float64 `json:"routekm"`
Createdat *time.Time `json:"-"`
Createdatist string `json:"createdat_ist,omitempty" gorm:"-"`
}
const bookingColumns = "bookingid, bookingno, tenantid, status, pickuppincode, deliverypincode, deliverycity, " +
"pickuplatitude, pickuplongitude, deliverylatitude, deliverylongitude, assignedmileruserid, routekm, createdat"
func round2(f float64) float64 { return math.Round(f*100) / 100 }
func (r *bookingRow) tidy() {
r.Pickuplatitude, r.Pickuplongitude = round2(r.Pickuplatitude), round2(r.Pickuplongitude)
r.Deliverylatitude, r.Deliverylongitude = round2(r.Deliverylatitude), round2(r.Deliverylongitude)
if r.Createdat != nil {
// pickupbookings.createdat is timestamp WITHOUT zone holding IST digits.
r.Createdatist = r.Createdat.Format("2006-01-02 15:04")
}
}
// Executors returns the read tools this backend can serve. A nil db or rdb
// just leaves the tools that need it out (they then answer "unavailable").
func Executors(db *gorm.DB, rdb *redis.Client) map[string]Executor {
execs := map[string]Executor{}
if db != nil {
execs["get_booking_cache"] = getBooking(db)
execs["scan_bookings"] = scanBookings(db)
}
if rdb != nil {
execs["nearby_milers"] = nearbyMilers(rdb)
}
return execs
}
func decode(input json.RawMessage, into any) error {
if len(input) == 0 {
return nil
}
if err := json.Unmarshal(input, into); err != nil {
return fmt.Errorf("invalid input: %v", err)
}
return nil
}
func getBooking(db *gorm.DB) Executor {
return func(ctx context.Context, input json.RawMessage) (any, error) {
var in struct {
BookingID int `json:"booking_id"`
}
if err := decode(input, &in); err != nil {
return nil, err
}
if in.BookingID <= 0 {
return nil, errors.New("booking_id (a positive integer) is required")
}
var row bookingRow
res := db.WithContext(ctx).Table("pickupbookings").Select(bookingColumns).
Where("bookingid = ?", in.BookingID).Limit(1).Scan(&row)
if res.Error != nil {
return nil, errors.New("booking lookup failed")
}
if res.RowsAffected == 0 {
return map[string]any{"found": false, "booking_id": in.BookingID}, nil
}
row.tidy()
return map[string]any{"found": true, "booking": row, "source": "pickupbookings table"}, nil
}
}
func scanBookings(db *gorm.DB) Executor {
return func(ctx context.Context, input json.RawMessage) (any, error) {
var in struct {
Status string `json:"status"`
Limit int `json:"limit"`
}
if err := decode(input, &in); err != nil {
return nil, err
}
if in.Limit <= 0 {
in.Limit = scanDefaultLimit
}
if in.Limit > scanMaxLimit {
in.Limit = scanMaxLimit
}
q := db.WithContext(ctx).Table("pickupbookings").Select(bookingColumns)
if s := strings.TrimSpace(in.Status); s != "" {
q = q.Where("status = ?", s)
}
var rows []bookingRow
if err := q.Order("bookingid DESC").Limit(in.Limit).Scan(&rows).Error; err != nil {
return nil, errors.New("booking scan failed")
}
byStatus := map[string]int{}
for i := range rows {
rows[i].tidy()
byStatus[rows[i].Status]++
}
return map[string]any{"count": len(rows), "bystatus": byStatus, "bookings": rows, "order": "newest first"}, nil
}
}
func nearbyMilers(rdb *redis.Client) Executor {
return func(ctx context.Context, input json.RawMessage) (any, error) {
var in struct {
Lat *float64 `json:"lat"`
Lon *float64 `json:"lon"`
RadiusKm float64 `json:"radius_km"`
}
if err := decode(input, &in); err != nil {
return nil, err
}
if in.Lat == nil || in.Lon == nil || math.Abs(*in.Lat) > 90 || math.Abs(*in.Lon) > 180 {
return nil, errors.New("lat and lon (decimal degrees) are required")
}
if in.RadiusKm <= 0 || in.RadiusKm > nearbyMaxKm {
in.RadiusKm = 5
}
locs, err := rdb.GeoSearchLocation(ctx, "milers:locations", &redis.GeoSearchLocationQuery{
GeoSearchQuery: redis.GeoSearchQuery{
Longitude: *in.Lon, Latitude: *in.Lat,
Radius: in.RadiusKm, RadiusUnit: "km", Sort: "ASC", Count: nearbyMaxCount,
},
WithDist: true,
}).Result()
if err != nil {
return nil, errors.New("live rider positions are unavailable")
}
riders := make([]map[string]any, 0, len(locs))
for _, l := range locs {
riders = append(riders, map[string]any{"miler": l.Name, "distancekm": math.Round(l.Dist*100) / 100})
}
return map[string]any{"count": len(riders), "radiuskm": in.RadiusKm, "riders": riders}, nil
}
}

View File

@@ -0,0 +1,82 @@
package playground
import (
"context"
"encoding/json"
"os"
"strings"
"testing"
"doormile/internal/testpg"
)
// The booking executors against a real Postgres. Skipped unless
// REGISTRY_TEST_DSN points at a THROWAWAY database (see
// internal/ai/registry/store_integration_test.go for how to start one).
//
// The table is a minimal stand-in for pickupbookings WITH personal columns,
// so the test proves the executors never select them.
func TestBookingExecutorsSelectNoPersonalColumns(t *testing.T) {
dsn := os.Getenv("REGISTRY_TEST_DSN")
if dsn == "" {
t.Skip("REGISTRY_TEST_DSN not set; skipping Postgres integration test")
}
db := testpg.Open(t, dsn, "aiplayground_test")
for _, q := range []string{
`DROP TABLE IF EXISTS pickupbookings`,
`CREATE TABLE pickupbookings (
bookingid serial PRIMARY KEY, bookingno text NOT NULL, tenantid int, status text,
pickuppincode text, deliverypincode text, deliverycity text,
pickuplatitude double precision, pickuplongitude double precision,
deliverylatitude double precision, deliverylongitude double precision,
assignedmileruserid int, routekm double precision, createdat timestamp,
pickupaddress text, deliveryaddress text, notes text)`,
`INSERT INTO pickupbookings (bookingno, status, pickuppincode, deliverypincode, deliverycity,
pickuplatitude, pickuplongitude, deliverylatitude, deliverylongitude, createdat, pickupaddress, deliveryaddress, notes)
VALUES ('DM-1', 'Created', '641001', '641002', 'Coimbatore', 11.01684, 76.95583, 11.0, 76.9, '2026-09-29 12:30', '12 MG Road', '4 Park St', 'call 9876543210'),
('DM-2', 'Cancelled', '641001', '641003', 'Coimbatore', 11.0, 76.9, 11.0, 76.9, '2026-09-29 12:40', 'x', 'y', 'z')`,
} {
if err := db.Exec(q).Error; err != nil {
t.Fatalf("setup: %v", err)
}
}
execs := Executors(db, nil)
if _, ok := execs["nearby_milers"]; ok {
t.Fatal("nearby_milers offered without Redis")
}
run := func(tool, input string) string {
out, err := execs[tool](context.Background(), json.RawMessage(input))
if err != nil {
t.Fatalf("%s: %v", tool, err)
}
b, _ := json.Marshal(Redact(out))
return string(b)
}
got := run("get_booking_cache", `{"booking_id":1}`)
for _, leaked := range []string{"MG Road", "Park St", "9876543210"} {
if strings.Contains(got, leaked) {
t.Fatalf("personal data %q in %s", leaked, got)
}
}
for _, want := range []string{`"found":true`, `"pickuplat":11.02`, `"createdat_ist":"2026-09-29 12:30"`, `"status":"Created"`} {
if !strings.Contains(got, want) {
t.Fatalf("missing %s in %s", want, got)
}
}
if !strings.Contains(run("get_booking_cache", `{"booking_id":99}`), `"found":false`) {
t.Fatal("missing booking should be found:false")
}
if _, err := execs["get_booking_cache"](context.Background(), json.RawMessage(`{}`)); err == nil {
t.Fatal("booking_id should be required")
}
scan := run("scan_bookings", `{"status":"Cancelled"}`)
if !strings.Contains(scan, `"count":1`) || !strings.Contains(scan, `"Cancelled":1`) {
t.Fatalf("scan = %s", scan)
}
if all := run("scan_bookings", `{"limit":500}`); !strings.Contains(all, `"count":2`) {
t.Fatalf("scan all = %s", all)
}
}

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)
}

View File

@@ -0,0 +1,228 @@
package telemetry
import (
"context"
"encoding/json"
"sort"
"time"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
// AgentRunStats is one agent's runs over the window.
type AgentRunStats struct {
Agentid string `json:"agentid"`
Runs int64 `json:"runs"`
Failed int64 `json:"failed"`
Avgdurationms float64 `json:"avgdurationms"`
Lastrunat *time.Time `json:"lastrunat"`
}
// DecisionCount is decisions of one type with one outcome over the window.
// Outcome is "pending" while none has been recorded.
type DecisionCount struct {
Decisiontype string `json:"decisiontype"`
Outcome string `json:"outcome"`
Count int64 `json:"count"`
}
// DecisionTypeStats rolls DecisionCount rows up per type.
type DecisionTypeStats struct {
Decisiontype string `json:"decisiontype"`
Total int64 `json:"total"`
Outcomes map[string]int64 `json:"outcomes"`
}
// Insights is everything the Insights tab shows.
type Insights struct {
Days int `json:"days"`
Since time.Time `json:"since"`
// Receiving is whether this backend is subscribed to AI_engine telemetry.
// False means an empty run list is "not connected", not "no activity".
Receiving bool `json:"receiving"`
Runs RunsSummary `json:"runs"`
Decisions DecisionSummary `json:"decisions"`
Live []AgentState `json:"live"`
}
type RunsSummary struct {
Total int64 `json:"total"`
Failed int64 `json:"failed"`
PerAgent []AgentRunStats `json:"peragent"`
}
type DecisionSummary struct {
Total int64 `json:"total"`
ByType []DecisionTypeStats `json:"bytype"`
}
// ClampDays keeps the window to what the table retains.
func ClampDays(days int) int {
switch {
case days < 1:
return 7
case days > RetentionDays:
return RetentionDays
default:
return days
}
}
// SummariseRuns totals per-agent rows, busiest agent first.
func SummariseRuns(rows []AgentRunStats) RunsSummary {
out := RunsSummary{PerAgent: append([]AgentRunStats{}, rows...)}
for _, r := range rows {
out.Total += r.Runs
out.Failed += r.Failed
}
sort.SliceStable(out.PerAgent, func(i, j int) bool {
if out.PerAgent[i].Runs != out.PerAgent[j].Runs {
return out.PerAgent[i].Runs > out.PerAgent[j].Runs
}
return out.PerAgent[i].Agentid < out.PerAgent[j].Agentid
})
return out
}
// SummariseDecisions rolls (type, outcome) counts up per type, largest first.
func SummariseDecisions(rows []DecisionCount) DecisionSummary {
byType := map[string]*DecisionTypeStats{}
var order []string
var total int64
for _, r := range rows {
st, ok := byType[r.Decisiontype]
if !ok {
st = &DecisionTypeStats{Decisiontype: r.Decisiontype, Outcomes: map[string]int64{}}
byType[r.Decisiontype] = st
order = append(order, r.Decisiontype)
}
st.Total += r.Count
st.Outcomes[r.Outcome] += r.Count
total += r.Count
}
out := DecisionSummary{Total: total, ByType: make([]DecisionTypeStats, 0, len(order))}
for _, k := range order {
out.ByType = append(out.ByType, *byType[k])
}
sort.SliceStable(out.ByType, func(i, j int) bool {
if out.ByType[i].Total != out.ByType[j].Total {
return out.ByType[i].Total > out.ByType[j].Total
}
return out.ByType[i].Decisiontype < out.ByType[j].Decisiontype
})
return out
}
// RunStats reads per-agent run statistics since the cutoff. A fixed single
// grouped query, whatever the volume.
func RunStats(db *gorm.DB, since time.Time) ([]AgentRunStats, error) {
var rows []AgentRunStats
err := db.Table("aiagentruns").
Select(`agentid,
COUNT(*) AS runs,
COUNT(*) FILTER (WHERE status <> 'completed') AS failed,
COALESCE(AVG(durationms), 0) AS avgdurationms,
MAX(receivedat) AS lastrunat`).
Where("receivedat >= ?", since).
Group("agentid").
Scan(&rows).Error
return rows, err
}
// DecisionCounts reads agent_decisions grouped by type and outcome. The table
// is written by the decision engine through POST /internal/agent-decisions.
func DecisionCounts(db *gorm.DB, since time.Time) ([]DecisionCount, error) {
var rows []DecisionCount
err := db.Table("agent_decisions").
Select("decision_type AS decisiontype, COALESCE(outcome, 'pending') AS outcome, COUNT(*) AS count").
Where("created_at >= ?", since).
Group("decision_type, COALESCE(outcome, 'pending')").
Scan(&rows).Error
return rows, err
}
// LiveStates reads the latest heartbeat of each agent that has one. Missing
// keys (an agent silent for over five minutes) are simply absent.
func LiveStates(rdb *redis.Client, agentIDs []string) []AgentState {
out := []AgentState{}
if rdb == nil || len(agentIDs) == 0 {
return out
}
keys := make([]string, len(agentIDs))
for i, id := range agentIDs {
keys[i] = StateKey(id)
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
vals, err := rdb.MGet(ctx, keys...).Result()
if err != nil {
return out
}
for _, v := range vals {
s, ok := v.(string)
if !ok {
continue
}
var st AgentState
if json.Unmarshal([]byte(s), &st) == nil && st.AgentID != "" {
out = append(out, st)
}
}
return out
}
// DecisionRow is one recent decision, as the Insights list shows it.
type DecisionRow struct {
ID uint64 `json:"id"`
Decisiontype string `json:"decisiontype"`
Bookingid *uint64 `json:"bookingid"`
Decision json.RawMessage `json:"decision"`
Reasoning string `json:"reasoning"`
Outcome *string `json:"outcome"`
Createdat time.Time `json:"createdat"`
}
type decisionScan struct {
ID uint64
Decisiontype string
Bookingid *uint64
Decision string
Reasoning string
Outcome *string
Createdat time.Time
}
// RecentDecisions pages agent_decisions newest first. beforeID (0 = start)
// is a keyset cursor, so a page is stable while new rows arrive. The context
// column is deliberately not returned: it can be large and holds rider data.
func RecentDecisions(db *gorm.DB, decisionType string, beforeID uint64, limit int) ([]DecisionRow, error) {
if limit <= 0 || limit > 100 {
limit = 25
}
q := db.Table("agent_decisions").
Select("id, decision_type AS decisiontype, booking_id AS bookingid, COALESCE(decision::text, 'null') AS decision, reasoning, outcome, created_at AS createdat").
Order("id DESC").Limit(limit)
if decisionType != "" {
q = q.Where("decision_type = ?", decisionType)
}
if beforeID > 0 {
q = q.Where("id < ?", beforeID)
}
var rows []decisionScan
if err := q.Scan(&rows).Error; err != nil {
return nil, err
}
out := make([]DecisionRow, 0, len(rows))
for _, r := range rows {
raw := json.RawMessage(r.Decision)
if !json.Valid(raw) {
raw = json.RawMessage("null")
}
out = append(out, DecisionRow{
ID: r.ID, Decisiontype: r.Decisiontype, Bookingid: r.Bookingid, Decision: raw,
Reasoning: truncate(r.Reasoning, 1000), Outcome: r.Outcome, Createdat: r.Createdat,
})
}
return out, nil
}

View File

@@ -0,0 +1,146 @@
// Package telemetry records what AI_engine's agents actually do.
//
// AI_engine publishes two fire-and-forget subjects on plain NATS
// (core/message_bus.py publish_telemetry):
//
// telemetry.task — after every task: agent, task id/type, status, error, duration
// telemetry.agent — every ~5 s per agent: status, current task, counters
//
// Tasks become rows in aiagentruns (Postgres); agent state goes to Redis with a
// short TTL. Both are observability only: nothing here may slow a booking or
// fail a request, and a malformed event is dropped, never guessed at.
package telemetry
import (
"encoding/json"
"errors"
"strings"
"time"
"unicode/utf8"
"doormile/models"
)
const (
maxIDLen = 64
maxErrorLen = 2000
)
// ErrInvalid is returned for an event that cannot be recorded as-is.
var ErrInvalid = errors.New("invalid telemetry event")
type taskEvent struct {
TS string `json:"ts"`
AgentID string `json:"agent_id"`
TaskID string `json:"task_id"`
TaskType string `json:"task_type"`
Status string `json:"status"`
Error *string `json:"error"`
DurationMS float64 `json:"duration_ms"`
}
// engineTimeLayouts are what Python's datetime.now().isoformat() produces:
// a naive local time, with or without microseconds.
var engineTimeLayouts = []string{"2006-01-02T15:04:05.999999", "2006-01-02T15:04:05"}
func parseEngineTime(s string) *time.Time {
for _, layout := range engineTimeLayouts {
if t, err := time.Parse(layout, s); err == nil {
return &t
}
}
return nil
}
// truncate cuts s to at most n bytes without splitting a UTF-8 rune.
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
s = s[:n]
for !utf8.ValidString(s) {
s = s[:len(s)-1]
}
return s
}
// ParseTask turns a telemetry.task payload into a run row stamped with
// receivedAt. It refuses — rather than repairs — an event with no agent id or
// no status, or an id too long to be one: a run attributed to the wrong agent
// is worse than a missing one.
func ParseTask(body []byte, receivedAt time.Time) (models.AIAgentRun, error) {
var ev taskEvent
if err := json.Unmarshal(body, &ev); err != nil {
return models.AIAgentRun{}, ErrInvalid
}
agent := strings.TrimSpace(ev.AgentID)
status := strings.ToLower(strings.TrimSpace(ev.Status))
if agent == "" || len(agent) > maxIDLen || status == "" || len(status) > 20 {
return models.AIAgentRun{}, ErrInvalid
}
run := models.AIAgentRun{
Agentid: agent,
Tasktype: truncate(strings.TrimSpace(ev.TaskType), maxIDLen),
Status: status,
Durationms: int(ev.DurationMS),
Occurredat: parseEngineTime(ev.TS),
Receivedat: receivedAt,
}
if run.Durationms < 0 {
run.Durationms = 0
}
if id := strings.TrimSpace(ev.TaskID); id != "" {
id = truncate(id, maxIDLen)
run.Taskid = &id
}
if ev.Error != nil {
run.Error = truncate(*ev.Error, maxErrorLen)
}
return run, nil
}
// AgentState is an agent's latest telemetry.agent heartbeat, as kept in Redis.
type AgentState struct {
AgentID string `json:"agentid"`
Status string `json:"status"`
CurrentTask *string `json:"currenttask"`
TasksCompleted int64 `json:"taskscompleted"`
TasksFailed int64 `json:"tasksfailed"`
LastSeenAt time.Time `json:"lastseenat"`
}
type agentEvent struct {
AgentID string `json:"agent_id"`
Status string `json:"status"`
CurrentTask *string `json:"current_task"`
TasksCompleted int64 `json:"tasks_completed"`
TasksFailed int64 `json:"tasks_failed"`
}
// ParseAgent turns a telemetry.agent payload into the state kept for it.
func ParseAgent(body []byte, seenAt time.Time) (AgentState, error) {
var ev agentEvent
if err := json.Unmarshal(body, &ev); err != nil {
return AgentState{}, ErrInvalid
}
agent := strings.TrimSpace(ev.AgentID)
if agent == "" || len(agent) > maxIDLen {
return AgentState{}, ErrInvalid
}
st := AgentState{
AgentID: agent,
Status: truncate(strings.TrimSpace(ev.Status), 20),
TasksCompleted: ev.TasksCompleted,
TasksFailed: ev.TasksFailed,
LastSeenAt: seenAt,
}
if ev.CurrentTask != nil {
t := truncate(*ev.CurrentTask, maxIDLen)
st.CurrentTask = &t
}
return st, nil
}
// StateKey is the Redis key an agent's latest state lives under.
func StateKey(agentID string) string { return "ai:agent:state:" + agentID }

View File

@@ -0,0 +1,169 @@
package telemetry
import (
"context"
"encoding/json"
"sync"
"sync/atomic"
"time"
"doormile/models"
"doormile/utils"
"github.com/nats-io/nats.go"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
const (
// QueueGroup makes each event land on exactly one backend replica, so a
// run is written once however many pods subscribe.
QueueGroup = "doormile-backend-telemetry"
bufferSize = 2000
flushEvery = 2 * time.Second
flushAt = 200
stateTTL = 5 * time.Minute
RetentionDays = 30
)
// Recorder buffers runs and writes them in batches. A NATS callback must never
// block on Postgres: a slow database would back up the subscription and, in
// nats.go, eventually mark it a slow consumer. So the callback only enqueues,
// and a full buffer drops (counted, logged) rather than waits.
type Recorder struct {
db *gorm.DB
rdb *redis.Client
runs chan models.AIAgentRun
now func() time.Time
dropped atomic.Int64
written atomic.Int64
once sync.Once
}
// NewRecorder builds a recorder. rdb may be nil: agent state is then not kept.
func NewRecorder(db *gorm.DB, rdb *redis.Client) *Recorder {
// time.Now, NOT utils.DBNow. DBNow returns IST digits labelled UTC, which
// is right only for the legacy timestamp-WITHOUT-time-zone columns. This
// table is created by AutoMigrate, so its columns are timestamptz, and a
// DBNow value lands 5h30m in the future — caught by the Phase 4 end-to-end
// run, where a run received at 20:57 IST read back as 02:27 next day.
return &Recorder{db: db, rdb: rdb, runs: make(chan models.AIAgentRun, bufferSize), now: time.Now}
}
// Enqueue offers a run to the writer without blocking. It reports whether the
// run was accepted.
func (r *Recorder) Enqueue(run models.AIAgentRun) bool {
select {
case r.runs <- run:
return true
default:
if n := r.dropped.Add(1); n == 1 || n%500 == 0 {
utils.Error("ai telemetry: run buffer full, dropping", "dropped_total", n)
}
return false
}
}
// HandleTask is the telemetry.task callback.
func (r *Recorder) HandleTask(body []byte) {
run, err := ParseTask(body, r.now())
if err != nil {
return
}
r.Enqueue(run)
}
// HandleAgent is the telemetry.agent callback. Best effort: Redis down means
// the Insights page shows no live state, never that a request fails.
func (r *Recorder) HandleAgent(body []byte) {
if r.rdb == nil {
return
}
st, err := ParseAgent(body, r.now())
if err != nil {
return
}
b, _ := json.Marshal(st)
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
_ = r.rdb.Set(ctx, StateKey(st.AgentID), b, stateTTL).Err()
}
// Flush writes whatever is buffered. Duplicates of an (agent, task id) pair —
// a redelivery — are ignored by the unique index.
func (r *Recorder) Flush(batch []models.AIAgentRun) {
if len(batch) == 0 {
return
}
if err := r.db.Clauses(clause.OnConflict{DoNothing: true}).CreateInBatches(batch, 200).Error; err != nil {
utils.Error("ai telemetry: writing runs failed", "count", len(batch), "error", err.Error())
return
}
r.written.Add(int64(len(batch)))
}
func (r *Recorder) writeLoop() {
ticker := time.NewTicker(flushEvery)
defer ticker.Stop()
batch := make([]models.AIAgentRun, 0, flushAt)
for {
select {
case run := <-r.runs:
batch = append(batch, run)
if len(batch) >= flushAt {
r.Flush(batch)
batch = batch[:0]
}
case <-ticker.C:
r.Flush(batch)
batch = batch[:0]
}
}
}
// Prune deletes runs older than the retention window. Returns rows removed.
func (r *Recorder) Prune() (int64, error) {
cutoff := r.now().AddDate(0, 0, -RetentionDays)
res := r.db.Where("receivedat < ?", cutoff).Delete(&models.AIAgentRun{})
return res.RowsAffected, res.Error
}
func (r *Recorder) pruneLoop() {
for {
if n, err := r.Prune(); err != nil {
utils.Error("ai telemetry: pruning old runs failed", "error", err.Error())
} else if n > 0 {
utils.Info("ai telemetry: pruned old runs", "count", n)
}
time.Sleep(24 * time.Hour)
}
}
// Start subscribes to AI_engine's telemetry and starts the writer. A nil NATS
// connection (NATS down, or not configured) logs and returns: the API serves
// without it, and the Insights page says no telemetry is being received.
func (r *Recorder) Start(nc *nats.Conn) {
if nc == nil {
utils.Info("ai telemetry: NATS not connected, agent runs will not be recorded")
return
}
r.once.Do(func() {
go r.writeLoop()
go r.pruneLoop()
if _, err := nc.QueueSubscribe("telemetry.task", QueueGroup, func(m *nats.Msg) { r.HandleTask(m.Data) }); err != nil {
utils.Error("ai telemetry: subscribe telemetry.task failed", "error", err.Error())
}
if _, err := nc.QueueSubscribe("telemetry.agent", QueueGroup, func(m *nats.Msg) { r.HandleAgent(m.Data) }); err != nil {
utils.Error("ai telemetry: subscribe telemetry.agent failed", "error", err.Error())
}
Receiving.Store(true)
utils.Info("ai telemetry: recording agent runs", "queue_group", QueueGroup)
})
}
// Receiving reports whether this process subscribed to telemetry at boot. The
// Insights endpoint returns it, so an empty page can say "not connected"
// rather than "no runs".
var Receiving atomic.Bool

View File

@@ -0,0 +1,135 @@
package telemetry
import (
"encoding/json"
"os"
"strings"
"testing"
"time"
"doormile/internal/testpg"
"doormile/models"
"gorm.io/gorm"
)
// Real SQL against a THROWAWAY Postgres (REGISTRY_TEST_DSN); skipped otherwise.
// Drops and recreates aiagentruns and agent_decisions in its own schema.
func pgDB(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, "aitelemetry_test")
all := []any{&models.AIAgentRun{}, &models.AgentDecision{}}
if err := db.Migrator().DropTable(all...); err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(all...); err != nil {
t.Fatal(err)
}
return db
}
func strp(s string) *string { return &s }
func TestPGFlushStoresRunsOnceAndAggregates(t *testing.T) {
db := pgDB(t)
r := &Recorder{db: db, now: func() time.Time { return now }}
batch := []models.AIAgentRun{
{Agentid: "EXCEPTION_AGENT", Taskid: strp("t1"), Status: "completed", Durationms: 100, Receivedat: now},
{Agentid: "EXCEPTION_AGENT", Taskid: strp("t2"), Status: "failed", Error: "boom", Durationms: 300, Receivedat: now},
{Agentid: "DISPATCH_AGENT", Taskid: nil, Status: "completed", Durationms: 50, Receivedat: now},
{Agentid: "DISPATCH_AGENT", Taskid: nil, Status: "completed", Durationms: 70, Receivedat: now},
}
r.Flush(batch)
// A redelivered event (same agent + task id) is ignored, not double-counted.
r.Flush([]models.AIAgentRun{{Agentid: "EXCEPTION_AGENT", Taskid: strp("t1"), Status: "completed", Durationms: 999, Receivedat: now}})
var n int64
db.Model(&models.AIAgentRun{}).Count(&n)
if n != 4 {
t.Fatalf("stored %d runs, want 4 (redelivery ignored, null task ids both kept)", n)
}
stats, err := RunStats(db, now.Add(-time.Hour))
if err != nil {
t.Fatal(err)
}
s := SummariseRuns(stats)
if s.Total != 4 || s.Failed != 1 || len(s.PerAgent) != 2 {
t.Fatalf("summary %+v", s)
}
for _, a := range s.PerAgent {
if a.Agentid == "EXCEPTION_AGENT" && (a.Runs != 2 || a.Failed != 1 || a.Avgdurationms != 200 || a.Lastrunat == nil) {
t.Errorf("EXCEPTION_AGENT stats %+v", a)
}
}
// Outside the window, nothing.
if stats, _ := RunStats(db, now.Add(time.Hour)); len(stats) != 0 {
t.Errorf("runs before the window were counted: %+v", stats)
}
}
func TestPGPruneRemovesOnlyExpiredRuns(t *testing.T) {
db := pgDB(t)
r := &Recorder{db: db, now: func() time.Time { return now }}
r.Flush([]models.AIAgentRun{
{Agentid: "A", Status: "completed", Receivedat: now.AddDate(0, 0, -(RetentionDays + 1))},
{Agentid: "A", Status: "completed", Receivedat: now.AddDate(0, 0, -1)},
})
removed, err := r.Prune()
if err != nil || removed != 1 {
t.Fatalf("pruned %d, %v; want 1", removed, err)
}
}
func TestPGDecisionsCountAndPage(t *testing.T) {
db := pgDB(t)
ok, pend := "success", (*string)(nil)
b1 := uint64(501)
for i, d := range []models.AgentDecision{
{DecisionType: "miler_assignment", BookingID: &b1, Context: `{"rider":"secret"}`, Decision: `{"miler_id":8}`, Reasoning: "nearest", Outcome: &ok},
{DecisionType: "miler_assignment", Context: `{}`, Decision: `{"miler_id":9}`, Reasoning: "load", Outcome: pend},
{DecisionType: "stall_response", Context: `{}`, Decision: `{"action":"alert"}`, Reasoning: "stalled 12m", Outcome: pend},
} {
d.CreatedAt = now.Add(time.Duration(i) * time.Minute)
if err := db.Create(&d).Error; err != nil {
t.Fatal(err)
}
}
counts, err := DecisionCounts(db, now.Add(-time.Hour))
if err != nil {
t.Fatal(err)
}
s := SummariseDecisions(counts)
if s.Total != 3 || s.ByType[0].Decisiontype != "miler_assignment" || s.ByType[0].Outcomes["success"] != 1 || s.ByType[0].Outcomes["pending"] != 1 {
t.Fatalf("decision summary %+v", s)
}
page, err := RecentDecisions(db, "", 0, 2)
if err != nil || len(page) != 2 || page[0].Decisiontype != "stall_response" {
t.Fatalf("first page %+v, %v", page, err)
}
var dec map[string]any
if json.Unmarshal(page[0].Decision, &dec) != nil || dec["action"] != "alert" {
t.Errorf("decision jsonb not returned as JSON: %s", page[0].Decision)
}
next, err := RecentDecisions(db, "", page[1].ID, 2)
if err != nil || len(next) != 1 || next[0].Bookingid == nil || *next[0].Bookingid != 501 {
t.Fatalf("second page %+v, %v", next, err)
}
only, _ := RecentDecisions(db, "stall_response", 0, 10)
if len(only) != 1 {
t.Errorf("type filter returned %d rows", len(only))
}
b, _ := json.Marshal(next)
if strings.Contains(string(b), "secret") {
t.Error("the context column (rider data) leaked into the decisions list")
}
}

View File

@@ -0,0 +1,177 @@
package telemetry
import (
"strings"
"testing"
"time"
"unicode/utf8"
"doormile/models"
)
var now = time.Date(2026, 9, 29, 18, 0, 0, 0, time.UTC)
// The exact shape core/agent.py publishes after a task.
const engineTask = `{"kind":"task","ts":"2026-09-29T17:59:58.123456","agent_id":"EXCEPTION_AGENT",
"task_id":"3f2c9a","task_type":"handle_stall","status":"completed","error":null,"duration_ms":412}`
func TestParseTaskReadsTheEngineShape(t *testing.T) {
run, err := ParseTask([]byte(engineTask), now)
if err != nil {
t.Fatal(err)
}
if run.Agentid != "EXCEPTION_AGENT" || run.Tasktype != "handle_stall" || run.Status != "completed" || run.Durationms != 412 {
t.Errorf("parsed %+v", run)
}
if run.Taskid == nil || *run.Taskid != "3f2c9a" {
t.Errorf("task id = %v", run.Taskid)
}
if !run.Receivedat.Equal(now) {
t.Errorf("receivedat = %v, want the backend's clock", run.Receivedat)
}
if run.Occurredat == nil || run.Occurredat.Format("15:04:05") != "17:59:58" {
t.Errorf("occurredat = %v", run.Occurredat)
}
}
func TestParseTaskKeepsAFailureAndItsError(t *testing.T) {
run, err := ParseTask([]byte(`{"agent_id":"DISPATCH_AGENT","task_id":"x","status":"FAILED","error":"boom","duration_ms":5}`), now)
if err != nil || run.Status != "failed" || run.Error != "boom" {
t.Fatalf("got %+v, %v", run, err)
}
}
// A run attributed to the wrong agent is worse than a missing one.
func TestParseTaskRefusesWhatItCannotAttribute(t *testing.T) {
cases := map[string]string{
"not json": `{nope`,
"no agent": `{"status":"completed"}`,
"blank agent": `{"agent_id":" ","status":"completed"}`,
"no status": `{"agent_id":"A"}`,
"agent id too long": `{"agent_id":"` + strings.Repeat("A", 65) + `","status":"completed"}`,
}
for name, body := range cases {
if _, err := ParseTask([]byte(body), now); err != ErrInvalid {
t.Errorf("%s: want ErrInvalid, got %v", name, err)
}
}
}
func TestParseTaskCleansUpEdgeValues(t *testing.T) {
long := strings.Repeat("é", 1500) // 3000 bytes of two-byte runes
run, err := ParseTask([]byte(`{"agent_id":"A","status":"completed","duration_ms":-40,"ts":"garbage","error":"`+long+`"}`), now)
if err != nil {
t.Fatal(err)
}
if run.Durationms != 0 {
t.Errorf("negative duration kept: %d", run.Durationms)
}
if run.Taskid != nil {
t.Error("an absent task id must be stored as null, not an empty string")
}
if run.Occurredat != nil {
t.Error("an unparseable engine time must be null, not guessed")
}
if len(run.Error) > maxErrorLen || !utf8.ValidString(run.Error) {
t.Errorf("error not truncated safely: %d bytes, valid=%v", len(run.Error), utf8.ValidString(run.Error))
}
}
func TestParseAgent(t *testing.T) {
st, err := ParseAgent([]byte(`{"kind":"agent","agent_id":"JARVIS","status":"idle","current_task":null,"tasks_completed":12,"tasks_failed":1}`), now)
if err != nil {
t.Fatal(err)
}
if st.AgentID != "JARVIS" || st.Status != "idle" || st.TasksCompleted != 12 || st.TasksFailed != 1 || !st.LastSeenAt.Equal(now) {
t.Errorf("parsed %+v", st)
}
if _, err := ParseAgent([]byte(`{"status":"idle"}`), now); err != ErrInvalid {
t.Error("an agent event with no id was accepted")
}
}
// The NATS callback must never block: a full buffer drops and counts.
func TestEnqueueDropsInsteadOfBlocking(t *testing.T) {
r := &Recorder{runs: make(chan models.AIAgentRun, 1), now: func() time.Time { return now }}
if !r.Enqueue(models.AIAgentRun{Agentid: "A"}) {
t.Fatal("first run refused")
}
done := make(chan bool)
go func() { done <- r.Enqueue(models.AIAgentRun{Agentid: "B"}) }()
select {
case ok := <-done:
if ok || r.dropped.Load() != 1 {
t.Errorf("full buffer: accepted=%v dropped=%d", ok, r.dropped.Load())
}
case <-time.After(time.Second):
t.Fatal("Enqueue blocked on a full buffer")
}
}
func TestHandlersIgnoreBadInputAndMissingRedis(t *testing.T) {
r := &Recorder{runs: make(chan models.AIAgentRun, 4), now: func() time.Time { return now }}
r.HandleTask([]byte(`{bad`))
r.HandleAgent([]byte(`{"agent_id":"A","status":"idle"}`)) // rdb nil: must not panic
if len(r.runs) != 0 {
t.Error("an invalid task event was enqueued")
}
r.HandleTask([]byte(engineTask))
if len(r.runs) != 1 {
t.Error("a valid task event was not enqueued")
}
}
func TestSummariseRuns(t *testing.T) {
s := SummariseRuns([]AgentRunStats{
{Agentid: "B", Runs: 3, Failed: 1},
{Agentid: "A", Runs: 10, Failed: 0},
{Agentid: "C", Runs: 3, Failed: 2},
})
if s.Total != 16 || s.Failed != 3 {
t.Errorf("totals %d/%d", s.Total, s.Failed)
}
var order []string
for _, a := range s.PerAgent {
order = append(order, a.Agentid)
}
if strings.Join(order, ",") != "A,B,C" {
t.Errorf("order %v, want busiest first then by id", order)
}
if empty := SummariseRuns(nil); empty.PerAgent == nil || empty.Total != 0 {
t.Error("no runs must summarise to an empty list, not null")
}
}
func TestSummariseDecisions(t *testing.T) {
s := SummariseDecisions([]DecisionCount{
{Decisiontype: "miler_assignment", Outcome: "success", Count: 7},
{Decisiontype: "stall_response", Outcome: "pending", Count: 2},
{Decisiontype: "miler_assignment", Outcome: "pending", Count: 3},
})
if s.Total != 12 || len(s.ByType) != 2 {
t.Fatalf("summary %+v", s)
}
first := s.ByType[0]
if first.Decisiontype != "miler_assignment" || first.Total != 10 || first.Outcomes["success"] != 7 || first.Outcomes["pending"] != 3 {
t.Errorf("miler_assignment rolled up wrong: %+v", first)
}
}
func TestClampDays(t *testing.T) {
for in, want := range map[int]int{0: 7, -3: 7, 1: 1, 7: 7, 30: 30, 90: RetentionDays} {
if got := ClampDays(in); got != want {
t.Errorf("ClampDays(%d) = %d, want %d", in, got, want)
}
}
}
// aiagentruns is created by AutoMigrate, so its columns are timestamptz. The
// recorder must stamp a true instant; utils.DBNow (IST digits labelled UTC) is
// 5h30m off as an instant and made the Phase 4 end-to-end run read a 20:57 IST
// run back as 02:27 the next day.
func TestRecorderStampsARealInstant(t *testing.T) {
r := NewRecorder(nil, nil)
if d := r.now().Sub(time.Now()); d > time.Minute || d < -time.Minute {
t.Fatalf("recorder clock is %v off real time; it must not use utils.DBNow", d)
}
}

48
internal/testpg/testpg.go Normal file
View File

@@ -0,0 +1,48 @@
// Package testpg opens throwaway Postgres schemas for integration tests.
// Imported only from _test.go files, so it never reaches the server binary.
package testpg
import (
"regexp"
"testing"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
var schemaName = regexp.MustCompile(`^[a-z_][a-z0-9_]{0,62}$`)
// Open connects to a THROWAWAY Postgres (the DSN from REGISTRY_TEST_DSN)
// inside a schema of its own, created if missing.
//
// Each test package passes a different schema: `go test ./...` runs packages
// in parallel, and two packages dropping and recreating the same tables in the
// same schema at once fail each other at random.
func Open(t testing.TB, dsn, schema string) *gorm.DB {
t.Helper()
if !schemaName.MatchString(schema) {
t.Fatalf("bad test schema name %q", schema)
}
cfg := &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}
base, err := gorm.Open(postgres.Open(dsn), cfg)
if err != nil {
t.Fatalf("connect: %v", err)
}
if err := base.Exec("CREATE SCHEMA IF NOT EXISTS " + schema).Error; err != nil {
t.Fatalf("create schema %s: %v", schema, err)
}
if sqlDB, err := base.DB(); err == nil {
_ = sqlDB.Close()
}
db, err := gorm.Open(postgres.Open(dsn+" search_path="+schema), cfg)
if err != nil {
t.Fatalf("connect to schema %s: %v", schema, err)
}
t.Cleanup(func() {
if sqlDB, err := db.DB(); err == nil {
_ = sqlDB.Close()
}
})
return db
}