aia 0.9.17 → 0.9.19

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ae5c7e9497837763b36226f8fc44aeb7dffe692c964e450938ff83aea043c10a
4
- data.tar.gz: b839a219cb3f6e5b34d9aa02ff7f3ced77ff039816b6b2a3d9ef79076d32c302
3
+ metadata.gz: 8fb298b4e9a1ddc4748425decde69e1c11d8e7eb195cf264b918c4d69bf64e01
4
+ data.tar.gz: a0cffea9fec68a81fbe5e5d36fed20255e33c1d230ec0dd518e08ad7adc56afa
5
5
  SHA512:
6
- metadata.gz: 7df6fe4bbf0fd2ce33b9827390340d3cf1f8fd5ad5b4f52ceb343769dee6d699ab87c5dd2686472cd480bf5ac1e20b612f5c69e70b72eab68735560601423d7c
7
- data.tar.gz: 9fa99822319f6e3ff213f62ba0c26121c045e4fa96035596df46b4d723df72cecb19c1abb98f760e6b435941a3cc9b869f6401da771452c3d226930b4f4ea104
6
+ metadata.gz: 6076117839c543fda6756e6657f69d9c35fb561213697e464701eb1515a1d38cff130e7cc57159bf44a8586387144257887906e2ffc63cf183886e816cfd6b84
7
+ data.tar.gz: 42a6d282bcf587edf84e0db8d13998e0f233f9a66f52977cbaa2d0c152d44e7691207a48a8bec68800e18b1abe81bd2964d841c29eca7437a69f91a2febfd67b
data/.version CHANGED
@@ -1 +1 @@
1
- 0.9.17
1
+ 0.9.19
data/CHANGELOG.md CHANGED
@@ -1,6 +1,88 @@
1
1
  # Changelog
2
2
  ## [Unreleased]
3
3
 
