cleanup tools

This commit is contained in:
Levi Neely 2026-04-12 16:26:50 +02:00
parent 1e40a6d573
commit 1b44244f81
11 changed files with 613 additions and 532 deletions

View File

@ -1,136 +0,0 @@
package tools
import "encoding/json"
// ExecuteDefs returns the ToolInfo definitions for the execute_* built-in tools.
// toolsPath is embedded in the descriptions.
func ExecuteDefs(toolsPath string) []ToolInfo {
return []ToolInfo{
{
Name: "execute_code",
Description: "Run inline code in a sandboxed environment.",
InputSchema: json.RawMessage(`{
"type": "object",
"required": ["code"],
"properties": {
"code": {"type": "string", "description": "Code to execute."},
"language": {"type": "string", "description": "Language interpreter (default: bash)."},
"timeout": {"type": "integer", "description": "Timeout in seconds (default: 30)."},
"sandbox": {"type": "string", "description": "Sandbox name (default: default)."}
}
}`),
},
{
Name: "execute_tool",
Description: "Run a named tool script from " + toolsPath + " in a sandboxed environment. Supported languages: bash, python3 (detected from shebang). Use this only for named scripts, not for inline shell commands.",
InputSchema: json.RawMessage(`{
"type": "object",
"required": ["tool"],
"properties": {
"tool": {"type": "string", "description": "Name of the tool script in the tools directory."},
"args": {"type": "array", "items": {"type": "string"}, "description": "Arguments for the tool script."},
"timeout": {"type": "integer", "description": "Timeout in seconds (default: 30)."},
"sandbox": {"type": "string", "description": "Sandbox name (default: default)."}
}
}`),
},
{
Name: "execute_pipe",
Description: "Run a pipeline of steps, piping stdout of each into stdin of the next. Use {code: \"cmd --flags\"} for shell commands; use {tool, args} only for named scripts in " + toolsPath + ".",
InputSchema: json.RawMessage(`{
"type": "object",
"required": ["pipe"],
"properties": {
"pipe": {
"type": "array",
"items": {
"type": "object",
"properties": {
"tool": {"type": "string"},
"args": {"type": "array", "items": {"type": "string"}},
"code": {"type": "string"}
}
}
},
"timeout": {"type": "integer", "description": "Timeout in seconds (default: 30)."},
"sandbox": {"type": "string", "description": "Sandbox name (default: default)."}
}
}`),
},
}
}
// MemoryDefs returns the ToolInfo definitions for the memory_* built-in tools.
func MemoryDefs() []ToolInfo {
return []ToolInfo{
{
Name: "memory_remember",
Description: "Save a memory that persists across sessions. Provide a descriptive title, relevant tags, and a concise body. Choose tags carefully — they are the primary recall mechanism. Prefer specific, stable terms over generic ones.",
InputSchema: json.RawMessage(`{
"type": "object",
"required": ["title", "tags", "body"],
"properties": {
"title": {"type": "string", "description": "Descriptive title. Should answer 'what is this about' on its own."},
"tags": {"type": "array", "items": {"type": "string"}, "minItems": 1, "description": "Tags for recall. Use specific, stable terms you would search for later."},
"body": {"type": "string", "description": "Memory content. Write concisely — key facts, decisions, and context. Not a transcript."}
}
}`),
},
{
Name: "memory_recall",
Description: "Search memories by keyword. Matches against titles and tags only — body content is not searched. Use specific terms likely to appear in titles or tags at write time.",
InputSchema: json.RawMessage(`{
"type": "object",
"required": ["query"],
"properties": {
"query": {"type": "string", "description": "Keyword to search for in memory titles and tags."}
}
}`),
},
}
}
// ReasoningDefs returns the ToolInfo definitions for the reasoning_* built-in tools.
func ReasoningDefs() []ToolInfo {
return []ToolInfo{
{
Name: "reasoning_think",
Description: "Internal reasoning tool for breaking down problems, planning approaches, analyzing constraints, and reasoning through multi-step solutions before acting. The thought content is not shown to the user.",
InputSchema: json.RawMessage(`{
"type": "object",
"required": ["thought"],
"properties": {
"thought": {"type": "string", "description": "A reflective note or intermediate reasoning step."}
}
}`),
},
{
Name: "reasoning_plan",
Description: "Executive planning tool. Before beginning multi-step work, decompose the goal into ordered steps with explicit dependencies. If a task backend is available (task_create), steps are committed to persistent storage and assigned IDs. Otherwise steps are queued for sequential execution. Use reasoning_think for moment-to-moment reflection; use reasoning_plan to lay out a complete work breakdown before acting. Steps may only depend on earlier steps (lower index).",
InputSchema: json.RawMessage(`{
"type": "object",
"required": ["goal", "steps"],
"properties": {
"goal": {"type": "string", "description": "Top-level goal or objective."},
"steps": {
"type": "array",
"description": "Ordered work steps. Each step may only list earlier steps (by 0-based index) in 'after'.",
"items": {
"type": "object",
"required": ["title"],
"properties": {
"title": {"type": "string", "description": "Short step title."},
"body": {"type": "string", "description": "Optional detail or acceptance criteria."},
"after": {
"type": "array",
"items": {"type": "integer"},
"description": "0-based indices of steps that must complete before this one."
}
}
}
}
}
}`),
},
}
}

