fix: ensure lossless artifact storage and strict mode compatibility

- OutputInterceptor: Buffer ALL chunks before spilling to disk to preserve
  full content losslessly. Previously, the rolling tail buffer could drop
  middle content before the spill decision was made.

- read_command_output schema: Include all properties in 'required' array
  for OpenAI strict mode compliance. With strict: true, all properties
  must be listed in required (optional ones use null union types).
This commit is contained in:
Hannes Rudolph 2026-01-27 11:56:06 -07:00
parent cf7472945d
commit 2dcd3a187f
2 changed files with 22 additions and 17 deletions

View file

@ -70,7 +70,9 @@ export default {
description: LIMIT_DESCRIPTION,
},
},
required: ["artifact_id"],
// With strict: true, ALL properties must be listed in required.
// Optional params use union type with null (e.g., ["string", "null"]).
required: ["artifact_id", "search", "offset", "limit"],
additionalProperties: false,
},
},

View file

@ -67,6 +67,13 @@ export class OutputInterceptor {
/** Number of bytes omitted from the middle */
private omittedBytes: number = 0
/**
* Pending chunks accumulated before spilling to disk.
* These contain ALL content (lossless) until we decide to spill.
* Once spilled, this array is cleared and subsequent writes go directly to disk.
*/
private pendingChunks: string[] = []
private writeStream: fs.WriteStream | null = null
private artifactPath: string
private totalBytes: number = 0
@ -115,8 +122,11 @@ export class OutputInterceptor {
// Handle disk spilling for full output preservation
if (!this.spilledToDisk) {
// Accumulate ALL chunks for lossless disk storage
this.pendingChunks.push(chunk)
if (this.totalBytes > this.previewBytes) {
this.spillToDisk(chunk)
this.spillToDisk()
}
} else {
// Already spilling - write directly to disk
@ -254,7 +264,7 @@ export class OutputInterceptor {
*
* @private
*/
private spillToDisk(currentChunk: string): void {
private spillToDisk(): void {
// Ensure directory exists
const dir = path.dirname(this.artifactPath)
if (!fs.existsSync(dir)) {
@ -262,22 +272,15 @@ export class OutputInterceptor {
}
this.writeStream = fs.createWriteStream(this.artifactPath)
// Write the full head buffer + any tail content accumulated so far
// Note: We need to reconstruct full output seen so far
// The full content before this chunk is: totalBytes - currentChunkBytes
// But we've already been tracking head/tail, so we write head + omitted + tail + current
// Actually, we need to write the complete original content
// Since we're spilling on the chunk that pushes us over, we need to write everything
// that came before plus this chunk
// Reconstruct: we have headBuffer (complete head) + whatever was in tail before trimming
// For simplicity, write head + tail + current chunk (the tail already has some data)
this.writeStream.write(this.headBuffer)
if (this.tailBuffer.length > 0) {
this.writeStream.write(this.tailBuffer)
// Write ALL pending chunks to disk for lossless storage.
// This ensures no content is lost, even if the preview buffers have dropped middle content.
for (const chunk of this.pendingChunks) {
this.writeStream.write(chunk)
}
// Don't write currentChunk here - it was already processed into head/tail buffers
// and will be written via the streaming path
// Clear pending chunks to free memory - subsequent writes go directly to disk
this.pendingChunks = []
this.spilledToDisk = true
}