content.go

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