View File

@ -1,4 +1,165 @@
package execute
import (
"context"
"encoding/json"
"fmt"
"io"
"ollie/internal/sandbox"
"regexp"
"strings"
)
// execute_code is implemented directly by Executor.Execute with trusted=false.
// See executor.go for the implementation.
var dangerousPatterns = []*regexp.Regexp{
regexp.MustCompile(`rm\s+(-[a-z]*r[a-z]*\s+)*-[a-z]*f[a-z]*\s*/(home|var|usr|etc|boot|root|bin|sbin|lib|opt|srv)?`),
regexp.MustCompile(`rm\s+(-[a-z]*f[a-z]*\s+)*-[a-z]*r[a-z]*\s*/(home|var|usr|etc|boot|root|bin|sbin|lib|opt|srv)?`),
regexp.MustCompile(`rm\s+.*--recursive.*--force`),
regexp.MustCompile(`rm\s+.*--force.*--recursive`),
regexp.MustCompile(`rm\s+(-[a-z]*r[a-z]*\s+)*-[a-z]*f[a-z]*\s+\.\.?(/|$)`),
regexp.MustCompile(`rm\s+(-[a-z]*r[a-z]*\s+)*-[a-z]*f[a-z]*\s+~`),
regexp.MustCompile(`rm\s+(-[a-z]*r[a-z]*\s+)*-[a-z]*f[a-z]*\s+\*`),
regexp.MustCompile(`:\s*\(\s*\)\s*\{\s*:\s*\|\s*:\s*&`),
regexp.MustCompile(`\bmkfs\b`),
regexp.MustCompile(`\bdd\b.*\bif=/dev/`),
regexp.MustCompile(`>\s*/dev/sd`),
regexp.MustCompile(`\beval\s+".*\$`),
regexp.MustCompile(`\b(sudo|su)\s`),
regexp.MustCompile(`/etc/(shadow|sudoers)`),
}
// Dispatch routes a named execute tool call. Called by tools.BuiltinServer.
func (e *Server) Dispatch(ctx context.Context, name string, args json.RawMessage) (string, error) {
switch name {
case "execute_code":
return dispatchExecuteCode(ctx, e, args)
case "execute_tool":
return dispatchExecuteTool(ctx, e, args)
case "execute_pipe":
return dispatchExecutePipe(ctx, e, args)
default:
return "", fmt.Errorf("unknown execute tool: %s", name)
}
}
// ValidateCode checks code against dangerous patterns.
func (e *Server) ValidateCode(code string) error {
if err := e.checkRateLimit(); err != nil {
return err
}
normalized := strings.ToLower(code)
normalized = whitespacePattern.ReplaceAllString(normalized, " ")
for _, pattern := range dangerousPatterns {
if pattern.MatchString(normalized) {
e.recordValidationFailure()
return fmt.Errorf("dangerous pattern detected")
}
}
return nil
}
func loadLayeredConfig(name string) (*sandbox.Config, error) {
baseCfg, err := sandbox.Load()
if err != nil {
return nil, fmt.Errorf("failed to load global config: %w", err)
}
baseLayer := sandbox.LayeredConfig{
Filesystem: baseCfg.Filesystem,
Network: baseCfg.Network,
Env: baseCfg.Env,
}
layers := []sandbox.LayeredConfig{baseLayer}
if name == "" {
name = "default"
}
sbxLayer, err := sandbox.LoadSandbox(name)
if err != nil {
return nil, fmt.Errorf("failed to load sandbox %q: %w", name, err)
}
layers = append(layers, sbxLayer)
general := sandbox.GeneralConfig{
BestEffort: baseCfg.General.BestEffort,
LogLevel: baseCfg.General.LogLevel,
}
advanced := sandbox.AdvancedConfig{
LDD: baseCfg.Advanced.LDD,
AddExec: baseCfg.Advanced.AddExec,
}
return sandbox.Merge(general, advanced, layers...), nil
}
type limitedWriter struct {
w io.Writer
written int
limit int
truncated bool
}
func (lw *limitedWriter) Write(p []byte) (n int, err error) {
if lw.written >= lw.limit {
lw.truncated = true
return len(p), nil
}
remaining := lw.limit - lw.written
toWrite := p
if len(p) > remaining {
toWrite = p[:remaining]
lw.truncated = true
}
written, err := lw.w.Write(toWrite)
lw.written += written
if err != nil {
return written, err
}
return len(p), nil
}
func dispatchExecuteCode(ctx context.Context, e *Server, args json.RawMessage) (string, error) {
code, language, sandbox, timeout, err := execArgs(args)
if err != nil {
return "", fmt.Errorf("execute_code: bad args: %w", err)
}
if code == "" {
return "", fmt.Errorf("execute_code: 'code' is required")
}
if e.Confirm != nil && !e.Confirm(fmt.Sprintf("execute_code: %s", code)) {
return "", fmt.Errorf("execute_code: denied by user")
}
return e.Execute(ctx, code, language, timeout, sandbox, false)
}
func execArgs(args json.RawMessage) (code, language, sandbox string, timeout int, err error) {
var a struct {
Code string `json:"code"`
Language string `json:"language"`
Timeout int `json:"timeout"`
Sandbox string `json:"sandbox"`
}
if err = json.Unmarshal(args, &a); err != nil {
return
}
code = a.Code
language = a.Language
if language == "" {
language = "bash"
}
timeout = a.Timeout
if timeout <= 0 {
timeout = 30
}
sandbox = a.Sandbox
if sandbox == "" {
sandbox = "default"
}
return
}

