Agent Engine Developer Guide
How to use and extend pkg/agent/ for multi-turn LLM runs with tools.
Configuration
agent.Config (alias of msg.Config) controls a single loop invocation:
cfg := agent.DefaultConfig()
cfg.ModelName = "gpt-4o"
cfg.ChatModel = "gpt-4o-mini" // optional dual-model
cfg.ToolModel = "gpt-4o"
cfg.MaxSteps = 30
cfg.Temperature = 0.7
cfg.ToolExecution = agent.ToolExecutionParallel
cfg.SteeringMode = agent.QueueAll
cfg.FollowUpMode = agent.QueueAll
| Field | Description |
|---|---|
TransformContext |
Prune or enrich messages before LLM conversion |
ConvertToLLM |
Defaults to transform.DefaultConvertToLLM |
PrepareNextTurn |
Refresh model/context between turns |
ShouldStopAfterTurn |
Early exit after a completed turn |
BeforeToolCall / AfterToolCall |
Tool interception hooks |
GetSteeringMessages / GetFollowUpMessages |
Set automatically on Agent via queues |
Dual-model routing (chat agent)
Configure in flowbot.yaml:
chat_agent:
chat_model: "gpt-4o-mini" # non-empty enables the chat agent
tool_model: "gpt-4o" # enables dual-model routing when set
When tool_model is set, the loop uses chat_model for the first LLM call and switches to tool_model after tool execution. Both models must be registered under models[] and use the same provider (v1).
For programmatic harness use, pass harness.Options.Router; chat agent sets cfg.ChatModel / cfg.ToolModel directly instead.
Stateless Loop
Use when you manage context yourself and do not need queues or subscriptions:
stream := agentevent.NewStream(32)
messages, err := agent.RunLoop(ctx,
[]agent.AgentMessage{agent.NewUserMessage("summarize logs")},
&agent.Context{SystemPrompt: "You are a homelab assistant."},
cfg,
agent.LoopDeps{Model: llmModel, Registry: registry},
stream,
)
Continue from an existing context (last message must be user or tool result):
messages, err := agent.RunLoopContinue(ctx, agentCtx, cfg, deps, stream)
Stateful Agent
ag := agent.NewAgent(agent.Options{
Model: llmModel,
Registry: registry,
Config: cfg,
InitialState: &agent.Context{
SystemPrompt: "Follow AGENTS.md conventions.",
},
})
ag.Subscribe(func(ev agentevent.Event) error {
if ev.Type == agentevent.TypeToolExecutionStart {
// render progress
}
return nil
})
stream, _ := ag.Prompt(ctx, agent.NewUserMessage("check disk usage"))
result, _ := stream.Await(ctx)
ag.Steer(agent.NewUserMessage("also check memory"))
ag.Abort() // cancel in-flight run
Implementing a Tool
type MyTool struct{}
func (MyTool) Name() string { return "disk_usage" }
func (MyTool) Description() string { return "Returns disk usage for a mount point" }
func (MyTool) Parameters() map[string]any {
return map[string]any{
"type": "object",
"properties": map[string]any{
"path": map[string]any{"type": "string", "description": "Mount path"},
},
"required": []string{"path"},
}
}
func (MyTool) Execute(ctx context.Context, id string, args map[string]any, onUpdate tool.UpdateHandler) (msg.ToolResultMessage, error) {
_ = onUpdate("reading stats...")
return msg.ToolResultMessage{
ToolCallID: id,
Name: "disk_usage",
Parts: []msg.ContentPart{msg.TextPart{Text: "72% used"}},
}, nil
}
Register and optionally restrict:
reg := tool.NewRegistry()
_ = reg.Register(MyTool{})
reg.SetActive([]string{"disk_usage"}) // review mode: read-only tools only
See reference: pkg/agent/tools/echo/.
Persistent memory (fact memory + session summaries)
Memory is agent-produced and orthogonal to Knowledge (user-authored docs). Short-term conversation context remains the session tree + compaction (unchanged).
Fact memory (memory_set / memory_get / memory_list / memory_delete)
Facts are keyed rows in Postgres (agent_memory_facts), isolated by memory_scope:
- Interactive chat: all sessions share scope
default - Pipeline
agent.run: pipeline name (auto-injected asmemory_scope) - Scheduled tasks: task flag
Pinned facts (plus recent facts under a budget of ~30 rows / 4k chars) are injected into the system prompt as <memory_facts>. Prompt cache keys include a facts fingerprint.
Permissions (key memory):
- Interactive:
read/listallow;write(set/delete) asks for approval - Pipeline / scheduled: write allowed when the tools are enabled (no confirm gate)
- Plan mode: get/list/search only; set/delete blocked
Interactive chat registers the memory tools by default. Pipeline agent.run and scheduled tasks must include the tools in the step Tools allowlist. The Pipeline editor shows Memory Facts when an agent.run step includes memory_set (or related memory tools).
Session summaries (search_session_summaries)
When a chat session is archived, Flowbot asynchronously generates a searchable summary (agent_session_summaries). Retrieval uses case-insensitive substring match (ContainsFold), not tsvector. Summaries are never auto-injected into the prompt. Unarchive keeps existing summaries; re-archive refreshes them.
Web admin:
/service/web/agent-memory— browse/edit/pin/delete facts by scope (create viamemory_set)/service/web/agent-session-summaries— browse/search/retry summaries
Session Persistence
Implement session.Storage for your backend (PostgreSQL, file, etc.):
type Storage interface {
Append(ctx context.Context, entry session.TreeEntry) error
GetBranch(ctx context.Context, leafID string) ([]session.TreeEntry, error)
GetLeafID(ctx context.Context) (string, error)
SetLeafID(ctx context.Context, id string) error
}
Usage:
store := session.NewMemoryStorage()
sess := session.New(store)
_ = sess.Append(ctx, session.TreeEntry{
ID: "1", Type: session.EntryMessage, Message: agent.NewUserMessage("hi"),
})
branch, _ := sess.GetBranch(ctx, "")
built := session.BuildContext(branch)
agentCtx := session.ToAgentContext(built, "system prompt here")
Branch rollback:
_ = sess.MoveTo(ctx, "previous-entry-id", "Summary of abandoned branch...")
With harness.Options.ContextManager set, Harness.MoveTo generates branch summaries automatically when the summary argument is empty.
Context Management
Wire compaction through the harness for production chat flows:
ctxMgr := ctxmgr.New(ctxmgr.Options{
Model: llmModel,
ModelName: "gpt-4o",
ContextWindow: config.ChatAgentContextWindow(),
Settings: ctxmgr.SettingsFromConfig(config.App.ChatAgent.Compaction),
SystemPrompt: systemPrompt,
})
Register models in flowbot.yaml and add catalog entries in pkg/agent/model/catalog.go for accurate context limits. Unknown model names use the default 128000-token budget.
Configure compaction thresholds in flowbot.yaml:
chat_agent:
chat_model: "gpt-4o"
compaction:
enabled: true
reserve_tokens: 16384
keep_recent_tokens: 20000
JSONL export (caller writes bytes to disk or DB):
data, _ := session.SerializeSession(entries)
entries, _ := session.DeserializeSession(data)
Harness
Higher-level orchestration with session persistence and hook bridging:
h := harness.New(harness.Options{
AgentOptions: agent.Options{Model: llmModel, Registry: registry},
Session: sess,
Router: model.NewRouter("gpt-4o-mini", "gpt-4o"),
SystemPrompt: "You are Flowbot.",
ModelName: "gpt-4o-mini",
Hooks: reg, // optional; see Typed Hooks below
})
_ = h.RegisterTool(echo.Tool{})
stream, _ := h.Prompt(ctx, agent.NewUserMessage("echo test"))
Harness.WaitIdle(ctx) blocks until the run finishes persisting session entries. Harness.Hooks() returns the registry for late registration (prefer registering before the first Prompt).
Typed Hooks (pkg/agent/hooks)
Process-local extension points (aligned with pi-agent harness hooks). One hooks.Registry per harness run; register handlers before Prompt.
import (
"context"
"github.com/flowline-io/flowbot/pkg/agent/hooks"
)
reg := hooks.NewRegistry()
// Mutable: runs before loop; bridged outside loop Config
hooks.OnBeforeAgentStart(reg, func(ctx context.Context, ev hooks.BeforeAgentStartEvent) (*hooks.BeforeAgentStartResult, error) {
prompt := ev.SystemPrompt + "\nExtra instructions."
return &hooks.BeforeAgentStartResult{SystemPrompt: &prompt}, nil
})
// Mutable: each LLM request; composed into Config.TransformContext
hooks.OnContext(reg, func(ctx context.Context, ev hooks.ContextEvent) (*hooks.ContextResult, error) {
return &hooks.ContextResult{Messages: ev.Messages}, nil
})
// Mutable: tool gate; composed into Config.BeforeToolCall
hooks.OnToolCall(reg, func(ctx context.Context, ev hooks.ToolCallEvent) (*hooks.ToolCallResult, error) {
if ev.ToolCall.Name == "dangerous" {
return &hooks.ToolCallResult{Block: true, Reason: "not allowed"}, nil
}
return nil, nil
})
// Mutable: patch tool output; Terminate stops the inner loop
hooks.OnToolResult(reg, func(ctx context.Context, ev hooks.ToolResultEvent) (*hooks.ToolResultResult, error) {
return nil, nil
})
// Read-only: harness lifecycle notifications
hooks.Observe(reg, func(ctx context.Context, ev hooks.ObservationEvent) error {
// ev.Type: hooks.EventSavePoint, EventContextUsage, EventModelUpdate, ...
return nil
})
Pass the registry into harness:
h := harness.New(harness.Options{Hooks: reg, /* ... */})
Registrar reference
| Function | Event constant | Result type | Effect |
|---|---|---|---|
OnBeforeAgentStart |
EventBeforeAgentStart |
BeforeAgentStartResult |
Replace prompts; Cancel → hooks.ErrRunCancelled |
OnContext |
EventContext |
ContextResult |
Replace message list before LLM |
OnToolCall |
EventToolCall |
ToolCallResult |
Block skips tool execution |
OnToolResult |
EventToolResult |
ToolResultResult |
Patch Parts / IsError; Terminate ends run |
Observe |
(various) | — | No loop impact |
OnObservation |
filter by type | — | Convenience wrapper over Observe |
Handlers receive the same context.Context passed to Harness.Prompt (respects cancellation during bridged loop hooks).
Direct loop use (without harness)
For tests or custom callers, bridge manually:
routed := model.ApplyDefaultRouter(cfg)
cfg = hooks.BridgeConfig(ctx, reg, routed)
Or set Config.TransformContext / BeforeToolCall / AfterToolCall directly on agent.Config without a registry.
Chat agent integration
internal/server/chatagent wires observational hooks per session run:
reg := hooks.NewRegistry()
RegisterHooks(reg, ChatHookDeps{SessionID: req.SessionID})
harness.New(harness.Options{Hooks: reg, /* ... */})
RegisterHooks logs context_usage and save_point at debug level. Add product hooks by extending RegisterHooks or registering on reg before harness.New.
LLM Provider Setup
Map flowbot YAML models to langchaingo:
model, name, err := agentllm.NewModel(ctx, "gpt-4o") // model name from config.models
Supported providers (via pkg/agent/llm provider constants): OpenAI, OpenAI-compatible, Anthropic, Gemini.
For tests, use agentllm.NewFakeModel with scripted ResponseScript entries (text and/or tool calls).
Multimodal Input
User turns use msg.MediaPart (Kind = image|audio|video) plus optional TextPart. Chatagent persists only file_id + mime + kind; temporary signed URLs or bytes are filled at convert time.
parts := transform.ProcessAttachments([]transform.Attachment{
{MIMEType: "image/png", FileID: "fid", Kind: msg.MediaKindImage},
})
msg := agent.NewUserMessageWithParts(parts...)
ConvertToLLM is provider-aware:
- Providers that accept remote image URLs receive a short-lived signed URL (
llms.ImageURLPart). - Providers that only accept bytes (e.g. Anthropic via langchaingo) hydrate from media storage (
llms.BinaryPart). - Non-image kinds must not be sent as
ImageURLPart; without catalogModalityAudioIn/ModalityVideoIn, chatagent Rejects before the loop.
Chat product flow: POST /chatagent/sessions/:id/media then POST …/messages with {text, attachments}.
Testing
Unit tests (table-driven, testify)
Location: pkg/agent/**/*_test.go. Minimum three cases per table per TDD spec.
go test ./pkg/agent/...
Use agentllm.NewFakeModel — never call real APIs in unit tests.
BDD acceptance
Location: tests/specs/agent_spec_test.go — full prompt → tool → response flow with fake model (no Docker LLM required).
go tool task test:specs
Extension Checklist
When adding features to pkg/agent/:
- Shared types go in
pkg/agent/msg/if multiple subpackages need them - Avoid import cycles: subpackages must not import root
agent - langchaingo stays inside
pkg/agent/llm/ - Database or file I/O stays outside core — use interfaces (
session.Storage) - Update architecture.md and pkg/agent/AGENTS.md
- Product hooks: use
pkg/agent/hooksregistrars on a per-runRegistry - Add table-driven tests (≥3 cases) and BDD coverage when behavior is user-visible
Error Handling (Result Pattern)
Flowbot mirrors pi-agent's layered error model:
- Low-level (
pkg/agent/result,env,ctxmgr, JSONL parse): returnresult.Result[T,E]with stable typed codes (compaction,timeout,not_found, …). Callers must branch onIsOk()— do not ignore failures. - Boundary (
harness,sessionpublic methods): adapt Result to Goerrorviaresult.GetOrErrorornormalizeHarnessError. - Tool layer: expected runtime failures become
ToolResultMessage{IsError: true};Executereturns(result, nil). - Loop layer: LLM, hook, and transport failures abort the run; tool errors do not.
compactResult := ctxmgr.RunCompaction(ctx, model, name, prep)
if !compactResult.IsOk() {
return result.GetOrError(compactResult)
}
Use result.CodeOf(err) or errors.As at HTTP/chat integration boundaries instead of string matching.
Future Integration Points
Not implemented in the core library (planned for upper layers):
- Provider payload hooks (
before_provider_request) — reserved inhooks/events.go, not yet implemented - Session compact/tree hooks (
session_before_compact) — second phase; requires ctxmgr callback wiring
Already wired in product layers:
- REST/SSE chat agent in
internal/server(/chatagent/*) - Pipeline
agent_runsteps (capability: core,operation: agent_run) with template-renderedpromptand ephemeral sessions chat_agentYAML →agent.Config(models, retry, sensors, sandbox)- Compaction via
pkg/agent/ctxmgrandharness.Options.ContextManager - LLM retry, agent metrics/OTel, path sensors, progress artifact, opt-in sandbox