@objectstack/service-ai 4.0.1 → 4.0.3

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 (44) hide show
  1. package/.turbo/turbo-build.log +11 -11
  2. package/CHANGELOG.md +17 -0
  3. package/dist/index.cjs +1632 -355
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.d.cts +330 -87
  6. package/dist/index.d.ts +330 -87
  7. package/dist/index.js +1623 -352
  8. package/dist/index.js.map +1 -1
  9. package/package.json +27 -5
  10. package/src/__tests__/ai-service.test.ts +260 -27
  11. package/src/__tests__/auth-and-toolcalling.test.ts +81 -29
  12. package/src/__tests__/chatbot-features.test.ts +397 -102
  13. package/src/__tests__/metadata-tools.test.ts +970 -0
  14. package/src/__tests__/objectql-conversation-service.test.ts +34 -16
  15. package/src/__tests__/tool-routes.test.ts +191 -0
  16. package/src/__tests__/vercel-stream-encoder.test.ts +310 -0
  17. package/src/adapters/index.ts +2 -0
  18. package/src/adapters/memory-adapter.ts +17 -9
  19. package/src/adapters/vercel-adapter.ts +148 -0
  20. package/src/agent-runtime.ts +27 -3
  21. package/src/agents/index.ts +1 -0
  22. package/src/agents/metadata-assistant-agent.ts +87 -0
  23. package/src/ai-service.ts +75 -36
  24. package/src/conversation/in-memory-conversation-service.ts +2 -2
  25. package/src/conversation/objectql-conversation-service.ts +67 -18
  26. package/src/index.ts +22 -2
  27. package/src/plugin.ts +237 -30
  28. package/src/routes/agent-routes.ts +68 -12
  29. package/src/routes/ai-routes.ts +93 -14
  30. package/src/routes/index.ts +1 -0
  31. package/src/routes/message-utils.ts +90 -0
  32. package/src/routes/tool-routes.ts +142 -0
  33. package/src/stream/index.ts +3 -0
  34. package/src/stream/vercel-stream-encoder.ts +153 -0
  35. package/src/tools/add-field.tool.ts +70 -0
  36. package/src/tools/create-object.tool.ts +66 -0
  37. package/src/tools/data-tools.ts +4 -101
  38. package/src/tools/delete-field.tool.ts +38 -0
  39. package/src/tools/describe-object.tool.ts +31 -0
  40. package/src/tools/index.ts +12 -1
  41. package/src/tools/list-objects.tool.ts +34 -0
  42. package/src/tools/metadata-tools.ts +430 -0
  43. package/src/tools/modify-field.tool.ts +44 -0
  44. package/src/tools/tool-registry.ts +32 -9
@@ -1,7 +1,9 @@
1
1
  // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
2
 
3
- import type { IAIService, IAIConversationService, AIMessage } from '@objectstack/spec/contracts';
3
+ import type { IAIService, IAIConversationService, ModelMessage } from '@objectstack/spec/contracts';
4
4
  import type { Logger } from '@objectstack/spec/contracts';
5
+ import { encodeVercelDataStream } from '../stream/vercel-stream-encoder.js';
6
+ import { normalizeMessage, validateMessageContent } from './message-utils.js';
5
7
 
6
8
  /**
7
9
  * Minimal HTTP handler abstraction so routes stay framework-agnostic.
@@ -63,14 +65,27 @@ export interface RouteResponse {
63
65
  stream?: boolean;
64
66
  /** Async iterable of SSE events (when stream=true) */
65
67
  events?: AsyncIterable<unknown>;
68
+ /**
69
+ * When `true`, the HTTP server layer should encode the `events` iterable
70
+ * using the Vercel AI Data Stream Protocol frame format (`0:`, `9:`, `d:`, …)
71
+ * instead of generic SSE `data:` lines.
72
+ *
73
+ * @see https://ai-sdk.dev/docs/ai-sdk-ui/stream-protocol
74
+ */
75
+ vercelDataStream?: boolean;
66
76
  }
67
77
 
68
78
  /** Valid message roles accepted by the AI routes. */
