diff --git a/cmd/ollie/main.go b/cmd/ollie/main.go index dc538bf..559bee5 100644 --- a/cmd/ollie/main.go +++ b/cmd/ollie/main.go @@ -2,18 +2,19 @@ package main import ( "context" + "crypto/rand" "encoding/json" "flag" "fmt" "os" + "strings" + "time" - "ollie/internal/agent" - "ollie/internal/backend" "ollie/internal/config" execpkg "ollie/internal/exec" "ollie/internal/tui" - "ollie/pkg/core" + "ollie/pkg/agent" ) func main() { @@ -36,10 +37,10 @@ func main() { os.Exit(1) } - backendName := agent.ResolveBackendName() + backendName := resolveBackendName() modelName := os.Getenv("OLLIE_MODEL") if modelName == "" { - modelName = agent.DefaultModelForBackend(backendName) + modelName = defaultModelForBackend(backendName) } builtinExec := execpkg.New( @@ -52,7 +53,7 @@ func main() { agentName = "default" } - sessionID := agent.NewSessionID() + sessionID := newSessionID() var resumeMessages []backend.Message if *sessionFlag != "" { sessionPath := sessionsDir + "/" + *sessionFlag + ".json" @@ -76,15 +77,13 @@ func main() { agentName = extraArgs[0] } - cfgPath := agent.AgentConfigPath(agentsDir, agentName) + cfgPath := agentConfigPath(agentsDir, agentName) cfg, cfgErr := config.Load(cfgPath) env := agent.BuildAgentEnv(cfg, builtinExec) var initialSession *agent.Session if len(resumeMessages) > 0 { - initialSession = agent.RestoreSession(resumeMessages, agent.ContextConfig{ - FixedOverheadChars: env.CtxOverhead, - }) + initialSession = agent.RestoreSession(resumeMessages, env.CtxOverhead) } agentCore := agent.NewAgentCore(agent.AgentCoreConfig{ @@ -112,7 +111,7 @@ func main() { fmt.Fprintf(os.Stderr, "session: %s\n", sessionID) } - env.Hooks.Run(core.HookAgentSpawn) + env.Hooks.Run(agent.HookAgentSpawn) if *promptFlag != "" { agentCore.Submit(context.Background(), *promptFlag, tui.MakeOutputFn(os.Stdout)) @@ -121,3 +120,61 @@ func main() { tui.New(agentCore).Run(context.Background()) } + +func resolveBackendName() string { + which := os.Getenv("OLLIE_BACKEND") + if which == "" { + which = "ollama" + } + if which != "openai" { + return which + } + url := strings.ToLower(os.Getenv("OLLIE_OPENAI_URL")) + switch { + case strings.Contains(url, "openrouter"): + return "openrouter" + case strings.Contains(url, "together"): + return "together" + case strings.Contains(url, "groq"): + return "groq" + case strings.Contains(url, "mistral"): + return "mistral" + case strings.Contains(url, "anthropic"): + return "anthropic" + case strings.Contains(url, "localhost") || strings.Contains(url, "127.0.0.1"): + return "local" + default: + return "openai" + } +} + +func defaultModelForBackend(name string) string { + switch name { + case "anthropic": + return "claude-sonnet-4-5" + case "openrouter": + return "deepseek/deepseek-v3.2" + case "kiro", "codewhisperer": + return "auto" + default: + return "qwen3.5:9b" + } +} + +func newSessionID() string { + b := make([]byte, 3) + rand.Read(b) //nolint:errcheck + return time.Now().Format("20060102-150405") + "-" + fmt.Sprintf("%06x", b) +} + +func agentConfigPath(agentsDir, name string) string { + p := agentsDir + "/" + name + ".json" + if _, err := os.Stat(p); err == nil { + return p + } + if name == "default" { + home, _ := os.UserHomeDir() + return home + "/.config/ollie/config.json" + } + return p +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index d504fc7..16be88c 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -13,7 +13,7 @@ import ( gotty "github.com/mattn/go-tty" readline "github.com/nyaosorg/go-readline-ny" - "ollie/pkg/core" + "ollie/pkg/agent" ) // recentHistory implements readline.IHistory for the multiline editor. @@ -33,13 +33,13 @@ func (h *recentHistory) At(i int) string { // TUI is the terminal frontend. It owns all TUI state and drives a Core. type TUI struct { - core core.Core + core agent.Core split *splitInput history recentHistory } // New creates a TUI backed by the given Core. -func New(c core.Core) *TUI { +func New(c agent.Core) *TUI { return &TUI{core: c} } @@ -80,7 +80,7 @@ func (t *TUI) Run(ctx context.Context) { t.split = newSplitInput(tt, tt.Output(), t.core.Prompt(), nil) appCtx, appCancel := context.WithCancelCause(ctx) - core.WatchSignals(appCancel, t.core, os.Stderr) + agent.WatchSignals(appCancel, t.core, os.Stderr) var lastCtrlC time.Time firstRead := true @@ -101,7 +101,7 @@ func (t *TUI) Run(ctx context.Context) { errs := err.Error() if errs == "interrupted" || errs == "^C" { now := time.Now() - if !lastCtrlC.IsZero() && now.Sub(lastCtrlC) <= core.CtrlCExitWindow { + if !lastCtrlC.IsZero() && now.Sub(lastCtrlC) <= agent.CtrlCExitWindow { break } lastCtrlC = now @@ -183,8 +183,8 @@ func (t *TUI) processInputWithSplit(ctx context.Context, input string, ed *multi // 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) { +func MakeOutputFn(out io.Writer) agent.EventHandler { + return func(em agent.Event) { switch em.Role { case "assistant": fmt.Fprint(out, em.Content) diff --git a/internal/agent/context.go b/pkg/agent/context.go similarity index 86% rename from internal/agent/context.go rename to pkg/agent/context.go index ccf5dbe..513fbd1 100644 --- a/internal/agent/context.go +++ b/pkg/agent/context.go @@ -8,9 +8,9 @@ import ( "ollie/internal/backend" ) -// ContextConfig controls the bounded context window behaviour. +// contextConfig controls the bounded context window behaviour. // All sizes are in characters (a rough proxy for tokens; ~4 chars per token). -type ContextConfig struct { +type contextConfig struct { // SoftLimit: if assembled history exceeds this, begin evicting old messages. // Defaults to 24000 (~6k tokens). SoftLimit int @@ -27,21 +27,21 @@ type ContextConfig struct { TailMessages int // FixedOverheadChars is the estimated character count of fixed per-request - // overhead (system prompt, tool schemas) sent outside the ContextBuilder. + // overhead (system prompt, tool schemas) sent outside the contextBuilder. // Subtracted from the budget before greedy inclusion. // Defaults to 0. FixedOverheadChars int } -func defaultContextConfig() ContextConfig { - return ContextConfig{ +func defaultContextConfig() contextConfig { + return contextConfig{ SoftLimit: 120_000, HardLimit: 400_000, TailMessages: 10, } } -func (c *ContextConfig) setDefaults() { +func (c *contextConfig) setDefaults() { if c.SoftLimit <= 0 { c.SoftLimit = 120_000 } @@ -53,38 +53,38 @@ func (c *ContextConfig) setDefaults() { } } -// ContextBuilder manages a rolling bounded history window. -type ContextBuilder struct { - cfg ContextConfig +// contextBuilder manages a rolling bounded history window. +type contextBuilder struct { + cfg contextConfig messages []backend.Message // full unbounded log } -// NewContextBuilder creates a ContextBuilder with the given config. -// Pass a zero-value ContextConfig to use all defaults. -func NewContextBuilder(cfg ContextConfig) *ContextBuilder { +// newContextBuilder creates a contextBuilder with the given config. +// Pass a zero-value contextConfig to use all defaults. +func newContextBuilder(cfg contextConfig) *contextBuilder { cfg.setDefaults() - return &ContextBuilder{cfg: cfg} + return &contextBuilder{cfg: cfg} } // Append adds a message to the full history. -func (cb *ContextBuilder) Append(m backend.Message) { +func (cb *contextBuilder) Append(m backend.Message) { cb.messages = append(cb.messages, m) } // Messages returns the full stored message log (unbounded). -func (cb *ContextBuilder) Messages() []backend.Message { +func (cb *contextBuilder) Messages() []backend.Message { return cb.messages } // Truncate discards all messages after index i. -func (cb *ContextBuilder) Truncate(i int) { +func (cb *contextBuilder) Truncate(i int) { if i < len(cb.messages) { cb.messages = cb.messages[:i] } } // EvictedMessages returns the messages that would be dropped by buildBounded. -func (cb *ContextBuilder) EvictedMessages() []backend.Message { +func (cb *contextBuilder) EvictedMessages() []backend.Message { var rest []backend.Message for _, m := range cb.messages { if m.Role != "system" { @@ -114,13 +114,13 @@ func (cb *ContextBuilder) EvictedMessages() []backend.Message { } // BoundedHistory returns a context-window-safe slice of messages. -func (cb *ContextBuilder) BoundedHistory() []backend.Message { +func (cb *contextBuilder) BoundedHistory() []backend.Message { return cb.buildBounded(false) } // BoundedHistoryWithNotice is like BoundedHistory but injects a compaction // notice message when older messages were dropped, so the model is aware. -func (cb *ContextBuilder) BoundedHistoryWithNotice() []backend.Message { +func (cb *contextBuilder) BoundedHistoryWithNotice() []backend.Message { return cb.buildBounded(true) } @@ -135,7 +135,7 @@ func (cb *ContextBuilder) BoundedHistoryWithNotice() []backend.Message { // accounting for FixedOverheadChars (system prompt, tool schemas). // 4. If total still exceeds HardLimit, oldest non-system/non-tail messages // are dropped atomically (assistant[tool_calls]+tool pairs together). -func (cb *ContextBuilder) buildBounded(injectNotice bool) []backend.Message { +func (cb *contextBuilder) buildBounded(injectNotice bool) []backend.Message { var system []backend.Message var rest []backend.Message @@ -243,10 +243,10 @@ func computeTailStart(rest []backend.Message, tailCount int) int { } // Len returns the number of stored messages. -func (cb *ContextBuilder) Len() int { return len(cb.messages) } +func (cb *contextBuilder) Len() int { return len(cb.messages) } // SystemMessages returns all system-role messages. -func (cb *ContextBuilder) SystemMessages() []backend.Message { +func (cb *contextBuilder) SystemMessages() []backend.Message { var out []backend.Message for _, m := range cb.messages { if m.Role == "system" { @@ -258,7 +258,7 @@ func (cb *ContextBuilder) SystemMessages() []backend.Message { // OlderMessages returns non-system messages that fall before the tail window. // These are the candidates for compaction. -func (cb *ContextBuilder) OlderMessages() []backend.Message { +func (cb *contextBuilder) OlderMessages() []backend.Message { var rest []backend.Message for _, m := range cb.messages { if m.Role != "system" { @@ -270,7 +270,7 @@ func (cb *ContextBuilder) OlderMessages() []backend.Message { } // TailWindow returns non-system messages within the protected tail window. -func (cb *ContextBuilder) TailWindow() []backend.Message { +func (cb *contextBuilder) TailWindow() []backend.Message { var rest []backend.Message for _, m := range cb.messages { if m.Role != "system" { @@ -283,7 +283,7 @@ func (cb *ContextBuilder) TailWindow() []backend.Message { // ApproxTokens returns a rough token estimate for the bounded history // using the 4-chars-per-token heuristic. -func (cb *ContextBuilder) ApproxTokens() int { +func (cb *contextBuilder) ApproxTokens() int { return totalChars(cb.BoundedHistory()) / 4 } @@ -347,8 +347,8 @@ func sanitizeHistory(msgs []backend.Message) []backend.Message { return result } -// ContextStats describes the current state of the context window. -type ContextStats struct { +// contextStats describes the current state of the context window. +type contextStats struct { StoredMessages int BoundedMessages int ApproxTokens int @@ -357,13 +357,13 @@ type ContextStats struct { HardLimit int } -func (cb *ContextBuilder) Stats() ContextStats { +func (cb *contextBuilder) Stats() contextStats { bounded := cb.BoundedHistory() evicted := len(cb.messages) - len(bounded) if evicted < 0 { evicted = 0 } - return ContextStats{ + return contextStats{ StoredMessages: len(cb.messages), BoundedMessages: len(bounded), ApproxTokens: (totalChars(bounded) + cb.cfg.FixedOverheadChars) / 4, @@ -374,7 +374,7 @@ func (cb *ContextBuilder) Stats() ContextStats { } // ContextStatsString returns a one-line human-readable summary. -func (cb *ContextBuilder) ContextStatsString() string { +func (cb *contextBuilder) ContextStatsString() string { s := cb.Stats() evictedStr := "" if s.Evicted > 0 { @@ -386,7 +386,7 @@ func (cb *ContextBuilder) ContextStatsString() string { // FormatContextDebug returns a multi-line breakdown of the bounded history // useful for debug output. -func (cb *ContextBuilder) FormatContextDebug() string { +func (cb *contextBuilder) FormatContextDebug() string { var sb strings.Builder s := cb.Stats() sb.WriteString(fmt.Sprintf("=== context window: ~%d tokens | %d bounded / %d stored | soft=%d hard=%d ===\n", diff --git a/internal/agent/core.go b/pkg/agent/core.go similarity index 86% rename from internal/agent/core.go rename to pkg/agent/core.go index 4aaa510..5303816 100644 --- a/internal/agent/core.go +++ b/pkg/agent/core.go @@ -21,7 +21,6 @@ import ( 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. @@ -43,9 +42,9 @@ Tool call examples: Run named tool: {"tool": "discover_skill.sh", "args": ["keyword"]} Pipeline: {"pipe": [{"code": "cat file.txt"}, {"code": "grep foo"}]}` -// BuildFirstPrompt seeds the first user message with the project file listing +// buildFirstPrompt seeds the first user message with the project file listing // and README so the agent has immediate context. -func BuildFirstPrompt(input string) string { +func buildFirstPrompt(input string) string { cwd, err := os.Getwd() if err != nil { return input @@ -120,8 +119,8 @@ func BuildFirstPrompt(input string) string { return sb.String() } -// SystemPrompt builds the full system prompt for a given tool set. -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)) @@ -133,8 +132,8 @@ func SystemPrompt(allTools []backend.Tool) string { "\nAvailable tools: " + strings.Join(names, ", ") } -// BuiltinTools is the set of tools provided by the agent core. -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.", @@ -215,16 +214,16 @@ 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 ToolExecutor - Confirm *ConfirmFn - Hooks core.Hooks - SystemPrompt string - GenParams backend.GenerationParams + mcpExec *tools.Executor + tools []backend.Tool + exec toolExecutor + confirm *confirmFn + Hooks Hooks + systemPrompt string + genParams backend.GenerationParams CtxOverhead int Messages []string - InvalidateCaches func() + invalidateCaches func() } // BuildAgentEnv constructs an AgentEnv from a config file and a builtin executor. @@ -260,9 +259,9 @@ 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 := core.Hooks{} + hooks := Hooks{} agentPrompt := "" trustedTools := map[string]struct{}{} var genParams backend.GenerationParams @@ -282,9 +281,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,13 +291,13 @@ 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{}{} } - var confirmFn ConfirmFn - confirmPtr := &confirmFn + var cfn confirmFn + confirmPtr := &cfn fileReadCache := make(map[string]bool) toolCallSeen := make(map[string]bool) @@ -312,7 +311,7 @@ func BuildAgentEnv(cfg *config.Config, builtinExec *execpkg.Executor) AgentEnv { return extractMCPText(raw), nil } if _, ok := builtinNames[name]; ok { - var cfn ConfirmFn + var cfn confirmFn if _, trusted := trustedTools[name]; !trusted { cfn = *confirmPtr } @@ -386,44 +385,23 @@ func BuildAgentEnv(cfg *config.Config, builtinExec *execpkg.Executor) AgentEnv { } return AgentEnv{ - McpExec: mcpExec, - Tools: allTools, - Exec: execFn, - Confirm: confirmPtr, + mcpExec: mcpExec, + tools: allTools, + exec: execFn, + confirm: confirmPtr, Hooks: hooks, - SystemPrompt: sp, - GenParams: genParams, + systemPrompt: sp, + genParams: genParams, CtxOverhead: overhead, Messages: messages, - InvalidateCaches: func() { + invalidateCaches: func() { clear(fileReadCache) clear(toolCallSeen) }, } } -// 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 - } - if name == "default" { - home, _ := os.UserHomeDir() - return home + "/.config/ollie/config.json" - } - return p -} - -// 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 { +func defaultModelForBackend(name string) string { switch name { case "anthropic": return "claude-sonnet-4-5" @@ -431,13 +409,12 @@ func DefaultModelForBackend(name string) string { return "deepseek/deepseek-v3.2" case "kiro", "codewhisperer": return "auto" - default: // ollama, local, groq, mistral, together, etc. + default: return "qwen3.5:9b" } } -// ResolveBackendName returns a short human-readable backend label from env vars. -func ResolveBackendName() string { +func resolveBackendName() string { which := os.Getenv("OLLIE_BACKEND") if which == "" { which = "ollama" @@ -464,9 +441,30 @@ func ResolveBackendName() 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 + } + if name == "default" { + home, _ := os.UserHomeDir() + return home + "/.config/ollie/config.json" + } + return p +} + +// 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) +} + + // infoEvent wraps a plain-text message as an info Event. -func infoEvent(text string) core.Event { - return core.Event{Role: "info", Content: text + "\n"} +func infoEvent(text string) Event { + return Event{Role: "info", Content: text + "\n"} } // actionHandle holds the cancel function for the current agent turn. @@ -474,7 +472,7 @@ type actionHandle struct { cancel context.CancelCauseFunc } -// AgentCoreConfig is the configuration for creating an AgentCore. +// AgentCoreConfig is the configuration for creating an agentCore. type AgentCoreConfig struct { Backend backend.Backend BackendName string @@ -488,12 +486,12 @@ type AgentCoreConfig struct { BuiltinExec *execpkg.Executor } -// AgentCore is the Core implementation. It owns all agent and session state +// agentCore is the Core implementation. It owns all agent and session state // but has no knowledge of how output is rendered. -type AgentCore struct { +type agentCore struct { session *Session - loopcfg Config - hooks core.Hooks + loopcfg loopConfig + hooks Hooks modelName string backendName string agentName string @@ -502,26 +500,26 @@ type AgentCore struct { sessionID string mcpExec *tools.Executor builtinExec *execpkg.Executor - confirmPtr *ConfirmFn + confirmPtr *confirmFn ctxOverhead int invalidateCaches func() currentAction atomic.Pointer[actionHandle] } -var _ core.Core = (*AgentCore)(nil) // compile-time interface check +var _ Core = (*agentCore)(nil) // compile-time interface check -// NewAgentCore creates an AgentCore from the given configuration. -func NewAgentCore(cfg AgentCoreConfig) *AgentCore { - loopcfg := Config{ +// NewAgentCore creates an agentCore from the given configuration. +func NewAgentCore(cfg AgentCoreConfig) Core { + loopcfg := loopConfig{ Backend: cfg.Backend, Model: cfg.ModelName, - SystemPrompt: cfg.Env.SystemPrompt, - Tools: cfg.Env.Tools, - Exec: cfg.Env.Exec, + systemPrompt: cfg.Env.systemPrompt, + Tools: cfg.Env.tools, + Exec: cfg.Env.exec, MaxSteps: 20, - GenerationParams: cfg.Env.GenParams, + GenerationParams: cfg.Env.genParams, } - return &AgentCore{ + return &agentCore{ session: cfg.Session, loopcfg: loopcfg, hooks: cfg.Env.Hooks, @@ -531,32 +529,32 @@ func NewAgentCore(cfg AgentCoreConfig) *AgentCore { agentsDir: cfg.AgentsDir, sessionsDir: cfg.SessionsDir, sessionID: cfg.SessionID, - mcpExec: cfg.Env.McpExec, + mcpExec: cfg.Env.mcpExec, builtinExec: cfg.BuiltinExec, - confirmPtr: cfg.Env.Confirm, + confirmPtr: cfg.Env.confirm, ctxOverhead: cfg.Env.CtxOverhead, - invalidateCaches: cfg.Env.InvalidateCaches, + invalidateCaches: cfg.Env.invalidateCaches, } } -func (s *AgentCore) prompt() string { +func (s *agentCore) prompt() string { return fmt.Sprintf("[%s :: %s] ", s.backendName, s.agentName) } // Prompt returns the display prompt string for the current session state. -func (s *AgentCore) Prompt() string { return s.prompt() } +func (s *agentCore) Prompt() string { return s.prompt() } -func (s *AgentCore) saveSession() { +func (s *agentCore) saveSession() { if s.session == nil || s.sessionID == "" || s.sessionsDir == "" { return } path := s.sessionsDir + "/" + s.sessionID + ".json" - if err := s.session.SaveTo(path, s.sessionID, s.agentName); err != nil { + if err := s.session.saveTo(path, s.sessionID, s.agentName); err != nil { fmt.Fprintln(os.Stderr, "session save:", err) } } -func (s *AgentCore) getActionCancel() context.CancelCauseFunc { +func (s *agentCore) getActionCancel() context.CancelCauseFunc { if a := s.currentAction.Load(); a != nil { return a.cancel } @@ -565,7 +563,7 @@ func (s *AgentCore) getActionCancel() context.CancelCauseFunc { // 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 { +func (s *agentCore) Interrupt(cause error) bool { if cancel := s.getActionCancel(); cancel != nil { cancel(cause) return true @@ -576,19 +574,19 @@ func (s *AgentCore) Interrupt(cause error) bool { // 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) { +func (s *agentCore) Submit(ctx context.Context, input string, handler EventHandler) { if s.handleCommand(ctx, input, handler) { return } - s.hooks.Run(core.HookUserPromptSubmit) + s.hooks.Run(HookUserPromptSubmit) if s.session == nil { - s.session = NewSessionWithConfig(BuildFirstPrompt(input), ContextConfig{ + s.session = newSessionWithConfig(buildFirstPrompt(input), contextConfig{ FixedOverheadChars: s.ctxOverhead, }) } else { - s.session.AppendUserMessage(input) + s.session.appendUserMessage(input) } actCtx, actCancel := context.WithCancelCause(ctx) @@ -598,25 +596,25 @@ func (s *AgentCore) Submit(ctx context.Context, input string, handler core.Event s.loopcfg.Output = handler *s.confirmPtr = nil // auto-approve all confirmations for now - s.hooks.Run(core.HookAgentSpawn) + s.hooks.Run(HookAgentSpawn) - err := Run(actCtx, s.loopcfg, s.session) + err := run(actCtx, s.loopcfg, s.session) actCancel(nil) s.currentAction.CompareAndSwap(handle, nil) - if err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, core.ErrInterrupted) { - handler(core.Event{Role: "error", Content: err.Error()}) - s.session.Rollback() + if err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, ErrInterrupted) { + handler(Event{Role: "error", Content: err.Error()}) + s.session.rollback() } - handler(core.Event{Role: "newline"}) + handler(Event{Role: "newline"}) - s.hooks.Run(core.HookStop) + s.hooks.Run(HookStop) s.saveSession() } -func (s *AgentCore) handleCommand(ctx context.Context, input string, handler core.EventHandler) bool { +func (s *agentCore) handleCommand(ctx context.Context, input string, handler EventHandler) bool { if strings.HasPrefix(input, "!") { cmdStr := strings.TrimSpace(input[1:]) if cmdStr == "" { @@ -657,8 +655,8 @@ func (s *AgentCore) handleCommand(ctx context.Context, input string, handler cor 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 handler(infoEvent(fmt.Sprintf("switched backend to: %s (model: %s)", s.backendName, s.modelName))) return true @@ -703,7 +701,7 @@ func (s *AgentCore) handleCommand(ctx context.Context, input string, handler cor return true } name := args[0] - cfgPath := AgentConfigPath(s.agentsDir, name) + cfgPath := agentConfigPath(s.agentsDir, name) cfg, err := config.Load(cfgPath) if err != nil { handler(infoEvent(fmt.Sprintf("error: agent %q: %v", name, err))) @@ -713,18 +711,18 @@ func (s *AgentCore) handleCommand(ctx context.Context, input string, handler cor s.mcpExec.Close() } env := BuildAgentEnv(cfg, s.builtinExec) - s.mcpExec = env.McpExec + 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.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.confirmPtr = env.confirm + s.invalidateCaches = env.invalidateCaches s.agentName = name s.session = nil - s.sessionID = NewSessionID() + s.sessionID = newSessionID() for _, msg := range env.Messages { handler(infoEvent(msg)) } @@ -736,7 +734,7 @@ func (s *AgentCore) handleCommand(ctx context.Context, input string, handler cor handler(infoEvent("nothing to compact")) return true } - n, summary, err := s.session.Compact(ctx, s.loopcfg.Backend, s.loopcfg.Model) + n, summary, err := s.session.compact(ctx, s.loopcfg.Backend, s.loopcfg.Model) if err != nil { handler(infoEvent("compact error: " + err.Error())) } else if n == 0 { @@ -744,7 +742,7 @@ func (s *AgentCore) handleCommand(ctx context.Context, input string, handler cor } else { handler(infoEvent(fmt.Sprintf("compacted %d messages", n))) if summary != "" { - handler(core.Event{Role: "newline"}) + handler(Event{Role: "newline"}) handler(infoEvent(summary)) } s.saveSession() @@ -759,7 +757,7 @@ func (s *AgentCore) handleCommand(ctx context.Context, input string, handler cor handler(infoEvent("no active session")) return true } - handler(infoEvent(strings.TrimRight(s.session.ContextDebug(), "\n"))) + handler(infoEvent(strings.TrimRight(s.session.contextDebug(), "\n"))) return true case "/history": @@ -767,7 +765,7 @@ func (s *AgentCore) handleCommand(ctx context.Context, input string, handler cor handler(infoEvent("no active session")) return true } - for _, msg := range s.session.History() { + for _, msg := range s.session.history() { preview := msg.Content if len(preview) > 200 { preview = preview[:200] + "..." @@ -778,7 +776,7 @@ func (s *AgentCore) handleCommand(ctx context.Context, input string, handler cor case "/clear": s.session = nil - s.sessionID = NewSessionID() + s.sessionID = newSessionID() if s.invalidateCaches != nil { s.invalidateCaches() } @@ -883,7 +881,7 @@ func extractMCPText(raw json.RawMessage) string { return strings.Join(parts, "\n") } -func dispatchBuiltinExec(ctx context.Context, name string, e *execpkg.Executor, confirm ConfirmFn, args json.RawMessage) (string, error) { +func dispatchBuiltinExec(ctx context.Context, name string, e *execpkg.Executor, confirm confirmFn, args json.RawMessage) (string, error) { switch name { case "file_read": return dispatchFileRead(confirm, args) @@ -924,7 +922,7 @@ func execArgs(args json.RawMessage) (code, language, sandbox string, timeout int return } -func dispatchExecuteCode(ctx context.Context, e *execpkg.Executor, confirm ConfirmFn, args json.RawMessage) (string, error) { +func dispatchExecuteCode(ctx context.Context, e *execpkg.Executor, confirm confirmFn, args json.RawMessage) (string, error) { code, language, sandbox, timeout, err := execArgs(args) if err != nil { return "", fmt.Errorf("execute_code: bad args: %w", err) @@ -938,7 +936,7 @@ func dispatchExecuteCode(ctx context.Context, e *execpkg.Executor, confirm Confi return e.Execute(ctx, code, language, timeout, sandbox, false) } -func dispatchExecuteTool(ctx context.Context, e *execpkg.Executor, confirm ConfirmFn, args json.RawMessage) (string, error) { +func dispatchExecuteTool(ctx context.Context, e *execpkg.Executor, confirm confirmFn, args json.RawMessage) (string, error) { var a struct { Tool string `json:"tool"` Args []string `json:"args"` @@ -982,7 +980,7 @@ func dispatchExecuteTool(ctx context.Context, e *execpkg.Executor, confirm Confi return e.Execute(ctx, code, language, timeout, sandbox, true) } -func dispatchExecutePipe(ctx context.Context, e *execpkg.Executor, confirm ConfirmFn, args json.RawMessage) (string, error) { +func dispatchExecutePipe(ctx context.Context, e *execpkg.Executor, confirm confirmFn, args json.RawMessage) (string, error) { var a struct { Pipe []execpkg.PipeStep `json:"pipe"` Timeout int `json:"timeout"` @@ -1012,7 +1010,7 @@ func dispatchExecutePipe(ctx context.Context, e *execpkg.Executor, confirm Confi return e.Execute(ctx, code, "bash", timeout, sandbox, true) } -func dispatchFileRead(confirm ConfirmFn, args json.RawMessage) (string, error) { +func dispatchFileRead(confirm confirmFn, args json.RawMessage) (string, error) { var a struct { Path string `json:"path"` } @@ -1037,7 +1035,7 @@ func dispatchFileRead(confirm ConfirmFn, args json.RawMessage) (string, error) { return strings.TrimRight(out.String(), "\n"), nil } -func dispatchFileWrite(confirm ConfirmFn, args json.RawMessage) (string, error) { +func dispatchFileWrite(confirm confirmFn, args json.RawMessage) (string, error) { var a struct { Path string `json:"path"` Content string `json:"content"` diff --git a/pkg/core/hooks.go b/pkg/agent/hooks.go similarity index 97% rename from pkg/core/hooks.go rename to pkg/agent/hooks.go index 1398c3f..d9b1d6d 100644 --- a/pkg/core/hooks.go +++ b/pkg/agent/hooks.go @@ -1,4 +1,4 @@ -package core +package agent import "os/exec" diff --git a/internal/agent/loop.go b/pkg/agent/loop.go similarity index 74% rename from internal/agent/loop.go rename to pkg/agent/loop.go index 91ca5bb..df2a581 100644 --- a/internal/agent/loop.go +++ b/pkg/agent/loop.go @@ -9,29 +9,28 @@ import ( "time" "ollie/internal/backend" - "ollie/pkg/core" ) const maxRateLimitRetries = 3 -type ToolExecutor func(ctx context.Context, name string, args json.RawMessage) (string, error) +type toolExecutor func(ctx context.Context, name string, args json.RawMessage) (string, error) -type Config struct { +type loopConfig struct { Backend backend.Backend Model string Tools []backend.Tool - Exec ToolExecutor - Confirm ConfirmFn + Exec toolExecutor + Confirm confirmFn MaxSteps int - Output core.EventHandler - SystemPrompt string + Output EventHandler + systemPrompt string GenerationParams backend.GenerationParams } -// ConfirmFn requests user confirmation for an action. Returns true if approved. -type ConfirmFn func(prompt string) bool +// confirmFn requests user confirmation for an action. Returns true if approved. +type confirmFn func(prompt string) bool -func Run(ctx context.Context, cfg Config, state State) error { +func run(ctx context.Context, cfg loopConfig, state state) error { maxSteps := cfg.MaxSteps if maxSteps <= 0 { maxSteps = 1 @@ -41,9 +40,9 @@ func Run(ctx context.Context, cfg Config, state State) error { hitLimit := false for step := range maxSteps { - history := state.History() - if cfg.SystemPrompt != "" { - history = append([]backend.Message{{Role: "system", Content: cfg.SystemPrompt}}, history...) + history := state.history() + if cfg.systemPrompt != "" { + history = append([]backend.Message{{Role: "system", Content: cfg.systemPrompt}}, history...) } // Stream the assistant's response, retrying on HTTP 429. @@ -76,7 +75,7 @@ func Run(ctx context.Context, cfg Config, state State) error { for ev := range ch { if ev.Content != "" { content.WriteString(ev.Content) - emit(cfg, core.Event{Role: "assistant", Content: ev.Content}) + emit(cfg, Event{Role: "assistant", Content: ev.Content}) } toolCalls = append(toolCalls, ev.ToolCalls...) if ev.Done { @@ -99,11 +98,11 @@ func Run(ctx context.Context, cfg Config, state State) error { // Announce and execute tool calls. msg := backend.Message{Role: "assistant", Content: content.String(), ToolCalls: toolCalls} - var results []ToolResult + var results []toolResult for _, tc := range toolCalls { if tc.Name == "" { - results = append(results, ToolResult{ + results = append(results, toolResult{ ToolCallID: tc.ID, Name: tc.Name, Content: "error: empty tool name", @@ -111,7 +110,7 @@ func Run(ctx context.Context, cfg Config, state State) error { }) continue } - emit(cfg, core.Event{Role: "call", Name: tc.Name, Content: string(tc.Arguments)}) + emit(cfg, Event{Role: "call", Name: tc.Name, Content: string(tc.Arguments)}) var result string var isErr bool @@ -128,23 +127,23 @@ func Run(ctx context.Context, cfg Config, state State) error { isErr = true } - results = append(results, ToolResult{ + results = append(results, toolResult{ ToolCallID: tc.ID, Name: tc.Name, Content: result, IsError: isErr, }) - emit(cfg, core.Event{Role: "tool", Name: tc.Name, Content: result}) + emit(cfg, Event{Role: "tool", Name: tc.Name, Content: result}) } - if err := state.Update(msg, results); err != nil { + if err := state.update(msg, results); err != nil { return fmt.Errorf("step %d update: %w", step, err) } if len(toolCalls) == 0 { - if err := state.MarkComplete(); err != nil { + if err := state.markComplete(); err != nil { return fmt.Errorf("mark complete: %w", err) } break @@ -157,13 +156,13 @@ func Run(ctx context.Context, cfg Config, state State) error { // Surface stall: only when the step limit was hit without completing. if hitLimit { - emit(cfg, core.Event{Role: "stalled", Content: "max steps"}) + emit(cfg, Event{Role: "stalled", Content: "max steps"}) } return nil } -func emit(cfg Config, msg core.Event) { +func emit(cfg loopConfig, msg Event) { if cfg.Output != nil { cfg.Output(msg) } @@ -172,7 +171,7 @@ func emit(cfg Config, msg core.Event) { // retryCountdown emits one "retry" OutputMsg per second, counting down from // wait, so the UI can display a live countdown. Returns ctx.Err() if the // context is cancelled before the wait elapses. -func retryCountdown(ctx context.Context, cfg Config, wait time.Duration) error { +func retryCountdown(ctx context.Context, cfg loopConfig, wait time.Duration) error { deadline := time.Now().Add(wait) for { remaining := time.Until(deadline) @@ -180,7 +179,7 @@ func retryCountdown(ctx context.Context, cfg Config, wait time.Duration) error { return nil } secs := int(remaining.Seconds()) + 1 - emit(cfg, core.Event{Role: "retry", Content: fmt.Sprintf("%d", secs)}) + emit(cfg, Event{Role: "retry", Content: fmt.Sprintf("%d", secs)}) select { case <-ctx.Done(): return ctx.Err() diff --git a/internal/agent/session.go b/pkg/agent/session.go similarity index 74% rename from internal/agent/session.go rename to pkg/agent/session.go index 280c1f0..70577ec 100644 --- a/internal/agent/session.go +++ b/pkg/agent/session.go @@ -18,7 +18,7 @@ type PersistedSession struct { } // SaveTo writes the full message history to path as JSON. -func (s *Session) SaveTo(path, id, agentName string) error { +func (s *Session) saveTo(path, id, agentName string) error { ps := PersistedSession{ ID: id, Agent: agentName, @@ -32,9 +32,9 @@ func (s *Session) SaveTo(path, id, agentName string) error { } // RestoreSession reconstructs a Session from a persisted message list, -// applying cfg to the ContextBuilder. -func RestoreSession(messages []backend.Message, cfg ContextConfig) *Session { - s := &Session{ctx: NewContextBuilder(cfg)} +// applying cfg to the contextBuilder. +func RestoreSession(messages []backend.Message, ctxOverhead int) *Session { + s := &Session{ctx: newContextBuilder(contextConfig{FixedOverheadChars: ctxOverhead})} for _, m := range messages { s.ctx.Append(m) } @@ -50,26 +50,26 @@ func RestoreSession(messages []backend.Message, cfg ContextConfig) *Session { // Session is an ephemeral in-memory state backend. // It lives only for the duration of the process; nothing is persisted. // -// History() returns a bounded context window via ContextBuilder to prevent -// prompt explosion across multi-step agent loops. Use NewSessionWithConfig +// History() returns a bounded context window via contextBuilder to prevent +// prompt explosion across multi-step agent loops. Use newSessionWithConfig // to override the default limits. type Session struct { goal string - ctx *ContextBuilder + ctx *contextBuilder complete bool } -// NewSession creates a Session with default context window limits. -func NewSession(goal string) *Session { - return NewSessionWithConfig(goal, ContextConfig{}) +// newSession creates a Session with default context window limits. +func newSession(goal string) *Session { + return newSessionWithConfig(goal, contextConfig{}) } -// NewSessionWithConfig creates a Session with explicit context window limits. -// Pass a zero-value ContextConfig to use all defaults. -func NewSessionWithConfig(goal string, cfg ContextConfig) *Session { +// newSessionWithConfig creates a Session with explicit context window limits. +// Pass a zero-value contextConfig to use all defaults. +func newSessionWithConfig(goal string, cfg contextConfig) *Session { s := &Session{ goal: goal, - ctx: NewContextBuilder(cfg), + ctx: newContextBuilder(cfg), } s.ctx.Append(backend.Message{Role: "user", Content: goal}) return s @@ -79,13 +79,13 @@ func (s *Session) Goal() string { return s.goal } // History returns the bounded history window safe for passing to the backend. // A compaction notice is injected when older messages have been evicted. -func (s *Session) History() []backend.Message { +func (s *Session) history() []backend.Message { return s.ctx.BoundedHistoryWithNotice() } -func (s *Session) IsComplete() bool { return s.complete } +func (s *Session) isComplete() bool { return s.complete } -func (s *Session) Update(assistant backend.Message, results []ToolResult) error { +func (s *Session) update(assistant backend.Message, results []toolResult) error { s.ctx.Append(assistant) for _, r := range results { s.ctx.Append(backend.Message{ @@ -97,7 +97,7 @@ func (s *Session) Update(assistant backend.Message, results []ToolResult) error return nil } -func (s *Session) MarkComplete() error { +func (s *Session) markComplete() error { s.complete = true return nil } @@ -106,7 +106,7 @@ func (s *Session) MarkComplete() error { // an LLM call, replacing them with a single summary system message. // Unlike the previous eviction-based approach, this works for any session size. // Returns (n compacted, summary text, error); n==0 means nothing to compact. -func (s *Session) Compact(ctx context.Context, b backend.Backend, model string) (int, string, error) { +func (s *Session) compact(ctx context.Context, b backend.Backend, model string) (int, string, error) { older := s.ctx.OlderMessages() if len(older) == 0 { return 0, "", nil @@ -157,7 +157,7 @@ func (s *Session) Compact(ctx context.Context, b backend.Backend, model string) // Rollback removes any trailing non-user messages from history, discarding // an incomplete assistant turn caused by an interruption. -func (s *Session) Rollback() { +func (s *Session) rollback() { s.complete = false msgs := s.ctx.Messages() i := len(msgs) @@ -167,22 +167,22 @@ func (s *Session) Rollback() { s.ctx.Truncate(i) } // flag so the loop will run again on the next call to Loop.Run. -func (s *Session) AppendUserMessage(content string) { +func (s *Session) appendUserMessage(content string) { s.complete = false s.ctx.Append(backend.Message{Role: "user", Content: content}) } -// ContextStats returns stats about the current context window. -func (s *Session) ContextStats() ContextStats { +// contextStats returns stats about the current context window. +func (s *Session) contextStats() contextStats { return s.ctx.Stats() } // ContextStatsString returns a one-line human-readable context summary. -func (s *Session) ContextStatsString() string { +func (s *Session) contextStatsString() string { return s.ctx.ContextStatsString() } // ContextDebug returns a multi-line breakdown of the bounded history. -func (s *Session) ContextDebug() string { +func (s *Session) contextDebug() string { return s.ctx.FormatContextDebug() } diff --git a/pkg/core/signals.go b/pkg/agent/signals.go similarity index 98% rename from pkg/core/signals.go rename to pkg/agent/signals.go index 69aaf99..d80a7be 100644 --- a/pkg/core/signals.go +++ b/pkg/agent/signals.go @@ -1,4 +1,4 @@ -package core +package agent import ( "context" diff --git a/pkg/core/signals_unix.go b/pkg/agent/signals_unix.go similarity index 87% rename from pkg/core/signals_unix.go rename to pkg/agent/signals_unix.go index 7870403..80c5397 100644 --- a/pkg/core/signals_unix.go +++ b/pkg/agent/signals_unix.go @@ -1,6 +1,6 @@ //go:build !windows -package core +package agent import "syscall" diff --git a/pkg/core/signals_windows.go b/pkg/agent/signals_windows.go similarity index 86% rename from pkg/core/signals_windows.go rename to pkg/agent/signals_windows.go index 9a67a30..6586b8e 100644 --- a/pkg/core/signals_windows.go +++ b/pkg/agent/signals_windows.go @@ -1,6 +1,6 @@ //go:build windows -package core +package agent import "os" diff --git a/internal/agent/state.go b/pkg/agent/state.go similarity index 63% rename from internal/agent/state.go rename to pkg/agent/state.go index 13fb85b..97dc9f5 100644 --- a/internal/agent/state.go +++ b/pkg/agent/state.go @@ -2,31 +2,28 @@ package agent import "ollie/internal/backend" -// ToolResult holds the output of a single tool call. -type ToolResult struct { +// toolResult holds the output of a single tool call. +type toolResult struct { ToolCallID string // may be empty (Ollama does not always set this) Name string Content string IsError bool } -// State is the interface both ephemeral and bead-backed state must satisfy. +// state is the interface both ephemeral and bead-backed state must satisfy. // The loop reads from it on Observe, writes to it on Update. -type State interface { - // Goal returns the prompt or task description driving this session. - Goal() string - +type state interface { // History returns the full conversation history for the current session. // The first entry is always the initial user message containing the goal. - History() []backend.Message + history() []backend.Message // Update appends the assistant's reply and any tool results to the history. // Called once per loop iteration after Act completes. - Update(assistant backend.Message, results []ToolResult) error + update(assistant backend.Message, results []toolResult) error // MarkComplete records that the goal has been achieved. - MarkComplete() error + markComplete() error // IsComplete returns true once MarkComplete has been called. - IsComplete() bool + isComplete() bool } diff --git a/pkg/core/core.go b/pkg/agent/types.go similarity index 85% rename from pkg/core/core.go rename to pkg/agent/types.go index b125e71..e5328bd 100644 --- a/pkg/core/core.go +++ b/pkg/agent/types.go @@ -1,4 +1,4 @@ -package core +package agent import ( "context" @@ -8,15 +8,15 @@ import ( // 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. +// Event is a typed output event emitted during an agent turn or in response +// to a command. type Event struct { Role string Name string Content string } -// EventHandler receives events from the Core. +// EventHandler receives events from the agent. type EventHandler func(Event) // Core is the interface between a frontend (TUI, HTTP handler, etc.) and the