human-friendliness: self-describing ctl, structured errors, status, overview
Make the namespace explain itself instead of requiring prior knowledge. - ctl is self-describing: reading it (empty write) or writing 'help' returns the valid verbs with one-line descriptions; an unknown verb errors with the valid list. Refactor dispatch to an ordered []ctlCmd carrying descriptions; drop the undocumented '.' alias and the drift-prone hardcoded help verb. Keep 'i' (drop 'inject') for fast injects. o's ctl usage now reads the live listing. - Errors carry severity + remediation. backend.ClassifyError maps the typed errors to transient/config/fatal with a one-line fix; the error event renders [[[error:<severity>]]] and a 'remediation:' line so a human knows whether to wait or intervene. - Add a human status file: 'thinking · 12s', 'calling shell · 3s', 'idle' — distinct from the machine-facing raw state. Wire the TUI bar to it. - Bare 'o' shows an overview of running sessions/agents with status, so you don't need to know any names to get oriented. Tests: dispatch help/unknown/routing, ClassifyError severity table.
This commit is contained in:
parent
f3933e21e7
commit
42cae9ee2e
|
|
@ -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: <tool>"
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 <name>", 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 <name>", 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 <pid>]", 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 <pid>, kill <pid>, out <pid>, dismiss <pid>)", 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 <name>", 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 <name>", 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)
|
||||
}),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 <command> [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 <session> ctl <cmd> (or o <session>/<agent> ctl <cmd>)" >&2
|
||||
exit 1
|
||||
fi
|
||||
# The ctl file is self-describing: an empty request returns the
|
||||
# valid commands with descriptions.
|
||||
echo "usage: o ctl <command> [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 <session>/<agent> 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] <command> [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 <prompt> 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
Loading…
Reference in New Issue