Class: Clacky::AgentConfig

Inherits:
Object
  • Object
show all
Defined in:
lib/clacky/agent_config.rb

Constant Summary collapse

CONFIG_DIR =
File.join(Dir.home, ".clacky")
CONFIG_FILE =
File.join(CONFIG_DIR, "config.yml")
CLAUDE_DEFAULT_MODEL =

Default model for ClaudeCode environment

"claude-sonnet-4-5"
PERMISSION_MODES =
[:auto_approve, :confirm_safes, :confirm_all].freeze
RUNTIME_ONLY_FIELDS =

Convert to YAML format (top-level array) Auto-injected lite models (auto_injected: true) are excluded from persistence —they are regenerated at load time from the provider preset. Runtime-only fields (id, auto_injected) are stripped before writing so config.yml remains backward compatible with users on older versions.

%w[id auto_injected].freeze
FALLBACK_COOLING_OFF_SECONDS =

How long to stay on the fallback model before probing the primary again.

30 * 60

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(options = {}) ⇒ AgentConfig

Returns a new instance of AgentConfig.



159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
# File 'lib/clacky/agent_config.rb', line 159

def initialize(options = {})
  @permission_mode = validate_permission_mode(options[:permission_mode])
  @max_tokens = options[:max_tokens] || 8192
  @verbose = options[:verbose] || false
  @enable_compression = options[:enable_compression].nil? ? true : options[:enable_compression]
  # Enable prompt caching by default for cost savings
  @enable_prompt_caching = options[:enable_prompt_caching].nil? ? true : options[:enable_prompt_caching]

  # Models configuration
  @models = options[:models] || []
  # Ensure every model has a stable runtime id — this is the single
  # invariant the rest of the system relies on. Regardless of how the
  # config was built (load from yml, direct .new in tests, add_model,
  # api_save_config), every model in @models will have an id.
  @models.each { |m| m["id"] ||= SecureRandom.uuid }

  @current_model_index = options[:current_model_index] || 0
  # Stable runtime id for the currently-selected model. Preferred over
  # @current_model_index because ids are immune to list reordering,
  # additions, and edits to model fields. Ids are injected at load time
  # and never persisted to config.yml (backward compatible with old files).
  # If caller didn't specify current_model_id, prefer the model marked
  # as `type: default` (the documented convention), falling back to
  # models[current_model_index] only if no default marker exists.
  @current_model_id = options[:current_model_id] ||
                      (@models.find { |m| m["type"] == "default" } || @models[@current_model_index])&.dig("id")

  # Memory and skill evolution configuration
  @memory_update_enabled = options[:memory_update_enabled].nil? ? true : options[:memory_update_enabled]
  @skill_evolution = options[:skill_evolution] || {
    enabled: true,
    auto_create_threshold: 12,
    reflection_mode: "llm_analysis"
  }
end

Instance Attribute Details

#current_model_idObject

Returns the value of attribute current_model_id.



154
155
156
# File 'lib/clacky/agent_config.rb', line 154

def current_model_id
  @current_model_id
end

#current_model_indexObject

Returns the value of attribute current_model_index.



154
155
156
# File 'lib/clacky/agent_config.rb', line 154

def current_model_index
  @current_model_index
end

#enable_compressionObject

Returns the value of attribute enable_compression.



154
155
156
# File 'lib/clacky/agent_config.rb', line 154

def enable_compression
  @enable_compression
end

#enable_prompt_cachingObject

Returns the value of attribute enable_prompt_caching.



154
155
156
# File 'lib/clacky/agent_config.rb', line 154

def enable_prompt_caching
  @enable_prompt_caching
end

#max_tokensObject

Returns the value of attribute max_tokens.



154
155
156
# File 'lib/clacky/agent_config.rb', line 154

def max_tokens
  @max_tokens
end

#memory_update_enabledObject

Returns the value of attribute memory_update_enabled.



154
155
156
# File 'lib/clacky/agent_config.rb', line 154

def memory_update_enabled
  @memory_update_enabled
end

#modelsObject

Returns the value of attribute models.



154
155
156
# File 'lib/clacky/agent_config.rb', line 154

def models
  @models
end

#permission_modeObject

