agent-bridge-hermes 1.0.2 → 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 CHANGED
@@ -4,4 +4,14 @@
4
4
 
5
5
  Windows 默认安装根目录为 `%LOCALAPPDATA%\hermes`,其他系统为 `~/.hermes`;自定义安装可配置 `hermesHome` 和 `pythonPath`。Python 入口通过官方依赖管理器加载当前环境。网关鉴权复用官方同用户私有管道票据,不开放远程网关端口。
6
6
 
7
+ 连接健康检查最多等待 120 秒,覆盖官方 Gateway 的 60 秒冷启动预算、依赖加载和首次 RPC。连接或会话操作超时明确提示未提交任务;生成任务仍使用 `timeoutMs`,超时不代表桌面任务已取消,也不会自动重发。
8
+
7
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,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;
@@ -50,14 +50,15 @@ class PythonHermesTransport {
50
50
  this.helper = (0, node_path_1.join)((0, node_path_1.dirname)(__filename), "gateway-client.py");
51
51
  }
52
52
  async connect() {
53
- await this.run({ op: "health" });
53
+ // 官方冷启动预算为 60 秒,另留依赖加载、鉴权和首次 RPC 的时间。
54
+ await this.run({ op: "health" }, undefined, 120_000);
54
55
  this.connectedState = true;
55
56
  }
56
57
  close() { this.connectedState = false; for (const cancel of this.pending)
57
58
  cancel(); }
58
- async createSession(title) {
59
+ async createSession(title, signal) {
59
60
  this.requireConnected();
60
- const value = await this.run({ op: "create", title });
61
+ const value = await this.run({ op: "create", title }, signal);
61
62
  if (!value || typeof value.sessionId !== "string" || !value.sessionId)
62
63
  throw new Error("Hermes Gateway 未返回会话 ID");
63
64
  return value.sessionId;
@@ -66,6 +67,24 @@ class PythonHermesTransport {
66
67
  this.requireConnected();
67
68
  await this.run({ op: "resume", sessionId });
68
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
+ }
69
88
  async complete(task, sessionId, timeoutMs = this.timeoutMs, signal, attachments) {
70
89
  this.requireConnected();
71
90
  (0, attachments_1.validateAttachments)(attachments);
@@ -94,7 +113,11 @@ class PythonHermesTransport {
94
113
  const cancel = () => { child.kill(); finish(new Error("Hermes 已停止等待,桌面任务可能仍在运行,请在桌面检查")); };
95
114
  const abort = cancel;
96
115
  this.pending.add(cancel);
97
- const timer = setTimeout(() => { child.kill(); finish(new Error("Hermes Gateway 等待超时,桌面任务可能仍在运行,请在桌面检查")); }, Math.max(1_000, timeoutMs));
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));
98
121
  signal?.addEventListener("abort", abort, { once: true });
99
122
  child.stdout.setEncoding("utf8");
100
123
  child.stdout.on("data", chunk => { stdout += String(chunk); if (stdout.length > 8 * 1024 * 1024)
@@ -134,9 +157,29 @@ class HermesGatewayClient {
134
157
  connected() { return this.isConnected; }
135
158
  async connect() { await this.transport.connect(); this.isConnected = true; }
136
159
  close() { this.transport.close(); this.isConnected = false; }
137
- createSession(title) { return this.transport.createSession(title); }
160
+ createSession(title, signal) { return this.transport.createSession(title, signal); }
138
161
  resumeSession(sessionId) { return this.transport.resumeSession(sessionId); }
139
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
+ }
140
183
  }
141
184
  exports.HermesGatewayClient = HermesGatewayClient;
142
185
  function sessionTitle(task) {
@@ -1,5 +1,8 @@
1
1
  import asyncio
2
+ import base64
2
3
  from contextlib import suppress
4
+ from datetime import datetime, timezone
5
+ import hashlib
3
6
  import json
4
7
  import os
5
8
  import sys
@@ -46,14 +49,36 @@ async def operate(client, request):
46
49
  return {"ok": True}
47
50
  if operation == "create":
48
51
  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"]}
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()}}
50
65
  session_id = str(request.get("sessionId") or "")
51
66
  if not session_id:
52
67
  fail("Hermes 会话 ID 不能为空")
53
- await client.rpc("session.resume", session_id=session_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
54
79
  if operation == "resume":
55
80
  return {"ok": True}
56
- if operation != "complete":
81
+ if operation not in ("complete", "completeSelected"):
57
82
  fail("不支持的 Hermes 操作")
58
83
  text = str(request.get("task") or "")
59
84
  images = []
@@ -93,11 +118,120 @@ async def operate(client, request):
93
118
  text = result.get("final_response")
94
119
  if not isinstance(text, str) or not text.strip():
95
120
  fail("Hermes Gateway 未返回文字")
96
- return {"ok": True, "sessionId": session_id, "text": text}
121
+ return {"ok": True, "sessionId": response_id, "text": text}
97
122
  await asyncio.sleep(0.5)
98
123
  fail("Hermes Gateway 等待超时,桌面任务可能仍在运行,请在桌面检查")
99
124
 
100
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
+
101
235
  try:
102
236
  sys.stdin.reconfigure(encoding="utf-8")
103
237
  sys.stdout.reconfigure(encoding="utf-8")
@@ -107,4 +241,12 @@ try:
107
241
  except SystemExit:
108
242
  raise
109
243
  except Exception as exc:
110
- fail("Hermes Gateway 通信失败,请在桌面检查连接与任务状态")
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) + "),请在桌面检查连接与任务状态")
@@ -2,18 +2,33 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.HermesPlugin = exports.hermesManifest = void 0;
4
4
  const attachments_1 = require("../../attachments");
5
- const agent_task_ability_1 = require("../agent-task-ability");
5
+ const agent_task_abilities_1 = require("../agent-task-abilities");
6
6
  const agent_1 = require("../../agent");
7
7
  const client_1 = require("./client");
8
8
  exports.hermesManifest = {
9
- id: "hermes", version: "1.0.2", displayName: "Hermes Desktop", type: "agent",
9
+ id: "hermes", version: "1.0.3", displayName: "Hermes Desktop", type: "agent",
10
10
  entry: "dist/src-ts/plugins/hermes/index.js", apiVersion: "1", process: "in-process",
11
11
  capabilities: ["chat", "health"], configSchema: "config.schema.json",
12
12
  };
13
13
  class HermesPlugin {
14
14
  options;
15
15
  manifest = exports.hermesManifest;
16
- abilities = [createReadinessAbility(() => this.health()), (0, agent_task_ability_1.createTaskAbility)(request => this.handle(request))];
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
+ }))];
17
32
  sessionOperations = ["new"];
