aia 0.9.18 → 0.9.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -79,10 +79,42 @@ module AIA
79
79
 
80
80
  def apply_file_config_to_struct(config, file_config)
81
81
  file_config.each do |key, value|
82
- config[key] = value
82
+ # Special handling for model array with roles (ADR-005 v2)
83
+ if (key == :model || key == 'model') && value.is_a?(Array) && value.first.is_a?(Hash)
84
+ config[:model] = process_model_array_with_roles(value)
85
+ else
86
+ config[key] = value
87
+ end
83
88
  end
84
89
  end
85
90
 
91
+ # Process model array with roles from config file (ADR-005 v2)
92
+ # Format: [{model: "gpt-4o", role: "architect"}, ...]
93
+ # Also supports models without roles: [{model: "gpt-4o"}, ...]
94
+ def process_model_array_with_roles(models_array)
95
+ return [] if models_array.nil? || models_array.empty?
96
+
97
+ model_specs = []
98
+ model_counts = Hash.new(0)
99
+
100
+ models_array.each do |spec|
101
+ model_name = spec[:model] || spec['model']
102
+ role_name = spec[:role] || spec['role']
103
+
104
+ model_counts[model_name] += 1
105
+ instance = model_counts[model_name]
106
+
107
+ model_specs << {
108
+ model: model_name,
109
+ role: role_name,
110
+ instance: instance,
111
+ internal_id: instance > 1 ? "#{model_name}##{instance}" : model_name
112
+ }
113
+ end
114
+
115
+ model_specs
116
+ end
117
+
86
118
  def normalize_last_refresh_date(config)
87
119
  return unless config.last_refresh&.is_a?(String)
88
120
 
@@ -101,7 +101,7 @@ module AIA
101
101
  def fetch_role(role_id)
102
102
  # Handle nil role_id
103
103
  return handle_missing_role("roles/") if role_id.nil?
104
-
104
+
105
105
  # Prepend roles_prefix if not already present
106
106
  unless role_id.start_with?(AIA.config.roles_prefix)
107
107
  role_id = "#{AIA.config.roles_prefix}/#{role_id}"
@@ -126,6 +126,27 @@ module AIA
126
126
  handle_missing_role(role_id)
127
127
  end
128
128
 
129
+ # Load role for a specific model (ADR-005)
130
+ # Takes a model spec hash and default role, returns role text
131
+ def load_role_for_model(model_spec, default_role = nil)
132
+ # Determine which role to use
133
+ role_id = if model_spec.is_a?(Hash)
134
+ model_spec[:role] || default_role
135
+ else
136
+ # Backward compatibility: if model_spec is a string, use default role
137
+ default_role
138
+ end
139
+
140
+ return nil if role_id.nil? || role_id.empty?
141
+
142
+ # Load the role using existing fetch_role method
143
+ role_prompt = fetch_role(role_id)
144
+ role_prompt.text
145
+ rescue => e
146
+ puts "Warning: Could not load role '#{role_id}' for model: #{e.message}"
147
+ nil
148
+ end
149
+
129
150
  def handle_missing_role(role_id)
130
151
  # Handle empty/nil role_id
131
152
  role_id = role_id.to_s.strip
@@ -5,11 +5,13 @@ require_relative '../extensions/ruby_llm/provider_fix'
5
5
 
6
6
  module AIA
7
7
  class RubyLLMAdapter
8
- attr_reader :tools
8
+ attr_reader :tools, :model_specs
9
9
 
10
10
  def initialize
11
- @models = extract_models_config
11
+ @model_specs = extract_models_config # Full specs with role info
12
+ @models = extract_model_names(@model_specs) # Just model names for backward compat
12
13
  @chats = {}
14
+ @contexts = {} # Store isolated contexts for each model
13
15
 
14
16
  configure_rubyllm
15
17
  refresh_local_model_registry
@@ -80,44 +82,72 @@ module AIA
80
82
  end
81
83
 
82
84
 
