AgentCore is a small Go library for building AI agents: a model that calls tools, turn by turn, until its task is done.
go get github.com/voocel/agentcoreRequires Go 1.26 or newer.
AgentCore sits between a model SDK and an application:
litellm models: messages, blocks, streams, errors, providers
agentcore the agent: the loop, tools, events, compaction
your app policy: which model, which tools, approval, storage, UI
- Built on litellm, not wrapping it. A message is litellm blocks with a role; streamed events are litellm events; errors are litellm errors. There is no second model layer to learn or to keep in sync.
- A stateless loop.
Runtakes a history and returns the history it ended with. The caller owns the history;Agentkeeps one for applications that want it kept. - Events are facts. Every lifecycle signal reaches one callback, in order. A streamed response arrives as deltas, then once as the final message; nothing in an event changes later.
- Messages are stored as they happen. Every message entering the history passes
Emitas aMessageEndfirst. An error fromEmitkeeps it out and stops the run, so storing there is durable. - Policy stays out. Approval, permissions, prompts and rendering belong to the application; the loop offers the hooks.
agentcore/ the loop (Run), Agent, Tool, Event, Message, Compactor
agentcore/compact/ Summarizer: replaces older history with a summary
agentcore/tools/ coding tools: read, write, edit, bash, glob, grep, ls; tool_search
agentcore/subagent/ the subagent tool: delegation to sub-agents
agentcore/task/ background tasks, and the tools that follow them
agentcore/schema/ a small JSON Schema builder for tool arguments
provider, err := deepseek.New(deepseek.Config{APIKey: os.Getenv("DEEPSEEK_API_KEY")})
if err != nil {
log.Fatal(err)
}
client, err := litellm.New(provider)
if err != nil {
log.Fatal(err)
}
workspace := tools.Workspace{Dir: ".", Files: tools.NewFileReadState()}
cfg := agentcore.Config{
Model: agentcore.Model{Client: client, Request: litellm.Request{Model: "deepseek-flash"}},
System: []litellm.Block{litellm.Text("You are a helpful coding assistant.")},
Tools: workspace.Tools(),
Emit: func(ev agentcore.Event) error {
switch ev := ev.(type) {
case agentcore.MessageDelta:
if d, ok := ev.Event.(litellm.TextDelta); ok {
fmt.Print(d.Text)
}
case agentcore.ToolStart:
fmt.Printf("\n[%s] %s\n", ev.Call.Name, ev.Call.Args)
}
return nil
},
}
history, err := agentcore.Run(ctx, cfg, nil, agentcore.UserText("What does this project do?"))Model.Request is the template of every call: the model's name and settings such as MaxTokens, Thinking and ProviderOptions. The loop sets its messages and tools. Set Model.Pricing to have each response's usage priced.
Runnable examples: examples/single and examples/multi.
Agent keeps a history and runs it, one run at a time:
agent := agentcore.NewAgent(cfg, history)
unsubscribe := agent.Subscribe(func(ev agentcore.Event) error {
switch e := ev.(type) {
case agentcore.MessageEnd:
return store.Append(e.Message) // a failure stops the run
case agentcore.CompactionEnd:
if e.Compaction != nil {
return store.Replace(e.Compaction.Messages) // the whole new history
}
}
return nil
})
defer unsubscribe()
ctx, cancel := context.WithCancel(ctx)
go agent.Prompt(ctx, agentcore.UserText("Fix the failing test"))
agent.Steer(agentcore.UserText("Use the table-driven style")) // reaches the next model call
agent.FollowUp(agentcore.UserText("Then update the docs")) // runs when it would stop
cancel() // ends the runA store that appends MessageEnd alone restores the history from before its compactions: Compaction.Messages is the whole new history, and Replaced how many messages of the old one it stands in for. NewAgent replaces Config.Emit, Steering and FollowUp with the subscribers, Steer and FollowUp.
A run ends when its context is cancelled. Prompt fails with ErrBusy while a run is under way. Continue answers the history as it stands, such as after a failed run, and fails with ErrNothingToContinue when it ends with a response. Compact compacts it on demand, Messages and SetMessages read and replace it. Every subscriber receives the RunEnd, even after one failed.
Config.Emit (or Agent.Subscribe) receives the events below. Switch on their type.
| Event | When |
|---|---|
MessageStart / MessageDelta |
a response starts / a litellm stream event of it arrives |
MessageEnd |
a message enters the history: a prompt, a response, a tool result |
ToolStart / ToolUpdate / ToolEnd |
a tool call starts, before the middleware (approval) / reports progress / ends with its result |
TurnEnd |
a response and the results of its tool calls are recorded |
Retry |
a model call failed transiently and will be made again |
CompactionStart / CompactionEnd |
the history is compacted |
RunEnd |
the run ends, with its reason, error and counts; always the last event |
Events are delivered one at a time; Emit must not block for long. Returning an error from it stops the run: the tool calls under way are cancelled and no further event is delivered but the RunEnd, which is always the last. The event refused takes no effect: a refused MessageEnd or CompactionEnd keeps the message or compaction out of the history.
Messages enter the history in the order they were taken, timed as they do; those taken from Steering, FollowUp or OnStop are recorded even when the run then ends before the model answers them. A response that fails, whether the vendor ended it with an error, the stream broke off or the run was cancelled, is recorded with what streamed of it, Stop set to StopError or StopAborted and its tool calls dropped, so a transcript shows it; it is never sent to the model again.
A tool is a struct:
type weatherArgs struct {
City string `json:"city"`
}
weather := agentcore.NewTool("weather", "Current weather of a city",
schema.Object(schema.Property("city", schema.String("City name")).Required()),
func(ctx context.Context, args weatherArgs) (agentcore.Result, error) {
return agentcore.TextResult("Sunny in " + args.City), nil
},
)- Arguments are validated against
Schemabefore a call runs; the model reads what does not fit. Checkvets a call before it is approved and may return a preview for people, such as the diffeditandwritereturn, found onToolCall.Preview.Parallellets calls run alongside the other parallel calls of their turn, up toMaxToolConcurrency.Deferredtools are offered only once a tool reference in the history names them, astool_searchreturns (seetools.Defer).- A
Resultholds litellm blocks (text, images, tool references);Result.Textis its text.Terminateends the run once the turn is recorded. - A running tool reports progress with
agentcore.ReportProgress(ctx, v); it arrives asToolUpdate.bashreports lines of output as strings,subagentreportssubagent.Progress.
Config.Middleware wraps every call that passed its checks, for approval, auditing or rewriting arguments:
approve := func(ctx context.Context, call agentcore.ToolCall, next agentcore.ToolFunc) (agentcore.Result, error) {
if call.Name == "bash" && !askUser(call) {
return agentcore.ErrorResult("The user declined this command."), nil
}
return next(ctx, call)
}A tools.Workspace makes the coding tools and holds what they share:
workspace := tools.Workspace{
Dir: ".", // relative paths resolve here; not a sandbox
FS: nil, // read/write/edit backend; nil is the local filesystem
Files: tools.NewFileReadState(), // read-before-write checks; nil checks nothing
Tasks: tasks, // bash's background commands; nil offers none
}
cfg.Tools = workspace.Tools() // or workspace.Read(), workspace.Bash(), ...| Tool | Arguments | Result |
|---|---|---|
read |
file_path, offset, limit |
numbered lines (2000 lines / 50KB at most), a directory listing, or an image |
write |
file_path, content |
a line saying what it wrote; its preview is the diff |
edit |
file_path, old_string, new_string, replace_all |
the file and the diff; exact, then whitespace- and indentation-tolerant matching |
bash |
command, timeout, workdir, description, run_in_background (with Tasks) |
the tail of the output (2000 lines / 50KB), then [exit code N], [timed out after …] or where the full output went |
glob |
pattern, path |
matching paths, newest first |
grep |
pattern, path, glob, ignore_case, literal, context_lines, limit |
path:line:text matches |
ls |
path, depth, ignore |
a tree |
Results are plain text. A failing command is not a failed bash call: its output and exit code are what the model needs. With Files, write refuses an existing file the model has not read whole, and write and edit one that changed since it was read. The working directory of a call's context (tools.WithCwd) overrides Dir, as for a git worktree entered mid-run. bash needs a POSIX shell (bash or sh) on PATH; on Windows that means Git Bash.
tools.Defer(tools) puts tools behind tool_search (query, max_results), whose description lists their names: the model sees their schemas only once it searched for them.
tasks := task.NewRuntime(dir, func(m agentcore.Message) { agent.FollowUp(m) })
cfg.Tools = append(cfg.Tools, tasks.Tools()...) // task_output (task_id, wait, timeout), task_stop (task_id)bash with run_in_background and the subagent tool's background mode run work as tasks of a task.Runtime: each has an ID, a status and an output file in dir, and runs until it ends or is stopped, with no timeout unless asked. When one ends, the Runtime hands the notify function a message announcing it, of task.KindNotification, to deliver as a follow-up. Runtime.Start runs work of your own as a task.
cfg.Compactor = compact.Summarizer{}
cfg.CompactAt = 100_000The loop compacts before a call whose history is estimated above CompactAt, and once when the provider reports a context overflow, then makes the call again; the first may fail without ending the run, the second may not. The estimate counts from the last response's reported input tokens. CompactAt should sit well above what a compaction keeps, or every call compacts again.
compact.Summarizer keeps the recent messages verbatim, a quarter of the history between 2k and 20k tokens, and replaces the rest with a checkpoint the conversation's own model writes: it extends the conversation's call for the part it replaces, which is served from the prompt cache, and asks for the checkpoint in <summary> tags; when that does not fit, or the answer has no tags, it asks a second time with a plain-text transcript. The checkpoint lists the files the replaced part read and changed, and the tools it loaded stay loaded. Implement agentcore.Compactor for another strategy.
delegate := subagent.New(tasks, // nil offers no background mode
subagent.Agent{
Name: "scout",
Description: "Fast codebase reconnaissance",
Config: func(s subagent.Spawn) (agentcore.Config, error) {
return agentcore.Config{Model: model, System: scoutPrompt, Tools: readOnlyTools()}, nil
},
},
)The model calls subagent with agent and task to run one agent; with tasks, an array of {agent, task}, to run several in parallel, 8 at a time; with chain to run them in order, {previous} in a task standing for the output of the step before; with background (given a task.Runtime) to run one as a task; model picks another model. Each run gets its own Config, so its tools keep their own state; its events go to that Config.Emit, and to the waiting call as subagent.Progress. A run that fails reports its error with what it said last. Runs nest at most subagent.MaxDepth deep.
| Field | Description |
|---|---|
Model |
The litellm client, the request template and the pricing |
System / Tools |
The system prompt blocks and the tools on offer |
Emit |
Receives the events; an error stops the run |
Steering / FollowUp |
Messages to deliver before the next call / when the run would stop |
OnStop |
Consulted when the run would stop: go on, stop, or fail |
Middleware |
Wraps every tool call, the first outermost |
MaxToolConcurrency |
Parallel calls running at once (below 2, one by one) |
MaxToolErrors |
Disables a tool failing that many turns in a row (0 never) |
Compactor / CompactAt |
Compaction, see above |
Cache |
A cache breakpoint after each call's last message |
MaxTurns |
Responses per run (0 means 100) |
MaxRetries |
Retries of a transient model failure, with backoff |
Apache License 2.0