* Handle large tool results by persisting to disk instead of truncating. * Returns the original block if no persistence needed, or a modified block * with the content replaced by a reference to the persisted file.
( toolResultBlock: ToolResultBlockParam, toolName: string, persistenceThreshold?: number, )
| 270 | * with the content replaced by a reference to the persisted file. |
| 271 | */ |
| 272 | async function maybePersistLargeToolResult( |
| 273 | toolResultBlock: ToolResultBlockParam, |
| 274 | toolName: string, |
| 275 | persistenceThreshold?: number, |
| 276 | ): Promise<ToolResultBlockParam> { |
| 277 | // Check size first before doing any async work - most tool results are small |
| 278 | const content = toolResultBlock.content |
| 279 | |
| 280 | // inc-4586: Empty tool_result content at the prompt tail causes some models |
| 281 | // (notably capybara) to emit the \n\nHuman: stop sequence and end their turn |
| 282 | // with zero output. The server renderer inserts no \n\nAssistant: marker after |
| 283 | // tool results, so a bare </function_results>\n\n pattern-matches to a turn |
| 284 | // boundary. Several tools can legitimately produce empty output (silent-success |
| 285 | // shell commands, MCP servers returning content:[], REPL statements, etc.). |
| 286 | // Inject a short marker so the model always has something to react to. |
| 287 | if (isToolResultContentEmpty(content)) { |
| 288 | logEvent('tengu_tool_empty_result', { |
| 289 | toolName: sanitizeToolNameForAnalytics(toolName), |
| 290 | }) |
| 291 | return { |
| 292 | ...toolResultBlock, |
| 293 | content: `(${toolName} completed with no output)`, |
| 294 | } |
| 295 | } |
| 296 | // Narrow after the emptiness guard — content is non-nullish past this point. |
| 297 | if (!content) { |
| 298 | return toolResultBlock |
| 299 | } |
| 300 | |
| 301 | // Skip persistence for image content blocks - they need to be sent as-is to Claude |
| 302 | if (hasImageBlock(content)) { |
| 303 | return toolResultBlock |
| 304 | } |
| 305 | |
| 306 | const size = contentSize(content) |
| 307 | |
| 308 | // Use tool-specific threshold if provided, otherwise fall back to global limit |
| 309 | const threshold = persistenceThreshold ?? MAX_TOOL_RESULT_BYTES |
| 310 | if (size <= threshold) { |
| 311 | return toolResultBlock |
| 312 | } |
| 313 | |
| 314 | // Persist the entire content as a unit |
| 315 | const result = await persistToolResult(content, toolResultBlock.tool_use_id) |
| 316 | if (isPersistError(result)) { |
| 317 | // If persistence failed, return the original block unchanged |
| 318 | return toolResultBlock |
| 319 | } |
| 320 | |
| 321 | const message = buildLargeToolResultMessage(result) |
| 322 | |
| 323 | // Log analytics |
| 324 | logEvent('tengu_tool_result_persisted', { |
| 325 | toolName: sanitizeToolNameForAnalytics(toolName), |
| 326 | originalSizeBytes: result.originalSize, |
| 327 | persistedSizeBytes: message.length, |
| 328 | estimatedOriginalTokens: Math.ceil(result.originalSize / BYTES_PER_TOKEN), |
| 329 | estimatedPersistedTokens: Math.ceil(message.length / BYTES_PER_TOKEN), |
no test coverage detected