Files
backend_fiesta/services/tools/registry.go
abhishek bb5f40926f Add the assistant tool registry and its first tool
Phase 1 of Nearle Buddy: an agent names a tool, and the registry decides
whether that is allowed, whether the arguments make sense, who is asking,
and what gets recorded — then runs a handler a person wrote and tested.

No agent gets raw table access. The usual argument for tools over
generated SQL is safety; here there is a harder one. The fields on this
backend do not mean what their names say, and it is measured:
orders.deliverystatus is an empty string on all 181 rows of tenant 1147,
orders.orderstatus never carries the six middle delivery stages,
deliveries.ridername holds statuses as often as names, deliverytype is
empty on every row in production. A model writing SQL gets each of those
wrong 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 in the handler beside the measurement that justified it.

Call does five things in order: find the tool, check the agent's
allow-list, validate arguments, confirm the caller is scoped to
something, run the handler — writing exactly one audit row whatever
happens, refusals included. A trail of successes answers "did anything
try to read another tenant?" with silence, which reads the same as no.

The model has no say in whose data is read. stuck_orders has no tenantid
field on its schema — absent, not rejected — and the tenant comes from
the session claims added in the previous commit. Arguments the tool did
not declare are dropped rather than passed on, so a model sending a
`where` clause gets it discarded.

stuck_orders: deliveries a rider was given and has not accepted, ten
minutes for a look, twenty-five for somebody now. Derived from assigntime
and orderstatus, so it does not depend on anyone having been watching.
Carries the wait in minutes, what to do, where to check it, and what it
covered. A capped answer says so — an empty result and a truncated one
look identical to a model and it will call both "none".

The audit sink writes to the log for now; a database sink is phase 8.
Nothing calls the registry yet: the loop and the model gateway are phase 2.

37 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 11:22:57 +05:30

432 lines
13 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"
"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
}
// 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.
func (r Request) Int(name string) int {
if v, ok := r.Args[name].(int); ok {
return 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
Schema Schema
Handler func(ctx context.Context, req Request) (Result, error)
}
// 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)
}
r.tools[t.Name] = t
return nil
}
// 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
}
// 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))
}
clean, err := tool.Schema.Validate(args)
if err != nil {
return finish(Result{}, OutcomeRefused, err.Error(), err)
}
entry.Args = clean
// 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)
}
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)
}