Returns the value of attribute permission_mode.



154
155
156
# File 'lib/clacky/agent_config.rb', line 154

def permission_mode
  @permission_mode
end

#skill_evolutionObject

Returns the value of attribute skill_evolution.



154
155
156
# File 'lib/clacky/agent_config.rb', line 154

def skill_evolution
  @skill_evolution
end

#verboseObject

Returns the value of attribute verbose.



154
155
156
# File 'lib/clacky/agent_config.rb', line 154

def verbose
  @verbose
end

Class Method Details

.load(config_file = CONFIG_FILE) ⇒ Object

Load configuration from file



196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
# File 'lib/clacky/agent_config.rb', line 196

def self.load(config_file = CONFIG_FILE)
  # Load from config file first
  if File.exist?(config_file)
    data = YAML.load_file(config_file)
  else
    data = nil
  end

  # Parse models from config
  models = parse_models(data)

  # Priority: config file > CLACKY_XXX env vars > ClaudeCode env vars
  if models.empty?
    # Try CLACKY_XXX environment variables first
    if ClackyEnv.default_configured?
      models << ClackyEnv.default_model_config
    # ClaudeCode (Anthropic) environment variable support is disabled
    # elsif ClaudeCodeEnv.configured?
    #   models << {
    #     "type" => "default",
    #     "api_key" => ClaudeCodeEnv.api_key,
    #     "base_url" => ClaudeCodeEnv.base_url,
    #     "model" => CLAUDE_DEFAULT_MODEL,
    #     "anthropic_format" => true
    #   }
    end

    # Add CLACKY_LITE_XXX if configured (only when loading from env)
    if ClackyEnv.lite_configured?
      models << ClackyEnv.lite_model_config
    end
  else
    # Config file exists, but check if we need to add env-based models
    # Only add if no model with that type exists
    has_default = models.any? { |m| m["type"] == "default" }
    has_lite = models.any? { |m| m["type"] == "lite" }

    # Add CLACKY default if not in config and env is set
    if !has_default && ClackyEnv.default_configured?
      models << ClackyEnv.default_model_config
    end

    # Add CLACKY lite if not in config and env is set
    if !has_lite && ClackyEnv.lite_configured?
      models << ClackyEnv.lite_model_config
    end

    # Ensure at least one model has type: default
    # If no model has type: default, assign it to the first model
    unless models.any? { |m| m["type"] == "default" }
      models.first["type"] = "default" if models.any?
    end
  end

  # Auto-inject lite model from provider preset when:
  # 1. A default model exists
  # 2. No lite model is configured yet (neither in file nor env)
  # 3. The default model's provider has a known lite_model
  # The injected lite model is runtime-only (not persisted to config.yml)
  inject_provider_lite_model(models)

  # Ensure every model has a stable runtime id — covers env-injected
  # models (CLACKY_XXX, CLAUDE_XXX) that don't go through parse_models.
  # Ids are NOT persisted to config.yml (see to_yaml).
  models.each { |m| m["id"] ||= SecureRandom.uuid }

  # Find the index of the model marked as "default" (type: default)
  # Fall back to 0 if no model has type: default
  default_index = models.find_index { |m| m["type"] == "default" } || 0
  default_id = models[default_index] && models[default_index]["id"]

  new(models: models, current_model_index: default_index, current_model_id: default_id)
end

Instance Method Details

#activate_fallback!(fallback_model_name) ⇒ Object

Switch to fallback model and start the cooling-off clock. Idempotent — calling again while already in :fallback_active renews the timestamp.

Parameters:

  • fallback_model_name (String)

    the fallback model to use



528
529
530
531
532
# File 'lib/clacky/agent_config.rb', line 528

def activate_fallback!(fallback_model_name)
  @fallback_state = :fallback_active
  @fallback_since = Time.now
  @fallback_model  = fallback_model_name
end

#add_model(model:, api_key:, base_url:, anthropic_format: false, type: nil) ⇒ Object

Add a new model configuration



478
479
480
481
482
483
484
485
486
487
# File 'lib/clacky/agent_config.rb', line 478

