1package message
2
3import (
4 "encoding/base64"
5 "errors"
6 "fmt"
7 "slices"
8 "strings"
9 "time"
10
11 "charm.land/catwalk/pkg/catwalk"
12 "charm.land/fantasy"
13 "charm.land/fantasy/providers/anthropic"
14 "charm.land/fantasy/providers/google"
15 "charm.land/fantasy/providers/openai"
16 "github.com/charmbracelet/crush/internal/stringext"
17)
18
19type MessageRole string
20
21const (
22 Assistant MessageRole = "assistant"
23 User MessageRole = "user"
24 System MessageRole = "system"
25 Tool MessageRole = "tool"
26)
27
28// mediaLoadFailedPlaceholder is the text substituted for image data that
29// cannot be decoded during session replay.
30const mediaLoadFailedPlaceholder = "[Image data could not be loaded]"
31
32type FinishReason string
33
34const (
35 FinishReasonEndTurn FinishReason = "end_turn"
36 FinishReasonMaxTokens FinishReason = "max_tokens"
37 FinishReasonToolUse FinishReason = "tool_use"
38 FinishReasonCanceled FinishReason = "canceled"
39 FinishReasonError FinishReason = "error"
40 FinishReasonPermissionDenied FinishReason = "permission_denied"
41
42 // Should never happen
43 FinishReasonUnknown FinishReason = "unknown"
44)
45
46type ContentPart interface {
47 isPart()
48}
49
50type ReasoningContent struct {
51 Thinking string `json:"thinking"`
52 Signature string `json:"signature"`
53 ThoughtSignature string `json:"thought_signature"` // Used for google
54 ToolID string `json:"tool_id"` // Used for openrouter google models
55 ResponsesData *openai.ResponsesReasoningMetadata `json:"responses_data"`
56 StartedAt int64 `json:"started_at,omitempty"`
57 FinishedAt int64 `json:"finished_at,omitempty"`
58}
59
60func (tc ReasoningContent) String() string {
61 return tc.Thinking
62}
63func (ReasoningContent) isPart() {}
64
65type TextContent struct {
66 Text string `json:"text"`
67}
68
69func (tc TextContent) String() string {
70 return tc.Text
71}
72
73func (TextContent) isPart() {}
74
75type ImageURLContent struct {
76 URL string `json:"url"`
77 Detail string `json:"detail,omitempty"`
78}
79
80func (iuc ImageURLContent) String() string {
81 return iuc.URL
82}
83
84func (ImageURLContent) isPart() {}
85
86type BinaryContent struct {
87 Path string
88 MIMEType string
89 Data []byte
90}
91
92func (bc BinaryContent) String(p catwalk.InferenceProvider) string {
93 base64Encoded := base64.StdEncoding.EncodeToString(bc.Data)
94 if p == catwalk.InferenceProviderOpenAI {
95 return "data:" + bc.MIMEType + ";base64," + base64Encoded
96 }
97 return base64Encoded
98}
99
100func (BinaryContent) isPart() {}
101
102type ToolCall struct {
103 ID string `json:"id"`
104 Name string `json:"name"`
105 Input string `json:"input"`
106 ProviderExecuted bool `json:"provider_executed"`
107 Finished bool `json:"finished"`
108}
109
110func (ToolCall) isPart() {}
111
112type ToolResult struct {
113 ToolCallID string `json:"tool_call_id"`
114 Name string `json:"name"`
115 Content string `json:"content"`
116 Data string `json:"data"`
117 MIMEType string `json:"mime_type"`
118 Metadata string `json:"metadata"`
119 IsError bool `json:"is_error"`
120}
121
122func (ToolResult) isPart() {}
123
124type Finish struct {
125 Reason FinishReason `json:"reason"`
126 Time int64 `json:"time"`
127 Message string `json:"message,omitempty"`
128 Details string `json:"details,omitempty"`
129}
130
131func (Finish) isPart() {}
132
133type Message struct {
134 ID string
135 Role MessageRole
136 SessionID string
137 Parts []ContentPart
138 Model string
139 Provider string
140 CreatedAt int64
141 UpdatedAt int64
142 IsSummaryMessage bool
143}
144
145func (m *Message) Content() TextContent {
146 for _, part := range m.Parts {
147 if c, ok := part.(TextContent); ok {
148 return c
149 }
150 }
151 return TextContent{}
152}
153
154func (m *Message) ReasoningContent() ReasoningContent {
155 for _, part := range m.Parts {
156 if c, ok := part.(ReasoningContent); ok {
157 return c
158 }
159 }
160 return ReasoningContent{}
161}
162
163func (m *Message) ImageURLContent() []ImageURLContent {
164 imageURLContents := make([]ImageURLContent, 0)
165 for _, part := range m.Parts {
166 if c, ok := part.(ImageURLContent); ok {
167 imageURLContents = append(imageURLContents, c)
168 }
169 }
170 return imageURLContents
171}
172
173func (m *Message) BinaryContent() []BinaryContent {
174 binaryContents := make([]BinaryContent, 0)
175 for _, part := range m.Parts {
176 if c, ok := part.(BinaryContent); ok {
177 binaryContents = append(binaryContents, c)
178 }
179 }
180 return binaryContents
181}
182
183func (m *Message) ToolCalls() []ToolCall {
184 toolCalls := make([]ToolCall, 0)
185 for _, part := range m.Parts {
186 if c, ok := part.(ToolCall); ok {
187 toolCalls = append(toolCalls, c)
188 }
189 }
190 return toolCalls
191}
192
193func (m *Message) ToolResults() []ToolResult {
194 toolResults := make([]ToolResult, 0)
195 for _, part := range m.Parts {
196 if c, ok := part.(ToolResult); ok {
197 toolResults = append(toolResults, c)
198 }
199 }
200 return toolResults
201}
202
203func (m *Message) IsFinished() bool {
204 for _, part := range m.Parts {
205 if _, ok := part.(Finish); ok {
206 return true
207 }
208 }
209 return false
210}
211
212func (m *Message) FinishPart() *Finish {
213 for _, part := range m.Parts {
214 if c, ok := part.(Finish); ok {
215 return &c
216 }
217 }
218 return nil
219}
220
221func (m *Message) FinishReason() FinishReason {
222 for _, part := range m.Parts {
223 if c, ok := part.(Finish); ok {
224 return c.Reason
225 }
226 }
227 return ""
228}
229
230func (m *Message) IsThinking() bool {
231 if m.ReasoningContent().Thinking != "" && m.Content().Text == "" && !m.IsFinished() {
232 return true
233 }
234 return false
235}
236
237func (m *Message) AppendContent(delta string) {
238 found := false
239 for i, part := range m.Parts {
240 if c, ok := part.(TextContent); ok {
241 m.Parts[i] = TextContent{Text: c.Text + delta}
242 found = true
243 }
244 }
245 if !found {
246 m.Parts = append(m.Parts, TextContent{Text: delta})
247 }
248}
249
250func (m *Message) AppendReasoningContent(delta string) {
251 found := false
252 for i, part := range m.Parts {
253 if c, ok := part.(ReasoningContent); ok {
254 m.Parts[i] = ReasoningContent{
255 Thinking: c.Thinking + delta,
256 Signature: c.Signature,
257 StartedAt: c.StartedAt,
258 FinishedAt: c.FinishedAt,
259 }
260 found = true
261 }
262 }
263 if !found {
264 m.Parts = append(m.Parts, ReasoningContent{
265 Thinking: delta,
266 StartedAt: time.Now().Unix(),
267 })
268 }
269}
270
271func (m *Message) AppendThoughtSignature(signature string, toolCallID string) {
272 for i, part := range m.Parts {
273 if c, ok := part.(ReasoningContent); ok {
274 m.Parts[i] = ReasoningContent{
275 Thinking: c.Thinking,
276 ThoughtSignature: c.ThoughtSignature + signature,
277 ToolID: toolCallID,
278 Signature: c.Signature,
279 StartedAt: c.StartedAt,
280 FinishedAt: c.FinishedAt,
281 }
282 return
283 }
284 }
285 m.Parts = append(m.Parts, ReasoningContent{ThoughtSignature: signature})
286}
287
288func (m *Message) AppendReasoningSignature(signature string) {
289 for i, part := range m.Parts {
290 if c, ok := part.(ReasoningContent); ok {
291 m.Parts[i] = ReasoningContent{
292 Thinking: c.Thinking,
293 Signature: c.Signature + signature,
294 StartedAt: c.StartedAt,
295 FinishedAt: c.FinishedAt,
296 }
297 return
298 }
299 }
300 m.Parts = append(m.Parts, ReasoningContent{Signature: signature})
301}
302
303func (m *Message) SetReasoningResponsesData(data *openai.ResponsesReasoningMetadata) {
304 for i, part := range m.Parts {
305 if c, ok := part.(ReasoningContent); ok {
306 m.Parts[i] = ReasoningContent{
307 Thinking: c.Thinking,
308 ResponsesData: data,
309 StartedAt: c.StartedAt,
310 FinishedAt: c.FinishedAt,
311 }
312 return
313 }
314 }
315}
316
317func (m *Message) FinishThinking() {
318 for i, part := range m.Parts {
319 if c, ok := part.(ReasoningContent); ok {
320 if c.FinishedAt == 0 {
321 m.Parts[i] = ReasoningContent{
322 Thinking: c.Thinking,
323 Signature: c.Signature,
324 StartedAt: c.StartedAt,
325 FinishedAt: time.Now().Unix(),
326 }
327 }
328 return
329 }
330 }
331}
332
333func (m *Message) ThinkingDuration() time.Duration {
334 reasoning := m.ReasoningContent()
335 if reasoning.StartedAt == 0 {
336 return 0
337 }
338
339 endTime := reasoning.FinishedAt
340 if endTime == 0 {
341 endTime = time.Now().Unix()
342 }
343
344 return time.Duration(endTime-reasoning.StartedAt) * time.Second
345}
346
347func (m *Message) FinishToolCall(toolCallID string) {
348 for i, part := range m.Parts {
349 if c, ok := part.(ToolCall); ok {
350 if c.ID == toolCallID {
351 m.Parts[i] = ToolCall{
352 ID: c.ID,
353 Name: c.Name,
354 Input: c.Input,
355 Finished: true,
356 }
357 return
358 }
359 }
360 }
361}
362
363func (m *Message) AppendToolCallInput(toolCallID string, inputDelta string) {
364 for i, part := range m.Parts {
365 if c, ok := part.(ToolCall); ok {
366 if c.ID == toolCallID {
367 m.Parts[i] = ToolCall{
368 ID: c.ID,
369 Name: c.Name,
370 Input: c.Input + inputDelta,
371 Finished: c.Finished,
372 }
373 return
374 }
375 }
376 }
377}
378
379func (m *Message) AddToolCall(tc ToolCall) {
380 for i, part := range m.Parts {
381 if c, ok := part.(ToolCall); ok {
382 if c.ID == tc.ID {
383 m.Parts[i] = tc
384 return
385 }
386 }
387 }
388 m.Parts = append(m.Parts, tc)
389}
390
391func (m *Message) SetToolCalls(tc []ToolCall) {
392 // remove any existing tool call part it could have multiple
393 parts := make([]ContentPart, 0)
394 for _, part := range m.Parts {
395 if _, ok := part.(ToolCall); ok {
396 continue
397 }
398 parts = append(parts, part)
399 }
400 m.Parts = parts
401 for _, toolCall := range tc {
402 m.Parts = append(m.Parts, toolCall)
403 }
404}
405
406func (m *Message) AddToolResult(tr ToolResult) {
407 m.Parts = append(m.Parts, tr)
408}
409
410func (m *Message) SetToolResults(tr []ToolResult) {
411 for _, toolResult := range tr {
412 m.Parts = append(m.Parts, toolResult)
413 }
414}
415
416// Clone returns a deep copy of the message with an independent Parts slice.
417// This prevents race conditions when the message is modified concurrently.
418func (m *Message) Clone() Message {
419 clone := *m
420 clone.Parts = make([]ContentPart, len(m.Parts))
421 copy(clone.Parts, m.Parts)
422 return clone
423}
424
425func (m *Message) AddFinish(reason FinishReason, message, details string) {
426 // remove any existing finish part
427 for i, part := range m.Parts {
428 if _, ok := part.(Finish); ok {
429 m.Parts = slices.Delete(m.Parts, i, i+1)
430 break
431 }
432 }
433 m.Parts = append(m.Parts, Finish{Reason: reason, Time: time.Now().Unix(), Message: message, Details: details})
434}
435
436func (m *Message) AddImageURL(url, detail string) {
437 m.Parts = append(m.Parts, ImageURLContent{URL: url, Detail: detail})
438}
439
440func (m *Message) AddBinary(mimeType string, data []byte) {
441 m.Parts = append(m.Parts, BinaryContent{MIMEType: mimeType, Data: data})
442}
443
444func PromptWithTextAttachments(prompt string, attachments []Attachment) string {
445 var sb strings.Builder
446 sb.WriteString(prompt)
447 addedAttachments := false
448 for _, content := range attachments {
449 if !content.IsText() {
450 continue
451 }
452 if !addedAttachments {
453 sb.WriteString("\n<system_info>The files below have been attached by the user, consider them in your response</system_info>\n")
454 addedAttachments = true
455 }
456 if content.FilePath != "" {
457 fmt.Fprintf(&sb, "<file path='%s'>\n", content.FilePath)
458 } else {
459 sb.WriteString("<file>\n")
460 }
461 sb.WriteString("\n")
462 sb.Write(content.Content)
463 sb.WriteString("\n</file>\n")
464 }
465 return sb.String()
466}
467
468func (m *Message) ToAIMessage() []fantasy.Message {
469 var messages []fantasy.Message
470 switch m.Role {
471 case User:
472 var parts []fantasy.MessagePart
473 text := strings.TrimSpace(m.Content().Text)
474 var textAttachments []Attachment
475 for _, content := range m.BinaryContent() {
476 if !strings.HasPrefix(content.MIMEType, "text/") {
477 continue
478 }
479 textAttachments = append(textAttachments, Attachment{
480 FilePath: content.Path,
481 MimeType: content.MIMEType,
482 Content: content.Data,
483 })
484 }
485 text = PromptWithTextAttachments(text, textAttachments)
486 if text != "" {
487 parts = append(parts, fantasy.TextPart{Text: text})
488 }
489 for _, content := range m.BinaryContent() {
490 // skip text attachements
491 if strings.HasPrefix(content.MIMEType, "text/") {
492 continue
493 }
494 parts = append(parts, fantasy.FilePart{
495 Filename: content.Path,
496 Data: content.Data,
497 MediaType: content.MIMEType,
498 })
499 }
500 messages = append(messages, fantasy.Message{
501 Role: fantasy.MessageRoleUser,
502 Content: parts,
503 })
504 case Assistant:
505 var parts []fantasy.MessagePart
506 text := strings.TrimSpace(m.Content().Text)
507 if text != "" {
508 parts = append(parts, fantasy.TextPart{Text: text})
509 }
510 reasoning := m.ReasoningContent()
511 if reasoning.Thinking != "" {
512 reasoningPart := fantasy.ReasoningPart{Text: reasoning.Thinking, ProviderOptions: fantasy.ProviderOptions{}}
513 if reasoning.Signature != "" {
514 reasoningPart.ProviderOptions[anthropic.Name] = &anthropic.ReasoningOptionMetadata{
515 Signature: reasoning.Signature,
516 }
517 }
518 if reasoning.ResponsesData != nil {
519 reasoningPart.ProviderOptions[openai.Name] = reasoning.ResponsesData
520 }
521 if reasoning.ThoughtSignature != "" {
522 reasoningPart.ProviderOptions[google.Name] = &google.ReasoningMetadata{
523 Signature: reasoning.ThoughtSignature,
524 ToolID: reasoning.ToolID,
525 }
526 }
527 parts = append(parts, reasoningPart)
528 }
529 for _, call := range m.ToolCalls() {
530 parts = append(parts, fantasy.ToolCallPart{
531 ToolCallID: call.ID,
532 ToolName: call.Name,
533 Input: call.Input,
534 ProviderExecuted: call.ProviderExecuted,
535 })
536 }
537 messages = append(messages, fantasy.Message{
538 Role: fantasy.MessageRoleAssistant,
539 Content: parts,
540 })
541 case Tool:
542 var parts []fantasy.MessagePart
543 for _, result := range m.ToolResults() {
544 var content fantasy.ToolResultOutputContent
545 if result.IsError {
546 content = fantasy.ToolResultOutputContentError{
547 Error: errors.New(result.Content),
548 }
549 } else if result.Data != "" {
550 if stringext.IsValidBase64(result.Data) {
551 content = fantasy.ToolResultOutputContentMedia{
552 Data: result.Data,
553 MediaType: result.MIMEType,
554 }
555 } else {
556 content = fantasy.ToolResultOutputContentText{
557 Text: mediaLoadFailedPlaceholder,
558 }
559 }
560 } else {
561 content = fantasy.ToolResultOutputContentText{
562 Text: result.Content,
563 }
564 }
565 parts = append(parts, fantasy.ToolResultPart{
566 ToolCallID: result.ToolCallID,
567 Output: content,
568 })
569 }
570 messages = append(messages, fantasy.Message{
571 Role: fantasy.MessageRoleTool,
572 Content: parts,
573 })
574 }
575 return messages
576}