9p: files for per-session dynamic tool loading and inspection

This commit is contained in:
lkn 2026-07-28 14:12:41 +02:00
parent f1f039da20
commit 96dda6e5f0
11 changed files with 228 additions and 180 deletions

View File

@ -52,9 +52,9 @@ type FileConfig struct {
Async_ bool Async_ bool
} }
func (e *FileConfig) Stat() (os.FileInfo, error) { return e.StatFn() } func (e *FileConfig) Stat() (os.FileInfo, error) { return e.StatFn() }
func (e *FileConfig) Read() ([]byte, error) { return e.ReadFn() } func (e *FileConfig) Read() ([]byte, error) { return e.ReadFn() }
func (e *FileConfig) Write(data []byte) error { return e.WriteFn(data) } func (e *FileConfig) Write(data []byte) error { return e.WriteFn(data) }
func (e *FileConfig) BlockingRead(ctx context.Context, base string) ([]byte, string, error) { func (e *FileConfig) BlockingRead(ctx context.Context, base string) ([]byte, string, error) {
return e.BlockingReadFn(ctx, base) return e.BlockingReadFn(ctx, base)
} }
@ -84,8 +84,8 @@ type SyntheticEntry struct {
IsDir_ bool IsDir_ bool
} }
func (e *SyntheticEntry) Name() string { return e.Name_ } func (e *SyntheticEntry) Name() string { return e.Name_ }
func (e *SyntheticEntry) IsDir() bool { return e.IsDir_ } func (e *SyntheticEntry) IsDir() bool { return e.IsDir_ }
func (e *SyntheticEntry) Type() os.FileMode { func (e *SyntheticEntry) Type() os.FileMode {
if e.IsDir_ { if e.IsDir_ {
return os.ModeDir return os.ModeDir

View File

@ -158,5 +158,3 @@ func TestFlatDirPutMkdirError(t *testing.T) {
t.Error("Write should fail when dir doesn't exist") t.Error("Write should fail when dir doesn't exist")
} }
} }

View File

@ -12,8 +12,9 @@ import "os"
// Paths without trailing "/" are scoped entries within a parent. // Paths without trailing "/" are scoped entries within a parent.
// //
// Template variables: // 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 ( const (
PathRoot = "/" PathRoot = "/"
PathSessions = "/s/" PathSessions = "/s/"
@ -67,30 +68,33 @@ var Perms = map[string]Perm{
"cleanup": 0555, "cleanup": 0555,
}, },
}, },
PathSessionDir: {DirMode: 0755}, PathSessionDir: {DirMode: 0755},
PathTools: {DirMode: 0755, FileMode: 0755}, PathTools: {DirMode: 0755, FileMode: 0755},
PathSessionFile: { PathSessionFile: {
Files: map[string]os.FileMode{ Files: map[string]os.FileMode{
"elevate": 0666, "elevate": 0666,
"plan": 0666, "plan": 0666,
"ctl": 0666, "ctl": 0666,
"prompt": 0666, "prompt": 0666,
"fifo.in": 0222, "fifo.in": 0222,
"fifo.out": 0444, "fifo.out": 0444,
"chat": 0444, "chat": 0444,
"offset": 0444, "offset": 0444,
"cfg": 0666, "cfg": 0666,
"state": 0444, "state": 0444,
"statewait": 0444, "statewait": 0444,
"usage": 0444, "usage": 0444,
"cost": 0444, "cost": 0444,
"ctxsz": 0444, "ctxsz": 0444,
"models": 0444, "models": 0444,
"systemprompt": 0444, "systemprompt": 0444,
"env": 0444, "env": 0444,
"tail": 0555, "tail": 0555,
"prompt.prev": 0444, "prompt.prev": 0444,
"context": 0444, "context": 0444,
"tools.loaded": 0444,
"tools.rev": 0444,
"tools": 0666,
}, },
}, },
} }

View File

