Documentation
¶
Overview ¶
Package agent provides the session-based streaming agent loop that ties llm, tools, History, and Locker together.
A Session runs the model in a loop: each turn streams one reply, runs the tool calls it asks for and feeds the results back, until the model replies without tool calls or MaximumTurns is reached. Run streams the run as Events on a channel; Drain turns such a channel into messages. RegisterVFSTools adds file tools over a vfs.Storage, RegisterPlanTool a todo list.
Every run is bounded by default: MaximumTurns, RunTimeout, LLMCallTimeout, ToolCallTimeout and MaximumToolResultBytes. A failing, panicking or timed-out tool is reported to the model and the run continues. Model requests carry prompt-cache breakpoints unless DisablePromptCaching is set.
The library never sees a scope parameter. Multi-tenant callers bind scope at VFS-tool registration time via vfs.ScopedStorage.Scope (ADR-013).
Concurrency is bounded at the session granularity by a pluggable Locker (ADR-024). The package ships LocalLocker for in-process use; callers implement Locker themselves for any other backend (e.g. Redis).
Index ¶
- func Drain(events <-chan Event) ([]llm.Message, error)
- func RegisterPlanTool(registry *tools.Registry) error
- func RegisterVFSTools(registry *tools.Registry, options *VFSToolOptions) error
- type EndReason
- type Event
- type EventKind
- type History
- type LocalLocker
- type Locker
- type RunOptions
- type Session
- type SessionOptions
- type ToolResult
- type VFSToolOptions
Examples ¶
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func Drain ¶
Drain consumes events from the channel until it closes and returns the assembled assistant message followed by tool-result messages, in the order they were produced this turn. Returns the first error encountered on EventError.
Drain is the recommended way for RunWithMessages callers to obtain the turn's output messages for appending to their own history store. Callers who also need streaming (e.g., to a UI) fan out the channel themselves before passing one branch to Drain.
func RegisterPlanTool ¶
RegisterPlanTool registers todo_write, a planning tool the model uses to keep a short task list for multi-step work. Each call sends the whole list and replaces the previous one; the tool validates it and returns it as {"todos": [...]}. The list lives only in the conversation, so it needs no storage and History keeps it; callers show progress from EventToolResult.
It returns an error for a nil registry and an error matching tools.ErrDuplicateTool if todo_write is already registered.
Example ¶
package main
import (
"context"
"encoding/json"
"fmt"
"github.com/govengers/aikido/agent"
"github.com/govengers/aikido/llm"
"github.com/govengers/aikido/tools"
)
func main() {
registry := tools.NewRegistry()
if err := agent.RegisterPlanTool(registry); err != nil {
fmt.Println(err)
return
}
result, err := registry.Dispatch(context.Background(), llm.ToolCall{
ID: "call-1",
Name: "todo_write",
Arguments: `{"todos":[{"content":"Read the spec","status":"completed"},{"content":"Write the summary","status":"in_progress"}]}`,
}, tools.Environment{SessionID: "conversation-3"})
if err != nil {
fmt.Println(err)
return
}
encoded, err := json.Marshal(result.Content)
if err != nil {
fmt.Println(err)
return
}
fmt.Println(string(encoded))
}
Output: {"todos":[{"content":"Read the spec","status":"completed"},{"content":"Write the summary","status":"in_progress"}]}
func RegisterVFSTools ¶
func RegisterVFSTools(registry *tools.Registry, options *VFSToolOptions) error
RegisterVFSTools registers read_file, list_files, grep and glob into the supplied registry, plus write_file, delete_file, edit_file, multi_edit and move_file when not read-only (grep, glob, edit_file, multi_edit and move_file are skipped with DisableExtended). If options.Storage satisfies vfs.Searchable, it also registers search. Tool handlers capture a copy of options (ADR-021), so changing options or its AllowedExtensions afterwards does not affect them.
It returns an error for a nil registry or Storage, and an error matching tools.ErrDuplicateTool if the registry already holds one of the names.
Example ¶
package main
import (
"fmt"
"github.com/govengers/aikido/agent"
"github.com/govengers/aikido/tools"
vfsmemory "github.com/govengers/aikido/vfs/memory"
)
func main() {
registry := tools.NewRegistry()
err := agent.RegisterVFSTools(registry, &agent.VFSToolOptions{
Storage: vfsmemory.New(),
ReadOnly: true,
})
if err != nil {
fmt.Println(err)
return
}
for _, definition := range registry.Definitions() {
fmt.Println(definition.Name)
}
}
Output: read_file list_files grep glob search
Types ¶
type EndReason ¶
type EndReason string
EndReason says why a run ended; EventEnd carries it. The string values are stable and safe to persist or send over the wire.
const ( // EndReasonStop means the model replied without tool calls. EndReasonStop EndReason = "stop" // EndReasonMaximumTurns means SessionOptions.MaximumTurns model calls ran // without a final reply. History is still updated. EndReasonMaximumTurns EndReason = "max_turns" // EndReasonError means the run failed; EventError carries the cause. EndReasonError EndReason = "error" // EndReasonTimeout means the caller's deadline, RunTimeout or // LLMCallTimeout expired. EndReasonTimeout EndReason = "timeout" // EndReasonCancelled means the caller cancelled the run's context. EndReasonCancelled EndReason = "cancelled" )
EndReason values emitted on EventEnd.
type Event ¶
type Event struct {
Kind EventKind
Text string
ToolCall *llm.ToolCall
ToolResult *ToolResult
Image *llm.ImagePart
Usage *llm.Usage
Err error
EndReason EndReason
}
Event is one streaming event from a session run. Kind says which of the other fields is set. The caller owns everything an Event points to, image bytes included: changing it does not affect the run, its next requests or History.
type EventKind ¶
type EventKind string
EventKind identifies the kind of an agent.Event. String-valued so kinds can be added in any order without renumbering callers.
const ( // EventText carries a streamed text delta of the assistant reply in Text. EventText EventKind = "text" // EventThinking carries streamed model reasoning in Text. It is not part // of the persisted conversation. EventThinking EventKind = "thinking" // EventToolCall carries a tool call of the model in ToolCall. Every call // of one model reply is emitted, in the reply's order, before any of // them runs; their EventToolResults follow in the same order. An // EventToolCall after an EventToolResult therefore starts a new reply. EventToolCall EventKind = "tool_call" // EventToolResult carries the outcome of one EventToolCall in // ToolResult; ToolResult.CallID names the call. It is emitted once the // tool ran. EventToolResult EventKind = "tool_result" // EventImage carries an image of the assistant reply in Image. EventImage EventKind = "image" // EventUsage carries the token usage of one model call in Usage. A call // that fails after the provider reported usage still gets one, before // its EventError. EventUsage EventKind = "usage" // EventError carries the error that ends the run in Err; EventEnd follows. EventError EventKind = "error" // EventEnd is the last event of a run and carries EndReason. EventEnd EventKind = "end" )
EventKind values. Each names the Event field that carries its payload.
type History ¶
type History interface {
Append(ctx context.Context, sessionID string, messages ...llm.Message) error
Read(ctx context.Context, sessionID string) ([]llm.Message, error)
}
History is the pluggable conversation-history interface (ADR-014).
SessionID is opaque to aikido; implementations use it as a key into whatever store the caller has. v1 ships an in-memory backend under agent/history/memory; agent/history/historytest is the conformance suite a backend runs in its tests.
Append is variadic so the agent can flush a turn's worth of messages (user, assistant, tool results) in a single backend round-trip; appending no messages is a no-op. Read returns the messages in append order, none (an empty or nil slice) for an unknown session; the caller may modify the returned slice. Implementations must be safe for concurrent use.
type LocalLocker ¶
type LocalLocker struct {
// contains filtered or unexported fields
}
LocalLocker is the in-process implementation of Locker. Suitable for single-replica deployments and tests. Memory grows with the number of distinct session IDs seen; call Forget(id) to drop a key once the session is finished. It is safe for concurrent use.
func NewLocalLocker ¶
func NewLocalLocker() *LocalLocker
NewLocalLocker returns a fresh in-process Locker.
func (*LocalLocker) Forget ¶
func (l *LocalLocker) Forget(sessionID string)
Forget drops the lock state for sessionID. Call it only after every Run of the session has completed; a Lock after Forget starts from a fresh, unheld lock.
type Locker ¶
Locker provides mutual exclusion keyed by session ID.
The Session acquires a lock on its ID before each Run begins (covering History.Read at turn start) and releases it after EventEnd. Lock returns an error if ctx ends before the lock is acquired. The unlock function returned by Lock must be called exactly once; the agent always calls it via defer. A panic in unlock is recovered and dropped, since it happens after EventEnd; an implementation that needs to report unlock failures logs them itself. Implementations must be safe for concurrent use.
type RunOptions ¶
type RunOptions struct {
// SystemSuffix is appended to SessionOptions.SystemPrompt for this run
// only, e.g. a reply-style instruction for a spoken answer. It is never
// written to History.
SystemSuffix string
// Thinking replaces SessionOptions.Thinking for this run's model calls.
// Nil keeps the session's setting.
Thinking *llm.ThinkingConfiguration
}
RunOptions tune one Run without changing the Session.
type Session ¶
type Session struct {
// contains filtered or unexported fields
}
Session bundles a session ID, a model + system prompt, a tool registry, a History plug-in, and a Locker plug-in. Multiple Run calls share the session's history, serialized by the Locker. A Session is safe for concurrent use.
func NewLocalSession ¶
func NewLocalSession(options *SessionOptions) (*Session, error)
NewLocalSession is a convenience constructor for single-replica deployments and tests. It auto-supplies in-memory implementations for History and Locker if the caller leaves them unset, then validates like NewSession.
For multi-replica deployments, or any setup needing a custom Locker (e.g., Redis) or a persistent History (e.g., Postgres), use NewSession directly and supply your own plug-ins.
History defaults to an in-memory store; conversation is lost when the process restarts.
func NewSession ¶
func NewSession(options *SessionOptions) (*Session, error)
NewSession constructs a Session. It returns an error if options is nil, a required field (ID, Client, Model, History, Locker) is empty, or a timeout is negative.
Example ¶
A production session: History and Locker are shared by every Session of the process (or replaced by persistent, cross-replica backends), the limits are explicit and prompt caching stays on.
package main
import (
"context"
"fmt"
"time"
"github.com/govengers/aikido/agent"
historymemory "github.com/govengers/aikido/agent/history/memory"
"github.com/govengers/aikido/llm"
"github.com/govengers/aikido/llm/llmtest"
)
func main() {
history := historymemory.New() // a Postgres-backed agent.History in production
locker := agent.NewLocalLocker() // a Redis-backed agent.Locker with several replicas
client := llmtest.NewStubClient(llmtest.TurnScript{Events: []llm.Event{
{Kind: llm.EventTextDelta, Text: "Done."},
{Kind: llm.EventEnd},
}})
session, err := agent.NewSession(&agent.SessionOptions{
ID: "tenant-7/conversation-1",
Client: client,
History: history,
Locker: locker,
Model: "anthropic/claude-sonnet-4.6",
SystemPrompt: "You keep the project notes.",
MaximumTurns: 10,
RunTimeout: 5 * time.Minute,
LLMCallTimeout: 2 * time.Minute,
ToolCallTimeout: 30 * time.Second,
MaximumToolResultBytes: 32 << 10,
Thinking: llm.ThinkingByEffort(llm.ThinkingEffortLow),
})
if err != nil {
fmt.Println(err)
return
}
events, _ := session.Run(context.Background(), "Note that the release is on Friday.")
for event := range events {
if event.Kind == agent.EventEnd {
fmt.Println("end:", event.EndReason)
}
}
stored, err := history.Read(context.Background(), "tenant-7/conversation-1")
if err != nil {
fmt.Println(err)
return
}
fmt.Println(len(stored), "messages in History")
}
Output: end: stop 2 messages in History
func (*Session) Run ¶
Run executes one agent turn from a single user message.
The user message and the assistant's response (including any tool calls and tool results produced this turn) are accumulated for one variadic History.Append at end of turn. A reply without text, images or tool calls is not accumulated. The returned channel always ends with EventEnd and is then closed; the returned error is always nil.
On EndReasonError (including History I/O failure, Lock-acquire timeout or a panic inside the run) the History is NOT updated for this turn: the caller knows to retry, and the durable transcript stays consistent.
The caller should read the channel until it closes. A caller that cancels ctx may stop reading: events that no longer fit the channel's buffer are then dropped, and the run still releases the session lock and closes the channel.
Example ¶
package main
import (
"context"
"fmt"
"strings"
"github.com/govengers/aikido/agent"
"github.com/govengers/aikido/llm"
"github.com/govengers/aikido/llm/llmtest"
)
func main() {
client := llmtest.NewStubClient(llmtest.TurnScript{Events: []llm.Event{
{Kind: llm.EventTextDelta, Text: "Hello, "},
{Kind: llm.EventTextDelta, Text: "world."},
{Kind: llm.EventEnd},
}})
session, err := agent.NewLocalSession(&agent.SessionOptions{
ID: "conversation-1",
Client: client,
Model: "openai/gpt-5.4",
})
if err != nil {
fmt.Println(err)
return
}
events, _ := session.Run(context.Background(), "Say hello.")
var reply strings.Builder
for event := range events {
switch event.Kind {
case agent.EventText:
reply.WriteString(event.Text)
case agent.EventEnd:
fmt.Printf("%s (%s)\n", reply.String(), event.EndReason)
default:
}
}
}
Output: Hello, world. (stop)
func (*Session) RunWithMessages ¶
RunWithMessages is the lower-level escape hatch for callers that maintain their own history shape (trimming, branched conversations, agent-as-subroutine).
The history slice is the conversation so far, excluding the system prompt (which Run prepends from SessionOptions.SystemPrompt). RunWithMessages does not append to History; the caller owns the message log here and uses Drain to assemble the produced messages. The channel follows the same contract as Run's; the returned error is always nil.
Example ¶
package main
import (
"context"
"encoding/json"
"fmt"
"github.com/govengers/aikido/agent"
"github.com/govengers/aikido/llm"
"github.com/govengers/aikido/llm/llmtest"
"github.com/govengers/aikido/tools"
)
func main() {
registry := tools.NewRegistry()
err := registry.Register(llm.ToolDefinition{Name: "clock"}, func(context.Context, json.RawMessage, tools.Environment) (tools.Result, error) {
return tools.Result{Content: "12:00"}, nil
})
if err != nil {
fmt.Println(err)
return
}
client := llmtest.NewStubClient(
llmtest.TurnScript{Events: []llm.Event{
{Kind: llm.EventToolCall, Tool: &llm.ToolCall{ID: "call-1", Name: "clock", Arguments: "{}"}},
{Kind: llm.EventEnd},
}},
llmtest.TurnScript{Events: []llm.Event{
{Kind: llm.EventTextDelta, Text: "It is noon."},
{Kind: llm.EventEnd},
}},
)
session, err := agent.NewLocalSession(&agent.SessionOptions{
ID: "conversation-2", Client: client, Model: "openai/gpt-5.4", Tools: registry,
})
if err != nil {
fmt.Println(err)
return
}
events, _ := session.RunWithMessages(context.Background(), []llm.Message{
{Role: llm.RoleUser, Content: "What time is it?"},
})
produced, err := agent.Drain(events)
if err != nil {
fmt.Println(err)
return
}
for _, message := range produced {
fmt.Printf("%s %q, %d tool calls\n", message.Role, message.Content, len(message.ToolCalls))
}
}
Output: assistant "", 1 tool calls tool "\"12:00\"", 0 tool calls assistant "It is noon.", 0 tool calls
func (*Session) RunWithOptions ¶
func (s *Session) RunWithOptions(ctx context.Context, userText string, options *RunOptions) (<-chan Event, error)
RunWithOptions is Run with per-run options. Nil options behave like Run. The run keeps its own copy of options.
type SessionOptions ¶
type SessionOptions struct {
// ID keys the session's History and Locker entries. Required.
ID string
// Client is the model client every turn streams from. Required.
Client llm.Client
// Tools are offered to the model and dispatched when it calls them. Nil
// offers no tools; a tool call then fails with tools.ErrUnknownTool.
Tools *tools.Registry
// History stores the conversation Run continues. Required.
History History
// Locker serializes runs of the same session ID. Required.
Locker Locker
// Model is the provider's model ID. Required.
Model string
// SystemPrompt is sent as the first message of every model call. Empty
// sends none. It is never written to History.
SystemPrompt string
// MaximumTurns caps the model calls of one run; zero or negative means 20.
MaximumTurns int
// RunTimeout caps one whole run; zero means 10 minutes, negative is an
// error.
RunTimeout time.Duration
// LLMCallTimeout caps one model call; zero means 180 seconds, negative is
// an error.
LLMCallTimeout time.Duration
// MaximumTokens caps the tokens of one model reply; zero means 16384.
MaximumTokens int
// Temperature is the sampling temperature; nil leaves the model default.
Temperature *float32
// Thinking sets the reasoning effort or budget of every model call, e.g.
// llm.ThinkingByEffort(llm.ThinkingEffortLow). Nil leaves the model default.
Thinking *llm.ThinkingConfiguration
// ToolCallTimeout caps one tool call; zero means 2 minutes, negative
// means no cap. A call that fails after its deadline passed is reported
// to the model as timed out and the run continues. The cap reaches the
// handler as its context's deadline, so it binds only handlers that
// honour ctx.
ToolCallTimeout time.Duration
// MaximumToolResultBytes caps the JSON of one tool result the model sees
// (and History stores); zero means 64 KiB, negative means no cap. A
// longer result is replaced by {"truncated":true,"total_bytes":N,
// "notice":"...","content":"<prefix of the JSON>"}, itself within the cap
// unless the cap is too small for the notice. EventToolResult always
// carries the full result.
MaximumToolResultBytes int
// DisablePromptCaching stops the run from setting prompt-cache
// breakpoints. By default every model request marks the system prompt
// and its last message (if that has content) with llm.CacheBreakpoint
// (5 minute TTL), at most 2 of the 4 breakpoints Anthropic allows. A
// request whose messages already carry a breakpoint is sent unchanged,
// so a History that sets its own keeps control. Breakpoints are added to
// the request only, never to History. Providers that cache on their own
// or not at all (OpenAI, most open models) ignore them; the Anthropic
// and Gemini models on OpenRouter honour them.
DisablePromptCaching bool
}
SessionOptions configure a Session. NewSession copies them, including the values Temperature and Thinking point to, so changing them afterwards does not affect the Session.
type ToolResult ¶
type ToolResult struct {
// CallID is the llm.ToolCall ID this result answers.
CallID string
// Name is the called tool's name.
Name string
// OK is false when the tool failed; Error then holds its message.
OK bool
// Content is the tool's full result when OK. The model gets it as JSON,
// cut at SessionOptions.MaximumToolResultBytes.
Content any
// Error is the failure message when not OK.
Error string
// ModelContent is the tool message content the model received: the JSON
// of Content or Error, cut at SessionOptions.MaximumToolResultBytes. Drain
// stores this copy, so a history rebuilt from events matches what the
// model saw.
ModelContent string
}
ToolResult is one tool execution result surfaced to the caller.
type VFSToolOptions ¶
type VFSToolOptions struct {
// Storage is the file store the tools work on. Required; multi-tenant
// callers pass a store already bound to the tenant's scope.
Storage vfs.Storage
// HideHiddenPaths hides paths with a segment beginning with "_" or "."
// from listings and rejects them as tool arguments.
HideHiddenPaths bool
// AllowedExtensions limits the tools to files with these extensions
// (".md", compared case-insensitively). Nil allows every file.
AllowedExtensions []string
// MaximumFileBytes caps the size of a file the tools write; zero or
// negative means 1 MiB.
MaximumFileBytes int64
// ReadOnly disables registration of write_file, delete_file, edit_file,
// multi_edit and move_file. Use this for fixed-corpus deployments (e.g.
// an embedded knowledge base over vfs/embedfs). Search and list/read
// remain registered.
ReadOnly bool
// DisableExtended skips registration of grep, glob, edit_file,
// multi_edit and move_file, leaving the original
// read_file/write_file/list_files/delete_file/search set.
DisableExtended bool
}
VFSToolOptions configure the built-in VFS tools.
Source Files
¶
Directories
¶
| Path | Synopsis |
|---|---|
|
history
|
|
|
historytest
Package historytest is the conformance suite every agent.History backend runs in its tests.
|
Package historytest is the conformance suite every agent.History backend runs in its tests. |
|
memory
Package memory provides an in-process agent.History backend.
|
Package memory provides an in-process agent.History backend. |