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