mcp-fs-shell-windows 0.2.20 → 0.2.29
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.
- package/LICENSE +2 -1
- package/README.md +155 -4
- package/dist/analyze_project/handler.js +62 -0
- package/dist/analyze_project/schema.js +5 -0
- package/dist/browser/browserActions.js +128 -0
- package/dist/browser/fuzzySearch.js +49 -0
- package/dist/browser/handler.js +227 -0
- package/dist/browser/launcher.js +103 -0
- package/dist/browser/schema.js +37 -0
- package/dist/browser/session.js +14 -0
- package/dist/compat/handler.js +254 -0
- package/dist/compat/schema.js +97 -0
- package/dist/gh/handler.js +406 -0
- package/dist/gh/schema.js +36 -0
- package/dist/git/handler.js +209 -0
- package/dist/git/schema.js +23 -0
- package/dist/launch_file/handler.js +3 -1
- package/dist/query_database/handler.js +27 -0
- package/dist/query_database/schema.js +5 -0
- package/dist/rag/handler.js +103 -0
- package/dist/rag/helpers.js +126 -0
- package/dist/rag/schema.js +16 -0
- package/dist/read_document/handler.js +61 -0
- package/dist/read_document/schema.js +4 -0
- package/dist/server.js +864 -1
- package/dist/shell/handler.js +8 -0
- package/dist/subagent/handler.js +752 -0
- package/dist/subagent/handoffMessage.js +56 -0
- package/dist/subagent/schema.js +18 -0
- package/dist/subagent/subAgentToolCallParser.js +491 -0
- package/dist/subagent/toolCallValidator.js +97 -0
- package/dist/system/handler.js +239 -0
- package/dist/system/schema.js +21 -0
- package/dist/web/ddgParse.js +51 -0
- package/dist/web/handler.js +286 -0
- package/dist/web/schema.js +18 -0
- package/package.json +22 -2
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// handoffMessage.ts — ported verbatim from the Beledarian reference
|
|
2
|
+
// (beledarians-lm-studio-tools src/handoffMessage.ts).
|
|
3
|
+
export function extractHandoffMessage(rawContent) {
|
|
4
|
+
const markerRegex = /\[HANDOFF_MESSAGE\]([\s\S]*?)\[\/HANDOFF_MESSAGE\]/i;
|
|
5
|
+
const markerMatch = rawContent.match(markerRegex);
|
|
6
|
+
if (markerMatch) {
|
|
7
|
+
const handoff = markerMatch[1].trim();
|
|
8
|
+
const response = rawContent.replace(markerRegex, "").trim();
|
|
9
|
+
return {
|
|
10
|
+
response,
|
|
11
|
+
handoffMessage: handoff || undefined,
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
const candidates = [];
|
|
15
|
+
const trimmed = rawContent.trim();
|
|
16
|
+
if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
|
|
17
|
+
candidates.push({ json: trimmed });
|
|
18
|
+
}
|
|
19
|
+
const fenceRegex = /```json\s*([\s\S]*?)```/gi;
|
|
20
|
+
let fenceMatch;
|
|
21
|
+
while ((fenceMatch = fenceRegex.exec(rawContent)) !== null) {
|
|
22
|
+
candidates.push({ json: fenceMatch[1], fullMatch: fenceMatch[0] });
|
|
23
|
+
}
|
|
24
|
+
for (const candidate of candidates) {
|
|
25
|
+
try {
|
|
26
|
+
const parsed = JSON.parse(candidate.json);
|
|
27
|
+
if (!parsed || typeof parsed !== "object")
|
|
28
|
+
continue;
|
|
29
|
+
const handoff = typeof parsed.handoff_message === "string"
|
|
30
|
+
? parsed.handoff_message.trim()
|
|
31
|
+
: "";
|
|
32
|
+
if (!handoff)
|
|
33
|
+
continue;
|
|
34
|
+
const explicitResponse = typeof parsed.response === "string"
|
|
35
|
+
? parsed.response.trim()
|
|
36
|
+
: typeof parsed.final_response === "string"
|
|
37
|
+
? parsed.final_response.trim()
|
|
38
|
+
: "";
|
|
39
|
+
let response = explicitResponse;
|
|
40
|
+
if (!response && candidate.fullMatch) {
|
|
41
|
+
response = rawContent.replace(candidate.fullMatch, "").trim();
|
|
42
|
+
}
|
|
43
|
+
if (!response && !candidate.fullMatch) {
|
|
44
|
+
response = "";
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
response,
|
|
48
|
+
handoffMessage: handoff,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
// Not valid JSON; continue scanning.
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return { response: rawContent };
|
|
56
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// subagent/schema.ts — zod schema for consult_secondary_agent (MCP filesystem fork).
|
|
2
|
+
// Parameter set mirrors the Beledarian reference (toolsProvider.ts L2468-2473).
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
export const ConsultSecondaryAgentArgsSchema = z.object({
|
|
5
|
+
task: z.string().describe("The task to delegate to the secondary agent."),
|
|
6
|
+
agent_role: z
|
|
7
|
+
.string()
|
|
8
|
+
.optional()
|
|
9
|
+
.describe("Key from 'Sub-Agent Profiles' config (e.g., 'coder'). Default: 'general'."),
|
|
10
|
+
context: z
|
|
11
|
+
.string()
|
|
12
|
+
.optional()
|
|
13
|
+
.describe("Additional context or data for the agent."),
|
|
14
|
+
allow_tools: z
|
|
15
|
+
.boolean()
|
|
16
|
+
.optional()
|
|
17
|
+
.describe("If true, the secondary agent can use tools like Web Search (DuckDuckGo, Wikipedia), File System (Read/List), and Code Execution (if enabled in settings). Default: false."),
|
|
18
|
+
});
|
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
// subAgentToolCallParser.ts — ported verbatim from the Beledarian reference
|
|
2
|
+
// (beledarians-lm-studio-tools src/subAgentToolCallParser.ts).
|
|
3
|
+
// Extracts a JSON tool call out of a raw model response message: structured
|
|
4
|
+
// tool_calls first, then content (fenced/inline/balanced JSON, legacy
|
|
5
|
+
// "call:tool{...}" syntax, prose+JSON fallback), then reasoning_content.
|
|
6
|
+
function isRecord(value) {
|
|
7
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
8
|
+
}
|
|
9
|
+
function sanitizeContent(text) {
|
|
10
|
+
return text
|
|
11
|
+
.replace(/<\|tool_call\>/gi, "")
|
|
12
|
+
.replace(/<tool_call\|>/gi, "")
|
|
13
|
+
.replace(/<\|.*?\|>/g, "")
|
|
14
|
+
// Strip XML-style thinking tags (e.g., <antThinking>, <thinking>) emitted by Claude and other models.
|
|
15
|
+
// These models embed their chain-of-thought in the content field wrapped in these tags.
|
|
16
|
+
.replace(/<antThinking[^>]*>[\s\S]*?(?:<\/antThinking>|$)/gi, "")
|
|
17
|
+
.replace(/<thinking[^>]*>[\s\S]*?(?:<\/thinking>|$)/gi, "")
|
|
18
|
+
// Strip leading "Thought: ..." reasoning block emitted by DeepSeek-R1, QwQ, etc.
|
|
19
|
+
// These models embed their chain-of-thought in the content field prefixed with "Thought:",
|
|
20
|
+
// separated from the actual response by a blank line (or sometimes just a single newline).
|
|
21
|
+
// The regex matches "Thought:" followed by any content up to:
|
|
22
|
+
// - A blank line (\n\n or \r\n\r\n), OR
|
|
23
|
+
// - A single newline if no blank line exists, OR
|
|
24
|
+
// - End of string if there's no separator at all
|
|
25
|
+
.replace(/^Thought:[\s\S]*?(?:\n\n|\r\n\r\n|\n|$)/, "")
|
|
26
|
+
// Strip "Thought for N seconds" preamble emitted by thinking models (e.g. DeepSeek-R1, QwQ).
|
|
27
|
+
// Handles integers, decimals, and optional trailing whitespace/newlines.
|
|
28
|
+
.replace(/^Thought for [\d.]+ seconds?\s*/i, "")
|
|
29
|
+
.trim();
|
|
30
|
+
}
|
|
31
|
+
function normalizeContent(content) {
|
|
32
|
+
if (typeof content === "string")
|
|
33
|
+
return content;
|
|
34
|
+
if (isRecord(content)) {
|
|
35
|
+
const text = content.text;
|
|
36
|
+
if (typeof text === "string")
|
|
37
|
+
return text;
|
|
38
|
+
const nested = content.content;
|
|
39
|
+
if (typeof nested === "string")
|
|
40
|
+
return nested;
|
|
41
|
+
}
|
|
42
|
+
if (!Array.isArray(content))
|
|
43
|
+
return "";
|
|
44
|
+
const chunks = [];
|
|
45
|
+
for (const part of content) {
|
|
46
|
+
if (typeof part === "string") {
|
|
47
|
+
chunks.push(part);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (!isRecord(part))
|
|
51
|
+
continue;
|
|
52
|
+
const text = part.text;
|
|
53
|
+
if (typeof text === "string") {
|
|
54
|
+
chunks.push(text);
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
const nested = part.content;
|
|
58
|
+
if (typeof nested === "string") {
|
|
59
|
+
chunks.push(nested);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return chunks.join("\n");
|
|
63
|
+
}
|
|
64
|
+
function normalizeToolName(value) {
|
|
65
|
+
if (typeof value !== "string")
|
|
66
|
+
return null;
|
|
67
|
+
let normalized = value.trim().toLowerCase();
|
|
68
|
+
if (!normalized)
|
|
69
|
+
return null;
|
|
70
|
+
if (normalized.startsWith("functions.")) {
|
|
71
|
+
normalized = normalized.slice("functions.".length);
|
|
72
|
+
}
|
|
73
|
+
// Handle common hallucinations and aliases
|
|
74
|
+
const mapping = {
|
|
75
|
+
"write_file": "save_file",
|
|
76
|
+
"savefile": "save_file",
|
|
77
|
+
"create_file": "save_file",
|
|
78
|
+
"overwrite_file": "save_file",
|
|
79
|
+
"readfile": "read_file",
|
|
80
|
+
"list_dir": "list_directory",
|
|
81
|
+
"ls": "list_directory",
|
|
82
|
+
"find_file": "find_files",
|
|
83
|
+
"search_file": "search_file_content",
|
|
84
|
+
"grep": "search_file_content",
|
|
85
|
+
"replace_text": "replace_text_in_file",
|
|
86
|
+
"replace_in_file": "replace_text_in_file"
|
|
87
|
+
};
|
|
88
|
+
return mapping[normalized] || normalized;
|
|
89
|
+
}
|
|
90
|
+
function tryParseJson(text) {
|
|
91
|
+
try {
|
|
92
|
+
return JSON.parse(text);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function normalizeArgs(toolName, args) {
|
|
99
|
+
let normalized = args;
|
|
100
|
+
if (toolName === "save_file" && Array.isArray(normalized)) {
|
|
101
|
+
normalized = { files: normalized };
|
|
102
|
+
}
|
|
103
|
+
if (!isRecord(normalized)) {
|
|
104
|
+
return {};
|
|
105
|
+
}
|
|
106
|
+
const result = { ...normalized };
|
|
107
|
+
// Generic path/data normalization for file tools
|
|
108
|
+
const fileTools = [
|
|
109
|
+
"read_file", "save_file", "replace_text_in_file", "read_file_range",
|
|
110
|
+
"search_file_content", "search_in_file", "insert_at_line",
|
|
111
|
+
"append_file", "delete_lines_in_file"
|
|
112
|
+
];
|
|
113
|
+
if (fileTools.includes(toolName)) {
|
|
114
|
+
if (typeof result.path === "string" && result.file_name === undefined) {
|
|
115
|
+
result.file_name = result.path;
|
|
116
|
+
}
|
|
117
|
+
if (typeof result.file_path === "string" && result.file_name === undefined) {
|
|
118
|
+
result.file_name = result.file_path;
|
|
119
|
+
}
|
|
120
|
+
if (typeof result.name === "string" && result.file_name === undefined) {
|
|
121
|
+
result.file_name = result.name;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
// Generic data/content normalization (separate from fileTools check
|
|
125
|
+
// since save_file/append_file/insert_at_line need both path AND content normalization)
|
|
126
|
+
if (["save_file", "append_file", "insert_at_line"].includes(toolName)) {
|
|
127
|
+
if (typeof result.data === "string" && result.content === undefined) {
|
|
128
|
+
result.content = result.data;
|
|
129
|
+
}
|
|
130
|
+
if (toolName === "insert_at_line" && typeof result.content === "string" && result.content_to_insert === undefined) {
|
|
131
|
+
result.content_to_insert = result.content;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
// Specific tool tweaks
|
|
135
|
+
if (toolName === "fuzzy_find_local_files" && !result.query && result.pattern) {
|
|
136
|
+
result.query = result.pattern;
|
|
137
|
+
}
|
|
138
|
+
if ((toolName === "search_file_content" || toolName === "search_in_file") && !result.pattern && result.query) {
|
|
139
|
+
result.pattern = result.query;
|
|
140
|
+
}
|
|
141
|
+
return result;
|
|
142
|
+
}
|
|
143
|
+
function buildToolCall(toolName, args) {
|
|
144
|
+
const normalizedName = normalizeToolName(toolName);
|
|
145
|
+
if (!normalizedName)
|
|
146
|
+
return null;
|
|
147
|
+
return {
|
|
148
|
+
tool: normalizedName,
|
|
149
|
+
args: normalizeArgs(normalizedName, args),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
function isMatchingBracket(open, close) {
|
|
153
|
+
return (open === "{" && close === "}") || (open === "[" && close === "]");
|
|
154
|
+
}
|
|
155
|
+
function extractBalancedJsonSnippets(text, maxSnippets = 12) {
|
|
156
|
+
const snippets = [];
|
|
157
|
+
let iterations = 0;
|
|
158
|
+
const MAX_ITERATIONS = 1000000;
|
|
159
|
+
for (let start = 0; start < text.length; start++) {
|
|
160
|
+
if (iterations > MAX_ITERATIONS)
|
|
161
|
+
break;
|
|
162
|
+
const opener = text[start];
|
|
163
|
+
if (opener !== "{" && opener !== "[")
|
|
164
|
+
continue;
|
|
165
|
+
const stack = [opener];
|
|
166
|
+
let inString = false;
|
|
167
|
+
let escaped = false;
|
|
168
|
+
let invalid = false;
|
|
169
|
+
// Limit the lookahead to prevent O(N^2) event loop blocking
|
|
170
|
+
const endLimit = Math.min(text.length, start + 100000);
|
|
171
|
+
for (let end = start + 1; end < endLimit; end++) {
|
|
172
|
+
iterations++;
|
|
173
|
+
if (iterations > MAX_ITERATIONS)
|
|
174
|
+
break;
|
|
175
|
+
const char = text[end];
|
|
176
|
+
if (inString) {
|
|
177
|
+
if (escaped) {
|
|
178
|
+
escaped = false;
|
|
179
|
+
}
|
|
180
|
+
else if (char === "\\") {
|
|
181
|
+
escaped = true;
|
|
182
|
+
}
|
|
183
|
+
else if (char === "\"") {
|
|
184
|
+
inString = false;
|
|
185
|
+
}
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
if (char === "\"") {
|
|
189
|
+
inString = true;
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
if (char === "{" || char === "[") {
|
|
193
|
+
stack.push(char);
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (char === "}" || char === "]") {
|
|
197
|
+
const currentOpen = stack.pop();
|
|
198
|
+
if (!currentOpen || !isMatchingBracket(currentOpen, char)) {
|
|
199
|
+
invalid = true;
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
202
|
+
if (stack.length === 0) {
|
|
203
|
+
snippets.push(text.slice(start, end + 1));
|
|
204
|
+
start = end;
|
|
205
|
+
break;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (invalid)
|
|
210
|
+
continue;
|
|
211
|
+
if (snippets.length >= maxSnippets)
|
|
212
|
+
break;
|
|
213
|
+
}
|
|
214
|
+
return snippets;
|
|
215
|
+
}
|
|
216
|
+
function extractJsonCandidates(text) {
|
|
217
|
+
const candidates = [];
|
|
218
|
+
const seen = new Set();
|
|
219
|
+
const pushCandidate = (value) => {
|
|
220
|
+
const trimmed = value.trim();
|
|
221
|
+
if (!trimmed || seen.has(trimmed))
|
|
222
|
+
return;
|
|
223
|
+
seen.add(trimmed);
|
|
224
|
+
candidates.push(trimmed);
|
|
225
|
+
};
|
|
226
|
+
const fencedRegex = /```(?:json|javascript|js|ts|python|py)?\s*([\s\S]*?)```/gi;
|
|
227
|
+
let fenceMatch;
|
|
228
|
+
while ((fenceMatch = fencedRegex.exec(text)) !== null) {
|
|
229
|
+
pushCandidate(fenceMatch[1]);
|
|
230
|
+
}
|
|
231
|
+
const trimmed = text.trim();
|
|
232
|
+
if ((trimmed.startsWith("{") && trimmed.endsWith("}")) || (trimmed.startsWith("[") && trimmed.endsWith("]"))) {
|
|
233
|
+
pushCandidate(trimmed);
|
|
234
|
+
}
|
|
235
|
+
for (const snippet of extractBalancedJsonSnippets(text)) {
|
|
236
|
+
pushCandidate(snippet);
|
|
237
|
+
}
|
|
238
|
+
return candidates;
|
|
239
|
+
}
|
|
240
|
+
function parseToolCallFromToolCalls(toolCalls) {
|
|
241
|
+
if (!Array.isArray(toolCalls))
|
|
242
|
+
return null;
|
|
243
|
+
for (const entry of toolCalls) {
|
|
244
|
+
if (!isRecord(entry))
|
|
245
|
+
continue;
|
|
246
|
+
const fn = entry.function;
|
|
247
|
+
if (isRecord(fn)) {
|
|
248
|
+
let args = fn.arguments;
|
|
249
|
+
if (typeof args === "string") {
|
|
250
|
+
args = tryParseJson(args) ?? {};
|
|
251
|
+
}
|
|
252
|
+
const parsed = buildToolCall(fn.name, args);
|
|
253
|
+
if (parsed)
|
|
254
|
+
return parsed;
|
|
255
|
+
}
|
|
256
|
+
let fallbackArgs = entry.arguments;
|
|
257
|
+
if (typeof fallbackArgs === "string") {
|
|
258
|
+
fallbackArgs = tryParseJson(fallbackArgs) ?? {};
|
|
259
|
+
}
|
|
260
|
+
const fallback = buildToolCall(entry.name, fallbackArgs);
|
|
261
|
+
if (fallback)
|
|
262
|
+
return fallback;
|
|
263
|
+
}
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
function findMatchingBrace(text, openBraceIndex) {
|
|
267
|
+
let depth = 0;
|
|
268
|
+
let inString = false;
|
|
269
|
+
let quote = "\"";
|
|
270
|
+
let escaped = false;
|
|
271
|
+
for (let i = openBraceIndex; i < text.length; i++) {
|
|
272
|
+
const ch = text[i];
|
|
273
|
+
if (inString) {
|
|
274
|
+
if (escaped) {
|
|
275
|
+
escaped = false;
|
|
276
|
+
}
|
|
277
|
+
else if (ch === "\\") {
|
|
278
|
+
escaped = true;
|
|
279
|
+
}
|
|
280
|
+
else if (ch === quote) {
|
|
281
|
+
inString = false;
|
|
282
|
+
}
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
if (ch === "\"" || ch === "'") {
|
|
286
|
+
inString = true;
|
|
287
|
+
quote = ch;
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
if (ch === "{") {
|
|
291
|
+
depth++;
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
if (ch === "}") {
|
|
295
|
+
depth--;
|
|
296
|
+
if (depth === 0)
|
|
297
|
+
return i;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return -1;
|
|
301
|
+
}
|
|
302
|
+
function parseLooseObject(insideBraces) {
|
|
303
|
+
let normalized = insideBraces.trim();
|
|
304
|
+
if (!normalized)
|
|
305
|
+
return {};
|
|
306
|
+
normalized = normalized.replace(/(^|[{,]\s*)([a-zA-Z_][\w-]*)\s*:/g, "$1\"$2\":");
|
|
307
|
+
normalized = normalized.replace(/'([^'\\]*(?:\\.[^'\\]*)*)'/g, (_match, inner) => {
|
|
308
|
+
const escapedInner = inner
|
|
309
|
+
.replace(/\\'/g, "'")
|
|
310
|
+
.replace(/"/g, "\\\"");
|
|
311
|
+
return `"${escapedInner}"`;
|
|
312
|
+
});
|
|
313
|
+
const parsed = tryParseJson(`{${normalized}}`);
|
|
314
|
+
return isRecord(parsed) ? parsed : null;
|
|
315
|
+
}
|
|
316
|
+
function parseToolCallFromLegacySyntax(text) {
|
|
317
|
+
const callRegex = /call\s*:\s*([a-zA-Z0-9_.-]+)\s*\{/gi;
|
|
318
|
+
let match;
|
|
319
|
+
while ((match = callRegex.exec(text)) !== null) {
|
|
320
|
+
const toolName = match[1];
|
|
321
|
+
const openBraceIndex = text.indexOf("{", match.index + match[0].length - 1);
|
|
322
|
+
if (openBraceIndex < 0)
|
|
323
|
+
continue;
|
|
324
|
+
const closeBraceIndex = findMatchingBrace(text, openBraceIndex);
|
|
325
|
+
if (closeBraceIndex < 0)
|
|
326
|
+
continue;
|
|
327
|
+
const body = text.slice(openBraceIndex + 1, closeBraceIndex);
|
|
328
|
+
const strictParsed = tryParseJson(`{${body}}`);
|
|
329
|
+
const parsedArgs = isRecord(strictParsed) ? strictParsed : parseLooseObject(body);
|
|
330
|
+
if (!parsedArgs)
|
|
331
|
+
continue;
|
|
332
|
+
const parsed = buildToolCall(toolName, parsedArgs);
|
|
333
|
+
if (parsed)
|
|
334
|
+
return parsed;
|
|
335
|
+
}
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
338
|
+
function parseToolCallObject(candidate, originalText) {
|
|
339
|
+
if (Array.isArray(candidate)) {
|
|
340
|
+
if (candidate.length > 0 && candidate.every(item => isRecord(item))) {
|
|
341
|
+
const asBatchSave = buildToolCall("save_file", candidate);
|
|
342
|
+
if (asBatchSave)
|
|
343
|
+
return asBatchSave;
|
|
344
|
+
}
|
|
345
|
+
for (const item of candidate) {
|
|
346
|
+
const parsed = parseToolCallObject(item, originalText);
|
|
347
|
+
if (parsed)
|
|
348
|
+
return parsed;
|
|
349
|
+
}
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
352
|
+
if (!isRecord(candidate))
|
|
353
|
+
return null;
|
|
354
|
+
const nestedToolCalls = parseToolCallFromToolCalls(candidate.tool_calls);
|
|
355
|
+
if (nestedToolCalls)
|
|
356
|
+
return nestedToolCalls;
|
|
357
|
+
if ("tool" in candidate && "args" in candidate) {
|
|
358
|
+
const parsed = buildToolCall(candidate.tool, candidate.args);
|
|
359
|
+
if (parsed)
|
|
360
|
+
return parsed;
|
|
361
|
+
}
|
|
362
|
+
if ("tool" in candidate && "parameters" in candidate) {
|
|
363
|
+
const parsed = buildToolCall(candidate.tool, candidate.parameters);
|
|
364
|
+
if (parsed)
|
|
365
|
+
return parsed;
|
|
366
|
+
}
|
|
367
|
+
// Support for { "function": "...", "parameters": {...} } format used by some Anthropic-compatible APIs and fine-tuned models
|
|
368
|
+
if ("function" in candidate && "parameters" in candidate) {
|
|
369
|
+
let args = candidate.parameters;
|
|
370
|
+
if (typeof args === "string") {
|
|
371
|
+
args = tryParseJson(args) ?? {};
|
|
372
|
+
}
|
|
373
|
+
const parsed = buildToolCall(candidate.function, args);
|
|
374
|
+
if (parsed)
|
|
375
|
+
return parsed;
|
|
376
|
+
}
|
|
377
|
+
// Support for { "function": "...", "arguments": {...} } format variant
|
|
378
|
+
if ("function" in candidate && "arguments" in candidate) {
|
|
379
|
+
let args = candidate.arguments;
|
|
380
|
+
if (typeof args === "string") {
|
|
381
|
+
args = tryParseJson(args) ?? {};
|
|
382
|
+
}
|
|
383
|
+
const parsed = buildToolCall(candidate.function, args);
|
|
384
|
+
if (parsed)
|
|
385
|
+
return parsed;
|
|
386
|
+
}
|
|
387
|
+
if ("name" in candidate && "arguments" in candidate) {
|
|
388
|
+
let args = candidate.arguments;
|
|
389
|
+
if (typeof args === "string") {
|
|
390
|
+
args = tryParseJson(args) ?? {};
|
|
391
|
+
}
|
|
392
|
+
const parsed = buildToolCall(candidate.name, args);
|
|
393
|
+
if (parsed)
|
|
394
|
+
return parsed;
|
|
395
|
+
}
|
|
396
|
+
if ((typeof candidate.file_name === "string" && typeof candidate.content === "string") ||
|
|
397
|
+
(typeof candidate.path === "string" && typeof candidate.data === "string")) {
|
|
398
|
+
const parsed = buildToolCall("save_file", candidate);
|
|
399
|
+
if (parsed)
|
|
400
|
+
return parsed;
|
|
401
|
+
}
|
|
402
|
+
const toToolNameMatch = originalText.match(/to=([a-zA-Z0-9_.-]+)/);
|
|
403
|
+
if (toToolNameMatch) {
|
|
404
|
+
const parsed = buildToolCall(toToolNameMatch[1], candidate);
|
|
405
|
+
if (parsed)
|
|
406
|
+
return parsed;
|
|
407
|
+
}
|
|
408
|
+
return null;
|
|
409
|
+
}
|
|
410
|
+
function parseToolCallFromContent(text) {
|
|
411
|
+
if (!text.trim())
|
|
412
|
+
return null;
|
|
413
|
+
const legacyCall = parseToolCallFromLegacySyntax(text);
|
|
414
|
+
if (legacyCall)
|
|
415
|
+
return legacyCall;
|
|
416
|
+
for (const candidateText of extractJsonCandidates(text)) {
|
|
417
|
+
const parsedJson = tryParseJson(candidateText);
|
|
418
|
+
if (parsedJson === undefined)
|
|
419
|
+
continue;
|
|
420
|
+
const parsedTool = parseToolCallObject(parsedJson, text);
|
|
421
|
+
if (parsedTool)
|
|
422
|
+
return parsedTool;
|
|
423
|
+
}
|
|
424
|
+
// --- Fix: Fallback for mixed prose+JSON tool calls ---
|
|
425
|
+
// When the model outputs something like:
|
|
426
|
+
// "Now I have data... {"tool": "save_file", "args": {...}}"
|
|
427
|
+
// The balanced JSON extractor above may fail because the whole text isn't valid JSON.
|
|
428
|
+
// This fallback specifically looks for tool call patterns embedded within prose text.
|
|
429
|
+
const toolMatch = text.match(/\{"[\s]*tool[\s]*"[\s]*:[\s]*"([a-zA-Z0-9_.-]+)"/);
|
|
430
|
+
if (toolMatch) {
|
|
431
|
+
// Find the matching closing brace by looking for args object
|
|
432
|
+
const argsStartIdx = text.indexOf('{', toolMatch.index || 0);
|
|
433
|
+
if (argsStartIdx >= 0) {
|
|
434
|
+
const closeBraceIdx = findMatchingBrace(text, argsStartIdx);
|
|
435
|
+
if (closeBraceIdx > 0) {
|
|
436
|
+
const jsonSnippet = text.slice(argsStartIdx, closeBraceIdx + 1);
|
|
437
|
+
const parsedJson = tryParseJson(jsonSnippet);
|
|
438
|
+
if (parsedJson && isRecord(parsedJson)) {
|
|
439
|
+
const parsedTool = parseToolCallObject(parsedJson, text);
|
|
440
|
+
if (parsedTool)
|
|
441
|
+
return parsedTool;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
return null;
|
|
447
|
+
}
|
|
448
|
+
export function parseSubAgentResponseMessage(message) {
|
|
449
|
+
if (!isRecord(message)) {
|
|
450
|
+
const content = sanitizeContent(typeof message === "string" ? message : "");
|
|
451
|
+
const contentToolCall = parseToolCallFromContent(content);
|
|
452
|
+
return {
|
|
453
|
+
content,
|
|
454
|
+
toolCall: contentToolCall,
|
|
455
|
+
toolCallSource: contentToolCall ? "content" : "none",
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
const content = sanitizeContent(normalizeContent(message.content));
|
|
459
|
+
const reasoningContent = sanitizeContent(normalizeContent(message.reasoning_content ?? message.reasoning ?? message.thinking ?? ""));
|
|
460
|
+
const structuredToolCall = parseToolCallFromToolCalls(message.tool_calls) ||
|
|
461
|
+
parseToolCallFromToolCalls(message.calls) ||
|
|
462
|
+
parseToolCallFromToolCalls(message.function_call ? [message.function_call] : undefined);
|
|
463
|
+
if (structuredToolCall) {
|
|
464
|
+
return {
|
|
465
|
+
content,
|
|
466
|
+
toolCall: structuredToolCall,
|
|
467
|
+
toolCallSource: "tool_calls",
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
const contentToolCall = parseToolCallFromContent(content);
|
|
471
|
+
if (contentToolCall) {
|
|
472
|
+
return {
|
|
473
|
+
content,
|
|
474
|
+
toolCall: contentToolCall,
|
|
475
|
+
toolCallSource: "content",
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
const reasoningToolCall = parseToolCallFromContent(reasoningContent);
|
|
479
|
+
if (reasoningToolCall) {
|
|
480
|
+
return {
|
|
481
|
+
content,
|
|
482
|
+
toolCall: reasoningToolCall,
|
|
483
|
+
toolCallSource: "reasoning",
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
return {
|
|
487
|
+
content,
|
|
488
|
+
toolCall: null,
|
|
489
|
+
toolCallSource: "none",
|
|
490
|
+
};
|
|
491
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// toolCallValidator.ts — ported verbatim from the Beledarian reference
|
|
2
|
+
// (beledarians-lm-studio-tools src/toolCallValidator.ts).
|
|
3
|
+
import { isAbsolute } from "path";
|
|
4
|
+
/**
|
|
5
|
+
* Validates sub-agent tool call parameters before execution.
|
|
6
|
+
* Returns null if valid, or an error message string if invalid.
|
|
7
|
+
*/
|
|
8
|
+
export function validateToolCall(toolName, args = {}) {
|
|
9
|
+
let toolValidationError = null;
|
|
10
|
+
// finish_task is always allowed - it's the termination signal, not an executable tool
|
|
11
|
+
if (toolName === "finish_task") {
|
|
12
|
+
return null; // Always valid, no parameters required beyond optional message/status
|
|
13
|
+
}
|
|
14
|
+
if (toolName === "save_file") {
|
|
15
|
+
if (Array.isArray(args.files)) {
|
|
16
|
+
for (const file of args.files) {
|
|
17
|
+
const fileName = file.file_name || file.name || file.path;
|
|
18
|
+
const fileContent = file.content || file.data;
|
|
19
|
+
if (!fileName && !fileContent) {
|
|
20
|
+
return `Tool 'save_file' batch mode requires [file_name, content] in all objects.`;
|
|
21
|
+
}
|
|
22
|
+
else if (!fileName) {
|
|
23
|
+
return `Tool 'save_file' batch missing 'file_name'. Hint: Use 'file_name'.`;
|
|
24
|
+
}
|
|
25
|
+
else if (!fileContent) {
|
|
26
|
+
return `Tool 'save_file' batch missing 'content'. Hint: Use 'content'.`;
|
|
27
|
+
}
|
|
28
|
+
else if (isAbsolute(fileName)) {
|
|
29
|
+
return `Tool 'save_file' batch rejected absolute path: '${fileName}'. SECURITY: Files can only be saved within workspace.`;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
const fileName = args.file_name || args.name || args.path;
|
|
35
|
+
const fileContent = args.content || args.data;
|
|
36
|
+
if (!fileName && fileContent === undefined) {
|
|
37
|
+
toolValidationError = `Tool 'save_file' requires parameters: [file_name, content]. Provided keys: ${Object.keys(args).join(", ") || "none"}.`;
|
|
38
|
+
}
|
|
39
|
+
else if (!fileName) {
|
|
40
|
+
toolValidationError = `Tool 'save_file' missing required parameter: 'file_name'.`;
|
|
41
|
+
}
|
|
42
|
+
else if (fileContent === undefined || fileContent === null) {
|
|
43
|
+
toolValidationError = `Tool 'save_file' missing required parameter: 'content'.`;
|
|
44
|
+
}
|
|
45
|
+
else if (isAbsolute(fileName)) {
|
|
46
|
+
toolValidationError = `Tool 'save_file' rejected absolute path: '${fileName}'. SECURITY: Files can only be saved within workspace. Use relative path like 'test.html'.`;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (toolName === "read_file") {
|
|
50
|
+
// Accept file_name, path, or name for consistency with parser normalization
|
|
51
|
+
const fileName = args.file_name || args.path || args.name;
|
|
52
|
+
if (!fileName) {
|
|
53
|
+
toolValidationError = `Tool 'read_file' requires parameter: [file_name]. Provided keys: ${Object.keys(args).join(", ") || "none"}. Hint: Use 'file_name' (not 'path', 'filepath', or 'file_path').`;
|
|
54
|
+
}
|
|
55
|
+
else if (isAbsolute(fileName)) {
|
|
56
|
+
toolValidationError = `Tool 'read_file' rejected absolute path. SECURITY: Only workspace paths allowed.`;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (toolName === "replace_text_in_file") {
|
|
60
|
+
const fileName = args.file_name || args.path || args.name;
|
|
61
|
+
const missing = [];
|
|
62
|
+
if (!fileName)
|
|
63
|
+
missing.push("file_name");
|
|
64
|
+
if (!args.old_string)
|
|
65
|
+
missing.push("old_string");
|
|
66
|
+
if (!args.new_string)
|
|
67
|
+
missing.push("new_string");
|
|
68
|
+
if (missing.length > 0) {
|
|
69
|
+
toolValidationError = `Tool 'replace_text_in_file' missing parameters: [${missing.join(", ")}]. Provided keys: ${Object.keys(args).join(", ") || "none"}.`;
|
|
70
|
+
}
|
|
71
|
+
else if (isAbsolute(fileName)) {
|
|
72
|
+
toolValidationError = `Tool 'replace_text_in_file' rejected absolute path. SECURITY: Only workspace paths allowed.`;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (toolName === "multi_replace_text") {
|
|
76
|
+
const fileName = args.file_name || args.path || args.name;
|
|
77
|
+
if (!fileName) {
|
|
78
|
+
toolValidationError = `Tool 'multi_replace_text' missing parameter: 'file_name'.`;
|
|
79
|
+
}
|
|
80
|
+
else if (isAbsolute(fileName)) {
|
|
81
|
+
toolValidationError = `Tool 'multi_replace_text' rejected absolute path. SECURITY: Files can only be saved within workspace.`;
|
|
82
|
+
}
|
|
83
|
+
else if (!Array.isArray(args.replacements) || args.replacements.length === 0) {
|
|
84
|
+
toolValidationError = `Tool 'multi_replace_text' requires an array 'replacements' with at least one replacement object.`;
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
for (let i = 0; i < args.replacements.length; i++) {
|
|
88
|
+
const rep = args.replacements[i];
|
|
89
|
+
if (!rep.start_line || !rep.end_line || !rep.old_string || typeof rep.new_string !== "string") {
|
|
90
|
+
toolValidationError = `Tool 'multi_replace_text' replacements[${i}] missing required fields (start_line, end_line, old_string, new_string).`;
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return toolValidationError;
|
|
97
|
+
}
|