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}