Skip to content

Commit 513f374

Browse files
authored
fix(agent-core-v2): validate goal records (#1694)
1 parent b6ae0a1 commit 513f374

5 files changed

Lines changed: 228 additions & 26 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@moonshot-ai/kimi-code": patch
3+
---
4+
5+
Reject malformed persisted goal records during session recovery.

packages/agent-core-v2/src/agent/goal/goalOps.ts

Lines changed: 36 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@ import { z } from 'zod';
3232
import { defineModel } from '#/wire/model';
3333

3434
import type {
35-
GoalActor,
3635
GoalBudgetLimits,
3736
GoalChange,
3837
GoalSnapshot,
@@ -56,6 +55,18 @@ export type GoalModelState = GoalState | null;
5655

5756
export const GoalModel = defineModel<GoalModelState>('goal', () => null);
5857

58+
const GoalStatusSchema = z.enum(['active', 'paused', 'blocked', 'complete']);
59+
60+
const GoalActorSchema = z.enum(['user', 'model', 'runtime', 'system']);
61+
62+
const GoalBudgetLimitsSchema = z
63+
.object({
64+
tokenBudget: z.number().finite().nonnegative().optional(),
65+
turnBudget: z.number().finite().nonnegative().optional(),
66+
wallClockBudgetMs: z.number().finite().nonnegative().optional(),
67+
})
68+
.strict();
69+
5970
declare module '#/app/event/eventBus' {
6071
interface DomainEventMap {
6172
'goal.updated': {
@@ -75,12 +86,17 @@ declare module '#/wire/types' {
7586
}
7687

7788
export const createGoal = GoalModel.defineOp('goal.create', {
78-
schema: z.object({
79-
goalId: z.string(),
80-
objective: z.string(),
81-
completionCriterion: z.string().optional(),
82-
wallClockResumedAt: z.number().optional(),
83-
}),
89+
schema: z
90+
.object({
91+
goalId: z.string(),
92+
objective: z.string(),
93+
completionCriterion: z.string().optional(),
94+
wallClockResumedAt: z.number().finite().nonnegative().optional(),
95+
status: GoalStatusSchema.optional(),
96+
actor: GoalActorSchema.optional(),
97+
budgetLimits: GoalBudgetLimitsSchema.optional(),
98+
})
99+
.strip(),
84100
apply: (_s, p) => ({
85101
goalId: p.goalId,
86102
objective: p.objective,
@@ -95,16 +111,19 @@ export const createGoal = GoalModel.defineOp('goal.create', {
95111
});
96112

97113
export const updateGoal = GoalModel.defineOp('goal.update', {
98-
schema: z.object({
99-
status: z.custom<GoalStatus>().optional(),
100-
reason: z.string().optional(),
101-
turnsUsed: z.number().optional(),
102-
tokensUsed: z.number().optional(),
103-
wallClockMs: z.number().optional(),
104-
wallClockResumedAt: z.number().optional(),
105-
budgetLimits: z.custom<GoalBudgetLimits>().optional(),
106-
actor: z.custom<GoalActor>().optional(),
107-
}),
114+
schema: z
115+
.object({
116+
goalId: z.string().optional(),
117+
status: GoalStatusSchema.optional(),
118+
reason: z.string().optional(),
119+
turnsUsed: z.number().finite().nonnegative().optional(),
120+
tokensUsed: z.number().finite().nonnegative().optional(),
121+
wallClockMs: z.number().finite().nonnegative().optional(),
122+
wallClockResumedAt: z.number().finite().nonnegative().optional(),
123+
budgetLimits: GoalBudgetLimitsSchema.optional(),
124+
actor: GoalActorSchema.optional(),
125+
})
126+
.strip(),
108127
apply: (s, p) => {
109128
if (s === null) return null;
110129
let next: GoalState | undefined;

packages/agent-core-v2/src/wire/record.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,15 @@ export interface WireMetadataRecord extends WireRecord {
2525
readonly created_at: number;
2626
}
2727

28+
export function isWireRecord(record: unknown): record is WireRecord {
29+
return (
30+
record !== null &&
31+
typeof record === 'object' &&
32+
!Array.isArray(record) &&
33+
typeof (record as { type?: unknown }).type === 'string'
34+
);
35+
}
36+
2837
export function createWireMetadataRecord(now = Date.now()): WireMetadataRecord {
2938
return {
3039
type: 'metadata',

packages/agent-core-v2/src/wire/wireService.ts

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import { OP_REGISTRY } from './op';
4141
import {
4242
AGENT_WIRE_RECORD_KEY,
4343
createWireMetadataRecord,
44+
isWireRecord,
4445
isWireMetadataRecord,
4546
opToWireRecord,
4647
wireRecordToPayload,
@@ -149,7 +150,13 @@ export class WireService extends Disposable implements IWireService {
149150
let recordIndex = 0;
150151
let hasRecords = false;
151152

152-
for await (const sourceRecord of source) {
153+
for await (const candidate of source) {
154+
const sourceRecord: unknown = candidate;
155+
if (!isWireRecord(sourceRecord)) {
156+
this.reportSkippedRecord(undefined, recordIndex, true);
157+
recordIndex++;
158+
continue;
159+
}
153160
if (!hasRecords) {
154161
hasRecords = true;
155162
if (sourceRecord.type !== 'metadata') {
@@ -207,21 +214,34 @@ export class WireService extends Disposable implements IWireService {
207214
private replayRecord(record: WireRecord, index: number): void {
208215
const descriptor = OP_REGISTRY.get(record.type);
209216
if (descriptor === undefined) {
210-
onUnexpectedError(
211-
new WireError(
212-
WireErrors.codes.WIRE_UNKNOWN_RECORD,
213-
`Unknown wire record type '${record.type}' skipped during restore`,
214-
{ details: { type: record.type, index } },
215-
),
216-
);
217+
this.reportSkippedRecord(record.type, index);
218+
return;
219+
}
220+
const payload = descriptor.schema.safeParse(wireRecordToPayload(record));
221+
if (!payload.success) {
222+
this.reportSkippedRecord(record.type, index, true);
217223
return;
218224
}
219225
this.execute({
220-
ops: [{ type: record.type, payload: wireRecordToPayload(record), descriptor }],
226+
ops: [{ type: record.type, payload: payload.data, descriptor }],
221227
silent: true,
222228
});
223229
}
224230

231+
private reportSkippedRecord(type: string | undefined, index: number, malformed = false): void {
232+
onUnexpectedError(
233+
new WireError(
234+
WireErrors.codes.WIRE_UNKNOWN_RECORD,
235+
type === undefined
236+
? 'Malformed wire record skipped during restore'
237+
: malformed
238+
? `Malformed wire record type '${type}' skipped during restore`
239+
: `Unknown wire record type '${type}' skipped during restore`,
240+
{ details: { type, index } },
241+
),
242+
);
243+
}
244+
225245
private execute(group: OpGroup): void {
226246
for (const op of group.ops) {
227247
const inst = this.ensureModel(op.descriptor.model);

packages/agent-core-v2/test/agent/goal/goalOps.test.ts

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
99
import { SyncDescriptor } from '#/_base/di/descriptors';
1010
import { DisposableStore } from '#/_base/di/lifecycle';
1111
import { TestInstantiationService } from '#/_base/di/test';
12+
import { resetUnexpectedErrorHandler, setUnexpectedErrorHandler } from '#/_base/errors/unexpectedError';
1213
import { Event } from '#/_base/event';
1314
import { IEventBus } from '#/app/event/eventBus';
1415
import { EventBusService } from '#/app/event/eventBusService';
@@ -264,4 +265,152 @@ describe('AgentGoalService (wire-backed)', () => {
264265
}),
265266
]);
266267
});
268+
269+
it('restores goal records with omitted optional fields from older journals', async () => {
270+
await restoreTestAgentWire(wire, log, testWireScope(SCOPE, KEY), [
271+
{ type: 'goal.create', goalId: 'goal-1', objective: 'work' },
272+
{ type: 'goal.update' },
273+
]);
274+
275+
expect(modelOf(wire)).toMatchObject({
276+
goalId: 'goal-1',
277+
status: 'paused',
278+
budgetLimits: {},
279+
});
280+
});
281+
282+
it('restores legacy goal create audit fields without changing normalized state', async () => {
283+
await restoreTestAgentWire(wire, log, testWireScope(SCOPE, KEY), [
284+
{
285+
type: 'goal.create',
286+
goalId: 'goal-1',
287+
objective: 'work',
288+
status: 'active',
289+
actor: 'user',
290+
budgetLimits: {},
291+
},
292+
]);
293+
294+
expect(modelOf(wire)).toMatchObject({
295+
goalId: 'goal-1',
296+
status: 'paused',
297+
budgetLimits: {},
298+
});
299+
});
300+
301+
it('restores a legacy goal update identity without changing state selection', async () => {
302+
await restoreTestAgentWire(wire, log, testWireScope(SCOPE, KEY), [
303+
{ type: 'goal.create', goalId: 'goal-1', objective: 'work' },
304+
{ type: 'goal.update', goalId: 'goal-1', status: 'blocked', reason: 'waiting' },
305+
]);
306+
307+
expect(modelOf(wire)).toMatchObject({
308+
goalId: 'goal-1',
309+
status: 'blocked',
310+
terminalReason: 'waiting',
311+
});
312+
});
313+
314+
it('strips forward-compatible goal fields during restore', async () => {
315+
await restoreTestAgentWire(wire, log, testWireScope(SCOPE, KEY), [
316+
{
317+
type: 'goal.create',
318+
goalId: 'goal-1',
319+
objective: 'work',
320+
futureField: true,
321+
},
322+
]);
323+
324+
expect(modelOf(wire)).toMatchObject({ goalId: 'goal-1', objective: 'work' });
325+
});
326+
327+
it('skips a goal update with an invalid status during restore', async () => {
328+
const unexpected: unknown[] = [];
329+
setUnexpectedErrorHandler((error) => unexpected.push(error));
330+
try {
331+
await restoreTestAgentWire(wire, log, testWireScope(SCOPE, KEY), [
332+
{ type: 'goal.create', goalId: 'goal-1', objective: 'work' },
333+
{ type: 'goal.update', status: 'cancelled' },
334+
]);
335+
336+
expect(modelOf(wire)).toMatchObject({ status: 'paused' });
337+
expect(unexpected).toContainEqual(
338+
expect.objectContaining({ code: 'wire.unknown_record', details: { type: 'goal.update', index: 1 } }),
339+
);
340+
} finally {
341+
resetUnexpectedErrorHandler();
342+
}
343+
});
344+
345+
it('skips a goal update with an invalid actor during restore', async () => {
346+
const unexpected: unknown[] = [];
347+
setUnexpectedErrorHandler((error) => unexpected.push(error));
348+
try {
349+
await restoreTestAgentWire(wire, log, testWireScope(SCOPE, KEY), [
350+
{ type: 'goal.create', goalId: 'goal-1', objective: 'work' },
351+
{ type: 'goal.update', actor: 'assistant' },
352+
]);
353+
354+
expect(modelOf(wire)).toMatchObject({ status: 'paused' });
355+
expect(unexpected).toContainEqual(
356+
expect.objectContaining({ code: 'wire.unknown_record', details: { type: 'goal.update', index: 1 } }),
357+
);
358+
} finally {
359+
resetUnexpectedErrorHandler();
360+
}
361+
});
362+
363+
it('skips negative and non-finite goal counters and budgets during restore', async () => {
364+
const unexpected: unknown[] = [];
365+
setUnexpectedErrorHandler((error) => unexpected.push(error));
366+
try {
367+
await restoreTestAgentWire(wire, log, testWireScope(SCOPE, KEY), [
368+
{ type: 'goal.create', goalId: 'goal-1', objective: 'work' },
369+
{ type: 'goal.update', turnsUsed: -1 },
370+
{ type: 'goal.update', tokensUsed: Number.POSITIVE_INFINITY },
371+
{ type: 'goal.update', wallClockMs: Number.NaN },
372+
{ type: 'goal.update', wallClockResumedAt: Number.NaN },
373+
{ type: 'goal.update', budgetLimits: { turnBudget: -1 } },
374+
{ type: 'goal.update', budgetLimits: { tokenBudget: Number.POSITIVE_INFINITY } },
375+
{ type: 'goal.update', budgetLimits: { wallClockBudgetMs: Number.NaN } },
376+
]);
377+
378+
expect(modelOf(wire)).toMatchObject({
379+
turnsUsed: 0,
380+
tokensUsed: 0,
381+
wallClockMs: 0,
382+
budgetLimits: {},
383+
});
384+
expect(unexpected).toHaveLength(7);
385+
} finally {
386+
resetUnexpectedErrorHandler();
387+
}
388+
});
389+
390+
it('skips null, arrays, and malformed nested goal records during restore', async () => {
391+
const unexpected: unknown[] = [];
392+
setUnexpectedErrorHandler((error) => unexpected.push(error));
393+
try {
394+
await restoreTestAgentWire(
395+
wire,
396+
log,
397+
testWireScope(SCOPE, KEY),
398+
[
399+
null,
400+
[],
401+
{
402+
type: 'goal.create',
403+
goalId: 'goal-1',
404+
objective: 'work',
405+
budgetLimits: { unexpected: true },
406+
},
407+
] as unknown as WireRecord[],
408+
);
409+
410+
expect(modelOf(wire)).toBeNull();
411+
expect(unexpected).toHaveLength(3);
412+
} finally {
413+
resetUnexpectedErrorHandler();
414+
}
415+
});
267416
});

0 commit comments

Comments
 (0)