// Package agent implements the core agent loop.
package agent

import (
	"context"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"log/slog"
	"sort"
	"strconv"
	"strings"
	"sync"
	"time"

	"github.com/google/uuid"
	"github.com/nugget/thane-ai-agent/internal/integrations/homeassistant"
	"github.com/nugget/thane-ai-agent/internal/model/fleet"
	"github.com/nugget/thane-ai-agent/internal/model/llm"
	"github.com/nugget/thane-ai-agent/internal/model/promptfmt"
	"github.com/nugget/thane-ai-agent/internal/model/prompts"
	"github.com/nugget/thane-ai-agent/internal/model/router"
	"github.com/nugget/thane-ai-agent/internal/model/talents"
	"github.com/nugget/thane-ai-agent/internal/model/toolcatalog"
	"github.com/nugget/thane-ai-agent/internal/platform/config"
	"github.com/nugget/thane-ai-agent/internal/platform/events"
	"github.com/nugget/thane-ai-agent/internal/platform/logging"
	"github.com/nugget/thane-ai-agent/internal/platform/provenance"
	"github.com/nugget/thane-ai-agent/internal/platform/scheduler"
	"github.com/nugget/thane-ai-agent/internal/platform/usage"
	"github.com/nugget/thane-ai-agent/internal/runtime/agentctx"
	"github.com/nugget/thane-ai-agent/internal/runtime/iterate"
	"github.com/nugget/thane-ai-agent/internal/runtime/loop"
	"github.com/nugget/thane-ai-agent/internal/state/awareness"
	"github.com/nugget/thane-ai-agent/internal/state/memory"
	"github.com/nugget/thane-ai-agent/internal/tools"
)

// Message represents a chat message.
type Message struct {
	Role    string             `json:"role"` // system, user, assistant
	Content string             `json:"content"`
	Images  []llm.ImageContent `json:"-"`
}

// Request represents an incoming agent request.
type Request struct {
	Messages        []Message              `json:"messages"`
	Model           string                 `json:"model,omitempty"`
	ConversationID  string                 `json:"conversation_id,omitempty"`
	ChannelBinding  *memory.ChannelBinding `json:"channel_binding,omitempty"`
	Hints           map[string]string      `json:"hints,omitempty"` // Routing hints (channel, mission, etc.)
	SkipContext     bool                   `json:"-"`               // Skip memory, tools, and context injection (for lightweight completions)
	AllowedTools    []string               `json:"-"`               // Optional allowlist of tools visible for this run
	ExcludeTools    []string               `json:"-"`               // Tool names to exclude from this run (e.g., lifecycle tools for recurring wakes)
	SkipTagFilter   bool                   `json:"-"`               // Bypass capability tag filtering (for self-scoping contexts like metacognitive)
	InitialTags     []string               `json:"-"`               // Tags to activate at Run start (carried forward from previous loop iterations)
	RuntimeTags     []string               `json:"-"`               // Trusted runtime-asserted tags pinned for this run only
	RuntimeTools    []*tools.Tool          `json:"-"`               // Request-scoped tools visible only to this run
	MaxIterations   int                    `json:"-"`               // Optional per-request iteration cap (0 = default)
	MaxOutputTokens int                    `json:"-"`               // Optional output-token budget across all iterations (0 = unlimited)
	ToolTimeout     time.Duration          `json:"-"`               // Optional per-tool timeout (0 = no extra timeout)
	UsageRole       string                 `json:"-"`               // Optional usage role override (e.g., "delegate")
	UsageTaskName   string                 `json:"-"`               // Optional usage task name override
	FallbackContent string                 `json:"-"`               // Optional static fallback text when the run yields no content
	PromptMode      agentctx.PromptMode    `json:"-"`               // Optional system-prompt shape override.

	// SystemPrompt, when non-empty, replaces the output of
	// buildSystemPrompt(). Used by callers that assemble their own
	// prompt context externally.
	SystemPrompt string `json:"-"`

	// SuppressAlwaysContext drops the always-on bucket from the
	// system-prompt assembler's context output. Default false matches
	// main-loop behavior (include presence, episodic memory, working
	// memory, notification history, etc.). Delegates set true so the
	// child agent sees only tag-scoped context appropriate to the
	// bounded task.
	SuppressAlwaysContext bool `json:"-"`
}

// StreamEvent is a single event in a streaming response.
// Alias to llm.StreamEvent for use by consumers.
type StreamEvent = llm.StreamEvent

// StreamCallback receives streaming events.
// Alias to llm.StreamCallback for compatibility.
type StreamCallback = llm.StreamCallback

// Stream event kinds re-exported for consumers.
const (
	KindToken         = llm.KindToken
	KindToolCallStart = llm.KindToolCallStart
	KindToolCallDone  = llm.KindToolCallDone
	KindDone          = llm.KindDone
	KindLLMResponse   = llm.KindLLMResponse
	KindLLMStart      = llm.KindLLMStart
)

// maxEgoBytes is the maximum size of ego.md content published through
// the core context provider. Content beyond this limit is truncated
// with a marker.
const maxEgoBytes = 16 * 1024

// maxTagContextBytes is the aggregate size limit for all tag context
// files injected into the system prompt. Individual files exceeding
// this threshold are truncated with a marker.
const maxTagContextBytes = 64 * 1024

// storedHistoryGapThreshold is the minimum elapsed time between adjacent
// stored messages before the LLM sees an explicit timeline marker.
const storedHistoryGapThreshold = 30 * time.Minute

// Response is the result of a single agent Run() call. It contains the
// model's final content, token usage counters, and metadata about which
// tools and capability tags were active during execution.
type Response struct {
	Content                  string                              `json:"content"`
	Model                    string                              `json:"model"`
	FinishReason             string                              `json:"finish_reason"`
	InputTokens              int                                 `json:"input_tokens,omitempty"`
	OutputTokens             int                                 `json:"output_tokens,omitempty"`
	CacheCreationInputTokens int                                 `json:"cache_creation_input_tokens,omitempty"`
	CacheReadInputTokens     int                                 `json:"cache_read_input_tokens,omitempty"`
	ContextWindow            int                                 `json:"context_window,omitempty"`
	ToolsUsed                map[string]int                      `json:"tools_used,omitempty"` // tool name → call count
	EffectiveTools           []string                            `json:"effective_tools,omitempty"`
	LoadedCapabilities       []toolcatalog.LoadedCapabilityEntry `json:"loaded_capabilities,omitempty"`
	Iterations               int                                 `json:"iterations,omitempty"`
	Exhausted                bool                                `json:"exhausted,omitempty"`

	// SessionID and RequestID are set by Run() so callers can
	// correlate post-run log lines with the agent loop's context.
	SessionID string `json:"session_id,omitempty"`
	RequestID string `json:"request_id,omitempty"`

	// ActiveTags is the set of capability tags active at the end of
	// the Run. Used by loops to carry forward activations.
	ActiveTags []string `json:"-"`
}

// MemoryStore is the conversation memory backend required by the agent loop.
// It manages per-conversation message history and token accounting so the
// loop can build prompts and enforce context-window limits.
type MemoryStore interface {
	GetMessages(conversationID string) []memory.Message
	AddMessage(conversationID, role, content string) error
	GetTokenCount(conversationID string) int
	Clear(conversationID string) error
	Stats() map[string]any
}

// ToolCallRecorder records tool call lifecycle events for observability.
// When the memory store also implements this interface, the agent loop
// calls RecordToolCall before execution and CompleteToolCall after,
// enabling auditing and debugging of tool interactions.
type ToolCallRecorder interface {
	RecordToolCall(conversationID, messageID, toolCallID, toolName, arguments string) error
	CompleteToolCall(toolCallID, result, errMsg string) error
}

// Compactor handles conversation compaction.
type Compactor interface {
	NeedsCompaction(conversationID string) bool
	Compact(ctx context.Context, conversationID string) error
	// CompactionThreshold returns the token count at which compaction
	// triggers.
	CompactionThreshold() int
}

// FailoverHandler is called before model failover to allow checkpointing.
type FailoverHandler interface {
	// OnFailover is called when switching from one model to another due to failure.
	// Returns an error if failover should be aborted.
	OnFailover(ctx context.Context, fromModel, toModel, reason string) error
}

// ContextRequest is re-exported from [agentctx] so callers within the
// agent package can use the bare type. The canonical definition lives
// in agentctx so context-producing packages can satisfy
// [TagContextProvider] without importing agent (which would cycle).
type ContextRequest = agentctx.ContextRequest

// TagContextProvider supplies live-computed context for the system
// prompt. Implementations either register against a specific
// capability tag (via [Loop.RegisterTagContextProvider]) — in which
// case they fire only when that tag is active — or against the
// always-on bucket (via [Loop.RegisterAlwaysContextProvider]), where
// they fire on every main-loop run but are suppressed for delegate
// runs that pass [agentctx.ContextRequest.IncludeAlways] = false.
//
// Output should follow #458 conventions: delta-annotated timestamps
// via [promptfmt.FormatDelta], machine-first format, pre-computed
// relationships.
type TagContextProvider interface {
	// TagContext returns context to inject. The ctx carries any
	// per-turn timeout (e.g., the shared 2-second HA deadline).
	TagContext(ctx context.Context, req agentctx.ContextRequest) (string, error)
}

// SessionArchiver handles session lifecycle and message archiving.
type SessionArchiver interface {
	// ArchiveConversation archives all messages from a conversation before clearing.
	ArchiveConversation(conversationID string, messages []memory.Message, reason string) error
	// StartSession begins a new session for a conversation.
	StartSession(conversationID string) (sessionID string, err error)
	// EndSession ends the current session.
	EndSession(sessionID string, reason string) error
	// ActiveSessionID returns the current session ID, or empty if none.
	ActiveSessionID(conversationID string) string
	// EnsureSession starts a session if none is active, returns the session ID.
	EnsureSession(conversationID string) string
	// ArchiveIterations copies iteration records to the immutable archive.
	ArchiveIterations(iterations []memory.ArchivedIteration) error
	// LinkPendingIterationToolCalls links archived tool calls to their
	// parent iterations using stored tool_call_ids.
	LinkPendingIterationToolCalls(sessionID string) error
	// OnMessage is called after each message to track session stats.
	OnMessage(conversationID string)
	// ActiveSessionStartedAt returns when the active session began,
	// or the zero time if there is no active session.
	ActiveSessionStartedAt(conversationID string) time.Time
}

// Loop is the core agent execution loop.
type Loop struct {
	logger              *slog.Logger
	memory              MemoryStore
	compactor           Compactor
	router              *router.Router
	llm                 llm.Client
	tools               *tools.Registry
	model               string
	recoveryModel       string        // Fast model for timeout recovery summaries (empty = disabled)
	retryBaseDelay      time.Duration // Base backoff delay between timeout retries (0 = use default)
	persona             string        // Persona content (replaces base system prompt if set)
	coreContextProvider *CoreContextProvider
	timezone            string // IANA timezone for Current Conditions (e.g., "America/Chicago")
	contextWindow       int    // Context window size of default model
	failoverHandler     FailoverHandler
	archiver            SessionArchiver
	extractor           *memory.Extractor
	orchestratorTools   []string                       // Restricted tool set for orchestrator mode (nil = all tools)
	liveRequestRecorder logging.RequestRecordFunc      // nil = no live request detail prefill
	requestRecorder     logging.RequestRecordFunc      // nil = request detail inspection disabled
	usageStore          *usage.Store                   // nil = no usage recording
	pricing             map[string]config.PricingEntry // model→cost for usage recording
	usageCatalog        *fleet.Catalog
	modelRegistry       *fleet.Registry
	modelRuntime        *fleet.Runtime

	// Capability tags — per-Run tool/talent filtering.
	//
	// Each Run() creates its own capabilityScope (stored in context)
	// seeded with always-active + channel-pinned tags. Tool handlers
	// mutate the scope via context, so concurrent Run() calls from
	// different channels are fully isolated.
	capTags       map[string]config.CapabilityTagConfig // tag definitions from config (static)
	parsedTalents []talents.Talent                      // pre-loaded talent structs for tag filtering (static)
	channelTags   map[string][]string                   // channel name → tag names (static)
	contactLookup ContactLookup                         // trust-gated contact profile lookup for origin context
	capTagStore   CapabilityTagStore                    // persists activated tags per conversation (nil = no persistence)
	lensProvider  func() []string                       // returns active global lenses (nil = none)
	capSurface    []toolcatalog.CapabilitySurface       // resolved capability surface for model-facing rendering

	// lastRunTags is a snapshot of the most recent Run()'s active
	// tags, used by the dashboard callback (which has no context).
	lastRunTagsMu sync.Mutex
	lastRunTags   map[string]bool

	// pendingProviders holds context-provider registrations made
	// before SetTagContextAssembler is called. SetTagContextAssembler
	// drains them into the assembler at wiring time. After that, the
	// pending fields are nil and Register* calls forward directly.
	pendingProvidersMu     sync.Mutex
	pendingTagProviders    map[string]TagContextProvider
	pendingAlwaysProviders []TagContextProvider

	// tagContextAssembler builds the Capability Context section from
	// tagged KB articles, tagged providers, and always-on providers.
	// Set via SetTagContextAssembler; nil disables tag context.
	tagContextAssembler *TagContextAssembler

	// haInject resolves <!-- ha-inject: ... --> directives in tag context files.
	haInject homeassistant.StateFetcher

	// nowFunc returns the current time. Tests override this for
	// deterministic output; production code leaves it as time.Now.
	nowFunc func() time.Time
}

// LoopOptions declares everything required or optionally configurable
// at agent loop construction. [NewLoop] validates Logger, Memory, LLM,
// and Model — the minimum a runnable loop needs. The remaining fields
// are configuration-dependent: production runs through internal/app
// supply Compactor, Router, HomeAssistant, Scheduler, ContextWindow,
// and the rest, but the CLI ask path constructs a deliberately
// minimal loop with most fields zero-valued. Treat anything outside
// the four explicitly-validated fields as optional but recommended
// for full functionality.
//
// Late-binding wiring that depends on subsystems initialized after the
// loop itself (capability tags, context providers, channel/extractor
// stores, failover handler) is configured through the loop's grouped
// Configure* methods rather than this struct.
type LoopOptions struct {
	// Required at construction. Validated by NewLoop.
	Logger *slog.Logger
	Memory MemoryStore
	LLM    llm.Client
	Model  string

	// Recommended for production use. Optional for minimal/test loops.
	Compactor     Compactor
	Router        *router.Router
	HomeAssistant *homeassistant.Client
	Scheduler     *scheduler.Scheduler
	ContextWindow int

	// Optional, stable at construction.
	Persona             string
	ParsedTalents       []talents.Talent
	Timezone            string
	RecoveryModel       string
	Archiver            SessionArchiver
	InjectFiles         []string
	EgoFile             string
	ProvenanceStore     *provenance.Store
	HAInject            homeassistant.StateFetcher
	ModelRegistry       *fleet.Registry
	ModelRuntime        *fleet.Runtime
	LiveRequestRecorder logging.RequestRecordFunc
	RequestRecorder     logging.RequestRecordFunc
}