18
33
  attachmentInput = true;
19
34
  client;
@@ -33,6 +48,7 @@ class HermesPlugin {
33
48
  transport: this.options.transport,
34
49
  };
35
50
  this.failure = undefined;
51
+ this.taskAbilities.reset();
36
52
  this.lifetime = new AbortController();
37
53
  this.client = this.options.client ?? new client_1.HermesGatewayClient(options);
38
54
  try {
@@ -47,6 +63,7 @@ class HermesPlugin {
47
63
  }
48
64
  async deactivate(_reason, _options) {
49
65
  this.lifetime.abort(new Error("Hermes 插件已停用"));
66
+ this.taskAbilities.reset();
50
67
  this.client?.close();
51
68
  this.client = undefined;
52
69
  }
@@ -64,9 +81,18 @@ class HermesPlugin {
64
81
  }
65
82
  handle(request) {
66
83
  const signal = request.signal ? AbortSignal.any([request.signal, this.lifetime.signal]) : this.lifetime.signal;
67
- const result = this.queue.then(() => this.runTask({ ...request, 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(); });
68
94
  this.queue = result.catch(() => undefined);
69
- return (0, agent_1.abortable)(result, request.signal);
95
+ return (0, agent_1.abortable)(result, signal);
70
96
  }
71
97
  async runTask(request) {
72
98
  (0, attachments_1.validateAttachments)(request.attachments);
@@ -96,7 +122,7 @@ class HermesPlugin {
96
122
  }
97
123
  async resolveSession(client, request, task) {
98
124
  if (request.newSession || !request.sessionId) {
99
- return client.createSession((0, client_1.sessionTitle)(task));
125
+ return client.createSession((0, client_1.sessionTitle)(task), request.signal);
100
126
  }
101
127
  await client.resumeSession(request.sessionId);
102
128
  return request.sessionId;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-bridge-hermes",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
4
4
  "description": "mu-agent-bridge 的 Hermes 桌面原生网关插件。",
5
5
  "keywords": [
6
6
  "mu-agent-bridge-plugin",
package/plugin.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "hermes",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
4
4
  "displayName": "Hermes Desktop",
5
5
  "type": "agent",
6
6
  "entry": "dist/src-ts/plugins/hermes/index.js",