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

View File

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

View File

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

View File

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