@objectstack/service-ai 4.0.0 → 4.0.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.
Files changed (40) hide show
  1. package/.turbo/turbo-build.log +11 -11
  2. package/CHANGELOG.md +20 -0
  3. package/dist/index.cjs +1245 -54
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.d.cts +344 -77
  6. package/dist/index.d.ts +344 -77
  7. package/dist/index.js +1230 -51
  8. package/dist/index.js.map +1 -1
  9. package/package.json +26 -4
  10. package/src/__tests__/ai-service.test.ts +248 -27
  11. package/src/__tests__/auth-and-toolcalling.test.ts +627 -0
  12. package/src/__tests__/chatbot-features.test.ts +229 -82
  13. package/src/__tests__/metadata-tools.test.ts +964 -0
  14. package/src/__tests__/objectql-conversation-service.test.ts +34 -16
  15. package/src/__tests__/vercel-stream-encoder.test.ts +263 -0
  16. package/src/adapters/index.ts +2 -0
  17. package/src/adapters/memory-adapter.ts +17 -9
  18. package/src/adapters/vercel-adapter.ts +148 -0
  19. package/src/agent-runtime.ts +27 -3
  20. package/src/agents/index.ts +1 -0
  21. package/src/agents/metadata-assistant-agent.ts +87 -0
  22. package/src/ai-service.ts +174 -22
  23. package/src/conversation/in-memory-conversation-service.ts +2 -2
  24. package/src/conversation/objectql-conversation-service.ts +67 -18
  25. package/src/index.ts +22 -3
  26. package/src/plugin.ts +166 -9
  27. package/src/routes/agent-routes.ts +28 -3
  28. package/src/routes/ai-routes.ts +231 -14
  29. package/src/routes/index.ts +1 -1
  30. package/src/stream/index.ts +3 -0
  31. package/src/stream/vercel-stream-encoder.ts +129 -0
  32. package/src/tools/add-field.tool.ts +70 -0
  33. package/src/tools/create-object.tool.ts +66 -0
  34. package/src/tools/delete-field.tool.ts +38 -0
  35. package/src/tools/describe-metadata-object.tool.ts +32 -0
  36. package/src/tools/index.ts +12 -1
  37. package/src/tools/list-metadata-objects.tool.ts +34 -0
  38. package/src/tools/metadata-tools.ts +430 -0
  39. package/src/tools/modify-field.tool.ts +44 -0
  40. package/src/tools/tool-registry.ts +32 -9
package/dist/index.js CHANGED
@@ -8,7 +8,9 @@ var MemoryLLMAdapter = class {
8
8
  }
