diff options
| author | Grail Finder <wohilas@gmail.com> | 2026-02-27 18:45:59 +0300 |
|---|---|---|
| committer | Grail Finder <wohilas@gmail.com> | 2026-02-27 18:45:59 +0300 |
| commit | 1fcab8365e8bdcf5658bfa601916e074a39a71e7 (patch) | |
| tree | 0aba354dacc0abd1d20350cff7180f39aea96d32 | |
| parent | c855c30ae2f0b5fb272ba08826dc3d79f9487c80 (diff) | |
Enha: tool filter
| -rw-r--r-- | bot.go | 77 | ||||
| -rw-r--r-- | llm.go | 69 | ||||
| -rw-r--r-- | main.go | 1 | ||||
| -rw-r--r-- | models/models.go | 86 | ||||
| -rw-r--r-- | tui.go | 15 |
5 files changed, 187 insertions, 61 deletions
@@ -777,7 +777,7 @@ func showSpinner() { botPersona = cfg.WriteNextMsgAsCompletionAgent } for botRespMode || toolRunningMode { - time.Sleep(100 * time.Millisecond) + time.Sleep(400 * time.Millisecond) spin := i % len(spinners) app.QueueUpdateDraw(func() { switch { @@ -1096,12 +1096,9 @@ func findCall(msg, toolCall string) bool { } lastToolCall.Args = openAIToolMap fc = lastToolCall - // Set lastToolCall.ID from parsed tool call ID if available - if len(openAIToolMap) > 0 { - if id, exists := openAIToolMap["id"]; exists { - lastToolCall.ID = id - } - } + // NOTE: We do NOT override lastToolCall.ID from arguments. + // The ID should come from the streaming response (chunk.ToolID) set earlier. + // Some tools like todo_create have "id" in their arguments which is NOT the tool call ID. } else { jsStr := toolCallRE.FindString(msg) if jsStr == "" { // no tool call case @@ -1138,14 +1135,21 @@ func findCall(msg, toolCall string) bool { lastToolCall.Args = fc.Args } // we got here => last msg recognized as a tool call (correct or not) - // make sure it has ToolCallID - if chatBody.Messages[len(chatBody.Messages)-1].ToolCallID == "" { - // Tool call IDs should be alphanumeric strings with length 9! - chatBody.Messages[len(chatBody.Messages)-1].ToolCallID = randString(9) - } - // Ensure lastToolCall.ID is set, fallback to assistant message's ToolCallID - if lastToolCall.ID == "" { - lastToolCall.ID = chatBody.Messages[len(chatBody.Messages)-1].ToolCallID + // Use the tool call ID from streaming response (lastToolCall.ID) + // Don't generate random ID - the ID should match between assistant message and tool response + lastMsgIdx := len(chatBody.Messages) - 1 + if lastToolCall.ID != "" { + chatBody.Messages[lastMsgIdx].ToolCallID = lastToolCall.ID + } + // Store tool call info in the assistant message + // Convert Args map to JSON string for storage + argsJSON, _ := json.Marshal(lastToolCall.Args) + chatBody.Messages[lastMsgIdx].ToolCalls = []models.ToolCall{ + { + ID: lastToolCall.ID, + Name: lastToolCall.Name, + Args: string(argsJSON), + }, } // call a func _, ok := fnMap[fc.Name] @@ -1175,15 +1179,18 @@ func findCall(msg, toolCall string) bool { toolRunningMode = true resp := callToolWithAgent(fc.Name, fc.Args) toolRunningMode = false - toolMsg := string(resp) // Remove the "tool response: " prefix and %+v formatting + toolMsg := string(resp) logger.Info("llm used a tool call", "tool_name", fc.Name, "too_args", fc.Args, "id", fc.ID, "tool_resp", toolMsg) fmt.Fprintf(textView, "%s[-:-:b](%d) <%s>: [-:-:-]\n%s\n", "\n\n", len(chatBody.Messages), cfg.ToolRole, toolMsg) // Create tool response message with the proper tool_call_id + // Mark shell commands as always visible + isShellCommand := fc.Name == "execute_command" toolResponseMsg := models.RoleMsg{ - Role: cfg.ToolRole, - Content: toolMsg, - ToolCallID: lastToolCall.ID, // Use the stored tool call ID + Role: cfg.ToolRole, + Content: toolMsg, + ToolCallID: lastToolCall.ID, + IsShellCommand: isShellCommand, } chatBody.Messages = append(chatBody.Messages, toolResponseMsg) logger.Debug("findCall: added actual tool response", "role", toolResponseMsg.Role, "content_len", len(toolResponseMsg.Content), "tool_call_id", toolResponseMsg.ToolCallID, "message_count_after_add", len(chatBody.Messages)) @@ -1201,8 +1208,36 @@ func findCall(msg, toolCall string) bool { func chatToTextSlice(messages []models.RoleMsg, showSys bool) []string { resp := make([]string, len(messages)) for i, msg := range messages { - // INFO: skips system msg and tool msg - if !showSys && (msg.Role == cfg.ToolRole || msg.Role == "system") { + // Handle tool call indicators (assistant messages with tool call but empty content) + if (msg.Role == cfg.AssistantRole || msg.Role == "assistant") && msg.ToolCallID != "" && msg.Content == "" && len(msg.ToolCalls) > 0 { + // This is a tool call indicator - show collapsed + if toolCollapsed { + toolName := msg.ToolCalls[0].Name + resp[i] = fmt.Sprintf("[yellow::i][tool call: %s (press Ctrl+T to expand)][-:-:-]", toolName) + } else { + // Show full tool call info + toolName := msg.ToolCalls[0].Name + resp[i] = fmt.Sprintf("[yellow::i][tool call: %s][-:-:-]\nargs: %s", toolName, msg.ToolCalls[0].Args) + } + continue + } + // Handle tool responses + if msg.Role == cfg.ToolRole || msg.Role == "tool" { + // Always show shell commands + if msg.IsShellCommand { + resp[i] = msg.ToText(i) + continue + } + // Hide non-shell tool responses when collapsed + if toolCollapsed { + continue + } + // When expanded, show tool responses + resp[i] = msg.ToText(i) + continue + } + // INFO: skips system msg when showSys is false + if !showSys && msg.Role == "system" { continue } resp[i] = msg.ToText(i) @@ -282,21 +282,38 @@ func (op LCPChat) FormMsg(msg, role string, resume bool) (io.Reader, error) { "content_len", len(newMsg.Content), "message_count_after_add", len(chatBody.Messages)) } filteredMessages, _ := filterMessagesForCurrentCharacter(chatBody.Messages) + // Filter out tool call indicators (assistant messages with ToolCallID but empty content) + var filteredForLLM []models.RoleMsg + for _, msg := range filteredMessages { + isToolCallIndicator := msg.Role != "system" && msg.ToolCallID != "" && msg.Content == "" && len(msg.ToolCalls) > 0 + if isToolCallIndicator { + continue + } + filteredForLLM = append(filteredForLLM, msg) + } // openai /v1/chat does not support custom roles; needs to be user, assistant, system // Add persona suffix to the last user message to indicate who the assistant should reply as bodyCopy := &models.ChatBody{ - Messages: make([]models.RoleMsg, len(filteredMessages)), + Messages: make([]models.RoleMsg, len(filteredForLLM)), Model: chatBody.Model, Stream: chatBody.Stream, } - for i, msg := range filteredMessages { + for i, msg := range filteredForLLM { strippedMsg := *stripThinkingFromMsg(&msg) if strippedMsg.Role == cfg.UserRole { bodyCopy.Messages[i] = strippedMsg bodyCopy.Messages[i].Role = "user" + } else if strippedMsg.Role == cfg.AssistantRole { + bodyCopy.Messages[i] = strippedMsg + bodyCopy.Messages[i].Role = "assistant" + } else if strippedMsg.Role == cfg.ToolRole { + bodyCopy.Messages[i] = strippedMsg + bodyCopy.Messages[i].Role = "tool" } else { bodyCopy.Messages[i] = strippedMsg } + // Clear ToolCalls - they're stored in chat history for display but not sent to LLM + bodyCopy.Messages[i].ToolCalls = nil } // Clean null/empty messages to prevent API issues bodyCopy.Messages = consolidateAssistantMessages(bodyCopy.Messages) @@ -423,20 +440,37 @@ func (ds DeepSeekerChat) FormMsg(msg, role string, resume bool) (io.Reader, erro } // Create copy of chat body with standardized user role filteredMessages, _ := filterMessagesForCurrentCharacter(chatBody.Messages) + // Filter out tool call indicators (assistant messages with ToolCallID but empty content) + var filteredForLLM []models.RoleMsg + for _, msg := range filteredMessages { + isToolCallIndicator := msg.Role != "system" && msg.ToolCallID != "" && msg.Content == "" && len(msg.ToolCalls) > 0 + if isToolCallIndicator { + continue + } + filteredForLLM = append(filteredForLLM, msg) + } // Add persona suffix to the last user message to indicate who the assistant should reply as bodyCopy := &models.ChatBody{ - Messages: make([]models.RoleMsg, len(filteredMessages)), + Messages: make([]models.RoleMsg, len(filteredForLLM)), Model: chatBody.Model, Stream: chatBody.Stream, } - for i, msg := range filteredMessages { + for i, msg := range filteredForLLM { strippedMsg := *stripThinkingFromMsg(&msg) if strippedMsg.Role == cfg.UserRole || i == 1 { bodyCopy.Messages[i] = strippedMsg bodyCopy.Messages[i].Role = "user" + } else if strippedMsg.Role == cfg.AssistantRole { + bodyCopy.Messages[i] = strippedMsg + bodyCopy.Messages[i].Role = "assistant" + } else if strippedMsg.Role == cfg.ToolRole { + bodyCopy.Messages[i] = strippedMsg + bodyCopy.Messages[i].Role = "tool" } else { bodyCopy.Messages[i] = strippedMsg } + // Clear ToolCalls - they're stored in chat history for display but not sent to LLM + bodyCopy.Messages[i].ToolCalls = nil } // Clean null/empty messages to prevent API issues bodyCopy.Messages = consolidateAssistantMessages(bodyCopy.Messages) @@ -587,20 +621,37 @@ func (or OpenRouterChat) FormMsg(msg, role string, resume bool) (io.Reader, erro } // Create copy of chat body with standardized user role filteredMessages, _ := filterMessagesForCurrentCharacter(chatBody.Messages) + // Filter out tool call indicators (assistant messages with ToolCallID but empty content) + var filteredForLLM []models.RoleMsg + for _, msg := range filteredMessages { + isToolCallIndicator := msg.Role != "system" && msg.ToolCallID != "" && msg.Content == "" && len(msg.ToolCalls) > 0 + if isToolCallIndicator { + continue + } + filteredForLLM = append(filteredForLLM, msg) + } // Add persona suffix to the last user message to indicate who the assistant should reply as bodyCopy := &models.ChatBody{ - Messages: make([]models.RoleMsg, len(filteredMessages)), + Messages: make([]models.RoleMsg, len(filteredForLLM)), Model: chatBody.Model, Stream: chatBody.Stream, } - for i, msg := range filteredMessages { + for i, msg := range filteredForLLM { strippedMsg := *stripThinkingFromMsg(&msg) - bodyCopy.Messages[i] = strippedMsg - // Standardize role if it's a user role - if bodyCopy.Messages[i].Role == cfg.UserRole { + if strippedMsg.Role == cfg.UserRole { bodyCopy.Messages[i] = strippedMsg bodyCopy.Messages[i].Role = "user" + } else if strippedMsg.Role == cfg.AssistantRole { + bodyCopy.Messages[i] = strippedMsg + bodyCopy.Messages[i].Role = "assistant" + } else if strippedMsg.Role == cfg.ToolRole { + bodyCopy.Messages[i] = strippedMsg + bodyCopy.Messages[i].Role = "tool" + } else { + bodyCopy.Messages[i] = strippedMsg } + // Clear ToolCalls - they're stored in chat history for display but not sent to LLM + bodyCopy.Messages[i].ToolCalls = nil } // Clean null/empty messages to prevent API issues bodyCopy.Messages = consolidateAssistantMessages(bodyCopy.Messages) @@ -16,6 +16,7 @@ var ( shellHistory []string shellHistoryPos int = -1 thinkingCollapsed = false + toolCollapsed = false statusLineTempl = "help (F12) | chat: [orange:-:b]%s[-:-:-] (F1) | [%s:-:b]tool use[-:-:-] (ctrl+k) | model: [%s:-:b]%s[-:-:-] (ctrl+l) | [%s:-:b]skip LLM resp[-:-:-] (F10)\nAPI: [orange:-:b]%s[-:-:-] (ctrl+v) | writing as: [orange:-:b]%s[-:-:-] (ctrl+q) | bot will write as [orange:-:b]%s[-:-:-] (ctrl+x)" focusSwitcher = map[tview.Primitive]tview.Primitive{} ) diff --git a/models/models.go b/models/models.go index 5ea85ba..f430dd5 100644 --- a/models/models.go +++ b/models/models.go @@ -27,6 +27,12 @@ type FuncCall struct { Args map[string]string `json:"args"` } +type ToolCall struct { + ID string `json:"id,omitempty"` + Name string `json:"name"` + Args string `json:"arguments"` +} + type LLMResp struct { Choices []struct { FinishReason string `json:"finish_reason"` @@ -108,7 +114,9 @@ type RoleMsg struct { Role string `json:"role"` Content string `json:"-"` ContentParts []any `json:"-"` - ToolCallID string `json:"tool_call_id,omitempty"` // For tool response messages + ToolCallID string `json:"tool_call_id,omitempty"` // For tool response messages + ToolCalls []ToolCall `json:"tool_calls,omitempty"` // For assistant messages with tool calls + IsShellCommand bool `json:"is_shell_command,omitempty"` // True for shell command outputs (always shown) KnownTo []string `json:"known_to,omitempty"` Stats *ResponseStats `json:"stats"` hasContentParts bool // Flag to indicate which content type to marshal @@ -121,33 +129,41 @@ func (m RoleMsg) MarshalJSON() ([]byte, error) { if m.hasContentParts { // Use structured content format aux := struct { - Role string `json:"role"` - Content []any `json:"content"` - ToolCallID string `json:"tool_call_id,omitempty"` - KnownTo []string `json:"known_to,omitempty"` - Stats *ResponseStats `json:"stats,omitempty"` + Role string `json:"role"` + Content []any `json:"content"` + ToolCallID string `json:"tool_call_id,omitempty"` + ToolCalls []ToolCall `json:"tool_calls,omitempty"` + IsShellCommand bool `json:"is_shell_command,omitempty"` + KnownTo []string `json:"known_to,omitempty"` + Stats *ResponseStats `json:"stats,omitempty"` }{ - Role: m.Role, - Content: m.ContentParts, - ToolCallID: m.ToolCallID, - KnownTo: m.KnownTo, - Stats: m.Stats, + Role: m.Role, + Content: m.ContentParts, + ToolCallID: m.ToolCallID, + ToolCalls: m.ToolCalls, + IsShellCommand: m.IsShellCommand, + KnownTo: m.KnownTo, + Stats: m.Stats, } return json.Marshal(aux) } else { // Use simple content format aux := struct { - Role string `json:"role"` - Content string `json:"content"` - ToolCallID string `json:"tool_call_id,omitempty"` - KnownTo []string `json:"known_to,omitempty"` - Stats *ResponseStats `json:"stats,omitempty"` + Role string `json:"role"` + Content string `json:"content"` + ToolCallID string `json:"tool_call_id,omitempty"` + ToolCalls []ToolCall `json:"tool_calls,omitempty"` + IsShellCommand bool `json:"is_shell_command,omitempty"` + KnownTo []string `json:"known_to,omitempty"` + Stats *ResponseStats `json:"stats,omitempty"` }{ - Role: m.Role, - Content: m.Content, - ToolCallID: m.ToolCallID, - KnownTo: m.KnownTo, - Stats: m.Stats, + Role: m.Role, + Content: m.Content, + ToolCallID: m.ToolCallID, + ToolCalls: m.ToolCalls, + IsShellCommand: m.IsShellCommand, + KnownTo: m.KnownTo, + Stats: m.Stats, } return json.Marshal(aux) } @@ -157,16 +173,20 @@ func (m RoleMsg) MarshalJSON() ([]byte, error) { func (m *RoleMsg) UnmarshalJSON(data []byte) error { // First, try to unmarshal as structured content format var structured struct { - Role string `json:"role"` - Content []any `json:"content"` - ToolCallID string `json:"tool_call_id,omitempty"` - KnownTo []string `json:"known_to,omitempty"` - Stats *ResponseStats `json:"stats,omitempty"` + Role string `json:"role"` + Content []any `json:"content"` + ToolCallID string `json:"tool_call_id,omitempty"` + ToolCalls []ToolCall `json:"tool_calls,omitempty"` + IsShellCommand bool `json:"is_shell_command,omitempty"` + KnownTo []string `json:"known_to,omitempty"` + Stats *ResponseStats `json:"stats,omitempty"` } if err := json.Unmarshal(data, &structured); err == nil && len(structured.Content) > 0 { m.Role = structured.Role m.ContentParts = structured.Content m.ToolCallID = structured.ToolCallID + m.ToolCalls = structured.ToolCalls + m.IsShellCommand = structured.IsShellCommand m.KnownTo = structured.KnownTo m.Stats = structured.Stats m.hasContentParts = true @@ -175,11 +195,13 @@ func (m *RoleMsg) UnmarshalJSON(data []byte) error { // Otherwise, unmarshal as simple content format var simple struct { - Role string `json:"role"` - Content string `json:"content"` - ToolCallID string `json:"tool_call_id,omitempty"` - KnownTo []string `json:"known_to,omitempty"` - Stats *ResponseStats `json:"stats,omitempty"` + Role string `json:"role"` + Content string `json:"content"` + ToolCallID string `json:"tool_call_id,omitempty"` + ToolCalls []ToolCall `json:"tool_calls,omitempty"` + IsShellCommand bool `json:"is_shell_command,omitempty"` + KnownTo []string `json:"known_to,omitempty"` + Stats *ResponseStats `json:"stats,omitempty"` } if err := json.Unmarshal(data, &simple); err != nil { return err @@ -187,6 +209,8 @@ func (m *RoleMsg) UnmarshalJSON(data []byte) error { m.Role = simple.Role m.Content = simple.Content m.ToolCallID = simple.ToolCallID + m.ToolCalls = simple.ToolCalls + m.IsShellCommand = simple.IsShellCommand m.KnownTo = simple.KnownTo m.Stats = simple.Stats m.hasContentParts = false @@ -99,6 +99,7 @@ var ( [yellow]Alt+8[white]: show char img or last picked img [yellow]Alt+9[white]: warm up (load) selected llama.cpp model [yellow]Alt+t[white]: toggle thinking blocks visibility (collapse/expand <think> blocks) +[yellow]Ctrl+t[white]: toggle tool call/response visibility (collapse/expand tool calls and non-shell tool responses) [yellow]Alt+i[white]: show colorscheme selection popup === scrolling chat window (some keys similar to vim) === @@ -563,6 +564,20 @@ func init() { } return nil } + // Handle Ctrl+T to toggle tool call/response visibility + if event.Key() == tcell.KeyRune && event.Rune() == 't' && event.Modifiers()&tcell.ModCtrl != 0 { + toolCollapsed = !toolCollapsed + textView.SetText(chatToText(chatBody.Messages, cfg.ShowSys)) + colorText() + status := "expanded" + if toolCollapsed { + status = "collapsed" + } + if err := notifyUser("tools", "Tool calls/responses "+status); err != nil { + logger.Error("failed to send notification", "error", err) + } + return nil + } if event.Key() == tcell.KeyRune && event.Rune() == 'i' && event.Modifiers()&tcell.ModAlt != 0 { if isFullScreenPageActive() { return event |
