@node-llm/orm 0.5.0 → 0.7.0

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.
@@ -6,7 +6,8 @@ import {
6
6
  AgentConfig,
7
7
  Message,
8
8
  ChatChunk,
9
- Usage
9
+ Usage,
10
+ ToolCall
10
11
  } from "@node-llm/core";
11
12
 
12
13
  /**
@@ -72,13 +73,13 @@ interface PrismaModel<T = Record<string, unknown>> {
72
73
  findUnique(args: { where: { id: string } }): Promise<T | null>;
73
74
  }
74
75
 
75
- type AgentClass<T extends Agent = Agent> = (new (
76
- overrides?: Partial<AgentConfig & ChatOptions>
76
+ type AgentClass<T extends Agent<any, any> = Agent<any, any>> = (new (
77
+ overrides?: Partial<AgentConfig<any> & ChatOptions>
77
78
  ) => T) & {
78
79
  name: string;
79
80
  model?: string;
80
- instructions?: string;
81
- tools?: unknown[];
81
+ instructions?: unknown;
82
+ tools?: unknown;
82
83
  };
83
84
 
84
85
  /**
@@ -87,6 +88,7 @@ type AgentClass<T extends Agent = Agent> = (new (
87
88
  * Follows "Code Wins" sovereignty:
88
89
  * - Model, Tools, Instructions come from the Agent class (code)
89
90
  * - Message history comes from the database
91
+ * - Metadata from DB is injected as 'inputs' for dynamic resolution
90
92
  *
91
93
  * @example
92
94
  * ```typescript
@@ -102,7 +104,10 @@ type AgentClass<T extends Agent = Agent> = (new (
102
104
  * const result = await session.ask("Hello");
103
105
  * ```
104
106
  */
