@objectstack/service-ai 4.0.3 → 4.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +293 -0
  2. package/dist/index.cjs +1176 -135
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.d.cts +1225 -430
  5. package/dist/index.d.ts +1225 -430
  6. package/dist/index.js +1160 -128
  7. package/dist/index.js.map +1 -1
  8. package/package.json +35 -8
  9. package/.turbo/turbo-build.log +0 -22
  10. package/CHANGELOG.md +0 -53
  11. package/src/__tests__/ai-service.test.ts +0 -964
  12. package/src/__tests__/auth-and-toolcalling.test.ts +0 -677
  13. package/src/__tests__/chatbot-features.test.ts +0 -1116
  14. package/src/__tests__/metadata-tools.test.ts +0 -970
  15. package/src/__tests__/objectql-conversation-service.test.ts +0 -382
  16. package/src/__tests__/tool-routes.test.ts +0 -191
  17. package/src/__tests__/vercel-stream-encoder.test.ts +0 -310
  18. package/src/adapters/index.ts +0 -6
  19. package/src/adapters/memory-adapter.ts +0 -72
  20. package/src/adapters/types.ts +0 -3
  21. package/src/adapters/vercel-adapter.ts +0 -148
  22. package/src/agent-runtime.ts +0 -154
  23. package/src/agents/data-chat-agent.ts +0 -79
  24. package/src/agents/index.ts +0 -4
  25. package/src/agents/metadata-assistant-agent.ts +0 -87
  26. package/src/ai-service.ts +0 -364
  27. package/src/conversation/in-memory-conversation-service.ts +0 -103
  28. package/src/conversation/index.ts +0 -4
  29. package/src/conversation/objectql-conversation-service.ts +0 -301
  30. package/src/index.ts +0 -60
  31. package/src/objects/ai-conversation.object.ts +0 -86
  32. package/src/objects/ai-message.object.ts +0 -86
  33. package/src/objects/index.ts +0 -10
  34. package/src/plugin.ts +0 -391
  35. package/src/routes/agent-routes.ts +0 -190
  36. package/src/routes/ai-routes.ts +0 -439
  37. package/src/routes/index.ts +0 -5
  38. package/src/routes/message-utils.ts +0 -90
  39. package/src/routes/tool-routes.ts +0 -142
  40. package/src/stream/index.ts +0 -3
  41. package/src/stream/vercel-stream-encoder.ts +0 -153
  42. package/src/tools/add-field.tool.ts +0 -70
  43. package/src/tools/create-object.tool.ts +0 -66
  44. package/src/tools/data-tools.ts +0 -293
  45. package/src/tools/delete-field.tool.ts +0 -38
  46. package/src/tools/describe-object.tool.ts +0 -31
  47. package/src/tools/index.ts +0 -18
  48. package/src/tools/list-objects.tool.ts +0 -34
  49. package/src/tools/metadata-tools.ts +0 -430
  50. package/src/tools/modify-field.tool.ts +0 -44
  51. package/src/tools/tool-registry.ts +0 -132
  52. package/tsconfig.json +0 -17
