lemura 1.5.2 → 1.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,24 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.5.4] - 2026-05-30
9
+
10
+ ### Fixed
11
+
12
+ - **Goal verification could not recover an incomplete response** (significant): when the goal verifier returned `achieved: false`, the runtime ran a single **tool-less** `complete()` correction whose output was never re-verified (the run was flagged done after the first check) and, in `stream()`, was never sent to the caller. A response missing a step that required a tool (read a file, write output, …) could therefore never actually be completed. The verifier now re-enters the ReAct loop with a corrective user turn so the model has **full tool access** and the corrected answer is **streamed**, bounded by the new `maxGoalCorrections` budget (default `1`). When the budget is exhausted and the goal is still unmet, a visible Goal Verification Warning is appended/streamed as before. Applies to both `run()` and `stream()`.
13
+
14
+ ### Added
15
+
16
+ - **`maxGoalCorrections`** (`SessionConfig`, default `1`): maximum goal-verification corrective re-entries per run. Set to `0` to disable corrective re-entry (a warning is surfaced instead).
17
+ - **`goalProgressReconciliation`** (`SessionConfig`, default `false`): when enabled, the agent periodically (every `goalInjectionN` tool rounds) reconciles which decomposed sub-goals are already complete and marks them done, so the re-injected goal block reflects real progress instead of always showing every sub-goal as pending. Counters goal drift on long runs at the cost of one small LLM call per reconciliation. This wires up `GoalInjector.markSubGoalDone()`, which was previously never invoked.
18
+
19
+ ## [1.5.3] - 2026-05-30
20
+
21
+ ### Fixed
22
+
23
+ - **Tool firewall `ask` decision could execute denied tools** (critical): when a `ToolFirewallConfig.onAsk` handler returned anything other than the exact string `'deny'` — including the boolean `false`, `undefined`, or by throwing — the tool was executed anyway. A user pressing "Stop"/deny could not actually stop the tool call. The `ask` path is now **fail-safe**: only an explicit accept signal (`'accept'` or `true`) allows execution; `'deny'`, `false`, `undefined`, `void`, and thrown errors all block the tool and inject a `Blocked by tool firewall` observation.
24
+ - `ToolFirewallConfig.onAsk` now accepts `'accept' | 'deny' | boolean | void` (and the `Promise` thereof) for ergonomics. The previous `'accept' | 'deny'` return remains valid.
25
+
8
26
  ## [1.5.2] - 2026-05-30
9
27
 
10
28
  ### Changed
package/README.md CHANGED
@@ -1,4 +1,6 @@
1
+ <p align="center">
1
2
  <img src="https://raw.githubusercontent.com/rzafiamy/lemura/main/sites/docs/public/lemura-logo.png" alt="lemura logo" width="200" />
3
+ </p>
2
4
 
3
5
  # lemura
4
6
 
@@ -24,10 +26,11 @@
24
26
 
25
27
  - **🧠 Dynamic Skill Market**: Switch skills on/off at runtime via tags, names, or tool dependencies.
26
28
  - **🔌 Native MCP Support**: Connect to any Model Context Protocol server with custom header support (Auth).
27
- - **🛡️ Tool Firewall**: Fully integrated ask/accept/deny policy layer for secure tool execution.
28
- - **🎯 Goal Maintenance**: LLM-powered sub-goal decomposition and status tracking across turns.
29
- - **🧹 Summary Injection**: Automatically compresses history while ensuring the model never "forgets" the context.
30
- - **🌊 Native Streaming**: Token-by-token completion for smooth, responsive user experiences.
29
+ - **🛡️ Tool Firewall**: Fully integrated ask/accept/deny policy layer for secure tool execution — fail-safe by design.
30
+ - **🎯 Goal Maintenance & Verification**: LLM-powered sub-goal decomposition, progress reconciliation, and post-run goal verification that re-enters the loop with full tool access to finish incomplete answers.
31
+ - **🧹 Context Compression**: Sandwich, history, and summary-injection strategies keep long conversations within budget while ensuring the model never "forgets" the context.
32
+ - **🌊 Native Streaming**: `run()` and `stream()` share one ReAct core — `stream()` completes all tool use and verification, then emits the final answer token-by-token.
33
+ - **📚 RAG Connector**: Pluggable `IRAGAdapter` interface plus a bundled in-memory adapter for ingest → query → context injection.
31
34
  - **📊 Observability**: Detailed tracing, token tracking, and structured logging with actionable hints.
32
35
 
33
36
  ## 🚀 Install