105
- export class AgentSession<T extends Agent = Agent> {
107
+ export class AgentSession<
108
+ I extends Record<string, any> = Record<string, any>,
109
+ T extends Agent<I, any> = Agent<I, any>
110
+ > {
106
111
  private currentMessageId: string | null = null;
107
112
  private tableNames: Required<TableNames>;
108
113
  private debug: boolean;
@@ -114,7 +119,8 @@ export class AgentSession<T extends Agent = Agent> {
114
119
  private record: AgentSessionRecord,
115
120
  tableNames?: TableNames,
116
121
  private agent: T = new AgentClass({
117
- llm
122
+ llm,
123
+ inputs: record.metadata as I
118
124
  }),
119
125
  debug: boolean = false
120
126
  ) {
@@ -126,6 +132,8 @@ export class AgentSession<T extends Agent = Agent> {
126
132
  toolCall: tableNames?.toolCall || "llmToolCall",
127
133
  request: tableNames?.request || "llmRequest"
128
134
  };
135
+
136
+ this.registerHooks();
129
137
  }
130
138
 
131
139
  private log(...args: any[]) {
@@ -150,8 +158,8 @@ export class AgentSession<T extends Agent = Agent> {
150
158
  }
151
159
 
152
160
  /** Session metadata */
153
- get metadata(): Record<string, unknown> | null | undefined {
154
- return this.record.metadata;
161
+ get metadata(): I | null | undefined {
162
+ return this.record.metadata as I;
155
163
  }
156
164
 
157
165
  /** Agent class name */
@@ -181,15 +189,72 @@ export class AgentSession<T extends Agent = Agent> {
181
189
  return getTable(this.prisma, name) as unknown as PrismaModel<R>;
182
190
  }
183
191
 
192
+ /**
193
+ * Register persistence hooks on the agent.
194
+ */
195
+ private registerHooks() {
196
+ this.agent.onToolCallStart(async (tc) => {
197
+ const toolCall = tc as ToolCall;
198
+ if (!this.currentMessageId) return;
199
+ const model = this.getModel(this.tableNames.toolCall);
200
+ await model.create({
201
+ data: {
202
+ messageId: this.currentMessageId,
203
+ toolCallId: toolCall.id,
204
+ name: toolCall.function.name,
205
+ arguments: toolCall.function.arguments
206
+ }
207
+ });
208
+ });
209
+
210
+ this.agent.onToolCallEnd(async (tc, result) => {
211
+ const toolCall = tc as ToolCall;
212
+ if (!this.currentMessageId) return;
213
+ const model = this.getModel(this.tableNames.toolCall);
214
+ try {
215
+ await model.update({
216
+ where: {
217
+ messageId_toolCallId: {
218
+ messageId: this.currentMessageId,
219
+ toolCallId: toolCall.id
220
+ }
221
+ } as any,
222
+ data: {
223
+ result: typeof result === "string" ? result : JSON.stringify(result)
224
+ }
225
+ });
226
+ } catch (e) {
227
+ this.log(`Failed to update tool call result: ${e}`);
228
+ }
229
+ });
230
+
231
+ this.agent.afterResponse(async (response) => {
232
+ const model = this.getModel(this.tableNames.request);
233
+ await model.create({
234
+ data: {
235
+ chatId: this.chatId,
236
+ messageId: this.currentMessageId,
237
+ provider: response.provider || "unknown",
238
+ model: response.model || "unknown",
239
+ statusCode: 200,
240
+ duration: 0,
241
+ inputTokens: response.usage?.input_tokens || 0,
242
+ outputTokens: response.usage?.output_tokens || 0,
243
+ cost: response.usage?.cost || 0
244
+ }
245
+ });
246
+ });
247
+ }
248
+
184
249
  /**
185
250
  * Send a message and persist the conversation.
186
251
  */
187
- async ask(input: string, options: AskOptions = {}): Promise<MessageRecord> {
252
+ async ask(message: string, options: AskOptions & { inputs?: I } = {}): Promise<MessageRecord> {
188
253
  const model = this.getModel<MessageRecord>(this.tableNames.message);
189
254
 
190
255
  // Persist user message
191
256
  await model.create({
192
- data: { chatId: this.chatId, role: "user", content: input }
257
+ data: { chatId: this.chatId, role: "user", content: message }
193
258
  });
194
259
 
195
260
  // Create placeholder for assistant message
@@ -200,8 +265,11 @@ export class AgentSession<T extends Agent = Agent> {
200
265
  this.currentMessageId = assistantMessage.id;
201
266
 
202
267
  try {
268
+ // Merge turn-level inputs with session metadata
269
+ const inputs = { ...(this.record.metadata as I), ...options.inputs };
270
+
203
271
  // Get response from agent (uses code-defined config + injected history)
204
- const response = await this.agent.ask(input, options);
272
+ const response = await this.agent.ask(message, { ...options, inputs });
205
273
 
206
274
  // Update assistant message with response
207
275
  return await model.update({
@@ -229,14 +297,14 @@ export class AgentSession<T extends Agent = Agent> {
229
297
  * Stream a response and persist the conversation.
230
298
  */
231
299
  async *askStream(
232
- input: string,
233
- options: AskOptions = {}
300
+ message: string,
301
+ options: AskOptions & { inputs?: I } = {}
234
302
  ): AsyncGenerator<ChatChunk, MessageRecord, undefined> {
235
303
  const model = this.getModel<MessageRecord>(this.tableNames.message);
236
304
 
237
305
  // Persist user message
238
306
  await model.create({
239
- data: { chatId: this.chatId, role: "user", content: input }
307
+ data: { chatId: this.chatId, role: "user", content: message }
240
308
  });
241
309
 
242
310
  // Create placeholder for assistant message
@@ -247,7 +315,9 @@ export class AgentSession<T extends Agent = Agent> {
247
315
  this.currentMessageId = assistantMessage.id;
248
316
 
249
317
  try {
250
- const stream = this.agent.stream(input, options);
318
+ // Merge turn-level inputs with session metadata
319
+ const inputs = { ...(this.record.metadata as I), ...options.inputs };
320
+ const stream = this.agent.stream(message, { ...options, inputs });
251
321
 
252
322
  let fullContent = "";
253
323
  let lastChunk: ChatChunk | null = null;
@@ -278,6 +348,44 @@ export class AgentSession<T extends Agent = Agent> {
278
348
  }
279
349
  }
280
350
 
351
+ /**
352
+ * Returns a usage summary for this chat session.
353
+ */
354
+ async stats(): Promise<Usage> {
355
+ const requestModel = getTable(this.prisma, this.tableNames.request);
356
+ const aggregate = await (requestModel as any).aggregate({
357
+ where: { chatId: this.chatId },
358
+ _sum: {
359
+ inputTokens: true,
360
+ outputTokens: true,
361
+ cost: true
362
+ }
363
+ });
364
+
365
+ return {
366
+ input_tokens: Number(aggregate._sum.inputTokens || 0),
367
+ output_tokens: Number(aggregate._sum.outputTokens || 0),
368
+ total_tokens: Number((aggregate._sum.inputTokens || 0) + (aggregate._sum.outputTokens || 0)),
369
+ cost: Number(aggregate._sum.cost || 0)
370
+ };
371
+ }
372
+
373
+ /**
374
+ * Add a tool to the session (turn-level).
375
+ */
376
+ withTool(tool: any): this {
377
+ this.agent.use(tool);
378
+ return this;
379
+ }
380
+
381
+ /**
382
+ * Add instructions to the session (turn-level).
383
+ */
384
+ withInstructions(instructions: string, options?: { replace?: boolean }): this {
385
+ this.agent.withInstructions(instructions, options);
386
+ return this;
387
+ }
388
+
281
389
  /**
282
390
  * Returns the current full message history for this session.
283
391
  */
@@ -297,26 +405,50 @@ export class AgentSession<T extends Agent = Agent> {
297
405
  await chatTable.delete({ where: { id: this.chatId } });
298
406
  // AgentSession record is deleted via Cascade from LlmChat
299
407
  }
408
+
409
+ /**
410
+ * Update session metadata and re-resolve agent configuration.
411
+ */
412
+ async updateMetadata(metadata: Partial<I>): Promise<void> {
413
+ const sessionTable = this.getModel<AgentSessionRecord>(this.tableNames.agentSession);
414
+ const newMetadata = { ...(this.record.metadata as I), ...metadata };
415
+
416
+ await sessionTable.update({
417
+ where: { id: this.id },
418
+ data: { metadata: newMetadata as any }
419
+ });
420
+
421
+ this.record.metadata = newMetadata as any;
422
+
423
+ // Apply changes to the underlying agent immediately
424
+ // resolveLazyConfig is private, so we need a cast or make it protected.
425
+ // Given we are in the same package, we can cast.
426
+ (this.agent as any).resolveLazyConfig(newMetadata);
427
+ }
300
428
  }
301
429
 
302
430
  /**
303
431
  * Options for creating a new agent session.
304
432
  */
305
- export interface CreateAgentSessionOptions {
306
- metadata?: Record<string, unknown>;
433
+ export interface CreateAgentSessionOptions<I = any> {
434
+ metadata?: I;
307
435
  tableNames?: TableNames;
308
436
  debug?: boolean;
437
+ model?: string;
438
+ provider?: string;
439
+ instructions?: string;
440
+ maxToolCalls?: number;
309
441
  }
310
442
 
311
443
  /**
312
444
  * Creates a new agent session and its persistent chat record.
313
445
  */
314
- export async function createAgentSession<T extends Agent>(
446
+ export async function createAgentSession<I extends Record<string, any>, T extends Agent<I, any>>(
315
447
  prisma: any,
316
448
  llm: NodeLLMCore,
317
449
  AgentClass: AgentClass<T>,
318
- options: CreateAgentSessionOptions = {}
319
- ): Promise<AgentSession<T>> {
450
+ options: CreateAgentSessionOptions<I> = {}
451
+ ): Promise<AgentSession<I, T>> {
320
452
  const tableNames = {
321
453
  agentSession: options.tableNames?.agentSession || "llmAgentSession",
322
454
  chat: options.tableNames?.chat || "llmChat",
@@ -331,9 +463,11 @@ export async function createAgentSession<T extends Agent>(
331
463
  const chatTable = getTable(prisma, tableNames.chat);
332
464
  const chatRecord = (await chatTable.create({
333
465
  data: {
334
- model: AgentClass.model || null,
335
- provider: null,
336
- instructions: AgentClass.instructions || null,
466
+ model: options.model || AgentClass.model || null,
467
+ provider: options.provider || null,
468
+ instructions:
469
+ options.instructions ||
470
+ (typeof AgentClass.instructions === "string" ? AgentClass.instructions : null),
337
471
  metadata: null // Runtime metadata goes in Chat, session context in AgentSession
338
472
  }
339
473
  })) as unknown as { id: string };
@@ -344,17 +478,27 @@ export async function createAgentSession<T extends Agent>(
344
478
  data: {
345
479
  agentClass: AgentClass.name,
346
480
  chatId: chatRecord.id,
347
- metadata: options.metadata || null
481
+ metadata: (options.metadata as any) || null
348
482
  }
349
483
  })) as unknown as AgentSessionRecord;
350
484
 
351
- return new AgentSession(
485
+ // 3. Instantiate Agent with overrides
486
+ const agent = new AgentClass({
487
+ llm,
488
+ inputs: sessionRecord.metadata as I,
489
+ model: options.model,
490
+ provider: options.provider,
491
+ instructions: options.instructions,
492
+ maxToolCalls: options.maxToolCalls
493
+ });
494
+
495
+ return new AgentSession<I, T>(
352
496
  prisma,
353
497
  llm,
354
498
  AgentClass,
355
499
  sessionRecord,
356
500
  options.tableNames,
357
- undefined,
501
+ agent,
358
502
  options.debug
359
503
  );
360
504
  }
@@ -370,13 +514,13 @@ export interface LoadAgentSessionOptions {
370
514
  /**
371
515
  * Loads an existing agent session and re-instantiates the agent with history.
372
516
  */
373
- export async function loadAgentSession<T extends Agent>(
517
+ export async function loadAgentSession<I extends Record<string, any>, T extends Agent<I, any>>(
374
518
  prisma: any,
375
519
  llm: NodeLLMCore,
376
520
  AgentClass: AgentClass<T>,
377
521
  sessionId: string,
378
522
  options: LoadAgentSessionOptions = {}
379
- ): Promise<AgentSession<T> | null> {
523
+ ): Promise<AgentSession<I, T> | null> {
380
524
  const tableNames = {
381
525
  agentSession: options.tableNames?.agentSession || "llmAgentSession",
382
526
  chat: options.tableNames?.chat || "llmChat",
@@ -417,14 +561,16 @@ export async function loadAgentSession<T extends Agent>(
417
561
  content: m.content || ""
418
562
  }));
419
563
 
420
- // 4. Instantiate agent with injected history and LLM
564
+ // 4. Instantiate agent with injected history, LLM, AND metadata (as inputs)
421
565
  // "Code Wins" - model, tools, instructions come from AgentClass
566
+ // Metadata from DB handles the lazy resolution of behavior
422
567
  const agent = new AgentClass({
423
568
  llm,
424
- messages: history
569
+ messages: history,
570
+ inputs: sessionRecord.metadata as I
425
571
  }) as T;
426
572
 
427
- return new AgentSession(
573
+ return new AgentSession<I, T>(
428
574
  prisma,
429
575
  llm,
430
576
  AgentClass,
@@ -180,10 +180,10 @@ export class Chat extends BaseChat {
180
180
  /**
181
181
  * Send a message and persist the conversation.
182
182
  */
183
- async ask(input: string, options: AskOptions = {}): Promise<MessageRecord> {
183
+ async ask(message: string, options: AskOptions = {}): Promise<MessageRecord> {
184
184
  const messageModel = this.tables.message;
185
185
  const userMessage = await (this.prisma as any)[messageModel].create({
186
- data: { chatId: this.id, role: "user", content: input }
186
+ data: { chatId: this.id, role: "user", content: message }
187
187
  });
188
188
 
189
189
  const assistantMessage = await (this.prisma as any)[messageModel].create({
@@ -202,7 +202,7 @@ export class Chat extends BaseChat {
202
202
  }));
203
203
 
204
204
  const coreChat = await this.prepareCoreChat(history, assistantMessage!.id);
205
- const response = await coreChat.ask(input, options);
205
+ const response = await coreChat.ask(message, options);
206
206
 
207
207
  return await (this.prisma as any)[messageModel].update({
208
208
  where: { id: assistantMessage!.id },
@@ -231,12 +231,12 @@ export class Chat extends BaseChat {
231
231
  * Yields ChatChunk objects for full visibility of thinking, content, and tools.
232
232
  */
233
233
  async *askStream(
234
- input: string,
234
+ message: string,
235
235
  options: AskOptions = {}
236
236
  ): AsyncGenerator<ChatChunk, MessageRecord, undefined> {
237
237
  const messageModel = this.tables.message;
238
238
  const userMessage = await (this.prisma as any)[messageModel].create({
239
- data: { chatId: this.id, role: "user", content: input }
239
+ data: { chatId: this.id, role: "user", content: message }
240
240
  });
241
241
 
242
242
  const assistantMessage = await (this.prisma as any)[messageModel].create({
@@ -255,7 +255,7 @@ export class Chat extends BaseChat {
255
255
  }));
256
256
 
257
257
  const coreChat = await this.prepareCoreChat(history, assistantMessage!.id);
258
- const stream = coreChat.stream(input, options);
258
+ const stream = coreChat.stream(message, options);
259
259
 
260
260
  let fullContent = "";
261
261
  let metadata: any = {};
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect, vi, beforeEach } from "vitest";
2
2
  import { Agent, Tool, NodeLLM } from "@node-llm/core";
3
- import { createAgentSession, loadAgentSession } from "../src/adapters/prisma/AgentSession";
3
+ import { createAgentSession, loadAgentSession } from "../src/adapters/prisma/AgentSession.js";
4
4
 
5
5
  // --- Mocks ---
6
6
 
@@ -12,7 +12,8 @@ const mockPrisma = {
12
12
  },
13
13
  llmAgentSession: {
14
14
  create: vi.fn(),
15
- findUnique: vi.fn()
15
+ findUnique: vi.fn(),
16
+ update: vi.fn()
16
17
  },
17
18
  llmMessage: {
18
19
  create: vi.fn(),
@@ -42,7 +43,8 @@ const createMockChat = () => {
42
43
  onToolCallStart: vi.fn().mockReturnThis(),
43
44
  onToolCallEnd: vi.fn().mockReturnThis(),
44
45
  onToolCallError: vi.fn().mockReturnThis(),
45
- onEndMessage: vi.fn().mockReturnThis()
46
+ onEndMessage: vi.fn().mockReturnThis(),
47
+ afterResponse: vi.fn().mockReturnThis()
46
48
  };
47
49
  return mockChat;
48
50
  };
@@ -201,4 +203,130 @@ describe("AgentSession", () => {
201
203
  );
202
204
  });
203
205
  });
206
+
207
+ describe("Lazy Evaluation & Metadata", () => {
208
+ interface TestInputs {
209
+ userName: string;
210
+ }
211
+
212
+ class LazyTestAgent extends Agent<TestInputs> {
213
+ static model = "gpt-4-lazy";
214
+ static instructions = (i: TestInputs) => `Hello ${i.userName}`;
215
+ }
216
+
217
+ it("injects metadata as inputs for lazy resolution during load", async () => {
218
+ mockPrisma.llmAgentSession.findUnique.mockResolvedValue({
219
+ id: "session-123",
220
+ chatId: "chat-123",
221
+ agentClass: "LazyTestAgent",
222
+ metadata: { userName: "Alice" }
223
+ });
224
+ mockPrisma.llmMessage.findMany.mockResolvedValue([]);
225
+
226
+ const session = await loadAgentSession(
227
+ mockPrisma as any,
228
+ mockLlm,
229
+ LazyTestAgent as any,
230
+ "session-123"
231
+ );
232
+
233
+ // Extract the underlying agent's chat instance
234
+ const mockChat = (session as any).agent.chat;
235
+ expect(mockChat.withInstructions).toHaveBeenCalledWith("Hello Alice", { replace: true });
236
+ });
237
+
238
+ it("merges turn-level inputs with session metadata during ask()", async () => {
239
+ mockPrisma.llmAgentSession.findUnique.mockResolvedValue({
240
+ id: "session-123",
241
+ chatId: "chat-123",
242
+ agentClass: "LazyTestAgent",
243
+ metadata: { userName: "Bob" }
244
+ });
245
+ mockPrisma.llmMessage.findMany.mockResolvedValue([]);
246
+ mockPrisma.llmMessage.create.mockResolvedValue({ id: "msg" });
247
+ mockPrisma.llmMessage.update.mockResolvedValue({ id: "msg" });
248
+
249
+ const session = (await loadAgentSession(
250
+ mockPrisma as any,
251
+ mockLlm,
252
+ LazyTestAgent as any,
253
+ "session-123"
254
+ ))!;
255
+
256
+ // Mock the instructions resolver again to proof turn-level override
257
+ LazyTestAgent.instructions = (i: any) => `Hi ${i.userName}, turn: ${i.turn}`;
258
+
259
+ await session.ask("Hello", { inputs: { turn: "1" } } as any);
260
+
261
+ const mockChat = (session as any).agent.chat;
262
+ expect(mockChat.ask).toHaveBeenCalledWith(
263
+ "Hello",
264
+ expect.objectContaining({
265
+ inputs: expect.objectContaining({
266
+ userName: "Bob",
267
+ turn: "1"
268
+ })
269
+ })
270
+ );
271
+ });
272
+ });
273
+
274
+ describe("Delegation & Metadata", () => {
275
+ it("delegates withTool to the underlying agent", async () => {
276
+ mockPrisma.llmAgentSession.findUnique.mockResolvedValue({
277
+ agentClass: "TestAgent",
278
+ metadata: {}
279
+ });
280
+ mockPrisma.llmMessage.findMany.mockResolvedValue([]);
281
+
282
+ const session = (await loadAgentSession(mockPrisma as any, mockLlm, TestAgent, "123"))!;
283
+ session.withTool({ name: "extra-tool" });
284
+
285
+ expect((session as any).agent.chat.withTools).toHaveBeenCalledWith(
286
+ [{ name: "extra-tool" }],
287
+ undefined
288
+ );
289
+ });
290
+
291
+ it("updates metadata and re-resolves lazy config", async () => {
292
+ class LazyAgent extends Agent<{ color: string }> {
293
+ static model = "mock-model";
294
+ static instructions = (i: any) => `Color is ${i.color}`;
295
+ }
296
+
297
+ mockPrisma.llmAgentSession.findUnique.mockResolvedValue({
298
+ id: "123",
299
+ agentClass: "LazyAgent",
300
+ metadata: { color: "red" }
301
+ });
302
+ mockPrisma.llmMessage.findMany.mockResolvedValue([]);
303
+ mockPrisma.llmAgentSession.update = vi.fn().mockResolvedValue({});
304
+
305
+ const session = (await loadAgentSession(
306
+ mockPrisma as any,
307
+ mockLlm,
308
+ LazyAgent as any,
309
+ "123"
310
+ ))!;
311
+
312
+ // Initial resolution
313
+ expect((session as any).agent.chat.withInstructions).toHaveBeenCalledWith("Color is red", {
314
+ replace: true
315
+ });
316
+
317
+ await session.updateMetadata({ color: "blue" });
318
+
319
+ // Verify DB update
320
+ expect(mockPrisma.llmAgentSession.update).toHaveBeenCalledWith(
321
+ expect.objectContaining({
322
+ data: { metadata: { color: "blue" } }
323
+ })
324
+ );
325
+
326
+ // Verify re-resolution
327
+ expect((session as any).agent.chat.withInstructions).toHaveBeenCalledWith("Color is blue", {
328
+ replace: true
329
+ });
330
+ });
331
+ });
204
332
  });
@@ -43,6 +43,7 @@ const mockChat = {
43
43
  onToolCallEnd: vi.fn().mockReturnThis(),
44
44
  onToolCallError: vi.fn().mockReturnThis(),
45
45
  onEndMessage: vi.fn().mockReturnThis(),
46
+ afterResponse: vi.fn().mockReturnThis(),
46
47
  ask: vi.fn(),
47
48
  messages: [],
48
49
  modelId: "agent-model"