Files
backend_fiesta/services/agents.go
2026-09-23 17:26:13 +05:30

230 lines
7.3 KiB
Go

package services
import (
"embed"
"fmt"
"io/fs"
"os"
"path"
"sort"
"strings"
"nearle/utils"
"gopkg.in/yaml.v3"
)
// Agents, as configuration.
//
// An agent is a document, not a class: a name, a tier, some words about its job,
// and a list of tools it may use. Adding one is a file. Changing what an agent
// can reach is an edit, not a deploy of new code — and crucially, nothing about
// the loop changes when either happens, so five agents cannot drift into five
// slightly different behaviours.
//
// ── Embedded, with a disk override ──────────────────────────────────────────
//
// The defaults are compiled in, so a binary always has working agents and a
// deployment cannot be broken by a missing directory. `ASSISTANT_AGENTS_DIR`
// replaces them wholesale — not merges, which would leave a deployment guessing
// which half of an agent it was running.
//
// ── Why the tool names are checked at startup ───────────────────────────────
//
// A typo in a tool name is invisible at runtime: the agent simply never calls
// that tool, the model explains it cannot look something up, and everything
// reports healthy. Refusing to start is the loud version of the same fact.
//go:embed agents/*.yaml
var embeddedAgents embed.FS
// basePrompt is the part every agent shares.
//
// In Go rather than repeated in each file, because it is about how this system
// works rather than about any one domain — and three copies of "say when a
// result was truncated" is three chances for one of them to lose it.
//
// Each agent's own `system` is appended, and says what that agent is for.
const basePrompt = `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.
When a tool refuses, tell the person what it said. A refusal usually names
something they can do — pick a branch, for instance.
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.`
// agentFile is one agent on disk.
type agentFile struct {
Name string `yaml:"name"`
Tier string `yaml:"tier"`
System string `yaml:"system"`
Tools []string `yaml:"tools"`
MaxSteps int `yaml:"max_steps"`
MaxToolCalls int `yaml:"max_tool_calls"`
}
// Sensible bounds for an agent that does not name its own.
const (
defaultMaxSteps = 4
defaultMaxToolCalls = 6
)
// LoadAgents reads the agent definitions.
//
// `dir` empty uses the embedded defaults. `known` reports whether a tool name
// exists — passed in rather than imported, so this does not depend on the
// registry and can be tested without one.
func LoadAgents(dir string, known func(string) bool) (map[string]Agent, error) {
files, err := readAgentFiles(dir)
if err != nil {
return nil, err
}
if len(files) == 0 {
return nil, fmt.Errorf("no agent definitions found in %q", dir)
}
agents := make(map[string]Agent, len(files))
for _, file := range files {
agent, err := file.build(known)
if err != nil {
return nil, err
}
if _, taken := agents[agent.Name]; taken {
// Two files claiming one name means one of them is being ignored,
// and which one depends on directory order.
return nil, fmt.Errorf("two agents are called %q", agent.Name)
}
agents[agent.Name] = agent
}
return agents, nil
}
func readAgentFiles(dir string) ([]agentFile, error) {
var (
entries []fs.DirEntry
read func(string) ([]byte, error)
err error
where string
)
if strings.TrimSpace(dir) == "" {
where = "agents"
entries, err = embeddedAgents.ReadDir(where)
read = func(name string) ([]byte, error) { return embeddedAgents.ReadFile(path.Join(where, name)) }
} else {
where = dir
entries, err = os.ReadDir(where)
read = func(name string) ([]byte, error) { return os.ReadFile(path.Join(where, name)) }
}
if err != nil {
return nil, fmt.Errorf("reading agent definitions from %q: %w", where, err)
}
// Sorted, so a duplicate name is reported against the same file every time
// rather than whichever the filesystem happened to hand back first.
names := make([]string, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".yaml") {
continue
}
names = append(names, entry.Name())
}
sort.Strings(names)
files := make([]agentFile, 0, len(names))
for _, name := range names {
raw, err := read(name)
if err != nil {
return nil, fmt.Errorf("reading %s: %w", name, err)
}
var file agentFile
if err := yaml.Unmarshal(raw, &file); err != nil {
return nil, fmt.Errorf("%s is not valid YAML: %w", name, err)
}
if file.Name == "" {
// Named by its contents, not its filename: a file renamed on deploy
// would otherwise silently become a different agent.
return nil, fmt.Errorf("%s does not name its agent", name)
}
files = append(files, file)
}
return files, nil
}
// build turns a file into an agent, refusing anything that would fail quietly.
func (f agentFile) build(known func(string) bool) (Agent, error) {
if len(f.Tools) == 0 {
// An agent with no tools can only answer from the prompt, which is the
// one thing this assistant is built not to do.
return Agent{}, fmt.Errorf("agent %q has no tools", f.Name)
}
for _, tool := range f.Tools {
if known != nil && !known(tool) {
return Agent{}, fmt.Errorf("agent %q lists a tool that does not exist: %q", f.Name, tool)
}
}
tier := strings.ToLower(strings.TrimSpace(f.Tier))
switch tier {
case utils.TierFast, utils.TierBalanced, utils.TierDeep:
case "":
tier = utils.TierBalanced
default:
// A tier nobody recognises would silently resolve to the balanced model
// via the gateway's fallback, so a deep agent could quietly run on the
// cheap one for months.
return Agent{}, fmt.Errorf("agent %q names an unknown tier %q", f.Name, f.Tier)
}
steps := f.MaxSteps
if steps <= 0 {
steps = defaultMaxSteps
}
calls := f.MaxToolCalls
if calls <= 0 {
calls = defaultMaxToolCalls
}
system := basePrompt
if extra := strings.TrimSpace(f.System); extra != "" {
system += "\n\n" + extra
}
return Agent{
Name: f.Name,
Tier: tier,
System: system,
Tools: f.Tools,
MaxSteps: steps,
MaxToolCalls: calls,
}, nil
}
// AgentNames lists what is loaded, for a startup log.
//
// Sorted, so two deployments running the same config log the same line and a
// diff between them means something.
func AgentNames(agents map[string]Agent) []string {
names := make([]string, 0, len(agents))
for name := range agents {
names = append(names, name)
}
sort.Strings(names)
return names
}