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 +4 -4
- data/.version +1 -1
- data/CHANGELOG.md +82 -0
- data/lib/aia/chat_processor_service.rb +14 -5
- data/lib/aia/ruby_llm_adapter.rb +92 -137
- data/lib/aia/session.rb +104 -28
- data/lib/extensions/ruby_llm/provider_fix.rb +57 -12
- metadata +1 -1
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 8fb298b4e9a1ddc4748425decde69e1c11d8e7eb195cf264b918c4d69bf64e01
|
4
|
+
data.tar.gz: a0cffea9fec68a81fbe5e5d36fed20255e33c1d230ec0dd518e08ad7adc56afa
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 6076117839c543fda6756e6657f69d9c35fb561213697e464701eb1515a1d38cff130e7cc57159bf44a8586387144257887906e2ffc63cf183886e816cfd6b84
|
7
|
+
data.tar.gz: 42a6d282bcf587edf84e0db8d13998e0f233f9a66f52977cbaa2d0c152d44e7691207a48a8bec68800e18b1abe81bd2964d841c29eca7437a69f91a2febfd67b
|
data/.version
CHANGED
@@ -1 +1 @@
|
|
1
|
-
0.9.
|
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
|
67
|
-
# with the LLM.
|
68
|
-
def send_to_client(
|
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
|
-
|
72
|
-
|
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
|
data/lib/aia/ruby_llm_adapter.rb
CHANGED
@@ -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
|
-
#
|
90
|
-
|
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
|
-
|
108
|
-
|
125
|
+
# Determine provider and actual model name
|
126
|
+
actual_model, provider = extract_model_and_provider(model_name)
|
109
127
|
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
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(
|
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.
|
474
|
-
|
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
|
-
|
483
|
-
|
484
|
-
|
485
|
-
|
486
|
-
|
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
|
-
|
492
|
-
|
493
|
-
|
494
|
-
|
495
|
-
|
496
|
-
|
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
|
-
|
523
|
-
|
524
|
-
|
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
|
-
|
536
|
-
|
537
|
-
|
538
|
-
|
539
|
-
|
540
|
-
|
541
|
-
if chat
|
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
|
-
|
548
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
372
|
-
|
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
|
-
|
375
|
-
|
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
|
-
|
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
|
-
|
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
|
-
#
|
421
|
-
|
422
|
-
|
423
|
-
|
424
|
-
|
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
|
-
#
|
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
|
-
#
|
463
|
-
# This
|
464
|
-
|
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
|
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
|
-
|
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
|
-
#
|
19
|
-
|
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)
|
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
|
-
|
31
|
-
|
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
|