def add_model(model:, api_key:, base_url:, anthropic_format: false, type: nil)
  @models << {
    "id" => SecureRandom.uuid,
    "api_key" => api_key,
    "base_url" => base_url,
    "model" => model,
    "anthropic_format" => anthropic_format,
    "type" => type
  }.compact
end

#anthropic_format?Boolean

Check if should use Anthropic format for current model

Returns:

  • (Boolean)


468
469
470
# File 'lib/clacky/agent_config.rb', line 468

def anthropic_format?
  current_model&.dig("anthropic_format") || false
end

#api_keyObject

Get API key for current model



435
436
437
# File 'lib/clacky/agent_config.rb', line 435

def api_key
  current_model&.dig("api_key")
end

#api_key=(value) ⇒ Object

Set API key for current model



440
441
442
443
# File 'lib/clacky/agent_config.rb', line 440

def api_key=(value)
  return unless current_model
  current_model["api_key"] = value
end

#base_urlObject

Get base URL for current model



446
447
448
# File 'lib/clacky/agent_config.rb', line 446

def base_url
  current_model&.dig("base_url")
end

#base_url=(value) ⇒ Object

Set base URL for current model



451
452
453
454
# File 'lib/clacky/agent_config.rb', line 451

def base_url=(value)
  return unless current_model
  current_model["base_url"] = value
end

#bedrock?Boolean

Check if current model uses Bedrock Converse API (ABSK key prefix or abs- model prefix)

Returns:

  • (Boolean)


473
474
475
# File 'lib/clacky/agent_config.rb', line 473

def bedrock?
  Clacky::MessageFormat::Bedrock.bedrock_api_key?(api_key.to_s, model_name.to_s)
end

#confirm_fallback_ok!Object

Called when a successful API response is received. If we were :probing (testing primary after cooling-off), this confirms the primary model is healthy again and resets everything. No-op in :primary_ok or :fallback_active states.



549
550
551
552
553
554
555
# File 'lib/clacky/agent_config.rb', line 549

def confirm_fallback_ok!
  return unless @fallback_state == :probing

  @fallback_state = nil
  @fallback_since = nil
  @fallback_model = nil
end

#current_modelObject

Get current model configuration.

Resolution order:

1. @current_model_id (primary source of truth — stable across list edits)
2. type: default (for config.yml that sets a default explicitly)
3. @current_model_index (back-compat for very old code paths)


588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
# File 'lib/clacky/agent_config.rb', line 588

def current_model
  return nil if @models.empty?

  if @current_model_id
    m = @models.find { |mm| mm["id"] == @current_model_id }
    return m if m
    # id no longer exists (model was deleted). Fall through to other
    # resolution strategies below, and clear the stale id.
    @current_model_id = nil
  end

  default_model = find_model_by_type("default")
  if default_model
    # Opportunistically re-anchor to this default's id so subsequent
    # lookups are O(1) and survive list reordering.
    @current_model_id = default_model["id"]
    return default_model
  end

  # Fallback to index-based for backward compatibility
  m = @models[@current_model_index]
  @current_model_id = m["id"] if m
  m
end

#deep_copyObject

Create a per-session copy of this config.

Plan B (shared models): we deliberately share the SAME @models array reference with all sessions (no deep clone). This is the key design decision that keeps session and global views in sync:

- User adds a model in Settings → every live session sees it instantly.
- User edits api_key/base_url → every live session's next API call
  picks up the new credentials (via current_model lookup).
- Model ids are stable across edits, so each session's
  @current_model_id continues to resolve correctly.

