content.go

  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}