View File

@ -1,132 +0,0 @@
package execute
import (
"context"
"encoding/json"
"fmt"
"strings"
)
// Dispatch routes a named execute tool call. Called by tools.BuiltinServer.
func (e *Server) Dispatch(ctx context.Context, name string, args json.RawMessage) (string, error) {
switch name {
case "execute_code":
return dispatchExecuteCode(ctx, e, args)
case "execute_tool":
return dispatchExecuteTool(ctx, e, args)
case "execute_pipe":
return dispatchExecutePipe(ctx, e, args)
default:
return "", fmt.Errorf("unknown execute tool: %s", name)
}
}
func execArgs(args json.RawMessage) (code, language, sandbox string, timeout int, err error) {
var a struct {
Code string `json:"code"`
Language string `json:"language"`
Timeout int `json:"timeout"`
Sandbox string `json:"sandbox"`
}
if err = json.Unmarshal(args, &a); err != nil {
return
}
code = a.Code
language = a.Language
if language == "" {
language = "bash"
}
timeout = a.Timeout
if timeout <= 0 {
timeout = 30
}
sandbox = a.Sandbox
if sandbox == "" {
sandbox = "default"
}
return
}
func dispatchExecuteCode(ctx context.Context, e *Server, args json.RawMessage) (string, error) {
code, language, sandbox, timeout, err := execArgs(args)
if err != nil {
return "", fmt.Errorf("execute_code: bad args: %w", err)
}
if code == "" {
return "", fmt.Errorf("execute_code: 'code' is required")
}
if e.Confirm != nil && !e.Confirm(fmt.Sprintf("execute_code: %s", squashWhitespace(code))) {
return "", fmt.Errorf("execute_code: denied by user")
}
return e.Execute(ctx, code, language, timeout, sandbox, false)
}
func dispatchExecuteTool(ctx context.Context, e *Server, args json.RawMessage) (string, error) {
var a struct {
Tool string `json:"tool"`
Args []string `json:"args"`
Timeout int `json:"timeout"`
Sandbox string `json:"sandbox"`
}
if err := json.Unmarshal(args, &a); err != nil {
return "", fmt.Errorf("execute_tool: bad args: %w", err)
}
if a.Tool == "" {
return "", fmt.Errorf("execute_tool: 'tool' is required")
}
if e.Confirm != nil && !e.Confirm(fmt.Sprintf("execute_tool: %s %s", a.Tool, strings.Join(a.Args, " "))) {
return "", fmt.Errorf("execute_tool: denied by user")
}
toolCode, err := ReadTool(a.Tool)
if err != nil {
return "", err
}
language := detectLanguage(toolCode)
code := toolCode
if len(a.Args) > 0 {
code = injectArgs(language, a.Tool, a.Args, toolCode)
}
timeout := a.Timeout
if timeout <= 0 {
timeout = 30
}
sandbox := a.Sandbox
if sandbox == "" {
sandbox = "default"
}
return e.Execute(ctx, code, language, timeout, sandbox, true)
}
func dispatchExecutePipe(ctx context.Context, e *Server, args json.RawMessage) (string, error) {
var a struct {
Pipe []PipeStep `json:"pipe"`
Timeout int `json:"timeout"`
Sandbox string `json:"sandbox"`
}
if err := json.Unmarshal(args, &a); err != nil {
return "", fmt.Errorf("execute_pipe: bad args: %w", err)
}
if len(a.Pipe) == 0 {
return "", fmt.Errorf("execute_pipe: 'pipe' is required")
}
code, _, err := BuildPipeline(a.Pipe)
if err != nil {
return "", err
}
if e.Confirm != nil && !e.Confirm(fmt.Sprintf("execute_pipe: %s", squashWhitespace(code))) {
return "", fmt.Errorf("execute_pipe: denied by user")
}
timeout := a.Timeout
if timeout <= 0 {
timeout = 30
}
sandbox := a.Sandbox
if sandbox == "" {
sandbox = "default"
}
return e.Execute(ctx, code, "bash", timeout, sandbox, true)
}
func squashWhitespace(s string) string {
return strings.Join(strings.Fields(s), " ")
}