Per-session state that MUST stay isolated (permission_mode, @current_model_id, @current_model_index, fallback state) are scalar copies via ‘dup` and don’t leak between sessions.

Before Plan B, sessions held deep-copied @models — which silently diverged from the global list any time the user added/edited a model in Settings, producing bugs like “Failed to switch model” for newly added models on Windows and Linux. See http_server.rb#api_switch_session_model and http_server.rb#api_save_config for the companion logic.



320
321
322
323
324
# File 'lib/clacky/agent_config.rb', line 320

def deep_copy
  # dup gives us a new AgentConfig with independent scalar ivars but
  # the same @models reference — exactly what we want.
  dup
end

#default_modelObject

Get the default model (type: default) Falls back to current_model for backward compatibility



497
498
499
# File 'lib/clacky/agent_config.rb', line 497

def default_model
  find_model_by_type("default") || current_model
end

#effective_model_nameObject

The effective model name to use for API calls.

  • :primary_ok / nil → configured model_name (primary)

  • :fallback_active → fallback model

  • :probing → configured model_name (trying primary silently)



572
573
574
575
576
577
578
579
580
# File 'lib/clacky/agent_config.rb', line 572

def effective_model_name
  case @fallback_state
  when :fallback_active
    @fallback_model || model_name
  else
    # :primary_ok (nil) and :probing both use the primary model
    model_name
  end
end

#fallback_active?Boolean

Returns true when a fallback model is currently being used (:fallback_active or :probing states).

Returns:

  • (Boolean)


559
560
561
# File 'lib/clacky/agent_config.rb', line 559

def fallback_active?
  @fallback_state == :fallback_active || @fallback_state == :probing
end

#fallback_model_for(model_name) ⇒ String?

Look up the fallback model name for the given model name. Uses the provider preset’s fallback_models table. Returns nil if no fallback is configured for this model.

Parameters:

  • model_name (String)

    the primary model name (e.g. “abs-claude-sonnet-4-6”)

Returns:

  • (String, nil)


515
516
517
518
519
520
521
522
523
# File 'lib/clacky/agent_config.rb', line 515

def fallback_model_for(model_name)
  m = current_model
  return nil unless m

  provider_id = Clacky::Providers.find_by_base_url(m["base_url"])
  return nil unless provider_id

  Clacky::Providers.fallback_model(provider_id, model_name)
end

#find_model_by_type(type) ⇒ Object

Find model by type (default or lite) Returns the model hash or nil if not found



491
492
493
# File 'lib/clacky/agent_config.rb', line 491

def find_model_by_type(type)
  @models.find { |m| m["type"] == type }
end

#get_model(index) ⇒ Object

Get model by index



357
358
359
# File 'lib/clacky/agent_config.rb', line 357

def get_model(index)
  @models[index]
end

#lite_modelObject

Get the lite model (type: lite) Returns nil if no lite model configured



503
504
505
# File 'lib/clacky/agent_config.rb', line 503

def lite_model
  find_model_by_type("lite")
end

#maybe_start_probingObject

Called at the start of every call_llm. If cooling-off has expired, transition from :fallback_active → :probing so the next request will silently test the primary model. No-op in any other state.



538
539
540
541
542
543
# File 'lib/clacky/agent_config.rb', line 538

def maybe_start_probing
  return unless @fallback_state == :fallback_active
  return unless @fallback_since && (Time.now - @fallback_since) >= FALLBACK_COOLING_OFF_SECONDS

  @fallback_state = :probing
end

#model_nameObject

Get model name for current model



457
458
459
# File 'lib/clacky/agent_config.rb', line 457

def model_name
  current_model&.dig("model")
end

#model_name=(value) ⇒ Object

Set model name for current model



462
463
464
465
# File 'lib/clacky/agent_config.rb', line 462

def model_name=(value)
  return unless current_model
  current_model["model"] = value
end

#model_namesObject

List all model names



430
431
432
# File 'lib/clacky/agent_config.rb', line 430

def model_names
  @models.map { |m| m["model"] }
end

#models_configured?Boolean

Check if any model is configured

Returns:

  • (Boolean)


348
349
350
# File 'lib/clacky/agent_config.rb', line 348

def models_configured?
  !@models.empty? && !current_model.nil?
end

#probing?Boolean

Returns true only when we are silently probing the primary model.

Returns:

  • (Boolean)


564
565
566
# File 'lib/clacky/agent_config.rb', line 564

def probing?
  @fallback_state == :probing
end

#remove_model(index) ⇒ Object

Remove a model by index Returns true if removed, false if index out of range or it’s the last model



640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
# File 'lib/clacky/agent_config.rb', line 640

def remove_model(index)
  # Don't allow removing the last model
  return false if @models.length <= 1
  return false if index < 0 || index >= @models.length

  removed = @models.delete_at(index)

  # Adjust current_model_index if necessary
  if @current_model_index >= @models.length
    @current_model_index = @models.length - 1
  end

  # If the removed model was the current one, clear @current_model_id.
  # current_model will then fall back to type: default / current_model_index.
  if removed && @current_model_id == removed["id"]
    @current_model_id = nil
  end

  true
end

#save(config_file = CONFIG_FILE) ⇒ Object



326
327
328
329
330
331
# File 'lib/clacky/agent_config.rb', line 326

def save(config_file = CONFIG_FILE)
  config_dir = File.dirname(config_file)
  FileUtils.mkdir_p(config_dir)
  File.write(config_file, to_yaml)
  FileUtils.chmod(0o600, config_file)
end

#set_default_model_by_id(id) ⇒ Boolean

Set the global default model marker (‘type: “default”`).

