From 18beb15698e211babfc49354740b569adc0c79fd Mon Sep 17 00:00:00 2001 From: Levi Neely Date: Wed, 29 Jul 2026 23:17:22 +0200 Subject: [PATCH] split: toolsrv/ (server framework) + tools/ (builtin handlers) toolsrv/ owns the Server struct, execution engine, registry, discovery. tools/ owns the built-in handlers (Shell, ToolList, SkillLoad, etc.) Dependency flows one way: tools/ imports toolsrv/. Server.Dispatch uses a handler map populated via WithBuiltins(). --- agent/agent.go | 6 +- agent/build_runtime.go | 16 +-- agent/loop.go | 10 +- agent/new.go | 4 +- agent/runtime.go | 4 +- backend/openai_encode_test.go | 2 +- session/session.go | 4 +- tools/skills.go | 139 -------------------- tools/tools.go | 213 ++++++++++++++++++++++++++----- toolsrv/accessors.go | 41 ++++++ {tools => toolsrv}/bootstrap.sh | 0 {tools => toolsrv}/discover.go | 2 +- {tools => toolsrv}/registry.go | 2 +- {tools => toolsrv}/remote.go | 2 +- {tools => toolsrv}/schema.go | 2 +- {tools => toolsrv}/server.go | 94 ++------------ {tools => toolsrv}/shell.go | 35 +---- toolsrv/skills.go | 65 ++++++++++ {tools => toolsrv}/stream.go | 2 +- {tools => toolsrv}/tier.go | 2 +- toolsrv/tools.go | 51 ++++++++ {tools => toolsrv}/tools_test.go | 14 +- 22 files changed, 386 insertions(+), 324 deletions(-) delete mode 100644 tools/skills.go create mode 100644 toolsrv/accessors.go rename {tools => toolsrv}/bootstrap.sh (100%) rename {tools => toolsrv}/discover.go (99%) rename {tools => toolsrv}/registry.go (99%) rename {tools => toolsrv}/remote.go (99%) rename {tools => toolsrv}/schema.go (99%) rename {tools => toolsrv}/server.go (84%) rename {tools => toolsrv}/shell.go (94%) create mode 100644 toolsrv/skills.go rename {tools => toolsrv}/stream.go (98%) rename {tools => toolsrv}/tier.go (99%) create mode 100644 toolsrv/tools.go rename {tools => toolsrv}/tools_test.go (76%) diff --git a/agent/agent.go b/agent/agent.go index 80118e5..2274eb9 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -14,7 +14,7 @@ import ( "github.com/simonfxr/pubsub" "ollie/backend" olog "ollie/log" - "ollie/tools" + "ollie/toolsrv" ) // Agent holds the state of the current Agent entity (the "agent" @@ -29,7 +29,7 @@ type Agent struct { agentsDir string baseLayers []string // system prompt layers for /agent reloads promptEnvExtra []string // PRIME_* vars for prompt resolution - newToolServer func() tools.Runner + newToolServer func() toolsrv.Runner newBackend func(string) (backend.Backend, error) currentAction atomic.Pointer[actionHandle] warnedContext bool @@ -473,7 +473,7 @@ func (ag *Agent) React(responseID, emoji string) error { } // execServer returns the execute server, or nil if unavailable. -func (ag *Agent) execServer() tools.Runner { +func (ag *Agent) execServer() toolsrv.Runner { return ag.runtime.ExecServer } diff --git a/agent/build_runtime.go b/agent/build_runtime.go index d751307..4f1beea 100644 --- a/agent/build_runtime.go +++ b/agent/build_runtime.go @@ -8,7 +8,7 @@ import ( "strings" "ollie/backend" - "ollie/tools" + "ollie/toolsrv" ) // BuildRuntime constructs a Runtime from a pre-configured Dispatcher and @@ -17,10 +17,10 @@ import ( // env provides additional environment variables injected into prompt resolution // subprocesses (e.g. OLLIE_SESSION_ID=xxx). // The caller is responsible for registering all servers on d before calling this. -func BuildRuntime(cfg *AgentConfig, srv tools.Runner, cwd string, env []string, baseLayers ...string) *Runtime { +func BuildRuntime(cfg *AgentConfig, srv toolsrv.Runner, cwd string, env []string, baseLayers ...string) *Runtime { var messages []string - var allToolInfos []tools.ToolInfo + var allToolInfos []toolsrv.ToolInfo var allTools []backend.Tool if cfg == nil || cfg.ToolsEnabled() { @@ -29,11 +29,11 @@ func BuildRuntime(cfg *AgentConfig, srv tools.Runner, cwd string, env []string, if listErr != nil { messages = append(messages, fmt.Sprintf("list tools: %v", listErr)) } - // Only built-in executors (with InputSchema) become backend tools. + // Only built-in executors (with InputSchema) become backend toolsrv. allTools = toolInfosToBackend(allToolInfos) // Append named tool scripts for preamble listing only. - allToolInfos = append(allToolInfos, tools.DiscoverTools()...) + allToolInfos = append(allToolInfos, toolsrv.DiscoverTools()...) } hooks := Hooks{} @@ -88,12 +88,12 @@ func BuildRuntime(cfg *AgentConfig, srv tools.Runner, cwd string, env []string, } var classify toolClassifier - if pc, ok := srv.(tools.ParallelClassifier); ok { + if pc, ok := srv.(toolsrv.ParallelClassifier); ok { classify = pc.IsParallelRead } var tierFn func(string, json.RawMessage) ResultTier - if tc, ok := srv.(tools.TierClassifier); ok { + if tc, ok := srv.(toolsrv.TierClassifier); ok { tierFn = func(name string, args json.RawMessage) ResultTier { switch tc.ResultTierArgs(name, args) { case "cold": @@ -156,7 +156,7 @@ func BuildRuntime(cfg *AgentConfig, srv tools.Runner, cwd string, env []string, } } -func toolInfosToBackend(infos []tools.ToolInfo) []backend.Tool { +func toolInfosToBackend(infos []toolsrv.ToolInfo) []backend.Tool { out := make([]backend.Tool, len(infos)) for i, t := range infos { out[i] = backend.Tool{ diff --git a/agent/loop.go b/agent/loop.go index ce2242f..21d647c 100644 --- a/agent/loop.go +++ b/agent/loop.go @@ -10,7 +10,7 @@ import ( "time" "ollie/backend" - "ollie/tools" + "ollie/toolsrv" ) const maxTransientRetries = 3 @@ -310,7 +310,7 @@ func run(ctx context.Context, cfg agentConfig, state state) error { streamed := false if cfg.Exec != nil { streamBytes := 0 - streamCtx := tools.WithOutputStream(ctx, func(data string) { + streamCtx := toolsrv.WithOutputStream(ctx, func(data string) { if streamBytes >= defaultToolResultMaxBytes { return // already at ceiling, drop further chunks } @@ -580,7 +580,7 @@ func run(ctx context.Context, cfg agentConfig, state state) error { if roundsWithoutPlan >= replanGate { state.update(backend.Message{ Role: "user", - Content: "[system: you have executed " + fmt.Sprintf("%d", replanGate) + " tool rounds without replanning. Stop and write a PLAN: block showing your current checklist before calling any more tools. Update s/$OLLIE_SESSION_ID/plan.]", + Content: "[system: you have executed " + fmt.Sprintf("%d", replanGate) + " tool rounds without replanning. Stop and write a PLAN: block showing your current checklist before calling any more toolsrv. Update s/$OLLIE_SESSION_ID/plan.]", }, nil) roundsWithoutPlan = 0 } @@ -611,7 +611,7 @@ func run(ctx context.Context, cfg agentConfig, state state) error { emit(cfg, Event{Role: "maxsteps", Content: fmt.Sprintf("%d", step+1)}) state.update(backend.Message{ Role: "user", - Content: fmt.Sprintf("[system: step budget exhausted (%d/%d steps used). Stop calling tools. Summarize what you have done and what remains, then stop.]", step+1, cfg.MaxSteps), + Content: fmt.Sprintf("[system: step budget exhausted (%d/%d steps used). Stop calling toolsrv. Summarize what you have done and what remains, then stop.]", step+1, cfg.MaxSteps), }, nil) break } @@ -1042,5 +1042,5 @@ func retryCountdown(ctx context.Context, cfg agentConfig, wait time.Duration) er const defaultToolResultMaxBytes = 131072 // toolClassifier reports whether a named tool is safe to run concurrently -// with other read-class tools. nil means treat all tools as serial. +// with other read-class toolsrv. nil means treat all tools as serial. type toolClassifier func(name string) bool diff --git a/agent/new.go b/agent/new.go index fb54f10..2674d41 100644 --- a/agent/new.go +++ b/agent/new.go @@ -6,7 +6,7 @@ import ( "github.com/simonfxr/pubsub" "ollie/backend" olog "ollie/log" - "ollie/tools" + "ollie/toolsrv" ) // AgentCfg is the configuration for constructing a new Agent. @@ -19,7 +19,7 @@ type AgentCfg struct { CWD string // working directory for tool execution BaseLayers []string PromptEnvExtra []string - NewToolServer func() tools.Runner + NewToolServer func() toolsrv.Runner NewBackend func(string) (backend.Backend, error) Bus *pubsub.Bus Log *olog.Logger diff --git a/agent/runtime.go b/agent/runtime.go index 5538bf5..a2cd62a 100644 --- a/agent/runtime.go +++ b/agent/runtime.go @@ -4,7 +4,7 @@ import ( "encoding/json" "ollie/backend" - "ollie/tools" + "ollie/toolsrv" ) // Runtime holds the swappable per-agent configuration. It contains everything @@ -13,7 +13,7 @@ import ( // agents replaces it atomically. type Runtime struct { Backend backend.Backend - ExecServer tools.Runner // the execute server (tool runtime, env, cwd) + ExecServer toolsrv.Runner // the execute server (tool runtime, env, cwd) Hooks Hooks Preamble string // compiled system prompt Tools []backend.Tool diff --git a/backend/openai_encode_test.go b/backend/openai_encode_test.go index 79fc5a8..6b735d9 100644 --- a/backend/openai_encode_test.go +++ b/backend/openai_encode_test.go @@ -318,7 +318,7 @@ func TestOpenRouterClaude_LastToolCacheControl(t *testing.T) { } // TestOpenRouterNonClaude_NoCacheControl verifies that a non-Claude model on -// openrouter does NOT get cache_control injected into system messages or tools. +// openrouter does NOT get cache_control injected into system messages or toolsrv. func TestOpenRouterNonClaude_NoCacheControl(t *testing.T) { top := captureRawRequest(t, "openrouter", "mistral-7b", []Message{{Role: "system", Content: "be helpful"}, {Role: "user", Content: "hi"}}, diff --git a/session/session.go b/session/session.go index 9095939..e0146fc 100644 --- a/session/session.go +++ b/session/session.go @@ -17,7 +17,7 @@ import ( "ollie/backend" olog "ollie/log" "ollie/paths" - "ollie/tools" + "ollie/toolsrv" ) // Config is the configuration for creating a session. @@ -32,7 +32,7 @@ type Config struct { CWD string History *agent.History Runtime *agent.Runtime - NewToolServer func() tools.Runner + NewToolServer func() toolsrv.Runner NewBackend func(string) (backend.Backend, error) Log *olog.Logger MaxSteps int diff --git a/tools/skills.go b/tools/skills.go deleted file mode 100644 index 1e6d536..0000000 --- a/tools/skills.go +++ /dev/null @@ -1,139 +0,0 @@ -package tools - -import ( - "context" - "encoding/json" - "fmt" - "strings" - - "ollie/skills" -) - -// 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) []ToolInfo { - if skillsReg == nil { - return nil - } - 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 -} diff --git a/tools/tools.go b/tools/tools.go index 614ca87..835dce8 100644 --- a/tools/tools.go +++ b/tools/tools.go @@ -1,48 +1,193 @@ -// Package tools implements the tool server (sandboxed execution, tool registry, -// skill management) and supporting types. +// Package tools provides the built-in tool handlers for the toolsrv server. package tools import ( "context" "encoding/json" + "fmt" + "strings" + + "ollie/toolsrv" ) -// ToolInfo describes a tool provided by a server. -type ToolInfo struct { - Server string - Name string - Description string - InputSchema json.RawMessage - // Prompt is the usage documentation for this tool, extracted from - // the script's ollie:prompt block. Included in the system prompt. - Prompt string - // Tier is the retention tier: "hot", "warm", or "cold". Parsed from - // the script's ollie:tier annotation. Empty defaults to "hot". - Tier string - // ReadOnly is true when the tool carries an "ollie:parallel read" annotation. - ReadOnly bool +// Builtins returns the default set of built-in tool handlers. +func Builtins() map[string]toolsrv.Handler { + return map[string]toolsrv.Handler{ + "shell": Shell, + "tool_list": ToolList, + "tool_load": ToolLoad, + "tool_active": ToolActive, + "skill_list": SkillList, + "skill_load": SkillLoad, + "skill_active": SkillActive, + } } -// Runner is the minimal interface satisfied by any tool server (local or remote). -// Consumers that need polymorphism over Server and RemoteServer use this. -type Runner interface { - ListTools() ([]ToolInfo, error) - CallTool(ctx context.Context, tool string, args json.RawMessage) (json.RawMessage, error) +// Shell executes a single bash command in a sandboxed environment. +func Shell(ctx context.Context, srv *toolsrv.Server, args json.RawMessage) (string, error) { + var a struct { + Cmd string `json:"cmd"` + Timeout int `json:"timeout"` + Sandbox string `json:"sandbox"` + Elevated bool `json:"elevated"` + Detach bool `json:"detach"` + } + if err := json.Unmarshal(args, &a); err != nil { + return "", fmt.Errorf("shell: bad args: %w", err) + } + if a.Cmd == "" { + return "", fmt.Errorf("shell: cmd is required") + } + timeout := a.Timeout + if timeout <= 0 { + timeout = 30 + } + if a.Elevated { + return srv.ExecuteElevated(ctx, a.Cmd, srv.CWD(), timeout, a.Detach) + } + sandboxName := a.Sandbox + if sandboxName == "" { + sandboxName = "default" + } + return srv.ExecuteInSandbox(ctx, a.Cmd, "bash", timeout, sandboxName, false, "", a.Detach) } -// ParallelClassifier is implemented by tool servers that can report whether a -// named tool is safe to run concurrently with other read-class tools. -// Returns false for unknown tools (conservative default). -type ParallelClassifier interface { - IsParallelRead(name string) bool +// ToolList lists all available tools from the global registry. +func ToolList(ctx context.Context, srv *toolsrv.Server, args json.RawMessage) (string, error) { + reg := srv.ToolRegistry() + if reg == nil { + return "", fmt.Errorf("tool_list: no registry available") + } + summaries := reg.Summaries() + if len(summaries) == 0 { + return "(no tools 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 } -// TierClassifier is implemented by tool servers that can report the retention -// tier of a tool's results: "hot" (verbatim), "warm" (summarized on compaction), -// or "cold" (immediately summarized). Returns "hot" for unknown tools. -type TierClassifier interface { - ResultTier(name string) string - // ResultTierArgs classifies the tier using both the outer tool name and its - // arguments. - ResultTierArgs(name string, args json.RawMessage) string +// ToolLoad loads a tool into the current session. +func ToolLoad(ctx context.Context, srv *toolsrv.Server, args json.RawMessage) (string, error) { + var a struct { + Name string `json:"name"` + } + if err := json.Unmarshal(args, &a); err != nil { + return "", fmt.Errorf("tool_load: bad args: %w", err) + } + if a.Name == "" { + return "", fmt.Errorf("tool_load: name is required") + } + reg := srv.ToolRegistry() + sid := srv.SessionID() + if reg == nil || sid == "" { + return "", fmt.Errorf("tool_load: no session registry") + } + if err := reg.Load(sid, a.Name); err != nil { + return "", fmt.Errorf("tool_load: %w", err) + } + return fmt.Sprintf("loaded: %s", a.Name), nil +} + +// ToolActive lists tools currently loaded (promoted) in this session. +func ToolActive(ctx context.Context, srv *toolsrv.Server, args json.RawMessage) (string, error) { + reg := srv.ToolRegistry() + sid := srv.SessionID() + if reg == nil || sid == "" { + return "(no tools loaded)", nil + } + loaded := reg.Loaded(sid) + if len(loaded) == 0 { + return "(no tools loaded)", nil + } + var out strings.Builder + for _, t := range loaded { + out.WriteString(t.Name) + if t.Description != "" { + out.WriteString(" — ") + out.WriteString(t.Description) + } + out.WriteString("\n") + } + return strings.TrimRight(out.String(), "\n"), nil +} + +// SkillList lists all available skills from the global registry. +func SkillList(ctx context.Context, srv *toolsrv.Server, args json.RawMessage) (string, error) { + reg := srv.SkillsRegistry() + if reg == nil { + return "", fmt.Errorf("skill_list: no skills registry available") + } + summaries := reg.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 +} + +// SkillLoad loads a skill into the current session. +func SkillLoad(ctx context.Context, srv *toolsrv.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") + } + reg := srv.SkillsRegistry() + sid := srv.SessionID() + if reg == nil || sid == "" { + return "", fmt.Errorf("skill_load: no session registry") + } + if err := reg.Load(sid, a.Name); err != nil { + return "", fmt.Errorf("skill_load: %w", err) + } + content, err := reg.SkillContent(a.Name) + if err != nil { + return "", fmt.Errorf("skill_load: %w", err) + } + injectMsg := fmt.Sprintf("[system: loaded skill %s]\n\n%s", a.Name, content) + srv.InjectContent(injectMsg) + return fmt.Sprintf("loaded: %s (%d bytes)", a.Name, len(content)), nil +} + +// SkillActive lists skills currently loaded in this session. +func SkillActive(ctx context.Context, srv *toolsrv.Server, args json.RawMessage) (string, error) { + reg := srv.SkillsRegistry() + sid := srv.SessionID() + if reg == nil || sid == "" { + return "(no skills loaded)", nil + } + loaded := reg.Loaded(sid) + 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 } diff --git a/toolsrv/accessors.go b/toolsrv/accessors.go new file mode 100644 index 0000000..1debffe --- /dev/null +++ b/toolsrv/accessors.go @@ -0,0 +1,41 @@ +package toolsrv + +import ( + "context" + "ollie/skills" +) + +// --- Exported accessors for builtin tool handlers --- + +// CWD returns the current working directory for this server. +func (e *Server) CWD() string { + e.wdMu.RLock() + defer e.wdMu.RUnlock() + return e.cwd +} + +// ToolRegistry returns the attached tool registry, or nil. +func (e *Server) ToolRegistry() *Registry { return e.toolRegistry } + +// SessionID returns the session ID associated with this server. +func (e *Server) SessionID() string { return e.sessionID } + +// SkillsRegistry returns the attached skills registry, or nil. +func (e *Server) SkillsRegistry() *skills.Registry { return e.skillsRegistry } + +// InjectContent calls the OnInjection hook if set. +func (e *Server) InjectContent(content string) { + if e.OnInjection != nil { + e.OnInjection(content) + } +} + +// ExecuteInSandbox runs a command in the sandbox. Exported for builtin tools. +func (e *Server) ExecuteInSandbox(ctx context.Context, cmd, language string, timeout int, sandbox string, trusted bool, stdin string, detach bool) (string, error) { + return e.executeWithStdin(ctx, cmd, language, timeout, sandbox, trusted, stdin, detach) +} + +// ExecuteElevated runs a command outside the sandbox via the elevation broker. +func (e *Server) ExecuteElevated(ctx context.Context, cmd, dir string, timeout int, detach bool) (string, error) { + return e.executeElevated(ctx, cmd, dir, timeout, detach) +} diff --git a/tools/bootstrap.sh b/toolsrv/bootstrap.sh similarity index 100% rename from tools/bootstrap.sh rename to toolsrv/bootstrap.sh diff --git a/tools/discover.go b/toolsrv/discover.go similarity index 99% rename from tools/discover.go rename to toolsrv/discover.go index 17f09a0..63a13cc 100644 --- a/tools/discover.go +++ b/toolsrv/discover.go @@ -1,4 +1,4 @@ -package tools +package toolsrv import ( "fmt" diff --git a/tools/registry.go b/toolsrv/registry.go similarity index 99% rename from tools/registry.go rename to toolsrv/registry.go index 372c68c..6a6e103 100644 --- a/tools/registry.go +++ b/toolsrv/registry.go @@ -1,4 +1,4 @@ -package tools +package toolsrv import ( "fmt" diff --git a/tools/remote.go b/toolsrv/remote.go similarity index 99% rename from tools/remote.go rename to toolsrv/remote.go index 2534db1..cf81b7f 100644 --- a/tools/remote.go +++ b/toolsrv/remote.go @@ -2,7 +2,7 @@ // split-brain remote execution. It connects to a remote host over SSH, // ensures ollie-remote is deployed, and returns a Server that // forwards execution calls over the RPC channel. -package tools +package toolsrv import ( "bufio" diff --git a/tools/schema.go b/toolsrv/schema.go similarity index 99% rename from tools/schema.go rename to toolsrv/schema.go index 78836ad..b7fb3fa 100644 --- a/tools/schema.go +++ b/toolsrv/schema.go @@ -1,4 +1,4 @@ -package tools +package toolsrv import ( "encoding/json" diff --git a/tools/server.go b/toolsrv/server.go similarity index 84% rename from tools/server.go rename to toolsrv/server.go index 6784fdc..923dd02 100644 --- a/tools/server.go +++ b/toolsrv/server.go @@ -1,4 +1,4 @@ -package tools +package toolsrv import ( "context" @@ -47,6 +47,9 @@ type Server struct { // Empty means all are allowed. allowTools map[string]bool + // builtins maps tool names to their handler functions. + builtins map[string]Handler + toolRegistry *Registry skillsRegistry *skills.Registry sessionID string @@ -114,6 +117,11 @@ func (e *Server) AllowTools() []string { return out } +// WithBuiltins registers the built-in tool handlers. +func WithBuiltins(m map[string]Handler) Option { + return func(s *Server) { s.builtins = m } +} + // WithToolRegistry attaches a tool registry and session ID to the Server. func WithToolRegistry(r *Registry, sessionID string) Option { return func(s *Server) { @@ -340,91 +348,15 @@ func (e *Server) Close() { } // Dispatch routes tool calls to the appropriate handler. +// Dispatch routes tool calls to the appropriate built-in handler. func (e *Server) Dispatch(ctx context.Context, name string, args json.RawMessage) (string, error) { if e.OnPreDispatch != nil { e.OnPreDispatch() } - - switch name { - case "shell": - return dispatchShell(ctx, e, args) - case "tool_list": - return dispatchToolList(ctx, e, args) - case "tool_load": - 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) + if h, ok := e.builtins[name]; ok { + return h(ctx, e, args) } -} - -// dispatchToolList lists all available tools from the global registry. -func dispatchToolList(ctx context.Context, e *Server, args json.RawMessage) (string, error) { - if e.toolRegistry == nil { - return "", fmt.Errorf("tool_list: no registry available") - } - summaries := e.toolRegistry.Summaries() - if len(summaries) == 0 { - return "(no tools 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 -} - -// dispatchToolLoad loads a tool into the current session. -func dispatchToolLoad(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("tool_load: bad args: %w", err) - } - if a.Name == "" { - return "", fmt.Errorf("tool_load: name is required") - } - if e.toolRegistry == nil || e.sessionID == "" { - return "", fmt.Errorf("tool_load: no session registry") - } - if err := e.toolRegistry.Load(e.sessionID, a.Name); err != nil { - return "", fmt.Errorf("tool_load: %w", err) - } - return fmt.Sprintf("loaded: %s", a.Name), nil -} - -// dispatchToolActive lists tools currently loaded (promoted) in this session. -func dispatchToolActive(ctx context.Context, e *Server, args json.RawMessage) (string, error) { - if e.toolRegistry == nil || e.sessionID == "" { - return "(no tools loaded)", nil - } - loaded := e.toolRegistry.Loaded(e.sessionID) - if len(loaded) == 0 { - return "(no tools loaded)", nil - } - var out strings.Builder - for _, t := range loaded { - out.WriteString(t.Name) - if t.Description != "" { - out.WriteString(" — ") - out.WriteString(t.Description) - } - out.WriteString("\n") - } - return strings.TrimRight(out.String(), "\n"), nil + return "", fmt.Errorf("unknown tool: %s", name) } // Detach signals the currently running process to be detached from the agent. diff --git a/tools/shell.go b/toolsrv/shell.go similarity index 94% rename from tools/shell.go rename to toolsrv/shell.go index a7e798b..b9ae2b4 100644 --- a/tools/shell.go +++ b/toolsrv/shell.go @@ -1,4 +1,4 @@ -package tools +package toolsrv import ( "bytes" @@ -114,39 +114,6 @@ func (lw *limitedWriter) Write(p []byte) (n int, err error) { } return len(p), nil } - -// dispatchShell handles the shell tool: a single bash command. -func dispatchShell(ctx context.Context, e *Server, args json.RawMessage) (string, error) { - var a struct { - Cmd string `json:"cmd"` - Timeout int `json:"timeout"` - Sandbox string `json:"sandbox"` - Elevated bool `json:"elevated"` - Detach bool `json:"detach"` - } - if err := json.Unmarshal(args, &a); err != nil { - return "", fmt.Errorf("shell: bad args: %w", err) - } - if a.Cmd == "" { - return "", fmt.Errorf("shell: cmd is required") - } - timeout := a.Timeout - if timeout <= 0 { - timeout = 30 - } - if a.Elevated { - e.wdMu.RLock() - workDir := e.cwd - e.wdMu.RUnlock() - return e.executeElevated(ctx, a.Cmd, workDir, timeout, a.Detach) - } - sandboxName := a.Sandbox - if sandboxName == "" { - sandboxName = "default" - } - return e.executeWithStdin(ctx, a.Cmd, "bash", timeout, sandboxName, false, "", a.Detach) -} -// executeElevated runs cmd outside the sandbox via the integrated elevation broker. // Connects to the broker socket, sends the request with the current env, // and streams the framed response back. func (e *Server) executeElevated(ctx context.Context, cmd, dir string, timeout int, doDetach ...bool) (string, error) { diff --git a/toolsrv/skills.go b/toolsrv/skills.go new file mode 100644 index 0000000..9da2e71 --- /dev/null +++ b/toolsrv/skills.go @@ -0,0 +1,65 @@ +package toolsrv + +import ( + "encoding/json" + + "ollie/skills" +) + +// 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) []ToolInfo { + if skillsReg == nil { + return nil + } + 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. diff --git a/tools/stream.go b/toolsrv/stream.go similarity index 98% rename from tools/stream.go rename to toolsrv/stream.go index 7c75f64..145fd37 100644 --- a/tools/stream.go +++ b/toolsrv/stream.go @@ -1,4 +1,4 @@ -package tools +package toolsrv import "context" diff --git a/tools/tier.go b/toolsrv/tier.go similarity index 99% rename from tools/tier.go rename to toolsrv/tier.go index 1bb7917..1b1fca8 100644 --- a/tools/tier.go +++ b/toolsrv/tier.go @@ -1,4 +1,4 @@ -package tools +package toolsrv import ( "encoding/json" diff --git a/toolsrv/tools.go b/toolsrv/tools.go new file mode 100644 index 0000000..3865fce --- /dev/null +++ b/toolsrv/tools.go @@ -0,0 +1,51 @@ +// Package tools implements the tool server (sandboxed execution, tool registry, +// skill management) and supporting types. +package toolsrv + +import ( + "context" + "encoding/json" +) + +// ToolInfo describes a tool provided by a server. +type ToolInfo struct { + Server string + Name string + Description string + InputSchema json.RawMessage + // Prompt is the usage documentation for this tool, extracted from + // the script's ollie:prompt block. Included in the system prompt. + Prompt string + // Tier is the retention tier: "hot", "warm", or "cold". Parsed from + // the script's ollie:tier annotation. Empty defaults to "hot". + Tier string + // ReadOnly is true when the tool carries an "ollie:parallel read" annotation. + ReadOnly bool +} + +// Runner is the minimal interface satisfied by any tool server (local or remote). +// Consumers that need polymorphism over Server and RemoteServer use this. +type Runner interface { + ListTools() ([]ToolInfo, error) + CallTool(ctx context.Context, tool string, args json.RawMessage) (json.RawMessage, error) +} + +// ParallelClassifier is implemented by tool servers that can report whether a +// named tool is safe to run concurrently with other read-class tools. +// Returns false for unknown tools (conservative default). +type ParallelClassifier interface { + IsParallelRead(name string) bool +} + +// TierClassifier is implemented by tool servers that can report the retention +// tier of a tool's results: "hot" (verbatim), "warm" (summarized on compaction), +// or "cold" (immediately summarized). Returns "hot" for unknown tools. +type TierClassifier interface { + ResultTier(name string) string + // ResultTierArgs classifies the tier using both the outer tool name and its + // arguments. + ResultTierArgs(name string, args json.RawMessage) string +} + +// Handler is the signature for built-in tool handlers. +type Handler func(ctx context.Context, srv *Server, args json.RawMessage) (string, error) diff --git a/tools/tools_test.go b/toolsrv/tools_test.go similarity index 76% rename from tools/tools_test.go rename to toolsrv/tools_test.go index 2bf60a3..632cbc3 100644 --- a/tools/tools_test.go +++ b/toolsrv/tools_test.go @@ -1,34 +1,34 @@ -package tools_test +package toolsrv_test import ( "context" "encoding/json" "testing" - "ollie/tools" + "ollie/toolsrv" ) // stubRunner is a minimal Runner used to verify the contract. type stubRunner struct { name string - tools []tools.ToolInfo + tools []toolsrv.ToolInfo } -func (s *stubRunner) ListTools() ([]tools.ToolInfo, error) { return s.tools, nil } +func (s *stubRunner) ListTools() ([]toolsrv.ToolInfo, error) { return s.tools, nil } func (s *stubRunner) CallTool(_ context.Context, tool string, _ json.RawMessage) (json.RawMessage, error) { return json.RawMessage(`{"tool":"` + tool + `"}`), nil } func newStub(name string, toolNames ...string) *stubRunner { - var ti []tools.ToolInfo + var ti []toolsrv.ToolInfo for _, n := range toolNames { - ti = append(ti, tools.ToolInfo{Name: n, Description: n + " desc"}) + ti = append(ti, toolsrv.ToolInfo{Name: n, Description: n + " desc"}) } return &stubRunner{name: name, tools: ti} } // checkRunnerContract verifies Runner invariants. -func checkRunnerContract(t *testing.T, r tools.Runner) { +func checkRunnerContract(t *testing.T, r toolsrv.Runner) { t.Helper() tl, err := r.ListTools() if err != nil {