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:
parent
873f83464f
commit
18beb15698
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"}},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
139
tools/skills.go
139
tools/skills.go
|
|
@ -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
|
||||
}
|
||||
213
tools/tools.go
213
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package tools
|
||||
package toolsrv
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package tools
|
||||
package toolsrv
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
|
@ -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"
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package tools
|
||||
package toolsrv
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
|
@ -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.
|
||||
|
|
@ -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) {
|
||||
|
|
@ -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.
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package tools
|
||||
package toolsrv
|
||||
|
||||
import "context"
|
||||
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package tools
|
||||
package toolsrv
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
|
@ -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)
|
||||
|
|
@ -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 {
|
||||
Reference in New Issue