This is separate from ‘switch_model_by_id`:

- `switch_model_by_id` only changes this session's current model.
- `set_default_model_by_id` mutates the shared `@models` array by
  moving the `type: "default"` marker to the given model.

Use cases:

- CLI (single-session): when the user picks a model, we both switch
  this session AND update the global default so future CLI launches
  use the same model. Caller must `save` to persist.
- Web UI Settings save flow: also uses this (via payload).

Do NOT call from per-session model switching in multi-session contexts (Web UI session-level switch), since it would leak into other sessions and change what new sessions start with.

Only one model may carry ‘type: “default”` at a time — this method clears the marker on any other model that had it.

Note: if the target model currently has ‘type: “lite”`, this method will overwrite it with `“default”`. That matches the existing single-slot `type` field semantics in the codebase.

Parameters:

  • id (String)

    the model’s runtime id

Returns:

  • (Boolean)

    true if marker was moved, false if id not found



412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
# File 'lib/clacky/agent_config.rb', line 412

def set_default_model_by_id(id)
  return false if id.nil? || id.to_s.empty?

  target = @models.find { |m| m["id"] == id }
  return false if target.nil?

  # Clear existing default marker(s) — there should only be one, but
  # be defensive in case of corrupted config.
  @models.each do |m|
    next if m["id"] == id
    m.delete("type") if m["type"] == "default"
  end

  target["type"] = "default"
  true
end

#set_model_type(index, type) ⇒ Object

Set a model’s type (default or lite) Ensures only one model has each type Returns true if successful

Parameters:

  • index (Integer)

    the model index

  • type (String, nil)

    “default”, “lite”, or nil to remove type



618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
# File 'lib/clacky/agent_config.rb', line 618

def set_model_type(index, type)
  return false if index < 0 || index >= @models.length
  return false unless ["default", "lite", nil].include?(type)

  if type
    # Remove type from any other model that has it
    @models.each do |m|
      m.delete("type") if m["type"] == type
    end
    
    # Set type on target model
    @models[index]["type"] = type
  else
    # Remove type from target model
    @models[index].delete("type")
  end

  true
end

#switch_model_by_id(id) ⇒ Boolean

Switch the current session to a specific model, identified by its stable runtime id.

This is a per-session operation:

- Updates this AgentConfig's `@current_model_id` (primary truth)
- Updates `@current_model_index` for back-compat observers
- Does NOT mutate the shared `@models` array's `type: "default"`
  marker. The "default model" is a global setting (initial model
  for new sessions) and is only changed via the Settings UI
  "save config" flow (`api_save_config`).

Parameters:

  • id (String)

    the model’s runtime id (see parse_models)

Returns:

  • (Boolean)

    true if switched, false if id not found



374
375
376
377
378
379
380
381
382
383
384
# File 'lib/clacky/agent_config.rb', line 374

def switch_model_by_id(id)
  return false if id.nil? || id.to_s.empty?

  index = @models.find_index { |m| m["id"] == id }
  return false if index.nil?

  @current_model_id = id
  @current_model_index = index

  true
end

#to_yamlObject



340
341
342
343
344
345
# File 'lib/clacky/agent_config.rb', line 340

def to_yaml
  persistable = @models.reject { |m| m["auto_injected"] }.map do |m|
    m.reject { |k, _| RUNTIME_ONLY_FIELDS.include?(k) }
  end
  YAML.dump(persistable)
end