multiplex toolsrv, wip zero-tool server

This commit is contained in:
Ollie Agent 2026-08-02 19:18:39 +02:00
parent 7b5573ee89
commit e25157ffcd
34 changed files with 740 additions and 533 deletions

View File

@ -454,6 +454,12 @@ func (ag *Agent) execServer() toolsrv.Runner {
return ag.runtime.ToolServer
}
// ToolServer returns the tool execution server, or nil if unavailable.
// Exported for use by the 9P filesystem layer to sync tool registries.
func (ag *Agent) ToolServer() toolsrv.Runner {
return ag.execServer()
}
// Queue pushes a prompt onto the agent's FIFO.
func (ag *Agent) Queue(prompt string) {
ag.fifo.Push(prompt)

View File

@ -65,6 +65,8 @@ type AgentConfig struct {
Model string `json:"model,omitempty"`
Tools *bool `json:"tools,omitempty"`
AllowTools []string `json:"allowTools,omitempty"`
AutoLoad []string `json:"autoLoad,omitempty"`
DisallowTools []string `json:"disallowTools,omitempty"`
MaxTokens int `json:"maxTokens,omitempty"`
MaxCompletionTokens int `json:"maxCompletionTokens,omitempty"`
// MaxSteps caps the number of tool-call rounds per turn. 0 means unlimited.

View File

@ -29,11 +29,8 @@ func BuildRuntime(cfg *AgentConfig, srv toolsrv.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 toolsrv.
// Only loaded tools become backend callable tools.
allTools = toolInfosToBackend(allToolInfos)
// Append named tool scripts for preamble listing only.
allToolInfos = append(allToolInfos, toolsrv.DiscoverTools()...)
}
hooks := Hooks{}
@ -139,8 +136,11 @@ func BuildRuntime(cfg *AgentConfig, srv toolsrv.Runner, cwd string, env []string
preamble += "\n# Available Tools\n\n" + toolListing.String()
}
// Append 9P discovery instructions for loading additional tools.
preamble += "\n## Tool discovery\n\nUse the 9P filesystem to discover and load tools dynamically:\n- **List available tools** — `ollie-9p read session/{id}/agent/{aid}/tools`\n- **Load a tool** — `echo \"toolname\" | ollie-9p write session/{id}/agent/{aid}/tools`\n- **See loaded tools** — `ollie-9p read session/{id}/agent/{aid}/tools.loaded`\n"
return &Runtime{
ToolServer: srv,
ToolServer: srv,
Tools: allTools,
Exec: exec,
ClassifyTool: classify,

View File

@ -29,8 +29,8 @@ import (
"sync"
"syscall"
"ollie/toolsrv"
"ollie/tools/builtin"
"ollie/toolsrv"
)
//go:embed sandbox/default.yaml
@ -98,6 +98,15 @@ func main() {
}
// Signal handling
// Auto-load essential core tools into the registry so they are immediately
// available for agent execution without requiring a separate tool_load call.
for _, name := range []string{"shell", "reasoning_think"} {
if err := server.LoadTool(name); err != nil {
fmt.Fprintf(os.Stderr, "auto-load %s: %v\n", name, err)
}
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sigCh := make(chan os.Signal, 1)
@ -194,6 +203,7 @@ type rpcResponse struct {
ID json.RawMessage `json:"id"`
Result any `json:"result,omitempty"`
Error *rpcError `json:"error,omitempty"`
Stream bool `json:"stream,omitempty"`
}
type rpcError struct {
@ -205,9 +215,20 @@ type outputNotif struct {
Data string `json:"data"`
}
type lockedEncoder struct {
mu sync.Mutex
enc *json.Encoder
}
func (e *lockedEncoder) Encode(v any) error {
e.mu.Lock()
defer e.mu.Unlock()
return e.enc.Encode(v)
}
func serveRPC(ctx context.Context, srv *toolsrv.Server, in io.Reader, out io.Writer) {
dec := json.NewDecoder(in)
enc := json.NewEncoder(out)
enc := &lockedEncoder{enc: json.NewEncoder(out)}
for {
var req rpcRequest
@ -223,180 +244,203 @@ func serveRPC(ctx context.Context, srv *toolsrv.Server, in io.Reader, out io.Wri
continue
}
switch req.Method {
case "list_tools":
tools, err := srv.ListTools()
if err != nil {
enc.Encode(rpcResponse{
JSONRPC: "2.0",
ID: req.ID,
Error: &rpcError{Code: -32000, Message: err.Error()},
})
} else {
enc.Encode(rpcResponse{
JSONRPC: "2.0",
ID: req.ID,
Result: tools,
})
}
go handleRPC(ctx, srv, req, enc)
}
}
case "host_info":
info := map[string]any{
"platform": runtime.GOOS,
"arch": runtime.GOARCH,
"is_git_repo": isGitRepo(*cwd),
}
func handleRPC(ctx context.Context, srv *toolsrv.Server, req rpcRequest, enc *lockedEncoder) {
switch req.Method {
case "list_tools":
tools, err := srv.ListTools()
if err != nil {
enc.Encode(rpcResponse{
JSONRPC: "2.0",
ID: req.ID,
Result: info,
Error: &rpcError{Code: -32000, Message: err.Error()},
})
case "ping":
} else {
enc.Encode(rpcResponse{
JSONRPC: "2.0",
ID: req.ID,
Result: "pong",
Result: tools,
})
}
case "set_env":
var params struct {
Key string `json:"key"`
Value string `json:"value"`
}
if err := json.Unmarshal(req.Params, &params); err != nil {
enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Error: &rpcError{Code: -32602, Message: err.Error()}})
continue
}
srv.SetEnv(params.Key, params.Value)
case "host_info":
info := map[string]any{
"platform": runtime.GOOS,
"arch": runtime.GOARCH,
"is_git_repo": isGitRepo(*cwd),
}
enc.Encode(rpcResponse{
JSONRPC: "2.0",
ID: req.ID,
Result: info,
})
case "ping":
enc.Encode(rpcResponse{
JSONRPC: "2.0",
ID: req.ID,
Result: "pong",
})
case "set_env":
var params struct {
Key string `json:"key"`
Value string `json:"value"`
}
if err := json.Unmarshal(req.Params, &params); err != nil {
enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Error: &rpcError{Code: -32602, Message: err.Error()}})
return
}
srv.SetEnv(params.Key, params.Value)
enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: true})
case "set_cwd":
var params struct {
Dir string `json:"dir"`
}
if err := json.Unmarshal(req.Params, &params); err != nil {
enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Error: &rpcError{Code: -32602, Message: err.Error()}})
return
}
srv.SetCWD(params.Dir)
enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: true})
case "set_allow_tools":
var params struct {
Names []string `json:"names"`
}
if err := json.Unmarshal(req.Params, &params); err != nil {
enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Error: &rpcError{Code: -32602, Message: err.Error()}})
return
}
srv.SetAllowTools(params.Names)
enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: true})
case "detach":
ok := srv.Detach()
enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: ok})
case "list_detached":
enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: srv.ListDetachedRaw()})
case "signal_detached":
var params struct {
PID int `json:"pid"`
Signal int `json:"signal"`
}
if err := json.Unmarshal(req.Params, &params); err != nil {
enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Error: &rpcError{Code: -32602, Message: err.Error()}})
return
}
if err := srv.SignalDetached(params.PID, syscall.Signal(params.Signal)); err != nil {
enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Error: &rpcError{Code: -32000, Message: err.Error()}})
} else {
enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: true})
}
case "set_cwd":
var params struct {
Dir string `json:"dir"`
}
if err := json.Unmarshal(req.Params, &params); err != nil {
enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Error: &rpcError{Code: -32602, Message: err.Error()}})
continue
}
srv.SetCWD(params.Dir)
enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: true})
case "get_detached_output":
var params struct {
PID int `json:"pid"`
}
if err := json.Unmarshal(req.Params, &params); err != nil {
enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Error: &rpcError{Code: -32602, Message: err.Error()}})
return
}
out, err := srv.GetDetachedOutput(params.PID)
if err != nil {
enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Error: &rpcError{Code: -32000, Message: err.Error()}})
} else {
enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: out})
}
case "set_allow_tools":
var params struct {
Names []string `json:"names"`
}
if err := json.Unmarshal(req.Params, &params); err != nil {
enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Error: &rpcError{Code: -32602, Message: err.Error()}})
continue
}
srv.SetAllowTools(params.Names)
enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: true})
case "dismiss_detached":
var params struct {
PID int `json:"pid"`
}
if err := json.Unmarshal(req.Params, &params); err != nil {
enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Error: &rpcError{Code: -32602, Message: err.Error()}})
return
}
ok := srv.DismissDetached(params.PID)
enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: ok})
case "detach":
ok := srv.Detach()
enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: ok})
case "is_parallel_read":
var params struct {
Name string `json:"name"`
}
if err := json.Unmarshal(req.Params, &params); err != nil {
enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Error: &rpcError{Code: -32602, Message: err.Error()}})
return
}
ok := srv.IsParallelRead(params.Name)
enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: ok})
case "list_detached":
enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: srv.ListDetachedRaw()})
case "result_tier":
var params struct {
Name string `json:"name"`
}
if err := json.Unmarshal(req.Params, &params); err != nil {
enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Error: &rpcError{Code: -32602, Message: err.Error()}})
return
}
tier := srv.ResultTier(params.Name)
enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: tier})
case "signal_detached":
var params struct {
PID int `json:"pid"`
Signal int `json:"signal"`
}
if err := json.Unmarshal(req.Params, &params); err != nil {
enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Error: &rpcError{Code: -32602, Message: err.Error()}})
continue
}
if err := srv.SignalDetached(params.PID, syscall.Signal(params.Signal)); err != nil {
enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Error: &rpcError{Code: -32000, Message: err.Error()}})
} else {
enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: true})
}
case "result_tier_args":
var params struct {
Name string `json:"name"`
Args json.RawMessage `json:"args"`
}
if err := json.Unmarshal(req.Params, &params); err != nil {
enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Error: &rpcError{Code: -32602, Message: err.Error()}})
return
}
tier := srv.ResultTierArgs(params.Name, params.Args)
enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: tier})
case "get_detached_output":
var params struct {
PID int `json:"pid"`
}
if err := json.Unmarshal(req.Params, &params); err != nil {
enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Error: &rpcError{Code: -32602, Message: err.Error()}})
continue
}
out, err := srv.GetDetachedOutput(params.PID)
if err != nil {
enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Error: &rpcError{Code: -32000, Message: err.Error()}})
} else {
enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: out})
}
case "tool_load":
var params struct {
Name string `json:"name"`
}
if err := json.Unmarshal(req.Params, &params); err != nil {
enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Error: &rpcError{Code: -32602, Message: err.Error()}})
return
}
if err := srv.LoadTool(params.Name); err != nil {
enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Error: &rpcError{Code: -32000, Message: err.Error()}})
} else {
res, _ := json.Marshal(map[string]string{"result": "loaded: " + params.Name})
enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: json.RawMessage(res)})
}
case "dismiss_detached":
var params struct {
PID int `json:"pid"`
}
if err := json.Unmarshal(req.Params, &params); err != nil {
enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Error: &rpcError{Code: -32602, Message: err.Error()}})
continue
}
ok := srv.DismissDetached(params.PID)
enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: ok})
case "is_parallel_read":
var params struct {
Name string `json:"name"`
}
if err := json.Unmarshal(req.Params, &params); err != nil {
enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Error: &rpcError{Code: -32602, Message: err.Error()}})
continue
}
ok := srv.IsParallelRead(params.Name)
enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: ok})
case "result_tier":
var params struct {
Name string `json:"name"`
}
if err := json.Unmarshal(req.Params, &params); err != nil {
enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Error: &rpcError{Code: -32602, Message: err.Error()}})
continue
}
tier := srv.ResultTier(params.Name)
enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: tier})
case "result_tier_args":
var params struct {
Name string `json:"name"`
Args json.RawMessage `json:"args"`
}
if err := json.Unmarshal(req.Params, &params); err != nil {
enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Error: &rpcError{Code: -32602, Message: err.Error()}})
continue
}
tier := srv.ResultTierArgs(params.Name, params.Args)
enc.Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: tier})
default:
// Dispatch to named tool via the tool server's CallTool.
streamCtx := toolsrv.WithOutputStream(ctx, func(data string) {
enc.Encode(rpcResponse{
JSONRPC: "2.0",
Result: outputNotif{Data: data},
})
default:
// Dispatch to named tool via the tool server's CallTool.
// Correlate streaming output with the request so multiplexed
// clients can route it to the correct caller.
streamCtx := toolsrv.WithOutputStream(ctx, func(data string) {
enc.Encode(rpcResponse{
JSONRPC: "2.0",
ID: req.ID,
Result: outputNotif{Data: data},
Stream: true,
})
})
result, err := srv.CallTool(streamCtx, req.Method, req.Params)
if err != nil {
enc.Encode(rpcResponse{
JSONRPC: "2.0",
ID: req.ID,
Error: &rpcError{Code: -32000, Message: err.Error()},
})
} else {
enc.Encode(rpcResponse{
JSONRPC: "2.0",
ID: req.ID,
Result: json.RawMessage(result),
})
result, err := srv.CallTool(streamCtx, req.Method, req.Params)
if err != nil {
enc.Encode(rpcResponse{
JSONRPC: "2.0",
ID: req.ID,
Error: &rpcError{Code: -32000, Message: err.Error()},
})
} else {
enc.Encode(rpcResponse{
JSONRPC: "2.0",
ID: req.ID,
Result: json.RawMessage(result),
})
}
}
}
}

