on main help sign in
fix(agent): validate tool call/results + strip tags from titles
c41a1a0Christian Rocha
internal/agent/agent.go+44−1
@@ -64,7 +64,10 @@ var titlePrompt []byte
64var summaryPrompt []byte
64var summaryPrompt []byte
65
65
66// Used to remove <think> tags from generated titles.
66// Used to remove <think> tags from generated titles.
67+var (
67−var thinkTagRegex = regexp.MustCompile(`<think>.*?</think>`)
68+ thinkTagRegex = regexp.MustCompile(`(?s)<think>.*?</think>`)
69+ orphanThinkTagRegex = regexp.MustCompile(`</?think>`)
70+)
68
71
69type SessionAgentCall struct {
72type SessionAgentCall struct {
70 SessionID string
73 SessionID string
@@ -769,6 +772,16 @@ If not, please feel free to ignore. Again do not mention this message to the use
769 ),
772 ),
770 ))
773 ))
771 }
774 }
775+ // Collect all tool call IDs present in assistant messages.
776+ knownToolCallIDs := make(map[string]struct{})
777+ for _, m := range msgs {
778+ if m.Role == message.Assistant {
779+ for _, tc := range m.ToolCalls() {
780+ knownToolCallIDs[tc.ID] = struct{}{}
781+ }
782+ }
783+ }
784+
772 for _, m := range msgs {
785 for _, m := range msgs {
773 if len(m.Parts) == 0 {
786 if len(m.Parts) == 0 {
774 continue
787 continue
@@ -778,6 +791,35 @@ If not, please feel free to ignore. Again do not mention this message to the use
778 if m.Role == message.Assistant && len(m.ToolCalls()) == 0 && m.Content().Text == "" && m.ReasoningContent().String() == "" {
791 if m.Role == message.Assistant && len(m.ToolCalls()) == 0 && m.Content().Text == "" && m.ReasoningContent().String() == "" {
779 continue
792 continue
780 }
793 }
794+ if m.Role == message.Tool {
795+ // Filter out tool results that have no matching tool call. An orphaned
796+ // result causes every subsequent API call to fail validation.
797+ aiMsgs := m.ToAIMessage()
798+ if len(aiMsgs) == 0 {
799+ continue
800+ }
801+ var validParts []fantasy.MessagePart
802+ for _, part := range aiMsgs[0].Content {
803+ tr, ok := fantasy.AsMessagePart[fantasy.ToolResultPart](part)
804+ if !ok {
805+ validParts = append(validParts, part)
806+ continue
807+ }
808+ if _, known := knownToolCallIDs[tr.ToolCallID]; known {
809+ validParts = append(validParts, part)
810+ } else {
811+ slog.Warn("Dropping orphaned tool result with no matching tool call",
812+ "tool_call_id", tr.ToolCallID,
813+ )
814+ }
815+ }
816+ if len(validParts) > 0 {
817+ msg := aiMsgs[0]
818+ msg.Content = validParts
819+ history = append(history, msg)
820+ }
821+ continue
822+ }
781 history = append(history, m.ToAIMessage()...)
823 history = append(history, m.ToAIMessage()...)
782 }
824 }
783
825
@@ -898,6 +940,7 @@ func (a *sessionAgent) generateTitle(ctx context.Context, sessionID string, user
898
940
899 // Remove thinking tags if present.
941 // Remove thinking tags if present.
900 title = thinkTagRegex.ReplaceAllString(title, "")
942 title = thinkTagRegex.ReplaceAllString(title, "")
943+ title = orphanThinkTagRegex.ReplaceAllString(title, "")
901
944
902 title = strings.TrimSpace(title)
945 title = strings.TrimSpace(title)
903 title = cmp.Or(title, DefaultSessionName)
946 title = cmp.Or(title, DefaultSessionName)
internal/agent/tools/fetch.go+1−1
@@ -160,7 +160,7 @@ func NewFetchTool(permissions permission.Service, workingDir string, client *htt
160 }
160 }
161 }
161 }
162 // truncate content if it exceeds max read size
162 // truncate content if it exceeds max read size
163− if int64(len(content)) > MaxFetchSize {
163+ if int64(len(content)) >= MaxFetchSize {
164 content = content[:MaxFetchSize]
164 content = content[:MaxFetchSize]
165 content += fmt.Sprintf("\n\n[Content truncated to %d bytes]", MaxFetchSize)
165 content += fmt.Sprintf("\n\n[Content truncated to %d bytes]", MaxFetchSize)
166 }
166 }