@luanpoppe/ai 1.1.0 → 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,331 @@
1
+ import {
2
+ BaseCheckpointSaver,
3
+ MemorySaver,
4
+ type StateSnapshot,
5
+ } from "@langchain/langgraph";
6
+ import type {
7
+ GetHistoryResult,
8
+ GraphWithStateHistory,
9
+ HistoryMessageItem,
10
+ MemoryConfig,
11
+ MessageRole,
12
+ } from "../@types/checkpointers";
13
+
14
+ export type { BaseCheckpointSaver };
15
+ export type {
16
+ MemoryConfig,
17
+ MemoryCheckpointerConfig,
18
+ SqliteCheckpointerConfig,
19
+ PostgresCheckpointerConfig,
20
+ RedisCheckpointerConfig,
21
+ MongoDBCheckpointerConfig,
22
+ GraphWithStateHistory,
23
+ MessageRole,
24
+ HistoryMessageItem,
25
+ GetHistoryResult,
26
+ } from "../@types/checkpointers";
27
+
28
+ /**
29
+ * Classe para gerenciar memória e checkpointers de conversas LangGraph.
30
+ *
31
+ * @example
32
+ * // Memória (desenvolvimento)
33
+ * const memory = new AIMemory({ type: "memory" });
34
+ * const checkpointer = await memory.getCheckpointer();
35
+ *
36
+ * @example
37
+ * // SQLite
38
+ * const memory = new AIMemory({ type: "sqlite", connectionString: "./data.db" });
39
+ *
40
+ * @example
41
+ * // Acessar histórico de conversa
42
+ * const { agent } = await ai.getRawAgent({ ... });
43
+ * const history = await memory.getHistory("thread-1", agent);
44
+ * const messages = history[0]?.values?.messages ?? [];
45
+ */
46
+ export class AIMemory {
47
+ private checkpointer: BaseCheckpointSaver | undefined;
48
+ private checkpointerPromise: Promise<BaseCheckpointSaver> | undefined;
49
+ private agent: GraphWithStateHistory | undefined;
50
+
51
+ constructor(
52
+ private config: MemoryConfig,
53
+ agent?: GraphWithStateHistory
54
+ ) {
55
+ this.agent = agent;
56
+ }
57
+
58
+ /**
59
+ * Define o agent/grafo para uso em getHistory quando graph não é passado.
60
+ * Chamado automaticamente por AI.getRawAgent() quando a instância está vinculada ao AI.
61
+ */
62
+ setAgent(agent: GraphWithStateHistory): void {
63
+ this.agent = agent;
64
+ }
65
+
66
+ /**
67
+ * Cria ou retorna a instância de checkpointer baseada na configuração.
68
+ *
69
+ * @example
70
+ * const cp = await memory.getCheckpointer();
71
+ * const graph = workflow.compile({ checkpointer: cp });
72
+ */
73
+ async getCheckpointer(): Promise<BaseCheckpointSaver> {
74
+ if (this.checkpointer) return this.checkpointer;
75
+ if (!this.checkpointerPromise) {
76
+ this.checkpointerPromise = this.createCheckpointer();
77
+ }
78
+ this.checkpointer = await this.checkpointerPromise;
79
+ return this.checkpointer;
80
+ }
81
+
82
+ /**
83
+ * Retorna o histórico de checkpoints e lista de mensagens de uma thread.
84
+ *
85
+ * @param threadId - ID da thread/conversa
86
+ * @param graph - Opcional. Grafo compilado com checkpointer. Se não passado, usa o agent
87
+ * definido no construtor ou via setAgent (ex: agent de ai.getRawAgent).
88
+ * @returns Objeto com fullHistory (checkpoints) e messages (lista com role, createdAt, content)
89
+ *
90
+ * @example
91
+ * // Com ai.memory (agent definido automaticamente em call/getRawAgent)
92
+ * await ai.call({ messages: [...], threadId: "1" });
93
+ * const { fullHistory, messages } = await ai.memory.getHistory("1");
94
+ *
95
+ * @example
96
+ * // Passando graph explicitamente
97
+ * const { fullHistory, messages } = await memory.getHistory("1", agent);
98
+ *
99
+ * @see https://docs.langchain.com/oss/javascript/langgraph/persistence#get-state-history
100
+ * @see https://docs.langchain.com/oss/javascript/langgraph/add-memory#manage-checkpoints
101
+ */
102
+ async getHistory(
103
+ threadId: string,
104
+ graph?: GraphWithStateHistory
105
+ ): Promise<GetHistoryResult> {
106
+ const graphToUse = graph ?? this.agent;
107
+ if (!graphToUse) {
108
+ throw new Error(
109
+ "É necessário passar graph em getHistory ou definir o agent no construtor/setAgent do AIMemory."
110
+ );
111
+ }
112
+ const config = { configurable: { thread_id: threadId } };
113
+ const fullHistory: StateSnapshot[] = [];
114
+ for await (const snapshot of graphToUse.getStateHistory(config)) {
115
+ fullHistory.push(snapshot);
116
+ }
117
+
118
+ const messages = this.extractMessagesFromHistory(fullHistory);
119
+ return { fullHistory, messages };
120
+ }
121
+
122
+ /**
123
+ * Extrai lista de mensagens com role, createdAt e content a partir do histórico.
124
+ * Percorre os checkpoints do mais antigo ao mais recente para atribuir createdAt correto.
125
+ */
126
+ private extractMessagesFromHistory(
127
+ history: StateSnapshot[]
128
+ ): HistoryMessageItem[] {
129
+ const result: HistoryMessageItem[] = [];
130
+ let previousMessageCount = 0;
131
+
132
+ // history[0] = mais recente, history[length-1] = mais antigo
133
+ // Cada snapshot acumula mensagens; só adicionamos as novas
134
+ for (let i = history.length - 1; i >= 0; i--) {
135
+ const snapshot = history[i]!;
136
+ const snapshotMessages = (snapshot.values?.messages ?? []) as Array<{
137
+ id?: string;
138
+ getType?: () => string;
139
+ _getType?: () => string;
140
+ content?: string | Array<unknown>;
141
+ constructor?: { name?: string };
142
+ role?: string;
143
+ }>;
144
+ const createdAt =
145
+ (snapshot as { createdAt?: string }).createdAt ??
146
+ new Date().toISOString();
147
+
148
+ for (let j = previousMessageCount; j < snapshotMessages.length; j++) {
149
+ const msg = snapshotMessages[j];
150
+ if (!msg) continue;
151
+ const role = this.getMessageRole(msg);
152
+ if (role === "system") continue;
153
+
154
+ result.push({
155
+ role: role as MessageRole,
156
+ createdAt,
157
+ content: this.extractContent(msg),
158
+ });
159
+ }
160
+ previousMessageCount = snapshotMessages.length;
161
+ }
162
+
163
+ return result;
164
+ }
165
+
166
+ private getMessageRole(msg: {
167
+ getType?: () => string;
168
+ _getType?: () => string;
169
+ constructor?: { name?: string };
170
+ role?: string;
171
+ }): "human" | "ai" | "tool" | "system" {
172
+ const role = (msg as { role?: string }).role?.toLowerCase();
173
+ if (role === "human" || role === "user") return "human";
174
+ if (role === "ai" || role === "assistant") return "ai";
175
+ if (role === "tool") return "tool";
176
+
177
+ const type =
178
+ msg.getType?.() ??
179
+ msg._getType?.() ??
180
+ msg.constructor?.name?.toLowerCase() ?? "";
181
+ const normalized = type.toLowerCase().replace("message", "").trim();
182
+ if (normalized === "human" || normalized === "user") return "human";
183
+ if (normalized === "ai" || normalized === "assistant") return "ai";
184
+ if (normalized === "tool") return "tool";
185
+ return "system";
186
+ }
187
+
188
+ private extractContent(msg: {
189
+ content?: string | Array<unknown>;
190
+ }): string {
191
+ const c = msg.content;
192
+ if (typeof c === "string") return c;
193
+ if (Array.isArray(c)) {
194
+ return c
195
+ .map((block: unknown) =>
196
+ typeof block === "string" ? block : (block as { text?: string })?.text ?? ""
197
+ )
198
+ .filter(Boolean)
199
+ .join("\n");
200
+ }
201
+ return "";
202
+ }
203
+
204
+ /**
205
+ * Retorna o estado atual (último checkpoint) de uma thread.
206
+ *
207
+ * @param threadId - ID da thread/conversa
208
+ * @param graph - Grafo compilado com checkpointer
209
+ * @param checkpointId - Opcional: ID de um checkpoint específico
210
+ *
211
+ * @example
212
+ * const state = await memory.getState("1", agent);
213
+ * const messages = state?.values?.messages ?? [];
214
+ *
215
+ * @see https://docs.langchain.com/oss/javascript/langgraph/persistence#get-state
216
+ */
217
+ async getState(
218
+ threadId: string,
219
+ graph?: GraphWithStateHistory,
220
+ checkpointId?: string
221
+ ): Promise<StateSnapshot | null> {
222
+ const graphToUse = graph ?? this.agent;
223
+ if (!graphToUse) {
224
+ throw new Error(
225
+ "É necessário passar graph em getState ou definir o agent no construtor/setAgent do AIMemory."
226
+ );
227
+ }
228
+ if (!graphToUse.getState) {
229
+ const { fullHistory } = await this.getHistory(threadId, graphToUse);
230
+ return fullHistory[0] ?? null;
231
+ }
232
+ const config = {
233
+ configurable: { thread_id: threadId, ...(checkpointId && { checkpoint_id: checkpointId }) },
234
+ };
235
+ return (await graphToUse.getState(config)) ?? null;
236
+ }
237
+
238
+ private async createCheckpointer(): Promise<BaseCheckpointSaver> {
239
+ if (this.checkpointer) return this.checkpointer;
240
+
241
+ switch (this.config.type) {
242
+ case "memory": {
243
+ return new MemorySaver();
244
+ }
245
+
246
+ case "sqlite": {
247
+ try {
248
+ const { SqliteSaver } = await import(
249
+ "@langchain/langgraph-checkpoint-sqlite"
250
+ );
251
+ return SqliteSaver.fromConnString(
252
+ this.config.connectionString
253
+ ) as unknown as BaseCheckpointSaver;
254
+ } catch (err) {
255
+ throw new Error(
256
+ 'Checkpointer SQLite requer o pacote "@langchain/langgraph-checkpoint-sqlite". ' +
257
+ "Instale com: pnpm add @langchain/langgraph-checkpoint-sqlite",
258
+ { cause: err }
259
+ );
260
+ }
261
+ }
262
+
263
+ case "postgres": {
264
+ try {
265
+ const { PostgresSaver } = await import(
266
+ "@langchain/langgraph-checkpoint-postgres"
267
+ );
268
+ const checkpointer = PostgresSaver.fromConnString(
269
+ this.config.connectionString
270
+ );
271
+ await checkpointer.setup();
272
+ return checkpointer as unknown as BaseCheckpointSaver;
273
+ } catch (err) {
274
+ throw new Error(
275
+ 'Checkpointer Postgres requer o pacote "@langchain/langgraph-checkpoint-postgres". ' +
276
+ "Instale com: pnpm add @langchain/langgraph-checkpoint-postgres",
277
+ { cause: err }
278
+ );
279
+ }
280
+ }
281
+
282
+ case "redis": {
283
+ try {
284
+ const { RedisSaver } = await import(
285
+ "@langchain/langgraph-checkpoint-redis"
286
+ );
287
+ return (await RedisSaver.fromUrl(
288
+ this.config.url,
289
+ this.config.options ?? {}
290
+ )) as unknown as BaseCheckpointSaver;
291
+ } catch (err) {
292
+ throw new Error(
293
+ 'Checkpointer Redis requer o pacote "@langchain/langgraph-checkpoint-redis". ' +
294
+ "Instale com: pnpm add @langchain/langgraph-checkpoint-redis",
295
+ { cause: err }
296
+ );
297
+ }
298
+ }
299
+
300
+ case "mongodb": {
301
+ try {
302
+ const { MongoDBSaver } = await import(
303
+ "@langchain/langgraph-checkpoint-mongodb"
304
+ );
305
+ if ("client" in this.config) {
306
+ return new MongoDBSaver({
307
+ client: this.config.client as any,
308
+ }) as unknown as BaseCheckpointSaver;
309
+ }
310
+ const { MongoClient } = await import("mongodb");
311
+ const client = new MongoClient(this.config.url) as any;
312
+ return new MongoDBSaver({ client }) as unknown as BaseCheckpointSaver;
313
+ } catch (err) {
314
+ throw new Error(
315
+ 'Checkpointer MongoDB requer os pacotes "@langchain/langgraph-checkpoint-mongodb" e "mongodb". ' +
316
+ "Instale com: pnpm add @langchain/langgraph-checkpoint-mongodb mongodb",
317
+ { cause: err }
318
+ );
319
+ }
320
+ }
321
+
322
+ default: {
323
+ const _exhaustive: never = this.config;
324
+ throw new Error(
325
+ `Tipo de checkpointer não suportado: ${(_exhaustive as any)?.type}`
326
+ );
327
+ }
328
+ }
329
+ }
330
+ }
331
+
@@ -0,0 +1,213 @@
1
+ import { AI } from "../../src/index";
2
+ import { AIMessages } from "../../src/langchain/messages";
3
+ import z from "zod";
4
+ import "dotenv/config";
5
+
6
+ /**
7
+ * Flag controlada por cada teste. Quando true, a PRÓXIMA chamada a createAgent
8
+ * retornará um agente mock que falha no invoke. As chamadas seguintes usam o real.
9
+ */
10
+ let simulateNextCreateAgentFailure = false;
11
+
12
+ /**
13
+ * Quantidade de chamadas a createAgent que devem falhar. Quando > 0, retorna mock que falha.
14
+ * Usado no teste "todos os modelos falharem" para evitar chamadas reais (timeout).
15
+ */
16
+ let simulateFailureCount = 0;
17
+
18
+ vi.mock("langchain", async (importOriginal) => {
19
+ const actual = await importOriginal<typeof import("langchain")>();
20
+ return {
21
+ ...actual,
22
+ createAgent: vi.fn((config: any) => {
23
+ if (simulateFailureCount > 0) {
24
+ simulateFailureCount--;
25
+ return {
26
+ invoke: vi.fn().mockRejectedValue(new Error("Simulated failure")),
27
+ } as any;
28
+ }
29
+ if (simulateNextCreateAgentFailure) {
30
+ simulateNextCreateAgentFailure = false;
31
+ return {
32
+ invoke: vi.fn().mockRejectedValue(new Error("Simulated failure")),
33
+ } as any;
34
+ }
35
+ return actual.createAgent(config);
36
+ }),
37
+ };
38
+ });
39
+
40
+ describe("AI E2E - Retry e Fallback (mock seletivo + real)", () => {
41
+ const openAIApiKey = process.env.OPENAI_API_KEY;
42
+ const openRouterApiKey = process.env.OPENROUTER_API_KEY;
43
+ const timeout = 180 * 1000;
44
+
45
+ beforeEach(() => {
46
+ simulateNextCreateAgentFailure = false;
47
+ simulateFailureCount = 0;
48
+ });
49
+
50
+ it(
51
+ "deve fazer fallback quando primeiro modelo falhar (mock) e segundo usar real GPT",
52
+ { timeout },
53
+ async () => {
54
+ if (!openAIApiKey) {
55
+ console.log("OPENAI_API_KEY não está configurada");
56
+ return;
57
+ }
58
+ simulateNextCreateAgentFailure = true;
59
+
60
+ const ai = new AI({ openAIApiKey: openAIApiKey });
61
+
62
+ const result = await ai.call({
63
+ aiModel: "gpt-5-nano",
64
+ messages: [AIMessages.human("Responda apenas com 'Fallback mock+real ok'")],
65
+ aiModelsFallback: ["gpt-5-nano"],
66
+ });
67
+
68
+ expect(result.text).toBeDefined();
69
+ expect(result.text.length).toBeGreaterThan(0);
70
+ expect(result.text.toLowerCase()).toContain("fallback");
71
+ },
72
+ );
73
+
74
+ it(
75
+ "deve fazer fallback quando primeiro modelo falhar (mock) e segundo usar real OpenRouter",
76
+ { timeout },
77
+ async () => {
78
+ if (!openRouterApiKey) {
79
+ console.log("OPENROUTER_API_KEY não está configurada");
80
+ return;
81
+ }
82
+ simulateNextCreateAgentFailure = true;
83
+
84
+ const ai = new AI({ openRouterApiKey: openRouterApiKey! });
85
+
86
+ const result = await ai.call({
87
+ aiModel: "openrouter/openai/gpt-5-nano",
88
+ messages: [AIMessages.human("Responda apenas com 'Fallback OpenRouter ok'")],
89
+ aiModelsFallback: ["openrouter/openai/gpt-5-nano"],
90
+ });
91
+
92
+ expect(result.text).toBeDefined();
93
+ expect(result.text.length).toBeGreaterThan(0);
94
+ expect(result.text.toLowerCase()).toContain("fallback");
95
+ },
96
+ );
97
+
98
+ it(
99
+ "deve fazer fallback entre provedores: GPT (mock fail) -> OpenRouter (real)",
100
+ { timeout },
101
+ async () => {
102
+ if (!openAIApiKey || !openRouterApiKey) {
103
+ console.log("OPENAI_API_KEY e OPENROUTER_API_KEY são necessárias");
104
+ return;
105
+ }
106
+ simulateNextCreateAgentFailure = true;
107
+
108
+ const ai = new AI({
109
+ openAIApiKey: openAIApiKey!,
110
+ openRouterApiKey: openRouterApiKey!,
111
+ });
112
+
113
+ const result = await ai.call({
114
+ aiModel: "gpt-5-nano",
115
+ messages: [AIMessages.human("Responda apenas com 'Fallback cross-provider ok'")],
116
+ aiModelsFallback: ["openrouter/openai/gpt-5-nano"],
117
+ });
118
+
119
+ expect(result.text).toBeDefined();
120
+ expect(result.text.length).toBeGreaterThan(0);
121
+ expect(result.text.toLowerCase()).toContain("fallback");
122
+ },
123
+ );
124
+
125
+ it(
126
+ "deve fazer fallback no callStructuredOutput: primeiro modelo falha (mock), segundo real",
127
+ { timeout },
128
+ async () => {
129
+ if (!openAIApiKey) {
130
+ console.log("OPENAI_API_KEY não está configurada");
131
+ return;
132
+ }
133
+ simulateNextCreateAgentFailure = true;
134
+
135
+ const ai = new AI({ openAIApiKey: openAIApiKey! });
136
+
137
+ const outputSchema = z.object({ answer: z.string() });
138
+
139
+ const result = await ai.callStructuredOutput({
140
+ aiModel: "gpt-5-nano",
141
+ messages: [
142
+ AIMessages.human('Responda com JSON: { "answer": "fallback structured ok" }'),
143
+ ],
144
+ outputSchema,
145
+ aiModelsFallback: ["gpt-5-nano"],
146
+ });
147
+
148
+ expect(result.response).toBeDefined();
149
+ expect(result.response.answer.toLowerCase()).toContain("fallback");
150
+ },
151
+ );
152
+
153
+ it(
154
+ "deve fazer fallback para o mesmo modelo quando primeira tentativa falhar (mock) e segunda usar real",
155
+ { timeout },
156
+ async () => {
157
+ if (!openAIApiKey) {
158
+ console.log("OPENAI_API_KEY não está configurada");
159
+ return;
160
+ }
161
+ simulateNextCreateAgentFailure = true;
162
+
163
+ const ai = new AI({ openAIApiKey: openAIApiKey! });
164
+
165
+ const result = await ai.call({
166
+ aiModel: "gpt-5-nano",
167
+ messages: [AIMessages.human("Diga: retry ok")],
168
+ maxRetries: 2,
169
+ aiModelsFallback: ["gpt-5-nano"],
170
+ });
171
+
172
+ expect(result.text).toBeDefined();
173
+ expect(result.text.length).toBeGreaterThan(0);
174
+ },
175
+ );
176
+
177
+ it(
178
+ "deve respeitar maxRetries e completar chamada com sucesso",
179
+ { timeout },
180
+ async () => {
181
+ if (!openAIApiKey) {
182
+ console.log("OPENAI_API_KEY não está configurada");
183
+ return;
184
+ }
185
+ const ai = new AI({ openAIApiKey: openAIApiKey! });
186
+
187
+ const result = await ai.call({
188
+ aiModel: "gpt-5-nano",
189
+ messages: [AIMessages.human("Diga apenas: retry config ok")],
190
+ maxRetries: 2,
191
+ });
192
+
193
+ expect(result.text).toBeDefined();
194
+ expect(result.text.length).toBeGreaterThan(0);
195
+ },
196
+ );
197
+
198
+ it("deve lançar exceção quando todos os modelos falharem", async () => {
199
+ simulateFailureCount = 2;
200
+
201
+ const ai = new AI({
202
+ openAIApiKey: "test-key",
203
+ });
204
+
205
+ await expect(
206
+ ai.call({
207
+ aiModel: "gpt-4",
208
+ messages: [AIMessages.human("teste")],
209
+ aiModelsFallback: ["gpt-5-nano"],
210
+ }),
211
+ ).rejects.toThrow("Simulated failure");
212
+ });
213
+ });