View File

@ -101,12 +101,6 @@ func runServer(sockPath string) {
agentsDirs := agent.AgentsDirs()
sessionsDir := paths.DataDir() + "/sessions"
// Create the tool registry
toolRegistry, regErr := toolsrv.NewRegistry()
if regErr != nil {
fmt.Fprintf(os.Stderr, "warning: tool registry: %v\n", regErr)
}
// Create the skills registry
skillsRegistry := skills.NewRegistry()
@ -120,7 +114,6 @@ func runServer(sockPath string) {
rootTree := fs.NewRoot(fs.Config{
Ctx: daemonCtx,
ToolRegistry: toolRegistry,
SkillsRegistry: skillsRegistry,
AgentsDir: agentsDirs[0],
SessionsDir: sessionsDir,

View File

@ -3,11 +3,17 @@
"$OLLIE_CFG_PATH/prompts/user-preferences.md",
"$OLLIE_CFG_PATH/prompts/agent-copilot.md"
],
"tools": true,
"autoLoad": [
"shell",
"reasoning_think",
"file_read",
"file_glob",
"file_grep"
],
"allowTools": ["file_read", "file_glob", "file_grep"],
"hooks": {
"turnError": [
"$OLLIE_CFG_PATH/scripts/x/freeloader $OLLIE_SESSION_ID"
]
}
}
}

View File

@ -4,6 +4,38 @@
"$OLLIE_CFG_PATH/prompts/agent-coding.md",
"bash -c 'd=$PWD; while [ \"$d\" != / ]; do [ -d \"$d/.beads\" ] && exec bd prime; [ ! -e \"$d/.git\" ] && break; d=$(dirname \"$d\"); done' 2>/dev/null || true"
],
"autoLoad": [
"shell",
"reasoning_think",
"file_read",
"file_write",
"file_edit",
"file_glob",
"file_grep",
"memory_recall",
"memory_remember",
"lsp_definition",
"lsp_diagnostics",
"lsp_hover",
"lsp_references",
"lsp_symbols",
"gui_screenshot",
"gui_windows",
"gui_clipboard",
"gui_notify",
"image_read",
"skill_list",
"skill_load",
"subagent_generate",
"process_list",
"process_output",
"process_signal",
"process_dismiss",
"logseq",
"system_logs",
"browser_screencap",
"route"
],
"maxSteps": 50,
"temperature": 0.5,
"maxTokens": 16384,
@ -16,4 +48,4 @@
"$OLLIE_CFG_PATH/scripts/x/freeloader $OLLIE_SESSION_ID"
]
}
}
}

