@molecule/api-ai-tools 1.0.9 → 1.2.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,191 @@
1
+ /**
2
+ * Tool-input normalization: accept the parameter names a weak executor
3
+ * actually sends, and never let a missing one reach a handler as `undefined`.
4
+ *
5
+ * @module
6
+ */
7
+ import { redactSecrets } from './utilities.js';
8
+ /**
9
+ * Other names a model sends for a declared parameter, most likely first.
10
+ *
11
+ * Every entry here is a name a real run actually produced or a direct synonym
12
+ * of one. `cmd` is the one that cost the most: `exec_command` read only
13
+ * `input.command`, so a call sent as `{ cmd, timeout }` reached
14
+ * `checkBlockedCommand(undefined)` and died with `Cannot read properties of
15
+ * undefined (reading 'match')` — an internal TypeError, not a usable error. The
16
+ * executor read that as "the shell tool is failing consistently now", stopped
17
+ * verifying, and ended the turn reporting "All checks pass" while seven
18
+ * acceptance checks failed (X0 rehearsal 83).
19
+ */
20
+ const PARAM_ALIASES = {
21
+ command: ['cmd', 'shell', 'script', 'bash', 'commandLine', 'command_line', 'run'],
22
+ path: ['file', 'filePath', 'file_path', 'filename', 'file_name', 'filepath', 'target'],
23
+ content: [
24
+ 'contents',
25
+ 'body',
26
+ 'text',
27
+ 'data',
28
+ 'source',
29
+ 'markdown',
30
+ 'fileContent',
31
+ 'file_content',
32
+ ],
33
+ pattern: ['query', 'regex', 'search', 'searchPattern', 'search_pattern', 'text'],
34
+ old_path: ['from', 'src', 'source', 'sourcePath', 'source_path', 'oldPath', 'old'],
35
+ new_path: ['to', 'dest', 'destination', 'destinationPath', 'destination_path', 'newPath', 'new'],
36
+ name: ['skill', 'skillName', 'skill_name', 'title', 'planName', 'plan_name'],
37
+ cwd: ['dir', 'directory', 'workdir', 'workingDirectory', 'working_directory'],
38
+ include: ['glob', 'filePattern', 'file_pattern'],
39
+ };
40
+ /** `old_path` ⇄ `oldPath`, so a casing slip is never a missing parameter. */
41
+ function caseVariants(key) {
42
+ const camel = key.replace(/_([a-z])/g, (_m, c) => c.toUpperCase());
43
+ const snake = key.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase();
44
+ return [camel, snake].filter((v) => v !== key);
45
+ }
46
+ /** Present means the key is there with a value that is not null/undefined. */
47
+ function present(obj, key) {
48
+ return Object.hasOwn(obj, key) && obj[key] !== undefined && obj[key] !== null;
49
+ }
50
+ const warned = new Set();
51
+ /**
52
+ * Move known aliases onto the declared parameter names and report what is still
53
+ * missing. Unknown keys are left in place — handlers ignore them, and dropping
54
+ * them would hide a model's intent from anything reading the call log.
55
+ *
56
+ * @param schema - The tool's declared schema.
57
+ * @param raw - Whatever the model sent as the tool input.
58
+ * @returns The normalized input plus the missing-required and aliased lists.
59
+ */
60
+ export function normalizeToolInput(schema, raw) {
61
+ const input = typeof raw === 'object' && raw !== null && !Array.isArray(raw)
62
+ ? { ...raw }
63
+ : {};
64
+ const properties = schema.parameters?.properties ?? {};
65
+ const declared = Object.keys(properties);
66
+ const declaredSet = new Set(declared);
67
+ const aliased = [];
68
+ for (const key of declared) {
69
+ if (present(input, key))
70
+ continue;
71
+ const candidates = [...caseVariants(key), ...(PARAM_ALIASES[key] ?? [])];
72
+ for (const alias of candidates) {
73
+ // Never steal a name this same schema declares — `path` on rename_file
74
+ // would be ambiguous between old_path and new_path, so it stays unread.
75
+ if (declaredSet.has(alias))
76
+ continue;
77
+ if (!present(input, alias))
78
+ continue;
79
+ input[key] = input[alias];
80
+ aliased.push(`${alias}→${key}`);
81
+ break;
82
+ }
83
+ }
84
+ // A declared string that arrived as a number/boolean is stringified rather
85
+ // than handed to a handler that will call .match()/.split() on it.
86
+ for (const key of declared) {
87
+ if (properties[key]?.type !== 'string')
88
+ continue;
89
+ const value = input[key];
90
+ if (typeof value === 'number' || typeof value === 'boolean')
91
+ input[key] = String(value);
92
+ }
93
+ const required = schema.parameters?.required ?? [];
94
+ const missing = required.filter((key) => {
95
+ if (!present(input, key))
96
+ return true;
97
+ const value = input[key];
98
+ return typeof value === 'string' && value.trim() === '' && key !== 'content';
99
+ });
100
+ if (aliased.length > 0) {
101
+ const signature = `${schema.name}:${aliased.join(',')}`;
102
+ if (!warned.has(signature)) {
103
+ warned.add(signature);
104
+ console.warn(`[ai-tools] ${schema.name} input normalized (${aliased.join(', ')})`);
105
+ }
106
+ }
107
+ return { input, missing, aliased };
108
+ }
109
+ /**
110
+ * The error a tool returns when a required parameter is still absent after
111
+ * aliasing. Model-facing, so it names the right parameter, what was actually
112
+ * sent, and the shape of a correct call — everything needed to fix it in one
113
+ * turn instead of concluding the tool is broken.
114
+ *
115
+ * @param schema - The tool's declared schema.
116
+ * @param input - The normalized input (used to list the keys that were sent).
117
+ * @param missing - The required parameters still absent.
118
+ * @returns A single-sentence-per-clause error string.
119
+ */
120
+ export function missingParamError(schema, input, missing) {
121
+ const properties = schema.parameters?.properties ?? {};
122
+ const sent = Object.keys(input);
123
+ const names = missing.map((k) => `"${k}"`).join(', ');
124
+ const example = Object.fromEntries((schema.parameters?.required ?? []).map((k) => [k, `<${properties[k]?.type ?? 'value'}>`]));
125
+ return (`${schema.name} needs ${missing.length === 1 ? 'the parameter' : 'the parameters'} ${names}. ` +
126
+ (sent.length > 0 ? `You sent: ${sent.join(', ')}. ` : 'You sent no parameters. ') +
127
+ `Call it as ${JSON.stringify(example)}. This is a parameter-name problem, not a broken tool — ` +
128
+ 'retry the same call with the names above.');
129
+ }
130
+ /**
131
+ * Wrap a tool handler so every call is normalized first and a handler that
132
+ * THROWS returns something the model can act on.
133
+ *
134
+ * The guard deliberately does NOT pre-empt the handler when a required
135
+ * parameter looks absent: several handlers accept a documented older shape
136
+ * (`edit_file`'s top-level `old_string`/`new_string`) or answer a missing
137
+ * parameter with far better guidance than a generic message could (`edit_file`
138
+ * telling the model to re-read the file). It steps in only where the handler
139
+ * would otherwise crash — and when it does, a still-missing required parameter
140
+ * is the accurate diagnosis for that crash, so it is reported as one.
141
+ *
142
+ * @param schema - The tool's declared schema.
143
+ * @param impl - The raw handler.
144
+ * @returns A handler with the same signature, guarded.
145
+ */
146
+ export function guardToolExecute(schema, impl, options = {}) {
147
+ // A thrown message is untrusted text (a connection string in a driver error,
148
+ // a key echoed by a failing command). The guard turns a throw into a RETURNED
149
+ // result, which leaves the consumer's thrown-error redaction path unused — so
150
+ // the message is redacted here, before it can reach the model, the SSE stream
151
+ // or the stored transcript. Defaults to this package's name-keyword redactor;
152
+ // pass a stronger one where the consumer has it.
153
+ const redactError = options.redactError ?? redactSecrets;
154
+ return async (raw, ...rest) => {
155
+ const { input, missing } = normalizeToolInput(schema, raw);
156
+ try {
157
+ return await impl(input, ...rest);
158
+ }
159
+ catch (e) {
160
+ if (missing.length > 0)
161
+ return { error: missingParamError(schema, input, missing) };
162
+ const message = redactError(e instanceof Error ? e.message : String(e));
163
+ return {
164
+ error: `${schema.name} failed: ${message}. Parameters sent: ${Object.keys(input).join(', ') || '(none)'}. ` +
165
+ `This tool expects ${JSON.stringify(schema.parameters?.required ?? [])} — check them and retry.`,
166
+ };
167
+ }
168
+ };
169
+ }
170
+ /**
171
+ * Apply {@link guardToolExecute} to a ready-made tool object (the
172
+ * `@molecule/api-ai` `AITool` shape: `{ name, description, parameters,
173
+ * execute }`), returning a copy whose `execute` is guarded.
174
+ *
175
+ * Use it at the point a consumer hands its full tool list to the model, so
176
+ * tools built outside this package get the same treatment as the ones built
177
+ * inside it.
178
+ *
179
+ * @param tool - The tool to guard.
180
+ * @returns A copy with a guarded `execute`.
181
+ */
182
+ export function guardAITool(tool, options = {}) {
183
+ const schema = {
184
+ name: tool.name,
185
+ parameters: tool.parameters,
186
+ };
187
+ return {
188
+ ...tool,
189
+ execute: guardToolExecute(schema, tool.execute, options),
190
+ };
191
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"schemas.d.ts","sourceRoot":"","sources":["../src/schemas.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAA;AAElD;;GAEG;AACH,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAA;IACZ,WAAW,EAAE,MAAM,CAAA;IACnB,UAAU,EAAE,UAAU,CAAA;CACvB;AAED;;GAEG;AACH,eAAO,MAAM,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,UAAU,CA2O1C,CAAA"}
1
+ {"version":3,"file":"schemas.d.ts","sourceRoot":"","sources":["../src/schemas.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAA;AAElD;;GAEG;AACH,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAA;IACZ,WAAW,EAAE,MAAM,CAAA;IACnB,UAAU,EAAE,UAAU,CAAA;CACvB;AAED;;GAEG;AACH,eAAO,MAAM,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,UAAU,CAqT1C,CAAA"}
package/dist/schemas.js CHANGED
@@ -24,16 +24,39 @@ export const TOOL_SCHEMAS = {
24
24
  },
25
25
  read_file: {
26
26
  name: 'read_file',
27
- description: 'Read the full content of a file as text. Always read a file before editing it.',
27
+ description: 'Read the full content of one file, or of SEVERAL files in a single call. Always read a ' +
28
+ 'file before editing it. When you need more than one file — surveying a project, ' +
29
+ 'gathering the files behind one feature — pass them all as `paths` in ONE call rather ' +
30
+ 'than calling this tool once per file: same content, a fraction of the time. ' +
31
+ 'For a @molecule package\u2019s docs call read_molecule_doc instead — one call, the whole ' +
32
+ 'document. A file under 80 KB comes back whole; do not page through it with offset/limit.',
28
33
  parameters: {
29
34
  type: 'object',
30
35
  properties: {
31
36
  path: {
32
37
  type: 'string',
33
- description: 'Path to the file to read (relative or absolute).',
38
+ description: 'Path to a single file to read (relative or absolute).',
39
+ },
40
+ paths: {
41
+ type: 'array',
42
+ items: { type: 'string' },
43
+ description: 'Paths to SEVERAL files to read in one call — preferred whenever you want more ' +
44
+ 'than one file. Returns `files`, one entry per path in the order given, each ' +
45
+ "with that file's content or its own error.",
46
+ },
47
+ offset: {
48
+ type: 'number',
49
+ description: 'First line to return, 1-based. NEGATIVE counts from the end, so `offset: -30` ' +
50
+ 'is the last 30 lines. Use with `limit` to read PART of a large file instead of ' +
51
+ 'all of it — never pipe a file through head/tail in the shell.',
52
+ },
53
+ limit: {
54
+ type: 'number',
55
+ description: 'How many lines to return from `offset`. The result reports `offset`, `lines` ' +
56
+ 'and `totalLines` so you can ask for the next window.',
34
57
  },
35
58
  },
36
- required: ['path'],
59
+ required: [],
37
60
  },
38
61
  },
39
62
  write_file: {
@@ -104,6 +127,12 @@ export const TOOL_SCHEMAS = {
104
127
  type: 'string',
105
128
  description: "File glob pattern to filter files (e.g. '*.ts', '*.tsx'). Optional.",
106
129
  },
130
+ contextLines: {
131
+ type: 'number',
132
+ description: 'Lines of surrounding context to return with each match, like `grep -C`. Each ' +
133
+ 'result carries `match: true` for the matching line and `false` for context. ' +
134
+ 'Use this instead of grepping through the shell.',
135
+ },
107
136
  },
108
137
  required: ['pattern'],
109
138
  },
@@ -174,7 +203,9 @@ export const TOOL_SCHEMAS = {
174
203
  },
175
204
  exec_command: {
176
205
  name: 'exec_command',
177
- description: 'Run a shell command and return its output. Use for build checks, npm commands, verification, etc.',
206
+ description: 'Run a shell command and return its output. Use for build checks, npm commands, verification, etc. ' +
207
+ 'Pass `run_in_background` for anything long-running: it returns a handle at once instead of ' +
208
+ 'being cut off at the time limit.',
178
209
  parameters: {
179
210
  type: 'object',
180
211
  properties: {
@@ -186,10 +217,44 @@ export const TOOL_SCHEMAS = {
186
217
  type: 'string',
187
218
  description: 'Working directory. Defaults to project root.',
188
219
  },
220
+ timeout: {
221
+ type: 'number',
222
+ description: "Optional time budget in milliseconds. Clamped to this tool's ceiling — ask for more and the ceiling applies and is reported, so split the work into smaller commands instead of re-running the same long one.",
223
+ },
224
+ run_in_background: {
225
+ type: 'boolean',
226
+ description: 'Start the command DETACHED and return immediately with a handle instead of waiting. ' +
227
+ 'Use it for anything that may outrun the ceiling — a full test suite, a production ' +
228
+ 'build, a long install. The result gives a log path to read with read_file and an ' +
229
+ 'exit-code file that appears when it finishes. Do the next piece of work while it ' +
230
+ 'runs; when you need its result, call wait_for_task (one call, no polling).',
231
+ },
189
232
  },
190
233
  required: ['command'],
191
234
  },
192
235
  },
236
+ wait_for_task: {
237
+ name: 'wait_for_task',
238
+ description: 'Wait for a command started with `run_in_background` and return its output and exit ' +
239
+ 'code. Blocks until it finishes or the time budget runs out, then returns — one call, ' +
240
+ 'no polling. Use it the moment you need that result to continue; never `sleep` and ' +
241
+ 'read the log by hand. If it is still running when the budget ends, the result says so ' +
242
+ 'and you can call it again.',
243
+ parameters: {
244
+ type: 'object',
245
+ properties: {
246
+ taskId: {
247
+ type: 'string',
248
+ description: 'The `taskId` the background command returned.',
249
+ },
250
+ timeout: {
251
+ type: 'number',
252
+ description: 'Optional time budget in milliseconds. Clamped to this tool’s ceiling.',
253
+ },
254
+ },
255
+ required: ['taskId'],
256
+ },
257
+ },
193
258
  save_plan: {
194
259
  name: 'save_plan',
195
260
  description: 'Save an implementation plan as a markdown file in .agents/plans/.',
@@ -1 +1 @@
1
- {"version":3,"file":"tools.d.ts","sourceRoot":"","sources":["../src/tools.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAA;AAG9C,OAAO,KAAK,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,YAAY,CAAA;AAsBnE;;;;;;GAMG;AACH,wBAAgB,UAAU,CAAC,OAAO,EAAE,gBAAgB,EAAE,MAAM,CAAC,EAAE,eAAe,GAAG,MAAM,EAAE,CA2zBxF"}
1
+ {"version":3,"file":"tools.d.ts","sourceRoot":"","sources":["../src/tools.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAA;AAY9C,OAAO,KAAK,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,YAAY,CAAA;AA+BnE;;;;;;GAMG;AACH,wBAAgB,UAAU,CAAC,OAAO,EAAE,gBAAgB,EAAE,MAAM,CAAC,EAAE,eAAe,GAAG,MAAM,EAAE,CA0tCxF"}