Skip to content

Commit 6b68057

Browse files
authored
Handle and print reasoning tokens (#111)
The runtime will emit a new event for thinking models `agent_choice_reasoning`. Another change is in the `agent_choice` event, it now only sends the content as a string. <img width="1842" height="1189" alt="Screenshot 2025-09-05 at 14 15 26" src="https://github.com/user-attachments/assets/42306349-7421-474e-96d7-9fa425d45cf5" /> Signed-off-by: Djordje Lukic <[email protected]>
1 parent 3001bd9 commit 6b68057

File tree

11 files changed

+86
-47
lines changed

11 files changed

+86
-47
lines changed

cmd/root/new.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ func NewNewCmd() *cobra.Command {
107107
fmt.Println()
108108
llmIsTyping = true
109109
}
110-
fmt.Printf("%s", e.Choice.Delta.Content)
110+
fmt.Printf("%s", e.Content)
111111
case *runtime.ToolCallEvent:
112112
if llmIsTyping {
113113
fmt.Println()

cmd/root/run.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -321,7 +321,7 @@ func runWithoutTUI(ctx context.Context, agentFilename string, rt *runtime.Runtim
321321
}
322322
llmIsTyping = true
323323
}
324-
fmt.Printf("%s", e.Choice.Delta.Content)
324+
fmt.Printf("%s", e.Content)
325325
case *runtime.ToolCallConfirmationEvent:
326326
if llmIsTyping {
327327
fmt.Println()

internal/tui/components/message/message.go

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,17 +45,15 @@ func New(msg *types.Message, renderer *glamour.TermRenderer) Model {
4545

4646
// Init initializes the message view
4747
func (mv *messageModel) Init() tea.Cmd {
48-
// Start spinner for empty assistant messages
49-
if mv.message.Type == types.MessageTypeAssistant && mv.message.Content == "" {
48+
if mv.message.Type == types.MessageTypeSpinner {
5049
return mv.spinner.Tick
5150
}
5251
return nil
5352
}
5453

5554
// Update handles messages and updates the message view state
5655
func (mv *messageModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
57-
// Handle spinner updates for empty assistant messages
58-
if mv.message.Type == types.MessageTypeAssistant && mv.message.Content == "" {
56+
if mv.message.Type == types.MessageTypeSpinner {
5957
var cmd tea.Cmd
6058
mv.spinner, cmd = mv.spinner.Update(msg)
6159
return mv, cmd
@@ -75,6 +73,8 @@ func (mv *messageModel) View() string {
7573
func (mv *messageModel) Render(int) string {
7674
msg := mv.message
7775
switch msg.Type {
76+
case types.MessageTypeSpinner:
77+
return mv.spinner.View()
7878
case types.MessageTypeUser:
7979
if rendered, err := mv.renderer.Render("> " + msg.Content); err == nil {
8080
return strings.TrimRight(rendered, "\n\r\t ")
@@ -92,6 +92,12 @@ func (mv *messageModel) Render(int) string {
9292
}
9393

9494
return strings.TrimRight(rendered, "\n\r\t ")
95+
case types.MessageTypeAssistantReasoning:
96+
if msg.Content == "" {
97+
return mv.spinner.View()
98+
}
99+
text := senderPrefix(msg.Sender) + msg.Content
100+
return styles.MutedStyle.Italic(true).Render("Thinking: " + text)
95101
case types.MessageTypeShellOutput:
96102
if rendered, err := mv.renderer.Render(fmt.Sprintf("```console\n%s\n```", msg.Content)); err == nil {
97103
return strings.TrimRight(rendered, "\n\r\t ")

internal/tui/components/messages/messages.go

Lines changed: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
"github.com/docker/cagent/internal/tui/core"
1818
"github.com/docker/cagent/internal/tui/core/layout"
1919
"github.com/docker/cagent/internal/tui/types"
20+
"github.com/docker/cagent/pkg/runtime"
2021
"github.com/docker/cagent/pkg/tools"
2122
)
2223

@@ -32,8 +33,8 @@ type Model interface {
3233
AddAssistantMessage() tea.Cmd
3334
AddSeparatorMessage() tea.Cmd
3435
AddOrUpdateToolCall(agentName string, toolCall tools.ToolCall, status types.ToolStatus) tea.Cmd
35-
AddToolResult(toolCall tools.ToolCall, result string, status types.ToolStatus) tea.Cmd
36-
AppendToLastMessage(agentName string, content string) tea.Cmd
36+
AddToolResult(msg *runtime.ToolCallResponseEvent, status types.ToolStatus) tea.Cmd
37+
AppendToLastMessage(agentName string, messageType types.MessageType, content string) tea.Cmd
3738
ClearMessages()
3839
ScrollToBottom() tea.Cmd
3940
AddShellOutputMessage(content string) tea.Cmd
@@ -477,7 +478,7 @@ func (m *model) AddShellOutputMessage(content string) tea.Cmd {
477478
// AddAssistantMessage adds an assistant message to the chat
478479
func (m *model) AddAssistantMessage() tea.Cmd {
479480
return m.addMessage(&types.Message{
480-
Type: types.MessageTypeAssistant,
481+
Type: types.MessageTypeSpinner,
481482
})
482483
}
483484

@@ -505,7 +506,7 @@ func (m *model) addMessage(msg *types.Message) tea.Cmd {
505506

506507
// AddSeparatorMessage adds a separator message to the chat
507508
func (m *model) AddSeparatorMessage() tea.Cmd {
508-
m.removeLastEmptyAssistantMessage()
509+
m.removeSpinner()
509510
msg := types.Message{
510511
Type: types.MessageTypeSeparator,
511512
}
@@ -535,7 +536,7 @@ func (m *model) AddOrUpdateToolCall(agentName string, toolCall tools.ToolCall, s
535536
}
536537

537538
// If not found by ID, remove last empty assistant message
538-
m.removeLastEmptyAssistantMessage()
539+
m.removeSpinner()
539540

540541
// Create new tool call
541542
msg := types.Message{
@@ -553,14 +554,14 @@ func (m *model) AddOrUpdateToolCall(agentName string, toolCall tools.ToolCall, s
553554
}
554555

555556
// AddToolResult adds tool result to the most recent matching tool call
556-
func (m *model) AddToolResult(toolCall tools.ToolCall, result string, status types.ToolStatus) tea.Cmd {
557+
func (m *model) AddToolResult(msg *runtime.ToolCallResponseEvent, status types.ToolStatus) tea.Cmd {
557558
for i := len(m.messages) - 1; i >= 0; i-- {
558-
msg := &m.messages[i]
559-
if msg.ToolCall.ID == toolCall.ID {
560-
msg.Content = result
561-
msg.ToolStatus = status
559+
toolMessage := &m.messages[i]
560+
if toolMessage.ToolCall.ID == msg.ToolCall.ID {
561+
toolMessage.Content = msg.Response
562+
toolMessage.ToolStatus = status
562563
// Update the corresponding view
563-
view := m.createToolCallView(msg)
564+
view := m.createToolCallView(toolMessage)
564565
m.views[i] = view
565566
return view.Init()
566567
}
@@ -569,14 +570,16 @@ func (m *model) AddToolResult(toolCall tools.ToolCall, result string, status typ
569570
}
570571

571572
// AppendToLastMessage appends content to the last message (for streaming)
572-
func (m *model) AppendToLastMessage(agentName, content string) tea.Cmd {
573+
func (m *model) AppendToLastMessage(agentName string, messageType types.MessageType, content string) tea.Cmd {
574+
m.removeSpinner()
575+
573576
if len(m.messages) == 0 {
574577
return nil
575578
}
576579
lastIdx := len(m.messages) - 1
577580
lastMsg := &m.messages[lastIdx]
578581

579-
if lastMsg.Type == types.MessageTypeAssistant {
582+
if lastMsg.Type == messageType {
580583
wasAtBottom := m.isAtBottom()
581584
lastMsg.Content += content
582585
lastMsg.Sender = agentName
@@ -599,7 +602,7 @@ func (m *model) AppendToLastMessage(agentName, content string) tea.Cmd {
599602
} else {
600603
// Create new assistant message
601604
msg := types.Message{
602-
Type: types.MessageTypeAssistant,
605+
Type: messageType,
603606
Content: content,
604607
Sender: agentName,
605608
}
@@ -653,13 +656,13 @@ func (m *model) createMessageView(msg *types.Message) layout.Model {
653656
return view
654657
}
655658

656-
// removeLastEmptyAssistantMessage removes the last message if it's an assistant message with empty content
657-
func (m *model) removeLastEmptyAssistantMessage() {
659+
// removeSpinner removes the last message if it's an assistant message with empty content
660+
func (m *model) removeSpinner() {
658661
if len(m.messages) > 0 {
659662
lastIdx := len(m.messages) - 1
660663
lastMessage := m.messages[lastIdx]
661664

662-
if lastMessage.Type == types.MessageTypeAssistant && strings.Trim(lastMessage.Content, "\r\n\t ") == "" {
665+
if lastMessage.Type == types.MessageTypeSpinner {
663666
m.messages = m.messages[:lastIdx]
664667
if len(m.views) > lastIdx {
665668
m.views = m.views[:lastIdx]

internal/tui/page/chat/chat.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ type KeyMap struct {
6565
Tab key.Binding
6666
Quit key.Binding
6767
Cancel key.Binding
68+
Copy key.Binding
6869
}
6970

7071
// DefaultKeyMap returns the default key bindings
@@ -203,7 +204,10 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
203204
cmd := p.messages.AddAssistantMessage()
204205
return p, tea.Batch(cmd, p.messages.ScrollToBottom(), spinnerCmd)
205206
case *runtime.AgentChoiceEvent:
206-
cmd := p.messages.AppendToLastMessage(msg.AgentName, msg.Choice.Delta.Content)
207+
cmd := p.messages.AppendToLastMessage(msg.AgentName, types.MessageTypeAssistant, msg.Content)
208+
return p, tea.Batch(cmd, p.messages.ScrollToBottom())
209+
case *runtime.AgentChoiceReasoningEvent:
210+
cmd := p.messages.AppendToLastMessage(msg.AgentName, types.MessageTypeAssistantReasoning, msg.Content)
207211
return p, tea.Batch(cmd, p.messages.ScrollToBottom())
208212
case *runtime.SessionTitleEvent:
209213
p.sessionTitle = msg.Title
@@ -249,7 +253,7 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
249253
return p, tea.Batch(cmd, p.messages.ScrollToBottom(), spinnerCmd)
250254
case *runtime.ToolCallResponseEvent:
251255
spinnerCmd := p.setWorking(true)
252-
cmd := p.messages.AddToolResult(msg.ToolCall, msg.Response, types.ToolStatusCompleted)
256+
cmd := p.messages.AddToolResult(msg, types.ToolStatusCompleted)
253257
return p, tea.Batch(cmd, p.messages.ScrollToBottom(), spinnerCmd)
254258
}
255259

internal/tui/types/types.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ type MessageType int
88
const (
99
MessageTypeUser MessageType = iota
1010
MessageTypeAssistant
11+
MessageTypeAssistantReasoning
12+
MessageTypeSpinner
1113
MessageTypeError
1214
MessageTypeShellOutput
1315
MessageTypeSeparator

pkg/chat/chat.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -86,10 +86,11 @@ const (
8686

8787
// MessageDelta represents a delta/chunk in a streaming response
8888
type MessageDelta struct {
89-
Role string `json:"role,omitempty"`
90-
Content string `json:"content,omitempty"`
91-
FunctionCall *tools.FunctionCall `json:"function_call,omitempty"`
92-
ToolCalls []tools.ToolCall `json:"tool_calls,omitempty"`
89+
Role string `json:"role,omitempty"`
90+
Content string `json:"content,omitempty"`
91+
ReasoningContent string `json:"reasoning_content,omitempty"`
92+
FunctionCall *tools.FunctionCall `json:"function_call,omitempty"`
93+
ToolCalls []tools.ToolCall `json:"tool_calls,omitempty"`
9394
}
9495

9596
// MessageStreamChoice represents a choice in a streaming response

pkg/model/provider/anthropic/adapter.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,8 @@ func (a *streamAdapter) Recv() (chat.MessageStreamResponse, error) {
6868
switch deltaVariant := eventVariant.Delta.AsAny().(type) {
6969
case anthropic.TextDelta:
7070
response.Choices[0].Delta.Content = deltaVariant.Text
71-
71+
case anthropic.ThinkingDelta:
72+
response.Choices[0].Delta.ReasoningContent = deltaVariant.Thinking
7273
case anthropic.InputJSONDelta:
7374
inputBytes := deltaVariant.PartialJSON
7475
toolCall := tools.ToolCall{

pkg/model/provider/oaistream/adapter.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,8 +78,9 @@ func (a *StreamAdapter) Recv() (chat.MessageStreamResponse, error) {
7878
Index: choice.Index,
7979
FinishReason: finishReason,
8080
Delta: chat.MessageDelta{
81-
Role: choice.Delta.Role,
82-
Content: choice.Delta.Content,
81+
Role: choice.Delta.Role,
82+
Content: choice.Delta.Content,
83+
ReasoningContent: choice.Delta.ReasoningContent,
8384
},
8485
}
8586

pkg/runtime/event.go

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package runtime
22

33
import (
4-
"github.com/docker/cagent/pkg/chat"
54
"github.com/docker/cagent/pkg/tools"
65
)
76

@@ -120,20 +119,35 @@ func (e *StreamStartedEvent) GetAgentName() string {
120119
func (e *StreamStartedEvent) isEvent() {}
121120

122121
type AgentChoiceEvent struct {
123-
Type string `json:"type"`
124-
Choice chat.MessageStreamChoice `json:"choice"`
122+
Type string `json:"type"`
123+
Content string `json:"content"`
125124
AgentContext
126125
}
127126

128-
func AgentChoice(agentName string, choice chat.MessageStreamChoice) Event { //nolint:gocritic
127+
func AgentChoice(agentName string, content string) Event { //nolint:gocritic
129128
return &AgentChoiceEvent{
130129
Type: "agent_choice",
131-
Choice: choice,
130+
Content: content,
132131
AgentContext: AgentContext{AgentName: agentName},
133132
}
134133
}
135134
func (e *AgentChoiceEvent) isEvent() {}
136135

136+
type AgentChoiceReasoningEvent struct {
137+
Type string `json:"type"`
138+
Content string `json:"content"`
139+
AgentContext
140+
}
141+
142+
func AgentChoiceReasoning(agentName string, content string) Event { //nolint:gocritic
143+
return &AgentChoiceReasoningEvent{
144+
Type: "agent_choice_reasoning",
145+
Content: content,
146+
AgentContext: AgentContext{AgentName: agentName},
147+
}
148+
}
149+
func (e *AgentChoiceReasoningEvent) isEvent() {}
150+
137151
type ErrorEvent struct {
138152
Type string `json:"type"`
139153
Error string `json:"error"`

0 commit comments

Comments
 (0)