From c8d2a7a907249b1d818fe32aa818bfd50ada2b1b Mon Sep 17 00:00:00 2001 From: Levi Neely Date: Thu, 9 Apr 2026 21:13:00 +0200 Subject: [PATCH] Merge agentcore into agent; decouple pkg/core from internal/agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AgentCore (the Core interface impl) belongs in internal/agent alongside the loop, session, and state it orchestrates — not in a separate one-file package. To break the resulting import cycle (agent → pkg/core → agent), define Event and EventHandler natively in pkg/core instead of aliasing from internal/agent. The agent loop now imports pkg/core for these types. Usage token tracking is internal to the loop and no longer emitted as an event (no consumer used it). Co-Authored-By: Claude Sonnet 4.6 --- cmd/ollie/main.go | 14 +++---- .../{agentcore/agentcore.go => agent/core.go} | 39 +++++++++---------- internal/agent/loop.go | 30 +++++--------- pkg/core/core.go | 10 +++-- 4 files changed, 41 insertions(+), 52 deletions(-) rename internal/{agentcore/agentcore.go => agent/core.go} (97%) diff --git a/cmd/ollie/main.go b/cmd/ollie/main.go index 7e39bf0..dc538bf 100644 --- a/cmd/ollie/main.go +++ b/cmd/ollie/main.go @@ -8,7 +8,7 @@ import ( "os" "ollie/internal/agent" - "ollie/internal/agentcore" + "ollie/internal/backend" "ollie/internal/config" execpkg "ollie/internal/exec" @@ -36,10 +36,10 @@ func main() { os.Exit(1) } - backendName := agentcore.ResolveBackendName() + backendName := agent.ResolveBackendName() modelName := os.Getenv("OLLIE_MODEL") if modelName == "" { - modelName = agentcore.DefaultModelForBackend(backendName) + modelName = agent.DefaultModelForBackend(backendName) } builtinExec := execpkg.New( @@ -52,7 +52,7 @@ func main() { agentName = "default" } - sessionID := agentcore.NewSessionID() + sessionID := agent.NewSessionID() var resumeMessages []backend.Message if *sessionFlag != "" { sessionPath := sessionsDir + "/" + *sessionFlag + ".json" @@ -76,9 +76,9 @@ func main() { agentName = extraArgs[0] } - cfgPath := agentcore.AgentConfigPath(agentsDir, agentName) + cfgPath := agent.AgentConfigPath(agentsDir, agentName) cfg, cfgErr := config.Load(cfgPath) - env := agentcore.BuildAgentEnv(cfg, builtinExec) + env := agent.BuildAgentEnv(cfg, builtinExec) var initialSession *agent.Session if len(resumeMessages) > 0 { @@ -87,7 +87,7 @@ func main() { }) } - agentCore := agentcore.NewAgentCore(agentcore.AgentCoreConfig{ + agentCore := agent.NewAgentCore(agent.AgentCoreConfig{ Backend: be, BackendName: backendName, ModelName: modelName, diff --git a/internal/agentcore/agentcore.go b/internal/agent/core.go similarity index 97% rename from internal/agentcore/agentcore.go rename to internal/agent/core.go index 98ef04b..4aaa510 100644 --- a/internal/agentcore/agentcore.go +++ b/internal/agent/core.go @@ -1,4 +1,4 @@ -package agentcore +package agent import ( "bytes" @@ -16,7 +16,6 @@ import ( "crypto/rand" - "ollie/internal/agent" "ollie/internal/backend" "ollie/internal/config" execpkg "ollie/internal/exec" @@ -218,8 +217,8 @@ var BuiltinTools = []backend.Tool{ type AgentEnv struct { McpExec *tools.Executor Tools []backend.Tool - Exec agent.ToolExecutor - Confirm *agent.ConfirmFn + Exec ToolExecutor + Confirm *ConfirmFn Hooks core.Hooks SystemPrompt string GenParams backend.GenerationParams @@ -298,7 +297,7 @@ func BuildAgentEnv(cfg *config.Config, builtinExec *execpkg.Executor) AgentEnv { builtinNames[t.Name] = struct{}{} } - var confirmFn agent.ConfirmFn + var confirmFn ConfirmFn confirmPtr := &confirmFn fileReadCache := make(map[string]bool) @@ -313,7 +312,7 @@ func BuildAgentEnv(cfg *config.Config, builtinExec *execpkg.Executor) AgentEnv { return extractMCPText(raw), nil } if _, ok := builtinNames[name]; ok { - var cfn agent.ConfirmFn + var cfn ConfirmFn if _, trusted := trustedTools[name]; !trusted { cfn = *confirmPtr } @@ -484,7 +483,7 @@ type AgentCoreConfig struct { AgentsDir string SessionsDir string SessionID string - Session *agent.Session + Session *Session Env AgentEnv BuiltinExec *execpkg.Executor } @@ -492,8 +491,8 @@ type AgentCoreConfig struct { // 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 + session *Session + loopcfg Config hooks core.Hooks modelName string backendName string @@ -503,7 +502,7 @@ type AgentCore struct { sessionID string mcpExec *tools.Executor builtinExec *execpkg.Executor - confirmPtr *agent.ConfirmFn + confirmPtr *ConfirmFn ctxOverhead int invalidateCaches func() currentAction atomic.Pointer[actionHandle] @@ -513,7 +512,7 @@ var _ core.Core = (*AgentCore)(nil) // compile-time interface check // NewAgentCore creates an AgentCore from the given configuration. func NewAgentCore(cfg AgentCoreConfig) *AgentCore { - loopcfg := agent.Config{ + loopcfg := Config{ Backend: cfg.Backend, Model: cfg.ModelName, SystemPrompt: cfg.Env.SystemPrompt, @@ -585,7 +584,7 @@ func (s *AgentCore) Submit(ctx context.Context, input string, handler core.Event s.hooks.Run(core.HookUserPromptSubmit) if s.session == nil { - s.session = agent.NewSessionWithConfig(BuildFirstPrompt(input), agent.ContextConfig{ + s.session = NewSessionWithConfig(BuildFirstPrompt(input), ContextConfig{ FixedOverheadChars: s.ctxOverhead, }) } else { @@ -601,7 +600,7 @@ func (s *AgentCore) Submit(ctx context.Context, input string, handler core.Event s.hooks.Run(core.HookAgentSpawn) - err := agent.Run(actCtx, s.loopcfg, s.session) + err := Run(actCtx, s.loopcfg, s.session) actCancel(nil) s.currentAction.CompareAndSwap(handle, nil) @@ -805,7 +804,7 @@ func (s *AgentCore) handleCommand(ctx context.Context, input string, handler cor } label := id if data, readErr := os.ReadFile(s.sessionsDir + "/" + e.Name()); readErr == nil { - var ps agent.PersistedSession + var ps PersistedSession if json.Unmarshal(data, &ps) == nil { goal := "" for _, msg := range ps.Messages { @@ -884,7 +883,7 @@ func extractMCPText(raw json.RawMessage) string { return strings.Join(parts, "\n") } -func dispatchBuiltinExec(ctx context.Context, name string, e *execpkg.Executor, confirm agent.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) @@ -925,7 +924,7 @@ func execArgs(args json.RawMessage) (code, language, sandbox string, timeout int return } -func dispatchExecuteCode(ctx context.Context, e *execpkg.Executor, confirm agent.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) @@ -939,7 +938,7 @@ func dispatchExecuteCode(ctx context.Context, e *execpkg.Executor, confirm agent return e.Execute(ctx, code, language, timeout, sandbox, false) } -func dispatchExecuteTool(ctx context.Context, e *execpkg.Executor, confirm agent.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"` @@ -983,7 +982,7 @@ func dispatchExecuteTool(ctx context.Context, e *execpkg.Executor, confirm agent return e.Execute(ctx, code, language, timeout, sandbox, true) } -func dispatchExecutePipe(ctx context.Context, e *execpkg.Executor, confirm agent.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"` @@ -1013,7 +1012,7 @@ func dispatchExecutePipe(ctx context.Context, e *execpkg.Executor, confirm agent return e.Execute(ctx, code, "bash", timeout, sandbox, true) } -func dispatchFileRead(confirm agent.ConfirmFn, args json.RawMessage) (string, error) { +func dispatchFileRead(confirm ConfirmFn, args json.RawMessage) (string, error) { var a struct { Path string `json:"path"` } @@ -1038,7 +1037,7 @@ func dispatchFileRead(confirm agent.ConfirmFn, args json.RawMessage) (string, er return strings.TrimRight(out.String(), "\n"), nil } -func dispatchFileWrite(confirm agent.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/internal/agent/loop.go b/internal/agent/loop.go index 48424ed..91ca5bb 100644 --- a/internal/agent/loop.go +++ b/internal/agent/loop.go @@ -9,19 +9,12 @@ 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 OutputFn func(msg OutputMsg) - -type OutputMsg struct { - Role string - Name string - Content string - Usage backend.Usage -} type Config struct { Backend backend.Backend @@ -30,7 +23,7 @@ type Config struct { Exec ToolExecutor Confirm ConfirmFn MaxSteps int - Output OutputFn + Output core.EventHandler SystemPrompt string GenerationParams backend.GenerationParams } @@ -77,18 +70,16 @@ func Run(ctx context.Context, cfg Config, state State) error { var content strings.Builder var toolCalls []backend.ToolCall - var usage backend.Usage var stopReason string var done bool for ev := range ch { if ev.Content != "" { content.WriteString(ev.Content) - emit(cfg, OutputMsg{Role: "assistant", Content: ev.Content}) + emit(cfg, core.Event{Role: "assistant", Content: ev.Content}) } toolCalls = append(toolCalls, ev.ToolCalls...) if ev.Done { - usage = ev.Usage stopReason = ev.StopReason done = true break @@ -120,7 +111,7 @@ func Run(ctx context.Context, cfg Config, state State) error { }) continue } - emit(cfg, OutputMsg{Role: "call", Name: tc.Name, Content: string(tc.Arguments)}) + emit(cfg, core.Event{Role: "call", Name: tc.Name, Content: string(tc.Arguments)}) var result string var isErr bool @@ -143,13 +134,10 @@ func Run(ctx context.Context, cfg Config, state State) error { Content: result, IsError: isErr, }) - emit(cfg, OutputMsg{Role: "tool", Name: tc.Name, Content: result}) + emit(cfg, core.Event{Role: "tool", Name: tc.Name, Content: result}) } - // Emit usage when we have real token counts. - if usage.InputTokens > 0 || usage.OutputTokens > 0 { - emit(cfg, OutputMsg{Role: "usage", Usage: usage}) - } + if err := state.Update(msg, results); err != nil { return fmt.Errorf("step %d update: %w", step, err) @@ -169,13 +157,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, OutputMsg{Role: "stalled", Content: "max steps"}) + emit(cfg, core.Event{Role: "stalled", Content: "max steps"}) } return nil } -func emit(cfg Config, msg OutputMsg) { +func emit(cfg Config, msg core.Event) { if cfg.Output != nil { cfg.Output(msg) } @@ -192,7 +180,7 @@ func retryCountdown(ctx context.Context, cfg Config, wait time.Duration) error { return nil } secs := int(remaining.Seconds()) + 1 - emit(cfg, OutputMsg{Role: "retry", Content: fmt.Sprintf("%d", secs)}) + emit(cfg, core.Event{Role: "retry", Content: fmt.Sprintf("%d", secs)}) select { case <-ctx.Done(): return ctx.Err() diff --git a/pkg/core/core.go b/pkg/core/core.go index 92d4c40..b125e71 100644 --- a/pkg/core/core.go +++ b/pkg/core/core.go @@ -3,8 +3,6 @@ package core import ( "context" "errors" - - "ollie/internal/agent" ) // ErrInterrupted is returned when the user cancels an agent turn (Ctrl-C). @@ -12,10 +10,14 @@ 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 +type Event struct { + Role string + Name string + Content string +} // EventHandler receives events from the Core. -type EventHandler = agent.OutputFn +type EventHandler func(Event) // Core is the interface between a frontend (TUI, HTTP handler, etc.) and the // agent engine. All output from the agent is delivered via EventHandler.