agent

package
v0.17.4 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 16, 2026 License: MIT Imports: 23 Imported by: 0

Documentation

Overview

Package agent is the orchestrator that wires session storage, provider streaming, permission gating, tool execution, and cost accounting into a single turn-by-turn loop.

Layering

internal/agent depends on every package below it: bus, session, store, provider, permission, tool, cost. It is the keystone of the v0.1 architecture. It must NOT import internal/ui or cmd/...; those import it.

The Send loop in one paragraph

Send appends the user message, then delegates the active turn to Fantasy. Fantasy owns model/tool iteration and is intentionally uncapped: cancellation is context-driven. A missing Fantasy model is a configuration error.

Sequential tool execution

Even when the provider returns multiple tool_use blocks in a single response, the agent executes them one at a time. Permission prompts are interactive — stacking modals on top of each other is hostile. Parallel execution is a v0.2 concern. This is safe because the Anthropic tool-use protocol does not require any specific ordering of tool_result blocks within a tool_result message.

Per-session serialisation

At most one Send is in flight per session ID. Concurrent Sends on the same session block on a per-session mutex; Sends on different sessions run independently. Compact participates in the same lock so it cannot race a Send on the same session.

Streaming-error and cancellation semantics

A mid-stream provider.EventError commits a partial assistant message containing whatever text/thinking arrived before the error, plus a stream_error metadata flag, and returns the wrapped error to the caller. This is intentional: the model's partial output is interesting to the user, and the conversation can be inspected before retrying. Tool calls that arrived before the error are NOT executed — we want a clean failure boundary.

Context cancellation, by contrast, commits NOTHING. When ctx is cancelled mid-stream the agent returns ctx.Err immediately; no message is appended. Rationale: cancellation is the user's explicit signal that they don't want this turn — preserving a half-formed assistant message would undo that intent and pollute history.

Cost lookups are best-effort

Pricing lookups go through the catalog. If the catalog returns cost.ErrModelNotPriced, the agent logs a slog.Warn and records the token usage with cost_usd = 0. A turn is never failed over pricing.

Index

Constants

This section is empty.

Variables

View Source
var ErrClosed = errors.New("agent: closed")

ErrClosed is returned by Send and Compact after Close.

View Source
var ErrInjectCap = errors.New("agent: plugin inject cap reached")

ErrInjectCap is returned by InjectMessage when a plugin has injected the maximum number of messages for the current turn.

View Source
var ErrNoActiveTurn = errors.New("agent: no active turn to steer")

ErrNoActiveTurn is returned by Steer when there is no active run to guide.

View Source
var ErrNothingToCompact = errors.New("agent: nothing to compact")

ErrNothingToCompact is returned by Compact when the session contains too few messages since the latest marker to justify summarising.

Functions

This section is empty.

Types

type Agent

type Agent struct {
	// contains filtered or unexported fields
}

Agent is the orchestrator. Construct via New; the zero value is not usable.

func New

func New(opts Options) (*Agent, error)

New constructs an Agent. Returns an error if any required option is nil.

func (*Agent) ClearQueue

func (a *Agent) ClearQueue(sessionID string) int

ClearQueue drops all pending queued sends for the session. Returns the number of sends that were dropped.

func (*Agent) Close

func (a *Agent) Close() error

Close releases the agent. After Close, Send and Compact return ErrClosed. Idempotent.

func (*Agent) Compact

func (a *Agent) Compact(ctx context.Context, sessionID string) (*session.Marker, error)

Compact summarises the session's pre-marker history and writes a new compaction marker. The agent itself generates the summary by calling the provider with a dedicated "summarise this conversation" prompt.

Returns ErrNothingToCompact if there are fewer than 4 messages since the latest marker — summarising 1–3 messages is not worth a provider round-trip. ErrNothingToCompact does NOT publish any events.

Bus events published in order:

Compact is permission-free (it uses no tools) and serialises against Send on the same session through the per-session lock.

func (*Agent) Enqueue added in v0.7.0

func (a *Agent) Enqueue(sessionID string, userParts []session.Part) error

Enqueue adds a prompt to the explicit per-session queue for later execution.

