agent.go

   1// Package agent is the core orchestration layer for Crush AI agents.
   2//
   3// It provides session-based AI agent functionality for managing
   4// conversations, tool execution, and message handling. It coordinates
   5// interactions between language models, messages, sessions, and tools while
   6// handling features like automatic summarization, queuing, and token
   7// management.
   8package agent
   9
  10import (
  11	"cmp"
  12	"context"
  13	_ "embed"
  14	"encoding/base64"
  15	"errors"
  16	"fmt"
  17	"log/slog"
  18	"net/http"
  19	"os"
  20	"regexp"
  21	"strconv"
  22	"strings"
  23	"sync"
  24	"time"
  25
  26	"charm.land/catwalk/pkg/catwalk"
  27	"charm.land/fantasy"
  28	"charm.land/fantasy/providers/anthropic"
  29	"charm.land/fantasy/providers/bedrock"
  30	"charm.land/fantasy/providers/google"
  31	"charm.land/fantasy/providers/openai"
  32	"charm.land/fantasy/providers/openrouter"
  33	"charm.land/fantasy/providers/vercel"
  34	"charm.land/lipgloss/v2"
  35	"github.com/charmbracelet/crush/internal/agent/hyper"
  36	"github.com/charmbracelet/crush/internal/agent/notify"
  37	"github.com/charmbracelet/crush/internal/agent/tools"
  38	"github.com/charmbracelet/crush/internal/agent/tools/mcp"
  39	"github.com/charmbracelet/crush/internal/config"
  40	"github.com/charmbracelet/crush/internal/csync"
  41	"github.com/charmbracelet/crush/internal/message"
  42	"github.com/charmbracelet/crush/internal/pubsub"
  43	"github.com/charmbracelet/crush/internal/session"
  44	"github.com/charmbracelet/crush/internal/stringext"
  45	"github.com/charmbracelet/crush/internal/version"
  46	"github.com/charmbracelet/x/exp/charmtone"
  47)
  48
  49const (
  50	DefaultSessionName = "Untitled Session"
  51
  52	// Constants for auto-summarization thresholds
  53	largeContextWindowThreshold = 200_000
  54	largeContextWindowBuffer    = 20_000
  55	smallContextWindowRatio     = 0.2
  56)
  57
  58var userAgent = fmt.Sprintf("Charm-Crush/%s (https://charm.land/crush)", version.Version)
  59
  60//go:embed templates/title.md
  61var titlePrompt []byte
  62
  63//go:embed templates/summary.md
  64var summaryPrompt []byte
  65
  66// Used to remove <think> tags from generated titles.
  67var (
  68	thinkTagRegex       = regexp.MustCompile(`(?s)<think>.*?</think>`)
  69	orphanThinkTagRegex = regexp.MustCompile(`</?think>`)
  70)
  71
  72type SessionAgentCall struct {
  73	SessionID        string
  74	Prompt           string
  75	ProviderOptions  fantasy.ProviderOptions
  76	Attachments      []message.Attachment
  77	MaxOutputTokens  int64
  78	Temperature      *float64
  79	TopP             *float64
  80	TopK             *int64
  81	FrequencyPenalty *float64
  82	PresencePenalty  *float64
  83	NonInteractive   bool
  84}
  85
  86type SessionAgent interface {
  87	Run(context.Context, SessionAgentCall) (*fantasy.AgentResult, error)
  88	SetModels(large Model, small Model)
  89	SetTools(tools []fantasy.AgentTool)
  90	SetSystemPrompt(systemPrompt string)
  91	Cancel(sessionID string)
  92	CancelAll()
  93	IsSessionBusy(sessionID string) bool
  94	IsBusy() bool
  95	QueuedPrompts(sessionID string) int
  96	QueuedPromptsList(sessionID string) []string
  97	ClearQueue(sessionID string)
  98	Summarize(context.Context, string, fantasy.ProviderOptions) error
  99	Model() Model
 100}
 101
 102type Model struct {
 103	Model      fantasy.LanguageModel
 104	CatwalkCfg catwalk.Model
 105	ModelCfg   config.SelectedModel
 106}
 107
 108type sessionAgent struct {
 109	largeModel         *csync.Value[Model]
 110	smallModel         *csync.Value[Model]
 111	systemPromptPrefix *csync.Value[string]
 112	systemPrompt       *csync.Value[string]
 113	tools              *csync.Slice[fantasy.AgentTool]
 114
 115	isSubAgent           bool
 116	sessions             session.Service
 117	messages             message.Service
 118	disableAutoSummarize bool
 119	isYolo               bool
 120	notify               pubsub.Publisher[notify.Notification]
 121
 122	messageQueue   *csync.Map[string, []SessionAgentCall]
 123	activeRequests *csync.Map[string, context.CancelFunc]
 124}
 125
 126type SessionAgentOptions struct {
 127	LargeModel           Model
 128	SmallModel           Model
 129	SystemPromptPrefix   string
 130	SystemPrompt         string
 131	IsSubAgent           bool
 132	DisableAutoSummarize bool
 133	IsYolo               bool
 134	Sessions             session.Service
 135	Messages             message.Service
 136	Tools                []fantasy.AgentTool
 137	Notify               pubsub.Publisher[notify.Notification]
 138}
 139
 140func NewSessionAgent(
 141	opts SessionAgentOptions,
 142) SessionAgent {
 143	return &sessionAgent{
 144		largeModel:           csync.NewValue(opts.LargeModel),
 145		smallModel:           csync.NewValue(opts.SmallModel),
 146		systemPromptPrefix:   csync.NewValue(opts.SystemPromptPrefix),
 147		systemPrompt:         csync.NewValue(opts.SystemPrompt),
 148		isSubAgent:           opts.IsSubAgent,
 149		sessions:             opts.Sessions,
 150		messages:             opts.Messages,
 151		disableAutoSummarize: opts.DisableAutoSummarize,
 152		tools:                csync.NewSliceFrom(opts.Tools),
 153		isYolo:               opts.IsYolo,
 154		notify:               opts.Notify,
 155		messageQueue:         csync.NewMap[string, []SessionAgentCall](),
 156		activeRequests:       csync.NewMap[string, context.CancelFunc](),
 157	}
 158}
 159
 160func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy.AgentResult, error) {
 161	if call.Prompt == "" && !message.ContainsTextAttachment(call.Attachments) {
 162		return nil, ErrEmptyPrompt
 163	}
 164	if call.SessionID == "" {
 165		return nil, ErrSessionMissing
 166	}
 167
 168	// Queue the message if busy
 169	if a.IsSessionBusy(call.SessionID) {
 170		existing, ok := a.messageQueue.Get(call.SessionID)
 171		if !ok {
 172			existing = []SessionAgentCall{}
 173		}
 174		existing = append(existing, call)
 175		a.messageQueue.Set(call.SessionID, existing)
 176		return nil, nil
 177	}
 178
 179	// Copy mutable fields under lock to avoid races with SetTools/SetModels.
 180	agentTools := a.tools.Copy()
 181	largeModel := a.largeModel.Get()
 182	systemPrompt := a.systemPrompt.Get()
 183	promptPrefix := a.systemPromptPrefix.Get()
 184	var instructions strings.Builder
 185
 186	for _, server := range mcp.GetStates() {
 187		if server.State != mcp.StateConnected {
 188			continue
 189		}
 190		if s := server.Client.InitializeResult().Instructions; s != "" {
 191			instructions.WriteString(s)
 192			instructions.WriteString("\n\n")
 193		}
 194	}
 195
 196	if s := instructions.String(); s != "" {
 197		systemPrompt += "\n\n<mcp-instructions>\n" + s + "\n</mcp-instructions>"
 198	}
 199
 200	if len(agentTools) > 0 {
 201		// Add Anthropic caching to the last tool.
 202		agentTools[len(agentTools)-1].SetProviderOptions(a.getCacheControlOptions())
 203	}
 204
 205	agent := fantasy.NewAgent(
 206		largeModel.Model,
 207		fantasy.WithSystemPrompt(systemPrompt),
 208		fantasy.WithTools(agentTools...),
 209		fantasy.WithUserAgent(userAgent),
 210	)
 211
 212	sessionLock := sync.Mutex{}
 213	currentSession, err := a.sessions.Get(ctx, call.SessionID)
 214	if err != nil {
 215		return nil, fmt.Errorf("failed to get session: %w", err)
 216	}
 217
 218	msgs, err := a.getSessionMessages(ctx, currentSession)
 219	if err != nil {
 220		return nil, fmt.Errorf("failed to get session messages: %w", err)
 221	}
 222
 223	var wg sync.WaitGroup
 224	// Generate title if first message.
 225	if len(msgs) == 0 {
 226		titleCtx := ctx // Copy to avoid race with ctx reassignment below.
 227		wg.Go(func() {
 228			a.generateTitle(titleCtx, call.SessionID, call.Prompt)
 229		})
 230	}
 231	defer wg.Wait()
 232
 233	// Add the user message to the session.
 234	_, err = a.createUserMessage(ctx, call)
 235	if err != nil {
 236		return nil, err
 237	}
 238
 239	// Add the session to the context.
 240	ctx = context.WithValue(ctx, tools.SessionIDContextKey, call.SessionID)
 241
 242	genCtx, cancel := context.WithCancel(ctx)
 243	a.activeRequests.Set(call.SessionID, cancel)
 244
 245	defer cancel()
 246	defer a.activeRequests.Del(call.SessionID)
 247
 248	history, files := a.preparePrompt(msgs, call.Attachments...)
 249
 250	startTime := time.Now()
 251	a.eventPromptSent(call.SessionID)
 252
 253	var currentAssistant *message.Message
 254	var shouldSummarize bool
 255	// Don't send MaxOutputTokens if 0 — some providers (e.g. LM Studio) reject it
 256	var maxOutputTokens *int64
 257	if call.MaxOutputTokens > 0 {
 258		maxOutputTokens = &call.MaxOutputTokens
 259	}
 260	result, err := agent.Stream(genCtx, fantasy.AgentStreamCall{
 261		Prompt:           message.PromptWithTextAttachments(call.Prompt, call.Attachments),
 262		Files:            files,
 263		Messages:         history,
 264		ProviderOptions:  call.ProviderOptions,
 265		MaxOutputTokens:  maxOutputTokens,
 266		TopP:             call.TopP,
 267		Temperature:      call.Temperature,
 268		PresencePenalty:  call.PresencePenalty,
 269		TopK:             call.TopK,
 270		FrequencyPenalty: call.FrequencyPenalty,
 271		PrepareStep: func(callContext context.Context, options fantasy.PrepareStepFunctionOptions) (_ context.Context, prepared fantasy.PrepareStepResult, err error) {
 272			prepared.Messages = options.Messages
 273			for i := range prepared.Messages {
 274				prepared.Messages[i].ProviderOptions = nil
 275			}
 276
 277			// Use latest tools (updated by SetTools when MCP tools change).
 278			prepared.Tools = a.tools.Copy()
 279
 280			queuedCalls, _ := a.messageQueue.Get(call.SessionID)
 281			a.messageQueue.Del(call.SessionID)
 282			for _, queued := range queuedCalls {
 283				userMessage, createErr := a.createUserMessage(callContext, queued)
 284				if createErr != nil {
 285					return callContext, prepared, createErr
 286				}
 287				prepared.Messages = append(prepared.Messages, userMessage.ToAIMessage()...)
 288			}
 289
 290			prepared.Messages = a.workaroundProviderMediaLimitations(prepared.Messages, largeModel)
 291
 292			lastSystemRoleInx := 0
 293			systemMessageUpdated := false
 294			for i, msg := range prepared.Messages {
 295				// Only add cache control to the last message.
 296				if msg.Role == fantasy.MessageRoleSystem {
 297					lastSystemRoleInx = i
 298				} else if !systemMessageUpdated {
 299					prepared.Messages[lastSystemRoleInx].ProviderOptions = a.getCacheControlOptions()
 300					systemMessageUpdated = true
 301				}
 302				// Than add cache control to the last 2 messages.
 303				if i > len(prepared.Messages)-3 {
 304					prepared.Messages[i].ProviderOptions = a.getCacheControlOptions()
 305				}
 306			}
 307
 308			if promptPrefix != "" {
 309				prepared.Messages = append([]fantasy.Message{fantasy.NewSystemMessage(promptPrefix)}, prepared.Messages...)
 310			}
 311
 312			var assistantMsg message.Message
 313			assistantMsg, err = a.messages.Create(callContext, call.SessionID, message.CreateMessageParams{
 314				Role:     message.Assistant,
 315				Parts:    []message.ContentPart{},
 316				Model:    largeModel.ModelCfg.Model,
 317				Provider: largeModel.ModelCfg.Provider,
 318			})
 319			if err != nil {
 320				return callContext, prepared, err
 321			}
 322			callContext = context.WithValue(callContext, tools.MessageIDContextKey, assistantMsg.ID)
 323			callContext = context.WithValue(callContext, tools.SupportsImagesContextKey, largeModel.CatwalkCfg.SupportsImages)
 324			callContext = context.WithValue(callContext, tools.ModelNameContextKey, largeModel.CatwalkCfg.Name)
 325			currentAssistant = &assistantMsg
 326			return callContext, prepared, err
 327		},
 328		OnReasoningStart: func(id string, reasoning fantasy.ReasoningContent) error {
 329			currentAssistant.AppendReasoningContent(reasoning.Text)
 330			return a.messages.Update(genCtx, *currentAssistant)
 331		},
 332		OnReasoningDelta: func(id string, text string) error {
 333			currentAssistant.AppendReasoningContent(text)
 334			return a.messages.Update(genCtx, *currentAssistant)
 335		},
 336		OnReasoningEnd: func(id string, reasoning fantasy.ReasoningContent) error {
 337			// handle anthropic signature
 338			if anthropicData, ok := reasoning.ProviderMetadata[anthropic.Name]; ok {
 339				if reasoning, ok := anthropicData.(*anthropic.ReasoningOptionMetadata); ok {
 340					currentAssistant.AppendReasoningSignature(reasoning.Signature)
 341				}
 342			}
 343			if googleData, ok := reasoning.ProviderMetadata[google.Name]; ok {
 344				if reasoning, ok := googleData.(*google.ReasoningMetadata); ok {
 345					currentAssistant.AppendThoughtSignature(reasoning.Signature, reasoning.ToolID)
 346				}
 347			}
 348			if openaiData, ok := reasoning.ProviderMetadata[openai.Name]; ok {
 349				if reasoning, ok := openaiData.(*openai.ResponsesReasoningMetadata); ok {
 350					currentAssistant.SetReasoningResponsesData(reasoning)
 351				}
 352			}
 353			currentAssistant.FinishThinking()
 354			return a.messages.Update(genCtx, *currentAssistant)
 355		},
 356		OnTextDelta: func(id string, text string) error {
 357			// Strip leading newline from initial text content. This is is
 358			// particularly important in non-interactive mode where leading
 359			// newlines are very visible.
 360			if len(currentAssistant.Parts) == 0 {
 361				text = strings.TrimPrefix(text, "\n")
 362			}
 363
 364			currentAssistant.AppendContent(text)
 365			return a.messages.Update(genCtx, *currentAssistant)
 366		},
 367		OnToolInputStart: func(id string, toolName string) error {
 368			toolCall := message.ToolCall{
 369				ID:               id,
 370				Name:             toolName,
 371				ProviderExecuted: false,
 372				Finished:         false,
 373			}
 374			currentAssistant.AddToolCall(toolCall)
 375			// Use parent ctx instead of genCtx to ensure the update succeeds
 376			// even if the request is canceled mid-stream
 377			return a.messages.Update(ctx, *currentAssistant)
 378		},
 379		OnRetry: func(err *fantasy.ProviderError, delay time.Duration) {
 380			slog.Warn("Provider request failed, retrying", providerRetryLogFields(err, delay)...)
 381		},
 382		OnToolCall: func(tc fantasy.ToolCallContent) error {
 383			toolCall := message.ToolCall{
 384				ID:               tc.ToolCallID,
 385				Name:             tc.ToolName,
 386				Input:            tc.Input,
 387				ProviderExecuted: false,
 388				Finished:         true,
 389			}
 390			currentAssistant.AddToolCall(toolCall)
 391			// Use parent ctx instead of genCtx to ensure the update succeeds
 392			// even if the request is canceled mid-stream
 393			return a.messages.Update(ctx, *currentAssistant)
 394		},
 395		OnToolResult: func(result fantasy.ToolResultContent) error {
 396			toolResult := a.convertToToolResult(result)
 397			// Use parent ctx instead of genCtx to ensure the message is created
 398			// even if the request is canceled mid-stream
 399			_, createMsgErr := a.messages.Create(ctx, currentAssistant.SessionID, message.CreateMessageParams{
 400				Role: message.Tool,
 401				Parts: []message.ContentPart{
 402					toolResult,
 403				},
 404			})
 405			return createMsgErr
 406		},
 407		OnStepFinish: func(stepResult fantasy.StepResult) error {
 408			finishReason := message.FinishReasonUnknown
 409			switch stepResult.FinishReason {
 410			case fantasy.FinishReasonLength:
 411				finishReason = message.FinishReasonMaxTokens
 412			case fantasy.FinishReasonStop:
 413				finishReason = message.FinishReasonEndTurn
 414			case fantasy.FinishReasonToolCalls:
 415				finishReason = message.FinishReasonToolUse
 416			}
 417			// If a tool result halted the turn (e.g. a hook halt or a
 418			// permission denial), the step ends on FinishReasonToolCalls but
 419			// the model will not be called again. Treat it as the end of the
 420			// turn so the UI can render the assistant footer.
 421			if finishReason == message.FinishReasonToolUse {
 422				for _, tr := range stepResult.Content.ToolResults() {
 423					if tr.StopTurn {
 424						finishReason = message.FinishReasonEndTurn
 425						break
 426					}
 427				}
 428			}
 429			currentAssistant.AddFinish(finishReason, "", "")
 430			sessionLock.Lock()
 431			defer sessionLock.Unlock()
 432
 433			updatedSession, getSessionErr := a.sessions.Get(ctx, call.SessionID)
 434			if getSessionErr != nil {
 435				return getSessionErr
 436			}
 437			a.updateSessionUsage(largeModel, &updatedSession, stepResult.Usage, a.openrouterCost(stepResult.ProviderMetadata))
 438			_, sessionErr := a.sessions.Save(ctx, updatedSession)
 439			if sessionErr != nil {
 440				return sessionErr
 441			}
 442			currentSession = updatedSession
 443			return a.messages.Update(genCtx, *currentAssistant)
 444		},
 445		StopWhen: []fantasy.StopCondition{
 446			func(_ []fantasy.StepResult) bool {
 447				cw := int64(largeModel.CatwalkCfg.ContextWindow)
 448				// If context window is unknown (0), skip auto-summarize
 449				// to avoid immediately truncating custom/local models.
 450				if cw == 0 {
 451					return false
 452				}
 453				tokens := currentSession.CompletionTokens + currentSession.PromptTokens
 454				remaining := cw - tokens
 455				var threshold int64
 456				if cw > largeContextWindowThreshold {
 457					threshold = largeContextWindowBuffer
 458				} else {
 459					threshold = int64(float64(cw) * smallContextWindowRatio)
 460				}
 461				if (remaining <= threshold) && !a.disableAutoSummarize {
 462					shouldSummarize = true
 463					return true
 464				}
 465				return false
 466			},
 467			func(steps []fantasy.StepResult) bool {
 468				return hasRepeatedToolCalls(steps, loopDetectionWindowSize, loopDetectionMaxRepeats)
 469			},
 470		},
 471	})
 472
 473	a.eventPromptResponded(call.SessionID, time.Since(startTime).Truncate(time.Second))
 474
 475	if err != nil {
 476		isHyper := largeModel.ModelCfg.Provider == hyper.Name
 477		isCancelErr := errors.Is(err, context.Canceled)
 478		if currentAssistant == nil {
 479			return result, err
 480		}
 481		// Ensure we finish thinking on error to close the reasoning state.
 482		currentAssistant.FinishThinking()
 483		toolCalls := currentAssistant.ToolCalls()
 484		// INFO: we use the parent context here because the genCtx has been cancelled.
 485		msgs, createErr := a.messages.List(ctx, currentAssistant.SessionID)
 486		if createErr != nil {
 487			return nil, createErr
 488		}
 489		for _, tc := range toolCalls {
 490			if !tc.Finished {
 491				tc.Finished = true
 492				tc.Input = "{}"
 493				currentAssistant.AddToolCall(tc)
 494				updateErr := a.messages.Update(ctx, *currentAssistant)
 495				if updateErr != nil {
 496					return nil, updateErr
 497				}
 498			}
 499
 500			found := false
 501			for _, msg := range msgs {
 502				if msg.Role == message.Tool {
 503					for _, tr := range msg.ToolResults() {
 504						if tr.ToolCallID == tc.ID {
 505							found = true
 506							break
 507						}
 508					}
 509				}
 510				if found {
 511					break
 512				}
 513			}
 514			if found {
 515				continue
 516			}
 517			content := "There was an error while executing the tool"
 518			if isCancelErr {
 519				content = "Error: user cancelled assistant tool calling"
 520			}
 521			toolResult := message.ToolResult{
 522				ToolCallID: tc.ID,
 523				Name:       tc.Name,
 524				Content:    content,
 525				IsError:    true,
 526			}
 527			_, createErr = a.messages.Create(ctx, currentAssistant.SessionID, message.CreateMessageParams{
 528				Role: message.Tool,
 529				Parts: []message.ContentPart{
 530					toolResult,
 531				},
 532			})
 533			if createErr != nil {
 534				return nil, createErr
 535			}
 536		}
 537		var fantasyErr *fantasy.Error
 538		var providerErr *fantasy.ProviderError
 539		const defaultTitle = "Provider Error"
 540		linkStyle := lipgloss.NewStyle().Foreground(charmtone.Guac).Underline(true)
 541		if isCancelErr {
 542			currentAssistant.AddFinish(message.FinishReasonCanceled, "User canceled request", "")
 543		} else if isHyper && errors.As(err, &providerErr) && providerErr.StatusCode == http.StatusUnauthorized {
 544			currentAssistant.AddFinish(message.FinishReasonError, "Unauthorized", `Please re-authenticate with Hyper. You can also run "crush auth" to re-authenticate.`)
 545			if a.notify != nil {
 546				a.notify.Publish(pubsub.CreatedEvent, notify.Notification{
 547					SessionID:    call.SessionID,
 548					SessionTitle: currentSession.Title,
 549					Type:         notify.TypeReAuthenticate,
 550					ProviderID:   largeModel.ModelCfg.Provider,
 551				})
 552			}
 553		} else if isHyper && errors.As(err, &providerErr) && providerErr.StatusCode == http.StatusPaymentRequired {
 554			url := hyper.BaseURL()
 555			link := linkStyle.Hyperlink(url, "id=hyper").Render(url)
 556			currentAssistant.AddFinish(message.FinishReasonError, "No credits", "You're out of credits. Add more at "+link)
 557		} else if errors.As(err, &providerErr) {
 558			if providerErr.Message == "The requested model is not supported." {
 559				url := "https://github.com/settings/copilot/features"
 560				link := linkStyle.Hyperlink(url, "id=copilot").Render(url)
 561				currentAssistant.AddFinish(
 562					message.FinishReasonError,
 563					"Copilot model not enabled",
 564					fmt.Sprintf("%q is not enabled in Copilot. Go to the following page to enable it. Then, wait 5 minutes before trying again. %s", largeModel.CatwalkCfg.Name, link),
 565				)
 566			} else {
 567				currentAssistant.AddFinish(message.FinishReasonError, cmp.Or(stringext.Capitalize(providerErr.Title), defaultTitle), providerErr.Message)
 568			}
 569		} else if errors.As(err, &fantasyErr) {
 570			currentAssistant.AddFinish(message.FinishReasonError, cmp.Or(stringext.Capitalize(fantasyErr.Title), defaultTitle), fantasyErr.Message)
 571		} else {
 572			currentAssistant.AddFinish(message.FinishReasonError, defaultTitle, err.Error())
 573		}
 574		// Note: we use the parent context here because the genCtx has been
 575		// cancelled.
 576		updateErr := a.messages.Update(ctx, *currentAssistant)
 577		if updateErr != nil {
 578			return nil, updateErr
 579		}
 580		return nil, err
 581	}
 582
 583	// Send notification that agent has finished its turn (skip for
 584	// nested/non-interactive sessions).
 585	if !call.NonInteractive && a.notify != nil {
 586		a.notify.Publish(pubsub.CreatedEvent, notify.Notification{
 587			SessionID:    call.SessionID,
 588			SessionTitle: currentSession.Title,
 589			Type:         notify.TypeAgentFinished,
 590		})
 591	}
 592
 593	if shouldSummarize {
 594		a.activeRequests.Del(call.SessionID)
 595		if summarizeErr := a.Summarize(genCtx, call.SessionID, call.ProviderOptions); summarizeErr != nil {
 596			return nil, summarizeErr
 597		}
 598		// If the agent wasn't done...
 599		if len(currentAssistant.ToolCalls()) > 0 {
 600			existing, ok := a.messageQueue.Get(call.SessionID)
 601			if !ok {
 602				existing = []SessionAgentCall{}
 603			}
 604			call.Prompt = fmt.Sprintf("The previous session was interrupted because it got too long, the initial user request was: `%s`", call.Prompt)
 605			existing = append(existing, call)
 606			a.messageQueue.Set(call.SessionID, existing)
 607		}
 608	}
 609
 610	// Release active request before processing queued messages.
 611	a.activeRequests.Del(call.SessionID)
 612	cancel()
 613
 614	queuedMessages, ok := a.messageQueue.Get(call.SessionID)
 615	if !ok || len(queuedMessages) == 0 {
 616		return result, err
 617	}
 618	// There are queued messages restart the loop.
 619	firstQueuedMessage := queuedMessages[0]
 620	a.messageQueue.Set(call.SessionID, queuedMessages[1:])
 621	return a.Run(ctx, firstQueuedMessage)
 622}
 623
 624func (a *sessionAgent) Summarize(ctx context.Context, sessionID string, opts fantasy.ProviderOptions) error {
 625	if a.IsSessionBusy(sessionID) {
 626		return ErrSessionBusy
 627	}
 628
 629	// Copy mutable fields under lock to avoid races with SetModels.
 630	largeModel := a.largeModel.Get()
 631	systemPromptPrefix := a.systemPromptPrefix.Get()
 632
 633	currentSession, err := a.sessions.Get(ctx, sessionID)
 634	if err != nil {
 635		return fmt.Errorf("failed to get session: %w", err)
 636	}
 637	msgs, err := a.getSessionMessages(ctx, currentSession)
 638	if err != nil {
 639		return err
 640	}
 641	if len(msgs) == 0 {
 642		// Nothing to summarize.
 643		return nil
 644	}
 645
 646	aiMsgs, _ := a.preparePrompt(msgs)
 647
 648	genCtx, cancel := context.WithCancel(ctx)
 649	a.activeRequests.Set(sessionID, cancel)
 650	defer a.activeRequests.Del(sessionID)
 651	defer cancel()
 652
 653	agent := fantasy.NewAgent(largeModel.Model,
 654		fantasy.WithSystemPrompt(string(summaryPrompt)),
 655		fantasy.WithUserAgent(userAgent),
 656	)
 657	summaryMessage, err := a.messages.Create(ctx, sessionID, message.CreateMessageParams{
 658		Role:             message.Assistant,
 659		Model:            largeModel.Model.Model(),
 660		Provider:         largeModel.Model.Provider(),
 661		IsSummaryMessage: true,
 662	})
 663	if err != nil {
 664		return err
 665	}
 666
 667	summaryPromptText := buildSummaryPrompt(currentSession.Todos)
 668
 669	resp, err := agent.Stream(genCtx, fantasy.AgentStreamCall{
 670		Prompt:          summaryPromptText,
 671		Messages:        aiMsgs,
 672		ProviderOptions: opts,
 673		PrepareStep: func(callContext context.Context, options fantasy.PrepareStepFunctionOptions) (_ context.Context, prepared fantasy.PrepareStepResult, err error) {
 674			prepared.Messages = options.Messages
 675			if systemPromptPrefix != "" {
 676				prepared.Messages = append([]fantasy.Message{fantasy.NewSystemMessage(systemPromptPrefix)}, prepared.Messages...)
 677			}
 678			return callContext, prepared, nil
 679		},
 680		OnReasoningDelta: func(id string, text string) error {
 681			summaryMessage.AppendReasoningContent(text)
 682			return a.messages.Update(genCtx, summaryMessage)
 683		},
 684		OnReasoningEnd: func(id string, reasoning fantasy.ReasoningContent) error {
 685			// Handle anthropic signature.
 686			if anthropicData, ok := reasoning.ProviderMetadata["anthropic"]; ok {
 687				if signature, ok := anthropicData.(*anthropic.ReasoningOptionMetadata); ok && signature.Signature != "" {
 688					summaryMessage.AppendReasoningSignature(signature.Signature)
 689				}
 690			}
 691			summaryMessage.FinishThinking()
 692			return a.messages.Update(genCtx, summaryMessage)
 693		},
 694		OnTextDelta: func(id, text string) error {
 695			summaryMessage.AppendContent(text)
 696			return a.messages.Update(genCtx, summaryMessage)
 697		},
 698	})
 699	if err != nil {
 700		isCancelErr := errors.Is(err, context.Canceled)
 701		if isCancelErr {
 702			// User cancelled summarize we need to remove the summary message.
 703			deleteErr := a.messages.Delete(ctx, summaryMessage.ID)
 704			return deleteErr
 705		}
 706		return err
 707	}
 708
 709	summaryMessage.AddFinish(message.FinishReasonEndTurn, "", "")
 710	err = a.messages.Update(genCtx, summaryMessage)
 711	if err != nil {
 712		return err
 713	}
 714
 715	var openrouterCost *float64
 716	for _, step := range resp.Steps {
 717		stepCost := a.openrouterCost(step.ProviderMetadata)
 718		if stepCost != nil {
 719			newCost := *stepCost
 720			if openrouterCost != nil {
 721				newCost += *openrouterCost
 722			}
 723			openrouterCost = &newCost
 724		}
 725	}
 726
 727	a.updateSessionUsage(largeModel, &currentSession, resp.TotalUsage, openrouterCost)
 728
 729	// Just in case, get just the last usage info.
 730	usage := resp.Response.Usage
 731	currentSession.SummaryMessageID = summaryMessage.ID
 732	currentSession.CompletionTokens = usage.OutputTokens
 733	currentSession.PromptTokens = 0
 734	_, err = a.sessions.Save(genCtx, currentSession)
 735	return err
 736}
 737
 738func (a *sessionAgent) getCacheControlOptions() fantasy.ProviderOptions {
 739	if t, _ := strconv.ParseBool(os.Getenv("CRUSH_DISABLE_ANTHROPIC_CACHE")); t {
 740		return fantasy.ProviderOptions{}
 741	}
 742	return fantasy.ProviderOptions{
 743		anthropic.Name: &anthropic.ProviderCacheControlOptions{
 744			CacheControl: anthropic.CacheControl{Type: "ephemeral"},
 745		},
 746		bedrock.Name: &anthropic.ProviderCacheControlOptions{
 747			CacheControl: anthropic.CacheControl{Type: "ephemeral"},
 748		},
 749		vercel.Name: &anthropic.ProviderCacheControlOptions{
 750			CacheControl: anthropic.CacheControl{Type: "ephemeral"},
 751		},
 752	}
 753}
 754
 755func (a *sessionAgent) createUserMessage(ctx context.Context, call SessionAgentCall) (message.Message, error) {
 756	parts := []message.ContentPart{message.TextContent{Text: call.Prompt}}
 757	var attachmentParts []message.ContentPart
 758	for _, attachment := range call.Attachments {
 759		attachmentParts = append(attachmentParts, message.BinaryContent{Path: attachment.FilePath, MIMEType: attachment.MimeType, Data: attachment.Content})
 760	}
 761	parts = append(parts, attachmentParts...)
 762	msg, err := a.messages.Create(ctx, call.SessionID, message.CreateMessageParams{
 763		Role:  message.User,
 764		Parts: parts,
 765	})
 766	if err != nil {
 767		return message.Message{}, fmt.Errorf("failed to create user message: %w", err)
 768	}
 769	return msg, nil
 770}
 771
 772func (a *sessionAgent) preparePrompt(msgs []message.Message, attachments ...message.Attachment) ([]fantasy.Message, []fantasy.FilePart) {
 773	var history []fantasy.Message
 774	if !a.isSubAgent {
 775		history = append(history, fantasy.NewUserMessage(
 776			fmt.Sprintf("<system_reminder>%s</system_reminder>",
 777				`This is a reminder that your todo list is currently empty. DO NOT mention this to the user explicitly because they are already aware.
 778If you are working on tasks that would benefit from a todo list please use the "todos" tool to create one.
 779If not, please feel free to ignore. Again do not mention this message to the user.`,
 780			),
 781		))
 782	}
 783	// Collect all tool call IDs present in assistant messages and all tool
 784	// result IDs present in tool messages. This lets us detect both orphaned
 785	// tool results (result without a call) and orphaned tool calls (call
 786	// without a result).
 787	knownToolCallIDs := make(map[string]struct{})
 788	knownToolResultIDs := make(map[string]struct{})
 789	for _, m := range msgs {
 790		switch m.Role {
 791		case message.Assistant:
 792			for _, tc := range m.ToolCalls() {
 793				knownToolCallIDs[tc.ID] = struct{}{}
 794			}
 795		case message.Tool:
 796			for _, tr := range m.ToolResults() {
 797				knownToolResultIDs[tr.ToolCallID] = struct{}{}
 798			}
 799		}
 800	}
 801
 802	for _, m := range msgs {
 803		if len(m.Parts) == 0 {
 804			continue
 805		}
 806		// Assistant message without content or tool calls (cancelled before it returned anything).
 807		if m.Role == message.Assistant && len(m.ToolCalls()) == 0 && m.Content().Text == "" && m.ReasoningContent().String() == "" {
 808			continue
 809		}
 810		if m.Role == message.Tool {
 811			if msg, ok := filterOrphanedToolResults(m, knownToolCallIDs); ok {
 812				history = append(history, msg)
 813			}
 814			continue
 815		}
 816		history = append(history, m.ToAIMessage()...)
 817
 818		if m.Role == message.Assistant {
 819			if msg, ok := syntheticToolResultsForOrphanedCalls(m, knownToolResultIDs); ok {
 820				history = append(history, msg)
 821			}
 822		}
 823	}
 824
 825	var files []fantasy.FilePart
 826	for _, attachment := range attachments {
 827		if attachment.IsText() {
 828			continue
 829		}
 830		files = append(files, fantasy.FilePart{
 831			Filename:  attachment.FileName,
 832			Data:      attachment.Content,
 833			MediaType: attachment.MimeType,
 834		})
 835	}
 836
 837	return history, files
 838}
 839
 840// filterOrphanedToolResults converts a tool message to a fantasy.Message,
 841// dropping any tool result parts whose tool_call_id has no matching tool call
 842// in the known set. An orphaned result causes API validation to fail on every
 843// subsequent turn, permanently locking the session. Returns the filtered
 844// message and true if at least one valid part remains.
 845func filterOrphanedToolResults(m message.Message, knownToolCallIDs map[string]struct{}) (fantasy.Message, bool) {
 846	aiMsgs := m.ToAIMessage()
 847	if len(aiMsgs) == 0 {
 848		return fantasy.Message{}, false
 849	}
 850	var validParts []fantasy.MessagePart
 851	for _, part := range aiMsgs[0].Content {
 852		tr, ok := fantasy.AsMessagePart[fantasy.ToolResultPart](part)
 853		if !ok {
 854			validParts = append(validParts, part)
 855			continue
 856		}
 857		if _, known := knownToolCallIDs[tr.ToolCallID]; known {
 858			validParts = append(validParts, part)
 859		} else {
 860			slog.Warn("Dropping orphaned tool result with no matching tool call",
 861				"tool_call_id", tr.ToolCallID,
 862			)
 863		}
 864	}
 865	if len(validParts) == 0 {
 866		return fantasy.Message{}, false
 867	}
 868	msg := aiMsgs[0]
 869	msg.Content = validParts
 870	return msg, true
 871}
 872
 873// syntheticToolResultsForOrphanedCalls returns a tool message containing
 874// synthetic tool results for any tool calls in the assistant message that
 875// have no matching result in knownToolResultIDs. LLM APIs require every
 876// tool_use to be immediately followed by a tool_result; an interrupted
 877// session can leave orphaned tool_use blocks that permanently lock the
 878// conversation. Returns the message and true if any synthetic results were
 879// produced.
 880func syntheticToolResultsForOrphanedCalls(m message.Message, knownToolResultIDs map[string]struct{}) (fantasy.Message, bool) {
 881	var syntheticParts []fantasy.MessagePart
 882	for _, tc := range m.ToolCalls() {
 883		if _, hasResult := knownToolResultIDs[tc.ID]; hasResult {
 884			continue
 885		}
 886		slog.Warn("Injecting synthetic tool result for orphaned tool call",
 887			"tool_call_id", tc.ID,
 888			"tool_name", tc.Name,
 889		)
 890		syntheticParts = append(syntheticParts, fantasy.ToolResultPart{
 891			ToolCallID: tc.ID,
 892			Output: fantasy.ToolResultOutputContentError{
 893				Error: errors.New("tool call was interrupted and did not produce a result, you may retry this call if the result is still needed"),
 894			},
 895		})
 896	}
 897	if len(syntheticParts) == 0 {
 898		return fantasy.Message{}, false
 899	}
 900	return fantasy.Message{
 901		Role:    fantasy.MessageRoleTool,
 902		Content: syntheticParts,
 903	}, true
 904}
 905
 906func (a *sessionAgent) getSessionMessages(ctx context.Context, session session.Session) ([]message.Message, error) {
 907	msgs, err := a.messages.List(ctx, session.ID)
 908	if err != nil {
 909		return nil, fmt.Errorf("failed to list messages: %w", err)
 910	}
 911
 912	if session.SummaryMessageID != "" {
 913		summaryMsgIndex := -1
 914		for i, msg := range msgs {
 915			if msg.ID == session.SummaryMessageID {
 916				summaryMsgIndex = i
 917				break
 918			}
 919		}
 920		if summaryMsgIndex != -1 {
 921			msgs = msgs[summaryMsgIndex:]
 922			msgs[0].Role = message.User
 923		}
 924	}
 925	return msgs, nil
 926}
 927
 928// generateTitle generates a session titled based on the initial prompt.
 929func (a *sessionAgent) generateTitle(ctx context.Context, sessionID string, userPrompt string) {
 930	if userPrompt == "" {
 931		return
 932	}
 933
 934	smallModel := a.smallModel.Get()
 935	largeModel := a.largeModel.Get()
 936	systemPromptPrefix := a.systemPromptPrefix.Get()
 937
 938	var maxOutputTokens int64 = 40
 939	if smallModel.CatwalkCfg.CanReason {
 940		maxOutputTokens = smallModel.CatwalkCfg.DefaultMaxTokens
 941	}
 942
 943	newAgent := func(m fantasy.LanguageModel, p []byte, tok int64) fantasy.Agent {
 944		return fantasy.NewAgent(m,
 945			fantasy.WithSystemPrompt(string(p)+"\n /no_think"),
 946			fantasy.WithMaxOutputTokens(tok),
 947			fantasy.WithUserAgent(userAgent),
 948		)
 949	}
 950
 951	streamCall := fantasy.AgentStreamCall{
 952		Prompt: fmt.Sprintf("Generate a concise title for the following content:\n\n%s\n <think>\n\n</think>", userPrompt),
 953		PrepareStep: func(callCtx context.Context, opts fantasy.PrepareStepFunctionOptions) (_ context.Context, prepared fantasy.PrepareStepResult, err error) {
 954			prepared.Messages = opts.Messages
 955			if systemPromptPrefix != "" {
 956				prepared.Messages = append([]fantasy.Message{
 957					fantasy.NewSystemMessage(systemPromptPrefix),
 958				}, prepared.Messages...)
 959			}
 960			return callCtx, prepared, nil
 961		},
 962	}
 963
 964	// Use the small model to generate the title.
 965	model := smallModel
 966	agent := newAgent(model.Model, titlePrompt, maxOutputTokens)
 967	resp, err := agent.Stream(ctx, streamCall)
 968	if err == nil {
 969		// We successfully generated a title with the small model.
 970		slog.Debug("Generated title with small model")
 971	} else {
 972		// It didn't work. Let's try with the big model.
 973		slog.Error("Error generating title with small model; trying big model", "err", err)
 974		model = largeModel
 975		agent = newAgent(model.Model, titlePrompt, maxOutputTokens)
 976		resp, err = agent.Stream(ctx, streamCall)
 977		if err == nil {
 978			slog.Debug("Generated title with large model")
 979		} else {
 980			// Welp, the large model didn't work either. Use the default
 981			// session name and return.
 982			slog.Error("Error generating title with large model", "err", err)
 983			saveErr := a.sessions.Rename(ctx, sessionID, DefaultSessionName)
 984			if saveErr != nil {
 985				slog.Error("Failed to save session title", "error", saveErr)
 986			}
 987			return
 988		}
 989	}
 990
 991	if resp == nil {
 992		// Actually, we didn't get a response so we can't. Use the default
 993		// session name and return.
 994		slog.Error("Response is nil; can't generate title")
 995		saveErr := a.sessions.Rename(ctx, sessionID, DefaultSessionName)
 996		if saveErr != nil {
 997			slog.Error("Failed to save session title", "error", saveErr)
 998		}
 999		return
1000	}
1001
1002	// Clean up title.
1003	var title string
1004	title = strings.ReplaceAll(resp.Response.Content.Text(), "\n", " ")
1005
1006	// Remove thinking tags if present.
1007	title = thinkTagRegex.ReplaceAllString(title, "")
1008	title = orphanThinkTagRegex.ReplaceAllString(title, "")
1009
1010	title = strings.TrimSpace(title)
1011	title = cmp.Or(title, DefaultSessionName)
1012
1013	// Calculate usage and cost.
1014	var openrouterCost *float64
1015	for _, step := range resp.Steps {
1016		stepCost := a.openrouterCost(step.ProviderMetadata)
1017		if stepCost != nil {
1018			newCost := *stepCost
1019			if openrouterCost != nil {
1020				newCost += *openrouterCost
1021			}
1022			openrouterCost = &newCost
1023		}
1024	}
1025
1026	modelConfig := model.CatwalkCfg
1027	cost := modelConfig.CostPer1MInCached/1e6*float64(resp.TotalUsage.CacheCreationTokens) +
1028		modelConfig.CostPer1MOutCached/1e6*float64(resp.TotalUsage.CacheReadTokens) +
1029		modelConfig.CostPer1MIn/1e6*float64(resp.TotalUsage.InputTokens) +
1030		modelConfig.CostPer1MOut/1e6*float64(resp.TotalUsage.OutputTokens)
1031
1032	// Use override cost if available (e.g., from OpenRouter).
1033	if openrouterCost != nil {
1034		cost = *openrouterCost
1035	}
1036
1037	promptTokens := resp.TotalUsage.InputTokens + resp.TotalUsage.CacheCreationTokens
1038	completionTokens := resp.TotalUsage.OutputTokens
1039
1040	// Atomically update only title and usage fields to avoid overriding other
1041	// concurrent session updates.
1042	saveErr := a.sessions.UpdateTitleAndUsage(ctx, sessionID, title, promptTokens, completionTokens, cost)
1043	if saveErr != nil {
1044		slog.Error("Failed to save session title and usage", "error", saveErr)
1045		return
1046	}
1047}
1048
1049func (a *sessionAgent) openrouterCost(metadata fantasy.ProviderMetadata) *float64 {
1050	openrouterMetadata, ok := metadata[openrouter.Name]
1051	if !ok {
1052		return nil
1053	}
1054
1055	opts, ok := openrouterMetadata.(*openrouter.ProviderMetadata)
1056	if !ok {
1057		return nil
1058	}
1059	return &opts.Usage.Cost
1060}
1061
1062func (a *sessionAgent) updateSessionUsage(model Model, session *session.Session, usage fantasy.Usage, overrideCost *float64) {
1063	modelConfig := model.CatwalkCfg
1064	cost := modelConfig.CostPer1MInCached/1e6*float64(usage.CacheCreationTokens) +
1065		modelConfig.CostPer1MOutCached/1e6*float64(usage.CacheReadTokens) +
1066		modelConfig.CostPer1MIn/1e6*float64(usage.InputTokens) +
1067		modelConfig.CostPer1MOut/1e6*float64(usage.OutputTokens)
1068
1069	a.eventTokensUsed(session.ID, model, usage, cost)
1070
1071	if overrideCost != nil {
1072		session.Cost += *overrideCost
1073	} else {
1074		session.Cost += cost
1075	}
1076
1077	session.CompletionTokens = usage.OutputTokens
1078	session.PromptTokens = usage.InputTokens + usage.CacheReadTokens
1079}
1080
1081func (a *sessionAgent) Cancel(sessionID string) {
1082	// Cancel regular requests. Don't use Take() here - we need the entry to
1083	// remain in activeRequests so IsBusy() returns true until the goroutine
1084	// fully completes (including error handling that may access the DB).
1085	// The defer in processRequest will clean up the entry.
1086	if cancel, ok := a.activeRequests.Get(sessionID); ok && cancel != nil {
1087		slog.Debug("Request cancellation initiated", "session_id", sessionID)
1088		cancel()
1089	}
1090
1091	// Also check for summarize requests.
1092	if cancel, ok := a.activeRequests.Get(sessionID + "-summarize"); ok && cancel != nil {
1093		slog.Debug("Summarize cancellation initiated", "session_id", sessionID)
1094		cancel()
1095	}
1096
1097	if a.QueuedPrompts(sessionID) > 0 {
1098		slog.Debug("Clearing queued prompts", "session_id", sessionID)
1099		a.messageQueue.Del(sessionID)
1100	}
1101}
1102
1103func (a *sessionAgent) ClearQueue(sessionID string) {
1104	if a.QueuedPrompts(sessionID) > 0 {
1105		slog.Debug("Clearing queued prompts", "session_id", sessionID)
1106		a.messageQueue.Del(sessionID)
1107	}
1108}
1109
1110func (a *sessionAgent) CancelAll() {
1111	if !a.IsBusy() {
1112		return
1113	}
1114	for key := range a.activeRequests.Seq2() {
1115		a.Cancel(key) // key is sessionID
1116	}
1117
1118	timeout := time.After(5 * time.Second)
1119	for a.IsBusy() {
1120		select {
1121		case <-timeout:
1122			return
1123		default:
1124			time.Sleep(200 * time.Millisecond)
1125		}
1126	}
1127}
1128
1129func (a *sessionAgent) IsBusy() bool {
1130	var busy bool
1131	for cancelFunc := range a.activeRequests.Seq() {
1132		if cancelFunc != nil {
1133			busy = true
1134			break
1135		}
1136	}
1137	return busy
1138}
1139
1140func (a *sessionAgent) IsSessionBusy(sessionID string) bool {
1141	_, busy := a.activeRequests.Get(sessionID)
1142	return busy
1143}
1144
1145func (a *sessionAgent) QueuedPrompts(sessionID string) int {
1146	l, ok := a.messageQueue.Get(sessionID)
1147	if !ok {
1148		return 0
1149	}
1150	return len(l)
1151}
1152
1153func (a *sessionAgent) QueuedPromptsList(sessionID string) []string {
1154	l, ok := a.messageQueue.Get(sessionID)
1155	if !ok {
1156		return nil
1157	}
1158	prompts := make([]string, len(l))
1159	for i, call := range l {
1160		prompts[i] = call.Prompt
1161	}
1162	return prompts
1163}
1164
1165func (a *sessionAgent) SetModels(large Model, small Model) {
1166	a.largeModel.Set(large)
1167	a.smallModel.Set(small)
1168}
1169
1170func (a *sessionAgent) SetTools(tools []fantasy.AgentTool) {
1171	a.tools.SetSlice(tools)
1172}
1173
1174func (a *sessionAgent) SetSystemPrompt(systemPrompt string) {
1175	a.systemPrompt.Set(systemPrompt)
1176}
1177
1178func (a *sessionAgent) Model() Model {
1179	return a.largeModel.Get()
1180}
1181
1182// convertToToolResult converts a fantasy tool result to a message tool result.
1183func (a *sessionAgent) convertToToolResult(result fantasy.ToolResultContent) message.ToolResult {
1184	baseResult := message.ToolResult{
1185		ToolCallID: result.ToolCallID,
1186		Name:       result.ToolName,
1187		Metadata:   result.ClientMetadata,
1188	}
1189
1190	switch result.Result.GetType() {
1191	case fantasy.ToolResultContentTypeText:
1192		if r, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentText](result.Result); ok {
1193			baseResult.Content = r.Text
1194		}
1195	case fantasy.ToolResultContentTypeError:
1196		if r, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentError](result.Result); ok {
1197			baseResult.Content = r.Error.Error()
1198			baseResult.IsError = true
1199		}
1200	case fantasy.ToolResultContentTypeMedia:
1201		if r, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentMedia](result.Result); ok {
1202			if !stringext.IsValidBase64(r.Data) {
1203				slog.Warn("Tool returned media with invalid base64 data, discarding image",
1204					"tool", result.ToolName,
1205					"tool_call_id", result.ToolCallID,
1206				)
1207				baseResult.Content = "Tool returned image data with invalid encoding"
1208				baseResult.IsError = true
1209			} else {
1210				content := r.Text
1211				if content == "" {
1212					content = fmt.Sprintf("Loaded %s content", r.MediaType)
1213				}
1214				baseResult.Content = content
1215				baseResult.Data = r.Data
1216				baseResult.MIMEType = r.MediaType
1217			}
1218		}
1219	}
1220
1221	return baseResult
1222}
1223
1224// workaroundProviderMediaLimitations converts media content in tool results to
1225// user messages for providers that don't natively support images in tool results.
1226//
1227// Problem: OpenAI, Google, OpenRouter, and other OpenAI-compatible providers
1228// don't support sending images/media in tool result messages - they only accept
1229// text in tool results. However, they DO support images in user messages.
1230//
1231// If we send media in tool results to these providers, the API returns an error.
1232//
1233// Solution: For these providers, we:
1234//  1. Replace the media in the tool result with a text placeholder
1235//  2. Inject a user message immediately after with the image as a file attachment
1236//  3. This maintains the tool execution flow while working around API limitations
1237//
1238// Anthropic and Bedrock support images natively in tool results, so we skip
1239// this workaround for them.
1240//
1241// Example transformation:
1242//
1243//	BEFORE: [tool result: image data]
1244//	AFTER:  [tool result: "Image loaded - see attached"], [user: image attachment]
1245func (a *sessionAgent) workaroundProviderMediaLimitations(messages []fantasy.Message, largeModel Model) []fantasy.Message {
1246	providerSupportsMedia := largeModel.ModelCfg.Provider == string(catwalk.InferenceProviderAnthropic) ||
1247		largeModel.ModelCfg.Provider == string(catwalk.InferenceProviderBedrock)
1248
1249	if providerSupportsMedia {
1250		return messages
1251	}
1252
1253	convertedMessages := make([]fantasy.Message, 0, len(messages))
1254
1255	for _, msg := range messages {
1256		if msg.Role != fantasy.MessageRoleTool {
1257			convertedMessages = append(convertedMessages, msg)
1258			continue
1259		}
1260
1261		textParts := make([]fantasy.MessagePart, 0, len(msg.Content))
1262		var mediaFiles []fantasy.FilePart
1263
1264		for _, part := range msg.Content {
1265			toolResult, ok := fantasy.AsMessagePart[fantasy.ToolResultPart](part)
1266			if !ok {
1267				textParts = append(textParts, part)
1268				continue
1269			}
1270
1271			if media, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentMedia](toolResult.Output); ok {
1272				decoded, err := base64.StdEncoding.DecodeString(media.Data)
1273				if err != nil {
1274					slog.Warn("Failed to decode media data", "error", err)
1275					textParts = append(textParts, part)
1276					continue
1277				}
1278
1279				mediaFiles = append(mediaFiles, fantasy.FilePart{
1280					Data:      decoded,
1281					MediaType: media.MediaType,
1282					Filename:  fmt.Sprintf("tool-result-%s", toolResult.ToolCallID),
1283				})
1284
1285				textParts = append(textParts, fantasy.ToolResultPart{
1286					ToolCallID: toolResult.ToolCallID,
1287					Output: fantasy.ToolResultOutputContentText{
1288						Text: "[Image/media content loaded - see attached file]",
1289					},
1290					ProviderOptions: toolResult.ProviderOptions,
1291				})
1292			} else {
1293				textParts = append(textParts, part)
1294			}
1295		}
1296
1297		convertedMessages = append(convertedMessages, fantasy.Message{
1298			Role:    fantasy.MessageRoleTool,
1299			Content: textParts,
1300		})
1301
1302		if len(mediaFiles) > 0 {
1303			convertedMessages = append(convertedMessages, fantasy.NewUserMessage(
1304				"Here is the media content from the tool result:",
1305				mediaFiles...,
1306			))
1307		}
1308	}
1309
1310	return convertedMessages
1311}
1312
1313// buildSummaryPrompt constructs the prompt text for session summarization.
1314func buildSummaryPrompt(todos []session.Todo) string {
1315	var sb strings.Builder
1316	sb.WriteString("Provide a detailed summary of our conversation above.")
1317	if len(todos) > 0 {
1318		sb.WriteString("\n\n## Current Todo List\n\n")
1319		for _, t := range todos {
1320			fmt.Fprintf(&sb, "- [%s] %s\n", t.Status, t.Content)
1321		}
1322		sb.WriteString("\nInclude these tasks and their statuses in your summary. ")
1323		sb.WriteString("Instruct the resuming assistant to use the `todos` tool to continue tracking progress on these tasks.")
1324	}
1325	return sb.String()
1326}
1327
1328func providerRetryLogFields(err *fantasy.ProviderError, delay time.Duration) []any {
1329	fields := []any{
1330		"retry_delay", delay.String(),
1331	}
1332	if err == nil {
1333		return fields
1334	}
1335	fields = append(fields, "status_code", err.StatusCode)
1336	if err.Title != "" {
1337		fields = append(fields, "title", err.Title)
1338	}
1339	if err.Message != "" {
1340		fields = append(fields, "message", err.Message)
1341	}
1342	return fields
1343}