85
+ # Create an isolated RubyLLM::Context for a model to prevent cross-talk (ADR-002)
86
+ # Each model gets its own context with provider-specific configuration
87
+ def create_isolated_context_for_model(model_name)
88
+ config = RubyLLM.config.dup
89
+
90
+ # Apply provider-specific configuration
91
+ if model_name.start_with?('lms/')
92
+ config.openai_api_base = ENV.fetch('LMS_API_BASE', 'http://localhost:1234/v1')
93
+ config.openai_api_key = 'dummy' # Local servers don't need a real API key
94
+ elsif model_name.start_with?('osaurus/')
95
+ config.openai_api_base = ENV.fetch('OSAURUS_API_BASE', 'http://localhost:11434/v1')
96
+ config.openai_api_key = 'dummy' # Local servers don't need a real API key
97
+ end
98
+
99
+ RubyLLM::Context.new(config)
100
+ end
101
+
102
+
103
+ # Extract the actual model name and provider from the prefixed model_name
104
+ # Returns: [actual_model, provider] where provider may be nil for auto-detection
105
+ def extract_model_and_provider(model_name)
106
+ if model_name.start_with?('ollama/')
107
+ [model_name.sub('ollama/', ''), 'ollama']
108
+ elsif model_name.start_with?('lms/') || model_name.start_with?('osaurus/')
109
+ [model_name.sub(%r{^(lms|osaurus)/}, ''), 'openai']
110
+ else
111
+ [model_name, nil] # Let RubyLLM auto-detect provider
112
+ end
113
+ end
114
+
115
+
83
116
  def setup_chats_with_tools
84
117
  valid_chats = {}
118
+ valid_contexts = {}
119
+ valid_specs = []
85
120
  failed_models = []
86
121
 
87
- @models.each do |model_name|
122
+ @model_specs.each do |spec|
123
+ model_name = spec[:model] # Actual model name (e.g., "gpt-4o")
124
+ internal_id = spec[:internal_id] # Key for storage (e.g., "gpt-4o#1", "gpt-4o#2")
125
+
88
126
  begin
89
- # Check if this is a local provider model and handle it specially
90
- if model_name.start_with?('ollama/')
91
- # For Ollama models, extract the actual model name and use assume_model_exists
92
- actual_model = model_name.sub('ollama/', '')
93
- chat = RubyLLM.chat(model: actual_model, provider: 'ollama', assume_model_exists: true)
94
- elsif model_name.start_with?('osaurus/')
95
- # For Osaurus models (OpenAI-compatible), create a custom context with the right API base
96
- actual_model = model_name.sub('osaurus/', '')
97
- custom_config = RubyLLM.config.dup
98
- custom_config.openai_api_base = ENV.fetch('OSAURUS_API_BASE', 'http://localhost:11434/v1')
99
- custom_config.openai_api_key = 'dummy' # Local servers don't need a real API key
100
- context = RubyLLM::Context.new(custom_config)
101
- chat = context.chat(model: actual_model, provider: 'openai', assume_model_exists: true)
102
- elsif model_name.start_with?('lms/')
103
- # For LM Studio models (OpenAI-compatible), create a custom context with the right API base
104
- actual_model = model_name.sub('lms/', '')
105
- lms_api_base = ENV.fetch('LMS_API_BASE', 'http://localhost:1234/v1')
127
+ # Create isolated context for this model to prevent cross-talk (ADR-002)
128
+ context = create_isolated_context_for_model(model_name)
106
129
 
107
- # Validate model exists in LM Studio
108
- validate_lms_model!(actual_model, lms_api_base)
130
+ # Determine provider and actual model name
131
+ actual_model, provider = extract_model_and_provider(model_name)
109
132
 
110
- custom_config = RubyLLM.config.dup
111
- custom_config.openai_api_base = lms_api_base
112
- custom_config.openai_api_key = 'dummy' # Local servers don't need a real API key
113
- context = RubyLLM::Context.new(custom_config)
114
- chat = context.chat(model: actual_model, provider: 'openai', assume_model_exists: true)
115
- else
116
- chat = RubyLLM.chat(model: model_name)
133
+ # Validate LM Studio models
134
+ if model_name.start_with?('lms/')
135
+ lms_api_base = ENV.fetch('LMS_API_BASE', 'http://localhost:1234/v1')
136
+ validate_lms_model!(actual_model, lms_api_base)
117
137
  end
