This commit is contained in:
2026-09-23 17:26:13 +05:30
parent 8e1549764b
commit 697b77f8c1
54 changed files with 5750 additions and 69 deletions

View File

@@ -31,6 +31,7 @@ import (
"errors"
"fmt"
"sort"
"strings"
"time"
)
@@ -213,6 +214,41 @@ func (s Schema) JSONSchema() map[string]any {
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
@@ -239,9 +275,34 @@ type Request struct {
// 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 {
if v, ok := r.Args[name].(int); ok {
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
}
@@ -309,8 +370,17 @@ type Tool struct {
// tool and explains the wrong number confidently.
Description string
Scope Scope
Schema Schema
Handler func(ctx context.Context, req Request) (Result, error)
// 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.
@@ -342,10 +412,42 @@ func (r *Registry) Register(t Tool) error {
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
@@ -372,6 +474,27 @@ func (r *Registry) Definitions(agent Agent) []map[string]any {
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
@@ -410,17 +533,38 @@ func (r *Registry) Call(ctx context.Context, agent Agent, name string, args map[
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 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)
// 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})