Skip to content

feat(ferment): auto-compact session at end of each step and phase#583

Draft
mikenorgate wants to merge 2 commits into
masterfrom
ferment-compaction
Draft

feat(ferment): auto-compact session at end of each step and phase#583
mikenorgate wants to merge 2 commits into
masterfrom
ferment-compaction

Conversation

@mikenorgate

Copy link
Copy Markdown
Contributor

Summary

After every successful complete_ferment_step or complete_ferment_phase, the ferment pipeline now automatically:

  1. Triggers session compaction with custom instructions telling the LLM to preserve the ferment plan, active phase, completed step/phase summary, and next step/phase details.
  2. Appends a hidden ferment_stage_handoff session entry (whether or not compaction succeeded) containing the full context handoff — ferment name, goal, success criteria, phase details, and next action.

Motivation

Long-running ferments accumulate a large session context across many phases and steps. Without compaction, the agent carries the full history of every previous phase into each new one, burning tokens and diluting focus. This feature ensures each phase starts with a fresh, compact summary focused on what matters for the next stage.

Changes

File Change
state.ts PendingCompaction type + transient pendingCompactions map with set/get/clear/drain
runtime.ts Wires all four state functions into FermentRuntime
tools/steps.ts Records pending compaction on all three completeStep success paths
tools/phases.ts Records pending compaction on completePhase success
auto-compaction.ts (new) maybeTriggerFermentCompaction: drains pending entries, calls ctx.compact(), appends handoff entry in onComplete and onError
index.ts Calls maybeTriggerFermentCompaction in agent_end (catch-all)
events.ts Calls maybeTriggerFermentCompaction in turn_end for between-phase compaction in automated continuation mode
auto-compaction.test.ts (new) 21 unit tests
index.test.ts 2 new agent_end compaction trigger tests

Design notes

Why turn_end + agent_end

In automated continuation mode the whole ferment executes in a single agent run — agent_end fires only once at the very end. Triggering in turn_end (which fires after each LLM turn) ensures compaction happens between phases during a continuous run. The agent_end call remains as a catch-all.

Why drainPendingCompactions()

complete_ferment calls setActive(undefined) before agent_end fires, so getActiveId() returns undefined at that point. Storing the fermentId inside PendingCompaction and draining the full map sidesteps this race.

Handoff entry always appended

ferment_stage_handoff is written in both onComplete and onError. Expected non-errors (session too small, already compacted, cancelled) are silently skipped. Unexpected errors surface via ctx.ui.notify. The handoff entry is the primary deliverable; compaction itself is a best-effort optimization.

Testing

pnpm run test  # 23 new tests pass; 10 pre-existing unrelated failures

After every successful complete_ferment_step or complete_ferment_phase,
automatically compact the session context and append a hidden
ferment_stage_handoff entry so each stage receives the ferment plan,
active phase details, and a summary of what was just completed.

Implementation
--------------
- state.ts: PendingCompaction type + transient pendingCompactions Map
  with setPendingCompaction / getPendingCompaction / clearPendingCompaction /
  drainPendingCompactions (drains the full map, used at agent_end so
  compaction fires even after complete_ferment clears getActiveId).
- runtime.ts: wires all four state functions into FermentRuntime.
- tools/steps.ts: records pending compaction on all three completeStep
  success paths (no-verify, verify-pass, tactical-pass).
- tools/phases.ts: records pending compaction on completePhase success.
- auto-compaction.ts (new): maybeTriggerFermentCompaction drains
  drainPendingCompactions() and calls ctx.compact() per ferment with
  custom instructions containing name / goal / successCriteria /
  active phase / completed step or phase summary / next up. On
  onComplete appends a hidden ferment_stage_handoff message. On
  onError: expected errors (too small, already compacted, cancelled) are
  silently skipped; handoff entry is always appended regardless.
- index.ts: calls maybeTriggerFermentCompaction in agent_end (catch-all
  for any residual pending compactions after the run finishes).
- events.ts: also calls maybeTriggerFermentCompaction in turn_end so
  that in automated-continuation mode (whole ferment = one agent run)
  compaction fires between phases, not just at the very end.

Bug fixes applied during development
--------------------------------------
1. getActiveId() race: complete_ferment calls setActive(undefined)
   before agent_end fires, so the original activeId-based lookup always
   returned undefined. Fixed by drainPendingCompactions() which uses the
   fermentId stored inside the PendingCompaction object.
2. Single-run automated mode: agent_end fires only once, after
   complete_ferment, so no between-phase compaction occurred. Fixed by
   also triggering in turn_end, which fires after each LLM turn and
   therefore between every phase in a continuous run.

Co-Authored-By: Kimchi <noreply@kimchi.dev>
@mikenorgate mikenorgate added the new feature Introduces a new feature label Jun 12, 2026
@kimchi-review

kimchi-review Bot commented Jun 12, 2026

Copy link
Copy Markdown

Kimchi Code Review

Property Value
Commit 5be80c6
Author @mikenorgate
Files changed 0
Review status Completed
Comments 5 (1 critical, 1 info, 3 warning)
Duration 289s

Summary

📊 Review Score: 68/100 (overall code quality — 0 lowest, 100 highest)
⏱️ Estimated effort to review: 3/5 (1 = trivial, 5 = very complex)

🧪 Tests: yes — Tests cover instruction building, handoff payload shape, and basic trigger/no-op paths. However, they do not cover the scenario where a pending compaction arrives while another is in-flight, and the in-flight guard test passes vacuously because the map is already drained.

📝 Found 5 issue(s). See inline comments for details.

