@yuaone/core 0.9.30 → 0.9.31

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();
@@ -2337,16 +2405,20 @@ export class AgentLoop extends EventEmitter {
2337
2405
  if (response.toolCalls.length === 0) {
2338
2406
  const content = response.content ?? "";
2339
2407
  let finalSummary = content || "Task completed.";
2408
+ // Track consecutive text-only iterations
2409
+ this._consecutiveTextOnlyIterations++;
2340
2410
  // 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++;
2411
+ // remind it to use tools or call task_complete.
2412
+ // Nudge on: first 2 unfulfilled continuations, OR every 2 consecutive text-only iterations.
2413
+ if (this._unfulfilledContinuations < 2 || (this._consecutiveTextOnlyIterations >= 2 && this._consecutiveTextOnlyIterations % 2 === 0)) {
2414
+ if (this._unfulfilledContinuations < 2)
2415
+ this._unfulfilledContinuations++;
2344
2416
  this.contextManager.addMessage({ role: "assistant", content });
2345
2417
  this.contextManager.addMessage({
2346
2418
  role: "user",
2347
- content: "Please complete your task: either use the available tools to take action, or call task_complete if you are done.",
2419
+ content: "Continue working. Use tools to make progress, or call task_complete if done.",
2348
2420
  });
2349
- this.emitEvent({ kind: "agent:thinking", content: `[nudge ${this._unfulfilledContinuations}/2] reminding LLM to use tools or call task_complete` });
2421
+ this.emitEvent({ kind: "agent:thinking", content: `[nudge ${this._unfulfilledContinuations}/2, text-only: ${this._consecutiveTextOnlyIterations}] reminding LLM to use tools or call task_complete` });
2350
2422
  continue;
2351
2423
  }
2352
2424
  // Do NOT reset _unfulfilledContinuations here — counter stays spent to prevent infinite nudge loop
@@ -2529,6 +2601,23 @@ export class AgentLoop extends EventEmitter {
2529
2601
  if (taskCompleteCall) {
2530
2602
  const callArgs = this.parseToolArgs(taskCompleteCall.arguments);
2531
2603
  const taskCompleteSummary = String(callArgs["summary"] ?? response.content ?? "Task completed.");
2604
+ // Stale GOAL_ACHIEVED prevention: if the last tool call failed, don't accept
2605
+ // completion — nudge the LLM to fix the issue first.
2606
+ if (this._lastToolFailed) {
2607
+ this._lastToolFailed = false; // reset so it doesn't block forever
2608
+ const nonProtocolCalls = response.toolCalls.filter((tc) => tc.name !== "task_complete");
2609
+ this.contextManager.addMessage({
2610
+ role: "assistant",
2611
+ content: response.content,
2612
+ tool_calls: nonProtocolCalls.length > 0 ? nonProtocolCalls : undefined,
2613
+ });
2614
+ this.contextManager.addMessage({
2615
+ role: "user",
2616
+ content: "The last tool call failed. Fix the issue before completing the task.",
2617
+ });
2618
+ this.emitEvent({ kind: "agent:thinking", content: "[stale-completion guard] last tool failed, rejecting task_complete" });
2619
+ continue;
2620
+ }
2532
2621
  // Save assistant message to context — filter task_complete from tool_calls.
2533
2622
  // task_complete is an internal protocol signal; including it in LLM history without
2534
2623
  // a matching role:"tool" result causes API 400 on the next turn.
@@ -2578,6 +2667,23 @@ export class AgentLoop extends EventEmitter {
2578
2667
  const { results: toolResults, deferredFixPrompts } = await this.executeTools(response.toolCalls);
2579
2668
  // Reflexion: 도구 결과 수집
2580
2669
  this.allToolResults.push(...toolResults);
2670
+ // Track whether any tool call failed this iteration (for stale GOAL_ACHIEVED prevention)
2671
+ const failedResults = toolResults.filter(r => !r.success);
2672
+ this._lastToolFailed = failedResults.length > 0;
2673
+ // Tool calls were made — reset consecutive text-only counter
2674
+ this._consecutiveTextOnlyIterations = 0;
2675
+ // Tool call failure auto-retry: inject error as user message so LLM can self-correct
2676
+ // (max 2 retries per tool_call_id to prevent infinite retry loops)
2677
+ for (const failed of failedResults) {
2678
+ const retryKey = failed.tool_call_id ?? failed.name;
2679
+ const retryCount = this._toolRetryCount.get(retryKey) ?? 0;
2680
+ if (retryCount < 2) {
2681
+ this._toolRetryCount.set(retryKey, retryCount + 1);
2682
+ // The error is already in the tool result message added below;
2683
+ // no extra injection needed — the LLM will see the failure and retry naturally.
2684
+ dlog("AGENT-LOOP", `tool failure auto-retry eligible`, { tool: failed.name, retryCount: retryCount + 1, maxRetries: 2 });
2685
+ }
2686
+ }
2581
2687
  // Phase 6: Record tool outcomes in capability graph
2582
2688
  for (const tr of toolResults) {
2583
2689
  recordToolOutcomeInGraph(this.capabilityGraph, tr.name, tr.success);
@@ -3087,7 +3193,7 @@ export class AgentLoop extends EventEmitter {
3087
3193
  const toolCalls = [];
3088
3194
  let usage = { input: 0, output: 0 };
3089
3195
  let finishReason = "stop";
3090
- const allTools = [...this.config.loop.tools, ...this.mcpToolDefinitions];
3196
+ const allTools = [...this.config.loop.tools, ...this.mcpToolDefinitions, SPAWN_SUB_AGENT_TOOL];
3091
3197
  const stream = this.llmClient.chatStream(messages, allTools, this.abortSignal ?? undefined);
3092
3198
  // 텍스트 버퍼링 — 1토큰씩 emit하지 않고 청크 단위로 모아서 emit
3093
3199
  let textBuffer = "";
@@ -3223,7 +3329,7 @@ export class AgentLoop extends EventEmitter {
3223
3329
  */
3224
3330
  async executeSingleTool(toolCall, toolCalls) {
3225
3331
  const args = this.parseToolArgs(toolCall.arguments);
3226
- const allDefinitions = [...this.config.loop.tools, ...this.mcpToolDefinitions];
3332
+ const allDefinitions = [...this.config.loop.tools, ...this.mcpToolDefinitions, SPAWN_SUB_AGENT_TOOL];
3227
3333
  const matchedDefinition = allDefinitions.find((t) => t.name === toolCall.name);
3228
3334
  // Governor: 안전성 검증
3229
3335
  try {
@@ -3293,6 +3399,11 @@ export class AgentLoop extends EventEmitter {
3293
3399
  return { result: pluginApprovalResult, deferredFixPrompt: null };
3294
3400
  }
3295
3401
  }
3402
+ // spawn_sub_agent 도구 호출 — SubAgent로 위임
3403
+ if (toolCall.name === "spawn_sub_agent") {
3404
+ const subAgentResult = await this.executeSpawnSubAgent(toolCall, args);
3405
+ return { result: subAgentResult, deferredFixPrompt: null };
3406
+ }
3296
3407
  // MCP 도구 호출 확인
3297
3408
  if (this.mcpClient && this.isMCPTool(toolCall.name)) {
3298
3409
  // Emit tool_start before execution (required for trace, QA pipeline, replay)
@@ -3678,6 +3789,138 @@ export class AgentLoop extends EventEmitter {
3678
3789
  }
3679
3790
  return { results, deferredFixPrompts };
3680
3791
  }
3792
+ /**
3793
+ * Execute spawn_sub_agent tool — creates a SubAgent, runs it, returns result.
3794
+ * The sub-agent inherits the parent's LLM config (provider, model, apiKey).
3795
+ */
3796
+ async executeSpawnSubAgent(toolCall, args) {
3797
+ const startTime = Date.now();
3798
+ const goal = String(args.goal ?? "");
3799
+ const roleInput = typeof args.role === "string" ? args.role : "coder";
3800
+ const role = mapToSubAgentRole(roleInput);
3801
+ if (!goal) {
3802
+ return {
3803
+ tool_call_id: toolCall.id,
3804
+ name: toolCall.name,
3805
+ output: "Error: 'goal' parameter is required for spawn_sub_agent.",
3806
+ success: false,
3807
+ durationMs: Date.now() - startTime,
3808
+ };
3809
+ }
3810
+ // Emit tool_start
3811
+ this.emitEvent({
3812
+ kind: "agent:tool_start",
3813
+ tool: toolCall.name,
3814
+ input: args,
3815
+ source: "builtin",
3816
+ });
3817
+ const taskId = `sub-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
3818
+ const projectPath = this.config.loop.projectPath || process.cwd();
3819
+ try {
3820
+ const subAgent = new SubAgent({
3821
+ taskId,
3822
+ goal,
3823
+ targetFiles: [], // sub-agent discovers files via tools
3824
+ readFiles: [],
3825
+ maxIterations: Math.min(this.config.loop.maxIterations, 15),
3826
+ projectPath,
3827
+ byokConfig: this.config.byok,
3828
+ tools: this.config.loop.tools.map((t) => t.name),
3829
+ createToolExecutor: (_workDir, _enabledTools) => this.toolExecutor,
3830
+ role,
3831
+ });
3832
+ // Forward sub-agent events as bg_update for TUI visibility
3833
+ subAgent.on("subagent:phase", (tid, phase) => {
3834
+ this.emitEvent({
3835
+ kind: "agent:bg_update",
3836
+ agentId: taskId,
3837
+ agentLabel: `sub-agent (${roleInput})`,
3838
+ eventType: "info",
3839
+ message: `Phase: ${phase}`,
3840
+ timestamp: Date.now(),
3841
+ });
3842
+ });
3843
+ subAgent.on("event", (event) => {
3844
+ // Re-emit sub-agent text/thinking for observability
3845
+ if (event.kind === "agent:text_delta") {
3846
+ this.emitEvent({
3847
+ kind: "agent:bg_update",
3848
+ agentId: taskId,
3849
+ agentLabel: `sub-agent (${roleInput})`,
3850
+ eventType: "info",
3851
+ message: String(event.text ?? ""),
3852
+ timestamp: Date.now(),
3853
+ });
3854
+ }
3855
+ else if (event.kind === "agent:thinking") {
3856
+ this.emitEvent({
3857
+ kind: "agent:bg_update",
3858
+ agentId: taskId,
3859
+ agentLabel: `sub-agent (${roleInput})`,
3860
+ eventType: "info",
3861
+ message: String(event.content ?? ""),
3862
+ timestamp: Date.now(),
3863
+ });
3864
+ }
3865
+ });
3866
+ // Build DAG context for the sub-agent
3867
+ const dagContext = {
3868
+ overallGoal: goal,
3869
+ totalTasks: 1,
3870
+ completedTasks: [],
3871
+ runningTasks: [taskId],
3872
+ };
3873
+ // Run sub-agent
3874
+ const result = await subAgent.run(dagContext);
3875
+ // Build output summary
3876
+ const changedFilesList = result.changedFiles.length > 0
3877
+ ? result.changedFiles.map((f) => f.path).join(", ")
3878
+ : "none";
3879
+ const output = [
3880
+ `## Sub-Agent Result (${roleInput})`,
3881
+ `- Task ID: ${taskId}`,
3882
+ `- Success: ${result.success}`,
3883
+ `- Iterations: ${result.iterations}`,
3884
+ `- Tokens: input=${result.tokensUsed.input}, output=${result.tokensUsed.output}`,
3885
+ `- Changed files: ${changedFilesList}`,
3886
+ ``,
3887
+ `### Summary`,
3888
+ result.summary,
3889
+ result.error ? `\n### Error\n${result.error}` : "",
3890
+ ].join("\n");
3891
+ this.emitEvent({
3892
+ kind: "agent:tool_result",
3893
+ tool: toolCall.name,
3894
+ output: output.length > 200 ? output.slice(0, 200) + "..." : output,
3895
+ durationMs: Date.now() - startTime,
3896
+ });
3897
+ return {
3898
+ tool_call_id: toolCall.id,
3899
+ name: toolCall.name,
3900
+ output,
3901
+ success: result.success,
3902
+ durationMs: Date.now() - startTime,
3903
+ };
3904
+ }
3905
+ catch (err) {
3906
+ const errorMsg = err instanceof Error ? err.message : String(err);
3907
+ this.emitEvent({
3908
+ kind: "agent:bg_update",
3909
+ agentId: taskId,
3910
+ agentLabel: `sub-agent (${roleInput})`,
3911
+ eventType: "error",
3912
+ message: `Sub-agent failed: ${errorMsg}`,
3913
+ timestamp: Date.now(),
3914
+ });
3915
+ return {
3916
+ tool_call_id: toolCall.id,
3917
+ name: toolCall.name,
3918
+ output: `Sub-agent error: ${errorMsg}`,
3919
+ success: false,
3920
+ durationMs: Date.now() - startTime,
3921
+ };
3922
+ }
3923
+ }
3681
3924
  /**
3682
3925
  * Governor의 ApprovalRequiredError를 ApprovalManager로 처리.
3683
3926
  * 승인되면 null 반환 (실행 계속), 거부되면 ToolResult 반환 (실행 차단).