Files
backend_fiesta/services/assistantService.go
abhishek 8e1549764b Nearle Buddy answers a typed question
Phase 2: the loop and the model gateway. The composer in the console has
said "Not connected yet" since it was built, because there was no
assistant endpoint anywhere. There is one now.

- utils/chat.go   the gateway, a sibling of embedding.go: one small
                  interface, a provider switch, the shared postJSON, no
                  framework. Agents name a TIER (fast/balanced/deep) and
                  config maps tier to model, so changing provider does not
                  touch an agent.
- services/assistantService.go  one loop for every agent. An agent is a
                  name, a tier, a prompt and an allow-list — data, not a
                  class — so a sixth is config rather than a subclass.
- the endpoint under /v1/web, inheriting middleware.WebAuth along with
  every other console route. The assistant reads the same data the console
  does and must read it as the same person.

What the model does not get to decide:

  whose data      the caller is built from the verified session in the
                  controller, never from the request body — there is no
                  tenant field to fill in. A test scripts the model calling
                  a tool with {"tenantid": 916} and asserts it ran for 1147.
  which tools     the registry enforces the agent's allow-list; a test
                  scripts a call to a tool the agent lacks and asserts the
                  handler never ran.
  when to stop    steps and tool calls are counted here. A model that keeps
                  calling tools is stopped by arithmetic, not by being
                  asked nicely.

Two quiet failures have tests of their own. A finish_reason of "length"
means the provider cut the reply off mid-sentence, which reads exactly
like a complete answer unless it is flagged. And a truncated tool result
reaches the model in words it will repeat — otherwise it describes a
capped list and an empty one identically.

A refused tool goes back as a message, not an error: a model told "that
tool needs a tenant" can explain it, where a model handed nothing says
"something went wrong".

Optional, like the embedder. Without ASSISTANT_PROVIDER the endpoint
answers "not switched on here", the composer stays disabled, and the tools
still work — they are ordinary Go functions, and only turning a sentence
into a tool call needs a model.

14 tests, against a scripted model rather than a live provider: these are
about what the loop refuses to let a model do, and that has to hold for
any model, including one behaving badly.

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

301 lines
11 KiB
Go

