Skip to content

refactor: decompose LlmSessionActor for composability (#411, #414)#417

Merged
Aaronontheweb merged 9 commits into
devfrom
claude-wt-refactor-llmsessionactor
Mar 25, 2026
Merged

refactor: decompose LlmSessionActor for composability (#411, #414)#417
Aaronontheweb merged 9 commits into
devfrom
claude-wt-refactor-llmsessionactor

Conversation

@Aaronontheweb

@Aaronontheweb Aaronontheweb commented Mar 25, 2026

Copy link
Copy Markdown
Collaborator

Summary

Refactors LlmSessionActor and SessionConfig for composability, maintainability, and eventual Akka.Agents framework extraction.

Phase 0 (this commit): SessionConfig Decomposition (#414)

  • Split 16-property SessionConfig into ModelCapabilities (runtime-derived), SessionTuning (internal constants), and slimmed SessionConfig (6 user-facing settings)
  • Timeouts converted from int seconds to TimeSpan, eliminating 15+ Math.Max(1, ...) guards
  • JSON schema enforces additionalProperties: false for the Session section
  • All consumers migrated: LlmSessionActor, DaemonRuntimeStatusService, ChatViewModel, SQLiteMemoryRecallCoordinator

Fixes added during review follow-up

  • Abort session passivation when new user work or delivery feedback arrives, instead of ACKing and dropping that work
  • Route final passivation distillation through the normal handling path and send the explicit observer handshake with Self as sender
  • Stop observer idle ReceiveTimeout ticks from resetting the parent session idle timer when there is nothing new to distill
  • Restore backward compatibility for legacy root-level Session.* tuning keys while preferring nested Session:Tuning:*
  • Replace stringly typed duplicate tool-call fingerprints with a small typed value object
  • Add regression coverage for passivation interruption, observer idle behavior, and config compatibility

Remaining phases (WIP):

  • Phase 1: Constructor dependency reduction (19 params -> ~7 via composite records)
  • Phase 2: State machine formalization (SessionPhase enum + TransitionTo() + Passivating state)
  • Phase 3: Handler module extractions (subscriber manager, delivery retry, turn state, tool cache, watchdog)
  • Phase 4: Static pipeline extractions (title generator, compaction, LLM invoker, tool execution, recall manager)

OpenSpec artifacts: openspec/changes/refactor-llm-session-actor/

Test plan

  • dotnet build - 0 errors
  • dotnet test - 1,400 tests pass, 0 failures
  • Targeted regression tests for session passivation, observer idle behavior, and config compatibility
  • dotnet slopwatch analyze - no new violations
  • Remaining phases implemented and verified
  • Persistence wire compatibility verified (events/snapshots unchanged)

Closes #414
Closes #426
Refs #411, #326

…sionTuning, and slimmed SessionConfig (#414)

Split the 16-property SessionConfig into three focused types:

- ModelCapabilities: runtime-derived model properties (ModelId, ContextWindowTokens,
  InputModalities, OutputModalities, CompactionModelId) registered as a separate
  DI singleton resolved from the capability detection pipeline.

- SessionTuning: internal tuning constants (compaction settings, tool retention,
  snapshot interval, title generation) nested under SessionConfig.Tuning. Bindable
  from config for testing but not part of the documented operator surface.

- SessionConfig: slimmed to 6 user-facing operational settings with TimeSpan
  timeouts (TurnLlmTimeout, ToolExecutionTimeout, SidecarLlmTimeout) replacing
  int-seconds properties, eliminating 15+ Math.Max(1, ...) guards.

Also includes:
- SessionConfig.BindFromConfiguration() static factory for backward-compatible
  JSON binding (int-seconds keys converted to TimeSpan with min 1s enforcement)
- ModelCapabilityResolution returns ModelCapabilities directly instead of a tuple
- JSON schema Session section enforces additionalProperties: false
- LlmSessionActor accepts ModelCapabilities as separate constructor parameter
- SQLiteMemoryRecallCoordinator takes SessionTuning for feature flags
- DaemonRuntimeStatusService and ChatViewModel use ModelCapabilities
- OpenSpec change artifacts for the full LlmSessionActor refactoring plan

All 1,400 tests pass.

Closes #414
…to 7 via composite records (#411)

Group individual dependencies into four composable records:

- SessionServices: core runtime (ClientProvider, PromptProvider, ContextLayers,
  TimeProvider, Paths)
- SessionToolServices: tool execution (ToolExecutor, AuditLogger, ToolRegistry,
  AccessPolicy, TrustDeriver, SkillRegistry) — nullable for tool-less sessions
- SessionMemoryServices: memory infrastructure (MemoryExtractor,
  RecallCoordinator, CheckpointSink, MemoryStore)
- SessionObservability: metrics and lifecycle (SessionMetrics,
  LifecycleObserver)

Composite records registered as DI singletons in Program.cs and resolved by
Akka.DependencyInjection's resolver.Props<LlmSessionActor>(entityId).

All 1,400 tests pass.
… Passivating state (#411)

Replace implicit Become() calls with explicit TransitionTo(SessionPhase) that
validates legal transitions and throws InvalidOperationException for illegal ones.

- SessionPhase enum: Recovering, Ready, Processing, Compacting, Passivating
- TransitionTo() validates transition legality, logs phase changes, and notifies
  the observer actor via SessionPhaseChanged messages
- Passivating behavior: buffers messages, requests final memory distillation from
  observer via DistillMemories, waits up to 5s grace period, then saves snapshot
  and stops. Falls back to immediate stop if no observer is configured.
- ReceiveTimeout in Ready now transitions to Passivating instead of inline
  snapshot+stop, enabling the memory observer to distill final memories before
  session death.

Legal transitions:
  Recovering → Ready
  Ready → Processing, Compacting, Passivating
  Processing → Ready, Compacting
  Compacting → Ready, Processing
  Passivating → (terminal)

All 1,400 tests pass.
…m LlmSessionActor (#411)

Extract 9 focused modules from LlmSessionActor, reducing it from 3,208 to
2,273 lines (~930 lines relocated).

Handler modules (src/Netclaw.Actors/Sessions/Handlers/):
- TurnStateTracker: per-turn counters, duplicate detection, budget tracking
- ProcessingWatchdog: operation ID tracking, timer management, expiry validation
- DeliveryRetryHandler: retry counting, eligibility, nudge construction
- DiscoveredToolCache: MCP tool retention, lease countdown, eviction

Pipeline utilities (src/Netclaw.Actors/Sessions/Pipelines/):
- SessionTitleGenerator: title generation decision + sidecar LLM call
- SessionCompactionPipeline: tiered compaction, observation generation,
  token estimation, CompactionParameters record
- SessionLlmInvoker: streaming LLM invocation with delta forwarding
- SessionToolExecutionPipeline: parallel tool execution, result clamping,
  sub-agent finding review, ToolCallResult record
- SessionRecallManager: turn recall cache, progressive exclusion, recall
  injection and formatting

Each module is independently testable without an ActorSystem. The actor
delegates to modules via field references, preserving identical behavior.

All 1,400 tests pass.
…ange

Sync delta specs to main specs:
- New: session-config-decomposition, session-state-machine
- Modified: netclaw-model-capabilities, netclaw-session

Archive completed change to openspec/changes/archive/2026-03-25-refactor-llm-session-actor/
@Aaronontheweb Aaronontheweb marked this pull request as ready for review March 25, 2026 12:40

@Aaronontheweb Aaronontheweb left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Reviewing piece by piece

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

LGTM

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

LGTM

_operationId++;
_operationName = operationName;

timers.StartSingleTimer(

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

akkadotnet/akka.net#7630 - worth noting if that if we ever allow parallel tool calls, we'll need to implement this in order to get those to work properly

/// empty-response retries, and force-no-tools state. Reset at the start of
/// each user turn.
/// </summary>
internal sealed class TurnStateTracker

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

We need to move more of the decision-making about "stuck turns" et al into this class.

…icate, and empty response decisions

TurnStateTracker was a property bag. Now it owns the actual decision-making:

- RecordToolCompletion(count, max) → ToolBudgetStatus (Ok/NudgeNeeded/Exhausted)
  with pre-built nudge text and remaining-call count
- CheckForDuplicates() → DuplicateToolNudge? with tool name extraction and
  nudge text, or null if no duplicates meet threshold
- EvaluateEmptyResponse() → EmptyResponseAction (Retry with nudge / Fail with
  error message and cause) covering pre-tool empty, post-tool first empty, and
  post-tool second empty scenarios
- ResetEmptyResponseGuards() for when model starts tool work (not stuck)
- ResetToolCounters() for mid-turn buffer drain

Result types use discriminated union records (ToolBudgetStatus, EmptyResponseAction)
so the actor uses pattern matching instead of flag checks. Each decision is now
independently unit-testable without an ActorSystem.

All 1,400 tests pass.

@Aaronontheweb Aaronontheweb left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Rest of my comments

Comment thread src/Netclaw.Actors/Sessions/Pipelines/SessionRecallManager.cs Outdated
try
{
using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(300));
return coordinator.RecallAsync(request, cts.Token)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Any way we can make this method just return async Task ?

Comment thread src/Netclaw.Actors/Sessions/LlmSessionActor.cs Outdated
Comment thread src/Netclaw.Actors/Sessions/LlmSessionActor.cs Outdated
Comment thread src/Netclaw.Actors/Sessions/LlmSessionActor.cs Outdated
Comment thread src/Netclaw.Configuration/SessionConfig.cs Outdated
Comment thread src/Netclaw.Actors/Sessions/LlmSessionActor.cs
Comment thread src/Netclaw.Actors/Sessions/Handlers/TurnStateTracker.cs Outdated
@Aaronontheweb Aaronontheweb merged commit 4759da6 into dev Mar 25, 2026
3 checks passed
@Aaronontheweb Aaronontheweb deleted the claude-wt-refactor-llmsessionactor branch March 25, 2026 17:59
@Aaronontheweb Aaronontheweb mentioned this pull request Mar 25, 2026
3 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant