fix(kiro): skip _partial field (may contain hallucinated paths), add pwd hint for retry

This commit is contained in:
Skyuno
2026-02-02 05:08:44 +08:00
parent a12e22c66f
commit ba168ec003
5 changed files with 679 additions and 26 deletions

View File

@@ -2443,6 +2443,7 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out
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 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
@@ -3227,6 +3228,62 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out
// Emit completed tool uses
for _, tu := range completedToolUses {
// 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 {
blockStop := kiroclaude.BuildClaudeContentBlockStopEvent(contentBlockIndex)
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStop, &translatorParam)
for _, chunk := range sseData {
if chunk != "" {
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
}
}
isTextBlockOpen = false
}
contentBlockIndex++
// 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 != "" {
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
}
}
// 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.",
}
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 != "" {
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
}
}
// 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 {
if chunk != "" {
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
}
}
hasToolUses = true // Keep this so stop_reason = tool_use
continue
}
hasToolUses = true
// Close text block if open
@@ -3525,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"

View File

@@ -17,7 +17,6 @@ import (
"github.com/tidwall/gjson"
)
// Kiro API request structs - field order determines JSON key order
// KiroPayload is the top-level request structure for Kiro API
@@ -34,7 +33,6 @@ type KiroInferenceConfig struct {
TopP float64 `json:"topP,omitempty"`
}
// KiroConversationState holds the conversation context
type KiroConversationState struct {
ChatTriggerType string `json:"chatTriggerType"` // Required: "MANUAL" - must be first field
@@ -117,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.
@@ -225,10 +225,10 @@ func BuildKiroPayload(claudeBody []byte, modelID, profileArn, origin string, isA
// Kiro API supports official thinking/reasoning mode via <thinking_mode> tag.
// When set to "enabled", Kiro returns reasoning content as official reasoningContentEvent
// rather than inline <thinking> 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 := `<thinking_mode>enabled</thinking_mode>
<max_thinking_length>200000</max_thinking_length>`
<max_thinking_length>16000</max_thinking_length>`
if systemPrompt != "" {
systemPrompt = thinkingHint + "\n\n" + systemPrompt
} else {
@@ -378,7 +378,6 @@ func hasThinkingTagInBody(body []byte) bool {
return strings.Contains(bodyStr, "<thinking_mode>") || strings.Contains(bodyStr, "<max_thinking_length>")
}
// IsThinkingEnabledFromHeader checks if thinking mode is enabled via Anthropic-Beta header.
// Claude CLI uses "Anthropic-Beta: interleaved-thinking-2025-05-14" to enable thinking.
func IsThinkingEnabledFromHeader(headers http.Header) bool {
@@ -743,7 +742,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()})

View File

@@ -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
}
}

View File

@@ -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
@@ -474,10 +475,31 @@ func ProcessToolUseEvent(event map[string]interface{}, currentToolUse *ToolUseSt
finalInput = make(map[string]interface{})
}
// 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)
@@ -485,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
}
@@ -519,4 +541,3 @@ func DeduplicateToolUses(toolUses []KiroToolUse) []KiroToolUse {
return unique
}

View File

@@ -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] = "<null>"
default:
// For complex types, just indicate presence
fields[key] = "<present>"
}
}
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,
}
}