118
- valid_chats[model_name] = chat
138
+
139
+ # Create chat using isolated context
140
+ chat = if provider
141
+ context.chat(model: actual_model, provider: provider, assume_model_exists: true)
142
+ else
143
+ context.chat(model: actual_model)
144
+ end
145
+
146
+ valid_chats[internal_id] = chat
147
+ valid_contexts[internal_id] = context
148
+ valid_specs << spec
119
149
  rescue StandardError => e
120
- failed_models << "#{model_name}: #{e.message}"
150
+ failed_models << "#{internal_id}: #{e.message}"
121
151
  end
122
152
  end
123
153
 
@@ -135,10 +165,12 @@ module AIA
135
165
  end
136
166
 
137
167
  @chats = valid_chats
168
+ @contexts = valid_contexts
169
+ @model_specs = valid_specs
138
170
  @models = valid_chats.keys
139
171
 
140
- # Update the config to reflect only the valid models
141
- AIA.config.model = @models
172
+ # Update the config to reflect only the valid models (keep as specs)
173
+ AIA.config.model = @model_specs
142
174
 
143
175
  # Report successful models
144
176
  if failed_models.any?
@@ -254,40 +286,96 @@ module AIA
254
286
  result
255
287
  end
256
288
 
257
- def single_model_chat(prompt, model_name)
258
- chat_instance = @chats[model_name]
289
+ def single_model_chat(prompt, internal_id)
290
+ chat_instance = @chats[internal_id]
259
291
  modes = chat_instance.model.modalities
260
292
 
261
293
  # TODO: Need to consider how to handle multi-mode models
262
294
  result = if modes.text_to_text?
263
- text_to_text_single(prompt, model_name)
295
+ text_to_text_single(prompt, internal_id)
264
296
  elsif modes.image_to_text?
265
- image_to_text_single(prompt, model_name)
297
+ image_to_text_single(prompt, internal_id)
266
298
  elsif modes.text_to_image?
267
- text_to_image_single(prompt, model_name)
299
+ text_to_image_single(prompt, internal_id)
268
300
  elsif modes.text_to_audio?
269
- text_to_audio_single(prompt, model_name)
301
+ text_to_audio_single(prompt, internal_id)
270
302
  elsif modes.audio_to_text?
271
- audio_to_text_single(prompt, model_name)
303
+ audio_to_text_single(prompt, internal_id)
272
304
  else
273
305
  # TODO: what else can be done?
274
- "Error: No matching modality for model #{model_name}"
306
+ "Error: No matching modality for model #{internal_id}"
275
307
  end
276
308
 
277
309
  result
278
310
  end
279
311
 
280
- def multi_model_chat(prompt)
312
+ # Prepend role content to prompt for a specific model (ADR-005)
313
+ def prepend_model_role(prompt, internal_id)
314
+ # Get model spec to find role
315
+ spec = get_model_spec(internal_id)
316
+ return prompt unless spec && spec[:role]
317
+
318
+ # Get role content using PromptHandler
319
+ # Need to create PromptHandler instance if not already available
320
+ prompt_handler = AIA::PromptHandler.new
321
+ role_content = prompt_handler.load_role_for_model(spec, AIA.config.role)
322
+
323
+ return prompt unless role_content
324
+
325
+ # Prepend role to prompt based on prompt type
326
+ if prompt.is_a?(String)
327
+ # Simple string prompt
328
+ "#{role_content}\n\n#{prompt}"
329
+ elsif prompt.is_a?(Array)
330
+ # Conversation array - prepend to first user message
331
+ prepend_role_to_conversation(prompt, role_content)
332
+ else
333
+ prompt
334
+ end
335
+ end
336
+
337
+ def prepend_role_to_conversation(conversation, role_content)
338
+ # Find the first user message and prepend role
339
+ modified = conversation.dup
340
+ first_user_index = modified.find_index { |msg| msg[:role] == "user" || msg["role"] == "user" }
341
+
342
+ if first_user_index
343
+ msg = modified[first_user_index].dup
344
+ role_key = msg.key?(:role) ? :role : "role"
345
+ content_key = msg.key?(:content) ? :content : "content"
346
+
347
+ msg[content_key] = "#{role_content}\n\n#{msg[content_key]}"
348
+ modified[first_user_index] = msg
349
+ end
350
+
351
+ modified
352
+ end
353
+
354
+ def multi_model_chat(prompt_or_contexts)
281
355
  results = {}
