File size: 7,207 Bytes
064bfd6 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 | import type {
Base64ImageSource,
ContentBlockParam,
ToolResultBlockParam,
} from '@anthropic-ai/sdk/resources/index.mjs'
import { readFile, stat } from 'fs/promises'
import { getOriginalCwd } from 'src/bootstrap/state.js'
import { logEvent } from 'src/services/analytics/index.js'
import type { ToolPermissionContext } from 'src/Tool.js'
import { getCwd } from 'src/utils/cwd.js'
import { pathInAllowedWorkingPath } from 'src/utils/permissions/filesystem.js'
import { setCwd } from 'src/utils/Shell.js'
import { shouldMaintainProjectWorkingDir } from '../../utils/envUtils.js'
import { maybeResizeAndDownsampleImageBuffer } from '../../utils/imageResizer.js'
import { getMaxOutputLength } from '../../utils/shell/outputLimits.js'
import { countCharInString, plural } from '../../utils/stringUtils.js'
/**
* Strips leading and trailing lines that contain only whitespace/newlines.
* Unlike trim(), this preserves whitespace within content lines and only removes
* completely empty lines from the beginning and end.
*/
export function stripEmptyLines(content: string): string {
const lines = content.split('\n')
// Find the first non-empty line
let startIndex = 0
while (startIndex < lines.length && lines[startIndex]?.trim() === '') {
startIndex++
}
// Find the last non-empty line
let endIndex = lines.length - 1
while (endIndex >= 0 && lines[endIndex]?.trim() === '') {
endIndex--
}
// If all lines are empty, return empty string
if (startIndex > endIndex) {
return ''
}
// Return the slice with non-empty lines
return lines.slice(startIndex, endIndex + 1).join('\n')
}
/**
* Check if content is a base64 encoded image data URL
*/
export function isImageOutput(content: string): boolean {
return /^data:image\/[a-z0-9.+_-]+;base64,/i.test(content)
}
const DATA_URI_RE = /^data:([^;]+);base64,(.+)$/
/**
* Parse a data-URI string into its media type and base64 payload.
* Input is trimmed before matching.
*/
export function parseDataUri(
s: string,
): { mediaType: string; data: string } | null {
const match = s.trim().match(DATA_URI_RE)
if (!match || !match[1] || !match[2]) return null
return { mediaType: match[1], data: match[2] }
}
/**
* Build an image tool_result block from shell stdout containing a data URI.
* Returns null if parse fails so callers can fall through to text handling.
*/
export function buildImageToolResult(
stdout: string,
toolUseID: string,
): ToolResultBlockParam | null {
const parsed = parseDataUri(stdout)
if (!parsed) return null
return {
tool_use_id: toolUseID,
type: 'tool_result',
content: [
{
type: 'image',
source: {
type: 'base64',
media_type: parsed.mediaType as Base64ImageSource['media_type'],
data: parsed.data,
},
},
],
}
}
// Cap file reads to 20 MB — any image data URI larger than this is
// well beyond what the API accepts (5 MB base64) and would OOM if read
// into memory.
const MAX_IMAGE_FILE_SIZE = 20 * 1024 * 1024
/**
* Resize image output from a shell tool. stdout is capped at
* getMaxOutputLength() when read back from the shell output file — if the
* full output spilled to disk, re-read it from there, since truncated base64
* would decode to a corrupt image that either throws here or gets rejected by
* the API. Caps dimensions too: compressImageBuffer only checks byte size, so
* a small-but-high-DPI PNG (e.g. matplotlib at dpi=300) sails through at full
* resolution and poisons many-image requests (CC-304).
*
* Returns the re-encoded data URI on success, or null if the source didn't
* parse as a data URI (caller decides whether to flip isImage).
*/
export async function resizeShellImageOutput(
stdout: string,
outputFilePath: string | undefined,
outputFileSize: number | undefined,
): Promise<string | null> {
let source = stdout
if (outputFilePath) {
const size = outputFileSize ?? (await stat(outputFilePath)).size
if (size > MAX_IMAGE_FILE_SIZE) return null
source = await readFile(outputFilePath, 'utf8')
}
const parsed = parseDataUri(source)
if (!parsed) return null
const buf = Buffer.from(parsed.data, 'base64')
const ext = parsed.mediaType.split('/')[1] || 'png'
const resized = await maybeResizeAndDownsampleImageBuffer(
buf,
buf.length,
ext,
)
return `data:image/${resized.mediaType};base64,${resized.buffer.toString('base64')}`
}
export function formatOutput(content: string): {
totalLines: number
truncatedContent: string
isImage?: boolean
} {
const isImage = isImageOutput(content)
if (isImage) {
return {
totalLines: 1,
truncatedContent: content,
isImage,
}
}
const maxOutputLength = getMaxOutputLength()
if (content.length <= maxOutputLength) {
return {
totalLines: countCharInString(content, '\n') + 1,
truncatedContent: content,
isImage,
}
}
const truncatedPart = content.slice(0, maxOutputLength)
const remainingLines = countCharInString(content, '\n', maxOutputLength) + 1
const truncated = `${truncatedPart}\n\n... [${remainingLines} lines truncated] ...`
return {
totalLines: countCharInString(content, '\n') + 1,
truncatedContent: truncated,
isImage,
}
}
export const stdErrAppendShellResetMessage = (stderr: string): string =>
`${stderr.trim()}\nShell cwd was reset to ${getOriginalCwd()}`
export function resetCwdIfOutsideProject(
toolPermissionContext: ToolPermissionContext,
): boolean {
const cwd = getCwd()
const originalCwd = getOriginalCwd()
const shouldMaintain = shouldMaintainProjectWorkingDir()
if (
shouldMaintain ||
// Fast path: originalCwd is unconditionally in allWorkingDirectories
// (filesystem.ts), so when cwd hasn't moved, pathInAllowedWorkingPath is
// trivially true — skip its syscalls for the no-cd common case.
(cwd !== originalCwd &&
!pathInAllowedWorkingPath(cwd, toolPermissionContext))
) {
// Reset to original directory if maintaining project dir OR outside allowed working directory
setCwd(originalCwd)
if (!shouldMaintain) {
logEvent('tengu_bash_tool_reset_to_original_dir', {})
return true
}
}
return false
}
/**
* Creates a human-readable summary of structured content blocks.
* Used to display MCP results with images and text in the UI.
*/
export function createContentSummary(content: ContentBlockParam[]): string {
const parts: string[] = []
let textCount = 0
let imageCount = 0
for (const block of content) {
if (block.type === 'image') {
imageCount++
} else if (block.type === 'text' && 'text' in block) {
textCount++
// Include first 200 chars of text blocks for context
const preview = block.text.slice(0, 200)
parts.push(preview + (block.text.length > 200 ? '...' : ''))
}
}
const summary: string[] = []
if (imageCount > 0) {
summary.push(`[${imageCount} ${plural(imageCount, 'image')}]`)
}
if (textCount > 0) {
summary.push(`[${textCount} text ${plural(textCount, 'block')}]`)
}
return `MCP Result: ${summary.join(', ')}${parts.length > 0 ? '\n\n' + parts.join('\n\n') : ''}`
}
|