@lowdefy/ai-utils 5.3.0 → 5.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -213,6 +213,13 @@
213
213
  type: 'AISDKAgent agent property "repairToolCall" should be a boolean.'
214
214
  }
215
215
  },
216
+ generateTitle: {
217
+ type: 'boolean',
218
+ description: 'When true, generate a short conversation title on the first turn from the first user message. Emits a "data-chat-title" data part that fires the block\'s onTitleGenerated event. Off by default.',
219
+ errorMessage: {
220
+ type: 'AISDKAgent agent property "generateTitle" should be a boolean.'
221
+ }
222
+ },
216
223
  prepareStep: {
217
224
  type: 'array',
218
225
  items: {
@@ -16,7 +16,7 @@
16
16
  import { createMCPClient } from '@ai-sdk/mcp';
17
17
  import { Experimental_StdioMCPTransport } from '@ai-sdk/mcp/mcp-stdio';
18
18
  import { ConfigError } from '@lowdefy/errors';
19
- import { serializer } from '@lowdefy/helpers';
19
+ import { serializer, translate } from '@lowdefy/helpers';
20
20
  import listFiles from './fileSystem/listFiles.js';
21
21
  import readFile from './fileSystem/readFile.js';
22
22
  import searchFiles from './fileSystem/searchFiles.js';
@@ -30,22 +30,36 @@ import RESERVED_PLATFORM_TOOL_NAMES from './reservedToolNames.js';
30
30
  function cleanBuildArtifact(obj) {
31
31
  return JSON.parse(JSON.stringify(serializer.deserialize(obj)));
32
32
  }
33
- function assertNotReserved(name, kind) {
33
+ function assertNotReserved(name, kind, i18n) {
34
34
  if (RESERVED_PLATFORM_TOOL_NAMES.includes(name)) {
35
- throw new ConfigError(`${kind} "${name}" uses a reserved platform tool name. ` + `Rename it (e.g., to "${name}-app" or "custom-${name}"). ` + `Reserved: ${RESERVED_PLATFORM_TOOL_NAMES.join(', ')}.`);
35
+ throw new ConfigError(translate({
36
+ key: 'agent.runtime.reservedToolName',
37
+ values: {
38
+ kind,
39
+ name,
40
+ reserved: RESERVED_PLATFORM_TOOL_NAMES.join(', ')
41
+ },
42
+ i18n
43
+ }));
36
44
  }
37
45
  }
38
46
  async function buildAgentTools({ agent, context, depth = 0 }) {
39
47
  const MAX_DEPTH = 5;
40
48
  if (depth > MAX_DEPTH) {
41
- throw new Error(`Sub-agent nesting exceeds maximum depth of ${MAX_DEPTH}.`);
49
+ throw new Error(translate({
50
+ key: 'agent.runtime.subAgentDepthExceeded',
51
+ values: {
52
+ max: MAX_DEPTH
53
+ },
54
+ i18n: context.i18n
55
+ }));
42
56
  }
43
57
  const tools = {};
44
58
  const mcpClients = [];
45
59
  // Build endpoint tools
46
60
  for (const toolConfig of agent.tools ?? []){
47
61
  const { endpointId, confirm } = toolConfig;
48
- assertNotReserved(endpointId, 'Endpoint tool');
62
+ assertNotReserved(endpointId, 'Endpoint tool', context.i18n);
49
63
  const endpointConfig = await context.getEndpointConfig({
50
64
  endpointId
51
65
  });
@@ -62,7 +76,10 @@ async function buildAgentTools({ agent, context, depth = 0 }) {
62
76
  });
63
77
  if (!result.success) {
64
78
  const err = serializer.deserialize(result.error);
65
- throw new Error(err?.message ?? 'Endpoint execution failed');
79
+ throw new Error(err?.message ?? translate({
80
+ key: 'agent.runtime.toolExecutionFailed',
81
+ i18n: context.i18n
82
+ }));
66
83
  }
67
84
  return cleanBuildArtifact(result.response);
68
85
  }
@@ -135,7 +152,7 @@ async function buildAgentTools({ agent, context, depth = 0 }) {
135
152
  }
136
153
  // Build sub-agent tools
137
154
  for (const subAgentRef of agent.agents ?? []){
138
- assertNotReserved(subAgentRef.agentId, 'Sub-agent');
155
+ assertNotReserved(subAgentRef.agentId, 'Sub-agent', context.i18n);
139
156
  const subAgentConfig = await context.getAgentConfig({
140
157
  agentId: subAgentRef.agentId
141
158
  });
@@ -12,7 +12,7 @@
12
12
  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
13
  See the License for the specific language governing permissions and
14
14
  limitations under the License.
15
- */ import { ToolLoopAgent, convertToModelMessages, createAgentUIStream, createUIMessageStream, createUIMessageStreamResponse, pruneMessages, stepCountIs, hasToolCall, validateUIMessages } from 'ai';
15
+ */ import { ToolLoopAgent, convertToModelMessages, createAgentUIStream, createUIMessageStream, createUIMessageStreamResponse, generateText, pruneMessages, stepCountIs, hasToolCall, validateUIMessages } from 'ai';
16
16
  import { serializer } from '@lowdefy/helpers';
17
17
  import buildAgentTools from './buildAgentTools.js';
18
18
  import buildPrepareStep from './buildPrepareStep.js';
@@ -69,14 +69,17 @@ const hookMapping = {
69
69
  onToolCallFinish: 'experimental_onToolCallFinish',
70
70
  onStepFinish: 'onStepFinish'
71
71
  };
72
- function createHookCallbacks({ hooks, callEndpoint }) {
72
+ function createHookCallbacks({ hooks, callEndpoint, locale }) {
73
73
  if (!hooks) return {};
74
74
  const callbacks = {};
75
75
  for (const [yamlKey, sdkKey] of Object.entries(hookMapping)){
76
76
  const endpointIds = hooks[yamlKey];
77
77
  if (!endpointIds || endpointIds.length === 0) continue;
78
78
  callbacks[sdkKey] = (event)=>{
79
- const payload = cleanHookEvent(event);
79
+ const payload = {
80
+ ...cleanHookEvent(event),
81
+ locale
82
+ };
80
83
  for (const endpointId of endpointIds){
81
84
  callEndpoint(endpointId, {
82
85
  payload
@@ -109,6 +112,13 @@ function convertDataUrlsToBase64(messages) {
109
112
  };
110
113
  });
111
114
  }
115
+ // Concatenate the text parts of the first user message — used as the source
116
+ // for generateTitle.
117
+ function getFirstUserText(messages) {
118
+ const firstUser = messages.find((msg)=>msg.role === 'user');
119
+ if (!firstUser) return '';
120
+ return (firstUser.parts ?? []).filter((part)=>part.type === 'text').map((part)=>part.text).join('');
121
+ }
112
122
  async function handleAgentChat({ connection, properties, context }) {
113
123
  const { agent, messages: rawMessages } = properties;
114
124
  const messages = convertDataUrlsToBase64(rawMessages);
@@ -124,9 +134,11 @@ async function handleAgentChat({ connection, properties, context }) {
124
134
  tools['update-page-state'] = updatePageStateTool;
125
135
  }
126
136
  const model = connection.provider(agent.properties.model);
137
+ const locale = context.i18n?.active;
127
138
  const hookCallbacks = createHookCallbacks({
128
139
  hooks: agent.hooks,
129
- callEndpoint: context.callEndpoint
140
+ callEndpoint: context.callEndpoint,
141
+ locale
130
142
  });
131
143
  // Prepend page context to instructions when pageContext is enabled
132
144
  let instructions = agent.properties.instructions;
@@ -196,11 +208,16 @@ async function handleAgentChat({ connection, properties, context }) {
196
208
  const timeoutConfig = agent.properties.timeout != null ? {
197
209
  timeout: agent.properties.timeout
198
210
  } : {};
211
+ const titleEnabled = agent.properties.generateTitle === true;
199
212
  const stream = createUIMessageStream({
200
213
  execute: async ({ writer })=>{
201
214
  const usageAccumulator = createUsageAccumulator();
202
215
  const steps = [];
203
216
  let agentStream;
217
+ // The AI SDK delivers the full updated UI message list (input + generated
218
+ // assistant reply) to the stream-level onFinish. Capture it so onFinish
219
+ // hooks persist the complete conversation, not just the request input.
220
+ let capturedMessages;
204
221
  function collectStep(stepResult) {
205
222
  usageAccumulator.add(stepResult);
206
223
  steps.push({
@@ -211,6 +228,37 @@ async function handleAgentChat({ connection, properties, context }) {
211
228
  finishReason: stepResult.finishReason
212
229
  });
213
230
  }
231
+ function captureMessages({ messages: finalMessages }) {
232
+ capturedMessages = finalMessages;
233
+ }
234
+ // Generate a short conversation title from the first user message,
235
+ // concurrently with the agent stream. The data-chat-title part fires the
236
+ // block's onTitleGenerated event. Only on the first turn; failures are
237
+ // non-fatal.
238
+ let titlePromise;
239
+ const isFirstTurn = !messages.some((msg)=>msg.role === 'assistant');
240
+ if (titleEnabled && isFirstTurn) {
241
+ const firstUserText = getFirstUserText(messages);
242
+ if (firstUserText) {
243
+ titlePromise = generateText({
244
+ model,
245
+ maxOutputTokens: 20,
246
+ prompt: `Generate a concise 3-6 word title for a conversation that begins with this message. Reply with only the title, no quotes:\n\n${firstUserText}`
247
+ }).then(({ text })=>{
248
+ const title = text.trim();
249
+ if (title) {
250
+ writer.write({
251
+ type: 'data-chat-title',
252
+ data: {
253
+ title
254
+ }
255
+ });
256
+ }
257
+ }).catch((error)=>{
258
+ console.warn(`generateTitle failed: ${error.message}`);
259
+ });
260
+ }
261
+ }
214
262
  if (pruneConfig) {
215
263
  // Decompose createAgentUIStream so we can insert pruneMessages
216
264
  // between the UIMessage→ModelMessage conversion and agent execution.
@@ -231,7 +279,8 @@ async function handleAgentChat({ connection, properties, context }) {
231
279
  onStepFinish: collectStep
232
280
  });
233
281
  agentStream = result.toUIMessageStream({
234
- originalMessages: validatedMessages
282
+ originalMessages: validatedMessages,
283
+ onFinish: captureMessages
235
284
  });
236
285
  } else {
237
286
  // createAgentUIStream validates UIMessages, converts to ModelMessages,
@@ -241,7 +290,8 @@ async function handleAgentChat({ connection, properties, context }) {
241
290
  agent: agentInstance,
242
291
  uiMessages: messages,
243
292
  ...timeoutConfig,
244
- onStepFinish: collectStep
293
+ onStepFinish: collectStep,
294
+ onFinish: captureMessages
245
295
  });
246
296
  }
247
297
  // Read the agent stream to completion before running onFinish hooks.
@@ -260,16 +310,21 @@ async function handleAgentChat({ connection, properties, context }) {
260
310
  errorText: writer.onError(error)
261
311
  });
262
312
  }
313
+ // Ensure the title (if any) is written before the stream closes.
314
+ if (titlePromise) {
315
+ await titlePromise;
316
+ }
263
317
  // Call onFinish hooks — awaited so dataParts can be written to stream.
264
318
  if (hasOnFinishHooks) {
265
319
  const finishPayload = {
266
- messages,
320
+ messages: capturedMessages ?? messages,
267
321
  steps,
268
322
  toolResults: steps.flatMap((s)=>s.toolResults ?? []),
269
323
  finishReason: usageAccumulator.getFinishReason(),
270
324
  isAborted: false,
271
325
  ...context.agentContext ?? {},
272
- usage: usageAccumulator.usage
326
+ usage: usageAccumulator.usage,
327
+ locale
273
328
  };
274
329
  for (const endpointId of onFinishEndpointIds){
275
330
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lowdefy/ai-utils",
3
- "version": "5.3.0",
3
+ "version": "5.5.0",
4
4
  "license": "Apache-2.0",
5
5
  "repository": {
6
6
  "type": "git",
@@ -17,8 +17,8 @@
17
17
  ],
18
18
  "dependencies": {
19
19
  "@ai-sdk/mcp": "1.0.41",
20
- "@lowdefy/errors": "5.3.0",
21
- "@lowdefy/helpers": "5.3.0",
20
+ "@lowdefy/errors": "5.5.0",
21
+ "@lowdefy/helpers": "5.5.0",
22
22
  "ai": "6.0.176",
23
23
  "glob": "13.0.6"
24
24
  },