func (*Agent) GenerateTitle added in v0.4.0

func (a *Agent) GenerateTitle(ctx context.Context, prompt string) (string, error)

GenerateTitle returns a concise, display-ready title for prompt using the configured internal title model (or the active Fantasy model when no small title model is configured). It does not mutate store state; callers own persistence.

func (*Agent) InjectMessage

func (a *Agent) InjectMessage(ctx context.Context, pluginName, sessionID, role, content string) error

InjectMessage appends a message to sessionID on behalf of a plugin.

role must be "user" or "assistant". Only "user" messages trigger a new agent turn; "assistant" messages are persisted but the loop is not re- entered (they serve as synthetic context injections).

Each plugin is tracked by pluginName. At most maxPluginInjectsPerTurn calls per pluginName per active turn are processed; additional calls return ErrInjectCap without appending anything.

func (*Agent) IsSessionBusy

func (a *Agent) IsSessionBusy(sessionID string) bool

IsSessionBusy reports whether the session has an active run in flight.

func (*Agent) QueueCount

func (a *Agent) QueueCount(sessionID string) int

QueueCount returns the number of pending queued sends for the session.

func (*Agent) QueuedPrompts

func (a *Agent) QueuedPrompts(sessionID string) []string

QueuedPrompts returns the queued prompt texts (first PartText of each) for display in the UI.

func (*Agent) RefreshHookSystemPromptAdditions added in v0.4.2

func (a *Agent) RefreshHookSystemPromptAdditions(ctx context.Context, sessionID, modeName string) error

RefreshHookSystemPromptAdditions invokes pre_message hooks without persisting a user message so plugins can rebuild their one-turn system prompt additions after external context changes, such as a UI mode switch. Deny/modified_message decisions are ignored because there is no user message to block or rewrite; system_prompt_append results replace any previously queued hook additions for the session.

func (*Agent) RefreshSessionTitle added in v0.4.0

func (a *Agent) RefreshSessionTitle(ctx context.Context, sessionID string) (title string, changed bool, err error)

RefreshSessionTitle asks the title model whether sessionID's current slug still describes the recent conversation. If the topic has changed, it writes a new formatted slug and returns changed=true. A KEEP response is a no-op.

func (*Agent) ResetPluginInjectCounters

func (a *Agent) ResetPluginInjectCounters(sessionID string)

ResetPluginInjectCounters resets the per-turn injection counters for sessionID. Called by the agent loop at the start of each turn so the cap applies per-turn, not per-session.

func (*Agent) Send

func (a *Agent) Send(ctx context.Context, sessionID string, userParts []session.Part) (*session.Message, error)

Send appends a user message to the session and runs the agent loop until the assistant produces a final response with no further tool calls. Fantasy-backed turns are uncapped. Returns the final committed assistant message.

If a Send is already in flight for the session, the new send is queued for a future turn and nil is returned. Active-turn guidance should use Steer.

Bus events emitted, in order per iteration:

  • bus.MessageAppended (user) once at start
  • bus.AssistantTextDelta streamed
  • bus.AssistantThinkingDelta streamed
  • bus.MessageAppended (assistant) per iteration end
  • bus.CostUpdated after each provider response
  • bus.ContextUsageUpdated after each provider response
  • bus.ToolCallRequested per tool call
  • bus.ToolCallCompleted per tool call
  • bus.MessageAppended (tool result) per tool call
  • bus.TurnCompleted after a successful turn
  • bus.QueueChanged when queue depth changes

Permission asks and tool progress events come from the tools themselves while their Execute method runs.

func (*Agent) SetModel

func (a *Agent) SetModel(providerName, modelName string, prv provider.Provider, fm fantasy.LanguageModel) error

SetModel hot-swaps the active provider and Fantasy model used by subsequent sends. It does not mutate existing session rows; callers own any UX or config persistence around the session-only runtime switch.

func (*Agent) SetSystemPrompt added in v0.3.4

func (a *Agent) SetSystemPrompt(prompt string) error

SetSystemPrompt replaces the system prompt used by subsequent sends. It does not mutate persisted messages; callers own any UI/config semantics for why the prompt changed.