@@ -1,310 +0,0 @@
1
- // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
-
3
- import { describe, it, expect } from 'vitest';
4
- import type { TextStreamPart, ToolSet } from '@objectstack/spec/contracts';
5
- import { encodeStreamPart, encodeVercelDataStream } from '../stream/vercel-stream-encoder.js';
6
-
7
- // Helper to parse SSE frame payload
8
- function parseSSE(frame: string): Record<string, unknown> | null {
9
- if (!frame.startsWith('data: ') || !frame.endsWith('\n\n')) return null;
10
- const json = frame.slice(6, -2);
11
- if (json === '[DONE]') return null;
12
- return JSON.parse(json);
13
- }
14
-
15
- // ─────────────────────────────────────────────────────────────────
16
- // encodeStreamPart — individual frame encoding (v6 SSE format)
17
- // ─────────────────────────────────────────────────────────────────
18
-
19
- describe('encodeStreamPart', () => {
20
- it('should encode text-delta as SSE frame', () => {
21
- const part = { type: 'text-delta', text: 'Hello world' } as TextStreamPart<ToolSet>;
22
- const frame = encodeStreamPart(part);
23
- const payload = parseSSE(frame);
24
- expect(payload).toEqual({ type: 'text-delta', id: '0', delta: 'Hello world' });
25
- });
26
-
27
- it('should JSON-escape text-delta content', () => {
28
- const part = { type: 'text-delta', text: 'say "hi"\nnewline' } as TextStreamPart<ToolSet>;
29
- const frame = encodeStreamPart(part);
30
- expect(frame.startsWith('data: ')).toBe(true);
31
- expect(frame.endsWith('\n\n')).toBe(true);
32
-
33
- // Verify round-trip: decode the frame payload back to the original text
34
- const payload = parseSSE(frame);
35
- expect(payload).not.toBeNull();
36
- expect((payload as Record<string, unknown>).delta).toBe('say "hi"\nnewline');
37
- });
38
-
39
- it('should encode tool-call as tool-input-available SSE frame', () => {
40
- const part = {
41
- type: 'tool-call',
42
- toolCallId: 'call_1',
43
- toolName: 'get_weather',
44
- input: { location: 'San Francisco' },
45
- } as TextStreamPart<ToolSet>;
46
-
47
- const frame = encodeStreamPart(part);
48
- const payload = parseSSE(frame);
49
- expect(payload).toEqual({
50
- type: 'tool-input-available',
51
- toolCallId: 'call_1',
52
- toolName: 'get_weather',
53
- input: { location: 'San Francisco' },
54
- });
55
- });
56
-
57
- it('should encode tool-input-start as SSE frame', () => {
58
- const part = {
59
- type: 'tool-input-start',
60
- id: 'call_2',
61
- toolName: 'search',
62
- } as TextStreamPart<ToolSet>;
63
-
64
- const frame = encodeStreamPart(part);
65
- const payload = parseSSE(frame);
66
- expect(payload).toEqual({
67
- type: 'tool-input-start',
68
- toolCallId: 'call_2',
69
- toolName: 'search',
70
- });
71
- });
72
-
73
- it('should encode tool-input-delta as SSE frame', () => {
74
- const part = {
75
- type: 'tool-input-delta',
76
- id: 'call_2',
77
- delta: '{"query":',
78
- } as TextStreamPart<ToolSet>;
79
-
80
- const frame = encodeStreamPart(part);
81
- const payload = parseSSE(frame);
82
- expect(payload).toEqual({
83
- type: 'tool-input-delta',
84
- toolCallId: 'call_2',
85
- inputTextDelta: '{"query":',
86
- });
87
- });
88
-
89
- it('should encode tool-result as tool-output-available SSE frame', () => {
90
- const part = {
91
- type: 'tool-result',
92
- toolCallId: 'call_1',
93
- toolName: 'get_weather',
94
- output: { temperature: 72 },
95
- } as TextStreamPart<ToolSet>;
96
-
97
- const frame = encodeStreamPart(part);
98
- const payload = parseSSE(frame);
99
- expect(payload).toEqual({
100
- type: 'tool-output-available',
101
- toolCallId: 'call_1',
102
- output: { temperature: 72 },
103
- });
104
- });
105
-
106
- it('should return empty string for finish (handled by generator)', () => {
107
- const part = {
108
- type: 'finish',
109
- finishReason: 'stop',
110
- totalUsage: { promptTokens: 10, completionTokens: 20, totalTokens: 30 },
111
- rawFinishReason: 'stop',
112
- } as unknown as TextStreamPart<ToolSet>;
113
-
114
- expect(encodeStreamPart(part)).toBe('');
115
- });
116
-
117
- it('should return empty string for finish-step (handled by generator)', () => {
118
- const part = {
119
- type: 'finish-step',
120
- finishReason: 'tool-calls',
121
- usage: { promptTokens: 5, completionTokens: 10, totalTokens: 15 },
122
- } as unknown as TextStreamPart<ToolSet>;
123
-
124
- expect(encodeStreamPart(part)).toBe('');
125
- });
126
-
127
- it('should return empty string for unknown event types', () => {
128
- const part = { type: 'unknown-internal' } as unknown as TextStreamPart<ToolSet>;
129
- expect(encodeStreamPart(part)).toBe('');
130
- });
131
-
132
- it('should encode reasoning-start part with g: prefix', () => {
133
- const part = {
134
- type: 'reasoning-start',
135
- id: 'r1',
136
- } as unknown as TextStreamPart<ToolSet>;
137
-
138
- const frame = encodeStreamPart(part);
139
- expect(frame).toBe('g:{"text":""}\n');
140
- });
141
-
142
- it('should encode reasoning-delta part with g: prefix', () => {
143
- const part = {
144
- type: 'reasoning-delta',
145
- id: 'r1',
146
- text: 'Let me think through this step by step...',
147
- } as unknown as TextStreamPart<ToolSet>;
148
-
149
- const frame = encodeStreamPart(part);
150
- expect(frame).toBe('g:{"text":"Let me think through this step by step..."}\n');
151
- });
152
-
153
- it('should encode reasoning-end part as empty (no specific end marker)', () => {
154
- const part = {
155
- type: 'reasoning-end',
156
- id: 'r1',
157
- } as unknown as TextStreamPart<ToolSet>;
158
-
159
- const frame = encodeStreamPart(part);
160
- expect(frame).toBe('');
161
- });
162
-
163
- it('should pass through custom step events', () => {
164
- const part = {
165
- type: 'step-start',
166
- stepId: 'step_1',
167
- stepName: 'Query database',
168
- } as unknown as TextStreamPart<ToolSet>;
169
-
170
- const frame = encodeStreamPart(part);
171
- const payload = parseSSE(frame);
172
- expect(payload).toEqual({
173
- type: 'step-start',
174
- stepId: 'step_1',
175
- stepName: 'Query database',
176
- });
177
- });
178
- });
179
-
180
- // ─────────────────────────────────────────────────────────────────
181
- // encodeVercelDataStream — async iterable transformation (v6 SSE)
182
- //
183
- // Lifecycle: start → start-step → text-start → ...events... → text-end → finish-step → finish → [DONE]
184
- // ─────────────────────────────────────────────────────────────────
185
-
186
- describe('encodeVercelDataStream', () => {
187
- it('should transform stream events into v6 UI Message Stream frames', async () => {
188
- async function* source(): AsyncIterable<TextStreamPart<ToolSet>> {
189
- yield { type: 'text-delta', text: 'Hello' } as TextStreamPart<ToolSet>;
190
- yield { type: 'text-delta', text: ' world' } as TextStreamPart<ToolSet>;
191
- yield {
192
- type: 'finish',
193
- finishReason: 'stop',
194
- totalUsage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
195
- rawFinishReason: 'stop',
196
- } as unknown as TextStreamPart<ToolSet>;
197
- }
198
-
199
- const frames: string[] = [];
200
- for await (const frame of encodeVercelDataStream(source())) {
201
- frames.push(frame);
202
- }
203
-
204
- // Preamble: start, start-step, text-start
205
- // Content: 2 text-deltas
206
- // Postamble: text-end, finish-step, finish, [DONE]
207
- expect(frames).toHaveLength(9);
208
-
209
- // Preamble
210
- expect(parseSSE(frames[0])).toEqual({ type: 'start' });
211
- expect(parseSSE(frames[1])).toEqual({ type: 'start-step' });
212
- expect(parseSSE(frames[2])).toEqual({ type: 'text-start', id: '0' });
213
-
214
- // Content
215
- expect(parseSSE(frames[3])).toMatchObject({ type: 'text-delta', delta: 'Hello' });
216
- expect(parseSSE(frames[4])).toMatchObject({ type: 'text-delta', delta: ' world' });
217
-
218
- // Postamble
219
- expect(parseSSE(frames[5])).toEqual({ type: 'text-end', id: '0' });
220
- expect(parseSSE(frames[6])).toEqual({ type: 'finish-step' });
221
- expect(parseSSE(frames[7])).toMatchObject({ type: 'finish', finishReason: 'stop' });
222
- expect(frames[8]).toBe('data: [DONE]\n\n');
223
- });
224
-
225
- it('should skip events with no wire format mapping', async () => {
226
- async function* source(): AsyncIterable<TextStreamPart<ToolSet>> {
227
- yield { type: 'text-delta', text: 'Hi' } as TextStreamPart<ToolSet>;
228
- yield { type: 'unknown-internal' } as unknown as TextStreamPart<ToolSet>;
229
- yield {
230
- type: 'finish',
231
- finishReason: 'stop',
232
- totalUsage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
233
- rawFinishReason: 'stop',
234
- } as unknown as TextStreamPart<ToolSet>;
235
- }
236
-
237
- const frames: string[] = [];
238
- for await (const frame of encodeVercelDataStream(source())) {
239
- frames.push(frame);
240
- }
241
-
242
- // Preamble(3) + 1 text-delta + Postamble(4) = 8 ('unknown-internal' dropped)
243
- expect(frames).toHaveLength(8);
244
- expect(parseSSE(frames[3])).toMatchObject({ type: 'text-delta', delta: 'Hi' });
245
- });
246
-
247
- it('should handle empty stream', async () => {
248
- async function* source(): AsyncIterable<TextStreamPart<ToolSet>> {
249
- // empty
250
- }
251
-
252
- const frames: string[] = [];
253
- for await (const frame of encodeVercelDataStream(source())) {
254
- frames.push(frame);
255
- }
256
-
257
- // Preamble(3) + text-end + finish-step + finish + [DONE] = 7
258
- expect(frames).toHaveLength(7);
259
- expect(parseSSE(frames[0])).toEqual({ type: 'start' });
260
- expect(parseSSE(frames[3])).toEqual({ type: 'text-end', id: '0' });
261
- expect(frames[6]).toBe('data: [DONE]\n\n');
262
- });
263
-
264
- it('should handle tool-call events in stream', async () => {
265
- async function* source(): AsyncIterable<TextStreamPart<ToolSet>> {
266
- yield {
267
- type: 'tool-call',
268
- toolCallId: 'call_1',
269
- toolName: 'search',
270
- input: { query: 'test' },
271
- } as TextStreamPart<ToolSet>;
272
- yield {
273
- type: 'tool-result',
274
- toolCallId: 'call_1',
275
- toolName: 'search',
276
- output: { hits: 42 },
277
- } as TextStreamPart<ToolSet>;
278
- yield {
279
- type: 'finish',
280
- finishReason: 'tool-calls',
281
- totalUsage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
282
- rawFinishReason: 'tool_calls',
283
- } as unknown as TextStreamPart<ToolSet>;
284
- }
285
-
286
- const frames: string[] = [];
287
- for await (const frame of encodeVercelDataStream(source())) {
288
- frames.push(frame);
289
- }
290
-
291
- // Preamble(3) + tool-input-available + tool-output-available + Postamble(4) = 9
292
- expect(frames).toHaveLength(9);
293
-
294
- // Verify tool-call frame content
295
- const toolCallPayload = parseSSE(frames[3]);
296
- expect(toolCallPayload).toMatchObject({
297
- type: 'tool-input-available',
298
- toolCallId: 'call_1',
299
- toolName: 'search',
300
- input: { query: 'test' },
301
- });
302
-
303
- const toolResultPayload = parseSSE(frames[4]);
304
- expect(toolResultPayload).toMatchObject({
305
- type: 'tool-output-available',
306
- toolCallId: 'call_1',
307
- output: { hits: 42 },
308
- });
309
- });
310
- });
@@ -1,6 +0,0 @@
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';
5
- export { VercelLLMAdapter } from './vercel-adapter.js';
6
- export type { VercelLLMAdapterConfig } from './vercel-adapter.js';
@@ -1,72 +0,0 @@
1
- // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
-
3
- import type {
4
- ModelMessage,
5
- AIRequestOptions,
6
- AIResult,
7
- TextStreamPart,
8
- ToolSet,
9
- } from '@objectstack/spec/contracts';
10
- import type { LLMAdapter } from '@objectstack/spec/contracts';
11
-
12
- /**
13
- * MemoryLLMAdapter — deterministic in-memory adapter for testing & development.
14
- *
15
- * Always echoes back the last user message prefixed with "[memory] ".
16
- * Useful for unit tests, CI pipelines, and local dev without an LLM key.
17
- */
18
- export class MemoryLLMAdapter implements LLMAdapter {
19
- readonly name = 'memory';
20
-
21
- async chat(messages: ModelMessage[], options?: AIRequestOptions): Promise<AIResult> {
22
- const lastUserMessage = [...messages].reverse().find(m => m.role === 'user');
23
- const userContent = lastUserMessage?.content;
24
- const text = typeof userContent === 'string' ? userContent : '(complex content)';
25
- const content = lastUserMessage
26
- ? `[memory] ${text}`
27
- : '[memory] (no user message)';
28
-
29
- return {
30
- content,
31
- model: options?.model ?? 'memory',
32
- usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
33
- };
34
- }
35
-
36
- async complete(prompt: string, options?: AIRequestOptions): Promise<AIResult> {
37
- return {
38
- content: `[memory] ${prompt}`,
39
- model: options?.model ?? 'memory',
40
- usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
41
- };
42
- }
43
-
44
- async *streamChat(
45
- messages: ModelMessage[],
46
- _options?: AIRequestOptions,
47
- ): AsyncIterable<TextStreamPart<ToolSet>> {
48
- const result = await this.chat(messages);
49
- // Emit word-by-word deltas for realistic streaming simulation
50
- const words = result.content.split(' ');
51
- for (let i = 0; i < words.length; i++) {
52
- const wordText = i === 0 ? words[i] : ` ${words[i]}`;
53
- yield { type: 'text-delta', id: `delta_${i}`, text: wordText } as TextStreamPart<ToolSet>;
54
- }
55
- yield {
56
- type: 'finish',
57
- finishReason: 'stop' as const,
58
- totalUsage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
59
- rawFinishReason: 'stop',
60
- } as unknown as TextStreamPart<ToolSet>;
61
- }
62
-
63
- async embed(input: string | string[]): Promise<number[][]> {
64
- const texts = Array.isArray(input) ? input : [input];
65
- // Return deterministic zero vectors of dimension 3
66
- return texts.map(() => [0, 0, 0]);
67
- }
68
-
69
- async listModels(): Promise<string[]> {
70
- return ['memory'];
71
- }
72
- }
@@ -1,3 +0,0 @@
1
- // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
-
3
- export type { LLMAdapter } from '@objectstack/spec/contracts';
@@ -1,148 +0,0 @@
1
- // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
-
3
- import type {
4
- ModelMessage,
5
- AIRequestOptions,
6
- AIResult,
7
- TextStreamPart,
8
- ToolSet,
9
- } from '@objectstack/spec/contracts';
10
- import type { LLMAdapter } from '@objectstack/spec/contracts';
11
- import type { AIToolDefinition } from '@objectstack/spec/contracts';
12
- import type { LanguageModelV2 } from '@ai-sdk/provider';
13
- import { generateText, streamText, tool as vercelTool, jsonSchema } from 'ai';
14
-
15
- /**
16
- * Convert ObjectStack `AIRequestOptions` into the subset of Vercel AI SDK
17
- * options supported by `generateText` / `streamText`.
18
- *
19
- * Forwards: temperature, maxTokens, stop (→ stopSequences), tools, toolChoice.
20
- */
21
- function buildVercelOptions(options?: AIRequestOptions): Record<string, unknown> {
22
- if (!options) return {};
23
-
24
- const opts: Record<string, unknown> = {};
25
-
26
- if (options.temperature != null) opts.temperature = options.temperature;
27
- if (options.maxTokens != null) opts.maxTokens = options.maxTokens;
28
- if (options.stop?.length) opts.stopSequences = options.stop;
29
-
30
- if (options.tools?.length) {
31
- const tools: Record<string, unknown> = {};
32
- for (const t of options.tools as AIToolDefinition[]) {
33
- tools[t.name] = vercelTool({
34
- description: t.description,
35
- inputSchema: jsonSchema(t.parameters as any),
36
- });
37
- }
38
- opts.tools = tools;
39
- }
40
-
41
- if (options.toolChoice != null) {
42
- opts.toolChoice = options.toolChoice;
43
- }
44
-
45
- return opts;
46
- }
47
-
48
- /**
49
- * VercelLLMAdapter — Production LLM adapter powered by the Vercel AI SDK.
50
- *
51
- * Wraps `generateText` / `streamText` from the `ai` package, delegating to
52
- * any Vercel AI SDK–compatible model provider (OpenAI, Anthropic, Google,
53
- * Ollama, etc.).
54
- *
55
- * @example
56
- * ```typescript
57
- * import { openai } from '@ai-sdk/openai';
58
- * import { VercelLLMAdapter } from '@objectstack/service-ai';
59
- *
60
- * const adapter = new VercelLLMAdapter({ model: openai('gpt-4o') });
61
- * ```
62
- */
63
- export class VercelLLMAdapter implements LLMAdapter {
64
- readonly name = 'vercel';
65
-
66
- private readonly model: LanguageModelV2;
67
-
68
- constructor(config: VercelLLMAdapterConfig) {
69
- this.model = config.model;
70
- }
71
-
72
- async chat(messages: ModelMessage[], options?: AIRequestOptions): Promise<AIResult> {
73
- const result = await generateText({
74
- model: this.model,
75
- messages,
76
- ...buildVercelOptions(options),
77
- });
78
-
79
- return {
80
- content: result.text,
81
- model: result.response?.modelId,
82
- toolCalls: result.toolCalls?.length ? result.toolCalls : undefined,
83
- usage: result.usage ? {
84
- promptTokens: result.usage.inputTokens ?? 0,
85
- completionTokens: result.usage.outputTokens ?? 0,
86
- totalTokens: result.usage.totalTokens ?? 0,
87
- } : undefined,
88
- };
89
- }
90
-
91
- async complete(prompt: string, options?: AIRequestOptions): Promise<AIResult> {
92
- const result = await generateText({
93
- model: this.model,
94
- prompt,
95
- ...buildVercelOptions(options),
96
- });
97
-
98
- return {
99
- content: result.text,
100
- model: result.response?.modelId,
101
- usage: result.usage ? {
102
- promptTokens: result.usage.inputTokens ?? 0,
103
- completionTokens: result.usage.outputTokens ?? 0,
104
- totalTokens: result.usage.totalTokens ?? 0,
105
- } : undefined,
106
- };
107
- }
108
-
109
- async *streamChat(
110
- messages: ModelMessage[],
111
- options?: AIRequestOptions,
112
- ): AsyncIterable<TextStreamPart<ToolSet>> {
113
- const result = streamText({
114
- model: this.model,
115
- messages,
116
- ...buildVercelOptions(options),
117
- });
118
-
119
- for await (const part of result.fullStream) {
120
- yield part as TextStreamPart<ToolSet>;
121
- }
122
- }
123
-
124
- async embed(_input: string | string[]): Promise<number[][]> {
125
- // Vercel AI SDK uses a separate EmbeddingModel — not supported via this adapter.
126
- throw new Error(
127
- '[VercelLLMAdapter] Embeddings require a dedicated EmbeddingModel. ' +
128
- 'Configure an embedding adapter instead.',
129
- );
130
- }
131
-
132
- async listModels(): Promise<string[]> {
133
- // Model listing is provider-specific and not available through the base SDK.
134
- return [];
135
- }
136
- }
137
-
138
- /**
139
- * Configuration for the Vercel LLM adapter.
140
- */
141
- export interface VercelLLMAdapterConfig {
142
- /**
143
- * A Vercel AI SDK–compatible language model instance.
144
- *
145
- * @example `openai('gpt-4o')` or `anthropic('claude-sonnet-4-20250514')`
146
- */
147
- model: LanguageModelV2;
148
- }
@@ -1,154 +0,0 @@
1
- // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
-
3
- import type {
4
- ModelMessage,
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
- * List all active agents registered in the metadata service.
45
- *
46
- * Returns a summary for each agent (name, label, role) suitable
47
- * for populating an agent selector dropdown in the UI.
48
- */
49
- async listAgents(): Promise<Array<{ name: string; label: string; role: string }>> {
50
- const rawItems = await this.metadataService.list('agent');
51
- const agents: Array<{ name: string; label: string; role: string }> = [];
52
-
53
- for (const raw of rawItems) {
54
- const result = AgentSchema.safeParse(raw);
55
- if (result.success && result.data.active) {
56
- agents.push({
57
- name: result.data.name,
58
- label: result.data.label,
59
- role: result.data.role,
60
- });
61
- }
62
- }
63
-
64
- return agents;
65
- }
66
-
67
- /**
68
- * Load and validate an agent definition by name.
69
- *
70
- * The raw metadata is validated through {@link AgentSchema} to ensure
71
- * required fields (`instructions`, `name`, `role`, etc.) are present
72
- * and well-typed. Returns `undefined` when the agent does not exist
73
- * or validation fails.
74
- */
75
- async loadAgent(agentName: string): Promise<Agent | undefined> {
76
- const raw = await this.metadataService.get('agent', agentName);
77
- if (!raw) return undefined;
78
-
79
- const result = AgentSchema.safeParse(raw);
80
- if (!result.success) {
81
- return undefined;
82
- }
83
- return result.data;
84
- }
85
-
86
- /**
87
- * Build the system message(s) that should be prepended to the
88
- * conversation when chatting with the given agent.
89
- */
90
- buildSystemMessages(agent: Agent, context?: AgentChatContext): ModelMessage[] {
91
- const parts: string[] = [];
92
-
93
- // Base instructions
94
- parts.push(agent.instructions);
95
-
96
- // Contextual hints from the user's current UI state
97
- if (context) {
98
- const ctx: string[] = [];
99
- if (context.objectName) ctx.push(`Current object: ${context.objectName}`);
100
- if (context.recordId) ctx.push(`Selected record ID: ${context.recordId}`);
101
- if (context.viewName) ctx.push(`Current view: ${context.viewName}`);
102
- if (ctx.length > 0) {
103
- parts.push('\n--- Current Context ---\n' + ctx.join('\n'));
104
- }
105
- }
106
-
107
- return [{ role: 'system' as const, content: parts.join('\n') }];
108
- }
109
-
110
- /**
111
- * Derive {@link AIRequestOptions} from an agent definition.
112
- *
113
- * Tool references declared in `agent.tools` are resolved by name against
114
- * `availableTools` (i.e. the full set of ToolRegistry definitions).
115
- * Any unresolved references (tools the agent declares but that are not
116
- * registered) are silently skipped — this is intentional so that agents
117
- * can be defined before all tools are available.
118
- *
119
- * @param agent - The agent definition to derive options from
120
- * @param availableTools - All tool definitions currently registered in the ToolRegistry
121
- * @returns Request options with model config and resolved tool definitions
122
- */
123
- buildRequestOptions(
124
- agent: Agent,
125
- availableTools: AIToolDefinition[],
126
- ): AIRequestOptions {
127
- const options: AIRequestOptions = {};
128
-
129
- // Model config
130
- if (agent.model) {
131
- options.model = agent.model.model;
132
- options.temperature = agent.model.temperature;
133
- options.maxTokens = agent.model.maxTokens;
134
- }
135
-
136
- // Resolve agent tool references → concrete tool definitions
137
- if (agent.tools && agent.tools.length > 0) {
138
- const toolMap = new Map(availableTools.map(t => [t.name, t]));
139
- const resolved: AIToolDefinition[] = [];
140
- for (const ref of agent.tools) {
141
- const def = toolMap.get(ref.name);
142
- if (def) {
143
- resolved.push(def);
144
- }
145
- }
146
- if (resolved.length > 0) {
147
- options.tools = resolved;
148
- options.toolChoice = 'auto';
149
- }
150
- }
151
-
152
- return options;
153
- }
154
- }