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