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