Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
165 changes: 165 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,169 @@ 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,
})),
...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),
);
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', () => {
Expand Down
113 changes: 106 additions & 7 deletions packages/core/src/goals/goal-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,24 @@ export interface GoalToolConfig {
getGoalRuntime(): GoalRuntime;
}

export type GetGoalToolParams = Record<string, never>;
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';
Expand Down Expand Up @@ -96,7 +113,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}`,
Expand All @@ -114,11 +136,18 @@ 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 80 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, 80-byte previews for earlier turns. full: the whole catalog and checkpoint verbatim. Uuids are identical in both.',
},
},
additionalProperties: false,
},
);
Expand Down Expand Up @@ -461,15 +490,85 @@ 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<GetGoalToolParams['view']>,
) {
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 clone = structuredClone(snapshot);
if (!clone.goal?.evidenceCheckpoint) return clone;
const { claims, ...checkpoint } = clone.goal.evidenceCheckpoint;
return {
...clone,
goal: {
...clone.goal,
evidenceCheckpoint: { ...checkpoint, claimCount: claims.length },
},
};
}

function summarizeCatalog(
catalog: NonNullable<GoalWorkerView['evidenceCatalog']>,
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 };
});
return {
...structuredClone(catalog),
entries,
...(shortenedPreviews > 0 ? { shortenedPreviews } : {}),
};
}

/** Cut `value` to at most `limit` UTF-8 bytes without splitting a code point. */
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 > limit) break;
byteLength += codePointBytes;
cutoff += codePoint.length;
}
return value.slice(0, cutoff);
}
Loading