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,