230 lines
7.3 KiB
Go
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
|
|
}
|