diff --git a/packages/core/src/goals/goal-evidence.ts b/packages/core/src/goals/goal-evidence.ts
index b77ff2ffa57..cb50dd3f2af 100644
--- a/packages/core/src/goals/goal-evidence.ts
+++ b/packages/core/src/goals/goal-evidence.ts
@@ -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;
@@ -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(
diff --git a/packages/core/src/goals/goal-protocol.ts b/packages/core/src/goals/goal-protocol.ts
index f9f823653a8..ec13d6526aa 100644
--- a/packages/core/src/goals/goal-protocol.ts
+++ b/packages/core/src/goals/goal-protocol.ts
@@ -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 =
'\nThe Goal is paused. Do not continue its objective unless the user resumes it. Treat this message as ordinary conversation.\n';
diff --git a/packages/core/src/goals/goal-reducer.ts b/packages/core/src/goals/goal-reducer.ts
index b7e54a36f19..33423e7e44e 100644
--- a/packages/core/src/goals/goal-reducer.ts
+++ b/packages/core/src/goals/goal-reducer.ts
@@ -5,6 +5,7 @@
*/
import {
+ GOAL_EVIDENCE_CATALOG_EXHAUSTED_REASON,
GOAL_STATE_VERSION,
type GoalControlRequest,
type GoalRecord,
@@ -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));
}
diff --git a/packages/core/src/goals/goal-runtime.test.ts b/packages/core/src/goals/goal-runtime.test.ts
index f116f6e5f2c..c3f31f67f28 100644
--- a/packages/core/src/goals/goal-runtime.test.ts
+++ b/packages/core/src/goals/goal-runtime.test.ts
@@ -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 = [];
+ 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')],
diff --git a/packages/core/src/goals/goal-runtime.ts b/packages/core/src/goals/goal-runtime.ts
index 66d2762b11e..fbdf9d81cff 100644
--- a/packages/core/src/goals/goal-runtime.ts
+++ b/packages/core/src/goals/goal-runtime.ts
@@ -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
diff --git a/packages/core/src/goals/goal-tools.test.ts b/packages/core/src/goals/goal-tools.test.ts
index 20b6daa0822..8f027742966 100644
--- a/packages/core/src/goals/goal-tools.test.ts
+++ b/packages/core/src/goals/goal-tools.test.ts
@@ -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,
@@ -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 () => {
diff --git a/packages/core/src/goals/goal-tools.ts b/packages/core/src/goals/goal-tools.ts
index 92059a672e6..05c4a3abbad 100644
--- a/packages/core/src/goals/goal-tools.ts
+++ b/packages/core/src/goals/goal-tools.ts
@@ -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) =>
@@ -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,
permit: GoalTurnPermit,