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 }