dsh-agent-toolkit 0.2.8 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/lib/client.js +1693 -212
- package/lib/client.js.map +1 -1
- package/lib/index.d.ts +17 -2
- package/lib/index.js +1969 -291
- package/lib/index.js.map +1 -1
- package/package.json +13 -4
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,19 @@ 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: {
|
|
74
|
+
config: {
|
|
75
|
+
prefix: "You are a coding agent powered by the {{model}} model. If you need information or a decision from the user, ask directly in your reply and wait for their next message.",
|
|
76
|
+
suffix: "Your working directory is {{cwd}}."
|
|
77
|
+
}
|
|
69
78
|
},
|
|
70
79
|
{
|
|
71
80
|
id: "@deepseek-ai/dsh-agent-instructions",
|
|
@@ -78,10 +87,10 @@ const BASIC_TOOLS = [
|
|
|
78
87
|
config: { sampleOverCapGlobResults: false }
|
|
79
88
|
}
|
|
80
89
|
];
|
|
81
|
-
/**
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
90
|
+
/** 内置工具名常量(兜底名单):agentPresets 缺席或 standing 枚举失败时,Agents 面板名册
|
|
91
|
+
* 与存量迁移回退到这份常量。语义已降级为"兜底",不再承诺是完整原生工具面——完整面 =
|
|
92
|
+
* 团队 preset 动态枚举(agents/tool-catalog.ts)。explorer 只读白名单仍从本常量派生
|
|
93
|
+
* (刻意的最小集,不追求完整)。 */
|
|
85
94
|
const NATIVE_TOOL_NAMES = [
|
|
86
95
|
process.platform === "win32" ? "pwsh" : "bash",
|
|
87
96
|
"read",
|
|
@@ -93,10 +102,50 @@ const NATIVE_TOOL_NAMES = [
|
|
|
93
102
|
];
|
|
94
103
|
//#endregion
|
|
95
104
|
//#region src/agents/builtin.ts
|
|
96
|
-
/** 内置保底 Agent 记录:main + explorer
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
105
|
+
/** 内置保底 Agent 记录:main + explorer(只读白名单 10 个)/ general(preset 面全量 20 个、禁二级委派)。 */
|
|
106
|
+
const SHELL_NAME = process.platform === "win32" ? "pwsh" : "bash";
|
|
107
|
+
/** 旧版 explorer 默认白名单(5 个):存量条件式迁移的等值比对基准。
|
|
108
|
+
* 独立于 EXPLORER_READONLY_ALLOW 保留旧派生式——比对基准不可随新名单漂移。
|
|
109
|
+
* shell 名平台互斥(win32=pwsh、其余=bash),必须从 NATIVE_TOOL_NAMES 派生不可写死。 */
|
|
110
|
+
const LEGACY_EXPLORER_ALLOW = NATIVE_TOOL_NAMES.filter((n) => n !== "write" && n !== "edit");
|
|
111
|
+
/** explorer 默认白名单(10 个):只读基础五件(shell/read/read_image/glob/grep,旧派生不变)
|
|
112
|
+
* + preset 面只读安全五件(web_search/todo_write/job_list/job_output/skill——skill 加载的是
|
|
113
|
+
* 指令文本,本身只读,用户决策默认可用)。
|
|
114
|
+
* 编排类(ralph/workflow)、写文件类(write/edit)、job_kill、ask_user_question、
|
|
115
|
+
* goal 三件套、exit_plan_mode 不进只读名单。 */
|
|
116
|
+
const EXPLORER_READONLY_ALLOW = [
|
|
117
|
+
...LEGACY_EXPLORER_ALLOW,
|
|
118
|
+
"web_search",
|
|
119
|
+
"todo_write",
|
|
120
|
+
"job_list",
|
|
121
|
+
"job_output",
|
|
122
|
+
"skill"
|
|
123
|
+
];
|
|
124
|
+
/** general 默认白名单(20 个):agent-team preset standing 面全量(2026-09-08 实测枚举),
|
|
125
|
+
* 不含 team_delegate(禁二级委派)与 run_code(宿主 Code Mode 保留名,restrict 拒收)。
|
|
126
|
+
* 静态名单:preset 面日后新增工具不自动进入,需人工再梳理(见 spec 非目标)。 */
|
|
127
|
+
const GENERAL_ALLOW = [
|
|
128
|
+
SHELL_NAME,
|
|
129
|
+
"read",
|
|
130
|
+
"write",
|
|
131
|
+
"edit",
|
|
132
|
+
"read_image",
|
|
133
|
+
"glob",
|
|
134
|
+
"grep",
|
|
135
|
+
"todo_write",
|
|
136
|
+
"web_search",
|
|
137
|
+
"ask_user_question",
|
|
138
|
+
"skill",
|
|
139
|
+
"exit_plan_mode",
|
|
140
|
+
"job_list",
|
|
141
|
+
"job_output",
|
|
142
|
+
"job_kill",
|
|
143
|
+
"create_goal",
|
|
144
|
+
"get_goal",
|
|
145
|
+
"update_goal",
|
|
146
|
+
"ralph",
|
|
147
|
+
"workflow"
|
|
148
|
+
];
|
|
100
149
|
const BUILTIN_AGENTS = [
|
|
101
150
|
{
|
|
102
151
|
id: "main",
|
|
@@ -120,7 +169,8 @@ const BUILTIN_AGENTS = [
|
|
|
120
169
|
persona: `你是通用执行员。按任务书独立完成多步骤工作,可以读写文件、运行命令。
|
|
121
170
|
动手前先阅读相关 AGENTS.md 并遵循项目约定;完成后运行与改动相关的检查
|
|
122
171
|
(测试/类型检查)验证改动,并在最终输出中报告验证结果。`,
|
|
123
|
-
builtin: true
|
|
172
|
+
builtin: true,
|
|
173
|
+
tools: { allow: [...GENERAL_ALLOW] }
|
|
124
174
|
}
|
|
125
175
|
];
|
|
126
176
|
//#endregion
|
|
@@ -237,11 +287,15 @@ async function markImported(ctx) {
|
|
|
237
287
|
const TOOLS_NATIVE_MIGRATED_KEY = "tools_native_migrated";
|
|
238
288
|
/** explorer 只读白名单一次性并入的 meta 表标记键。 */
|
|
239
289
|
const EXPLORER_READONLY_MIGRATED_KEY = "explorer_readonly_migrated";
|
|
290
|
+
/** tools.allow 一次性并入「preset 面 − 原生常量」差集的 meta 表标记键。 */
|
|
291
|
+
const TOOLS_PRESET_MIGRATED_KEY = "tools_preset_catalog_migrated";
|
|
292
|
+
/** 内置角色工具名单重选(explorer 5→10 / general 无→20)一次性迁移的 meta 表标记键。 */
|
|
293
|
+
const BUILTIN_TOOLS_RECATALOG_MIGRATED_KEY = "builtin_tools_recatalog_migrated";
|
|
240
294
|
/**
|
|
241
|
-
* 打开 dsh_agent_toolkit 域 → 首启 YAML 导入 → 旧记录迁移(promptLayers/原生并入/explorer
|
|
295
|
+
* 打开 dsh_agent_toolkit 域 → 首启 YAML 导入 → 旧记录迁移(promptLayers/原生并入/preset 差集并入/explorer 只读/内置名单重选)→
|
|
242
296
|
* 缺 main/explorer/general 时种入内置 → 构建内存缓存。域由 apply 统一 open(storage-domain 同名单开),此处只消费表句柄。
|
|
243
297
|
*/
|
|
244
|
-
async function createRegistry(warn, tables) {
|
|
298
|
+
async function createRegistry(warn, tables, listPresetTools) {
|
|
245
299
|
const { agents, meta } = tables;
|
|
246
300
|
await importRolesYaml({
|
|
247
301
|
agents,
|
|
@@ -262,6 +316,25 @@ async function createRegistry(warn, tables) {
|
|
|
262
316
|
if (next !== record) await agents.put(id, next);
|
|
263
317
|
}
|
|
264
318
|
if (!nativeMigrated) await meta.put(TOOLS_NATIVE_MIGRATED_KEY, { value: "1" });
|
|
319
|
+
if (meta.get("tools_preset_catalog_migrated") === void 0 && listPresetTools !== void 0) {
|
|
320
|
+
let extra;
|
|
321
|
+
try {
|
|
322
|
+
extra = (await listPresetTools()).filter((n) => !NATIVE_TOOL_NAMES.includes(n));
|
|
323
|
+
} catch {
|
|
324
|
+
extra = void 0;
|
|
325
|
+
}
|
|
326
|
+
if (extra !== void 0 && extra.length > 0) {
|
|
327
|
+
for (const [id, record] of agents.entries()) {
|
|
328
|
+
if (record.builtin === true || record.tools === void 0) continue;
|
|
329
|
+
const missing = extra.filter((n) => !record.tools.allow.includes(n));
|
|
330
|
+
if (missing.length > 0) await agents.put(id, {
|
|
331
|
+
...record,
|
|
332
|
+
tools: { allow: [...record.tools.allow, ...missing] }
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
await meta.put(TOOLS_PRESET_MIGRATED_KEY, { value: "1" });
|
|
336
|
+
}
|
|
337
|
+
}
|
|
265
338
|
if (!(meta.get("explorer_readonly_migrated") !== void 0)) {
|
|
266
339
|
const explorer = agents.get("explorer");
|
|
267
340
|
if (explorer !== void 0 && explorer.tools === void 0) await agents.put("explorer", {
|
|
@@ -270,6 +343,24 @@ async function createRegistry(warn, tables) {
|
|
|
270
343
|
});
|
|
271
344
|
await meta.put(EXPLORER_READONLY_MIGRATED_KEY, { value: "1" });
|
|
272
345
|
}
|
|
346
|
+
if (meta.get("builtin_tools_recatalog_migrated") === void 0) {
|
|
347
|
+
const explorer = agents.get("explorer");
|
|
348
|
+
const legacyShapes = [LEGACY_EXPLORER_ALLOW, [
|
|
349
|
+
...LEGACY_EXPLORER_ALLOW,
|
|
350
|
+
"write",
|
|
351
|
+
"edit"
|
|
352
|
+
]];
|
|
353
|
+
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", {
|
|
354
|
+
...explorer,
|
|
355
|
+
tools: { allow: [...EXPLORER_READONLY_ALLOW] }
|
|
356
|
+
});
|
|
357
|
+
const general = agents.get("general");
|
|
358
|
+
if (general?.builtin === true && general.tools === void 0) await agents.put("general", {
|
|
359
|
+
...general,
|
|
360
|
+
tools: { allow: [...GENERAL_ALLOW] }
|
|
361
|
+
});
|
|
362
|
+
await meta.put(BUILTIN_TOOLS_RECATALOG_MIGRATED_KEY, { value: "1" });
|
|
363
|
+
}
|
|
273
364
|
await seedBuiltins(agents);
|
|
274
365
|
const cache = /* @__PURE__ */ new Map();
|
|
275
366
|
for (const [id, record] of agents.entries()) cache.set(id, record);
|
|
@@ -319,6 +410,27 @@ async function seedBuiltins(agents) {
|
|
|
319
410
|
for (const builtin of BUILTIN_AGENTS) if (agents.get(builtin.id) === void 0) await agents.put(builtin.id, builtin);
|
|
320
411
|
}
|
|
321
412
|
//#endregion
|
|
413
|
+
//#region src/agents/tool-catalog.ts
|
|
414
|
+
function createToolCatalog(ctx, presetId) {
|
|
415
|
+
return {
|
|
416
|
+
async listPresetTools() {
|
|
417
|
+
const presets = ctx.get("agentPresets", false);
|
|
418
|
+
if (presets === void 0) return [...NATIVE_TOOL_NAMES];
|
|
419
|
+
try {
|
|
420
|
+
const key = await presets.standingKeyFor(presetId);
|
|
421
|
+
const global = new Set(ctx.tools.schemas().map((s) => s.name));
|
|
422
|
+
return ctx.tools.schemas(key).map((s) => s.name).filter((n) => !global.has(n) && n !== RUN_CODE_NAME).sort();
|
|
423
|
+
} catch (error) {
|
|
424
|
+
ctx.logger.warn(`dsh-agent-toolkit: 枚举 preset "${presetId}" 工具面失败,回退内置常量:${error instanceof Error ? error.message : String(error)}`);
|
|
425
|
+
return [...NATIVE_TOOL_NAMES];
|
|
426
|
+
}
|
|
427
|
+
},
|
|
428
|
+
listGlobalTools() {
|
|
429
|
+
return ctx.tools.schemas().map((s) => s.name).filter((n) => n !== RUN_CODE_NAME);
|
|
430
|
+
}
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
//#endregion
|
|
322
434
|
//#region src/shared/storage.ts
|
|
323
435
|
function openDomainSafely(ctx, domain, warn, beforeClose) {
|
|
324
436
|
const ready = ctx.storageDomain.open(domain);
|
|
@@ -1313,10 +1425,19 @@ function createDelegateTool(toolName, deps) {
|
|
|
1313
1425
|
async execute(args, exec) {
|
|
1314
1426
|
const parent = exec.agent;
|
|
1315
1427
|
if (!parent) throw new Error("team_delegate 需要调用方 agent(exec.agent 为空)");
|
|
1316
|
-
const roster = deps.roster().filter((r) => r.id !== "main");
|
|
1428
|
+
const roster = deps.roster().filter((r) => r.id !== "main" && isTeamVisible(r));
|
|
1317
1429
|
const role = roster.find((r) => r.id === args.role);
|
|
1318
1430
|
if (!role) throw new Error(`未知角色 "${args.role}"。可用角色:${roster.map((r) => r.id).join(", ")}`);
|
|
1319
1431
|
const persona = deps.buildPersona(role);
|
|
1432
|
+
let toolFilter;
|
|
1433
|
+
if (role.tools !== void 0) {
|
|
1434
|
+
const visible = new Set(deps.visibleSurface(parent).filter((n) => n !== RUN_CODE_NAME));
|
|
1435
|
+
const effective = role.tools.allow.filter((n) => visible.has(n));
|
|
1436
|
+
const dropped = role.tools.allow.filter((n) => !visible.has(n));
|
|
1437
|
+
if (dropped.length > 0) deps.warn(`dsh-agent-toolkit: 角色 ${role.id} 白名单含本会话不可见工具,委派时忽略:${dropped.join(", ")}`);
|
|
1438
|
+
if (effective.length === 0) throw new Error(`dsh-agent-toolkit: 角色 ${role.id} 工具白名单求交后为空(原 ${role.tools.allow.length} 个均不可见):${role.tools.allow.join(", ")}`);
|
|
1439
|
+
toolFilter = { allow: effective };
|
|
1440
|
+
}
|
|
1320
1441
|
const request = {
|
|
1321
1442
|
label: `role:${role.id}: ${args.description}`,
|
|
1322
1443
|
prompt: [{
|
|
@@ -1331,7 +1452,7 @@ function createDelegateTool(toolName, deps) {
|
|
|
1331
1452
|
provider: role.model.provider,
|
|
1332
1453
|
model: role.model.model
|
|
1333
1454
|
} } : {},
|
|
1334
|
-
...
|
|
1455
|
+
...toolFilter !== void 0 ? { toolFilter } : {}
|
|
1335
1456
|
};
|
|
1336
1457
|
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
1458
|
provider: parent.options.provider,
|
|
@@ -1351,8 +1472,6 @@ function createDelegateTool(toolName, deps) {
|
|
|
1351
1472
|
}
|
|
1352
1473
|
//#endregion
|
|
1353
1474
|
//#region src/delegate/index.ts
|
|
1354
|
-
/** 团队名册段在 prompt 中的位置:紧随内置 subagent 段(116.5)之后。 */
|
|
1355
|
-
const TEAM_SECTION_ORDER = 116.6;
|
|
1356
1475
|
/**
|
|
1357
1476
|
* 团队名册段文本:仅当该 agent scope 的可见工具里确有委派工具时渲染,否则空
|
|
1358
1477
|
* (空段渲染时被丢弃)。工具白名单 restrict 只作用于 tools 视图,名册段必须
|
|
@@ -1360,7 +1479,7 @@ const TEAM_SECTION_ORDER = 116.6;
|
|
|
1360
1479
|
*/
|
|
1361
1480
|
function teamSectionText(toolName, roles, toolVisible) {
|
|
1362
1481
|
if (!toolVisible) return "";
|
|
1363
|
-
return `你有一组可委派的成员:用 ${toolName} 把自包含的子任务委派给合适的成员,成员结果会作为工具返回值回到本对话。\n可用成员:\n${roles.filter((r) => r.id !== "main").map((r) => `${r.id}: ${r.description ?? r.name}`).join("\n")}`;
|
|
1482
|
+
return `你有一组可委派的成员:用 ${toolName} 把自包含的子任务委派给合适的成员,成员结果会作为工具返回值回到本对话。\n可用成员:\n${roles.filter((r) => r.id !== "main" && isTeamVisible(r)).map((r) => `${r.id}: ${r.description ?? r.name}`).join("\n")}`;
|
|
1364
1483
|
}
|
|
1365
1484
|
/**
|
|
1366
1485
|
* 挂载 team_delegate 工具与函数式团队提示段。
|
|
@@ -1383,7 +1502,11 @@ function setupDelegate(ctx, config, registry, channels) {
|
|
|
1383
1502
|
buildPersona: (role) => buildAgentPersona({ rules: config.rules }, role, role.model),
|
|
1384
1503
|
startRun: (pr, request) => ctx.subagents.start(pr, request),
|
|
1385
1504
|
active: channels.active,
|
|
1386
|
-
recordRoute: channels.recordRoute
|
|
1505
|
+
recordRoute: channels.recordRoute,
|
|
1506
|
+
visibleSurface: (agent) => agent.ctx.tools.schemas(scopeOf(agent.ctx)).map((s) => s.name),
|
|
1507
|
+
warn: (msg) => {
|
|
1508
|
+
ctx.logger.warn(msg);
|
|
1509
|
+
}
|
|
1387
1510
|
}));
|
|
1388
1511
|
};
|
|
1389
1512
|
ctx.on("subagent/provider-added", (p) => {
|
|
@@ -1408,7 +1531,7 @@ function setupDelegate(ctx, config, registry, channels) {
|
|
|
1408
1531
|
else ctx.logger.info(`dsh-agent-toolkit: subagent provider "${provider}" 尚未注册;team_delegate 将等它出现时挂载`);
|
|
1409
1532
|
ctx.systemPrompt.section({
|
|
1410
1533
|
name: "plugin:dsh-agent-toolkit:team",
|
|
1411
|
-
order:
|
|
1534
|
+
order: ctx.systemPrompt.getSectionOrder("TOOL_SUBAGENT") + .1,
|
|
1412
1535
|
text: (context) => teamSectionText(config.toolName, registry.list(), ctx.tools.get(config.toolName, context.scope) !== void 0)
|
|
1413
1536
|
});
|
|
1414
1537
|
}
|
|
@@ -1504,7 +1627,7 @@ function createAgentsApiHandler(deps) {
|
|
|
1504
1627
|
}
|
|
1505
1628
|
if (sub === "/tools" && method === "GET") {
|
|
1506
1629
|
json$1(res, 200, {
|
|
1507
|
-
|
|
1630
|
+
preset: await deps.listPresetTools(),
|
|
1508
1631
|
global: deps.listTools()
|
|
1509
1632
|
});
|
|
1510
1633
|
return;
|
|
@@ -1631,7 +1754,7 @@ function buildCreateAgentGuidance(input) {
|
|
|
1631
1754
|
"3. 迭代:用户有修改意见时按意见修订名称、描述、个性和工具后再次确认,直到用户明确确认。"
|
|
1632
1755
|
];
|
|
1633
1756
|
if (input.requirement !== "") lines.push("", "## 用户初始需求", `用户已在命令中提供初始需求:「${input.requirement}」。请据此减少提问轮次,仅就不明确的点提问。`);
|
|
1634
|
-
lines.push("", "## 现有 Agent id(不可复用)", input.agentIds.join(", "), "id 规则:小写字母开头,仅含小写字母/数字/连字符([a-z0-9-]),最长 32 字符。", "", "## 可用工具清单",
|
|
1757
|
+
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
1758
|
if (input.origin === void 0) lines.push("", "## 落库", "当前宿主无 web 服务,无法自动落库。用户确认推荐后,请把最终配置完整输出给用户,并提示其打开 Agents 面板按推荐内容手动创建。");
|
|
1636
1759
|
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
1760
|
return lines.join("\n");
|
|
@@ -1648,12 +1771,13 @@ function setupCreateAgentCommand(ctx, deps) {
|
|
|
1648
1771
|
name: "create-agent",
|
|
1649
1772
|
description: "交互式创建 Agent 团队成员:访谈澄清需求 → 推荐配置 → 确认后经面板 API 落库",
|
|
1650
1773
|
input: { hint: "初始需求描述,可空" },
|
|
1651
|
-
handler: ({ rawInput, agent }) => {
|
|
1774
|
+
handler: async ({ rawInput, agent }) => {
|
|
1652
1775
|
const webServer = ctx.get("webServer");
|
|
1653
1776
|
const origin = webServer === void 0 ? void 0 : `http://127.0.0.1:${webServer.port}`;
|
|
1654
1777
|
const text = buildCreateAgentGuidance({
|
|
1655
1778
|
requirement: rawInput.trim(),
|
|
1656
1779
|
agentIds: deps.registry.list().map((agent) => agent.id),
|
|
1780
|
+
presetTools: await deps.listPresetTools(),
|
|
1657
1781
|
globalTools: deps.listTools(),
|
|
1658
1782
|
origin
|
|
1659
1783
|
});
|
|
@@ -1682,8 +1806,8 @@ const TOOLKIT_PERSONA_SECTION = "prompt-stack:persona";
|
|
|
1682
1806
|
* (global + 祖先 scope 层),own 层(agentCtx 直接挂载)的名字既不可 restrict 也
|
|
1683
1807
|
* 不受 restrict 影响,直接挂进 agentCtx 的白名单会抛 unknown global tools。
|
|
1684
1808
|
*/
|
|
1685
|
-
async function setupAgentScope(agentCtx, hooks,
|
|
1686
|
-
await
|
|
1809
|
+
async function setupAgentScope(agentCtx, hooks, joiner) {
|
|
1810
|
+
await joiner.join(agentCtx);
|
|
1687
1811
|
if (hooks.persona !== void 0) agentCtx.systemPrompt.section({
|
|
1688
1812
|
name: TOOLKIT_PERSONA_SECTION,
|
|
1689
1813
|
order: 10,
|
|
@@ -1694,10 +1818,56 @@ async function setupAgentScope(agentCtx, hooks, toolsScope) {
|
|
|
1694
1818
|
order: section.order,
|
|
1695
1819
|
text: section.text
|
|
1696
1820
|
});
|
|
1697
|
-
if (hooks.tools !== void 0)
|
|
1821
|
+
if (hooks.tools !== void 0) {
|
|
1822
|
+
const visible = new Set(agentCtx.tools.schemas(scopeOf(agentCtx)).map((s) => s.name).filter((n) => n !== RUN_CODE_NAME));
|
|
1823
|
+
const effective = hooks.tools.filter((n) => visible.has(n));
|
|
1824
|
+
const dropped = hooks.tools.filter((n) => !visible.has(n));
|
|
1825
|
+
if (dropped.length > 0) agentCtx.logger.warn(`dsh-agent-toolkit: 工具白名单含本会话不可见工具,已忽略:${dropped.join(", ")}`);
|
|
1826
|
+
if (effective.length === 0) throw new Error(`dsh-agent-toolkit: 工具白名单求交后为空(原 ${hooks.tools.length} 个均不可见):${hooks.tools.join(", ")}`);
|
|
1827
|
+
agentCtx.tools.restrict({ allow: effective });
|
|
1828
|
+
}
|
|
1829
|
+
}
|
|
1830
|
+
//#endregion
|
|
1831
|
+
//#region src/channels/agents-port.ts
|
|
1832
|
+
function createAgentsPort(ctx, joiner, ownedSessions) {
|
|
1833
|
+
function adaptAgent(handle) {
|
|
1834
|
+
const { agent } = handle;
|
|
1835
|
+
return {
|
|
1836
|
+
sessionId: String(agent.id),
|
|
1837
|
+
followup: (message) => agent.followup(message),
|
|
1838
|
+
cancel: () => agent.cancel({ kind: "user" }),
|
|
1839
|
+
whenIdle: () => agent.whenIdle()
|
|
1840
|
+
};
|
|
1841
|
+
}
|
|
1842
|
+
return {
|
|
1843
|
+
async create(input) {
|
|
1844
|
+
ownedSessions?.add(input.sessionId);
|
|
1845
|
+
return adaptAgent(await ctx.agents.create({
|
|
1846
|
+
sessionId: SessionId(input.sessionId),
|
|
1847
|
+
meta: { cwd: input.cwd },
|
|
1848
|
+
...input.agentOptions !== void 0 ? { agentOptions: input.agentOptions } : {},
|
|
1849
|
+
setup: (agentCtx) => setupAgentScope(agentCtx, input.hooks, joiner)
|
|
1850
|
+
}));
|
|
1851
|
+
},
|
|
1852
|
+
async resume(input) {
|
|
1853
|
+
ownedSessions?.add(input.sessionId);
|
|
1854
|
+
return adaptAgent(await ctx.agents.resume({
|
|
1855
|
+
resumeSessionId: SessionId(input.sessionId),
|
|
1856
|
+
...input.agentOptions !== void 0 ? { agentOptions: input.agentOptions } : {},
|
|
1857
|
+
setup: (agentCtx) => setupAgentScope(agentCtx, input.hooks, joiner)
|
|
1858
|
+
}));
|
|
1859
|
+
}
|
|
1860
|
+
};
|
|
1698
1861
|
}
|
|
1699
1862
|
//#endregion
|
|
1700
1863
|
//#region src/channels/feishu/api.ts
|
|
1864
|
+
/** 从 lark SDK 抛出的 axios 错误提取飞书业务错误码(无则 undefined)。 */
|
|
1865
|
+
function feishuErrorCode(error) {
|
|
1866
|
+
const data = error?.response?.data;
|
|
1867
|
+
if (typeof data !== "object" || data === null) return void 0;
|
|
1868
|
+
const code = data.code;
|
|
1869
|
+
return typeof code === "number" ? code : void 0;
|
|
1870
|
+
}
|
|
1701
1871
|
/** 附件服务接受的图片媒体类型(与宿主 attachment v1 一致)。 */
|
|
1702
1872
|
const IMAGE_MEDIA_TYPES = [
|
|
1703
1873
|
"image/png",
|
|
@@ -1973,27 +2143,10 @@ const STATUS_FINAL = {
|
|
|
1973
2143
|
error: "❌ 输出出错",
|
|
1974
2144
|
cancelled: "⏹ 已取消"
|
|
1975
2145
|
};
|
|
1976
|
-
/**
|
|
1977
|
-
const
|
|
2146
|
+
/** 拆卡定格状态行文案(旧卡内容已接续到下一张卡片)。 */
|
|
2147
|
+
const STATUS_CONTINUED = "📦 内容较长,已接续到下一张卡片";
|
|
1978
2148
|
/** 单卡组件数安全上限(飞书硬上限 200;面板按 2 计:面板 + 内嵌 markdown)。 */
|
|
1979
2149
|
const CARD_ELEMENT_LIMIT = 190;
|
|
1980
|
-
/** 按 UTF-8 字节上限截头(保留头部),不劈开多字节字符与代理对。 */
|
|
1981
|
-
function sliceByBytes(text, maxBytes) {
|
|
1982
|
-
if (Buffer.byteLength(text, "utf8") <= maxBytes) return text;
|
|
1983
|
-
let lo = 0;
|
|
1984
|
-
let hi = text.length;
|
|
1985
|
-
while (lo < hi) {
|
|
1986
|
-
const mid = Math.ceil((lo + hi) / 2);
|
|
1987
|
-
if (Buffer.byteLength(text.slice(0, mid), "utf8") <= maxBytes) lo = mid;
|
|
1988
|
-
else hi = mid - 1;
|
|
1989
|
-
}
|
|
1990
|
-
let cut = lo;
|
|
1991
|
-
if (cut > 0) {
|
|
1992
|
-
const code = text.charCodeAt(cut - 1);
|
|
1993
|
-
if (code >= 55296 && code <= 56319) cut -= 1;
|
|
1994
|
-
}
|
|
1995
|
-
return text.slice(0, cut);
|
|
1996
|
-
}
|
|
1997
2150
|
/** 按 UTF-8 字节上限截尾(保留尾部),不劈开多字节字符与代理对;截断时头部加省略标记。 */
|
|
1998
2151
|
function sliceTailByBytes(text, maxBytes) {
|
|
1999
2152
|
if (Buffer.byteLength(text, "utf8") <= maxBytes) return text;
|
|
@@ -2013,8 +2166,29 @@ function sliceTailByBytes(text, maxBytes) {
|
|
|
2013
2166
|
}
|
|
2014
2167
|
return PROCESS_OMITTED + text.slice(cut);
|
|
2015
2168
|
}
|
|
2169
|
+
/** JSON 串内内容的转义后字节数(去首尾引号);卡片 DSL 真实字节记账用。 */
|
|
2170
|
+
function escapedLen(s) {
|
|
2171
|
+
return Buffer.byteLength(JSON.stringify(s), "utf8") - 2;
|
|
2172
|
+
}
|
|
2173
|
+
/** 按转义后字节上限截头(保留头部),不劈开多字节字符与代理对。 */
|
|
2174
|
+
function sliceByEscapedBytes(text, maxBytes) {
|
|
2175
|
+
if (escapedLen(text) <= maxBytes) return text;
|
|
2176
|
+
let lo = 0;
|
|
2177
|
+
let hi = text.length;
|
|
2178
|
+
while (lo < hi) {
|
|
2179
|
+
const mid = Math.ceil((lo + hi) / 2);
|
|
2180
|
+
if (escapedLen(text.slice(0, mid)) <= maxBytes) lo = mid;
|
|
2181
|
+
else hi = mid - 1;
|
|
2182
|
+
}
|
|
2183
|
+
let cut = lo;
|
|
2184
|
+
if (cut > 0) {
|
|
2185
|
+
const code = text.charCodeAt(cut - 1);
|
|
2186
|
+
if (code >= 55296 && code <= 56319) cut -= 1;
|
|
2187
|
+
}
|
|
2188
|
+
return text.slice(0, cut);
|
|
2189
|
+
}
|
|
2016
2190
|
/** 新卡:仅状态行的流式卡;段后续经插入组件 API 动态加入。 */
|
|
2017
|
-
function buildCardJson() {
|
|
2191
|
+
function buildCardJson(printStep) {
|
|
2018
2192
|
return JSON.stringify({
|
|
2019
2193
|
schema: "2.0",
|
|
2020
2194
|
config: {
|
|
@@ -2022,7 +2196,7 @@ function buildCardJson() {
|
|
|
2022
2196
|
summary: { content: "生成中…" },
|
|
2023
2197
|
streaming_config: {
|
|
2024
2198
|
print_frequency_ms: { default: 70 },
|
|
2025
|
-
print_step: { default:
|
|
2199
|
+
print_step: { default: printStep },
|
|
2026
2200
|
print_strategy: "fast"
|
|
2027
2201
|
}
|
|
2028
2202
|
},
|
|
@@ -2065,30 +2239,56 @@ const initialStreamState = () => ({
|
|
|
2065
2239
|
carry: void 0
|
|
2066
2240
|
});
|
|
2067
2241
|
/** 把段序列的新增部分同步到卡片;新段 insert 到状态行之前,尾段增长走元素 update,满卡关流开续卡。 */
|
|
2068
|
-
function planSync(state, segments, maxBytes, processMaxBytes) {
|
|
2242
|
+
function planSync(state, segments, maxBytes, processMaxBytes, printStep) {
|
|
2069
2243
|
const ops = [];
|
|
2070
2244
|
let { cardId, seq, cardBytes, cardElements, segCounter, closedSegCount, tail, carry } = state;
|
|
2071
|
-
|
|
2072
|
-
|
|
2245
|
+
/** 约定:先改规划局部变量再 push——commit 捕获该 op 完成后的状态快照。 */
|
|
2246
|
+
const push = (op) => {
|
|
2247
|
+
const snap = {
|
|
2248
|
+
cardId,
|
|
2249
|
+
seq,
|
|
2250
|
+
cardBytes,
|
|
2251
|
+
cardElements,
|
|
2252
|
+
segCounter,
|
|
2253
|
+
closedSegCount,
|
|
2254
|
+
tail,
|
|
2255
|
+
carry
|
|
2256
|
+
};
|
|
2073
2257
|
ops.push({
|
|
2074
|
-
|
|
2075
|
-
|
|
2258
|
+
op,
|
|
2259
|
+
commit: () => ({ ...snap })
|
|
2076
2260
|
});
|
|
2077
|
-
|
|
2261
|
+
};
|
|
2262
|
+
const ensureCard = () => {
|
|
2263
|
+
if (cardId !== null) return;
|
|
2264
|
+
const cardJson = buildCardJson(printStep);
|
|
2078
2265
|
cardId = PENDING_CARD_ID;
|
|
2079
2266
|
seq = 0;
|
|
2080
|
-
cardBytes =
|
|
2267
|
+
cardBytes = Buffer.byteLength(cardJson, "utf8");
|
|
2081
2268
|
cardElements = 1;
|
|
2269
|
+
push({
|
|
2270
|
+
type: "create",
|
|
2271
|
+
cardJson
|
|
2272
|
+
});
|
|
2273
|
+
push({ type: "send" });
|
|
2082
2274
|
};
|
|
2083
2275
|
const closeCard = () => {
|
|
2084
2276
|
seq += 1;
|
|
2085
|
-
|
|
2086
|
-
type: "
|
|
2087
|
-
|
|
2277
|
+
push({
|
|
2278
|
+
type: "update",
|
|
2279
|
+
elementId: STATUS_ELEMENT_ID,
|
|
2280
|
+
content: STATUS_CONTINUED,
|
|
2088
2281
|
sequence: seq
|
|
2089
2282
|
});
|
|
2283
|
+
seq += 1;
|
|
2090
2284
|
cardId = null;
|
|
2091
2285
|
tail = void 0;
|
|
2286
|
+
push({
|
|
2287
|
+
type: "settings",
|
|
2288
|
+
streaming: false,
|
|
2289
|
+
sequence: seq,
|
|
2290
|
+
summary: STATUS_CONTINUED
|
|
2291
|
+
});
|
|
2092
2292
|
};
|
|
2093
2293
|
let i = tail?.segIndex ?? closedSegCount;
|
|
2094
2294
|
while (i < segments.length) {
|
|
@@ -2098,35 +2298,35 @@ function planSync(state, segments, maxBytes, processMaxBytes) {
|
|
|
2098
2298
|
const elementContent = seg.kind === "text" ? content.slice(base) : content;
|
|
2099
2299
|
if (tail !== void 0 && tail.segIndex === i) {
|
|
2100
2300
|
if (elementContent !== tail.shownText) {
|
|
2101
|
-
const delta =
|
|
2301
|
+
const delta = escapedLen(elementContent) - escapedLen(tail.shownText);
|
|
2102
2302
|
if (cardBytes + delta <= maxBytes) {
|
|
2103
2303
|
seq += 1;
|
|
2104
|
-
ops.push({
|
|
2105
|
-
type: "update",
|
|
2106
|
-
elementId: tail.elementId,
|
|
2107
|
-
content: elementContent,
|
|
2108
|
-
sequence: seq
|
|
2109
|
-
});
|
|
2110
2304
|
cardBytes += delta;
|
|
2111
2305
|
tail = {
|
|
2112
2306
|
...tail,
|
|
2113
2307
|
shownText: elementContent
|
|
2114
2308
|
};
|
|
2309
|
+
push({
|
|
2310
|
+
type: "update",
|
|
2311
|
+
elementId: tail.elementId,
|
|
2312
|
+
content: elementContent,
|
|
2313
|
+
sequence: seq
|
|
2314
|
+
});
|
|
2115
2315
|
} else if (seg.kind === "text") {
|
|
2116
|
-
const piece =
|
|
2316
|
+
const piece = sliceByEscapedBytes(elementContent, escapedLen(tail.shownText) + (maxBytes - cardBytes));
|
|
2117
2317
|
if (piece.length > tail.shownText.length) {
|
|
2118
2318
|
seq += 1;
|
|
2119
|
-
|
|
2319
|
+
cardBytes += escapedLen(piece) - escapedLen(tail.shownText);
|
|
2320
|
+
tail = {
|
|
2321
|
+
...tail,
|
|
2322
|
+
shownText: piece
|
|
2323
|
+
};
|
|
2324
|
+
push({
|
|
2120
2325
|
type: "update",
|
|
2121
2326
|
elementId: tail.elementId,
|
|
2122
2327
|
content: piece,
|
|
2123
2328
|
sequence: seq
|
|
2124
2329
|
});
|
|
2125
|
-
cardBytes += Buffer.byteLength(piece, "utf8") - Buffer.byteLength(tail.shownText, "utf8");
|
|
2126
|
-
tail = {
|
|
2127
|
-
...tail,
|
|
2128
|
-
shownText: piece
|
|
2129
|
-
};
|
|
2130
2330
|
}
|
|
2131
2331
|
carry = {
|
|
2132
2332
|
segIndex: i,
|
|
@@ -2147,21 +2347,17 @@ function planSync(state, segments, maxBytes, processMaxBytes) {
|
|
|
2147
2347
|
continue;
|
|
2148
2348
|
}
|
|
2149
2349
|
if (seg.kind === "process") {
|
|
2150
|
-
const
|
|
2151
|
-
|
|
2350
|
+
const elementId = `seg_${segCounter + 1}`;
|
|
2351
|
+
const elementJson = buildSegmentJson("process", elementId, elementContent);
|
|
2352
|
+
const elBytes = Buffer.byteLength(elementJson, "utf8");
|
|
2353
|
+
if (cardId !== null && (cardBytes + elBytes > maxBytes || cardElements + 2 > CARD_ELEMENT_LIMIT)) {
|
|
2152
2354
|
closeCard();
|
|
2153
2355
|
continue;
|
|
2154
2356
|
}
|
|
2155
2357
|
ensureCard();
|
|
2156
2358
|
segCounter += 1;
|
|
2157
|
-
const elementId = `seg_${segCounter}`;
|
|
2158
2359
|
seq += 1;
|
|
2159
|
-
|
|
2160
|
-
type: "insert",
|
|
2161
|
-
elementJson: buildSegmentJson("process", elementId, elementContent),
|
|
2162
|
-
sequence: seq
|
|
2163
|
-
});
|
|
2164
|
-
cardBytes += windowBytes;
|
|
2360
|
+
cardBytes += elBytes;
|
|
2165
2361
|
cardElements += 2;
|
|
2166
2362
|
tail = {
|
|
2167
2363
|
segIndex: i,
|
|
@@ -2169,6 +2365,11 @@ function planSync(state, segments, maxBytes, processMaxBytes) {
|
|
|
2169
2365
|
base: 0,
|
|
2170
2366
|
shownText: elementContent
|
|
2171
2367
|
};
|
|
2368
|
+
push({
|
|
2369
|
+
type: "insert",
|
|
2370
|
+
elementJson,
|
|
2371
|
+
sequence: seq
|
|
2372
|
+
});
|
|
2172
2373
|
} else {
|
|
2173
2374
|
if (elementContent.length === 0) {
|
|
2174
2375
|
closedSegCount = i + 1;
|
|
@@ -2181,17 +2382,14 @@ function planSync(state, segments, maxBytes, processMaxBytes) {
|
|
|
2181
2382
|
continue;
|
|
2182
2383
|
}
|
|
2183
2384
|
ensureCard();
|
|
2184
|
-
const
|
|
2185
|
-
|
|
2385
|
+
const elementId = `seg_${segCounter + 1}`;
|
|
2386
|
+
const overhead = Buffer.byteLength(buildSegmentJson("text", elementId, ""), "utf8");
|
|
2387
|
+
const piece = sliceByEscapedBytes(elementContent, maxBytes - cardBytes - overhead);
|
|
2388
|
+
if (piece.length === 0) throw new Error(`cardMaxBytes=${maxBytes} 过小,扣基础卡与元素开销后连一个字符都容纳不了`);
|
|
2186
2389
|
segCounter += 1;
|
|
2187
|
-
const
|
|
2390
|
+
const elementJson = buildSegmentJson("text", elementId, piece);
|
|
2188
2391
|
seq += 1;
|
|
2189
|
-
|
|
2190
|
-
type: "insert",
|
|
2191
|
-
elementJson: buildSegmentJson("text", elementId, piece),
|
|
2192
|
-
sequence: seq
|
|
2193
|
-
});
|
|
2194
|
-
cardBytes += Buffer.byteLength(piece, "utf8");
|
|
2392
|
+
cardBytes += Buffer.byteLength(elementJson, "utf8");
|
|
2195
2393
|
cardElements += 1;
|
|
2196
2394
|
tail = {
|
|
2197
2395
|
segIndex: i,
|
|
@@ -2199,6 +2397,11 @@ function planSync(state, segments, maxBytes, processMaxBytes) {
|
|
|
2199
2397
|
base,
|
|
2200
2398
|
shownText: piece
|
|
2201
2399
|
};
|
|
2400
|
+
push({
|
|
2401
|
+
type: "insert",
|
|
2402
|
+
elementJson,
|
|
2403
|
+
sequence: seq
|
|
2404
|
+
});
|
|
2202
2405
|
if (piece.length < elementContent.length) {
|
|
2203
2406
|
carry = {
|
|
2204
2407
|
segIndex: i,
|
|
@@ -2214,19 +2417,21 @@ function planSync(state, segments, maxBytes, processMaxBytes) {
|
|
|
2214
2417
|
closedSegCount = i + 1;
|
|
2215
2418
|
i += 1;
|
|
2216
2419
|
}
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
carry
|
|
2227
|
-
},
|
|
2228
|
-
ops
|
|
2420
|
+
const snap = {
|
|
2421
|
+
cardId,
|
|
2422
|
+
seq,
|
|
2423
|
+
cardBytes,
|
|
2424
|
+
cardElements,
|
|
2425
|
+
segCounter,
|
|
2426
|
+
closedSegCount,
|
|
2427
|
+
tail,
|
|
2428
|
+
carry
|
|
2229
2429
|
};
|
|
2430
|
+
ops.push({
|
|
2431
|
+
op: { type: "noop" },
|
|
2432
|
+
commit: () => ({ ...snap })
|
|
2433
|
+
});
|
|
2434
|
+
return { ops };
|
|
2230
2435
|
}
|
|
2231
2436
|
/** 定格:先 update 状态行(流式还开着),再关闭 + summary。 */
|
|
2232
2437
|
function planFinalize(state, status) {
|
|
@@ -2256,6 +2461,10 @@ async function withRetry(fn, attempts = 3, baseDelayMs = 300) {
|
|
|
2256
2461
|
}
|
|
2257
2462
|
throw lastError;
|
|
2258
2463
|
}
|
|
2464
|
+
/** 卡片输出异常时的用户提示(仅真实废弃一张已发卡时发送)。 */
|
|
2465
|
+
const ABANDON_NOTICE = "⚠️ 卡片输出异常,已在新卡片继续;如有内容缺失请重发。";
|
|
2466
|
+
/** 单次 flush 内连续废弃换卡的上限(防异常死循环;超限抛给出站链日志)。 */
|
|
2467
|
+
const MAX_ABANDON_PER_FLUSH = 3;
|
|
2259
2468
|
var FeishuReplyHandle = class {
|
|
2260
2469
|
api;
|
|
2261
2470
|
chatId;
|
|
@@ -2265,6 +2474,7 @@ var FeishuReplyHandle = class {
|
|
|
2265
2474
|
segments = [];
|
|
2266
2475
|
tail = Promise.resolve();
|
|
2267
2476
|
timer;
|
|
2477
|
+
planQueued = false;
|
|
2268
2478
|
finalized = false;
|
|
2269
2479
|
constructor(api, chatId, tunables, log) {
|
|
2270
2480
|
this.api = api;
|
|
@@ -2272,7 +2482,6 @@ var FeishuReplyHandle = class {
|
|
|
2272
2482
|
this.tunables = tunables;
|
|
2273
2483
|
this.log = log;
|
|
2274
2484
|
}
|
|
2275
|
-
/** 惰性建卡:无文本输出的 turn 不产生空卡片。 */
|
|
2276
2485
|
beginTurn() {
|
|
2277
2486
|
return Promise.resolve();
|
|
2278
2487
|
}
|
|
@@ -2297,49 +2506,204 @@ var FeishuReplyHandle = class {
|
|
|
2297
2506
|
}
|
|
2298
2507
|
this.flush();
|
|
2299
2508
|
await this.tail;
|
|
2509
|
+
const hadCard = this.state.cardId !== null;
|
|
2300
2510
|
const { ops } = planFinalize(this.state, status);
|
|
2301
|
-
this.enqueue(() =>
|
|
2302
|
-
|
|
2511
|
+
this.enqueue(async () => {
|
|
2512
|
+
for (const op of ops) if (await this.execOne({
|
|
2513
|
+
op,
|
|
2514
|
+
commit: (s) => s
|
|
2515
|
+
}) === "abandoned") return;
|
|
2516
|
+
});
|
|
2517
|
+
if (!hadCard && detail !== void 0) this.enqueue(() => withRetry(() => this.api.sendText(this.chatId, detail)).then(() => void 0));
|
|
2303
2518
|
await this.tail;
|
|
2304
2519
|
}
|
|
2305
2520
|
notice(text) {
|
|
2306
2521
|
this.enqueue(() => withRetry(() => this.api.sendText(this.chatId, text)).then(() => void 0));
|
|
2307
2522
|
return this.tail.then(() => void 0);
|
|
2308
2523
|
}
|
|
2524
|
+
/**
|
|
2525
|
+
* 规划入串行链:planSync 在执行点读最新已确认状态与最新 segments,
|
|
2526
|
+
* 在飞期间到达的 flush 只标位不重复规划(杜绝重复建卡/重复 insert)。
|
|
2527
|
+
*/
|
|
2309
2528
|
flush() {
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
this.
|
|
2313
|
-
|
|
2529
|
+
if (this.planQueued) return;
|
|
2530
|
+
this.planQueued = true;
|
|
2531
|
+
this.enqueue(async () => {
|
|
2532
|
+
this.planQueued = false;
|
|
2533
|
+
const { ops } = planSync(this.state, this.segments, this.tunables.cardMaxBytes, this.tunables.processMaxBytes, this.tunables.cardPrintStep);
|
|
2534
|
+
await this.exec(ops);
|
|
2535
|
+
});
|
|
2536
|
+
}
|
|
2537
|
+
/** 逐 op 确认执行;遇废弃从已确认状态重新规划续写(上限 MAX_ABANDON_PER_FLUSH 次)。 */
|
|
2538
|
+
async exec(ops) {
|
|
2539
|
+
let pending = ops;
|
|
2540
|
+
for (let attempts = 0;; attempts++) {
|
|
2541
|
+
let abandoned = false;
|
|
2542
|
+
for (const planned of pending) if (await this.execOne(planned) === "abandoned") {
|
|
2543
|
+
abandoned = true;
|
|
2544
|
+
break;
|
|
2545
|
+
}
|
|
2546
|
+
if (!abandoned) return;
|
|
2547
|
+
if (attempts >= MAX_ABANDON_PER_FLUSH) throw new Error("卡片连续废弃超限,本批输出放弃(下一 flush 继续)");
|
|
2548
|
+
pending = planSync(this.state, this.segments, this.tunables.cardMaxBytes, this.tunables.processMaxBytes, this.tunables.cardPrintStep).ops;
|
|
2549
|
+
}
|
|
2550
|
+
}
|
|
2551
|
+
/**
|
|
2552
|
+
* 单个 op:成功(或 insert 300301 视同成功)才 commit;
|
|
2553
|
+
* 失败按错误码分类:流式超时重激活重放 / 200860 废弃续写 / 未知错误 seq+2 重放一次。
|
|
2554
|
+
*/
|
|
2555
|
+
async execOne(planned) {
|
|
2556
|
+
const { op } = planned;
|
|
2557
|
+
if (op.type === "noop") {
|
|
2558
|
+
this.commit(planned);
|
|
2559
|
+
return "ok";
|
|
2560
|
+
}
|
|
2561
|
+
const liveCard = this.state.cardId !== null && this.state.cardId !== "__pending__";
|
|
2562
|
+
try {
|
|
2563
|
+
await this.invokeThenCommit(planned);
|
|
2564
|
+
return "ok";
|
|
2565
|
+
} catch (error) {
|
|
2566
|
+
if (!liveCard) throw error;
|
|
2567
|
+
const code = feishuErrorCode(error);
|
|
2568
|
+
if (op.type === "insert" && code === 300301) {
|
|
2569
|
+
this.commit(planned);
|
|
2570
|
+
return "ok";
|
|
2571
|
+
}
|
|
2572
|
+
if (code === 200850 || code === 200510) {
|
|
2573
|
+
if (await this.reactivate()) try {
|
|
2574
|
+
await this.invokeThenCommit(planned, this.state.seq + 1);
|
|
2575
|
+
return "ok";
|
|
2576
|
+
} catch {
|
|
2577
|
+
return this.abandon("流式超时重激活后重放失败", true);
|
|
2578
|
+
}
|
|
2579
|
+
return this.abandon("流式超时且重激活失败", true);
|
|
2580
|
+
}
|
|
2581
|
+
if (code === 200860) return this.abandon("卡片超出平台大小上限", false);
|
|
2582
|
+
try {
|
|
2583
|
+
const retrySeq = (op.type === "insert" || op.type === "update" || op.type === "settings" ? op.sequence : this.state.seq) + 2;
|
|
2584
|
+
await this.invokeThenCommit(planned, retrySeq);
|
|
2585
|
+
return "ok";
|
|
2586
|
+
} catch (retryError) {
|
|
2587
|
+
if (op.type === "insert" && feishuErrorCode(retryError) === 300301) {
|
|
2588
|
+
this.commit(planned);
|
|
2589
|
+
return "ok";
|
|
2590
|
+
}
|
|
2591
|
+
return this.abandon("卡片操作重放失败", true);
|
|
2592
|
+
}
|
|
2593
|
+
}
|
|
2594
|
+
}
|
|
2595
|
+
/**
|
|
2596
|
+
* 执行 API 并在成功后 commit;seqOverride 用于重放(create 的真实 cardId 在此覆盖进状态)。
|
|
2597
|
+
* 带序号 op 的发送/提交序号一律取 max(本次序号, 已确认 seq+1):重放/重激活会推高已确认 seq,
|
|
2598
|
+
* 批内后续 op 的规划序号可能落后,照发会以非递增序号碰撞(平台可能视作幂等 no-op 静默丢 op →
|
|
2599
|
+
* 定格/关流 op 消失 → 卡死「输出中」)。
|
|
2600
|
+
*/
|
|
2601
|
+
async invokeThenCommit(planned, seqOverride) {
|
|
2602
|
+
const effectiveSeq = effectiveSeqOf(planned.op, seqOverride, this.state.seq);
|
|
2603
|
+
const op = withSeq(planned.op, effectiveSeq);
|
|
2604
|
+
if (op.type === "create") {
|
|
2605
|
+
const id = await withRetry(() => this.api.createCard(op.cardJson));
|
|
2606
|
+
this.state = {
|
|
2607
|
+
...planned.commit(this.state),
|
|
2608
|
+
cardId: id
|
|
2609
|
+
};
|
|
2610
|
+
return;
|
|
2611
|
+
}
|
|
2612
|
+
if (op.type === "send") await withRetry(() => this.api.sendCardMessage(this.chatId, this.state.cardId));
|
|
2613
|
+
else if (op.type === "insert") await this.api.insertElement(this.state.cardId, op.elementJson, STATUS_ELEMENT_ID, op.sequence);
|
|
2614
|
+
else if (op.type === "update") await this.api.updateCardElement(this.state.cardId, op.elementId, op.content, op.sequence);
|
|
2615
|
+
else if (op.type === "settings") await this.api.setCardStreaming(this.state.cardId, op.streaming, op.sequence, op.summary);
|
|
2616
|
+
this.commit(planned, effectiveSeq);
|
|
2617
|
+
}
|
|
2618
|
+
commit(planned, seqOverride) {
|
|
2619
|
+
const next = planned.commit(this.state);
|
|
2620
|
+
const current = this.state.cardId;
|
|
2621
|
+
const cardId = next.cardId === "__pending__" && current !== null && current !== "__pending__" ? current : next.cardId;
|
|
2622
|
+
this.state = seqOverride !== void 0 ? {
|
|
2623
|
+
...next,
|
|
2624
|
+
seq: seqOverride,
|
|
2625
|
+
cardId
|
|
2626
|
+
} : {
|
|
2627
|
+
...next,
|
|
2628
|
+
seq: Math.max(next.seq, this.state.seq),
|
|
2629
|
+
cardId
|
|
2630
|
+
};
|
|
2631
|
+
}
|
|
2632
|
+
/** 流式超时后的官方恢复路径:settings 重设 streaming_mode:true(占一个 sequence)。 */
|
|
2633
|
+
async reactivate() {
|
|
2634
|
+
const { cardId, seq } = this.state;
|
|
2635
|
+
if (cardId === null || cardId === "__pending__") return false;
|
|
2636
|
+
try {
|
|
2637
|
+
await this.api.setCardStreaming(cardId, true, seq + 1);
|
|
2638
|
+
this.state = {
|
|
2639
|
+
...this.state,
|
|
2640
|
+
seq: seq + 1
|
|
2641
|
+
};
|
|
2642
|
+
return true;
|
|
2643
|
+
} catch {
|
|
2644
|
+
return false;
|
|
2645
|
+
}
|
|
2646
|
+
}
|
|
2647
|
+
/**
|
|
2648
|
+
* 废弃当前卡:尽力关流 → cardId 归零、尾段回卷为 carry → 调用方重新规划续写。
|
|
2649
|
+
* reshowTail=true(未知失败,op 可能已执行)时从尾段 base 重演(少量重复优于丢失);
|
|
2650
|
+
* reshowTail=false(200860 确定性未应用)时跳过已显示部分(零重复)。
|
|
2651
|
+
*/
|
|
2652
|
+
async abandon(reason, reshowTail) {
|
|
2653
|
+
const { cardId, tail, seq } = this.state;
|
|
2654
|
+
const hadRealCard = cardId !== null && cardId !== "__pending__";
|
|
2655
|
+
if (hadRealCard) await this.api.setCardStreaming(cardId, false, seq + 1).catch(() => void 0);
|
|
2656
|
+
if (tail !== void 0) {
|
|
2657
|
+
const base = this.segments[tail.segIndex]?.kind === "process" ? 0 : reshowTail ? tail.base : tail.base + tail.shownText.length;
|
|
2658
|
+
this.state = {
|
|
2659
|
+
...this.state,
|
|
2660
|
+
cardId: null,
|
|
2661
|
+
tail: void 0,
|
|
2662
|
+
closedSegCount: tail.segIndex,
|
|
2663
|
+
carry: {
|
|
2664
|
+
segIndex: tail.segIndex,
|
|
2665
|
+
base
|
|
2666
|
+
}
|
|
2667
|
+
};
|
|
2668
|
+
} else this.state = {
|
|
2669
|
+
...this.state,
|
|
2670
|
+
cardId: null
|
|
2671
|
+
};
|
|
2672
|
+
this.log(`[project-bot] 卡片输出异常(${reason}),已废弃当前卡并在新卡继续`);
|
|
2673
|
+
if (hadRealCard) await withRetry(() => this.api.sendText(this.chatId, ABANDON_NOTICE)).catch(() => void 0);
|
|
2674
|
+
return "abandoned";
|
|
2314
2675
|
}
|
|
2315
2676
|
enqueue(task) {
|
|
2316
2677
|
this.tail = this.tail.then(task).catch((error) => {
|
|
2317
|
-
if (this.state.cardId === "__pending__") {
|
|
2318
|
-
const t = this.state.tail;
|
|
2319
|
-
this.state = {
|
|
2320
|
-
...this.state,
|
|
2321
|
-
cardId: null,
|
|
2322
|
-
...t !== void 0 ? {
|
|
2323
|
-
tail: void 0,
|
|
2324
|
-
carry: {
|
|
2325
|
-
segIndex: t.segIndex,
|
|
2326
|
-
base: t.base
|
|
2327
|
-
},
|
|
2328
|
-
closedSegCount: t.segIndex
|
|
2329
|
-
} : {}
|
|
2330
|
-
};
|
|
2331
|
-
}
|
|
2332
2678
|
this.log(`[project-bot] 卡片操作失败:${error instanceof Error ? error.message : String(error)}`);
|
|
2333
2679
|
});
|
|
2334
2680
|
}
|
|
2335
|
-
async exec(ops) {
|
|
2336
|
-
for (const op of ops) if (op.type === "create") this.state.cardId = await withRetry(() => this.api.createCard(op.cardJson));
|
|
2337
|
-
else if (op.type === "send") await withRetry(() => this.api.sendCardMessage(this.chatId, this.state.cardId));
|
|
2338
|
-
else if (op.type === "insert") await withRetry(() => this.api.insertElement(this.state.cardId, op.elementJson, STATUS_ELEMENT_ID, op.sequence));
|
|
2339
|
-
else if (op.type === "update") await withRetry(() => this.api.updateCardElement(this.state.cardId, op.elementId, op.content, op.sequence));
|
|
2340
|
-
else await withRetry(() => this.api.setCardStreaming(this.state.cardId, op.streaming, op.sequence, op.summary));
|
|
2341
|
-
}
|
|
2342
2681
|
};
|
|
2682
|
+
/** 重放用:仅带 sequence 语义的 op 替换序号(create/send 无序号)。 */
|
|
2683
|
+
function withSeq(op, sequence) {
|
|
2684
|
+
if (sequence === void 0) return op;
|
|
2685
|
+
if (op.type === "insert") return {
|
|
2686
|
+
...op,
|
|
2687
|
+
sequence
|
|
2688
|
+
};
|
|
2689
|
+
if (op.type === "update") return {
|
|
2690
|
+
...op,
|
|
2691
|
+
sequence
|
|
2692
|
+
};
|
|
2693
|
+
if (op.type === "settings") return {
|
|
2694
|
+
...op,
|
|
2695
|
+
sequence
|
|
2696
|
+
};
|
|
2697
|
+
return op;
|
|
2698
|
+
}
|
|
2699
|
+
/**
|
|
2700
|
+
* 带序号 op 的生效 sequence(create/send 返回 undefined = 不占序号):
|
|
2701
|
+
* 重放/重激活推高已确认 seq 后,规划或重放序号落后时抬升到已确认 seq+1,保证单调不碰撞。
|
|
2702
|
+
*/
|
|
2703
|
+
function effectiveSeqOf(op, override, confirmedSeq) {
|
|
2704
|
+
if (op.type !== "insert" && op.type !== "update" && op.type !== "settings") return void 0;
|
|
2705
|
+
return Math.max(override ?? op.sequence, confirmedSeq + 1);
|
|
2706
|
+
}
|
|
2343
2707
|
/** 「处理中」表情:加上后返回删除 disposer;加/删失败都静默(表情残留无害)。 */
|
|
2344
2708
|
function makeAck(api, messageId, emojiType) {
|
|
2345
2709
|
return async () => {
|
|
@@ -2354,45 +2718,165 @@ function makeAck(api, messageId, emojiType) {
|
|
|
2354
2718
|
};
|
|
2355
2719
|
}
|
|
2356
2720
|
//#endregion
|
|
2357
|
-
//#region src/channels/feishu
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
text: parsed.text,
|
|
2382
|
-
...loadImages !== void 0 ? { loadImages } : {},
|
|
2383
|
-
reply,
|
|
2384
|
-
ackProcessing: makeAck(api, parsed.messageId, tunables.processingReactionEmoji)
|
|
2385
|
-
});
|
|
2721
|
+
//#region src/channels/approval/feishu.ts
|
|
2722
|
+
const STATUS_TEXT = {
|
|
2723
|
+
allowed: "✅ 已允许",
|
|
2724
|
+
rejected: "❌ 已拒绝",
|
|
2725
|
+
cancelled: "⏹ 已取消"
|
|
2726
|
+
};
|
|
2727
|
+
function bodyMarkdown(prompt) {
|
|
2728
|
+
const lines = [`**Bot**:${prompt.botName}`, `**工具**:\`${prompt.toolName}\``];
|
|
2729
|
+
if (prompt.reason !== void 0) lines.push(`**理由**:${prompt.reason}`);
|
|
2730
|
+
return lines.join("\n");
|
|
2731
|
+
}
|
|
2732
|
+
function button(text, type, key, decision) {
|
|
2733
|
+
return {
|
|
2734
|
+
tag: "button",
|
|
2735
|
+
text: {
|
|
2736
|
+
tag: "plain_text",
|
|
2737
|
+
content: text
|
|
2738
|
+
},
|
|
2739
|
+
type,
|
|
2740
|
+
behaviors: [{
|
|
2741
|
+
type: "callback",
|
|
2742
|
+
value: {
|
|
2743
|
+
key,
|
|
2744
|
+
decision
|
|
2386
2745
|
}
|
|
2387
|
-
}
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2746
|
+
}]
|
|
2747
|
+
};
|
|
2748
|
+
}
|
|
2749
|
+
/** 审批卡(带按钮);summary 供会话列表/推送预览。 */
|
|
2750
|
+
function buildApprovalCardJson(prompt) {
|
|
2751
|
+
return JSON.stringify({
|
|
2752
|
+
schema: "2.0",
|
|
2753
|
+
config: { summary: { content: `权限申请:${prompt.toolName}` } },
|
|
2754
|
+
header: {
|
|
2755
|
+
title: {
|
|
2756
|
+
tag: "plain_text",
|
|
2757
|
+
content: "权限申请"
|
|
2758
|
+
},
|
|
2759
|
+
template: "orange"
|
|
2760
|
+
},
|
|
2761
|
+
body: { elements: [{
|
|
2762
|
+
tag: "markdown",
|
|
2763
|
+
content: bodyMarkdown(prompt)
|
|
2764
|
+
}, {
|
|
2765
|
+
tag: "action",
|
|
2766
|
+
actions: [button("允许", "primary", prompt.key, "allow"), button("拒绝", "danger", prompt.key, "reject")]
|
|
2767
|
+
}] }
|
|
2768
|
+
});
|
|
2769
|
+
}
|
|
2770
|
+
/** 终态卡(无按钮):定格审批结果;operatorName 仅允许/拒绝时有。 */
|
|
2771
|
+
function buildApprovalFinalCardJson(prompt, status, operatorName) {
|
|
2772
|
+
const statusLine = STATUS_TEXT[status] + (operatorName !== void 0 ? ` · ${operatorName}` : "");
|
|
2773
|
+
const template = status === "allowed" ? "green" : status === "rejected" ? "red" : "grey";
|
|
2774
|
+
return JSON.stringify({
|
|
2775
|
+
schema: "2.0",
|
|
2776
|
+
config: { summary: { content: `权限申请${STATUS_TEXT[status].slice(2).trim()}:${prompt.toolName}` } },
|
|
2777
|
+
header: {
|
|
2778
|
+
title: {
|
|
2779
|
+
tag: "plain_text",
|
|
2780
|
+
content: `权限申请 · ${statusLine}`
|
|
2781
|
+
},
|
|
2782
|
+
template
|
|
2783
|
+
},
|
|
2784
|
+
body: { elements: [{
|
|
2785
|
+
tag: "markdown",
|
|
2786
|
+
content: bodyMarkdown(prompt)
|
|
2787
|
+
}] }
|
|
2788
|
+
});
|
|
2789
|
+
}
|
|
2790
|
+
/** 飞书审批能力:present = 建卡 + 发消息;finalize = replaceCard 定格(sequence 从 1 起,create/send 不占)。 */
|
|
2791
|
+
var FeishuApprovalPresenter = class {
|
|
2792
|
+
api;
|
|
2793
|
+
log;
|
|
2794
|
+
constructor(api, log) {
|
|
2795
|
+
this.api = api;
|
|
2796
|
+
this.log = log;
|
|
2797
|
+
}
|
|
2798
|
+
async present(prompt) {
|
|
2799
|
+
const cardId = await withRetry(() => this.api.createCard(buildApprovalCardJson(prompt)));
|
|
2800
|
+
await withRetry(() => this.api.sendCardMessage(prompt.chatId, cardId));
|
|
2801
|
+
return { finalize: async (status, operatorName) => {
|
|
2802
|
+
try {
|
|
2803
|
+
await withRetry(() => this.api.replaceCard(cardId, buildApprovalFinalCardJson(prompt, status, operatorName), 1));
|
|
2804
|
+
} catch (error) {
|
|
2805
|
+
this.log(`[project-bot] 审批卡片定格失败(card ${cardId}):${error instanceof Error ? error.message : String(error)}`);
|
|
2806
|
+
}
|
|
2807
|
+
} };
|
|
2808
|
+
}
|
|
2809
|
+
};
|
|
2810
|
+
//#endregion
|
|
2811
|
+
//#region src/channels/feishu/card-action.ts
|
|
2812
|
+
/** card.action.trigger 回调解析:SDK normalizeCardAction → 渠道无关 CardActionInput;toast 应答帧负载。 */
|
|
2813
|
+
/** raw → CardActionInput;normalize 失败(畸形/缺字段)→ undefined。 */
|
|
2814
|
+
function toCardActionInput(raw) {
|
|
2815
|
+
if (raw === null || typeof raw !== "object") return void 0;
|
|
2816
|
+
const evt = lark.normalizeCardAction(raw);
|
|
2817
|
+
if (evt === null) return void 0;
|
|
2818
|
+
return {
|
|
2819
|
+
chatId: evt.chatId,
|
|
2820
|
+
operatorOpenId: evt.operator.openId,
|
|
2821
|
+
...evt.operator.name !== void 0 ? { operatorName: evt.operator.name } : {},
|
|
2822
|
+
value: evt.action.value
|
|
2823
|
+
};
|
|
2824
|
+
}
|
|
2825
|
+
/** WS 应答帧负载:handler 返回值经 WSClient 回传(lib/index.js handleEventData:truthy result → respPayload.data)。 */
|
|
2826
|
+
function toastResponse(ack) {
|
|
2827
|
+
if (ack?.toast === void 0) return void 0;
|
|
2828
|
+
return { toast: {
|
|
2829
|
+
type: "info",
|
|
2830
|
+
content: ack.toast
|
|
2831
|
+
} };
|
|
2832
|
+
}
|
|
2833
|
+
//#endregion
|
|
2834
|
+
//#region src/channels/feishu/index.ts
|
|
2835
|
+
/** 飞书渠道:WSClient 长连接收事件 → 解析 → 核心;出站走 FeishuReplyHandle。 */
|
|
2836
|
+
const feishuChannel = {
|
|
2837
|
+
type: "feishu",
|
|
2838
|
+
async start(bot, io, tunables, log) {
|
|
2839
|
+
const { appId } = bot.record.feishu;
|
|
2840
|
+
const api = createFeishuApi(new lark.Client({
|
|
2841
|
+
appId,
|
|
2842
|
+
appSecret: bot.secret
|
|
2843
|
+
}));
|
|
2844
|
+
const botOpenId = await api.getBotOpenId();
|
|
2845
|
+
const dedup = new MessageDedup();
|
|
2846
|
+
const dispatcher = new lark.EventDispatcher({}).register({
|
|
2847
|
+
"im.message.message_read_v1": async () => void 0,
|
|
2848
|
+
"im.message.receive_v1": async (data) => {
|
|
2849
|
+
const parsed = parseMessageEvent(data, botOpenId);
|
|
2850
|
+
if (parsed === null || !dedup.check(parsed.messageId)) return;
|
|
2851
|
+
const reply = new FeishuReplyHandle(api, parsed.chatId, tunables, log);
|
|
2852
|
+
const loadImages = parsed.imageKeys.length > 0 ? async () => Promise.all(parsed.imageKeys.map(async (key) => api.downloadImage(parsed.messageId, key))) : void 0;
|
|
2853
|
+
io.onMessage({
|
|
2854
|
+
botId: bot.record.id,
|
|
2855
|
+
chatId: parsed.chatId,
|
|
2856
|
+
userId: parsed.userId,
|
|
2857
|
+
messageId: parsed.messageId,
|
|
2858
|
+
text: parsed.text,
|
|
2859
|
+
...loadImages !== void 0 ? { loadImages } : {},
|
|
2860
|
+
reply,
|
|
2861
|
+
ackProcessing: makeAck(api, parsed.messageId, tunables.processingReactionEmoji)
|
|
2862
|
+
});
|
|
2863
|
+
},
|
|
2864
|
+
"card.action.trigger": (raw) => {
|
|
2865
|
+
if (io.onCardAction === void 0) return void 0;
|
|
2866
|
+
const action = toCardActionInput(raw);
|
|
2867
|
+
if (action === void 0) return void 0;
|
|
2868
|
+
return toastResponse(io.onCardAction(action)) ?? void 0;
|
|
2869
|
+
}
|
|
2870
|
+
});
|
|
2871
|
+
const ws = new lark.WSClient({
|
|
2872
|
+
appId,
|
|
2873
|
+
appSecret: bot.secret,
|
|
2874
|
+
loggerLevel: lark.LoggerLevel.warn
|
|
2875
|
+
});
|
|
2876
|
+
await ws.start({ eventDispatcher: dispatcher });
|
|
2877
|
+
return {
|
|
2878
|
+
approval: new FeishuApprovalPresenter(api, log),
|
|
2879
|
+
close: () => {
|
|
2396
2880
|
ws.close({ force: true });
|
|
2397
2881
|
return Promise.resolve();
|
|
2398
2882
|
},
|
|
@@ -2448,6 +2932,13 @@ const projectBotDomain = defineDomain({
|
|
|
2448
2932
|
function bindingKey(botId, chatId) {
|
|
2449
2933
|
return `${botId}:${chatId}`;
|
|
2450
2934
|
}
|
|
2935
|
+
//#endregion
|
|
2936
|
+
//#region src/channels/outbound.ts
|
|
2937
|
+
/** 从 assistant 消息内容块取最后一个 text 块的纯文本;无 text 块返回 ''(非 undefined)。 */
|
|
2938
|
+
function lastTextOf(content) {
|
|
2939
|
+
const texts = content.filter((b) => b.type === "text" && typeof b.text === "string").map((b) => b.text);
|
|
2940
|
+
return texts.length === 0 ? "" : texts[texts.length - 1];
|
|
2941
|
+
}
|
|
2451
2942
|
/** 向段序列追加内容:与尾段同类则合并,异类开新段。 */
|
|
2452
2943
|
function appendToSegments(segments, kind, text) {
|
|
2453
2944
|
const tail = segments[segments.length - 1];
|
|
@@ -2457,6 +2948,30 @@ function appendToSegments(segments, kind, text) {
|
|
|
2457
2948
|
content: text
|
|
2458
2949
|
});
|
|
2459
2950
|
}
|
|
2951
|
+
/** 向段序列应用一个流式 chunk:text-delta → text、reasoning-delta → process、reasoning block-end → 过程段段落分隔;其余类型不消费返回 false。 */
|
|
2952
|
+
function applyStreamChunk(segments, chunk) {
|
|
2953
|
+
if (chunk.type === "text-delta") appendToSegments(segments, "text", chunk.text);
|
|
2954
|
+
else if (chunk.type === "reasoning-delta") appendToSegments(segments, "process", chunk.text);
|
|
2955
|
+
else if (chunk.type === "block-end" && chunk.block?.type === "reasoning" && segments[segments.length - 1]?.kind === "process") appendToSegments(segments, "process", "\n\n");
|
|
2956
|
+
else return false;
|
|
2957
|
+
return true;
|
|
2958
|
+
}
|
|
2959
|
+
/**
|
|
2960
|
+
* 对账尾 text 段:把最后一个 text 段的内容替换为权威全文(丢帧补齐)。
|
|
2961
|
+
* 前置条件:调用方已保证尾 text 段归属本结算 step(lastTextStep 命中结算 step)。
|
|
2962
|
+
* 返回是否发生替换;authoritative 为空、无 text 段或内容已相等时返回 false。
|
|
2963
|
+
*/
|
|
2964
|
+
function reconcileTrailingText(segments, authoritative) {
|
|
2965
|
+
if (authoritative === "") return false;
|
|
2966
|
+
let tail;
|
|
2967
|
+
for (let i = segments.length - 1; i >= 0; i--) if (segments[i].kind === "text") {
|
|
2968
|
+
tail = segments[i];
|
|
2969
|
+
break;
|
|
2970
|
+
}
|
|
2971
|
+
if (tail === void 0 || tail.content === authoritative) return false;
|
|
2972
|
+
tail.content = authoritative;
|
|
2973
|
+
return true;
|
|
2974
|
+
}
|
|
2460
2975
|
function mapTurnEnd(reason) {
|
|
2461
2976
|
if (reason.kind === "completed") return "done";
|
|
2462
2977
|
if (reason.kind === "aborted" || reason.kind === "interrupted") return "cancelled";
|
|
@@ -2492,14 +3007,12 @@ var Outbound = class {
|
|
|
2492
3007
|
};
|
|
2493
3008
|
return;
|
|
2494
3009
|
}
|
|
2495
|
-
if (event.type === "assistant/
|
|
3010
|
+
if (event.type === "assistant/message") {
|
|
2496
3011
|
const turn = rt.turn;
|
|
2497
3012
|
if (turn === void 0 || turn.n !== event.data.turn) return;
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
else if (chunk.type === "block-end" && chunk.block?.type === "reasoning" && turn.segments[turn.segments.length - 1]?.kind === "process") appendToSegments(turn.segments, "process", "\n\n");
|
|
2502
|
-
else return;
|
|
3013
|
+
if (turn.lastTextStep !== event.data.step) return;
|
|
3014
|
+
const authoritative = lastTextOf(event.data.message.content ?? []);
|
|
3015
|
+
if (!reconcileTrailingText(turn.segments, authoritative)) return;
|
|
2503
3016
|
const snapshot = turn.segments.map((s) => ({ ...s }));
|
|
2504
3017
|
this.enqueue(rt, async () => {
|
|
2505
3018
|
if (rt.reply === void 0) return;
|
|
@@ -2544,6 +3057,36 @@ var Outbound = class {
|
|
|
2544
3057
|
}
|
|
2545
3058
|
}
|
|
2546
3059
|
/**
|
|
3060
|
+
* 出站:瞬态流式帧 → 段构建 + 打字机。chunk 帧不携带 turn/step,由 start 帧建 attempt 基线
|
|
3061
|
+
* (turn/step,宿主 accumulator 同款取数);end 帧不消费;生命周期仍由 turn/start、turn/end 驱动。
|
|
3062
|
+
* 帧为 fire-and-forget,丢帧后无重传,正文由 assistant/message 结算对账有界恢复。
|
|
3063
|
+
*/
|
|
3064
|
+
handleAssistantFrame(sessionId, frame) {
|
|
3065
|
+
const rt = this.sessions.get(sessionId);
|
|
3066
|
+
if (rt === void 0) return;
|
|
3067
|
+
const turn = rt.turn;
|
|
3068
|
+
if (turn === void 0) return;
|
|
3069
|
+
if (frame.type === "start") {
|
|
3070
|
+
if (turn.n !== frame.turn) return;
|
|
3071
|
+
turn.attemptStep = frame.step;
|
|
3072
|
+
return;
|
|
3073
|
+
}
|
|
3074
|
+
if (frame.type !== "chunk") return;
|
|
3075
|
+
const step = turn.attemptStep;
|
|
3076
|
+
if (step === void 0) return;
|
|
3077
|
+
if (!applyStreamChunk(turn.segments, frame.chunk)) return;
|
|
3078
|
+
if (frame.chunk.type === "text-delta") turn.lastTextStep = step;
|
|
3079
|
+
const snapshot = turn.segments.map((s) => ({ ...s }));
|
|
3080
|
+
this.enqueue(rt, async () => {
|
|
3081
|
+
if (rt.reply === void 0) return;
|
|
3082
|
+
if (!turn.began) {
|
|
3083
|
+
await rt.reply.beginTurn();
|
|
3084
|
+
turn.began = true;
|
|
3085
|
+
}
|
|
3086
|
+
await rt.reply.update(snapshot);
|
|
3087
|
+
});
|
|
3088
|
+
}
|
|
3089
|
+
/**
|
|
2547
3090
|
* turn 外错误(agent/error:resume/驱动边界失败等没有 turn/end 的场景):
|
|
2548
3091
|
* notice 错误摘要并释放 inflight 槽 + 删除表情;turn 进行中的错误由 turn/end 报告,跳过防双发。
|
|
2549
3092
|
*/
|
|
@@ -2607,6 +3150,7 @@ var Inbound = class {
|
|
|
2607
3150
|
return;
|
|
2608
3151
|
}
|
|
2609
3152
|
rt.inflight = { ack: void 0 };
|
|
3153
|
+
rt.reply = msg.reply;
|
|
2610
3154
|
rt.inflight.ack = await msg.ackProcessing().catch(() => void 0) ?? void 0;
|
|
2611
3155
|
const imageRefs = [];
|
|
2612
3156
|
if (msg.loadImages !== void 0) {
|
|
@@ -2647,6 +3191,26 @@ function hooksOf(bot) {
|
|
|
2647
3191
|
};
|
|
2648
3192
|
}
|
|
2649
3193
|
//#endregion
|
|
3194
|
+
//#region src/channels/role-assembly.ts
|
|
3195
|
+
/** 角色 persona 的 scoped 段名(与 Router 既有产出一致,逐字段不可改)。 */
|
|
3196
|
+
const ROLE_PERSONA_SECTION = "dsh-agent-toolkit:agent:persona";
|
|
3197
|
+
/** 角色形态创作期注入:persona 非空 → 单 section(order 0);tools 白名单存在 → 带上(由 setupAgentScope restrict)。 */
|
|
3198
|
+
function roleHooks(role) {
|
|
3199
|
+
const sections = role.persona === void 0 || role.persona.trim().length === 0 ? [] : [{
|
|
3200
|
+
name: ROLE_PERSONA_SECTION,
|
|
3201
|
+
order: 0,
|
|
3202
|
+
text: role.persona
|
|
3203
|
+
}];
|
|
3204
|
+
return {
|
|
3205
|
+
...sections.length > 0 ? { sections } : {},
|
|
3206
|
+
...role.tools !== void 0 ? { tools: role.tools.allow } : {}
|
|
3207
|
+
};
|
|
3208
|
+
}
|
|
3209
|
+
/** 角色模型:自配优先,缺省回退宿主默认模型(与 Router 语义一致)。 */
|
|
3210
|
+
function roleAgentOptions(role, defaultModel) {
|
|
3211
|
+
return role.model ?? defaultModel();
|
|
3212
|
+
}
|
|
3213
|
+
//#endregion
|
|
2650
3214
|
//#region src/channels/router.ts
|
|
2651
3215
|
/** 绑定路由:(botId, chatId) → 长期会话;create / resume / reset。 */
|
|
2652
3216
|
/** 发起人提示段名:bot 会话声明来源渠道与发起人 open_id。 */
|
|
@@ -2674,21 +3238,22 @@ var Router = class {
|
|
|
2674
3238
|
this.registry = registry;
|
|
2675
3239
|
this.injectSender = injectSender;
|
|
2676
3240
|
}
|
|
2677
|
-
/**
|
|
3241
|
+
/**
|
|
3242
|
+
* 取(或建/恢复)该 chat 的会话 runtime。存量活跃会话保持其 reply——运行中 turn 的出站
|
|
3243
|
+
* 必须留在原句柄收尾,reply 刷新由 Inbound 在 in-flight 准入通过后执行;
|
|
3244
|
+
* retiring(已 cancel、收尾中)的会话不可复用,重绑窗口内恢复时 resume + adopt 重建。
|
|
3245
|
+
*/
|
|
2678
3246
|
async ensure(bot, chatId, reply, userId) {
|
|
2679
3247
|
const bound = this.bindings.get(bot.id, chatId);
|
|
2680
3248
|
if (bound !== void 0) {
|
|
2681
3249
|
const existing = this.sessions.get(bound);
|
|
2682
|
-
if (existing !== void 0)
|
|
2683
|
-
existing.reply = reply;
|
|
2684
|
-
return existing;
|
|
2685
|
-
}
|
|
3250
|
+
if (existing !== void 0 && !existing.retiring) return existing;
|
|
2686
3251
|
const agent = await this.agents.resume({
|
|
2687
3252
|
sessionId: bound,
|
|
2688
3253
|
...this.resolveSession(bot, userId)
|
|
2689
3254
|
});
|
|
2690
3255
|
await this.attach(bot.project, bound);
|
|
2691
|
-
return this.adopt(bot.id, chatId, bound, agent, reply);
|
|
3256
|
+
return this.adopt(bot.id, chatId, userId, bound, agent, reply);
|
|
2692
3257
|
}
|
|
2693
3258
|
const sessionId = randomUUID();
|
|
2694
3259
|
const agent = await this.agents.create({
|
|
@@ -2698,7 +3263,7 @@ var Router = class {
|
|
|
2698
3263
|
});
|
|
2699
3264
|
await this.bindings.set(bot.id, chatId, sessionId);
|
|
2700
3265
|
await this.attach(bot.project, sessionId);
|
|
2701
|
-
return this.adopt(bot.id, chatId, sessionId, agent, reply);
|
|
3266
|
+
return this.adopt(bot.id, chatId, userId, sessionId, agent, reply);
|
|
2702
3267
|
}
|
|
2703
3268
|
/** attach 失败仅告警(会话降级为未分组),不阻塞消息处理。 */
|
|
2704
3269
|
async attach(cwd, sessionId) {
|
|
@@ -2737,49 +3302,130 @@ var Router = class {
|
|
|
2737
3302
|
hooks: this.withSenderSection(hooksOf(bot), bot, userId)
|
|
2738
3303
|
};
|
|
2739
3304
|
}
|
|
2740
|
-
const sections = role.persona === void 0 || role.persona.trim().length === 0 ? [] : [{
|
|
2741
|
-
name: "dsh-agent-toolkit:agent:persona",
|
|
2742
|
-
order: 0,
|
|
2743
|
-
text: role.persona
|
|
2744
|
-
}];
|
|
2745
3305
|
return {
|
|
2746
|
-
agentOptions: role
|
|
2747
|
-
hooks: this.withSenderSection(
|
|
2748
|
-
...sections.length > 0 ? { sections } : {},
|
|
2749
|
-
...role.tools !== void 0 ? { tools: role.tools.allow } : {}
|
|
2750
|
-
}, bot, userId)
|
|
3306
|
+
agentOptions: roleAgentOptions(role, this.defaultModel),
|
|
3307
|
+
hooks: this.withSenderSection(roleHooks(role), bot, userId)
|
|
2751
3308
|
};
|
|
2752
3309
|
}
|
|
2753
|
-
/** /new
|
|
3310
|
+
/** /new:取消旧会话;等 turn/end 落定(旧卡在旧句柄 finalize)后再摘出 sessions。 */
|
|
2754
3311
|
async reset(bot, chatId, reply, userId) {
|
|
2755
3312
|
const bound = this.bindings.get(bot.id, chatId);
|
|
2756
3313
|
if (bound !== void 0) {
|
|
2757
|
-
this.sessions.get(bound)
|
|
2758
|
-
this.
|
|
3314
|
+
const old = this.sessions.get(bound);
|
|
3315
|
+
if (old !== void 0) this.retire(bound, old);
|
|
2759
3316
|
await this.bindings.delete(bot.id, chatId);
|
|
2760
3317
|
}
|
|
2761
3318
|
return this.ensure(bot, chatId, reply, userId);
|
|
2762
3319
|
}
|
|
3320
|
+
/** 取消会话并等出站链落定后摘出 sessions(让在飞 turn 的 turn/end 正常 finalize 旧卡)。 */
|
|
3321
|
+
retire(sessionId, rt) {
|
|
3322
|
+
rt.retiring = true;
|
|
3323
|
+
rt.agent.cancel();
|
|
3324
|
+
(async () => {
|
|
3325
|
+
await rt.agent.whenIdle().catch(() => void 0);
|
|
3326
|
+
await rt.tail.catch(() => void 0);
|
|
3327
|
+
if (this.sessions.get(sessionId) === rt) this.sessions.delete(sessionId);
|
|
3328
|
+
})();
|
|
3329
|
+
}
|
|
2763
3330
|
lookup(botId, chatId) {
|
|
2764
3331
|
const bound = this.bindings.get(botId, chatId);
|
|
2765
3332
|
return bound === void 0 ? void 0 : this.sessions.get(bound);
|
|
2766
3333
|
}
|
|
2767
|
-
adopt(botId, chatId, sessionId, agent, reply) {
|
|
3334
|
+
adopt(botId, chatId, userId, sessionId, agent, reply) {
|
|
2768
3335
|
const rt = {
|
|
2769
3336
|
botId,
|
|
2770
3337
|
chatId,
|
|
2771
3338
|
sessionId,
|
|
3339
|
+
initiatorOpenId: userId,
|
|
2772
3340
|
agent,
|
|
2773
3341
|
reply,
|
|
2774
3342
|
inflight: void 0,
|
|
2775
3343
|
tail: Promise.resolve(),
|
|
2776
|
-
turn: void 0
|
|
3344
|
+
turn: void 0,
|
|
3345
|
+
retiring: false
|
|
2777
3346
|
};
|
|
2778
3347
|
this.sessions.set(sessionId, rt);
|
|
2779
3348
|
return rt;
|
|
2780
3349
|
}
|
|
2781
3350
|
};
|
|
2782
3351
|
//#endregion
|
|
3352
|
+
//#region src/channels/approval/center.ts
|
|
3353
|
+
/** 审批中心(渠道无关):自有 bot 会话的 approval ask → 渠道审批卡片挂起 → 卡片回调 resolve。
|
|
3354
|
+
* spec: docs/superpowers/specs/archive/2026-09-08-feishu-approval-card-design.md(飞书独占 / 仅发起人 / 不超时)。 */
|
|
3355
|
+
var ApprovalCenter = class {
|
|
3356
|
+
sessions;
|
|
3357
|
+
channelFor;
|
|
3358
|
+
warn;
|
|
3359
|
+
newId;
|
|
3360
|
+
pending = /* @__PURE__ */ new Map();
|
|
3361
|
+
constructor(sessions, channelFor, warn, newId = randomUUID) {
|
|
3362
|
+
this.sessions = sessions;
|
|
3363
|
+
this.channelFor = channelFor;
|
|
3364
|
+
this.warn = warn;
|
|
3365
|
+
this.newId = newId;
|
|
3366
|
+
}
|
|
3367
|
+
async handleRequest(req) {
|
|
3368
|
+
const sessionId = String(req.agent.session.id);
|
|
3369
|
+
const rt = this.sessions.get(sessionId);
|
|
3370
|
+
if (rt === void 0) return void 0;
|
|
3371
|
+
if (req.signal?.aborted === true) return "cancelled";
|
|
3372
|
+
const channel = this.channelFor(rt.botId);
|
|
3373
|
+
if (channel === void 0) {
|
|
3374
|
+
this.warn(`[project-bot] bot "${rt.botId}" 的渠道无审批能力,回退其他审批通道`);
|
|
3375
|
+
return;
|
|
3376
|
+
}
|
|
3377
|
+
const key = this.newId();
|
|
3378
|
+
let presentation;
|
|
3379
|
+
try {
|
|
3380
|
+
presentation = await channel.presenter.present({
|
|
3381
|
+
key,
|
|
3382
|
+
chatId: rt.chatId,
|
|
3383
|
+
botName: channel.botName,
|
|
3384
|
+
toolName: req.toolName,
|
|
3385
|
+
...req.reason !== void 0 ? { reason: req.reason } : {}
|
|
3386
|
+
});
|
|
3387
|
+
} catch (error) {
|
|
3388
|
+
this.warn(`[project-bot] 审批卡片发送失败,回退其他审批通道:${error instanceof Error ? error.message : String(error)}`);
|
|
3389
|
+
return;
|
|
3390
|
+
}
|
|
3391
|
+
return new Promise((resolve) => {
|
|
3392
|
+
const entry = {
|
|
3393
|
+
sessionId,
|
|
3394
|
+
resolve,
|
|
3395
|
+
presentation,
|
|
3396
|
+
signal: req.signal,
|
|
3397
|
+
onAbort: () => this.settle(key, "cancelled")
|
|
3398
|
+
};
|
|
3399
|
+
this.pending.set(key, entry);
|
|
3400
|
+
req.signal?.addEventListener("abort", entry.onAbort, { once: true });
|
|
3401
|
+
});
|
|
3402
|
+
}
|
|
3403
|
+
handleCardAction(action) {
|
|
3404
|
+
const value = action.value;
|
|
3405
|
+
if (value === null || typeof value !== "object" || typeof value.key !== "string" || value.decision !== "allow" && value.decision !== "reject") return void 0;
|
|
3406
|
+
const entry = this.pending.get(value.key);
|
|
3407
|
+
if (entry === void 0) return { toast: "该申请已处理或已失效" };
|
|
3408
|
+
const rt = this.sessions.get(entry.sessionId);
|
|
3409
|
+
if (rt === void 0 || rt.initiatorOpenId !== action.operatorOpenId) return { toast: "仅会话发起人可审批" };
|
|
3410
|
+
this.settle(value.key, value.decision === "allow" ? "allowed-once" : "rejected", action.operatorName);
|
|
3411
|
+
return { toast: value.decision === "allow" ? "已允许" : "已拒绝" };
|
|
3412
|
+
}
|
|
3413
|
+
dispose() {
|
|
3414
|
+
for (const key of [...this.pending.keys()]) this.settle(key, "cancelled");
|
|
3415
|
+
}
|
|
3416
|
+
settle(key, outcome, operatorName) {
|
|
3417
|
+
const entry = this.pending.get(key);
|
|
3418
|
+
if (entry === void 0) return;
|
|
3419
|
+
this.pending.delete(key);
|
|
3420
|
+
entry.signal?.removeEventListener("abort", entry.onAbort);
|
|
3421
|
+
entry.resolve(outcome);
|
|
3422
|
+
const status = outcome === "allowed-once" ? "allowed" : outcome === "rejected" ? "rejected" : "cancelled";
|
|
3423
|
+
entry.presentation.finalize(status, operatorName).catch((error) => {
|
|
3424
|
+
this.warn(`[project-bot] 审批卡片定格失败:${error instanceof Error ? error.message : String(error)}`);
|
|
3425
|
+
});
|
|
3426
|
+
}
|
|
3427
|
+
};
|
|
3428
|
+
//#endregion
|
|
2783
3429
|
//#region src/channels/runtime.ts
|
|
2784
3430
|
var BotRuntime = class {
|
|
2785
3431
|
deps;
|
|
@@ -2787,6 +3433,7 @@ var BotRuntime = class {
|
|
|
2787
3433
|
router;
|
|
2788
3434
|
inbound;
|
|
2789
3435
|
outbound;
|
|
3436
|
+
approval;
|
|
2790
3437
|
handles = /* @__PURE__ */ new Map();
|
|
2791
3438
|
constructor(deps) {
|
|
2792
3439
|
this.deps = deps;
|
|
@@ -2800,6 +3447,14 @@ var BotRuntime = class {
|
|
|
2800
3447
|
onError: (m) => deps.log.warn(m)
|
|
2801
3448
|
});
|
|
2802
3449
|
this.outbound = new Outbound(this.sessions, (m) => deps.log.warn(m), deps.maxErrorDetailChars);
|
|
3450
|
+
this.approval = new ApprovalCenter(this.sessions, (botId) => {
|
|
3451
|
+
const presenter = this.handles.get(botId)?.approval;
|
|
3452
|
+
if (presenter === void 0) return void 0;
|
|
3453
|
+
return {
|
|
3454
|
+
presenter,
|
|
3455
|
+
botName: this.deps.bots.get(botId)?.name ?? botId
|
|
3456
|
+
};
|
|
3457
|
+
}, (m) => deps.log.warn(m));
|
|
2803
3458
|
}
|
|
2804
3459
|
async startAll() {
|
|
2805
3460
|
for (const botId of [...this.deps.bots.keys()]) await this.reconcile(botId);
|
|
@@ -2828,7 +3483,10 @@ var BotRuntime = class {
|
|
|
2828
3483
|
const handle = await channel.start({
|
|
2829
3484
|
record,
|
|
2830
3485
|
secret
|
|
2831
|
-
}, {
|
|
3486
|
+
}, {
|
|
3487
|
+
onMessage: (msg) => this.inbound.onMessage(msg),
|
|
3488
|
+
onCardAction: (action) => this.approval.handleCardAction(action)
|
|
3489
|
+
}, this.deps.tunables, (m) => this.deps.log.warn(m));
|
|
2832
3490
|
this.handles.set(botId, handle);
|
|
2833
3491
|
} catch (error) {
|
|
2834
3492
|
this.deps.log.warn(`[project-bot] bot "${botId}" 渠道启动失败:${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -2837,19 +3495,23 @@ var BotRuntime = class {
|
|
|
2837
3495
|
/** 删除 bot:停渠道、取消会话、清绑定。 */
|
|
2838
3496
|
async stopBot(botId) {
|
|
2839
3497
|
await this.stopChannel(botId);
|
|
2840
|
-
for (const [sessionId, rt] of [...this.sessions]) if (rt.botId === botId)
|
|
2841
|
-
rt.agent.cancel();
|
|
2842
|
-
this.sessions.delete(sessionId);
|
|
2843
|
-
}
|
|
3498
|
+
for (const [sessionId, rt] of [...this.sessions]) if (rt.botId === botId) this.retire(sessionId, rt);
|
|
2844
3499
|
await this.bindingStore().deleteBot(botId);
|
|
2845
3500
|
}
|
|
2846
3501
|
/** 解绑渠道:停渠道、取消在飞会话;绑定表与持久会话保留(重绑后 resume 接续)。 */
|
|
2847
3502
|
async unbindBot(botId) {
|
|
2848
3503
|
await this.stopChannel(botId);
|
|
2849
|
-
for (const [sessionId, rt] of [...this.sessions]) if (rt.botId === botId)
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
3504
|
+
for (const [sessionId, rt] of [...this.sessions]) if (rt.botId === botId) this.retire(sessionId, rt);
|
|
3505
|
+
}
|
|
3506
|
+
/** 取消会话并等出站链落定后摘出 sessions(让在飞 turn 的 turn/end 正常 finalize 旧卡)。 */
|
|
3507
|
+
retire(sessionId, rt) {
|
|
3508
|
+
rt.retiring = true;
|
|
3509
|
+
rt.agent.cancel();
|
|
3510
|
+
(async () => {
|
|
3511
|
+
await rt.agent.whenIdle().catch(() => void 0);
|
|
3512
|
+
await rt.tail.catch(() => void 0);
|
|
3513
|
+
if (this.sessions.get(sessionId) === rt) this.sessions.delete(sessionId);
|
|
3514
|
+
})();
|
|
2853
3515
|
}
|
|
2854
3516
|
statusOf(botId) {
|
|
2855
3517
|
const record = this.deps.bots.get(botId);
|
|
@@ -2865,6 +3527,7 @@ var BotRuntime = class {
|
|
|
2865
3527
|
}));
|
|
2866
3528
|
await Promise.allSettled([...this.handles.values()].map((h) => h.close()));
|
|
2867
3529
|
this.handles.clear();
|
|
3530
|
+
this.approval.dispose();
|
|
2868
3531
|
}
|
|
2869
3532
|
async stopChannel(botId) {
|
|
2870
3533
|
const handle = this.handles.get(botId);
|
|
@@ -2891,6 +3554,20 @@ var BotRuntime = class {
|
|
|
2891
3554
|
}
|
|
2892
3555
|
};
|
|
2893
3556
|
//#endregion
|
|
3557
|
+
//#region src/channels/scope-joiner.ts
|
|
3558
|
+
function createScopeJoiner(ctx, presetId, fallback, warn) {
|
|
3559
|
+
return { async join(agentCtx) {
|
|
3560
|
+
const agentPresets = ctx.get("agentPresets", false);
|
|
3561
|
+
if (agentPresets !== void 0) try {
|
|
3562
|
+
await agentPresets.mount(agentCtx, presetId);
|
|
3563
|
+
return;
|
|
3564
|
+
} catch (error) {
|
|
3565
|
+
warn(`dsh-agent-toolkit: bot 会话挂载 preset "${presetId}" 失败,回退基础工具 standing scope(此后该会话委派子会话将看不到基础工具):${error instanceof Error ? error.message : String(error)}`);
|
|
3566
|
+
}
|
|
3567
|
+
await fallback.join(agentCtx);
|
|
3568
|
+
} };
|
|
3569
|
+
}
|
|
3570
|
+
//#endregion
|
|
2894
3571
|
//#region src/channels/tool-scope.ts
|
|
2895
3572
|
/** 默认加载器:复刻 preset mount 的模块解析——loader 的 unwrapExports 取 `default ?? 命名导出模块`。 */
|
|
2896
3573
|
async function loadToolModule(specifier) {
|
|
@@ -2940,10 +3617,17 @@ function createToolsScope(ctx, loadTool = loadToolModule) {
|
|
|
2940
3617
|
};
|
|
2941
3618
|
}
|
|
2942
3619
|
//#endregion
|
|
3620
|
+
//#region src/channels/approval/answerer.ts
|
|
3621
|
+
function createApprovalAnswerer(centerOf) {
|
|
3622
|
+
return async (req, next) => {
|
|
3623
|
+
return await centerOf()?.handleRequest(req) ?? next();
|
|
3624
|
+
};
|
|
3625
|
+
}
|
|
3626
|
+
//#endregion
|
|
2943
3627
|
//#region src/bots/api.ts
|
|
2944
3628
|
/** 浏览器半 RPC:单前缀路由 /dsh-agent-toolkit/api/bots + 内部路径分发。 */
|
|
2945
3629
|
const MAX_BODY_BYTES = 65536;
|
|
2946
|
-
const CreateBodySchema = z$1.object({
|
|
3630
|
+
const CreateBodySchema$1 = z$1.object({
|
|
2947
3631
|
/** 缺省时后端自动生成(bot-<8 位随机小写字母数字>)。 */
|
|
2948
3632
|
id: z$1.string().regex(BOT_ID_RE).optional(),
|
|
2949
3633
|
name: z$1.string().min(1).max(64),
|
|
@@ -2964,7 +3648,7 @@ const CreateBodySchema = z$1.object({
|
|
|
2964
3648
|
appSecretRef: z$1.string().regex(CREDENTIAL_REF_RE).optional()
|
|
2965
3649
|
})
|
|
2966
3650
|
});
|
|
2967
|
-
const UpdateBodySchema = z$1.object({
|
|
3651
|
+
const UpdateBodySchema$1 = z$1.object({
|
|
2968
3652
|
name: z$1.string().min(1).max(64).optional(),
|
|
2969
3653
|
project: z$1.string().min(1).optional(),
|
|
2970
3654
|
persona: z$1.string().max(8e3).nullable().optional(),
|
|
@@ -3030,7 +3714,7 @@ function createApiHandler(deps) {
|
|
|
3030
3714
|
if (sub === "/bots" && method === "POST") {
|
|
3031
3715
|
const body = await readJsonBody(req, res);
|
|
3032
3716
|
if (body === void 0) return;
|
|
3033
|
-
const parsed = CreateBodySchema.safeParse(body);
|
|
3717
|
+
const parsed = CreateBodySchema$1.safeParse(body);
|
|
3034
3718
|
if (!parsed.success) {
|
|
3035
3719
|
json(res, 400, { error: parsed.error.issues[0]?.message ?? "invalid body" });
|
|
3036
3720
|
return;
|
|
@@ -3090,7 +3774,7 @@ function createApiHandler(deps) {
|
|
|
3090
3774
|
}
|
|
3091
3775
|
const body = await readJsonBody(req, res);
|
|
3092
3776
|
if (body === void 0) return;
|
|
3093
|
-
const parsed = UpdateBodySchema.safeParse(body);
|
|
3777
|
+
const parsed = UpdateBodySchema$1.safeParse(body);
|
|
3094
3778
|
if (!parsed.success) {
|
|
3095
3779
|
json(res, 400, { error: parsed.error.issues[0]?.message ?? "invalid body" });
|
|
3096
3780
|
return;
|
|
@@ -3209,7 +3893,7 @@ function createApiHandler(deps) {
|
|
|
3209
3893
|
//#endregion
|
|
3210
3894
|
//#region src/bots/register-app.ts
|
|
3211
3895
|
/** 扫码一键创建飞书应用:lark.registerApp(OAuth 2.0 Device Authorization Grant)的状态机封装。 */
|
|
3212
|
-
/**
|
|
3896
|
+
/** 扫码创建应用时申请的权限/事件/回调(流式卡片 + 收发消息 + 表情 + 通讯录基础信息 + 审批卡片回传)。
|
|
3213
3897
|
* 故意不加 as const:readonly 元组不可赋值给 SDK AppAddons 的 mutable string[],
|
|
3214
3898
|
* 否则 bots/index.ts 的 lark.registerApp(options) 透传会 typecheck 失败。 */
|
|
3215
3899
|
const FEISHU_REGISTER_APP_ADDONS = {
|
|
@@ -3219,7 +3903,8 @@ const FEISHU_REGISTER_APP_ADDONS = {
|
|
|
3219
3903
|
"cardkit:card:write",
|
|
3220
3904
|
"contact:user.base:readonly"
|
|
3221
3905
|
] },
|
|
3222
|
-
events: { items: { tenant: ["im.message.receive_v1"] } }
|
|
3906
|
+
events: { items: { tenant: ["im.message.receive_v1"] } },
|
|
3907
|
+
callbacks: { items: ["card.action.trigger"] }
|
|
3223
3908
|
};
|
|
3224
3909
|
var RegisterAppService = class {
|
|
3225
3910
|
deps;
|
|
@@ -3293,6 +3978,7 @@ function setupBots(ctx, config, deps) {
|
|
|
3293
3978
|
cardUpdateThrottleMs: config.cardUpdateThrottleMs,
|
|
3294
3979
|
cardMaxBytes: config.cardMaxBytes,
|
|
3295
3980
|
processMaxBytes: config.processMaxBytes,
|
|
3981
|
+
cardPrintStep: config.cardPrintStep,
|
|
3296
3982
|
processingReactionEmoji: config.processingReactionEmoji
|
|
3297
3983
|
};
|
|
3298
3984
|
const storeSecret = async (key, secret) => {
|
|
@@ -3300,34 +3986,10 @@ function setupBots(ctx, config, deps) {
|
|
|
3300
3986
|
await ctx.credentials.set(credentialRef(ref), secret);
|
|
3301
3987
|
return ref;
|
|
3302
3988
|
};
|
|
3303
|
-
/** 创作期注入已迁至 agent-setup.ts + tool-scope.ts(基础工具行 standing scope 挂载 + persona/tools
|
|
3989
|
+
/** 创作期注入已迁至 agent-setup.ts + tool-scope.ts(基础工具行 standing scope 挂载 + persona/tools)。
|
|
3990
|
+
* preset 优先 joiner(agent-bot 组合):mount 成功后委派子会话 composeFrom 认父(spec: docs/superpowers/specs/archive/2026-09-07-bot-delegation-preset-mount-design.md)。 */
|
|
3304
3991
|
const toolsScope = createToolsScope(ctx);
|
|
3305
|
-
const agentsPort =
|
|
3306
|
-
async create(input) {
|
|
3307
|
-
return adaptAgent(await ctx.agents.create({
|
|
3308
|
-
sessionId: SessionId(input.sessionId),
|
|
3309
|
-
meta: { cwd: input.cwd },
|
|
3310
|
-
...input.agentOptions !== void 0 ? { agentOptions: input.agentOptions } : {},
|
|
3311
|
-
setup: (agentCtx) => setupAgentScope(agentCtx, input.hooks, toolsScope)
|
|
3312
|
-
}));
|
|
3313
|
-
},
|
|
3314
|
-
async resume(input) {
|
|
3315
|
-
return adaptAgent(await ctx.agents.resume({
|
|
3316
|
-
resumeSessionId: SessionId(input.sessionId),
|
|
3317
|
-
...input.agentOptions !== void 0 ? { agentOptions: input.agentOptions } : {},
|
|
3318
|
-
setup: (agentCtx) => setupAgentScope(agentCtx, input.hooks, toolsScope)
|
|
3319
|
-
}));
|
|
3320
|
-
}
|
|
3321
|
-
};
|
|
3322
|
-
function adaptAgent(handle) {
|
|
3323
|
-
const { agent } = handle;
|
|
3324
|
-
return {
|
|
3325
|
-
sessionId: String(agent.id),
|
|
3326
|
-
followup: (message) => agent.followup(message),
|
|
3327
|
-
cancel: () => agent.cancel({ kind: "user" }),
|
|
3328
|
-
whenIdle: () => agent.whenIdle()
|
|
3329
|
-
};
|
|
3330
|
-
}
|
|
3992
|
+
const agentsPort = createAgentsPort(ctx, deps.botPresetId !== void 0 ? createScopeJoiner(ctx, deps.botPresetId, toolsScope, log.warn) : toolsScope, deps.ownedSessions);
|
|
3331
3993
|
const workspaceRegistry = ctx.get("workspaceRegistry", false);
|
|
3332
3994
|
const workspacePort = { async attach(cwd, sessionId) {
|
|
3333
3995
|
if (workspaceRegistry === void 0) throw new Error("workspaceRegistry 服务不可用");
|
|
@@ -3393,6 +4055,10 @@ function setupBots(ctx, config, deps) {
|
|
|
3393
4055
|
ctx.on("session/event", (session, event) => {
|
|
3394
4056
|
runtime?.outbound.handleSessionEvent(String(session.header.id), event);
|
|
3395
4057
|
});
|
|
4058
|
+
ctx.on("agent/assistant-stream", ({ agent, frame }) => {
|
|
4059
|
+
runtime?.outbound.handleAssistantFrame(String(agent.session.id), frame);
|
|
4060
|
+
});
|
|
4061
|
+
if (config.approval) ctx.on("approval/request", createApprovalAnswerer(() => runtime?.approval), { prepend: true });
|
|
3396
4062
|
ctx.on("agent/error", ({ agent, error }) => {
|
|
3397
4063
|
const text = error instanceof Error ? error.message : String(error?.message ?? error);
|
|
3398
4064
|
runtime?.outbound.handleAgentError(String(agent.session.id), text);
|
|
@@ -3441,11 +4107,42 @@ function setupBots(ctx, config, deps) {
|
|
|
3441
4107
|
});
|
|
3442
4108
|
}
|
|
3443
4109
|
//#endregion
|
|
4110
|
+
//#region src/agents/bot-preset.ts
|
|
4111
|
+
/** agent-bot preset 组合序列化:BASIC_TOOLS → preset 行(bot 会话挂载的最小组合,
|
|
4112
|
+
* 委派子会话 composeFrom 认父的前提;spec: docs/superpowers/specs/archive/2026-09-07-bot-delegation-preset-mount-design.md)。 */
|
|
4113
|
+
/** BASIC_TOOLS 插件包名 → preset 行 id(与 standard/agent-team 的行 id 对齐)。新增 BASIC_TOOLS 行必须在此登记。 */
|
|
4114
|
+
const ROW_IDS = {
|
|
4115
|
+
"@deepseek-ai/dsh-persona": "persona",
|
|
4116
|
+
"@deepseek-ai/dsh-agent-instructions": "agent-instructions",
|
|
4117
|
+
"@deepseek-ai/dsh-tool-pwsh": "tool-pwsh",
|
|
4118
|
+
"@deepseek-ai/dsh-tool-bash": "tool-bash",
|
|
4119
|
+
"@deepseek-ai/dsh-tool-fs": "tool-fs",
|
|
4120
|
+
"@deepseek-ai/dsh-tool-fs-search": "tool-fs-search"
|
|
4121
|
+
};
|
|
4122
|
+
const BOT_PRESET_NAME = "Bot 会话";
|
|
4123
|
+
const BOT_PRESET_DESCRIPTION = "飞书 bot 会话组合:基础工具行(persona/instructions/shell/fs/fs-search);委派子会话经 composeFrom 继承同一组合";
|
|
4124
|
+
/** BASIC_TOOLS → preset 行对象数组(未登记映射的包名抛错,防新增工具行静默丢 id)。 */
|
|
4125
|
+
function serializeBotRows(tools) {
|
|
4126
|
+
return tools.map((tool) => {
|
|
4127
|
+
const rowId = ROW_IDS[tool.id];
|
|
4128
|
+
if (rowId === void 0) throw new Error(`bot-preset: BASIC_TOOLS 行 ${tool.id} 未登记 preset 行 id 映射`);
|
|
4129
|
+
return {
|
|
4130
|
+
id: rowId,
|
|
4131
|
+
name: tool.id,
|
|
4132
|
+
...tool.config !== void 0 ? { config: tool.config } : {}
|
|
4133
|
+
};
|
|
4134
|
+
});
|
|
4135
|
+
}
|
|
4136
|
+
/** agent-bot composition 文本(平台相关 shell 行已由 BASIC_TOOLS 选定,不用 !!js)。 */
|
|
4137
|
+
function botPresetComposition() {
|
|
4138
|
+
return yaml.dump(serializeBotRows(BASIC_TOOLS), { lineWidth: -1 });
|
|
4139
|
+
}
|
|
4140
|
+
//#endregion
|
|
3444
4141
|
//#region src/agents/team-preset.ts
|
|
3445
4142
|
/**
|
|
3446
4143
|
* Agent 团队 preset 自动生成:派生宿主当前 shipped standard composition,
|
|
3447
4144
|
* 文本级禁用 subagent 工具族 4 个行,写入首个 trust=user 的 preset root。
|
|
3448
|
-
* 设计:docs/superpowers/specs/2026-09-02-agent-team-preset-design.md
|
|
4145
|
+
* 设计:docs/superpowers/specs/archive/2026-09-02-agent-team-preset-design.md
|
|
3449
4146
|
*/
|
|
3450
4147
|
/** 禁用目标行:覆盖与 team_delegate 竞争/配套的 5 个模型可见工具所属的 4 个行。 */
|
|
3451
4148
|
const TEAM_PRESET_DISABLED_ROWS = [
|
|
@@ -3501,9 +4198,38 @@ const MARKER_CONTENT = "dsh-agent-toolkit";
|
|
|
3501
4198
|
const PRESET_ID = /^[a-z0-9][a-z0-9-]*$/;
|
|
3502
4199
|
const GENERATED_HEADER = "# 本文件由 dsh-agent-toolkit 自动生成,勿手改(每次启动重写)。\n";
|
|
3503
4200
|
/**
|
|
3504
|
-
*
|
|
3505
|
-
*
|
|
3506
|
-
|
|
4201
|
+
* 写一个生成 preset 目录:无标记的同名用户目录不覆盖(warn 返回 false);marker-first 写入。
|
|
4202
|
+
* composition / metadata 写失败(含半途失败)留下的带标记目录,下次启动会被正常重写自愈。
|
|
4203
|
+
*/
|
|
4204
|
+
async function writeGeneratedPreset(dir, composition, metadata, warn) {
|
|
4205
|
+
const markerPath = join(dir, MARKER_FILE);
|
|
4206
|
+
let dirExists = true;
|
|
4207
|
+
try {
|
|
4208
|
+
await access(dir);
|
|
4209
|
+
} catch {
|
|
4210
|
+
dirExists = false;
|
|
4211
|
+
}
|
|
4212
|
+
if (dirExists) {
|
|
4213
|
+
let marked = false;
|
|
4214
|
+
try {
|
|
4215
|
+
marked = (await readFile(markerPath, "utf8")).trim() === MARKER_CONTENT;
|
|
4216
|
+
} catch {}
|
|
4217
|
+
if (!marked) {
|
|
4218
|
+
warn(`dsh-agent-toolkit: ${dir} 已存在且非本插件生成,不覆盖,跳过生成`);
|
|
4219
|
+
return false;
|
|
4220
|
+
}
|
|
4221
|
+
}
|
|
4222
|
+
await mkdir(dir, { recursive: true });
|
|
4223
|
+
await writeFile(markerPath, `${MARKER_CONTENT}\n`, "utf8");
|
|
4224
|
+
await writeFile(join(dir, COMPOSITION_FILE), composition, "utf8");
|
|
4225
|
+
await writeFile(join(dir, METADATA_FILE), yaml.dump(metadata, { lineWidth: -1 }), "utf8");
|
|
4226
|
+
return true;
|
|
4227
|
+
}
|
|
4228
|
+
/**
|
|
4229
|
+
* 启动时生成/刷新 agent-team 与 agent-bot 两个 preset。所有失败路径 warn 降级,不影响插件其余功能。
|
|
4230
|
+
* 不设为默认 preset、卸载不删目录(可能有会话在用;composition 不引用 toolkit 行,残留 preset 自身
|
|
4231
|
+
* 仍可用)。每次启动重写:standing mount 按文件代际,重写只影响新会话。agent-bot 内容来自 BASIC_TOOLS,
|
|
4232
|
+
* 不依赖源 preset 读取(read 失败只跳过 agent-team);两块独立 try/catch、独立 marker 保护。
|
|
3507
4233
|
*/
|
|
3508
4234
|
async function setupAgentTeamPreset(ctx, config) {
|
|
3509
4235
|
if (!config.enabled) return;
|
|
@@ -3512,52 +4238,979 @@ async function setupAgentTeamPreset(ctx, config) {
|
|
|
3512
4238
|
};
|
|
3513
4239
|
const agentPresets = ctx.get("agentPresets", false);
|
|
3514
4240
|
if (agentPresets === void 0) return;
|
|
3515
|
-
if (!PRESET_ID.test(config.id)) {
|
|
3516
|
-
warn(`dsh-agent-toolkit: agentTeamPreset.id "${config.id}" 不是合法 preset id,跳过 agent-team 生成`);
|
|
3517
|
-
return;
|
|
3518
|
-
}
|
|
3519
|
-
let source;
|
|
3520
|
-
try {
|
|
3521
|
-
source = await agentPresets.read(config.source);
|
|
3522
|
-
} catch (error) {
|
|
3523
|
-
warn(`dsh-agent-toolkit: 读取源 preset "${config.source}" 失败,跳过 agent-team 生成:${error instanceof Error ? error.message : String(error)}`);
|
|
3524
|
-
return;
|
|
3525
|
-
}
|
|
3526
4241
|
const root = agentPresets.roots.find((r) => r.trust === "user");
|
|
3527
4242
|
if (root === void 0) {
|
|
3528
|
-
warn("dsh-agent-toolkit: preset roots 中无 trust=user 的目录,跳过 agent-team 生成");
|
|
4243
|
+
warn("dsh-agent-toolkit: preset roots 中无 trust=user 的目录,跳过 agent-team / agent-bot 生成");
|
|
3529
4244
|
return;
|
|
3530
4245
|
}
|
|
3531
|
-
const
|
|
3532
|
-
|
|
3533
|
-
|
|
3534
|
-
|
|
3535
|
-
let dirExists = true;
|
|
4246
|
+
const presetDir = (id) => join(resolve(expandHomePath(root.path)), id);
|
|
4247
|
+
if (!PRESET_ID.test(config.id)) warn(`dsh-agent-toolkit: agentTeamPreset.id "${config.id}" 不是合法 preset id,跳过 agent-team 生成`);
|
|
4248
|
+
else {
|
|
4249
|
+
let source;
|
|
3536
4250
|
try {
|
|
3537
|
-
await
|
|
3538
|
-
} catch {
|
|
3539
|
-
|
|
4251
|
+
source = await agentPresets.read(config.source);
|
|
4252
|
+
} catch (error) {
|
|
4253
|
+
warn(`dsh-agent-toolkit: 读取源 preset "${config.source}" 失败,跳过 agent-team 生成:${error instanceof Error ? error.message : String(error)}`);
|
|
3540
4254
|
}
|
|
3541
|
-
if (
|
|
3542
|
-
|
|
4255
|
+
if (source !== void 0) {
|
|
4256
|
+
const dir = presetDir(config.id);
|
|
3543
4257
|
try {
|
|
3544
|
-
|
|
3545
|
-
|
|
3546
|
-
|
|
3547
|
-
|
|
3548
|
-
|
|
4258
|
+
await writeGeneratedPreset(dir, GENERATED_HEADER + disableSubagentRows(source, warn), {
|
|
4259
|
+
name: config.name,
|
|
4260
|
+
description: config.description
|
|
4261
|
+
}, warn);
|
|
4262
|
+
} catch (error) {
|
|
4263
|
+
warn(`dsh-agent-toolkit: 写入 agent-team preset 失败(${dir}):${error instanceof Error ? error.message : String(error)}`);
|
|
3549
4264
|
}
|
|
3550
4265
|
}
|
|
3551
|
-
|
|
3552
|
-
|
|
3553
|
-
|
|
3554
|
-
|
|
3555
|
-
|
|
3556
|
-
|
|
3557
|
-
|
|
4266
|
+
}
|
|
4267
|
+
if (config.id === config.botsId) warn(`dsh-agent-toolkit: agentTeamPreset.id 与 agentTeamPreset.botsId 相同(均为 "${config.id}"),跳过 agent-bot 生成`);
|
|
4268
|
+
else if (!PRESET_ID.test(config.botsId)) warn(`dsh-agent-toolkit: agentTeamPreset.botsId "${config.botsId}" 不是合法 preset id,跳过 agent-bot 生成`);
|
|
4269
|
+
else try {
|
|
4270
|
+
await writeGeneratedPreset(presetDir(config.botsId), GENERATED_HEADER + botPresetComposition(), {
|
|
4271
|
+
name: BOT_PRESET_NAME,
|
|
4272
|
+
description: BOT_PRESET_DESCRIPTION
|
|
4273
|
+
}, warn);
|
|
3558
4274
|
} catch (error) {
|
|
3559
|
-
warn(`dsh-agent-toolkit: 写入 agent-
|
|
4275
|
+
warn(`dsh-agent-toolkit: 写入 agent-bot preset 失败:${error instanceof Error ? error.message : String(error)}`);
|
|
4276
|
+
}
|
|
4277
|
+
}
|
|
4278
|
+
//#endregion
|
|
4279
|
+
//#region src/schedule/store.ts
|
|
4280
|
+
/** schedule 模块存储域声明:CronTask/CronRun 记录 schema + domain 布局的单一来源。*/
|
|
4281
|
+
/** 调度规则三选一:cron(5 字段,可选 IANA 时区,省略 = 进程时区)/ at(RFC 3339 一次性)/ every(≥60s,创建时间为锚)。*/
|
|
4282
|
+
const CronScheduleSchema = z$1.discriminatedUnion("kind", [
|
|
4283
|
+
z$1.object({
|
|
4284
|
+
kind: z$1.literal("cron"),
|
|
4285
|
+
expr: z$1.string().min(1),
|
|
4286
|
+
timeZone: z$1.string().min(1).optional()
|
|
4287
|
+
}),
|
|
4288
|
+
z$1.object({
|
|
4289
|
+
kind: z$1.literal("at"),
|
|
4290
|
+
at: z$1.string().min(1)
|
|
4291
|
+
}),
|
|
4292
|
+
z$1.object({
|
|
4293
|
+
kind: z$1.literal("every"),
|
|
4294
|
+
seconds: z$1.number().int().min(60)
|
|
4295
|
+
})
|
|
4296
|
+
]);
|
|
4297
|
+
/** 执行目标:主 Agent(宿主默认模型,无 persona/restrict)或注册表角色(persona/model/工具白名单随角色)。*/
|
|
4298
|
+
const CronTargetSchema = z$1.discriminatedUnion("kind", [z$1.object({ kind: z$1.literal("main") }), z$1.object({
|
|
4299
|
+
kind: z$1.literal("role"),
|
|
4300
|
+
roleId: z$1.string().min(1)
|
|
4301
|
+
})]);
|
|
4302
|
+
const CronTaskSchema = z$1.object({
|
|
4303
|
+
id: z$1.string().min(1),
|
|
4304
|
+
name: z$1.string().min(1).max(64),
|
|
4305
|
+
prompt: z$1.string().min(1).max(8e3),
|
|
4306
|
+
cwd: z$1.string().min(1),
|
|
4307
|
+
schedule: CronScheduleSchema,
|
|
4308
|
+
target: CronTargetSchema,
|
|
4309
|
+
catchup: z$1.boolean(),
|
|
4310
|
+
enabled: z$1.boolean(),
|
|
4311
|
+
/** 调度器维护的持久缓存(重启 rearm 依据),UTC ISO 串。*/
|
|
4312
|
+
nextRunAt: z$1.string().nullable(),
|
|
4313
|
+
createdAt: z$1.string(),
|
|
4314
|
+
updatedAt: z$1.string()
|
|
4315
|
+
});
|
|
4316
|
+
const CronRunSchema = z$1.object({
|
|
4317
|
+
id: z$1.string().min(1),
|
|
4318
|
+
taskId: z$1.string().min(1),
|
|
4319
|
+
triggeredAt: z$1.string(),
|
|
4320
|
+
finishedAt: z$1.string().optional(),
|
|
4321
|
+
status: z$1.enum([
|
|
4322
|
+
"running",
|
|
4323
|
+
"ok",
|
|
4324
|
+
"error",
|
|
4325
|
+
"skipped-overlap"
|
|
4326
|
+
]),
|
|
4327
|
+
/** 本次执行新建的会话 id(skipped-overlap 无会话)。*/
|
|
4328
|
+
sessionId: z$1.string().optional(),
|
|
4329
|
+
/** 错误摘要(截断)。*/
|
|
4330
|
+
error: z$1.string().optional()
|
|
4331
|
+
});
|
|
4332
|
+
/** domain 名/表名均受 UNIT_NAME_RE 约束(^[a-z][a-z0-9_]*$),不允许连字符。*/
|
|
4333
|
+
const scheduleDomain = defineDomain({
|
|
4334
|
+
name: "dsh_agent_toolkit_schedule",
|
|
4335
|
+
version: 1,
|
|
4336
|
+
tables: {
|
|
4337
|
+
tasks: domainTable(CronTaskSchema),
|
|
4338
|
+
runs: domainTable(CronRunSchema),
|
|
4339
|
+
meta: domainTable(z$1.object({ value: z$1.string() }))
|
|
4340
|
+
}
|
|
4341
|
+
});
|
|
4342
|
+
/** 某任务的 run 列表(新 → 旧;同刻按 id 倒序稳定化)。*/
|
|
4343
|
+
function listRuns(runs, taskId) {
|
|
4344
|
+
return [...runs.entries()].map(([, run]) => run).filter((run) => run.taskId === taskId).sort((a, b) => b.triggeredAt.localeCompare(a.triggeredAt) || b.id.localeCompare(a.id));
|
|
4345
|
+
}
|
|
4346
|
+
/** 环形保留某任务最近 limit 条 run,超出删最旧。*/
|
|
4347
|
+
async function trimRuns(runs, taskId, limit) {
|
|
4348
|
+
const mine = listRuns(runs, taskId);
|
|
4349
|
+
for (const run of mine.slice(limit)) await runs.delete(run.id);
|
|
4350
|
+
}
|
|
4351
|
+
/** 删除任务连带清运行历史。*/
|
|
4352
|
+
async function deleteRunsOf(runs, taskId) {
|
|
4353
|
+
for (const run of listRuns(runs, taskId)) await runs.delete(run.id);
|
|
4354
|
+
}
|
|
4355
|
+
//#endregion
|
|
4356
|
+
//#region src/schedule/api.ts
|
|
4357
|
+
const CreateBodySchema = z$1.object({
|
|
4358
|
+
name: z$1.string().min(1).max(64),
|
|
4359
|
+
prompt: z$1.string().min(1).max(8e3),
|
|
4360
|
+
cwd: z$1.string().min(1),
|
|
4361
|
+
schedule: CronScheduleSchema,
|
|
4362
|
+
target: CronTargetSchema,
|
|
4363
|
+
catchup: z$1.boolean(),
|
|
4364
|
+
enabled: z$1.boolean()
|
|
4365
|
+
});
|
|
4366
|
+
const UpdateBodySchema = CreateBodySchema.partial();
|
|
4367
|
+
function createCronApiHandler(deps) {
|
|
4368
|
+
return async (req, res) => {
|
|
4369
|
+
const sub = new URL(req.url ?? "/", "http://127.0.0.1").pathname.replace(/^\/dsh-agent-toolkit\/api\/cron/, "") || "/";
|
|
4370
|
+
const method = req.method ?? "GET";
|
|
4371
|
+
const taskMatch = /^\/tasks\/([^/]+)$/.exec(sub);
|
|
4372
|
+
const triggerMatch = /^\/tasks\/([^/]+)\/trigger$/.exec(sub);
|
|
4373
|
+
const runsMatch = /^\/tasks\/([^/]+)\/runs$/.exec(sub);
|
|
4374
|
+
if (sub === "/tasks" && method === "GET") {
|
|
4375
|
+
json$1(res, 200, { tasks: deps.service.list() });
|
|
4376
|
+
return;
|
|
4377
|
+
}
|
|
4378
|
+
if (sub === "/tasks" && method === "POST") {
|
|
4379
|
+
const body = await readJsonBody$1(req, res);
|
|
4380
|
+
if (body === void 0) return;
|
|
4381
|
+
const parsed = CreateBodySchema.safeParse(body);
|
|
4382
|
+
if (!parsed.success) {
|
|
4383
|
+
json$1(res, 400, { error: parsed.error.issues[0]?.message ?? "invalid body" });
|
|
4384
|
+
return;
|
|
4385
|
+
}
|
|
4386
|
+
const result = await deps.service.create(parsed.data);
|
|
4387
|
+
if (!result.ok) {
|
|
4388
|
+
json$1(res, 400, { error: result.error });
|
|
4389
|
+
return;
|
|
4390
|
+
}
|
|
4391
|
+
json$1(res, 200, { task: result.value });
|
|
4392
|
+
return;
|
|
4393
|
+
}
|
|
4394
|
+
if (taskMatch !== null && method === "PUT") {
|
|
4395
|
+
const id = decodeURIComponent(taskMatch[1]);
|
|
4396
|
+
if (deps.service.get(id) === void 0) {
|
|
4397
|
+
json$1(res, 404, { error: `定时任务 "${id}" 不存在` });
|
|
4398
|
+
return;
|
|
4399
|
+
}
|
|
4400
|
+
const body = await readJsonBody$1(req, res);
|
|
4401
|
+
if (body === void 0) return;
|
|
4402
|
+
const parsed = UpdateBodySchema.safeParse(body);
|
|
4403
|
+
if (!parsed.success) {
|
|
4404
|
+
json$1(res, 400, { error: parsed.error.issues[0]?.message ?? "invalid body" });
|
|
4405
|
+
return;
|
|
4406
|
+
}
|
|
4407
|
+
const result = await deps.service.update(id, parsed.data);
|
|
4408
|
+
if (!result.ok) {
|
|
4409
|
+
json$1(res, 400, { error: result.error });
|
|
4410
|
+
return;
|
|
4411
|
+
}
|
|
4412
|
+
json$1(res, 200, { task: result.value });
|
|
4413
|
+
return;
|
|
4414
|
+
}
|
|
4415
|
+
if (taskMatch !== null && method === "DELETE") {
|
|
4416
|
+
const id = decodeURIComponent(taskMatch[1]);
|
|
4417
|
+
if (deps.service.get(id) === void 0) {
|
|
4418
|
+
json$1(res, 404, { error: `定时任务 "${id}" 不存在` });
|
|
4419
|
+
return;
|
|
4420
|
+
}
|
|
4421
|
+
const result = await deps.service.remove(id);
|
|
4422
|
+
if (!result.ok) {
|
|
4423
|
+
json$1(res, 400, { error: result.error });
|
|
4424
|
+
return;
|
|
4425
|
+
}
|
|
4426
|
+
json$1(res, 200, { ok: true });
|
|
4427
|
+
return;
|
|
4428
|
+
}
|
|
4429
|
+
if (triggerMatch !== null && method === "POST") {
|
|
4430
|
+
const id = decodeURIComponent(triggerMatch[1]);
|
|
4431
|
+
if (deps.service.get(id) === void 0) {
|
|
4432
|
+
json$1(res, 404, { error: `定时任务 "${id}" 不存在` });
|
|
4433
|
+
return;
|
|
4434
|
+
}
|
|
4435
|
+
const result = await deps.service.trigger(id);
|
|
4436
|
+
if (!result.ok) {
|
|
4437
|
+
json$1(res, /正在运行/.test(result.error) ? 409 : 400, { error: result.error });
|
|
4438
|
+
return;
|
|
4439
|
+
}
|
|
4440
|
+
json$1(res, 200, { run: result.value });
|
|
4441
|
+
return;
|
|
4442
|
+
}
|
|
4443
|
+
if (runsMatch !== null && method === "GET") {
|
|
4444
|
+
const id = decodeURIComponent(runsMatch[1]);
|
|
4445
|
+
if (deps.service.get(id) === void 0) {
|
|
4446
|
+
json$1(res, 404, { error: `定时任务 "${id}" 不存在` });
|
|
4447
|
+
return;
|
|
4448
|
+
}
|
|
4449
|
+
json$1(res, 200, { runs: deps.service.runsOf(id, deps.runHistoryLimit) });
|
|
4450
|
+
return;
|
|
4451
|
+
}
|
|
4452
|
+
if (sub === "/projects" && method === "GET") {
|
|
4453
|
+
json$1(res, 200, { projects: deps.listProjects() });
|
|
4454
|
+
return;
|
|
4455
|
+
}
|
|
4456
|
+
if (sub === "/tasks" || taskMatch !== null || triggerMatch !== null || runsMatch !== null || sub === "/projects") {
|
|
4457
|
+
json$1(res, 405, { error: "method not allowed" });
|
|
4458
|
+
return;
|
|
4459
|
+
}
|
|
4460
|
+
json$1(res, 404, { error: "not found" });
|
|
4461
|
+
};
|
|
4462
|
+
}
|
|
4463
|
+
//#endregion
|
|
4464
|
+
//#region src/schedule/executor.ts
|
|
4465
|
+
/** 定时任务执行器:输入一条 task,输出一条 run 记录(建会话 → followup → whenIdle/超时 → 落库)。 */
|
|
4466
|
+
/** 来源段名:声明本会话由定时任务触发(order 20,与渠道 sender 段同位)。 */
|
|
4467
|
+
const TASK_SECTION_NAME = "dsh-agent-toolkit:schedule:task";
|
|
4468
|
+
function taskSectionText(task, triggeredAt) {
|
|
4469
|
+
return `本会话由定时任务「${task.name}」(id: ${task.id})于 ${triggeredAt} 触发。`;
|
|
4470
|
+
}
|
|
4471
|
+
/** 错误摘要最大字符数(对齐 feishu.errorDetailMaxChars 默认 500)。 */
|
|
4472
|
+
const ERROR_MAX_CHARS = 500;
|
|
4473
|
+
var RunTimeoutError = class extends Error {
|
|
4474
|
+
constructor() {
|
|
4475
|
+
super("timeout");
|
|
4476
|
+
}
|
|
4477
|
+
};
|
|
4478
|
+
function createExecutor(deps) {
|
|
4479
|
+
async function persist(run) {
|
|
4480
|
+
await deps.runs.put(run.id, run);
|
|
4481
|
+
await trimRuns(deps.runs, run.taskId, deps.runHistoryLimit);
|
|
3560
4482
|
}
|
|
4483
|
+
return { async trigger(task) {
|
|
4484
|
+
const triggeredAt = new Date(deps.now()).toISOString();
|
|
4485
|
+
const sessionId = deps.newSessionId();
|
|
4486
|
+
const run = {
|
|
4487
|
+
id: deps.newRunId(),
|
|
4488
|
+
taskId: task.id,
|
|
4489
|
+
triggeredAt,
|
|
4490
|
+
status: "running",
|
|
4491
|
+
sessionId
|
|
4492
|
+
};
|
|
4493
|
+
await persist(run);
|
|
4494
|
+
const finish = async (status, error) => {
|
|
4495
|
+
const done = {
|
|
4496
|
+
...run,
|
|
4497
|
+
status,
|
|
4498
|
+
finishedAt: new Date(deps.now()).toISOString(),
|
|
4499
|
+
...error !== void 0 ? { error: error.slice(0, ERROR_MAX_CHARS) } : {}
|
|
4500
|
+
};
|
|
4501
|
+
await persist(done);
|
|
4502
|
+
return done;
|
|
4503
|
+
};
|
|
4504
|
+
const source = {
|
|
4505
|
+
name: TASK_SECTION_NAME,
|
|
4506
|
+
order: 20,
|
|
4507
|
+
text: taskSectionText(task, triggeredAt)
|
|
4508
|
+
};
|
|
4509
|
+
let agentOptions;
|
|
4510
|
+
let hooks;
|
|
4511
|
+
const roleId = task.target.kind === "role" ? task.target.roleId : "main";
|
|
4512
|
+
const role = roleId === "main" ? void 0 : deps.registry.get(roleId);
|
|
4513
|
+
if (roleId === "main" || role === void 0) {
|
|
4514
|
+
if (roleId !== "main" && role === void 0) deps.warn(`[schedule] 定时任务 "${task.id}" 的角色 "${roleId}" 不存在,降级主 Agent 形态`);
|
|
4515
|
+
agentOptions = deps.defaultModel();
|
|
4516
|
+
hooks = { sections: [source] };
|
|
4517
|
+
} else {
|
|
4518
|
+
const base = roleHooks(role);
|
|
4519
|
+
agentOptions = roleAgentOptions(role, deps.defaultModel);
|
|
4520
|
+
hooks = {
|
|
4521
|
+
...base,
|
|
4522
|
+
sections: [...base.sections ?? [], source]
|
|
4523
|
+
};
|
|
4524
|
+
}
|
|
4525
|
+
let agent;
|
|
4526
|
+
try {
|
|
4527
|
+
agent = await deps.agents.create({
|
|
4528
|
+
sessionId,
|
|
4529
|
+
cwd: task.cwd,
|
|
4530
|
+
agentOptions,
|
|
4531
|
+
hooks
|
|
4532
|
+
});
|
|
4533
|
+
} catch (error) {
|
|
4534
|
+
return finish("error", error instanceof Error ? error.message : String(error));
|
|
4535
|
+
}
|
|
4536
|
+
try {
|
|
4537
|
+
await deps.workspace.attach(task.cwd, sessionId);
|
|
4538
|
+
} catch (error) {
|
|
4539
|
+
deps.warn(`[schedule] 会话 ${sessionId} 挂载 workspace 失败:${error instanceof Error ? error.message : String(error)}`);
|
|
4540
|
+
}
|
|
4541
|
+
agent.followup(createUserMessage({
|
|
4542
|
+
content: [{
|
|
4543
|
+
type: "text",
|
|
4544
|
+
text: task.prompt
|
|
4545
|
+
}],
|
|
4546
|
+
source: { kind: "user" }
|
|
4547
|
+
}));
|
|
4548
|
+
const idle = agent.whenIdle();
|
|
4549
|
+
idle.catch(() => void 0);
|
|
4550
|
+
const timeout = deps.delay(deps.runTimeoutMs).then(() => {
|
|
4551
|
+
throw new RunTimeoutError();
|
|
4552
|
+
});
|
|
4553
|
+
timeout.catch(() => void 0);
|
|
4554
|
+
try {
|
|
4555
|
+
await Promise.race([idle, timeout]);
|
|
4556
|
+
return await finish("ok");
|
|
4557
|
+
} catch (error) {
|
|
4558
|
+
if (error instanceof RunTimeoutError) {
|
|
4559
|
+
agent.cancel();
|
|
4560
|
+
return await finish("error", `timeout(超过 ${Math.round(deps.runTimeoutMs / 6e4)} 分钟未空闲,已取消)`);
|
|
4561
|
+
}
|
|
4562
|
+
return await finish("error", error instanceof Error ? error.message : String(error));
|
|
4563
|
+
}
|
|
4564
|
+
} };
|
|
4565
|
+
}
|
|
4566
|
+
//#endregion
|
|
4567
|
+
//#region src/schedule/timing.ts
|
|
4568
|
+
/** cron/at/every 纯时间运算:校验、下一触发点、预览、启动 rearm。全部注入 now,无隐藏时钟。*/
|
|
4569
|
+
/** croner 选项:paused 仅做解析与预测,不注册真实定时器(计时由调度器 tick 驱动)。*/
|
|
4570
|
+
function cronerOptions(schedule) {
|
|
4571
|
+
return {
|
|
4572
|
+
paused: true,
|
|
4573
|
+
...schedule.timeZone !== void 0 ? { timezone: schedule.timeZone } : {}
|
|
4574
|
+
};
|
|
4575
|
+
}
|
|
4576
|
+
/** 创建/更新即校验;合法返回 undefined,非法返回错误消息(不入库由调用方保证)。*/
|
|
4577
|
+
function validateSchedule(schedule, nowMs) {
|
|
4578
|
+
switch (schedule.kind) {
|
|
4579
|
+
case "cron": {
|
|
4580
|
+
const fields = schedule.expr.trim().split(/\s+/);
|
|
4581
|
+
if (fields.length !== 5) return `cron 表达式须为 5 字段(分 时 日 月 周),收到 ${fields.length} 字段;秒级/年字段暂不开放`;
|
|
4582
|
+
if (schedule.timeZone !== void 0) try {
|
|
4583
|
+
new Intl.DateTimeFormat("en-US", { timeZone: schedule.timeZone });
|
|
4584
|
+
} catch {
|
|
4585
|
+
return `未知时区:${schedule.timeZone}`;
|
|
4586
|
+
}
|
|
4587
|
+
try {
|
|
4588
|
+
new Cron(schedule.expr, cronerOptions(schedule));
|
|
4589
|
+
} catch (error) {
|
|
4590
|
+
return `非法 cron 表达式"${schedule.expr}":${error instanceof Error ? error.message : String(error)}`;
|
|
4591
|
+
}
|
|
4592
|
+
return;
|
|
4593
|
+
}
|
|
4594
|
+
case "at": {
|
|
4595
|
+
const t = Date.parse(schedule.at);
|
|
4596
|
+
if (Number.isNaN(t)) return `非法 at 时间(须 RFC 3339):${schedule.at}`;
|
|
4597
|
+
if (t <= nowMs) return `at 时间须在未来:${schedule.at}`;
|
|
4598
|
+
return;
|
|
4599
|
+
}
|
|
4600
|
+
case "every": return;
|
|
4601
|
+
}
|
|
4602
|
+
}
|
|
4603
|
+
/** 下一触发点(epoch ms);无未来触发点(at 过期等)返回 null。*/
|
|
4604
|
+
function nextOccurrence(schedule, createdAtMs, nowMs) {
|
|
4605
|
+
switch (schedule.kind) {
|
|
4606
|
+
case "cron": {
|
|
4607
|
+
const next = new Cron(schedule.expr, cronerOptions(schedule)).nextRun(new Date(nowMs));
|
|
4608
|
+
return next === null ? null : next.getTime();
|
|
4609
|
+
}
|
|
4610
|
+
case "at": {
|
|
4611
|
+
const t = Date.parse(schedule.at);
|
|
4612
|
+
return t > nowMs ? t : null;
|
|
4613
|
+
}
|
|
4614
|
+
case "every": {
|
|
4615
|
+
const intervalMs = schedule.seconds * 1e3;
|
|
4616
|
+
if (nowMs < createdAtMs) return createdAtMs;
|
|
4617
|
+
return createdAtMs + (Math.floor((nowMs - createdAtMs) / intervalMs) + 1) * intervalMs;
|
|
4618
|
+
}
|
|
4619
|
+
}
|
|
4620
|
+
}
|
|
4621
|
+
/**
|
|
4622
|
+
* 启动 rearm:对一条持久化任务重算 enabled/nextRunAt(无变化返回原引用,调用方按引用决定是否写回)。
|
|
4623
|
+
* - nextRunAt 仍在未来 → 保持;
|
|
4624
|
+
* - 已过期且 catchup → 设为「立即」(首个 tick 补跑一次);
|
|
4625
|
+
* - 已过期且非 catchup → 下一未来触发点;没有(at 一次性)→ enabled=false、nextRunAt=null(过期即作废)。
|
|
4626
|
+
*/
|
|
4627
|
+
function rearmTask(task, nowMs) {
|
|
4628
|
+
if (!task.enabled) return task.nextRunAt === null ? task : {
|
|
4629
|
+
...task,
|
|
4630
|
+
nextRunAt: null
|
|
4631
|
+
};
|
|
4632
|
+
const persisted = task.nextRunAt === null ? null : Date.parse(task.nextRunAt);
|
|
4633
|
+
if (persisted !== null && persisted > nowMs) return task;
|
|
4634
|
+
if (persisted !== null && task.catchup) return {
|
|
4635
|
+
...task,
|
|
4636
|
+
nextRunAt: new Date(nowMs).toISOString()
|
|
4637
|
+
};
|
|
4638
|
+
const next = nextOccurrence(task.schedule, Date.parse(task.createdAt), nowMs);
|
|
4639
|
+
return next === null ? {
|
|
4640
|
+
...task,
|
|
4641
|
+
enabled: false,
|
|
4642
|
+
nextRunAt: null
|
|
4643
|
+
} : {
|
|
4644
|
+
...task,
|
|
4645
|
+
nextRunAt: new Date(next).toISOString()
|
|
4646
|
+
};
|
|
4647
|
+
}
|
|
4648
|
+
/** 创建/更新后的 fresh 重算(catchup 是停机补跑语义,不适用于编辑路径)。*/
|
|
4649
|
+
function recomputeTask(task, nowMs) {
|
|
4650
|
+
if (!task.enabled) return {
|
|
4651
|
+
...task,
|
|
4652
|
+
nextRunAt: null
|
|
4653
|
+
};
|
|
4654
|
+
const next = nextOccurrence(task.schedule, Date.parse(task.createdAt), nowMs);
|
|
4655
|
+
return next === null ? {
|
|
4656
|
+
...task,
|
|
4657
|
+
enabled: false,
|
|
4658
|
+
nextRunAt: null
|
|
4659
|
+
} : {
|
|
4660
|
+
...task,
|
|
4661
|
+
nextRunAt: new Date(next).toISOString()
|
|
4662
|
+
};
|
|
4663
|
+
}
|
|
4664
|
+
/** 触发/跳过后推进:cron/every 取 now 之后下一触发点;at 一次性作废。*/
|
|
4665
|
+
function advanceAfterTrigger(task, nowMs) {
|
|
4666
|
+
if (task.schedule.kind === "at") return {
|
|
4667
|
+
...task,
|
|
4668
|
+
enabled: false,
|
|
4669
|
+
nextRunAt: null
|
|
4670
|
+
};
|
|
4671
|
+
const next = nextOccurrence(task.schedule, Date.parse(task.createdAt), nowMs);
|
|
4672
|
+
return next === null ? {
|
|
4673
|
+
...task,
|
|
4674
|
+
enabled: false,
|
|
4675
|
+
nextRunAt: null
|
|
4676
|
+
} : {
|
|
4677
|
+
...task,
|
|
4678
|
+
nextRunAt: new Date(next).toISOString()
|
|
4679
|
+
};
|
|
4680
|
+
}
|
|
4681
|
+
//#endregion
|
|
4682
|
+
//#region src/schedule/scheduler.ts
|
|
4683
|
+
function createScheduler(deps) {
|
|
4684
|
+
/** 内存重叠锁:同任务上次未结束则本次记 skipped-overlap,不并发执行。 */
|
|
4685
|
+
const running = /* @__PURE__ */ new Set();
|
|
4686
|
+
async function recordSkipped(task) {
|
|
4687
|
+
const nowIso = new Date(deps.now()).toISOString();
|
|
4688
|
+
const run = {
|
|
4689
|
+
id: deps.newRunId(),
|
|
4690
|
+
taskId: task.id,
|
|
4691
|
+
triggeredAt: nowIso,
|
|
4692
|
+
finishedAt: nowIso,
|
|
4693
|
+
status: "skipped-overlap"
|
|
4694
|
+
};
|
|
4695
|
+
await deps.runs.put(run.id, run);
|
|
4696
|
+
await trimRuns(deps.runs, task.id, deps.runHistoryLimit);
|
|
4697
|
+
}
|
|
4698
|
+
function launch(task) {
|
|
4699
|
+
running.add(task.id);
|
|
4700
|
+
deps.execute(task).catch((error) => deps.warn(`[schedule] 任务 "${task.id}" 执行器异常:${error instanceof Error ? error.message : String(error)}`)).finally(() => {
|
|
4701
|
+
running.delete(task.id);
|
|
4702
|
+
const current = deps.tasks.get(task.id);
|
|
4703
|
+
if (current === void 0 || !current.enabled) return;
|
|
4704
|
+
deps.tasks.put(task.id, advanceAfterTrigger(current, deps.now()));
|
|
4705
|
+
});
|
|
4706
|
+
}
|
|
4707
|
+
return {
|
|
4708
|
+
async rearmAll() {
|
|
4709
|
+
for (const [id, task] of deps.tasks.entries()) {
|
|
4710
|
+
const rearmed = rearmTask(task, deps.now());
|
|
4711
|
+
if (rearmed !== task) await deps.tasks.put(id, rearmed);
|
|
4712
|
+
}
|
|
4713
|
+
},
|
|
4714
|
+
async tick() {
|
|
4715
|
+
for (const [, task] of deps.tasks.entries()) {
|
|
4716
|
+
if (!task.enabled || task.nextRunAt === null) continue;
|
|
4717
|
+
if (Date.parse(task.nextRunAt) > deps.now()) continue;
|
|
4718
|
+
if (running.has(task.id)) {
|
|
4719
|
+
await recordSkipped(task);
|
|
4720
|
+
await deps.tasks.put(task.id, advanceAfterTrigger(task, deps.now()));
|
|
4721
|
+
continue;
|
|
4722
|
+
}
|
|
4723
|
+
launch(task);
|
|
4724
|
+
}
|
|
4725
|
+
},
|
|
4726
|
+
recompute: (task) => recomputeTask(task, deps.now()),
|
|
4727
|
+
async triggerManual(taskId) {
|
|
4728
|
+
const task = deps.tasks.get(taskId);
|
|
4729
|
+
if (task === void 0) throw new Error(`定时任务 "${taskId}" 不存在`);
|
|
4730
|
+
if (running.has(taskId)) throw new Error(`定时任务 "${task.name}" 正在运行中,本次手动触发已跳过`);
|
|
4731
|
+
running.add(taskId);
|
|
4732
|
+
try {
|
|
4733
|
+
return await deps.execute(task);
|
|
4734
|
+
} finally {
|
|
4735
|
+
running.delete(taskId);
|
|
4736
|
+
}
|
|
4737
|
+
},
|
|
4738
|
+
isRunning: (taskId) => running.has(taskId)
|
|
4739
|
+
};
|
|
4740
|
+
}
|
|
4741
|
+
//#endregion
|
|
4742
|
+
//#region src/schedule/service.ts
|
|
4743
|
+
function createCronService(deps) {
|
|
4744
|
+
/** 创建/更新共用校验:schedule 语法与时值 → cwd → roleId 存在性。 */
|
|
4745
|
+
const validateIO = (input) => {
|
|
4746
|
+
const scheduleError = validateSchedule(input.schedule, deps.now());
|
|
4747
|
+
if (scheduleError !== void 0) return scheduleError;
|
|
4748
|
+
if (!deps.validateProject(input.cwd)) return `项目路径不可用:${input.cwd}`;
|
|
4749
|
+
if (input.target.kind === "role" && deps.registry.get(input.target.roleId) === void 0) return `角色 "${input.target.roleId}" 不存在`;
|
|
4750
|
+
};
|
|
4751
|
+
return {
|
|
4752
|
+
list() {
|
|
4753
|
+
return [...deps.tasks.entries()].map(([, task]) => {
|
|
4754
|
+
const last = listRuns(deps.runs, task.id)[0];
|
|
4755
|
+
return last === void 0 ? task : {
|
|
4756
|
+
...task,
|
|
4757
|
+
lastRun: {
|
|
4758
|
+
status: last.status,
|
|
4759
|
+
triggeredAt: last.triggeredAt
|
|
4760
|
+
}
|
|
4761
|
+
};
|
|
4762
|
+
});
|
|
4763
|
+
},
|
|
4764
|
+
get: (id) => deps.tasks.get(id),
|
|
4765
|
+
async create(input) {
|
|
4766
|
+
const invalid = validateIO(input);
|
|
4767
|
+
if (invalid !== void 0) return {
|
|
4768
|
+
ok: false,
|
|
4769
|
+
error: invalid
|
|
4770
|
+
};
|
|
4771
|
+
const nowIso = new Date(deps.now()).toISOString();
|
|
4772
|
+
const parsed = CronTaskSchema.safeParse({
|
|
4773
|
+
id: deps.newTaskId(),
|
|
4774
|
+
...input,
|
|
4775
|
+
nextRunAt: null,
|
|
4776
|
+
createdAt: nowIso,
|
|
4777
|
+
updatedAt: nowIso
|
|
4778
|
+
});
|
|
4779
|
+
if (!parsed.success) return {
|
|
4780
|
+
ok: false,
|
|
4781
|
+
error: parsed.error.issues[0]?.message ?? "invalid task"
|
|
4782
|
+
};
|
|
4783
|
+
const task = deps.scheduler.recompute(parsed.data);
|
|
4784
|
+
await deps.tasks.put(task.id, task);
|
|
4785
|
+
return {
|
|
4786
|
+
ok: true,
|
|
4787
|
+
value: task
|
|
4788
|
+
};
|
|
4789
|
+
},
|
|
4790
|
+
async update(id, patch) {
|
|
4791
|
+
const existing = deps.tasks.get(id);
|
|
4792
|
+
if (existing === void 0) return {
|
|
4793
|
+
ok: false,
|
|
4794
|
+
error: `定时任务 "${id}" 不存在`
|
|
4795
|
+
};
|
|
4796
|
+
const merged = {
|
|
4797
|
+
...existing,
|
|
4798
|
+
...Object.fromEntries(Object.entries(patch).filter(([, v]) => v !== void 0)),
|
|
4799
|
+
id: existing.id,
|
|
4800
|
+
updatedAt: new Date(deps.now()).toISOString()
|
|
4801
|
+
};
|
|
4802
|
+
const invalid = validateIO(merged);
|
|
4803
|
+
if (invalid !== void 0) return {
|
|
4804
|
+
ok: false,
|
|
4805
|
+
error: invalid
|
|
4806
|
+
};
|
|
4807
|
+
const parsed = CronTaskSchema.safeParse(merged);
|
|
4808
|
+
if (!parsed.success) return {
|
|
4809
|
+
ok: false,
|
|
4810
|
+
error: parsed.error.issues[0]?.message ?? "invalid task"
|
|
4811
|
+
};
|
|
4812
|
+
const task = deps.scheduler.recompute(parsed.data);
|
|
4813
|
+
await deps.tasks.put(id, task);
|
|
4814
|
+
return {
|
|
4815
|
+
ok: true,
|
|
4816
|
+
value: task
|
|
4817
|
+
};
|
|
4818
|
+
},
|
|
4819
|
+
async remove(id) {
|
|
4820
|
+
if (deps.tasks.get(id) === void 0) return {
|
|
4821
|
+
ok: false,
|
|
4822
|
+
error: `定时任务 "${id}" 不存在`
|
|
4823
|
+
};
|
|
4824
|
+
await deps.tasks.delete(id);
|
|
4825
|
+
await deleteRunsOf(deps.runs, id);
|
|
4826
|
+
return {
|
|
4827
|
+
ok: true,
|
|
4828
|
+
value: null
|
|
4829
|
+
};
|
|
4830
|
+
},
|
|
4831
|
+
async trigger(id) {
|
|
4832
|
+
try {
|
|
4833
|
+
return {
|
|
4834
|
+
ok: true,
|
|
4835
|
+
value: await deps.scheduler.triggerManual(id)
|
|
4836
|
+
};
|
|
4837
|
+
} catch (error) {
|
|
4838
|
+
return {
|
|
4839
|
+
ok: false,
|
|
4840
|
+
error: error instanceof Error ? error.message : String(error)
|
|
4841
|
+
};
|
|
4842
|
+
}
|
|
4843
|
+
},
|
|
4844
|
+
runsOf: (id, limit) => listRuns(deps.runs, id).slice(0, limit)
|
|
4845
|
+
};
|
|
4846
|
+
}
|
|
4847
|
+
//#endregion
|
|
4848
|
+
//#region src/schedule/tools.ts
|
|
4849
|
+
/** 互斥探测目标:宿主 @deepseek-ai/dsh-schedule 的会话内提醒工具。 */
|
|
4850
|
+
const HOST_SCHEDULE_TOOL = "schedule_create";
|
|
4851
|
+
/** 扁平参数 → CronSchedule(缺 kind 对应字段抛错——模型可读的自描述消息)。 */
|
|
4852
|
+
function scheduleOf(args) {
|
|
4853
|
+
const kind = args.scheduleKind;
|
|
4854
|
+
if (kind === "cron") {
|
|
4855
|
+
if (typeof args.cronExpr !== "string" || args.cronExpr.trim() === "") throw new Error("scheduleKind=cron 需要 cronExpr(5 字段 cron 表达式)");
|
|
4856
|
+
return {
|
|
4857
|
+
kind: "cron",
|
|
4858
|
+
expr: args.cronExpr,
|
|
4859
|
+
...typeof args.timeZone === "string" && args.timeZone !== "" ? { timeZone: args.timeZone } : {}
|
|
4860
|
+
};
|
|
4861
|
+
}
|
|
4862
|
+
if (kind === "at") {
|
|
4863
|
+
if (typeof args.atTime !== "string" || args.atTime === "") throw new Error("scheduleKind=at 需要 atTime(RFC 3339 时间)");
|
|
4864
|
+
return {
|
|
4865
|
+
kind: "at",
|
|
4866
|
+
at: args.atTime
|
|
4867
|
+
};
|
|
4868
|
+
}
|
|
4869
|
+
if (kind === "every") {
|
|
4870
|
+
if (typeof args.everySeconds !== "number") throw new Error("scheduleKind=every 需要 everySeconds(≥60 的秒数)");
|
|
4871
|
+
return {
|
|
4872
|
+
kind: "every",
|
|
4873
|
+
seconds: args.everySeconds
|
|
4874
|
+
};
|
|
4875
|
+
}
|
|
4876
|
+
throw new Error(`未知 scheduleKind:${String(kind)}(可选 cron / at / every)`);
|
|
4877
|
+
}
|
|
4878
|
+
function targetOf(args) {
|
|
4879
|
+
if (args.targetKind === "role") {
|
|
4880
|
+
if (typeof args.roleId !== "string" || args.roleId === "") throw new Error("targetKind=role 需要 roleId");
|
|
4881
|
+
return {
|
|
4882
|
+
kind: "role",
|
|
4883
|
+
roleId: args.roleId
|
|
4884
|
+
};
|
|
4885
|
+
}
|
|
4886
|
+
return { kind: "main" };
|
|
4887
|
+
}
|
|
4888
|
+
/** 服务结果展开:ok:false → 抛错(defineTool 约定:抛错即工具错误结果)。 */
|
|
4889
|
+
function unwrap(result) {
|
|
4890
|
+
if (!result.ok) throw new Error(result.error);
|
|
4891
|
+
return result.value;
|
|
4892
|
+
}
|
|
4893
|
+
const JSON_OUTPUT = {
|
|
4894
|
+
schema: { type: "json" },
|
|
4895
|
+
render: (_args, value) => [{
|
|
4896
|
+
type: "text",
|
|
4897
|
+
text: JSON.stringify(value, null, 2)
|
|
4898
|
+
}]
|
|
4899
|
+
};
|
|
4900
|
+
const SCHEDULE_PARAMS_REQUIRED = { scheduleKind: {
|
|
4901
|
+
type: "string",
|
|
4902
|
+
required: true,
|
|
4903
|
+
description: "One of: cron / at / every"
|
|
4904
|
+
} };
|
|
4905
|
+
const SCHEDULE_PARAMS_OPTIONAL = { scheduleKind: {
|
|
4906
|
+
type: "string",
|
|
4907
|
+
description: "Replace schedule kind: cron / at / every (provide the matching fields too)"
|
|
4908
|
+
} };
|
|
4909
|
+
const SCHEDULE_FIELD_PARAMS = {
|
|
4910
|
+
cronExpr: {
|
|
4911
|
+
type: "string",
|
|
4912
|
+
description: "Required when scheduleKind=cron: 5-field cron expression \"minute hour day-of-month month day-of-week\" (seconds/years not supported)"
|
|
4913
|
+
},
|
|
4914
|
+
timeZone: {
|
|
4915
|
+
type: "string",
|
|
4916
|
+
description: "Optional IANA time zone for cron (e.g. \"Asia/Shanghai\"); default: server process time zone"
|
|
4917
|
+
},
|
|
4918
|
+
atTime: {
|
|
4919
|
+
type: "string",
|
|
4920
|
+
description: "Required when scheduleKind=at: one-shot RFC 3339 time (e.g. \"2026-09-08T09:00:00+08:00\")"
|
|
4921
|
+
},
|
|
4922
|
+
everySeconds: {
|
|
4923
|
+
type: "number",
|
|
4924
|
+
description: "Required when scheduleKind=every: interval seconds (>= 60), anchored at creation time"
|
|
4925
|
+
}
|
|
4926
|
+
};
|
|
4927
|
+
const TARGET_PARAMS_REQUIRED = { targetKind: {
|
|
4928
|
+
type: "string",
|
|
4929
|
+
required: true,
|
|
4930
|
+
description: "One of: main (main agent, default model) / role (registry role with its persona/model/tool allowlist)"
|
|
4931
|
+
} };
|
|
4932
|
+
const TARGET_PARAMS_OPTIONAL = { targetKind: {
|
|
4933
|
+
type: "string",
|
|
4934
|
+
description: "Replace target: main / role"
|
|
4935
|
+
} };
|
|
4936
|
+
const ROLE_ID_PARAM = { roleId: {
|
|
4937
|
+
type: "string",
|
|
4938
|
+
description: "Required when targetKind=role: registry role id"
|
|
4939
|
+
} };
|
|
4940
|
+
function createCronTools(service) {
|
|
4941
|
+
return [
|
|
4942
|
+
defineTool({
|
|
4943
|
+
name: "cron_task_create",
|
|
4944
|
+
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.",
|
|
4945
|
+
parameters: {
|
|
4946
|
+
name: {
|
|
4947
|
+
type: "string",
|
|
4948
|
+
required: true,
|
|
4949
|
+
description: "Task name (display)"
|
|
4950
|
+
},
|
|
4951
|
+
prompt: {
|
|
4952
|
+
type: "string",
|
|
4953
|
+
required: true,
|
|
4954
|
+
description: "The prompt sent as the user message when the task fires. Self-contained: the session has no other context."
|
|
4955
|
+
},
|
|
4956
|
+
cwd: {
|
|
4957
|
+
type: "string",
|
|
4958
|
+
required: true,
|
|
4959
|
+
description: "Working directory of the new session (must be a valid project path)"
|
|
4960
|
+
},
|
|
4961
|
+
...SCHEDULE_PARAMS_REQUIRED,
|
|
4962
|
+
...SCHEDULE_FIELD_PARAMS,
|
|
4963
|
+
...TARGET_PARAMS_REQUIRED,
|
|
4964
|
+
...ROLE_ID_PARAM,
|
|
4965
|
+
catchup: {
|
|
4966
|
+
type: "boolean",
|
|
4967
|
+
required: true,
|
|
4968
|
+
description: "true: run once immediately after restart if a run was missed while stopped; false: skip to the next future occurrence"
|
|
4969
|
+
},
|
|
4970
|
+
enabled: {
|
|
4971
|
+
type: "boolean",
|
|
4972
|
+
required: true,
|
|
4973
|
+
description: "false: create paused (nextRunAt stays null until enabled via cron_task_update)"
|
|
4974
|
+
}
|
|
4975
|
+
},
|
|
4976
|
+
output: JSON_OUTPUT,
|
|
4977
|
+
isConcurrencySafe: () => true,
|
|
4978
|
+
async execute(rawArgs) {
|
|
4979
|
+
const args = rawArgs;
|
|
4980
|
+
const input = {
|
|
4981
|
+
name: args.name,
|
|
4982
|
+
prompt: args.prompt,
|
|
4983
|
+
cwd: args.cwd,
|
|
4984
|
+
schedule: scheduleOf(args),
|
|
4985
|
+
target: targetOf(args),
|
|
4986
|
+
catchup: args.catchup,
|
|
4987
|
+
enabled: args.enabled
|
|
4988
|
+
};
|
|
4989
|
+
return unwrap(await service.create(input));
|
|
4990
|
+
}
|
|
4991
|
+
}),
|
|
4992
|
+
defineTool({
|
|
4993
|
+
name: "cron_task_list",
|
|
4994
|
+
description: "List all scheduled tasks with enabled flag, next run time and last run status.",
|
|
4995
|
+
parameters: {},
|
|
4996
|
+
output: JSON_OUTPUT,
|
|
4997
|
+
isConcurrencySafe: () => true,
|
|
4998
|
+
async execute() {
|
|
4999
|
+
return service.list();
|
|
5000
|
+
}
|
|
5001
|
+
}),
|
|
5002
|
+
defineTool({
|
|
5003
|
+
name: "cron_task_update",
|
|
5004
|
+
description: "Update any fields of a scheduled task by id. nextRunAt is recomputed.",
|
|
5005
|
+
parameters: {
|
|
5006
|
+
id: {
|
|
5007
|
+
type: "string",
|
|
5008
|
+
required: true,
|
|
5009
|
+
description: "Task id"
|
|
5010
|
+
},
|
|
5011
|
+
name: {
|
|
5012
|
+
type: "string",
|
|
5013
|
+
description: "New name"
|
|
5014
|
+
},
|
|
5015
|
+
prompt: {
|
|
5016
|
+
type: "string",
|
|
5017
|
+
description: "New prompt"
|
|
5018
|
+
},
|
|
5019
|
+
cwd: {
|
|
5020
|
+
type: "string",
|
|
5021
|
+
description: "New working directory"
|
|
5022
|
+
},
|
|
5023
|
+
...SCHEDULE_PARAMS_OPTIONAL,
|
|
5024
|
+
...SCHEDULE_FIELD_PARAMS,
|
|
5025
|
+
...TARGET_PARAMS_OPTIONAL,
|
|
5026
|
+
...ROLE_ID_PARAM,
|
|
5027
|
+
catchup: {
|
|
5028
|
+
type: "boolean",
|
|
5029
|
+
description: "New catchup flag"
|
|
5030
|
+
},
|
|
5031
|
+
enabled: {
|
|
5032
|
+
type: "boolean",
|
|
5033
|
+
description: "Enable/disable the task"
|
|
5034
|
+
}
|
|
5035
|
+
},
|
|
5036
|
+
output: JSON_OUTPUT,
|
|
5037
|
+
isConcurrencySafe: () => true,
|
|
5038
|
+
async execute(rawArgs) {
|
|
5039
|
+
const args = rawArgs;
|
|
5040
|
+
const patch = {};
|
|
5041
|
+
if (args.name !== void 0) patch.name = args.name;
|
|
5042
|
+
if (args.prompt !== void 0) patch.prompt = args.prompt;
|
|
5043
|
+
if (args.cwd !== void 0) patch.cwd = args.cwd;
|
|
5044
|
+
if (args.scheduleKind !== void 0) patch.schedule = scheduleOf(args);
|
|
5045
|
+
if (args.targetKind !== void 0) patch.target = targetOf(args);
|
|
5046
|
+
if (args.catchup !== void 0) patch.catchup = args.catchup;
|
|
5047
|
+
if (args.enabled !== void 0) patch.enabled = args.enabled;
|
|
5048
|
+
return unwrap(await service.update(args.id, patch));
|
|
5049
|
+
}
|
|
5050
|
+
}),
|
|
5051
|
+
defineTool({
|
|
5052
|
+
name: "cron_task_delete",
|
|
5053
|
+
description: "Delete a scheduled task by id, including its run history.",
|
|
5054
|
+
parameters: { id: {
|
|
5055
|
+
type: "string",
|
|
5056
|
+
required: true,
|
|
5057
|
+
description: "Task id"
|
|
5058
|
+
} },
|
|
5059
|
+
output: JSON_OUTPUT,
|
|
5060
|
+
isConcurrencySafe: () => true,
|
|
5061
|
+
async execute(rawArgs) {
|
|
5062
|
+
return unwrap(await service.remove(rawArgs.id));
|
|
5063
|
+
}
|
|
5064
|
+
}),
|
|
5065
|
+
defineTool({
|
|
5066
|
+
name: "cron_task_trigger",
|
|
5067
|
+
description: "Trigger a scheduled task once right now (works even when disabled; fails with an error if the task is already running).",
|
|
5068
|
+
parameters: { id: {
|
|
5069
|
+
type: "string",
|
|
5070
|
+
required: true,
|
|
5071
|
+
description: "Task id"
|
|
5072
|
+
} },
|
|
5073
|
+
output: JSON_OUTPUT,
|
|
5074
|
+
isConcurrencySafe: () => true,
|
|
5075
|
+
async execute(rawArgs) {
|
|
5076
|
+
return unwrap(await service.trigger(rawArgs.id));
|
|
5077
|
+
}
|
|
5078
|
+
})
|
|
5079
|
+
];
|
|
5080
|
+
}
|
|
5081
|
+
/**
|
|
5082
|
+
* 注册 cron_* 工具到主 Agent scope 并探测宿主 schedule 互斥(spec §1/§5)。
|
|
5083
|
+
* 门控:跳过 subagent(session.header.origin === 'subagent')与插件自有会话(ownedSessions:
|
|
5084
|
+
* bot/schedule 会话)——dsh-schedule src/index.ts:45-49 同款 agent/created 模式,
|
|
5085
|
+
* 外加存量 roots 立即注册(HMR 重挂后当前主会话不丢工具)。
|
|
5086
|
+
* HMR 安全:镜像 dsh-schedule src/index.ts:44-76——attached 身份表防同一 agent 二次挂载,
|
|
5087
|
+
* toolkit 级 ctx.effect 卸载时 Promise.allSettled 摘下所有 agent 上挂的工具
|
|
5088
|
+
* (agent.ctx 属宿主、长于 toolkit fiber,仅靠 agent.ctx.effect 会在重挂时残留)。
|
|
5089
|
+
*/
|
|
5090
|
+
function setupCronTools(ctx, tools, ownedSessions) {
|
|
5091
|
+
const attached = /* @__PURE__ */ new Map();
|
|
5092
|
+
let stopping = false;
|
|
5093
|
+
ctx.effect(() => {
|
|
5094
|
+
const stopCreated = ctx.on("agent/created", ({ agent }) => {
|
|
5095
|
+
if (stopping || attached.has(agent)) return;
|
|
5096
|
+
attach(agent);
|
|
5097
|
+
});
|
|
5098
|
+
for (const agent of ctx.agents.roots()) attach(agent);
|
|
5099
|
+
return async () => {
|
|
5100
|
+
stopping = true;
|
|
5101
|
+
stopCreated();
|
|
5102
|
+
const cleanups = [...attached.values()];
|
|
5103
|
+
attached.clear();
|
|
5104
|
+
await Promise.allSettled(cleanups.map((cleanup) => Promise.resolve(cleanup())));
|
|
5105
|
+
};
|
|
5106
|
+
}, "dsh-agent-toolkit.cron-tools()");
|
|
5107
|
+
function attach(agent) {
|
|
5108
|
+
if (attached.has(agent)) return;
|
|
5109
|
+
if (agent.session.header.origin === "subagent") return;
|
|
5110
|
+
if (ownedSessions.has(String(agent.session.id))) return;
|
|
5111
|
+
const cleanup = agent.ctx.effect(() => {
|
|
5112
|
+
const scope = scopeOf(agent.ctx);
|
|
5113
|
+
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(二选一)");
|
|
5114
|
+
const disposers = tools.map((tool) => agent.ctx.tools.register(tool));
|
|
5115
|
+
return () => {
|
|
5116
|
+
for (const dispose of disposers) dispose();
|
|
5117
|
+
if (attached.get(agent) === cleanup) attached.delete(agent);
|
|
5118
|
+
};
|
|
5119
|
+
}, "dsh-agent-toolkit.cron-tools.attach()");
|
|
5120
|
+
attached.set(agent, cleanup);
|
|
5121
|
+
}
|
|
5122
|
+
}
|
|
5123
|
+
//#endregion
|
|
5124
|
+
//#region src/schedule/index.ts
|
|
5125
|
+
/** schedule 模块接线:存储域 → executor/scheduler/service → cron_* 工具 + HTTP API + 30s tick。 */
|
|
5126
|
+
/** tick 间隔固定 30s(spec §8:不进 Config——YAGNI)。 */
|
|
5127
|
+
const TICK_MS = 3e4;
|
|
5128
|
+
function setupSchedule(ctx, config, deps) {
|
|
5129
|
+
const warn = (m) => ctx.logger.warn(m);
|
|
5130
|
+
const toolsScope = createToolsScope(ctx);
|
|
5131
|
+
const agentsPort = createAgentsPort(ctx, deps.botPresetId !== void 0 ? createScopeJoiner(ctx, deps.botPresetId, toolsScope, warn) : toolsScope, deps.ownedSessions);
|
|
5132
|
+
const workspaceRegistryOf = () => ctx.get("workspaceRegistry", false);
|
|
5133
|
+
const workspacePort = { async attach(cwd, sessionId) {
|
|
5134
|
+
const registry = workspaceRegistryOf();
|
|
5135
|
+
if (registry === void 0) throw new Error("workspaceRegistry 服务不可用");
|
|
5136
|
+
await (await registry.create(cwd)).attachSession(SessionId(sessionId));
|
|
5137
|
+
} };
|
|
5138
|
+
let scheduler;
|
|
5139
|
+
const started = openDomainSafely(ctx, scheduleDomain, warn).then(async (domain) => {
|
|
5140
|
+
const tasks = domain.table("tasks");
|
|
5141
|
+
const runs = domain.table("runs");
|
|
5142
|
+
const executor = createExecutor({
|
|
5143
|
+
agents: agentsPort,
|
|
5144
|
+
registry: deps.registry,
|
|
5145
|
+
defaultModel: () => {
|
|
5146
|
+
const selection = ctx.agentDefaultModel.currentSelection();
|
|
5147
|
+
return {
|
|
5148
|
+
provider: selection.provider,
|
|
5149
|
+
model: selection.model
|
|
5150
|
+
};
|
|
5151
|
+
},
|
|
5152
|
+
workspace: workspacePort,
|
|
5153
|
+
runs,
|
|
5154
|
+
runHistoryLimit: config.runHistoryLimit,
|
|
5155
|
+
runTimeoutMs: config.runTimeoutMinutes * 6e4,
|
|
5156
|
+
warn,
|
|
5157
|
+
now: () => Date.now(),
|
|
5158
|
+
newSessionId: () => randomUUID(),
|
|
5159
|
+
newRunId: () => randomUUID(),
|
|
5160
|
+
delay: (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
5161
|
+
});
|
|
5162
|
+
scheduler = createScheduler({
|
|
5163
|
+
tasks,
|
|
5164
|
+
runs,
|
|
5165
|
+
runHistoryLimit: config.runHistoryLimit,
|
|
5166
|
+
execute: (task) => executor.trigger(task),
|
|
5167
|
+
now: () => Date.now(),
|
|
5168
|
+
newRunId: () => randomUUID(),
|
|
5169
|
+
warn
|
|
5170
|
+
});
|
|
5171
|
+
const service = createCronService({
|
|
5172
|
+
tasks,
|
|
5173
|
+
runs,
|
|
5174
|
+
scheduler,
|
|
5175
|
+
registry: deps.registry,
|
|
5176
|
+
validateProject: (path) => existsSync(path),
|
|
5177
|
+
now: () => Date.now(),
|
|
5178
|
+
newTaskId: () => randomUUID()
|
|
5179
|
+
});
|
|
5180
|
+
setupCronTools(ctx, createCronTools(service), deps.ownedSessions);
|
|
5181
|
+
registerOptionalRoutes(ctx, (webCtx) => {
|
|
5182
|
+
const dispose = webCtx.webServer.register({
|
|
5183
|
+
kind: "prefix",
|
|
5184
|
+
path: "/dsh-agent-toolkit/api/cron",
|
|
5185
|
+
handler: async (req, res) => {
|
|
5186
|
+
try {
|
|
5187
|
+
await started;
|
|
5188
|
+
await createCronApiHandler({
|
|
5189
|
+
service,
|
|
5190
|
+
listProjects: () => workspaceRegistryOf()?.list().map((w) => w.path) ?? [],
|
|
5191
|
+
runHistoryLimit: config.runHistoryLimit
|
|
5192
|
+
})(req, res);
|
|
5193
|
+
} catch (error) {
|
|
5194
|
+
res.writeHead(500, { "content-type": "application/json" }).end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }));
|
|
5195
|
+
}
|
|
5196
|
+
}
|
|
5197
|
+
});
|
|
5198
|
+
return () => dispose();
|
|
5199
|
+
});
|
|
5200
|
+
await scheduler.rearmAll();
|
|
5201
|
+
});
|
|
5202
|
+
started.catch((error) => {
|
|
5203
|
+
warn(`[schedule] 启动失败:${error instanceof Error ? error.message : String(error)}`);
|
|
5204
|
+
});
|
|
5205
|
+
ctx.effect(() => {
|
|
5206
|
+
const timer = setInterval(() => {
|
|
5207
|
+
started.then(() => scheduler?.tick());
|
|
5208
|
+
}, TICK_MS);
|
|
5209
|
+
return () => clearInterval(timer);
|
|
5210
|
+
});
|
|
5211
|
+
ctx.effect(() => async () => {
|
|
5212
|
+
await toolsScope.dispose();
|
|
5213
|
+
});
|
|
3561
5214
|
}
|
|
3562
5215
|
//#endregion
|
|
3563
5216
|
//#region src/index.ts
|
|
@@ -3602,33 +5255,46 @@ const Config = z.object({
|
|
|
3602
5255
|
toolName: z.string().default("team_delegate"),
|
|
3603
5256
|
feishu: z.object({
|
|
3604
5257
|
cardUpdateThrottleMs: z.number().default(500),
|
|
3605
|
-
cardMaxBytes: z.number().default(
|
|
5258
|
+
cardMaxBytes: z.number().default(26e3),
|
|
5259
|
+
cardPrintStep: z.number().default(5),
|
|
3606
5260
|
processMaxBytes: z.number().default(8e3),
|
|
3607
5261
|
registerAppTimeoutMs: z.number().default(6e5),
|
|
3608
5262
|
processingReactionEmoji: z.string().default("OneSecond"),
|
|
3609
5263
|
errorDetailMaxChars: z.number().default(500),
|
|
3610
|
-
injectSender: z.boolean().default(true)
|
|
5264
|
+
injectSender: z.boolean().default(true),
|
|
5265
|
+
approval: z.boolean().default(true)
|
|
3611
5266
|
}).default({
|
|
3612
5267
|
cardUpdateThrottleMs: 500,
|
|
3613
|
-
cardMaxBytes:
|
|
5268
|
+
cardMaxBytes: 26e3,
|
|
5269
|
+
cardPrintStep: 5,
|
|
3614
5270
|
processMaxBytes: 8e3,
|
|
3615
5271
|
registerAppTimeoutMs: 6e5,
|
|
3616
5272
|
processingReactionEmoji: "OneSecond",
|
|
3617
5273
|
errorDetailMaxChars: 500,
|
|
3618
|
-
injectSender: true
|
|
5274
|
+
injectSender: true,
|
|
5275
|
+
approval: true
|
|
3619
5276
|
}),
|
|
3620
5277
|
agentTeamPreset: z.object({
|
|
3621
5278
|
enabled: z.boolean().default(true),
|
|
3622
5279
|
id: z.string().default("agent-team"),
|
|
3623
5280
|
source: z.string().default("standard"),
|
|
3624
5281
|
name: z.string().default("Agent 团队"),
|
|
3625
|
-
description: z.string().default("Agent 团队模式:禁用原生 subagent 工具族,委派统一走 team_delegate 团队角色")
|
|
5282
|
+
description: z.string().default("Agent 团队模式:禁用原生 subagent 工具族,委派统一走 team_delegate 团队角色"),
|
|
5283
|
+
botsId: z.string().default("agent-bot")
|
|
3626
5284
|
}).default({
|
|
3627
5285
|
enabled: true,
|
|
3628
5286
|
id: "agent-team",
|
|
3629
5287
|
source: "standard",
|
|
3630
5288
|
name: "Agent 团队",
|
|
3631
|
-
description: "Agent 团队模式:禁用原生 subagent 工具族,委派统一走 team_delegate 团队角色"
|
|
5289
|
+
description: "Agent 团队模式:禁用原生 subagent 工具族,委派统一走 team_delegate 团队角色",
|
|
5290
|
+
botsId: "agent-bot"
|
|
5291
|
+
}),
|
|
5292
|
+
schedule: z.object({
|
|
5293
|
+
runTimeoutMinutes: z.number().min(1).default(60),
|
|
5294
|
+
runHistoryLimit: z.natural().min(1).default(20)
|
|
5295
|
+
}).default({
|
|
5296
|
+
runTimeoutMinutes: 60,
|
|
5297
|
+
runHistoryLimit: 20
|
|
3632
5298
|
})
|
|
3633
5299
|
});
|
|
3634
5300
|
async function apply(ctx, config) {
|
|
@@ -3643,10 +5309,12 @@ async function apply(ctx, config) {
|
|
|
3643
5309
|
meta: domain.table("meta"),
|
|
3644
5310
|
promptLayers: domain.table("prompt_layers")
|
|
3645
5311
|
};
|
|
5312
|
+
await setupAgentTeamPreset(ctx, config.agentTeamPreset);
|
|
5313
|
+
const toolCatalog = createToolCatalog(ctx, config.agentTeamPreset.id);
|
|
3646
5314
|
const registry = await createRegistry(warn, {
|
|
3647
5315
|
agents: tables.agents,
|
|
3648
5316
|
meta: tables.meta
|
|
3649
|
-
});
|
|
5317
|
+
}, toolCatalog.listPresetTools);
|
|
3650
5318
|
const layerSource = await openLayerSource({
|
|
3651
5319
|
promptLayers: tables.promptLayers,
|
|
3652
5320
|
meta: tables.meta
|
|
@@ -3675,10 +5343,10 @@ async function apply(ctx, config) {
|
|
|
3675
5343
|
active: activeRoutes,
|
|
3676
5344
|
routes: routesTable
|
|
3677
5345
|
});
|
|
3678
|
-
const listTools = () => ctx.tools.schemas().map((s) => s.name);
|
|
3679
5346
|
setupAgentsApi(ctx, {
|
|
3680
5347
|
registry,
|
|
3681
|
-
listTools,
|
|
5348
|
+
listTools: toolCatalog.listGlobalTools,
|
|
5349
|
+
listPresetTools: toolCatalog.listPresetTools,
|
|
3682
5350
|
listProviders: () => ctx.llm.listProviders().map(({ id, name }) => ({
|
|
3683
5351
|
id,
|
|
3684
5352
|
name
|
|
@@ -3690,7 +5358,8 @@ async function apply(ctx, config) {
|
|
|
3690
5358
|
});
|
|
3691
5359
|
setupCreateAgentCommand(ctx, {
|
|
3692
5360
|
registry,
|
|
3693
|
-
listTools
|
|
5361
|
+
listTools: toolCatalog.listGlobalTools,
|
|
5362
|
+
listPresetTools: toolCatalog.listPresetTools
|
|
3694
5363
|
});
|
|
3695
5364
|
setupPromptLayersApi(ctx, {
|
|
3696
5365
|
source: layerSource,
|
|
@@ -3710,9 +5379,18 @@ async function apply(ctx, config) {
|
|
|
3710
5379
|
};
|
|
3711
5380
|
}
|
|
3712
5381
|
});
|
|
3713
|
-
|
|
3714
|
-
if (config.modules.feishu) setupBots(ctx, config.feishu, {
|
|
5382
|
+
const ownedSessions = /* @__PURE__ */ new Set();
|
|
5383
|
+
if (config.modules.feishu) setupBots(ctx, config.feishu, {
|
|
5384
|
+
registry,
|
|
5385
|
+
botPresetId: config.agentTeamPreset.enabled ? config.agentTeamPreset.botsId : void 0,
|
|
5386
|
+
ownedSessions
|
|
5387
|
+
});
|
|
3715
5388
|
if (config.modules.usage) setupUsage(ctx, { timezone: config.timezone }, name);
|
|
5389
|
+
setupSchedule(ctx, config.schedule, {
|
|
5390
|
+
registry,
|
|
5391
|
+
botPresetId: config.agentTeamPreset.enabled ? config.agentTeamPreset.botsId : void 0,
|
|
5392
|
+
ownedSessions
|
|
5393
|
+
});
|
|
3716
5394
|
}
|
|
3717
5395
|
//#endregion
|
|
3718
5396
|
export { Config, apply, inject, name };
|