@yuaone/core 0.9.30 → 0.9.32

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.
@@ -47,6 +47,7 @@ import { SelfDebugLoop } from "./self-debug-loop.js";
47
47
  import { SkillLearner } from "./skill-learner.js";
48
48
  import { RepoKnowledgeGraph } from "./repo-knowledge-graph.js";
49
49
  import { BackgroundAgentManager } from "./background-agent.js";
50
+ import { SubAgent } from "./sub-agent.js";
50
51
  import { ReasoningAggregator } from "./reasoning-aggregator.js";
51
52
  import { ReasoningTree } from "./reasoning-tree.js";
52
53
  import { ContextCompressor } from "./context-compressor.js";
@@ -112,6 +113,52 @@ import { dlog, dlogSep } from "./debug-logger.js";
112
113
  */
113
114
  /** Minimum confidence for classification-based hints/routing to activate */
114
115
  const CLASSIFICATION_CONFIDENCE_THRESHOLD = 0.6;
116
+ /** spawn_sub_agent tool definition — allows the LLM to delegate tasks to sub-agents */
117
+ const SPAWN_SUB_AGENT_TOOL = {
118
+ name: "spawn_sub_agent",
119
+ description: "Spawn a sub-agent to work on a delegated task in parallel. " +
120
+ "The sub-agent runs an independent agent loop with its own tool access, " +
121
+ "inheriting the same LLM configuration. Use this for tasks that can be " +
122
+ "broken down (e.g., implement one module while you work on another, " +
123
+ "run a review pass, verify changes).",
124
+ parameters: {
125
+ type: "object",
126
+ properties: {
127
+ goal: {
128
+ type: "string",
129
+ description: "The specific task goal for the sub-agent to accomplish.",
130
+ },
131
+ role: {
132
+ type: "string",
133
+ enum: ["coder", "critic", "verifier", "specialist"],
134
+ description: "Sub-agent role. 'coder' writes code, 'critic' reviews for issues, " +
135
+ "'verifier' checks correctness, 'specialist' handles domain tasks. " +
136
+ "Defaults to 'coder'.",
137
+ },
138
+ },
139
+ required: ["goal"],
140
+ additionalProperties: false,
141
+ },
142
+ source: "builtin",
143
+ readOnly: false,
144
+ requiresApproval: false,
145
+ riskLevel: "medium",
146
+ };
147
+ /** Map user-facing sub-agent roles to internal SubAgentRole */
148
+ function mapToSubAgentRole(role) {
149
+ switch (role) {
150
+ case "critic":
151
+ return "reviewer";
152
+ case "verifier":
153
+ return "tester";
154
+ case "specialist":
155
+ return "coder";
156
+ case "coder":
157
+ return "coder";
158
+ default:
159
+ return "coder";
160
+ }
161
+ }
115
162
  export class AgentLoop extends EventEmitter {
116
163
  abortSignal;
117
164
  llmClient;
@@ -140,6 +187,7 @@ export class AgentLoop extends EventEmitter {
140
187
  changedFiles = [];
141
188
  aborted = false;
142
189
  initialized = false;
190
+ partialInit = false;
143
191
  continuationEngine = null;
144
192
  mcpClient = null;
145
193
  mcpToolDefinitions = [];
@@ -202,6 +250,12 @@ export class AgentLoop extends EventEmitter {
202
250
  iterationTsFilesModified = [];
203
251
  /** Task 3: Whether tsc was run in the previous iteration (skip cooldown) */
204
252
  tscRanLastIteration = false;
253
+ /** Tracks whether the last tool call in the most recent iteration failed */
254
+ _lastToolFailed = false;
255
+ /** Per-tool-call retry counter: tool_call_id → retry count (max 2) */
256
+ _toolRetryCount = new Map();
257
+ /** Count of consecutive iterations where LLM returned text-only (no tool calls, no task_complete) */
258
+ _consecutiveTextOnlyIterations = 0;
205
259
  // ─── OverheadGovernor — subsystem execution policy ──────────────────────
206
260
  overheadGovernor;
207
261
  /** Writes since last verify ran (for QA/quickVerify trigger) */
@@ -399,7 +453,10 @@ export class AgentLoop extends EventEmitter {
399
453
  async init() {
400
454
  if (this.initialized)
401
455
  return;
402
- this.initialized = true;
456
+ // Guard against concurrent re-entry while init is already running
457
+ if (this.partialInit)
458
+ return;
459
+ this.partialInit = true;
403
460
  // Task 1: Initialize ContextBudgetManager with the total token budget
404
461
  this.contextBudgetManager = new ContextBudgetManager({
405
462
  totalBudget: this.config.loop.totalTokenBudget,
@@ -625,7 +682,7 @@ export class AgentLoop extends EventEmitter {
625
682
  const enhancedPrompt = buildSystemPrompt({
626
683
  projectStructure,
627
684
  yuanMdContent,
628
- tools: [...this.config.loop.tools, ...this.mcpToolDefinitions],
685
+ tools: [...this.config.loop.tools, ...this.mcpToolDefinitions, SPAWN_SUB_AGENT_TOOL],
629
686
  projectPath,
630
687
  environment: this.environment,
631
688
  });
@@ -879,6 +936,10 @@ export class AgentLoop extends EventEmitter {
879
936
  this.continuousReflection.on("reflection:context_overflow", () => {
880
937
  void this.handleSoftContextOverflow();
881
938
  });
939
+ // Mark fully initialized — partialInit is cleared so timeout-interrupted
940
+ // runs can detect that init completed (partialInit=false + initialized=true).
941
+ this.initialized = true;
942
+ this.partialInit = false;
882
943
  }
883
944
  /**
884
945
  * MemoryManager의 학습/실패 기록을 시스템 메시지로 변환.
@@ -944,6 +1005,9 @@ export class AgentLoop extends EventEmitter {
944
1005
  // Task 1: reset context summarization guard per run
945
1006
  this._contextSummarizationDone = false;
946
1007
  this._unfulfilledContinuations = 0;
1008
+ this._lastToolFailed = false;
1009
+ this._toolRetryCount.clear();
1010
+ this._consecutiveTextOnlyIterations = 0;
947
1011
  }
948
1012
  // Reset resumedFromSession flag after consuming it — prevents stale state
949
1013
  // from leaking into subsequent runs when the same AgentLoop instance is reused.
@@ -957,11 +1021,15 @@ export class AgentLoop extends EventEmitter {
957
1021
  dlog("AGENT-LOOP", `emitting agent:start, sessionId=${this.sessionId}`);
958
1022
  this.emitEvent({ kind: "agent:start", goal: userMessage });
959
1023
  // 첫 실행 시 메모리/프로젝트 컨텍스트 자동 로드
960
- // init은 최대 2초만 블로킹 — Ollama/analyzeProject 등 느린 작업은 백그라운드 계속
1024
+ // init은 최대 5초만 블로킹 — Ollama/analyzeProject 등 느린 작업은 백그라운드 계속
961
1025
  await Promise.race([
962
1026
  this.init(),
963
- new Promise(resolve => setTimeout(resolve, 2_000)),
1027
+ new Promise(resolve => setTimeout(resolve, 5_000)),
964
1028
  ]);
1029
+ // If init timed out (partialInit still true), allow retry on next run
1030
+ if (this.partialInit && !this.initialized) {
1031
+ this.partialInit = false;
1032
+ }
965
1033
  // Always generate a fresh sessionId per run — prevents BudgetGovernorV2
966
1034
  // from accumulating exhausted task budget across multiple runs on the same instance.
967
1035
  this.sessionId = randomUUID();
@@ -2248,9 +2316,11 @@ export class AgentLoop extends EventEmitter {
2248
2316
  response = await this.callLLMStreaming(messages);
2249
2317
  }
2250
2318
  catch (err) {
2319
+ const errMsg = err instanceof Error ? err.message : String(err);
2320
+ dlog("AGENT-LOOP", `run() LLM error`, { reason: "ERROR", error: errMsg, tokensUsed: this.tokenUsage.total, iterations: this.iterationCount });
2321
+ this.emit("event", { kind: "agent:error", message: errMsg, retryable: false });
2251
2322
  if (err instanceof LLMError) {
2252
- dlog("AGENT-LOOP", `run() terminating`, { reason: "ERROR", tokensUsed: this.tokenUsage.total, iterations: this.iterationCount });
2253
- return { reason: "ERROR", error: err.message };
2323
+ return { reason: "ERROR", error: errMsg };
2254
2324
  }
2255
2325
  throw err;
2256
2326
  }
@@ -2337,16 +2407,20 @@ export class AgentLoop extends EventEmitter {
2337
2407
  if (response.toolCalls.length === 0) {
2338
2408
  const content = response.content ?? "";
2339
2409
  let finalSummary = content || "Task completed.";
2410
+ // Track consecutive text-only iterations
2411
+ this._consecutiveTextOnlyIterations++;
2340
2412
  // If LLM outputs text with no tool calls and hasn't signalled completion via task_complete,
2341
- // remind it once to use tools or call task_complete.
2342
- if (this._unfulfilledContinuations < 2) {
2343
- this._unfulfilledContinuations++;
2413
+ // remind it to use tools or call task_complete.
2414
+ // Nudge on: first 2 unfulfilled continuations, OR every 2 consecutive text-only iterations.
2415
+ if (this._unfulfilledContinuations < 2 || (this._consecutiveTextOnlyIterations >= 2 && this._consecutiveTextOnlyIterations % 2 === 0)) {
2416
+ if (this._unfulfilledContinuations < 2)
2417
+ this._unfulfilledContinuations++;
2344
2418
  this.contextManager.addMessage({ role: "assistant", content });
2345
2419
  this.contextManager.addMessage({
2346
2420
  role: "user",
2347
- content: "Please complete your task: either use the available tools to take action, or call task_complete if you are done.",
2421
+ content: "Continue working. Use tools to make progress, or call task_complete if done.",
2348
2422
  });
2349
- this.emitEvent({ kind: "agent:thinking", content: `[nudge ${this._unfulfilledContinuations}/2] reminding LLM to use tools or call task_complete` });
2423
+ this.emitEvent({ kind: "agent:thinking", content: `[nudge ${this._unfulfilledContinuations}/2, text-only: ${this._consecutiveTextOnlyIterations}] reminding LLM to use tools or call task_complete` });
2350
2424
  continue;
2351
2425
  }
2352
2426
  // Do NOT reset _unfulfilledContinuations here — counter stays spent to prevent infinite nudge loop
@@ -2529,6 +2603,23 @@ export class AgentLoop extends EventEmitter {
2529
2603
  if (taskCompleteCall) {
2530
2604
  const callArgs = this.parseToolArgs(taskCompleteCall.arguments);
2531
2605
  const taskCompleteSummary = String(callArgs["summary"] ?? response.content ?? "Task completed.");
2606
+ // Stale GOAL_ACHIEVED prevention: if the last tool call failed, don't accept
2607
+ // completion — nudge the LLM to fix the issue first.
2608
+ if (this._lastToolFailed) {
2609
+ this._lastToolFailed = false; // reset so it doesn't block forever
2610
+ const nonProtocolCalls = response.toolCalls.filter((tc) => tc.name !== "task_complete");
2611
+ this.contextManager.addMessage({
2612
+ role: "assistant",
2613
+ content: response.content,
2614
+ tool_calls: nonProtocolCalls.length > 0 ? nonProtocolCalls : undefined,
2615
+ });
2616
+ this.contextManager.addMessage({
2617
+ role: "user",
2618
+ content: "The last tool call failed. Fix the issue before completing the task.",
2619
+ });
2620
+ this.emitEvent({ kind: "agent:thinking", content: "[stale-completion guard] last tool failed, rejecting task_complete" });
2621
+ continue;
2622
+ }
2532
2623
  // Save assistant message to context — filter task_complete from tool_calls.
2533
2624
  // task_complete is an internal protocol signal; including it in LLM history without
2534
2625
  // a matching role:"tool" result causes API 400 on the next turn.
@@ -2578,6 +2669,23 @@ export class AgentLoop extends EventEmitter {
2578
2669
  const { results: toolResults, deferredFixPrompts } = await this.executeTools(response.toolCalls);
2579
2670
  // Reflexion: 도구 결과 수집
2580
2671
  this.allToolResults.push(...toolResults);
2672
+ // Track whether any tool call failed this iteration (for stale GOAL_ACHIEVED prevention)
2673
+ const failedResults = toolResults.filter(r => !r.success);
2674
+ this._lastToolFailed = failedResults.length > 0;
2675
+ // Tool calls were made — reset consecutive text-only counter
2676
+ this._consecutiveTextOnlyIterations = 0;
2677
+ // Tool call failure auto-retry: inject error as user message so LLM can self-correct
2678
+ // (max 2 retries per tool_call_id to prevent infinite retry loops)
2679
+ for (const failed of failedResults) {
2680
+ const retryKey = failed.tool_call_id ?? failed.name;
2681
+ const retryCount = this._toolRetryCount.get(retryKey) ?? 0;
2682
+ if (retryCount < 2) {
2683
+ this._toolRetryCount.set(retryKey, retryCount + 1);
2684
+ // The error is already in the tool result message added below;
2685
+ // no extra injection needed — the LLM will see the failure and retry naturally.
2686
+ dlog("AGENT-LOOP", `tool failure auto-retry eligible`, { tool: failed.name, retryCount: retryCount + 1, maxRetries: 2 });
2687
+ }
2688
+ }
2581
2689
  // Phase 6: Record tool outcomes in capability graph
2582
2690
  for (const tr of toolResults) {
2583
2691
  recordToolOutcomeInGraph(this.capabilityGraph, tr.name, tr.success);
@@ -3087,7 +3195,7 @@ export class AgentLoop extends EventEmitter {
3087
3195
  const toolCalls = [];
3088
3196
  let usage = { input: 0, output: 0 };
3089
3197
  let finishReason = "stop";
3090
- const allTools = [...this.config.loop.tools, ...this.mcpToolDefinitions];
3198
+ const allTools = [...this.config.loop.tools, ...this.mcpToolDefinitions, SPAWN_SUB_AGENT_TOOL];
3091
3199
  const stream = this.llmClient.chatStream(messages, allTools, this.abortSignal ?? undefined);
3092
3200
  // 텍스트 버퍼링 — 1토큰씩 emit하지 않고 청크 단위로 모아서 emit
3093
3201
  let textBuffer = "";
@@ -3223,7 +3331,7 @@ export class AgentLoop extends EventEmitter {
3223
3331
  */
3224
3332
  async executeSingleTool(toolCall, toolCalls) {
3225
3333
  const args = this.parseToolArgs(toolCall.arguments);
3226
- const allDefinitions = [...this.config.loop.tools, ...this.mcpToolDefinitions];
3334
+ const allDefinitions = [...this.config.loop.tools, ...this.mcpToolDefinitions, SPAWN_SUB_AGENT_TOOL];
3227
3335
  const matchedDefinition = allDefinitions.find((t) => t.name === toolCall.name);
3228
3336
  // Governor: 안전성 검증
3229
3337
  try {
@@ -3293,6 +3401,11 @@ export class AgentLoop extends EventEmitter {
3293
3401
  return { result: pluginApprovalResult, deferredFixPrompt: null };
3294
3402
  }
3295
3403
  }
3404
+ // spawn_sub_agent 도구 호출 — SubAgent로 위임
3405
+ if (toolCall.name === "spawn_sub_agent") {
3406
+ const subAgentResult = await this.executeSpawnSubAgent(toolCall, args);
3407
+ return { result: subAgentResult, deferredFixPrompt: null };
3408
+ }
3296
3409
  // MCP 도구 호출 확인
3297
3410
  if (this.mcpClient && this.isMCPTool(toolCall.name)) {
3298
3411
  // Emit tool_start before execution (required for trace, QA pipeline, replay)
@@ -3678,6 +3791,138 @@ export class AgentLoop extends EventEmitter {
3678
3791
  }
3679
3792
  return { results, deferredFixPrompts };
3680
3793
  }
3794
+ /**
3795
+ * Execute spawn_sub_agent tool — creates a SubAgent, runs it, returns result.
3796
+ * The sub-agent inherits the parent's LLM config (provider, model, apiKey).
3797
+ */
3798
+ async executeSpawnSubAgent(toolCall, args) {
3799
+ const startTime = Date.now();
3800
+ const goal = String(args.goal ?? "");
3801
+ const roleInput = typeof args.role === "string" ? args.role : "coder";
3802
+ const role = mapToSubAgentRole(roleInput);
3803
+ if (!goal) {
3804
+ return {
3805
+ tool_call_id: toolCall.id,
3806
+ name: toolCall.name,
3807
+ output: "Error: 'goal' parameter is required for spawn_sub_agent.",
3808
+ success: false,
3809
+ durationMs: Date.now() - startTime,
3810
+ };
3811
+ }
3812
+ // Emit tool_start
3813
+ this.emitEvent({
3814
+ kind: "agent:tool_start",
3815
+ tool: toolCall.name,
3816
+ input: args,
3817
+ source: "builtin",
3818
+ });
3819
+ const taskId = `sub-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
3820
+ const projectPath = this.config.loop.projectPath || process.cwd();
3821
+ try {
3822
+ const subAgent = new SubAgent({
3823
+ taskId,
3824
+ goal,
3825
+ targetFiles: [], // sub-agent discovers files via tools
3826
+ readFiles: [],
3827
+ maxIterations: Math.min(this.config.loop.maxIterations, 15),
3828
+ projectPath,
3829
+ byokConfig: this.config.byok,
3830
+ tools: this.config.loop.tools.map((t) => t.name),
3831
+ createToolExecutor: (_workDir, _enabledTools) => this.toolExecutor,
3832
+ role,
3833
+ });
3834
+ // Forward sub-agent events as bg_update for TUI visibility
3835
+ subAgent.on("subagent:phase", (tid, phase) => {
3836
+ this.emitEvent({
3837
+ kind: "agent:bg_update",
3838
+ agentId: taskId,
3839
+ agentLabel: `sub-agent (${roleInput})`,
3840
+ eventType: "info",
3841
+ message: `Phase: ${phase}`,
3842
+ timestamp: Date.now(),
3843
+ });
3844
+ });
3845
+ subAgent.on("event", (event) => {
3846
+ // Re-emit sub-agent text/thinking for observability
3847
+ if (event.kind === "agent:text_delta") {
3848
+ this.emitEvent({
3849
+ kind: "agent:bg_update",
3850
+ agentId: taskId,
3851
+ agentLabel: `sub-agent (${roleInput})`,
3852
+ eventType: "info",
3853
+ message: String(event.text ?? ""),
3854
+ timestamp: Date.now(),
3855
+ });
3856
+ }
3857
+ else if (event.kind === "agent:thinking") {
3858
+ this.emitEvent({
3859
+ kind: "agent:bg_update",
3860
+ agentId: taskId,
3861
+ agentLabel: `sub-agent (${roleInput})`,
3862
+ eventType: "info",
3863
+ message: String(event.content ?? ""),
3864
+ timestamp: Date.now(),
3865
+ });
3866
+ }
3867
+ });
3868
+ // Build DAG context for the sub-agent
3869
+ const dagContext = {
3870
+ overallGoal: goal,
3871
+ totalTasks: 1,
3872
+ completedTasks: [],
3873
+ runningTasks: [taskId],
3874
+ };
3875
+ // Run sub-agent
3876
+ const result = await subAgent.run(dagContext);
3877
+ // Build output summary
3878
+ const changedFilesList = result.changedFiles.length > 0
3879
+ ? result.changedFiles.map((f) => f.path).join(", ")
3880
+ : "none";
3881
+ const output = [
3882
+ `## Sub-Agent Result (${roleInput})`,
3883
+ `- Task ID: ${taskId}`,
3884
+ `- Success: ${result.success}`,
3885
+ `- Iterations: ${result.iterations}`,
3886
+ `- Tokens: input=${result.tokensUsed.input}, output=${result.tokensUsed.output}`,
3887
+ `- Changed files: ${changedFilesList}`,
3888
+ ``,
3889
+ `### Summary`,
3890
+ result.summary,
3891
+ result.error ? `\n### Error\n${result.error}` : "",
3892
+ ].join("\n");
3893
+ this.emitEvent({
3894
+ kind: "agent:tool_result",
3895
+ tool: toolCall.name,
3896
+ output: output.length > 200 ? output.slice(0, 200) + "..." : output,
3897
+ durationMs: Date.now() - startTime,
3898
+ });
3899
+ return {
3900
+ tool_call_id: toolCall.id,
3901
+ name: toolCall.name,
3902
+ output,
3903
+ success: result.success,
3904
+ durationMs: Date.now() - startTime,
3905
+ };
3906
+ }
3907
+ catch (err) {
3908
+ const errorMsg = err instanceof Error ? err.message : String(err);
3909
+ this.emitEvent({
3910
+ kind: "agent:bg_update",
3911
+ agentId: taskId,
3912
+ agentLabel: `sub-agent (${roleInput})`,
3913
+ eventType: "error",
3914
+ message: `Sub-agent failed: ${errorMsg}`,
3915
+ timestamp: Date.now(),
3916
+ });
3917
+ return {
3918
+ tool_call_id: toolCall.id,
3919
+ name: toolCall.name,
3920
+ output: `Sub-agent error: ${errorMsg}`,
3921
+ success: false,
3922
+ durationMs: Date.now() - startTime,
3923
+ };
3924
+ }
3925
+ }
3681
3926
  /**
3682
3927
  * Governor의 ApprovalRequiredError를 ApprovalManager로 처리.
3683
3928
  * 승인되면 null 반환 (실행 계속), 거부되면 ToolResult 반환 (실행 차단).