@lowdefy/ai-utils 5.5.1 → 6.0.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.
@@ -0,0 +1,155 @@
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
+ $schema: 'http://json-schema.org/draft-07/schema#',
17
+ title: 'Lowdefy Request Schema - GenerateObject',
18
+ type: 'object',
19
+ required: [
20
+ 'model',
21
+ 'schema'
22
+ ],
23
+ properties: {
24
+ model: {
25
+ type: 'string',
26
+ description: 'Model id to generate with.',
27
+ errorMessage: {
28
+ type: 'GenerateObject request property "model" should be a string.'
29
+ }
30
+ },
31
+ schema: {
32
+ type: 'object',
33
+ description: 'JSON Schema describing the object the model must generate.',
34
+ errorMessage: {
35
+ type: 'GenerateObject request property "schema" should be an object.'
36
+ }
37
+ },
38
+ schemaName: {
39
+ type: 'string',
40
+ description: 'Optional name for the output schema, passed to the model.',
41
+ errorMessage: {
42
+ type: 'GenerateObject request property "schemaName" should be a string.'
43
+ }
44
+ },
45
+ schemaDescription: {
46
+ type: 'string',
47
+ description: 'Optional description of the output schema, passed to the model.',
48
+ errorMessage: {
49
+ type: 'GenerateObject request property "schemaDescription" should be a string.'
50
+ }
51
+ },
52
+ prompt: {
53
+ type: 'string',
54
+ description: 'Text prompt. Use either "prompt" or "messages", not both.',
55
+ errorMessage: {
56
+ type: 'GenerateObject request property "prompt" should be a string.'
57
+ }
58
+ },
59
+ messages: {
60
+ type: 'array',
61
+ items: {
62
+ type: 'object'
63
+ },
64
+ description: 'Model messages ({ role, content }). Use either "prompt" or "messages", not both.',
65
+ errorMessage: {
66
+ type: 'GenerateObject request property "messages" should be an array of objects.'
67
+ }
68
+ },
69
+ system: {
70
+ type: 'string',
71
+ description: 'System prompt.',
72
+ errorMessage: {
73
+ type: 'GenerateObject request property "system" should be a string.'
74
+ }
75
+ },
76
+ maxOutputTokens: {
77
+ type: 'integer',
78
+ minimum: 1,
79
+ description: 'Maximum number of tokens to generate.',
80
+ errorMessage: {
81
+ type: 'GenerateObject request property "maxOutputTokens" should be an integer.',
82
+ minimum: 'GenerateObject request property "maxOutputTokens" should be at least 1.'
83
+ }
84
+ },
85
+ temperature: {
86
+ type: 'number',
87
+ minimum: 0,
88
+ maximum: 2,
89
+ description: 'Sampling temperature.',
90
+ errorMessage: {
91
+ type: 'GenerateObject request property "temperature" should be a number.',
92
+ minimum: 'GenerateObject request property "temperature" should be at least 0.',
93
+ maximum: 'GenerateObject request property "temperature" should be at most 2.'
94
+ }
95
+ },
96
+ topP: {
97
+ type: 'number',
98
+ description: 'Nucleus sampling.',
99
+ errorMessage: {
100
+ type: 'GenerateObject request property "topP" should be a number.'
101
+ }
102
+ },
103
+ topK: {
104
+ type: 'number',
105
+ description: 'Only sample from the top K options for each subsequent token.',
106
+ errorMessage: {
107
+ type: 'GenerateObject request property "topK" should be a number.'
108
+ }
109
+ },
110
+ frequencyPenalty: {
111
+ type: 'number',
112
+ description: 'Penalize repeated tokens by frequency.',
113
+ errorMessage: {
114
+ type: 'GenerateObject request property "frequencyPenalty" should be a number.'
115
+ }
116
+ },
117
+ presencePenalty: {
118
+ type: 'number',
119
+ description: 'Penalize tokens that have already appeared.',
120
+ errorMessage: {
121
+ type: 'GenerateObject request property "presencePenalty" should be a number.'
122
+ }
123
+ },
124
+ seed: {
125
+ type: 'integer',
126
+ description: 'Seed for deterministic sampling, if supported by the model.',
127
+ errorMessage: {
128
+ type: 'GenerateObject request property "seed" should be an integer.'
129
+ }
130
+ },
131
+ maxRetries: {
132
+ type: 'integer',
133
+ minimum: 0,
134
+ description: 'Maximum number of retries. Defaults to 2.',
135
+ errorMessage: {
136
+ type: 'GenerateObject request property "maxRetries" should be an integer.',
137
+ minimum: 'GenerateObject request property "maxRetries" should be at least 0.'
138
+ }
139
+ },
140
+ providerOptions: {
141
+ type: 'object',
142
+ description: 'Provider-specific options, keyed by provider.',
143
+ errorMessage: {
144
+ type: 'GenerateObject request property "providerOptions" should be an object.'
145
+ }
146
+ }
147
+ },
148
+ errorMessage: {
149
+ type: 'GenerateObject request properties should be an object.',
150
+ required: {
151
+ model: 'GenerateObject request should have required property "model".',
152
+ schema: 'GenerateObject request should have required property "schema".'
153
+ }
154
+ }
155
+ };
@@ -0,0 +1,142 @@
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
+ $schema: 'http://json-schema.org/draft-07/schema#',
17
+ title: 'Lowdefy Request Schema - GenerateText',
18
+ type: 'object',
19
+ required: [
20
+ 'model'
21
+ ],
22
+ properties: {
23
+ model: {
24
+ type: 'string',
25
+ description: 'Model id to generate with.',
26
+ errorMessage: {
27
+ type: 'GenerateText request property "model" should be a string.'
28
+ }
29
+ },
30
+ prompt: {
31
+ type: 'string',
32
+ description: 'Text prompt. Use either "prompt" or "messages", not both.',
33
+ errorMessage: {
34
+ type: 'GenerateText request property "prompt" should be a string.'
35
+ }
36
+ },
37
+ messages: {
38
+ type: 'array',
39
+ items: {
40
+ type: 'object'
41
+ },
42
+ description: 'Model messages ({ role, content }). Use either "prompt" or "messages", not both.',
43
+ errorMessage: {
44
+ type: 'GenerateText request property "messages" should be an array of objects.'
45
+ }
46
+ },
47
+ system: {
48
+ type: 'string',
49
+ description: 'System prompt.',
50
+ errorMessage: {
51
+ type: 'GenerateText request property "system" should be a string.'
52
+ }
53
+ },
54
+ maxOutputTokens: {
55
+ type: 'integer',
56
+ minimum: 1,
57
+ description: 'Maximum number of tokens to generate.',
58
+ errorMessage: {
59
+ type: 'GenerateText request property "maxOutputTokens" should be an integer.',
60
+ minimum: 'GenerateText request property "maxOutputTokens" should be at least 1.'
61
+ }
62
+ },
63
+ temperature: {
64
+ type: 'number',
65
+ minimum: 0,
66
+ maximum: 2,
67
+ description: 'Sampling temperature.',
68
+ errorMessage: {
69
+ type: 'GenerateText request property "temperature" should be a number.',
70
+ minimum: 'GenerateText request property "temperature" should be at least 0.',
71
+ maximum: 'GenerateText request property "temperature" should be at most 2.'
72
+ }
73
+ },
74
+ topP: {
75
+ type: 'number',
76
+ description: 'Nucleus sampling.',
77
+ errorMessage: {
78
+ type: 'GenerateText request property "topP" should be a number.'
79
+ }
80
+ },
81
+ topK: {
82
+ type: 'number',
83
+ description: 'Only sample from the top K options for each subsequent token.',
84
+ errorMessage: {
85
+ type: 'GenerateText request property "topK" should be a number.'
86
+ }
87
+ },
88
+ frequencyPenalty: {
89
+ type: 'number',
90
+ description: 'Penalize repeated tokens by frequency.',
91
+ errorMessage: {
92
+ type: 'GenerateText request property "frequencyPenalty" should be a number.'
93
+ }
94
+ },
95
+ presencePenalty: {
96
+ type: 'number',
97
+ description: 'Penalize tokens that have already appeared.',
98
+ errorMessage: {
99
+ type: 'GenerateText request property "presencePenalty" should be a number.'
100
+ }
101
+ },
102
+ seed: {
103
+ type: 'integer',
104
+ description: 'Seed for deterministic sampling, if supported by the model.',
105
+ errorMessage: {
106
+ type: 'GenerateText request property "seed" should be an integer.'
107
+ }
108
+ },
109
+ stopSequences: {
110
+ type: 'array',
111
+ items: {
112
+ type: 'string'
113
+ },
114
+ description: 'Sequences that stop generation.',
115
+ errorMessage: {
116
+ type: 'GenerateText request property "stopSequences" should be an array of strings.'
117
+ }
118
+ },
119
+ maxRetries: {
120
+ type: 'integer',
121
+ minimum: 0,
122
+ description: 'Maximum number of retries. Defaults to 2.',
123
+ errorMessage: {
124
+ type: 'GenerateText request property "maxRetries" should be an integer.',
125
+ minimum: 'GenerateText request property "maxRetries" should be at least 0.'
126
+ }
127
+ },
128
+ providerOptions: {
129
+ type: 'object',
130
+ description: 'Provider-specific options, keyed by provider.',
131
+ errorMessage: {
132
+ type: 'GenerateText request property "providerOptions" should be an object.'
133
+ }
134
+ }
135
+ },
136
+ errorMessage: {
137
+ type: 'GenerateText request properties should be an object.',
138
+ required: {
139
+ model: 'GenerateText request should have required property "model".'
140
+ }
141
+ }
142
+ };
@@ -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, translate } from '@lowdefy/helpers';
19
+ import { getKey, isReserved, serializer, setKey, 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,7 +30,7 @@ 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, i18n) {
33
+ function assertNotPlatformToolName(name, kind, i18n) {
34
34
  if (RESERVED_PLATFORM_TOOL_NAMES.includes(name)) {
35
35
  throw new ConfigError(translate({
36
36
  key: 'agent.runtime.reservedToolName',
@@ -43,7 +43,9 @@ function assertNotReserved(name, kind, i18n) {
43
43
  }));
44
44
  }
45
45
  }
46
- async function buildAgentTools({ agent, context, depth = 0 }) {
46
+ // autoApprove skips needsApproval on confirm tools — used by headless
47
+ // run-to-completion agents, where no client exists to resolve an approval.
48
+ async function buildAgentTools({ agent, context, depth = 0, autoApprove = false }) {
47
49
  const MAX_DEPTH = 5;
48
50
  if (depth > MAX_DEPTH) {
49
51
  throw new Error(translate({
@@ -56,17 +58,29 @@ async function buildAgentTools({ agent, context, depth = 0 }) {
56
58
  }
57
59
  const tools = {};
58
60
  const mcpClients = [];
59
- // Build endpoint tools
61
+ // Build endpoint tools. The model-facing tool name is toolConfig.name
62
+ // (assigned by buildAgents — endpoint id with '/' → '__' unless overridden);
63
+ // execution still targets the endpointId.
60
64
  for (const toolConfig of agent.tools ?? []){
61
65
  const { endpointId, confirm } = toolConfig;
62
- assertNotReserved(endpointId, 'Endpoint tool', context.i18n);
66
+ const toolName = toolConfig.name ?? endpointId;
67
+ assertNotPlatformToolName(toolName, 'Endpoint tool', context.i18n);
68
+ // A second, disjoint list: RESERVED_PLATFORM_TOOL_NAMES are the tools Lowdefy itself registers,
69
+ // isReserved are the prototype-pollution keys setKey refuses. A name can pass one gate and fail
70
+ // the other, so both checks are needed. The build rejects reserved key names, so this only fires
71
+ // for a stale or hand-edited build artifact. Skip the tool rather than fail the whole agent, as
72
+ // the MCP name cases below do.
73
+ if (isReserved(toolName)) {
74
+ console.warn(`Endpoint tool "${toolName}" uses a reserved key name — skipped.`);
75
+ continue;
76
+ }
63
77
  const endpointConfig = await context.getEndpointConfig({
64
78
  endpointId
65
79
  });
66
- tools[endpointId] = tool({
80
+ setKey(tools, toolName, tool({
67
81
  description: endpointConfig.description,
68
82
  inputSchema: jsonSchema(cleanBuildArtifact(endpointConfig.payloadSchema)),
69
- ...confirm ? {
83
+ ...confirm && !autoApprove ? {
70
84
  needsApproval: true
71
85
  } : {},
72
86
  execute: async (input, { abortSignal } = {})=>{
@@ -83,7 +97,7 @@ async function buildAgentTools({ agent, context, depth = 0 }) {
83
97
  }
84
98
  return cleanBuildArtifact(result.response);
85
99
  }
86
- });
100
+ }));
87
101
  }
88
102
  // Build MCP clients and merge their tools
89
103
  for (const mcpSource of agent.mcp ?? []){
@@ -132,27 +146,38 @@ async function buildAgentTools({ agent, context, depth = 0 }) {
132
146
  console.warn(`MCP tool "${name}" from ${source.url ?? source.command} ` + `uses a reserved platform tool name — skipped.`);
133
147
  continue;
134
148
  }
135
- if (tools[name]) {
136
- console.warn(`MCP tool "${name}" from ${source.url ?? source.command} ` + `conflicts with endpoint tool — skipped.`);
149
+ // Same policy as the reserved-platform-name and collision cases above:
150
+ // MCP tool names are not under app control, so warn and skip.
151
+ if (isReserved(name)) {
152
+ console.warn(`MCP tool "${name}" from ${source.url ?? source.command} ` + `uses a reserved key name — skipped.`);
137
153
  continue;
138
154
  }
139
- if (source.confirm) {
140
- tools[name] = {
141
- ...mcpTool,
142
- needsApproval: true
143
- };
144
- } else {
145
- tools[name] = mcpTool;
155
+ if (getKey(tools, name)) {
156
+ console.warn(`MCP tool "${name}" from ${source.url ?? source.command} ` + `conflicts with endpoint tool — skipped.`);
157
+ continue;
146
158
  }
159
+ const needsApproval = source.confirm && !autoApprove;
160
+ setKey(tools, name, needsApproval ? {
161
+ ...mcpTool,
162
+ needsApproval: true
163
+ } : mcpTool);
147
164
  }
148
165
  } catch (err) {
149
166
  const label = source.transport === 'stdio' ? source.command : source.url;
150
167
  console.warn(`MCP server "${label}" tool listing failed: ${err.message}`);
151
168
  }
152
169
  }
153
- // Build sub-agent tools
170
+ // Build sub-agent tools — keyed by name for the same provider-rule reason
171
+ // as endpoint tools (scoped agent ids contain '/').
154
172
  for (const subAgentRef of agent.agents ?? []){
155
- assertNotReserved(subAgentRef.agentId, 'Sub-agent', context.i18n);
173
+ const subAgentToolName = subAgentRef.name ?? subAgentRef.agentId;
174
+ assertNotPlatformToolName(subAgentToolName, 'Sub-agent', context.i18n);
175
+ // Same policy as the endpoint tool name above: unreachable from valid config, so skip rather
176
+ // than fail the whole agent.
177
+ if (isReserved(subAgentToolName)) {
178
+ console.warn(`Sub-agent tool "${subAgentToolName}" uses a reserved key name — skipped.`);
179
+ continue;
180
+ }
156
181
  const subAgentConfig = await context.getAgentConfig({
157
182
  agentId: subAgentRef.agentId
158
183
  });
@@ -166,7 +191,8 @@ async function buildAgentTools({ agent, context, depth = 0 }) {
166
191
  const { tools: subTools, mcpClients: subMcpClients } = await buildAgentTools({
167
192
  agent: subAgentConfig,
168
193
  context,
169
- depth: depth + 1
194
+ depth: depth + 1,
195
+ autoApprove
170
196
  });
171
197
  const subModel = subConnection.provider(subAgentConfig.properties.model);
172
198
  const subAgent = new ToolLoopAgent({
@@ -192,7 +218,7 @@ async function buildAgentTools({ agent, context, depth = 0 }) {
192
218
  'task'
193
219
  ]
194
220
  });
195
- tools[subAgentRef.agentId] = tool({
221
+ setKey(tools, subAgentToolName, tool({
196
222
  description,
197
223
  inputSchema,
198
224
  execute: async (input, { abortSignal })=>{
@@ -213,7 +239,7 @@ async function buildAgentTools({ agent, context, depth = 0 }) {
213
239
  type: 'text',
214
240
  value: output.text ?? String(output)
215
241
  })
216
- });
242
+ }));
217
243
  }
218
244
  // Build fileSystem tools
219
245
  if (agent.properties?.fileSystem) {
@@ -0,0 +1,50 @@
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 { type } from '@lowdefy/helpers';
16
+ const callSettings = [
17
+ 'system',
18
+ 'maxOutputTokens',
19
+ 'temperature',
20
+ 'topP',
21
+ 'topK',
22
+ 'frequencyPenalty',
23
+ 'presencePenalty',
24
+ 'seed',
25
+ 'stopSequences',
26
+ 'maxRetries',
27
+ 'providerOptions'
28
+ ];
29
+ function buildGenerateCallOptions({ request }) {
30
+ if (type.isNone(request.prompt) && type.isNone(request.messages)) {
31
+ throw new Error('Either "prompt" or "messages" must be provided.');
32
+ }
33
+ if (!type.isNone(request.prompt) && !type.isNone(request.messages)) {
34
+ throw new Error('Only one of "prompt" or "messages" may be provided, not both.');
35
+ }
36
+ const options = {};
37
+ if (!type.isNone(request.prompt)) {
38
+ options.prompt = request.prompt;
39
+ }
40
+ if (!type.isNone(request.messages)) {
41
+ options.messages = request.messages;
42
+ }
43
+ callSettings.forEach((setting)=>{
44
+ if (!type.isNone(request[setting])) {
45
+ options[setting] = request[setting];
46
+ }
47
+ });
48
+ return options;
49
+ }
50
+ export default buildGenerateCallOptions;
@@ -0,0 +1,38 @@
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 handleGenerateObject from './handleGenerateObject.js';
16
+ import schema from './GenerateObjectSchema.js';
17
+ // Builds the GenerateObject request resolver for an AI provider connection.
18
+ // createProvider is the only provider-specific piece — request resolvers
19
+ // receive evaluated connection properties (not a provider instance) from the
20
+ // request interface layer, so the resolver constructs the provider itself.
21
+ function createGenerateObject({ createProvider }) {
22
+ async function GenerateObject({ connection, request }) {
23
+ const provider = createProvider({
24
+ connection
25
+ });
26
+ return handleGenerateObject({
27
+ model: provider(request.model),
28
+ request
29
+ });
30
+ }
31
+ GenerateObject.schema = schema;
32
+ GenerateObject.meta = {
33
+ checkRead: false,
34
+ checkWrite: false
35
+ };
36
+ return GenerateObject;
37
+ }
38
+ export default createGenerateObject;
@@ -0,0 +1,38 @@
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 handleGenerateText from './handleGenerateText.js';
16
+ import schema from './GenerateTextSchema.js';
17
+ // Builds the GenerateText request resolver for an AI provider connection.
18
+ // createProvider is the only provider-specific piece — request resolvers
19
+ // receive evaluated connection properties (not a provider instance) from the
20
+ // request interface layer, so the resolver constructs the provider itself.
21
+ function createGenerateText({ createProvider }) {
22
+ async function GenerateText({ connection, request }) {
23
+ const provider = createProvider({
24
+ connection
25
+ });
26
+ return handleGenerateText({
27
+ model: provider(request.model),
28
+ request
29
+ });
30
+ }
31
+ GenerateText.schema = schema;
32
+ GenerateText.meta = {
33
+ checkRead: false,
34
+ checkWrite: false
35
+ };
36
+ return GenerateText;
37
+ }
38
+ export default createGenerateText;
@@ -0,0 +1,157 @@
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, stepCountIs, hasToolCall } from 'ai';
16
+ import buildAgentTools from './buildAgentTools.js';
17
+ import buildPrepareStep from './buildPrepareStep.js';
18
+ import buildUpdatePageStateTool from './buildUpdatePageStateTool.js';
19
+ // Strip non-serializable fields from agent-level hook events before sending as payload.
20
+ // messages excluded here — the stream-level onFinish sends UIMessage[] directly.
21
+ function cleanHookEvent(event) {
22
+ const clean = {};
23
+ for (const [key, value] of Object.entries(event)){
24
+ if (key === 'abortSignal') continue;
25
+ if (key === 'messages') continue;
26
+ if (typeof value === 'function') continue;
27
+ clean[key] = value;
28
+ }
29
+ return clean;
30
+ }
31
+ // Maps YAML hook names to AI SDK callback names and creates fire-and-forget callbacks.
32
+ // onFinish is intentionally excluded — it is handled by the callers (at the stream
33
+ // level in handleAgentChat, after generate in handleAgentGenerate) so that hooks
34
+ // are awaited.
35
+ const hookMapping = {
36
+ onStart: 'experimental_onStart',
37
+ onStepStart: 'experimental_onStepStart',
38
+ onToolCallStart: 'experimental_onToolCallStart',
39
+ onToolCallFinish: 'experimental_onToolCallFinish',
40
+ onStepFinish: 'onStepFinish'
41
+ };
42
+ function createHookCallbacks({ hooks, callEndpoint, locale }) {
43
+ if (!hooks) return {};
44
+ const callbacks = {};
45
+ for (const [yamlKey, sdkKey] of Object.entries(hookMapping)){
46
+ const endpointIds = hooks[yamlKey];
47
+ if (!endpointIds || endpointIds.length === 0) continue;
48
+ callbacks[sdkKey] = (event)=>{
49
+ const payload = {
50
+ ...cleanHookEvent(event),
51
+ locale
52
+ };
53
+ for (const endpointId of endpointIds){
54
+ callEndpoint(endpointId, {
55
+ payload
56
+ }).catch(()=>{});
57
+ }
58
+ };
59
+ }
60
+ return callbacks;
61
+ }
62
+ // Shared agent assembly for the streaming chat path and the headless generate
63
+ // path: tools, model, hook callbacks, instructions, stop conditions, and the
64
+ // ToolLoopAgent instance.
65
+ async function createToolLoopAgent({ connection, agent, context, autoApprove = false }) {
66
+ const { tools, mcpClients } = await buildAgentTools({
67
+ agent,
68
+ context,
69
+ autoApprove
70
+ });
71
+ const sharedState = context.agentContext?.sharedState;
72
+ const updatePageStateTool = buildUpdatePageStateTool({
73
+ sharedState
74
+ });
75
+ if (updatePageStateTool) {
76
+ tools['update-page-state'] = updatePageStateTool;
77
+ }
78
+ const model = connection.provider(agent.properties.model);
79
+ const locale = context.i18n?.active;
80
+ const hookCallbacks = createHookCallbacks({
81
+ hooks: agent.hooks,
82
+ callEndpoint: context.callEndpoint,
83
+ locale
84
+ });
85
+ // Prepend page context to instructions when pageContext is enabled
86
+ let instructions = agent.properties.instructions;
87
+ if (agent.properties.pageContext && context.agentContext) {
88
+ const ctx = context.agentContext;
89
+ const contextLines = [
90
+ '<context>'
91
+ ];
92
+ if (ctx.pageId) contextLines.push(` pageId: ${ctx.pageId}`);
93
+ if (ctx.userId) contextLines.push(` userId: ${ctx.userId}`);
94
+ if (ctx.conversationId) contextLines.push(` conversationId: ${ctx.conversationId}`);
95
+ if (ctx.urlQuery && Object.keys(ctx.urlQuery).length > 0) {
96
+ contextLines.push(` urlQuery: ${JSON.stringify(ctx.urlQuery)}`);
97
+ }
98
+ if (ctx.sharedState && Object.keys(ctx.sharedState).length > 0) {
99
+ contextLines.push(` sharedState: ${JSON.stringify(ctx.sharedState)}`);
100
+ }
101
+ contextLines.push('</context>');
102
+ instructions = `${contextLines.join('\n')}\n\n${instructions ?? ''}`;
103
+ }
104
+ // Build stop conditions
105
+ const stopConditions = [
106
+ stepCountIs(agent.properties.maxSteps ?? 10)
107
+ ];
108
+ const stopOnToolCall = agent.properties.stopOnToolCall;
109
+ if (stopOnToolCall) {
110
+ const toolNames = Array.isArray(stopOnToolCall) ? stopOnToolCall : [
111
+ stopOnToolCall
112
+ ];
113
+ for (const name of toolNames){
114
+ stopConditions.push(hasToolCall(name));
115
+ }
116
+ }
117
+ const agentInstance = new ToolLoopAgent({
118
+ model,
119
+ instructions,
120
+ tools,
121
+ stopWhen: stopConditions.length === 1 ? stopConditions[0] : stopConditions,
122
+ maxOutputTokens: agent.properties.maxOutputTokens,
123
+ temperature: agent.properties.temperature,
124
+ toolChoice: agent.properties.toolChoice ?? 'auto',
125
+ providerOptions: agent.properties.providerOptions,
126
+ activeTools: agent.properties.activeTools,
127
+ topP: agent.properties.topP,
128
+ topK: agent.properties.topK,
129
+ frequencyPenalty: agent.properties.frequencyPenalty,
130
+ presencePenalty: agent.properties.presencePenalty,
131
+ seed: agent.properties.seed,
132
+ stopSequences: agent.properties.stopSequences,
133
+ maxRetries: agent.properties.maxRetries,
134
+ ...agent.properties.prepareStep ? {
135
+ prepareStep: buildPrepareStep(agent.properties.prepareStep)
136
+ } : {},
137
+ ...hookCallbacks,
138
+ ...agent.properties.repairToolCall ? {
139
+ experimental_repairToolCall: async ({ toolCall })=>{
140
+ return {
141
+ ...toolCall
142
+ };
143
+ }
144
+ } : {}
145
+ });
146
+ const timeoutConfig = agent.properties.timeout != null ? {
147
+ timeout: agent.properties.timeout
148
+ } : {};
149
+ return {
150
+ agentInstance,
151
+ mcpClients,
152
+ model,
153
+ timeoutConfig,
154
+ locale
155
+ };
156
+ }
157
+ export default createToolLoopAgent;
@@ -0,0 +1,44 @@
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
+ */ function createUsageAccumulator() {
16
+ const usage = {
17
+ inputTokens: 0,
18
+ outputTokens: 0,
19
+ totalTokens: 0,
20
+ reasoningTokens: 0,
21
+ cacheReadTokens: 0,
22
+ cacheWriteTokens: 0
23
+ };
24
+ let finishReason = 'stop';
25
+ function add(stepResult) {
26
+ const stepUsage = stepResult?.usage ?? stepResult;
27
+ if (stepResult?.finishReason) {
28
+ finishReason = stepResult.finishReason;
29
+ }
30
+ if (!stepUsage) return;
31
+ usage.inputTokens += stepUsage.inputTokens ?? 0;
32
+ usage.outputTokens += stepUsage.outputTokens ?? 0;
33
+ usage.totalTokens += stepUsage.totalTokens ?? 0;
34
+ usage.reasoningTokens += stepUsage.outputTokenDetails?.reasoningTokens ?? 0;
35
+ usage.cacheReadTokens += stepUsage.inputTokenDetails?.cacheReadTokens ?? 0;
36
+ usage.cacheWriteTokens += stepUsage.inputTokenDetails?.cacheWriteTokens ?? 0;
37
+ }
38
+ return {
39
+ usage,
40
+ add,
41
+ getFinishReason: ()=>finishReason
42
+ };
43
+ }
44
+ export default createUsageAccumulator;
@@ -12,83 +12,11 @@
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, generateText, pruneMessages, stepCountIs, hasToolCall, validateUIMessages } from 'ai';
15
+ */ import { convertToModelMessages, createAgentUIStream, createUIMessageStream, createUIMessageStreamResponse, generateId, generateText, pruneMessages, TypeValidationError, validateUIMessages } from 'ai';
16
16
  import { serializer } from '@lowdefy/helpers';
17
- import buildAgentTools from './buildAgentTools.js';
18
- import buildPrepareStep from './buildPrepareStep.js';
19
- import buildUpdatePageStateTool from './buildUpdatePageStateTool.js';
20
- function createUsageAccumulator() {
21
- const usage = {
22
- inputTokens: 0,
23
- outputTokens: 0,
24
- totalTokens: 0,
25
- reasoningTokens: 0,
26
- cacheReadTokens: 0,
27
- cacheWriteTokens: 0
28
- };
29
- let finishReason = 'stop';
30
- function add(stepResult) {
31
- const stepUsage = stepResult?.usage ?? stepResult;
32
- if (stepResult?.finishReason) {
33
- finishReason = stepResult.finishReason;
34
- }
35
- if (!stepUsage) return;
36
- usage.inputTokens += stepUsage.inputTokens ?? 0;
37
- usage.outputTokens += stepUsage.outputTokens ?? 0;
38
- usage.totalTokens += stepUsage.totalTokens ?? 0;
39
- usage.reasoningTokens += stepUsage.outputTokenDetails?.reasoningTokens ?? 0;
40
- usage.cacheReadTokens += stepUsage.inputTokenDetails?.cacheReadTokens ?? 0;
41
- usage.cacheWriteTokens += stepUsage.inputTokenDetails?.cacheWriteTokens ?? 0;
42
- }
43
- return {
44
- usage,
45
- add,
46
- getFinishReason: ()=>finishReason
47
- };
48
- }
49
- // Strip non-serializable fields from agent-level hook events before sending as payload.
50
- // messages excluded here — the stream-level onFinish sends UIMessage[] directly.
51
- function cleanHookEvent(event) {
52
- const clean = {};
53
- for (const [key, value] of Object.entries(event)){
54
- if (key === 'abortSignal') continue;
55
- if (key === 'messages') continue;
56
- if (typeof value === 'function') continue;
57
- clean[key] = value;
58
- }
59
- return clean;
60
- }
61
- // Maps YAML hook names to AI SDK callback names and creates fire-and-forget callbacks.
62
- // onFinish is intentionally excluded — it is handled at the stream level inside the
63
- // createUIMessageStream execute function so that hooks are awaited and can return
64
- // dataParts to be written to the response stream.
65
- const hookMapping = {
66
- onStart: 'experimental_onStart',
67
- onStepStart: 'experimental_onStepStart',
68
- onToolCallStart: 'experimental_onToolCallStart',
69
- onToolCallFinish: 'experimental_onToolCallFinish',
70
- onStepFinish: 'onStepFinish'
71
- };
72
- function createHookCallbacks({ hooks, callEndpoint, locale }) {
73
- if (!hooks) return {};
74
- const callbacks = {};
75
- for (const [yamlKey, sdkKey] of Object.entries(hookMapping)){
76
- const endpointIds = hooks[yamlKey];
77
- if (!endpointIds || endpointIds.length === 0) continue;
78
- callbacks[sdkKey] = (event)=>{
79
- const payload = {
80
- ...cleanHookEvent(event),
81
- locale
82
- };
83
- for (const endpointId of endpointIds){
84
- callEndpoint(endpointId, {
85
- payload
86
- }).catch(()=>{});
87
- }
88
- };
89
- }
90
- return callbacks;
91
- }
17
+ import createToolLoopAgent from './createToolLoopAgent.js';
18
+ import createUsageAccumulator from './createUsageAccumulator.js';
19
+ import handleAgentGenerate from './handleAgentGenerate.js';
92
20
  // Convert data: URLs in file parts to raw base64 so the AI SDK does not attempt
93
21
  // to download them (it only supports http/https). The mediaType field already
94
22
  // carries the MIME type, so nothing is lost.
@@ -112,6 +40,27 @@ function convertDataUrlsToBase64(messages) {
112
40
  };
113
41
  });
114
42
  }
43
+ // Drop messages that carry no parts. The AI SDK client pushes the assistant
44
+ // message on the stream's `start` chunk, before any content arrives, so a
45
+ // request that fails after that point leaves an assistant shell with
46
+ // `parts: []` in the client's history. Sent back, that shell fails UIMessage
47
+ // validation ("Message must contain at least one part") — and keeps failing on
48
+ // every later turn, so one transient error would otherwise kill the
49
+ // conversation until a reload. There is nothing in an empty message for the
50
+ // model anyway.
51
+ function dropEmptyMessages(messages) {
52
+ return messages.filter((msg)=>Array.isArray(msg.parts) && msg.parts.length > 0);
53
+ }
54
+ // What the client is told when a turn fails. TypeValidationError.message
55
+ // embeds JSON.stringify(value) — for a UIMessage validation failure that is
56
+ // the entire conversation, which the chat block then shows in a toast. The
57
+ // full error is logged server-side before this is written to the stream.
58
+ function clientErrorText(error) {
59
+ if (TypeValidationError.isInstance(error)) {
60
+ return 'The conversation could not be sent: a message failed validation.';
61
+ }
62
+ return error?.message ?? String(error);
63
+ }
115
64
  // Concatenate the text parts of the first user message — used as the source
116
65
  // for generateTitle.
117
66
  function getFirstUserText(messages) {
@@ -120,96 +69,29 @@ function getFirstUserText(messages) {
120
69
  return (firstUser.parts ?? []).filter((part)=>part.type === 'text').map((part)=>part.text).join('');
121
70
  }
122
71
  async function handleAgentChat({ connection, properties, context }) {
72
+ // Headless run-to-completion mode (CallAgent routine step) — dispatched here
73
+ // so provider agent plugins delegate unchanged in both modes.
74
+ if (context.mode === 'generate') {
75
+ return handleAgentGenerate({
76
+ connection,
77
+ properties,
78
+ context
79
+ });
80
+ }
123
81
  const { agent, messages: rawMessages } = properties;
124
- const messages = convertDataUrlsToBase64(rawMessages);
125
- const { tools, mcpClients } = await buildAgentTools({
82
+ const messages = dropEmptyMessages(convertDataUrlsToBase64(rawMessages));
83
+ const { agentInstance, mcpClients, model, timeoutConfig, locale } = await createToolLoopAgent({
84
+ connection,
126
85
  agent,
127
86
  context
128
87
  });
129
- const sharedState = context.agentContext?.sharedState;
130
- const updatePageStateTool = buildUpdatePageStateTool({
131
- sharedState
132
- });
133
- if (updatePageStateTool) {
134
- tools['update-page-state'] = updatePageStateTool;
135
- }
136
- const model = connection.provider(agent.properties.model);
137
- const locale = context.i18n?.active;
138
- const hookCallbacks = createHookCallbacks({
139
- hooks: agent.hooks,
140
- callEndpoint: context.callEndpoint,
141
- locale
142
- });
143
- // Prepend page context to instructions when pageContext is enabled
144
- let instructions = agent.properties.instructions;
145
- if (agent.properties.pageContext && context.agentContext) {
146
- const ctx = context.agentContext;
147
- const contextLines = [
148
- '<context>'
149
- ];
150
- if (ctx.pageId) contextLines.push(` pageId: ${ctx.pageId}`);
151
- if (ctx.userId) contextLines.push(` userId: ${ctx.userId}`);
152
- if (ctx.conversationId) contextLines.push(` conversationId: ${ctx.conversationId}`);
153
- if (ctx.urlQuery && Object.keys(ctx.urlQuery).length > 0) {
154
- contextLines.push(` urlQuery: ${JSON.stringify(ctx.urlQuery)}`);
155
- }
156
- if (ctx.sharedState && Object.keys(ctx.sharedState).length > 0) {
157
- contextLines.push(` sharedState: ${JSON.stringify(ctx.sharedState)}`);
158
- }
159
- contextLines.push('</context>');
160
- instructions = `${contextLines.join('\n')}\n\n${instructions ?? ''}`;
161
- }
162
- // Build stop conditions
163
- const stopConditions = [
164
- stepCountIs(agent.properties.maxSteps ?? 10)
165
- ];
166
- const stopOnToolCall = agent.properties.stopOnToolCall;
167
- if (stopOnToolCall) {
168
- const toolNames = Array.isArray(stopOnToolCall) ? stopOnToolCall : [
169
- stopOnToolCall
170
- ];
171
- for (const name of toolNames){
172
- stopConditions.push(hasToolCall(name));
173
- }
174
- }
175
- const agentInstance = new ToolLoopAgent({
176
- model,
177
- instructions,
178
- tools,
179
- stopWhen: stopConditions.length === 1 ? stopConditions[0] : stopConditions,
180
- maxOutputTokens: agent.properties.maxOutputTokens,
181
- temperature: agent.properties.temperature,
182
- toolChoice: agent.properties.toolChoice ?? 'auto',
183
- providerOptions: agent.properties.providerOptions,
184
- activeTools: agent.properties.activeTools,
185
- topP: agent.properties.topP,
186
- topK: agent.properties.topK,
187
- frequencyPenalty: agent.properties.frequencyPenalty,
188
- presencePenalty: agent.properties.presencePenalty,
189
- seed: agent.properties.seed,
190
- stopSequences: agent.properties.stopSequences,
191
- maxRetries: agent.properties.maxRetries,
192
- ...agent.properties.prepareStep ? {
193
- prepareStep: buildPrepareStep(agent.properties.prepareStep)
194
- } : {},
195
- ...hookCallbacks,
196
- ...agent.properties.repairToolCall ? {
197
- experimental_repairToolCall: async ({ toolCall })=>{
198
- return {
199
- ...toolCall
200
- };
201
- }
202
- } : {}
203
- });
204
88
  const onFinishEndpointIds = agent.hooks?.onFinish;
205
89
  const hasOnFinishHooks = onFinishEndpointIds && onFinishEndpointIds.length > 0;
206
90
  const hasMcpClients = mcpClients.length > 0;
207
91
  const pruneConfig = agent.properties.prune;
208
- const timeoutConfig = agent.properties.timeout != null ? {
209
- timeout: agent.properties.timeout
210
- } : {};
211
92
  const titleEnabled = agent.properties.generateTitle === true;
212
93
  const stream = createUIMessageStream({
94
+ onError: clientErrorText,
213
95
  execute: async ({ writer })=>{
214
96
  const usageAccumulator = createUsageAccumulator();
215
97
  const steps = [];
@@ -259,52 +141,74 @@ async function handleAgentChat({ connection, properties, context }) {
259
141
  });
260
142
  }
261
143
  }
262
- if (pruneConfig) {
263
- // Decompose createAgentUIStream so we can insert pruneMessages
264
- // between the UIMessage→ModelMessage conversion and agent execution.
265
- const validatedMessages = await validateUIMessages({
266
- messages,
267
- tools: agentInstance.tools
268
- });
269
- const modelMessages = await convertToModelMessages(validatedMessages, {
270
- tools: agentInstance.tools
271
- });
272
- const prunedMessages = pruneMessages({
273
- messages: modelMessages,
274
- ...pruneConfig
275
- });
276
- const result = await agentInstance.stream({
277
- prompt: prunedMessages,
278
- ...timeoutConfig,
279
- onStepFinish: collectStep
280
- });
281
- agentStream = result.toUIMessageStream({
282
- originalMessages: validatedMessages,
283
- onFinish: captureMessages
284
- });
285
- } else {
286
- // createAgentUIStream validates UIMessages, converts to ModelMessages,
287
- // runs the agent, and returns a UIMessageStream — handling the full
288
- // UI→model→UI conversion that ToolLoopAgent.stream() does not.
289
- agentStream = await createAgentUIStream({
290
- agent: agentInstance,
291
- uiMessages: messages,
292
- ...timeoutConfig,
293
- onStepFinish: collectStep,
294
- onFinish: captureMessages
295
- });
296
- }
297
- // Read the agent stream to completion before running onFinish hooks.
298
- // writer.merge() is fire-and-forget (returns void in the AI SDK), so we
299
- // manually read the stream to ensure hooks fire after the agent finishes.
300
- const reader = agentStream.getReader();
144
+ // One try around validation, the agent run and the read: a UIMessage that
145
+ // fails validation throws from createAgentUIStream / validateUIMessages
146
+ // before any stream exists, and that fault has to be logged and redacted
147
+ // the same way as a fault mid-stream.
301
148
  try {
149
+ if (pruneConfig) {
150
+ // Decompose createAgentUIStream so we can insert pruneMessages
151
+ // between the UIMessage→ModelMessage conversion and agent execution.
152
+ const validatedMessages = await validateUIMessages({
153
+ messages,
154
+ tools: agentInstance.tools
155
+ });
156
+ const modelMessages = await convertToModelMessages(validatedMessages, {
157
+ tools: agentInstance.tools
158
+ });
159
+ const prunedMessages = pruneMessages({
160
+ messages: modelMessages,
161
+ ...pruneConfig
162
+ });
163
+ const result = await agentInstance.stream({
164
+ prompt: prunedMessages,
165
+ ...timeoutConfig,
166
+ onStepFinish: collectStep
167
+ });
168
+ agentStream = result.toUIMessageStream({
169
+ originalMessages: validatedMessages,
170
+ // Without a generator the assistant message reaches onFinish with
171
+ // id: '' — see the createAgentUIStream call below.
172
+ generateMessageId: generateId,
173
+ onFinish: captureMessages
174
+ });
175
+ } else {
176
+ // createAgentUIStream validates UIMessages, converts to ModelMessages,
177
+ // runs the agent, and returns a UIMessageStream — handling the full
178
+ // UI→model→UI conversion that ToolLoopAgent.stream() does not.
179
+ agentStream = await createAgentUIStream({
180
+ agent: agentInstance,
181
+ uiMessages: messages,
182
+ ...timeoutConfig,
183
+ // The AI SDK only ids the assistant message it builds when the caller
184
+ // supplies a generator: toUIMessageStream derives responseMessageId
185
+ // only if generateMessageId != null, and handleUIMessageStreamFinish
186
+ // then seeds the streaming state with `messageId ?? ''`, with no
187
+ // messageId on the start chunk to override it. Without this every
188
+ // assistant message an onFinish hook persists carries id: '', so a
189
+ // saved transcript has them all sharing one id — and a UI that keys
190
+ // its bubbles by message id (AgentChat does) renders the last reply in
191
+ // every assistant bubble when the conversation is reloaded. The client
192
+ // generates its own id while streaming, so the damage only shows up
193
+ // after a reload.
194
+ generateMessageId: generateId,
195
+ onStepFinish: collectStep,
196
+ onFinish: captureMessages
197
+ });
198
+ }
199
+ // Read the agent stream to completion before running onFinish hooks.
200
+ // writer.merge() is fire-and-forget (returns void in the AI SDK), so we
201
+ // manually read the stream to ensure hooks fire after the agent finishes.
202
+ const reader = agentStream.getReader();
302
203
  while(true){
303
204
  const { done, value } = await reader.read();
304
205
  if (done) break;
305
206
  writer.write(value);
306
207
  }
307
208
  } catch (error) {
209
+ // The client only sees the (possibly redacted) error text, so the fault
210
+ // is logged server-side before it is written to the stream.
211
+ console.error(`Agent stream failed: ${error.message}`);
308
212
  writer.write({
309
213
  type: 'error',
310
214
  errorText: writer.onError(error)
@@ -0,0 +1,93 @@
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 createToolLoopAgent from './createToolLoopAgent.js';
16
+ import createUsageAccumulator from './createUsageAccumulator.js';
17
+ // Runs an agent headlessly to completion — no chat UI, no streaming. Used by
18
+ // the CallAgent routine step. Confirm tools auto-execute (no client exists to
19
+ // resolve an approval), update-page-state is excluded (callers pass no
20
+ // sharedState), and no conversation title is generated.
21
+ async function handleAgentGenerate({ connection, properties, context }) {
22
+ const { agent, prompt } = properties;
23
+ const { agentInstance, mcpClients, timeoutConfig, locale } = await createToolLoopAgent({
24
+ connection,
25
+ agent,
26
+ context,
27
+ autoApprove: true
28
+ });
29
+ const usageAccumulator = createUsageAccumulator();
30
+ let result;
31
+ try {
32
+ result = await agentInstance.generate({
33
+ prompt,
34
+ ...timeoutConfig,
35
+ onStepFinish: (stepResult)=>usageAccumulator.add(stepResult)
36
+ });
37
+ } finally{
38
+ await Promise.all(mcpClients.map(({ client })=>client.close().catch(()=>{})));
39
+ }
40
+ const toolCalls = result.steps.flatMap((step)=>step.toolCalls ?? []).map(({ toolCallId, toolName, input })=>({
41
+ toolCallId,
42
+ toolName,
43
+ input
44
+ }));
45
+ const toolResults = result.steps.flatMap((step)=>step.toolResults ?? []).map(({ toolCallId, toolName, output })=>({
46
+ toolCallId,
47
+ toolName,
48
+ output
49
+ }));
50
+ // JSON round-trip guards serializability — tool outputs can carry
51
+ // non-serializable provider values, and the summary lands in _step.
52
+ const summary = JSON.parse(JSON.stringify({
53
+ text: result.text,
54
+ finishReason: result.finishReason,
55
+ usage: usageAccumulator.usage,
56
+ toolCalls,
57
+ toolResults
58
+ }));
59
+ // Call onFinish hooks — awaited, mirroring the stream path, but any
60
+ // dataParts returned by hook endpoints are ignored (there is no stream).
61
+ const onFinishEndpointIds = agent.hooks?.onFinish;
62
+ if (onFinishEndpointIds && onFinishEndpointIds.length > 0) {
63
+ const finishPayload = {
64
+ messages: result.response.messages,
65
+ steps: result.steps.map(({ stepNumber, text, toolCalls: calls, toolResults: results, finishReason })=>({
66
+ stepNumber,
67
+ text,
68
+ toolCalls: calls,
69
+ toolResults: results,
70
+ finishReason
71
+ })),
72
+ toolResults,
73
+ finishReason: result.finishReason,
74
+ isAborted: false,
75
+ ...context.agentContext ?? {},
76
+ usage: usageAccumulator.usage,
77
+ locale
78
+ };
79
+ for (const endpointId of onFinishEndpointIds){
80
+ try {
81
+ await context.callEndpoint(endpointId, {
82
+ payload: finishPayload
83
+ });
84
+ } catch (error) {
85
+ console.warn(`onFinish hook "${endpointId}" failed: ${error.message}`);
86
+ }
87
+ }
88
+ }
89
+ return {
90
+ result: summary
91
+ };
92
+ }
93
+ export default handleAgentGenerate;
@@ -0,0 +1,40 @@
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 { generateText, jsonSchema, Output } from 'ai';
16
+ import buildGenerateCallOptions from './buildGenerateCallOptions.js';
17
+ // generateObject is deprecated in ai v6 — structured output is generateText
18
+ // with an Output.object spec, which also keeps both handlers on one code path.
19
+ async function handleGenerateObject({ model, request }) {
20
+ const result = await generateText({
21
+ model,
22
+ output: Output.object({
23
+ schema: jsonSchema(request.schema),
24
+ name: request.schemaName,
25
+ description: request.schemaDescription
26
+ }),
27
+ ...buildGenerateCallOptions({
28
+ request
29
+ })
30
+ });
31
+ return {
32
+ object: result.output,
33
+ reasoningText: result.reasoningText,
34
+ finishReason: result.finishReason,
35
+ usage: result.usage,
36
+ providerMetadata: result.providerMetadata,
37
+ warnings: result.warnings
38
+ };
39
+ }
40
+ export default handleGenerateObject;
@@ -0,0 +1,33 @@
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 { generateText } from 'ai';
16
+ import buildGenerateCallOptions from './buildGenerateCallOptions.js';
17
+ async function handleGenerateText({ model, request }) {
18
+ const result = await generateText({
19
+ model,
20
+ ...buildGenerateCallOptions({
21
+ request
22
+ })
23
+ });
24
+ return {
25
+ text: result.text,
26
+ reasoningText: result.reasoningText,
27
+ finishReason: result.finishReason,
28
+ usage: result.usage,
29
+ providerMetadata: result.providerMetadata,
30
+ warnings: result.warnings
31
+ };
32
+ }
33
+ export default handleGenerateText;
package/dist/index.js CHANGED
@@ -12,5 +12,8 @@
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
- */ export { default as handleAgentChat } from './handleAgentChat.js';
15
+ */ export { default as createGenerateObject } from './createGenerateObject.js';
16
+ export { default as createGenerateText } from './createGenerateText.js';
17
+ export { default as handleAgentChat } from './handleAgentChat.js';
18
+ export { default as handleAgentGenerate } from './handleAgentGenerate.js';
16
19
  export { default as RESERVED_PLATFORM_TOOL_NAMES } from './reservedToolNames.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lowdefy/ai-utils",
3
- "version": "5.5.1",
3
+ "version": "6.0.0",
4
4
  "license": "Apache-2.0",
5
5
  "repository": {
6
6
  "type": "git",
@@ -17,15 +17,16 @@
17
17
  ],
18
18
  "dependencies": {
19
19
  "@ai-sdk/mcp": "1.0.41",
20
- "@lowdefy/errors": "5.5.1",
21
- "@lowdefy/helpers": "5.5.1",
20
+ "@lowdefy/errors": "6.0.0",
21
+ "@lowdefy/helpers": "6.0.0",
22
22
  "ai": "6.0.176",
23
23
  "glob": "13.0.6"
24
24
  },
25
25
  "devDependencies": {
26
26
  "@jest/globals": "28.1.3",
27
- "@swc/cli": "0.8.0",
28
- "@swc/core": "1.15.18",
27
+ "@lowdefy/ajv": "6.0.0",
28
+ "@swc/cli": "0.8.1",
29
+ "@swc/core": "1.15.32",
29
30
  "@swc/jest": "0.2.39",
30
31
  "jest": "28.1.3"
31
32
  },