tool loading: require explicit autoLoad, clean package structure

Remove lazy tool loading (load-on-call). Tools must now be explicitly
listed in the agent's autoLoad config. Calling an unloaded tool fails
with a clear error message.

Package structure improvements:
- embedding/index.go: generic Index type for semantic matching
- skills/skills.go: uses embedding.Index internally, keeps Skill type
- agent/skill_match.go: matchSkills() for skill discovery
- agent/tool_match.go: matchTools() for tool hints (new file)

Tool hints now match only loaded tools, not all tools on disk.
This makes agent capabilities explicit and auditable.
This commit is contained in:
Levi Neely 2026-08-21 10:11:51 +02:00
parent 263ea51d29
commit fef6cdc307
6 changed files with 237 additions and 188 deletions

View File

@ -1,39 +1,22 @@
package agent
import (
"encoding/json"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"ollie/embedding"
"ollie/skills"
"ollie/util"
)
// Skill matching configuration.
const (
// skillMatchThreshold is the minimum cosine similarity to include a skill.
skillMatchThreshold = 0.35
// skillMatchLimit is the maximum number of skills to inject per turn.
skillMatchLimit = 3
// toolMatchThreshold is the minimum cosine similarity to include a tool hint.
toolMatchThreshold = 0.35
// toolMatchLimit is the maximum number of tool hints to inject per turn.
toolMatchLimit = 5
skillMatchLimit = 3
)
var (
skillIndex *skills.Index
skillInitErr error
skillOnce sync.Once
toolIndex *skills.Index
toolInitErr error
toolOnce sync.Once
)
// matchSkills finds skills relevant to the user's input and returns
@ -69,7 +52,6 @@ func matchSkills(input string) string {
sb.WriteString("\" relevance=\"")
sb.WriteString(formatScore(r.Score))
sb.WriteString("\">\n")
// Include full skill content (already includes frontmatter)
sb.WriteString(r.Skill.Content)
if !strings.HasSuffix(r.Skill.Content, "\n") {
sb.WriteString("\n")
@ -80,113 +62,10 @@ func matchSkills(input string) string {
return sb.String()
}
// matchTools finds tools relevant to the user's input and returns
// a hint block telling the model to call them.
func matchTools(input string) string {
toolOnce.Do(func() {
idx, err := newToolIndex()
if err != nil {
toolInitErr = err
return
}
toolIndex = idx
})
if toolIndex == nil {
return ""
}
workflowText := normalizeWorkflow(input).SearchText()
workflow := normalizeWorkflow(input)
searchInput := input
if workflowText != "" {
searchInput += " Workflow: " + workflowText
}
results, err := toolIndex.Match(searchInput, toolMatchThreshold, toolMatchLimit)
if err != nil {
return ""
}
for i := range results {
results[i].Score += workflowToolAdjustment(results[i].Skill.Name, workflow)
}
sort.SliceStable(results, func(i, j int) bool {
return results[i].Score > results[j].Score
})
if len(results) == 0 {
return ""
}
var sb strings.Builder
sb.WriteString("<tool-hints>\n")
sb.WriteString("Relevant tools for this request:\n\n")
for _, r := range results {
sb.WriteString("→ `")
sb.WriteString(r.Skill.Name)
sb.WriteString("({...})` — ")
sb.WriteString(r.Skill.Description)
sb.WriteString("\n")
}
sb.WriteString("\nTo make one callable, use client_9p to load it through the agent ctl file:\n")
sb.WriteString("client_9p(op=\"rdwr\", path=\"session/$OLLIE_SESSION_ID/agent/$OLLIE_UNAME/ctl\", data=\"tool_load <name>\")\n")
sb.WriteString("Then call the loaded tool.\n")
sb.WriteString("</tool-hints>\n")
return sb.String()
}
func formatScore(s float32) string {
// Format as percentage
pct := int(s * 100)
if pct > 99 {
pct = 99
}
return string([]byte{'0' + byte(pct/10), '0' + byte(pct%10), '%'})
}
// newToolIndex creates a skill-compatible index from tool .meta files.
func newToolIndex() (*skills.Index, error) {
model, err := embedding.LoadModel(skills.DefaultModelDir())
if err != nil {
return nil, err
}
toolsDir := filepath.Join(util.CfgDir(), "tools")
entries, err := os.ReadDir(toolsDir)
if err != nil {
model.Close()
return nil, err
}
var toolSkills []skills.Skill
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".meta") {
continue
}
metaPath := filepath.Join(toolsDir, entry.Name())
data, err := os.ReadFile(metaPath)
if err != nil {
continue
}
var meta struct {
Description string `json:"description"`
Prompt string `json:"prompt"`
Keywords []string `json:"keywords"`
}
if json.Unmarshal(data, &meta) != nil || meta.Description == "" {
continue
}
toolName := strings.TrimSuffix(entry.Name(), ".meta")
searchText := meta.Description
if meta.Prompt != "" {
searchText += " Usage: " + meta.Prompt
}
if len(meta.Keywords) > 0 {
searchText += " Keywords: " + strings.Join(meta.Keywords, ", ")
}
toolSkills = append(toolSkills, skills.Skill{
Name: toolName,
Description: searchText,
})
}
return skills.NewIndexFromSkills(model, toolSkills)
}

