@ynhcj/xiaoyi-channel 0.0.207-beta → 0.0.209-beta
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/dist/index.d.ts +3 -8
- package/dist/index.js +5 -1
- package/dist/src/bot.js +16 -77
- package/dist/src/cspl/config.js +3 -0
- package/dist/src/reply-dispatcher.js +4 -3
- package/dist/src/skill-retriever/config.js +2 -0
- package/dist/src/skill-retriever/hooks.js +1 -0
- package/dist/src/skill-retriever/tool-search.d.ts +3 -0
- package/dist/src/skill-retriever/tool-search.js +27 -11
- package/dist/src/skill-retriever/types.d.ts +1 -0
- package/dist/src/tools/device-tool-map.js +0 -6
- package/dist/src/tools/hmos-cli.d.ts +103 -0
- package/dist/src/tools/hmos-cli.js +561 -0
- package/dist/src/tools/invoke.d.ts +48 -0
- package/dist/src/tools/invoke.js +1201 -0
- package/dist/src/websocket.js +10 -0
- package/package.json +1 -1
|
@@ -0,0 +1,1201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* invoke meta-tool — single-file implementation.
|
|
3
|
+
*
|
|
4
|
+
* The `invoke` tool is the only tool the LLM sees for calling skill-defined
|
|
5
|
+
* tools. It locates the real tool definition via (bundleName, toolName),
|
|
6
|
+
* dispatches to Cloud/MCP (PluginExecutor) or Device (A2A command), and
|
|
7
|
+
* returns the result.
|
|
8
|
+
*
|
|
9
|
+
* Protocol: invoke.md
|
|
10
|
+
*
|
|
11
|
+
* Sections:
|
|
12
|
+
* 1. Types
|
|
13
|
+
* 2. Errors
|
|
14
|
+
* 3. SKILL.md parser
|
|
15
|
+
* 4. Tool cache
|
|
16
|
+
* 5. Template renderer
|
|
17
|
+
* 6. Cloud executor (REST / SSE / Websocket)
|
|
18
|
+
* 7. Device executor
|
|
19
|
+
* 8. Invoke tool factory (public API)
|
|
20
|
+
*/
|
|
21
|
+
import fs from "fs";
|
|
22
|
+
import path from "path";
|
|
23
|
+
import os from "os";
|
|
24
|
+
import { v4 as uuidv4 } from "uuid";
|
|
25
|
+
import WebSocket from "ws";
|
|
26
|
+
import { logger } from "../utils/logger.js";
|
|
27
|
+
import { getXYWebSocketManager } from "../client.js";
|
|
28
|
+
import { sendCommand } from "../formatter.js";
|
|
29
|
+
import { getCurrentTaskId } from "../task-manager.js";
|
|
30
|
+
const RETRYABLE_CODES = new Set([
|
|
31
|
+
"RATE_LIMIT",
|
|
32
|
+
"TIMEOUT",
|
|
33
|
+
"NETWORK_ERROR",
|
|
34
|
+
]);
|
|
35
|
+
const CLIENT_ERROR_CODES = new Set([
|
|
36
|
+
"INVALID_PARAM",
|
|
37
|
+
"INVALID_TOOL_DEFINITION",
|
|
38
|
+
"CONFIG_MISSING",
|
|
39
|
+
"AUTH_FAIL",
|
|
40
|
+
"PERMISSION_DENIED",
|
|
41
|
+
"CLI_COMMAND_BLOCKED",
|
|
42
|
+
"TOOL_NOT_FOUND",
|
|
43
|
+
"TOOL_CONFLICT",
|
|
44
|
+
"UNSUPPORTED_PLUGIN_TYPE",
|
|
45
|
+
"UNSUPPORTED_PROTOCOL",
|
|
46
|
+
"DEVICE_TOOL_BLOCKED",
|
|
47
|
+
"UNKNOWN",
|
|
48
|
+
]);
|
|
49
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
50
|
+
// 2. Errors
|
|
51
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
52
|
+
export class InvokeError extends Error {
|
|
53
|
+
name = "InvokeError";
|
|
54
|
+
code;
|
|
55
|
+
retryable;
|
|
56
|
+
details;
|
|
57
|
+
constructor(code, message, details) {
|
|
58
|
+
super(message);
|
|
59
|
+
this.code = code;
|
|
60
|
+
this.retryable = RETRYABLE_CODES.has(code);
|
|
61
|
+
this.details = details;
|
|
62
|
+
}
|
|
63
|
+
get status() {
|
|
64
|
+
return CLIENT_ERROR_CODES.has(this.code) ? 400 : 500;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
export function invokeErrorToResult(error) {
|
|
68
|
+
const body = {
|
|
69
|
+
code: error.code,
|
|
70
|
+
message: error.message,
|
|
71
|
+
retryable: error.retryable,
|
|
72
|
+
};
|
|
73
|
+
if (error.details && Object.keys(error.details).length > 0) {
|
|
74
|
+
body.details = error.details;
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
content: [{ type: "text", text: JSON.stringify(body) }],
|
|
78
|
+
details: null,
|
|
79
|
+
isError: true,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
function unknownErrorToResult(_err) {
|
|
83
|
+
return invokeErrorToResult(new InvokeError("UNKNOWN", "An unexpected error occurred while executing the tool."));
|
|
84
|
+
}
|
|
85
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
86
|
+
// 3. SKILL.md frontmatter parser
|
|
87
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
88
|
+
function parseSkillName(skillDirPath) {
|
|
89
|
+
const skillMdPath = path.join(skillDirPath, "SKILL.md");
|
|
90
|
+
let content;
|
|
91
|
+
try {
|
|
92
|
+
content = fs.readFileSync(skillMdPath, "utf-8");
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
return extractNameFromFrontmatter(content);
|
|
98
|
+
}
|
|
99
|
+
function extractNameFromFrontmatter(content) {
|
|
100
|
+
if (content.charCodeAt(0) === 0xfeff)
|
|
101
|
+
content = content.slice(1);
|
|
102
|
+
const lines = content.split(/\r?\n/);
|
|
103
|
+
if (lines.length === 0 || lines[0].trim() !== "---")
|
|
104
|
+
return null;
|
|
105
|
+
for (let i = 1; i < lines.length; i++) {
|
|
106
|
+
const line = lines[i];
|
|
107
|
+
if (line.trim() === "---")
|
|
108
|
+
break;
|
|
109
|
+
const match = line.match(/^name:\s*(.+)$/);
|
|
110
|
+
if (match) {
|
|
111
|
+
let value = match[1].trim();
|
|
112
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
113
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
114
|
+
value = value.slice(1, -1);
|
|
115
|
+
}
|
|
116
|
+
return value.length > 0 ? value : null;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
122
|
+
// 4. Tool cache (globalThis singleton)
|
|
123
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
124
|
+
const DEFAULT_SKILL_ROOT = path.join(os.homedir(), ".openclaw", "workspace", "skills");
|
|
125
|
+
const REFRESH_INTERVAL_MS = 30_000;
|
|
126
|
+
const REQUIRED_FIELDS = ["schemaVersion", "bundleName", "toolName", "pluginType", "description", "arguments"];
|
|
127
|
+
const CORE_FIELDS = ["bundleName", "toolName", "toolType", "pluginType", "protocol", "description", "arguments", "deviceCommand"];
|
|
128
|
+
const VALID_PLUGIN_TYPES = new Set(["Cloud", "Device", "MCP"]);
|
|
129
|
+
const VALID_PROTOCOLS = new Set(["REST", "SSE", "Websocket"]);
|
|
130
|
+
const FILENAME_PATTERN = /^(.+)__(.+)\.json$/;
|
|
131
|
+
const _g = globalThis;
|
|
132
|
+
const CACHE_SLOT = "__xyInvokeToolCache";
|
|
133
|
+
function buildKey(bundleName, toolName) {
|
|
134
|
+
return `${bundleName}__${toolName}`;
|
|
135
|
+
}
|
|
136
|
+
function validateToolDefinition(raw, filePath) {
|
|
137
|
+
const errs = [];
|
|
138
|
+
for (const field of REQUIRED_FIELDS) {
|
|
139
|
+
if (!(field in raw) || raw[field] === null || raw[field] === undefined) {
|
|
140
|
+
errs.push(`missing required field: ${field}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (typeof raw.schemaVersion !== "string")
|
|
144
|
+
errs.push("schemaVersion must be a string");
|
|
145
|
+
if (typeof raw.bundleName !== "string" || raw.bundleName.length === 0)
|
|
146
|
+
errs.push("bundleName must be a non-empty string");
|
|
147
|
+
if (typeof raw.toolName !== "string" || raw.toolName.length === 0)
|
|
148
|
+
errs.push("toolName must be a non-empty string");
|
|
149
|
+
if (typeof raw.pluginType === "string" && !VALID_PLUGIN_TYPES.has(raw.pluginType))
|
|
150
|
+
errs.push(`invalid pluginType: ${raw.pluginType}`);
|
|
151
|
+
const pluginType = raw.pluginType;
|
|
152
|
+
if (pluginType === "Cloud" || pluginType === "MCP") {
|
|
153
|
+
if (!raw.protocol || typeof raw.protocol !== "string")
|
|
154
|
+
errs.push("protocol is required when pluginType is Cloud or MCP");
|
|
155
|
+
else if (!VALID_PROTOCOLS.has(raw.protocol))
|
|
156
|
+
errs.push(`invalid protocol: ${raw.protocol}`);
|
|
157
|
+
}
|
|
158
|
+
if (pluginType === "Device") {
|
|
159
|
+
if (!raw.deviceCommand || typeof raw.deviceCommand !== "object")
|
|
160
|
+
errs.push("deviceCommand is required when pluginType is Device");
|
|
161
|
+
}
|
|
162
|
+
if (raw.arguments && typeof raw.arguments === "object") {
|
|
163
|
+
const args = raw.arguments;
|
|
164
|
+
if (args.type !== "object")
|
|
165
|
+
errs.push("arguments.type must be 'object'");
|
|
166
|
+
if (!args.properties || typeof args.properties !== "object")
|
|
167
|
+
errs.push("arguments.properties must be an object");
|
|
168
|
+
if (!Array.isArray(args.required))
|
|
169
|
+
errs.push("arguments.required must be an array");
|
|
170
|
+
if (args.properties && typeof args.properties === "object" && "bundleName" in args.properties) {
|
|
171
|
+
errs.push("arguments.properties must not declare 'bundleName' — it is a reserved invoke routing field");
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (pluginType === "Device" && raw.deviceCommand && typeof raw.deviceCommand === "object") {
|
|
175
|
+
const dc = raw.deviceCommand;
|
|
176
|
+
const tpl = dc.template;
|
|
177
|
+
const pl = tpl?.payload;
|
|
178
|
+
const ep = pl?.executeParam;
|
|
179
|
+
if (typeof ep?.bundleName === "string" && ep.bundleName !== raw.bundleName) {
|
|
180
|
+
errs.push(`bundleName mismatch: top-level '${raw.bundleName}' != deviceCommand '${ep.bundleName}'`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (typeof raw.description !== "string" || raw.description.length === 0)
|
|
184
|
+
errs.push("description must be a non-empty string");
|
|
185
|
+
return errs.length > 0 ? { filePath, errors: errs } : null;
|
|
186
|
+
}
|
|
187
|
+
function parseFilename(fileName) {
|
|
188
|
+
const match = fileName.match(FILENAME_PATTERN);
|
|
189
|
+
return match ? { bundleName: match[1], toolName: match[2] } : null;
|
|
190
|
+
}
|
|
191
|
+
// -- conflict detection --------------------------------------------------
|
|
192
|
+
function extractCoreFields(def) {
|
|
193
|
+
const core = {};
|
|
194
|
+
const defRec = def;
|
|
195
|
+
for (const field of CORE_FIELDS) {
|
|
196
|
+
if (field in defRec)
|
|
197
|
+
core[field] = defRec[field];
|
|
198
|
+
}
|
|
199
|
+
return core;
|
|
200
|
+
}
|
|
201
|
+
function coreFieldsEqual(a, b) {
|
|
202
|
+
return JSON.stringify(extractCoreFields(a)) === JSON.stringify(extractCoreFields(b));
|
|
203
|
+
}
|
|
204
|
+
// -- scan ----------------------------------------------------------------
|
|
205
|
+
function scanSkillDirectories(rootDir) {
|
|
206
|
+
const entries = new Map();
|
|
207
|
+
const conflicts = new Map();
|
|
208
|
+
if (!fs.existsSync(rootDir)) {
|
|
209
|
+
logger.log(`[INVOKE-CACHE] Skills root not found: ${rootDir}`);
|
|
210
|
+
return { entries, conflicts };
|
|
211
|
+
}
|
|
212
|
+
let skillDirs;
|
|
213
|
+
try {
|
|
214
|
+
skillDirs = fs.readdirSync(rootDir, { withFileTypes: true });
|
|
215
|
+
}
|
|
216
|
+
catch (err) {
|
|
217
|
+
logger.error(`[INVOKE-CACHE] Failed to read: ${rootDir}`, err);
|
|
218
|
+
return { entries, conflicts };
|
|
219
|
+
}
|
|
220
|
+
for (const dirent of skillDirs) {
|
|
221
|
+
if (!dirent.isDirectory() || dirent.name.startsWith("."))
|
|
222
|
+
continue;
|
|
223
|
+
const skillDir = path.join(rootDir, dirent.name);
|
|
224
|
+
const skillName = parseSkillName(skillDir);
|
|
225
|
+
if (!skillName)
|
|
226
|
+
continue;
|
|
227
|
+
const toolsDir = path.join(skillDir, "references", "tools");
|
|
228
|
+
if (!fs.existsSync(toolsDir))
|
|
229
|
+
continue;
|
|
230
|
+
let toolFiles;
|
|
231
|
+
try {
|
|
232
|
+
toolFiles = fs.readdirSync(toolsDir, { withFileTypes: true });
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
for (const toolFile of toolFiles) {
|
|
238
|
+
if (!toolFile.isFile() || !toolFile.name.endsWith(".json"))
|
|
239
|
+
continue;
|
|
240
|
+
const filePath = path.join(toolsDir, toolFile.name);
|
|
241
|
+
const parsed = parseFilename(toolFile.name);
|
|
242
|
+
if (!parsed)
|
|
243
|
+
continue;
|
|
244
|
+
let raw;
|
|
245
|
+
try {
|
|
246
|
+
raw = JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
247
|
+
}
|
|
248
|
+
catch (err) {
|
|
249
|
+
logger.error(`[INVOKE-CACHE] Failed to parse: ${filePath}`, err);
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
if (!raw || typeof raw !== "object")
|
|
253
|
+
continue;
|
|
254
|
+
const verr = validateToolDefinition(raw, filePath);
|
|
255
|
+
if (verr) {
|
|
256
|
+
logger.error(`[INVOKE-CACHE] INVALID_TOOL_DEFINITION: ${filePath}\n ${verr.errors.join("\n ")}`);
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
const definition = raw;
|
|
260
|
+
if (definition.bundleName !== parsed.bundleName || definition.toolName !== parsed.toolName) {
|
|
261
|
+
logger.error(`[INVOKE-CACHE] Filename mismatch: ${toolFile.name}`);
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
let mtimeMs = 0;
|
|
265
|
+
try {
|
|
266
|
+
mtimeMs = fs.statSync(filePath).mtimeMs;
|
|
267
|
+
}
|
|
268
|
+
catch { /* ok */ }
|
|
269
|
+
const entry = { definition, skillName, filePath, mtimeMs };
|
|
270
|
+
const key = buildKey(definition.bundleName, definition.toolName);
|
|
271
|
+
const existing = entries.get(key);
|
|
272
|
+
if (existing) {
|
|
273
|
+
if (!coreFieldsEqual(existing.definition, definition)) {
|
|
274
|
+
let conflict = conflicts.get(key);
|
|
275
|
+
if (!conflict) {
|
|
276
|
+
conflict = { key: { bundleName: definition.bundleName, toolName: definition.toolName }, entries: [existing] };
|
|
277
|
+
}
|
|
278
|
+
conflict.entries.push(entry);
|
|
279
|
+
conflicts.set(key, conflict);
|
|
280
|
+
entries.delete(key);
|
|
281
|
+
logger.log(`[INVOKE-CACHE] TOOL_CONFLICT: ${key} (${conflict.entries.length} defs)`);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
else {
|
|
285
|
+
entries.set(key, entry);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return { entries, conflicts };
|
|
290
|
+
}
|
|
291
|
+
// -- cache singleton -----------------------------------------------------
|
|
292
|
+
function createCacheState(rootDir) {
|
|
293
|
+
const { entries, conflicts } = scanSkillDirectories(rootDir);
|
|
294
|
+
let rootMtimeMs = 0;
|
|
295
|
+
try {
|
|
296
|
+
rootMtimeMs = fs.statSync(rootDir).mtimeMs;
|
|
297
|
+
}
|
|
298
|
+
catch { /* ok */ }
|
|
299
|
+
return { entries, conflicts, lastScanMs: Date.now(), rootDir, rootMtimeMs };
|
|
300
|
+
}
|
|
301
|
+
function maybeRefresh(state) {
|
|
302
|
+
const now = Date.now();
|
|
303
|
+
if (now - state.lastScanMs < REFRESH_INTERVAL_MS)
|
|
304
|
+
return;
|
|
305
|
+
let currentMtime = 0;
|
|
306
|
+
try {
|
|
307
|
+
currentMtime = fs.statSync(state.rootDir).mtimeMs;
|
|
308
|
+
}
|
|
309
|
+
catch {
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
if (currentMtime !== state.rootMtimeMs) {
|
|
313
|
+
logger.log("[INVOKE-CACHE] mtime changed, refreshing...");
|
|
314
|
+
const { entries, conflicts } = scanSkillDirectories(state.rootDir);
|
|
315
|
+
state.entries = entries;
|
|
316
|
+
state.conflicts = conflicts;
|
|
317
|
+
state.rootMtimeMs = currentMtime;
|
|
318
|
+
}
|
|
319
|
+
state.lastScanMs = now;
|
|
320
|
+
}
|
|
321
|
+
function wrapState(state) {
|
|
322
|
+
return {
|
|
323
|
+
get(b, t) { maybeRefresh(state); return state.entries.get(buildKey(b, t)) ?? null; },
|
|
324
|
+
getConflict(b, t) { return state.conflicts.get(buildKey(b, t)) ?? null; },
|
|
325
|
+
getAll() { maybeRefresh(state); return Array.from(state.entries.values()); },
|
|
326
|
+
getConflicts() { return Array.from(state.conflicts.values()); },
|
|
327
|
+
async refresh() {
|
|
328
|
+
const { entries, conflicts } = scanSkillDirectories(state.rootDir);
|
|
329
|
+
state.entries = entries;
|
|
330
|
+
state.conflicts = conflicts;
|
|
331
|
+
state.lastScanMs = Date.now();
|
|
332
|
+
try {
|
|
333
|
+
state.rootMtimeMs = fs.statSync(state.rootDir).mtimeMs;
|
|
334
|
+
}
|
|
335
|
+
catch {
|
|
336
|
+
state.rootMtimeMs = 0;
|
|
337
|
+
}
|
|
338
|
+
logger.log(`[INVOKE-CACHE] Refreshed: ${state.entries.size} tools, ${state.conflicts.size} conflicts`);
|
|
339
|
+
},
|
|
340
|
+
getRootDir() { return state.rootDir; },
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
function getToolCache(rootDir) {
|
|
344
|
+
const dir = rootDir ?? DEFAULT_SKILL_ROOT;
|
|
345
|
+
const existing = _g[CACHE_SLOT];
|
|
346
|
+
if (existing && existing.rootDir === dir)
|
|
347
|
+
return wrapState(existing);
|
|
348
|
+
const state = createCacheState(dir);
|
|
349
|
+
_g[CACHE_SLOT] = state;
|
|
350
|
+
logger.log(`[INVOKE-CACHE] Init: ${state.entries.size} tools, ${state.conflicts.size} conflicts from ${dir}`);
|
|
351
|
+
return wrapState(state);
|
|
352
|
+
}
|
|
353
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
354
|
+
// 5. Template renderer (#{arguments.xxx} placeholders)
|
|
355
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
356
|
+
const PLACEHOLDER_RE = /#\{arguments\.([a-zA-Z0-9_]+)\}/g;
|
|
357
|
+
function renderDeviceCommand(template, businessParams, argumentsSchema) {
|
|
358
|
+
const cloned = deepClone(template);
|
|
359
|
+
walkAndReplace(cloned, businessParams, new Set(argumentsSchema.required ?? []), []);
|
|
360
|
+
return cloned;
|
|
361
|
+
}
|
|
362
|
+
function deepClone(value) {
|
|
363
|
+
if (value === null || value === undefined)
|
|
364
|
+
return value;
|
|
365
|
+
if (typeof value !== "object")
|
|
366
|
+
return value;
|
|
367
|
+
if (Array.isArray(value))
|
|
368
|
+
return value.map((item) => deepClone(item));
|
|
369
|
+
const result = {};
|
|
370
|
+
for (const key of Object.keys(value)) {
|
|
371
|
+
result[key] = deepClone(value[key]);
|
|
372
|
+
}
|
|
373
|
+
return result;
|
|
374
|
+
}
|
|
375
|
+
function walkAndReplace(node, params, requiredSet, pathStack) {
|
|
376
|
+
if (node === null || node === undefined)
|
|
377
|
+
return null;
|
|
378
|
+
if (typeof node === "string")
|
|
379
|
+
return replaceInString(node, params, requiredSet, pathStack);
|
|
380
|
+
if (Array.isArray(node)) {
|
|
381
|
+
for (let i = 0; i < node.length; i++)
|
|
382
|
+
walkAndReplace(node[i], params, requiredSet, [...pathStack, String(i)]);
|
|
383
|
+
return null;
|
|
384
|
+
}
|
|
385
|
+
if (typeof node === "object") {
|
|
386
|
+
const obj = node;
|
|
387
|
+
const keysToDelete = [];
|
|
388
|
+
for (const key of Object.keys(obj)) {
|
|
389
|
+
const value = obj[key];
|
|
390
|
+
if (typeof value === "string") {
|
|
391
|
+
const result = replaceInString(value, params, requiredSet, [...pathStack, key]);
|
|
392
|
+
if (result === null)
|
|
393
|
+
keysToDelete.push(key);
|
|
394
|
+
else if (result.size === 0 && hasPlaceholders(value))
|
|
395
|
+
obj[key] = replacePlaceholders(value, params);
|
|
396
|
+
}
|
|
397
|
+
else if (typeof value === "object" && value !== null) {
|
|
398
|
+
const missingSet = walkAndReplace(value, params, requiredSet, [...pathStack, key]);
|
|
399
|
+
if (missingSet !== null && missingSet.size > 0)
|
|
400
|
+
keysToDelete.push(key);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
for (const k of keysToDelete)
|
|
404
|
+
delete obj[k];
|
|
405
|
+
return null;
|
|
406
|
+
}
|
|
407
|
+
return null;
|
|
408
|
+
}
|
|
409
|
+
function replaceInString(value, params, requiredSet, pathStack) {
|
|
410
|
+
const foundKeys = [];
|
|
411
|
+
const missingRequired = [];
|
|
412
|
+
const missingOptional = [];
|
|
413
|
+
for (const match of value.matchAll(PLACEHOLDER_RE))
|
|
414
|
+
foundKeys.push(match[1]);
|
|
415
|
+
if (foundKeys.length === 0)
|
|
416
|
+
return new Set();
|
|
417
|
+
for (const key of foundKeys) {
|
|
418
|
+
const v = params[key];
|
|
419
|
+
if (v === undefined || v === null || v === "") {
|
|
420
|
+
(requiredSet.has(key) ? missingRequired : missingOptional).push(key);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
if (missingRequired.length > 0) {
|
|
424
|
+
throw new InvokeError("INVALID_PARAM", `Missing required parameter(s) for template field '${pathStack.join(".")}': ${missingRequired.join(", ")}`, { field: pathStack.join("."), missing: missingRequired });
|
|
425
|
+
}
|
|
426
|
+
if (missingOptional.length === foundKeys.length)
|
|
427
|
+
return null;
|
|
428
|
+
return new Set(missingOptional);
|
|
429
|
+
}
|
|
430
|
+
function hasPlaceholders(value) {
|
|
431
|
+
PLACEHOLDER_RE.lastIndex = 0;
|
|
432
|
+
return PLACEHOLDER_RE.test(value);
|
|
433
|
+
}
|
|
434
|
+
function replacePlaceholders(value, params) {
|
|
435
|
+
return value.replace(PLACEHOLDER_RE, (_m, key) => {
|
|
436
|
+
const v = params[key];
|
|
437
|
+
return v === undefined || v === null ? _m : String(v);
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
441
|
+
// 5b. Safety net: detect unreplaced placeholders in rendered output
|
|
442
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
443
|
+
/** Matches ANY remaining #{...} pattern regardless of prefix. */
|
|
444
|
+
const UNRESOLVED_RE = /#\{[^}]+\}/;
|
|
445
|
+
/**
|
|
446
|
+
* Recursively scan a rendered payload for unreplaced template placeholders.
|
|
447
|
+
* Returns tuples of [path, placeholderText] for each unreplaced slot found.
|
|
448
|
+
*/
|
|
449
|
+
function findUnresolvedPlaceholders(obj, prefix) {
|
|
450
|
+
const results = [];
|
|
451
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
452
|
+
const currentPath = `${prefix}.${key}`;
|
|
453
|
+
if (typeof value === "string") {
|
|
454
|
+
const match = value.match(UNRESOLVED_RE);
|
|
455
|
+
if (match) {
|
|
456
|
+
results.push([currentPath, match[0]]);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
else if (typeof value === "object" && value !== null) {
|
|
460
|
+
if (Array.isArray(value)) {
|
|
461
|
+
for (let i = 0; i < value.length; i++) {
|
|
462
|
+
const item = value[i];
|
|
463
|
+
if (typeof item === "string") {
|
|
464
|
+
const match = item.match(UNRESOLVED_RE);
|
|
465
|
+
if (match)
|
|
466
|
+
results.push([`${currentPath}[${i}]`, match[0]]);
|
|
467
|
+
}
|
|
468
|
+
else if (typeof item === "object" && item !== null) {
|
|
469
|
+
results.push(...findUnresolvedPlaceholders(item, `${currentPath}[${i}]`));
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
else {
|
|
474
|
+
results.push(...findUnresolvedPlaceholders(value, currentPath));
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
return results;
|
|
479
|
+
}
|
|
480
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
481
|
+
// 6. Cloud / MCP executor
|
|
482
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
483
|
+
const ENV_FILE_PATH = path.join(os.homedir(), ".openclaw", ".xiaoyienv");
|
|
484
|
+
// Per invoke.md §4.3: REST, SSE, and Websocket all use the same endpoint.
|
|
485
|
+
// Only the accept header and response handling differ:
|
|
486
|
+
// - REST (accept: application/json) → single JSON frame
|
|
487
|
+
// - SSE/Websocket (accept: text/event-stream) → multiple SSE frames, only final frame used (§4.7)
|
|
488
|
+
const UNIFIED_API_SUFFIX = "/plugin-executor-service-ws/v1/skill-action-executor/query";
|
|
489
|
+
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
490
|
+
const REQUIRED_ENV_VARS = ["SERVICE_URL", "PERSONAL-API-KEY", "PERSONAL-UID"];
|
|
491
|
+
function readEnvFile() {
|
|
492
|
+
if (!fs.existsSync(ENV_FILE_PATH)) {
|
|
493
|
+
throw new InvokeError("CONFIG_MISSING", `Environment file not found: ${ENV_FILE_PATH}`);
|
|
494
|
+
}
|
|
495
|
+
let envData;
|
|
496
|
+
try {
|
|
497
|
+
envData = fs.readFileSync(ENV_FILE_PATH, "utf-8");
|
|
498
|
+
}
|
|
499
|
+
catch {
|
|
500
|
+
throw new InvokeError("CONFIG_MISSING", `Failed to read: ${ENV_FILE_PATH}`);
|
|
501
|
+
}
|
|
502
|
+
const env = {};
|
|
503
|
+
for (const line of envData.split("\n")) {
|
|
504
|
+
const trimmed = line.trim();
|
|
505
|
+
if (!trimmed || trimmed.startsWith("#"))
|
|
506
|
+
continue;
|
|
507
|
+
const eqIdx = trimmed.indexOf("=");
|
|
508
|
+
if (eqIdx === -1)
|
|
509
|
+
continue;
|
|
510
|
+
const key = trimmed.substring(0, eqIdx).trim();
|
|
511
|
+
const value = trimmed.substring(eqIdx + 1).trim();
|
|
512
|
+
if (key && REQUIRED_ENV_VARS.includes(key))
|
|
513
|
+
env[key] = value;
|
|
514
|
+
}
|
|
515
|
+
return env;
|
|
516
|
+
}
|
|
517
|
+
function loadCloudConfig() {
|
|
518
|
+
const env = readEnvFile();
|
|
519
|
+
if (!env["SERVICE_URL"])
|
|
520
|
+
throw new InvokeError("CONFIG_MISSING", "SERVICE_URL is not set in .xiaoyienv");
|
|
521
|
+
if (!env["PERSONAL-API-KEY"])
|
|
522
|
+
throw new InvokeError("CONFIG_MISSING", "PERSONAL-API-KEY is not set in .xiaoyienv");
|
|
523
|
+
if (!env["PERSONAL-UID"])
|
|
524
|
+
throw new InvokeError("CONFIG_MISSING", "PERSONAL-UID is not set in .xiaoyienv");
|
|
525
|
+
return { serviceUrl: env["SERVICE_URL"], apiKey: env["PERSONAL-API-KEY"], uid: env["PERSONAL-UID"] };
|
|
526
|
+
}
|
|
527
|
+
function buildCloudRequest(p) {
|
|
528
|
+
return {
|
|
529
|
+
version: "1.0",
|
|
530
|
+
session: {
|
|
531
|
+
isNew: "true",
|
|
532
|
+
sessionId: p.sessionId,
|
|
533
|
+
interactionId: 1,
|
|
534
|
+
conversationId: uuidv4(),
|
|
535
|
+
agentLoginSessionId1: p.agentId,
|
|
536
|
+
},
|
|
537
|
+
endpoint: {
|
|
538
|
+
countryCode: "CN",
|
|
539
|
+
device: {
|
|
540
|
+
deviceId: uuidv4(),
|
|
541
|
+
deviceType: 0,
|
|
542
|
+
manufacturer: "",
|
|
543
|
+
ohosVersion: "2.0",
|
|
544
|
+
phoneType: "NOH-AN00",
|
|
545
|
+
prdVer: "11.3.4.202",
|
|
546
|
+
sysVer: "HarmonyOS_6.0",
|
|
547
|
+
timezone: "GMT+08:00",
|
|
548
|
+
odid: "12345",
|
|
549
|
+
},
|
|
550
|
+
locale: "zh-CN",
|
|
551
|
+
privacyOption: {},
|
|
552
|
+
supportFeatureList: [
|
|
553
|
+
{ featureType: "CONTENT_CARD", featureVersion: "6.0" },
|
|
554
|
+
],
|
|
555
|
+
},
|
|
556
|
+
skillActionExecutorTask: {
|
|
557
|
+
actionName: p.definition.toolName,
|
|
558
|
+
content: p.businessParams,
|
|
559
|
+
bundleName: p.definition.bundleName,
|
|
560
|
+
},
|
|
561
|
+
utterance: {
|
|
562
|
+
original: "",
|
|
563
|
+
},
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
function buildHeaders(config, skillName, protocol) {
|
|
567
|
+
return {
|
|
568
|
+
"content-type": "application/json",
|
|
569
|
+
accept: protocol === "REST" ? "application/json" : "text/event-stream",
|
|
570
|
+
"x-hag-trace-id": uuidv4(),
|
|
571
|
+
"x-uid": config.uid,
|
|
572
|
+
"x-api-key": config.apiKey,
|
|
573
|
+
"x-request-from": "openclaw",
|
|
574
|
+
"x-skill-id": skillName,
|
|
575
|
+
"x-prd-pkg-name": "com.huawei.hag",
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
// Per invoke.md §4.3: REST, SSE, and Websocket all use the same endpoint.
|
|
579
|
+
// Communication is via WebSocket — the serviceUrl is converted to wss://.
|
|
580
|
+
// Response handling differs by protocol:
|
|
581
|
+
// - REST: single JSON frame
|
|
582
|
+
// - SSE/Websocket: multiple SSE frames; only the final frame is used (§4.7)
|
|
583
|
+
async function executePluginExecutor(config, requestBody, headers, toolName, bundleName, protocol) {
|
|
584
|
+
// Convert serviceUrl to wss:// regardless of original protocol (http, https, etc.)
|
|
585
|
+
const wsBaseUrl = config.serviceUrl.replace(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//i, "wss://");
|
|
586
|
+
const url = `${wsBaseUrl}${UNIFIED_API_SUFFIX}`;
|
|
587
|
+
const isStreaming = protocol === "SSE" || protocol === "Websocket";
|
|
588
|
+
logger.log(`[INVOKE-CLOUD] calling PluginExecutor via WebSocket`, { url, toolName, bundleName, protocol });
|
|
589
|
+
const urlObj = new URL(url);
|
|
590
|
+
const isWssWithIP = urlObj.protocol === "wss:" && /^(\d{1,3}\.){3}\d{1,3}$/.test(urlObj.hostname);
|
|
591
|
+
const wsOptions = { headers };
|
|
592
|
+
if (isWssWithIP) {
|
|
593
|
+
wsOptions.rejectUnauthorized = false;
|
|
594
|
+
}
|
|
595
|
+
return new Promise((resolve, reject) => {
|
|
596
|
+
const ws = new WebSocket(url, wsOptions);
|
|
597
|
+
const messages = [];
|
|
598
|
+
let settled = false;
|
|
599
|
+
const timeoutId = setTimeout(() => {
|
|
600
|
+
if (settled)
|
|
601
|
+
return;
|
|
602
|
+
settled = true;
|
|
603
|
+
ws.close();
|
|
604
|
+
logger.warn("[INVOKE-CLOUD] WebSocket request timed out", { url, toolName, bundleName, protocol });
|
|
605
|
+
reject(new InvokeError("TIMEOUT", "Cloud tool execution timed out (60s)"));
|
|
606
|
+
}, DEFAULT_TIMEOUT_MS);
|
|
607
|
+
ws.on("open", () => {
|
|
608
|
+
logger.log(`[INVOKE-CLOUD] WebSocket connected, sending request`, requestBody);
|
|
609
|
+
ws.send(JSON.stringify(requestBody));
|
|
610
|
+
});
|
|
611
|
+
ws.on("message", (data) => {
|
|
612
|
+
const text = data.toString();
|
|
613
|
+
messages.push(text);
|
|
614
|
+
if (isStreaming) {
|
|
615
|
+
// SSE/Websocket: check if we've received a final frame so we can
|
|
616
|
+
// resolve immediately without waiting for the server to close.
|
|
617
|
+
const joined = messages.join("");
|
|
618
|
+
const events = tryParseSSE(joined) ?? parseConcatenatedJSON(joined);
|
|
619
|
+
const lastEvent = events.length > 0 ? events[events.length - 1] : null;
|
|
620
|
+
if (lastEvent && isFinalEvent(lastEvent)) {
|
|
621
|
+
if (settled)
|
|
622
|
+
return;
|
|
623
|
+
settled = true;
|
|
624
|
+
clearTimeout(timeoutId);
|
|
625
|
+
const resultStr = typeof lastEvent === "string" ? lastEvent : JSON.stringify(lastEvent);
|
|
626
|
+
logger.log("[INVOKE-CLOUD] final frame detected, resolving immediately", {
|
|
627
|
+
toolName, bundleName, totalEvents: events.length, resultLength: resultStr.length,
|
|
628
|
+
});
|
|
629
|
+
resolve({ content: [{ type: "text", text: resultStr }], details: null });
|
|
630
|
+
ws.close();
|
|
631
|
+
}
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
// REST: single-frame response — resolve on first message and close.
|
|
635
|
+
if (settled)
|
|
636
|
+
return;
|
|
637
|
+
settled = true;
|
|
638
|
+
clearTimeout(timeoutId);
|
|
639
|
+
try {
|
|
640
|
+
const body = JSON.parse(text);
|
|
641
|
+
const bodyStr = JSON.stringify(body);
|
|
642
|
+
logger.log("[INVOKE-CLOUD] execution succeeded", { toolName, bundleName, protocol, resultLength: bodyStr.length, result: bodyStr });
|
|
643
|
+
resolve({ content: [{ type: "text", text: bodyStr }], details: null });
|
|
644
|
+
}
|
|
645
|
+
catch {
|
|
646
|
+
logger.warn("[INVOKE-CLOUD] response is not valid JSON, returning raw text", { toolName, bundleName });
|
|
647
|
+
resolve({ content: [{ type: "text", text: text }], details: null });
|
|
648
|
+
}
|
|
649
|
+
ws.close();
|
|
650
|
+
});
|
|
651
|
+
// on("close") is now a fallback: the server may close before we detect a
|
|
652
|
+
// final frame, or we may have missed it (e.g. fragmented across messages).
|
|
653
|
+
ws.on("close", (code) => {
|
|
654
|
+
if (settled)
|
|
655
|
+
return;
|
|
656
|
+
settled = true;
|
|
657
|
+
clearTimeout(timeoutId);
|
|
658
|
+
if (isStreaming) {
|
|
659
|
+
if (messages.length === 0) {
|
|
660
|
+
logger.warn("[INVOKE-CLOUD] WebSocket closed with no data", { toolName, bundleName, closeCode: code });
|
|
661
|
+
reject(new InvokeError("UPSTREAM_ERROR", `WebSocket closed without sending data (code: ${code})`));
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
try {
|
|
665
|
+
const result = parseSSEFromText(messages.join(""), toolName, bundleName);
|
|
666
|
+
resolve(result);
|
|
667
|
+
}
|
|
668
|
+
catch (err) {
|
|
669
|
+
reject(err instanceof InvokeError ? err : new InvokeError("UPSTREAM_ERROR", "Failed to parse SSE response"));
|
|
670
|
+
}
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
if (messages.length > 0) {
|
|
674
|
+
try {
|
|
675
|
+
const body = JSON.parse(messages[0]);
|
|
676
|
+
const bodyStr = JSON.stringify(body);
|
|
677
|
+
logger.log("[INVOKE-CLOUD] execution succeeded (via close)", { toolName, bundleName, protocol, resultLength: bodyStr.length, result: bodyStr });
|
|
678
|
+
resolve({ content: [{ type: "text", text: bodyStr }], details: null });
|
|
679
|
+
}
|
|
680
|
+
catch {
|
|
681
|
+
logger.warn("[INVOKE-CLOUD] response is not valid JSON, returning raw text", { toolName, bundleName });
|
|
682
|
+
resolve({ content: [{ type: "text", text: messages[0] }], details: null });
|
|
683
|
+
}
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
logger.error(`[INVOKE-CLOUD] WebSocket closed with no response`, { toolName, bundleName, protocol, closeCode: code });
|
|
687
|
+
reject(new InvokeError("NETWORK_ERROR", `WebSocket connection closed with code ${code} and no data`));
|
|
688
|
+
});
|
|
689
|
+
ws.on("error", (error) => {
|
|
690
|
+
if (settled)
|
|
691
|
+
return;
|
|
692
|
+
settled = true;
|
|
693
|
+
clearTimeout(timeoutId);
|
|
694
|
+
logger.error("[INVOKE-CLOUD] WebSocket error", {
|
|
695
|
+
url, toolName, bundleName, protocol,
|
|
696
|
+
error: error.message,
|
|
697
|
+
});
|
|
698
|
+
const errMsg = error.message || "";
|
|
699
|
+
if (errMsg.includes("401") || errMsg.includes("Unauthorized")) {
|
|
700
|
+
reject(new InvokeError("AUTH_FAIL", "Authentication failed — check credentials"));
|
|
701
|
+
}
|
|
702
|
+
else if (errMsg.includes("403") || errMsg.includes("Forbidden")) {
|
|
703
|
+
reject(new InvokeError("PERMISSION_DENIED", "Permission denied by UserAccessService"));
|
|
704
|
+
}
|
|
705
|
+
else if (errMsg.includes("429")) {
|
|
706
|
+
reject(new InvokeError("RATE_LIMIT", "Rate limit exceeded — retry later"));
|
|
707
|
+
}
|
|
708
|
+
else {
|
|
709
|
+
reject(new InvokeError("NETWORK_ERROR", `WebSocket connection failed: ${error.message}`));
|
|
710
|
+
}
|
|
711
|
+
});
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
// Per invoke.md §4.7: consume full stream, ignore intermediate chunks,
|
|
715
|
+
// only return the final complete result.
|
|
716
|
+
//
|
|
717
|
+
// The response may arrive in two formats:
|
|
718
|
+
// a) Standard SSE: "data: ...\n\n" lines
|
|
719
|
+
// b) Concatenated JSON frames: each WebSocket message is a complete JSON
|
|
720
|
+
// object; when joined they form consecutive JSON objects like "{...}{...}"
|
|
721
|
+
// In both cases we parse each frame/event and return the last one.
|
|
722
|
+
function parseSSEFromText(text, toolName, bundleName) {
|
|
723
|
+
const events = [];
|
|
724
|
+
// First, try SSE format (data: prefix with blank-line separators)
|
|
725
|
+
const sseEvents = tryParseSSE(text);
|
|
726
|
+
if (sseEvents !== null) {
|
|
727
|
+
events.push(...sseEvents);
|
|
728
|
+
}
|
|
729
|
+
else {
|
|
730
|
+
// Fall back to concatenated-JSON format
|
|
731
|
+
events.push(...parseConcatenatedJSON(text));
|
|
732
|
+
}
|
|
733
|
+
if (events.length === 0) {
|
|
734
|
+
logger.warn("[INVOKE-CLOUD] stream ended with no complete result", { toolName, bundleName });
|
|
735
|
+
throw new InvokeError("UPSTREAM_ERROR", "stream ended without a complete result");
|
|
736
|
+
}
|
|
737
|
+
// Per §4.7: only the final complete result is returned; intermediate chunks are ignored
|
|
738
|
+
const lastEvent = events[events.length - 1];
|
|
739
|
+
const resultStr = typeof lastEvent === "string" ? lastEvent : JSON.stringify(lastEvent);
|
|
740
|
+
logger.log("[INVOKE-CLOUD] execution succeeded", {
|
|
741
|
+
toolName, bundleName,
|
|
742
|
+
totalEvents: events.length,
|
|
743
|
+
resultLength: resultStr.length,
|
|
744
|
+
result: resultStr
|
|
745
|
+
});
|
|
746
|
+
return { content: [{ type: "text", text: resultStr }], details: null };
|
|
747
|
+
}
|
|
748
|
+
/** Try to parse as SSE format (data: + blank-line delimiters). Returns null if no SSE events found. */
|
|
749
|
+
function tryParseSSE(text) {
|
|
750
|
+
const lines = text.split(/\r?\n/);
|
|
751
|
+
let currentDataLines = [];
|
|
752
|
+
const events = [];
|
|
753
|
+
let hasDataPrefix = false;
|
|
754
|
+
for (const line of lines) {
|
|
755
|
+
if (line.startsWith("data:")) {
|
|
756
|
+
hasDataPrefix = true;
|
|
757
|
+
currentDataLines.push(line.slice(5).trim());
|
|
758
|
+
}
|
|
759
|
+
else if (line.trim() === "" && currentDataLines.length > 0) {
|
|
760
|
+
const dataStr = currentDataLines.join("\n");
|
|
761
|
+
currentDataLines = [];
|
|
762
|
+
try {
|
|
763
|
+
events.push(JSON.parse(dataStr));
|
|
764
|
+
}
|
|
765
|
+
catch {
|
|
766
|
+
events.push(dataStr);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
// Flush remaining data if stream doesn't end with a blank line
|
|
771
|
+
if (currentDataLines.length > 0) {
|
|
772
|
+
const dataStr = currentDataLines.join("\n");
|
|
773
|
+
try {
|
|
774
|
+
events.push(JSON.parse(dataStr));
|
|
775
|
+
}
|
|
776
|
+
catch {
|
|
777
|
+
events.push(dataStr);
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
return hasDataPrefix && events.length > 0 ? events : null;
|
|
781
|
+
}
|
|
782
|
+
/** Parse concatenated JSON objects by tracking brace depth. */
|
|
783
|
+
function parseConcatenatedJSON(text) {
|
|
784
|
+
const events = [];
|
|
785
|
+
let i = 0;
|
|
786
|
+
while (i < text.length) {
|
|
787
|
+
// Skip whitespace
|
|
788
|
+
while (i < text.length && /\s/.test(text[i]))
|
|
789
|
+
i++;
|
|
790
|
+
if (i >= text.length)
|
|
791
|
+
break;
|
|
792
|
+
if (text[i] !== "{") {
|
|
793
|
+
i++;
|
|
794
|
+
continue;
|
|
795
|
+
}
|
|
796
|
+
let depth = 0;
|
|
797
|
+
let inString = false;
|
|
798
|
+
let escaped = false;
|
|
799
|
+
const start = i;
|
|
800
|
+
for (; i < text.length; i++) {
|
|
801
|
+
const ch = text[i];
|
|
802
|
+
if (escaped) {
|
|
803
|
+
escaped = false;
|
|
804
|
+
continue;
|
|
805
|
+
}
|
|
806
|
+
if (ch === "\\" && inString) {
|
|
807
|
+
escaped = true;
|
|
808
|
+
continue;
|
|
809
|
+
}
|
|
810
|
+
if (ch === '"') {
|
|
811
|
+
inString = !inString;
|
|
812
|
+
continue;
|
|
813
|
+
}
|
|
814
|
+
if (inString)
|
|
815
|
+
continue;
|
|
816
|
+
if (ch === "{") {
|
|
817
|
+
depth++;
|
|
818
|
+
}
|
|
819
|
+
else if (ch === "}") {
|
|
820
|
+
depth--;
|
|
821
|
+
if (depth === 0) {
|
|
822
|
+
i++;
|
|
823
|
+
const jsonStr = text.slice(start, i);
|
|
824
|
+
try {
|
|
825
|
+
events.push(JSON.parse(jsonStr));
|
|
826
|
+
}
|
|
827
|
+
catch { /* skip malformed */ }
|
|
828
|
+
break;
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
return events;
|
|
834
|
+
}
|
|
835
|
+
/**
|
|
836
|
+
* Check whether a parsed SSE/JSON event is a "final" frame.
|
|
837
|
+
*
|
|
838
|
+
* The plugin-executor-service marks the last event with
|
|
839
|
+
* `actionExecutorResult.isFinal === true`; intermediate events have it set
|
|
840
|
+
* to `false` or omit it entirely.
|
|
841
|
+
*/
|
|
842
|
+
function isFinalEvent(parsed) {
|
|
843
|
+
if (!parsed || typeof parsed !== "object")
|
|
844
|
+
return false;
|
|
845
|
+
const obj = parsed;
|
|
846
|
+
const aer = obj.actionExecutorResult;
|
|
847
|
+
if (!aer || typeof aer !== "object")
|
|
848
|
+
return false;
|
|
849
|
+
return aer.isFinal === true;
|
|
850
|
+
}
|
|
851
|
+
async function executeCloudTool(params) {
|
|
852
|
+
const { definition, skillName, businessParams } = params;
|
|
853
|
+
const { toolName, bundleName, protocol: definitionProtocol } = definition;
|
|
854
|
+
const protocol = definitionProtocol ?? "REST";
|
|
855
|
+
logger.log("[INVOKE-CLOUD] executing cloud tool", {
|
|
856
|
+
toolName, bundleName, skillName, protocol,
|
|
857
|
+
businessKeysCount: Object.keys(businessParams).length,
|
|
858
|
+
});
|
|
859
|
+
if (protocol !== "REST" && protocol !== "SSE" && protocol !== "Websocket") {
|
|
860
|
+
logger.warn("[INVOKE-CLOUD] unknown protocol", { toolName, bundleName, protocol });
|
|
861
|
+
throw new InvokeError("UNSUPPORTED_PROTOCOL", `Unknown protocol: ${protocol}`);
|
|
862
|
+
}
|
|
863
|
+
// Per invoke.md §4.3: REST, SSE, and Websocket all use the same endpoint.
|
|
864
|
+
// Only the accept header and response handling differ.
|
|
865
|
+
const config = loadCloudConfig();
|
|
866
|
+
return executePluginExecutor(config, buildCloudRequest(params), buildHeaders(config, skillName, protocol), toolName, bundleName, protocol);
|
|
867
|
+
}
|
|
868
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
869
|
+
// 7. Device executor
|
|
870
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
871
|
+
const LOCKS_SLOT = "__xyInvokeDeviceLocks";
|
|
872
|
+
if (!_g[LOCKS_SLOT])
|
|
873
|
+
_g[LOCKS_SLOT] = new Map();
|
|
874
|
+
const deviceLocks = _g[LOCKS_SLOT];
|
|
875
|
+
function acquireDeviceLock(sessionId) {
|
|
876
|
+
return deviceLocks.get(sessionId) ?? null;
|
|
877
|
+
}
|
|
878
|
+
function setDeviceLock(sessionId) {
|
|
879
|
+
let release;
|
|
880
|
+
const promise = new Promise((r) => { release = r; });
|
|
881
|
+
deviceLocks.set(sessionId, promise);
|
|
882
|
+
return () => { if (deviceLocks.get(sessionId) === promise)
|
|
883
|
+
deviceLocks.delete(sessionId); release(); };
|
|
884
|
+
}
|
|
885
|
+
function validateBundleNameConsistency(definition) {
|
|
886
|
+
const dc = definition.deviceCommand;
|
|
887
|
+
if (!dc)
|
|
888
|
+
return;
|
|
889
|
+
const ep = dc.template?.payload?.executeParam;
|
|
890
|
+
if (typeof ep?.bundleName === "string" && ep.bundleName !== definition.bundleName) {
|
|
891
|
+
throw new InvokeError("INVALID_TOOL_DEFINITION", `Device tool bundleName mismatch: top-level '${definition.bundleName}' != deviceCommand '${ep.bundleName}'`);
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
const DEVICE_TIMEOUT_MS = 60_000;
|
|
895
|
+
async function executeDeviceTool(params, sessionCtx) {
|
|
896
|
+
const { definition, businessParams, toolCallId } = params;
|
|
897
|
+
const { config, sessionId, taskId, messageId } = sessionCtx;
|
|
898
|
+
const log = logger.withContext(sessionId, taskId);
|
|
899
|
+
if (definition.pluginType !== "Device")
|
|
900
|
+
throw new InvokeError("UNSUPPORTED_PLUGIN_TYPE", "Expected Device pluginType");
|
|
901
|
+
if (!definition.deviceCommand)
|
|
902
|
+
throw new InvokeError("INVALID_TOOL_DEFINITION", "Device tool missing deviceCommand");
|
|
903
|
+
validateBundleNameConsistency(definition);
|
|
904
|
+
if (sessionCtx.isCron)
|
|
905
|
+
throw new InvokeError("DEVICE_TOOL_BLOCKED", "Device tools not available in cron sessions");
|
|
906
|
+
const rendered = renderDeviceCommand(definition.deviceCommand.template, businessParams, definition.arguments);
|
|
907
|
+
// Safety net: catch any unreplaced #{...} placeholders in the rendered payload
|
|
908
|
+
const unresolved = findUnresolvedPlaceholders(rendered, "payload");
|
|
909
|
+
if (unresolved.length > 0) {
|
|
910
|
+
const details = unresolved.map(([path, text]) => `${path}='${text}'`).join(", ");
|
|
911
|
+
throw new InvokeError("INVALID_PARAM", `Template has unfilled slots: ${details}`);
|
|
912
|
+
}
|
|
913
|
+
const ep = rendered.payload?.executeParam;
|
|
914
|
+
const intentName = ep?.intentName;
|
|
915
|
+
if (!intentName)
|
|
916
|
+
throw new InvokeError("INVALID_TOOL_DEFINITION", "deviceCommand missing executeParam.intentName");
|
|
917
|
+
log.log("[INVOKE-DEVICE] executing device tool", {
|
|
918
|
+
toolCallId, toolName: definition.toolName, bundleName: definition.bundleName,
|
|
919
|
+
intentName, businessKeysCount: Object.keys(businessParams).length,
|
|
920
|
+
});
|
|
921
|
+
const command = {
|
|
922
|
+
header: rendered.header,
|
|
923
|
+
payload: rendered.payload,
|
|
924
|
+
};
|
|
925
|
+
const prevLock = acquireDeviceLock(sessionId);
|
|
926
|
+
if (prevLock) {
|
|
927
|
+
log.log("[INVOKE-DEVICE] waiting for previous device tool lock", { sessionId, intentName });
|
|
928
|
+
await prevLock;
|
|
929
|
+
}
|
|
930
|
+
const unlock = setDeviceLock(sessionId);
|
|
931
|
+
try {
|
|
932
|
+
const wsManager = getXYWebSocketManager(config);
|
|
933
|
+
return new Promise((resolve, reject) => {
|
|
934
|
+
const timeout = setTimeout(() => {
|
|
935
|
+
wsManager.off("data-event", handler);
|
|
936
|
+
log.warn("[INVOKE-DEVICE] device tool timed out", { intentName, toolCallId, timeoutMs: DEVICE_TIMEOUT_MS });
|
|
937
|
+
reject(new InvokeError("TIMEOUT", `Device tool '${intentName}' timed out after 60s`));
|
|
938
|
+
}, DEVICE_TIMEOUT_MS);
|
|
939
|
+
const handler = (event) => {
|
|
940
|
+
if (event.intentName !== intentName)
|
|
941
|
+
return;
|
|
942
|
+
clearTimeout(timeout);
|
|
943
|
+
wsManager.off("data-event", handler);
|
|
944
|
+
if (event.status === "success" && event.outputs) {
|
|
945
|
+
log.log("[INVOKE-DEVICE] device tool succeeded", { intentName, toolCallId, resultLength: JSON.stringify(event.outputs).length, event });
|
|
946
|
+
resolve({ content: [{ type: "text", text: JSON.stringify(event.outputs) }], details: null });
|
|
947
|
+
}
|
|
948
|
+
else {
|
|
949
|
+
const detail = event.outputs ? JSON.stringify(event.outputs) : event.status;
|
|
950
|
+
log.warn("[INVOKE-DEVICE] device tool failed", { intentName, toolCallId, status: event.status, detail });
|
|
951
|
+
reject(new InvokeError("UPSTREAM_ERROR", `Device tool '${intentName}' failed: ${detail}`));
|
|
952
|
+
}
|
|
953
|
+
};
|
|
954
|
+
wsManager.on("data-event", handler);
|
|
955
|
+
const currentTaskId = getCurrentTaskId(sessionId) ?? taskId;
|
|
956
|
+
sendCommand({ config, sessionId, taskId: currentTaskId, messageId, command, toolCallId }).catch((error) => {
|
|
957
|
+
clearTimeout(timeout);
|
|
958
|
+
wsManager.off("data-event", handler);
|
|
959
|
+
log.error("[INVOKE-DEVICE] sendCommand failed", { intentName, toolCallId, error: error instanceof Error ? error.message : String(error) });
|
|
960
|
+
reject(new InvokeError("NETWORK_ERROR", `Failed to send device command: ${error instanceof Error ? error.message : String(error)}`));
|
|
961
|
+
});
|
|
962
|
+
});
|
|
963
|
+
}
|
|
964
|
+
finally {
|
|
965
|
+
unlock();
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
function validateAndExtract(params) {
|
|
969
|
+
if (!params || typeof params !== "object" || Array.isArray(params)) {
|
|
970
|
+
return {
|
|
971
|
+
content: [{ type: "text", text: JSON.stringify({
|
|
972
|
+
code: "INVALID_PARAM",
|
|
973
|
+
message: "invoke expects an object with 'functionName' (or 'funcName') and 'arguments' (or 'params'). arguments must contain 'bundleName' and any business parameters.",
|
|
974
|
+
retryable: false,
|
|
975
|
+
}) }],
|
|
976
|
+
details: null,
|
|
977
|
+
isError: true,
|
|
978
|
+
};
|
|
979
|
+
}
|
|
980
|
+
const p = params;
|
|
981
|
+
// Accept both new and legacy field names — new takes precedence when both present
|
|
982
|
+
const allowedTopLevel = new Set(["functionName", "funcName", "arguments", "params"]);
|
|
983
|
+
const extraFields = Object.keys(p).filter((k) => !allowedTopLevel.has(k));
|
|
984
|
+
if (extraFields.length > 0) {
|
|
985
|
+
return {
|
|
986
|
+
content: [{ type: "text", text: JSON.stringify({
|
|
987
|
+
code: "INVALID_PARAM",
|
|
988
|
+
message: `Unexpected top-level field(s): ${extraFields.join(", ")}. Only 'functionName' (or 'funcName') and 'arguments' (or 'params') are allowed.`,
|
|
989
|
+
retryable: false,
|
|
990
|
+
}) }],
|
|
991
|
+
details: null,
|
|
992
|
+
isError: true,
|
|
993
|
+
};
|
|
994
|
+
}
|
|
995
|
+
// Resolve tool name: new 'functionName' takes precedence over legacy 'funcName'
|
|
996
|
+
const rawFuncName = p.functionName ?? p.funcName;
|
|
997
|
+
if (typeof rawFuncName !== "string" || rawFuncName.length === 0) {
|
|
998
|
+
return {
|
|
999
|
+
content: [{ type: "text", text: JSON.stringify({ code: "INVALID_PARAM", message: "functionName (or funcName) must be a non-empty string.", retryable: false }) }],
|
|
1000
|
+
details: null,
|
|
1001
|
+
isError: true,
|
|
1002
|
+
};
|
|
1003
|
+
}
|
|
1004
|
+
const functionName = rawFuncName;
|
|
1005
|
+
// Resolve invoke params: new 'arguments' takes precedence over legacy 'params'
|
|
1006
|
+
const rawArgs = p.arguments ?? p.params;
|
|
1007
|
+
if (!rawArgs || typeof rawArgs !== "object" || Array.isArray(rawArgs)) {
|
|
1008
|
+
return {
|
|
1009
|
+
content: [{ type: "text", text: JSON.stringify({
|
|
1010
|
+
code: "INVALID_PARAM",
|
|
1011
|
+
message: "arguments (or params) must be an object containing 'bundleName' and any business parameters. Legacy array format is not supported.",
|
|
1012
|
+
retryable: false,
|
|
1013
|
+
}) }],
|
|
1014
|
+
details: null,
|
|
1015
|
+
isError: true,
|
|
1016
|
+
};
|
|
1017
|
+
}
|
|
1018
|
+
const ip = rawArgs;
|
|
1019
|
+
const bundleName = ip.bundleName;
|
|
1020
|
+
if (typeof bundleName !== "string" || bundleName.length === 0) {
|
|
1021
|
+
return {
|
|
1022
|
+
content: [{ type: "text", text: JSON.stringify({
|
|
1023
|
+
code: "INVALID_PARAM", message: "arguments.bundleName must be a non-empty string.", retryable: false,
|
|
1024
|
+
}) }],
|
|
1025
|
+
details: null,
|
|
1026
|
+
isError: true,
|
|
1027
|
+
};
|
|
1028
|
+
}
|
|
1029
|
+
// Reject legacy top-level bundleName (outside both arguments and params)
|
|
1030
|
+
if ("bundleName" in p && p.bundleName !== undefined && !("arguments" in p) && !("params" in p)) {
|
|
1031
|
+
return {
|
|
1032
|
+
content: [{ type: "text", text: JSON.stringify({
|
|
1033
|
+
code: "INVALID_PARAM",
|
|
1034
|
+
message: "bundleName must be inside 'arguments' (or 'params') object. Use: { functionName, arguments: { bundleName, ... } }",
|
|
1035
|
+
retryable: false,
|
|
1036
|
+
}) }],
|
|
1037
|
+
details: null,
|
|
1038
|
+
isError: true,
|
|
1039
|
+
};
|
|
1040
|
+
}
|
|
1041
|
+
const businessParams = {};
|
|
1042
|
+
for (const key of Object.keys(ip)) {
|
|
1043
|
+
if (key !== "bundleName")
|
|
1044
|
+
businessParams[key] = ip[key];
|
|
1045
|
+
}
|
|
1046
|
+
return { toolName: functionName, bundleName, businessParams };
|
|
1047
|
+
}
|
|
1048
|
+
function validateBusinessParams(businessParams, definition) {
|
|
1049
|
+
const required = definition.arguments.required ?? [];
|
|
1050
|
+
const missing = [];
|
|
1051
|
+
for (const field of required) {
|
|
1052
|
+
if (businessParams[field] === undefined || businessParams[field] === null)
|
|
1053
|
+
missing.push(field);
|
|
1054
|
+
}
|
|
1055
|
+
if (missing.length > 0) {
|
|
1056
|
+
return {
|
|
1057
|
+
content: [{ type: "text", text: JSON.stringify({
|
|
1058
|
+
code: "INVALID_PARAM",
|
|
1059
|
+
message: `Missing required parameter(s): ${missing.join(", ")}`,
|
|
1060
|
+
retryable: false,
|
|
1061
|
+
details: { missing, toolName: definition.toolName },
|
|
1062
|
+
}) }],
|
|
1063
|
+
details: null,
|
|
1064
|
+
isError: true,
|
|
1065
|
+
};
|
|
1066
|
+
}
|
|
1067
|
+
return null;
|
|
1068
|
+
}
|
|
1069
|
+
/**
|
|
1070
|
+
* Create the invoke meta-tool for the given session context.
|
|
1071
|
+
*
|
|
1072
|
+
* This is the ONLY exported function — the single integration point for
|
|
1073
|
+
* create-all-tools.ts.
|
|
1074
|
+
*/
|
|
1075
|
+
export function createInvokeTool(ctx) {
|
|
1076
|
+
return {
|
|
1077
|
+
name: "invoke",
|
|
1078
|
+
label: "Invoke",
|
|
1079
|
+
description: "调用已安装 skill 中声明的工具。必须传 functionName(或funcName) 与 arguments(或params);functionName 的值等于工具定义中的 toolName;arguments 是包含 bundleName 和业务参数字段的对象;完整业务参数定义见 references/tools/<bundleName>__<toolName>.json。",
|
|
1080
|
+
parameters: {
|
|
1081
|
+
type: "object",
|
|
1082
|
+
properties: {
|
|
1083
|
+
functionName: {
|
|
1084
|
+
type: "string",
|
|
1085
|
+
minLength: 1,
|
|
1086
|
+
description: "工具名称,值等于对应 references/tools JSON 中的 toolName,如 weather_query(优先使用;funcName 已废弃但仍兼容)。",
|
|
1087
|
+
},
|
|
1088
|
+
funcName: {
|
|
1089
|
+
type: "string",
|
|
1090
|
+
minLength: 1,
|
|
1091
|
+
description: "[废弃] 请使用 functionName 替代。工具名称,值等于对应 references/tools JSON 中的 toolName。",
|
|
1092
|
+
},
|
|
1093
|
+
arguments: {
|
|
1094
|
+
type: "object",
|
|
1095
|
+
description: "包含定位字段 bundleName 与业务参数字段;除 bundleName 外的字段遵循对应 references/tools JSON 中的 arguments schema(优先使用;params 已废弃但仍兼容)。",
|
|
1096
|
+
properties: {
|
|
1097
|
+
bundleName: {
|
|
1098
|
+
type: "string",
|
|
1099
|
+
minLength: 1,
|
|
1100
|
+
description: "HarmonyOS 应用唯一标识,如 com.example.weather。",
|
|
1101
|
+
},
|
|
1102
|
+
},
|
|
1103
|
+
required: ["bundleName"],
|
|
1104
|
+
additionalProperties: true,
|
|
1105
|
+
},
|
|
1106
|
+
params: {
|
|
1107
|
+
type: "object",
|
|
1108
|
+
description: "[废弃] 请使用 arguments 替代。包含定位字段 bundleName 与业务参数字段。",
|
|
1109
|
+
properties: {
|
|
1110
|
+
bundleName: {
|
|
1111
|
+
type: "string",
|
|
1112
|
+
minLength: 1,
|
|
1113
|
+
description: "HarmonyOS 应用唯一标识,如 com.example.weather。",
|
|
1114
|
+
},
|
|
1115
|
+
},
|
|
1116
|
+
required: ["bundleName"],
|
|
1117
|
+
additionalProperties: true,
|
|
1118
|
+
},
|
|
1119
|
+
},
|
|
1120
|
+
required: [],
|
|
1121
|
+
additionalProperties: false,
|
|
1122
|
+
},
|
|
1123
|
+
async execute(toolCallId, rawParams, _signal, _onUpdate) {
|
|
1124
|
+
// Layer 1: input validation
|
|
1125
|
+
const validated = validateAndExtract(rawParams);
|
|
1126
|
+
if ("content" in validated) {
|
|
1127
|
+
logger.warn("[INVOKE] input validation failed", { toolCallId });
|
|
1128
|
+
return validated;
|
|
1129
|
+
}
|
|
1130
|
+
const { toolName, bundleName, businessParams } = validated;
|
|
1131
|
+
const businessKeys = Object.keys(businessParams);
|
|
1132
|
+
logger.log("[INVOKE] tool called", {
|
|
1133
|
+
toolCallId, toolName, bundleName,
|
|
1134
|
+
businessKeys: businessKeys.join(","),
|
|
1135
|
+
businessKeysCount: businessKeys.length,
|
|
1136
|
+
});
|
|
1137
|
+
// Layer 2: cache lookup
|
|
1138
|
+
const cache = getToolCache();
|
|
1139
|
+
const entry = cache.get(bundleName, toolName);
|
|
1140
|
+
if (!entry) {
|
|
1141
|
+
const conflict = cache.getConflict(bundleName, toolName);
|
|
1142
|
+
if (conflict) {
|
|
1143
|
+
const files = conflict.entries.map((e) => e.filePath).join(", ");
|
|
1144
|
+
logger.warn("[INVOKE] tool conflict", { toolCallId, toolName, bundleName, conflictCount: conflict.entries.length });
|
|
1145
|
+
return invokeErrorToResult(new InvokeError("TOOL_CONFLICT", `Multiple conflicting definitions for '${toolName}' in bundle '${bundleName}': ${files}`));
|
|
1146
|
+
}
|
|
1147
|
+
logger.warn("[INVOKE] tool not found", { toolCallId, toolName, bundleName });
|
|
1148
|
+
return invokeErrorToResult(new InvokeError("TOOL_NOT_FOUND", `Tool '${toolName}' not found in bundle '${bundleName}'.`));
|
|
1149
|
+
}
|
|
1150
|
+
const { definition, skillName } = entry;
|
|
1151
|
+
logger.log("[INVOKE] cache hit", {
|
|
1152
|
+
toolCallId, toolName, bundleName, skillName,
|
|
1153
|
+
pluginType: definition.pluginType,
|
|
1154
|
+
protocol: definition.protocol ?? "N/A",
|
|
1155
|
+
});
|
|
1156
|
+
// Layer 2b: business param validation
|
|
1157
|
+
const paramError = validateBusinessParams(businessParams, definition);
|
|
1158
|
+
if (paramError) {
|
|
1159
|
+
logger.warn("[INVOKE] business param validation failed", { toolCallId, toolName, bundleName });
|
|
1160
|
+
return paramError;
|
|
1161
|
+
}
|
|
1162
|
+
// Layer 3: execute
|
|
1163
|
+
const pluginType = definition.pluginType;
|
|
1164
|
+
logger.log(`[INVOKE] dispatching to ${pluginType} executor`, { toolCallId, toolName, bundleName, pluginType });
|
|
1165
|
+
try {
|
|
1166
|
+
if (pluginType === "Cloud" || pluginType === "MCP") {
|
|
1167
|
+
const result = await executeCloudTool({ definition, businessParams, skillName, sessionId: ctx.sessionId, agentId: ctx.agentId });
|
|
1168
|
+
logger.log("[INVOKE] cloud execution succeeded", {
|
|
1169
|
+
toolCallId, toolName, bundleName, pluginType,
|
|
1170
|
+
resultLength: result.content[0]?.text?.length ?? 0,
|
|
1171
|
+
});
|
|
1172
|
+
return result;
|
|
1173
|
+
}
|
|
1174
|
+
if (pluginType === "Device") {
|
|
1175
|
+
const result = await executeDeviceTool({ definition, businessParams, toolCallId }, ctx);
|
|
1176
|
+
logger.log("[INVOKE] device execution succeeded", {
|
|
1177
|
+
toolCallId, toolName, bundleName,
|
|
1178
|
+
resultLength: result.content[0]?.text?.length ?? 0,
|
|
1179
|
+
});
|
|
1180
|
+
return result;
|
|
1181
|
+
}
|
|
1182
|
+
logger.warn("[INVOKE] unsupported pluginType", { toolCallId, toolName, bundleName, pluginType });
|
|
1183
|
+
return invokeErrorToResult(new InvokeError("UNSUPPORTED_PLUGIN_TYPE", `Unsupported pluginType '${pluginType}'. Must be Cloud, Device, or MCP.`));
|
|
1184
|
+
}
|
|
1185
|
+
catch (err) {
|
|
1186
|
+
if (err instanceof InvokeError) {
|
|
1187
|
+
logger.warn("[INVOKE] invocation error", {
|
|
1188
|
+
toolCallId, toolName, bundleName, pluginType,
|
|
1189
|
+
errorCode: err.code, errorMessage: err.message, retryable: err.retryable,
|
|
1190
|
+
});
|
|
1191
|
+
return invokeErrorToResult(err);
|
|
1192
|
+
}
|
|
1193
|
+
logger.error("[INVOKE] unexpected execution error", {
|
|
1194
|
+
toolCallId, toolName, bundleName, pluginType,
|
|
1195
|
+
error: err instanceof Error ? `${err.name}: ${err.message}` : String(err),
|
|
1196
|
+
});
|
|
1197
|
+
return unknownErrorToResult(err);
|
|
1198
|
+
}
|
|
1199
|
+
},
|
|
1200
|
+
};
|
|
1201
|
+
}
|