// NewLoop creates a new agent loop. Returns an error when a required
// LoopOptions field is missing. All optional wiring is applied during
// construction; late-binding wiring (capability tags, context
// providers, etc.) is supplied through the loop's grouped Configure*
// methods after dependent subsystems initialize.
func NewLoop(opts LoopOptions) (*Loop, error) {
	if opts.Logger == nil {
		return nil, errors.New("agent.NewLoop: Logger is required")
	}
	if opts.Memory == nil {
		return nil, errors.New("agent.NewLoop: Memory is required")
	}
	if opts.LLM == nil {
		return nil, errors.New("agent.NewLoop: LLM is required")
	}
	if opts.Model == "" {
		return nil, errors.New("agent.NewLoop: Model is required")
	}

	l := &Loop{
		logger:              opts.Logger,
		memory:              opts.Memory,
		compactor:           opts.Compactor,
		router:              opts.Router,
		llm:                 opts.LLM,
		tools:               tools.NewRegistry(opts.HomeAssistant, opts.Scheduler),
		model:               opts.Model,
		parsedTalents:       opts.ParsedTalents,
		persona:             opts.Persona,
		contextWindow:       opts.ContextWindow,
		timezone:            opts.Timezone,
		recoveryModel:       opts.RecoveryModel,
		archiver:            opts.Archiver,
		haInject:            opts.HAInject,
		modelRegistry:       opts.ModelRegistry,
		modelRuntime:        opts.ModelRuntime,
		liveRequestRecorder: opts.LiveRequestRecorder,
		requestRecorder:     opts.RequestRecorder,
		nowFunc:             time.Now,
	}
	if opts.EgoFile != "" || opts.ProvenanceStore != nil || len(opts.InjectFiles) > 0 {
		l.ensureCoreContextProvider().updateEgoFile(opts.EgoFile)
		l.coreContextProvider.updateProvenanceStore(opts.ProvenanceStore)
		l.coreContextProvider.updateInjectFiles(opts.InjectFiles)
	}
	return l, nil
}

// SetFailoverHandler configures a handler to be called before model failover.
func (l *Loop) SetFailoverHandler(handler FailoverHandler) {
	l.failoverHandler = handler
}

// RegisterTagContextProvider registers a live context provider for a
// capability tag. The provider fires when that tag is active during
// system-prompt assembly. Only one provider per tag is supported; a
// second registration for the same tag replaces the previous one.
//
// If the tag context assembler has been wired (via
// [Loop.SetTagContextAssembler]) the registration forwards directly;
// otherwise it stages locally and is drained into the assembler when
// SetTagContextAssembler runs. This ordering accommodates init phases
// that register providers before the late-binding capability tag
// resolution completes.
func (l *Loop) RegisterTagContextProvider(tag string, p TagContextProvider) {
	if p == nil {
		return
	}
	// Match the assembler's normalization so staged registrations
	// can't drift from direct registrations. Without this, "foo" and
	// "foo " stage as distinct keys but collide on the same trimmed
	// tag inside the assembler — last-write-wins becomes random map
	// iteration order.
	tag = strings.TrimSpace(tag)
	if tag == "" {
		return
	}
	l.pendingProvidersMu.Lock()
	if l.tagContextAssembler != nil {
		l.pendingProvidersMu.Unlock()
		l.tagContextAssembler.RegisterTaggedProvider(tag, p)
		return
	}
	if l.pendingTagProviders == nil {
		l.pendingTagProviders = make(map[string]TagContextProvider)
	}
	l.pendingTagProviders[tag] = p
	l.pendingProvidersMu.Unlock()
}

// RegisterAlwaysContextProvider adds a provider to the always-on
// bucket. Always-on providers fire on every main-loop run but are
// suppressed for delegate runs where SuppressAlwaysContext is set.
// Same staging semantics as [Loop.RegisterTagContextProvider].
func (l *Loop) RegisterAlwaysContextProvider(p TagContextProvider) {
	if p == nil {
		return
	}
	l.pendingProvidersMu.Lock()
	if l.tagContextAssembler != nil {
		l.pendingProvidersMu.Unlock()
		l.tagContextAssembler.RegisterAlwaysProvider(p)
		return
	}
	l.pendingAlwaysProviders = append(l.pendingAlwaysProviders, p)
	l.pendingProvidersMu.Unlock()
}

// The setters below remain for test convenience and for callers that
// need to mutate a single field after construction. Production wiring
// in internal/app/* goes through [LoopOptions] at construction and the
// grouped Configure* methods for late-binding state; new code should
// prefer those entry points.

// SetEgoFile sets the path to ego.md. The file is published through
// the always-on context-provider pipeline and read fresh on each turn.
func (l *Loop) SetEgoFile(path string) {
	l.ensureCoreContextProvider().updateEgoFile(path)
}

// SetInjectFiles sets the core context files published through the
// always-on context-provider pipeline on each turn.
func (l *Loop) SetInjectFiles(paths []string) {
	l.ensureCoreContextProvider().updateInjectFiles(paths)
}

// UseInjectFileVerifier configures the verifier used before each
// core context file read during system-prompt assembly.
func (l *Loop) UseInjectFileVerifier(verifier func(context.Context, string, string) error) {
	l.ensureCoreContextProvider().updateInjectFileVerifier(verifier)
}

func (l *Loop) ensureCoreContextProvider() *CoreContextProvider {
	if l.coreContextProvider != nil {
		return l.coreContextProvider
	}
	l.coreContextProvider = NewCoreContextProvider(CoreContextProviderConfig{
		Logger: l.logger,
		Now:    l.nowFunc,
	})
	l.RegisterAlwaysContextProvider(l.coreContextProvider)
	return l.coreContextProvider
}

// SetHAInject configures the HA entity state resolver for tag context
// documents.
func (l *Loop) SetHAInject(fetcher homeassistant.StateFetcher) { l.haInject = fetcher }

// UseModelRegistry configures the live model registry used for
// explicit model resolution and runtime usage attribution.
func (l *Loop) UseModelRegistry(registry *fleet.Registry) { l.modelRegistry = registry }

// UseModelRuntime configures the live model runtime used for explicit
// runner preparation flows such as LM Studio context expansion.
func (l *Loop) UseModelRuntime(runtime *fleet.Runtime) { l.modelRuntime = runtime }

// UseLiveRequestRecorder configures live request detail recording for
// in-flight turns.
func (l *Loop) UseLiveRequestRecorder(recorder logging.RequestRecordFunc) {
	l.liveRequestRecorder = recorder
}

// CapabilityWiring bundles the late-binding capability state resolved
// after the full tool registry is assembled. It groups the four
// post-construction setters that finalize_capability_tags previously
// invoked individually so the dependency surface is visible at the
// call site.
type CapabilityWiring struct {
	Tags             map[string]config.CapabilityTagConfig
	ParsedTalents    []talents.Talent
	Surface          []toolcatalog.CapabilitySurface
	Store            CapabilityTagStore
	ContextAssembler *TagContextAssembler
}

// ConfigureCapabilityWiring applies the resolved capability tag
// surface, store, talents, and context assembler. Empty fields are
// no-ops so callers can stage the wiring incrementally.
func (l *Loop) ConfigureCapabilityWiring(w CapabilityWiring) {
	if w.ParsedTalents != nil {
		l.parsedTalents = w.ParsedTalents
	}
	if len(w.Tags) > 0 {
		l.SetCapabilityTags(w.Tags, w.ParsedTalents)
	}
	if len(w.Surface) > 0 {
		l.UseCapabilitySurface(w.Surface)
	}
	if w.Store != nil {
		l.capTagStore = w.Store
	}
	if w.ContextAssembler != nil {
		l.tagContextAssembler = w.ContextAssembler
	}
}

// SessionStoreWiring bundles the late-binding storage handles
// initialized after the channel/storage subsystem comes online.
type SessionStoreWiring struct {
	Extractor    *memory.Extractor
	UsageStore   *usage.Store
	Pricing      map[string]config.PricingEntry
	UsageCatalog *fleet.Catalog
}

// ConfigureSessionStores applies extractor and usage-recording wiring.
// Each field is independently optional.
func (l *Loop) ConfigureSessionStores(w SessionStoreWiring) {
	if w.Extractor != nil {
		l.extractor = w.Extractor
	}
	if w.UsageStore != nil {
		l.usageStore = w.UsageStore
		l.pricing = w.Pricing
		l.usageCatalog = w.UsageCatalog
	}
}

// ChannelDelegationWiring bundles the late-binding routing and
// delegation policy that depend on the delegation init phase.
type ChannelDelegationWiring struct {
	OrchestratorTools []string
	ChannelTags       map[string][]string
	LensProvider      func() []string
}

// ConfigureChannelDelegation applies orchestrator tool gating, channel
// tag mappings, and the global lens provider. Each field is
// independently optional.
func (l *Loop) ConfigureChannelDelegation(w ChannelDelegationWiring) {
	if w.OrchestratorTools != nil {
		l.orchestratorTools = w.OrchestratorTools
	}
	if w.ChannelTags != nil {
		l.channelTags = w.ChannelTags
	}
	if w.LensProvider != nil {
		l.lensProvider = w.LensProvider
	}
}

// SetTagContextAssembler configures the shared assembler that builds
// the Capability Context section of the system prompt from tagged KB
// articles, tagged providers, and always-on providers. Drains any
// provider registrations staged before this call into the assembler.
func (l *Loop) SetTagContextAssembler(a *TagContextAssembler) {
	l.pendingProvidersMu.Lock()
	pendingTagged := l.pendingTagProviders
	pendingAlways := l.pendingAlwaysProviders
	l.pendingTagProviders = nil
	l.pendingAlwaysProviders = nil
	l.tagContextAssembler = a
	l.pendingProvidersMu.Unlock()

	if a == nil {
		return
	}
	for tag, p := range pendingTagged {
		a.RegisterTaggedProvider(tag, p)
	}
	coreRegistered := false
	for _, p := range pendingAlways {
		a.RegisterAlwaysProvider(p)
		if p == l.coreContextProvider {
			coreRegistered = true
		}
	}
	if l.coreContextProvider != nil && !coreRegistered {
		a.RegisterAlwaysProvider(l.coreContextProvider)
	}
}

func (l *Loop) contextAssemblerForPrompt() *TagContextAssembler {
	l.pendingProvidersMu.Lock()
	assembler := l.tagContextAssembler
	if assembler != nil {
		l.pendingProvidersMu.Unlock()
		return assembler
	}
	pendingTagged := make(map[string]TagContextProvider, len(l.pendingTagProviders))
	for tag, p := range l.pendingTagProviders {
		pendingTagged[tag] = p
	}
	pendingAlways := append([]TagContextProvider(nil), l.pendingAlwaysProviders...)
	l.pendingProvidersMu.Unlock()

	if len(pendingTagged) == 0 && len(pendingAlways) == 0 {
		return nil
	}
	assembler = NewTagContextAssembler(TagContextAssemblerConfig{
		HAInject: l.haInject,
		Logger:   l.logger,
	})
	for tag, p := range pendingTagged {
		assembler.RegisterTaggedProvider(tag, p)
	}
	for _, p := range pendingAlways {
		assembler.RegisterAlwaysProvider(p)
	}
	return assembler
}

// SetOrchestratorTools configures the restricted tool set for all
// iterations of the agent loop. When set, only the named tools are
// advertised on every LLM call, keeping the primary model in
// orchestrator mode and steering it toward delegation. If neither
// thane_now nor thane_assign is registered in the tool registry,
// gating is silently disabled to avoid leaving the agent without
// any way to delegate.
func (l *Loop) SetOrchestratorTools(names []string) {
	l.orchestratorTools = names
}

// SetCapabilityTags configures tag-driven tool and talent filtering.
// Tags marked always_active are activated immediately. The method also
// builds the tool registry's tag index and stores parsed talents for
// per-run filtering. When capTags is nil or empty, capability tagging
// is disabled and all tools/talents load unconditionally.
func (l *Loop) SetCapabilityTags(capTags map[string]config.CapabilityTagConfig, parsedTalents []talents.Talent) {
	if len(capTags) == 0 {
		return
	}
	l.capTags = capTags
	l.parsedTalents = parsedTalents

	// Build tag index for tool filtering.
	tagIndex := make(map[string][]string, len(capTags))
	for tag, cfg := range capTags {
		tagIndex[tag] = cfg.Tools
	}
	l.tools.SetTagIndex(tagIndex)

	// Seed lastRunTags with always-active tags for initial dashboard display.
	l.lastRunTagsMu.Lock()
	l.lastRunTags = make(map[string]bool)
	for tag, cfg := range capTags {
		if cfg.AlwaysActive {
			l.lastRunTags[tag] = true
		}
	}
	l.lastRunTagsMu.Unlock()
}

// UseCapabilitySurface stores the resolved capability surface used by
// shared model-facing renderers such as prompt summaries and capability
// manifest/help generation.
func (l *Loop) UseCapabilitySurface(surface []toolcatalog.CapabilitySurface) {
	l.capSurface = toolcatalog.SortCapabilitySurface(surface)
}

// SetUsageRecorder configures persistent token usage recording. When
// set, every LLM completion in the agent loop is persisted for cost
// attribution and analysis.
func (l *Loop) SetUsageRecorder(store *usage.Store, pricing map[string]config.PricingEntry, cat *fleet.Catalog) {
	l.usageStore = store
	l.pricing = pricing
	l.usageCatalog = cat
}

// SetChannelTags configures channel-pinned tag activation. When a
// Run() request carries a "source" hint matching a key in channelTags,
// the listed capability tags are activated for that run in addition to
// any always-active or agent-requested tags. Channel-pinned tags are
// ref-counted per concurrent Run() call and cannot be dropped via
// DropCapability. They are removed on return to prevent cross-channel bleed.
func (l *Loop) SetChannelTags(ct map[string][]string) {
	l.channelTags = ct
}

// UseContactLookup configures the contact-directory lookup used to
// include trust-gated contact profiles in session origin context.
func (l *Loop) UseContactLookup(lookup ContactLookup) {
	l.contactLookup = lookup
}

func (l *Loop) filterOriginPinnedTags(origin SessionOrigin, tags []string) []string {
	return l.filterOriginPinnedTagsForSource(origin, tags, "origin_policy")
}

func (l *Loop) filterRuntimePinnedTags(origin SessionOrigin, tags []string) []string {
	return l.filterOriginPinnedTagsForSource(origin, tags, "runtime")
}

