Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@ai_kit/core",
"version": "1.8.0",
"version": "1.9.0",
"description": "",
"type": "module",
"main": "./dist/index.js",
Expand Down
33 changes: 32 additions & 1 deletion packages/core/src/agents/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ import {
} from "./toolLoop.js";
import { buildToonSystemPrompt, parseToonStructuredOutput } from "./toon.js";
import { getJsonSchemaFromStructuredOutput } from "./structuredOutputSchema.js";
import {
resolveResilienceConfig,
type StructuredOutputResilienceOptions,
} from "./structuredOutputResilience.js";

const Output = BaseOutput as typeof BaseOutput & {
object: <SCHEMA extends FlexibleSchema<unknown>>(options: {
Expand All @@ -61,8 +65,9 @@ export type {
AgentStructuredOutput,
} from "./types.js";
export { DEFAULT_MAX_STEP_TOOLS } from "./toolLoop.js";
export type { StructuredOutputResilienceOptions } from "./structuredOutputResilience.js";

export interface AgentConfig {
export interface AgentConfig extends StructuredOutputResilienceOptions {
name: string;
instructions?: string;
model: LanguageModel;
Expand All @@ -84,6 +89,8 @@ export class Agent {
private loopToolsEnabled: boolean;
private maxStepTools: number;
private toonEnabled: boolean;
private normalizeStructuredKeysDefault?: boolean;
private structuredOutputRepairDefault?: boolean | { maxAttempts?: number };
readonly memory?: Memory;

constructor({
Expand All @@ -96,6 +103,8 @@ export class Agent {
maxStepTools,
toon,
memory,
normalizeStructuredKeys,
structuredOutputRepair,
}: AgentConfig) {
this.name = name;
this.instructions = instructions;
Expand All @@ -107,11 +116,22 @@ export class Agent {
this.loopToolsEnabled = loopTools ?? false;
this.maxStepTools = maxStepTools ?? DEFAULT_MAX_STEP_TOOLS;
this.toonEnabled = toon ?? false;
this.normalizeStructuredKeysDefault = normalizeStructuredKeys;
this.structuredOutputRepairDefault = structuredOutputRepair;
if (memory) {
this.memory = new Memory(memory);
}
}

private resolveResilience(options: StructuredOutputResilienceOptions) {
return resolveResilienceConfig({
normalizeStructuredKeys:
options.normalizeStructuredKeys ?? this.normalizeStructuredKeysDefault,
structuredOutputRepair:
options.structuredOutputRepair ?? this.structuredOutputRepairDefault,
});
}

withTelemetry(enabled: boolean = true) {
this.telemetryEnabled = enabled;
return this;
Expand Down Expand Up @@ -234,6 +254,7 @@ export class Agent {
telemetryEnabled: this.telemetryEnabled,
telemetryDefaults: this.telemetryDefaults,
agentName: this.name,
resilienceConfig: this.resolveResilience(options),
}),
);
}
Expand Down Expand Up @@ -261,6 +282,7 @@ export class Agent {
telemetryDefaults: this.telemetryDefaults,
agentName: this.name,
loopToolsEnabled: loopSettings.enabled,
resilienceConfig: this.resolveResilience(options),
});

return result;
Expand All @@ -275,6 +297,8 @@ export class Agent {
structuredOutput: _structured,
runtime: _runtime,
toon: _toon,
normalizeStructuredKeys: _normalizeStructuredKeys,
structuredOutputRepair: _structuredOutputRepair,
...rest
} = options;
const {
Expand Down Expand Up @@ -355,6 +379,8 @@ export class Agent {
structuredOutput: _structured,
runtime: _runtime,
toon: _toon,
normalizeStructuredKeys: _normalizeStructuredKeys,
structuredOutputRepair: _structuredOutputRepair,
...rest
} = options;
const {
Expand Down Expand Up @@ -501,6 +527,7 @@ export class Agent {
telemetryDefaults: this.telemetryDefaults,
agentName: this.name,
loopToolsEnabled: loopSettings.enabled,
resilienceConfig: this.resolveResilience(options),
});
},
});
Expand All @@ -515,6 +542,8 @@ export class Agent {
structuredOutput: _structured,
runtime: _runtime,
toon: _toon,
normalizeStructuredKeys: _normalizeStructuredKeys,
structuredOutputRepair: _structuredOutputRepair,
...rest
} = options;
const {
Expand Down Expand Up @@ -605,6 +634,8 @@ export class Agent {
structuredOutput: _structured,
runtime: _runtime,
toon: _toon,
normalizeStructuredKeys: _normalizeStructuredKeys,
structuredOutputRepair: _structuredOutputRepair,
...rest
} = options;
const {
Expand Down
133 changes: 133 additions & 0 deletions packages/core/src/agents/structurePipeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,12 @@ vi.mock("./structuredOutputSchema.js", () => ({
}));

import {
generateWithDirectStructuredObject,
generateWithStructuredPipeline,
shouldUseStructuredPipeline,
streamWithStructuredPipeline,
} from "./structurePipeline.js";
import { resolveResilienceConfig } from "./structuredOutputResilience.js";

class MockStreamResult {
text: Promise<string>;
Expand Down Expand Up @@ -81,6 +83,25 @@ describe("structurePipeline", () => {
outputObjectMock.mockClear();
});

it("shouldUseStructuredPipeline reconnaît Output.object via .name (sans .type)", () => {
// The AI SDK's Output.object() result carries { name: "object" } and no
// `type`. The gate must treat it as a structured object all the same.
const outputObjectShape = { name: "object" } as any;

expect(
shouldUseStructuredPipeline({ provider: "scaleway.chat" } as any, {}, outputObjectShape),
).toBe(true);
expect(
shouldUseStructuredPipeline({ provider: "anthropic" } as any, {}, outputObjectShape),
).toBe(true);
expect(
shouldUseStructuredPipeline({ provider: "openai" } as any, {}, outputObjectShape),
).toBe(false);
expect(
shouldUseStructuredPipeline({ provider: "anthropic" } as any, {}, { name: "text" } as any),
).toBe(false);
});

it("shouldUseStructuredPipeline respecte toon, type et provider", () => {
const structuredOutput = { type: "object" } as any;

Expand Down Expand Up @@ -205,4 +226,116 @@ describe("structurePipeline", () => {
);
expect(() => (result as any).experimental_output).toThrow("structured stream failed");
});

it("normalise une clé dérivée de la sortie structurée sans reprise", async () => {
generateTextMock.mockResolvedValueOnce({ text: "Réponse libre" });
generateTextMock.mockResolvedValueOnce({ output: { Summary: "OK" } });

const result = await generateWithStructuredPipeline({
model: { provider: "anthropic" } as any,
structuredOutput: { type: "object" } as any,
options: { prompt: "Fais un résumé" } as any,
telemetryEnabled: false,
loopToolsEnabled: false,
});

expect((result as any).experimental_output).toEqual({ summary: "OK" });
expect(generateTextMock).toHaveBeenCalledTimes(2);
});

it("relance le modèle pour réparer un alias sémantique (clé manquante)", async () => {
generateTextMock.mockResolvedValueOnce({ text: "Réponse libre" });
generateTextMock.mockResolvedValueOnce({ output: { resume: "mauvaise clé" } });
generateTextMock.mockResolvedValueOnce({ output: { summary: "réparé" } });

const result = await generateWithStructuredPipeline({
model: { provider: "anthropic" } as any,
structuredOutput: { type: "object" } as any,
options: { prompt: "Fais un résumé" } as any,
telemetryEnabled: false,
loopToolsEnabled: false,
});

expect((result as any).experimental_output).toEqual({ summary: "réparé" });
expect(generateTextMock).toHaveBeenCalledTimes(3);

const repairPayload = generateTextMock.mock.calls[2]?.[0];
const lastMessage = repairPayload.messages[repairPayload.messages.length - 1];
expect(lastMessage.role).toBe("user");
expect(lastMessage.content).toContain("summary");
});

it("ne relance pas quand la réparation est désactivée", async () => {
generateTextMock.mockResolvedValueOnce({ text: "Réponse libre" });
generateTextMock.mockResolvedValueOnce({ output: { resume: "mauvaise clé" } });

const result = await generateWithStructuredPipeline({
model: { provider: "anthropic" } as any,
structuredOutput: { type: "object" } as any,
options: { prompt: "Fais un résumé" } as any,
telemetryEnabled: false,
loopToolsEnabled: false,
resilienceConfig: resolveResilienceConfig({ structuredOutputRepair: false }),
});

expect((result as any).experimental_output).toEqual({ resume: "mauvaise clé" });
expect(generateTextMock).toHaveBeenCalledTimes(2);
});

it("normalise la clé dérivée du dernier objet en streaming", async () => {
const baseStreamResult = new MockStreamResult("Texte stream") as any;
const objectStream = {
output: Promise.resolve({ Summary: "stream-ok" }),
partialOutputStream: asAsyncIterable([{ summary: "partiel" }]),
};

streamTextMock.mockReturnValueOnce(baseStreamResult);
streamTextMock.mockResolvedValueOnce(objectStream);

const result = await streamWithStructuredPipeline({
model: { provider: "anthropic" } as any,
structuredOutput: { type: "object" } as any,
options: { prompt: "stream" } as any,
telemetryEnabled: false,
loopToolsEnabled: false,
});

await Promise.resolve();
await Promise.resolve();
expect((result as any).experimental_output).toEqual({ summary: "stream-ok" });
});

it("normalise la clé dérivée sur le chemin direct (sans outils)", async () => {
generateTextMock.mockResolvedValueOnce({ output: { Summary: "OK" } });

const result = await generateWithDirectStructuredObject({
model: { provider: "anthropic" } as any,
structuredOutput: { type: "object" } as any,
options: { prompt: "Fais un résumé" } as any,
telemetryEnabled: false,
});

expect((result as any).experimental_output).toEqual({ summary: "OK" });
expect(generateTextMock).toHaveBeenCalledTimes(1);
});

it("répare un alias sémantique sur le chemin direct", async () => {
generateTextMock.mockResolvedValueOnce({ output: { resume: "mauvaise clé" } });
generateTextMock.mockResolvedValueOnce({ output: { summary: "réparé" } });

const result = await generateWithDirectStructuredObject({
model: { provider: "anthropic" } as any,
structuredOutput: { type: "object" } as any,
options: { prompt: "Fais un résumé" } as any,
telemetryEnabled: false,
});

expect((result as any).experimental_output).toEqual({ summary: "réparé" });
expect(generateTextMock).toHaveBeenCalledTimes(2);

const repairPayload = generateTextMock.mock.calls[1]?.[0];
const lastMessage = repairPayload.messages[repairPayload.messages.length - 1];
expect(lastMessage.role).toBe("user");
expect(lastMessage.content).toContain("summary");
});
});
Loading