agent-bridge-hermes 0.0.0-stage → 1.0.2
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 +6 -2
- package/config.schema.json +24 -0
- package/dist/src-ts/agent.js +69 -0
- package/dist/src-ts/attachments.js +62 -0
- package/dist/src-ts/plugin.js +13 -0
- package/dist/src-ts/plugins/agent-task-ability.js +36 -0
- package/dist/src-ts/plugins/hermes/client.js +145 -0
- package/dist/src-ts/plugins/hermes/gateway-client.py +110 -0
- package/dist/src-ts/plugins/hermes/index.js +122 -0
- package/package.json +30 -4
- package/plugin.json +14 -0
package/README.md
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Hermes Desktop 插件
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
通过官方 Python 客户端连接同用户的 Hermes 本地 Gateway,支持健康检查、文本及附件输入、新建与恢复会话。需要先安装官方 Hermes Desktop,并在相同用户下完成模型配置。插件复用 Hermes 的模型设置,不读取或另存模型 KEY。
|
|
4
|
+
|
|
5
|
+
Windows 默认安装根目录为 `%LOCALAPPDATA%\hermes`,其他系统为 `~/.hermes`;自定义安装可配置 `hermesHome` 和 `pythonPath`。Python 入口通过官方依赖管理器加载当前环境。网关鉴权复用官方同用户私有管道票据,不开放远程网关端口。
|
|
6
|
+
|
|
7
|
+
会话返回 Hermes 的真实 ID,保存该 ID 即可续聊;`newSession` 创建独立会话。支持文本文件等普通文件以及 PNG/JPEG/WebP 图片输入,文件经官方 `file.attach` 引用,图片经 `image.attach_bytes` 及 `prompt.submit` 提交;不把附件内容伪装成文本。单个附件最多 32 MiB、每条最多 8 个、合计最多 64 MiB,并受 Hermes 自身更严格的限制约束。附件暂存失败时不提交任务。暂未实现模型生成附件的回传。任务按调用顺序执行,不自动批准工具或交互确认;需要确认时请在桌面操作。取消、卸载与超时只停止等待,已经提交的桌面任务可能继续运行;请在桌面检查,避免盲目重发。
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"type": "object",
|
|
3
|
+
"properties": {
|
|
4
|
+
"aid": {
|
|
5
|
+
"type": "string",
|
|
6
|
+
"description": "关联的 AID"
|
|
7
|
+
},
|
|
8
|
+
"hermesHome": {
|
|
9
|
+
"type": "string",
|
|
10
|
+
"description": "Hermes 安装根目录,默认使用当前用户官方安装目录"
|
|
11
|
+
},
|
|
12
|
+
"pythonPath": {
|
|
13
|
+
"type": "string",
|
|
14
|
+
"description": "Python 可执行文件,默认优先使用 Hermes 自带版本"
|
|
15
|
+
},
|
|
16
|
+
"timeoutMs": {
|
|
17
|
+
"type": "integer",
|
|
18
|
+
"minimum": 1,
|
|
19
|
+
"default": 1200000,
|
|
20
|
+
"description": "等待回复的毫秒数;超时不终止桌面任务"
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"additionalProperties": false
|
|
24
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.AgentOperationError = exports.AgentError = void 0;
|
|
4
|
+
exports.abortable = abortable;
|
|
5
|
+
exports.errorDetails = errorDetails;
|
|
6
|
+
async function abortable(promise, signal) {
|
|
7
|
+
if (!signal)
|
|
8
|
+
return promise;
|
|
9
|
+
if (signal.aborted) {
|
|
10
|
+
// promise 已在调用方启动,先观察其拒绝,避免预取消时产生未处理拒绝。
|
|
11
|
+
void promise.catch(() => undefined);
|
|
12
|
+
signal.throwIfAborted();
|
|
13
|
+
}
|
|
14
|
+
return new Promise((resolve, reject) => {
|
|
15
|
+
let settled = false;
|
|
16
|
+
const cleanup = () => signal.removeEventListener("abort", abort);
|
|
17
|
+
const abort = () => {
|
|
18
|
+
if (settled)
|
|
19
|
+
return;
|
|
20
|
+
settled = true;
|
|
21
|
+
cleanup();
|
|
22
|
+
reject(signal.reason);
|
|
23
|
+
};
|
|
24
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
25
|
+
promise.then((value) => {
|
|
26
|
+
if (settled)
|
|
27
|
+
return;
|
|
28
|
+
settled = true;
|
|
29
|
+
cleanup();
|
|
30
|
+
resolve(value);
|
|
31
|
+
}, (error) => {
|
|
32
|
+
if (settled)
|
|
33
|
+
return;
|
|
34
|
+
settled = true;
|
|
35
|
+
cleanup();
|
|
36
|
+
reject(error);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
class AgentError extends Error {
|
|
41
|
+
constructor(message, options) {
|
|
42
|
+
super(message, options);
|
|
43
|
+
this.name = "AgentError";
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
exports.AgentError = AgentError;
|
|
47
|
+
/** 保留底层错误码,避免只留下 fetch failed 或超时这类表层提示。 */
|
|
48
|
+
function errorDetails(error) {
|
|
49
|
+
const parts = [];
|
|
50
|
+
const seen = new Set();
|
|
51
|
+
for (let current = error; current && !seen.has(current) && seen.size < 5;) {
|
|
52
|
+
seen.add(current);
|
|
53
|
+
const value = current;
|
|
54
|
+
const message = typeof value.message === "string" ? value.message : String(current);
|
|
55
|
+
parts.push(`${message}${value.code ? `(${String(value.code)})` : ""}`);
|
|
56
|
+
current = value.cause;
|
|
57
|
+
}
|
|
58
|
+
return parts.join(";原因:") || String(error);
|
|
59
|
+
}
|
|
60
|
+
class AgentOperationError extends AgentError {
|
|
61
|
+
code;
|
|
62
|
+
stage;
|
|
63
|
+
constructor(code, stage, cause) {
|
|
64
|
+
super(`${code}:${stage}失败:${errorDetails(cause)}`, { cause });
|
|
65
|
+
this.code = code;
|
|
66
|
+
this.stage = stage;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
exports.AgentOperationError = AgentOperationError;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.MAX_TOTAL_ATTACHMENT_BYTES = exports.MAX_ATTACHMENTS = exports.MAX_ATTACHMENT_BYTES = void 0;
|
|
4
|
+
exports.detectImageContentType = detectImageContentType;
|
|
5
|
+
exports.attachmentFilename = attachmentFilename;
|
|
6
|
+
exports.validateAttachment = validateAttachment;
|
|
7
|
+
exports.validateAttachments = validateAttachments;
|
|
8
|
+
exports.asAttachments = asAttachments;
|
|
9
|
+
exports.MAX_ATTACHMENT_BYTES = 32 * 1024 * 1024;
|
|
10
|
+
exports.MAX_ATTACHMENTS = 8;
|
|
11
|
+
exports.MAX_TOTAL_ATTACHMENT_BYTES = 64 * 1024 * 1024;
|
|
12
|
+
function detectImageContentType(data) {
|
|
13
|
+
if (data.length >= 8 && data.slice(0, 8).every((value, index) => value === [137, 80, 78, 71, 13, 10, 26, 10][index]))
|
|
14
|
+
return "image/png";
|
|
15
|
+
if (data.length >= 3 && data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff)
|
|
16
|
+
return "image/jpeg";
|
|
17
|
+
if (data.length >= 12 && String.fromCharCode(...data.slice(0, 4)) === "RIFF" && String.fromCharCode(...data.slice(8, 12)) === "WEBP")
|
|
18
|
+
return "image/webp";
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
function attachmentFilename(filename) {
|
|
22
|
+
if (typeof filename !== "string" || /[\u0000-\u001f\u007f]/.test(filename))
|
|
23
|
+
throw new Error("附件文件名无效");
|
|
24
|
+
const name = filename.split(/[\\/]/).pop() ?? "";
|
|
25
|
+
if (!name.trim() || name === "." || name === "..")
|
|
26
|
+
throw new Error("附件文件名无效");
|
|
27
|
+
return name;
|
|
28
|
+
}
|
|
29
|
+
function validateAttachment(item) {
|
|
30
|
+
if (!item || !(item.data instanceof Uint8Array) || item.data.byteLength === 0)
|
|
31
|
+
throw new Error("附件内容不能为空");
|
|
32
|
+
if (item.data.byteLength > exports.MAX_ATTACHMENT_BYTES)
|
|
33
|
+
throw new Error("单个附件不能超过 32 MiB");
|
|
34
|
+
attachmentFilename(item.filename);
|
|
35
|
+
if (item.kind !== "image" && item.kind !== "file")
|
|
36
|
+
throw new Error("附件类型无效");
|
|
37
|
+
if (typeof item.contentType !== "string" || !/^[A-Za-z0-9!#$&^_.+-]+\/[A-Za-z0-9!#$&^_.+-]+$/.test(item.contentType))
|
|
38
|
+
throw new Error("附件 MIME 类型无效");
|
|
39
|
+
if (item.kind === "image") {
|
|
40
|
+
const detected = detectImageContentType(item.data);
|
|
41
|
+
if (!detected || detected !== item.contentType.toLowerCase())
|
|
42
|
+
throw new Error("图片内容与 MIME 类型不一致");
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function validateAttachments(items) {
|
|
46
|
+
if (!items)
|
|
47
|
+
return;
|
|
48
|
+
if (!Array.isArray(items))
|
|
49
|
+
throw new Error("附件列表无效");
|
|
50
|
+
if (items.length > exports.MAX_ATTACHMENTS)
|
|
51
|
+
throw new Error("单条消息最多包含 8 个附件");
|
|
52
|
+
let total = 0;
|
|
53
|
+
for (const item of items) {
|
|
54
|
+
validateAttachment(item);
|
|
55
|
+
total += item.data.byteLength;
|
|
56
|
+
}
|
|
57
|
+
if (total > exports.MAX_TOTAL_ATTACHMENT_BYTES)
|
|
58
|
+
throw new Error("单条消息附件总大小不能超过 64 MiB");
|
|
59
|
+
}
|
|
60
|
+
function asAttachments(images) {
|
|
61
|
+
return (images ?? []).map((image) => ({ ...image, kind: "image" }));
|
|
62
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.resolveRuntimePort = resolveRuntimePort;
|
|
4
|
+
function resolveRuntimePort(config, runtime, pluginId, envName, fallback) {
|
|
5
|
+
const injected = runtime?.ports?.[pluginId];
|
|
6
|
+
if (typeof injected === "number" && Number.isInteger(injected) && injected > 0)
|
|
7
|
+
return injected;
|
|
8
|
+
const configured = config.cdpPort;
|
|
9
|
+
if (typeof configured === "number" && Number.isInteger(configured) && configured > 0)
|
|
10
|
+
return configured;
|
|
11
|
+
const environment = Number(process.env[envName] ?? "");
|
|
12
|
+
return Number.isInteger(environment) && environment > 0 ? environment : fallback;
|
|
13
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createTaskAbility = createTaskAbility;
|
|
4
|
+
const node_crypto_1 = require("node:crypto");
|
|
5
|
+
const agent_1 = require("../agent");
|
|
6
|
+
function createTaskAbility(handle) {
|
|
7
|
+
return {
|
|
8
|
+
definition: {
|
|
9
|
+
path: "agent/task/run",
|
|
10
|
+
name: "执行大模型任务",
|
|
11
|
+
summary: "在独立新会话中执行自然语言任务并返回文字;可能使用目标 Agent 的工具、读写文件及访问网络,须逐次确认。图片和文件不通过此接口交付。",
|
|
12
|
+
contract_revision: "1",
|
|
13
|
+
params_schema: { type: "object", required: ["task"], properties: { task: { type: "string", minLength: 1, maxLength: 20_000, pattern: "\\S" } }, additionalProperties: false },
|
|
14
|
+
result_schema: { type: "object", required: ["text"], properties: { text: { type: "string", minLength: 1, maxLength: 60_000 }, attachments_omitted: { type: "integer", minimum: 1 } }, additionalProperties: false },
|
|
15
|
+
effects: ["state.read", "state.write", "data.egress", "data.local_write", "credential.access", "code.execute", "system.interact"],
|
|
16
|
+
auth_required: true, require_confirm: true, idempotency: "keyed",
|
|
17
|
+
allowed_modes: ["explicit"], aggregation: "none", dedupe_retention_seconds: 3600,
|
|
18
|
+
},
|
|
19
|
+
handler: async (params, { signal }) => {
|
|
20
|
+
signal.throwIfAborted();
|
|
21
|
+
const task = params?.task;
|
|
22
|
+
if (!params || typeof params !== "object" || Array.isArray(params) || Object.keys(params).length !== 1 || typeof task !== "string" || !task.trim() || task.length > 20_000)
|
|
23
|
+
throw new Error("任务参数必须仅包含 1 至 20,000 字符的非空 task");
|
|
24
|
+
const response = await (0, agent_1.abortable)(handle({ task, sessionId: (0, node_crypto_1.randomUUID)(), newSession: true, signal, attachmentOutput: false }), signal);
|
|
25
|
+
const omitted = response.attachments?.length || response.images?.length || 0;
|
|
26
|
+
let text = response.text?.trim() ?? "";
|
|
27
|
+
if (omitted)
|
|
28
|
+
text += `${text ? "\n\n" : ""}附件尚未通过本次 Ability 交付(${omitted} 个);请使用现有聊天附件入口获取。`;
|
|
29
|
+
if (!text)
|
|
30
|
+
throw new Error("任务未返回文字或附件,不能确认执行结果");
|
|
31
|
+
if (text.length > 60_000)
|
|
32
|
+
throw new Error("任务返回文字超过 60,000 字符,未截断或宣称交付完成");
|
|
33
|
+
return { text, ...(omitted ? { attachments_omitted: omitted } : {}) };
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.HermesGatewayClient = void 0;
|
|
4
|
+
exports.sessionTitle = sessionTitle;
|
|
5
|
+
const attachments_1 = require("../../attachments");
|
|
6
|
+
const node_fs_1 = require("node:fs");
|
|
7
|
+
const promises_1 = require("node:fs/promises");
|
|
8
|
+
const node_os_1 = require("node:os");
|
|
9
|
+
const node_path_1 = require("node:path");
|
|
10
|
+
const node_crypto_1 = require("node:crypto");
|
|
11
|
+
const node_child_process_1 = require("node:child_process");
|
|
12
|
+
function parseJsonLines(output) {
|
|
13
|
+
for (const line of output.split(/\r?\n/).reverse()) {
|
|
14
|
+
const text = line.trim();
|
|
15
|
+
if (!text || text.startsWith("<Objs"))
|
|
16
|
+
continue;
|
|
17
|
+
try {
|
|
18
|
+
return JSON.parse(text);
|
|
19
|
+
}
|
|
20
|
+
catch { /* 安装器的日志行不是协议响应,继续找最后一个 JSON。 */ }
|
|
21
|
+
}
|
|
22
|
+
throw new Error("Hermes Gateway 未返回 JSON 状态");
|
|
23
|
+
}
|
|
24
|
+
async function findPython(root, configured) {
|
|
25
|
+
if (configured)
|
|
26
|
+
return configured;
|
|
27
|
+
try {
|
|
28
|
+
for (const entry of (await (0, promises_1.readdir)((0, node_path_1.join)(root, "tools"))).sort().reverse()) {
|
|
29
|
+
if (!entry.startsWith("python-"))
|
|
30
|
+
continue;
|
|
31
|
+
const candidate = process.platform === "win32" ? (0, node_path_1.join)(root, "tools", entry, "python.exe") : (0, node_path_1.join)(root, "tools", entry, "bin", "python3");
|
|
32
|
+
if ((0, node_fs_1.existsSync)(candidate))
|
|
33
|
+
return candidate;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
catch { /* 非引导安装使用 PATH 中的 Python。 */ }
|
|
37
|
+
return process.platform === "win32" ? "python.exe" : "python3";
|
|
38
|
+
}
|
|
39
|
+
class PythonHermesTransport {
|
|
40
|
+
connectedState = false;
|
|
41
|
+
pending = new Set();
|
|
42
|
+
root;
|
|
43
|
+
python;
|
|
44
|
+
timeoutMs;
|
|
45
|
+
helper;
|
|
46
|
+
constructor(options) {
|
|
47
|
+
this.root = (0, node_path_1.resolve)(options.hermesHome ?? process.env.HERMES_HOME ?? (process.platform === "win32" ? (0, node_path_1.join)(process.env.LOCALAPPDATA ?? (0, node_path_1.join)((0, node_os_1.homedir)(), "AppData", "Local"), "hermes") : (0, node_path_1.join)((0, node_os_1.homedir)(), ".hermes")));
|
|
48
|
+
this.python = options.pythonPath;
|
|
49
|
+
this.timeoutMs = options.timeoutMs ?? 1_200_000;
|
|
50
|
+
this.helper = (0, node_path_1.join)((0, node_path_1.dirname)(__filename), "gateway-client.py");
|
|
51
|
+
}
|
|
52
|
+
async connect() {
|
|
53
|
+
await this.run({ op: "health" });
|
|
54
|
+
this.connectedState = true;
|
|
55
|
+
}
|
|
56
|
+
close() { this.connectedState = false; for (const cancel of this.pending)
|
|
57
|
+
cancel(); }
|
|
58
|
+
async createSession(title) {
|
|
59
|
+
this.requireConnected();
|
|
60
|
+
const value = await this.run({ op: "create", title });
|
|
61
|
+
if (!value || typeof value.sessionId !== "string" || !value.sessionId)
|
|
62
|
+
throw new Error("Hermes Gateway 未返回会话 ID");
|
|
63
|
+
return value.sessionId;
|
|
64
|
+
}
|
|
65
|
+
async resumeSession(sessionId) {
|
|
66
|
+
this.requireConnected();
|
|
67
|
+
await this.run({ op: "resume", sessionId });
|
|
68
|
+
}
|
|
69
|
+
async complete(task, sessionId, timeoutMs = this.timeoutMs, signal, attachments) {
|
|
70
|
+
this.requireConnected();
|
|
71
|
+
(0, attachments_1.validateAttachments)(attachments);
|
|
72
|
+
const encoded = attachments?.map(item => ({ kind: item.kind, filename: (0, attachments_1.attachmentFilename)(item.filename), contentType: item.contentType, data: Buffer.from(item.data).toString("base64") }));
|
|
73
|
+
const value = await this.run({ op: "complete", task, sessionId, timeoutMs, attachments: encoded }, signal, timeoutMs + 15_000);
|
|
74
|
+
if (!value || typeof value.sessionId !== "string" || typeof value.text !== "string" || !value.text.trim())
|
|
75
|
+
throw new Error("Hermes Gateway 未返回文字");
|
|
76
|
+
return { sessionId: value.sessionId, text: value.text };
|
|
77
|
+
}
|
|
78
|
+
requireConnected() { if (!this.connectedState)
|
|
79
|
+
throw new Error("Hermes Gateway 客户端未连接"); }
|
|
80
|
+
requireActive(payload) { if (payload.op !== "health")
|
|
81
|
+
this.requireConnected(); }
|
|
82
|
+
run(payload, signal, timeoutMs = 30_000) {
|
|
83
|
+
return new Promise((resolvePromise, reject) => {
|
|
84
|
+
if (!(0, node_fs_1.existsSync)(this.helper))
|
|
85
|
+
return reject(new Error("Hermes 插件缺少 Gateway 通信入口"));
|
|
86
|
+
void findPython(this.root, this.python).then(python => {
|
|
87
|
+
signal?.throwIfAborted();
|
|
88
|
+
this.requireActive(payload);
|
|
89
|
+
const child = (0, node_child_process_1.spawn)(python, [this.helper], { env: { ...process.env, PYTHONIOENCODING: "utf-8", PYTHONUTF8: "1", HERMES_HOME: this.root, HERMES_AGENT_SOURCE: (0, node_path_1.join)(this.root, "hermes-agent") }, stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
|
|
90
|
+
let stdout = "";
|
|
91
|
+
let settled = false;
|
|
92
|
+
const finish = (error, value) => { if (settled)
|
|
93
|
+
return; settled = true; this.pending.delete(cancel); clearTimeout(timer); signal?.removeEventListener("abort", abort); error ? reject(error) : resolvePromise(value ?? {}); };
|
|
94
|
+
const cancel = () => { child.kill(); finish(new Error("Hermes 已停止等待,桌面任务可能仍在运行,请在桌面检查")); };
|
|
95
|
+
const abort = cancel;
|
|
96
|
+
this.pending.add(cancel);
|
|
97
|
+
const timer = setTimeout(() => { child.kill(); finish(new Error("Hermes Gateway 等待超时,桌面任务可能仍在运行,请在桌面检查")); }, Math.max(1_000, timeoutMs));
|
|
98
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
99
|
+
child.stdout.setEncoding("utf8");
|
|
100
|
+
child.stdout.on("data", chunk => { stdout += String(chunk); if (stdout.length > 8 * 1024 * 1024)
|
|
101
|
+
cancel(); });
|
|
102
|
+
child.stderr.resume();
|
|
103
|
+
child.stdin.on("error", () => finish(new Error("Hermes Gateway 输入失败")));
|
|
104
|
+
child.on("error", () => finish(new Error("Hermes Python 启动失败,请检查 pythonPath")));
|
|
105
|
+
child.on("close", code => {
|
|
106
|
+
if (settled)
|
|
107
|
+
return;
|
|
108
|
+
let value;
|
|
109
|
+
try {
|
|
110
|
+
value = parseJsonLines(stdout);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
finish(new Error(`Hermes Gateway 通信进程退出(${code ?? "unknown"})`));
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (!value || typeof value !== "object" || value.ok !== true) {
|
|
117
|
+
const message = (value?.error ?? "Hermes Gateway 通信失败") || "Hermes Gateway 通信失败";
|
|
118
|
+
finish(new Error(String(message)));
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
finish(undefined, value);
|
|
122
|
+
});
|
|
123
|
+
child.stdin.end(JSON.stringify(payload), "utf8");
|
|
124
|
+
}).catch(error => reject(error));
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
class HermesGatewayClient {
|
|
129
|
+
transport;
|
|
130
|
+
isConnected = false;
|
|
131
|
+
constructor(options = {}) {
|
|
132
|
+
this.transport = options.transport ?? new PythonHermesTransport(options);
|
|
133
|
+
}
|
|
134
|
+
connected() { return this.isConnected; }
|
|
135
|
+
async connect() { await this.transport.connect(); this.isConnected = true; }
|
|
136
|
+
close() { this.transport.close(); this.isConnected = false; }
|
|
137
|
+
createSession(title) { return this.transport.createSession(title); }
|
|
138
|
+
resumeSession(sessionId) { return this.transport.resumeSession(sessionId); }
|
|
139
|
+
complete(task, sessionId, timeoutMs, signal, attachments) { return this.transport.complete(task, sessionId, timeoutMs, signal, attachments); }
|
|
140
|
+
}
|
|
141
|
+
exports.HermesGatewayClient = HermesGatewayClient;
|
|
142
|
+
function sessionTitle(task) {
|
|
143
|
+
const text = task.trim().replace(/\s+/g, " ");
|
|
144
|
+
return `Agent Bridge:${text.slice(0, 80) || (0, node_crypto_1.randomUUID)()}`;
|
|
145
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from contextlib import suppress
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import uuid
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def fail(message):
|
|
10
|
+
print(json.dumps({"ok": False, "error": str(message)[:500]}, ensure_ascii=False), flush=True)
|
|
11
|
+
raise SystemExit(1)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
async def main(request):
|
|
15
|
+
source = os.environ.get("HERMES_AGENT_SOURCE")
|
|
16
|
+
if not source:
|
|
17
|
+
fail("未找到 Hermes 源码目录")
|
|
18
|
+
sys.path.insert(0, source)
|
|
19
|
+
try:
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from pm.environments import activate_dependencies
|
|
22
|
+
activate_dependencies(Path(source))
|
|
23
|
+
from hermes_cli.gateway_client import connect_gateway
|
|
24
|
+
except Exception as exc:
|
|
25
|
+
fail("Hermes Gateway 客户端不可用,请检查官方安装及依赖")
|
|
26
|
+
os.environ.pop("HERMES_TUI_GATEWAY_URL", None)
|
|
27
|
+
os.environ.pop("HERMES_TUI_GATEWAY_TOKEN", None)
|
|
28
|
+
async with connect_gateway() as client:
|
|
29
|
+
async def drain_events():
|
|
30
|
+
while True:
|
|
31
|
+
await client.events.get()
|
|
32
|
+
# 事件队列必须持续消费,避免长回复阻塞官方客户端的 RPC 读取。
|
|
33
|
+
drain = asyncio.create_task(drain_events())
|
|
34
|
+
try:
|
|
35
|
+
return await operate(client, request)
|
|
36
|
+
finally:
|
|
37
|
+
drain.cancel()
|
|
38
|
+
with suppress(asyncio.CancelledError):
|
|
39
|
+
await drain
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
async def operate(client, request):
|
|
43
|
+
operation = request.get("op")
|
|
44
|
+
if operation == "health":
|
|
45
|
+
await client.rpc("session.list", scope="live", limit=1)
|
|
46
|
+
return {"ok": True}
|
|
47
|
+
if operation == "create":
|
|
48
|
+
snapshot = await client.rpc("session.create", source="gui", request_id=str(uuid.uuid4()), title=str(request.get("title") or "Agent Bridge"))
|
|
49
|
+
return {"ok": True, "sessionId": snapshot["session_id"]}
|
|
50
|
+
session_id = str(request.get("sessionId") or "")
|
|
51
|
+
if not session_id:
|
|
52
|
+
fail("Hermes 会话 ID 不能为空")
|
|
53
|
+
await client.rpc("session.resume", session_id=session_id)
|
|
54
|
+
if operation == "resume":
|
|
55
|
+
return {"ok": True}
|
|
56
|
+
if operation != "complete":
|
|
57
|
+
fail("不支持的 Hermes 操作")
|
|
58
|
+
text = str(request.get("task") or "")
|
|
59
|
+
images = []
|
|
60
|
+
try:
|
|
61
|
+
for item in request.get("attachments") or []:
|
|
62
|
+
if item["kind"] == "image":
|
|
63
|
+
staged = await client.rpc("image.attach_bytes", session_id=session_id,
|
|
64
|
+
content_base64=item["data"], filename=item["filename"])
|
|
65
|
+
if staged.get("attached") is not True:
|
|
66
|
+
raise ValueError("图片暂存未完成")
|
|
67
|
+
images.append({"path": staged["path"], "mime": staged["mime"]})
|
|
68
|
+
elif item["kind"] == "file":
|
|
69
|
+
staged = await client.rpc("file.attach", session_id=session_id,
|
|
70
|
+
data_url="data:" + item["contentType"] + ";base64," + item["data"],
|
|
71
|
+
name=item["filename"])
|
|
72
|
+
if staged.get("attached") is not True or not staged.get("ref_text"):
|
|
73
|
+
raise ValueError("文件暂存未完成")
|
|
74
|
+
text += "\n" + staged["ref_text"]
|
|
75
|
+
else:
|
|
76
|
+
raise ValueError("附件类型无效")
|
|
77
|
+
except Exception:
|
|
78
|
+
fail("Hermes 附件暂存失败,未提交任务,请检查桌面附件限制")
|
|
79
|
+
params = {"session_id": session_id, "text": text, "submission_id": str(uuid.uuid4())}
|
|
80
|
+
if images:
|
|
81
|
+
params["attachments"] = images
|
|
82
|
+
admission = await client.rpc("prompt.submit", **params)
|
|
83
|
+
admission_id = admission.get("admission_id")
|
|
84
|
+
if not admission_id:
|
|
85
|
+
fail("Hermes Gateway 未返回 admission_id")
|
|
86
|
+
deadline = asyncio.get_running_loop().time() + max(1, int(request.get("timeoutMs") or 1200000)) / 1000
|
|
87
|
+
while asyncio.get_running_loop().time() < deadline:
|
|
88
|
+
receipt = await client.rpc("prompt.receipt", session_id=session_id, admission_id=admission_id, include_result=True)
|
|
89
|
+
if receipt.get("status") == "terminal":
|
|
90
|
+
result = receipt.get("result") or {}
|
|
91
|
+
if receipt.get("outcome") != "completed":
|
|
92
|
+
fail("Hermes Gateway 任务未完成,请在桌面检查")
|
|
93
|
+
text = result.get("final_response")
|
|
94
|
+
if not isinstance(text, str) or not text.strip():
|
|
95
|
+
fail("Hermes Gateway 未返回文字")
|
|
96
|
+
return {"ok": True, "sessionId": session_id, "text": text}
|
|
97
|
+
await asyncio.sleep(0.5)
|
|
98
|
+
fail("Hermes Gateway 等待超时,桌面任务可能仍在运行,请在桌面检查")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
sys.stdin.reconfigure(encoding="utf-8")
|
|
103
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
104
|
+
raw = sys.stdin.read()
|
|
105
|
+
request = json.loads(raw)
|
|
106
|
+
print(json.dumps(asyncio.run(main(request)), ensure_ascii=False), flush=True)
|
|
107
|
+
except SystemExit:
|
|
108
|
+
raise
|
|
109
|
+
except Exception as exc:
|
|
110
|
+
fail("Hermes Gateway 通信失败,请在桌面检查连接与任务状态")
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.HermesPlugin = exports.hermesManifest = void 0;
|
|
4
|
+
const attachments_1 = require("../../attachments");
|
|
5
|
+
const agent_task_ability_1 = require("../agent-task-ability");
|
|
6
|
+
const agent_1 = require("../../agent");
|
|
7
|
+
const client_1 = require("./client");
|
|
8
|
+
exports.hermesManifest = {
|
|
9
|
+
id: "hermes", version: "1.0.2", displayName: "Hermes Desktop", type: "agent",
|
|
10
|
+
entry: "dist/src-ts/plugins/hermes/index.js", apiVersion: "1", process: "in-process",
|
|
11
|
+
capabilities: ["chat", "health"], configSchema: "config.schema.json",
|
|
12
|
+
};
|
|
13
|
+
class HermesPlugin {
|
|
14
|
+
options;
|
|
15
|
+
manifest = exports.hermesManifest;
|
|
16
|
+
abilities = [createReadinessAbility(() => this.health()), (0, agent_task_ability_1.createTaskAbility)(request => this.handle(request))];
|
|
17
|
+
sessionOperations = ["new"];
|
|
18
|
+
attachmentInput = true;
|
|
19
|
+
client;
|
|
20
|
+
failure;
|
|
21
|
+
timeoutMs = 1_200_000;
|
|
22
|
+
queue = Promise.resolve();
|
|
23
|
+
lifetime = new AbortController();
|
|
24
|
+
constructor(options = {}) {
|
|
25
|
+
this.options = options;
|
|
26
|
+
}
|
|
27
|
+
async activate(context) {
|
|
28
|
+
this.timeoutMs = typeof context.config.timeoutMs === "number" && context.config.timeoutMs > 0 ? context.config.timeoutMs : 1_200_000;
|
|
29
|
+
const options = {
|
|
30
|
+
hermesHome: typeof context.config.hermesHome === "string" ? context.config.hermesHome : this.options.hermesHome,
|
|
31
|
+
pythonPath: typeof context.config.pythonPath === "string" ? context.config.pythonPath : this.options.pythonPath,
|
|
32
|
+
timeoutMs: this.timeoutMs,
|
|
33
|
+
transport: this.options.transport,
|
|
34
|
+
};
|
|
35
|
+
this.failure = undefined;
|
|
36
|
+
this.lifetime = new AbortController();
|
|
37
|
+
this.client = this.options.client ?? new client_1.HermesGatewayClient(options);
|
|
38
|
+
try {
|
|
39
|
+
await this.client.connect();
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
this.failure = error instanceof Error ? error.message : String(error);
|
|
43
|
+
context.logger.warn("Hermes Gateway 连接失败", { error: this.failure });
|
|
44
|
+
this.client = undefined;
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
async deactivate(_reason, _options) {
|
|
49
|
+
this.lifetime.abort(new Error("Hermes 插件已停用"));
|
|
50
|
+
this.client?.close();
|
|
51
|
+
this.client = undefined;
|
|
52
|
+
}
|
|
53
|
+
async health() {
|
|
54
|
+
try {
|
|
55
|
+
if (this.client)
|
|
56
|
+
await this.client.connect();
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return { status: "failed", message: "Hermes Gateway 健康检查失败" };
|
|
60
|
+
}
|
|
61
|
+
return this.client?.connected()
|
|
62
|
+
? { status: "ready", details: { transport: "hermes-gateway" } }
|
|
63
|
+
: { status: "failed", message: this.failure ?? "Hermes Gateway 未连接", details: { transport: "hermes-gateway" } };
|
|
64
|
+
}
|
|
65
|
+
handle(request) {
|
|
66
|
+
const signal = request.signal ? AbortSignal.any([request.signal, this.lifetime.signal]) : this.lifetime.signal;
|
|
67
|
+
const result = this.queue.then(() => this.runTask({ ...request, signal }));
|
|
68
|
+
this.queue = result.catch(() => undefined);
|
|
69
|
+
return (0, agent_1.abortable)(result, request.signal);
|
|
70
|
+
}
|
|
71
|
+
async runTask(request) {
|
|
72
|
+
(0, attachments_1.validateAttachments)(request.attachments);
|
|
73
|
+
const task = request.task?.trim() || (request.attachments?.length ? "请处理附件" : "");
|
|
74
|
+
if (!task)
|
|
75
|
+
throw new Error("消息内容不能为空");
|
|
76
|
+
request.signal?.throwIfAborted();
|
|
77
|
+
const client = this.client;
|
|
78
|
+
if (!client?.connected())
|
|
79
|
+
throw new Error("Hermes 插件未连接 Gateway");
|
|
80
|
+
request.signal?.throwIfAborted();
|
|
81
|
+
const session = await this.resolveSession(client, request, task);
|
|
82
|
+
const result = await (0, agent_1.abortable)(client.complete(task, session, request.timeoutMs ?? this.timeoutMs, request.signal, request.attachments), request.signal);
|
|
83
|
+
return { text: result.text, sessionId: result.sessionId };
|
|
84
|
+
}
|
|
85
|
+
async controlSession(request) {
|
|
86
|
+
if (request.operation !== "new")
|
|
87
|
+
throw new Error(`Hermes 不支持会话操作:${String(request.operation)}`);
|
|
88
|
+
const sessionId = request.sessionId?.trim();
|
|
89
|
+
if (!sessionId)
|
|
90
|
+
throw new Error("Hermes 新会话缺少 sessionId");
|
|
91
|
+
const client = this.client;
|
|
92
|
+
if (!client?.connected())
|
|
93
|
+
throw new Error("Hermes 插件未连接 Gateway");
|
|
94
|
+
const backendId = await client.createSession((0, client_1.sessionTitle)(request.name ?? sessionId));
|
|
95
|
+
return { text: "Hermes 已创建新会话", sessionId: backendId };
|
|
96
|
+
}
|
|
97
|
+
async resolveSession(client, request, task) {
|
|
98
|
+
if (request.newSession || !request.sessionId) {
|
|
99
|
+
return client.createSession((0, client_1.sessionTitle)(task));
|
|
100
|
+
}
|
|
101
|
+
await client.resumeSession(request.sessionId);
|
|
102
|
+
return request.sessionId;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
exports.HermesPlugin = HermesPlugin;
|
|
106
|
+
function createReadinessAbility(health) {
|
|
107
|
+
return {
|
|
108
|
+
definition: {
|
|
109
|
+
path: "agent/status/readiness", name: "Agent 就绪状态", summary: "读取 Hermes Desktop 当前是否就绪。", contract_revision: "1",
|
|
110
|
+
params_schema: { type: "object", properties: {}, additionalProperties: false },
|
|
111
|
+
result_schema: { type: "object", required: ["status"], properties: { status: { type: "string", enum: ["ready", "degraded", "failed"] } }, additionalProperties: false },
|
|
112
|
+
effects: ["state.read"], auth_required: true, require_confirm: true, idempotency: "keyed", allowed_modes: ["explicit"], aggregation: "none", dedupe_retention_seconds: 3600,
|
|
113
|
+
},
|
|
114
|
+
handler: async (params) => {
|
|
115
|
+
if (!params || typeof params !== "object" || Array.isArray(params) || Object.keys(params).length)
|
|
116
|
+
throw new Error("readiness 参数必须为空对象");
|
|
117
|
+
const status = (await health()).status;
|
|
118
|
+
return { status: status === "ready" || status === "failed" ? status : "degraded" };
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
exports.default = HermesPlugin;
|
package/package.json
CHANGED
|
@@ -1,6 +1,32 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-bridge-hermes",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.2",
|
|
4
|
+
"description": "mu-agent-bridge 的 Hermes 桌面原生网关插件。",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"mu-agent-bridge-plugin",
|
|
7
|
+
"mu-agent-bridge-agent"
|
|
8
|
+
],
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "https://codeup.aliyun.com/6808da56fb3da14b899afd54/agent_bridge.git",
|
|
12
|
+
"directory": "packages/hermes"
|
|
13
|
+
},
|
|
14
|
+
"main": "dist/src-ts/plugins/hermes/index.js",
|
|
15
|
+
"files": [
|
|
16
|
+
"dist/",
|
|
17
|
+
"plugin.json",
|
|
18
|
+
"config.schema.json",
|
|
19
|
+
"README.md"
|
|
20
|
+
],
|
|
21
|
+
"agentBridge": {
|
|
22
|
+
"plugin": "./plugin.json",
|
|
23
|
+
"type": "agent"
|
|
24
|
+
},
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public",
|
|
27
|
+
"registry": "https://registry.npmjs.org"
|
|
28
|
+
},
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=22"
|
|
31
|
+
}
|
|
32
|
+
}
|
package/plugin.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "hermes",
|
|
3
|
+
"version": "1.0.2",
|
|
4
|
+
"displayName": "Hermes Desktop",
|
|
5
|
+
"type": "agent",
|
|
6
|
+
"entry": "dist/src-ts/plugins/hermes/index.js",
|
|
7
|
+
"apiVersion": "1",
|
|
8
|
+
"process": "in-process",
|
|
9
|
+
"capabilities": [
|
|
10
|
+
"chat",
|
|
11
|
+
"health"
|
|
12
|
+
],
|
|
13
|
+
"configSchema": "config.schema.json"
|
|
14
|
+
}
|