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