Skip to content

Commit 6f5ab1c

Browse files
committed
fix(agentic-kit): harden generate and tool ids
1 parent 9d23b61 commit 6f5ab1c

3 files changed

Lines changed: 220 additions & 21 deletions

File tree

packages/agentic-kit/__tests__/adapter.test.ts

Lines changed: 188 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {
22
AgentKit,
3+
type AssistantMessage,
34
createAssistantMessageEventStream,
45
getMessageText,
56
type ModelDescriptor,
@@ -20,6 +21,29 @@ function createFakeModel(): ModelDescriptor {
2021
};
2122
}
2223

24+
function createAssistantMessage(
25+
overrides: Partial<AssistantMessage> = {}
26+
): AssistantMessage {
27+
return {
28+
role: 'assistant',
29+
api: 'fake-api',
30+
provider: 'fake',
31+
model: 'demo',
32+
usage: {
33+
input: 0,
34+
output: 0,
35+
cacheRead: 0,
36+
cacheWrite: 0,
37+
totalTokens: 0,
38+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
39+
},
40+
stopReason: 'stop',
41+
timestamp: Date.now(),
42+
content: [{ type: 'text', text: 'hello world' }],
43+
...overrides,
44+
};
45+
}
46+
2347
describe('agentic-kit core', () => {
2448
it('transforms cross-provider thinking and inserts orphaned tool results', () => {
2549
const sourceModel = createFakeModel();
@@ -150,6 +174,67 @@ describe('agentic-kit core', () => {
150174
});
151175
});
152176

177+
it('normalizes short mistral tool-call ids without hanging and keeps tool results aligned', () => {
178+
const sourceModel = createFakeModel();
179+
const targetModel: ModelDescriptor = {
180+
...sourceModel,
181+
provider: 'mistral',
182+
api: 'openai-compatible',
183+
id: 'mistral-demo',
184+
compat: {
185+
toolCallIdFormat: 'mistral9',
186+
},
187+
};
188+
189+
const transformed = transformMessages(
190+
[
191+
{
192+
...createAssistantMessage({
193+
api: sourceModel.api,
194+
provider: sourceModel.provider,
195+
model: sourceModel.id,
196+
stopReason: 'toolUse',
197+
content: [{ type: 'toolCall', id: '%', name: 'lookup', arguments: { city: 'Paris' } }],
198+
}),
199+
},
200+
{
201+
role: 'toolResult',
202+
toolCallId: '%',
203+
toolName: 'lookup',
204+
content: [{ type: 'text', text: 'ok' }],
205+
isError: false,
206+
timestamp: Date.now(),
207+
},
208+
],
209+
targetModel
210+
);
211+
212+
expect(transformed).toHaveLength(2);
213+
expect(transformed[0]?.role).toBe('assistant');
214+
expect(transformed[1]).toMatchObject({
215+
role: 'toolResult',
216+
toolName: 'lookup',
217+
isError: false,
218+
});
219+
220+
const assistant = transformed[0];
221+
if (!assistant || assistant.role !== 'assistant') {
222+
throw new Error('Expected assistant message');
223+
}
224+
225+
const toolCall = assistant.content[0];
226+
if (!toolCall || toolCall.type !== 'toolCall') {
227+
throw new Error('Expected tool call content');
228+
}
229+
230+
expect(toolCall.id).toMatch(/^[A-Za-z0-9]{9}$/);
231+
expect(toolCall.id).not.toContain('_');
232+
expect(transformed[1]).toMatchObject({
233+
role: 'toolResult',
234+
toolCallId: toolCall.id,
235+
});
236+
});
237+
153238
it('keeps the legacy AgentKit generate API working through structured streams', async () => {
154239
const provider: ProviderAdapter & { name: string } = {
155240
api: 'fake-api',
@@ -158,23 +243,7 @@ describe('agentic-kit core', () => {
158243
createModel: () => createFakeModel(),
159244
stream: () => {
160245
const stream = createAssistantMessageEventStream();
161-
const message = {
162-
role: 'assistant' as const,
163-
api: 'fake-api',
164-
provider: 'fake',
165-
model: 'demo',
166-
usage: {
167-
input: 0,
168-
output: 0,
169-
cacheRead: 0,
170-
cacheWrite: 0,
171-
totalTokens: 0,
172-
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
173-
},
174-
stopReason: 'stop' as const,
175-
timestamp: Date.now(),
176-
content: [{ type: 'text' as const, text: 'hello world' }],
177-
};
246+
const message = createAssistantMessage();
178247

179248
queueMicrotask(() => {
180249
stream.push({ type: 'start', partial: { ...message, content: [{ type: 'text', text: '' }] } });
@@ -214,6 +283,108 @@ describe('agentic-kit core', () => {
214283
await expect(kit.generate({ model: 'demo', prompt: 'hi' })).resolves.toBe('hello world');
215284
});
216285

286+
it('rejects legacy generate when a provider returns a terminal error in non-stream mode', async () => {
287+
const provider: ProviderAdapter & { name: string } = {
288+
api: 'fake-api',
289+
provider: 'fake',
290+
name: 'fake',
291+
createModel: () => createFakeModel(),
292+
stream: () => {
293+
const stream = createAssistantMessageEventStream();
294+
const failure = createAssistantMessage({
295+
stopReason: 'error',
296+
errorMessage: 'provider failed',
297+
content: [{ type: 'text', text: '' }],
298+
});
299+
300+
queueMicrotask(() => {
301+
stream.push({ type: 'error', reason: 'error', error: failure });
302+
stream.end(failure);
303+
});
304+
305+
return stream;
306+
},
307+
};
308+
309+
const kit = new AgentKit().addProvider(provider);
310+
const onComplete = jest.fn();
311+
const onError = jest.fn();
312+
const onStateChange = jest.fn();
313+
314+
await expect(
315+
kit.generate(
316+
{ model: 'demo', prompt: 'hi' },
317+
{ onComplete, onError, onStateChange }
318+
)
319+
).rejects.toThrow('provider failed');
320+
321+
expect(onError).toHaveBeenCalledTimes(1);
322+
expect(onComplete).not.toHaveBeenCalled();
323+
expect(onStateChange).not.toHaveBeenCalledWith('complete');
324+
});
325+
326+
it('rejects legacy generate when a provider returns a terminal error in stream mode', async () => {
327+
const provider: ProviderAdapter & { name: string } = {
328+
api: 'fake-api',
329+
provider: 'fake',
330+
name: 'fake',
331+
createModel: () => createFakeModel(),
332+
stream: () => {
333+
const stream = createAssistantMessageEventStream();
334+
const partial = createAssistantMessage({
335+
content: [{ type: 'text', text: 'partial' }],
336+
});
337+
const failure = createAssistantMessage({
338+
stopReason: 'error',
339+
errorMessage: 'provider failed',
340+
content: [{ type: 'text', text: 'partial' }],
341+
});
342+
343+
queueMicrotask(() => {
344+
stream.push({ type: 'start', partial: { ...partial, content: [{ type: 'text', text: '' }] } });
345+
stream.push({
346+
type: 'text_start',
347+
contentIndex: 0,
348+
partial: { ...partial, content: [{ type: 'text', text: '' }] },
349+
});
350+
stream.push({
351+
type: 'text_delta',
352+
contentIndex: 0,
353+
delta: 'partial',
354+
partial,
355+
});
356+
stream.push({ type: 'error', reason: 'error', error: failure });
357+
stream.end(failure);
358+
});
359+
360+
return stream;
361+
},
362+
};
363+
364+
const kit = new AgentKit().addProvider(provider);
365+
const chunks: string[] = [];
366+
const onComplete = jest.fn();
367+
const onError = jest.fn();
368+
const onStateChange = jest.fn();
369+
370+
await expect(
371+
kit.generate(
372+
{ model: 'demo', prompt: 'hi', stream: true },
373+
{
374+
onChunk: (chunk) => chunks.push(chunk),
375+
onComplete,
376+
onError,
377+
onStateChange,
378+
}
379+
)
380+
).rejects.toThrow('provider failed');
381+
382+
expect(chunks).toEqual(['partial']);
383+
expect(onStateChange).toHaveBeenCalledWith('streaming');
384+
expect(onComplete).not.toHaveBeenCalled();
385+
expect(onError).toHaveBeenCalledTimes(1);
386+
});
387+
217388
it('extracts assistant text from mixed content blocks', () => {
218389
const text = getMessageText({
219390
role: 'assistant',

packages/agentic-kit/src/index.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ export class AgentKit {
141141
options?.onChunk?.(event.delta);
142142
}
143143
}
144+
assertLegacyGenerateSucceeded(await response.result());
144145
options?.onComplete?.();
145146
} catch (error) {
146147
options?.onError?.(error as Error);
@@ -150,10 +151,11 @@ export class AgentKit {
150151
return;
151152
}
152153

153-
options?.onStateChange?.('complete');
154-
155154
try {
156-
const message = await completeWithProvider(provider, model, context, streamOptions);
155+
const message = assertLegacyGenerateSucceeded(
156+
await completeWithProvider(provider, model, context, streamOptions)
157+
);
158+
options?.onStateChange?.('complete');
157159
const text = getMessageText(message);
158160
options?.onComplete?.();
159161
return text;
@@ -294,3 +296,15 @@ async function completeWithProvider(
294296
function getProviderName(provider: NamedProviderAdapter): string {
295297
return provider.name ?? provider.provider;
296298
}
299+
300+
function assertLegacyGenerateSucceeded(message: AssistantMessage): AssistantMessage {
301+
if (message.stopReason === 'error' || message.stopReason === 'aborted') {
302+
throw new Error(
303+
message.errorMessage ??
304+
(message.stopReason === 'aborted' ? 'Generation aborted.' : 'Generation failed.'),
305+
{ cause: message }
306+
);
307+
}
308+
309+
return message;
310+
}

packages/agentic-kit/src/transform-messages.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ function normalizeToolCallId(id: string, model: ModelDescriptor): string {
141141
return alphanumeric.slice(0, 9);
142142
}
143143

144-
return stableId(id, 9, /^[a-zA-Z0-9]+$/);
144+
return stableAlphanumericId(id, 9);
145145
}
146146

147147
const safe = id.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 64);
@@ -177,3 +177,17 @@ function stableId(input: string, length: number, pattern: RegExp): string {
177177

178178
return value.padEnd(length, '0');
179179
}
180+
181+
function stableAlphanumericId(input: string, length: number): string {
182+
let hash = 0;
183+
for (let i = 0; i < input.length; i += 1) {
184+
hash = (hash * 31 + input.charCodeAt(i)) >>> 0;
185+
}
186+
187+
const value = `tc${hash.toString(36)}`.replace(/[^a-zA-Z0-9]/g, '');
188+
if (value.length >= length) {
189+
return value.slice(0, length);
190+
}
191+
192+
return value.padEnd(length, '0');
193+
}

0 commit comments

Comments
 (0)