4
+ ### [0.9.19] 2025-10-06
5
+
6
+ #### Bug Fixes
7
+ - **CRITICAL BUG FIX**: Fixed multi-model cross-talk issue (#118) where models could see each other's conversation history
8
+ - **BUG FIX**: Implemented complete two-level context isolation to prevent models from contaminating each other's responses
9
+ - **BUG FIX**: Fixed token count inflation caused by models processing combined conversation histories
10
+
11
+ #### Technical Changes
12
+ - **Level 1 (Library)**: Implemented per-model RubyLLM::Context isolation - each model now has its own Context instance (lib/aia/ruby_llm_adapter.rb)
13
+ - **Level 2 (Application)**: Implemented per-model ContextManager isolation - each model maintains its own conversation history (lib/aia/session.rb)
14
+ - Added `parse_multi_model_response` method to extract individual model responses from combined output (lib/aia/session.rb:502-533)
15
+ - Enhanced `multi_model_chat` to accept Hash of per-model conversations (lib/aia/ruby_llm_adapter.rb:305-334)
16
+ - Updated ChatProcessorService to handle both Array (single model) and Hash (multi-model with per-model contexts) inputs (lib/aia/chat_processor_service.rb:68-83)
17
+ - Refactored RubyLLMAdapter:
18
+ - Added `@contexts` hash to store per-model Context instances
19
+ - Added `create_isolated_context_for_model` helper method (lines 84-99)
20
+ - Added `extract_model_and_provider` helper method (lines 102-112)
21
+ - Simplified `clear_context` from 92 lines to 40 lines (56% reduction)
22
+ - Updated directive handlers to work with per-model context managers
23
+ - Added comprehensive test coverage with 6 new tests for multi-model isolation
24
+ - Updated LocalProvidersTest to reflect Context-based architecture
25
+
26
+ #### Architecture
27
+ - **ADR-002-revised**: Complete Multi-Model Isolation (see `.architecture/decisions/adrs/ADR-002-revised-multi-model-isolation.md`)
28
+ - Eliminated global state dependencies in multi-model chat sessions
29
+ - Maintained backward compatibility with single-model mode (verified with tests)
30
+
31
+ #### Test Coverage
32
+ - Added `test/aia/multi_model_isolation_test.rb` with comprehensive isolation tests
33
+ - Tests cover: response parsing, per-model context managers, single-model compatibility, RubyLLM::Context isolation
34
+ - Full test suite: 282 runs, 837 assertions, 0 failures, 0 errors, 13 skips ✅
35
+
36
+ #### Expected Behavior After Fix
37
+ Previously, when running multi-model chat with repeated prompts:
38
+ - ❌ Models would see BOTH their own AND other models' responses
39
+ - ❌ Models would report inflated counts (e.g., "5 times", "6 times" instead of "3 times")
40
+ - ❌ Token counts would be inflated due to contaminated context
41
+
42
+ Now with the fix:
43
+ - ✅ Each model sees ONLY its own conversation history
44
+ - ✅ Each model correctly reports its own interaction count
45
+ - ✅ Token counts accurately reflect per-model conversation size
46
+
47
+ #### Usage Examples
48
+ ```bash
49
+ # Multi-model chat now properly isolates each model's context
50
+ bin/aia --chat --model lms/openai/gpt-oss-20b,ollama/gpt-oss:20b --metrics
51
+
52
+ > pick a random language and say hello
53
+ # LMS: "Habari!" (Swahili)
54
+ # Ollama: "Kaixo!" (Basque)
55
+
56
+ > do it again
57
+ # LMS: "Habari!" (only sees its own previous response)
58
+ # Ollama: "Kaixo!" (only sees its own previous response)
59
+
60
+ > do it again
61
+ > how many times did you say hello to me?
62
+
63
+ # Both models correctly respond: "3 times"
64
+ # (Previously: LMS would say "5 times", Ollama "6 times" due to cross-talk)
65
+ ```
66
+
67
+ ### [0.9.18] 2025-10-05
68
+
69
+ #### Bug Fixes
70
+ - **BUG FIX**: Fixed RubyLLM provider error parsing to handle both OpenAI and LM Studio error formats
71
+ - **BUG FIX**: Fixed "String does not have #dig method" errors when parsing error responses from local providers
72
+ - **BUG FIX**: Enhanced error parsing to gracefully handle malformed JSON responses
73
+
74
+ #### Improvements
75
+ - **ENHANCEMENT**: Removed debug output statements from RubyLLMAdapter for cleaner production logs
76
+ - **ENHANCEMENT**: Improved error handling with debug logging for JSON parsing failures
77
+
78
+ #### Documentation
79
+ - **DOCUMENTATION**: Added Local Models entry to MkDocs navigation for better documentation accessibility
80
+
81
+ #### Technical Changes
82
+ - Enhanced provider_fix extension to support multiple error response formats (lib/extensions/ruby_llm/provider_fix.rb)
83
+ - Cleaned up debug puts statements from RubyLLMAdapter and provider_fix
84
+ - Added robust JSON parsing with fallback error handling
85
+
4
86
  ### [0.9.17] 2025-10-04
5
87
 
6
88
  #### New Features
@@ -63,13 +63,22 @@ module AIA
63
63
  end
64
64
 
65
65
 
66
- # conversation is an Array of Hashes. Each entry is an interchange
67
- # with the LLM.
68
- def send_to_client(conversation)
66
+ # conversation is an Array of Hashes (single model) or Hash of Arrays (multi-model per-model contexts)
67
+ # Each entry is an interchange with the LLM.
68
+ def send_to_client(conversation_or_conversations)
69
69
  maybe_change_model
70
70
 
71
- puts "[DEBUG ChatProcessor] Sending conversation to client: #{conversation.inspect[0..500]}..." if AIA.config.debug
72
- result = AIA.client.chat(conversation)
71
+ # Handle per-model conversations (Hash) or single conversation (Array) - ADR-002 revised
72
+ if conversation_or_conversations.is_a?(Hash)
73
+ # Multi-model with per-model contexts: pass Hash directly to adapter
74
+ puts "[DEBUG ChatProcessor] Sending per-model conversations to client" if AIA.config.debug
75
+ result = AIA.client.chat(conversation_or_conversations)
76
+ else
77
+ # Single conversation for single model
78
+ puts "[DEBUG ChatProcessor] Sending conversation to client: #{conversation_or_conversations.inspect[0..500]}..." if AIA.config.debug
79
+ result = AIA.client.chat(conversation_or_conversations)
80
+ end
81
+
73
82
  puts "[DEBUG ChatProcessor] Client returned: #{result.class} - #{result.inspect[0..500]}..." if AIA.config.debug
74
83
  result
75
84
  end
@@ -10,6 +10,7 @@ module AIA
10
10
  def initialize
11
11
  @models = extract_models_config
12
12
  @chats = {}
13
+ @contexts = {} # Store isolated contexts for each model
13
14
 
14
15
  configure_rubyllm
15
16
  refresh_local_model_registry
@@ -80,42 +81,65 @@ module AIA
80
81
  end
81
82
 
82
83
 
84
+ # Create an isolated RubyLLM::Context for a model to prevent cross-talk (ADR-002)
85
+ # Each model gets its own context with provider-specific configuration
86
+ def create_isolated_context_for_model(model_name)
87
+ config = RubyLLM.config.dup
88
+
89
+ # Apply provider-specific configuration
90
+ if model_name.start_with?('lms/')
91
+ config.openai_api_base = ENV.fetch('LMS_API_BASE', 'http://localhost:1234/v1')
92
+ config.openai_api_key = 'dummy' # Local servers don't need a real API key
93
+ elsif model_name.start_with?('osaurus/')
94
+ config.openai_api_base = ENV.fetch('OSAURUS_API_BASE', 'http://localhost:11434/v1')
95
+ config.openai_api_key = 'dummy' # Local servers don't need a real API key
96
+ end
97
+
98
+ RubyLLM::Context.new(config)
99
+ end
100
+
101
+
102
+ # Extract the actual model name and provider from the prefixed model_name
103
+ # Returns: [actual_model, provider] where provider may be nil for auto-detection
104
+ def extract_model_and_provider(model_name)
105
+ if model_name.start_with?('ollama/')
106
+ [model_name.sub('ollama/', ''), 'ollama']
107
+ elsif model_name.start_with?('lms/') || model_name.start_with?('osaurus/')
108
+ [model_name.sub(%r{^(lms|osaurus)/}, ''), 'openai']
109
+ else
110
+ [model_name, nil] # Let RubyLLM auto-detect provider
111
+ end
112
+ end
113
+
114
+
83
115
  def setup_chats_with_tools
84
116
  valid_chats = {}
117
+ valid_contexts = {}
85
118
  failed_models = []
86
119
 
87
120
  @models.each do |model_name|
88
121
  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')
122
+ # Create isolated context for this model to prevent cross-talk (ADR-002)
123
+ context = create_isolated_context_for_model(model_name)
106
124
 
107
- # Validate model exists in LM Studio
108
- validate_lms_model!(actual_model, lms_api_base)
125
+ # Determine provider and actual model name
126
+ actual_model, provider = extract_model_and_provider(model_name)
109
127
 
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)
128
+ # Validate LM Studio models
129
+ if model_name.start_with?('lms/')
130
+ lms_api_base = ENV.fetch('LMS_API_BASE', 'http://localhost:1234/v1')
131
+ validate_lms_model!(actual_model, lms_api_base)
117
132
  end
