updates on the ai and agent and all thse things awith onboarding
This commit is contained in:
238
internal/ai/playground/openai_compat.go
Normal file
238
internal/ai/playground/openai_compat.go
Normal 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)
|
||||
}
|
||||
130
internal/ai/playground/openai_compat_test.go
Normal file
130
internal/ai/playground/openai_compat_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
341
internal/ai/playground/playground.go
Normal file
341
internal/ai/playground/playground.go
Normal 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
|
||||
}
|
||||
280
internal/ai/playground/playground_test.go
Normal file
280
internal/ai/playground/playground_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
78
internal/ai/playground/redact.go
Normal file
78
internal/ai/playground/redact.go
Normal 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
|
||||
}
|
||||
178
internal/ai/playground/tools.go
Normal file
178
internal/ai/playground/tools.go
Normal 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
|
||||
}
|
||||
}
|
||||
82
internal/ai/playground/tools_integration_test.go
Normal file
82
internal/ai/playground/tools_integration_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
480
internal/ai/registry/registry_test.go
Normal file
480
internal/ai/registry/registry_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
134
internal/ai/registry/rules.go
Normal file
134
internal/ai/registry/rules.go
Normal 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
|
||||
}
|
||||
304
internal/ai/registry/seed.go
Normal file
304
internal/ai/registry/seed.go
Normal 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"}},
|
||||
}
|
||||
389
internal/ai/registry/store.go
Normal file
389
internal/ai/registry/store.go
Normal 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)
|
||||
}
|
||||
280
internal/ai/registry/store_integration_test.go
Normal file
280
internal/ai/registry/store_integration_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
152
internal/ai/registry/thresholds.go
Normal file
152
internal/ai/registry/thresholds.go
Normal 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)
|
||||
}
|
||||
228
internal/ai/telemetry/insights.go
Normal file
228
internal/ai/telemetry/insights.go
Normal 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
|
||||
}
|
||||
146
internal/ai/telemetry/parse.go
Normal file
146
internal/ai/telemetry/parse.go
Normal 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 }
|
||||
169
internal/ai/telemetry/recorder.go
Normal file
169
internal/ai/telemetry/recorder.go
Normal 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
|
||||
135
internal/ai/telemetry/store_integration_test.go
Normal file
135
internal/ai/telemetry/store_integration_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
177
internal/ai/telemetry/telemetry_test.go
Normal file
177
internal/ai/telemetry/telemetry_test.go
Normal 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
48
internal/testpg/testpg.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user