func (*Agent) Steer added in v0.7.0

func (a *Agent) Steer(sessionID string, userParts []session.Part) error

Steer queues active-turn guidance to be applied at the next Fantasy step.

type MemoryLoader added in v0.5.0

type MemoryLoader interface {
	ListMemories(ctx context.Context) ([]*session.Memory, error)
}

MemoryLoader provides non-session memories in prompt-injection order.

type Options

type Options struct {
	// Bus is the in-process event bus.  Required.
	Bus *bus.Bus
	// Store is the session persistence layer.  Required.
	Store session.Store
	// Provider is the model adapter.  Required.
	Provider provider.Provider
	// FantasyModel, when non-nil, is used by the active turn loop via
	// fantasy.Agent.Stream. Provider remains required for name/model metadata
	// and cost lookup.
	FantasyModel fantasy.LanguageModel
	// TitleFantasyModel, when non-nil, is used for cheap internal session-title
	// generation. Nil falls back to FantasyModel.
	TitleFantasyModel fantasy.LanguageModel
	// Permission is the permission engine the tools call into.  Required.
	Permission *permission.Engine
	// Tools is the registry of callable tools.  Required.
	Tools *tool.Registry
	// Catalog resolves model pricing.  Required.
	Catalog *cost.Catalog
	// SystemPrompt is the optional system prompt sent on every turn.
	SystemPrompt string
	// Pwd is the working directory passed to tools via ExecContext.  Empty
	// means the tool helpers fall back to os.Getwd.
	Pwd string
	// Now is an injectable clock for bus event timestamps.  Nil means time.Now.
	Now func() time.Time
	// ContextWindow is the model's maximum context size in tokens.  When
	// non-zero, [bus.ContextUsageUpdated.PctUsed] is computed against the
	// input-available window (ContextWindow minus MaxOutput).
	ContextWindow int64
	// MaxOutput is the model's reserved output budget in tokens.  The
	// provider deducts this from the context window before accepting input,
	// so the effective ceiling for prompt tokens is ContextWindow-MaxOutput.
	// Reserving it from the PctUsed denominator keeps the gauge from reading
	// optimistically near the limit.  Zero means "unknown" — no reservation.
	MaxOutput int64
	// CompactionMaxTokens caps the size of the generated summary in
	// Compact.  Zero means 1024.
	CompactionMaxTokens int
	// LazyContext, when non-nil, enables the per-tool-call subdir
	// AGENTS.md / CLAUDE.md loader (see agentsmd.LazyTracker).  Nil
	// means the feature is off — the agent loop never injects subdir
	// context.  Tracker state is per-Agent, but the agent maintains
	// a per-session pending-block buffer so multiple sessions
	// sharing one Agent do not bleed context into each other (only
	// the seen-dir set is shared, which is the intended behaviour
	// for one workspace).
	LazyContext *agentsmd.LazyTracker
	// MemoryLoader, when non-nil, loads file-backed global/project memories for
	// prompt injection. Session memories are always loaded from Store.
	MemoryLoader MemoryLoader
	// Reasoning is the session-scoped reasoning knob copied onto
	// every [provider.Request] this agent issues.  The zero value
	// means "no reasoning" — adapters that support reasoning will
	// not enable it.  CLI / config plumb a [provider.Reasoning]
	// into this field at bootstrap.
	Reasoning provider.Reasoning
	// Hooks, when non-nil, gates each turn through the hook
	// framework (pre_message, pre_tool, post_tool, post_message).
	// A nil Hooks means "no hooks" — the agent loop treats it as a
	// no-op without any nil-deref risk.
	Hooks *hook.Registry
	// CompactionThresholdPct, when > 0, enables the advisory compaction
	// suggestion.  After each turn the agent checks context usage against
	// this percentage.  If usage is at or above the threshold and the flag
	// has not already fired for this session × crossing, it publishes a
	// [bus.CompactionRequested] with Source="threshold".  Valid range 1–99;
	// 0 disables the suggestion.  Default 80 (supplied by cmd/hygge/cli
	// from config.Compaction.ThresholdPct).
	CompactionThresholdPct float64

	// TurnContextDecorator, when non-nil, is called at the start of each
	// Fantasy turn to decorate the context before it is passed to
	// fantasy.Agent.Stream.  Callers use this to inject per-turn values
	// (e.g. session IDs for HTTP transport middleware) without coupling the
	// agent package to any specific provider package.  The returned context
	// replaces the one passed in for the duration of the turn.
	TurnContextDecorator func(ctx context.Context, sessionID string) context.Context
}

