agent-bridge-hermes 0.0.0-stage → 1.0.3
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 +16 -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-abilities.js +124 -0
- package/dist/src-ts/plugins/agent-task-ability.js +36 -0
- package/dist/src-ts/plugins/hermes/client.js +188 -0
- package/dist/src-ts/plugins/hermes/gateway-client.py +252 -0
- package/dist/src-ts/plugins/hermes/index.js +148 -0
- package/package.json +30 -4
- package/plugin.json +14 -0
package/README.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
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
|
+
连接健康检查最多等待 120 秒,覆盖官方 Gateway 的 60 秒冷启动预算、依赖加载和首次 RPC。连接或会话操作超时明确提示未提交任务;生成任务仍使用 `timeoutMs`,超时不代表桌面任务已取消,也不会自动重发。
|
|
8
|
+
|
|
9
|
+
会话返回 Hermes 的真实 ID,保存该 ID 即可续聊;`newSession` 创建独立会话。支持文本文件等普通文件以及 PNG/JPEG/WebP 图片输入,文件经官方 `file.attach` 引用,图片经 `image.attach_bytes` 及 `prompt.submit` 提交;不把附件内容伪装成文本。单个附件最多 32 MiB、每条最多 8 个、合计最多 64 MiB,并受 Hermes 自身更严格的限制约束。附件暂存失败时不提交任务。暂未实现模型生成附件的回传。任务按调用顺序执行,不自动批准工具或交互确认;需要确认时请在桌面操作。取消、卸载与超时只停止等待,已经提交的桌面任务可能继续运行;请在桌面检查,避免盲目重发。
|
|
10
|
+
|
|
11
|
+
## 远端任务 Ability
|
|
12
|
+
|
|
13
|
+
当前源码声明 `agent/task/run`、`agent/task/recent`、`agent/task/read`、`agent/task/switch` 和 `agent/task/new`,使用原生 Gateway 接口。`run` 接受 `{"task":"需要执行的自然语言任务"}`,未选择任务时独立新建;`switch` 或 `new` 成功后,同一 Provider 与已认证 Peer 的后续 `run` 续聊并返回 `text`、持久会话 `sessionId`,重载清除选择。
|
|
14
|
+
|
|
15
|
+
`recent` 使用 Gateway 的持久化目录(`session.list scope=stored`),不宣称历史完整。`read` 只读 Gateway 已加载会话的用户和助手文字,不自动恢复或切换;未加载时需先显式 `switch`。`switch` 仅验证精确持久 ID,未加载的目标不在前 200 条可验证目录中时需先在桌面加载,正在运行的任务会拒绝切换或续聊;不以新建任务替代消失的目标。
|
|
16
|
+
|
|
17
|
+
需在 AUN 中验证 Provider 的 Owner、选择发布并逐次确认执行;升级不会自动公开。任务按目标 Agent 的权限执行,Ability 仅交付文字。参数和契约见仓库[桌面 Agent Ability 使用说明](../../docs/desktop-agent-ability.md)。
|
|
@@ -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,124 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.AgentTaskAbilities = void 0;
|
|
4
|
+
const agent_task_ability_1 = require("./agent-task-ability");
|
|
5
|
+
const idSchema = { type: "string", minLength: 1, maxLength: 512 };
|
|
6
|
+
const sessionSchema = { type: "object", required: ["id", "title"], properties: { id: idSchema, title: { type: "string" }, updatedAt: { type: "string" }, updatedLabel: { type: "string" }, active: { type: "boolean" } }, additionalProperties: false };
|
|
7
|
+
const pageProperties = { scope: { type: "string" }, complete: { type: "boolean" }, missingReasons: { type: "array", items: { type: "string" } }, readAt: { type: "string" }, nextCursor: { type: "string" } };
|
|
8
|
+
function params(value, fields) {
|
|
9
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).some(key => !fields.includes(key)))
|
|
10
|
+
throw new Error("任务能力参数无效或含未知字段");
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
13
|
+
function string(value, name, max) {
|
|
14
|
+
if (typeof value !== "string" || !value.trim() || value.length > max || /[\u0000-\u001f]/.test(value))
|
|
15
|
+
throw new Error(`任务${name}参数无效`);
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
function limit(value, max) {
|
|
19
|
+
if (value === undefined)
|
|
20
|
+
return 20;
|
|
21
|
+
if (!Number.isInteger(value) || Number(value) < 1 || Number(value) > max)
|
|
22
|
+
throw new Error(`任务分页参数必须为 1–${max}`);
|
|
23
|
+
return Number(value);
|
|
24
|
+
}
|
|
25
|
+
/** 只统一任务契约和远程选择;桌面身份、队列及会话恢复由插件验证。 */
|
|
26
|
+
class AgentTaskAbilities {
|
|
27
|
+
adapter;
|
|
28
|
+
selected = new Map();
|
|
29
|
+
generation = 0;
|
|
30
|
+
abilities;
|
|
31
|
+
constructor(adapter) {
|
|
32
|
+
this.adapter = adapter;
|
|
33
|
+
const task = (0, agent_task_ability_1.createTaskAbility)(async () => { throw new Error("任务入口未绑定"); });
|
|
34
|
+
this.abilities = [{
|
|
35
|
+
definition: { ...task.definition, contract_revision: "2", summary: "执行自然语言任务;本 Provider 与 Peer 经 switch/new 选择后续聊,否则独立新建。重载清除选择。空白页可能返回带 pending 前缀的临时逻辑句柄,首轮成功后返回原生 ID。仅交付文字,逐次确认;具体传输不支持时明确报错。", result_schema: { ...task.definition.result_schema, required: ["text", "sessionId"], properties: { ...task.definition.result_schema.properties, sessionId: idSchema } } },
|
|
36
|
+
handler: async (value, context) => {
|
|
37
|
+
const generation = this.generation;
|
|
38
|
+
let sessionId = "";
|
|
39
|
+
const run = (0, agent_task_ability_1.createTaskAbility)(request => this.execute(context, "run", async () => {
|
|
40
|
+
const key = context.callerAid ? this.key(context) : undefined;
|
|
41
|
+
const selectedId = key ? this.selected.get(key) : undefined;
|
|
42
|
+
const response = await this.adapter.run(request, selectedId, context);
|
|
43
|
+
context.signal.throwIfAborted();
|
|
44
|
+
this.checkGeneration(generation);
|
|
45
|
+
sessionId = string(response.sessionId, "标识", 512);
|
|
46
|
+
if (key && selectedId)
|
|
47
|
+
this.selected.set(key, sessionId);
|
|
48
|
+
return response;
|
|
49
|
+
}));
|
|
50
|
+
return { ...await run.handler(value, context), sessionId };
|
|
51
|
+
},
|
|
52
|
+
}, this.ability("recent", "最近任务", "只读目标插件原生任务目录,不切换或发送消息;scope、complete、missingReasons 说明目录范围。部分传输不支持。", { limit: { type: "integer", minimum: 1, maximum: 50 } }, [], { type: "object", required: ["sessions", "scope", "complete", "missingReasons", "readAt"], properties: { sessions: { type: "array", maxItems: 50, items: sessionSchema }, ...pageProperties }, additionalProperties: false }, ["state.read", "data.egress"], async (value, context) => {
|
|
53
|
+
const count = limit(params(value, ["limit"]).limit, 50);
|
|
54
|
+
return this.execute(context, "recent", async () => {
|
|
55
|
+
const page = await this.adapter.recent(count, context);
|
|
56
|
+
if (!page.supported)
|
|
57
|
+
throw new Error(page.reason);
|
|
58
|
+
if (page.sessions.length > count)
|
|
59
|
+
throw new Error("任务目录返回数量超过 limit,未截断或宣称完整");
|
|
60
|
+
const { supported: _supported, ...result } = page;
|
|
61
|
+
return { ...result, scope: this.adapter.scope("recent") };
|
|
62
|
+
});
|
|
63
|
+
}), this.ability("read", "读取任务正文", "只读指定任务可验证的用户和助理文字,不切换或发送;部分客户端要求先 switch。范围由 scope 和完整性字段说明。", { sessionId: idSchema, limit: { type: "integer", minimum: 1, maximum: 100 }, cursor: { type: "string", minLength: 1, maxLength: 4096 } }, ["sessionId"], { type: "object", required: ["session", "turns", "scope", "complete", "missingReasons", "readAt"], properties: { session: sessionSchema, turns: { type: "array", maxItems: 100, items: { type: "object", required: ["role", "text"], properties: { id: { type: "string" }, role: { type: "string", enum: ["user", "assistant"] }, text: { type: "string", maxLength: 60_000 }, time: { type: "string" } }, additionalProperties: false } }, ...pageProperties }, additionalProperties: false }, ["state.read", "data.egress"], async (value, context) => {
|
|
64
|
+
const input = params(value, ["sessionId", "limit", "cursor"]);
|
|
65
|
+
const query = { sessionId: string(input.sessionId, "标识", 512), limit: limit(input.limit, 100), ...(input.cursor === undefined ? {} : { cursor: string(input.cursor, "游标", 4096) }) };
|
|
66
|
+
return this.execute(context, "read", async () => {
|
|
67
|
+
const page = await this.adapter.read(query, context);
|
|
68
|
+
if (!page.supported)
|
|
69
|
+
throw new Error(page.reason);
|
|
70
|
+
if (page.session.id !== query.sessionId)
|
|
71
|
+
throw new Error("任务正文身份与请求不一致,未返回其他任务内容");
|
|
72
|
+
if (page.turns.length > query.limit || page.turns.reduce((total, turn) => total + turn.text.length, 0) > 60_000)
|
|
73
|
+
throw new Error("任务正文超过分页或 60,000 字符上限,请减小 limit;未截断");
|
|
74
|
+
const { supported: _supported, ...result } = page;
|
|
75
|
+
return { ...result, scope: this.adapter.scope("read") };
|
|
76
|
+
});
|
|
77
|
+
}), this.ability("switch", "切换任务", "只选择精确 ID 的已有任务,不发送消息;之后仅此 Provider 与认证 Peer 的 run 续聊。具体客户端不支持安全切换时明确报错。", { sessionId: idSchema }, ["sessionId"], this.selectionSchema(), ["state.read", "state.write", "system.interact"], (value, context) => this.select(value, context, false)),
|
|
78
|
+
this.ability("new", "新建任务", "新建并选择空白任务,不发送消息。未分配原生 ID 时返回带 pending 前缀的临时逻辑句柄,首轮 run 后返回原生 ID。仅本 Provider 与认证 Peer 使用,重载清除选择。", {}, [], this.selectionSchema(), ["state.read", "state.write", "system.interact"], (value, context) => this.select(value, context, true))];
|
|
79
|
+
}
|
|
80
|
+
reset() { this.generation++; this.selected.clear(); }
|
|
81
|
+
checkGeneration(generation) {
|
|
82
|
+
if (generation !== this.generation)
|
|
83
|
+
throw new Error("任务能力实例已重载,旧调用及选择已失效,请重新调用");
|
|
84
|
+
}
|
|
85
|
+
key(context) {
|
|
86
|
+
if (!context.callerAid)
|
|
87
|
+
throw new Error("缺少宿主认证的调用者身份,请升级 AUN 插件与后台后重新调用");
|
|
88
|
+
return JSON.stringify([context.providerAid, context.callerAid]);
|
|
89
|
+
}
|
|
90
|
+
async execute(context, action, work) {
|
|
91
|
+
const generation = this.generation;
|
|
92
|
+
context.signal.throwIfAborted();
|
|
93
|
+
return this.adapter.execute(context, action, async () => {
|
|
94
|
+
context.signal.throwIfAborted();
|
|
95
|
+
this.checkGeneration(generation);
|
|
96
|
+
const result = await work();
|
|
97
|
+
context.signal.throwIfAborted();
|
|
98
|
+
this.checkGeneration(generation);
|
|
99
|
+
return result;
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
async select(value, context, create) {
|
|
103
|
+
const generation = this.generation;
|
|
104
|
+
const input = params(value, create ? [] : ["sessionId"]);
|
|
105
|
+
const target = create ? undefined : string(input.sessionId, "标识", 512);
|
|
106
|
+
const key = this.key(context);
|
|
107
|
+
return this.execute(context, create ? "new" : "switch", async () => {
|
|
108
|
+
const selection = await this.adapter.select(target, context);
|
|
109
|
+
string(selection.sessionId, "标识", 512);
|
|
110
|
+
if (target && target !== selection.sessionId)
|
|
111
|
+
throw new Error("任务切换身份不一致,选择未保存");
|
|
112
|
+
context.signal.throwIfAborted();
|
|
113
|
+
this.checkGeneration(generation);
|
|
114
|
+
this.selected.set(key, selection.sessionId);
|
|
115
|
+
return selection;
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
selectionSchema() { return { type: "object", required: ["sessionId", "title"], properties: { sessionId: idSchema, title: { type: "string" } }, additionalProperties: false }; }
|
|
119
|
+
ability(action, name, summary, properties, required, result_schema, effects, handler) {
|
|
120
|
+
const definition = { path: `agent/task/${action}`, name, summary, contract_revision: "1", params_schema: { type: "object", properties, required, additionalProperties: false }, result_schema, effects, auth_required: true, require_confirm: true, idempotency: "keyed", allowed_modes: ["explicit"], aggregation: "none", dedupe_retention_seconds: 3600 };
|
|
121
|
+
return { definition, handler };
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
exports.AgentTaskAbilities = AgentTaskAbilities;
|
|
@@ -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,188 @@
|
|
|
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
|
+
// 官方冷启动预算为 60 秒,另留依赖加载、鉴权和首次 RPC 的时间。
|
|
54
|
+
await this.run({ op: "health" }, undefined, 120_000);
|
|
55
|
+
this.connectedState = true;
|
|
56
|
+
}
|
|
57
|
+
close() { this.connectedState = false; for (const cancel of this.pending)
|
|
58
|
+
cancel(); }
|
|
59
|
+
async createSession(title, signal) {
|
|
60
|
+
this.requireConnected();
|
|
61
|
+
const value = await this.run({ op: "create", title }, signal);
|
|
62
|
+
if (!value || typeof value.sessionId !== "string" || !value.sessionId)
|
|
63
|
+
throw new Error("Hermes Gateway 未返回会话 ID");
|
|
64
|
+
return value.sessionId;
|
|
65
|
+
}
|
|
66
|
+
async resumeSession(sessionId) {
|
|
67
|
+
this.requireConnected();
|
|
68
|
+
await this.run({ op: "resume", sessionId });
|
|
69
|
+
}
|
|
70
|
+
async listSessions(limit, signal) {
|
|
71
|
+
return (await this.run({ op: "recent", limit }, signal)).page;
|
|
72
|
+
}
|
|
73
|
+
async readSession(query, signal) {
|
|
74
|
+
return (await this.run({ op: "read", ...query }, signal)).page;
|
|
75
|
+
}
|
|
76
|
+
async selectSession(sessionId, signal) {
|
|
77
|
+
const value = await this.run({ op: "select", sessionId }, signal);
|
|
78
|
+
if (value.sessionId !== sessionId || typeof value.title !== "string")
|
|
79
|
+
throw new Error("Hermes 精确会话身份不一致");
|
|
80
|
+
return { sessionId, title: value.title };
|
|
81
|
+
}
|
|
82
|
+
async completeSelected(task, sessionId, timeoutMs = this.timeoutMs, signal) {
|
|
83
|
+
const value = await this.run({ op: "completeSelected", task, sessionId, timeoutMs }, signal, timeoutMs + 15_000);
|
|
84
|
+
if (value.sessionId !== sessionId || typeof value.text !== "string" || !value.text.trim())
|
|
85
|
+
throw new Error("Hermes 精确会话回复无效");
|
|
86
|
+
return { sessionId, text: value.text };
|
|
87
|
+
}
|
|
88
|
+
async complete(task, sessionId, timeoutMs = this.timeoutMs, signal, attachments) {
|
|
89
|
+
this.requireConnected();
|
|
90
|
+
(0, attachments_1.validateAttachments)(attachments);
|
|
91
|
+
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") }));
|
|
92
|
+
const value = await this.run({ op: "complete", task, sessionId, timeoutMs, attachments: encoded }, signal, timeoutMs + 15_000);
|
|
93
|
+
if (!value || typeof value.sessionId !== "string" || typeof value.text !== "string" || !value.text.trim())
|
|
94
|
+
throw new Error("Hermes Gateway 未返回文字");
|
|
95
|
+
return { sessionId: value.sessionId, text: value.text };
|
|
96
|
+
}
|
|
97
|
+
requireConnected() { if (!this.connectedState)
|
|
98
|
+
throw new Error("Hermes Gateway 客户端未连接"); }
|
|
99
|
+
requireActive(payload) { if (payload.op !== "health")
|
|
100
|
+
this.requireConnected(); }
|
|
101
|
+
run(payload, signal, timeoutMs = 30_000) {
|
|
102
|
+
return new Promise((resolvePromise, reject) => {
|
|
103
|
+
if (!(0, node_fs_1.existsSync)(this.helper))
|
|
104
|
+
return reject(new Error("Hermes 插件缺少 Gateway 通信入口"));
|
|
105
|
+
void findPython(this.root, this.python).then(python => {
|
|
106
|
+
signal?.throwIfAborted();
|
|
107
|
+
this.requireActive(payload);
|
|
108
|
+
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 });
|
|
109
|
+
let stdout = "";
|
|
110
|
+
let settled = false;
|
|
111
|
+
const finish = (error, value) => { if (settled)
|
|
112
|
+
return; settled = true; this.pending.delete(cancel); clearTimeout(timer); signal?.removeEventListener("abort", abort); error ? reject(error) : resolvePromise(value ?? {}); };
|
|
113
|
+
const cancel = () => { child.kill(); finish(new Error("Hermes 已停止等待,桌面任务可能仍在运行,请在桌面检查")); };
|
|
114
|
+
const abort = cancel;
|
|
115
|
+
this.pending.add(cancel);
|
|
116
|
+
const timer = setTimeout(() => {
|
|
117
|
+
child.kill();
|
|
118
|
+
const submitted = payload.op === "complete" || payload.op === "completeSelected";
|
|
119
|
+
finish(new Error(submitted ? "Hermes Gateway 等待超时,桌面任务可能仍在运行,请在桌面检查" : "Hermes Gateway 连接或会话操作等待超时,未提交任务;请检查官方 Gateway 状态"));
|
|
120
|
+
}, Math.max(1_000, timeoutMs));
|
|
121
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
122
|
+
child.stdout.setEncoding("utf8");
|
|
123
|
+
child.stdout.on("data", chunk => { stdout += String(chunk); if (stdout.length > 8 * 1024 * 1024)
|
|
124
|
+
cancel(); });
|
|
125
|
+
child.stderr.resume();
|
|
126
|
+
child.stdin.on("error", () => finish(new Error("Hermes Gateway 输入失败")));
|
|
127
|
+
child.on("error", () => finish(new Error("Hermes Python 启动失败,请检查 pythonPath")));
|
|
128
|
+
child.on("close", code => {
|
|
129
|
+
if (settled)
|
|
130
|
+
return;
|
|
131
|
+
let value;
|
|
132
|
+
try {
|
|
133
|
+
value = parseJsonLines(stdout);
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
finish(new Error(`Hermes Gateway 通信进程退出(${code ?? "unknown"})`));
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (!value || typeof value !== "object" || value.ok !== true) {
|
|
140
|
+
const message = (value?.error ?? "Hermes Gateway 通信失败") || "Hermes Gateway 通信失败";
|
|
141
|
+
finish(new Error(String(message)));
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
finish(undefined, value);
|
|
145
|
+
});
|
|
146
|
+
child.stdin.end(JSON.stringify(payload), "utf8");
|
|
147
|
+
}).catch(error => reject(error));
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
class HermesGatewayClient {
|
|
152
|
+
transport;
|
|
153
|
+
isConnected = false;
|
|
154
|
+
constructor(options = {}) {
|
|
155
|
+
this.transport = options.transport ?? new PythonHermesTransport(options);
|
|
156
|
+
}
|
|
157
|
+
connected() { return this.isConnected; }
|
|
158
|
+
async connect() { await this.transport.connect(); this.isConnected = true; }
|
|
159
|
+
close() { this.transport.close(); this.isConnected = false; }
|
|
160
|
+
createSession(title, signal) { return this.transport.createSession(title, signal); }
|
|
161
|
+
resumeSession(sessionId) { return this.transport.resumeSession(sessionId); }
|
|
162
|
+
complete(task, sessionId, timeoutMs, signal, attachments) { return this.transport.complete(task, sessionId, timeoutMs, signal, attachments); }
|
|
163
|
+
listSessions(limit, signal) {
|
|
164
|
+
if (!this.transport.listSessions)
|
|
165
|
+
return Promise.reject(new Error("Hermes 当前传输不支持原生任务目录"));
|
|
166
|
+
return this.transport.listSessions(limit, signal);
|
|
167
|
+
}
|
|
168
|
+
readSession(query, signal) {
|
|
169
|
+
if (!this.transport.readSession)
|
|
170
|
+
return Promise.reject(new Error("Hermes 当前传输不支持原生任务正文"));
|
|
171
|
+
return this.transport.readSession(query, signal);
|
|
172
|
+
}
|
|
173
|
+
selectSession(sessionId, signal) {
|
|
174
|
+
if (!this.transport.selectSession)
|
|
175
|
+
return Promise.reject(new Error("Hermes 当前传输不支持精确任务选择"));
|
|
176
|
+
return this.transport.selectSession(sessionId, signal);
|
|
177
|
+
}
|
|
178
|
+
completeSelected(task, sessionId, timeoutMs, signal) {
|
|
179
|
+
if (!this.transport.completeSelected)
|
|
180
|
+
return Promise.reject(new Error("Hermes 当前传输不支持精确任务续聊"));
|
|
181
|
+
return this.transport.completeSelected(task, sessionId, timeoutMs, signal);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
exports.HermesGatewayClient = HermesGatewayClient;
|
|
185
|
+
function sessionTitle(task) {
|
|
186
|
+
const text = task.trim().replace(/\s+/g, " ");
|
|
187
|
+
return `Agent Bridge:${text.slice(0, 80) || (0, node_crypto_1.randomUUID)()}`;
|
|
188
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import base64
|
|
3
|
+
from contextlib import suppress
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
import uuid
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def fail(message):
|
|
13
|
+
print(json.dumps({"ok": False, "error": str(message)[:500]}, ensure_ascii=False), flush=True)
|
|
14
|
+
raise SystemExit(1)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
async def main(request):
|
|
18
|
+
source = os.environ.get("HERMES_AGENT_SOURCE")
|
|
19
|
+
if not source:
|
|
20
|
+
fail("未找到 Hermes 源码目录")
|
|
21
|
+
sys.path.insert(0, source)
|
|
22
|
+
try:
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from pm.environments import activate_dependencies
|
|
25
|
+
activate_dependencies(Path(source))
|
|
26
|
+
from hermes_cli.gateway_client import connect_gateway
|
|
27
|
+
except Exception as exc:
|
|
28
|
+
fail("Hermes Gateway 客户端不可用,请检查官方安装及依赖")
|
|
29
|
+
os.environ.pop("HERMES_TUI_GATEWAY_URL", None)
|
|
30
|
+
os.environ.pop("HERMES_TUI_GATEWAY_TOKEN", None)
|
|
31
|
+
async with connect_gateway() as client:
|
|
32
|
+
async def drain_events():
|
|
33
|
+
while True:
|
|
34
|
+
await client.events.get()
|
|
35
|
+
# 事件队列必须持续消费,避免长回复阻塞官方客户端的 RPC 读取。
|
|
36
|
+
drain = asyncio.create_task(drain_events())
|
|
37
|
+
try:
|
|
38
|
+
return await operate(client, request)
|
|
39
|
+
finally:
|
|
40
|
+
drain.cancel()
|
|
41
|
+
with suppress(asyncio.CancelledError):
|
|
42
|
+
await drain
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
async def operate(client, request):
|
|
46
|
+
operation = request.get("op")
|
|
47
|
+
if operation == "health":
|
|
48
|
+
await client.rpc("session.list", scope="live", limit=1)
|
|
49
|
+
return {"ok": True}
|
|
50
|
+
if operation == "create":
|
|
51
|
+
snapshot = await client.rpc("session.create", source="gui", request_id=str(uuid.uuid4()), title=str(request.get("title") or "Agent Bridge"))
|
|
52
|
+
return {"ok": True, "sessionId": snapshot.get("stored_session_id") or snapshot["session_id"]}
|
|
53
|
+
if operation == "recent":
|
|
54
|
+
limit = page_limit(request, 50)
|
|
55
|
+
rows = (await client.rpc("session.list", scope="stored", limit=limit + 1)).get("sessions")
|
|
56
|
+
if not isinstance(rows, list):
|
|
57
|
+
fail("Hermes 原生任务目录格式无效")
|
|
58
|
+
sessions = []
|
|
59
|
+
for row in rows[:limit]:
|
|
60
|
+
if not isinstance(row, dict) or not isinstance(row.get("id"), str) or not row["id"]:
|
|
61
|
+
fail("Hermes 原生任务目录缺少精确 ID")
|
|
62
|
+
sessions.append({"id": row["id"], "title": str(row.get("title") or "")})
|
|
63
|
+
return {"ok": True, "page": {"supported": True, "sessions": sessions, "complete": False,
|
|
64
|
+
"missingReasons": ["仅当前配置的持久化目录,不含未持久化空白任务;Gateway 未返回总数或更新时间"], "readAt": read_at()}}
|
|
65
|
+
session_id = str(request.get("sessionId") or "")
|
|
66
|
+
if not session_id:
|
|
67
|
+
fail("Hermes 会话 ID 不能为空")
|
|
68
|
+
if operation == "read":
|
|
69
|
+
return await read_history(client, request, session_id)
|
|
70
|
+
response_id = session_id
|
|
71
|
+
if operation in ("select", "completeSelected"):
|
|
72
|
+
session_id, title = await select_exact(client, session_id)
|
|
73
|
+
if operation == "select":
|
|
74
|
+
return {"ok": True, "sessionId": response_id, "title": title}
|
|
75
|
+
else:
|
|
76
|
+
snapshot = await client.rpc("session.resume", session_id=session_id)
|
|
77
|
+
session_id = snapshot.get("session_id") or session_id
|
|
78
|
+
response_id = snapshot.get("stored_session_id") or snapshot.get("session_key") or response_id
|
|
79
|
+
if operation == "resume":
|
|
80
|
+
return {"ok": True}
|
|
81
|
+
if operation not in ("complete", "completeSelected"):
|
|
82
|
+
fail("不支持的 Hermes 操作")
|
|
83
|
+
text = str(request.get("task") or "")
|
|
84
|
+
images = []
|
|
85
|
+
try:
|
|
86
|
+
for item in request.get("attachments") or []:
|
|
87
|
+
if item["kind"] == "image":
|
|
88
|
+
staged = await client.rpc("image.attach_bytes", session_id=session_id,
|
|
89
|
+
content_base64=item["data"], filename=item["filename"])
|
|
90
|
+
if staged.get("attached") is not True:
|
|
91
|
+
raise ValueError("图片暂存未完成")
|
|
92
|
+
images.append({"path": staged["path"], "mime": staged["mime"]})
|
|
93
|
+
elif item["kind"] == "file":
|
|
94
|
+
staged = await client.rpc("file.attach", session_id=session_id,
|
|
95
|
+
data_url="data:" + item["contentType"] + ";base64," + item["data"],
|
|
96
|
+
name=item["filename"])
|
|
97
|
+
if staged.get("attached") is not True or not staged.get("ref_text"):
|
|
98
|
+
raise ValueError("文件暂存未完成")
|
|
99
|
+
text += "\n" + staged["ref_text"]
|
|
100
|
+
else:
|
|
101
|
+
raise ValueError("附件类型无效")
|
|
102
|
+
except Exception:
|
|
103
|
+
fail("Hermes 附件暂存失败,未提交任务,请检查桌面附件限制")
|
|
104
|
+
params = {"session_id": session_id, "text": text, "submission_id": str(uuid.uuid4())}
|
|
105
|
+
if images:
|
|
106
|
+
params["attachments"] = images
|
|
107
|
+
admission = await client.rpc("prompt.submit", **params)
|
|
108
|
+
admission_id = admission.get("admission_id")
|
|
109
|
+
if not admission_id:
|
|
110
|
+
fail("Hermes Gateway 未返回 admission_id")
|
|
111
|
+
deadline = asyncio.get_running_loop().time() + max(1, int(request.get("timeoutMs") or 1200000)) / 1000
|
|
112
|
+
while asyncio.get_running_loop().time() < deadline:
|
|
113
|
+
receipt = await client.rpc("prompt.receipt", session_id=session_id, admission_id=admission_id, include_result=True)
|
|
114
|
+
if receipt.get("status") == "terminal":
|
|
115
|
+
result = receipt.get("result") or {}
|
|
116
|
+
if receipt.get("outcome") != "completed":
|
|
117
|
+
fail("Hermes Gateway 任务未完成,请在桌面检查")
|
|
118
|
+
text = result.get("final_response")
|
|
119
|
+
if not isinstance(text, str) or not text.strip():
|
|
120
|
+
fail("Hermes Gateway 未返回文字")
|
|
121
|
+
return {"ok": True, "sessionId": response_id, "text": text}
|
|
122
|
+
await asyncio.sleep(0.5)
|
|
123
|
+
fail("Hermes Gateway 等待超时,桌面任务可能仍在运行,请在桌面检查")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def read_at():
|
|
127
|
+
return datetime.now(timezone.utc).isoformat()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def page_limit(request, maximum):
|
|
131
|
+
value = request.get("limit", 20)
|
|
132
|
+
if type(value) is not int or not 1 <= value <= maximum:
|
|
133
|
+
fail("Hermes 任务分页参数无效")
|
|
134
|
+
return value
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
async def live_session(client, session_id):
|
|
138
|
+
rows = (await client.rpc("session.active_list")).get("sessions")
|
|
139
|
+
if not isinstance(rows, list):
|
|
140
|
+
fail("Hermes 已加载会话目录格式无效")
|
|
141
|
+
return next((row for row in rows if isinstance(row, dict) and
|
|
142
|
+
(row.get("session_key") == session_id or row.get("id") == session_id)), None)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
async def select_exact(client, session_id):
|
|
146
|
+
row = await live_session(client, session_id)
|
|
147
|
+
if row:
|
|
148
|
+
if row.get("status") != "idle":
|
|
149
|
+
fail("Hermes 目标任务正在运行或状态未知,请稍后重试")
|
|
150
|
+
else:
|
|
151
|
+
# shortcut: 原生目录没有按 ID 查询,超过 200 条时先在桌面加载目标后再切换。
|
|
152
|
+
rows = (await client.rpc("session.list", scope="stored", limit=200)).get("sessions")
|
|
153
|
+
if not isinstance(rows, list):
|
|
154
|
+
fail("Hermes 原生任务目录格式无效")
|
|
155
|
+
row = next((item for item in rows if isinstance(item, dict) and item.get("id") == session_id), None)
|
|
156
|
+
if row is None:
|
|
157
|
+
fail("Hermes 未找到精确任务 ID;目标不存在或不在可验证目录,请先在桌面加载")
|
|
158
|
+
snapshot = await client.rpc("session.resume", session_id=row.get("session_key") or session_id, omit_messages=True)
|
|
159
|
+
runtime_id = snapshot.get("session_id")
|
|
160
|
+
actual = snapshot.get("stored_session_id") or snapshot.get("session_key")
|
|
161
|
+
if not runtime_id or snapshot.get("session_id") != runtime_id or actual != (row.get("session_key") or session_id):
|
|
162
|
+
fail("Hermes 精确会话身份不一致,未提交任务或保存选择")
|
|
163
|
+
if snapshot.get("running") is not False:
|
|
164
|
+
fail("Hermes 目标任务正在运行或状态未知,请稍后重试")
|
|
165
|
+
return runtime_id, str(row.get("title") or "")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
async def read_history(client, request, session_id):
|
|
169
|
+
limit = page_limit(request, 100)
|
|
170
|
+
row = await live_session(client, session_id)
|
|
171
|
+
if row is None:
|
|
172
|
+
fail("Hermes 任务未加载,无法只读正文;请先显式 switch,不会自动恢复或切换")
|
|
173
|
+
history = await client.rpc("session.history", session_id=row["id"])
|
|
174
|
+
current = await live_session(client, session_id)
|
|
175
|
+
if current is None or current.get("id") != row["id"] or current.get("session_key") != row.get("session_key"):
|
|
176
|
+
fail("Hermes 精确目标已消失或变化,请重新读取")
|
|
177
|
+
messages = history.get("messages")
|
|
178
|
+
if not isinstance(messages, list):
|
|
179
|
+
fail("Hermes 原生任务正文格式无效")
|
|
180
|
+
native_count = history.get("count")
|
|
181
|
+
if type(native_count) is not int or native_count < len(messages):
|
|
182
|
+
fail("Hermes 原生任务正文数量格式无效")
|
|
183
|
+
turns = []
|
|
184
|
+
omitted = native_count != len(messages)
|
|
185
|
+
for message in messages:
|
|
186
|
+
if not isinstance(message, dict):
|
|
187
|
+
fail("Hermes 原生任务消息格式无效")
|
|
188
|
+
text = message.get("text")
|
|
189
|
+
if (message.get("role") not in ("user", "assistant") or not isinstance(text, str) or not text.strip()
|
|
190
|
+
or message.get("channel") in ("analysis", "reasoning")
|
|
191
|
+
or message.get("display_kind") in ("hidden", "analysis", "reasoning")):
|
|
192
|
+
omitted = True
|
|
193
|
+
continue
|
|
194
|
+
turn = {"role": message["role"], "text": text}
|
|
195
|
+
if message.get("row_id") is not None:
|
|
196
|
+
turn["id"] = str(message["row_id"])
|
|
197
|
+
timestamp = message.get("timestamp")
|
|
198
|
+
if type(timestamp) in (int, float) and timestamp > 0:
|
|
199
|
+
turn["time"] = datetime.fromtimestamp(timestamp, timezone.utc).isoformat()
|
|
200
|
+
turns.append(turn)
|
|
201
|
+
omitted = omitted or any(message.get(field) for field in (
|
|
202
|
+
"reasoning", "thinking", "reasoning_content", "reasoning_details",
|
|
203
|
+
"codex_reasoning_items", "codex_message_items", "tool_calls", "attachments"))
|
|
204
|
+
snapshot = hashlib.sha256(json.dumps([session_id, turns], ensure_ascii=False, sort_keys=True).encode("utf8")).hexdigest()
|
|
205
|
+
start = 0
|
|
206
|
+
cursor = request.get("cursor")
|
|
207
|
+
if cursor is not None:
|
|
208
|
+
try:
|
|
209
|
+
if not isinstance(cursor, str) or not 1 <= len(cursor) <= 4096:
|
|
210
|
+
raise ValueError()
|
|
211
|
+
decoded = json.loads(base64.urlsafe_b64decode(cursor + "=" * (-len(cursor) % 4)))
|
|
212
|
+
if (set(decoded) != {"sessionId", "snapshot", "offset"} or decoded["sessionId"] != session_id
|
|
213
|
+
or decoded["snapshot"] != snapshot or type(decoded["offset"]) is not int
|
|
214
|
+
or not 0 < decoded["offset"] < len(turns)):
|
|
215
|
+
raise ValueError()
|
|
216
|
+
start = decoded["offset"]
|
|
217
|
+
except Exception:
|
|
218
|
+
fail("Hermes 正文游标或快照无效,请重新读取")
|
|
219
|
+
selected = turns[start:start + limit]
|
|
220
|
+
if sum(len(turn["text"].encode("utf-16le")) // 2 for turn in selected) > 60000:
|
|
221
|
+
fail("Hermes 任务正文超过 60,000 字符,请减小 limit;未截断")
|
|
222
|
+
end = start + len(selected)
|
|
223
|
+
missing = ["仅返回 Gateway 可验证的用户和助理文字;推理、工具及非文字内容不交付"] if omitted else []
|
|
224
|
+
if current.get("status") != "idle":
|
|
225
|
+
missing.append("任务正在运行或状态未知,正文可能尚未落入完整历史")
|
|
226
|
+
if end < len(turns):
|
|
227
|
+
missing.append("正文尚有后续分页")
|
|
228
|
+
page = {"supported": True, "session": {"id": session_id, "title": str(row.get("title") or "")},
|
|
229
|
+
"turns": selected, "complete": not missing, "missingReasons": missing, "readAt": read_at()}
|
|
230
|
+
if end < len(turns):
|
|
231
|
+
page["nextCursor"] = base64.urlsafe_b64encode(json.dumps({"sessionId": session_id, "snapshot": snapshot, "offset": end}).encode("utf8")).decode("ascii").rstrip("=")
|
|
232
|
+
return {"ok": True, "page": page}
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
try:
|
|
236
|
+
sys.stdin.reconfigure(encoding="utf-8")
|
|
237
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
238
|
+
raw = sys.stdin.read()
|
|
239
|
+
request = json.loads(raw)
|
|
240
|
+
print(json.dumps(asyncio.run(main(request)), ensure_ascii=False), flush=True)
|
|
241
|
+
except SystemExit:
|
|
242
|
+
raise
|
|
243
|
+
except Exception as exc:
|
|
244
|
+
# 只暴露异常类型和官方枚举状态,不能把含凭据的异常正文回传。
|
|
245
|
+
kind = type(exc).__name__
|
|
246
|
+
state = getattr(exc, "state", None)
|
|
247
|
+
reason = getattr(exc, "reason_code", None)
|
|
248
|
+
details = [kind]
|
|
249
|
+
for value in (state, reason):
|
|
250
|
+
if isinstance(value, str) and value and all(c.isascii() and (c.isalnum() or c in "_-") for c in value):
|
|
251
|
+
details.append(value)
|
|
252
|
+
fail("Hermes Gateway 通信失败(" + "/".join(details) + "),请在桌面检查连接与任务状态")
|
|
@@ -0,0 +1,148 @@
|
|
|
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_abilities_1 = require("../agent-task-abilities");
|
|
6
|
+
const agent_1 = require("../../agent");
|
|
7
|
+
const client_1 = require("./client");
|
|
8
|
+
exports.hermesManifest = {
|
|
9
|
+
id: "hermes", version: "1.0.3", 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
|
+
taskAbilities = new agent_task_abilities_1.AgentTaskAbilities({
|
|
17
|
+
execute: (context, _action, work) => this.enqueue(work, context.signal),
|
|
18
|
+
scope: () => "Hermes Gateway 当前配置的持久化任务目录;正文仅限已加载会话,不自动恢复或切换",
|
|
19
|
+
recent: (limit, context) => this.requireClient().listSessions(limit, context.signal),
|
|
20
|
+
read: (query, context) => this.requireClient().readSession(query, context.signal),
|
|
21
|
+
select: async (sessionId, context) => sessionId
|
|
22
|
+
? this.requireClient().selectSession(sessionId, context.signal)
|
|
23
|
+
: { sessionId: await this.requireClient().createSession((0, client_1.sessionTitle)("远程新任务"), context.signal), title: "远程新任务" },
|
|
24
|
+
run: async (request, selectedId, context) => selectedId
|
|
25
|
+
? this.requireClient().completeSelected(request.task, selectedId, request.timeoutMs ?? this.timeoutMs, context.signal)
|
|
26
|
+
: this.runTask(request),
|
|
27
|
+
});
|
|
28
|
+
abilities = [createReadinessAbility(() => this.health()), ...this.taskAbilities.abilities.map(ability => ({
|
|
29
|
+
...ability,
|
|
30
|
+
handler: (params, context) => ability.handler(params, { ...context, signal: AbortSignal.any([context.signal, this.lifetime.signal]) }),
|
|
31
|
+
}))];
|
|
32
|
+
sessionOperations = ["new"];
|
|
33
|
+
attachmentInput = true;
|
|
34
|
+
client;
|
|
35
|
+
failure;
|
|
36
|
+
timeoutMs = 1_200_000;
|
|
37
|
+
queue = Promise.resolve();
|
|
38
|
+
lifetime = new AbortController();
|
|
39
|
+
constructor(options = {}) {
|
|
40
|
+
this.options = options;
|
|
41
|
+
}
|
|
42
|
+
async activate(context) {
|
|
43
|
+
this.timeoutMs = typeof context.config.timeoutMs === "number" && context.config.timeoutMs > 0 ? context.config.timeoutMs : 1_200_000;
|
|
44
|
+
const options = {
|
|
45
|
+
hermesHome: typeof context.config.hermesHome === "string" ? context.config.hermesHome : this.options.hermesHome,
|
|
46
|
+
pythonPath: typeof context.config.pythonPath === "string" ? context.config.pythonPath : this.options.pythonPath,
|
|
47
|
+
timeoutMs: this.timeoutMs,
|
|
48
|
+
transport: this.options.transport,
|
|
49
|
+
};
|
|
50
|
+
this.failure = undefined;
|
|
51
|
+
this.taskAbilities.reset();
|
|
52
|
+
this.lifetime = new AbortController();
|
|
53
|
+
this.client = this.options.client ?? new client_1.HermesGatewayClient(options);
|
|
54
|
+
try {
|
|
55
|
+
await this.client.connect();
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
this.failure = error instanceof Error ? error.message : String(error);
|
|
59
|
+
context.logger.warn("Hermes Gateway 连接失败", { error: this.failure });
|
|
60
|
+
this.client = undefined;
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
async deactivate(_reason, _options) {
|
|
65
|
+
this.lifetime.abort(new Error("Hermes 插件已停用"));
|
|
66
|
+
this.taskAbilities.reset();
|
|
67
|
+
this.client?.close();
|
|
68
|
+
this.client = undefined;
|
|
69
|
+
}
|
|
70
|
+
async health() {
|
|
71
|
+
try {
|
|
72
|
+
if (this.client)
|
|
73
|
+
await this.client.connect();
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return { status: "failed", message: "Hermes Gateway 健康检查失败" };
|
|
77
|
+
}
|
|
78
|
+
return this.client?.connected()
|
|
79
|
+
? { status: "ready", details: { transport: "hermes-gateway" } }
|
|
80
|
+
: { status: "failed", message: this.failure ?? "Hermes Gateway 未连接", details: { transport: "hermes-gateway" } };
|
|
81
|
+
}
|
|
82
|
+
handle(request) {
|
|
83
|
+
const signal = request.signal ? AbortSignal.any([request.signal, this.lifetime.signal]) : this.lifetime.signal;
|
|
84
|
+
return this.enqueue(() => this.runTask({ ...request, signal }), signal);
|
|
85
|
+
}
|
|
86
|
+
requireClient() {
|
|
87
|
+
if (!this.client?.connected())
|
|
88
|
+
throw new Error("Hermes 插件未连接 Gateway");
|
|
89
|
+
return this.client;
|
|
90
|
+
}
|
|
91
|
+
enqueue(work, signal) {
|
|
92
|
+
const lifetime = this.lifetime.signal;
|
|
93
|
+
const result = this.queue.then(async () => { lifetime.throwIfAborted(); signal?.throwIfAborted(); return work(); });
|
|
94
|
+
this.queue = result.catch(() => undefined);
|
|
95
|
+
return (0, agent_1.abortable)(result, signal);
|
|
96
|
+
}
|
|
97
|
+
async runTask(request) {
|
|
98
|
+
(0, attachments_1.validateAttachments)(request.attachments);
|
|
99
|
+
const task = request.task?.trim() || (request.attachments?.length ? "请处理附件" : "");
|
|
100
|
+
if (!task)
|
|
101
|
+
throw new Error("消息内容不能为空");
|
|
102
|
+
request.signal?.throwIfAborted();
|
|
103
|
+
const client = this.client;
|
|
104
|
+
if (!client?.connected())
|
|
105
|
+
throw new Error("Hermes 插件未连接 Gateway");
|
|
106
|
+
request.signal?.throwIfAborted();
|
|
107
|
+
const session = await this.resolveSession(client, request, task);
|
|
108
|
+
const result = await (0, agent_1.abortable)(client.complete(task, session, request.timeoutMs ?? this.timeoutMs, request.signal, request.attachments), request.signal);
|
|
109
|
+
return { text: result.text, sessionId: result.sessionId };
|
|
110
|
+
}
|
|
111
|
+
async controlSession(request) {
|
|
112
|
+
if (request.operation !== "new")
|
|
113
|
+
throw new Error(`Hermes 不支持会话操作:${String(request.operation)}`);
|
|
114
|
+
const sessionId = request.sessionId?.trim();
|
|
115
|
+
if (!sessionId)
|
|
116
|
+
throw new Error("Hermes 新会话缺少 sessionId");
|
|
117
|
+
const client = this.client;
|
|
118
|
+
if (!client?.connected())
|
|
119
|
+
throw new Error("Hermes 插件未连接 Gateway");
|
|
120
|
+
const backendId = await client.createSession((0, client_1.sessionTitle)(request.name ?? sessionId));
|
|
121
|
+
return { text: "Hermes 已创建新会话", sessionId: backendId };
|
|
122
|
+
}
|
|
123
|
+
async resolveSession(client, request, task) {
|
|
124
|
+
if (request.newSession || !request.sessionId) {
|
|
125
|
+
return client.createSession((0, client_1.sessionTitle)(task), request.signal);
|
|
126
|
+
}
|
|
127
|
+
await client.resumeSession(request.sessionId);
|
|
128
|
+
return request.sessionId;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
exports.HermesPlugin = HermesPlugin;
|
|
132
|
+
function createReadinessAbility(health) {
|
|
133
|
+
return {
|
|
134
|
+
definition: {
|
|
135
|
+
path: "agent/status/readiness", name: "Agent 就绪状态", summary: "读取 Hermes Desktop 当前是否就绪。", contract_revision: "1",
|
|
136
|
+
params_schema: { type: "object", properties: {}, additionalProperties: false },
|
|
137
|
+
result_schema: { type: "object", required: ["status"], properties: { status: { type: "string", enum: ["ready", "degraded", "failed"] } }, additionalProperties: false },
|
|
138
|
+
effects: ["state.read"], auth_required: true, require_confirm: true, idempotency: "keyed", allowed_modes: ["explicit"], aggregation: "none", dedupe_retention_seconds: 3600,
|
|
139
|
+
},
|
|
140
|
+
handler: async (params) => {
|
|
141
|
+
if (!params || typeof params !== "object" || Array.isArray(params) || Object.keys(params).length)
|
|
142
|
+
throw new Error("readiness 参数必须为空对象");
|
|
143
|
+
const status = (await health()).status;
|
|
144
|
+
return { status: status === "ready" || status === "failed" ? status : "degraded" };
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
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.3",
|
|
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.3",
|
|
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
|
+
}
|