View File

@ -1,250 +0,0 @@
// Package execute provides the builtin execute_code, execute_tool, and
// execute_pipe tools. The Server struct is the shared sandbox runner.
package execute
import (
"bytes"
"context"
"fmt"
"io"
"os"
"os/exec"
"regexp"
"strings"
"sync"
"syscall"
"time"
"ollie/internal/sandbox"
)
// Server runs code in a sandboxed environment.
type Server struct {
// Confirm is an optional function called before executing sensitive operations.
// If it returns false, the operation is denied.
Confirm func(string) bool
// workdir is the working directory for sandboxed commands. If empty,
// the process working directory is used.
wdMu sync.RWMutex
workdir string
// rate limiting state (per-Server)
rateLimitMu sync.Mutex
validationFailures int
lastFailure time.Time
blockedUntil time.Time
}
// New creates a new Server with the given working directory.
func New(workdir string) *Server { return &Server{workdir: workdir} }
// SetWorkDir updates the working directory used for subsequent command executions.
func (e *Server) SetWorkDir(dir string) {
e.wdMu.Lock()
e.workdir = dir
e.wdMu.Unlock()
}
var dangerousPatterns = []*regexp.Regexp{
regexp.MustCompile(`rm\s+(-[a-z]*r[a-z]*\s+)*-[a-z]*f[a-z]*\s*/(home|var|usr|etc|boot|root|bin|sbin|lib|opt|srv)?`),
regexp.MustCompile(`rm\s+(-[a-z]*f[a-z]*\s+)*-[a-z]*r[a-z]*\s*/(home|var|usr|etc|boot|root|bin|sbin|lib|opt|srv)?`),
regexp.MustCompile(`rm\s+.*--recursive.*--force`),
regexp.MustCompile(`rm\s+.*--force.*--recursive`),
regexp.MustCompile(`rm\s+(-[a-z]*r[a-z]*\s+)*-[a-z]*f[a-z]*\s+\.\.?(/|$)`),
regexp.MustCompile(`rm\s+(-[a-z]*r[a-z]*\s+)*-[a-z]*f[a-z]*\s+~`),
regexp.MustCompile(`rm\s+(-[a-z]*r[a-z]*\s+)*-[a-z]*f[a-z]*\s+\*`),
regexp.MustCompile(`:\s*\(\s*\)\s*\{\s*:\s*\|\s*:\s*&`),
regexp.MustCompile(`\bmkfs\b`),
regexp.MustCompile(`\bdd\b.*\bif=/dev/`),
regexp.MustCompile(`>\s*/dev/sd`),
regexp.MustCompile(`\beval\s+".*\$`),
regexp.MustCompile(`\b(sudo|su)\s`),
regexp.MustCompile(`/etc/(shadow|sudoers)`),
}
var whitespacePattern = regexp.MustCompile(`\s+`)
const (
maxFailures = 5
blockDuration = 30 * time.Second
failureWindow = 60 * time.Second
)
func (e *Server) checkRateLimit() error {
e.rateLimitMu.Lock()
defer e.rateLimitMu.Unlock()
now := time.Now()
if now.Before(e.blockedUntil) {
remaining := e.blockedUntil.Sub(now).Round(time.Second)
return fmt.Errorf("rate limited: too many validation failures, blocked for %v", remaining)
}
return nil
}
func (e *Server) recordValidationFailure() {
e.rateLimitMu.Lock()
defer e.rateLimitMu.Unlock()
now := time.Now()
if now.Sub(e.lastFailure) > failureWindow {
e.validationFailures = 0
}
e.validationFailures++
e.lastFailure = now
if e.validationFailures >= maxFailures {
e.blockedUntil = now.Add(blockDuration)
e.validationFailures = 0
}
}
// ValidateCode checks code against dangerous patterns.
func (e *Server) ValidateCode(code string) error {
if err := e.checkRateLimit(); err != nil {
return err
}
normalized := strings.ToLower(code)
normalized = whitespacePattern.ReplaceAllString(normalized, " ")
for _, pattern := range dangerousPatterns {
if pattern.MatchString(normalized) {
e.recordValidationFailure()
return fmt.Errorf("dangerous pattern detected")
}
}
return nil
}
func loadLayeredConfig(name string) (*sandbox.Config, error) {
baseCfg, err := sandbox.Load()
if err != nil {
return nil, fmt.Errorf("failed to load global config: %w", err)
}
baseLayer := sandbox.LayeredConfig{
Filesystem: baseCfg.Filesystem,
Network: baseCfg.Network,
Env: baseCfg.Env,
}
layers := []sandbox.LayeredConfig{baseLayer}
if name == "" {
name = "default"
}
sbxLayer, err := sandbox.LoadSandbox(name)
if err != nil {
return nil, fmt.Errorf("failed to load sandbox %q: %w", name, err)
}
layers = append(layers, sbxLayer)
general := sandbox.GeneralConfig{
BestEffort: baseCfg.General.BestEffort,
LogLevel: baseCfg.General.LogLevel,
}
advanced := sandbox.AdvancedConfig{
LDD: baseCfg.Advanced.LDD,
AddExec: baseCfg.Advanced.AddExec,
}
return sandbox.Merge(general, advanced, layers...), nil
}
// Execute runs code in a sandbox and returns combined stdout+stderr.
func (e *Server) Execute(ctx context.Context, code, language string, timeout int, sandboxName string, trusted bool) (string, error) {
if timeout <= 0 {
timeout = 30
}
if !trusted {
if err := e.ValidateCode(code); err != nil {
return "", err
}
}
cfg, err := loadLayeredConfig(sandboxName)
if err != nil {
return "", err
}
e.wdMu.RLock()
workDir := e.workdir
e.wdMu.RUnlock()
if workDir == "" {
workDir, _ = os.Getwd()
}
ctx, cancel := context.WithTimeout(ctx, time.Duration(timeout)*time.Second)
defer cancel()
var cmd *exec.Cmd
var interpreter []string
switch language {
case "bash", "":
interpreter = []string{"bash", "-c", code}
case "python3", "python":
interpreter = []string{"python3", "-c", code}
default:
return "", fmt.Errorf("unsupported language: %s (supported: bash, python3)", language)
}
wrapped := sandbox.WrapCommand(cfg, interpreter, workDir)
cmd = exec.CommandContext(ctx, wrapped[0], wrapped[1:]...)
cmd.Dir = workDir
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
cmd.Cancel = func() error {
if cmd.Process != nil {
syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
}
return nil
}
var outputBuf bytes.Buffer
lw := &limitedWriter{w: &outputBuf, limit: 10 * 1024 * 1024}
cmd.Stdout = lw
cmd.Stderr = lw
err = cmd.Run()
output := outputBuf.Bytes()
if lw.truncated {
output = append(output, []byte("\n[output truncated at 10MB]")...)
}
if ctx.Err() == context.DeadlineExceeded {
return "", fmt.Errorf("execution timeout after %d seconds", timeout)
}
if err != nil {
return string(output), fmt.Errorf("execution failed: %v\nOutput: %s", err, string(output))
}
return string(output), nil
}
type limitedWriter struct {
w io.Writer
written int
limit int
truncated bool
}
func (lw *limitedWriter) Write(p []byte) (n int, err error) {
if lw.written >= lw.limit {
lw.truncated = true
return len(p), nil
}
remaining := lw.limit - lw.written
toWrite := p
if len(p) > remaining {
toWrite = p[:remaining]
lw.truncated = true
}
written, err := lw.w.Write(toWrite)
lw.written += written
if err != nil {
return written, err
}
return len(p), nil
}

