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: 11 additions & 8 deletions packages/core/src/goals/goal-evidence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,7 @@ export class GoalEvidenceRecordIndexAccumulator {
this.lastPartPreviewValues,
).trim();
}
preview = capPreviewBytes(preview);
preview = capPreviewBytes(preview, CATALOG_PREVIEW_BYTE_LIMIT);
const catalogEntry =
this.provenance && this.parsedGoalContext && preview
? {
Expand Down Expand Up @@ -959,7 +959,10 @@ function checkpointCatalogEntries(
uuid: claim.id,
provenance: 'goal_checkpoint',
turnId: `checkpoint:${checkpoint.checkpointId}`,
preview: capPreviewBytes(claim.claim.slice(0, CATALOG_PREVIEW_LIMIT)),
preview: capPreviewBytes(
claim.claim.slice(0, CATALOG_PREVIEW_LIMIT),
CATALOG_PREVIEW_BYTE_LIMIT,
),
proofKind: claim.proofKind,
}));
}
Expand Down Expand Up @@ -1067,18 +1070,17 @@ function legacySafeProvenance(
}

/**
* Cut `value` to at most {@link CATALOG_PREVIEW_BYTE_LIMIT} UTF-8 bytes
* without splitting a code point.
* Cut `value` to at most `limit` UTF-8 bytes without splitting a code point.
*/
function capPreviewBytes(value: string): string {
if (Buffer.byteLength(value, 'utf8') <= CATALOG_PREVIEW_BYTE_LIMIT) {
export function capPreviewBytes(value: string, limit: number): string {
if (Buffer.byteLength(value, 'utf8') <= limit) {
return value;
}
let byteLength = 0;
let cutoff = 0;
for (const codePoint of value) {
const codePointBytes = Buffer.byteLength(codePoint, 'utf8');
if (byteLength + codePointBytes > CATALOG_PREVIEW_BYTE_LIMIT) break;
if (byteLength + codePointBytes > limit) break;
byteLength += codePointBytes;
cutoff += codePoint.length;
}
Expand Down Expand Up @@ -1139,6 +1141,7 @@ function evidencePreview(
if (projection?.displayText !== undefined) {
return capPreviewBytes(
projection.displayText.slice(0, CATALOG_PREVIEW_LIMIT).trim(),
CATALOG_PREVIEW_BYTE_LIMIT,
);
}
let preview = '';
Expand All @@ -1159,7 +1162,7 @@ function evidencePreview(
}
if (preview.length >= CATALOG_PREVIEW_LIMIT) break;
}
return capPreviewBytes(preview.trim());
return capPreviewBytes(preview.trim(), CATALOG_PREVIEW_BYTE_LIMIT);
}

function renderToolResponse(functionResponse: {
Expand Down
177 changes: 177 additions & 0 deletions packages/core/src/goals/goal-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
type GoalTurnHost,
} from './goal-runtime.js';
import {
type GetGoalToolParams,
GetGoalTool,
UpdateGoalTool,
type GoalToolConfig,
Expand Down Expand Up @@ -350,6 +351,7 @@ describe('GetGoalTool', () => {
expect(getSnapshotForPermit).toHaveBeenCalledWith(permit);
expect(JSON.parse(String(result.llmContent))).toEqual({
active: true,
view: 'summary',
snapshot,
evidenceCatalog: {
entries: [
Expand All @@ -368,6 +370,181 @@ describe('GetGoalTool', () => {
expect(String(result.llmContent)).not.toContain('must not leak');
expect(result.returnDisplay).toBe('Active goal · revision 3');
});

it('exposes the view parameter and nothing else', () => {
const tool = new GetGoalTool(makeConfig({ getGoalForWorker: vi.fn() }));
expect(tool.schema.parametersJsonSchema).toEqual({
type: 'object',
properties: {
view: {
type: 'string',
enum: ['summary', 'full'],
description: expect.stringContaining('summary (default)'),
},
},
additionalProperties: false,
});
});

// A long-running Goal after a few checkpoints: 32 claims of the maximum
// length, a catalog at its entry cap, and a lineage at its cap.
const LONG_CLAIM = 'C'.repeat(2_000);
const LONG_PREVIEW_ASCII = 'p'.repeat(240);
const LONG_PREVIEW_CJK = '证'.repeat(80); // 240 bytes
const checkpointedGoal = () => ({
goalId: 'goal-1',
revision: 3,
objective: 'Ship Goal v3',
status: 'active' as const,
evidenceCursor: { recordId: 'checkpoint-9' },
turnCount: 40,
activeTimeMs: 120,
tokensUsed: 0,
createdAt: 10,
updatedAt: 20,
evidenceCheckpoint: {
checkpointId: 'checkpoint-9',
createdAt: 15,
claims: Array.from({ length: 32 }, (_, index) => ({
id: `checkpoint-9:${index + 1}`,
proofKind: 'external_fact' as const,
claim: `SECRET_CLAIM_TEXT ${LONG_CLAIM}`,
sourceRefs: Array.from(
{ length: 4 },
(_, ref) => `src-${index}-${ref}`,
),
})),
},
});
const checkpointedCatalog = () => ({
entries: [
...Array.from({ length: 32 }, (_, index) => ({
uuid: `checkpoint-9:${index + 1}`,
provenance: 'goal_checkpoint' as const,
turnId: 'checkpoint:checkpoint-9',
preview: `claim ${index + 1} ${LONG_PREVIEW_ASCII}`.slice(0, 240),
proofKind: 'external_fact' as const,
})),
...Array.from({ length: 60 }, (_, index) => ({
uuid: `earlier-${index}`,
provenance: 'tool_result' as const,
turnId: `earlier-turn-${index % 12}`,
preview: index % 2 === 0 ? LONG_PREVIEW_ASCII : LONG_PREVIEW_CJK,
proofKind: 'external_fact' as const,
})),
{
uuid: 'earlier-short',
provenance: 'tool_result' as const,
turnId: 'earlier-turn-0',
preview: '12 tests passed',
proofKind: 'external_fact' as const,
},
...Array.from({ length: 8 }, (_, index) => ({
uuid: `current-${index}`,
provenance: 'assistant_output' as const,
turnId: permit.turnId,
preview: LONG_PREVIEW_ASCII,
proofKind: 'delivered_output' as const,
})),
],
lineageTurnIds: [
...Array.from({ length: 15 }, (_, index) => `earlier-turn-${index}`),
permit.turnId,
],
truncated: false,
});
const checkpointedTool = () =>
new GetGoalTool(
makeConfig({
getGoalForWorker: vi.fn().mockResolvedValue({
goalId: 'goal-1',
revision: 3,
objective: 'Ship Goal v3',
evidenceCursor: { recordId: 'checkpoint-9' },
evidenceCatalog: checkpointedCatalog(),
}),
getSnapshotForPermit: vi.fn(() => ({
v: 2 as const,
activity: 'running' as const,
goal: checkpointedGoal(),
})),
}),
);
const read = async (params: GetGoalToolParams) => {
const invocation = goalTurnContext.run(permit, () =>
checkpointedTool().build(params),
);
const result = await invocation.execute(new AbortController().signal);
return String(result.llmContent);
};

it('collapses checkpoint claims and shortens earlier previews in the summary view', async () => {
const content = await read({});
const payload = JSON.parse(content);

// The claims' text is the duplicate: each claim is already a catalog entry.
expect(content).not.toContain('SECRET_CLAIM_TEXT');
expect(payload.snapshot.goal.evidenceCheckpoint).toEqual({
checkpointId: 'checkpoint-9',
createdAt: 15,
claimCount: 32,
});
expect(payload.view).toBe('summary');

const entries: Array<{
uuid: string;
turnId: string;
provenance: string;
preview: string;
}> = payload.evidenceCatalog.entries;
// Every uuid survives: the summary changes what is shown, not what is
// citable.
expect(entries.map((entry) => entry.uuid)).toEqual(
checkpointedCatalog().entries.map((entry) => entry.uuid),
);
for (const entry of entries) {
const bytes = Buffer.byteLength(entry.preview, 'utf8');
if (
entry.provenance === 'goal_checkpoint' ||
entry.turnId === permit.turnId
) {
expect(bytes).toBe(240);
} else {
expect(bytes).toBeLessThanOrEqual(80);
}
}
// Multi-byte previews are cut on a code point, not mid-character.
expect(entries.find((entry) => entry.uuid === 'earlier-1')?.preview).toBe(
'证'.repeat(26),
);
// An earlier-turn preview already within the cap passes through
// byte-identical and is not counted as shortened.
expect(
entries.find((entry) => entry.uuid === 'earlier-short')?.preview,
).toBe('12 tests passed');
expect(payload.evidenceCatalog.shortenedPreviews).toBe(60);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The summary fixture gives every earlier-turn entry a 240-byte preview, so the branch where an earlier-turn preview is already within the 80-byte cap — kept unchanged and not counted in shortenedPreviews — is never exercised. A mutation check confirmed the gap: changing if (preview !== entry.preview) shortenedPreviews += 1; to an unconditional shortenedPreviews += 1; leaves the whole suite green (39/39), and the byte-identical pass-through guarantee for short earlier-turn previews is pinned by no assertion. A regression inflating shortenedPreviews would tell the model that a view: "full" read reveals more than it actually would — undermining the read-full decision the field exists to inform.

Add one earlier-turn entry whose preview is already under the cap, keep the count at 60, and pin the pass-through:

// in the checkpointedCatalog fixture:
{ uuid: 'earlier-short', provenance: 'tool_result', turnId: 'earlier-turn-0',
  preview: '12 tests passed', proofKind: 'external_fact' },

// in the summary test — count unchanged:
expect(payload.evidenceCatalog.shortenedPreviews).toBe(60);
// plus an assertion that the short preview is returned byte-identical

The probe verified the new assertion earns its place: the same mutant fails with AssertionError: expected 61 to be 60 once that entry exists, and the un-mutated code stays green with it.

中文说明

摘要视图的 fixture 给每一条更早轮次的条目都设置了 240 字节的 preview,因此「更早轮次的 preview 已经在 80 字节上限之内——保持原样且不记入 shortenedPreviews」这个分支从未被执行。变异检验确认了这个缺口:把 if (preview !== entry.preview) shortenedPreviews += 1; 改成无条件的 shortenedPreviews += 1;,整个测试套件仍然全绿(39/39);短 preview 逐字节原样返回的保证也没有任何断言锁定。一个夸大 shortenedPreviews 的回归会告诉模型 view: "full" 读取能揭示比实际更多的内容——破坏了这个字段本要支撑的「是否值得完整读取」的决策。

修复方式:在 checkpointedCatalog fixture 中增加一条 preview 已低于上限的更早轮次条目(示例见上方英文代码块),计数保持 60,并断言该短 preview 原样返回。探针已验证该断言有效:加入该条目后,同一变异体以 AssertionError: expected 61 to be 60 失败,未变异代码保持全绿。

— qwen3.8-max via Qwen Code /review (v0.22.0)

expect(payload.evidenceCatalog.lineageTurnIds).toHaveLength(16);
});

it('returns the whole checkpoint and catalog in the full view', async () => {
const payload = JSON.parse(await read({ view: 'full' }));

expect(payload.view).toBe('full');
expect(payload.snapshot.goal).toEqual(checkpointedGoal());
expect(payload.evidenceCatalog).toEqual(checkpointedCatalog());
expect(payload.evidenceCatalog).not.toHaveProperty('shortenedPreviews');
});

it('keeps a steady-state summary read under a fixed byte ceiling', async () => {
const summaryBytes = Buffer.byteLength(await read({}), 'utf8');
const fullBytes = Buffer.byteLength(await read({ view: 'full' }), 'utf8');

// The full read of this fixture is what a long Goal paid on every
// get_goal before: the 2,000-character claims alone are ~64 KB.
expect(fullBytes).toBeGreaterThan(100_000);
expect(summaryBytes).toBeLessThanOrEqual(36_000);
expect(fullBytes / summaryBytes).toBeGreaterThanOrEqual(3);
});
});

describe('UpdateGoalTool', () => {
Expand Down
Loading
Loading