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	"os"
  19	"regexp"
  20	"strconv"
  21	"strings"
  22	"sync"
  23	"time"
  24
  25	"charm.land/catwalk/pkg/catwalk"
  26	"charm.land/fantasy"
  27	"charm.land/fantasy/providers/anthropic"
  28	"charm.land/fantasy/providers/bedrock"
  29	"charm.land/fantasy/providers/google"
  30	"charm.land/fantasy/providers/openai"
  31	"charm.land/fantasy/providers/openrouter"
  32	"charm.land/fantasy/providers/vercel"
  33	"charm.land/lipgloss/v2"
  34	"github.com/charmbracelet/crush/internal/agent/hyper"
  35	"github.com/charmbracelet/crush/internal/agent/notify"
  36	"github.com/charmbracelet/crush/internal/agent/tools"
  37	"github.com/charmbracelet/crush/internal/agent/tools/mcp"
  38	"github.com/charmbracelet/crush/internal/config"
  39	"github.com/charmbracelet/crush/internal/csync"
  40	"github.com/charmbracelet/crush/internal/message"
  41	"github.com/charmbracelet/crush/internal/permission"
  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			// TODO: implement
 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			currentAssistant.AddFinish(finishReason, "", "")
 418			sessionLock.Lock()
 419			defer sessionLock.Unlock()
 420
 421			updatedSession, getSessionErr := a.sessions.Get(ctx, call.SessionID)
 422			if getSessionErr != nil {
 423				return getSessionErr
 424			}
 425			a.updateSessionUsage(largeModel, &updatedSession, stepResult.Usage, a.openrouterCost(stepResult.ProviderMetadata))
 426			_, sessionErr := a.sessions.Save(ctx, updatedSession)
 427			if sessionErr != nil {
 428				return sessionErr
 429			}
 430			currentSession = updatedSession
 431			return a.messages.Update(genCtx, *currentAssistant)
 432		},
 433		StopWhen: []fantasy.StopCondition{
 434			func(_ []fantasy.StepResult) bool {
 435				cw := int64(largeModel.CatwalkCfg.ContextWindow)
 436				// If context window is unknown (0), skip auto-summarize
 437				// to avoid immediately truncating custom/local models.
 438				if cw == 0 {
 439					return false
 440				}
 441				tokens := currentSession.CompletionTokens + currentSession.PromptTokens
 442				remaining := cw - tokens
 443				var threshold int64
 444				if cw > largeContextWindowThreshold {
 445					threshold = largeContextWindowBuffer
 446				} else {
 447					threshold = int64(float64(cw) * smallContextWindowRatio)
 448				}
 449				if (remaining <= threshold) && !a.disableAutoSummarize {
 450					shouldSummarize = true
 451					return true
 452				}
 453				return false
 454			},
 455			func(steps []fantasy.StepResult) bool {
 456				return hasRepeatedToolCalls(steps, loopDetectionWindowSize, loopDetectionMaxRepeats)
 457			},
 458		},
 459	})
 460
 461	a.eventPromptResponded(call.SessionID, time.Since(startTime).Truncate(time.Second))
 462
 463	if err != nil {
 464		isCancelErr := errors.Is(err, context.Canceled)
 465		isPermissionErr := errors.Is(err, permission.ErrorPermissionDenied)
 466		if currentAssistant == nil {
 467			return result, err
 468		}
 469		// Ensure we finish thinking on error to close the reasoning state.
 470		currentAssistant.FinishThinking()
 471		toolCalls := currentAssistant.ToolCalls()
 472		// INFO: we use the parent context here because the genCtx has been cancelled.
 473		msgs, createErr := a.messages.List(ctx, currentAssistant.SessionID)
 474		if createErr != nil {
 475			return nil, createErr
 476		}
 477		for _, tc := range toolCalls {
 478			if !tc.Finished {
 479				tc.Finished = true
 480				tc.Input = "{}"
 481				currentAssistant.AddToolCall(tc)
 482				updateErr := a.messages.Update(ctx, *currentAssistant)
 483				if updateErr != nil {
 484					return nil, updateErr
 485				}
 486			}
 487
 488			found := false
 489			for _, msg := range msgs {
 490				if msg.Role == message.Tool {
 491					for _, tr := range msg.ToolResults() {
 492						if tr.ToolCallID == tc.ID {
 493							found = true
 494							break
 495						}
 496					}
 497				}
 498				if found {
 499					break
 500				}
 501			}
 502			if found {
 503				continue
 504			}
 505			content := "There was an error while executing the tool"
 506			if isCancelErr {
 507				content = "Error: user cancelled assistant tool calling"
 508			} else if isPermissionErr {
 509				content = "User denied permission"
 510			}
 511			toolResult := message.ToolResult{
 512				ToolCallID: tc.ID,
 513				Name:       tc.Name,
 514				Content:    content,
 515				IsError:    true,
 516			}
 517			_, createErr = a.messages.Create(ctx, currentAssistant.SessionID, message.CreateMessageParams{
 518				Role: message.Tool,
 519				Parts: []message.ContentPart{
 520					toolResult,
 521				},
 522			})
 523			if createErr != nil {
 524				return nil, createErr
 525			}
 526		}
 527		var fantasyErr *fantasy.Error
 528		var providerErr *fantasy.ProviderError
 529		const defaultTitle = "Provider Error"
 530		linkStyle := lipgloss.NewStyle().Foreground(charmtone.Guac).Underline(true)
 531		if isCancelErr {
 532			currentAssistant.AddFinish(message.FinishReasonCanceled, "User canceled request", "")
 533		} else if isPermissionErr {
 534			currentAssistant.AddFinish(message.FinishReasonPermissionDenied, "User denied permission", "")
 535		} else if errors.Is(err, hyper.ErrUnauthorized) {
 536			currentAssistant.AddFinish(message.FinishReasonError, "Unauthorized", `Please re-authenticate with Hyper. You can also run "crush auth" to re-authenticate.`)
 537			if a.notify != nil {
 538				a.notify.Publish(pubsub.CreatedEvent, notify.Notification{
 539					SessionID:    call.SessionID,
 540					SessionTitle: currentSession.Title,
 541					Type:         notify.TypeReAuthenticate,
 542					ProviderID:   largeModel.ModelCfg.Provider,
 543				})
 544			}
 545		} else if errors.Is(err, hyper.ErrNoCredits) {
 546			url := hyper.BaseURL()
 547			link := linkStyle.Hyperlink(url, "id=hyper").Render(url)
 548			currentAssistant.AddFinish(message.FinishReasonError, "No credits", "You're out of credits. Add more at "+link)
 549		} else if errors.As(err, &providerErr) {
 550			if providerErr.Message == "The requested model is not supported." {
 551				url := "https://github.com/settings/copilot/features"
 552				link := linkStyle.Hyperlink(url, "id=copilot").Render(url)
 553				currentAssistant.AddFinish(
 554					message.FinishReasonError,
 555					"Copilot model not enabled",
 556					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),
 557				)
 558			} else {
 559				currentAssistant.AddFinish(message.FinishReasonError, cmp.Or(stringext.Capitalize(providerErr.Title), defaultTitle), providerErr.Message)
 560			}
 561		} else if errors.As(err, &fantasyErr) {
 562			currentAssistant.AddFinish(message.FinishReasonError, cmp.Or(stringext.Capitalize(fantasyErr.Title), defaultTitle), fantasyErr.Message)
 563		} else {
 564			currentAssistant.AddFinish(message.FinishReasonError, defaultTitle, err.Error())
 565		}
 566		// Note: we use the parent context here because the genCtx has been
 567		// cancelled.
 568		updateErr := a.messages.Update(ctx, *currentAssistant)
 569		if updateErr != nil {
 570			return nil, updateErr
 571		}
 572		return nil, err
 573	}
 574
 575	// Send notification that agent has finished its turn (skip for
 576	// nested/non-interactive sessions).
 577	if !call.NonInteractive && a.notify != nil {
 578		a.notify.Publish(pubsub.CreatedEvent, notify.Notification{
 579			SessionID:    call.SessionID,
 580			SessionTitle: currentSession.Title,
 581			Type:         notify.TypeAgentFinished,
 582		})
 583	}
 584
 585	if shouldSummarize {
 586		a.activeRequests.Del(call.SessionID)
 587		if summarizeErr := a.Summarize(genCtx, call.SessionID, call.ProviderOptions); summarizeErr != nil {
 588			return nil, summarizeErr
 589		}
 590		// If the agent wasn't done...
 591		if len(currentAssistant.ToolCalls()) > 0 {
 592			existing, ok := a.messageQueue.Get(call.SessionID)
 593			if !ok {
 594				existing = []SessionAgentCall{}
 595			}
 596			call.Prompt = fmt.Sprintf("The previous session was interrupted because it got too long, the initial user request was: `%s`", call.Prompt)
 597			existing = append(existing, call)
 598			a.messageQueue.Set(call.SessionID, existing)
 599		}
 600	}
 601
 602	// Release active request before processing queued messages.
 603	a.activeRequests.Del(call.SessionID)
 604	cancel()
 605
 606	queuedMessages, ok := a.messageQueue.Get(call.SessionID)
 607	if !ok || len(queuedMessages) == 0 {
 608		return result, err
 609	}
 610	// There are queued messages restart the loop.
 611	firstQueuedMessage := queuedMessages[0]
 612	a.messageQueue.Set(call.SessionID, queuedMessages[1:])
 613	return a.Run(ctx, firstQueuedMessage)
 614}
 615
 616func (a *sessionAgent) Summarize(ctx context.Context, sessionID string, opts fantasy.ProviderOptions) error {
 617	if a.IsSessionBusy(sessionID) {
 618		return ErrSessionBusy
 619	}
 620
 621	// Copy mutable fields under lock to avoid races with SetModels.
 622	largeModel := a.largeModel.Get()
 623	systemPromptPrefix := a.systemPromptPrefix.Get()
 624
 625	currentSession, err := a.sessions.Get(ctx, sessionID)
 626	if err != nil {
 627		return fmt.Errorf("failed to get session: %w", err)
 628	}
 629	msgs, err := a.getSessionMessages(ctx, currentSession)
 630	if err != nil {
 631		return err
 632	}
 633	if len(msgs) == 0 {
 634		// Nothing to summarize.
 635		return nil
 636	}
 637
 638	aiMsgs, _ := a.preparePrompt(msgs)
 639
 640	genCtx, cancel := context.WithCancel(ctx)
 641	a.activeRequests.Set(sessionID, cancel)
 642	defer a.activeRequests.Del(sessionID)
 643	defer cancel()
 644
 645	agent := fantasy.NewAgent(largeModel.Model,
 646		fantasy.WithSystemPrompt(string(summaryPrompt)),
 647		fantasy.WithUserAgent(userAgent),
 648	)
 649	summaryMessage, err := a.messages.Create(ctx, sessionID, message.CreateMessageParams{
 650		Role:             message.Assistant,
 651		Model:            largeModel.Model.Model(),
 652		Provider:         largeModel.Model.Provider(),
 653		IsSummaryMessage: true,
 654	})
 655	if err != nil {
 656		return err
 657	}
 658
 659	summaryPromptText := buildSummaryPrompt(currentSession.Todos)
 660
 661	resp, err := agent.Stream(genCtx, fantasy.AgentStreamCall{
 662		Prompt:          summaryPromptText,
 663		Messages:        aiMsgs,
 664		ProviderOptions: opts,
 665		PrepareStep: func(callContext context.Context, options fantasy.PrepareStepFunctionOptions) (_ context.Context, prepared fantasy.PrepareStepResult, err error) {
 666			prepared.Messages = options.Messages
 667			if systemPromptPrefix != "" {
 668				prepared.Messages = append([]fantasy.Message{fantasy.NewSystemMessage(systemPromptPrefix)}, prepared.Messages...)
 669			}
 670			return callContext, prepared, nil
 671		},
 672		OnReasoningDelta: func(id string, text string) error {
 673			summaryMessage.AppendReasoningContent(text)
 674			return a.messages.Update(genCtx, summaryMessage)
 675		},
 676		OnReasoningEnd: func(id string, reasoning fantasy.ReasoningContent) error {
 677			// Handle anthropic signature.
 678			if anthropicData, ok := reasoning.ProviderMetadata["anthropic"]; ok {
 679				if signature, ok := anthropicData.(*anthropic.ReasoningOptionMetadata); ok && signature.Signature != "" {
 680					summaryMessage.AppendReasoningSignature(signature.Signature)
 681				}
 682			}
 683			summaryMessage.FinishThinking()
 684			return a.messages.Update(genCtx, summaryMessage)
 685		},
 686		OnTextDelta: func(id, text string) error {
 687			summaryMessage.AppendContent(text)
 688			return a.messages.Update(genCtx, summaryMessage)
 689		},
 690	})
 691	if err != nil {
 692		isCancelErr := errors.Is(err, context.Canceled)
 693		if isCancelErr {
 694			// User cancelled summarize we need to remove the summary message.
 695			deleteErr := a.messages.Delete(ctx, summaryMessage.ID)
 696			return deleteErr
 697		}
 698		return err
 699	}
 700
 701	summaryMessage.AddFinish(message.FinishReasonEndTurn, "", "")
 702	err = a.messages.Update(genCtx, summaryMessage)
 703	if err != nil {
 704		return err
 705	}
 706
 707	var openrouterCost *float64
 708	for _, step := range resp.Steps {
 709		stepCost := a.openrouterCost(step.ProviderMetadata)
 710		if stepCost != nil {
 711			newCost := *stepCost
 712			if openrouterCost != nil {
 713				newCost += *openrouterCost
 714			}
 715			openrouterCost = &newCost
 716		}
 717	}
 718
 719	a.updateSessionUsage(largeModel, &currentSession, resp.TotalUsage, openrouterCost)
 720
 721	// Just in case, get just the last usage info.
 722	usage := resp.Response.Usage
 723	currentSession.SummaryMessageID = summaryMessage.ID
 724	currentSession.CompletionTokens = usage.OutputTokens
 725	currentSession.PromptTokens = 0
 726	_, err = a.sessions.Save(genCtx, currentSession)
 727	return err
 728}
 729
 730func (a *sessionAgent) getCacheControlOptions() fantasy.ProviderOptions {
 731	if t, _ := strconv.ParseBool(os.Getenv("CRUSH_DISABLE_ANTHROPIC_CACHE")); t {
 732		return fantasy.ProviderOptions{}
 733	}
 734	return fantasy.ProviderOptions{
 735		anthropic.Name: &anthropic.ProviderCacheControlOptions{
 736			CacheControl: anthropic.CacheControl{Type: "ephemeral"},
 737		},
 738		bedrock.Name: &anthropic.ProviderCacheControlOptions{
 739			CacheControl: anthropic.CacheControl{Type: "ephemeral"},
 740		},
 741		vercel.Name: &anthropic.ProviderCacheControlOptions{
 742			CacheControl: anthropic.CacheControl{Type: "ephemeral"},
 743		},
 744	}
 745}
 746
 747func (a *sessionAgent) createUserMessage(ctx context.Context, call SessionAgentCall) (message.Message, error) {
 748	parts := []message.ContentPart{message.TextContent{Text: call.Prompt}}
 749	var attachmentParts []message.ContentPart
 750	for _, attachment := range call.Attachments {
 751		attachmentParts = append(attachmentParts, message.BinaryContent{Path: attachment.FilePath, MIMEType: attachment.MimeType, Data: attachment.Content})
 752	}
 753	parts = append(parts, attachmentParts...)
 754	msg, err := a.messages.Create(ctx, call.SessionID, message.CreateMessageParams{
 755		Role:  message.User,
 756		Parts: parts,
 757	})
 758	if err != nil {
 759		return message.Message{}, fmt.Errorf("failed to create user message: %w", err)
 760	}
 761	return msg, nil
 762}
 763
 764func (a *sessionAgent) preparePrompt(msgs []message.Message, attachments ...message.Attachment) ([]fantasy.Message, []fantasy.FilePart) {
 765	var history []fantasy.Message
 766	if !a.isSubAgent {
 767		history = append(history, fantasy.NewUserMessage(
 768			fmt.Sprintf("<system_reminder>%s</system_reminder>",
 769				`This is a reminder that your todo list is currently empty. DO NOT mention this to the user explicitly because they are already aware.
 770If you are working on tasks that would benefit from a todo list please use the "todos" tool to create one.
 771If not, please feel free to ignore. Again do not mention this message to the user.`,
 772			),
 773		))
 774	}
 775	// Collect all tool call IDs present in assistant messages and all tool
 776	// result IDs present in tool messages. This lets us detect both orphaned
 777	// tool results (result without a call) and orphaned tool calls (call
 778	// without a result).
 779	knownToolCallIDs := make(map[string]struct{})
 780	knownToolResultIDs := make(map[string]struct{})
 781	for _, m := range msgs {
 782		switch m.Role {
 783		case message.Assistant:
 784			for _, tc := range m.ToolCalls() {
 785				knownToolCallIDs[tc.ID] = struct{}{}
 786			}
 787		case message.Tool:
 788			for _, tr := range m.ToolResults() {
 789				knownToolResultIDs[tr.ToolCallID] = struct{}{}
 790			}
 791		}
 792	}
 793
 794	for _, m := range msgs {
 795		if len(m.Parts) == 0 {
 796			continue
 797		}
 798		// Assistant message without content or tool calls (cancelled before it returned anything).
 799		if m.Role == message.Assistant && len(m.ToolCalls()) == 0 && m.Content().Text == "" && m.ReasoningContent().String() == "" {
 800			continue
 801		}
 802		if m.Role == message.Tool {
 803			if msg, ok := filterOrphanedToolResults(m, knownToolCallIDs); ok {
 804				history = append(history, msg)
 805			}
 806			continue
 807		}
 808		history = append(history, m.ToAIMessage()...)
 809
 810		if m.Role == message.Assistant {
 811			if msg, ok := syntheticToolResultsForOrphanedCalls(m, knownToolResultIDs); ok {
 812				history = append(history, msg)
 813			}
 814		}
 815	}
 816
 817	var files []fantasy.FilePart
 818	for _, attachment := range attachments {
 819		if attachment.IsText() {
 820			continue
 821		}
 822		files = append(files, fantasy.FilePart{
 823			Filename:  attachment.FileName,
 824			Data:      attachment.Content,
 825			MediaType: attachment.MimeType,
 826		})
 827	}
 828
 829	return history, files
 830}
 831
 832// filterOrphanedToolResults converts a tool message to a fantasy.Message,
 833// dropping any tool result parts whose tool_call_id has no matching tool call
 834// in the known set. An orphaned result causes API validation to fail on every
 835// subsequent turn, permanently locking the session. Returns the filtered
 836// message and true if at least one valid part remains.
 837func filterOrphanedToolResults(m message.Message, knownToolCallIDs map[string]struct{}) (fantasy.Message, bool) {
 838	aiMsgs := m.ToAIMessage()
 839	if len(aiMsgs) == 0 {
 840		return fantasy.Message{}, false
 841	}
 842	var validParts []fantasy.MessagePart
 843	for _, part := range aiMsgs[0].Content {
 844		tr, ok := fantasy.AsMessagePart[fantasy.ToolResultPart](part)
 845		if !ok {
 846			validParts = append(validParts, part)
 847			continue
 848		}
 849		if _, known := knownToolCallIDs[tr.ToolCallID]; known {
 850			validParts = append(validParts, part)
 851		} else {
 852			slog.Warn("Dropping orphaned tool result with no matching tool call",
 853				"tool_call_id", tr.ToolCallID,
 854			)
 855		}
 856	}
 857	if len(validParts) == 0 {
 858		return fantasy.Message{}, false
 859	}
 860	msg := aiMsgs[0]
 861	msg.Content = validParts
 862	return msg, true
 863}
 864
 865// syntheticToolResultsForOrphanedCalls returns a tool message containing
 866// synthetic tool results for any tool calls in the assistant message that
 867// have no matching result in knownToolResultIDs. LLM APIs require every
 868// tool_use to be immediately followed by a tool_result; an interrupted
 869// session can leave orphaned tool_use blocks that permanently lock the
 870// conversation. Returns the message and true if any synthetic results were
 871// produced.
 872func syntheticToolResultsForOrphanedCalls(m message.Message, knownToolResultIDs map[string]struct{}) (fantasy.Message, bool) {
 873	var syntheticParts []fantasy.MessagePart
 874	for _, tc := range m.ToolCalls() {
 875		if _, hasResult := knownToolResultIDs[tc.ID]; hasResult {
 876			continue
 877		}
 878		slog.Warn("Injecting synthetic tool result for orphaned tool call",
 879			"tool_call_id", tc.ID,
 880			"tool_name", tc.Name,
 881		)
 882		syntheticParts = append(syntheticParts, fantasy.ToolResultPart{
 883			ToolCallID: tc.ID,
 884			Output: fantasy.ToolResultOutputContentError{
 885				Error: errors.New("tool call was interrupted and did not produce a result, you may retry this call if the result is still needed"),
 886			},
 887		})
 888	}
 889	if len(syntheticParts) == 0 {
 890		return fantasy.Message{}, false
 891	}
 892	return fantasy.Message{
 893		Role:    fantasy.MessageRoleTool,
 894		Content: syntheticParts,
 895	}, true
 896}
 897
 898func (a *sessionAgent) getSessionMessages(ctx context.Context, session session.Session) ([]message.Message, error) {
 899	msgs, err := a.messages.List(ctx, session.ID)
 900	if err != nil {
 901		return nil, fmt.Errorf("failed to list messages: %w", err)
 902	}
 903
 904	if session.SummaryMessageID != "" {
 905		summaryMsgIndex := -1
 906		for i, msg := range msgs {
 907			if msg.ID == session.SummaryMessageID {
 908				summaryMsgIndex = i
 909				break
 910			}
 911		}
 912		if summaryMsgIndex != -1 {
 913			msgs = msgs[summaryMsgIndex:]
 914			msgs[0].Role = message.User
 915		}
 916	}
 917	return msgs, nil
 918}
 919
 920// generateTitle generates a session titled based on the initial prompt.
 921func (a *sessionAgent) generateTitle(ctx context.Context, sessionID string, userPrompt string) {
 922	if userPrompt == "" {
 923		return
 924	}
 925
 926	smallModel := a.smallModel.Get()
 927	largeModel := a.largeModel.Get()
 928	systemPromptPrefix := a.systemPromptPrefix.Get()
 929
 930	var maxOutputTokens int64 = 40
 931	if smallModel.CatwalkCfg.CanReason {
 932		maxOutputTokens = smallModel.CatwalkCfg.DefaultMaxTokens
 933	}
 934
 935	newAgent := func(m fantasy.LanguageModel, p []byte, tok int64) fantasy.Agent {
 936		return fantasy.NewAgent(m,
 937			fantasy.WithSystemPrompt(string(p)+"\n /no_think"),
 938			fantasy.WithMaxOutputTokens(tok),
 939			fantasy.WithUserAgent(userAgent),
 940		)
 941	}
 942
 943	streamCall := fantasy.AgentStreamCall{
 944		Prompt: fmt.Sprintf("Generate a concise title for the following content:\n\n%s\n <think>\n\n</think>", userPrompt),
 945		PrepareStep: func(callCtx context.Context, opts fantasy.PrepareStepFunctionOptions) (_ context.Context, prepared fantasy.PrepareStepResult, err error) {
 946			prepared.Messages = opts.Messages
 947			if systemPromptPrefix != "" {
 948				prepared.Messages = append([]fantasy.Message{
 949					fantasy.NewSystemMessage(systemPromptPrefix),
 950				}, prepared.Messages...)
 951			}
 952			return callCtx, prepared, nil
 953		},
 954	}
 955
 956	// Use the small model to generate the title.
 957	model := smallModel
 958	agent := newAgent(model.Model, titlePrompt, maxOutputTokens)
 959	resp, err := agent.Stream(ctx, streamCall)
 960	if err == nil {
 961		// We successfully generated a title with the small model.
 962		slog.Debug("Generated title with small model")
 963	} else {
 964		// It didn't work. Let's try with the big model.
 965		slog.Error("Error generating title with small model; trying big model", "err", err)
 966		model = largeModel
 967		agent = newAgent(model.Model, titlePrompt, maxOutputTokens)
 968		resp, err = agent.Stream(ctx, streamCall)
 969		if err == nil {
 970			slog.Debug("Generated title with large model")
 971		} else {
 972			// Welp, the large model didn't work either. Use the default
 973			// session name and return.
 974			slog.Error("Error generating title with large model", "err", err)
 975			saveErr := a.sessions.Rename(ctx, sessionID, DefaultSessionName)
 976			if saveErr != nil {
 977				slog.Error("Failed to save session title", "error", saveErr)
 978			}
 979			return
 980		}
 981	}
 982
 983	if resp == nil {
 984		// Actually, we didn't get a response so we can't. Use the default
 985		// session name and return.
 986		slog.Error("Response is nil; can't generate title")
 987		saveErr := a.sessions.Rename(ctx, sessionID, DefaultSessionName)
 988		if saveErr != nil {
 989			slog.Error("Failed to save session title", "error", saveErr)
 990		}
 991		return
 992	}
 993
 994	// Clean up title.
 995	var title string
 996	title = strings.ReplaceAll(resp.Response.Content.Text(), "\n", " ")
 997
 998	// Remove thinking tags if present.
 999	title = thinkTagRegex.ReplaceAllString(title, "")
1000	title = orphanThinkTagRegex.ReplaceAllString(title, "")
1001
1002	title = strings.TrimSpace(title)
1003	title = cmp.Or(title, DefaultSessionName)
1004
1005	// Calculate usage and cost.
1006	var openrouterCost *float64
1007	for _, step := range resp.Steps {
1008		stepCost := a.openrouterCost(step.ProviderMetadata)
1009		if stepCost != nil {
1010			newCost := *stepCost
1011			if openrouterCost != nil {
1012				newCost += *openrouterCost
1013			}
1014			openrouterCost = &newCost
1015		}
1016	}
1017
1018	modelConfig := model.CatwalkCfg
1019	cost := modelConfig.CostPer1MInCached/1e6*float64(resp.TotalUsage.CacheCreationTokens) +
1020		modelConfig.CostPer1MOutCached/1e6*float64(resp.TotalUsage.CacheReadTokens) +
1021		modelConfig.CostPer1MIn/1e6*float64(resp.TotalUsage.InputTokens) +
1022		modelConfig.CostPer1MOut/1e6*float64(resp.TotalUsage.OutputTokens)
1023
1024	// Use override cost if available (e.g., from OpenRouter).
1025	if openrouterCost != nil {
1026		cost = *openrouterCost
1027	}
1028
1029	promptTokens := resp.TotalUsage.InputTokens + resp.TotalUsage.CacheCreationTokens
1030	completionTokens := resp.TotalUsage.OutputTokens
1031
1032	// Atomically update only title and usage fields to avoid overriding other
1033	// concurrent session updates.
1034	saveErr := a.sessions.UpdateTitleAndUsage(ctx, sessionID, title, promptTokens, completionTokens, cost)
1035	if saveErr != nil {
1036		slog.Error("Failed to save session title and usage", "error", saveErr)
1037		return
1038	}
1039}
1040
1041func (a *sessionAgent) openrouterCost(metadata fantasy.ProviderMetadata) *float64 {
1042	openrouterMetadata, ok := metadata[openrouter.Name]
1043	if !ok {
1044		return nil
1045	}
1046
1047	opts, ok := openrouterMetadata.(*openrouter.ProviderMetadata)
1048	if !ok {
1049		return nil
1050	}
1051	return &opts.Usage.Cost
1052}
1053
1054func (a *sessionAgent) updateSessionUsage(model Model, session *session.Session, usage fantasy.Usage, overrideCost *float64) {
1055	modelConfig := model.CatwalkCfg
1056	cost := modelConfig.CostPer1MInCached/1e6*float64(usage.CacheCreationTokens) +
1057		modelConfig.CostPer1MOutCached/1e6*float64(usage.CacheReadTokens) +
1058		modelConfig.CostPer1MIn/1e6*float64(usage.InputTokens) +
1059		modelConfig.CostPer1MOut/1e6*float64(usage.OutputTokens)
1060
1061	a.eventTokensUsed(session.ID, model, usage, cost)
1062
1063	if overrideCost != nil {
1064		session.Cost += *overrideCost
1065	} else {
1066		session.Cost += cost
1067	}
1068
1069	session.CompletionTokens = usage.OutputTokens
1070	session.PromptTokens = usage.InputTokens + usage.CacheReadTokens
1071}
1072
1073func (a *sessionAgent) Cancel(sessionID string) {
1074	// Cancel regular requests. Don't use Take() here - we need the entry to
1075	// remain in activeRequests so IsBusy() returns true until the goroutine
1076	// fully completes (including error handling that may access the DB).
1077	// The defer in processRequest will clean up the entry.
1078	if cancel, ok := a.activeRequests.Get(sessionID); ok && cancel != nil {
1079		slog.Debug("Request cancellation initiated", "session_id", sessionID)
1080		cancel()
1081	}
1082
1083	// Also check for summarize requests.
1084	if cancel, ok := a.activeRequests.Get(sessionID + "-summarize"); ok && cancel != nil {
1085		slog.Debug("Summarize cancellation initiated", "session_id", sessionID)
1086		cancel()
1087	}
1088
1089	if a.QueuedPrompts(sessionID) > 0 {
1090		slog.Debug("Clearing queued prompts", "session_id", sessionID)
1091		a.messageQueue.Del(sessionID)
1092	}
1093}
1094
1095func (a *sessionAgent) ClearQueue(sessionID string) {
1096	if a.QueuedPrompts(sessionID) > 0 {
1097		slog.Debug("Clearing queued prompts", "session_id", sessionID)
1098		a.messageQueue.Del(sessionID)
1099	}
1100}
1101
1102func (a *sessionAgent) CancelAll() {
1103	if !a.IsBusy() {
1104		return
1105	}
1106	for key := range a.activeRequests.Seq2() {
1107		a.Cancel(key) // key is sessionID
1108	}
1109
1110	timeout := time.After(5 * time.Second)
1111	for a.IsBusy() {
1112		select {
1113		case <-timeout:
1114			return
1115		default:
1116			time.Sleep(200 * time.Millisecond)
1117		}
1118	}
1119}
1120
1121func (a *sessionAgent) IsBusy() bool {
1122	var busy bool
1123	for cancelFunc := range a.activeRequests.Seq() {
1124		if cancelFunc != nil {
1125			busy = true
1126			break
1127		}
1128	}
1129	return busy
1130}
1131
1132func (a *sessionAgent) IsSessionBusy(sessionID string) bool {
1133	_, busy := a.activeRequests.Get(sessionID)
1134	return busy
1135}
1136
1137func (a *sessionAgent) QueuedPrompts(sessionID string) int {
1138	l, ok := a.messageQueue.Get(sessionID)
1139	if !ok {
1140		return 0
1141	}
1142	return len(l)
1143}
1144
1145func (a *sessionAgent) QueuedPromptsList(sessionID string) []string {
1146	l, ok := a.messageQueue.Get(sessionID)
1147	if !ok {
1148		return nil
1149	}
1150	prompts := make([]string, len(l))
1151	for i, call := range l {
1152		prompts[i] = call.Prompt
1153	}
1154	return prompts
1155}
1156
1157func (a *sessionAgent) SetModels(large Model, small Model) {
1158	a.largeModel.Set(large)
1159	a.smallModel.Set(small)
1160}
1161
1162func (a *sessionAgent) SetTools(tools []fantasy.AgentTool) {
1163	a.tools.SetSlice(tools)
1164}
1165
1166func (a *sessionAgent) SetSystemPrompt(systemPrompt string) {
1167	a.systemPrompt.Set(systemPrompt)
1168}
1169
1170func (a *sessionAgent) Model() Model {
1171	return a.largeModel.Get()
1172}
1173
1174// convertToToolResult converts a fantasy tool result to a message tool result.
1175func (a *sessionAgent) convertToToolResult(result fantasy.ToolResultContent) message.ToolResult {
1176	baseResult := message.ToolResult{
1177		ToolCallID: result.ToolCallID,
1178		Name:       result.ToolName,
1179		Metadata:   result.ClientMetadata,
1180	}
1181
1182	switch result.Result.GetType() {
1183	case fantasy.ToolResultContentTypeText:
1184		if r, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentText](result.Result); ok {
1185			baseResult.Content = r.Text
1186		}
1187	case fantasy.ToolResultContentTypeError:
1188		if r, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentError](result.Result); ok {
1189			baseResult.Content = r.Error.Error()
1190			baseResult.IsError = true
1191		}
1192	case fantasy.ToolResultContentTypeMedia:
1193		if r, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentMedia](result.Result); ok {
1194			if !stringext.IsValidBase64(r.Data) {
1195				slog.Warn("Tool returned media with invalid base64 data, discarding image",
1196					"tool", result.ToolName,
1197					"tool_call_id", result.ToolCallID,
1198				)
1199				baseResult.Content = "Tool returned image data with invalid encoding"
1200				baseResult.IsError = true
1201			} else {
1202				content := r.Text
1203				if content == "" {
1204					content = fmt.Sprintf("Loaded %s content", r.MediaType)
1205				}
1206				baseResult.Content = content
1207				baseResult.Data = r.Data
1208				baseResult.MIMEType = r.MediaType
1209			}
1210		}
1211	}
1212
1213	return baseResult
1214}
1215
1216// workaroundProviderMediaLimitations converts media content in tool results to
1217// user messages for providers that don't natively support images in tool results.
1218//
1219// Problem: OpenAI, Google, OpenRouter, and other OpenAI-compatible providers
1220// don't support sending images/media in tool result messages - they only accept
1221// text in tool results. However, they DO support images in user messages.
1222//
1223// If we send media in tool results to these providers, the API returns an error.
1224//
1225// Solution: For these providers, we:
1226//  1. Replace the media in the tool result with a text placeholder
1227//  2. Inject a user message immediately after with the image as a file attachment
1228//  3. This maintains the tool execution flow while working around API limitations
1229//
1230// Anthropic and Bedrock support images natively in tool results, so we skip
1231// this workaround for them.
1232//
1233// Example transformation:
1234//
1235//	BEFORE: [tool result: image data]
1236//	AFTER:  [tool result: "Image loaded - see attached"], [user: image attachment]
1237func (a *sessionAgent) workaroundProviderMediaLimitations(messages []fantasy.Message, largeModel Model) []fantasy.Message {
1238	providerSupportsMedia := largeModel.ModelCfg.Provider == string(catwalk.InferenceProviderAnthropic) ||
1239		largeModel.ModelCfg.Provider == string(catwalk.InferenceProviderBedrock)
1240
1241	if providerSupportsMedia {
1242		return messages
1243	}
1244
1245	convertedMessages := make([]fantasy.Message, 0, len(messages))
1246
1247	for _, msg := range messages {
1248		if msg.Role != fantasy.MessageRoleTool {
1249			convertedMessages = append(convertedMessages, msg)
1250			continue
1251		}
1252
1253		textParts := make([]fantasy.MessagePart, 0, len(msg.Content))
1254		var mediaFiles []fantasy.FilePart
1255
1256		for _, part := range msg.Content {
1257			toolResult, ok := fantasy.AsMessagePart[fantasy.ToolResultPart](part)
1258			if !ok {
1259				textParts = append(textParts, part)
1260				continue
1261			}
1262
1263			if media, ok := fantasy.AsToolResultOutputType[fantasy.ToolResultOutputContentMedia](toolResult.Output); ok {
1264				decoded, err := base64.StdEncoding.DecodeString(media.Data)
1265				if err != nil {
1266					slog.Warn("Failed to decode media data", "error", err)
1267					textParts = append(textParts, part)
1268					continue
1269				}
1270
1271				mediaFiles = append(mediaFiles, fantasy.FilePart{
1272					Data:      decoded,
1273					MediaType: media.MediaType,
1274					Filename:  fmt.Sprintf("tool-result-%s", toolResult.ToolCallID),
1275				})
1276
1277				textParts = append(textParts, fantasy.ToolResultPart{
1278					ToolCallID: toolResult.ToolCallID,
1279					Output: fantasy.ToolResultOutputContentText{
1280						Text: "[Image/media content loaded - see attached file]",
1281					},
1282					ProviderOptions: toolResult.ProviderOptions,
1283				})
1284			} else {
1285				textParts = append(textParts, part)
1286			}
1287		}
1288
1289		convertedMessages = append(convertedMessages, fantasy.Message{
1290			Role:    fantasy.MessageRoleTool,
1291			Content: textParts,
1292		})
1293
1294		if len(mediaFiles) > 0 {
1295			convertedMessages = append(convertedMessages, fantasy.NewUserMessage(
1296				"Here is the media content from the tool result:",
1297				mediaFiles...,
1298			))
1299		}
1300	}
1301
1302	return convertedMessages
1303}
1304
1305// buildSummaryPrompt constructs the prompt text for session summarization.
1306func buildSummaryPrompt(todos []session.Todo) string {
1307	var sb strings.Builder
1308	sb.WriteString("Provide a detailed summary of our conversation above.")
1309	if len(todos) > 0 {
1310		sb.WriteString("\n\n## Current Todo List\n\n")
1311		for _, t := range todos {
1312			fmt.Fprintf(&sb, "- [%s] %s\n", t.Status, t.Content)
1313		}
1314		sb.WriteString("\nInclude these tasks and their statuses in your summary. ")
1315		sb.WriteString("Instruct the resuming assistant to use the `todos` tool to continue tracking progress on these tasks.")
1316	}
1317	return sb.String()
1318}