legion-llm 0.14.17 → 0.14.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 911fae6cf0899e9ce7ed1fea909e023b411f3437ea0225d263bcc1cb95d6f9b6
4
- data.tar.gz: e7f85c4c731a1e5fe8af13dfcc85f4497619256923b6eba39afc2d9e49574863
3
+ metadata.gz: 96388b9de10bee10e82a3d0ec28ee9206710cfdc0c61684022fc94bb9250a9b0
4
+ data.tar.gz: 93362c4565301c19d55a32cb28b9e2e8fae145a893fb7330594188916280fa9e
5
5
  SHA512:
6
- metadata.gz: bd8368de549a561693308531c6a5efd64ba2cbf09ca8f5cc638c76c335944392686a44418204eb5895e9386d388160550b79426c4ed832931c759dec0eee2c4d
7
- data.tar.gz: cd03fa5498c7783c3be2a587c3d6ec8543ab203b19ddb3e6e207388107e30ed54b66f08980f7a5c2b9e64147d4c4defe23bbb62ed46ebf33257a96f3ceeae9d8
6
+ metadata.gz: 717d93944bb01f208072e86b90ecf4649baa084da362cab3217f3c58c76c6e7da3c35ad088907b8ed10a607199ae4604fcc6f89862c2f625e1ce8da9160afdf7
7
+ data.tar.gz: b80edee9bd60d15b433e3a5b816eeb90ee75065b9018a7ec07acc7574191af17904752531a1361cdf41ebdd44f6fcc9dea08a4db69e3336c1ef3371e1e4a8c80
data/CHANGELOG.md CHANGED
@@ -1,5 +1,39 @@
1
1
  # Legion LLM Changelog
2
2
 
3
+ ## [0.14.20] - 2026-07-24
4
+
5
+ ### Fixed
6
+ - **Curator `strip_thinking` no longer runs on tool messages.** Tool results (`role: :tool`) contain raw command output, not thinking content. The method was incorrectly applying `lstrip` and thinking-tag detection to tool results, stripping leading whitespace from Bash output on every curation pass.
7
+ - **Curator `strip_thinking_tags` no longer lstrips content unconditionally.** Leading whitespace is only removed between consecutive tag removals, not from the original input.
8
+ - **StreamAssembler allows interleaved thinking blocks.** Models that produce think→content→think→content sequences no longer have subsequent thinking phases silently dropped. Each thinking phase opens a new block instead of being discarded.
9
+ - **Trigger match strips all tagged blocks and harness messages.** Tool injection no longer triggers on words from `<system-reminder>`, CLAUDE.md content, or harness prompts (SUGGESTION MODE, title generation). Only actual user conversational text is scanned for trigger words.
10
+
11
+ ### Changed
12
+ - **Context curation token targets raised.** `target_context_tokens` 60K→120K, `summarize_threshold` 90K→120K, `target_tokens` 60K→90K.
13
+
14
+ ## [0.14.19] - 2026-07-24
15
+
16
+ ### Fixed
17
+ - **Curator `strip_thinking` no longer runs on tool messages.** Tool results (`role: :tool`) contain raw command output, not thinking content. The method was incorrectly applying `lstrip` and thinking-tag detection to tool results, stripping leading whitespace from Bash output on every curation pass.
18
+
19
+ ## [0.14.18] - 2026-07-24
20
+
21
+ ### Changed
22
+ - `@applied_signals` rewritten from flat Array to structured Hash with named keys (`advisory_id`, `behavioral_synapse_ids`, `trace_ids`, `advisory_types`, `envelope_keys`, `prediction_id`, `response_stats`)
23
+ - `step_response_return` implemented with `populate_response_stats`, `fire_pipeline_observation`, `record_applied_to_gaia`
24
+ - `:response_return` removed from `ASYNC_SAFE_STEPS` (attribution must complete synchronously)
25
+ - `gut_check` moved before `metering` in `POST_PROVIDER_STEPS`
26
+ - GaiaCaller: `caller_class:`/`caller_client:` as explicit kwargs; `:class`/`:client` at top level of caller hash
27
+
28
+ ### Added
29
+ - `fire_pipeline_observation` — calls `Gaia.observe_from_pipeline` for `:human` callers (forms bonds from API clients)
30
+ - `gaia_preferred_lane` in TierAssigner — injects preferred provider/model for `gaia:*` callers from settings
31
+ - `require_relative 'steps/gut_check'` in steps.rb (fixes boot crash)
32
+ - `preferred_provider`/`preferred_model` in `gaia_defaults` settings
33
+
34
+ ### Removed
35
+ - `record_advisory_meta_to_gaia` method and its call in `gaia_advisory` step (superseded by `@applied_signals` seeding)
36
+
3
37
  ## [0.14.17] - 2026-07-22