View File

@ -1,6 +1,11 @@
package execute
// execute_pipe constructs a pipeline of execute_code and execute_tool calls
// to enable the composition of tools and arbitrary code.
import (
"context"
"encoding/json"
"fmt"
"strings"
)
@ -55,3 +60,33 @@ func BuildPipeline(steps []PipeStep) (string, bool, error) {
return strings.Join(parts, " |\n"), true, nil
}
func dispatchExecutePipe(ctx context.Context, e *Server, args json.RawMessage) (string, error) {
var a struct {
Pipe []PipeStep `json:"pipe"`
Timeout int `json:"timeout"`
Sandbox string `json:"sandbox"`
}
if err := json.Unmarshal(args, &a); err != nil {
return "", fmt.Errorf("execute_pipe: bad args: %w", err)
}
if len(a.Pipe) == 0 {
return "", fmt.Errorf("execute_pipe: 'pipe' is required")
}
code, _, err := BuildPipeline(a.Pipe)
if err != nil {
return "", err
}
if e.Confirm != nil && !e.Confirm(fmt.Sprintf("execute_pipe: %s", code)) {
return "", fmt.Errorf("execute_pipe: denied by user")
}
timeout := a.Timeout
if timeout <= 0 {
timeout = 30
}
sandbox := a.Sandbox
if sandbox == "" {
sandbox = "default"
}
return e.Execute(ctx, code, "bash", timeout, sandbox, true)
}

View File