282
356
 
357
+ # Check if we're receiving per-model contexts (Hash) or shared prompt (String/Array) - ADR-002 revised
358
+ per_model_contexts = prompt_or_contexts.is_a?(Hash) &&
359
+ prompt_or_contexts.keys.all? { |k| @models.include?(k) }
360
+
283
361
  Async do |task|
284
- @models.each do |model_name|
362
+ @models.each do |internal_id|
285
363
  task.async do
286
364
  begin
287
- result = single_model_chat(prompt, model_name)
288
- results[model_name] = result
365
+ # Use model-specific context if available, otherwise shared prompt
366
+ prompt = if per_model_contexts
367
+ prompt_or_contexts[internal_id]
368
+ else
369
+ prompt_or_contexts
370
+ end
371
+
372
+ # Add per-model role if specified (ADR-005)
373
+ prompt = prepend_model_role(prompt, internal_id)
374
+
375
+ result = single_model_chat(prompt, internal_id)
376
+ results[internal_id] = result
289
377
  rescue StandardError => e
290
- results[model_name] = "Error with #{model_name}: #{e.message}"
378
+ results[internal_id] = "Error with #{internal_id}: #{e.message}"
291
379
  end
292
380
  end
293
381
  end
@@ -319,14 +407,17 @@ module AIA
319
407
  primary_chat = @chats[primary_model]
320
408
 
321
409
  # Build the consensus prompt with all model responses
410
+ # Note: This prompt does NOT include the model's role (ADR-005)
411
+ # The primary model synthesizes neutrally without role bias
322
412
  consensus_prompt = build_consensus_prompt(results)
323
413
 
324
414
  begin
325
415
  # Have the primary model generate the consensus
416
+ # The consensus prompt is already role-neutral
326
417
  consensus_result = primary_chat.ask(consensus_prompt).content
327
418
 
328
- # Format the consensus response
329
- "from: #{primary_model} (consensus)\n#{consensus_result}"
419
+ # Format the consensus response - no role label for consensus
420
+ "from: #{primary_model}\n#{consensus_result}"
330
421
  rescue StandardError => e
331
422
  # If consensus fails, fall back to individual responses
332
423
  "Error generating consensus: #{e.message}\n\n" + format_individual_responses(results)
@@ -370,10 +461,14 @@ module AIA
370
461
  # Return structured data that preserves metrics for multi-model
371
462
  format_multi_model_with_metrics(results)
372
463
  else
373
- # Original string formatting for non-metrics mode
464
+ # Original string formatting for non-metrics mode with role labels (ADR-005)
374
465
  output = []
375
- results.each do |model_name, result|
376
- output << "from: #{model_name}"
466
+ results.each do |internal_id, result|
467
+ # Get model spec to include role in output
468
+ spec = get_model_spec(internal_id)
469
+ display_name = format_model_display_name(spec)
470
+
471
+ output << "from: #{display_name}"
377
472
  # Extract content from RubyLLM::Message if needed
378
473
  content = if result.respond_to?(:content)
379
474
  result.content
@@ -387,6 +482,27 @@ module AIA
387
482
  end
388
483
  end
389
484
 
485
+ # Format display name with instance number and role (ADR-005)
486
+ def format_model_display_name(spec)
487
+ return spec unless spec.is_a?(Hash)
488
+
489
+ model_name = spec[:model]
490
+ instance = spec[:instance]
491
+ role = spec[:role]
492
+
493
+ # Add instance number if > 1
494
+ display = if instance > 1
495
+ "#{model_name} ##{instance}"
496
+ else
497
+ model_name
498
+ end
499
+
500
+ # Add role label if present
501
+ display += " (#{role})" if role
502
+
503
+ display
504
+ end
505
+
390
506
  def format_multi_model_with_metrics(results)
