From 96dda6e5f03f810122d401a3454f8bbb1b74e01b Mon Sep 17 00:00:00 2001 From: lkn Date: Tue, 28 Jul 2026 14:12:41 +0200 Subject: [PATCH] 9p: files for per-session dynamic tool loading and inspection --- fs/fs.go | 10 +-- fs/fs_test.go | 2 - fs/perm.go | 50 +++++++------ fs/synth.go | 8 +-- fs/tree.go | 2 +- main.go | 47 +++--------- main_test.go | 160 +++++++++++++++++++++++------------------ server/elevate_tree.go | 6 +- server/server.go | 40 +++-------- session/session.go | 28 +++++++- session/sessionfile.go | 55 +++++++++++++- 11 files changed, 228 insertions(+), 180 deletions(-) diff --git a/fs/fs.go b/fs/fs.go index b931e4a..82438ef 100644 --- a/fs/fs.go +++ b/fs/fs.go @@ -52,9 +52,9 @@ type FileConfig struct { Async_ bool } -func (e *FileConfig) Stat() (os.FileInfo, error) { return e.StatFn() } -func (e *FileConfig) Read() ([]byte, error) { return e.ReadFn() } -func (e *FileConfig) Write(data []byte) error { return e.WriteFn(data) } +func (e *FileConfig) Stat() (os.FileInfo, error) { return e.StatFn() } +func (e *FileConfig) Read() ([]byte, error) { return e.ReadFn() } +func (e *FileConfig) Write(data []byte) error { return e.WriteFn(data) } func (e *FileConfig) BlockingRead(ctx context.Context, base string) ([]byte, string, error) { return e.BlockingReadFn(ctx, base) } @@ -84,8 +84,8 @@ type SyntheticEntry struct { IsDir_ bool } -func (e *SyntheticEntry) Name() string { return e.Name_ } -func (e *SyntheticEntry) IsDir() bool { return e.IsDir_ } +func (e *SyntheticEntry) Name() string { return e.Name_ } +func (e *SyntheticEntry) IsDir() bool { return e.IsDir_ } func (e *SyntheticEntry) Type() os.FileMode { if e.IsDir_ { return os.ModeDir diff --git a/fs/fs_test.go b/fs/fs_test.go index 9eeb68a..3c49179 100644 --- a/fs/fs_test.go +++ b/fs/fs_test.go @@ -158,5 +158,3 @@ func TestFlatDirPutMkdirError(t *testing.T) { t.Error("Write should fail when dir doesn't exist") } } - - diff --git a/fs/perm.go b/fs/perm.go index fab5fc0..82b0627 100644 --- a/fs/perm.go +++ b/fs/perm.go @@ -12,8 +12,9 @@ import "os" // Paths without trailing "/" are scoped entries within a parent. // // Template variables: -// {id} — session ID (dynamic, matches any session) -// {file} — file name within a session directory +// +// {id} — session ID (dynamic, matches any session) +// {file} — file name within a session directory const ( PathRoot = "/" PathSessions = "/s/" @@ -67,30 +68,33 @@ var Perms = map[string]Perm{ "cleanup": 0555, }, }, - PathSessionDir: {DirMode: 0755}, - PathTools: {DirMode: 0755, FileMode: 0755}, + PathSessionDir: {DirMode: 0755}, + PathTools: {DirMode: 0755, FileMode: 0755}, PathSessionFile: { Files: map[string]os.FileMode{ - "elevate": 0666, - "plan": 0666, - "ctl": 0666, - "prompt": 0666, - "fifo.in": 0222, - "fifo.out": 0444, - "chat": 0444, - "offset": 0444, - "cfg": 0666, - "state": 0444, - "statewait": 0444, - "usage": 0444, - "cost": 0444, - "ctxsz": 0444, - "models": 0444, + "elevate": 0666, + "plan": 0666, + "ctl": 0666, + "prompt": 0666, + "fifo.in": 0222, + "fifo.out": 0444, + "chat": 0444, + "offset": 0444, + "cfg": 0666, + "state": 0444, + "statewait": 0444, + "usage": 0444, + "cost": 0444, + "ctxsz": 0444, + "models": 0444, "systemprompt": 0444, - "env": 0444, - "tail": 0555, - "prompt.prev": 0444, - "context": 0444, + "env": 0444, + "tail": 0555, + "prompt.prev": 0444, + "context": 0444, + "tools.loaded": 0444, + "tools.rev": 0444, + "tools": 0666, }, }, } diff --git a/fs/synth.go b/fs/synth.go index f0317af..5e2b4a3 100644 --- a/fs/synth.go +++ b/fs/synth.go @@ -11,11 +11,11 @@ type FileSpec struct { Name string Mode os.FileMode Read func() ([]byte, error) - Write func([]byte) error // nil = read-only + Write func([]byte) error // nil = read-only Wait func(ctx context.Context, base string) (content []byte, nextBase string, err error) // nil = not waitable - Size func() int64 // optional; if nil, len(Read()) - OneShot bool // true = yields data once per open - Async bool // true = writes dispatched asynchronously + Size func() int64 // optional; if nil, len(Read()) + OneShot bool // true = yields data once per open + Async bool // true = writes dispatched asynchronously } // NewSynth creates a Tree backed by a table of FileSpecs (synthetic files). diff --git a/fs/tree.go b/fs/tree.go index e2b1981..f3a5197 100644 --- a/fs/tree.go +++ b/fs/tree.go @@ -365,4 +365,4 @@ func (d *Tree) MkdirAll(rel string) error { return fmt.Errorf("no backing directory") } return os.MkdirAll(filepath.Join(d.Tree[0], rel), PermMkdir) -} \ No newline at end of file +} diff --git a/main.go b/main.go index 55b6ef1..0a1ca13 100644 --- a/main.go +++ b/main.go @@ -21,6 +21,7 @@ import ( olog "ollie/pkg/log" "ollie/pkg/paths" "ollie/pkg/tools/execute" + "ollie/pkg/tools/registry" fs "olliesrv/fs" "olliesrv/mount" "olliesrv/server" @@ -159,10 +160,17 @@ func runServer(sockPath string) { agentsDirs := agent.AgentsDirs() sessionsDir := paths.DataDir() + "/sessions" + // Create the tool registry + toolRegistry, regErr := registry.NewRegistry() + if regErr != nil { + fmt.Fprintf(os.Stderr, "warning: tool registry: %v\n", regErr) + } + // D-Bus adapter (initialized after manager so callbacks can reference it). var dbusAdapter *DBusAdapter mgr := session.NewManager(session.ManagerConfig{ + ToolRegistry: toolRegistry, AgentsDir: agentsDirs[0], SessionsDir: sessionsDir, Log: sink.NewLogger("9p"), @@ -248,7 +256,7 @@ func runServer(sockPath string) { srv = server.New(server.Config{ Sink: sink, SessionMgr: mgr, - RootStore: NewRootStore(), + RootStore: NewRootStore(toolRegistry), ElevateBroker: elevateBroker, InvalidateModels: modelCache.Invalidate, ToolPrompt: execute.ToolPrompt, @@ -329,7 +337,7 @@ func runServer(sockPath string) { // ToolIndex generates a tool index from a tree's file listing. // NewRootStore returns a read-only FileTree for synthetic root-level files. -func NewRootStore() *fs.Tree { +func NewRootStore(reg *registry.Registry) *fs.Tree { helpPath := paths.CfgDir() + "/help.md" readEntries := map[string]func() ([]byte, error){ "backends": func() ([]byte, error) { @@ -380,7 +388,7 @@ func NewRootStore() *fs.Tree { if _, ok := readEntries[name]; ok { return name, nil } - if name == "ctl" || name == "complete" || name == "generate" || name == "route" || name == "tools" { + if name == "ctl" || name == "complete" || name == "generate" || name == "route" { return name, nil } return "", fmt.Errorf("%s: not found", name) @@ -394,7 +402,6 @@ func NewRootStore() *fs.Tree { fs.FileEntry("models", fs.Perms[fs.PathRoot].FileMode), fs.FileEntry("help", fs.Perms[fs.PathRoot].FileMode), fs.FileEntry("route", 0666), - fs.FileEntry("tools", 0666), }, nil }), fs.WithOpener(func(_ []string, name string) (fs.File, error) { @@ -422,38 +429,6 @@ func NewRootStore() *fs.Tree { BlockingReadFn: notBlocking, }, nil } - if name == "tools" { - var query string - return &fs.FileConfig{ - StatFn: func() (os.FileInfo, error) { return &fs.SyntheticFileInfo{Name_: "tools", Mode_: 0666}, nil }, - ReadFn: func() ([]byte, error) { - if query != "" { - result := execute.ToolPrompt(query) - query = "" // clear after read - if result == "" { - return []byte("no prompt found for: " + query + "\n"), nil - } - return []byte(result + "\n"), nil - } - // Surface listing - infos := execute.DiscoverTools() - var sb strings.Builder - for _, ti := range infos { - if ti.Description != "" { - fmt.Fprintf(&sb, "%-20s %s\n", ti.Name, ti.Description) - } else { - sb.WriteString(ti.Name + "\n") - } - } - return []byte(sb.String()), nil - }, - WriteFn: func(data []byte) error { - query = strings.TrimSpace(string(data)) - return nil - }, - BlockingReadFn: notBlocking, - }, nil - } if name == "ctl" { return &fs.FileConfig{ StatFn: func() (os.FileInfo, error) { return &fs.SyntheticFileInfo{Name_: "ctl", Mode_: 0666}, nil }, diff --git a/main_test.go b/main_test.go index dbbc630..d327cd8 100644 --- a/main_test.go +++ b/main_test.go @@ -14,29 +14,29 @@ import ( "ollie/pkg/agent" "ollie/pkg/backend" olog "ollie/pkg/log" - "olliesrv/session" "olliesrv/fs" + "olliesrv/session" ) // --- stub agent.Core --- type stubCore struct { - state string - running bool - backend_ string - model string - agentName string - cwd string - usage string - ctxsz string - models string - sysprompt string - reply string - params backend.GenerationParams - closed bool - waitCh chan string - submitted []string - queued []string + state string + running bool + backend_ string + model string + agentName string + cwd string + usage string + ctxsz string + models string + sysprompt string + reply string + params backend.GenerationParams + closed bool + waitCh chan string + submitted []string + queued []string interrupted bool setSessionIDErr error reactResponseID string @@ -54,59 +54,65 @@ func (c *stubCore) Submit(_ context.Context, input string) { close(c.submitCh) } } -func (c *stubCore) Interrupt(error) bool { c.interrupted = true; return c.running } -func (c *stubCore) Inject(string) {} -func (c *stubCore) Queue(s string) { c.queued = append(c.queued, s) } +func (c *stubCore) Interrupt(error) bool { c.interrupted = true; return c.running } +func (c *stubCore) Inject(string) {} +func (c *stubCore) Queue(s string) { c.queued = append(c.queued, s) } func (c *stubCore) PopQueue() (string, bool) { - if len(c.queued) == 0 { return "", false } - s := c.queued[0]; c.queued = c.queued[1:]; return s, true + if len(c.queued) == 0 { + return "", false + } + s := c.queued[0] + c.queued = c.queued[1:] + return s, true } -func (c *stubCore) IsRunning() bool { return c.running } -func (c *stubCore) State() string { return c.state } -func (c *stubCore) Reply() string { return c.reply } -func (c *stubCore) AgentName() string { return c.agentName } -func (c *stubCore) BackendName() string { return c.backend_ } -func (c *stubCore) ModelName() string { return c.model } -func (c *stubCore) CtxSz() string { return c.ctxsz } -func (c *stubCore) Usage() string { return c.usage } -func (c *stubCore) Cost() string { return "" } -func (c *stubCore) ListModels() string { return c.models } -func (c *stubCore) CWD() string { return c.cwd } -func (c *stubCore) SetCWD(dir string) error { c.cwd = dir; return nil } -func (c *stubCore) SetSessionID(string) error { return c.setSessionIDErr } -func (c *stubCore) Context() []backend.Message { return nil } -func (c *stubCore) SystemPrompt() string { return c.sysprompt } -func (c *stubCore) GenerationParams() backend.GenerationParams { return c.params } -func (c *stubCore) SetGenerationParams(p backend.GenerationParams) error { c.params = p; return nil } -func (c *stubCore) SetEnv(string, string) {} +func (c *stubCore) IsRunning() bool { return c.running } +func (c *stubCore) State() string { return c.state } +func (c *stubCore) Reply() string { return c.reply } +func (c *stubCore) AgentName() string { return c.agentName } +func (c *stubCore) BackendName() string { return c.backend_ } +func (c *stubCore) ModelName() string { return c.model } +func (c *stubCore) CtxSz() string { return c.ctxsz } +func (c *stubCore) Usage() string { return c.usage } +func (c *stubCore) Cost() string { return "" } +func (c *stubCore) ListModels() string { return c.models } +func (c *stubCore) CWD() string { return c.cwd } +func (c *stubCore) SetCWD(dir string) error { c.cwd = dir; return nil } +func (c *stubCore) SetSessionID(string) error { return c.setSessionIDErr } +func (c *stubCore) Context() []backend.Message { return nil } +func (c *stubCore) SystemPrompt() string { return c.sysprompt } +func (c *stubCore) GenerationParams() backend.GenerationParams { return c.params } +func (c *stubCore) SetGenerationParams(p backend.GenerationParams) error { c.params = p; return nil } +func (c *stubCore) SetEnv(string, string) {} func (c *stubCore) WaitChange(ctx context.Context, _, _ string) (string, bool) { if c.waitCh != nil { select { - case v := <-c.waitCh: return v, true - case <-ctx.Done(): return "", false + case v := <-c.waitCh: + return v, true + case <-ctx.Done(): + return "", false } } <-ctx.Done() return "", false } -func (c *stubCore) Close() { c.closed = true } -func (c *stubCore) Detach() bool { return false } -func (c *stubCore) ListDetached() []agent.DetachedInfo { return nil } -func (c *stubCore) SignalDetached(int, int) error { return nil } -func (c *stubCore) GetDetachedOutput(int) (string, error) { return "", nil } -func (c *stubCore) DismissDetached(int) bool { return false } -func (c *stubCore) InjectSystemEvent(string) {} -func (c *stubCore) Reactions() map[string]string { return nil } -func (c *stubCore) React(string) {} +func (c *stubCore) Close() { c.closed = true } +func (c *stubCore) Detach() bool { return false } +func (c *stubCore) ListDetached() []agent.DetachedInfo { return nil } +func (c *stubCore) SignalDetached(int, int) error { return nil } +func (c *stubCore) GetDetachedOutput(int) (string, error) { return "", nil } +func (c *stubCore) DismissDetached(int) bool { return false } +func (c *stubCore) InjectSystemEvent(string) {} +func (c *stubCore) Reactions() map[string]string { return nil } +func (c *stubCore) React(string) {} func (c *stubCore) ReactTo(responseID, emoji string) error { c.reactResponseID = responseID c.reactEmoji = emoji return nil } -func (c *stubCore) SaveSession(string) error { return nil } -func (c *stubCore) ToolCallCount() int64 { return 0 } -func (c *stubCore) CompactionModel() string { return "" } -func (c *stubCore) SetCompactionModel(string) {} +func (c *stubCore) SaveSession(string) error { return nil } +func (c *stubCore) ToolCallCount() int64 { return 0 } +func (c *stubCore) CompactionModel() string { return "" } +func (c *stubCore) SetCompactionModel(string) {} func (c *stubCore) Bus() *pubsub.Bus { if c.bus_ == nil { c.bus_ = pubsub.NewBus() @@ -115,6 +121,7 @@ func (c *stubCore) Bus() *pubsub.Bus { } type publishCore struct{ *stubCore } + func (c *publishCore) Submit(_ context.Context, input string) { c.submitted = append(c.submitted, input) bus := c.Bus() @@ -126,6 +133,7 @@ func (c *publishCore) Submit(_ context.Context, input string) { } type blockingCore struct{ *stubCore } + func (c *blockingCore) Submit(ctx context.Context, input string) { c.submitted = append(c.submitted, input) <-ctx.Done() @@ -166,23 +174,31 @@ func newTestSessionManagerWithCore(t *testing.T) *session.Manager { func testStoreRead(t *testing.T, s fs.FileTree, name string) []byte { t.Helper() e, err := s.Open(name) - if err != nil { t.Fatalf("Open(%s): %v", name, err) } + if err != nil { + t.Fatalf("Open(%s): %v", name, err) + } data, err := e.Read() - if err != nil { t.Fatalf("Read(%s): %v", name, err) } + if err != nil { + t.Fatalf("Read(%s): %v", name, err) + } return data } func testStoreWrite(t *testing.T, s fs.FileTree, name string, data []byte) { t.Helper() e, err := s.Open(name) - if err != nil { t.Fatalf("Open(%s): %v", name, err) } - if err := e.Write(data); err != nil { t.Fatalf("Write(%s): %v", name, err) } + if err != nil { + t.Fatalf("Open(%s): %v", name, err) + } + if err := e.Write(data); err != nil { + t.Fatalf("Write(%s): %v", name, err) + } } -var _ = time.Second // suppress unused import +var _ = time.Second // suppress unused import var _ = strings.Contains // suppress unused import -var _ = fmt.Sprintf // suppress unused import -var _ = os.Remove // suppress unused import +var _ = fmt.Sprintf // suppress unused import +var _ = os.Remove // suppress unused import func newTestSessionFileStore(t *testing.T, sess *session.Session) (*fs.Tree, *stubCore) { t.Helper() @@ -193,6 +209,7 @@ func newTestSessionFileStore(t *testing.T, sess *session.Session) (*fs.Tree, *st func(id string) error { return nil }, func(data []byte) error { return nil }, func() {}, + nil, ) return sf, core } @@ -200,8 +217,9 @@ func newTestSessionFileStore(t *testing.T, sess *session.Session) (*fs.Tree, *st func newTestSessionFileStoreWith(t *testing.T, sess *session.Session, kill func(), rename func(string) error, save func([]byte) error) *fs.Tree { t.Helper() sink := testSink() - return session.NewSessionTree(sess, sink.NewLogger("test"), kill, rename, save, func() {}) + return session.NewSessionTree(sess, sink.NewLogger("test"), kill, rename, save, func() {}, nil) } + // ===== session.Session ===== func TestSessionAppendLog(t *testing.T) { @@ -418,7 +436,7 @@ func TestSessionFileStoreReadableContract(t *testing.T) { defer sess.Cancel() sink := testSink() _ = session.NewSessionTree(sess, sink.NewLogger("test"), - func() {}, func(string) error { return nil }, func([]byte) error { return nil }, func() {}) + func() {}, func(string) error { return nil }, func([]byte) error { return nil }, func() {}, nil) } func TestSessionFileStoreList(t *testing.T) { @@ -426,7 +444,7 @@ func TestSessionFileStoreList(t *testing.T) { defer sess.Cancel() sink := testSink() sf := session.NewSessionTree(sess, sink.NewLogger("test"), - func() {}, func(string) error { return nil }, func([]byte) error { return nil }, func() {}) + func() {}, func(string) error { return nil }, func([]byte) error { return nil }, func() {}, nil) entries, err := sf.List() if err != nil { @@ -443,7 +461,7 @@ func TestSessionFileStoreStatChat(t *testing.T) { sess.AppendLog([]byte("hello")) sink := testSink() sf := session.NewSessionTree(sess, sink.NewLogger("test"), - func() {}, func(string) error { return nil }, func([]byte) error { return nil }, func() {}) + func() {}, func(string) error { return nil }, func([]byte) error { return nil }, func() {}, nil) fi, err := sf.Stat("chat") if err != nil { @@ -460,7 +478,7 @@ func TestSessionFileStoreGetChat(t *testing.T) { sess.AppendLog([]byte("hello")) sink := testSink() sf := session.NewSessionTree(sess, sink.NewLogger("test"), - func() {}, func(string) error { return nil }, func([]byte) error { return nil }, func() {}) + func() {}, func(string) error { return nil }, func([]byte) error { return nil }, func() {}, nil) data := testStoreRead(t, sf, "chat") if string(data) != "hello" { @@ -473,7 +491,7 @@ func TestSessionFileStoreGetContent(t *testing.T) { defer sess.Cancel() sink := testSink() sf := session.NewSessionTree(sess, sink.NewLogger("test"), - func() {}, func(string) error { return nil }, func([]byte) error { return nil }, func() {}) + func() {}, func(string) error { return nil }, func([]byte) error { return nil }, func() {}, nil) for _, name := range []string{"cfg", "offset", "usage", "ctxsz", "models", "systemprompt"} { if _, err := sf.Open(name); err != nil { @@ -487,7 +505,7 @@ func TestSessionFileStorePutCwd(t *testing.T) { defer sess.Cancel() sink := testSink() sf := session.NewSessionTree(sess, sink.NewLogger("test"), - func() {}, func(string) error { return nil }, func([]byte) error { return nil }, func() {}) + func() {}, func(string) error { return nil }, func([]byte) error { return nil }, func() {}, nil) testStoreWrite(t, sf, "cfg", []byte("cwd=/new/path")) core := sess.Core.(*stubCore) @@ -501,7 +519,7 @@ func TestSessionFileStorePutEmpty(t *testing.T) { defer sess.Cancel() sink := testSink() sf := session.NewSessionTree(sess, sink.NewLogger("test"), - func() {}, func(string) error { return nil }, func([]byte) error { return nil }, func() {}) + func() {}, func(string) error { return nil }, func([]byte) error { return nil }, func() {}, nil) // Empty write is a no-op e, err := sf.Open("cfg") @@ -949,7 +967,7 @@ func TestSessionReactFile(t *testing.T) { defer cancel() core := &stubCore{state: "idle"} sess := session.NewSession("s1", core, ctx, cancel) - store := session.NewSessionTree(sess, testSink().NewLogger("test"), func() {}, func(string) error { return nil }, nil, nil) + store := session.NewSessionTree(sess, testSink().NewLogger("test"), func() {}, func(string) error { return nil }, nil, nil, nil) testStoreWrite(t, store, "react", []byte("👍")) if core.reactResponseID != "" || core.reactEmoji != "👍" { diff --git a/server/elevate_tree.go b/server/elevate_tree.go index 7feee46..bdcd973 100644 --- a/server/elevate_tree.go +++ b/server/elevate_tree.go @@ -59,9 +59,9 @@ func (t *elevateTree) Open(name string) (fs.File, error) { return nil, fmt.Errorf("%s: not found", name) } -func (t *elevateTree) Create(name string) error { return fmt.Errorf("cannot create in elevate") } -func (t *elevateTree) Delete(name string) error { return fmt.Errorf("cannot delete in elevate") } -func (t *elevateTree) Rename(_, _ string) error { return fmt.Errorf("cannot rename in elevate") } +func (t *elevateTree) Create(name string) error { return fmt.Errorf("cannot create in elevate") } +func (t *elevateTree) Delete(name string) error { return fmt.Errorf("cannot delete in elevate") } +func (t *elevateTree) Rename(_, _ string) error { return fmt.Errorf("cannot rename in elevate") } // --- policy file --- diff --git a/server/server.go b/server/server.go index 006123f..c3e56d0 100644 --- a/server/server.go +++ b/server/server.go @@ -19,6 +19,7 @@ import ( "ollie/pkg/elevate" olog "ollie/pkg/log" "ollie/pkg/paths" + "ollie/pkg/tools/registry" "olliesrv/fs" "olliesrv/session" @@ -27,8 +28,8 @@ import ( // Re-export fs types for use in server.go. type ( - File = fs.File - FileTree = fs.FileTree + File = fs.File + FileTree = fs.FileTree syntheticFileInfo = fs.SyntheticFileInfo ) @@ -74,6 +75,7 @@ type Server struct { groups map[string]map[string]bool // group → set of members invalidateModels func() toolPrompt func(string) string + toolRegistry *registry.Registry } // Config holds the pre-built trees and manager for the server. @@ -84,6 +86,8 @@ type Config struct { ElevateBroker *elevate.Broker InvalidateModels func() ToolPrompt func(string) string + // ToolRegistry is the shared tool registry for lazy tool promotion. + ToolRegistry *registry.Registry } // Option configures the 9P server. @@ -99,6 +103,7 @@ func New(cfg Config) *Server { groups: make(map[string]map[string]bool), invalidateModels: cfg.InvalidateModels, toolPrompt: cfg.ToolPrompt, + toolRegistry: cfg.ToolRegistry, } if cfg.ElevateBroker != nil { s.elevateTree = newElevateTree(cfg.ElevateBroker) @@ -312,12 +317,6 @@ func (s *Server) checkPerm(uname, path string, mode uint8) error { return nil } - - - - - - // readFile opens an entry in a tree and reads it. func readFile(s fs.FileTree, name string) ([]byte, error) { e, err := s.Open(name) @@ -798,12 +797,6 @@ func (s *Server) read(cs *connState, fc *plan9.Fcall, ctx context.Context) *plan return s.readDNS(fc, &f.routeResult) } - // /tools: return per-fid result from previous write (offset 0-based) - if path == "/tools" { - s.log.Debug("Tread path=%q offset=%d count=%d", path, fc.Offset, fc.Count) - return s.readDNS(fc, &f.toolsResult) - } - s.log.Debug("Tread path=%q offset=%d count=%d", path, fc.Offset, fc.Count) entry, err := s.openEntry(path) if err != nil { @@ -960,22 +953,6 @@ func (s *Server) write(cs *connState, fc *plan9.Fcall) *plan9.Fcall { return &plan9.Fcall{Type: plan9.Rwrite, Tag: fc.Tag, Count: uint32(len(fc.Data))} } - // /tools: lookup tool prompt by name (per-fid state, like /net/dns) - if f.path == "/tools" { - cs.mu.Unlock() - name := strings.TrimSpace(string(fc.Data)) - if s.toolPrompt == nil { - return errFcall(fc, "tool lookup not configured") - } - result := s.toolPrompt(name) - if result == "" { - f.toolsResult = []byte("no prompt found for: " + name + "\n") - } else { - f.toolsResult = []byte(result + "\n") - } - return &plan9.Fcall{Type: plan9.Rwrite, Tag: fc.Tag, Count: uint32(len(fc.Data))} - } - // Accumulate; the 9P client may split large writes across multiple Twrite messages. end := int(fc.Offset) + len(fc.Data) if end > len(f.writeBuf) { @@ -1097,7 +1074,6 @@ func (s *Server) clunk(cs *connState, fc *plan9.Fcall) *plan9.Fcall { return &plan9.Fcall{Type: plan9.Rclunk, Tag: fc.Tag} } - func (s *Server) remove(cs *connState, fc *plan9.Fcall) *plan9.Fcall { cs.mu.Lock() f, ok := cs.fids[fc.Fid] @@ -1127,6 +1103,8 @@ func (s *Server) remove(cs *connState, fc *plan9.Fcall) *plan9.Fcall { // as a goroutine because they block for the entire agent turn). func (s *Server) handleWrite(path, input, uname string) error { s.log.Debug("handleWrite path=%q input_len=%d uname=%q", path, len(input), uname) + + // Handle /tools/load - promote a tool for the current session st, name := s.route(path) if st == nil { return nil diff --git a/session/session.go b/session/session.go index 0325dde..3310b02 100644 --- a/session/session.go +++ b/session/session.go @@ -21,6 +21,7 @@ import ( "ollie/pkg/remote" "ollie/pkg/tools" "ollie/pkg/tools/execute" + "ollie/pkg/tools/registry" "olliesrv/fs" "olliesrv/prompts" ) @@ -249,6 +250,8 @@ type ManagerConfig struct { EnableDBus bool // InvalidateModels clears the model cache, forcing a refresh. InvalidateModels func() + // ToolRegistry is the shared tool registry for lazy tool promotion. + ToolRegistry *registry.Registry // OnSessionCreated is called after a new session is added to the manager. // Receives the session ID and the Session pointer. OnSessionCreated func(id string, sess *Session) @@ -707,6 +710,7 @@ func (s *Manager) openStore(sess *Session) (*fs.Tree, error) { func(newID string) error { return s.renameSession(sess.id, newID) }, nil, // no transcript saving s.cfg.InvalidateModels, + s.cfg.ToolRegistry, ), nil } @@ -901,7 +905,17 @@ func (s *Manager) restoreSession(ps *agent.PersistedSession) error { } envBlock := prompts.Environment(cwd, platform, isGitRepo, "") - rt := agent.BuildRuntime(cfg, newDisp(), cwd, env, sysPrompt, opModel, envBlock) + disp := newDisp() + // Register the registry-backed tool server for lazy tool promotion + if s.cfg.ToolRegistry != nil { + if execSrv, ok := disp.GetServer("execute"); ok { + if es, ok := execSrv.(*execute.Server); ok { + regSrv := registry.NewServer(s.cfg.ToolRegistry, sessID, es) + disp.AddServer("registry", regSrv) + } + } + } + rt := agent.BuildRuntime(cfg, disp, cwd, env, sysPrompt, opModel, envBlock) restoredSession := agent.RestoreSession(ps) @@ -1218,7 +1232,17 @@ func (s *Manager) CreateSession(args []string) (string, error) { } envBlock := prompts.Environment(cwd, platform, isGitRepo, "") - rt := agent.BuildRuntime(cfg, newDisp(), cwd, env, sysPrompt, opModel, envBlock) + disp := newDisp() + // Register the registry-backed tool server for lazy tool promotion + if s.cfg.ToolRegistry != nil { + if execSrv, ok := disp.GetServer("execute"); ok { + if es, ok := execSrv.(*execute.Server); ok { + regSrv := registry.NewServer(s.cfg.ToolRegistry, sessID, es) + disp.AddServer("registry", regSrv) + } + } + } + rt := agent.BuildRuntime(cfg, disp, cwd, env, sysPrompt, opModel, envBlock) // sessPtr is set after NewSession; the ReadPlanStep closure captures it. core = agent.NewAgentCore(agent.AgentCoreConfig{ diff --git a/session/sessionfile.go b/session/sessionfile.go index 0ed8a0b..579ff19 100644 --- a/session/sessionfile.go +++ b/session/sessionfile.go @@ -11,6 +11,7 @@ import ( "ollie/pkg/agent" "ollie/pkg/backend" olog "ollie/pkg/log" + "ollie/pkg/tools/registry" "olliesrv/fs" ) @@ -42,6 +43,9 @@ var FileList = []struct { {"context", false, false}, {"react", false, true}, {"reactions", false, false}, + {"tools.loaded", false, false}, + {"tools.rev", false, false}, + {"tools", false, false}, } // sessionFilePerms returns the permission registry for per-session files. @@ -49,8 +53,8 @@ func sessionFilePerms() map[string]os.FileMode { return fs.Perms[fs.PathSessionFile].Files } -func NewSessionTree(sess *Session, log *olog.Logger, kill func(), rename func(newID string) error, saveTranscript func([]byte) error, invalidateModels func()) *fs.Tree { - h := &sessionHelper{sess: sess, log: log, kill: kill, rename: rename, saveTranscript: saveTranscript, invalidateModels: invalidateModels} +func NewSessionTree(sess *Session, log *olog.Logger, kill func(), rename func(newID string) error, saveTranscript func([]byte) error, invalidateModels func(), toolRegistry *registry.Registry) *fs.Tree { + h := &sessionHelper{sess: sess, log: log, kill: kill, rename: rename, saveTranscript: saveTranscript, invalidateModels: invalidateModels, toolRegistry: toolRegistry} perms := sessionFilePerms() specs := make([]fs.FileSpec, len(FileList)) for i, f := range FileList { @@ -69,6 +73,7 @@ type sessionHelper struct { rename func(newID string) error saveTranscript func([]byte) error invalidateModels func() + toolRegistry *registry.Registry } func (h *sessionHelper) fileSpec(name string, mode os.FileMode) fs.FileSpec { @@ -200,6 +205,17 @@ func (h *sessionHelper) fileSpec(name string, mode os.FileMode) fs.FileSpec { } return h.handleCfg(input) } + case "tools": + fs.Write = func(data []byte) error { + input := strings.TrimSpace(string(data)) + if input == "" { + return nil + } + if h.toolRegistry == nil { + return fmt.Errorf("registry not available") + } + return h.toolRegistry.Load(h.sess.id, input) + } case "react": fs.Write = func(data []byte) error { input := strings.TrimSpace(string(data)) @@ -276,6 +292,41 @@ func (h *sessionHelper) content(name string) string { enc.Encode(m) } return sb.String() + case "tools.loaded": + if h.toolRegistry == nil { + return "registry not available\n" + } + loaded := h.toolRegistry.Loaded(h.sess.id) + if len(loaded) == 0 { + return "(no tools loaded)\n" + } + 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 sb.String() + case "tools.rev": + if h.toolRegistry == nil { + return "0\n" + } + return fmt.Sprintf("%d\n", h.toolRegistry.Revision(h.sess.id)) + case "tools": + if h.toolRegistry == nil { + return "registry not available\n" + } + var sb strings.Builder + for _, ti := range h.toolRegistry.Summaries() { + if ti.Description != "" { + fmt.Fprintf(&sb, "%-20s %s\n", ti.Name, ti.Description) + } else { + sb.WriteString(ti.Name + "\n") + } + } + return sb.String() } return "" }