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

@@ -53,42 +53,6 @@ type Agent struct {
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"`
@@ -103,6 +67,12 @@ type AssistantAnswer struct {
// 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"`
// Set when the assistant resolved a change and is waiting on the person.
//
// One card, never a list. A card proposing several actions hides the one
// they would have refused, so the loop returns the first and stops — the
// next change is asked for separately.
Awaiting *tools.Proposal `json:"awaiting,omitempty"`
}
// AssistantStep is one tool call, for the console to render.
@@ -119,6 +89,12 @@ type AssistantStep struct {
// AssistantService answers a question.
type AssistantService interface {
Ask(ctx context.Context, agentName, question string, caller tools.Caller) (AssistantAnswer, error)
// Approve performs a change the person has agreed to.
//
// Takes no question and involves no model: the card names the action, and
// the registry re-validates it against the live database. The assistant is
// not in this call at all, which is the point of splitting it out.
Approve(ctx context.Context, agentName, card string, caller tools.Caller) (AssistantAnswer, error)
// Available reports whether typed questions work at all here.
Available() bool
}
@@ -129,15 +105,42 @@ type assistantService struct {
agents map[string]Agent
}
// NewAssistantService takes its agents already loaded and validated.
//
// No fallback to a built-in set: a deployment whose agent files failed to load
// should refuse to start, not quietly run a different assistant than the one
// its configuration describes.
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 }
func (s *assistantService) Approve(ctx context.Context, agentName, card string, caller tools.Caller) (AssistantAnswer, error) {
agent, known := s.agents[agentName]
if !known {
return AssistantAnswer{}, fmt.Errorf("no assistant called %q", agentName)
}
// No model is consulted. A deployment with no provider can still approve a
// card it issued earlier, which matters: the change is the person's
// decision, and it should not stop being possible because a provider is
// down.
result, err := s.registry.Approve(ctx, tools.Agent{Name: agent.Name, Tools: agent.Tools}, card, caller)
if err != nil {
return AssistantAnswer{}, err
}
answer := AssistantAnswer{
Reply: result.Note,
Used: []AssistantStep{{Tool: "approved", Outcome: tools.OutcomeOK, Rows: result.Count, Scope: result.Scope}},
}
if result.Source != "" {
answer.Sources = []string{result.Source}
}
return answer, nil
}
// maxQuestion bounds what a person can send.
//
// Not a safety control — it is a cost one. A pasted spreadsheet as a "question"
@@ -222,6 +225,14 @@ func (s *assistantService) Ask(ctx context.Context, agentName, question string,
}
answer.Used = append(answer.Used, step)
// A resolved write ends the turn. The model is not asked to carry
// on planning around a change that has not happened, and it is not
// given a second chance to propose something else in the same
// breath.
if proposal, ok := result.Rows.(tools.Proposal); ok && err == nil {
answer.Awaiting = &proposal
}
if result.Source != "" && !seenSource[result.Source] {
seenSource[result.Source] = true
answer.Sources = append(answer.Sources, result.Source)