mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-31 05:32:58 +00:00
fix(server): cap shell timeline text by UTF-8 bytes
This commit is contained in:
@@ -9,10 +9,10 @@ The invariant is:
|
||||
|
||||
> If the daemon has committed timeline rows for an agent, any connected client that opens or resumes that agent eventually displays every row through the daemon's current tail.
|
||||
|
||||
Tool output is bounded before it enters either delivery path. Canonical shell tool output is sliced
|
||||
to 64 KiB, and the same bounded item is used for durable timeline rows and live stream events.
|
||||
Provider history hydration applies the same rule so reopening an agent cannot restore an oversized
|
||||
tool payload.
|
||||
Tool output is bounded before it enters either delivery path. Canonical shell tool output and failed
|
||||
shell error text are capped at 64 KiB of UTF-8 data, and the same bounded item is used for durable
|
||||
timeline rows and live stream events. Provider history hydration applies the same rule so reopening
|
||||
an agent cannot restore an oversized tool payload.
|
||||
|
||||
## Presence is not delivery
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ import type {
|
||||
|
||||
const COALESCE_WINDOW_MS = AGENT_STREAM_COALESCE_DEFAULT_WINDOW_MS;
|
||||
const BEFORE_COALESCE_WINDOW_MS = Math.max(COALESCE_WINDOW_MS - 1, 0);
|
||||
const TOOL_CALL_CONTENT_MAX_LENGTH = 64 * 1024;
|
||||
const TOOL_CALL_CONTENT_MAX_BYTES = 64 * 1024;
|
||||
|
||||
const TEST_CAPABILITIES: AgentCapabilityFlags = {
|
||||
supportsStreaming: false,
|
||||
@@ -385,7 +385,7 @@ describe("target coalesced behavior", () => {
|
||||
const output = `${"a".repeat(512 * 1024)}${"z".repeat(512 * 1024)}`;
|
||||
const expectedItem = toolCall({
|
||||
status: "completed",
|
||||
output: "a".repeat(TOOL_CALL_CONTENT_MAX_LENGTH),
|
||||
output: "a".repeat(TOOL_CALL_CONTENT_MAX_BYTES),
|
||||
});
|
||||
|
||||
session.pushEvent(timelineEvent(toolCall({ status: "completed", output })));
|
||||
@@ -403,6 +403,27 @@ describe("target coalesced behavior", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("bounds tool output by UTF-8 bytes", async () => {
|
||||
const harness = createHarness();
|
||||
try {
|
||||
const { agentId, session } = await createManagedSession(harness);
|
||||
const output = "漢".repeat(TOOL_CALL_CONTENT_MAX_BYTES);
|
||||
const expectedItem = toolCall({
|
||||
status: "completed",
|
||||
output: "漢".repeat(Math.floor(TOOL_CALL_CONTENT_MAX_BYTES / 3)),
|
||||
});
|
||||
|
||||
session.pushEvent(timelineEvent(toolCall({ status: "completed", output })));
|
||||
await waitForSessionEventQueue();
|
||||
|
||||
expect(getTimelineItems(await harness.manager.getTimelineRows(agentId))).toEqual([
|
||||
expectedItem,
|
||||
]);
|
||||
} finally {
|
||||
harness.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("bounds appended tool output before persisting and streaming it", async () => {
|
||||
const harness = createHarness();
|
||||
try {
|
||||
@@ -410,7 +431,7 @@ describe("target coalesced behavior", () => {
|
||||
const output = `${"a".repeat(512 * 1024)}${"z".repeat(512 * 1024)}`;
|
||||
const expectedItem = toolCall({
|
||||
status: "completed",
|
||||
output: "a".repeat(TOOL_CALL_CONTENT_MAX_LENGTH),
|
||||
output: "a".repeat(TOOL_CALL_CONTENT_MAX_BYTES),
|
||||
});
|
||||
|
||||
await harness.manager.appendTimelineItem(agentId, toolCall({ status: "completed", output }));
|
||||
@@ -434,7 +455,7 @@ describe("target coalesced behavior", () => {
|
||||
const output = `${"a".repeat(512 * 1024)}${"z".repeat(512 * 1024)}`;
|
||||
const expectedItem = toolCall({
|
||||
status: "completed",
|
||||
output: "a".repeat(TOOL_CALL_CONTENT_MAX_LENGTH),
|
||||
output: "a".repeat(TOOL_CALL_CONTENT_MAX_BYTES),
|
||||
});
|
||||
session.setHistory([timelineEvent(toolCall({ status: "completed", output }))]);
|
||||
|
||||
@@ -455,7 +476,7 @@ describe("target coalesced behavior", () => {
|
||||
const output = `${"a".repeat(512 * 1024)}${"z".repeat(512 * 1024)}`;
|
||||
const expectedItem = toolCall({
|
||||
status: "completed",
|
||||
output: "a".repeat(TOOL_CALL_CONTENT_MAX_LENGTH),
|
||||
output: "a".repeat(TOOL_CALL_CONTENT_MAX_BYTES),
|
||||
});
|
||||
|
||||
await harness.manager.emitLiveTimelineItem(
|
||||
@@ -473,17 +494,22 @@ describe("target coalesced behavior", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("bounds failed shell output carried in the error", async () => {
|
||||
test("bounds failed shell output carried in error content and message", async () => {
|
||||
const harness = createHarness();
|
||||
try {
|
||||
const { agentId, session } = await createManagedSession(harness);
|
||||
const content = `${"a".repeat(512 * 1024)}${"z".repeat(512 * 1024)}`;
|
||||
const expectedItem = toolCall({
|
||||
status: "failed",
|
||||
error: { content: "a".repeat(TOOL_CALL_CONTENT_MAX_LENGTH) },
|
||||
error: {
|
||||
content: "a".repeat(TOOL_CALL_CONTENT_MAX_BYTES),
|
||||
message: "a".repeat(TOOL_CALL_CONTENT_MAX_BYTES),
|
||||
},
|
||||
});
|
||||
|
||||
session.pushEvent(timelineEvent(toolCall({ status: "failed", error: { content } })));
|
||||
session.pushEvent(
|
||||
timelineEvent(toolCall({ status: "failed", error: { content, message: content } })),
|
||||
);
|
||||
await waitForSessionEventQueue();
|
||||
|
||||
expect(getTimelineItems(await harness.manager.getTimelineRows(agentId))).toEqual([
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { StringDecoder } from "node:string_decoder";
|
||||
import type { AgentTimelineItem } from "./agent-sdk-types.js";
|
||||
|
||||
const TOOL_CALL_CONTENT_MAX_LENGTH = 64 * 1024;
|
||||
const TOOL_CALL_CONTENT_MAX_BYTES = 64 * 1024;
|
||||
|
||||
function limitTextContent(value: string): string {
|
||||
if (Buffer.byteLength(value, "utf8") <= TOOL_CALL_CONTENT_MAX_BYTES) {
|
||||
return value;
|
||||
}
|
||||
const bytes = Buffer.from(value, "utf8").subarray(0, TOOL_CALL_CONTENT_MAX_BYTES);
|
||||
return new StringDecoder("utf8").write(bytes);
|
||||
}
|
||||
|
||||
function limitFailedShellError(item: AgentTimelineItem): AgentTimelineItem {
|
||||
if (
|
||||
@@ -8,19 +17,30 @@ function limitFailedShellError(item: AgentTimelineItem): AgentTimelineItem {
|
||||
item.detail.type !== "shell" ||
|
||||
item.status !== "failed" ||
|
||||
typeof item.error !== "object" ||
|
||||
item.error === null ||
|
||||
!("content" in item.error) ||
|
||||
typeof item.error.content !== "string" ||
|
||||
item.error.content.length <= TOOL_CALL_CONTENT_MAX_LENGTH
|
||||
item.error === null
|
||||
) {
|
||||
return item;
|
||||
}
|
||||
|
||||
const error: Record<string, unknown> = { ...item.error };
|
||||
let changed = false;
|
||||
for (const key of ["content", "message"] as const) {
|
||||
const value = error[key];
|
||||
if (typeof value !== "string") {
|
||||
continue;
|
||||
}
|
||||
const limitedValue = limitTextContent(value);
|
||||
if (limitedValue !== value) {
|
||||
error[key] = limitedValue;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (!changed) {
|
||||
return item;
|
||||
}
|
||||
return {
|
||||
...item,
|
||||
error: {
|
||||
...item.error,
|
||||
content: item.error.content.slice(0, TOOL_CALL_CONTENT_MAX_LENGTH),
|
||||
},
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -33,14 +53,15 @@ export function limitAgentTimelineItemContent(item: AgentTimelineItem): AgentTim
|
||||
) {
|
||||
return item;
|
||||
}
|
||||
if (item.detail.output.length <= TOOL_CALL_CONTENT_MAX_LENGTH) {
|
||||
const output = limitTextContent(item.detail.output);
|
||||
if (output === item.detail.output) {
|
||||
return item;
|
||||
}
|
||||
return {
|
||||
...item,
|
||||
detail: {
|
||||
...item.detail,
|
||||
output: item.detail.output.slice(0, TOOL_CALL_CONTENT_MAX_LENGTH),
|
||||
output,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user