godot-daedalus_backend 1.0.1 → 1.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/main.ts +1 -1
- package/src/ping-client.ts +1 -1
- package/src/server/backend-health.ts +64 -0
- package/src/server/websocket-server.ts +393 -34
- package/src/session/approval-persistence.ts +279 -0
- package/src/session/session-store.ts +55 -1
- package/src/skills/registry.ts +8 -0
- package/src/tools/approval-gateway.ts +22 -5
- package/src/workflow/planner.ts +8 -0
- package/src/workflow/runner.ts +20 -0
package/README.md
CHANGED
package/package.json
CHANGED
package/src/main.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createServer } from "./server/websocket-server.js";
|
|
2
2
|
import { McpHost } from "./mcp/mcp-host.js";
|
|
3
3
|
|
|
4
|
-
const DEFAULT_PORT: number =
|
|
4
|
+
const DEFAULT_PORT: number = 38180;
|
|
5
5
|
const portText: string = process.env.PORT ?? String(DEFAULT_PORT);
|
|
6
6
|
const port: number = Number.parseInt(portText, 10);
|
|
7
7
|
|
package/src/ping-client.ts
CHANGED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
const BACKEND_HEALTH_NAME: string = "godot-daedalus-backend";
|
|
6
|
+
const PACKAGE_ROOT: string = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
|
|
7
|
+
const PACKAGE_JSON_PATH: string = resolve(PACKAGE_ROOT, "package.json");
|
|
8
|
+
|
|
9
|
+
type PackageManifest = {
|
|
10
|
+
name?: unknown;
|
|
11
|
+
version?: unknown;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type BackendHealthResult = {
|
|
15
|
+
name: string;
|
|
16
|
+
version: string;
|
|
17
|
+
pid: number;
|
|
18
|
+
mode: "development" | "runtime";
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
let cachedPackageVersion: string | null = null;
|
|
22
|
+
|
|
23
|
+
export function getBackendPackageVersion(): string {
|
|
24
|
+
if (cachedPackageVersion !== null) {
|
|
25
|
+
return cachedPackageVersion;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const envVersion: string | undefined = process.env.npm_package_version;
|
|
29
|
+
if (envVersion !== undefined && envVersion.trim() !== "") {
|
|
30
|
+
cachedPackageVersion = envVersion.trim();
|
|
31
|
+
return cachedPackageVersion;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const manifestText: string = readFileSync(PACKAGE_JSON_PATH, "utf8");
|
|
36
|
+
const manifest: PackageManifest = JSON.parse(manifestText) as PackageManifest;
|
|
37
|
+
if (typeof manifest.version === "string" && manifest.version.trim() !== "") {
|
|
38
|
+
cachedPackageVersion = manifest.version.trim();
|
|
39
|
+
return cachedPackageVersion;
|
|
40
|
+
}
|
|
41
|
+
} catch {
|
|
42
|
+
// health 不能因为版本元数据不可读而阻断 WebSocket 启动。
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
cachedPackageVersion = "0.0.0";
|
|
46
|
+
return cachedPackageVersion;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function getBackendRuntimeMode(): "development" | "runtime" {
|
|
50
|
+
if (process.env.NODE_ENV === "development" || process.env.npm_lifecycle_event === "dev") {
|
|
51
|
+
return "development";
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return "runtime";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function createBackendHealthResult(): BackendHealthResult {
|
|
58
|
+
return {
|
|
59
|
+
name: BACKEND_HEALTH_NAME,
|
|
60
|
+
version: getBackendPackageVersion(),
|
|
61
|
+
pid: process.pid,
|
|
62
|
+
mode: getBackendRuntimeMode()
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
type DeepSeekAgentContinuation,
|
|
11
11
|
type DeepSeekAgentResult
|
|
12
12
|
} from "../providers/deepseek-agent.js";
|
|
13
|
-
import type { OnToolEvent } from "../tools/tool-dispatcher.js";
|
|
13
|
+
import type { OnToolEvent, ToolEvent } from "../tools/tool-dispatcher.js";
|
|
14
14
|
import { chatWithDeepSeek, createDeepSeekClient, type DeepSeekChatOptions } from "../providers/deepseek-client.js";
|
|
15
15
|
import { McpHost } from "../mcp/mcp-host.js";
|
|
16
16
|
import type { CustomMcpServerRuntimeStatus } from "../mcp/mcp-host.js";
|
|
@@ -44,7 +44,7 @@ import {
|
|
|
44
44
|
archiveSession, deleteArchivedSession, deleteSession, listArchivedSessions, renameSession, restoreArchivedSession,
|
|
45
45
|
rewindSessionFromRequest,
|
|
46
46
|
readSummary, writeSummary,
|
|
47
|
-
appendSessionEvent, clearSessionEvents,
|
|
47
|
+
appendSessionEvent, appendApprovalEvent, clearSessionEvents, readApprovalEvents,
|
|
48
48
|
openSessionRecentTimeline, openSessionTimelinePage,
|
|
49
49
|
type SessionMetadata,
|
|
50
50
|
type SessionSummary,
|
|
@@ -80,6 +80,18 @@ import {
|
|
|
80
80
|
type ThinkingEventBuffer
|
|
81
81
|
} from "./client-session.js";
|
|
82
82
|
import { assertKnownRequestMethod } from "./request-dispatcher.js";
|
|
83
|
+
import { getToolPolicy } from "../tools/tool-policy.js";
|
|
84
|
+
import type { PendingApproval } from "../tools/approval-gateway.js";
|
|
85
|
+
import { getLlmToolExecutionIdentity } from "../tools/tool-idempotency.js";
|
|
86
|
+
import { resolveToolMapping } from "../tools/llm-tools.js";
|
|
87
|
+
import {
|
|
88
|
+
createPersistedApprovalRequestedData,
|
|
89
|
+
createRuntimePendingContinuation,
|
|
90
|
+
foldPendingApprovalStates,
|
|
91
|
+
serializePendingApprovalState,
|
|
92
|
+
type PendingApprovalState
|
|
93
|
+
} from "../session/approval-persistence.js";
|
|
94
|
+
import { createBackendHealthResult } from "./backend-health.js";
|
|
83
95
|
|
|
84
96
|
const tokenCounterPromise: Promise<TokenCounter> = createTokenCounter();
|
|
85
97
|
let sessionCompressorPromptCache: string | undefined;
|
|
@@ -98,6 +110,18 @@ const MAX_NEXT_STEP_HINT_COUNT: number = 5;
|
|
|
98
110
|
const MAX_NEXT_STEP_HINT_MESSAGE_CHARS: number = 320;
|
|
99
111
|
const MAX_GUIDE_TEXT_CHARS: number = 4000;
|
|
100
112
|
|
|
113
|
+
type WorkflowPhaseToolStats = {
|
|
114
|
+
toolEvents: number;
|
|
115
|
+
proposeToolEvents: number;
|
|
116
|
+
writeToolEvents: number;
|
|
117
|
+
approvalEvents: number;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
type WorkflowPhaseRunResult = {
|
|
121
|
+
agentResult: DeepSeekAgentResult;
|
|
122
|
+
toolStats: WorkflowPhaseToolStats;
|
|
123
|
+
};
|
|
124
|
+
|
|
101
125
|
function fingerprintText(text: string): string {
|
|
102
126
|
if (text.length === 0) {
|
|
103
127
|
return "empty";
|
|
@@ -1085,6 +1109,73 @@ function createToolEventForwarder(socket: WebSocket, requestId: string, session:
|
|
|
1085
1109
|
};
|
|
1086
1110
|
}
|
|
1087
1111
|
|
|
1112
|
+
function createEmptyWorkflowPhaseToolStats(): WorkflowPhaseToolStats {
|
|
1113
|
+
return {
|
|
1114
|
+
toolEvents: 0,
|
|
1115
|
+
proposeToolEvents: 0,
|
|
1116
|
+
writeToolEvents: 0,
|
|
1117
|
+
approvalEvents: 0
|
|
1118
|
+
};
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
function updateWorkflowPhaseToolStats(stats: WorkflowPhaseToolStats, event: ToolEvent): void {
|
|
1122
|
+
if (!event.type.startsWith("tool.")) {
|
|
1123
|
+
return;
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
stats.toolEvents += 1;
|
|
1127
|
+
|
|
1128
|
+
if (event.type === "tool.approval_required") {
|
|
1129
|
+
stats.approvalEvents += 1;
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
const toolName: string | undefined = "toolName" in event ? event.toolName : undefined;
|
|
1133
|
+
if (toolName === undefined) {
|
|
1134
|
+
return;
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
const policy = getToolPolicy(toolName);
|
|
1138
|
+
if (policy?.risk === "propose") {
|
|
1139
|
+
stats.proposeToolEvents += 1;
|
|
1140
|
+
}
|
|
1141
|
+
if (policy?.risk === "write" || policy?.risk === "destructive") {
|
|
1142
|
+
stats.writeToolEvents += 1;
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
function shouldRequireWorkflowWriteTool(phase: WorkflowPhase): boolean {
|
|
1147
|
+
return phase.toolGroup === "write";
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
function didWorkflowWritePhaseExecute(phase: WorkflowPhase, stats: WorkflowPhaseToolStats): boolean {
|
|
1151
|
+
if (stats.writeToolEvents > 0 || stats.approvalEvents > 0) {
|
|
1152
|
+
return true;
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
return isWorkflowProposalPhase(phase) && stats.proposeToolEvents > 0;
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
function isWorkflowProposalPhase(phase: WorkflowPhase): boolean {
|
|
1159
|
+
const text: string = `${phase.id}\n${phase.title}\n${phase.instruction}`.toLowerCase();
|
|
1160
|
+
return text.includes("propose")
|
|
1161
|
+
|| text.includes("preview")
|
|
1162
|
+
|| text.includes("diff")
|
|
1163
|
+
|| text.includes("预览")
|
|
1164
|
+
|| text.includes("提案")
|
|
1165
|
+
|| text.includes("方案");
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
function createWorkflowWriteGuardRetryMessage(phaseMessage: string): string {
|
|
1169
|
+
return [
|
|
1170
|
+
phaseMessage,
|
|
1171
|
+
"",
|
|
1172
|
+
"## 后端执行守卫",
|
|
1173
|
+
"上一次候选回复没有实际调用当前阶段需要的 propose/write 工具,也没有触发审批,因此当前阶段还没有完成。",
|
|
1174
|
+
"如果当前阶段是预览/提案,请调用允许的 propose_* 工具;如果当前阶段是实际修改,请调用写入工具并按审批流程暂停。",
|
|
1175
|
+
"不要只描述计划、步骤或意图。"
|
|
1176
|
+
].join("\n");
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1088
1179
|
function createPendingAiContinuation(
|
|
1089
1180
|
params: AiChatParams,
|
|
1090
1181
|
options: DeepSeekChatOptions,
|
|
@@ -1117,6 +1208,155 @@ function createPendingAiContinuation(
|
|
|
1117
1208
|
return pendingContinuation;
|
|
1118
1209
|
}
|
|
1119
1210
|
|
|
1211
|
+
async function persistApprovalRequested(
|
|
1212
|
+
session: ClientSession,
|
|
1213
|
+
mcpHost: McpHost,
|
|
1214
|
+
approvalId: string,
|
|
1215
|
+
pendingContinuation: PendingAiContinuation
|
|
1216
|
+
): Promise<void> {
|
|
1217
|
+
if (session.sessionId === undefined) {
|
|
1218
|
+
return;
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
const pendingApproval: PendingApproval | undefined = session.approvalGateway.getPending(approvalId);
|
|
1222
|
+
if (pendingApproval === undefined) {
|
|
1223
|
+
return;
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
await appendApprovalEvent(
|
|
1227
|
+
session.sessionId,
|
|
1228
|
+
approvalId,
|
|
1229
|
+
pendingContinuation.requestId,
|
|
1230
|
+
"requested",
|
|
1231
|
+
createPersistedApprovalRequestedData(pendingApproval, pendingContinuation, mcpHost.getActiveWorkspaceId())
|
|
1232
|
+
);
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
async function registerPendingApprovalContinuation(
|
|
1236
|
+
session: ClientSession,
|
|
1237
|
+
mcpHost: McpHost,
|
|
1238
|
+
approvalId: string,
|
|
1239
|
+
pendingContinuation: PendingAiContinuation
|
|
1240
|
+
): Promise<void> {
|
|
1241
|
+
session.pendingAiContinuations.set(approvalId, pendingContinuation);
|
|
1242
|
+
await persistApprovalRequested(session, mcpHost, approvalId, pendingContinuation);
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
async function loadHydratedPendingApprovalStates(
|
|
1246
|
+
session: ClientSession,
|
|
1247
|
+
apiKey?: string | undefined
|
|
1248
|
+
): Promise<{ states: PendingApprovalState[]; hadEvents: boolean }> {
|
|
1249
|
+
if (session.sessionId === undefined) {
|
|
1250
|
+
return {
|
|
1251
|
+
states: createMemoryPendingApprovalStates(session),
|
|
1252
|
+
hadEvents: false
|
|
1253
|
+
};
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
const approvalEvents = await readApprovalEvents(session.sessionId);
|
|
1257
|
+
if (approvalEvents.length === 0) {
|
|
1258
|
+
return {
|
|
1259
|
+
states: createMemoryPendingApprovalStates(session),
|
|
1260
|
+
hadEvents: false
|
|
1261
|
+
};
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
const states: PendingApprovalState[] = foldPendingApprovalStates(approvalEvents);
|
|
1265
|
+
session.approvalGateway.replacePending(states.map((state: PendingApprovalState): PendingApproval => state.approval));
|
|
1266
|
+
const pendingIds: Set<string> = new Set(states.map((state: PendingApprovalState): string => state.approval.approvalId));
|
|
1267
|
+
for (const approvalId of session.pendingAiContinuations.keys()) {
|
|
1268
|
+
if (!pendingIds.has(approvalId)) {
|
|
1269
|
+
session.pendingAiContinuations.delete(approvalId);
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
if (apiKey !== undefined) {
|
|
1274
|
+
for (const state of states) {
|
|
1275
|
+
if (state.continuation !== undefined) {
|
|
1276
|
+
session.pendingAiContinuations.set(state.approval.approvalId, createRuntimePendingContinuation(state.continuation, apiKey));
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
return {
|
|
1282
|
+
states,
|
|
1283
|
+
hadEvents: true
|
|
1284
|
+
};
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
function createMemoryPendingApprovalStates(session: ClientSession): PendingApprovalState[] {
|
|
1288
|
+
return session.approvalGateway.listPending().map((pendingApproval: PendingApproval): PendingApprovalState => {
|
|
1289
|
+
const timestamp: string = new Date(pendingApproval.createdAt).toISOString();
|
|
1290
|
+
return {
|
|
1291
|
+
approval: pendingApproval,
|
|
1292
|
+
status: "pending",
|
|
1293
|
+
restored: false,
|
|
1294
|
+
interrupted: false,
|
|
1295
|
+
requestId: "",
|
|
1296
|
+
createdAt: timestamp,
|
|
1297
|
+
updatedAt: timestamp
|
|
1298
|
+
};
|
|
1299
|
+
});
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
function findPendingApprovalState(states: PendingApprovalState[], approvalId: string): PendingApprovalState | undefined {
|
|
1303
|
+
return states.find((state: PendingApprovalState): boolean => state.approval.approvalId === approvalId);
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
async function restorePendingContinuationForApproval(
|
|
1307
|
+
session: ClientSession,
|
|
1308
|
+
state: PendingApprovalState | undefined,
|
|
1309
|
+
apiKey: string | undefined
|
|
1310
|
+
): Promise<PendingAiContinuation | undefined> {
|
|
1311
|
+
const approvalId: string | undefined = state?.approval.approvalId;
|
|
1312
|
+
if (approvalId !== undefined) {
|
|
1313
|
+
const existingContinuation: PendingAiContinuation | undefined = session.pendingAiContinuations.get(approvalId);
|
|
1314
|
+
if (existingContinuation !== undefined) {
|
|
1315
|
+
return existingContinuation;
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
if (state?.continuation === undefined || apiKey === undefined) {
|
|
1320
|
+
return undefined;
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
const restoredContinuation: PendingAiContinuation = createRuntimePendingContinuation(state.continuation, apiKey);
|
|
1324
|
+
session.pendingAiContinuations.set(state.approval.approvalId, restoredContinuation);
|
|
1325
|
+
return restoredContinuation;
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
async function validatePendingApprovalBeforeExecution(
|
|
1329
|
+
session: ClientSession,
|
|
1330
|
+
mcpHost: McpHost,
|
|
1331
|
+
pendingApproval: PendingApproval
|
|
1332
|
+
): Promise<string | null> {
|
|
1333
|
+
const decision = await session.approvalGateway.evaluate(pendingApproval.llmToolName, pendingApproval.args, pendingApproval.toolCallId);
|
|
1334
|
+
if (decision.action === "deny") {
|
|
1335
|
+
return decision.reason;
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
try {
|
|
1339
|
+
resolveToolMapping(pendingApproval.llmToolName);
|
|
1340
|
+
} catch (error: unknown) {
|
|
1341
|
+
return error instanceof Error ? error.message : "审批工具当前不可用";
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
const currentIdentity = getLlmToolExecutionIdentity(
|
|
1345
|
+
pendingApproval.llmToolName,
|
|
1346
|
+
pendingApproval.args,
|
|
1347
|
+
mcpHost.getActiveWorkspaceId()
|
|
1348
|
+
);
|
|
1349
|
+
if (
|
|
1350
|
+
pendingApproval.executionFingerprint !== undefined
|
|
1351
|
+
&& currentIdentity !== undefined
|
|
1352
|
+
&& currentIdentity.fingerprint !== pendingApproval.executionFingerprint
|
|
1353
|
+
) {
|
|
1354
|
+
return "当前 workspace 与创建审批时不一致,不能执行该审批。";
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
return null;
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1120
1360
|
function sendAiPaused(socket: WebSocket, requestId: string, agentResult: Extract<DeepSeekAgentResult, { status: "approval_required" }>): void {
|
|
1121
1361
|
sendJson(socket, {
|
|
1122
1362
|
type: "event",
|
|
@@ -1152,7 +1392,7 @@ async function sendContinuedAgentResult(
|
|
|
1152
1392
|
pendingContinuation.stream,
|
|
1153
1393
|
pendingContinuation.workflowState
|
|
1154
1394
|
);
|
|
1155
|
-
session
|
|
1395
|
+
await registerPendingApprovalContinuation(session, mcpHost, agentResult.approvalId, nextPendingContinuation);
|
|
1156
1396
|
sendAiPaused(socket, requestId, agentResult);
|
|
1157
1397
|
return;
|
|
1158
1398
|
}
|
|
@@ -1223,11 +1463,20 @@ async function runWorkflowPhase(
|
|
|
1223
1463
|
persistRequestId: string,
|
|
1224
1464
|
streamPhase: boolean,
|
|
1225
1465
|
abortSignal?: AbortSignal | undefined
|
|
1226
|
-
): Promise<
|
|
1227
|
-
const
|
|
1228
|
-
|
|
1466
|
+
): Promise<WorkflowPhaseRunResult> {
|
|
1467
|
+
const toolStats: WorkflowPhaseToolStats = createEmptyWorkflowPhaseToolStats();
|
|
1468
|
+
const forwardToolEvent: OnToolEvent = createToolEventForwarder(socket, requestId, session, persistRequestId);
|
|
1469
|
+
const onToolEvent: OnToolEvent = (event: ToolEvent): void => {
|
|
1470
|
+
updateWorkflowPhaseToolStats(toolStats, event);
|
|
1471
|
+
forwardToolEvent(event);
|
|
1472
|
+
};
|
|
1473
|
+
const agentResult: DeepSeekAgentResult = streamPhase
|
|
1229
1474
|
? await runDeepSeekAgentStreaming(params, options, history, fullSystemPrompt, mcpHost, session.approvalGateway, phase.allowedTools, onToolEvent, abortSignal)
|
|
1230
1475
|
: await runDeepSeekAgent(params, options, history, fullSystemPrompt, mcpHost, session.approvalGateway, phase.allowedTools, onToolEvent, abortSignal);
|
|
1476
|
+
return {
|
|
1477
|
+
agentResult,
|
|
1478
|
+
toolStats
|
|
1479
|
+
};
|
|
1231
1480
|
}
|
|
1232
1481
|
|
|
1233
1482
|
async function createWorkflowPhasePrompt(
|
|
@@ -1336,21 +1585,59 @@ async function continueWorkflowExecution(
|
|
|
1336
1585
|
].filter((section: string): boolean => section.length > 0).join("\n\n");
|
|
1337
1586
|
const fullSystemPrompt: string = await createWorkflowPhasePrompt(phase, phaseParams, mcpHost, session, requestId, guidePromptSection);
|
|
1338
1587
|
let agentResult: DeepSeekAgentResult;
|
|
1588
|
+
let phaseToolStats: WorkflowPhaseToolStats = createEmptyWorkflowPhaseToolStats();
|
|
1339
1589
|
try {
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1590
|
+
if (agentResultOverride !== undefined) {
|
|
1591
|
+
agentResult = agentResultOverride;
|
|
1592
|
+
phaseToolStats.approvalEvents = 1;
|
|
1593
|
+
phaseToolStats.writeToolEvents = 1;
|
|
1594
|
+
} else {
|
|
1595
|
+
let phaseRunResult: WorkflowPhaseRunResult = await runWorkflowPhase(
|
|
1596
|
+
socket,
|
|
1597
|
+
phaseParams,
|
|
1598
|
+
options,
|
|
1599
|
+
state.history,
|
|
1600
|
+
fullSystemPrompt,
|
|
1601
|
+
phase,
|
|
1602
|
+
mcpHost,
|
|
1603
|
+
session,
|
|
1604
|
+
requestId,
|
|
1605
|
+
persistRequestId,
|
|
1606
|
+
streamPhase,
|
|
1607
|
+
abortSignal
|
|
1608
|
+
);
|
|
1609
|
+
agentResult = phaseRunResult.agentResult;
|
|
1610
|
+
phaseToolStats = phaseRunResult.toolStats;
|
|
1611
|
+
|
|
1612
|
+
if (
|
|
1613
|
+
agentResult.status === "completed"
|
|
1614
|
+
&& shouldRequireWorkflowWriteTool(phase)
|
|
1615
|
+
&& !didWorkflowWritePhaseExecute(phase, phaseToolStats)
|
|
1616
|
+
) {
|
|
1617
|
+
const retryPhaseParams: AiChatParams = createPhaseParams(
|
|
1618
|
+
state.originalParams,
|
|
1619
|
+
phase,
|
|
1620
|
+
createWorkflowWriteGuardRetryMessage(phaseMessage),
|
|
1621
|
+
false
|
|
1622
|
+
);
|
|
1623
|
+
phaseRunResult = await runWorkflowPhase(
|
|
1624
|
+
socket,
|
|
1625
|
+
retryPhaseParams,
|
|
1626
|
+
options,
|
|
1627
|
+
state.history,
|
|
1628
|
+
fullSystemPrompt,
|
|
1629
|
+
phase,
|
|
1630
|
+
mcpHost,
|
|
1631
|
+
session,
|
|
1632
|
+
requestId,
|
|
1633
|
+
persistRequestId,
|
|
1634
|
+
false,
|
|
1635
|
+
abortSignal
|
|
1636
|
+
);
|
|
1637
|
+
agentResult = phaseRunResult.agentResult;
|
|
1638
|
+
phaseToolStats = phaseRunResult.toolStats;
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1354
1641
|
} catch (error: unknown) {
|
|
1355
1642
|
throw new WorkflowExecutionError(error instanceof Error ? error.message : "Workflow phase failed", plan, error);
|
|
1356
1643
|
}
|
|
@@ -1359,7 +1646,7 @@ async function continueWorkflowExecution(
|
|
|
1359
1646
|
if (agentResult.status === "approval_required") {
|
|
1360
1647
|
plan = updateWorkflowPhaseStatus(plan, phase.id, "paused");
|
|
1361
1648
|
const pausedState: WorkflowRunState = { ...state, plan, phaseIndex: index, phaseOutputs };
|
|
1362
|
-
|
|
1649
|
+
const pendingContinuation: PendingAiContinuation = createWorkflowPendingContinuation(
|
|
1363
1650
|
phaseParams,
|
|
1364
1651
|
options,
|
|
1365
1652
|
agentResult,
|
|
@@ -1368,12 +1655,22 @@ async function continueWorkflowExecution(
|
|
|
1368
1655
|
persistRequestId,
|
|
1369
1656
|
userCreatedAt,
|
|
1370
1657
|
streamPhase
|
|
1371
|
-
)
|
|
1658
|
+
);
|
|
1659
|
+
await registerPendingApprovalContinuation(session, mcpHost, agentResult.approvalId, pendingContinuation);
|
|
1372
1660
|
sendWorkflowTodoSnapshot(socket, requestId, session, plan, persistRequestId);
|
|
1373
1661
|
sendAiPaused(socket, requestId, agentResult);
|
|
1374
1662
|
return;
|
|
1375
1663
|
}
|
|
1376
1664
|
|
|
1665
|
+
if (shouldRequireWorkflowWriteTool(phase) && !didWorkflowWritePhaseExecute(phase, phaseToolStats)) {
|
|
1666
|
+
const guardMessage: string = `写入阶段「${phase.title}」没有实际调用写入工具或触发审批,已阻止将该 Todo 标记为完成。`;
|
|
1667
|
+
throw new WorkflowExecutionError(
|
|
1668
|
+
guardMessage,
|
|
1669
|
+
plan,
|
|
1670
|
+
new Error(guardMessage)
|
|
1671
|
+
);
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1377
1674
|
phaseOutputs = appendPhaseOutput(phaseOutputs, phase, agentResult.text);
|
|
1378
1675
|
plan = updateWorkflowPhaseStatus(plan, phase.id, "done");
|
|
1379
1676
|
state = { ...state, plan, phaseIndex: index + 1, phaseOutputs };
|
|
@@ -1996,12 +2293,7 @@ async function handleRequest(socket: WebSocket, request: ClientRequest, session:
|
|
|
1996
2293
|
type: "response",
|
|
1997
2294
|
id: request.id,
|
|
1998
2295
|
ok: true,
|
|
1999
|
-
result:
|
|
2000
|
-
name: "godot-daedalus-backend",
|
|
2001
|
-
version: "1.0.1",
|
|
2002
|
-
pid: process.pid,
|
|
2003
|
-
mode: process.env.NODE_ENV === "development" || process.env.npm_lifecycle_event === "dev" ? "development" : "runtime"
|
|
2004
|
-
}
|
|
2296
|
+
result: createBackendHealthResult()
|
|
2005
2297
|
});
|
|
2006
2298
|
break;
|
|
2007
2299
|
|
|
@@ -2273,7 +2565,7 @@ async function handleRequest(socket: WebSocket, request: ClientRequest, session:
|
|
|
2273
2565
|
const agentResult: DeepSeekAgentResult = await runDeepSeekAgentStreaming(params, options, history, fullSystemPrompt, mcpHost, session.approvalGateway, allowedToolNames, onToolEvent, abortController.signal);
|
|
2274
2566
|
|
|
2275
2567
|
if (agentResult.status === "approval_required") {
|
|
2276
|
-
|
|
2568
|
+
const pendingContinuation: PendingAiContinuation = createPendingAiContinuation(
|
|
2277
2569
|
params,
|
|
2278
2570
|
options,
|
|
2279
2571
|
agentResult.continuation,
|
|
@@ -2282,7 +2574,8 @@ async function handleRequest(socket: WebSocket, request: ClientRequest, session:
|
|
|
2282
2574
|
request.id,
|
|
2283
2575
|
turnStartedAt,
|
|
2284
2576
|
true
|
|
2285
|
-
)
|
|
2577
|
+
);
|
|
2578
|
+
await registerPendingApprovalContinuation(session, mcpHost, agentResult.approvalId, pendingContinuation);
|
|
2286
2579
|
sendAiPaused(socket, request.id, agentResult);
|
|
2287
2580
|
break;
|
|
2288
2581
|
}
|
|
@@ -2308,7 +2601,7 @@ async function handleRequest(socket: WebSocket, request: ClientRequest, session:
|
|
|
2308
2601
|
const agentResult: DeepSeekAgentResult = await runDeepSeekAgent(params, options, history, fullSystemPrompt, mcpHost, session.approvalGateway, allowedToolNames, onToolEvent, abortController.signal);
|
|
2309
2602
|
|
|
2310
2603
|
if (agentResult.status === "approval_required") {
|
|
2311
|
-
|
|
2604
|
+
const pendingContinuation: PendingAiContinuation = createPendingAiContinuation(
|
|
2312
2605
|
params,
|
|
2313
2606
|
options,
|
|
2314
2607
|
agentResult.continuation,
|
|
@@ -2317,7 +2610,8 @@ async function handleRequest(socket: WebSocket, request: ClientRequest, session:
|
|
|
2317
2610
|
request.id,
|
|
2318
2611
|
turnStartedAt,
|
|
2319
2612
|
false
|
|
2320
|
-
)
|
|
2613
|
+
);
|
|
2614
|
+
await registerPendingApprovalContinuation(session, mcpHost, agentResult.approvalId, pendingContinuation);
|
|
2321
2615
|
sendJson(socket, {
|
|
2322
2616
|
type: "response",
|
|
2323
2617
|
id: request.id,
|
|
@@ -2509,6 +2803,7 @@ async function handleRequest(socket: WebSocket, request: ClientRequest, session:
|
|
|
2509
2803
|
|
|
2510
2804
|
case "session.info":
|
|
2511
2805
|
await waitForFullSessionLoad(session);
|
|
2806
|
+
await loadHydratedPendingApprovalStates(session);
|
|
2512
2807
|
sendJson(socket, {
|
|
2513
2808
|
type: "response",
|
|
2514
2809
|
id: request.id,
|
|
@@ -3570,16 +3865,19 @@ async function handleRequest(socket: WebSocket, request: ClientRequest, session:
|
|
|
3570
3865
|
}
|
|
3571
3866
|
|
|
3572
3867
|
case "approval.list":
|
|
3868
|
+
{
|
|
3869
|
+
const hydrated = await loadHydratedPendingApprovalStates(session);
|
|
3573
3870
|
sendJson(socket, {
|
|
3574
3871
|
type: "response",
|
|
3575
3872
|
id: request.id,
|
|
3576
3873
|
ok: true,
|
|
3577
3874
|
result: {
|
|
3578
|
-
pending:
|
|
3875
|
+
pending: hydrated.states.map(serializePendingApprovalState),
|
|
3579
3876
|
mode: session.approvalGateway.getMode()
|
|
3580
3877
|
}
|
|
3581
3878
|
});
|
|
3582
3879
|
break;
|
|
3880
|
+
}
|
|
3583
3881
|
|
|
3584
3882
|
case "approval.mode.set":
|
|
3585
3883
|
session.approvalGateway.setMode(request.params.mode);
|
|
@@ -3598,6 +3896,8 @@ async function handleRequest(socket: WebSocket, request: ClientRequest, session:
|
|
|
3598
3896
|
const abortController: AbortController = new AbortController();
|
|
3599
3897
|
session.activeAbortControllers.set(request.id, abortController);
|
|
3600
3898
|
try {
|
|
3899
|
+
const apiKey: string | undefined = await ensureProviderConfigured(session);
|
|
3900
|
+
const hydrated = await loadHydratedPendingApprovalStates(session, apiKey);
|
|
3601
3901
|
const pending = session.approvalGateway.getPending(request.params.approvalId);
|
|
3602
3902
|
if (!pending) {
|
|
3603
3903
|
sendJson(socket, {
|
|
@@ -3609,8 +3909,54 @@ async function handleRequest(socket: WebSocket, request: ClientRequest, session:
|
|
|
3609
3909
|
break;
|
|
3610
3910
|
}
|
|
3611
3911
|
|
|
3612
|
-
const
|
|
3912
|
+
const validationError: string | null = await validatePendingApprovalBeforeExecution(session, mcpHost, pending);
|
|
3913
|
+
if (validationError !== null) {
|
|
3914
|
+
if (session.sessionId !== undefined) {
|
|
3915
|
+
await appendApprovalEvent(session.sessionId, pending.approvalId, findPendingApprovalState(hydrated.states, pending.approvalId)?.requestId ?? request.id, "failed", {
|
|
3916
|
+
message: validationError
|
|
3917
|
+
});
|
|
3918
|
+
}
|
|
3919
|
+
sendJson(socket, {
|
|
3920
|
+
type: "response",
|
|
3921
|
+
id: request.id,
|
|
3922
|
+
ok: false,
|
|
3923
|
+
error: { code: "approval_validation_failed", message: validationError }
|
|
3924
|
+
});
|
|
3925
|
+
break;
|
|
3926
|
+
}
|
|
3927
|
+
|
|
3928
|
+
const pendingState: PendingApprovalState | undefined = findPendingApprovalState(hydrated.states, request.params.approvalId);
|
|
3929
|
+
const pendingContinuation: PendingAiContinuation | undefined = await restorePendingContinuationForApproval(session, pendingState, apiKey);
|
|
3930
|
+
if (pendingState?.continuation !== undefined && pendingContinuation === undefined) {
|
|
3931
|
+
const message: string = "当前没有可用的 DeepSeek API key,无法恢复审批后的 LLM continuation。请先配置 provider 后重试。";
|
|
3932
|
+
if (session.sessionId !== undefined) {
|
|
3933
|
+
await appendApprovalEvent(session.sessionId, pending.approvalId, pendingState.requestId, "failed", { message });
|
|
3934
|
+
}
|
|
3935
|
+
sendJson(socket, {
|
|
3936
|
+
type: "response",
|
|
3937
|
+
id: request.id,
|
|
3938
|
+
ok: false,
|
|
3939
|
+
error: { code: "provider_not_configured", message }
|
|
3940
|
+
});
|
|
3941
|
+
break;
|
|
3942
|
+
}
|
|
3943
|
+
const approvalPersistRequestId: string = pendingContinuation?.requestId ?? pendingState?.requestId ?? request.id;
|
|
3944
|
+
if (session.sessionId !== undefined) {
|
|
3945
|
+
await appendApprovalEvent(session.sessionId, pending.approvalId, approvalPersistRequestId, "approved", {
|
|
3946
|
+
approvedAt: new Date().toISOString()
|
|
3947
|
+
});
|
|
3948
|
+
await appendApprovalEvent(session.sessionId, pending.approvalId, approvalPersistRequestId, "executing", {
|
|
3949
|
+
startedAt: new Date().toISOString()
|
|
3950
|
+
});
|
|
3951
|
+
}
|
|
3613
3952
|
const result = await session.approvalGateway.approve(request.params.approvalId, mcpHost);
|
|
3953
|
+
if (session.sessionId !== undefined) {
|
|
3954
|
+
await appendApprovalEvent(session.sessionId, pending.approvalId, approvalPersistRequestId, "executed", {
|
|
3955
|
+
resultChars: result.content.length,
|
|
3956
|
+
cached: result.cached === true,
|
|
3957
|
+
executedAt: new Date().toISOString()
|
|
3958
|
+
});
|
|
3959
|
+
}
|
|
3614
3960
|
|
|
3615
3961
|
sendJson(socket, {
|
|
3616
3962
|
type: "response",
|
|
@@ -3707,6 +4053,11 @@ async function handleRequest(socket: WebSocket, request: ClientRequest, session:
|
|
|
3707
4053
|
sendAiCancelled(socket, request.id);
|
|
3708
4054
|
break;
|
|
3709
4055
|
}
|
|
4056
|
+
if (session.sessionId !== undefined) {
|
|
4057
|
+
await appendApprovalEvent(session.sessionId, request.params.approvalId, request.id, "failed", {
|
|
4058
|
+
message: error instanceof Error ? error.message : "Approval failed"
|
|
4059
|
+
});
|
|
4060
|
+
}
|
|
3710
4061
|
sendJson(socket, {
|
|
3711
4062
|
type: "response",
|
|
3712
4063
|
id: request.id,
|
|
@@ -3724,7 +4075,15 @@ async function handleRequest(socket: WebSocket, request: ClientRequest, session:
|
|
|
3724
4075
|
|
|
3725
4076
|
case "approval.reject": {
|
|
3726
4077
|
try {
|
|
4078
|
+
const hydrated = await loadHydratedPendingApprovalStates(session);
|
|
4079
|
+
const pendingState: PendingApprovalState | undefined = findPendingApprovalState(hydrated.states, request.params.approvalId);
|
|
3727
4080
|
const rejected = session.approvalGateway.reject(request.params.approvalId);
|
|
4081
|
+
session.pendingAiContinuations.delete(request.params.approvalId);
|
|
4082
|
+
if (session.sessionId !== undefined) {
|
|
4083
|
+
await appendApprovalEvent(session.sessionId, request.params.approvalId, pendingState?.requestId ?? request.id, "rejected", {
|
|
4084
|
+
rejectedAt: new Date().toISOString()
|
|
4085
|
+
});
|
|
4086
|
+
}
|
|
3728
4087
|
sendJson(socket, {
|
|
3729
4088
|
type: "response",
|
|
3730
4089
|
id: request.id,
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import type { AiChatParams } from "../protocol/types.js";
|
|
2
|
+
import type { DeepSeekAgentContinuation } from "../providers/deepseek-agent.js";
|
|
3
|
+
import type { DeepSeekChatOptions } from "../providers/deepseek-client.js";
|
|
4
|
+
import type { PendingAiContinuation } from "../server/client-session.js";
|
|
5
|
+
import type { PendingApproval } from "../tools/approval-gateway.js";
|
|
6
|
+
import type { WorkflowRunState } from "../workflow/types.js";
|
|
7
|
+
import type { StoredApprovalEvent } from "./session-store.js";
|
|
8
|
+
|
|
9
|
+
export type PersistedDeepSeekChatOptions = {
|
|
10
|
+
model?: string | undefined;
|
|
11
|
+
baseUrl?: string | undefined;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type PersistedPendingAiContinuation = {
|
|
15
|
+
params: AiChatParams;
|
|
16
|
+
options: PersistedDeepSeekChatOptions;
|
|
17
|
+
continuation: DeepSeekAgentContinuation;
|
|
18
|
+
allowedToolNames?: readonly string[] | undefined;
|
|
19
|
+
userMessage: string;
|
|
20
|
+
requestId: string;
|
|
21
|
+
userCreatedAt: string;
|
|
22
|
+
stream: boolean;
|
|
23
|
+
workflowState?: WorkflowRunState | undefined;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type PersistedApprovalRequestedData = {
|
|
27
|
+
approval: PendingApproval;
|
|
28
|
+
continuation?: PersistedPendingAiContinuation | undefined;
|
|
29
|
+
workspaceId?: string | undefined;
|
|
30
|
+
createdAt: string;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type PendingApprovalState = {
|
|
34
|
+
approval: PendingApproval;
|
|
35
|
+
status: "pending" | "interrupted";
|
|
36
|
+
restored: boolean;
|
|
37
|
+
interrupted: boolean;
|
|
38
|
+
requestId: string;
|
|
39
|
+
createdAt: string;
|
|
40
|
+
updatedAt: string;
|
|
41
|
+
continuation?: PersistedPendingAiContinuation | undefined;
|
|
42
|
+
workspaceId?: string | undefined;
|
|
43
|
+
lastError?: string | undefined;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export function createPersistedApprovalRequestedData(
|
|
47
|
+
approval: PendingApproval,
|
|
48
|
+
continuation: PendingAiContinuation | undefined,
|
|
49
|
+
workspaceId: string | undefined
|
|
50
|
+
): PersistedApprovalRequestedData {
|
|
51
|
+
const data: PersistedApprovalRequestedData = {
|
|
52
|
+
approval,
|
|
53
|
+
createdAt: new Date(approval.createdAt).toISOString()
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
if (continuation !== undefined) {
|
|
57
|
+
data.continuation = createPersistedPendingContinuation(continuation);
|
|
58
|
+
}
|
|
59
|
+
if (workspaceId !== undefined) {
|
|
60
|
+
data.workspaceId = workspaceId;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return data;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function createRuntimePendingContinuation(
|
|
67
|
+
persisted: PersistedPendingAiContinuation,
|
|
68
|
+
apiKey: string
|
|
69
|
+
): PendingAiContinuation {
|
|
70
|
+
const options: DeepSeekChatOptions = { apiKey };
|
|
71
|
+
if (persisted.options.model !== undefined) {
|
|
72
|
+
options.model = persisted.options.model;
|
|
73
|
+
}
|
|
74
|
+
if (persisted.options.baseUrl !== undefined) {
|
|
75
|
+
options.baseUrl = persisted.options.baseUrl;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const continuation: PendingAiContinuation = {
|
|
79
|
+
params: persisted.params,
|
|
80
|
+
options,
|
|
81
|
+
continuation: persisted.continuation,
|
|
82
|
+
userMessage: persisted.userMessage,
|
|
83
|
+
requestId: persisted.requestId,
|
|
84
|
+
userCreatedAt: persisted.userCreatedAt,
|
|
85
|
+
stream: persisted.stream
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
if (persisted.allowedToolNames !== undefined) {
|
|
89
|
+
continuation.allowedToolNames = [...persisted.allowedToolNames];
|
|
90
|
+
}
|
|
91
|
+
if (persisted.workflowState !== undefined) {
|
|
92
|
+
continuation.workflowState = persisted.workflowState;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return continuation;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function foldPendingApprovalStates(events: StoredApprovalEvent[]): PendingApprovalState[] {
|
|
99
|
+
const states: Map<string, PendingApprovalState> = new Map();
|
|
100
|
+
const sortedEvents: StoredApprovalEvent[] = [...events].sort((left: StoredApprovalEvent, right: StoredApprovalEvent): number => {
|
|
101
|
+
const timeComparison: number = left.createdAt.localeCompare(right.createdAt);
|
|
102
|
+
return timeComparison !== 0 ? timeComparison : left.id.localeCompare(right.id);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
for (const event of sortedEvents) {
|
|
106
|
+
if (event.schemaVersion !== 1) {
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (event.event === "requested") {
|
|
111
|
+
const requestedData: PersistedApprovalRequestedData | null = parseRequestedData(event.data);
|
|
112
|
+
if (requestedData === null) {
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
states.set(event.approvalId, {
|
|
117
|
+
approval: requestedData.approval,
|
|
118
|
+
status: "pending",
|
|
119
|
+
restored: true,
|
|
120
|
+
interrupted: false,
|
|
121
|
+
requestId: event.requestId,
|
|
122
|
+
createdAt: event.createdAt,
|
|
123
|
+
updatedAt: event.createdAt,
|
|
124
|
+
continuation: requestedData.continuation,
|
|
125
|
+
workspaceId: requestedData.workspaceId
|
|
126
|
+
});
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const existing: PendingApprovalState | undefined = states.get(event.approvalId);
|
|
131
|
+
if (existing === undefined) {
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (event.event === "rejected" || event.event === "executed" || event.event === "cancelled") {
|
|
136
|
+
states.delete(event.approvalId);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (event.event === "executing") {
|
|
141
|
+
existing.status = "interrupted";
|
|
142
|
+
existing.interrupted = true;
|
|
143
|
+
existing.updatedAt = event.createdAt;
|
|
144
|
+
states.set(event.approvalId, existing);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (event.event === "approved") {
|
|
149
|
+
existing.updatedAt = event.createdAt;
|
|
150
|
+
states.set(event.approvalId, existing);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (event.event === "failed") {
|
|
155
|
+
existing.status = "pending";
|
|
156
|
+
existing.interrupted = false;
|
|
157
|
+
existing.updatedAt = event.createdAt;
|
|
158
|
+
existing.lastError = extractErrorMessage(event.data);
|
|
159
|
+
states.set(event.approvalId, existing);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return [...states.values()];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function serializePendingApprovalState(state: PendingApprovalState): Record<string, unknown> {
|
|
167
|
+
const result: Record<string, unknown> = {
|
|
168
|
+
...state.approval,
|
|
169
|
+
status: state.status,
|
|
170
|
+
restored: state.restored,
|
|
171
|
+
interrupted: state.interrupted,
|
|
172
|
+
requestId: state.requestId,
|
|
173
|
+
createdAt: state.createdAt,
|
|
174
|
+
updatedAt: state.updatedAt
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
if (state.lastError !== undefined) {
|
|
178
|
+
result.lastError = state.lastError;
|
|
179
|
+
}
|
|
180
|
+
if (state.workspaceId !== undefined) {
|
|
181
|
+
result.workspaceId = state.workspaceId;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return result;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function createPersistedPendingContinuation(continuation: PendingAiContinuation): PersistedPendingAiContinuation {
|
|
188
|
+
const options: PersistedDeepSeekChatOptions = {};
|
|
189
|
+
if (continuation.options.model !== undefined) {
|
|
190
|
+
options.model = continuation.options.model;
|
|
191
|
+
}
|
|
192
|
+
if (continuation.options.baseUrl !== undefined) {
|
|
193
|
+
options.baseUrl = continuation.options.baseUrl;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const persisted: PersistedPendingAiContinuation = {
|
|
197
|
+
params: continuation.params,
|
|
198
|
+
options,
|
|
199
|
+
continuation: continuation.continuation,
|
|
200
|
+
userMessage: continuation.userMessage,
|
|
201
|
+
requestId: continuation.requestId,
|
|
202
|
+
userCreatedAt: continuation.userCreatedAt,
|
|
203
|
+
stream: continuation.stream
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
if (continuation.allowedToolNames !== undefined) {
|
|
207
|
+
persisted.allowedToolNames = [...continuation.allowedToolNames];
|
|
208
|
+
}
|
|
209
|
+
if (continuation.workflowState !== undefined) {
|
|
210
|
+
persisted.workflowState = continuation.workflowState;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return persisted;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function parseRequestedData(value: unknown): PersistedApprovalRequestedData | null {
|
|
217
|
+
if (!isRecord(value)) {
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const approvalValue: unknown = value.approval;
|
|
222
|
+
if (!isPendingApproval(approvalValue)) {
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const data: PersistedApprovalRequestedData = {
|
|
227
|
+
approval: approvalValue,
|
|
228
|
+
createdAt: typeof value.createdAt === "string" ? value.createdAt : new Date(approvalValue.createdAt).toISOString()
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
if (isPersistedContinuation(value.continuation)) {
|
|
232
|
+
data.continuation = value.continuation;
|
|
233
|
+
}
|
|
234
|
+
if (typeof value.workspaceId === "string") {
|
|
235
|
+
data.workspaceId = value.workspaceId;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return data;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function isPersistedContinuation(value: unknown): value is PersistedPendingAiContinuation {
|
|
242
|
+
if (!isRecord(value)) {
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return isRecord(value.params)
|
|
247
|
+
&& isRecord(value.options)
|
|
248
|
+
&& isRecord(value.continuation)
|
|
249
|
+
&& typeof value.userMessage === "string"
|
|
250
|
+
&& typeof value.requestId === "string"
|
|
251
|
+
&& typeof value.userCreatedAt === "string"
|
|
252
|
+
&& typeof value.stream === "boolean";
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function isPendingApproval(value: unknown): value is PendingApproval {
|
|
256
|
+
if (!isRecord(value)) {
|
|
257
|
+
return false;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return typeof value.approvalId === "string"
|
|
261
|
+
&& typeof value.toolCallId === "string"
|
|
262
|
+
&& typeof value.toolName === "string"
|
|
263
|
+
&& typeof value.llmToolName === "string"
|
|
264
|
+
&& isRecord(value.args)
|
|
265
|
+
&& typeof value.reason === "string"
|
|
266
|
+
&& typeof value.createdAt === "number";
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function extractErrorMessage(value: unknown): string | undefined {
|
|
270
|
+
if (!isRecord(value)) {
|
|
271
|
+
return undefined;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return typeof value.message === "string" ? value.message : undefined;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
278
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
279
|
+
}
|
|
@@ -36,6 +36,16 @@ export type StoredSessionEvent = {
|
|
|
36
36
|
createdAt: string;
|
|
37
37
|
};
|
|
38
38
|
|
|
39
|
+
export type StoredApprovalEvent = {
|
|
40
|
+
id: string;
|
|
41
|
+
schemaVersion: 1;
|
|
42
|
+
approvalId: string;
|
|
43
|
+
requestId: string;
|
|
44
|
+
event: string;
|
|
45
|
+
data: unknown;
|
|
46
|
+
createdAt: string;
|
|
47
|
+
};
|
|
48
|
+
|
|
39
49
|
export type StoredSession = {
|
|
40
50
|
metadata: SessionMetadata;
|
|
41
51
|
messages: StoredMessage[];
|
|
@@ -68,7 +78,7 @@ function assertSafeSessionId(sessionId: string): string {
|
|
|
68
78
|
return sessionId;
|
|
69
79
|
}
|
|
70
80
|
|
|
71
|
-
function getSessionDir(sessionId: string): string {
|
|
81
|
+
export function getSessionDir(sessionId: string): string {
|
|
72
82
|
return join(SESSIONS_DIR, assertSafeSessionId(sessionId));
|
|
73
83
|
}
|
|
74
84
|
|
|
@@ -119,6 +129,10 @@ function eventsPath(sessionId: string): string {
|
|
|
119
129
|
return join(getSessionDir(sessionId), "events.jsonl");
|
|
120
130
|
}
|
|
121
131
|
|
|
132
|
+
function approvalEventsPath(sessionId: string): string {
|
|
133
|
+
return join(getSessionDir(sessionId), "approval-events.jsonl");
|
|
134
|
+
}
|
|
135
|
+
|
|
122
136
|
export async function createSession(title: string, workspaceId?: string, skillId?: string): Promise<SessionMetadata> {
|
|
123
137
|
const timestamp: string = new Date().toISOString();
|
|
124
138
|
const dateStr: string = timestamp.slice(0, 10).replace(/-/g, "");
|
|
@@ -137,6 +151,7 @@ export async function createSession(title: string, workspaceId?: string, skillId
|
|
|
137
151
|
await writeFile(join(dir, "metadata.json"), JSON.stringify(metadata, null, 2), "utf8");
|
|
138
152
|
await writeFile(join(dir, "messages.jsonl"), "", "utf8");
|
|
139
153
|
await writeFile(join(dir, "events.jsonl"), "", "utf8");
|
|
154
|
+
await writeFile(join(dir, "approval-events.jsonl"), "", "utf8");
|
|
140
155
|
|
|
141
156
|
return metadata;
|
|
142
157
|
}
|
|
@@ -451,6 +466,18 @@ export async function rewindSessionFromRequest(sessionId: string, requestId: str
|
|
|
451
466
|
keptEvents.map((event: StoredSessionEvent): string => JSON.stringify(event) + "\n").join(""),
|
|
452
467
|
"utf8"
|
|
453
468
|
);
|
|
469
|
+
try {
|
|
470
|
+
const rawApprovalEvents: string = await readFile(approvalEventsPath(sessionId), "utf8");
|
|
471
|
+
const keptApprovalEvents: StoredApprovalEvent[] = parseJsonLines<StoredApprovalEvent>(rawApprovalEvents)
|
|
472
|
+
.filter((event: StoredApprovalEvent): boolean => !removedRequestIds.has(event.requestId));
|
|
473
|
+
await writeFile(
|
|
474
|
+
approvalEventsPath(sessionId),
|
|
475
|
+
keptApprovalEvents.map((event: StoredApprovalEvent): string => JSON.stringify(event) + "\n").join(""),
|
|
476
|
+
"utf8"
|
|
477
|
+
);
|
|
478
|
+
} catch {
|
|
479
|
+
// Older sessions may not have approval persistence yet.
|
|
480
|
+
}
|
|
454
481
|
|
|
455
482
|
return keptMessages;
|
|
456
483
|
}
|
|
@@ -477,6 +504,33 @@ export async function appendSessionEvent(sessionId: string, requestId: string, e
|
|
|
477
504
|
await writeFile(eventFile, line, { encoding: "utf8", flag: "a" });
|
|
478
505
|
}
|
|
479
506
|
|
|
507
|
+
export async function appendApprovalEvent(sessionId: string, approvalId: string, requestId: string, event: string, data: unknown): Promise<void> {
|
|
508
|
+
await ensureSessionsDir();
|
|
509
|
+
const eventFile: string = approvalEventsPath(sessionId);
|
|
510
|
+
const timestamp: string = new Date().toISOString();
|
|
511
|
+
const record: StoredApprovalEvent = {
|
|
512
|
+
id: `approval-event-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
|
|
513
|
+
schemaVersion: 1,
|
|
514
|
+
approvalId,
|
|
515
|
+
requestId,
|
|
516
|
+
event,
|
|
517
|
+
data,
|
|
518
|
+
createdAt: timestamp
|
|
519
|
+
};
|
|
520
|
+
const line: string = JSON.stringify(record) + "\n";
|
|
521
|
+
await writeFile(eventFile, line, { encoding: "utf8", flag: "a" });
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
export async function readApprovalEvents(sessionId: string): Promise<StoredApprovalEvent[]> {
|
|
525
|
+
await ensureSessionsDir();
|
|
526
|
+
try {
|
|
527
|
+
const rawLines: string = await readFile(approvalEventsPath(sessionId), "utf8");
|
|
528
|
+
return parseJsonLines<StoredApprovalEvent>(rawLines);
|
|
529
|
+
} catch {
|
|
530
|
+
return [];
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
480
534
|
export async function clearSessionEvents(sessionId: string): Promise<void> {
|
|
481
535
|
await ensureSessionsDir();
|
|
482
536
|
await writeFile(eventsPath(sessionId), "", "utf8");
|
package/src/skills/registry.ts
CHANGED
|
@@ -56,6 +56,7 @@ const READ_TOOLS: string[] = [
|
|
|
56
56
|
];
|
|
57
57
|
|
|
58
58
|
const FILE_CREATE_TOOLS: string[] = [
|
|
59
|
+
"mcp_godot_propose_create_text_file",
|
|
59
60
|
"mcp_godot_create_text_file"
|
|
60
61
|
];
|
|
61
62
|
|
|
@@ -71,7 +72,9 @@ const TERMINAL_WRITE_TOOLS: string[] = [
|
|
|
71
72
|
];
|
|
72
73
|
|
|
73
74
|
const FILE_EDIT_TOOLS: string[] = [
|
|
75
|
+
"mcp_godot_propose_overwrite_text_file",
|
|
74
76
|
"mcp_godot_overwrite_text_file",
|
|
77
|
+
"mcp_godot_propose_replace_text_in_file",
|
|
75
78
|
"mcp_godot_replace_text_in_file",
|
|
76
79
|
"mcp_godot_propose_set_project_setting",
|
|
77
80
|
"mcp_godot_set_project_setting",
|
|
@@ -80,10 +83,15 @@ const FILE_EDIT_TOOLS: string[] = [
|
|
|
80
83
|
];
|
|
81
84
|
|
|
82
85
|
const SCENE_WRITE_TOOLS: string[] = [
|
|
86
|
+
"mcp_godot_propose_create_scene",
|
|
83
87
|
"mcp_godot_create_scene",
|
|
88
|
+
"mcp_godot_propose_add_node_to_scene",
|
|
84
89
|
"mcp_godot_add_node_to_scene",
|
|
90
|
+
"mcp_godot_propose_attach_script_to_node",
|
|
85
91
|
"mcp_godot_attach_script_to_node",
|
|
92
|
+
"mcp_godot_propose_connect_signal_in_scene",
|
|
86
93
|
"mcp_godot_connect_signal_in_scene",
|
|
94
|
+
"mcp_godot_propose_apply_scene_patch",
|
|
87
95
|
"mcp_godot_apply_scene_patch",
|
|
88
96
|
"mcp_godot_editor_apply_scene_patch"
|
|
89
97
|
];
|
|
@@ -20,7 +20,6 @@ export type ApprovalResult =
|
|
|
20
20
|
|
|
21
21
|
export class ApprovalGateway {
|
|
22
22
|
private pendingApprovals: Map<string, PendingApproval> = new Map();
|
|
23
|
-
private approvalIdCounter: number = 0;
|
|
24
23
|
private mode: ApprovalMode;
|
|
25
24
|
|
|
26
25
|
constructor(mode: ApprovalMode = "manual") {
|
|
@@ -43,6 +42,26 @@ export class ApprovalGateway {
|
|
|
43
42
|
return this.pendingApprovals.get(approvalId);
|
|
44
43
|
}
|
|
45
44
|
|
|
45
|
+
replacePending(pendingApprovals: PendingApproval[]): void {
|
|
46
|
+
this.pendingApprovals.clear();
|
|
47
|
+
for (const pendingApproval of pendingApprovals) {
|
|
48
|
+
this.pendingApprovals.set(pendingApproval.approvalId, pendingApproval);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
upsertPending(pendingApproval: PendingApproval): void {
|
|
53
|
+
this.pendingApprovals.set(pendingApproval.approvalId, pendingApproval);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
removePending(approvalId: string): PendingApproval | undefined {
|
|
57
|
+
const pending: PendingApproval | undefined = this.pendingApprovals.get(approvalId);
|
|
58
|
+
if (pending !== undefined) {
|
|
59
|
+
this.pendingApprovals.delete(approvalId);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return pending;
|
|
63
|
+
}
|
|
64
|
+
|
|
46
65
|
async evaluate(
|
|
47
66
|
llmToolName: string,
|
|
48
67
|
args: Record<string, unknown>,
|
|
@@ -67,8 +86,7 @@ export class ApprovalGateway {
|
|
|
67
86
|
}
|
|
68
87
|
}
|
|
69
88
|
|
|
70
|
-
const approvalId: string = `approval-${
|
|
71
|
-
this.approvalIdCounter += 1;
|
|
89
|
+
const approvalId: string = `approval-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
72
90
|
|
|
73
91
|
const pending: PendingApproval = {
|
|
74
92
|
approvalId,
|
|
@@ -92,9 +110,8 @@ export class ApprovalGateway {
|
|
|
92
110
|
throw new Error(`Approval not found: ${approvalId}`);
|
|
93
111
|
}
|
|
94
112
|
|
|
95
|
-
this.pendingApprovals.delete(approvalId);
|
|
96
|
-
|
|
97
113
|
const result = await executeLlmToolWithIdempotency(mcpHost, pending.llmToolName, pending.args);
|
|
114
|
+
this.pendingApprovals.delete(approvalId);
|
|
98
115
|
return { content: result.content, cached: result.reused };
|
|
99
116
|
}
|
|
100
117
|
|
package/src/workflow/planner.ts
CHANGED
|
@@ -44,13 +44,21 @@ export const VERIFY_TOOLS: string[] = [
|
|
|
44
44
|
];
|
|
45
45
|
|
|
46
46
|
export const WRITE_TOOLS: string[] = [
|
|
47
|
+
"mcp_godot_propose_create_text_file",
|
|
47
48
|
"mcp_godot_create_text_file",
|
|
49
|
+
"mcp_godot_propose_overwrite_text_file",
|
|
48
50
|
"mcp_godot_overwrite_text_file",
|
|
51
|
+
"mcp_godot_propose_replace_text_in_file",
|
|
49
52
|
"mcp_godot_replace_text_in_file",
|
|
53
|
+
"mcp_godot_propose_create_scene",
|
|
50
54
|
"mcp_godot_create_scene",
|
|
55
|
+
"mcp_godot_propose_add_node_to_scene",
|
|
51
56
|
"mcp_godot_add_node_to_scene",
|
|
57
|
+
"mcp_godot_propose_attach_script_to_node",
|
|
52
58
|
"mcp_godot_attach_script_to_node",
|
|
59
|
+
"mcp_godot_propose_connect_signal_in_scene",
|
|
53
60
|
"mcp_godot_connect_signal_in_scene",
|
|
61
|
+
"mcp_godot_propose_apply_scene_patch",
|
|
54
62
|
"mcp_godot_apply_scene_patch",
|
|
55
63
|
"mcp_godot_editor_apply_scene_patch",
|
|
56
64
|
"mcp_godot_propose_set_project_setting",
|
package/src/workflow/runner.ts
CHANGED
|
@@ -86,12 +86,14 @@ export function createPhaseParams(originalParams: AiChatParams, phase: WorkflowP
|
|
|
86
86
|
}
|
|
87
87
|
|
|
88
88
|
export function createPhasePrompt(phase: WorkflowPhase, skillPrompt: string, mcpSystemContext: string): string {
|
|
89
|
+
const toolGroupRules: string[] = createPhaseToolGroupRules(phase);
|
|
89
90
|
return [
|
|
90
91
|
"## 工作流阶段约束",
|
|
91
92
|
`- 当前阶段:${phase.title}(${phase.id})`,
|
|
92
93
|
`- 阶段目标:${phase.instruction}`,
|
|
93
94
|
"- 只完成当前阶段,不要提前总结整个任务。",
|
|
94
95
|
"- 如果需要写入或执行审批工具,按现有审批流程暂停。",
|
|
96
|
+
...toolGroupRules,
|
|
95
97
|
"- 当前阶段实际可用工具:",
|
|
96
98
|
...phase.allowedTools.map((toolName: string): string => ` - ${toolName}`),
|
|
97
99
|
"",
|
|
@@ -100,6 +102,24 @@ export function createPhasePrompt(phase: WorkflowPhase, skillPrompt: string, mcp
|
|
|
100
102
|
].filter((part: string): boolean => part.length > 0).join("\n\n");
|
|
101
103
|
}
|
|
102
104
|
|
|
105
|
+
function createPhaseToolGroupRules(phase: WorkflowPhase): string[] {
|
|
106
|
+
if (phase.toolGroup === "write") {
|
|
107
|
+
return [
|
|
108
|
+
"- 当前是写入/提案阶段:如果阶段目标是预览、提案或 diff,必须调用对应 propose_* 工具。",
|
|
109
|
+
"- 如果阶段目标是实际创建、修改、删除或应用补丁,必须调用实际写入工具;写入工具触发审批时按现有流程暂停。",
|
|
110
|
+
"- 不要只输出计划、意图或“稍后将执行”;后端会把未调用当前阶段所需工具的阶段视为未完成。"
|
|
111
|
+
];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (phase.toolGroup === "verify") {
|
|
115
|
+
return [
|
|
116
|
+
"- 当前是验证阶段:优先实际调用诊断或验证工具,不要只描述验证计划。"
|
|
117
|
+
];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return [];
|
|
121
|
+
}
|
|
122
|
+
|
|
103
123
|
export function appendPhaseOutput(outputs: WorkflowPhaseOutput[], phase: WorkflowPhase, text: string): WorkflowPhaseOutput[] {
|
|
104
124
|
const clippedText: string = text.length > MAX_PHASE_OUTPUT_CHARS
|
|
105
125
|
? `${text.slice(0, MAX_PHASE_OUTPUT_CHARS)}\n\n[阶段输出已截断,原始长度 ${text.length} 字符]`
|