func (l *Loop) filterOriginPinnedTagsForSource(origin SessionOrigin, tags []string, policySource string) []string {
	if len(tags) == 0 || l.capTags == nil {
		return nil
	}
	filtered := make([]string, 0, len(tags))
	for _, tag := range cleanUnique(tags) {
		cfg, ok := l.capTags[tag]
		if !ok {
			if l.logger != nil {
				l.logger.Warn("session origin policy referenced inactive capability tag", "tag", tag)
			}
			continue
		}
		if policySource != "runtime" && runtimeOnlyOriginTag(tag) {
			if l.logger != nil {
				l.logger.Warn("session origin policy skipped runtime-only tag",
					"tag", tag,
					"policy_source", policySource,
					"origin_source", origin.Source,
					"channel", origin.Channel,
					"contact_name", origin.ContactName,
				)
			}
			continue
		}
		if policySource != "runtime" && cfg.Protected {
			if l.logger != nil {
				l.logger.Warn("session origin policy skipped protected tag",
					"tag", tag,
					"policy_source", policySource,
					"origin_source", origin.Source,
					"channel", origin.Channel,
					"contact_name", origin.ContactName,
				)
			}
			continue
		}
		if cfg.Protected && tag == "owner" && !origin.IsOwner {
			if l.logger != nil {
				l.logger.Warn("session origin policy skipped protected tag",
					"tag", tag,
					"policy_source", policySource,
					"origin_source", origin.Source,
					"channel", origin.Channel,
					"contact_name", origin.ContactName,
				)
			}
			continue
		}
		filtered = append(filtered, tag)
	}
	return filtered
}

func runtimeOnlyOriginTag(tag string) bool {
	switch tag {
	case "message_channel", "owner":
		return true
	default:
		return false
	}
}

// SetLensProvider configures a function that returns the currently
// active global lenses. These are merged into every Run's capability
// scope alongside always-active and channel-pinned tags.
func (l *Loop) SetLensProvider(fn func() []string) {
	l.lensProvider = fn
}

// SetCapabilityTagStore configures persistent storage for per-conversation
// capability tags. When set, tags activated via activate_capability are
// saved at the end of each Run and restored at the start of the next Run
// for the same conversation.
func (l *Loop) SetCapabilityTagStore(store CapabilityTagStore) {
	l.capTagStore = store
}

// HAInject returns the HA entity state fetcher used for resolving
// ha-inject directives in context files. May be nil when HA is not
// configured.
func (l *Loop) HAInject() homeassistant.StateFetcher {
	return l.haInject
}

// ActiveTags returns a snapshot of the currently active capability tags.
// ActiveTags returns the active tags from the context-scoped capability
// scope when available, falling back to the most recent Run()'s
// snapshot. This satisfies the [tools.CapabilityManager] interface.
func (l *Loop) ActiveTags(ctx context.Context) map[string]bool {
	if scope := capabilityScopeFromContext(ctx); scope != nil {
		return scope.Snapshot()
	}
	return l.LastRunTags()
}

// LastRunTags returns a snapshot of the most recent Run()'s active
// tags. Used by the dashboard callback which has no Run context.
func (l *Loop) LastRunTags() map[string]bool {
	l.lastRunTagsMu.Lock()
	defer l.lastRunTagsMu.Unlock()
	if l.lastRunTags == nil {
		return nil
	}
	snap := make(map[string]bool, len(l.lastRunTags))
	for k, v := range l.lastRunTags {
		snap[k] = v
	}
	return snap
}

// RequestCapability activates a capability tag for the current Run.
// Delegates to the context-scoped capabilityScope. Both configured
// tags and ad-hoc tags are accepted.
func (l *Loop) RequestCapability(ctx context.Context, tag string) error {
	scope := capabilityScopeFromContext(ctx)
	if scope == nil {
		return fmt.Errorf("no capability scope in context")
	}
	if err := scope.Request(tag); err != nil {
		return err
	}

	// Update dashboard snapshot.
	l.updateLastRunTags(scope)

	configured := "ad-hoc"
	if _, ok := l.capTags[tag]; ok {
		configured = "configured"
	}
	l.logger.Info("capability activated", "tag", tag, "type", configured)
	return nil
}

// DropCapability deactivates a capability tag for the current Run.
// Delegates to the context-scoped capabilityScope. Always-active and
// channel-pinned tags cannot be dropped.
func (l *Loop) DropCapability(ctx context.Context, tag string) error {
	scope := capabilityScopeFromContext(ctx)
	if scope == nil {
		return fmt.Errorf("no capability scope in context")
	}
	if err := scope.Drop(tag); err != nil {
		return err
	}

	// Update dashboard snapshot.
	l.updateLastRunTags(scope)

	l.logger.Info("capability deactivated", "tag", tag)
	return nil
}

// ResetCapabilities returns the current Run to baseline capability
// state by dropping all user-activated tags while preserving
// always-active, protected, and pinned tags.
func (l *Loop) ResetCapabilities(ctx context.Context) ([]string, error) {
	scope := capabilityScopeFromContext(ctx)
	if scope == nil {
		return nil, fmt.Errorf("no capability scope in context")
	}

	dropped := scope.UserActivatedTags()
	sort.Strings(dropped)
	for _, tag := range dropped {
		if err := scope.Drop(tag); err != nil {
			return nil, err
		}
	}

	l.updateLastRunTags(scope)
	l.logger.Info("capability state reset to baseline", "dropped_tags", dropped)
	return dropped, nil
}

// updateLastRunTags copies the scope's active tags into lastRunTags
// so the dashboard (which has no context) can display current state.
func (l *Loop) updateLastRunTags(scope *capabilityScope) {
	snap := scope.Snapshot()
	l.lastRunTagsMu.Lock()
	l.lastRunTags = snap
	l.lastRunTagsMu.Unlock()
}

// Tools returns the tool registry for adding additional tools.
func (l *Loop) Tools() *tools.Registry {
	return l.tools
}

// Router returns the model router, or nil if no router is configured.
func (l *Loop) Router() *router.Router {
	return l.router
}

// Simple greeting patterns that don't need tool calls
var greetingPatterns = []string{
	"hi", "hello", "hey", "howdy", "hiya", "yo",
	"good morning", "good afternoon", "good evening",
	"what's up", "whats up", "sup",
}

// isSimpleGreeting checks if the message is a simple greeting
func isSimpleGreeting(msg string) bool {
	lower := strings.ToLower(strings.TrimSpace(msg))
	// Remove punctuation
	lower = strings.TrimRight(lower, "!?.,")
	for _, pattern := range greetingPatterns {
		if lower == pattern {
			return true
		}
	}
	return false
}

// Greeting responses to cycle through
var greetingResponses = []string{
	"Hey! What can I help you with?",
	"Hi there! How can I help?",
	"Hello! What would you like me to do?",
	"Hey! Ready to help.",
}

var greetingIndex int

// getGreetingResponse returns a friendly greeting response
func getGreetingResponse() string {
	resp := greetingResponses[greetingIndex%len(greetingResponses)]
	greetingIndex++
	return resp
}

// promptSection records the name and byte boundaries of one section in
// the assembled system prompt. Used for content retention (prompt
// archival with section metadata) and future section-level diffing.
type promptSection struct {
	name  string
	start int
	end   int
}

func (l *Loop) buildSystemPrompt(ctx context.Context, userMessage string, _ []memory.Message) string {
	prompt, _ := l.buildSystemPromptWithProfileSections(ctx, userMessage, nil, llm.DefaultModelInteractionProfile())
	return prompt
}

func (l *Loop) buildSystemPromptWithProfile(ctx context.Context, userMessage string, _ []memory.Message, profile llm.ModelInteractionProfile) string {
	prompt, _ := l.buildSystemPromptWithProfileSections(ctx, userMessage, nil, profile)
	return prompt
}

func (l *Loop) buildSystemPromptWithProfileSections(ctx context.Context, userMessage string, _ []memory.Message, profile llm.ModelInteractionProfile) (string, []llm.PromptSection) {
	var sb strings.Builder
	promptMode := agentctx.PromptModeFromContext(ctx)
	taskPrompt := promptMode == agentctx.PromptModeTask

	// Snapshot active tags from the per-Run capability scope.
	tags := snapshotTagsFromContext(ctx)

	// Track section boundaries for debug dump.
	var sections []promptSection
	mark := func(name string) { sections = append(sections, promptSection{name: name, start: sb.Len()}) }
	seal := func() { sections[len(sections)-1].end = sb.Len() }

	// 1. Persona (identity — who am I)
	mark("PERSONA")
	if taskPrompt {
		sb.WriteString(prompts.DelegateSystemPrompt())
	} else if l.persona != "" {
		sb.WriteString(l.persona)
	} else {
		sb.WriteString(prompts.BaseSystemPrompt())
	}
	seal()

	// 2. Runtime contract (execution semantics — how should I use tools)
	mark("RUNTIME CONTRACT")
	sb.WriteString("\n\n")
	if taskPrompt {
		sb.WriteString(prompts.DelegateRuntimeContract())
	} else {
		sb.WriteString(prompts.RuntimeContract())
	}
	seal()

	if contract := strings.TrimSpace(profile.ToolCallingContract()); contract != "" {
		mark("TOOL CALLING CONTRACT")
		sb.WriteString("\n\n## Tool Calling Contract\n\n")
		sb.WriteString(contract)
		sb.WriteString("\n")
		seal()
	}

	// 4. Talents (behavior — how should I act)
	// Keep always-on guidance ahead of volatile context so provider-side
	// prompt caching can retain the stable behavioral prefix.
	alwaysOnTalents, taggedTalents := talents.SplitByTags(l.parsedTalents, tags)
	if !taskPrompt && alwaysOnTalents != "" {
		mark("TALENTS ALWAYS ON")
		sb.WriteString("\n\n## Behavioral Guidance\n\n")
		sb.WriteString(alwaysOnTalents)
		seal()
	}
	if taggedTalents != "" {
		mark("TALENTS TAGGED")
		if !taskPrompt && alwaysOnTalents != "" {
			sb.WriteString("\n\n---\n\n")
		} else {
			sb.WriteString("\n\n## Behavioral Guidance\n\n")
		}
		sb.WriteString(taggedTalents)
		seal()
	}

	// 5. Active capabilities (dynamic runtime state).
	if activeSummary := toolcatalog.RenderLoadedCapabilitySummary(l.capSurface, tags); activeSummary != "" {
		mark("ACTIVE CAPABILITIES")
		sb.WriteString("\n\n## Active Capabilities\n\n")
		sb.WriteString(activeSummary)
		sb.WriteString("\n")
		seal()
	}

	// 5b. Session origin policy (runtime data about why this run was shaped).
	if !taskPrompt {
		if originCtx := l.renderSessionOriginContext(ctx); originCtx != "" {
			mark("SESSION ORIGIN CONTEXT")
			sb.WriteString("\n\n## Session Origin Context\n\n")
			sb.WriteString(originCtx)
			seal()
		}
	}

	// 6. Capability context (capability knowledge + ambient context)
	// One unified section assembled by TagContextAssembler from three
	// sources: tagged KB articles, tagged providers, and always-on
	// providers. Always-on providers are gated by IncludeAlways: main
	// loop runs include them; delegate runs (which set
	// req.SuppressAlwaysContext via the loops launch) do not.
	if assembler := l.contextAssemblerForPrompt(); assembler != nil {
		haCtx, haCancel := context.WithTimeout(ctx, 2*time.Second)
		defer haCancel()

		req := ContextRequest{
			UserMessage:   userMessage,
			ActiveTags:    tags,
			IncludeAlways: !taskPrompt && !tools.SuppressAlwaysContextFromContext(ctx),
		}
		if capCtx := assembler.Build(haCtx, req); capCtx != "" {
			mark("CAPABILITY CONTEXT")
			sb.WriteString("\n\n## Capability Context\n\n")
			sb.WriteString(capCtx)
			seal()
		}
	}

	// 7. Current Conditions (environment — where/when am I)
	mark("CURRENT CONDITIONS")
	sb.WriteString("\n\n")
	sb.WriteString(awareness.CurrentConditions(l.timezone))
	seal()

	text := sb.String()
	return text, promptSectionsFromBoundaries(text, sections)
}

func (l *Loop) renderSessionOriginContext(ctx context.Context) string {
	result := sessionOriginPolicyResultFromContext(ctx)
	if result == nil || result.empty() {
		return ""
	}
	payload := struct {
		Origin      SessionOrigin              `json:"origin"`
		Contact     *ContactContext            `json:"contact,omitempty"`
		Applied     []SessionOriginAppliedRule `json:"applied_rules,omitempty"`
		Tags        []string                   `json:"tags,omitempty"`
		ContextRefs []string                   `json:"context_refs,omitempty"`
	}{
		Origin:      result.Origin,
		Contact:     l.originContactContext(result),
		Applied:     result.Applied,
		Tags:        result.Tags,
		ContextRefs: result.ContextRefs,
	}
	data, err := json.Marshal(payload)
	if err != nil {
		l.logger.Warn("failed to marshal session origin context", "error", err)
		return ""
	}

	var sb strings.Builder
	sb.WriteString("Generated runtime data. Use it to understand which origin-derived policy is active for this run.\n\n")
	sb.WriteString("```json\n")
	sb.Write(data)
	sb.WriteString("\n```\n")

	if assembler := l.contextAssemblerForPrompt(); assembler != nil && len(result.ContextRefs) > 0 {
		haCtx, haCancel := context.WithTimeout(ctx, 2*time.Second)
		defer haCancel()
		if refs := assembler.BuildRefs(haCtx, result.ContextRefs); refs != "" {
			sb.WriteString("\n\n### Context Refs\n\n")
			sb.WriteString(refs)
		}
	}

	return sb.String()
}

func (l *Loop) originContactContext(result *SessionOriginPolicyResult) *ContactContext {
	if result == nil || l.contactLookup == nil {
		return nil
	}
	source := result.Origin.Source
	if source == "" {
		source = result.Origin.Channel
	}
	if result.Origin.ContactID != "" {
		if contact := l.contactLookup.LookupContactByID(result.Origin.ContactID, source); contact != nil {
			return contact
		}
	}
	if result.Origin.ContactName != "" {
		return l.contactLookup.LookupContact(result.Origin.ContactName, source)
	}
	return nil
}

func (l *Loop) contactOriginPolicy(origin SessionOrigin) *ContactOriginPolicy {
	if l.contactLookup == nil {
		return nil
	}
	if origin.ContactID == "" && origin.ContactName == "" {
		return nil
	}
	source := origin.Source
	if source == "" {
		source = origin.Channel
	}
	policy := l.contactLookup.LookupContactOriginPolicy(origin.ContactID, origin.ContactName, source)
	if policy == nil || (len(policy.Tags) == 0 && len(policy.ContextRefs) == 0) {
		return nil
	}
	return &ContactOriginPolicy{
		Tags:        cleanUnique(policy.Tags),
		ContextRefs: cleanUnique(policy.ContextRefs),
	}
}

func promptSectionsFromBoundaries(text string, sections []promptSection) []llm.PromptSection {
	if len(sections) == 0 {
		return nil
	}
	result := make([]llm.PromptSection, 0, len(sections))
	for _, section := range sections {
		if section.end <= section.start || section.start < 0 || section.end > len(text) {
			continue
		}
		result = append(result, llm.PromptSection{
			Name:     section.name,
			Content:  text[section.start:section.end],
			CacheTTL: promptSectionCacheTTL(section.name),
		})
	}
	return result
}