133
+
134
+ # Create chat using isolated context
135
+ chat = if provider
136
+ context.chat(model: actual_model, provider: provider, assume_model_exists: true)
137
+ else
138
+ context.chat(model: actual_model)
139
+ end
140
+
118
141
  valid_chats[model_name] = chat
142
+ valid_contexts[model_name] = context
119
143
  rescue StandardError => e
120
144
  failed_models << "#{model_name}: #{e.message}"
121
145
  end
@@ -135,6 +159,7 @@ module AIA
135
159
  end
136
160
 
137
161
  @chats = valid_chats
162
+ @contexts = valid_contexts
138
163
  @models = valid_chats.keys
139
164
 
140
165
  # Update the config to reflect only the valid models
@@ -243,10 +268,6 @@ module AIA
243
268
 
244
269
 
245
270
  def chat(prompt)
246
- puts "[DEBUG RubyLLMAdapter.chat] Received prompt class: #{prompt.class}" if AIA.config.debug
247
- puts "[DEBUG RubyLLMAdapter.chat] Prompt inspect: #{prompt.inspect[0..500]}..." if AIA.config.debug
248
- puts "[DEBUG RubyLLMAdapter.chat] Models: #{@models.inspect}" if AIA.config.debug
249
-
250
271
  result = if @models.size == 1
251
272
  # Single model - use the original behavior
252
273
  single_model_chat(prompt, @models.first)
@@ -255,52 +276,50 @@ module AIA
255
276
  multi_model_chat(prompt)
256
277
  end
257
278
 
258
- puts "[DEBUG RubyLLMAdapter.chat] Returning result class: #{result.class}" if AIA.config.debug
259
- puts "[DEBUG RubyLLMAdapter.chat] Result inspect: #{result.inspect[0..500]}..." if AIA.config.debug
260
279
  result
261
280
  end
262
281
 
263
282
  def single_model_chat(prompt, model_name)
264
- puts "[DEBUG single_model_chat] Model name: #{model_name}" if AIA.config.debug
265
283
  chat_instance = @chats[model_name]
