diff --git a/cmd/olliesrv/internal/agent/agent.go b/cmd/olliesrv/internal/agent/agent.go index ce8c616..08dec6f 100644 --- a/cmd/olliesrv/internal/agent/agent.go +++ b/cmd/olliesrv/internal/agent/agent.go @@ -10,6 +10,7 @@ import ( "strings" "sync" "sync/atomic" + "time" "9fans.net/go/plan9" p9client "9fans.net/go/plan9/client" @@ -41,6 +42,7 @@ type Agent struct { // Execution state — owned by the agent, protected by stateMu. state string // "idle", "thinking", "calling: " + stateSince time.Time // when the current state began reply string // last assistant response getCwd func() string // returns session working directory id string // agent identity (unique principal) diff --git a/cmd/olliesrv/internal/agent/loop.go b/cmd/olliesrv/internal/agent/loop.go index edeec1c..54308c2 100644 --- a/cmd/olliesrv/internal/agent/loop.go +++ b/cmd/olliesrv/internal/agent/loop.go @@ -168,7 +168,7 @@ func (ag *Agent) run(ctx context.Context) error { _, compactErr := ag.autoCompact(ctx) if compactErr != nil { - ag.emit(Event{Role: "error", Content: compactErr.Error()}) + ag.emit(Event{Role: "error", Name: backend.SeverityFatal, Content: compactErr.Error()}) break } diff --git a/cmd/olliesrv/internal/agent/retry.go b/cmd/olliesrv/internal/agent/retry.go index 5bbfbd7..f4f9e02 100644 --- a/cmd/olliesrv/internal/agent/retry.go +++ b/cmd/olliesrv/internal/agent/retry.go @@ -50,7 +50,7 @@ func (ag *Agent) trackErrors(ctx context.Context, results []toolResult, es *erro } if es.consecutive >= consecutiveErrorHardLimit { - ag.emit(Event{Role: "error", Content: fmt.Sprintf("%d consecutive tool errors — aborting", es.consecutive)}) + ag.emit(Event{Role: "error", Name: backend.SeverityFatal, Content: fmt.Sprintf("%d consecutive tool errors — aborting\nremediation: the model is stuck; rephrase the task or intervene", es.consecutive)}) return fmt.Errorf("step %d: %d consecutive tool errors", step, es.consecutive) } if es.consecutive == consecutiveErrorSoftLimit { diff --git a/cmd/olliesrv/internal/agent/state.go b/cmd/olliesrv/internal/agent/state.go index da988a1..eed94f4 100644 --- a/cmd/olliesrv/internal/agent/state.go +++ b/cmd/olliesrv/internal/agent/state.go @@ -6,7 +6,13 @@ package agent -import "context" +import ( + "context" + "fmt" + "strconv" + "strings" + "time" +) // WatchField names supported by Agent.WaitChange. const ( @@ -26,6 +32,7 @@ func (ag *Agent) State() string { func (ag *Agent) SetState(state string) { ag.stateMu.Lock() ag.state = state + ag.stateSince = time.Now() ag.stateMu.Unlock() ag.notifyChange() if ag.onStateChange != nil { @@ -33,6 +40,38 @@ func (ag *Agent) SetState(state string) { } } +// Status renders a human-readable one-line status: the current state and how +// long it has been in that state, e.g. "thinking · 12s" or "calling shell · 3s". +// The raw state string is machine-facing; this is the human-facing view. +func (ag *Agent) Status() string { + ag.stateMu.RLock() + state := ag.state + since := ag.stateSince + ag.stateMu.RUnlock() + + if state == "" { + state = "idle" + } + // "calling: shell" reads better as "calling shell". + state = strings.Replace(state, "calling: ", "calling ", 1) + if state == "idle" || since.IsZero() { + return state + } + return state + " · " + humanDuration(time.Since(since)) +} + +// humanDuration renders a short elapsed duration: "3s", "2m10s", "1h04m". +func humanDuration(d time.Duration) string { + secs := int(d.Seconds()) + if secs < 60 { + return strconv.Itoa(secs) + "s" + } + if secs < 3600 { + return strconv.Itoa(secs/60) + "m" + strconv.Itoa(secs%60) + "s" + } + return strconv.Itoa(secs/3600) + "h" + fmt.Sprintf("%02dm", (secs%3600)/60) +} + // Reply returns the agent's last assistant response. func (ag *Agent) Reply() string { ag.stateMu.RLock() diff --git a/cmd/olliesrv/internal/agent/turn.go b/cmd/olliesrv/internal/agent/turn.go index ee7f5e3..2e1eb9f 100644 --- a/cmd/olliesrv/internal/agent/turn.go +++ b/cmd/olliesrv/internal/agent/turn.go @@ -51,7 +51,7 @@ func (ag *Agent) Submit(ctx context.Context, input string) { a.cancel(fmt.Errorf("%v", r)) } ag.SetState("idle") - ag.emit(Event{Role: "error", Content: fmt.Sprintf("%v", r)}) + ag.emit(Event{Role: "error", Name: backend.SeverityFatal, Content: fmt.Sprintf("%v", r)}) // Re-submit next FIFO item in a new goroutine so queued // prompts aren't orphaned by the panic. if next, ok := ag.fifo.Pop(); ok { @@ -122,7 +122,7 @@ func (ag *Agent) executeTurn(ctx context.Context, input string) string { ti, listErr := ag.runtime.ToolServer.ListTools() if listErr != nil { ag.log.Error("tool server unavailable: %v", listErr) - ag.emit(Event{Role: "error", Content: fmt.Sprintf("tool server unavailable: %v", listErr)}) + ag.emit(Event{Role: "error", Name: backend.SeverityFatal, Content: fmt.Sprintf("tool server unavailable: %v\nremediation: the tool server process is not responding; kill and recreate the session", listErr)}) ag.SetState("idle") return "" } @@ -354,7 +354,12 @@ func (ag *Agent) executeTurn(ctx context.Context, input string) string { ag.save() return "" } - ag.emit(Event{Role: "error", Content: err.Error()}) + severity, remediation := backend.ClassifyError(err) + content := err.Error() + if remediation != "" { + content += "\nremediation: " + remediation + } + ag.emit(Event{Role: "error", Name: severity, Content: content}) // Drain one FIFO item. if next, ok := ag.fifo.Pop(); ok { return next diff --git a/cmd/olliesrv/internal/backend/backend.go b/cmd/olliesrv/internal/backend/backend.go index 2101fbb..c100bc8 100644 --- a/cmd/olliesrv/internal/backend/backend.go +++ b/cmd/olliesrv/internal/backend/backend.go @@ -7,6 +7,7 @@ import ( "bufio" "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -141,6 +142,41 @@ type ToolUnsupportedError struct { func (e *ToolUnsupportedError) Error() string { return e.Message } +// Error severity levels for the human-facing error surface. +const ( + // SeverityTransient: temporary, the runtime is retrying. Wait. + SeverityTransient = "transient" + // SeverityConfig: the user's configuration or request is wrong. Act. + SeverityConfig = "config" + // SeverityFatal: the turn aborted and won't recover on its own. + SeverityFatal = "fatal" +) + +// ClassifyError maps an error to a human-facing severity and a one-line +// remediation hint. The remediation is empty when there's nothing useful to +// suggest. Classification keys off the typed backend errors; anything +// unrecognised is fatal with no remediation. +func ClassifyError(err error) (severity, remediation string) { + if err == nil { + return "", "" + } + var rl *RateLimitError + var tr *TransientError + var co *ContextOverflowError + var tu *ToolUnsupportedError + switch { + case errors.As(err, &rl): + return SeverityTransient, "rate limited by the provider; the runtime will retry automatically" + case errors.As(err, &tr): + return SeverityTransient, "temporary provider/network error; the runtime will retry automatically" + case errors.As(err, &co): + return SeverityConfig, "context window exceeded; run 'ctl compact' or start a fresh session" + case errors.As(err, &tu): + return SeverityConfig, "this model does not support tools; switch models or use an agent with no tools" + } + return SeverityFatal, "" +} + // GenerationParams controls sampling behaviour for a single ChatStream call. // Zero values mean "use the API default". type GenerationParams struct { diff --git a/cmd/olliesrv/internal/backend/classify_test.go b/cmd/olliesrv/internal/backend/classify_test.go new file mode 100644 index 0000000..b4f0d86 --- /dev/null +++ b/cmd/olliesrv/internal/backend/classify_test.go @@ -0,0 +1,43 @@ +package backend + +import ( + "errors" + "fmt" + "testing" +) + +func TestClassifyError(t *testing.T) { + cases := []struct { + err error + wantSeverity string + wantRemed bool + }{ + {nil, "", false}, + {&RateLimitError{}, SeverityTransient, true}, + {&TransientError{}, SeverityTransient, true}, + {&ContextOverflowError{}, SeverityConfig, true}, + {&ToolUnsupportedError{}, SeverityConfig, true}, + {errors.New("something else"), SeverityFatal, false}, + } + for _, c := range cases { + sev, remed := ClassifyError(c.err) + if sev != c.wantSeverity { + t.Errorf("ClassifyError(%v) severity = %q, want %q", c.err, sev, c.wantSeverity) + } + if (remed != "") != c.wantRemed { + t.Errorf("ClassifyError(%v) remediation = %q, wantNonEmpty=%v", c.err, remed, c.wantRemed) + } + } +} + +func TestClassifyError_Wrapped(t *testing.T) { + // Classification must see through fmt.Errorf %w wrapping. + err := fmt.Errorf("step 2: %w", &ContextOverflowError{Message: "too long"}) + sev, remed := ClassifyError(err) + if sev != SeverityConfig { + t.Errorf("severity = %q, want %q", sev, SeverityConfig) + } + if remed == "" { + t.Error("expected remediation for context overflow") + } +} diff --git a/cmd/olliesrv/internal/fs/dispatch_test.go b/cmd/olliesrv/internal/fs/dispatch_test.go new file mode 100644 index 0000000..5f10769 --- /dev/null +++ b/cmd/olliesrv/internal/fs/dispatch_test.go @@ -0,0 +1,55 @@ +package fs + +import ( + "strings" + "testing" +) + +func testCmds() []ctlCmd { + ok := func(_ []string) ([]byte, error) { return []byte("ok\n"), nil } + return []ctlCmd{ + {"stop", "interrupt the current turn", ok}, + {"compact", "compact context now", ok}, + } +} + +func TestDispatch_HelpListsCommands(t *testing.T) { + // Empty input and "help" both return the self-describing listing. + for _, in := range []string{"", "help", " "} { + out, err := dispatch(testCmds(), []byte(in)) + if err != nil { + t.Fatalf("dispatch(%q) error: %v", in, err) + } + s := string(out) + if !strings.Contains(s, "stop") || !strings.Contains(s, "interrupt the current turn") { + t.Errorf("help listing missing stop/description: %q", s) + } + if !strings.Contains(s, "compact") { + t.Errorf("help listing missing compact: %q", s) + } + } +} + +func TestDispatch_UnknownVerbListsValid(t *testing.T) { + _, err := dispatch(testCmds(), []byte("stpo")) + if err == nil { + t.Fatal("expected error for unknown verb") + } + msg := err.Error() + if !strings.Contains(msg, "stpo") { + t.Errorf("error should name the bad verb: %q", msg) + } + if !strings.Contains(msg, "stop") || !strings.Contains(msg, "compact") { + t.Errorf("error should list valid verbs: %q", msg) + } +} + +func TestDispatch_RoutesToHandler(t *testing.T) { + out, err := dispatch(testCmds(), []byte("stop")) + if err != nil { + t.Fatalf("dispatch error: %v", err) + } + if string(out) != "ok\n" { + t.Errorf("got %q, want ok", out) + } +} diff --git a/cmd/olliesrv/internal/fs/spec.go b/cmd/olliesrv/internal/fs/spec.go index 9bce7a3..56cb4d5 100644 --- a/cmd/olliesrv/internal/fs/spec.go +++ b/cmd/olliesrv/internal/fs/spec.go @@ -220,8 +220,8 @@ func buildTreeSpec(cfg *Config) virtfs.FsNodeDecl { virtfs.FileNode("ctl", 0666, virtfs.Doc("Server control. Write: 'invalidate', 'kill'"), virtfs.Rdwr(func(_ context.Context, data []byte) ([]byte, error) { - return dispatch(map[string]func([]string) ([]byte, error){ - "invalidate": func(_ []string) ([]byte, error) { + return dispatch([]ctlCmd{ + {"invalidate", "clear the models cache", func(_ []string) ([]byte, error) { if mc != nil { mc.Invalidate() } @@ -229,13 +229,13 @@ func buildTreeSpec(cfg *Config) virtfs.FsNodeDecl { cfg.Invalidate() } return []byte("ok\n"), nil - }, - "kill": func(_ []string) ([]byte, error) { + }}, + {"kill", "shut down the server", func(_ []string) ([]byte, error) { if cfg.Shutdown != nil { cfg.Shutdown() } return []byte("ok\n"), nil - }, + }}, }, data) }), ), @@ -478,30 +478,29 @@ func buildSessionChildren( ), virtfs.FileNode("ctl", 0666, virtfs.Rdwr(func(_ context.Context, data []byte) ([]byte, error) { - return dispatch(map[string]func([]string) ([]byte, error){ - "kill": func(_ []string) ([]byte, error) { return []byte("ok\n"), removeFn() }, - ".": func(_ []string) ([]byte, error) { return []byte("ok\n"), removeFn() }, - "save": func(_ []string) ([]byte, error) { + return dispatch([]ctlCmd{ + {"kill", "destroy this session", func(_ []string) ([]byte, error) { return []byte("ok\n"), removeFn() }}, + {"save", "persist session state to disk", func(_ []string) ([]byte, error) { s.Save() return []byte("ok\n"), nil - }, - "invalidate": func(_ []string) ([]byte, error) { + }}, + {"invalidate", "clear the models cache", func(_ []string) ([]byte, error) { s.InvalidateModelsCache() return []byte("ok\n"), nil - }, - "pause": func(_ []string) ([]byte, error) { + }}, + {"pause", "pause all agents in this session", func(_ []string) ([]byte, error) { if err := s.Pause(); err != nil { return nil, err } return []byte("ok\n"), nil - }, - "resume": func(_ []string) ([]byte, error) { + }}, + {"resume", "resume all agents in this session", func(_ []string) ([]byte, error) { if err := s.Resume(); err != nil { return nil, err } return []byte("ok\n"), nil - }, - "run": func(args []string) ([]byte, error) { + }}, + {"run", "run a workflow: run [workflow] [variant]", func(args []string) ([]byte, error) { workflow := s.Workflow() variant := s.Variant() if len(args) > 0 { @@ -512,7 +511,7 @@ func buildSessionChildren( } runWorkflow(s, workflow, variant) return []byte("ok\n"), nil - }, + }}, }, data) }), ), @@ -837,6 +836,12 @@ func buildAgentChildren(a *agent.Agent, s *session.Session) []virtfs.FsNodeDecl return []byte(st + "\n"), st, nil }, a.SignalCh), ), + virtfs.FileNode("status", 0444, + virtfs.Doc("Human-readable status: current activity and elapsed time"), + virtfs.Read(func() ([]byte, error) { + return []byte(a.Status() + "\n"), nil + }), + ), virtfs.FileNode("log", 0444, virtfs.Read(func() ([]byte, error) { const maxWindow = 64 * 1024 @@ -936,17 +941,17 @@ func buildAgentChildren(a *agent.Agent, s *session.Session) []virtfs.FsNodeDecl }), virtfs.FileNode("ctl", 0666, virtfs.Rdwr(func(_ context.Context, data []byte) ([]byte, error) { - return dispatch(map[string]func([]string) ([]byte, error){ - "kill": func(_ []string) ([]byte, error) { + return dispatch([]ctlCmd{ + {"kill", "destroy this agent", func(_ []string) ([]byte, error) { s.RemoveAgent(a.ID()) session.PublishEvent("session."+s.ID+".agent."+a.ID()+".kill", "") return []byte("ok\n"), nil - }, - "stop": func(_ []string) ([]byte, error) { + }}, + {"stop", "interrupt the current turn", func(_ []string) ([]byte, error) { a.Interrupt(agent.ErrInterrupted) return []byte("ok\n"), nil - }, - "detach": func(_ []string) ([]byte, error) { + }}, + {"detach", "detach the foreground process to background", func(_ []string) ([]byte, error) { ts := a.ToolServer() if ts == nil { return nil, fmt.Errorf("no tool server") @@ -955,30 +960,29 @@ func buildAgentChildren(a *agent.Agent, s *session.Session) []virtfs.FsNodeDecl return nil, fmt.Errorf("no foreground process to detach") } return []byte("ok\n"), nil - }, - "compact": func(_ []string) ([]byte, error) { + }}, + {"compact", "compact context now", func(_ []string) ([]byte, error) { if err := a.Compact(s.Ctx); err != nil { return nil, err } return []byte("ok\n"), nil - }, - "compactionmodel": func(args []string) ([]byte, error) { + }}, + {"compactionmodel", "get/set the compaction model", func(args []string) ([]byte, error) { model := strings.TrimSpace(strings.Join(args, " ")) if model == "" { return []byte(a.CompactionModel() + "\n"), nil } a.SetCompactionModel(model) return []byte(model + "\n"), nil - }, - "clear": func(_ []string) ([]byte, error) { + }}, + {"clear", "clear chat history", func(_ []string) ([]byte, error) { if err := a.Clear(); err != nil { return nil, err } return []byte("ok\n"), nil - }, - "inject": inject, - "i": inject, - "agent": func(args []string) ([]byte, error) { + }}, + {"i", "inject text into the running turn", inject}, + {"agent", "get/switch agent profile: agent [name]", func(args []string) ([]byte, error) { if len(args) == 0 { return []byte(a.Profile() + "\n"), nil } @@ -992,8 +996,8 @@ func buildAgentChildren(a *agent.Agent, s *session.Session) []virtfs.FsNodeDecl session.LoadTools(cfg, ts, s.ID, a.ID(), nil) } return []byte(args[0] + "\n"), nil - }, - "model": func(args []string) ([]byte, error) { + }}, + {"model", "get/set model: model [name]", func(args []string) ([]byte, error) { if len(args) == 0 { if be := a.Backend(); be != nil { return []byte(be.Model() + "\n"), nil @@ -1006,11 +1010,11 @@ func buildAgentChildren(a *agent.Agent, s *session.Session) []virtfs.FsNodeDecl return []byte(modelPricingBlock(be, name)), nil } return []byte(name + "\n"), nil - }, - "models": func(_ []string) ([]byte, error) { + }}, + {"models", "list available models", func(_ []string) ([]byte, error) { return []byte(s.CachedListModels() + "\n"), nil - }, - "tools": func(_ []string) ([]byte, error) { + }}, + {"tools", "list loaded tools", func(_ []string) ([]byte, error) { ts := a.ToolServer() if ts == nil { return []byte("(no tool server)\n"), nil @@ -1028,8 +1032,8 @@ func buildAgentChildren(a *agent.Agent, s *session.Session) []virtfs.FsNodeDecl } } return []byte(sb.String()), nil - }, - "tool_load": func(args []string) ([]byte, error) { + }}, + {"tool_load", "load a tool: tool_load ", func(args []string) ([]byte, error) { if len(args) == 0 { return nil, fmt.Errorf("tool_load requires a tool name") } @@ -1044,15 +1048,15 @@ func buildAgentChildren(a *agent.Agent, s *session.Session) []virtfs.FsNodeDecl a.SetToolsPreamble(agent.RenderTools(infos)) } return []byte(args[0] + "\n"), nil - }, - "tools_all": func(args []string) ([]byte, error) { + }}, + {"tools_all", "list all available tools", func(args []string) ([]byte, error) { ts := a.ToolServer() if ts == nil { return nil, fmt.Errorf("no tool server") } return ts.ListAllTools() - }, - "tool_unload": func(args []string) ([]byte, error) { + }}, + {"tool_unload", "unload a tool: tool_unload ", func(args []string) ([]byte, error) { if len(args) == 0 { return nil, fmt.Errorf("tool_unload requires a tool name") } @@ -1067,8 +1071,8 @@ func buildAgentChildren(a *agent.Agent, s *session.Session) []virtfs.FsNodeDecl a.SetToolsPreamble(agent.RenderTools(infos)) } return []byte(args[0] + "\n"), nil - }, - "backend": func(args []string) ([]byte, error) { + }}, + {"backend", "get/set backend: backend [name]", func(args []string) ([]byte, error) { if len(args) == 0 { if be := a.Backend(); be != nil { return []byte(be.Name() + "\n"), nil @@ -1080,18 +1084,18 @@ func buildAgentChildren(a *agent.Agent, s *session.Session) []virtfs.FsNodeDecl return nil, err } return []byte(name + "\n"), nil - }, - "name": func(args []string) ([]byte, error) { + }}, + {"name", "get/set agent name: name [value]", func(args []string) ([]byte, error) { if len(args) == 0 { return []byte(a.Name() + "\n"), nil } a.SetName(strings.Join(args, " ")) return []byte(strings.Join(args, " ") + "\n"), nil - }, - "cwd": func(args []string) ([]byte, error) { + }}, + {"cwd", "print the working directory", func(args []string) ([]byte, error) { return []byte(a.Cwd() + "\n"), nil - }, - "proc": func(args []string) ([]byte, error) { + }}, + {"proc", "manage background procs: proc [top|term|kill|out|dismiss ]", func(args []string) ([]byte, error) { ts := a.ToolServer() if ts == nil { return nil, fmt.Errorf("no tool server") @@ -1160,11 +1164,11 @@ func buildAgentChildren(a *agent.Agent, s *session.Session) []virtfs.FsNodeDecl default: return nil, fmt.Errorf("unknown proc subcommand: %s (use: top, term , kill , out , dismiss )", subcmd) } - }, - "systemprompt": func(_ []string) ([]byte, error) { + }}, + {"systemprompt", "print the rendered system prompt", func(_ []string) ([]byte, error) { return []byte(a.SystemPrompt() + "\n"), nil - }, - "peeradd": func(args []string) ([]byte, error) { + }}, + {"peeradd", "link a peer agent: peeradd ", func(args []string) ([]byte, error) { if len(args) == 0 { return nil, fmt.Errorf("peeradd requires agent name") } @@ -1176,8 +1180,8 @@ func buildAgentChildren(a *agent.Agent, s *session.Session) []virtfs.FsNodeDecl target.AddPeer(a.Name()) go s.Save() return []byte("ok\n"), nil - }, - "peerdel": func(args []string) ([]byte, error) { + }}, + {"peerdel", "unlink a peer agent: peerdel ", func(args []string) ([]byte, error) { if len(args) == 0 { return nil, fmt.Errorf("peerdel requires agent name") } @@ -1187,18 +1191,15 @@ func buildAgentChildren(a *agent.Agent, s *session.Session) []virtfs.FsNodeDecl } go s.Save() return []byte("ok\n"), nil - }, - "peers": func(_ []string) ([]byte, error) { + }}, + {"peers", "list linked peer agents", func(_ []string) ([]byte, error) { peers := a.Peers() if len(peers) == 0 { return []byte("(no peers)\n"), nil } return []byte(strings.Join(peers, "\n") + "\n"), nil - }, - "stats": stats, - "help": func(_ []string) ([]byte, error) { - return []byte("stop detach compact clear inject agent model models tools tool_load tool_unload cwd proc name backend systemprompt peeradd peerdel peers stats help\n"), nil - }, + }}, + {"stats", "print usage and cost statistics", stats}, }, data) }), ), diff --git a/cmd/olliesrv/internal/fs/support.go b/cmd/olliesrv/internal/fs/support.go index 3156f9d..8b6b2b8 100644 --- a/cmd/olliesrv/internal/fs/support.go +++ b/cmd/olliesrv/internal/fs/support.go @@ -22,11 +22,25 @@ func wireAgentEvents(sessID string, ag *agent.Agent) { }) } -// dispatch routes a command to the matching handler based on the first word. -func dispatch(handlers map[string]func([]string) ([]byte, error), data []byte) ([]byte, error) { +// ctlCmd is one control verb: its name, a one-line description for the +// self-describing help listing, and its handler. +type ctlCmd struct { + name string + desc string + fn func([]string) ([]byte, error) +} + +// dispatch routes a ctl write to a command by its first word. Reading the ctl +// file (empty write) or writing "help" returns the command listing. An unknown +// verb returns an error listing the valid verbs. +func dispatch(cmds []ctlCmd, data []byte) ([]byte, error) { input := strings.TrimSpace(string(data)) - if input == "" { - return nil, nil + if input == "" || input == "help" { + var sb strings.Builder + for _, c := range cmds { + fmt.Fprintf(&sb, "%-16s %s\n", c.name, c.desc) + } + return []byte(sb.String()), nil } parts := strings.Fields(input) command := parts[0] @@ -35,11 +49,16 @@ func dispatch(handlers map[string]func([]string) ([]byte, error), data []byte) ( command = name args = append([]string{value}, args...) } - h, ok := handlers[command] - if !ok { - return nil, fmt.Errorf("unknown ctl command: %s", command) + for _, c := range cmds { + if c.name == command { + return c.fn(args) + } } - return h(args) + names := make([]string, len(cmds)) + for i, c := range cmds { + names[i] = c.name + } + return nil, fmt.Errorf("unknown ctl command %q; valid: %s", command, strings.Join(names, ", ")) } // stripMarkers removes [[[...]]] marker lines, source fence delimiter lines, diff --git a/data/scripts/o b/data/scripts/o index 95a2121..46ccd08 100755 --- a/data/scripts/o +++ b/data/scripts/o @@ -41,7 +41,7 @@ CTX_SESSION="" # Session name if in session/agent context CTX_AGENT="" # Agent name if in agent context # ── Known commands (to distinguish from context) ───────────────── -COMMANDS="read write rdwr ls new ctl prompt log tui env generate help" +COMMANDS="read write rdwr ls new ctl prompt log tui env generate overview help" is_command() { local arg="$1" @@ -148,14 +148,14 @@ cmd_generate() { cmd_ctl() { if [[ $# -lt 1 ]]; then - echo "usage: o ctl [args...]" >&2 - if [[ -n "$CTX_AGENT" ]]; then - echo " Agent: stop, compact, clear, inject, agent, model, cwd, name" >&2 - elif [[ -n "$CTX_SESSION" ]]; then - echo " Session: kill, pause, resume" >&2 - else - echo " (requires session or agent context)" >&2 + if [[ -z "$PREFIX" ]]; then + echo "usage: o ctl (or o / ctl )" >&2 + exit 1 fi + # The ctl file is self-describing: an empty request returns the + # valid commands with descriptions. + echo "usage: o ctl [args...]" >&2 + printf '' | ollie-9p rdwr "${PREFIX}/ctl" >&2 exit 1 fi @@ -191,7 +191,7 @@ cmd_prompt() { # Interactive REPL mode echo "[$CTX_SESSION/$CTX_AGENT]" echo "Type . on a blank line to send, Ctrl+D to exit." - echo "/ prefix runs ctl: /stop, /compact, /model X, /inject X" + echo "/ prefix runs ctl: /stop, /compact, /model X, /i X (type /help for all)" echo "!q exits the TUI." echo "" @@ -330,7 +330,8 @@ cmd_tui() { *) bg="default" ;; esac tmux set-option -t "=${tmux_session}" status-right-style "fg=default,bg=$bg" 2>/dev/null - tmux set-option -t "=${tmux_session}" status-right "$state | %H:%M" 2>/dev/null + status=$(ollie-9p read "${PREFIX}/status" 2>/dev/null | head -n1) + tmux set-option -t "=${tmux_session}" status-right "${status:-$state} | %H:%M" 2>/dev/null done done & local bg_pid=$! @@ -346,6 +347,35 @@ cmd_env() { [[ -n "$CTX_AGENT" ]] && echo "export OLLIE_AGENT=$CTX_AGENT" } +# cmd_overview shows all running sessions and their agents with a human-readable +# status, so you can see what's live without knowing any names in advance. +cmd_overview() { + local idx + idx=$(ollie-9p read "session/idx" 2>/dev/null || true) + if [[ -z "$idx" ]]; then + echo "no active sessions" + echo "start one: o / new [cwd]" + return + fi + # session/idx: id \t name \t paused \t connected \t remote \t cwd + while IFS=$'\t' read -r _id sname paused _conn remote cwd; do + [[ -z "$sname" ]] && continue + local tag="" + [[ "$paused" == "true" ]] && tag=" (paused)" + [[ -n "$remote" ]] && tag="$tag [remote: $remote]" + printf '%s%s\t%s\n' "$sname" "$tag" "$cwd" + # agent idx: sess-id \t agent-id \t agent-name \t parent-id \t depth \t state + local aidx + aidx=$(ollie-9p read "session/$sname/agent/idx" 2>/dev/null || true) + while IFS=$'\t' read -r _sid _aid aname _pid _depth _state; do + [[ -z "$aname" ]] && continue + local status + status=$(ollie-9p read "session/$sname/agent/$aname/status" 2>/dev/null | head -n1) + printf ' %s/%s\t%s\n' "$sname" "$aname" "${status:-?}" + done <<< "$aidx" + done <<< "$idx" +} + cmd_help() { cat <<'EOF' usage: o [context] [args...] @@ -363,6 +393,7 @@ Paths starting with / are root-relative (ignore context): Commands (availability depends on context): Root level: + overview Show running sessions/agents and their status ls List sessions, models, etc. generate One-shot generation (no session needed) @@ -415,6 +446,7 @@ dispatch_cmd() { new) cmd_new "$@" ;; tui) cmd_tui "$@" ;; env) cmd_env "$@" ;; + overview) cmd_overview "$@" ;; help|-h|--help) cmd_help ;; *) echo "unknown command: $cmd" >&2 @@ -445,7 +477,9 @@ set -- $* # Main dispatch if [[ $# -lt 1 ]]; then - cmd_help + cmd_overview + echo "" + echo "run 'o help' for usage" exit 0 fi diff --git a/format/event.go b/format/event.go index 8e88fa2..fee2abc 100644 --- a/format/event.go +++ b/format/event.go @@ -25,7 +25,11 @@ func FormatEvent(role, name, content, outputFormat string) []byte { } return []byte(ToolDelim(name) + fence + "\n" + strings.TrimRight(content, "\n") + "\n```\n" + EndTag + "\n") case "error": - return []byte(RoleDelim("error") + "\n" + content + "\n" + EndTag + "\n") + role := "error" + if name != "" { + role = "error:" + name + } + return []byte(RoleDelim(role) + "\n" + content + "\n" + EndTag + "\n") case "info": return []byte(RoleDelim("info") + "\n" + content + "\n" + EndTag + "\n") case "retry":