// promptSectionCacheTTL maps a system-prompt section name to its
// Anthropic cache TTL. See docs/anthropic-caching.md for the policy,
// the decision tree for adding new sections, and why volatile
// sections must return "" rather than a short TTL (they'd churn the
// cache instead of amortizing it).
func promptSectionCacheTTL(name string) string {
	switch name {
	case "PERSONA", "EGO", "RUNTIME CONTRACT", "INJECTED CONTEXT", "TOOL CALLING CONTRACT", "TALENTS ALWAYS ON":
		return "1h"
	case "TALENTS TAGGED":
		return "5m"
	default:
		return ""
	}
}

func appendPromptSection(sections []llm.PromptSection, section llm.PromptSection) []llm.PromptSection {
	if strings.TrimSpace(section.Content) == "" {
		return sections
	}
	return append(sections, section)
}

func (l *Loop) modelInteractionProfileForModel(model string) llm.ModelInteractionProfile {
	input := llm.ModelProfileInput{Model: strings.TrimSpace(model)}
	cat := l.currentModelCatalog()
	if cat == nil {
		return llm.ProfileForModel(input)
	}
	dep, err := cat.ResolveDeploymentRef(model)
	if err != nil {
		return llm.ProfileForModel(input)
	}
	input.Provider = dep.Provider
	input.Model = dep.ModelName
	input.Family = dep.Family
	input.Families = append([]string(nil), dep.Families...)
	input.TrainedForToolUse = dep.TrainedForToolUse
	return llm.ProfileForModel(input)
}

func (l *Loop) repairToolCall(tc llm.ToolCall) (llm.ToolCall, bool) {
	name := strings.TrimSpace(tc.Function.Name)
	if name == "" {
		return tc, false
	}
	if l.tools != nil && l.tools.Get(name) != nil {
		return tc, false
	}

	if repaired, ok := l.repairCapabilityToolCall(tc); ok {
		return repaired, true
	}
	return tc, false
}

func (l *Loop) repairCapabilityToolCall(tc llm.ToolCall) (llm.ToolCall, bool) {
	name := canonicalCapabilityAlias(tc.Function.Name)
	if name == "" {
		return tc, false
	}

	switch name {
	case "list_capabilities", "list_loaded_capabilities", "loaded_capabilities", "active_capabilities", "get_loaded_capabilities":
		tc.Function.Name = "list_loaded_capabilities"
		if tc.Function.Arguments == nil {
			tc.Function.Arguments = map[string]any{}
		}
		return tc, true
	case "reset_capability", "reset_capabilities", "reset_loaded_capabilities", "reset_capability_state", "restore_capability_baseline":
		tc.Function.Name = "reset_capabilities"
		tc.Function.Arguments = map[string]any{}
		return tc, true
	case "request_capability", "load_capability":
		if tag, ok := l.resolveCapabilityTagAlias(extractCapabilityTagArg(tc.Function.Arguments)); ok {
			tc.Function.Name = "activate_capability"
			tc.Function.Arguments = map[string]any{"tag": tag}
			return tc, true
		}
	case "drop_capability", "unload_capability":
		if tag, ok := l.resolveCapabilityTagAlias(extractCapabilityTagArg(tc.Function.Arguments)); ok {
			tc.Function.Name = "deactivate_capability"
			tc.Function.Arguments = map[string]any{"tag": tag}
			return tc, true
		}
	}

	for _, prefix := range []struct {
		Prefix string
		Tool   string
	}{
		{Prefix: "activate_", Tool: "activate_capability"},
		{Prefix: "request_", Tool: "activate_capability"},
		{Prefix: "load_", Tool: "activate_capability"},
		{Prefix: "deactivate_", Tool: "deactivate_capability"},
		{Prefix: "disable_", Tool: "deactivate_capability"},
		{Prefix: "drop_", Tool: "deactivate_capability"},
		{Prefix: "unload_", Tool: "deactivate_capability"},
	} {
		if tail, ok := strings.CutPrefix(name, prefix.Prefix); ok {
			if tag, ok := l.resolveCapabilityTagAlias(tail); ok {
				tc.Function.Name = prefix.Tool
				tc.Function.Arguments = map[string]any{"tag": tag}
				return tc, true
			}
		}
	}

	if tail, ok := strings.CutSuffix(name, "_capability"); ok {
		if tag, ok := l.resolveCapabilityTagAlias(tail); ok {
			tc.Function.Name = "activate_capability"
			tc.Function.Arguments = map[string]any{"tag": tag}
			return tc, true
		}
	}

	return tc, false
}

func (l *Loop) resolveCapabilityTagAlias(alias string) (string, bool) {
	alias = canonicalCapabilityAlias(alias)
	if alias == "" {
		return "", false
	}
	candidates := []string{alias}
	if trimmed, ok := strings.CutSuffix(alias, "_capability"); ok && trimmed != "" {
		candidates = append(candidates, trimmed)
	}
	for _, candidate := range candidates {
		for _, entry := range l.capSurface {
			if canonicalCapabilityAlias(entry.Tag) == candidate {
				return entry.Tag, true
			}
		}
		for tag := range l.capTags {
			if canonicalCapabilityAlias(tag) == candidate {
				return tag, true
			}
		}
	}
	return "", false
}

func extractCapabilityTagArg(args map[string]any) string {
	if len(args) == 0 {
		return ""
	}
	for _, key := range []string{"tag", "capability", "name"} {
		if value, ok := args[key].(string); ok {
			if trimmed := strings.TrimSpace(value); trimmed != "" {
				return trimmed
			}
		}
	}
	return ""
}

func canonicalCapabilityAlias(value string) string {
	value = strings.TrimSpace(strings.ToLower(value))
	if value == "" {
		return ""
	}
	replacer := strings.NewReplacer("-", "_", " ", "_")
	value = replacer.Replace(value)
	for strings.Contains(value, "__") {
		value = strings.ReplaceAll(value, "__", "_")
	}
	return strings.Trim(value, "_")
}

// generateRequestID returns a human-scannable identifier for a single
// user-message turn (e.g., "r_7f3ab2c1d5e6f7a8"). It uses 8 random
// bytes from a UUIDv7 (bytes 8-15), giving ~62 bits of effective
// entropy after the variant/version bits. This is wide enough that
// birthday collisions are negligible even over millions of retained
// request traces.
func generateRequestID() string {
	id, err := uuid.NewV7()
	if err != nil {
		return fmt.Sprintf("r_%016x", time.Now().UnixNano())
	}
	// Bytes 8-15 are the random section of UUIDv7.
	return "r_" + hex.EncodeToString(id[8:16])
}

