godot-daedalus_backend 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.md +101 -0
  2. package/bin/godot-daedalus-backend.js +4 -0
  3. package/bin/godot-daedalus-mcp.js +4 -0
  4. package/bin/godot-daedalus-terminal-mcp.js +4 -0
  5. package/bin/run-tsx-entry.js +26 -0
  6. package/package.json +54 -0
  7. package/scripts/deepseek-tokenizer-server.py +54 -0
  8. package/src/app-paths.ts +36 -0
  9. package/src/main.ts +21 -0
  10. package/src/mcp/content-length-protocol.ts +68 -0
  11. package/src/mcp/custom-mcp-config-store.ts +397 -0
  12. package/src/mcp/godot-diagnostics-bridge.ts +1298 -0
  13. package/src/mcp/godot-editor-bridge.ts +307 -0
  14. package/src/mcp/godot-mcp-server.ts +3484 -0
  15. package/src/mcp/godot-paths.ts +151 -0
  16. package/src/mcp/godot-project-settings.ts +233 -0
  17. package/src/mcp/godot-tool-registration.ts +46 -0
  18. package/src/mcp/mcp-config.ts +48 -0
  19. package/src/mcp/mcp-host.ts +393 -0
  20. package/src/mcp/mcp-session.ts +81 -0
  21. package/src/mcp/terminal-mcp-server.ts +576 -0
  22. package/src/mcp/tscn-tools.ts +302 -0
  23. package/src/mcp/types.ts +12 -0
  24. package/src/ping-client.ts +24 -0
  25. package/src/prompts/registry.ts +97 -0
  26. package/src/prompts/templates/backend-helper.md +25 -0
  27. package/src/prompts/templates/gdscript-reviewer.md +19 -0
  28. package/src/prompts/templates/godot-assistant.md +225 -0
  29. package/src/prompts/templates/scene-architect.md +15 -0
  30. package/src/prompts/templates/session-compressor.md +33 -0
  31. package/src/protocol/schema.ts +486 -0
  32. package/src/protocol/types.ts +77 -0
  33. package/src/providers/deepseek-agent.ts +1014 -0
  34. package/src/providers/deepseek-client.ts +114 -0
  35. package/src/providers/deepseek-dsml-tools.ts +90 -0
  36. package/src/providers/deepseek-loose-tools.ts +450 -0
  37. package/src/providers/provider-config-store.ts +164 -0
  38. package/src/server/client-session.ts +93 -0
  39. package/src/server/request-dispatcher.ts +74 -0
  40. package/src/server/response-helpers.ts +33 -0
  41. package/src/server/send-json.ts +8 -0
  42. package/src/server/websocket-server.ts +3997 -0
  43. package/src/session/session-compressor.ts +68 -0
  44. package/src/session/session-store.ts +669 -0
  45. package/src/skills/registry.ts +180 -0
  46. package/src/skills/templates/backend-helper.md +12 -0
  47. package/src/skills/templates/file-creator.md +14 -0
  48. package/src/skills/templates/gdscript-review.md +12 -0
  49. package/src/skills/templates/godot-project-init.md +29 -0
  50. package/src/skills/templates/scene-builder.md +12 -0
  51. package/src/tokens/deepseek-tokenizer-counter.ts +233 -0
  52. package/src/tokens/model-profiles.ts +38 -0
  53. package/src/tokens/token-counter-factory.ts +52 -0
  54. package/src/tokens/token-counter.ts +22 -0
  55. package/src/tools/approval-gateway.ts +111 -0
  56. package/src/tools/llm-tools.ts +1415 -0
  57. package/src/tools/tool-dispatcher.ts +147 -0
  58. package/src/tools/tool-event-describer.ts +387 -0
  59. package/src/tools/tool-idempotency.ts +373 -0
  60. package/src/tools/tool-policy-table.ts +61 -0
  61. package/src/tools/tool-policy.ts +73 -0
  62. package/src/workflow/llm-planner.ts +407 -0
  63. package/src/workflow/planner.ts +201 -0
  64. package/src/workflow/runner.ts +141 -0
  65. package/src/workflow/types.ts +69 -0
  66. package/src/workspace/registry.ts +104 -0
  67. package/src/workspace/types.ts +7 -0
