@yuaone/core 0.6.1 → 0.7.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 (61) hide show
  1. package/dist/agent-loop.d.ts +36 -0
  2. package/dist/agent-loop.d.ts.map +1 -1
  3. package/dist/agent-loop.js +504 -97
  4. package/dist/agent-loop.js.map +1 -1
  5. package/dist/code-indexer.d.ts +50 -0
  6. package/dist/code-indexer.d.ts.map +1 -0
  7. package/dist/code-indexer.js +199 -0
  8. package/dist/code-indexer.js.map +1 -0
  9. package/dist/failure-recovery.d.ts +15 -2
  10. package/dist/failure-recovery.d.ts.map +1 -1
  11. package/dist/failure-recovery.js +53 -2
  12. package/dist/failure-recovery.js.map +1 -1
  13. package/dist/index.d.ts +6 -0
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +6 -0
  16. package/dist/index.js.map +1 -1
  17. package/dist/llm-client.d.ts +11 -2
  18. package/dist/llm-client.d.ts.map +1 -1
  19. package/dist/llm-client.js +22 -8
  20. package/dist/llm-client.js.map +1 -1
  21. package/dist/planner/index.d.ts +9 -0
  22. package/dist/planner/index.d.ts.map +1 -0
  23. package/dist/planner/index.js +5 -0
  24. package/dist/planner/index.js.map +1 -0
  25. package/dist/planner/milestone-checker.d.ts +48 -0
  26. package/dist/planner/milestone-checker.d.ts.map +1 -0
  27. package/dist/planner/milestone-checker.js +113 -0
  28. package/dist/planner/milestone-checker.js.map +1 -0
  29. package/dist/planner/plan-evaluator.d.ts +35 -0
  30. package/dist/planner/plan-evaluator.d.ts.map +1 -0
  31. package/dist/planner/plan-evaluator.js +92 -0
  32. package/dist/planner/plan-evaluator.js.map +1 -0
  33. package/dist/planner/replanning-engine.d.ts +37 -0
  34. package/dist/planner/replanning-engine.d.ts.map +1 -0
  35. package/dist/planner/replanning-engine.js +130 -0
  36. package/dist/planner/replanning-engine.js.map +1 -0
  37. package/dist/planner/risk-estimator.d.ts +44 -0
  38. package/dist/planner/risk-estimator.d.ts.map +1 -0
  39. package/dist/planner/risk-estimator.js +108 -0
  40. package/dist/planner/risk-estimator.js.map +1 -0
  41. package/dist/world-model/index.d.ts +8 -0
  42. package/dist/world-model/index.d.ts.map +1 -0
  43. package/dist/world-model/index.js +5 -0
  44. package/dist/world-model/index.js.map +1 -0
  45. package/dist/world-model/simulation-engine.d.ts +58 -0
  46. package/dist/world-model/simulation-engine.d.ts.map +1 -0
  47. package/dist/world-model/simulation-engine.js +191 -0
  48. package/dist/world-model/simulation-engine.js.map +1 -0
  49. package/dist/world-model/state-store.d.ts +149 -0
  50. package/dist/world-model/state-store.d.ts.map +1 -0
  51. package/dist/world-model/state-store.js +379 -0
  52. package/dist/world-model/state-store.js.map +1 -0
  53. package/dist/world-model/state-updater.d.ts +35 -0
  54. package/dist/world-model/state-updater.d.ts.map +1 -0
  55. package/dist/world-model/state-updater.js +131 -0
  56. package/dist/world-model/state-updater.js.map +1 -0
  57. package/dist/world-model/transition-model.d.ts +54 -0
  58. package/dist/world-model/transition-model.d.ts.map +1 -0
  59. package/dist/world-model/transition-model.js +240 -0
  60. package/dist/world-model/transition-model.js.map +1 -0
  61. package/package.json +1 -1
@@ -8,6 +8,8 @@
8
8
  */
9
9
  import { EventEmitter } from "node:events";
10
10
  import { randomUUID } from "node:crypto";
11
+ import { execSync } from "node:child_process";
12
+ import { basename, join as pathJoin } from "node:path";
11
13
  import { BYOKClient } from "./llm-client.js";
12
14
  import { Governor } from "./governor.js";
13
15
  import { ContextManager } from "./context-manager.js";
@@ -50,6 +52,10 @@ import { DependencyAnalyzer } from "./dependency-analyzer.js";
50
52
  import { CrossFileRefactor } from "./cross-file-refactor.js";
51
53
  import { ContextBudgetManager } from "./context-budget.js";
52
54
  import { QAPipeline } from "./qa-pipeline.js";
55
+ import { PersonaManager } from "./persona.js";
56
+ import { InMemoryVectorStore, OllamaEmbeddingProvider } from "./vector-store.js";
57
+ import { StateStore, TransitionModel, SimulationEngine, StateUpdater, } from "./world-model/index.js";
58
+ import { MilestoneChecker, RiskEstimator, PlanEvaluator, ReplanningEngine, } from "./planner/index.js";
53
59
  /**
54
60
  * AgentLoop — YUAN 에이전트의 핵심 실행 루프.
55
61
  *
@@ -103,6 +109,7 @@ export class AgentLoop extends EventEmitter {
103
109
  tokenBudgetManager;
104
110
  allToolResults = [];
105
111
  currentTaskIndex = 0;
112
+ _lastInjectedTaskIndex = -1; // track when plan progress was last injected
106
113
  changedFiles = [];
107
114
  aborted = false;
108
115
  initialized = false;
@@ -158,6 +165,25 @@ export class AgentLoop extends EventEmitter {
158
165
  iterationTsFilesModified = [];
159
166
  /** Task 3: Whether tsc was run in the previous iteration (skip cooldown) */
