@yuaone/core 0.4.4 → 0.4.6
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/dist/agent-loop.d.ts +9 -0
- package/dist/agent-loop.d.ts.map +1 -1
- package/dist/agent-loop.js +484 -223
- package/dist/agent-loop.js.map +1 -1
- package/dist/constants.d.ts +16 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +16 -0
- package/dist/constants.js.map +1 -1
- package/dist/context-manager.d.ts.map +1 -1
- package/dist/context-manager.js +24 -1
- package/dist/context-manager.js.map +1 -1
- package/dist/governor.d.ts.map +1 -1
- package/dist/governor.js +11 -1
- package/dist/governor.js.map +1 -1
- package/dist/llm-client.d.ts.map +1 -1
- package/dist/llm-client.js +18 -1
- package/dist/llm-client.js.map +1 -1
- package/dist/security.d.ts +8 -0
- package/dist/security.d.ts.map +1 -1
- package/dist/security.js +60 -1
- package/dist/security.js.map +1 -1
- package/dist/self-debug-loop.d.ts +3 -0
- package/dist/self-debug-loop.d.ts.map +1 -1
- package/dist/self-debug-loop.js +8 -1
- package/dist/self-debug-loop.js.map +1 -1
- package/package.json +1 -1
package/dist/agent-loop.js
CHANGED
|
@@ -45,6 +45,9 @@ import { RepoKnowledgeGraph } from "./repo-knowledge-graph.js";
|
|
|
45
45
|
import { BackgroundAgentManager } from "./background-agent.js";
|
|
46
46
|
import { ReasoningAggregator } from "./reasoning-aggregator.js";
|
|
47
47
|
import { ReasoningTree } from "./reasoning-tree.js";
|
|
48
|
+
import { ContextCompressor } from "./context-compressor.js";
|
|
49
|
+
import { DependencyAnalyzer } from "./dependency-analyzer.js";
|
|
50
|
+
import { CrossFileRefactor } from "./cross-file-refactor.js";
|
|
48
51
|
/**
|
|
49
52
|
* AgentLoop — YUAN 에이전트의 핵심 실행 루프.
|
|
50
53
|
*
|
|
@@ -746,6 +749,57 @@ export class AgentLoop extends EventEmitter {
|
|
|
746
749
|
});
|
|
747
750
|
}
|
|
748
751
|
}
|
|
752
|
+
// CrossFileRefactor: detect rename/move intent and inject preview hint
|
|
753
|
+
if (this.config.loop.projectPath) {
|
|
754
|
+
try {
|
|
755
|
+
const renameMatch = userMessage.match(/\brename\s+[`'"]?(\w[\w.]*)[`'"]?\s+to\s+[`'"]?(\w[\w.]*)[`'"]/i);
|
|
756
|
+
const moveMatch = userMessage.match(/\bmove\s+[`'"]?([\w./\\-]+)[`'"]?\s+to\s+[`'"]?([\w./\\-]+)[`'"]/i);
|
|
757
|
+
if (renameMatch) {
|
|
758
|
+
const [, symbolName, newName] = renameMatch;
|
|
759
|
+
const refactor = new CrossFileRefactor(this.config.loop.projectPath);
|
|
760
|
+
const preview = await refactor.renameSymbol(symbolName, newName);
|
|
761
|
+
if (preview.totalChanges > 0) {
|
|
762
|
+
const affectedList = preview.affectedFiles
|
|
763
|
+
.map((f) => ` - ${f.file} (${f.changes.length} change(s))`)
|
|
764
|
+
.join("\n");
|
|
765
|
+
this.contextManager.addMessage({
|
|
766
|
+
role: "system",
|
|
767
|
+
content: `[CrossFileRefactor] Rename "${symbolName}" → "${newName}" preview:\n` +
|
|
768
|
+
`Risk: ${preview.riskLevel}, Files affected: ${preview.affectedFiles.length}\n` +
|
|
769
|
+
affectedList +
|
|
770
|
+
(preview.warnings.length > 0
|
|
771
|
+
? `\nWarnings: ${preview.warnings.join("; ")}`
|
|
772
|
+
: ""),
|
|
773
|
+
});
|
|
774
|
+
this.iterationSystemMsgCount++;
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
else if (moveMatch) {
|
|
778
|
+
const [, symbolOrFile, destination] = moveMatch;
|
|
779
|
+
const refactor = new CrossFileRefactor(this.config.loop.projectPath);
|
|
780
|
+
// Try move as symbol move (source heuristic: look for a file with that name)
|
|
781
|
+
const preview = await refactor.moveSymbol(symbolOrFile, symbolOrFile, destination);
|
|
782
|
+
if (preview.totalChanges > 0) {
|
|
783
|
+
const affectedList = preview.affectedFiles
|
|
784
|
+
.map((f) => ` - ${f.file} (${f.changes.length} change(s))`)
|
|
785
|
+
.join("\n");
|
|
786
|
+
this.contextManager.addMessage({
|
|
787
|
+
role: "system",
|
|
788
|
+
content: `[CrossFileRefactor] Move "${symbolOrFile}" → "${destination}" preview:\n` +
|
|
789
|
+
`Risk: ${preview.riskLevel}, Files affected: ${preview.affectedFiles.length}\n` +
|
|
790
|
+
affectedList +
|
|
791
|
+
(preview.warnings.length > 0
|
|
792
|
+
? `\nWarnings: ${preview.warnings.join("; ")}`
|
|
793
|
+
: ""),
|
|
794
|
+
});
|
|
795
|
+
this.iterationSystemMsgCount++;
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
catch {
|
|
800
|
+
// CrossFileRefactor preview failure is non-fatal
|
|
801
|
+
}
|
|
802
|
+
}
|
|
749
803
|
// 복잡도 감지 → 필요 시 자동 플래닝
|
|
750
804
|
await this.maybeCreatePlan(userMessage);
|
|
751
805
|
// ContinuousReflection 시작 (1분 간격 체크포인트/자기검증/컨텍스트모니터)
|
|
@@ -868,10 +922,29 @@ export class AgentLoop extends EventEmitter {
|
|
|
868
922
|
for (const learning of learnings) {
|
|
869
923
|
this.memoryManager.addLearning(learning.category, learning.content);
|
|
870
924
|
}
|
|
925
|
+
// 감지된 컨벤션 저장 (기존에 추출만 되고 저장 안 되던 버그 수정)
|
|
926
|
+
for (const convention of analysis.conventions) {
|
|
927
|
+
this.memoryManager.addConvention(convention);
|
|
928
|
+
}
|
|
929
|
+
// 감지된 패턴 저장
|
|
930
|
+
for (const pattern of analysis.toolPatterns) {
|
|
931
|
+
if (pattern.successRate > 0.7 && pattern.count >= 3) {
|
|
932
|
+
this.memoryManager.addPattern({
|
|
933
|
+
name: `tool:${pattern.tool}`,
|
|
934
|
+
description: `success ${Math.round(pattern.successRate * 100)}%, avg ${pattern.avgDurationMs}ms`,
|
|
935
|
+
files: this.changedFiles.slice(0, 5),
|
|
936
|
+
frequency: pattern.count,
|
|
937
|
+
});
|
|
938
|
+
}
|
|
939
|
+
}
|
|
871
940
|
// 에러로 종료된 경우 실패 기록도 추가
|
|
872
941
|
if (result.reason === "ERROR") {
|
|
873
942
|
this.memoryManager.addFailedApproach(`Task: ${userGoal.slice(0, 80)}`, result.error ?? "Unknown error");
|
|
874
943
|
}
|
|
944
|
+
// 오래된 항목 정리 (매 5회 실행마다)
|
|
945
|
+
if (this.iterationCount % 5 === 0) {
|
|
946
|
+
this.memoryManager.prune();
|
|
947
|
+
}
|
|
875
948
|
// 메모리 저장
|
|
876
949
|
await this.memoryManager.save();
|
|
877
950
|
}
|
|
@@ -1045,11 +1118,16 @@ export class AgentLoop extends EventEmitter {
|
|
|
1045
1118
|
const complexity = this.detectComplexity(userMessage);
|
|
1046
1119
|
this.currentComplexity = complexity;
|
|
1047
1120
|
// 임계값 미만이면 플래닝 스킵
|
|
1048
|
-
|
|
1121
|
+
// Bug 4 fix: extend thresholdOrder to include "massive" (4), so that when planningThreshold
|
|
1122
|
+
// is "complex", both "complex" (3) and "massive" (4) trigger planning.
|
|
1123
|
+
// Previously "massive" had no entry and fell through to undefined → NaN comparisons.
|
|
1124
|
+
const thresholdOrder = { simple: 1, moderate: 2, complex: 3, massive: 4 };
|
|
1049
1125
|
const complexityOrder = {
|
|
1050
1126
|
trivial: 0, simple: 1, moderate: 2, complex: 3, massive: 4,
|
|
1051
1127
|
};
|
|
1052
|
-
|
|
1128
|
+
// Use the threshold for the configured level; "complex" threshold activates for complexity >= "complex"
|
|
1129
|
+
const effectiveThreshold = thresholdOrder[this.planningThreshold] ?? 2;
|
|
1130
|
+
if ((complexityOrder[complexity] ?? 0) < effectiveThreshold) {
|
|
1053
1131
|
return;
|
|
1054
1132
|
}
|
|
1055
1133
|
this.emitSubagent("planner", "start", `task complexity ${complexity}. creating execution plan`);
|
|
@@ -1078,6 +1156,40 @@ export class AgentLoop extends EventEmitter {
|
|
|
1078
1156
|
* 사용자 메시지에서 태스크 복잡도를 휴리스틱으로 추정.
|
|
1079
1157
|
* LLM 호출 없이 빠르게 결정 (토큰 절약).
|
|
1080
1158
|
*/
|
|
1159
|
+
/**
|
|
1160
|
+
* Detect the best test/verify command for the current project.
|
|
1161
|
+
* Bug 3 fix: replaces the hardcoded "pnpm build" default.
|
|
1162
|
+
*/
|
|
1163
|
+
detectTestCommand() {
|
|
1164
|
+
const projectPath = this.config.loop.projectPath;
|
|
1165
|
+
try {
|
|
1166
|
+
const { existsSync } = require("node:fs");
|
|
1167
|
+
// Check for package.json with a "test" script
|
|
1168
|
+
const pkgPath = `${projectPath}/package.json`;
|
|
1169
|
+
if (existsSync(pkgPath)) {
|
|
1170
|
+
try {
|
|
1171
|
+
const pkg = JSON.parse(require("node:fs").readFileSync(pkgPath, "utf-8"));
|
|
1172
|
+
const scripts = pkg.scripts;
|
|
1173
|
+
if (scripts?.test && scripts.test !== "echo \"Error: no test specified\" && exit 1") {
|
|
1174
|
+
// Prefer pnpm if pnpm-lock.yaml exists
|
|
1175
|
+
const usesPnpm = existsSync(`${projectPath}/pnpm-lock.yaml`);
|
|
1176
|
+
return usesPnpm ? "pnpm test" : "npm test";
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
catch {
|
|
1180
|
+
// package.json parse failure — fall through
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
// Check for tsconfig.json → TypeScript type check
|
|
1184
|
+
if (existsSync(`${projectPath}/tsconfig.json`)) {
|
|
1185
|
+
return "npx tsc --noEmit";
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
catch {
|
|
1189
|
+
// Require/existsSync failure in unusual environments — use default
|
|
1190
|
+
}
|
|
1191
|
+
return "pnpm build";
|
|
1192
|
+
}
|
|
1081
1193
|
detectComplexity(message) {
|
|
1082
1194
|
const lower = message.toLowerCase();
|
|
1083
1195
|
const len = message.length;
|
|
@@ -1280,6 +1392,39 @@ export class AgentLoop extends EventEmitter {
|
|
|
1280
1392
|
// Soft context rollover:
|
|
1281
1393
|
// checkpoint first, then let ContextManager compact instead of aborting/throwing.
|
|
1282
1394
|
const contextUsageRatio = this.contextManager.getUsageRatio();
|
|
1395
|
+
// Bug 5 fix: use ContextCompressor as an alternative when context pressure is high (>70%)
|
|
1396
|
+
// At 70-84% we apply intelligent priority-based compression before falling back to truncation.
|
|
1397
|
+
if (contextUsageRatio >= 0.70 && contextUsageRatio < 0.85) {
|
|
1398
|
+
try {
|
|
1399
|
+
// Estimate maxTokens from the usage ratio and current message count
|
|
1400
|
+
// contextUsageRatio = estimatedTokens / (maxTokens - outputReserve)
|
|
1401
|
+
// We use a conservative 128_000 as a safe upper bound
|
|
1402
|
+
const estimatedMaxTokens = 128_000;
|
|
1403
|
+
const contextCompressor = new ContextCompressor({
|
|
1404
|
+
maxTokens: estimatedMaxTokens,
|
|
1405
|
+
reserveTokens: Math.ceil(estimatedMaxTokens * 0.15),
|
|
1406
|
+
});
|
|
1407
|
+
const currentMessages = this.contextManager.getMessages();
|
|
1408
|
+
const currentTokenEstimate = Math.ceil(estimatedMaxTokens * contextUsageRatio);
|
|
1409
|
+
const compressed = contextCompressor.compress(currentMessages, currentTokenEstimate);
|
|
1410
|
+
if (compressed.evicted > 0 || compressed.summarized > 0) {
|
|
1411
|
+
// Replace messages in contextManager with compressed version
|
|
1412
|
+
// by clearing and re-adding (contextManager.addMessages is the public API)
|
|
1413
|
+
const internalMessages = this.contextManager.messages;
|
|
1414
|
+
if (internalMessages) {
|
|
1415
|
+
internalMessages.length = 0;
|
|
1416
|
+
internalMessages.push(...compressed.messages);
|
|
1417
|
+
}
|
|
1418
|
+
this.emitEvent({
|
|
1419
|
+
kind: "agent:thinking",
|
|
1420
|
+
content: `Context pressure ${Math.round(contextUsageRatio * 100)}%: ContextCompressor applied (evicted ${compressed.evicted}, summarized ${compressed.summarized} messages).`,
|
|
1421
|
+
});
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
catch {
|
|
1425
|
+
// ContextCompressor failure is non-fatal; ContextManager will handle via compactHistory
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1283
1428
|
if (contextUsageRatio >= 0.85) {
|
|
1284
1429
|
if (!this.checkpointSaved) {
|
|
1285
1430
|
await this.saveAutoCheckpoint(iteration);
|
|
@@ -1659,8 +1804,11 @@ export class AgentLoop extends EventEmitter {
|
|
|
1659
1804
|
const rootCauseAnalysis = this.selfDebugLoop.analyzeError(errorSummary);
|
|
1660
1805
|
if (rootCauseAnalysis.confidence >= 0.5) {
|
|
1661
1806
|
const debugStrategy = this.selfDebugLoop.selectStrategy(iteration - 2, []);
|
|
1807
|
+
// Bug 3 fix: use dynamic test command detection instead of hardcoded "pnpm build"
|
|
1808
|
+
const testCmd = this.config.loop.testCommand
|
|
1809
|
+
?? this.detectTestCommand();
|
|
1662
1810
|
const debugPrompt = this.selfDebugLoop.buildFixPrompt(debugStrategy, {
|
|
1663
|
-
testCommand:
|
|
1811
|
+
testCommand: testCmd,
|
|
1664
1812
|
errorOutput: errorSummary,
|
|
1665
1813
|
changedFiles: this.changedFiles,
|
|
1666
1814
|
originalSnapshots: this.originalSnapshots,
|
|
@@ -1668,6 +1816,30 @@ export class AgentLoop extends EventEmitter {
|
|
|
1668
1816
|
currentStrategy: debugStrategy,
|
|
1669
1817
|
});
|
|
1670
1818
|
debugSuffix = `\n\n[SelfDebug L${Math.min(iteration - 2, 5)}] Strategy: ${debugStrategy}\n${debugPrompt}`;
|
|
1819
|
+
// Bug 2 fix: wire a real llmFixer so selfDebugLoop.debug() can call LLM
|
|
1820
|
+
if (debugStrategy !== "escalate") {
|
|
1821
|
+
this.selfDebugLoop.debug({
|
|
1822
|
+
testCommand: testCmd,
|
|
1823
|
+
errorOutput: errorSummary,
|
|
1824
|
+
changedFiles: this.changedFiles,
|
|
1825
|
+
originalSnapshots: this.originalSnapshots,
|
|
1826
|
+
toolExecutor: this.toolExecutor,
|
|
1827
|
+
llmFixer: async (prompt) => {
|
|
1828
|
+
try {
|
|
1829
|
+
const response = await this.llmClient.chat([
|
|
1830
|
+
{ role: "system", content: "You are an expert debugging assistant. Analyze the error and provide tool calls to fix it." },
|
|
1831
|
+
{ role: "user", content: prompt },
|
|
1832
|
+
], []);
|
|
1833
|
+
return response.content ?? "";
|
|
1834
|
+
}
|
|
1835
|
+
catch {
|
|
1836
|
+
return "";
|
|
1837
|
+
}
|
|
1838
|
+
},
|
|
1839
|
+
}).catch(() => {
|
|
1840
|
+
// selfDebugLoop.debug() failures are non-fatal — recovery continues
|
|
1841
|
+
});
|
|
1842
|
+
}
|
|
1671
1843
|
}
|
|
1672
1844
|
}
|
|
1673
1845
|
this.contextManager.addMessage({
|
|
@@ -1921,256 +2093,345 @@ export class AgentLoop extends EventEmitter {
|
|
|
1921
2093
|
* 3. 도구 실행
|
|
1922
2094
|
* 4. AutoFixLoop 결과 검증 → 실패 시 에러 피드백 메시지 추가
|
|
1923
2095
|
*/
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
const
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
if (err instanceof ApprovalRequiredError) {
|
|
1939
|
-
// Governor가 위험 감지 → ApprovalManager로 승인 프로세스 위임
|
|
1940
|
-
const approvalResult = await this.handleApproval(toolCall, args, err);
|
|
1941
|
-
if (approvalResult) {
|
|
1942
|
-
results.push(approvalResult);
|
|
1943
|
-
continue;
|
|
1944
|
-
}
|
|
1945
|
-
// 승인됨 → 계속 실행
|
|
1946
|
-
}
|
|
1947
|
-
// Generic tool-definition approval gate
|
|
1948
|
-
if (matchedDefinition?.requiresApproval) {
|
|
1949
|
-
const definitionApprovalReq = {
|
|
1950
|
-
id: `definition-approval-${toolCall.id}`,
|
|
1951
|
-
toolName: toolCall.name,
|
|
1952
|
-
arguments: args,
|
|
1953
|
-
reason: matchedDefinition.source === "mcp"
|
|
1954
|
-
? `MCP tool "${toolCall.name}" requires approval`
|
|
1955
|
-
: `Tool "${toolCall.name}" requires approval`,
|
|
1956
|
-
riskLevel: matchedDefinition.riskLevel === "critical" ||
|
|
1957
|
-
matchedDefinition.riskLevel === "high"
|
|
1958
|
-
? "high"
|
|
1959
|
-
: "medium",
|
|
1960
|
-
timeout: 120_000,
|
|
1961
|
-
};
|
|
1962
|
-
const definitionApprovalResult = await this.handleApprovalRequest(toolCall, definitionApprovalReq);
|
|
1963
|
-
if (definitionApprovalResult) {
|
|
1964
|
-
results.push(definitionApprovalResult);
|
|
1965
|
-
continue;
|
|
1966
|
-
}
|
|
1967
|
-
}
|
|
1968
|
-
else {
|
|
1969
|
-
throw err;
|
|
1970
|
-
}
|
|
1971
|
-
}
|
|
1972
|
-
// ApprovalManager: 추가 승인 체크 (Governor가 못 잡은 규칙)
|
|
1973
|
-
const approvalRequest = this.approvalManager.checkApproval(toolCall.name, args);
|
|
1974
|
-
if (approvalRequest) {
|
|
1975
|
-
const approvalResult = await this.handleApprovalRequest(toolCall, approvalRequest);
|
|
2096
|
+
/**
|
|
2097
|
+
* Execute a single tool call (extracted helper for parallel execution support).
|
|
2098
|
+
*/
|
|
2099
|
+
async executeSingleTool(toolCall, toolCalls) {
|
|
2100
|
+
const args = this.parseToolArgs(toolCall.arguments);
|
|
2101
|
+
const allDefinitions = [...this.config.loop.tools, ...this.mcpToolDefinitions];
|
|
2102
|
+
const matchedDefinition = allDefinitions.find((t) => t.name === toolCall.name);
|
|
2103
|
+
// Governor: 안전성 검증
|
|
2104
|
+
try {
|
|
2105
|
+
this.governor.validateToolCall(toolCall);
|
|
2106
|
+
}
|
|
2107
|
+
catch (err) {
|
|
2108
|
+
if (err instanceof ApprovalRequiredError) {
|
|
2109
|
+
const approvalResult = await this.handleApproval(toolCall, args, err);
|
|
1976
2110
|
if (approvalResult) {
|
|
1977
|
-
|
|
1978
|
-
continue;
|
|
2111
|
+
return { result: approvalResult, deferredFixPrompt: null };
|
|
1979
2112
|
}
|
|
1980
2113
|
// 승인됨 → 계속 실행
|
|
1981
2114
|
}
|
|
1982
|
-
//
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
(matchedPluginTool.tool.requiresApproval === true ||
|
|
1987
|
-
matchedPluginTool.tool.sideEffectLevel === "destructive")) {
|
|
1988
|
-
const pluginApprovalReq = {
|
|
1989
|
-
id: `plugin-approval-${toolCall.id}`,
|
|
2115
|
+
// Generic tool-definition approval gate
|
|
2116
|
+
if (matchedDefinition?.requiresApproval) {
|
|
2117
|
+
const definitionApprovalReq = {
|
|
2118
|
+
id: `definition-approval-${toolCall.id}`,
|
|
1990
2119
|
toolName: toolCall.name,
|
|
1991
2120
|
arguments: args,
|
|
1992
|
-
reason:
|
|
1993
|
-
? "
|
|
1994
|
-
: "
|
|
1995
|
-
riskLevel:
|
|
2121
|
+
reason: matchedDefinition.source === "mcp"
|
|
2122
|
+
? `MCP tool "${toolCall.name}" requires approval`
|
|
2123
|
+
: `Tool "${toolCall.name}" requires approval`,
|
|
2124
|
+
riskLevel: matchedDefinition.riskLevel === "critical" ||
|
|
2125
|
+
matchedDefinition.riskLevel === "high"
|
|
2126
|
+
? "high"
|
|
2127
|
+
: "medium",
|
|
1996
2128
|
timeout: 120_000,
|
|
1997
2129
|
};
|
|
1998
|
-
const
|
|
1999
|
-
if (
|
|
2000
|
-
|
|
2001
|
-
continue;
|
|
2130
|
+
const definitionApprovalResult = await this.handleApprovalRequest(toolCall, definitionApprovalReq);
|
|
2131
|
+
if (definitionApprovalResult) {
|
|
2132
|
+
return { result: definitionApprovalResult, deferredFixPrompt: null };
|
|
2002
2133
|
}
|
|
2003
|
-
// Approved → proceed with execution
|
|
2004
2134
|
}
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
const mcpResult = await this.executeMCPTool(toolCall);
|
|
2008
|
-
results.push(mcpResult);
|
|
2009
|
-
this.emitEvent({
|
|
2010
|
-
kind: "agent:tool_result",
|
|
2011
|
-
tool: toolCall.name,
|
|
2012
|
-
output: mcpResult.output.length > 200
|
|
2013
|
-
? mcpResult.output.slice(0, 200) + "..."
|
|
2014
|
-
: mcpResult.output,
|
|
2015
|
-
durationMs: mcpResult.durationMs,
|
|
2016
|
-
});
|
|
2017
|
-
this.emitEvent({
|
|
2018
|
-
kind: "agent:reasoning_delta",
|
|
2019
|
-
text: `tool finished: ${toolCall.name}`,
|
|
2020
|
-
});
|
|
2021
|
-
continue;
|
|
2135
|
+
else if (err instanceof ApprovalRequiredError) {
|
|
2136
|
+
// already handled above
|
|
2022
2137
|
}
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
const toolAbort = new AbortController();
|
|
2026
|
-
this.interruptManager.registerToolAbort(toolAbort);
|
|
2027
|
-
// rollback용 원본 스냅샷은 실행 전에 저장
|
|
2028
|
-
if (["file_write", "file_edit"].includes(toolCall.name)) {
|
|
2029
|
-
const candidatePath = args.path ??
|
|
2030
|
-
args.file;
|
|
2031
|
-
if (candidatePath) {
|
|
2032
|
-
const filePathStr = String(candidatePath);
|
|
2033
|
-
if (!this.originalSnapshots.has(filePathStr)) {
|
|
2034
|
-
try {
|
|
2035
|
-
const { readFile } = await import("node:fs/promises");
|
|
2036
|
-
const original = await readFile(filePathStr, "utf-8");
|
|
2037
|
-
this.originalSnapshots.set(filePathStr, original);
|
|
2038
|
-
}
|
|
2039
|
-
catch (err) {
|
|
2040
|
-
if (err.code !== "ENOENT") {
|
|
2041
|
-
throw err;
|
|
2042
|
-
}
|
|
2043
|
-
}
|
|
2044
|
-
}
|
|
2045
|
-
}
|
|
2138
|
+
else {
|
|
2139
|
+
throw err;
|
|
2046
2140
|
}
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2141
|
+
}
|
|
2142
|
+
// ApprovalManager: 추가 승인 체크
|
|
2143
|
+
const approvalRequest = this.approvalManager.checkApproval(toolCall.name, args);
|
|
2144
|
+
if (approvalRequest) {
|
|
2145
|
+
const approvalResult = await this.handleApprovalRequest(toolCall, approvalRequest);
|
|
2146
|
+
if (approvalResult) {
|
|
2147
|
+
return { result: approvalResult, deferredFixPrompt: null };
|
|
2148
|
+
}
|
|
2149
|
+
}
|
|
2150
|
+
// Plugin Tool Approval Gate
|
|
2151
|
+
const pluginTools = this.pluginRegistry.getAllTools();
|
|
2152
|
+
const matchedPluginTool = pluginTools.find((pt) => pt.tool.name === toolCall.name);
|
|
2153
|
+
if (matchedPluginTool &&
|
|
2154
|
+
(matchedPluginTool.tool.requiresApproval === true ||
|
|
2155
|
+
matchedPluginTool.tool.sideEffectLevel === "destructive")) {
|
|
2156
|
+
const pluginApprovalReq = {
|
|
2157
|
+
id: `plugin-approval-${toolCall.id}`,
|
|
2158
|
+
toolName: toolCall.name,
|
|
2159
|
+
arguments: args,
|
|
2160
|
+
reason: `Plugin tool "${toolCall.name}" (from ${matchedPluginTool.pluginId}) requires approval (${matchedPluginTool.tool.sideEffectLevel === "destructive"
|
|
2161
|
+
? "destructive side effect"
|
|
2162
|
+
: "requiresApproval=true"})`,
|
|
2163
|
+
riskLevel: matchedPluginTool.tool.riskLevel === "high" ? "high" : "medium",
|
|
2164
|
+
timeout: 120_000,
|
|
2165
|
+
};
|
|
2166
|
+
const pluginApprovalResult = await this.handleApprovalRequest(toolCall, pluginApprovalReq);
|
|
2167
|
+
if (pluginApprovalResult) {
|
|
2168
|
+
return { result: pluginApprovalResult, deferredFixPrompt: null };
|
|
2169
|
+
}
|
|
2170
|
+
}
|
|
2171
|
+
// MCP 도구 호출 확인
|
|
2172
|
+
if (this.mcpClient && this.isMCPTool(toolCall.name)) {
|
|
2173
|
+
const mcpResult = await this.executeMCPTool(toolCall);
|
|
2174
|
+
this.emitEvent({
|
|
2175
|
+
kind: "agent:tool_result",
|
|
2176
|
+
tool: toolCall.name,
|
|
2177
|
+
output: mcpResult.output.length > 200
|
|
2178
|
+
? mcpResult.output.slice(0, 200) + "..."
|
|
2179
|
+
: mcpResult.output,
|
|
2180
|
+
durationMs: mcpResult.durationMs,
|
|
2181
|
+
});
|
|
2182
|
+
this.emitEvent({ kind: "agent:reasoning_delta", text: `tool finished: ${toolCall.name}` });
|
|
2183
|
+
return { result: mcpResult, deferredFixPrompt: null };
|
|
2184
|
+
}
|
|
2185
|
+
// 도구 실행
|
|
2186
|
+
const startTime = Date.now();
|
|
2187
|
+
const toolAbort = new AbortController();
|
|
2188
|
+
this.interruptManager.registerToolAbort(toolAbort);
|
|
2189
|
+
if (["file_write", "file_edit"].includes(toolCall.name)) {
|
|
2190
|
+
const candidatePath = args.path ??
|
|
2191
|
+
args.file;
|
|
2192
|
+
if (candidatePath) {
|
|
2193
|
+
const filePathStr = String(candidatePath);
|
|
2194
|
+
if (!this.originalSnapshots.has(filePathStr)) {
|
|
2053
2195
|
try {
|
|
2054
|
-
const
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
? args.path
|
|
2058
|
-
: typeof args.file === "string"
|
|
2059
|
-
? args.file
|
|
2060
|
-
: undefined,
|
|
2061
|
-
});
|
|
2062
|
-
for (const skill of relevantSkills.slice(0, 1)) {
|
|
2063
|
-
this.skillLearner.updateConfidence(skill.id, result.success);
|
|
2064
|
-
}
|
|
2196
|
+
const { readFile } = await import("node:fs/promises");
|
|
2197
|
+
const original = await readFile(filePathStr, "utf-8");
|
|
2198
|
+
this.originalSnapshots.set(filePathStr, original);
|
|
2065
2199
|
}
|
|
2066
|
-
catch {
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
this.emitEvent({
|
|
2070
|
-
kind: "agent:tool_result",
|
|
2071
|
-
tool: toolCall.name,
|
|
2072
|
-
output: result.output.length > 200
|
|
2073
|
-
? result.output.slice(0, 200) + "..."
|
|
2074
|
-
: result.output,
|
|
2075
|
-
durationMs: result.durationMs,
|
|
2076
|
-
});
|
|
2077
|
-
this.emitReasoning(`success: ${toolCall.name}`);
|
|
2078
|
-
this.reasoningTree.add("tool", `success: ${toolCall.name}`);
|
|
2079
|
-
// 파일 변경 이벤트 + 추적
|
|
2080
|
-
if (["file_write", "file_edit"].includes(toolCall.name) &&
|
|
2081
|
-
result.success) {
|
|
2082
|
-
const filePath = args.path ??
|
|
2083
|
-
args.file ??
|
|
2084
|
-
"unknown";
|
|
2085
|
-
const filePathStr = String(filePath);
|
|
2086
|
-
// 변경 파일 추적 (메모리 업데이트용)
|
|
2087
|
-
if (!this.changedFiles.includes(filePathStr)) {
|
|
2088
|
-
this.changedFiles.push(filePathStr);
|
|
2200
|
+
catch (err) {
|
|
2201
|
+
if (err.code !== "ENOENT")
|
|
2202
|
+
throw err;
|
|
2089
2203
|
}
|
|
2090
|
-
this.emitEvent({
|
|
2091
|
-
kind: "agent:file_change",
|
|
2092
|
-
path: filePathStr,
|
|
2093
|
-
diff: result.output,
|
|
2094
|
-
});
|
|
2095
|
-
// ImpactAnalyzer: 변경 영향 분석 (비동기, 실패 무시)
|
|
2096
|
-
if (this.impactAnalyzer) {
|
|
2097
|
-
this.analyzeFileImpact(filePathStr).catch(() => { });
|
|
2098
|
-
}
|
|
2099
|
-
}
|
|
2100
|
-
// AutoFixLoop: 결과 검증 (fix prompt는 tool results 추가 후 context에 넣음)
|
|
2101
|
-
const fixPrompt = await this.validateAndFeedback(toolCall.name, result);
|
|
2102
|
-
if (fixPrompt) {
|
|
2103
|
-
deferredFixPrompts.push(fixPrompt);
|
|
2104
2204
|
}
|
|
2105
2205
|
}
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
kind: "agent:error",
|
|
2120
|
-
message: `Tool ${toolCall.name} cancelled by interrupt`,
|
|
2121
|
-
retryable: false,
|
|
2206
|
+
}
|
|
2207
|
+
try {
|
|
2208
|
+
const result = await this.toolExecutor.execute(toolCall, toolAbort?.signal);
|
|
2209
|
+
this.interruptManager.clearToolAbort();
|
|
2210
|
+
if (this.skillLearner) {
|
|
2211
|
+
try {
|
|
2212
|
+
const relevantSkills = this.skillLearner.getRelevantSkills({
|
|
2213
|
+
errorMessage: `${toolCall.name}\n${result.output}`,
|
|
2214
|
+
filePath: typeof args.path === "string"
|
|
2215
|
+
? args.path
|
|
2216
|
+
: typeof args.file === "string"
|
|
2217
|
+
? args.file
|
|
2218
|
+
: undefined,
|
|
2122
2219
|
});
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
for (let i = currentIdx + 1; i < toolCalls.length; i++) {
|
|
2126
|
-
results.push({
|
|
2127
|
-
tool_call_id: toolCalls[i].id,
|
|
2128
|
-
name: toolCalls[i].name,
|
|
2129
|
-
output: `[SKIPPED] Previous tool was interrupted.`,
|
|
2130
|
-
success: false,
|
|
2131
|
-
durationMs: 0,
|
|
2132
|
-
});
|
|
2220
|
+
for (const skill of relevantSkills.slice(0, 1)) {
|
|
2221
|
+
this.skillLearner.updateConfidence(skill.id, result.success);
|
|
2133
2222
|
}
|
|
2134
|
-
// soft interrupt: 루프 계속 / hard interrupt: aborted=true로 종료
|
|
2135
|
-
break;
|
|
2136
2223
|
}
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2224
|
+
catch { }
|
|
2225
|
+
}
|
|
2226
|
+
this.emitEvent({
|
|
2227
|
+
kind: "agent:tool_result",
|
|
2228
|
+
tool: toolCall.name,
|
|
2229
|
+
output: result.output.length > 200
|
|
2230
|
+
? result.output.slice(0, 200) + "..."
|
|
2231
|
+
: result.output,
|
|
2232
|
+
durationMs: result.durationMs,
|
|
2233
|
+
});
|
|
2234
|
+
this.emitReasoning(`success: ${toolCall.name}`);
|
|
2235
|
+
this.reasoningTree.add("tool", `success: ${toolCall.name}`);
|
|
2236
|
+
if (["file_write", "file_edit"].includes(toolCall.name) && result.success) {
|
|
2237
|
+
const filePath = args.path ??
|
|
2238
|
+
args.file ??
|
|
2239
|
+
"unknown";
|
|
2240
|
+
const filePathStr = String(filePath);
|
|
2241
|
+
if (!this.changedFiles.includes(filePathStr)) {
|
|
2242
|
+
this.changedFiles.push(filePathStr);
|
|
2154
2243
|
}
|
|
2155
|
-
|
|
2244
|
+
this.emitEvent({ kind: "agent:file_change", path: filePathStr, diff: result.output });
|
|
2245
|
+
if (this.impactAnalyzer) {
|
|
2246
|
+
this.analyzeFileImpact(filePathStr).catch(() => { });
|
|
2247
|
+
}
|
|
2248
|
+
}
|
|
2249
|
+
const fixPrompt = await this.validateAndFeedback(toolCall.name, result);
|
|
2250
|
+
return { result, deferredFixPrompt: fixPrompt ?? null };
|
|
2251
|
+
}
|
|
2252
|
+
catch (err) {
|
|
2253
|
+
this.interruptManager.clearToolAbort();
|
|
2254
|
+
const durationMs = Date.now() - startTime;
|
|
2255
|
+
if (toolAbort.signal.aborted) {
|
|
2256
|
+
const abortResult = {
|
|
2156
2257
|
tool_call_id: toolCall.id,
|
|
2157
2258
|
name: toolCall.name,
|
|
2158
|
-
output: `
|
|
2259
|
+
output: `[INTERRUPTED] Tool execution was cancelled by user interrupt.`,
|
|
2159
2260
|
success: false,
|
|
2160
2261
|
durationMs,
|
|
2161
|
-
}
|
|
2262
|
+
};
|
|
2162
2263
|
this.emitEvent({
|
|
2163
2264
|
kind: "agent:error",
|
|
2164
|
-
message: `Tool ${toolCall.name}
|
|
2165
|
-
retryable:
|
|
2166
|
-
});
|
|
2167
|
-
this.emitEvent({
|
|
2168
|
-
kind: "agent:reasoning_delta",
|
|
2169
|
-
text: `failed: ${toolCall.name}`,
|
|
2265
|
+
message: `Tool ${toolCall.name} cancelled by interrupt`,
|
|
2266
|
+
retryable: false,
|
|
2170
2267
|
});
|
|
2171
|
-
|
|
2268
|
+
return { result: abortResult, deferredFixPrompt: null };
|
|
2269
|
+
}
|
|
2270
|
+
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
2271
|
+
if (this.skillLearner) {
|
|
2272
|
+
try {
|
|
2273
|
+
const relevantSkills = this.skillLearner.getRelevantSkills({
|
|
2274
|
+
errorMessage: `${toolCall.name}\n${errorMessage}`,
|
|
2275
|
+
filePath: typeof args.path === "string"
|
|
2276
|
+
? args.path
|
|
2277
|
+
: typeof args.file === "string"
|
|
2278
|
+
? args.file
|
|
2279
|
+
: undefined,
|
|
2280
|
+
});
|
|
2281
|
+
for (const skill of relevantSkills.slice(0, 1)) {
|
|
2282
|
+
this.skillLearner.updateConfidence(skill.id, false);
|
|
2283
|
+
}
|
|
2284
|
+
}
|
|
2285
|
+
catch { }
|
|
2286
|
+
}
|
|
2287
|
+
const errorResult = {
|
|
2288
|
+
tool_call_id: toolCall.id,
|
|
2289
|
+
name: toolCall.name,
|
|
2290
|
+
output: `Error: ${errorMessage}`,
|
|
2291
|
+
success: false,
|
|
2292
|
+
durationMs,
|
|
2293
|
+
};
|
|
2294
|
+
this.emitEvent({
|
|
2295
|
+
kind: "agent:error",
|
|
2296
|
+
message: `Tool ${toolCall.name} failed: ${errorMessage}`,
|
|
2297
|
+
retryable: true,
|
|
2298
|
+
});
|
|
2299
|
+
this.emitEvent({ kind: "agent:reasoning_delta", text: `failed: ${toolCall.name}` });
|
|
2300
|
+
this.reasoningTree.add("tool", `failed: ${toolCall.name}`);
|
|
2301
|
+
return { result: errorResult, deferredFixPrompt: null };
|
|
2302
|
+
}
|
|
2303
|
+
}
|
|
2304
|
+
async executeTools(toolCalls) {
|
|
2305
|
+
// Reorder write tool calls using DependencyAnalyzer so files with no deps run first
|
|
2306
|
+
const writeToolNames = new Set(['file_write', 'file_edit']);
|
|
2307
|
+
const writeToolCalls = toolCalls.filter((tc) => writeToolNames.has(tc.name));
|
|
2308
|
+
if (writeToolCalls.length > 1 && this.config.loop.projectPath) {
|
|
2309
|
+
try {
|
|
2310
|
+
const depAnalyzer = new DependencyAnalyzer();
|
|
2311
|
+
const depGraph = await depAnalyzer.analyze(this.config.loop.projectPath);
|
|
2312
|
+
const writeFilePaths = writeToolCalls
|
|
2313
|
+
.map((tc) => {
|
|
2314
|
+
const args = this.parseToolArgs(tc.arguments);
|
|
2315
|
+
return typeof args.path === "string" ? args.path : null;
|
|
2316
|
+
})
|
|
2317
|
+
.filter((p) => p !== null);
|
|
2318
|
+
if (writeFilePaths.length > 1) {
|
|
2319
|
+
const groups = depAnalyzer.groupIndependentFiles(depGraph, writeFilePaths);
|
|
2320
|
+
// Build ordered list of file paths: independent groups first, dependent files after
|
|
2321
|
+
const orderedPaths = [];
|
|
2322
|
+
for (const group of groups) {
|
|
2323
|
+
for (const f of group.files) {
|
|
2324
|
+
if (!orderedPaths.includes(f))
|
|
2325
|
+
orderedPaths.push(f);
|
|
2326
|
+
}
|
|
2327
|
+
}
|
|
2328
|
+
// Reorder writeToolCalls according to orderedPaths
|
|
2329
|
+
const reordered = [];
|
|
2330
|
+
for (const filePath of orderedPaths) {
|
|
2331
|
+
const tc = writeToolCalls.find((c) => {
|
|
2332
|
+
const args = this.parseToolArgs(c.arguments);
|
|
2333
|
+
return args.path === filePath;
|
|
2334
|
+
});
|
|
2335
|
+
if (tc)
|
|
2336
|
+
reordered.push(tc);
|
|
2337
|
+
}
|
|
2338
|
+
// Add any write calls that didn't match a path (shouldn't happen, but be safe)
|
|
2339
|
+
for (const tc of writeToolCalls) {
|
|
2340
|
+
if (!reordered.includes(tc))
|
|
2341
|
+
reordered.push(tc);
|
|
2342
|
+
}
|
|
2343
|
+
// Rebuild toolCalls preserving non-write tools in their original positions,
|
|
2344
|
+
// replacing write tools with dependency-ordered sequence
|
|
2345
|
+
const reorderedAll = [];
|
|
2346
|
+
let writeIdx = 0;
|
|
2347
|
+
for (const tc of toolCalls) {
|
|
2348
|
+
if (writeToolNames.has(tc.name)) {
|
|
2349
|
+
reorderedAll.push(reordered[writeIdx++] ?? tc);
|
|
2350
|
+
}
|
|
2351
|
+
else {
|
|
2352
|
+
reorderedAll.push(tc);
|
|
2353
|
+
}
|
|
2354
|
+
}
|
|
2355
|
+
toolCalls = reorderedAll;
|
|
2356
|
+
}
|
|
2357
|
+
}
|
|
2358
|
+
catch {
|
|
2359
|
+
// DependencyAnalyzer failure is non-fatal — fall through to original ordering
|
|
2172
2360
|
}
|
|
2173
2361
|
}
|
|
2362
|
+
// Group tool calls: read-only can run in parallel, write tools run sequentially
|
|
2363
|
+
const readOnlyTools = new Set(['file_read', 'grep', 'glob', 'code_search']);
|
|
2364
|
+
const batches = [];
|
|
2365
|
+
let currentBatch = [];
|
|
2366
|
+
for (const toolCall of toolCalls) {
|
|
2367
|
+
if (readOnlyTools.has(toolCall.name)) {
|
|
2368
|
+
currentBatch.push(toolCall);
|
|
2369
|
+
}
|
|
2370
|
+
else {
|
|
2371
|
+
if (currentBatch.length > 0) {
|
|
2372
|
+
batches.push(currentBatch);
|
|
2373
|
+
currentBatch = [];
|
|
2374
|
+
}
|
|
2375
|
+
batches.push([toolCall]); // write tools run solo
|
|
2376
|
+
}
|
|
2377
|
+
}
|
|
2378
|
+
if (currentBatch.length > 0)
|
|
2379
|
+
batches.push(currentBatch);
|
|
2380
|
+
if (toolCalls.length > 1)
|
|
2381
|
+
this.emitReasoning(`parallel tool batch started (${toolCalls.length} tools)`);
|
|
2382
|
+
const results = [];
|
|
2383
|
+
const deferredFixPrompts = [];
|
|
2384
|
+
for (const batch of batches) {
|
|
2385
|
+
if (batch.length === 1) {
|
|
2386
|
+
// Sequential single tool execution
|
|
2387
|
+
const { result, deferredFixPrompt } = await this.executeSingleTool(batch[0], toolCalls);
|
|
2388
|
+
if (result)
|
|
2389
|
+
results.push(result);
|
|
2390
|
+
if (deferredFixPrompt)
|
|
2391
|
+
deferredFixPrompts.push(deferredFixPrompt);
|
|
2392
|
+
// Check for interrupt result — stop processing remaining batches
|
|
2393
|
+
if (result && result.output.startsWith('[INTERRUPTED]')) {
|
|
2394
|
+
// Add placeholder results for remaining unexecuted tool calls
|
|
2395
|
+
const executedIds = new Set(results.map(r => r.tool_call_id));
|
|
2396
|
+
for (const tc of toolCalls) {
|
|
2397
|
+
if (!executedIds.has(tc.id)) {
|
|
2398
|
+
results.push({
|
|
2399
|
+
tool_call_id: tc.id,
|
|
2400
|
+
name: tc.name,
|
|
2401
|
+
output: `[SKIPPED] Previous tool was interrupted.`,
|
|
2402
|
+
success: false,
|
|
2403
|
+
durationMs: 0,
|
|
2404
|
+
});
|
|
2405
|
+
}
|
|
2406
|
+
}
|
|
2407
|
+
break;
|
|
2408
|
+
}
|
|
2409
|
+
}
|
|
2410
|
+
else {
|
|
2411
|
+
// Parallel execution for read-only tools in this batch
|
|
2412
|
+
this.emitReasoning(`running ${batch.length} read-only tools in parallel`);
|
|
2413
|
+
const batchResults = await Promise.allSettled(batch.map(tc => this.executeSingleTool(tc, toolCalls)));
|
|
2414
|
+
for (const settled of batchResults) {
|
|
2415
|
+
if (settled.status === 'fulfilled') {
|
|
2416
|
+
if (settled.value.result)
|
|
2417
|
+
results.push(settled.value.result);
|
|
2418
|
+
if (settled.value.deferredFixPrompt)
|
|
2419
|
+
deferredFixPrompts.push(settled.value.deferredFixPrompt);
|
|
2420
|
+
}
|
|
2421
|
+
else {
|
|
2422
|
+
// Parallel tool failure — record as error result
|
|
2423
|
+
const tc = batch[batchResults.indexOf(settled)];
|
|
2424
|
+
results.push({
|
|
2425
|
+
tool_call_id: tc?.id ?? 'unknown',
|
|
2426
|
+
name: tc?.name ?? 'unknown',
|
|
2427
|
+
output: `Error: ${settled.reason instanceof Error ? settled.reason.message : String(settled.reason)}`,
|
|
2428
|
+
success: false,
|
|
2429
|
+
durationMs: 0,
|
|
2430
|
+
});
|
|
2431
|
+
}
|
|
2432
|
+
} // end for (const settled of batchResults)
|
|
2433
|
+
} // end else (parallel batch)
|
|
2434
|
+
} // end for (const batch of batches)
|
|
2174
2435
|
return { results, deferredFixPrompts };
|
|
2175
2436
|
}
|
|
2176
2437
|
/**
|