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