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