@lowdefy/ai-utils 0.0.0-experimental-20260414134741

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,268 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */ import { ToolLoopAgent, convertToModelMessages, createAgentUIStream, createUIMessageStream, createUIMessageStreamResponse, pruneMessages, stepCountIs, hasToolCall, validateUIMessages } from 'ai';
16
+ import { serializer } from '@lowdefy/helpers';
17
+ import buildAgentTools from './buildAgentTools.js';
18
+ import buildPrepareStep from './buildPrepareStep.js';
19
+ function createUsageAccumulator() {
20
+ const usage = {
21
+ inputTokens: 0,
22
+ outputTokens: 0,
23
+ totalTokens: 0,
24
+ reasoningTokens: 0,
25
+ cacheReadTokens: 0,
26
+ cacheWriteTokens: 0
27
+ };
28
+ let finishReason = 'stop';
29
+ function add(stepResult) {
30
+ const stepUsage = stepResult?.usage ?? stepResult;
31
+ if (stepResult?.finishReason) {
32
+ finishReason = stepResult.finishReason;
33
+ }
34
+ if (!stepUsage) return;
35
+ usage.inputTokens += stepUsage.inputTokens ?? 0;
36
+ usage.outputTokens += stepUsage.outputTokens ?? 0;
37
+ usage.totalTokens += stepUsage.totalTokens ?? 0;
38
+ usage.reasoningTokens += stepUsage.outputTokenDetails?.reasoningTokens ?? 0;
39
+ usage.cacheReadTokens += stepUsage.inputTokenDetails?.cacheReadTokens ?? 0;
40
+ usage.cacheWriteTokens += stepUsage.inputTokenDetails?.cacheWriteTokens ?? 0;
41
+ }
42
+ return {
43
+ usage,
44
+ add,
45
+ getFinishReason: ()=>finishReason
46
+ };
47
+ }
48
+ // Strip non-serializable fields from agent-level hook events before sending as payload.
49
+ // messages excluded here — the stream-level onFinish sends UIMessage[] directly.
50
+ function cleanHookEvent(event) {
51
+ const clean = {};
52
+ for (const [key, value] of Object.entries(event)){
53
+ if (key === 'abortSignal') continue;
54
+ if (key === 'messages') continue;
55
+ if (typeof value === 'function') continue;
56
+ clean[key] = value;
57
+ }
58
+ return clean;
59
+ }
60
+ // Maps YAML hook names to AI SDK callback names and creates fire-and-forget callbacks.
61
+ // onFinish is intentionally excluded — it is handled at the stream level inside the
62
+ // createUIMessageStream execute function so that hooks are awaited and can return
63
+ // dataParts to be written to the response stream.
64
+ const hookMapping = {
65
+ onStart: 'experimental_onStart',
66
+ onStepStart: 'experimental_onStepStart',
67
+ onToolCallStart: 'experimental_onToolCallStart',
68
+ onToolCallFinish: 'experimental_onToolCallFinish',
69
+ onStepFinish: 'onStepFinish'
70
+ };
71
+ function createHookCallbacks({ hooks, callEndpoint }) {
72
+ if (!hooks) return {};
73
+ const callbacks = {};
74
+ for (const [yamlKey, sdkKey] of Object.entries(hookMapping)){
75
+ const endpointIds = hooks[yamlKey];
76
+ if (!endpointIds || endpointIds.length === 0) continue;
77
+ callbacks[sdkKey] = (event)=>{
78
+ const payload = cleanHookEvent(event);
79
+ for (const endpointId of endpointIds){
80
+ callEndpoint(endpointId, {
81
+ payload
82
+ }).catch(()=>{});
83
+ }
84
+ };
85
+ }
86
+ return callbacks;
87
+ }
88
+ // Convert data: URLs in file parts to raw base64 so the AI SDK does not attempt
89
+ // to download them (it only supports http/https). The mediaType field already
90
+ // carries the MIME type, so nothing is lost.
91
+ function convertDataUrlsToBase64(messages) {
92
+ return messages.map((msg)=>{
93
+ if (!msg.parts) return msg;
94
+ const converted = msg.parts.map((part)=>{
95
+ if (part.type !== 'file' || typeof part.url !== 'string' || !part.url.startsWith('data:')) {
96
+ return part;
97
+ }
98
+ const commaIndex = part.url.indexOf(',');
99
+ if (commaIndex === -1) return part;
100
+ return {
101
+ ...part,
102
+ url: part.url.slice(commaIndex + 1)
103
+ };
104
+ });
105
+ return {
106
+ ...msg,
107
+ parts: converted
108
+ };
109
+ });
110
+ }
111
+ async function handleAgentChat({ connection, properties, context }) {
112
+ const { agent, messages: rawMessages } = properties;
113
+ const messages = convertDataUrlsToBase64(rawMessages);
114
+ const { tools, mcpClients } = await buildAgentTools({
115
+ agent,
116
+ context
117
+ });
118
+ const model = connection.provider(agent.properties.model);
119
+ const hookCallbacks = createHookCallbacks({
120
+ hooks: agent.hooks,
121
+ callEndpoint: context.callEndpoint
122
+ });
123
+ // Prepend page context to instructions when pageContext is enabled
124
+ let instructions = agent.properties.instructions;
125
+ if (agent.properties.pageContext && context.agentContext) {
126
+ const ctx = context.agentContext;
127
+ const contextLines = [
128
+ '<context>'
129
+ ];
130
+ if (ctx.pageId) contextLines.push(` pageId: ${ctx.pageId}`);
131
+ if (ctx.userId) contextLines.push(` userId: ${ctx.userId}`);
132
+ if (ctx.conversationId) contextLines.push(` conversationId: ${ctx.conversationId}`);
133
+ if (ctx.urlQuery && Object.keys(ctx.urlQuery).length > 0) {
134
+ contextLines.push(` urlQuery: ${JSON.stringify(ctx.urlQuery)}`);
135
+ }
136
+ contextLines.push('</context>');
137
+ instructions = `${contextLines.join('\n')}\n\n${instructions ?? ''}`;
138
+ }
139
+ // Build stop conditions
140
+ const stopConditions = [
141
+ stepCountIs(agent.properties.maxSteps ?? 10)
142
+ ];
143
+ const stopOnToolCall = agent.properties.stopOnToolCall;
144
+ if (stopOnToolCall) {
145
+ const toolNames = Array.isArray(stopOnToolCall) ? stopOnToolCall : [
146
+ stopOnToolCall
147
+ ];
148
+ for (const name of toolNames){
149
+ stopConditions.push(hasToolCall(name));
150
+ }
151
+ }
152
+ const agentInstance = new ToolLoopAgent({
153
+ model,
154
+ instructions,
155
+ tools,
156
+ stopWhen: stopConditions.length === 1 ? stopConditions[0] : stopConditions,
157
+ maxOutputTokens: agent.properties.maxOutputTokens,
158
+ temperature: agent.properties.temperature,
159
+ toolChoice: agent.properties.toolChoice ?? 'auto',
160
+ providerOptions: agent.properties.providerOptions,
161
+ activeTools: agent.properties.activeTools,
162
+ topP: agent.properties.topP,
163
+ topK: agent.properties.topK,
164
+ frequencyPenalty: agent.properties.frequencyPenalty,
165
+ presencePenalty: agent.properties.presencePenalty,
166
+ seed: agent.properties.seed,
167
+ stopSequences: agent.properties.stopSequences,
168
+ maxRetries: agent.properties.maxRetries,
169
+ ...agent.properties.prepareStep ? {
170
+ prepareStep: buildPrepareStep(agent.properties.prepareStep)
171
+ } : {},
172
+ ...hookCallbacks,
173
+ ...agent.properties.repairToolCall ? {
174
+ experimental_repairToolCall: async ({ toolCall })=>{
175
+ return {
176
+ ...toolCall
177
+ };
178
+ }
179
+ } : {}
180
+ });
181
+ const onFinishEndpointIds = agent.hooks?.onFinish;
182
+ const hasOnFinishHooks = onFinishEndpointIds && onFinishEndpointIds.length > 0;
183
+ const hasMcpClients = mcpClients.length > 0;
184
+ const pruneConfig = agent.properties.prune;
185
+ const timeoutConfig = agent.properties.timeout != null ? {
186
+ timeout: agent.properties.timeout
187
+ } : {};
188
+ const stream = createUIMessageStream({
189
+ execute: async ({ writer })=>{
190
+ const usageAccumulator = createUsageAccumulator();
191
+ let agentStream;
192
+ if (pruneConfig) {
193
+ // Decompose createAgentUIStream so we can insert pruneMessages
194
+ // between the UIMessage→ModelMessage conversion and agent execution.
195
+ const validatedMessages = await validateUIMessages({
196
+ messages,
197
+ tools: agentInstance.tools
198
+ });
199
+ const modelMessages = await convertToModelMessages(validatedMessages, {
200
+ tools: agentInstance.tools
201
+ });
202
+ const prunedMessages = pruneMessages({
203
+ messages: modelMessages,
204
+ ...pruneConfig
205
+ });
206
+ const result = await agentInstance.stream({
207
+ prompt: prunedMessages,
208
+ ...timeoutConfig,
209
+ onStepFinish: (stepResult)=>{
210
+ usageAccumulator.add(stepResult);
211
+ }
212
+ });
213
+ agentStream = result.toUIMessageStream({
214
+ originalMessages: validatedMessages
215
+ });
216
+ } else {
217
+ // createAgentUIStream validates UIMessages, converts to ModelMessages,
218
+ // runs the agent, and returns a UIMessageStream — handling the full
219
+ // UI→model→UI conversion that ToolLoopAgent.stream() does not.
220
+ agentStream = await createAgentUIStream({
221
+ agent: agentInstance,
222
+ uiMessages: messages,
223
+ ...timeoutConfig,
224
+ onStepFinish: (stepResult)=>{
225
+ usageAccumulator.add(stepResult);
226
+ }
227
+ });
228
+ }
229
+ writer.merge(agentStream);
230
+ // After merge resolves the agent stream is complete.
231
+ // Call onFinish hooks — awaited so dataParts can be written to stream.
232
+ if (hasOnFinishHooks) {
233
+ const finishPayload = {
234
+ messages,
235
+ finishReason: usageAccumulator.getFinishReason(),
236
+ isAborted: false,
237
+ ...context.agentContext ?? {},
238
+ usage: usageAccumulator.usage
239
+ };
240
+ for (const endpointId of onFinishEndpointIds){
241
+ try {
242
+ const hookResponse = await context.callEndpoint(endpointId, {
243
+ payload: finishPayload
244
+ });
245
+ const responseData = serializer.deserialize(hookResponse?.response);
246
+ if (Array.isArray(responseData?.dataParts)) {
247
+ for (const part of responseData.dataParts){
248
+ writer.write(part);
249
+ }
250
+ }
251
+ } catch (error) {
252
+ console.warn(`onFinish hook "${endpointId}" failed: ${error.message}`);
253
+ }
254
+ }
255
+ }
256
+ if (hasMcpClients) {
257
+ await Promise.all(mcpClients.map(({ client })=>client.close().catch(()=>{})));
258
+ }
259
+ }
260
+ });
261
+ const response = createUIMessageStreamResponse({
262
+ stream
263
+ });
264
+ return {
265
+ response
266
+ };
267
+ }
268
+ export default handleAgentChat;
package/dist/index.js ADDED
@@ -0,0 +1,15 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */ export { default as handleAgentChat } from './handleAgentChat.js';
package/dist/types.js ADDED
@@ -0,0 +1,19 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */ export default {
16
+ agents: [
17
+ 'AISDKAgent'
18
+ ]
19
+ };
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@lowdefy/ai-utils",
3
+ "version": "0.0.0-experimental-20260414134741",
4
+ "license": "Apache-2.0",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./dist/index.js",
8
+ "./agents": "./dist/agents.js",
9
+ "./types": "./dist/types.js"
10
+ },
11
+ "files": [
12
+ "dist/*"
13
+ ],
14
+ "dependencies": {
15
+ "@ai-sdk/mcp": "1.0.30",
16
+ "ai": "6.0.116",
17
+ "glob": "11.0.2",
18
+ "@lowdefy/helpers": "0.0.0-experimental-20260414134741"
19
+ },
20
+ "devDependencies": {
21
+ "@jest/globals": "28.1.3",
22
+ "@swc/cli": "0.8.0",
23
+ "@swc/core": "1.15.18",
24
+ "@swc/jest": "0.2.39",
25
+ "jest": "28.1.3"
26
+ },
27
+ "publishConfig": {
28
+ "access": "public"
29
+ },
30
+ "scripts": {
31
+ "build": "swc src --out-dir dist --config-file ../../../.swcrc --cli-config-file ../../../.swc-cli.json",
32
+ "clean": "rm -rf dist",
33
+ "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js"
34
+ }
35
+ }