// Run executes one iteration of the agent loop.
// If stream is non-nil, tokens are pushed to it as they arrive.
func (l *Loop) Run(ctx context.Context, req *Request, stream StreamCallback) (resp *Response, err error) {
	convID := req.ConversationID
	if convID == "" {
		convID = "default"
	}

	// Track session activity on successful completion.
	// Skip for lightweight requests (auxiliary) to avoid session noise.
	defer func() {
		if err == nil && l.archiver != nil && !req.SkipContext {
			l.archiver.EnsureSession(convID)
			l.archiver.OnMessage(convID)
		}
	}()

	// Get session ID for log correlation. The session ID (UUIDv7 prefix)
	// is more meaningful than the conversation name (usually "default").
	sessionID := convID // fallback if no archiver
	if l.archiver != nil {
		if sid := l.archiver.ActiveSessionID(convID); sid != "" {
			sessionID = sid
		}
	}
	sessionTag := memory.ShortID(sessionID) // 8-char display tag for usage records

	// Generate a request-scoped ID and logger. Every log line within this
	// turn carries request_id so you can grep for a single user→response cycle.
	// The logger is based on the context logger (not l.logger) so upstream
	// fields from entry points (subsystem=api, sender, task_id) are preserved.
	// The agent loop overrides subsystem to "agent" for its own log lines.
	requestID := generateRequestID()
	log := logging.Logger(ctx).With(
		"subsystem", logging.SubsystemAgent,
		"request_id", requestID,
		"session_id", sessionID,
		"conversation_id", convID,
	)
	ctx = logging.WithLogger(ctx, log)
	runStarted := time.Now()
	defer func() {
		attrs := []any{
			"kind", events.KindRequestComplete,
			"ok", err == nil,
			"elapsed_ms", time.Since(runStarted).Milliseconds(),
			"skip_context", req.SkipContext,
		}
		if !req.SkipContext {
			attrs = append(attrs, "context_tokens", l.memory.GetTokenCount(convID))
		}
		if resp != nil {
			attrs = append(attrs,
				"model", resp.Model,
				"input_tokens", resp.InputTokens,
				"output_tokens", resp.OutputTokens,
				"cache_creation_input_tokens", resp.CacheCreationInputTokens,
				"cache_read_input_tokens", resp.CacheReadInputTokens,
				"iterations", resp.Iterations,
				"finish_reason", resp.FinishReason,
				"exhausted", resp.Exhausted,
			)
			if len(resp.ToolsUsed) > 0 {
				attrs = append(attrs, "tools_used", resp.ToolsUsed)
			}
		}
		if err != nil {
			log.Warn("request complete", append(attrs, "error", err.Error())...)
			return
		}
		log.Info("request complete", attrs...)
	}()

	log.Info("request start",
		"kind", events.KindRequestStart,
		"message_count", len(req.Messages),
		"mission", req.Hints["mission"],
		"prompt_mode", req.PromptMode.OrDefault(),
		"skip_context", req.SkipContext,
		"max_iterations", req.MaxIterations,
	)

	// Always use Thane's memory as the source of truth.
	// For externally-managed conversations (owu-), the client sends full history
	// but Thane's store is the superset (includes tool calls, results, etc.).
	// Only store the NEW message from the client — the last user message.
	//
	// Skip memory entirely for lightweight requests (auxiliary title/tag gen)
	// to avoid polluting conversation history.
	var history []memory.Message
	if !req.SkipContext {
		history = l.memory.GetMessages(convID)

		if strings.HasPrefix(convID, "owu-") {
			// External client (Open WebUI): only add the last user message
			for i := len(req.Messages) - 1; i >= 0; i-- {
				if req.Messages[i].Role == "user" {
					if err := l.memory.AddMessage(convID, "user", req.Messages[i].Content); err != nil {
						log.Warn("failed to store message", "error", err)
					}
					break
				}
			}
		} else {
			// Internal/API clients: store all messages
			for _, m := range req.Messages {
				if err := l.memory.AddMessage(convID, m.Role, m.Content); err != nil {
					log.Warn("failed to store message", "error", err)
				}
			}
		}
	}

	// Extract user message for context search
	var userMessage string
	for i := len(req.Messages) - 1; i >= 0; i-- {
		if req.Messages[i].Role == "user" {
			userMessage = req.Messages[i].Content
			break
		}
	}

	// Fast-path: handle simple greetings without tool calls
	if isSimpleGreeting(userMessage) {
		log.Debug("simple greeting detected, responding directly")
		response := getGreetingResponse()
		if err := l.memory.AddMessage(convID, "assistant", response); err != nil {
			log.Warn("failed to store greeting response", "error", err)
		}
		return &Response{
			Content:      response,
			Model:        "greeting-handler",
			FinishReason: "stop",
			SessionID:    sessionID,
			RequestID:    requestID,
		}, nil
	}

	// Lightweight path: skip memory, tools, and heavy context injection.
	// Used for auxiliary requests (title/tag generation) that don't need the
	// full agent loop. Just send messages to the LLM with no tools.
	if req.SkipContext {
		startTime := time.Now()

		// Resolve model via router (same logic as full path, but inline)
		liteModel := req.Model
		var liteDecision *router.Decision
		if (liteModel == "" || liteModel == "thane") && l.router != nil {
			liteModel, liteDecision = l.router.Route(ctx, router.Request{
				Query:    userMessage,
				Hints:    req.Hints,
				Priority: router.PriorityBackground,
			})
		}
		if liteModel == "" {
			liteModel = l.model
		}

		log.Info("llm call",
			"kind", events.KindLLMCall,
			"iteration", 0,
			"model", liteModel,
			"message_count", len(req.Messages),
			"skip_context", true,
		)

		var llmMessages []llm.Message
		for _, m := range req.Messages {
			llmMessages = append(llmMessages, llm.Message{
				Role:    m.Role,
				Content: m.Content,
			})
		}

		llmResp, err := l.llm.ChatStream(ctx, liteModel, llmMessages, nil, stream)
		if err != nil {
			// Record failed outcome
			if l.router != nil && liteDecision != nil {
				l.router.RecordFailure(liteDecision.RequestID, time.Since(startTime).Milliseconds(), 0, isTimeout(err))
			}
			return nil, fmt.Errorf("lightweight completion: %w", err)
		}

		// Record successful outcome
		if l.router != nil && liteDecision != nil {
			l.router.RecordOutcome(liteDecision.RequestID, time.Since(startTime).Milliseconds(), llmResp.InputTokens+llmResp.OutputTokens, true)
		}

		log.Info("llm response",
			"kind", events.KindLLMResponse,
			"iteration", 0,
			"model", llmResp.Model,
			"input_tokens", llmResp.InputTokens,
			"output_tokens", llmResp.OutputTokens,
			"tool_calls", len(llmResp.Message.ToolCalls),
			"elapsed_ms", time.Since(startTime).Milliseconds(),
		)

		l.recordUsage(ctx, req, llmResp.Model, llmResp.InputTokens, llmResp.OutputTokens, llmResp.CacheCreationInputTokens, llmResp.CacheCreation5mInputTokens, llmResp.CacheCreation1hInputTokens, llmResp.CacheReadInputTokens, convID, sessionTag, requestID, llmResp.UpstreamRequestID)

		return &Response{
			Content:                  llmResp.Message.Content,
			Model:                    llmResp.Model,
			FinishReason:             "stop",
			InputTokens:              llmResp.InputTokens,
			OutputTokens:             llmResp.OutputTokens,
			CacheCreationInputTokens: llmResp.CacheCreationInputTokens,
			CacheReadInputTokens:     llmResp.CacheReadInputTokens,
			SessionID:                sessionID,
			RequestID:                requestID,
		}, nil
	}

	channelBinding := req.ChannelBinding.Clone()
	if channelBinding == nil {
		channelBinding = l.conversationChannelBinding(convID)
	}
	origin := newSessionOrigin(req.Hints, channelBinding)
	originResult := SessionOriginPolicyResult{Origin: origin}
	if contactPolicy := l.contactOriginPolicy(origin); contactPolicy != nil {
		originResult.addApplied(SessionOriginAppliedRule{
			Name:        "contact_origin",
			Source:      "contacts",
			Tags:        l.filterOriginPinnedTags(origin, contactPolicy.Tags),
			ContextRefs: contactPolicy.ContextRefs,
		})
	}

	// Create a per-Run capability scope seeded with always-active tags.
	// Channel-pinned tags are merged based on the request's source hint.
	// Runtime-asserted tags (such as owner) are pinned from typed
	// conversation bindings so tool gating can rely on Go-validated
	// metadata instead of prompt reconstruction.
	var scope *capabilityScope
	if l.capTags != nil {
		var lenses []string
		if l.lensProvider != nil {
			lenses = l.lensProvider()
		}
		scope = newCapabilityScope(l.capTags, lenses)
		// Seed tags carried forward from previous loop iterations.
		for _, tag := range req.InitialTags {
			_ = scope.Request(tag)
		}
		if runtimeTags := l.filterRuntimePinnedTags(origin, req.RuntimeTags); len(runtimeTags) > 0 {
			scope.PinChannelTags(runtimeTags)
			originResult.addApplied(SessionOriginAppliedRule{
				Name:   "runtime_tags",
				Source: "runtime",
				Tags:   runtimeTags,
			})
			log.Info("runtime tags activated",
				"source", origin.Source,
				"channel", origin.Channel,
				"pinned_tags", runtimeTags,
			)
		}
		// Restore conversation-persisted capability tags.
		if l.capTagStore != nil && convID != "" {
			if saved, err := l.capTagStore.LoadTags(convID); err == nil {
				for _, tag := range saved {
					_ = scope.Request(tag)
				}
			} else {
				log.Warn("failed to load conversation capability tags",
					"conversation_id", convID, "error", err)
			}
		}
		if source := req.Hints["source"]; source != "" {
			if pinnedTags, ok := l.channelTags[source]; ok {
				pinnedTags = l.filterOriginPinnedTags(origin, pinnedTags)
				scope.PinChannelTags(pinnedTags)
				originResult.addApplied(SessionOriginAppliedRule{
					Name:   "source:" + source,
					Source: "channel_tags",
					Tags:   pinnedTags,
				})
				log.Info("channel tags activated",
					"source", source,
					"pinned_tags", pinnedTags,
				)
			}
		}
		if len(originResult.Tags) > 0 {
			scope.PinChannelTags(originResult.Tags)
			log.Info("session origin tags activated",
				"source", origin.Source,
				"channel", origin.Channel,
				"contact_name", origin.ContactName,
				"pinned_tags", originResult.Tags,
			)
		}
		if channelBinding != nil && channelBinding.IsOwner {
			scope.PinChannelTags([]string{"owner"})
			originResult.addApplied(SessionOriginAppliedRule{
				Name:   "owner_binding",
				Source: "channel_binding",
				Tags:   []string{"owner"},
			})
			log.Info("runtime tag activated",
				"tag", "owner",
				"channel", channelBinding.Channel,
				"contact_name", channelBinding.ContactName,
			)
		}
		ctx = withCapabilityScope(ctx, scope)
		l.updateLastRunTags(scope)
	}
	ctx = withSessionOriginPolicyResult(ctx, originResult)

	// Build messages for LLM. Enrich ctx with conversation ID so that
	// context providers (e.g. working memory) can scope their output.
	// Propagate request hints so channel-aware providers can adapt.
	ctx = agentctx.WithPromptMode(ctx, req.PromptMode)
	promptCtx := tools.WithConversationID(ctx, convID)
	promptCtx = tools.WithHints(promptCtx, req.Hints)
	promptCtx = tools.WithChannelBinding(promptCtx, channelBinding)
	promptCtx = tools.WithSuppressAlwaysContext(promptCtx, req.SuppressAlwaysContext)

	var systemPrompt string
	var systemSections []llm.PromptSection
	if req.SystemPrompt != "" {
		systemPrompt = req.SystemPrompt
	} else {
		systemPrompt, systemSections = l.buildSystemPromptWithProfileSections(promptCtx, userMessage, history, llm.DefaultModelInteractionProfile())
	}

	usageInfo := awareness.ContextUsageInfo{
		ContextWindow:  l.contextWindow,
		MessageCount:   len(history),
		ConversationID: convID,
		SessionID:      sessionTag, // already truncated to 8 chars
		RequestID:      requestID,
	}
	for _, m := range history {
		if m.Role == "system" && strings.HasPrefix(m.Content, "[Conversation Summary]") {
			usageInfo.CompactionCount++
		}
	}
	if l.archiver != nil {
		if started := l.archiver.ActiveSessionStartedAt(convID); !started.IsZero() {
			usageInfo.SessionAge = time.Since(started)
		}
	}

	llmMessages := buildInitialLLMMessages(systemPrompt, systemSections, history, req.Messages, convID, l.now())
	updateSystemMessage := func() {
		if len(llmMessages) > 0 && llmMessages[0].Role == "system" {
			llmMessages[0].Content = systemPrompt
			llmMessages[0].Sections = systemSections
		}
	}
	rebuildSystemPromptForModel := func(model string) {
		if req.SystemPrompt != "" {
			return
		}
		usageInfo.Model = model
		systemPrompt, systemSections = l.buildSystemPromptWithProfileSections(promptCtx, userMessage, history, l.modelInteractionProfileForModel(model))
		updateSystemMessage()
	}

	// Request-level tool restrictions are static for the run. Apply them
	// before model routing so tool-count-sensitive decisions see the same
	// effective surface the model will actually get.
	baseTools := l.tools
	if len(req.AllowedTools) > 0 {
		baseTools = baseTools.FilteredCopy(req.AllowedTools)
	}
	if len(req.RuntimeTools) > 0 {
		baseTools = baseTools.WithRuntimeTools(req.RuntimeTools)
	}
	if len(req.ExcludeTools) > 0 {
		baseTools = baseTools.FilteredCopyExcluding(req.ExcludeTools)
		log.Info("tools excluded from run", "excluded", req.ExcludeTools)
	}

	// Determine whether tool gating is active before model selection so
	// both router decisions and explicit-model preflight can reason
	// about the actual tool surface this run will expose.
	gatingActive := len(l.orchestratorTools) > 0 && (l.tools.Get("thane_now") != nil || l.tools.Get("thane_assign") != nil)
	if req.Hints[router.HintDelegationGating] == "disabled" {
		gatingActive = false
	}
	if gatingActive {
		log.Info("orchestrator tool gating active", "tools", l.orchestratorTools)
	}
	skipTagFilter := req.SkipTagFilter

	visibleTools := baseTools
	if gatingActive {
		visibleTools = visibleTools.FilteredCopy(l.orchestratorTools)
	}
	needsTools := len(visibleTools.List()) > 0
	needsStreaming := stream != nil
	needsImages := messagesNeedImages(req.Messages)
	contextSize := estimateLLMMessagesContextTokens(llmMessages)
	query := ""
	for i := len(req.Messages) - 1; i >= 0; i-- {
		if req.Messages[i].Role == "user" {
			query = req.Messages[i].Content
			break
		}
	}
	routeWithContextSize := func(size int) (string, *router.Decision, error) {
		routerReq := router.Request{
			Query:          query,
			ContextSize:    size,
			NeedsTools:     needsTools,
			NeedsStreaming: needsStreaming,
			NeedsImages:    needsImages,
			ToolCount:      len(visibleTools.List()),
			Priority:       router.PriorityInteractive,
			Hints:          req.Hints,
		}

		selected, decision := l.router.Route(ctx, routerReq)
		if needsImages && decision != nil && decision.NoEligible {
			return "", decision, noEligibleImageRoutingError(l.currentModelCatalog(), decision)
		}
		return selected, decision, nil
	}

	// Select model via router
	model := req.Model
	var routerDecision *router.Decision

	log.Debug("model selection start", "req_model", req.Model, "default_model", l.model)

	if model == "" || model == "thane" {
		if l.router != nil {
			// Estimate effective prompt size for routing. This includes
			// the assembled system prompt, user-visible message text, and
			// a conservative surcharge for image-bearing inputs.
			var routeErr error
			model, routerDecision, routeErr = routeWithContextSize(contextSize)
			if routeErr != nil {
				return nil, routeErr
			}
			log.Debug("model selected by router", "model", model)
		} else {
			model = l.model
			log.Debug("model selected as default (no router)", "model", model)
		}
	} else {
		rebuildSystemPromptForModel(model)
		contextSize = estimateLLMMessagesContextTokens(llmMessages)
		if _, prepErr := l.maybePrepareExplicitModel(ctx, model, needsTools, needsStreaming, needsImages, contextSize); prepErr != nil {
			return nil, prepErr
		}
		resolvedModel, err := l.preflightExplicitModel(model, needsTools, needsStreaming, needsImages, contextSize)
		if err != nil {
			return nil, err
		}
		model = resolvedModel
		log.Debug("model specified in request, skipping router", "model", model)
	}

	for routedPromptChecks := 0; routerDecision != nil; routedPromptChecks++ {
		rebuildSystemPromptForModel(model)
		actualContextSize := estimateLLMMessagesContextTokens(llmMessages)
		resolvedModel, err := l.preflightExplicitModel(model, needsTools, needsStreaming, needsImages, actualContextSize)
		if err == nil {
			model = resolvedModel
			break
		}
		if l.router == nil || !isContextWindowIncompatible(err) || routedPromptChecks >= 2 {
			return nil, err
		}

		previousModel := model
		var routeErr error
		model, routerDecision, routeErr = routeWithContextSize(actualContextSize)
		if routeErr != nil {
			return nil, routeErr
		}
		if model == previousModel {
			return nil, err
		}
		log.Info("model rerouted after model-specific prompt context check",
			"from_model", previousModel,
			"to_model", model,
			"context_size", actualContextSize,
		)
	}

	rebuildSystemPromptForModel(model)

	usageInfo.Model = model
	usageInfo.Routed = routerDecision != nil
	if cat := l.currentModelCatalog(); cat != nil {
		if dep, err := cat.ResolveDeploymentRef(model); err == nil && dep.ContextWindow > 0 {
			usageInfo.ContextWindow = dep.ContextWindow
		}
	}
	usageInfo.TokenCount = estimateLLMMessagesContextTokens(llmMessages)
	if line := awareness.FormatContextUsage(usageInfo); line != "" {
		systemPrompt += "\n" + line
		systemSections = appendPromptSection(systemSections, llm.PromptSection{
			Name:    "CONTEXT USAGE",
			Content: "\n" + line,
		})
	}
	updateSystemMessage()

	l.seedLiveRequestDetail(ctx, requestID, systemPrompt, userMessage, model, 0, llmMessages)

	startTime := time.Now()

	// Estimate system prompt size for cost logging.
	systemTokens := len(llmMessages[0].Content) / 4 // rough char-to-token ratio

	// Check if memory store supports tool call recording.
	recorder, hasRecorder := l.memory.(ToolCallRecorder)

	// Track whether the error handler triggered timeout recovery.
	var timeoutRecovered bool

	// Optional per-tool timeout wrapper for request-scoped runs such as
	// delegates. Cancelled after each tool completes.
	var currentToolCancel context.CancelFunc
	var currentToolStart time.Time
	var currentToolCallID string

	maxIterations := 50
	if req.MaxIterations > 0 {
		maxIterations = req.MaxIterations
	}

	currentTools := func() *tools.Registry {
		toolsForIter := baseTools
		if scope != nil && !skipTagFilter {
			if tagSnap := scope.Snapshot(); len(tagSnap) > 0 {
				tagList := make([]string, 0, len(tagSnap))
				for tag := range tagSnap {
					tagList = append(tagList, tag)
				}
				toolsForIter = baseTools.FilterByTags(tagList)
			}
		}
		return toolsForIter
	}
	activeTagList := func() []string {
		if scope == nil {
			return nil
		}
		tagSnap := scope.Snapshot()
		if len(tagSnap) == 0 {
			return nil
		}
		tags := make([]string, 0, len(tagSnap))
		for tag, active := range tagSnap {
			if active {
				tags = append(tags, tag)
			}
		}
		sort.Strings(tags)
		return tags
	}
	var (
		liveStreamMu        sync.Mutex
		liveStreamModel     string
		liveStreamIteration int
		liveStreamMessages  []llm.Message
		liveStreamText      strings.Builder
	)
	liveStreamCallback := stream
	if stream != nil && l.liveRequestRecorder != nil {
		liveStreamCallback = func(event llm.StreamEvent) {
			if event.Kind == llm.KindToken && event.Token != "" {
				liveStreamMu.Lock()
				liveStreamText.WriteString(event.Token)
				content := liveStreamText.String()
				model := liveStreamModel
				iterationCount := liveStreamIteration
				msgs := append([]llm.Message(nil), liveStreamMessages...)
				liveStreamMu.Unlock()

				l.liveRequestRecorder(ctx, logging.RequestContent{
					RequestID:        requestID,
					SystemPrompt:     systemPrompt,
					UserContent:      userMessage,
					Model:            model,
					AssistantContent: content,
					IterationCount:   iterationCount,
					Messages:         msgs,
				})
			}
			stream(event)
		}
	}
	effectiveToolNames := func() []string {
		toolsForIter := currentTools()
		if gatingActive {
			toolsForIter = toolsForIter.FilteredCopy(l.orchestratorTools)
		}
		defs := toolsForIter.List()
		if len(defs) == 0 {
			return nil
		}
		names := make([]string, 0, len(defs))
		for _, def := range defs {
			fn, _ := def["function"].(map[string]any)
			name, _ := fn["name"].(string)
			if name != "" {
				names = append(names, name)
			}
		}
		sort.Strings(names)
		return names
	}

	// Build iterate.Config with agent-specific callbacks.
	iterCfg := iterate.Config{
		MaxIterations:   maxIterations,
		Model:           model,
		LLM:             l.llm,
		Stream:          liveStreamCallback,
		DeferMixedText:  true,
		NudgeOnEmpty:    true,
		NudgePrompt:     prompts.EmptyResponseNudge,
		FallbackContent: firstNonEmpty(req.FallbackContent, prompts.EmptyResponseFallback),

		// Per-iteration tool definitions: recompute effective tools each
		// iteration so tags activated via activate_capability are reflected.
		ToolDefs: func(i int) []map[string]any {
			toolsForIter := currentTools()
			if gatingActive {
				return toolsForIter.FilteredCopy(l.orchestratorTools).List()
			}
			return toolsForIter.List()
		},

		// Tool availability check using the effective tools for this iteration.
		CheckToolAvail: func(toolName string) bool {
			toolsForIter := currentTools()
			if gatingActive {
				return toolsForIter.FilteredCopy(l.orchestratorTools).Get(toolName) != nil
			}
			return toolsForIter.Get(toolName) != nil
		},

		NormalizeToolCall: func(iterCtx context.Context, i int, tc llm.ToolCall) llm.ToolCall {
			repaired, changed := l.repairToolCall(tc)
			if changed {
				logging.Logger(iterCtx).Info("repaired tool call",
					"iteration", i,
					"from", tc.Function.Name,
					"to", repaired.Function.Name,
				)
			}
			return repaired
		},

		CheckBudget: func(totalOut int) bool {
			return req.MaxOutputTokens > 0 && totalOut >= req.MaxOutputTokens
		},

		Executor: &iterate.DirectExecutor{
			Exec: func(execCtx context.Context, name, argsJSON string) (string, error) {
				toolsForExec := currentTools()
				if gatingActive {
					toolsForExec = toolsForExec.FilteredCopy(l.orchestratorTools)
				}
				return toolsForExec.Execute(execCtx, name, argsJSON)
			},
		},

		// Iteration lifecycle callbacks.
		OnIterationStart: func(iterCtx context.Context, i int, currentModel string, msgs []llm.Message, _ []map[string]any) {
			iterLog := logging.Logger(iterCtx)

			// Rebuild system prompt each iteration so that:
			// - Capability context reflects tags activated mid-run
			// - Watchlist entities, state changes, and conditions are fresh
			// - KB articles and live providers see current tag state
			// Skip rebuild when a custom SystemPrompt is in use for callers
			// that assemble their own context externally.
			if i > 0 && len(msgs) > 0 && msgs[0].Role == "system" && req.SystemPrompt == "" {
				rebuilt := l.buildSystemPromptWithProfile(iterCtx, userMessage, history, l.modelInteractionProfileForModel(currentModel))
				// Omit FormatContextUsage — usageInfo was computed before the
				// run and would be misleading after prompt content changes.
				msgs[0].Content = rebuilt
				systemPrompt = rebuilt // keep retained content in sync
				systemTokens = len(rebuilt) / 4
			}

			msgSnapshot := append([]llm.Message(nil), msgs...)
			liveStreamMu.Lock()
			liveStreamModel = currentModel
			liveStreamIteration = i
			liveStreamMessages = msgSnapshot
			liveStreamText.Reset()
			liveStreamMu.Unlock()

			l.seedLiveRequestDetail(iterCtx, requestID, systemPrompt, userMessage, currentModel, i, msgSnapshot)

			iterMsgTokens := 0
			for _, m := range msgs {
				iterMsgTokens += len(m.Content) / 4
			}
			iterLog.Info("llm call",
				"kind", events.KindLLMCall,
				"iteration", i,
				"model", currentModel,
				"message_count", len(msgs),
				"est_tokens", iterMsgTokens,
				"system_tokens", systemTokens,
			)
			if stream != nil {
				effectiveTools := effectiveToolNames()
				startData := map[string]any{
					"request_id":      requestID,
					"est_tokens":      iterMsgTokens + systemTokens,
					"messages":        len(msgs),
					"iteration":       i,
					"tools":           len(effectiveTools),
					"effective_tools": effectiveTools,
					"active_tags":     activeTagList(),
				}
				if routerDecision != nil {
					startData["complexity"] = routerDecision.Complexity.String()
					if routerDecision.DetectedIntent != "" {
						startData["intent"] = routerDecision.DetectedIntent
					}
					startData["reasoning"] = routerDecision.Reasoning
				}
				stream(llm.StreamEvent{
					Kind:     llm.KindLLMStart,
					Response: &llm.ChatResponse{Model: currentModel},
					Data:     startData,
				})
			}
		},

		OnLLMResponse: func(iterCtx context.Context, llmResp *llm.ChatResponse, i int) {
			if stream != nil {
				stream(llm.StreamEvent{
					Kind:     llm.KindLLMResponse,
					Response: llmResp,
					Data: map[string]any{
						"request_id": requestID,
					},
				})
			}
			iterLog := logging.Logger(iterCtx)
			iterLog.Info("llm response",
				"kind", events.KindLLMResponse,
				"iteration", i,
				"model", llmResp.Model,
				"input_tokens", llmResp.InputTokens,
				"output_tokens", llmResp.OutputTokens,
				"tool_calls", len(llmResp.Message.ToolCalls),
			)
		},

		// Error handling: timeout retry, recovery model, failover.
		OnLLMError: l.buildLLMErrorHandler(ctx, stream, model, req, &timeoutRecovered),

		// Enrich context before each tool execution.
		OnBeforeToolExec: func(iterCtx context.Context, i int, tc llm.ToolCall) context.Context {
			toolCallID, _ := uuid.NewV7()
			toolCallIDStr := toolCallID.String()

			toolCtx := tools.WithConversationID(iterCtx, convID)
			toolCtx = tools.WithChannelBinding(toolCtx, channelBinding)
			toolCtx = tools.WithHints(toolCtx, req.Hints)
			if scope != nil {
				toolCtx = tools.WithInheritableCapabilityTags(toolCtx, scope.InheritableTags())
			}
			if l.archiver != nil {
				if sid := l.archiver.ActiveSessionID(convID); sid != "" {
					toolCtx = tools.WithSessionID(toolCtx, sid)
				}
			}
			toolCtx = tools.WithToolCallID(toolCtx, toolCallIDStr)
			toolCtx = tools.WithIterationIndex(toolCtx, i)
			if lid := loop.LoopIDFromContext(ctx); lid != "" {
				toolCtx = tools.WithLoopID(toolCtx, lid)
			} else if lid := req.Hints["loop_id"]; lid != "" {
				toolCtx = tools.WithLoopID(toolCtx, lid)
			}
			if req.ToolTimeout > 0 {
				toolCtx, currentToolCancel = context.WithTimeout(toolCtx, req.ToolTimeout)
			}
			currentToolStart = time.Now()
			currentToolCallID = toolCallIDStr
			logging.Logger(toolCtx).Info("tool call",
				"kind", events.KindToolCall,
				"iteration", i,
				"tool", tc.Function.Name,
				"tool_call_id", toolCallIDStr,
			)

			// Record tool call start.
			if hasRecorder {
				argsJSON := ""
				if tc.Function.Arguments != nil {
					argsBytes, _ := json.Marshal(tc.Function.Arguments)
					argsJSON = string(argsBytes)
				}
				if err := recorder.RecordToolCall(convID, "", toolCallIDStr, tc.Function.Name, argsJSON); err != nil {
					logging.Logger(iterCtx).Warn("failed to record tool call", "error", err)
				}
			}

			return toolCtx
		},

		OnToolCallStart: func(iterCtx context.Context, tc llm.ToolCall) {
			if stream != nil {
				stream(llm.StreamEvent{
					Kind:     llm.KindToolCallStart,
					ToolCall: &tc,
				})
			}
		},

		OnToolCallDone: func(iterCtx context.Context, toolName, result, errMsg string) {
			if currentToolCancel != nil {
				currentToolCancel()
				currentToolCancel = nil
			}
			durationMS := int64(0)
			if !currentToolStart.IsZero() {
				durationMS = time.Since(currentToolStart).Milliseconds()
			}
			currentToolStart = time.Time{}
			toolCallIDStr := tools.ToolCallIDFromContext(iterCtx)
			if toolCallIDStr == "" {
				toolCallIDStr = currentToolCallID
			}
			currentToolCallID = ""
			if stream != nil {
				doneData := map[string]any{
					"active_tags":     activeTagList(),
					"effective_tools": effectiveToolNames(),
				}
				stream(llm.StreamEvent{
					Kind:       llm.KindToolCallDone,
					ToolName:   toolName,
					ToolResult: result,
					ToolError:  errMsg,
					Data:       doneData,
				})
			}
			toolLog := logging.Logger(iterCtx)
			if errMsg != "" {
				toolLog.Warn("tool done",
					"kind", events.KindToolDone,
					"tool", toolName,
					"tool_call_id", toolCallIDStr,
					"ok", false,
					"duration_ms", durationMS,
					"error", errMsg,
				)
			} else {
				toolLog.Info("tool done",
					"kind", events.KindToolDone,
					"tool", toolName,
					"tool_call_id", toolCallIDStr,
					"ok", true,
					"duration_ms", durationMS,
				)
			}
			// Record tool call completion.
			if hasRecorder {
				if toolCallIDStr != "" {
					if err := recorder.CompleteToolCall(toolCallIDStr, result, errMsg); err != nil {
						logging.Logger(iterCtx).Warn("failed to complete tool call record", "error", err)
					}
				}
			}
		},

		// Post-response: memory storage, fact extraction, compaction.
		OnTextResponse: func(iterCtx context.Context, content string, msgs []llm.Message) {
			if err := l.memory.AddMessage(convID, "assistant", content); err != nil {
				logging.Logger(iterCtx).Warn("failed to store response", "error", err)
			}
			// Async fact extraction.
			if l.extractor != nil {
				extractMsgs := recentSlice(history, 6)
				go func() {
					if !l.extractor.ShouldExtract(userMessage, content, len(history)+len(req.Messages)+1, req.SkipContext) {
						return
					}
					extractCtx, cancel := context.WithTimeout(context.Background(), l.extractor.Timeout())
					defer cancel()
					if err := l.extractor.Extract(extractCtx, userMessage, content, extractMsgs); err != nil {
						log.Warn("fact extraction failed", "error", err)
					}
				}()
			}
			// Compaction.
			if l.compactor != nil && l.compactor.NeedsCompaction(convID) {
				preTokens := l.memory.GetTokenCount(convID)
				preMessages := len(l.memory.GetMessages(convID))
				logging.Logger(iterCtx).Info("triggering compaction",
					"tokens_before", preTokens,
					"messages_before", preMessages,
				)
				go func() {
					compactStart := time.Now()
					if err := l.compactor.Compact(context.Background(), convID); err != nil {
						log.Error("compaction failed", "error", err)
					} else {
						postTokens := l.memory.GetTokenCount(convID)
						postMessages := len(l.memory.GetMessages(convID))
						log.Info("compaction completed",
							"tokens_after", postTokens,
							"messages_after", postMessages,
							"tokens_freed", preTokens-postTokens,
							"messages_compacted", preMessages-postMessages,
							"elapsed", time.Since(compactStart).Round(time.Second),
						)
					}
				}()
			}
		},
	}

	engine := &iterate.Engine{}
	iterResult, err := engine.Run(ctx, iterCfg, llmMessages)
	if err != nil {
		if l.router != nil && routerDecision != nil {
			latency := time.Since(startTime).Milliseconds()
			l.router.RecordFailure(routerDecision.RequestID, latency, l.memory.GetTokenCount(convID), isTimeout(err))
		}
		return nil, err
	}

	// Record router outcome.
	if l.router != nil && routerDecision != nil {
		latency := time.Since(startTime).Milliseconds()
		l.router.RecordOutcome(routerDecision.RequestID, latency, l.memory.GetTokenCount(convID), true)
	}

	// For exhausted runs, store the forced text in memory.
	if iterResult.Exhausted && iterResult.Content != "" {
		if err := l.memory.AddMessage(convID, "assistant", iterResult.Content); err != nil {
			log.Warn("failed to store response", "error", err)
		}
	}

	finishReason := "stop"
	if iterResult.Exhausted {
		finishReason = iterResult.ExhaustReason
		if finishReason == "" {
			finishReason = "max_iterations"
		}
	}
	// Detect when the error handler triggered timeout recovery.
	if timeoutRecovered {
		finishReason = "timeout_recovery"
	}

	// Snapshot active tags for loops to carry forward.
	var activeTags []string
	if scope != nil {
		for tag := range scope.Snapshot() {
			activeTags = append(activeTags, tag)
		}
		sort.Strings(activeTags)

		// Persist conversation-scoped capability tags so they survive
		// across messages within the same conversation. Only user-
		// activated tags are saved — always-active, channel-pinned,
		// and lens tags are loaded independently each Run.
		if l.capTagStore != nil && convID != "" {
			userTags := scope.UserActivatedTags()
			sort.Strings(userTags)
			if err := l.capTagStore.SaveTags(convID, userTags); err != nil {
				log.Warn("failed to save conversation capability tags",
					"conversation_id", convID, "error", err)
			}
		}
	}

	resp = &Response{
		Content:                  iterResult.Content,
		Model:                    iterResult.Model,
		FinishReason:             finishReason,
		InputTokens:              iterResult.InputTokens,
		OutputTokens:             iterResult.OutputTokens,
		CacheCreationInputTokens: iterResult.CacheCreationInputTokens,
		CacheReadInputTokens:     iterResult.CacheReadInputTokens,
		ContextWindow:            usageInfo.ContextWindow,
		ToolsUsed:                iterResult.ToolsUsed,
		EffectiveTools:           effectiveToolNames(),
		Iterations:               iterResult.IterationCount,
		Exhausted:                iterResult.Exhausted,
		SessionID:                sessionID,
		RequestID:                requestID,
		ActiveTags:               activeTags,
		LoadedCapabilities:       toolcatalog.BuildLoadedCapabilityEntries(l.capSurface, activeTags),
	}

	l.recordLiveRequestDetail(ctx, requestID, systemPrompt, userMessage, iterResult)

	l.recordUsage(ctx, req, iterResult.Model, iterResult.InputTokens, iterResult.OutputTokens, iterResult.CacheCreationInputTokens, iterResult.CacheCreation5mInputTokens, iterResult.CacheCreation1hInputTokens, iterResult.CacheReadInputTokens, convID, sessionTag, requestID, iterResult.UpstreamRequestID)
	l.archiveIterations(log, convID, iterResult.Iterations)

	// Content retention is fire-and-forget with a short deadline so it
	// never blocks response delivery.
	go func() {
		bgCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
		defer cancel()
		l.retainContent(bgCtx, requestID, systemPrompt, userMessage, iterResult)
	}()

	return resp, nil
}

