godot-daedalus_backend 1.0.1 → 1.0.2
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/package.json +1 -1
- package/src/server/websocket-server.ts +391 -28
- 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/package.json
CHANGED
|
@@ -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,17 @@ 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";
|
|
83
94
|
|
|
84
95
|
const tokenCounterPromise: Promise<TokenCounter> = createTokenCounter();
|
|
85
96
|
let sessionCompressorPromptCache: string | undefined;
|
|
@@ -98,6 +109,18 @@ const MAX_NEXT_STEP_HINT_COUNT: number = 5;
|
|
|
98
109
|
const MAX_NEXT_STEP_HINT_MESSAGE_CHARS: number = 320;
|
|
99
110
|
const MAX_GUIDE_TEXT_CHARS: number = 4000;
|
|
100
111
|
|
|
112
|
+
type WorkflowPhaseToolStats = {
|
|
113
|
+
toolEvents: number;
|
|
114
|
+
proposeToolEvents: number;
|
|
115
|
+
writeToolEvents: number;
|
|
116
|
+
approvalEvents: number;
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
type WorkflowPhaseRunResult = {
|
|
120
|
+
agentResult: DeepSeekAgentResult;
|
|
121
|
+
toolStats: WorkflowPhaseToolStats;
|
|
122
|
+
};
|
|
123
|
+
|
|
101
124
|
function fingerprintText(text: string): string {
|
|
102
125
|
if (text.length === 0) {
|
|
103
126
|
return "empty";
|
|
@@ -1085,6 +1108,73 @@ function createToolEventForwarder(socket: WebSocket, requestId: string, session:
|
|
|
1085
1108
|
};
|
|
1086
1109
|
}
|
|
1087
1110
|
|
|
1111
|
+
function createEmptyWorkflowPhaseToolStats(): WorkflowPhaseToolStats {
|
|
1112
|
+
return {
|
|
1113
|
+
toolEvents: 0,
|
|
1114
|
+
proposeToolEvents: 0,
|
|
1115
|
+
writeToolEvents: 0,
|
|
1116
|
+
approvalEvents: 0
|
|
1117
|
+
};
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
function updateWorkflowPhaseToolStats(stats: WorkflowPhaseToolStats, event: ToolEvent): void {
|
|
1121
|
+
if (!event.type.startsWith("tool.")) {
|
|
1122
|
+
return;
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
stats.toolEvents += 1;
|
|
1126
|
+
|
|
1127
|
+
if (event.type === "tool.approval_required") {
|
|
1128
|
+
stats.approvalEvents += 1;
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
const toolName: string | undefined = "toolName" in event ? event.toolName : undefined;
|
|
1132
|
+
if (toolName === undefined) {
|
|
1133
|
+
return;
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
const policy = getToolPolicy(toolName);
|
|
1137
|
+
if (policy?.risk === "propose") {
|
|
1138
|
+
stats.proposeToolEvents += 1;
|
|
1139
|
+
}
|
|
1140
|
+
if (policy?.risk === "write" || policy?.risk === "destructive") {
|
|
1141
|
+
stats.writeToolEvents += 1;
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
function shouldRequireWorkflowWriteTool(phase: WorkflowPhase): boolean {
|
|
1146
|
+
return phase.toolGroup === "write";
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
function didWorkflowWritePhaseExecute(phase: WorkflowPhase, stats: WorkflowPhaseToolStats): boolean {
|
|
1150
|
+
if (stats.writeToolEvents > 0 || stats.approvalEvents > 0) {
|
|
1151
|
+
return true;
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
return isWorkflowProposalPhase(phase) && stats.proposeToolEvents > 0;
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
function isWorkflowProposalPhase(phase: WorkflowPhase): boolean {
|
|
1158
|
+
const text: string = `${phase.id}\n${phase.title}\n${phase.instruction}`.toLowerCase();
|
|
1159
|
+
return text.includes("propose")
|
|
1160
|
+
|| text.includes("preview")
|
|
1161
|
+
|| text.includes("diff")
|
|
1162
|
+
|| text.includes("预览")
|
|
1163
|
+
|| text.includes("提案")
|
|
1164
|
+
|| text.includes("方案");
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
function createWorkflowWriteGuardRetryMessage(phaseMessage: string): string {
|
|
1168
|
+
return [
|
|
1169
|
+
phaseMessage,
|
|
1170
|
+
"",
|
|
1171
|
+
"## 后端执行守卫",
|
|
1172
|
+
"上一次候选回复没有实际调用当前阶段需要的 propose/write 工具,也没有触发审批,因此当前阶段还没有完成。",
|
|
1173
|
+
"如果当前阶段是预览/提案,请调用允许的 propose_* 工具;如果当前阶段是实际修改,请调用写入工具并按审批流程暂停。",
|
|
1174
|
+
"不要只描述计划、步骤或意图。"
|
|
1175
|
+
].join("\n");
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1088
1178
|
function createPendingAiContinuation(
|
|
1089
1179
|
params: AiChatParams,
|
|
1090
1180
|
options: DeepSeekChatOptions,
|
|
@@ -1117,6 +1207,155 @@ function createPendingAiContinuation(
|
|
|
1117
1207
|
return pendingContinuation;
|
|
1118
1208
|
}
|
|
1119
1209
|
|
|
1210
|
+
async function persistApprovalRequested(
|
|
1211
|
+
session: ClientSession,
|
|
1212
|
+
mcpHost: McpHost,
|
|
1213
|
+
approvalId: string,
|
|
1214
|
+
pendingContinuation: PendingAiContinuation
|
|
1215
|
+
): Promise<void> {
|
|
1216
|
+
if (session.sessionId === undefined) {
|
|
1217
|
+
return;
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
const pendingApproval: PendingApproval | undefined = session.approvalGateway.getPending(approvalId);
|
|
1221
|
+
if (pendingApproval === undefined) {
|
|
1222
|
+
return;
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
await appendApprovalEvent(
|
|
1226
|
+
session.sessionId,
|
|
1227
|
+
approvalId,
|
|
1228
|
+
pendingContinuation.requestId,
|
|
1229
|
+
"requested",
|
|
1230
|
+
createPersistedApprovalRequestedData(pendingApproval, pendingContinuation, mcpHost.getActiveWorkspaceId())
|
|
1231
|
+
);
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
async function registerPendingApprovalContinuation(
|
|
1235
|
+
session: ClientSession,
|
|
1236
|
+
mcpHost: McpHost,
|
|
1237
|
+
approvalId: string,
|
|
1238
|
+
pendingContinuation: PendingAiContinuation
|
|
1239
|
+
): Promise<void> {
|
|
1240
|
+
session.pendingAiContinuations.set(approvalId, pendingContinuation);
|
|
1241
|
+
await persistApprovalRequested(session, mcpHost, approvalId, pendingContinuation);
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
async function loadHydratedPendingApprovalStates(
|
|
1245
|
+
session: ClientSession,
|
|
1246
|
+
apiKey?: string | undefined
|
|
1247
|
+
): Promise<{ states: PendingApprovalState[]; hadEvents: boolean }> {
|
|
1248
|
+
if (session.sessionId === undefined) {
|
|
1249
|
+
return {
|
|
1250
|
+
states: createMemoryPendingApprovalStates(session),
|
|
1251
|
+
hadEvents: false
|
|
1252
|
+
};
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
const approvalEvents = await readApprovalEvents(session.sessionId);
|
|
1256
|
+
if (approvalEvents.length === 0) {
|
|
1257
|
+
return {
|
|
1258
|
+
states: createMemoryPendingApprovalStates(session),
|
|
1259
|
+
hadEvents: false
|
|
1260
|
+
};
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
const states: PendingApprovalState[] = foldPendingApprovalStates(approvalEvents);
|
|
1264
|
+
session.approvalGateway.replacePending(states.map((state: PendingApprovalState): PendingApproval => state.approval));
|
|
1265
|
+
const pendingIds: Set<string> = new Set(states.map((state: PendingApprovalState): string => state.approval.approvalId));
|
|
1266
|
+
for (const approvalId of session.pendingAiContinuations.keys()) {
|
|
1267
|
+
if (!pendingIds.has(approvalId)) {
|
|
1268
|
+
session.pendingAiContinuations.delete(approvalId);
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
if (apiKey !== undefined) {
|
|
1273
|
+
for (const state of states) {
|
|
1274
|
+
if (state.continuation !== undefined) {
|
|
1275
|
+
session.pendingAiContinuations.set(state.approval.approvalId, createRuntimePendingContinuation(state.continuation, apiKey));
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
return {
|
|
1281
|
+
states,
|
|
1282
|
+
hadEvents: true
|
|
1283
|
+
};
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
function createMemoryPendingApprovalStates(session: ClientSession): PendingApprovalState[] {
|
|
1287
|
+
return session.approvalGateway.listPending().map((pendingApproval: PendingApproval): PendingApprovalState => {
|
|
1288
|
+
const timestamp: string = new Date(pendingApproval.createdAt).toISOString();
|
|
1289
|
+
return {
|
|
1290
|
+
approval: pendingApproval,
|
|
1291
|
+
status: "pending",
|
|
1292
|
+
restored: false,
|
|
1293
|
+
interrupted: false,
|
|
1294
|
+
requestId: "",
|
|
1295
|
+
createdAt: timestamp,
|
|
1296
|
+
updatedAt: timestamp
|
|
1297
|
+
};
|
|
1298
|
+
});
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
function findPendingApprovalState(states: PendingApprovalState[], approvalId: string): PendingApprovalState | undefined {
|
|
1302
|
+
return states.find((state: PendingApprovalState): boolean => state.approval.approvalId === approvalId);
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
async function restorePendingContinuationForApproval(
|
|
1306
|
+
session: ClientSession,
|
|
1307
|
+
state: PendingApprovalState | undefined,
|
|
1308
|
+
apiKey: string | undefined
|
|
1309
|
+
): Promise<PendingAiContinuation | undefined> {
|
|
1310
|
+
const approvalId: string | undefined = state?.approval.approvalId;
|
|
1311
|
+
if (approvalId !== undefined) {
|
|
1312
|
+
const existingContinuation: PendingAiContinuation | undefined = session.pendingAiContinuations.get(approvalId);
|
|
1313
|
+
if (existingContinuation !== undefined) {
|
|
1314
|
+
return existingContinuation;
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
if (state?.continuation === undefined || apiKey === undefined) {
|
|
1319
|
+
return undefined;
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
const restoredContinuation: PendingAiContinuation = createRuntimePendingContinuation(state.continuation, apiKey);
|
|
1323
|
+
session.pendingAiContinuations.set(state.approval.approvalId, restoredContinuation);
|
|
1324
|
+
return restoredContinuation;
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
async function validatePendingApprovalBeforeExecution(
|
|
1328
|
+
session: ClientSession,
|
|
1329
|
+
mcpHost: McpHost,
|
|
1330
|
+
pendingApproval: PendingApproval
|
|
1331
|
+
): Promise<string | null> {
|
|
1332
|
+
const decision = await session.approvalGateway.evaluate(pendingApproval.llmToolName, pendingApproval.args, pendingApproval.toolCallId);
|
|
1333
|
+
if (decision.action === "deny") {
|
|
1334
|
+
return decision.reason;
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
try {
|
|
1338
|
+
resolveToolMapping(pendingApproval.llmToolName);
|
|
1339
|
+
} catch (error: unknown) {
|
|
1340
|
+
return error instanceof Error ? error.message : "审批工具当前不可用";
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
const currentIdentity = getLlmToolExecutionIdentity(
|
|
1344
|
+
pendingApproval.llmToolName,
|
|
1345
|
+
pendingApproval.args,
|
|
1346
|
+
mcpHost.getActiveWorkspaceId()
|
|
1347
|
+
);
|
|
1348
|
+
if (
|
|
1349
|
+
pendingApproval.executionFingerprint !== undefined
|
|
1350
|
+
&& currentIdentity !== undefined
|
|
1351
|
+
&& currentIdentity.fingerprint !== pendingApproval.executionFingerprint
|
|
1352
|
+
) {
|
|
1353
|
+
return "当前 workspace 与创建审批时不一致,不能执行该审批。";
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
return null;
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1120
1359
|
function sendAiPaused(socket: WebSocket, requestId: string, agentResult: Extract<DeepSeekAgentResult, { status: "approval_required" }>): void {
|
|
1121
1360
|
sendJson(socket, {
|
|
1122
1361
|
type: "event",
|
|
@@ -1152,7 +1391,7 @@ async function sendContinuedAgentResult(
|
|
|
1152
1391
|
pendingContinuation.stream,
|
|
1153
1392
|
pendingContinuation.workflowState
|
|
1154
1393
|
);
|
|
1155
|
-
session
|
|
1394
|
+
await registerPendingApprovalContinuation(session, mcpHost, agentResult.approvalId, nextPendingContinuation);
|
|
1156
1395
|
sendAiPaused(socket, requestId, agentResult);
|
|
1157
1396
|
return;
|
|
1158
1397
|
}
|
|
@@ -1223,11 +1462,20 @@ async function runWorkflowPhase(
|
|
|
1223
1462
|
persistRequestId: string,
|
|
1224
1463
|
streamPhase: boolean,
|
|
1225
1464
|
abortSignal?: AbortSignal | undefined
|
|
1226
|
-
): Promise<
|
|
1227
|
-
const
|
|
1228
|
-
|
|
1465
|
+
): Promise<WorkflowPhaseRunResult> {
|
|
1466
|
+
const toolStats: WorkflowPhaseToolStats = createEmptyWorkflowPhaseToolStats();
|
|
1467
|
+
const forwardToolEvent: OnToolEvent = createToolEventForwarder(socket, requestId, session, persistRequestId);
|
|
1468
|
+
const onToolEvent: OnToolEvent = (event: ToolEvent): void => {
|
|
1469
|
+
updateWorkflowPhaseToolStats(toolStats, event);
|
|
1470
|
+
forwardToolEvent(event);
|
|
1471
|
+
};
|
|
1472
|
+
const agentResult: DeepSeekAgentResult = streamPhase
|
|
1229
1473
|
? await runDeepSeekAgentStreaming(params, options, history, fullSystemPrompt, mcpHost, session.approvalGateway, phase.allowedTools, onToolEvent, abortSignal)
|
|
1230
1474
|
: await runDeepSeekAgent(params, options, history, fullSystemPrompt, mcpHost, session.approvalGateway, phase.allowedTools, onToolEvent, abortSignal);
|
|
1475
|
+
return {
|
|
1476
|
+
agentResult,
|
|
1477
|
+
toolStats
|
|
1478
|
+
};
|
|
1231
1479
|
}
|
|
1232
1480
|
|
|
1233
1481
|
async function createWorkflowPhasePrompt(
|
|
@@ -1336,21 +1584,59 @@ async function continueWorkflowExecution(
|
|
|
1336
1584
|
].filter((section: string): boolean => section.length > 0).join("\n\n");
|
|
1337
1585
|
const fullSystemPrompt: string = await createWorkflowPhasePrompt(phase, phaseParams, mcpHost, session, requestId, guidePromptSection);
|
|
1338
1586
|
let agentResult: DeepSeekAgentResult;
|
|
1587
|
+
let phaseToolStats: WorkflowPhaseToolStats = createEmptyWorkflowPhaseToolStats();
|
|
1339
1588
|
try {
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1589
|
+
if (agentResultOverride !== undefined) {
|
|
1590
|
+
agentResult = agentResultOverride;
|
|
1591
|
+
phaseToolStats.approvalEvents = 1;
|
|
1592
|
+
phaseToolStats.writeToolEvents = 1;
|
|
1593
|
+
} else {
|
|
1594
|
+
let phaseRunResult: WorkflowPhaseRunResult = await runWorkflowPhase(
|
|
1595
|
+
socket,
|
|
1596
|
+
phaseParams,
|
|
1597
|
+
options,
|
|
1598
|
+
state.history,
|
|
1599
|
+
fullSystemPrompt,
|
|
1600
|
+
phase,
|
|
1601
|
+
mcpHost,
|
|
1602
|
+
session,
|
|
1603
|
+
requestId,
|
|
1604
|
+
persistRequestId,
|
|
1605
|
+
streamPhase,
|
|
1606
|
+
abortSignal
|
|
1607
|
+
);
|
|
1608
|
+
agentResult = phaseRunResult.agentResult;
|
|
1609
|
+
phaseToolStats = phaseRunResult.toolStats;
|
|
1610
|
+
|
|
1611
|
+
if (
|
|
1612
|
+
agentResult.status === "completed"
|
|
1613
|
+
&& shouldRequireWorkflowWriteTool(phase)
|
|
1614
|
+
&& !didWorkflowWritePhaseExecute(phase, phaseToolStats)
|
|
1615
|
+
) {
|
|
1616
|
+
const retryPhaseParams: AiChatParams = createPhaseParams(
|
|
1617
|
+
state.originalParams,
|
|
1618
|
+
phase,
|
|
1619
|
+
createWorkflowWriteGuardRetryMessage(phaseMessage),
|
|
1620
|
+
false
|
|
1621
|
+
);
|
|
1622
|
+
phaseRunResult = await runWorkflowPhase(
|
|
1623
|
+
socket,
|
|
1624
|
+
retryPhaseParams,
|
|
1625
|
+
options,
|
|
1626
|
+
state.history,
|
|
1627
|
+
fullSystemPrompt,
|
|
1628
|
+
phase,
|
|
1629
|
+
mcpHost,
|
|
1630
|
+
session,
|
|
1631
|
+
requestId,
|
|
1632
|
+
persistRequestId,
|
|
1633
|
+
false,
|
|
1634
|
+
abortSignal
|
|
1635
|
+
);
|
|
1636
|
+
agentResult = phaseRunResult.agentResult;
|
|
1637
|
+
phaseToolStats = phaseRunResult.toolStats;
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1354
1640
|
} catch (error: unknown) {
|
|
1355
1641
|
throw new WorkflowExecutionError(error instanceof Error ? error.message : "Workflow phase failed", plan, error);
|
|
1356
1642
|
}
|
|
@@ -1359,7 +1645,7 @@ async function continueWorkflowExecution(
|
|
|
1359
1645
|
if (agentResult.status === "approval_required") {
|
|
1360
1646
|
plan = updateWorkflowPhaseStatus(plan, phase.id, "paused");
|
|
1361
1647
|
const pausedState: WorkflowRunState = { ...state, plan, phaseIndex: index, phaseOutputs };
|
|
1362
|
-
|
|
1648
|
+
const pendingContinuation: PendingAiContinuation = createWorkflowPendingContinuation(
|
|
1363
1649
|
phaseParams,
|
|
1364
1650
|
options,
|
|
1365
1651
|
agentResult,
|
|
@@ -1368,12 +1654,22 @@ async function continueWorkflowExecution(
|
|
|
1368
1654
|
persistRequestId,
|
|
1369
1655
|
userCreatedAt,
|
|
1370
1656
|
streamPhase
|
|
1371
|
-
)
|
|
1657
|
+
);
|
|
1658
|
+
await registerPendingApprovalContinuation(session, mcpHost, agentResult.approvalId, pendingContinuation);
|
|
1372
1659
|
sendWorkflowTodoSnapshot(socket, requestId, session, plan, persistRequestId);
|
|
1373
1660
|
sendAiPaused(socket, requestId, agentResult);
|
|
1374
1661
|
return;
|
|
1375
1662
|
}
|
|
1376
1663
|
|
|
1664
|
+
if (shouldRequireWorkflowWriteTool(phase) && !didWorkflowWritePhaseExecute(phase, phaseToolStats)) {
|
|
1665
|
+
const guardMessage: string = `写入阶段「${phase.title}」没有实际调用写入工具或触发审批,已阻止将该 Todo 标记为完成。`;
|
|
1666
|
+
throw new WorkflowExecutionError(
|
|
1667
|
+
guardMessage,
|
|
1668
|
+
plan,
|
|
1669
|
+
new Error(guardMessage)
|
|
1670
|
+
);
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1377
1673
|
phaseOutputs = appendPhaseOutput(phaseOutputs, phase, agentResult.text);
|
|
1378
1674
|
plan = updateWorkflowPhaseStatus(plan, phase.id, "done");
|
|
1379
1675
|
state = { ...state, plan, phaseIndex: index + 1, phaseOutputs };
|
|
@@ -2273,7 +2569,7 @@ async function handleRequest(socket: WebSocket, request: ClientRequest, session:
|
|
|
2273
2569
|
const agentResult: DeepSeekAgentResult = await runDeepSeekAgentStreaming(params, options, history, fullSystemPrompt, mcpHost, session.approvalGateway, allowedToolNames, onToolEvent, abortController.signal);
|
|
2274
2570
|
|
|
2275
2571
|
if (agentResult.status === "approval_required") {
|
|
2276
|
-
|
|
2572
|
+
const pendingContinuation: PendingAiContinuation = createPendingAiContinuation(
|
|
2277
2573
|
params,
|
|
2278
2574
|
options,
|
|
2279
2575
|
agentResult.continuation,
|
|
@@ -2282,7 +2578,8 @@ async function handleRequest(socket: WebSocket, request: ClientRequest, session:
|
|
|
2282
2578
|
request.id,
|
|
2283
2579
|
turnStartedAt,
|
|
2284
2580
|
true
|
|
2285
|
-
)
|
|
2581
|
+
);
|
|
2582
|
+
await registerPendingApprovalContinuation(session, mcpHost, agentResult.approvalId, pendingContinuation);
|
|
2286
2583
|
sendAiPaused(socket, request.id, agentResult);
|
|
2287
2584
|
break;
|
|
2288
2585
|
}
|
|
@@ -2308,7 +2605,7 @@ async function handleRequest(socket: WebSocket, request: ClientRequest, session:
|
|
|
2308
2605
|
const agentResult: DeepSeekAgentResult = await runDeepSeekAgent(params, options, history, fullSystemPrompt, mcpHost, session.approvalGateway, allowedToolNames, onToolEvent, abortController.signal);
|
|
2309
2606
|
|
|
2310
2607
|
if (agentResult.status === "approval_required") {
|
|
2311
|
-
|
|
2608
|
+
const pendingContinuation: PendingAiContinuation = createPendingAiContinuation(
|
|
2312
2609
|
params,
|
|
2313
2610
|
options,
|
|
2314
2611
|
agentResult.continuation,
|
|
@@ -2317,7 +2614,8 @@ async function handleRequest(socket: WebSocket, request: ClientRequest, session:
|
|
|
2317
2614
|
request.id,
|
|
2318
2615
|
turnStartedAt,
|
|
2319
2616
|
false
|
|
2320
|
-
)
|
|
2617
|
+
);
|
|
2618
|
+
await registerPendingApprovalContinuation(session, mcpHost, agentResult.approvalId, pendingContinuation);
|
|
2321
2619
|
sendJson(socket, {
|
|
2322
2620
|
type: "response",
|
|
2323
2621
|
id: request.id,
|
|
@@ -2509,6 +2807,7 @@ async function handleRequest(socket: WebSocket, request: ClientRequest, session:
|
|
|
2509
2807
|
|
|
2510
2808
|
case "session.info":
|
|
2511
2809
|
await waitForFullSessionLoad(session);
|
|
2810
|
+
await loadHydratedPendingApprovalStates(session);
|
|
2512
2811
|
sendJson(socket, {
|
|
2513
2812
|
type: "response",
|
|
2514
2813
|
id: request.id,
|
|
@@ -3570,16 +3869,19 @@ async function handleRequest(socket: WebSocket, request: ClientRequest, session:
|
|
|
3570
3869
|
}
|
|
3571
3870
|
|
|
3572
3871
|
case "approval.list":
|
|
3872
|
+
{
|
|
3873
|
+
const hydrated = await loadHydratedPendingApprovalStates(session);
|
|
3573
3874
|
sendJson(socket, {
|
|
3574
3875
|
type: "response",
|
|
3575
3876
|
id: request.id,
|
|
3576
3877
|
ok: true,
|
|
3577
3878
|
result: {
|
|
3578
|
-
pending:
|
|
3879
|
+
pending: hydrated.states.map(serializePendingApprovalState),
|
|
3579
3880
|
mode: session.approvalGateway.getMode()
|
|
3580
3881
|
}
|
|
3581
3882
|
});
|
|
3582
3883
|
break;
|
|
3884
|
+
}
|
|
3583
3885
|
|
|
3584
3886
|
case "approval.mode.set":
|
|
3585
3887
|
session.approvalGateway.setMode(request.params.mode);
|
|
@@ -3598,6 +3900,8 @@ async function handleRequest(socket: WebSocket, request: ClientRequest, session:
|
|
|
3598
3900
|
const abortController: AbortController = new AbortController();
|
|
3599
3901
|
session.activeAbortControllers.set(request.id, abortController);
|
|
3600
3902
|
try {
|
|
3903
|
+
const apiKey: string | undefined = await ensureProviderConfigured(session);
|
|
3904
|
+
const hydrated = await loadHydratedPendingApprovalStates(session, apiKey);
|
|
3601
3905
|
const pending = session.approvalGateway.getPending(request.params.approvalId);
|
|
3602
3906
|
if (!pending) {
|
|
3603
3907
|
sendJson(socket, {
|
|
@@ -3609,8 +3913,54 @@ async function handleRequest(socket: WebSocket, request: ClientRequest, session:
|
|
|
3609
3913
|
break;
|
|
3610
3914
|
}
|
|
3611
3915
|
|
|
3612
|
-
const
|
|
3916
|
+
const validationError: string | null = await validatePendingApprovalBeforeExecution(session, mcpHost, pending);
|
|
3917
|
+
if (validationError !== null) {
|
|
3918
|
+
if (session.sessionId !== undefined) {
|
|
3919
|
+
await appendApprovalEvent(session.sessionId, pending.approvalId, findPendingApprovalState(hydrated.states, pending.approvalId)?.requestId ?? request.id, "failed", {
|
|
3920
|
+
message: validationError
|
|
3921
|
+
});
|
|
3922
|
+
}
|
|
3923
|
+
sendJson(socket, {
|
|
3924
|
+
type: "response",
|
|
3925
|
+
id: request.id,
|
|
3926
|
+
ok: false,
|
|
3927
|
+
error: { code: "approval_validation_failed", message: validationError }
|
|
3928
|
+
});
|
|
3929
|
+
break;
|
|
3930
|
+
}
|
|
3931
|
+
|
|
3932
|
+
const pendingState: PendingApprovalState | undefined = findPendingApprovalState(hydrated.states, request.params.approvalId);
|
|
3933
|
+
const pendingContinuation: PendingAiContinuation | undefined = await restorePendingContinuationForApproval(session, pendingState, apiKey);
|
|
3934
|
+
if (pendingState?.continuation !== undefined && pendingContinuation === undefined) {
|
|
3935
|
+
const message: string = "当前没有可用的 DeepSeek API key,无法恢复审批后的 LLM continuation。请先配置 provider 后重试。";
|
|
3936
|
+
if (session.sessionId !== undefined) {
|
|
3937
|
+
await appendApprovalEvent(session.sessionId, pending.approvalId, pendingState.requestId, "failed", { message });
|
|
3938
|
+
}
|
|
3939
|
+
sendJson(socket, {
|
|
3940
|
+
type: "response",
|
|
3941
|
+
id: request.id,
|
|
3942
|
+
ok: false,
|
|
3943
|
+
error: { code: "provider_not_configured", message }
|
|
3944
|
+
});
|
|
3945
|
+
break;
|
|
3946
|
+
}
|
|
3947
|
+
const approvalPersistRequestId: string = pendingContinuation?.requestId ?? pendingState?.requestId ?? request.id;
|
|
3948
|
+
if (session.sessionId !== undefined) {
|
|
3949
|
+
await appendApprovalEvent(session.sessionId, pending.approvalId, approvalPersistRequestId, "approved", {
|
|
3950
|
+
approvedAt: new Date().toISOString()
|
|
3951
|
+
});
|
|
3952
|
+
await appendApprovalEvent(session.sessionId, pending.approvalId, approvalPersistRequestId, "executing", {
|
|
3953
|
+
startedAt: new Date().toISOString()
|
|
3954
|
+
});
|
|
3955
|
+
}
|
|
3613
3956
|
const result = await session.approvalGateway.approve(request.params.approvalId, mcpHost);
|
|
3957
|
+
if (session.sessionId !== undefined) {
|
|
3958
|
+
await appendApprovalEvent(session.sessionId, pending.approvalId, approvalPersistRequestId, "executed", {
|
|
3959
|
+
resultChars: result.content.length,
|
|
3960
|
+
cached: result.cached === true,
|
|
3961
|
+
executedAt: new Date().toISOString()
|
|
3962
|
+
});
|
|
3963
|
+
}
|
|
3614
3964
|
|
|
3615
3965
|
sendJson(socket, {
|
|
3616
3966
|
type: "response",
|
|
@@ -3707,6 +4057,11 @@ async function handleRequest(socket: WebSocket, request: ClientRequest, session:
|
|
|
3707
4057
|
sendAiCancelled(socket, request.id);
|
|
3708
4058
|
break;
|
|
3709
4059
|
}
|
|
4060
|
+
if (session.sessionId !== undefined) {
|
|
4061
|
+
await appendApprovalEvent(session.sessionId, request.params.approvalId, request.id, "failed", {
|
|
4062
|
+
message: error instanceof Error ? error.message : "Approval failed"
|
|
4063
|
+
});
|
|
4064
|
+
}
|
|
3710
4065
|
sendJson(socket, {
|
|
3711
4066
|
type: "response",
|
|
3712
4067
|
id: request.id,
|
|
@@ -3724,7 +4079,15 @@ async function handleRequest(socket: WebSocket, request: ClientRequest, session:
|
|
|
3724
4079
|
|
|
3725
4080
|
case "approval.reject": {
|
|
3726
4081
|
try {
|
|
4082
|
+
const hydrated = await loadHydratedPendingApprovalStates(session);
|
|
4083
|
+
const pendingState: PendingApprovalState | undefined = findPendingApprovalState(hydrated.states, request.params.approvalId);
|
|
3727
4084
|
const rejected = session.approvalGateway.reject(request.params.approvalId);
|
|
4085
|
+
session.pendingAiContinuations.delete(request.params.approvalId);
|
|
4086
|
+
if (session.sessionId !== undefined) {
|
|
4087
|
+
await appendApprovalEvent(session.sessionId, request.params.approvalId, pendingState?.requestId ?? request.id, "rejected", {
|
|
4088
|
+
rejectedAt: new Date().toISOString()
|
|
4089
|
+
});
|
|
4090
|
+
}
|
|
3728
4091
|
sendJson(socket, {
|
|
3729
4092
|
type: "response",
|
|
3730
4093
|
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} 字符]`
|