@@ -0,0 +1,373 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { dirname } from "node:path";
4
+ import { getToolExecutionLedgerPath } from "../app-paths.js";
5
+ import type { McpHost } from "../mcp/mcp-host.js";
6
+ import { MAX_TOOL_RESULT_CHARS, resolveToolMapping } from "./llm-tools.js";
7
+ import { getToolPolicy } from "./tool-policy.js";
8
+
9
+ const TOOL_EXECUTION_DEDUP_TTL_MS: number = 30 * 60 * 1000;
10
+ const MAX_COMPLETED_TOOL_EXECUTIONS: number = 500;
11
+
12
+ type ToolResultContent = {
13
+ content: Array<{ type: string; text?: string }>;
14
+ };
15
+
16
+ type ToolExecutionRecord = {
17
+ fingerprint: string;
18
+ scope: string;
19
+ llmToolName: string;
20
+ serverId: string;
21
+ toolName: string;
22
+ argsHash: string;
23
+ content: string;
24
+ rawContentLength: number;
25
+ truncated: boolean;
26
+ createdAt: string;
27
+ expiresAt: string;
28
+ };
29
+
30
+ export type IdempotentToolExecutionResult = {
31
+ content: string;
32
+ rawContentLength: number;
33
+ truncated: boolean;
34
+ reused: boolean;
35
+ fingerprint?: string | undefined;
36
+ };
37
+
38
+ type ToolExecutionIdentity = {
39
+ fingerprint: string;
40
+ scope: string;
41
+ serverId: string;
42
+ toolName: string;
43
+ argsHash: string;
44
+ };
45
+
46
+ const completedToolExecutions: Map<string, ToolExecutionRecord> = new Map();
47
+ const inFlightToolExecutions: Map<string, Promise<IdempotentToolExecutionResult>> = new Map();
48
+
49
+ let ledgerLoadPromise: Promise<void> | null = null;
50
+ let ledgerWriteQueue: Promise<void> = Promise.resolve();
51
+
52
+ const GODOT_PROJECT_MUTATION_TOOLS: ReadonlySet<string> = new Set([
53
+ "mcp_godot_set_project_setting",
54
+ "mcp_godot_unset_project_setting",
55
+ "mcp_godot_create_text_file",
56
+ "mcp_godot_overwrite_text_file",
57
+ "mcp_godot_replace_text_in_file",
58
+ "mcp_godot_delete_file",
59
+ "mcp_godot_create_scene",
60
+ "mcp_godot_add_node_to_scene",
61
+ "mcp_godot_attach_script_to_node",
62
+ "mcp_godot_connect_signal_in_scene",
63
+ "mcp_godot_apply_scene_patch",
64
+ "mcp_godot_editor_apply_scene_patch"
65
+ ]);
66
+
67
+ function normalizeForStableJson(value: unknown): unknown {
68
+ if (Array.isArray(value)) {
69
+ return value.map((item: unknown): unknown => normalizeForStableJson(item));
70
+ }
71
+
72
+ if (value !== null && typeof value === "object") {
73
+ const record: Record<string, unknown> = value as Record<string, unknown>;
74
+ const normalized: Record<string, unknown> = {};
75
+ for (const key of Object.keys(record).sort()) {
76
+ normalized[key] = normalizeForStableJson(record[key]);
77
+ }
78
+ return normalized;
79
+ }
80
+
81
+ if (value === undefined) {
82
+ return null;
83
+ }
84
+
85
+ return value;
86
+ }
87
+
88
+ function stableJson(value: unknown): string {
89
+ return JSON.stringify(normalizeForStableJson(value));
90
+ }
91
+
92
+ function sha256(value: string): string {
93
+ return createHash("sha256").update(value).digest("hex");
94
+ }
95
+
96
+ function trimToolResult(text: string): string {
97
+ if (text.length <= MAX_TOOL_RESULT_CHARS) {
98
+ return text;
99
+ }
100
+
101
+ return text.slice(0, MAX_TOOL_RESULT_CHARS) + `\n\n[结果已截断,原始长度 ${text.length} 字符]`;
102
+ }
103
+
104
+ function extractTextContent(result: ToolResultContent): string {
105
+ const firstContent = result.content[0];
106
+ if (firstContent !== undefined && firstContent.text !== undefined) {
107
+ return firstContent.text;
108
+ }
109
+
110
+ return JSON.stringify(result);
111
+ }
112
+
113
+ function isRecordExpired(record: ToolExecutionRecord, now: number = Date.now()): boolean {
114
+ return Date.parse(record.expiresAt) <= now;
115
+ }
116
+
117
+ function isToolExecutionRecord(value: unknown): value is ToolExecutionRecord {
118
+ if (value === null || typeof value !== "object") {
119
+ return false;
120
+ }
121
+
122
+ const record: Partial<ToolExecutionRecord> = value as Partial<ToolExecutionRecord>;
123
+ return typeof record.fingerprint === "string"
124
+ && typeof record.scope === "string"
125
+ && typeof record.llmToolName === "string"
126
+ && typeof record.serverId === "string"
127
+ && typeof record.toolName === "string"
128
+ && typeof record.argsHash === "string"
129
+ && typeof record.content === "string"
130
+ && typeof record.rawContentLength === "number"
131
+ && typeof record.truncated === "boolean"
132
+ && typeof record.createdAt === "string"
133
+ && typeof record.expiresAt === "string";
134
+ }
135
+
136
+ async function loadLedger(): Promise<void> {
137
+ try {
138
+ const raw: string = await readFile(getToolExecutionLedgerPath(), "utf8");
139
+ const now: number = Date.now();
140
+ for (const line of raw.split("\n")) {
141
+ const trimmed: string = line.trim();
142
+ if (trimmed.length === 0) {
143
+ continue;
144
+ }
145
+
146
+ try {
147
+ const parsed: unknown = JSON.parse(trimmed);
148
+ if (isToolExecutionRecord(parsed) && !isRecordExpired(parsed, now)) {
149
+ completedToolExecutions.set(parsed.fingerprint, parsed);
150
+ }
151
+ } catch {
152
+ // 忽略损坏的 ledger 行,避免单条记录影响后端启动。
153
+ }
154
+ }
155
+ pruneCompletedToolExecutions(now);
156
+ } catch {
157
+ // Ledger 不存在时按空记录处理。
158
+ }
159
+ }
160
+
161
+ async function ensureLedgerLoaded(): Promise<void> {
162
+ if (ledgerLoadPromise === null) {
163
+ ledgerLoadPromise = loadLedger();
164
+ }
165
+
166
+ await ledgerLoadPromise;
167
+ }
168
+
169
+ function pruneCompletedToolExecutions(now: number = Date.now()): void {
170
+ for (const [fingerprint, record] of completedToolExecutions.entries()) {
171
+ if (isRecordExpired(record, now)) {
172
+ completedToolExecutions.delete(fingerprint);
173
+ }
174
+ }
175
+
176
+ while (completedToolExecutions.size > MAX_COMPLETED_TOOL_EXECUTIONS) {
177
+ const oldestFingerprint: string | undefined = completedToolExecutions.keys().next().value;
178
+ if (oldestFingerprint === undefined) {
179
+ break;
180
+ }
181
+ completedToolExecutions.delete(oldestFingerprint);
182
+ }
183
+ }
184
+
185
+ function enqueueLedgerWrite(record: ToolExecutionRecord): Promise<void> {
186
+ ledgerWriteQueue = ledgerWriteQueue.then(async (): Promise<void> => {
187
+ const ledgerPath: string = getToolExecutionLedgerPath();
188
+ await mkdir(dirname(ledgerPath), { recursive: true });
189
+ await writeFile(ledgerPath, JSON.stringify(record) + "\n", { encoding: "utf8", flag: "a" });
190
+ }, async (): Promise<void> => {
191
+ const ledgerPath: string = getToolExecutionLedgerPath();
192
+ await mkdir(dirname(ledgerPath), { recursive: true });
193
+ await writeFile(ledgerPath, JSON.stringify(record) + "\n", { encoding: "utf8", flag: "a" });
194
+ });
195
+
196
+ return ledgerWriteQueue;
197
+ }
198
+
199
+ export function shouldDedupeLlmToolExecution(llmToolName: string): boolean {
200
+ const policy = getToolPolicy(llmToolName);
201
+ return policy?.risk === "write" || policy?.risk === "destructive";
202
+ }
203
+
204
+ export function getLlmToolExecutionIdentity(
205
+ llmToolName: string,
206
+ args: Record<string, unknown>,
207
+ scope: string = "workspace:none"
208
+ ): ToolExecutionIdentity | undefined {
209
+ if (!shouldDedupeLlmToolExecution(llmToolName)) {
210
+ return undefined;
211
+ }
212
+
213
+ const mapping = resolveToolMapping(llmToolName);
214
+ const argsHash: string = sha256(stableJson(args));
215
+ const fingerprintHash: string = sha256(`${scope}\n${mapping.serverId}\n${mapping.toolName}\n${argsHash}`);
216
+ return {
217
+ fingerprint: `${mapping.serverId}:${mapping.toolName}:${fingerprintHash}`,
218
+ scope,
219
+ serverId: mapping.serverId,
220
+ toolName: mapping.toolName,
221
+ argsHash
222
+ };
223
+ }
224
+
225
+ function getMcpExecutionScope(mcpHost: McpHost): string {
226
+ return mcpHost.getActiveWorkspaceId() ?? "workspace:none";
227
+ }
228
+
229
+ function addRefreshPath(paths: Set<string>, value: unknown): void {
230
+ if (typeof value !== "string") {
231
+ return;
232
+ }
233
+
234
+ const trimmed: string = value.trim();
235
+ if (trimmed.length === 0) {
236
+ return;
237
+ }
238
+
239
+ paths.add(trimmed);
240
+ }
241
+
242
+ function collectGodotRefreshPaths(args: Record<string, unknown>): string[] {
243
+ const paths: Set<string> = new Set();
244
+ addRefreshPath(paths, args.relativePath);
245
+ addRefreshPath(paths, args.scenePath);
246
+ addRefreshPath(paths, args.scriptPath);
247
+ addRefreshPath(paths, args.resourcePath);
248
+ addRefreshPath(paths, args.path);
249
+
250
+ const operations: unknown = args.operations;
251
+ if (Array.isArray(operations)) {
252
+ for (const operation of operations) {
253
+ if (operation === null || typeof operation !== "object") {
254
+ continue;
255
+ }
256
+
257
+ const operationRecord: Record<string, unknown> = operation as Record<string, unknown>;
258
+ addRefreshPath(paths, operationRecord.scenePath);
259
+ addRefreshPath(paths, operationRecord.scriptPath);
260
+ addRefreshPath(paths, operationRecord.resourcePath);
261
+ addRefreshPath(paths, operationRecord.path);
262
+ }
263
+ }
264
+
265
+ return [...paths];
266
+ }
267
+
268
+ function refreshEditorFilesystemAfterGodotMutation(
269
+ mcpHost: McpHost,
270
+ llmToolName: string,
271
+ args: Record<string, unknown>
272
+ ): void {
273
+ if (!GODOT_PROJECT_MUTATION_TOOLS.has(llmToolName)) {
274
+ return;
275
+ }
276
+
277
+ const changedPaths: string[] = collectGodotRefreshPaths(args);
278
+ void mcpHost.getEditorBridge().refreshFilesystem(changedPaths).catch((error: unknown): void => {
279
+ const message: string = error instanceof Error ? error.message : String(error);
280
+ console.warn(`[godot_editor] resource filesystem refresh failed after ${llmToolName}: ${message}`);
281
+ });
282
+ }
283
+
284
+ async function executeMappedTool(
285
+ mcpHost: McpHost,
286
+ serverId: string,
287
+ toolName: string,
288
+ args: Record<string, unknown>,
289
+ fingerprint?: string | undefined
290
+ ): Promise<IdempotentToolExecutionResult> {
291
+ const result = await mcpHost.callTool(serverId, toolName, args) as ToolResultContent;
292
+ const textResult: string = extractTextContent(result);
293
+ const truncated: boolean = textResult.length > MAX_TOOL_RESULT_CHARS;
294
+ return {
295
+ content: trimToolResult(textResult),
296
+ rawContentLength: textResult.length,
297
+ truncated,
298
+ reused: false,
299
+ fingerprint
300
+ };
301
+ }
302
+
303
+ export async function executeLlmToolWithIdempotency(
304
+ mcpHost: McpHost,
305
+ llmToolName: string,
306
+ args: Record<string, unknown>
307
+ ): Promise<IdempotentToolExecutionResult> {
308
+ const identity: ToolExecutionIdentity | undefined = getLlmToolExecutionIdentity(llmToolName, args, getMcpExecutionScope(mcpHost));
309
+ if (identity === undefined) {
310
+ const mapping = resolveToolMapping(llmToolName);
311
+ const result: IdempotentToolExecutionResult = await executeMappedTool(mcpHost, mapping.serverId, mapping.toolName, args);
312
+ refreshEditorFilesystemAfterGodotMutation(mcpHost, llmToolName, args);
313
+ return result;
314
+ }
315
+
316
+ await ensureLedgerLoaded();
317
+ pruneCompletedToolExecutions();
318
+
319
+ const existingRecord: ToolExecutionRecord | undefined = completedToolExecutions.get(identity.fingerprint);
320
+ if (existingRecord !== undefined && !isRecordExpired(existingRecord)) {
321
+ refreshEditorFilesystemAfterGodotMutation(mcpHost, llmToolName, args);
322
+ return {
323
+ content: existingRecord.content,
324
+ rawContentLength: existingRecord.rawContentLength,
325
+ truncated: existingRecord.truncated,
326
+ reused: true,
327
+ fingerprint: identity.fingerprint
328
+ };
329
+ }
330
+
331
+ const existingInFlight: Promise<IdempotentToolExecutionResult> | undefined = inFlightToolExecutions.get(identity.fingerprint);
332
+ if (existingInFlight !== undefined) {
333
+ const result: IdempotentToolExecutionResult = await existingInFlight;
334
+ refreshEditorFilesystemAfterGodotMutation(mcpHost, llmToolName, args);
335
+ return { ...result, reused: true };
336
+ }
337
+
338
+ const executionPromise: Promise<IdempotentToolExecutionResult> = (async (): Promise<IdempotentToolExecutionResult> => {
339
+ const result: IdempotentToolExecutionResult = await executeMappedTool(
340
+ mcpHost,
341
+ identity.serverId,
342
+ identity.toolName,
343
+ args,
344
+ identity.fingerprint
345
+ );
346
+ refreshEditorFilesystemAfterGodotMutation(mcpHost, llmToolName, args);
347
+ const createdAt: string = new Date().toISOString();
348
+ const record: ToolExecutionRecord = {
349
+ fingerprint: identity.fingerprint,
350
+ scope: identity.scope,
351
+ llmToolName,
352
+ serverId: identity.serverId,
353
+ toolName: identity.toolName,
354
+ argsHash: identity.argsHash,
355
+ content: result.content,
356
+ rawContentLength: result.rawContentLength,
357
+ truncated: result.truncated,
358
+ createdAt,
359
+ expiresAt: new Date(Date.now() + TOOL_EXECUTION_DEDUP_TTL_MS).toISOString()
360
+ };
361
+ completedToolExecutions.set(identity.fingerprint, record);
362
+ pruneCompletedToolExecutions();
363
+ await enqueueLedgerWrite(record);
364
+ return result;
365
+ })();
366
+
367
+ inFlightToolExecutions.set(identity.fingerprint, executionPromise);
368
+ try {
369
+ return await executionPromise;
370
+ } finally {
371
+ inFlightToolExecutions.delete(identity.fingerprint);
372
+ }
373
+ }
@@ -0,0 +1,61 @@
1
+ import type { ToolPolicy } from "./tool-policy.js";
2
+
3
+ export const TOOL_POLICIES: Record<string, ToolPolicy> = {
4
+ "mcp_godot_get_project_summary": { risk: "read" },
5
+ "mcp_godot_list_project_files": { risk: "read" },
6
+ "mcp_godot_list_scenes": { risk: "read" },
7
+ "mcp_godot_list_scripts": { risk: "read" },
8
+ "mcp_godot_read_text_file": { risk: "read" },
9
+ "mcp_godot_search_text": { risk: "read" },
10
+ "mcp_godot_get_project_log_config": { risk: "read" },
11
+ "mcp_godot_list_project_logs": { risk: "read" },
12
+ "mcp_godot_read_project_log": { risk: "read" },
13
+ "mcp_godot_get_project_settings": { risk: "read" },
14
+ "mcp_godot_get_editor_config_summary": { risk: "read" },
15
+ "mcp_godot_get_editor_settings": { risk: "read" },
16
+ "mcp_godot_list_editor_config_files": { risk: "read" },
17
+ "mcp_godot_read_editor_config_file": { risk: "read" },
18
+ "mcp_godot_get_editor_project_state": { risk: "read" },
19
+ "mcp_godot_get_recent_projects": { risk: "read" },
20
+ "mcp_godot_propose_set_project_setting": { risk: "propose" },
21
+ "mcp_godot_set_project_setting": { risk: "write" },
22
+ "mcp_godot_propose_unset_project_setting": { risk: "propose" },
23
+ "mcp_godot_unset_project_setting": { risk: "write" },
24
+ "mcp_godot_propose_create_text_file": { risk: "propose" },
25
+ "mcp_godot_create_text_file": { risk: "write" },
26
+ "mcp_godot_propose_overwrite_text_file": { risk: "propose" },
27
+ "mcp_godot_overwrite_text_file": { risk: "write" },
28
+ "mcp_godot_propose_replace_text_in_file": { risk: "propose" },
29
+ "mcp_godot_replace_text_in_file": { risk: "write" },
30
+ "mcp_godot_delete_file": { risk: "destructive" },
31
+ "mcp_godot_inspect_scene_tree": { risk: "read" },
32
+ "mcp_godot_propose_create_scene": { risk: "propose" },
33
+ "mcp_godot_create_scene": { risk: "write" },
34
+ "mcp_godot_propose_add_node_to_scene": { risk: "propose" },
35
+ "mcp_godot_add_node_to_scene": { risk: "write" },
36
+ "mcp_godot_propose_attach_script_to_node": { risk: "propose" },
37
+ "mcp_godot_attach_script_to_node": { risk: "write" },
38
+ "mcp_godot_propose_connect_signal_in_scene": { risk: "propose" },
39
+ "mcp_godot_connect_signal_in_scene": { risk: "write" },
40
+ "mcp_godot_propose_apply_scene_patch": { risk: "propose" },
41
+ "mcp_godot_apply_scene_patch": { risk: "write" },
42
+ "mcp_godot_editor_get_context": { risk: "read" },
43
+ "mcp_godot_editor_get_selected_nodes": { risk: "read" },
44
+ "mcp_godot_editor_inspect_node": { risk: "read" },
45
+ "mcp_godot_editor_apply_scene_patch": { risk: "write" },
46
+ "mcp_godot_lsp_get_status": { risk: "read" },
47
+ "mcp_godot_lsp_get_file_diagnostics": { risk: "read" },
48
+ "mcp_godot_lsp_get_document_symbols": { risk: "read" },
49
+ "mcp_godot_lsp_hover": { risk: "read" },
50
+ "mcp_godot_lsp_goto_definition": { risk: "read" },
51
+ "mcp_godot_dap_get_status": { risk: "read" },
52
+ "mcp_godot_dap_get_last_error": { risk: "read" },
53
+ "mcp_godot_dap_get_stack_trace": { risk: "read" },
54
+ "mcp_godot_dap_get_variables": { risk: "read" },
55
+ "mcp_terminal_get_capabilities": { risk: "read" },
56
+ "mcp_terminal_run_safe_preset": { risk: "verify" },
57
+ "mcp_terminal_run_write_preset": { risk: "write" },
58
+ "mcp_terminal_run_godot_scene_script": { risk: "write" },
59
+ };
60
+
61
+ export const HARD_BLOCKED_TOOLS: Set<string> = new Set([]);
@@ -0,0 +1,73 @@
1
+ import { isDynamicMcpToolName } from "./llm-tools.js";
2
+ import { HARD_BLOCKED_TOOLS, TOOL_POLICIES } from "./tool-policy-table.js";
3
+
4
+ export type ApprovalMode = "read-only" | "manual" | "auto-safe" | "bypass";
5
+
6
+ export type ToolRisk = "read" | "verify" | "propose" | "write" | "destructive";
7
+
8
+ export type ToolPolicy = {
9
+ risk: ToolRisk;
10
+ };
11
+
12
+ export function getToolPolicy(toolName: string): ToolPolicy | undefined {
13
+ if (isDynamicMcpToolName(toolName)) {
14
+ return { risk: "write" };
15
+ }
16
+
17
+ return TOOL_POLICIES[toolName];
18
+ }
19
+
20
+ export function isHardBlocked(toolName: string): boolean {
21
+ return HARD_BLOCKED_TOOLS.has(toolName);
22
+ }
23
+
24
+ export type ApprovalDecision =
25
+ | { action: "allow" }
26
+ | { action: "request_approval"; reason: string }
27
+ | { action: "deny"; reason: string };
28
+
29
+ export function evaluateToolCall(
30
+ mode: ApprovalMode,
31
+ toolName: string,
32
+ _args: Record<string, unknown>
33
+ ): ApprovalDecision {
34
+ const policy: ToolPolicy | undefined = getToolPolicy(toolName);
35
+
36
+ if (!policy) {
37
+ return { action: "deny", reason: `未知工具: ${toolName}` };
38
+ }
39
+
40
+ if (isHardBlocked(toolName)) {
41
+ return { action: "deny", reason: "该工具已被硬性禁用" };
42
+ }
43
+
44
+ if (mode === "read-only") {
45
+ return policy.risk === "read" || policy.risk === "verify"
46
+ ? { action: "allow" }
47
+ : { action: "deny", reason: "当前为只读模式,不允许写操作" };
48
+ }
49
+
50
+ if (mode === "manual") {
51
+ if (policy.risk === "read" || policy.risk === "verify" || policy.risk === "propose") {
52
+ return { action: "allow" };
53
+ }
54
+
55
+ return { action: "request_approval", reason: "写操作需要用户在 Godot 客户端确认" };
56
+ }
57
+
58
+ if (mode === "auto-safe") {
59
+ if (policy.risk === "read" || policy.risk === "verify" || policy.risk === "propose") {
60
+ return { action: "allow" };
61
+ }
62
+
63
+ return { action: "request_approval", reason: "此写操作需要确认(auto-safe 模式)" };
64
+ }
65
+
66
+ if (mode === "bypass") {
67
+ return policy.risk === "destructive"
68
+ ? { action: "request_approval", reason: "破坏性操作仍需用户确认" }
69
+ : { action: "allow" };
70
+ }
71
+
72
+ return { action: "deny", reason: "未知审批模式" };
73
+ }