diff --git a/internal/runtime/executor/kiro_executor.go b/internal/runtime/executor/kiro_executor.go index 47a04130..5a2cfa2b 100644 --- a/internal/runtime/executor/kiro_executor.go +++ b/internal/runtime/executor/kiro_executor.go @@ -2442,8 +2442,9 @@ func (e *KiroExecutor) extractEventTypeFromBytes(headers []byte) string { func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out chan<- cliproxyexecutor.StreamChunk, targetFormat sdktranslator.Format, model string, originalReq, claudeBody []byte, reporter *usageReporter, thinkingEnabled bool) { reader := bufio.NewReaderSize(body, 20*1024*1024) // 20MB buffer to match other providers var totalUsage usage.Detail - var hasToolUses bool // Track if any tool uses were emitted - var upstreamStopReason string // Track stop_reason from upstream events + var hasToolUses bool // Track if any tool uses were emitted + var hasTruncatedTools bool // Track if any tool uses were truncated + var upstreamStopReason string // Track stop_reason from upstream events // Tool use state tracking for input buffering and deduplication processedIDs := make(map[string]bool) @@ -3221,40 +3222,16 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out _ = signature // Signature can be used for verification if needed case "toolUseEvent": - // Debug: log raw toolUseEvent payload for large tool inputs - if log.IsLevelEnabled(log.DebugLevel) { - payloadStr := string(payload) - if len(payloadStr) > 500 { - payloadStr = payloadStr[:500] + "...[truncated]" - } - log.Debugf("kiro: raw toolUseEvent payload (%d bytes): %s", len(payload), payloadStr) - } // Handle dedicated tool use events with input buffering completedToolUses, newState := kiroclaude.ProcessToolUseEvent(event, currentToolUse, processedIDs) currentToolUse = newState // Emit completed tool uses for _, tu := range completedToolUses { - // Check for truncated write marker - emit as a Bash tool that echoes the error - // This way Claude Code will execute it, see the error, and the agent can retry - if tu.Name == "__truncated_write__" { - filePath := "" - if fp, ok := tu.Input["file_path"].(string); ok && fp != "" { - filePath = fp - } - - // Create a Bash tool that echoes the error message - // This will be executed by Claude Code and the agent will see the result - var errorMsg string - if filePath != "" { - errorMsg = fmt.Sprintf("echo '[WRITE TOOL ERROR] The file content for \"%s\" is too large to be transmitted by the upstream API. You MUST retry by writing the file in smaller chunks: First use Write to create the file with the first 700 lines, then use multiple Edit operations to append the remaining content in chunks of ~700 lines each.'", filePath) - } else { - errorMsg = "echo '[WRITE TOOL ERROR] The file content is too large to be transmitted by the upstream API. The Write tool input was truncated. You MUST retry by writing the file in smaller chunks: First use Write to create the file with the first 700 lines, then use multiple Edit operations to append the remaining content in chunks of ~700 lines each.'" - } - - log.Warnf("kiro: converting truncated write to Bash echo for file: %s", filePath) - - hasToolUses = true + // Check if this tool was truncated - emit with SOFT_LIMIT_REACHED marker + if tu.IsTruncated { + hasTruncatedTools = true + log.Infof("kiro: streamToChannel emitting truncated tool with SOFT_LIMIT_REACHED: %s (ID: %s)", tu.Name, tu.ToolUseID) // Close text block if open if isTextBlockOpen && contentBlockIndex >= 0 { @@ -3270,8 +3247,8 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out contentBlockIndex++ - // Emit as Bash tool_use - blockStart := kiroclaude.BuildClaudeContentBlockStartEvent(contentBlockIndex, "tool_use", tu.ToolUseID, "Bash") + // Emit tool_use with SOFT_LIMIT_REACHED marker input + blockStart := kiroclaude.BuildClaudeContentBlockStartEvent(contentBlockIndex, "tool_use", tu.ToolUseID, tu.Name) sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStart, &translatorParam) for _, chunk := range sseData { if chunk != "" { @@ -3279,16 +3256,14 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out } } - // Emit the Bash command as input - bashInput := map[string]interface{}{ - "command": errorMsg, + // Build SOFT_LIMIT_REACHED marker input + markerInput := map[string]interface{}{ + "_status": "SOFT_LIMIT_REACHED", + "_message": "Tool output was truncated. Split content into smaller chunks (max 300 lines). Due to potential model hallucination, you MUST re-fetch the current working directory and generate the correct file_path.", } - inputJSON, err := json.Marshal(bashInput) - if err != nil { - log.Errorf("kiro: failed to marshal bash input for truncated write error: %v", err) - continue - } - inputDelta := kiroclaude.BuildClaudeInputJsonDeltaEvent(string(inputJSON), contentBlockIndex) + + markerJSON, _ := json.Marshal(markerInput) + inputDelta := kiroclaude.BuildClaudeInputJsonDeltaEvent(string(markerJSON), contentBlockIndex) sseData = sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, inputDelta, &translatorParam) for _, chunk := range sseData { if chunk != "" { @@ -3296,6 +3271,7 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out } } + // Close tool_use block blockStop := kiroclaude.BuildClaudeContentBlockStopEvent(contentBlockIndex) sseData = sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStop, &translatorParam) for _, chunk := range sseData { @@ -3304,7 +3280,8 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out } } - continue // Skip the normal tool_use emission + hasToolUses = true // Keep this so stop_reason = tool_use + continue } hasToolUses = true @@ -3605,7 +3582,12 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out } // Determine stop reason: prefer upstream, then detect tool_use, default to end_turn + // SOFT_LIMIT_REACHED: Keep stop_reason = "tool_use" so Claude continues the loop stopReason := upstreamStopReason + if hasTruncatedTools { + // Log that we're using SOFT_LIMIT_REACHED approach + log.Infof("kiro: streamToChannel using SOFT_LIMIT_REACHED - keeping stop_reason=tool_use for truncated tools") + } if stopReason == "" { if hasToolUses { stopReason = "tool_use" diff --git a/internal/translator/kiro/claude/kiro_claude_request.go b/internal/translator/kiro/claude/kiro_claude_request.go index c04ee939..4e498c24 100644 --- a/internal/translator/kiro/claude/kiro_claude_request.go +++ b/internal/translator/kiro/claude/kiro_claude_request.go @@ -115,9 +115,11 @@ type KiroAssistantResponseMessage struct { // KiroToolUse represents a tool invocation by the assistant type KiroToolUse struct { - ToolUseID string `json:"toolUseId"` - Name string `json:"name"` - Input map[string]interface{} `json:"input"` + ToolUseID string `json:"toolUseId"` + Name string `json:"name"` + Input map[string]interface{} `json:"input"` + IsTruncated bool `json:"-"` // Internal flag, not serialized + TruncationInfo *TruncationInfo `json:"-"` // Truncation details, not serialized } // ConvertClaudeRequestToKiro converts a Claude API request to Kiro format. @@ -242,10 +244,10 @@ IMPORTANT: Always attempt to fetch information FIRST before declining. You CAN a // Kiro API supports official thinking/reasoning mode via tag. // When set to "enabled", Kiro returns reasoning content as official reasoningContentEvent // rather than inline tags in assistantResponseEvent. - // We use a high max_thinking_length to allow extensive reasoning. + // We cap max_thinking_length to reserve space for tool outputs and prevent truncation. if thinkingEnabled { thinkingHint := `enabled -200000` +16000` if systemPrompt != "" { systemPrompt = thinkingHint + "\n\n" + systemPrompt } else { @@ -771,7 +773,35 @@ func BuildUserMessageStruct(msg gjson.Result, modelID, origin string) (KiroUserI resultContent := part.Get("content") var textContents []KiroTextContent - if resultContent.IsArray() { + + // Check if this tool_result contains error from our SOFT_LIMIT_REACHED tool_use + // The client will return an error when trying to execute a tool with marker input + resultStr := resultContent.String() + isSoftLimitError := strings.Contains(resultStr, "SOFT_LIMIT_REACHED") || + strings.Contains(resultStr, "_status") || + strings.Contains(resultStr, "truncated") || + strings.Contains(resultStr, "missing required") || + strings.Contains(resultStr, "invalid input") || + strings.Contains(resultStr, "Error writing file") + + if isError && isSoftLimitError { + // Replace error content with SOFT_LIMIT_REACHED guidance + log.Infof("kiro: detected SOFT_LIMIT_REACHED in tool_result for %s, replacing with guidance", toolUseID) + softLimitMsg := `SOFT_LIMIT_REACHED + +Your previous tool call was incomplete due to API output size limits. +The content was PARTIALLY transmitted but NOT executed. + +REQUIRED ACTION: +1. Split your content into smaller chunks (max 300 lines per call) +2. For file writes: Create file with first chunk, then use append for remaining +3. Do NOT regenerate content you already attempted - continue from where you stopped + +STATUS: This is NOT an error. Continue with smaller chunks.` + textContents = append(textContents, KiroTextContent{Text: softLimitMsg}) + // Mark as SUCCESS so Claude doesn't treat it as a failure + isError = false + } else if resultContent.IsArray() { for _, item := range resultContent.Array() { if item.Get("type").String() == "text" { textContents = append(textContents, KiroTextContent{Text: item.Get("text").String()}) diff --git a/internal/translator/kiro/claude/kiro_claude_response.go b/internal/translator/kiro/claude/kiro_claude_response.go index 313c9059..89a760cd 100644 --- a/internal/translator/kiro/claude/kiro_claude_response.go +++ b/internal/translator/kiro/claude/kiro_claude_response.go @@ -55,14 +55,39 @@ func BuildClaudeResponse(content string, toolUses []KiroToolUse, model string, u } } - // Add tool_use blocks + // Add tool_use blocks - emit truncated tools with SOFT_LIMIT_REACHED marker + hasTruncatedTools := false for _, toolUse := range toolUses { - contentBlocks = append(contentBlocks, map[string]interface{}{ - "type": "tool_use", - "id": toolUse.ToolUseID, - "name": toolUse.Name, - "input": toolUse.Input, - }) + if toolUse.IsTruncated && toolUse.TruncationInfo != nil { + // Emit tool_use with SOFT_LIMIT_REACHED marker input + hasTruncatedTools = true + log.Infof("kiro: buildClaudeResponse emitting truncated tool with SOFT_LIMIT_REACHED: %s (ID: %s)", toolUse.Name, toolUse.ToolUseID) + + markerInput := map[string]interface{}{ + "_status": "SOFT_LIMIT_REACHED", + "_message": "Tool output was truncated. Split content into smaller chunks (max 300 lines). Due to potential model hallucination, you MUST re-fetch the current working directory and generate the correct file_path.", + } + + contentBlocks = append(contentBlocks, map[string]interface{}{ + "type": "tool_use", + "id": toolUse.ToolUseID, + "name": toolUse.Name, + "input": markerInput, + }) + } else { + // Normal tool use + contentBlocks = append(contentBlocks, map[string]interface{}{ + "type": "tool_use", + "id": toolUse.ToolUseID, + "name": toolUse.Name, + "input": toolUse.Input, + }) + } + } + + // Log if we used SOFT_LIMIT_REACHED + if hasTruncatedTools { + log.Infof("kiro: buildClaudeResponse using SOFT_LIMIT_REACHED - keeping stop_reason=tool_use") } // Ensure at least one content block (Claude API requires non-empty content) @@ -74,6 +99,7 @@ func BuildClaudeResponse(content string, toolUses []KiroToolUse, model string, u } // Use upstream stopReason; apply fallback logic if not provided + // SOFT_LIMIT_REACHED: Keep stop_reason = "tool_use" so Claude continues the loop if stopReason == "" { stopReason = "end_turn" if len(toolUses) > 0 { @@ -201,4 +227,4 @@ func ExtractThinkingFromContent(content string) []map[string]interface{} { } return blocks -} \ No newline at end of file +} diff --git a/internal/translator/kiro/claude/kiro_claude_tools.go b/internal/translator/kiro/claude/kiro_claude_tools.go index 6020a8a4..d00c7493 100644 --- a/internal/translator/kiro/claude/kiro_claude_tools.go +++ b/internal/translator/kiro/claude/kiro_claude_tools.go @@ -14,10 +14,11 @@ import ( // ToolUseState tracks the state of an in-progress tool use during streaming. type ToolUseState struct { - ToolUseID string - Name string - InputBuffer strings.Builder - IsComplete bool + ToolUseID string + Name string + InputBuffer strings.Builder + IsComplete bool + TruncationInfo *TruncationInfo // Truncation detection result (set when complete) } // Pre-compiled regex patterns for performance @@ -395,17 +396,6 @@ func ProcessToolUseEvent(event map[string]interface{}, currentToolUse *ToolUseSt isStop = stop } - // Debug: log when stop event arrives - if isStop { - log.Debugf("kiro: toolUseEvent stop=true received for tool %s (ID: %s), currentToolUse buffer len: %d", - toolName, toolUseID, func() int { - if currentToolUse != nil { - return currentToolUse.InputBuffer.Len() - } - return -1 - }()) - } - // Get input - can be string (fragment) or object (complete) var inputFragment string var inputMap map[string]interface{} @@ -477,98 +467,39 @@ func ProcessToolUseEvent(event map[string]interface{}, currentToolUse *ToolUseSt if isStop && currentToolUse != nil { fullInput := currentToolUse.InputBuffer.String() - // Check for Write tool with empty or missing input - this happens when Kiro API - // completely skips sending input for large file writes - if currentToolUse.Name == "Write" && len(strings.TrimSpace(fullInput)) == 0 { - log.Warnf("kiro: Write tool received no input from upstream API. The file content may be too large to transmit.") - // Return nil to skip this tool use - it will be handled as a truncation error - // The caller should emit a text block explaining the error instead - if processedIDs != nil { - processedIDs[currentToolUse.ToolUseID] = true - } - log.Infof("kiro: skipping Write tool use %s due to empty input (content too large)", currentToolUse.ToolUseID) - // Return a special marker tool use that indicates truncation - toolUse := KiroToolUse{ - ToolUseID: currentToolUse.ToolUseID, - Name: "__truncated_write__", // Special marker name - Input: map[string]interface{}{ - "error": "Write tool input was not transmitted by upstream API. The file content is too large.", - }, - } - toolUses = append(toolUses, toolUse) - return toolUses, nil - } - // Repair and parse the accumulated JSON repairedJSON := RepairJSON(fullInput) var finalInput map[string]interface{} if err := json.Unmarshal([]byte(repairedJSON), &finalInput); err != nil { log.Warnf("kiro: failed to parse accumulated tool input: %v, raw: %s", err, fullInput) finalInput = make(map[string]interface{}) - - // Check if this is a Write tool with truncated input (missing content field) - // This happens when the Kiro API truncates large tool inputs - if currentToolUse.Name == "Write" && strings.Contains(fullInput, "file_path") && !strings.Contains(fullInput, "content") { - log.Warnf("kiro: Write tool input was truncated by upstream API (content field missing). The file content may be too large.") - // Extract file_path if possible for error context - filePath := "" - if idx := strings.Index(fullInput, "file_path"); idx >= 0 { - // Try to extract the file path value - rest := fullInput[idx:] - if colonIdx := strings.Index(rest, ":"); colonIdx >= 0 { - rest = strings.TrimSpace(rest[colonIdx+1:]) - if len(rest) > 0 && rest[0] == '"' { - rest = rest[1:] - if endQuote := strings.Index(rest, "\""); endQuote >= 0 { - filePath = rest[:endQuote] - } - } - } - } - if processedIDs != nil { - processedIDs[currentToolUse.ToolUseID] = true - } - // Return a special marker tool use that indicates truncation - toolUse := KiroToolUse{ - ToolUseID: currentToolUse.ToolUseID, - Name: "__truncated_write__", // Special marker name - Input: map[string]interface{}{ - "error": "Write tool content was truncated by upstream API. The file content is too large.", - "file_path": filePath, - }, - } - toolUses = append(toolUses, toolUse) - return toolUses, nil - } } - // Additional check: Write tool parsed successfully but missing content field - if currentToolUse.Name == "Write" { - if _, hasContent := finalInput["content"]; !hasContent { - if filePath, hasPath := finalInput["file_path"]; hasPath { - log.Warnf("kiro: Write tool input missing 'content' field, likely truncated by upstream API") - if processedIDs != nil { - processedIDs[currentToolUse.ToolUseID] = true - } - // Return a special marker tool use that indicates truncation - toolUse := KiroToolUse{ - ToolUseID: currentToolUse.ToolUseID, - Name: "__truncated_write__", // Special marker name - Input: map[string]interface{}{ - "error": "Write tool content field was missing. The file content is too large.", - "file_path": filePath, - }, - } - toolUses = append(toolUses, toolUse) - return toolUses, nil - } + // Detect truncation for all tools + truncInfo := DetectTruncation(currentToolUse.Name, currentToolUse.ToolUseID, fullInput, finalInput) + if truncInfo.IsTruncated { + log.Warnf("kiro: TRUNCATION DETECTED for tool %s (ID: %s): type=%s, raw_size=%d bytes", + currentToolUse.Name, currentToolUse.ToolUseID, truncInfo.TruncationType, len(fullInput)) + log.Warnf("kiro: truncation details: %s", truncInfo.ErrorMessage) + if len(truncInfo.ParsedFields) > 0 { + log.Infof("kiro: partial fields received: %v", truncInfo.ParsedFields) } + // Store truncation info in the state for upstream handling + currentToolUse.TruncationInfo = &truncInfo + } else { + log.Infof("kiro: tool use %s input length: %d bytes (no truncation)", currentToolUse.Name, len(fullInput)) } + // Create the tool use with truncation info if applicable toolUse := KiroToolUse{ - ToolUseID: currentToolUse.ToolUseID, - Name: currentToolUse.Name, - Input: finalInput, + ToolUseID: currentToolUse.ToolUseID, + Name: currentToolUse.Name, + Input: finalInput, + IsTruncated: truncInfo.IsTruncated, + TruncationInfo: nil, // Will be set below if truncated + } + if truncInfo.IsTruncated { + toolUse.TruncationInfo = &truncInfo } toolUses = append(toolUses, toolUse) @@ -576,7 +507,7 @@ func ProcessToolUseEvent(event map[string]interface{}, currentToolUse *ToolUseSt processedIDs[currentToolUse.ToolUseID] = true } - log.Infof("kiro: completed tool use: %s (ID: %s)", currentToolUse.Name, currentToolUse.ToolUseID) + log.Infof("kiro: completed tool use: %s (ID: %s, truncated: %v)", currentToolUse.Name, currentToolUse.ToolUseID, truncInfo.IsTruncated) return toolUses, nil } @@ -610,4 +541,3 @@ func DeduplicateToolUses(toolUses []KiroToolUse) []KiroToolUse { return unique } - diff --git a/internal/translator/kiro/claude/truncation_detector.go b/internal/translator/kiro/claude/truncation_detector.go new file mode 100644 index 00000000..b05ec11a --- /dev/null +++ b/internal/translator/kiro/claude/truncation_detector.go @@ -0,0 +1,517 @@ +// Package claude provides truncation detection for Kiro tool call responses. +// When Kiro API reaches its output token limit, tool call JSON may be truncated, +// resulting in incomplete or unparseable input parameters. +package claude + +import ( + "encoding/json" + "strings" + + log "github.com/sirupsen/logrus" +) + +// TruncationInfo contains details about detected truncation in a tool use event. +type TruncationInfo struct { + IsTruncated bool // Whether truncation was detected + TruncationType string // Type of truncation detected + ToolName string // Name of the truncated tool + ToolUseID string // ID of the truncated tool use + RawInput string // The raw (possibly truncated) input string + ParsedFields map[string]string // Fields that were successfully parsed before truncation + ErrorMessage string // Human-readable error message +} + +// TruncationType constants for different truncation scenarios +const ( + TruncationTypeNone = "" // No truncation detected + TruncationTypeEmptyInput = "empty_input" // No input data received at all + TruncationTypeInvalidJSON = "invalid_json" // JSON is syntactically invalid (truncated mid-value) + TruncationTypeMissingFields = "missing_fields" // JSON parsed but critical fields are missing + TruncationTypeIncompleteString = "incomplete_string" // String value was cut off mid-content +) + +// KnownWriteTools lists tool names that typically write content and have a "content" field. +// These tools are checked for content field truncation specifically. +var KnownWriteTools = map[string]bool{ + "Write": true, + "write_to_file": true, + "fsWrite": true, + "create_file": true, + "edit_file": true, + "apply_diff": true, + "str_replace_editor": true, + "insert": true, +} + +// KnownCommandTools lists tool names that execute commands. +var KnownCommandTools = map[string]bool{ + "Bash": true, + "execute": true, + "run_command": true, + "shell": true, + "terminal": true, + "execute_python": true, +} + +// RequiredFieldsByTool maps tool names to their required fields. +// If any of these fields are missing, the tool input is considered truncated. +var RequiredFieldsByTool = map[string][]string{ + "Write": {"file_path", "content"}, + "write_to_file": {"path", "content"}, + "fsWrite": {"path", "content"}, + "create_file": {"path", "content"}, + "edit_file": {"path"}, + "apply_diff": {"path", "diff"}, + "str_replace_editor": {"path", "old_str", "new_str"}, + "Bash": {"command"}, + "execute": {"command"}, + "run_command": {"command"}, +} + +// DetectTruncation checks if the tool use input appears to be truncated. +// It returns detailed information about the truncation status and type. +func DetectTruncation(toolName, toolUseID, rawInput string, parsedInput map[string]interface{}) TruncationInfo { + info := TruncationInfo{ + ToolName: toolName, + ToolUseID: toolUseID, + RawInput: rawInput, + ParsedFields: make(map[string]string), + } + + // Scenario 1: Empty input buffer - no data received at all + if strings.TrimSpace(rawInput) == "" { + info.IsTruncated = true + info.TruncationType = TruncationTypeEmptyInput + info.ErrorMessage = "Tool input was completely empty - API response may have been truncated before tool parameters were transmitted" + log.Warnf("kiro: truncation detected [%s] for tool %s (ID: %s): empty input buffer", + info.TruncationType, toolName, toolUseID) + return info + } + + // Scenario 2: JSON parse failure - syntactically invalid JSON + if parsedInput == nil || len(parsedInput) == 0 { + // Check if the raw input looks like truncated JSON + if looksLikeTruncatedJSON(rawInput) { + info.IsTruncated = true + info.TruncationType = TruncationTypeInvalidJSON + info.ParsedFields = extractPartialFields(rawInput) + info.ErrorMessage = buildTruncationErrorMessage(toolName, info.TruncationType, info.ParsedFields, rawInput) + log.Warnf("kiro: truncation detected [%s] for tool %s (ID: %s): JSON parse failed, raw length=%d bytes", + info.TruncationType, toolName, toolUseID, len(rawInput)) + return info + } + } + + // Scenario 3: JSON parsed but critical fields are missing + if parsedInput != nil { + requiredFields, hasRequirements := RequiredFieldsByTool[toolName] + if hasRequirements { + missingFields := findMissingRequiredFields(parsedInput, requiredFields) + if len(missingFields) > 0 { + info.IsTruncated = true + info.TruncationType = TruncationTypeMissingFields + info.ParsedFields = extractParsedFieldNames(parsedInput) + info.ErrorMessage = buildMissingFieldsErrorMessage(toolName, missingFields, info.ParsedFields) + log.Warnf("kiro: truncation detected [%s] for tool %s (ID: %s): missing required fields: %v", + info.TruncationType, toolName, toolUseID, missingFields) + return info + } + } + + // Scenario 4: Check for incomplete string values (very short content for write tools) + if isWriteTool(toolName) { + if contentTruncation := detectContentTruncation(parsedInput, rawInput); contentTruncation != "" { + info.IsTruncated = true + info.TruncationType = TruncationTypeIncompleteString + info.ParsedFields = extractParsedFieldNames(parsedInput) + info.ErrorMessage = contentTruncation + log.Warnf("kiro: truncation detected [%s] for tool %s (ID: %s): %s", + info.TruncationType, toolName, toolUseID, contentTruncation) + return info + } + } + } + + // No truncation detected + info.IsTruncated = false + info.TruncationType = TruncationTypeNone + return info +} + +// looksLikeTruncatedJSON checks if the raw string appears to be truncated JSON. +func looksLikeTruncatedJSON(raw string) bool { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return false + } + + // Must start with { to be considered JSON + if !strings.HasPrefix(trimmed, "{") { + return false + } + + // Count brackets to detect imbalance + openBraces := strings.Count(trimmed, "{") + closeBraces := strings.Count(trimmed, "}") + openBrackets := strings.Count(trimmed, "[") + closeBrackets := strings.Count(trimmed, "]") + + // Bracket imbalance suggests truncation + if openBraces > closeBraces || openBrackets > closeBrackets { + return true + } + + // Check for obvious truncation patterns + // - Ends with a quote but no closing brace + // - Ends with a colon (mid key-value) + // - Ends with a comma (mid object/array) + lastChar := trimmed[len(trimmed)-1] + if lastChar != '}' && lastChar != ']' { + // Check if it's not a complete simple value + if lastChar == '"' || lastChar == ':' || lastChar == ',' { + return true + } + } + + // Check for unclosed strings (odd number of unescaped quotes) + inString := false + escaped := false + for i := 0; i < len(trimmed); i++ { + c := trimmed[i] + if escaped { + escaped = false + continue + } + if c == '\\' { + escaped = true + continue + } + if c == '"' { + inString = !inString + } + } + if inString { + return true // Unclosed string + } + + return false +} + +// extractPartialFields attempts to extract any field names from malformed JSON. +// This helps provide context about what was received before truncation. +func extractPartialFields(raw string) map[string]string { + fields := make(map[string]string) + + // Simple pattern matching for "key": "value" or "key": value patterns + // This works even with truncated JSON + trimmed := strings.TrimSpace(raw) + if !strings.HasPrefix(trimmed, "{") { + return fields + } + + // Remove opening brace + content := strings.TrimPrefix(trimmed, "{") + + // Split by comma (rough parsing) + parts := strings.Split(content, ",") + for _, part := range parts { + part = strings.TrimSpace(part) + if colonIdx := strings.Index(part, ":"); colonIdx > 0 { + key := strings.TrimSpace(part[:colonIdx]) + key = strings.Trim(key, `"`) + value := strings.TrimSpace(part[colonIdx+1:]) + + // Truncate long values for display + if len(value) > 50 { + value = value[:50] + "..." + } + fields[key] = value + } + } + + return fields +} + +// extractParsedFieldNames returns the field names from a successfully parsed map. +func extractParsedFieldNames(parsed map[string]interface{}) map[string]string { + fields := make(map[string]string) + for key, val := range parsed { + switch v := val.(type) { + case string: + if len(v) > 50 { + fields[key] = v[:50] + "..." + } else { + fields[key] = v + } + case nil: + fields[key] = "" + default: + // For complex types, just indicate presence + fields[key] = "" + } + } + return fields +} + +// findMissingRequiredFields checks which required fields are missing from the parsed input. +func findMissingRequiredFields(parsed map[string]interface{}, required []string) []string { + var missing []string + for _, field := range required { + if _, exists := parsed[field]; !exists { + missing = append(missing, field) + } + } + return missing +} + +// isWriteTool checks if the tool is a known write/file operation tool. +func isWriteTool(toolName string) bool { + return KnownWriteTools[toolName] +} + +// detectContentTruncation checks if the content field appears truncated for write tools. +func detectContentTruncation(parsed map[string]interface{}, rawInput string) string { + // Check for content field + content, hasContent := parsed["content"] + if !hasContent { + return "" + } + + contentStr, isString := content.(string) + if !isString { + return "" + } + + // Heuristic: if raw input is very large but content is suspiciously short, + // it might indicate truncation during JSON repair + if len(rawInput) > 1000 && len(contentStr) < 100 { + return "content field appears suspiciously short compared to raw input size" + } + + // Check for code blocks that appear to be cut off + if strings.Contains(contentStr, "```") { + openFences := strings.Count(contentStr, "```") + if openFences%2 != 0 { + return "content contains unclosed code fence (```) suggesting truncation" + } + } + + return "" +} + +// buildTruncationErrorMessage creates a human-readable error message for truncation. +func buildTruncationErrorMessage(toolName, truncationType string, parsedFields map[string]string, rawInput string) string { + var sb strings.Builder + sb.WriteString("Tool input was truncated by the API. ") + + switch truncationType { + case TruncationTypeEmptyInput: + sb.WriteString("No input data was received.") + case TruncationTypeInvalidJSON: + sb.WriteString("JSON was cut off mid-transmission. ") + if len(parsedFields) > 0 { + sb.WriteString("Partial fields received: ") + first := true + for k := range parsedFields { + if !first { + sb.WriteString(", ") + } + sb.WriteString(k) + first = false + } + } + case TruncationTypeMissingFields: + sb.WriteString("Required fields are missing from the input.") + case TruncationTypeIncompleteString: + sb.WriteString("Content appears to be shortened or incomplete.") + } + + sb.WriteString(" Received ") + sb.WriteString(string(rune(len(rawInput)))) + sb.WriteString(" bytes. Please retry with smaller content chunks.") + + return sb.String() +} + +// buildMissingFieldsErrorMessage creates an error message for missing required fields. +func buildMissingFieldsErrorMessage(toolName string, missingFields []string, parsedFields map[string]string) string { + var sb strings.Builder + sb.WriteString("Tool '") + sb.WriteString(toolName) + sb.WriteString("' is missing required fields: ") + sb.WriteString(strings.Join(missingFields, ", ")) + sb.WriteString(". Fields received: ") + + first := true + for k := range parsedFields { + if !first { + sb.WriteString(", ") + } + sb.WriteString(k) + first = false + } + + sb.WriteString(". This usually indicates the API response was truncated.") + return sb.String() +} + +// IsTruncated is a convenience function to check if a tool use appears truncated. +func IsTruncated(toolName, rawInput string, parsedInput map[string]interface{}) bool { + info := DetectTruncation(toolName, "", rawInput, parsedInput) + return info.IsTruncated +} + +// GetTruncationSummary returns a short summary string for logging. +func GetTruncationSummary(info TruncationInfo) string { + if !info.IsTruncated { + return "" + } + + result, _ := json.Marshal(map[string]interface{}{ + "tool": info.ToolName, + "type": info.TruncationType, + "parsed_fields": info.ParsedFields, + "raw_input_size": len(info.RawInput), + }) + return string(result) +} + +// SoftFailureMessage contains the message structure for a truncation soft failure. +// This is returned to Claude as a tool_result to guide retry behavior. +type SoftFailureMessage struct { + Status string // "incomplete" - not an error, just incomplete + Reason string // Why the tool call was incomplete + Guidance []string // Step-by-step retry instructions + Context string // Any context about what was received + MaxLineHint int // Suggested maximum lines per chunk +} + +// BuildSoftFailureMessage creates a structured message for Claude when truncation is detected. +// This follows the "soft failure" pattern: +// - For Claude: Clear explanation of what happened and how to fix +// - For User: Hidden or minimized (appears as normal processing) +// +// Key principle: "Conclusion First" +// 1. First state what happened (incomplete) +// 2. Then explain how to fix (chunked approach) +// 3. Provide specific guidance (line limits) +func BuildSoftFailureMessage(info TruncationInfo) SoftFailureMessage { + msg := SoftFailureMessage{ + Status: "incomplete", + MaxLineHint: 300, // Conservative default + } + + // Build reason based on truncation type + switch info.TruncationType { + case TruncationTypeEmptyInput: + msg.Reason = "Your tool call was too large and the input was completely lost during transmission." + msg.MaxLineHint = 200 + case TruncationTypeInvalidJSON: + msg.Reason = "Your tool call was truncated mid-transmission, resulting in incomplete JSON." + msg.MaxLineHint = 250 + case TruncationTypeMissingFields: + msg.Reason = "Your tool call was partially received but critical fields were cut off." + msg.MaxLineHint = 300 + case TruncationTypeIncompleteString: + msg.Reason = "Your tool call content was truncated - the full content did not arrive." + msg.MaxLineHint = 350 + default: + msg.Reason = "Your tool call was truncated by the API due to output size limits." + } + + // Build context from parsed fields + if len(info.ParsedFields) > 0 { + var parts []string + for k, v := range info.ParsedFields { + if len(v) > 30 { + v = v[:30] + "..." + } + parts = append(parts, k+"="+v) + } + msg.Context = "Received partial data: " + strings.Join(parts, ", ") + } + + // Build retry guidance - CRITICAL: Conclusion first approach + msg.Guidance = []string{ + "CONCLUSION: Split your output into smaller chunks and retry.", + "", + "REQUIRED APPROACH:", + "1. For file writes: Write in chunks of ~" + formatInt(msg.MaxLineHint) + " lines maximum", + "2. For new files: First create with initial chunk, then append remaining sections", + "3. For edits: Make surgical, targeted changes - avoid rewriting entire files", + "", + "EXAMPLE (writing a 600-line file):", + " - Step 1: Write lines 1-300 (create file)", + " - Step 2: Append lines 301-600 (extend file)", + "", + "DO NOT attempt to write the full content again in a single call.", + "The API has a hard output limit that cannot be bypassed.", + } + + return msg +} + +// formatInt converts an integer to string (helper to avoid strconv import) +func formatInt(n int) string { + if n == 0 { + return "0" + } + result := "" + for n > 0 { + result = string(rune('0'+n%10)) + result + n /= 10 + } + return result +} + +// BuildSoftFailureToolResult creates a tool_result content for Claude. +// This is what Claude will see when a tool call is truncated. +// Returns a string that should be used as the tool_result content. +func BuildSoftFailureToolResult(info TruncationInfo) string { + msg := BuildSoftFailureMessage(info) + + var sb strings.Builder + sb.WriteString("TOOL_CALL_INCOMPLETE\n") + sb.WriteString("status: ") + sb.WriteString(msg.Status) + sb.WriteString("\n") + sb.WriteString("reason: ") + sb.WriteString(msg.Reason) + sb.WriteString("\n") + + if msg.Context != "" { + sb.WriteString("context: ") + sb.WriteString(msg.Context) + sb.WriteString("\n") + } + + sb.WriteString("\n") + for _, line := range msg.Guidance { + if line != "" { + sb.WriteString(line) + sb.WriteString("\n") + } + } + + return sb.String() +} + +// CreateTruncationToolResult creates a KiroToolUse that represents a soft failure. +// Instead of returning the truncated tool_use, we return a tool with a special +// error result that guides Claude to retry with smaller chunks. +// +// This is the key mechanism for "soft failure": +// - stop_reason remains "tool_use" so Claude continues +// - The tool_result content explains the issue and how to fix it +// - Claude will read this and adjust its approach +func CreateTruncationToolResult(info TruncationInfo) KiroToolUse { + // We create a pseudo tool_use that represents the failed attempt + // The executor will convert this to a tool_result with the guidance message + return KiroToolUse{ + ToolUseID: info.ToolUseID, + Name: info.ToolName, + Input: nil, // No input since it was truncated + IsTruncated: true, + TruncationInfo: &info, + } +}