@@ -142,13 +145,29 @@ Explore the architecture and advanced capabilities of `lemura` at [lemura.makix.
142
145
 
143
146
  | Export | Description |
144
147
  |---|---|
145
- | `SessionManager` | The main entry point orchestrating the ReAct loop and tools. |
146
- | `ContextManager` | Manages the conversation history using compression strategies. |
147
- | `OpenAICompatibleAdapter` | Reference adapter for OpenAI, Groq, Together, etc. |
148
- | `ToolRegistry` | Registers and executes tools for the agent. |
148
+ | `SessionManager` | The main entry point orchestrating the ReAct loop, tools, goals, and streaming. |
149
+ | `ContextManager` | Manages the conversation history using pluggable compression strategies. |
150
+ | `OpenAICompatibleAdapter` | Reference adapter for OpenAI, Groq, Together, Ollama, and any OpenAI-compatible endpoint. |
151
+ | `ToolRegistry` | Registers, validates, and executes tools with timeout and budget enforcement. |
149
152
  | `SkillInjector` | Loads and formats YAML/Markdown skills into system prompts. |
153
+ | `MCPClient` / `MCPClientRegistry` | Connect to Model Context Protocol servers and register their tools. |
154
+ | `InMemoryRAGAdapter` | Self-contained RAG adapter for testing the ingest → query round-trip. |
150
155
  | `DefaultLogger` | Colorized logger with Problem/Hints metadata support. |
151
156
 
157
+ ### Subpath Exports
158
+
159
+ Each layer is independently importable so consumers only bundle what they use:
160
+
161
+ ```ts
162
+ import { SandwichCompressionStrategy } from 'lemura/context';
163
+ import { OpenAICompatibleAdapter } from 'lemura/adapters';
164
+ import { ToolRegistry } from 'lemura/tools';
165
+ import { SkillInjector } from 'lemura/skills';
166
+ import { InMemoryRAGAdapter } from 'lemura/rag';
167
+ import { MCPClient } from 'lemura/mcp';
168
+ import { DefaultLogger } from 'lemura/logger';
169
+ ```
170
+
152
171
  ## 🪵 Logging and Tracing
153
172
 
154
173
  `lemura` features a premium, structured logging system designed for developer experience. It provides colorized output and actionable hints for errors.
@@ -190,7 +209,7 @@ When an error occurs (like an invalid API key), `lemura` provides beautiful, str
190
209
 
191
210
  ## 🤝 Contributing
192
211
 
193
- We welcome contributions! Please read our [Internal Rules](./.cursor/rules/Project.md) and [Documentation Guidelines](./.cursor/rules/Documentation.md) before submitting a PR.
212
+ We welcome contributions! Please read our [Internal Rules](./.agent/rules/Project.md) and [Documentation Guidelines](./.agent/rules/Documentation.md) before submitting a PR.
194
213
 
195
214
  ## 📄 License
196
215
 
@@ -199,9 +199,14 @@ interface ToolFirewallConfig {
199
199
  rules?: ToolFirewallRule[];
200
200
  /**
201
201
  * Called when a tool hits the 'ask' decision.
202
- * Return 'accept' or 'deny'. If omitted, 'ask' behaves like 'deny'.
202
+ *
203
+ * Return `'accept'` (or `true`) to allow the tool to run; return `'deny'`
204
+ * (or `false`) to block it. The decision is **fail-safe**: only an explicit
205
+ * accept signal allows execution — any other value (`'deny'`, `false`,
206
+ * `undefined`, `null`, a thrown error) blocks the tool. If `onAsk` is
207
+ * omitted entirely, an `'ask'` decision behaves like `'deny'`.
203
208
  */
204
- onAsk?: (toolName: string, argsJson: string) => Promise<'accept' | 'deny'> | 'accept' | 'deny';
209
+ onAsk?: (toolName: string, argsJson: string) => Promise<'accept' | 'deny' | boolean | void> | 'accept' | 'deny' | boolean | void;
205
210
  }
206
211
  /**
207
212
  * Execution budget constraints for tool calls within a session.
@@ -299,6 +304,24 @@ interface SessionConfig {
299
304
  * @since 1.5.0
300
305
  */
301
306
  enableGoalVerification?: boolean;
307
+ /**
308
+ * Maximum number of goal-verification corrections per run. When verification
309
+ * finds the response incomplete, the agent re-enters the ReAct loop (with full
310
+ * tool access) up to this many times to actually resolve what is missing,
311
+ * rather than emitting a tool-less one-shot rewrite. Defaults to 1.
312
+ * Set to 0 to disable corrective re-entry (a warning is surfaced instead).
313
+ * @since 1.5.4
314
+ */
315
+ maxGoalCorrections?: number;
316
+ /**
317
+ * When true, the agent periodically reconciles which decomposed sub-goals are
318
+ * already complete (one small LLM call every `goalInjectionN` tool rounds) and
319
+ * marks them done, so the re-injected goal block reflects real progress instead
320
+ * of always showing every sub-goal as pending. Counters goal drift on long runs.
321
+ * Defaults to false (no extra calls). Requires `enableGoalPlanning`.
322
+ * @since 1.5.4
323
+ */
324
+ goalProgressReconciliation?: boolean;
302
325
  goalInjectionFrequency?: 'always' | 'every_N_turns' | 'on_compression';
303
326
  goalInjectionPosition?: 'system_prompt' | 'pre_turn';
304
327
  /** Skill budget — max tokens the skill injection block may consume */
@@ -199,9 +199,14 @@ interface ToolFirewallConfig {
199
199
  rules?: ToolFirewallRule[];
200
200
  /**
201
201
  * Called when a tool hits the 'ask' decision.
202
- * Return 'accept' or 'deny'. If omitted, 'ask' behaves like 'deny'.
202
+ *
203
+ * Return `'accept'` (or `true`) to allow the tool to run; return `'deny'`
204
+ * (or `false`) to block it. The decision is **fail-safe**: only an explicit
205
+ * accept signal allows execution — any other value (`'deny'`, `false`,
206
+ * `undefined`, `null`, a thrown error) blocks the tool. If `onAsk` is
207
+ * omitted entirely, an `'ask'` decision behaves like `'deny'`.
203
208
  */
204
- onAsk?: (toolName: string, argsJson: string) => Promise<'accept' | 'deny'> | 'accept' | 'deny';
209
+ onAsk?: (toolName: string, argsJson: string) => Promise<'accept' | 'deny' | boolean | void> | 'accept' | 'deny' | boolean | void;
205
210
  }
206
211
  /**
207
212
  * Execution budget constraints for tool calls within a session.
@@ -299,6 +304,24 @@ interface SessionConfig {
299
304
  * @since 1.5.0
300
305
  */
301
306
  enableGoalVerification?: boolean;
307
+ /**
308
+ * Maximum number of goal-verification corrections per run. When verification
309
+ * finds the response incomplete, the agent re-enters the ReAct loop (with full
310
+ * tool access) up to this many times to actually resolve what is missing,
311
+ * rather than emitting a tool-less one-shot rewrite. Defaults to 1.
312
+ * Set to 0 to disable corrective re-entry (a warning is surfaced instead).
313
+ * @since 1.5.4
314
+ */
315
+ maxGoalCorrections?: number;
316
+ /**
317
+ * When true, the agent periodically reconciles which decomposed sub-goals are
318
+ * already complete (one small LLM call every `goalInjectionN` tool rounds) and
319
+ * marks them done, so the re-injected goal block reflects real progress instead
320
+ * of always showing every sub-goal as pending. Counters goal drift on long runs.
321
+ * Defaults to false (no extra calls). Requires `enableGoalPlanning`.
322
+ * @since 1.5.4
323
+ */
324
+ goalProgressReconciliation?: boolean;
302
325
  goalInjectionFrequency?: 'always' | 'every_N_turns' | 'on_compression';
303
326
  goalInjectionPosition?: 'system_prompt' | 'pre_turn';
304
327
  /** Skill budget — max tokens the skill injection block may consume */
package/dist/index.d.mts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { a as IProviderAdapter, d as TranscriptionRequest, e as TranscriptionResponse, f as SynthesisRequest, A as AudioChunk, V as VisionRequest, g as VisionResponse, h as ImageGenRequest, i as ImageGenResponse, M as ModelInfo, C as ContextWindow, T as Turn, j as ContentBlock, I as IToolDefinition } from './adapters-DAzmrg4l.mjs';
2
2
  export { k as CompletionChunk, l as CompletionRequest, m as CompletionResponse, b as IContextStrategy, c as IScratchpadAdapter, n as IStorageAdapter, N as NormalizedMessage, o as STMItem, p as STMRegistryConfig, S as ShortTermMemoryRegistry, q as TokenUsage, r as ToolCall, s as ToolContext, t as ToolResult } from './adapters-DAzmrg4l.mjs';
3
- import { S as SessionConfig, G as Goal, I as IToolResponseProcessor, T as ToolResponseEvaluation, M as MCPServerConfig, a as MCPToolDefinition } from './agent-DPSUNlK6.mjs';
4
- export { b as GoalInjector, c as GoalVerifierResult, d as MCPJsonRpcRequest, e as MCPJsonRpcResponse, f as MCPTransportType, g as MediaConfig, h as ToolDecision, i as ToolExecutionBudget, j as ToolFirewallConfig, k as ToolFirewallRule, l as TraceEvent } from './agent-DPSUNlK6.mjs';
3
+ import { S as SessionConfig, G as Goal, I as IToolResponseProcessor, T as ToolResponseEvaluation, M as MCPServerConfig, a as MCPToolDefinition } from './agent-UBaqufhp.mjs';
4
+ export { b as GoalInjector, c as GoalVerifierResult, d as MCPJsonRpcRequest, e as MCPJsonRpcResponse, f as MCPTransportType, g as MediaConfig, h as ToolDecision, i as ToolExecutionBudget, j as ToolFirewallConfig, k as ToolFirewallRule, l as TraceEvent } from './agent-UBaqufhp.mjs';
5
5
  export { LemuraAdapterError, LemuraContextOverflowError, LemuraError, LemuraMCPConnectionError, LemuraMCPError, LemuraMCPTimeoutError, LemuraMaxIterationsError, LemuraSkillInjectionError, LemuraToolNotFoundError, LemuraToolTimeoutError, LemuraToolValidationError } from './types/index.mjs';
6
6
  import { I as ILogger } from './logger-DxvKliuk.mjs';
7
7
  export { L as LogLevel, a as LogMetadata, S as Severity } from './logger-DxvKliuk.mjs';
@@ -365,6 +365,15 @@ declare class SessionManager {
365
365
  * manually set via `setGoal()`.
366
366
  */
367
367
  private _runMiniPlanningStep;
368
+ /**
369
+ * Reconciles sub-goal completion against the recent conversation so the
370
+ * re-injected goal block reflects real progress (anti-drift). Runs only when
371
+ * `goalProgressReconciliation` is enabled, and is a no-op when there are no
372
+ * pending sub-goals. One small, non-fatal LLM call; failures are swallowed.
373
+ *
374
+ * @since 1.5.4
375
+ */
376
+ private _reconcileSubGoals;
368
377
  /** Builds the system prompt, injecting skills and goal if configured. */
369
378
  private buildSystemPrompt;
370
379
  /** Builds the messages array for the provider from the current context. */
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { a as IProviderAdapter, d as TranscriptionRequest, e as TranscriptionResponse, f as SynthesisRequest, A as AudioChunk, V as VisionRequest, g as VisionResponse, h as ImageGenRequest, i as ImageGenResponse, M as ModelInfo, C as ContextWindow, T as Turn, j as ContentBlock, I as IToolDefinition } from './adapters-CIRkrCHl.js';
2
2
  export { k as CompletionChunk, l as CompletionRequest, m as CompletionResponse, b as IContextStrategy, c as IScratchpadAdapter, n as IStorageAdapter, N as NormalizedMessage, o as STMItem, p as STMRegistryConfig, S as ShortTermMemoryRegistry, q as TokenUsage, r as ToolCall, s as ToolContext, t as ToolResult } from './adapters-CIRkrCHl.js';
3
- import { S as SessionConfig, G as Goal, I as IToolResponseProcessor, T as ToolResponseEvaluation, M as MCPServerConfig, a as MCPToolDefinition } from './agent-Cq_oRvoc.js';
4
- export { b as GoalInjector, c as GoalVerifierResult, d as MCPJsonRpcRequest, e as MCPJsonRpcResponse, f as MCPTransportType, g as MediaConfig, h as ToolDecision, i as ToolExecutionBudget, j as ToolFirewallConfig, k as ToolFirewallRule, l as TraceEvent } from './agent-Cq_oRvoc.js';
3
+ import { S as SessionConfig, G as Goal, I as IToolResponseProcessor, T as ToolResponseEvaluation, M as MCPServerConfig, a as MCPToolDefinition } from './agent-CknicweT.js';
4
+ export { b as GoalInjector, c as GoalVerifierResult, d as MCPJsonRpcRequest, e as MCPJsonRpcResponse, f as MCPTransportType, g as MediaConfig, h as ToolDecision, i as ToolExecutionBudget, j as ToolFirewallConfig, k as ToolFirewallRule, l as TraceEvent } from './agent-CknicweT.js';
5
5
  export { LemuraAdapterError, LemuraContextOverflowError, LemuraError, LemuraMCPConnectionError, LemuraMCPError, LemuraMCPTimeoutError, LemuraMaxIterationsError, LemuraSkillInjectionError, LemuraToolNotFoundError, LemuraToolTimeoutError, LemuraToolValidationError } from './types/index.js';
6
6
  import { I as ILogger } from './logger-DxvKliuk.js';
7
7
  export { L as LogLevel, a as LogMetadata, S as Severity } from './logger-DxvKliuk.js';
@@ -365,6 +365,15 @@ declare class SessionManager {
365
365
  * manually set via `setGoal()`.
366
366
  */
367
367
  private _runMiniPlanningStep;
368
+ /**
369
+ * Reconciles sub-goal completion against the recent conversation so the
370
+ * re-injected goal block reflects real progress (anti-drift). Runs only when
371
+ * `goalProgressReconciliation` is enabled, and is a no-op when there are no
372
+ * pending sub-goals. One small, non-fatal LLM call; failures are swallowed.
373
+ *
374
+ * @since 1.5.4
375
+ */
376
+ private _reconcileSubGoals;
368
377
  /** Builds the system prompt, injecting skills and goal if configured. */
369
378
  private buildSystemPrompt;
370
379
  /** Builds the messages array for the provider from the current context. */
package/dist/index.js CHANGED
@@ -3028,6 +3028,64 @@ Respond ONLY with valid JSON (no markdown, no explanations):
3028
3028
  this.logger.warn(`[GoalInjector] Mini-planning step failed: ${err.message ?? String(err)}`);
3029
3029
  }
3030
3030
  }
3031
+ /**
3032
+ * Reconciles sub-goal completion against the recent conversation so the
3033
+ * re-injected goal block reflects real progress (anti-drift). Runs only when
3034
+ * `goalProgressReconciliation` is enabled, and is a no-op when there are no
3035
+ * pending sub-goals. One small, non-fatal LLM call; failures are swallowed.
3036
+ *
3037
+ * @since 1.5.4
3038
+ */
3039
+ async _reconcileSubGoals() {
3040
+ if (!this.config.goalProgressReconciliation) return;
3041
+ if (!this.goalInjector) return;
3042
+ const goal = this.goalInjector.getGoal();
3043
+ const completed = new Set(goal.completedSubGoals ?? []);
3044
+ const pending = (goal.decomposition ?? []).filter((sg) => !completed.has(sg));
3045
+ if (pending.length === 0) return;
3046
+ const recentTurns = this.context.turns.slice(-6).map((t) => {
3047
+ const text = typeof t.content === "string" ? t.content : JSON.stringify(t.content);
3048
+ return `[${t.role}]: ${text.slice(0, 400)}`;
3049
+ }).join("\n\n");
3050
+ const pendingList = pending.map((sg, i) => `${i + 1}. ${sg}`).join("\n");
3051
+ try {
3052
+ const response = await this.adapter.complete({
3053
+ model: this.config.model,
3054
+ temperature: 0,
3055
+ maxTokens: 256,
3056
+ messages: [
3057
+ {
3058
+ role: "system",
3059
+ content: 'You track sub-goal progress. Given pending sub-goals and the recent conversation, return ONLY the 1-based indices of sub-goals that are now fully completed. Respond with valid JSON only, no prose: {"completed": number[]}'
3060
+ },
3061
+ {
3062
+ role: "user",
3063
+ content: `Pending sub-goals:
3064
+ ${pendingList}
3065
+
3066
+ Recent conversation:
3067
+ ${recentTurns}
3068
+
3069
+ Which pending sub-goals are now fully completed?`
3070
+ }
3071
+ ]
3072
+ });
3073
+ const match = response.content.match(/\{[\s\S]*\}/);
3074
+ if (!match) return;
3075
+ const parsed = JSON.parse(match[0]);
3076
+ if (!Array.isArray(parsed.completed)) return;
3077
+ for (const idx of parsed.completed) {
3078
+ const sg = pending[idx - 1];
3079
+ if (sg) {
3080
+ this.goalInjector.markSubGoalDone(sg);
3081
+ this.emitTrace("planning", "subgoal_done", { subGoal: sg });
3082
+ }
3083
+ }
3084
+ this.context.metadata["goal"] = this.goalInjector.getGoal();
3085
+ } catch (err) {
3086
+ this.logger.warn(`[GoalInjector] Sub-goal reconciliation failed (non-fatal): ${err.message ?? String(err)}`);
3087
+ }
3088
+ }
3031
3089
  // -----------------------------------------------------------------------
3032
3090
  // Internal helpers
3033
3091
  // -----------------------------------------------------------------------
@@ -3260,8 +3318,17 @@ ${blocks.join("\n\n")}
3260
3318
  }