@ -1,20 +1,99 @@
package execute
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"regexp"
"sync"
"syscall"
"time"
"ollie/internal/sandbox"
"ollie/pkg/tools"
)
// Server runs code in a sandboxed environment.
type Server struct {
// Confirm is an optional function called before executing sensitive operations.
// If it returns false, the operation is denied.
Confirm func(string) bool
// workdir is the working directory for sandboxed commands. If empty,
// the process working directory is used.
wdMu sync.RWMutex
workdir string
// rate limiting state (per-Server)
rateLimitMu sync.Mutex
validationFailures int
lastFailure time.Time
blockedUntil time.Time
}
// Decl returns a factory for an execute Server with the given working directory.
func Decl(workdir string) func() tools.Server {
return func() tools.Server { return New(workdir) }
}
// ListTools implements tools.Server.
// ListTools implements tools.Server, returning the ToolInfo definitions for the execute_* built-in tools.
func (e *Server) ListTools() ([]tools.ToolInfo, error) {
return tools.ExecuteDefs(ToolsPath()), nil
return []tools.ToolInfo{
{
Name: "execute_code",
Description: "Run inline code in a sandboxed environment.",
InputSchema: json.RawMessage(`{
"type": "object",
"required": ["code"],
"properties": {
"code": {"type": "string", "description": "Code to execute."},
"language": {"type": "string", "description": "Language interpreter (default: bash)."},
"timeout": {"type": "integer", "description": "Timeout in seconds (default: 30)."},
"sandbox": {"type": "string", "description": "Sandbox name (default: default)."}
}
}`),
},
{
Name: "execute_tool",
Description: "Run a named tool script from " + ToolsPath() + " in a sandboxed environment. Supported languages: bash, python3 (detected from shebang). Use this only for named scripts, not for inline shell commands.",
InputSchema: json.RawMessage(`{
"type": "object",
"required": ["tool"],
"properties": {
"tool": {"type": "string", "description": "Name of the tool script in the tools directory."},
"args": {"type": "array", "items": {"type": "string"}, "description": "Arguments for the tool script."},
"timeout": {"type": "integer", "description": "Timeout in seconds (default: 30)."},
"sandbox": {"type": "string", "description": "Sandbox name (default: default)."}
}
}`),
},
{
Name: "execute_pipe",
Description: "Run a pipeline of steps, piping stdout of each into stdin of the next. Use {code: \"cmd --flags\"} for shell commands; use {tool, args} only for named scripts in " + ToolsPath() + ".",
InputSchema: json.RawMessage(`{
"type": "object",
"required": ["pipe"],
"properties": {
"pipe": {
"type": "array",
"items": {
"type": "object",
"properties": {
"tool": {"type": "string"},
"args": {"type": "array", "items": {"type": "string"}},
"code": {"type": "string"}
}
}
},
"timeout": {"type": "integer", "description": "Timeout in seconds (default: 30)."},
"sandbox": {"type": "string", "description": "Sandbox name (default: default)."}
}
}`),
},
}, nil
}
// CallTool implements tools.Server.
@ -28,6 +107,122 @@ func (e *Server) CallTool(ctx context.Context, tool string, args json.RawMessage
})
}
// New creates a new Server with the given working directory.
func New(workdir string) *Server { return &Server{workdir: workdir} }
// SetWorkDir updates the working directory used for subsequent command executions.
func (e *Server) SetWorkDir(dir string) {
e.wdMu.Lock()
e.workdir = dir
e.wdMu.Unlock()
}
var whitespacePattern = regexp.MustCompile(`\s+`)
const (
maxFailures = 5
blockDuration = 30 * time.Second
failureWindow = 60 * time.Second
)
func (e *Server) checkRateLimit() error {
e.rateLimitMu.Lock()
defer e.rateLimitMu.Unlock()
now := time.Now()
if now.Before(e.blockedUntil) {
remaining := e.blockedUntil.Sub(now).Round(time.Second)
return fmt.Errorf("rate limited: too many validation failures, blocked for %v", remaining)
}
return nil
}
func (e *Server) recordValidationFailure() {
e.rateLimitMu.Lock()
defer e.rateLimitMu.Unlock()
now := time.Now()
if now.Sub(e.lastFailure) > failureWindow {
e.validationFailures = 0
}
e.validationFailures++
e.lastFailure = now
if e.validationFailures >= maxFailures {
e.blockedUntil = now.Add(blockDuration)
e.validationFailures = 0
}
}
// Execute runs code in a sandbox and returns combined stdout+stderr.
func (e *Server) Execute(ctx context.Context, code, language string, timeout int, sandboxName string, trusted bool) (string, error) {
if timeout <= 0 {
timeout = 30
}
if !trusted {
if err := e.ValidateCode(code); err != nil {
return "", err
}
}
cfg, err := loadLayeredConfig(sandboxName)
if err != nil {
return "", err
}
e.wdMu.RLock()
workDir := e.workdir
e.wdMu.RUnlock()
if workDir == "" {
workDir, _ = os.Getwd()
}
ctx, cancel := context.WithTimeout(ctx, time.Duration(timeout)*time.Second)
defer cancel()
var cmd *exec.Cmd
var interpreter []string
switch language {
case "bash", "":
interpreter = []string{"bash", "-c", code}
case "python3", "python":
interpreter = []string{"python3", "-c", code}
default:
return "", fmt.Errorf("unsupported language: %s (supported: bash, python3)", language)
}
wrapped := sandbox.WrapCommand(cfg, interpreter, workDir)
cmd = exec.CommandContext(ctx, wrapped[0], wrapped[1:]...)
cmd.Dir = workDir
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
cmd.Cancel = func() error {
if cmd.Process != nil {
syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
}
return nil
}
var outputBuf bytes.Buffer
lw := &limitedWriter{w: &outputBuf, limit: 10 * 1024 * 1024}
cmd.Stdout = lw
cmd.Stderr = lw
err = cmd.Run()
output := outputBuf.Bytes()
if lw.truncated {
output = append(output, []byte("\n[output truncated at 10MB]")...)
}
if ctx.Err() == context.DeadlineExceeded {
return "", fmt.Errorf("execution timeout after %d seconds", timeout)
}
if err != nil {
return string(output), fmt.Errorf("execution failed: %v\nOutput: %s", err, string(output))
}
return string(output), nil
}
// Close implements tools.Server (no-op).
func (e *Server) Close() {}