391
507
  # Create a composite response that includes all model responses and metrics
392
508
  formatted_content = []
@@ -452,96 +568,46 @@ module AIA
452
568
 
453
569
  # Clear the chat context/history
454
570
  # Needed for the //clear and //restore directives
571
+ # Simplified with ADR-002: Each model has isolated context, no global state to manage
455
572
  def clear_context
456
- @chats.each do |model_name, chat|
457
- # Option 1: Directly clear the messages array in the current chat object
458
- if chat.instance_variable_defined?(:@messages)
459
- chat.instance_variable_get(:@messages)
460
- # Force a completely empty array, not just attempting to clear it
461
- chat.instance_variable_set(:@messages, [])
462
- end
463
- end
464
-
465
- # Option 2: Force RubyLLM to create a new chat instance at the global level
466
- # This ensures any shared state is reset
467
- RubyLLM.instance_variable_set(:@chat, nil) if RubyLLM.instance_variable_defined?(:@chat)
573
+ old_chats = @chats.dup
574
+ new_chats = {}
468
575
 
469
- # Option 3: Try to create fresh chat instances, but don't exit on failure
470
- # This is safer for use in directives like //restore
471
- old_chats = @chats
472
- @chats = {} # First clear the chats hash
576
+ @models.each do |model_name|
577
+ begin
578
+ # Get the isolated context for this model
579
+ context = @contexts[model_name]
580
+ actual_model, provider = extract_model_and_provider(model_name)
473
581
 
474
- begin
475
- @models.each do |model_name|
476
- # Try to recreate each chat, but if it fails, keep the old one
477
- begin
478
- # Check if this is a local provider model and handle it specially
479
- if model_name.start_with?('ollama/')
480
- actual_model = model_name.sub('ollama/', '')
481
- @chats[model_name] = RubyLLM.chat(model: actual_model, provider: 'ollama', assume_model_exists: true)
482
- elsif model_name.start_with?('osaurus/')
483
- actual_model = model_name.sub('osaurus/', '')
484
- custom_config = RubyLLM.config.dup
485
- custom_config.openai_api_base = ENV.fetch('OSAURUS_API_BASE', 'http://localhost:11434/v1')
486
- custom_config.openai_api_key = 'dummy'
487
- context = RubyLLM::Context.new(custom_config)
488
- @chats[model_name] = context.chat(model: actual_model, provider: 'openai', assume_model_exists: true)
489
- elsif model_name.start_with?('lms/')
490
- actual_model = model_name.sub('lms/', '')
491
- lms_api_base = ENV.fetch('LMS_API_BASE', 'http://localhost:1234/v1')
492
-
493
- # Validate model exists in LM Studio
494
- validate_lms_model!(actual_model, lms_api_base)
495
-
496
- custom_config = RubyLLM.config.dup
497
- custom_config.openai_api_base = lms_api_base
498
- custom_config.openai_api_key = 'dummy'
499
- context = RubyLLM::Context.new(custom_config)
500
- @chats[model_name] = context.chat(model: actual_model, provider: 'openai', assume_model_exists: true)
501
- else
502
- @chats[model_name] = RubyLLM.chat(model: model_name)
503
- end
582
+ # Create a fresh chat instance from the same isolated context
583
+ chat = if provider
584
+ context.chat(model: actual_model, provider: provider, assume_model_exists: true)
585
+ else
586
+ context.chat(model: actual_model)
587
+ end
504
588
 
