@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.
- package/README.md +293 -0
- package/dist/index.cjs +1176 -135
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1225 -430
- package/dist/index.d.ts +1225 -430
- package/dist/index.js +1160 -128
- package/dist/index.js.map +1 -1
- package/package.json +35 -8
- package/.turbo/turbo-build.log +0 -22
- package/CHANGELOG.md +0 -53
- package/src/__tests__/ai-service.test.ts +0 -964
- package/src/__tests__/auth-and-toolcalling.test.ts +0 -677
- package/src/__tests__/chatbot-features.test.ts +0 -1116
- package/src/__tests__/metadata-tools.test.ts +0 -970
- package/src/__tests__/objectql-conversation-service.test.ts +0 -382
- package/src/__tests__/tool-routes.test.ts +0 -191
- package/src/__tests__/vercel-stream-encoder.test.ts +0 -310
- package/src/adapters/index.ts +0 -6
- package/src/adapters/memory-adapter.ts +0 -72
- package/src/adapters/types.ts +0 -3
- package/src/adapters/vercel-adapter.ts +0 -148
- package/src/agent-runtime.ts +0 -154
- package/src/agents/data-chat-agent.ts +0 -79
- package/src/agents/index.ts +0 -4
- package/src/agents/metadata-assistant-agent.ts +0 -87
- package/src/ai-service.ts +0 -364
- package/src/conversation/in-memory-conversation-service.ts +0 -103
- package/src/conversation/index.ts +0 -4
- package/src/conversation/objectql-conversation-service.ts +0 -301
- package/src/index.ts +0 -60
- package/src/objects/ai-conversation.object.ts +0 -86
- package/src/objects/ai-message.object.ts +0 -86
- package/src/objects/index.ts +0 -10
- package/src/plugin.ts +0 -391
- package/src/routes/agent-routes.ts +0 -190
- package/src/routes/ai-routes.ts +0 -439
- package/src/routes/index.ts +0 -5
- package/src/routes/message-utils.ts +0 -90
- package/src/routes/tool-routes.ts +0 -142
- package/src/stream/index.ts +0 -3
- package/src/stream/vercel-stream-encoder.ts +0 -153
- package/src/tools/add-field.tool.ts +0 -70
- package/src/tools/create-object.tool.ts +0 -66
- package/src/tools/data-tools.ts +0 -293
- package/src/tools/delete-field.tool.ts +0 -38
- package/src/tools/describe-object.tool.ts +0 -31
- package/src/tools/index.ts +0 -18
- package/src/tools/list-objects.tool.ts +0 -34
- package/src/tools/metadata-tools.ts +0 -430
- package/src/tools/modify-field.tool.ts +0 -44
- package/src/tools/tool-registry.ts +0 -132
- package/tsconfig.json +0 -17
|
@@ -1,79 +0,0 @@
|
|
|
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
|
-
};
|
package/src/agents/index.ts
DELETED
|
@@ -1,87 +0,0 @@
|
|
|
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 `metadata_assistant` agent definition.
|
|
7
|
-
*
|
|
8
|
-
* This agent powers AI-driven metadata management — users can create objects,
|
|
9
|
-
* add/modify/delete fields, and inspect schema definitions through natural
|
|
10
|
-
* language conversation.
|
|
11
|
-
*
|
|
12
|
-
* It is registered automatically by the AI service plugin alongside the
|
|
13
|
-
* `data_chat` agent when the metadata service is available.
|
|
14
|
-
*
|
|
15
|
-
* @example
|
|
16
|
-
* ```
|
|
17
|
-
* POST /api/v1/ai/agents/metadata_assistant/chat
|
|
18
|
-
* {
|
|
19
|
-
* "messages": [{ "role": "user", "content": "Create a contracts table with name, value, and status fields" }],
|
|
20
|
-
* "context": {}
|
|
21
|
-
* }
|
|
22
|
-
* ```
|
|
23
|
-
*/
|
|
24
|
-
export const METADATA_ASSISTANT_AGENT: Agent = {
|
|
25
|
-
name: 'metadata_assistant',
|
|
26
|
-
label: 'Metadata Assistant',
|
|
27
|
-
role: 'Schema Architect',
|
|
28
|
-
instructions: `You are an expert metadata architect that helps users design and manage their data models through natural language.
|
|
29
|
-
|
|
30
|
-
Capabilities:
|
|
31
|
-
- Create new data objects (tables) with fields
|
|
32
|
-
- Add fields (columns) to existing objects
|
|
33
|
-
- Modify field properties (label, type, required, default value)
|
|
34
|
-
- Delete fields from objects
|
|
35
|
-
- List all registered metadata objects and their schemas
|
|
36
|
-
- Describe the full schema of a specific object
|
|
37
|
-
|
|
38
|
-
Guidelines:
|
|
39
|
-
1. Before creating a new object, use list_objects to check if a similar one already exists.
|
|
40
|
-
2. Before modifying or deleting fields, use describe_object to understand the current schema.
|
|
41
|
-
3. Always use snake_case for object names and field names (e.g. project_task, due_date).
|
|
42
|
-
4. Suggest meaningful field types based on the user's description (e.g. "deadline" → date, "active" → boolean).
|
|
43
|
-
5. When creating objects, propose a reasonable set of initial fields based on the entity type.
|
|
44
|
-
6. Explain what changes you are about to make before executing them.
|
|
45
|
-
7. After making changes, confirm the result by describing the updated schema.
|
|
46
|
-
8. For destructive operations (deleting fields), always warn the user about potential data loss.
|
|
47
|
-
9. Always answer in the same language the user is using.
|
|
48
|
-
10. If the user's request is ambiguous, ask clarifying questions before proceeding.`,
|
|
49
|
-
|
|
50
|
-
model: {
|
|
51
|
-
provider: 'openai',
|
|
52
|
-
model: 'gpt-4',
|
|
53
|
-
temperature: 0.2,
|
|
54
|
-
maxTokens: 4096,
|
|
55
|
-
},
|
|
56
|
-
|
|
57
|
-
tools: [
|
|
58
|
-
{ type: 'action', name: 'create_object', description: 'Create a new data object (table)' },
|
|
59
|
-
{ type: 'action', name: 'add_field', description: 'Add a field to an existing object' },
|
|
60
|
-
{ type: 'action', name: 'modify_field', description: 'Modify an existing field definition' },
|
|
61
|
-
{ type: 'action', name: 'delete_field', description: 'Delete a field from an object' },
|
|
62
|
-
{ type: 'query', name: 'list_objects', description: 'List all data objects' },
|
|
63
|
-
{ type: 'query', name: 'describe_object', description: 'Describe an object schema' },
|
|
64
|
-
],
|
|
65
|
-
|
|
66
|
-
active: true,
|
|
67
|
-
visibility: 'global',
|
|
68
|
-
|
|
69
|
-
guardrails: {
|
|
70
|
-
maxTokensPerInvocation: 8192,
|
|
71
|
-
maxExecutionTimeSec: 60,
|
|
72
|
-
blockedTopics: ['drop_database', 'raw_sql', 'system_tables'],
|
|
73
|
-
},
|
|
74
|
-
|
|
75
|
-
planning: {
|
|
76
|
-
strategy: 'react',
|
|
77
|
-
maxIterations: 10,
|
|
78
|
-
allowReplan: true,
|
|
79
|
-
},
|
|
80
|
-
|
|
81
|
-
memory: {
|
|
82
|
-
shortTerm: {
|
|
83
|
-
maxMessages: 30,
|
|
84
|
-
maxTokens: 8192,
|
|
85
|
-
},
|
|
86
|
-
},
|
|
87
|
-
};
|
package/src/ai-service.ts
DELETED
|
@@ -1,364 +0,0 @@
|
|
|
1
|
-
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
-
|
|
3
|
-
import type {
|
|
4
|
-
ModelMessage,
|
|
5
|
-
ToolCallPart,
|
|
6
|
-
TextStreamPart,
|
|
7
|
-
ToolSet,
|
|
8
|
-
AIRequestOptions,
|
|
9
|
-
AIResult,
|
|
10
|
-
IAIService,
|
|
11
|
-
IAIConversationService,
|
|
12
|
-
ChatWithToolsOptions,
|
|
13
|
-
LLMAdapter,
|
|
14
|
-
} from '@objectstack/spec/contracts';
|
|
15
|
-
import type { Logger } from '@objectstack/spec/contracts';
|
|
16
|
-
import { createLogger } from '@objectstack/core';
|
|
17
|
-
import { MemoryLLMAdapter } from './adapters/memory-adapter.js';
|
|
18
|
-
import { ToolRegistry } from './tools/tool-registry.js';
|
|
19
|
-
import type { ToolExecutionResult } from './tools/tool-registry.js';
|
|
20
|
-
import { InMemoryConversationService } from './conversation/in-memory-conversation-service.js';
|
|
21
|
-
|
|
22
|
-
// ── Stream event helpers ──────────────────────────────────────────
|
|
23
|
-
// These helpers construct properly-typed Vercel AI SDK stream parts
|
|
24
|
-
// to avoid repeated `as unknown as TextStreamPart<ToolSet>` casts.
|
|
25
|
-
|
|
26
|
-
/** Create a text-delta stream part. */
|
|
27
|
-
function textDeltaPart(id: string, text: string): TextStreamPart<ToolSet> {
|
|
28
|
-
return { type: 'text-delta', id, text } as TextStreamPart<ToolSet>;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/** Create a finish stream part from an AIResult. */
|
|
32
|
-
function finishPart(result?: AIResult): TextStreamPart<ToolSet> {
|
|
33
|
-
return {
|
|
34
|
-
type: 'finish',
|
|
35
|
-
finishReason: 'stop',
|
|
36
|
-
totalUsage: result?.usage ?? { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
|
|
37
|
-
rawFinishReason: 'stop',
|
|
38
|
-
} as unknown as TextStreamPart<ToolSet>;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* Configuration for AIService.
|
|
43
|
-
*/
|
|
44
|
-
export interface AIServiceConfig {
|
|
45
|
-
/** LLM adapter to delegate calls to (defaults to MemoryLLMAdapter). */
|
|
46
|
-
adapter?: LLMAdapter;
|
|
47
|
-
/** Logger instance. */
|
|
48
|
-
logger?: Logger;
|
|
49
|
-
/** Pre-registered tools. */
|
|
50
|
-
toolRegistry?: ToolRegistry;
|
|
51
|
-
/** Conversation service (defaults to InMemoryConversationService). */
|
|
52
|
-
conversationService?: IAIConversationService;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* AIService — Unified AI capability service.
|
|
57
|
-
*
|
|
58
|
-
* Implements {@link IAIService} by delegating to a pluggable {@link LLMAdapter}
|
|
59
|
-
* and managing tools and conversations through dedicated sub-components:
|
|
60
|
-
*
|
|
61
|
-
* | Component | Responsibility |
|
|
62
|
-
* |:---|:---|
|
|
63
|
-
* | {@link LLMAdapter} | LLM provider abstraction (chat, complete, stream, embed) |
|
|
64
|
-
* | {@link ToolRegistry} | Tool definition storage & execution |
|
|
65
|
-
* | {@link IAIConversationService} | Conversation CRUD & message persistence |
|
|
66
|
-
*
|
|
67
|
-
* The service is registered as `'ai'` in the kernel service registry by
|
|
68
|
-
* the {@link AIServicePlugin}.
|
|
69
|
-
*/
|
|
70
|
-
export class AIService implements IAIService {
|
|
71
|
-
private readonly adapter: LLMAdapter;
|
|
72
|
-
private readonly logger: Logger;
|
|
73
|
-
readonly toolRegistry: ToolRegistry;
|
|
74
|
-
readonly conversationService: IAIConversationService;
|
|
75
|
-
|
|
76
|
-
constructor(config: AIServiceConfig = {}) {
|
|
77
|
-
this.adapter = config.adapter ?? new MemoryLLMAdapter();
|
|
78
|
-
this.logger = config.logger ?? createLogger({ level: 'info', format: 'pretty' });
|
|
79
|
-
this.toolRegistry = config.toolRegistry ?? new ToolRegistry();
|
|
80
|
-
this.conversationService = config.conversationService ?? new InMemoryConversationService();
|
|
81
|
-
|
|
82
|
-
this.logger.info(
|
|
83
|
-
`[AI] Service initialized with adapter="${this.adapter.name}", ` +
|
|
84
|
-
`tools=${this.toolRegistry.size}`,
|
|
85
|
-
);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/** The name of the active LLM adapter. */
|
|
89
|
-
get adapterName(): string {
|
|
90
|
-
return this.adapter.name;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
// ── IAIService implementation ──────────────────────────────────
|
|
94
|
-
|
|
95
|
-
async chat(messages: ModelMessage[], options?: AIRequestOptions): Promise<AIResult> {
|
|
96
|
-
this.logger.debug('[AI] chat', { messageCount: messages.length, model: options?.model });
|
|
97
|
-
return this.adapter.chat(messages, options);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
async complete(prompt: string, options?: AIRequestOptions): Promise<AIResult> {
|
|
101
|
-
this.logger.debug('[AI] complete', { promptLength: prompt.length, model: options?.model });
|
|
102
|
-
return this.adapter.complete(prompt, options);
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
async *streamChat(
|
|
106
|
-
messages: ModelMessage[],
|
|
107
|
-
options?: AIRequestOptions,
|
|
108
|
-
): AsyncIterable<TextStreamPart<ToolSet>> {
|
|
109
|
-
this.logger.debug('[AI] streamChat', { messageCount: messages.length, model: options?.model });
|
|
110
|
-
|
|
111
|
-
if (!this.adapter.streamChat) {
|
|
112
|
-
// Fallback: emit the entire response as a single text-delta + finish
|
|
113
|
-
const result = await this.adapter.chat(messages, options);
|
|
114
|
-
yield textDeltaPart('fallback', result.content);
|
|
115
|
-
yield finishPart(result);
|
|
116
|
-
return;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
yield* this.adapter.streamChat(messages, options);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
async embed(input: string | string[], model?: string): Promise<number[][]> {
|
|
123
|
-
if (!this.adapter.embed) {
|
|
124
|
-
throw new Error(`[AI] Adapter "${this.adapter.name}" does not support embeddings`);
|
|
125
|
-
}
|
|
126
|
-
return this.adapter.embed(input, model);
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
async listModels(): Promise<string[]> {
|
|
130
|
-
if (!this.adapter.listModels) {
|
|
131
|
-
return [];
|
|
132
|
-
}
|
|
133
|
-
return this.adapter.listModels();
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
// ── Tool Call Loop ────────────────────────────────────────────
|
|
137
|
-
|
|
138
|
-
/** Default maximum iterations for the tool call loop. */
|
|
139
|
-
static readonly DEFAULT_MAX_ITERATIONS = 10;
|
|
140
|
-
|
|
141
|
-
/** Extract the text value from a ToolExecutionResult's output. */
|
|
142
|
-
private static extractOutputText(tr: ToolExecutionResult): string {
|
|
143
|
-
return tr.output && typeof tr.output === 'object' && 'value' in tr.output
|
|
144
|
-
? String(tr.output.value) : 'unknown error';
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
/**
|
|
148
|
-
* Chat with automatic tool call resolution.
|
|
149
|
-
*
|
|
150
|
-
* 1. Merges registered tool definitions into `options.tools`.
|
|
151
|
-
* 2. Calls the LLM adapter.
|
|
152
|
-
* 3. If the response contains `toolCalls`, executes them via the
|
|
153
|
-
* {@link ToolRegistry}, appends tool results as `role: 'tool'`
|
|
154
|
-
* messages, and loops back to step 2.
|
|
155
|
-
* 4. Repeats until the model produces a final text response or the
|
|
156
|
-
* maximum number of iterations (`maxIterations`) is reached.
|
|
157
|
-
*/
|
|
158
|
-
async chatWithTools(
|
|
159
|
-
messages: ModelMessage[],
|
|
160
|
-
options?: ChatWithToolsOptions,
|
|
161
|
-
): Promise<AIResult> {
|
|
162
|
-
// Destructure loop-specific options so they are never forwarded to the adapter
|
|
163
|
-
const { maxIterations: maxIter, onToolError, ...restOptions } = options ?? {};
|
|
164
|
-
const maxIterations = maxIter ?? AIService.DEFAULT_MAX_ITERATIONS;
|
|
165
|
-
const registeredTools = this.toolRegistry.getAll();
|
|
166
|
-
|
|
167
|
-
// Merge registered tools with any explicitly provided tools
|
|
168
|
-
const mergedTools = [
|
|
169
|
-
...registeredTools,
|
|
170
|
-
...(restOptions.tools ?? []),
|
|
171
|
-
];
|
|
172
|
-
|
|
173
|
-
// Build the options that will be sent to every LLM call in the loop
|
|
174
|
-
const chatOptions: AIRequestOptions = {
|
|
175
|
-
...restOptions,
|
|
176
|
-
tools: mergedTools.length > 0 ? mergedTools : undefined,
|
|
177
|
-
toolChoice: mergedTools.length > 0 ? (restOptions.toolChoice ?? 'auto') : undefined,
|
|
178
|
-
};
|
|
179
|
-
|
|
180
|
-
// Working copy of the conversation
|
|
181
|
-
const conversation = [...messages];
|
|
182
|
-
|
|
183
|
-
// Track errors across iterations for diagnostics
|
|
184
|
-
const toolErrors: Array<{ iteration: number; toolName: string; error: string }> = [];
|
|
185
|
-
|
|
186
|
-
this.logger.debug('[AI] chatWithTools start', {
|
|
187
|
-
messageCount: conversation.length,
|
|
188
|
-
toolCount: mergedTools.length,
|
|
189
|
-
maxIterations,
|
|
190
|
-
});
|
|
191
|
-
|
|
192
|
-
let abortedByCallback = false;
|
|
193
|
-
|
|
194
|
-
for (let iteration = 0; iteration < maxIterations; iteration++) {
|
|
195
|
-
const result = await this.adapter.chat(conversation, chatOptions);
|
|
196
|
-
|
|
197
|
-
// If the model did not request any tool calls we're done
|
|
198
|
-
if (!result.toolCalls || result.toolCalls.length === 0) {
|
|
199
|
-
this.logger.debug('[AI] chatWithTools finished', { iteration, content: result.content.slice(0, 80) });
|
|
200
|
-
return result;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
this.logger.debug('[AI] chatWithTools tool calls', {
|
|
204
|
-
iteration,
|
|
205
|
-
calls: result.toolCalls.map(tc => tc.toolName),
|
|
206
|
-
});
|
|
207
|
-
|
|
208
|
-
// Append the assistant's response (with tool call metadata) to the conversation
|
|
209
|
-
const assistantContent: Array<{ type: 'text'; text: string } | ToolCallPart> = [];
|
|
210
|
-
if (result.content) assistantContent.push({ type: 'text', text: result.content });
|
|
211
|
-
assistantContent.push(...result.toolCalls);
|
|
212
|
-
conversation.push({
|
|
213
|
-
role: 'assistant',
|
|
214
|
-
content: assistantContent,
|
|
215
|
-
} as ModelMessage);
|
|
216
|
-
|
|
217
|
-
// Execute all tool calls in parallel
|
|
218
|
-
const toolResults: ToolExecutionResult[] = await this.toolRegistry.executeAll(result.toolCalls);
|
|
219
|
-
|
|
220
|
-
// Process results: track errors and honour onToolError callback
|
|
221
|
-
for (const tr of toolResults) {
|
|
222
|
-
if (tr.isError) {
|
|
223
|
-
// Match tool call by toolCallId for robust attribution
|
|
224
|
-
const matchedCall = result.toolCalls!.find(tc => tc.toolCallId === tr.toolCallId);
|
|
225
|
-
const toolName = matchedCall?.toolName ?? 'unknown';
|
|
226
|
-
const errorText = AIService.extractOutputText(tr);
|
|
227
|
-
const errorEntry = { iteration, toolName, error: errorText };
|
|
228
|
-
toolErrors.push(errorEntry);
|
|
229
|
-
this.logger.warn('[AI] chatWithTools tool error', errorEntry);
|
|
230
|
-
|
|
231
|
-
if (onToolError && matchedCall) {
|
|
232
|
-
const action = onToolError(matchedCall, errorText);
|
|
233
|
-
if (action === 'abort') {
|
|
234
|
-
abortedByCallback = true;
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
// Append each tool result as a `role: 'tool'` message
|
|
240
|
-
conversation.push({
|
|
241
|
-
role: 'tool',
|
|
242
|
-
content: [tr],
|
|
243
|
-
} as ModelMessage);
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
if (abortedByCallback) {
|
|
247
|
-
break;
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
// Distinguish user-driven abort from max-iterations exhaustion in logs
|
|
252
|
-
if (abortedByCallback) {
|
|
253
|
-
this.logger.warn('[AI] chatWithTools aborted by onToolError callback', { toolErrors });
|
|
254
|
-
} else {
|
|
255
|
-
this.logger.warn('[AI] chatWithTools max iterations reached, forcing final response', {
|
|
256
|
-
toolErrors: toolErrors.length > 0 ? toolErrors : undefined,
|
|
257
|
-
});
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
// Make one last call *without* tools so the model is forced to produce text.
|
|
261
|
-
const finalResult = await this.adapter.chat(conversation, {
|
|
262
|
-
...chatOptions,
|
|
263
|
-
tools: undefined,
|
|
264
|
-
toolChoice: undefined,
|
|
265
|
-
});
|
|
266
|
-
return finalResult;
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
/**
|
|
270
|
-
* Stream chat with automatic tool call resolution.
|
|
271
|
-
*
|
|
272
|
-
* Works like {@link chatWithTools} but yields SSE events. When the model
|
|
273
|
-
* requests tool calls during streaming, they are executed and the results
|
|
274
|
-
* fed back until a final text stream is produced.
|
|
275
|
-
*/
|
|
276
|
-
async *streamChatWithTools(
|
|
277
|
-
messages: ModelMessage[],
|
|
278
|
-
options?: ChatWithToolsOptions,
|
|
279
|
-
): AsyncIterable<TextStreamPart<ToolSet>> {
|
|
280
|
-
const { maxIterations: maxIter, onToolError, ...restOptions } = options ?? {};
|
|
281
|
-
const maxIterations = maxIter ?? AIService.DEFAULT_MAX_ITERATIONS;
|
|
282
|
-
const registeredTools = this.toolRegistry.getAll();
|
|
283
|
-
|
|
284
|
-
const mergedTools = [
|
|
285
|
-
...registeredTools,
|
|
286
|
-
...(restOptions.tools ?? []),
|
|
287
|
-
];
|
|
288
|
-
|
|
289
|
-
const chatOptions: AIRequestOptions = {
|
|
290
|
-
...restOptions,
|
|
291
|
-
tools: mergedTools.length > 0 ? mergedTools : undefined,
|
|
292
|
-
toolChoice: mergedTools.length > 0 ? (restOptions.toolChoice ?? 'auto') : undefined,
|
|
293
|
-
};
|
|
294
|
-
|
|
295
|
-
const conversation = [...messages];
|
|
296
|
-
let abortedByCallback = false;
|
|
297
|
-
|
|
298
|
-
for (let iteration = 0; iteration < maxIterations; iteration++) {
|
|
299
|
-
// Use non-streaming chat for intermediate tool-call rounds
|
|
300
|
-
const result = await this.adapter.chat(conversation, chatOptions);
|
|
301
|
-
|
|
302
|
-
if (!result.toolCalls || result.toolCalls.length === 0) {
|
|
303
|
-
// Final round — return the probed result without an extra model call
|
|
304
|
-
yield textDeltaPart('stream', result.content);
|
|
305
|
-
yield finishPart(result);
|
|
306
|
-
return;
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
// Emit tool-call events so the client can see tool execution progress
|
|
310
|
-
for (const tc of result.toolCalls) {
|
|
311
|
-
yield { type: 'tool-call', toolCallId: tc.toolCallId, toolName: tc.toolName, input: tc.input } as TextStreamPart<ToolSet>;
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
const assistantContent: Array<{ type: 'text'; text: string } | ToolCallPart> = [];
|
|
315
|
-
if (result.content) assistantContent.push({ type: 'text', text: result.content });
|
|
316
|
-
assistantContent.push(...result.toolCalls);
|
|
317
|
-
conversation.push({
|
|
318
|
-
role: 'assistant',
|
|
319
|
-
content: assistantContent,
|
|
320
|
-
} as ModelMessage);
|
|
321
|
-
|
|
322
|
-
const toolResults: ToolExecutionResult[] = await this.toolRegistry.executeAll(result.toolCalls);
|
|
323
|
-
|
|
324
|
-
for (const tr of toolResults) {
|
|
325
|
-
if (tr.isError && onToolError) {
|
|
326
|
-
const matchedCall = result.toolCalls!.find(tc => tc.toolCallId === tr.toolCallId);
|
|
327
|
-
if (matchedCall) {
|
|
328
|
-
const errorText = AIService.extractOutputText(tr);
|
|
329
|
-
const action = onToolError(matchedCall, errorText);
|
|
330
|
-
if (action === 'abort') {
|
|
331
|
-
abortedByCallback = true;
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
// Emit tool-result so the client can see tool output via SSE
|
|
336
|
-
yield {
|
|
337
|
-
type: 'tool-result',
|
|
338
|
-
toolCallId: tr.toolCallId,
|
|
339
|
-
toolName: tr.toolName,
|
|
340
|
-
output: tr.output,
|
|
341
|
-
} as TextStreamPart<ToolSet>;
|
|
342
|
-
conversation.push({
|
|
343
|
-
role: 'tool',
|
|
344
|
-
content: [tr],
|
|
345
|
-
} as ModelMessage);
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
if (abortedByCallback) {
|
|
349
|
-
break;
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
// Forced final response (no tools) — either aborted or max iterations
|
|
354
|
-
if (abortedByCallback) {
|
|
355
|
-
this.logger.warn('[AI] streamChatWithTools aborted by onToolError callback');
|
|
356
|
-
} else {
|
|
357
|
-
this.logger.warn('[AI] streamChatWithTools max iterations reached');
|
|
358
|
-
}
|
|
359
|
-
const finalOptions = { ...chatOptions, tools: undefined, toolChoice: undefined };
|
|
360
|
-
const result = await this.adapter.chat(conversation, finalOptions);
|
|
361
|
-
yield textDeltaPart('stream', result.content);
|
|
362
|
-
yield finishPart(result);
|
|
363
|
-
}
|
|
364
|
-
}
|
|
@@ -1,103 +0,0 @@
|
|
|
1
|
-
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
-
|
|
3
|
-
import type {
|
|
4
|
-
AIConversation,
|
|
5
|
-
ModelMessage,
|
|
6
|
-
IAIConversationService,
|
|
7
|
-
} from '@objectstack/spec/contracts';
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* InMemoryConversationService — Reference implementation of IAIConversationService.
|
|
11
|
-
*
|
|
12
|
-
* Stores conversations in a simple Map. Suitable for development, testing,
|
|
13
|
-
* and single-process deployments. Production environments should replace
|
|
14
|
-
* this with a persistent implementation (e.g., backed by ObjectQL/SQL).
|
|
15
|
-
*/
|
|
16
|
-
export class InMemoryConversationService implements IAIConversationService {
|
|
17
|
-
private readonly store = new Map<string, AIConversation>();
|
|
18
|
-
private counter = 0;
|
|
19
|
-
|
|
20
|
-
async create(options: {
|
|
21
|
-
title?: string;
|
|
22
|
-
agentId?: string;
|
|
23
|
-
userId?: string;
|
|
24
|
-
metadata?: Record<string, unknown>;
|
|
25
|
-
} = {}): Promise<AIConversation> {
|
|
26
|
-
const now = new Date().toISOString();
|
|
27
|
-
const id = `conv_${++this.counter}`;
|
|
28
|
-
|
|
29
|
-
const conversation: AIConversation = {
|
|
30
|
-
id,
|
|
31
|
-
title: options.title,
|
|
32
|
-
agentId: options.agentId,
|
|
33
|
-
userId: options.userId,
|
|
34
|
-
messages: [],
|
|
35
|
-
createdAt: now,
|
|
36
|
-
updatedAt: now,
|
|
37
|
-
metadata: options.metadata,
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
this.store.set(id, conversation);
|
|
41
|
-
return conversation;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
async get(conversationId: string): Promise<AIConversation | null> {
|
|
45
|
-
return this.store.get(conversationId) ?? null;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
async list(options: {
|
|
49
|
-
userId?: string;
|
|
50
|
-
agentId?: string;
|
|
51
|
-
limit?: number;
|
|
52
|
-
cursor?: string;
|
|
53
|
-
} = {}): Promise<AIConversation[]> {
|
|
54
|
-
let results = Array.from(this.store.values());
|
|
55
|
-
|
|
56
|
-
if (options.userId) {
|
|
57
|
-
results = results.filter(c => c.userId === options.userId);
|
|
58
|
-
}
|
|
59
|
-
if (options.agentId) {
|
|
60
|
-
results = results.filter(c => c.agentId === options.agentId);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
// Simple cursor-based pagination: cursor = conversation ID
|
|
64
|
-
if (options.cursor) {
|
|
65
|
-
const idx = results.findIndex(c => c.id === options.cursor);
|
|
66
|
-
if (idx >= 0) {
|
|
67
|
-
results = results.slice(idx + 1);
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
if (options.limit && options.limit > 0) {
|
|
72
|
-
results = results.slice(0, options.limit);
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
return results;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
async addMessage(conversationId: string, message: ModelMessage): Promise<AIConversation> {
|
|
79
|
-
const conversation = this.store.get(conversationId);
|
|
80
|
-
if (!conversation) {
|
|
81
|
-
throw new Error(`Conversation "${conversationId}" not found`);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
conversation.messages.push(message);
|
|
85
|
-
conversation.updatedAt = new Date().toISOString();
|
|
86
|
-
return conversation;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
async delete(conversationId: string): Promise<void> {
|
|
90
|
-
this.store.delete(conversationId);
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/** Total number of stored conversations. */
|
|
94
|
-
get size(): number {
|
|
95
|
-
return this.store.size;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/** Clear all conversations. */
|
|
99
|
-
clear(): void {
|
|
100
|
-
this.store.clear();
|
|
101
|
-
this.counter = 0;
|
|
102
|
-
}
|
|
103
|
-
}
|