Skip to content

Commit 271fa3e

Browse files
committed
fix(plugins): persist dynamic tool request epochs
1 parent 44309eb commit 271fa3e

15 files changed

Lines changed: 561 additions & 97 deletions

packages/core/src/__tests__/run-composition.test.ts

Lines changed: 35 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -20,41 +20,59 @@
2020
import assert from 'node:assert/strict';
2121
import { test } from 'node:test';
2222
import {
23+
createRequestCompositionSnapshot,
24+
decodeRequestCompositionSnapshot,
2325
decodeRunCompositionSnapshot,
26+
REQUEST_COMPOSITION_SCHEMA_VERSION,
2427
RUN_COMPOSITION_SCHEMA_VERSION,
2528
} from '../run-composition.js';
2629

27-
test('Run Composition snapshots reject ambiguous toolsets and malformed hashes', () => {
30+
test('Run Composition snapshots contain only immutable bootstrap facts', () => {
2831
const valid = {
2932
schemaVersion: RUN_COMPOSITION_SCHEMA_VERSION,
3033
composerId: 'maka.interactive',
3134
composerRevision: '1',
32-
sourceRevisions: [{ id: 'skill-catalog', revision: 'skills-0' }],
33-
baseSystemPromptHash: hash('1'),
34-
toolCatalogHash: hash('2'),
35-
toolAvailabilityHash: hash('3'),
3635
baseProviderOptionsHash: hash('4'),
37-
toolNames: ['Read'],
3836
contextWindow: null,
3937
};
4038

4139
for (const candidate of [
42-
{ ...valid, baseSystemPromptHash: 'sha256:short' },
43-
{ ...valid, toolNames: ['Write', 'Read'] },
44-
{ ...valid, toolNames: ['Read', 'Read'] },
45-
{
46-
...valid,
47-
sourceRevisions: [
48-
{ id: 'skill-catalog', revision: 'skills-0' },
49-
{ id: 'runtime-policy', revision: '1' },
50-
],
51-
},
52-
{ ...valid, sourceRevisions: [{ id: 'skill-catalog', revision: '' }] },
40+
{ ...valid, baseProviderOptionsHash: 'sha256:short' },
41+
{ ...valid, toolNames: ['Read'] },
5342
]) {
5443
assert.throws(() => decodeRunCompositionSnapshot(candidate));
5544
}
5645
});
5746

47+
test('Request Composition snapshots canonicalize complete model-visible tool surfaces', () => {
48+
const snapshot = createRequestCompositionSnapshot(
49+
{
50+
compositionId: 'composition-1',
51+
step: 1,
52+
sourceRevisions: [{ id: 'skill-catalog', revision: 'skills-1' }],
53+
systemPromptHash: hash('1'),
54+
toolCatalogHash: hash('2'),
55+
toolAvailabilityHash: hash('3'),
56+
providerOptionsHash: hash('4'),
57+
toolNames: ['Write', 'Read'],
58+
toolSchemas: [
59+
{ name: 'Write', description: 'write', inputSchema: { type: 'object' } },
60+
{ name: 'Read', description: 'read', inputSchema: { type: 'object' } },
61+
],
62+
},
63+
'change',
64+
);
65+
assert.equal(snapshot.schemaVersion, REQUEST_COMPOSITION_SCHEMA_VERSION);
66+
assert.deepEqual(snapshot.toolNames, ['Read', 'Write']);
67+
assert.deepEqual(
68+
snapshot.toolSchemas.map((schema) => schema.name),
69+
['Read', 'Write'],
70+
);
71+
assert.throws(() =>
72+
decodeRequestCompositionSnapshot({ ...snapshot, toolNames: ['Read', 'Read'] }),
73+
);
74+
});
75+
5876
function hash(seed: string): `sha256:${string}` {
5977
return `sha256:${seed.repeat(64)}`;
6078
}

packages/core/src/agent-run.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,7 @@ export interface AgentRunHeader {
183183
agentSwarmAuthorization?: AgentSwarmAuthorizationSource;
184184
/** Effective tool protocol for this run. Optional on legacy runs. */
185185
toolMode?: ToolMode;
186-
/** Immutable composer-owned prompt and tool-surface snapshot committed before provider dispatch. */
186+
/** Immutable composer and provider bootstrap baseline committed before provider dispatch. */
187187
runComposition?: RunCompositionSnapshot;
188188
createdAt: number;
189189
updatedAt: number;
@@ -424,6 +424,7 @@ export const AGENT_RUN_EVENT_TYPES = [
424424
'sandbox_denial_detected',
425425
'provider_request_captured',
426426
'provider_request_attempt_recorded',
427+
'request_composition_resolved',
427428
'model_call_attempt_recorded',
428429
'history_compact_checkpoint_recorded',
429430
'model_projection_transition_recorded',

packages/core/src/model-call-attempt.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,8 @@ export interface ModelCallAttempt {
155155

156156
/** Runtime tool-loop step index within the turn. */
157157
step: number;
158+
/** Logical request-composition snapshot used by this step and all of its retries. */
159+
requestCompositionId?: string;
158160
/** Retry ordinal within the logical call; 0 is the first dispatch. */
159161
attempt: number;
160162

@@ -224,6 +226,7 @@ const MODEL_CALL_ATTEMPT_SHAPE = defineObjectShape<ModelCallAttempt>()(
224226
[
225227
'connectionSlug',
226228
'historyCompactRoute',
229+
'requestCompositionId',
227230
'contextWindow',
228231
'captureArtifactId',
229232
'requestObservation',
@@ -410,6 +413,7 @@ export function decodeModelCallAttempt(value: unknown): ModelCallAttempt {
410413
isNonEmptyString(value.runId) &&
411414
isNonEmptyString(value.turnId) &&
412415
isNonNegativeInteger(value.step) &&
416+
isOptionalString(value.requestCompositionId) &&
413417
isNonNegativeInteger(value.attempt) &&
414418
(MODEL_CALL_KINDS as readonly unknown[]).includes(value.callKind) &&
415419
(value.historyCompactRoute === undefined ||

packages/core/src/run-composition.ts

Lines changed: 113 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@
1919

2020
import { defineObjectShape, hasExactShape, isRecord } from './record-schema.js';
2121

22-
export const RUN_COMPOSITION_SCHEMA_VERSION = 1 as const;
22+
export const RUN_COMPOSITION_SCHEMA_VERSION = 2 as const;
23+
export const REQUEST_COMPOSITION_SCHEMA_VERSION = 1 as const;
2324

2425
export interface RunCompositionSourceRevision {
2526
readonly id: string;
@@ -30,30 +31,60 @@ export interface RunCompositionSnapshot {
3031
readonly schemaVersion: typeof RUN_COMPOSITION_SCHEMA_VERSION;
3132
readonly composerId: string;
3233
readonly composerRevision: string;
34+
readonly baseProviderOptionsHash: `sha256:${string}`;
35+
readonly contextWindow: number | null;
36+
}
37+
38+
export interface RequestCompositionToolSchema {
39+
readonly name: string;
40+
readonly description: string;
41+
readonly inputSchema: Record<string, unknown>;
42+
readonly providerTool?: Record<string, unknown>;
43+
}
44+
45+
/**
46+
* One model-visible request surface, frozen at a logical model-step boundary.
47+
* A later step appends a new snapshot only when one of these effective fields
48+
* changes; physical retries of the same step keep referring to this snapshot.
49+
*/
50+
export interface RequestCompositionSnapshot {
51+
readonly schemaVersion: typeof REQUEST_COMPOSITION_SCHEMA_VERSION;
52+
readonly compositionId: string;
53+
readonly step: number;
54+
readonly reason: 'initial' | 'change';
3355
readonly sourceRevisions: readonly RunCompositionSourceRevision[];
34-
readonly baseSystemPromptHash: `sha256:${string}`;
56+
readonly systemPromptHash: `sha256:${string}`;
3557
readonly toolCatalogHash: `sha256:${string}`;
3658
readonly toolAvailabilityHash: `sha256:${string}`;
37-
readonly baseProviderOptionsHash: `sha256:${string}`;
59+
readonly providerOptionsHash: `sha256:${string}`;
3860
readonly toolNames: readonly string[];
39-
readonly contextWindow: number | null;
61+
readonly toolSchemas: readonly RequestCompositionToolSchema[];
4062
}
4163

4264
const RUN_COMPOSITION_SHAPE = defineObjectShape<RunCompositionSnapshot>()(
65+
['schemaVersion', 'composerId', 'composerRevision', 'baseProviderOptionsHash', 'contextWindow'],
66+
[],
67+
);
68+
const REQUEST_COMPOSITION_SHAPE = defineObjectShape<RequestCompositionSnapshot>()(
4369
[
4470
'schemaVersion',
45-
'composerId',
46-
'composerRevision',
71+
'compositionId',
72+
'step',
73+
'reason',
4774
'sourceRevisions',
48-
'baseSystemPromptHash',
75+
'systemPromptHash',
4976
'toolCatalogHash',
5077
'toolAvailabilityHash',
51-
'baseProviderOptionsHash',
78+
'providerOptionsHash',
5279
'toolNames',
53-
'contextWindow',
80+
'toolSchemas',
5481
],
5582
[],
5683
);
84+
const REQUEST_COMPOSITION_TOOL_SCHEMA_SHAPE = defineObjectShape<RequestCompositionToolSchema>()(
85+
['name', 'description', 'inputSchema'],
86+
['providerTool'],
87+
);
5788

5889
const ID_PATTERN = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/;
5990
const HASH_PATTERN = /^sha256:[a-f0-9]{64}$/;
@@ -70,23 +101,12 @@ export function decodeRunCompositionSnapshot(value: unknown): RunCompositionSnap
70101
value.schemaVersion === RUN_COMPOSITION_SCHEMA_VERSION &&
71102
boundedMatchingString(value.composerId, ID_PATTERN, 128) &&
72103
boundedString(value.composerRevision, 128) &&
73-
canonicalSourceRevisions(value.sourceRevisions) &&
74-
hash(value.baseSystemPromptHash) &&
75-
hash(value.toolCatalogHash) &&
76-
hash(value.toolAvailabilityHash) &&
77104
hash(value.baseProviderOptionsHash) &&
78-
canonicalToolNames(value.toolNames) &&
79105
(value.contextWindow === null ||
80106
(Number.isSafeInteger(value.contextWindow) && (value.contextWindow as number) > 0));
81107
if (!valid) throw new Error('Invalid Run Composition snapshot schema');
82108
return Object.freeze({
83109
...(value as unknown as RunCompositionSnapshot),
84-
sourceRevisions: Object.freeze(
85-
(value.sourceRevisions as RunCompositionSourceRevision[]).map((source) =>
86-
Object.freeze({ ...source }),
87-
),
88-
),
89-
toolNames: Object.freeze([...(value.toolNames as string[])]),
90110
});
91111
}
92112

@@ -98,10 +118,63 @@ export function createRunCompositionSnapshot(
98118
return decodeRunCompositionSnapshot({
99119
schemaVersion: RUN_COMPOSITION_SCHEMA_VERSION,
100120
...input,
121+
});
122+
}
123+
124+
export type RequestCompositionSnapshotInput = Omit<
125+
RequestCompositionSnapshot,
126+
'schemaVersion' | 'reason'
127+
>;
128+
129+
export function createRequestCompositionSnapshot(
130+
input: RequestCompositionSnapshotInput,
131+
reason: RequestCompositionSnapshot['reason'],
132+
): RequestCompositionSnapshot {
133+
return decodeRequestCompositionSnapshot({
134+
schemaVersion: REQUEST_COMPOSITION_SCHEMA_VERSION,
135+
...input,
136+
reason,
101137
sourceRevisions: [...input.sourceRevisions].sort((left, right) =>
102138
compareExactString(left.id, right.id),
103139
),
104140
toolNames: [...input.toolNames].sort(compareExactString),
141+
toolSchemas: [...input.toolSchemas].sort((left, right) =>
142+
compareExactString(left.name, right.name),
143+
),
144+
});
145+
}
146+
147+
export function decodeRequestCompositionSnapshot(value: unknown): RequestCompositionSnapshot {
148+
if (!isRecord(value) || !hasExactShape(value, REQUEST_COMPOSITION_SHAPE)) {
149+
throw new Error('Invalid Request Composition snapshot schema');
150+
}
151+
const valid =
152+
value.schemaVersion === REQUEST_COMPOSITION_SCHEMA_VERSION &&
153+
boundedString(value.compositionId, 128) &&
154+
Number.isSafeInteger(value.step) &&
155+
(value.step as number) >= 0 &&
156+
(value.reason === 'initial' || value.reason === 'change') &&
157+
canonicalSourceRevisions(value.sourceRevisions) &&
158+
hash(value.systemPromptHash) &&
159+
hash(value.toolCatalogHash) &&
160+
hash(value.toolAvailabilityHash) &&
161+
hash(value.providerOptionsHash) &&
162+
canonicalToolNames(value.toolNames) &&
163+
canonicalToolSchemas(value.toolSchemas);
164+
if (!valid) throw new Error('Invalid Request Composition snapshot schema');
165+
return Object.freeze({
166+
...(value as unknown as RequestCompositionSnapshot),
167+
sourceRevisions: Object.freeze(
168+
(value.sourceRevisions as RunCompositionSourceRevision[]).map((source) =>
169+
Object.freeze({ ...source }),
170+
),
171+
),
172+
toolNames: Object.freeze([...(value.toolNames as string[])]),
173+
toolSchemas: Object.freeze(
174+
(value.toolSchemas as RequestCompositionToolSchema[]).map((schema) =>
175+
Object.freeze(structuredClone(schema)),
176+
),
177+
),
105178
});
106179
}
107180

@@ -137,6 +210,26 @@ function canonicalToolNames(value: unknown): value is string[] {
137210
return true;
138211
}
139212

213+
function canonicalToolSchemas(value: unknown): value is RequestCompositionToolSchema[] {
214+
if (!Array.isArray(value) || value.length > 512) return false;
215+
let previous: string | undefined;
216+
for (const schema of value) {
217+
if (
218+
!isRecord(schema) ||
219+
!hasExactShape(schema, REQUEST_COMPOSITION_TOOL_SCHEMA_SHAPE) ||
220+
!boundedString(schema.name, 256) ||
221+
!boundedString(schema.description, 16_384) ||
222+
!isRecord(schema.inputSchema) ||
223+
(schema.providerTool !== undefined && !isRecord(schema.providerTool)) ||
224+
(previous !== undefined && previous >= schema.name)
225+
) {
226+
return false;
227+
}
228+
previous = schema.name;
229+
}
230+
return true;
231+
}
232+
140233
function hash(value: unknown): value is `sha256:${string}` {
141234
return typeof value === 'string' && HASH_PATTERN.test(value);
142235
}

0 commit comments

Comments
 (0)