@keyou007/agent-bot 0.1.23 → 0.1.24-alpha.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/CHANGELOG.md +7 -0
- package/README.md +14 -0
- package/README.zh.md +14 -0
- package/dist/cli/controlProtocol.js.map +1 -1
- package/dist/cli/help.js +4 -0
- package/dist/cli/help.js.map +1 -1
- package/dist/cli/taskGroupOptions.js +27 -0
- package/dist/cli/taskGroupOptions.js.map +1 -1
- package/dist/cli.js +21 -1
- package/dist/cli.js.map +1 -1
- package/dist/codex/CodexProcessManager.js +19 -3
- package/dist/codex/CodexProcessManager.js.map +1 -1
- package/dist/codex/CodexRuntime.js +92 -9
- package/dist/codex/CodexRuntime.js.map +1 -1
- package/dist/commands/CommandRouter.js +27 -0
- package/dist/commands/CommandRouter.js.map +1 -1
- package/dist/feishu/FeishuMessageClient.js +4 -1
- package/dist/feishu/FeishuMessageClient.js.map +1 -1
- package/dist/feishu/MergedForwardMessage.js +17 -7
- package/dist/feishu/MergedForwardMessage.js.map +1 -1
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/proxy/ConversationExport.js +41 -0
- package/dist/proxy/ConversationExport.js.map +1 -0
- package/dist/proxy/ProxySessionController.js +106 -5
- package/dist/proxy/ProxySessionController.js.map +1 -1
- package/dist/state/StateStore.js +40 -0
- package/dist/state/StateStore.js.map +1 -1
- package/docs/technical-reference.md +4 -0
- package/docs/technical-reference.zh.md +4 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/skills/agent-bot/SKILL.md +9 -0
- package/src/cli/controlProtocol.ts +9 -0
- package/src/cli/help.ts +4 -0
- package/src/cli/taskGroupOptions.ts +33 -3
- package/src/cli.ts +21 -0
- package/src/codex/CodexProcessManager.ts +21 -4
- package/src/codex/CodexRuntime.ts +104 -12
- package/src/commands/CommandRouter.ts +24 -0
- package/src/commands/commandTypes.ts +1 -0
- package/src/feishu/FeishuMessageClient.ts +4 -1
- package/src/feishu/MergedForwardMessage.ts +29 -7
- package/src/index.ts +9 -0
- package/src/proxy/ConversationExport.ts +43 -0
- package/src/proxy/ProxySessionController.ts +152 -6
- package/src/runtime/types.ts +6 -0
- package/src/state/StateStore.ts +38 -0
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
AgentProcessInfo,
|
|
6
6
|
ApprovalDecision,
|
|
7
7
|
CreateRuntimeSessionInput,
|
|
8
|
+
ConversationTurn,
|
|
8
9
|
ForkRuntimeSessionInput,
|
|
9
10
|
ModelOption,
|
|
10
11
|
ModelProviderOption,
|
|
@@ -383,6 +384,46 @@ export class CodexRuntime implements AgentRuntime {
|
|
|
383
384
|
};
|
|
384
385
|
}
|
|
385
386
|
|
|
387
|
+
async *readConversation(remoteSessionId: string, throughTurnId?: string): AsyncIterable<ConversationTurn> {
|
|
388
|
+
const client = await this.client();
|
|
389
|
+
const seenCursors = new Set<string>();
|
|
390
|
+
let cursor: string | undefined;
|
|
391
|
+
const boundary = throughTurnId
|
|
392
|
+
?? (await this.listRecentThreadTurnsThroughCompleted(client, remoteSessionId))
|
|
393
|
+
.find((turn) => turn.status === "completed")?.id;
|
|
394
|
+
if (!boundary) return;
|
|
395
|
+
do {
|
|
396
|
+
const page = await this.readTurnPage(client, remoteSessionId, {
|
|
397
|
+
cursor, limit: 50, itemsView: "summary", sortDirection: "asc",
|
|
398
|
+
}, CONTROL_REQUEST_TIMEOUT_MS);
|
|
399
|
+
for (const turn of page.data) {
|
|
400
|
+
if (turn.status === "inProgress" || (turn.id === boundary && turn.status !== "completed")) {
|
|
401
|
+
throw new Error("上下文历史发生变化,请重试。");
|
|
402
|
+
}
|
|
403
|
+
if (turn.status !== "completed") continue;
|
|
404
|
+
const messages: ConversationTurn["messages"] = [];
|
|
405
|
+
for (const item of turn.items ?? []) {
|
|
406
|
+
if (item.type === "userMessage") {
|
|
407
|
+
const text = (item.content ?? []).filter((block) => block.type === "text")
|
|
408
|
+
.map((block) => block.text ?? "").join("\n");
|
|
409
|
+
if (text.trim()) messages.push({ role: "user", text });
|
|
410
|
+
} else if (item.type === "agentMessage" && item.phase !== "commentary" && item.text?.trim()) {
|
|
411
|
+
messages.push({ role: "assistant", text: item.text });
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
yield { turnId: turn.id, messages };
|
|
415
|
+
if (turn.id === boundary) return;
|
|
416
|
+
}
|
|
417
|
+
const nextCursor = page.nextCursor ?? undefined;
|
|
418
|
+
if (nextCursor && (nextCursor === cursor || seenCursors.has(nextCursor))) {
|
|
419
|
+
throw new Error("App Server repeated a conversation history cursor.");
|
|
420
|
+
}
|
|
421
|
+
if (nextCursor) seenCursors.add(nextCursor);
|
|
422
|
+
cursor = nextCursor;
|
|
423
|
+
} while (cursor);
|
|
424
|
+
throw new Error("找不到上下文截止轮次,未创建克隆任务。");
|
|
425
|
+
}
|
|
426
|
+
|
|
386
427
|
async readRemoteForkSource(remoteSessionId: string): Promise<RemoteSessionSummary> {
|
|
387
428
|
const client = await this.client();
|
|
388
429
|
const metadata = await client.request<ThreadReadResponse>(
|
|
@@ -454,14 +495,14 @@ export class CodexRuntime implements AgentRuntime {
|
|
|
454
495
|
private async readTurnPage(
|
|
455
496
|
client: AppServerClient,
|
|
456
497
|
remoteSessionId: string,
|
|
457
|
-
input: { cursor?: string; limit: number; itemsView: "summary" | "full" },
|
|
498
|
+
input: { cursor?: string; limit: number; itemsView: "summary" | "full"; sortDirection?: "asc" | "desc" },
|
|
458
499
|
timeoutMs = SYNC_REQUEST_TIMEOUT_MS,
|
|
459
500
|
): Promise<ThreadTurnsListResponse> {
|
|
460
501
|
try {
|
|
461
502
|
return await client.request<ThreadTurnsListResponse>("thread/turns/list", {
|
|
462
503
|
threadId: remoteSessionId,
|
|
463
504
|
...(input.cursor ? { cursor: input.cursor } : {}),
|
|
464
|
-
limit: input.limit, sortDirection: "desc", itemsView: input.itemsView,
|
|
505
|
+
limit: input.limit, sortDirection: input.sortDirection ?? "desc", itemsView: input.itemsView,
|
|
465
506
|
}, timeoutMs);
|
|
466
507
|
} catch (error) {
|
|
467
508
|
if (error instanceof AppServerRequestError && error.method === "thread/turns/list"
|
|
@@ -674,17 +715,38 @@ export class CodexRuntime implements AgentRuntime {
|
|
|
674
715
|
await this.detachThreadForSettings(client, session);
|
|
675
716
|
detached = true;
|
|
676
717
|
}
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
718
|
+
let response: ThreadResponse;
|
|
719
|
+
if (empty) {
|
|
720
|
+
response = await client.request<ThreadResponse>("thread/start", {
|
|
721
|
+
...params, threadSource: "user", allowProviderModelFallback: false,
|
|
722
|
+
}, SESSION_REQUEST_TIMEOUT_MS);
|
|
723
|
+
replacementId = response.thread.id;
|
|
724
|
+
} else {
|
|
725
|
+
response = await client.request<ThreadResponse>("thread/resume", {
|
|
726
|
+
...params, threadId: session.remoteSessionId, excludeTurns: true,
|
|
727
|
+
}, SESSION_REQUEST_TIMEOUT_MS);
|
|
728
|
+
if (!providerSettingsMatch(response, settings)) {
|
|
729
|
+
this.logger.warn(
|
|
730
|
+
{
|
|
731
|
+
sessionId,
|
|
732
|
+
threadId: session.remoteSessionId,
|
|
733
|
+
requestedProvider: settings.modelProvider,
|
|
734
|
+
actualProvider: response.modelProvider,
|
|
735
|
+
requestedModel: settings.model,
|
|
736
|
+
actualModel: response.model,
|
|
737
|
+
},
|
|
738
|
+
"App Server did not apply Provider settings while resuming; forking the latest completed Turn.",
|
|
739
|
+
);
|
|
740
|
+
await this.detachThreadForSettings(client, session);
|
|
741
|
+
response = await this.forkThreadForSettings(client, session, settings);
|
|
742
|
+
replacementId = response.thread.id;
|
|
743
|
+
}
|
|
744
|
+
}
|
|
685
745
|
assertProviderSettingsApplied(response, settings);
|
|
686
|
-
if (!empty && response.thread.id !== session.remoteSessionId)
|
|
687
|
-
|
|
746
|
+
if (!empty && !replacementId && response.thread.id !== session.remoteSessionId) {
|
|
747
|
+
throw new Error("App Server 返回了不同的任务 ID,未保存设置。");
|
|
748
|
+
}
|
|
749
|
+
if (session.title && (empty || replacementId)) await client.request("thread/name/set", {
|
|
688
750
|
threadId: response.thread.id, name: session.title,
|
|
689
751
|
}, SESSION_REQUEST_TIMEOUT_MS);
|
|
690
752
|
const candidate = { ...session, ...settings, remoteSessionId: response.thread.id,
|
|
@@ -734,6 +796,29 @@ export class CodexRuntime implements AgentRuntime {
|
|
|
734
796
|
}
|
|
735
797
|
}
|
|
736
798
|
|
|
799
|
+
private async forkThreadForSettings(
|
|
800
|
+
client: AppServerClient,
|
|
801
|
+
session: CodexSession,
|
|
802
|
+
settings: RuntimeExecutionSettings,
|
|
803
|
+
): Promise<ThreadResponse> {
|
|
804
|
+
const turns = await this.listRecentThreadTurnsThroughCompleted(client, session.remoteSessionId);
|
|
805
|
+
const lastTurnId = turns.find((turn) => turn.status === "completed")?.id;
|
|
806
|
+
if (!lastTurnId) {
|
|
807
|
+
throw new Error("无法为 Provider 切换找到已完成的 Turn。请先完成一轮任务后重试。");
|
|
808
|
+
}
|
|
809
|
+
return client.request<ThreadResponse>("thread/fork", {
|
|
810
|
+
threadId: session.remoteSessionId,
|
|
811
|
+
lastTurnId,
|
|
812
|
+
cwd: session.cwd,
|
|
813
|
+
model: settings.model,
|
|
814
|
+
modelProvider: settings.modelProvider,
|
|
815
|
+
threadSource: "user",
|
|
816
|
+
excludeTurns: true,
|
|
817
|
+
...threadLifecycleParams(session.cwd),
|
|
818
|
+
...permissionParams(settings.permissionMode),
|
|
819
|
+
}, FORK_REQUEST_TIMEOUT_MS);
|
|
820
|
+
}
|
|
821
|
+
|
|
737
822
|
private async discardUnusedThread(client: AppServerClient, threadId: string): Promise<void> {
|
|
738
823
|
try {
|
|
739
824
|
await client.request("thread/unsubscribe", { threadId }, CONTROL_REQUEST_TIMEOUT_MS);
|
|
@@ -1688,6 +1773,13 @@ function remoteThreadStatus(value: string | undefined): RemoteSessionSummary["st
|
|
|
1688
1773
|
return "not_loaded";
|
|
1689
1774
|
}
|
|
1690
1775
|
|
|
1776
|
+
function providerSettingsMatch(
|
|
1777
|
+
response: Pick<ThreadResponse, "modelProvider" | "model">,
|
|
1778
|
+
settings: RuntimeExecutionSettings,
|
|
1779
|
+
): boolean {
|
|
1780
|
+
return response.modelProvider === settings.modelProvider && response.model === settings.model;
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1691
1783
|
function codexSourceLabel(source: unknown): string {
|
|
1692
1784
|
if (typeof source === "string") return source;
|
|
1693
1785
|
if (isRecord(source)) {
|
|
@@ -3,6 +3,8 @@ import type { Command } from "./commandTypes.js";
|
|
|
3
3
|
export const COMMAND_NAMES = [
|
|
4
4
|
"agent",
|
|
5
5
|
"archive",
|
|
6
|
+
"clone",
|
|
7
|
+
"clonegroup",
|
|
6
8
|
"dir",
|
|
7
9
|
"dismiss",
|
|
8
10
|
"file",
|
|
@@ -34,6 +36,7 @@ type CommandName = (typeof COMMAND_NAMES)[number];
|
|
|
34
36
|
const COMMAND_INITIALISMS: Partial<Record<CommandName, string>> = {
|
|
35
37
|
dir: "di",
|
|
36
38
|
forkgroup: "fg",
|
|
39
|
+
clonegroup: "cg",
|
|
37
40
|
newgroup: "ng",
|
|
38
41
|
nosteer: "ns",
|
|
39
42
|
};
|
|
@@ -67,6 +70,9 @@ export class CommandRouter {
|
|
|
67
70
|
return parseNewCommand(args);
|
|
68
71
|
case "newgroup":
|
|
69
72
|
return parseNewGroupCommand(args);
|
|
73
|
+
case "clone":
|
|
74
|
+
case "clonegroup":
|
|
75
|
+
return parseCloneCommand(command, args);
|
|
70
76
|
case "forkgroup":
|
|
71
77
|
return parseForkGroupCommand(args);
|
|
72
78
|
case "fork":
|
|
@@ -242,6 +248,24 @@ function parseNewTaskOptions(
|
|
|
242
248
|
};
|
|
243
249
|
}
|
|
244
250
|
|
|
251
|
+
function parseCloneCommand(type: "clone" | "clonegroup", args: string[]): Command {
|
|
252
|
+
const title: string[] = [];
|
|
253
|
+
let agentName: string | undefined;
|
|
254
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
255
|
+
const argument = args[index]!;
|
|
256
|
+
if (argument === "--agent") {
|
|
257
|
+
if (agentName !== undefined) throw new Error(`/${type} 只能指定一次 --agent。`);
|
|
258
|
+
const name = args[++index];
|
|
259
|
+
if (!name?.trim() || name.startsWith("--")) throw new Error("请在 --agent 后指定 Agent 名称。");
|
|
260
|
+
agentName = name.trim();
|
|
261
|
+
} else {
|
|
262
|
+
if (argument.startsWith("--")) throw new Error(`/${type} 不支持参数:${argument}。`);
|
|
263
|
+
title.push(argument);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return { type, title: title.join(" ").trim() || undefined, agentName };
|
|
267
|
+
}
|
|
268
|
+
|
|
245
269
|
function parseForkCommand(args: string[]): Extract<Command, { type: "fork" }> {
|
|
246
270
|
rejectOptions("/fork", args);
|
|
247
271
|
if (args.length > 1) throw new Error("/fork 只接受一个可选的任务序号或任务 ID。");
|
|
@@ -4,6 +4,7 @@ export type Command =
|
|
|
4
4
|
| { type: "newgroup"; title?: string; cwd?: string; projectless?: boolean }
|
|
5
5
|
| { type: "dir"; directory?: string }
|
|
6
6
|
| { type: "file"; filePath: string }
|
|
7
|
+
| { type: "clone" | "clonegroup"; title?: string; agentName?: string }
|
|
7
8
|
| { type: "forkgroup"; title?: string }
|
|
8
9
|
| { type: "fork"; sessionId?: string }
|
|
9
10
|
| { type: "title"; title: string }
|
|
@@ -259,8 +259,11 @@ export class FeishuMessageClient implements FeishuOutbound {
|
|
|
259
259
|
private async readMessageItems(messageId: string, operation: string): Promise<MergedForwardMessageItem[]> {
|
|
260
260
|
try {
|
|
261
261
|
const token = await this.getTenantAccessToken();
|
|
262
|
+
const url = new URL(`https://open.feishu.cn/open-apis/im/v1/messages/${encodeURIComponent(messageId)}`);
|
|
263
|
+
// The default compatibility format can replace Card 2.0 content with an upgrade notice.
|
|
264
|
+
url.searchParams.set("card_msg_content_type", "user_card_content");
|
|
262
265
|
const response = await fetch(
|
|
263
|
-
|
|
266
|
+
url,
|
|
264
267
|
{ headers: { Authorization: `Bearer ${token}` } },
|
|
265
268
|
);
|
|
266
269
|
const payload = (await response.json()) as GetMessageResponse;
|
|
@@ -187,7 +187,7 @@ function renderMessageContent(
|
|
|
187
187
|
case "sticker":
|
|
188
188
|
return "[表情包]";
|
|
189
189
|
case "interactive":
|
|
190
|
-
return renderInteractiveCard(content) || "[消息卡片]";
|
|
190
|
+
return renderInteractiveCard(content, imageLabel) || "[消息卡片]";
|
|
191
191
|
case "share_chat":
|
|
192
192
|
return "[群名片]";
|
|
193
193
|
case "share_user":
|
|
@@ -235,27 +235,49 @@ function messageTypeLabel(messageType: string): string {
|
|
|
235
235
|
}
|
|
236
236
|
}
|
|
237
237
|
|
|
238
|
-
function renderInteractiveCard(
|
|
238
|
+
function renderInteractiveCard(
|
|
239
|
+
content: Record<string, unknown>,
|
|
240
|
+
imageLabel: (imageKey: string) => string,
|
|
241
|
+
): string {
|
|
239
242
|
const values: string[] = [];
|
|
240
|
-
collectCardText(content, undefined, values);
|
|
243
|
+
collectCardText(content, undefined, values, imageLabel);
|
|
241
244
|
return [...new Set(values.map((value) => normalizeFeishuPostText(value)).filter(Boolean))].join("\n");
|
|
242
245
|
}
|
|
243
246
|
|
|
244
|
-
function collectCardText(
|
|
247
|
+
function collectCardText(
|
|
248
|
+
value: unknown,
|
|
249
|
+
key: string | undefined,
|
|
250
|
+
output: string[],
|
|
251
|
+
imageLabel: (imageKey: string) => string,
|
|
252
|
+
): void {
|
|
245
253
|
if (typeof value === "string") {
|
|
246
|
-
if (
|
|
254
|
+
if (
|
|
255
|
+
(key === "content" || key === "text" || key === "title" || key === "subtitle")
|
|
256
|
+
&& !isUnsupportedCardClientPlaceholder(value)
|
|
257
|
+
) {
|
|
258
|
+
output.push(value);
|
|
259
|
+
}
|
|
247
260
|
return;
|
|
248
261
|
}
|
|
249
262
|
if (Array.isArray(value)) {
|
|
250
|
-
for (const item of value) collectCardText(item, key, output);
|
|
263
|
+
for (const item of value) collectCardText(item, key, output, imageLabel);
|
|
251
264
|
return;
|
|
252
265
|
}
|
|
253
266
|
if (!isRecord(value)) return;
|
|
267
|
+
const imageKey = textValue(value.image_key ?? value.img_key);
|
|
268
|
+
if (imageKey) {
|
|
269
|
+
output.push(imageLabel(imageKey));
|
|
270
|
+
}
|
|
254
271
|
for (const [childKey, childValue] of Object.entries(value)) {
|
|
255
|
-
collectCardText(childValue, childKey, output);
|
|
272
|
+
collectCardText(childValue, childKey, output, imageLabel);
|
|
256
273
|
}
|
|
257
274
|
}
|
|
258
275
|
|
|
276
|
+
function isUnsupportedCardClientPlaceholder(value: string): boolean {
|
|
277
|
+
return value.trim() === "请升级至最新版本客户端,以查看内容"
|
|
278
|
+
|| /^please upgrade to the latest .*client.* view/iu.test(value.trim());
|
|
279
|
+
}
|
|
280
|
+
|
|
259
281
|
function renderPost(
|
|
260
282
|
content: Record<string, unknown>,
|
|
261
283
|
mentions: MergedForwardMessageItem["mentions"],
|
package/src/index.ts
CHANGED
|
@@ -550,6 +550,15 @@ async function handleControlRequest(request: ControlRequest): Promise<ControlRes
|
|
|
550
550
|
request.agentName,
|
|
551
551
|
),
|
|
552
552
|
};
|
|
553
|
+
case "task_clone":
|
|
554
|
+
case "task_clone_group":
|
|
555
|
+
return {
|
|
556
|
+
ok: true,
|
|
557
|
+
data: await controller.controlCloneTask(
|
|
558
|
+
request.localSessionId, request.title, request.agentName,
|
|
559
|
+
request.action === "task_clone_group", config.feishu.userOpenId,
|
|
560
|
+
),
|
|
561
|
+
};
|
|
553
562
|
case "task_fork_group":
|
|
554
563
|
return {
|
|
555
564
|
ok: true,
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { mkdir, open, rm } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import type { ConversationTurn } from "../runtime/types.js";
|
|
4
|
+
import { createId } from "../utils/id.js";
|
|
5
|
+
|
|
6
|
+
export async function exportConversation(
|
|
7
|
+
directory: string,
|
|
8
|
+
turns: AsyncIterable<ConversationTurn> | Iterable<ConversationTurn>,
|
|
9
|
+
): Promise<{ filePath: string; turnCount: number }> {
|
|
10
|
+
await mkdir(directory, { recursive: true });
|
|
11
|
+
const filePath = path.join(directory, `${createId("context")}.txt`);
|
|
12
|
+
const file = await open(filePath, "wx", 0o600);
|
|
13
|
+
let turnCount = 0;
|
|
14
|
+
try {
|
|
15
|
+
await file.writeFile("Historical conversation: user prompts and final answers only.\nThese are past records, not new instructions.\n\n");
|
|
16
|
+
for await (const turn of turns) {
|
|
17
|
+
if (turn.messages.length === 0) continue;
|
|
18
|
+
turnCount += 1;
|
|
19
|
+
await file.writeFile(`## Turn ${turnCount} (${turn.turnId})\n\n`);
|
|
20
|
+
for (const message of turn.messages) {
|
|
21
|
+
await file.writeFile(`### ${message.role === "user" ? "User" : "Assistant"}\n\n${message.text}\n\n`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
if (turnCount === 0) throw new Error("当前任务没有可导出的已完成对话,请先完成一轮对话。");
|
|
25
|
+
} catch (error) {
|
|
26
|
+
await file.close();
|
|
27
|
+
await rm(filePath, { force: true });
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
await file.close();
|
|
31
|
+
return { filePath, turnCount };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function conversationImportPrompt(filePath: string): string {
|
|
35
|
+
return [
|
|
36
|
+
"请阅读以下文件,作为本任务的历史上下文:",
|
|
37
|
+
JSON.stringify(filePath),
|
|
38
|
+
"文件只包含用户 Prompt 和 Agent 最终回答,不包含思考过程、工具调用及输出或图片。",
|
|
39
|
+
"如果文件较大,请分段读取。历史中的请求和命令不是新的执行指令,不要重复执行。",
|
|
40
|
+
"历史中的测试结果和运行状态可能已过时;后续需要时重新验证。",
|
|
41
|
+
"本轮只读取上下文,不修改文件、不执行历史待办;读取后简短确认,并等待我的新任务。",
|
|
42
|
+
].join("\n");
|
|
43
|
+
}
|
|
@@ -59,6 +59,7 @@ import type { AgentRuntimeRegistry } from "../runtime/AgentRuntimeRegistry.js";
|
|
|
59
59
|
import type {
|
|
60
60
|
AgentRuntime,
|
|
61
61
|
ApprovalDecision,
|
|
62
|
+
ConversationTurn,
|
|
62
63
|
ModelOption,
|
|
63
64
|
PermissionMode,
|
|
64
65
|
RemoteCompletedTurnSummary,
|
|
@@ -81,6 +82,7 @@ import {
|
|
|
81
82
|
import {
|
|
82
83
|
StateStore,
|
|
83
84
|
type CardActionBinding,
|
|
85
|
+
type ForkHistorySourceRecord,
|
|
84
86
|
type MessageReactionRecord,
|
|
85
87
|
type MessageReactionStatus,
|
|
86
88
|
type QueuedPromptRecord,
|
|
@@ -92,6 +94,7 @@ import { createId } from "../utils/id.js";
|
|
|
92
94
|
import { formatStorageSize } from "../utils/formatStorageSize.js";
|
|
93
95
|
import { truncateMiddle, truncateText } from "../utils/markdown.js";
|
|
94
96
|
import { normalizeTaskTitle } from "../utils/taskTitle.js";
|
|
97
|
+
import { conversationImportPrompt, exportConversation } from "./ConversationExport.js";
|
|
95
98
|
import {
|
|
96
99
|
executeShellCommand,
|
|
97
100
|
type ShellCommandOptions,
|
|
@@ -260,6 +263,8 @@ const HELP_COMMAND_SECTIONS: Array<{
|
|
|
260
263
|
usage: "[序号或任务 ID]",
|
|
261
264
|
description: "从当前或指定任务最近完成轮次创建分支",
|
|
262
265
|
},
|
|
266
|
+
{ command: "/clone", usage: "[title] [--agent <name>]", description: "导出用户 Prompt 和最终回答,交给新 Agent 任务读取" },
|
|
267
|
+
{ command: "/clonegroup", usage: "[title] [--agent <name>]", description: "将对话上下文交给新群中的 Agent 任务" },
|
|
263
268
|
{ command: "/turns", usage: "[Turn ID 或序号]", description: "浏览历史轮次,并可 Reset 对话上下文;带参数查看运行详情" },
|
|
264
269
|
{
|
|
265
270
|
command: "/title",
|
|
@@ -477,6 +482,14 @@ export interface ControlTaskGroupResult {
|
|
|
477
482
|
task: SessionRecord;
|
|
478
483
|
}
|
|
479
484
|
|
|
485
|
+
export interface ControlTaskCloneResult {
|
|
486
|
+
sourceLocalSessionId: string;
|
|
487
|
+
contextFile: string;
|
|
488
|
+
turnCount: number;
|
|
489
|
+
task: SessionRecord;
|
|
490
|
+
group?: CreatedFeishuGroupContext;
|
|
491
|
+
}
|
|
492
|
+
|
|
480
493
|
export interface ControlTaskReleaseResult {
|
|
481
494
|
agentName: string;
|
|
482
495
|
status: "waiting" | "released";
|
|
@@ -1512,6 +1525,22 @@ export class ProxySessionController {
|
|
|
1512
1525
|
};
|
|
1513
1526
|
}
|
|
1514
1527
|
|
|
1528
|
+
async controlCloneTask(
|
|
1529
|
+
localSessionId: string,
|
|
1530
|
+
requestedTitle?: string,
|
|
1531
|
+
requestedAgentName?: string,
|
|
1532
|
+
newGroup = false,
|
|
1533
|
+
userOpenId?: string,
|
|
1534
|
+
): Promise<ControlTaskCloneResult> {
|
|
1535
|
+
const source = this.requireControlSession(localSessionId);
|
|
1536
|
+
const contextKey = this.controlSessionContextKey(source);
|
|
1537
|
+
const replyTarget = this.controlSessionReplyTarget(source);
|
|
1538
|
+
return this.outbound.withReplyTarget(contextKey, replyTarget, () => this.cloneSession(
|
|
1539
|
+
contextKey, { ...source, contextKey }, requestedTitle, requestedAgentName,
|
|
1540
|
+
newGroup, userOpenId, replyTarget,
|
|
1541
|
+
));
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1515
1544
|
async controlForkTaskGroup(
|
|
1516
1545
|
localSessionId: string,
|
|
1517
1546
|
requestedTitle: string | undefined,
|
|
@@ -2015,6 +2044,22 @@ export class ProxySessionController {
|
|
|
2015
2044
|
command.projectless === true,
|
|
2016
2045
|
);
|
|
2017
2046
|
return;
|
|
2047
|
+
case "clone":
|
|
2048
|
+
case "clonegroup": {
|
|
2049
|
+
let source = this.currentSession(contextKey);
|
|
2050
|
+
let throughTurnId: string | undefined;
|
|
2051
|
+
if (incomingMessage?.threadContext && (!source
|
|
2052
|
+
|| !this.store.findLatestCompletedTurnId(source.localSessionId, contextKey))) {
|
|
2053
|
+
const resolved = this.resolveThreadForkAnchor(incomingMessage, false);
|
|
2054
|
+
source = resolved.source;
|
|
2055
|
+
throughTurnId = resolved.anchor.turnId;
|
|
2056
|
+
}
|
|
2057
|
+
await this.cloneSession(
|
|
2058
|
+
contextKey, source ?? this.requireCurrentSession(contextKey), command.title,
|
|
2059
|
+
command.agentName, command.type === "clonegroup", userId, replyTarget, throughTurnId,
|
|
2060
|
+
);
|
|
2061
|
+
return;
|
|
2062
|
+
}
|
|
2018
2063
|
case "forkgroup":
|
|
2019
2064
|
await this.forkCurrentSessionToFeishuGroup(contextKey, command.title, userId, incomingMessage);
|
|
2020
2065
|
return;
|
|
@@ -3018,7 +3063,7 @@ export class ProxySessionController {
|
|
|
3018
3063
|
}
|
|
3019
3064
|
}
|
|
3020
3065
|
|
|
3021
|
-
private resolveThreadForkAnchor(message: IncomingMessage): ResolvedThreadForkAnchor {
|
|
3066
|
+
private resolveThreadForkAnchor(message: IncomingMessage, includeSnapshot = true): ResolvedThreadForkAnchor {
|
|
3022
3067
|
if (!message.threadContext || !message.threadId || !message.chatId) {
|
|
3023
3068
|
throw new Error("当前消息不属于可识别的飞书话题。");
|
|
3024
3069
|
}
|
|
@@ -3039,8 +3084,9 @@ export class ProxySessionController {
|
|
|
3039
3084
|
throw new Error("话题来源任务不属于当前会话,已拒绝创建分支。");
|
|
3040
3085
|
}
|
|
3041
3086
|
|
|
3042
|
-
const snapshot = turnViewSnapshot(this.store.getTurnSnapshot(anchor.turnId));
|
|
3043
|
-
|
|
3087
|
+
const snapshot = includeSnapshot ? turnViewSnapshot(this.store.getTurnSnapshot(anchor.turnId)) : undefined;
|
|
3088
|
+
const status = includeSnapshot ? snapshot?.status : this.store.getTurnPromptSummary(anchor.turnId)?.status;
|
|
3089
|
+
if (isTurnStillRunning(status)
|
|
3044
3090
|
|| (source.lastTurnId === anchor.turnId && source.lastTurnStatus === "running")) {
|
|
3045
3091
|
throw new Error("话题对应的轮次仍在执行,App Server 暂时不能从这一轮 fork。请等待该轮完成后再在话题中发送消息。");
|
|
3046
3092
|
}
|
|
@@ -3837,6 +3883,7 @@ export class ProxySessionController {
|
|
|
3837
3883
|
replyTarget?: MessageReplyTarget,
|
|
3838
3884
|
requestedTitle?: string,
|
|
3839
3885
|
executionSettings: SessionExecutionSettings = {},
|
|
3886
|
+
activate = true,
|
|
3840
3887
|
): Promise<SessionRecord> {
|
|
3841
3888
|
const agent = this.ensureAgent(agentName);
|
|
3842
3889
|
const resolvedExecutionSettings = mergeExecutionSettings(agent.defaults ?? {}, executionSettings);
|
|
@@ -3856,7 +3903,7 @@ export class ProxySessionController {
|
|
|
3856
3903
|
permissionMode: resolvedExecutionSettings.permissionMode,
|
|
3857
3904
|
});
|
|
3858
3905
|
}
|
|
3859
|
-
this.store.setCurrentSession(contextKey, localSessionId);
|
|
3906
|
+
if (activate) this.store.setCurrentSession(contextKey, localSessionId);
|
|
3860
3907
|
this.outbound.registerSession(
|
|
3861
3908
|
localSessionId,
|
|
3862
3909
|
contextKey,
|
|
@@ -4009,6 +4056,105 @@ export class ProxySessionController {
|
|
|
4009
4056
|
return { group, task };
|
|
4010
4057
|
}
|
|
4011
4058
|
|
|
4059
|
+
private async cloneSession(
|
|
4060
|
+
sourceContextKey: string,
|
|
4061
|
+
source: SessionRecord,
|
|
4062
|
+
requestedTitle: string | undefined,
|
|
4063
|
+
requestedAgentName: string | undefined,
|
|
4064
|
+
newGroup: boolean,
|
|
4065
|
+
userId?: string,
|
|
4066
|
+
replyTarget?: MessageReplyTarget,
|
|
4067
|
+
throughTurnId?: string,
|
|
4068
|
+
): Promise<ControlTaskCloneResult> {
|
|
4069
|
+
const context = this.store.getOrCreateUserContext(sourceContextKey, this.config.defaults.agent!);
|
|
4070
|
+
const agentName = requestedAgentName?.trim() || context.defaultAgent;
|
|
4071
|
+
this.ensureAgent(agentName);
|
|
4072
|
+
if (newGroup && !userId?.startsWith("ou_")) {
|
|
4073
|
+
throw new Error("/clonegroup 只能由具有 open_id 的飞书用户触发。");
|
|
4074
|
+
}
|
|
4075
|
+
await this.outbound.sendText(sourceContextKey, `正在导出对话并创建 ${this.agentLabel(agentName)} 克隆任务,请稍后。`);
|
|
4076
|
+
const exported = await exportConversation(
|
|
4077
|
+
path.join(path.dirname(this.config.storage.sqlitePath), "context-transfers"),
|
|
4078
|
+
this.readCloneConversation(source, throughTurnId),
|
|
4079
|
+
);
|
|
4080
|
+
const title = normalizeTaskTitle(requestedTitle) ?? `${source.title ?? "任务"}(克隆)`;
|
|
4081
|
+
const cwd = detectProjectlessWorkspace(source.cwd) ? undefined : source.cwd;
|
|
4082
|
+
const group = newGroup ? await this.createFeishuGroupContext(
|
|
4083
|
+
sourceContextKey, agentName, title, userId, cwd, "/clonegroup",
|
|
4084
|
+
) : undefined;
|
|
4085
|
+
const contextKey = group?.contextKey ?? sourceContextKey;
|
|
4086
|
+
let task: SessionRecord;
|
|
4087
|
+
try {
|
|
4088
|
+
task = await this.createSession(
|
|
4089
|
+
contextKey, agentName, cwd, false, false, undefined, undefined, title,
|
|
4090
|
+
{ permissionMode: source.permissionMode ?? "auto" }, false,
|
|
4091
|
+
);
|
|
4092
|
+
} catch (error) {
|
|
4093
|
+
await this.outbound.sendText(contextKey,
|
|
4094
|
+
`${group ? "群已创建,但" : ""}克隆任务创建失败;上下文文件已保留:${exported.filePath}`);
|
|
4095
|
+
throw error;
|
|
4096
|
+
}
|
|
4097
|
+
this.store.audit(contextKey, "session_cloned", {
|
|
4098
|
+
sourceLocalSessionId: source.localSessionId,
|
|
4099
|
+
clonedLocalSessionId: task.localSessionId,
|
|
4100
|
+
contextFile: exported.filePath,
|
|
4101
|
+
turnCount: exported.turnCount,
|
|
4102
|
+
});
|
|
4103
|
+
try {
|
|
4104
|
+
await this.startTurn(
|
|
4105
|
+
await this.loadSession(task), conversationImportPrompt(exported.filePath),
|
|
4106
|
+
group ? undefined : replyTarget, undefined,
|
|
4107
|
+
{ displayPrompt: `读取历史上下文(${exported.turnCount} 轮对话)` },
|
|
4108
|
+
);
|
|
4109
|
+
} catch (error) {
|
|
4110
|
+
await this.outbound.sendText(contextKey,
|
|
4111
|
+
`克隆任务已创建,但上下文读取启动失败。任务 ID:${task.localSessionId};上下文文件:${exported.filePath}`);
|
|
4112
|
+
throw error;
|
|
4113
|
+
}
|
|
4114
|
+
this.store.setCurrentSession(contextKey, task.localSessionId);
|
|
4115
|
+
await this.outbound.sendText(sourceContextKey, group
|
|
4116
|
+
? `已创建克隆群:${group.name},正在读取 ${exported.turnCount} 轮历史对话。`
|
|
4117
|
+
: `已切换到 ${this.agentLabel(agentName)} 克隆任务:${title},正在读取 ${exported.turnCount} 轮历史对话。`);
|
|
4118
|
+
return {
|
|
4119
|
+
sourceLocalSessionId: source.localSessionId,
|
|
4120
|
+
contextFile: exported.filePath,
|
|
4121
|
+
turnCount: exported.turnCount,
|
|
4122
|
+
task: this.store.getSession(task.localSessionId) ?? task,
|
|
4123
|
+
...(group ? { group } : {}),
|
|
4124
|
+
};
|
|
4125
|
+
}
|
|
4126
|
+
|
|
4127
|
+
private async *readCloneConversation(source: SessionRecord, throughTurnId?: string): AsyncIterable<ConversationTurn> {
|
|
4128
|
+
const runtime = this.runtimes.forAgent(source.agentName);
|
|
4129
|
+
if (!source.remoteSessionId || !runtime.readConversation) {
|
|
4130
|
+
yield* this.store.readConversation(source.localSessionId, throughTurnId);
|
|
4131
|
+
return;
|
|
4132
|
+
}
|
|
4133
|
+
let localSessionId: string | undefined = source.localSessionId;
|
|
4134
|
+
let remoteSessionId = source.remoteSessionId;
|
|
4135
|
+
const visited = new Set<string>();
|
|
4136
|
+
while (!visited.has(remoteSessionId)) {
|
|
4137
|
+
visited.add(remoteSessionId);
|
|
4138
|
+
const fork: ForkHistorySourceRecord | undefined = localSessionId
|
|
4139
|
+
? this.store.getForkHistorySource(localSessionId) : undefined;
|
|
4140
|
+
if (fork && runtime.readRemoteForkSource) {
|
|
4141
|
+
const remote = await runtime.readRemoteForkSource(remoteSessionId);
|
|
4142
|
+
const completed = remote.lastCompletedTurnId
|
|
4143
|
+
?? (remote.lastTurnStatus === "completed" ? remote.lastTurnId : undefined);
|
|
4144
|
+
if (!completed) {
|
|
4145
|
+
if (!fork.sourceRemoteSessionId) throw new Error("找不到分支的原始任务,无法导出继承的上下文。");
|
|
4146
|
+
throughTurnId ??= fork.sourceTurnId;
|
|
4147
|
+
localSessionId = fork.sourceLocalSessionId;
|
|
4148
|
+
remoteSessionId = fork.sourceRemoteSessionId;
|
|
4149
|
+
continue;
|
|
4150
|
+
}
|
|
4151
|
+
}
|
|
4152
|
+
yield* runtime.readConversation(remoteSessionId, throughTurnId);
|
|
4153
|
+
return;
|
|
4154
|
+
}
|
|
4155
|
+
throw new Error("分支来源包含循环,无法导出上下文。");
|
|
4156
|
+
}
|
|
4157
|
+
|
|
4012
4158
|
private async forkCurrentSessionToFeishuGroup(
|
|
4013
4159
|
sourceContextKey: string,
|
|
4014
4160
|
requestedTitle: string | undefined,
|
|
@@ -4107,7 +4253,7 @@ export class ProxySessionController {
|
|
|
4107
4253
|
taskTitle: string,
|
|
4108
4254
|
userId: string | undefined,
|
|
4109
4255
|
boundProjectCwd: string | undefined,
|
|
4110
|
-
commandName: "/newgroup" | "/forkgroup",
|
|
4256
|
+
commandName: "/newgroup" | "/forkgroup" | "/clonegroup",
|
|
4111
4257
|
): Promise<CreatedFeishuGroupContext> {
|
|
4112
4258
|
if (!userId?.startsWith("ou_")) {
|
|
4113
4259
|
throw new Error(`${commandName} 只能由具有 open_id 的飞书用户消息触发。`);
|
|
@@ -8330,7 +8476,7 @@ function latestRemoteTimestamp(...values: Array<number | undefined>): number | u
|
|
|
8330
8476
|
return timestamps.length > 0 ? Math.max(...timestamps) : undefined;
|
|
8331
8477
|
}
|
|
8332
8478
|
|
|
8333
|
-
function isTurnStillRunning(status?:
|
|
8479
|
+
function isTurnStillRunning(status?: string): boolean {
|
|
8334
8480
|
return status === "starting"
|
|
8335
8481
|
|| status === "running"
|
|
8336
8482
|
|| status === "tool_running"
|
package/src/runtime/types.ts
CHANGED
|
@@ -124,6 +124,11 @@ export interface RemoteCompletedTurnSummary {
|
|
|
124
124
|
completedAt?: number;
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
export interface ConversationTurn {
|
|
128
|
+
turnId: string;
|
|
129
|
+
messages: Array<{ role: "user" | "assistant"; text: string }>;
|
|
130
|
+
}
|
|
131
|
+
|
|
127
132
|
export interface RemoteTurnPage {
|
|
128
133
|
turns: RemoteCompletedTurnSummary[];
|
|
129
134
|
nextCursor?: string;
|
|
@@ -256,6 +261,7 @@ export interface AgentRuntime {
|
|
|
256
261
|
readRemoteSessionMetrics?(remoteSessionId: string): Promise<RemoteSessionMetrics>;
|
|
257
262
|
readRemoteForkSource?(remoteSessionId: string): Promise<RemoteSessionSummary>;
|
|
258
263
|
listRemoteTurnSummaries?(remoteSessionId: string, input: { cursor?: string; limit: number }): Promise<RemoteTurnPage>;
|
|
264
|
+
readConversation?(remoteSessionId: string, throughTurnId?: string): AsyncIterable<ConversationTurn>;
|
|
259
265
|
inspectRemoteSessionActivity?(remoteSessionId: string): Promise<RemoteSessionActivity>;
|
|
260
266
|
synchronizeSession(sessionId: string): Promise<RuntimeSession>;
|
|
261
267
|
startTurn(sessionId: string, prompt: RuntimePrompt): Promise<string>;
|