From fef6cdc307d0d5d8eeec420c3df07302c2eb7a76 Mon Sep 17 00:00:00 2001 From: Levi Neely Date: Fri, 21 Aug 2026 10:11:51 +0200 Subject: [PATCH] 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. --- cmd/olliesrv/internal/agent/skill_match.go | 123 +-------------------- cmd/olliesrv/internal/agent/tool_match.go | 107 ++++++++++++++++++ cmd/olliesrv/internal/agent/turn.go | 9 +- cmd/toolsrv/internal/server/proc.go | 9 +- embedding/index.go | 87 +++++++++++++++ skills/skills.go | 90 ++++++--------- 6 files changed, 237 insertions(+), 188 deletions(-) create mode 100644 cmd/olliesrv/internal/agent/tool_match.go create mode 100644 embedding/index.go diff --git a/cmd/olliesrv/internal/agent/skill_match.go b/cmd/olliesrv/internal/agent/skill_match.go index 0d23cf3..301e5aa 100644 --- a/cmd/olliesrv/internal/agent/skill_match.go +++ b/cmd/olliesrv/internal/agent/skill_match.go @@ -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("\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 \")\n") - sb.WriteString("Then call the loaded tool.\n") - sb.WriteString("\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) -} diff --git a/cmd/olliesrv/internal/agent/tool_match.go b/cmd/olliesrv/internal/agent/tool_match.go new file mode 100644 index 0000000..63ecb90 --- /dev/null +++ b/cmd/olliesrv/internal/agent/tool_match.go @@ -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("\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("\n") + return sb.String() +} diff --git a/cmd/olliesrv/internal/agent/turn.go b/cmd/olliesrv/internal/agent/turn.go index d6994a4..9b5eeca 100644 --- a/cmd/olliesrv/internal/agent/turn.go +++ b/cmd/olliesrv/internal/agent/turn.go @@ -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) } } diff --git a/cmd/toolsrv/internal/server/proc.go b/cmd/toolsrv/internal/server/proc.go index eda1ac8..041b91b 100644 --- a/cmd/toolsrv/internal/server/proc.go +++ b/cmd/toolsrv/internal/server/proc.go @@ -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. diff --git a/embedding/index.go b/embedding/index.go new file mode 100644 index 0000000..af84a89 --- /dev/null +++ b/embedding/index.go @@ -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 +} diff --git a/skills/skills.go b/skills/skills.go index 333280e..f6b849d 100644 --- a/skills/skills.go +++ b/skills/skills.go @@ -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 }