Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged.

## Unreleased

### A long tool result or relayed answer is cut between characters, not through an emoji

A tool result over 20,000 characters, and a Bot's answer over 12,000 relayed back through a handoff,
were cut by UTF-16 code unit. When the cut landed inside an emoji or any other character outside the
Basic Multilingual Plane, the text handed to the model ended on half of it: a lone surrogate that
JSON carries as a bare `\ud83d` and UTF-8 turns into a replacement character. The cut now stops one
unit short in that case, the way an attached text file's already did. Anything that fits is
untouched, and the note saying the result was cut reads as before.
### A boundary rule can ask what started a run, not only whose authority it carries

A routine's turn goes through exactly the path a person's chat turn does, as the routine's owner:
Expand Down
3 changes: 2 additions & 1 deletion server/src/agents/handoff-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
type AuditStore,
recordAuditEvent,
} from "../audit";
import { cutAtCodeUnits } from "../channels/text";
import { DEFAULT_MAX_ATTEMPTS, type WorkQueue } from "../work/queue";
import { HANDOFF_KIND } from "./handoff";

Expand Down Expand Up @@ -508,7 +509,7 @@ const RELAY_ANSWER_LIMIT = 12_000;

function clip(answer: string): string {
if (answer.length <= RELAY_ANSWER_LIMIT) return answer;
return `${answer.slice(0, RELAY_ANSWER_LIMIT)}\n\n[…the answer was cut here for length]`;
return `${cutAtCodeUnits(answer, RELAY_ANSWER_LIMIT)}\n\n[…the answer was cut here for length]`;
}

/**
Expand Down
19 changes: 19 additions & 0 deletions server/src/channels/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,25 @@
*/
const GRAPHEMES = new Intl.Segmenter(undefined, { granularity: "grapheme" });

/**
* The first `limit` UTF-16 code units of `text`, one fewer when the cut would split a character.
*
* `slice` counts code units, and every emoji, every astral-plane glyph and every CJK extension
* character is two of them. A limit landing between the two halves leaves a lone high surrogate as
* the last unit, which is not a character: `JSON.stringify` sends it as a bare `\ud83d` and UTF-8
* encodes it as U+FFFD, so whatever reads the cut text — a model, most often — is handed a broken
* character that was never in the source. `extractDocumentText` guards the same cut on attachments.
*
* The orphan is dropped rather than completed, so the result never exceeds the limit it was asked
* for. It cannot be a lone surrogate that was already in the text and happened to land last: only a
* high surrogate is dropped, and one followed by its pair in the source is exactly the split case.
*/
export function cutAtCodeUnits(text: string, limit: number): string {
const sliced = text.slice(0, limit);
const last = sliced.charCodeAt(sliced.length - 1);
return last >= 0xd800 && last <= 0xdbff ? sliced.slice(0, -1) : sliced;
}