@ -11,11 +11,11 @@ type FileSpec struct {
Name string Name string
Mode os.FileMode Mode os.FileMode
Read func() ([]byte, error) 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 Wait func(ctx context.Context, base string) (content []byte, nextBase string, err error) // nil = not waitable
Size func() int64 // optional; if nil, len(Read()) Size func() int64 // optional; if nil, len(Read())
OneShot bool // true = yields data once per open OneShot bool // true = yields data once per open
Async bool // true = writes dispatched asynchronously Async bool // true = writes dispatched asynchronously
} }
// NewSynth creates a Tree backed by a table of FileSpecs (synthetic files). // NewSynth creates a Tree backed by a table of FileSpecs (synthetic files).

View File

@ -365,4 +365,4 @@ func (d *Tree) MkdirAll(rel string) error {
return fmt.Errorf("no backing directory") return fmt.Errorf("no backing directory")
} }
return os.MkdirAll(filepath.Join(d.Tree[0], rel), PermMkdir) return os.MkdirAll(filepath.Join(d.Tree[0], rel), PermMkdir)
} }

47
main.go
View File

@ -21,6 +21,7 @@ import (
olog "ollie/pkg/log" olog "ollie/pkg/log"
"ollie/pkg/paths" "ollie/pkg/paths"
"ollie/pkg/tools/execute" "ollie/pkg/tools/execute"
"ollie/pkg/tools/registry"
fs "olliesrv/fs" fs "olliesrv/fs"
"olliesrv/mount" "olliesrv/mount"
"olliesrv/server" "olliesrv/server"
@ -159,10 +160,17 @@ func runServer(sockPath string) {
agentsDirs := agent.AgentsDirs() agentsDirs := agent.AgentsDirs()
sessionsDir := paths.DataDir() + "/sessions" 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). // D-Bus adapter (initialized after manager so callbacks can reference it).
var dbusAdapter *DBusAdapter var dbusAdapter *DBusAdapter
mgr := session.NewManager(session.ManagerConfig{ mgr := session.NewManager(session.ManagerConfig{
ToolRegistry: toolRegistry,
AgentsDir: agentsDirs[0], AgentsDir: agentsDirs[0],
SessionsDir: sessionsDir, SessionsDir: sessionsDir,
Log: sink.NewLogger("9p"), Log: sink.NewLogger("9p"),
@ -248,7 +256,7 @@ func runServer(sockPath string) {
srv = server.New(server.Config{ srv = server.New(server.Config{
Sink: sink, Sink: sink,
SessionMgr: mgr, SessionMgr: mgr,
RootStore: NewRootStore(), RootStore: NewRootStore(toolRegistry),
ElevateBroker: elevateBroker, ElevateBroker: elevateBroker,
InvalidateModels: modelCache.Invalidate, InvalidateModels: modelCache.Invalidate,
ToolPrompt: execute.ToolPrompt, ToolPrompt: execute.ToolPrompt,
@ -329,7 +337,7 @@ func runServer(sockPath string) {
// ToolIndex generates a tool index from a tree's file listing. // ToolIndex generates a tool index from a tree's file listing.
// NewRootStore returns a read-only FileTree for synthetic root-level files. // 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" helpPath := paths.CfgDir() + "/help.md"
readEntries := map[string]func() ([]byte, error){ readEntries := map[string]func() ([]byte, error){
"backends": func() ([]byte, error) { "backends": func() ([]byte, error) {
@ -380,7 +388,7 @@ func NewRootStore() *fs.Tree {
if _, ok := readEntries[name]; ok { if _, ok := readEntries[name]; ok {
return name, nil 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 name, nil
} }
return "", fmt.Errorf("%s: not found", name) 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("models", fs.Perms[fs.PathRoot].FileMode),
fs.FileEntry("help", fs.Perms[fs.PathRoot].FileMode), fs.FileEntry("help", fs.Perms[fs.PathRoot].FileMode),
fs.FileEntry("route", 0666), fs.FileEntry("route", 0666),
fs.FileEntry("tools", 0666),
}, nil }, nil
}), }),
fs.WithOpener(func(_ []string, name string) (fs.File, error) { fs.WithOpener(func(_ []string, name string) (fs.File, error) {
@ -422,38 +429,6 @@ func NewRootStore() *fs.Tree {
BlockingReadFn: notBlocking, BlockingReadFn: notBlocking,
}, nil }, 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" { if name == "ctl" {
return &fs.FileConfig{ return &fs.FileConfig{
StatFn: func() (os.FileInfo, error) { return &fs.SyntheticFileInfo{Name_: "ctl", Mode_: 0666}, nil }, StatFn: func() (os.FileInfo, error) { return &fs.SyntheticFileInfo{Name_: "ctl", Mode_: 0666}, nil },

View File

@ -14,29 +14,29 @@ import (
"ollie/pkg/agent" "ollie/pkg/agent"
"ollie/pkg/backend" "ollie/pkg/backend"
olog "ollie/pkg/log" olog "ollie/pkg/log"
"olliesrv/session"
"olliesrv/fs" "olliesrv/fs"
"olliesrv/session"
) )
// --- stub agent.Core --- // --- stub agent.Core ---
type stubCore struct { type stubCore struct {
state string state string
running bool running bool
backend_ string backend_ string
model string model string
agentName string agentName string
cwd string cwd string
usage string usage string
ctxsz string ctxsz string
models string models string
sysprompt string sysprompt string
reply string reply string
params backend.GenerationParams params backend.GenerationParams
closed bool closed bool
waitCh chan string waitCh chan string
submitted []string submitted []string
queued []string queued []string
interrupted bool interrupted bool
setSessionIDErr error setSessionIDErr error
reactResponseID string reactResponseID string
@ -54,59 +54,65 @@ func (c *stubCore) Submit(_ context.Context, input string) {
close(c.submitCh) close(c.submitCh)
} }
} }
func (c *stubCore) Interrupt(error) bool { c.interrupted = true; return c.running } func (c *stubCore) Interrupt(error) bool { c.interrupted = true; return c.running }
func (c *stubCore) Inject(string) {} func (c *stubCore) Inject(string) {}
func (c *stubCore) Queue(s string) { c.queued = append(c.queued, s) } func (c *stubCore) Queue(s string) { c.queued = append(c.queued, s) }
func (c *stubCore) PopQueue() (string, bool) { func (c *stubCore) PopQueue() (string, bool) {
if len(c.queued) == 0 { return "", false } if len(c.queued) == 0 {
s := c.queued[0]; c.queued = c.queued[1:]; return s, true 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) IsRunning() bool { return c.running }
func (c *stubCore) State() string { return c.state } func (c *stubCore) State() string { return c.state }
func (c *stubCore) Reply() string { return c.reply } func (c *stubCore) Reply() string { return c.reply }
func (c *stubCore) AgentName() string { return c.agentName } func (c *stubCore) AgentName() string { return c.agentName }
func (c *stubCore) BackendName() string { return c.backend_ } func (c *stubCore) BackendName() string { return c.backend_ }
func (c *stubCore) ModelName() string { return c.model } func (c *stubCore) ModelName() string { return c.model }
func (c *stubCore) CtxSz() string { return c.ctxsz } func (c *stubCore) CtxSz() string { return c.ctxsz }
func (c *stubCore) Usage() string { return c.usage } func (c *stubCore) Usage() string { return c.usage }
func (c *stubCore) Cost() string { return "" } func (c *stubCore) Cost() string { return "" }
func (c *stubCore) ListModels() string { return c.models } func (c *stubCore) ListModels() string { return c.models }
func (c *stubCore) CWD() string { return c.cwd } func (c *stubCore) CWD() string { return c.cwd }
func (c *stubCore) SetCWD(dir string) error { c.cwd = dir; return nil } func (c *stubCore) SetCWD(dir string) error { c.cwd = dir; return nil }
func (c *stubCore) SetSessionID(string) error { return c.setSessionIDErr } func (c *stubCore) SetSessionID(string) error { return c.setSessionIDErr }
func (c *stubCore) Context() []backend.Message { return nil } func (c *stubCore) Context() []backend.Message { return nil }
func (c *stubCore) SystemPrompt() string { return c.sysprompt } func (c *stubCore) SystemPrompt() string { return c.sysprompt }
func (c *stubCore) GenerationParams() backend.GenerationParams { return c.params } 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) SetGenerationParams(p backend.GenerationParams) error { c.params = p; return nil }
func (c *stubCore) SetEnv(string, string) {} func (c *stubCore) SetEnv(string, string) {}
func (c *stubCore) WaitChange(ctx context.Context, _, _ string) (string, bool) { func (c *stubCore) WaitChange(ctx context.Context, _, _ string) (string, bool) {
if c.waitCh != nil { if c.waitCh != nil {
select { select {
case v := <-c.waitCh: return v, true case v := <-c.waitCh:
case <-ctx.Done(): return "", false return v, true
case <-ctx.Done():
return "", false
} }
} }
<-ctx.Done() <-ctx.Done()
return "", false return "", false
} }
func (c *stubCore) Close() { c.closed = true } func (c *stubCore) Close() { c.closed = true }
func (c *stubCore) Detach() bool { return false } func (c *stubCore) Detach() bool { return false }
func (c *stubCore) ListDetached() []agent.DetachedInfo { return nil } func (c *stubCore) ListDetached() []agent.DetachedInfo { return nil }
func (c *stubCore) SignalDetached(int, int) error { return nil } func (c *stubCore) SignalDetached(int, int) error { return nil }
func (c *stubCore) GetDetachedOutput(int) (string, error) { return "", nil } func (c *stubCore) GetDetachedOutput(int) (string, error) { return "", nil }
func (c *stubCore) DismissDetached(int) bool { return false } func (c *stubCore) DismissDetached(int) bool { return false }
func (c *stubCore) InjectSystemEvent(string) {} func (c *stubCore) InjectSystemEvent(string) {}
func (c *stubCore) Reactions() map[string]string { return nil } func (c *stubCore) Reactions() map[string]string { return nil }
func (c *stubCore) React(string) {} func (c *stubCore) React(string) {}
func (c *stubCore) ReactTo(responseID, emoji string) error { func (c *stubCore) ReactTo(responseID, emoji string) error {
c.reactResponseID = responseID c.reactResponseID = responseID
c.reactEmoji = emoji c.reactEmoji = emoji
return nil return nil
} }
func (c *stubCore) SaveSession(string) error { return nil } func (c *stubCore) SaveSession(string) error { return nil }
func (c *stubCore) ToolCallCount() int64 { return 0 } func (c *stubCore) ToolCallCount() int64 { return 0 }
func (c *stubCore) CompactionModel() string { return "" } func (c *stubCore) CompactionModel() string { return "" }
func (c *stubCore) SetCompactionModel(string) {} func (c *stubCore) SetCompactionModel(string) {}
func (c *stubCore) Bus() *pubsub.Bus { func (c *stubCore) Bus() *pubsub.Bus {
if c.bus_ == nil { if c.bus_ == nil {
c.bus_ = pubsub.NewBus() c.bus_ = pubsub.NewBus()
@ -115,6 +121,7 @@ func (c *stubCore) Bus() *pubsub.Bus {
} }
type publishCore struct{ *stubCore } type publishCore struct{ *stubCore }
func (c *publishCore) Submit(_ context.Context, input string) { func (c *publishCore) Submit(_ context.Context, input string) {
c.submitted = append(c.submitted, input) c.submitted = append(c.submitted, input)
bus := c.Bus() bus := c.Bus()
@ -126,6 +133,7 @@ func (c *publishCore) Submit(_ context.Context, input string) {
} }
type blockingCore struct{ *stubCore } type blockingCore struct{ *stubCore }
func (c *blockingCore) Submit(ctx context.Context, input string) { func (c *blockingCore) Submit(ctx context.Context, input string) {
c.submitted = append(c.submitted, input) c.submitted = append(c.submitted, input)
<-ctx.Done() <-ctx.Done()
@ -166,23 +174,31 @@ func newTestSessionManagerWithCore(t *testing.T) *session.Manager {
func testStoreRead(t *testing.T, s fs.FileTree, name string) []byte { func testStoreRead(t *testing.T, s fs.FileTree, name string) []byte {
t.Helper() t.Helper()
e, err := s.Open(name) 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() 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 return data
} }
func testStoreWrite(t *testing.T, s fs.FileTree, name string, data []byte) { func testStoreWrite(t *testing.T, s fs.FileTree, name string, data []byte) {
t.Helper() t.Helper()
e, err := s.Open(name) e, err := s.Open(name)
if err != nil { t.Fatalf("Open(%s): %v", name, err) } if err != nil {
if err := e.Write(data); err != nil { t.Fatalf("Write(%s): %v", name, err) } 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 _ = strings.Contains // suppress unused import
var _ = fmt.Sprintf // suppress unused import var _ = fmt.Sprintf // suppress unused import
var _ = os.Remove // suppress unused import var _ = os.Remove // suppress unused import
func newTestSessionFileStore(t *testing.T, sess *session.Session) (*fs.Tree, *stubCore) { func newTestSessionFileStore(t *testing.T, sess *session.Session) (*fs.Tree, *stubCore) {
t.Helper() t.Helper()
@ -193,6 +209,7 @@ func newTestSessionFileStore(t *testing.T, sess *session.Session) (*fs.Tree, *st
func(id string) error { return nil }, func(id string) error { return nil },
func(data []byte) error { return nil }, func(data []byte) error { return nil },
func() {}, func() {},
nil,
) )
return sf, core 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 { func newTestSessionFileStoreWith(t *testing.T, sess *session.Session, kill func(), rename func(string) error, save func([]byte) error) *fs.Tree {
t.Helper() t.Helper()
sink := testSink() 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 ===== // ===== session.Session =====
func TestSessionAppendLog(t *testing.T) { func TestSessionAppendLog(t *testing.T) {
@ -418,7 +436,7 @@ func TestSessionFileStoreReadableContract(t *testing.T) {
defer sess.Cancel() defer sess.Cancel()
sink := testSink() sink := testSink()
_ = session.NewSessionTree(sess, sink.NewLogger("test"), _ = 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) { func TestSessionFileStoreList(t *testing.T) {
@ -426,7 +444,7 @@ func TestSessionFileStoreList(t *testing.T) {
defer sess.Cancel() defer sess.Cancel()
sink := testSink() sink := testSink()
sf := session.NewSessionTree(sess, sink.NewLogger("test"), 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() entries, err := sf.List()
if err != nil { if err != nil {
@ -443,7 +461,7 @@ func TestSessionFileStoreStatChat(t *testing.T) {
sess.AppendLog([]byte("hello")) sess.AppendLog([]byte("hello"))
sink := testSink() sink := testSink()
sf := session.NewSessionTree(sess, sink.NewLogger("test"), 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") fi, err := sf.Stat("chat")
if err != nil { if err != nil {
@ -460,7 +478,7 @@ func TestSessionFileStoreGetChat(t *testing.T) {
sess.AppendLog([]byte("hello")) sess.AppendLog([]byte("hello"))
sink := testSink() sink := testSink()
sf := session.NewSessionTree(sess, sink.NewLogger("test"), 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") data := testStoreRead(t, sf, "chat")
if string(data) != "hello" { if string(data) != "hello" {
@ -473,7 +491,7 @@ func TestSessionFileStoreGetContent(t *testing.T) {
defer sess.Cancel() defer sess.Cancel()
sink := testSink() sink := testSink()
sf := session.NewSessionTree(sess, sink.NewLogger("test"), 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"} { for _, name := range []string{"cfg", "offset", "usage", "ctxsz", "models", "systemprompt"} {
if _, err := sf.Open(name); err != nil { if _, err := sf.Open(name); err != nil {
@ -487,7 +505,7 @@ func TestSessionFileStorePutCwd(t *testing.T) {
defer sess.Cancel() defer sess.Cancel()
sink := testSink() sink := testSink()
sf := session.NewSessionTree(sess, sink.NewLogger("test"), 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")) testStoreWrite(t, sf, "cfg", []byte("cwd=/new/path"))
core := sess.Core.(*stubCore) core := sess.Core.(*stubCore)
@ -501,7 +519,7 @@ func TestSessionFileStorePutEmpty(t *testing.T) {
defer sess.Cancel() defer sess.Cancel()
sink := testSink() sink := testSink()
sf := session.NewSessionTree(sess, sink.NewLogger("test"), 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 // Empty write is a no-op
e, err := sf.Open("cfg") e, err := sf.Open("cfg")
@ -949,7 +967,7 @@ func TestSessionReactFile(t *testing.T) {
defer cancel() defer cancel()
core := &stubCore{state: "idle"} core := &stubCore{state: "idle"}
sess := session.NewSession("s1", core, ctx, cancel) 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("👍")) testStoreWrite(t, store, "react", []byte("👍"))
if core.reactResponseID != "" || core.reactEmoji != "👍" { if core.reactResponseID != "" || core.reactEmoji != "👍" {

View File

@ -59,9 +59,9 @@ func (t *elevateTree) Open(name string) (fs.File, error) {
return nil, fmt.Errorf("%s: not found", name) 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) 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) 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) Rename(_, _ string) error { return fmt.Errorf("cannot rename in elevate") }
// --- policy file --- // --- policy file ---

View File

@ -19,6 +19,7 @@ import (
"ollie/pkg/elevate" "ollie/pkg/elevate"
olog "ollie/pkg/log" olog "ollie/pkg/log"
"ollie/pkg/paths" "ollie/pkg/paths"
"ollie/pkg/tools/registry"
"olliesrv/fs" "olliesrv/fs"
"olliesrv/session" "olliesrv/session"
@ -27,8 +28,8 @@ import (
// Re-export fs types for use in server.go. // Re-export fs types for use in server.go.
type ( type (
File = fs.File File = fs.File
FileTree = fs.FileTree FileTree = fs.FileTree
syntheticFileInfo = fs.SyntheticFileInfo syntheticFileInfo = fs.SyntheticFileInfo
) )
@ -74,6 +75,7 @@ type Server struct {
groups map[string]map[string]bool // group → set of members groups map[string]map[string]bool // group → set of members
invalidateModels func() invalidateModels func()
toolPrompt func(string) string toolPrompt func(string) string
toolRegistry *registry.Registry
} }
// Config holds the pre-built trees and manager for the server. // Config holds the pre-built trees and manager for the server.
@ -84,6 +86,8 @@ type Config struct {
ElevateBroker *elevate.Broker ElevateBroker *elevate.Broker
InvalidateModels func() InvalidateModels func()
ToolPrompt func(string) string ToolPrompt func(string) string
// ToolRegistry is the shared tool registry for lazy tool promotion.
ToolRegistry *registry.Registry
} }
// Option configures the 9P server. // Option configures the 9P server.
@ -99,6 +103,7 @@ func New(cfg Config) *Server {
groups: make(map[string]map[string]bool), groups: make(map[string]map[string]bool),
invalidateModels: cfg.InvalidateModels, invalidateModels: cfg.InvalidateModels,
toolPrompt: cfg.ToolPrompt, toolPrompt: cfg.ToolPrompt,
toolRegistry: cfg.ToolRegistry,
} }
if cfg.ElevateBroker != nil { if cfg.ElevateBroker != nil {
s.elevateTree = newElevateTree(cfg.ElevateBroker) s.elevateTree = newElevateTree(cfg.ElevateBroker)
@ -312,12 +317,6 @@ func (s *Server) checkPerm(uname, path string, mode uint8) error {
return nil return nil
} }
// readFile opens an entry in a tree and reads it. // readFile opens an entry in a tree and reads it.
func readFile(s fs.FileTree, name string) ([]byte, error) { func readFile(s fs.FileTree, name string) ([]byte, error) {
e, err := s.Open(name) 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) 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) s.log.Debug("Tread path=%q offset=%d count=%d", path, fc.Offset, fc.Count)
entry, err := s.openEntry(path) entry, err := s.openEntry(path)
if err != nil { 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))} 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. // Accumulate; the 9P client may split large writes across multiple Twrite messages.
end := int(fc.Offset) + len(fc.Data) end := int(fc.Offset) + len(fc.Data)
if end > len(f.writeBuf) { 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} return &plan9.Fcall{Type: plan9.Rclunk, Tag: fc.Tag}
} }
func (s *Server) remove(cs *connState, fc *plan9.Fcall) *plan9.Fcall { func (s *Server) remove(cs *connState, fc *plan9.Fcall) *plan9.Fcall {
cs.mu.Lock() cs.mu.Lock()
f, ok := cs.fids[fc.Fid] 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). // as a goroutine because they block for the entire agent turn).
func (s *Server) handleWrite(path, input, uname string) error { func (s *Server) handleWrite(path, input, uname string) error {
s.log.Debug("handleWrite path=%q input_len=%d uname=%q", path, len(input), uname) 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) st, name := s.route(path)
if st == nil { if st == nil {
return nil return nil

View File

@ -21,6 +21,7 @@ import (
"ollie/pkg/remote" "ollie/pkg/remote"
"ollie/pkg/tools" "ollie/pkg/tools"
"ollie/pkg/tools/execute" "ollie/pkg/tools/execute"
"ollie/pkg/tools/registry"
"olliesrv/fs" "olliesrv/fs"
"olliesrv/prompts" "olliesrv/prompts"
) )
@ -249,6 +250,8 @@ type ManagerConfig struct {
EnableDBus bool EnableDBus bool
// InvalidateModels clears the model cache, forcing a refresh. // InvalidateModels clears the model cache, forcing a refresh.
InvalidateModels func() 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. // OnSessionCreated is called after a new session is added to the manager.
// Receives the session ID and the Session pointer. // Receives the session ID and the Session pointer.
OnSessionCreated func(id string, sess *Session) 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) }, func(newID string) error { return s.renameSession(sess.id, newID) },
nil, // no transcript saving nil, // no transcript saving
s.cfg.InvalidateModels, s.cfg.InvalidateModels,
s.cfg.ToolRegistry,
), nil ), nil
} }
@ -901,7 +905,17 @@ func (s *Manager) restoreSession(ps *agent.PersistedSession) error {
} }
envBlock := prompts.Environment(cwd, platform, isGitRepo, "") 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) restoredSession := agent.RestoreSession(ps)
@ -1218,7 +1232,17 @@ func (s *Manager) CreateSession(args []string) (string, error) {
} }
envBlock := prompts.Environment(cwd, platform, isGitRepo, "") 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. // sessPtr is set after NewSession; the ReadPlanStep closure captures it.
core = agent.NewAgentCore(agent.AgentCoreConfig{ core = agent.NewAgentCore(agent.AgentCoreConfig{

View File

@ -11,6 +11,7 @@ import (
"ollie/pkg/agent" "ollie/pkg/agent"
"ollie/pkg/backend" "ollie/pkg/backend"
olog "ollie/pkg/log" olog "ollie/pkg/log"
"ollie/pkg/tools/registry"
"olliesrv/fs" "olliesrv/fs"
) )
@ -42,6 +43,9 @@ var FileList = []struct {
{"context", false, false}, {"context", false, false},
{"react", false, true}, {"react", false, true},
{"reactions", false, false}, {"reactions", false, false},
{"tools.loaded", false, false},
{"tools.rev", false, false},
{"tools", false, false},
} }
// sessionFilePerms returns the permission registry for per-session files. // 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 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 { 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} h := &sessionHelper{sess: sess, log: log, kill: kill, rename: rename, saveTranscript: saveTranscript, invalidateModels: invalidateModels, toolRegistry: toolRegistry}
perms := sessionFilePerms() perms := sessionFilePerms()
specs := make([]fs.FileSpec, len(FileList)) specs := make([]fs.FileSpec, len(FileList))
for i, f := range FileList { for i, f := range FileList {
@ -69,6 +73,7 @@ type sessionHelper struct {
rename func(newID string) error rename func(newID string) error
saveTranscript func([]byte) error saveTranscript func([]byte) error
invalidateModels func() invalidateModels func()
toolRegistry *registry.Registry
} }
func (h *sessionHelper) fileSpec(name string, mode os.FileMode) fs.FileSpec { 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) 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": case "react":
fs.Write = func(data []byte) error { fs.Write = func(data []byte) error {
input := strings.TrimSpace(string(data)) input := strings.TrimSpace(string(data))
@ -276,6 +292,41 @@ func (h *sessionHelper) content(name string) string {
enc.Encode(m) enc.Encode(m)
} }
return sb.String() 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 "" return ""
} }