agent

package module
v0.1.3 Latest Latest
Warning

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

Go to latest
Published: Aug 22, 2026 License: MIT Imports: 9 Imported by: 0

README

agent

A small, model-agnostic coding-agent runtime in Go. It turns a stream of LLM events into a stream of tool calls — with retries, steering, follow-ups, abort, and thinking-budget clamping already handled — and ships the standard coding toolbox (bash, read, write, edit, editdiff, find, grep, imageresize, plan).

Why

Wiring an LLM into a working coding agent means re-solving the same control-flow problems every time: stream assistant turns, parse tool calls out of partial JSON, execute tools, feed results back, retry on transient/rate-limit errors, fold steering and follow-up messages into the loop, and clamp thinking budgets to what the model supports. agent packages that loop behind a compact API so the host only supplies a model, a tool set, a prompt, and a streaming function.

Dependencies

The runtime is deliberately lean. It depends only on:

  • github.com/kfet/ai — portable AI primitives (messages, tools, models, streaming events).
  • github.com/kfet/pinexec — the bash tool's cancellable shell runner.
  • golang.org/x/image and golang.org/x/text — image resizing and Unicode-aware path handling for the tools subpackage.

No session store, MCP runtime, TUI, extension host, provider catalog, or HTTP client. A forbidden_imports_test.go guard fails the build if the transitive import graph ever grows beyond that sanctioned set.

Layout

Package Purpose
agent (root) Agent, the agent loop, ToolSet, AgentTool, thinking-level clamping, side-query sanitisation.
agent/tools The standard coding toolbox: bash, read, write, edit, editdiff, find, grep, imageresize, plan.

Coverage

The repo ships the sibling-convention make all (gofmt + vet + staticcheck + race + a covgate gate). The gate floor is 100%.

Attribution

Portions are ported from pi-mono (MIT, Copyright (c) 2025 Mario Zechner). Files derived from that project carry a // Ported from: header. See LICENSE.

License

MIT — see LICENSE.

Documentation

Overview

Package agent is a model-agnostic coding-agent runtime in Go.

It turns a stream of LLM events into a stream of tool calls, with retries, steering, follow-ups, abort, and thinking-budget clamping already correct. Callers supply a model, a tool set, a prompt, and a streaming function; the agent emits structured events the host can render however it wants.

