Skip to content

Commit 48eb552

Browse files
committed
fix(tasks): read output previews by range
Read only the requested task-output tail so polling stays responsive when logs are large.
1 parent 5cc7d7f commit 48eb552

5 files changed

Lines changed: 58 additions & 8 deletions

File tree

.changeset/fix-task-preview-utf8.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
"@pymodel/pythinker-code": patch
33
---
44

5-
Keep task output previews valid at UTF-8 byte boundaries.
5+
Keep task output previews responsive for large logs and valid at UTF-8 byte boundaries.

packages/agent-core-v2/src/agent/task/persist.ts

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -142,14 +142,38 @@ export class AgentTaskPersistence {
142142
taskId: string,
143143
maxPreviewBytes: number,
144144
): Promise<AgentTaskStoredOutputSnapshot | undefined> {
145-
const output = await this.readTaskOutputData(taskId);
146-
if (output === undefined) return undefined;
147-
const preview = utf8TailPreview(output.data, maxPreviewBytes);
145+
let root = this.primaryRoot();
146+
let outputSizeBytes = await this.bytes.size(this.taskOutputScope(taskId, root), OUTPUT_LOG_KEY);
147+
if (outputSizeBytes === undefined) {
148+
const fallbackRoot = this.fallbackRoot;
149+
if (fallbackRoot === undefined) return undefined;
150+
root = fallbackRoot;
151+
outputSizeBytes = await this.bytes.size(this.taskOutputScope(taskId, root), OUTPUT_LOG_KEY);
152+
if (outputSizeBytes === undefined) return undefined;
153+
}
154+
155+
const previewLimit = Math.min(outputSizeBytes, Math.max(0, Math.trunc(maxPreviewBytes)));
156+
const data = new Uint8Array(previewLimit);
157+
let offset = 0;
158+
if (previewLimit > 0) {
159+
const start = outputSizeBytes - previewLimit;
160+
for await (const chunk of this.bytes.readStream(
161+
this.taskOutputScope(taskId, root),
162+
OUTPUT_LOG_KEY,
163+
{ start, end: outputSizeBytes - 1 },
164+
)) {
165+
const slice = chunk.subarray(0, previewLimit - offset);
166+
data.set(slice, offset);
167+
offset += slice.byteLength;
168+
if (offset === previewLimit) break;
169+
}
170+
}
171+
const preview = utf8TailPreview(data.subarray(0, offset), previewLimit);
148172
return {
149-
outputPath: this.taskOutputFileAt(taskId, output.root),
150-
outputSizeBytes: output.data.byteLength,
173+
outputPath: this.taskOutputFileAt(taskId, root),
174+
outputSizeBytes,
151175
previewBytes: preview.bytes,
152-
truncated: output.data.byteLength > preview.bytes,
176+
truncated: outputSizeBytes > preview.bytes,
153177
preview: preview.text,
154178
};
155179
}

packages/agent-core-v2/test/agent/task/persist.test.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { mkdir, rm, stat, writeFile } from 'node:fs/promises';
22
import { tmpdir } from 'node:os';
33
import { join } from 'pathe';
44

5-
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
5+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
66

77
import { SyncDescriptor } from '#/_base/di/descriptors';
88
import { DisposableStore } from '#/_base/di/lifecycle';
@@ -59,6 +59,7 @@ beforeEach(async () => {
5959
});
6060

6161
afterEach(async () => {
62+
vi.restoreAllMocks();
6263
disposables.dispose();
6364
await rm(sessionDir, { recursive: true, force: true });
6465
});
@@ -195,6 +196,27 @@ describe('AgentTaskPersistence', () => {
195196
});
196197
});
197198

199+
it('reads only the requested persisted output tail for a snapshot', async () => {
200+
const taskId = 'bash-tail0000';
201+
const output = `${'x'.repeat(1024 * 1024)}tail`;
202+
await persistence.appendTaskOutput(taskId, output);
203+
const read = vi.spyOn(bytes, 'read');
204+
const readStream = vi.spyOn(bytes, 'readStream');
205+
206+
expect(await persistence.readTaskOutputSnapshot(taskId, 4)).toMatchObject({
207+
outputSizeBytes: output.length,
208+
previewBytes: 4,
209+
truncated: true,
210+
preview: 'tail',
211+
});
212+
expect(read).not.toHaveBeenCalled();
213+
expect(readStream).toHaveBeenCalledWith(
214+
`${SESSION_SCOPE}/tasks/${taskId}`,
215+
'output.log',
216+
{ start: output.length - 4, end: output.length - 1 },
217+
);
218+
});
219+
198220
it('readTaskOutputBytes returns empty string when output.log is absent', async () => {
199221
expect(await persistence.readTaskOutputBytes('bash-none0001', 0, 100)).toBe('');
200222
});

packages/agent-core-v2/test/agent/task/taskService.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,7 @@ describe('AgentTaskService', () => {
174174
append: async () => {},
175175
list: async () => [],
176176
delete: async () => {},
177+
size: async () => undefined,
177178
flush: async () => {},
178179
close: async () => {},
179180
});
@@ -1142,6 +1143,7 @@ describe('AgentTaskService', () => {
11421143
},
11431144
list: async () => [],
11441145
delete: async () => {},
1146+
size: async () => undefined,
11451147
flush: async () => {},
11461148
close: async () => {},
11471149
});

packages/agent-core-v2/test/agent/task/tools/task-tools.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1185,6 +1185,7 @@ describe('WaitForTool (harness)', () => {
11851185

11861186
const slow = controllableProcess();
11871187
const taskA = tasks.registerTask(new ProcessTask(slow.proc, 'sleep 30', 'slow'));
1188+
const waitDelivered = ctx.once('task.waitDelivered');
11881189
const pending = executeTool(tool!, context('wait_race', { timeout: 30 }));
11891190

11901191
const late = controllableProcess();
@@ -1205,6 +1206,7 @@ describe('WaitForTool (harness)', () => {
12051206
slow.pushOutput('A-OUT\n');
12061207
slow.resolveWait(0);
12071208
const result = await pending;
1209+
await waitDelivered;
12081210
const output = outputString(result);
12091211

12101212
expect(result.isError ?? false).toBe(false);

0 commit comments

Comments
 (0)