Make pkg/agent internals private; move app-level utils to cmd/ollie

Everything not part of the agent<->frontend contract is now unexported:
- contextConfig/Builder/Stats, loopConfig, toolExecutor, confirmFn,
  run, state, toolResult — internal loop/session machinery
- buildFirstPrompt, systemPrompt, builtinTools — impl details
- agentCore struct (NewAgentCore returns Core)
- Session methods (saveTo, history, rollback, compact, etc.)
- AgentEnv internal fields (mcpExec, exec, confirm, tools, etc.)

App-level utilities moved from pkg/agent to cmd/ollie:
- resolveBackendName, defaultModelForBackend, newSessionID,
  agentConfigPath

RestoreSession now takes int (ctxOverhead) directly, eliminating
the need for contextConfig to be public.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Levi Neely 2026-04-09 21:31:02 +02:00
parent c8d2a7a907
commit f280ef17db
12 changed files with 285 additions and 234 deletions

View File

@ -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
}

View File

@ -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)

View File

@ -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",

View File

@ -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"`

View File

@ -1,4 +1,4 @@
package core
package agent
import "os/exec"

View File

@ -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()

View File

@ -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()
}

View File

@ -1,4 +1,4 @@
package core
package agent
import (
"context"

View File

@ -1,6 +1,6 @@
//go:build !windows
package core
package agent
import "syscall"

View File

@ -1,6 +1,6 @@
//go:build windows
package core
package agent
import "os"

View File

@ -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
}

View File

@ -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