diff --git a/internal/agent/agent.go b/internal/agent/agent.go index ace750512ee94c69b67045620934a5d828dfd2db..1866a7865879fc83da7a8fdbaa5c87e70fcea1f2 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -64,7 +64,10 @@ var titlePrompt []byte var summaryPrompt []byte // Used to remove tags from generated titles. -var thinkTagRegex = regexp.MustCompile(`.*?`) +var ( + thinkTagRegex = regexp.MustCompile(`(?s).*?`) + orphanThinkTagRegex = regexp.MustCompile(``) +) type SessionAgentCall struct { SessionID string @@ -769,6 +772,16 @@ If not, please feel free to ignore. Again do not mention this message to the use ), )) } + // Collect all tool call IDs present in assistant messages. + knownToolCallIDs := make(map[string]struct{}) + for _, m := range msgs { + if m.Role == message.Assistant { + for _, tc := range m.ToolCalls() { + knownToolCallIDs[tc.ID] = struct{}{} + } + } + } + for _, m := range msgs { if len(m.Parts) == 0 { continue @@ -778,6 +791,35 @@ If not, please feel free to ignore. Again do not mention this message to the use if m.Role == message.Assistant && len(m.ToolCalls()) == 0 && m.Content().Text == "" && m.ReasoningContent().String() == "" { continue } + if m.Role == message.Tool { + // Filter out tool results that have no matching tool call. An orphaned + // result causes every subsequent API call to fail validation. + aiMsgs := m.ToAIMessage() + if len(aiMsgs) == 0 { + continue + } + var validParts []fantasy.MessagePart + for _, part := range aiMsgs[0].Content { + tr, ok := fantasy.AsMessagePart[fantasy.ToolResultPart](part) + if !ok { + validParts = append(validParts, part) + continue + } + if _, known := knownToolCallIDs[tr.ToolCallID]; known { + validParts = append(validParts, part) + } else { + slog.Warn("Dropping orphaned tool result with no matching tool call", + "tool_call_id", tr.ToolCallID, + ) + } + } + if len(validParts) > 0 { + msg := aiMsgs[0] + msg.Content = validParts + history = append(history, msg) + } + continue + } history = append(history, m.ToAIMessage()...) } @@ -898,6 +940,7 @@ func (a *sessionAgent) generateTitle(ctx context.Context, sessionID string, user // Remove thinking tags if present. title = thinkTagRegex.ReplaceAllString(title, "") + title = orphanThinkTagRegex.ReplaceAllString(title, "") title = strings.TrimSpace(title) title = cmp.Or(title, DefaultSessionName) diff --git a/internal/agent/tools/fetch.go b/internal/agent/tools/fetch.go index b9620567384fe992db29b492704862353fd3c3ba..90db1a179980e8fa3f65491c28123b0b62e797fb 100644 --- a/internal/agent/tools/fetch.go +++ b/internal/agent/tools/fetch.go @@ -160,7 +160,7 @@ func NewFetchTool(permissions permission.Service, workingDir string, client *htt } } // truncate content if it exceeds max read size - if int64(len(content)) > MaxFetchSize { + if int64(len(content)) >= MaxFetchSize { content = content[:MaxFetchSize] content += fmt.Sprintf("\n\n[Content truncated to %d bytes]", MaxFetchSize) }