266
- puts "[DEBUG single_model_chat] Chat instance: #{chat_instance.class}" if AIA.config.debug
267
-
268
284
  modes = chat_instance.model.modalities
269
- puts "[DEBUG single_model_chat] Modalities: #{modes.inspect}" if AIA.config.debug
270
285
 
271
286
  # TODO: Need to consider how to handle multi-mode models
272
287
  result = if modes.text_to_text?
273
- puts "[DEBUG single_model_chat] Using text_to_text_single" if AIA.config.debug
274
288
  text_to_text_single(prompt, model_name)
275
289
  elsif modes.image_to_text?
276
- puts "[DEBUG single_model_chat] Using image_to_text_single" if AIA.config.debug
277
290
  image_to_text_single(prompt, model_name)
278
291
  elsif modes.text_to_image?
279
- puts "[DEBUG single_model_chat] Using text_to_image_single" if AIA.config.debug
280
292
  text_to_image_single(prompt, model_name)
281
293
  elsif modes.text_to_audio?
282
- puts "[DEBUG single_model_chat] Using text_to_audio_single" if AIA.config.debug
283
294
  text_to_audio_single(prompt, model_name)
284
295
  elsif modes.audio_to_text?
285
- puts "[DEBUG single_model_chat] Using audio_to_text_single" if AIA.config.debug
286
296
  audio_to_text_single(prompt, model_name)
287
297
  else
288
- puts "[DEBUG single_model_chat] No matching modality!" if AIA.config.debug
289
298
  # TODO: what else can be done?
290
299
  "Error: No matching modality for model #{model_name}"
291
300
  end
292
301
 
293
- puts "[DEBUG single_model_chat] Result class: #{result.class}" if AIA.config.debug
294
302
  result
295
303
  end
296
304
 
297
- def multi_model_chat(prompt)
305
+ def multi_model_chat(prompt_or_contexts)
298
306
  results = {}
299
307
 
308
+ # Check if we're receiving per-model contexts (Hash) or shared prompt (String/Array) - ADR-002 revised
309
+ per_model_contexts = prompt_or_contexts.is_a?(Hash) &&
310
+ prompt_or_contexts.keys.all? { |k| @models.include?(k) }
311
+
300
312
  Async do |task|
301
313
  @models.each do |model_name|
302
314
  task.async do
303
315
  begin
316
+ # Use model-specific context if available, otherwise shared prompt
317
+ prompt = if per_model_contexts
318
+ prompt_or_contexts[model_name]
319
+ else
320
+ prompt_or_contexts
321
+ end
322
+
304
323
  result = single_model_chat(prompt, model_name)
305
324
  results[model_name] = result
306
325
  rescue StandardError => e
@@ -469,96 +488,46 @@ module AIA
469
488
 
470
489
  # Clear the chat context/history
471
490
  # Needed for the //clear and //restore directives
491
+ # Simplified with ADR-002: Each model has isolated context, no global state to manage
472
492
  def clear_context
473
- @chats.each do |model_name, chat|
474
- # Option 1: Directly clear the messages array in the current chat object
475
- if chat.instance_variable_defined?(:@messages)
476
- chat.instance_variable_get(:@messages)
477
- # Force a completely empty array, not just attempting to clear it
478
- chat.instance_variable_set(:@messages, [])
479
- end
480
- end
493
+ old_chats = @chats.dup
494
+ new_chats = {}
481
495
 
482
- # Option 2: Force RubyLLM to create a new chat instance at the global level
483
- # This ensures any shared state is reset
484
- RubyLLM.instance_variable_set(:@chat, nil) if RubyLLM.instance_variable_defined?(:@chat)
485
-
486
- # Option 3: Try to create fresh chat instances, but don't exit on failure
487
- # This is safer for use in directives like //restore
488
- old_chats = @chats
489
- @chats = {} # First clear the chats hash
496
+ @models.each do |model_name|
497
+ begin
498
+ # Get the isolated context for this model
499
+ context = @contexts[model_name]
500
+ actual_model, provider = extract_model_and_provider(model_name)
490
501
 
