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
19 changes: 19 additions & 0 deletions changelog.d/cuts-keep-pairs.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
- **Text cut to a fixed length for an agent to read no longer ends or
starts on half a surrogate pair.** An emoji or another character outside
the Basic Multilingual Plane that fell across such a cut left half of its
pair, which the agent read as a replacement character (U+FFFD) or, in a
result's data, as an escape such as `\ud83d`. These cuts now leave out a
pair they would halve:
- the event gate's quote of a debounced event's content (200 units);
- a utility's one-liner in `utils list` (160 units);
- a failed spill's reason in a truncated tool result (200 units);
- each recent error in health's `snapshot` (400 units);
- the last 2000 units of a dead background script's output, in the wake
that tells the agent it died;
- a thrown error's text, as a subconscious tool's failed result reports
it. It's now cut at 500 characters counted as code points, as
`boundedErrorMessage` cuts, and its "(N characters)" counts code points.
A text within 500 characters now comes back whole, even past 500 UTF-16
units. So do the framework's and the MCPL checkpoint manager's other
quotes made through `thrownText`, such as a refusal when stored state
can't be read.
30 changes: 5 additions & 25 deletions src/framework.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ import { ChannelRegistry, type ChannelToolOrigin } from './mcpl/channel-registry
import { ConversationRouter, CONVERSATION_ROUTING_DEPRECATION_NOTICE } from './mcpl/conversation-router.js';
import type { IncomingMessageFacts, RoutingDecision } from './mcpl/conversation-router.js';
import { safeSlice } from './safe-slice.js';
import { boundedErrorMessage as thrownText } from './error-message.js';
import { ProviderWaits, EVERY_MODEL, isReleaseModel, withoutControls, type ProviderWait } from './provider-waits.js';
import type { WorkspaceModule } from './modules/workspace/index.js';
import type { ToolResultGuard, WithheldSpill } from './tool-result-guard.js';
Expand Down Expand Up @@ -388,7 +389,7 @@ const UTILS_TOOL: import('./types/index.js').ToolDefinition = {
const utilityOneLiner = (description: string): string => {
const line = description.split('\n')[0]!;
const dot = line.indexOf('. ');
return (dot >= 0 ? line.slice(0, dot + 1) : line).slice(0, 160);
return safeSlice(dot >= 0 ? line.slice(0, dot + 1) : line, 0, 160);
};

/** Shallow validation of run args against a utility's declared inputSchema:
Expand Down Expand Up @@ -1672,28 +1673,6 @@ export function shedMayHelp(errorType: string | undefined, httpStatus: number |
return errorType === 'invalid_request' && (httpStatus === undefined || httpStatus === 400 || httpStatus === 422);
}

/** The most of a thrown value's or a tool error's text that reaches a log line or a server's error answer. */
const THROWN_TEXT_LIMIT = 500;

/**
* What a thrown value says, safely and briefly: an Error's message, or any
* other value, as a string. Every inspection and coercion of the value is
* inside the try, so reading it can't throw: a revoked Proxy (whose
* instanceof throws), a hostile getter, an Error whose message isn't a
* string, or a value with no string form reads as text or as unreadable.
* The text is bounded, since a provider or handler error can carry a whole
* request body.
*/
function thrownText(error: unknown): string {
let text: string;
try {
text = String(error instanceof Error ? error.message : error);
} catch {
text = '(a value that could not be read)';
}
return text.length <= THROWN_TEXT_LIMIT ? text : `${text.slice(0, THROWN_TEXT_LIMIT)}… (${text.length} characters)`;
}

/** A stored value that read but isn't in a form this code takes, as against
* a read that failed: starting again changes nothing until it's repaired,
* so the refusal that quotes it says so instead of "once it can be read". */
Expand Down Expand Up @@ -15890,7 +15869,8 @@ export class AgentFramework {
// promise die silently. Deliberate cancels/disposes stay silent (the
// agent or host chose them); clean exits stay silent (the null path).
if (record.status === 'died' && !record.cancelled) {
const tail = (exec.tail ?? exec.stderr ?? '').slice(-2000);
const output = exec.tail ?? exec.stderr ?? '';
const tail = safeSlice(output, Math.max(0, output.length - 2000), output.length);
const elapsedMin = Math.round((Date.now() - record.startedAt) / 60_000);
const envelope =
`[background script ${record.id}] Your background script DIED ${elapsedMin}m after start ` +
Expand Down Expand Up @@ -16137,7 +16117,7 @@ export class AgentFramework {
} catch (err) {
failure = err instanceof Error ? err.message : String(err);
}
failure = failure.slice(0, 200);
failure = safeSlice(failure, 0, 200);
this.emitTrace({
type: 'tool:spill_failed',
label: safeLabel,
Expand Down
3 changes: 2 additions & 1 deletion src/gate/event-gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import { existsSync, readFileSync, statSync, writeFileSync, mkdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { GateScript } from './gate-script.js';
import { safeSlice } from '../safe-slice.js';

import type { ToolDefinition } from '../types/events.js';
import type {
Expand Down Expand Up @@ -1484,7 +1485,7 @@ export class EventGate {
const event: PendingEvent = {
policyName: policy.name,
content: info.content.length > MAX_CONTENT_SNIPPET
? info.content.slice(0, MAX_CONTENT_SNIPPET) + '...'
? safeSlice(info.content, 0, MAX_CONTENT_SNIPPET) + '...'
: info.content,
eventType: info.eventType,
timestamp: Date.now(),
Expand Down
12 changes: 1 addition & 11 deletions src/mcpl/checkpoint-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

import type { JsStore } from '@animalabs/chronicle';
import { ensureStateRegistered } from '../module-registry.js';
import { boundedErrorMessage as thrownText } from '../error-message.js';

import type { StateCheckpoint, JsonPatchOperation } from './types.js';

Expand All @@ -31,17 +32,6 @@ const STATE_PREFIX = 'mcpl/state';
const MCPL_STOPS = `MCPL doesn't start without them, and neither does create() with MCPL servers configured, ` +
`nor a first connectMcplServer()`;

/** A thrown value's text, bounded and never throwing, as framework.ts's thrownText. */
function thrownText(error: unknown): string {
let text: string;
try {
text = String(error instanceof Error ? error.message : error);
} catch {
text = '(a value that could not be read)';
}
return text.length <= 500 ? text : `${text.slice(0, 500)}… (${text.length} characters)`;
}

/** A stored JSON value's kind, for a refusal that names what it found without its content. */
function storedKind(value: unknown): string {
return value === undefined ? 'missing'
Expand Down
3 changes: 2 additions & 1 deletion src/modules/health/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import type { ToolDefinition, ToolCall, ToolResult, ProcessEvent } from '../../t
import type { EventResponse, ProcessState } from '../../types/module.js';
import type { AgentFramework } from '../../framework.js';
import { formatZonedDateTime, resolveTimeZone } from '../../timezone.js';
import { safeSlice } from '../../safe-slice.js';

export interface HealthModuleConfig {
/** IANA zone for timestamps returned by the snapshot tool. */
Expand Down Expand Up @@ -196,7 +197,7 @@ export class HealthModule implements Module {
.map((s) => ({
timestamp: s.timestamp,
agentName: s.agentName,
error: typeof s.error === 'string' ? s.error.slice(0, 400) : s.error,
error: typeof s.error === 'string' ? safeSlice(s.error, 0, 400) : s.error,
requestId: s.requestId,
}));

Expand Down
25 changes: 25 additions & 0 deletions test/boot-state-read-errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -594,6 +594,31 @@ test("MCPL checkpoint trees that can't be read, or that aren't checkpoint trees,
await f.create();
});

test("an unreadable checkpoint store's error is quoted to 500 characters, counted in code points, without halving a surrogate pair", inStoreDir('boot-read-long-error-', async (storePath) => {
const store = JsStore.openOrCreate({ path: storePath });
// 499 characters, then an emoji whose two UTF-16 units straddle unit 500.
// Counted in code points, the emoji is the 500th character.
const message = `${'x'.repeat(499)}😀 and more`;
const proto = JsStore.prototype as unknown as { getStateJson(this: JsStore, id: string): unknown };
const original = proto.getStateJson;
proto.getStateJson = function (this: JsStore, id: string) {
if (id === 'mcpl/checkpoints') throw new Error(message);
return original.call(this, id);
};
try {
assert.throws(() => new CheckpointManager(store, () => {}), (error: Error) => {
assert.ok(
error.message.startsWith(`the MCPL checkpoint trees (mcpl/checkpoints) could not be read: ${'x'.repeat(499)}😀… (509 characters). MCPL doesn't start`),
`the cut keeps the emoji whole, and the count is of code points: …${JSON.stringify(error.message.slice(555, 600))}`,
);
return true;
});
} finally {
proto.getStateJson = original;
store.close();
}
}));

/** A stored host-managed checkpoint tree for `server`'s `featureSet`, holding `state`, as the checkpoint manager stores them. */
function seedHostManaged(storePath: string, server: string, featureSet: string, state: unknown): void {
const store = JsStore.openOrCreate({ path: storePath });
Expand Down
45 changes: 45 additions & 0 deletions test/code-execution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1339,6 +1339,51 @@ describe('framework background scripts + spill (real python3)', () => {
}
});

it('a dead script\'s last output is cut at 2000 units without halving a surrogate pair', async () => {
// 1500 emoji, then the traceback. Whichever parity the cut 2000 units
// from the end has, one of the two runs (with or without one more unit
// after the emoji) puts it on a pair's second half.
for (const after of ['', 'x']) {
const { tempDir, storePath } = tempStorePath('pytc-bgpair-');
const membrane = new MockMembrane();
membrane.pushResponse(createMockResponse([{ type: 'text', text: 'noted' }]));
const framework = await createBgFramework(storePath, membrane, {});
try {
framework.start();
const started = await framework.executeToolCall({
id: 'c1',
name: 'code_execution',
input: {
code: `import asyncio\nprint("\\U0001F600" * 1500 + "${after}", flush=True)\nawait asyncio.sleep(0.1)\nraise ValueError("done")`,
background: true,
},
callerAgentName: 'prime',
});
assert.strictEqual(started.success, true, JSON.stringify(started));

const deadline = Date.now() + 20_000;
let crashMessage: { content: ContentBlockLike[] } | null = null;
while (Date.now() < deadline && !crashMessage) {
await new Promise((r) => setTimeout(r, 100));
const cm = framework.getAgent('prime')?.getContextManager();
const msgs = (cm?.queryMessages({}).messages ?? []) as unknown as Array<{ content: ContentBlockLike[]; metadata?: { source?: string } }>;
crashMessage = msgs.find((m) => m.metadata?.source === 'background-script') ?? null;
}
assert.ok(crashMessage, 'crash wake should be injected');
const text = (crashMessage.content[0] as { text?: string }).text ?? '';
// A halved pair would be stored as U+FFFD, or kept as a lone surrogate.
const last = text.split('Last output:\n')[1] ?? '';
assert.ok(last.startsWith('\u{1F600}'),
`the last output starts on a whole emoji (after ${JSON.stringify(after)}): ${JSON.stringify(last.slice(0, 4))}`);
assert.ok(!text.includes('\ufffd') && !/[\ud800-\udbff](?![\udc00-\udfff])|(?<![\ud800-\udbff])[\udc00-\udfff]/.test(text),
`no half of a pair (after ${JSON.stringify(after)})`);
} finally {
await framework.stop();
rmSync(tempDir, { recursive: true, force: true });
}
}
});

it('oversized tool results spill to a workspace file with a reference', async () => {
const { tempDir, storePath } = tempStorePath('pytc-spill-');
const mountDir = join(tempDir, 'mount');
Expand Down
20 changes: 20 additions & 0 deletions test/event-gate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,26 @@ describe('debounce', () => {
assert.doesNotMatch(text, /##/);
});

it('a quoted event\'s content is cut where no surrogate pair is halved', (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
const path = writeConfig('debounce-pair.json', {
policies: [
{ name: 'files', match: { scope: ['workspace:file-changed'] }, behavior: { debounce: 100 } },
],
default: 'skip',
});
const { gate, messages } = makeGate(path);

// 199 units, then an emoji whose two units straddle the 200-unit cut.
gate.evaluate(event({ eventType: 'workspace:file-changed', content: `${'a'.repeat(199)}😀 and on` }));
t.mock.timers.tick(100);

assert.strictEqual(messages.length, 1);
const text = (messages[0].content as Array<{ text: string }>)[0].text;
assert.ok(text.includes(`: ${'a'.repeat(199)}...`), `the cut leaves the emoji out whole: ${JSON.stringify(text)}`);
assert.ok(!/[\ud800-\udbff](?![\udc00-\udfff])|(?<![\ud800-\udbff])[\udc00-\udfff]/.test(text), 'no half of a pair');
});

it('a batched wake carries the newest channel event\'s channel AND author together (provenance)', async () => {
const path = writeConfig('debounce-provenance.json', {
policies: [
Expand Down
18 changes: 18 additions & 0 deletions test/health-snapshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,3 +96,21 @@ describe('HealthModule snapshot timestamp resilience', () => {
);
});
});

describe('HealthModule snapshot error text', () => {
it('cuts a recent error at 400 units without halving a surrogate pair', async () => {
// 399 units, then an emoji whose two units straddle the 400-unit cut.
const error = `${'e'.repeat(399)}😀 and on`;
const entries = [
{ sequence: 0, entry: { timestamp: 1751900000000, agentName: 'a', requestId: 'r0', success: false, error, request: {}, durationMs: 1 } },
];
const health = new HealthModule({ timeZone: 'UTC' });
health.bind(frameworkStub(entries), {} as JsStore);

const result = await health.handleToolCall(snapshotCall());

assert.equal(result.success, true, `snapshot failed: ${result.error}`);
const shown = (result.data as { inferences: { recentErrors: Array<{ error: string }> } }).inferences.recentErrors[0]!.error;
assert.equal(shown, 'e'.repeat(399), 'the cut leaves the emoji out whole');
});
});
29 changes: 29 additions & 0 deletions test/stop-with-work-in-flight.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -871,6 +871,35 @@ describe('stop() with work still in flight', () => {
await framework.stop();
}
});

it('a rejection over 500 characters is cut there, counting code points, without halving a surrogate pair', async () => {
const { framework, settle, pushed } = await withTuneOut();
try {
// 499 characters, then an emoji whose two UTF-16 units straddle unit
// 500. Counted in code points, the emoji is the 500th character.
const message = `${'x'.repeat(499)}😀 and more`;
settle().reject(new Error(message));
await until(() => pushed.length > 0, 'the failed result');
const result = (pushed[0] as unknown as { result: ToolResult }).result;
assert.equal(result.error, `${'x'.repeat(499)}😀… (509 characters)`, 'the cut keeps the emoji whole, and the count is of code points');
} finally {
await framework.stop();
}
});

it('a rejection of 500 characters or fewer comes back whole, though it is longer than 500 UTF-16 units', async () => {
const { framework, settle, pushed } = await withTuneOut();
try {
// 300 emoji: 600 UTF-16 units, 300 characters.
const message = '😀'.repeat(300);
settle().reject(new Error(message));
await until(() => pushed.length > 0, 'the failed result');
const result = (pushed[0] as unknown as { result: ToolResult }).result;
assert.equal(result.error, message, 'within 500 characters, nothing is cut and no note is added');
} finally {
await framework.stop();
}
});
});

it('every tool result the framework produces names its tool, and the call it answers', () => {
Expand Down
25 changes: 25 additions & 0 deletions test/tool-result-spill.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -526,6 +526,31 @@ describe('tool-result spill completion (issue #89)', () => {
}
});

it('cuts a failed spill\'s reason at 200 units without halving a surrogate pair', async () => {
// 199 units, then an emoji whose two units straddle the 200-unit cut.
const reason = `${'r'.repeat(199)}😀 and the rest of the reason`;
const proto = WorkspaceModule.prototype as unknown as { writeBinary: (...args: unknown[]) => Promise<unknown> };
const writeBinary = proto.writeBinary;
proto.writeBinary = async () => ({ success: false, error: reason });
let h: SpillHarness | undefined;
try {
h = await startSpillTurn({
prefix: 'spill-pair-',
result: { success: true, data: { blob: 'w'.repeat(60_000) } },
withWorkspace: true,
});
const stored = await waitForStoredToolResult(h.framework);
assert.ok(stored, 'tool result should be stored');
assert.ok(stored.content.includes(`FAILED (${'r'.repeat(199)})`), `the cut leaves the emoji out whole: ${stored.content.slice(-400)}`);
} finally {
proto.writeBinary = writeBinary;
if (h) {
await h.framework.stop();
rmSync(h.tempDir, { recursive: true, force: true });
}
}
});

it('clamps the default down to the strategy bound and reports it', async () => {
// maxMessageTokens=1000 → strategy bound 4000 < default 24000. This branch
// decides the cap for every resident on a bounded strategy — pin it.
Expand Down
21 changes: 21 additions & 0 deletions test/utils-surface.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,27 @@ describe('utils surface', () => {
} finally { await framework.stop(); rmSync(dir, { recursive: true, force: true }); }
});

it('lists a first line over 160 units cut there, without halving a surrogate pair', async () => {
const long = {
name: 'long',
async start(): Promise<void> {},
async stop(): Promise<void> {},
getTools: (): ToolDefinition[] => [],
// 159 units, then an emoji whose two units straddle the 160-unit cut.
getUtilities: (): ToolDefinition[] => [
{ name: 'op', description: `${'d'.repeat(159)}😀 and the rest of a long first line`, inputSchema: { type: 'object' } },
],
handleToolCall: async (): Promise<ToolResult> => ({ success: true }),
onProcess: async (): Promise<any> => ({}),
} as unknown as Module;
const { framework, dir } = await makeFramework([long]);
try {
const list = await framework.executeToolCall({ id: 'c1', name: 'utils', input: { action: 'list' }, callerAgentName: 'agent' });
assert.ok(list.success, list.error);
assert.deepEqual(list.data, [{ name: 'long--op', description: 'd'.repeat(159) }], 'the cut leaves the emoji out whole');
} finally { await framework.stop(); rmSync(dir, { recursive: true, force: true }); }
});

it('arg misses teach by bounce: schema rides the error', async () => {
const stub = new StubModule();
const { framework, dir } = await makeFramework([stub]);
Expand Down
Loading