4
38
 
5
39
  ### Added
@@ -352,24 +352,26 @@ module Legion
352
352
  end
353
353
 
354
354
  def handle_thinking_delta(text, signature)
355
- # Thinking always comes before text per the canonical ordering.
356
- # When emit_thinking_blocks is off we still accumulate the internal
357
- # buffer (so finalize can attach it to the final message audit) but
358
- # never emit blocks to the client.
359
355
  @full_thinking << text.to_s unless text.to_s.empty?
360
356
  @full_thinking_signature ||= signature.to_s unless signature.to_s.empty?
361
357
 
362
358
  return unless @emit_thinking_blocks
363
359
 
364
- unless @thinking_block_open || @thinking_block_closed
360
+ # If thinking arrives after text has started, open a new thinking block
361
+ if @thinking_block_closed
362
+ close_text_block
363
+ @thinking_block_closed = false
364
+ @thinking_block_index = @next_block_index
365
+ @next_block_index += 1
366
+ guard { @emitter.on_thinking_open(block_index: @thinking_block_index) }
367
+ @thinking_block_open = true
368
+ elsif !@thinking_block_open
365
369
  @thinking_block_index = @next_block_index
366
370
  @next_block_index += 1
367
371
  guard { @emitter.on_thinking_open(block_index: @thinking_block_index) }
368
372
  @thinking_block_open = true
369
373
  end
370
374
 
371
- return if @thinking_block_closed
372
-
373
375
  @phase = :mid_thinking
374
376
  guard { @emitter.on_thinking_delta(block_index: @thinking_block_index, text: text.to_s, signature: signature.to_s) }
375
377
  end
@@ -123,6 +123,7 @@ module Legion
123
123
  # Heuristic: remove extended thinking blocks, keep conclusions.
124
124
  def strip_thinking(msg)
125
125
  return msg unless setting(:thinking_eviction, true)
126
+ return msg if msg[:role] == :tool
126
127
 
127
128
  content = msg[:content].to_s
128
129
  stripped = strip_thinking_tags(content)
@@ -136,6 +137,10 @@ module Legion
136
137
  chars_removed = content.length - stripped.length
137
138
  log.info "[llm][curator] action=strip_thinking conversation_id=#{@conversation_id} " \
138
139
  "chars_removed=#{chars_removed} original_chars=#{content.length} stripped_chars=#{stripped.length}"
140
+ if content.length < 50
141
+ log.debug "[llm][curator] action=strip_thinking_debug conversation_id=#{@conversation_id} " \
142
+ "original=#{content.inspect} stripped=#{stripped.inspect} role=#{msg[:role]}"
143
+ end
139
144
  msg.merge(content: stripped, curated: true, original_content: content)
140
145
  end
141
146
 
@@ -293,7 +298,7 @@ module Legion
293
298
  end
294
299
 
295
300
  def strip_thinking_tags(text)
296
- result = text.lstrip
301
+ result = text
297
302
  loop do
298
303
  stripped = false
299
304
  THINKING_TAG_PAIRS.each do |open_tag, close_tag|
@@ -20,7 +20,14 @@ module Legion
20
20
 
21
21
  # GAIA system prompt (highest priority enrichment)
22
22
  if (gaia = enrichments.dig('gaia:system_prompt', :content))
23
- parts << gaia
23
+ prompt = gaia
24
+ if (partner = enrichments.dig('gaia:partner_model', :data))
25
+ # Render partner_model slots (e.g. {{partner_name}}, {{partner_standing}})
26
+ partner.each do |slot, value|
27
+ prompt = prompt.gsub("{{#{slot}}}", value.to_s)
28
+ end
29
+ end
30
+ parts << prompt
24
31
  end
25
32
 
26
33
  # Prior conversation history loaded by the context step.
@@ -50,6 +50,8 @@ module Legion
50
50
  return unless assignment
51
51
 
52
52
  @proactive_tier_assignment = assignment