View File

@ -0,0 +1,107 @@
package agent
import (
"sort"
"strings"
"sync"
"ollie/embedding"
"ollie/skills"
"ollie/toolsrv/protocol"
)
// Tool matching configuration.
const (
toolMatchThreshold = 0.35
toolMatchLimit = 5
)
var (
// Shared embedding model for tool matching.
toolEmbedModel *embedding.Model
toolEmbedErr error
toolEmbedOnce sync.Once
)
// getToolEmbedModel returns the shared embedding model, loading it on first call.
func getToolEmbedModel() *embedding.Model {
toolEmbedOnce.Do(func() {
toolEmbedModel, toolEmbedErr = embedding.LoadModel(skills.DefaultModelDir())
})
return toolEmbedModel
}
// matchTools finds tools relevant to the user's input from the loaded tools
// and returns a hint block telling the model to call them.
func matchTools(input string, tools []protocol.ToolInfo) string {
if len(tools) == 0 {
return ""
}
model := getToolEmbedModel()
if model == nil {
return ""
}
// Convert tools to embedding items
items := make([]embedding.Item, 0, len(tools))
for _, ti := range tools {
if ti.Description == "" {
continue
}
searchText := ti.Description
if ti.Prompt != "" {
searchText += " Usage: " + ti.Prompt
}
items = append(items, embedding.Item{
Name: ti.Name,
Description: searchText,
})
}
if len(items) == 0 {
return ""
}
// Build index and match
idx, err := embedding.NewIndex(model, items)
if err != nil {
return ""
}
workflowText := normalizeWorkflow(input).SearchText()
workflow := normalizeWorkflow(input)
searchInput := input
if workflowText != "" {
searchInput += " Workflow: " + workflowText
}
results, err := idx.Match(searchInput, toolMatchThreshold, toolMatchLimit)
if err != nil {
return ""
}
// Apply workflow adjustments
for i := range results {
results[i].Score += workflowToolAdjustment(results[i].Item.Name, workflow)
}
sort.SliceStable(results, func(i, j int) bool {
return results[i].Score > results[j].Score
})
if len(results) == 0 {
return ""
}
var sb strings.Builder
sb.WriteString("<tool-hints>\n")
sb.WriteString("Relevant tools for this request:\n\n")
for _, r := range results {
sb.WriteString("→ `")
sb.WriteString(r.Item.Name)
sb.WriteString("({...})` — ")
sb.WriteString(r.Item.Description)
sb.WriteString("\n")
}
sb.WriteString("</tool-hints>\n")
return sb.String()
}

View File

