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"
|
"github.com/simonfxr/pubsub"
|
||||||
"ollie/backend"
|
"ollie/backend"
|
||||||
olog "ollie/log"
|
olog "ollie/log"
|
||||||
"ollie/tools"
|
"ollie/toolsrv"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Agent holds the state of the current Agent entity (the "agent"
|
// Agent holds the state of the current Agent entity (the "agent"
|
||||||
|
|
@ -29,7 +29,7 @@ type Agent struct {
|
||||||
agentsDir string
|
agentsDir string
|
||||||
baseLayers []string // system prompt layers for /agent reloads
|
baseLayers []string // system prompt layers for /agent reloads
|
||||||
promptEnvExtra []string // PRIME_* vars for prompt resolution
|
promptEnvExtra []string // PRIME_* vars for prompt resolution
|
||||||
newToolServer func() tools.Runner
|
newToolServer func() toolsrv.Runner
|
||||||
newBackend func(string) (backend.Backend, error)
|
newBackend func(string) (backend.Backend, error)
|
||||||
currentAction atomic.Pointer[actionHandle]
|
currentAction atomic.Pointer[actionHandle]
|
||||||
warnedContext bool
|
warnedContext bool
|
||||||
|
|
@ -473,7 +473,7 @@ func (ag *Agent) React(responseID, emoji string) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// execServer returns the execute server, or nil if unavailable.
|
// 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
|
return ag.runtime.ExecServer
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"ollie/backend"
|
"ollie/backend"
|
||||||
"ollie/tools"
|
"ollie/toolsrv"
|
||||||
)
|
)
|
||||||
|
|
||||||
// BuildRuntime constructs a Runtime from a pre-configured Dispatcher and
|
// BuildRuntime constructs a Runtime from a pre-configured Dispatcher and
|
||||||
|
|
@ -17,10 +17,10 @@ import (
|
||||||
// env provides additional environment variables injected into prompt resolution
|
// env provides additional environment variables injected into prompt resolution
|
||||||
// subprocesses (e.g. OLLIE_SESSION_ID=xxx).
|
// subprocesses (e.g. OLLIE_SESSION_ID=xxx).
|
||||||
// The caller is responsible for registering all servers on d before calling this.
|
// 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 messages []string
|
||||||
|
|
||||||
var allToolInfos []tools.ToolInfo
|
var allToolInfos []toolsrv.ToolInfo
|
||||||
var allTools []backend.Tool
|
var allTools []backend.Tool
|
||||||
|
|
||||||
if cfg == nil || cfg.ToolsEnabled() {
|
if cfg == nil || cfg.ToolsEnabled() {
|
||||||
|
|
@ -29,11 +29,11 @@ func BuildRuntime(cfg *AgentConfig, srv tools.Runner, cwd string, env []string,
|
||||||
if listErr != nil {
|
if listErr != nil {
|
||||||
messages = append(messages, fmt.Sprintf("list tools: %v", listErr))
|
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)
|
allTools = toolInfosToBackend(allToolInfos)
|
||||||
|
|
||||||
// Append named tool scripts for preamble listing only.
|
// Append named tool scripts for preamble listing only.
|
||||||
allToolInfos = append(allToolInfos, tools.DiscoverTools()...)
|
allToolInfos = append(allToolInfos, toolsrv.DiscoverTools()...)
|
||||||
}
|
}
|
||||||
|
|
||||||
hooks := Hooks{}
|
hooks := Hooks{}
|
||||||
|
|
@ -88,12 +88,12 @@ func BuildRuntime(cfg *AgentConfig, srv tools.Runner, cwd string, env []string,
|
||||||
}
|
}
|
||||||
|
|
||||||
var classify toolClassifier
|
var classify toolClassifier
|
||||||
if pc, ok := srv.(tools.ParallelClassifier); ok {
|
if pc, ok := srv.(toolsrv.ParallelClassifier); ok {
|
||||||
classify = pc.IsParallelRead
|
classify = pc.IsParallelRead
|
||||||
}
|
}
|
||||||
|
|
||||||
var tierFn func(string, json.RawMessage) ResultTier
|
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 {
|
tierFn = func(name string, args json.RawMessage) ResultTier {
|
||||||
switch tc.ResultTierArgs(name, args) {
|
switch tc.ResultTierArgs(name, args) {
|
||||||
case "cold":
|
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))
|
out := make([]backend.Tool, len(infos))
|
||||||
for i, t := range infos {
|
for i, t := range infos {
|
||||||
out[i] = backend.Tool{
|
out[i] = backend.Tool{
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"ollie/backend"
|
"ollie/backend"
|
||||||
"ollie/tools"
|
"ollie/toolsrv"
|
||||||
)
|
)
|
||||||
|
|
||||||
const maxTransientRetries = 3
|
const maxTransientRetries = 3
|
||||||
|
|
@ -310,7 +310,7 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
|
||||||
streamed := false
|
streamed := false
|
||||||
if cfg.Exec != nil {
|
if cfg.Exec != nil {
|
||||||
streamBytes := 0
|
streamBytes := 0
|
||||||
streamCtx := tools.WithOutputStream(ctx, func(data string) {
|
streamCtx := toolsrv.WithOutputStream(ctx, func(data string) {
|
||||||
if streamBytes >= defaultToolResultMaxBytes {
|
if streamBytes >= defaultToolResultMaxBytes {
|
||||||
return // already at ceiling, drop further chunks
|
return // already at ceiling, drop further chunks
|
||||||
}
|
}
|
||||||
|
|
@ -580,7 +580,7 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
|
||||||
if roundsWithoutPlan >= replanGate {
|
if roundsWithoutPlan >= replanGate {
|
||||||
state.update(backend.Message{
|
state.update(backend.Message{
|
||||||
Role: "user",
|
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)
|
}, nil)
|
||||||
roundsWithoutPlan = 0
|
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)})
|
emit(cfg, Event{Role: "maxsteps", Content: fmt.Sprintf("%d", step+1)})
|
||||||
state.update(backend.Message{
|
state.update(backend.Message{
|
||||||
Role: "user",
|
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)
|
}, nil)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
@ -1042,5 +1042,5 @@ func retryCountdown(ctx context.Context, cfg agentConfig, wait time.Duration) er
|
||||||
const defaultToolResultMaxBytes = 131072
|
const defaultToolResultMaxBytes = 131072
|
||||||
|
|
||||||
// toolClassifier reports whether a named tool is safe to run concurrently
|
// 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
|
type toolClassifier func(name string) bool
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import (
|
||||||
"github.com/simonfxr/pubsub"
|
"github.com/simonfxr/pubsub"
|
||||||
"ollie/backend"
|
"ollie/backend"
|
||||||
olog "ollie/log"
|
olog "ollie/log"
|
||||||
"ollie/tools"
|
"ollie/toolsrv"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AgentCfg is the configuration for constructing a new Agent.
|
// AgentCfg is the configuration for constructing a new Agent.
|
||||||
|
|
@ -19,7 +19,7 @@ type AgentCfg struct {
|
||||||
CWD string // working directory for tool execution
|
CWD string // working directory for tool execution
|
||||||
BaseLayers []string
|
BaseLayers []string
|
||||||
PromptEnvExtra []string
|
PromptEnvExtra []string
|
||||||
NewToolServer func() tools.Runner
|
NewToolServer func() toolsrv.Runner
|
||||||
NewBackend func(string) (backend.Backend, error)
|
NewBackend func(string) (backend.Backend, error)
|
||||||
Bus *pubsub.Bus
|
Bus *pubsub.Bus
|
||||||
Log *olog.Logger
|
Log *olog.Logger
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
|
||||||
"ollie/backend"
|
"ollie/backend"
|
||||||
"ollie/tools"
|
"ollie/toolsrv"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Runtime holds the swappable per-agent configuration. It contains everything
|
// Runtime holds the swappable per-agent configuration. It contains everything
|
||||||
|
|
@ -13,7 +13,7 @@ import (
|
||||||
// agents replaces it atomically.
|
// agents replaces it atomically.
|
||||||
type Runtime struct {
|
type Runtime struct {
|
||||||
Backend backend.Backend
|
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
|
Hooks Hooks
|
||||||
Preamble string // compiled system prompt
|
Preamble string // compiled system prompt
|
||||||
Tools []backend.Tool
|
Tools []backend.Tool
|
||||||
|
|
|
||||||
|
|
@ -318,7 +318,7 @@ func TestOpenRouterClaude_LastToolCacheControl(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestOpenRouterNonClaude_NoCacheControl verifies that a non-Claude model on
|
// 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) {
|
func TestOpenRouterNonClaude_NoCacheControl(t *testing.T) {
|
||||||
top := captureRawRequest(t, "openrouter", "mistral-7b",
|
top := captureRawRequest(t, "openrouter", "mistral-7b",
|
||||||
[]Message{{Role: "system", Content: "be helpful"}, {Role: "user", Content: "hi"}},
|
[]Message{{Role: "system", Content: "be helpful"}, {Role: "user", Content: "hi"}},
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ import (
|
||||||
"ollie/backend"
|
"ollie/backend"
|
||||||
olog "ollie/log"
|
olog "ollie/log"
|
||||||
"ollie/paths"
|
"ollie/paths"
|
||||||
"ollie/tools"
|
"ollie/toolsrv"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config is the configuration for creating a session.
|
// Config is the configuration for creating a session.
|
||||||
|
|
@ -32,7 +32,7 @@ type Config struct {
|
||||||
CWD string
|
CWD string
|
||||||
History *agent.History
|
History *agent.History
|
||||||
Runtime *agent.Runtime
|
Runtime *agent.Runtime
|
||||||
NewToolServer func() tools.Runner
|
NewToolServer func() toolsrv.Runner
|
||||||
NewBackend func(string) (backend.Backend, error)
|
NewBackend func(string) (backend.Backend, error)
|
||||||
Log *olog.Logger
|
Log *olog.Logger
|
||||||
MaxSteps int
|
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,
|
// Package tools provides the built-in tool handlers for the toolsrv server.
|
||||||
// skill management) and supporting types.
|
|
||||||
package tools
|
package tools
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"ollie/toolsrv"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ToolInfo describes a tool provided by a server.
|
// Builtins returns the default set of built-in tool handlers.
|
||||||
type ToolInfo struct {
|
func Builtins() map[string]toolsrv.Handler {
|
||||||
Server string
|
return map[string]toolsrv.Handler{
|
||||||
Name string
|
"shell": Shell,
|
||||||
Description string
|
"tool_list": ToolList,
|
||||||
InputSchema json.RawMessage
|
"tool_load": ToolLoad,
|
||||||
// Prompt is the usage documentation for this tool, extracted from
|
"tool_active": ToolActive,
|
||||||
// the script's ollie:prompt block. Included in the system prompt.
|
"skill_list": SkillList,
|
||||||
Prompt string
|
"skill_load": SkillLoad,
|
||||||
// Tier is the retention tier: "hot", "warm", or "cold". Parsed from
|
"skill_active": SkillActive,
|
||||||
// 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).
|
// Shell executes a single bash command in a sandboxed environment.
|
||||||
// Consumers that need polymorphism over Server and RemoteServer use this.
|
func Shell(ctx context.Context, srv *toolsrv.Server, args json.RawMessage) (string, error) {
|
||||||
type Runner interface {
|
var a struct {
|
||||||
ListTools() ([]ToolInfo, error)
|
Cmd string `json:"cmd"`
|
||||||
CallTool(ctx context.Context, tool string, args json.RawMessage) (json.RawMessage, error)
|
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
|
// ToolList lists all available tools from the global registry.
|
||||||
// named tool is safe to run concurrently with other read-class tools.
|
func ToolList(ctx context.Context, srv *toolsrv.Server, args json.RawMessage) (string, error) {
|
||||||
// Returns false for unknown tools (conservative default).
|
reg := srv.ToolRegistry()
|
||||||
type ParallelClassifier interface {
|
if reg == nil {
|
||||||
IsParallelRead(name string) bool
|
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
|
// ToolLoad loads a tool into the current session.
|
||||||
// tier of a tool's results: "hot" (verbatim), "warm" (summarized on compaction),
|
func ToolLoad(ctx context.Context, srv *toolsrv.Server, args json.RawMessage) (string, error) {
|
||||||
// or "cold" (immediately summarized). Returns "hot" for unknown tools.
|
var a struct {
|
||||||
type TierClassifier interface {
|
Name string `json:"name"`
|
||||||
ResultTier(name string) string
|
}
|
||||||
// ResultTierArgs classifies the tier using both the outer tool name and its
|
if err := json.Unmarshal(args, &a); err != nil {
|
||||||
// arguments.
|
return "", fmt.Errorf("tool_load: bad args: %w", err)
|
||||||
ResultTierArgs(name string, args json.RawMessage) string
|
}
|
||||||
|
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 (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package tools
|
package toolsrv
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
// split-brain remote execution. It connects to a remote host over SSH,
|
// split-brain remote execution. It connects to a remote host over SSH,
|
||||||
// ensures ollie-remote is deployed, and returns a Server that
|
// ensures ollie-remote is deployed, and returns a Server that
|
||||||
// forwards execution calls over the RPC channel.
|
// forwards execution calls over the RPC channel.
|
||||||
package tools
|
package toolsrv
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package tools
|
package toolsrv
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package tools
|
package toolsrv
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
@ -47,6 +47,9 @@ type Server struct {
|
||||||
// Empty means all are allowed.
|
// Empty means all are allowed.
|
||||||
allowTools map[string]bool
|
allowTools map[string]bool
|
||||||
|
|
||||||
|
// builtins maps tool names to their handler functions.
|
||||||
|
builtins map[string]Handler
|
||||||
|
|
||||||
toolRegistry *Registry
|
toolRegistry *Registry
|
||||||
skillsRegistry *skills.Registry
|
skillsRegistry *skills.Registry
|
||||||
sessionID string
|
sessionID string
|
||||||
|
|
@ -114,6 +117,11 @@ func (e *Server) AllowTools() []string {
|
||||||
return out
|
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.
|
// WithToolRegistry attaches a tool registry and session ID to the Server.
|
||||||
func WithToolRegistry(r *Registry, sessionID string) Option {
|
func WithToolRegistry(r *Registry, sessionID string) Option {
|
||||||
return func(s *Server) {
|
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 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) {
|
func (e *Server) Dispatch(ctx context.Context, name string, args json.RawMessage) (string, error) {
|
||||||
if e.OnPreDispatch != nil {
|
if e.OnPreDispatch != nil {
|
||||||
e.OnPreDispatch()
|
e.OnPreDispatch()
|
||||||
}
|
}
|
||||||
|
if h, ok := e.builtins[name]; ok {
|
||||||
switch name {
|
return h(ctx, e, args)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
return "", fmt.Errorf("unknown tool: %s", name)
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detach signals the currently running process to be detached from the agent.
|
// Detach signals the currently running process to be detached from the agent.
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package tools
|
package toolsrv
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
|
@ -114,39 +114,6 @@ func (lw *limitedWriter) Write(p []byte) (n int, err error) {
|
||||||
}
|
}
|
||||||
return len(p), nil
|
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,
|
// Connects to the broker socket, sends the request with the current env,
|
||||||
// and streams the framed response back.
|
// and streams the framed response back.
|
||||||
func (e *Server) executeElevated(ctx context.Context, cmd, dir string, timeout int, doDetach ...bool) (string, error) {
|
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"
|
import "context"
|
||||||
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package tools
|
package toolsrv
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"ollie/tools"
|
"ollie/toolsrv"
|
||||||
)
|
)
|
||||||
|
|
||||||
// stubRunner is a minimal Runner used to verify the contract.
|
// stubRunner is a minimal Runner used to verify the contract.
|
||||||
type stubRunner struct {
|
type stubRunner struct {
|
||||||
name string
|
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) {
|
func (s *stubRunner) CallTool(_ context.Context, tool string, _ json.RawMessage) (json.RawMessage, error) {
|
||||||
return json.RawMessage(`{"tool":"` + tool + `"}`), nil
|
return json.RawMessage(`{"tool":"` + tool + `"}`), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func newStub(name string, toolNames ...string) *stubRunner {
|
func newStub(name string, toolNames ...string) *stubRunner {
|
||||||
var ti []tools.ToolInfo
|
var ti []toolsrv.ToolInfo
|
||||||
for _, n := range toolNames {
|
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}
|
return &stubRunner{name: name, tools: ti}
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkRunnerContract verifies Runner invariants.
|
// checkRunnerContract verifies Runner invariants.
|
||||||
func checkRunnerContract(t *testing.T, r tools.Runner) {
|
func checkRunnerContract(t *testing.T, r toolsrv.Runner) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
tl, err := r.ListTools()
|
tl, err := r.ListTools()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Reference in New Issue