160
167
  tscRanLastIteration = false;
168
+ /** PersonaManager — learns user communication style, injects persona into system prompt */
169
+ personaManager = null;
170
+ /** InMemoryVectorStore — RAG: semantic code context retrieval for relevant snippets */
171
+ vectorStore = null;
172
+ /** Last user message — used for task-specific memory retrieval */
173
+ lastUserMessage = "";
174
+ // World Model
175
+ worldModel = null;
176
+ transitionModel = null;
177
+ simulationEngine = null;
178
+ stateUpdater = null;
179
+ // Proactive Replanning
180
+ planEvaluator = null;
181
+ riskEstimator = null;
182
+ replanningEngine = null;
183
+ milestoneChecker = null;
184
+ activeMilestones = [];
185
+ completedPlanTaskIds = new Set();
186
+ allToolResultsSinceLastReplan = [];
161
187
  tokenUsage = {
162
188
  input: 0,
163
189
  output: 0,
@@ -322,6 +348,27 @@ export class AgentLoop extends EventEmitter {
322
348
  // MemoryManager (structured learnings)
323
349
  this.memoryManager = new MemoryManager(projectPath);
324
350
  await this.memoryManager.load();
351
+ // PersonaManager — user communication style learning + persona injection
352
+ const personaUserId = basename(projectPath) || "default";
353
+ this.personaManager = new PersonaManager({
354
+ userId: personaUserId,
355
+ profilePath: pathJoin(projectPath, ".yuan", `persona-${personaUserId}.json`),
356
+ enableLearning: true,
357
+ });
358
+ await this.personaManager.loadProfile().catch(() => { });
359
+ // InMemoryVectorStore — RAG semantic code context (TF-IDF fallback if Ollama unavailable)
360
+ this.vectorStore = new InMemoryVectorStore({
361
+ projectId: personaUserId,
362
+ projectPath,
363
+ embeddingProvider: new OllamaEmbeddingProvider(),
364
+ });
365
+ await this.vectorStore.load().catch(() => { });
366
+ // Background indexing — non-blocking, fires and forgets
367
+ const vectorStoreRef = this.vectorStore;
368
+ import("./code-indexer.js").then(({ CodeIndexer }) => {
369
+ const indexer = new CodeIndexer({});
370
+ indexer.indexProject(projectPath, vectorStoreRef).catch(() => { });
371
+ }).catch(() => { });
325
372
  // 프로젝트 구조 분석
326
373
  projectStructure = await this.yuanMemory.analyzeProject();
327
374
  }
@@ -362,6 +409,32 @@ export class AgentLoop extends EventEmitter {
362
409
  catch {
363
410
  // WorldState 수집 실패는 치명적이지 않음
364
411
  }
412
+ // Initialize World Model
413
+ if (this.worldState && projectPath) {
414
+ try {
415
+ this.transitionModel = new TransitionModel();
416
+ this.worldModel = StateStore.fromSnapshot(this.worldState, projectPath);
417
+ this.simulationEngine = new SimulationEngine(this.transitionModel, this.worldModel);
418
+ this.stateUpdater = new StateUpdater(this.worldModel, projectPath);
419
+ }
420
+ catch {
421
+ // World Model initialization failure is non-fatal
422
+ }
423
+ }
424
+ // Capture last known good git commit for FailureRecovery rollback
425
+ try {
426
+ const headHash = execSync("git rev-parse HEAD", {
427
+ cwd: projectPath,
428
+ stdio: "pipe",
429
+ timeout: 5_000,
430
+ }).toString().trim();
431
+ if (headHash) {
432
+ this.failureRecovery.setLastGoodCommit(headHash, projectPath);
433
+ }
434
+ }
435
+ catch {
436
+ // Not a git repo or git unavailable — FailureRecovery will use file-level rollback only
437
+ }
365
438
  // ImpactAnalyzer 생성
366
439
  this.impactAnalyzer = new ImpactAnalyzer({ projectPath });
367
440
  // ContinuationEngine 생성
@@ -479,6 +552,18 @@ export class AgentLoop extends EventEmitter {
479
552
  if (this.skillLearner) {
480
553
  this.planner.setSkillLearner(this.skillLearner);
481
554
  }
555
+ // Initialize Proactive Replanning (requires planner + impactAnalyzer + worldModel)
556
+ try {
557
+ if (this.impactAnalyzer && this.worldModel && this.simulationEngine && this.transitionModel) {
558
+ this.milestoneChecker = new MilestoneChecker();
559
+ this.riskEstimator = new RiskEstimator(this.transitionModel, this.impactAnalyzer);
560
+ this.planEvaluator = new PlanEvaluator(this.worldModel, this.simulationEngine);
561
+ this.replanningEngine = new ReplanningEngine(this.planner, this.planEvaluator, this.riskEstimator, this.milestoneChecker);
562
+ }
563
+ }
564
+ catch {
565
+ // Proactive replanning initialization failure is non-fatal
566
+ }
482
567
  if (this.skillLearner) {
483
568
  const learnedSkills = this.skillLearner.getAllSkills();
484
569
  if (learnedSkills.length > 0) {
@@ -719,8 +804,58 @@ export class AgentLoop extends EventEmitter {
719
804
  role: "user",
720
805
  content: userMessage,
721
806
  });
807
+ // PersonaManager — 유저 메시지로 커뮤니케이션 스타일 학습
808
+ this.lastUserMessage = userMessage;
809
+ if (this.personaManager) {
810
+ this.personaManager.analyzeUserMessage(userMessage);
811
+ }
722
812
  this.emitEvent({ kind: "agent:start", goal: userMessage });
723
813
  try {
814
+ // Persona injection — 유저 선호도/언어/스타일 어댑테이션을 시스템 메시지로 주입
815
+ if (this.personaManager) {
816
+ const personaSection = this.personaManager.buildPersonaPrompt();
817
+ if (personaSection) {
818
+ this.contextManager.addMessage({ role: "system", content: personaSection });
819
+ }
820
+ }
821
+ // MemoryManager.getRelevant() — 현재 태스크와 관련된 conventions/patterns/warnings 주입
822
+ if (this.memoryManager) {
823
+ const relevant = this.memoryManager.getRelevant(userMessage);
824
+ const parts = [];
825
+ if (relevant.conventions.length > 0) {
826
+ parts.push(`## Project Conventions\n${relevant.conventions.slice(0, 5).map((c) => `- ${c}`).join("\n")}`);
827
+ }
828
+ if (relevant.warnings.length > 0) {
829
+ parts.push(`## Relevant Warnings\n${relevant.warnings.slice(0, 3).map((w) => `⚠ ${w}`).join("\n")}`);
830
+ }
831
+ if (relevant.patterns.length > 0) {
832
+ parts.push(`## Relevant Code Patterns\n${relevant.patterns.slice(0, 3).map((p) => `- **${p.name}**: ${p.description}`).join("\n")}`);
833
+ }
834
+ if (parts.length > 0) {
835
+ this.contextManager.addMessage({
836
+ role: "system",
837
+ content: `[Task Memory]\n${parts.join("\n\n")}`,
838
+ });
839
+ }
840
+ }
841
+ // VectorStore RAG — 태스크와 의미적으로 유사한 코드 컨텍스트 검색·주입
842
+ if (this.vectorStore) {
843
+ try {
844
+ const hits = await this.vectorStore.search(userMessage, 3, 0.2);
845
+ if (hits.length > 0) {
846
+ const ragCtx = hits
847
+ .map((h) => `**${h.id}** (relevance: ${(h.similarity * 100).toFixed(0)}%)\n${h.text.slice(0, 400)}`)
848
+ .join("\n\n---\n\n");
849
+ this.contextManager.addMessage({
850
+ role: "system",
851
+ content: `[RAG Context — semantically relevant code snippets]\n${ragCtx}`,
852
+ });
853
+ }
854
+ }
855
+ catch {
856
+ // VectorStore search failure is non-fatal
857
+ }
858
+ }
724
859
  // Reflexion: 과거 실행에서 배운 가이던스 주입
725
860
  if (this.reflexionEngine) {
726
861
  try {
@@ -984,6 +1119,10 @@ export class AgentLoop extends EventEmitter {
984
1119
  }
985
1120
  // 메모리 저장
986
1121
  await this.memoryManager.save();
1122
+ // PersonaManager — 유저 프로필 저장 (학습된 커뮤니케이션 스타일 유지)
1123
+ if (this.personaManager) {
1124
+ await this.personaManager.saveProfile().catch(() => { });
1125
+ }
987
1126
  }
988
1127
  catch {
989
1128
  // 메모리 저장 실패는 치명적이지 않음
@@ -1152,7 +1291,7 @@ export class AgentLoop extends EventEmitter {
1152
1291
  async maybeCreatePlan(userMessage) {
1153
1292
  if (!this.planner || !this.enablePlanning)
1154
1293
  return;
1155
- const complexity = this.detectComplexity(userMessage);
1294
+ const complexity = await this.detectComplexity(userMessage);
1156
1295
  this.currentComplexity = complexity;
1157
1296
  // 임계값 미만이면 플래닝 스킵
1158
1297
  // Bug 4 fix: extend thresholdOrder to include "massive" (4), so that when planningThreshold
@@ -1172,6 +1311,20 @@ export class AgentLoop extends EventEmitter {
1172
1311
  const plan = await this.planner.createHierarchicalPlan(userMessage, this.llmClient);
1173
1312
  this.activePlan = plan;
1174
1313
  this.currentTaskIndex = 0;
1314
+ // Run plan simulation + extract milestones
1315
+ if (this.simulationEngine && this.milestoneChecker && this.activePlan) {
1316
+ const capturedPlan = this.activePlan;
1317
+ this.simulationEngine.simulate(capturedPlan).then((simResult) => {
1318
+ if (simResult.criticalSteps.length > 0 || simResult.overallSuccessProbability < 0.6) {
1319
+ this.contextManager.addMessage({
1320
+ role: "system",
1321
+ content: this.simulationEngine.formatForPrompt(simResult),
1322
+ });
1323
+ }
1324
+ }).catch(() => { });
1325
+ this.activeMilestones = this.milestoneChecker.extractMilestones(this.activePlan);
1326
+ this.completedPlanTaskIds.clear();
1327
+ }
1175
1328
  const planTokenEstimate = plan.tactical.length * 500;
1176
1329
  this.tokenBudgetManager.recordUsage("planner", planTokenEstimate, planTokenEstimate);
1177
1330
  const planContext = this.formatPlanForContext(plan);
@@ -1227,7 +1380,7 @@ export class AgentLoop extends EventEmitter {
1227
1380
  }
1228
1381
  return "pnpm build";
1229
1382
  }
1230
- detectComplexity(message) {
1383
+ _detectComplexityHeuristic(message) {
1231
1384
  const lower = message.toLowerCase();
1232
1385
  const len = message.length;
1233
1386
  // 복잡도 점수 계산
@@ -1264,6 +1417,48 @@ export class AgentLoop extends EventEmitter {
1264
1417
  score += 2;
1265
1418
  else if (filePaths && filePaths.length >= 2)
1266
1419
  score += 1;
1420
+ // 야망형 태스크 키워드 — 짧아도 대형 작업 (score +4 → 즉시 "complex" 이상)
1421
+ // "OS 만들어", "설계해줘", "컴파일러 구현" 같은 짧지만 massive한 요청을 잡기 위함
1422
+ const ambitiousSystemKeywords = [
1423
+ "운영체제", "os ", " os", "kernel", "커널",
1424
+ "compiler", "컴파일러", "인터프리터", "interpreter",
1425
+ "database", "데이터베이스", "dbms",
1426
+ "framework", "프레임워크",
1427
+ "vm ", "virtual machine", "가상머신", "hypervisor",
1428
+ "distributed", "분산 시스템", "분산시스템",
1429
+ "blockchain", "블록체인",
1430
+ "게임 엔진", "game engine",
1431
+ ];
1432
+ for (const kw of ambitiousSystemKeywords) {
1433
+ if (lower.includes(kw)) {
1434
+ score += 4;
1435
+ break;
1436
+ }
1437
+ }
1438
+ // 설계/아키텍처 요청 키워드 (score +3)
1439
+ const designKeywords = [
1440
+ "설계", "디자인해", "아키텍처 만들", "구조 설계",
1441
+ "design the", "architect the", "design a ", "design an ",
1442
+ "from scratch", "처음부터", "새로 만들", "전부 만들",
1443
+ ];
1444
+ for (const kw of designKeywords) {
1445
+ if (lower.includes(kw)) {
1446
+ score += 3;
1447
+ break;
1448
+ }
1449
+ }
1450
+ // 전체 구현 요청 키워드 (score +2)
1451
+ const buildKeywords = [
1452
+ "만들어줘", "만들어 줘", "만들어봐", "구현해줘", "개발해줘",
1453
+ "build me", "create a full", "implement a full", "make me a",
1454
+ "전체 구현", "full implementation", "complete implementation",
1455
+ ];
1456
+ for (const kw of buildKeywords) {
1457
+ if (lower.includes(kw)) {
1458
+ score += 2;
1459
+ break;
1460
+ }
1461
+ }
1267
1462
  // 간단한 작업 키워드 (감점)
1268
1463
  const simpleKeywords = [
1269
1464
  "fix", "고쳐", "수정해",
@@ -1288,6 +1483,37 @@ export class AgentLoop extends EventEmitter {
1288
1483
  return "complex";
1289
1484
  return "massive";
1290
1485
  }
1486
+ /**
1487
+ * Hybrid complexity detection: keyword heuristic for clear cases,
1488
+ * LLM single-word classification for ambiguous borderline cases (score 1-3).
1489
+ * This prevents both over-planning (trivial tasks) and under-planning
1490
+ * (ambitious short requests that keywords miss across any language).
1491
+ */
1492
+ async detectComplexity(message) {
1493
+ const heuristic = this._detectComplexityHeuristic(message);
1494
+ // Clear extremes — trust heuristic, skip LLM call cost
1495
+ if (heuristic === "trivial" || heuristic === "massive")
1496
+ return heuristic;
1497
+ // Borderline ambiguous range: ask LLM for one-word verdict
1498
+ // Short cheap call: no tools, 1-word response, ~50 tokens total
1499
+ try {
1500
+ const resp = await this.llmClient.chat([
1501
+ {
1502
+ role: "user",
1503
+ content: `Rate this software task complexity in ONE word only (trivial/simple/moderate/complex/massive). Task: "${message.slice(0, 300)}"`,
1504
+ },
1505
+ ], []);
1506
+ const word = (resp.content ?? "").trim().toLowerCase().replace(/[^a-z]/g, "");
1507
+ const valid = ["trivial", "simple", "moderate", "complex", "massive"];
1508
+ if (valid.includes(word)) {
1509
+ return word;
1510
+ }
1511
+ }
1512
+ catch {
1513
+ // LLM classification failure — fall back to heuristic
1514
+ }
1515
+ return heuristic;
1516
+ }
1291
1517
  /**
1292
1518
  * HierarchicalPlan을 LLM이 따라갈 수 있는 컨텍스트 메시지로 포맷.
1293
1519
  */
@@ -1426,6 +1652,47 @@ export class AgentLoop extends EventEmitter {
1426
1652
  const iterationStart = Date.now();
1427
1653
  this.emitReasoning(`iteration ${iteration}: preparing context`);
1428
1654
  this.iterationSystemMsgCount = 0; // Reset per-iteration (prevents accumulation across iterations)
1655
+ // Proactive replanning check (every 5 iterations when plan is active)
1656
+ if (this.replanningEngine &&
1657
+ this.activePlan &&
1658
+ this.activeMilestones.length > 0 &&
1659
+ iteration > 0 &&
1660
+ iteration % 5 === 0) {
1661
+ try {
1662
+ const tokenBudget = this.config?.loop?.totalTokenBudget ?? 200_000;
1663
+ const tokensUsed = this.tokenUsage.total;
1664
+ const replanResult = await this.replanningEngine.evaluate(this.activePlan, this.worldModel?.getState() ?? {}, [...this.completedPlanTaskIds], this.allToolResultsSinceLastReplan, tokensUsed, tokenBudget, [...this.changedFiles], iteration, this.activeMilestones, this.llmClient);
1665
+ if (replanResult.triggered) {
1666
+ this.emitEvent({ kind: "agent:thinking", content: `[Proactive Replan] ${replanResult.message}` });
1667
+ if (replanResult.newPlan) {
1668
+ this.activePlan = replanResult.newPlan;
1669
+ this.activeMilestones = this.milestoneChecker.extractMilestones(this.activePlan);
1670
+ this.completedPlanTaskIds.clear();
1671
+ }
1672
+ else if (replanResult.modifiedTasks && replanResult.modifiedTasks.length > 0) {
1673
+ for (const modified of replanResult.modifiedTasks) {
1674
+ const idx = this.activePlan.tactical.findIndex(t => t.id === modified.id);
1675
+ if (idx >= 0)
1676
+ this.activePlan.tactical[idx] = modified;
1677
+ }
1678
+ }
1679
+ // Reset tool results accumulator after replan
1680
+ this.allToolResultsSinceLastReplan = [];
1681
+ // Inject replan notice into context
1682
+ this.contextManager.addMessage({
1683
+ role: "system",
1684
+ content: `[Proactive Replan] ${replanResult.message}\nScope: ${replanResult.decision.scope}, Risk: ${replanResult.decision.urgency}`,
1685
+ });
1686
+ }
1687
+ }
1688
+ catch {
1689
+ // Non-blocking — proactive replanning failures should not crash the agent
1690
+ }
1691
+ }
1692
+ // Plan progress injection — every 3 iterations or when task advances
1693
+ if (this.activePlan) {
1694
+ this.injectPlanProgress(iteration);
1695
+ }
1429
1696
  // Policy validation — check cost limits from ExecutionPolicyEngine
1430
1697
  if (this.policyEngine) {
1431
1698
  try {
@@ -1768,6 +2035,8 @@ export class AgentLoop extends EventEmitter {
1768
2035
  const { results: toolResults, deferredFixPrompts } = await this.executeTools(response.toolCalls);
1769
2036
  // Reflexion: 도구 결과 수집
1770
2037
  this.allToolResults.push(...toolResults);
2038
+ // Accumulate tool results for proactive replanning evaluation
2039
+ this.allToolResultsSinceLastReplan.push(...toolResults);
1771
2040
  // Tool plan tracking: 실행된 도구 이름 기록
1772
2041
  for (const result of toolResults) {
1773
2042
  this.executedToolNames.push(result.name);
@@ -1898,6 +2167,10 @@ export class AgentLoop extends EventEmitter {
1898
2167
  this.tscRanLastIteration = false;
1899
2168
  }
1900
2169
  }
2170
+ // Plan task advancement — check if current task's target files were modified
2171
+ if (this.activePlan) {
2172
+ this.tryAdvancePlanTask();
2173
+ }
1901
2174
  // iteration 이벤트
1902
2175
  this.emitEvent({
1903
2176
  kind: "agent:iteration",
@@ -2216,7 +2489,7 @@ export class AgentLoop extends EventEmitter {
2216
2489
  let usage = { input: 0, output: 0 };
2217
2490
  let finishReason = "stop";
2218
2491
  const allTools = [...this.config.loop.tools, ...this.mcpToolDefinitions];
2219
- const stream = this.llmClient.chatStream(messages, allTools);
2492
+ const stream = this.llmClient.chatStream(messages, allTools, this.abortSignal ?? undefined);
2220
2493
  // 텍스트 버퍼링 — 1토큰씩 emit하지 않고 청크 단위로 모아서 emit
2221
2494
  let textBuffer = "";
2222
2495
  let flushTimer = null;
@@ -2521,6 +2794,10 @@ export class AgentLoop extends EventEmitter {
2521
2794
  });
2522
2795
  }
2523
2796
  }
2797
+ // StateUpdater: sync world model with actual tool execution result
2798
+ if (this.stateUpdater && result.success) {
2799
+ this.stateUpdater.applyToolResult(toolCall.name, args, result).catch(() => { });
2800
+ }
2524
2801
  const fixPrompt = await this.validateAndFeedback(toolCall.name, result);
2525
2802
  return { result, deferredFixPrompt: fixPrompt ?? null };
2526
2803
  }
@@ -2577,136 +2854,172 @@ export class AgentLoop extends EventEmitter {
2577
2854
  }
2578
2855
  }
2579
2856
  async executeTools(toolCalls) {
2580
- // Reorder write tool calls using DependencyAnalyzer so files with no deps run first
2581
- const writeToolNames = new Set(['file_write', 'file_edit']);
2582
- const writeToolCalls = toolCalls.filter((tc) => writeToolNames.has(tc.name));
2857
+ if (toolCalls.length === 0)
2858
+ return { results: [], deferredFixPrompts: [] };
2859
+ // ─── Step 1: Build execution plan ───────────────────────────────────────
2860
+ // Strategy:
2861
+ // • Read-only tools (low-risk) → batch together, run all in parallel
2862
+ // • Write tools (independent files) → use DependencyAnalyzer to group,
2863
+ // run each independent group in parallel
2864
+ // • Write tools (dependent files) → run sequentially after their deps
2865
+ // • shell_exec / git_ops / etc. → always sequential (side-effects)
2866
+ const READ_ONLY = new Set(['file_read', 'grep', 'glob', 'code_search', 'security_scan']);
2867
+ const WRITE_TOOLS = new Set(['file_write', 'file_edit']);
2868
+ // Separate reads, writes, and heavy side-effect tools
2869
+ const writeToolCalls = toolCalls.filter((tc) => WRITE_TOOLS.has(tc.name));
2870
+ // ─── Step 2: Dependency-aware write tool batching ────────────────────────
2871
+ // Map each write tool call to a "wave index" (0 = can run first, 1 = needs wave-0 done, etc.)
2872
+ const writeBatchMap = new Map(); // tc.id → wave index
2583
2873
  if (writeToolCalls.length > 1 && this.config.loop.projectPath) {
2584
2874
  try {
2585
2875
  const depAnalyzer = new DependencyAnalyzer();
2586
2876
  const depGraph = await depAnalyzer.analyze(this.config.loop.projectPath);
2587
- const writeFilePaths = writeToolCalls
2588
- .map((tc) => {
2877
+ // Collect target file paths from write tool args
2878
+ const writeFilePaths = writeToolCalls.flatMap((tc) => {
2589
2879
  const args = this.parseToolArgs(tc.arguments);
2590
- return typeof args.path === "string" ? args.path : null;
2591
- })
2592
- .filter((p) => p !== null);
2880
+ const p = typeof args.path === "string" ? args.path
2881
+ : typeof args.file_path === "string" ? args.file_path
2882
+ : null;
2883
+ return p ? [p] : [];
2884
+ });
2593
2885
  if (writeFilePaths.length > 1) {
2594
2886
  const groups = depAnalyzer.groupIndependentFiles(depGraph, writeFilePaths);
2595
- // Build ordered list of file paths: independent groups first, dependent files after
2596
- const orderedPaths = [];
2887
+ // Assign wave indices: independent groups get wave 0,
2888
+ // dependent groups get wave = max(their dep waves) + 1
2889
+ // For simplicity: canParallelize=true → wave 0, else sequential waves
2890
+ let wave = 0;
2597
2891
  for (const group of groups) {
2598
- for (const f of group.files) {
2599
- if (!orderedPaths.includes(f))
2600
- orderedPaths.push(f);
2601
- }
2602
- }
2603
- // Reorder writeToolCalls according to orderedPaths
2604
- const reordered = [];
2605
- for (const filePath of orderedPaths) {
2606
- const tc = writeToolCalls.find((c) => {
2607
- const args = this.parseToolArgs(c.arguments);
2608
- return args.path === filePath;
2609
- });
2610
- if (tc)
2611
- reordered.push(tc);
2612
- }
2613
- // Add any write calls that didn't match a path (shouldn't happen, but be safe)
2614
- for (const tc of writeToolCalls) {
2615
- if (!reordered.includes(tc))
2616
- reordered.push(tc);
2617
- }
2618
- // Rebuild toolCalls preserving non-write tools in their original positions,
2619
- // replacing write tools with dependency-ordered sequence
2620
- const reorderedAll = [];
2621
- let writeIdx = 0;
2622
- for (const tc of toolCalls) {
2623
- if (writeToolNames.has(tc.name)) {
2624
- reorderedAll.push(reordered[writeIdx++] ?? tc);
2625
- }
2626
- else {
2627
- reorderedAll.push(tc);
2892
+ if (!group.canParallelize)
2893
+ wave++;
2894
+ for (const filePath of group.files) {
2895
+ const tc = writeToolCalls.find((c) => {
2896
+ const args = this.parseToolArgs(c.arguments);
2897
+ const p = args.path ?? args.file_path;
2898
+ return p === filePath;
2899
+ });
2900
+ if (tc)
2901
+ writeBatchMap.set(tc.id, wave);
2628
2902
  }
2903
+ if (group.canParallelize)
2904
+ wave = 0; // reset: next independent group is also wave 0
2629
2905
  }
2630
- toolCalls = reorderedAll;
2631
2906
  }
2632
2907
  }
2633
2908
  catch {
2634
- // DependencyAnalyzer failure is non-fatal — fall through to original ordering
2909
+ // DependencyAnalyzer failure is non-fatal — all writes run sequentially
2635
2910
  }
2636
2911
  }
2637
- // Group tool calls: read-only can run in parallel, write tools run sequentially
2638
- const readOnlyTools = new Set(['file_read', 'grep', 'glob', 'code_search']);
2912
+ // ─── Step 3: Build ordered batch list ────────────────────────────────────
2913
+ // Final structure: array of batches, each batch runs in parallel.
2914
+ // Reads accumulate until interrupted by a non-read tool.
2915
+ // Writes are grouped by wave (same wave → parallel, different wave → sequential).
2639
2916
  const batches = [];
2640
- let currentBatch = [];
2641
- for (const toolCall of toolCalls) {
2642
- if (readOnlyTools.has(toolCall.name)) {
2643
- currentBatch.push(toolCall);
2917
+ let readBatch = [];
2918
+ const writeBatchGroups = new Map(); // wave → calls
2919
+ const flushReadBatch = () => {
2920
+ if (readBatch.length > 0) {
2921
+ batches.push({ calls: [...readBatch], label: `${readBatch.length} read-only` });
2922
+ readBatch = [];
2923
+ }
2924
+ };
2925
+ const flushWriteBatches = () => {
2926
+ if (writeBatchGroups.size === 0)
2927
+ return;
2928
+ const waves = [...writeBatchGroups.keys()].sort((a, b) => a - b);
2929
+ for (const w of waves) {
2930
+ const wCalls = writeBatchGroups.get(w);
2931
+ batches.push({
2932
+ calls: wCalls,
2933
+ label: wCalls.length > 1
2934
+ ? `${wCalls.length} independent writes (wave ${w})`
2935
+ : `write: ${wCalls[0].name}`,
2936
+ });
2937
+ }
2938
+ writeBatchGroups.clear();
2939
+ };
2940
+ for (const tc of toolCalls) {
2941
+ if (READ_ONLY.has(tc.name)) {
2942
+ // Reads accumulate; don't flush write batches yet (they don't conflict)
2943
+ readBatch.push(tc);
2944
+ }
2945
+ else if (WRITE_TOOLS.has(tc.name)) {
2946
+ // Flush any pending reads first (reads before writes)
2947
+ flushReadBatch();
2948
+ const wave = writeBatchMap.get(tc.id) ?? 99; // unknown dep → run last
2949
+ if (!writeBatchGroups.has(wave))
2950
+ writeBatchGroups.set(wave, []);
2951
+ writeBatchGroups.get(wave).push(tc);
2644
2952
  }
2645
2953
  else {
2646
- if (currentBatch.length > 0) {
2647
- batches.push(currentBatch);
2648
- currentBatch = [];
2649
- }
2650
- batches.push([toolCall]); // write tools run solo
2954
+ // Heavy tool (shell_exec, git_ops, etc.) → flush everything, run solo
2955
+ flushReadBatch();
2956
+ flushWriteBatches();
2957
+ batches.push({ calls: [tc], label: tc.name });
2651
2958
  }
2652
2959
  }
2653
- if (currentBatch.length > 0)
2654
- batches.push(currentBatch);
2655
- if (toolCalls.length > 1)
2656
- this.emitReasoning(`parallel tool batch started (${toolCalls.length} tools)`);
2960
+ flushReadBatch();
2961
+ flushWriteBatches();
2962
+ // ─── Step 4: Execute batches ─────────────────────────────────────────────
2963
+ if (toolCalls.length > 1) {
2964
+ const parallelCount = batches.filter(b => b.calls.length > 1).length;
2965
+ this.emitReasoning(parallelCount > 0
2966
+ ? `executing ${toolCalls.length} tools in ${batches.length} batches (${parallelCount} parallel)`
2967
+ : `executing ${toolCalls.length} tools sequentially`);
2968
+ }
2657
2969
  const results = [];
2658
2970
  const deferredFixPrompts = [];
2971
+ let interrupted = false;
2659
2972
  for (const batch of batches) {
2660
- if (batch.length === 1) {
2661
- // Sequential single tool execution
2662
- const { result, deferredFixPrompt } = await this.executeSingleTool(batch[0], toolCalls);
2973
+ if (interrupted) {
2974
+ // Fill remaining tools with SKIPPED placeholders
2975
+ for (const tc of batch.calls) {
2976
+ results.push({
2977
+ tool_call_id: tc.id,
2978
+ name: tc.name,
2979
+ output: '[SKIPPED] Execution interrupted.',
2980
+ success: false,
2981
+ durationMs: 0,
2982
+ });
2983
+ }
2984
+ continue;
2985
+ }
2986
+ if (batch.calls.length === 1) {
2987
+ // Single tool — sequential execution
2988
+ const { result, deferredFixPrompt } = await this.executeSingleTool(batch.calls[0], toolCalls);
2663
2989
  if (result)
2664
2990
  results.push(result);
2665
2991
  if (deferredFixPrompt)
2666
2992
  deferredFixPrompts.push(deferredFixPrompt);
2667
- // Check for interrupt result — stop processing remaining batches
2668
- if (result && result.output.startsWith('[INTERRUPTED]')) {
2669
- // Add placeholder results for remaining unexecuted tool calls
2670
- const executedIds = new Set(results.map(r => r.tool_call_id));
2671
- for (const tc of toolCalls) {
2672
- if (!executedIds.has(tc.id)) {
2673
- results.push({
2674
- tool_call_id: tc.id,
2675
- name: tc.name,
2676
- output: `[SKIPPED] Previous tool was interrupted.`,
2677
- success: false,
2678
- durationMs: 0,
2679
- });
2680
- }
2681
- }
2682
- break;
2683
- }
2993
+ if (result?.output.startsWith('[INTERRUPTED]'))
2994
+ interrupted = true;
2684
2995
  }
2685
2996
  else {
2686
- // Parallel execution for read-only tools in this batch
2687
- this.emitReasoning(`running ${batch.length} read-only tools in parallel`);
2688
- const batchResults = await Promise.allSettled(batch.map(tc => this.executeSingleTool(tc, toolCalls)));
2689
- for (const settled of batchResults) {
2690
- if (settled.status === 'fulfilled') {
2691
- if (settled.value.result)
2692
- results.push(settled.value.result);
2693
- if (settled.value.deferredFixPrompt)
2694
- deferredFixPrompts.push(settled.value.deferredFixPrompt);
2997
+ // Multi-tool parallel execution
2998
+ this.emitReasoning(`⚡ running ${batch.label} in parallel`);
2999
+ const settled = await Promise.allSettled(batch.calls.map((tc) => this.executeSingleTool(tc, toolCalls)));
3000
+ for (let i = 0; i < settled.length; i++) {
3001
+ const s = settled[i];
3002
+ const tc = batch.calls[i];
3003
+ if (s.status === 'fulfilled') {
3004
+ if (s.value.result)
3005
+ results.push(s.value.result);
3006
+ if (s.value.deferredFixPrompt)
3007
+ deferredFixPrompts.push(s.value.deferredFixPrompt);
3008
+ if (s.value.result?.output.startsWith('[INTERRUPTED]'))
3009
+ interrupted = true;
2695
3010
  }
2696
3011
  else {
2697
- // Parallel tool failure — record as error result
2698
- const tc = batch[batchResults.indexOf(settled)];
2699
3012
  results.push({
2700
- tool_call_id: tc?.id ?? 'unknown',
2701
- name: tc?.name ?? 'unknown',
2702
- output: `Error: ${settled.reason instanceof Error ? settled.reason.message : String(settled.reason)}`,
3013
+ tool_call_id: tc.id,
3014
+ name: tc.name,
3015
+ output: `Error: ${s.reason instanceof Error ? s.reason.message : String(s.reason)}`,
2703
3016
  success: false,
2704
3017
  durationMs: 0,
2705
3018
  });
2706
3019
  }
2707
- } // end for (const settled of batchResults)
2708
- } // end else (parallel batch)
2709
- } // end for (const batch of batches)
3020
+ }
3021
+ }
3022
+ }
2710
3023
  return { results, deferredFixPrompts };
2711
3024
  }
2712
3025
  /**
@@ -2841,6 +3154,100 @@ export class AgentLoop extends EventEmitter {
2841
3154
  // 체크포인트 저장 실패는 치명적이지 않음
2842
3155
  }
2843
3156
  }
3157
+ /**
3158
+ * 매 iteration 시작 시 현재 플랜 진행 상황을 컨텍스트에 주입.
3159
+ * 같은 태스크 인덱스라면 3 iteration마다만 주입 (컨텍스트 bloat 방지).
3160
+ */
3161
+ injectPlanProgress(iteration) {
3162
+ if (!this.activePlan)
3163
+ return;
3164
+ const tasks = this.activePlan.tactical;
3165
+ if (tasks.length === 0)
3166
+ return;
3167
+ const idx = this.currentTaskIndex;
3168
+ const total = tasks.length;
3169
+ // 같은 태스크면 3iteration마다, 태스크 전진 시 즉시 주입
3170
+ const taskAdvanced = idx !== this._lastInjectedTaskIndex;
3171
+ if (!taskAdvanced && iteration > 1 && iteration % 3 !== 1)
3172
+ return;
3173
+ this._lastInjectedTaskIndex = idx;
3174
+ const lines = [
3175
+ `## Plan Progress [${idx}/${total} done]`,
3176
+ ];
3177
+ for (let i = 0; i < tasks.length; i++) {
3178
+ const task = tasks[i];
3179
+ const marker = i < idx ? "✓" : i === idx ? "→" : "○";
3180
+ lines.push(`${marker} Task ${i + 1}: ${task.description}`);
3181
+ if (i === idx && task.targetFiles.length > 0) {
3182
+ lines.push(` Files: ${task.targetFiles.join(", ")}`);
3183
+ }
3184
+ }
3185
+ if (idx < total) {
3186
+ const cur = tasks[idx];
3187
+ lines.push(`\nCurrent task: **${cur.description}**`);
3188
+ if (cur.toolStrategy.length > 0) {
3189
+ lines.push(`Suggested tools: ${cur.toolStrategy.join(", ")}`);
3190
+ }
3191
+ if (cur.readFiles.length > 0) {
3192
+ lines.push(`Read first: ${cur.readFiles.join(", ")}`);
3193
+ }
3194
+ }
3195
+ else {
3196
+ lines.push(`\nAll tasks complete — verify and wrap up.`);
3197
+ }
3198
+ this.contextManager.addMessage({
3199
+ role: "system",
3200
+ content: lines.join("\n"),
3201
+ });
3202
+ this.iterationSystemMsgCount++;
3203
+ }
3204
+ /**
3205
+ * 현재 태스크의 targetFiles가 changedFiles에 포함됐는지 확인해
3206
+ * 완료 감지 시 다음 태스크로 자동 전진.
3207
+ */
3208
+ tryAdvancePlanTask() {
3209
+ if (!this.activePlan)
3210
+ return;
3211
+ const tasks = this.activePlan.tactical;
3212
+ if (this.currentTaskIndex >= tasks.length)
3213
+ return;
3214
+ const currentTask = tasks[this.currentTaskIndex];
3215
+ if (!currentTask)
3216
+ return;
3217
+ // targetFiles가 없으면 tool call이 있었던 것만으로 완료 간주
3218
+ if (currentTask.targetFiles.length === 0) {
3219
+ if (this.allToolResults.length > 0) {
3220
+ this.completedPlanTaskIds.add(currentTask.id);
3221
+ this.currentTaskIndex++;
3222
+ this._emitPlanAdvance(tasks);
3223
+ }
3224
+ return;
3225
+ }
3226
+ // targetFiles 중 하나라도 changedFiles에 있으면 완료
3227
+ const changedBasenames = new Set(this.changedFiles.map((f) => f.split("/").pop().toLowerCase()));
3228
+ const targetBasenames = currentTask.targetFiles.map((f) => f.split("/").pop().toLowerCase());
3229
+ const hit = targetBasenames.some((b) => changedBasenames.has(b));
3230
+ if (hit) {
3231
+ this.completedPlanTaskIds.add(currentTask.id);
3232
+ this.currentTaskIndex++;
3233
+ this._emitPlanAdvance(tasks);
3234
+ }
3235
+ }
3236
+ _emitPlanAdvance(tasks) {
3237
+ const idx = this.currentTaskIndex;
3238
+ if (idx < tasks.length) {
3239
+ this.emitEvent({
3240
+ kind: "agent:thinking",
3241
+ content: `✓ Task ${idx}/${tasks.length} done. Next: ${tasks[idx].description}`,
3242
+ });
3243
+ }
3244
+ else {
3245
+ this.emitEvent({
3246
+ kind: "agent:thinking",
3247
+ content: `✓ All ${tasks.length} tasks completed.`,
3248
+ });
3249
+ }
3250
+ }
2844
3251
  /**
2845
3252
  * 현재 plan에서 진행 상황을 추출한다.
2846
3253
  */