func (l *Loop) conversationChannelBinding(conversationID string) *memory.ChannelBinding {
	if conversationID == "" || l.memory == nil {
		return nil
	}
	var conv *memory.Conversation
	switch store := l.memory.(type) {
	case *memory.SQLiteStore:
		conv = store.GetConversation(conversationID)
	case *memory.Store:
		conv = store.GetConversation(conversationID)
	default:
		return nil
	}
	if conv == nil || conv.Metadata == nil {
		return nil
	}
	return conv.Metadata.ChannelBinding.Clone()
}

// buildLLMErrorHandler returns the OnLLMError callback that implements
// the agent's timeout retry, recovery model downshift, and failover logic.
func (l *Loop) buildLLMErrorHandler(ctx context.Context, stream llm.StreamCallback, defaultModel string, req *Request, timeoutRecovered *bool) func(context.Context, error, string, []llm.Message, []map[string]any, llm.StreamCallback) (*llm.ChatResponse, string, error) {
	explicitModelRequested := strings.TrimSpace(req.Model) != ""

	return func(iterCtx context.Context, err error, model string,
		msgs []llm.Message, toolDefs []map[string]any,
		_ llm.StreamCallback) (*llm.ChatResponse, string, error) {

		iterLog := logging.Logger(iterCtx)
		if cancelErr := canceledContextError(err, ctx, iterCtx); cancelErr != nil {
			iterLog.Debug("LLM call canceled", "error", cancelErr, "model", model)
			return nil, "", cancelErr
		}
		iterLog.Error("LLM call failed", "error", err, "model", model)

		if isTimeout(err) {
			// Timeout recovery: retry same model with exponential backoff.
			baseDelay := l.retryBaseDelay
			if baseDelay == 0 {
				baseDelay = timeoutRetryBaseDelay
			}
			for retry := 1; retry <= timeoutRetryLimit; retry++ {
				backoff := baseDelay * time.Duration(1<<(retry-1))
				iterLog.Warn("LLM timeout, retrying same model",
					"retry", retry,
					"backoff", backoff.Round(time.Second),
					"model", model,
				)
				select {
				case <-ctx.Done():
					return nil, "", ctx.Err()
				case <-time.After(backoff):
				}
				resp, retryErr := l.llm.ChatStream(iterCtx, model, msgs, toolDefs, stream)
				if retryErr == nil {
					iterLog.Info("LLM retry succeeded", "retry", retry, "model", model)
					return resp, model, nil
				}
				if !isTimeout(retryErr) {
					return nil, "", retryErr
				}
			}
			// Retries exhausted. Downshift to recovery model only if
			// configured AND tool calls were already completed — a plain
			// timeout on the first LLM call (no tool work done) should
			// surface the static fallback, not a misleading "recovery" summary.
			if l.recoveryModel != "" && len(toolsUsedFromMessages(msgs)) > 0 {
				iterLog.Warn("retries exhausted, downshifting to recovery model",
					"recovery_model", l.recoveryModel,
				)
				recoveryMessages := buildRecoveryPrompt(msgs, toolsUsedFromMessages(msgs))
				recoveryCtx, recoveryCancel := context.WithTimeout(context.Background(), timeoutRecoveryDeadline)
				resp, recoveryErr := l.llm.ChatStream(recoveryCtx, l.recoveryModel, recoveryMessages, nil, stream)
				recoveryCancel()
				if recoveryErr != nil {
					iterLog.Error("recovery model also failed",
						"error", recoveryErr,
						"recovery_model", l.recoveryModel,
					)
					// Return a static recovery response as content.
					return &llm.ChatResponse{
						Model:   l.recoveryModel,
						Message: llm.Message{Role: "assistant", Content: prompts.TimeoutRecoveryEmpty},
					}, l.recoveryModel, nil
				}
				iterLog.Info("timeout recovery successful", "recovery_model", l.recoveryModel)
				if resp.Message.Content == "" {
					resp.Message.Content = prompts.TimeoutRecoveryEmpty
				}
				*timeoutRecovered = true
				return resp, l.recoveryModel, nil
			}
			// No recovery model — return a static fallback response
			// so the user sees something rather than an error.
			iterLog.Error("LLM timeout with no recovery model, returning static fallback")
			*timeoutRecovered = true
			used := toolsUsedFromMessages(msgs)
			names := make([]string, 0, len(used))
			for name := range used {
				names = append(names, name)
			}
			sort.Strings(names)
			parts := make([]string, 0, len(names))
			total := 0
			for _, name := range names {
				count := used[name]
				parts = append(parts, fmt.Sprintf("%s ×%d", name, count))
				total += count
			}
			toolList := strings.Join(parts, ", ")
			if toolList == "" {
				toolList = "none"
			}
			return &llm.ChatResponse{
				Model:   model,
				Message: llm.Message{Role: "assistant", Content: fmt.Sprintf(prompts.TimeoutRecoveryFallback, total, toolList)},
			}, model, nil
		}

		// Ambiguous route selection is user-fixable and should surface
		// directly instead of being silently collapsed to the default.
		var ambiguous *llm.AmbiguousModelError
		if errors.As(err, &ambiguous) {
			return nil, "", err
		}

		if explicitModelRequested {
			if resp, recoveredModel, recoveryErr, handled := l.maybeRetryExplicitModelAfterProviderContextError(iterCtx, model, err, msgs, toolDefs, stream); handled {
				if recoveryErr != nil {
					iterLog.Warn("explicit model context recovery failed", "model", model, "error", recoveryErr)
					return nil, "", recoveryErr
				}
				iterLog.Info("explicit model context recovery successful", "model", recoveredModel)
				return resp, recoveredModel, nil
			}
		}

		if isUserFixableModelError(err) {
			iterLog.Info("user-fixable model error, skipping failover", "model", model)
			return nil, "", err
		}

		if explicitModelRequested {
			iterLog.Info("explicit model requested, skipping failover", "model", model)
			return nil, "", err
		}

		// Non-timeout error: failover to the router's current default
		// model when available so live routing policy updates apply here
		// too. Fall back to the loop's static startup default only when
		// no router is configured.
		fallbackModel := l.model
		if l.router != nil && l.router.DefaultModel() != "" {
			fallbackModel = l.router.DefaultModel()
		}
		if model != fallbackModel {
			iterLog.Info("attempting failover", "from", model, "to", fallbackModel)
			if l.failoverHandler != nil {
				if ferr := l.failoverHandler.OnFailover(iterCtx, model, fallbackModel, err.Error()); ferr != nil {
					iterLog.Warn("failover handler failed", "error", ferr)
				}
			}
			resp, failErr := l.llm.ChatStream(iterCtx, fallbackModel, msgs, toolDefs, stream)
			if failErr != nil {
				iterLog.Error("failover also failed", "error", failErr, "model", fallbackModel)
				return nil, "", failErr
			}
			iterLog.Info("failover successful", "model", fallbackModel)
			return resp, fallbackModel, nil
		}

		return nil, "", err
	}
}

