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