505
- # Re-add tools if they were previously loaded
506
- if @tools && !@tools.empty? && @chats[model_name].model&.supports_functions?
507
- @chats[model_name].with_tools(*@tools)
508
- end
509
- rescue StandardError => e
510
- # If we can't create a new chat, keep the old one but clear its context
511
- warn "Warning: Could not recreate chat for #{model_name}: #{e.message}. Keeping existing instance."
512
- @chats[model_name] = old_chats[model_name]
513
- # Clear the old chat's messages if possible
514
- if @chats[model_name] && @chats[model_name].instance_variable_defined?(:@messages)
515
- @chats[model_name].instance_variable_set(:@messages, [])
516
- end
589
+ # Re-add tools if they were previously loaded
590
+ if @tools && !@tools.empty? && chat.model&.supports_functions?
591
+ chat.with_tools(*@tools)
517
592
  end
518
- end
519
- rescue StandardError => e
520
- # If something went terribly wrong, restore the old chats but clear their contexts
521
- warn "Warning: Error during context clearing: #{e.message}. Attempting to recover."
522
- @chats = old_chats
523
- @chats.each_value do |chat|
524
- if chat.instance_variable_defined?(:@messages)
593
+
594
+ new_chats[model_name] = chat
595
+ rescue StandardError => e
596
+ # If recreation fails, keep the old chat but clear its messages
597
+ warn "Warning: Could not recreate chat for #{model_name}: #{e.message}. Clearing existing chat."
598
+ chat = old_chats[model_name]
599
+ if chat&.instance_variable_defined?(:@messages)
525
600
  chat.instance_variable_set(:@messages, [])
526
601
  end
602
+ chat.clear_history if chat&.respond_to?(:clear_history)
603
+ new_chats[model_name] = chat
527
604
  end
528
605
  end
529
606
 
530
- # Option 4: Call official clear_history method if it exists
531
- @chats.each_value do |chat|
532
- chat.clear_history if chat.respond_to?(:clear_history)
533
- end
534
-
535
- # Final verification
536
- @chats.each_value do |chat|
537
- if chat.instance_variable_defined?(:@messages) && !chat.instance_variable_get(:@messages).empty?
538
- chat.instance_variable_set(:@messages, [])
539
- end
540
- end
541
-
542
- return 'Chat context successfully cleared.'
607
+ @chats = new_chats
608
+ 'Chat context successfully cleared.'
543
609
  rescue StandardError => e
544
- return "Error clearing chat context: #{e.message}"
610
+ "Error clearing chat context: #{e.message}"
545
611
  end
546
612
 
547
613
 
@@ -624,16 +690,40 @@ module AIA
624
690
  def extract_models_config
625
691
  models_config = AIA.config.model
626
692
 
627
- # Handle backward compatibility - if it's a string, convert to array
693
+ # Handle backward compatibility
628
694
  if models_config.is_a?(String)
629
- [models_config]
695
+ # Old format: single string
696
+ [{model: models_config, role: nil, instance: 1, internal_id: models_config}]
630
697
  elsif models_config.is_a?(Array)
631
- models_config
698
+ if models_config.empty?
699
+ # Empty array - use default
700
+ [{model: 'gpt-4o-mini', role: nil, instance: 1, internal_id: 'gpt-4o-mini'}]
701
+ elsif models_config.first.is_a?(Hash)
702
+ # New format: array of hashes with model specs
703
+ models_config
704
+ else
705
+ # Old format: array of strings
706
+ models_config.map { |m| {model: m, role: nil, instance: 1, internal_id: m} }
707
+ end
632
708
  else
633
- ['gpt-4o-mini'] # fallback to default
709
+ # Fallback to default
710
+ [{model: 'gpt-4o-mini', role: nil, instance: 1, internal_id: 'gpt-4o-mini'}]
634
711
  end
635
712
  end
636
713
 
714
+ def extract_model_names(model_specs)
715
+ # Extract just the model names from the specs
716
+ # For models with instance > 1, use internal_id (e.g., "gpt-4o#2")
717
+ model_specs.map do |spec|
718
+ spec[:internal_id]
719
+ end
720
+ end
721
+
722
+ def get_model_spec(internal_id)
723
+ # Find the spec for a given internal_id
724
+ @model_specs.find { |spec| spec[:internal_id] == internal_id }
725
+ end
726
+
637
727
 
638
728
  def extract_text_prompt(prompt)
639
729
  if prompt.is_a?(String)