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().
This commit is contained in:
Levi Neely 2026-07-29 23:17:22 +02:00
parent 873f83464f
commit 18beb15698
22 changed files with 386 additions and 324 deletions

View File

@ -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
}

View File

@ -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{

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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"}},

View File

@ -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

View File

@ -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
}

View File

@ -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
}

41
toolsrv/accessors.go Normal file
View File

@ -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)
}

View File

@ -1,4 +1,4 @@
package tools
package toolsrv
import (
"fmt"

View File

@ -1,4 +1,4 @@
package tools
package toolsrv
import (
"fmt"

View File

@ -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"

View File

@ -1,4 +1,4 @@
package tools
package toolsrv
import (
"encoding/json"

View File

@ -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.

View File

@ -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) {

65
toolsrv/skills.go Normal file
View File

@ -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.

View File

@ -1,4 +1,4 @@
package tools
package toolsrv
import "context"

View File

@ -1,4 +1,4 @@
package tools
package toolsrv
import (
"encoding/json"

51
toolsrv/tools.go Normal file
View File

@ -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)

View File

@ -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 {