// toArchivedIterations converts [iterate.IterationRecord] to archive-ready structs.
func toArchivedIterations(sessionID string, iters []iterate.IterationRecord) []memory.ArchivedIteration {
	archived := make([]memory.ArchivedIteration, len(iters))
	for i, iter := range iters {
		archived[i] = memory.ArchivedIteration{
			SessionID:      sessionID,
			IterationIndex: iter.Index,
			Model:          iter.Model,
			InputTokens:    iter.InputTokens,
			OutputTokens:   iter.OutputTokens,
			ToolCallCount:  len(iter.ToolCallIDs),
			ToolCallIDs:    iter.ToolCallIDs,
			ToolsOffered:   iter.ToolsOffered,
			StartedAt:      iter.StartedAt,
			DurationMs:     iter.DurationMs,
			HasToolCalls:   iter.HasToolCalls,
			BreakReason:    iter.BreakReason,
		}
	}
	return archived
}

// archiveIterations persists iteration records. Tool call linkage happens
// later in ArchiveConversation when tool calls are moved to the archive.
// Errors are logged but not returned.
func (l *Loop) archiveIterations(log *slog.Logger, convID string, iterations []iterate.IterationRecord) {
	if l.archiver == nil || len(iterations) == 0 {
		return
	}
	// Ensure a session exists so first-turn iterations are not lost.
	sessionID := l.archiver.EnsureSession(convID)
	if sessionID == "" {
		log.Warn("no active session for iteration archive", "conversation_id", convID)
		return
	}
	archived := toArchivedIterations(sessionID, iterations)
	if err := l.archiver.ArchiveIterations(archived); err != nil {
		log.Warn("failed to archive iterations", "error", err)
	}
}

// retainContent captures request-level content (system prompt, tool call
// details, messages) for live inspection and optional persistence.
func (l *Loop) retainContent(ctx context.Context, requestID, systemPrompt, userMessage string, result *iterate.Result) {
	if l.requestRecorder == nil {
		return
	}
	l.requestRecorder(ctx, logging.RequestContent{
		RequestID:        requestID,
		SystemPrompt:     systemPrompt,
		UserContent:      userMessage,
		Model:            result.Model,
		AssistantContent: result.Content,
		IterationCount:   result.IterationCount,
		InputTokens:      result.InputTokens,
		OutputTokens:     result.OutputTokens,
		ToolsUsed:        result.ToolsUsed,
		Exhausted:        result.Exhausted,
		ExhaustReason:    result.ExhaustReason,
		Messages:         result.Messages,
	})
}

func (l *Loop) recordLiveRequestDetail(ctx context.Context, requestID, systemPrompt, userMessage string, result *iterate.Result) {
	if l.liveRequestRecorder == nil || result == nil {
		return
	}
	l.liveRequestRecorder(ctx, logging.RequestContent{
		RequestID:        requestID,
		SystemPrompt:     systemPrompt,
		UserContent:      userMessage,
		Model:            result.Model,
		AssistantContent: result.Content,
		IterationCount:   result.IterationCount,
		InputTokens:      result.InputTokens,
		OutputTokens:     result.OutputTokens,
		ToolsUsed:        result.ToolsUsed,
		Exhausted:        result.Exhausted,
		ExhaustReason:    result.ExhaustReason,
		Messages:         result.Messages,
	})
}

func (l *Loop) seedLiveRequestDetail(ctx context.Context, requestID, systemPrompt, userMessage, model string, iterationCount int, messages []llm.Message) {
	if l.liveRequestRecorder == nil {
		return
	}
	l.liveRequestRecorder(ctx, logging.RequestContent{
		RequestID:      requestID,
		SystemPrompt:   systemPrompt,
		UserContent:    userMessage,
		Model:          model,
		IterationCount: iterationCount,
		Messages:       messages,
	})
}

// Timeout recovery constants.
const (
	// timeoutRetryLimit is the number of same-model retries before
	// downshifting to the recovery model.
	timeoutRetryLimit = 2

	// timeoutRecoveryDeadline is the context deadline for the recovery
	// model call. Kept short because the recovery model should be fast.
	timeoutRecoveryDeadline = 30 * time.Second

	// timeoutRetryBaseDelay is the initial backoff delay between retries.
	timeoutRetryBaseDelay = 2 * time.Second

	// maxToolResultPreview is the maximum length of a tool result
	// included in the recovery prompt.
	maxToolResultPreview = 200
)

// isTimeout reports whether err is a timeout or deadline-exceeded error.
// It checks for context.DeadlineExceeded and common provider-level
// timeout indicators (Anthropic overload, HTTP 529). String matching
// is case-insensitive to handle varied provider error formats.
func isTimeout(err error) bool {
	if errors.Is(err, context.DeadlineExceeded) {
		return true
	}
	msg := strings.ToLower(err.Error())
	return strings.Contains(msg, "timeout") ||
		strings.Contains(msg, "overloaded") ||
		strings.Contains(msg, "529")
}

func canceledContextError(err error, contexts ...context.Context) error {
	for _, ctx := range contexts {
		if ctx == nil {
			continue
		}
		if errors.Is(ctx.Err(), context.Canceled) {
			return context.Canceled
		}
	}
	if errors.Is(err, context.Canceled) {
		return context.Canceled
	}
	return nil
}

func isUserFixableModelError(err error) bool {
	if err == nil {
		return false
	}
	msg := strings.TrimSpace(err.Error())
	if !strings.HasPrefix(msg, "API error ") {
		return false
	}
	rest := strings.TrimPrefix(msg, "API error ")
	codeField, _, ok := strings.Cut(rest, ":")
	if !ok {
		return false
	}
	code, parseErr := strconv.Atoi(strings.TrimSpace(codeField))
	if parseErr != nil {
		return false
	}
	return code >= 400 && code < 500
}

// buildRecoveryPrompt constructs a minimal message history for the
// recovery model. It scans llmMessages for completed tool calls and
// builds a summary the recovery model can use to tell the user what
// happened.
func buildRecoveryPrompt(messages []llm.Message, toolsUsed map[string]int) []llm.Message {
	var sb strings.Builder
	sb.WriteString("The previous assistant completed these tool calls before timing out:\n\n")

	for _, msg := range messages {
		if msg.Role != "tool" {
			continue
		}
		status := "success"
		preview := msg.Content
		if strings.HasPrefix(preview, "Error:") {
			status = "error"
		}
		if runes := []rune(preview); len(runes) > maxToolResultPreview {
			preview = string(runes[:maxToolResultPreview]) + "..."
		}
		sb.WriteString(fmt.Sprintf("- [%s] %s\n", status, strings.TrimSpace(preview)))
	}

	sb.WriteString("\nTool call counts: ")
	names := make([]string, 0, len(toolsUsed))
	for name := range toolsUsed {
		names = append(names, name)
	}
	sort.Strings(names)
	parts := make([]string, 0, len(names))
	for _, name := range names {
		parts = append(parts, fmt.Sprintf("%s ×%d", name, toolsUsed[name]))
	}
	sb.WriteString(strings.Join(parts, ", "))
	sb.WriteString("\n\nSummarize what was completed and what may remain undone. Be concise.")

	return []llm.Message{
		{
			Role:    "system",
			Content: prompts.TimeoutRecoverySystem,
		},
		{
			Role:    "user",
			Content: sb.String(),
		},
	}
}

// toolsUsedFromMessages derives a tool-name to call-count map from the
// message history. It pairs tool calls in assistant messages with their
// corresponding tool result messages to count only completed calls.
func toolsUsedFromMessages(msgs []llm.Message) map[string]int {
	// Build a map of tool call ID → tool name from assistant messages.
	callNames := make(map[string]string)
	for _, m := range msgs {
		for _, tc := range m.ToolCalls {
			callNames[tc.ID] = tc.Function.Name
		}
	}
	// Count only tool calls that have a matching result message.
	used := make(map[string]int)
	for _, m := range msgs {
		if m.Role == "tool" && m.ToolCallID != "" {
			if name, ok := callNames[m.ToolCallID]; ok {
				used[name]++
			}
		}
	}
	return used
}

// MemoryStats returns current memory statistics.
func (l *Loop) MemoryStats() map[string]any {
	return l.memory.Stats()
}

// GetHistory returns the conversation messages for a given conversation.
func (l *Loop) GetHistory(conversationID string) []memory.Message {
	return l.memory.GetMessages(conversationID)
}

func (l *Loop) GetTokenCount(conversationID string) int {
	return l.memory.GetTokenCount(conversationID)
}

// GetContextWindow returns the context window size of the default model.
func (l *Loop) GetContextWindow() int {
	return l.contextWindow
}

// ResetConversation archives and then clears the conversation history.
func (l *Loop) ResetConversation(conversationID string) error {
	l.archiveAndEndSession(conversationID, "reset")
	l.clearPersistedCapabilityTags(conversationID)

	// Clean up temp files for this conversation.
	if l.tools != nil {
		if tfs := l.tools.TempFileStore(); tfs != nil {
			if err := tfs.Cleanup(conversationID); err != nil {
				l.logger.Error("failed to clean up temp files on reset",
					"conversation_id", conversationID,
					"error", err,
				)
			}
		}
	}

	if err := l.memory.Clear(conversationID); err != nil {
		return err
	}

	// Start a fresh session.
	if l.archiver != nil {
		if _, err := l.archiver.StartSession(conversationID); err != nil {
			l.logger.Error("failed to start new session after reset", "error", err)
		}
	}

	return nil
}

// CloseSession gracefully closes the current session, archives messages,
// injects a carry-forward handoff into the new session, and starts a
// fresh session. Unlike ResetConversation, the carry-forward summary
// provides continuity across the session boundary.
func (l *Loop) CloseSession(conversationID, reason, carryForward string) error {
	if reason == "" {
		reason = "close"
	}

	// Archive and end current session (same pattern as ResetConversation).
	l.archiveAndEndSession(conversationID, reason)
	l.clearPersistedCapabilityTags(conversationID)

	// Clean up temp files for this conversation.
	if l.tools != nil {
		if tfs := l.tools.TempFileStore(); tfs != nil {
			if err := tfs.Cleanup(conversationID); err != nil {
				l.logger.Error("failed to clean up temp files on close",
					"conversation_id", conversationID,
					"error", err,
				)
			}
		}
	}

	if err := l.memory.Clear(conversationID); err != nil {
		return fmt.Errorf("clear memory: %w", err)
	}

	// Start a fresh session.
	if l.archiver != nil {
		if _, err := l.archiver.StartSession(conversationID); err != nil {
			l.logger.Error("failed to start new session after close", "error", err)
		}
	}

	// Inject carry-forward into the new session as a system message.
	if carryForward != "" {
		if cs, ok := l.memory.(interface {
			AddCompactionSummary(string, string) error
		}); ok {
			if err := cs.AddCompactionSummary(conversationID, "[Session Handoff]\n"+carryForward); err != nil {
				l.logger.Error("failed to inject carry-forward", "error", err)
			}
		}
	}

	l.logger.Info("session closed",
		"conversation_id", conversationID,
		"reason", reason,
		"carry_forward_len", len(carryForward),
	)
	return nil
}

