diff --git a/agent/context.go b/internal/agent/context.go similarity index 99% rename from agent/context.go rename to internal/agent/context.go index 1319546..ccf5dbe 100644 --- a/agent/context.go +++ b/internal/agent/context.go @@ -5,7 +5,7 @@ import ( "slices" "strings" - "ollie/backend" + "ollie/internal/backend" ) // ContextConfig controls the bounded context window behaviour. diff --git a/agent/loop.go b/internal/agent/loop.go similarity index 99% rename from agent/loop.go rename to internal/agent/loop.go index 20d56b7..48424ed 100644 --- a/agent/loop.go +++ b/internal/agent/loop.go @@ -8,7 +8,7 @@ import ( "strings" "time" - "ollie/backend" + "ollie/internal/backend" ) const maxRateLimitRetries = 3 diff --git a/agent/session.go b/internal/agent/session.go similarity index 99% rename from agent/session.go rename to internal/agent/session.go index fa48dfa..280c1f0 100644 --- a/agent/session.go +++ b/internal/agent/session.go @@ -7,7 +7,7 @@ import ( "os" "strings" - "ollie/backend" + "ollie/internal/backend" ) // PersistedSession is the on-disk format for a saved session. diff --git a/agent/state.go b/internal/agent/state.go similarity index 96% rename from agent/state.go rename to internal/agent/state.go index 7b5a647..13fb85b 100644 --- a/agent/state.go +++ b/internal/agent/state.go @@ -1,6 +1,6 @@ package agent -import "ollie/backend" +import "ollie/internal/backend" // ToolResult holds the output of a single tool call. type ToolResult struct { diff --git a/main.go b/internal/agentcore/agentcore.go similarity index 67% rename from main.go rename to internal/agentcore/agentcore.go index 4b6ecd7..7665817 100644 --- a/main.go +++ b/internal/agentcore/agentcore.go @@ -1,14 +1,11 @@ -package main +package agentcore import ( "bytes" "context" - "crypto/rand" "encoding/json" "errors" - "flag" "fmt" - "io" "io/fs" "os" "os/exec" @@ -17,16 +14,15 @@ import ( "sync/atomic" "time" - multiline "github.com/hymkor/go-multiline-ny" - gotty "github.com/mattn/go-tty" - readline "github.com/nyaosorg/go-readline-ny" + "crypto/rand" - "ollie/agent" - "ollie/backend" - "ollie/config" - execpkg "ollie/exec" - "ollie/mcp" - "ollie/tools" + "ollie/internal/agent" + "ollie/internal/backend" + "ollie/internal/config" + execpkg "ollie/internal/exec" + "ollie/internal/mcp" + "ollie/internal/tools" + "ollie/pkg/core" ) const systemPromptBase = `Use the fewest words possible. No preamble, filler, or narration ("Let me...", "I'll now...", "Great!"). No explanations of actions taken. No summaries of completed work. No reasoning unless asked. If the answer is one word, write one word. @@ -48,7 +44,9 @@ Tool call examples: Run named tool: {"tool": "discover_skill.sh", "args": ["keyword"]} Pipeline: {"pipe": [{"code": "cat file.txt"}, {"code": "grep foo"}]}` -func buildFirstPrompt(input string) string { +// BuildFirstPrompt seeds the first user message with the project file listing +// and README so the agent has immediate context. +func BuildFirstPrompt(input string) string { cwd, err := os.Getwd() if err != nil { return input @@ -123,7 +121,8 @@ func buildFirstPrompt(input string) string { return sb.String() } -func systemPrompt(allTools []backend.Tool) string { +// SystemPrompt builds the full system prompt for a given tool set. +func SystemPrompt(allTools []backend.Tool) string { cwd, _ := os.Getwd() now := time.Now().Format("2006-01-02 15:04:05 MST") names := make([]string, len(allTools)) @@ -135,7 +134,8 @@ func systemPrompt(allTools []backend.Tool) string { "\nAvailable tools: " + strings.Join(names, ", ") } -var builtinTools = []backend.Tool{ +// BuiltinTools is the set of tools provided by the agent core. +var BuiltinTools = []backend.Tool{ { Name: "execute_code", Description: "Run inline code in a sandboxed environment.", @@ -214,21 +214,22 @@ var builtinTools = []backend.Tool{ }, } -// agentEnv holds the runtime state derived from an agent config file. -type agentEnv struct { - mcpExec *tools.Executor - tools []backend.Tool - exec agent.ToolExecutor - confirm *agent.ConfirmFn - hooks map[string]string - systemPrompt string - genParams backend.GenerationParams - ctxOverhead int - messages []string - invalidateCaches func() +// AgentEnv holds the runtime state derived from an agent config file. +type AgentEnv struct { + McpExec *tools.Executor + Tools []backend.Tool + Exec agent.ToolExecutor + Confirm *agent.ConfirmFn + Hooks map[string]string + SystemPrompt string + GenParams backend.GenerationParams + CtxOverhead int + Messages []string + InvalidateCaches func() } -func buildAgentEnv(cfg *config.Config, builtinExec *execpkg.Executor) agentEnv { +// BuildAgentEnv constructs an AgentEnv from a config file and a builtin executor. +func BuildAgentEnv(cfg *config.Config, builtinExec *execpkg.Executor) AgentEnv { var messages []string mcpExec := tools.NewExecutor() @@ -260,7 +261,7 @@ func buildAgentEnv(cfg *config.Config, builtinExec *execpkg.Executor) agentEnv { serverOf[t.Name] = t.Server } - allTools := append(mcpToolsToBackend(mcpTools), builtinTools...) + allTools := append(mcpToolsToBackend(mcpTools), BuiltinTools...) hooks := map[string]string{} agentPrompt := "" @@ -282,9 +283,9 @@ func buildAgentEnv(cfg *config.Config, builtinExec *execpkg.Executor) agentEnv { } } - sp := systemPrompt(allTools) + sp := SystemPrompt(allTools) if agentPrompt != "" { - sp = systemPrompt(allTools) + "\n\n" + agentPrompt + sp = SystemPrompt(allTools) + "\n\n" + agentPrompt } overhead := len(sp) @@ -292,8 +293,8 @@ func buildAgentEnv(cfg *config.Config, builtinExec *execpkg.Executor) agentEnv { overhead += len(t.Name) + len(t.Description) + len(t.Parameters) } - builtinNames := make(map[string]struct{}, len(builtinTools)) - for _, t := range builtinTools { + builtinNames := make(map[string]struct{}, len(BuiltinTools)) + for _, t := range BuiltinTools { builtinNames[t.Name] = struct{}{} } @@ -385,24 +386,25 @@ func buildAgentEnv(cfg *config.Config, builtinExec *execpkg.Executor) agentEnv { return rawExec(ctx, name, args) } - return agentEnv{ - mcpExec: mcpExec, - tools: allTools, - exec: execFn, - confirm: confirmPtr, - hooks: hooks, - systemPrompt: sp, - genParams: genParams, - ctxOverhead: overhead, - messages: messages, - invalidateCaches: func() { + return AgentEnv{ + McpExec: mcpExec, + Tools: allTools, + Exec: execFn, + Confirm: confirmPtr, + Hooks: hooks, + SystemPrompt: sp, + GenParams: genParams, + CtxOverhead: overhead, + Messages: messages, + InvalidateCaches: func() { clear(fileReadCache) clear(toolCallSeen) }, } } -func agentConfigPath(agentsDir, name string) string { +// AgentConfigPath resolves the config file path for a named agent. +func AgentConfigPath(agentsDir, name string) string { p := agentsDir + "/" + name + ".json" if _, err := os.Stat(p); err == nil { return p @@ -414,14 +416,15 @@ func agentConfigPath(agentsDir, name string) string { return p } -func newSessionID() string { +// NewSessionID generates a unique session identifier. +func NewSessionID() string { b := make([]byte, 3) rand.Read(b) //nolint:errcheck return time.Now().Format("20060102-150405") + "-" + fmt.Sprintf("%06x", b) } -// defaultModelForBackend returns a sensible default model for the given backend label. -func defaultModelForBackend(name string) string { +// DefaultModelForBackend returns a sensible default model for the given backend label. +func DefaultModelForBackend(name string) string { switch name { case "anthropic": return "claude-sonnet-4-5" @@ -434,8 +437,8 @@ func defaultModelForBackend(name string) string { } } -// resolveBackendName returns a short human-readable backend label. -func resolveBackendName() string { +// ResolveBackendName returns a short human-readable backend label from env vars. +func ResolveBackendName() string { which := os.Getenv("OLLIE_BACKEND") if which == "" { which = "ollama" @@ -462,13 +465,33 @@ func resolveBackendName() string { } } +// infoEvent wraps a plain-text message as an info Event. +func infoEvent(text string) core.Event { + return core.Event{Role: "info", Content: text + "\n"} +} + // actionHandle holds the cancel function for the current agent turn. type actionHandle struct { cancel context.CancelCauseFunc } -// appState is the top-level runtime state, replacing the old bubbletea model. -type appState struct { +// AgentCoreConfig is the configuration for creating an AgentCore. +type AgentCoreConfig struct { + Backend backend.Backend + BackendName string + ModelName string + AgentName string + AgentsDir string + SessionsDir string + SessionID string + Session *agent.Session + Env AgentEnv + BuiltinExec *execpkg.Executor +} + +// AgentCore is the Core implementation. It owns all agent and session state +// but has no knowledge of how output is rendered. +type AgentCore struct { session *agent.Session loopcfg agent.Config hooks map[string]string @@ -483,31 +506,48 @@ type appState struct { confirmPtr *agent.ConfirmFn ctxOverhead int invalidateCaches func() - split *splitInput currentAction atomic.Pointer[actionHandle] - history recentHistory } -// recentHistory implements readline.IHistory for the multiline editor. -type recentHistory struct { - entries []string -} +var _ core.Core = (*AgentCore)(nil) // compile-time interface check -var _ readline.IHistory = (*recentHistory)(nil) - -func (h *recentHistory) Len() int { return len(h.entries) } -func (h *recentHistory) At(i int) string { - if i >= 0 && i < len(h.entries) { - return h.entries[i] +// NewAgentCore creates an AgentCore from the given configuration. +func NewAgentCore(cfg AgentCoreConfig) *AgentCore { + loopcfg := agent.Config{ + Backend: cfg.Backend, + Model: cfg.ModelName, + SystemPrompt: cfg.Env.SystemPrompt, + Tools: cfg.Env.Tools, + Exec: cfg.Env.Exec, + MaxSteps: 20, + GenerationParams: cfg.Env.GenParams, + } + return &AgentCore{ + session: cfg.Session, + loopcfg: loopcfg, + hooks: cfg.Env.Hooks, + modelName: cfg.ModelName, + backendName: cfg.BackendName, + agentName: cfg.AgentName, + agentsDir: cfg.AgentsDir, + sessionsDir: cfg.SessionsDir, + sessionID: cfg.SessionID, + mcpExec: cfg.Env.McpExec, + builtinExec: cfg.BuiltinExec, + confirmPtr: cfg.Env.Confirm, + ctxOverhead: cfg.Env.CtxOverhead, + invalidateCaches: cfg.Env.InvalidateCaches, } - return "" } -func (s *appState) prompt() string { +func (s *AgentCore) prompt() string { return fmt.Sprintf("[%s :: %s] ", s.backendName, s.agentName) } -func (s *appState) saveSession() { +// Prompt returns the display prompt string for the current session state. +func (s *AgentCore) Prompt() string { return s.prompt() } + +func (s *AgentCore) saveSession() { if s.session == nil || s.sessionID == "" || s.sessionsDir == "" { return } @@ -517,137 +557,28 @@ func (s *appState) saveSession() { } } -func (s *appState) getActionCancel() context.CancelCauseFunc { +func (s *AgentCore) getActionCancel() context.CancelCauseFunc { if a := s.currentAction.Load(); a != nil { return a.cancel } return nil } -func (s *appState) runInteractiveTTY(ctx context.Context) { - t, err := gotty.Open() - if err != nil { - fmt.Fprintln(os.Stderr, "tty:", err) - return - } - defer t.Close() - - var ed multiline.Editor - restorePaste, err := setupBracketedPaste(t, &ed) - if err != nil { - fmt.Fprintln(os.Stderr, "bracketed paste:", err) - } else { - defer restorePaste() - } - - ed.SetPrompt(func(w io.Writer, lnum int) (int, error) { - if lnum == 0 { - return fmt.Fprint(w, s.prompt()) - } - return fmt.Fprint(w, "... ") - }) - - ed.SubmitOnEnterWhen(func(lines []string, _ int) bool { - if len(lines) <= 1 { - return true - } - return !strings.HasSuffix(strings.TrimSpace(lines[len(lines)-1]), "\\") - }) - - ed.SetHistory(&s.history) - ed.SetHistoryCycling(true) - - s.split = newSplitInput(t, t.Output(), s.prompt(), nil) - - appCtx, appCancel := context.WithCancelCause(ctx) - startSignalWatcher(appCancel, s.getActionCancel, os.Stderr) - - var lastCtrlC time.Time - firstRead := true - - for appCtx.Err() == nil { - if firstRead { - firstRead = false - if _, h, err := t.Size(); err == nil && h > 0 { - clearScreenAndMoveToBottom(t.Output(), h) - } - } - - lines, err := ed.Read(appCtx) - if err != nil { - if errors.Is(err, io.EOF) { - break - } - errs := err.Error() - if errs == "interrupted" || errs == "^C" { - now := time.Now() - if !lastCtrlC.IsZero() && now.Sub(lastCtrlC) <= ctrlCExitWindow { - break - } - lastCtrlC = now - fmt.Fprint(os.Stderr, "^C (press Ctrl-C again to exit)\n") - continue - } - fmt.Fprintln(os.Stderr, "input error:", err) - break - } - - input := strings.Join(lines, "\n") - lastCtrlC = time.Time{} - - if strings.TrimSpace(input) == "" { - continue - } - - s.history.entries = append(s.history.entries, input) - - if len(lines) == 1 { - if w, _, err := t.Size(); err == nil { - prompt := s.prompt() - if shouldRerenderSubmittedSingleLine(prompt, input, w) { - rerenderSubmittedSingleLine(t.Output(), prompt, input) - } - } - } - - s.processInputWithSplit(appCtx, input, &ed) +// Interrupt cancels the current in-progress agent turn. +// Returns true if an action was running and was cancelled. +func (s *AgentCore) Interrupt(cause error) bool { + if cancel := s.getActionCancel(); cancel != nil { + cancel(cause) + return true } + return false } -func (s *appState) processInputWithSplit(ctx context.Context, input string, ed *multiline.Editor) { - if s.split == nil { - s.processInput(ctx, input, os.Stdout) - return - } - - s.split.SetPrompt(s.prompt()) - wrapper := s.split.Enter() - out := io.Writer(os.Stdout) - if wrapper != nil { - out = wrapper - } - - s.processInput(ctx, input, out) - - for ctx.Err() == nil { - q, ok := s.split.PopQueue() - if !ok { - break - } - s.split.SetPrompt(s.prompt()) - s.split.EchoQueuedInput(q) - s.history.entries = append(s.history.entries, q) - s.processInput(ctx, q, out) - } - - _, pending := s.split.Exit() - if pending != "" { - ed.SetDefault([]string{pending}) - } -} - -func (s *appState) processInput(ctx context.Context, input string, out io.Writer) { - if s.handleCommand(ctx, input, out) { +// Submit implements Core. It processes one line of user input: slash commands +// and shell shortcuts are dispatched immediately via handler; any other input +// starts an agent turn that streams events to handler. +func (s *AgentCore) Submit(ctx context.Context, input string, handler core.EventHandler) { + if s.handleCommand(ctx, input, handler) { return } @@ -656,7 +587,7 @@ func (s *appState) processInput(ctx context.Context, input string, out io.Writer } if s.session == nil { - s.session = agent.NewSessionWithConfig(buildFirstPrompt(input), agent.ContextConfig{ + s.session = agent.NewSessionWithConfig(BuildFirstPrompt(input), agent.ContextConfig{ FixedOverheadChars: s.ctxOverhead, }) } else { @@ -667,7 +598,7 @@ func (s *appState) processInput(ctx context.Context, input string, out io.Writer handle := &actionHandle{cancel: actCancel} s.currentAction.Store(handle) - s.loopcfg.Output = makeOutputFn(out) + s.loopcfg.Output = handler *s.confirmPtr = nil // auto-approve all confirmations for now if hook := s.hooks["agentSpawn"]; hook != "" { @@ -678,12 +609,12 @@ func (s *appState) processInput(ctx context.Context, input string, out io.Writer actCancel(nil) s.currentAction.CompareAndSwap(handle, nil) - if err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, ErrInterrupted) { - fmt.Fprintf(out, "error: %v\n", err) + if err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, core.ErrInterrupted) { + handler(core.Event{Role: "error", Content: err.Error()}) s.session.Rollback() } - fmt.Fprintln(out) + handler(core.Event{Role: "newline"}) if hook := s.hooks["stop"]; hook != "" { exec.Command("sh", "-c", hook).Run() //nolint:errcheck @@ -692,34 +623,7 @@ func (s *appState) processInput(ctx context.Context, input string, out io.Writer s.saveSession() } -func makeOutputFn(out io.Writer) agent.OutputFn { - return func(em agent.OutputMsg) { - switch em.Role { - case "assistant": - fmt.Fprint(out, em.Content) - case "call": - args := squashWhitespace(em.Content) - if len(args) > 500 { - args = args[:500] + "..." - } - fmt.Fprintf(out, "-> %s(%s)\n", em.Name, args) - case "tool": - s := strings.TrimRight(em.Content, "\n") - if len(s) > 500 { - s = s[:500] + "..." - } - fmt.Fprintf(out, "= %s\n", s) - case "retry": - fmt.Fprintf(out, "retrying in %ss...\n", em.Content) - case "error": - fmt.Fprintf(out, "error: %s\n", em.Content) - case "stalled": - fmt.Fprintln(out, "agent stalled") - } - } -} - -func (s *appState) handleCommand(ctx context.Context, input string, out io.Writer) bool { +func (s *AgentCore) handleCommand(ctx context.Context, input string, handler core.EventHandler) bool { if strings.HasPrefix(input, "!") { cmdStr := strings.TrimSpace(input[1:]) if cmdStr == "" { @@ -727,10 +631,10 @@ func (s *appState) handleCommand(ctx context.Context, input string, out io.Write } o, err := exec.Command("sh", "-c", cmdStr).CombinedOutput() if err != nil { - fmt.Fprintf(out, "error: %v\n", err) + handler(infoEvent("error: " + err.Error())) } if len(o) > 0 { - fmt.Fprint(out, strings.TrimRight(string(o), "\n")+"\n") + handler(infoEvent(strings.TrimRight(string(o), "\n"))) } return true } @@ -748,58 +652,38 @@ func (s *appState) handleCommand(ctx context.Context, input string, out io.Write args := parts[1:] switch cmd { - case "/queued": - var qcmd queuedCommand - var err error - if len(args) == 0 { - qcmd, err = parseQueuedCommandArgs("") - } else { - qcmd, err = parseQueuedCommandArgs(strings.Join(args, " ")) - } - if err != nil { - fmt.Fprintln(out, err) - return true - } - if s.split == nil { - _, msg := runQueuedCommand(nil, qcmd) - fmt.Fprintln(out, msg) - } else { - fmt.Fprintln(out, s.split.runQueuedCommand(qcmd)) - } - return true - case "/backend": if len(args) == 0 { - fmt.Fprintln(out, "error: /backend requires an argument (e.g., /backend ollama)") + handler(infoEvent("error: /backend requires an argument (e.g., /backend ollama)")) return true } os.Setenv("OLLIE_BACKEND", args[0]) be, err := backend.New() if err != nil { - fmt.Fprintf(out, "error: failed to switch backend: %v\n", err) + handler(infoEvent(fmt.Sprintf("error: failed to switch backend: %v", err))) return true } s.loopcfg.Backend = be - s.backendName = resolveBackendName() - s.loopcfg.Model = defaultModelForBackend(s.backendName) + s.backendName = ResolveBackendName() + s.loopcfg.Model = DefaultModelForBackend(s.backendName) s.modelName = s.loopcfg.Model - fmt.Fprintf(out, "switched backend to: %s (model: %s)\n", s.backendName, s.modelName) + handler(infoEvent(fmt.Sprintf("switched backend to: %s (model: %s)", s.backendName, s.modelName))) return true case "/model": if len(args) == 0 { - fmt.Fprintln(out, "error: /model requires an argument (e.g., /model qwen3:8b)") + handler(infoEvent("error: /model requires an argument (e.g., /model qwen3:8b)")) return true } s.loopcfg.Model = args[0] s.modelName = args[0] - fmt.Fprintf(out, "switched model to: %s\n", args[0]) + handler(infoEvent("switched model to: " + args[0])) return true case "/agents": entries, err := os.ReadDir(s.agentsDir) if err != nil { - fmt.Fprintf(out, "agents: %v\n", err) + handler(infoEvent(fmt.Sprintf("agents: %v", err))) return true } found := false @@ -812,63 +696,63 @@ func (s *appState) handleCommand(ctx context.Context, input string, out io.Write if name == s.agentName { marker = "* " } - fmt.Fprintln(out, marker+name) + handler(infoEvent(marker + name)) found = true } if !found { - fmt.Fprintln(out, "no agents found in "+s.agentsDir) + handler(infoEvent("no agents found in " + s.agentsDir)) } return true case "/agent": if len(args) == 0 { - fmt.Fprintf(out, "active agent: %s\n", s.agentName) + handler(infoEvent("active agent: " + s.agentName)) return true } name := args[0] - cfgPath := agentConfigPath(s.agentsDir, name) + cfgPath := AgentConfigPath(s.agentsDir, name) cfg, err := config.Load(cfgPath) if err != nil { - fmt.Fprintf(out, "error: agent %q: %v\n", name, err) + handler(infoEvent(fmt.Sprintf("error: agent %q: %v", name, err))) return true } if s.mcpExec != nil { s.mcpExec.Close() } - env := buildAgentEnv(cfg, s.builtinExec) - s.mcpExec = env.mcpExec - s.hooks = env.hooks - s.loopcfg.SystemPrompt = env.systemPrompt - s.loopcfg.Tools = env.tools - s.loopcfg.Exec = env.exec - s.loopcfg.GenerationParams = env.genParams - s.ctxOverhead = env.ctxOverhead - s.confirmPtr = env.confirm - s.invalidateCaches = env.invalidateCaches + env := BuildAgentEnv(cfg, s.builtinExec) + s.mcpExec = env.McpExec + s.hooks = env.Hooks + s.loopcfg.SystemPrompt = env.SystemPrompt + s.loopcfg.Tools = env.Tools + s.loopcfg.Exec = env.Exec + s.loopcfg.GenerationParams = env.GenParams + s.ctxOverhead = env.CtxOverhead + s.confirmPtr = env.Confirm + s.invalidateCaches = env.InvalidateCaches s.agentName = name s.session = nil - s.sessionID = newSessionID() - for _, msg := range env.messages { - fmt.Fprintln(out, msg) + s.sessionID = NewSessionID() + for _, msg := range env.Messages { + handler(infoEvent(msg)) } - fmt.Fprintf(out, "agent: %s\n", name) + handler(infoEvent("agent: " + name)) return true case "/compact": if s.session == nil { - fmt.Fprintln(out, "nothing to compact") + handler(infoEvent("nothing to compact")) return true } n, summary, err := s.session.Compact(ctx, s.loopcfg.Backend, s.loopcfg.Model) if err != nil { - fmt.Fprintln(out, "compact error:", err) + handler(infoEvent("compact error: " + err.Error())) } else if n == 0 { - fmt.Fprintln(out, "nothing to compact") + handler(infoEvent("nothing to compact")) } else { - fmt.Fprintf(out, "compacted %d messages\n", n) + handler(infoEvent(fmt.Sprintf("compacted %d messages", n))) if summary != "" { - fmt.Fprintln(out) - fmt.Fprintln(out, summary) + handler(core.Event{Role: "newline"}) + handler(infoEvent(summary)) } s.saveSession() if s.invalidateCaches != nil { @@ -879,15 +763,15 @@ func (s *appState) handleCommand(ctx context.Context, input string, out io.Write case "/context": if s.session == nil { - fmt.Fprintln(out, "no active session") + handler(infoEvent("no active session")) return true } - fmt.Fprintln(out, s.session.ContextDebug()) + handler(infoEvent(strings.TrimRight(s.session.ContextDebug(), "\n"))) return true case "/history": if s.session == nil { - fmt.Fprintln(out, "no active session") + handler(infoEvent("no active session")) return true } for _, msg := range s.session.History() { @@ -895,23 +779,23 @@ func (s *appState) handleCommand(ctx context.Context, input string, out io.Write if len(preview) > 200 { preview = preview[:200] + "..." } - fmt.Fprintf(out, "[%s] %s\n", msg.Role, preview) + handler(infoEvent(fmt.Sprintf("[%s] %s", msg.Role, preview))) } return true case "/clear": s.session = nil - s.sessionID = newSessionID() + s.sessionID = NewSessionID() if s.invalidateCaches != nil { s.invalidateCaches() } - fmt.Fprintln(out, "cleared") + handler(infoEvent("cleared")) return true case "/sessions": entries, err := os.ReadDir(s.sessionsDir) if err != nil { - fmt.Fprintf(out, "sessions: %v\n", err) + handler(infoEvent(fmt.Sprintf("sessions: %v", err))) return true } found := false @@ -942,161 +826,39 @@ func (s *appState) handleCommand(ctx context.Context, input string, out io.Write label = fmt.Sprintf("%-24s [%s] %q", id, ps.Agent, goal) } } - fmt.Fprintln(out, marker+label) + handler(infoEvent(marker + label)) found = true } if !found { - fmt.Fprintln(out, "no sessions found in "+s.sessionsDir) + handler(infoEvent("no sessions found in " + s.sessionsDir)) } return true case "/help": - fmt.Fprintln(out, "Available commands:") - fmt.Fprintln(out, " /agents - list available agent configs") - fmt.Fprintln(out, " /sessions - list saved sessions") - fmt.Fprintln(out, " /agent [name] - show or switch active agent") - fmt.Fprintln(out, " /backend - switch backend (ollama, openai)") - fmt.Fprintln(out, " /model - switch model") - fmt.Fprintln(out, " /queued [pop|clear] - manage queued prompts") - fmt.Fprintln(out, " /compact - summarize evicted context messages") - fmt.Fprintln(out, " /context - show context window debug info") - fmt.Fprintln(out, " /history - dump bounded message history") - fmt.Fprintln(out, " /clear - clear session") - fmt.Fprintln(out, " /help - show this help") - fmt.Fprintln(out, " ! - run shell command") + lines := []string{ + "Available commands:", + " /agents - list available agent configs", + " /sessions - list saved sessions", + " /agent [name] - show or switch active agent", + " /backend - switch backend (ollama, openai)", + " /model - switch model", + " /queued [pop|clear] - manage queued prompts", + " /compact - summarize evicted context messages", + " /context - show context window debug info", + " /history - dump bounded message history", + " /clear - clear session", + " /help - show this help", + " ! - run shell command", + } + for _, l := range lines { + handler(infoEvent(l)) + } return true } return false } -func main() { - sessionFlag := flag.String("session", "", "resume a session by ID") - promptFlag := flag.String("prompt", "", "run a single prompt non-interactively and exit") - flag.Parse() - extraArgs := flag.Args() - - home, _ := os.UserHomeDir() - agentsDir := home + "/.config/ollie/agents" - sessionsDir := home + "/.config/ollie/sessions" - if err := os.MkdirAll(sessionsDir, 0700); err != nil { - fmt.Fprintln(os.Stderr, "sessions dir:", err) - os.Exit(1) - } - - be, err := backend.New() - if err != nil { - fmt.Fprintln(os.Stderr, "failed to create backend:", err) - os.Exit(1) - } - - backendName := resolveBackendName() - - modelName := os.Getenv("OLLIE_MODEL") - if modelName == "" { - modelName = defaultModelForBackend(backendName) - } - builtinExec := execpkg.New( - home+"/.local/state/ollie", - home+"/.cache/ollie/exec", - ) - - agentName := os.Getenv("OLLIE_AGENT") - if agentName == "" { - agentName = "default" - } - - sessionID := newSessionID() - var resumeMessages []backend.Message - if *sessionFlag != "" { - sessionPath := sessionsDir + "/" + *sessionFlag + ".json" - data, readErr := os.ReadFile(sessionPath) - if readErr != nil { - fmt.Fprintln(os.Stderr, "--session:", readErr) - os.Exit(1) - } - var ps agent.PersistedSession - if jsonErr := json.Unmarshal(data, &ps); jsonErr != nil { - fmt.Fprintln(os.Stderr, "--session: bad JSON:", jsonErr) - os.Exit(1) - } - sessionID = ps.ID - resumeMessages = ps.Messages - if ps.Agent != "" && len(extraArgs) == 0 { - agentName = ps.Agent - } - } - if len(extraArgs) > 0 { - agentName = extraArgs[0] - } - - cfgPath := agentConfigPath(agentsDir, agentName) - cfg, cfgErr := config.Load(cfgPath) - - env := buildAgentEnv(cfg, builtinExec) - - var initialSession *agent.Session - if len(resumeMessages) > 0 { - initialSession = agent.RestoreSession(resumeMessages, agent.ContextConfig{ - FixedOverheadChars: env.ctxOverhead, - }) - } - - loopcfg := agent.Config{ - Backend: be, - Model: modelName, - SystemPrompt: env.systemPrompt, - Tools: env.tools, - Exec: env.exec, - MaxSteps: 20, - GenerationParams: env.genParams, - } - - s := &appState{ - session: initialSession, - loopcfg: loopcfg, - hooks: env.hooks, - modelName: modelName, - backendName: backendName, - agentName: agentName, - agentsDir: agentsDir, - sessionsDir: sessionsDir, - sessionID: sessionID, - mcpExec: env.mcpExec, - builtinExec: builtinExec, - confirmPtr: env.confirm, - ctxOverhead: env.ctxOverhead, - invalidateCaches: env.invalidateCaches, - } - - if cfgErr != nil { - fmt.Fprintln(os.Stderr, "agent config:", cfgErr) - } - for _, msg := range env.messages { - fmt.Fprintln(os.Stderr, msg) - } - if len(resumeMessages) > 0 { - fmt.Fprintf(os.Stderr, "session: %s (resumed)\n", sessionID) - } else { - fmt.Fprintf(os.Stderr, "session: %s\n", sessionID) - } - - if hook := env.hooks["agentSpawn"]; hook != "" { - exec.Command("sh", "-c", hook).Run() //nolint:errcheck - } - - if *promptFlag != "" { - s.processInput(context.Background(), *promptFlag, os.Stdout) - return - } - - s.runInteractiveTTY(context.Background()) -} - -func squashWhitespace(s string) string { - return strings.Join(strings.Fields(s), " ") -} - func mcpToolsToBackend(mcpTools []tools.ToolInfo) []backend.Tool { out := make([]backend.Tool, len(mcpTools)) for i, t := range mcpTools { @@ -1332,3 +1094,7 @@ func dispatchFileWrite(confirm agent.ConfirmFn, args json.RawMessage) (string, e } return fmt.Sprintf("replaced lines %d-%d in %s", start, end, a.Path), nil } + +func squashWhitespace(s string) string { + return strings.Join(strings.Fields(s), " ") +} diff --git a/backend/anthropic.go b/internal/backend/anthropic.go similarity index 100% rename from backend/anthropic.go rename to internal/backend/anthropic.go diff --git a/backend/backend.go b/internal/backend/backend.go similarity index 100% rename from backend/backend.go rename to internal/backend/backend.go diff --git a/backend/codewhisperer.go b/internal/backend/codewhisperer.go similarity index 100% rename from backend/codewhisperer.go rename to internal/backend/codewhisperer.go diff --git a/backend/codewhisperer_internal.go b/internal/backend/codewhisperer_internal.go similarity index 100% rename from backend/codewhisperer_internal.go rename to internal/backend/codewhisperer_internal.go diff --git a/backend/copilot.go b/internal/backend/copilot.go similarity index 100% rename from backend/copilot.go rename to internal/backend/copilot.go diff --git a/backend/new.go b/internal/backend/new.go similarity index 100% rename from backend/new.go rename to internal/backend/new.go diff --git a/backend/ollama.go b/internal/backend/ollama.go similarity index 100% rename from backend/ollama.go rename to internal/backend/ollama.go diff --git a/backend/openai.go b/internal/backend/openai.go similarity index 100% rename from backend/openai.go rename to internal/backend/openai.go diff --git a/config/config.go b/internal/config/config.go similarity index 100% rename from config/config.go rename to internal/config/config.go diff --git a/config/config_test.go b/internal/config/config_test.go similarity index 100% rename from config/config_test.go rename to internal/config/config_test.go diff --git a/exec/exec.go b/internal/exec/exec.go similarity index 100% rename from exec/exec.go rename to internal/exec/exec.go diff --git a/exec/exec_test.go b/internal/exec/exec_test.go similarity index 100% rename from exec/exec_test.go rename to internal/exec/exec_test.go diff --git a/exec/metrics.go b/internal/exec/metrics.go similarity index 100% rename from exec/metrics.go rename to internal/exec/metrics.go diff --git a/mcp/client.go b/internal/mcp/client.go similarity index 100% rename from mcp/client.go rename to internal/mcp/client.go diff --git a/mcp/transport.go b/internal/mcp/transport.go similarity index 100% rename from mcp/transport.go rename to internal/mcp/transport.go diff --git a/tools/executor.go b/internal/tools/executor.go similarity index 98% rename from tools/executor.go rename to internal/tools/executor.go index df578ec..19b7881 100644 --- a/tools/executor.go +++ b/internal/tools/executor.go @@ -3,7 +3,7 @@ package tools import ( "encoding/json" "fmt" - "ollie/mcp" + "ollie/internal/mcp" ) // ToolInfo describes a tool provided by an MCP server. diff --git a/bracketed_paste.go b/internal/tui/bracketed_paste.go similarity index 99% rename from bracketed_paste.go rename to internal/tui/bracketed_paste.go index 95aadc6..885e9d0 100644 --- a/bracketed_paste.go +++ b/internal/tui/bracketed_paste.go @@ -1,4 +1,4 @@ -package main +package tui import ( "fmt" diff --git a/interrupt_commands.go b/internal/tui/interrupt_commands.go similarity index 98% rename from interrupt_commands.go rename to internal/tui/interrupt_commands.go index 59864f5..aeefbb4 100644 --- a/interrupt_commands.go +++ b/internal/tui/interrupt_commands.go @@ -1,4 +1,4 @@ -package main +package tui import ( "fmt" diff --git a/queued_commands.go b/internal/tui/queued_commands.go similarity index 99% rename from queued_commands.go rename to internal/tui/queued_commands.go index 4db176c..9d33f32 100644 --- a/queued_commands.go +++ b/internal/tui/queued_commands.go @@ -1,4 +1,4 @@ -package main +package tui import ( "fmt" diff --git a/rerender_input.go b/internal/tui/rerender_input.go similarity index 99% rename from rerender_input.go rename to internal/tui/rerender_input.go index 08bd542..e545a81 100644 --- a/rerender_input.go +++ b/internal/tui/rerender_input.go @@ -1,4 +1,4 @@ -package main +package tui import ( "fmt" diff --git a/signals.go b/internal/tui/signals.go similarity index 62% rename from signals.go rename to internal/tui/signals.go index 0117679..33b803c 100644 --- a/signals.go +++ b/internal/tui/signals.go @@ -1,22 +1,19 @@ -package main +package tui import ( "context" - "errors" "fmt" "io" "os" "os/signal" "time" -) -var ErrInterrupted = errors.New("interrupted") + "ollie/pkg/core" +) const ctrlCExitWindow = 750 * time.Millisecond -type ActionCanceler func() context.CancelCauseFunc - -func startSignalWatcher(appCancel context.CancelCauseFunc, getActionCancel ActionCanceler, errStream io.Writer) { +func startSignalWatcher(appCancel context.CancelCauseFunc, c core.Core, errStream io.Writer) { ch := make(chan os.Signal, 16) signals := []os.Signal{os.Interrupt} if haveSIGTERM { @@ -34,10 +31,8 @@ func startSignalWatcher(appCancel context.CancelCauseFunc, getActionCancel Actio appCancel(context.Canceled) continue case os.Interrupt: - if cancel := getActionCancel(); cancel != nil { + if c.Interrupt(core.ErrInterrupted) { fmt.Fprint(errStream, "\n^C\n") - cancel(ErrInterrupted) - continue } } } diff --git a/signals_unix.go b/internal/tui/signals_unix.go similarity index 88% rename from signals_unix.go rename to internal/tui/signals_unix.go index b21a202..9d47a98 100644 --- a/signals_unix.go +++ b/internal/tui/signals_unix.go @@ -1,6 +1,6 @@ //go:build !windows -package main +package tui import "syscall" diff --git a/signals_windows.go b/internal/tui/signals_windows.go similarity index 87% rename from signals_windows.go rename to internal/tui/signals_windows.go index 4c6b1c0..e55558f 100644 --- a/signals_windows.go +++ b/internal/tui/signals_windows.go @@ -1,6 +1,6 @@ //go:build windows -package main +package tui import "os" diff --git a/splitinput.go b/internal/tui/splitinput.go similarity index 99% rename from splitinput.go rename to internal/tui/splitinput.go index 9fc2c49..7796d2d 100644 --- a/splitinput.go +++ b/internal/tui/splitinput.go @@ -1,6 +1,6 @@ //go:build !windows -package main +package tui import ( "fmt" diff --git a/splitinput_windows.go b/internal/tui/splitinput_windows.go similarity index 97% rename from splitinput_windows.go rename to internal/tui/splitinput_windows.go index 563cfe4..c1444e5 100644 --- a/splitinput_windows.go +++ b/internal/tui/splitinput_windows.go @@ -1,6 +1,6 @@ //go:build windows -package main +package tui import "io" diff --git a/internal/tui/tui.go b/internal/tui/tui.go new file mode 100644 index 0000000..40aa36c --- /dev/null +++ b/internal/tui/tui.go @@ -0,0 +1,219 @@ +package tui + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "strings" + "time" + + multiline "github.com/hymkor/go-multiline-ny" + gotty "github.com/mattn/go-tty" + readline "github.com/nyaosorg/go-readline-ny" + + "ollie/pkg/core" +) + +// recentHistory implements readline.IHistory for the multiline editor. +type recentHistory struct { + entries []string +} + +var _ readline.IHistory = (*recentHistory)(nil) + +func (h *recentHistory) Len() int { return len(h.entries) } +func (h *recentHistory) At(i int) string { + if i >= 0 && i < len(h.entries) { + return h.entries[i] + } + return "" +} + +// TUI is the terminal frontend. It owns all TUI state and drives a Core. +type TUI struct { + core core.Core + split *splitInput + history recentHistory +} + +// New creates a TUI backed by the given Core. +func New(c core.Core) *TUI { + return &TUI{core: c} +} + +// Run starts the interactive readline loop, blocking until the user exits. +func (t *TUI) Run(ctx context.Context) { + tt, err := gotty.Open() + if err != nil { + fmt.Fprintln(os.Stderr, "tty:", err) + return + } + defer tt.Close() + + var ed multiline.Editor + restorePaste, err := setupBracketedPaste(tt, &ed) + if err != nil { + fmt.Fprintln(os.Stderr, "bracketed paste:", err) + } else { + defer restorePaste() + } + + ed.SetPrompt(func(w io.Writer, lnum int) (int, error) { + if lnum == 0 { + return fmt.Fprint(w, t.core.Prompt()) + } + return fmt.Fprint(w, "... ") + }) + + ed.SubmitOnEnterWhen(func(lines []string, _ int) bool { + if len(lines) <= 1 { + return true + } + return !strings.HasSuffix(strings.TrimSpace(lines[len(lines)-1]), "\\") + }) + + ed.SetHistory(&t.history) + ed.SetHistoryCycling(true) + + t.split = newSplitInput(tt, tt.Output(), t.core.Prompt(), nil) + + appCtx, appCancel := context.WithCancelCause(ctx) + startSignalWatcher(appCancel, t.core, os.Stderr) + + var lastCtrlC time.Time + firstRead := true + + for appCtx.Err() == nil { + if firstRead { + firstRead = false + if _, h, err := tt.Size(); err == nil && h > 0 { + clearScreenAndMoveToBottom(tt.Output(), h) + } + } + + lines, err := ed.Read(appCtx) + if err != nil { + if errors.Is(err, io.EOF) { + break + } + errs := err.Error() + if errs == "interrupted" || errs == "^C" { + now := time.Now() + if !lastCtrlC.IsZero() && now.Sub(lastCtrlC) <= ctrlCExitWindow { + break + } + lastCtrlC = now + fmt.Fprint(os.Stderr, "^C (press Ctrl-C again to exit)\n") + continue + } + fmt.Fprintln(os.Stderr, "input error:", err) + break + } + + input := strings.Join(lines, "\n") + lastCtrlC = time.Time{} + + if strings.TrimSpace(input) == "" { + continue + } + + t.history.entries = append(t.history.entries, input) + + if len(lines) == 1 { + if w, _, err := tt.Size(); err == nil { + prompt := t.core.Prompt() + if shouldRerenderSubmittedSingleLine(prompt, input, w) { + rerenderSubmittedSingleLine(tt.Output(), prompt, input) + } + } + } + + t.processInputWithSplit(appCtx, input, &ed) + } +} + +func (t *TUI) processInputWithSplit(ctx context.Context, input string, ed *multiline.Editor) { + // /queued is TUI-specific: it manipulates the split input queue. + if cmd, ok, err := parseQueuedCommandLine(input); ok { + var msg string + if err != nil { + msg = err.Error() + } else if t.split == nil { + _, msg = runQueuedCommand(nil, cmd) + } else { + msg = t.split.runQueuedCommand(cmd) + } + fmt.Fprintln(os.Stdout, msg) + return + } + + if t.split == nil { + t.core.Submit(ctx, input, MakeOutputFn(os.Stdout)) + return + } + + t.split.SetPrompt(t.core.Prompt()) + wrapper := t.split.Enter() + out := io.Writer(os.Stdout) + if wrapper != nil { + out = wrapper + } + + handler := MakeOutputFn(out) + t.core.Submit(ctx, input, handler) + + for ctx.Err() == nil { + q, ok := t.split.PopQueue() + if !ok { + break + } + t.split.SetPrompt(t.core.Prompt()) + t.split.EchoQueuedInput(q) + t.history.entries = append(t.history.entries, q) + t.core.Submit(ctx, q, handler) + } + + _, pending := t.split.Exit() + if pending != "" { + ed.SetDefault([]string{pending}) + } +} + +// MakeOutputFn returns an EventHandler that renders events as text to out. +// This is the TUI's bridge from typed events to terminal output. +func MakeOutputFn(out io.Writer) core.EventHandler { + return func(em core.Event) { + switch em.Role { + case "assistant": + fmt.Fprint(out, em.Content) + case "call": + args := squashWhitespace(em.Content) + if len(args) > 500 { + args = args[:500] + "..." + } + fmt.Fprintf(out, "-> %s(%s)\n", em.Name, args) + case "tool": + s := strings.TrimRight(em.Content, "\n") + if len(s) > 500 { + s = s[:500] + "..." + } + fmt.Fprintf(out, "= %s\n", s) + case "retry": + fmt.Fprintf(out, "retrying in %ss...\n", em.Content) + case "error": + fmt.Fprintf(out, "error: %s\n", em.Content) + case "stalled": + fmt.Fprintln(out, "agent stalled") + case "info": + fmt.Fprint(out, em.Content) + case "newline": + fmt.Fprintln(out) + } + } +} + +func squashWhitespace(s string) string { + return strings.Join(strings.Fields(s), " ") +} diff --git a/main_test.go b/main_test.go deleted file mode 100644 index 06ab7d0..0000000 --- a/main_test.go +++ /dev/null @@ -1 +0,0 @@ -package main diff --git a/mkfile b/mkfile index e631d5b..4808d5f 100644 --- a/mkfile +++ b/mkfile @@ -3,7 +3,7 @@ INSTALL_PATH=$HOME/bin all:V: install build:V: - go build -o $INSTALL_PATH/ollie + go build -o $INSTALL_PATH/ollie ./cmd/ollie mkdir -p $HOME/.config/ollie cp -rf agents $HOME/.config/ollie diff --git a/pkg/core/core.go b/pkg/core/core.go new file mode 100644 index 0000000..92d4c40 --- /dev/null +++ b/pkg/core/core.go @@ -0,0 +1,34 @@ +package core + +import ( + "context" + "errors" + + "ollie/internal/agent" +) + +// ErrInterrupted is returned when the user cancels an agent turn (Ctrl-C). +var ErrInterrupted = errors.New("interrupted") + +// Event is a typed output event emitted by the Core during an agent turn +// or in response to a command. +type Event = agent.OutputMsg + +// EventHandler receives events from the Core. +type EventHandler = agent.OutputFn + +// Core is the interface between a frontend (TUI, HTTP handler, etc.) and the +// agent engine. All output from the agent is delivered via EventHandler. +type Core interface { + // Submit processes one line of user input. Slash commands and shell + // shortcuts are dispatched synchronously; any other input starts an agent + // turn that streams events to handler until the turn is complete. + Submit(ctx context.Context, input string, handler EventHandler) + + // Prompt returns the display prompt string for the current session state. + Prompt() string + + // Interrupt cancels the current in-progress agent turn. + // Returns true if an action was running and was cancelled. + Interrupt(cause error) bool +}