What to expect

Kimchi will analyze the changes in this pull request and post:

  • A summary of the overall changes
  • Inline comments on specific lines with findings categorized by issue type

The review typically completes within a few minutes. This comment will be updated once the review is ready.

Interact with Kimchi
  • @getkimchi review — re-trigger a full review on the latest commit
  • @getkimchi summary — regenerate the PR summary
  • @getkimchi ignore — skip this PR (no review will be posted)
  • Reply to any inline comment to ask follow-up questions or request clarification
Configuration

Reviews are configured by your organization admin.
Review instructions, excluded directories, and severity thresholds can be adjusted per repository in the Kimchi dashboard.


Powered by Kimchi — AI-powered code review by CAST AI

@kimchi-review kimchi-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📊 Review Score: 68/100 (overall code quality — 0 lowest, 100 highest)
⏱️ Estimated effort to review: 3/5 (1 = trivial, 5 = very complex)

🧪 Tests: yes — Tests cover instruction building, handoff payload shape, and basic trigger/no-op paths. However, they do not cover the scenario where a pending compaction arrives while another is in-flight, and the in-flight guard test passes vacuously because the map is already drained.

📝 Found 5 issue(s). See inline comments for details.

* Check for a pending compaction request and fire `ctx.compact()` if one exists.
*
* Called from the `agent_end` event handler so the compaction does not interrupt
* the agent loop that just finished. The in-flight guard prevents double-fire

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨🔀 Concurrency

The drainPendingCompactions() call removes pending items from the map before the in-flight guard is checked. If ctx.compact() is still executing for a ferment, a subsequent maybeTriggerFermentCompaction call will drain the new pending item and then silently drop it inside triggerCompactionForPending because compactionInProgress.has(fermentId) returns true. This causes lost compaction/handoff entries for step or phase completions that occur while a previous compaction is running.

💡 Suggestion: Change the logic so that pending items are only removed from the map once triggerCompactionForPending confirms compaction is not already in-flight. For example, iterate over pendingCompactions entries, and only delete the key after passing the guard, or re-insert the item with setPendingCompaction when the guard causes an early return.

const nextAction = buildNextActionDescription(ferment)

const lines: string[] = ["Preserve ferment plan details in the summary:"]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️🏗️ Design

When pending.kind is 'phase', buildCustomInstructions labels the completed phase as the Active phase. A phase that was just completed is no longer active; the next activated phase is. This misleads the compaction model by describing stale state as current.

💡 Suggestion: For phase-kind pendings, emit a Completed phase: line first, then look up the phase whose status === 'active' for the Active phase: line, similar to how buildHandoffDetails resolves activePhase.

pending,
)
pi.sendMessage(
{

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️⚠️ Error Handling

The onError callback invokes appendHandoffEntry() and ctx.ui?.notify() without a try/catch. If pi.sendMessage throws, the exception propagates as an unhandled error in the async callback.

💡 Suggestion: Wrap the body of onError in a try/catch block so any unexpected failure during handoff entry creation or notification is logged but not thrown.

{ id: "step-1", description: "Step" },
)
storageMap.set(ferment.id, ferment)
runtime.setActive(ferment)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️🧪 Testing

The in-flight guard test does not actually exercise the guard because drainPendingCompactions already emptied the map on the first call. The second call returns early due to an empty pending list, not because of the compactionInProgress set.

💡 Suggestion: After the first maybeTriggerFermentCompaction call, insert a new pending compaction into the runtime state and invoke the function again without calling onComplete; assert that ctx.compact is still called only once and that the new pending is preserved or processed later.

nextPhaseGoal?: string
completedStepSummary?: string
completedPhaseSummary?: string
/** Number of tokens in the session before compaction was triggered (from CompactionResult.tokensBefore) */

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️🔧 Maintainability

compactionInProgress is a module-level Set, which leaks state across tests and prevents multiple isolated runtimes in the same process.

💡 Suggestion: Move the in-flight tracking into the FermentRuntime state object so it is scoped to the runtime instance and reset naturally with clearFermentState.

- Move in-flight tracking from module-level Set to FermentRuntime state
  (state.ts: compactionInFlight Set + markCompactionInFlight /
  clearCompactionInFlight / isCompactionInFlight / clearAllPendingCompactions).
  Scoped to runtime instance, resets on session_start, no cross-test leakage.

- Fix drainPendingCompactions concurrency: previously drained ALL entries
  including in-flight ones, causing silent drops when a second compaction
  arrived while the first was still running. Now skips in-flight ferments and
  leaves their entry in the map for the next turn_end / agent_end to retry.

- Fix active-phase label for phase-kind pending: the completed phase is no
  longer 'active'. buildCustomInstructions now emits 'Completed phase' for
  phase boundaries and looks up the next status='active' phase separately.

- Wrap onError body in try/catch: pi.sendMessage can throw; the error must
  not propagate as an unhandled rejection from the compact callback.

- Fix vacuous in-flight guard test: old test re-called maybeTriggerFerment-
  Compaction after the map was already drained, not while compaction was
  actually in-flight. New test: inserts a second pending entry while onComplete
  has not fired yet, asserts it is held back, fires onComplete, asserts the
  held entry processes on the next call.

- Fix TS2783 'id is specified more than once' in auto-compaction.test.ts:
  makeStep/makePhase now spread overrides last (required fields only, no
  duplicate literals).

- Clear pending compactions and in-flight set at session_start.

Co-Authored-By: Kimchi <noreply@kimchi.dev>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

new feature Introduces a new feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant