agent

package module
v0.2.0 Latest Latest
Warning

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

Go to latest
Published: Jul 11, 2026 License: Apache-2.0 Imports: 9 Imported by: 0

Documentation

Overview

Package agent implements a generic, application-agnostic LLM agent loop.

This is a bare-bones, embeddable foundation for building any agent. It owns the multi-turn conversation, dispatches tool calls, and emits lifecycle events on a typed channel. It does NOT know about file edits, validation, lint, memory, approval flow, prompts, or any other application concept — those live in the consuming application.

The agent ships with zero tools. Consumers register their own tools via Tool and extend behavior via Hooks (BeforeToolCall, AfterToolCall, TransformContext, GetSteeringMessages, GetFollowUpMessages). Anything an application needs to layer on top of the loop happens through those two extension points.

Index

Constants

This section is empty.

Variables

View Source
var ErrInvalidOptions = errors.New("agent: invalid options")

ErrInvalidOptions is returned by New when Options is missing a required field or contains an inconsistency that would produce an unusable agent (nil tool, empty tool name, duplicate tool name).

View Source
var ErrRunInProgress = errors.New("agent: a run is already in progress")

ErrRunInProgress is returned by Agent.Prompt when called while a run is already active. Callers must Abort the existing run (and optionally WaitForIdle) before starting a new one.

Functions

This section is empty.

Types

type AfterToolCallInput

type AfterToolCallInput struct {
	// CallID identifies the tool call that finished.
	CallID string
	// Name is the tool name that ran.
	Name string
	// Args is the raw JSON argument string from the LLM.
	Args string
	// Result is the tool's return value before any hook overrides apply.
	Result ToolResult
}

AfterToolCallInput carries the data AfterToolCall sees about a finished tool call.

type AfterToolCallResult

type AfterToolCallResult struct {
	// Content overrides the tool result text (when non-nil).
	Content *string
	// IsError overrides the error flag (when non-nil).
	IsError *bool
	// Terminate signals the agent should stop after the current tool
	// batch. Early termination only fires when every finalized tool
	// result in the batch sets this to true.
	Terminate bool
}

AfterToolCallResult is the optional override from AfterToolCall. Each pointer field, when non-nil, replaces the corresponding part of the finalized result. Nil means "leave that field as the tool returned it."

type Agent

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

Agent runs the multi-turn LLM loop. Construct with New; drive with Agent.Prompt / Agent.Reply; observe via the events channel from Options.

Concurrency: Agent serializes its loop on a single goroutine. Agent.Prompt, Agent.Reply, Agent.Abort, Agent.State, and Agent.WaitForIdle are safe to call from any goroutine. Hooks run on the loop goroutine and must not block indefinitely.

func New

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

New constructs an Agent from Options. Returns ErrInvalidOptions wrapped with a specific reason when validation fails.

Validation rules:

  • Provider must be non-nil.
  • Events must be non-nil.
  • Each entry in Tools must be non-nil.
  • Each tool's Definition().Function.Name must be non-empty.
  • Tool names must be unique. A duplicate is a configuration error; silently overwriting would desync the tools map and toolDefs slice (the LLM would see the duplicate while only one route exists).
  • MaxTurns must not be negative (zero means unlimited).

func (*Agent) Abort

func (a *Agent) Abort()

Abort cancels the current run. Safe to call when no run is active — it is a no-op in that case. The events channel receives an event.AgentEnd once the loop unwinds; use Agent.WaitForIdle to block until that happens.

func (*Agent) Prompt

func (a *Agent) Prompt(ctx context.Context, content string) error

Prompt starts a new run with the given user message. Returns ErrRunInProgress when a run is already active; callers must Agent.Abort the previous run (and optionally Agent.WaitForIdle) before starting a new one. Lifecycle progress flows through the events channel; Agent.WaitForIdle blocks for run completion.

The provided context governs the run's lifetime. Cancelling it is equivalent to Agent.Abort: the loop unwinds and emits event.AgentEnd. Hooks and tool Execute calls receive a child context derived from this ctx so they unblock alongside the run.

func (*Agent) PromptWithMessages

func (a *Agent) PromptWithMessages(ctx context.Context, messages []llm.Message) error

PromptWithMessages starts a new run with a caller-supplied initial transcript instead of the [system?, user] slice Agent.Prompt builds from Options.SystemPrompt + content.

Useful for applications that compose dynamic per-run transcripts — e.g. a coding agent that injects file content, context files, and a memory summary alongside the user's goal, with system text that depends on runtime mode and configuration. Such applications should leave Options.SystemPrompt empty and drive every run through this method.

The agent takes a defensive copy of messages; the caller may mutate its slice after the call returns. An empty slice is rejected with ErrInvalidOptions — a run with no initial messages would have nothing to send to the provider on the first turn.

Same lifecycle semantics as Agent.Prompt: ErrRunInProgress when a run is active, ctx cancellation unwinds the loop, event.AgentEnd signals completion.

func (*Agent) ReplaceMessages

func (a *Agent) ReplaceMessages(msgs []llm.Message) error

ReplaceMessages overwrites the saved transcript with msgs. Returns ErrRunInProgress if a run is active — callers must Agent.Abort and Agent.WaitForIdle before replacing. msgs is cloned; the caller retains ownership of the input slice.

Use cases: out-of-band compaction (replace with a smaller slice before the next run resumes) and history reset (pass nil to start the next run from a clean slate). The next Agent.PromptWithMessages call will overwrite again — this method only matters when the consumer drives a resume-from-saved-state path.

func (*Agent) Reply

func (a *Agent) Reply(_ context.Context, content string) bool

Reply queues a developer follow-up for the active run. Returns true when the message was accepted, false when no run is active or the reply queue is full.

8a semantics: the queue is a buffered channel of capacity 1. A second Reply before the loop drains the first is rejected with false; the caller can retry after Agent.WaitForIdle reaches the next idle point or after observing a event.TurnEnd without tool calls.

func (*Agent) State

func (a *Agent) State() State

State returns a read-only snapshot of the agent's current state. Slices and maps in the result are safe to read without further synchronization; modifying them does NOT affect the agent.

func (*Agent) WaitForIdle

func (a *Agent) WaitForIdle()

WaitForIdle blocks until the current run (if any) has finished and the loop goroutine has unwound. Returns immediately when no run is active.

type BeforeToolCallInput

type BeforeToolCallInput struct {
	// CallID is the tool call's unique identifier.
	CallID string
	// Name is the tool name the LLM dispatched.
	Name string
	// Args is the raw JSON argument string from the LLM.
	Args string
}

BeforeToolCallInput carries the data BeforeToolCall needs to evaluate a pending tool call.

type BeforeToolCallResult

type BeforeToolCallResult struct {
	// Block prevents the tool from executing. The loop emits a synthesized
	// error tool-result so the LLM observes the rejection.
	Block bool
	// Reason is the message surfaced to the LLM when Block is true.
	// Empty Reason yields a generic "tool blocked" message.
	Reason string
}

BeforeToolCallResult is the optional return shape from BeforeToolCall. Non-zero fields override or block; the zero value lets execution proceed.

type Hooks