9
9
  async chat(messages, options) {
10
10
  const lastUserMessage = [...messages].reverse().find((m) => m.role === "user");
11
- const content = lastUserMessage ? `[memory] ${lastUserMessage.content}` : "[memory] (no user message)";
11
+ const userContent = lastUserMessage?.content;
12
+ const text = typeof userContent === "string" ? userContent : "(complex content)";
13
+ const content = lastUserMessage ? `[memory] ${text}` : "[memory] (no user message)";
12
14
  return {
13
15
  content,
14
16
  model: options?.model ?? "memory",
@@ -26,10 +28,15 @@ var MemoryLLMAdapter = class {
26
28
  const result = await this.chat(messages);
27
29
  const words = result.content.split(" ");
28
30
  for (let i = 0; i < words.length; i++) {
29
- const textDelta = i === 0 ? words[i] : ` ${words[i]}`;
30
- yield { type: "text-delta", textDelta };
31
+ const wordText = i === 0 ? words[i] : ` ${words[i]}`;
32
+ yield { type: "text-delta", id: `delta_${i}`, text: wordText };
31
33
  }
32
- yield { type: "finish", result };
34
+ yield {
35
+ type: "finish",
36
+ finishReason: "stop",
37
+ totalUsage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
38
+ rawFinishReason: "stop"
39
+ };
33
40
  }
34
41
  async embed(input) {
35
42
  const texts = Array.isArray(input) ? input : [input];
@@ -92,21 +99,34 @@ var ToolRegistry = class {
92
99
  * Execute a tool call and return the result.
93
100
  */
94
101
  async execute(toolCall) {
95
- const handler = this.handlers.get(toolCall.name);
102
+ const handler = this.handlers.get(toolCall.toolName);
96
103
  if (!handler) {
97
104
  return {
98
- toolCallId: toolCall.id,
99
- content: `Tool "${toolCall.name}" is not registered`,
105
+ type: "tool-result",
106
+ toolCallId: toolCall.toolCallId,
107
+ toolName: toolCall.toolName,
108
+ output: { type: "text", value: `Tool "${toolCall.toolName}" is not registered` },
100
109
  isError: true
101
110
  };
102
111
  }
103
112
  try {
104
- const args = JSON.parse(toolCall.arguments);
113
+ const args = typeof toolCall.input === "string" ? JSON.parse(toolCall.input) : toolCall.input ?? {};
105
114
  const content = await handler(args);
106
- return { toolCallId: toolCall.id, content };
115
+ return {
116
+ type: "tool-result",
117
+ toolCallId: toolCall.toolCallId,
118
+ toolName: toolCall.toolName,
119
+ output: { type: "text", value: content }
120
+ };
107
121
  } catch (err) {
108
122
  const message = err instanceof Error ? err.message : String(err);
109
- return { toolCallId: toolCall.id, content: message, isError: true };
123
+ return {
124
+ type: "tool-result",
125
+ toolCallId: toolCall.toolCallId,
126
+ toolName: toolCall.toolName,
127
+ output: { type: "text", value: message },
128
+ isError: true
129
+ };
110
130
  }
111
131
  }
112
132
  /**
@@ -192,6 +212,17 @@ var InMemoryConversationService = class {
192
212
  };
193
213
 
194
214
  // src/ai-service.ts
215
+ function textDeltaPart(id, text) {
216
+ return { type: "text-delta", id, text };
217
+ }
218
+ function finishPart(result) {
219
+ return {
220
+ type: "finish",
221
+ finishReason: "stop",
222
+ totalUsage: result?.usage ?? { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
223
+ rawFinishReason: "stop"
224
+ };
225
+ }
195
226
  var _AIService = class _AIService {
196
227
  constructor(config = {}) {
197
228
  this.adapter = config.adapter ?? new MemoryLLMAdapter();
@@ -219,8 +250,8 @@ var _AIService = class _AIService {
219
250
  this.logger.debug("[AI] streamChat", { messageCount: messages.length, model: options?.model });
220
251
  if (!this.adapter.streamChat) {
221
252
  const result = await this.adapter.chat(messages, options);
222
- yield { type: "text-delta", textDelta: result.content };
223
- yield { type: "finish", result };
253
+ yield textDeltaPart("fallback", result.content);
254
+ yield finishPart(result);
224
255
  return;
225
256
  }
226
257
  yield* this.adapter.streamChat(messages, options);
@@ -237,6 +268,10 @@ var _AIService = class _AIService {
237
268
  }
238
269
  return this.adapter.listModels();
239
270
  }
271
+ /** Extract the text value from a ToolExecutionResult's output. */
272
+ static extractOutputText(tr) {
273
+ return tr.output && typeof tr.output === "object" && "value" in tr.output ? String(tr.output.value) : "unknown error";
274
+ }
240
275
  /**
241
276
  * Chat with automatic tool call resolution.
242
277
  *
@@ -249,7 +284,7 @@ var _AIService = class _AIService {
249
284
  * maximum number of iterations (`maxIterations`) is reached.
250
285
  */
251
286
  async chatWithTools(messages, options) {
252
- const { maxIterations: maxIter, ...restOptions } = options ?? {};
287
+ const { maxIterations: maxIter, onToolError, ...restOptions } = options ?? {};
253
288
  const maxIterations = maxIter ?? _AIService.DEFAULT_MAX_ITERATIONS;
254
289
  const registeredTools = this.toolRegistry.getAll();
255
290
  const mergedTools = [
@@ -262,11 +297,13 @@ var _AIService = class _AIService {
262
297
  toolChoice: mergedTools.length > 0 ? restOptions.toolChoice ?? "auto" : void 0
263
298
  };
264
299
  const conversation = [...messages];
300
+ const toolErrors = [];
265
301
  this.logger.debug("[AI] chatWithTools start", {
266
302
  messageCount: conversation.length,
267
303
  toolCount: mergedTools.length,
268
304
  maxIterations
269
305
  });
306
+ let abortedByCallback = false;
270
307
  for (let iteration = 0; iteration < maxIterations; iteration++) {
271
308
  const result = await this.adapter.chat(conversation, chatOptions);
272
309
  if (!result.toolCalls || result.toolCalls.length === 0) {
@@ -275,23 +312,47 @@ var _AIService = class _AIService {
275
312
  }
276
313
  this.logger.debug("[AI] chatWithTools tool calls", {
277
314
  iteration,
278
- calls: result.toolCalls.map((tc) => tc.name)
315
+ calls: result.toolCalls.map((tc) => tc.toolName)
279
316
  });
317
+ const assistantContent = [];
318
+ if (result.content) assistantContent.push({ type: "text", text: result.content });
319
+ assistantContent.push(...result.toolCalls);
280
320
  conversation.push({
281
321
  role: "assistant",
282
- content: result.content ?? "",
283
- toolCalls: result.toolCalls
322
+ content: assistantContent
284
323
  });
285
324
  const toolResults = await this.toolRegistry.executeAll(result.toolCalls);
286
325
  for (const tr of toolResults) {
326
+ if (tr.isError) {
327
+ const matchedCall = result.toolCalls.find((tc) => tc.toolCallId === tr.toolCallId);
328
+ const toolName = matchedCall?.toolName ?? "unknown";
329
+ const errorText = _AIService.extractOutputText(tr);
330
+ const errorEntry = { iteration, toolName, error: errorText };
331
+ toolErrors.push(errorEntry);
332
+ this.logger.warn("[AI] chatWithTools tool error", errorEntry);
333
+ if (onToolError && matchedCall) {
334
+ const action = onToolError(matchedCall, errorText);
335
+ if (action === "abort") {
336
+ abortedByCallback = true;
337
+ }
338
+ }
339
+ }
287
340
  conversation.push({
288
341
  role: "tool",
289
- content: tr.content,
290
- toolCallId: tr.toolCallId
342
+ content: [tr]
291
343
  });
292
344
  }
345
+ if (abortedByCallback) {
346
+ break;
347
+ }
348
+ }
349
+ if (abortedByCallback) {
350
+ this.logger.warn("[AI] chatWithTools aborted by onToolError callback", { toolErrors });
351
+ } else {
352
+ this.logger.warn("[AI] chatWithTools max iterations reached, forcing final response", {
353
+ toolErrors: toolErrors.length > 0 ? toolErrors : void 0
354
+ });
293
355
  }
294
- this.logger.warn("[AI] chatWithTools max iterations reached, forcing final response");
295
356
  const finalResult = await this.adapter.chat(conversation, {
296
357
  ...chatOptions,
297
358
  tools: void 0,
@@ -299,14 +360,175 @@ var _AIService = class _AIService {
299
360
  });
300
361
  return finalResult;
301
362
  }
363
+ /**
364
+ * Stream chat with automatic tool call resolution.
365
+ *
366
+ * Works like {@link chatWithTools} but yields SSE events. When the model
367
+ * requests tool calls during streaming, they are executed and the results
368
+ * fed back until a final text stream is produced.
369
+ */
370
+ async *streamChatWithTools(messages, options) {
371
+ const { maxIterations: maxIter, onToolError, ...restOptions } = options ?? {};
372
+ const maxIterations = maxIter ?? _AIService.DEFAULT_MAX_ITERATIONS;
373
+ const registeredTools = this.toolRegistry.getAll();
374
+ const mergedTools = [
375
+ ...registeredTools,
376
+ ...restOptions.tools ?? []
377
+ ];
378
+ const chatOptions = {
379
+ ...restOptions,
380
+ tools: mergedTools.length > 0 ? mergedTools : void 0,
381
+ toolChoice: mergedTools.length > 0 ? restOptions.toolChoice ?? "auto" : void 0
382
+ };
383
+ const conversation = [...messages];
384
+ let abortedByCallback = false;
385
+ for (let iteration = 0; iteration < maxIterations; iteration++) {
386
+ const result2 = await this.adapter.chat(conversation, chatOptions);
387
+ if (!result2.toolCalls || result2.toolCalls.length === 0) {
388
+ yield textDeltaPart("stream", result2.content);
389
+ yield finishPart(result2);
390
+ return;
391
+ }
392
+ for (const tc of result2.toolCalls) {
393
+ yield { type: "tool-call", toolCallId: tc.toolCallId, toolName: tc.toolName, input: tc.input };
394
+ }
395
+ const assistantContent = [];
396
+ if (result2.content) assistantContent.push({ type: "text", text: result2.content });
397
+ assistantContent.push(...result2.toolCalls);
398
+ conversation.push({
399
+ role: "assistant",
400
+ content: assistantContent
401
+ });
402
+ const toolResults = await this.toolRegistry.executeAll(result2.toolCalls);
403
+ for (const tr of toolResults) {
404
+ if (tr.isError && onToolError) {
405
+ const matchedCall = result2.toolCalls.find((tc) => tc.toolCallId === tr.toolCallId);
406
+ if (matchedCall) {
407
+ const errorText = _AIService.extractOutputText(tr);
408
+ const action = onToolError(matchedCall, errorText);
409
+ if (action === "abort") {
410
+ abortedByCallback = true;
411
+ }
412
+ }
413
+ }
414
+ conversation.push({
415
+ role: "tool",
416
+ content: [tr]
417
+ });
418
+ }
419
+ if (abortedByCallback) {
420
+ break;
421
+ }
422
+ }
423
+ if (abortedByCallback) {
424
+ this.logger.warn("[AI] streamChatWithTools aborted by onToolError callback");
425
+ } else {
426
+ this.logger.warn("[AI] streamChatWithTools max iterations reached");
427
+ }
428
+ const finalOptions = { ...chatOptions, tools: void 0, toolChoice: void 0 };
429
+ const result = await this.adapter.chat(conversation, finalOptions);
430
+ yield textDeltaPart("stream", result.content);
431
+ yield finishPart(result);
432
+ }
302
433
  };
303
434
  // ── Tool Call Loop ────────────────────────────────────────────
304
435
  /** Default maximum iterations for the tool call loop. */
305
436
  _AIService.DEFAULT_MAX_ITERATIONS = 10;
306
437
  var AIService = _AIService;
307
438
 
439
+ // src/stream/vercel-stream-encoder.ts
440
+ function sse(data) {
441
+ return `data: ${JSON.stringify(data)}
442
+
443
+ `;
444
+ }
445
+ function encodeStreamPart(part) {
446
+ switch (part.type) {
447
+ case "text-delta":
448
+ return sse({ type: "text-delta", id: "0", delta: part.text });
449
+ case "tool-input-start":
450
+ return sse({
451
+ type: "tool-input-start",
452
+ toolCallId: part.id,
453
+ toolName: part.toolName
454
+ });
455
+ case "tool-input-delta":
456
+ return sse({
457
+ type: "tool-input-delta",
458
+ toolCallId: part.id,
459
+ inputTextDelta: part.delta
460
+ });
461
+ case "tool-call":
462
+ return sse({
463
+ type: "tool-input-available",
464
+ toolCallId: part.toolCallId,
465
+ toolName: part.toolName,
466
+ input: part.input
467
+ });
468
+ case "tool-result":
469
+ return sse({
470
+ type: "tool-output-available",
471
+ toolCallId: part.toolCallId,
472
+ output: part.output
473
+ });
474
+ case "error":
475
+ return sse({
476
+ type: "error",
477
+ errorText: String(part.error)
478
+ });
479
+ // finish-step and finish are handled by the generator, not here
480
+ default:
481
+ return "";
482
+ }
483
+ }
484
+ async function* encodeVercelDataStream(events) {
485
+ yield sse({ type: "start" });
486
+ yield sse({ type: "start-step" });
487
+ yield sse({ type: "text-start", id: "0" });
488
+ let textOpen = true;
489
+ let finishReason = "stop";
490
+ for await (const part of events) {
491
+ if (part.type === "finish") {
492
+ finishReason = part.finishReason ?? "stop";
493
+ }
494
+ if (part.type === "finish-step" || part.type === "finish") {
495
+ if (textOpen) {
496
+ yield sse({ type: "text-end", id: "0" });
497
+ textOpen = false;
498
+ }
499
+ continue;
500
+ }
501
+ const frame = encodeStreamPart(part);
502
+ if (frame) {
503
+ yield frame;
504
+ }
505
+ }
506
+ if (textOpen) {
507
+ yield sse({ type: "text-end", id: "0" });
508
+ }
509
+ yield sse({ type: "finish-step" });
510
+ yield sse({ type: "finish", finishReason });
511
+ yield "data: [DONE]\n\n";
512
+ }
513
+
308
514
  // src/routes/ai-routes.ts
309
515
  var VALID_ROLES = /* @__PURE__ */ new Set(["system", "user", "assistant", "tool"]);
516
+ function normalizeMessage(raw) {
517
+ const role = raw.role;
518
+ if (typeof raw.content === "string") {
519
+ return { role, content: raw.content };
520
+ }
521
+ if (Array.isArray(raw.content)) {
522
+ return { role, content: raw.content };
523
+ }
524
+ if (Array.isArray(raw.parts)) {
525
+ const textParts = raw.parts.filter((p) => p.type === "text" && typeof p.text === "string").map((p) => p.text);
526
+ if (textParts.length > 0) {
527
+ return { role, content: textParts.join("") };
528
+ }
529
+ }
530
+ return { role, content: "" };
531
+ }
310
532
  function validateMessage(raw) {
311
533
  if (typeof raw !== "object" || raw === null) {
312
534
  return "each message must be an object";
@@ -315,20 +537,56 @@ function validateMessage(raw) {
315
537
  if (typeof msg.role !== "string" || !VALID_ROLES.has(msg.role)) {
316
538
  return `message.role must be one of ${[...VALID_ROLES].map((r) => `"${r}"`).join(", ")}`;
317
539
  }
318
- if (typeof msg.content !== "string") {
319
- return "message.content must be a string";
540
+ const content = msg.content;
541
+ if (Array.isArray(msg.parts)) {
542
+ return null;
320
543
  }
321
- return null;
544
+ if (typeof content === "string") {
545
+ return null;
546
+ }
547
+ if (Array.isArray(content)) {
548
+ for (const part of content) {
549
+ if (typeof part !== "object" || part === null) {
550
+ return "message.content array elements must be non-null objects";
551
+ }
552
+ const partObj = part;
553
+ if (typeof partObj.type !== "string") {
554
+ return 'each message.content array element must have a string "type" property';
555
+ }
556
+ if (partObj.type === "text" && typeof partObj.text !== "string") {
557
+ return 'message.content elements with type "text" must have a string "text" property';
558
+ }
559
+ }
560
+ return null;
561
+ }
562
+ if (content === null || content === void 0) {
563
+ if (msg.role === "assistant" || msg.role === "tool") {
564
+ return null;
565
+ }
566
+ }
567
+ return "message.content must be a string, an array, or include parts";
322
568
  }
323
569
  function buildAIRoutes(aiService, conversationService, logger) {
324
570
  return [
325
571
  // ── Chat ────────────────────────────────────────────────────
572
+ //
573
+ // Dual-mode endpoint compatible with both the legacy ObjectStack
574
+ // format (`{ messages, options }`) and the Vercel AI SDK useChat
575
+ // flat format (`{ messages, system, model, stream, … }`).
576
+ //
577
+ // Behaviour:
578
+ // • `stream !== false` → Vercel Data Stream Protocol (SSE)
579
+ // • `stream === false` → JSON response (legacy)
580
+ //
326
581
  {
327
582
  method: "POST",
328
583
  path: "/api/v1/ai/chat",
329
- description: "Synchronous chat completion",
584
+ description: "Chat completion (supports Vercel AI Data Stream Protocol)",
585
+ auth: true,
586
+ permissions: ["ai:chat"],
330
587
  handler: async (req) => {
331
- const { messages, options } = req.body ?? {};
588
+ const body = req.body ?? {};
589
+ const messages = body.messages;
332
590
  if (!Array.isArray(messages) || messages.length === 0) {
333
591
  return { status: 400, body: { error: "messages array is required" } };
334
592
  }
@@ -336,8 +594,49 @@ function buildAIRoutes(aiService, conversationService, logger) {
336
594
  const err = validateMessage(msg);
337
595
  if (err) return { status: 400, body: { error: err } };
338
596
  }
597
+ const nested = body.options ?? {};
598
+ const resolvedOptions = {
599
+ ...nested,
600
+ ...body.model != null && { model: body.model },
601
+ ...body.temperature != null && { temperature: body.temperature },
602
+ ...body.maxTokens != null && { maxTokens: body.maxTokens }
603
+ };
604
+ const rawSystemPrompt = body.system ?? body.systemPrompt;
605
+ if (rawSystemPrompt != null && typeof rawSystemPrompt !== "string") {
606
+ return { status: 400, body: { error: "system/systemPrompt must be a string" } };
607
+ }
608
+ const systemPrompt = rawSystemPrompt;
609
+ const finalMessages = [
610
+ ...systemPrompt ? [{ role: "system", content: systemPrompt }] : [],
611
+ ...messages.map((m) => normalizeMessage(m))
612
+ ];
613
+ const wantStream = body.stream !== false;
614
+ if (wantStream) {
615
+ try {
616
+ if (!aiService.streamChatWithTools) {
617
+ return { status: 501, body: { error: "Streaming is not supported by the configured AI service" } };
618
+ }
619
+ const events = aiService.streamChatWithTools(finalMessages, resolvedOptions);
620
+ return {
621
+ status: 200,
622
+ stream: true,
623
+ vercelDataStream: true,
624
+ contentType: "text/event-stream",
625
+ headers: {
626
+ "Content-Type": "text/event-stream",
627
+ "Cache-Control": "no-cache",
628
+ "Connection": "keep-alive",
629
+ "x-vercel-ai-ui-message-stream": "v1"
630
+ },
631
+ events: encodeVercelDataStream(events)
632
+ };
633
+ } catch (err) {
634
+ logger.error("[AI Route] /chat stream error", err instanceof Error ? err : void 0);
635
+ return { status: 500, body: { error: "Internal AI service error" } };
636
+ }
637
+ }
339
638
  try {
340
- const result = await aiService.chat(messages, options);
639
+ const result = await aiService.chatWithTools(finalMessages, resolvedOptions);
341
640
  return { status: 200, body: result };
342
641
  } catch (err) {
343
642
  logger.error("[AI Route] /chat error", err instanceof Error ? err : void 0);
@@ -350,6 +649,8 @@ function buildAIRoutes(aiService, conversationService, logger) {
350
649
  method: "POST",
351
650
  path: "/api/v1/ai/chat/stream",
352
651
  description: "SSE streaming chat completion",
652
+ auth: true,
653
+ permissions: ["ai:chat"],
353
654
  handler: async (req) => {
354
655
  const { messages, options } = req.body ?? {};
355
656
  if (!Array.isArray(messages) || messages.length === 0) {
@@ -363,7 +664,7 @@ function buildAIRoutes(aiService, conversationService, logger) {
363
664
  if (!aiService.streamChat) {
364
665
  return { status: 501, body: { error: "Streaming is not supported by the configured AI service" } };
365
666
  }
366
- const events = aiService.streamChat(messages, options);
667
+ const events = aiService.streamChat(messages.map((m) => normalizeMessage(m)), options);
367
668
  return { status: 200, stream: true, events };
368
669
  } catch (err) {
369
670
  logger.error("[AI Route] /chat/stream error", err instanceof Error ? err : void 0);
@@ -376,6 +677,8 @@ function buildAIRoutes(aiService, conversationService, logger) {
376
677
  method: "POST",
377
678
  path: "/api/v1/ai/complete",
378
679
  description: "Text completion",
680
+ auth: true,
681
+ permissions: ["ai:complete"],
379
682
  handler: async (req) => {
380
683
  const { prompt, options } = req.body ?? {};
381
684
  if (!prompt || typeof prompt !== "string") {
@@ -395,6 +698,8 @@ function buildAIRoutes(aiService, conversationService, logger) {
395
698
  method: "GET",
396
699
  path: "/api/v1/ai/models",
397
700
  description: "List available models",
701
+ auth: true,
702
+ permissions: ["ai:read"],
398
703
  handler: async () => {
399
704
  try {
400
705
  const models = aiService.listModels ? await aiService.listModels() : [];
@@ -410,9 +715,17 @@ function buildAIRoutes(aiService, conversationService, logger) {
410
715
  method: "POST",
411
716
  path: "/api/v1/ai/conversations",
412
717
  description: "Create a conversation",
718
+ auth: true,
719
+ permissions: ["ai:conversations"],
413
720
  handler: async (req) => {
414
721
  try {
415
- const options = req.body ?? {};
722
+ if (req.body !== void 0 && req.body !== null && (typeof req.body !== "object" || Array.isArray(req.body))) {
723
+ return { status: 400, body: { error: "Invalid request payload" } };
724
+ }
725
+ const options = { ...req.body ?? {} };
726
+ if (req.user?.userId) {
727
+ options.userId = req.user.userId;
728
+ }
416
729
  const conversation = await conversationService.create(options);
417
730
  return { status: 201, body: conversation };
418
731
  } catch (err) {
@@ -425,6 +738,8 @@ function buildAIRoutes(aiService, conversationService, logger) {
425
738
  method: "GET",
426
739
  path: "/api/v1/ai/conversations",
427
740
  description: "List conversations",
741
+ auth: true,
742
+ permissions: ["ai:conversations"],
428
743
  handler: async (req) => {
429
744
  try {
430
745
  const rawQuery = req.query ?? {};
@@ -436,6 +751,9 @@ function buildAIRoutes(aiService, conversationService, logger) {
436
751
  }
437
752
  options.limit = parsedLimit;
438
753
  }
754
+ if (req.user?.userId) {
755
+ options.userId = req.user.userId;
756
+ }
439
757
  const conversations = await conversationService.list(options);
440
758
  return { status: 200, body: { conversations } };
441
759
  } catch (err) {
@@ -448,6 +766,8 @@ function buildAIRoutes(aiService, conversationService, logger) {
448
766
  method: "POST",
449
767
  path: "/api/v1/ai/conversations/:id/messages",
450
768
  description: "Add message to a conversation",
769
+ auth: true,
770
+ permissions: ["ai:conversations"],
451
771
  handler: async (req) => {
452
772
  const id = req.params?.id;
453
773
  if (!id) {
@@ -459,6 +779,15 @@ function buildAIRoutes(aiService, conversationService, logger) {
459
779
  return { status: 400, body: { error: validationError } };
460
780
  }
461
781
  try {
782
+ if (req.user?.userId) {
783
+ const existing = await conversationService.get(id);
784
+ if (!existing) {
785
+ return { status: 404, body: { error: `Conversation "${id}" not found` } };
786
+ }
787
+ if (existing.userId && existing.userId !== req.user.userId) {
788
+ return { status: 403, body: { error: "You do not have access to this conversation" } };
789
+ }
790
+ }
462
791
  const conversation = await conversationService.addMessage(id, message);
463
792
  return { status: 200, body: conversation };
464
793
  } catch (err) {
@@ -475,12 +804,23 @@ function buildAIRoutes(aiService, conversationService, logger) {
475
804
  method: "DELETE",
476
805
  path: "/api/v1/ai/conversations/:id",
477
806
  description: "Delete a conversation",
807
+ auth: true,
808
+ permissions: ["ai:conversations"],
478
809
  handler: async (req) => {
479
810
  const id = req.params?.id;
480
811
  if (!id) {
481
812
  return { status: 400, body: { error: "conversation id is required" } };
482
813
  }
483
814
  try {
815
+ if (req.user?.userId) {
816
+ const existing = await conversationService.get(id);
817
+ if (!existing) {
818
+ return { status: 404, body: { error: `Conversation "${id}" not found` } };
819
+ }
820
+ if (existing.userId && existing.userId !== req.user.userId) {
821
+ return { status: 403, body: { error: "You do not have access to this conversation" } };
822
+ }
823
+ }
484
824
  await conversationService.delete(id);
485
825
  return { status: 204 };
486
826
  } catch (err) {
@@ -509,10 +849,33 @@ function validateAgentMessage(raw) {
509
849
  }
510
850
  function buildAgentRoutes(aiService, agentRuntime, logger) {
511
851
  return [
852
+ // ── List active agents ──────────────────────────────────────
853
+ {
854
+ method: "GET",
855
+ path: "/api/v1/ai/agents",
856
+ description: "List all active AI agents",
857
+ auth: true,
858
+ permissions: ["ai:chat"],
859
+ handler: async () => {
860
+ try {
861
+ const agents = await agentRuntime.listAgents();
862
+ return { status: 200, body: { agents } };
863
+ } catch (err) {
864
+ logger.error(
865
+ "[AI Route] /agents list error",
866
+ err instanceof Error ? err : void 0
867
+ );
868
+ return { status: 500, body: { error: "Internal AI service error" } };
869
+ }
870
+ }
871
+ },
872
+ // ── Chat with a specific agent ──────────────────────────────
512
873
  {
513
874
  method: "POST",
514
875
  path: "/api/v1/ai/agents/:agentName/chat",
515
876
  description: "Chat with a specific AI agent",
877
+ auth: true,
878
+ permissions: ["ai:chat", "ai:agents"],
516
879
  handler: async (req) => {
517
880
  const agentName = req.params?.agentName;
518
881
  if (!agentName) {
@@ -666,13 +1029,35 @@ var ObjectQLConversationService = class {
666
1029
  }
667
1030
  const now = (/* @__PURE__ */ new Date()).toISOString();
668
1031
  const msgId = `msg_${randomUUID()}`;
1032
+ let contentStr;
1033
+ let toolCallsJson = null;
1034
+ let toolCallId = null;
1035
+ if (message.role === "system" || message.role === "user") {
1036
+ contentStr = typeof message.content === "string" ? message.content : JSON.stringify(message.content);
1037
+ } else if (message.role === "assistant") {
1038
+ if (typeof message.content === "string") {
1039
+ contentStr = message.content;
1040
+ } else {
1041
+ const parts = message.content;
1042
+ const textParts = parts.filter((p) => p.type === "text").map((p) => p.text);
1043
+ const toolCalls = parts.filter((p) => p.type === "tool-call");
1044
+ contentStr = textParts.join("");
1045
+ if (toolCalls.length > 0) toolCallsJson = JSON.stringify(toolCalls);
1046
+ }
1047
+ } else if (message.role === "tool") {
1048
+ contentStr = JSON.stringify(message.content);
1049
+ const firstResult = Array.isArray(message.content) ? message.content[0] : void 0;
1050
+ if (firstResult && "toolCallId" in firstResult) toolCallId = firstResult.toolCallId;
1051
+ } else {
1052
+ contentStr = "";
1053
+ }
669
1054
  await this.engine.insert(MESSAGES_OBJECT, {
670
1055
  id: msgId,
671
1056
  conversation_id: conversationId,
672
1057
  role: message.role,
673
- content: message.content,
674
- tool_calls: message.toolCalls ? JSON.stringify(message.toolCalls) : null,
675
- tool_call_id: message.toolCallId ?? null,
1058
+ content: contentStr,
1059
+ tool_calls: toolCallsJson,
1060
+ tool_call_id: toolCallId,
676
1061
  created_at: now
677
1062
  });
678
1063
  await this.engine.update(CONVERSATIONS_OBJECT, { id: conversationId, updated_at: now }, {
@@ -717,21 +1102,42 @@ var ObjectQLConversationService = class {
717
1102
  };
718
1103
  }
719
1104
  /**
720
- * Map a database row to an AIMessage.
1105
+ * Map a database row to a ModelMessage.
721
1106
  */
722
1107
  toMessage(row) {
723
- const msg = {
724
- role: row.role,
725
- content: row.content
726
- };
727
- const toolCalls = this.safeParse(row.tool_calls);
728
- if (toolCalls) {
729
- msg.toolCalls = toolCalls;
730
- }
731
- if (row.tool_call_id) {
732
- msg.toolCallId = row.tool_call_id;
1108
+ switch (row.role) {
1109
+ case "system":
1110
+ return { role: "system", content: row.content };
1111
+ case "user":
1112
+ return { role: "user", content: row.content };
1113
+ case "assistant": {
1114
+ const toolCalls = this.safeParse(row.tool_calls);
1115
+ if (toolCalls && toolCalls.length > 0) {
1116
+ const content = [];
1117
+ if (row.content) content.push({ type: "text", text: row.content });
1118
+ content.push(...toolCalls);
1119
+ return { role: "assistant", content };
1120
+ }
1121
+ return { role: "assistant", content: row.content };
1122
+ }
1123
+ case "tool": {
1124
+ const toolResults = this.safeParse(row.content);
1125
+ if (toolResults && toolResults.length > 0 && toolResults[0]?.type === "tool-result") {
1126
+ return { role: "tool", content: toolResults };
1127
+ }
1128
+ return {
1129
+ role: "tool",
1130
+ content: [{
1131
+ type: "tool-result",
1132
+ toolCallId: row.tool_call_id ?? "",
1133
+ toolName: "unknown",
1134
+ output: { type: "text", value: row.content }
1135
+ }]
1136
+ };
1137
+ }
1138
+ default:
1139
+ return { role: "user", content: row.content };
733
1140
  }
734
- return msg;
735
1141
  }
736
1142
  };
737
1143
 
@@ -1128,6 +1534,479 @@ function registerDataTools(registry, context) {
1128
1534
  registry.register(AGGREGATE_DATA_TOOL, createAggregateDataHandler(context));
1129
1535
  }
1130
1536
 
1537
+ // src/tools/create-object.tool.ts
1538
+ import { defineTool } from "@objectstack/spec/ai";
1539
+ var createObjectTool = defineTool({
1540
+ name: "create_object",
1541
+ label: "Create Object",
1542
+ description: "Creates a new data object (table) with the specified name, label, and optional field definitions. Use this when the user wants to create a new entity, table, or data model.",
1543
+ category: "data",
1544
+ builtIn: true,
1545
+ // NOTE: requiresConfirmation is intentionally false (default) because the
1546
+ // server-side tool-call loop in AIService.chatWithTools/streamChatWithTools
1547
+ // executes tool calls immediately without checking this flag. The flag
1548
+ // should only be set once server-side approval gating is implemented to
1549
+ // avoid giving users a false sense of safety.
1550
+ parameters: {
1551
+ type: "object",
1552
+ properties: {
1553
+ name: {
1554
+ type: "string",
1555
+ description: "Machine name for the object (snake_case, e.g. project_task)"
1556
+ },
1557
+ label: {
1558
+ type: "string",
1559
+ description: "Human-readable display name (e.g. Project Task)"
1560
+ },
1561
+ fields: {
1562
+ type: "array",
1563
+ description: "Initial fields to create with the object",
1564
+ items: {
1565
+ type: "object",
1566
+ properties: {
1567
+ name: { type: "string", description: "Field machine name (snake_case)" },
1568
+ label: { type: "string", description: "Field display name" },
1569
+ type: {
1570
+ type: "string",
1571
+ description: "Field data type",
1572
+ enum: ["text", "textarea", "number", "boolean", "date", "datetime", "select", "lookup", "formula", "autonumber"]
1573
+ },
1574
+ required: { type: "boolean", description: "Whether the field is required" }
1575
+ },
1576
+ required: ["name", "type"]
1577
+ }
1578
+ },
1579
+ enableFeatures: {
1580
+ type: "object",
1581
+ description: "Object capability flags",
1582
+ properties: {
1583
+ trackHistory: { type: "boolean" },
1584
+ apiEnabled: { type: "boolean" }
1585
+ }
1586
+ }
1587
+ },
1588
+ required: ["name", "label"],
1589
+ additionalProperties: false
1590
+ }
1591
+ });
1592
+
1593
+ // src/tools/add-field.tool.ts
1594
+ import { defineTool as defineTool2 } from "@objectstack/spec/ai";
1595
+ var addFieldTool = defineTool2({
1596
+ name: "add_field",
1597
+ label: "Add Field",
1598
+ description: "Adds a new field (column) to an existing data object. Use this when the user wants to add a property, column, or attribute to a table.",
1599
+ category: "data",
1600
+ builtIn: true,
1601
+ parameters: {
1602
+ type: "object",
1603
+ properties: {
1604
+ objectName: {
1605
+ type: "string",
1606
+ description: "Target object machine name (snake_case)"
1607
+ },
1608
+ name: {
1609
+ type: "string",
1610
+ description: "Field machine name (snake_case, e.g. due_date)"
1611
+ },
1612
+ label: {
1613
+ type: "string",
1614
+ description: "Human-readable field label (e.g. Due Date)"
1615
+ },
1616
+ type: {
1617
+ type: "string",
1618
+ description: "Field data type",
1619
+ enum: ["text", "textarea", "number", "boolean", "date", "datetime", "select", "lookup", "formula", "autonumber"]
1620
+ },
1621
+ required: {
1622
+ type: "boolean",
1623
+ description: "Whether the field is required"
1624
+ },
1625
+ defaultValue: {
1626
+ description: "Default value for the field"
1627
+ },
1628
+ options: {
1629
+ type: "array",
1630
+ description: "Options for select/picklist fields",
1631
+ items: {
1632
+ type: "object",
1633
+ properties: {
1634
+ label: { type: "string" },
1635
+ value: {
1636
+ type: "string",
1637
+ description: "Option machine identifier (lowercase snake_case, e.g. high_priority)",
1638
+ pattern: "^[a-z_][a-z0-9_]*$"
1639
+ }
1640
+ }
1641
+ }
1642
+ },
1643
+ reference: {
1644
+ type: "string",
1645
+ description: "Referenced object name for lookup fields (snake_case, e.g. account)"
1646
+ }
1647
+ },
1648
+ required: ["objectName", "name", "type"],
1649
+ additionalProperties: false
1650
+ }
1651
+ });
1652
+
1653
+ // src/tools/modify-field.tool.ts
1654
+ import { defineTool as defineTool3 } from "@objectstack/spec/ai";
1655
+ var modifyFieldTool = defineTool3({
1656
+ name: "modify_field",
1657
+ label: "Modify Field",
1658
+ description: "Modifies an existing field definition (label, type, required, default value, etc.) on a data object. Use this when the user wants to change or reconfigure an existing column or attribute (not rename it).",
1659
+ category: "data",
1660
+ builtIn: true,
1661
+ parameters: {
1662
+ type: "object",
1663
+ properties: {
1664
+ objectName: {
1665
+ type: "string",
1666
+ description: "Target object machine name (snake_case)"
1667
+ },
1668
+ fieldName: {
1669
+ type: "string",
1670
+ description: "Existing field machine name to modify (snake_case)"
1671
+ },
1672
+ changes: {
1673
+ type: "object",
1674
+ description: "Field properties to update (partial patch)",
1675
+ properties: {
1676
+ label: { type: "string", description: "New display label" },
1677
+ type: { type: "string", description: "New field type" },
1678
+ required: { type: "boolean", description: "Update required constraint" },
1679
+ defaultValue: { description: "New default value" }
1680
+ }
1681
+ }
1682
+ },
1683
+ required: ["objectName", "fieldName", "changes"],
1684
+ additionalProperties: false
1685
+ }
1686
+ });
1687
+
1688
+ // src/tools/delete-field.tool.ts
1689
+ import { defineTool as defineTool4 } from "@objectstack/spec/ai";
1690
+ var deleteFieldTool = defineTool4({
1691
+ name: "delete_field",
1692
+ label: "Delete Field",
1693
+ description: "Removes a field (column) from an existing data object. This is a destructive operation. Use this when the user explicitly wants to remove an attribute or column from a table.",
1694
+ category: "data",
1695
+ builtIn: true,
1696
+ // NOTE: requiresConfirmation is intentionally false (default) because the
1697
+ // server-side tool-call loop in AIService.chatWithTools/streamChatWithTools
1698
+ // executes tool calls immediately without checking this flag. The flag
1699
+ // should only be set once server-side approval gating is implemented.
1700
+ parameters: {
1701
+ type: "object",
1702
+ properties: {
1703
+ objectName: {
1704
+ type: "string",
1705
+ description: "Target object machine name (snake_case)"
1706
+ },
1707
+ fieldName: {
1708
+ type: "string",
1709
+ description: "Field machine name to delete (snake_case)"
1710
+ }
1711
+ },
1712
+ required: ["objectName", "fieldName"],
1713
+ additionalProperties: false
1714
+ }
1715
+ });
1716
+
1717
+ // src/tools/list-metadata-objects.tool.ts
1718
+ import { defineTool as defineTool5 } from "@objectstack/spec/ai";
1719
+ var listMetadataObjectsTool = defineTool5({
1720
+ name: "list_metadata_objects",
1721
+ label: "List Metadata Objects",
1722
+ description: "Lists all registered metadata objects (tables) in the current environment. Use this when the user wants to see what tables, entities, or data models are defined in metadata.",
1723
+ category: "data",
1724
+ builtIn: true,
1725
+ parameters: {
1726
+ type: "object",
1727
+ properties: {
1728
+ filter: {
1729
+ type: "string",
1730
+ description: "Optional name or label substring to filter objects"
1731
+ },
1732
+ includeFields: {
1733
+ type: "boolean",
1734
+ description: "Whether to include field summaries for each object (default: false)"
1735
+ }
1736
+ },
1737
+ additionalProperties: false
1738
+ }
1739
+ });
1740
+
1741
+ // src/tools/describe-metadata-object.tool.ts
1742
+ import { defineTool as defineTool6 } from "@objectstack/spec/ai";
1743
+ var describeMetadataObjectTool = defineTool6({
1744
+ name: "describe_metadata_object",
1745
+ label: "Describe Metadata Object",
1746
+ description: "Returns the full metadata schema details of a data object, including all fields, types, relationships, and configuration. Use this when the user wants to inspect or understand the metadata structure of a specific table or entity.",
1747
+ category: "data",
1748
+ builtIn: true,
1749
+ parameters: {
1750
+ type: "object",
1751
+ properties: {
1752
+ objectName: {
1753
+ type: "string",
1754
+ description: "Object machine name to describe (snake_case)"
1755
+ }
1756
+ },
1757
+ required: ["objectName"],
1758
+ additionalProperties: false
1759
+ }
1760
+ });
1761
+
1762
+ // src/tools/metadata-tools.ts
1763
+ var METADATA_TOOL_DEFINITIONS = [
1764
+ createObjectTool,
1765
+ addFieldTool,
1766
+ modifyFieldTool,
1767
+ deleteFieldTool,
1768
+ listMetadataObjectsTool,
1769
+ describeMetadataObjectTool
1770
+ ];
1771
+ var SNAKE_CASE_RE = /^[a-z_][a-z0-9_]*$/;
1772
+ function isSnakeCase(value) {
1773
+ return SNAKE_CASE_RE.test(value);
1774
+ }
1775
+ function createCreateObjectHandler(ctx) {
1776
+ return async (args) => {
1777
+ const { name, label, fields, enableFeatures } = args;
1778
+ if (!name || !label) {
1779
+ return JSON.stringify({ error: 'Both "name" and "label" are required' });
1780
+ }
1781
+ if (!isSnakeCase(name)) {
1782
+ return JSON.stringify({ error: `Invalid object name "${name}". Must be snake_case.` });
1783
+ }
1784
+ const existing = await ctx.metadataService.getObject(name);
1785
+ if (existing) {
1786
+ return JSON.stringify({ error: `Object "${name}" already exists` });
1787
+ }
1788
+ const fieldMap = {};
1789
+ if (fields && Array.isArray(fields)) {
1790
+ const seenNames = /* @__PURE__ */ new Set();
1791
+ for (const f of fields) {
1792
+ if (!f.name) {
1793
+ return JSON.stringify({ error: 'Each field must have a "name" property' });
1794
+ }
1795
+ if (!isSnakeCase(f.name)) {
1796
+ return JSON.stringify({ error: `Invalid field name "${f.name}". Must be snake_case.` });
1797
+ }
1798
+ if (seenNames.has(f.name)) {
1799
+ return JSON.stringify({ error: `Duplicate field name "${f.name}" in initial fields` });
1800
+ }
1801
+ seenNames.add(f.name);
1802
+ fieldMap[f.name] = {
1803
+ type: f.type,
1804
+ ...f.label ? { label: f.label } : {},
1805
+ ...f.required !== void 0 ? { required: f.required } : {}
1806
+ };
1807
+ }
1808
+ }
1809
+ const objectDef = {
1810
+ name,
1811
+ label,
1812
+ ...Object.keys(fieldMap).length > 0 ? { fields: fieldMap } : {},
1813
+ ...enableFeatures ? { enable: enableFeatures } : {}
1814
+ };
1815
+ await ctx.metadataService.register("object", name, objectDef);
1816
+ return JSON.stringify({
1817
+ name,
1818
+ label,
1819
+ fieldCount: Object.keys(fieldMap).length
1820
+ });
1821
+ };
1822
+ }
1823
+ function createAddFieldHandler(ctx) {
1824
+ return async (args) => {
1825
+ const { objectName, name, label, type, required, defaultValue, options, reference } = args;
1826
+ if (!objectName || !name || !type) {
1827
+ return JSON.stringify({ error: '"objectName", "name", and "type" are required' });
1828
+ }
1829
+ if (!isSnakeCase(objectName)) {
1830
+ return JSON.stringify({ error: `Invalid object name "${objectName}". Must be snake_case.` });
1831
+ }
1832
+ if (!isSnakeCase(name)) {
1833
+ return JSON.stringify({ error: `Invalid field name "${name}". Must be snake_case.` });
1834
+ }
1835
+ if (reference && !isSnakeCase(reference)) {
1836
+ return JSON.stringify({ error: `Invalid reference "${reference}". Must be a snake_case object name.` });
1837
+ }
1838
+ if (options && Array.isArray(options)) {
1839
+ for (const opt of options) {
1840
+ if (opt.value && !isSnakeCase(opt.value)) {
1841
+ return JSON.stringify({ error: `Invalid option value "${opt.value}". Must be lowercase snake_case.` });
1842
+ }
1843
+ }
1844
+ }
1845
+ const objectDef = await ctx.metadataService.getObject(objectName);
1846
+ if (!objectDef) {
1847
+ return JSON.stringify({ error: `Object "${objectName}" not found` });
1848
+ }
1849
+ const def = objectDef;
1850
+ if (def.fields && def.fields[name]) {
1851
+ return JSON.stringify({ error: `Field "${name}" already exists on object "${objectName}"` });
1852
+ }
1853
+ const fieldDef = {
1854
+ type,
1855
+ ...label ? { label } : {},
1856
+ ...required !== void 0 ? { required } : {},
1857
+ ...defaultValue !== void 0 ? { defaultValue } : {},
1858
+ ...options ? { options } : {},
1859
+ ...reference ? { reference } : {}
1860
+ };
1861
+ const updatedFields = { ...def.fields ?? {}, [name]: fieldDef };
1862
+ await ctx.metadataService.register("object", objectName, {
1863
+ ...def,
1864
+ fields: updatedFields
1865
+ });
1866
+ return JSON.stringify({
1867
+ objectName,
1868
+ fieldName: name,
1869
+ fieldType: type
1870
+ });
1871
+ };
1872
+ }
1873
+ function createModifyFieldHandler(ctx) {
1874
+ return async (args) => {
1875
+ const { objectName, fieldName, changes } = args;
1876
+ if (!objectName || !fieldName || !changes) {
1877
+ return JSON.stringify({ error: '"objectName", "fieldName", and "changes" are required' });
1878
+ }
1879
+ if (!isSnakeCase(objectName)) {
1880
+ return JSON.stringify({ error: `Invalid object name "${objectName}". Must be snake_case.` });
1881
+ }
1882
+ if (!isSnakeCase(fieldName)) {
1883
+ return JSON.stringify({ error: `Invalid field name "${fieldName}". Must be snake_case.` });
1884
+ }
1885
+ const objectDef = await ctx.metadataService.getObject(objectName);
1886
+ if (!objectDef) {
1887
+ return JSON.stringify({ error: `Object "${objectName}" not found` });
1888
+ }
1889
+ const def = objectDef;
1890
+ if (!def.fields || !def.fields[fieldName]) {
1891
+ return JSON.stringify({ error: `Field "${fieldName}" not found on object "${objectName}"` });
1892
+ }
1893
+ const existingField = def.fields[fieldName];
1894
+ const updatedField = { ...existingField, ...changes };
1895
+ const updatedFields = { ...def.fields, [fieldName]: updatedField };
1896
+ await ctx.metadataService.register("object", objectName, {
1897
+ ...def,
1898
+ fields: updatedFields
1899
+ });
1900
+ return JSON.stringify({
1901
+ objectName,
1902
+ fieldName,
1903
+ updatedProperties: Object.keys(changes)
1904
+ });
1905
+ };
1906
+ }
1907
+ function createDeleteFieldHandler(ctx) {
1908
+ return async (args) => {
1909
+ const { objectName, fieldName } = args;
1910
+ if (!objectName || !fieldName) {
1911
+ return JSON.stringify({ error: '"objectName" and "fieldName" are required' });
1912
+ }
1913
+ if (!isSnakeCase(objectName)) {
1914
+ return JSON.stringify({ error: `Invalid object name "${objectName}". Must be snake_case.` });
1915
+ }
1916
+ if (!isSnakeCase(fieldName)) {
1917
+ return JSON.stringify({ error: `Invalid field name "${fieldName}". Must be snake_case.` });
1918
+ }
1919
+ const objectDef = await ctx.metadataService.getObject(objectName);
1920
+ if (!objectDef) {
1921
+ return JSON.stringify({ error: `Object "${objectName}" not found` });
1922
+ }
1923
+ const def = objectDef;
1924
+ if (!def.fields || !def.fields[fieldName]) {
1925
+ return JSON.stringify({ error: `Field "${fieldName}" not found on object "${objectName}"` });
1926
+ }
1927
+ const { [fieldName]: _removed, ...remainingFields } = def.fields;
1928
+ await ctx.metadataService.register("object", objectName, {
1929
+ ...def,
1930
+ fields: remainingFields
1931
+ });
1932
+ return JSON.stringify({
1933
+ objectName,
1934
+ fieldName,
1935
+ success: true
1936
+ });
1937
+ };
1938
+ }
1939
+ function createListObjectsHandler2(ctx) {
1940
+ return async (args) => {
1941
+ const { filter, includeFields } = args ?? {};
1942
+ const objects = await ctx.metadataService.listObjects();
1943
+ let result = objects.map((o) => {
1944
+ const base = {
1945
+ name: o.name,
1946
+ label: o.label ?? o.name,
1947
+ fieldCount: o.fields ? Object.keys(o.fields).length : 0
1948
+ };
1949
+ if (includeFields && o.fields) {
1950
+ base.fields = Object.entries(o.fields).map(([key, f]) => ({
1951
+ name: key,
1952
+ type: f.type,
1953
+ label: f.label ?? key
1954
+ }));
1955
+ }
1956
+ return base;
1957
+ });
1958
+ if (filter) {
1959
+ const lower = filter.toLowerCase();
1960
+ result = result.filter(
1961
+ (o) => o.name.toLowerCase().includes(lower) || o.label.toLowerCase().includes(lower)
1962
+ );
1963
+ }
1964
+ return JSON.stringify({
1965
+ objects: result,
1966
+ totalCount: result.length
1967
+ });
1968
+ };
1969
+ }
1970
+ function createDescribeObjectHandler2(ctx) {
1971
+ return async (args) => {
1972
+ const { objectName } = args;
1973
+ if (!objectName) {
1974
+ return JSON.stringify({ error: '"objectName" is required' });
1975
+ }
1976
+ if (!isSnakeCase(objectName)) {
1977
+ return JSON.stringify({ error: `Invalid object name "${objectName}". Must be snake_case.` });
1978
+ }
1979
+ const objectDef = await ctx.metadataService.getObject(objectName);
1980
+ if (!objectDef) {
1981
+ return JSON.stringify({ error: `Object "${objectName}" not found` });
1982
+ }
1983
+ const def = objectDef;
1984
+ const fields = def.fields ?? {};
1985
+ const fieldSummary = Object.entries(fields).map(([key, f]) => ({
1986
+ name: key,
1987
+ type: f.type,
1988
+ label: f.label ?? key,
1989
+ required: f.required ?? false,
1990
+ ...f.reference ? { reference: f.reference } : {},
1991
+ ...f.options ? { options: f.options } : {}
1992
+ }));
1993
+ return JSON.stringify({
1994
+ name: def.name,
1995
+ label: def.label ?? def.name,
1996
+ fields: fieldSummary,
1997
+ enableFeatures: def.enable ?? {}
1998
+ });
1999
+ };
2000
+ }
2001
+ function registerMetadataTools(registry, context) {
2002
+ registry.register(createObjectTool, createCreateObjectHandler(context));
2003
+ registry.register(addFieldTool, createAddFieldHandler(context));
2004
+ registry.register(modifyFieldTool, createModifyFieldHandler(context));
2005
+ registry.register(deleteFieldTool, createDeleteFieldHandler(context));
2006
+ registry.register(listMetadataObjectsTool, createListObjectsHandler2(context));
2007
+ registry.register(describeMetadataObjectTool, createDescribeObjectHandler2(context));
2008
+ }
2009
+
1131
2010
  // src/agent-runtime.ts
1132
2011
  import { AgentSchema } from "@objectstack/spec/ai";
1133
2012
  var AgentRuntime = class {
@@ -1135,6 +2014,27 @@ var AgentRuntime = class {
1135
2014
  this.metadataService = metadataService;
1136
2015
  }
1137
2016
  // ── Public API ────────────────────────────────────────────────
2017
+ /**
2018
+ * List all active agents registered in the metadata service.
2019
+ *
2020
+ * Returns a summary for each agent (name, label, role) suitable
2021
+ * for populating an agent selector dropdown in the UI.
2022
+ */
2023
+ async listAgents() {
2024
+ const rawItems = await this.metadataService.list("agent");
2025
+ const agents = [];
2026
+ for (const raw of rawItems) {
2027
+ const result = AgentSchema.safeParse(raw);
2028
+ if (result.success && result.data.active) {
2029
+ agents.push({
2030
+ name: result.data.name,
2031
+ label: result.data.label,
2032
+ role: result.data.role
2033
+ });
2034
+ }
2035
+ }
2036
+ return agents;
2037
+ }
1138
2038
  /**
1139
2039
  * Load and validate an agent definition by name.
1140
2040
  *
@@ -1263,15 +2163,233 @@ Guidelines:
1263
2163
  }
1264
2164
  };
1265
2165
 
2166
+ // src/agents/metadata-assistant-agent.ts
2167
+ var METADATA_ASSISTANT_AGENT = {
2168
+ name: "metadata_assistant",
2169
+ label: "Metadata Assistant",
2170
+ role: "Schema Architect",
2171
+ instructions: `You are an expert metadata architect that helps users design and manage their data models through natural language.
2172
+
2173
+ Capabilities:
2174
+ - Create new data objects (tables) with fields
2175
+ - Add fields (columns) to existing objects
2176
+ - Modify field properties (label, type, required, default value)
2177
+ - Delete fields from objects
2178
+ - List all registered metadata objects and their schemas
2179
+ - Describe the full schema of a specific object
2180
+
2181
+ Guidelines:
2182
+ 1. Before creating a new object, use list_metadata_objects to check if a similar one already exists.
2183
+ 2. Before modifying or deleting fields, use describe_metadata_object to understand the current schema.
2184
+ 3. Always use snake_case for object names and field names (e.g. project_task, due_date).
2185
+ 4. Suggest meaningful field types based on the user's description (e.g. "deadline" \u2192 date, "active" \u2192 boolean).
2186
+ 5. When creating objects, propose a reasonable set of initial fields based on the entity type.
2187
+ 6. Explain what changes you are about to make before executing them.
2188
+ 7. After making changes, confirm the result by describing the updated schema.
2189
+ 8. For destructive operations (deleting fields), always warn the user about potential data loss.
2190
+ 9. Always answer in the same language the user is using.
2191
+ 10. If the user's request is ambiguous, ask clarifying questions before proceeding.`,
2192
+ model: {
2193
+ provider: "openai",
2194
+ model: "gpt-4",
2195
+ temperature: 0.2,
2196
+ maxTokens: 4096
2197
+ },
2198
+ tools: [
2199
+ { type: "action", name: "create_object", description: "Create a new data object (table)" },
2200
+ { type: "action", name: "add_field", description: "Add a field to an existing object" },
2201
+ { type: "action", name: "modify_field", description: "Modify an existing field definition" },
2202
+ { type: "action", name: "delete_field", description: "Delete a field from an object" },
2203
+ { type: "query", name: "list_metadata_objects", description: "List all metadata objects" },
2204
+ { type: "query", name: "describe_metadata_object", description: "Describe an object schema" }
2205
+ ],
2206
+ active: true,
2207
+ visibility: "global",
2208
+ guardrails: {
2209
+ maxTokensPerInvocation: 8192,
2210
+ maxExecutionTimeSec: 60,
2211
+ blockedTopics: ["drop_database", "raw_sql", "system_tables"]
2212
+ },
2213
+ planning: {
2214
+ strategy: "react",
2215
+ maxIterations: 10,
2216
+ allowReplan: true
2217
+ },
2218
+ memory: {
2219
+ shortTerm: {
2220
+ maxMessages: 30,
2221
+ maxTokens: 8192
2222
+ }
2223
+ }
2224
+ };
2225
+
2226
+ // src/adapters/vercel-adapter.ts
2227
+ import { generateText, streamText, tool as vercelTool, jsonSchema } from "ai";
2228
+ function buildVercelOptions(options) {
2229
+ if (!options) return {};
2230
+ const opts = {};
2231
+ if (options.temperature != null) opts.temperature = options.temperature;
2232
+ if (options.maxTokens != null) opts.maxTokens = options.maxTokens;
2233
+ if (options.stop?.length) opts.stopSequences = options.stop;
2234
+ if (options.tools?.length) {
2235
+ const tools = {};
2236
+ for (const t of options.tools) {
2237
+ tools[t.name] = vercelTool({
2238
+ description: t.description,
2239
+ inputSchema: jsonSchema(t.parameters)
2240
+ });
2241
+ }
2242
+ opts.tools = tools;
2243
+ }
2244
+ if (options.toolChoice != null) {
2245
+ opts.toolChoice = options.toolChoice;
2246
+ }
2247
+ return opts;
2248
+ }
2249
+ var VercelLLMAdapter = class {
2250
+ constructor(config) {
2251
+ this.name = "vercel";
2252
+ this.model = config.model;
2253
+ }
2254
+ async chat(messages, options) {
2255
+ const result = await generateText({
2256
+ model: this.model,
2257
+ messages,
2258
+ ...buildVercelOptions(options)
2259
+ });
2260
+ return {
2261
+ content: result.text,
2262
+ model: result.response?.modelId,
2263
+ toolCalls: result.toolCalls?.length ? result.toolCalls : void 0,
2264
+ usage: result.usage ? {
2265
+ promptTokens: result.usage.inputTokens ?? 0,
2266
+ completionTokens: result.usage.outputTokens ?? 0,
2267
+ totalTokens: result.usage.totalTokens ?? 0
2268
+ } : void 0
2269
+ };
2270
+ }
2271
+ async complete(prompt, options) {
2272
+ const result = await generateText({
2273
+ model: this.model,
2274
+ prompt,
2275
+ ...buildVercelOptions(options)
2276
+ });
2277
+ return {
2278
+ content: result.text,
2279
+ model: result.response?.modelId,
2280
+ usage: result.usage ? {
2281
+ promptTokens: result.usage.inputTokens ?? 0,
2282
+ completionTokens: result.usage.outputTokens ?? 0,
2283
+ totalTokens: result.usage.totalTokens ?? 0
2284
+ } : void 0
2285
+ };
2286
+ }
2287
+ async *streamChat(messages, options) {
2288
+ const result = streamText({
2289
+ model: this.model,
2290
+ messages,
2291
+ ...buildVercelOptions(options)
2292
+ });
2293
+ for await (const part of result.fullStream) {
2294
+ yield part;
2295
+ }
2296
+ }
2297
+ async embed(_input) {
2298
+ throw new Error(
2299
+ "[VercelLLMAdapter] Embeddings require a dedicated EmbeddingModel. Configure an embedding adapter instead."
2300
+ );
2301
+ }
2302
+ async listModels() {
2303
+ return [];
2304
+ }
2305
+ };
2306
+
1266
2307
  // src/plugin.ts
1267
2308
  var AIServicePlugin = class {
1268
2309
  constructor(options = {}) {
1269
2310
  this.name = "com.objectstack.service-ai";
1270
2311
  this.version = "1.0.0";
1271
2312
  this.type = "standard";
1272
- this.dependencies = [];
2313
+ this.dependencies = ["com.objectstack.engine.objectql"];
1273
2314
  this.options = options;
1274
2315
  }
2316
+ /**
2317
+ * Auto-detect LLM provider from environment variables.
2318
+ *
2319
+ * Priority order:
2320
+ * 1. AI_GATEWAY_MODEL → Vercel AI Gateway
2321
+ * 2. OPENAI_API_KEY → OpenAI
2322
+ * 3. ANTHROPIC_API_KEY → Anthropic
2323
+ * 4. GOOGLE_GENERATIVE_AI_API_KEY → Google
2324
+ * 5. Fallback → MemoryLLMAdapter
2325
+ *
2326
+ * Returns the adapter and a description for logging.
2327
+ */
2328
+ async detectAdapter(ctx) {
2329
+ const gatewayModel = process.env.AI_GATEWAY_MODEL;
2330
+ if (gatewayModel) {
2331
+ try {
2332
+ const gatewayPkg = "@ai-sdk/gateway";
2333
+ const { gateway } = await import(
2334
+ /* webpackIgnore: true */
2335
+ gatewayPkg
2336
+ );
2337
+ const adapter = new VercelLLMAdapter({ model: gateway(gatewayModel) });
2338
+ return { adapter, description: `Vercel AI Gateway (model: ${gatewayModel})` };
2339
+ } catch (err) {
2340
+ ctx.logger.warn(
2341
+ `[AI] Failed to load @ai-sdk/gateway for AI_GATEWAY_MODEL=${gatewayModel}, trying next provider`,
2342
+ err instanceof Error ? { error: err.message } : void 0
2343
+ );
2344
+ }
2345
+ }
2346
+ const providerConfigs = [
2347
+ {
2348
+ envKey: "OPENAI_API_KEY",
2349
+ pkg: "@ai-sdk/openai",
2350
+ factory: "openai",
2351
+ defaultModel: "gpt-4o",
2352
+ displayName: "OpenAI"
2353
+ },
2354
+ {
2355
+ envKey: "ANTHROPIC_API_KEY",
2356
+ pkg: "@ai-sdk/anthropic",
2357
+ factory: "anthropic",
2358
+ defaultModel: "claude-sonnet-4-20250514",
2359
+ displayName: "Anthropic"
2360
+ },
2361
+ {
2362
+ envKey: "GOOGLE_GENERATIVE_AI_API_KEY",
2363
+ pkg: "@ai-sdk/google",
2364
+ factory: "google",
2365
+ defaultModel: "gemini-2.0-flash",
2366
+ displayName: "Google"
2367
+ }
2368
+ ];
2369
+ for (const { envKey, pkg, factory, defaultModel, displayName } of providerConfigs) {
2370
+ if (process.env[envKey]) {
2371
+ try {
2372
+ const mod = await import(
2373
+ /* webpackIgnore: true */
2374
+ pkg
2375
+ );
2376
+ const createModel = mod[factory] ?? mod.default;
2377
+ if (typeof createModel === "function") {
2378
+ const modelId = process.env.AI_MODEL ?? defaultModel;
2379
+ const adapter = new VercelLLMAdapter({ model: createModel(modelId) });
2380
+ return { adapter, description: `${displayName} (model: ${modelId})` };
2381
+ }
2382
+ } catch (err) {
2383
+ ctx.logger.warn(
2384
+ `[AI] Failed to load ${pkg} for ${envKey}, trying next provider`,
2385
+ err instanceof Error ? { error: err.message } : void 0
2386
+ );
2387
+ }
2388
+ }
2389
+ }
2390
+ ctx.logger.warn("[AI] No LLM provider configured via environment variables. Falling back to MemoryLLMAdapter (echo mode). Set AI_GATEWAY_MODEL, OPENAI_API_KEY, ANTHROPIC_API_KEY, or GOOGLE_GENERATIVE_AI_API_KEY to use a real LLM.");
2391
+ return { adapter: new MemoryLLMAdapter(), description: "MemoryLLMAdapter (echo mode - for testing only)" };
2392
+ }
1275
2393
  async init(ctx) {
1276
2394
  let hasExisting = false;
1277
2395
  try {
@@ -1293,8 +2411,19 @@ var AIServicePlugin = class {
1293
2411
  } catch {
1294
2412
  }
1295
2413
  }
2414
+ let adapter;
2415
+ let adapterDescription;
2416
+ if (this.options.adapter) {
2417
+ adapter = this.options.adapter;
2418
+ adapterDescription = `${adapter.name} (explicitly configured)`;
2419
+ } else {
2420
+ const detected = await this.detectAdapter(ctx);
2421
+ adapter = detected.adapter;
2422
+ adapterDescription = detected.description;
2423
+ }
2424
+ ctx.logger.info(`[AI] Using LLM adapter: ${adapterDescription}`);
1296
2425
  const config = {
1297
- adapter: this.options.adapter,
2426
+ adapter,
1298
2427
  logger: ctx.logger,
1299
2428
  conversationService
1300
2429
  };
@@ -1304,7 +2433,7 @@ var AIServicePlugin = class {
1304
2433
  } else {
1305
2434
  ctx.registerService("ai", this.service);
1306
2435
  }
1307
- ctx.registerService("app.com.objectstack.service-ai", {
2436
+ ctx.getService("manifest").register({
1308
2437
  id: "com.objectstack.service-ai",
1309
2438
  name: "AI Service",
1310
2439
  version: "1.0.0",
@@ -1317,13 +2446,32 @@ var AIServicePlugin = class {
1317
2446
  ctx.logger.debug("[AI] Before chat", { messages });
1318
2447
  });
1319
2448
  }
2449
+ try {
2450
+ const setupNav = ctx.getService("setupNav");
2451
+ if (setupNav) {
2452
+ setupNav.contribute({
2453
+ areaId: "area_ai",
2454
+ items: [
2455
+ { id: "nav_ai_conversations", type: "object", label: { key: "setup.nav.ai_conversations", defaultValue: "Conversations" }, objectName: "conversations", icon: "message-square", order: 10 },
2456
+ { id: "nav_ai_messages", type: "object", label: { key: "setup.nav.ai_messages", defaultValue: "Messages" }, objectName: "messages", icon: "messages-square", order: 20 }
2457
+ ]
2458
+ });
2459
+ ctx.logger.info("[AI] Navigation items contributed to Setup App");
2460
+ }
2461
+ } catch {
2462
+ }
1320
2463
  ctx.logger.info("[AI] Service initialized");
1321
2464
  }
1322
2465
  async start(ctx) {
1323
2466
  if (!this.service) return;
2467
+ let metadataService;
2468
+ try {
2469
+ metadataService = ctx.getService("metadata");
2470
+ } catch {
2471
+ ctx.logger.debug("[AI] Metadata service not available");
2472
+ }
1324
2473
  try {
1325
2474
  const dataEngine = ctx.getService("data");
1326
- const metadataService = ctx.getService("metadata");
1327
2475
  if (dataEngine && metadataService) {
1328
2476
  registerDataTools(this.service.toolRegistry, { dataEngine, metadataService });
1329
2477
  ctx.logger.info("[AI] Built-in data tools registered");
@@ -1336,14 +2484,29 @@ var AIServicePlugin = class {
1336
2484
  }
1337
2485
  }
1338
2486
  } catch {
1339
- ctx.logger.debug("[AI] Data engine or metadata service not available, skipping data tools");
2487
+ ctx.logger.debug("[AI] Data engine not available, skipping data tools");
2488
+ }
2489
+ if (metadataService) {
2490
+ try {
2491
+ registerMetadataTools(this.service.toolRegistry, { metadataService });
2492
+ ctx.logger.info("[AI] Built-in metadata tools registered");
2493
+ const agentExists = typeof metadataService.exists === "function" ? await metadataService.exists("agent", METADATA_ASSISTANT_AGENT.name) : false;
2494
+ if (!agentExists) {
2495
+ await metadataService.register("agent", METADATA_ASSISTANT_AGENT.name, METADATA_ASSISTANT_AGENT);
2496
+ ctx.logger.info("[AI] metadata_assistant agent registered");
2497
+ } else {
2498
+ ctx.logger.debug("[AI] metadata_assistant agent already exists, skipping auto-registration");
2499
+ }
2500
+ } catch (err) {
2501
+ ctx.logger.debug("[AI] Failed to register metadata tools", err instanceof Error ? err : void 0);
2502
+ }
1340
2503
  }
1341
2504
  await ctx.trigger("ai:ready", this.service);
1342
2505
  const routes = buildAIRoutes(this.service, this.service.conversationService, ctx.logger);
1343
2506
  try {
1344
- const metadataService = ctx.getService("metadata");
1345
- if (metadataService) {
1346
- const agentRuntime = new AgentRuntime(metadataService);
2507
+ const metadataService2 = ctx.getService("metadata");
2508
+ if (metadataService2) {
2509
+ const agentRuntime = new AgentRuntime(metadataService2);
1347
2510
  const agentRoutes = buildAgentRoutes(this.service, agentRuntime, ctx.logger);
1348
2511
  routes.push(...agentRoutes);
1349
2512
  }
@@ -1351,6 +2514,10 @@ var AIServicePlugin = class {
1351
2514
  ctx.logger.debug("[AI] Metadata service not available, skipping agent routes");
1352
2515
  }
1353
2516
  await ctx.trigger("ai:routes", routes);
2517
+ const kernel = ctx.getKernel();
2518
+ if (kernel) {
2519
+ kernel.__aiRoutes = routes;
2520
+ }
1354
2521
  ctx.logger.info(
1355
2522
  `[AI] Service started \u2014 adapter="${this.service.adapterName}", tools=${this.service.toolRegistry.size}, routes=${routes.length}`
1356
2523
  );
@@ -1368,11 +2535,23 @@ export {
1368
2535
  DATA_CHAT_AGENT,
1369
2536
  DATA_TOOL_DEFINITIONS,
1370
2537
  InMemoryConversationService,
2538
+ METADATA_ASSISTANT_AGENT,
2539
+ METADATA_TOOL_DEFINITIONS,
1371
2540
  MemoryLLMAdapter,
1372
2541
  ObjectQLConversationService,
1373
2542
  ToolRegistry,
2543
+ VercelLLMAdapter,
2544
+ addFieldTool,
1374
2545
  buildAIRoutes,
1375
2546
  buildAgentRoutes,
1376
- registerDataTools
2547
+ createObjectTool,
2548
+ deleteFieldTool,
2549
+ describeMetadataObjectTool,
2550
+ encodeStreamPart,
2551
+ encodeVercelDataStream,
2552
+ listMetadataObjectsTool,
2553
+ modifyFieldTool,
2554
+ registerDataTools,
2555
+ registerMetadataTools
1377
2556
  };
1378
2557
  //# sourceMappingURL=index.js.map