@ -131,8 +131,13 @@ func (ag *Agent) executeTurn(ctx context.Context, input string) string {
if skillContent := matchSkills(input); skillContent != "" {
contextParts = append(contextParts, skillContent)
}
if ag.runtime.ToolServer != nil {
if toolHints := matchTools(input); toolHints != "" {
if len(ag.runtime.ToolMeta) > 0 {
// Convert map to slice for matching
tools := make([]protocol.ToolInfo, 0, len(ag.runtime.ToolMeta))
for _, ti := range ag.runtime.ToolMeta {
tools = append(tools, ti)
}
if toolHints := matchTools(input, tools); toolHints != "" {
contextParts = append(contextParts, toolHints)
}
}

View File

@ -290,14 +290,7 @@ func (st *State) NewProc(ctx context.Context, payload string, background bool) (
info, ok := reg.Lookup(aid, toolName)
if !ok {
// Auto-load if tool exists
if err := reg.Load(aid, toolName); err != nil {
return "", 0, err // "tool not found: X"
}
info, _ = reg.Lookup(aid, toolName)
if st.OnToolsChanged != nil {
st.OnToolsChanged()
}
return "", 0, fmt.Errorf("tool not loaded: %s (add to autoLoad in agent config)", toolName)
}
// Reject shell calls that invoke a native tool.

87
embedding/index.go Normal file
View File

@ -0,0 +1,87 @@
package embedding
import (
"fmt"
"sort"
"sync"
)
// Item represents something that can be embedded and matched.
type Item struct {
Name string
Description string // text used for embedding
}
// Index holds precomputed embeddings for semantic matching.
type Index struct {
model *Model
items []Item
vecs []Vector
mu sync.RWMutex
}
// NewIndex creates an index from items using the given embedding model.
// The model is borrowed, not owned — caller is responsible for its lifecycle.
func NewIndex(model *Model, items []Item) (*Index, error) {
if model == nil {
return nil, fmt.Errorf("model is nil")
}
vecs := make([]Vector, len(items))
for i, item := range items {
vec, err := model.Embed(item.Description)
if err != nil {
return nil, fmt.Errorf("embed %q: %w", item.Name, err)
}
vecs[i] = vec
}
return &Index{
model: model,
items: items,
vecs: vecs,
}, nil
}
// Match returns items matching the query, sorted by relevance.
// threshold is the minimum cosine similarity (0-1) to include.
// limit is the maximum number of results (0 for no limit).
func (idx *Index) Match(query string, threshold float32, limit int) ([]MatchResult, error) {
idx.mu.RLock()
defer idx.mu.RUnlock()
if len(idx.items) == 0 {
return nil, nil
}
qvec, err := idx.model.Embed(query)
if err != nil {
return nil, fmt.Errorf("embed query: %w", err)
}
var results []MatchResult
for i, item := range idx.items {
sim := CosineSimilarity(qvec, idx.vecs[i])
if sim >= threshold {
results = append(results, MatchResult{
Item: item,
Score: sim,
})
}
}
sort.Slice(results, func(i, j int) bool {
return results[i].Score > results[j].Score
})
if limit > 0 && len(results) > limit {
results = results[:limit]
}
return results, nil
}
// MatchResult holds a matched item and its similarity score.
type MatchResult struct {
Item Item
Score float32
}

View File

@ -1,4 +1,5 @@
// Package skills provides skill discovery, embedding, and matching.
// Package skills provides skill discovery and matching.
// Skills are markdown knowledge modules with YAML frontmatter.
package skills
import (
@ -7,7 +8,6 @@ import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
@ -27,10 +27,16 @@ type Skill struct {
type Index struct {
model *embedding.Model
skills []Skill
vecs []embedding.Vector
index *embedding.Index
mu sync.RWMutex
}
// MatchResult holds a matched skill and its similarity score.
type MatchResult struct {
Skill Skill
Score float32
}
// NewIndex creates a new skill index with precomputed embeddings.
// modelDir is the path to the directory containing the ONNX model.
// skillDirs is a list of directories to scan for skills.
@ -48,25 +54,6 @@ func NewIndex(modelDir string, skillDirs []string) (*Index, error) {
return idx, nil
}
// NewIndexFromSkills creates an index from pre-loaded skills.
// Takes ownership of the model.
func NewIndexFromSkills(model *embedding.Model, skills []Skill) (*Index, error) {
idx := &Index{model: model, skills: skills}
// Compute embeddings
vecs := make([]embedding.Vector, len(skills))
for i, skill := range skills {
vec, err := model.Embed(skill.Description)
if err != nil {
return nil, fmt.Errorf("embed skill %q: %w", skill.Name, err)
}
vecs[i] = vec
}
idx.vecs = vecs
return idx, nil
}
// Close releases resources.
func (idx *Index) Close() error {
if idx.model != nil {
@ -82,41 +69,27 @@ func (idx *Index) Match(query string, threshold float32, limit int) ([]MatchResu
idx.mu.RLock()
defer idx.mu.RUnlock()
if len(idx.skills) == 0 {
if idx.index == nil {
return nil, nil
}
qvec, err := idx.model.Embed(query)
results, err := idx.index.Match(query, threshold, limit)
if err != nil {
return nil, fmt.Errorf("embed query: %w", err)
return nil, err
}
var results []MatchResult
for i, skill := range idx.skills {
sim := embedding.CosineSimilarity(qvec, idx.vecs[i])
if sim >= threshold {
results = append(results, MatchResult{
Skill: skill,
Score: sim,
})
// Convert embedding.MatchResult to MatchResult with full Skill
out := make([]MatchResult, len(results))
for i, r := range results {
// Find the skill by name
for _, skill := range idx.skills {
if skill.Name == r.Item.Name {
out[i] = MatchResult{Skill: skill, Score: r.Score}
break
}
}
}
// Sort by score descending
sort.Slice(results, func(i, j int) bool {
return results[i].Score > results[j].Score
})
if limit > 0 && len(results) > limit {
results = results[:limit]
}
return results, nil
}
// MatchResult holds a matched skill and its similarity score.
type MatchResult struct {
Skill Skill
Score float32
return out, nil
}
// All returns all indexed skills.
@ -167,18 +140,23 @@ func (idx *Index) loadSkillsLocked(dirs []string) error {
}
}
// Compute embeddings
vecs := make([]embedding.Vector, len(skills))
// Convert to embedding.Item for the generic index
items := make([]embedding.Item, len(skills))
for i, skill := range skills {
vec, err := idx.model.Embed(skill.Description)
if err != nil {
return fmt.Errorf("embed skill %q: %w", skill.Name, err)
items[i] = embedding.Item{
Name: skill.Name,
Description: skill.Description,
}
vecs[i] = vec
}
// Build embedding index
embIdx, err := embedding.NewIndex(idx.model, items)
if err != nil {
return err
}
idx.skills = skills
idx.vecs = vecs
idx.index = embIdx
return nil
}