491
- begin
492
- @models.each do |model_name|
493
- # Try to recreate each chat, but if it fails, keep the old one
494
- begin
495
- # Check if this is a local provider model and handle it specially
496
- if model_name.start_with?('ollama/')
497
- actual_model = model_name.sub('ollama/', '')
498
- @chats[model_name] = RubyLLM.chat(model: actual_model, provider: 'ollama', assume_model_exists: true)
499
- elsif model_name.start_with?('osaurus/')
500
- actual_model = model_name.sub('osaurus/', '')
501
- custom_config = RubyLLM.config.dup
502
- custom_config.openai_api_base = ENV.fetch('OSAURUS_API_BASE', 'http://localhost:11434/v1')
503
- custom_config.openai_api_key = 'dummy'
504
- context = RubyLLM::Context.new(custom_config)
505
- @chats[model_name] = context.chat(model: actual_model, provider: 'openai', assume_model_exists: true)
506
- elsif model_name.start_with?('lms/')
507
- actual_model = model_name.sub('lms/', '')
508
- lms_api_base = ENV.fetch('LMS_API_BASE', 'http://localhost:1234/v1')
509
-
510
- # Validate model exists in LM Studio
511
- validate_lms_model!(actual_model, lms_api_base)
512
-
513
- custom_config = RubyLLM.config.dup
514
- custom_config.openai_api_base = lms_api_base
515
- custom_config.openai_api_key = 'dummy'
516
- context = RubyLLM::Context.new(custom_config)
517
- @chats[model_name] = context.chat(model: actual_model, provider: 'openai', assume_model_exists: true)
518
- else
519
- @chats[model_name] = RubyLLM.chat(model: model_name)
520
- end
502
+ # Create a fresh chat instance from the same isolated context
503
+ chat = if provider
504
+ context.chat(model: actual_model, provider: provider, assume_model_exists: true)
505
+ else
506
+ context.chat(model: actual_model)
507
+ end
521
508
 
522
- # Re-add tools if they were previously loaded
523
- if @tools && !@tools.empty? && @chats[model_name].model&.supports_functions?
524
- @chats[model_name].with_tools(*@tools)
525
- end
526
- rescue StandardError => e
527
- # If we can't create a new chat, keep the old one but clear its context
528
- warn "Warning: Could not recreate chat for #{model_name}: #{e.message}. Keeping existing instance."
529
- @chats[model_name] = old_chats[model_name]
530
- # Clear the old chat's messages if possible
531
- if @chats[model_name] && @chats[model_name].instance_variable_defined?(:@messages)
532
- @chats[model_name].instance_variable_set(:@messages, [])
533
- end
509
+ # Re-add tools if they were previously loaded
510
+ if @tools && !@tools.empty? && chat.model&.supports_functions?
511
+ chat.with_tools(*@tools)
534
512
  end
535
- end
536
- rescue StandardError => e
537
- # If something went terribly wrong, restore the old chats but clear their contexts
538
- warn "Warning: Error during context clearing: #{e.message}. Attempting to recover."
539
- @chats = old_chats
540
- @chats.each_value do |chat|
541
- if chat.instance_variable_defined?(:@messages)
513
+
514
+ new_chats[model_name] = chat
515
+ rescue StandardError => e
516
+ # If recreation fails, keep the old chat but clear its messages
517
+ warn "Warning: Could not recreate chat for #{model_name}: #{e.message}. Clearing existing chat."
518
+ chat = old_chats[model_name]
519
+ if chat&.instance_variable_defined?(:@messages)
542
520
  chat.instance_variable_set(:@messages, [])
543
521
  end
522
+ chat.clear_history if chat&.respond_to?(:clear_history)
523
+ new_chats[model_name] = chat
544
524
  end
545
525
  end
546
526
 
547
- # Option 4: Call official clear_history method if it exists
548
- @chats.each_value do |chat|
549
- chat.clear_history if chat.respond_to?(:clear_history)
550
- end
551
-
552
- # Final verification
553
- @chats.each_value do |chat|
554
- if chat.instance_variable_defined?(:@messages) && !chat.instance_variable_get(:@messages).empty?
555
- chat.instance_variable_set(:@messages, [])
556
- end
557
- end
558
-
559
- return 'Chat context successfully cleared.'
527
+ @chats = new_chats
528
+ 'Chat context successfully cleared.'
560
529
  rescue StandardError => e
561
- return "Error clearing chat context: #{e.message}"
530
+ "Error clearing chat context: #{e.message}"
562
531
  end
563
532
 
564
533
 
@@ -672,29 +641,15 @@ module AIA
672
641
  chat_instance = @chats[model_name]
673
642
  text_prompt = extract_text_prompt(prompt)
674
643
 
675
- puts "[DEBUG RubyLLMAdapter] Sending to model #{model_name}: #{text_prompt[0..100]}..." if AIA.config.debug
676
-
677
644
  response = if AIA.config.context_files.empty?
678
645
  chat_instance.ask(text_prompt)
679
646
  else
