Module: Assistant::ComplexityEscalator

Defined in:
app/services/assistant/complexity_escalator.rb

Overview

"Start cheap, upgrade when the task proves complex."

Sunny always begins a session on the cheap default model. Once the session
reveals its complexity — the model declares a multi-step plan, or the
conversation has grown long — this picks the lowest-cost model still strong
enough for the work and climbs the capability ladder toward it.

Two invariants:

  • It only ever CLIMBS the ladder, never descends — switching down mid-task
    would throw away accumulated reasoning context.
  • It only climbs while the user's monthly budget allows. Out of budget it
    returns nil and the caller stays on the cheap tier — the budget is a
    governor (soft-degrade), never a hard block on an in-flight conversation.

The plan-step count is the primary complexity signal: a declared multi-step
plan is the model's own admission that the task is non-trivial. Conversation
length is a weaker secondary signal for long, unplanned grinds.

See doc/tasks/202606031730_SUNNY_BUDGET_AND_AUTO_ESCALATION.md.

Constant Summary collapse

LADDER =

Capability ladder, cheapest → strongest. Keys match ChatService::MODELS.
No Sonnet rung — from Gemini Pro the next jump is straight to Opus, then
to Opus with the 1M-token context window for marathon / huge-context work.

%w[gemini-flash gemini-pro claude-opus claude-opus-1m].freeze
OPUS_1M_MIN_STEPS =

Plan-step thresholds for each rung above the cheap default.

6
OPUS_MIN_STEPS =

very large job — also wants the 1M context window

3
PRO_MIN_STEPS =

substantial multi-step job

1
LONG_CONVERSATION_MESSAGES =

Message counts above which an unplanned conversation is treated as complex
(long → Pro) or context-heavy enough to warrant the 1M window (very long).

20
VERY_LONG_CONVERSATION_MESSAGES =
60

Class Method Summary collapse

Class Method Details

.floor_index(plan_step_count:, history_length:) ⇒ Integer

Lowest ladder index a session of this realized complexity should run on.

Parameters:

  • plan_step_count (Integer)

    steps in the declared execution plan (0 if none)

  • history_length (Integer)

    messages so far in the conversation

Returns:

  • (Integer)

    index into LADDER (0 = cheapest)



45
46
47
48
49
50
51
52
53
# File 'app/services/assistant/complexity_escalator.rb', line 45

def floor_index(plan_step_count:, history_length:)
  return 3 if plan_step_count >= OPUS_1M_MIN_STEPS || history_length > VERY_LONG_CONVERSATION_MESSAGES # claude-opus-1m
  return 2 if plan_step_count >= OPUS_MIN_STEPS # claude-opus
  if plan_step_count >= PRO_MIN_STEPS || history_length > LONG_CONVERSATION_MESSAGES
    return 1 # gemini-pro
  end

  0 # gemini-flash
end

.upgrade(current_model:, plan_step_count:, history_length:, user_context: {}, budget: nil) ⇒ Hash?

Decide whether to upgrade the model for a session that has revealed its
complexity. Climbs LADDER to the complexity floor, gated on remaining
budget.

Parameters:

  • current_model (String)

    the model key chosen so far this turn

  • plan_step_count (Integer)

    steps in the declared execution plan

  • history_length (Integer)

    messages so far in the conversation

  • user_context (Hash) (defaults to: {})

    carries party_id / is_manager for the budget

  • budget (Assistant::MonthlyBudget, nil) (defaults to: nil)

    injected in tests; built from
    user_context otherwise

Returns:

  • (Hash, nil)

    { model:, reason: } to upgrade to, or nil to leave as-is



66
67
68
69
70
71
72
73
74
75
76
77
78
79
# File 'app/services/assistant/complexity_escalator.rb', line 66

def upgrade(current_model:, plan_step_count:, history_length:, user_context: {}, budget: nil)
  current_index = LADDER.index(current_model) || 0
  floor = floor_index(plan_step_count: plan_step_count, history_length: history_length)
  return nil if floor <= current_index

  budget ||= MonthlyBudget.for_user_context(user_context)
  return nil unless budget.allows_escalation?

  target = LADDER[floor]
  trigger = plan_step_count.positive? ? "#{plan_step_count}-step plan" : 'long session'
  { model: target,
    reason: "Auto-upgraded to #{target} — complex #{trigger}, " \
            "$#{format('%.2f', budget.remaining_usd)} budget left" }
end