@delexec/ops 0.1.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.
- package/README.md +3 -0
- package/README.zh-CN.md +6 -0
- package/node_modules/@delexec/caller-controller/README.md +3 -0
- package/node_modules/@delexec/caller-controller/README.zh-CN.md +6 -0
- package/node_modules/@delexec/caller-controller/package.json +53 -0
- package/node_modules/@delexec/caller-controller/src/server.js +127 -0
- package/node_modules/@delexec/caller-controller-core/README.md +3 -0
- package/node_modules/@delexec/caller-controller-core/README.zh-CN.md +6 -0
- package/node_modules/@delexec/caller-controller-core/package.json +26 -0
- package/node_modules/@delexec/caller-controller-core/src/index.js +1612 -0
- package/node_modules/@delexec/caller-skill-adapter/package.json +12 -0
- package/node_modules/@delexec/caller-skill-adapter/src/server.js +1042 -0
- package/node_modules/@delexec/caller-skill-mcp-adapter/README.md +65 -0
- package/node_modules/@delexec/caller-skill-mcp-adapter/package.json +16 -0
- package/node_modules/@delexec/caller-skill-mcp-adapter/src/server.js +527 -0
- package/node_modules/@delexec/responder-controller/README.md +3 -0
- package/node_modules/@delexec/responder-controller/README.zh-CN.md +6 -0
- package/node_modules/@delexec/responder-controller/package.json +53 -0
- package/node_modules/@delexec/responder-controller/src/server.js +254 -0
- package/node_modules/@delexec/responder-runtime-core/README.md +3 -0
- package/node_modules/@delexec/responder-runtime-core/README.zh-CN.md +6 -0
- package/node_modules/@delexec/responder-runtime-core/package.json +26 -0
- package/node_modules/@delexec/responder-runtime-core/src/executors.js +326 -0
- package/node_modules/@delexec/responder-runtime-core/src/index.js +1202 -0
- package/node_modules/@delexec/runtime-utils/README.md +3 -0
- package/node_modules/@delexec/runtime-utils/README.zh-CN.md +6 -0
- package/node_modules/@delexec/runtime-utils/package.json +23 -0
- package/node_modules/@delexec/runtime-utils/src/index.js +338 -0
- package/node_modules/@delexec/sqlite-store/README.md +3 -0
- package/node_modules/@delexec/sqlite-store/README.zh-CN.md +6 -0
- package/node_modules/@delexec/sqlite-store/package.json +26 -0
- package/node_modules/@delexec/sqlite-store/src/index.js +68 -0
- package/node_modules/@delexec/transport-email/README.md +3 -0
- package/node_modules/@delexec/transport-email/README.zh-CN.md +6 -0
- package/node_modules/@delexec/transport-email/package.json +23 -0
- package/node_modules/@delexec/transport-email/src/index.js +185 -0
- package/node_modules/@delexec/transport-emailengine/README.md +3 -0
- package/node_modules/@delexec/transport-emailengine/README.zh-CN.md +6 -0
- package/node_modules/@delexec/transport-emailengine/package.json +26 -0
- package/node_modules/@delexec/transport-emailengine/src/index.js +210 -0
- package/node_modules/@delexec/transport-gmail/README.md +3 -0
- package/node_modules/@delexec/transport-gmail/README.zh-CN.md +6 -0
- package/node_modules/@delexec/transport-gmail/package.json +26 -0
- package/node_modules/@delexec/transport-gmail/src/index.js +295 -0
- package/node_modules/@delexec/transport-relay-http/README.md +3 -0
- package/node_modules/@delexec/transport-relay-http/README.zh-CN.md +6 -0
- package/node_modules/@delexec/transport-relay-http/package.json +23 -0
- package/node_modules/@delexec/transport-relay-http/src/index.js +124 -0
- package/package.json +64 -0
- package/src/cli.js +1571 -0
- package/src/config.js +1180 -0
- package/src/example-hotline-worker.js +65 -0
- package/src/example-hotline.js +196 -0
- package/src/logging.js +56 -0
- package/src/supervisor.js +3070 -0
|
@@ -0,0 +1,1042 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import http from "node:http";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
import { ensureOpsDirectories, getOpsHomeDir, readJsonFile, writeJsonFile } from "@delexec/runtime-utils";
|
|
8
|
+
|
|
9
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
10
|
+
const __dirname = path.dirname(__filename);
|
|
11
|
+
const OPS_SESSION_HEADER = "X-Ops-Session";
|
|
12
|
+
|
|
13
|
+
function loadDisplayHintsMap() {
|
|
14
|
+
const map = new Map();
|
|
15
|
+
try {
|
|
16
|
+
const contractsRoot = path.resolve(__dirname, "../../../packages/caller-controller-core/node_modules/@delexec/contracts");
|
|
17
|
+
const altRoot = path.resolve(__dirname, "../../../../node_modules/@delexec/contracts");
|
|
18
|
+
const localRoot = path.resolve(__dirname, "../../../../../repos/protocol/docs/templates/hotlines");
|
|
19
|
+
const roots = [
|
|
20
|
+
localRoot,
|
|
21
|
+
path.join(contractsRoot, "templates/hotlines"),
|
|
22
|
+
path.join(altRoot, "templates/hotlines")
|
|
23
|
+
];
|
|
24
|
+
for (const root of roots) {
|
|
25
|
+
if (!fs.existsSync(root)) continue;
|
|
26
|
+
for (const hotlineId of fs.readdirSync(root)) {
|
|
27
|
+
const hintsPath = path.join(root, hotlineId, "output_display_hints.json");
|
|
28
|
+
if (fs.existsSync(hintsPath)) {
|
|
29
|
+
try {
|
|
30
|
+
map.set(hotlineId, JSON.parse(fs.readFileSync(hintsPath, "utf8")));
|
|
31
|
+
} catch {
|
|
32
|
+
// ignore malformed hints
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
if (map.size > 0) break;
|
|
37
|
+
}
|
|
38
|
+
} catch {
|
|
39
|
+
// non-fatal
|
|
40
|
+
}
|
|
41
|
+
return map;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const displayHintsMap = loadDisplayHintsMap();
|
|
45
|
+
|
|
46
|
+
function sendJson(res, statusCode, data) {
|
|
47
|
+
res.writeHead(statusCode, {
|
|
48
|
+
"content-type": "application/json; charset=utf-8",
|
|
49
|
+
"access-control-allow-origin": "*",
|
|
50
|
+
"access-control-allow-methods": "GET,POST,OPTIONS",
|
|
51
|
+
"access-control-allow-headers": "Content-Type"
|
|
52
|
+
});
|
|
53
|
+
res.end(JSON.stringify(data));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function structuredError(code, message, extra = {}) {
|
|
57
|
+
return {
|
|
58
|
+
ok: false,
|
|
59
|
+
error: {
|
|
60
|
+
code,
|
|
61
|
+
message,
|
|
62
|
+
retryable: false,
|
|
63
|
+
...extra
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function normalizedString(value) {
|
|
69
|
+
if (value === undefined || value === null) {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
const trimmed = String(value).trim();
|
|
73
|
+
return trimmed || null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function nowIso() {
|
|
77
|
+
return new Date().toISOString();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function buildCallerHeaders() {
|
|
81
|
+
const headers = {};
|
|
82
|
+
if (process.env.CALLER_PLATFORM_API_KEY || process.env.PLATFORM_API_KEY) {
|
|
83
|
+
headers["X-Platform-Api-Key"] = process.env.CALLER_PLATFORM_API_KEY || process.env.PLATFORM_API_KEY;
|
|
84
|
+
}
|
|
85
|
+
return headers;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function callerBaseUrl() {
|
|
89
|
+
return process.env.CALLER_CONTROLLER_BASE_URL || `http://127.0.0.1:${process.env.CALLER_CONTROLLER_PORT || 8081}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function supervisorBaseUrl() {
|
|
93
|
+
return process.env.OPS_SUPERVISOR_BASE_URL || `http://127.0.0.1:${process.env.OPS_PORT_SUPERVISOR || 8079}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function readOpsSessionToken() {
|
|
97
|
+
const session = readJsonFile(path.join(ensureOpsDirectories(), "run", "session.json"), null);
|
|
98
|
+
if (!session?.token || !session?.expires_at) {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
const expiresAt = Date.parse(session.expires_at);
|
|
102
|
+
if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
return String(session.token);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function sanitizeHotlineIdForFileName(hotlineId) {
|
|
109
|
+
return String(hotlineId || "")
|
|
110
|
+
.toLowerCase()
|
|
111
|
+
.replace(/[^a-z0-9.-]+/g, "-")
|
|
112
|
+
.replace(/^-+|-+$/g, "");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function ensureCallerSkillDirectories() {
|
|
116
|
+
ensureOpsDirectories();
|
|
117
|
+
const preparedDir = path.join(getOpsHomeDir(), "prepared-requests");
|
|
118
|
+
fs.mkdirSync(preparedDir, { recursive: true, mode: 0o700 });
|
|
119
|
+
return {
|
|
120
|
+
preparedDir,
|
|
121
|
+
draftDir: path.join(getOpsHomeDir(), "hotline-registration-drafts")
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function getHotlineRegistrationDraftFile(hotlineId) {
|
|
126
|
+
const { draftDir } = ensureCallerSkillDirectories();
|
|
127
|
+
const safeName = sanitizeHotlineIdForFileName(hotlineId) || "hotline";
|
|
128
|
+
return path.join(draftDir, `${safeName}.registration.json`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function generatePreparedRequestId() {
|
|
132
|
+
return `prep_${crypto.randomUUID()}`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function getPreparedRequestFile(preparedRequestId) {
|
|
136
|
+
const { preparedDir } = ensureCallerSkillDirectories();
|
|
137
|
+
return path.join(preparedDir, `${preparedRequestId}.json`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function loadPreparedRequest(preparedRequestId) {
|
|
141
|
+
const filePath = getPreparedRequestFile(preparedRequestId);
|
|
142
|
+
return {
|
|
143
|
+
filePath,
|
|
144
|
+
record: readJsonFile(filePath, null)
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function savePreparedRequest(record) {
|
|
149
|
+
const filePath = getPreparedRequestFile(record.prepared_request_id);
|
|
150
|
+
writeJsonFile(filePath, record);
|
|
151
|
+
return filePath;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function invalidatePriorPreparedRequests(hotlineId, agentSessionId) {
|
|
155
|
+
if (!agentSessionId) {
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const { preparedDir } = ensureCallerSkillDirectories();
|
|
159
|
+
if (!fs.existsSync(preparedDir)) {
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
for (const name of fs.readdirSync(preparedDir)) {
|
|
163
|
+
if (!name.endsWith(".json")) continue;
|
|
164
|
+
const filePath = path.join(preparedDir, name);
|
|
165
|
+
const record = readJsonFile(filePath, null);
|
|
166
|
+
if (!record) continue;
|
|
167
|
+
if (record.hotline_id !== hotlineId) continue;
|
|
168
|
+
if (record.source_agent_session_id !== agentSessionId) continue;
|
|
169
|
+
if (!["draft", "ready"].includes(record.status)) continue;
|
|
170
|
+
record.status = "invalidated";
|
|
171
|
+
record.updated_at = nowIso();
|
|
172
|
+
writeJsonFile(filePath, record);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function isSafeLocalTextPath(targetPath) {
|
|
177
|
+
if (!targetPath || !path.isAbsolute(targetPath)) {
|
|
178
|
+
return { ok: false, reason: "LOCAL_FILE_PATH_REQUIRED" };
|
|
179
|
+
}
|
|
180
|
+
const ext = path.extname(targetPath).toLowerCase();
|
|
181
|
+
if (![".md", ".txt"].includes(ext)) {
|
|
182
|
+
return { ok: false, reason: "LOCAL_FILE_UNSUPPORTED_EXTENSION" };
|
|
183
|
+
}
|
|
184
|
+
const normalized = path.resolve(targetPath);
|
|
185
|
+
const parentDir = path.dirname(normalized);
|
|
186
|
+
const tempDir = fs.existsSync(parentDir)
|
|
187
|
+
? fs.realpathSync.native(parentDir)
|
|
188
|
+
: path.resolve(parentDir);
|
|
189
|
+
const mineruTempPrefixes = [
|
|
190
|
+
path.join("/var", "folders"),
|
|
191
|
+
path.join("/private", "var", "folders")
|
|
192
|
+
];
|
|
193
|
+
const explicitReadableRoots = [
|
|
194
|
+
"/Users/hejiajiudeeyu/Documents",
|
|
195
|
+
"/tmp"
|
|
196
|
+
];
|
|
197
|
+
const inExplicitRoot = explicitReadableRoots.some((root) => normalized.startsWith(path.resolve(root) + path.sep) || normalized === path.resolve(root));
|
|
198
|
+
const inMineruTemp = mineruTempPrefixes.some((prefix) => tempDir.startsWith(prefix));
|
|
199
|
+
if (!inExplicitRoot && !inMineruTemp) {
|
|
200
|
+
return { ok: false, reason: "LOCAL_FILE_PATH_NOT_ALLOWED" };
|
|
201
|
+
}
|
|
202
|
+
return { ok: true, path: normalized };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function readLocalTextFile(targetPath) {
|
|
206
|
+
const guard = isSafeLocalTextPath(targetPath);
|
|
207
|
+
if (!guard.ok) {
|
|
208
|
+
return {
|
|
209
|
+
status: 400,
|
|
210
|
+
body: structuredError(guard.reason, "Local file path is not allowed or not supported", {
|
|
211
|
+
path: targetPath || null
|
|
212
|
+
})
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
if (!fs.existsSync(guard.path)) {
|
|
216
|
+
return {
|
|
217
|
+
status: 404,
|
|
218
|
+
body: structuredError("LOCAL_FILE_NOT_FOUND", "Local text file does not exist", {
|
|
219
|
+
path: guard.path
|
|
220
|
+
})
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
const stats = fs.statSync(guard.path);
|
|
224
|
+
if (!stats.isFile()) {
|
|
225
|
+
return {
|
|
226
|
+
status: 400,
|
|
227
|
+
body: structuredError("LOCAL_FILE_NOT_REGULAR", "Local path must point to a regular file", {
|
|
228
|
+
path: guard.path
|
|
229
|
+
})
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
const maxBytes = Number(process.env.LOCAL_TEXT_FILE_MAX_BYTES || 120000);
|
|
233
|
+
const content = fs.readFileSync(guard.path, "utf8");
|
|
234
|
+
const truncated = Buffer.byteLength(content, "utf8") > maxBytes;
|
|
235
|
+
const finalContent = truncated ? content.slice(0, maxBytes) : content;
|
|
236
|
+
return {
|
|
237
|
+
status: 200,
|
|
238
|
+
body: {
|
|
239
|
+
ok: true,
|
|
240
|
+
path: guard.path,
|
|
241
|
+
content: finalContent,
|
|
242
|
+
truncated,
|
|
243
|
+
bytes_read: Buffer.byteLength(finalContent, "utf8")
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function parseJsonBody(req) {
|
|
249
|
+
return new Promise((resolve, reject) => {
|
|
250
|
+
const chunks = [];
|
|
251
|
+
req.on("data", (chunk) => chunks.push(chunk));
|
|
252
|
+
req.on("end", () => {
|
|
253
|
+
if (chunks.length === 0) {
|
|
254
|
+
resolve({});
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
try {
|
|
258
|
+
resolve(JSON.parse(Buffer.concat(chunks).toString("utf8")));
|
|
259
|
+
} catch {
|
|
260
|
+
reject(new Error("invalid_json"));
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
req.on("error", reject);
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function requestRawJson(baseUrl, pathname, { method = "GET", headers = {}, body } = {}) {
|
|
268
|
+
const response = await fetch(new URL(pathname, baseUrl), {
|
|
269
|
+
method,
|
|
270
|
+
headers: {
|
|
271
|
+
...headers,
|
|
272
|
+
...(body === undefined ? {} : { "content-type": "application/json; charset=utf-8" })
|
|
273
|
+
},
|
|
274
|
+
body: body === undefined ? undefined : JSON.stringify(body)
|
|
275
|
+
});
|
|
276
|
+
const text = await response.text();
|
|
277
|
+
return {
|
|
278
|
+
status: response.status,
|
|
279
|
+
body: text ? JSON.parse(text) : null
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function requestJson(baseUrl, pathname, { method = "GET", body } = {}) {
|
|
284
|
+
return requestRawJson(baseUrl, pathname, {
|
|
285
|
+
method,
|
|
286
|
+
headers: buildCallerHeaders(),
|
|
287
|
+
body
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async function requestSupervisorJson(pathname) {
|
|
292
|
+
const token = readOpsSessionToken();
|
|
293
|
+
return requestRawJson(supervisorBaseUrl(), pathname, {
|
|
294
|
+
headers: token ? { [OPS_SESSION_HEADER]: token } : {}
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async function requestCatalogItems(query = "") {
|
|
299
|
+
const suffix = query ? `?${query}` : "";
|
|
300
|
+
try {
|
|
301
|
+
const supervisor = await requestSupervisorJson(`/catalog/hotlines${suffix}`);
|
|
302
|
+
if (supervisor.status === 200) {
|
|
303
|
+
return supervisor;
|
|
304
|
+
}
|
|
305
|
+
} catch {
|
|
306
|
+
// The skill adapter can run without the Ops supervisor in tests or minimal setups.
|
|
307
|
+
}
|
|
308
|
+
return requestJson(callerBaseUrl(), `/controller/hotlines${suffix}`);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async function requestCatalogDetail(hotlineId) {
|
|
312
|
+
try {
|
|
313
|
+
const supervisor = await requestSupervisorJson(`/catalog/hotlines/${encodeURIComponent(hotlineId)}`);
|
|
314
|
+
if (supervisor.status === 200) {
|
|
315
|
+
return supervisor;
|
|
316
|
+
}
|
|
317
|
+
} catch {
|
|
318
|
+
// Fall back to the caller controller catalog below.
|
|
319
|
+
}
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function mapRequestState(request, result = null) {
|
|
324
|
+
const resultPackage = result?.result_package || request?.result_package || null;
|
|
325
|
+
return {
|
|
326
|
+
request_id: request?.request_id || null,
|
|
327
|
+
status: request?.status || "UNKNOWN",
|
|
328
|
+
hotline_id: request?.hotline_id || resultPackage?.hotline_id || null,
|
|
329
|
+
responder_id: request?.responder_id || resultPackage?.responder_id || null,
|
|
330
|
+
result: resultPackage?.output || null,
|
|
331
|
+
error: resultPackage?.error || null,
|
|
332
|
+
result_package: resultPackage,
|
|
333
|
+
human_summary: resultPackage?.human_summary || null
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function buildCallerSkillManifest() {
|
|
338
|
+
return {
|
|
339
|
+
skill: {
|
|
340
|
+
name: "caller-skill",
|
|
341
|
+
version: "0.1.0",
|
|
342
|
+
mode: "local_only",
|
|
343
|
+
description: "Progressive-disclosure caller skill for local hotline discovery, preparation, dispatch, and result reporting."
|
|
344
|
+
},
|
|
345
|
+
actions: [
|
|
346
|
+
{
|
|
347
|
+
name: "search_hotlines_brief",
|
|
348
|
+
method: "POST",
|
|
349
|
+
path: "/skills/caller/search-hotlines-brief",
|
|
350
|
+
description: "Fuzzy narrowing from a large hotline space into a short candidate list."
|
|
351
|
+
},
|
|
352
|
+
{
|
|
353
|
+
name: "search_hotlines_detailed",
|
|
354
|
+
method: "POST",
|
|
355
|
+
path: "/skills/caller/search-hotlines-detailed",
|
|
356
|
+
description: "Detailed comparison for a small candidate set before selection."
|
|
357
|
+
},
|
|
358
|
+
{
|
|
359
|
+
name: "read_hotline",
|
|
360
|
+
method: "GET",
|
|
361
|
+
path: "/skills/caller/hotlines/:hotlineId",
|
|
362
|
+
description: "Read the selected hotline contract and caller-facing template."
|
|
363
|
+
},
|
|
364
|
+
{
|
|
365
|
+
name: "prepare_request",
|
|
366
|
+
method: "POST",
|
|
367
|
+
path: "/skills/caller/prepare-request",
|
|
368
|
+
description: "Validate and normalize candidate input against the hotline schema."
|
|
369
|
+
},
|
|
370
|
+
{
|
|
371
|
+
name: "send_request",
|
|
372
|
+
method: "POST",
|
|
373
|
+
path: "/skills/caller/send-request",
|
|
374
|
+
description: "Send a previously prepared request and optionally wait for terminal state."
|
|
375
|
+
},
|
|
376
|
+
{
|
|
377
|
+
name: "report_response",
|
|
378
|
+
method: "GET",
|
|
379
|
+
path: "/skills/caller/requests/:requestId/report",
|
|
380
|
+
description: "Read and normalize request terminal state for agent consumption."
|
|
381
|
+
}
|
|
382
|
+
],
|
|
383
|
+
orchestration: {
|
|
384
|
+
search_phase_order: "flexible",
|
|
385
|
+
execution_phase_order: [
|
|
386
|
+
"read_hotline",
|
|
387
|
+
"prepare_request",
|
|
388
|
+
"send_request",
|
|
389
|
+
"report_response"
|
|
390
|
+
],
|
|
391
|
+
go_back_after_read_to: [
|
|
392
|
+
"search_hotlines_brief",
|
|
393
|
+
"search_hotlines_detailed"
|
|
394
|
+
],
|
|
395
|
+
polling_owner: "adapter"
|
|
396
|
+
}
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function mapCatalogBriefItem(item, score = null, matchReason = null) {
|
|
401
|
+
const source = item.source || (item.review_status && item.review_status !== "local_only" ? "platform" : "local");
|
|
402
|
+
return {
|
|
403
|
+
hotline_id: item.hotline_id,
|
|
404
|
+
display_name: item.display_name || item.hotline_id,
|
|
405
|
+
short_description: item.description || null,
|
|
406
|
+
task_types: item.task_types || [],
|
|
407
|
+
source,
|
|
408
|
+
match_reason: matchReason,
|
|
409
|
+
score
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function mapCatalogDetailedItem(item, draftInfo = null) {
|
|
414
|
+
const draft = draftInfo?.draft || null;
|
|
415
|
+
const localOnly = item.source === "local" || !item.review_status || item.review_status === "local_only";
|
|
416
|
+
return {
|
|
417
|
+
hotline_id: item.hotline_id,
|
|
418
|
+
responder_id: item.responder_id,
|
|
419
|
+
display_name: draft?.display_name || item.display_name || item.hotline_id,
|
|
420
|
+
description: draft?.description || item.description || null,
|
|
421
|
+
input_summary: draft?.input_summary || draft?.summary || null,
|
|
422
|
+
output_summary: draft?.output_summary || null,
|
|
423
|
+
task_types: draft?.task_types || item.task_types || [],
|
|
424
|
+
draft_ready: Boolean(draft),
|
|
425
|
+
local_only: localOnly,
|
|
426
|
+
review_status: item.review_status || "local_only"
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function computeCatalogMatch(item, queryTerms = [], taskGoalTerms = [], taskType = null) {
|
|
431
|
+
const haystacks = [
|
|
432
|
+
item.hotline_id,
|
|
433
|
+
item.display_name,
|
|
434
|
+
item.description,
|
|
435
|
+
...(item.task_types || []),
|
|
436
|
+
...(item.capabilities || []),
|
|
437
|
+
...(item.tags || [])
|
|
438
|
+
]
|
|
439
|
+
.filter(Boolean)
|
|
440
|
+
.map((entry) => String(entry).toLowerCase());
|
|
441
|
+
|
|
442
|
+
const allTerms = [...queryTerms, ...taskGoalTerms].filter(Boolean);
|
|
443
|
+
let score = 0;
|
|
444
|
+
const matched = new Set();
|
|
445
|
+
|
|
446
|
+
for (const term of allTerms) {
|
|
447
|
+
if (haystacks.some((value) => value.includes(term))) {
|
|
448
|
+
score += queryTerms.includes(term) ? 2 : 1;
|
|
449
|
+
matched.add(term);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
if (taskType && (item.task_types || []).some((entry) => String(entry).toLowerCase() === taskType.toLowerCase())) {
|
|
454
|
+
score += 3;
|
|
455
|
+
matched.add(`task_type:${taskType}`);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const matchReason = matched.size > 0 ? `matches ${Array.from(matched).join(", ")}` : null;
|
|
459
|
+
return { score, matchReason };
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function tokenizeSearchText(value) {
|
|
463
|
+
return normalizedString(value)
|
|
464
|
+
? normalizedString(value)
|
|
465
|
+
.toLowerCase()
|
|
466
|
+
.split(/[^a-z0-9._-]+/i)
|
|
467
|
+
.map((term) => term.trim())
|
|
468
|
+
.filter(Boolean)
|
|
469
|
+
: [];
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
async function waitForTerminalRequest(requestId, { timeoutMs, intervalMs } = {}) {
|
|
473
|
+
const startedAt = Date.now();
|
|
474
|
+
const maxWaitMs = Number.isFinite(Number(timeoutMs)) ? Number(timeoutMs) : Number(process.env.SKILL_MAX_WAIT_MS || 30000);
|
|
475
|
+
const pollEveryMs = Number.isFinite(Number(intervalMs)) ? Number(intervalMs) : Number(process.env.SKILL_POLL_INTERVAL_MS || 250);
|
|
476
|
+
while (Date.now() - startedAt < maxWaitMs) {
|
|
477
|
+
const request = await requestJson(callerBaseUrl(), `/controller/requests/${encodeURIComponent(requestId)}`);
|
|
478
|
+
if (request.status !== 200) {
|
|
479
|
+
return request;
|
|
480
|
+
}
|
|
481
|
+
const result = await requestJson(callerBaseUrl(), `/controller/requests/${encodeURIComponent(requestId)}/result`);
|
|
482
|
+
if (["SUCCEEDED", "FAILED", "UNVERIFIED", "TIMED_OUT"].includes(request.body?.status) || result.body?.available === true) {
|
|
483
|
+
return {
|
|
484
|
+
status: 200,
|
|
485
|
+
body: mapRequestState(request.body, result.body)
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
await new Promise((resolve) => setTimeout(resolve, pollEveryMs));
|
|
489
|
+
}
|
|
490
|
+
return {
|
|
491
|
+
status: 200,
|
|
492
|
+
body: {
|
|
493
|
+
request_id: requestId,
|
|
494
|
+
status: "PENDING",
|
|
495
|
+
result: null,
|
|
496
|
+
error: {
|
|
497
|
+
code: "SKILL_WAIT_TIMEOUT",
|
|
498
|
+
message: "request did not reach terminal state before skill timeout",
|
|
499
|
+
retryable: true
|
|
500
|
+
},
|
|
501
|
+
result_package: null,
|
|
502
|
+
human_summary: null
|
|
503
|
+
}
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
async function resolveCatalogTarget(hotlineId, responderId = null) {
|
|
508
|
+
const supervisorTarget = responderId ? null : await requestCatalogDetail(hotlineId);
|
|
509
|
+
if (supervisorTarget?.status === 200) {
|
|
510
|
+
return {
|
|
511
|
+
status: 200,
|
|
512
|
+
body: supervisorTarget.body
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const params = new URLSearchParams();
|
|
517
|
+
if (hotlineId) {
|
|
518
|
+
params.set("hotline_id", hotlineId);
|
|
519
|
+
}
|
|
520
|
+
if (responderId) {
|
|
521
|
+
params.set("responder_id", responderId);
|
|
522
|
+
}
|
|
523
|
+
const catalog = await requestCatalogItems(params.toString());
|
|
524
|
+
if (catalog.status !== 200) {
|
|
525
|
+
return catalog;
|
|
526
|
+
}
|
|
527
|
+
const selected = (catalog.body?.items || []).find((item) => {
|
|
528
|
+
if (item.hotline_id !== hotlineId) {
|
|
529
|
+
return false;
|
|
530
|
+
}
|
|
531
|
+
if (responderId && item.responder_id !== responderId) {
|
|
532
|
+
return false;
|
|
533
|
+
}
|
|
534
|
+
return true;
|
|
535
|
+
});
|
|
536
|
+
if (!selected) {
|
|
537
|
+
return {
|
|
538
|
+
status: 404,
|
|
539
|
+
body: structuredError("HOTLINE_NOT_FOUND", "no catalog hotline matched the requested hotlineId", {
|
|
540
|
+
hotline_id: hotlineId,
|
|
541
|
+
responder_id: responderId
|
|
542
|
+
})
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
return {
|
|
546
|
+
status: 200,
|
|
547
|
+
body: selected
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function loadHotlineDraft(hotlineId) {
|
|
552
|
+
const draftFile = getHotlineRegistrationDraftFile(hotlineId);
|
|
553
|
+
return {
|
|
554
|
+
draft_file: draftFile,
|
|
555
|
+
draft: readJsonFile(draftFile, null)
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function buildReadHotlineResponse(selected, draftInfo) {
|
|
560
|
+
const draft = draftInfo.draft || {};
|
|
561
|
+
const localOnly = selected.source === "local" || !selected.review_status || selected.review_status === "local_only";
|
|
562
|
+
return {
|
|
563
|
+
hotline_id: selected.hotline_id,
|
|
564
|
+
responder_id: selected.responder_id,
|
|
565
|
+
display_name: draft.display_name || selected.display_name || selected.hotline_id,
|
|
566
|
+
description: draft.description || selected.description || null,
|
|
567
|
+
input_summary: draft.input_summary || draft.summary || null,
|
|
568
|
+
output_summary: draft.output_summary || null,
|
|
569
|
+
input_schema: draft.input_schema || null,
|
|
570
|
+
output_schema: draft.output_schema || null,
|
|
571
|
+
draft_ready: Boolean(draftInfo.draft),
|
|
572
|
+
draft_file: draftInfo.draft_file,
|
|
573
|
+
local_only: localOnly,
|
|
574
|
+
review_status: selected.review_status || "local_only",
|
|
575
|
+
task_types: draft.task_types || selected.task_types || [],
|
|
576
|
+
output_display_hints: displayHintsMap.get(selected.hotline_id) ?? null
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function normalizeValueBySchema(schema, value, field, errors, warnings) {
|
|
581
|
+
if (!schema || typeof schema !== "object") {
|
|
582
|
+
return value;
|
|
583
|
+
}
|
|
584
|
+
const type = schema.type;
|
|
585
|
+
if (Array.isArray(schema.enum) && !schema.enum.includes(value)) {
|
|
586
|
+
errors.push({
|
|
587
|
+
field,
|
|
588
|
+
code: "INVALID_ENUM_VALUE",
|
|
589
|
+
message: `${field} must be one of: ${schema.enum.join(", ")}`
|
|
590
|
+
});
|
|
591
|
+
return value;
|
|
592
|
+
}
|
|
593
|
+
if (!type) {
|
|
594
|
+
return value;
|
|
595
|
+
}
|
|
596
|
+
switch (type) {
|
|
597
|
+
case "string": {
|
|
598
|
+
if (typeof value !== "string") {
|
|
599
|
+
errors.push({
|
|
600
|
+
field,
|
|
601
|
+
code: "INVALID_TYPE",
|
|
602
|
+
message: `${field} must be a string`
|
|
603
|
+
});
|
|
604
|
+
return value;
|
|
605
|
+
}
|
|
606
|
+
const trimmed = value.trim();
|
|
607
|
+
if (value !== trimmed) {
|
|
608
|
+
warnings.push({
|
|
609
|
+
field,
|
|
610
|
+
code: "STRING_TRIMMED",
|
|
611
|
+
message: `${field} was trimmed`
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
return trimmed;
|
|
615
|
+
}
|
|
616
|
+
case "number":
|
|
617
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
618
|
+
errors.push({
|
|
619
|
+
field,
|
|
620
|
+
code: "INVALID_TYPE",
|
|
621
|
+
message: `${field} must be a number`
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
return value;
|
|
625
|
+
case "integer":
|
|
626
|
+
if (!Number.isInteger(value)) {
|
|
627
|
+
errors.push({
|
|
628
|
+
field,
|
|
629
|
+
code: "INVALID_TYPE",
|
|
630
|
+
message: `${field} must be an integer`
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
return value;
|
|
634
|
+
case "boolean":
|
|
635
|
+
if (typeof value !== "boolean") {
|
|
636
|
+
errors.push({
|
|
637
|
+
field,
|
|
638
|
+
code: "INVALID_TYPE",
|
|
639
|
+
message: `${field} must be a boolean`
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
return value;
|
|
643
|
+
case "array":
|
|
644
|
+
if (!Array.isArray(value)) {
|
|
645
|
+
errors.push({
|
|
646
|
+
field,
|
|
647
|
+
code: "INVALID_TYPE",
|
|
648
|
+
message: `${field} must be an array`
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
return value;
|
|
652
|
+
case "object":
|
|
653
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
654
|
+
errors.push({
|
|
655
|
+
field,
|
|
656
|
+
code: "INVALID_TYPE",
|
|
657
|
+
message: `${field} must be an object`
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
return value;
|
|
661
|
+
default:
|
|
662
|
+
return value;
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
function validatePreparedInput(inputSchema, candidateInput) {
|
|
667
|
+
const errors = [];
|
|
668
|
+
const warnings = [];
|
|
669
|
+
if (!inputSchema || typeof inputSchema !== "object" || inputSchema.type !== "object") {
|
|
670
|
+
return {
|
|
671
|
+
normalized_input: candidateInput || {},
|
|
672
|
+
errors: [
|
|
673
|
+
{
|
|
674
|
+
field: null,
|
|
675
|
+
code: "HOTLINE_INPUT_SCHEMA_UNSUPPORTED",
|
|
676
|
+
message: "hotline input schema must be an object schema"
|
|
677
|
+
}
|
|
678
|
+
],
|
|
679
|
+
warnings
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
const input = candidateInput && typeof candidateInput === "object" && !Array.isArray(candidateInput) ? candidateInput : {};
|
|
683
|
+
const properties = inputSchema.properties && typeof inputSchema.properties === "object" ? inputSchema.properties : {};
|
|
684
|
+
const required = Array.isArray(inputSchema.required) ? inputSchema.required : [];
|
|
685
|
+
const additionalProperties = inputSchema.additionalProperties;
|
|
686
|
+
const normalized = {};
|
|
687
|
+
|
|
688
|
+
for (const [field, value] of Object.entries(input)) {
|
|
689
|
+
if (!Object.prototype.hasOwnProperty.call(properties, field)) {
|
|
690
|
+
if (additionalProperties === false) {
|
|
691
|
+
errors.push({
|
|
692
|
+
field,
|
|
693
|
+
code: "UNEXPECTED_FIELD",
|
|
694
|
+
message: `${field} is not allowed by the hotline input schema`
|
|
695
|
+
});
|
|
696
|
+
} else {
|
|
697
|
+
normalized[field] = value;
|
|
698
|
+
}
|
|
699
|
+
continue;
|
|
700
|
+
}
|
|
701
|
+
normalized[field] = normalizeValueBySchema(properties[field], value, field, errors, warnings);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
for (const field of required) {
|
|
705
|
+
if (!Object.prototype.hasOwnProperty.call(normalized, field)) {
|
|
706
|
+
errors.push({
|
|
707
|
+
field,
|
|
708
|
+
code: "REQUIRED_FIELD_MISSING",
|
|
709
|
+
message: `${field} is required`
|
|
710
|
+
});
|
|
711
|
+
continue;
|
|
712
|
+
}
|
|
713
|
+
const def = properties[field];
|
|
714
|
+
if (def?.type === "string" && typeof normalized[field] === "string" && normalized[field].length === 0) {
|
|
715
|
+
errors.push({
|
|
716
|
+
field,
|
|
717
|
+
code: "EMPTY_STRING_NOT_ALLOWED",
|
|
718
|
+
message: `${field} must not be empty`
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
return {
|
|
724
|
+
normalized_input: normalized,
|
|
725
|
+
errors,
|
|
726
|
+
warnings
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function buildPreparedRequestRecord({ hotlineId, selected, draft, normalizedInput, errors, warnings, agentSessionId }) {
|
|
731
|
+
const preparedRequestId = generatePreparedRequestId();
|
|
732
|
+
const status = errors.length > 0 ? "draft" : "ready";
|
|
733
|
+
const createdAt = nowIso();
|
|
734
|
+
return {
|
|
735
|
+
prepared_request_id: preparedRequestId,
|
|
736
|
+
hotline_id: hotlineId,
|
|
737
|
+
responder_id: selected.responder_id,
|
|
738
|
+
task_type: draft?.task_types?.[0] || selected.task_types?.[0] || null,
|
|
739
|
+
expected_signer_public_key_pem: selected.responder_public_key_pem || null,
|
|
740
|
+
output_schema: draft?.output_schema || null,
|
|
741
|
+
normalized_input: normalizedInput,
|
|
742
|
+
errors,
|
|
743
|
+
warnings,
|
|
744
|
+
review: {
|
|
745
|
+
required: false,
|
|
746
|
+
status: "not_required"
|
|
747
|
+
},
|
|
748
|
+
status,
|
|
749
|
+
request_id: null,
|
|
750
|
+
created_at: createdAt,
|
|
751
|
+
updated_at: createdAt,
|
|
752
|
+
expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
|
|
753
|
+
source_agent_session_id: agentSessionId
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function preparedRequestExpired(record) {
|
|
758
|
+
return Boolean(record?.expires_at) && Date.parse(record.expires_at) <= Date.now();
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
async function buildRequestReport(requestId) {
|
|
762
|
+
const request = await requestJson(callerBaseUrl(), `/controller/requests/${encodeURIComponent(requestId)}`);
|
|
763
|
+
if (request.status !== 200) {
|
|
764
|
+
return request;
|
|
765
|
+
}
|
|
766
|
+
const result = await requestJson(callerBaseUrl(), `/controller/requests/${encodeURIComponent(requestId)}/result`);
|
|
767
|
+
return {
|
|
768
|
+
status: 200,
|
|
769
|
+
body: mapRequestState(request.body, result.body)
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
export function createCallerSkillAdapterServer() {
|
|
774
|
+
return http.createServer(async (req, res) => {
|
|
775
|
+
const method = req.method || "GET";
|
|
776
|
+
const url = new URL(req.url || "/", "http://localhost");
|
|
777
|
+
const pathname = url.pathname;
|
|
778
|
+
|
|
779
|
+
try {
|
|
780
|
+
if (method === "OPTIONS") {
|
|
781
|
+
sendJson(res, 204, {});
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
if (method === "GET" && pathname === "/healthz") {
|
|
786
|
+
sendJson(res, 200, { ok: true, service: "caller-skill-adapter" });
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
if (method === "GET" && pathname === "/skills/caller/manifest") {
|
|
791
|
+
sendJson(res, 200, buildCallerSkillManifest());
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
if (method === "POST" && pathname === "/skills/local-file/read") {
|
|
796
|
+
const body = await parseJsonBody(req);
|
|
797
|
+
const result = readLocalTextFile(normalizedString(body.path));
|
|
798
|
+
sendJson(res, result.status, result.body);
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
if (method === "POST" && pathname === "/skills/caller/search-hotlines-brief") {
|
|
803
|
+
const body = await parseJsonBody(req);
|
|
804
|
+
const queryTerms = tokenizeSearchText(body.query);
|
|
805
|
+
const taskGoalTerms = tokenizeSearchText(body.task_goal || body.taskGoal);
|
|
806
|
+
const taskType = normalizedString(body.task_type || body.taskType);
|
|
807
|
+
const limit = Math.max(1, Math.min(Number(body.limit || 8), 25));
|
|
808
|
+
const catalog = await requestCatalogItems();
|
|
809
|
+
if (catalog.status !== 200) {
|
|
810
|
+
sendJson(res, catalog.status, catalog.body);
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
const ranked = (catalog.body?.items || [])
|
|
815
|
+
.map((item) => {
|
|
816
|
+
const { score, matchReason } = computeCatalogMatch(item, queryTerms, taskGoalTerms, taskType);
|
|
817
|
+
return {
|
|
818
|
+
item: mapCatalogBriefItem(item, score, matchReason),
|
|
819
|
+
score
|
|
820
|
+
};
|
|
821
|
+
})
|
|
822
|
+
.filter((entry) => queryTerms.length === 0 && taskGoalTerms.length === 0 && !taskType ? true : entry.score > 0)
|
|
823
|
+
.sort((left, right) => right.score - left.score || left.item.hotline_id.localeCompare(right.item.hotline_id))
|
|
824
|
+
.slice(0, limit)
|
|
825
|
+
.map((entry) => entry.item);
|
|
826
|
+
|
|
827
|
+
sendJson(res, 200, { items: ranked });
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
if (method === "POST" && pathname === "/skills/caller/search-hotlines-detailed") {
|
|
832
|
+
const body = await parseJsonBody(req);
|
|
833
|
+
const hotlineIds = Array.isArray(body.hotline_ids || body.hotlineIds)
|
|
834
|
+
? (body.hotline_ids || body.hotlineIds).map((entry) => normalizedString(entry)).filter(Boolean)
|
|
835
|
+
: [];
|
|
836
|
+
if (hotlineIds.length === 0) {
|
|
837
|
+
sendJson(res, 400, structuredError("HOTLINE_IDS_REQUIRED", "hotline_ids must contain at least one hotline id"));
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
const catalog = await requestCatalogItems();
|
|
842
|
+
if (catalog.status !== 200) {
|
|
843
|
+
sendJson(res, catalog.status, catalog.body);
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
const items = hotlineIds
|
|
848
|
+
.map((hotlineId) => (catalog.body?.items || []).find((item) => item.hotline_id === hotlineId))
|
|
849
|
+
.filter(Boolean)
|
|
850
|
+
.map((item) => mapCatalogDetailedItem(item, loadHotlineDraft(item.hotline_id)));
|
|
851
|
+
|
|
852
|
+
sendJson(res, 200, { items });
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
const readHotlineMatch = pathname.match(/^\/skills\/caller\/hotlines\/([^/]+)$/);
|
|
857
|
+
if (method === "GET" && readHotlineMatch) {
|
|
858
|
+
const hotlineId = decodeURIComponent(readHotlineMatch[1]);
|
|
859
|
+
const target = await resolveCatalogTarget(hotlineId);
|
|
860
|
+
if (target.status !== 200) {
|
|
861
|
+
sendJson(res, target.status, target.body);
|
|
862
|
+
return;
|
|
863
|
+
}
|
|
864
|
+
const draftInfo = loadHotlineDraft(hotlineId);
|
|
865
|
+
if (!draftInfo.draft) {
|
|
866
|
+
sendJson(res, 404, structuredError("HOTLINE_DRAFT_NOT_FOUND", "hotline registration draft was not found", {
|
|
867
|
+
hotline_id: hotlineId,
|
|
868
|
+
draft_file: draftInfo.draft_file
|
|
869
|
+
}));
|
|
870
|
+
return;
|
|
871
|
+
}
|
|
872
|
+
sendJson(res, 200, buildReadHotlineResponse(target.body, draftInfo));
|
|
873
|
+
return;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
if (method === "POST" && pathname === "/skills/caller/prepare-request") {
|
|
877
|
+
const body = await parseJsonBody(req);
|
|
878
|
+
const hotlineId = normalizedString(body.hotline_id || body.hotlineId);
|
|
879
|
+
if (!hotlineId) {
|
|
880
|
+
sendJson(res, 400, structuredError("HOTLINE_ID_REQUIRED", "hotline_id is required"));
|
|
881
|
+
return;
|
|
882
|
+
}
|
|
883
|
+
const target = await resolveCatalogTarget(hotlineId, normalizedString(body.responder_id || body.responderId));
|
|
884
|
+
if (target.status !== 200) {
|
|
885
|
+
sendJson(res, target.status, target.body);
|
|
886
|
+
return;
|
|
887
|
+
}
|
|
888
|
+
const draftInfo = loadHotlineDraft(hotlineId);
|
|
889
|
+
if (!draftInfo.draft) {
|
|
890
|
+
sendJson(res, 404, structuredError("HOTLINE_DRAFT_NOT_FOUND", "hotline registration draft was not found", {
|
|
891
|
+
hotline_id: hotlineId,
|
|
892
|
+
draft_file: draftInfo.draft_file
|
|
893
|
+
}));
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
const agentSessionId = normalizedString(body.agent_session_id || body.agentSessionId);
|
|
898
|
+
invalidatePriorPreparedRequests(hotlineId, agentSessionId);
|
|
899
|
+
|
|
900
|
+
const validation = validatePreparedInput(draftInfo.draft.input_schema, body.input);
|
|
901
|
+
const record = buildPreparedRequestRecord({
|
|
902
|
+
hotlineId,
|
|
903
|
+
selected: target.body,
|
|
904
|
+
draft: draftInfo.draft,
|
|
905
|
+
normalizedInput: validation.normalized_input,
|
|
906
|
+
errors: validation.errors,
|
|
907
|
+
warnings: validation.warnings,
|
|
908
|
+
agentSessionId
|
|
909
|
+
});
|
|
910
|
+
savePreparedRequest(record);
|
|
911
|
+
|
|
912
|
+
sendJson(res, 200, {
|
|
913
|
+
prepared_request_id: record.prepared_request_id,
|
|
914
|
+
hotline_id: record.hotline_id,
|
|
915
|
+
status: record.status,
|
|
916
|
+
normalized_input: record.normalized_input,
|
|
917
|
+
errors: record.errors,
|
|
918
|
+
warnings: record.warnings,
|
|
919
|
+
review: record.review,
|
|
920
|
+
expires_at: record.expires_at
|
|
921
|
+
});
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
if (method === "POST" && pathname === "/skills/caller/send-request") {
|
|
926
|
+
const body = await parseJsonBody(req);
|
|
927
|
+
const preparedRequestId = normalizedString(body.prepared_request_id || body.preparedRequestId);
|
|
928
|
+
if (!preparedRequestId) {
|
|
929
|
+
sendJson(res, 400, structuredError("PREPARED_REQUEST_ID_REQUIRED", "prepared_request_id is required"));
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
932
|
+
const { filePath, record } = loadPreparedRequest(preparedRequestId);
|
|
933
|
+
if (!record) {
|
|
934
|
+
sendJson(res, 404, structuredError("PREPARED_REQUEST_NOT_FOUND", "prepared request was not found", {
|
|
935
|
+
prepared_request_id: preparedRequestId
|
|
936
|
+
}));
|
|
937
|
+
return;
|
|
938
|
+
}
|
|
939
|
+
if (preparedRequestExpired(record)) {
|
|
940
|
+
record.status = "expired";
|
|
941
|
+
record.updated_at = nowIso();
|
|
942
|
+
writeJsonFile(filePath, record);
|
|
943
|
+
sendJson(res, 409, structuredError("PREPARED_REQUEST_EXPIRED", "prepared request has expired", {
|
|
944
|
+
prepared_request_id: preparedRequestId
|
|
945
|
+
}));
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
948
|
+
if (record.status !== "ready") {
|
|
949
|
+
sendJson(res, 409, structuredError("PREPARED_REQUEST_NOT_READY", "prepared request is not ready to send", {
|
|
950
|
+
prepared_request_id: preparedRequestId,
|
|
951
|
+
status: record.status,
|
|
952
|
+
errors: record.errors || []
|
|
953
|
+
}));
|
|
954
|
+
return;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
const createBody = {
|
|
958
|
+
responder_id: record.responder_id,
|
|
959
|
+
hotline_id: record.hotline_id,
|
|
960
|
+
expected_signer_public_key_pem: record.expected_signer_public_key_pem,
|
|
961
|
+
task_type: record.task_type,
|
|
962
|
+
input: record.normalized_input,
|
|
963
|
+
payload: record.normalized_input,
|
|
964
|
+
output_schema: record.output_schema
|
|
965
|
+
};
|
|
966
|
+
|
|
967
|
+
const created = await requestJson(callerBaseUrl(), "/controller/requests", {
|
|
968
|
+
method: "POST",
|
|
969
|
+
body: createBody
|
|
970
|
+
});
|
|
971
|
+
if (created.status !== 201) {
|
|
972
|
+
sendJson(res, created.status, created.body);
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
const requestId = created.body?.request_id;
|
|
977
|
+
await requestJson(callerBaseUrl(), `/controller/requests/${encodeURIComponent(requestId)}/contract-draft`, {
|
|
978
|
+
method: "POST",
|
|
979
|
+
body: {
|
|
980
|
+
...createBody,
|
|
981
|
+
task_input: record.normalized_input
|
|
982
|
+
}
|
|
983
|
+
});
|
|
984
|
+
|
|
985
|
+
const dispatched = await requestJson(callerBaseUrl(), `/controller/requests/${encodeURIComponent(requestId)}/dispatch`, {
|
|
986
|
+
method: "POST",
|
|
987
|
+
body: {
|
|
988
|
+
...createBody,
|
|
989
|
+
task_input: record.normalized_input
|
|
990
|
+
}
|
|
991
|
+
});
|
|
992
|
+
if (![200, 202].includes(dispatched.status)) {
|
|
993
|
+
sendJson(res, dispatched.status, dispatched.body);
|
|
994
|
+
return;
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
record.status = "sent";
|
|
998
|
+
record.request_id = requestId;
|
|
999
|
+
record.updated_at = nowIso();
|
|
1000
|
+
writeJsonFile(filePath, record);
|
|
1001
|
+
|
|
1002
|
+
const wait = body.wait !== false;
|
|
1003
|
+
if (!wait) {
|
|
1004
|
+
sendJson(res, 202, {
|
|
1005
|
+
request_id: requestId,
|
|
1006
|
+
hotline_id: record.hotline_id,
|
|
1007
|
+
status: "PENDING"
|
|
1008
|
+
});
|
|
1009
|
+
return;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
const terminal = await waitForTerminalRequest(requestId);
|
|
1013
|
+
sendJson(res, terminal.status, terminal.body);
|
|
1014
|
+
return;
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
const reportMatch = pathname.match(/^\/skills\/caller\/requests\/([^/]+)\/report$/);
|
|
1018
|
+
if (method === "GET" && reportMatch) {
|
|
1019
|
+
const requestId = decodeURIComponent(reportMatch[1]);
|
|
1020
|
+
const report = await buildRequestReport(requestId);
|
|
1021
|
+
sendJson(res, report.status, report.body);
|
|
1022
|
+
return;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
sendJson(res, 404, structuredError("NOT_FOUND", "unknown route"));
|
|
1026
|
+
} catch (error) {
|
|
1027
|
+
sendJson(
|
|
1028
|
+
res,
|
|
1029
|
+
500,
|
|
1030
|
+
structuredError("SKILL_ADAPTER_RUNTIME_ERROR", error instanceof Error ? error.message : "unknown_error")
|
|
1031
|
+
);
|
|
1032
|
+
}
|
|
1033
|
+
});
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === __filename) {
|
|
1037
|
+
const port = Number(process.env.PORT || 8091);
|
|
1038
|
+
const server = createCallerSkillAdapterServer();
|
|
1039
|
+
server.listen(port, "0.0.0.0", () => {
|
|
1040
|
+
console.log(`[caller-skill-adapter] listening on ${port}`);
|
|
1041
|
+
});
|
|
1042
|
+
}
|