@yuaone/core 0.4.5 → 0.4.7

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.
@@ -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
- const thresholdOrder = { simple: 1, moderate: 2, complex: 3 };
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
- if ((complexityOrder[complexity] ?? 0) < thresholdOrder[this.planningThreshold]) {
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: "pnpm build",
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
- async executeTools(toolCalls) {
1925
- if (toolCalls.length > 1)
1926
- this.emitReasoning(`parallel tool batch started (${toolCalls.length} tools)`);
1927
- const results = [];
1928
- const deferredFixPrompts = [];
1929
- for (const toolCall of toolCalls) {
1930
- const args = this.parseToolArgs(toolCall.arguments);
1931
- const allDefinitions = [...this.config.loop.tools, ...this.mcpToolDefinitions];
1932
- const matchedDefinition = allDefinitions.find((t) => t.name === toolCall.name);
1933
- // Governor: 안전성 검증
1934
- try {
1935
- this.governor.validateToolCall(toolCall);
1936
- }
1937
- catch (err) {
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
- results.push(approvalResult);
1978
- continue;
2111
+ return { result: approvalResult, deferredFixPrompt: null };
1979
2112
  }
1980
2113
  // 승인됨 → 계속 실행
1981
2114
  }
1982
- // Plugin Tool Approval Gate: check plugin tools requiring approval
1983
- const pluginTools = this.pluginRegistry.getAllTools();
1984
- const matchedPluginTool = pluginTools.find((pt) => pt.tool.name === toolCall.name);
1985
- if (matchedPluginTool &&
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: `Plugin tool "${toolCall.name}" (from ${matchedPluginTool.pluginId}) requires approval (${matchedPluginTool.tool.sideEffectLevel === "destructive"
1993
- ? "destructive side effect"
1994
- : "requiresApproval=true"})`,
1995
- riskLevel: matchedPluginTool.tool.riskLevel === "high" ? "high" : "medium",
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 pluginApprovalResult = await this.handleApprovalRequest(toolCall, pluginApprovalReq);
1999
- if (pluginApprovalResult) {
2000
- results.push(pluginApprovalResult);
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
- // MCP 도구 호출 확인
2006
- if (this.mcpClient && this.isMCPTool(toolCall.name)) {
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
- // 도구 실행 — AbortController를 InterruptManager에 등록
2024
- const startTime = Date.now();
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
- try {
2048
- const result = await this.toolExecutor.execute(toolCall, toolAbort?.signal);
2049
- this.interruptManager.clearToolAbort();
2050
- // Learned skill confidence feedback
2051
- // 성공한 실행 결과/도구명/출력에 매칭되는 learned skill이 있으면 confidence 갱신
2052
- if (this.skillLearner) {
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 relevantSkills = this.skillLearner.getRelevantSkills({
2055
- errorMessage: `${toolCall.name}\n${result.output}`,
2056
- filePath: typeof args.path === "string"
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
- results.push(result);
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
- catch (err) {
2107
- this.interruptManager.clearToolAbort();
2108
- const durationMs = Date.now() - startTime;
2109
- // AbortError인 경우 (인터럽트로 취소됨)
2110
- if (toolAbort.signal.aborted) {
2111
- results.push({
2112
- tool_call_id: toolCall.id,
2113
- name: toolCall.name,
2114
- output: `[INTERRUPTED] Tool execution was cancelled by user interrupt.`,
2115
- success: false,
2116
- durationMs,
2117
- });
2118
- this.emitEvent({
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
- // 남은 tool calls에 대해 placeholder result 추가 (OpenAI 400 방지)
2124
- const currentIdx = toolCalls.indexOf(toolCall);
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
- const errorMessage = err instanceof Error ? err.message : String(err);
2138
- // Learned skill confidence feedback (failure path)
2139
- if (this.skillLearner) {
2140
- try {
2141
- const relevantSkills = this.skillLearner.getRelevantSkills({
2142
- errorMessage: `${toolCall.name}\n${errorMessage}`,
2143
- filePath: typeof args.path === "string"
2144
- ? args.path
2145
- : typeof args.file === "string"
2146
- ? args.file
2147
- : undefined,
2148
- });
2149
- for (const skill of relevantSkills.slice(0, 1)) {
2150
- this.skillLearner.updateConfidence(skill.id, false);
2151
- }
2152
- }
2153
- catch { }
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
- results.push({
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: `Error: ${errorMessage}`,
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} failed: ${errorMessage}`,
2165
- retryable: true,
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
- this.reasoningTree.add("tool", `failed: ${toolCall.name}`);
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
  /**