package services
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"nearle/services/tools"
"nearle/utils"
)
// Nearle Buddy's loop.
//
// One loop runs every agent. An agent is a name, a tier, a system prompt and an
// allow-list — data, not a class — so a sixth agent is a config entry rather
// than a subclass, and the behaviour they all share cannot drift between them.
//
// The shape is the ordinary one: ask the model, run any tools it asked for,
// give it the results, ask again, stop when it answers in words. What matters
// is what the loop refuses to let the model decide.
//
// ── What the model does not get to choose ───────────────────────────────────
//
// - Whose data it reads. The caller comes from the verified session and is
// passed to the registry directly. No tool accepts a tenant argument.
// - Which tools exist. The allow-list is the agent's, enforced by the
// registry; a model asking for something else is refused, not obeyed.
// - When to stop. Steps and tool calls are counted here. A model that keeps
// calling tools is stopped by arithmetic, not by being asked nicely.
//
// ── A refused tool is a message, not an error ───────────────────────────────
//
// When the registry refuses a call, the refusal goes back to the model as the
// tool's result. A model told "that tool needs a tenant" can explain the
// problem to the person; a model handed a 500 says "something went wrong",
// which is true and useless. The refusal is still audited either way.
// Agent is one assistant, as data.
type Agent struct {
Name string
Tier string
// System is what the model is told about its job. Rules that MUST hold do
// not live here — a prompt is a request. This is for tone, scope and the
// habits that make an answer useful.
System string
Tools []string
// MaxSteps bounds the conversation: one step is one model round trip.
MaxSteps int
// MaxToolCalls bounds the work across the whole conversation, because a
// model can ask for several tools in a single step.
MaxToolCalls int
}
// DefaultAgents are the agents phase 2 ships with.
//
// One, deliberately. The loop, the gateway, the transport and the audit trail
// are the parts that are expensive to change later, and they are easier to get
// right against a single agent with a single tool than across five. The other
// four are a config change once this one is proven — which is phase 3, where
// they move to YAML and stop being Go at all.
var DefaultAgents = map[string]Agent{
"orders": {
Name: "orders",
Tier: utils.TierBalanced,
System: strings.TrimSpace(`
You are Nearle Buddy, helping a shopkeeper run their business from the Nearle console.
Answer from tool results and nothing else. Every number you state must have come
from a tool in this conversation. If no tool can answer, say so plainly and say
what you would need — never estimate, and never fill a gap from general knowledge
about retail.
When a tool returns no rows, that is an answer: say there are none. Do not
describe an empty result as a problem with the system.
When a result says it was truncated, say so in your reply. Do not describe a
capped list as the full picture.
Say what your answer covers — one branch or all of them — using the scope the
tool reports.
Be brief. A shopkeeper reading this is mid-shift: lead with the answer, then the
detail that makes it actionable. No preamble, no restating the question.`),
Tools: []string{"stuck_orders"},
MaxSteps: 4,
MaxToolCalls: 6,
},
}
// AssistantAnswer is what one question produced.
type AssistantAnswer struct {
Reply string `json:"reply"`
// What was actually run, in order. Returned to the console so an answer can
// show its working — Buddy states a conclusion, and this is how a person
// sees which numbers it came from.
Used []AssistantStep `json:"used,omitempty"`
Model string `json:"model,omitempty"`
// Where to go and check. Collected from the tools that answered.
Sources []string `json:"sources,omitempty"`
// True when the loop stopped on its own limits rather than because the
// model finished. The reply is still returned — a partial answer beats a
// spinner — but it is flagged rather than passed off as complete.
Incomplete bool `json:"incomplete,omitempty"`
}
// AssistantStep is one tool call, for the console to render.
type AssistantStep struct {
Tool string `json:"tool"`
// Refused calls are included on purpose. An answer that quietly dropped a
// refusal would look like the assistant chose not to look.
Outcome string `json:"outcome"`
Rows int `json:"rows,omitempty"`
Detail string `json:"detail,omitempty"`
Scope string `json:"scope,omitempty"`
}
// AssistantService answers a question.
type AssistantService interface {
Ask(ctx context.Context, agentName, question string, caller tools.Caller) (AssistantAnswer, error)
// Available reports whether typed questions work at all here.
Available() bool
}
type assistantService struct {
registry *tools.Registry
chat utils.Chat
agents map[string]Agent
}
func NewAssistantService(registry *tools.Registry, chat utils.Chat, agents map[string]Agent) AssistantService {
if agents == nil {
agents = DefaultAgents
}
return &assistantService{registry: registry, chat: chat, agents: agents}
}
func (s *assistantService) Available() bool { return s.chat != nil }
// maxQuestion bounds what a person can send.
//
// Not a safety control — it is a cost one. A pasted spreadsheet as a "question"
// is a large bill and a worse answer.
const maxQuestion = 4000
func (s *assistantService) Ask(ctx context.Context, agentName, question string, caller tools.Caller) (AssistantAnswer, error) {
question = strings.TrimSpace(question)
if question == "" {
return AssistantAnswer{}, fmt.Errorf("ask a question")
}
if len(question) > maxQuestion {
return AssistantAnswer{}, fmt.Errorf("that question is too long; keep it under %d characters", maxQuestion)
}
if s.chat == nil {
return AssistantAnswer{}, utils.ErrChatNotConfigured
}
agent, known := s.agents[agentName]
if !known {
return AssistantAnswer{}, fmt.Errorf("no assistant called %q", agentName)
}
allow := tools.Agent{Name: agent.Name, Tools: agent.Tools}
messages := []utils.Message{
{Role: utils.RoleSystem, Content: agent.System},
{Role: utils.RoleUser, Content: question},
}
answer := AssistantAnswer{Model: s.chat.ModelFor(agent.Tier)}
calls := 0
seenSource := map[string]bool{}
for step := 0; step < agent.MaxSteps; step++ {
reply, err := s.chat.Complete(ctx, utils.ChatRequest{
Tier: agent.Tier,
Messages: messages,
Tools: s.registry.Definitions(allow),
})
if err != nil {
return AssistantAnswer{}, err
}
answer.Model = reply.Model
if len(reply.ToolCalls) == 0 {
answer.Reply = strings.TrimSpace(reply.Content)
// `length` means the provider cut the reply off mid-sentence. A
// truncated answer reads exactly like a complete one unless it is
// flagged here.
if reply.StopReason == "length" {
answer.Incomplete = true
}
return answer, nil
}
// The assistant turn has to go back verbatim, tool calls and all, or
// the model has no record of what it asked for and asks again.
messages = append(messages, utils.Message{
Role: utils.RoleAssistant,
Content: reply.Content,
ToolCalls: reply.ToolCalls,
})
for _, call := range reply.ToolCalls {
if calls >= agent.MaxToolCalls {
answer.Incomplete = true
messages = append(messages, utils.Message{
Role: utils.RoleTool,
ToolCallID: call.ID,
Name: call.Name,
Content: "Refused: this conversation has already run its maximum number of tool calls. Answer with what you have and say it is partial.",
})
continue
}
calls++
result, err := s.registry.Call(ctx, allow, call.Name, call.Arguments, caller)
step := AssistantStep{Tool: call.Name, Outcome: tools.OutcomeOK, Rows: result.Count, Scope: result.Scope}
if err != nil {
step.Outcome = tools.OutcomeRefused
step.Detail = err.Error()
}
answer.Used = append(answer.Used, step)
if result.Source != "" && !seenSource[result.Source] {
seenSource[result.Source] = true
answer.Sources = append(answer.Sources, result.Source)
}
messages = append(messages, utils.Message{
Role: utils.RoleTool,
ToolCallID: call.ID,
Name: call.Name,
Content: toolMessage(result, err),
})
}
}
// Out of steps with the model still working. Ask once for what it has
// rather than returning nothing: a partial answer beats a blank panel, and
// `Incomplete` is what stops it being passed off as the whole story.
answer.Incomplete = true
messages = append(messages, utils.Message{
Role: utils.RoleUser,
Content: "Answer now with what you already have, and say plainly that you ran out of steps before finishing.",
})
reply, err := s.chat.Complete(ctx, utils.ChatRequest{Tier: agent.Tier, Messages: messages})
if err != nil {
return answer, err
}
answer.Reply = strings.TrimSpace(reply.Content)
return answer, nil
}
// toolMessage is what the model is told a tool returned.
//
// A refusal is reported as text, not as a failure: a model told "that tool
// needs a tenant" can explain it to the person, where a model handed nothing
// says "something went wrong".
//
// The rows go back as JSON because that is what the model reads most reliably,
// and `note` rides alongside them rather than inside, so an instruction about
// truncation cannot be mistaken for data.
func toolMessage(result tools.Result, err error) string {
if err != nil {
return "Refused: " + err.Error()
}
payload := map[string]any{
"rows": result.Rows,
"count": result.Count,
}
if result.Scope != "" {
payload["covers"] = result.Scope
}
if result.Truncated {
payload["truncated"] = true
}
if result.Note != "" {
payload["note"] = result.Note
}
encoded, marshalErr := json.Marshal(payload)
if marshalErr != nil {
return fmt.Sprintf("Refused: the result could not be encoded: %v", marshalErr)
}
return string(encoded)
}
// assistantTimeout bounds one question end to end.
//
// Generous, because a deep question makes several round trips, and short enough
// that a wedged provider does not hold a console connection open all afternoon.
const assistantTimeout = 90 * time.Second
// WithTimeout is the bound the HTTP layer applies. Here rather than in the
// controller so every caller of Ask — HTTP today, MCP later — gets the same one.
func WithTimeout(ctx context.Context) (context.Context, context.CancelFunc) {
return context.WithTimeout(ctx, assistantTimeout)
}