View File

@ -4,6 +4,38 @@
"$OLLIE_CFG_PATH/prompts/agent-coding.md",
"bash -c 'd=$PWD; while [ \"$d\" != / ]; do [ -d \"$d/.beads\" ] && exec bd prime; [ ! -e \"$d/.git\" ] && break; d=$(dirname \"$d\"); done' 2>/dev/null || true"
],
"autoLoad": [
"shell",
"reasoning_think",
"file_read",
"file_write",
"file_edit",
"file_glob",
"file_grep",
"memory_recall",
"memory_remember",
"lsp_definition",
"lsp_diagnostics",
"lsp_hover",
"lsp_references",
"lsp_symbols",
"gui_screenshot",
"gui_windows",
"gui_clipboard",
"gui_notify",
"image_read",
"skill_list",
"skill_load",
"subagent_generate",
"process_list",
"process_output",
"process_signal",
"process_dismiss",
"logseq",
"system_logs",
"browser_screencap",
"route"
],
"backend": "openrouter",
"model": "deepseek/deepseek-v4-flash",
"maxSteps": 50,
@ -14,4 +46,4 @@
"$OLLIE_CFG_PATH/scripts/x/freeloader $OLLIE_SESSION_ID"
]
}
}
}

View File

@ -1,7 +1,19 @@
{
"prompt": [
"$OLLIE_CFG_PATH/prompts/user-preferences.md",
"$OLLIE_CFG_PATH/prompts/agent-explorer.md",
"$OLLIE_CFG_PATH/prompts/agent-explorer.md"
],
"autoLoad": [
"shell",
"reasoning_think",
"file_read",
"file_glob",
"file_grep",
"gui_screenshot",
"gui_windows",
"gui_clipboard",
"gui_notify",
"image_read"
],
"maxSteps": 40,
"temperature": 0.3,
@ -11,4 +23,4 @@
"$OLLIE_CFG_PATH/scripts/x/freeloader $OLLIE_SESSION_ID"
]
}
}
}

View File

@ -1,11 +1,17 @@
{
"prompt": [
"$OLLIE_CFG_PATH/prompts/user-preferences.md",
"$OLLIE_CFG_PATH/prompts/agent-librarian.md",
"$OLLIE_CFG_PATH/prompts/agent-librarian.md"
],
"autoLoad": [
"shell",
"reasoning_think",
"file_read",
"file_glob",
"file_grep",
"memory_recall",
"memory_remember"
],
"maxSteps": 20,
"temperature": 0.3,
"maxTokens": 16384,
"allowTools": [
"file_read",
"file_glob",
@ -13,9 +19,12 @@
"memory_recall",
"memory_remember"
],
"maxSteps": 20,
"temperature": 0.3,
"maxTokens": 16384,
"hooks": {
"turnError": [
"$OLLIE_CFG_PATH/scripts/x/freeloader $OLLIE_SESSION_ID"
]
}
}
}

View File

@ -1,7 +1,20 @@
{
"prompt": [
"$OLLIE_CFG_PATH/prompts/user-preferences.md",
"$OLLIE_CFG_PATH/prompts/agent-navigator.md",
"$OLLIE_CFG_PATH/prompts/agent-navigator.md"
],
"autoLoad": [
"shell",
"reasoning_think",
"file_read",
"file_glob",
"file_grep",
"gui_screenshot",
"gui_windows",
"gui_clipboard",
"gui_notify",
"image_read",
"browser_screencap"
],
"temperature": 0.3,
"maxTokens": 8192,
@ -11,4 +24,4 @@
"$OLLIE_CFG_PATH/scripts/x/freeloader $OLLIE_SESSION_ID"
]
}
}
}

View File

@ -1,11 +1,21 @@
{
"prompt": [
"$OLLIE_CFG_PATH/prompts/user-preferences.md",
"$OLLIE_CFG_PATH/prompts/agent-taskmanager.md",
"$OLLIE_CFG_PATH/prompts/agent-taskmanager.md"
],
"autoLoad": [
"shell",
"reasoning_think",
"file_read",
"file_glob",
"file_grep",
"file_write",
"file_edit",
"subagent_generate",
"subagent_spawn",
"memory_recall",
"memory_remember"
],
"maxSteps": 20,
"temperature": 0.3,
"maxTokens": 8192,
"allowTools": [
"file_read",
"file_glob",
@ -17,9 +27,12 @@
"memory_recall",
"memory_remember"
],
"maxSteps": 20,
"temperature": 0.3,
"maxTokens": 8192,
"hooks": {
"turnError": [
"$OLLIE_CFG_PATH/scripts/x/freeloader $OLLIE_SESSION_ID"
]
}
}
}

View File

@ -1,7 +1,30 @@
{
"prompt": [
"$OLLIE_CFG_PATH/prompts/user-preferences.md",
"$OLLIE_CFG_PATH/prompts/agent-theo.md",
"$OLLIE_CFG_PATH/prompts/agent-theo.md"
],
"autoLoad": [
"shell",
"reasoning_think",
"file_read",
"file_glob",
"file_grep",
"file_write",
"file_edit",
"gui_screenshot",
"gui_windows",
"gui_clipboard",
"gui_notify",
"image_read",
"memory_recall",
"memory_remember",
"lsp_definition",
"lsp_diagnostics",
"lsp_hover",
"lsp_references",
"lsp_symbols",
"skill_list",
"skill_load"
],
"maxSteps": 30,
"temperature": 0.3,
@ -11,4 +34,4 @@
"$OLLIE_CFG_PATH/scripts/x/freeloader $OLLIE_SESSION_ID"
]
}
}
}

6
data/tools/reasoning_think Executable file
View File

@ -0,0 +1,6 @@
#!/bin/bash
# reasoning_think — scratchpad for externalizing reasoning.
# The thought is recorded in conversation history but not shown to the user.
# Takes up practically zero context space — the value is in writing it.
# This is a no-op: the LLM handler logs the thought internally.
exit 0

View File

@ -0,0 +1,16 @@
{
"description": "Scratchpad for externalizing reasoning. Recorded in history but not shown to the user.",
"prompt": "Scratchpad for externalizing reasoning. Recorded in conversation history but not shown to the user.",
"args": {
"type": "object",
"required": ["thought"],
"properties": {
"thought": {
"type": "string",
"description": "Your reasoning."
}
}
},
"tier": "hot",
"readOnly": true
}

33
data/tools/shell Executable file
View File

@ -0,0 +1,33 @@
#!/bin/bash
# Shell — execute a single bash command in a sandboxed environment.
# Already running inside the sandbox applied by toolsrv.
# Reads JSON args from stdin: {"cmd": "...", "timeout": 30, "elevated": false}
#
# This is the bootstrap primitive. All other tool scripts use this
# to execute commands on the system.
set -euo pipefail
# Parse JSON args from stdin.
args=$(cat)
# Extract fields using python (preferred) or jq (fallback).
if command -v python3 &>/dev/null; then
cmd=$(echo "$args" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('cmd',''))")
elif command -v python &>/dev/null; then
cmd=$(echo "$args" | python -c "import json,sys; d=json.load(sys.stdin); print(d.get('cmd',''))")
elif command -v jq &>/dev/null; then
cmd=$(echo "$args" | jq -r '.cmd // ""')
else
echo '{"isError":true,"content":[{"type":"text","text":"shell: need python3, python, or jq to parse args"}]}'
exit 1
fi
if [ -z "$cmd" ]; then
echo '{"isError":true,"content":[{"type":"text","text":"shell: cmd is required"}]}'
exit 1
fi
# Execute the command. The sandbox (landlock) is already applied by toolsrv.
# We use eval to handle pipes, redirects, and compound commands.
eval "$cmd"

28
data/tools/shell.meta Normal file
View File

@ -0,0 +1,28 @@
{
"description": "Execute a single bash command in a sandboxed environment.",
"prompt": "Execute a single bash command in a sandboxed environment.\n\nUsage: {\"cmd\": \"your command here\"}\n\nSandbox prevents dangerous operations. Use for computation, builds, scripting.\ntimeout applies to each call (default: 30s). A non-zero exit is an error.",
"args": {
"type": "object",
"required": ["cmd"],
"properties": {
"cmd": {
"type": "string",
"description": "Bash command to execute."
},
"timeout": {
"type": "integer",
"description": "Timeout in seconds (default: 30). Use 0 for no timeout."
},
"sandbox": {
"type": "string",
"description": "Sandbox profile name (default: default)."
},
"elevated": {
"type": "boolean",
"description": "Run outside the sandbox via elevation broker."
}
}
},
"tier": "hot",
"readOnly": false
}

View File

