lemura 1.4.4 → 1.5.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.
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
- import { a as IProviderAdapter, T as TranscriptionRequest, d as TranscriptionResponse, e as SynthesisRequest, A as AudioChunk, V as VisionRequest, f as VisionResponse, g as ImageGenRequest, h as ImageGenResponse, M as ModelInfo, C as ContextWindow, i as Turn, j as ContentBlock, I as IToolDefinition } from './adapters-CVcfWf85.js';
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-CVcfWf85.js';
3
- import { S as SessionConfig, I as IToolResponseProcessor, T as ToolResponseEvaluation, M as MCPServerConfig, a as MCPToolDefinition } from './agent-BH_uE2nT.js';
4
- export { b as MCPJsonRpcRequest, c as MCPJsonRpcResponse, d as MCPTransportType, e as MediaConfig, f as ToolDecision, g as ToolExecutionBudget, h as ToolFirewallConfig, i as ToolFirewallRule, j as TraceEvent } from './agent-BH_uE2nT.js';
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-DHQOGl8Y.js';
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-DHQOGl8Y.js';
3
+ import { S as SessionConfig, G as Goal, I as IToolResponseProcessor, T as ToolResponseEvaluation, M as MCPServerConfig, a as MCPToolDefinition } from './agent-CjoFQGlk.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-CjoFQGlk.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';
@@ -188,67 +188,6 @@ declare class ContinuationPlanner {
188
188
  private _advanceIndex;
189
189
  }
190
190
 
191
- interface Goal {
192
- id: string;
193
- statement: string;
194
- /** High-level sub-goals decomposed from the main statement */
195
- decomposition: string[];
196
- successCriteria: string[];
197
- injectionFrequency: 'always' | 'every_N_turns' | 'on_compression';
198
- injectionPosition: 'system_prompt' | 'pre_turn';
199
- /** Sub-goals already completed — updated via `markSubGoalDone()` */
200
- completedSubGoals?: string[];
201
- }
202
- /**
203
- * GoalInjector keeps the original task objective visible throughout the ReAct loop,
204
- * preventing goal drift after many tool calls and context compressions.
205
- *
206
- * Usage in SessionManager:
207
- * - For `system_prompt` position: call `injectInto(prompt)` which appends the goal block.
208
- * - For `pre_turn` position: call `getFormattedBlock()` and push as a system message.
209
- */
210
- declare class GoalInjector {
211
- private goal;
212
- private turnsSinceInjection;
213
- constructor(goal: Goal);
214
- /**
215
- * Returns the formatted `[CURRENT GOAL]` block string — without caring about
216
- * where it will be placed. Callers decide whether to append to a system prompt
217
- * or push as a separate message.
218
- */
219
- getFormattedBlock(): string;
220
- /**
221
- * Appends the goal block to the given prompt string (for `system_prompt` position).
222
- * For `pre_turn` position, use `getFormattedBlock()` directly.
223
- *
224
- * @param prompt - The existing system prompt to append to.
225
- */
226
- injectInto(prompt: string): string;
227
- /**
228
- * Returns true when the goal should be re-injected this turn,
229
- * based on `injectionFrequency`.
230
- *
231
- * @param turnIndex - The current turn index in the ReAct loop (0-based)
232
- * @param compressionOccurred - Whether context was compressed this iteration
233
- * @param injectionN - The N for 'every_N_turns' frequency (default: 3)
234
- */
235
- shouldInjectThisTurn(turnIndex: number, compressionOccurred?: boolean, injectionN?: number): boolean;
236
- /**
237
- * Updates the goal with new sub-goal decomposition and success criteria,
238
- * typically populated by the mini-planning LLM call.
239
- */
240
- updateDecomposition(decomposition: string[], successCriteria?: string[]): void;
241
- /**
242
- * Marks a sub-goal as completed so it moves to the "completed" section
243
- * in subsequent injections.
244
- */
245
- markSubGoalDone(subGoal: string): void;
246
- /** Returns a snapshot of the current goal state (safe to store in context.metadata). */
247
- getGoal(): Goal;
248
- /** Increments the internal turn counter (used for `every_N_turns` frequency). */
249
- incrementTurn(): void;
250
- }
251
-
252
191
  /**
253
192
  * Core entry point for lemura agent sessions.
254
193
  *
@@ -449,9 +388,6 @@ declare class SessionManager {
449
388
  /**
450
389
  * Runs the full ReAct loop for a user message and returns the final assistant response.
451
390
  *
452
- * When `enableGoalPlanning` is true and no goal has been manually set, a mini-planning
453
- * LLM call is made before the first iteration to decompose the task into sub-goals.
454
- *
455
391
  * @param userMessage - The user's message (string or multimodal content blocks)
456
392
  * @returns The assistant's final response string
457
393
  * @throws {LemuraMaxIterationsError} When the loop exceeds `maxIterations`
@@ -460,8 +396,8 @@ declare class SessionManager {
460
396
  /**
461
397
  * Runs the ReAct loop and streams the final assistant response token-by-token.
462
398
  *
463
- * Tool calls within the loop are still executed synchronously (they must complete
464
- * before streaming the conclusion). Only the final LLM text output is streamed.
399
+ * All tool calls, goal verification, and corrections complete before any token
400
+ * is yielded the stream delivers only the clean final response.
465
401
  *
466
402
  * @param userMessage - The user's message (string or multimodal content blocks)
467
403
  * @returns An `AsyncIterable<string>` of delta tokens from the final response
@@ -474,6 +410,29 @@ declare class SessionManager {
474
410
  * ```
475
411
  */