3261
3319
  if (firewall.decision === "ask") {
3262
3320
  if (this.config.toolFirewall?.onAsk) {
3263
- const userDecision = await this.config.toolFirewall.onAsk(toolName, argsJson);
3264
- if (userDecision === "deny") {
3321
+ let accepted = false;
3322
+ try {
3323
+ const userDecision = await this.config.toolFirewall.onAsk(toolName, argsJson);
3324
+ accepted = userDecision === "accept" || userDecision === true;
3325
+ } catch (e) {
3326
+ this.logger.warn(`Tool firewall onAsk handler threw \u2014 treating as deny: ${toolName}`, {
3327
+ error: e instanceof Error ? e.message : String(e)
3328
+ });
3329
+ accepted = false;
3330
+ }
3331
+ if (!accepted) {
3265
3332
  this.logger.warn(`Tool blocked by firewall (ask \u2192 deny): ${toolName}`, { reason: firewall.reason });
3266
3333
  toolResults.push({ toolCallId, content: `Blocked by tool firewall: ${firewall.reason}` });
3267
3334
  return false;
@@ -3483,6 +3550,7 @@ ${blocks.join("\n\n")}
3483
3550
  this.iterations = 0;
3484
3551
  this.stepCounter = new StepCounter(this.config.maxSteps ?? 20);
3485
3552
  const maxCompletionTokens = this.config.maxCompletionTokens ?? 4e3;
3553
+ let correctionsRemaining = this.config.maxGoalCorrections ?? 1;
3486
3554
  while (this.iterations < maxIts) {
3487
3555
  this.iterations++;
3488
3556
  this.logger.debug(`[stream] ReAct Iteration ${this.iterations}/${maxIts}`);
@@ -3544,6 +3612,9 @@ ${blocks.join("\n\n")}
3544
3612
  if (this.config.onTurn) this.config.onTurn(toolTurn);
3545
3613
  }
3546
3614
  if (this.goalInjector) this.goalInjector.incrementTurn();
3615
+ if (this.config.goalProgressReconciliation && this.iterations % (this.config.goalInjectionN ?? 3) === 0) {
3616
+ await this._reconcileSubGoals();
3617
+ }
3547
3618
  continue;
3548
3619
  }
3549
3620
  this.context.tokenCount = this.context.turns.reduce((sum, t) => sum + t.tokenCount, 0) + this.adapter.estimateTokens(this.context.systemPrompt || "");
@@ -3585,60 +3656,47 @@ ${blocks.join("\n\n")}
3585
3656
  if (this.config.onTurn) this.config.onTurn(finalTurn);
3586
3657
  if (finalFinishReason === "stop") {
3587
3658
  const verdict = await this._verifyGoal(this.context.turns);
3588
- if (verdict && !verdict.achieved && verdict.missing) {
3589
- this.logger.info(`[GoalVerifier] Incomplete \u2014 running silent correction: "${verdict.missing}"`);
3659
+ if (verdict && !verdict.achieved && verdict.missing && correctionsRemaining > 0 && this.iterations < maxIts) {
3660
+ correctionsRemaining--;
3661
+ this.logger.info(`[GoalVerifier] Incomplete \u2014 re-entering loop with tools to correct: "${verdict.missing}" (${correctionsRemaining} correction(s) left)`);
3590
3662
  this.emitTrace("verification", "goal_correction_start", {
3591
3663
  missing: verdict.missing,
3592
- reason: verdict.reason
3664
+ reason: verdict.reason,
3665
+ correctionsRemaining
3593
3666
  });
3594
- try {
3595
- const corrMsgs = this.buildMessages(this.buildSystemPrompt(verdict.missing, this.iterations), this.iterations);
3596
- corrMsgs.push({ role: "user", content: verdict.missing });
3597
- const correction = await this.adapter.complete({
3598
- model: this.config.model,
3599
- messages: corrMsgs,
3600
- maxTokens: maxCompletionTokens
3601
- });
3602
- if (correction.content) {
3603
- this.context.turns.push({
3604
- role: "assistant",
3605
- content: correction.content,
3606
- tokenCount: correction.usage?.completionTokens ?? this.adapter.estimateTokens(correction.content),
3607
- turnIndex: this.context.turns.length,
3608
- compressed: false
3609
- });
3610
- this.emitTrace("verification", "goal_correction_done", {
3611
- missing: verdict.missing,
3612
- correctionTokens: correction.usage?.completionTokens
3613
- });
3614
- }
3615
- } catch (err) {
3616
- const errMsg = err.message;
3617
- this.logger.warn(`[GoalVerifier] Correction failed (non-fatal): ${errMsg}`);
3618
- this.emitTrace("error", "goal_correction_failed", { error: errMsg }, null, null, "error");
3619
- }
3620
- const finalVerdict = await this._verifyGoal(this.context.turns);
3621
- if (finalVerdict && !finalVerdict.achieved) {
3622
- this.logger.warn(`[GoalVerifier] Final verification failed: ${finalVerdict.reason}`);
3623
- this.emitTrace("verification", "goal_verification_result", {
3624
- achieved: false,
3625
- reason: finalVerdict.reason,
3626
- missing: finalVerdict.missing
3627
- }, null, null, "error");
3628
- const warningBlock = `
3667
+ const directive = `[Goal verification found the previous response incomplete. Continue working \u2014 use tools as needed \u2014 to address what is still missing, then provide the complete final answer.]
3668
+
3669
+ Still missing: ${verdict.missing}`;
3670
+ this.context.turns.push({
3671
+ role: "user",
3672
+ content: directive,
3673
+ tokenCount: this.adapter.estimateTokens(directive),
3674
+ turnIndex: this.context.turns.length,
3675
+ compressed: false
3676
+ });
3677
+ if (this.goalInjector) this.goalInjector.incrementTurn();
3678
+ continue;
3679
+ }
3680
+ if (verdict && !verdict.achieved) {
3681
+ this.logger.warn(`[GoalVerifier] Goal still unmet after corrections: ${verdict.reason}`);
3682
+ this.emitTrace("verification", "goal_verification_result", {
3683
+ achieved: false,
3684
+ reason: verdict.reason,
3685
+ missing: verdict.missing
3686
+ }, null, null, "error");
3687
+ const warningBlock = `
3629
3688
 
3630
3689
  ---
3631
3690
 
3632
3691
  \u26A0\uFE0F **Goal Verification Warning**
3633
3692
  * **Status:** Success criteria not fully met.
3634
- * **Reason:** ${finalVerdict.reason ?? "Unknown"}
3635
- * **Missing:** ${finalVerdict.missing ?? "Not specified"}
3693
+ * **Reason:** ${verdict.reason ?? "Unknown"}
3694
+ * **Missing:** ${verdict.missing ?? "Not specified"}
3636
3695
 
3637
3696
  `;
3638
- yield warningBlock;
3639
- const lastTurn = [...this.context.turns].reverse().find((t) => t.role === "assistant");
3640
- if (lastTurn) lastTurn.content = lastTurn.content + warningBlock;
3641
- }
3697
+ yield warningBlock;
3698
+ const lastTurn = [...this.context.turns].reverse().find((t) => t.role === "assistant");
3699
+ if (lastTurn) lastTurn.content = lastTurn.content + warningBlock;
3642
3700
  }
3643
3701
  }
3644
3702
  this.logger.info(`[stream] Streaming run completed`);
@@ -3690,7 +3748,7 @@ ${blocks.join("\n\n")}
3690
3748
  this.iterations = 0;
3691
3749
  this.stepCounter = new StepCounter(this.config.maxSteps ?? 20);
3692
3750
  const maxCompletionTokens = this.config.maxCompletionTokens ?? 4e3;
3693
- let goalVerificationDone = false;
3751
+ let correctionsRemaining = this.config.maxGoalCorrections ?? 1;
3694
3752
  while (this.iterations < maxIts) {
3695
3753
  this.iterations++;
3696
3754
  this.logger.debug(`[${opts.label}] ReAct Iteration ${this.iterations}/${maxIts}`);
@@ -3825,6 +3883,9 @@ ${blocks.join("\n\n")}
3825
3883
  if (this.config.onTurn) this.config.onTurn(toolTurn);
3826
3884
  }
3827
3885
  if (this.goalInjector) this.goalInjector.incrementTurn();
3886
+ if (this.config.goalProgressReconciliation && this.iterations % (this.config.goalInjectionN ?? 3) === 0) {
3887
+ await this._reconcileSubGoals();
3888
+ }
3828
3889
  continue;
3829
3890
  }
3830
3891
  if (response.finishReason === "stop" || response.finishReason === "max_tokens" || response.finishReason === "error") {
@@ -3837,52 +3898,35 @@ ${blocks.join("\n\n")}
3837
3898
  };
3838
3899
  this.context.turns.push(finalTurn);
3839
3900
  if (this.config.onTurn) this.config.onTurn(finalTurn);
3840
- if (response.finishReason === "stop" && !goalVerificationDone) {
3841
- goalVerificationDone = true;
3901
+ if (response.finishReason === "stop") {
3842
3902
  const verdict = await this._verifyGoal(this.context.turns);
3843
- if (verdict && !verdict.achieved && verdict.missing) {
3844
- this.logger.info(`[GoalVerifier] Incomplete \u2014 running silent correction: "${verdict.missing}"`);
3903
+ if (verdict && !verdict.achieved && verdict.missing && correctionsRemaining > 0 && this.iterations < maxIts) {
3904
+ correctionsRemaining--;
3905
+ this.logger.info(`[GoalVerifier] Incomplete \u2014 re-entering loop with tools to correct: "${verdict.missing}" (${correctionsRemaining} correction(s) left)`);
3845
3906
  this.emitTrace("verification", "goal_correction_start", {
3846
3907
  missing: verdict.missing,
3847
- reason: verdict.reason
3908
+ reason: verdict.reason,
3909
+ correctionsRemaining
3848
3910
  });
3849
- try {
3850
- const correctionMessages = this.buildMessages(
3851
- this.buildSystemPrompt(verdict.missing, this.iterations),
3852
- this.iterations
3853
- );
3854
- correctionMessages.push({ role: "user", content: verdict.missing });
3855
- const correction = await this.adapter.complete({
3856
- model: this.config.model,
3857
- messages: correctionMessages,
3858
- maxTokens: maxCompletionTokens
3859
- });
3860
- if (correction.content) {
3861
- this.context.turns.push({
3862
- role: "assistant",
3863
- content: correction.content,
3864
- tokenCount: correction.usage?.completionTokens ?? this.adapter.estimateTokens(correction.content),
3865
- turnIndex: this.context.turns.length,
3866
- compressed: false
3867
- });
3868
- this.emitTrace("verification", "goal_correction_done", {
3869
- missing: verdict.missing,
3870
- correctionTokens: correction.usage?.completionTokens
3871
- });
3872
- }
3873
- } catch (err) {
3874
- const errMsg = err.message;
3875
- this.logger.warn(`[GoalVerifier] Correction failed (non-fatal): ${errMsg}`);
3876
- this.emitTrace("error", "goal_correction_failed", { error: errMsg }, null, null, "error");
3877
- }
3911
+ const directive = `[Goal verification found the previous response incomplete. Continue working \u2014 use tools as needed \u2014 to address what is still missing, then provide the complete final answer.]
3912
+
3913
+ Still missing: ${verdict.missing}`;
3914
+ this.context.turns.push({
3915
+ role: "user",
3916
+ content: directive,
3917
+ tokenCount: this.adapter.estimateTokens(directive),
3918
+ turnIndex: this.context.turns.length,
3919
+ compressed: false
3920
+ });
3921
+ if (this.goalInjector) this.goalInjector.incrementTurn();
3922
+ continue;
3878
3923
  }
3879
- const finalVerdict = await this._verifyGoal(this.context.turns);
3880
- if (finalVerdict && !finalVerdict.achieved) {
3881
- this.logger.warn(`[GoalVerifier] Final verification failed: ${finalVerdict.reason}`);
3924
+ if (verdict && !verdict.achieved) {
3925
+ this.logger.warn(`[GoalVerifier] Goal still unmet after corrections: ${verdict.reason}`);
3882
3926
  this.emitTrace("verification", "goal_verification_result", {
3883
3927
  achieved: false,
3884
- reason: finalVerdict.reason,
3885
- missing: finalVerdict.missing
3928
+ reason: verdict.reason,
3929
+ missing: verdict.missing
3886
3930
  }, null, null, "error");
3887
3931
  const warningBlock = `
3888
3932
 
@@ -3890,8 +3934,8 @@ ${blocks.join("\n\n")}
3890
3934
 
3891
3935
  \u26A0\uFE0F **Goal Verification Warning**
3892
3936
  * **Status:** Success criteria not fully met.
3893
- * **Reason:** ${finalVerdict.reason ?? "Unknown"}
3894
- * **Missing:** ${finalVerdict.missing ?? "Not specified"}
3937
+ * **Reason:** ${verdict.reason ?? "Unknown"}
3938
+ * **Missing:** ${verdict.missing ?? "Not specified"}
3895
3939
 
3896
3940
  `;
3897
3941
  const lastTurn = [...this.context.turns].reverse().find((t) => t.role === "assistant");