agent
This commit is contained in:
229
services/agents.go
Normal file
229
services/agents.go
Normal file
@@ -0,0 +1,229 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user