53
+ @applied_signals[:envelope_keys] << "tier_assigned:#{assignment[:source]}" if @applied_signals.is_a?(Hash)
54
+
53
55
  @audit[:'routing:tier_assignment'] = {
54
56
  outcome: :success,
55
57
  detail: "proactive tier=#{assignment[:tier]} source=#{assignment[:source]}",
@@ -52,6 +52,7 @@ module Legion
52
52
  include Steps::StickyRunners
53
53
  include Steps::ToolHistory
54
54
  include Steps::StickyPersist
55
+ include Steps::GutCheck
55
56
 
56
57
  PRE_PROVIDER_STEPS = %i[
57
58
  tracing_init idempotency conversation_uuid context_load
@@ -61,14 +62,14 @@ module Legion
61
62
  ].freeze
62
63
 
63
64
  POST_PROVIDER_STEPS = %i[
64
- response_normalization post_response metering debate confidence_scoring
65
+ response_normalization post_response gut_check metering debate confidence_scoring
65
66
  tool_calls sticky_persist
66
67
  context_store knowledge_capture response_return
67
68
  ].freeze
68
69
 
69
70
  STEPS = (PRE_PROVIDER_STEPS + %i[provider_call] + POST_PROVIDER_STEPS).freeze
70
71
 
71
- ASYNC_SAFE_STEPS = %i[post_response knowledge_capture response_return].freeze
72
+ ASYNC_SAFE_STEPS = %i[post_response knowledge_capture].freeze
72
73
 
73
74
  THINKING_TAG_PAIRS = [
74
75
  ['<thinking>', '</thinking>'],
@@ -134,6 +135,15 @@ module Legion
134
135
  @injected_tool_map = {}
135
136
  @native_tool_source_map = {}
136
137
  @freshly_triggered_keys = []
138
+ @applied_signals = {
139
+ advisory_id: nil,
140
+ behavioral_synapse_ids: [],
141
+ trace_ids: [],
142
+ advisory_types: [],
143
+ envelope_keys: [],
144
+ prediction_id: nil,
145
+ response_stats: {}
146
+ }
137
147
  @context_accounting = ContextAccounting.empty
138
148
  end
139
149
 
@@ -1073,7 +1083,58 @@ module Legion
1073
1083
  handle_exception(e, level: :warn, operation: 'llm.pipeline.trigger_async_curation', conversation_id: conv_id)
1074
1084
  end
1075
1085
 
1076
- def step_response_return; end
1086
+ def step_response_return
1087
+ populate_response_stats
1088
+ fire_pipeline_observation
1089
+ record_applied_to_gaia
1090
+ end
1091
+
1092
+ def populate_response_stats
1093
+ @applied_signals[:response_stats] = {
1094
+ output_tokens: @extracted_tokens.respond_to?(:output_tokens) ? @extracted_tokens.output_tokens.to_i : 0,
1095
+ provider: @resolved_provider,
1096
+ model: @resolved_model,
1097
+ tier: @resolved_tier,
1098
+ latency_ms: if @timestamps&.dig(:provider_start) && @timestamps[:provider_end]
1099
+ ((@timestamps[:provider_end] - @timestamps[:provider_start]) * 1000).round
1100
+ else
1101
+ 0
1102
+ end,
1103
+ exchange_id: @exchange_id,
1104
+ conversation_id: @request.conversation_id
1105
+ }
1106
+ end
1107
+
1108
+ def fire_pipeline_observation
1109
+ return unless defined?(::Legion::Gaia) && ::Legion::Gaia.respond_to?(:observe_from_pipeline)
1110
+
1111
+ identity = @request.caller&.dig(:requested_by, :identity)
1112
+ caller_type = @request.caller&.dig(:requested_by, :type)
1113
+ return unless identity && caller_type&.to_sym == :human
1114
+
1115
+ exchange_id = @exchange_id || @request.id
1116
+ ::Legion::Gaia.observe_from_pipeline(
1117
+ identity: identity,
1118
+ caller: @request.caller,
1119
+ exchange_id: exchange_id
1120
+ )
1121
+ rescue StandardError => e
1122
+ handle_exception(e, level: :warn, operation: 'llm.pipeline.response_return.observe_pipeline')
1123
+ end
1124
+
1125
+ def record_applied_to_gaia
1126
+ return unless defined?(::Legion::Gaia) && ::Legion::Gaia.respond_to?(:record_response_applied)
1127
+ return if @applied_signals[:advisory_id].nil? && @applied_signals[:behavioral_synapse_ids].empty?
1128
+
1129
+ identity = @request.caller&.dig(:requested_by, :identity)
1130
+ ::Legion::Gaia.record_response_applied(
1131
+ advisory_id: @applied_signals[:advisory_id],
1132
+ identity: identity,
1133
+ applied: @applied_signals.dup
1134
+ )
1135
+ rescue StandardError => e
1136
+ handle_exception(e, level: :warn, operation: 'llm.pipeline.response_return.record_applied')
1137
+ end
1077
1138
 
1078
1139
  def finalize_context_accounting
1079
1140
  tokens = @context_accounting[:tokens]
@@ -1178,6 +1239,10 @@ module Legion
1178
1239
 
1179
1240
  log.debug("[pipeline][build_response] action=build request_id=#{@request.id} provider=#{@resolved_provider} model=#{@resolved_model}")
1180
1241
 
1242
+ # Synchronous delivery attribution hook (H2-llm)
1243
+ # Records what signals (GAIA hints, role mappings, etc) were actually applied to the final route.
1244
+ emit_final_delivery_attribution
1245
+
1181
1246
  Response.build(
1182
1247
  request_id: @request.id,
1183
1248
  conversation_id: @request.conversation_id || "conv_#{SecureRandom.hex(8)}",
@@ -1206,6 +1271,17 @@ module Legion
1206
1271
  )
1207
1272
  end
1208
1273
 
1274
+ def emit_final_delivery_attribution
1275
+ keys = @applied_signals.is_a?(Hash) ? @applied_signals[:envelope_keys] : []
1276
+ return if keys.nil? || keys.empty?
1277
+
1278
+ log.info("[pipeline][attribution] action=emit_final_delivery signals=#{keys.join(',')}")
1279
+ @audit[:'delivery:attribution'] = {
1280
+ applied_signals: @applied_signals,
1281
+ timestamp: Time.now
1282
+ }
1283
+ end
1284
+
1209
1285
  def requested_deferred_tool_names
1210
1286
  return [] unless @request.respond_to?(:metadata)
1211
1287
 
@@ -10,26 +10,30 @@ module Legion
10
10
 
11
11
  extend Legion::Logging::Helper
12
12
 
13
- def chat(message:, phase: 'unknown', tick_trace_id: nil, tick_span_id: nil, caller: nil, **kwargs)
13
+ def chat(message:, phase: 'unknown', tick_trace_id: nil, tick_span_id: nil,
14
+ caller: nil, caller_class: nil, caller_client: nil, **kwargs)
14
15
  log.info("[llm][gaia] chat phase=#{phase} model=#{kwargs[:model] || 'default'}")
16
+
15
17
  request = Request.build(
16
18
  messages: [{ role: :user, content: message }],
17
19
  system: kwargs[:system],
18
20
  routing: { provider: kwargs[:provider], model: kwargs[:model] }.compact,
19
- caller: caller || gaia_caller(phase),
21
+ caller: caller || gaia_caller(phase, caller_class: caller_class, caller_client: caller_client),
20
22
  tracing: gaia_tracing(phase, tick_trace_id, tick_span_id)
21
23
  )
22
24
  Executor.new(request).call
23
25
  end
24
26
 
25
- def structured(message:, schema:, phase: 'unknown', tick_trace_id: nil, tick_span_id: nil, caller: nil, **kwargs)
27
+ def structured(message:, schema:, phase: 'unknown', tick_trace_id: nil, tick_span_id: nil,
28
+ caller: nil, caller_class: nil, caller_client: nil, **kwargs)
26
29
  log.info("[llm][gaia] structured phase=#{phase} model=#{kwargs[:model] || 'default'}")
30
+
27
31
  request = Request.build(
28
32
  messages: [{ role: :user, content: message }],
29
33
  system: kwargs[:system],
30
34
  routing: { provider: kwargs[:provider], model: kwargs[:model] }.compact,
31
35
  response_format: { type: :json_schema, schema: schema },
32
- caller: caller || gaia_caller(phase),
36
+ caller: caller || gaia_caller(phase, caller_class: caller_class, caller_client: caller_client),
33
37
  tracing: gaia_tracing(phase, tick_trace_id, tick_span_id)
34
38
  )
35
39
  Executor.new(request).call
@@ -40,8 +44,8 @@ module Legion
40
44
  LLM.embed(text, **)
41
45
  end
42
46
 
43
- def gaia_caller(phase)
44
- {
47
+ def gaia_caller(phase, caller_class: nil, caller_client: nil)
48
+ hash = {
45
49
  requested_by: {
46
50
  identity: "gaia:tick:#{phase}",
47
51
  type: :system,
@@ -49,6 +53,9 @@ module Legion
49
53
  name: "GAIA #{phase.to_s.tr('_', ' ').capitalize}"
50
54
  }
51
55
  }
56
+ hash[:class] = caller_class if caller_class
57
+ hash[:client] = caller_client if caller_client
58
+ hash
52
59
  end
53
60
 
54
61
  def gaia_tracing(phase, trace_id, span_id)
@@ -36,7 +36,7 @@ module Legion
36
36
 
37
37
  private
38
38
 
39
- def execute_native_tool_loop
39
+ def execute_native_tool_loop # rubocop:disable Metrics/AbcSize
40
40
  messages = native_dispatch_messages.dup
41
41
  max_rounds = Legion::Settings[:llm][:max_tool_rounds].to_i
42
42
  max_rounds = 200 unless max_rounds.positive?
@@ -60,6 +60,17 @@ module Legion
60
60
  result = apply_synthesized_tool_calls(result, tool_calls) if tool_calls.any?
61
61
  end
62
62
  if tool_calls.empty?
63
+ result_text = result.respond_to?(:text) ? result.text : (result[:result] || result[:content])
64
+ result_thinking = if result.respond_to?(:thinking)
65
+ t = result.thinking
66
+ t.respond_to?(:content) ? t.content : t.to_s
67
+ end
68
+ log.debug "[llm][native_tool_loop] action=exit_no_tool_calls round=#{round} " \
69
+ "result_text_length=#{result_text.to_s.length} " \
70
+ "result_text=#{result_text.to_s.inspect} " \
71
+ "thinking_length=#{result_thinking.to_s.length} " \
72
+ "thinking_first_200=#{result_thinking.to_s[0, 200].inspect} " \
73
+ "stop_reason=#{result.respond_to?(:stop_reason) ? result.stop_reason : 'n/a'}"
63
74
  log.debug "[llm][executor] action=native_tool_loop.complete rounds=#{round} reason=no_tool_calls"
64
75
  @last_tool_loop_messages = messages
65
76
  return result
@@ -143,7 +154,7 @@ module Legion
143
154
  @native_tool_loop_round = nil
144
155
  end
145
156
 
146
- def execute_native_streaming_tool_loop(&block)
157
+ def execute_native_streaming_tool_loop(&block) # rubocop:disable Metrics/AbcSize
147
158
  messages = native_dispatch_messages.dup
148
159
  max_rounds = Legion::Settings[:llm][:max_tool_rounds].to_i
149
160
  max_rounds = 200 unless max_rounds.positive?
@@ -167,6 +178,17 @@ module Legion
167
178
  result = apply_synthesized_tool_calls(result, tool_calls) if tool_calls.any?
168
179
  end
169
180
  if tool_calls.empty?
181
+ result_text = result.respond_to?(:text) ? result.text : (result[:result] || result[:content])
182
+ result_thinking = if result.respond_to?(:thinking)
183
+ t = result.thinking
184
+ t.respond_to?(:content) ? t.content : t.to_s
185
+ end
186
+ log.debug "[llm][native_tool_loop] action=stream_exit_no_tool_calls round=#{round} " \
187
+ "result_text_length=#{result_text.to_s.length} " \
188
+ "result_text=#{result_text.to_s.inspect} " \
189
+ "thinking_length=#{result_thinking.to_s.length} " \
190
+ "thinking_first_200=#{result_thinking.to_s[0, 200].inspect} " \
191
+ "stop_reason=#{result.respond_to?(:stop_reason) ? result.stop_reason : 'n/a'}"
170
192
  log.debug "[llm][executor] action=native_streaming_tool_loop.complete rounds=#{round} reason=no_tool_calls"
171
193
  @last_tool_loop_messages = messages
172
194
  return result
@@ -90,6 +90,7 @@ module Legion
90
90
  "original_chars=#{original_chars} synthetic_chars=#{debate_result[:synthetic_response].content.to_s.length} " \
91
91
  "rounds=#{debate_result[:rounds]}"
92
92
  )
93
+ @applied_signals[:envelope_keys] << 'debate:applied' if @applied_signals.is_a?(Hash)
93
94
  @enrichments['debate:result'] = {
94
95
  content: "debate completed: #{debate_result[:rounds]} rounds, judge synthesis produced",
95
96
  data: debate_result[:metadata],
@@ -44,16 +44,12 @@ module Legion
44
44
  timestamp: Time.now
45
45
  }
46
46
 
47
- if advisory[:system_prompt]
48
- log_step_info(:gaia_advisory, :system_prompt_injected, chars: advisory[:system_prompt].to_s.length)
49
- @enrichments['gaia:system_prompt'] = {
50
- content: advisory[:system_prompt],
51
- timestamp: Time.now
52
- }
53
- end
47
+ # system_prompt is injected via gaia:system_prompt enrichment key below
54
48
 
55
49
  if advisory[:routing_hint]
56
- log_step_info(:gaia_advisory, :routing_hint_injected, tier: advisory[:routing_hint][:recommended_tier] || 'none')
50
+ hint = advisory[:routing_hint]
51
+ tier_value = hint[:tier] || hint[:recommended_tier] || 'none'
52
+ log_step_info(:gaia_advisory, :routing_hint_injected, tier: tier_value)
57
53
  @enrichments['gaia:routing_hint'] = {
58
54
  data: advisory[:routing_hint],
59
55
  timestamp: Time.now
@@ -66,7 +62,12 @@ module Legion
66
62
  from: 'gaia', to: 'pipeline'
67
63
  )
68
64
 
69
- record_advisory_meta_to_gaia(advisory)
65
+ advisory_id = advisory[:advisory_id] || advisory['advisory_id']
66
+ if advisory_id
67
+ @applied_signals[:advisory_id] = advisory_id
68
+ @applied_signals[:advisory_types] = classify_advisory_types(advisory)
69
+ end
70
+
70
71
  log_step_info(
71
72
  :gaia_advisory,
72
73
  :complete,
@@ -228,29 +229,6 @@ module Legion
228
229
  nil
229
230
  end
230
231
 
231
- def record_advisory_meta_to_gaia(advisory)
232
- unless defined?(::Legion::Gaia) && ::Legion::Gaia.respond_to?(:record_advisory_meta)
233
- log_step_debug(:gaia_advisory, :record_meta_skipped, reason: :gaia_unavailable)
234
- return
235
- end
236
- unless advisory[:partner_context]
237
- log_step_debug(:gaia_advisory, :record_meta_skipped, reason: :no_partner_context)
238
- return
239
- end
240
-
241
- advisory_id = SecureRandom.uuid
242
- advisory_types = classify_advisory_types(advisory)
243
-
244
- ::Legion::Gaia.record_advisory_meta(
245
- advisory_id: advisory_id,
246
- advisory_types: advisory_types
247
- )
248
- log_step_debug(:gaia_advisory, :record_meta, advisory_id: advisory_id, advisory_type_count: advisory_types.size)
249
- rescue StandardError => e
250
- handle_exception(e, level: :warn, operation: 'llm.pipeline.steps.gaia_advisory.record_meta')
251
- nil
252
- end
253
-
254
232
  def classify_advisory_types(advisory)
255
233
  types = []
256
234
  pc = advisory[:partner_context]
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/logging/helper'
4
+ require_relative 'logging'
5
+
6
+ module Legion
7
+ module LLM
8
+ module Inference
9
+ module Steps
10
+ module GutCheck
11
+ include Legion::Logging::Helper
12
+ include Steps::Logging
13
+
14
+ def step_gut_check
15
+ unless defined?(::Legion::Gaia::Gut) && ::Legion::Gaia::Gut.respond_to?(:check)
16
+ log_step_debug(:gut_check, :skipped, reason: :gaia_gut_unavailable)
17
+ return
18
+ end
19
+
20
+ # Gather draft stats for conflict detection.
21
+ # This typically includes things like routing source, applied signals, and model used.
22
+ draft_stats = {
23
+ resolved_provider: @resolved_provider,
24
+ resolved_model: @resolved_model,
25
+ applied_signals: @applied_signals,
26
+ tier: @resolved_tier || @proactive_tier_assignment&.dig(:tier)
27
+ }
28
+
29
+ identity = @request.caller&.dig(:requested_by, :identity) || 'unknown'
30
+
31
+ log_step_debug(:gut_check, :checking, identity: identity)
32
+
33
+ result = ::Legion::Gaia::Gut.check(
34
+ identity: identity,
35
+ draft_stats: draft_stats
36
+ )
37
+
38
+ if result && result[:conflict]
39
+ log_step_info(:gut_check, :conflict_detected,
40
+ severity: result[:severity],
41
+ reason: result[:reason])
42
+
43
+ @warnings << "Cognitive conflict detected by GAIA Gut Check: #{result[:reason]}"
44
+
45
+ # If the conflict is critical, we could potentially trigger an escalation
46
+ # or mark the response as suspect. For now, we follow the requirement to detect.
47
+ @audit[:'delivery:gut_check'] = {
48
+ outcome: :conflict,
49
+ severity: result[:severity],
50
+ reason: result[:reason],
51
+ timestamp: Time.now
52
+ }
53
+ else
54
+ log_step_debug(:gut_check, :no_conflict)
55
+ end
56
+ rescue StandardError => e
57
+ @warnings << "Gut check error: #{e.message}"
58
+ handle_exception(e, level: :warn, operation: 'llm.pipeline.steps.gut_check')
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -38,6 +38,7 @@ module Legion
38
38
  result = apollo_retrieve(query: query, strategy: strategy)
39
39
  record_rag_enrichment(result, strategy)
40
40
  record_rag_timeline(result, strategy, start_time)
41
+ @applied_signals[:envelope_keys] << "rag_context:#{strategy}" if @applied_signals.is_a?(Hash)
41
42
  rescue StandardError => e
42
43
  @warnings << "RAG context error: #{e.message}"
43
44
  handle_exception(e, level: :warn, operation: 'llm.pipeline.steps.rag_context')
@@ -33,7 +33,12 @@ module Legion
33
33
  end
34
34
 
35
35
  # 1. GAIA routing hint
36
- recommended = nested_value(gaia_hint, :data, :recommended_tier)
36
+ recommended = nested_value(gaia_hint, :data, :tier)
37
+ if recommended.nil? && nested_value(gaia_hint, :data, :recommended_tier)
38
+ log.warn('[llm][routing] gaia_hint uses deprecated :recommended_tier; GAIA advisory should emit :tier')
39
+ recommended = nested_value(gaia_hint, :data, :recommended_tier)
40
+ end
41
+
37
42
  if recommended
38
43
  log.info("[llm][routing] tier_assigned source=gaia tier=#{recommended}")
39
44
  return { tier: recommended.to_sym, source: :gaia }
@@ -78,11 +83,22 @@ module Legion
78
83
 
79
84
  tier = value(mapping, :tier)
80
85
  log.info("[llm][routing] tier_mapping identity=#{identity} tier=#{tier}")
81
- return { tier: tier&.to_sym, intent: value(mapping, :intent), source: :role_mapping }
86
+ result = { tier: tier&.to_sym, intent: value(mapping, :intent), source: :role_mapping }
87
+ result.merge!(gaia_preferred_lane) if identity.start_with?('gaia:')
88
+ return result
82
89
  end
83
90
  nil
84
91
  end
85
92
 
93
+ def gaia_preferred_lane
94
+ provider = Legion::Settings.dig(:llm, :gaia, :preferred_provider)
95
+ model = Legion::Settings.dig(:llm, :gaia, :preferred_model)
96
+ extras = {}
97
+ extras[:provider] = provider.to_sym if provider
98
+ extras[:model] = model.to_s if model
99
+ extras
100
+ end
101
+
86
102
  def tier_mappings
87
103
  configured = Legion::Settings[:llm][:routing][:tier_mappings]
88
104
  configured.nil? || configured.empty? ? DEFAULT_MAPPINGS : configured
@@ -16,9 +16,11 @@ module Legion
16
16
  session_limit = Legion::LLM::Metering::Tokens.summary[:session_max_tokens]
17
17
  log_step_debug(:token_budget, :checking, max_input_tokens: max_input || 'none',
18
18
  session_total: session_total, session_limit: session_limit || 'none')
19
+
19
20
  check_input_cap(max_input) if max_input&.positive?
20
21
  check_session_budget
21
22
  log_step_debug(:token_budget, :passed, max_input_tokens: max_input || 'none')
23
+ @applied_signals[:envelope_keys] << 'token_budget:passed' if @applied_signals.is_a?(Hash)
22
24
  rescue Legion::LLM::TokenBudgetExceeded
23
25
  log_step_info(:token_budget, :blocked)
24
26
  raise
@@ -70,40 +70,56 @@ module Legion
70
70
  record_trigger_match_timeline(0, start_time)
71
71
  end
72
72
 
73
- private
73
+ HARNESS_PREFIXES = [
74
+ '[SUGGESTION MODE',
75
+ '[REQUEST INTERRUPTED',
76
+ 'Write the title in the predominant language'
77
+ ].freeze
74
78
 
75
79
  def extract_recent_text
76
80
  depth = trigger_scan_depth
77
81
  messages = @request.messages.last(depth)
82
+
78
83
  text = messages.filter_map do |msg|
79
84
  next unless msg.is_a?(Hash)
80
85
  next unless (msg[:role] || msg['role']).to_s == 'user'
81
86
 
82
87
  content = msg[:content] || msg['content']
83
- if content.is_a?(Array)
84
- content.filter_map do |c|
85
- if c.is_a?(String)
86
- c
87
- else
88
- (c.is_a?(Hash) ? (c[:text] || c['text']) : nil)
89
- end
90
- end.join(' ')
91
- else
92
- content.to_s
93
- end
88
+ raw = if content.is_a?(Array)
89
+ content.filter_map do |c|
90
+ if c.is_a?(String)
91
+ c
92
+ else
93
+ (c.is_a?(Hash) ? (c[:text] || c['text']) : nil)
94
+ end
95
+ end.join(' ')
96
+ else
97
+ content.to_s
98
+ end
99
+ next if harness_message?(raw)
100
+
101
+ raw
94
102
  end.join(' ')
95
103
 
96
104
  sanitize_trigger_text(text)
97
105
  end
98
106
 
99
107
  def sanitize_trigger_text(text)
100
- stripped = strip_system_reminders(text)
101
- session_text = extract_session_text(stripped)
102
- session_text || stripped
108
+ session_text = extract_session_text(text)
109
+ return session_text if session_text
110
+
111
+ strip_tagged_blocks(text)
103
112
  end
104
113
 
105
- def strip_system_reminders(text)
106
- text.to_s.gsub(%r{<system-reminder\b[^>]*>.*?</system-reminder>}mi, ' ')
114
+ def strip_tagged_blocks(text)
115
+ text.to_s.gsub(%r{<[a-z][\w-]*\b[^>]*>.*?</[a-z][\w-]*>}mi, ' ')
116
+ end
117
+
118
+ def harness_message?(text)
119
+ return false if text.nil? || text.empty?
120
+
121
+ trimmed = text.lstrip
122
+ HARNESS_PREFIXES.any? { |prefix| trimmed.start_with?(prefix) }
107
123
  end
108
124
 
109
125
  def extract_session_text(text)
@@ -31,5 +31,6 @@ require_relative 'steps/confidence_scoring'
31
31
  require_relative 'steps/token_budget'
32
32
  require_relative 'steps/prompt_cache'
33
33
  require_relative 'steps/debate'
34
+ require_relative 'steps/gut_check'
34
35
  require_relative 'steps/tool_history'
35
36
  require_relative 'steps/sticky_persist'
@@ -10,6 +10,7 @@ require_relative 'inference/profile'
10
10
  require_relative 'inference/timeline'
11
11
  require_relative 'inference/tracing'
12
12
  require_relative 'inference/steps'
13
+ require_relative 'inference/steps/gut_check'
13
14
  require_relative 'inference/tool_dispatcher'
14
15
  require_relative 'inference/audit_publisher'
15
16
  require_relative 'inference/enrichment_injector'
@@ -57,6 +57,35 @@ module Legion
57
57
  rng: default_rng,
58
58
  **
59
59
  )
60
+ # 0. GAIA Preferred Provider/Model (OP2)
61
+ if type == :inference && (tiers.include?(:gaia) || tiers.include?('gaia'))
62
+ pref_provider = Legion::Settings.dig(:llm, :gaia, :preferred_provider)
63
+ pref_model = Legion::Settings.dig(:llm, :gaia, :preferred_model)
64
+
65
+ if pref_provider || pref_model
66
+ # If either is set, we try to force a lane matching both (if both set) or either.
67
+ # We prioritize this over general weights for the :gaia tier.
68
+ candidates = Legion::LLM::Inventory.lanes_for(
69
+ provider: pref_provider,
70
+ model: pref_model,
71
+ type: type
72
+ )
73
+
74
+ if candidates&.any?
75
+ # Filter candidates by hard constraints (privacy, context, etc)
76
+ passing = candidates.select do |lane|
77
+ lane_passes_hard_filters?(
78
+ lane: lane, type: type, tiers: tiers, providers: providers, instances: instances,
79
+ models: models, capabilities: capabilities, thinking: thinking, privacy: privacy,
80
+ estimated_context: estimated_context
81
+ )
82
+ end
83
+ # Use the first one that passes hard filters as the preferred selection
84
+ return passing.first if passing.any?
85
+ end
86
+ end
87
+ end
88
+
60
89
  candidates = if providers.size == 1 && instances.size <= 1
61
90
  Legion::LLM::Inventory.lanes_for(
62
91
  provider: providers.first, instance: instances.first, type: type
@@ -330,7 +330,9 @@ module Legion
330
330
 
331
331
  def self.gaia_defaults
332
332
  {
333
- advisory_enabled: true
333
+ advisory_enabled: true,
334
+ preferred_provider: nil,
335
+ preferred_model: nil
334
336
  }
335
337
  end
336
338
 
@@ -435,7 +437,7 @@ module Legion
435
437
  # Claude Code linter/formatter file-dump note.
436
438
  'was modified, either by the user or by a linter'
437
439
  ],
438
- target_context_tokens: 60_000,
440
+ target_context_tokens: 120_000,
439
441
  context_window_threshold: 0.90,
440
442
  archive_dropped_turns: true,
441
443
  archive_preserve_recent: 10
@@ -444,8 +446,8 @@ module Legion
444
446
 
445
447
  def self.conversation_defaults
446
448
  {
447
- summarize_threshold: 90_000,
448
- target_tokens: 60_000,
449
+ summarize_threshold: 120_000,
450
+ target_tokens: 90_000,
449
451
  preserve_recent: 10,
450
452
  auto_compact: true
451
453
  }
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Legion
4
4
  module LLM
5
- VERSION = '0.14.17'
5
+ VERSION = '0.14.20'
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: legion-llm
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.14.17
4
+ version: 0.14.20
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -360,6 +360,7 @@ files:
360
360
  - lib/legion/llm/inference/steps/confidence_scoring.rb
361
361
  - lib/legion/llm/inference/steps/debate.rb
362
362
  - lib/legion/llm/inference/steps/gaia_advisory.rb
363
+ - lib/legion/llm/inference/steps/gut_check.rb
363
364
  - lib/legion/llm/inference/steps/knowledge_capture.rb
364
365
  - lib/legion/llm/inference/steps/logging.rb
365
366
  - lib/legion/llm/inference/steps/mcp_discovery.rb