Merge agentcore into agent; decouple pkg/core from internal/agent

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 <noreply@anthropic.com>
This commit is contained in:
Levi Neely 2026-04-09 21:13:00 +02:00
parent 992c47dd5b
commit c8d2a7a907
4 changed files with 41 additions and 52 deletions

View File

@ -8,7 +8,7 @@ import (
"os" "os"
"ollie/internal/agent" "ollie/internal/agent"
"ollie/internal/agentcore"
"ollie/internal/backend" "ollie/internal/backend"
"ollie/internal/config" "ollie/internal/config"
execpkg "ollie/internal/exec" execpkg "ollie/internal/exec"
@ -36,10 +36,10 @@ func main() {
os.Exit(1) os.Exit(1)
} }
backendName := agentcore.ResolveBackendName() backendName := agent.ResolveBackendName()
modelName := os.Getenv("OLLIE_MODEL") modelName := os.Getenv("OLLIE_MODEL")
if modelName == "" { if modelName == "" {
modelName = agentcore.DefaultModelForBackend(backendName) modelName = agent.DefaultModelForBackend(backendName)
} }
builtinExec := execpkg.New( builtinExec := execpkg.New(
@ -52,7 +52,7 @@ func main() {
agentName = "default" agentName = "default"
} }
sessionID := agentcore.NewSessionID() sessionID := agent.NewSessionID()
var resumeMessages []backend.Message var resumeMessages []backend.Message
if *sessionFlag != "" { if *sessionFlag != "" {
sessionPath := sessionsDir + "/" + *sessionFlag + ".json" sessionPath := sessionsDir + "/" + *sessionFlag + ".json"
@ -76,9 +76,9 @@ func main() {
agentName = extraArgs[0] agentName = extraArgs[0]
} }
cfgPath := agentcore.AgentConfigPath(agentsDir, agentName) cfgPath := agent.AgentConfigPath(agentsDir, agentName)
cfg, cfgErr := config.Load(cfgPath) cfg, cfgErr := config.Load(cfgPath)
env := agentcore.BuildAgentEnv(cfg, builtinExec) env := agent.BuildAgentEnv(cfg, builtinExec)
var initialSession *agent.Session var initialSession *agent.Session
if len(resumeMessages) > 0 { if len(resumeMessages) > 0 {
@ -87,7 +87,7 @@ func main() {
}) })
} }
agentCore := agentcore.NewAgentCore(agentcore.AgentCoreConfig{ agentCore := agent.NewAgentCore(agent.AgentCoreConfig{
Backend: be, Backend: be,
BackendName: backendName, BackendName: backendName,
ModelName: modelName, ModelName: modelName,

View File

@ -1,4 +1,4 @@
package agentcore package agent
import ( import (
"bytes" "bytes"
@ -16,7 +16,6 @@ import (
"crypto/rand" "crypto/rand"
"ollie/internal/agent"
"ollie/internal/backend" "ollie/internal/backend"
"ollie/internal/config" "ollie/internal/config"
execpkg "ollie/internal/exec" execpkg "ollie/internal/exec"
@ -218,8 +217,8 @@ var BuiltinTools = []backend.Tool{
type AgentEnv struct { type AgentEnv struct {
McpExec *tools.Executor McpExec *tools.Executor
Tools []backend.Tool Tools []backend.Tool
Exec agent.ToolExecutor Exec ToolExecutor
Confirm *agent.ConfirmFn Confirm *ConfirmFn
Hooks core.Hooks Hooks core.Hooks
SystemPrompt string SystemPrompt string
GenParams backend.GenerationParams GenParams backend.GenerationParams
@ -298,7 +297,7 @@ func BuildAgentEnv(cfg *config.Config, builtinExec *execpkg.Executor) AgentEnv {
builtinNames[t.Name] = struct{}{} builtinNames[t.Name] = struct{}{}
} }
var confirmFn agent.ConfirmFn var confirmFn ConfirmFn
confirmPtr := &confirmFn confirmPtr := &confirmFn
fileReadCache := make(map[string]bool) fileReadCache := make(map[string]bool)
@ -313,7 +312,7 @@ func BuildAgentEnv(cfg *config.Config, builtinExec *execpkg.Executor) AgentEnv {
return extractMCPText(raw), nil return extractMCPText(raw), nil
} }
if _, ok := builtinNames[name]; ok { if _, ok := builtinNames[name]; ok {
var cfn agent.ConfirmFn var cfn ConfirmFn
if _, trusted := trustedTools[name]; !trusted { if _, trusted := trustedTools[name]; !trusted {
cfn = *confirmPtr cfn = *confirmPtr
} }
@ -484,7 +483,7 @@ type AgentCoreConfig struct {
AgentsDir string AgentsDir string
SessionsDir string SessionsDir string
SessionID string SessionID string
Session *agent.Session Session *Session
Env AgentEnv Env AgentEnv
BuiltinExec *execpkg.Executor BuiltinExec *execpkg.Executor
} }
@ -492,8 +491,8 @@ type AgentCoreConfig struct {
// 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. // but has no knowledge of how output is rendered.
type AgentCore struct { type AgentCore struct {
session *agent.Session session *Session
loopcfg agent.Config loopcfg Config
hooks core.Hooks hooks core.Hooks
modelName string modelName string
backendName string backendName string
@ -503,7 +502,7 @@ type AgentCore struct {
sessionID string sessionID string
mcpExec *tools.Executor mcpExec *tools.Executor
builtinExec *execpkg.Executor builtinExec *execpkg.Executor
confirmPtr *agent.ConfirmFn confirmPtr *ConfirmFn
ctxOverhead int ctxOverhead int
invalidateCaches func() invalidateCaches func()
currentAction atomic.Pointer[actionHandle] 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. // NewAgentCore creates an AgentCore from the given configuration.
func NewAgentCore(cfg AgentCoreConfig) *AgentCore { func NewAgentCore(cfg AgentCoreConfig) *AgentCore {
loopcfg := agent.Config{ loopcfg := Config{
Backend: cfg.Backend, Backend: cfg.Backend,
Model: cfg.ModelName, Model: cfg.ModelName,
SystemPrompt: cfg.Env.SystemPrompt, 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) s.hooks.Run(core.HookUserPromptSubmit)
if s.session == nil { if s.session == nil {
s.session = agent.NewSessionWithConfig(BuildFirstPrompt(input), agent.ContextConfig{ s.session = NewSessionWithConfig(BuildFirstPrompt(input), ContextConfig{
FixedOverheadChars: s.ctxOverhead, FixedOverheadChars: s.ctxOverhead,
}) })
} else { } else {
@ -601,7 +600,7 @@ func (s *AgentCore) Submit(ctx context.Context, input string, handler core.Event
s.hooks.Run(core.HookAgentSpawn) s.hooks.Run(core.HookAgentSpawn)
err := agent.Run(actCtx, s.loopcfg, s.session) err := Run(actCtx, s.loopcfg, s.session)
actCancel(nil) actCancel(nil)
s.currentAction.CompareAndSwap(handle, nil) s.currentAction.CompareAndSwap(handle, nil)
@ -805,7 +804,7 @@ func (s *AgentCore) handleCommand(ctx context.Context, input string, handler cor
} }
label := id label := id
if data, readErr := os.ReadFile(s.sessionsDir + "/" + e.Name()); readErr == nil { if data, readErr := os.ReadFile(s.sessionsDir + "/" + e.Name()); readErr == nil {
var ps agent.PersistedSession var ps PersistedSession
if json.Unmarshal(data, &ps) == nil { if json.Unmarshal(data, &ps) == nil {
goal := "" goal := ""
for _, msg := range ps.Messages { for _, msg := range ps.Messages {
@ -884,7 +883,7 @@ func extractMCPText(raw json.RawMessage) string {
return strings.Join(parts, "\n") 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 { switch name {
case "file_read": case "file_read":
return dispatchFileRead(confirm, args) return dispatchFileRead(confirm, args)
@ -925,7 +924,7 @@ func execArgs(args json.RawMessage) (code, language, sandbox string, timeout int
return 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) code, language, sandbox, timeout, err := execArgs(args)
if err != nil { if err != nil {
return "", fmt.Errorf("execute_code: bad args: %w", err) 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) 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 { var a struct {
Tool string `json:"tool"` Tool string `json:"tool"`
Args []string `json:"args"` 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) 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 { var a struct {
Pipe []execpkg.PipeStep `json:"pipe"` Pipe []execpkg.PipeStep `json:"pipe"`
Timeout int `json:"timeout"` 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) 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 { var a struct {
Path string `json:"path"` 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 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 { var a struct {
Path string `json:"path"` Path string `json:"path"`
Content string `json:"content"` Content string `json:"content"`

View File

@ -9,19 +9,12 @@ import (
"time" "time"
"ollie/internal/backend" "ollie/internal/backend"
"ollie/pkg/core"
) )
const maxRateLimitRetries = 3 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 OutputFn func(msg OutputMsg)
type OutputMsg struct {
Role string
Name string
Content string
Usage backend.Usage
}
type Config struct { type Config struct {
Backend backend.Backend Backend backend.Backend
@ -30,7 +23,7 @@ type Config struct {
Exec ToolExecutor Exec ToolExecutor
Confirm ConfirmFn Confirm ConfirmFn
MaxSteps int MaxSteps int
Output OutputFn Output core.EventHandler
SystemPrompt string SystemPrompt string
GenerationParams backend.GenerationParams GenerationParams backend.GenerationParams
} }
@ -77,18 +70,16 @@ func Run(ctx context.Context, cfg Config, state State) error {
var content strings.Builder var content strings.Builder
var toolCalls []backend.ToolCall var toolCalls []backend.ToolCall
var usage backend.Usage
var stopReason string var stopReason string
var done bool var done bool
for ev := range ch { for ev := range ch {
if ev.Content != "" { if ev.Content != "" {
content.WriteString(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...) toolCalls = append(toolCalls, ev.ToolCalls...)
if ev.Done { if ev.Done {
usage = ev.Usage
stopReason = ev.StopReason stopReason = ev.StopReason
done = true done = true
break break
@ -120,7 +111,7 @@ func Run(ctx context.Context, cfg Config, state State) error {
}) })
continue 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 result string
var isErr bool var isErr bool
@ -143,13 +134,10 @@ func Run(ctx context.Context, cfg Config, state State) error {
Content: result, Content: result,
IsError: isErr, 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 { if err := state.Update(msg, results); err != nil {
return fmt.Errorf("step %d update: %w", step, err) 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. // Surface stall: only when the step limit was hit without completing.
if hitLimit { if hitLimit {
emit(cfg, OutputMsg{Role: "stalled", Content: "max steps"}) emit(cfg, core.Event{Role: "stalled", Content: "max steps"})
} }
return nil return nil
} }
func emit(cfg Config, msg OutputMsg) { func emit(cfg Config, msg core.Event) {
if cfg.Output != nil { if cfg.Output != nil {
cfg.Output(msg) cfg.Output(msg)
} }
@ -192,7 +180,7 @@ func retryCountdown(ctx context.Context, cfg Config, wait time.Duration) error {
return nil return nil
} }
secs := int(remaining.Seconds()) + 1 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 { select {
case <-ctx.Done(): case <-ctx.Done():
return ctx.Err() return ctx.Err()

View File

@ -3,8 +3,6 @@ package core
import ( import (
"context" "context"
"errors" "errors"
"ollie/internal/agent"
) )
// ErrInterrupted is returned when the user cancels an agent turn (Ctrl-C). // 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 // Event is a typed output event emitted by the Core during an agent turn
// or in response to a command. // 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. // 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 // Core is the interface between a frontend (TUI, HTTP handler, etc.) and the
// agent engine. All output from the agent is delivered via EventHandler. // agent engine. All output from the agent is delivered via EventHandler.