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