on main help sign in
fix(agent): validate tool call/results + strip tags from titles

This fixes a case where tool results could not have a matching tool call, resulting in corrupted sessions. There's also an interleaved fix for trimming rogue "</think>" tags from session titles while we're at it.

commitc41a1a0e40b90fa2ce20b076a3741cfa6dbdff8a authorChristian Rocha <christian@rocha.is> dateMon 13 Apr 2026, 12:18 (6 months ago) parent40b7cdd
2 files changed+45−2
internal/agent/agent.go+44−1 internal/agent/tools/fetch.go+1−1
internal/agent/agent.go+44−1
@@ -64,7 +64,10 @@ var titlePrompt []byte
6464var summaryPrompt []byte
6565
6666// Used to remove <think> tags from generated titles.
67−var thinkTagRegex = regexp.MustCompile(`<think>.*?</think>`)
67+var (
68+ thinkTagRegex = regexp.MustCompile(`(?s)<think>.*?</think>`)
69+ orphanThinkTagRegex = regexp.MustCompile(`</?think>`)
70+)
6871
6972type SessionAgentCall struct {
7073 SessionID string
@@ -769,6 +772,16 @@ If not, please feel free to ignore. Again do not mention this message to the use
769772 ),
770773 ))
771774 }
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+
772785 for _, m := range msgs {
773786 if len(m.Parts) == 0 {
774787 continue
@@ -778,6 +791,35 @@ If not, please feel free to ignore. Again do not mention this message to the use
778791 if m.Role == message.Assistant && len(m.ToolCalls()) == 0 && m.Content().Text == "" && m.ReasoningContent().String() == "" {
779792 continue
780793 }
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+ }
781823 history = append(history, m.ToAIMessage()...)
782824 }
783825
@@ -898,6 +940,7 @@ func (a *sessionAgent) generateTitle(ctx context.Context, sessionID string, user
898940
899941 // Remove thinking tags if present.
900942 title = thinkTagRegex.ReplaceAllString(title, "")
943+ title = orphanThinkTagRegex.ReplaceAllString(title, "")
901944
902945 title = strings.TrimSpace(title)
903946 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
160160 }
161161 }
162162 // truncate content if it exceeds max read size
163− if int64(len(content)) > MaxFetchSize {
163+ if int64(len(content)) >= MaxFetchSize {
164164 content = content[:MaxFetchSize]
165165 content += fmt.Sprintf("\n\n[Content truncated to %d bytes]", MaxFetchSize)
166166 }