View File

@ -1,6 +1,12 @@
package execute
// execute_tool is a specific case of execute_code, such that the name of the
// script passed to `tool` is loaded into memory from `$OLLIE_9MOUNT/t/` and
// run with Execute(...).
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
@ -84,3 +90,39 @@ func ReadTool(name string) (string, error) {
}
return string(data), nil
}
func dispatchExecuteTool(ctx context.Context, e *Server, args json.RawMessage) (string, error) {
var a struct {
Tool string `json:"tool"`
Args []string `json:"args"`
Timeout int `json:"timeout"`
Sandbox string `json:"sandbox"`
}
if err := json.Unmarshal(args, &a); err != nil {
return "", fmt.Errorf("execute_tool: bad args: %w", err)
}
if a.Tool == "" {
return "", fmt.Errorf("execute_tool: 'tool' is required")
}
if e.Confirm != nil && !e.Confirm(fmt.Sprintf("execute_tool: %s %s", a.Tool, strings.Join(a.Args, " "))) {
return "", fmt.Errorf("execute_tool: denied by user")
}
toolCode, err := ReadTool(a.Tool)
if err != nil {
return "", err
}
language := detectLanguage(toolCode)
code := toolCode
if len(a.Args) > 0 {
code = injectArgs(language, a.Tool, a.Args, toolCode)
}
timeout := a.Timeout
if timeout <= 0 {
timeout = 30
}
sandbox := a.Sandbox
if sandbox == "" {
sandbox = "default"
}
return e.Execute(ctx, code, language, timeout, sandbox, true)
}

View File

