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}
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}