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