agent
This commit is contained in:
@@ -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})
|
||||
|
||||
Reference in New Issue
Block a user