69
79
  const VALID_ROLES = new Set<string>(['system', 'user', 'assistant', 'tool']);
70
80
 
71
81
  /**
72
- * Validate that `raw` is a well-formed AIMessage.
82
+ * Validate that `raw` is a well-formed message.
73
83
  * Returns null on success, or an error string on failure.
84
+ *
85
+ * Accepts:
86
+ * - Simple string `content` (legacy)
87
+ * - Array `content` (e.g. `[{ type: 'text', text: '...' }]`)
88
+ * - Vercel AI SDK v6 `parts` format (content may be absent/null)
74
89
  */
75
90
  function validateMessage(raw: unknown): string | null {
76
91
  if (typeof raw !== 'object' || raw === null) {
@@ -80,10 +95,10 @@ function validateMessage(raw: unknown): string | null {
80
95
  if (typeof msg.role !== 'string' || !VALID_ROLES.has(msg.role)) {
81
96
  return `message.role must be one of ${[...VALID_ROLES].map(r => `"${r}"`).join(', ')}`;
82
97
  }
83
- if (typeof msg.content !== 'string') {
84
- return 'message.content must be a string';
85
- }
86
- return null;
98
+
99
+ // Assistant / tool messages may legitimately have null or missing content
100
+ const allowEmpty = msg.role === 'assistant' || msg.role === 'tool';
101
+ return validateMessageContent(msg, { allowEmptyContent: allowEmpty });
87
102
  }
88
103
 
89
104
  /**
@@ -112,18 +127,26 @@ export function buildAIRoutes(
112
127
  ): RouteDefinition[] {
113
128
  return [
114
129
  // ── Chat ────────────────────────────────────────────────────
130
+ //
131
+ // Dual-mode endpoint compatible with both the legacy ObjectStack
132
+ // format (`{ messages, options }`) and the Vercel AI SDK useChat
133
+ // flat format (`{ messages, system, model, stream, … }`).
134
+ //
135
+ // Behaviour:
136
+ // • `stream !== false` → Vercel Data Stream Protocol (SSE)
137
+ // • `stream === false` → JSON response (legacy)
138
+ //
115
139
  {
116
140
  method: 'POST',
117
141
  path: '/api/v1/ai/chat',
118
- description: 'Synchronous chat completion',
142
+ description: 'Chat completion (supports Vercel AI Data Stream Protocol)',
119
143
  auth: true,
120
144
  permissions: ['ai:chat'],
121
145
  handler: async (req) => {
122
- const { messages, options } = (req.body ?? {}) as {
123
- messages?: unknown[];
124
- options?: Record<string, unknown>;
125
- };
146
+ const body = (req.body ?? {}) as Record<string, unknown>;
126
147
 
148
+ // ── Parse messages ───────────────────────────────────
149
+ const messages = body.messages as unknown[] | undefined;
127
150
  if (!Array.isArray(messages) || messages.length === 0) {
128
151
  return { status: 400, body: { error: 'messages array is required' } };
129
152
  }
@@ -133,8 +156,64 @@ export function buildAIRoutes(
133
156
  if (err) return { status: 400, body: { error: err } };
134
157
  }
135
158
 
159
+ // ── Resolve options ──────────────────────────────────
160
+ // Accept legacy nested `options` object **or** Vercel-style
161
+ // flat fields (`model`, `temperature`, `maxTokens`).
162
+ const nested = (body.options ?? {}) as Record<string, unknown>;
163
+ const resolvedOptions: Record<string, unknown> = {
164
+ ...nested,
165
+ ...(body.model != null && { model: body.model }),
166
+ ...(body.temperature != null && { temperature: body.temperature }),
167
+ ...(body.maxTokens != null && { maxTokens: body.maxTokens }),
168
+ };
169
+
170
+ // ── Prepend system prompt ────────────────────────────
171
+ // Vercel useChat sends `system` (or the deprecated `systemPrompt`)
172
+ // as a top-level field. We prepend it as a system message.
173
+ const rawSystemPrompt = body.system ?? body.systemPrompt;
174
+ if (rawSystemPrompt != null && typeof rawSystemPrompt !== 'string') {
175
+ return { status: 400, body: { error: 'system/systemPrompt must be a string' } };
176
+ }
177
+ const systemPrompt = rawSystemPrompt as string | undefined;
178
+ const finalMessages: ModelMessage[] = [
179
+ ...(systemPrompt
180
+ ? [{ role: 'system' as const, content: systemPrompt }]
181
+ : []),
182
+ ...messages.map(m => normalizeMessage(m as Record<string, unknown>)),
183
+ ];
184
+
185
+ // ── Choose response mode ─────────────────────────────
186
+ const wantStream = body.stream !== false;
187
+
188
+ if (wantStream) {
189
+ // UI Message Stream Protocol (SSE with JSON payloads)
190
+ try {
191
+ if (!(aiService as any).streamChatWithTools) {
192
+ return { status: 501, body: { error: 'Streaming is not supported by the configured AI service' } };
193
+ }
194
+ const events = (aiService as any).streamChatWithTools(finalMessages, resolvedOptions as any);
195
+ return {
196
+ status: 200,
197
+ stream: true,
198
+ vercelDataStream: true,
199
+ contentType: 'text/event-stream',
200
+ headers: {
201
+ 'Content-Type': 'text/event-stream',
202
+ 'Cache-Control': 'no-cache',
203
+ 'Connection': 'keep-alive',
204
+ 'x-vercel-ai-ui-message-stream': 'v1',
205
+ },
206
+ events: encodeVercelDataStream(events),
207
+ };
208
+ } catch (err) {
209
+ logger.error('[AI Route] /chat stream error', err instanceof Error ? err : undefined);
210
+ return { status: 500, body: { error: 'Internal AI service error' } };
211
+ }
212
+ }
213
+
214
+ // JSON response (non-streaming)
136
215
  try {
137
- const result = await aiService.chat(messages as AIMessage[], options as any);
216
+ const result = await (aiService as any).chatWithTools(finalMessages, resolvedOptions as any);
138
217
  return { status: 200, body: result };
139
218
  } catch (err) {
140
219
  logger.error('[AI Route] /chat error', err instanceof Error ? err : undefined);
@@ -169,7 +248,7 @@ export function buildAIRoutes(
169
248
  if (!aiService.streamChat) {
170
249
  return { status: 501, body: { error: 'Streaming is not supported by the configured AI service' } };
171
250
  }
172
- const events = aiService.streamChat(messages as AIMessage[], options as any);
251
+ const events = aiService.streamChat(messages.map(m => normalizeMessage(m as Record<string, unknown>)), options as any);
173
252
  return { status: 200, stream: true, events };
174
253
  } catch (err) {
175
254
  logger.error('[AI Route] /chat/stream error', err instanceof Error ? err : undefined);
@@ -312,7 +391,7 @@ export function buildAIRoutes(
312
391
  }
313
392
  }
314
393
 
315
- const conversation = await conversationService.addMessage(id, message as AIMessage);
394
+ const conversation = await conversationService.addMessage(id, message as ModelMessage);
316
395
  return { status: 200, body: conversation };
317
396
  } catch (err) {
318
397
  const msg = err instanceof Error ? err.message : String(err);
@@ -2,3 +2,4 @@
2
2
 
3
3
  export { buildAIRoutes } from './ai-routes.js';
4
4
  export type { RouteDefinition, RouteRequest, RouteResponse, RouteUserContext } from './ai-routes.js';
5
+ export { normalizeMessage, validateMessageContent } from './message-utils.js';
@@ -0,0 +1,90 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ import type { ModelMessage } from '@objectstack/spec/contracts';
4
+
5
+ /**
6
+ * Normalize a Vercel AI SDK v6 message (which may use `parts` instead of
7
+ * `content`) into a plain `{ role, content }` ModelMessage.
8
+ *
9
+ * Shared between the general chat routes and agent chat routes.
10
+ */
11
+ export function normalizeMessage(raw: Record<string, unknown>): ModelMessage {
12
+ const role = raw.role as string;
13
+
14
+ // If content is already a string, use it directly
15
+ if (typeof raw.content === 'string') {
16
+ return { role, content: raw.content } as unknown as ModelMessage;
17
+ }
18
+
19
+ // If content is an array (multi-part), pass through
20
+ if (Array.isArray(raw.content)) {
21
+ return { role, content: raw.content } as unknown as ModelMessage;
22
+ }
23
+
24
+ // Vercel AI SDK v6: extract text from `parts` array
25
+ if (Array.isArray(raw.parts)) {
26
+ const textParts = (raw.parts as Array<Record<string, unknown>>)
27
+ .filter(p => p.type === 'text' && typeof p.text === 'string')
28
+ .map(p => p.text as string);
29
+ if (textParts.length > 0) {
30
+ return { role, content: textParts.join('') } as unknown as ModelMessage;
31
+ }
32
+ }
33
+
34
+ // Fallback: empty content (e.g. tool-only assistant messages)
35
+ return { role, content: '' } as unknown as ModelMessage;
36
+ }
37
+
38
+ /**
39
+ * Validate message content/parts format (role-agnostic).
40
+ *
41
+ * Returns `null` when the content shape is valid, or an error string
42
+ * describing the first violation found.
43
+ *
44
+ * Accepts:
45
+ * - Simple string `content` (legacy)
46
+ * - Array `content` (e.g. `[{ type: 'text', text: '...' }]`)
47
+ * - Vercel AI SDK v6 `parts` format (content may be absent/null)
48
+ * - Null/undefined `content` for assistant messages (when `allowEmpty` is true)
49
+ */
50
+ export function validateMessageContent(
51
+ msg: Record<string, unknown>,
52
+ opts?: { allowEmptyContent?: boolean },
53
+ ): string | null {
54
+ const content = msg.content;
55
+
56
+ // Vercel AI SDK v6 sends `parts` instead of (or alongside) `content`.
57
+ // Accept any message that carries a `parts` array, even when `content` is absent.
58
+ if (Array.isArray(msg.parts)) {
59
+ return null;
60
+ }
61
+
62
+ // content is a plain string — OK
63
+ if (typeof content === 'string') {
64
+ return null;
65
+ }
66
+
67
+ // content is an array of typed parts (legacy multi-part format)
68
+ if (Array.isArray(content)) {
69
+ for (const part of content as unknown[]) {
70
+ if (typeof part !== 'object' || part === null) {
71
+ return 'message.content array elements must be non-null objects';
72
+ }
73
+ const partObj = part as Record<string, unknown>;
74
+ if (typeof partObj.type !== 'string') {
75
+ return 'each message.content array element must have a string "type" property';
76
+ }
77
+ if (partObj.type === 'text' && typeof partObj.text !== 'string') {
78
+ return 'message.content elements with type "text" must have a string "text" property';
79
+ }
80
+ }
81
+ return null;
82
+ }
83
+
84
+ // Allow empty content for certain roles (e.g. assistant tool-call messages)
85
+ if ((content === null || content === undefined) && opts?.allowEmptyContent) {
86
+ return null;
87
+ }
88
+
89
+ return 'message.content must be a string, an array, or include parts';
90
+ }
@@ -0,0 +1,142 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ import type { Logger } from '@objectstack/spec/contracts';
4
+ import type { AIService } from '../ai-service.js';
5
+ import type { RouteDefinition } from './ai-routes.js';
6
+
7
+ /**
8
+ * Extract string value from tool execution result output.
9
+ */
10
+ function extractOutputValue(output: any): string {
11
+ if (!output) return '';
12
+ if (typeof output === 'string') return output;
13
+ if (typeof output === 'object' && 'value' in output) {
14
+ return String(output.value ?? '');
15
+ }
16
+ return JSON.stringify(output);
17
+ }
18
+
19
+ /**
20
+ * Build tool-specific REST routes.
21
+ *
22
+ * | Method | Path | Description |
23
+ * |:---|:---|:---|
24
+ * | GET | /api/v1/ai/tools | List all registered tools |
25
+ * | POST | /api/v1/ai/tools/:toolName/execute | Execute a tool with parameters |
26
+ */
27
+ export function buildToolRoutes(
28
+ aiService: AIService,
29
+ logger: Logger,
30
+ ): RouteDefinition[] {
31
+ return [
32
+ // ── List registered tools ──────────────────────────────────────
33
+ {
34
+ method: 'GET',
35
+ path: '/api/v1/ai/tools',
36
+ description: 'List all registered AI tools',
37
+ auth: true,
38
+ permissions: ['ai:tools'],
39
+ handler: async () => {
40
+ try {
41
+ const tools = aiService.toolRegistry.getAll();
42
+ return {
43
+ status: 200,
44
+ body: {
45
+ tools: tools.map(t => ({
46
+ name: t.name,
47
+ description: t.description,
48
+ category: (t as any).category,
49
+ }))
50
+ }
51
+ };
52
+ } catch (err) {
53
+ logger.error(
54
+ '[AI Route] /tools list error',
55
+ err instanceof Error ? err : undefined,
56
+ );
57
+ return { status: 500, body: { error: 'Internal AI service error' } };
58
+ }
59
+ },
60
+ },
61
+
62
+ // ── Execute a tool ──────────────────────────────────────────────
63
+ //
64
+ // Executes a tool with the provided parameters.
65
+ // This is intended for testing/playground use.
66
+ //
67
+ {
68
+ method: 'POST',
69
+ path: '/api/v1/ai/tools/:toolName/execute',
70
+ description: 'Execute a tool with parameters (playground/testing)',
71
+ auth: true,
72
+ permissions: ['ai:tools', 'ai:execute'],
73
+ handler: async (req) => {
74
+ const toolName = req.params?.toolName;
75
+ if (!toolName) {
76
+ return { status: 400, body: { error: 'toolName parameter is required' } };
77
+ }
78
+
79
+ // Parse request body
80
+ const body = (req.body ?? {}) as Record<string, unknown>;
81
+ const { parameters } = body as {
82
+ parameters?: Record<string, unknown>;
83
+ };
84
+
85
+ if (!parameters || typeof parameters !== 'object') {
86
+ return { status: 400, body: { error: 'parameters object is required' } };
87
+ }
88
+
89
+ try {
90
+ // Check if tool exists
91
+ if (!aiService.toolRegistry.has(toolName)) {
92
+ return { status: 404, body: { error: `Tool "${toolName}" not found` } };
93
+ }
94
+
95
+ // Execute the tool using ToolRegistry's execute method
96
+ const startTime = Date.now();
97
+
98
+ const toolCallPart = {
99
+ type: 'tool-call' as const,
100
+ toolCallId: `playground-${Date.now()}`,
101
+ toolName,
102
+ input: parameters,
103
+ };
104
+
105
+ const result = await aiService.toolRegistry.execute(toolCallPart);
106
+ const duration = Date.now() - startTime;
107
+
108
+ // Check if execution resulted in an error
109
+ if (result.isError) {
110
+ const errorMsg = extractOutputValue(result.output);
111
+ logger.error(
112
+ `[AI Route] Tool execution error: ${toolName}`,
113
+ new Error(errorMsg),
114
+ );
115
+ return {
116
+ status: 500,
117
+ body: {
118
+ error: errorMsg,
119
+ duration,
120
+ },
121
+ };
122
+ }
123
+
124
+ return {
125
+ status: 200,
126
+ body: {
127
+ result: extractOutputValue(result.output),
128
+ duration,
129
+ toolName,
130
+ },
131
+ };
132
+ } catch (err) {
133
+ logger.error(
134
+ '[AI Route] /tools/:toolName/execute error',
135
+ err instanceof Error ? err : undefined,
136
+ );
137
+ return { status: 500, body: { error: 'Internal AI service error' } };
138
+ }
139
+ },
140
+ },
141
+ ];
142
+ }
@@ -0,0 +1,3 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ export { encodeStreamPart, encodeVercelDataStream } from './vercel-stream-encoder.js';
@@ -0,0 +1,153 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ /**
4
+ * Vercel AI SDK v6 — UI Message Stream Encoder
5
+ *
6
+ * Converts `AsyncIterable<TextStreamPart<ToolSet>>` (the internal ObjectStack
7
+ * streaming format) into the Vercel AI SDK v6 **UI Message Stream Protocol**.
8
+ *
9
+ * Wire format: Server-Sent Events (SSE) with JSON payloads.
10
+ * `data: {"type":"text-delta","id":"0","delta":"Hello"}\n\n`
11
+ *
12
+ * The client-side `DefaultChatTransport` from `ai` v6 uses
13
+ * `parseJsonEventStream` to parse these SSE events.
14
+ *
15
+ * @see https://ai-sdk.dev/docs/ai-sdk-ui/stream-protocol
16
+ */
17
+
18
+ import type { TextStreamPart, ToolSet } from 'ai';
19
+
20
+ // ── SSE helpers ──────────────────────────────────────────────────────
21
+
22
+ function sse(data: object): string {
23
+ return `data: ${JSON.stringify(data)}\n\n`;
24
+ }
25
+
26
+ /**
27
+ * Encode data using Vercel AI SDK Data Stream Protocol prefixes.
28
+ * @see https://ai-sdk.dev/docs/ai-sdk-ui/stream-protocol
29
+ */
30
+ function dataStreamLine(prefix: string, data: object): string {
31
+ return `${prefix}:${JSON.stringify(data)}\n`;
32
+ }
33
+
34
+ // ── Public API ──────────────────────────────────────────────────────
35
+
36
+ /**
37
+ * Encode a single `TextStreamPart` event into SSE-formatted UI Message
38
+ * Stream chunk(s).
39
+ *
40
+ * Returns an empty string for event types that have no wire-format mapping.
41
+ */
42
+ export function encodeStreamPart(part: TextStreamPart<ToolSet>): string {
43
+ switch (part.type) {
44
+ case 'text-delta':
45
+ return sse({ type: 'text-delta', id: '0', delta: part.text });
46
+
47
+ case 'tool-input-start':
48
+ return sse({
49
+ type: 'tool-input-start',
50
+ toolCallId: part.id,
51
+ toolName: part.toolName,
52
+ });
53
+
54
+ case 'tool-input-delta':
55
+ return sse({
56
+ type: 'tool-input-delta',
57
+ toolCallId: part.id,
58
+ inputTextDelta: part.delta,
59
+ });
60
+
61
+ case 'tool-call':
62
+ return sse({
63
+ type: 'tool-input-available',
64
+ toolCallId: part.toolCallId,
65
+ toolName: part.toolName,
66
+ input: part.input,
67
+ });
68
+
69
+ case 'tool-result':
70
+ return sse({
71
+ type: 'tool-output-available',
72
+ toolCallId: part.toolCallId,
73
+ output: part.output,
74
+ });
75
+
76
+ case 'error':
77
+ return sse({
78
+ type: 'error',
79
+ errorText: String(part.error),
80
+ });
81
+
82
+ // Handle reasoning/thinking streams (DeepSeek R1, o1-style models)
83
+ // Use 'g:' prefix for reasoning content per Vercel AI SDK protocol
84
+ case 'reasoning-start':
85
+ return dataStreamLine('g', { text: '' });
86
+
87
+ case 'reasoning-delta':
88
+ return dataStreamLine('g', { text: part.text });
89
+
90
+ case 'reasoning-end':
91
+ return ''; // No specific end marker needed for reasoning
92
+
93
+ // finish-step and finish are handled by the generator, not here
94
+ default:
95
+ // Pass through any unknown event types that might be custom
96
+ // (e.g., step-start, step-finish from custom providers)
97
+ if ((part as any).type?.startsWith('step-')) {
98
+ return sse(part as any);
99
+ }
100
+ return '';
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Transform an `AsyncIterable<TextStreamPart>` into an `AsyncIterable<string>`
106
+ * where each yielded string is an SSE-formatted UI Message Stream chunk.
107
+ *
108
+ * Lifecycle order required by the client:
109
+ * start → start-step → text-start → text-delta* → text-end → finish-step → finish → [DONE]
110
+ */
111
+ export async function* encodeVercelDataStream(
112
+ events: AsyncIterable<TextStreamPart<ToolSet>>,
113
+ ): AsyncIterable<string> {
114
+ // Preamble
115
+ yield sse({ type: 'start' });
116
+ yield sse({ type: 'start-step' });
117
+ yield sse({ type: 'text-start', id: '0' });
118
+
119
+ let textOpen = true;
120
+ let finishReason = 'stop';
121
+
122
+ for await (const part of events) {
123
+ // Capture finish reason
124
+ if (part.type === 'finish') {
125
+ finishReason = part.finishReason ?? 'stop';
126
+ }
127
+
128
+ // Before finish-step/finish, close the text part first
129
+ if (part.type === 'finish-step' || part.type === 'finish') {
130
+ if (textOpen) {
131
+ yield sse({ type: 'text-end', id: '0' });
132
+ textOpen = false;
133
+ }
134
+ // Don't emit these via encodeStreamPart — we handle them in postamble
135
+ continue;
136
+ }
137
+
138
+ const frame = encodeStreamPart(part);
139
+ if (frame) {
140
+ yield frame;
141
+ }
142
+ }
143
+
144
+ // Close text if still open (safety)
145
+ if (textOpen) {
146
+ yield sse({ type: 'text-end', id: '0' });
147
+ }
148
+
149
+ // Postamble
150
+ yield sse({ type: 'finish-step' });
151
+ yield sse({ type: 'finish', finishReason });
152
+ yield 'data: [DONE]\n\n';
153
+ }
@@ -0,0 +1,70 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ import { defineTool } from '@objectstack/spec/ai';
4
+
5
+ /**
6
+ * add_field — AI Tool Metadata
7
+ *
8
+ * Adds a new field (column) to an existing data object.
9
+ * Validates snake_case for objectName, field name, reference,
10
+ * and select option values before merging into the definition.
11
+ */
12
+ export const addFieldTool = defineTool({
13
+ name: 'add_field',
14
+ label: 'Add Field',
15
+ description:
16
+ 'Adds a new field (column) to an existing data object. ' +
17
+ 'Use this when the user wants to add a property, column, or attribute to a table.',
18
+ category: 'data',
19
+ builtIn: true,
20
+ parameters: {
21
+ type: 'object',
22
+ properties: {
23
+ objectName: {
24
+ type: 'string',
25
+ description: 'Target object machine name (snake_case)',
26
+ },
27
+ name: {
28
+ type: 'string',
29
+ description: 'Field machine name (snake_case, e.g. due_date)',
30
+ },
31
+ label: {
32
+ type: 'string',
33
+ description: 'Human-readable field label (e.g. Due Date)',
34
+ },
35
+ type: {
36
+ type: 'string',
37
+ description: 'Field data type',
38
+ enum: ['text', 'textarea', 'number', 'boolean', 'date', 'datetime', 'select', 'lookup', 'formula', 'autonumber'],
39
+ },
40
+ required: {
41
+ type: 'boolean',
42
+ description: 'Whether the field is required',
43
+ },
44
+ defaultValue: {
45
+ description: 'Default value for the field',
46
+ },
47
+ options: {
48
+ type: 'array',
49
+ description: 'Options for select/picklist fields',
50
+ items: {
51
+ type: 'object',
52
+ properties: {
53
+ label: { type: 'string' },
54
+ value: {
55
+ type: 'string',
56
+ description: 'Option machine identifier (lowercase snake_case, e.g. high_priority)',
57
+ pattern: '^[a-z_][a-z0-9_]*$',
58
+ },
59
+ },
60
+ },
61
+ },
62
+ reference: {
63
+ type: 'string',
64
+ description: 'Referenced object name for lookup fields (snake_case, e.g. account)',
65
+ },
66
+ },
67
+ required: ['objectName', 'name', 'type'],
68
+ additionalProperties: false,
69
+ },
70
+ });