@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, tool, jsonSchema, stepCountIs } from 'ai';
16
+ import { createMCPClient } from '@ai-sdk/mcp';
17
+ import { Experimental_StdioMCPTransport } from '@ai-sdk/mcp/mcp-stdio';
18
+ import { serializer } from '@lowdefy/helpers';
19
+ import listFiles from './fileSystem/listFiles.js';
20
+ import readFile from './fileSystem/readFile.js';
21
+ import searchFiles from './fileSystem/searchFiles.js';
22
+ import statFile from './fileSystem/statFile.js';
23
+ // Build artifacts contain serializer markers (~k, ~r, ~l) as non-enumerable
24
+ // properties and ~arr wrappers for arrays. JSON.parse(JSON.stringify(obj))
25
+ // strips non-enumerable props and produces a clean JSON Schema for the AI SDK.
26
+ function cleanBuildArtifact(obj) {
27
+ return JSON.parse(JSON.stringify(obj, (key, value)=>{
28
+ if (key.startsWith('~')) return undefined;
29
+ return value;
30
+ }));
31
+ }
32
+ async function buildAgentTools({ agent, context, depth = 0 }) {
33
+ const MAX_DEPTH = 5;
34
+ if (depth > MAX_DEPTH) {
35
+ throw new Error(`Sub-agent nesting exceeds maximum depth of ${MAX_DEPTH}.`);
36
+ }
37
+ const tools = {};
38
+ const mcpClients = [];
39
+ // Build endpoint tools
40
+ for (const toolConfig of agent.tools ?? []){
41
+ const { endpointId, confirm } = toolConfig;
42
+ const endpointConfig = await context.getEndpointConfig({
43
+ endpointId
44
+ });
45
+ tools[endpointId] = tool({
46
+ description: endpointConfig.description,
47
+ inputSchema: jsonSchema(cleanBuildArtifact(endpointConfig.payloadSchema)),
48
+ ...confirm ? {
49
+ needsApproval: true
50
+ } : {},
51
+ execute: async (input, { abortSignal } = {})=>{
52
+ const result = await context.callEndpoint(endpointId, {
53
+ payload: input,
54
+ abortSignal
55
+ });
56
+ if (!result.success) {
57
+ const err = serializer.deserialize(result.error);
58
+ throw new Error(err?.message ?? 'Endpoint execution failed');
59
+ }
60
+ return cleanBuildArtifact(result.response);
61
+ }
62
+ });
63
+ }
64
+ // Build MCP clients and merge their tools
65
+ for (const mcpSource of agent.mcp ?? []){
66
+ const evaluatedSource = context.evaluateOperators(mcpSource);
67
+ try {
68
+ let transport;
69
+ if (evaluatedSource.transport === 'stdio') {
70
+ transport = new Experimental_StdioMCPTransport({
71
+ command: evaluatedSource.command,
72
+ args: evaluatedSource.args,
73
+ env: {
74
+ ...process.env,
75
+ ...evaluatedSource.env ?? {}
76
+ }
77
+ });
78
+ } else {
79
+ transport = {
80
+ type: evaluatedSource.transport ?? 'http',
81
+ url: evaluatedSource.url,
82
+ ...evaluatedSource.headers ? {
83
+ headers: evaluatedSource.headers
84
+ } : {}
85
+ };
86
+ }
87
+ const client = await createMCPClient({
88
+ transport
89
+ });
90
+ mcpClients.push({
91
+ client,
92
+ source: evaluatedSource
93
+ });
94
+ } catch (err) {
95
+ const label = evaluatedSource.transport === 'stdio' ? evaluatedSource.command : evaluatedSource.url;
96
+ console.warn(`MCP server "${label}" unreachable: ${err.message}`);
97
+ }
98
+ }
99
+ // Merge MCP tools with endpoint tools
100
+ for (const { client, source } of mcpClients){
101
+ try {
102
+ const mcpTools = await client.tools();
103
+ for (const [name, mcpTool] of Object.entries(mcpTools)){
104
+ if (tools[name]) {
105
+ console.warn(`MCP tool "${name}" from ${source.url ?? source.command} conflicts with endpoint tool — skipped.`);
106
+ continue;
107
+ }
108
+ if (source.confirm) {
109
+ tools[name] = {
110
+ ...mcpTool,
111
+ needsApproval: true
112
+ };
113
+ } else {
114
+ tools[name] = mcpTool;
115
+ }
116
+ }
117
+ } catch (err) {
118
+ const label = source.transport === 'stdio' ? source.command : source.url;
119
+ console.warn(`MCP server "${label}" tool listing failed: ${err.message}`);
120
+ }
121
+ }
122
+ // Build sub-agent tools
123
+ for (const subAgentRef of agent.agents ?? []){
124
+ const subAgentConfig = await context.getAgentConfig({
125
+ agentId: subAgentRef.agentId
126
+ });
127
+ const subConnection = await context.getConnectionForAgent({
128
+ agentConfig: subAgentConfig
129
+ });
130
+ subAgentConfig.mcp = await context.resolveMcpSources({
131
+ agentConfig: subAgentConfig
132
+ });
133
+ // Recursively build sub-agent's tools
134
+ const { tools: subTools, mcpClients: subMcpClients } = await buildAgentTools({
135
+ agent: subAgentConfig,
136
+ context,
137
+ depth: depth + 1
138
+ });
139
+ const subModel = subConnection.provider(subAgentConfig.properties.model);
140
+ const subAgent = new ToolLoopAgent({
141
+ model: subModel,
142
+ instructions: subAgentConfig.properties.instructions,
143
+ tools: subTools,
144
+ stopWhen: stepCountIs(subAgentConfig.properties.maxSteps ?? 10),
145
+ maxOutputTokens: subAgentConfig.properties.maxOutputTokens,
146
+ temperature: subAgentConfig.properties.temperature,
147
+ toolChoice: subAgentConfig.properties.toolChoice ?? 'auto',
148
+ providerOptions: subAgentConfig.properties.providerOptions
149
+ });
150
+ const description = subAgentRef.description ?? `Delegate task to the ${subAgentRef.agentId} agent`;
151
+ const inputSchema = subAgentRef.inputSchema ? jsonSchema(subAgentRef.inputSchema) : jsonSchema({
152
+ type: 'object',
153
+ properties: {
154
+ task: {
155
+ type: 'string',
156
+ description: 'The task to delegate'
157
+ }
158
+ },
159
+ required: [
160
+ 'task'
161
+ ]
162
+ });
163
+ tools[subAgentRef.agentId] = tool({
164
+ description,
165
+ inputSchema,
166
+ execute: async (input, { abortSignal })=>{
167
+ const prompt = input.task ?? JSON.stringify(input);
168
+ const result = await subAgent.generate({
169
+ prompt,
170
+ abortSignal
171
+ });
172
+ // Cleanup sub-agent's MCP clients
173
+ await Promise.all(subMcpClients.map(({ client })=>client.close().catch(()=>{})));
174
+ return {
175
+ _subAgent: true,
176
+ agentId: subAgentRef.agentId,
177
+ text: result.text
178
+ };
179
+ },
180
+ toModelOutput: ({ output })=>({
181
+ type: 'text',
182
+ value: output.text ?? String(output)
183
+ })
184
+ });
185
+ }
186
+ // Build fileSystem tools
187
+ if (agent.properties?.fileSystem) {
188
+ const { basePath } = agent.properties.fileSystem;
189
+ tools['read-file'] = tool({
190
+ description: 'Read a file by path. Use list-files or search-files first to discover available paths.',
191
+ inputSchema: jsonSchema({
192
+ type: 'object',
193
+ properties: {
194
+ path: {
195
+ type: 'string',
196
+ description: 'File path relative to the base directory.'
197
+ }
198
+ },
199
+ required: [
200
+ 'path'
201
+ ]
202
+ }),
203
+ execute: async ({ path })=>readFile(basePath, {
204
+ path
205
+ })
206
+ });
207
+ tools['list-files'] = tool({
208
+ description: 'List files and directories. Supports optional glob patterns for filtering.',
209
+ inputSchema: jsonSchema({
210
+ type: 'object',
211
+ properties: {
212
+ path: {
213
+ type: 'string',
214
+ description: 'Directory path to list. Omit for root.'
215
+ },
216
+ glob: {
217
+ type: 'string',
218
+ description: 'Glob pattern to filter results, e.g. "**/*.md".'
219
+ }
220
+ }
221
+ }),
222
+ execute: async (params)=>listFiles(basePath, params)
223
+ });
224
+ tools['search-files'] = tool({
225
+ description: 'Search for text across files. Returns matching files with line numbers and context.',
226
+ inputSchema: jsonSchema({
227
+ type: 'object',
228
+ properties: {
229
+ query: {
230
+ type: 'string',
231
+ description: 'Text to search for.'
232
+ },
233
+ glob: {
234
+ type: 'string',
235
+ description: 'Glob pattern to limit which files are searched.'
236
+ }
237
+ },
238
+ required: [
239
+ 'query'
240
+ ]
241
+ }),
242
+ execute: async (params)=>searchFiles(basePath, params)
243
+ });
244
+ tools['stat-file'] = tool({
245
+ description: 'Get metadata for a file or directory (size, type, modified date).',
246
+ inputSchema: jsonSchema({
247
+ type: 'object',
248
+ properties: {
249
+ path: {
250
+ type: 'string',
251
+ description: 'File or directory path relative to the base directory.'
252
+ }
253
+ },
254
+ required: [
255
+ 'path'
256
+ ]
257
+ }),
258
+ execute: async ({ path })=>statFile(basePath, {
259
+ path
260
+ })
261
+ });
262
+ }
263
+ return {
264
+ tools,
265
+ mcpClients
266
+ };
267
+ }
268
+ export default buildAgentTools;
@@ -0,0 +1,42 @@
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 matchesRule(rule, stepNumber) {
16
+ if (rule.steps) {
17
+ return rule.steps.includes(stepNumber);
18
+ }
19
+ if (rule.from != null) {
20
+ const fromOk = stepNumber >= rule.from;
21
+ const toOk = rule.to == null || stepNumber <= rule.to;
22
+ return fromOk && toOk;
23
+ }
24
+ return false;
25
+ }
26
+ function buildPrepareStep(rules) {
27
+ if (!rules || rules.length === 0) return undefined;
28
+ return async function prepareStep({ stepNumber }) {
29
+ for (const rule of rules){
30
+ if (matchesRule(rule, stepNumber)) {
31
+ const config = {};
32
+ if (rule.activeTools) config.activeTools = rule.activeTools;
33
+ if (rule.toolChoice) config.toolChoice = rule.toolChoice;
34
+ if (rule.maxOutputTokens) config.maxOutputTokens = rule.maxOutputTokens;
35
+ if (rule.temperature != null) config.temperature = rule.temperature;
36
+ return config;
37
+ }
38
+ }
39
+ return {};
40
+ };
41
+ }
42
+ export default buildPrepareStep;
@@ -0,0 +1,62 @@
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 fs from 'fs/promises';
16
+ import path from 'path';
17
+ import { glob } from 'glob';
18
+ import resolvePath from './resolvePath.js';
19
+ async function listFiles(basePath, { path: dirPath, glob: globPattern } = {}) {
20
+ const absoluteBase = path.resolve(basePath);
21
+ const resolved = resolvePath(basePath, dirPath);
22
+ if (globPattern) {
23
+ const matches = await glob(globPattern, {
24
+ cwd: resolved,
25
+ dot: true
26
+ });
27
+ const safeMatches = matches.filter((match)=>{
28
+ const abs = path.resolve(resolved, match);
29
+ return abs === absoluteBase || abs.startsWith(absoluteBase + path.sep);
30
+ });
31
+ const results = [];
32
+ for (const match of safeMatches.sort()){
33
+ const absoluteMatch = path.resolve(resolved, match);
34
+ const relativePath = path.relative(absoluteBase, absoluteMatch);
35
+ const stats = await fs.stat(absoluteMatch);
36
+ results.push({
37
+ name: path.basename(match),
38
+ path: relativePath,
39
+ type: stats.isDirectory() ? 'directory' : 'file',
40
+ size: stats.size
41
+ });
42
+ }
43
+ return results;
44
+ }
45
+ const entries = await fs.readdir(resolved, {
46
+ withFileTypes: true
47
+ });
48
+ const results = [];
49
+ for (const entry of entries.sort((a, b)=>a.name.localeCompare(b.name))){
50
+ const absoluteEntry = path.join(resolved, entry.name);
51
+ const relativePath = path.relative(absoluteBase, absoluteEntry);
52
+ const stats = await fs.stat(absoluteEntry);
53
+ results.push({
54
+ name: entry.name,
55
+ path: relativePath,
56
+ type: entry.isDirectory() ? 'directory' : 'file',
57
+ size: stats.size
58
+ });
59
+ }
60
+ return results;
61
+ }
62
+ export default listFiles;
@@ -0,0 +1,36 @@
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 fs from 'fs/promises';
16
+ import resolvePath from './resolvePath.js';
17
+ const MAX_FILE_SIZE = 512 * 1024; // 512KB
18
+ async function readFile(basePath, { path: filePath }) {
19
+ const resolved = resolvePath(basePath, filePath);
20
+ const stats = await fs.stat(resolved);
21
+ if (stats.size <= MAX_FILE_SIZE) {
22
+ return await fs.readFile(resolved, 'utf-8');
23
+ }
24
+ const handle = await fs.open(resolved, 'r');
25
+ try {
26
+ const buffer = Buffer.alloc(MAX_FILE_SIZE);
27
+ await handle.read(buffer, 0, MAX_FILE_SIZE, 0);
28
+ const content = buffer.toString('utf-8');
29
+ const shownKB = Math.round(MAX_FILE_SIZE / 1024);
30
+ const totalKB = Math.round(stats.size / 1024);
31
+ return `${content}\n\n[File truncated — showing first ${shownKB}KB of ${totalKB}KB. Use search-files to find specific content.]`;
32
+ } finally{
33
+ await handle.close();
34
+ }
35
+ }
36
+ export default readFile;
@@ -0,0 +1,26 @@
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 path from 'path';
16
+ function resolvePath(basePath, requestPath) {
17
+ const normalizedRequest = (requestPath ?? '').replace(/^\/+/, '');
18
+ const absoluteBase = path.resolve(basePath);
19
+ const absoluteTarget = path.resolve(absoluteBase, normalizedRequest || '.');
20
+ const baseWithSep = absoluteBase + path.sep;
21
+ if (absoluteTarget !== absoluteBase && !absoluteTarget.startsWith(baseWithSep)) {
22
+ throw new Error(`Path "${requestPath}" resolves outside the base directory.`);
23
+ }
24
+ return absoluteTarget;
25
+ }
26
+ export default resolvePath;
@@ -0,0 +1,69 @@
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 fs from 'fs/promises';
16
+ import path from 'path';
17
+ import { glob } from 'glob';
18
+ const MAX_FILE_SIZE = 1024 * 1024; // 1MB
19
+ const MAX_TOTAL_MATCHES = 200;
20
+ async function searchFiles(basePath, { query, glob: globPattern }) {
21
+ const absoluteBase = path.resolve(basePath);
22
+ const baseWithSep = absoluteBase + path.sep;
23
+ const pattern = globPattern ?? '**/*';
24
+ const files = await glob(pattern, {
25
+ cwd: absoluteBase,
26
+ nodir: true,
27
+ dot: true,
28
+ absolute: true
29
+ });
30
+ const safeFiles = files.filter((file)=>file.startsWith(baseWithSep));
31
+ const results = [];
32
+ let totalMatches = 0;
33
+ const queryLower = query.toLowerCase();
34
+ for (const file of safeFiles.sort()){
35
+ if (totalMatches >= MAX_TOTAL_MATCHES) break;
36
+ const stats = await fs.stat(file);
37
+ if (stats.size > MAX_FILE_SIZE) continue;
38
+ let content;
39
+ try {
40
+ content = await fs.readFile(file, 'utf-8');
41
+ } catch {
42
+ continue;
43
+ }
44
+ const lines = content.split('\n');
45
+ const matches = [];
46
+ for(let i = 0; i < lines.length; i++){
47
+ if (totalMatches >= MAX_TOTAL_MATCHES) break;
48
+ if (lines[i].toLowerCase().includes(queryLower)) {
49
+ matches.push({
50
+ lineNumber: i + 1,
51
+ line: lines[i],
52
+ context: {
53
+ before: i > 0 ? lines[i - 1] : null,
54
+ after: i < lines.length - 1 ? lines[i + 1] : null
55
+ }
56
+ });
57
+ totalMatches += 1;
58
+ }
59
+ }
60
+ if (matches.length > 0) {
61
+ results.push({
62
+ path: path.relative(absoluteBase, file),
63
+ matches
64
+ });
65
+ }
66
+ }
67
+ return results;
68
+ }
69
+ export default searchFiles;
@@ -0,0 +1,30 @@
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 fs from 'fs/promises';
16
+ import path from 'path';
17
+ import resolvePath from './resolvePath.js';
18
+ async function statFile(basePath, { path: filePath }) {
19
+ const resolved = resolvePath(basePath, filePath);
20
+ const stats = await fs.stat(resolved);
21
+ return {
22
+ name: path.basename(resolved),
23
+ path: filePath,
24
+ size: stats.size,
25
+ type: stats.isDirectory() ? 'directory' : 'file',
26
+ modified: stats.mtime.toISOString(),
27
+ created: stats.birthtime.toISOString()
28
+ };
29
+ }
30
+ export default statFile;