type Hooks struct {
	// BeforeToolCall fires after the LLM emits a tool call but before the
	// tool's Execute method runs. The hook can block execution by setting
	// BeforeToolCallResult.Block; the loop emits a synthesized error
	// tool-result in that case so the LLM sees the rejection.
	BeforeToolCall func(ctx context.Context, c BeforeToolCallInput) (BeforeToolCallResult, error)

	// AfterToolCall fires after Execute returns, before the loop forwards
	// the result to the LLM. The hook can override Content / IsError or
	// signal early termination.
	AfterToolCall func(ctx context.Context, c AfterToolCallInput) (AfterToolCallResult, error)

	// TransformContext rewrites the message slice before each LLM call.
	// Used for compaction, redaction, or context-set injection. Return the
	// (possibly modified) slice; nil is treated as "use the input as-is."
	TransformContext func(ctx context.Context, messages []llm.Message) ([]llm.Message, error)

	// SteeringMessages returns messages the application wants injected
	// after the current assistant turn finishes. Returning nil or an empty
	// slice means "nothing pending."
	SteeringMessages func(ctx context.Context) ([]llm.Message, error)

	// FollowUpMessages returns messages the application wants processed
	// after the agent would otherwise stop. Distinct from steering: these
	// are NOT injected mid-run; they trigger a fresh continuation.
	FollowUpMessages func(ctx context.Context) ([]llm.Message, error)

	// BeforePark fires after FollowUpMessages returns no messages and
	// before the loop parks on [Agent.awaitReply]. The hook returns the
	// payload the foundation includes in [event.AgentParked]; the
	// foundation has no opinion on what Finished means — the hook owns
	// the application-level "all work done" signal. A nil hook causes
	// the foundation to emit a zero-value AgentParked.
	//
	// BeforePark exists so that the application's "we're parked" signal
	// flows through the same event pipeline as every other foundation
	// event, preserving order with trailing MessageUpdate/AgentEnd
	// events. Sending an application-shaped event directly from
	// FollowUpMessages races the event pipeline and was the
	// historical source of out-of-order delivery to consumers.
	BeforePark func(ctx context.Context) (event.AgentParked, error)

	// OnTruncated fires when the provider's terminal Done event reports
	// Truncated=true, before the foundation surfaces the truncation as a
	// run-ending error. The hook owns the recovery decision: splice
	// rejection or recovery messages into the transcript and either retry
	// the turn or end the run.
	//
	// Returning Retry=true continues the loop after appending Messages —
	// the truncated tool calls are NEVER executed (partial arguments may
	// corrupt state) but their tool-role rejections in Messages keep the
	// transcript well-formed for the next provider call. Returning
	// Retry=false ends the run silently after appending Messages — the
	// hook is responsible for surfacing a user-facing [event.Error] (or an
	// application-shaped error event) before returning. A nil hook is
	// equivalent to Retry=false with the foundation emitting a generic
	// "provider truncated response" error.
	OnTruncated func(ctx context.Context, c TruncationInput) (TruncationResult, error)
}

Hooks let the application layer extend the loop without modifying it. Every field is optional — a nil hook is skipped. Hooks run synchronously on the agent's goroutine; long-running work should be dispatched off the hook's call stack.

Adding a new hook field is a parallel-surface change — see /nib/patterns.md "Parallel-surface triple: `Hooks` ↔ `mergeHooks` ↔ `chainX`". The field here must be paired with a `mergeHooks` switch case and a `chainX` helper in `kit/toolset.go`.

type Options

type Options struct {
	// Provider is the LLM provider used for every turn. Required.
	Provider llm.Provider

	// Events is the channel the agent writes lifecycle events to.
	// Required. The caller must drain this channel; the agent blocks
	// briefly on control-flow events (5s) and drops high-volume
	// streaming events ([event.MessageUpdate], [event.TurnUsage],
	// [event.InputEstimate]) when the channel is full.
	Events chan<- event.Event

	// SystemPrompt is the system message prepended to every run's
	// transcript. Empty means "no system message." Application layers
	// compose their prompt and pass the result here.
	SystemPrompt string

	// Tools are the tool implementations registered against this agent.
	// The agent advertises Definition() of each to the LLM and routes
	// tool calls to Execute(). Tools share the agent's lifetime.
	Tools []Tool

	// MaxTurns caps the number of LLM turns the loop may take without an
	// intervening user message — the guard against tool-call ping-pong in
	// embedded or unattended deployments. The counter increments per turn
	// (steering, follow-up, and truncation-retry turns all count) and
	// resets to zero each time [Agent.Reply] delivers input. When the loop
	// would start turn MaxTurns+1 it instead emits
	// [event.MaxTurnsReached] and ends the run cleanly: every dispatched
	// tool call already has its result appended, so the transcript stays
	// well-formed and the conversation can continue on the next Prompt.
	// Zero (the default) means unlimited; negative is rejected by [New].
	MaxTurns int

	// Hooks are the application-layer extension points. Nil disables a
	// given hook; the agent skips it without ceremony.
	Hooks Hooks
}

