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
11 changes: 6 additions & 5 deletions packages/core/src/goals/goal-evidence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@
*/

import type { Part } from '@google/genai';
import type {
GoalRecord,
GoalTerminalProposal,
GoalTurnPermit,
import {
GOAL_EVIDENCE_CATALOG_EXHAUSTED_REASON,
type GoalRecord,
type GoalTerminalProposal,
type GoalTurnPermit,
} from './goal-protocol.js';

const CATALOG_PREVIEW_LIMIT = 240;
Expand Down Expand Up @@ -179,7 +180,7 @@ export function validateGoalEvidenceReferences(
if (input.proposal.status === 'complete' && analysis.catalogTruncated) {
throw new InvalidGoalEvidenceReferenceError(
'catalog_truncated',
'A complete Goal proposal requires an exhaustive bounded evidence catalog.',
GOAL_EVIDENCE_CATALOG_EXHAUSTED_REASON,
);
}
const evidenceBytes = citedRecords.reduce(
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/goals/goal-protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
export const GOAL_STATE_VERSION = 2 as const;
export const GOAL_PROPOSAL_REASON_MAX_CHARACTERS = 8_000;
export const GOAL_PROPOSAL_REASON_MAX_BYTES = 16_000;
export const GOAL_EVIDENCE_CATALOG_EXHAUSTED_REASON =
'The current Goal revision exceeded the bounded evidence catalog. Automatic retries cannot recover. Edit or replace the Goal before resuming it.';

export const PAUSED_GOAL_SYSTEM_REMINDER =
'<system-reminder>\nThe Goal is paused. Do not continue its objective unless the user resumes it. Treat this message as ordinary conversation.\n</system-reminder>';
Expand Down
10 changes: 10 additions & 0 deletions packages/core/src/goals/goal-reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*/

import {
GOAL_EVIDENCE_CATALOG_EXHAUSTED_REASON,
GOAL_STATE_VERSION,
type GoalControlRequest,
type GoalRecord,
Expand Down Expand Up @@ -122,6 +123,15 @@ export function reduceGoalControl(
snapshotOf(current),
);
}
if (
current.status === 'usage_limited' &&
current.lastReason === GOAL_EVIDENCE_CATALOG_EXHAUSTED_REASON
) {
throw new GoalInvalidTransitionError(
'An evidence-limited Goal cannot be resumed; edit or replace the Goal first',
snapshotOf(current),
);
}
if (request.action !== 'resume') {
return assertNever(request, snapshotOf(current));
}
Expand Down
81 changes: 81 additions & 0 deletions packages/core/src/goals/goal-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,87 @@ describe('goal runtime', () => {
expect(host.started).toHaveLength(2);
});

it('stops continuations when completion evidence exceeds the catalog', async () => {
const journal = fakeGoalJournal();
let records: readonly RuntimeRecord[] = [];
const evidenceSource = fakeEvidenceSource(() => records);
const verifier: GoalVerifier = vi.fn();
const host = fakeGoalTurnHost();
const runtime = createGoalRuntime({ journal, evidenceSource, verifier });
runtime.bindHost(host);
await runtime.dispatch({ action: 'create', objective: 'deliver result' });
const permit = host.started[0];
const cursorId = runtime.getSnapshot().goal!.evidenceCursor.recordId!;
records = [
verifierEvidenceRecords(permit, cursorId)[0]!,
...Array.from({ length: 101 }, (_, index) => ({
...verifierEvidenceRecords(
permit,
cursorId,
`assistant-evidence-${index}`,
)[1]!,
message: {
role: 'model',
parts: [{ text: `Delivered result ${index}` }],
},
})),
];
runtime.recordTerminalProposal(permit, {
status: 'complete',
reason: 'Delivered',
evidenceRefs: ['assistant-evidence-100'],
});
const causes: Array<GoalStateCause | undefined> = [];
runtime.subscribe((_snapshot, cause) => causes.push(cause));

await runtime.finishTurn(permit);

expect(verifier).not.toHaveBeenCalled();
expect(runtime.getSnapshot()).toMatchObject({
activity: 'idle',
goal: {
status: 'usage_limited',
lastReason: expect.stringContaining('bounded evidence catalog'),
},
});
expect(journal.appended.map((payload) => payload.cause)).toEqual([
'create',
'turn_finished',
'usage_limited',
]);
expect(causes).toEqual(['turn_finished', 'usage_limited']);
expect(host.started).toHaveLength(1);

await expect(
runtime.dispatch({
action: 'resume',
expectedGoalId: permit.goalId,
expectedRevision: permit.revision,
}),
).rejects.toThrow('edit or replace');
expect(host.started).toHaveLength(1);

const edited = await runtime.dispatch({
action: 'edit',
objective: 'deliver result',
expectedGoalId: permit.goalId,
expectedRevision: permit.revision,
});
expect(edited.snapshot.goal).toMatchObject({
status: 'usage_limited',
revision: 2,
lastReason: undefined,
});
expect(edited.snapshot.goal?.evidenceCursor.recordId).not.toBe(cursorId);
await runtime.dispatch({
action: 'resume',
expectedGoalId: permit.goalId,
expectedRevision: 2,
});
expect(runtime.getSnapshot().goal?.status).toBe('active');
expect(host.started).toHaveLength(2);
});

it.each([
['flush', new Error('flush failed')],
['read', new Error('read failed')],
Expand Down
11 changes: 7 additions & 4 deletions packages/core/src/goals/goal-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -583,10 +583,13 @@ export function createGoalRuntime(
} catch (error) {
if (attempt.controller.signal.aborted) return;
if (error instanceof InvalidGoalEvidenceReferenceError) {
outcome = {
kind: 'decision',
result: { decision: 'reject', reason: error.message },
};
outcome =
error.code === 'catalog_truncated'
? { kind: 'usage_limited', reason: error.message }
: {
kind: 'decision',
result: { decision: 'reject', reason: error.message },
};
} else {
const reason =
error instanceof EvidenceSourceUnavailableError
Expand Down
15 changes: 9 additions & 6 deletions packages/core/src/goals/goal-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -387,8 +387,11 @@ describe('UpdateGoalTool', () => {
expect(recordTerminalProposal).not.toHaveBeenCalled();
});

it('rejects completion when the evidence catalog is truncated', async () => {
const recordTerminalProposal = vi.fn();
it('queues truncated completion for boundary classification', async () => {
const recordTerminalProposal = vi.fn(() => ({
recorded: true,
readyForVerification: true,
}));
const runtime = {
getGoalForWorker: vi.fn().mockResolvedValue({
goalId: permit.goalId,
Expand Down Expand Up @@ -437,11 +440,11 @@ describe('UpdateGoalTool', () => {
const result = await invocation.execute(new AbortController().signal);

expect(JSON.parse(String(result.llmContent))).toMatchObject({
proposalRecorded: false,
readyForVerification: false,
error: expect.stringContaining('not provably exhaustive'),
proposalRecorded: true,
readyForVerification: true,
});
expect(recordTerminalProposal).not.toHaveBeenCalled();
expect(result.terminateTurn).toBe(true);
expect(recordTerminalProposal).toHaveBeenCalledOnce();
});

it('records one proposal while leaving the Goal active', async () => {
Expand Down
21 changes: 0 additions & 21 deletions packages/core/src/goals/goal-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,12 +183,6 @@ class UpdateGoalInvocation extends BaseToolInvocation<
};
}
const citedEvidenceRefs = new Set(normalizedEvidenceRefs);
if (
this.params.status === 'complete' &&
view.evidenceCatalog?.truncated
) {
return truncatedCatalogResult();
}
const uncitedCurrentDeliveredOutput = evidenceEntries
.filter(
(entry) =>
Expand Down Expand Up @@ -381,21 +375,6 @@ async function workerViewForPermit(
}
}

function truncatedCatalogResult(): GoalToolResult {
const error =
'The bounded evidence catalog is truncated, so current-turn output is not provably exhaustive. Continue in a new Goal turn with a smaller evidence set.';
return {
llmContent: JSON.stringify({
proposalRecorded: false,
readyForVerification: false,
goalLifecycleChanged: false,
error,
}),
returnDisplay:
'Goal proposal was not recorded because its evidence catalog is truncated.',
};
}

function recordTerminalProposalForPermit(
runtime: Pick<GoalRuntime, 'recordTerminalProposal'>,
permit: GoalTurnPermit,
Expand Down
Loading