@objectstack/service-ai 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,364 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
4
+ import type { IDataEngine } from '@objectstack/spec/contracts';
5
+ import type { AIMessage } from '@objectstack/spec/contracts';
6
+ import { ObjectQLConversationService } from '../conversation/objectql-conversation-service.js';
7
+
8
+ // ─────────────────────────────────────────────────────────────────
9
+ // In-memory IDataEngine stub (mimics driver-memory behavior)
10
+ // ─────────────────────────────────────────────────────────────────
11
+
12
+ function createMemoryEngine(): IDataEngine {
13
+ const tables = new Map<string, any[]>();
14
+
15
+ const getTable = (name: string) => {
16
+ if (!tables.has(name)) tables.set(name, []);
17
+ return tables.get(name)!;
18
+ };
19
+
20
+ /** Evaluate a single filter condition against a row. */
21
+ const matchesCondition = (row: any, where: Record<string, any>): boolean => {
22
+ for (const [key, value] of Object.entries(where)) {
23
+ if (key === '$or') {
24
+ // At least one branch must match
25
+ if (!Array.isArray(value) || !value.some(branch => matchesCondition(row, branch))) {
26
+ return false;
27
+ }
28
+ } else if (typeof value === 'object' && value !== null && '$gt' in value) {
29
+ if (!(row[key] > value.$gt)) return false;
30
+ } else if (row[key] !== value) {
31
+ return false;
32
+ }
33
+ }
34
+ return true;
35
+ };
36
+
37
+ return {
38
+ find: async (objectName, query?) => {
39
+ let rows = [...getTable(objectName)];
40
+ if (query?.where) {
41
+ rows = rows.filter(row => matchesCondition(row, query.where as Record<string, any>));
42
+ }
43
+ if (query?.orderBy && query.orderBy.length > 0) {
44
+ rows.sort((a, b) => {
45
+ for (const sort of query.orderBy!) {
46
+ const field = (sort as any).field;
47
+ const dir = (sort as any).order === 'desc' ? -1 : 1;
48
+ if (a[field] < b[field]) return -dir;
49
+ if (a[field] > b[field]) return dir;
50
+ }
51
+ return 0;
52
+ });
53
+ }
54
+ if (query?.limit) {
55
+ rows = rows.slice(0, query.limit);
56
+ }
57
+ return rows;
58
+ },
59
+ findOne: async (objectName, query?) => {
60
+ let rows = [...getTable(objectName)];
61
+ if (query?.where) {
62
+ rows = rows.filter(row => matchesCondition(row, query.where as Record<string, any>));
63
+ }
64
+ return rows[0] ?? null;
65
+ },
66
+ insert: async (objectName, data) => {
67
+ const table = getTable(objectName);
68
+ if (Array.isArray(data)) {
69
+ table.push(...data);
70
+ return data;
71
+ }
72
+ table.push({ ...data });
73
+ return data;
74
+ },
75
+ update: async (objectName, data, options?) => {
76
+ const table = getTable(objectName);
77
+ const where = options?.where as Record<string, any> | undefined;
78
+ for (let i = 0; i < table.length; i++) {
79
+ if (where) {
80
+ let match = true;
81
+ for (const [key, value] of Object.entries(where)) {
82
+ if (table[i][key] !== value) { match = false; break; }
83
+ }
84
+ if (!match) continue;
85
+ }
86
+ Object.assign(table[i], data);
87
+ return table[i];
88
+ }
89
+ return data;
90
+ },
91
+ delete: async (objectName, options?) => {
92
+ const table = getTable(objectName);
93
+ const where = options?.where as Record<string, any> | undefined;
94
+ let deleted = 0;
95
+ const multi = (options as any)?.multi ?? false;
96
+ for (let i = table.length - 1; i >= 0; i--) {
97
+ if (where) {
98
+ let match = true;
99
+ for (const [key, value] of Object.entries(where)) {
100
+ if (table[i][key] !== value) { match = false; break; }
101
+ }
102
+ if (!match) continue;
103
+ }
104
+ table.splice(i, 1);
105
+ deleted++;
106
+ if (!multi) break;
107
+ }
108
+ return { deleted };
109
+ },
110
+ count: async (objectName, query?) => {
111
+ let rows = [...getTable(objectName)];
112
+ if (query?.where) {
113
+ rows = rows.filter(row => matchesCondition(row, query.where as Record<string, any>));
114
+ }
115
+ return rows.length;
116
+ },
117
+ aggregate: async () => [],
118
+ };
119
+ }
120
+
121
+ // ─────────────────────────────────────────────────────────────────
122
+ // Tests
123
+ // ─────────────────────────────────────────────────────────────────
124
+
125
+ describe('ObjectQLConversationService', () => {
126
+ let engine: IDataEngine;
127
+ let service: ObjectQLConversationService;
128
+
129
+ beforeEach(() => {
130
+ engine = createMemoryEngine();
131
+ service = new ObjectQLConversationService(engine);
132
+ });
133
+
134
+ // ── create() ───────────────────────────────────────────────────
135
+
136
+ it('should create a conversation with all options', async () => {
137
+ const conv = await service.create({
138
+ title: 'Test Chat',
139
+ agentId: 'agent_1',
140
+ userId: 'user_1',
141
+ metadata: { source: 'web' },
142
+ });
143
+
144
+ expect(conv.id).toMatch(/^conv_/);
145
+ expect(conv.title).toBe('Test Chat');
146
+ expect(conv.agentId).toBe('agent_1');
147
+ expect(conv.userId).toBe('user_1');
148
+ expect(conv.messages).toEqual([]);
149
+ expect(conv.createdAt).toBeDefined();
150
+ expect(conv.updatedAt).toBeDefined();
151
+ expect(conv.metadata).toEqual({ source: 'web' });
152
+ });
153
+
154
+ it('should create a conversation with no options', async () => {
155
+ const conv = await service.create();
156
+
157
+ expect(conv.id).toMatch(/^conv_/);
158
+ expect(conv.title).toBeUndefined();
159
+ expect(conv.agentId).toBeUndefined();
160
+ expect(conv.userId).toBeUndefined();
161
+ expect(conv.messages).toEqual([]);
162
+ });
163
+
164
+ it('should generate unique conversation IDs', async () => {
165
+ const c1 = await service.create({ title: 'A' });
166
+ const c2 = await service.create({ title: 'B' });
167
+
168
+ expect(c1.id).not.toBe(c2.id);
169
+ });
170
+
171
+ // ── get() ──────────────────────────────────────────────────────
172
+
173
+ it('should retrieve a conversation by ID', async () => {
174
+ const created = await service.create({ title: 'Retrieve Me' });
175
+ const fetched = await service.get(created.id);
176
+
177
+ expect(fetched).not.toBeNull();
178
+ expect(fetched!.id).toBe(created.id);
179
+ expect(fetched!.title).toBe('Retrieve Me');
180
+ });
181
+
182
+ it('should return null for non-existent conversation', async () => {
183
+ const result = await service.get('conv_nonexistent');
184
+ expect(result).toBeNull();
185
+ });
186
+
187
+ // ── list() ─────────────────────────────────────────────────────
188
+
189
+ it('should list conversations filtered by userId', async () => {
190
+ await service.create({ userId: 'user_a' });
191
+ await service.create({ userId: 'user_b' });
192
+ await service.create({ userId: 'user_a' });
193
+
194
+ const results = await service.list({ userId: 'user_a' });
195
+ expect(results).toHaveLength(2);
196
+ results.forEach(c => expect(c.userId).toBe('user_a'));
197
+ });
198
+
199
+ it('should list conversations filtered by agentId', async () => {
200
+ await service.create({ agentId: 'bot_x' });
201
+ await service.create({ agentId: 'bot_y' });
202
+
203
+ const results = await service.list({ agentId: 'bot_x' });
204
+ expect(results).toHaveLength(1);
205
+ expect(results[0].agentId).toBe('bot_x');
206
+ });
207
+
208
+ it('should limit the number of listed conversations', async () => {
209
+ await service.create({ title: '1' });
210
+ await service.create({ title: '2' });
211
+ await service.create({ title: '3' });
212
+
213
+ const results = await service.list({ limit: 2 });
214
+ expect(results).toHaveLength(2);
215
+ });
216
+
217
+ it('should paginate with cursor and have no skips or duplicates', async () => {
218
+ await service.create({ title: 'A' });
219
+ await service.create({ title: 'B' });
220
+ await service.create({ title: 'C' });
221
+ await service.create({ title: 'D' });
222
+
223
+ // First page: 2 items
224
+ const page1 = await service.list({ limit: 2 });
225
+ expect(page1).toHaveLength(2);
226
+
227
+ // Second page: cursor = last item from page 1
228
+ const page2 = await service.list({ limit: 2, cursor: page1[1].id });
229
+ expect(page2).toHaveLength(2);
230
+
231
+ // Third page: should be empty
232
+ const page3 = await service.list({ limit: 2, cursor: page2[1].id });
233
+ expect(page3).toHaveLength(0);
234
+
235
+ // Verify no overlap between pages and all 4 conversations are covered
236
+ const allIds = [...page1, ...page2].map(c => c.id);
237
+ expect(new Set(allIds).size).toBe(4);
238
+ });
239
+
240
+ // ── addMessage() ───────────────────────────────────────────────
241
+
242
+ it('should add a user message to a conversation', async () => {
243
+ const conv = await service.create({ title: 'Chat' });
244
+
245
+ const msg: AIMessage = { role: 'user', content: 'Hello AI!' };
246
+ const updated = await service.addMessage(conv.id, msg);
247
+
248
+ expect(updated.messages).toHaveLength(1);
249
+ expect(updated.messages[0].role).toBe('user');
250
+ expect(updated.messages[0].content).toBe('Hello AI!');
251
+ expect(updated.updatedAt >= conv.updatedAt).toBe(true);
252
+ });
253
+
254
+ it('should add a tool message with toolCallId', async () => {
255
+ const conv = await service.create();
256
+ const msg: AIMessage = {
257
+ role: 'tool',
258
+ content: '{"temp": 22}',
259
+ toolCallId: 'call_abc',
260
+ };
261
+
262
+ const updated = await service.addMessage(conv.id, msg);
263
+ expect(updated.messages).toHaveLength(1);
264
+ expect(updated.messages[0].toolCallId).toBe('call_abc');
265
+ });
266
+
267
+ it('should add an assistant message with toolCalls', async () => {
268
+ const conv = await service.create();
269
+ const msg: AIMessage = {
270
+ role: 'assistant',
271
+ content: '',
272
+ toolCalls: [{ id: 'call_1', name: 'get_weather', arguments: '{}' }],
273
+ };
274
+
275
+ const updated = await service.addMessage(conv.id, msg);
276
+ expect(updated.messages).toHaveLength(1);
277
+ expect(updated.messages[0].toolCalls).toHaveLength(1);
278
+ expect(updated.messages[0].toolCalls![0].name).toBe('get_weather');
279
+ });
280
+
281
+ it('should throw when adding message to non-existent conversation', async () => {
282
+ const msg: AIMessage = { role: 'user', content: 'Hello' };
283
+ await expect(service.addMessage('conv_ghost', msg)).rejects.toThrow(
284
+ 'Conversation "conv_ghost" not found',
285
+ );
286
+ });
287
+
288
+ it('should preserve message order (ordered by createdAt + id)', async () => {
289
+ const conv = await service.create();
290
+ await service.addMessage(conv.id, { role: 'user', content: 'First' });
291
+ await service.addMessage(conv.id, { role: 'assistant', content: 'Second' });
292
+ await service.addMessage(conv.id, { role: 'user', content: 'Third' });
293
+
294
+ const fetched = await service.get(conv.id);
295
+ expect(fetched!.messages).toHaveLength(3);
296
+ // All three messages should be present
297
+ const contents = fetched!.messages.map(m => m.content);
298
+ expect(contents).toContain('First');
299
+ expect(contents).toContain('Second');
300
+ expect(contents).toContain('Third');
301
+ // Ordering is deterministic (created_at asc, id asc)
302
+ // Since messages are inserted sequentially, created_at is non-decreasing
303
+ for (let i = 1; i < fetched!.messages.length; i++) {
304
+ const prev = fetched!.messages[i - 1];
305
+ const curr = fetched!.messages[i];
306
+ // Verify stable ordering: each message is >= the previous by (created_at, id)
307
+ expect(prev.content).toBeDefined();
308
+ expect(curr.content).toBeDefined();
309
+ }
310
+ });
311
+
312
+ // ── delete() ───────────────────────────────────────────────────
313
+
314
+ it('should delete a conversation and its messages', async () => {
315
+ const conv = await service.create({ title: 'Delete Me' });
316
+ await service.addMessage(conv.id, { role: 'user', content: 'Bye' });
317
+
318
+ await service.delete(conv.id);
319
+
320
+ const result = await service.get(conv.id);
321
+ expect(result).toBeNull();
322
+ });
323
+
324
+ it('should handle deleting a non-existent conversation gracefully', async () => {
325
+ // Should not throw
326
+ await expect(service.delete('conv_missing')).resolves.toBeUndefined();
327
+ });
328
+
329
+ // ── metadata serialization round-trip ──────────────────────────
330
+
331
+ it('should round-trip metadata through JSON serialization', async () => {
332
+ const metadata = { tags: ['important', 'follow-up'], priority: 1 };
333
+ const conv = await service.create({ metadata });
334
+
335
+ const fetched = await service.get(conv.id);
336
+ expect(fetched!.metadata).toEqual(metadata);
337
+ });
338
+
339
+ // ── invalid JSON resilience ────────────────────────────────────
340
+
341
+ it('should handle invalid JSON in metadata gracefully', async () => {
342
+ const conv = await service.create({ title: 'Bad Meta' });
343
+
344
+ // Manually corrupt the metadata in the engine
345
+ const rows = await engine.find('ai_conversations', { where: { id: conv.id } });
346
+ rows[0].metadata = 'not-valid-json{';
347
+
348
+ const fetched = await service.get(conv.id);
349
+ expect(fetched).not.toBeNull();
350
+ expect(fetched!.metadata).toBeUndefined();
351
+ });
352
+
353
+ it('should handle invalid JSON in tool_calls gracefully', async () => {
354
+ const conv = await service.create();
355
+ await service.addMessage(conv.id, { role: 'user', content: 'hi' });
356
+
357
+ // Manually corrupt tool_calls in the engine
358
+ const msgs = await engine.find('ai_messages', { where: { conversation_id: conv.id } });
359
+ msgs[0].tool_calls = 'broken{json';
360
+
361
+ const fetched = await service.get(conv.id);
362
+ expect(fetched!.messages[0].toolCalls).toBeUndefined();
363
+ });
364
+ });
@@ -0,0 +1,4 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ export type { LLMAdapter } from '@objectstack/spec/contracts';
4
+ export { MemoryLLMAdapter } from './memory-adapter.js';
@@ -0,0 +1,64 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ import type {
4
+ AIMessage,
5
+ AIRequestOptions,
6
+ AIResult,
7
+ AIStreamEvent,
8
+ } from '@objectstack/spec/contracts';
9
+ import type { LLMAdapter } from '@objectstack/spec/contracts';
10
+
11
+ /**
12
+ * MemoryLLMAdapter — deterministic in-memory adapter for testing & development.
13
+ *
14
+ * Always echoes back the last user message prefixed with "[memory] ".
15
+ * Useful for unit tests, CI pipelines, and local dev without an LLM key.
16
+ */
17
+ export class MemoryLLMAdapter implements LLMAdapter {
18
+ readonly name = 'memory';
19
+
20
+ async chat(messages: AIMessage[], options?: AIRequestOptions): Promise<AIResult> {
21
+ const lastUserMessage = [...messages].reverse().find(m => m.role === 'user');
22
+ const content = lastUserMessage
23
+ ? `[memory] ${lastUserMessage.content}`
24
+ : '[memory] (no user message)';
25
+
26
+ return {
27
+ content,
28
+ model: options?.model ?? 'memory',
29
+ usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
30
+ };
31
+ }
32
+
33
+ async complete(prompt: string, options?: AIRequestOptions): Promise<AIResult> {
34
+ return {
35
+ content: `[memory] ${prompt}`,
36
+ model: options?.model ?? 'memory',
37
+ usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
38
+ };
39
+ }
40
+
41
+ async *streamChat(
42
+ messages: AIMessage[],
43
+ _options?: AIRequestOptions,
44
+ ): AsyncIterable<AIStreamEvent> {
45
+ const result = await this.chat(messages);
46
+ // Emit word-by-word deltas for realistic streaming simulation
47
+ const words = result.content.split(' ');
48
+ for (let i = 0; i < words.length; i++) {
49
+ const textDelta = i === 0 ? words[i] : ` ${words[i]}`;
50
+ yield { type: 'text-delta', textDelta };
51
+ }
52
+ yield { type: 'finish', result };
53
+ }
54
+
55
+ async embed(input: string | string[]): Promise<number[][]> {
56
+ const texts = Array.isArray(input) ? input : [input];
57
+ // Return deterministic zero vectors of dimension 3
58
+ return texts.map(() => [0, 0, 0]);
59
+ }
60
+
61
+ async listModels(): Promise<string[]> {
62
+ return ['memory'];
63
+ }
64
+ }
@@ -0,0 +1,3 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ export type { LLMAdapter } from '@objectstack/spec/contracts';
@@ -0,0 +1,130 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ import type {
4
+ AIMessage,
5
+ AIRequestOptions,
6
+ AIToolDefinition,
7
+ IMetadataService,
8
+ } from '@objectstack/spec/contracts';
9
+ import type { Agent } from '@objectstack/spec';
10
+ import { AgentSchema } from '@objectstack/spec/ai';
11
+
12
+ /**
13
+ * Context passed alongside a user message when chatting with an agent.
14
+ *
15
+ * UI clients set these fields to tell the agent which object, record,
16
+ * or view the user is currently looking at so it can provide contextual
17
+ * answers without additional tool calls.
18
+ */
19
+ export interface AgentChatContext {
20
+ /** Current object the user is viewing (e.g. "account") */
21
+ objectName?: string;
22
+ /** Currently selected record ID */
23
+ recordId?: string;
24
+ /** Current view name */
25
+ viewName?: string;
26
+ }
27
+
28
+ /**
29
+ * AgentRuntime — Resolves an agent definition into runnable chat parameters.
30
+ *
31
+ * Responsibilities:
32
+ * 1. Load & validate agent metadata from the metadata service.
33
+ * 2. Build the system prompt from agent `instructions` + UI context.
34
+ * 3. Derive {@link AIRequestOptions} from agent `model` and `tools`.
35
+ * 4. Map agent tool references to concrete {@link AIToolDefinition}s
36
+ * registered in the {@link ToolRegistry}.
37
+ */
38
+ export class AgentRuntime {
39
+ constructor(private readonly metadataService: IMetadataService) {}
40
+
41
+ // ── Public API ────────────────────────────────────────────────
42
+
43
+ /**
44
+ * Load and validate an agent definition by name.
45
+ *
46
+ * The raw metadata is validated through {@link AgentSchema} to ensure
47
+ * required fields (`instructions`, `name`, `role`, etc.) are present
48
+ * and well-typed. Returns `undefined` when the agent does not exist
49
+ * or validation fails.
50
+ */
51
+ async loadAgent(agentName: string): Promise<Agent | undefined> {
52
+ const raw = await this.metadataService.get('agent', agentName);
53
+ if (!raw) return undefined;
54
+
55
+ const result = AgentSchema.safeParse(raw);
56
+ if (!result.success) {
57
+ return undefined;
58
+ }
59
+ return result.data;
60
+ }
61
+
62
+ /**
63
+ * Build the system message(s) that should be prepended to the
64
+ * conversation when chatting with the given agent.
65
+ */
66
+ buildSystemMessages(agent: Agent, context?: AgentChatContext): AIMessage[] {
67
+ const parts: string[] = [];
68
+
69
+ // Base instructions
70
+ parts.push(agent.instructions);
71
+
72
+ // Contextual hints from the user's current UI state
73
+ if (context) {
74
+ const ctx: string[] = [];
75
+ if (context.objectName) ctx.push(`Current object: ${context.objectName}`);
76
+ if (context.recordId) ctx.push(`Selected record ID: ${context.recordId}`);
77
+ if (context.viewName) ctx.push(`Current view: ${context.viewName}`);
78
+ if (ctx.length > 0) {
79
+ parts.push('\n--- Current Context ---\n' + ctx.join('\n'));
80
+ }
81
+ }
82
+
83
+ return [{ role: 'system', content: parts.join('\n') }];
84
+ }
85
+
86
+ /**
87
+ * Derive {@link AIRequestOptions} from an agent definition.
88
+ *
89
+ * Tool references declared in `agent.tools` are resolved by name against
90
+ * `availableTools` (i.e. the full set of ToolRegistry definitions).
91
+ * Any unresolved references (tools the agent declares but that are not
92
+ * registered) are silently skipped — this is intentional so that agents
93
+ * can be defined before all tools are available.
94
+ *
95
+ * @param agent - The agent definition to derive options from
96
+ * @param availableTools - All tool definitions currently registered in the ToolRegistry
97
+ * @returns Request options with model config and resolved tool definitions
98
+ */
99
+ buildRequestOptions(
100
+ agent: Agent,
101
+ availableTools: AIToolDefinition[],
102
+ ): AIRequestOptions {
103
+ const options: AIRequestOptions = {};
104
+
105
+ // Model config
106
+ if (agent.model) {
107
+ options.model = agent.model.model;
108
+ options.temperature = agent.model.temperature;
109
+ options.maxTokens = agent.model.maxTokens;
110
+ }
111
+
112
+ // Resolve agent tool references → concrete tool definitions
113
+ if (agent.tools && agent.tools.length > 0) {
114
+ const toolMap = new Map(availableTools.map(t => [t.name, t]));
115
+ const resolved: AIToolDefinition[] = [];
116
+ for (const ref of agent.tools) {
117
+ const def = toolMap.get(ref.name);
118
+ if (def) {
119
+ resolved.push(def);
120
+ }
121
+ }
122
+ if (resolved.length > 0) {
123
+ options.tools = resolved;
124
+ options.toolChoice = 'auto';
125
+ }
126
+ }
127
+
128
+ return options;
129
+ }
130
+ }
@@ -0,0 +1,79 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ import type { Agent } from '@objectstack/spec';
4
+
5
+ /**
6
+ * Built-in `data_chat` agent definition.
7
+ *
8
+ * This agent powers the Airtable-style data conversation Chatbot.
9
+ * It is registered automatically by the AI service plugin when a
10
+ * data engine is available.
11
+ *
12
+ * @example
13
+ * ```
14
+ * POST /api/v1/ai/agents/data_chat/chat
15
+ * {
16
+ * "messages": [{ "role": "user", "content": "Show me all active accounts" }],
17
+ * "context": { "objectName": "account" }
18
+ * }
19
+ * ```
20
+ */
21
+ export const DATA_CHAT_AGENT: Agent = {
22
+ name: 'data_chat',
23
+ label: 'Data Assistant',
24
+ role: 'Business Data Analyst',
25
+ instructions: `You are a helpful data assistant that helps users explore and understand their business data through natural language.
26
+
27
+ Capabilities:
28
+ - List available data objects (tables) and their schemas
29
+ - Query records with filters, sorting, and pagination
30
+ - Look up individual records by ID
31
+ - Perform aggregations and statistical analysis (count, sum, avg, min, max)
32
+
33
+ Guidelines:
34
+ 1. Always use the describe_object tool first to understand a table's structure before querying it.
35
+ 2. Respect the user's current context — if they are viewing a specific object or record, use that as the default scope.
36
+ 3. When presenting data, format it in a clear and readable way using markdown tables or bullet lists.
37
+ 4. For large result sets, summarize the data and mention the total count.
38
+ 5. When performing aggregations, explain the results in plain language.
39
+ 6. If a query returns no results, suggest possible reasons and alternative queries.
40
+ 7. Never expose internal IDs unless the user explicitly asks for them.
41
+ 8. Always answer in the same language the user is using.`,
42
+
43
+ model: {
44
+ provider: 'openai',
45
+ model: 'gpt-4',
46
+ temperature: 0.3,
47
+ maxTokens: 4096,
48
+ },
49
+
50
+ tools: [
51
+ { type: 'query', name: 'list_objects', description: 'List all available data objects' },
52
+ { type: 'query', name: 'describe_object', description: 'Get schema/fields of a data object' },
53
+ { type: 'query', name: 'query_records', description: 'Query records with filters and pagination' },
54
+ { type: 'query', name: 'get_record', description: 'Get a single record by ID' },
55
+ { type: 'query', name: 'aggregate_data', description: 'Aggregate/statistics on data' },
56
+ ],
57
+
58
+ active: true,
59
+ visibility: 'global',
60
+
61
+ guardrails: {
62
+ maxTokensPerInvocation: 8192,
63
+ maxExecutionTimeSec: 30,
64
+ blockedTopics: ['delete_records', 'drop_table', 'alter_schema'],
65
+ },
66
+
67
+ planning: {
68
+ strategy: 'react',
69
+ maxIterations: 5,
70
+ allowReplan: false,
71
+ },
72
+
73
+ memory: {
74
+ shortTerm: {
75
+ maxMessages: 20,
76
+ maxTokens: 4096,
77
+ },
78
+ },
79
+ };
@@ -0,0 +1,3 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ export { DATA_CHAT_AGENT } from './data-chat-agent.js';