diff --git a/packages/core/src/goals/goal-evidence.ts b/packages/core/src/goals/goal-evidence.ts index a1ca93796a3..5c45b3a3d1a 100644 --- a/packages/core/src/goals/goal-evidence.ts +++ b/packages/core/src/goals/goal-evidence.ts @@ -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 ? { @@ -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, })); } @@ -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; } @@ -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 = ''; @@ -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: { diff --git a/packages/core/src/goals/goal-tools.test.ts b/packages/core/src/goals/goal-tools.test.ts index 86d7f0bfb60..6cd5c6d0196 100644 --- a/packages/core/src/goals/goal-tools.test.ts +++ b/packages/core/src/goals/goal-tools.test.ts @@ -15,6 +15,7 @@ import { type GoalTurnHost, } from './goal-runtime.js'; import { + type GetGoalToolParams, GetGoalTool, UpdateGoalTool, type GoalToolConfig, @@ -350,6 +351,7 @@ describe('GetGoalTool', () => { expect(getSnapshotForPermit).toHaveBeenCalledWith(permit); expect(JSON.parse(String(result.llmContent))).toEqual({ active: true, + view: 'summary', snapshot, evidenceCatalog: { entries: [ @@ -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); + 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', () => { diff --git a/packages/core/src/goals/goal-tools.ts b/packages/core/src/goals/goal-tools.ts index 822e76532fb..93ce7622976 100644 --- a/packages/core/src/goals/goal-tools.ts +++ b/packages/core/src/goals/goal-tools.ts @@ -6,7 +6,10 @@ import { ToolDisplayNames, ToolNames } from '../tools/tool-names.js'; import type { ToolInvocation, ToolResult } from '../tools/tools.js'; -import { GOAL_EVIDENCE_REFERENCE_LIMIT } from './goal-evidence.js'; +import { + capPreviewBytes, + GOAL_EVIDENCE_REFERENCE_LIMIT, +} from './goal-evidence.js'; import { BaseDeclarativeTool, BaseToolInvocation, @@ -32,7 +35,24 @@ export interface GoalToolConfig { getGoalRuntime(): GoalRuntime; } -export type GetGoalToolParams = Record; +export interface GetGoalToolParams { + /** + * `summary` (default) keeps the payload small on every read: checkpoint + * claims collapse to a count (their previews are already catalog entries), + * and entries from earlier turns carry previews capped at + * SUMMARY_PREVIEW_BYTE_LIMIT. `full` returns the whole catalog and the + * checkpoint verbatim. Entry uuids are identical in both views. + */ + view?: 'summary' | 'full'; +} + +/** + * Preview bytes an earlier-turn entry keeps in the summary view. Enough to + * recognise what a record is ("12 tests passed", "wrote src/x.ts") without + * re-sending the 240-byte preview on every read of a Goal that has been + * running for a while -- one call used to cost the whole bounded catalog. + */ +const SUMMARY_PREVIEW_BYTE_LIMIT = 80; export interface UpdateGoalToolParams { status: 'complete' | 'blocked'; @@ -96,7 +116,12 @@ class GetGoalInvocation extends BaseToolInvocation< ) { throw staleGoalTurnError(); } - const payload = projectWorkerView(view, snapshot); + const payload = projectWorkerView( + view, + snapshot, + this.permit, + this.params.view ?? 'summary', + ); return { llmContent: JSON.stringify(payload), returnDisplay: `Active goal · revision ${view.revision}`, @@ -114,11 +139,17 @@ export class GetGoalTool extends BaseDeclarativeTool< super( GetGoalTool.Name, ToolDisplayNames.GET_GOAL, - 'Read the current Goal identity, objective, evidence cursor, and bounded evidence-reference catalog for this permitted Goal turn. Outside a permitted Goal turn it reports "active": false together with "lastGoal", a scalar summary (goalId, revision, status, turnCount, activeTimeMs, tokensUsed, and lastReason when one was recorded) of the session\'s most recent Goal, so a Goal that has already stopped can still be inspected. It never returns uncited transcript history or changes Goal state. Use the result silently; do not narrate or acknowledge the retrieval to the user.', + `Read the current Goal identity, objective, evidence cursor, and bounded evidence-reference catalog for this permitted Goal turn. The default "summary" view keeps every read small: checkpoint claims are reported as a count (each claim is already an evidenceCatalog entry with its own preview), entries from this turn and checkpoint entries keep full previews, and entries from earlier turns carry previews shortened to ${SUMMARY_PREVIEW_BYTE_LIMIT} bytes. Every entry uuid is present in both views and is valid for update_goal; request view "full" only when a shortened preview is not enough to decide what to cite. Outside a permitted Goal turn it reports "active": false together with "lastGoal", a scalar summary (goalId, revision, status, turnCount, activeTimeMs, tokensUsed, and lastReason when one was recorded) of the session's most recent Goal, so a Goal that has already stopped can still be inspected. It never returns uncited transcript history or changes Goal state. Use the result silently; do not narrate or acknowledge the retrieval to the user.`, Kind.Read, { type: 'object', - properties: {}, + properties: { + view: { + type: 'string', + enum: ['summary', 'full'], + description: `summary (default): checkpoint claims as a count, full previews only for this turn and checkpoint entries, ${SUMMARY_PREVIEW_BYTE_LIMIT}-byte previews for earlier turns. full: the whole catalog and checkpoint verbatim. Uuids are identical in both.`, + }, + }, additionalProperties: false, }, ); @@ -461,15 +492,77 @@ function staleGoalTurnError(): Error { return new Error(STALE_GOAL_TURN_MESSAGE); } -function projectWorkerView(view: GoalWorkerView, snapshot: GoalSnapshotV2) { +function projectWorkerView( + view: GoalWorkerView, + snapshot: GoalSnapshotV2, + permit: GoalTurnPermit, + detail: NonNullable, +) { + const full = detail === 'full'; return { active: true, - snapshot: structuredClone(snapshot), + view: detail, + snapshot: full ? structuredClone(snapshot) : summarizeSnapshot(snapshot), ...(view.evidenceCatalog - ? { evidenceCatalog: structuredClone(view.evidenceCatalog) } + ? { + evidenceCatalog: full + ? structuredClone(view.evidenceCatalog) + : summarizeCatalog(view.evidenceCatalog, permit), + } : {}), ...(view.verifierFeedback ? { verifierFeedback: view.verifierFeedback } : {}), }; } + +/** + * The checkpoint's claims are the largest thing a Goal record carries -- up to + * 32 claims of up to 2,000 characters -- and every one of them is already in + * the catalog as a `goal_checkpoint` entry with a preview and the same uuid. + * The summary keeps the checkpoint's identity and drops the duplicate text. + */ +function summarizeSnapshot(snapshot: GoalSnapshotV2) { + const goal = snapshot.goal; + const checkpoint = goal?.evidenceCheckpoint; + if (!goal || !checkpoint) return structuredClone(snapshot); + // Collapse the claims to their count before cloning, not after: the claims + // are the bulk of a checkpoint and none of them survives the summary. + const { claims, ...checkpointRest } = checkpoint; + return structuredClone({ + ...snapshot, + goal: { + ...goal, + evidenceCheckpoint: { ...checkpointRest, claimCount: claims.length }, + }, + }); +} + +function summarizeCatalog( + catalog: NonNullable, + permit: GoalTurnPermit, +) { + let shortenedPreviews = 0; + const entries = catalog.entries.map((entry) => { + // Checkpoint claims are the compacted proof of everything before the + // window, and this turn's entries are the ones a proposal cites next; both + // keep their full preview. Earlier turns only need to be recognisable. + if ( + entry.provenance === 'goal_checkpoint' || + entry.turnId === permit.turnId + ) { + return { ...entry }; + } + const preview = capPreviewBytes(entry.preview, SUMMARY_PREVIEW_BYTE_LIMIT); + if (preview !== entry.preview) shortenedPreviews += 1; + return { ...entry, preview }; + }); + // Clone only what survives the summary; the entries above are rebuilt from + // the originals, so cloning them first would allocate and drop the copy. + const { entries: _entries, ...catalogRest } = catalog; + return { + ...structuredClone(catalogRest), + entries, + ...(shortenedPreviews > 0 ? { shortenedPreviews } : {}), + }; +}