/**
* One line a roster can draw: control characters stripped, whitespace collapsed, cut on grapheme
* clusters so an emoji is never split. The caller supplies the cap; a preview and a title want
Expand Down
3 changes: 2 additions & 1 deletion server/src/plugins/builtin-routines.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { cutAtCodeUnits } from "../channels/text";
import {
MAX_RUN_ERROR,
type Routine,
Expand Down Expand Up @@ -242,7 +243,7 @@ function asResult(text: string): McpCallResult {
return { text, isError: false, truncated: false };
}
return {
text: `${text.slice(0, MAX_RESULT_CHARS)}\n\n[truncated: the tool returned ${text.length} characters]`,
text: `${cutAtCodeUnits(text, MAX_RESULT_CHARS)}\n\n[truncated: the tool returned ${text.length} characters]`,
isError: false,
truncated: true,
};
Expand Down
3 changes: 2 additions & 1 deletion server/src/plugins/google-drive-rest.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { cutAtCodeUnits } from "../channels/text";
import { MAX_RESULT_CHARS, type McpCallResult, type McpTool } from "./mcp";

/**
Expand Down Expand Up @@ -292,7 +293,7 @@ function asResult(text: string): McpCallResult {
return { text: joined, isError: false, truncated: false };
}
return {
text: `${joined.slice(0, MAX_RESULT_CHARS)}\n\n[truncated: the tool returned ${joined.length} characters]`,
text: `${cutAtCodeUnits(joined, MAX_RESULT_CHARS)}\n\n[truncated: the tool returned ${joined.length} characters]`,
isError: false,
truncated: true,
};
Expand Down
3 changes: 2 additions & 1 deletion server/src/plugins/mcp.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { cutAtCodeUnits } from "../channels/text";

/**
* The only place in this deployment that speaks MCP to somebody else's server.
Expand Down Expand Up @@ -74,7 +75,7 @@ export function resultText(content: unknown): {
return { text: joined, truncated: false };
}
return {
text: `${joined.slice(0, MAX_RESULT_CHARS)}\n\n[truncated: the tool returned ${joined.length} characters]`,
text: `${cutAtCodeUnits(joined, MAX_RESULT_CHARS)}\n\n[truncated: the tool returned ${joined.length} characters]`,
truncated: true,
};
}
Expand Down
14 changes: 14 additions & 0 deletions server/tests/agent-handoff-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -518,6 +518,20 @@ describe("relaying the answer home", () => {
expect(offered[0]?.task).toContain("[…the answer was cut here for length]");
expect((offered[0]?.task ?? "").length).toBeLessThan(14_000);
});

test("an answer cut inside a character loses the whole character, not half of it", async () => {
// 12,000 code units are kept, and an emoji is two: its high half on the last kept unit would
// reach the relaying run's prompt as a lone surrogate.
const { runner: sweeper, offered } = runner({
answer: `${"x".repeat(11_999)}😀tail`,
});

await sweeper.sweep();

expect(offered[0]?.task).toContain(
`${"x".repeat(11_999)}\n\n[…the answer was cut here for length]`,
);
});
});

/**
Expand Down
28 changes: 28 additions & 0 deletions server/tests/builtin-routines.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
type RoutineTools,
useRoutineTools,
} from "../src/plugins/builtin-routines";
import { MAX_RESULT_CHARS } from "../src/plugins/mcp";
import {
type Routine,
RoutineNotFoundError,
Expand Down Expand Up @@ -104,6 +105,33 @@ afterEach(() => {
useRoutineTools(null);
});

describe("a list too long for one result", () => {
test("is cut between characters, never through one", async () => {
const listing = (instruction: string) =>
recordingTools({
async listFor() {
return [{ ...SUMMARY, instruction }];
},
});

// Where the instruction starts in a listing, measured rather than assumed, so the emoji's high
// half lands on the last code unit the limit keeps whatever words surround it.
listing("MARKER");
const probe = await callTool(CONNECTION, "list_routines", {});
const before = probe.text.indexOf("MARKER");
expect(before).toBeGreaterThan(-1);

const filler = "a".repeat(MAX_RESULT_CHARS - 1 - before);
listing(`${filler}😀tail`);
const result = await callTool(CONNECTION, "list_routines", {});

expect(result.truncated).toBe(true);
expect(result.text.split("\n\n[truncated")[0]).toBe(
`${probe.text.slice(0, before)}${filler}`,
);
});
});

describe("the tool list", () => {
test("is the four routine tools, named exactly", async () => {
const tools = await listTools();
Expand Down
31 changes: 31 additions & 0 deletions server/tests/google-drive-rest.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { afterEach, describe, expect, test } from "bun:test";
import { catalogueEntry } from "../src/plugins/catalogue";
import { callTool, listTools } from "../src/plugins/google-drive-rest";
import { MAX_RESULT_CHARS } from "../src/plugins/mcp";
import { transportFor } from "../src/plugins/transport";

/**
Expand Down Expand Up @@ -236,4 +237,34 @@ describe("reading a file asks Drive what it is first", () => {
expect(result.isError).toBe(true);
expect(calls).toHaveLength(0);
});

test("a file too long for one result is cut between characters, never through one", async () => {
// The name and a blank line lead the result, so the filler is sized to put an emoji's high half
// on the last code unit the limit keeps.
const heading = "notes.txt\n\n";
const filler = "a".repeat(MAX_RESULT_CHARS - 1 - heading.length);
let served = 0;
globalThis.fetch = (async () => {
served += 1;
return served === 1
? new Response(
JSON.stringify({
id: "txt1",
name: "notes.txt",
mimeType: "text/plain",
}),
{ headers: { "content-type": "application/json" } },
)
: new Response(`${filler}😀tail`, {
headers: { "content-type": "text/plain" },
});
}) as unknown as typeof fetch;

const result = await callTool(connection, "read_file_content", {
fileId: "txt1",
});

expect(result.truncated).toBe(true);
expect(result.text.split("\n\n[truncated")[0]).toBe(`${heading}${filler}`);
});
});
23 changes: 23 additions & 0 deletions server/tests/mcp-result.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,4 +99,27 @@ describe("a result too large to hand a model", () => {
expect(truncated).toBe(false);
expect(text).toBe(exact);
});

test("a cut that would land inside a character stops one code unit short", () => {
// The limit counts UTF-16 code units, and an emoji is two of them. Cut between the two and the
// result ends on a lone high surrogate: `JSON.stringify` sends it as a bare `\ud83d` and UTF-8
// turns it into U+FFFD, so the model is handed a broken character for a reason that has nothing
// to do with what the tool said. `extractDocumentText` guards the same cut on attachments.
const emoji = "😀";
expect(emoji.length).toBe(2);
const input = `${"a".repeat(MAX_RESULT_CHARS - 1)}${emoji}tail`;
const { text, truncated } = resultText([{ type: "text", text: input }]);
expect(truncated).toBe(true);
expect(text).toBe(
`${"a".repeat(MAX_RESULT_CHARS - 1)}\n\n[truncated: the tool returned ${input.length} characters]`,
);
});

test("a cut that lands between characters still keeps the whole limit", () => {
const input = `${"a".repeat(MAX_RESULT_CHARS - 2)}😀tail`;
const { text } = resultText([{ type: "text", text: input }]);
expect(text).toBe(
`${"a".repeat(MAX_RESULT_CHARS - 2)}😀\n\n[truncated: the tool returned ${input.length} characters]`,
);
});
});