Files
backend_fiesta/services/tools/registry.go
2026-09-23 17:26:13 +05:30

576 lines
19 KiB
Go

// Package tools is the registry every assistant call goes through.
//
// An agent does not reach the database. It names a tool, and this package
// decides whether that is allowed, whether the arguments make sense, who is
// asking, and what gets recorded — then runs a handler that was written by a
// person and tested.
//
// ── Why a registry rather than generated SQL ────────────────────────────────
//
// The usual reason is safety. Here there is a harder one: the fields on this
// backend do not mean what their names say, and it is measured and documented.
// `orders.deliverystatus` is an empty string on all 181 rows of tenant 1147.
// `orders.orderstatus` only ever carries pending, delivered or cancelled, so
// the six delivery stages in between never reach it. `deliveries.ridername`
// holds delivery statuses as often as names. `orders.deliverytype` is empty on
// every row in production, so filtering on it hides the entire list.
// `billedat` is local wall-clock labelled `Z`, which puts 19 of 20 bills in the
// future.
//
// A model writing SQL gets every one of those wrong, confidently, with no
// error — it reports a cancel rate from a column of empty strings and nobody
// can tell. A model calling a tool cannot, because the correction lives inside
// the handler with the measurement that justified it written beside it.
//
// So: no agent gets raw table access, and a tool that accepts a `where` string
// is a table with extra steps.
package tools
import (
"context"
"errors"
"fmt"
"sort"
"strings"
"time"
)
// Scope separates reads from writes.
//
// Not decoration. A write tool goes through human approval before it runs
// (that is a later phase), and the registry is where the two are told apart, so
// the distinction has to be declared on the tool rather than inferred from its
// name.
type Scope string
const (
ScopeRead Scope = "read"
ScopeWrite Scope = "write"
)
// Kind is the type of one argument. Deliberately three.
//
// Enough for every tool that exists, and small enough that the validator is
// readable in one sitting. A tool wanting a nested object is a tool doing two
// things.
type Kind string
const (
KindInt Kind = "integer"
KindString Kind = "string"
KindBool Kind = "boolean"
)
// Field is one argument a tool accepts.
//
// `Max == 0` means unbounded, which is why no field here wants a negative
// range — none do, and a pointer per bound to express "unset" would cost every
// call site clarity to buy a case that has not come up.
type Field struct {
Name string
// What the model reads to decide what to put here. Written for the model,
// not for a developer: "minutes a job may sit unaccepted before it counts
// as stuck" beats "threshold".
Description string
Kind Kind
Required bool
Min, Max int
Default any
}
// Schema is a tool's argument contract.
type Schema struct{ Fields []Field }
var (
ErrUnknownTool = errors.New("no such tool")
ErrNotAllowed = errors.New("this agent may not use that tool")
ErrBadArgument = errors.New("argument is not valid")
ErrNoTenant = errors.New("caller names no tenant")
)
// Validate checks arguments and fills in defaults.
//
// Returns a NEW map rather than editing the caller's, and the returned map is
// what the handler sees. Anything not declared is dropped rather than passed
// through — a handler must never receive a key it did not ask for, or an
// argument the model invented becomes an argument the handler might one day
// start reading.
func (s Schema) Validate(args map[string]any) (map[string]any, error) {
clean := make(map[string]any, len(s.Fields))
for _, field := range s.Fields {
raw, sent := args[field.Name]
if !sent || raw == nil {
if field.Required {
return nil, fmt.Errorf("%w: %s is required", ErrBadArgument, field.Name)
}
if field.Default != nil {
clean[field.Name] = field.Default
}
continue
}
value, err := coerce(field, raw)
if err != nil {
return nil, err
}
clean[field.Name] = value
}
return clean, nil
}
// coerce turns what arrived into what the field declared.
//
// JSON numbers arrive as float64 whatever they looked like on the wire, so an
// integer field has to accept one and check it is whole. Reading it as an int
// directly would fail every call made over HTTP, which is all of them.
func coerce(field Field, raw any) (any, error) {
switch field.Kind {
case KindInt:
var n int
switch v := raw.(type) {
case int:
n = v
case int64:
n = int(v)
case float64:
if v != float64(int(v)) {
return nil, fmt.Errorf("%w: %s must be a whole number", ErrBadArgument, field.Name)
}
n = int(v)
default:
return nil, fmt.Errorf("%w: %s must be a number", ErrBadArgument, field.Name)
}
if n < field.Min {
return nil, fmt.Errorf("%w: %s must be at least %d", ErrBadArgument, field.Name, field.Min)
}
if field.Max > 0 && n > field.Max {
return nil, fmt.Errorf("%w: %s must be at most %d", ErrBadArgument, field.Name, field.Max)
}
return n, nil
case KindString:
text, ok := raw.(string)
if !ok {
return nil, fmt.Errorf("%w: %s must be text", ErrBadArgument, field.Name)
}
if field.Max > 0 && len(text) > field.Max {
return nil, fmt.Errorf("%w: %s is longer than %d characters", ErrBadArgument, field.Name, field.Max)
}
return text, nil
case KindBool:
flag, ok := raw.(bool)
if !ok {
return nil, fmt.Errorf("%w: %s must be true or false", ErrBadArgument, field.Name)
}
return flag, nil
}
return nil, fmt.Errorf("%w: %s has no type", ErrBadArgument, field.Name)
}
// JSONSchema renders the contract in the form a model and MCP both expect.
//
// Kept as a projection of `Schema` rather than the source of truth, so the
// validator and the description a model is given cannot drift: there is one
// declaration and this is a view of it.
func (s Schema) JSONSchema() map[string]any {
properties := map[string]any{}
required := []string{}
for _, field := range s.Fields {
property := map[string]any{
"type": string(field.Kind),
"description": field.Description,
}
if field.Kind == KindInt {
property["minimum"] = field.Min
if field.Max > 0 {
property["maximum"] = field.Max
}
}
if field.Default != nil {
property["default"] = field.Default
}
properties[field.Name] = property
if field.Required {
required = append(required, field.Name)
}
}
sort.Strings(required)
schema := map[string]any{
"type": "object",
"properties": properties,
// The model may not invent arguments. A tool that tolerated extras
// would make the schema a suggestion.
"additionalProperties": false,
}
if len(required) > 0 {
schema["required"] = required
}
return schema
}
// Requires is the scope a tool needs before it may run.
//
// Declared on the tool and enforced by the registry, not written out inside
// each handler. Seven tools carrying the same four lines is seven chances for
// the eighth to be written without them — and a tool that forgets does not
// fail, it reads whatever a zero tenant returns.
//
// The zero value is the strictest, deliberately. A tool that declares nothing
// is confined to one merchant, so forgetting is safe rather than silent.
type Requires int
const (
// RequiresTenant confines the tool to one merchant. The default.
RequiresTenant Requires = iota
// RequiresBranch additionally needs a branch in view — for reads that exist
// per outlet and have no all-branches form, like till presence.
RequiresBranch
// RequiresNothing is for tools that touch no shop data at all. There is one:
// the product help corpus, which describes how Nearle works and carries
// nothing about anybody.
RequiresNothing
)
// scopingArguments are names no tool may accept.
//
// The registry refuses to register a tool whose schema offers one, because an
// argument is something the MODEL fills in — and the model is the one part of
// this system that can be argued with. Whose data is read is decided by the
// session and never by the conversation.
var scopingArguments = map[string]bool{
"tenantid": true, "tenant_id": true, "tenant": true,
"locationid": true, "location_id": true, "store_id": true, "branch": true,
"partnerid": true, "customerid": true, "appuserid": true, "userid": true,
}
// Caller is the verified session a tool runs on behalf of.
//
// Built from `middleware.WebAuth`'s claims and never from anything the model
// said. That is the whole arrangement: the model chooses the tool and the
// arguments, and has no say at all in whose data it reads.
type Caller struct {
Userid int
Tenantid int
Locationid int
// Nearle staff, from `app_users.issuperadmin` — the only thing that reads
// across tenants. NOT a role: `app_roles` calls roleid 1 "Super admin" and
// tenant onboarding wrote 1 for every shop owner.
Superadmin bool
}
// Request is what a handler receives.
type Request struct {
Args map[string]any
Caller Caller
}
// Int reads a validated integer argument.
//
// No error return, on purpose: by the time a handler runs, the schema has
// already refused anything that is not an int in range, so a second check here
// would be unreachable code that still has to be read.
//
// ── Why it also accepts the JSON number types ───────────────────────────────
//
// `Validate` produces a real `int`, so in the ordinary path the first case is
// the only one that ever matches. The others exist because the cost of missing
// one is a SILENT ZERO, and a zero id is a plausible-looking argument rather
// than an obvious fault.
//
// That is not hypothetical. An approval card seals its arguments as JSON, and
// `41` comes back from that as the float64 `41`; the first version of the
// approval path handed those to a handler unvalidated, which looked up request
// 0 and answered "request 0 is not waiting for approval" — a sentence that
// reads like a stale card rather than a bug. The real fix was to validate the
// card's arguments, and that is done. This is the second line of defence, so
// the next path that forgets is merely redundant instead of quietly wrong.
func (r Request) Int(name string) int {
switch v := r.Args[name].(type) {
case int:
return v
case int64:
return int(v)
case float64:
// Whole numbers only. A fractional value here means something upstream
// skipped validation AND the caller sent a fraction, and truncating it
// silently would be inventing an answer.
if v == float64(int(v)) {
return int(v)
}
}
return 0
}
func (r Request) String(name string) string {
if v, ok := r.Args[name].(string); ok {
return v
}
return ""
}
func (r Request) Bool(name string) bool {
if v, ok := r.Args[name].(bool); ok {
return v
}
return false
}
// Result is what a tool answers with.
//
// Rows, never prose. The handler returns structured data and the model does the
// phrasing; a handler that wrote sentences would mean two layers formatting the
// same fact, and they drift.
type Result struct {
// Concrete typed slices, marshalled by the caller. `any` here so handlers
// stay typed rather than every one of them building maps.
Rows any
Count int
// True when there were more rows than were returned, with `Note` saying so
// in words. An empty answer and a capped answer look identical to a model,
// and it will describe both as "none".
Truncated bool
Note string
// The console route showing the same rows, so an answer can carry a link to
// what proves it. Buddy states a conclusion; this is where a person checks.
Source string
// What the answer covers — "all branches", or a named one. A tool that
// omits it lets an answer about one branch be read as an answer about all.
Scope string
}
// Agent is a caller's allow-list.
//
// The registry refuses a tool an agent has not named, rather than the model
// declining to call it. A prompt is a request; this is a rule.
type Agent struct {
Name string
Tools []string
}
func (a Agent) Allows(tool string) bool {
for _, name := range a.Tools {
if name == tool {
return true
}
}
return false
}
// Tool is one thing an agent can do.
type Tool struct {
Name string
// What the model reads to choose between tools. The most load-bearing
// string in the package: a vague one produces a model that calls the wrong
// tool and explains the wrong number confidently.
Description string
Scope Scope
// Needs is the scope required before the handler runs. Zero value is
// RequiresTenant, so a tool that says nothing is confined to one merchant.
Needs Requires
Schema Schema
Handler func(ctx context.Context, req Request) (Result, error)
// The two halves of a write, settable only through WriteTool. Unexported so
// there is no shape of Tool a caller can build that performs a write when
// the model asks for it.
propose ProposeFunc
execute ExecuteFunc
}
// Registry holds the tools and is the only way to reach one.
type Registry struct {
tools map[string]Tool
audit AuditSink
now func() time.Time
}
func New(audit AuditSink) *Registry {
if audit == nil {
audit = DiscardAudit{}
}
return &Registry{tools: map[string]Tool{}, audit: audit, now: time.Now}
}
// Register adds a tool. Duplicate names are refused rather than overwritten:
// silently replacing a tool is how a permission check disappears.
func (r *Registry) Register(t Tool) error {
if t.Name == "" {
return errors.New("a tool needs a name")
}
if t.Handler == nil {
return fmt.Errorf("tool %q has no handler", t.Name)
}
if t.Description == "" {
return fmt.Errorf("tool %q has no description; a model cannot choose it", t.Name)
}
if _, taken := r.tools[t.Name]; taken {
return fmt.Errorf("tool %q is already registered", t.Name)
}
if t.Scope == ScopeWrite && (t.propose == nil || t.execute == nil) {
return fmt.Errorf(
"tool %q is a write but was not built with WriteTool; a write must resolve to a card and execute separately",
t.Name)
}
for _, field := range t.Schema.Fields {
if scopingArguments[strings.ToLower(field.Name)] {
return fmt.Errorf(
"tool %q offers %q as an argument; whose data is read comes from the session, never from the model",
t.Name, field.Name)
}
}
r.tools[t.Name] = t
return nil
}
// Tool returns one registered tool.
//
// Exposed for the MCP door, which has to know a tool's SCOPE before offering
// it — a write is left out of the listing entirely rather than described and
// then refused. Read-only: the returned copy cannot change what is registered.
func (r *Registry) Tool(name string) (Tool, bool) {
tool, ok := r.tools[name]
return tool, ok
}
// Has reports whether a tool exists.
//
// Used to validate an agent definition at startup. A typo in a tool name is
// otherwise invisible: the agent never calls it, the model says it cannot look
// something up, and everything reports healthy.
func (r *Registry) Has(name string) bool {
_, ok := r.tools[name]
return ok
}
// Definitions describes the tools one agent may use, for a model or for MCP.
//
// Built from the agent's allow-list rather than from everything registered, so
// a model is never told about a tool it would then be refused — which reads to
// a model as a malfunction and to a person as the assistant being broken.
func (r *Registry) Definitions(agent Agent) []map[string]any {
names := make([]string, 0, len(agent.Tools))
for _, name := range agent.Tools {
if _, ok := r.tools[name]; ok {
names = append(names, name)
}
}
sort.Strings(names)
out := make([]map[string]any, 0, len(names))
for _, name := range names {
tool := r.tools[name]
out = append(out, map[string]any{
"name": tool.Name,
"description": tool.Description,
"input_schema": tool.Schema.JSONSchema(),
})
}
return out
}
// satisfies reports whether this caller may run this tool.
func satisfies(tool Tool, caller Caller) error {
switch tool.Needs {
case RequiresNothing:
return nil
case RequiresBranch:
if caller.Tenantid <= 0 {
return fmt.Errorf("%w: %s needs a shop; pick one first", ErrNoTenant, tool.Name)
}
if caller.Locationid <= 0 {
return fmt.Errorf("%w: %s covers one branch at a time; pick a branch first", ErrNoTenant, tool.Name)
}
return nil
default:
if caller.Tenantid <= 0 {
return fmt.Errorf("%w: %s needs a shop; staff must pick one first", ErrNoTenant, tool.Name)
}
return nil
}
}
// Call is the one entry point, and it does five things in this order:
// find the tool, check the agent may use it, validate the arguments, confirm
// the caller is scoped to something, and run the handler — recording exactly
// one audit row whatever happens, including every refusal.
//
// The order matters. Arguments are validated before the handler sees them so a
// handler never defends itself, and the caller is checked before the handler
// runs so a tool cannot forget to.
func (r *Registry) Call(ctx context.Context, agent Agent, name string, args map[string]any, caller Caller) (Result, error) {
started := r.now()
entry := AuditEntry{
At: started,
Agent: agent.Name,
Tool: name,
Userid: caller.Userid,
Tenantid: caller.Tenantid,
Args: args,
}
finish := func(result Result, outcome string, detail string, err error) (Result, error) {
entry.Outcome = outcome
entry.Detail = detail
entry.Rows = result.Count
entry.Took = r.now().Sub(started)
r.audit.Write(ctx, entry)
return result, err
}
tool, known := r.tools[name]
if !known {
return finish(Result{}, OutcomeRefused, "unknown tool", fmt.Errorf("%w: %s", ErrUnknownTool, name))
}
entry.Scope = string(tool.Scope)
if !agent.Allows(name) {
return finish(Result{}, OutcomeRefused, "not on the agent's allow-list", fmt.Errorf("%w: %s cannot use %s", ErrNotAllowed, agent.Name, name))
}
// A caller scoped to nothing must not be treated as a caller scoped to
// everything. Go's zero value is 0, so an unset tenant and a platform
// account look identical unless staff status is asked for separately.
if caller.Tenantid <= 0 && !caller.Superadmin {
return finish(Result{}, OutcomeRefused, "no tenant on the caller", ErrNoTenant)
}
// The scope the tool declared. Enforced here rather than inside the handler
// so a tool written next year cannot forget — and refused BEFORE the handler
// runs, so no query is built from a scope that was never established.
//
// Staff are not exempt. A platform account carries no tenant, and "every
// merchant at once" is not an answer to "what is stuck?" — so they are told
// to pick one, in the same words a branch user would get.
if err := satisfies(tool, caller); err != nil {
return finish(Result{}, OutcomeRefused, err.Error(), err)
}
clean, err := tool.Schema.Validate(args)
if err != nil {
return finish(Result{}, OutcomeRefused, err.Error(), err)
}
entry.Args = clean
// A write does not run here. It resolves into a card and stops — the only
// path to the write itself is Approve, with a person in between.
if tool.Scope == ScopeWrite {
result, err := r.proposeWrite(ctx, tool, Request{Args: clean, Caller: caller}, started)
if err != nil {
return finish(Result{}, OutcomeRefused, err.Error(), err)
}
return finish(result, "proposed", "awaiting approval", nil)
}
result, err := tool.Handler(ctx, Request{Args: clean, Caller: caller})
if err != nil {
return finish(Result{}, OutcomeFailed, err.Error(), err)
}
return finish(result, OutcomeOK, "", nil)
}