680
647
  chat_instance.ask(text_prompt, with: AIA.config.context_files)
681
648
  end
682
649
 
683
- # Debug output to understand the response structure
684
- puts "[DEBUG RubyLLMAdapter] Response class: #{response.class}" if AIA.config.debug
685
- puts "[DEBUG RubyLLMAdapter] Response inspect: #{response.inspect[0..500]}..." if AIA.config.debug
686
-
687
- if response.respond_to?(:content)
688
- puts "[DEBUG RubyLLMAdapter] Response content: #{response.content[0..200]}..." if AIA.config.debug
689
- else
690
- puts "[DEBUG RubyLLMAdapter] Response (no content method): #{response.to_s[0..200]}..." if AIA.config.debug
691
- end
692
-
693
650
  # Return the full response object to preserve token information
694
651
  response
695
652
  rescue StandardError => e
696
- puts "[DEBUG RubyLLMAdapter] Error in text_to_text_single: #{e.class} - #{e.message}" if AIA.config.debug
697
- puts "[DEBUG RubyLLMAdapter] Backtrace: #{e.backtrace[0..5].join("\n")}" if AIA.config.debug
698
653
  e.message
699
654
  end
700
655
 
data/lib/aia/session.rb CHANGED
@@ -45,7 +45,21 @@ module AIA
45
45
  end
46
46
 
47
47
  def initialize_components
48
- @context_manager = ContextManager.new(system_prompt: AIA.config.system_prompt)
48
+ # For multi-model: create separate context manager per model (ADR-002 revised)
49
+ # For single-model: maintain backward compatibility with single context manager
50
+ if AIA.config.model.is_a?(Array) && AIA.config.model.size > 1
51
+ @context_managers = {}
52
+ AIA.config.model.each do |model_name|
53
+ @context_managers[model_name] = ContextManager.new(
54
+ system_prompt: AIA.config.system_prompt
55
+ )
56
+ end
57
+ @context_manager = nil # Signal we're using per-model managers
58
+ else
59
+ @context_manager = ContextManager.new(system_prompt: AIA.config.system_prompt)
60
+ @context_managers = nil
61
+ end
62
+
49
63
  @ui_presenter = UIPresenter.new
50
64
  @directive_processor = DirectiveProcessor.new
51
65
  @chat_processor = ChatProcessorService.new(@ui_presenter, @directive_processor)
@@ -368,11 +382,29 @@ module AIA
368
382
  @chat_prompt.text = follow_up_prompt
369
383
  processed_prompt = @chat_prompt.to_s
370
384
 
371
- @context_manager.add_to_context(role: "user", content: processed_prompt)
372
- conversation = @context_manager.get_context
385
+ # Handle per-model contexts (ADR-002 revised)
386
+ if @context_managers
387
+ # Multi-model: add user prompt to each model's context
388
+ @context_managers.each_value do |ctx_mgr|
389
+ ctx_mgr.add_to_context(role: "user", content: processed_prompt)
390
+ end
373
391
 
374
- @ui_presenter.display_thinking_animation
375
- response_data = @chat_processor.process_prompt(conversation)
392
+ # Get per-model conversations
393
+ conversations = {}
394
+ @context_managers.each do |model_name, ctx_mgr|
395
+ conversations[model_name] = ctx_mgr.get_context
396
+ end
397
+
398
+ @ui_presenter.display_thinking_animation
399
+ response_data = @chat_processor.process_prompt(conversations)
400
+ else
401
+ # Single-model: use original logic
402
+ @context_manager.add_to_context(role: "user", content: processed_prompt)
403
+ conversation = @context_manager.get_context
404
+
405
+ @ui_presenter.display_thinking_animation
406
+ response_data = @chat_processor.process_prompt(conversation)
407
+ end
376
408
 
377
409
  # Handle new response format with metrics
378
410
  if response_data.is_a?(Hash)
@@ -386,7 +418,7 @@ module AIA
386
418
  end
387
419
 
388
420
  @ui_presenter.display_ai_response(content)
389
-
421
+
390
422
  # Display metrics if enabled and available (chat mode only)
391
423
  if AIA.config.show_metrics
392
424
  if multi_metrics
@@ -397,8 +429,22 @@ module AIA
397
429
  @ui_presenter.display_token_metrics(metrics)
398
430
  end
399
431
  end