476
412
  stream(userMessage: string | ContentBlock[]): AsyncIterable<string>;
413
+ /**
414
+ * Core ReAct execution loop shared by `run()` and `stream()`.
415
+ *
416
+ * Uses `adapter.complete()` exclusively — no streaming occurs here.
417
+ * Goal verification and silent corrections run inside this method,
418
+ * fully isolated from the caller's delivery path.
419
+ *
420
+ * @returns The final assistant response string
421
+ * @throws {LemuraMaxIterationsError} When the loop exceeds `maxIterations`
422
+ */
423
+ private _executeLoop;
424
+ /**
425
+ * Verifies whether the goal was achieved after a `stop` finish.
426
+ *
427
+ * Priority:
428
+ * 1. `config.goalVerifier` callback (Option A — user-supplied)
429
+ * 2. Built-in LLM check against `successCriteria` (Option C — fallback)
430
+ *
431
+ * Returns `null` when verification is skipped (no goal, planning disabled, etc.).
432
+ *
433
+ * @since 1.5.0
434
+ */
435
+ private _verifyGoal;
477
436
  /**
478
437
  * Resets the session: clears conversation history, resets iteration counters,
479
438
  * tool execution budget tallies, and goal/plan state.
@@ -687,4 +646,4 @@ declare class MCPClientRegistry {
687
646
  private _bridge;
688
647
  }
689
648
 
690
- export { AudioChunk, ContentBlock, ContextWindow, type ContinuationPlan, ContinuationPlanner, type ContinuationStep, FinalResponseFormatter, type Goal, GoalInjector, ILogger, IProviderAdapter, IToolDefinition, IToolResponseProcessor, ImageGenRequest, ImageGenResponse, MCPClient, MCPClientRegistry, MCPServerConfig, MCPToolDefinition, MediaBridge, ModelInfo, SessionConfig, SessionManager, SkillInjector, type StepCondition, StepCounter, type StepVerifier, type StepVerifierResult, SynthesisRequest, ToolRegistry, ToolResponseEvaluation, ToolResponseProcessor, type ToolResponseProcessorConfig, TranscriptionRequest, TranscriptionResponse, Turn, VisionRequest, VisionResponse };
649
+ export { AudioChunk, ContentBlock, ContextWindow, type ContinuationPlan, ContinuationPlanner, type ContinuationStep, FinalResponseFormatter, Goal, ILogger, IProviderAdapter, IToolDefinition, IToolResponseProcessor, ImageGenRequest, ImageGenResponse, MCPClient, MCPClientRegistry, MCPServerConfig, MCPToolDefinition, MediaBridge, ModelInfo, SessionConfig, SessionManager, SkillInjector, type StepCondition, StepCounter, type StepVerifier, type StepVerifierResult, SynthesisRequest, ToolRegistry, ToolResponseEvaluation, ToolResponseProcessor, type ToolResponseProcessorConfig, TranscriptionRequest, TranscriptionResponse, Turn, VisionRequest, VisionResponse };
package/dist/index.js CHANGED
@@ -3320,9 +3320,6 @@ ${planStatus}`;
3320
3320
  /**
3321
3321
  * Runs the full ReAct loop for a user message and returns the final assistant response.
3322
3322
  *
3323
- * When `enableGoalPlanning` is true and no goal has been manually set, a mini-planning
3324
- * LLM call is made before the first iteration to decompose the task into sub-goals.
3325
- *
3326
3323
  * @param userMessage - The user's message (string or multimodal content blocks)
3327
3324
  * @returns The assistant's final response string
3328
3325
  * @throws {LemuraMaxIterationsError} When the loop exceeds `maxIterations`
@@ -3330,6 +3327,194 @@ ${planStatus}`;
3330
3327
  async run(userMessage) {
3331
3328
  if (this.mcpReady) await this.mcpReady;
3332
3329
  await this.ensureScratchpadLoaded();
3330
+ return this._executeLoop(userMessage, { label: "run" });
3331
+ }
3332
+ /**
3333
+ * Runs the ReAct loop and streams the final assistant response token-by-token.
3334
+ *
3335
+ * All tool calls, goal verification, and corrections complete before any token
3336
+ * is yielded — the stream delivers only the clean final response.
3337
+ *
3338
+ * @param userMessage - The user's message (string or multimodal content blocks)
3339
+ * @returns An `AsyncIterable<string>` of delta tokens from the final response
3340
+ *
3341
+ * @example
3342
+ * ```typescript
3343
+ * for await (const token of session.stream('Tell me a story')) {
3344
+ * process.stdout.write(token);
3345
+ * }
3346
+ * ```
3347
+ */
3348
+ async *stream(userMessage) {
3349
+ if (this.mcpReady) await this.mcpReady;
3350
+ await this.ensureScratchpadLoaded();
3351
+ const userMessageStr = Array.isArray(userMessage) ? "[Multimodal Content]" : userMessage;
3352
+ this.logger.info(`Starting streaming session run`, { model: this.config.model, message: userMessageStr });
3353
+ if (this.config.enableGoalPlanning && !this.goalInjector) {
3354
+ this.goalInjector = new GoalInjector({
3355
+ id: "auto",
3356
+ statement: typeof userMessage === "string" ? userMessage : "[multimodal]",
3357
+ decomposition: [],
3358
+ successCriteria: ["The user request is fully answered"],
3359
+ injectionFrequency: this.config.goalInjectionFrequency ?? "always",
3360
+ injectionPosition: this.config.goalInjectionPosition ?? "system_prompt"
3361
+ });
3362
+ this.context.metadata["goal"] = this.goalInjector.getGoal();
3363
+ await this._runMiniPlanningStep(userMessageStr);
3364
+ }
3365
+ this.context.turns.push({
3366
+ role: "user",
3367
+ content: userMessage,
3368
+ tokenCount: Array.isArray(userMessage) ? userMessage.length * 50 : this.adapter.estimateTokens(userMessage),
3369
+ turnIndex: this.context.turns.length,
3370
+ compressed: false
3371
+ });
3372
+ const maxIts = this.config.maxIterations || 10;
3373
+ this.iterations = 0;
3374
+ this.stepCounter = new StepCounter(this.config.maxSteps ?? 20);
3375
+ const maxCompletionTokens = this.config.maxCompletionTokens ?? 4e3;
3376
+ while (this.iterations < maxIts) {
3377
+ this.iterations++;
3378
+ this.logger.debug(`[stream] ReAct Iteration ${this.iterations}/${maxIts}`);
3379
+ this.context.tokenCount = this.context.turns.reduce((sum, t) => sum + t.tokenCount, 0) + this.adapter.estimateTokens(this.context.systemPrompt || "");
3380
+ this.context = await this.contextManager.prepare(this.context);
3381
+ const systemPrompt = this.buildSystemPrompt(userMessageStr, this.iterations);
3382
+ const messages = this.buildMessages(systemPrompt, this.iterations);
3383
+ if (this.stepCounter.isMaxReached()) {
3384
+ messages.push({
3385
+ role: "system",
3386
+ content: this.stepCounter.getForcedConclusionPrompt() + "\n\n" + FinalResponseFormatter.getRequiredStructure()
3387
+ });
3388
+ }
3389
+ let response;
3390
+ try {
3391
+ response = await this.adapter.complete({
3392
+ model: this.config.model,
3393
+ messages,
3394
+ tools: this.stepCounter.isMaxReached() ? [] : this.toolRegistry.getAll(),
3395
+ maxTokens: maxCompletionTokens
3396
+ });
3397
+ } catch (err) {
3398
+ this.logger.fatal(`Provider call failed: ${err.message}`);
3399
+ throw err;
3400
+ }
3401
+ if (response.finishReason === "tool_call" && response.toolCalls) {
3402
+ this.logger.info(`[stream] Tool calls: ${response.toolCalls.map((tc) => tc.name).join(", ")}`);
3403
+ this.stepCounter.increment(response.toolCalls.length);
3404
+ const toolResults = [];
3405
+ for (const tc of response.toolCalls) {
3406
+ const ok = await this.passesFirewall(tc.name, tc.arguments, tc.id, toolResults);
3407
+ if (!ok) continue;
3408
+ try {
3409
+ toolResults.push({ toolCallId: tc.id, content: await this.executeSingleToolCall(tc) });
3410
+ } catch (e) {
3411
+ toolResults.push({ toolCallId: tc.id, content: `Error: ${e instanceof Error ? e.message : String(e)}` });
3412
+ }
3413
+ }
3414
+ const assistantTurn = {
3415
+ role: "assistant",
3416
+ content: response.content || "",
3417
+ tokenCount: this.adapter.estimateTokens(response.content || "") + 50,
3418
+ turnIndex: this.context.turns.length,
3419
+ compressed: false,
3420
+ toolCalls: response.toolCalls
3421
+ };
3422
+ this.context.turns.push(assistantTurn);
3423
+ if (this.config.onTurn) this.config.onTurn(assistantTurn);
3424
+ for (const res of toolResults) {
3425
+ const toolTurn = {
3426
+ role: "tool",
3427
+ content: res.content,
3428
+ tokenCount: this.adapter.estimateTokens(res.content),
3429
+ turnIndex: this.context.turns.length,
3430
+ compressed: false,
3431
+ toolResults: [res]
3432
+ };
3433
+ this.context.turns.push(toolTurn);
3434
+ if (this.config.onTurn) this.config.onTurn(toolTurn);
3435
+ }
3436
+ if (this.goalInjector) this.goalInjector.incrementTurn();
3437
+ continue;
3438
+ }
3439
+ this.context.tokenCount = this.context.turns.reduce((sum, t) => sum + t.tokenCount, 0) + this.adapter.estimateTokens(this.context.systemPrompt || "");
3440
+ this.context = await this.contextManager.prepare(this.context);
3441
+ const finalSystemPrompt = this.buildSystemPrompt(userMessageStr, this.iterations);
3442
+ const finalMessages = this.buildMessages(finalSystemPrompt, this.iterations);
3443
+ let accumulated = "";
3444
+ let finalTokenCount = 0;
3445
+ let finalFinishReason;
3446
+ for await (const chunk of this.adapter.stream({
3447
+ model: this.config.model,
3448
+ messages: finalMessages,
3449
+ maxTokens: maxCompletionTokens,
3450
+ stream: true
3451
+ })) {
3452
+ if (chunk.delta) {
3453
+ accumulated += chunk.delta;
3454
+ finalTokenCount += Math.ceil(chunk.delta.length / 4);
3455
+ yield chunk.delta;
3456
+ }
3457
+ if (chunk.finished) {
3458
+ finalFinishReason = chunk.finishReason;
3459
+ if (chunk.usage) this.totalTokens += chunk.usage.totalTokens;
3460
+ this.emitTrace("thinking", "llm_stream_finished", {
3461
+ usage: chunk.usage,
3462
+ totalTokens: this.totalTokens,
3463
+ finishReason: chunk.finishReason
3464
+ });
3465
+ }
3466
+ }
3467
+ const finalTurn = {
3468
+ role: "assistant",
3469
+ content: accumulated,
3470
+ tokenCount: finalTokenCount,
3471
+ turnIndex: this.context.turns.length,
3472
+ compressed: false
3473
+ };
3474
+ this.context.turns.push(finalTurn);
3475
+ if (this.config.onTurn) this.config.onTurn(finalTurn);
3476
+ if (finalFinishReason === "stop") {
3477
+ const verdict = await this._verifyGoal(this.context.turns);
3478
+ if (verdict && !verdict.achieved && verdict.missing) {
3479
+ this.logger.info(`[GoalVerifier] Incomplete \u2014 running silent correction: "${verdict.missing}"`);
3480
+ try {
3481
+ const corrMsgs = this.buildMessages(this.buildSystemPrompt(verdict.missing, this.iterations), this.iterations);
3482
+ corrMsgs.push({ role: "user", content: verdict.missing });
3483
+ const correction = await this.adapter.complete({
3484
+ model: this.config.model,
3485
+ messages: corrMsgs,
3486
+ maxTokens: maxCompletionTokens
3487
+ });
3488
+ if (correction.content) {
3489
+ this.context.turns.push({
3490
+ role: "assistant",
3491
+ content: correction.content,
3492
+ tokenCount: correction.usage?.completionTokens ?? this.adapter.estimateTokens(correction.content),
3493
+ turnIndex: this.context.turns.length,
3494
+ compressed: false
3495
+ });
3496
+ }
3497
+ } catch (err) {
3498
+ this.logger.warn(`[GoalVerifier] Correction failed (non-fatal): ${err.message}`);
3499
+ }
3500
+ }
3501
+ }
3502
+ this.logger.info(`[stream] Streaming run completed`);
3503
+ return;
3504
+ }
3505
+ throw new LemuraMaxIterationsError(`Exceeded max iterations of ${maxIts}`);
3506
+ }
3507
+ /**
3508
+ * Core ReAct execution loop shared by `run()` and `stream()`.
3509
+ *
3510
+ * Uses `adapter.complete()` exclusively — no streaming occurs here.
3511
+ * Goal verification and silent corrections run inside this method,
3512
+ * fully isolated from the caller's delivery path.
3513
+ *
3514
+ * @returns The final assistant response string
3515
+ * @throws {LemuraMaxIterationsError} When the loop exceeds `maxIterations`
3516
+ */
3517
+ async _executeLoop(userMessage, opts) {
3333
3518
  const userMessageStr = Array.isArray(userMessage) ? "[Multimodal Content]" : userMessage;
3334
3519
  this.logger.info(`Starting new session run`, {
3335
3520
  model: this.config.model,
@@ -3363,13 +3548,14 @@ ${planStatus}`;
3363
3548
  this.iterations = 0;
3364
3549
  this.stepCounter = new StepCounter(this.config.maxSteps ?? 20);
3365
3550
  const maxCompletionTokens = this.config.maxCompletionTokens ?? 4e3;
3551
+ let goalVerificationDone = false;
3366
3552
  while (this.iterations < maxIts) {
3367
3553
  this.iterations++;
3368
- this.logger.debug(`ReAct Iteration ${this.iterations}/${maxIts}`);
3554
+ this.logger.debug(`[${opts.label}] ReAct Iteration ${this.iterations}/${maxIts}`);
3369
3555
  this.context.tokenCount = this.context.turns.reduce((sum, t) => sum + t.tokenCount, 0) + this.adapter.estimateTokens(this.context.systemPrompt || "");
3370
3556
  this.context = await this.contextManager.prepare(this.context);
3371
3557
  const systemPrompt = this.buildSystemPrompt(userMessageStr, this.iterations);
3372
- let messages = this.buildMessages(systemPrompt, this.iterations);
3558
+ const messages = this.buildMessages(systemPrompt, this.iterations);
3373
3559
  if (this.stepCounter.isMaxReached()) {
3374
3560
  this.logger.warn(`maxSteps (${this.config.maxSteps ?? 20}) reached \u2014 forcing final response`);
3375
3561
  messages.push({
@@ -3402,9 +3588,7 @@ ${planStatus}`;
3402
3588
  this.emitTrace("error", "llm_call_failed", { error: e.message ?? String(err) });
3403
3589
  throw err;
3404
3590
  }
3405
- if (response.usage) {
3406
- this.totalTokens += response.usage.totalTokens;
3407
- }
3591
+ if (response.usage) this.totalTokens += response.usage.totalTokens;
3408
3592
  this.emitTrace("thinking", "llm_call", {
3409
3593
  model: this.config.model,
3410
3594
  usage: response.usage,
@@ -3499,7 +3683,37 @@ ${planStatus}`;
3499
3683
  };
3500
3684
  this.context.turns.push(finalTurn);
3501
3685
  if (this.config.onTurn) this.config.onTurn(finalTurn);
3502
- this.logger.info(`Run completed successfully`);
3686
+ if (response.finishReason === "stop" && !goalVerificationDone) {
3687
+ goalVerificationDone = true;
3688
+ const verdict = await this._verifyGoal(this.context.turns);
3689
+ if (verdict && !verdict.achieved && verdict.missing) {
3690
+ this.logger.info(`[GoalVerifier] Incomplete \u2014 running silent correction: "${verdict.missing}"`);
3691
+ try {
3692
+ const correctionMessages = this.buildMessages(
3693
+ this.buildSystemPrompt(verdict.missing, this.iterations),
3694
+ this.iterations
3695
+ );
3696
+ correctionMessages.push({ role: "user", content: verdict.missing });
3697
+ const correction = await this.adapter.complete({
3698
+ model: this.config.model,
3699
+ messages: correctionMessages,
3700
+ maxTokens: maxCompletionTokens
3701
+ });
3702
+ if (correction.content) {
3703
+ this.context.turns.push({
3704
+ role: "assistant",
3705
+ content: correction.content,
3706
+ tokenCount: correction.usage?.completionTokens ?? this.adapter.estimateTokens(correction.content),
3707
+ turnIndex: this.context.turns.length,
3708
+ compressed: false
3709
+ });
3710
+ }
3711
+ } catch (err) {
3712
+ this.logger.warn(`[GoalVerifier] Correction failed (non-fatal): ${err.message}`);
3713
+ }
3714
+ }
3715
+ }
3716
+ this.logger.info(`[${opts.label}] Run completed successfully`);
3503
3717
  return response.content;
3504
3718
  }
3505
3719
  }
@@ -3514,165 +3728,93 @@ ${planStatus}`;
3514
3728
  throw maxItsErr;
3515
3729
  }
3516
3730
  /**
3517
- * Runs the ReAct loop and streams the final assistant response token-by-token.
3731
+ * Verifies whether the goal was achieved after a `stop` finish.
3518
3732
  *
3519
- * Tool calls within the loop are still executed synchronously (they must complete
3520
- * before streaming the conclusion). Only the final LLM text output is streamed.
3733
+ * Priority:
3734
+ * 1. `config.goalVerifier` callback (Option A user-supplied)
3735
+ * 2. Built-in LLM check against `successCriteria` (Option C — fallback)
3521
3736
  *
3522
- * @param userMessage - The user's message (string or multimodal content blocks)
3523
- * @returns An `AsyncIterable<string>` of delta tokens from the final response
3737
+ * Returns `null` when verification is skipped (no goal, planning disabled, etc.).
3524
3738
  *
3525
- * @example
3526
- * ```typescript
3527
- * for await (const token of session.stream('Tell me a story')) {
3528
- * process.stdout.write(token);
3529
- * }
3530
- * ```
3739
+ * @since 1.5.0
3531
3740
  */
3532
- async *stream(userMessage) {
3533
- if (this.mcpReady) await this.mcpReady;
3534
- await this.ensureScratchpadLoaded();
3535
- const userMessageStr = Array.isArray(userMessage) ? "[Multimodal Content]" : userMessage;
3536
- this.logger.info(`Starting streaming session run`, {
3537
- model: this.config.model,
3538
- message: userMessageStr
3539
- });
3540
- if (this.config.enableGoalPlanning && !this.goalInjector) {
3541
- this.goalInjector = new GoalInjector({
3542
- id: "auto",
3543
- statement: typeof userMessage === "string" ? userMessage : "[multimodal]",
3544
- decomposition: [],
3545
- successCriteria: ["The user request is fully answered"],
3546
- injectionFrequency: this.config.goalInjectionFrequency ?? "always",
3547
- injectionPosition: this.config.goalInjectionPosition ?? "system_prompt"
3548
- });
3549
- this.context.metadata["goal"] = this.goalInjector.getGoal();
3550
- await this._runMiniPlanningStep(userMessageStr);
3551
- }
3552
- this.context.turns.push({
3553
- role: "user",
3554
- content: userMessage,
3555
- tokenCount: Array.isArray(userMessage) ? userMessage.length * 50 : this.adapter.estimateTokens(userMessage),
3556
- turnIndex: this.context.turns.length,
3557
- compressed: false
3558
- });
3559
- const maxIts = this.config.maxIterations || 10;
3560
- this.iterations = 0;
3561
- this.stepCounter = new StepCounter(this.config.maxSteps ?? 20);
3562
- const maxCompletionTokens = this.config.maxCompletionTokens ?? 4e3;
3563
- while (this.iterations < maxIts) {
3564
- this.iterations++;
3565
- this.logger.debug(`[stream] ReAct Iteration ${this.iterations}/${maxIts}`);
3566
- this.context.tokenCount = this.context.turns.reduce((sum, t) => sum + t.tokenCount, 0) + this.adapter.estimateTokens(this.context.systemPrompt || "");
3567
- this.context = await this.contextManager.prepare(this.context);
3568
- const systemPrompt = this.buildSystemPrompt(userMessageStr, this.iterations);
3569
- const messages = this.buildMessages(systemPrompt, this.iterations);
3570
- if (this.stepCounter.isMaxReached()) {
3571
- messages.push({
3572
- role: "system",
3573
- content: this.stepCounter.getForcedConclusionPrompt() + "\n\n" + FinalResponseFormatter.getRequiredStructure()
3741
+ async _verifyGoal(turns) {
3742
+ if (!this.config.enableGoalPlanning || !this.goalInjector) return null;
3743
+ if (this.config.enableGoalVerification === false) return null;
3744
+ const goal = this.goalInjector.getGoal();
3745
+ if (!goal.statement) return null;
3746
+ this.emitTrace("verification", "goal_verification_start", { goalStatement: goal.statement });
3747
+ try {
3748
+ if (this.config.goalVerifier) {
3749
+ const result = await this.config.goalVerifier(goal, turns);
3750
+ this.emitTrace("verification", "goal_verification_result", {
3751
+ achieved: result.achieved,
3752
+ reason: result.reason,
3753
+ missing: result.missing,
3754
+ source: "custom"
3574
3755
  });
3575
- }
3576
- let response;
3577
- try {
3578
- response = await this.adapter.complete({
3756
+ return result;
3757
+ }
3758
+ const GENERIC_CRITERION = "The user request is fully answered";
3759
+ const meaningfulCriteria = goal.successCriteria?.filter((c) => c !== GENERIC_CRITERION) ?? [];
3760
+ if (meaningfulCriteria.length > 0) {
3761
+ const recentTurns = turns.slice(-6).map((t) => {
3762
+ const text = typeof t.content === "string" ? t.content : JSON.stringify(t.content);
3763
+ return `[${t.role}]: ${text.slice(0, 400)}`;
3764
+ }).join("\n\n");
3765
+ const criteriaList = meaningfulCriteria.map((c, i) => `${i + 1}. ${c}`).join("\n");
3766
+ const response = await this.adapter.complete({
3579
3767
  model: this.config.model,
3580
- messages,
3581
- tools: this.stepCounter.isMaxReached() ? [] : this.toolRegistry.getAll(),
3582
- maxTokens: maxCompletionTokens
3768
+ temperature: 0,
3769
+ maxTokens: 256,
3770
+ messages: [
3771
+ {
3772
+ role: "system",
3773
+ content: 'You are a strict goal-completion verifier. Respond ONLY with a valid JSON object \u2014 no markdown, no prose:\n{"achieved": true|false, "reason": "<short explanation>", "missing": "<what is still needed, or empty string>"}'
3774
+ },
3775
+ {
3776
+ role: "user",
3777
+ content: `Goal: ${goal.statement}
3778
+
3779
+ Success criteria:
3780
+ ${criteriaList}
3781
+
3782
+ Recent conversation:
3783
+ ${recentTurns}
3784
+
3785
+ Were ALL success criteria met?`
3786
+ }
3787
+ ]
3583
3788
  });
3584
- } catch (err) {
3585
- this.logger.fatal(`Provider call failed: ${err.message}`);
3586
- throw err;
3587
- }
3588
- if (response.finishReason === "tool_call" && response.toolCalls) {
3589
- this.logger.info(`[stream] Tool calls: ${response.toolCalls.map((tc) => tc.name).join(", ")}`);
3590
- this.stepCounter.increment(response.toolCalls.length);
3591
- const toolResults = [];
3592
- for (const tc of response.toolCalls) {
3593
- const ok = await this.passesFirewall(tc.name, tc.arguments, tc.id, toolResults);
3594
- if (!ok) continue;
3595
- try {
3596
- const content = await this.executeSingleToolCall(tc);
3597
- toolResults.push({ toolCallId: tc.id, content });
3598
- } catch (e) {
3599
- const msg = e instanceof Error ? e.message : String(e);
3600
- toolResults.push({ toolCallId: tc.id, content: `Error: ${msg}` });
3789
+ let verdict = null;
3790
+ try {
3791
+ const jsonMatch = response.content.match(/\{[\s\S]*\}/);
3792
+ if (jsonMatch) {
3793
+ const parsed = JSON.parse(jsonMatch[0]);
3794
+ verdict = {
3795
+ achieved: parsed.achieved === true,
3796
+ ...typeof parsed.reason === "string" && { reason: parsed.reason },
3797
+ ...typeof parsed.missing === "string" && { missing: parsed.missing }
3798
+ };
3601
3799
  }
3800
+ } catch {
3801
+ this.logger.warn("[GoalVerifier] Failed to parse built-in verifier response \u2014 skipping");
3602
3802
  }
3603
- const assistantTurn = {
3604
- role: "assistant",
3605
- content: response.content || "",
3606
- tokenCount: this.adapter.estimateTokens(response.content || "") + 50,
3607
- turnIndex: this.context.turns.length,
3608
- compressed: false,
3609
- toolCalls: response.toolCalls
3610
- };
3611
- this.context.turns.push(assistantTurn);
3612
- if (this.config.onTurn) this.config.onTurn(assistantTurn);
3613
- for (const res of toolResults) {
3614
- const toolTurn = {
3615
- role: "tool",
3616
- content: res.content,
3617
- tokenCount: this.adapter.estimateTokens(res.content),
3618
- turnIndex: this.context.turns.length,
3619
- compressed: false,
3620
- toolResults: [res]
3621
- };
3622
- this.context.turns.push(toolTurn);
3623
- if (this.config.onTurn) this.config.onTurn(toolTurn);
3624
- }
3625
- if (this.goalInjector) this.goalInjector.incrementTurn();
3626
- continue;
3627
- }
3628
- this.context.tokenCount = this.context.turns.reduce((sum, t) => sum + t.tokenCount, 0) + this.adapter.estimateTokens(this.context.systemPrompt || "");
3629
- this.context = await this.contextManager.prepare(this.context);
3630
- const finalSystemPrompt = this.buildSystemPrompt(userMessageStr, this.iterations);
3631
- const finalMessages = this.buildMessages(finalSystemPrompt, this.iterations);
3632
- let accumulated = "";
3633
- let finalFinishReason = void 0;
3634
- let finalTokenCount = 0;
3635
- for await (const chunk of this.adapter.stream({
3636
- model: this.config.model,
3637
- messages: finalMessages,
3638
- maxTokens: maxCompletionTokens,
3639
- stream: true
3640
- })) {
3641
- if (chunk.delta) {
3642
- accumulated += chunk.delta;
3643
- finalTokenCount += Math.ceil(chunk.delta.length / 4);
3644
- yield chunk.delta;
3645
- }
3646
- if (chunk.finished) {
3647
- finalFinishReason = chunk.finishReason;
3648
- if (chunk.usage) {
3649
- this.totalTokens += chunk.usage.totalTokens;
3650
- }
3651
- this.emitTrace("thinking", "llm_stream_finished", {
3652
- usage: chunk.usage,
3653
- totalTokens: this.totalTokens,
3654
- finishReason: chunk.finishReason
3803
+ if (verdict) {
3804
+ this.emitTrace("verification", "goal_verification_result", {
3805
+ achieved: verdict.achieved,
3806
+ reason: verdict.reason,
3807
+ missing: verdict.missing,
3808
+ source: "built_in"
3655
3809
  });
3810
+ return verdict;
3656
3811
  }
3657
3812
  }
3658
- const finalTurn = {
3659
- role: "assistant",
3660
- content: accumulated,
3661
- tokenCount: finalTokenCount,
3662
- turnIndex: this.context.turns.length,
3663
- compressed: false
3664
- };
3665
- this.context.turns.push(finalTurn);
3666
- if (this.config.onTurn) this.config.onTurn(finalTurn);
3667
- this.logger.info(`[stream] Streaming run completed (finishReason: ${finalFinishReason ?? "stop"})`);
3668
- return;
3813
+ } catch (err) {
3814
+ const msg = err instanceof Error ? err.message : String(err);
3815
+ this.logger.warn(`[GoalVerifier] Verification step failed (non-fatal): ${msg}`);
3669
3816
  }
3670
- const maxItsErr = new LemuraMaxIterationsError(`Exceeded max iterations of ${maxIts}`);
3671
- this.logger.fatal(maxItsErr.message, {
3672
- problem: "The streaming agent loop exceeded its max iterations.",
3673
- hints: ["Increase maxIterations or reduce task complexity."]
3674
- });
3675
- throw maxItsErr;
3817
+ return null;
3676
3818
  }
3677
3819
  /**
3678
3820
  * Resets the session: clears conversation history, resets iteration counters,