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 +4 -4
- data/CHANGELOG.md +34 -0
- data/lib/legion/llm/api/stream_assembler.rb +9 -7
- data/lib/legion/llm/context/curator.rb +6 -1
- data/lib/legion/llm/inference/enrichment_injector.rb +8 -1
- data/lib/legion/llm/inference/executor/routing.rb +2 -0
- data/lib/legion/llm/inference/executor.rb +79 -3
- data/lib/legion/llm/inference/gaia_caller.rb +13 -6
- data/lib/legion/llm/inference/native_tool_loop.rb +24 -2
- data/lib/legion/llm/inference/steps/debate.rb +1 -0
- data/lib/legion/llm/inference/steps/gaia_advisory.rb +10 -32
- data/lib/legion/llm/inference/steps/gut_check.rb +64 -0
- data/lib/legion/llm/inference/steps/rag_context.rb +1 -0
- data/lib/legion/llm/inference/steps/tier_assigner.rb +18 -2
- data/lib/legion/llm/inference/steps/token_budget.rb +2 -0
- data/lib/legion/llm/inference/steps/trigger_match.rb +33 -17
- data/lib/legion/llm/inference/steps.rb +1 -0
- data/lib/legion/llm/inference.rb +1 -0
- data/lib/legion/llm/router.rb +29 -0
- data/lib/legion/llm/settings.rb +6 -4
- data/lib/legion/llm/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 96388b9de10bee10e82a3d0ec28ee9206710cfdc0c61684022fc94bb9250a9b0
|
|
4
|
+
data.tar.gz: 93362c4565301c19d55a32cb28b9e2e8fae145a893fb7330594188916280fa9e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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, :
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
101
|
-
session_text
|
|
102
|
-
|
|
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
|
|
106
|
-
text.to_s.gsub(%r{<
|
|
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'
|
data/lib/legion/llm/inference.rb
CHANGED
|
@@ -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'
|
data/lib/legion/llm/router.rb
CHANGED
|
@@ -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
|
data/lib/legion/llm/settings.rb
CHANGED
|
@@ -330,7 +330,9 @@ module Legion
|
|
|
330
330
|
|
|
331
331
|
def self.gaia_defaults
|
|
332
332
|
{
|
|
333
|
-
advisory_enabled:
|
|
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:
|
|
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:
|
|
448
|
-
target_tokens:
|
|
449
|
+
summarize_threshold: 120_000,
|
|
450
|
+
target_tokens: 90_000,
|
|
449
451
|
preserve_recent: 10,
|
|
450
452
|
auto_compact: true
|
|
451
453
|
}
|
data/lib/legion/llm/version.rb
CHANGED
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.
|
|
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
|