Package agent is intentionally small. It does not depend on a session store, MCP runtime, TUI, extension host, provider catalog, or any HTTP client. Its only dependencies are github.com/kfet/ai (the portable AI primitives: Message, Tool, Model, Usage, Context, streaming event types), github.com/kfet/pinexec (the bash tool's shell runner), and a couple of golang.org/x/ packages used by the image/path tools.

Headline API

The simplest path is Agent:

a := agent.NewAgent(agent.AgentOptions{
    InitialState: &agent.AgentState{Model: model},
    StreamFn:     myStreamFn,        // any provider client
    Tools:        agent.ToolSetFrom(tools),
    ConvertToLLM: agent.DefaultConvertToLLM,
})
unsubscribe := a.Subscribe(func(ev agent.AgentEvent) {
    // render ev however you like
})
defer unsubscribe()
_ = a.Prompt("Refactor handler.go to use the new config struct.")
a.WaitForIdle()

For non-streaming one-shot use cases, Agent.SimplePrompt returns the assistant's final text without spinning up the full loop.

StreamFn

The agent does not know how to talk to a specific provider. Callers pass a StreamFn — any function that, given a model, prompt context, and options, returns an github.com/kfet/ai.AssistantMessageEventStream. When the per-call StreamFn is nil, the agent falls back to DefaultStreamFn, a package-level factory hook that host applications install to wire up their own provider registry. Setting neither yields a clear "no stream function configured" error.

Tools

Tools are AgentTool values; collect them in a ToolSet. Each tool is a name, a JSON schema, an executor, and optional display hints. The standard coding toolbox lives in github.com/kfet/agent/tools.

Thinking levels

The canonical ladder is max → xhigh → high → medium → low → minimal → off. ClampThinkingLevel walks a requested level down to whatever the model actually supports. Knowledge about which specific model IDs support which levels lives outside this package — the host computes the available set and passes it in.

Concurrency

Agent is safe for concurrent use by its event subscribers and by callers issuing Prompt/Steer/FollowUp/Abort. Internal state is guarded by a single mutex; subscribers are dispatched synchronously from the agent's goroutine, so subscribers must not block.

Example

Example demonstrates the headline path: create an Agent, subscribe to its events, send a prompt, wait for it to finish.

package main

import (
	"fmt"
	"sync"
	"time"

	"github.com/kfet/agent"
	"github.com/kfet/ai"
)

// fakeStreamFn returns a StreamFn that replays the given assistant
// messages, one per call, looping on the last response after the
// canned list is exhausted.
func fakeStreamFn(responses ...*ai.AssistantMessage) agent.StreamFn {
	var mu sync.Mutex
	idx := 0
	return func(_ *ai.Model, _ ai.Context, _ *ai.SimpleStreamOptions) *ai.AssistantMessageEventStream {
		mu.Lock()
		msg := responses[idx]
		if idx < len(responses)-1 {
			idx++
		}
		mu.Unlock()
		s := ai.NewAssistantMessageEventStream()
		go func() {
			s.Push(ai.AssistantMessageEvent{Type: ai.EventStart, Partial: msg})
			s.Push(ai.AssistantMessageEvent{Type: ai.EventDone, Reason: msg.StopReason, Message: msg})
			s.End(nil)
		}()
		return s
	}
}

// exampleModel returns a Model wired up for Anthropic Messages so the
// examples compile against a realistic shape. No HTTP is involved —
// the StreamFn is faked.
func exampleModel() *ai.Model {
	return &ai.Model{
		ID:            "example-model",
		Name:          "Example Model",
		API:           ai.APIAnthropicMessages,
		Provider:      ai.ProviderAnthropic,
		ContextWindow: 200000,
		MaxTokens:     4096,
	}
}

func textResponse(text string) *ai.AssistantMessage {
	return &ai.AssistantMessage{
		Role:       ai.RoleAssistant,
		Content:    []ai.AssistantContent{ai.NewTextContent(text)},
		API:        ai.APIAnthropicMessages,
		Provider:   ai.ProviderAnthropic,
		Model:      "example-model",
		StopReason: ai.StopReasonStop,
		Timestamp:  time.Now().UnixMilli(),
	}
}

func main() {
	a := agent.NewAgent(agent.AgentOptions{
		Model:    exampleModel(),
		StreamFn: fakeStreamFn(textResponse("Hello, world.")),
	})

	var got string
	var mu sync.Mutex
	unsubscribe := a.Subscribe(func(ev agent.AgentEvent) {
		if ev.Type != agent.EventMessageEnd || ev.Message == nil {
			return
		}
		if text := ev.Message.Text(); text != "" {
			mu.Lock()
			got = text
			mu.Unlock()
		}
	})
	defer unsubscribe()

	if err := a.Prompt("hi"); err != nil {
		fmt.Println("prompt error:", err)
		return
	}
	a.WaitForIdle()

	mu.Lock()
	defer mu.Unlock()
	fmt.Println(got)
}
Output:
Hello, world.

Index

Examples

Constants

View Source
const (
	ThinkingOff     = ai.ThinkingOff
	ThinkingMinimal = ai.ThinkingMinimal
	ThinkingLow     = ai.ThinkingLow
	ThinkingMedium  = ai.ThinkingMedium
	ThinkingHigh    = ai.ThinkingHigh
	ThinkingXHigh   = ai.ThinkingXHigh
	ThinkingMax     = ai.ThinkingMax
)

Re-export ai.ThinkingLevel constants for convenience.

View Source
const AutoResumeMarker = "▶"

AutoResumeMarker is the single-symbol user message the agent loop injects to auto-resume an assistant turn that was killed by a transport/stream error (e.g. "connection reset by peer" mid-stream) rather than a clean stop or tool call. The "play" triangle is an unambiguous, documented signal that the agent resumed the turn automatically — NOT real human input — mapping onto the situation: the turn was paused by the reset, and this presses play to resume it. U+25B6 (no variation selector) is a single code point that renders reliably across terminals, log files, and JSON transcripts.

Variables

CanonicalThinkingLadder lists thinking levels from highest to lowest. ClampThinkingLevel walks down this ladder when the requested level is not available for a given model.

View Source
var DefaultStreamFn func(ctx context.Context) StreamFn

DefaultStreamFn is consulted when an Agent's per-instance StreamFn is nil. Host applications install a factory here that closes over their provider registry; external consumers of this package either pass StreamFn explicitly or set DefaultStreamFn themselves.

The factory receives the call-site context.Context so the closure it returns can thread cancellation through to the provider stream.

A nil DefaultStreamFn plus a nil per-call StreamFn yields a "no stream function configured" error from Prompt / SimplePrompt.

Functions

func DefaultConvertToLLM

func DefaultConvertToLLM(messages []AgentMessage) ([]ai.Message, error)

DefaultConvertToLLM keeps only LLM-compatible messages.

func IsCanonicalThinkingLevel

func IsCanonicalThinkingLevel(l ThinkingLevel) bool

IsCanonicalThinkingLevel reports whether l is one of the recognised thinking levels (off, minimal, low, medium, high, xhigh, max).

func ToAIThinkingLevel

func ToAIThinkingLevel(t ThinkingLevel) ai.ThinkingLevel

ToAIThinkingLevel converts a ThinkingLevel to the ai-layer value. Returns empty string for "off" (off means no thinking).

func ValidPriority

func ValidPriority(p string) bool

ValidPriority reports whether p is a known plan entry priority.

func ValidStatus

func ValidStatus(s string) bool

ValidStatus reports whether s is a known plan entry status.

Types

type Agent

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

Agent orchestrates the agent loop with state management and event dispatch.

func NewAgent

func NewAgent(opts AgentOptions) *Agent

NewAgent creates a new Agent with the given options.

func (*Agent) Abort

func (a *Agent) Abort()

Abort cancels the current streaming operation.

func (*Agent) AppendMessage

func (a *Agent) AppendMessage(m AgentMessage)

AppendMessage appends a message.

func (*Agent) ClearAllQueues

func (a *Agent) ClearAllQueues()

ClearAllQueues clears both steering and follow-up queues.

func (*Agent) ClearFollowUpQueue

func (a *Agent) ClearFollowUpQueue()

ClearFollowUpQueue clears the follow-up queue.

func (*Agent) ClearMessages

func (a *Agent) ClearMessages()

ClearMessages clears all messages.

func (*Agent) ClearSteeringQueue

func (a *Agent) ClearSteeringQueue()

ClearSteeringQueue clears the steering queue.

func (*Agent) Continue

func (a *Agent) Continue() error

Continue resumes from the current context (retries, queued messages).

func (*Agent) FollowUp

func (a *Agent) FollowUp(m AgentMessage)

FollowUp queues a follow-up message for after the agent finishes.

func (*Agent) FollowUpQueueLen

func (a *Agent) FollowUpQueueLen() int

FollowUpQueueLen returns the number of queued follow-up messages.

func (*Agent) GetAndClearFollowUpQueue

func (a *Agent) GetAndClearFollowUpQueue() []AgentMessage

GetAndClearFollowUpQueue atomically returns and clears the follow-up queue.

func (*Agent) GetFollowUpMode

func (a *Agent) GetFollowUpMode() string

GetFollowUpMode returns the current follow-up mode.

func (*Agent) GetMaxRetryDelayMs

func (a *Agent) GetMaxRetryDelayMs() *int

GetMaxRetryDelayMs returns the current max retry delay.

func (*Agent) GetSessionID

func (a *Agent) GetSessionID() string

GetSessionID returns the current session ID.

func (*Agent) GetSteeringMode

func (a *Agent) GetSteeringMode() string

GetSteeringMode returns the current steering mode.

func (*Agent) GetThinkingBudgets

func (a *Agent) GetThinkingBudgets() *ai.ThinkingBudgets

GetThinkingBudgets returns the current thinking budgets.

func (*Agent) GetTransport

func (a *Agent) GetTransport() ai.Transport

GetTransport returns the current preferred transport.

func (*Agent) HasQueuedMessages

func (a *Agent) HasQueuedMessages() bool

HasQueuedMessages returns true if there are any queued messages.

func (*Agent) IdleChan

func (a *Agent) IdleChan() <-chan struct{}

IdleChan returns a channel that is closed when the agent is idle (not currently processing a prompt). Unlike WaitForIdle it composes with select, so callers can race agent idleness against their own cancellation or timeout:

select {
case <-a.IdleChan():
case <-ctx.Done():
}

A freshly-created agent that has never run reads as idle (the returned channel is already closed). While a run is in flight the channel is open and is closed when the run completes.

func (*Agent) PeekFollowUpQueue

func (a *Agent) PeekFollowUpQueue() []AgentMessage

PeekFollowUpQueue returns a snapshot of the follow-up queue without modifying it.

func (*Agent) Prompt

func (a *Agent) Prompt(input string) error

Prompt sends a text prompt to the agent.

func (*Agent) PromptMessages

func (a *Agent) PromptMessages(messages []AgentMessage) error

PromptMessages sends agent messages as a prompt.

func (*Agent) RemoveFollowUp

func (a *Agent) RemoveFollowUp(index int) (AgentMessage, bool)

RemoveFollowUp removes and returns the message at the given 0-based index. Returns the message and true if found, zero value and false otherwise.

func (*Agent) ReplaceMessages

func (a *Agent) ReplaceMessages(msgs []AgentMessage)

ReplaceMessages replaces all messages.

func (*Agent) Reset

func (a *Agent) Reset()

Reset clears all state except system prompt and model.

func (*Agent) SetCompaction

func (a *Agent) SetCompaction(c *ai.AnthropicCompaction)

SetCompaction updates the Anthropic server-side compaction settings.

func (*Agent) SetFollowUpMode

func (a *Agent) SetFollowUpMode(mode string)

SetFollowUpMode sets the follow-up mode.

func (*Agent) SetMaxRetryDelayMs

func (a *Agent) SetMaxRetryDelayMs(ms *int)

SetMaxRetryDelayMs sets the max retry delay.

func (*Agent) SetModel

func (a *Agent) SetModel(m *ai.Model)

SetModel sets the model.

func (*Agent) SetServerTools

func (a *Agent) SetServerTools(tools []ai.AnthropicServerTool)

SetServerTools updates the Anthropic server-side tools (web search, code execution, etc.).

func (*Agent) SetSessionID

func (a *Agent) SetSessionID(id string)

SetSessionID sets the session ID for provider caching.

func (*Agent) SetSteeringMode

func (a *Agent) SetSteeringMode(mode string)

SetSteeringMode sets the steering mode.

func (*Agent) SetStreamFn

func (a *Agent) SetStreamFn(fn StreamFn)

SetStreamFn overrides the stream function used for LLM calls.

func (*Agent) SetSystemPrompt

func (a *Agent) SetSystemPrompt(prompt string)

SetSystemPrompt sets the system prompt.

func (*Agent) SetThinkingBudgets

func (a *Agent) SetThinkingBudgets(tb *ai.ThinkingBudgets)

SetThinkingBudgets sets custom thinking budgets.

func (*Agent) SetThinkingLevel

func (a *Agent) SetThinkingLevel(level ThinkingLevel)

SetThinkingLevel sets the thinking level.

func (*Agent) SetTransport

func (a *Agent) SetTransport(t ai.Transport)

SetTransport sets the preferred transport.

func (*Agent) SimplePrompt

func (a *Agent) SimplePrompt(ctx context.Context, messages []AgentMessage, opts *SimplePromptOptions) (string, error)

SimplePrompt makes a single-turn LLM call with the given messages. See SimplePromptStream for the full contract — this is a thin wrapper that drops streaming events on the floor.

NO-COMPACTION CONTRACT: SimplePrompt MUST NOT trigger auto-compaction, ever. See SimplePromptStream's contract for the same guarantee.

Example

ExampleAgent_SimplePrompt shows the non-streaming one-shot variant that just returns the final assistant text. Useful for batch jobs or for embedding the agent inside a larger non-interactive flow.

package main

import (
	"context"
	"fmt"
	"sync"
	"time"

	"github.com/kfet/agent"
	"github.com/kfet/ai"
)

// fakeStreamFn returns a StreamFn that replays the given assistant
// messages, one per call, looping on the last response after the
// canned list is exhausted.
func fakeStreamFn(responses ...*ai.AssistantMessage) agent.StreamFn {
	var mu sync.Mutex
	idx := 0
	return func(_ *ai.Model, _ ai.Context, _ *ai.SimpleStreamOptions) *ai.AssistantMessageEventStream {
		mu.Lock()
		msg := responses[idx]
		if idx < len(responses)-1 {
			idx++
		}
		mu.Unlock()
		s := ai.NewAssistantMessageEventStream()
		go func() {
			s.Push(ai.AssistantMessageEvent{Type: ai.EventStart, Partial: msg})
			s.Push(ai.AssistantMessageEvent{Type: ai.EventDone, Reason: msg.StopReason, Message: msg})
			s.End(nil)
		}()
		return s
	}
}

// exampleModel returns a Model wired up for Anthropic Messages so the
// examples compile against a realistic shape. No HTTP is involved —
// the StreamFn is faked.
func exampleModel() *ai.Model {
	return &ai.Model{
		ID:            "example-model",
		Name:          "Example Model",
		API:           ai.APIAnthropicMessages,
		Provider:      ai.ProviderAnthropic,
		ContextWindow: 200000,
		MaxTokens:     4096,
	}
}

func textResponse(text string) *ai.AssistantMessage {
	return &ai.AssistantMessage{
		Role:       ai.RoleAssistant,
		Content:    []ai.AssistantContent{ai.NewTextContent(text)},
		API:        ai.APIAnthropicMessages,
		Provider:   ai.ProviderAnthropic,
		Model:      "example-model",
		StopReason: ai.StopReasonStop,
		Timestamp:  time.Now().UnixMilli(),
	}
}

func main() {
	a := agent.NewAgent(agent.AgentOptions{
		Model:    exampleModel(),
		StreamFn: fakeStreamFn(textResponse("42")),
	})

	out, err := a.SimplePrompt(context.Background(), []agent.AgentMessage{
		{Message: ai.NewUserMsg("What is the answer?", time.Now().UnixMilli())},
	}, nil)
	if err != nil {
		fmt.Println("simple prompt error:", err)
		return
	}
	fmt.Println(out)
}
Output:
42

func (*Agent) SimplePromptStream

func (a *Agent) SimplePromptStream(ctx context.Context, messages []AgentMessage, opts *SimplePromptOptions, onEvent func(AgentEvent)) (string, *ai.AssistantMessage, error)

SimplePromptStream makes a single-turn LLM call with the given messages and forwards each agent event to onEvent as it is emitted. Behavior is otherwise identical to SimplePrompt:

  • Reuses the agent's model, streamFn, api key resolution, and transport config but sends no tools, runs no agent loop, and does not modify the agent's state. The caller provides the full message list.
  • Safe to call concurrently while the agent loop is running.

onEvent may be nil — events are then discarded. Callbacks are invoked synchronously on the same goroutine that drains the stream, so callers must keep their work cheap (the next event blocks until the callback returns).

Returns the rendered text, the final assistant message (or nil on error), and any error. On "no usable content" the error string includes a per-block summary so callers can diagnose redacted/empty responses without losing the raw message.

NO-COMPACTION CONTRACT: SimplePromptStream MUST NOT trigger auto-compaction. This is guaranteed by two design choices that must be preserved:

  1. The AgentLoopConfig built here intentionally omits the Compaction field, so no server-side compaction is requested.
  2. The events channel is a private, local channel drained synchronously by this function — events never reach AgentSession.checkAutoCompaction.

Do not forward these events to the session or add Compaction to the config.

func (*Agent) State

func (a *Agent) State() AgentState

State returns the current agent state. The caller should not modify it.

func (*Agent) Steer

func (a *Agent) Steer(m AgentMessage)

Steer queues a steering message to interrupt the agent mid-run.

func (*Agent) Subscribe

func (a *Agent) Subscribe(fn func(AgentEvent)) func()

Subscribe registers an event listener. Returns an unsubscribe function.

func (*Agent) UpdateTools

func (a *Agent) UpdateTools(fn func(ts *ToolSet))

UpdateTools applies fn to the agent's ToolSet under the agent lock. This is the safe way to mutate tools — the callback sees the current state and all changes are atomic. No stale snapshots, no clobbering.

func (*Agent) WaitForIdle

func (a *Agent) WaitForIdle()

WaitForIdle blocks until the agent finishes processing. It is a thin convenience wrapper around IdleChan.

type AgentContext

type AgentContext struct {
	SystemPrompt string
	Messages     []AgentMessage
	Tools        *ToolSet
}

AgentContext is like ai.Context but uses AgentTool.

type AgentEvent

type AgentEvent struct {
	Type AgentEventType

	// For agent_end
	Messages []AgentMessage

	// For turn_end
	TurnMessage *AgentMessage
	ToolResults []ai.ToolResultMessage

	// For message_start, message_update, message_end
	Message *AgentMessage

	// For message_update
	AssistantMessageEvent *ai.AssistantMessageEvent

	// For tool_execution_start, tool_execution_update, tool_execution_end
	ToolCallID  string
	ToolName    string
	Args        any
	DisplayHint *ToolDisplayHint

	// For tool_execution_update
	PartialResult any
	StatusMessage string // progress message from extensions (e.g. "Calling Read...")

	// For tool_execution_end
	Result  any
	IsError bool

	// For stream_retry
	RetryAttempt int    // 1-based attempt number of the upcoming retry
	ErrorMessage string // the stream error that triggered the retry
}

AgentEvent represents a lifecycle event from the agent.

type AgentEventType

type AgentEventType string

AgentEventType identifies the type of agent lifecycle event.

const (
	EventAgentStart          AgentEventType = "agent_start"
	EventAgentEnd            AgentEventType = "agent_end"
	EventTurnStart           AgentEventType = "turn_start"
	EventTurnEnd             AgentEventType = "turn_end"
	EventMessageStart        AgentEventType = "message_start"
	EventMessageUpdate       AgentEventType = "message_update"
	EventMessageEnd          AgentEventType = "message_end"
	EventToolExecutionStart  AgentEventType = "tool_execution_start"
	EventToolExecutionUpdate AgentEventType = "tool_execution_update"
	EventToolExecutionEnd    AgentEventType = "tool_execution_end"
	// EventStreamRetry is emitted when the agent loop detects a mid-tool-call
	// stream error (stop_reason=error with an incomplete tool_use block whose
	// Arguments never finished streaming) and is about to retry the request
	// after dropping the broken partial from history.
	EventStreamRetry AgentEventType = "stream_retry"
	// EventAutoResume is emitted when an assistant turn ends with a transport/
	// stream error (connection reset, broken pipe, unexpected EOF, …) rather
	// than a clean stop or tool call, and the agent loop is auto-resuming the
	// turn instead of pausing for a human. RetryAttempt is the 1-based resume
	// number and ErrorMessage is the transport error that triggered it. When a
	// partial response had already been emitted, the resume injects the
	// AutoResumeMarker user message so the model continues cleanly.
	EventAutoResume AgentEventType = "auto_resume"
)

type AgentLoopConfig

type AgentLoopConfig struct {
	ai.SimpleStreamOptions

	// Model is the LLM model to use.
	Model *ai.Model

	// ConvertToLLM converts AgentMessages to LLM-compatible Messages before each call.
	ConvertToLLM func(messages []AgentMessage) ([]ai.Message, error)

	// TransformContext is an optional transform applied before ConvertToLLM.
	// Use for context window management, injecting external context, etc.
	TransformContext func(ctx context.Context, messages []AgentMessage) ([]AgentMessage, error)

	// GetAPIKey resolves an API key dynamically for each LLM call.
	// Useful for short-lived OAuth tokens that may expire during tool execution.
	GetAPIKey func(provider string) (string, error)

	// GetSteeringMessages returns steering messages to inject mid-run.
	// Called after the current assistant turn finishes executing its tool calls,
	// unless ShouldStopAfterTurn exits first.
	// Tool calls from the current assistant message are not skipped.
	//
	// Contract: must not return an error. Return nil/empty when no steering messages are available.
	GetSteeringMessages func() ([]AgentMessage, error)

	// ShouldStopAfterTurn is called after each turn fully completes and the
	// turn_end event has been emitted. If it returns true, the loop emits
	// agent_end and exits before polling steering or follow-up queues, without
	// starting another LLM call.
	//
	// Use this to request a graceful stop after the current turn, e.g. before
	// context gets too full.
	//
	// Contract: must not panic. Panicking interrupts the agent loop without
	// producing a normal event sequence.
	ShouldStopAfterTurn func(ctx ShouldStopAfterTurnContext) bool

	// GetFollowUpMessages returns follow-up messages after the agent would otherwise stop.
	GetFollowUpMessages func() ([]AgentMessage, error)

	// Reasoning specifies the thinking/reasoning level.
	Reasoning ai.ThinkingLevel

	// SessionID is the unique identifier for this session.
	SessionID string

	// ThinkingBudgets specifies token budgets for thinking.
	ThinkingBudgets *ai.ThinkingBudgets

	// Transport is the preferred transport for providers that support multiple transports.
	Transport ai.Transport

	// MaxRetryDelayMs is the maximum delay between retries in milliseconds.
	MaxRetryDelayMs *int

	// ServerTools configures Anthropic server-side tools (web search, code execution, etc.).
	// Only used when the model provider is Anthropic.
	ServerTools []ai.AnthropicServerTool

	// Compaction configures Anthropic server-side context compaction.
	Compaction *ai.AnthropicCompaction

	// OnPayload is an optional callback to inspect or replace provider payloads before sending.
	// Return nil to keep the original payload unchanged.
	OnPayload func(payload any, model *ai.Model) any

	// OnRetry is invoked before a retryable pre-stream error (rate limit /
	// overloaded / transient 5xx) is retried. Sessions can use this to notify
	// the user that a retry is in flight.
	OnRetry func(attempt int, delaySeconds float64, errMsg string)
}

AgentLoopConfig configures the agent loop.

type AgentMessage

type AgentMessage struct {
	ai.Message
	// Custom holds extension-defined message types (e.g., BashExecutionMessage).
	// When non-nil, the Message field may be empty and Custom determines the role.
	Custom any `json:"custom,omitempty"`
}

AgentMessage is a message in the agent's conversation. It wraps an ai.Message and can be extended with custom message types.

func AgentLoop

func AgentLoop(
	ctx context.Context,
	prompts []AgentMessage,
	agentCtx *AgentContext,
	config *AgentLoopConfig,
	streamFn StreamFn,
	events chan<- AgentEvent,
) []AgentMessage

AgentLoop starts an agent loop with new prompt messages. Events are emitted to the returned channel.

func AgentLoopContinue

func AgentLoopContinue(
	ctx context.Context,
	agentCtx *AgentContext,
	config *AgentLoopConfig,
	streamFn StreamFn,
	events chan<- AgentEvent,
) ([]AgentMessage, error)

AgentLoopContinue continues an agent loop from the current context. Used for retries where context already has user message or tool results.

func NewAgentMessage

func NewAgentMessage(msg ai.Message) AgentMessage

NewAgentMessage wraps an ai.Message as an AgentMessage.

func StripUnmatchedToolCalls

func StripUnmatchedToolCalls(msgs []AgentMessage) []AgentMessage

StripUnmatchedToolCalls returns a copy of msgs with every assistant tool-call content block removed when no ToolResult message in msgs carries a matching ToolCallID. Assistant messages left with no content blocks are dropped entirely. All other messages pass through unchanged, and the input slice and its messages are never mutated.

This sanitizes a message snapshot for a one-shot side query. The snapshot is taken from live session state, which — because the assistant turn is committed on EventMessageEnd before its tools execute — can end with an in-flight tool call that has no result yet (notably the very `aside` invocation driving the side query). Appending a user question after such a dangling tool_use produces a malformed context: the model role-plays a continuation of the executor's turn (e.g. narrating that the tool "failed") instead of answering the question. Stripping the unmatched calls yields a well-formed context that ends on a complete turn.

func (*AgentMessage) Text

func (m *AgentMessage) Text() string

Text returns the concatenated text of an assistant message's text content blocks (joined without separators, in source order), or "" if the message is not an assistant message or carries no text. It is the smallest viable way to pull the rendered answer out of an EventMessageEnd payload without walking AsAssistant().Content by hand.

type AgentOptions

type AgentOptions struct {
	// Model is the LLM model the agent runs on. Convenience field lifted
	// from AgentState — see InitialState for the precedence rules.
	Model *ai.Model

	// SystemPrompt is the agent's system prompt. Convenience field lifted
	// from AgentState — see InitialState for the precedence rules.
	SystemPrompt string

	// ThinkingLevel is the agent's reasoning level. Convenience field lifted
	// from AgentState — see InitialState for the precedence rules.
	ThinkingLevel ThinkingLevel

	// Tools is the agent's tool set. Convenience field lifted from
	// AgentState — see InitialState for the precedence rules.
	Tools *ToolSet

	// InitialState restores a full AgentState (bulk restore, e.g. replaying a
	// snapshot). It is layered ON TOP of the convenience fields above, so any
	// field set on InitialState wins over the matching convenience field.
	// Leave it nil and use the convenience fields for the common case.
	InitialState *AgentState

	// ConvertToLLM converts AgentMessages to LLM Messages before each call.
	// Defaults to DefaultConvertToLLM (filters to user/assistant/toolResult)
	// when nil — callers only set this for exotic context shaping.
	ConvertToLLM func(messages []AgentMessage) ([]ai.Message, error)

	// TransformContext is applied before ConvertToLLM for context pruning etc.
	TransformContext func(ctx context.Context, messages []AgentMessage) ([]AgentMessage, error)

	// SteeringMode: "all" = send all steering messages at once, "one-at-a-time" = one per turn.
	SteeringMode string

	// FollowUpMode: "all" = send all follow-up messages at once, "one-at-a-time" = one per turn.
	FollowUpMode string

	// StreamFn is a custom stream function. When nil, the agent falls
	// back to DefaultStreamFn.
	StreamFn StreamFn

	// SessionID is forwarded to LLM providers for session-based caching.
	SessionID string

	// GetAPIKey resolves an API key dynamically for each LLM call.
	GetAPIKey func(provider string) (string, error)

	// ThinkingBudgets sets custom token budgets for thinking levels.
	ThinkingBudgets *ai.ThinkingBudgets

	// Transport is the preferred transport for providers that support multiple transports.
	Transport ai.Transport

	// MaxRetryDelayMs caps how long to wait for server-requested retries.
	MaxRetryDelayMs *int

	// ServerTools configures Anthropic server-side tools (web search, code execution, etc.).
	ServerTools []ai.AnthropicServerTool

	// Compaction configures Anthropic server-side context compaction.
	Compaction *ai.AnthropicCompaction

	// OnPayload is an optional callback to inspect or replace provider payloads before sending.
	// Return nil to keep the original payload unchanged.
	OnPayload func(payload any, model *ai.Model) any

	// OnRetry is invoked before a retryable pre-stream error is retried.
	OnRetry func(attempt int, delaySeconds float64, errMsg string)
}

AgentOptions configures an Agent.

type AgentState

type AgentState struct {
	SystemPrompt     string
	Model            *ai.Model
	ThinkingLevel    ThinkingLevel
	Tools            *ToolSet
	Messages         []AgentMessage
	IsStreaming      bool
	StreamMessage    *AgentMessage
	PendingToolCalls map[string]bool
	Error            string
}

AgentState holds the current state of the agent.

type AgentTool

type AgentTool struct {
	ai.Tool

	// Label is a human-readable label for UI display.
	Label string

	// DisplayHint tells the TUI how to format this tool's execution.
	// Nil means use built-in formatting or the generic fallback.
	DisplayHint *ToolDisplayHint

	// Execute runs the tool. The context can be cancelled for abort.
	Execute func(
		ctx context.Context,
		toolCallID string,
		params map[string]any,
		onUpdate AgentToolUpdateCallback,
	) (AgentToolResult, error)
}

AgentTool extends ai.Tool with execution capability.

type AgentToolResult

type AgentToolResult struct {
	// Content blocks supporting text and images.
	Content []ai.ToolResultContent
	// Details for UI display or logging.
	Details any
	// Meta is small, structured metadata the LLM should see alongside the
	// content (e.g. a content hash). Copied onto ToolResultMessage.Meta and
	// rendered for the provider-bound message only — internal consumers
	// that join content blocks never see it.
	Meta map[string]string
	// IsError signals that the tool result represents an error,
	// even when Execute returns a nil error. Used by extension hooks
	// to mark a modified result as an error.
	IsError bool
	// Terminate hints that the agent should stop after the current tool batch.
	// Early termination only happens when every finalized tool result in the batch
	// sets this to true.
	Terminate bool
	// StatusMessage is a transient progress label for the UI (e.g.
	// "Calling Read..."). It is only meaningful on partial-update
	// results and never persisted.
	StatusMessage string
}

AgentToolResult is the result of executing a tool.

type AgentToolUpdateCallback

type AgentToolUpdateCallback func(partialResult AgentToolResult)

AgentToolUpdateCallback is called during streaming tool execution.

type BlockSummary

type BlockSummary struct {
	Type   string `json:"type"`
	Len    int    `json:"len"`
	SigLen int    `json:"sig_len,omitempty"`
}

BlockSummary is a compact description of a single content block from an assistant message. It carries enough to diagnose "empty" / redacted responses (where a thinking block with sig_len>0 and len=0 is the smoking gun) without keeping the raw payload around.

func SummarizeBlocks

func SummarizeBlocks(content []ai.AssistantContent) []BlockSummary

SummarizeBlocks produces a BlockSummary slice for the given content. Exported so session-layer code can attach the same summary to a SideQueryResult on success.

type PlanEntry

type PlanEntry struct {
	Content  string            `json:"content"`
	Status   PlanEntryStatus   `json:"status"`
	Priority PlanEntryPriority `json:"priority"`
}

PlanEntry represents a single entry in a plan.

type PlanEntryPriority

type PlanEntryPriority string

PlanEntryPriority represents the priority of a plan entry.

const (
	PlanEntryPriorityHigh   PlanEntryPriority = "high"
	PlanEntryPriorityMedium PlanEntryPriority = "medium"
	PlanEntryPriorityLow    PlanEntryPriority = "low"
)

type PlanEntryStatus

type PlanEntryStatus string

PlanEntryStatus represents the status of a plan entry.

const (
	PlanEntryStatusPending    PlanEntryStatus = "pending"
	PlanEntryStatusInProgress PlanEntryStatus = "in_progress"
	PlanEntryStatusCompleted  PlanEntryStatus = "completed"
)

type ShouldStopAfterTurnContext

type ShouldStopAfterTurnContext struct {
	// Message is the assistant message that completed the turn.
	Message *ai.AssistantMessage
	// ToolResults are the tool result messages passed to the preceding turn_end event.
	ToolResults []ai.ToolResultMessage
	// Context is the current agent context after the turn's assistant message
	// and tool results have been appended.
	Context AgentContext
	// NewMessages are the messages that this loop invocation will return if it
	// exits at this point. Prompt runs include the initial prompt messages;
	// continuation runs do not include pre-existing context messages.
	NewMessages []AgentMessage
}

ShouldStopAfterTurnContext is the context passed to AgentLoopConfig.ShouldStopAfterTurn.

type SimplePromptOptions

type SimplePromptOptions struct {
	// Model overrides the LLM model used for this call.
	// When non-nil, the provider is implied by Model.Api and the appropriate
	// API key is resolved via the agent's GetAPIKey for that provider.
	Model *ai.Model

	// Reasoning overrides the thinking/reasoning effort.
	// Empty string ("") inherits the agent's current ThinkingLevel.
	// Use ai.ThinkingOff explicitly to disable thinking for this call.
	Reasoning ai.ThinkingLevel
}

SimplePromptOptions overrides per-call settings for SimplePrompt. All fields are optional; nil/empty values inherit the agent's current state.

Used to support "advisor" patterns where a side query is routed to a different (typically stronger) model than the executor agent is running on.

type StreamFn

type StreamFn func(model *ai.Model, ctx ai.Context, options *ai.SimpleStreamOptions) *ai.AssistantMessageEventStream

StreamFn is the function that creates an LLM streaming call.

type ThinkingLevel

type ThinkingLevel = ai.ThinkingLevel

ThinkingLevel is an alias for ai.ThinkingLevel so all packages use the same type.

func ClampThinkingLevel

func ClampThinkingLevel(requested ThinkingLevel, available []ThinkingLevel) ThinkingLevel

ClampThinkingLevel returns the highest level in `available` that is at or below `requested` on the canonical ladder (max→xhigh→high→medium→low→minimal→off).

If `requested` is empty, it returns "" unchanged (callers treat this as "no opinion, leave the current level alone").

If `available` is empty, it falls back to ThinkingOff.

If `requested` is not on the canonical ladder, it is returned unchanged when present in `available`, otherwise ThinkingOff is returned.

Example

ExampleClampThinkingLevel shows how a host clamps a requested reasoning level to whatever the underlying model supports. The canonical ladder is max → xhigh → high → medium → low → minimal → off.

package main

import (
	"fmt"

	"github.com/kfet/agent"
)

func main() {
	// A model that supports up to "high".
	available := []agent.ThinkingLevel{
		agent.ThinkingOff, agent.ThinkingLow, agent.ThinkingMedium, agent.ThinkingHigh,
	}
	fmt.Println(agent.ClampThinkingLevel(agent.ThinkingMax, available))
	fmt.Println(agent.ClampThinkingLevel(agent.ThinkingMedium, available))
	fmt.Println(agent.ClampThinkingLevel("", available))
}
Output:
high
medium

type TitleArg

type TitleArg struct {
	// Name is the JSON parameter name.
	Name string `json:"name"`
	// Style controls how the value is rendered: "path" shortens and accents
	// it, "pattern" wraps it in /…/, "accent" just colours it.  Empty string
	// means plain text.
	Style string `json:"style,omitempty"`
	// Label is an optional prefix shown before the value (e.g. "in").
	Label string `json:"label,omitempty"`
}

TitleArg describes a single argument to display on the tool header line.

type ToolClassification

type ToolClassification struct {
	Builtin    []string            // sorted built-in tool names
	Extensions map[string][]string // extension name → sorted tool names
}

ToolClassification holds tools grouped into built-in and per-extension buckets. MCP tools (prefixed "mcp__") are excluded.

type ToolDisplayHint

type ToolDisplayHint struct {
	// TitleArgs lists argument names to show on the header line, in order.
	TitleArgs []TitleArg `json:"title_args,omitempty"`
	// ResultMaxLines is the default number of result lines shown when
	// collapsed.  Zero means use the default (10).
	ResultMaxLines int `json:"result_max_lines,omitempty"`
	// UseBox renders the tool output in a bordered box (like bash).
	UseBox bool `json:"use_box,omitempty"`
}

ToolDisplayHint tells the UI how to format a tool's execution display. Extensions provide this when registering tools so the TUI can render them nicely instead of falling back to a raw JSON dump.

type ToolSet

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

ToolSet is an ordered, name-unique collection of AgentTool values. Adding a tool with a name that already exists overwrites the previous entry (keeping insertion order of the *first* occurrence). This makes duplicate tool names structurally impossible.

All read methods are nil-safe: calling them on a nil *ToolSet returns zero values rather than panicking.

func NewToolSet

func NewToolSet() *ToolSet

NewToolSet creates an empty ToolSet.

func ToolSetFrom

func ToolSetFrom(tools []AgentTool) *ToolSet

ToolSetFrom builds a ToolSet from a slice of tools. If the slice contains duplicate names, the last entry wins.

func (*ToolSet) Add

func (ts *ToolSet) Add(t AgentTool)

Add inserts or replaces a tool. If a tool with the same name already exists, the definition is updated in place without changing order.

func (*ToolSet) ClassifyTools

func (ts *ToolSet) ClassifyTools(extensionTools map[string][]string) ToolClassification

ClassifyTools partitions the tool set into built-in and per-extension groups, excluding MCP tools. extensionTools maps extension name → tool names and is used to attribute tools to their owning extension; any non-MCP tool not in the map is considered built-in. Both the built-in list and per-extension lists are sorted alphabetically.

func (*ToolSet) Clone

func (ts *ToolSet) Clone() *ToolSet

Clone returns a deep copy of the ToolSet. Returns nil if ts is nil.

func (*ToolSet) Get

func (ts *ToolSet) Get(name string) (AgentTool, bool)

Get returns the tool with the given name and true, or zero value and false.

func (*ToolSet) Has

func (ts *ToolSet) Has(name string) bool

Has reports whether a tool with the given name exists.

func (*ToolSet) Len

func (ts *ToolSet) Len() int

Len returns the number of tools.

func (*ToolSet) Names

func (ts *ToolSet) Names() []string

Names returns tool names in insertion order.

func (*ToolSet) Remove

func (ts *ToolSet) Remove(name string)

Remove deletes a tool by name. No-op if the name doesn't exist or ts is nil.

func (*ToolSet) Slice

func (ts *ToolSet) Slice() []AgentTool

Slice returns a copy of the tools in insertion order.

Directories

Path Synopsis
Package tools provides the standard coding toolbox for the agent runtime: bash, read, write, edit, editdiff, find, grep, imageresize, and plan.
Package tools provides the standard coding toolbox for the agent runtime: bash, read, write, edit, editdiff, find, grep, imageresize, and plan.

Jump to

Keyboard shortcuts

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