Skip to content
2 changes: 2 additions & 0 deletions packages/cli/src/i18n/locales/en.js
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,8 @@ export default {
'toolDisplayName.Shell': 'toolDisplayName.Shell',
'toolDisplayName.Shell Command': 'toolDisplayName.Shell Command',
'toolDisplayName.TodoList': 'toolDisplayName.TodoList',
'toolDisplayName.Goal': 'toolDisplayName.Goal',
'toolDisplayName.UpdateGoal': 'toolDisplayName.UpdateGoal',
'toolDisplayName.SaveMemory': 'toolDisplayName.SaveMemory',
'toolDisplayName.Agent': 'toolDisplayName.Agent',
'toolDisplayName.Artifact': 'toolDisplayName.Artifact',
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/i18n/locales/zh-TW.js
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,8 @@ export default {
'toolDisplayName.Shell': '運行命令',
'toolDisplayName.Shell Command': 'Shell 命令',
'toolDisplayName.TodoList': '任務清單',
'toolDisplayName.Goal': '目標',
'toolDisplayName.UpdateGoal': '更新目標',
'toolDisplayName.SaveMemory': '儲存記憶',
'toolDisplayName.Agent': 'Agent',
'toolDisplayName.Artifact': '製品',
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/i18n/locales/zh.js
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,8 @@ export default {
'toolDisplayName.Shell': '运行命令',
'toolDisplayName.Shell Command': 'Shell 命令',
'toolDisplayName.TodoList': '任务清单',
'toolDisplayName.Goal': '目标',
'toolDisplayName.UpdateGoal': '更新目标',
'toolDisplayName.SaveMemory': '保存记忆',
'toolDisplayName.Agent': 'Agent',
'toolDisplayName.Artifact': '制品',
Expand Down
15 changes: 15 additions & 0 deletions packages/core/src/goals/goal-protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
*/

export const GOAL_STATE_VERSION = 2 as const;
export const GOAL_PROPOSAL_REASON_MAX_CHARACTERS = 8_000;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Cover the inclusive reason boundaries

The tests exercise short valid values and values just over these limits, but not exactly 8,000 characters or 16,000 UTF-8 bytes. An accidental > to >= change would reject documented maximum-sized reasons while the current tests still pass. Add exact-boundary acceptance cases for both limits.

— Codex GPT-5 via Qwen Code /review

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已修复

验证证据:commit a0581d2 新增 8,000 字符与 16,000 UTF-8 字节精确边界接受用例;Goal 回归 280/280 通过。

export const GOAL_PROPOSAL_REASON_MAX_BYTES = 16_000;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] Keep valid inputs within the verifier request budget

A valid 24,000-byte delivered-output evidence record is serialized both in evidence and currentDeliveredOutput; adding a valid 16,000-byte reason already reaches roughly 64,000 bytes before goal, proposal, and evidence metadata. The verifier then throws GoalVerifierInputTooLargeError, and the Goal becomes usage_limited even though every individual input satisfies its limit. Budget these limits against the fully serialized request or avoid duplicating delivered output.

— Codex GPT-5 via Qwen Code /review

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已修复

验证证据:commit a0581d2 用 currentTurnId 标识当前轮 delivered_output,移除 evidence content 的重复序列化;24,000-byte evidence + 16,000-byte reason 的回归用例通过,Goal 回归 280/280、build/typecheck 通过。


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 Expand Up @@ -99,6 +101,19 @@ export interface GoalTerminalProposal {
blockerKind?: 'authority' | 'external' | 'repeated';
}

export function validateGoalProposalReason(reason: string): string | null {
if (!reason.trim()) return 'Goal proposal reason must not be empty';
if ([...reason].length > GOAL_PROPOSAL_REASON_MAX_CHARACTERS) {
return `Goal proposal reason exceeds ${GOAL_PROPOSAL_REASON_MAX_CHARACTERS} characters`;
}
if (
new TextEncoder().encode(reason).byteLength > GOAL_PROPOSAL_REASON_MAX_BYTES
) {
return `Goal proposal reason exceeds ${GOAL_PROPOSAL_REASON_MAX_BYTES} UTF-8 bytes`;
}
return null;
}

export type GoalStateCause =
| 'create'
| 'replace'
Expand Down
49 changes: 42 additions & 7 deletions packages/core/src/goals/goal-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,13 @@
import { describe, expect, it, vi } from 'vitest';
import type { GoalEvidenceRecord } from './goal-evidence.js';
import type { GoalRecoveryRecord } from './goal-persistence.js';
import type {
GoalSnapshotV2,
GoalStateCause,
GoalStateRecordPayloadV2,
GoalTurnPermit,
TranscriptCursor,
import {
GOAL_PROPOSAL_REASON_MAX_BYTES,
type GoalSnapshotV2,
type GoalStateCause,
type GoalStateRecordPayloadV2,
type GoalTurnPermit,
type TranscriptCursor,
} from './goal-protocol.js';
import {
createGoalRuntime,
Expand Down Expand Up @@ -1538,7 +1539,7 @@ describe('goal runtime', () => {
});
});

it('returns a defensive worker view and rejects it after permit invalidation', async () => {
it('returns defensive worker state and checks the complete permit atomically', async () => {
const journal = fakeGoalJournal();
const host = fakeGoalTurnHost();
const runtime = createGoalRuntime({ journal });
Expand All @@ -1547,12 +1548,20 @@ describe('goal runtime', () => {
const permit = host.started[0];

const view = await runtime.getGoalForWorker(permit);
const permittedSnapshot = runtime.getSnapshotForPermit(permit);
view.objective = 'mutated';
view.evidenceCursor.recordId = 'mutated';
permittedSnapshot.goal!.objective = 'mutated snapshot';
expect(runtime.getSnapshot().goal).toMatchObject({
objective: 'ship',
evidenceCursor: { recordId: expect.not.stringContaining('mutated') },
});
expect(() =>
runtime.getSnapshotForPermit({
...permit,
turnId: 'different-turn',
}),
).toThrow('Goal turn permit is no longer valid');

await runtime.dispatch({
action: 'edit',
Expand All @@ -1563,6 +1572,32 @@ describe('goal runtime', () => {
await expect(runtime.getGoalForWorker(permit)).rejects.toThrow(
'Goal turn permit is no longer valid',
);
expect(() => runtime.getSnapshotForPermit(permit)).toThrow(
'Goal turn permit is no longer valid',
);
});

it('rejects an oversized proposal reason before consuming the turn proposal slot', async () => {
const host = fakeGoalTurnHost();
const runtime = createGoalRuntime({ journal: fakeGoalJournal() });
runtime.bindHost(host);
await runtime.dispatch({ action: 'create', objective: 'ship' });
const permit = host.started[0];

expect(() =>
runtime.recordTerminalProposal(permit, {
status: 'complete',
reason: '界'.repeat(Math.floor(GOAL_PROPOSAL_REASON_MAX_BYTES / 3) + 1),
evidenceRefs: ['oversized'],
}),
).toThrow(/UTF-8 bytes/i);
expect(
runtime.recordTerminalProposal(permit, {
status: 'complete',
reason: 'valid reason',
evidenceRefs: ['valid'],
}),
).toEqual({ recorded: true, readyForVerification: true });
});

it('requires three repeated blocked turns and resets that audit on resume', async () => {
Expand Down
28 changes: 22 additions & 6 deletions packages/core/src/goals/goal-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
type GoalTerminalProposal,
type GoalTurnPermit,
type TranscriptCursor,
validateGoalProposalReason,
} from './goal-protocol.js';
import {
elapsedActiveTime,
Expand All @@ -40,6 +41,9 @@ import {
type GoalRecoveryRecord,
} from './goal-persistence.js';

export const GOAL_RUNTIME_DISPOSED_MESSAGE = 'Goal runtime has been disposed';
export const STALE_GOAL_TURN_MESSAGE = 'Goal turn permit is no longer valid';

export interface GoalJournal {
getTranscriptCursor(): TranscriptCursor;
recordGoalState(
Expand Down Expand Up @@ -89,6 +93,7 @@ export interface GoalPendingProposal {

export interface GoalRuntime {
getSnapshot(): GoalSnapshotV2;
getSnapshotForPermit(permit: GoalTurnPermit): GoalSnapshotV2;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] Avoid breaking existing GoalRuntime implementers

GoalRuntime is publicly re-exported, and adding this required method makes every downstream class, mock, or object literal implementing the previous interface fail TypeScript compilation. The tool adapter already checks this capability at runtime, so preserve source compatibility by making it optional there or introduce a separate extended interface for permit-scoped snapshots.

— Codex GPT-5 via Qwen Code /review

subscribe(
listener: (snapshot: GoalSnapshotV2, cause?: GoalStateCause) => void,
): () => void;
Expand Down Expand Up @@ -162,7 +167,7 @@ export function createGoalRuntime(
type VerificationAttempt = NonNullable<typeof verificationAttempt>;

const assertAvailable = () => {
if (disposed) throw new Error('Goal runtime has been disposed');
if (disposed) throw new Error(GOAL_RUNTIME_DISPOSED_MESSAGE);
};

const assertOperational = () => {
Expand Down Expand Up @@ -295,6 +300,14 @@ export function createGoalRuntime(
currentPermit.revision === permit.revision &&
currentPermit.turnId === permit.turnId;

const getSnapshotForPermit = (permit: GoalTurnPermit): GoalSnapshotV2 => {
assertOperational();
if (!isCurrentPermit(permit) || !snapshot.goal) {
throw new Error(STALE_GOAL_TURN_MESSAGE);
}
return getSnapshot();
};

const isCurrentVerificationAttempt = (attempt: VerificationAttempt) =>
verificationAttempt === attempt &&
snapshot.goal?.goalId === attempt.permit.goalId &&
Expand Down Expand Up @@ -531,6 +544,7 @@ export function createGoalRuntime(

return {
getSnapshot,
getSnapshotForPermit,
subscribe(
listener: (value: GoalSnapshotV2, cause?: GoalStateCause) => void,
): () => void {
Expand Down Expand Up @@ -659,7 +673,7 @@ export function createGoalRuntime(
getVerifierFeedback(permit: GoalTurnPermit): string | undefined {
assertOperational();
if (!isCurrentPermit(permit)) {
throw new Error('Goal turn permit is no longer valid');
throw new Error(STALE_GOAL_TURN_MESSAGE);
}
return currentTurnFeedback;
},
Expand All @@ -668,7 +682,7 @@ export function createGoalRuntime(
async (): Promise<VerificationAttempt | undefined> => {
assertOperational();
if (!isCurrentPermit(permit) || !snapshot.goal) {
throw new Error('Goal turn permit is no longer valid');
throw new Error(STALE_GOAL_TURN_MESSAGE);
}
const recordUuid = randomUUID();
const nextGoal = reduceGoalTurnFinished(snapshot.goal, {
Expand Down Expand Up @@ -754,7 +768,7 @@ export function createGoalRuntime(
async getGoalForWorker(permit: GoalTurnPermit): Promise<GoalWorkerView> {
assertOperational();
if (!isCurrentPermit(permit) || !snapshot.goal) {
throw new Error('Goal turn permit is no longer valid');
throw new Error(STALE_GOAL_TURN_MESSAGE);
}
const goal = structuredClone(snapshot.goal);
const verifierFeedback = currentTurnFeedback;
Expand All @@ -776,7 +790,7 @@ export function createGoalRuntime(
permit,
});
if (!isCurrentPermit(permit) || !snapshot.goal) {
throw new Error('Goal turn permit is no longer valid');
throw new Error(STALE_GOAL_TURN_MESSAGE);
}
return {
goalId: goal.goalId,
Expand All @@ -793,8 +807,10 @@ export function createGoalRuntime(
): GoalProposalReceipt {
assertOperational();
if (!isCurrentPermit(permit)) {
throw new Error('Goal turn permit is no longer valid');
throw new Error(STALE_GOAL_TURN_MESSAGE);
}
const reasonError = validateGoalProposalReason(proposal.reason);
if (reasonError) throw new Error(reasonError);
if (currentProposal) {
return {
recorded: false,
Expand Down
Loading
Loading