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}