@objectstack/service-ai 4.0.2 → 4.0.4

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.
package/dist/index.js CHANGED
@@ -1,972 +1,915 @@
1
- // src/ai-service.ts
2
- import { createLogger } from "@objectstack/core";
1
+ var __defProp = Object.defineProperty;
2
+ var __getOwnPropNames = Object.getOwnPropertyNames;
3
+ var __esm = (fn, res) => function __init() {
4
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
5
+ };
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
3
10
 
4
- // src/adapters/memory-adapter.ts
5
- var MemoryLLMAdapter = class {
6
- constructor() {
7
- this.name = "memory";
8
- }
9
- async chat(messages, options) {
10
- const lastUserMessage = [...messages].reverse().find((m) => m.role === "user");
11
- const userContent = lastUserMessage?.content;
12
- const text = typeof userContent === "string" ? userContent : "(complex content)";
13
- const content = lastUserMessage ? `[memory] ${text}` : "[memory] (no user message)";
14
- return {
15
- content,
16
- model: options?.model ?? "memory",
17
- usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 }
11
+ // src/tools/data-tools.ts
12
+ var data_tools_exports = {};
13
+ __export(data_tools_exports, {
14
+ AGGREGATE_DATA_TOOL: () => AGGREGATE_DATA_TOOL,
15
+ DATA_TOOL_DEFINITIONS: () => DATA_TOOL_DEFINITIONS,
16
+ GET_RECORD_TOOL: () => GET_RECORD_TOOL,
17
+ QUERY_RECORDS_TOOL: () => QUERY_RECORDS_TOOL,
18
+ registerDataTools: () => registerDataTools
19
+ });
20
+ function createQueryRecordsHandler(ctx) {
21
+ return async (args) => {
22
+ const {
23
+ objectName,
24
+ where,
25
+ fields,
26
+ orderBy,
27
+ limit,
28
+ offset
29
+ } = args;
30
+ const rawLimit = limit ?? DEFAULT_QUERY_LIMIT;
31
+ const safeLimit = Number.isFinite(rawLimit) && rawLimit > 0 ? Math.min(Math.floor(rawLimit), MAX_QUERY_LIMIT) : DEFAULT_QUERY_LIMIT;
32
+ const safeOffset = Number.isFinite(offset) && offset >= 0 ? Math.floor(offset) : void 0;
33
+ const records = await ctx.dataEngine.find(objectName, {
34
+ where,
35
+ fields,
36
+ orderBy,
37
+ limit: safeLimit,
38
+ offset: safeOffset
39
+ });
40
+ return JSON.stringify({ count: records.length, records });
41
+ };
42
+ }
43
+ function createGetRecordHandler(ctx) {
44
+ return async (args) => {
45
+ const { objectName, recordId, fields } = args;
46
+ const record = await ctx.dataEngine.findOne(objectName, {
47
+ where: { id: recordId },
48
+ fields
49
+ });
50
+ if (!record) {
51
+ return JSON.stringify({ error: `Record "${recordId}" not found in "${objectName}"` });
52
+ }
53
+ return JSON.stringify(record);
54
+ };
55
+ }
56
+ function createAggregateDataHandler(ctx) {
57
+ return async (args) => {
58
+ const { objectName, aggregations, groupBy, where } = args;
59
+ for (const a of aggregations) {
60
+ if (!VALID_AGG_FUNCTIONS.has(a.function)) {
61
+ return JSON.stringify({
62
+ error: `Invalid aggregation function "${a.function}". Allowed: ${[...VALID_AGG_FUNCTIONS].join(", ")}`
63
+ });
64
+ }
65
+ }
66
+ const result = await ctx.dataEngine.aggregate(objectName, {
67
+ where,
68
+ groupBy,
69
+ aggregations: aggregations.map((a) => ({
70
+ function: a.function,
71
+ field: a.field,
72
+ alias: a.alias
73
+ }))
74
+ });
75
+ return JSON.stringify(result);
76
+ };
77
+ }
78
+ function registerDataTools(registry, context) {
79
+ registry.register(QUERY_RECORDS_TOOL, createQueryRecordsHandler(context));
80
+ registry.register(GET_RECORD_TOOL, createGetRecordHandler(context));
81
+ registry.register(AGGREGATE_DATA_TOOL, createAggregateDataHandler(context));
82
+ }
83
+ var MAX_QUERY_LIMIT, DEFAULT_QUERY_LIMIT, QUERY_RECORDS_TOOL, GET_RECORD_TOOL, AGGREGATE_DATA_TOOL, DATA_TOOL_DEFINITIONS, VALID_AGG_FUNCTIONS;
84
+ var init_data_tools = __esm({
85
+ "src/tools/data-tools.ts"() {
86
+ "use strict";
87
+ MAX_QUERY_LIMIT = 200;
88
+ DEFAULT_QUERY_LIMIT = 20;
89
+ QUERY_RECORDS_TOOL = {
90
+ name: "query_records",
91
+ description: "Query records from a data object with optional filters, field selection, sorting, and pagination. Returns an array of matching records.",
92
+ parameters: {
93
+ type: "object",
94
+ properties: {
95
+ objectName: {
96
+ type: "string",
97
+ description: "The snake_case name of the object to query"
98
+ },
99
+ where: {
100
+ type: "object",
101
+ description: 'Filter conditions as key-value pairs (e.g. { "status": "active" }) or MongoDB-style operators (e.g. { "amount": { "$gt": 100 } })'
102
+ },
103
+ fields: {
104
+ type: "array",
105
+ items: { type: "string" },
106
+ description: "List of field names to return (omit for all fields)"
107
+ },
108
+ orderBy: {
109
+ type: "array",
110
+ items: {
111
+ type: "object",
112
+ properties: {
113
+ field: { type: "string" },
114
+ order: { type: "string", enum: ["asc", "desc"] }
115
+ }
116
+ },
117
+ description: 'Sort order (e.g. [{ "field": "created_at", "order": "desc" }])'
118
+ },
119
+ limit: {
120
+ type: "number",
121
+ description: `Maximum number of records to return (default ${DEFAULT_QUERY_LIMIT}, max ${MAX_QUERY_LIMIT})`
122
+ },
123
+ offset: {
124
+ type: "number",
125
+ description: "Number of records to skip for pagination"
126
+ }
127
+ },
128
+ required: ["objectName"],
129
+ additionalProperties: false
130
+ }
18
131
  };
19
- }
20
- async complete(prompt, options) {
21
- return {
22
- content: `[memory] ${prompt}`,
23
- model: options?.model ?? "memory",
24
- usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 }
132
+ GET_RECORD_TOOL = {
133
+ name: "get_record",
134
+ description: "Get a single record by its ID from a data object.",
135
+ parameters: {
136
+ type: "object",
137
+ properties: {
138
+ objectName: {
139
+ type: "string",
140
+ description: "The snake_case name of the object"
141
+ },
142
+ recordId: {
143
+ type: "string",
144
+ description: "The unique ID of the record"
145
+ },
146
+ fields: {
147
+ type: "array",
148
+ items: { type: "string" },
149
+ description: "List of field names to return (omit for all fields)"
150
+ }
151
+ },
152
+ required: ["objectName", "recordId"],
153
+ additionalProperties: false
154
+ }
25
155
  };
26
- }
27
- async *streamChat(messages, _options) {
28
- const result = await this.chat(messages);
29
- const words = result.content.split(" ");
30
- for (let i = 0; i < words.length; i++) {
31
- const wordText = i === 0 ? words[i] : ` ${words[i]}`;
32
- yield { type: "text-delta", id: `delta_${i}`, text: wordText };
33
- }
34
- yield {
35
- type: "finish",
36
- finishReason: "stop",
37
- totalUsage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
38
- rawFinishReason: "stop"
156
+ AGGREGATE_DATA_TOOL = {
157
+ name: "aggregate_data",
158
+ description: "Perform aggregation/statistical operations on a data object. Supports count, sum, avg, min, max with optional groupBy and where filters.",
159
+ parameters: {
160
+ type: "object",
161
+ properties: {
162
+ objectName: {
163
+ type: "string",
164
+ description: "The snake_case name of the object to aggregate"
165
+ },
166
+ aggregations: {
167
+ type: "array",
168
+ items: {
169
+ type: "object",
170
+ properties: {
171
+ function: {
172
+ type: "string",
173
+ enum: ["count", "sum", "avg", "min", "max", "count_distinct"],
174
+ description: "Aggregation function"
175
+ },
176
+ field: {
177
+ type: "string",
178
+ description: "Field to aggregate (optional for count)"
179
+ },
180
+ alias: {
181
+ type: "string",
182
+ description: "Result column alias"
183
+ }
184
+ },
185
+ required: ["function", "alias"]
186
+ },
187
+ description: "Aggregation definitions"
188
+ },
189
+ groupBy: {
190
+ type: "array",
191
+ items: { type: "string" },
192
+ description: "Fields to group by"
193
+ },
194
+ where: {
195
+ type: "object",
196
+ description: "Filter conditions applied before aggregation"
197
+ }
198
+ },
199
+ required: ["objectName", "aggregations"],
200
+ additionalProperties: false
201
+ }
39
202
  };
203
+ DATA_TOOL_DEFINITIONS = [
204
+ QUERY_RECORDS_TOOL,
205
+ GET_RECORD_TOOL,
206
+ AGGREGATE_DATA_TOOL
207
+ ];
208
+ VALID_AGG_FUNCTIONS = /* @__PURE__ */ new Set([
209
+ "count",
210
+ "sum",
211
+ "avg",
212
+ "min",
213
+ "max",
214
+ "count_distinct"
215
+ ]);
40
216
  }
41
- async embed(input) {
42
- const texts = Array.isArray(input) ? input : [input];
43
- return texts.map(() => [0, 0, 0]);
44
- }
45
- async listModels() {
46
- return ["memory"];
47
- }
48
- };
217
+ });
49
218
 
50
- // src/tools/tool-registry.ts
51
- var ToolRegistry = class {
52
- constructor() {
53
- this.definitions = /* @__PURE__ */ new Map();
54
- this.handlers = /* @__PURE__ */ new Map();
55
- }
56
- /**
57
- * Register a tool with its definition and handler.
58
- * @param definition - Tool definition (name, description, parameters schema)
59
- * @param handler - Async function that executes the tool
60
- */
61
- register(definition, handler) {
62
- this.definitions.set(definition.name, definition);
63
- this.handlers.set(definition.name, handler);
219
+ // src/tools/create-object.tool.ts
220
+ import { defineTool } from "@objectstack/spec/ai";
221
+ var createObjectTool;
222
+ var init_create_object_tool = __esm({
223
+ "src/tools/create-object.tool.ts"() {
224
+ "use strict";
225
+ createObjectTool = defineTool({
226
+ name: "create_object",
227
+ label: "Create Object",
228
+ 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.",
229
+ category: "data",
230
+ builtIn: true,
231
+ // NOTE: requiresConfirmation is intentionally false (default) because the
232
+ // server-side tool-call loop in AIService.chatWithTools/streamChatWithTools
233
+ // executes tool calls immediately without checking this flag. The flag
234
+ // should only be set once server-side approval gating is implemented to
235
+ // avoid giving users a false sense of safety.
236
+ parameters: {
237
+ type: "object",
238
+ properties: {
239
+ name: {
240
+ type: "string",
241
+ description: "Machine name for the object (snake_case, e.g. project_task)"
242
+ },
243
+ label: {
244
+ type: "string",
245
+ description: "Human-readable display name (e.g. Project Task)"
246
+ },
247
+ fields: {
248
+ type: "array",
249
+ description: "Initial fields to create with the object",
250
+ items: {
251
+ type: "object",
252
+ properties: {
253
+ name: { type: "string", description: "Field machine name (snake_case)" },
254
+ label: { type: "string", description: "Field display name" },
255
+ type: {
256
+ type: "string",
257
+ description: "Field data type",
258
+ enum: ["text", "textarea", "number", "boolean", "date", "datetime", "select", "lookup", "formula", "autonumber"]
259
+ },
260
+ required: { type: "boolean", description: "Whether the field is required" }
261
+ },
262
+ required: ["name", "type"]
263
+ }
264
+ },
265
+ enableFeatures: {
266
+ type: "object",
267
+ description: "Object capability flags",
268
+ properties: {
269
+ trackHistory: { type: "boolean" },
270
+ apiEnabled: { type: "boolean" }
271
+ }
272
+ }
273
+ },
274
+ required: ["name", "label"],
275
+ additionalProperties: false
276
+ }
277
+ });
64
278
  }
65
- /**
66
- * Unregister a tool by name.
67
- */
68
- unregister(name) {
69
- this.definitions.delete(name);
70
- this.handlers.delete(name);
279
+ });
280
+
281
+ // src/tools/add-field.tool.ts
282
+ import { defineTool as defineTool2 } from "@objectstack/spec/ai";
283
+ var addFieldTool;
284
+ var init_add_field_tool = __esm({
285
+ "src/tools/add-field.tool.ts"() {
286
+ "use strict";
287
+ addFieldTool = defineTool2({
288
+ name: "add_field",
289
+ label: "Add Field",
290
+ 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.",
291
+ category: "data",
292
+ builtIn: true,
293
+ parameters: {
294
+ type: "object",
295
+ properties: {
296
+ objectName: {
297
+ type: "string",
298
+ description: "Target object machine name (snake_case)"
299
+ },
300
+ name: {
301
+ type: "string",
302
+ description: "Field machine name (snake_case, e.g. due_date)"
303
+ },
304
+ label: {
305
+ type: "string",
306
+ description: "Human-readable field label (e.g. Due Date)"
307
+ },
308
+ type: {
309
+ type: "string",
310
+ description: "Field data type",
311
+ enum: ["text", "textarea", "number", "boolean", "date", "datetime", "select", "lookup", "formula", "autonumber"]
312
+ },
313
+ required: {
314
+ type: "boolean",
315
+ description: "Whether the field is required"
316
+ },
317
+ defaultValue: {
318
+ description: "Default value for the field"
319
+ },
320
+ options: {
321
+ type: "array",
322
+ description: "Options for select/picklist fields",
323
+ items: {
324
+ type: "object",
325
+ properties: {
326
+ label: { type: "string" },
327
+ value: {
328
+ type: "string",
329
+ description: "Option machine identifier (lowercase snake_case, e.g. high_priority)",
330
+ pattern: "^[a-z_][a-z0-9_]*$"
331
+ }
332
+ }
333
+ }
334
+ },
335
+ reference: {
336
+ type: "string",
337
+ description: "Referenced object name for lookup fields (snake_case, e.g. account)"
338
+ }
339
+ },
340
+ required: ["objectName", "name", "type"],
341
+ additionalProperties: false
342
+ }
343
+ });
71
344
  }
72
- /**
73
- * Check whether a tool is registered.
74
- */
75
- has(name) {
76
- return this.definitions.has(name);
345
+ });
346
+
347
+ // src/tools/modify-field.tool.ts
348
+ import { defineTool as defineTool3 } from "@objectstack/spec/ai";
349
+ var modifyFieldTool;
350
+ var init_modify_field_tool = __esm({
351
+ "src/tools/modify-field.tool.ts"() {
352
+ "use strict";
353
+ modifyFieldTool = defineTool3({
354
+ name: "modify_field",
355
+ label: "Modify Field",
356
+ 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).",
357
+ category: "data",
358
+ builtIn: true,
359
+ parameters: {
360
+ type: "object",
361
+ properties: {
362
+ objectName: {
363
+ type: "string",
364
+ description: "Target object machine name (snake_case)"
365
+ },
366
+ fieldName: {
367
+ type: "string",
368
+ description: "Existing field machine name to modify (snake_case)"
369
+ },
370
+ changes: {
371
+ type: "object",
372
+ description: "Field properties to update (partial patch)",
373
+ properties: {
374
+ label: { type: "string", description: "New display label" },
375
+ type: { type: "string", description: "New field type" },
376
+ required: { type: "boolean", description: "Update required constraint" },
377
+ defaultValue: { description: "New default value" }
378
+ }
379
+ }
380
+ },
381
+ required: ["objectName", "fieldName", "changes"],
382
+ additionalProperties: false
383
+ }
384
+ });
77
385
  }
78
- /**
79
- * Get the definition for a registered tool.
80
- */
81
- getDefinition(name) {
82
- return this.definitions.get(name);
386
+ });
387
+
388
+ // src/tools/delete-field.tool.ts
389
+ import { defineTool as defineTool4 } from "@objectstack/spec/ai";
390
+ var deleteFieldTool;
391
+ var init_delete_field_tool = __esm({
392
+ "src/tools/delete-field.tool.ts"() {
393
+ "use strict";
394
+ deleteFieldTool = defineTool4({
395
+ name: "delete_field",
396
+ label: "Delete Field",
397
+ 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.",
398
+ category: "data",
399
+ builtIn: true,
400
+ // NOTE: requiresConfirmation is intentionally false (default) because the
401
+ // server-side tool-call loop in AIService.chatWithTools/streamChatWithTools
402
+ // executes tool calls immediately without checking this flag. The flag
403
+ // should only be set once server-side approval gating is implemented.
404
+ parameters: {
405
+ type: "object",
406
+ properties: {
407
+ objectName: {
408
+ type: "string",
409
+ description: "Target object machine name (snake_case)"
410
+ },
411
+ fieldName: {
412
+ type: "string",
413
+ description: "Field machine name to delete (snake_case)"
414
+ }
415
+ },
416
+ required: ["objectName", "fieldName"],
417
+ additionalProperties: false
418
+ }
419
+ });
83
420
  }
84
- /**
85
- * Return all registered tool definitions.
86
- */
87
- getAll() {
88
- return Array.from(this.definitions.values());
421
+ });
422
+
423
+ // src/tools/list-objects.tool.ts
424
+ import { defineTool as defineTool5 } from "@objectstack/spec/ai";
425
+ var listObjectsTool;
426
+ var init_list_objects_tool = __esm({
427
+ "src/tools/list-objects.tool.ts"() {
428
+ "use strict";
429
+ listObjectsTool = defineTool5({
430
+ name: "list_objects",
431
+ label: "List Objects",
432
+ description: "Lists all registered data objects (tables) in the current environment. Use this when the user wants to see what tables, entities, or data models are available.",
433
+ category: "data",
434
+ builtIn: true,
435
+ parameters: {
436
+ type: "object",
437
+ properties: {
438
+ filter: {
439
+ type: "string",
440
+ description: "Optional name or label substring to filter objects"
441
+ },
442
+ includeFields: {
443
+ type: "boolean",
444
+ description: "Whether to include field summaries for each object (default: false)"
445
+ }
446
+ },
447
+ additionalProperties: false
448
+ }
449
+ });
89
450
  }
90
- /** Number of registered tools. */
91
- get size() {
92
- return this.definitions.size;
451
+ });
452
+
453
+ // src/tools/describe-object.tool.ts
454
+ import { defineTool as defineTool6 } from "@objectstack/spec/ai";
455
+ var describeObjectTool;
456
+ var init_describe_object_tool = __esm({
457
+ "src/tools/describe-object.tool.ts"() {
458
+ "use strict";
459
+ describeObjectTool = defineTool6({
460
+ name: "describe_object",
461
+ label: "Describe Object",
462
+ description: "Returns the full schema details of a data object, including all fields, types, relationships, and configuration. Use this to understand the structure of a table before querying or modifying it.",
463
+ category: "data",
464
+ builtIn: true,
465
+ parameters: {
466
+ type: "object",
467
+ properties: {
468
+ objectName: {
469
+ type: "string",
470
+ description: "Object machine name to describe (snake_case)"
471
+ }
472
+ },
473
+ required: ["objectName"],
474
+ additionalProperties: false
475
+ }
476
+ });
93
477
  }
94
- /** All registered tool names. */
95
- names() {
96
- return Array.from(this.definitions.keys());
97
- }
98
- /**
99
- * Execute a tool call and return the result.
100
- */
101
- async execute(toolCall) {
102
- const handler = this.handlers.get(toolCall.toolName);
103
- if (!handler) {
104
- return {
105
- type: "tool-result",
106
- toolCallId: toolCall.toolCallId,
107
- toolName: toolCall.toolName,
108
- output: { type: "text", value: `Tool "${toolCall.toolName}" is not registered` },
109
- isError: true
110
- };
478
+ });
479
+
480
+ // src/tools/metadata-tools.ts
481
+ var metadata_tools_exports = {};
482
+ __export(metadata_tools_exports, {
483
+ METADATA_TOOL_DEFINITIONS: () => METADATA_TOOL_DEFINITIONS,
484
+ addFieldTool: () => addFieldTool,
485
+ createObjectTool: () => createObjectTool,
486
+ deleteFieldTool: () => deleteFieldTool,
487
+ describeObjectTool: () => describeObjectTool,
488
+ listObjectsTool: () => listObjectsTool,
489
+ modifyFieldTool: () => modifyFieldTool,
490
+ registerMetadataTools: () => registerMetadataTools
491
+ });
492
+ function isSnakeCase(value) {
493
+ return SNAKE_CASE_RE.test(value);
494
+ }
495
+ function createCreateObjectHandler(ctx) {
496
+ return async (args) => {
497
+ const { name, label, fields, enableFeatures } = args;
498
+ if (!name || !label) {
499
+ return JSON.stringify({ error: 'Both "name" and "label" are required' });
111
500
  }
112
- try {
113
- const args = typeof toolCall.input === "string" ? JSON.parse(toolCall.input) : toolCall.input ?? {};
114
- const content = await handler(args);
115
- return {
116
- type: "tool-result",
117
- toolCallId: toolCall.toolCallId,
118
- toolName: toolCall.toolName,
119
- output: { type: "text", value: content }
120
- };
121
- } catch (err) {
122
- const message = err instanceof Error ? err.message : String(err);
123
- return {
124
- type: "tool-result",
125
- toolCallId: toolCall.toolCallId,
126
- toolName: toolCall.toolName,
127
- output: { type: "text", value: message },
128
- isError: true
129
- };
501
+ if (!isSnakeCase(name)) {
502
+ return JSON.stringify({ error: `Invalid object name "${name}". Must be snake_case.` });
130
503
  }
131
- }
132
- /**
133
- * Execute multiple tool calls in parallel.
134
- */
135
- async executeAll(toolCalls) {
136
- return Promise.all(toolCalls.map((tc) => this.execute(tc)));
137
- }
138
- /**
139
- * Clear all registered tools.
140
- */
141
- clear() {
142
- this.definitions.clear();
143
- this.handlers.clear();
144
- }
145
- };
146
-
147
- // src/conversation/in-memory-conversation-service.ts
148
- var InMemoryConversationService = class {
149
- constructor() {
150
- this.store = /* @__PURE__ */ new Map();
151
- this.counter = 0;
152
- }
153
- async create(options = {}) {
154
- const now = (/* @__PURE__ */ new Date()).toISOString();
155
- const id = `conv_${++this.counter}`;
156
- const conversation = {
157
- id,
158
- title: options.title,
159
- agentId: options.agentId,
160
- userId: options.userId,
161
- messages: [],
162
- createdAt: now,
163
- updatedAt: now,
164
- metadata: options.metadata
504
+ const existing = await ctx.metadataService.getObject(name);
505
+ if (existing) {
506
+ return JSON.stringify({ error: `Object "${name}" already exists` });
507
+ }
508
+ const fieldMap = {};
509
+ if (fields && Array.isArray(fields)) {
510
+ const seenNames = /* @__PURE__ */ new Set();
511
+ for (const f of fields) {
512
+ if (!f.name) {
513
+ return JSON.stringify({ error: 'Each field must have a "name" property' });
514
+ }
515
+ if (!isSnakeCase(f.name)) {
516
+ return JSON.stringify({ error: `Invalid field name "${f.name}". Must be snake_case.` });
517
+ }
518
+ if (seenNames.has(f.name)) {
519
+ return JSON.stringify({ error: `Duplicate field name "${f.name}" in initial fields` });
520
+ }
521
+ seenNames.add(f.name);
522
+ fieldMap[f.name] = {
523
+ type: f.type,
524
+ ...f.label ? { label: f.label } : {},
525
+ ...f.required !== void 0 ? { required: f.required } : {}
526
+ };
527
+ }
528
+ }
529
+ const objectDef = {
530
+ name,
531
+ label,
532
+ ...Object.keys(fieldMap).length > 0 ? { fields: fieldMap } : {},
533
+ ...enableFeatures ? { enable: enableFeatures } : {}
165
534
  };
166
- this.store.set(id, conversation);
167
- return conversation;
168
- }
169
- async get(conversationId) {
170
- return this.store.get(conversationId) ?? null;
171
- }
172
- async list(options = {}) {
173
- let results = Array.from(this.store.values());
174
- if (options.userId) {
175
- results = results.filter((c) => c.userId === options.userId);
535
+ await ctx.metadataService.register("object", name, objectDef);
536
+ return JSON.stringify({
537
+ name,
538
+ label,
539
+ fieldCount: Object.keys(fieldMap).length
540
+ });
541
+ };
542
+ }
543
+ function createAddFieldHandler(ctx) {
544
+ return async (args) => {
545
+ const { objectName, name, label, type, required, defaultValue, options, reference } = args;
546
+ if (!objectName || !name || !type) {
547
+ return JSON.stringify({ error: '"objectName", "name", and "type" are required' });
176
548
  }
177
- if (options.agentId) {
178
- results = results.filter((c) => c.agentId === options.agentId);
549
+ if (!isSnakeCase(objectName)) {
550
+ return JSON.stringify({ error: `Invalid object name "${objectName}". Must be snake_case.` });
179
551
  }
180
- if (options.cursor) {
181
- const idx = results.findIndex((c) => c.id === options.cursor);
182
- if (idx >= 0) {
183
- results = results.slice(idx + 1);
552
+ if (!isSnakeCase(name)) {
553
+ return JSON.stringify({ error: `Invalid field name "${name}". Must be snake_case.` });
554
+ }
555
+ if (reference && !isSnakeCase(reference)) {
556
+ return JSON.stringify({ error: `Invalid reference "${reference}". Must be a snake_case object name.` });
557
+ }
558
+ if (options && Array.isArray(options)) {
559
+ for (const opt of options) {
560
+ if (opt.value && !isSnakeCase(opt.value)) {
561
+ return JSON.stringify({ error: `Invalid option value "${opt.value}". Must be lowercase snake_case.` });
562
+ }
184
563
  }
185
564
  }
186
- if (options.limit && options.limit > 0) {
187
- results = results.slice(0, options.limit);
565
+ const objectDef = await ctx.metadataService.getObject(objectName);
566
+ if (!objectDef) {
567
+ return JSON.stringify({ error: `Object "${objectName}" not found` });
188
568
  }
189
- return results;
190
- }
191
- async addMessage(conversationId, message) {
192
- const conversation = this.store.get(conversationId);
193
- if (!conversation) {
194
- throw new Error(`Conversation "${conversationId}" not found`);
569
+ const def = objectDef;
570
+ if (def.fields && def.fields[name]) {
571
+ return JSON.stringify({ error: `Field "${name}" already exists on object "${objectName}"` });
195
572
  }
196
- conversation.messages.push(message);
197
- conversation.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
198
- return conversation;
199
- }
200
- async delete(conversationId) {
201
- this.store.delete(conversationId);
202
- }
203
- /** Total number of stored conversations. */
204
- get size() {
205
- return this.store.size;
206
- }
207
- /** Clear all conversations. */
208
- clear() {
209
- this.store.clear();
210
- this.counter = 0;
211
- }
212
- };
213
-
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"
573
+ const fieldDef = {
574
+ type,
575
+ ...label ? { label } : {},
576
+ ...required !== void 0 ? { required } : {},
577
+ ...defaultValue !== void 0 ? { defaultValue } : {},
578
+ ...options ? { options } : {},
579
+ ...reference ? { reference } : {}
580
+ };
581
+ const updatedFields = { ...def.fields ?? {}, [name]: fieldDef };
582
+ await ctx.metadataService.register("object", objectName, {
583
+ ...def,
584
+ fields: updatedFields
585
+ });
586
+ return JSON.stringify({
587
+ objectName,
588
+ fieldName: name,
589
+ fieldType: type
590
+ });
224
591
  };
225
592
  }
226
- var _AIService = class _AIService {
227
- constructor(config = {}) {
228
- this.adapter = config.adapter ?? new MemoryLLMAdapter();
229
- this.logger = config.logger ?? createLogger({ level: "info", format: "pretty" });
230
- this.toolRegistry = config.toolRegistry ?? new ToolRegistry();
231
- this.conversationService = config.conversationService ?? new InMemoryConversationService();
232
- this.logger.info(
233
- `[AI] Service initialized with adapter="${this.adapter.name}", tools=${this.toolRegistry.size}`
234
- );
235
- }
236
- /** The name of the active LLM adapter. */
237
- get adapterName() {
238
- return this.adapter.name;
239
- }
240
- // ── IAIService implementation ──────────────────────────────────
241
- async chat(messages, options) {
242
- this.logger.debug("[AI] chat", { messageCount: messages.length, model: options?.model });
243
- return this.adapter.chat(messages, options);
244
- }
245
- async complete(prompt, options) {
246
- this.logger.debug("[AI] complete", { promptLength: prompt.length, model: options?.model });
247
- return this.adapter.complete(prompt, options);
248
- }
249
- async *streamChat(messages, options) {
250
- this.logger.debug("[AI] streamChat", { messageCount: messages.length, model: options?.model });
251
- if (!this.adapter.streamChat) {
252
- const result = await this.adapter.chat(messages, options);
253
- yield textDeltaPart("fallback", result.content);
254
- yield finishPart(result);
255
- return;
593
+ function createModifyFieldHandler(ctx) {
594
+ return async (args) => {
595
+ const { objectName, fieldName, changes } = args;
596
+ if (!objectName || !fieldName || !changes) {
597
+ return JSON.stringify({ error: '"objectName", "fieldName", and "changes" are required' });
256
598
  }
257
- yield* this.adapter.streamChat(messages, options);
258
- }
259
- async embed(input, model) {
260
- if (!this.adapter.embed) {
261
- throw new Error(`[AI] Adapter "${this.adapter.name}" does not support embeddings`);
599
+ if (!isSnakeCase(objectName)) {
600
+ return JSON.stringify({ error: `Invalid object name "${objectName}". Must be snake_case.` });
262
601
  }
263
- return this.adapter.embed(input, model);
264
- }
265
- async listModels() {
266
- if (!this.adapter.listModels) {
267
- return [];
602
+ if (!isSnakeCase(fieldName)) {
603
+ return JSON.stringify({ error: `Invalid field name "${fieldName}". Must be snake_case.` });
268
604
  }
269
- return this.adapter.listModels();
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
- }
275
- /**
276
- * Chat with automatic tool call resolution.
277
- *
278
- * 1. Merges registered tool definitions into `options.tools`.
279
- * 2. Calls the LLM adapter.
280
- * 3. If the response contains `toolCalls`, executes them via the
281
- * {@link ToolRegistry}, appends tool results as `role: 'tool'`
282
- * messages, and loops back to step 2.
283
- * 4. Repeats until the model produces a final text response or the
284
- * maximum number of iterations (`maxIterations`) is reached.
285
- */
286
- async chatWithTools(messages, options) {
287
- const { maxIterations: maxIter, onToolError, ...restOptions } = options ?? {};
288
- const maxIterations = maxIter ?? _AIService.DEFAULT_MAX_ITERATIONS;
289
- const registeredTools = this.toolRegistry.getAll();
290
- const mergedTools = [
291
- ...registeredTools,
292
- ...restOptions.tools ?? []
293
- ];
294
- const chatOptions = {
295
- ...restOptions,
296
- tools: mergedTools.length > 0 ? mergedTools : void 0,
297
- toolChoice: mergedTools.length > 0 ? restOptions.toolChoice ?? "auto" : void 0
298
- };
299
- const conversation = [...messages];
300
- const toolErrors = [];
301
- this.logger.debug("[AI] chatWithTools start", {
302
- messageCount: conversation.length,
303
- toolCount: mergedTools.length,
304
- maxIterations
605
+ const objectDef = await ctx.metadataService.getObject(objectName);
606
+ if (!objectDef) {
607
+ return JSON.stringify({ error: `Object "${objectName}" not found` });
608
+ }
609
+ const def = objectDef;
610
+ if (!def.fields || !def.fields[fieldName]) {
611
+ return JSON.stringify({ error: `Field "${fieldName}" not found on object "${objectName}"` });
612
+ }
613
+ const existingField = def.fields[fieldName];
614
+ const updatedField = { ...existingField, ...changes };
615
+ const updatedFields = { ...def.fields, [fieldName]: updatedField };
616
+ await ctx.metadataService.register("object", objectName, {
617
+ ...def,
618
+ fields: updatedFields
305
619
  });
306
- let abortedByCallback = false;
307
- for (let iteration = 0; iteration < maxIterations; iteration++) {
308
- const result = await this.adapter.chat(conversation, chatOptions);
309
- if (!result.toolCalls || result.toolCalls.length === 0) {
310
- this.logger.debug("[AI] chatWithTools finished", { iteration, content: result.content.slice(0, 80) });
311
- return result;
312
- }
313
- this.logger.debug("[AI] chatWithTools tool calls", {
314
- iteration,
315
- calls: result.toolCalls.map((tc) => tc.toolName)
316
- });
317
- const assistantContent = [];
318
- if (result.content) assistantContent.push({ type: "text", text: result.content });
319
- assistantContent.push(...result.toolCalls);
320
- conversation.push({
321
- role: "assistant",
322
- content: assistantContent
323
- });
324
- const toolResults = await this.toolRegistry.executeAll(result.toolCalls);
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
- }
340
- conversation.push({
341
- role: "tool",
342
- content: [tr]
343
- });
344
- }
345
- if (abortedByCallback) {
346
- break;
347
- }
620
+ return JSON.stringify({
621
+ objectName,
622
+ fieldName,
623
+ updatedProperties: Object.keys(changes)
624
+ });
625
+ };
626
+ }
627
+ function createDeleteFieldHandler(ctx) {
628
+ return async (args) => {
629
+ const { objectName, fieldName } = args;
630
+ if (!objectName || !fieldName) {
631
+ return JSON.stringify({ error: '"objectName" and "fieldName" are required' });
348
632
  }
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
- });
633
+ if (!isSnakeCase(objectName)) {
634
+ return JSON.stringify({ error: `Invalid object name "${objectName}". Must be snake_case.` });
355
635
  }
356
- const finalResult = await this.adapter.chat(conversation, {
357
- ...chatOptions,
358
- tools: void 0,
359
- toolChoice: void 0
636
+ if (!isSnakeCase(fieldName)) {
637
+ return JSON.stringify({ error: `Invalid field name "${fieldName}". Must be snake_case.` });
638
+ }
639
+ const objectDef = await ctx.metadataService.getObject(objectName);
640
+ if (!objectDef) {
641
+ return JSON.stringify({ error: `Object "${objectName}" not found` });
642
+ }
643
+ const def = objectDef;
644
+ if (!def.fields || !def.fields[fieldName]) {
645
+ return JSON.stringify({ error: `Field "${fieldName}" not found on object "${objectName}"` });
646
+ }
647
+ const { [fieldName]: _removed, ...remainingFields } = def.fields;
648
+ await ctx.metadataService.register("object", objectName, {
649
+ ...def,
650
+ fields: remainingFields
360
651
  });
361
- return finalResult;
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;
652
+ return JSON.stringify({
653
+ objectName,
654
+ fieldName,
655
+ success: true
656
+ });
657
+ };
658
+ }
659
+ function createListObjectsHandler(ctx) {
660
+ return async (args) => {
661
+ const { filter, includeFields } = args ?? {};
662
+ const objects = await ctx.metadataService.listObjects();
663
+ let result = objects.map((o) => {
664
+ const base = {
665
+ name: o.name,
666
+ label: o.label ?? o.name,
667
+ fieldCount: o.fields ? Object.keys(o.fields).length : 0
668
+ };
669
+ if (includeFields && o.fields) {
670
+ base.fields = Object.entries(o.fields).map(([key, f]) => ({
671
+ name: key,
672
+ type: f.type,
673
+ label: f.label ?? key
674
+ }));
421
675
  }
676
+ return base;
677
+ });
678
+ if (filter) {
679
+ const lower = filter.toLowerCase();
680
+ result = result.filter(
681
+ (o) => o.name.toLowerCase().includes(lower) || o.label.toLowerCase().includes(lower)
682
+ );
422
683
  }
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
- }
433
- };
434
- // ── Tool Call Loop ────────────────────────────────────────────
435
- /** Default maximum iterations for the tool call loop. */
436
- _AIService.DEFAULT_MAX_ITERATIONS = 10;
437
- var AIService = _AIService;
438
-
439
- // src/stream/vercel-stream-encoder.ts
440
- function sse(data) {
441
- return `data: ${JSON.stringify(data)}
442
-
443
- `;
684
+ return JSON.stringify({
685
+ objects: result,
686
+ totalCount: result.length
687
+ });
688
+ };
444
689
  }
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";
690
+ function createDescribeObjectHandler(ctx) {
691
+ return async (args) => {
692
+ const { objectName } = args;
693
+ if (!objectName) {
694
+ return JSON.stringify({ error: '"objectName" is required' });
493
695
  }
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;
696
+ if (!isSnakeCase(objectName)) {
697
+ return JSON.stringify({ error: `Invalid object name "${objectName}". Must be snake_case.` });
500
698
  }
501
- const frame = encodeStreamPart(part);
502
- if (frame) {
503
- yield frame;
699
+ const objectDef = await ctx.metadataService.getObject(objectName);
700
+ if (!objectDef) {
701
+ return JSON.stringify({ error: `Object "${objectName}" not found` });
504
702
  }
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";
703
+ const def = objectDef;
704
+ const fields = def.fields ?? {};
705
+ const fieldSummary = Object.entries(fields).map(([key, f]) => ({
706
+ name: key,
707
+ type: f.type,
708
+ label: f.label ?? key,
709
+ required: f.required ?? false,
710
+ ...f.reference ? { reference: f.reference } : {},
711
+ ...f.options ? { options: f.options } : {}
712
+ }));
713
+ return JSON.stringify({
714
+ name: def.name,
715
+ label: def.label ?? def.name,
716
+ fields: fieldSummary,
717
+ enableFeatures: def.enable ?? {}
718
+ });
719
+ };
720
+ }
721
+ function registerMetadataTools(registry, context) {
722
+ registry.register(createObjectTool, createCreateObjectHandler(context));
723
+ registry.register(addFieldTool, createAddFieldHandler(context));
724
+ registry.register(modifyFieldTool, createModifyFieldHandler(context));
725
+ registry.register(deleteFieldTool, createDeleteFieldHandler(context));
726
+ registry.register(listObjectsTool, createListObjectsHandler(context));
727
+ registry.register(describeObjectTool, createDescribeObjectHandler(context));
512
728
  }
729
+ var METADATA_TOOL_DEFINITIONS, SNAKE_CASE_RE;
730
+ var init_metadata_tools = __esm({
731
+ "src/tools/metadata-tools.ts"() {
732
+ "use strict";
733
+ init_create_object_tool();
734
+ init_add_field_tool();
735
+ init_modify_field_tool();
736
+ init_delete_field_tool();
737
+ init_list_objects_tool();
738
+ init_describe_object_tool();
739
+ init_create_object_tool();
740
+ init_add_field_tool();
741
+ init_modify_field_tool();
742
+ init_delete_field_tool();
743
+ init_list_objects_tool();
744
+ init_describe_object_tool();
745
+ METADATA_TOOL_DEFINITIONS = [
746
+ createObjectTool,
747
+ addFieldTool,
748
+ modifyFieldTool,
749
+ deleteFieldTool,
750
+ listObjectsTool,
751
+ describeObjectTool
752
+ ];
753
+ SNAKE_CASE_RE = /^[a-z_][a-z0-9_]*$/;
754
+ }
755
+ });
513
756
 
514
- // src/routes/ai-routes.ts
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 };
757
+ // src/ai-service.ts
758
+ import { createLogger } from "@objectstack/core";
759
+
760
+ // src/adapters/memory-adapter.ts
761
+ var MemoryLLMAdapter = class {
762
+ constructor() {
763
+ this.name = "memory";
520
764
  }
521
- if (Array.isArray(raw.content)) {
522
- return { role, content: raw.content };
765
+ async chat(messages, options) {
766
+ const lastUserMessage = [...messages].reverse().find((m) => m.role === "user");
767
+ const userContent = lastUserMessage?.content;
768
+ const text = typeof userContent === "string" ? userContent : "(complex content)";
769
+ const content = lastUserMessage ? `[memory] ${text}` : "[memory] (no user message)";
770
+ return {
771
+ content,
772
+ model: options?.model ?? "memory",
773
+ usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 }
774
+ };
523
775
  }
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("") };
776
+ async complete(prompt, options) {
777
+ return {
778
+ content: `[memory] ${prompt}`,
779
+ model: options?.model ?? "memory",
780
+ usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 }
781
+ };
782
+ }
783
+ async *streamChat(messages, _options) {
784
+ const result = await this.chat(messages);
785
+ const words = result.content.split(" ");
786
+ for (let i = 0; i < words.length; i++) {
787
+ const wordText = i === 0 ? words[i] : ` ${words[i]}`;
788
+ yield { type: "text-delta", id: `delta_${i}`, text: wordText };
528
789
  }
790
+ yield {
791
+ type: "finish",
792
+ finishReason: "stop",
793
+ totalUsage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
794
+ rawFinishReason: "stop"
795
+ };
529
796
  }
530
- return { role, content: "" };
531
- }
532
- function validateMessage(raw) {
533
- if (typeof raw !== "object" || raw === null) {
534
- return "each message must be an object";
797
+ async embed(input) {
798
+ const texts = Array.isArray(input) ? input : [input];
799
+ return texts.map(() => [0, 0, 0]);
535
800
  }
536
- const msg = raw;
537
- if (typeof msg.role !== "string" || !VALID_ROLES.has(msg.role)) {
538
- return `message.role must be one of ${[...VALID_ROLES].map((r) => `"${r}"`).join(", ")}`;
801
+ async listModels() {
802
+ return ["memory"];
539
803
  }
540
- const content = msg.content;
541
- if (Array.isArray(msg.parts)) {
542
- return null;
804
+ };
805
+
806
+ // src/tools/tool-registry.ts
807
+ var ToolRegistry = class {
808
+ constructor() {
809
+ this.definitions = /* @__PURE__ */ new Map();
810
+ this.handlers = /* @__PURE__ */ new Map();
543
811
  }
544
- if (typeof content === "string") {
545
- return null;
812
+ /**
813
+ * Register a tool with its definition and handler.
814
+ * @param definition - Tool definition (name, description, parameters schema)
815
+ * @param handler - Async function that executes the tool
816
+ */
817
+ register(definition, handler) {
818
+ this.definitions.set(definition.name, definition);
819
+ this.handlers.set(definition.name, handler);
546
820
  }
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;
821
+ /**
822
+ * Unregister a tool by name.
823
+ */
824
+ unregister(name) {
825
+ this.definitions.delete(name);
826
+ this.handlers.delete(name);
561
827
  }
562
- if (content === null || content === void 0) {
563
- if (msg.role === "assistant" || msg.role === "tool") {
564
- return null;
565
- }
828
+ /**
829
+ * Check whether a tool is registered.
830
+ */
831
+ has(name) {
832
+ return this.definitions.has(name);
566
833
  }
567
- return "message.content must be a string, an array, or include parts";
568
- }
569
- function buildAIRoutes(aiService, conversationService, logger) {
570
- return [
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
- //
581
- {
582
- method: "POST",
583
- path: "/api/v1/ai/chat",
584
- description: "Chat completion (supports Vercel AI Data Stream Protocol)",
585
- auth: true,
586
- permissions: ["ai:chat"],
587
- handler: async (req) => {
588
- const body = req.body ?? {};
589
- const messages = body.messages;
590
- if (!Array.isArray(messages) || messages.length === 0) {
591
- return { status: 400, body: { error: "messages array is required" } };
592
- }
593
- for (const msg of messages) {
594
- const err = validateMessage(msg);
595
- if (err) return { status: 400, body: { error: err } };
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
- }
638
- try {
639
- const result = await aiService.chatWithTools(finalMessages, resolvedOptions);
640
- return { status: 200, body: result };
641
- } catch (err) {
642
- logger.error("[AI Route] /chat error", err instanceof Error ? err : void 0);
643
- return { status: 500, body: { error: "Internal AI service error" } };
644
- }
645
- }
646
- },
647
- // ── Stream Chat (SSE) ──────────────────────────────────────
648
- {
649
- method: "POST",
650
- path: "/api/v1/ai/chat/stream",
651
- description: "SSE streaming chat completion",
652
- auth: true,
653
- permissions: ["ai:chat"],
654
- handler: async (req) => {
655
- const { messages, options } = req.body ?? {};
656
- if (!Array.isArray(messages) || messages.length === 0) {
657
- return { status: 400, body: { error: "messages array is required" } };
658
- }
659
- for (const msg of messages) {
660
- const err = validateMessage(msg);
661
- if (err) return { status: 400, body: { error: err } };
662
- }
663
- try {
664
- if (!aiService.streamChat) {
665
- return { status: 501, body: { error: "Streaming is not supported by the configured AI service" } };
666
- }
667
- const events = aiService.streamChat(messages.map((m) => normalizeMessage(m)), options);
668
- return { status: 200, stream: true, events };
669
- } catch (err) {
670
- logger.error("[AI Route] /chat/stream error", err instanceof Error ? err : void 0);
671
- return { status: 500, body: { error: "Internal AI service error" } };
672
- }
673
- }
674
- },
675
- // ── Complete ────────────────────────────────────────────────
676
- {
677
- method: "POST",
678
- path: "/api/v1/ai/complete",
679
- description: "Text completion",
680
- auth: true,
681
- permissions: ["ai:complete"],
682
- handler: async (req) => {
683
- const { prompt, options } = req.body ?? {};
684
- if (!prompt || typeof prompt !== "string") {
685
- return { status: 400, body: { error: "prompt string is required" } };
686
- }
687
- try {
688
- const result = await aiService.complete(prompt, options);
689
- return { status: 200, body: result };
690
- } catch (err) {
691
- logger.error("[AI Route] /complete error", err instanceof Error ? err : void 0);
692
- return { status: 500, body: { error: "Internal AI service error" } };
693
- }
694
- }
695
- },
696
- // ── Models ──────────────────────────────────────────────────
697
- {
698
- method: "GET",
699
- path: "/api/v1/ai/models",
700
- description: "List available models",
701
- auth: true,
702
- permissions: ["ai:read"],
703
- handler: async () => {
704
- try {
705
- const models = aiService.listModels ? await aiService.listModels() : [];
706
- return { status: 200, body: { models } };
707
- } catch (err) {
708
- logger.error("[AI Route] /models error", err instanceof Error ? err : void 0);
709
- return { status: 500, body: { error: "Internal AI service error" } };
710
- }
711
- }
712
- },
713
- // ── Conversations ──────────────────────────────────────────
714
- {
715
- method: "POST",
716
- path: "/api/v1/ai/conversations",
717
- description: "Create a conversation",
718
- auth: true,
719
- permissions: ["ai:conversations"],
720
- handler: async (req) => {
721
- try {
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
- }
729
- const conversation = await conversationService.create(options);
730
- return { status: 201, body: conversation };
731
- } catch (err) {
732
- logger.error("[AI Route] POST /conversations error", err instanceof Error ? err : void 0);
733
- return { status: 500, body: { error: "Internal AI service error" } };
734
- }
735
- }
736
- },
737
- {
738
- method: "GET",
739
- path: "/api/v1/ai/conversations",
740
- description: "List conversations",
741
- auth: true,
742
- permissions: ["ai:conversations"],
743
- handler: async (req) => {
744
- try {
745
- const rawQuery = req.query ?? {};
746
- const options = { ...rawQuery };
747
- if (typeof rawQuery.limit === "string") {
748
- const parsedLimit = Number(rawQuery.limit);
749
- if (!Number.isFinite(parsedLimit) || parsedLimit <= 0 || !Number.isInteger(parsedLimit)) {
750
- return { status: 400, body: { error: "Invalid limit parameter" } };
751
- }
752
- options.limit = parsedLimit;
753
- }
754
- if (req.user?.userId) {
755
- options.userId = req.user.userId;
756
- }
757
- const conversations = await conversationService.list(options);
758
- return { status: 200, body: { conversations } };
759
- } catch (err) {
760
- logger.error("[AI Route] GET /conversations error", err instanceof Error ? err : void 0);
761
- return { status: 500, body: { error: "Internal AI service error" } };
762
- }
763
- }
764
- },
765
- {
766
- method: "POST",
767
- path: "/api/v1/ai/conversations/:id/messages",
768
- description: "Add message to a conversation",
769
- auth: true,
770
- permissions: ["ai:conversations"],
771
- handler: async (req) => {
772
- const id = req.params?.id;
773
- if (!id) {
774
- return { status: 400, body: { error: "conversation id is required" } };
775
- }
776
- const message = req.body;
777
- const validationError = validateMessage(message);
778
- if (validationError) {
779
- return { status: 400, body: { error: validationError } };
780
- }
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
- }
791
- const conversation = await conversationService.addMessage(id, message);
792
- return { status: 200, body: conversation };
793
- } catch (err) {
794
- const msg = err instanceof Error ? err.message : String(err);
795
- if (msg.includes("not found")) {
796
- return { status: 404, body: { error: msg } };
797
- }
798
- logger.error("[AI Route] POST /conversations/:id/messages error", err instanceof Error ? err : void 0);
799
- return { status: 500, body: { error: "Internal AI service error" } };
800
- }
801
- }
802
- },
803
- {
804
- method: "DELETE",
805
- path: "/api/v1/ai/conversations/:id",
806
- description: "Delete a conversation",
807
- auth: true,
808
- permissions: ["ai:conversations"],
809
- handler: async (req) => {
810
- const id = req.params?.id;
811
- if (!id) {
812
- return { status: 400, body: { error: "conversation id is required" } };
813
- }
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
- }
824
- await conversationService.delete(id);
825
- return { status: 204 };
826
- } catch (err) {
827
- logger.error("[AI Route] DELETE /conversations/:id error", err instanceof Error ? err : void 0);
828
- return { status: 500, body: { error: "Internal AI service error" } };
829
- }
830
- }
831
- }
832
- ];
833
- }
834
-
835
- // src/routes/agent-routes.ts
836
- var ALLOWED_AGENT_ROLES = /* @__PURE__ */ new Set(["user", "assistant"]);
837
- function validateAgentMessage(raw) {
838
- if (typeof raw !== "object" || raw === null) {
839
- return "each message must be an object";
834
+ /**
835
+ * Get the definition for a registered tool.
836
+ */
837
+ getDefinition(name) {
838
+ return this.definitions.get(name);
840
839
  }
841
- const msg = raw;
842
- if (typeof msg.role !== "string" || !ALLOWED_AGENT_ROLES.has(msg.role)) {
843
- return `message.role must be one of ${[...ALLOWED_AGENT_ROLES].map((r) => `"${r}"`).join(", ")} for agent chat`;
840
+ /**
841
+ * Return all registered tool definitions.
842
+ */
843
+ getAll() {
844
+ return Array.from(this.definitions.values());
844
845
  }
845
- if (typeof msg.content !== "string") {
846
- return "message.content must be a string";
846
+ /** Number of registered tools. */
847
+ get size() {
848
+ return this.definitions.size;
847
849
  }
848
- return null;
849
- }
850
- function buildAgentRoutes(aiService, agentRuntime, logger) {
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 ──────────────────────────────
873
- {
874
- method: "POST",
875
- path: "/api/v1/ai/agents/:agentName/chat",
876
- description: "Chat with a specific AI agent",
877
- auth: true,
878
- permissions: ["ai:chat", "ai:agents"],
879
- handler: async (req) => {
880
- const agentName = req.params?.agentName;
881
- if (!agentName) {
882
- return { status: 400, body: { error: "agentName parameter is required" } };
883
- }
884
- const {
885
- messages: rawMessages,
886
- context: chatContext,
887
- options: extraOptions
888
- } = req.body ?? {};
889
- if (!Array.isArray(rawMessages) || rawMessages.length === 0) {
890
- return { status: 400, body: { error: "messages array is required" } };
891
- }
892
- for (const msg of rawMessages) {
893
- const err = validateAgentMessage(msg);
894
- if (err) return { status: 400, body: { error: err } };
895
- }
896
- const agent = await agentRuntime.loadAgent(agentName);
897
- if (!agent) {
898
- return { status: 404, body: { error: `Agent "${agentName}" not found` } };
899
- }
900
- if (!agent.active) {
901
- return { status: 403, body: { error: `Agent "${agentName}" is not active` } };
902
- }
903
- try {
904
- const systemMessages = agentRuntime.buildSystemMessages(agent, chatContext);
905
- const agentOptions = agentRuntime.buildRequestOptions(
906
- agent,
907
- aiService.toolRegistry.getAll()
908
- );
909
- const safeOverrides = {};
910
- if (extraOptions) {
911
- const ALLOWED_KEYS = /* @__PURE__ */ new Set(["temperature", "maxTokens", "stop"]);
912
- for (const key of Object.keys(extraOptions)) {
913
- if (ALLOWED_KEYS.has(key)) {
914
- safeOverrides[key] = extraOptions[key];
915
- }
916
- }
917
- }
918
- const mergedOptions = { ...agentOptions, ...safeOverrides };
919
- const fullMessages = [
920
- ...systemMessages,
921
- ...rawMessages
922
- ];
923
- const result = await aiService.chatWithTools(fullMessages, {
924
- ...mergedOptions,
925
- maxIterations: agent.planning?.maxIterations
926
- });
927
- return { status: 200, body: result };
928
- } catch (err) {
929
- logger.error(
930
- "[AI Route] /agents/:agentName/chat error",
931
- err instanceof Error ? err : void 0
932
- );
933
- return { status: 500, body: { error: "Internal AI service error" } };
934
- }
935
- }
850
+ /** All registered tool names. */
851
+ names() {
852
+ return Array.from(this.definitions.keys());
853
+ }
854
+ /**
855
+ * Execute a tool call and return the result.
856
+ */
857
+ async execute(toolCall) {
858
+ const handler = this.handlers.get(toolCall.toolName);
859
+ if (!handler) {
860
+ return {
861
+ type: "tool-result",
862
+ toolCallId: toolCall.toolCallId,
863
+ toolName: toolCall.toolName,
864
+ output: { type: "text", value: `Tool "${toolCall.toolName}" is not registered` },
865
+ isError: true
866
+ };
936
867
  }
937
- ];
938
- }
868
+ try {
869
+ const args = typeof toolCall.input === "string" ? JSON.parse(toolCall.input) : toolCall.input ?? {};
870
+ const content = await handler(args);
871
+ return {
872
+ type: "tool-result",
873
+ toolCallId: toolCall.toolCallId,
874
+ toolName: toolCall.toolName,
875
+ output: { type: "text", value: content }
876
+ };
877
+ } catch (err) {
878
+ const message = err instanceof Error ? err.message : String(err);
879
+ return {
880
+ type: "tool-result",
881
+ toolCallId: toolCall.toolCallId,
882
+ toolName: toolCall.toolName,
883
+ output: { type: "text", value: message },
884
+ isError: true
885
+ };
886
+ }
887
+ }
888
+ /**
889
+ * Execute multiple tool calls in parallel.
890
+ */
891
+ async executeAll(toolCalls) {
892
+ return Promise.all(toolCalls.map((tc) => this.execute(tc)));
893
+ }
894
+ /**
895
+ * Clear all registered tools.
896
+ */
897
+ clear() {
898
+ this.definitions.clear();
899
+ this.handlers.clear();
900
+ }
901
+ };
939
902
 
940
- // src/conversation/objectql-conversation-service.ts
941
- import { randomUUID } from "crypto";
942
- var CONVERSATIONS_OBJECT = "ai_conversations";
943
- var MESSAGES_OBJECT = "ai_messages";
944
- var CONVERSATION_ORDER = [
945
- { field: "created_at", order: "asc" },
946
- { field: "id", order: "asc" }
947
- ];
948
- var MESSAGE_ORDER = [
949
- { field: "created_at", order: "asc" },
950
- { field: "id", order: "asc" }
951
- ];
952
- var ObjectQLConversationService = class {
953
- constructor(engine) {
954
- this.engine = engine;
903
+ // src/conversation/in-memory-conversation-service.ts
904
+ var InMemoryConversationService = class {
905
+ constructor() {
906
+ this.store = /* @__PURE__ */ new Map();
907
+ this.counter = 0;
955
908
  }
956
909
  async create(options = {}) {
957
- const now = (/* @__PURE__ */ new Date()).toISOString();
958
- const id = `conv_${randomUUID()}`;
959
- const record = {
960
- id,
961
- title: options.title ?? null,
962
- agent_id: options.agentId ?? null,
963
- user_id: options.userId ?? null,
964
- metadata: options.metadata ? JSON.stringify(options.metadata) : null,
965
- created_at: now,
966
- updated_at: now
967
- };
968
- await this.engine.insert(CONVERSATIONS_OBJECT, record);
969
- return {
910
+ const now = (/* @__PURE__ */ new Date()).toISOString();
911
+ const id = `conv_${++this.counter}`;
912
+ const conversation = {
970
913
  id,
971
914
  title: options.title,
972
915
  agentId: options.agentId,
@@ -976,1036 +919,1281 @@ var ObjectQLConversationService = class {
976
919
  updatedAt: now,
977
920
  metadata: options.metadata
978
921
  };
922
+ this.store.set(id, conversation);
923
+ return conversation;
979
924
  }
980
925
  async get(conversationId) {
981
- const row = await this.engine.findOne(CONVERSATIONS_OBJECT, {
982
- where: { id: conversationId }
926
+ return this.store.get(conversationId) ?? null;
927
+ }
928
+ async list(options = {}) {
929
+ let results = Array.from(this.store.values());
930
+ if (options.userId) {
931
+ results = results.filter((c) => c.userId === options.userId);
932
+ }
933
+ if (options.agentId) {
934
+ results = results.filter((c) => c.agentId === options.agentId);
935
+ }
936
+ if (options.cursor) {
937
+ const idx = results.findIndex((c) => c.id === options.cursor);
938
+ if (idx >= 0) {
939
+ results = results.slice(idx + 1);
940
+ }
941
+ }
942
+ if (options.limit && options.limit > 0) {
943
+ results = results.slice(0, options.limit);
944
+ }
945
+ return results;
946
+ }
947
+ async addMessage(conversationId, message) {
948
+ const conversation = this.store.get(conversationId);
949
+ if (!conversation) {
950
+ throw new Error(`Conversation "${conversationId}" not found`);
951
+ }
952
+ conversation.messages.push(message);
953
+ conversation.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
954
+ return conversation;
955
+ }
956
+ async delete(conversationId) {
957
+ this.store.delete(conversationId);
958
+ }
959
+ /** Total number of stored conversations. */
960
+ get size() {
961
+ return this.store.size;
962
+ }
963
+ /** Clear all conversations. */
964
+ clear() {
965
+ this.store.clear();
966
+ this.counter = 0;
967
+ }
968
+ };
969
+
970
+ // src/ai-service.ts
971
+ function textDeltaPart(id, text) {
972
+ return { type: "text-delta", id, text };
973
+ }
974
+ function finishPart(result) {
975
+ return {
976
+ type: "finish",
977
+ finishReason: "stop",
978
+ totalUsage: result?.usage ?? { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
979
+ rawFinishReason: "stop"
980
+ };
981
+ }
982
+ var _AIService = class _AIService {
983
+ constructor(config = {}) {
984
+ this.adapter = config.adapter ?? new MemoryLLMAdapter();
985
+ this.logger = config.logger ?? createLogger({ level: "info", format: "pretty" });
986
+ this.toolRegistry = config.toolRegistry ?? new ToolRegistry();
987
+ this.conversationService = config.conversationService ?? new InMemoryConversationService();
988
+ this.logger.info(
989
+ `[AI] Service initialized with adapter="${this.adapter.name}", tools=${this.toolRegistry.size}`
990
+ );
991
+ }
992
+ /** The name of the active LLM adapter. */
993
+ get adapterName() {
994
+ return this.adapter.name;
995
+ }
996
+ // ── IAIService implementation ──────────────────────────────────
997
+ async chat(messages, options) {
998
+ this.logger.debug("[AI] chat", { messageCount: messages.length, model: options?.model });
999
+ return this.adapter.chat(messages, options);
1000
+ }
1001
+ async complete(prompt, options) {
1002
+ this.logger.debug("[AI] complete", { promptLength: prompt.length, model: options?.model });
1003
+ return this.adapter.complete(prompt, options);
1004
+ }
1005
+ async *streamChat(messages, options) {
1006
+ this.logger.debug("[AI] streamChat", { messageCount: messages.length, model: options?.model });
1007
+ if (!this.adapter.streamChat) {
1008
+ const result = await this.adapter.chat(messages, options);
1009
+ yield textDeltaPart("fallback", result.content);
1010
+ yield finishPart(result);
1011
+ return;
1012
+ }
1013
+ yield* this.adapter.streamChat(messages, options);
1014
+ }
1015
+ async embed(input, model) {
1016
+ if (!this.adapter.embed) {
1017
+ throw new Error(`[AI] Adapter "${this.adapter.name}" does not support embeddings`);
1018
+ }
1019
+ return this.adapter.embed(input, model);
1020
+ }
1021
+ async listModels() {
1022
+ if (!this.adapter.listModels) {
1023
+ return [];
1024
+ }
1025
+ return this.adapter.listModels();
1026
+ }
1027
+ /** Extract the text value from a ToolExecutionResult's output. */
1028
+ static extractOutputText(tr) {
1029
+ return tr.output && typeof tr.output === "object" && "value" in tr.output ? String(tr.output.value) : "unknown error";
1030
+ }
1031
+ /**
1032
+ * Chat with automatic tool call resolution.
1033
+ *
1034
+ * 1. Merges registered tool definitions into `options.tools`.
1035
+ * 2. Calls the LLM adapter.
1036
+ * 3. If the response contains `toolCalls`, executes them via the
1037
+ * {@link ToolRegistry}, appends tool results as `role: 'tool'`
1038
+ * messages, and loops back to step 2.
1039
+ * 4. Repeats until the model produces a final text response or the
1040
+ * maximum number of iterations (`maxIterations`) is reached.
1041
+ */
1042
+ async chatWithTools(messages, options) {
1043
+ const { maxIterations: maxIter, onToolError, ...restOptions } = options ?? {};
1044
+ const maxIterations = maxIter ?? _AIService.DEFAULT_MAX_ITERATIONS;
1045
+ const registeredTools = this.toolRegistry.getAll();
1046
+ const mergedTools = [
1047
+ ...registeredTools,
1048
+ ...restOptions.tools ?? []
1049
+ ];
1050
+ const chatOptions = {
1051
+ ...restOptions,
1052
+ tools: mergedTools.length > 0 ? mergedTools : void 0,
1053
+ toolChoice: mergedTools.length > 0 ? restOptions.toolChoice ?? "auto" : void 0
1054
+ };
1055
+ const conversation = [...messages];
1056
+ const toolErrors = [];
1057
+ this.logger.debug("[AI] chatWithTools start", {
1058
+ messageCount: conversation.length,
1059
+ toolCount: mergedTools.length,
1060
+ maxIterations
983
1061
  });
984
- if (!row) return null;
985
- const messages = await this.engine.find(MESSAGES_OBJECT, {
986
- where: { conversation_id: conversationId },
987
- orderBy: MESSAGE_ORDER
1062
+ let abortedByCallback = false;
1063
+ for (let iteration = 0; iteration < maxIterations; iteration++) {
1064
+ const result = await this.adapter.chat(conversation, chatOptions);
1065
+ if (!result.toolCalls || result.toolCalls.length === 0) {
1066
+ this.logger.debug("[AI] chatWithTools finished", { iteration, content: result.content.slice(0, 80) });
1067
+ return result;
1068
+ }
1069
+ this.logger.debug("[AI] chatWithTools tool calls", {
1070
+ iteration,
1071
+ calls: result.toolCalls.map((tc) => tc.toolName)
1072
+ });
1073
+ const assistantContent = [];
1074
+ if (result.content) assistantContent.push({ type: "text", text: result.content });
1075
+ assistantContent.push(...result.toolCalls);
1076
+ conversation.push({
1077
+ role: "assistant",
1078
+ content: assistantContent
1079
+ });
1080
+ const toolResults = await this.toolRegistry.executeAll(result.toolCalls);
1081
+ for (const tr of toolResults) {
1082
+ if (tr.isError) {
1083
+ const matchedCall = result.toolCalls.find((tc) => tc.toolCallId === tr.toolCallId);
1084
+ const toolName = matchedCall?.toolName ?? "unknown";
1085
+ const errorText = _AIService.extractOutputText(tr);
1086
+ const errorEntry = { iteration, toolName, error: errorText };
1087
+ toolErrors.push(errorEntry);
1088
+ this.logger.warn("[AI] chatWithTools tool error", errorEntry);
1089
+ if (onToolError && matchedCall) {
1090
+ const action = onToolError(matchedCall, errorText);
1091
+ if (action === "abort") {
1092
+ abortedByCallback = true;
1093
+ }
1094
+ }
1095
+ }
1096
+ conversation.push({
1097
+ role: "tool",
1098
+ content: [tr]
1099
+ });
1100
+ }
1101
+ if (abortedByCallback) {
1102
+ break;
1103
+ }
1104
+ }
1105
+ if (abortedByCallback) {
1106
+ this.logger.warn("[AI] chatWithTools aborted by onToolError callback", { toolErrors });
1107
+ } else {
1108
+ this.logger.warn("[AI] chatWithTools max iterations reached, forcing final response", {
1109
+ toolErrors: toolErrors.length > 0 ? toolErrors : void 0
1110
+ });
1111
+ }
1112
+ const finalResult = await this.adapter.chat(conversation, {
1113
+ ...chatOptions,
1114
+ tools: void 0,
1115
+ toolChoice: void 0
988
1116
  });
989
- return this.toConversation(row, messages);
1117
+ return finalResult;
1118
+ }
1119
+ /**
1120
+ * Stream chat with automatic tool call resolution.
1121
+ *
1122
+ * Works like {@link chatWithTools} but yields SSE events. When the model
1123
+ * requests tool calls during streaming, they are executed and the results
1124
+ * fed back until a final text stream is produced.
1125
+ */
1126
+ async *streamChatWithTools(messages, options) {
1127
+ const { maxIterations: maxIter, onToolError, ...restOptions } = options ?? {};
1128
+ const maxIterations = maxIter ?? _AIService.DEFAULT_MAX_ITERATIONS;
1129
+ const registeredTools = this.toolRegistry.getAll();
1130
+ const mergedTools = [
1131
+ ...registeredTools,
1132
+ ...restOptions.tools ?? []
1133
+ ];
1134
+ const chatOptions = {
1135
+ ...restOptions,
1136
+ tools: mergedTools.length > 0 ? mergedTools : void 0,
1137
+ toolChoice: mergedTools.length > 0 ? restOptions.toolChoice ?? "auto" : void 0
1138
+ };
1139
+ const conversation = [...messages];
1140
+ let abortedByCallback = false;
1141
+ for (let iteration = 0; iteration < maxIterations; iteration++) {
1142
+ const result2 = await this.adapter.chat(conversation, chatOptions);
1143
+ if (!result2.toolCalls || result2.toolCalls.length === 0) {
1144
+ yield textDeltaPart("stream", result2.content);
1145
+ yield finishPart(result2);
1146
+ return;
1147
+ }
1148
+ for (const tc of result2.toolCalls) {
1149
+ yield { type: "tool-call", toolCallId: tc.toolCallId, toolName: tc.toolName, input: tc.input };
1150
+ }
1151
+ const assistantContent = [];
1152
+ if (result2.content) assistantContent.push({ type: "text", text: result2.content });
1153
+ assistantContent.push(...result2.toolCalls);
1154
+ conversation.push({
1155
+ role: "assistant",
1156
+ content: assistantContent
1157
+ });
1158
+ const toolResults = await this.toolRegistry.executeAll(result2.toolCalls);
1159
+ for (const tr of toolResults) {
1160
+ if (tr.isError && onToolError) {
1161
+ const matchedCall = result2.toolCalls.find((tc) => tc.toolCallId === tr.toolCallId);
1162
+ if (matchedCall) {
1163
+ const errorText = _AIService.extractOutputText(tr);
1164
+ const action = onToolError(matchedCall, errorText);
1165
+ if (action === "abort") {
1166
+ abortedByCallback = true;
1167
+ }
1168
+ }
1169
+ }
1170
+ yield {
1171
+ type: "tool-result",
1172
+ toolCallId: tr.toolCallId,
1173
+ toolName: tr.toolName,
1174
+ output: tr.output
1175
+ };
1176
+ conversation.push({
1177
+ role: "tool",
1178
+ content: [tr]
1179
+ });
1180
+ }
1181
+ if (abortedByCallback) {
1182
+ break;
1183
+ }
1184
+ }
1185
+ if (abortedByCallback) {
1186
+ this.logger.warn("[AI] streamChatWithTools aborted by onToolError callback");
1187
+ } else {
1188
+ this.logger.warn("[AI] streamChatWithTools max iterations reached");
1189
+ }
1190
+ const finalOptions = { ...chatOptions, tools: void 0, toolChoice: void 0 };
1191
+ const result = await this.adapter.chat(conversation, finalOptions);
1192
+ yield textDeltaPart("stream", result.content);
1193
+ yield finishPart(result);
990
1194
  }
991
- async list(options = {}) {
992
- const where = {};
993
- if (options.userId) where.user_id = options.userId;
994
- if (options.agentId) where.agent_id = options.agentId;
995
- if (options.cursor) {
996
- const cursorRow = await this.engine.findOne(CONVERSATIONS_OBJECT, {
997
- where: { id: options.cursor },
998
- fields: ["created_at", "id"]
1195
+ };
1196
+ // ── Tool Call Loop ────────────────────────────────────────────
1197
+ /** Default maximum iterations for the tool call loop. */
1198
+ _AIService.DEFAULT_MAX_ITERATIONS = 10;
1199
+ var AIService = _AIService;
1200
+
1201
+ // src/stream/vercel-stream-encoder.ts
1202
+ function sse(data) {
1203
+ return `data: ${JSON.stringify(data)}
1204
+
1205
+ `;
1206
+ }
1207
+ function dataStreamLine(prefix, data) {
1208
+ return `${prefix}:${JSON.stringify(data)}
1209
+ `;
1210
+ }
1211
+ function encodeStreamPart(part) {
1212
+ switch (part.type) {
1213
+ case "text-delta":
1214
+ return sse({ type: "text-delta", id: "0", delta: part.text });
1215
+ case "tool-input-start":
1216
+ return sse({
1217
+ type: "tool-input-start",
1218
+ toolCallId: part.id,
1219
+ toolName: part.toolName
999
1220
  });
1000
- if (cursorRow) {
1001
- where.$or = [
1002
- { created_at: { $gt: cursorRow.created_at } },
1003
- { created_at: cursorRow.created_at, id: { $gt: cursorRow.id } }
1004
- ];
1221
+ case "tool-input-delta":
1222
+ return sse({
1223
+ type: "tool-input-delta",
1224
+ toolCallId: part.id,
1225
+ inputTextDelta: part.delta
1226
+ });
1227
+ case "tool-call":
1228
+ return sse({
1229
+ type: "tool-input-available",
1230
+ toolCallId: part.toolCallId,
1231
+ toolName: part.toolName,
1232
+ input: part.input
1233
+ });
1234
+ case "tool-result":
1235
+ return sse({
1236
+ type: "tool-output-available",
1237
+ toolCallId: part.toolCallId,
1238
+ output: part.output
1239
+ });
1240
+ case "error":
1241
+ return sse({
1242
+ type: "error",
1243
+ errorText: String(part.error)
1244
+ });
1245
+ // Handle reasoning/thinking streams (DeepSeek R1, o1-style models)
1246
+ // Use 'g:' prefix for reasoning content per Vercel AI SDK protocol
1247
+ case "reasoning-start":
1248
+ return dataStreamLine("g", { text: "" });
1249
+ case "reasoning-delta":
1250
+ return dataStreamLine("g", { text: part.text });
1251
+ case "reasoning-end":
1252
+ return "";
1253
+ // No specific end marker needed for reasoning
1254
+ // finish-step and finish are handled by the generator, not here
1255
+ default:
1256
+ if (part.type?.startsWith("step-")) {
1257
+ return sse(part);
1005
1258
  }
1006
- }
1007
- const rows = await this.engine.find(CONVERSATIONS_OBJECT, {
1008
- where: Object.keys(where).length > 0 ? where : void 0,
1009
- orderBy: CONVERSATION_ORDER,
1010
- limit: options.limit && options.limit > 0 ? options.limit : void 0
1011
- });
1012
- const conversations = await Promise.all(
1013
- rows.map(async (row) => {
1014
- const messages = await this.engine.find(MESSAGES_OBJECT, {
1015
- where: { conversation_id: row.id },
1016
- orderBy: MESSAGE_ORDER
1017
- });
1018
- return this.toConversation(row, messages);
1019
- })
1020
- );
1021
- return conversations;
1259
+ return "";
1022
1260
  }
1023
- async addMessage(conversationId, message) {
1024
- const row = await this.engine.findOne(CONVERSATIONS_OBJECT, {
1025
- where: { id: conversationId }
1026
- });
1027
- if (!row) {
1028
- throw new Error(`Conversation "${conversationId}" not found`);
1261
+ }
1262
+ async function* encodeVercelDataStream(events) {
1263
+ yield sse({ type: "start" });
1264
+ yield sse({ type: "start-step" });
1265
+ yield sse({ type: "text-start", id: "0" });
1266
+ let textOpen = true;
1267
+ let finishReason = "stop";
1268
+ for await (const part of events) {
1269
+ if (part.type === "finish") {
1270
+ finishReason = part.finishReason ?? "stop";
1029
1271
  }
1030
- const now = (/* @__PURE__ */ new Date()).toISOString();
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);
1272
+ if (part.type === "finish-step" || part.type === "finish") {
1273
+ if (textOpen) {
1274
+ yield sse({ type: "text-end", id: "0" });
1275
+ textOpen = false;
1046
1276
  }
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 = "";
1277
+ continue;
1278
+ }
1279
+ const frame = encodeStreamPart(part);
1280
+ if (frame) {
1281
+ yield frame;
1053
1282
  }
1054
- await this.engine.insert(MESSAGES_OBJECT, {
1055
- id: msgId,
1056
- conversation_id: conversationId,
1057
- role: message.role,
1058
- content: contentStr,
1059
- tool_calls: toolCallsJson,
1060
- tool_call_id: toolCallId,
1061
- created_at: now
1062
- });
1063
- await this.engine.update(CONVERSATIONS_OBJECT, { id: conversationId, updated_at: now }, {
1064
- where: { id: conversationId }
1065
- });
1066
- return await this.get(conversationId);
1067
1283
  }
1068
- async delete(conversationId) {
1069
- await this.engine.delete(MESSAGES_OBJECT, {
1070
- where: { conversation_id: conversationId },
1071
- multi: true
1072
- });
1073
- await this.engine.delete(CONVERSATIONS_OBJECT, {
1074
- where: { id: conversationId }
1075
- });
1284
+ if (textOpen) {
1285
+ yield sse({ type: "text-end", id: "0" });
1076
1286
  }
1077
- // ── Private helpers ──────────────────────────────────────────────
1078
- /**
1079
- * Safely parse a JSON string, returning `undefined` on failure.
1080
- */
1081
- safeParse(value, fallback) {
1082
- if (!value) return void 0;
1083
- try {
1084
- return JSON.parse(value);
1085
- } catch {
1086
- return fallback;
1287
+ yield sse({ type: "finish-step" });
1288
+ yield sse({ type: "finish", finishReason });
1289
+ yield "data: [DONE]\n\n";
1290
+ }
1291
+
1292
+ // src/routes/message-utils.ts
1293
+ function normalizeMessage(raw) {
1294
+ const role = raw.role;
1295
+ if (typeof raw.content === "string") {
1296
+ return { role, content: raw.content };
1297
+ }
1298
+ if (Array.isArray(raw.content)) {
1299
+ return { role, content: raw.content };
1300
+ }
1301
+ if (Array.isArray(raw.parts)) {
1302
+ const textParts = raw.parts.filter((p) => p.type === "text" && typeof p.text === "string").map((p) => p.text);
1303
+ if (textParts.length > 0) {
1304
+ return { role, content: textParts.join("") };
1087
1305
  }
1088
1306
  }
1089
- /**
1090
- * Map a database row + message rows to an AIConversation.
1091
- */
1092
- toConversation(row, messageRows) {
1093
- return {
1094
- id: row.id,
1095
- title: row.title ?? void 0,
1096
- agentId: row.agent_id ?? void 0,
1097
- userId: row.user_id ?? void 0,
1098
- messages: messageRows.map((m) => this.toMessage(m)),
1099
- createdAt: row.created_at,
1100
- updatedAt: row.updated_at,
1101
- metadata: this.safeParse(row.metadata)
1102
- };
1307
+ return { role, content: "" };
1308
+ }
1309
+ function validateMessageContent(msg, opts) {
1310
+ const content = msg.content;
1311
+ if (Array.isArray(msg.parts)) {
1312
+ return null;
1103
1313
  }
1104
- /**
1105
- * Map a database row to a ModelMessage.
1106
- */
1107
- toMessage(row) {
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 };
1314
+ if (typeof content === "string") {
1315
+ return null;
1316
+ }
1317
+ if (Array.isArray(content)) {
1318
+ for (const part of content) {
1319
+ if (typeof part !== "object" || part === null) {
1320
+ return "message.content array elements must be non-null objects";
1122
1321
  }
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
- };
1322
+ const partObj = part;
1323
+ if (typeof partObj.type !== "string") {
1324
+ return 'each message.content array element must have a string "type" property';
1325
+ }
1326
+ if (partObj.type === "text" && typeof partObj.text !== "string") {
1327
+ return 'message.content elements with type "text" must have a string "text" property';
1137
1328
  }
1138
- default:
1139
- return { role: "user", content: row.content };
1140
1329
  }
1330
+ return null;
1141
1331
  }
1142
- };
1143
-
1144
- // src/objects/ai-conversation.object.ts
1145
- import { ObjectSchema, Field } from "@objectstack/spec/data";
1146
- var AiConversationObject = ObjectSchema.create({
1147
- namespace: "ai",
1148
- name: "conversations",
1149
- label: "AI Conversation",
1150
- pluralLabel: "AI Conversations",
1151
- icon: "message-square",
1152
- isSystem: true,
1153
- description: "Persistent AI conversation metadata",
1154
- fields: {
1155
- id: Field.text({
1156
- label: "Conversation ID",
1157
- required: true,
1158
- readonly: true
1159
- }),
1160
- title: Field.text({
1161
- label: "Title",
1162
- required: false,
1163
- maxLength: 500,
1164
- description: "Conversation title or summary"
1165
- }),
1166
- agent_id: Field.text({
1167
- label: "Agent ID",
1168
- required: false,
1169
- maxLength: 255,
1170
- description: "Associated AI agent identifier"
1171
- }),
1172
- user_id: Field.text({
1173
- label: "User ID",
1174
- required: false,
1175
- maxLength: 255,
1176
- description: "User who owns the conversation"
1177
- }),
1178
- metadata: Field.textarea({
1179
- label: "Metadata",
1180
- required: false,
1181
- description: "JSON-serialized conversation metadata"
1182
- }),
1183
- created_at: Field.datetime({
1184
- label: "Created At",
1185
- required: true,
1186
- defaultValue: "NOW()",
1187
- readonly: true
1188
- }),
1189
- updated_at: Field.datetime({
1190
- label: "Updated At",
1191
- required: true,
1192
- defaultValue: "NOW()",
1193
- readonly: true
1194
- })
1195
- },
1196
- indexes: [
1197
- { fields: ["user_id"] },
1198
- { fields: ["agent_id"] },
1199
- { fields: ["created_at"] }
1200
- ],
1201
- enable: {
1202
- trackHistory: false,
1203
- searchable: false,
1204
- apiEnabled: true,
1205
- apiMethods: ["get", "list", "create", "update", "delete"],
1206
- trash: false,
1207
- mru: false
1332
+ if ((content === null || content === void 0) && opts?.allowEmptyContent) {
1333
+ return null;
1208
1334
  }
1209
- });
1335
+ return "message.content must be a string, an array, or include parts";
1336
+ }
1210
1337
 
1211
- // src/objects/ai-message.object.ts
1212
- import { ObjectSchema as ObjectSchema2, Field as Field2 } from "@objectstack/spec/data";
1213
- var AiMessageObject = ObjectSchema2.create({
1214
- namespace: "ai",
1215
- name: "messages",
1216
- label: "AI Message",
1217
- pluralLabel: "AI Messages",
1218
- icon: "message-circle",
1219
- isSystem: true,
1220
- description: "Individual messages within AI conversations",
1221
- fields: {
1222
- id: Field2.text({
1223
- label: "Message ID",
1224
- required: true,
1225
- readonly: true
1226
- }),
1227
- conversation_id: Field2.text({
1228
- label: "Conversation ID",
1229
- required: true,
1230
- description: "Foreign key to ai_conversations"
1231
- }),
1232
- role: Field2.select({
1233
- label: "Role",
1234
- required: true,
1235
- options: [
1236
- { label: "System", value: "system" },
1237
- { label: "User", value: "user" },
1238
- { label: "Assistant", value: "assistant" },
1239
- { label: "Tool", value: "tool" }
1240
- ]
1241
- }),
1242
- content: Field2.textarea({
1243
- label: "Content",
1244
- required: true,
1245
- description: "Message content"
1246
- }),
1247
- tool_calls: Field2.textarea({
1248
- label: "Tool Calls",
1249
- required: false,
1250
- description: "JSON-serialized tool calls (when role=assistant)"
1251
- }),
1252
- tool_call_id: Field2.text({
1253
- label: "Tool Call ID",
1254
- required: false,
1255
- maxLength: 255,
1256
- description: "ID of the tool call this message responds to (when role=tool)"
1257
- }),
1258
- created_at: Field2.datetime({
1259
- label: "Created At",
1260
- required: true,
1261
- defaultValue: "NOW()",
1262
- readonly: true
1263
- })
1264
- },
1265
- indexes: [
1266
- { fields: ["conversation_id"] },
1267
- { fields: ["conversation_id", "created_at"] }
1268
- ],
1269
- enable: {
1270
- trackHistory: false,
1271
- searchable: false,
1272
- apiEnabled: true,
1273
- apiMethods: ["get", "list", "create"],
1274
- trash: false,
1275
- mru: false
1338
+ // src/routes/ai-routes.ts
1339
+ var VALID_ROLES = /* @__PURE__ */ new Set(["system", "user", "assistant", "tool"]);
1340
+ function validateMessage(raw) {
1341
+ if (typeof raw !== "object" || raw === null) {
1342
+ return "each message must be an object";
1276
1343
  }
1277
- });
1278
-
1279
- // src/tools/data-tools.ts
1280
- var MAX_QUERY_LIMIT = 200;
1281
- var DEFAULT_QUERY_LIMIT = 20;
1282
- var LIST_OBJECTS_TOOL = {
1283
- name: "list_objects",
1284
- description: "List all available data objects (tables) in the system. Returns object names and labels.",
1285
- parameters: {
1286
- type: "object",
1287
- properties: {},
1288
- additionalProperties: false
1344
+ const msg = raw;
1345
+ if (typeof msg.role !== "string" || !VALID_ROLES.has(msg.role)) {
1346
+ return `message.role must be one of ${[...VALID_ROLES].map((r) => `"${r}"`).join(", ")}`;
1289
1347
  }
1290
- };
1291
- var DESCRIBE_OBJECT_TOOL = {
1292
- name: "describe_object",
1293
- description: "Get the schema (fields, types, labels) of a specific data object. Use this to understand the structure of a table before querying it.",
1294
- parameters: {
1295
- type: "object",
1296
- properties: {
1297
- objectName: {
1298
- type: "string",
1299
- description: "The snake_case name of the object to describe"
1348
+ const allowEmpty = msg.role === "assistant" || msg.role === "tool";
1349
+ return validateMessageContent(msg, { allowEmptyContent: allowEmpty });
1350
+ }
1351
+ function buildAIRoutes(aiService, conversationService, logger) {
1352
+ return [
1353
+ // ── Chat ────────────────────────────────────────────────────
1354
+ //
1355
+ // Dual-mode endpoint compatible with both the legacy ObjectStack
1356
+ // format (`{ messages, options }`) and the Vercel AI SDK useChat
1357
+ // flat format (`{ messages, system, model, stream, … }`).
1358
+ //
1359
+ // Behaviour:
1360
+ // • `stream !== false` → Vercel Data Stream Protocol (SSE)
1361
+ // • `stream === false` → JSON response (legacy)
1362
+ //
1363
+ {
1364
+ method: "POST",
1365
+ path: "/api/v1/ai/chat",
1366
+ description: "Chat completion (supports Vercel AI Data Stream Protocol)",
1367
+ auth: true,
1368
+ permissions: ["ai:chat"],
1369
+ handler: async (req) => {
1370
+ const body = req.body ?? {};
1371
+ const messages = body.messages;
1372
+ if (!Array.isArray(messages) || messages.length === 0) {
1373
+ return { status: 400, body: { error: "messages array is required" } };
1374
+ }
1375
+ for (const msg of messages) {
1376
+ const err = validateMessage(msg);
1377
+ if (err) return { status: 400, body: { error: err } };
1378
+ }
1379
+ const nested = body.options ?? {};
1380
+ const resolvedOptions = {
1381
+ ...nested,
1382
+ ...body.model != null && { model: body.model },
1383
+ ...body.temperature != null && { temperature: body.temperature },
1384
+ ...body.maxTokens != null && { maxTokens: body.maxTokens }
1385
+ };
1386
+ const rawSystemPrompt = body.system ?? body.systemPrompt;
1387
+ if (rawSystemPrompt != null && typeof rawSystemPrompt !== "string") {
1388
+ return { status: 400, body: { error: "system/systemPrompt must be a string" } };
1389
+ }
1390
+ const systemPrompt = rawSystemPrompt;
1391
+ const finalMessages = [
1392
+ ...systemPrompt ? [{ role: "system", content: systemPrompt }] : [],
1393
+ ...messages.map((m) => normalizeMessage(m))
1394
+ ];
1395
+ const wantStream = body.stream !== false;
1396
+ if (wantStream) {
1397
+ try {
1398
+ if (!aiService.streamChatWithTools) {
1399
+ return { status: 501, body: { error: "Streaming is not supported by the configured AI service" } };
1400
+ }
1401
+ const events = aiService.streamChatWithTools(finalMessages, resolvedOptions);
1402
+ return {
1403
+ status: 200,
1404
+ stream: true,
1405
+ vercelDataStream: true,
1406
+ contentType: "text/event-stream",
1407
+ headers: {
1408
+ "Content-Type": "text/event-stream",
1409
+ "Cache-Control": "no-cache",
1410
+ "Connection": "keep-alive",
1411
+ "x-vercel-ai-ui-message-stream": "v1"
1412
+ },
1413
+ events: encodeVercelDataStream(events)
1414
+ };
1415
+ } catch (err) {
1416
+ logger.error("[AI Route] /chat stream error", err instanceof Error ? err : void 0);
1417
+ return { status: 500, body: { error: "Internal AI service error" } };
1418
+ }
1419
+ }
1420
+ try {
1421
+ const result = await aiService.chatWithTools(finalMessages, resolvedOptions);
1422
+ return { status: 200, body: result };
1423
+ } catch (err) {
1424
+ logger.error("[AI Route] /chat error", err instanceof Error ? err : void 0);
1425
+ return { status: 500, body: { error: "Internal AI service error" } };
1426
+ }
1427
+ }
1428
+ },
1429
+ // ── Stream Chat (SSE) ──────────────────────────────────────
1430
+ {
1431
+ method: "POST",
1432
+ path: "/api/v1/ai/chat/stream",
1433
+ description: "SSE streaming chat completion",
1434
+ auth: true,
1435
+ permissions: ["ai:chat"],
1436
+ handler: async (req) => {
1437
+ const { messages, options } = req.body ?? {};
1438
+ if (!Array.isArray(messages) || messages.length === 0) {
1439
+ return { status: 400, body: { error: "messages array is required" } };
1440
+ }
1441
+ for (const msg of messages) {
1442
+ const err = validateMessage(msg);
1443
+ if (err) return { status: 400, body: { error: err } };
1444
+ }
1445
+ try {
1446
+ if (!aiService.streamChat) {
1447
+ return { status: 501, body: { error: "Streaming is not supported by the configured AI service" } };
1448
+ }
1449
+ const events = aiService.streamChat(messages.map((m) => normalizeMessage(m)), options);
1450
+ return { status: 200, stream: true, events };
1451
+ } catch (err) {
1452
+ logger.error("[AI Route] /chat/stream error", err instanceof Error ? err : void 0);
1453
+ return { status: 500, body: { error: "Internal AI service error" } };
1454
+ }
1300
1455
  }
1301
1456
  },
1302
- required: ["objectName"],
1303
- additionalProperties: false
1304
- }
1305
- };
1306
- var QUERY_RECORDS_TOOL = {
1307
- name: "query_records",
1308
- description: "Query records from a data object with optional filters, field selection, sorting, and pagination. Returns an array of matching records.",
1309
- parameters: {
1310
- type: "object",
1311
- properties: {
1312
- objectName: {
1313
- type: "string",
1314
- description: "The snake_case name of the object to query"
1315
- },
1316
- where: {
1317
- type: "object",
1318
- description: 'Filter conditions as key-value pairs (e.g. { "status": "active" }) or MongoDB-style operators (e.g. { "amount": { "$gt": 100 } })'
1319
- },
1320
- fields: {
1321
- type: "array",
1322
- items: { type: "string" },
1323
- description: "List of field names to return (omit for all fields)"
1324
- },
1325
- orderBy: {
1326
- type: "array",
1327
- items: {
1328
- type: "object",
1329
- properties: {
1330
- field: { type: "string" },
1331
- order: { type: "string", enum: ["asc", "desc"] }
1332
- }
1333
- },
1334
- description: 'Sort order (e.g. [{ "field": "created_at", "order": "desc" }])'
1335
- },
1336
- limit: {
1337
- type: "number",
1338
- description: `Maximum number of records to return (default ${DEFAULT_QUERY_LIMIT}, max ${MAX_QUERY_LIMIT})`
1339
- },
1340
- offset: {
1341
- type: "number",
1342
- description: "Number of records to skip for pagination"
1457
+ // ── Complete ────────────────────────────────────────────────
1458
+ {
1459
+ method: "POST",
1460
+ path: "/api/v1/ai/complete",
1461
+ description: "Text completion",
1462
+ auth: true,
1463
+ permissions: ["ai:complete"],
1464
+ handler: async (req) => {
1465
+ const { prompt, options } = req.body ?? {};
1466
+ if (!prompt || typeof prompt !== "string") {
1467
+ return { status: 400, body: { error: "prompt string is required" } };
1468
+ }
1469
+ try {
1470
+ const result = await aiService.complete(prompt, options);
1471
+ return { status: 200, body: result };
1472
+ } catch (err) {
1473
+ logger.error("[AI Route] /complete error", err instanceof Error ? err : void 0);
1474
+ return { status: 500, body: { error: "Internal AI service error" } };
1475
+ }
1343
1476
  }
1344
1477
  },
1345
- required: ["objectName"],
1346
- additionalProperties: false
1347
- }
1348
- };
1349
- var GET_RECORD_TOOL = {
1350
- name: "get_record",
1351
- description: "Get a single record by its ID from a data object.",
1352
- parameters: {
1353
- type: "object",
1354
- properties: {
1355
- objectName: {
1356
- type: "string",
1357
- description: "The snake_case name of the object"
1358
- },
1359
- recordId: {
1360
- type: "string",
1361
- description: "The unique ID of the record"
1362
- },
1363
- fields: {
1364
- type: "array",
1365
- items: { type: "string" },
1366
- description: "List of field names to return (omit for all fields)"
1478
+ // ── Models ──────────────────────────────────────────────────
1479
+ {
1480
+ method: "GET",
1481
+ path: "/api/v1/ai/models",
1482
+ description: "List available models",
1483
+ auth: true,
1484
+ permissions: ["ai:read"],
1485
+ handler: async () => {
1486
+ try {
1487
+ const models = aiService.listModels ? await aiService.listModels() : [];
1488
+ return { status: 200, body: { models } };
1489
+ } catch (err) {
1490
+ logger.error("[AI Route] /models error", err instanceof Error ? err : void 0);
1491
+ return { status: 500, body: { error: "Internal AI service error" } };
1492
+ }
1367
1493
  }
1368
1494
  },
1369
- required: ["objectName", "recordId"],
1370
- additionalProperties: false
1371
- }
1372
- };
1373
- var AGGREGATE_DATA_TOOL = {
1374
- name: "aggregate_data",
1375
- description: "Perform aggregation/statistical operations on a data object. Supports count, sum, avg, min, max with optional groupBy and where filters.",
1376
- parameters: {
1377
- type: "object",
1378
- properties: {
1379
- objectName: {
1380
- type: "string",
1381
- description: "The snake_case name of the object to aggregate"
1382
- },
1383
- aggregations: {
1384
- type: "array",
1385
- items: {
1386
- type: "object",
1387
- properties: {
1388
- function: {
1389
- type: "string",
1390
- enum: ["count", "sum", "avg", "min", "max", "count_distinct"],
1391
- description: "Aggregation function"
1392
- },
1393
- field: {
1394
- type: "string",
1395
- description: "Field to aggregate (optional for count)"
1396
- },
1397
- alias: {
1398
- type: "string",
1399
- description: "Result column alias"
1400
- }
1401
- },
1402
- required: ["function", "alias"]
1403
- },
1404
- description: "Aggregation definitions"
1405
- },
1406
- groupBy: {
1407
- type: "array",
1408
- items: { type: "string" },
1409
- description: "Fields to group by"
1410
- },
1411
- where: {
1412
- type: "object",
1413
- description: "Filter conditions applied before aggregation"
1495
+ // ── Conversations ──────────────────────────────────────────
1496
+ {
1497
+ method: "POST",
1498
+ path: "/api/v1/ai/conversations",
1499
+ description: "Create a conversation",
1500
+ auth: true,
1501
+ permissions: ["ai:conversations"],
1502
+ handler: async (req) => {
1503
+ try {
1504
+ if (req.body !== void 0 && req.body !== null && (typeof req.body !== "object" || Array.isArray(req.body))) {
1505
+ return { status: 400, body: { error: "Invalid request payload" } };
1506
+ }
1507
+ const options = { ...req.body ?? {} };
1508
+ if (req.user?.userId) {
1509
+ options.userId = req.user.userId;
1510
+ }
1511
+ const conversation = await conversationService.create(options);
1512
+ return { status: 201, body: conversation };
1513
+ } catch (err) {
1514
+ logger.error("[AI Route] POST /conversations error", err instanceof Error ? err : void 0);
1515
+ return { status: 500, body: { error: "Internal AI service error" } };
1516
+ }
1414
1517
  }
1415
1518
  },
1416
- required: ["objectName", "aggregations"],
1417
- additionalProperties: false
1418
- }
1419
- };
1420
- var DATA_TOOL_DEFINITIONS = [
1421
- LIST_OBJECTS_TOOL,
1422
- DESCRIBE_OBJECT_TOOL,
1423
- QUERY_RECORDS_TOOL,
1424
- GET_RECORD_TOOL,
1425
- AGGREGATE_DATA_TOOL
1426
- ];
1427
- function createListObjectsHandler(ctx) {
1428
- return async () => {
1429
- const objects = await ctx.metadataService.listObjects();
1430
- const summary = objects.map((o) => ({
1431
- name: o.name,
1432
- label: o.label ?? o.name
1433
- }));
1434
- return JSON.stringify(summary);
1435
- };
1436
- }
1437
- function createDescribeObjectHandler(ctx) {
1438
- return async (args) => {
1439
- const { objectName } = args;
1440
- const objectDef = await ctx.metadataService.getObject(objectName);
1441
- if (!objectDef) {
1442
- return JSON.stringify({ error: `Object "${objectName}" not found` });
1443
- }
1444
- const def = objectDef;
1445
- const fields = def.fields ?? {};
1446
- const fieldSummary = {};
1447
- for (const [key, f] of Object.entries(fields)) {
1448
- fieldSummary[key] = {
1449
- type: f.type,
1450
- label: f.label ?? key,
1451
- required: f.required ?? false,
1452
- ...f.reference ? { reference: f.reference } : {},
1453
- ...f.options ? { options: f.options } : {}
1454
- };
1455
- }
1456
- return JSON.stringify({
1457
- name: def.name,
1458
- label: def.label ?? def.name,
1459
- fields: fieldSummary
1460
- });
1461
- };
1462
- }
1463
- function createQueryRecordsHandler(ctx) {
1464
- return async (args) => {
1465
- const {
1466
- objectName,
1467
- where,
1468
- fields,
1469
- orderBy,
1470
- limit,
1471
- offset
1472
- } = args;
1473
- const rawLimit = limit ?? DEFAULT_QUERY_LIMIT;
1474
- const safeLimit = Number.isFinite(rawLimit) && rawLimit > 0 ? Math.min(Math.floor(rawLimit), MAX_QUERY_LIMIT) : DEFAULT_QUERY_LIMIT;
1475
- const safeOffset = Number.isFinite(offset) && offset >= 0 ? Math.floor(offset) : void 0;
1476
- const records = await ctx.dataEngine.find(objectName, {
1477
- where,
1478
- fields,
1479
- orderBy,
1480
- limit: safeLimit,
1481
- offset: safeOffset
1482
- });
1483
- return JSON.stringify({ count: records.length, records });
1484
- };
1485
- }
1486
- function createGetRecordHandler(ctx) {
1487
- return async (args) => {
1488
- const { objectName, recordId, fields } = args;
1489
- const record = await ctx.dataEngine.findOne(objectName, {
1490
- where: { id: recordId },
1491
- fields
1492
- });
1493
- if (!record) {
1494
- return JSON.stringify({ error: `Record "${recordId}" not found in "${objectName}"` });
1495
- }
1496
- return JSON.stringify(record);
1497
- };
1498
- }
1499
- var VALID_AGG_FUNCTIONS = /* @__PURE__ */ new Set([
1500
- "count",
1501
- "sum",
1502
- "avg",
1503
- "min",
1504
- "max",
1505
- "count_distinct"
1506
- ]);
1507
- function createAggregateDataHandler(ctx) {
1508
- return async (args) => {
1509
- const { objectName, aggregations, groupBy, where } = args;
1510
- for (const a of aggregations) {
1511
- if (!VALID_AGG_FUNCTIONS.has(a.function)) {
1512
- return JSON.stringify({
1513
- error: `Invalid aggregation function "${a.function}". Allowed: ${[...VALID_AGG_FUNCTIONS].join(", ")}`
1514
- });
1519
+ {
1520
+ method: "GET",
1521
+ path: "/api/v1/ai/conversations",
1522
+ description: "List conversations",
1523
+ auth: true,
1524
+ permissions: ["ai:conversations"],
1525
+ handler: async (req) => {
1526
+ try {
1527
+ const rawQuery = req.query ?? {};
1528
+ const options = { ...rawQuery };
1529
+ if (typeof rawQuery.limit === "string") {
1530
+ const parsedLimit = Number(rawQuery.limit);
1531
+ if (!Number.isFinite(parsedLimit) || parsedLimit <= 0 || !Number.isInteger(parsedLimit)) {
1532
+ return { status: 400, body: { error: "Invalid limit parameter" } };
1533
+ }
1534
+ options.limit = parsedLimit;
1535
+ }
1536
+ if (req.user?.userId) {
1537
+ options.userId = req.user.userId;
1538
+ }
1539
+ const conversations = await conversationService.list(options);
1540
+ return { status: 200, body: { conversations } };
1541
+ } catch (err) {
1542
+ logger.error("[AI Route] GET /conversations error", err instanceof Error ? err : void 0);
1543
+ return { status: 500, body: { error: "Internal AI service error" } };
1544
+ }
1515
1545
  }
1516
- }
1517
- const result = await ctx.dataEngine.aggregate(objectName, {
1518
- where,
1519
- groupBy,
1520
- aggregations: aggregations.map((a) => ({
1521
- function: a.function,
1522
- field: a.field,
1523
- alias: a.alias
1524
- }))
1525
- });
1526
- return JSON.stringify(result);
1527
- };
1528
- }
1529
- function registerDataTools(registry, context) {
1530
- registry.register(LIST_OBJECTS_TOOL, createListObjectsHandler(context));
1531
- registry.register(DESCRIBE_OBJECT_TOOL, createDescribeObjectHandler(context));
1532
- registry.register(QUERY_RECORDS_TOOL, createQueryRecordsHandler(context));
1533
- registry.register(GET_RECORD_TOOL, createGetRecordHandler(context));
1534
- registry.register(AGGREGATE_DATA_TOOL, createAggregateDataHandler(context));
1535
- }
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"]
1546
+ },
1547
+ {
1548
+ method: "POST",
1549
+ path: "/api/v1/ai/conversations/:id/messages",
1550
+ description: "Add message to a conversation",
1551
+ auth: true,
1552
+ permissions: ["ai:conversations"],
1553
+ handler: async (req) => {
1554
+ const id = req.params?.id;
1555
+ if (!id) {
1556
+ return { status: 400, body: { error: "conversation id is required" } };
1577
1557
  }
1578
- },
1579
- enableFeatures: {
1580
- type: "object",
1581
- description: "Object capability flags",
1582
- properties: {
1583
- trackHistory: { type: "boolean" },
1584
- apiEnabled: { type: "boolean" }
1558
+ const message = req.body;
1559
+ const validationError = validateMessage(message);
1560
+ if (validationError) {
1561
+ return { status: 400, body: { error: validationError } };
1562
+ }
1563
+ try {
1564
+ if (req.user?.userId) {
1565
+ const existing = await conversationService.get(id);
1566
+ if (!existing) {
1567
+ return { status: 404, body: { error: `Conversation "${id}" not found` } };
1568
+ }
1569
+ if (existing.userId && existing.userId !== req.user.userId) {
1570
+ return { status: 403, body: { error: "You do not have access to this conversation" } };
1571
+ }
1572
+ }
1573
+ const conversation = await conversationService.addMessage(id, message);
1574
+ return { status: 200, body: conversation };
1575
+ } catch (err) {
1576
+ const msg = err instanceof Error ? err.message : String(err);
1577
+ if (msg.includes("not found")) {
1578
+ return { status: 404, body: { error: msg } };
1579
+ }
1580
+ logger.error("[AI Route] POST /conversations/:id/messages error", err instanceof Error ? err : void 0);
1581
+ return { status: 500, body: { error: "Internal AI service error" } };
1585
1582
  }
1586
1583
  }
1587
1584
  },
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_]*$"
1585
+ {
1586
+ method: "DELETE",
1587
+ path: "/api/v1/ai/conversations/:id",
1588
+ description: "Delete a conversation",
1589
+ auth: true,
1590
+ permissions: ["ai:conversations"],
1591
+ handler: async (req) => {
1592
+ const id = req.params?.id;
1593
+ if (!id) {
1594
+ return { status: 400, body: { error: "conversation id is required" } };
1595
+ }
1596
+ try {
1597
+ if (req.user?.userId) {
1598
+ const existing = await conversationService.get(id);
1599
+ if (!existing) {
1600
+ return { status: 404, body: { error: `Conversation "${id}" not found` } };
1601
+ }
1602
+ if (existing.userId && existing.userId !== req.user.userId) {
1603
+ return { status: 403, body: { error: "You do not have access to this conversation" } };
1639
1604
  }
1640
1605
  }
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" }
1606
+ await conversationService.delete(id);
1607
+ return { status: 204 };
1608
+ } catch (err) {
1609
+ logger.error("[AI Route] DELETE /conversations/:id error", err instanceof Error ? err : void 0);
1610
+ return { status: 500, body: { error: "Internal AI service error" } };
1680
1611
  }
1681
1612
  }
1682
- },
1683
- required: ["objectName", "fieldName", "changes"],
1684
- additionalProperties: false
1685
- }
1686
- });
1613
+ }
1614
+ ];
1615
+ }
1687
1616
 
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
1617
+ // src/routes/agent-routes.ts
1618
+ var ALLOWED_AGENT_ROLES = /* @__PURE__ */ new Set(["user", "assistant"]);
1619
+ function validateAgentMessage(raw) {
1620
+ if (typeof raw !== "object" || raw === null) {
1621
+ return "each message must be an object";
1714
1622
  }
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
1623
+ const msg = raw;
1624
+ if (typeof msg.role !== "string" || !ALLOWED_AGENT_ROLES.has(msg.role)) {
1625
+ return `message.role must be one of ${[...ALLOWED_AGENT_ROLES].map((r) => `"${r}"`).join(", ")} for agent chat`;
1738
1626
  }
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)"
1627
+ const allowEmpty = msg.role === "assistant";
1628
+ return validateMessageContent(msg, { allowEmptyContent: allowEmpty });
1629
+ }
1630
+ function buildAgentRoutes(aiService, agentRuntime, logger) {
1631
+ return [
1632
+ // ── List active agents ──────────────────────────────────────
1633
+ {
1634
+ method: "GET",
1635
+ path: "/api/v1/ai/agents",
1636
+ description: "List all active AI agents",
1637
+ auth: true,
1638
+ permissions: ["ai:chat"],
1639
+ handler: async () => {
1640
+ try {
1641
+ const agents = await agentRuntime.listAgents();
1642
+ return { status: 200, body: { agents } };
1643
+ } catch (err) {
1644
+ logger.error(
1645
+ "[AI Route] /agents list error",
1646
+ err instanceof Error ? err : void 0
1647
+ );
1648
+ return { status: 500, body: { error: "Internal AI service error" } };
1649
+ }
1755
1650
  }
1756
1651
  },
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' });
1652
+ // ── Chat with a specific agent ──────────────────────────────
1653
+ //
1654
+ // Dual-mode endpoint matching the general chat route behaviour:
1655
+ // • `stream !== false` → Vercel Data Stream Protocol (SSE)
1656
+ // • `stream === false` → JSON response (legacy)
1657
+ //
1658
+ {
1659
+ method: "POST",
1660
+ path: "/api/v1/ai/agents/:agentName/chat",
1661
+ description: "Chat with a specific AI agent (supports Vercel AI Data Stream Protocol)",
1662
+ auth: true,
1663
+ permissions: ["ai:chat", "ai:agents"],
1664
+ handler: async (req) => {
1665
+ const agentName = req.params?.agentName;
1666
+ if (!agentName) {
1667
+ return { status: 400, body: { error: "agentName parameter is required" } };
1794
1668
  }
1795
- if (!isSnakeCase(f.name)) {
1796
- return JSON.stringify({ error: `Invalid field name "${f.name}". Must be snake_case.` });
1669
+ const body = req.body ?? {};
1670
+ const {
1671
+ messages: rawMessages,
1672
+ context: chatContext,
1673
+ options: extraOptions
1674
+ } = body;
1675
+ if (!Array.isArray(rawMessages) || rawMessages.length === 0) {
1676
+ return { status: 400, body: { error: "messages array is required" } };
1797
1677
  }
1798
- if (seenNames.has(f.name)) {
1799
- return JSON.stringify({ error: `Duplicate field name "${f.name}" in initial fields` });
1678
+ for (const msg of rawMessages) {
1679
+ const err = validateAgentMessage(msg);
1680
+ if (err) return { status: 400, body: { error: err } };
1681
+ }
1682
+ const agent = await agentRuntime.loadAgent(agentName);
1683
+ if (!agent) {
1684
+ return { status: 404, body: { error: `Agent "${agentName}" not found` } };
1685
+ }
1686
+ if (!agent.active) {
1687
+ return { status: 403, body: { error: `Agent "${agentName}" is not active` } };
1688
+ }
1689
+ try {
1690
+ const systemMessages = agentRuntime.buildSystemMessages(agent, chatContext);
1691
+ const agentOptions = agentRuntime.buildRequestOptions(
1692
+ agent,
1693
+ aiService.toolRegistry.getAll()
1694
+ );
1695
+ const safeOverrides = {};
1696
+ if (extraOptions) {
1697
+ const ALLOWED_KEYS = /* @__PURE__ */ new Set(["temperature", "maxTokens", "stop"]);
1698
+ for (const key of Object.keys(extraOptions)) {
1699
+ if (ALLOWED_KEYS.has(key)) {
1700
+ safeOverrides[key] = extraOptions[key];
1701
+ }
1702
+ }
1703
+ }
1704
+ const mergedOptions = { ...agentOptions, ...safeOverrides };
1705
+ const fullMessages = [
1706
+ ...systemMessages,
1707
+ ...rawMessages.map((m) => normalizeMessage(m))
1708
+ ];
1709
+ const chatWithToolsOptions = {
1710
+ ...mergedOptions,
1711
+ maxIterations: agent.planning?.maxIterations
1712
+ };
1713
+ const wantStream = body.stream !== false;
1714
+ if (wantStream) {
1715
+ if (!aiService.streamChatWithTools) {
1716
+ return { status: 501, body: { error: "Streaming is not supported by the configured AI service" } };
1717
+ }
1718
+ const events = aiService.streamChatWithTools(fullMessages, chatWithToolsOptions);
1719
+ return {
1720
+ status: 200,
1721
+ stream: true,
1722
+ vercelDataStream: true,
1723
+ contentType: "text/event-stream",
1724
+ headers: {
1725
+ "Content-Type": "text/event-stream",
1726
+ "Cache-Control": "no-cache",
1727
+ "Connection": "keep-alive",
1728
+ "x-vercel-ai-ui-message-stream": "v1"
1729
+ },
1730
+ events: encodeVercelDataStream(events)
1731
+ };
1732
+ }
1733
+ const result = await aiService.chatWithTools(fullMessages, chatWithToolsOptions);
1734
+ return { status: 200, body: result };
1735
+ } catch (err) {
1736
+ logger.error(
1737
+ "[AI Route] /agents/:agentName/chat error",
1738
+ err instanceof Error ? err : void 0
1739
+ );
1740
+ return { status: 500, body: { error: "Internal AI service error" } };
1800
1741
  }
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
1742
  }
1808
1743
  }
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
- };
1744
+ ];
1822
1745
  }
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.` });
1746
+
1747
+ // src/routes/tool-routes.ts
1748
+ function extractOutputValue(output) {
1749
+ if (!output) return "";
1750
+ if (typeof output === "string") return output;
1751
+ if (typeof output === "object" && "value" in output) {
1752
+ return String(output.value ?? "");
1753
+ }
1754
+ return JSON.stringify(output);
1755
+ }
1756
+ function buildToolRoutes(aiService, logger) {
1757
+ return [
1758
+ // ── List registered tools ──────────────────────────────────────
1759
+ {
1760
+ method: "GET",
1761
+ path: "/api/v1/ai/tools",
1762
+ description: "List all registered AI tools",
1763
+ auth: true,
1764
+ permissions: ["ai:tools"],
1765
+ handler: async () => {
1766
+ try {
1767
+ const tools = aiService.toolRegistry.getAll();
1768
+ return {
1769
+ status: 200,
1770
+ body: {
1771
+ tools: tools.map((t) => ({
1772
+ name: t.name,
1773
+ description: t.description,
1774
+ category: t.category
1775
+ }))
1776
+ }
1777
+ };
1778
+ } catch (err) {
1779
+ logger.error(
1780
+ "[AI Route] /tools list error",
1781
+ err instanceof Error ? err : void 0
1782
+ );
1783
+ return { status: 500, body: { error: "Internal AI service error" } };
1784
+ }
1785
+ }
1786
+ },
1787
+ // ── Execute a tool ──────────────────────────────────────────────
1788
+ //
1789
+ // Executes a tool with the provided parameters.
1790
+ // This is intended for testing/playground use.
1791
+ //
1792
+ {
1793
+ method: "POST",
1794
+ path: "/api/v1/ai/tools/:toolName/execute",
1795
+ description: "Execute a tool with parameters (playground/testing)",
1796
+ auth: true,
1797
+ permissions: ["ai:tools", "ai:execute"],
1798
+ handler: async (req) => {
1799
+ const toolName = req.params?.toolName;
1800
+ if (!toolName) {
1801
+ return { status: 400, body: { error: "toolName parameter is required" } };
1802
+ }
1803
+ const body = req.body ?? {};
1804
+ const { parameters } = body;
1805
+ if (!parameters || typeof parameters !== "object") {
1806
+ return { status: 400, body: { error: "parameters object is required" } };
1807
+ }
1808
+ try {
1809
+ if (!aiService.toolRegistry.has(toolName)) {
1810
+ return { status: 404, body: { error: `Tool "${toolName}" not found` } };
1811
+ }
1812
+ const startTime = Date.now();
1813
+ const toolCallPart = {
1814
+ type: "tool-call",
1815
+ toolCallId: `playground-${Date.now()}`,
1816
+ toolName,
1817
+ input: parameters
1818
+ };
1819
+ const result = await aiService.toolRegistry.execute(toolCallPart);
1820
+ const duration = Date.now() - startTime;
1821
+ if (result.isError) {
1822
+ const errorMsg = extractOutputValue(result.output);
1823
+ logger.error(
1824
+ `[AI Route] Tool execution error: ${toolName}`,
1825
+ new Error(errorMsg)
1826
+ );
1827
+ return {
1828
+ status: 500,
1829
+ body: {
1830
+ error: errorMsg,
1831
+ duration
1832
+ }
1833
+ };
1834
+ }
1835
+ return {
1836
+ status: 200,
1837
+ body: {
1838
+ result: extractOutputValue(result.output),
1839
+ duration,
1840
+ toolName
1841
+ }
1842
+ };
1843
+ } catch (err) {
1844
+ logger.error(
1845
+ "[AI Route] /tools/:toolName/execute error",
1846
+ err instanceof Error ? err : void 0
1847
+ );
1848
+ return { status: 500, body: { error: "Internal AI service error" } };
1842
1849
  }
1843
1850
  }
1844
1851
  }
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 } : {}
1852
+ ];
1853
+ }
1854
+
1855
+ // src/conversation/objectql-conversation-service.ts
1856
+ import { randomUUID } from "crypto";
1857
+ var CONVERSATIONS_OBJECT = "ai_conversations";
1858
+ var MESSAGES_OBJECT = "ai_messages";
1859
+ var CONVERSATION_ORDER = [
1860
+ { field: "created_at", order: "asc" },
1861
+ { field: "id", order: "asc" }
1862
+ ];
1863
+ var MESSAGE_ORDER = [
1864
+ { field: "created_at", order: "asc" },
1865
+ { field: "id", order: "asc" }
1866
+ ];
1867
+ var ObjectQLConversationService = class {
1868
+ constructor(engine) {
1869
+ this.engine = engine;
1870
+ }
1871
+ async create(options = {}) {
1872
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1873
+ const id = `conv_${randomUUID()}`;
1874
+ const record = {
1875
+ id,
1876
+ title: options.title ?? null,
1877
+ agent_id: options.agentId ?? null,
1878
+ user_id: options.userId ?? null,
1879
+ metadata: options.metadata ? JSON.stringify(options.metadata) : null,
1880
+ created_at: now,
1881
+ updated_at: now
1860
1882
  };
1861
- const updatedFields = { ...def.fields ?? {}, [name]: fieldDef };
1862
- await ctx.metadataService.register("object", objectName, {
1863
- ...def,
1864
- fields: updatedFields
1883
+ await this.engine.insert(CONVERSATIONS_OBJECT, record);
1884
+ return {
1885
+ id,
1886
+ title: options.title,
1887
+ agentId: options.agentId,
1888
+ userId: options.userId,
1889
+ messages: [],
1890
+ createdAt: now,
1891
+ updatedAt: now,
1892
+ metadata: options.metadata
1893
+ };
1894
+ }
1895
+ async get(conversationId) {
1896
+ const row = await this.engine.findOne(CONVERSATIONS_OBJECT, {
1897
+ where: { id: conversationId }
1865
1898
  });
1866
- return JSON.stringify({
1867
- objectName,
1868
- fieldName: name,
1869
- fieldType: type
1899
+ if (!row) return null;
1900
+ const messages = await this.engine.find(MESSAGES_OBJECT, {
1901
+ where: { conversation_id: conversationId },
1902
+ orderBy: MESSAGE_ORDER
1870
1903
  });
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}"` });
1904
+ return this.toConversation(row, messages);
1905
+ }
1906
+ async list(options = {}) {
1907
+ const where = {};
1908
+ if (options.userId) where.user_id = options.userId;
1909
+ if (options.agentId) where.agent_id = options.agentId;
1910
+ if (options.cursor) {
1911
+ const cursorRow = await this.engine.findOne(CONVERSATIONS_OBJECT, {
1912
+ where: { id: options.cursor },
1913
+ fields: ["created_at", "id"]
1914
+ });
1915
+ if (cursorRow) {
1916
+ where.$or = [
1917
+ { created_at: { $gt: cursorRow.created_at } },
1918
+ { created_at: cursorRow.created_at, id: { $gt: cursorRow.id } }
1919
+ ];
1920
+ }
1892
1921
  }
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
1922
+ const rows = await this.engine.find(CONVERSATIONS_OBJECT, {
1923
+ where: Object.keys(where).length > 0 ? where : void 0,
1924
+ orderBy: CONVERSATION_ORDER,
1925
+ limit: options.limit && options.limit > 0 ? options.limit : void 0
1899
1926
  });
1900
- return JSON.stringify({
1901
- objectName,
1902
- fieldName,
1903
- updatedProperties: Object.keys(changes)
1927
+ const conversations = await Promise.all(
1928
+ rows.map(async (row) => {
1929
+ const messages = await this.engine.find(MESSAGES_OBJECT, {
1930
+ where: { conversation_id: row.id },
1931
+ orderBy: MESSAGE_ORDER
1932
+ });
1933
+ return this.toConversation(row, messages);
1934
+ })
1935
+ );
1936
+ return conversations;
1937
+ }
1938
+ async addMessage(conversationId, message) {
1939
+ const row = await this.engine.findOne(CONVERSATIONS_OBJECT, {
1940
+ where: { id: conversationId }
1904
1941
  });
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}"` });
1942
+ if (!row) {
1943
+ throw new Error(`Conversation "${conversationId}" not found`);
1926
1944
  }
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
- }));
1945
+ const now = (/* @__PURE__ */ new Date()).toISOString();
1946
+ const msgId = `msg_${randomUUID()}`;
1947
+ let contentStr;
1948
+ let toolCallsJson = null;
1949
+ let toolCallId = null;
1950
+ if (message.role === "system" || message.role === "user") {
1951
+ contentStr = typeof message.content === "string" ? message.content : JSON.stringify(message.content);
1952
+ } else if (message.role === "assistant") {
1953
+ if (typeof message.content === "string") {
1954
+ contentStr = message.content;
1955
+ } else {
1956
+ const parts = message.content;
1957
+ const textParts = parts.filter((p) => p.type === "text").map((p) => p.text);
1958
+ const toolCalls = parts.filter((p) => p.type === "tool-call");
1959
+ contentStr = textParts.join("");
1960
+ if (toolCalls.length > 0) toolCallsJson = JSON.stringify(toolCalls);
1955
1961
  }
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
- );
1962
+ } else if (message.role === "tool") {
1963
+ contentStr = JSON.stringify(message.content);
1964
+ const firstResult = Array.isArray(message.content) ? message.content[0] : void 0;
1965
+ if (firstResult && "toolCallId" in firstResult) toolCallId = firstResult.toolCallId;
1966
+ } else {
1967
+ contentStr = "";
1963
1968
  }
1964
- return JSON.stringify({
1965
- objects: result,
1966
- totalCount: result.length
1969
+ await this.engine.insert(MESSAGES_OBJECT, {
1970
+ id: msgId,
1971
+ conversation_id: conversationId,
1972
+ role: message.role,
1973
+ content: contentStr,
1974
+ tool_calls: toolCallsJson,
1975
+ tool_call_id: toolCallId,
1976
+ created_at: now
1967
1977
  });
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
+ await this.engine.update(CONVERSATIONS_OBJECT, { id: conversationId, updated_at: now }, {
1979
+ where: { id: conversationId }
1980
+ });
1981
+ return await this.get(conversationId);
1982
+ }
1983
+ async delete(conversationId) {
1984
+ await this.engine.delete(MESSAGES_OBJECT, {
1985
+ where: { conversation_id: conversationId },
1986
+ multi: true
1987
+ });
1988
+ await this.engine.delete(CONVERSATIONS_OBJECT, {
1989
+ where: { id: conversationId }
1990
+ });
1991
+ }
1992
+ // ── Private helpers ──────────────────────────────────────────────
1993
+ /**
1994
+ * Safely parse a JSON string, returning `undefined` on failure.
1995
+ */
1996
+ safeParse(value, fallback) {
1997
+ if (!value) return void 0;
1998
+ try {
1999
+ return JSON.parse(value);
2000
+ } catch {
2001
+ return fallback;
1978
2002
  }
1979
- const objectDef = await ctx.metadataService.getObject(objectName);
1980
- if (!objectDef) {
1981
- return JSON.stringify({ error: `Object "${objectName}" not found` });
2003
+ }
2004
+ /**
2005
+ * Map a database row + message rows to an AIConversation.
2006
+ */
2007
+ toConversation(row, messageRows) {
2008
+ return {
2009
+ id: row.id,
2010
+ title: row.title ?? void 0,
2011
+ agentId: row.agent_id ?? void 0,
2012
+ userId: row.user_id ?? void 0,
2013
+ messages: messageRows.map((m) => this.toMessage(m)),
2014
+ createdAt: row.created_at,
2015
+ updatedAt: row.updated_at,
2016
+ metadata: this.safeParse(row.metadata)
2017
+ };
2018
+ }
2019
+ /**
2020
+ * Map a database row to a ModelMessage.
2021
+ */
2022
+ toMessage(row) {
2023
+ switch (row.role) {
2024
+ case "system":
2025
+ return { role: "system", content: row.content };
2026
+ case "user":
2027
+ return { role: "user", content: row.content };
2028
+ case "assistant": {
2029
+ const toolCalls = this.safeParse(row.tool_calls);
2030
+ if (toolCalls && toolCalls.length > 0) {
2031
+ const content = [];
2032
+ if (row.content) content.push({ type: "text", text: row.content });
2033
+ content.push(...toolCalls);
2034
+ return { role: "assistant", content };
2035
+ }
2036
+ return { role: "assistant", content: row.content };
2037
+ }
2038
+ case "tool": {
2039
+ const toolResults = this.safeParse(row.content);
2040
+ if (toolResults && toolResults.length > 0 && toolResults[0]?.type === "tool-result") {
2041
+ return { role: "tool", content: toolResults };
2042
+ }
2043
+ return {
2044
+ role: "tool",
2045
+ content: [{
2046
+ type: "tool-result",
2047
+ toolCallId: row.tool_call_id ?? "",
2048
+ toolName: "unknown",
2049
+ output: { type: "text", value: row.content }
2050
+ }]
2051
+ };
2052
+ }
2053
+ default:
2054
+ return { role: "user", content: row.content };
1982
2055
  }
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
- }
2056
+ }
2057
+ };
2058
+
2059
+ // src/objects/ai-conversation.object.ts
2060
+ import { ObjectSchema, Field } from "@objectstack/spec/data";
2061
+ var AiConversationObject = ObjectSchema.create({
2062
+ namespace: "ai",
2063
+ name: "conversations",
2064
+ label: "AI Conversation",
2065
+ pluralLabel: "AI Conversations",
2066
+ icon: "message-square",
2067
+ isSystem: true,
2068
+ description: "Persistent AI conversation metadata",
2069
+ fields: {
2070
+ id: Field.text({
2071
+ label: "Conversation ID",
2072
+ required: true,
2073
+ readonly: true
2074
+ }),
2075
+ title: Field.text({
2076
+ label: "Title",
2077
+ required: false,
2078
+ maxLength: 500,
2079
+ description: "Conversation title or summary"
2080
+ }),
2081
+ agent_id: Field.text({
2082
+ label: "Agent ID",
2083
+ required: false,
2084
+ maxLength: 255,
2085
+ description: "Associated AI agent identifier"
2086
+ }),
2087
+ user_id: Field.text({
2088
+ label: "User ID",
2089
+ required: false,
2090
+ maxLength: 255,
2091
+ description: "User who owns the conversation"
2092
+ }),
2093
+ metadata: Field.textarea({
2094
+ label: "Metadata",
2095
+ required: false,
2096
+ description: "JSON-serialized conversation metadata"
2097
+ }),
2098
+ created_at: Field.datetime({
2099
+ label: "Created At",
2100
+ required: true,
2101
+ defaultValue: "NOW()",
2102
+ readonly: true
2103
+ }),
2104
+ updated_at: Field.datetime({
2105
+ label: "Updated At",
2106
+ required: true,
2107
+ defaultValue: "NOW()",
2108
+ readonly: true
2109
+ })
2110
+ },
2111
+ indexes: [
2112
+ { fields: ["user_id"] },
2113
+ { fields: ["agent_id"] },
2114
+ { fields: ["created_at"] }
2115
+ ],
2116
+ enable: {
2117
+ trackHistory: false,
2118
+ searchable: false,
2119
+ apiEnabled: true,
2120
+ apiMethods: ["get", "list", "create", "update", "delete"],
2121
+ trash: false,
2122
+ mru: false
2123
+ }
2124
+ });
2125
+
2126
+ // src/objects/ai-message.object.ts
2127
+ import { ObjectSchema as ObjectSchema2, Field as Field2 } from "@objectstack/spec/data";
2128
+ var AiMessageObject = ObjectSchema2.create({
2129
+ namespace: "ai",
2130
+ name: "messages",
2131
+ label: "AI Message",
2132
+ pluralLabel: "AI Messages",
2133
+ icon: "message-circle",
2134
+ isSystem: true,
2135
+ description: "Individual messages within AI conversations",
2136
+ fields: {
2137
+ id: Field2.text({
2138
+ label: "Message ID",
2139
+ required: true,
2140
+ readonly: true
2141
+ }),
2142
+ conversation_id: Field2.text({
2143
+ label: "Conversation ID",
2144
+ required: true,
2145
+ description: "Foreign key to ai_conversations"
2146
+ }),
2147
+ role: Field2.select({
2148
+ label: "Role",
2149
+ required: true,
2150
+ options: [
2151
+ { label: "System", value: "system" },
2152
+ { label: "User", value: "user" },
2153
+ { label: "Assistant", value: "assistant" },
2154
+ { label: "Tool", value: "tool" }
2155
+ ]
2156
+ }),
2157
+ content: Field2.textarea({
2158
+ label: "Content",
2159
+ required: true,
2160
+ description: "Message content"
2161
+ }),
2162
+ tool_calls: Field2.textarea({
2163
+ label: "Tool Calls",
2164
+ required: false,
2165
+ description: "JSON-serialized tool calls (when role=assistant)"
2166
+ }),
2167
+ tool_call_id: Field2.text({
2168
+ label: "Tool Call ID",
2169
+ required: false,
2170
+ maxLength: 255,
2171
+ description: "ID of the tool call this message responds to (when role=tool)"
2172
+ }),
2173
+ created_at: Field2.datetime({
2174
+ label: "Created At",
2175
+ required: true,
2176
+ defaultValue: "NOW()",
2177
+ readonly: true
2178
+ })
2179
+ },
2180
+ indexes: [
2181
+ { fields: ["conversation_id"] },
2182
+ { fields: ["conversation_id", "created_at"] }
2183
+ ],
2184
+ enable: {
2185
+ trackHistory: false,
2186
+ searchable: false,
2187
+ apiEnabled: true,
2188
+ apiMethods: ["get", "list", "create"],
2189
+ trash: false,
2190
+ mru: false
2191
+ }
2192
+ });
2193
+
2194
+ // src/plugin.ts
2195
+ init_data_tools();
2196
+ init_metadata_tools();
2009
2197
 
2010
2198
  // src/agent-runtime.ts
2011
2199
  import { AgentSchema } from "@objectstack/spec/ai";
@@ -2179,8 +2367,8 @@ Capabilities:
2179
2367
  - Describe the full schema of a specific object
2180
2368
 
2181
2369
  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.
2370
+ 1. Before creating a new object, use list_objects to check if a similar one already exists.
2371
+ 2. Before modifying or deleting fields, use describe_object to understand the current schema.
2184
2372
  3. Always use snake_case for object names and field names (e.g. project_task, due_date).
2185
2373
  4. Suggest meaningful field types based on the user's description (e.g. "deadline" \u2192 date, "active" \u2192 boolean).
2186
2374
  5. When creating objects, propose a reasonable set of initial fields based on the entity type.
@@ -2200,8 +2388,8 @@ Guidelines:
2200
2388
  { type: "action", name: "add_field", description: "Add a field to an existing object" },
2201
2389
  { type: "action", name: "modify_field", description: "Modify an existing field definition" },
2202
2390
  { 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" }
2391
+ { type: "query", name: "list_objects", description: "List all data objects" },
2392
+ { type: "query", name: "describe_object", description: "Describe an object schema" }
2205
2393
  ],
2206
2394
  active: true,
2207
2395
  visibility: "global",
@@ -2452,8 +2640,8 @@ var AIServicePlugin = class {
2452
2640
  setupNav.contribute({
2453
2641
  areaId: "area_ai",
2454
2642
  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 }
2643
+ { id: "nav_ai_conversations", type: "object", label: "Conversations", objectName: "conversations", icon: "message-square", order: 10 },
2644
+ { id: "nav_ai_messages", type: "object", label: "Messages", objectName: "messages", icon: "messages-square", order: 20 }
2457
2645
  ]
2458
2646
  });
2459
2647
  ctx.logger.info("[AI] Navigation items contributed to Setup App");
@@ -2467,20 +2655,40 @@ var AIServicePlugin = class {
2467
2655
  let metadataService;
2468
2656
  try {
2469
2657
  metadataService = ctx.getService("metadata");
2470
- } catch {
2658
+ console.log("[AI Plugin] Retrieved metadata service:", !!metadataService, "has getRegisteredTypes:", typeof metadataService?.getRegisteredTypes);
2659
+ } catch (e) {
2660
+ console.log("[AI] Metadata service not available:", e.message);
2471
2661
  ctx.logger.debug("[AI] Metadata service not available");
2472
2662
  }
2473
2663
  try {
2474
2664
  const dataEngine = ctx.getService("data");
2475
- if (dataEngine && metadataService) {
2476
- registerDataTools(this.service.toolRegistry, { dataEngine, metadataService });
2665
+ if (dataEngine) {
2666
+ registerDataTools(this.service.toolRegistry, { dataEngine });
2477
2667
  ctx.logger.info("[AI] Built-in data tools registered");
2478
- const agentExists = typeof metadataService.exists === "function" ? await metadataService.exists("agent", DATA_CHAT_AGENT.name) : false;
2479
- if (!agentExists) {
2480
- await metadataService.register("agent", DATA_CHAT_AGENT.name, DATA_CHAT_AGENT);
2481
- ctx.logger.info("[AI] data_chat agent registered");
2482
- } else {
2483
- ctx.logger.debug("[AI] data_chat agent already exists, skipping auto-registration");
2668
+ if (metadataService) {
2669
+ const { DATA_TOOL_DEFINITIONS: DATA_TOOL_DEFINITIONS2 } = await Promise.resolve().then(() => (init_data_tools(), data_tools_exports));
2670
+ for (const toolDef of DATA_TOOL_DEFINITIONS2) {
2671
+ const toolExists = typeof metadataService.exists === "function" ? await metadataService.exists("tool", toolDef.name) : false;
2672
+ if (!toolExists) {
2673
+ await metadataService.register("tool", toolDef.name, toolDef);
2674
+ }
2675
+ }
2676
+ ctx.logger.info(`[AI] ${DATA_TOOL_DEFINITIONS2.length} data tools registered as metadata`);
2677
+ }
2678
+ if (metadataService) {
2679
+ try {
2680
+ const agentExists = typeof metadataService.exists === "function" ? await metadataService.exists("agent", DATA_CHAT_AGENT.name) : false;
2681
+ if (!agentExists) {
2682
+ await metadataService.register("agent", DATA_CHAT_AGENT.name, DATA_CHAT_AGENT);
2683
+ console.log("[AI] Registered data_chat agent to metadataService");
2684
+ ctx.logger.info("[AI] data_chat agent registered");
2685
+ } else {
2686
+ console.log("[AI] data_chat agent already exists, skipping");
2687
+ ctx.logger.debug("[AI] data_chat agent already exists, skipping auto-registration");
2688
+ }
2689
+ } catch (err) {
2690
+ ctx.logger.warn("[AI] Failed to register data_chat agent", err instanceof Error ? { error: err.message, stack: err.stack } : { error: String(err) });
2691
+ }
2484
2692
  }
2485
2693
  }
2486
2694
  } catch {
@@ -2490,12 +2698,26 @@ var AIServicePlugin = class {
2490
2698
  try {
2491
2699
  registerMetadataTools(this.service.toolRegistry, { metadataService });
2492
2700
  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");
2701
+ const { METADATA_TOOL_DEFINITIONS: METADATA_TOOL_DEFINITIONS2 } = await Promise.resolve().then(() => (init_metadata_tools(), metadata_tools_exports));
2702
+ for (const toolDef of METADATA_TOOL_DEFINITIONS2) {
2703
+ const toolExists = typeof metadataService.exists === "function" ? await metadataService.exists("tool", toolDef.name) : false;
2704
+ if (!toolExists) {
2705
+ await metadataService.register("tool", toolDef.name, toolDef);
2706
+ }
2707
+ }
2708
+ ctx.logger.info(`[AI] ${METADATA_TOOL_DEFINITIONS2.length} metadata tools registered as metadata`);
2709
+ try {
2710
+ const agentExists = typeof metadataService.exists === "function" ? await metadataService.exists("agent", METADATA_ASSISTANT_AGENT.name) : false;
2711
+ if (!agentExists) {
2712
+ await metadataService.register("agent", METADATA_ASSISTANT_AGENT.name, METADATA_ASSISTANT_AGENT);
2713
+ console.log("[AI] Registered metadata_assistant agent to metadataService");
2714
+ ctx.logger.info("[AI] metadata_assistant agent registered");
2715
+ } else {
2716
+ console.log("[AI] metadata_assistant agent already exists, skipping");
2717
+ ctx.logger.debug("[AI] metadata_assistant agent already exists, skipping auto-registration");
2718
+ }
2719
+ } catch (err) {
2720
+ ctx.logger.warn("[AI] Failed to register metadata_assistant agent", err instanceof Error ? { error: err.message, stack: err.stack } : { error: String(err) });
2499
2721
  }
2500
2722
  } catch (err) {
2501
2723
  ctx.logger.debug("[AI] Failed to register metadata tools", err instanceof Error ? err : void 0);
@@ -2503,14 +2725,15 @@ var AIServicePlugin = class {
2503
2725
  }
2504
2726
  await ctx.trigger("ai:ready", this.service);
2505
2727
  const routes = buildAIRoutes(this.service, this.service.conversationService, ctx.logger);
2506
- try {
2507
- const metadataService2 = ctx.getService("metadata");
2508
- if (metadataService2) {
2509
- const agentRuntime = new AgentRuntime(metadataService2);
2510
- const agentRoutes = buildAgentRoutes(this.service, agentRuntime, ctx.logger);
2511
- routes.push(...agentRoutes);
2512
- }
2513
- } catch {
2728
+ const toolRoutes = buildToolRoutes(this.service, ctx.logger);
2729
+ routes.push(...toolRoutes);
2730
+ ctx.logger.info(`[AI] Tool routes registered (${toolRoutes.length} routes)`);
2731
+ if (metadataService) {
2732
+ const agentRuntime = new AgentRuntime(metadataService);
2733
+ const agentRoutes = buildAgentRoutes(this.service, agentRuntime, ctx.logger);
2734
+ routes.push(...agentRoutes);
2735
+ ctx.logger.info(`[AI] Agent routes registered (${agentRoutes.length} routes)`);
2736
+ } else {
2514
2737
  ctx.logger.debug("[AI] Metadata service not available, skipping agent routes");
2515
2738
  }
2516
2739
  await ctx.trigger("ai:routes", routes);
@@ -2526,6 +2749,11 @@ var AIServicePlugin = class {
2526
2749
  this.service = void 0;
2527
2750
  }
2528
2751
  };
2752
+
2753
+ // src/index.ts
2754
+ init_data_tools();
2755
+ init_metadata_tools();
2756
+ init_metadata_tools();
2529
2757
  export {
2530
2758
  AIService,
2531
2759
  AIServicePlugin,
@@ -2544,12 +2772,13 @@ export {
2544
2772
  addFieldTool,
2545
2773
  buildAIRoutes,
2546
2774
  buildAgentRoutes,
2775
+ buildToolRoutes,
2547
2776
  createObjectTool,
2548
2777
  deleteFieldTool,
2549
- describeMetadataObjectTool,
2778
+ describeObjectTool,
2550
2779
  encodeStreamPart,
2551
2780
  encodeVercelDataStream,
2552
- listMetadataObjectsTool,
2781
+ listObjectsTool,
2553
2782
  modifyFieldTool,
2554
2783
  registerDataTools,
2555
2784
  registerMetadataTools