Options bundles the construction-time inputs for New. Every field other than Provider and Events is optional.

type State

type State struct {
	// Messages is the conversation transcript at snapshot time.
	Messages []llm.Message
	// Streaming is true while the agent is processing a turn.
	Streaming bool
	// PendingToolCalls is the set of tool call IDs currently executing.
	PendingToolCalls map[string]bool
	// LastError is the most recent loop-level error message, empty when none.
	LastError string
}

State is the public snapshot of an agent's runtime state. Returned by Agent.State for read-only inspection by frontends and hooks. Mutating the slices or maps does not affect the agent.

type Tool

type Tool interface {
	// Definition returns the tool's function schema for the LLM.
	Definition() llm.ToolDef

	// Execute handles a tool call and returns a result fed back to the LLM.
	// The agent does not interpret Result.Content beyond passing it as the
	// tool message body — application-layer interpretation happens in hooks.
	Execute(ctx context.Context, call llm.ToolCall) ToolResult
}

Tool is a capability the agent can invoke during the LLM loop. Each tool publishes its OpenAI-compatible schema and runs the corresponding logic.

The agent calls Definition once at registration to populate the tool list sent to the LLM, and Execute every time the LLM dispatches a call. Tools must be safe to invoke concurrently when registered with concurrent execution; per-tool state (caches, retry counters) needs its own synchronization.

Implementations should pass github.com/latebit-io/nib/kit/contracttest.Tool — the fixture verifies Definition() shape and stability, panic-free handling of malformed/empty arguments, and concurrent Execute safety.

type ToolResult

type ToolResult struct {
	// Content is the text returned to the LLM as the tool's reply.
	Content string

	// IsError flags that the tool execution failed. The LLM still sees
	// Content; frontends can render the failure distinctively.
	IsError bool
}

ToolResult is the value a tool returns to the agent loop.

The shape is deliberately minimal: a string fed to the LLM and an error flag. Application-specific concepts (edit proposals, approval state, navigation requests) do NOT live here — they belong in the application layer and are surfaced through hooks (BeforeToolCall / AfterToolCall) or via tool-internal channels the application layer owns.

type TruncationInput

type TruncationInput struct {
	// Assistant is the truncated assistant message. The foundation has
	// ALREADY appended it to the transcript before invoking the hook so
	// any rejection messages the hook returns slot in immediately after
	// it. ToolCalls (if any) carry possibly-partial argument JSON; the
	// hook must NOT attempt to execute them.
	Assistant llm.Message
	// ToolCalls is the pending tool call slice from the truncated
	// assistant message — equivalent to Assistant.ToolCalls but lifted
	// for ergonomic access. Chat-completion transcripts require a
	// tool-role reply for every entry before the next assistant turn;
	// hooks that retry must include those replies in [TruncationResult].
	ToolCalls []llm.ToolCall
}

TruncationInput carries the data Hooks.OnTruncated needs to decide whether to retry the turn or end the run.

type TruncationResult

type TruncationResult struct {
	// Retry continues the loop with another turn after appending
	// Messages. False ends the run after appending Messages.
	Retry bool
	// Messages are appended to the transcript before retry — typically
	// a tool-role rejection for each pending tool call (chat-completion
	// transcripts validate the pairing) plus an optional user-role
	// nudge. Empty/nil is allowed; the foundation just continues with
	// the transcript as the hook left it.
	Messages []llm.Message
}

TruncationResult is the optional decision returned from Hooks.OnTruncated. The zero value (Retry=false, Messages=nil) ends the run without appending anything.

Directories

Path Synopsis
Package event defines the generic agent-loop lifecycle events.
Package event defines the generic agent-loop lifecycle events.

Jump to

Keyboard shortcuts

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