dsh-agent-toolkit 0.2.7 → 0.2.9
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 +1 -1
- package/lib/client.js +1808 -294
- package/lib/client.js.map +1 -1
- package/lib/index.d.ts +17 -2
- package/lib/index.js +1919 -296
- package/lib/index.js.map +1 -1
- package/package.json +6 -1
package/lib/index.js
CHANGED
|
@@ -5,14 +5,15 @@ import { access, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
|
|
5
5
|
import { join, resolve } from "node:path";
|
|
6
6
|
import yaml from "js-yaml";
|
|
7
7
|
import { expandHomePath, resolveDshHome } from "@deepseek-ai/dsh-home-paths";
|
|
8
|
-
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
8
|
+
import { RUN_CODE_NAME, defineTool } from "@deepseek-ai/dsh-tools";
|
|
9
|
+
import { bindScopeParent, createScope, scopeOf } from "@deepseek-ai/dsh-scope";
|
|
9
10
|
import { createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
10
11
|
import { existsSync } from "node:fs";
|
|
11
12
|
import { SessionId } from "@deepseek-ai/dsh-session";
|
|
12
13
|
import { credentialRef } from "@deepseek-ai/dsh-credentials";
|
|
13
14
|
import * as lark from "@larksuiteoapi/node-sdk";
|
|
14
15
|
import { randomBytes, randomUUID } from "node:crypto";
|
|
15
|
-
import {
|
|
16
|
+
import { Cron } from "croner";
|
|
16
17
|
import { setupUsage } from "@dsh-agent-toolkit/token-usage";
|
|
17
18
|
//#region src/agents/store.ts
|
|
18
19
|
/** agents 注册表存储域声明:记录 schema + domain 布局的单一来源。 */
|
|
@@ -34,7 +35,8 @@ const AgentRecordSchema = z$1.object({
|
|
|
34
35
|
model: z$1.string()
|
|
35
36
|
}).optional(),
|
|
36
37
|
tools: z$1.object({ allow: z$1.array(z$1.string()).min(1) }).optional(),
|
|
37
|
-
builtin: z$1.boolean().optional()
|
|
38
|
+
builtin: z$1.boolean().optional(),
|
|
39
|
+
visibleInTeam: z$1.boolean().optional()
|
|
38
40
|
});
|
|
39
41
|
/** domain 名/表名受 UNIT_NAME_RE 约束(^[a-z][a-z0-9_]*$),不允许连字符。 */
|
|
40
42
|
const agentToolkitDomain = defineDomain({
|
|
@@ -60,12 +62,16 @@ function migrateAgentRecord(record) {
|
|
|
60
62
|
persona
|
|
61
63
|
} : rest;
|
|
62
64
|
}
|
|
65
|
+
/** 团队可见性判定:缺省(undefined)与 true 均可见;仅显式 false 隐藏。 */
|
|
66
|
+
function isTeamVisible(role) {
|
|
67
|
+
return role.visibleInTeam !== false;
|
|
68
|
+
}
|
|
63
69
|
//#endregion
|
|
64
70
|
//#region src/channels/basic-tools.ts
|
|
65
71
|
const BASIC_TOOLS = [
|
|
66
72
|
{
|
|
67
73
|
id: "@deepseek-ai/dsh-persona",
|
|
68
|
-
config: { text: "You are a coding agent powered by the {{model}} model. Your working directory is {{cwd}}." }
|
|
74
|
+
config: { text: "You are a coding agent powered by the {{model}} model. Your working directory is {{cwd}}. If you need information or a decision from the user, ask directly in your reply and wait for their next message." }
|
|
69
75
|
},
|
|
70
76
|
{
|
|
71
77
|
id: "@deepseek-ai/dsh-agent-instructions",
|
|
@@ -78,10 +84,10 @@ const BASIC_TOOLS = [
|
|
|
78
84
|
config: { sampleOverCapGlobResults: false }
|
|
79
85
|
}
|
|
80
86
|
];
|
|
81
|
-
/**
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
87
|
+
/** 内置工具名常量(兜底名单):agentPresets 缺席或 standing 枚举失败时,Agents 面板名册
|
|
88
|
+
* 与存量迁移回退到这份常量。语义已降级为"兜底",不再承诺是完整原生工具面——完整面 =
|
|
89
|
+
* 团队 preset 动态枚举(agents/tool-catalog.ts)。explorer 只读白名单仍从本常量派生
|
|
90
|
+
* (刻意的最小集,不追求完整)。 */
|
|
85
91
|
const NATIVE_TOOL_NAMES = [
|
|
86
92
|
process.platform === "win32" ? "pwsh" : "bash",
|
|
87
93
|
"read",
|
|
@@ -93,10 +99,50 @@ const NATIVE_TOOL_NAMES = [
|
|
|
93
99
|
];
|
|
94
100
|
//#endregion
|
|
95
101
|
//#region src/agents/builtin.ts
|
|
96
|
-
/** 内置保底 Agent 记录:main + explorer
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
102
|
+
/** 内置保底 Agent 记录:main + explorer(只读白名单 10 个)/ general(preset 面全量 20 个、禁二级委派)。 */
|
|
103
|
+
const SHELL_NAME = process.platform === "win32" ? "pwsh" : "bash";
|
|
104
|
+
/** 旧版 explorer 默认白名单(5 个):存量条件式迁移的等值比对基准。
|
|
105
|
+
* 独立于 EXPLORER_READONLY_ALLOW 保留旧派生式——比对基准不可随新名单漂移。
|
|
106
|
+
* shell 名平台互斥(win32=pwsh、其余=bash),必须从 NATIVE_TOOL_NAMES 派生不可写死。 */
|
|
107
|
+
const LEGACY_EXPLORER_ALLOW = NATIVE_TOOL_NAMES.filter((n) => n !== "write" && n !== "edit");
|
|
108
|
+
/** explorer 默认白名单(10 个):只读基础五件(shell/read/read_image/glob/grep,旧派生不变)
|
|
109
|
+
* + preset 面只读安全五件(web_search/todo_write/job_list/job_output/skill——skill 加载的是
|
|
110
|
+
* 指令文本,本身只读,用户决策默认可用)。
|
|
111
|
+
* 编排类(ralph/workflow)、写文件类(write/edit)、job_kill、ask_user_question、
|
|
112
|
+
* goal 三件套、exit_plan_mode 不进只读名单。 */
|
|
113
|
+
const EXPLORER_READONLY_ALLOW = [
|
|
114
|
+
...LEGACY_EXPLORER_ALLOW,
|
|
115
|
+
"web_search",
|
|
116
|
+
"todo_write",
|
|
117
|
+
"job_list",
|
|
118
|
+
"job_output",
|
|
119
|
+
"skill"
|
|
120
|
+
];
|
|
121
|
+
/** general 默认白名单(20 个):agent-team preset standing 面全量(2026-09-08 实测枚举),
|
|
122
|
+
* 不含 team_delegate(禁二级委派)与 run_code(宿主 Code Mode 保留名,restrict 拒收)。
|
|
123
|
+
* 静态名单:preset 面日后新增工具不自动进入,需人工再梳理(见 spec 非目标)。 */
|
|
124
|
+
const GENERAL_ALLOW = [
|
|
125
|
+
SHELL_NAME,
|
|
126
|
+
"read",
|
|
127
|
+
"write",
|
|
128
|
+
"edit",
|
|
129
|
+
"read_image",
|
|
130
|
+
"glob",
|
|
131
|
+
"grep",
|
|
132
|
+
"todo_write",
|
|
133
|
+
"web_search",
|
|
134
|
+
"ask_user_question",
|
|
135
|
+
"skill",
|
|
136
|
+
"exit_plan_mode",
|
|
137
|
+
"job_list",
|
|
138
|
+
"job_output",
|
|
139
|
+
"job_kill",
|
|
140
|
+
"create_goal",
|
|
141
|
+
"get_goal",
|
|
142
|
+
"update_goal",
|
|
143
|
+
"ralph",
|
|
144
|
+
"workflow"
|
|
145
|
+
];
|
|
100
146
|
const BUILTIN_AGENTS = [
|
|
101
147
|
{
|
|
102
148
|
id: "main",
|
|
@@ -120,7 +166,8 @@ const BUILTIN_AGENTS = [
|
|
|
120
166
|
persona: `你是通用执行员。按任务书独立完成多步骤工作,可以读写文件、运行命令。
|
|
121
167
|
动手前先阅读相关 AGENTS.md 并遵循项目约定;完成后运行与改动相关的检查
|
|
122
168
|
(测试/类型检查)验证改动,并在最终输出中报告验证结果。`,
|
|
123
|
-
builtin: true
|
|
169
|
+
builtin: true,
|
|
170
|
+
tools: { allow: [...GENERAL_ALLOW] }
|
|
124
171
|
}
|
|
125
172
|
];
|
|
126
173
|
//#endregion
|
|
@@ -237,11 +284,15 @@ async function markImported(ctx) {
|
|
|
237
284
|
const TOOLS_NATIVE_MIGRATED_KEY = "tools_native_migrated";
|
|
238
285
|
/** explorer 只读白名单一次性并入的 meta 表标记键。 */
|
|
239
286
|
const EXPLORER_READONLY_MIGRATED_KEY = "explorer_readonly_migrated";
|
|
287
|
+
/** tools.allow 一次性并入「preset 面 − 原生常量」差集的 meta 表标记键。 */
|
|
288
|
+
const TOOLS_PRESET_MIGRATED_KEY = "tools_preset_catalog_migrated";
|
|
289
|
+
/** 内置角色工具名单重选(explorer 5→10 / general 无→20)一次性迁移的 meta 表标记键。 */
|
|
290
|
+
const BUILTIN_TOOLS_RECATALOG_MIGRATED_KEY = "builtin_tools_recatalog_migrated";
|
|
240
291
|
/**
|
|
241
|
-
* 打开 dsh_agent_toolkit 域 → 首启 YAML 导入 → 旧记录迁移(promptLayers/原生并入/explorer
|
|
292
|
+
* 打开 dsh_agent_toolkit 域 → 首启 YAML 导入 → 旧记录迁移(promptLayers/原生并入/preset 差集并入/explorer 只读/内置名单重选)→
|
|
242
293
|
* 缺 main/explorer/general 时种入内置 → 构建内存缓存。域由 apply 统一 open(storage-domain 同名单开),此处只消费表句柄。
|
|
243
294
|
*/
|
|
244
|
-
async function createRegistry(warn, tables) {
|
|
295
|
+
async function createRegistry(warn, tables, listPresetTools) {
|
|
245
296
|
const { agents, meta } = tables;
|
|
246
297
|
await importRolesYaml({
|
|
247
298
|
agents,
|
|
@@ -262,6 +313,25 @@ async function createRegistry(warn, tables) {
|
|
|
262
313
|
if (next !== record) await agents.put(id, next);
|
|
263
314
|
}
|
|
264
315
|
if (!nativeMigrated) await meta.put(TOOLS_NATIVE_MIGRATED_KEY, { value: "1" });
|
|
316
|
+
if (meta.get("tools_preset_catalog_migrated") === void 0 && listPresetTools !== void 0) {
|
|
317
|
+
let extra;
|
|
318
|
+
try {
|
|
319
|
+
extra = (await listPresetTools()).filter((n) => !NATIVE_TOOL_NAMES.includes(n));
|
|
320
|
+
} catch {
|
|
321
|
+
extra = void 0;
|
|
322
|
+
}
|
|
323
|
+
if (extra !== void 0 && extra.length > 0) {
|
|
324
|
+
for (const [id, record] of agents.entries()) {
|
|
325
|
+
if (record.builtin === true || record.tools === void 0) continue;
|
|
326
|
+
const missing = extra.filter((n) => !record.tools.allow.includes(n));
|
|
327
|
+
if (missing.length > 0) await agents.put(id, {
|
|
328
|
+
...record,
|
|
329
|
+
tools: { allow: [...record.tools.allow, ...missing] }
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
await meta.put(TOOLS_PRESET_MIGRATED_KEY, { value: "1" });
|
|
333
|
+
}
|
|
334
|
+
}
|
|
265
335
|
if (!(meta.get("explorer_readonly_migrated") !== void 0)) {
|
|
266
336
|
const explorer = agents.get("explorer");
|
|
267
337
|
if (explorer !== void 0 && explorer.tools === void 0) await agents.put("explorer", {
|
|
@@ -270,6 +340,24 @@ async function createRegistry(warn, tables) {
|
|
|
270
340
|
});
|
|
271
341
|
await meta.put(EXPLORER_READONLY_MIGRATED_KEY, { value: "1" });
|
|
272
342
|
}
|
|
343
|
+
if (meta.get("builtin_tools_recatalog_migrated") === void 0) {
|
|
344
|
+
const explorer = agents.get("explorer");
|
|
345
|
+
const legacyShapes = [LEGACY_EXPLORER_ALLOW, [
|
|
346
|
+
...LEGACY_EXPLORER_ALLOW,
|
|
347
|
+
"write",
|
|
348
|
+
"edit"
|
|
349
|
+
]];
|
|
350
|
+
if (explorer?.builtin === true && explorer.tools !== void 0 && legacyShapes.some((shape) => explorer.tools.allow.length === shape.length && explorer.tools.allow.every((n, i) => n === shape[i]))) await agents.put("explorer", {
|
|
351
|
+
...explorer,
|
|
352
|
+
tools: { allow: [...EXPLORER_READONLY_ALLOW] }
|
|
353
|
+
});
|
|
354
|
+
const general = agents.get("general");
|
|
355
|
+
if (general?.builtin === true && general.tools === void 0) await agents.put("general", {
|
|
356
|
+
...general,
|
|
357
|
+
tools: { allow: [...GENERAL_ALLOW] }
|
|
358
|
+
});
|
|
359
|
+
await meta.put(BUILTIN_TOOLS_RECATALOG_MIGRATED_KEY, { value: "1" });
|
|
360
|
+
}
|
|
273
361
|
await seedBuiltins(agents);
|
|
274
362
|
const cache = /* @__PURE__ */ new Map();
|
|
275
363
|
for (const [id, record] of agents.entries()) cache.set(id, record);
|
|
@@ -319,6 +407,27 @@ async function seedBuiltins(agents) {
|
|
|
319
407
|
for (const builtin of BUILTIN_AGENTS) if (agents.get(builtin.id) === void 0) await agents.put(builtin.id, builtin);
|
|
320
408
|
}
|
|
321
409
|
//#endregion
|
|
410
|
+
//#region src/agents/tool-catalog.ts
|
|
411
|
+
function createToolCatalog(ctx, presetId) {
|
|
412
|
+
return {
|
|
413
|
+
async listPresetTools() {
|
|
414
|
+
const presets = ctx.get("agentPresets", false);
|
|
415
|
+
if (presets === void 0) return [...NATIVE_TOOL_NAMES];
|
|
416
|
+
try {
|
|
417
|
+
const key = await presets.standingKeyFor(presetId);
|
|
418
|
+
const global = new Set(ctx.tools.schemas().map((s) => s.name));
|
|
419
|
+
return ctx.tools.schemas(key).map((s) => s.name).filter((n) => !global.has(n) && n !== RUN_CODE_NAME).sort();
|
|
420
|
+
} catch (error) {
|
|
421
|
+
ctx.logger.warn(`dsh-agent-toolkit: 枚举 preset "${presetId}" 工具面失败,回退内置常量:${error instanceof Error ? error.message : String(error)}`);
|
|
422
|
+
return [...NATIVE_TOOL_NAMES];
|
|
423
|
+
}
|
|
424
|
+
},
|
|
425
|
+
listGlobalTools() {
|
|
426
|
+
return ctx.tools.schemas().map((s) => s.name).filter((n) => n !== RUN_CODE_NAME);
|
|
427
|
+
}
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
//#endregion
|
|
322
431
|
//#region src/shared/storage.ts
|
|
323
432
|
function openDomainSafely(ctx, domain, warn, beforeClose) {
|
|
324
433
|
const ready = ctx.storageDomain.open(domain);
|
|
@@ -1313,10 +1422,19 @@ function createDelegateTool(toolName, deps) {
|
|
|
1313
1422
|
async execute(args, exec) {
|
|
1314
1423
|
const parent = exec.agent;
|
|
1315
1424
|
if (!parent) throw new Error("team_delegate 需要调用方 agent(exec.agent 为空)");
|
|
1316
|
-
const roster = deps.roster().filter((r) => r.id !== "main");
|
|
1425
|
+
const roster = deps.roster().filter((r) => r.id !== "main" && isTeamVisible(r));
|
|
1317
1426
|
const role = roster.find((r) => r.id === args.role);
|
|
1318
1427
|
if (!role) throw new Error(`未知角色 "${args.role}"。可用角色:${roster.map((r) => r.id).join(", ")}`);
|
|
1319
1428
|
const persona = deps.buildPersona(role);
|
|
1429
|
+
let toolFilter;
|
|
1430
|
+
if (role.tools !== void 0) {
|
|
1431
|
+
const visible = new Set(deps.visibleSurface(parent).filter((n) => n !== RUN_CODE_NAME));
|
|
1432
|
+
const effective = role.tools.allow.filter((n) => visible.has(n));
|
|
1433
|
+
const dropped = role.tools.allow.filter((n) => !visible.has(n));
|
|
1434
|
+
if (dropped.length > 0) deps.warn(`dsh-agent-toolkit: 角色 ${role.id} 白名单含本会话不可见工具,委派时忽略:${dropped.join(", ")}`);
|
|
1435
|
+
if (effective.length === 0) throw new Error(`dsh-agent-toolkit: 角色 ${role.id} 工具白名单求交后为空(原 ${role.tools.allow.length} 个均不可见):${role.tools.allow.join(", ")}`);
|
|
1436
|
+
toolFilter = { allow: effective };
|
|
1437
|
+
}
|
|
1320
1438
|
const request = {
|
|
1321
1439
|
label: `role:${role.id}: ${args.description}`,
|
|
1322
1440
|
prompt: [{
|
|
@@ -1331,7 +1449,7 @@ function createDelegateTool(toolName, deps) {
|
|
|
1331
1449
|
provider: role.model.provider,
|
|
1332
1450
|
model: role.model.model
|
|
1333
1451
|
} } : {},
|
|
1334
|
-
...
|
|
1452
|
+
...toolFilter !== void 0 ? { toolFilter } : {}
|
|
1335
1453
|
};
|
|
1336
1454
|
const route = (role.model !== void 0 && role.model.provider !== "" && role.model.model !== "" ? role.model : void 0) ?? (typeof parent.options.provider === "string" && parent.options.provider !== "" && typeof parent.options.model === "string" && parent.options.model !== "" ? {
|
|
1337
1455
|
provider: parent.options.provider,
|
|
@@ -1360,7 +1478,7 @@ const TEAM_SECTION_ORDER = 116.6;
|
|
|
1360
1478
|
*/
|
|
1361
1479
|
function teamSectionText(toolName, roles, toolVisible) {
|
|
1362
1480
|
if (!toolVisible) return "";
|
|
1363
|
-
return `你有一组可委派的成员:用 ${toolName} 把自包含的子任务委派给合适的成员,成员结果会作为工具返回值回到本对话。\n可用成员:\n${roles.filter((r) => r.id !== "main").map((r) => `${r.id}: ${r.description ?? r.name}`).join("\n")}`;
|
|
1481
|
+
return `你有一组可委派的成员:用 ${toolName} 把自包含的子任务委派给合适的成员,成员结果会作为工具返回值回到本对话。\n可用成员:\n${roles.filter((r) => r.id !== "main" && isTeamVisible(r)).map((r) => `${r.id}: ${r.description ?? r.name}`).join("\n")}`;
|
|
1364
1482
|
}
|
|
1365
1483
|
/**
|
|
1366
1484
|
* 挂载 team_delegate 工具与函数式团队提示段。
|
|
@@ -1383,7 +1501,11 @@ function setupDelegate(ctx, config, registry, channels) {
|
|
|
1383
1501
|
buildPersona: (role) => buildAgentPersona({ rules: config.rules }, role, role.model),
|
|
1384
1502
|
startRun: (pr, request) => ctx.subagents.start(pr, request),
|
|
1385
1503
|
active: channels.active,
|
|
1386
|
-
recordRoute: channels.recordRoute
|
|
1504
|
+
recordRoute: channels.recordRoute,
|
|
1505
|
+
visibleSurface: (agent) => agent.ctx.tools.schemas(scopeOf(agent.ctx)).map((s) => s.name),
|
|
1506
|
+
warn: (msg) => {
|
|
1507
|
+
ctx.logger.warn(msg);
|
|
1508
|
+
}
|
|
1387
1509
|
}));
|
|
1388
1510
|
};
|
|
1389
1511
|
ctx.on("subagent/provider-added", (p) => {
|
|
@@ -1504,7 +1626,7 @@ function createAgentsApiHandler(deps) {
|
|
|
1504
1626
|
}
|
|
1505
1627
|
if (sub === "/tools" && method === "GET") {
|
|
1506
1628
|
json$1(res, 200, {
|
|
1507
|
-
|
|
1629
|
+
preset: await deps.listPresetTools(),
|
|
1508
1630
|
global: deps.listTools()
|
|
1509
1631
|
});
|
|
1510
1632
|
return;
|
|
@@ -1631,7 +1753,7 @@ function buildCreateAgentGuidance(input) {
|
|
|
1631
1753
|
"3. 迭代:用户有修改意见时按意见修订名称、描述、个性和工具后再次确认,直到用户明确确认。"
|
|
1632
1754
|
];
|
|
1633
1755
|
if (input.requirement !== "") lines.push("", "## 用户初始需求", `用户已在命令中提供初始需求:「${input.requirement}」。请据此减少提问轮次,仅就不明确的点提问。`);
|
|
1634
|
-
lines.push("", "## 现有 Agent id(不可复用)", input.agentIds.join(", "), "id 规则:小写字母开头,仅含小写字母/数字/连字符([a-z0-9-]),最长 32 字符。", "", "## 可用工具清单",
|
|
1756
|
+
lines.push("", "## 现有 Agent id(不可复用)", input.agentIds.join(", "), "id 规则:小写字母开头,仅含小写字母/数字/连字符([a-z0-9-]),最长 32 字符。", "", "## 可用工具清单", `团队 preset 工具:${input.presetTools.join(", ")}`, `全局工具:${input.globalTools.join(", ")}`, "省略 tools 字段表示不限制(Agent 可使用全部工具)。一旦给出白名单,该 Agent 只有列出的工具可用:通常应保留 read/glob/grep/shell 等基础工具,否则失去读文件/搜索/执行命令等基本能力(最终取舍按需求判断,如只读角色可去掉 write/edit)。");
|
|
1635
1757
|
if (input.origin === void 0) lines.push("", "## 落库", "当前宿主无 web 服务,无法自动落库。用户确认推荐后,请把最终配置完整输出给用户,并提示其打开 Agents 面板按推荐内容手动创建。");
|
|
1636
1758
|
else lines.push("", "## 落库(用户明确确认后执行)", "用你的 shell 工具调用 Agents 面板同一 HTTP 端点完成创建:", `1. 先 GET ${input.origin}/dsh-agent-toolkit/api/agents 复核所选 id 仍未被占用;`, `2. 再 PUT ${input.origin}/dsh-agent-toolkit/api/agents/<id>,请求体为 JSON(不要在 body 中携带 id 或 builtin 字段):`, " {\"name\":\"...\",\"description\":\"...\",\"persona\":\"...\",\"tools\":{\"allow\":[\"...\"]}}", " (description/persona/tools 均可省略;省略 tools 表示不限制)", "3. curl 示例(Windows 的 pwsh 里用 curl.exe):", ` curl.exe -s -X PUT "${input.origin}/dsh-agent-toolkit/api/agents/<id>" -H "Content-Type: application/json" -d "{\\"name\\":\\"...\\"}"`, `4. 返回 200 后必须再 GET ${input.origin}/dsh-agent-toolkit/api/agents,在返回列表中找到该 id 的记录,把它的 name/description/persona/tools 关键字段展示给用户,作为落库证据;`, "5. 落库成功后告知用户可在 Agents 面板查看、并可被 team_delegate 委派;任一步返回 4xx 则把错误信息展示给用户,修正后重试。");
|
|
1637
1759
|
return lines.join("\n");
|
|
@@ -1648,12 +1770,13 @@ function setupCreateAgentCommand(ctx, deps) {
|
|
|
1648
1770
|
name: "create-agent",
|
|
1649
1771
|
description: "交互式创建 Agent 团队成员:访谈澄清需求 → 推荐配置 → 确认后经面板 API 落库",
|
|
1650
1772
|
input: { hint: "初始需求描述,可空" },
|
|
1651
|
-
handler: ({ rawInput, agent }) => {
|
|
1773
|
+
handler: async ({ rawInput, agent }) => {
|
|
1652
1774
|
const webServer = ctx.get("webServer");
|
|
1653
1775
|
const origin = webServer === void 0 ? void 0 : `http://127.0.0.1:${webServer.port}`;
|
|
1654
1776
|
const text = buildCreateAgentGuidance({
|
|
1655
1777
|
requirement: rawInput.trim(),
|
|
1656
1778
|
agentIds: deps.registry.list().map((agent) => agent.id),
|
|
1779
|
+
presetTools: await deps.listPresetTools(),
|
|
1657
1780
|
globalTools: deps.listTools(),
|
|
1658
1781
|
origin
|
|
1659
1782
|
});
|
|
@@ -1682,8 +1805,8 @@ const TOOLKIT_PERSONA_SECTION = "prompt-stack:persona";
|
|
|
1682
1805
|
* (global + 祖先 scope 层),own 层(agentCtx 直接挂载)的名字既不可 restrict 也
|
|
1683
1806
|
* 不受 restrict 影响,直接挂进 agentCtx 的白名单会抛 unknown global tools。
|
|
1684
1807
|
*/
|
|
1685
|
-
async function setupAgentScope(agentCtx, hooks,
|
|
1686
|
-
await
|
|
1808
|
+
async function setupAgentScope(agentCtx, hooks, joiner) {
|
|
1809
|
+
await joiner.join(agentCtx);
|
|
1687
1810
|
if (hooks.persona !== void 0) agentCtx.systemPrompt.section({
|
|
1688
1811
|
name: TOOLKIT_PERSONA_SECTION,
|
|
1689
1812
|
order: 10,
|
|
@@ -1694,10 +1817,56 @@ async function setupAgentScope(agentCtx, hooks, toolsScope) {
|
|
|
1694
1817
|
order: section.order,
|
|
1695
1818
|
text: section.text
|
|
1696
1819
|
});
|
|
1697
|
-
if (hooks.tools !== void 0)
|
|
1820
|
+
if (hooks.tools !== void 0) {
|
|
1821
|
+
const visible = new Set(agentCtx.tools.schemas(scopeOf(agentCtx)).map((s) => s.name).filter((n) => n !== RUN_CODE_NAME));
|
|
1822
|
+
const effective = hooks.tools.filter((n) => visible.has(n));
|
|
1823
|
+
const dropped = hooks.tools.filter((n) => !visible.has(n));
|
|
1824
|
+
if (dropped.length > 0) agentCtx.logger.warn(`dsh-agent-toolkit: 工具白名单含本会话不可见工具,已忽略:${dropped.join(", ")}`);
|
|
1825
|
+
if (effective.length === 0) throw new Error(`dsh-agent-toolkit: 工具白名单求交后为空(原 ${hooks.tools.length} 个均不可见):${hooks.tools.join(", ")}`);
|
|
1826
|
+
agentCtx.tools.restrict({ allow: effective });
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1829
|
+
//#endregion
|
|
1830
|
+
//#region src/channels/agents-port.ts
|
|
1831
|
+
function createAgentsPort(ctx, joiner, ownedSessions) {
|
|
1832
|
+
function adaptAgent(handle) {
|
|
1833
|
+
const { agent } = handle;
|
|
1834
|
+
return {
|
|
1835
|
+
sessionId: String(agent.id),
|
|
1836
|
+
followup: (message) => agent.followup(message),
|
|
1837
|
+
cancel: () => agent.cancel({ kind: "user" }),
|
|
1838
|
+
whenIdle: () => agent.whenIdle()
|
|
1839
|
+
};
|
|
1840
|
+
}
|
|
1841
|
+
return {
|
|
1842
|
+
async create(input) {
|
|
1843
|
+
ownedSessions?.add(input.sessionId);
|
|
1844
|
+
return adaptAgent(await ctx.agents.create({
|
|
1845
|
+
sessionId: SessionId(input.sessionId),
|
|
1846
|
+
meta: { cwd: input.cwd },
|
|
1847
|
+
...input.agentOptions !== void 0 ? { agentOptions: input.agentOptions } : {},
|
|
1848
|
+
setup: (agentCtx) => setupAgentScope(agentCtx, input.hooks, joiner)
|
|
1849
|
+
}));
|
|
1850
|
+
},
|
|
1851
|
+
async resume(input) {
|
|
1852
|
+
ownedSessions?.add(input.sessionId);
|
|
1853
|
+
return adaptAgent(await ctx.agents.resume({
|
|
1854
|
+
resumeSessionId: SessionId(input.sessionId),
|
|
1855
|
+
...input.agentOptions !== void 0 ? { agentOptions: input.agentOptions } : {},
|
|
1856
|
+
setup: (agentCtx) => setupAgentScope(agentCtx, input.hooks, joiner)
|
|
1857
|
+
}));
|
|
1858
|
+
}
|
|
1859
|
+
};
|
|
1698
1860
|
}
|
|
1699
1861
|
//#endregion
|
|
1700
1862
|
//#region src/channels/feishu/api.ts
|
|
1863
|
+
/** 从 lark SDK 抛出的 axios 错误提取飞书业务错误码(无则 undefined)。 */
|
|
1864
|
+
function feishuErrorCode(error) {
|
|
1865
|
+
const data = error?.response?.data;
|
|
1866
|
+
if (typeof data !== "object" || data === null) return void 0;
|
|
1867
|
+
const code = data.code;
|
|
1868
|
+
return typeof code === "number" ? code : void 0;
|
|
1869
|
+
}
|
|
1701
1870
|
/** 附件服务接受的图片媒体类型(与宿主 attachment v1 一致)。 */
|
|
1702
1871
|
const IMAGE_MEDIA_TYPES = [
|
|
1703
1872
|
"image/png",
|
|
@@ -1835,6 +2004,15 @@ function createFeishuApi(client) {
|
|
|
1835
2004
|
data,
|
|
1836
2005
|
mediaType
|
|
1837
2006
|
};
|
|
2007
|
+
},
|
|
2008
|
+
async getBotOpenId() {
|
|
2009
|
+
const res = await client.request({
|
|
2010
|
+
method: "GET",
|
|
2011
|
+
url: "https://open.feishu.cn/open-apis/bot/v3/info"
|
|
2012
|
+
});
|
|
2013
|
+
const openId = res.bot?.open_id;
|
|
2014
|
+
if (typeof openId !== "string" || openId.length === 0) throw new Error(`获取机器人信息失败:code=${res.code} msg=${res.msg}`);
|
|
2015
|
+
return openId;
|
|
1838
2016
|
}
|
|
1839
2017
|
};
|
|
1840
2018
|
}
|
|
@@ -1888,8 +2066,10 @@ function parsePostContent(content) {
|
|
|
1888
2066
|
* SDK handler 收到的 data 即事件体(README 示例 `data.message` 直接解构);
|
|
1889
2067
|
* 兼容包一层 { event } 的形态。过滤:机器人消息、非 text/post/image 类型、
|
|
1890
2068
|
* 群内未 @机器人、无文本且无图片。
|
|
2069
|
+
* 群消息 @ 校验身份:mention 的 open_id 必须等于本机器人(应用若持全部群消息权限,
|
|
2070
|
+
* 会收到 @ 其他机器人的消息,不能误触发)。
|
|
1891
2071
|
*/
|
|
1892
|
-
function parseMessageEvent(data) {
|
|
2072
|
+
function parseMessageEvent(data, botOpenId) {
|
|
1893
2073
|
const wrapped = data;
|
|
1894
2074
|
const event = wrapped.event ?? wrapped;
|
|
1895
2075
|
if (event.sender?.sender_type !== "user") return null;
|
|
@@ -1900,7 +2080,7 @@ function parseMessageEvent(data) {
|
|
|
1900
2080
|
if (typeof msg.content !== "string") return null;
|
|
1901
2081
|
if (typeof msg.message_id !== "string" || typeof msg.chat_id !== "string") return null;
|
|
1902
2082
|
if (msg.chat_type !== "p2p" && msg.chat_type !== "group") return null;
|
|
1903
|
-
if (msg.chat_type === "group" && !(msg.mentions ?? []).some((m) => m.mentioned_type === "bot")) return null;
|
|
2083
|
+
if (msg.chat_type === "group" && !(msg.mentions ?? []).some((m) => m.mentioned_type === "bot" && m.id?.open_id === botOpenId)) return null;
|
|
1904
2084
|
let text = "";
|
|
1905
2085
|
let imageKeys = [];
|
|
1906
2086
|
if (msg.message_type === "text") try {
|
|
@@ -1962,27 +2142,10 @@ const STATUS_FINAL = {
|
|
|
1962
2142
|
error: "❌ 输出出错",
|
|
1963
2143
|
cancelled: "⏹ 已取消"
|
|
1964
2144
|
};
|
|
1965
|
-
/**
|
|
1966
|
-
const
|
|
2145
|
+
/** 拆卡定格状态行文案(旧卡内容已接续到下一张卡片)。 */
|
|
2146
|
+
const STATUS_CONTINUED = "📦 内容较长,已接续到下一张卡片";
|
|
1967
2147
|
/** 单卡组件数安全上限(飞书硬上限 200;面板按 2 计:面板 + 内嵌 markdown)。 */
|
|
1968
2148
|
const CARD_ELEMENT_LIMIT = 190;
|
|
1969
|
-
/** 按 UTF-8 字节上限截头(保留头部),不劈开多字节字符与代理对。 */
|
|
1970
|
-
function sliceByBytes(text, maxBytes) {
|
|
1971
|
-
if (Buffer.byteLength(text, "utf8") <= maxBytes) return text;
|
|
1972
|
-
let lo = 0;
|
|
1973
|
-
let hi = text.length;
|
|
1974
|
-
while (lo < hi) {
|
|
1975
|
-
const mid = Math.ceil((lo + hi) / 2);
|
|
1976
|
-
if (Buffer.byteLength(text.slice(0, mid), "utf8") <= maxBytes) lo = mid;
|
|
1977
|
-
else hi = mid - 1;
|
|
1978
|
-
}
|
|
1979
|
-
let cut = lo;
|
|
1980
|
-
if (cut > 0) {
|
|
1981
|
-
const code = text.charCodeAt(cut - 1);
|
|
1982
|
-
if (code >= 55296 && code <= 56319) cut -= 1;
|
|
1983
|
-
}
|
|
1984
|
-
return text.slice(0, cut);
|
|
1985
|
-
}
|
|
1986
2149
|
/** 按 UTF-8 字节上限截尾(保留尾部),不劈开多字节字符与代理对;截断时头部加省略标记。 */
|
|
1987
2150
|
function sliceTailByBytes(text, maxBytes) {
|
|
1988
2151
|
if (Buffer.byteLength(text, "utf8") <= maxBytes) return text;
|
|
@@ -2002,8 +2165,29 @@ function sliceTailByBytes(text, maxBytes) {
|
|
|
2002
2165
|
}
|
|
2003
2166
|
return PROCESS_OMITTED + text.slice(cut);
|
|
2004
2167
|
}
|
|
2168
|
+
/** JSON 串内内容的转义后字节数(去首尾引号);卡片 DSL 真实字节记账用。 */
|
|
2169
|
+
function escapedLen(s) {
|
|
2170
|
+
return Buffer.byteLength(JSON.stringify(s), "utf8") - 2;
|
|
2171
|
+
}
|
|
2172
|
+
/** 按转义后字节上限截头(保留头部),不劈开多字节字符与代理对。 */
|
|
2173
|
+
function sliceByEscapedBytes(text, maxBytes) {
|
|
2174
|
+
if (escapedLen(text) <= maxBytes) return text;
|
|
2175
|
+
let lo = 0;
|
|
2176
|
+
let hi = text.length;
|
|
2177
|
+
while (lo < hi) {
|
|
2178
|
+
const mid = Math.ceil((lo + hi) / 2);
|
|
2179
|
+
if (escapedLen(text.slice(0, mid)) <= maxBytes) lo = mid;
|
|
2180
|
+
else hi = mid - 1;
|
|
2181
|
+
}
|
|
2182
|
+
let cut = lo;
|
|
2183
|
+
if (cut > 0) {
|
|
2184
|
+
const code = text.charCodeAt(cut - 1);
|
|
2185
|
+
if (code >= 55296 && code <= 56319) cut -= 1;
|
|
2186
|
+
}
|
|
2187
|
+
return text.slice(0, cut);
|
|
2188
|
+
}
|
|
2005
2189
|
/** 新卡:仅状态行的流式卡;段后续经插入组件 API 动态加入。 */
|
|
2006
|
-
function buildCardJson() {
|
|
2190
|
+
function buildCardJson(printStep) {
|
|
2007
2191
|
return JSON.stringify({
|
|
2008
2192
|
schema: "2.0",
|
|
2009
2193
|
config: {
|
|
@@ -2011,7 +2195,7 @@ function buildCardJson() {
|
|
|
2011
2195
|
summary: { content: "生成中…" },
|
|
2012
2196
|
streaming_config: {
|
|
2013
2197
|
print_frequency_ms: { default: 70 },
|
|
2014
|
-
print_step: { default:
|
|
2198
|
+
print_step: { default: printStep },
|
|
2015
2199
|
print_strategy: "fast"
|
|
2016
2200
|
}
|
|
2017
2201
|
},
|
|
@@ -2054,30 +2238,56 @@ const initialStreamState = () => ({
|
|
|
2054
2238
|
carry: void 0
|
|
2055
2239
|
});
|
|
2056
2240
|
/** 把段序列的新增部分同步到卡片;新段 insert 到状态行之前,尾段增长走元素 update,满卡关流开续卡。 */
|
|
2057
|
-
function planSync(state, segments, maxBytes, processMaxBytes) {
|
|
2241
|
+
function planSync(state, segments, maxBytes, processMaxBytes, printStep) {
|
|
2058
2242
|
const ops = [];
|
|
2059
2243
|
let { cardId, seq, cardBytes, cardElements, segCounter, closedSegCount, tail, carry } = state;
|
|
2060
|
-
|
|
2061
|
-
|
|
2244
|
+
/** 约定:先改规划局部变量再 push——commit 捕获该 op 完成后的状态快照。 */
|
|
2245
|
+
const push = (op) => {
|
|
2246
|
+
const snap = {
|
|
2247
|
+
cardId,
|
|
2248
|
+
seq,
|
|
2249
|
+
cardBytes,
|
|
2250
|
+
cardElements,
|
|
2251
|
+
segCounter,
|
|
2252
|
+
closedSegCount,
|
|
2253
|
+
tail,
|
|
2254
|
+
carry
|
|
2255
|
+
};
|
|
2062
2256
|
ops.push({
|
|
2063
|
-
|
|
2064
|
-
|
|
2257
|
+
op,
|
|
2258
|
+
commit: () => ({ ...snap })
|
|
2065
2259
|
});
|
|
2066
|
-
|
|
2260
|
+
};
|
|
2261
|
+
const ensureCard = () => {
|
|
2262
|
+
if (cardId !== null) return;
|
|
2263
|
+
const cardJson = buildCardJson(printStep);
|
|
2067
2264
|
cardId = PENDING_CARD_ID;
|
|
2068
2265
|
seq = 0;
|
|
2069
|
-
cardBytes =
|
|
2266
|
+
cardBytes = Buffer.byteLength(cardJson, "utf8");
|
|
2070
2267
|
cardElements = 1;
|
|
2268
|
+
push({
|
|
2269
|
+
type: "create",
|
|
2270
|
+
cardJson
|
|
2271
|
+
});
|
|
2272
|
+
push({ type: "send" });
|
|
2071
2273
|
};
|
|
2072
2274
|
const closeCard = () => {
|
|
2073
2275
|
seq += 1;
|
|
2074
|
-
|
|
2075
|
-
type: "
|
|
2076
|
-
|
|
2276
|
+
push({
|
|
2277
|
+
type: "update",
|
|
2278
|
+
elementId: STATUS_ELEMENT_ID,
|
|
2279
|
+
content: STATUS_CONTINUED,
|
|
2077
2280
|
sequence: seq
|
|
2078
2281
|
});
|
|
2282
|
+
seq += 1;
|
|
2079
2283
|
cardId = null;
|
|
2080
2284
|
tail = void 0;
|
|
2285
|
+
push({
|
|
2286
|
+
type: "settings",
|
|
2287
|
+
streaming: false,
|
|
2288
|
+
sequence: seq,
|
|
2289
|
+
summary: STATUS_CONTINUED
|
|
2290
|
+
});
|
|
2081
2291
|
};
|
|
2082
2292
|
let i = tail?.segIndex ?? closedSegCount;
|
|
2083
2293
|
while (i < segments.length) {
|
|
@@ -2087,35 +2297,35 @@ function planSync(state, segments, maxBytes, processMaxBytes) {
|
|
|
2087
2297
|
const elementContent = seg.kind === "text" ? content.slice(base) : content;
|
|
2088
2298
|
if (tail !== void 0 && tail.segIndex === i) {
|
|
2089
2299
|
if (elementContent !== tail.shownText) {
|
|
2090
|
-
const delta =
|
|
2300
|
+
const delta = escapedLen(elementContent) - escapedLen(tail.shownText);
|
|
2091
2301
|
if (cardBytes + delta <= maxBytes) {
|
|
2092
2302
|
seq += 1;
|
|
2093
|
-
ops.push({
|
|
2094
|
-
type: "update",
|
|
2095
|
-
elementId: tail.elementId,
|
|
2096
|
-
content: elementContent,
|
|
2097
|
-
sequence: seq
|
|
2098
|
-
});
|
|
2099
2303
|
cardBytes += delta;
|
|
2100
2304
|
tail = {
|
|
2101
2305
|
...tail,
|
|
2102
2306
|
shownText: elementContent
|
|
2103
2307
|
};
|
|
2308
|
+
push({
|
|
2309
|
+
type: "update",
|
|
2310
|
+
elementId: tail.elementId,
|
|
2311
|
+
content: elementContent,
|
|
2312
|
+
sequence: seq
|
|
2313
|
+
});
|
|
2104
2314
|
} else if (seg.kind === "text") {
|
|
2105
|
-
const piece =
|
|
2315
|
+
const piece = sliceByEscapedBytes(elementContent, escapedLen(tail.shownText) + (maxBytes - cardBytes));
|
|
2106
2316
|
if (piece.length > tail.shownText.length) {
|
|
2107
2317
|
seq += 1;
|
|
2108
|
-
|
|
2318
|
+
cardBytes += escapedLen(piece) - escapedLen(tail.shownText);
|
|
2319
|
+
tail = {
|
|
2320
|
+
...tail,
|
|
2321
|
+
shownText: piece
|
|
2322
|
+
};
|
|
2323
|
+
push({
|
|
2109
2324
|
type: "update",
|
|
2110
2325
|
elementId: tail.elementId,
|
|
2111
2326
|
content: piece,
|
|
2112
2327
|
sequence: seq
|
|
2113
2328
|
});
|
|
2114
|
-
cardBytes += Buffer.byteLength(piece, "utf8") - Buffer.byteLength(tail.shownText, "utf8");
|
|
2115
|
-
tail = {
|
|
2116
|
-
...tail,
|
|
2117
|
-
shownText: piece
|
|
2118
|
-
};
|
|
2119
2329
|
}
|
|
2120
2330
|
carry = {
|
|
2121
2331
|
segIndex: i,
|
|
@@ -2136,21 +2346,17 @@ function planSync(state, segments, maxBytes, processMaxBytes) {
|
|
|
2136
2346
|
continue;
|
|
2137
2347
|
}
|
|
2138
2348
|
if (seg.kind === "process") {
|
|
2139
|
-
const
|
|
2140
|
-
|
|
2349
|
+
const elementId = `seg_${segCounter + 1}`;
|
|
2350
|
+
const elementJson = buildSegmentJson("process", elementId, elementContent);
|
|
2351
|
+
const elBytes = Buffer.byteLength(elementJson, "utf8");
|
|
2352
|
+
if (cardId !== null && (cardBytes + elBytes > maxBytes || cardElements + 2 > CARD_ELEMENT_LIMIT)) {
|
|
2141
2353
|
closeCard();
|
|
2142
2354
|
continue;
|
|
2143
2355
|
}
|
|
2144
2356
|
ensureCard();
|
|
2145
2357
|
segCounter += 1;
|
|
2146
|
-
const elementId = `seg_${segCounter}`;
|
|
2147
2358
|
seq += 1;
|
|
2148
|
-
|
|
2149
|
-
type: "insert",
|
|
2150
|
-
elementJson: buildSegmentJson("process", elementId, elementContent),
|
|
2151
|
-
sequence: seq
|
|
2152
|
-
});
|
|
2153
|
-
cardBytes += windowBytes;
|
|
2359
|
+
cardBytes += elBytes;
|
|
2154
2360
|
cardElements += 2;
|
|
2155
2361
|
tail = {
|
|
2156
2362
|
segIndex: i,
|
|
@@ -2158,6 +2364,11 @@ function planSync(state, segments, maxBytes, processMaxBytes) {
|
|
|
2158
2364
|
base: 0,
|
|
2159
2365
|
shownText: elementContent
|
|
2160
2366
|
};
|
|
2367
|
+
push({
|
|
2368
|
+
type: "insert",
|
|
2369
|
+
elementJson,
|
|
2370
|
+
sequence: seq
|
|
2371
|
+
});
|
|
2161
2372
|
} else {
|
|
2162
2373
|
if (elementContent.length === 0) {
|
|
2163
2374
|
closedSegCount = i + 1;
|
|
@@ -2170,17 +2381,14 @@ function planSync(state, segments, maxBytes, processMaxBytes) {
|
|
|
2170
2381
|
continue;
|
|
2171
2382
|
}
|
|
2172
2383
|
ensureCard();
|
|
2173
|
-
const
|
|
2174
|
-
|
|
2384
|
+
const elementId = `seg_${segCounter + 1}`;
|
|
2385
|
+
const overhead = Buffer.byteLength(buildSegmentJson("text", elementId, ""), "utf8");
|
|
2386
|
+
const piece = sliceByEscapedBytes(elementContent, maxBytes - cardBytes - overhead);
|
|
2387
|
+
if (piece.length === 0) throw new Error(`cardMaxBytes=${maxBytes} 过小,扣基础卡与元素开销后连一个字符都容纳不了`);
|
|
2175
2388
|
segCounter += 1;
|
|
2176
|
-
const
|
|
2389
|
+
const elementJson = buildSegmentJson("text", elementId, piece);
|
|
2177
2390
|
seq += 1;
|
|
2178
|
-
|
|
2179
|
-
type: "insert",
|
|
2180
|
-
elementJson: buildSegmentJson("text", elementId, piece),
|
|
2181
|
-
sequence: seq
|
|
2182
|
-
});
|
|
2183
|
-
cardBytes += Buffer.byteLength(piece, "utf8");
|
|
2391
|
+
cardBytes += Buffer.byteLength(elementJson, "utf8");
|
|
2184
2392
|
cardElements += 1;
|
|
2185
2393
|
tail = {
|
|
2186
2394
|
segIndex: i,
|
|
@@ -2188,6 +2396,11 @@ function planSync(state, segments, maxBytes, processMaxBytes) {
|
|
|
2188
2396
|
base,
|
|
2189
2397
|
shownText: piece
|
|
2190
2398
|
};
|
|
2399
|
+
push({
|
|
2400
|
+
type: "insert",
|
|
2401
|
+
elementJson,
|
|
2402
|
+
sequence: seq
|
|
2403
|
+
});
|
|
2191
2404
|
if (piece.length < elementContent.length) {
|
|
2192
2405
|
carry = {
|
|
2193
2406
|
segIndex: i,
|
|
@@ -2203,19 +2416,21 @@ function planSync(state, segments, maxBytes, processMaxBytes) {
|
|
|
2203
2416
|
closedSegCount = i + 1;
|
|
2204
2417
|
i += 1;
|
|
2205
2418
|
}
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
carry
|
|
2216
|
-
},
|
|
2217
|
-
ops
|
|
2419
|
+
const snap = {
|
|
2420
|
+
cardId,
|
|
2421
|
+
seq,
|
|
2422
|
+
cardBytes,
|
|
2423
|
+
cardElements,
|
|
2424
|
+
segCounter,
|
|
2425
|
+
closedSegCount,
|
|
2426
|
+
tail,
|
|
2427
|
+
carry
|
|
2218
2428
|
};
|
|
2429
|
+
ops.push({
|
|
2430
|
+
op: { type: "noop" },
|
|
2431
|
+
commit: () => ({ ...snap })
|
|
2432
|
+
});
|
|
2433
|
+
return { ops };
|
|
2219
2434
|
}
|
|
2220
2435
|
/** 定格:先 update 状态行(流式还开着),再关闭 + summary。 */
|
|
2221
2436
|
function planFinalize(state, status) {
|
|
@@ -2245,6 +2460,10 @@ async function withRetry(fn, attempts = 3, baseDelayMs = 300) {
|
|
|
2245
2460
|
}
|
|
2246
2461
|
throw lastError;
|
|
2247
2462
|
}
|
|
2463
|
+
/** 卡片输出异常时的用户提示(仅真实废弃一张已发卡时发送)。 */
|
|
2464
|
+
const ABANDON_NOTICE = "⚠️ 卡片输出异常,已在新卡片继续;如有内容缺失请重发。";
|
|
2465
|
+
/** 单次 flush 内连续废弃换卡的上限(防异常死循环;超限抛给出站链日志)。 */
|
|
2466
|
+
const MAX_ABANDON_PER_FLUSH = 3;
|
|
2248
2467
|
var FeishuReplyHandle = class {
|
|
2249
2468
|
api;
|
|
2250
2469
|
chatId;
|
|
@@ -2254,6 +2473,7 @@ var FeishuReplyHandle = class {
|
|
|
2254
2473
|
segments = [];
|
|
2255
2474
|
tail = Promise.resolve();
|
|
2256
2475
|
timer;
|
|
2476
|
+
planQueued = false;
|
|
2257
2477
|
finalized = false;
|
|
2258
2478
|
constructor(api, chatId, tunables, log) {
|
|
2259
2479
|
this.api = api;
|
|
@@ -2261,7 +2481,6 @@ var FeishuReplyHandle = class {
|
|
|
2261
2481
|
this.tunables = tunables;
|
|
2262
2482
|
this.log = log;
|
|
2263
2483
|
}
|
|
2264
|
-
/** 惰性建卡:无文本输出的 turn 不产生空卡片。 */
|
|
2265
2484
|
beginTurn() {
|
|
2266
2485
|
return Promise.resolve();
|
|
2267
2486
|
}
|
|
@@ -2286,49 +2505,204 @@ var FeishuReplyHandle = class {
|
|
|
2286
2505
|
}
|
|
2287
2506
|
this.flush();
|
|
2288
2507
|
await this.tail;
|
|
2508
|
+
const hadCard = this.state.cardId !== null;
|
|
2289
2509
|
const { ops } = planFinalize(this.state, status);
|
|
2290
|
-
this.enqueue(() =>
|
|
2291
|
-
|
|
2510
|
+
this.enqueue(async () => {
|
|
2511
|
+
for (const op of ops) if (await this.execOne({
|
|
2512
|
+
op,
|
|
2513
|
+
commit: (s) => s
|
|
2514
|
+
}) === "abandoned") return;
|
|
2515
|
+
});
|
|
2516
|
+
if (!hadCard && detail !== void 0) this.enqueue(() => withRetry(() => this.api.sendText(this.chatId, detail)).then(() => void 0));
|
|
2292
2517
|
await this.tail;
|
|
2293
2518
|
}
|
|
2294
2519
|
notice(text) {
|
|
2295
2520
|
this.enqueue(() => withRetry(() => this.api.sendText(this.chatId, text)).then(() => void 0));
|
|
2296
2521
|
return this.tail.then(() => void 0);
|
|
2297
2522
|
}
|
|
2523
|
+
/**
|
|
2524
|
+
* 规划入串行链:planSync 在执行点读最新已确认状态与最新 segments,
|
|
2525
|
+
* 在飞期间到达的 flush 只标位不重复规划(杜绝重复建卡/重复 insert)。
|
|
2526
|
+
*/
|
|
2298
2527
|
flush() {
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
this.
|
|
2302
|
-
|
|
2528
|
+
if (this.planQueued) return;
|
|
2529
|
+
this.planQueued = true;
|
|
2530
|
+
this.enqueue(async () => {
|
|
2531
|
+
this.planQueued = false;
|
|
2532
|
+
const { ops } = planSync(this.state, this.segments, this.tunables.cardMaxBytes, this.tunables.processMaxBytes, this.tunables.cardPrintStep);
|
|
2533
|
+
await this.exec(ops);
|
|
2534
|
+
});
|
|
2535
|
+
}
|
|
2536
|
+
/** 逐 op 确认执行;遇废弃从已确认状态重新规划续写(上限 MAX_ABANDON_PER_FLUSH 次)。 */
|
|
2537
|
+
async exec(ops) {
|
|
2538
|
+
let pending = ops;
|
|
2539
|
+
for (let attempts = 0;; attempts++) {
|
|
2540
|
+
let abandoned = false;
|
|
2541
|
+
for (const planned of pending) if (await this.execOne(planned) === "abandoned") {
|
|
2542
|
+
abandoned = true;
|
|
2543
|
+
break;
|
|
2544
|
+
}
|
|
2545
|
+
if (!abandoned) return;
|
|
2546
|
+
if (attempts >= MAX_ABANDON_PER_FLUSH) throw new Error("卡片连续废弃超限,本批输出放弃(下一 flush 继续)");
|
|
2547
|
+
pending = planSync(this.state, this.segments, this.tunables.cardMaxBytes, this.tunables.processMaxBytes, this.tunables.cardPrintStep).ops;
|
|
2548
|
+
}
|
|
2549
|
+
}
|
|
2550
|
+
/**
|
|
2551
|
+
* 单个 op:成功(或 insert 300301 视同成功)才 commit;
|
|
2552
|
+
* 失败按错误码分类:流式超时重激活重放 / 200860 废弃续写 / 未知错误 seq+2 重放一次。
|
|
2553
|
+
*/
|
|
2554
|
+
async execOne(planned) {
|
|
2555
|
+
const { op } = planned;
|
|
2556
|
+
if (op.type === "noop") {
|
|
2557
|
+
this.commit(planned);
|
|
2558
|
+
return "ok";
|
|
2559
|
+
}
|
|
2560
|
+
const liveCard = this.state.cardId !== null && this.state.cardId !== "__pending__";
|
|
2561
|
+
try {
|
|
2562
|
+
await this.invokeThenCommit(planned);
|
|
2563
|
+
return "ok";
|
|
2564
|
+
} catch (error) {
|
|
2565
|
+
if (!liveCard) throw error;
|
|
2566
|
+
const code = feishuErrorCode(error);
|
|
2567
|
+
if (op.type === "insert" && code === 300301) {
|
|
2568
|
+
this.commit(planned);
|
|
2569
|
+
return "ok";
|
|
2570
|
+
}
|
|
2571
|
+
if (code === 200850 || code === 200510) {
|
|
2572
|
+
if (await this.reactivate()) try {
|
|
2573
|
+
await this.invokeThenCommit(planned, this.state.seq + 1);
|
|
2574
|
+
return "ok";
|
|
2575
|
+
} catch {
|
|
2576
|
+
return this.abandon("流式超时重激活后重放失败", true);
|
|
2577
|
+
}
|
|
2578
|
+
return this.abandon("流式超时且重激活失败", true);
|
|
2579
|
+
}
|
|
2580
|
+
if (code === 200860) return this.abandon("卡片超出平台大小上限", false);
|
|
2581
|
+
try {
|
|
2582
|
+
const retrySeq = (op.type === "insert" || op.type === "update" || op.type === "settings" ? op.sequence : this.state.seq) + 2;
|
|
2583
|
+
await this.invokeThenCommit(planned, retrySeq);
|
|
2584
|
+
return "ok";
|
|
2585
|
+
} catch (retryError) {
|
|
2586
|
+
if (op.type === "insert" && feishuErrorCode(retryError) === 300301) {
|
|
2587
|
+
this.commit(planned);
|
|
2588
|
+
return "ok";
|
|
2589
|
+
}
|
|
2590
|
+
return this.abandon("卡片操作重放失败", true);
|
|
2591
|
+
}
|
|
2592
|
+
}
|
|
2593
|
+
}
|
|
2594
|
+
/**
|
|
2595
|
+
* 执行 API 并在成功后 commit;seqOverride 用于重放(create 的真实 cardId 在此覆盖进状态)。
|
|
2596
|
+
* 带序号 op 的发送/提交序号一律取 max(本次序号, 已确认 seq+1):重放/重激活会推高已确认 seq,
|
|
2597
|
+
* 批内后续 op 的规划序号可能落后,照发会以非递增序号碰撞(平台可能视作幂等 no-op 静默丢 op →
|
|
2598
|
+
* 定格/关流 op 消失 → 卡死「输出中」)。
|
|
2599
|
+
*/
|
|
2600
|
+
async invokeThenCommit(planned, seqOverride) {
|
|
2601
|
+
const effectiveSeq = effectiveSeqOf(planned.op, seqOverride, this.state.seq);
|
|
2602
|
+
const op = withSeq(planned.op, effectiveSeq);
|
|
2603
|
+
if (op.type === "create") {
|
|
2604
|
+
const id = await withRetry(() => this.api.createCard(op.cardJson));
|
|
2605
|
+
this.state = {
|
|
2606
|
+
...planned.commit(this.state),
|
|
2607
|
+
cardId: id
|
|
2608
|
+
};
|
|
2609
|
+
return;
|
|
2610
|
+
}
|
|
2611
|
+
if (op.type === "send") await withRetry(() => this.api.sendCardMessage(this.chatId, this.state.cardId));
|
|
2612
|
+
else if (op.type === "insert") await this.api.insertElement(this.state.cardId, op.elementJson, STATUS_ELEMENT_ID, op.sequence);
|
|
2613
|
+
else if (op.type === "update") await this.api.updateCardElement(this.state.cardId, op.elementId, op.content, op.sequence);
|
|
2614
|
+
else if (op.type === "settings") await this.api.setCardStreaming(this.state.cardId, op.streaming, op.sequence, op.summary);
|
|
2615
|
+
this.commit(planned, effectiveSeq);
|
|
2616
|
+
}
|
|
2617
|
+
commit(planned, seqOverride) {
|
|
2618
|
+
const next = planned.commit(this.state);
|
|
2619
|
+
const current = this.state.cardId;
|
|
2620
|
+
const cardId = next.cardId === "__pending__" && current !== null && current !== "__pending__" ? current : next.cardId;
|
|
2621
|
+
this.state = seqOverride !== void 0 ? {
|
|
2622
|
+
...next,
|
|
2623
|
+
seq: seqOverride,
|
|
2624
|
+
cardId
|
|
2625
|
+
} : {
|
|
2626
|
+
...next,
|
|
2627
|
+
seq: Math.max(next.seq, this.state.seq),
|
|
2628
|
+
cardId
|
|
2629
|
+
};
|
|
2630
|
+
}
|
|
2631
|
+
/** 流式超时后的官方恢复路径:settings 重设 streaming_mode:true(占一个 sequence)。 */
|
|
2632
|
+
async reactivate() {
|
|
2633
|
+
const { cardId, seq } = this.state;
|
|
2634
|
+
if (cardId === null || cardId === "__pending__") return false;
|
|
2635
|
+
try {
|
|
2636
|
+
await this.api.setCardStreaming(cardId, true, seq + 1);
|
|
2637
|
+
this.state = {
|
|
2638
|
+
...this.state,
|
|
2639
|
+
seq: seq + 1
|
|
2640
|
+
};
|
|
2641
|
+
return true;
|
|
2642
|
+
} catch {
|
|
2643
|
+
return false;
|
|
2644
|
+
}
|
|
2645
|
+
}
|
|
2646
|
+
/**
|
|
2647
|
+
* 废弃当前卡:尽力关流 → cardId 归零、尾段回卷为 carry → 调用方重新规划续写。
|
|
2648
|
+
* reshowTail=true(未知失败,op 可能已执行)时从尾段 base 重演(少量重复优于丢失);
|
|
2649
|
+
* reshowTail=false(200860 确定性未应用)时跳过已显示部分(零重复)。
|
|
2650
|
+
*/
|
|
2651
|
+
async abandon(reason, reshowTail) {
|
|
2652
|
+
const { cardId, tail, seq } = this.state;
|
|
2653
|
+
const hadRealCard = cardId !== null && cardId !== "__pending__";
|
|
2654
|
+
if (hadRealCard) await this.api.setCardStreaming(cardId, false, seq + 1).catch(() => void 0);
|
|
2655
|
+
if (tail !== void 0) {
|
|
2656
|
+
const base = this.segments[tail.segIndex]?.kind === "process" ? 0 : reshowTail ? tail.base : tail.base + tail.shownText.length;
|
|
2657
|
+
this.state = {
|
|
2658
|
+
...this.state,
|
|
2659
|
+
cardId: null,
|
|
2660
|
+
tail: void 0,
|
|
2661
|
+
closedSegCount: tail.segIndex,
|
|
2662
|
+
carry: {
|
|
2663
|
+
segIndex: tail.segIndex,
|
|
2664
|
+
base
|
|
2665
|
+
}
|
|
2666
|
+
};
|
|
2667
|
+
} else this.state = {
|
|
2668
|
+
...this.state,
|
|
2669
|
+
cardId: null
|
|
2670
|
+
};
|
|
2671
|
+
this.log(`[project-bot] 卡片输出异常(${reason}),已废弃当前卡并在新卡继续`);
|
|
2672
|
+
if (hadRealCard) await withRetry(() => this.api.sendText(this.chatId, ABANDON_NOTICE)).catch(() => void 0);
|
|
2673
|
+
return "abandoned";
|
|
2303
2674
|
}
|
|
2304
2675
|
enqueue(task) {
|
|
2305
2676
|
this.tail = this.tail.then(task).catch((error) => {
|
|
2306
|
-
if (this.state.cardId === "__pending__") {
|
|
2307
|
-
const t = this.state.tail;
|
|
2308
|
-
this.state = {
|
|
2309
|
-
...this.state,
|
|
2310
|
-
cardId: null,
|
|
2311
|
-
...t !== void 0 ? {
|
|
2312
|
-
tail: void 0,
|
|
2313
|
-
carry: {
|
|
2314
|
-
segIndex: t.segIndex,
|
|
2315
|
-
base: t.base
|
|
2316
|
-
},
|
|
2317
|
-
closedSegCount: t.segIndex
|
|
2318
|
-
} : {}
|
|
2319
|
-
};
|
|
2320
|
-
}
|
|
2321
2677
|
this.log(`[project-bot] 卡片操作失败:${error instanceof Error ? error.message : String(error)}`);
|
|
2322
2678
|
});
|
|
2323
2679
|
}
|
|
2324
|
-
async exec(ops) {
|
|
2325
|
-
for (const op of ops) if (op.type === "create") this.state.cardId = await withRetry(() => this.api.createCard(op.cardJson));
|
|
2326
|
-
else if (op.type === "send") await withRetry(() => this.api.sendCardMessage(this.chatId, this.state.cardId));
|
|
2327
|
-
else if (op.type === "insert") await withRetry(() => this.api.insertElement(this.state.cardId, op.elementJson, STATUS_ELEMENT_ID, op.sequence));
|
|
2328
|
-
else if (op.type === "update") await withRetry(() => this.api.updateCardElement(this.state.cardId, op.elementId, op.content, op.sequence));
|
|
2329
|
-
else await withRetry(() => this.api.setCardStreaming(this.state.cardId, op.streaming, op.sequence, op.summary));
|
|
2330
|
-
}
|
|
2331
2680
|
};
|
|
2681
|
+
/** 重放用:仅带 sequence 语义的 op 替换序号(create/send 无序号)。 */
|
|
2682
|
+
function withSeq(op, sequence) {
|
|
2683
|
+
if (sequence === void 0) return op;
|
|
2684
|
+
if (op.type === "insert") return {
|
|
2685
|
+
...op,
|
|
2686
|
+
sequence
|
|
2687
|
+
};
|
|
2688
|
+
if (op.type === "update") return {
|
|
2689
|
+
...op,
|
|
2690
|
+
sequence
|
|
2691
|
+
};
|
|
2692
|
+
if (op.type === "settings") return {
|
|
2693
|
+
...op,
|
|
2694
|
+
sequence
|
|
2695
|
+
};
|
|
2696
|
+
return op;
|
|
2697
|
+
}
|
|
2698
|
+
/**
|
|
2699
|
+
* 带序号 op 的生效 sequence(create/send 返回 undefined = 不占序号):
|
|
2700
|
+
* 重放/重激活推高已确认 seq 后,规划或重放序号落后时抬升到已确认 seq+1,保证单调不碰撞。
|
|
2701
|
+
*/
|
|
2702
|
+
function effectiveSeqOf(op, override, confirmedSeq) {
|
|
2703
|
+
if (op.type !== "insert" && op.type !== "update" && op.type !== "settings") return void 0;
|
|
2704
|
+
return Math.max(override ?? op.sequence, confirmedSeq + 1);
|
|
2705
|
+
}
|
|
2332
2706
|
/** 「处理中」表情:加上后返回删除 disposer;加/删失败都静默(表情残留无害)。 */
|
|
2333
2707
|
function makeAck(api, messageId, emojiType) {
|
|
2334
2708
|
return async () => {
|
|
@@ -2343,52 +2717,173 @@ function makeAck(api, messageId, emojiType) {
|
|
|
2343
2717
|
};
|
|
2344
2718
|
}
|
|
2345
2719
|
//#endregion
|
|
2346
|
-
//#region src/channels/feishu
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
const { appId } = bot.record.feishu;
|
|
2352
|
-
const api = createFeishuApi(new lark.Client({
|
|
2353
|
-
appId,
|
|
2354
|
-
appSecret: bot.secret
|
|
2355
|
-
}));
|
|
2356
|
-
const dedup = new MessageDedup();
|
|
2357
|
-
const dispatcher = new lark.EventDispatcher({}).register({
|
|
2358
|
-
"im.message.message_read_v1": async () => void 0,
|
|
2359
|
-
"im.message.receive_v1": async (data) => {
|
|
2360
|
-
const parsed = parseMessageEvent(data);
|
|
2361
|
-
if (parsed === null || !dedup.check(parsed.messageId)) return;
|
|
2362
|
-
const reply = new FeishuReplyHandle(api, parsed.chatId, tunables, log);
|
|
2363
|
-
const loadImages = parsed.imageKeys.length > 0 ? async () => Promise.all(parsed.imageKeys.map(async (key) => api.downloadImage(parsed.messageId, key))) : void 0;
|
|
2364
|
-
io.onMessage({
|
|
2365
|
-
botId: bot.record.id,
|
|
2366
|
-
chatId: parsed.chatId,
|
|
2367
|
-
userId: parsed.userId,
|
|
2368
|
-
messageId: parsed.messageId,
|
|
2369
|
-
text: parsed.text,
|
|
2370
|
-
...loadImages !== void 0 ? { loadImages } : {},
|
|
2371
|
-
reply,
|
|
2372
|
-
ackProcessing: makeAck(api, parsed.messageId, tunables.processingReactionEmoji)
|
|
2373
|
-
});
|
|
2374
|
-
}
|
|
2375
|
-
});
|
|
2376
|
-
const ws = new lark.WSClient({
|
|
2377
|
-
appId,
|
|
2378
|
-
appSecret: bot.secret,
|
|
2379
|
-
loggerLevel: lark.LoggerLevel.warn
|
|
2380
|
-
});
|
|
2381
|
-
await ws.start({ eventDispatcher: dispatcher });
|
|
2382
|
-
return {
|
|
2383
|
-
close: () => {
|
|
2384
|
-
ws.close({ force: true });
|
|
2385
|
-
return Promise.resolve();
|
|
2386
|
-
},
|
|
2387
|
-
status: () => ws.getConnectionStatus().state
|
|
2388
|
-
};
|
|
2389
|
-
}
|
|
2720
|
+
//#region src/channels/approval/feishu.ts
|
|
2721
|
+
const STATUS_TEXT = {
|
|
2722
|
+
allowed: "✅ 已允许",
|
|
2723
|
+
rejected: "❌ 已拒绝",
|
|
2724
|
+
cancelled: "⏹ 已取消"
|
|
2390
2725
|
};
|
|
2391
|
-
|
|
2726
|
+
function bodyMarkdown(prompt) {
|
|
2727
|
+
const lines = [`**Bot**:${prompt.botName}`, `**工具**:\`${prompt.toolName}\``];
|
|
2728
|
+
if (prompt.reason !== void 0) lines.push(`**理由**:${prompt.reason}`);
|
|
2729
|
+
return lines.join("\n");
|
|
2730
|
+
}
|
|
2731
|
+
function button(text, type, key, decision) {
|
|
2732
|
+
return {
|
|
2733
|
+
tag: "button",
|
|
2734
|
+
text: {
|
|
2735
|
+
tag: "plain_text",
|
|
2736
|
+
content: text
|
|
2737
|
+
},
|
|
2738
|
+
type,
|
|
2739
|
+
behaviors: [{
|
|
2740
|
+
type: "callback",
|
|
2741
|
+
value: {
|
|
2742
|
+
key,
|
|
2743
|
+
decision
|
|
2744
|
+
}
|
|
2745
|
+
}]
|
|
2746
|
+
};
|
|
2747
|
+
}
|
|
2748
|
+
/** 审批卡(带按钮);summary 供会话列表/推送预览。 */
|
|
2749
|
+
function buildApprovalCardJson(prompt) {
|
|
2750
|
+
return JSON.stringify({
|
|
2751
|
+
schema: "2.0",
|
|
2752
|
+
config: { summary: { content: `权限申请:${prompt.toolName}` } },
|
|
2753
|
+
header: {
|
|
2754
|
+
title: {
|
|
2755
|
+
tag: "plain_text",
|
|
2756
|
+
content: "权限申请"
|
|
2757
|
+
},
|
|
2758
|
+
template: "orange"
|
|
2759
|
+
},
|
|
2760
|
+
body: { elements: [{
|
|
2761
|
+
tag: "markdown",
|
|
2762
|
+
content: bodyMarkdown(prompt)
|
|
2763
|
+
}, {
|
|
2764
|
+
tag: "action",
|
|
2765
|
+
actions: [button("允许", "primary", prompt.key, "allow"), button("拒绝", "danger", prompt.key, "reject")]
|
|
2766
|
+
}] }
|
|
2767
|
+
});
|
|
2768
|
+
}
|
|
2769
|
+
/** 终态卡(无按钮):定格审批结果;operatorName 仅允许/拒绝时有。 */
|
|
2770
|
+
function buildApprovalFinalCardJson(prompt, status, operatorName) {
|
|
2771
|
+
const statusLine = STATUS_TEXT[status] + (operatorName !== void 0 ? ` · ${operatorName}` : "");
|
|
2772
|
+
const template = status === "allowed" ? "green" : status === "rejected" ? "red" : "grey";
|
|
2773
|
+
return JSON.stringify({
|
|
2774
|
+
schema: "2.0",
|
|
2775
|
+
config: { summary: { content: `权限申请${STATUS_TEXT[status].slice(2).trim()}:${prompt.toolName}` } },
|
|
2776
|
+
header: {
|
|
2777
|
+
title: {
|
|
2778
|
+
tag: "plain_text",
|
|
2779
|
+
content: `权限申请 · ${statusLine}`
|
|
2780
|
+
},
|
|
2781
|
+
template
|
|
2782
|
+
},
|
|
2783
|
+
body: { elements: [{
|
|
2784
|
+
tag: "markdown",
|
|
2785
|
+
content: bodyMarkdown(prompt)
|
|
2786
|
+
}] }
|
|
2787
|
+
});
|
|
2788
|
+
}
|
|
2789
|
+
/** 飞书审批能力:present = 建卡 + 发消息;finalize = replaceCard 定格(sequence 从 1 起,create/send 不占)。 */
|
|
2790
|
+
var FeishuApprovalPresenter = class {
|
|
2791
|
+
api;
|
|
2792
|
+
log;
|
|
2793
|
+
constructor(api, log) {
|
|
2794
|
+
this.api = api;
|
|
2795
|
+
this.log = log;
|
|
2796
|
+
}
|
|
2797
|
+
async present(prompt) {
|
|
2798
|
+
const cardId = await withRetry(() => this.api.createCard(buildApprovalCardJson(prompt)));
|
|
2799
|
+
await withRetry(() => this.api.sendCardMessage(prompt.chatId, cardId));
|
|
2800
|
+
return { finalize: async (status, operatorName) => {
|
|
2801
|
+
try {
|
|
2802
|
+
await withRetry(() => this.api.replaceCard(cardId, buildApprovalFinalCardJson(prompt, status, operatorName), 1));
|
|
2803
|
+
} catch (error) {
|
|
2804
|
+
this.log(`[project-bot] 审批卡片定格失败(card ${cardId}):${error instanceof Error ? error.message : String(error)}`);
|
|
2805
|
+
}
|
|
2806
|
+
} };
|
|
2807
|
+
}
|
|
2808
|
+
};
|
|
2809
|
+
//#endregion
|
|
2810
|
+
//#region src/channels/feishu/card-action.ts
|
|
2811
|
+
/** card.action.trigger 回调解析:SDK normalizeCardAction → 渠道无关 CardActionInput;toast 应答帧负载。 */
|
|
2812
|
+
/** raw → CardActionInput;normalize 失败(畸形/缺字段)→ undefined。 */
|
|
2813
|
+
function toCardActionInput(raw) {
|
|
2814
|
+
if (raw === null || typeof raw !== "object") return void 0;
|
|
2815
|
+
const evt = lark.normalizeCardAction(raw);
|
|
2816
|
+
if (evt === null) return void 0;
|
|
2817
|
+
return {
|
|
2818
|
+
chatId: evt.chatId,
|
|
2819
|
+
operatorOpenId: evt.operator.openId,
|
|
2820
|
+
...evt.operator.name !== void 0 ? { operatorName: evt.operator.name } : {},
|
|
2821
|
+
value: evt.action.value
|
|
2822
|
+
};
|
|
2823
|
+
}
|
|
2824
|
+
/** WS 应答帧负载:handler 返回值经 WSClient 回传(lib/index.js handleEventData:truthy result → respPayload.data)。 */
|
|
2825
|
+
function toastResponse(ack) {
|
|
2826
|
+
if (ack?.toast === void 0) return void 0;
|
|
2827
|
+
return { toast: {
|
|
2828
|
+
type: "info",
|
|
2829
|
+
content: ack.toast
|
|
2830
|
+
} };
|
|
2831
|
+
}
|
|
2832
|
+
//#endregion
|
|
2833
|
+
//#region src/channels/feishu/index.ts
|
|
2834
|
+
/** 飞书渠道:WSClient 长连接收事件 → 解析 → 核心;出站走 FeishuReplyHandle。 */
|
|
2835
|
+
const feishuChannel = {
|
|
2836
|
+
type: "feishu",
|
|
2837
|
+
async start(bot, io, tunables, log) {
|
|
2838
|
+
const { appId } = bot.record.feishu;
|
|
2839
|
+
const api = createFeishuApi(new lark.Client({
|
|
2840
|
+
appId,
|
|
2841
|
+
appSecret: bot.secret
|
|
2842
|
+
}));
|
|
2843
|
+
const botOpenId = await api.getBotOpenId();
|
|
2844
|
+
const dedup = new MessageDedup();
|
|
2845
|
+
const dispatcher = new lark.EventDispatcher({}).register({
|
|
2846
|
+
"im.message.message_read_v1": async () => void 0,
|
|
2847
|
+
"im.message.receive_v1": async (data) => {
|
|
2848
|
+
const parsed = parseMessageEvent(data, botOpenId);
|
|
2849
|
+
if (parsed === null || !dedup.check(parsed.messageId)) return;
|
|
2850
|
+
const reply = new FeishuReplyHandle(api, parsed.chatId, tunables, log);
|
|
2851
|
+
const loadImages = parsed.imageKeys.length > 0 ? async () => Promise.all(parsed.imageKeys.map(async (key) => api.downloadImage(parsed.messageId, key))) : void 0;
|
|
2852
|
+
io.onMessage({
|
|
2853
|
+
botId: bot.record.id,
|
|
2854
|
+
chatId: parsed.chatId,
|
|
2855
|
+
userId: parsed.userId,
|
|
2856
|
+
messageId: parsed.messageId,
|
|
2857
|
+
text: parsed.text,
|
|
2858
|
+
...loadImages !== void 0 ? { loadImages } : {},
|
|
2859
|
+
reply,
|
|
2860
|
+
ackProcessing: makeAck(api, parsed.messageId, tunables.processingReactionEmoji)
|
|
2861
|
+
});
|
|
2862
|
+
},
|
|
2863
|
+
"card.action.trigger": (raw) => {
|
|
2864
|
+
if (io.onCardAction === void 0) return void 0;
|
|
2865
|
+
const action = toCardActionInput(raw);
|
|
2866
|
+
if (action === void 0) return void 0;
|
|
2867
|
+
return toastResponse(io.onCardAction(action)) ?? void 0;
|
|
2868
|
+
}
|
|
2869
|
+
});
|
|
2870
|
+
const ws = new lark.WSClient({
|
|
2871
|
+
appId,
|
|
2872
|
+
appSecret: bot.secret,
|
|
2873
|
+
loggerLevel: lark.LoggerLevel.warn
|
|
2874
|
+
});
|
|
2875
|
+
await ws.start({ eventDispatcher: dispatcher });
|
|
2876
|
+
return {
|
|
2877
|
+
approval: new FeishuApprovalPresenter(api, log),
|
|
2878
|
+
close: () => {
|
|
2879
|
+
ws.close({ force: true });
|
|
2880
|
+
return Promise.resolve();
|
|
2881
|
+
},
|
|
2882
|
+
status: () => ws.getConnectionStatus().state
|
|
2883
|
+
};
|
|
2884
|
+
}
|
|
2885
|
+
};
|
|
2886
|
+
//#endregion
|
|
2392
2887
|
//#region src/bots/store.ts
|
|
2393
2888
|
/** project-bot 存储域声明:身份、版本、记录 zod schema 的单一来源。 */
|
|
2394
2889
|
/** 飞书自建应用 appId 形态(WSClient 同款校验)。 */
|
|
@@ -2595,6 +3090,7 @@ var Inbound = class {
|
|
|
2595
3090
|
return;
|
|
2596
3091
|
}
|
|
2597
3092
|
rt.inflight = { ack: void 0 };
|
|
3093
|
+
rt.reply = msg.reply;
|
|
2598
3094
|
rt.inflight.ack = await msg.ackProcessing().catch(() => void 0) ?? void 0;
|
|
2599
3095
|
const imageRefs = [];
|
|
2600
3096
|
if (msg.loadImages !== void 0) {
|
|
@@ -2635,6 +3131,26 @@ function hooksOf(bot) {
|
|
|
2635
3131
|
};
|
|
2636
3132
|
}
|
|
2637
3133
|
//#endregion
|
|
3134
|
+
//#region src/channels/role-assembly.ts
|
|
3135
|
+
/** 角色 persona 的 scoped 段名(与 Router 既有产出一致,逐字段不可改)。 */
|
|
3136
|
+
const ROLE_PERSONA_SECTION = "dsh-agent-toolkit:agent:persona";
|
|
3137
|
+
/** 角色形态创作期注入:persona 非空 → 单 section(order 0);tools 白名单存在 → 带上(由 setupAgentScope restrict)。 */
|
|
3138
|
+
function roleHooks(role) {
|
|
3139
|
+
const sections = role.persona === void 0 || role.persona.trim().length === 0 ? [] : [{
|
|
3140
|
+
name: ROLE_PERSONA_SECTION,
|
|
3141
|
+
order: 0,
|
|
3142
|
+
text: role.persona
|
|
3143
|
+
}];
|
|
3144
|
+
return {
|
|
3145
|
+
...sections.length > 0 ? { sections } : {},
|
|
3146
|
+
...role.tools !== void 0 ? { tools: role.tools.allow } : {}
|
|
3147
|
+
};
|
|
3148
|
+
}
|
|
3149
|
+
/** 角色模型:自配优先,缺省回退宿主默认模型(与 Router 语义一致)。 */
|
|
3150
|
+
function roleAgentOptions(role, defaultModel) {
|
|
3151
|
+
return role.model ?? defaultModel();
|
|
3152
|
+
}
|
|
3153
|
+
//#endregion
|
|
2638
3154
|
//#region src/channels/router.ts
|
|
2639
3155
|
/** 绑定路由:(botId, chatId) → 长期会话;create / resume / reset。 */
|
|
2640
3156
|
/** 发起人提示段名:bot 会话声明来源渠道与发起人 open_id。 */
|
|
@@ -2662,21 +3178,22 @@ var Router = class {
|
|
|
2662
3178
|
this.registry = registry;
|
|
2663
3179
|
this.injectSender = injectSender;
|
|
2664
3180
|
}
|
|
2665
|
-
/**
|
|
3181
|
+
/**
|
|
3182
|
+
* 取(或建/恢复)该 chat 的会话 runtime。存量活跃会话保持其 reply——运行中 turn 的出站
|
|
3183
|
+
* 必须留在原句柄收尾,reply 刷新由 Inbound 在 in-flight 准入通过后执行;
|
|
3184
|
+
* retiring(已 cancel、收尾中)的会话不可复用,重绑窗口内恢复时 resume + adopt 重建。
|
|
3185
|
+
*/
|
|
2666
3186
|
async ensure(bot, chatId, reply, userId) {
|
|
2667
3187
|
const bound = this.bindings.get(bot.id, chatId);
|
|
2668
3188
|
if (bound !== void 0) {
|
|
2669
3189
|
const existing = this.sessions.get(bound);
|
|
2670
|
-
if (existing !== void 0)
|
|
2671
|
-
existing.reply = reply;
|
|
2672
|
-
return existing;
|
|
2673
|
-
}
|
|
3190
|
+
if (existing !== void 0 && !existing.retiring) return existing;
|
|
2674
3191
|
const agent = await this.agents.resume({
|
|
2675
3192
|
sessionId: bound,
|
|
2676
3193
|
...this.resolveSession(bot, userId)
|
|
2677
3194
|
});
|
|
2678
3195
|
await this.attach(bot.project, bound);
|
|
2679
|
-
return this.adopt(bot.id, chatId, bound, agent, reply);
|
|
3196
|
+
return this.adopt(bot.id, chatId, userId, bound, agent, reply);
|
|
2680
3197
|
}
|
|
2681
3198
|
const sessionId = randomUUID();
|
|
2682
3199
|
const agent = await this.agents.create({
|
|
@@ -2686,7 +3203,7 @@ var Router = class {
|
|
|
2686
3203
|
});
|
|
2687
3204
|
await this.bindings.set(bot.id, chatId, sessionId);
|
|
2688
3205
|
await this.attach(bot.project, sessionId);
|
|
2689
|
-
return this.adopt(bot.id, chatId, sessionId, agent, reply);
|
|
3206
|
+
return this.adopt(bot.id, chatId, userId, sessionId, agent, reply);
|
|
2690
3207
|
}
|
|
2691
3208
|
/** attach 失败仅告警(会话降级为未分组),不阻塞消息处理。 */
|
|
2692
3209
|
async attach(cwd, sessionId) {
|
|
@@ -2696,10 +3213,6 @@ var Router = class {
|
|
|
2696
3213
|
this.onWarn(`[project-bot] 会话 ${sessionId} 挂载 workspace 失败:${error instanceof Error ? error.message : String(error)}`);
|
|
2697
3214
|
}
|
|
2698
3215
|
}
|
|
2699
|
-
/** 有 agentOptions 原样透传;无则回退宿主默认模型(存量 bot 不抛 no provider/model)。 */
|
|
2700
|
-
resolveOptions(bot) {
|
|
2701
|
-
return bot.agentOptions ?? this.defaultModel();
|
|
2702
|
-
}
|
|
2703
3216
|
/** injectSender 开启时向 hooks.sections 末尾追加 sender 段(主/角色形态通用)。 */
|
|
2704
3217
|
withSenderSection(hooks, bot, userId) {
|
|
2705
3218
|
if (!this.injectSender) return hooks;
|
|
@@ -2715,7 +3228,7 @@ var Router = class {
|
|
|
2715
3228
|
}
|
|
2716
3229
|
/**
|
|
2717
3230
|
* 按 bot.agentRef 解析会话组装(agentOptions + 创作期 hooks):
|
|
2718
|
-
* - 缺省/指向 main → 主 Agent 形态:bot 自带 persona/tools +
|
|
3231
|
+
* - 缺省/指向 main → 主 Agent 形态:bot 自带 persona/tools + 模型(自配 agentOptions 优先,缺省回退宿主默认模型);
|
|
2719
3232
|
* - 指向角色 → 角色形态:persona 单 section + tools.restrict + role.model;
|
|
2720
3233
|
* - 指向不存在角色 → warn 并降级为主 Agent 形态。
|
|
2721
3234
|
*/
|
|
@@ -2725,53 +3238,134 @@ var Router = class {
|
|
|
2725
3238
|
if (role === void 0 || ref === "main") {
|
|
2726
3239
|
if (role === void 0 && ref !== "main") this.onWarn(`[project-bot] bot "${bot.id}" 的 agentRef "${ref}" 不存在,降级绑定主 Agent`);
|
|
2727
3240
|
return {
|
|
2728
|
-
agentOptions: this.
|
|
3241
|
+
agentOptions: bot.agentOptions ?? this.defaultModel(),
|
|
2729
3242
|
hooks: this.withSenderSection(hooksOf(bot), bot, userId)
|
|
2730
3243
|
};
|
|
2731
3244
|
}
|
|
2732
|
-
const sections = role.persona === void 0 || role.persona.trim().length === 0 ? [] : [{
|
|
2733
|
-
name: "dsh-agent-toolkit:agent:persona",
|
|
2734
|
-
order: 0,
|
|
2735
|
-
text: role.persona
|
|
2736
|
-
}];
|
|
2737
3245
|
return {
|
|
2738
|
-
agentOptions: role
|
|
2739
|
-
hooks: this.withSenderSection(
|
|
2740
|
-
...sections.length > 0 ? { sections } : {},
|
|
2741
|
-
...role.tools !== void 0 ? { tools: role.tools.allow } : {}
|
|
2742
|
-
}, bot, userId)
|
|
3246
|
+
agentOptions: roleAgentOptions(role, this.defaultModel),
|
|
3247
|
+
hooks: this.withSenderSection(roleHooks(role), bot, userId)
|
|
2743
3248
|
};
|
|
2744
3249
|
}
|
|
2745
|
-
/** /new
|
|
3250
|
+
/** /new:取消旧会话;等 turn/end 落定(旧卡在旧句柄 finalize)后再摘出 sessions。 */
|
|
2746
3251
|
async reset(bot, chatId, reply, userId) {
|
|
2747
3252
|
const bound = this.bindings.get(bot.id, chatId);
|
|
2748
3253
|
if (bound !== void 0) {
|
|
2749
|
-
this.sessions.get(bound)
|
|
2750
|
-
this.
|
|
3254
|
+
const old = this.sessions.get(bound);
|
|
3255
|
+
if (old !== void 0) this.retire(bound, old);
|
|
2751
3256
|
await this.bindings.delete(bot.id, chatId);
|
|
2752
3257
|
}
|
|
2753
3258
|
return this.ensure(bot, chatId, reply, userId);
|
|
2754
3259
|
}
|
|
3260
|
+
/** 取消会话并等出站链落定后摘出 sessions(让在飞 turn 的 turn/end 正常 finalize 旧卡)。 */
|
|
3261
|
+
retire(sessionId, rt) {
|
|
3262
|
+
rt.retiring = true;
|
|
3263
|
+
rt.agent.cancel();
|
|
3264
|
+
(async () => {
|
|
3265
|
+
await rt.agent.whenIdle().catch(() => void 0);
|
|
3266
|
+
await rt.tail.catch(() => void 0);
|
|
3267
|
+
if (this.sessions.get(sessionId) === rt) this.sessions.delete(sessionId);
|
|
3268
|
+
})();
|
|
3269
|
+
}
|
|
2755
3270
|
lookup(botId, chatId) {
|
|
2756
3271
|
const bound = this.bindings.get(botId, chatId);
|
|
2757
3272
|
return bound === void 0 ? void 0 : this.sessions.get(bound);
|
|
2758
3273
|
}
|
|
2759
|
-
adopt(botId, chatId, sessionId, agent, reply) {
|
|
3274
|
+
adopt(botId, chatId, userId, sessionId, agent, reply) {
|
|
2760
3275
|
const rt = {
|
|
2761
3276
|
botId,
|
|
2762
3277
|
chatId,
|
|
2763
3278
|
sessionId,
|
|
3279
|
+
initiatorOpenId: userId,
|
|
2764
3280
|
agent,
|
|
2765
3281
|
reply,
|
|
2766
3282
|
inflight: void 0,
|
|
2767
3283
|
tail: Promise.resolve(),
|
|
2768
|
-
turn: void 0
|
|
3284
|
+
turn: void 0,
|
|
3285
|
+
retiring: false
|
|
2769
3286
|
};
|
|
2770
3287
|
this.sessions.set(sessionId, rt);
|
|
2771
3288
|
return rt;
|
|
2772
3289
|
}
|
|
2773
3290
|
};
|
|
2774
3291
|
//#endregion
|
|
3292
|
+
//#region src/channels/approval/center.ts
|
|
3293
|
+
/** 审批中心(渠道无关):自有 bot 会话的 approval ask → 渠道审批卡片挂起 → 卡片回调 resolve。
|
|
3294
|
+
* spec: docs/superpowers/specs/2026-09-08-feishu-approval-card-design.md(飞书独占 / 仅发起人 / 不超时)。 */
|
|
3295
|
+
var ApprovalCenter = class {
|
|
3296
|
+
sessions;
|
|
3297
|
+
channelFor;
|
|
3298
|
+
warn;
|
|
3299
|
+
newId;
|
|
3300
|
+
pending = /* @__PURE__ */ new Map();
|
|
3301
|
+
constructor(sessions, channelFor, warn, newId = randomUUID) {
|
|
3302
|
+
this.sessions = sessions;
|
|
3303
|
+
this.channelFor = channelFor;
|
|
3304
|
+
this.warn = warn;
|
|
3305
|
+
this.newId = newId;
|
|
3306
|
+
}
|
|
3307
|
+
async handleRequest(req) {
|
|
3308
|
+
const sessionId = String(req.agent.session.id);
|
|
3309
|
+
const rt = this.sessions.get(sessionId);
|
|
3310
|
+
if (rt === void 0) return void 0;
|
|
3311
|
+
if (req.signal?.aborted === true) return "cancelled";
|
|
3312
|
+
const channel = this.channelFor(rt.botId);
|
|
3313
|
+
if (channel === void 0) {
|
|
3314
|
+
this.warn(`[project-bot] bot "${rt.botId}" 的渠道无审批能力,回退其他审批通道`);
|
|
3315
|
+
return;
|
|
3316
|
+
}
|
|
3317
|
+
const key = this.newId();
|
|
3318
|
+
let presentation;
|
|
3319
|
+
try {
|
|
3320
|
+
presentation = await channel.presenter.present({
|
|
3321
|
+
key,
|
|
3322
|
+
chatId: rt.chatId,
|
|
3323
|
+
botName: channel.botName,
|
|
3324
|
+
toolName: req.toolName,
|
|
3325
|
+
...req.reason !== void 0 ? { reason: req.reason } : {}
|
|
3326
|
+
});
|
|
3327
|
+
} catch (error) {
|
|
3328
|
+
this.warn(`[project-bot] 审批卡片发送失败,回退其他审批通道:${error instanceof Error ? error.message : String(error)}`);
|
|
3329
|
+
return;
|
|
3330
|
+
}
|
|
3331
|
+
return new Promise((resolve) => {
|
|
3332
|
+
const entry = {
|
|
3333
|
+
sessionId,
|
|
3334
|
+
resolve,
|
|
3335
|
+
presentation,
|
|
3336
|
+
signal: req.signal,
|
|
3337
|
+
onAbort: () => this.settle(key, "cancelled")
|
|
3338
|
+
};
|
|
3339
|
+
this.pending.set(key, entry);
|
|
3340
|
+
req.signal?.addEventListener("abort", entry.onAbort, { once: true });
|
|
3341
|
+
});
|
|
3342
|
+
}
|
|
3343
|
+
handleCardAction(action) {
|
|
3344
|
+
const value = action.value;
|
|
3345
|
+
if (value === null || typeof value !== "object" || typeof value.key !== "string" || value.decision !== "allow" && value.decision !== "reject") return void 0;
|
|
3346
|
+
const entry = this.pending.get(value.key);
|
|
3347
|
+
if (entry === void 0) return { toast: "该申请已处理或已失效" };
|
|
3348
|
+
const rt = this.sessions.get(entry.sessionId);
|
|
3349
|
+
if (rt === void 0 || rt.initiatorOpenId !== action.operatorOpenId) return { toast: "仅会话发起人可审批" };
|
|
3350
|
+
this.settle(value.key, value.decision === "allow" ? "allowed-once" : "rejected", action.operatorName);
|
|
3351
|
+
return { toast: value.decision === "allow" ? "已允许" : "已拒绝" };
|
|
3352
|
+
}
|
|
3353
|
+
dispose() {
|
|
3354
|
+
for (const key of [...this.pending.keys()]) this.settle(key, "cancelled");
|
|
3355
|
+
}
|
|
3356
|
+
settle(key, outcome, operatorName) {
|
|
3357
|
+
const entry = this.pending.get(key);
|
|
3358
|
+
if (entry === void 0) return;
|
|
3359
|
+
this.pending.delete(key);
|
|
3360
|
+
entry.signal?.removeEventListener("abort", entry.onAbort);
|
|
3361
|
+
entry.resolve(outcome);
|
|
3362
|
+
const status = outcome === "allowed-once" ? "allowed" : outcome === "rejected" ? "rejected" : "cancelled";
|
|
3363
|
+
entry.presentation.finalize(status, operatorName).catch((error) => {
|
|
3364
|
+
this.warn(`[project-bot] 审批卡片定格失败:${error instanceof Error ? error.message : String(error)}`);
|
|
3365
|
+
});
|
|
3366
|
+
}
|
|
3367
|
+
};
|
|
3368
|
+
//#endregion
|
|
2775
3369
|
//#region src/channels/runtime.ts
|
|
2776
3370
|
var BotRuntime = class {
|
|
2777
3371
|
deps;
|
|
@@ -2779,6 +3373,7 @@ var BotRuntime = class {
|
|
|
2779
3373
|
router;
|
|
2780
3374
|
inbound;
|
|
2781
3375
|
outbound;
|
|
3376
|
+
approval;
|
|
2782
3377
|
handles = /* @__PURE__ */ new Map();
|
|
2783
3378
|
constructor(deps) {
|
|
2784
3379
|
this.deps = deps;
|
|
@@ -2792,6 +3387,14 @@ var BotRuntime = class {
|
|
|
2792
3387
|
onError: (m) => deps.log.warn(m)
|
|
2793
3388
|
});
|
|
2794
3389
|
this.outbound = new Outbound(this.sessions, (m) => deps.log.warn(m), deps.maxErrorDetailChars);
|
|
3390
|
+
this.approval = new ApprovalCenter(this.sessions, (botId) => {
|
|
3391
|
+
const presenter = this.handles.get(botId)?.approval;
|
|
3392
|
+
if (presenter === void 0) return void 0;
|
|
3393
|
+
return {
|
|
3394
|
+
presenter,
|
|
3395
|
+
botName: this.deps.bots.get(botId)?.name ?? botId
|
|
3396
|
+
};
|
|
3397
|
+
}, (m) => deps.log.warn(m));
|
|
2795
3398
|
}
|
|
2796
3399
|
async startAll() {
|
|
2797
3400
|
for (const botId of [...this.deps.bots.keys()]) await this.reconcile(botId);
|
|
@@ -2820,7 +3423,10 @@ var BotRuntime = class {
|
|
|
2820
3423
|
const handle = await channel.start({
|
|
2821
3424
|
record,
|
|
2822
3425
|
secret
|
|
2823
|
-
}, {
|
|
3426
|
+
}, {
|
|
3427
|
+
onMessage: (msg) => this.inbound.onMessage(msg),
|
|
3428
|
+
onCardAction: (action) => this.approval.handleCardAction(action)
|
|
3429
|
+
}, this.deps.tunables, (m) => this.deps.log.warn(m));
|
|
2824
3430
|
this.handles.set(botId, handle);
|
|
2825
3431
|
} catch (error) {
|
|
2826
3432
|
this.deps.log.warn(`[project-bot] bot "${botId}" 渠道启动失败:${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -2829,19 +3435,23 @@ var BotRuntime = class {
|
|
|
2829
3435
|
/** 删除 bot:停渠道、取消会话、清绑定。 */
|
|
2830
3436
|
async stopBot(botId) {
|
|
2831
3437
|
await this.stopChannel(botId);
|
|
2832
|
-
for (const [sessionId, rt] of [...this.sessions]) if (rt.botId === botId)
|
|
2833
|
-
rt.agent.cancel();
|
|
2834
|
-
this.sessions.delete(sessionId);
|
|
2835
|
-
}
|
|
3438
|
+
for (const [sessionId, rt] of [...this.sessions]) if (rt.botId === botId) this.retire(sessionId, rt);
|
|
2836
3439
|
await this.bindingStore().deleteBot(botId);
|
|
2837
3440
|
}
|
|
2838
3441
|
/** 解绑渠道:停渠道、取消在飞会话;绑定表与持久会话保留(重绑后 resume 接续)。 */
|
|
2839
3442
|
async unbindBot(botId) {
|
|
2840
3443
|
await this.stopChannel(botId);
|
|
2841
|
-
for (const [sessionId, rt] of [...this.sessions]) if (rt.botId === botId)
|
|
2842
|
-
|
|
2843
|
-
|
|
2844
|
-
|
|
3444
|
+
for (const [sessionId, rt] of [...this.sessions]) if (rt.botId === botId) this.retire(sessionId, rt);
|
|
3445
|
+
}
|
|
3446
|
+
/** 取消会话并等出站链落定后摘出 sessions(让在飞 turn 的 turn/end 正常 finalize 旧卡)。 */
|
|
3447
|
+
retire(sessionId, rt) {
|
|
3448
|
+
rt.retiring = true;
|
|
3449
|
+
rt.agent.cancel();
|
|
3450
|
+
(async () => {
|
|
3451
|
+
await rt.agent.whenIdle().catch(() => void 0);
|
|
3452
|
+
await rt.tail.catch(() => void 0);
|
|
3453
|
+
if (this.sessions.get(sessionId) === rt) this.sessions.delete(sessionId);
|
|
3454
|
+
})();
|
|
2845
3455
|
}
|
|
2846
3456
|
statusOf(botId) {
|
|
2847
3457
|
const record = this.deps.bots.get(botId);
|
|
@@ -2857,6 +3467,7 @@ var BotRuntime = class {
|
|
|
2857
3467
|
}));
|
|
2858
3468
|
await Promise.allSettled([...this.handles.values()].map((h) => h.close()));
|
|
2859
3469
|
this.handles.clear();
|
|
3470
|
+
this.approval.dispose();
|
|
2860
3471
|
}
|
|
2861
3472
|
async stopChannel(botId) {
|
|
2862
3473
|
const handle = this.handles.get(botId);
|
|
@@ -2883,6 +3494,20 @@ var BotRuntime = class {
|
|
|
2883
3494
|
}
|
|
2884
3495
|
};
|
|
2885
3496
|
//#endregion
|
|
3497
|
+
//#region src/channels/scope-joiner.ts
|
|
3498
|
+
function createScopeJoiner(ctx, presetId, fallback, warn) {
|
|
3499
|
+
return { async join(agentCtx) {
|
|
3500
|
+
const agentPresets = ctx.get("agentPresets", false);
|
|
3501
|
+
if (agentPresets !== void 0) try {
|
|
3502
|
+
await agentPresets.mount(agentCtx, presetId);
|
|
3503
|
+
return;
|
|
3504
|
+
} catch (error) {
|
|
3505
|
+
warn(`dsh-agent-toolkit: bot 会话挂载 preset "${presetId}" 失败,回退基础工具 standing scope(此后该会话委派子会话将看不到基础工具):${error instanceof Error ? error.message : String(error)}`);
|
|
3506
|
+
}
|
|
3507
|
+
await fallback.join(agentCtx);
|
|
3508
|
+
} };
|
|
3509
|
+
}
|
|
3510
|
+
//#endregion
|
|
2886
3511
|
//#region src/channels/tool-scope.ts
|
|
2887
3512
|
/** 默认加载器:复刻 preset mount 的模块解析——loader 的 unwrapExports 取 `default ?? 命名导出模块`。 */
|
|
2888
3513
|
async function loadToolModule(specifier) {
|
|
@@ -2932,10 +3557,17 @@ function createToolsScope(ctx, loadTool = loadToolModule) {
|
|
|
2932
3557
|
};
|
|
2933
3558
|
}
|
|
2934
3559
|
//#endregion
|
|
3560
|
+
//#region src/channels/approval/answerer.ts
|
|
3561
|
+
function createApprovalAnswerer(centerOf) {
|
|
3562
|
+
return async (req, next) => {
|
|
3563
|
+
return await centerOf()?.handleRequest(req) ?? next();
|
|
3564
|
+
};
|
|
3565
|
+
}
|
|
3566
|
+
//#endregion
|
|
2935
3567
|
//#region src/bots/api.ts
|
|
2936
3568
|
/** 浏览器半 RPC:单前缀路由 /dsh-agent-toolkit/api/bots + 内部路径分发。 */
|
|
2937
3569
|
const MAX_BODY_BYTES = 65536;
|
|
2938
|
-
const CreateBodySchema = z$1.object({
|
|
3570
|
+
const CreateBodySchema$1 = z$1.object({
|
|
2939
3571
|
/** 缺省时后端自动生成(bot-<8 位随机小写字母数字>)。 */
|
|
2940
3572
|
id: z$1.string().regex(BOT_ID_RE).optional(),
|
|
2941
3573
|
name: z$1.string().min(1).max(64),
|
|
@@ -2956,7 +3588,7 @@ const CreateBodySchema = z$1.object({
|
|
|
2956
3588
|
appSecretRef: z$1.string().regex(CREDENTIAL_REF_RE).optional()
|
|
2957
3589
|
})
|
|
2958
3590
|
});
|
|
2959
|
-
const UpdateBodySchema = z$1.object({
|
|
3591
|
+
const UpdateBodySchema$1 = z$1.object({
|
|
2960
3592
|
name: z$1.string().min(1).max(64).optional(),
|
|
2961
3593
|
project: z$1.string().min(1).optional(),
|
|
2962
3594
|
persona: z$1.string().max(8e3).nullable().optional(),
|
|
@@ -3022,7 +3654,7 @@ function createApiHandler(deps) {
|
|
|
3022
3654
|
if (sub === "/bots" && method === "POST") {
|
|
3023
3655
|
const body = await readJsonBody(req, res);
|
|
3024
3656
|
if (body === void 0) return;
|
|
3025
|
-
const parsed = CreateBodySchema.safeParse(body);
|
|
3657
|
+
const parsed = CreateBodySchema$1.safeParse(body);
|
|
3026
3658
|
if (!parsed.success) {
|
|
3027
3659
|
json(res, 400, { error: parsed.error.issues[0]?.message ?? "invalid body" });
|
|
3028
3660
|
return;
|
|
@@ -3082,7 +3714,7 @@ function createApiHandler(deps) {
|
|
|
3082
3714
|
}
|
|
3083
3715
|
const body = await readJsonBody(req, res);
|
|
3084
3716
|
if (body === void 0) return;
|
|
3085
|
-
const parsed = UpdateBodySchema.safeParse(body);
|
|
3717
|
+
const parsed = UpdateBodySchema$1.safeParse(body);
|
|
3086
3718
|
if (!parsed.success) {
|
|
3087
3719
|
json(res, 400, { error: parsed.error.issues[0]?.message ?? "invalid body" });
|
|
3088
3720
|
return;
|
|
@@ -3201,7 +3833,7 @@ function createApiHandler(deps) {
|
|
|
3201
3833
|
//#endregion
|
|
3202
3834
|
//#region src/bots/register-app.ts
|
|
3203
3835
|
/** 扫码一键创建飞书应用:lark.registerApp(OAuth 2.0 Device Authorization Grant)的状态机封装。 */
|
|
3204
|
-
/**
|
|
3836
|
+
/** 扫码创建应用时申请的权限/事件/回调(流式卡片 + 收发消息 + 表情 + 通讯录基础信息 + 审批卡片回传)。
|
|
3205
3837
|
* 故意不加 as const:readonly 元组不可赋值给 SDK AppAddons 的 mutable string[],
|
|
3206
3838
|
* 否则 bots/index.ts 的 lark.registerApp(options) 透传会 typecheck 失败。 */
|
|
3207
3839
|
const FEISHU_REGISTER_APP_ADDONS = {
|
|
@@ -3211,7 +3843,8 @@ const FEISHU_REGISTER_APP_ADDONS = {
|
|
|
3211
3843
|
"cardkit:card:write",
|
|
3212
3844
|
"contact:user.base:readonly"
|
|
3213
3845
|
] },
|
|
3214
|
-
events: { items: { tenant: ["im.message.receive_v1"] } }
|
|
3846
|
+
events: { items: { tenant: ["im.message.receive_v1"] } },
|
|
3847
|
+
callbacks: { items: ["card.action.trigger"] }
|
|
3215
3848
|
};
|
|
3216
3849
|
var RegisterAppService = class {
|
|
3217
3850
|
deps;
|
|
@@ -3285,6 +3918,7 @@ function setupBots(ctx, config, deps) {
|
|
|
3285
3918
|
cardUpdateThrottleMs: config.cardUpdateThrottleMs,
|
|
3286
3919
|
cardMaxBytes: config.cardMaxBytes,
|
|
3287
3920
|
processMaxBytes: config.processMaxBytes,
|
|
3921
|
+
cardPrintStep: config.cardPrintStep,
|
|
3288
3922
|
processingReactionEmoji: config.processingReactionEmoji
|
|
3289
3923
|
};
|
|
3290
3924
|
const storeSecret = async (key, secret) => {
|
|
@@ -3292,34 +3926,10 @@ function setupBots(ctx, config, deps) {
|
|
|
3292
3926
|
await ctx.credentials.set(credentialRef(ref), secret);
|
|
3293
3927
|
return ref;
|
|
3294
3928
|
};
|
|
3295
|
-
/** 创作期注入已迁至 agent-setup.ts + tool-scope.ts(基础工具行 standing scope 挂载 + persona/tools
|
|
3929
|
+
/** 创作期注入已迁至 agent-setup.ts + tool-scope.ts(基础工具行 standing scope 挂载 + persona/tools)。
|
|
3930
|
+
* preset 优先 joiner(agent-bot 组合):mount 成功后委派子会话 composeFrom 认父(spec: docs/superpowers/specs/2026-09-07-bot-delegation-preset-mount-design.md)。 */
|
|
3296
3931
|
const toolsScope = createToolsScope(ctx);
|
|
3297
|
-
const agentsPort =
|
|
3298
|
-
async create(input) {
|
|
3299
|
-
return adaptAgent(await ctx.agents.create({
|
|
3300
|
-
sessionId: SessionId(input.sessionId),
|
|
3301
|
-
meta: { cwd: input.cwd },
|
|
3302
|
-
...input.agentOptions !== void 0 ? { agentOptions: input.agentOptions } : {},
|
|
3303
|
-
setup: (agentCtx) => setupAgentScope(agentCtx, input.hooks, toolsScope)
|
|
3304
|
-
}));
|
|
3305
|
-
},
|
|
3306
|
-
async resume(input) {
|
|
3307
|
-
return adaptAgent(await ctx.agents.resume({
|
|
3308
|
-
resumeSessionId: SessionId(input.sessionId),
|
|
3309
|
-
...input.agentOptions !== void 0 ? { agentOptions: input.agentOptions } : {},
|
|
3310
|
-
setup: (agentCtx) => setupAgentScope(agentCtx, input.hooks, toolsScope)
|
|
3311
|
-
}));
|
|
3312
|
-
}
|
|
3313
|
-
};
|
|
3314
|
-
function adaptAgent(handle) {
|
|
3315
|
-
const { agent } = handle;
|
|
3316
|
-
return {
|
|
3317
|
-
sessionId: String(agent.id),
|
|
3318
|
-
followup: (message) => agent.followup(message),
|
|
3319
|
-
cancel: () => agent.cancel({ kind: "user" }),
|
|
3320
|
-
whenIdle: () => agent.whenIdle()
|
|
3321
|
-
};
|
|
3322
|
-
}
|
|
3932
|
+
const agentsPort = createAgentsPort(ctx, deps.botPresetId !== void 0 ? createScopeJoiner(ctx, deps.botPresetId, toolsScope, log.warn) : toolsScope, deps.ownedSessions);
|
|
3323
3933
|
const workspaceRegistry = ctx.get("workspaceRegistry", false);
|
|
3324
3934
|
const workspacePort = { async attach(cwd, sessionId) {
|
|
3325
3935
|
if (workspaceRegistry === void 0) throw new Error("workspaceRegistry 服务不可用");
|
|
@@ -3385,6 +3995,7 @@ function setupBots(ctx, config, deps) {
|
|
|
3385
3995
|
ctx.on("session/event", (session, event) => {
|
|
3386
3996
|
runtime?.outbound.handleSessionEvent(String(session.header.id), event);
|
|
3387
3997
|
});
|
|
3998
|
+
if (config.approval) ctx.on("approval/request", createApprovalAnswerer(() => runtime?.approval), { prepend: true });
|
|
3388
3999
|
ctx.on("agent/error", ({ agent, error }) => {
|
|
3389
4000
|
const text = error instanceof Error ? error.message : String(error?.message ?? error);
|
|
3390
4001
|
runtime?.outbound.handleAgentError(String(agent.session.id), text);
|
|
@@ -3433,6 +4044,37 @@ function setupBots(ctx, config, deps) {
|
|
|
3433
4044
|
});
|
|
3434
4045
|
}
|
|
3435
4046
|
//#endregion
|
|
4047
|
+
//#region src/agents/bot-preset.ts
|
|
4048
|
+
/** agent-bot preset 组合序列化:BASIC_TOOLS → preset 行(bot 会话挂载的最小组合,
|
|
4049
|
+
* 委派子会话 composeFrom 认父的前提;spec: docs/superpowers/specs/2026-09-07-bot-delegation-preset-mount-design.md)。 */
|
|
4050
|
+
/** BASIC_TOOLS 插件包名 → preset 行 id(与 standard/agent-team 的行 id 对齐)。新增 BASIC_TOOLS 行必须在此登记。 */
|
|
4051
|
+
const ROW_IDS = {
|
|
4052
|
+
"@deepseek-ai/dsh-persona": "persona",
|
|
4053
|
+
"@deepseek-ai/dsh-agent-instructions": "agent-instructions",
|
|
4054
|
+
"@deepseek-ai/dsh-tool-pwsh": "tool-pwsh",
|
|
4055
|
+
"@deepseek-ai/dsh-tool-bash": "tool-bash",
|
|
4056
|
+
"@deepseek-ai/dsh-tool-fs": "tool-fs",
|
|
4057
|
+
"@deepseek-ai/dsh-tool-fs-search": "tool-fs-search"
|
|
4058
|
+
};
|
|
4059
|
+
const BOT_PRESET_NAME = "Bot 会话";
|
|
4060
|
+
const BOT_PRESET_DESCRIPTION = "飞书 bot 会话组合:基础工具行(persona/instructions/shell/fs/fs-search);委派子会话经 composeFrom 继承同一组合";
|
|
4061
|
+
/** BASIC_TOOLS → preset 行对象数组(未登记映射的包名抛错,防新增工具行静默丢 id)。 */
|
|
4062
|
+
function serializeBotRows(tools) {
|
|
4063
|
+
return tools.map((tool) => {
|
|
4064
|
+
const rowId = ROW_IDS[tool.id];
|
|
4065
|
+
if (rowId === void 0) throw new Error(`bot-preset: BASIC_TOOLS 行 ${tool.id} 未登记 preset 行 id 映射`);
|
|
4066
|
+
return {
|
|
4067
|
+
id: rowId,
|
|
4068
|
+
name: tool.id,
|
|
4069
|
+
...tool.config !== void 0 ? { config: tool.config } : {}
|
|
4070
|
+
};
|
|
4071
|
+
});
|
|
4072
|
+
}
|
|
4073
|
+
/** agent-bot composition 文本(平台相关 shell 行已由 BASIC_TOOLS 选定,不用 !!js)。 */
|
|
4074
|
+
function botPresetComposition() {
|
|
4075
|
+
return yaml.dump(serializeBotRows(BASIC_TOOLS), { lineWidth: -1 });
|
|
4076
|
+
}
|
|
4077
|
+
//#endregion
|
|
3436
4078
|
//#region src/agents/team-preset.ts
|
|
3437
4079
|
/**
|
|
3438
4080
|
* Agent 团队 preset 自动生成:派生宿主当前 shipped standard composition,
|
|
@@ -3493,9 +4135,38 @@ const MARKER_CONTENT = "dsh-agent-toolkit";
|
|
|
3493
4135
|
const PRESET_ID = /^[a-z0-9][a-z0-9-]*$/;
|
|
3494
4136
|
const GENERATED_HEADER = "# 本文件由 dsh-agent-toolkit 自动生成,勿手改(每次启动重写)。\n";
|
|
3495
4137
|
/**
|
|
3496
|
-
*
|
|
3497
|
-
*
|
|
3498
|
-
|
|
4138
|
+
* 写一个生成 preset 目录:无标记的同名用户目录不覆盖(warn 返回 false);marker-first 写入。
|
|
4139
|
+
* composition / metadata 写失败(含半途失败)留下的带标记目录,下次启动会被正常重写自愈。
|
|
4140
|
+
*/
|
|
4141
|
+
async function writeGeneratedPreset(dir, composition, metadata, warn) {
|
|
4142
|
+
const markerPath = join(dir, MARKER_FILE);
|
|
4143
|
+
let dirExists = true;
|
|
4144
|
+
try {
|
|
4145
|
+
await access(dir);
|
|
4146
|
+
} catch {
|
|
4147
|
+
dirExists = false;
|
|
4148
|
+
}
|
|
4149
|
+
if (dirExists) {
|
|
4150
|
+
let marked = false;
|
|
4151
|
+
try {
|
|
4152
|
+
marked = (await readFile(markerPath, "utf8")).trim() === MARKER_CONTENT;
|
|
4153
|
+
} catch {}
|
|
4154
|
+
if (!marked) {
|
|
4155
|
+
warn(`dsh-agent-toolkit: ${dir} 已存在且非本插件生成,不覆盖,跳过生成`);
|
|
4156
|
+
return false;
|
|
4157
|
+
}
|
|
4158
|
+
}
|
|
4159
|
+
await mkdir(dir, { recursive: true });
|
|
4160
|
+
await writeFile(markerPath, `${MARKER_CONTENT}\n`, "utf8");
|
|
4161
|
+
await writeFile(join(dir, COMPOSITION_FILE), composition, "utf8");
|
|
4162
|
+
await writeFile(join(dir, METADATA_FILE), yaml.dump(metadata, { lineWidth: -1 }), "utf8");
|
|
4163
|
+
return true;
|
|
4164
|
+
}
|
|
4165
|
+
/**
|
|
4166
|
+
* 启动时生成/刷新 agent-team 与 agent-bot 两个 preset。所有失败路径 warn 降级,不影响插件其余功能。
|
|
4167
|
+
* 不设为默认 preset、卸载不删目录(可能有会话在用;composition 不引用 toolkit 行,残留 preset 自身
|
|
4168
|
+
* 仍可用)。每次启动重写:standing mount 按文件代际,重写只影响新会话。agent-bot 内容来自 BASIC_TOOLS,
|
|
4169
|
+
* 不依赖源 preset 读取(read 失败只跳过 agent-team);两块独立 try/catch、独立 marker 保护。
|
|
3499
4170
|
*/
|
|
3500
4171
|
async function setupAgentTeamPreset(ctx, config) {
|
|
3501
4172
|
if (!config.enabled) return;
|
|
@@ -3504,52 +4175,979 @@ async function setupAgentTeamPreset(ctx, config) {
|
|
|
3504
4175
|
};
|
|
3505
4176
|
const agentPresets = ctx.get("agentPresets", false);
|
|
3506
4177
|
if (agentPresets === void 0) return;
|
|
3507
|
-
if (!PRESET_ID.test(config.id)) {
|
|
3508
|
-
warn(`dsh-agent-toolkit: agentTeamPreset.id "${config.id}" 不是合法 preset id,跳过 agent-team 生成`);
|
|
3509
|
-
return;
|
|
3510
|
-
}
|
|
3511
|
-
let source;
|
|
3512
|
-
try {
|
|
3513
|
-
source = await agentPresets.read(config.source);
|
|
3514
|
-
} catch (error) {
|
|
3515
|
-
warn(`dsh-agent-toolkit: 读取源 preset "${config.source}" 失败,跳过 agent-team 生成:${error instanceof Error ? error.message : String(error)}`);
|
|
3516
|
-
return;
|
|
3517
|
-
}
|
|
3518
4178
|
const root = agentPresets.roots.find((r) => r.trust === "user");
|
|
3519
4179
|
if (root === void 0) {
|
|
3520
|
-
warn("dsh-agent-toolkit: preset roots 中无 trust=user 的目录,跳过 agent-team 生成");
|
|
4180
|
+
warn("dsh-agent-toolkit: preset roots 中无 trust=user 的目录,跳过 agent-team / agent-bot 生成");
|
|
3521
4181
|
return;
|
|
3522
4182
|
}
|
|
3523
|
-
const
|
|
3524
|
-
|
|
3525
|
-
|
|
3526
|
-
|
|
3527
|
-
let dirExists = true;
|
|
4183
|
+
const presetDir = (id) => join(resolve(expandHomePath(root.path)), id);
|
|
4184
|
+
if (!PRESET_ID.test(config.id)) warn(`dsh-agent-toolkit: agentTeamPreset.id "${config.id}" 不是合法 preset id,跳过 agent-team 生成`);
|
|
4185
|
+
else {
|
|
4186
|
+
let source;
|
|
3528
4187
|
try {
|
|
3529
|
-
await
|
|
3530
|
-
} catch {
|
|
3531
|
-
|
|
4188
|
+
source = await agentPresets.read(config.source);
|
|
4189
|
+
} catch (error) {
|
|
4190
|
+
warn(`dsh-agent-toolkit: 读取源 preset "${config.source}" 失败,跳过 agent-team 生成:${error instanceof Error ? error.message : String(error)}`);
|
|
3532
4191
|
}
|
|
3533
|
-
if (
|
|
3534
|
-
|
|
4192
|
+
if (source !== void 0) {
|
|
4193
|
+
const dir = presetDir(config.id);
|
|
3535
4194
|
try {
|
|
3536
|
-
|
|
3537
|
-
|
|
3538
|
-
|
|
3539
|
-
|
|
3540
|
-
|
|
4195
|
+
await writeGeneratedPreset(dir, GENERATED_HEADER + disableSubagentRows(source, warn), {
|
|
4196
|
+
name: config.name,
|
|
4197
|
+
description: config.description
|
|
4198
|
+
}, warn);
|
|
4199
|
+
} catch (error) {
|
|
4200
|
+
warn(`dsh-agent-toolkit: 写入 agent-team preset 失败(${dir}):${error instanceof Error ? error.message : String(error)}`);
|
|
3541
4201
|
}
|
|
3542
4202
|
}
|
|
3543
|
-
|
|
3544
|
-
|
|
3545
|
-
|
|
3546
|
-
|
|
3547
|
-
|
|
3548
|
-
|
|
3549
|
-
|
|
4203
|
+
}
|
|
4204
|
+
if (config.id === config.botsId) warn(`dsh-agent-toolkit: agentTeamPreset.id 与 agentTeamPreset.botsId 相同(均为 "${config.id}"),跳过 agent-bot 生成`);
|
|
4205
|
+
else if (!PRESET_ID.test(config.botsId)) warn(`dsh-agent-toolkit: agentTeamPreset.botsId "${config.botsId}" 不是合法 preset id,跳过 agent-bot 生成`);
|
|
4206
|
+
else try {
|
|
4207
|
+
await writeGeneratedPreset(presetDir(config.botsId), GENERATED_HEADER + botPresetComposition(), {
|
|
4208
|
+
name: BOT_PRESET_NAME,
|
|
4209
|
+
description: BOT_PRESET_DESCRIPTION
|
|
4210
|
+
}, warn);
|
|
3550
4211
|
} catch (error) {
|
|
3551
|
-
warn(`dsh-agent-toolkit: 写入 agent-
|
|
4212
|
+
warn(`dsh-agent-toolkit: 写入 agent-bot preset 失败:${error instanceof Error ? error.message : String(error)}`);
|
|
4213
|
+
}
|
|
4214
|
+
}
|
|
4215
|
+
//#endregion
|
|
4216
|
+
//#region src/schedule/store.ts
|
|
4217
|
+
/** schedule 模块存储域声明:CronTask/CronRun 记录 schema + domain 布局的单一来源。*/
|
|
4218
|
+
/** 调度规则三选一:cron(5 字段,可选 IANA 时区,省略 = 进程时区)/ at(RFC 3339 一次性)/ every(≥60s,创建时间为锚)。*/
|
|
4219
|
+
const CronScheduleSchema = z$1.discriminatedUnion("kind", [
|
|
4220
|
+
z$1.object({
|
|
4221
|
+
kind: z$1.literal("cron"),
|
|
4222
|
+
expr: z$1.string().min(1),
|
|
4223
|
+
timeZone: z$1.string().min(1).optional()
|
|
4224
|
+
}),
|
|
4225
|
+
z$1.object({
|
|
4226
|
+
kind: z$1.literal("at"),
|
|
4227
|
+
at: z$1.string().min(1)
|
|
4228
|
+
}),
|
|
4229
|
+
z$1.object({
|
|
4230
|
+
kind: z$1.literal("every"),
|
|
4231
|
+
seconds: z$1.number().int().min(60)
|
|
4232
|
+
})
|
|
4233
|
+
]);
|
|
4234
|
+
/** 执行目标:主 Agent(宿主默认模型,无 persona/restrict)或注册表角色(persona/model/工具白名单随角色)。*/
|
|
4235
|
+
const CronTargetSchema = z$1.discriminatedUnion("kind", [z$1.object({ kind: z$1.literal("main") }), z$1.object({
|
|
4236
|
+
kind: z$1.literal("role"),
|
|
4237
|
+
roleId: z$1.string().min(1)
|
|
4238
|
+
})]);
|
|
4239
|
+
const CronTaskSchema = z$1.object({
|
|
4240
|
+
id: z$1.string().min(1),
|
|
4241
|
+
name: z$1.string().min(1).max(64),
|
|
4242
|
+
prompt: z$1.string().min(1).max(8e3),
|
|
4243
|
+
cwd: z$1.string().min(1),
|
|
4244
|
+
schedule: CronScheduleSchema,
|
|
4245
|
+
target: CronTargetSchema,
|
|
4246
|
+
catchup: z$1.boolean(),
|
|
4247
|
+
enabled: z$1.boolean(),
|
|
4248
|
+
/** 调度器维护的持久缓存(重启 rearm 依据),UTC ISO 串。*/
|
|
4249
|
+
nextRunAt: z$1.string().nullable(),
|
|
4250
|
+
createdAt: z$1.string(),
|
|
4251
|
+
updatedAt: z$1.string()
|
|
4252
|
+
});
|
|
4253
|
+
const CronRunSchema = z$1.object({
|
|
4254
|
+
id: z$1.string().min(1),
|
|
4255
|
+
taskId: z$1.string().min(1),
|
|
4256
|
+
triggeredAt: z$1.string(),
|
|
4257
|
+
finishedAt: z$1.string().optional(),
|
|
4258
|
+
status: z$1.enum([
|
|
4259
|
+
"running",
|
|
4260
|
+
"ok",
|
|
4261
|
+
"error",
|
|
4262
|
+
"skipped-overlap"
|
|
4263
|
+
]),
|
|
4264
|
+
/** 本次执行新建的会话 id(skipped-overlap 无会话)。*/
|
|
4265
|
+
sessionId: z$1.string().optional(),
|
|
4266
|
+
/** 错误摘要(截断)。*/
|
|
4267
|
+
error: z$1.string().optional()
|
|
4268
|
+
});
|
|
4269
|
+
/** domain 名/表名均受 UNIT_NAME_RE 约束(^[a-z][a-z0-9_]*$),不允许连字符。*/
|
|
4270
|
+
const scheduleDomain = defineDomain({
|
|
4271
|
+
name: "dsh_agent_toolkit_schedule",
|
|
4272
|
+
version: 1,
|
|
4273
|
+
tables: {
|
|
4274
|
+
tasks: domainTable(CronTaskSchema),
|
|
4275
|
+
runs: domainTable(CronRunSchema),
|
|
4276
|
+
meta: domainTable(z$1.object({ value: z$1.string() }))
|
|
3552
4277
|
}
|
|
4278
|
+
});
|
|
4279
|
+
/** 某任务的 run 列表(新 → 旧;同刻按 id 倒序稳定化)。*/
|
|
4280
|
+
function listRuns(runs, taskId) {
|
|
4281
|
+
return [...runs.entries()].map(([, run]) => run).filter((run) => run.taskId === taskId).sort((a, b) => b.triggeredAt.localeCompare(a.triggeredAt) || b.id.localeCompare(a.id));
|
|
4282
|
+
}
|
|
4283
|
+
/** 环形保留某任务最近 limit 条 run,超出删最旧。*/
|
|
4284
|
+
async function trimRuns(runs, taskId, limit) {
|
|
4285
|
+
const mine = listRuns(runs, taskId);
|
|
4286
|
+
for (const run of mine.slice(limit)) await runs.delete(run.id);
|
|
4287
|
+
}
|
|
4288
|
+
/** 删除任务连带清运行历史。*/
|
|
4289
|
+
async function deleteRunsOf(runs, taskId) {
|
|
4290
|
+
for (const run of listRuns(runs, taskId)) await runs.delete(run.id);
|
|
4291
|
+
}
|
|
4292
|
+
//#endregion
|
|
4293
|
+
//#region src/schedule/api.ts
|
|
4294
|
+
const CreateBodySchema = z$1.object({
|
|
4295
|
+
name: z$1.string().min(1).max(64),
|
|
4296
|
+
prompt: z$1.string().min(1).max(8e3),
|
|
4297
|
+
cwd: z$1.string().min(1),
|
|
4298
|
+
schedule: CronScheduleSchema,
|
|
4299
|
+
target: CronTargetSchema,
|
|
4300
|
+
catchup: z$1.boolean(),
|
|
4301
|
+
enabled: z$1.boolean()
|
|
4302
|
+
});
|
|
4303
|
+
const UpdateBodySchema = CreateBodySchema.partial();
|
|
4304
|
+
function createCronApiHandler(deps) {
|
|
4305
|
+
return async (req, res) => {
|
|
4306
|
+
const sub = new URL(req.url ?? "/", "http://127.0.0.1").pathname.replace(/^\/dsh-agent-toolkit\/api\/cron/, "") || "/";
|
|
4307
|
+
const method = req.method ?? "GET";
|
|
4308
|
+
const taskMatch = /^\/tasks\/([^/]+)$/.exec(sub);
|
|
4309
|
+
const triggerMatch = /^\/tasks\/([^/]+)\/trigger$/.exec(sub);
|
|
4310
|
+
const runsMatch = /^\/tasks\/([^/]+)\/runs$/.exec(sub);
|
|
4311
|
+
if (sub === "/tasks" && method === "GET") {
|
|
4312
|
+
json$1(res, 200, { tasks: deps.service.list() });
|
|
4313
|
+
return;
|
|
4314
|
+
}
|
|
4315
|
+
if (sub === "/tasks" && method === "POST") {
|
|
4316
|
+
const body = await readJsonBody$1(req, res);
|
|
4317
|
+
if (body === void 0) return;
|
|
4318
|
+
const parsed = CreateBodySchema.safeParse(body);
|
|
4319
|
+
if (!parsed.success) {
|
|
4320
|
+
json$1(res, 400, { error: parsed.error.issues[0]?.message ?? "invalid body" });
|
|
4321
|
+
return;
|
|
4322
|
+
}
|
|
4323
|
+
const result = await deps.service.create(parsed.data);
|
|
4324
|
+
if (!result.ok) {
|
|
4325
|
+
json$1(res, 400, { error: result.error });
|
|
4326
|
+
return;
|
|
4327
|
+
}
|
|
4328
|
+
json$1(res, 200, { task: result.value });
|
|
4329
|
+
return;
|
|
4330
|
+
}
|
|
4331
|
+
if (taskMatch !== null && method === "PUT") {
|
|
4332
|
+
const id = decodeURIComponent(taskMatch[1]);
|
|
4333
|
+
if (deps.service.get(id) === void 0) {
|
|
4334
|
+
json$1(res, 404, { error: `定时任务 "${id}" 不存在` });
|
|
4335
|
+
return;
|
|
4336
|
+
}
|
|
4337
|
+
const body = await readJsonBody$1(req, res);
|
|
4338
|
+
if (body === void 0) return;
|
|
4339
|
+
const parsed = UpdateBodySchema.safeParse(body);
|
|
4340
|
+
if (!parsed.success) {
|
|
4341
|
+
json$1(res, 400, { error: parsed.error.issues[0]?.message ?? "invalid body" });
|
|
4342
|
+
return;
|
|
4343
|
+
}
|
|
4344
|
+
const result = await deps.service.update(id, parsed.data);
|
|
4345
|
+
if (!result.ok) {
|
|
4346
|
+
json$1(res, 400, { error: result.error });
|
|
4347
|
+
return;
|
|
4348
|
+
}
|
|
4349
|
+
json$1(res, 200, { task: result.value });
|
|
4350
|
+
return;
|
|
4351
|
+
}
|
|
4352
|
+
if (taskMatch !== null && method === "DELETE") {
|
|
4353
|
+
const id = decodeURIComponent(taskMatch[1]);
|
|
4354
|
+
if (deps.service.get(id) === void 0) {
|
|
4355
|
+
json$1(res, 404, { error: `定时任务 "${id}" 不存在` });
|
|
4356
|
+
return;
|
|
4357
|
+
}
|
|
4358
|
+
const result = await deps.service.remove(id);
|
|
4359
|
+
if (!result.ok) {
|
|
4360
|
+
json$1(res, 400, { error: result.error });
|
|
4361
|
+
return;
|
|
4362
|
+
}
|
|
4363
|
+
json$1(res, 200, { ok: true });
|
|
4364
|
+
return;
|
|
4365
|
+
}
|
|
4366
|
+
if (triggerMatch !== null && method === "POST") {
|
|
4367
|
+
const id = decodeURIComponent(triggerMatch[1]);
|
|
4368
|
+
if (deps.service.get(id) === void 0) {
|
|
4369
|
+
json$1(res, 404, { error: `定时任务 "${id}" 不存在` });
|
|
4370
|
+
return;
|
|
4371
|
+
}
|
|
4372
|
+
const result = await deps.service.trigger(id);
|
|
4373
|
+
if (!result.ok) {
|
|
4374
|
+
json$1(res, /正在运行/.test(result.error) ? 409 : 400, { error: result.error });
|
|
4375
|
+
return;
|
|
4376
|
+
}
|
|
4377
|
+
json$1(res, 200, { run: result.value });
|
|
4378
|
+
return;
|
|
4379
|
+
}
|
|
4380
|
+
if (runsMatch !== null && method === "GET") {
|
|
4381
|
+
const id = decodeURIComponent(runsMatch[1]);
|
|
4382
|
+
if (deps.service.get(id) === void 0) {
|
|
4383
|
+
json$1(res, 404, { error: `定时任务 "${id}" 不存在` });
|
|
4384
|
+
return;
|
|
4385
|
+
}
|
|
4386
|
+
json$1(res, 200, { runs: deps.service.runsOf(id, deps.runHistoryLimit) });
|
|
4387
|
+
return;
|
|
4388
|
+
}
|
|
4389
|
+
if (sub === "/projects" && method === "GET") {
|
|
4390
|
+
json$1(res, 200, { projects: deps.listProjects() });
|
|
4391
|
+
return;
|
|
4392
|
+
}
|
|
4393
|
+
if (sub === "/tasks" || taskMatch !== null || triggerMatch !== null || runsMatch !== null || sub === "/projects") {
|
|
4394
|
+
json$1(res, 405, { error: "method not allowed" });
|
|
4395
|
+
return;
|
|
4396
|
+
}
|
|
4397
|
+
json$1(res, 404, { error: "not found" });
|
|
4398
|
+
};
|
|
4399
|
+
}
|
|
4400
|
+
//#endregion
|
|
4401
|
+
//#region src/schedule/executor.ts
|
|
4402
|
+
/** 定时任务执行器:输入一条 task,输出一条 run 记录(建会话 → followup → whenIdle/超时 → 落库)。 */
|
|
4403
|
+
/** 来源段名:声明本会话由定时任务触发(order 20,与渠道 sender 段同位)。 */
|
|
4404
|
+
const TASK_SECTION_NAME = "dsh-agent-toolkit:schedule:task";
|
|
4405
|
+
function taskSectionText(task, triggeredAt) {
|
|
4406
|
+
return `本会话由定时任务「${task.name}」(id: ${task.id})于 ${triggeredAt} 触发。`;
|
|
4407
|
+
}
|
|
4408
|
+
/** 错误摘要最大字符数(对齐 feishu.errorDetailMaxChars 默认 500)。 */
|
|
4409
|
+
const ERROR_MAX_CHARS = 500;
|
|
4410
|
+
var RunTimeoutError = class extends Error {
|
|
4411
|
+
constructor() {
|
|
4412
|
+
super("timeout");
|
|
4413
|
+
}
|
|
4414
|
+
};
|
|
4415
|
+
function createExecutor(deps) {
|
|
4416
|
+
async function persist(run) {
|
|
4417
|
+
await deps.runs.put(run.id, run);
|
|
4418
|
+
await trimRuns(deps.runs, run.taskId, deps.runHistoryLimit);
|
|
4419
|
+
}
|
|
4420
|
+
return { async trigger(task) {
|
|
4421
|
+
const triggeredAt = new Date(deps.now()).toISOString();
|
|
4422
|
+
const sessionId = deps.newSessionId();
|
|
4423
|
+
const run = {
|
|
4424
|
+
id: deps.newRunId(),
|
|
4425
|
+
taskId: task.id,
|
|
4426
|
+
triggeredAt,
|
|
4427
|
+
status: "running",
|
|
4428
|
+
sessionId
|
|
4429
|
+
};
|
|
4430
|
+
await persist(run);
|
|
4431
|
+
const finish = async (status, error) => {
|
|
4432
|
+
const done = {
|
|
4433
|
+
...run,
|
|
4434
|
+
status,
|
|
4435
|
+
finishedAt: new Date(deps.now()).toISOString(),
|
|
4436
|
+
...error !== void 0 ? { error: error.slice(0, ERROR_MAX_CHARS) } : {}
|
|
4437
|
+
};
|
|
4438
|
+
await persist(done);
|
|
4439
|
+
return done;
|
|
4440
|
+
};
|
|
4441
|
+
const source = {
|
|
4442
|
+
name: TASK_SECTION_NAME,
|
|
4443
|
+
order: 20,
|
|
4444
|
+
text: taskSectionText(task, triggeredAt)
|
|
4445
|
+
};
|
|
4446
|
+
let agentOptions;
|
|
4447
|
+
let hooks;
|
|
4448
|
+
const roleId = task.target.kind === "role" ? task.target.roleId : "main";
|
|
4449
|
+
const role = roleId === "main" ? void 0 : deps.registry.get(roleId);
|
|
4450
|
+
if (roleId === "main" || role === void 0) {
|
|
4451
|
+
if (roleId !== "main" && role === void 0) deps.warn(`[schedule] 定时任务 "${task.id}" 的角色 "${roleId}" 不存在,降级主 Agent 形态`);
|
|
4452
|
+
agentOptions = deps.defaultModel();
|
|
4453
|
+
hooks = { sections: [source] };
|
|
4454
|
+
} else {
|
|
4455
|
+
const base = roleHooks(role);
|
|
4456
|
+
agentOptions = roleAgentOptions(role, deps.defaultModel);
|
|
4457
|
+
hooks = {
|
|
4458
|
+
...base,
|
|
4459
|
+
sections: [...base.sections ?? [], source]
|
|
4460
|
+
};
|
|
4461
|
+
}
|
|
4462
|
+
let agent;
|
|
4463
|
+
try {
|
|
4464
|
+
agent = await deps.agents.create({
|
|
4465
|
+
sessionId,
|
|
4466
|
+
cwd: task.cwd,
|
|
4467
|
+
agentOptions,
|
|
4468
|
+
hooks
|
|
4469
|
+
});
|
|
4470
|
+
} catch (error) {
|
|
4471
|
+
return finish("error", error instanceof Error ? error.message : String(error));
|
|
4472
|
+
}
|
|
4473
|
+
try {
|
|
4474
|
+
await deps.workspace.attach(task.cwd, sessionId);
|
|
4475
|
+
} catch (error) {
|
|
4476
|
+
deps.warn(`[schedule] 会话 ${sessionId} 挂载 workspace 失败:${error instanceof Error ? error.message : String(error)}`);
|
|
4477
|
+
}
|
|
4478
|
+
agent.followup(createUserMessage({
|
|
4479
|
+
content: [{
|
|
4480
|
+
type: "text",
|
|
4481
|
+
text: task.prompt
|
|
4482
|
+
}],
|
|
4483
|
+
source: { kind: "user" }
|
|
4484
|
+
}));
|
|
4485
|
+
const idle = agent.whenIdle();
|
|
4486
|
+
idle.catch(() => void 0);
|
|
4487
|
+
const timeout = deps.delay(deps.runTimeoutMs).then(() => {
|
|
4488
|
+
throw new RunTimeoutError();
|
|
4489
|
+
});
|
|
4490
|
+
timeout.catch(() => void 0);
|
|
4491
|
+
try {
|
|
4492
|
+
await Promise.race([idle, timeout]);
|
|
4493
|
+
return await finish("ok");
|
|
4494
|
+
} catch (error) {
|
|
4495
|
+
if (error instanceof RunTimeoutError) {
|
|
4496
|
+
agent.cancel();
|
|
4497
|
+
return await finish("error", `timeout(超过 ${Math.round(deps.runTimeoutMs / 6e4)} 分钟未空闲,已取消)`);
|
|
4498
|
+
}
|
|
4499
|
+
return await finish("error", error instanceof Error ? error.message : String(error));
|
|
4500
|
+
}
|
|
4501
|
+
} };
|
|
4502
|
+
}
|
|
4503
|
+
//#endregion
|
|
4504
|
+
//#region src/schedule/timing.ts
|
|
4505
|
+
/** cron/at/every 纯时间运算:校验、下一触发点、预览、启动 rearm。全部注入 now,无隐藏时钟。*/
|
|
4506
|
+
/** croner 选项:paused 仅做解析与预测,不注册真实定时器(计时由调度器 tick 驱动)。*/
|
|
4507
|
+
function cronerOptions(schedule) {
|
|
4508
|
+
return {
|
|
4509
|
+
paused: true,
|
|
4510
|
+
...schedule.timeZone !== void 0 ? { timezone: schedule.timeZone } : {}
|
|
4511
|
+
};
|
|
4512
|
+
}
|
|
4513
|
+
/** 创建/更新即校验;合法返回 undefined,非法返回错误消息(不入库由调用方保证)。*/
|
|
4514
|
+
function validateSchedule(schedule, nowMs) {
|
|
4515
|
+
switch (schedule.kind) {
|
|
4516
|
+
case "cron": {
|
|
4517
|
+
const fields = schedule.expr.trim().split(/\s+/);
|
|
4518
|
+
if (fields.length !== 5) return `cron 表达式须为 5 字段(分 时 日 月 周),收到 ${fields.length} 字段;秒级/年字段暂不开放`;
|
|
4519
|
+
if (schedule.timeZone !== void 0) try {
|
|
4520
|
+
new Intl.DateTimeFormat("en-US", { timeZone: schedule.timeZone });
|
|
4521
|
+
} catch {
|
|
4522
|
+
return `未知时区:${schedule.timeZone}`;
|
|
4523
|
+
}
|
|
4524
|
+
try {
|
|
4525
|
+
new Cron(schedule.expr, cronerOptions(schedule));
|
|
4526
|
+
} catch (error) {
|
|
4527
|
+
return `非法 cron 表达式"${schedule.expr}":${error instanceof Error ? error.message : String(error)}`;
|
|
4528
|
+
}
|
|
4529
|
+
return;
|
|
4530
|
+
}
|
|
4531
|
+
case "at": {
|
|
4532
|
+
const t = Date.parse(schedule.at);
|
|
4533
|
+
if (Number.isNaN(t)) return `非法 at 时间(须 RFC 3339):${schedule.at}`;
|
|
4534
|
+
if (t <= nowMs) return `at 时间须在未来:${schedule.at}`;
|
|
4535
|
+
return;
|
|
4536
|
+
}
|
|
4537
|
+
case "every": return;
|
|
4538
|
+
}
|
|
4539
|
+
}
|
|
4540
|
+
/** 下一触发点(epoch ms);无未来触发点(at 过期等)返回 null。*/
|
|
4541
|
+
function nextOccurrence(schedule, createdAtMs, nowMs) {
|
|
4542
|
+
switch (schedule.kind) {
|
|
4543
|
+
case "cron": {
|
|
4544
|
+
const next = new Cron(schedule.expr, cronerOptions(schedule)).nextRun(new Date(nowMs));
|
|
4545
|
+
return next === null ? null : next.getTime();
|
|
4546
|
+
}
|
|
4547
|
+
case "at": {
|
|
4548
|
+
const t = Date.parse(schedule.at);
|
|
4549
|
+
return t > nowMs ? t : null;
|
|
4550
|
+
}
|
|
4551
|
+
case "every": {
|
|
4552
|
+
const intervalMs = schedule.seconds * 1e3;
|
|
4553
|
+
if (nowMs < createdAtMs) return createdAtMs;
|
|
4554
|
+
return createdAtMs + (Math.floor((nowMs - createdAtMs) / intervalMs) + 1) * intervalMs;
|
|
4555
|
+
}
|
|
4556
|
+
}
|
|
4557
|
+
}
|
|
4558
|
+
/**
|
|
4559
|
+
* 启动 rearm:对一条持久化任务重算 enabled/nextRunAt(无变化返回原引用,调用方按引用决定是否写回)。
|
|
4560
|
+
* - nextRunAt 仍在未来 → 保持;
|
|
4561
|
+
* - 已过期且 catchup → 设为「立即」(首个 tick 补跑一次);
|
|
4562
|
+
* - 已过期且非 catchup → 下一未来触发点;没有(at 一次性)→ enabled=false、nextRunAt=null(过期即作废)。
|
|
4563
|
+
*/
|
|
4564
|
+
function rearmTask(task, nowMs) {
|
|
4565
|
+
if (!task.enabled) return task.nextRunAt === null ? task : {
|
|
4566
|
+
...task,
|
|
4567
|
+
nextRunAt: null
|
|
4568
|
+
};
|
|
4569
|
+
const persisted = task.nextRunAt === null ? null : Date.parse(task.nextRunAt);
|
|
4570
|
+
if (persisted !== null && persisted > nowMs) return task;
|
|
4571
|
+
if (persisted !== null && task.catchup) return {
|
|
4572
|
+
...task,
|
|
4573
|
+
nextRunAt: new Date(nowMs).toISOString()
|
|
4574
|
+
};
|
|
4575
|
+
const next = nextOccurrence(task.schedule, Date.parse(task.createdAt), nowMs);
|
|
4576
|
+
return next === null ? {
|
|
4577
|
+
...task,
|
|
4578
|
+
enabled: false,
|
|
4579
|
+
nextRunAt: null
|
|
4580
|
+
} : {
|
|
4581
|
+
...task,
|
|
4582
|
+
nextRunAt: new Date(next).toISOString()
|
|
4583
|
+
};
|
|
4584
|
+
}
|
|
4585
|
+
/** 创建/更新后的 fresh 重算(catchup 是停机补跑语义,不适用于编辑路径)。*/
|
|
4586
|
+
function recomputeTask(task, nowMs) {
|
|
4587
|
+
if (!task.enabled) return {
|
|
4588
|
+
...task,
|
|
4589
|
+
nextRunAt: null
|
|
4590
|
+
};
|
|
4591
|
+
const next = nextOccurrence(task.schedule, Date.parse(task.createdAt), nowMs);
|
|
4592
|
+
return next === null ? {
|
|
4593
|
+
...task,
|
|
4594
|
+
enabled: false,
|
|
4595
|
+
nextRunAt: null
|
|
4596
|
+
} : {
|
|
4597
|
+
...task,
|
|
4598
|
+
nextRunAt: new Date(next).toISOString()
|
|
4599
|
+
};
|
|
4600
|
+
}
|
|
4601
|
+
/** 触发/跳过后推进:cron/every 取 now 之后下一触发点;at 一次性作废。*/
|
|
4602
|
+
function advanceAfterTrigger(task, nowMs) {
|
|
4603
|
+
if (task.schedule.kind === "at") return {
|
|
4604
|
+
...task,
|
|
4605
|
+
enabled: false,
|
|
4606
|
+
nextRunAt: null
|
|
4607
|
+
};
|
|
4608
|
+
const next = nextOccurrence(task.schedule, Date.parse(task.createdAt), nowMs);
|
|
4609
|
+
return next === null ? {
|
|
4610
|
+
...task,
|
|
4611
|
+
enabled: false,
|
|
4612
|
+
nextRunAt: null
|
|
4613
|
+
} : {
|
|
4614
|
+
...task,
|
|
4615
|
+
nextRunAt: new Date(next).toISOString()
|
|
4616
|
+
};
|
|
4617
|
+
}
|
|
4618
|
+
//#endregion
|
|
4619
|
+
//#region src/schedule/scheduler.ts
|
|
4620
|
+
function createScheduler(deps) {
|
|
4621
|
+
/** 内存重叠锁:同任务上次未结束则本次记 skipped-overlap,不并发执行。 */
|
|
4622
|
+
const running = /* @__PURE__ */ new Set();
|
|
4623
|
+
async function recordSkipped(task) {
|
|
4624
|
+
const nowIso = new Date(deps.now()).toISOString();
|
|
4625
|
+
const run = {
|
|
4626
|
+
id: deps.newRunId(),
|
|
4627
|
+
taskId: task.id,
|
|
4628
|
+
triggeredAt: nowIso,
|
|
4629
|
+
finishedAt: nowIso,
|
|
4630
|
+
status: "skipped-overlap"
|
|
4631
|
+
};
|
|
4632
|
+
await deps.runs.put(run.id, run);
|
|
4633
|
+
await trimRuns(deps.runs, task.id, deps.runHistoryLimit);
|
|
4634
|
+
}
|
|
4635
|
+
function launch(task) {
|
|
4636
|
+
running.add(task.id);
|
|
4637
|
+
deps.execute(task).catch((error) => deps.warn(`[schedule] 任务 "${task.id}" 执行器异常:${error instanceof Error ? error.message : String(error)}`)).finally(() => {
|
|
4638
|
+
running.delete(task.id);
|
|
4639
|
+
const current = deps.tasks.get(task.id);
|
|
4640
|
+
if (current === void 0 || !current.enabled) return;
|
|
4641
|
+
deps.tasks.put(task.id, advanceAfterTrigger(current, deps.now()));
|
|
4642
|
+
});
|
|
4643
|
+
}
|
|
4644
|
+
return {
|
|
4645
|
+
async rearmAll() {
|
|
4646
|
+
for (const [id, task] of deps.tasks.entries()) {
|
|
4647
|
+
const rearmed = rearmTask(task, deps.now());
|
|
4648
|
+
if (rearmed !== task) await deps.tasks.put(id, rearmed);
|
|
4649
|
+
}
|
|
4650
|
+
},
|
|
4651
|
+
async tick() {
|
|
4652
|
+
for (const [, task] of deps.tasks.entries()) {
|
|
4653
|
+
if (!task.enabled || task.nextRunAt === null) continue;
|
|
4654
|
+
if (Date.parse(task.nextRunAt) > deps.now()) continue;
|
|
4655
|
+
if (running.has(task.id)) {
|
|
4656
|
+
await recordSkipped(task);
|
|
4657
|
+
await deps.tasks.put(task.id, advanceAfterTrigger(task, deps.now()));
|
|
4658
|
+
continue;
|
|
4659
|
+
}
|
|
4660
|
+
launch(task);
|
|
4661
|
+
}
|
|
4662
|
+
},
|
|
4663
|
+
recompute: (task) => recomputeTask(task, deps.now()),
|
|
4664
|
+
async triggerManual(taskId) {
|
|
4665
|
+
const task = deps.tasks.get(taskId);
|
|
4666
|
+
if (task === void 0) throw new Error(`定时任务 "${taskId}" 不存在`);
|
|
4667
|
+
if (running.has(taskId)) throw new Error(`定时任务 "${task.name}" 正在运行中,本次手动触发已跳过`);
|
|
4668
|
+
running.add(taskId);
|
|
4669
|
+
try {
|
|
4670
|
+
return await deps.execute(task);
|
|
4671
|
+
} finally {
|
|
4672
|
+
running.delete(taskId);
|
|
4673
|
+
}
|
|
4674
|
+
},
|
|
4675
|
+
isRunning: (taskId) => running.has(taskId)
|
|
4676
|
+
};
|
|
4677
|
+
}
|
|
4678
|
+
//#endregion
|
|
4679
|
+
//#region src/schedule/service.ts
|
|
4680
|
+
function createCronService(deps) {
|
|
4681
|
+
/** 创建/更新共用校验:schedule 语法与时值 → cwd → roleId 存在性。 */
|
|
4682
|
+
const validateIO = (input) => {
|
|
4683
|
+
const scheduleError = validateSchedule(input.schedule, deps.now());
|
|
4684
|
+
if (scheduleError !== void 0) return scheduleError;
|
|
4685
|
+
if (!deps.validateProject(input.cwd)) return `项目路径不可用:${input.cwd}`;
|
|
4686
|
+
if (input.target.kind === "role" && deps.registry.get(input.target.roleId) === void 0) return `角色 "${input.target.roleId}" 不存在`;
|
|
4687
|
+
};
|
|
4688
|
+
return {
|
|
4689
|
+
list() {
|
|
4690
|
+
return [...deps.tasks.entries()].map(([, task]) => {
|
|
4691
|
+
const last = listRuns(deps.runs, task.id)[0];
|
|
4692
|
+
return last === void 0 ? task : {
|
|
4693
|
+
...task,
|
|
4694
|
+
lastRun: {
|
|
4695
|
+
status: last.status,
|
|
4696
|
+
triggeredAt: last.triggeredAt
|
|
4697
|
+
}
|
|
4698
|
+
};
|
|
4699
|
+
});
|
|
4700
|
+
},
|
|
4701
|
+
get: (id) => deps.tasks.get(id),
|
|
4702
|
+
async create(input) {
|
|
4703
|
+
const invalid = validateIO(input);
|
|
4704
|
+
if (invalid !== void 0) return {
|
|
4705
|
+
ok: false,
|
|
4706
|
+
error: invalid
|
|
4707
|
+
};
|
|
4708
|
+
const nowIso = new Date(deps.now()).toISOString();
|
|
4709
|
+
const parsed = CronTaskSchema.safeParse({
|
|
4710
|
+
id: deps.newTaskId(),
|
|
4711
|
+
...input,
|
|
4712
|
+
nextRunAt: null,
|
|
4713
|
+
createdAt: nowIso,
|
|
4714
|
+
updatedAt: nowIso
|
|
4715
|
+
});
|
|
4716
|
+
if (!parsed.success) return {
|
|
4717
|
+
ok: false,
|
|
4718
|
+
error: parsed.error.issues[0]?.message ?? "invalid task"
|
|
4719
|
+
};
|
|
4720
|
+
const task = deps.scheduler.recompute(parsed.data);
|
|
4721
|
+
await deps.tasks.put(task.id, task);
|
|
4722
|
+
return {
|
|
4723
|
+
ok: true,
|
|
4724
|
+
value: task
|
|
4725
|
+
};
|
|
4726
|
+
},
|
|
4727
|
+
async update(id, patch) {
|
|
4728
|
+
const existing = deps.tasks.get(id);
|
|
4729
|
+
if (existing === void 0) return {
|
|
4730
|
+
ok: false,
|
|
4731
|
+
error: `定时任务 "${id}" 不存在`
|
|
4732
|
+
};
|
|
4733
|
+
const merged = {
|
|
4734
|
+
...existing,
|
|
4735
|
+
...Object.fromEntries(Object.entries(patch).filter(([, v]) => v !== void 0)),
|
|
4736
|
+
id: existing.id,
|
|
4737
|
+
updatedAt: new Date(deps.now()).toISOString()
|
|
4738
|
+
};
|
|
4739
|
+
const invalid = validateIO(merged);
|
|
4740
|
+
if (invalid !== void 0) return {
|
|
4741
|
+
ok: false,
|
|
4742
|
+
error: invalid
|
|
4743
|
+
};
|
|
4744
|
+
const parsed = CronTaskSchema.safeParse(merged);
|
|
4745
|
+
if (!parsed.success) return {
|
|
4746
|
+
ok: false,
|
|
4747
|
+
error: parsed.error.issues[0]?.message ?? "invalid task"
|
|
4748
|
+
};
|
|
4749
|
+
const task = deps.scheduler.recompute(parsed.data);
|
|
4750
|
+
await deps.tasks.put(id, task);
|
|
4751
|
+
return {
|
|
4752
|
+
ok: true,
|
|
4753
|
+
value: task
|
|
4754
|
+
};
|
|
4755
|
+
},
|
|
4756
|
+
async remove(id) {
|
|
4757
|
+
if (deps.tasks.get(id) === void 0) return {
|
|
4758
|
+
ok: false,
|
|
4759
|
+
error: `定时任务 "${id}" 不存在`
|
|
4760
|
+
};
|
|
4761
|
+
await deps.tasks.delete(id);
|
|
4762
|
+
await deleteRunsOf(deps.runs, id);
|
|
4763
|
+
return {
|
|
4764
|
+
ok: true,
|
|
4765
|
+
value: null
|
|
4766
|
+
};
|
|
4767
|
+
},
|
|
4768
|
+
async trigger(id) {
|
|
4769
|
+
try {
|
|
4770
|
+
return {
|
|
4771
|
+
ok: true,
|
|
4772
|
+
value: await deps.scheduler.triggerManual(id)
|
|
4773
|
+
};
|
|
4774
|
+
} catch (error) {
|
|
4775
|
+
return {
|
|
4776
|
+
ok: false,
|
|
4777
|
+
error: error instanceof Error ? error.message : String(error)
|
|
4778
|
+
};
|
|
4779
|
+
}
|
|
4780
|
+
},
|
|
4781
|
+
runsOf: (id, limit) => listRuns(deps.runs, id).slice(0, limit)
|
|
4782
|
+
};
|
|
4783
|
+
}
|
|
4784
|
+
//#endregion
|
|
4785
|
+
//#region src/schedule/tools.ts
|
|
4786
|
+
/** 互斥探测目标:宿主 @deepseek-ai/dsh-schedule 的会话内提醒工具。 */
|
|
4787
|
+
const HOST_SCHEDULE_TOOL = "schedule_create";
|
|
4788
|
+
/** 扁平参数 → CronSchedule(缺 kind 对应字段抛错——模型可读的自描述消息)。 */
|
|
4789
|
+
function scheduleOf(args) {
|
|
4790
|
+
const kind = args.scheduleKind;
|
|
4791
|
+
if (kind === "cron") {
|
|
4792
|
+
if (typeof args.cronExpr !== "string" || args.cronExpr.trim() === "") throw new Error("scheduleKind=cron 需要 cronExpr(5 字段 cron 表达式)");
|
|
4793
|
+
return {
|
|
4794
|
+
kind: "cron",
|
|
4795
|
+
expr: args.cronExpr,
|
|
4796
|
+
...typeof args.timeZone === "string" && args.timeZone !== "" ? { timeZone: args.timeZone } : {}
|
|
4797
|
+
};
|
|
4798
|
+
}
|
|
4799
|
+
if (kind === "at") {
|
|
4800
|
+
if (typeof args.atTime !== "string" || args.atTime === "") throw new Error("scheduleKind=at 需要 atTime(RFC 3339 时间)");
|
|
4801
|
+
return {
|
|
4802
|
+
kind: "at",
|
|
4803
|
+
at: args.atTime
|
|
4804
|
+
};
|
|
4805
|
+
}
|
|
4806
|
+
if (kind === "every") {
|
|
4807
|
+
if (typeof args.everySeconds !== "number") throw new Error("scheduleKind=every 需要 everySeconds(≥60 的秒数)");
|
|
4808
|
+
return {
|
|
4809
|
+
kind: "every",
|
|
4810
|
+
seconds: args.everySeconds
|
|
4811
|
+
};
|
|
4812
|
+
}
|
|
4813
|
+
throw new Error(`未知 scheduleKind:${String(kind)}(可选 cron / at / every)`);
|
|
4814
|
+
}
|
|
4815
|
+
function targetOf(args) {
|
|
4816
|
+
if (args.targetKind === "role") {
|
|
4817
|
+
if (typeof args.roleId !== "string" || args.roleId === "") throw new Error("targetKind=role 需要 roleId");
|
|
4818
|
+
return {
|
|
4819
|
+
kind: "role",
|
|
4820
|
+
roleId: args.roleId
|
|
4821
|
+
};
|
|
4822
|
+
}
|
|
4823
|
+
return { kind: "main" };
|
|
4824
|
+
}
|
|
4825
|
+
/** 服务结果展开:ok:false → 抛错(defineTool 约定:抛错即工具错误结果)。 */
|
|
4826
|
+
function unwrap(result) {
|
|
4827
|
+
if (!result.ok) throw new Error(result.error);
|
|
4828
|
+
return result.value;
|
|
4829
|
+
}
|
|
4830
|
+
const JSON_OUTPUT = {
|
|
4831
|
+
schema: { type: "json" },
|
|
4832
|
+
render: (_args, value) => [{
|
|
4833
|
+
type: "text",
|
|
4834
|
+
text: JSON.stringify(value, null, 2)
|
|
4835
|
+
}]
|
|
4836
|
+
};
|
|
4837
|
+
const SCHEDULE_PARAMS_REQUIRED = { scheduleKind: {
|
|
4838
|
+
type: "string",
|
|
4839
|
+
required: true,
|
|
4840
|
+
description: "One of: cron / at / every"
|
|
4841
|
+
} };
|
|
4842
|
+
const SCHEDULE_PARAMS_OPTIONAL = { scheduleKind: {
|
|
4843
|
+
type: "string",
|
|
4844
|
+
description: "Replace schedule kind: cron / at / every (provide the matching fields too)"
|
|
4845
|
+
} };
|
|
4846
|
+
const SCHEDULE_FIELD_PARAMS = {
|
|
4847
|
+
cronExpr: {
|
|
4848
|
+
type: "string",
|
|
4849
|
+
description: "Required when scheduleKind=cron: 5-field cron expression \"minute hour day-of-month month day-of-week\" (seconds/years not supported)"
|
|
4850
|
+
},
|
|
4851
|
+
timeZone: {
|
|
4852
|
+
type: "string",
|
|
4853
|
+
description: "Optional IANA time zone for cron (e.g. \"Asia/Shanghai\"); default: server process time zone"
|
|
4854
|
+
},
|
|
4855
|
+
atTime: {
|
|
4856
|
+
type: "string",
|
|
4857
|
+
description: "Required when scheduleKind=at: one-shot RFC 3339 time (e.g. \"2026-09-08T09:00:00+08:00\")"
|
|
4858
|
+
},
|
|
4859
|
+
everySeconds: {
|
|
4860
|
+
type: "number",
|
|
4861
|
+
description: "Required when scheduleKind=every: interval seconds (>= 60), anchored at creation time"
|
|
4862
|
+
}
|
|
4863
|
+
};
|
|
4864
|
+
const TARGET_PARAMS_REQUIRED = { targetKind: {
|
|
4865
|
+
type: "string",
|
|
4866
|
+
required: true,
|
|
4867
|
+
description: "One of: main (main agent, default model) / role (registry role with its persona/model/tool allowlist)"
|
|
4868
|
+
} };
|
|
4869
|
+
const TARGET_PARAMS_OPTIONAL = { targetKind: {
|
|
4870
|
+
type: "string",
|
|
4871
|
+
description: "Replace target: main / role"
|
|
4872
|
+
} };
|
|
4873
|
+
const ROLE_ID_PARAM = { roleId: {
|
|
4874
|
+
type: "string",
|
|
4875
|
+
description: "Required when targetKind=role: registry role id"
|
|
4876
|
+
} };
|
|
4877
|
+
function createCronTools(service) {
|
|
4878
|
+
return [
|
|
4879
|
+
defineTool({
|
|
4880
|
+
name: "cron_task_create",
|
|
4881
|
+
description: "Create a scheduled task that runs a prompt in a NEW standalone session (main agent or a registry role) on a cron/at/every schedule. The task survives restarts; a run missed while stopped is caught up once when catchup=true. Use cron_task_list to see existing tasks.",
|
|
4882
|
+
parameters: {
|
|
4883
|
+
name: {
|
|
4884
|
+
type: "string",
|
|
4885
|
+
required: true,
|
|
4886
|
+
description: "Task name (display)"
|
|
4887
|
+
},
|
|
4888
|
+
prompt: {
|
|
4889
|
+
type: "string",
|
|
4890
|
+
required: true,
|
|
4891
|
+
description: "The prompt sent as the user message when the task fires. Self-contained: the session has no other context."
|
|
4892
|
+
},
|
|
4893
|
+
cwd: {
|
|
4894
|
+
type: "string",
|
|
4895
|
+
required: true,
|
|
4896
|
+
description: "Working directory of the new session (must be a valid project path)"
|
|
4897
|
+
},
|
|
4898
|
+
...SCHEDULE_PARAMS_REQUIRED,
|
|
4899
|
+
...SCHEDULE_FIELD_PARAMS,
|
|
4900
|
+
...TARGET_PARAMS_REQUIRED,
|
|
4901
|
+
...ROLE_ID_PARAM,
|
|
4902
|
+
catchup: {
|
|
4903
|
+
type: "boolean",
|
|
4904
|
+
required: true,
|
|
4905
|
+
description: "true: run once immediately after restart if a run was missed while stopped; false: skip to the next future occurrence"
|
|
4906
|
+
},
|
|
4907
|
+
enabled: {
|
|
4908
|
+
type: "boolean",
|
|
4909
|
+
required: true,
|
|
4910
|
+
description: "false: create paused (nextRunAt stays null until enabled via cron_task_update)"
|
|
4911
|
+
}
|
|
4912
|
+
},
|
|
4913
|
+
output: JSON_OUTPUT,
|
|
4914
|
+
isConcurrencySafe: () => true,
|
|
4915
|
+
async execute(rawArgs) {
|
|
4916
|
+
const args = rawArgs;
|
|
4917
|
+
const input = {
|
|
4918
|
+
name: args.name,
|
|
4919
|
+
prompt: args.prompt,
|
|
4920
|
+
cwd: args.cwd,
|
|
4921
|
+
schedule: scheduleOf(args),
|
|
4922
|
+
target: targetOf(args),
|
|
4923
|
+
catchup: args.catchup,
|
|
4924
|
+
enabled: args.enabled
|
|
4925
|
+
};
|
|
4926
|
+
return unwrap(await service.create(input));
|
|
4927
|
+
}
|
|
4928
|
+
}),
|
|
4929
|
+
defineTool({
|
|
4930
|
+
name: "cron_task_list",
|
|
4931
|
+
description: "List all scheduled tasks with enabled flag, next run time and last run status.",
|
|
4932
|
+
parameters: {},
|
|
4933
|
+
output: JSON_OUTPUT,
|
|
4934
|
+
isConcurrencySafe: () => true,
|
|
4935
|
+
async execute() {
|
|
4936
|
+
return service.list();
|
|
4937
|
+
}
|
|
4938
|
+
}),
|
|
4939
|
+
defineTool({
|
|
4940
|
+
name: "cron_task_update",
|
|
4941
|
+
description: "Update any fields of a scheduled task by id. nextRunAt is recomputed.",
|
|
4942
|
+
parameters: {
|
|
4943
|
+
id: {
|
|
4944
|
+
type: "string",
|
|
4945
|
+
required: true,
|
|
4946
|
+
description: "Task id"
|
|
4947
|
+
},
|
|
4948
|
+
name: {
|
|
4949
|
+
type: "string",
|
|
4950
|
+
description: "New name"
|
|
4951
|
+
},
|
|
4952
|
+
prompt: {
|
|
4953
|
+
type: "string",
|
|
4954
|
+
description: "New prompt"
|
|
4955
|
+
},
|
|
4956
|
+
cwd: {
|
|
4957
|
+
type: "string",
|
|
4958
|
+
description: "New working directory"
|
|
4959
|
+
},
|
|
4960
|
+
...SCHEDULE_PARAMS_OPTIONAL,
|
|
4961
|
+
...SCHEDULE_FIELD_PARAMS,
|
|
4962
|
+
...TARGET_PARAMS_OPTIONAL,
|
|
4963
|
+
...ROLE_ID_PARAM,
|
|
4964
|
+
catchup: {
|
|
4965
|
+
type: "boolean",
|
|
4966
|
+
description: "New catchup flag"
|
|
4967
|
+
},
|
|
4968
|
+
enabled: {
|
|
4969
|
+
type: "boolean",
|
|
4970
|
+
description: "Enable/disable the task"
|
|
4971
|
+
}
|
|
4972
|
+
},
|
|
4973
|
+
output: JSON_OUTPUT,
|
|
4974
|
+
isConcurrencySafe: () => true,
|
|
4975
|
+
async execute(rawArgs) {
|
|
4976
|
+
const args = rawArgs;
|
|
4977
|
+
const patch = {};
|
|
4978
|
+
if (args.name !== void 0) patch.name = args.name;
|
|
4979
|
+
if (args.prompt !== void 0) patch.prompt = args.prompt;
|
|
4980
|
+
if (args.cwd !== void 0) patch.cwd = args.cwd;
|
|
4981
|
+
if (args.scheduleKind !== void 0) patch.schedule = scheduleOf(args);
|
|
4982
|
+
if (args.targetKind !== void 0) patch.target = targetOf(args);
|
|
4983
|
+
if (args.catchup !== void 0) patch.catchup = args.catchup;
|
|
4984
|
+
if (args.enabled !== void 0) patch.enabled = args.enabled;
|
|
4985
|
+
return unwrap(await service.update(args.id, patch));
|
|
4986
|
+
}
|
|
4987
|
+
}),
|
|
4988
|
+
defineTool({
|
|
4989
|
+
name: "cron_task_delete",
|
|
4990
|
+
description: "Delete a scheduled task by id, including its run history.",
|
|
4991
|
+
parameters: { id: {
|
|
4992
|
+
type: "string",
|
|
4993
|
+
required: true,
|
|
4994
|
+
description: "Task id"
|
|
4995
|
+
} },
|
|
4996
|
+
output: JSON_OUTPUT,
|
|
4997
|
+
isConcurrencySafe: () => true,
|
|
4998
|
+
async execute(rawArgs) {
|
|
4999
|
+
return unwrap(await service.remove(rawArgs.id));
|
|
5000
|
+
}
|
|
5001
|
+
}),
|
|
5002
|
+
defineTool({
|
|
5003
|
+
name: "cron_task_trigger",
|
|
5004
|
+
description: "Trigger a scheduled task once right now (works even when disabled; fails with an error if the task is already running).",
|
|
5005
|
+
parameters: { id: {
|
|
5006
|
+
type: "string",
|
|
5007
|
+
required: true,
|
|
5008
|
+
description: "Task id"
|
|
5009
|
+
} },
|
|
5010
|
+
output: JSON_OUTPUT,
|
|
5011
|
+
isConcurrencySafe: () => true,
|
|
5012
|
+
async execute(rawArgs) {
|
|
5013
|
+
return unwrap(await service.trigger(rawArgs.id));
|
|
5014
|
+
}
|
|
5015
|
+
})
|
|
5016
|
+
];
|
|
5017
|
+
}
|
|
5018
|
+
/**
|
|
5019
|
+
* 注册 cron_* 工具到主 Agent scope 并探测宿主 schedule 互斥(spec §1/§5)。
|
|
5020
|
+
* 门控:跳过 subagent(session.header.origin === 'subagent')与插件自有会话(ownedSessions:
|
|
5021
|
+
* bot/schedule 会话)——dsh-schedule src/index.ts:45-49 同款 agent/created 模式,
|
|
5022
|
+
* 外加存量 roots 立即注册(HMR 重挂后当前主会话不丢工具)。
|
|
5023
|
+
* HMR 安全:镜像 dsh-schedule src/index.ts:44-76——attached 身份表防同一 agent 二次挂载,
|
|
5024
|
+
* toolkit 级 ctx.effect 卸载时 Promise.allSettled 摘下所有 agent 上挂的工具
|
|
5025
|
+
* (agent.ctx 属宿主、长于 toolkit fiber,仅靠 agent.ctx.effect 会在重挂时残留)。
|
|
5026
|
+
*/
|
|
5027
|
+
function setupCronTools(ctx, tools, ownedSessions) {
|
|
5028
|
+
const attached = /* @__PURE__ */ new Map();
|
|
5029
|
+
let stopping = false;
|
|
5030
|
+
ctx.effect(() => {
|
|
5031
|
+
const stopCreated = ctx.on("agent/created", ({ agent }) => {
|
|
5032
|
+
if (stopping || attached.has(agent)) return;
|
|
5033
|
+
attach(agent);
|
|
5034
|
+
});
|
|
5035
|
+
for (const agent of ctx.agents.roots()) attach(agent);
|
|
5036
|
+
return async () => {
|
|
5037
|
+
stopping = true;
|
|
5038
|
+
stopCreated();
|
|
5039
|
+
const cleanups = [...attached.values()];
|
|
5040
|
+
attached.clear();
|
|
5041
|
+
await Promise.allSettled(cleanups.map((cleanup) => Promise.resolve(cleanup())));
|
|
5042
|
+
};
|
|
5043
|
+
}, "dsh-agent-toolkit.cron-tools()");
|
|
5044
|
+
function attach(agent) {
|
|
5045
|
+
if (attached.has(agent)) return;
|
|
5046
|
+
if (agent.session.header.origin === "subagent") return;
|
|
5047
|
+
if (ownedSessions.has(String(agent.session.id))) return;
|
|
5048
|
+
const cleanup = agent.ctx.effect(() => {
|
|
5049
|
+
const scope = scopeOf(agent.ctx);
|
|
5050
|
+
if (scope !== void 0 ? ctx.tools.get(HOST_SCHEDULE_TOOL, scope) !== void 0 : ctx.tools.get(HOST_SCHEDULE_TOOL) !== void 0) ctx.logger.warn("dsh-agent-toolkit: 检测到宿主 @deepseek-ai/dsh-schedule 的 schedule_create 与 cron_* 并存——两套调度工具并存会显著提高模型误选率,且会话内提醒在会话冷时静默失败;请从组合中移除 @deepseek-ai/dsh-schedule(二选一)");
|
|
5051
|
+
const disposers = tools.map((tool) => agent.ctx.tools.register(tool));
|
|
5052
|
+
return () => {
|
|
5053
|
+
for (const dispose of disposers) dispose();
|
|
5054
|
+
if (attached.get(agent) === cleanup) attached.delete(agent);
|
|
5055
|
+
};
|
|
5056
|
+
}, "dsh-agent-toolkit.cron-tools.attach()");
|
|
5057
|
+
attached.set(agent, cleanup);
|
|
5058
|
+
}
|
|
5059
|
+
}
|
|
5060
|
+
//#endregion
|
|
5061
|
+
//#region src/schedule/index.ts
|
|
5062
|
+
/** schedule 模块接线:存储域 → executor/scheduler/service → cron_* 工具 + HTTP API + 30s tick。 */
|
|
5063
|
+
/** tick 间隔固定 30s(spec §8:不进 Config——YAGNI)。 */
|
|
5064
|
+
const TICK_MS = 3e4;
|
|
5065
|
+
function setupSchedule(ctx, config, deps) {
|
|
5066
|
+
const warn = (m) => ctx.logger.warn(m);
|
|
5067
|
+
const toolsScope = createToolsScope(ctx);
|
|
5068
|
+
const agentsPort = createAgentsPort(ctx, deps.botPresetId !== void 0 ? createScopeJoiner(ctx, deps.botPresetId, toolsScope, warn) : toolsScope, deps.ownedSessions);
|
|
5069
|
+
const workspaceRegistryOf = () => ctx.get("workspaceRegistry", false);
|
|
5070
|
+
const workspacePort = { async attach(cwd, sessionId) {
|
|
5071
|
+
const registry = workspaceRegistryOf();
|
|
5072
|
+
if (registry === void 0) throw new Error("workspaceRegistry 服务不可用");
|
|
5073
|
+
await (await registry.create(cwd)).attachSession(SessionId(sessionId));
|
|
5074
|
+
} };
|
|
5075
|
+
let scheduler;
|
|
5076
|
+
const started = openDomainSafely(ctx, scheduleDomain, warn).then(async (domain) => {
|
|
5077
|
+
const tasks = domain.table("tasks");
|
|
5078
|
+
const runs = domain.table("runs");
|
|
5079
|
+
const executor = createExecutor({
|
|
5080
|
+
agents: agentsPort,
|
|
5081
|
+
registry: deps.registry,
|
|
5082
|
+
defaultModel: () => {
|
|
5083
|
+
const selection = ctx.agentDefaultModel.currentSelection();
|
|
5084
|
+
return {
|
|
5085
|
+
provider: selection.provider,
|
|
5086
|
+
model: selection.model
|
|
5087
|
+
};
|
|
5088
|
+
},
|
|
5089
|
+
workspace: workspacePort,
|
|
5090
|
+
runs,
|
|
5091
|
+
runHistoryLimit: config.runHistoryLimit,
|
|
5092
|
+
runTimeoutMs: config.runTimeoutMinutes * 6e4,
|
|
5093
|
+
warn,
|
|
5094
|
+
now: () => Date.now(),
|
|
5095
|
+
newSessionId: () => randomUUID(),
|
|
5096
|
+
newRunId: () => randomUUID(),
|
|
5097
|
+
delay: (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
5098
|
+
});
|
|
5099
|
+
scheduler = createScheduler({
|
|
5100
|
+
tasks,
|
|
5101
|
+
runs,
|
|
5102
|
+
runHistoryLimit: config.runHistoryLimit,
|
|
5103
|
+
execute: (task) => executor.trigger(task),
|
|
5104
|
+
now: () => Date.now(),
|
|
5105
|
+
newRunId: () => randomUUID(),
|
|
5106
|
+
warn
|
|
5107
|
+
});
|
|
5108
|
+
const service = createCronService({
|
|
5109
|
+
tasks,
|
|
5110
|
+
runs,
|
|
5111
|
+
scheduler,
|
|
5112
|
+
registry: deps.registry,
|
|
5113
|
+
validateProject: (path) => existsSync(path),
|
|
5114
|
+
now: () => Date.now(),
|
|
5115
|
+
newTaskId: () => randomUUID()
|
|
5116
|
+
});
|
|
5117
|
+
setupCronTools(ctx, createCronTools(service), deps.ownedSessions);
|
|
5118
|
+
registerOptionalRoutes(ctx, (webCtx) => {
|
|
5119
|
+
const dispose = webCtx.webServer.register({
|
|
5120
|
+
kind: "prefix",
|
|
5121
|
+
path: "/dsh-agent-toolkit/api/cron",
|
|
5122
|
+
handler: async (req, res) => {
|
|
5123
|
+
try {
|
|
5124
|
+
await started;
|
|
5125
|
+
await createCronApiHandler({
|
|
5126
|
+
service,
|
|
5127
|
+
listProjects: () => workspaceRegistryOf()?.list().map((w) => w.path) ?? [],
|
|
5128
|
+
runHistoryLimit: config.runHistoryLimit
|
|
5129
|
+
})(req, res);
|
|
5130
|
+
} catch (error) {
|
|
5131
|
+
res.writeHead(500, { "content-type": "application/json" }).end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }));
|
|
5132
|
+
}
|
|
5133
|
+
}
|
|
5134
|
+
});
|
|
5135
|
+
return () => dispose();
|
|
5136
|
+
});
|
|
5137
|
+
await scheduler.rearmAll();
|
|
5138
|
+
});
|
|
5139
|
+
started.catch((error) => {
|
|
5140
|
+
warn(`[schedule] 启动失败:${error instanceof Error ? error.message : String(error)}`);
|
|
5141
|
+
});
|
|
5142
|
+
ctx.effect(() => {
|
|
5143
|
+
const timer = setInterval(() => {
|
|
5144
|
+
started.then(() => scheduler?.tick());
|
|
5145
|
+
}, TICK_MS);
|
|
5146
|
+
return () => clearInterval(timer);
|
|
5147
|
+
});
|
|
5148
|
+
ctx.effect(() => async () => {
|
|
5149
|
+
await toolsScope.dispose();
|
|
5150
|
+
});
|
|
3553
5151
|
}
|
|
3554
5152
|
//#endregion
|
|
3555
5153
|
//#region src/index.ts
|
|
@@ -3594,33 +5192,46 @@ const Config = z.object({
|
|
|
3594
5192
|
toolName: z.string().default("team_delegate"),
|
|
3595
5193
|
feishu: z.object({
|
|
3596
5194
|
cardUpdateThrottleMs: z.number().default(500),
|
|
3597
|
-
cardMaxBytes: z.number().default(
|
|
5195
|
+
cardMaxBytes: z.number().default(26e3),
|
|
5196
|
+
cardPrintStep: z.number().default(5),
|
|
3598
5197
|
processMaxBytes: z.number().default(8e3),
|
|
3599
5198
|
registerAppTimeoutMs: z.number().default(6e5),
|
|
3600
5199
|
processingReactionEmoji: z.string().default("OneSecond"),
|
|
3601
5200
|
errorDetailMaxChars: z.number().default(500),
|
|
3602
|
-
injectSender: z.boolean().default(true)
|
|
5201
|
+
injectSender: z.boolean().default(true),
|
|
5202
|
+
approval: z.boolean().default(true)
|
|
3603
5203
|
}).default({
|
|
3604
5204
|
cardUpdateThrottleMs: 500,
|
|
3605
|
-
cardMaxBytes:
|
|
5205
|
+
cardMaxBytes: 26e3,
|
|
5206
|
+
cardPrintStep: 5,
|
|
3606
5207
|
processMaxBytes: 8e3,
|
|
3607
5208
|
registerAppTimeoutMs: 6e5,
|
|
3608
5209
|
processingReactionEmoji: "OneSecond",
|
|
3609
5210
|
errorDetailMaxChars: 500,
|
|
3610
|
-
injectSender: true
|
|
5211
|
+
injectSender: true,
|
|
5212
|
+
approval: true
|
|
3611
5213
|
}),
|
|
3612
5214
|
agentTeamPreset: z.object({
|
|
3613
5215
|
enabled: z.boolean().default(true),
|
|
3614
5216
|
id: z.string().default("agent-team"),
|
|
3615
5217
|
source: z.string().default("standard"),
|
|
3616
5218
|
name: z.string().default("Agent 团队"),
|
|
3617
|
-
description: z.string().default("Agent 团队模式:禁用原生 subagent 工具族,委派统一走 team_delegate 团队角色")
|
|
5219
|
+
description: z.string().default("Agent 团队模式:禁用原生 subagent 工具族,委派统一走 team_delegate 团队角色"),
|
|
5220
|
+
botsId: z.string().default("agent-bot")
|
|
3618
5221
|
}).default({
|
|
3619
5222
|
enabled: true,
|
|
3620
5223
|
id: "agent-team",
|
|
3621
5224
|
source: "standard",
|
|
3622
5225
|
name: "Agent 团队",
|
|
3623
|
-
description: "Agent 团队模式:禁用原生 subagent 工具族,委派统一走 team_delegate 团队角色"
|
|
5226
|
+
description: "Agent 团队模式:禁用原生 subagent 工具族,委派统一走 team_delegate 团队角色",
|
|
5227
|
+
botsId: "agent-bot"
|
|
5228
|
+
}),
|
|
5229
|
+
schedule: z.object({
|
|
5230
|
+
runTimeoutMinutes: z.number().min(1).default(60),
|
|
5231
|
+
runHistoryLimit: z.natural().min(1).default(20)
|
|
5232
|
+
}).default({
|
|
5233
|
+
runTimeoutMinutes: 60,
|
|
5234
|
+
runHistoryLimit: 20
|
|
3624
5235
|
})
|
|
3625
5236
|
});
|
|
3626
5237
|
async function apply(ctx, config) {
|
|
@@ -3635,10 +5246,12 @@ async function apply(ctx, config) {
|
|
|
3635
5246
|
meta: domain.table("meta"),
|
|
3636
5247
|
promptLayers: domain.table("prompt_layers")
|
|
3637
5248
|
};
|
|
5249
|
+
await setupAgentTeamPreset(ctx, config.agentTeamPreset);
|
|
5250
|
+
const toolCatalog = createToolCatalog(ctx, config.agentTeamPreset.id);
|
|
3638
5251
|
const registry = await createRegistry(warn, {
|
|
3639
5252
|
agents: tables.agents,
|
|
3640
5253
|
meta: tables.meta
|
|
3641
|
-
});
|
|
5254
|
+
}, toolCatalog.listPresetTools);
|
|
3642
5255
|
const layerSource = await openLayerSource({
|
|
3643
5256
|
promptLayers: tables.promptLayers,
|
|
3644
5257
|
meta: tables.meta
|
|
@@ -3667,10 +5280,10 @@ async function apply(ctx, config) {
|
|
|
3667
5280
|
active: activeRoutes,
|
|
3668
5281
|
routes: routesTable
|
|
3669
5282
|
});
|
|
3670
|
-
const listTools = () => ctx.tools.schemas().map((s) => s.name);
|
|
3671
5283
|
setupAgentsApi(ctx, {
|
|
3672
5284
|
registry,
|
|
3673
|
-
listTools,
|
|
5285
|
+
listTools: toolCatalog.listGlobalTools,
|
|
5286
|
+
listPresetTools: toolCatalog.listPresetTools,
|
|
3674
5287
|
listProviders: () => ctx.llm.listProviders().map(({ id, name }) => ({
|
|
3675
5288
|
id,
|
|
3676
5289
|
name
|
|
@@ -3682,7 +5295,8 @@ async function apply(ctx, config) {
|
|
|
3682
5295
|
});
|
|
3683
5296
|
setupCreateAgentCommand(ctx, {
|
|
3684
5297
|
registry,
|
|
3685
|
-
listTools
|
|
5298
|
+
listTools: toolCatalog.listGlobalTools,
|
|
5299
|
+
listPresetTools: toolCatalog.listPresetTools
|
|
3686
5300
|
});
|
|
3687
5301
|
setupPromptLayersApi(ctx, {
|
|
3688
5302
|
source: layerSource,
|
|
@@ -3702,9 +5316,18 @@ async function apply(ctx, config) {
|
|
|
3702
5316
|
};
|
|
3703
5317
|
}
|
|
3704
5318
|
});
|
|
3705
|
-
|
|
3706
|
-
if (config.modules.feishu) setupBots(ctx, config.feishu, {
|
|
5319
|
+
const ownedSessions = /* @__PURE__ */ new Set();
|
|
5320
|
+
if (config.modules.feishu) setupBots(ctx, config.feishu, {
|
|
5321
|
+
registry,
|
|
5322
|
+
botPresetId: config.agentTeamPreset.enabled ? config.agentTeamPreset.botsId : void 0,
|
|
5323
|
+
ownedSessions
|
|
5324
|
+
});
|
|
3707
5325
|
if (config.modules.usage) setupUsage(ctx, { timezone: config.timezone }, name);
|
|
5326
|
+
setupSchedule(ctx, config.schedule, {
|
|
5327
|
+
registry,
|
|
5328
|
+
botPresetId: config.agentTeamPreset.enabled ? config.agentTeamPreset.botsId : void 0,
|
|
5329
|
+
ownedSessions
|
|
5330
|
+
});
|
|
3708
5331
|
}
|
|
3709
5332
|
//#endregion
|
|
3710
5333
|
export { Config, apply, inject, name };
|