(turnId: number, signal: AbortSignal)
| 668 | } |
| 669 | |
| 670 | private async runStepLoop(turnId: number, signal: AbortSignal): Promise<LoopTurnStopReason> { |
| 671 | let stopHookContinuationUsed = false; |
| 672 | let goalOutcomeMessageContinuationUsed = false; |
| 673 | const deduper = new ToolCallDeduplicator({ telemetry: this.agent.telemetry }); |
| 674 | await this.agent.mcp?.waitForInitialLoad(signal); |
| 675 | // Surface the active goal at the start of the turn (append-only; no-op when |
| 676 | // there is no active goal). Each goal continuation is its own turn, so this |
| 677 | // re-injects the reminder once per turn rather than per step, preserving prompt caching. |
| 678 | await this.agent.injection.injectGoal(); |
| 679 | while (true) { |
| 680 | signal.throwIfAborted(); |
| 681 | const model = this.agent.config.model; |
| 682 | const loopControl = this.agent.kimiConfig?.loopControl; |
| 683 | let stopForGoalBudget = false; |
| 684 | try { |
| 685 | const result = await runTurn({ |
| 686 | turnId: String(turnId), |
| 687 | signal, |
| 688 | llm: this.agent.llm, |
| 689 | buildMessages: () => this.agent.context.messages, |
| 690 | buildMessagesStrict: () => this.agent.context.strictMessages, |
| 691 | dispatchEvent: this.buildDispatchEvent(turnId), |
| 692 | tools: this.agent.tools.loopTools, |
| 693 | log: this.agent.log, |
| 694 | maxSteps: loopControl?.maxStepsPerTurn, |
| 695 | maxRetryAttempts: loopControl?.maxRetriesPerStep, |
| 696 | recordStepUsage: async (usage) => { |
| 697 | try { |
| 698 | const snapshot = await this.agent.goal.recordTokenUsage(grandTotal(usage)); |
| 699 | stopForGoalBudget = snapshot?.budget.overBudget === true; |
| 700 | } catch (error) { |
| 701 | this.agent.log.warn('goal token accounting failed', { error }); |
| 702 | } |
| 703 | }, |
| 704 | hooks: { |
| 705 | beforeStep: async ({ signal: stepSignal }) => { |
| 706 | this.agent.microCompaction.detect(); |
| 707 | await this.agent.fullCompaction.beforeStep(stepSignal); |
| 708 | // Flush steered messages (background-task / cron notifications, |
| 709 | // user interrupts) AFTER compaction so they land in the |
| 710 | // post-compaction context instead of being dropped by it. The |
| 711 | // keep/drop decision lives in |
| 712 | // `compactionUserMessageDisposition()`; these origins are not |
| 713 | // re-injected later, so append them only after compaction runs. |
| 714 | this.flushSteerBuffer(); |
| 715 | await this.agent.injection.inject(); |
| 716 | deduper.beginStep(); |
| 717 | return; |
| 718 | }, |
| 719 | afterStep: async ({ usage }) => { |
| 720 | this.agent.usage.record(model, usage, 'turn'); |
| 721 | await this.agent.fullCompaction.afterStep(); |
| 722 | deduper.endStep(); |
| 723 | return stopForGoalBudget ? { stopTurn: true } : undefined; |
| 724 | }, |
| 725 | // oxlint-disable-next-line no-loop-func -- stop hook continuation state is scoped to this turn. |
| 726 | shouldContinueAfterStop: async (ctx) => { |
| 727 | const { signal } = ctx; |
no test coverage detected