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:
parent
263ea51d29
commit
fef6cdc307
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue