refactor toolsrv into client protocol and metadata packages

This commit is contained in:
Ollie Agent 2026-08-16 17:22:59 +02:00
parent e972b5666a
commit 81933e5281
39 changed files with 326 additions and 311 deletions

View File

@ -27,8 +27,9 @@ import (
"text/tabwriter"
"time"
"9fans.net/go/plan9"
p9client "ollie/lib9p/client"
"9fans.net/go/plan9"
)
var (

View File

@ -4,12 +4,14 @@ import (
"fmt"
"sync"
toolsrvclient "ollie/toolsrv/client"
"ollie/toolsrv/protocol"
"github.com/godbus/dbus/v5"
"ollie/toolsrv"
)
// bypassNotifier handles desktop notifications for bypass requests.
// When a notification action is clicked, it writes the resolution directly to toolsrv.
// When a notification action is clicked, it writes the resolution directly to toolsrvclient.
type bypassNotifier struct {
conn *dbus.Conn
@ -23,7 +25,7 @@ type pendingRequest struct {
}
type sessionRef struct {
dialFn func() *toolsrv.Conn
dialFn func() *toolsrvclient.Conn
}
var notifier *bypassNotifier
@ -50,15 +52,15 @@ func initBypassNotifier(conn *dbus.Conn) {
}
// notifyBypass sends a desktop notification for a bypass request.
// When the user responds, it writes the resolution directly to toolsrv.
func notifyBypass(req *toolsrv.BypassRequest, sessionID string, dialFn func() *toolsrv.Conn) {
// When the user responds, it writes the resolution directly to toolsrvclient.
func notifyBypass(req *protocol.BypassRequest, sessionID string, dialFn func() *toolsrvclient.Conn) {
if notifier == nil {
return
}
notifier.sendNotification(req, sessionID, dialFn)
}
func (n *bypassNotifier) sendNotification(req *toolsrv.BypassRequest, sessionID string, dialFn func() *toolsrv.Conn) {
func (n *bypassNotifier) sendNotification(req *protocol.BypassRequest, sessionID string, dialFn func() *toolsrvclient.Conn) {
obj := n.conn.Object("org.freedesktop.Notifications", "/org/freedesktop/Notifications")
summary := "Elevation Request"

View File

@ -13,7 +13,7 @@ import (
lib9p "ollie/lib9p/client"
olog "ollie/log"
"ollie/paths"
"ollie/toolsrv"
toolsrvclient "ollie/toolsrv/client"
)
// Agent holds the state of the current Agent entity (the "agent"
@ -28,7 +28,7 @@ type Agent struct {
agentsDir string
systemPrompt string // system prompt for /agent reloads
envBlock string // environment block for /agent reloads
newToolServer func() *toolsrv.Conn
newToolServer func() *toolsrvclient.Conn
newBackend func(string) (backend.Backend, error)
currentAction atomic.Pointer[actionHandle]
warnedContext bool
@ -331,7 +331,7 @@ func (ag *Agent) emit(ev Event) {
// SetToolServer updates the tool server connection and factory.
// Used when resuming a paused session that was restored without infra.
func (ag *Agent) SetToolServer(newToolServer func() *toolsrv.Conn, conn *toolsrv.Conn) {
func (ag *Agent) SetToolServer(newToolServer func() *toolsrvclient.Conn, conn *toolsrvclient.Conn) {
ag.newToolServer = newToolServer
if ag.runtime != nil {
ag.runtime.ToolServer = conn
@ -623,7 +623,7 @@ func (ag *Agent) ListModels() []string {
// 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.Conn {
func (ag *Agent) ToolServer() *toolsrvclient.Conn {
return ag.runtime.ToolServer
}
@ -688,7 +688,7 @@ type AgentParams struct {
Cwd string // working directory for tool execution
SystemPrompt string
EnvBlock string
NewToolServer func() *toolsrv.Conn
NewToolServer func() *toolsrvclient.Conn
NewBackend func(string) (backend.Backend, error)
Log *olog.Logger
StartupMsgs []string

View File

@ -23,13 +23,13 @@ func (p *Prompt) UnmarshalJSON(data []byte) error {
}
type AgentConfig struct {
Prompt Prompt `json:"prompt,omitempty"`
UserPrompts Prompt `json:"userPrompts,omitempty"`
Backend string `json:"backend,omitempty"`
Model string `json:"model,omitempty"`
Tools *bool `json:"tools,omitempty"`
Prompt Prompt `json:"prompt,omitempty"`
UserPrompts Prompt `json:"userPrompts,omitempty"`
Backend string `json:"backend,omitempty"`
Model string `json:"model,omitempty"`
Tools *bool `json:"tools,omitempty"`
AutoLoad []string `json:"autoLoad,omitempty"`
AutoLoad []string `json:"autoLoad,omitempty"`
// MaxSteps caps the number of tool-call rounds per turn. 0 means unlimited.
MaxSteps int `json:"maxSteps,omitempty"`
CompactionModel string `json:"compactionModel,omitempty"`

View File

@ -4,11 +4,11 @@ import (
"strings"
"testing"
"ollie/toolsrv"
"ollie/toolsrv/protocol"
)
func TestRefreshToolListing(t *testing.T) {
infos := []toolsrv.ToolInfo{
infos := []protocol.ToolInfo{
{
Name: "new_tool",
Description: "New description",

View File

@ -26,7 +26,7 @@ func TestBuildCompactedHistory_OrphanedToolWalkback(t *testing.T) {
{Role: "assistant", Content: "old response 2"},
{Role: "user", Content: "request tool use"},
{Role: "assistant", Content: "", ToolCalls: []backend.ToolCall{{ID: "tc1", Name: "shell"}}}, // index 5
{Role: "tool", ToolCallID: "tc1", Content: "command output"}, // index 6 - naive hotStart lands here!
{Role: "tool", ToolCallID: "tc1", Content: "command output"}, // index 6 - naive hotStart lands here!
{Role: "user", Content: "next question"},
{Role: "assistant", Content: "answer"},
{Role: "user", Content: "another question"},

View File

@ -12,7 +12,7 @@ import (
"ollie/cmd/olliesrv/internal/backend"
"ollie/paths"
"ollie/toolsrv"
toolsrvclient "ollie/toolsrv/client"
)
const maxTransientRetries = 3
@ -599,7 +599,7 @@ func (ag *Agent) execOne(ctx context.Context, call backend.ToolCall) (toolResult
ag.emit(Event{Role: "tool", Name: call.Name, Content: result, OutputFormat: toolOutputFormat(rt, call.Name)})
return toolResult{ToolCallID: call.ID, Name: call.Name, Content: result, IsError: true}, true
}
var rlErr *toolsrv.RateLimitedError
var rlErr *toolsrvclient.RateLimitedError
if errors.As(err, &rlErr) {
result = fmt.Sprintf("error: shell is rate-limited — blocked for %v. Do not call shell() again until the block expires. Use other tools or wait.", rlErr.Remaining)
} else {

View File

@ -5,12 +5,12 @@ import (
"testing"
"ollie/cmd/olliesrv/internal/backend"
"ollie/toolsrv"
"ollie/toolsrv/protocol"
)
func TestToolConflictKeys(t *testing.T) {
rt := &Runtime{
ToolMeta: map[string]toolsrv.ToolInfo{
ToolMeta: map[string]protocol.ToolInfo{
"file_read": {Name: "file_read", Scope: "read"},
"file_grep": {Name: "file_grep", Scope: "read"},
"file_edit": {Name: "file_edit", Scope: "write"},
@ -80,7 +80,7 @@ func TestExecToolCallsBatching(t *testing.T) {
// Verify the batching logic groups correctly without actually executing.
// We test by inspecting which calls would be grouped together.
rt := &Runtime{
ToolMeta: map[string]toolsrv.ToolInfo{
ToolMeta: map[string]protocol.ToolInfo{
"file_read": {Name: "file_read", Scope: "read"},
"file_edit": {Name: "file_edit", Scope: "write"},
"file_write": {Name: "file_write", Scope: "write"},
@ -144,7 +144,7 @@ func TestExecToolCallsBatching(t *testing.T) {
func TestExecToolCallsSamePathSerializes(t *testing.T) {
rt := &Runtime{
ToolMeta: map[string]toolsrv.ToolInfo{
ToolMeta: map[string]protocol.ToolInfo{
"file_edit": {Name: "file_edit", Scope: "write"},
},
}

View File

@ -10,7 +10,8 @@ import (
"ollie/cmd/olliesrv/internal/backend"
"ollie/cmd/olliesrv/internal/prompts"
"ollie/toolsrv"
toolsrvclient "ollie/toolsrv/client"
"ollie/toolsrv/protocol"
)
// --- Preamble sections ---
@ -92,8 +93,8 @@ func EnvironmentBlock(cwd, platform string, isGitRepo bool, sandbox string) stri
// renderTools produces a single combined section: a one-line summary per tool
// followed immediately by its documentation (if any). This keeps the listing
// and usage instructions adjacent so models don't lose context between them.
func RenderTools(infos []toolsrv.ToolInfo) string {
infos = append([]toolsrv.ToolInfo(nil), infos...)
func RenderTools(infos []protocol.ToolInfo) string {
infos = append([]protocol.ToolInfo(nil), infos...)
sort.SliceStable(infos, func(i, j int) bool {
if infos[i].Name == infos[j].Name {
return infos[i].Server < infos[j].Server
@ -138,10 +139,10 @@ func RenderTools(infos []toolsrv.ToolInfo) string {
// agents replaces it atomically.
type Runtime struct {
Backend backend.Backend
ToolServer *toolsrv.Conn
ToolServer *toolsrvclient.Conn
Preamble *Preamble
Tools []backend.Tool
ToolMeta map[string]toolsrv.ToolInfo
ToolMeta map[string]protocol.ToolInfo
Exec toolExecutor
GenParams backend.GenerationParams
MaxSteps int
@ -160,10 +161,10 @@ func (rt *Runtime) PreambleString() string {
// BuildRuntime constructs a Runtime from a pre-configured tool server and
// optional agent config.
func BuildRuntime(cfg *AgentConfig, srv *toolsrv.Conn, cwd string, env []string, systemPrompt, envBlock string) *Runtime {
func BuildRuntime(cfg *AgentConfig, srv *toolsrvclient.Conn, cwd string, env []string, systemPrompt, envBlock string) *Runtime {
var messages []string
var allToolInfos []toolsrv.ToolInfo
var allToolInfos []protocol.ToolInfo
var allTools []backend.Tool
if srv != nil && (cfg == nil || cfg.ToolsEnabled()) {
@ -210,7 +211,7 @@ func BuildRuntime(cfg *AgentConfig, srv *toolsrv.Conn, cwd string, env []string,
return text, blocks, nil
}
toolMeta := make(map[string]toolsrv.ToolInfo, len(allToolInfos))
toolMeta := make(map[string]protocol.ToolInfo, len(allToolInfos))
for _, ti := range allToolInfos {
toolMeta[ti.Name] = ti
}
@ -250,7 +251,7 @@ func ResolveSystemPrompt(cfg *AgentConfig) string {
return prompts.DefaultSystemPrompt
}
func toolInfosToBackend(infos []toolsrv.ToolInfo) []backend.Tool {
func toolInfosToBackend(infos []protocol.ToolInfo) []backend.Tool {
out := make([]backend.Tool, len(infos))
for i, t := range infos {
out[i] = backend.Tool{
@ -308,9 +309,9 @@ func injectDispatchFlags(schema json.RawMessage) json.RawMessage {
// extractToolResult parses the tool server's JSON-RPC response into text
// and optional content blocks.
//
// See toolsrv.ToolResult for the wire format.
// See protocol.ToolResult for the wire format.
func extractToolResult(raw json.RawMessage) (text string, contentBlocks []backend.ContentBlock, isError bool) {
var result toolsrv.ToolResult
var result protocol.ToolResult
if err := json.Unmarshal(raw, &result); err != nil {
return string(raw), nil, false
}

View File

@ -4,7 +4,7 @@ import (
"strings"
"testing"
"ollie/toolsrv"
"ollie/toolsrv/protocol"
)
func TestEnvironmentBlockStable(t *testing.T) {
@ -19,7 +19,7 @@ func TestEnvironmentBlockStable(t *testing.T) {
}
func TestPreambleSections(t *testing.T) {
toolInfos := []toolsrv.ToolInfo{
toolInfos := []protocol.ToolInfo{
{Name: "file_read", Description: "Read a file with line numbers", Prompt: "**file_read**\n\nRead a file with line numbers...", InputSchema: []byte(`{"type":"object"}`)},
{Name: "file_write", Description: "Create or overwrite a file", Prompt: "**file_write**\n\nCreate or overwrite a file...", InputSchema: []byte(`{"type":"object"}`), Server: "remote"},
{Name: "shell", Description: "Execute shell command", InputSchema: []byte(`{}`)},
@ -41,9 +41,9 @@ func TestPreambleSections(t *testing.T) {
}
func TestRenderToolsStableOrder(t *testing.T) {
infos := []toolsrv.ToolInfo{{Name: "z_tool", Description: "z"}, {Name: "a_tool", Description: "a"}}
infos := []protocol.ToolInfo{{Name: "z_tool", Description: "z"}, {Name: "a_tool", Description: "a"}}
first := RenderTools(infos)
second := RenderTools([]toolsrv.ToolInfo{infos[1], infos[0]})
second := RenderTools([]protocol.ToolInfo{infos[1], infos[0]})
if first != second || strings.Index(first, "a_tool") > strings.Index(first, "z_tool") {
t.Fatalf("tool rendering is not deterministic: %q", first)
}

View File

@ -10,7 +10,7 @@ import (
"ollie/cmd/olliesrv/internal/backend"
"ollie/cmd/olliesrv/internal/metrics"
"ollie/toolsrv"
"ollie/toolsrv/protocol"
)
// Submit processes one line of user input: it starts an agent turn that streams
@ -131,7 +131,7 @@ func (ag *Agent) executeTurn(ctx context.Context, input string) string {
return ""
}
ag.runtime.Tools = toolInfosToBackend(ti)
meta := make(map[string]toolsrv.ToolInfo, len(ti))
meta := make(map[string]protocol.ToolInfo, len(ti))
for _, info := range ti {
meta[info.Name] = info
}

View File

@ -16,4 +16,3 @@ func NewGemini(apiKey string) (*OpenAIBackend, error) {
b.model = geminiDefaultModel
return b, nil
}

View File

@ -1078,7 +1078,6 @@ func kiroRandomJitter() time.Duration {
return time.Duration(n%uint32(kiroTokenExpiryJitter/time.Millisecond)) * time.Millisecond
}
// ── SQLite helpers ────────────────────────────────────────────────────────────
// Kiro supports two auth flows whose tokens live under different SQLite keys:
@ -1474,5 +1473,3 @@ func newKiroAuthSource(apiKey string) (kiroAuthSource, error) {
func kiroDefaultSQLiteKey() string {
return "sqlite://" + filepath.Join(paths.UserDataHome(), "kiro-cli", "data.sqlite3")
}

View File

@ -544,4 +544,3 @@ func (b *OpenAIBackend) ChatStream(ctx context.Context, messages []Message, tool
return streamRequest(b.client, httpReq, "openai", streamOpenAISSE)
}

View File

@ -318,7 +318,7 @@ func TestOpenRouterClaude_LastToolCacheControl(t *testing.T) {
}
// TestOpenRouterNonClaude_NoCacheControl verifies that a non-Claude model on
// openrouter does NOT get cache_control injected into system messages or toolsrv.
// openrouter does NOT get cache_control injected into system messages or toolsrvclient.
func TestOpenRouterNonClaude_NoCacheControl(t *testing.T) {
top := captureRawRequest(t, "openrouter", "mistral-7b",
[]Message{{Role: "system", Content: "be helpful"}, {Role: "user", Content: "hi"}},

View File

@ -11,12 +11,12 @@ import (
const (
RequestTTL = 300 * time.Second
BurstLimit = 3 // max denied/unanswered requests in burst window
BurstLimit = 3 // max denied/unanswered requests in burst window
RefillInterval = 10 * time.Second // one slot recovers every 10s (30s to full)
)
// Broker manages bypass request evaluation and policy.
// It does NOT execute commands - that happens in toolsrv.
// It does NOT execute commands - that happens in toolsrvclient.
type Broker struct {
policy *PolicyStore
notify NotifyFunc

View File

@ -3,24 +3,24 @@ package fs
import (
"context"
"ollie/virtfs"
olog "ollie/log"
"ollie/cmd/olliesrv/internal/session"
olog "ollie/log"
"ollie/virtfs"
)
// Config carries all runtime dependencies for the filesystem tree.
// It is the single input to NewRoot.
type Config struct {
Ctx context.Context
Log *olog.Logger
Sink *olog.Sink
AgentsDir string
SessionsDir string
Yolo bool
ModelCache *ModelCache
BypassNotify session.BypassNotifyFunc
Shutdown func()
Invalidate func()
Ctx context.Context
Log *olog.Logger
Sink *olog.Sink
AgentsDir string
SessionsDir string
Yolo bool
ModelCache *ModelCache
BypassNotify session.BypassNotifyFunc
Shutdown func()
Invalidate func()
}
// NewRoot creates the complete 9P filesystem tree from the spec.

View File

@ -10,7 +10,7 @@ import (
"ollie/cmd/olliesrv/internal/agent"
"ollie/cmd/olliesrv/internal/session"
"ollie/toolsrv"
"ollie/toolsrv/protocol"
"ollie/virtfs"
)
@ -106,7 +106,7 @@ func parseAgentNewRequest(data []byte) (session.AgentParams, string, string) {
continue
}
if k, v, ok := strings.Cut(tok, "="); ok {
v = toolsrv.UnescapeValue(v)
v = protocol.UnescapeValue(v)
switch k {
case "cwd":
p.CWD = v

View File

@ -14,7 +14,8 @@ import (
"ollie/cmd/olliesrv/internal/toolclient"
olog "ollie/log"
"ollie/paths"
"ollie/toolsrv"
toolsrvclient "ollie/toolsrv/client"
"ollie/toolsrv/protocol"
)
// Session owns all session-level state: identity, agents, tool server lifecycle.
@ -38,8 +39,8 @@ type Session struct {
// Mutable state protected by mu
mu sync.RWMutex
name string // friendly name
toolsConn *toolsrv.Conn // multiplexed RPC connection
name string // friendly name
toolsConn *toolsrvclient.Conn // multiplexed RPC connection
paused bool
// Autosave
@ -84,7 +85,7 @@ func (s *Session) SetName(name string) {
}
// ToolsConn returns the tool server connection.
func (s *Session) ToolsConn() *toolsrv.Conn {
func (s *Session) ToolsConn() *toolsrvclient.Conn {
s.mu.RLock()
defer s.mu.RUnlock()
return s.toolsConn
@ -92,7 +93,7 @@ func (s *Session) ToolsConn() *toolsrv.Conn {
// DialToolServer dials a fresh toolsrv connection via the keeper.
// Caller is responsible for closing the returned conn.
func (s *Session) DialToolServer() *toolsrv.Conn {
func (s *Session) DialToolServer() *toolsrvclient.Conn {
s.mu.RLock()
k := s.Keeper
s.mu.RUnlock()
@ -107,7 +108,7 @@ func (s *Session) DialToolServer() *toolsrv.Conn {
}
// SetToolsConn sets the tool server connection.
func (s *Session) SetToolsConn(conn *toolsrv.Conn) {
func (s *Session) SetToolsConn(conn *toolsrvclient.Conn) {
s.mu.Lock()
defer s.mu.Unlock()
s.toolsConn = conn
@ -115,7 +116,7 @@ func (s *Session) SetToolsConn(conn *toolsrv.Conn) {
// BypassNotifyFunc is called when a bypass request arrives.
// It receives the request, session ID, and a dial function to get a connection for resolution.
type BypassNotifyFunc func(req *toolsrv.BypassRequest, sessionID string, dialFn func() *toolsrv.Conn)
type BypassNotifyFunc func(req *protocol.BypassRequest, sessionID string, dialFn func() *toolsrvclient.Conn)
// StartBypassLoop starts a goroutine that reads bypass requests from toolsrv
// and calls notifyFn for each one. This should be called after SetToolsConn.
@ -151,7 +152,7 @@ func (s *Session) runBypassLoop(notifyFn BypassNotifyFunc) {
// Notify (non-blocking) - the notifier will write resolution when user responds
if notifyFn != nil {
notifyFn(req, s.ID, s.DialToolServer)
notifyFn((*protocol.BypassRequest)(req), s.ID, s.DialToolServer)
}
}
}
@ -326,7 +327,7 @@ func (s *Session) Resume() error {
if err != nil {
return fmt.Errorf("resume agent dial: %w", err)
}
ag.SetToolServer(func() *toolsrv.Conn {
ag.SetToolServer(func() *toolsrvclient.Conn {
c, _ := s.Keeper.Dial()
return c
}, agConn)
@ -392,7 +393,7 @@ func (s *Session) LoadTool(name string, ag *agent.Agent) error {
}
// LoadToolOnConn loads a tool on the given connection.
func LoadToolOnConn(conn *toolsrv.Conn, name string) error {
func LoadToolOnConn(conn *toolsrvclient.Conn, name string) error {
if conn == nil {
return nil
}

View File

@ -5,10 +5,10 @@ import (
"fmt"
"ollie/cmd/olliesrv/internal/agent"
"ollie/cmd/olliesrv/internal/prompts"
"ollie/cmd/olliesrv/internal/toolclient"
"ollie/paths"
"ollie/cmd/olliesrv/internal/prompts"
"ollie/toolsrv"
toolsrvclient "ollie/toolsrv/client"
)
// SessionInfra holds the tool server infrastructure for a session.
@ -16,8 +16,8 @@ import (
type SessionInfra struct {
Proc *toolclient.Process
Keeper *toolclient.ProcessKeeper
ToolsConn *toolsrv.Conn
NewToolServer func() *toolsrv.Conn
ToolsConn *toolsrvclient.Conn
NewToolServer func() *toolsrvclient.Conn
Platform string
IsGitRepo bool
}
@ -39,7 +39,7 @@ type ToolServerConfig struct {
type InfraConfig struct {
Proc *toolclient.Process
Keeper *toolclient.ProcessKeeper
ToolsConn *toolsrv.Conn
ToolsConn *toolsrvclient.Conn
}
// SetupToolServer spawns the tool server process and creates the infrastructure.
@ -50,9 +50,9 @@ func SetupToolServer(cfg ToolServerConfig) (*SessionInfra, error) {
// Reuse existing infrastructure if provided.
if cfg.ReuseFrom != nil {
r := cfg.ReuseFrom
var newToolServer func() *toolsrv.Conn
var newToolServer func() *toolsrvclient.Conn
if r.Keeper != nil {
newToolServer = func() *toolsrv.Conn {
newToolServer = func() *toolsrvclient.Conn {
conn, err := r.Keeper.Dial()
if err != nil {
return nil
@ -60,7 +60,7 @@ func SetupToolServer(cfg ToolServerConfig) (*SessionInfra, error) {
return conn
}
} else {
newToolServer = func() *toolsrv.Conn {
newToolServer = func() *toolsrvclient.Conn {
return r.ToolsConn
}
}
@ -79,7 +79,7 @@ func SetupToolServer(cfg ToolServerConfig) (*SessionInfra, error) {
}
var proc *toolclient.Process
var keeper *toolclient.ProcessKeeper
var newToolServer func() *toolsrv.Conn
var newToolServer func() *toolsrvclient.Conn
var err error
var platform string
var isGitRepo bool
@ -102,7 +102,7 @@ func SetupToolServer(cfg ToolServerConfig) (*SessionInfra, error) {
Yolo: cfg.Yolo,
})
})
newToolServer = func() *toolsrv.Conn {
newToolServer = func() *toolsrvclient.Conn {
conn, err := keeper.Dial()
if err != nil {
return nil
@ -126,7 +126,7 @@ func SetupToolServer(cfg ToolServerConfig) (*SessionInfra, error) {
keeper = toolclient.NewProcessKeeper(cfg.Ctx, proc, func(ctx context.Context) (*toolclient.Process, error) {
return toolclient.Spawn(ctx, cfg.CWD, dialOpts...)
})
newToolServer = func() *toolsrv.Conn {
newToolServer = func() *toolsrvclient.Conn {
conn, err := keeper.Dial()
if err != nil {
return nil
@ -173,7 +173,7 @@ func BuildPromptLayers(cfg *agent.AgentConfig, cwd, sessID, uname, platform stri
// LoadAutoLoadTools loads all tools from the agent config's autoLoad list.
// It first sends the session ID and uname to the tool server so that
// ollie-remote can attach its tool registry before processing tool_load RPCs.
func LoadAutoLoadTools(cfg *agent.AgentConfig, conn *toolsrv.Conn, sessID, uname string, logError func(string, ...any)) {
func LoadAutoLoadTools(cfg *agent.AgentConfig, conn *toolsrvclient.Conn, sessID, uname string, logError func(string, ...any)) {
if cfg == nil || conn == nil {
return
}

View File

@ -1,4 +1,4 @@
// spawn.go - Process spawning and lifecycle management for toolsrv.
// spawn.go - Process spawning and lifecycle management for toolsrvclient.
package toolclient
import (
@ -18,7 +18,7 @@ import (
"time"
"ollie/paths"
toolsrvclient "ollie/toolsrv"
toolsrvclient "ollie/toolsrv/client"
)
// Process represents a running toolsrv process.

View File

@ -11,13 +11,14 @@ import (
"path/filepath"
"syscall"
"9fans.net/go/plan9/client"
"ollie/cmd/olliesrv/internal/agent"
"ollie/env"
"ollie/cmd/olliesrv/internal/fs"
"ollie/env"
olog "ollie/log"
"ollie/paths"
"9fans.net/go/plan9/client"
"github.com/godbus/dbus/v5"
)

View File

@ -15,8 +15,8 @@ import (
"time"
"ollie/cmd/olliesrv/internal/fs"
olog "ollie/log"
"ollie/cmd/olliesrv/internal/session"
olog "ollie/log"
"9fans.net/go/plan9"
)
@ -113,18 +113,36 @@ func New(cfg Config) *Server {
}
return &plan9.Fcall{Type: plan9.Rversion, Tag: fc.Tag, Msize: msize, Version: "9P2000"}
},
plan9.Tauth: func(cs *connState, fc *plan9.Fcall, _ context.Context) *plan9.Fcall { return errFcall(fc, "no auth required") },
plan9.Tflush: func(cs *connState, fc *plan9.Fcall, _ context.Context) *plan9.Fcall { return flush(cs, fc) },
plan9.Tauth: func(cs *connState, fc *plan9.Fcall, _ context.Context) *plan9.Fcall {
return errFcall(fc, "no auth required")
},
plan9.Tflush: func(cs *connState, fc *plan9.Fcall, _ context.Context) *plan9.Fcall { return flush(cs, fc) },
plan9.Tattach: func(cs *connState, fc *plan9.Fcall, _ context.Context) *plan9.Fcall { return attach(s.groups, cs, fc) },
plan9.Twalk: func(cs *connState, fc *plan9.Fcall, _ context.Context) *plan9.Fcall { return walk(cfg.RootTree, cs, fc) },
plan9.Topen: func(cs *connState, fc *plan9.Fcall, _ context.Context) *plan9.Fcall { return open(cfg.RootTree, s.groups, cs, fc) },
plan9.Tcreate: func(cs *connState, fc *plan9.Fcall, _ context.Context) *plan9.Fcall { return create(cfg.RootTree, cs, fc) },
plan9.Tread: func(cs *connState, fc *plan9.Fcall, ctx context.Context) *plan9.Fcall { return read(cfg.RootTree, cs, fc, ctx) },
plan9.Twrite: func(cs *connState, fc *plan9.Fcall, _ context.Context) *plan9.Fcall { return write(cs, fc) },
plan9.Tstat: func(cs *connState, fc *plan9.Fcall, _ context.Context) *plan9.Fcall { return stat(cfg.RootTree, cs, fc) },
plan9.Twstat: func(cs *connState, fc *plan9.Fcall, _ context.Context) *plan9.Fcall { return wstat(cfg.RootTree, cs, fc) },
plan9.Tclunk: func(cs *connState, fc *plan9.Fcall, _ context.Context) *plan9.Fcall { return clunk(cfg.RootTree, cs, fc) },
plan9.Tremove: func(cs *connState, fc *plan9.Fcall, _ context.Context) *plan9.Fcall { return remove(cfg.RootTree, cs, fc) },
plan9.Twalk: func(cs *connState, fc *plan9.Fcall, _ context.Context) *plan9.Fcall {
return walk(cfg.RootTree, cs, fc)
},
plan9.Topen: func(cs *connState, fc *plan9.Fcall, _ context.Context) *plan9.Fcall {
return open(cfg.RootTree, s.groups, cs, fc)
},
plan9.Tcreate: func(cs *connState, fc *plan9.Fcall, _ context.Context) *plan9.Fcall {
return create(cfg.RootTree, cs, fc)
},
plan9.Tread: func(cs *connState, fc *plan9.Fcall, ctx context.Context) *plan9.Fcall {
return read(cfg.RootTree, cs, fc, ctx)
},
plan9.Twrite: func(cs *connState, fc *plan9.Fcall, _ context.Context) *plan9.Fcall { return write(cs, fc) },
plan9.Tstat: func(cs *connState, fc *plan9.Fcall, _ context.Context) *plan9.Fcall {
return stat(cfg.RootTree, cs, fc)
},
plan9.Twstat: func(cs *connState, fc *plan9.Fcall, _ context.Context) *plan9.Fcall {
return wstat(cfg.RootTree, cs, fc)
},
plan9.Tclunk: func(cs *connState, fc *plan9.Fcall, _ context.Context) *plan9.Fcall {
return clunk(cfg.RootTree, cs, fc)
},
plan9.Tremove: func(cs *connState, fc *plan9.Fcall, _ context.Context) *plan9.Fcall {
return remove(cfg.RootTree, cs, fc)
},
}
return s
}

View File

@ -15,7 +15,9 @@ import (
"testing"
"time"
"ollie/toolsrv"
toolsrvclient "ollie/toolsrv/client"
"ollie/toolsrv/metadata"
"ollie/toolsrv/protocol"
)
var testToolsrvBinary string
@ -85,7 +87,7 @@ network:
}
// Symlink the real tools directory so tests can find them
realToolsPath := toolsrv.ToolsPath()
realToolsPath := metadata.ToolsPath()
if _, err := os.Stat(realToolsPath); err == nil {
if err := os.Symlink(realToolsPath, filepath.Join(cfgDir, "tools")); err != nil {
os.RemoveAll(tmpDir)
@ -93,7 +95,7 @@ network:
}
}
socketPath = filepath.Join(tmpDir, "toolsrv.sock")
socketPath = filepath.Join(tmpDir, "toolsrvclient.sock")
cwd, _ := os.Getwd()
ctx, cancel := context.WithCancel(context.Background())
@ -140,7 +142,7 @@ func TestIntegration_FirstConnectionSetsSecret(t *testing.T) {
// First connection with a secret should succeed
secret := "test-secret-first-connection"
conn, err := toolsrv.Dial(socketPath, secret)
conn, err := toolsrvclient.Dial(socketPath, secret)
if err != nil {
t.Fatalf("first Dial failed: %v", err)
}
@ -164,7 +166,7 @@ func TestIntegration_SameSecretReconnects(t *testing.T) {
secret := "test-secret-reconnect"
// First connection establishes the secret
conn1, err := toolsrv.Dial(socketPath, secret)
conn1, err := toolsrvclient.Dial(socketPath, secret)
if err != nil {
t.Fatalf("first Dial failed: %v", err)
}
@ -172,7 +174,7 @@ func TestIntegration_SameSecretReconnects(t *testing.T) {
conn1.Close()
// Second connection with same secret should succeed
conn2, err := toolsrv.Dial(socketPath, secret)
conn2, err := toolsrvclient.Dial(socketPath, secret)
if err != nil {
t.Fatalf("second Dial failed: %v", err)
}
@ -189,14 +191,14 @@ func TestIntegration_WrongSecretFails(t *testing.T) {
defer cleanup()
// First connection establishes the secret
conn1, err := toolsrv.Dial(socketPath, "correct-secret")
conn1, err := toolsrvclient.Dial(socketPath, "correct-secret")
if err != nil {
t.Fatalf("first Dial failed: %v", err)
}
conn1.Close()
// Second connection with wrong secret should fail
conn2, err := toolsrv.Dial(socketPath, "wrong-secret")
conn2, err := toolsrvclient.Dial(socketPath, "wrong-secret")
if err == nil {
conn2.Close()
t.Fatal("Dial with wrong secret should fail")
@ -208,7 +210,7 @@ func TestIntegration_EmptySecretGeneratesOne(t *testing.T) {
defer cleanup()
// Empty secret should generate a random one
conn, err := toolsrv.Dial(socketPath, "")
conn, err := toolsrvclient.Dial(socketPath, "")
if err != nil {
t.Fatalf("Dial with empty secret failed: %v", err)
}
@ -229,7 +231,7 @@ func TestIntegration_BasicOperations(t *testing.T) {
socketPath, cleanup := startTestServer(t)
defer cleanup()
conn, err := toolsrv.Dial(socketPath, "test-secret-ops")
conn, err := toolsrvclient.Dial(socketPath, "test-secret-ops")
if err != nil {
t.Fatalf("Dial failed: %v", err)
}
@ -260,14 +262,14 @@ func TestIntegration_BasicOperations(t *testing.T) {
func TestIntegration_ToolExecution(t *testing.T) {
// Skip if tools directory doesn't exist (CI environment)
if _, err := os.ReadDir(toolsrv.ToolsPath()); err != nil {
if _, err := os.ReadDir(metadata.ToolsPath()); err != nil {
t.Skipf("skipping: tools directory not available: %v", err)
}
socketPath, cleanup := startTestServer(t)
defer cleanup()
conn, err := toolsrv.Dial(socketPath, "test-secret-exec")
conn, err := toolsrvclient.Dial(socketPath, "test-secret-exec")
if err != nil {
t.Fatalf("Dial failed: %v", err)
}
@ -304,14 +306,14 @@ func TestIntegration_ToolExecution(t *testing.T) {
}
// Parse the result the same way agent/runtime.go does
var toolResult toolsrv.ToolResult
var toolResult protocol.ToolResult
if err := json.Unmarshal(result, &toolResult); err != nil {
t.Fatalf("failed to parse toolsrv.ToolResult: %v (raw: %s)", err, string(result))
t.Fatalf("failed to parse protocol.ToolResult: %v (raw: %s)", err, string(result))
}
// Verify structure
if len(toolResult.Content) == 0 {
t.Fatal("toolsrv.ToolResult.Content is empty")
t.Fatal("protocol.ToolResult.Content is empty")
}
if toolResult.Content[0].Type != "text" {
t.Errorf("expected type 'text', got %q", toolResult.Content[0].Type)
@ -320,7 +322,7 @@ func TestIntegration_ToolExecution(t *testing.T) {
t.Errorf("output doesn't contain expected text: %q", toolResult.Content[0].Text)
}
if toolResult.IsError {
t.Error("toolsrv.ToolResult.IsError should be false")
t.Error("protocol.ToolResult.IsError should be false")
}
t.Logf("Tool result: %+v", toolResult)
@ -328,14 +330,14 @@ func TestIntegration_ToolExecution(t *testing.T) {
func TestIntegration_MultilineContent(t *testing.T) {
// Skip if tools directory doesn't exist (CI environment)
if _, err := os.ReadDir(toolsrv.ToolsPath()); err != nil {
if _, err := os.ReadDir(metadata.ToolsPath()); err != nil {
t.Skipf("skipping: tools directory not available: %v", err)
}
socketPath, cleanup := startTestServer(t)
defer cleanup()
conn, err := toolsrv.Dial(socketPath, "test-secret-multiline")
conn, err := toolsrvclient.Dial(socketPath, "test-secret-multiline")
if err != nil {
t.Fatalf("Dial failed: %v", err)
}
@ -360,9 +362,9 @@ func TestIntegration_MultilineContent(t *testing.T) {
t.Fatalf("CallTool failed: %v", err)
}
var toolResult toolsrv.ToolResult
var toolResult protocol.ToolResult
if err := json.Unmarshal(result, &toolResult); err != nil {
t.Fatalf("failed to parse toolsrv.ToolResult: %v (raw: %s)", err, string(result))
t.Fatalf("failed to parse protocol.ToolResult: %v (raw: %s)", err, string(result))
}
if toolResult.IsError {
@ -382,14 +384,14 @@ func TestIntegration_MultilineContent(t *testing.T) {
func TestIntegration_ToolExecutionError(t *testing.T) {
// Skip if tools directory doesn't exist (CI environment)
if _, err := os.ReadDir(toolsrv.ToolsPath()); err != nil {
if _, err := os.ReadDir(metadata.ToolsPath()); err != nil {
t.Skipf("skipping: tools directory not available: %v", err)
}
socketPath, cleanup := startTestServer(t)
defer cleanup()
conn, err := toolsrv.Dial(socketPath, "test-secret-exec-err")
conn, err := toolsrvclient.Dial(socketPath, "test-secret-exec-err")
if err != nil {
t.Fatalf("Dial failed: %v", err)
}
@ -410,14 +412,14 @@ func TestIntegration_ToolExecutionError(t *testing.T) {
}
// Parse the result
var toolResult toolsrv.ToolResult
var toolResult protocol.ToolResult
if err := json.Unmarshal(result, &toolResult); err != nil {
t.Fatalf("failed to parse toolsrv.ToolResult: %v (raw: %s)", err, string(result))
t.Fatalf("failed to parse protocol.ToolResult: %v (raw: %s)", err, string(result))
}
// Should be an error result with exit code info
if len(toolResult.Content) == 0 {
t.Fatal("toolsrv.ToolResult.Content is empty")
t.Fatal("protocol.ToolResult.Content is empty")
}
if !toolResult.IsError {
t.Error("expected IsError=true for non-zero exit")
@ -436,7 +438,7 @@ func TestIntegration_ConcurrentConnections(t *testing.T) {
secret := "test-secret-concurrent"
// Establish the secret
conn0, err := toolsrv.Dial(socketPath, secret)
conn0, err := toolsrvclient.Dial(socketPath, secret)
if err != nil {
t.Fatalf("initial Dial failed: %v", err)
}
@ -445,11 +447,11 @@ func TestIntegration_ConcurrentConnections(t *testing.T) {
// Open multiple concurrent connections
const numConns = 5
conns := make([]*toolsrv.Conn, numConns)
conns := make([]*toolsrvclient.Conn, numConns)
errors := make([]error, numConns)
for i := 0; i < numConns; i++ {
conns[i], errors[i] = toolsrv.Dial(socketPath, secret)
conns[i], errors[i] = toolsrvclient.Dial(socketPath, secret)
}
// Check all succeeded with same token
@ -478,7 +480,7 @@ func TestIntegration_ProcessKeeperRespawn(t *testing.T) {
socketPath1, cleanup1 := startTestServer(t)
secret1 := "secret-for-first-server"
conn1, err := toolsrv.Dial(socketPath1, secret1)
conn1, err := toolsrvclient.Dial(socketPath1, secret1)
if err != nil {
cleanup1()
t.Fatalf("first Dial failed: %v", err)
@ -495,7 +497,7 @@ func TestIntegration_ProcessKeeperRespawn(t *testing.T) {
// New server should accept a new secret (any secret, since it's fresh)
secret2 := "secret-for-second-server"
conn2, err := toolsrv.Dial(socketPath2, secret2)
conn2, err := toolsrvclient.Dial(socketPath2, secret2)
if err != nil {
t.Fatalf("second Dial failed: %v", err)
}
@ -508,7 +510,7 @@ func TestIntegration_ProcessKeeperRespawn(t *testing.T) {
}
// Old secret should NOT work on new server
conn3, err := toolsrv.Dial(socketPath2, secret1)
conn3, err := toolsrvclient.Dial(socketPath2, secret1)
if err == nil {
conn3.Close()
t.Error("old secret should not work on new server")
@ -522,14 +524,14 @@ func TestIntegration_ProcessKeeperRespawn(t *testing.T) {
// call causes the call to return quickly (not wait for the full tool execution).
func TestIntegration_ToolCancellation(t *testing.T) {
// Skip if tools directory doesn't exist (CI environment)
if _, err := os.ReadDir(toolsrv.ToolsPath()); err != nil {
if _, err := os.ReadDir(metadata.ToolsPath()); err != nil {
t.Skipf("skipping: tools directory not available: %v", err)
}
socketPath, cleanup := startTestServer(t)
defer cleanup()
conn, err := toolsrv.Dial(socketPath, "test-secret-cancel")
conn, err := toolsrvclient.Dial(socketPath, "test-secret-cancel")
if err != nil {
t.Fatalf("Dial failed: %v", err)
}
@ -588,14 +590,14 @@ func TestIntegration_ToolCancellation(t *testing.T) {
// take a long time to complete.
func TestIntegration_ToolCancellation_ContextPropagation(t *testing.T) {
// Skip if tools directory doesn't exist (CI environment)
if _, err := os.ReadDir(toolsrv.ToolsPath()); err != nil {
if _, err := os.ReadDir(metadata.ToolsPath()); err != nil {
t.Skipf("skipping: tools directory not available: %v", err)
}
socketPath, cleanup := startTestServer(t)
defer cleanup()
conn, err := toolsrv.Dial(socketPath, "test-secret-cancel-prop")
conn, err := toolsrvclient.Dial(socketPath, "test-secret-cancel-prop")
if err != nil {
t.Fatalf("Dial failed: %v", err)
}

View File

@ -1,4 +1,4 @@
// Package bypass handles bypass request submission and resolution for toolsrv.
// Package bypass handles bypass request submission and resolution for toolsrvclient.
// Bypass requests are submitted by sandboxed execution, exposed via 9P for
// external approval (olliesrv), and resolved when the approver responds.
package bypass

View File

@ -17,7 +17,8 @@ import (
"ollie/cmd/toolsrv/internal/bypass"
"ollie/cmd/toolsrv/internal/sandbox"
"ollie/paths"
"ollie/toolsrv"
"ollie/toolsrv/metadata"
"ollie/toolsrv/protocol"
)
// Config contains all context needed for tool execution.
@ -46,9 +47,9 @@ func WithStreamFunc(ctx context.Context, fn func(string)) context.Context {
}
// ExecuteTool runs a tool script with the given args and returns the result.
func ExecuteTool(ctx context.Context, info toolsrv.ToolInfo, args json.RawMessage, cfg Config) (json.RawMessage, error) {
func ExecuteTool(ctx context.Context, info protocol.ToolInfo, args json.RawMessage, cfg Config) (json.RawMessage, error) {
// Resolve script path
toolPath, err := toolsrv.ResolveTool(info.Name)
toolPath, err := metadata.ResolveTool(info.Name)
if err != nil {
return nil, fmt.Errorf("resolve tool %s: %w", info.Name, err)
}
@ -99,13 +100,13 @@ func ExecuteTool(ctx context.Context, info toolsrv.ToolInfo, args json.RawMessag
}
if err != nil {
return json.Marshal(toolsrv.ToolResult{
return json.Marshal(protocol.ToolResult{
IsError: true,
Content: []toolsrv.ToolResultContent{{Type: "text", Text: result + ": " + err.Error()}},
Content: []protocol.ToolResultContent{{Type: "text", Text: result + ": " + err.Error()}},
})
}
return json.Marshal(toolsrv.ToolResult{
Content: []toolsrv.ToolResultContent{{Type: "text", Text: result}},
return json.Marshal(protocol.ToolResult{
Content: []protocol.ToolResultContent{{Type: "text", Text: result}},
})
}
@ -144,7 +145,7 @@ func executeSandboxed(ctx context.Context, toolPath, stdinData, cwd string, envE
envMap[k] = v
}
// Prepend tools directory to PATH so meta-only cmd fields can reference other tools
if toolsDir := toolsrv.ToolsPath(); toolsDir != "" {
if toolsDir := metadata.ToolsPath(); toolsDir != "" {
if existing := envMap["PATH"]; existing != "" {
envMap["PATH"] = toolsDir + ":" + existing
} else {

View File

@ -1,4 +1,4 @@
// Package registry provides the tool registry for toolsrv.
// Package registry provides the tool registry for toolsrvclient.
// Tools are scoped per-agent within a session (one toolsrv per session).
package registry
@ -7,28 +7,29 @@ import (
"sort"
"sync"
"ollie/toolsrv"
"ollie/toolsrv/metadata"
"ollie/toolsrv/protocol"
)
// Registry tracks loaded tools per agent.
type Registry struct {
mu sync.RWMutex
agents map[string]map[string]toolsrv.ToolInfo
agents map[string]map[string]protocol.ToolInfo
revisions map[string]uint64
}
// New creates a new tool registry.
func New() (*Registry, error) {
return &Registry{
agents: make(map[string]map[string]toolsrv.ToolInfo),
agents: make(map[string]map[string]protocol.ToolInfo),
revisions: make(map[string]uint64),
}, nil
}
// discover scans the tools directory and returns all valid tool infos.
func (r *Registry) discover() map[string]toolsrv.ToolInfo {
infos := toolsrv.DiscoverTools()
tools := make(map[string]toolsrv.ToolInfo, len(infos))
func (r *Registry) discover() map[string]protocol.ToolInfo {
infos := metadata.DiscoverTools()
tools := make(map[string]protocol.ToolInfo, len(infos))
for _, info := range infos {
tools[info.Name] = info
}
@ -47,7 +48,7 @@ func (r *Registry) Load(agentID, name string) error {
defer r.mu.Unlock()
if r.agents[agentID] == nil {
r.agents[agentID] = make(map[string]toolsrv.ToolInfo)
r.agents[agentID] = make(map[string]protocol.ToolInfo)
}
r.agents[agentID][name] = tool
@ -75,7 +76,7 @@ func (r *Registry) Unload(agentID, name string) error {
}
// Loaded returns all loaded tools for an agent.
func (r *Registry) Loaded(agentID string) []toolsrv.ToolInfo {
func (r *Registry) Loaded(agentID string) []protocol.ToolInfo {
r.mu.RLock()
defer r.mu.RUnlock()
@ -84,7 +85,7 @@ func (r *Registry) Loaded(agentID string) []toolsrv.ToolInfo {
return nil
}
var loaded []toolsrv.ToolInfo
var loaded []protocol.ToolInfo
for _, info := range agentTools {
loaded = append(loaded, info)
}
@ -93,13 +94,13 @@ func (r *Registry) Loaded(agentID string) []toolsrv.ToolInfo {
}
// Lookup returns a tool by name for an agent.
func (r *Registry) Lookup(agentID, name string) (toolsrv.ToolInfo, bool) {
func (r *Registry) Lookup(agentID, name string) (protocol.ToolInfo, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
agentTools, ok := r.agents[agentID]
if !ok {
return toolsrv.ToolInfo{}, false
return protocol.ToolInfo{}, false
}
tool, exists := agentTools[name]

View File

@ -1,4 +1,4 @@
// proc.go - Process management and tool operations for toolsrv.
// proc.go - Process management and tool operations for toolsrvclient.
package server
import (
@ -19,10 +19,10 @@ import (
"ollie/cmd/toolsrv/internal/exec"
"ollie/cmd/toolsrv/internal/registry"
"ollie/paths"
"ollie/toolsrv"
"ollie/toolsrv/protocol"
)
// State implements the filesystem state for toolsrv.
// State implements the filesystem state for toolsrvclient.
type State struct {
mu sync.RWMutex
@ -40,7 +40,7 @@ type State struct {
// Process management
procMu sync.Mutex
procs map[int]*Proc
nextID int
nextID int
procLimit int
// Callbacks
@ -90,7 +90,7 @@ func NewState(cwd string) *State {
cwd: cwd,
env: make(map[string]string),
procs: make(map[int]*Proc),
nextID: 1,
nextID: 1,
procLimit: 32,
}
}
@ -143,7 +143,7 @@ func (st *State) GetEnv(key string) string {
// --- Tool Registry ---
// ListTools returns loaded tools for the given agent.
func (st *State) ListTools(agentID string) []toolsrv.ToolInfo {
func (st *State) ListTools(agentID string) []protocol.ToolInfo {
if agentID == "" {
return nil
}
@ -217,7 +217,7 @@ func (st *State) allocID() int {
// If background is true, returns immediately with pid.
func (st *State) NewProc(ctx context.Context, payload string, background bool) (result string, pid int, err error) {
// Parse payload
args := toolsrv.ParsePayload(payload)
args := protocol.ParsePayload(payload)
toolName := args["tool"]
if toolName == "" {
return "", 0, fmt.Errorf("missing 'tool' in payload")
@ -378,7 +378,7 @@ func (st *State) NewProc(ctx context.Context, payload string, background bool) (
// executeTool runs a tool and returns output + exit code.
// timeout: 0 = no timeout, >0 = seconds.
func (st *State) executeTool(ctx context.Context, info toolsrv.ToolInfo, args map[string]string, cwd string, envExtra map[string]string, yolo bool, timeout int, output io.Writer, started chan *os.Process) (string, int) {
func (st *State) executeTool(ctx context.Context, info protocol.ToolInfo, args map[string]string, cwd string, envExtra map[string]string, yolo bool, timeout int, output io.Writer, started chan *os.Process) (string, int) {
// Convert args map to JSON for the execution path
jsonArgs := argsToJSON(args)

View File

@ -6,7 +6,7 @@ import (
"testing"
"time"
"ollie/toolsrv"
"ollie/toolsrv/protocol"
)
func TestState_Basic(t *testing.T) {
@ -134,7 +134,7 @@ func TestState_HandleProcCtl(t *testing.T) {
func TestProc_Stat(t *testing.T) {
proc := &Proc{
ID: 1,
ID: 1,
Tool: "shell",
StartTime: time.Now(),
done: make(chan struct{}),
@ -191,10 +191,10 @@ func TestParsePayload(t *testing.T) {
}
for _, tt := range tests {
got := toolsrv.ParsePayload(tt.input)
got := protocol.ParsePayload(tt.input)
for k, v := range tt.want {
if got[k] != v {
t.Errorf("toolsrv.ParsePayload(%q)[%q] = %q, want %q", tt.input, k, got[k], v)
t.Errorf("protocol.ParsePayload(%q)[%q] = %q, want %q", tt.input, k, got[k], v)
}
}
}
@ -206,7 +206,7 @@ func TestGcProcs(t *testing.T) {
// Add a proc that's exited and was read long ago
st.procMu.Lock()
st.procs[1] = &Proc{
ID: 1,
ID: 1,
Exited: true,
EndTime: time.Now().Add(-20 * time.Minute),
LastRead: time.Now().Add(-15 * time.Minute),
@ -214,20 +214,20 @@ func TestGcProcs(t *testing.T) {
}
// Add a proc that's exited but never read
st.procs[2] = &Proc{
ID: 2,
ID: 2,
Exited: true,
done: make(chan struct{}),
}
// Add a proc that's exited and recently read
st.procs[3] = &Proc{
ID: 3,
ID: 3,
Exited: true,
LastRead: time.Now(),
done: make(chan struct{}),
}
// Add a still-running proc
st.procs[4] = &Proc{
ID: 4,
ID: 4,
Exited: false,
LastRead: time.Now().Add(-20 * time.Minute),
done: make(chan struct{}),

View File

@ -1,4 +1,4 @@
// spec.go - virtfs-based namespace specification for toolsrv.
// spec.go - virtfs-based namespace specification for toolsrvclient.
package server
import (
@ -15,7 +15,7 @@ import (
"ollie/cmd/toolsrv/internal/bypass"
"ollie/cmd/toolsrv/internal/registry"
"ollie/toolsrv"
"ollie/toolsrv/metadata"
"ollie/virtfs"
)
@ -23,10 +23,10 @@ import (
type Server struct {
mu sync.RWMutex
secret string // set on first auth, verified on subsequent
token string // session token returned after auth
registry *registry.Registry // tool registry
yolo bool // skip sandbox
secret string // set on first auth, verified on subsequent
token string // session token returned after auth
registry *registry.Registry // tool registry
yolo bool // skip sandbox
// Process management
Fs *State
@ -129,7 +129,7 @@ func Spec(srv *Server) virtfs.FsNodeDecl {
virtfs.FileNode("all", 0444,
virtfs.Doc("All available tools on disk: name<tab>description per line"),
virtfs.Read(func() ([]byte, error) {
tools := toolsrv.DiscoverTools()
tools := metadata.DiscoverTools()
var sb strings.Builder
for _, ti := range tools {
fmt.Fprintf(&sb, "%s\t%s\n", ti.Name, ti.Description)

View File

@ -26,8 +26,8 @@ import (
"syscall"
"time"
"ollie/cmd/toolsrv/internal/server"
"ollie/cmd/toolsrv/internal/registry"
"ollie/cmd/toolsrv/internal/server"
"ollie/env"
lib9p "ollie/lib9p/client"
@ -36,11 +36,11 @@ import (
)
var (
cwd = flag.String("cwd", ".", "working directory for execution")
listenPath = flag.String("listen", "", "Unix socket path to listen on (required)")
sessionID = flag.String("session-id", "", "session ID for tool registry")
yolo = flag.Bool("yolo", false, "skip sandbox enforcement")
noAuth = flag.Bool("no-auth", false, "disable Tauth authentication (for debugging)")
cwd = flag.String("cwd", ".", "working directory for execution")
listenPath = flag.String("listen", "", "Unix socket path to listen on (required)")
sessionID = flag.String("session-id", "", "session ID for tool registry")
yolo = flag.Bool("yolo", false, "skip sandbox enforcement")
noAuth = flag.Bool("no-auth", false, "disable Tauth authentication (for debugging)")
idleTimeout = flag.Duration("idle-timeout", 30*time.Second, "exit after this duration with no connections (0 to disable)")
)

View File

@ -1,4 +1,4 @@
// p9.go - 9P filesystem server for toolsrv.
// p9.go - 9P filesystem server for toolsrvclient.
package main
import (

View File

@ -1,5 +1,5 @@
// client9p.go - 9P client for toolsrv.
package toolsrv
// client9p.go - 9P client for toolsrvclient.
package client
import (
"context"
@ -12,9 +12,11 @@ import (
"9fans.net/go/plan9"
p9client "9fans.net/go/plan9/client"
"ollie/toolsrv/protocol"
)
// Conn is a 9P connection to a toolsrv.
// Conn is a 9P connection to a toolsrvclient.
type Conn struct {
fsys *p9client.Fsys
conn *p9client.Conn
@ -102,7 +104,7 @@ func (c *Conn) Token() string {
}
// ListTools returns the list of loaded tools for this agent.
func (c *Conn) ListTools() ([]ToolInfo, error) {
func (c *Conn) ListTools() ([]protocol.ToolInfo, error) {
fid, err := c.fsys.Open("tools", plan9.ORDWR)
if err != nil {
return nil, err
@ -129,7 +131,7 @@ func (c *Conn) ListTools() ([]ToolInfo, error) {
}
}
var tools []ToolInfo
var tools []protocol.ToolInfo
if err := json.Unmarshal(result, &tools); err != nil {
return nil, fmt.Errorf("parse tools: %w", err)
}
@ -245,7 +247,7 @@ func (c *Conn) LoadTool(name string) error {
return err
}
// ListAllTools reads the full catalog of available tools from toolsrv.
// ListAllTools reads the full catalog of available tools from toolsrvclient.
func (c *Conn) ListAllTools() ([]byte, error) {
fid, err := c.fsys.Open("all", plan9.OREAD)
if err != nil {
@ -475,7 +477,7 @@ func (c *Conn) GetDetachedOutput(pid int) (string, error) {
// --- Helper types ---
// HostInfo contains information about the host running toolsrv.
// HostInfo contains information about the host running toolsrvclient.
type HostInfo struct {
Platform string
IsGitRepo bool
@ -539,18 +541,10 @@ func escapeValue(s string) string {
return string(result)
}
// BypassRequest represents a bypass request from toolsrv.
type BypassRequest struct {
ID string `json:"id"`
Cmd string `json:"cmd"`
Cwd string `json:"cwd"`
Env map[string]string `json:"env,omitempty"`
}
// ReadBypassPending blocks until a bypass request is available.
// This should be called in a loop by the approval handler.
// Returns nil, ctx.Err() if context is cancelled.
func (c *Conn) ReadBypassPending(ctx context.Context) (*BypassRequest, error) {
func (c *Conn) ReadBypassPending(ctx context.Context) (*protocol.BypassRequest, error) {
fid, err := c.fsys.Open("bypass/pending", plan9.OREAD)
if err != nil {
return nil, fmt.Errorf("open bypass/pending: %w", err)
@ -588,7 +582,7 @@ func (c *Conn) ReadBypassPending(ctx context.Context) (*BypassRequest, error) {
}
fid.Close()
var req BypassRequest
var req protocol.BypassRequest
if err := json.Unmarshal(result, &req); err != nil {
return nil, fmt.Errorf("parse bypass request: %w", err)
}

View File

@ -0,0 +1,48 @@
// registry.go - Tool discovery and shared types.
package metadata
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"ollie/paths"
"ollie/toolsrv/protocol"
)
// ToolsPath returns ~/.config/ollie/tools.
func ToolsPath() string {
return paths.CfgDir() + "/tools"
}
// DiscoverTools scans the tools directory for .meta files and returns
// metadata for all tools.
func DiscoverTools() []protocol.ToolInfo {
dir := ToolsPath()
entries, err := os.ReadDir(dir)
if err != nil {
return nil
}
var infos []protocol.ToolInfo
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".meta") {
continue
}
name := strings.TrimSuffix(e.Name(), ".meta")
data, err := os.ReadFile(filepath.Join(dir, e.Name()))
if err != nil {
continue
}
var m MetaFile
if err := json.Unmarshal(data, &m); err != nil {
continue
}
info := ToolInfoFromMeta(name, &m)
if info.Name == "" {
continue // no variant matched — tool unavailable on this host
}
infos = append(infos, info)
}
return infos
}

View File

@ -1,8 +1,9 @@
package toolsrv
package metadata
import (
"encoding/json"
"fmt"
"ollie/toolsrv/protocol"
"os"
"os/exec"
"path/filepath"
@ -162,10 +163,10 @@ func checkCondition(key, val string) bool {
// ToolInfoFromMeta builds a ToolInfo from a MetaFile.
// Applies variant resolution first. Returns zero ToolInfo if no variant matches.
func ToolInfoFromMeta(name string, m *MetaFile) ToolInfo {
func ToolInfoFromMeta(name string, m *MetaFile) protocol.ToolInfo {
resolved := m.Resolve()
if resolved == nil {
return ToolInfo{}
return protocol.ToolInfo{}
}
args := resolved.Args
if args == nil {
@ -175,7 +176,7 @@ func ToolInfoFromMeta(name string, m *MetaFile) ToolInfo {
if tier == "" {
tier = "hot"
}
return ToolInfo{
return protocol.ToolInfo{
Name: name,
Description: resolved.Description,
InputSchema: args,

View File

@ -1,5 +1,5 @@
// parse.go — Shared key=value payload parsing.
package toolsrv
package protocol
import "strings"

39
toolsrv/protocol/types.go Normal file
View File

@ -0,0 +1,39 @@
// types.go - Shared types for toolsrv
package protocol
import "encoding/json"
// ToolInfo describes a tool provided by a server.
type ToolInfo struct {
Server string
Name string
Description string
InputSchema json.RawMessage
Prompt string
Tier string
Scope string
OutputFormat string
ResetsCounter bool
}
// ToolResult is the structured result from a tool execution.
type ToolResult struct {
Content []ToolResultContent `json:"content"`
IsError bool `json:"isError,omitempty"`
}
// ToolResultContent is a single content item in a tool result.
type ToolResultContent struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
MediaType string `json:"mediaType,omitempty"`
Data string `json:"data,omitempty"`
}
// BypassRequest represents a bypass request from toolsrv.
type BypassRequest struct {
ID string `json:"id"`
Cmd string `json:"cmd"`
Cwd string `json:"cwd"`
Env map[string]string `json:"env,omitempty"`
}

View File

@ -1,74 +0,0 @@
// registry.go - Tool discovery and shared types.
package toolsrv
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"ollie/paths"
)
// 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
// Scope classifies the tool's concurrency behavior for parallel dispatch:
// "read" — path-scoped read; never conflicts (always parallelizable)
// "write" — path-scoped write; conflicts only on same file path
// "global" — full serialization barrier; runs alone
// Empty defaults to inference: "write" if tool has a path arg, else "global".
Scope string
// OutputFormat is the source-fence language for tool output. Empty means plaintext.
OutputFormat string
// ResetsCounter is true for action-oriented tools (file_write, lsp_rename, etc.)
// that indicate active progress rather than passive research. When true, the
// agent loop resets its step counter, allowing continued work without hitting
// the soft step-budget guardrail.
ResetsCounter bool
}
// ToolsPath returns ~/.config/ollie/tools.
func ToolsPath() string {
return paths.CfgDir() + "/tools"
}
// DiscoverTools scans the tools directory for .meta files and returns
// metadata for all tools.
func DiscoverTools() []ToolInfo {
dir := ToolsPath()
entries, err := os.ReadDir(dir)
if err != nil {
return nil
}
var infos []ToolInfo
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".meta") {
continue
}
name := strings.TrimSuffix(e.Name(), ".meta")
data, err := os.ReadFile(filepath.Join(dir, e.Name()))
if err != nil {
continue
}
var m MetaFile
if err := json.Unmarshal(data, &m); err != nil {
continue
}
info := ToolInfoFromMeta(name, &m)
if info.Name == "" {
continue // no variant matched — tool unavailable on this host
}
infos = append(infos, info)
}
return infos
}

View File

@ -1,16 +0,0 @@
// types.go - Shared types for toolsrv
package toolsrv
// ToolResult is the structured result from a tool execution.
type ToolResult struct {
Content []ToolResultContent `json:"content"`
IsError bool `json:"isError,omitempty"`
}
// ToolResultContent is a single content item in a tool result.
type ToolResultContent struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
MediaType string `json:"mediaType,omitempty"`
Data string `json:"data,omitempty"`
}