400
-
401
- @context_manager.add_to_context(role: "assistant", content: content)
432
+
433
+ # Add responses to context (ADR-002 revised)
434
+ if @context_managers
435
+ # Multi-model: parse combined response and add each model's response to its own context
436
+ parsed_responses = parse_multi_model_response(content)
437
+ parsed_responses.each do |model_name, model_response|
438
+ @context_managers[model_name]&.add_to_context(
439
+ role: "assistant",
440
+ content: model_response
441
+ )
442
+ end
443
+ else
444
+ # Single-model: add response to single context
445
+ @context_manager.add_to_context(role: "assistant", content: content)
446
+ end
447
+
402
448
  @chat_processor.speak(content)
403
449
 
404
450
  @ui_presenter.display_separator
@@ -406,7 +452,10 @@ module AIA
406
452
  end
407
453
 
408
454
  def process_chat_directive(follow_up_prompt)
409
- directive_output = @directive_processor.process(follow_up_prompt, @context_manager)
455
+ # For multi-model, use first context manager for directives (ADR-002 revised)
456
+ # TODO: Consider if directives should affect all contexts or just one
457
+ context_for_directive = @context_managers ? @context_managers.values.first : @context_manager
458
+ directive_output = @directive_processor.process(follow_up_prompt, context_for_directive)
410
459
 
411
460
  return handle_clear_directive if follow_up_prompt.strip.start_with?("//clear")
412
461
  return handle_checkpoint_directive(directive_output) if follow_up_prompt.strip.start_with?("//checkpoint")
@@ -417,13 +466,16 @@ module AIA
417
466
  end
418
467
 
419
468
  def handle_clear_directive
420
- # The directive processor has called context_manager.clear_context
421
- # but we need to also clear the LLM client's context
422
-
423
- # First, clear the context manager's context
424
- @context_manager.clear_context(keep_system_prompt: true)
469
+ # Clear context manager(s) - ADR-002 revised
470
+ if @context_managers
471
+ # Multi-model: clear all context managers
472
+ @context_managers.each_value { |ctx_mgr| ctx_mgr.clear_context(keep_system_prompt: true) }
473
+ else
474
+ # Single-model: clear single context manager
475
+ @context_manager.clear_context(keep_system_prompt: true)
476
+ end
425
477
 
426
- # Second, try clearing the client's context
478
+ # Try clearing the client's context
427
479
  if AIA.config.client && AIA.config.client.respond_to?(:clear_context)
428
480
  begin
429
481
  AIA.config.client.clear_context
@@ -446,10 +498,9 @@ module AIA
446
498
  end
447
499
 
448
500
  def handle_restore_directive(directive_output)
449
- # If the restore was successful, we also need to refresh the client's context
501
+ # If the restore was successful, we also need to refresh the client's context - ADR-002 revised
450
502
  if directive_output.start_with?("Context restored")
451
503
  # Clear the client's context without reinitializing the entire adapter
452
- # This avoids the risk of exiting if model initialization fails
453
504
  if AIA.config.client && AIA.config.client.respond_to?(:clear_context)
454
505
  begin
455
506
  AIA.config.client.clear_context
@@ -459,17 +510,9 @@ module AIA
459
510
  end
460
511
  end
461
512
 
462
- # Rebuild the conversation in the LLM client from the restored context
463
- # This ensures the LLM's internal state matches what we restored
464
- if AIA.config.client && @context_manager
465
- begin
466
- restored_context = @context_manager.get_context
467
- # The client's context has been cleared, so we can safely continue
468
- # The next interaction will use the restored context from context_manager
469
- rescue => e
470
- STDERR.puts "Warning: Error syncing restored context: #{e.message}"
471
- end
472
- end
513
+ # Note: For multi-model, only the first context manager was used for restore
514
+ # This is a limitation of the current directive system
515
+ # TODO: Consider supporting restore for all context managers
473
516
  end
474
517
 
475
518
  @ui_presenter.display_info(directive_output)
@@ -485,6 +528,39 @@ module AIA
485
528
  "I executed this directive: #{follow_up_prompt}\nHere's the output: #{directive_output}\nLet's continue our conversation."
486
529
  end
487
530
 