@ -2,7 +2,9 @@ package reasoning
import (
"context"
"encoding/json"
"fmt"
"ollie/pkg/tools"
"os"
"path/filepath"
"regexp"
@ -10,6 +12,61 @@ import (
"time"
)
// SetMemoryBackend implements tools.MemoryBackendSetter.
func (s *Server) SetMemoryBackend(b tools.MemoryBackend) { s.Memory = b }
var ToolRemember = tools.ToolInfo{
Name: "memory_remember",
Description: `Save a memory that persists across sessions.
Provide a descriptive title, relevant tags, and a concise body. Choose tags carefully because they strongly influence later recall. Prefer specific, stable terms over generic ones. Store durable facts, decisions, preferences, or context — not raw transcripts or temporary chatter.`,
InputSchema: json.RawMessage(`{
"type": "object",
"additionalProperties": false,
"required": ["title", "tags", "body"],
"properties": {
"title": {
"type": "string",
"minLength": 3,
"description": "Descriptive title that can stand alone and answer 'what is this about?'"
},
"tags": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": {
"type": "string",
"minLength": 2
},
"description": "Specific, stable recall tags you would search for later. Prefer concrete topics, people, projects, decisions, or preferences."
},
"body": {
"type": "string",
"minLength": 8,
"description": "Concise memory content: key facts, decisions, preferences, and essential context. Not a transcript."
}
}
}`),
}
var ToolRecall = tools.ToolInfo{
Name: "memory_recall",
Description: `Search saved memories by keyword.
Search matches titles and tags only; body content is not searched. Use short, specific terms likely to appear literally in titles or tags at write time. Prefer names, project identifiers, stable topics, or exact tags over full sentences or vague descriptions.`,
InputSchema: json.RawMessage(`{
"type": "object",
"additionalProperties": false,
"required": ["query"],
"properties": {
"query": {
"type": "string",
"minLength": 2,
"description": "Short keyword or phrase to search for in memory titles and tags. Prefer literal terms such as names, projects, topics, or exact tags."
}
}
}`),
}
// FlatDirBackend implements tools.MemoryBackend using a plain directory of
// markdown files. Filenames follow a denote-like convention:
//

View File

@ -0,0 +1,87 @@
package reasoning
import (
"encoding/json"
"ollie/pkg/tools"
)
var ToolPlan = tools.ToolInfo{
Name: "reasoning_plan",
Description: `Executive planning tool.
Use this tool before taking action whenever the task is non-trivial: specifically, when it requires multiple steps, has dependencies, may produce side effects, benefits from persistence/resumption, or the correct order is not obvious.
First, create a complete ordered plan for the current goal. The plan must:
- Break the goal into concrete, outcome-oriented steps
- Order steps by execution sequence
- Record explicit dependencies
- Allow dependencies only on earlier steps (lower index)
- Keep steps small enough to execute or verify independently
- Avoid redundant or purely decorative steps
Each step should include:
- index
- short title
- description
- depends_on (list of earlier step indexes only)
- acceptance_criteria
If a task backend is available via ` + "`task_create`" + `, persist the plan and steps there and use returned task IDs in subsequent updates. If no backend is available, maintain the same ordered plan in memory and execute it sequentially.
Use ` + "`reasoning_plan`" + `only to create or revise the overall work breakdown. Use ` + "`reasoning_think`" + ` for short, local reflection during execution. Do not substitute moment-to-moment reasoning for a full plan when planning is required.
Re-plan only if:
- a step fails,
- new information changes the goal or constraints,
- dependencies were incorrect or incomplete,
- or a better decomposition becomes necessary.
Do not begin executing dependent work until its dependencies are complete.
`,
InputSchema: json.RawMessage(`{
"type": "object",
"additionalProperties": false,
"required": ["goal", "steps"],
"properties": {
"goal": {
"type": "string",
"description": "Top-level goal or objective."
},
"steps": {
"type": "array",
"description": "Ordered plan steps. Dependencies may only reference earlier 0-based step indexes.",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["title"],
"properties": {
"title": {
"type": "string",
"description": "Short, outcome-oriented step title."
},
"description": {
"type": "string",
"description": "Optional implementation detail for the step."
},
"depends_on": {
"type": "array",
"description": "0-based indexes of earlier steps that must complete before this one.",
"items": {
"type": "integer",
"minimum": 0
},
"default": []
},
"acceptance_criteria": {
"type": "string",
"description": "How to tell the step is complete or successful."
}
}
}
}
}
}`),
}
// SetPlanBackend implements tools.PlanBackendSetter.
func (s *Server) SetPlanBackend(b tools.PlanBackend) { s.Plan = b }

View File

@ -15,7 +15,9 @@ func Decl() func() tools.Server { return func() tools.Server { return &Server{}
// Server implements tools.Server for reasoning tools.
type Server struct {
// Plan is the optional task persistence backend. When nil, reasoning_plan
// produces an in-context plan only.
// produces an in-context plan only. This in-context plan queues each step
// of the plan, in order, as prompts for the model to execute when the
// planning turn finishes.
Plan tools.PlanBackend
// Memory is the memory persistence backend. Always non-nil after
@ -23,22 +25,21 @@ type Server struct {
Memory tools.MemoryBackend
}
// SetPlanBackend implements tools.PlanBackendSetter.
func (s *Server) SetPlanBackend(b tools.PlanBackend) { s.Plan = b }
// SetMemoryBackend implements tools.MemoryBackendSetter.
func (s *Server) SetMemoryBackend(b tools.MemoryBackend) { s.Memory = b }
// ListTools implements tools.Server.
func (s *Server) ListTools() ([]tools.ToolInfo, error) {
return append(tools.ReasoningDefs(), tools.MemoryDefs()...), nil
return []tools.ToolInfo{
ToolThink,
ToolPlan,
ToolRemember,
ToolRecall,
}, nil
}
// CallTool implements tools.Server.
func (s *Server) CallTool(ctx context.Context, tool string, args json.RawMessage) (json.RawMessage, error) {
var text string
switch tool {
case "reasoning_think":
case ToolThink.Name:
var a struct {
Thought string `json:"thought"`
}
@ -46,19 +47,19 @@ func (s *Server) CallTool(ctx context.Context, tool string, args json.RawMessage
text = "error: missing required field 'thought'"
}
// No-op: the thought is recorded in conversation history by the loop.
case "reasoning_plan":
case ToolPlan.Name:
var err error
text, err = s.plan(ctx, args)
if err != nil {
text = "error: " + err.Error()
}
case "memory_remember":
case ToolRemember.Name:
var err error
text, err = s.remember(ctx, args)
if err != nil {
text = "error: " + err.Error()
}
case "memory_recall":
case ToolRecall.Name:
var err error
text, err = s.recall(ctx, args)
if err != nil {
@ -189,6 +190,7 @@ func (s *Server) recall(ctx context.Context, args json.RawMessage) (string, erro
return s.Memory.Recall(ctx, a.Query)
}
// Compile-time interface checks
var _ tools.Server = (*Server)(nil)
var _ tools.PlanBackendSetter = (*Server)(nil)
var _ tools.MemoryBackendSetter = (*Server)(nil)

View File

@ -0,0 +1,20 @@
package reasoning
import (
"encoding/json"
"ollie/pkg/tools"
)
// think is a no-op tool for reasoning models. it functions like an in-context "scratchpad".
var ToolThink = tools.ToolInfo{
Name: "reasoning_think",
Description: "Internal reasoning tool for breaking down problems, planning approaches, analyzing constraints, and reasoning through multi-step solutions before acting. The thought content is not shown to the user.",
InputSchema: json.RawMessage(`{
"type": "object",
"required": ["thought"],
"properties": {
"thought": {"type": "string", "description": "A reflective note or intermediate reasoning step."}
}
}`),
}