add skills registry and skill_list/skill_load/skill_active built-in dispatchers
This commit is contained in:
parent
852ce1e41b
commit
e3a9ff4bf3
|
|
@ -0,0 +1,189 @@
|
|||
// Package skills parses skill metadata from SKILL.md front-matter and
|
||||
// provides an in-memory registry for skill discovery and session-level loading.
|
||||
package skills
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// SkillsPath returns the directory to search for skill modules.
|
||||
// Resolved from OLLIE_SKILLS_PATH, then ~/.config/ollie/skills.
|
||||
func SkillsPath() string {
|
||||
if p := os.Getenv("OLLIE_SKILLS_PATH"); p != "" {
|
||||
return p
|
||||
}
|
||||
home, _ := os.UserHomeDir()
|
||||
return home + "/.config/ollie/skills"
|
||||
}
|
||||
|
||||
// SkillInfo describes an available skill module.
|
||||
type SkillInfo struct {
|
||||
Name string
|
||||
Description string
|
||||
Dir string // directory containing SKILL.md
|
||||
}
|
||||
|
||||
// Registry discovers and loads skills from the filesystem.
|
||||
// Tracks which skills are loaded per session.
|
||||
type Registry struct {
|
||||
mu sync.RWMutex
|
||||
global map[string]SkillInfo
|
||||
sessions map[string]map[string]bool // sessionID → skill name → loaded
|
||||
}
|
||||
|
||||
func NewRegistry() *Registry {
|
||||
return &Registry{
|
||||
global: make(map[string]SkillInfo),
|
||||
sessions: make(map[string]map[string]bool),
|
||||
}
|
||||
}
|
||||
|
||||
// Discover scans the skills directory and registers all found skills.
|
||||
func (r *Registry) Discover() error {
|
||||
dir := SkillsPath()
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read skills dir %s: %w", dir, err)
|
||||
}
|
||||
global := make(map[string]SkillInfo)
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := e.Name()
|
||||
skillDir := filepath.Join(dir, name)
|
||||
skillFile := filepath.Join(skillDir, "SKILL.md")
|
||||
f, err := os.Open(skillFile)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
meta, err := ParseFrontMatter(f, name, skillDir)
|
||||
f.Close()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
global[name] = SkillInfo{
|
||||
Name: meta.Name,
|
||||
Description: meta.Description,
|
||||
Dir: skillDir,
|
||||
}
|
||||
}
|
||||
r.mu.Lock()
|
||||
r.global = global
|
||||
r.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Summaries returns all discovered skills.
|
||||
func (r *Registry) Summaries() []SkillInfo {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
out := make([]SkillInfo, 0, len(r.global))
|
||||
for _, info := range r.global {
|
||||
out = append(out, info)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Load marks a skill as loaded for the given session.
|
||||
func (r *Registry) Load(sessionID, name string) error {
|
||||
r.mu.RLock()
|
||||
_, exists := r.global[name]
|
||||
r.mu.RUnlock()
|
||||
if !exists {
|
||||
return fmt.Errorf("skill not found: %s", name)
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.sessions[sessionID] == nil {
|
||||
r.sessions[sessionID] = make(map[string]bool)
|
||||
}
|
||||
r.sessions[sessionID][name] = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// Unload removes a skill from the session's loaded set.
|
||||
func (r *Registry) Unload(sessionID, name string) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if _, ok := r.sessions[sessionID]; ok {
|
||||
delete(r.sessions[sessionID], name)
|
||||
if len(r.sessions[sessionID]) == 0 {
|
||||
delete(r.sessions, sessionID)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Loaded returns skills currently loaded for a session.
|
||||
func (r *Registry) Loaded(sessionID string) []SkillInfo {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
names, ok := r.sessions[sessionID]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out := make([]SkillInfo, 0, len(names))
|
||||
for name := range names {
|
||||
if info, exists := r.global[name]; exists {
|
||||
out = append(out, info)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// SkillContent reads and returns the full content of a skill's SKILL.md file,
|
||||
// with front-matter stripped. Returns (content, error).
|
||||
func (r *Registry) SkillContent(name string) (string, error) {
|
||||
r.mu.RLock()
|
||||
info, exists := r.global[name]
|
||||
r.mu.RUnlock()
|
||||
if !exists {
|
||||
return "", fmt.Errorf("skill not found: %s", name)
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(info.Dir, "SKILL.md"))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read skill %s: %w", name, err)
|
||||
}
|
||||
return StripFrontMatter(string(data)), nil
|
||||
}
|
||||
|
||||
// IsLoaded checks if a skill is loaded for the given session.
|
||||
func (r *Registry) IsLoaded(sessionID, name string) bool {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
names, ok := r.sessions[sessionID]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return names[name]
|
||||
}
|
||||
|
||||
// StripFrontMatter removes YAML front-matter from a markdown string,
|
||||
// returning everything after the closing ---.
|
||||
func StripFrontMatter(content string) string {
|
||||
lines := strings.Split(content, "\n")
|
||||
if len(lines) < 2 || strings.TrimSpace(lines[0]) != "---" {
|
||||
return content
|
||||
}
|
||||
inFM := false
|
||||
bodyStart := 0
|
||||
for i, line := range lines {
|
||||
if strings.TrimSpace(line) == "---" {
|
||||
if !inFM {
|
||||
inFM = true
|
||||
} else {
|
||||
bodyStart = i + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if bodyStart == 0 {
|
||||
return content
|
||||
}
|
||||
return strings.Join(lines[bodyStart:], "\n")
|
||||
}
|
||||
|
|
@ -58,6 +58,12 @@ func (e *Server) Dispatch(ctx context.Context, name string, args json.RawMessage
|
|||
return dispatchToolLoad(ctx, e, args)
|
||||
case "tool_active":
|
||||
return dispatchToolActive(ctx, e, args)
|
||||
case "skill_list":
|
||||
return dispatchSkillList(ctx, e, args)
|
||||
case "skill_load":
|
||||
return dispatchSkillLoad(ctx, e, args)
|
||||
case "skill_active":
|
||||
return dispatchSkillActive(ctx, e, args)
|
||||
default:
|
||||
return "", fmt.Errorf("unknown execute tool: %s", name)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import (
|
|||
"ollie/internal/sandbox"
|
||||
"ollie/pkg/paths"
|
||||
"ollie/pkg/tools"
|
||||
"ollie/pkg/skills"
|
||||
)
|
||||
|
||||
const (
|
||||
|
|
@ -55,9 +56,13 @@ type Server struct {
|
|||
allowTools map[string]bool
|
||||
|
||||
toolRegistry *Registry
|
||||
|
||||
skillsRegistry *skills.Registry
|
||||
sessionID string
|
||||
|
||||
// OnInjection is called when a skill is loaded and its content
|
||||
// should be injected into the agent's context.
|
||||
OnInjection func(content string)
|
||||
|
||||
// rate limiting state (per-Server)
|
||||
rateLimitMu sync.Mutex
|
||||
validationFailures int
|
||||
|
|
@ -193,6 +198,9 @@ Returns tools with descriptions, one per line.`,
|
|||
if e.toolRegistry != nil && e.sessionID != "" {
|
||||
all = append(all, e.toolRegistry.Loaded(e.sessionID)...)
|
||||
}
|
||||
// Append skill tools if skills registry is available
|
||||
skillTools := ListSkillsTools(e.skillsRegistry, e.sessionID)
|
||||
all = append(all, skillTools...)
|
||||
return all, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,140 @@
|
|||
package execute
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"ollie/pkg/skills"
|
||||
"ollie/pkg/tools"
|
||||
)
|
||||
|
||||
// SetSkillsRegistry attaches a skills registry to the execute server.
|
||||
// The server uses it to serve skill_list, skill_load, and skill_active.
|
||||
func (e *Server) SetSkillsRegistry(r *skills.Registry) {
|
||||
e.skillsRegistry = r
|
||||
}
|
||||
|
||||
// ListSkillsTools returns ToolInfo entries for the skill_* built-ins.
|
||||
// These are included alongside the standard tool_* tools.
|
||||
func ListSkillsTools(skillsReg *skills.Registry, sessionID string) []tools.ToolInfo {
|
||||
if skillsReg == nil {
|
||||
return nil
|
||||
}
|
||||
tools := []tools.ToolInfo{
|
||||
{
|
||||
Name: "skill_list",
|
||||
Description: `List available skill modules with name and description.
|
||||
|
||||
Usage: {"name": "skillname"} — if name is provided, loads that skill.
|
||||
Otherwise lists all skills with descriptions.`,
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}`),
|
||||
},
|
||||
{
|
||||
Name: "skill_load",
|
||||
Description: `Load a skill module into the current session's context.
|
||||
|
||||
Usage: {"name": "skillname"}
|
||||
|
||||
The skill content (SKILL.md body) is injected into the agent's context
|
||||
and persists across turns.`,
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type": "object",
|
||||
"required": ["name"],
|
||||
"properties": {
|
||||
"name": {"type": "string", "description": "Skill name to load."}
|
||||
}
|
||||
}`),
|
||||
},
|
||||
{
|
||||
Name: "skill_active",
|
||||
Description: `List skills currently loaded in this session.
|
||||
|
||||
Usage: (no arguments)
|
||||
|
||||
Returns loaded skill names, one per line.`,
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}`),
|
||||
},
|
||||
}
|
||||
return tools
|
||||
}
|
||||
|
||||
// dispatchSkillList lists all available skills from the global registry.
|
||||
func dispatchSkillList(ctx context.Context, e *Server, args json.RawMessage) (string, error) {
|
||||
if e.skillsRegistry == nil {
|
||||
return "", fmt.Errorf("skill_list: no skills registry available")
|
||||
}
|
||||
summaries := e.skillsRegistry.Summaries()
|
||||
if len(summaries) == 0 {
|
||||
return "(no skills found)", nil
|
||||
}
|
||||
var out strings.Builder
|
||||
for _, s := range summaries {
|
||||
out.WriteString(s.Name)
|
||||
if s.Description != "" {
|
||||
out.WriteString(" — ")
|
||||
out.WriteString(s.Description)
|
||||
}
|
||||
out.WriteString("\n")
|
||||
}
|
||||
return strings.TrimRight(out.String(), "\n"), nil
|
||||
}
|
||||
|
||||
// dispatchSkillLoad loads a skill into the current session.
|
||||
func dispatchSkillLoad(ctx context.Context, e *Server, args json.RawMessage) (string, error) {
|
||||
var a struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := json.Unmarshal(args, &a); err != nil {
|
||||
return "", fmt.Errorf("skill_load: bad args: %w", err)
|
||||
}
|
||||
if a.Name == "" {
|
||||
return "", fmt.Errorf("skill_load: name is required")
|
||||
}
|
||||
if e.skillsRegistry == nil || e.sessionID == "" {
|
||||
return "", fmt.Errorf("skill_load: no session registry")
|
||||
}
|
||||
if err := e.skillsRegistry.Load(e.sessionID, a.Name); err != nil {
|
||||
return "", fmt.Errorf("skill_load: %w", err)
|
||||
}
|
||||
content, err := e.skillsRegistry.SkillContent(a.Name)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("skill_load: %w", err)
|
||||
}
|
||||
|
||||
// Inject skill content into the agent context.
|
||||
// Skill content is sent as a system message so it persists across turns.
|
||||
injectMsg := fmt.Sprintf("[system: loaded skill %s]\n\n%s", a.Name, content)
|
||||
if e.OnInjection != nil {
|
||||
e.OnInjection(injectMsg)
|
||||
}
|
||||
return fmt.Sprintf("loaded: %s (%d bytes)", a.Name, len(content)), nil
|
||||
}
|
||||
|
||||
// dispatchSkillActive lists skills currently loaded in this session.
|
||||
func dispatchSkillActive(ctx context.Context, e *Server, args json.RawMessage) (string, error) {
|
||||
if e.skillsRegistry == nil || e.sessionID == "" {
|
||||
return "(no skills loaded)", nil
|
||||
}
|
||||
loaded := e.skillsRegistry.Loaded(e.sessionID)
|
||||
if len(loaded) == 0 {
|
||||
return "(no skills loaded)", nil
|
||||
}
|
||||
var out strings.Builder
|
||||
for _, s := range loaded {
|
||||
out.WriteString(s.Name)
|
||||
if s.Description != "" {
|
||||
out.WriteString(" — ")
|
||||
out.WriteString(s.Description)
|
||||
}
|
||||
out.WriteString("\n")
|
||||
}
|
||||
return strings.TrimRight(out.String(), "\n"), nil
|
||||
}
|
||||
Reference in New Issue