531
+ # Parse multi-model response into per-model responses (ADR-002 revised)
532
+ # Input: "from: lms/model\nHabari!\n\nfrom: ollama/model\nKaixo!"
533
+ # Output: {"lms/model" => "Habari!", "ollama/model" => "Kaixo!"}
534
+ def parse_multi_model_response(combined_response)
535
+ return {} if combined_response.nil? || combined_response.empty?
536
+
537
+ responses = {}
538
+ current_model = nil
539
+ current_content = []
540
+
541
+ combined_response.each_line do |line|
542
+ if line =~ /^from:\s+(.+)$/
543
+ # Save previous model's response
544
+ if current_model
545
+ responses[current_model] = current_content.join.strip
546
+ end
547
+
548
+ # Start new model
549
+ current_model = $1.strip
550
+ current_content = []
551
+ elsif current_model
552
+ current_content << line
553
+ end
554
+ end
555
+
556
+ # Save last model's response
557
+ if current_model
558
+ responses[current_model] = current_content.join.strip
559
+ end
560
+
561
+ responses
562
+ end
563
+
488
564
  def cleanup_chat_prompt
489
565
  if @chat_prompt_id
490
566
  puts "[DEBUG] Cleaning up chat prompt: #{@chat_prompt_id}" if AIA.debug?
@@ -1,34 +1,79 @@
1
1
  # lib/extensions/ruby_llm/provider_fix.rb
2
2
  #
3
- # Monkey patch to fix LM Studio compatibility with RubyLLM Provider
3
+ # Monkey patch to fix LM Studio compatibility with RubyLLM
4
4
  # LM Studio sometimes returns response.body as a String that fails JSON parsing
5
5
  # This causes "String does not have #dig method" errors in parse_error
6
6
 
7
+ # Load RubyLLM first to ensure Provider class exists
8
+ require 'ruby_llm'
9
+
7
10
  module RubyLLM
8
- class Provider
11
+ module ProviderErrorFix
9
12
  # Override the parse_error method to handle String responses from LM Studio
13
+ # Parses error response from provider API.
14
+ #
15
+ # Supports two error formats:
16
+ # 1. OpenAI standard: {"error": {"message": "...", "type": "...", "code": "..."}}
17
+ # 2. Simple format: {"error": "error message"}
18
+ #
19
+ # @param response [Faraday::Response] The HTTP response
20
+ # @return [String, nil] The error message or nil if parsing fails
21
+ #
22
+ # @example OpenAI format
23
+ # response = double(body: '{"error": {"message": "Rate limit exceeded"}}')
24
+ # parse_error(response) #=> "Rate limit exceeded"
25
+ #
26
+ # @example Simple format (LM Studio, some local providers)
27
+ # response = double(body: '{"error": "Token limit exceeded"}')
28
+ # parse_error(response) #=> "Token limit exceeded"
10
29
  def parse_error(response)
11
30
  return if response.body.empty?
12
31
 
13
32
  body = try_parse_json(response.body)
14
-
15
- # Be more explicit about type checking to prevent String#dig errors
16
33
  case body
17
34
  when Hash
18
- # Only call dig if we're certain it's a Hash
19
- body.dig('error', 'message')
35
+ # Handle both formats:
36
+ # - {"error": "message"} (LM Studio, some providers)
37
+ # - {"error": {"message": "..."}} (OpenAI standard)
38
+ error_value = body['error']
39
+ return nil unless error_value
40
+
41
+ case error_value
42
+ when Hash
43
+ error_value['message']
44
+ when String
45
+ error_value
46
+ else
47
+ error_value.to_s if error_value
48
+ end
20
49
  when Array
21
- # Only call dig on array elements if they're Hashes
22
50
  body.filter_map do |part|
23
- part.is_a?(Hash) ? part.dig('error', 'message') : part.to_s
51
+ next unless part.is_a?(Hash)
52
+
53
+ error_value = part['error']
54
+ next unless error_value
55
+
56
+ case error_value
57
+ when Hash then error_value['message']
58
+ when String then error_value
59
+ else error_value.to_s if error_value
60
+ end
24
61
  end.join('. ')
25
62
  else
26
- # For Strings or any other type, convert to string
27
63
  body.to_s
28
64
  end
29
65
  rescue StandardError => e
30
- # Fallback in case anything goes wrong
31
- "Error parsing response: #{e.message}"
66
+ RubyLLM.logger.debug "Error parsing response: #{e.message}"
67
+ nil
32
68
  end
33
69
  end
34
- end
70
+ end
71
+
72
+ # Apply the prepend to all Provider subclasses
73
+ # LM Studio uses the OpenAI provider, so we need to prepend to all provider classes
74
+ RubyLLM::Provider.prepend(RubyLLM::ProviderErrorFix)
75
+
76
+ # Also prepend to all registered provider classes
77
+ RubyLLM::Provider.providers.each do |slug, provider_class|
78
+ provider_class.prepend(RubyLLM::ProviderErrorFix)
79
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: aia
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.17
4
+ version: 0.9.19
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dewayne VanHoozer