hob-bot 1.0.4 → 1.2.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 +76 -6
- package/README.zh-CN.md +76 -6
- package/dist/web/assets/DesktopView-CEBJFAIN.js +5 -0
- package/dist/web/assets/index-Di5lyTHO.css +1 -0
- package/dist/web/assets/index-y09i1RGd.js +267 -0
- package/dist/web/assets/{math-zZ4JA8eM.js → math-weKGDUmL.js} +2 -2
- package/dist/web/index.html +2 -2
- package/dist/web/mock/desktop.jpg +0 -0
- package/dist/web/mock/photo.jpg +0 -0
- package/dist/web/mock/photo2.jpg +0 -0
- package/dist/web/mock/sunset.html +90 -0
- package/package.json +7 -1
- package/server/src/actions.ts +16 -56
- package/server/src/assistant-skills.ts +215 -0
- package/server/src/boot.ts +1 -0
- package/server/src/claude-status.ts +47 -0
- package/server/src/config.ts +3 -1
- package/server/src/delegations.ts +21 -11
- package/server/src/desktop-control.ts +152 -0
- package/server/src/desktop-events.ts +63 -0
- package/server/src/desktop-public.ts +43 -0
- package/server/src/desktop-tools.ts +161 -0
- package/server/src/desktop.ts +76 -0
- package/server/src/embedding.ts +52 -0
- package/server/src/files.ts +63 -43
- package/server/src/http.ts +325 -13
- package/server/src/job-view.ts +52 -0
- package/server/src/jobs.ts +326 -44
- package/server/src/knowledge-index.ts +123 -0
- package/server/src/knowledge-tools.ts +94 -0
- package/server/src/knowledge.ts +451 -0
- package/server/src/lore-entry.ts +10 -0
- package/server/src/main.ts +6 -0
- package/server/src/mcp.ts +745 -0
- package/server/src/memory.ts +11 -4
- package/server/src/now.ts +33 -0
- package/server/src/overview.ts +4 -4
- package/server/src/pages.ts +1 -1
- package/server/src/pi.ts +92 -16
- package/server/src/prompt.ts +51 -156
- package/server/src/providers.ts +3 -2
- package/server/src/push.ts +39 -5
- package/server/src/router-activity.ts +6 -0
- package/server/src/router-tools.ts +184 -39
- package/server/src/router.ts +154 -17
- package/server/src/routines.ts +14 -4
- package/server/src/runtime.ts +15 -0
- package/server/src/settings.ts +43 -1
- package/server/src/store.ts +4 -3
- package/server/src/transcript.ts +57 -4
- package/server/src/worker-tools.ts +14 -12
- package/shared/knowledge.ts +92 -0
- package/shared/mcp.ts +91 -0
- package/shared/skills.ts +18 -0
- package/shared/types.ts +104 -20
- package/vendor/claude-bridge/HOB-PATCH.md +10 -0
- package/vendor/claude-bridge/LICENSE +21 -0
- package/vendor/claude-bridge/package.json +62 -0
- package/vendor/claude-bridge/src/agents-md.ts +14 -0
- package/vendor/claude-bridge/src/askclaude-schema.ts +92 -0
- package/vendor/claude-bridge/src/askclaude-ui.ts +90 -0
- package/vendor/claude-bridge/src/attachments.ts +135 -0
- package/vendor/claude-bridge/src/config.ts +105 -0
- package/vendor/claude-bridge/src/convert.ts +222 -0
- package/vendor/claude-bridge/src/extract-tool-results.ts +47 -0
- package/vendor/claude-bridge/src/index.ts +2503 -0
- package/vendor/claude-bridge/src/log-paths.ts +19 -0
- package/vendor/claude-bridge/src/mcp-server.ts +169 -0
- package/vendor/claude-bridge/src/models.ts +164 -0
- package/vendor/claude-bridge/src/prompt-capture.ts +420 -0
- package/vendor/claude-bridge/src/prompt-stream.ts +102 -0
- package/vendor/claude-bridge/src/query-state.ts +118 -0
- package/vendor/claude-bridge/src/rate-limit.ts +48 -0
- package/vendor/claude-bridge/src/session-cwd.ts +38 -0
- package/vendor/claude-bridge/src/session-verify.ts +38 -0
- package/vendor/claude-bridge/src/skills.ts +20 -0
- package/vendor/claude-bridge/src/transcript.ts +86 -0
- package/vendor/claude-bridge/src/usage.ts +53 -0
- package/vendor/lore-pi-extension/package.json +1 -1
- package/vendor/lore-pi-extension/tools.ts +8 -5
- package/dist/web/assets/index-BCGEZvgg.css +0 -1
- package/dist/web/assets/index-BqkIAlk9.js +0 -258
package/server/src/jobs.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { homedir } from "node:os";
|
|
2
|
-
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
3
|
-
import { basename, extname } from "node:path";
|
|
2
|
+
import { copyFileSync, existsSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
3
|
+
import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
4
|
import type { AgentSession, SessionManager } from "@earendil-works/pi-coding-agent";
|
|
5
|
-
import type
|
|
5
|
+
import { Type, type ImageContent } from "@earendil-works/pi-ai";
|
|
6
6
|
import { commandSummary } from "../../shared/command.ts";
|
|
7
7
|
import type {
|
|
8
8
|
ActivityLine,
|
|
@@ -10,23 +10,29 @@ import type {
|
|
|
10
10
|
ApprovalOption,
|
|
11
11
|
Job,
|
|
12
12
|
JobsPage,
|
|
13
|
+
JobRound,
|
|
13
14
|
JobStatus,
|
|
14
15
|
JobStep,
|
|
15
16
|
RoutineRun,
|
|
16
17
|
} from "../../shared/types.ts";
|
|
17
18
|
import { IMAGE_TYPES, isImagePath } from "../../shared/media.ts";
|
|
18
19
|
import { newId } from "./ids.ts";
|
|
20
|
+
import { availableDesktopTools, type DesktopToolSession } from "./desktop-tools.ts";
|
|
19
21
|
import { imageConfigured } from "./image.ts";
|
|
22
|
+
import { boundKnowledge, findKnowledgeTool, searchKnowledgeTool } from "./knowledge-tools.ts";
|
|
23
|
+
import { MCP_SESSION_TOOLS, mcpEnabled, mcpServerDisplay } from "./mcp.ts";
|
|
20
24
|
import { jobModel } from "./models.ts";
|
|
25
|
+
import { duration, jobProcessLines } from "./job-view.ts";
|
|
21
26
|
import { overviewTool } from "./overview.ts";
|
|
22
27
|
import { pageTool } from "./pages.ts";
|
|
23
28
|
import { pushApproval, pushJobEnd } from "./push.ts";
|
|
24
29
|
import { buildWorkerPrompt, type Abilities, type PromptSettings } from "./prompt.ts";
|
|
25
30
|
import { availableSkills } from "./skills.ts";
|
|
31
|
+
import { enabledAssistantSkills, listAssistantSkills, readSkillFiles, recordAssistantSkillUse, saveAssistantSkill, usableAssistantSkills } from "./assistant-skills.ts";
|
|
26
32
|
import type { HobRuntime } from "./runtime.ts";
|
|
27
33
|
import { showPath } from "./text.ts";
|
|
28
34
|
import { monthDayTimeText } from "./time.ts";
|
|
29
|
-
import { STEP_EVENT_LIMIT, deriveSteps, textOfContent, toolStep, withOutput } from "./transcript.ts";
|
|
35
|
+
import { SEDIMENT_PROMPT_HEAD, STEP_EVENT_LIMIT, deriveSteps, textOfContent, toolStep, withOutput } from "./transcript.ts";
|
|
30
36
|
import { registerAskTool, registerImageTool, registerReportTool } from "./worker-tools.ts";
|
|
31
37
|
import { searchTools } from "./search.ts";
|
|
32
38
|
|
|
@@ -78,6 +84,7 @@ export interface StartJobInput {
|
|
|
78
84
|
model?: string;
|
|
79
85
|
thinking?: string;
|
|
80
86
|
routineId?: string;
|
|
87
|
+
expectMinutes?: number;
|
|
81
88
|
delegationId?: string;
|
|
82
89
|
}
|
|
83
90
|
|
|
@@ -85,22 +92,41 @@ interface WorkerState {
|
|
|
85
92
|
job: Job;
|
|
86
93
|
session?: AgentSession;
|
|
87
94
|
manager?: SessionManager;
|
|
88
|
-
reported?: { outcome: "done" | "stuck" | "need_approval";
|
|
95
|
+
reported?: { outcome: "done" | "stuck" | "need_approval"; result: string; files?: string[] };
|
|
89
96
|
lastText?: string;
|
|
90
97
|
askedFollowUp: boolean;
|
|
91
98
|
lastActivityAt: number;
|
|
99
|
+
pausedMs: number;
|
|
100
|
+
pausedAt?: number;
|
|
101
|
+
nextReminder: number;
|
|
92
102
|
finishTimer?: NodeJS.Timeout;
|
|
93
103
|
ask?: (label: string) => void;
|
|
94
104
|
/** 用户叫停了:收尾期间这个 worker 的 report/追问都不再算数 */
|
|
95
105
|
stopped?: boolean;
|
|
96
106
|
/** 正在跑的工具步骤,按 toolCallId 存:输出更新、跑完都按同一个步骤 id 替换 */
|
|
97
107
|
steps: Map<string, JobStep>;
|
|
108
|
+
readPaths: Map<string, string>;
|
|
98
109
|
/** 你补的话已交给任务、还没被读到(任务做完当前这一步才读);读到时换成正式的一行 */
|
|
99
110
|
queued: JobStep[];
|
|
111
|
+
/** 任务过程里 MCP 工具的显示名(转义后的服务名 → 配置里的原名) */
|
|
112
|
+
mcpServers: Record<string, string>;
|
|
113
|
+
desktop?: DesktopToolSession;
|
|
100
114
|
}
|
|
101
115
|
|
|
102
116
|
/** 推给助手的任务结果最多带这么多字的报告。 */
|
|
103
|
-
const EVENT_SUMMARY_MAX =
|
|
117
|
+
const EVENT_SUMMARY_MAX = 3000;
|
|
118
|
+
|
|
119
|
+
export function jobResultEvent(job: Job, prefix = `任务「${job.title}」`): string {
|
|
120
|
+
const status = job.status === "done" ? "已完成" : job.status === "stuck" ? "受阻" : job.status === "stopped" ? "已停止" : "失败";
|
|
121
|
+
const result = job.summary;
|
|
122
|
+
const header = job.expect && job.durationMs !== undefined
|
|
123
|
+
? `${prefix}(${job.id})${status},用时 ${duration(job.durationMs)},预计 ${job.expect.minutes} 分钟。`
|
|
124
|
+
: `${prefix}(${job.id})${status}。`;
|
|
125
|
+
if (!result) return `${header}\n${job.statusReason ?? "没有结果"}`;
|
|
126
|
+
return result.length > EVENT_SUMMARY_MAX
|
|
127
|
+
? `${header}\n结果(共 ${result.length} 字,以下是前 3,000 字;全文:${job.reportPath}):\n${result.slice(0, EVENT_SUMMARY_MAX)}`
|
|
128
|
+
: `${header}\n结果:\n${result}`;
|
|
129
|
+
}
|
|
104
130
|
|
|
105
131
|
function trim(value: string, max = 240): string {
|
|
106
132
|
const oneLine = value.replace(/\s+/g, " ").trim();
|
|
@@ -133,6 +159,10 @@ function activityFor(toolName: string, args: Record<string, unknown>, cwd = ""):
|
|
|
133
159
|
export class JobManager {
|
|
134
160
|
private workers = new Map<string, WorkerState>();
|
|
135
161
|
private queue: string[] = [];
|
|
162
|
+
private followUps = new Map<string, string>();
|
|
163
|
+
private finishing = new Map<string, Promise<void>>();
|
|
164
|
+
private sediments = new Map<string, { cancelled: boolean; session?: AgentSession; task?: Promise<void> }>();
|
|
165
|
+
private replied = new Set<string>();
|
|
136
166
|
private watchdog?: NodeJS.Timeout;
|
|
137
167
|
|
|
138
168
|
constructor(private readonly runtime: HobRuntime) {}
|
|
@@ -175,6 +205,7 @@ export class JobManager {
|
|
|
175
205
|
const steps = deriveSteps(this.store.workerFile(jobId), {
|
|
176
206
|
cwd: job.cwd ?? "",
|
|
177
207
|
save: (stepId, text) => this.store.writeStepOutput(jobId, stepId, text),
|
|
208
|
+
mcpServers: mcpServerDisplay(this.runtime.home),
|
|
178
209
|
});
|
|
179
210
|
return [...steps, ...(this.workers.get(jobId)?.queued ?? [])];
|
|
180
211
|
}
|
|
@@ -182,6 +213,18 @@ export class JobManager {
|
|
|
182
213
|
async start(assistantId: string, input: StartJobInput): Promise<Job> {
|
|
183
214
|
const assistant = this.store.assistant(assistantId);
|
|
184
215
|
if (!assistant) throw new Error(`助手不存在:${assistantId}`);
|
|
216
|
+
if (input.cwd?.trim()) {
|
|
217
|
+
const canonical = (path: string): string => {
|
|
218
|
+
const absolute = resolve(path);
|
|
219
|
+
let parent = absolute;
|
|
220
|
+
while (!existsSync(parent) && dirname(parent) !== parent) parent = dirname(parent);
|
|
221
|
+
return resolve(realpathSync(parent), relative(parent, absolute));
|
|
222
|
+
};
|
|
223
|
+
const path = relative(canonical(this.runtime.home), canonical(input.cwd));
|
|
224
|
+
if (!path || (path !== ".." && !path.startsWith(`..${sep}`) && !isAbsolute(path))) {
|
|
225
|
+
throw new Error("不能在 Hob 的数据目录里干活;不填 cwd 就在任务自己的目录里做。");
|
|
226
|
+
}
|
|
227
|
+
}
|
|
185
228
|
await this.runtime.models.get();
|
|
186
229
|
const requested =
|
|
187
230
|
input.model || input.thinking
|
|
@@ -195,6 +238,14 @@ export class JobManager {
|
|
|
195
238
|
...(hasImageFiles(input.files) ? { hasImages: true } : {}),
|
|
196
239
|
});
|
|
197
240
|
const id = this.nextJobId();
|
|
241
|
+
const durations = input.routineId ? (this.store.routine(input.routineId)?.runs ?? [])
|
|
242
|
+
.filter((run) => run.status === "done" && run.durationMs !== undefined && Number.isFinite(run.durationMs) && run.durationMs >= 0)
|
|
243
|
+
.slice(0, 5).map((run) => run.durationMs!).sort((a, b) => a - b) : [];
|
|
244
|
+
const middle = Math.floor(durations.length / 2);
|
|
245
|
+
const median = durations.length % 2 ? durations[middle] : (durations[middle - 1] + durations[middle]) / 2;
|
|
246
|
+
const expect: NonNullable<Job["expect"]> = input.routineId && durations.length
|
|
247
|
+
? { minutes: Math.max(1, Math.ceil(median / 60_000)), source: "routine", runs: durations.length }
|
|
248
|
+
: input.expectMinutes !== undefined ? { minutes: input.expectMinutes, source: "assistant" } : { minutes: 10, source: "default" };
|
|
198
249
|
const job: Job = {
|
|
199
250
|
id,
|
|
200
251
|
assistantId,
|
|
@@ -202,6 +253,7 @@ export class JobManager {
|
|
|
202
253
|
title: input.title,
|
|
203
254
|
instruction: input.instruction,
|
|
204
255
|
status: "queued",
|
|
256
|
+
expect,
|
|
205
257
|
createdAt: new Date().toISOString(),
|
|
206
258
|
stats: { reads: 0, commands: 0, edits: 0 },
|
|
207
259
|
activity: [],
|
|
@@ -215,8 +267,7 @@ export class JobManager {
|
|
|
215
267
|
};
|
|
216
268
|
this.store.saveJob(job);
|
|
217
269
|
if (job.delegationId) this.runtime.delegations.addJob(job.delegationId, job.id);
|
|
218
|
-
|
|
219
|
-
if (!input.routineId) await this.runtime.router.addItem(assistantId, { kind: "job", jobId: job.id });
|
|
270
|
+
await this.runtime.router.addItem(assistantId, { kind: "job", jobId: job.id });
|
|
220
271
|
this.bus.emit({ type: "job", job });
|
|
221
272
|
this.publishBusy(assistantId);
|
|
222
273
|
this.queue.push(job.id);
|
|
@@ -249,7 +300,9 @@ export class JobManager {
|
|
|
249
300
|
const id = this.queue.shift()!;
|
|
250
301
|
const job = this.store.job(id);
|
|
251
302
|
if (!job || job.status !== "queued") continue;
|
|
252
|
-
|
|
303
|
+
const followUp = this.followUps.get(id);
|
|
304
|
+
this.followUps.delete(id);
|
|
305
|
+
void this.launch(job, followUp).catch((error) => this.failLaunch(job, error));
|
|
253
306
|
}
|
|
254
307
|
}
|
|
255
308
|
|
|
@@ -258,7 +311,7 @@ export class JobManager {
|
|
|
258
311
|
await this.finish(job.id, "failed", undefined, message);
|
|
259
312
|
}
|
|
260
313
|
|
|
261
|
-
private async launch(job: Job): Promise<void> {
|
|
314
|
+
private async launch(job: Job, followUp?: string): Promise<void> {
|
|
262
315
|
const running: Job = {
|
|
263
316
|
...job,
|
|
264
317
|
status: "running",
|
|
@@ -274,28 +327,41 @@ export class JobManager {
|
|
|
274
327
|
...baseAbilities,
|
|
275
328
|
image: baseAbilities.image && imageConfigured(this.runtime.settings.settings.image),
|
|
276
329
|
};
|
|
277
|
-
const
|
|
330
|
+
const knowledge = await boundKnowledge(this.runtime, running.assistantId);
|
|
331
|
+
const customTools = [registerReportTool(this.runtime, running.id), registerAskTool(this.runtime, running.id), findKnowledgeTool(this.runtime)];
|
|
332
|
+
if (knowledge.length) customTools.push(searchKnowledgeTool(this.runtime, running.assistantId));
|
|
278
333
|
if (abilities.image) customTools.push(registerImageTool(this.runtime, running.id));
|
|
279
334
|
if (this.runtime.settings.settings.general.showPage !== false) customTools.push(pageTool(this.runtime, running.assistantId, false));
|
|
280
335
|
customTools.push(...searchTools(this.runtime.home, () => this.runtime.settings.settings.search));
|
|
336
|
+
const desktop = await availableDesktopTools(this.runtime, running.assistantId, !!this.runtime.models.find(running.model ?? this.runtime.settings.settings.models.work.ref)?.input.includes("image"));
|
|
337
|
+
if (this.store.job(running.id)?.status !== "running") { await desktop?.dispose(); return; }
|
|
338
|
+
if (desktop) customTools.push(...desktop.tools());
|
|
281
339
|
// 定时跑的就是 Worker:主助手的任务要看得到各助这段时间的动静才能写早报
|
|
282
340
|
if (assistant?.primary) customTools.push(overviewTool(this.runtime));
|
|
341
|
+
// MCP:配置里有启用中的服务才加载(改完配置,之后新开的任务生效)
|
|
342
|
+
const mcp = mcpEnabled(this.runtime.home);
|
|
343
|
+
const ownSkills = enabledAssistantSkills(this.runtime.home, running.assistantId);
|
|
344
|
+
const ownNames = new Set(ownSkills.map((skill) => skill.name));
|
|
283
345
|
const opened = await this.runtime.pi.open({
|
|
284
346
|
file: this.store.workerFile(running.id),
|
|
285
347
|
cwd: running.cwd ?? homedir(),
|
|
286
348
|
model: running.model ?? this.runtime.settings.settings.models.work.ref,
|
|
287
349
|
thinking: running.thinking ?? this.runtime.settings.settings.models.work.thinking,
|
|
288
350
|
memory: this.runtime.settings.memory().provider,
|
|
289
|
-
skills: availableSkills(this.runtime.home, undefined, this.runtime.settings.settings.skills?.disabled),
|
|
351
|
+
skills: [...ownSkills, ...availableSkills(this.runtime.home, undefined, this.runtime.settings.settings.skills?.disabled).filter((skill) => !ownNames.has(skill.name))],
|
|
290
352
|
disabledSkills: this.runtime.settings.settings.skills?.disabled,
|
|
291
353
|
systemPrompt: () =>
|
|
292
|
-
buildWorkerPrompt(this.store, running.id, { ...this.promptSettings(), abilities }),
|
|
354
|
+
buildWorkerPrompt(this.store, running.id, { ...this.promptSettings(), abilities, knowledgeNames: knowledge.map((collection) => collection.name) }),
|
|
293
355
|
tools: [
|
|
294
356
|
...workerTools(abilities),
|
|
295
357
|
...customTools.map((tool) => tool.name),
|
|
358
|
+
...(mcp ? MCP_SESSION_TOOLS : []),
|
|
296
359
|
],
|
|
297
360
|
customTools,
|
|
361
|
+
mcp,
|
|
298
362
|
});
|
|
363
|
+
if (this.store.job(running.id)?.status !== "running") { opened.session.dispose(); await desktop?.dispose(); return; }
|
|
364
|
+
const mcpServers = mcpServerDisplay(this.runtime.home);
|
|
299
365
|
|
|
300
366
|
const worker: WorkerState = {
|
|
301
367
|
job: running,
|
|
@@ -303,14 +369,26 @@ export class JobManager {
|
|
|
303
369
|
manager: opened.manager,
|
|
304
370
|
askedFollowUp: false,
|
|
305
371
|
lastActivityAt: Date.now(),
|
|
372
|
+
pausedMs: 0,
|
|
373
|
+
nextReminder: running.expect?.source === "routine" ? 1.5 : 1,
|
|
306
374
|
steps: new Map(),
|
|
375
|
+
readPaths: new Map(),
|
|
307
376
|
queued: [],
|
|
377
|
+
mcpServers,
|
|
378
|
+
desktop,
|
|
308
379
|
};
|
|
309
380
|
this.workers.set(running.id, worker);
|
|
310
|
-
|
|
381
|
+
if (followUp !== undefined) {
|
|
382
|
+
const step: JobStep = { id: `q${Date.now()}-0`, kind: "text", at: new Date().toISOString(), from: "user", text: followUp, queued: true };
|
|
383
|
+
worker.queued.push(step);
|
|
384
|
+
this.emitStep(worker, step);
|
|
385
|
+
}
|
|
386
|
+
const images = followUp === undefined ? imageContents(running.inputs) : [];
|
|
311
387
|
|
|
312
388
|
opened.session.subscribe((event) => {
|
|
389
|
+
if (worker.stopped) return;
|
|
313
390
|
if (event.type === "tool_execution_start") {
|
|
391
|
+
if (event.toolName === "read" && typeof event.args?.path === "string") worker.readPaths.set(event.toolCallId, event.args.path);
|
|
314
392
|
this.recordActivity(worker, event.toolName, event.args ?? {});
|
|
315
393
|
const step = toolStep({
|
|
316
394
|
id: event.toolCallId,
|
|
@@ -318,6 +396,7 @@ export class JobManager {
|
|
|
318
396
|
args: event.args ?? {},
|
|
319
397
|
at: new Date().toISOString(),
|
|
320
398
|
cwd: worker.job.cwd ?? "",
|
|
399
|
+
mcpServers: worker.mcpServers,
|
|
321
400
|
running: true,
|
|
322
401
|
});
|
|
323
402
|
worker.steps.set(event.toolCallId, step);
|
|
@@ -330,9 +409,17 @@ export class JobManager {
|
|
|
330
409
|
return;
|
|
331
410
|
}
|
|
332
411
|
if (event.type === "tool_execution_end") {
|
|
412
|
+
if (event.toolName === "read" && !event.isError) {
|
|
413
|
+
const readPath = worker.readPaths.get(event.toolCallId);
|
|
414
|
+
if (readPath) {
|
|
415
|
+
try { this.recordSkillUse(worker, readPath); }
|
|
416
|
+
catch (error) { console.error("记录技能用量失败:", error); }
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
worker.readPaths.delete(event.toolCallId);
|
|
333
420
|
const step =
|
|
334
421
|
worker.steps.get(event.toolCallId) ??
|
|
335
|
-
toolStep({ id: event.toolCallId, name: event.toolName, args: {}, cwd: worker.job.cwd ?? "" });
|
|
422
|
+
toolStep({ id: event.toolCallId, name: event.toolName, args: {}, cwd: worker.job.cwd ?? "", mcpServers: worker.mcpServers });
|
|
336
423
|
worker.steps.delete(event.toolCallId);
|
|
337
424
|
this.emitStep(
|
|
338
425
|
worker,
|
|
@@ -368,12 +455,28 @@ export class JobManager {
|
|
|
368
455
|
});
|
|
369
456
|
|
|
370
457
|
void opened.session
|
|
371
|
-
.prompt(running.instruction, images.length ? { images } : undefined)
|
|
458
|
+
.prompt(followUp ?? running.instruction, images.length ? { images } : undefined)
|
|
372
459
|
.catch(async (error: unknown) => {
|
|
373
|
-
await this.finish(running.id, "failed", undefined, error instanceof Error ? error.message : String(error));
|
|
460
|
+
if (!worker.stopped) await this.finish(running.id, "failed", undefined, error instanceof Error ? error.message : String(error));
|
|
374
461
|
});
|
|
375
462
|
}
|
|
376
463
|
|
|
464
|
+
private recordSkillUse(worker: WorkerState, path: string): void {
|
|
465
|
+
const job = this.store.job(worker.job.id);
|
|
466
|
+
if (!job) return;
|
|
467
|
+
let absolute: string;
|
|
468
|
+
try { absolute = realpathSync(resolve(job.cwd ?? homedir(), path)); }
|
|
469
|
+
catch { return; }
|
|
470
|
+
const skill = usableAssistantSkills(this.runtime.home, job.assistantId).find((item) => existsSync(item.path) && realpathSync(item.path) === absolute);
|
|
471
|
+
if (!skill || job.skillsUsed?.includes(skill.name)) return;
|
|
472
|
+
recordAssistantSkillUse(this.runtime.home, job.assistantId, skill.name);
|
|
473
|
+
const next = { ...job, skillsUsed: [...(job.skillsUsed ?? []), skill.name] };
|
|
474
|
+
this.store.saveJob(next);
|
|
475
|
+
worker.job = next;
|
|
476
|
+
this.bus.emit({ type: "job", job: next });
|
|
477
|
+
this.bus.emit({ type: "skills", assistantId: job.assistantId });
|
|
478
|
+
}
|
|
479
|
+
|
|
377
480
|
private recordActivity(worker: WorkerState, toolName: string, args: Record<string, unknown>): void {
|
|
378
481
|
worker.lastActivityAt = Date.now();
|
|
379
482
|
const line = activityFor(toolName, args);
|
|
@@ -467,7 +570,7 @@ export class JobManager {
|
|
|
467
570
|
await this.finish(worker.job.id, "stuck", undefined, "未提交结果");
|
|
468
571
|
return;
|
|
469
572
|
}
|
|
470
|
-
await session.prompt("你还没有调用 report 提交结果。请现在调用 report(outcome,
|
|
573
|
+
await session.prompt("你还没有调用 report 提交结果。请现在调用 report(outcome, result)。");
|
|
471
574
|
}
|
|
472
575
|
|
|
473
576
|
/** report 工具收到结果后由 worker-tools 调用。 */
|
|
@@ -477,10 +580,10 @@ export class JobManager {
|
|
|
477
580
|
const status: JobStatus = worker.reported.outcome === "done" ? "done" : "stuck";
|
|
478
581
|
// 不写原因:outcome 只说明模型自己认为没做完,状态词(受阻、待确认)卡片上已经写过一遍,
|
|
479
582
|
// 当原因存下来就会重复。真正的原因由看门狗和停止操作给。
|
|
480
|
-
await this.finish(workerId, status, worker.reported.
|
|
583
|
+
await this.finish(workerId, status, worker.reported.result, undefined, worker.reported.files);
|
|
481
584
|
}
|
|
482
585
|
|
|
483
|
-
reportFromTool(jobId: string, data: { outcome: "done" | "stuck" | "need_approval";
|
|
586
|
+
reportFromTool(jobId: string, data: { outcome: "done" | "stuck" | "need_approval"; result: string; files?: string[] }): void {
|
|
484
587
|
const worker = this.workers.get(jobId);
|
|
485
588
|
if (!worker) return;
|
|
486
589
|
worker.reported = data;
|
|
@@ -494,10 +597,28 @@ export class JobManager {
|
|
|
494
597
|
reason?: string,
|
|
495
598
|
files?: string[],
|
|
496
599
|
): Promise<void> {
|
|
600
|
+
const pending = this.finishing.get(jobId);
|
|
601
|
+
if (pending) return pending;
|
|
602
|
+
const task = this.finishOnce(jobId, status, summary, reason, files);
|
|
603
|
+
this.finishing.set(jobId, task);
|
|
604
|
+
try { await task; } finally { this.finishing.delete(jobId); }
|
|
605
|
+
const job = this.store.job(jobId);
|
|
606
|
+
const steps = status === "done" && job?.status === "done" ? this.transcript(jobId) : [];
|
|
607
|
+
// 自动沉淀:用户中途接着说过话;或者没照着技能做、而且是定时执行或动手 20 次以上。
|
|
608
|
+
// 照着技能顺利做完的不再沉淀,免得每天跑完都改一版。
|
|
609
|
+
const followed = this.replied.has(jobId) || steps.some((step) => step.from === "user" && step.text !== "沉淀技能");
|
|
610
|
+
const fresh = !job?.skillsUsed?.length && (Boolean(job?.routineId) || steps.filter((step) => step.kind === "tool").length >= 20);
|
|
611
|
+
if (job?.status === "done" && status === "done" && (followed || fresh)) this.startSediment(jobId);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
private async finishOnce(jobId: string, status: JobStatus, summary?: string, reason?: string, files?: string[]): Promise<void> {
|
|
497
615
|
const job = this.store.job(jobId);
|
|
498
616
|
if (!job || ENDED.includes(job.status)) return;
|
|
499
617
|
const worker = this.workers.get(jobId);
|
|
618
|
+
if (worker) worker.stopped = true;
|
|
500
619
|
if (worker?.finishTimer) clearTimeout(worker.finishTimer);
|
|
620
|
+
this.followUps.delete(jobId);
|
|
621
|
+
await worker?.desktop?.dispose();
|
|
501
622
|
await this.settle(job, status, { summary, reason, files, lastText: worker?.lastText });
|
|
502
623
|
|
|
503
624
|
if (worker?.session) {
|
|
@@ -515,10 +636,7 @@ export class JobManager {
|
|
|
515
636
|
): Promise<void> {
|
|
516
637
|
const endedAt = new Date().toISOString();
|
|
517
638
|
// 报告原样保留(不压成一行、不截断):任务详情和 report.md 要完整的;只有推给助手的事件太长时才截
|
|
518
|
-
const cleanSummary = options.summary?.trim()
|
|
519
|
-
const eventSummary = cleanSummary && cleanSummary.length > EVENT_SUMMARY_MAX
|
|
520
|
-
? `${cleanSummary.slice(0, EVENT_SUMMARY_MAX)}…(报告过长已截断,完整内容在任务详情)`
|
|
521
|
-
: cleanSummary;
|
|
639
|
+
const cleanSummary = options.summary?.trim() ? options.summary : undefined;
|
|
522
640
|
const statusText = status === "done" ? "已完成" : status === "stuck" ? "受阻" : status === "stopped" ? "已停止" : "失败";
|
|
523
641
|
const reportPath = this.store.writeReport(
|
|
524
642
|
job.id,
|
|
@@ -528,13 +646,9 @@ export class JobManager {
|
|
|
528
646
|
`状态:${statusText}${options.reason ? `(${options.reason})` : ""}`,
|
|
529
647
|
`结束时间:${monthDayTimeText(this.runtime.settings.settings.general.timezone)}`,
|
|
530
648
|
"",
|
|
531
|
-
"##
|
|
649
|
+
"## 结果",
|
|
532
650
|
"",
|
|
533
|
-
cleanSummary ?? "
|
|
534
|
-
"",
|
|
535
|
-
"## 任务最后一条回复",
|
|
536
|
-
"",
|
|
537
|
-
options.lastText ?? "(没有回复)",
|
|
651
|
+
cleanSummary ?? options.lastText ?? "(没有回复)",
|
|
538
652
|
"",
|
|
539
653
|
"## 任务说明",
|
|
540
654
|
"",
|
|
@@ -547,11 +661,12 @@ export class JobManager {
|
|
|
547
661
|
endedAt,
|
|
548
662
|
reportPath,
|
|
549
663
|
...(job.startedAt ? { durationMs: Date.parse(endedAt) - Date.parse(job.startedAt) } : {}),
|
|
550
|
-
...(
|
|
664
|
+
...(options.summary !== undefined ? { summary: cleanSummary } : {}),
|
|
551
665
|
...(options.reason ? { statusReason: options.reason } : {}),
|
|
552
|
-
...(options.
|
|
666
|
+
...(options.summary !== undefined ? { files: options.files ?? [] } : {}),
|
|
553
667
|
};
|
|
554
668
|
this.store.saveJob(next);
|
|
669
|
+
for (const path of options.files ?? []) void this.runtime.knowledge.collect({ origin: "output", path, description: job.title, assistantId: job.assistantId, jobId: job.id }).catch((error) => console.error("知识库收集失败:", error));
|
|
555
670
|
// 转交里的任务:先把转交重新打开,结果由目标助手接着处理完才回流 Hob(见 DelegationManager.resume)
|
|
556
671
|
const delegationId = !job.routineId && job.delegationId && this.runtime.delegations.resume(job.delegationId) ? job.delegationId : undefined;
|
|
557
672
|
this.bus.emit({ type: "job", job: next });
|
|
@@ -571,20 +686,25 @@ export class JobManager {
|
|
|
571
686
|
if (job.routineId) {
|
|
572
687
|
const routine = this.store.routine(job.routineId);
|
|
573
688
|
if (routine) {
|
|
574
|
-
const run: RoutineRun = {
|
|
689
|
+
const run: RoutineRun = {
|
|
690
|
+
jobId: job.id, at: endedAt, status,
|
|
691
|
+
...(next.durationMs !== undefined ? { durationMs: next.durationMs } : {}),
|
|
692
|
+
tools: next.stats.reads + next.stats.commands + next.stats.edits,
|
|
693
|
+
...(next.usage ? { tokens: next.usage.input + next.usage.output } : {}),
|
|
694
|
+
};
|
|
575
695
|
const runs = [run, ...routine.runs.filter((item) => item.jobId !== job.id)].slice(0, 20);
|
|
576
696
|
const updated = { ...routine, runs };
|
|
577
697
|
this.store.saveRoutine(updated);
|
|
578
698
|
this.bus.emit({ type: "routine", routine: updated });
|
|
579
699
|
await this.runtime.router
|
|
580
|
-
.event(job.assistantId, `定时任务「${routine.name}
|
|
581
|
-
routine: { id: routine.id, name: routine.name },
|
|
700
|
+
.event(job.assistantId, `${jobResultEvent(next, `定时任务「${routine.name}」`)}${filesNote}`, {
|
|
701
|
+
routine: { id: routine.id, name: routine.name, jobId: job.id },
|
|
582
702
|
...cont,
|
|
583
703
|
})
|
|
584
704
|
.catch(() => undefined);
|
|
585
705
|
}
|
|
586
706
|
} else if (assistant) {
|
|
587
|
-
const text =
|
|
707
|
+
const text = `${jobResultEvent(next)}${filesNote}`;
|
|
588
708
|
if (delegationId) void this.runtime.router.relay(job.assistantId, text, delegationId, cont).catch(() => this.runtime.delegations.turnEnded(delegationId, "目标助手处理失败", true));
|
|
589
709
|
else await this.runtime.router.event(job.assistantId, text, cont).catch(() => undefined);
|
|
590
710
|
}
|
|
@@ -612,14 +732,60 @@ export class JobManager {
|
|
|
612
732
|
}
|
|
613
733
|
}
|
|
614
734
|
const lines: string[] = [];
|
|
615
|
-
if (posted.length) lines.push(
|
|
616
|
-
if (others.length) lines.push(
|
|
735
|
+
if (posted.length) lines.push(`图片已发到对话里:${posted.join("、")}`);
|
|
736
|
+
if (others.length) lines.push(`文件:${others.join("、")}`);
|
|
617
737
|
return { note: lines.length ? `\n${lines.join("\n")}` : "", posted: posted.length };
|
|
618
738
|
}
|
|
619
739
|
|
|
620
|
-
async reply(jobId: string, text: string): Promise<void> {
|
|
740
|
+
async reply(jobId: string, text: string, source: "user" | "router" = "user"): Promise<void> {
|
|
741
|
+
await this.finishing.get(jobId);
|
|
742
|
+
const sediment = this.sediments.get(jobId);
|
|
743
|
+
if (sediment) {
|
|
744
|
+
sediment.cancelled = true;
|
|
745
|
+
await sediment.session?.abort().catch(() => undefined);
|
|
746
|
+
await sediment.task;
|
|
747
|
+
}
|
|
748
|
+
this.replied.add(jobId);
|
|
749
|
+
const job = this.store.job(jobId);
|
|
750
|
+
if (!job) throw new Error("没有这个任务。");
|
|
751
|
+
if (ENDED.includes(job.status)) {
|
|
752
|
+
const { endedAt, statusReason, durationMs, summary, files, reportPath, ...rest } = job;
|
|
753
|
+
const previous = job.rounds ?? [];
|
|
754
|
+
// 这一轮的报告另存一份:下一轮写报告会覆盖原文件。原文件不在了就不存,不让续做因此失败
|
|
755
|
+
const savedReport = reportPath && existsSync(reportPath) ? join(dirname(reportPath), `report.round-${previous.length + 1}.md`) : undefined;
|
|
756
|
+
if (reportPath && savedReport) copyFileSync(reportPath, savedReport);
|
|
757
|
+
const used = previous.reduce((sum, round) => ({ input: sum.input + (round.usage?.input ?? 0), output: sum.output + (round.usage?.output ?? 0), cost: sum.cost + (round.usage?.cost ?? 0) }), { input: 0, output: 0, cost: 0 });
|
|
758
|
+
const usage = job.usage && previous.every((round) => round.usage) ? { input: job.usage.input - used.input, output: job.usage.output - used.output, cost: Number((job.usage.cost - used.cost).toFixed(6)) } : undefined;
|
|
759
|
+
const ended: JobRound = { status: job.status, statusReason, startedAt: job.startedAt, endedAt, durationMs, summary, files, usage, reportPath: savedReport, followUp: text };
|
|
760
|
+
const rounds = [...previous, ended];
|
|
761
|
+
const queued: Job = { ...rest, rounds, status: "queued", expect: { minutes: 10, source: "default" }, startedAt: undefined };
|
|
762
|
+
this.store.saveJob(queued);
|
|
763
|
+
try {
|
|
764
|
+
await this.runtime.router.addItem(job.assistantId, { kind: "job", jobId, round: rounds.length });
|
|
765
|
+
} catch (error) {
|
|
766
|
+
this.store.saveJob(job);
|
|
767
|
+
throw error;
|
|
768
|
+
}
|
|
769
|
+
this.followUps.set(jobId, `用户接着说(${monthDayTimeText(this.runtime.settings.settings.general.timezone)}):${text}`);
|
|
770
|
+
this.bus.emit({ type: "job", job: queued });
|
|
771
|
+
this.publishBusy(job.assistantId);
|
|
772
|
+
this.queue.push(jobId);
|
|
773
|
+
this.pump();
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
621
776
|
const worker = this.workers.get(jobId);
|
|
622
777
|
if (!worker?.session) throw new Error("这个任务当前未在运行。");
|
|
778
|
+
// 任务卡在 ask_user 等答复:用户在对话里回的话就是答复,不然插话要等提问返回才读得到,任务永远等下去
|
|
779
|
+
const asking = worker.ask ? this.store.approvals().find((item) => item.jobId === jobId && item.status === "pending") : undefined;
|
|
780
|
+
if (asking) {
|
|
781
|
+
const answered: Approval = source === "user"
|
|
782
|
+
? { ...asking, status: "answered", answerText: text, answeredAt: new Date().toISOString() }
|
|
783
|
+
: { ...asking, status: "expired" };
|
|
784
|
+
this.store.saveApproval(answered);
|
|
785
|
+
this.bus.emit({ type: "approval", approval: answered });
|
|
786
|
+
this.resolveAsk(answered, source === "user" ? `用户没有选给出的选项,而是在对话里回复:${text}` : `助手转达:${text}`);
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
623
789
|
// 先显示出来:任务在跑命令时要等这一步做完才读到,界面上标着「等待读取」
|
|
624
790
|
const step: JobStep = { id: `q${Date.now()}-${worker.queued.length}`, kind: "text", at: new Date().toISOString(), from: "user", text, queued: true };
|
|
625
791
|
worker.queued.push(step);
|
|
@@ -633,7 +799,101 @@ export class JobManager {
|
|
|
633
799
|
}
|
|
634
800
|
}
|
|
635
801
|
|
|
636
|
-
|
|
802
|
+
startSediment(jobId: string, source: "manual" | "auto" = "auto"): void {
|
|
803
|
+
const job = this.store.job(jobId);
|
|
804
|
+
if (!job) throw new Error("任务不存在");
|
|
805
|
+
if (job.status !== "done" || this.finishing.has(jobId) || this.workers.has(jobId)) throw new Error("只有已完成的任务可以沉淀");
|
|
806
|
+
if (this.sediments.has(jobId)) throw new Error("任务正在沉淀技能");
|
|
807
|
+
const state: { cancelled: boolean; session?: AgentSession; task?: Promise<void> } = { cancelled: false };
|
|
808
|
+
this.sediments.set(jobId, state);
|
|
809
|
+
state.task = this.sedimentRound(job, state, source).catch(async (error) => {
|
|
810
|
+
if (state.cancelled) return;
|
|
811
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
812
|
+
console.error("沉淀技能失败:", error);
|
|
813
|
+
await this.runtime.router.addItem(job.assistantId, { kind: "note", icon: "info", text: `技能沉淀失败:${message}` }).catch((noteError) => console.error("写入沉淀失败提示失败:", noteError));
|
|
814
|
+
}).finally(() => {
|
|
815
|
+
this.sediments.delete(jobId);
|
|
816
|
+
this.bus.emit({ type: "sediment-finished", jobId });
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
private async sedimentRound(job: Job, state: { cancelled: boolean; session?: AgentSession }, source: "manual" | "auto"): Promise<void> {
|
|
821
|
+
let calledSave = false;
|
|
822
|
+
const savedSkills = new Set<string>();
|
|
823
|
+
let lastText = "";
|
|
824
|
+
const skillTool = {
|
|
825
|
+
name: "save_skill", label: "保存技能",
|
|
826
|
+
description: "保存或修补这个助手的技能。name 只用小写字母、数字和连字符,和已有技能同名就是修补,旧版会保留。content 是 SKILL.md 的正文,不用写开头的 name 和 description。files 放脚本和参考资料,路径以 scripts/ 或 references/ 开头。",
|
|
827
|
+
parameters: Type.Object({
|
|
828
|
+
name: Type.String({ description: "技能的目录名,如 gateway-daily-check。" }),
|
|
829
|
+
title: Type.String({ description: "中文名,如「网关每日巡检」。" }),
|
|
830
|
+
description: Type.String({ description: "一句话写什么时候用,120 字以内。" }),
|
|
831
|
+
content: Type.String({ description: "SKILL.md 正文。" }),
|
|
832
|
+
files: Type.Optional(Type.Array(Type.Object({ path: Type.String(), content: Type.Optional(Type.String()), from: Type.Optional(Type.String()) }), { description: "可选,脚本和参考资料:path 加 content,或 path 加 from(工作目录里已有文件的路径,直接复制)。" })),
|
|
833
|
+
}),
|
|
834
|
+
execute: async (_id: string, params: any) => {
|
|
835
|
+
calledSave = true;
|
|
836
|
+
if (state.cancelled || this.store.job(job.id)?.status !== "done") throw new Error("任务已继续,取消沉淀");
|
|
837
|
+
const files = readSkillFiles(job.cwd ?? this.store.workDir(job.id), params.files);
|
|
838
|
+
const overwriteVersion = savedSkills.has(params.name);
|
|
839
|
+
const skill = saveAssistantSkill(this.runtime.home, job.assistantId, { ...params, files }, job.id, false, { overwriteVersion });
|
|
840
|
+
savedSkills.add(skill.name);
|
|
841
|
+
if (!overwriteVersion) {
|
|
842
|
+
let text = skill.version === 1 ? `已沉淀技能「${skill.title}」` : `已更新技能「${skill.title}」· 第 ${skill.version} 版`;
|
|
843
|
+
const routine = job.routineId && this.store.routine(job.routineId);
|
|
844
|
+
if (routine && !routine.skill) {
|
|
845
|
+
this.runtime.routines.update(routine.id, { skill: skill.name });
|
|
846
|
+
text = `已沉淀技能「${skill.title}」,以后定时执行按它做`;
|
|
847
|
+
}
|
|
848
|
+
await this.runtime.router.addItem(job.assistantId, { kind: "note", icon: "skill", text, path: skill.path });
|
|
849
|
+
}
|
|
850
|
+
this.bus.emit({ type: "skills", assistantId: job.assistantId });
|
|
851
|
+
return { content: [{ type: "text" as const, text: "技能已保存。" }], details: undefined };
|
|
852
|
+
},
|
|
853
|
+
};
|
|
854
|
+
const skills = listAssistantSkills(this.runtime.home, job.assistantId);
|
|
855
|
+
const existing = skills.map((skill) => `${skill.name}:${skill.title} —— ${skill.description}`).join("\n") || "无";
|
|
856
|
+
const message = `${SEDIMENT_PROMPT_HEAD},下次照着做,更快、更稳。
|
|
857
|
+
|
|
858
|
+
技能写的是这一类事怎么做,不是这一次的经过。下次接到同类的事,照着它就能一次做成用户要的样子。
|
|
859
|
+
- 按做事的顺序写步骤,带上要用的命令、查询和判断点;怎么判断结果对不对,写在对应的那一步里。
|
|
860
|
+
- 用户在过程中纠正过的做法、对结果提过的要求,写进去。
|
|
861
|
+
- 坑写成一条规则加一句原因,放在它影响的那一步。规则要离开这一次也成立:不写事情经过和这一次的数据;会变的情况不写死,写怎么查。
|
|
862
|
+
- 同一条经验只写一次。修补时直接改掉写错的那句,不在后面追加更正。
|
|
863
|
+
- 助手的规矩、工具说明里已经有的,不再重复。
|
|
864
|
+
- 能写成脚本的写成只读脚本,放在 scripts/,一次输出要看的全部结果。写好后用 bash 真跑一遍,输出对得上再保存。偶尔才用得上的细节放 references/,按主题起文件名。
|
|
865
|
+
- 会改东西的步骤只写进文档,不写进脚本,也不要执行。
|
|
866
|
+
|
|
867
|
+
不写进技能:
|
|
868
|
+
- 临时故障和网络错误。环境没装好、没配好导致的失败,只写怎么装好、配好。
|
|
869
|
+
- 「某个工具不能用」这类结论:环境修好以后,它还会一直挡着你。
|
|
870
|
+
- 这次没走通的尝试:不能写成推荐做法。
|
|
871
|
+
|
|
872
|
+
下面已有同类技能的,修补它,不要另建;新建时,名字和说明按这一类事来起,不按这一次。没有值得沉淀的,就回复「没有需要沉淀的」,不调用 save_skill。
|
|
873
|
+
|
|
874
|
+
这个助手已有的技能:${existing}`;
|
|
875
|
+
if (state.cancelled) return;
|
|
876
|
+
const opened = await this.runtime.pi.open({
|
|
877
|
+
file: this.store.workerFile(job.id), cwd: job.cwd ?? homedir(),
|
|
878
|
+
model: job.model ?? this.runtime.settings.settings.models.work.ref,
|
|
879
|
+
thinking: job.thinking ?? this.runtime.settings.settings.models.work.thinking,
|
|
880
|
+
memory: "off", systemPrompt: () => buildWorkerPrompt(this.store, job.id, this.promptSettings()),
|
|
881
|
+
tools: ["read", "ls", "grep", "find", "bash", "save_skill"], customTools: [skillTool],
|
|
882
|
+
});
|
|
883
|
+
state.session = opened.session;
|
|
884
|
+
opened.session.subscribe((event) => {
|
|
885
|
+
if (event.type === "message_end" && event.message.role === "assistant") lastText = textOfContent(event.message.content).trim();
|
|
886
|
+
});
|
|
887
|
+
try {
|
|
888
|
+
if (!state.cancelled) await opened.session.prompt(message);
|
|
889
|
+
if (!state.cancelled && source === "manual" && !calledSave) {
|
|
890
|
+
const reason = lastText && lastText !== "没有需要沉淀的" ? `:${lastText}` : "";
|
|
891
|
+
await this.runtime.router.addItem(job.assistantId, { kind: "note", icon: "skill", text: `「${job.title}」没有可沉淀的做法${reason}` });
|
|
892
|
+
}
|
|
893
|
+
} finally { opened.session.dispose(); }
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
async stop(jobId: string, reason = "你叫停了"): Promise<void> {
|
|
637
897
|
const job = this.store.job(jobId);
|
|
638
898
|
if (!job) throw new Error("没有这个任务。");
|
|
639
899
|
const worker = this.workers.get(jobId);
|
|
@@ -645,7 +905,7 @@ export class JobManager {
|
|
|
645
905
|
// abort 会把正在执行的工具(bash 子进程)一起终止
|
|
646
906
|
await worker.session?.abort().catch(() => undefined);
|
|
647
907
|
}
|
|
648
|
-
await this.finish(jobId, "stopped", undefined,
|
|
908
|
+
await this.finish(jobId, "stopped", undefined, reason);
|
|
649
909
|
}
|
|
650
910
|
|
|
651
911
|
/** Worker 的 ask_user:出审批卡、把任务标成待确认,阻塞到用户答复。 */
|
|
@@ -676,7 +936,7 @@ export class JobManager {
|
|
|
676
936
|
const waiting: Job = { ...job, status: "waiting" };
|
|
677
937
|
this.store.saveJob(waiting);
|
|
678
938
|
this.bus.emit({ type: "job", job: waiting });
|
|
679
|
-
if (worker) worker.job = waiting;
|
|
939
|
+
if (worker) { worker.job = waiting; worker.pausedAt = Date.now(); }
|
|
680
940
|
return await new Promise<string>((resolve) => {
|
|
681
941
|
if (worker) worker.ask = resolve;
|
|
682
942
|
else resolve("(任务未在运行)");
|
|
@@ -696,6 +956,8 @@ export class JobManager {
|
|
|
696
956
|
worker?.ask?.(answer);
|
|
697
957
|
if (worker) {
|
|
698
958
|
worker.ask = undefined;
|
|
959
|
+
if (worker.pausedAt !== undefined) worker.pausedMs += Date.now() - worker.pausedAt;
|
|
960
|
+
worker.pausedAt = undefined;
|
|
699
961
|
worker.lastActivityAt = Date.now();
|
|
700
962
|
}
|
|
701
963
|
}
|
|
@@ -712,6 +974,24 @@ export class JobManager {
|
|
|
712
974
|
checkStalls(now = Date.now()): void {
|
|
713
975
|
const idleMs = this.runtime.settings.settings.work.jobStallMinutes * 60_000;
|
|
714
976
|
for (const [jobId, worker] of this.workers) {
|
|
977
|
+
const job = this.store.job(jobId);
|
|
978
|
+
if (job?.status === "running" && job.startedAt && job.expect && worker.pausedAt === undefined) {
|
|
979
|
+
const elapsed = Math.max(0, now - Date.parse(job.startedAt) - worker.pausedMs);
|
|
980
|
+
if (elapsed >= job.expect.minutes * 60_000 * worker.nextReminder) {
|
|
981
|
+
while (elapsed >= job.expect.minutes * 60_000 * worker.nextReminder) worker.nextReminder *= 2;
|
|
982
|
+
const source = job.expect.source === "assistant" ? "派活时估的" : job.expect.source === "routine" ? `这个定时任务以前 ${job.expect.runs} 次的中位数` : "默认 10 分钟";
|
|
983
|
+
const routine = job.routineId && this.store.routine(job.routineId);
|
|
984
|
+
const prefix = routine ? `定时任务「${routine.name}」` : `任务「${job.title}」`;
|
|
985
|
+
const steps = this.transcript(jobId);
|
|
986
|
+
for (const step of worker.steps.values()) {
|
|
987
|
+
const index = steps.findIndex((item) => item.id === step.id);
|
|
988
|
+
if (index >= 0) steps[index] = step; else steps.push(step);
|
|
989
|
+
}
|
|
990
|
+
const lines = jobProcessLines(steps, this.runtime.settings.settings.general.timezone, new Date(now)).slice(0, 10);
|
|
991
|
+
const text = `${prefix}(${job.id})已运行 ${Math.floor(elapsed / 60_000)} 分钟,超过预计的 ${job.expect.minutes} 分钟(${source})。\n最近的过程(新的在前):${lines.length ? `\n${lines.join("\n")}` : ""}`;
|
|
992
|
+
void this.runtime.router.event(job.assistantId, text).catch((error) => console.error("任务超时通知失败:", error));
|
|
993
|
+
}
|
|
994
|
+
}
|
|
715
995
|
// 在等用户答提问不算没响应:等多久都是用户的事
|
|
716
996
|
if (!worker.ask && now - worker.lastActivityAt > idleMs) {
|
|
717
997
|
void this.finish(jobId, "stuck", undefined, `${Math.round(idleMs / 60_000)} 分钟无响应`);
|
|
@@ -740,11 +1020,13 @@ export class JobManager {
|
|
|
740
1020
|
}
|
|
741
1021
|
}
|
|
742
1022
|
this.queue = [];
|
|
1023
|
+
this.followUps.clear();
|
|
743
1024
|
}
|
|
744
1025
|
|
|
745
1026
|
disposeAll(): void {
|
|
746
1027
|
this.stopWatchdog();
|
|
747
|
-
for (const
|
|
1028
|
+
for (const state of this.sediments.values()) { state.cancelled = true; void state.session?.abort(); }
|
|
1029
|
+
for (const worker of this.workers.values()) { worker.session?.dispose(); void worker.desktop?.dispose(); }
|
|
748
1030
|
this.workers.clear();
|
|
749
1031
|
}
|
|
750
1032
|
|
|
@@ -755,7 +1037,7 @@ export class JobManager {
|
|
|
755
1037
|
const active = this.store
|
|
756
1038
|
.jobs()
|
|
757
1039
|
.filter((job) => job.status === "running" || job.status === "queued" || job.status === "waiting");
|
|
758
|
-
if (!active.length) return;
|
|
1040
|
+
if (!active.length && !this.finishing.size && !this.sediments.size) return;
|
|
759
1041
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
760
1042
|
}
|
|
761
1043
|
}
|