Skip to content

Commit b35de52

Browse files
committed
fix(agents): keep structured prompt summaries UTF-16 safe
1 parent bbd01c1 commit b35de52

2 files changed

Lines changed: 48 additions & 3 deletions

File tree

src/agents/embedded-agent-runner/run/attempt.prompt-helpers.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,31 @@ vi.mock("../../../plugins/host-hook-state.js", () => hostHookStateMocks);
3838

3939
import {
4040
forgetPromptBuildDrainCacheForRun,
41+
mergeOrphanedTrailingUserPrompt,
4142
resolvePromptSubmissionSkipReason,
4243
resolveAttemptMediaTaskSystemPromptAddition,
4344
resolvePromptBuildHookResult,
4445
shouldInjectHeartbeatPrompt,
4546
} from "./attempt.prompt-helpers.js";
4647

48+
function hasLoneSurrogate(value: string): boolean {
49+
for (let index = 0; index < value.length; index += 1) {
50+
const code = value.charCodeAt(index);
51+
if (code >= 0xd800 && code <= 0xdbff) {
52+
const next = value.charCodeAt(index + 1);
53+
if (!(next >= 0xdc00 && next <= 0xdfff)) {
54+
return true;
55+
}
56+
index += 1;
57+
continue;
58+
}
59+
if (code >= 0xdc00 && code <= 0xdfff) {
60+
return true;
61+
}
62+
}
63+
return false;
64+
}
65+
4766
describe("shouldInjectHeartbeatPrompt", () => {
4867
it("keeps global heartbeat guidance out of commitment-only runs", () => {
4968
const heartbeatParams = {
@@ -64,6 +83,31 @@ describe("shouldInjectHeartbeatPrompt", () => {
6483
});
6584
});
6685

86+
describe("mergeOrphanedTrailingUserPrompt", () => {
87+
it("keeps structured media and JSON summaries on UTF-16 boundaries", () => {
88+
const result = mergeOrphanedTrailingUserPrompt({
89+
prompt: "Continue.",
90+
trigger: "user",
91+
leafMessage: {
92+
content: [
93+
{
94+
type: "image_url",
95+
image_url: { url: `${"u".repeat(299)}😀tail` },
96+
},
97+
{
98+
[`${"k".repeat(997)}😀tail`]: 1,
99+
},
100+
],
101+
},
102+
});
103+
104+
expect(result.merged).toBe(true);
105+
expect(hasLoneSurrogate(result.prompt)).toBe(false);
106+
expect(result.prompt).toContain("[image_url]");
107+
expect(result.prompt).toContain("chars)");
108+
});
109+
});
110+
67111
describe("resolveAttemptMediaTaskSystemPromptAddition", () => {
68112
it("joins active media task guidance for user triggers", () => {
69113
imageGenerationTaskStatusMocks.buildActiveImageGenerationTaskPromptContextForSession.mockReturnValue(

src/agents/embedded-agent-runner/run/attempt.prompt-helpers.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { buildActiveVideoGenerationTaskPromptContextForSession } from "../../vid
3030
import { buildEmbeddedCompactionRuntimeContext } from "../compaction-runtime-context.js";
3131
import { resolveContextEngineCapabilities } from "../context-engine-capabilities.js";
3232
import { log } from "../logger.js";
33+
import { truncateUtf16Safe } from "../../../utils.js";
3334
import { shouldInjectHeartbeatPromptForTrigger } from "./trigger-policy.js";
3435
import type { EmbeddedRunAttemptParams } from "./types.js";
3536

@@ -337,7 +338,7 @@ function summarizeStructuredMediaRef(label: string, value: unknown): string | un
337338
return `[${label}] inline data URI (${mimeType}, ${trimmed.length} chars)`;
338339
}
339340
if (trimmed.length > MAX_STRUCTURED_MEDIA_REF_CHARS) {
340-
return `[${label}] ${trimmed.slice(0, MAX_STRUCTURED_MEDIA_REF_CHARS)}... (${trimmed.length} chars)`;
341+
return `[${label}] ${truncateUtf16Safe(trimmed, MAX_STRUCTURED_MEDIA_REF_CHARS)}... (${trimmed.length} chars)`;
341342
}
342343
return `[${label}] ${trimmed}`;
343344
}
@@ -349,7 +350,7 @@ function summarizeStructuredJsonString(value: string): string {
349350
}
350351
const trimmed = value.trim();
351352
if (trimmed.length > MAX_STRUCTURED_JSON_STRING_CHARS) {
352-
return `${trimmed.slice(0, MAX_STRUCTURED_JSON_STRING_CHARS)}... (${trimmed.length} chars)`;
353+
return `${truncateUtf16Safe(trimmed, MAX_STRUCTURED_JSON_STRING_CHARS)}... (${trimmed.length} chars)`;
353354
}
354355
return value;
355356
}
@@ -418,7 +419,7 @@ function stringifyStructuredJsonFallback(part: unknown): string | undefined {
418419
(match) => `[inline data URI: ${match.length} chars]`,
419420
);
420421
return withoutInlineData.length > 1_000
421-
? `${withoutInlineData.slice(0, 1_000)}... (${withoutInlineData.length} chars)`
422+
? `${truncateUtf16Safe(withoutInlineData, 1_000)}... (${withoutInlineData.length} chars)`
422423
: withoutInlineData;
423424
} catch {
424425
return undefined;

0 commit comments

Comments
 (0)