// CheckpointSession archives a snapshot of the current conversation state
// without ending the session. The active session continues uninterrupted.
func (l *Loop) CheckpointSession(conversationID, label string) error {
	if l.archiver == nil {
		return fmt.Errorf("no archiver configured")
	}

	messages := l.getAllMessages(conversationID)
	if len(messages) == 0 {
		return fmt.Errorf("no messages to checkpoint")
	}

	reason := "checkpoint"
	if label != "" {
		reason = "checkpoint:" + label
	}

	if err := l.archiver.ArchiveConversation(conversationID, messages, reason); err != nil {
		return fmt.Errorf("archive checkpoint: %w", err)
	}

	l.logger.Info("session checkpoint created",
		"conversation_id", conversationID,
		"label", label,
		"messages", len(messages),
	)
	return nil
}

// SplitSession retroactively splits the current session at a past message
// boundary. Messages before the split point are archived as a completed
// session; messages at and after the split point are retained as the
// current session. Exactly one of atIndex (negative offset from end) or
// atMessage (substring match) must be non-zero.
func (l *Loop) SplitSession(conversationID string, atIndex int, atMessage string) error {
	if l.archiver == nil {
		return fmt.Errorf("no archiver configured")
	}

	messages := l.getAllMessages(conversationID)
	if len(messages) == 0 {
		return fmt.Errorf("no messages to split")
	}

	splitIdx, err := findSplitPoint(messages, atIndex, atMessage)
	if err != nil {
		return err
	}

	preSplit := messages[:splitIdx]
	postSplit := messages[splitIdx:]

	// Archive pre-split messages.
	if err := l.archiver.ArchiveConversation(conversationID, preSplit, "split"); err != nil {
		return fmt.Errorf("archive pre-split messages: %w", err)
	}

	// End the current session at the split-point timestamp.
	if sid := l.archiver.ActiveSessionID(conversationID); sid != "" {
		if err := l.archiver.EndSession(sid, "split"); err != nil {
			l.logger.Error("failed to end session at split point", "error", err)
		}
	}
	l.clearPersistedCapabilityTags(conversationID)

	// Start a new session for the post-split messages.
	if _, err := l.archiver.StartSession(conversationID); err != nil {
		l.logger.Error("failed to start new session after split", "error", err)
	}

	// Rebuild working memory with only the post-split messages.
	if err := l.memory.Clear(conversationID); err != nil {
		return fmt.Errorf("clear memory for split: %w", err)
	}
	for _, m := range postSplit {
		if err := l.memory.AddMessage(conversationID, m.Role, m.Content); err != nil {
			l.logger.Error("failed to re-add post-split message", "error", err, "role", m.Role)
		}
	}

	l.logger.Info("session split",
		"conversation_id", conversationID,
		"pre_split_msgs", len(preSplit),
		"post_split_msgs", len(postSplit),
	)
	return nil
}

// getAllMessages retrieves all messages for a conversation, preferring the
// now returns the current time via the configurable clock. If nowFunc
// is nil (e.g., in tests using a bare struct literal), it falls back to
// time.Now.
func (l *Loop) now() time.Time {
	if l.nowFunc != nil {
		return l.nowFunc()
	}
	return time.Now()
}

// full-fidelity GetAllMessages when available.
func (l *Loop) getAllMessages(conversationID string) []memory.Message {
	if full, ok := l.memory.(interface {
		GetAllMessages(string) []memory.Message
	}); ok {
		return full.GetAllMessages(conversationID)
	}
	return l.memory.GetMessages(conversationID)
}

// maxTranscriptBytes caps the transcript size returned by
// ConversationTranscript to avoid generating oversized LLM prompts.
const maxTranscriptBytes = 32 * 1024

// ConversationTranscript returns a formatted text transcript of the
// current in-memory conversation for the given ID. System and tool
// messages are excluded to focus on user/assistant dialogue. Returns
// an empty string if no user/assistant messages exist after filtering
// (for example, when there are no messages or only system/tool
// messages). The output is capped at [maxTranscriptBytes] to keep
// downstream LLM prompts within reasonable context limits.
func (l *Loop) ConversationTranscript(conversationID string) string {
	messages := l.getAllMessages(conversationID)
	if len(messages) == 0 {
		return ""
	}
	now := l.now()
	var b strings.Builder
	for _, m := range messages {
		if m.Role == "system" || m.Role == "tool" {
			continue
		}
		fmt.Fprintf(&b, "[%s] %s: %s\n", promptfmt.FormatDeltaOnly(m.Timestamp, now), m.Role, m.Content)
		if b.Len() > maxTranscriptBytes {
			b.WriteString("\n... (truncated)\n")
			break
		}
	}
	return b.String()
}

// archiveAndEndSession archives all messages and ends the active session.
// Errors are logged but not propagated — callers should not be blocked by
// archive failures.
func (l *Loop) archiveAndEndSession(conversationID, reason string) {
	if l.archiver == nil {
		return
	}

	messages := l.getAllMessages(conversationID)
	if len(messages) > 0 {
		if err := l.archiver.ArchiveConversation(conversationID, messages, reason); err != nil {
			l.logger.Error("failed to archive conversation", "error", err)
		}
	}

	if sid := l.archiver.ActiveSessionID(conversationID); sid != "" {
		if err := l.archiver.EndSession(sid, reason); err != nil {
			l.logger.Error("failed to end session", "error", err)
		}
	}
}

func (l *Loop) clearPersistedCapabilityTags(conversationID string) {
	if l.capTagStore == nil || conversationID == "" {
		return
	}
	if err := l.capTagStore.SaveTags(conversationID, nil); err != nil {
		l.logger.Warn("failed to clear conversation capability tags",
			"conversation_id", conversationID,
			"error", err,
		)
	}
}

// findSplitPoint determines the message index at which to split. Exactly
// one of atIndex (negative offset from the end) or atMessage (substring
// match) must be provided. Returns the index of the first post-split message.
func findSplitPoint(messages []memory.Message, atIndex int, atMessage string) (int, error) {
	if atIndex != 0 {
		// Negative offset from end: -1 = last message, -5 = five from end.
		idx := len(messages) + atIndex
		if idx <= 0 || idx >= len(messages) {
			return 0, fmt.Errorf("at_index %d out of range for %d messages", atIndex, len(messages))
		}
		return idx, nil
	}

	// Substring match — find the first message containing the string.
	for i, m := range messages {
		if i == 0 {
			continue // Splitting before the first message is a no-op.
		}
		if strings.Contains(m.Content, atMessage) {
			return i, nil
		}
	}
	return 0, fmt.Errorf("no message found containing %q", atMessage)
}

// ShutdownArchive archives the current conversation state before shutdown.
func (l *Loop) ShutdownArchive(conversationID string) {
	l.archiveAndEndSession(conversationID, "shutdown")
}

// TriggerCompaction manually triggers conversation compaction.
func (l *Loop) TriggerCompaction(ctx context.Context, conversationID string) error {
	if l.compactor == nil {
		return fmt.Errorf("compaction not configured")
	}
	return l.compactor.Compact(ctx, conversationID)
}

// ToolsJSON returns the tools definition as JSON (for debugging).
func (l *Loop) ToolsJSON() string {
	data, _ := json.MarshalIndent(l.tools.List(), "", "  ")
	return string(data)
}

// Process is a convenience wrapper for single-shot requests (no streaming).
func (l *Loop) Process(ctx context.Context, conversationID, message string) (string, error) {
	req := &Request{
		ConversationID: conversationID,
		Messages: []Message{{
			Role:    "user",
			Content: message,
		}},
	}

	resp, err := l.Run(ctx, req, nil)
	if err != nil {
		return "", err
	}
	return resp.Content, nil
}

// recentSlice returns the last n messages from the slice, or all of them
// if the slice is shorter than n. Used for extraction context.
func recentSlice(msgs []memory.Message, n int) []memory.Message {
	if len(msgs) <= n {
		return msgs
	}
	return msgs[len(msgs)-n:]
}

// buildInitialLLMMessages assembles the provider-neutral chat payload for
// the first model call in a run. The system prompt carries durable runtime
// instructions; stored conversation turns stay in role-native messages so
// provider adapters can map them to their native messages[] transport.
func buildInitialLLMMessages(systemPrompt string, systemSections []llm.PromptSection, history []memory.Message, requestMessages []Message, conversationID string, now time.Time) []llm.Message {
	out := make([]llm.Message, 0, 1+len(history)+len(requestMessages))
	out = append(out, llm.Message{
		Role:     "system",
		Content:  systemPrompt,
		Sections: systemSections,
	})
	var previous *memory.Message
	for i := range history {
		converted, ok := historyMessageToLLM(history[i], now)
		if !ok {
			continue
		}
		if previous != nil {
			if marker, ok := historyGapMarker(*previous, history[i]); ok {
				out = append(out, marker)
			}
		}
		out = append(out, converted)
		previous = &history[i]
	}
	for _, m := range triggerMessagesForRequest(conversationID, requestMessages) {
		out = append(out, llm.Message{
			Role:    m.Role,
			Content: m.Content,
			Images:  append([]llm.ImageContent(nil), m.Images...),
		})
	}
	return out
}

// triggerMessagesForRequest returns the request messages that represent the
// new turn. Open WebUI sends the whole client-side transcript each request;
// Thane memory is the authoritative transcript, so only the final user turn
// is appended for owu-* conversations.
func triggerMessagesForRequest(conversationID string, messages []Message) []Message {
	if !strings.HasPrefix(conversationID, "owu-") || len(messages) == 0 {
		return messages
	}
	for i := len(messages) - 1; i >= 0; i-- {
		if messages[i].Role == "user" {
			return messages[i:]
		}
	}
	return messages
}

// historyMessageToLLM converts stored memory rows into safe chat messages.
// User and assistant turns remain role-native. Memory system rows are
// compaction summaries and handoffs, not provider-level system prompts, so
// they are carried as historical assistant notes.
func historyMessageToLLM(m memory.Message, now time.Time) (llm.Message, bool) {
	role := strings.TrimSpace(m.Role)
	content := m.Content
	if role == "" && strings.TrimSpace(content) == "" {
		return llm.Message{}, false
	}
	switch role {
	case "user", "assistant":
		return llm.Message{Role: role, Content: historyContentHeader("stored conversation history", role, m.Timestamp, now) + content}, true
	case "system":
		return llm.Message{
			Role:    "assistant",
			Content: historyContentHeader("stored conversation memory note; original_role=system; not active instruction", "", m.Timestamp, now) + content,
		}, true
	case "tool":
		return llm.Message{
			Role:    "assistant",
			Content: historyContentHeader("stored historical tool result; original_role=tool; context only", "", m.Timestamp, now) + content,
		}, true
	default:
		return llm.Message{
			Role:    "user",
			Content: historyContentHeader("stored historical message; original_role="+role, "", m.Timestamp, now) + content,
		}, true
	}
}

func historyContentHeader(kind, role string, timestamp, now time.Time) string {
	var parts []string
	if kind != "" {
		parts = append(parts, kind)
	}
	if role != "" {
		parts = append(parts, "role="+role)
	}
	if !timestamp.IsZero() && !now.IsZero() {
		parts = append(parts, "age_delta="+promptfmt.FormatDeltaOnly(timestamp, now))
	}
	return "[" + strings.Join(parts, "; ") + "]\n"
}

func historyGapMarker(previous, next memory.Message) (llm.Message, bool) {
	if previous.Timestamp.IsZero() || next.Timestamp.IsZero() {
		return llm.Message{}, false
	}
	gap := next.Timestamp.Sub(previous.Timestamp)
	if gap < storedHistoryGapThreshold {
		return llm.Message{}, false
	}
	seconds := int64(gap.Truncate(time.Second) / time.Second)
	if seconds <= 0 {
		return llm.Message{}, false
	}
	return llm.Message{
		Role:    "assistant",
		Content: fmt.Sprintf("[stored conversation history gap; metadata only]\n+%ds elapsed between adjacent stored messages before the next entry.", seconds),
	}, true
}

func firstNonEmpty(values ...string) string {
	for _, value := range values {
		if value != "" {
			return value
		}
	}
	return ""
}

// recordUsage persists a usage record for a completed LLM interaction.
// No-op when usage recording is not configured. Errors are logged but
// do not affect the caller.
//
// cacheCreate5m and cacheCreate1h break down cacheCreateIn by TTL when
// the provider exposes the breakdown (Anthropic). Pass 0/0 when the
// provider doesn't attribute the writes; pricing falls back to the 5m
// rate for the unattributed portion.
//
// upstreamRequestID is the provider-side request ID (e.g. Anthropic's
// `x-request-id` response header) when the provider exposes one. Pass
// "" when no upstream ID is available; the column accepts empty.
func (l *Loop) recordUsage(ctx context.Context, req *Request, model string, totalIn, totalOut, cacheCreateIn, cacheCreate5m, cacheCreate1h, cacheReadIn int, convID, sessionTag, requestID, upstreamRequestID string) {
	if l.usageStore == nil {
		return
	}

	role := "interactive"
	taskName := ""
	if req.UsageRole != "" {
		role = req.UsageRole
	}
	if req.UsageTaskName != "" {
		taskName = req.UsageTaskName
	}
	if req.SkipContext {
		role = "auxiliary"
	}
	if req.Hints != nil {
		if req.Hints["source"] == "scheduler" {
			role = "scheduled"
			taskName = req.Hints["task"]
		}
	}

	identity := usage.ResolveModelIdentity(model, l.currentModelCatalog())
	cost := usage.ComputeDetailedCostForIdentityWithTTL(identity, totalIn, cacheCreateIn, cacheCreate5m, cacheCreate1h, cacheReadIn, totalOut, l.pricing)
	rec := usage.Record{
		Timestamp:                  time.Now(),
		RequestID:                  requestID,
		UpstreamRequestID:          upstreamRequestID,
		SessionID:                  sessionTag,
		ConversationID:             convID,
		Model:                      identity.Model,
		UpstreamModel:              identity.UpstreamModel,
		Resource:                   identity.Resource,
		Provider:                   identity.Provider,
		InputTokens:                totalIn,
		OutputTokens:               totalOut,
		CacheCreationInputTokens:   cacheCreateIn,
		CacheCreation5mInputTokens: cacheCreate5m,
		CacheCreation1hInputTokens: cacheCreate1h,
		CacheReadInputTokens:       cacheReadIn,
		CostUSD:                    cost,
		Role:                       role,
		TaskName:                   taskName,
	}

	if err := l.usageStore.Record(ctx, rec); err != nil {
		l.logger.Warn("failed to record usage",
			"error", err,
			"request_id", requestID,
		)
	}
}
