| 133 | content := chmctx.Truncate(raw) |
| 134 | // Truncate keeps head+tail and drops the middle, which is exactly where a |
| 135 | // failing assertion or a stack trace sits. Spill the whole result to a file |
| 136 | // and name it, so recovering the dropped middle is one grep instead of |
| 137 | // re-running the command that produced it with narrower flags. |
| 138 | if len(content) < len(raw) { |
| 139 | if path := spillOutput(raw); path != "" { |
| 140 | content += fmt.Sprintf("\n(full %d-byte output saved to %s - grep or read that file instead of re-running the command)", len(raw), path) |
| 141 | } |
| 142 | } |
| 143 | return chmctx.Message{ |
| 144 | Role: chmctx.RoleTool, |
| 145 | Content: content, |
| 146 | ToolCallID: call.ID, |
| 147 | ToolName: call.Name, |
| 148 | } |
| 149 | } |
| 150 | |
| 151 | // spillOutput writes an over-budget tool result to a temp file and returns its |
| 152 | // path. Best effort: on failure the model keeps the truncated view it would |
| 153 | // have had anyway. Cleanup belongs to the OS tmp reaper - each file is already |
| 154 | // bounded by bash's own capture ceiling. |
| 155 | func spillOutput(s string) string { |
| 156 | f, err := os.CreateTemp("", "codehamr-out-*.txt") |
| 157 | if err != nil { |
| 158 | return "" |
| 159 | } |
| 160 | defer f.Close() |
| 161 | if _, err := f.WriteString(s); err != nil { |
| 162 | os.Remove(f.Name()) |
| 163 | return "" |
| 164 | } |
| 165 | return f.Name() |
| 166 | } |
| 167 | |
| 168 | // intArg coerces a numeric tool argument. Weak local tool-call parsers emit |
| 169 | // integers as JSON numbers or as bare strings; both must mean the same thing, |
| 170 | // or read_file's continuation note ("continue with offset=451") silently |
| 171 | // re-reads the head forever and the model loops on a file it can never finish. |
| 172 | func intArg(args map[string]any, key string) int { |
| 173 | switch v := args[key].(type) { |
| 174 | case float64: |
| 175 | return int(v) |
| 176 | case string: |
| 177 | v = strings.TrimSpace(v) |
| 178 | if n, err := strconv.Atoi(v); err == nil { |
| 179 | return n |
| 180 | } |
| 181 | // "451.0": the same integer wearing a float's clothes. Dropping it to 0 |
| 182 | // hands read_file the head window again, with the same continuation note |
| 183 | // - a success every time, so no failure streak can ever form. |
| 184 | if f, err := strconv.ParseFloat(v, 64); err == nil { |
| 185 | return int(f) |
| 186 | } |
| 187 | } |
| 188 | return 0 |
| 189 | } |
| 190 | |
| 191 | // boolArg coerces a boolean tool argument, for the same reason as intArg. A |
| 192 | // string "true" silently read as false would make write_file OVERWRITE where |