Files
backend_fiesta/services/tools/stuckorders.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

257 lines
8.9 KiB
Go

package tools
import (
"context"
"fmt"
"sort"
"strings"
"time"
"nearle/models"
)
// "Which orders are stuck?" — the first tool, and a real one.
//
// A delivery that is still `pending` some time after it was handed out is a job
// nobody has picked up. The rider may not have seen it, may have no device, may
// have put the phone down. The console cannot tell which, and does not need to:
// the wait itself is the fact worth surfacing, and every one of these is a
// customer waiting without knowing why.
//
// ── Derived, never remembered ───────────────────────────────────────────────
//
// Nothing stores "this job went unaccepted". It is computed from `assigntime`
// and `orderstatus`, both of which every delivery read already returns, so the
// answer does not depend on anybody having been watching when it happened.
//
// ── Why the two thresholds ──────────────────────────────────────────────────
//
// Ten minutes is worth a look; twenty-five needs somebody now. A single
// threshold either cries wolf at three minutes — which trains people to ignore
// it, and an ignored flag is worse than none — or stays silent until the
// customer has already called.
const (
// StuckLookMinutes is when a wait becomes worth a glance.
StuckLookMinutes = 10
// StuckNowMinutes is when it needs a person.
StuckNowMinutes = 25
// stuckMaxRows caps one answer. A model handed four hundred rows summarises
// them into a sentence nobody can check; a dispatcher can act on ten.
stuckMaxRows = 50
)
// DeliveryReader is the one thing this tool needs from the rest of the app.
//
// Narrowed to a single method so the tool can be tested without a database, and
// so it cannot quietly grow a second dependency. The real implementation is
// `services.DeliveriesService`.
type DeliveryReader interface {
GetDeliveries(input models.DeliveryQuery) []models.Deliveryinfo
}
// StuckOrder is one row of the answer.
//
// Field names are what the model will read back to a person, so they say what
// they mean: `WaitingMinutes`, not `delta`.
type StuckOrder struct {
Deliveryid int `json:"deliveryid"`
Orderid string `json:"orderid"`
Rider string `json:"rider,omitempty"`
Branch string `json:"branch,omitempty"`
Customer string `json:"customer,omitempty"`
AssignedAt string `json:"assigned_at"`
WaitingMinutes int `json:"waiting_minutes"`
// "look" or "now" — which bucket the wait falls in. Returned rather than
// left to the model to work out from the number, so the threshold is
// decided in one place and cannot be re-invented in a sentence.
Urgency string `json:"urgency"`
Action string `json:"action"`
}
// StuckOrders builds the tool.
//
// `now` is injected so the tests can ask what the board looked like at a fixed
// instant. Production passes `time.Now`.
func StuckOrders(deliveries DeliveryReader, now func() time.Time) Tool {
if now == nil {
now = time.Now
}
return Tool{
Name: "stuck_orders",
Description: "Deliveries a rider has been given but has not accepted yet, oldest first. " +
"Use for questions about jobs that are stuck, not moving, unaccepted, or riders who have not started. " +
"Returns the wait in minutes and what to do about each one.",
Scope: ScopeRead,
Schema: Schema{Fields: []Field{{
Name: "minutes_waiting",
Description: "Only count jobs unaccepted for at least this many minutes. Defaults to 10.",
Kind: KindInt,
Min: 1,
Max: 720,
Default: StuckLookMinutes,
}}},
Handler: func(_ context.Context, req Request) (Result, error) {
// The tenant comes from the verified session, never from an
// argument. There is deliberately no `tenantid` field on the schema
// above: a tool that accepted one would let the model be talked into
// reading somebody else's shop, and the model is the one part of
// this system that can be argued with.
if req.Caller.Tenantid <= 0 {
return Result{}, fmt.Errorf("stuck_orders needs a tenant; staff must pick one first")
}
threshold := req.Int("minutes_waiting")
if threshold <= 0 {
threshold = StuckLookMinutes
}
rows := deliveries.GetDeliveries(models.DeliveryQuery{
Tenantid: req.Caller.Tenantid,
// The caller's own branch when they have one. A branch user asking
// "what is stuck?" means their shop; an admin with no home branch
// means all of them.
Locationid: req.Caller.Locationid,
Pagesize: 500,
Pageno: 1,
})
at := now()
stuck := make([]StuckOrder, 0, 8)
for _, row := range rows {
waited, ok := unacceptedFor(row, at)
if !ok || waited < time.Duration(threshold)*time.Minute {
continue
}
minutes := int(waited.Minutes())
urgency, action := "look", "Check the rider has seen it."
if minutes >= StuckNowMinutes {
urgency, action = "now", "Call the rider, or give the job to somebody else."
}
stuck = append(stuck, StuckOrder{
Deliveryid: row.Deliveryid,
Orderid: row.Orderid,
// `ridername` is not reliably a name — on tenant 916 every
// rider has delivery statuses in that column too, and for two
// of five the status is the MORE common value. Excluded by
// vocabulary rather than by a hand-written list.
Rider: riderName(row.Ridername),
Branch: row.Locationname,
Customer: row.Deliverycustomer,
AssignedAt: row.Assigntime,
WaitingMinutes: minutes,
Urgency: urgency,
Action: action,
})
}
// Worst first, then longest waiting. This is a worklist, not a log:
// the row to deal with next belongs at the top.
sort.SliceStable(stuck, func(i, j int) bool {
if stuck[i].Urgency != stuck[j].Urgency {
return stuck[i].Urgency == "now"
}
return stuck[i].WaitingMinutes > stuck[j].WaitingMinutes
})
result := Result{
Count: len(stuck),
Source: "/admin/dispatch",
Scope: scopeWords(req.Caller),
}
if len(stuck) > stuckMaxRows {
result.Truncated = true
result.Note = fmt.Sprintf(
"%d jobs are waiting; the %d longest are listed. Say so — do not describe this as the full list.",
len(stuck), stuckMaxRows)
stuck = stuck[:stuckMaxRows]
}
result.Rows = stuck
return result, nil
},
}
}
// unacceptedFor is how long a job has sat with nobody accepting it.
//
// Only `pending` counts. A job the rider accepted, picked up or delivered is
// not stuck however old it is, and `rejected` or `skipped` is a different
// problem with a different answer — those are somebody's to reassign, not to
// chase.
//
// A stamp that will not parse returns false rather than 1970. Reading an
// unparseable `assigntime` as the epoch would report every such row as fifty
// years late, which is the kind of number that gets a whole screen ignored.
func unacceptedFor(row models.Deliveryinfo, now time.Time) (time.Duration, bool) {
if strings.ToLower(strings.TrimSpace(row.Orderstatus)) != "pending" {
return 0, false
}
assigned, ok := parseStamp(row.Assigntime)
if !ok {
return 0, false
}
waited := now.Sub(assigned)
if waited < 0 {
// A clock ahead of ours, not a job from the future.
return 0, false
}
return waited, true
}
// parseStamp reads `assigntime` as the writer actually writes it.
//
// Local wall-clock with no zone — `2026-09-23 14:05:31` — which is what
// `stampNow` produces. Parsed in the server's own location rather than UTC,
// because reading local digits as UTC would put every job five and a half hours
// out in India and turn a fresh assignment into a four-hour wait.
func parseStamp(raw string) (time.Time, bool) {
text := strings.TrimSpace(raw)
if text == "" {
return time.Time{}, false
}
for _, layout := range []string{
"2006-01-02 15:04:05",
"2006-01-02T15:04:05",
time.RFC3339,
} {
if at, err := time.ParseInLocation(layout, text, time.Local); err == nil {
return at, true
}
}
return time.Time{}, false
}
// riderName keeps a name and drops a status wearing one.
//
// `deliveries.ridername` carries both. Measured on tenant 916: rider 897 has
// "Varun" 69 times and "delivered" 75, so neither "first non-empty" nor "most
// common" finds the name. The statuses are excluded by vocabulary, so a status
// added to the ladder is excluded the same day.
func riderName(raw string) string {
name := strings.ToLower(strings.TrimSpace(raw))
if name == "" {
return ""
}
for _, status := range []string{
"pending", "accepted", "arrived", "picked", "active",
"skipped", "rejected", "delivered", "cancelled", "waiting",
} {
if name == status {
return ""
}
}
return strings.TrimSpace(raw)
}
// scopeWords says what the answer covers, in words a person would use.
func scopeWords(caller Caller) string {
if caller.Locationid > 0 {
return "this branch"
}
return "all branches"
}