@ -227,61 +227,37 @@ func listAgents(ctx HandlerCtx) ([]FsNodeDecl, error) {
return []byte("#!/bin/sh\nexec tail -f \"$(dirname \"$0\")/chat\"\n"), nil
})),
// tools — list all available tools
Leaf("tools", 0666,
Read(func(ctx HandlerCtx) ([]byte, error) {
if ctx.ToolReg == nil {
return []byte("registry not available\n"), nil
}
var sb strings.Builder
for _, ti := range ctx.ToolReg.Summaries() {
if ti.Description != "" {
fmt.Fprintf(&sb, "%-20s %s\n", ti.Name, ti.Description)
} else {
sb.WriteString(ti.Name + "\n")
// tools — list all available tools (read) / load a tool (write)
Leaf("tools", 0666,
Read(func(ctx HandlerCtx) ([]byte, error) {
// Use the session's dedicated tools management connection,
// NOT a.ToolServer() which would deadlock if the agent
// loop is mid-execution (it holds the Conn mutex).
if s.toolsConn == nil {
return []byte("(no tool server)\n"), nil
}
}
return []byte(sb.String()), nil
}),
Write(func(ctx HandlerCtx, data []byte) error {
input := strings.TrimSpace(string(data))
if input == "" {
return nil
}
if ctx.ToolReg == nil {
return fmt.Errorf("registry not available")
}
return ctx.ToolReg.Load(s.id, input)
}),
),
loaded, err := s.toolsConn.ListTools()
if err != nil {
return []byte(fmt.Sprintf("(error: %v)\n", err)), nil
}
var sb strings.Builder
for _, ti := range loaded {
if ti.Description != "" {
fmt.Fprintf(&sb, "%-20s %s\n", ti.Name, ti.Description)
} else {
sb.WriteString(ti.Name + "\n")
}
}
return []byte(sb.String()), nil
}),
Write(func(ctx HandlerCtx, data []byte) error {
return s.loadTool(string(data), a)
}),
),
// toolsrv.loaded — currently loaded tools
Leaf("toolsrv.loaded", 0444, Read(func(ctx HandlerCtx) ([]byte, error) {
if ctx.ToolReg == nil {
return []byte("registry not available\n"), nil
}
loaded := ctx.ToolReg.Loaded(s.id)
if len(loaded) == 0 {
return []byte("(no tools loaded)\n"), nil
}
var sb strings.Builder
for _, ti := range loaded {
if ti.Description != "" {
fmt.Fprintf(&sb, "%-20s %s\n", ti.Name, ti.Description)
} else {
sb.WriteString(ti.Name + "\n")
}
}
return []byte(sb.String()), nil
})),
// toolsrv.rev — tool registry revision
Leaf("toolsrv.rev", 0444, Read(func(ctx HandlerCtx) ([]byte, error) {
if ctx.ToolReg == nil {
return []byte("0\n"), nil
}
return []byte(fmt.Sprintf("%d\n", ctx.ToolReg.Revision(s.id))), nil
})),
// name — agent name
Leaf("name", 0666,

View File

@ -510,7 +510,18 @@ func CreateAgent(rs *rootState, sessName string, args []string) error {
envBlock := prompts.Environment(cwd, platform, isGitRepo, "")
toolSrv := newToolServer()
// Load configured tools before building the runtime. BuildRuntime snapshots
// the loaded-tool list to construct the agent's initial tool definitions.
if cfg != nil {
for _, tl := range cfg.AutoLoad {
if err := loadToolOnRunner(toolSrv, tl); err != nil {
rs.cfg.Log.Error("session %s: autoLoad tool %q: %v", sessName, tl, err)
}
}
}
rt := agent.BuildRuntime(cfg, toolSrv, cwd, env, sysPrompt, opModel, envBlock)
sess.toolsConn = toolSrv
al := NewAgentLog(remoteTarget)
@ -602,4 +613,4 @@ func CreateAgentFromRoot(root *Tree, sessName string, args []string) (string, er
return "", fmt.Errorf("agent not created")
}
return sess.Core.Agent().Name(), nil
}
}

View File

@ -8,7 +8,6 @@ import (
"ollie/elevate"
olog "ollie/log"
"ollie/skills"
"ollie/toolsrv"
coresession "ollie/session"
)
@ -23,7 +22,6 @@ type Config struct {
MkdirAll func(string, os.FileMode) error
NewCore func(sessionID, agentName, cwd string) (*coresession.Session, error)
Yolo bool
ToolRegistry *toolsrv.Registry
SkillsRegistry *skills.Registry
ModelCache *ModelCache
ElevateBroker *elevate.Broker
@ -49,7 +47,6 @@ func NewRoot(cfg Config) *Tree {
ctx := HandlerCtx{
Root: nil, // set after tree is built
Log: cfg.Log,
ToolReg: cfg.ToolRegistry,
Skills: cfg.SkillsRegistry,
Models: cfg.ModelCache,
Elevate: cfg.ElevateBroker,

View File

@ -163,7 +163,7 @@ func restoreSession(rs *rootState, ps *agent.PersistedAgent) error {
promptEnv = []string{
"PRIME_CWD=" + cwd,
"PRIME_PLATFORM=" + proc.Info.Platform,
"PRIME_IS_GIT_REPO=" + fmt.Sprintf("%v", proc.Info.IsGitRepo),
"PRIME_IS_GIT_REPO=" + fmt.Sprintf("%v", proc.Info.IsGitRepo),
}
} else {
var dialOpts []toolsrv.Option
@ -212,6 +212,16 @@ func restoreSession(rs *rootState, ps *agent.PersistedAgent) error {
envBlock := prompts.Environment(cwd, platform, isGitRepo, "")
toolSrv := newToolServer()
// Load configured tools before building the runtime. BuildRuntime snapshots
// the loaded-tool list to construct the restored agent's tool definitions.
if cfg != nil {
for _, tl := range cfg.AutoLoad {
if err := loadToolOnRunner(toolSrv, tl); err != nil {
rs.cfg.Log.Error("session %s: autoLoad tool %q: %v", sessID, tl, err)
}
}
}
rt := agent.BuildRuntime(cfg, toolSrv, cwd, env, sysPrompt, opModel, envBlock)
restoredSession := agent.RestoreHistory(ps)
@ -253,9 +263,21 @@ func restoreSession(rs *rootState, ps *agent.PersistedAgent) error {
sess.uname = uname
sess.SetAgentLog(uname, al)
sess.proc = proc
// Use the multiplexed connection for both agent execution and 9P management.
sess.toolsConn = toolSrv
replayMessagesToLog(sess.AgentLog(), ps.Messages)
// Pre-load autoLoad tools into the remote.
if cfg != nil {
ag := sess.Core.Agent()
for _, tl := range cfg.AutoLoad {
if err := sess.loadTool(tl, ag); err != nil {
rs.cfg.Log.Error("session %s: autoLoad tool %q: %v", sessID, tl, err)
}
}
}
name := sess.Name()
rs.mu.Lock()
rs.sessions[name] = sess

View File

@ -10,6 +10,7 @@ import (
"ollie/agent"
"ollie/backend"
"ollie/paths"
"ollie/toolsrv"
)
// ── Root-level handlers ──────────────────────────────────────────
@ -51,6 +52,20 @@ func readAgents(ctx HandlerCtx) ([]byte, error) {
return []byte(sb.String()), nil
}
// readTools lists all discoverable tools from disk.
// This is a global catalog — the same for all sessions and agents.
func readTools(ctx HandlerCtx) ([]byte, error) {
tools := toolsrv.DiscoverTools()
if len(tools) == 0 {
return []byte("(no tools found)\n"), nil
}
var sb strings.Builder
for _, ti := range tools {
fmt.Fprintf(&sb, "%-24s %s\n", ti.Name, ti.Description)
}
return []byte(sb.String()), nil
}
func writeRootCtl(ctx HandlerCtx, data []byte) error {
cmd := strings.TrimSpace(string(data))
switch cmd {

View File

@ -16,6 +16,7 @@ var treeSpec = Dir("/",
Leaf("help", 0444, Read(readHelp), GID("agent")),
Leaf("models", 0444, Read(readModels), GID("agent")),
Leaf("agents", 0444, Read(readAgents), GID("agent")),
Leaf("tools", 0444, Read(readTools), GID("agent")),
Leaf("ctl", 0666, Write(writeRootCtl), GID("agent")),
Leaf("eventwait", 0444,
Read(readEventwait),
@ -51,4 +52,4 @@ var treeSpec = Dir("/",
// nopRead returns empty content.
func nopRead(_ HandlerCtx) ([]byte, error) {
return nil, nil
}
}

View File

@ -2,6 +2,8 @@ package fs
import (
"context"
"encoding/json"
"fmt"
"strings"
"sync"
"time"
@ -23,6 +25,10 @@ type Session struct {
cancel context.CancelFunc
proc *toolsrv.Process // toolsrv subprocess (session owns lifecycle)
// toolsConn is the multiplexed connection to the remote tool server,
// shared by agent execution and 9P tool-management handlers.
toolsConn toolsrv.Runner
// AgentLogs for the session's agents, keyed by agent ID.
// Backward compat: AgentLog() returns the first agent's log (by map iteration order).
AgentLogs map[string]*AgentLog
@ -31,6 +37,10 @@ type Session struct {
modelsMu sync.Mutex
modelsCache string
modelsCacheAt time.Time
// disallowTools is the set of tool names that cannot be loaded
// for agents in this session. Set from agent config at creation.
disallowTools map[string]struct{}
}
// AgentLog holds all per-agent state exposed via 9P.
@ -130,6 +140,65 @@ func (sess *Session) InvalidateModelsCache() {
sess.modelsMu.Unlock()
}
// loadTool loads a tool into the remote ollie-remote process via the
// session's management toolsConn. Returns an error if the tool is in
// the session's disallow list or if the remote rejects it.
//
// This is the single convergent path for tool loading — both the 9P
// agent/{id}/tools write handler and the autoLoad initialization call this.
func (sess *Session) loadTool(name string, ag *agent.Agent) error {
name = strings.TrimSpace(name)
if name == "" {
return nil
}
// Check disallow list.
sess.mu.RLock()
_, blocked := sess.disallowTools[name]
sess.mu.RUnlock()
if blocked {
return fmt.Errorf("tool %q is disallowed for this session", name)
}
// Load on the remote ollie-remote process (for agent execution).
// Prefer the session's management toolsConn (avoids deadlocking with
// the agent loop), fall back to the agent's ToolServer.
var runner toolsrv.Runner
if sess.toolsConn != nil {
runner = sess.toolsConn
} else if ag != nil {
runner = ag.ToolServer()
}
return loadToolOnRunner(runner, name)
}
func loadToolOnRunner(runner toolsrv.Runner, name string) error {
if runner == nil {
return nil
}
ctxTO, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
args, _ := json.Marshal(map[string]string{"name": name})
if _, err := runner.CallTool(ctxTO, "tool_load", json.RawMessage(args)); err != nil {
return fmt.Errorf("remote load: %w", err)
}
return nil
}
// SetDisallowTools replaces the session's disallow tool list.
func (sess *Session) SetDisallowTools(names []string) {
sess.mu.Lock()
defer sess.mu.Unlock()
if len(names) == 0 {
sess.disallowTools = nil
return
}
sess.disallowTools = make(map[string]struct{}, len(names))
for _, n := range names {
sess.disallowTools[n] = struct{}{}
}
}
func (sess *Session) RunnableID() string { return sess.id }
func (sess *Session) Uname() string { return sess.uname }
func (sess *Session) Name() string {

View File

@ -137,6 +137,8 @@ install-data:
# Tools (executables)
mkdir -p {{cfg}}/tools/_lib
install -m755 data/tools/browser_screencap {{cfg}}/tools/browser_screencap
install -m755 data/tools/reasoning_think {{cfg}}/tools/reasoning_think
install -m755 data/tools/shell {{cfg}}/tools/shell
install -m755 data/tools/file_edit {{cfg}}/tools/file_edit
install -m755 data/tools/file_glob {{cfg}}/tools/file_glob
install -m755 data/tools/file_grep {{cfg}}/tools/file_grep

View File

@ -24,9 +24,11 @@ You have autonomous access to tools and skills. Proactively load what you need
## Tools
- **`tool_list`** — discover all available tools with descriptions.
- **`tool_load`** — promote a tool to a native callable function: `{"name": "toolname"}`.
- **`tool_active`** — list tools currently loaded in your session.
Tools are loaded into your session at startup (configured in the agent's `autoLoad` list). Additional tools can be loaded dynamically via the 9P filesystem:
- **List available tools** — write to `session/{id}/agent/{aid}/tools` (empty string lists all)
- **Load a tool** — write the tool name to `session/{id}/agent/{aid}/tools`
- **See loaded tools** — read from `session/{id}/agent/{aid}/tools`
Once loaded, a tool becomes a first-class function. Call it directly by name with JSON arguments matching its schema.
@ -51,7 +53,7 @@ Skills are markdown modules that provide specialized domain knowledge, conventio
**Procedure** — before reaching for `shell`, follow this sequence:
1. **Is a loaded tool already fit for purpose?** If so, use it directly.
2. **Search available tools** (`tool_list`) — is there an unloaded tool that fits? Load it (`tool_load`) and use it.
2. **Search available tools** — check `session/{id}/agent/{aid}/tools` (read) to see what's available. Load any missing tool by writing its name there.
3. **Only if no tool exists** for the operation, fall back to `shell`.
**Common mappings** (not exhaustive):

View File

@ -28,6 +28,7 @@ filesystem:
- "/dev"
- "{HOME}/img"
- "{OLLIE_CFG_PATH}"
- "{OLLIE_SKILLS_PATH}"
rw:
- "{OLLIE_MEMORY_PATH}"
- "{OLLIE_PLAN_PATH}"
@ -78,6 +79,8 @@ filesystem:
- "/dev/fuse"
- "{OLLIE_DATA_PATH}"
- "{HOME}/notes"
- "{OLLIE_AGENTS_PATH}"
- "{OLLIE_PROMPTS_PATH}"
rox:
- "/usr"
- "/lib"
@ -101,16 +104,7 @@ filesystem:
- "{HOME}/.cache/uv"
- "{HOME}/bin"
- "{OLLIE}"
- "{OLLIE_CFG_PATH}"
- "{OLLIE_DATA_PATH}"
- "{OLLIE_MEMORY_PATH}"
- "{OLLIE_PLAN_PATH}"
- "{OLLIE_SKILLS_PATH}"
- "{OLLIE_TMP_PATH}"
- "{OLLIE_TOOLS_PATH}"
- "{OLLIE_TRANSCRIPT_PATH}"
- "{OLLIE_AGENTS_PATH}"
- "{OLLIE_PROMPTS_PATH}"
# XDG defaults for OLLIE_* paths (in case env vars are unset)
- "{XDG_CONFIG_HOME}/ollie"
- "{XDG_DATA_HOME}/ollie"

View File

@ -5,12 +5,8 @@ package builtin
import "ollie/toolsrv"
// Builtins returns the default set of built-in tool handlers.
// Returns nil — there are zero built-in tools. All tools are loaded
// dynamically via the 9P agent/{id}/tools write mechanism.
func Builtins() map[string]toolsrv.Handler {
return map[string]toolsrv.Handler{
"shell": Shell,
"reasoning_think": Think,
"tool_list": ToolList,
"tool_load": ToolLoad,
"tool_active": ToolActive,
}
}
return nil
}

View File

@ -1,25 +0,0 @@
package builtin
import (
"context"
"encoding/json"
"fmt"
"ollie/toolsrv"
)
// Think is a scratchpad for externalizing reasoning. The thought is recorded
// in conversation history but not shown to the user. Takes up practically
// zero context space — the value is in writing it.
func Think(ctx context.Context, srv *toolsrv.Server, args json.RawMessage) (string, error) {
var a struct {
Thought string `json:"thought"`
}
if err := json.Unmarshal(args, &a); err != nil {
return "", fmt.Errorf("reasoning_think: bad args: %w", err)
}
if a.Thought == "" {
return "", fmt.Errorf("reasoning_think: thought is required")
}
return "", nil
}

View File

@ -1,38 +0,0 @@
package builtin
import (
"context"
"encoding/json"
"fmt"
"ollie/toolsrv"
)
// 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)
}

View File

@ -1,97 +0,0 @@
package builtin
import (
"context"
"encoding/json"
"fmt"
"strings"
"ollie/toolsrv"
)
// 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")
}
// Refresh loaded tool schemas from disk so the model sees current definitions.
sid := srv.SessionID()
if sid != "" {
reg.RefreshLoaded(sid)
}
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")
}
// Notify the agent that the tool listing may have changed so the
// preamble stays in sync with the directory contents.
if srv.OnToolsChanged != nil {
var listing strings.Builder
for _, s := range summaries {
if s.Description != "" {
fmt.Fprintf(&listing, "- **%s** — %s\n", s.Name, s.Description)
}
}
srv.OnToolsChanged(listing.String())
}
return strings.TrimRight(out.String(), "\n"), nil
}
// 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
}

View File

@ -16,7 +16,13 @@ import (
// agent layer uses via type assertions. Both local and remote connections
// return the same *Conn — the only difference is the underlying transport.
type Conn struct {
mu sync.Mutex
writeMu sync.Mutex
pendingMu sync.Mutex
pending map[int64]*pendingCall
closed chan struct{}
closeOnce sync.Once
readErr error
enc *json.Encoder
dec *json.Decoder
nextID atomic.Int64
@ -32,20 +38,28 @@ type Conn struct {
// NewConn wraps an io.ReadWriteCloser as a JSON-RPC connection.
// The cleanup function is called when Close() is invoked.
func NewConn(rwc io.ReadWriteCloser, cleanup func()) *Conn {
return &Conn{
c := &Conn{
enc: json.NewEncoder(rwc),
dec: json.NewDecoder(bufio.NewReader(rwc)),
cleanup: cleanup,
pending: make(map[int64]*pendingCall),
closed: make(chan struct{}),
}
go c.readLoop()
return c
}
// NewConnSplit creates a Conn from separate reader/writer (e.g. stdin/stdout pipes).
func NewConnSplit(r io.Reader, w io.WriteCloser, cleanup func()) *Conn {
return &Conn{
c := &Conn{
enc: json.NewEncoder(w),
dec: json.NewDecoder(bufio.NewReader(r)),
cleanup: cleanup,
pending: make(map[int64]*pendingCall),
closed: make(chan struct{}),
}
go c.readLoop()
return c
}
// --- Runner interface ---
@ -84,9 +98,9 @@ func (c *Conn) SetAllowTools(names []string) {
}
func (c *Conn) SetOnToolsChanged(fn func(string)) {
c.mu.Lock()
c.pendingMu.Lock()
c.onToolsChanged = fn
c.mu.Unlock()
c.pendingMu.Unlock()
}
func (c *Conn) Detach() bool {
@ -175,6 +189,7 @@ func (c *Conn) ResultTierArgs(name string, args json.RawMessage) string {
// Close shuts down the connection and subprocess.
func (c *Conn) Close() {
c.fail(fmt.Errorf("connection closed"))
if c.cleanup != nil {
c.cleanup()
}
@ -199,6 +214,55 @@ func (c *Conn) FetchHostInfo() (HostInfo, error) {
return info, nil
}
type pendingCall struct {
response chan rpcResponse
ctx context.Context
}
func (c *Conn) readLoop() {
for {
var resp rpcResponse
if err := c.dec.Decode(&resp); err != nil {
c.fail(fmt.Errorf("rpc read: %w", err))
return
}
c.pendingMu.Lock()
call := c.pending[resp.ID]
c.pendingMu.Unlock()
if call == nil {
continue
}
// Tool output is sent as an intermediate response with the request ID.
var notif outputNotification
if resp.Stream && resp.Error == nil && json.Unmarshal(resp.Result, &notif) == nil {
if notif.Data != "" {
StreamOutput(call.ctx, notif.Data)
}
continue
}
c.pendingMu.Lock()
delete(c.pending, resp.ID)
c.pendingMu.Unlock()
call.response <- resp
}
}
func (c *Conn) fail(err error) {
c.closeOnce.Do(func() {
c.pendingMu.Lock()
c.readErr = err
close(c.closed)
for id, call := range c.pending {
delete(c.pending, id)
call.response <- rpcResponse{Error: &rpcError{Code: -32000, Message: err.Error()}}
}
c.pendingMu.Unlock()
})
}
// --- JSON-RPC internals ---
func (c *Conn) call(method string, params json.RawMessage) (json.RawMessage, error) {
@ -207,41 +271,45 @@ func (c *Conn) call(method string, params json.RawMessage) (json.RawMessage, err
func (c *Conn) callStreaming(ctx context.Context, method string, params json.RawMessage) (json.RawMessage, error) {
id := c.nextID.Add(1)
req := rpcRequest{
JSONRPC: "2.0",
ID: id,
Method: method,
Params: params,
call := &pendingCall{response: make(chan rpcResponse, 1), ctx: ctx}
c.pendingMu.Lock()
select {
case <-c.closed:
c.pendingMu.Unlock()
return nil, fmt.Errorf("rpc %s: connection closed", method)
default:
}
c.pending[id] = call
c.pendingMu.Unlock()
c.mu.Lock()
if err := c.enc.Encode(req); err != nil {
c.mu.Unlock()
c.writeMu.Lock()
err := c.enc.Encode(rpcRequest{JSONRPC: "2.0", ID: id, Method: method, Params: params})
c.writeMu.Unlock()
if err != nil {
c.pendingMu.Lock()
delete(c.pending, id)
c.pendingMu.Unlock()
return nil, fmt.Errorf("rpc write %s: %w", method, err)
}
// Read responses, handling interleaved streaming notifications.
for {
var resp rpcResponse
if err := c.dec.Decode(&resp); err != nil {
c.mu.Unlock()
return nil, fmt.Errorf("rpc read %s: %w", method, err)
}
// Notification: id == 0 means streaming output or event.
if resp.ID == 0 && resp.Result != nil {
var notif outputNotification
if json.Unmarshal(resp.Result, &notif) == nil && notif.Data != "" {
StreamOutput(ctx, notif.Data)
}
continue
}
// Actual response for our request.
c.mu.Unlock()
select {
case resp := <-call.response:
if resp.Error != nil {
return nil, fmt.Errorf("rpc %s: %s", method, resp.Error.Message)
}
return resp.Result, nil
case <-ctx.Done():
c.pendingMu.Lock()
delete(c.pending, id)
c.pendingMu.Unlock()
return nil, ctx.Err()
case <-c.closed:
c.pendingMu.Lock()
err := c.readErr
c.pendingMu.Unlock()
if err == nil {
err = fmt.Errorf("connection closed")
}
return nil, fmt.Errorf("rpc %s: %w", method, err)
}
}

View File

@ -16,6 +16,7 @@ type rpcResponse struct {
ID int64 `json:"id"`
Result json.RawMessage `json:"result,omitempty"`
Error *rpcError `json:"error,omitempty"`
Stream bool `json:"stream,omitempty"`
}
type rpcError struct {

View File

@ -143,80 +143,25 @@ func WithToolRegistry(r *Registry, sessionID string) Option {
}
}
// ListTools implements Server, returning shell plus any
// tools promoted in the session's tool registry.
func (e *Server) ListTools() ([]ToolInfo, error) {
all := []ToolInfo{
{
Name: "shell",
Description: `Execute a single bash command in a sandboxed environment.
Usage: {"cmd": "your command here"}
Sandbox prevents dangerous operations. Use for computation, builds, scripting.
timeout applies to each call (default: 30s). A non-zero exit is an error.`,
InputSchema: json.RawMessage(`{
"type": "object",
"required": ["cmd"],
"properties": {
"cmd": {"type": "string", "description": "Bash command to execute."},
"timeout": {"type": "integer", "description": "Timeout in seconds (default: 30). Use 0 for no timeout."},
"sandbox": {"type": "string", "description": "Sandbox profile name (default: default)."},
"elevated": {"type": "boolean", "description": "Run outside the sandbox via elevation broker."}
}
}`),
},
{
Name: "tool_list",
Description: `List all available tools that can be loaded.
Usage: {"name": "toolname"} — if name is provided, loads that tool.
Otherwise lists all tools with descriptions.`,
InputSchema: json.RawMessage(`{
"type": "object",
"properties": {}
}`),
},
{
Name: "tool_load",
Description: `Load a tool by name into the current session.
Usage: {"name": "toolname"}
After loading, the tool becomes a native callable function.`,
InputSchema: json.RawMessage(`{
"type": "object",
"required": ["name"],
"properties": {
"name": {"type": "string", "description": "Tool name to load."}
}
}`),
},
{
Name: "tool_active",
Description: `List tools currently loaded (promoted) in this session.
Usage: (no arguments)
Returns tools with descriptions, one per line.`,
InputSchema: json.RawMessage(`{
"type": "object",
"properties": {}
}`),
},
{
Name: "reasoning_think",
Description: `Scratchpad for externalizing reasoning. Recorded in history but not shown to the user.`,
InputSchema: json.RawMessage(`{
"type": "object",
"required": ["thought"],
"properties": {
"thought": {"type": "string", "description": "Your reasoning."}
}
}`),
},
// LoadTool loads a tool by name into this server's tool registry.
// Returns an error if no registry is configured or the tool cannot be found.
func (e *Server) LoadTool(name string) error {
if e.toolRegistry == nil {
return fmt.Errorf("no tool registry configured")
}
if e.sessionID == "" {
return fmt.Errorf("no session ID configured")
}
return e.toolRegistry.Load(e.sessionID, name)
}
// ListTools implements Server, returning tools loaded in the session's
// tool registry. With zero built-in tools, only dynamically loaded tools
// appear here.
func (e *Server) ListTools() ([]ToolInfo, error) {
var all []ToolInfo
if e.toolRegistry != nil && e.sessionID != "" {
all = append(all, e.toolRegistry.Loaded(e.sessionID)...)