Options configures an Agent. Bus, Store, Provider, Permission, Tools, and Catalog are required; the rest have sensible defaults.

type QueuedSend

type QueuedSend struct {
	// Parts is the user message content to send once the session is free.
	Parts []session.Part
}

QueuedSend holds the payload for a user message that arrived while the session was already busy. The send is held in the per-session queue and dispatched automatically when the current run completes.

type Runtime

type Runtime struct {
	// contains filtered or unexported fields
}

Runtime owns the model/tool assembly for active agent turns. It is intentionally narrower than Agent: Agent remains the external compatibility surface for UI/CLI callers, while Runtime decides which turn runner to use and how Fantasy tools are adapted from tool.Registry.

func NewRuntime

func NewRuntime(opts RuntimeOptions) *Runtime

NewRuntime constructs the turn runtime. Tools may be nil only in tests that do not execute turns; Agent.New validates the production path first.

func (*Runtime) GenerateTitle

func (r *Runtime) GenerateTitle(ctx context.Context, prompt string, maxTokens int) (string, provider.Usage, error)

GenerateTitle is the narrow no-tool seam for model-generated session titles. Callers decide whether to persist the returned title or treat KEEP as a no-op. Uses streaming because some OpenAI-compatible providers reject non-stream completions (e.g. "Stream must be set to true"), matching how Summarize runs. maxTokens is accepted for API stability but intentionally not forwarded: several reasoning-class endpoints reject max_output_tokens entirely. The titleSystemInstruction prompt already constrains the model to a single line.

func (*Runtime) SetModel

func (r *Runtime) SetModel(model fantasy.LanguageModel)

SetModel replaces the Fantasy language model used to create future agents. Existing fantasy.Agent instances are per-turn and are not reused, so the handle swap is enough to invalidate the old model for subsequent sends and internal compaction/title calls. The rest of the handle (provider identity) is preserved; Agent.SetModel stores a complete new handle instead.

func (*Runtime) Summarize

func (r *Runtime) Summarize(ctx context.Context, messages []fantasy.Message, maxTokens int) (string, provider.Usage, error)

Summarize runs a no-tool Fantasy agent for internal conversation compaction.

type RuntimeOptions

type RuntimeOptions struct {
	Model      fantasy.LanguageModel
	TitleModel fantasy.LanguageModel
	Tools      *tool.Registry
	// ProviderName is the hygge provider id (e.g. "openrouter"). Optional;
	// when set, drives provider-specific token-accounting normalization in
	// Runtime's no-tool summary/title calls. Lower-case is expected to match
	// the same convention used by the parent Agent's Provider.Name().
	// Ignored when Handle is supplied.
	ProviderName string
	// Handle, when non-nil, shares the owning Agent's active-model bundle so
	// Agent.SetModel swaps are immediately visible to the runtime. When nil
	// (standalone runtimes in tests), a private handle is built from Model
	// and ProviderName.
	Handle *atomic.Pointer[modelHandle]
}

RuntimeOptions configures Runtime.

type SessionAgent

type SessionAgent struct {
	// contains filtered or unexported fields
}

SessionAgent owns per-session turn execution. Queueing and busy-state live on Agent for API compatibility; this type is the handoff point for the eventual Phase 5/6 migration of the rest of the session lifecycle.

func NewSessionAgent

func NewSessionAgent(agent *Agent, runtime *Runtime) *SessionAgent

NewSessionAgent wires an Agent-compatible session runner.

func (*SessionAgent) RunTurn

func (s *SessionAgent) RunTurn(ctx context.Context, sessionID, modelName string) (*session.Message, error)

RunTurn executes one model turn for a session using the configured runtime.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL