@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.cjs CHANGED
@@ -28,12 +28,24 @@ __export(index_exports, {
28
28
  DATA_CHAT_AGENT: () => DATA_CHAT_AGENT,
29
29
  DATA_TOOL_DEFINITIONS: () => DATA_TOOL_DEFINITIONS,
30
30
  InMemoryConversationService: () => InMemoryConversationService,
31
+ METADATA_ASSISTANT_AGENT: () => METADATA_ASSISTANT_AGENT,
32
+ METADATA_TOOL_DEFINITIONS: () => METADATA_TOOL_DEFINITIONS,
31
33
  MemoryLLMAdapter: () => MemoryLLMAdapter,
32
34
  ObjectQLConversationService: () => ObjectQLConversationService,
33
35
  ToolRegistry: () => ToolRegistry,
36
+ VercelLLMAdapter: () => VercelLLMAdapter,
37
+ addFieldTool: () => addFieldTool,
34
38
  buildAIRoutes: () => buildAIRoutes,
35
39
  buildAgentRoutes: () => buildAgentRoutes,
36
- registerDataTools: () => registerDataTools
40
+ createObjectTool: () => createObjectTool,
41
+ deleteFieldTool: () => deleteFieldTool,
42
+ describeMetadataObjectTool: () => describeMetadataObjectTool,
43
+ encodeStreamPart: () => encodeStreamPart,
44
+ encodeVercelDataStream: () => encodeVercelDataStream,
45
+ listMetadataObjectsTool: () => listMetadataObjectsTool,
46
+ modifyFieldTool: () => modifyFieldTool,
47
+ registerDataTools: () => registerDataTools,
48
+ registerMetadataTools: () => registerMetadataTools
37
49
  });
38
50
  module.exports = __toCommonJS(index_exports);
39
51
 
@@ -47,7 +59,9 @@ var MemoryLLMAdapter = class {
47
59
  }
48
60
  async chat(messages, options) {
49
61
  const lastUserMessage = [...messages].reverse().find((m) => m.role === "user");
50
- const content = lastUserMessage ? `[memory] ${lastUserMessage.content}` : "[memory] (no user message)";
62
+ const userContent = lastUserMessage?.content;
63
+ const text = typeof userContent === "string" ? userContent : "(complex content)";
64
+ const content = lastUserMessage ? `[memory] ${text}` : "[memory] (no user message)";
51
65
  return {
52
66
  content,
53
67
  model: options?.model ?? "memory",
@@ -65,10 +79,15 @@ var MemoryLLMAdapter = class {
65
79
  const result = await this.chat(messages);
66
80
  const words = result.content.split(" ");
67
81
  for (let i = 0; i < words.length; i++) {
68
- const textDelta = i === 0 ? words[i] : ` ${words[i]}`;
69
- yield { type: "text-delta", textDelta };
82
+ const wordText = i === 0 ? words[i] : ` ${words[i]}`;
83
+ yield { type: "text-delta", id: `delta_${i}`, text: wordText };
70
84
  }
71
- yield { type: "finish", result };
85
+ yield {
86
+ type: "finish",
87
+ finishReason: "stop",
88
+ totalUsage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
89
+ rawFinishReason: "stop"
90
+ };
72
91
  }
73
92
  async embed(input) {
74
93
  const texts = Array.isArray(input) ? input : [input];
@@ -131,21 +150,34 @@ var ToolRegistry = class {
131
150
  * Execute a tool call and return the result.
132
151
  */
133
152
  async execute(toolCall) {
134
- const handler = this.handlers.get(toolCall.name);
153
+ const handler = this.handlers.get(toolCall.toolName);
135
154
  if (!handler) {
136
155
  return {
137
- toolCallId: toolCall.id,
138
- content: `Tool "${toolCall.name}" is not registered`,
156
+ type: "tool-result",
157
+ toolCallId: toolCall.toolCallId,
158
+ toolName: toolCall.toolName,
159
+ output: { type: "text", value: `Tool "${toolCall.toolName}" is not registered` },
139
160
  isError: true
140
161
  };
141
162
  }
142
163
  try {
143
- const args = JSON.parse(toolCall.arguments);
164
+ const args = typeof toolCall.input === "string" ? JSON.parse(toolCall.input) : toolCall.input ?? {};
144
165
  const content = await handler(args);
145
- return { toolCallId: toolCall.id, content };
166
+ return {
167
+ type: "tool-result",
168
+ toolCallId: toolCall.toolCallId,
169
+ toolName: toolCall.toolName,
170
+ output: { type: "text", value: content }
171
+ };
146
172
  } catch (err) {
147
173
  const message = err instanceof Error ? err.message : String(err);
148
- return { toolCallId: toolCall.id, content: message, isError: true };
174
+ return {
175
+ type: "tool-result",
176
+ toolCallId: toolCall.toolCallId,
177
+ toolName: toolCall.toolName,
178
+ output: { type: "text", value: message },
179
+ isError: true
180
+ };
149
181
  }
150
182
  }
151
183
  /**
@@ -231,6 +263,17 @@ var InMemoryConversationService = class {
231
263
  };
232
264
 
233
265
  // src/ai-service.ts
266
+ function textDeltaPart(id, text) {
267
+ return { type: "text-delta", id, text };
268
+ }
269
+ function finishPart(result) {
270
+ return {
271
+ type: "finish",
272
+ finishReason: "stop",
273
+ totalUsage: result?.usage ?? { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
274
+ rawFinishReason: "stop"
275
+ };
276
+ }
234
277
  var _AIService = class _AIService {
235
278
  constructor(config = {}) {
236
279
  this.adapter = config.adapter ?? new MemoryLLMAdapter();
@@ -258,8 +301,8 @@ var _AIService = class _AIService {
258
301
  this.logger.debug("[AI] streamChat", { messageCount: messages.length, model: options?.model });
259
302
  if (!this.adapter.streamChat) {
260
303
  const result = await this.adapter.chat(messages, options);
261
- yield { type: "text-delta", textDelta: result.content };
262
- yield { type: "finish", result };
304
+ yield textDeltaPart("fallback", result.content);
305
+ yield finishPart(result);
263
306
  return;
264
307
  }
265
308
  yield* this.adapter.streamChat(messages, options);
@@ -276,6 +319,10 @@ var _AIService = class _AIService {
276
319
  }
277
320
  return this.adapter.listModels();
278
321
  }
322
+ /** Extract the text value from a ToolExecutionResult's output. */
323
+ static extractOutputText(tr) {
324
+ return tr.output && typeof tr.output === "object" && "value" in tr.output ? String(tr.output.value) : "unknown error";
325
+ }
279
326
  /**
280
327
  * Chat with automatic tool call resolution.
281
328
  *
@@ -288,7 +335,7 @@ var _AIService = class _AIService {
288
335
  * maximum number of iterations (`maxIterations`) is reached.
289
336
  */
290
337
  async chatWithTools(messages, options) {
291
- const { maxIterations: maxIter, ...restOptions } = options ?? {};
338
+ const { maxIterations: maxIter, onToolError, ...restOptions } = options ?? {};
292
339
  const maxIterations = maxIter ?? _AIService.DEFAULT_MAX_ITERATIONS;
293
340
  const registeredTools = this.toolRegistry.getAll();
294
341
  const mergedTools = [
@@ -301,11 +348,13 @@ var _AIService = class _AIService {
301
348
  toolChoice: mergedTools.length > 0 ? restOptions.toolChoice ?? "auto" : void 0
302
349
  };
303
350
  const conversation = [...messages];
351
+ const toolErrors = [];
304
352
  this.logger.debug("[AI] chatWithTools start", {
305
353
  messageCount: conversation.length,
306
354
  toolCount: mergedTools.length,
307
355
  maxIterations
308
356
  });
357
+ let abortedByCallback = false;
309
358
  for (let iteration = 0; iteration < maxIterations; iteration++) {
310
359
  const result = await this.adapter.chat(conversation, chatOptions);
311
360
  if (!result.toolCalls || result.toolCalls.length === 0) {
@@ -314,23 +363,47 @@ var _AIService = class _AIService {
314
363
  }
315
364
  this.logger.debug("[AI] chatWithTools tool calls", {
316
365
  iteration,
317
- calls: result.toolCalls.map((tc) => tc.name)
366
+ calls: result.toolCalls.map((tc) => tc.toolName)
318
367
  });
368
+ const assistantContent = [];
369
+ if (result.content) assistantContent.push({ type: "text", text: result.content });
370
+ assistantContent.push(...result.toolCalls);
319
371
  conversation.push({
320
372
  role: "assistant",
321
- content: result.content ?? "",
322
- toolCalls: result.toolCalls
373
+ content: assistantContent
323
374
  });
324
375
  const toolResults = await this.toolRegistry.executeAll(result.toolCalls);
325
376
  for (const tr of toolResults) {
377
+ if (tr.isError) {
378
+ const matchedCall = result.toolCalls.find((tc) => tc.toolCallId === tr.toolCallId);
379
+ const toolName = matchedCall?.toolName ?? "unknown";
380
+ const errorText = _AIService.extractOutputText(tr);
381
+ const errorEntry = { iteration, toolName, error: errorText };
382
+ toolErrors.push(errorEntry);
383
+ this.logger.warn("[AI] chatWithTools tool error", errorEntry);
384
+ if (onToolError && matchedCall) {
385
+ const action = onToolError(matchedCall, errorText);
386
+ if (action === "abort") {
387
+ abortedByCallback = true;
388
+ }
389
+ }
390
+ }
326
391
  conversation.push({
327
392
  role: "tool",
328
- content: tr.content,
329
- toolCallId: tr.toolCallId
393
+ content: [tr]
330
394
  });
331
395
  }
396
+ if (abortedByCallback) {
397
+ break;
398
+ }
399
+ }
400
+ if (abortedByCallback) {
401
+ this.logger.warn("[AI] chatWithTools aborted by onToolError callback", { toolErrors });
402
+ } else {
403
+ this.logger.warn("[AI] chatWithTools max iterations reached, forcing final response", {
404
+ toolErrors: toolErrors.length > 0 ? toolErrors : void 0
405
+ });
332
406
  }
333
- this.logger.warn("[AI] chatWithTools max iterations reached, forcing final response");
334
407
  const finalResult = await this.adapter.chat(conversation, {
335
408
  ...chatOptions,
336
409
  tools: void 0,
@@ -338,14 +411,175 @@ var _AIService = class _AIService {
338
411
  });
339
412
  return finalResult;
340
413
  }
414
+ /**
415
+ * Stream chat with automatic tool call resolution.
416
+ *
417
+ * Works like {@link chatWithTools} but yields SSE events. When the model
418
+ * requests tool calls during streaming, they are executed and the results
419
+ * fed back until a final text stream is produced.
420
+ */
421
+ async *streamChatWithTools(messages, options) {
422
+ const { maxIterations: maxIter, onToolError, ...restOptions } = options ?? {};
423
+ const maxIterations = maxIter ?? _AIService.DEFAULT_MAX_ITERATIONS;
424
+ const registeredTools = this.toolRegistry.getAll();
425
+ const mergedTools = [
426
+ ...registeredTools,
427
+ ...restOptions.tools ?? []
428
+ ];
429
+ const chatOptions = {
430
+ ...restOptions,
431
+ tools: mergedTools.length > 0 ? mergedTools : void 0,
432
+ toolChoice: mergedTools.length > 0 ? restOptions.toolChoice ?? "auto" : void 0
433
+ };
434
+ const conversation = [...messages];
435
+ let abortedByCallback = false;
436
+ for (let iteration = 0; iteration < maxIterations; iteration++) {
437
+ const result2 = await this.adapter.chat(conversation, chatOptions);
438
+ if (!result2.toolCalls || result2.toolCalls.length === 0) {
439
+ yield textDeltaPart("stream", result2.content);
440
+ yield finishPart(result2);
441
+ return;
442
+ }
443
+ for (const tc of result2.toolCalls) {
444
+ yield { type: "tool-call", toolCallId: tc.toolCallId, toolName: tc.toolName, input: tc.input };
445
+ }
446
+ const assistantContent = [];
447
+ if (result2.content) assistantContent.push({ type: "text", text: result2.content });
448
+ assistantContent.push(...result2.toolCalls);
449
+ conversation.push({
450
+ role: "assistant",
451
+ content: assistantContent
452
+ });
453
+ const toolResults = await this.toolRegistry.executeAll(result2.toolCalls);
454
+ for (const tr of toolResults) {
455
+ if (tr.isError && onToolError) {
456
+ const matchedCall = result2.toolCalls.find((tc) => tc.toolCallId === tr.toolCallId);
457
+ if (matchedCall) {
458
+ const errorText = _AIService.extractOutputText(tr);
459
+ const action = onToolError(matchedCall, errorText);
460
+ if (action === "abort") {
461
+ abortedByCallback = true;
462
+ }
463
+ }
464
+ }
465
+ conversation.push({
466
+ role: "tool",
467
+ content: [tr]
468
+ });
469
+ }
470
+ if (abortedByCallback) {
471
+ break;
472
+ }
473
+ }
474
+ if (abortedByCallback) {
475
+ this.logger.warn("[AI] streamChatWithTools aborted by onToolError callback");
476
+ } else {
477
+ this.logger.warn("[AI] streamChatWithTools max iterations reached");
478
+ }
479
+ const finalOptions = { ...chatOptions, tools: void 0, toolChoice: void 0 };
480
+ const result = await this.adapter.chat(conversation, finalOptions);
481
+ yield textDeltaPart("stream", result.content);
482
+ yield finishPart(result);
483
+ }
341
484
  };
342
485
  // ── Tool Call Loop ────────────────────────────────────────────
343
486
  /** Default maximum iterations for the tool call loop. */
344
487
  _AIService.DEFAULT_MAX_ITERATIONS = 10;
345
488
  var AIService = _AIService;
346
489
 
490
+ // src/stream/vercel-stream-encoder.ts
491
+ function sse(data) {
492
+ return `data: ${JSON.stringify(data)}
493
+
494
+ `;
495
+ }
496
+ function encodeStreamPart(part) {
497
+ switch (part.type) {
498
+ case "text-delta":
499
+ return sse({ type: "text-delta", id: "0", delta: part.text });
500
+ case "tool-input-start":
501
+ return sse({
502
+ type: "tool-input-start",
503
+ toolCallId: part.id,
504
+ toolName: part.toolName
505
+ });
506
+ case "tool-input-delta":
507
+ return sse({
508
+ type: "tool-input-delta",
509
+ toolCallId: part.id,
510
+ inputTextDelta: part.delta
511
+ });
512
+ case "tool-call":
513
+ return sse({
514
+ type: "tool-input-available",
515
+ toolCallId: part.toolCallId,
516
+ toolName: part.toolName,
517
+ input: part.input
518
+ });
519
+ case "tool-result":
520
+ return sse({
521
+ type: "tool-output-available",
522
+ toolCallId: part.toolCallId,
523
+ output: part.output
524
+ });
525
+ case "error":
526
+ return sse({
527
+ type: "error",
528
+ errorText: String(part.error)
529
+ });
530
+ // finish-step and finish are handled by the generator, not here
531
+ default:
532
+ return "";
533
+ }
534
+ }
535
+ async function* encodeVercelDataStream(events) {
536
+ yield sse({ type: "start" });
537
+ yield sse({ type: "start-step" });
538
+ yield sse({ type: "text-start", id: "0" });
539
+ let textOpen = true;
540
+ let finishReason = "stop";
541
+ for await (const part of events) {
542
+ if (part.type === "finish") {
543
+ finishReason = part.finishReason ?? "stop";
544
+ }
545
+ if (part.type === "finish-step" || part.type === "finish") {
546
+ if (textOpen) {
547
+ yield sse({ type: "text-end", id: "0" });
548
+ textOpen = false;
549
+ }
550
+ continue;
551
+ }
552
+ const frame = encodeStreamPart(part);
553
+ if (frame) {
554
+ yield frame;
555
+ }
556
+ }
557
+ if (textOpen) {
558
+ yield sse({ type: "text-end", id: "0" });
559
+ }
560
+ yield sse({ type: "finish-step" });
561
+ yield sse({ type: "finish", finishReason });
562
+ yield "data: [DONE]\n\n";
563
+ }
564
+
347
565
  // src/routes/ai-routes.ts
348
566
  var VALID_ROLES = /* @__PURE__ */ new Set(["system", "user", "assistant", "tool"]);
567
+ function normalizeMessage(raw) {
568
+ const role = raw.role;
569
+ if (typeof raw.content === "string") {
570
+ return { role, content: raw.content };
571
+ }
572
+ if (Array.isArray(raw.content)) {
573
+ return { role, content: raw.content };
574
+ }
575
+ if (Array.isArray(raw.parts)) {
576
+ const textParts = raw.parts.filter((p) => p.type === "text" && typeof p.text === "string").map((p) => p.text);
577
+ if (textParts.length > 0) {
578
+ return { role, content: textParts.join("") };
579
+ }
580
+ }
581
+ return { role, content: "" };
582
+ }
349
583
  function validateMessage(raw) {
350
584
  if (typeof raw !== "object" || raw === null) {
351
585
  return "each message must be an object";
@@ -354,20 +588,56 @@ function validateMessage(raw) {
354
588
  if (typeof msg.role !== "string" || !VALID_ROLES.has(msg.role)) {
355
589
  return `message.role must be one of ${[...VALID_ROLES].map((r) => `"${r}"`).join(", ")}`;
356
590
  }
357
- if (typeof msg.content !== "string") {
358
- return "message.content must be a string";
591
+ const content = msg.content;
592
+ if (Array.isArray(msg.parts)) {
593
+ return null;
359
594
  }
360
- return null;
595
+ if (typeof content === "string") {
596
+ return null;
597
+ }
598
+ if (Array.isArray(content)) {
599
+ for (const part of content) {
600
+ if (typeof part !== "object" || part === null) {
601
+ return "message.content array elements must be non-null objects";
602
+ }
603
+ const partObj = part;
604
+ if (typeof partObj.type !== "string") {
605
+ return 'each message.content array element must have a string "type" property';
606
+ }
607
+ if (partObj.type === "text" && typeof partObj.text !== "string") {
608
+ return 'message.content elements with type "text" must have a string "text" property';
609
+ }
610
+ }
611
+ return null;
612
+ }
613
+ if (content === null || content === void 0) {
614
+ if (msg.role === "assistant" || msg.role === "tool") {
615
+ return null;
616
+ }
617
+ }
618
+ return "message.content must be a string, an array, or include parts";
361
619
  }
362
620
  function buildAIRoutes(aiService, conversationService, logger) {
363
621
  return [
364
622
  // ── Chat ────────────────────────────────────────────────────
623
+ //
624
+ // Dual-mode endpoint compatible with both the legacy ObjectStack
625
+ // format (`{ messages, options }`) and the Vercel AI SDK useChat
626
+ // flat format (`{ messages, system, model, stream, … }`).
627
+ //
628
+ // Behaviour:
629
+ // • `stream !== false` → Vercel Data Stream Protocol (SSE)
630
+ // • `stream === false` → JSON response (legacy)
631
+ //
365
632
  {
366
633
  method: "POST",
367
634
  path: "/api/v1/ai/chat",
368
- description: "Synchronous chat completion",
635
+ description: "Chat completion (supports Vercel AI Data Stream Protocol)",
636
+ auth: true,
637
+ permissions: ["ai:chat"],
369
638
  handler: async (req) => {
370
- const { messages, options } = req.body ?? {};
639
+ const body = req.body ?? {};
640
+ const messages = body.messages;
371
641
  if (!Array.isArray(messages) || messages.length === 0) {
372
642
  return { status: 400, body: { error: "messages array is required" } };
373
643
  }
@@ -375,8 +645,49 @@ function buildAIRoutes(aiService, conversationService, logger) {
375
645
  const err = validateMessage(msg);
376
646
  if (err) return { status: 400, body: { error: err } };
377
647
  }
648
+ const nested = body.options ?? {};
649
+ const resolvedOptions = {
650
+ ...nested,
651
+ ...body.model != null && { model: body.model },
652
+ ...body.temperature != null && { temperature: body.temperature },
653
+ ...body.maxTokens != null && { maxTokens: body.maxTokens }
654
+ };
655
+ const rawSystemPrompt = body.system ?? body.systemPrompt;
656
+ if (rawSystemPrompt != null && typeof rawSystemPrompt !== "string") {
657
+ return { status: 400, body: { error: "system/systemPrompt must be a string" } };
658
+ }
659
+ const systemPrompt = rawSystemPrompt;
660
+ const finalMessages = [
661
+ ...systemPrompt ? [{ role: "system", content: systemPrompt }] : [],
662
+ ...messages.map((m) => normalizeMessage(m))
663
+ ];
664
+ const wantStream = body.stream !== false;
665
+ if (wantStream) {
666
+ try {
667
+ if (!aiService.streamChatWithTools) {
668
+ return { status: 501, body: { error: "Streaming is not supported by the configured AI service" } };
669
+ }
670
+ const events = aiService.streamChatWithTools(finalMessages, resolvedOptions);
671
+ return {
672
+ status: 200,
673
+ stream: true,
674
+ vercelDataStream: true,
675
+ contentType: "text/event-stream",
676
+ headers: {
677
+ "Content-Type": "text/event-stream",
678
+ "Cache-Control": "no-cache",
679
+ "Connection": "keep-alive",
680
+ "x-vercel-ai-ui-message-stream": "v1"
681
+ },
682
+ events: encodeVercelDataStream(events)
683
+ };
684
+ } catch (err) {
685
+ logger.error("[AI Route] /chat stream error", err instanceof Error ? err : void 0);
686
+ return { status: 500, body: { error: "Internal AI service error" } };
687
+ }
688
+ }
378
689
  try {
379
- const result = await aiService.chat(messages, options);
690
+ const result = await aiService.chatWithTools(finalMessages, resolvedOptions);
380
691
  return { status: 200, body: result };
381
692
  } catch (err) {
382
693
  logger.error("[AI Route] /chat error", err instanceof Error ? err : void 0);
@@ -389,6 +700,8 @@ function buildAIRoutes(aiService, conversationService, logger) {
389
700
  method: "POST",
390
701
  path: "/api/v1/ai/chat/stream",
391
702
  description: "SSE streaming chat completion",
703
+ auth: true,
704
+ permissions: ["ai:chat"],
392
705
  handler: async (req) => {
393
706
  const { messages, options } = req.body ?? {};
394
707
  if (!Array.isArray(messages) || messages.length === 0) {
@@ -402,7 +715,7 @@ function buildAIRoutes(aiService, conversationService, logger) {
402
715
  if (!aiService.streamChat) {
403
716
  return { status: 501, body: { error: "Streaming is not supported by the configured AI service" } };
404
717
  }
405
- const events = aiService.streamChat(messages, options);
718
+ const events = aiService.streamChat(messages.map((m) => normalizeMessage(m)), options);
406
719
  return { status: 200, stream: true, events };
407
720
  } catch (err) {
408
721
  logger.error("[AI Route] /chat/stream error", err instanceof Error ? err : void 0);
@@ -415,6 +728,8 @@ function buildAIRoutes(aiService, conversationService, logger) {
415
728
  method: "POST",
416
729
  path: "/api/v1/ai/complete",
417
730
  description: "Text completion",
731
+ auth: true,
732
+ permissions: ["ai:complete"],
418
733
  handler: async (req) => {
419
734
  const { prompt, options } = req.body ?? {};
420
735
  if (!prompt || typeof prompt !== "string") {
@@ -434,6 +749,8 @@ function buildAIRoutes(aiService, conversationService, logger) {
434
749
  method: "GET",
435
750
  path: "/api/v1/ai/models",
436
751
  description: "List available models",
752
+ auth: true,
753
+ permissions: ["ai:read"],
437
754
  handler: async () => {
438
755
  try {
439
756
  const models = aiService.listModels ? await aiService.listModels() : [];
@@ -449,9 +766,17 @@ function buildAIRoutes(aiService, conversationService, logger) {
449
766
  method: "POST",
450
767
  path: "/api/v1/ai/conversations",
451
768
  description: "Create a conversation",
769
+ auth: true,
770
+ permissions: ["ai:conversations"],
452
771
  handler: async (req) => {
453
772
  try {
454
- const options = req.body ?? {};
773
+ if (req.body !== void 0 && req.body !== null && (typeof req.body !== "object" || Array.isArray(req.body))) {
774
+ return { status: 400, body: { error: "Invalid request payload" } };
775
+ }
776
+ const options = { ...req.body ?? {} };
777
+ if (req.user?.userId) {
778
+ options.userId = req.user.userId;
779
+ }
455
780
  const conversation = await conversationService.create(options);
456
781
  return { status: 201, body: conversation };
457
782
  } catch (err) {
@@ -464,6 +789,8 @@ function buildAIRoutes(aiService, conversationService, logger) {
464
789
  method: "GET",
465
790
  path: "/api/v1/ai/conversations",
466
791
  description: "List conversations",
792
+ auth: true,
793
+ permissions: ["ai:conversations"],
467
794
  handler: async (req) => {
468
795
  try {
469
796
  const rawQuery = req.query ?? {};
@@ -475,6 +802,9 @@ function buildAIRoutes(aiService, conversationService, logger) {
475
802
  }
476
803
  options.limit = parsedLimit;
477
804
  }
805
+ if (req.user?.userId) {
806
+ options.userId = req.user.userId;
807
+ }
478
808
  const conversations = await conversationService.list(options);
479
809
  return { status: 200, body: { conversations } };
480
810
  } catch (err) {
@@ -487,6 +817,8 @@ function buildAIRoutes(aiService, conversationService, logger) {
487
817
  method: "POST",
488
818
  path: "/api/v1/ai/conversations/:id/messages",
489
819
  description: "Add message to a conversation",
820
+ auth: true,
821
+ permissions: ["ai:conversations"],
490
822
  handler: async (req) => {
491
823
  const id = req.params?.id;
492
824
  if (!id) {
@@ -498,6 +830,15 @@ function buildAIRoutes(aiService, conversationService, logger) {
498
830
  return { status: 400, body: { error: validationError } };
499
831
  }
500
832
  try {
833
+ if (req.user?.userId) {
834
+ const existing = await conversationService.get(id);
835
+ if (!existing) {
836
+ return { status: 404, body: { error: `Conversation "${id}" not found` } };
837
+ }
838
+ if (existing.userId && existing.userId !== req.user.userId) {
839
+ return { status: 403, body: { error: "You do not have access to this conversation" } };
840
+ }
841
+ }
501
842
  const conversation = await conversationService.addMessage(id, message);
502
843
  return { status: 200, body: conversation };
503
844
  } catch (err) {
@@ -514,12 +855,23 @@ function buildAIRoutes(aiService, conversationService, logger) {
514
855
  method: "DELETE",
515
856
  path: "/api/v1/ai/conversations/:id",
516
857
  description: "Delete a conversation",
858
+ auth: true,
859
+ permissions: ["ai:conversations"],
517
860
  handler: async (req) => {
518
861
  const id = req.params?.id;
519
862
  if (!id) {
520
863
  return { status: 400, body: { error: "conversation id is required" } };
521
864
  }
522
865
  try {
866
+ if (req.user?.userId) {
867
+ const existing = await conversationService.get(id);
868
+ if (!existing) {
869
+ return { status: 404, body: { error: `Conversation "${id}" not found` } };
870
+ }
871
+ if (existing.userId && existing.userId !== req.user.userId) {
872
+ return { status: 403, body: { error: "You do not have access to this conversation" } };
873
+ }
874
+ }
523
875
  await conversationService.delete(id);
524
876
  return { status: 204 };
525
877
  } catch (err) {
@@ -548,10 +900,33 @@ function validateAgentMessage(raw) {
548
900
  }
549
901
  function buildAgentRoutes(aiService, agentRuntime, logger) {
550
902
  return [
903
+ // ── List active agents ──────────────────────────────────────
904
+ {
905
+ method: "GET",
906
+ path: "/api/v1/ai/agents",
907
+ description: "List all active AI agents",
908
+ auth: true,
909
+ permissions: ["ai:chat"],
910
+ handler: async () => {
911
+ try {
912
+ const agents = await agentRuntime.listAgents();
913
+ return { status: 200, body: { agents } };
914
+ } catch (err) {
915
+ logger.error(
916
+ "[AI Route] /agents list error",
917
+ err instanceof Error ? err : void 0
918
+ );
919
+ return { status: 500, body: { error: "Internal AI service error" } };
920
+ }
921
+ }
922
+ },
923
+ // ── Chat with a specific agent ──────────────────────────────
551
924
  {
552
925
  method: "POST",
553
926
  path: "/api/v1/ai/agents/:agentName/chat",
554
927
  description: "Chat with a specific AI agent",
928
+ auth: true,
929
+ permissions: ["ai:chat", "ai:agents"],
555
930
  handler: async (req) => {
556
931
  const agentName = req.params?.agentName;
557
932
  if (!agentName) {
@@ -705,13 +1080,35 @@ var ObjectQLConversationService = class {
705
1080
  }
706
1081
  const now = (/* @__PURE__ */ new Date()).toISOString();
707
1082
  const msgId = `msg_${(0, import_node_crypto.randomUUID)()}`;
1083
+ let contentStr;
1084
+ let toolCallsJson = null;
1085
+ let toolCallId = null;
1086
+ if (message.role === "system" || message.role === "user") {
1087
+ contentStr = typeof message.content === "string" ? message.content : JSON.stringify(message.content);
1088
+ } else if (message.role === "assistant") {
1089
+ if (typeof message.content === "string") {
1090
+ contentStr = message.content;
1091
+ } else {
1092
+ const parts = message.content;
1093
+ const textParts = parts.filter((p) => p.type === "text").map((p) => p.text);
1094
+ const toolCalls = parts.filter((p) => p.type === "tool-call");
1095
+ contentStr = textParts.join("");
1096
+ if (toolCalls.length > 0) toolCallsJson = JSON.stringify(toolCalls);
1097
+ }
1098
+ } else if (message.role === "tool") {
1099
+ contentStr = JSON.stringify(message.content);
1100
+ const firstResult = Array.isArray(message.content) ? message.content[0] : void 0;
1101
+ if (firstResult && "toolCallId" in firstResult) toolCallId = firstResult.toolCallId;
1102
+ } else {
1103
+ contentStr = "";
1104
+ }
708
1105
  await this.engine.insert(MESSAGES_OBJECT, {
709
1106
  id: msgId,
710
1107
  conversation_id: conversationId,
711
1108
  role: message.role,
712
- content: message.content,
713
- tool_calls: message.toolCalls ? JSON.stringify(message.toolCalls) : null,
714
- tool_call_id: message.toolCallId ?? null,
1109
+ content: contentStr,
1110
+ tool_calls: toolCallsJson,
1111
+ tool_call_id: toolCallId,
715
1112
  created_at: now
716
1113
  });
717
1114
  await this.engine.update(CONVERSATIONS_OBJECT, { id: conversationId, updated_at: now }, {
@@ -756,21 +1153,42 @@ var ObjectQLConversationService = class {
756
1153
  };
757
1154
  }
758
1155
  /**
759
- * Map a database row to an AIMessage.
1156
+ * Map a database row to a ModelMessage.
760
1157
  */
761
1158
  toMessage(row) {
762
- const msg = {
763
- role: row.role,
764
- content: row.content
765
- };
766
- const toolCalls = this.safeParse(row.tool_calls);
767
- if (toolCalls) {
768
- msg.toolCalls = toolCalls;
769
- }
770
- if (row.tool_call_id) {
771
- msg.toolCallId = row.tool_call_id;
1159
+ switch (row.role) {
1160
+ case "system":
1161
+ return { role: "system", content: row.content };
1162
+ case "user":
1163
+ return { role: "user", content: row.content };
1164
+ case "assistant": {
1165
+ const toolCalls = this.safeParse(row.tool_calls);
1166
+ if (toolCalls && toolCalls.length > 0) {
1167
+ const content = [];
1168
+ if (row.content) content.push({ type: "text", text: row.content });
1169
+ content.push(...toolCalls);
1170
+ return { role: "assistant", content };
1171
+ }
1172
+ return { role: "assistant", content: row.content };
1173
+ }
1174
+ case "tool": {
1175
+ const toolResults = this.safeParse(row.content);
1176
+ if (toolResults && toolResults.length > 0 && toolResults[0]?.type === "tool-result") {
1177
+ return { role: "tool", content: toolResults };
1178
+ }
1179
+ return {
1180
+ role: "tool",
1181
+ content: [{
1182
+ type: "tool-result",
1183
+ toolCallId: row.tool_call_id ?? "",
1184
+ toolName: "unknown",
1185
+ output: { type: "text", value: row.content }
1186
+ }]
1187
+ };
1188
+ }
1189
+ default:
1190
+ return { role: "user", content: row.content };
772
1191
  }
773
- return msg;
774
1192
  }
775
1193
  };
776
1194
 
@@ -1167,13 +1585,507 @@ function registerDataTools(registry, context) {
1167
1585
  registry.register(AGGREGATE_DATA_TOOL, createAggregateDataHandler(context));
1168
1586
  }
1169
1587
 
1170
- // src/agent-runtime.ts
1588
+ // src/tools/create-object.tool.ts
1171
1589
  var import_ai = require("@objectstack/spec/ai");
1590
+ var createObjectTool = (0, import_ai.defineTool)({
1591
+ name: "create_object",
1592
+ label: "Create Object",
1593
+ 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.",
1594
+ category: "data",
1595
+ builtIn: true,
1596
+ // NOTE: requiresConfirmation is intentionally false (default) because the
1597
+ // server-side tool-call loop in AIService.chatWithTools/streamChatWithTools
1598
+ // executes tool calls immediately without checking this flag. The flag
1599
+ // should only be set once server-side approval gating is implemented to
1600
+ // avoid giving users a false sense of safety.
1601
+ parameters: {
1602
+ type: "object",
1603
+ properties: {
1604
+ name: {
1605
+ type: "string",
1606
+ description: "Machine name for the object (snake_case, e.g. project_task)"
1607
+ },
1608
+ label: {
1609
+ type: "string",
1610
+ description: "Human-readable display name (e.g. Project Task)"
1611
+ },
1612
+ fields: {
1613
+ type: "array",
1614
+ description: "Initial fields to create with the object",
1615
+ items: {
1616
+ type: "object",
1617
+ properties: {
1618
+ name: { type: "string", description: "Field machine name (snake_case)" },
1619
+ label: { type: "string", description: "Field display name" },
1620
+ type: {
1621
+ type: "string",
1622
+ description: "Field data type",
1623
+ enum: ["text", "textarea", "number", "boolean", "date", "datetime", "select", "lookup", "formula", "autonumber"]
1624
+ },
1625
+ required: { type: "boolean", description: "Whether the field is required" }
1626
+ },
1627
+ required: ["name", "type"]
1628
+ }
1629
+ },
1630
+ enableFeatures: {
1631
+ type: "object",
1632
+ description: "Object capability flags",
1633
+ properties: {
1634
+ trackHistory: { type: "boolean" },
1635
+ apiEnabled: { type: "boolean" }
1636
+ }
1637
+ }
1638
+ },
1639
+ required: ["name", "label"],
1640
+ additionalProperties: false
1641
+ }
1642
+ });
1643
+
1644
+ // src/tools/add-field.tool.ts
1645
+ var import_ai2 = require("@objectstack/spec/ai");
1646
+ var addFieldTool = (0, import_ai2.defineTool)({
1647
+ name: "add_field",
1648
+ label: "Add Field",
1649
+ 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.",
1650
+ category: "data",
1651
+ builtIn: true,
1652
+ parameters: {
1653
+ type: "object",
1654
+ properties: {
1655
+ objectName: {
1656
+ type: "string",
1657
+ description: "Target object machine name (snake_case)"
1658
+ },
1659
+ name: {
1660
+ type: "string",
1661
+ description: "Field machine name (snake_case, e.g. due_date)"
1662
+ },
1663
+ label: {
1664
+ type: "string",
1665
+ description: "Human-readable field label (e.g. Due Date)"
1666
+ },
1667
+ type: {
1668
+ type: "string",
1669
+ description: "Field data type",
1670
+ enum: ["text", "textarea", "number", "boolean", "date", "datetime", "select", "lookup", "formula", "autonumber"]
1671
+ },
1672
+ required: {
1673
+ type: "boolean",
1674
+ description: "Whether the field is required"
1675
+ },
1676
+ defaultValue: {
1677
+ description: "Default value for the field"
1678
+ },
1679
+ options: {
1680
+ type: "array",
1681
+ description: "Options for select/picklist fields",
1682
+ items: {
1683
+ type: "object",
1684
+ properties: {
1685
+ label: { type: "string" },
1686
+ value: {
1687
+ type: "string",
1688
+ description: "Option machine identifier (lowercase snake_case, e.g. high_priority)",
1689
+ pattern: "^[a-z_][a-z0-9_]*$"
1690
+ }
1691
+ }
1692
+ }
1693
+ },
1694
+ reference: {
1695
+ type: "string",
1696
+ description: "Referenced object name for lookup fields (snake_case, e.g. account)"
1697
+ }
1698
+ },
1699
+ required: ["objectName", "name", "type"],
1700
+ additionalProperties: false
1701
+ }
1702
+ });
1703
+
1704
+ // src/tools/modify-field.tool.ts
1705
+ var import_ai3 = require("@objectstack/spec/ai");
1706
+ var modifyFieldTool = (0, import_ai3.defineTool)({
1707
+ name: "modify_field",
1708
+ label: "Modify Field",
1709
+ 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).",
1710
+ category: "data",
1711
+ builtIn: true,
1712
+ parameters: {
1713
+ type: "object",
1714
+ properties: {
1715
+ objectName: {
1716
+ type: "string",
1717
+ description: "Target object machine name (snake_case)"
1718
+ },
1719
+ fieldName: {
1720
+ type: "string",
1721
+ description: "Existing field machine name to modify (snake_case)"
1722
+ },
1723
+ changes: {
1724
+ type: "object",
1725
+ description: "Field properties to update (partial patch)",
1726
+ properties: {
1727
+ label: { type: "string", description: "New display label" },
1728
+ type: { type: "string", description: "New field type" },
1729
+ required: { type: "boolean", description: "Update required constraint" },
1730
+ defaultValue: { description: "New default value" }
1731
+ }
1732
+ }
1733
+ },
1734
+ required: ["objectName", "fieldName", "changes"],
1735
+ additionalProperties: false
1736
+ }
1737
+ });
1738
+
1739
+ // src/tools/delete-field.tool.ts
1740
+ var import_ai4 = require("@objectstack/spec/ai");
1741
+ var deleteFieldTool = (0, import_ai4.defineTool)({
1742
+ name: "delete_field",
1743
+ label: "Delete Field",
1744
+ 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.",
1745
+ category: "data",
1746
+ builtIn: true,
1747
+ // NOTE: requiresConfirmation is intentionally false (default) because the
1748
+ // server-side tool-call loop in AIService.chatWithTools/streamChatWithTools
1749
+ // executes tool calls immediately without checking this flag. The flag
1750
+ // should only be set once server-side approval gating is implemented.
1751
+ parameters: {
1752
+ type: "object",
1753
+ properties: {
1754
+ objectName: {
1755
+ type: "string",
1756
+ description: "Target object machine name (snake_case)"
1757
+ },
1758
+ fieldName: {
1759
+ type: "string",
1760
+ description: "Field machine name to delete (snake_case)"
1761
+ }
1762
+ },
1763
+ required: ["objectName", "fieldName"],
1764
+ additionalProperties: false
1765
+ }
1766
+ });
1767
+
1768
+ // src/tools/list-metadata-objects.tool.ts
1769
+ var import_ai5 = require("@objectstack/spec/ai");
1770
+ var listMetadataObjectsTool = (0, import_ai5.defineTool)({
1771
+ name: "list_metadata_objects",
1772
+ label: "List Metadata Objects",
1773
+ 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.",
1774
+ category: "data",
1775
+ builtIn: true,
1776
+ parameters: {
1777
+ type: "object",
1778
+ properties: {
1779
+ filter: {
1780
+ type: "string",
1781
+ description: "Optional name or label substring to filter objects"
1782
+ },
1783
+ includeFields: {
1784
+ type: "boolean",
1785
+ description: "Whether to include field summaries for each object (default: false)"
1786
+ }
1787
+ },
1788
+ additionalProperties: false
1789
+ }
1790
+ });
1791
+
1792
+ // src/tools/describe-metadata-object.tool.ts
1793
+ var import_ai6 = require("@objectstack/spec/ai");
1794
+ var describeMetadataObjectTool = (0, import_ai6.defineTool)({
1795
+ name: "describe_metadata_object",
1796
+ label: "Describe Metadata Object",
1797
+ 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.",
1798
+ category: "data",
1799
+ builtIn: true,
1800
+ parameters: {
1801
+ type: "object",
1802
+ properties: {
1803
+ objectName: {
1804
+ type: "string",
1805
+ description: "Object machine name to describe (snake_case)"
1806
+ }
1807
+ },
1808
+ required: ["objectName"],
1809
+ additionalProperties: false
1810
+ }
1811
+ });
1812
+
1813
+ // src/tools/metadata-tools.ts
1814
+ var METADATA_TOOL_DEFINITIONS = [
1815
+ createObjectTool,
1816
+ addFieldTool,
1817
+ modifyFieldTool,
1818
+ deleteFieldTool,
1819
+ listMetadataObjectsTool,
1820
+ describeMetadataObjectTool
1821
+ ];
1822
+ var SNAKE_CASE_RE = /^[a-z_][a-z0-9_]*$/;
1823
+ function isSnakeCase(value) {
1824
+ return SNAKE_CASE_RE.test(value);
1825
+ }
1826
+ function createCreateObjectHandler(ctx) {
1827
+ return async (args) => {
1828
+ const { name, label, fields, enableFeatures } = args;
1829
+ if (!name || !label) {
1830
+ return JSON.stringify({ error: 'Both "name" and "label" are required' });
1831
+ }
1832
+ if (!isSnakeCase(name)) {
1833
+ return JSON.stringify({ error: `Invalid object name "${name}". Must be snake_case.` });
1834
+ }
1835
+ const existing = await ctx.metadataService.getObject(name);
1836
+ if (existing) {
1837
+ return JSON.stringify({ error: `Object "${name}" already exists` });
1838
+ }
1839
+ const fieldMap = {};
1840
+ if (fields && Array.isArray(fields)) {
1841
+ const seenNames = /* @__PURE__ */ new Set();
1842
+ for (const f of fields) {
1843
+ if (!f.name) {
1844
+ return JSON.stringify({ error: 'Each field must have a "name" property' });
1845
+ }
1846
+ if (!isSnakeCase(f.name)) {
1847
+ return JSON.stringify({ error: `Invalid field name "${f.name}". Must be snake_case.` });
1848
+ }
1849
+ if (seenNames.has(f.name)) {
1850
+ return JSON.stringify({ error: `Duplicate field name "${f.name}" in initial fields` });
1851
+ }
1852
+ seenNames.add(f.name);
1853
+ fieldMap[f.name] = {
1854
+ type: f.type,
1855
+ ...f.label ? { label: f.label } : {},
1856
+ ...f.required !== void 0 ? { required: f.required } : {}
1857
+ };
1858
+ }
1859
+ }
1860
+ const objectDef = {
1861
+ name,
1862
+ label,
1863
+ ...Object.keys(fieldMap).length > 0 ? { fields: fieldMap } : {},
1864
+ ...enableFeatures ? { enable: enableFeatures } : {}
1865
+ };
1866
+ await ctx.metadataService.register("object", name, objectDef);
1867
+ return JSON.stringify({
1868
+ name,
1869
+ label,
1870
+ fieldCount: Object.keys(fieldMap).length
1871
+ });
1872
+ };
1873
+ }
1874
+ function createAddFieldHandler(ctx) {
1875
+ return async (args) => {
1876
+ const { objectName, name, label, type, required, defaultValue, options, reference } = args;
1877
+ if (!objectName || !name || !type) {
1878
+ return JSON.stringify({ error: '"objectName", "name", and "type" are required' });
1879
+ }
1880
+ if (!isSnakeCase(objectName)) {
1881
+ return JSON.stringify({ error: `Invalid object name "${objectName}". Must be snake_case.` });
1882
+ }
1883
+ if (!isSnakeCase(name)) {
1884
+ return JSON.stringify({ error: `Invalid field name "${name}". Must be snake_case.` });
1885
+ }
1886
+ if (reference && !isSnakeCase(reference)) {
1887
+ return JSON.stringify({ error: `Invalid reference "${reference}". Must be a snake_case object name.` });
1888
+ }
1889
+ if (options && Array.isArray(options)) {
1890
+ for (const opt of options) {
1891
+ if (opt.value && !isSnakeCase(opt.value)) {
1892
+ return JSON.stringify({ error: `Invalid option value "${opt.value}". Must be lowercase snake_case.` });
1893
+ }
1894
+ }
1895
+ }
1896
+ const objectDef = await ctx.metadataService.getObject(objectName);
1897
+ if (!objectDef) {
1898
+ return JSON.stringify({ error: `Object "${objectName}" not found` });
1899
+ }
1900
+ const def = objectDef;
1901
+ if (def.fields && def.fields[name]) {
1902
+ return JSON.stringify({ error: `Field "${name}" already exists on object "${objectName}"` });
1903
+ }
1904
+ const fieldDef = {
1905
+ type,
1906
+ ...label ? { label } : {},
1907
+ ...required !== void 0 ? { required } : {},
1908
+ ...defaultValue !== void 0 ? { defaultValue } : {},
1909
+ ...options ? { options } : {},
1910
+ ...reference ? { reference } : {}
1911
+ };
1912
+ const updatedFields = { ...def.fields ?? {}, [name]: fieldDef };
1913
+ await ctx.metadataService.register("object", objectName, {
1914
+ ...def,
1915
+ fields: updatedFields
1916
+ });
1917
+ return JSON.stringify({
1918
+ objectName,
1919
+ fieldName: name,
1920
+ fieldType: type
1921
+ });
1922
+ };
1923
+ }
1924
+ function createModifyFieldHandler(ctx) {
1925
+ return async (args) => {
1926
+ const { objectName, fieldName, changes } = args;
1927
+ if (!objectName || !fieldName || !changes) {
1928
+ return JSON.stringify({ error: '"objectName", "fieldName", and "changes" are required' });
1929
+ }
1930
+ if (!isSnakeCase(objectName)) {
1931
+ return JSON.stringify({ error: `Invalid object name "${objectName}". Must be snake_case.` });
1932
+ }
1933
+ if (!isSnakeCase(fieldName)) {
1934
+ return JSON.stringify({ error: `Invalid field name "${fieldName}". Must be snake_case.` });
1935
+ }
1936
+ const objectDef = await ctx.metadataService.getObject(objectName);
1937
+ if (!objectDef) {
1938
+ return JSON.stringify({ error: `Object "${objectName}" not found` });
1939
+ }
1940
+ const def = objectDef;
1941
+ if (!def.fields || !def.fields[fieldName]) {
1942
+ return JSON.stringify({ error: `Field "${fieldName}" not found on object "${objectName}"` });
1943
+ }
1944
+ const existingField = def.fields[fieldName];
1945
+ const updatedField = { ...existingField, ...changes };
1946
+ const updatedFields = { ...def.fields, [fieldName]: updatedField };
1947
+ await ctx.metadataService.register("object", objectName, {
1948
+ ...def,
1949
+ fields: updatedFields
1950
+ });
1951
+ return JSON.stringify({
1952
+ objectName,
1953
+ fieldName,
1954
+ updatedProperties: Object.keys(changes)
1955
+ });
1956
+ };
1957
+ }
1958
+ function createDeleteFieldHandler(ctx) {
1959
+ return async (args) => {
1960
+ const { objectName, fieldName } = args;
1961
+ if (!objectName || !fieldName) {
1962
+ return JSON.stringify({ error: '"objectName" and "fieldName" are required' });
1963
+ }
1964
+ if (!isSnakeCase(objectName)) {
1965
+ return JSON.stringify({ error: `Invalid object name "${objectName}". Must be snake_case.` });
1966
+ }
1967
+ if (!isSnakeCase(fieldName)) {
1968
+ return JSON.stringify({ error: `Invalid field name "${fieldName}". Must be snake_case.` });
1969
+ }
1970
+ const objectDef = await ctx.metadataService.getObject(objectName);
1971
+ if (!objectDef) {
1972
+ return JSON.stringify({ error: `Object "${objectName}" not found` });
1973
+ }
1974
+ const def = objectDef;
1975
+ if (!def.fields || !def.fields[fieldName]) {
1976
+ return JSON.stringify({ error: `Field "${fieldName}" not found on object "${objectName}"` });
1977
+ }
1978
+ const { [fieldName]: _removed, ...remainingFields } = def.fields;
1979
+ await ctx.metadataService.register("object", objectName, {
1980
+ ...def,
1981
+ fields: remainingFields
1982
+ });
1983
+ return JSON.stringify({
1984
+ objectName,
1985
+ fieldName,
1986
+ success: true
1987
+ });
1988
+ };
1989
+ }
1990
+ function createListObjectsHandler2(ctx) {
1991
+ return async (args) => {
1992
+ const { filter, includeFields } = args ?? {};
1993
+ const objects = await ctx.metadataService.listObjects();
1994
+ let result = objects.map((o) => {
1995
+ const base = {
1996
+ name: o.name,
1997
+ label: o.label ?? o.name,
1998
+ fieldCount: o.fields ? Object.keys(o.fields).length : 0
1999
+ };
2000
+ if (includeFields && o.fields) {
2001
+ base.fields = Object.entries(o.fields).map(([key, f]) => ({
2002
+ name: key,
2003
+ type: f.type,
2004
+ label: f.label ?? key
2005
+ }));
2006
+ }
2007
+ return base;
2008
+ });
2009
+ if (filter) {
2010
+ const lower = filter.toLowerCase();
2011
+ result = result.filter(
2012
+ (o) => o.name.toLowerCase().includes(lower) || o.label.toLowerCase().includes(lower)
2013
+ );
2014
+ }
2015
+ return JSON.stringify({
2016
+ objects: result,
2017
+ totalCount: result.length
2018
+ });
2019
+ };
2020
+ }
2021
+ function createDescribeObjectHandler2(ctx) {
2022
+ return async (args) => {
2023
+ const { objectName } = args;
2024
+ if (!objectName) {
2025
+ return JSON.stringify({ error: '"objectName" is required' });
2026
+ }
2027
+ if (!isSnakeCase(objectName)) {
2028
+ return JSON.stringify({ error: `Invalid object name "${objectName}". Must be snake_case.` });
2029
+ }
2030
+ const objectDef = await ctx.metadataService.getObject(objectName);
2031
+ if (!objectDef) {
2032
+ return JSON.stringify({ error: `Object "${objectName}" not found` });
2033
+ }
2034
+ const def = objectDef;
2035
+ const fields = def.fields ?? {};
2036
+ const fieldSummary = Object.entries(fields).map(([key, f]) => ({
2037
+ name: key,
2038
+ type: f.type,
2039
+ label: f.label ?? key,
2040
+ required: f.required ?? false,
2041
+ ...f.reference ? { reference: f.reference } : {},
2042
+ ...f.options ? { options: f.options } : {}
2043
+ }));
2044
+ return JSON.stringify({
2045
+ name: def.name,
2046
+ label: def.label ?? def.name,
2047
+ fields: fieldSummary,
2048
+ enableFeatures: def.enable ?? {}
2049
+ });
2050
+ };
2051
+ }
2052
+ function registerMetadataTools(registry, context) {
2053
+ registry.register(createObjectTool, createCreateObjectHandler(context));
2054
+ registry.register(addFieldTool, createAddFieldHandler(context));
2055
+ registry.register(modifyFieldTool, createModifyFieldHandler(context));
2056
+ registry.register(deleteFieldTool, createDeleteFieldHandler(context));
2057
+ registry.register(listMetadataObjectsTool, createListObjectsHandler2(context));
2058
+ registry.register(describeMetadataObjectTool, createDescribeObjectHandler2(context));
2059
+ }
2060
+
2061
+ // src/agent-runtime.ts
2062
+ var import_ai7 = require("@objectstack/spec/ai");
1172
2063
  var AgentRuntime = class {
1173
2064
  constructor(metadataService) {
1174
2065
  this.metadataService = metadataService;
1175
2066
  }
1176
2067
  // ── Public API ────────────────────────────────────────────────
2068
+ /**
2069
+ * List all active agents registered in the metadata service.
2070
+ *
2071
+ * Returns a summary for each agent (name, label, role) suitable
2072
+ * for populating an agent selector dropdown in the UI.
2073
+ */
2074
+ async listAgents() {
2075
+ const rawItems = await this.metadataService.list("agent");
2076
+ const agents = [];
2077
+ for (const raw of rawItems) {
2078
+ const result = import_ai7.AgentSchema.safeParse(raw);
2079
+ if (result.success && result.data.active) {
2080
+ agents.push({
2081
+ name: result.data.name,
2082
+ label: result.data.label,
2083
+ role: result.data.role
2084
+ });
2085
+ }
2086
+ }
2087
+ return agents;
2088
+ }
1177
2089
  /**
1178
2090
  * Load and validate an agent definition by name.
1179
2091
  *
@@ -1185,7 +2097,7 @@ var AgentRuntime = class {
1185
2097
  async loadAgent(agentName) {
1186
2098
  const raw = await this.metadataService.get("agent", agentName);
1187
2099
  if (!raw) return void 0;
1188
- const result = import_ai.AgentSchema.safeParse(raw);
2100
+ const result = import_ai7.AgentSchema.safeParse(raw);
1189
2101
  if (!result.success) {
1190
2102
  return void 0;
1191
2103
  }
@@ -1302,15 +2214,233 @@ Guidelines:
1302
2214
  }
1303
2215
  };
1304
2216
 
2217
+ // src/agents/metadata-assistant-agent.ts
2218
+ var METADATA_ASSISTANT_AGENT = {
2219
+ name: "metadata_assistant",
2220
+ label: "Metadata Assistant",
2221
+ role: "Schema Architect",
2222
+ instructions: `You are an expert metadata architect that helps users design and manage their data models through natural language.
2223
+
2224
+ Capabilities:
2225
+ - Create new data objects (tables) with fields
2226
+ - Add fields (columns) to existing objects
2227
+ - Modify field properties (label, type, required, default value)
2228
+ - Delete fields from objects
2229
+ - List all registered metadata objects and their schemas
2230
+ - Describe the full schema of a specific object
2231
+
2232
+ Guidelines:
2233
+ 1. Before creating a new object, use list_metadata_objects to check if a similar one already exists.
2234
+ 2. Before modifying or deleting fields, use describe_metadata_object to understand the current schema.
2235
+ 3. Always use snake_case for object names and field names (e.g. project_task, due_date).
2236
+ 4. Suggest meaningful field types based on the user's description (e.g. "deadline" \u2192 date, "active" \u2192 boolean).
2237
+ 5. When creating objects, propose a reasonable set of initial fields based on the entity type.
2238
+ 6. Explain what changes you are about to make before executing them.
2239
+ 7. After making changes, confirm the result by describing the updated schema.
2240
+ 8. For destructive operations (deleting fields), always warn the user about potential data loss.
2241
+ 9. Always answer in the same language the user is using.
2242
+ 10. If the user's request is ambiguous, ask clarifying questions before proceeding.`,
2243
+ model: {
2244
+ provider: "openai",
2245
+ model: "gpt-4",
2246
+ temperature: 0.2,
2247
+ maxTokens: 4096
2248
+ },
2249
+ tools: [
2250
+ { type: "action", name: "create_object", description: "Create a new data object (table)" },
2251
+ { type: "action", name: "add_field", description: "Add a field to an existing object" },
2252
+ { type: "action", name: "modify_field", description: "Modify an existing field definition" },
2253
+ { type: "action", name: "delete_field", description: "Delete a field from an object" },
2254
+ { type: "query", name: "list_metadata_objects", description: "List all metadata objects" },
2255
+ { type: "query", name: "describe_metadata_object", description: "Describe an object schema" }
2256
+ ],
2257
+ active: true,
2258
+ visibility: "global",
2259
+ guardrails: {
2260
+ maxTokensPerInvocation: 8192,
2261
+ maxExecutionTimeSec: 60,
2262
+ blockedTopics: ["drop_database", "raw_sql", "system_tables"]
2263
+ },
2264
+ planning: {
2265
+ strategy: "react",
2266
+ maxIterations: 10,
2267
+ allowReplan: true
2268
+ },
2269
+ memory: {
2270
+ shortTerm: {
2271
+ maxMessages: 30,
2272
+ maxTokens: 8192
2273
+ }
2274
+ }
2275
+ };
2276
+
2277
+ // src/adapters/vercel-adapter.ts
2278
+ var import_ai8 = require("ai");
2279
+ function buildVercelOptions(options) {
2280
+ if (!options) return {};
2281
+ const opts = {};
2282
+ if (options.temperature != null) opts.temperature = options.temperature;
2283
+ if (options.maxTokens != null) opts.maxTokens = options.maxTokens;
2284
+ if (options.stop?.length) opts.stopSequences = options.stop;
2285
+ if (options.tools?.length) {
2286
+ const tools = {};
2287
+ for (const t of options.tools) {
2288
+ tools[t.name] = (0, import_ai8.tool)({
2289
+ description: t.description,
2290
+ inputSchema: (0, import_ai8.jsonSchema)(t.parameters)
2291
+ });
2292
+ }
2293
+ opts.tools = tools;
2294
+ }
2295
+ if (options.toolChoice != null) {
2296
+ opts.toolChoice = options.toolChoice;
2297
+ }
2298
+ return opts;
2299
+ }
2300
+ var VercelLLMAdapter = class {
2301
+ constructor(config) {
2302
+ this.name = "vercel";
2303
+ this.model = config.model;
2304
+ }
2305
+ async chat(messages, options) {
2306
+ const result = await (0, import_ai8.generateText)({
2307
+ model: this.model,
2308
+ messages,
2309
+ ...buildVercelOptions(options)
2310
+ });
2311
+ return {
2312
+ content: result.text,
2313
+ model: result.response?.modelId,
2314
+ toolCalls: result.toolCalls?.length ? result.toolCalls : void 0,
2315
+ usage: result.usage ? {
2316
+ promptTokens: result.usage.inputTokens ?? 0,
2317
+ completionTokens: result.usage.outputTokens ?? 0,
2318
+ totalTokens: result.usage.totalTokens ?? 0
2319
+ } : void 0
2320
+ };
2321
+ }
2322
+ async complete(prompt, options) {
2323
+ const result = await (0, import_ai8.generateText)({
2324
+ model: this.model,
2325
+ prompt,
2326
+ ...buildVercelOptions(options)
2327
+ });
2328
+ return {
2329
+ content: result.text,
2330
+ model: result.response?.modelId,
2331
+ usage: result.usage ? {
2332
+ promptTokens: result.usage.inputTokens ?? 0,
2333
+ completionTokens: result.usage.outputTokens ?? 0,
2334
+ totalTokens: result.usage.totalTokens ?? 0
2335
+ } : void 0
2336
+ };
2337
+ }
2338
+ async *streamChat(messages, options) {
2339
+ const result = (0, import_ai8.streamText)({
2340
+ model: this.model,
2341
+ messages,
2342
+ ...buildVercelOptions(options)
2343
+ });
2344
+ for await (const part of result.fullStream) {
2345
+ yield part;
2346
+ }
2347
+ }
2348
+ async embed(_input) {
2349
+ throw new Error(
2350
+ "[VercelLLMAdapter] Embeddings require a dedicated EmbeddingModel. Configure an embedding adapter instead."
2351
+ );
2352
+ }
2353
+ async listModels() {
2354
+ return [];
2355
+ }
2356
+ };
2357
+
1305
2358
  // src/plugin.ts
1306
2359
  var AIServicePlugin = class {
1307
2360
  constructor(options = {}) {
1308
2361
  this.name = "com.objectstack.service-ai";
1309
2362
  this.version = "1.0.0";
1310
2363
  this.type = "standard";
1311
- this.dependencies = [];
2364
+ this.dependencies = ["com.objectstack.engine.objectql"];
1312
2365
  this.options = options;
1313
2366
  }
2367
+ /**
2368
+ * Auto-detect LLM provider from environment variables.
2369
+ *
2370
+ * Priority order:
2371
+ * 1. AI_GATEWAY_MODEL → Vercel AI Gateway
2372
+ * 2. OPENAI_API_KEY → OpenAI
2373
+ * 3. ANTHROPIC_API_KEY → Anthropic
2374
+ * 4. GOOGLE_GENERATIVE_AI_API_KEY → Google
2375
+ * 5. Fallback → MemoryLLMAdapter
2376
+ *
2377
+ * Returns the adapter and a description for logging.
2378
+ */
2379
+ async detectAdapter(ctx) {
2380
+ const gatewayModel = process.env.AI_GATEWAY_MODEL;
2381
+ if (gatewayModel) {
2382
+ try {
2383
+ const gatewayPkg = "@ai-sdk/gateway";
2384
+ const { gateway } = await import(
2385
+ /* webpackIgnore: true */
2386
+ gatewayPkg
2387
+ );
2388
+ const adapter = new VercelLLMAdapter({ model: gateway(gatewayModel) });
2389
+ return { adapter, description: `Vercel AI Gateway (model: ${gatewayModel})` };
2390
+ } catch (err) {
2391
+ ctx.logger.warn(
2392
+ `[AI] Failed to load @ai-sdk/gateway for AI_GATEWAY_MODEL=${gatewayModel}, trying next provider`,
2393
+ err instanceof Error ? { error: err.message } : void 0
2394
+ );
2395
+ }
2396
+ }
2397
+ const providerConfigs = [
2398
+ {
2399
+ envKey: "OPENAI_API_KEY",
2400
+ pkg: "@ai-sdk/openai",
2401
+ factory: "openai",
2402
+ defaultModel: "gpt-4o",
2403
+ displayName: "OpenAI"
2404
+ },
2405
+ {
2406
+ envKey: "ANTHROPIC_API_KEY",
2407
+ pkg: "@ai-sdk/anthropic",
2408
+ factory: "anthropic",
2409
+ defaultModel: "claude-sonnet-4-20250514",
2410
+ displayName: "Anthropic"
2411
+ },
2412
+ {
2413
+ envKey: "GOOGLE_GENERATIVE_AI_API_KEY",
2414
+ pkg: "@ai-sdk/google",
2415
+ factory: "google",
2416
+ defaultModel: "gemini-2.0-flash",
2417
+ displayName: "Google"
2418
+ }
2419
+ ];
2420
+ for (const { envKey, pkg, factory, defaultModel, displayName } of providerConfigs) {
2421
+ if (process.env[envKey]) {
2422
+ try {
2423
+ const mod = await import(
2424
+ /* webpackIgnore: true */
2425
+ pkg
2426
+ );
2427
+ const createModel = mod[factory] ?? mod.default;
2428
+ if (typeof createModel === "function") {
2429
+ const modelId = process.env.AI_MODEL ?? defaultModel;
2430
+ const adapter = new VercelLLMAdapter({ model: createModel(modelId) });
2431
+ return { adapter, description: `${displayName} (model: ${modelId})` };
2432
+ }
2433
+ } catch (err) {
2434
+ ctx.logger.warn(
2435
+ `[AI] Failed to load ${pkg} for ${envKey}, trying next provider`,
2436
+ err instanceof Error ? { error: err.message } : void 0
2437
+ );
2438
+ }
2439
+ }
2440
+ }
2441
+ 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.");
2442
+ return { adapter: new MemoryLLMAdapter(), description: "MemoryLLMAdapter (echo mode - for testing only)" };
2443
+ }
1314
2444
  async init(ctx) {
1315
2445
  let hasExisting = false;
1316
2446
  try {
@@ -1332,8 +2462,19 @@ var AIServicePlugin = class {
1332
2462
  } catch {
1333
2463
  }
1334
2464
  }
2465
+ let adapter;
2466
+ let adapterDescription;
2467
+ if (this.options.adapter) {
2468
+ adapter = this.options.adapter;
2469
+ adapterDescription = `${adapter.name} (explicitly configured)`;
2470
+ } else {
2471
+ const detected = await this.detectAdapter(ctx);
2472
+ adapter = detected.adapter;
2473
+ adapterDescription = detected.description;
2474
+ }
2475
+ ctx.logger.info(`[AI] Using LLM adapter: ${adapterDescription}`);
1335
2476
  const config = {
1336
- adapter: this.options.adapter,
2477
+ adapter,
1337
2478
  logger: ctx.logger,
1338
2479
  conversationService
1339
2480
  };
@@ -1343,7 +2484,7 @@ var AIServicePlugin = class {
1343
2484
  } else {
1344
2485
  ctx.registerService("ai", this.service);
1345
2486
  }
1346
- ctx.registerService("app.com.objectstack.service-ai", {
2487
+ ctx.getService("manifest").register({
1347
2488
  id: "com.objectstack.service-ai",
1348
2489
  name: "AI Service",
1349
2490
  version: "1.0.0",
@@ -1356,13 +2497,32 @@ var AIServicePlugin = class {
1356
2497
  ctx.logger.debug("[AI] Before chat", { messages });
1357
2498
  });
1358
2499
  }
2500
+ try {
2501
+ const setupNav = ctx.getService("setupNav");
2502
+ if (setupNav) {
2503
+ setupNav.contribute({
2504
+ areaId: "area_ai",
2505
+ items: [
2506
+ { id: "nav_ai_conversations", type: "object", label: { key: "setup.nav.ai_conversations", defaultValue: "Conversations" }, objectName: "conversations", icon: "message-square", order: 10 },
2507
+ { id: "nav_ai_messages", type: "object", label: { key: "setup.nav.ai_messages", defaultValue: "Messages" }, objectName: "messages", icon: "messages-square", order: 20 }
2508
+ ]
2509
+ });
2510
+ ctx.logger.info("[AI] Navigation items contributed to Setup App");
2511
+ }
2512
+ } catch {
2513
+ }
1359
2514
  ctx.logger.info("[AI] Service initialized");
1360
2515
  }
1361
2516
  async start(ctx) {
1362
2517
  if (!this.service) return;
2518
+ let metadataService;
2519
+ try {
2520
+ metadataService = ctx.getService("metadata");
2521
+ } catch {
2522
+ ctx.logger.debug("[AI] Metadata service not available");
2523
+ }
1363
2524
  try {
1364
2525
  const dataEngine = ctx.getService("data");
1365
- const metadataService = ctx.getService("metadata");
1366
2526
  if (dataEngine && metadataService) {
1367
2527
  registerDataTools(this.service.toolRegistry, { dataEngine, metadataService });
1368
2528
  ctx.logger.info("[AI] Built-in data tools registered");
@@ -1375,14 +2535,29 @@ var AIServicePlugin = class {
1375
2535
  }
1376
2536
  }
1377
2537
  } catch {
1378
- ctx.logger.debug("[AI] Data engine or metadata service not available, skipping data tools");
2538
+ ctx.logger.debug("[AI] Data engine not available, skipping data tools");
2539
+ }
2540
+ if (metadataService) {
2541
+ try {
2542
+ registerMetadataTools(this.service.toolRegistry, { metadataService });
2543
+ ctx.logger.info("[AI] Built-in metadata tools registered");
2544
+ const agentExists = typeof metadataService.exists === "function" ? await metadataService.exists("agent", METADATA_ASSISTANT_AGENT.name) : false;
2545
+ if (!agentExists) {
2546
+ await metadataService.register("agent", METADATA_ASSISTANT_AGENT.name, METADATA_ASSISTANT_AGENT);
2547
+ ctx.logger.info("[AI] metadata_assistant agent registered");
2548
+ } else {
2549
+ ctx.logger.debug("[AI] metadata_assistant agent already exists, skipping auto-registration");
2550
+ }
2551
+ } catch (err) {
2552
+ ctx.logger.debug("[AI] Failed to register metadata tools", err instanceof Error ? err : void 0);
2553
+ }
1379
2554
  }
1380
2555
  await ctx.trigger("ai:ready", this.service);
1381
2556
  const routes = buildAIRoutes(this.service, this.service.conversationService, ctx.logger);
1382
2557
  try {
1383
- const metadataService = ctx.getService("metadata");
1384
- if (metadataService) {
1385
- const agentRuntime = new AgentRuntime(metadataService);
2558
+ const metadataService2 = ctx.getService("metadata");
2559
+ if (metadataService2) {
2560
+ const agentRuntime = new AgentRuntime(metadataService2);
1386
2561
  const agentRoutes = buildAgentRoutes(this.service, agentRuntime, ctx.logger);
1387
2562
  routes.push(...agentRoutes);
1388
2563
  }
@@ -1390,6 +2565,10 @@ var AIServicePlugin = class {
1390
2565
  ctx.logger.debug("[AI] Metadata service not available, skipping agent routes");
1391
2566
  }
1392
2567
  await ctx.trigger("ai:routes", routes);
2568
+ const kernel = ctx.getKernel();
2569
+ if (kernel) {
2570
+ kernel.__aiRoutes = routes;
2571
+ }
1393
2572
  ctx.logger.info(
1394
2573
  `[AI] Service started \u2014 adapter="${this.service.adapterName}", tools=${this.service.toolRegistry.size}, routes=${routes.length}`
1395
2574
  );
@@ -1408,11 +2587,23 @@ var AIServicePlugin = class {
1408
2587
  DATA_CHAT_AGENT,
1409
2588
  DATA_TOOL_DEFINITIONS,
1410
2589
  InMemoryConversationService,
2590
+ METADATA_ASSISTANT_AGENT,
2591
+ METADATA_TOOL_DEFINITIONS,
1411
2592
  MemoryLLMAdapter,
1412
2593
  ObjectQLConversationService,
1413
2594
  ToolRegistry,
2595
+ VercelLLMAdapter,
2596
+ addFieldTool,
1414
2597
  buildAIRoutes,
1415
2598
  buildAgentRoutes,
1416
- registerDataTools
2599
+ createObjectTool,
2600
+ deleteFieldTool,
2601
+ describeMetadataObjectTool,
2602
+ encodeStreamPart,
2603
+ encodeVercelDataStream,
2604
+ listMetadataObjectsTool,
2605
+ modifyFieldTool,
2606
+ registerDataTools,
2607
+ registerMetadataTools
1417
2608
  });
1418
2609
  //# sourceMappingURL=index.cjs.map