legion-llm 0.14.16 → 0.14.18
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 +29 -0
- data/lib/legion/llm/api/namespaces/anthropic/messages.rb +1 -0
- data/lib/legion/llm/api/namespaces/native/inference.rb +1 -0
- data/lib/legion/llm/api/namespaces/openai/chat/completions.rb +1 -0
- data/lib/legion/llm/api/namespaces/openai/responses.rb +1 -0
- data/lib/legion/llm/api/shared_helpers.rb +11 -0
- data/lib/legion/llm/inference/enrichment_injector.rb +8 -1
- data/lib/legion/llm/inference/executor/routing.rb +10 -19
- data/lib/legion/llm/inference/executor.rb +79 -3
- data/lib/legion/llm/inference/gaia_caller.rb +13 -6
- 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.rb +1 -0
- data/lib/legion/llm/inference.rb +1 -0
- data/lib/legion/llm/inventory.rb +22 -0
- data/lib/legion/llm/router.rb +56 -1
- data/lib/legion/llm/settings.rb +3 -1
- 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: eb744016e12f01c149812c027353b25ecba016826789d83a3a795a5b9cba2a19
|
|
4
|
+
data.tar.gz: 8ed359b0f6d0b9d147a04a419ad30bf26834a258b1448c625e0a6614187f5db3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 74db62246f4ff813981821a699374fa21e39d84b2d7496c28c969c3f2345ea777d7c74cd5a763222b5fa0014864eda6ae81332609a595b9f51a02162198af778
|
|
7
|
+
data.tar.gz: 46b000f6e78863046f6c05560ba24aa49a16d097f92cea59390735b9acb42b9fbed1f1f0d04715262b5a9f07b1b521c370484b11acb010822a6dc56737dde40c
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,34 @@
|
|
|
1
1
|
# Legion LLM Changelog
|
|
2
2
|
|
|
3
|
+
## [0.14.18] - 2026-07-24
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
- `@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`)
|
|
7
|
+
- `step_response_return` implemented with `populate_response_stats`, `fire_pipeline_observation`, `record_applied_to_gaia`
|
|
8
|
+
- `:response_return` removed from `ASYNC_SAFE_STEPS` (attribution must complete synchronously)
|
|
9
|
+
- `gut_check` moved before `metering` in `POST_PROVIDER_STEPS`
|
|
10
|
+
- GaiaCaller: `caller_class:`/`caller_client:` as explicit kwargs; `:class`/`:client` at top level of caller hash
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- `fire_pipeline_observation` — calls `Gaia.observe_from_pipeline` for `:human` callers (forms bonds from API clients)
|
|
14
|
+
- `gaia_preferred_lane` in TierAssigner — injects preferred provider/model for `gaia:*` callers from settings
|
|
15
|
+
- `require_relative 'steps/gut_check'` in steps.rb (fixes boot crash)
|
|
16
|
+
- `preferred_provider`/`preferred_model` in `gaia_defaults` settings
|
|
17
|
+
|
|
18
|
+
### Removed
|
|
19
|
+
- `record_advisory_meta_to_gaia` method and its call in `gaia_advisory` step (superseded by `@applied_signals` seeding)
|
|
20
|
+
|
|
21
|
+
## [0.14.17] - 2026-07-22
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
- Physical Binning (SSoT v2): Router Range Sieve steers requests to hardware based on workload size
|
|
25
|
+
- 3-tier priority: specific range match → generalist → full eligible fallback
|
|
26
|
+
- Boundaries are lower-inclusive, upper-exclusive (`[min, max)`)
|
|
27
|
+
- Escalation drains each tier naturally via `tried_lanes`
|
|
28
|
+
- Inventory lane enrichment: `preferred_min_context_tokens` / `preferred_max_context_tokens` injected from instance settings
|
|
29
|
+
- Works for ALL providers automatically (same settings path as instance weights)
|
|
30
|
+
- Settings path: `settings[:extensions][:llm][provider][:instances][id][:preferred_min/max_context_tokens]`
|
|
31
|
+
|
|
3
32
|
## [0.14.16] - 2026-07-14
|
|
4
33
|
|
|
5
34
|
### Fixed
|
|
@@ -95,6 +95,7 @@ module Legion
|
|
|
95
95
|
stream: false,
|
|
96
96
|
started_at: request_started_at
|
|
97
97
|
)
|
|
98
|
+
set_routing_response_headers(pipeline_response: pipeline_response)
|
|
98
99
|
|
|
99
100
|
if canonical_format
|
|
100
101
|
status_code, response_headers, body_string = Legion::LLM::API::DebugFormats.render_canonical_response(
|
|
@@ -106,6 +106,7 @@ module Legion
|
|
|
106
106
|
stream: false,
|
|
107
107
|
started_at: request_started_at
|
|
108
108
|
)
|
|
109
|
+
set_routing_response_headers(pipeline_response: pipeline_response)
|
|
109
110
|
|
|
110
111
|
if canonical_format
|
|
111
112
|
status_code, response_headers, body_string = Legion::LLM::API::DebugFormats.render_canonical_response(
|
|
@@ -104,6 +104,7 @@ module Legion
|
|
|
104
104
|
stream: false,
|
|
105
105
|
started_at: request_started_at
|
|
106
106
|
)
|
|
107
|
+
set_routing_response_headers(pipeline_response: pipeline_response)
|
|
107
108
|
|
|
108
109
|
if canonical_format
|
|
109
110
|
status_code, response_headers, body_string = Legion::LLM::API::DebugFormats.render_canonical_response(
|
|
@@ -211,6 +211,17 @@ module Legion
|
|
|
211
211
|
request_id: request_id)
|
|
212
212
|
end
|
|
213
213
|
|
|
214
|
+
def set_routing_response_headers(pipeline_response:)
|
|
215
|
+
routing = pipeline_response.respond_to?(:routing) ? pipeline_response.routing || {} : {}
|
|
216
|
+
provider_val = api_hash_value(routing, :provider)
|
|
217
|
+
instance_val = api_hash_value(routing, :instance)
|
|
218
|
+
model_val = api_hash_value(routing, :model)
|
|
219
|
+
|
|
220
|
+
headers 'X-Legion-Provider' => provider_val.to_s if provider_val
|
|
221
|
+
headers 'X-Legion-Instance' => instance_val.to_s if instance_val
|
|
222
|
+
headers 'X-Legion-Model' => model_val.to_s if model_val
|
|
223
|
+
end
|
|
224
|
+
|
|
214
225
|
def api_duration_ms(started_at)
|
|
215
226
|
return 0 unless started_at
|
|
216
227
|
|
|
@@ -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]}",
|
|
@@ -204,20 +206,16 @@ module Legion
|
|
|
204
206
|
# dispatch. Use ContextAccounting (walks canonical structs / content
|
|
205
207
|
# blocks) for messages, estimate the INJECTED system (baseline + RAG +
|
|
206
208
|
# skills + prior-history text), and count client tools. Messages are the
|
|
207
|
-
# lane-independent-reduced set — what actually goes on the wire.
|
|
208
209
|
def estimate_request_tokens
|
|
209
|
-
# Mirror the dispatch shape EXACTLY: native_dispatch_messages sends
|
|
210
|
-
# @request.messages (lane-independent-reduced); the conversation
|
|
211
|
-
# history lives in the injected SYSTEM (via EnrichmentInjector), not
|
|
212
|
-
# in the messages array — counting it in both would double-count.
|
|
213
210
|
reduced = reduce_messages_for_dispatch(Array(@request.messages))
|
|
214
211
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
212
|
+
msg_tokens = ContextAccounting.estimate_message_tokens(reduced)
|
|
213
|
+
sys_tokens = ContextAccounting.estimate_text_tokens(routing_estimate_injected_system)
|
|
214
|
+
tool_tokens = ContextAccounting.estimate_json_tokens(routing_estimate_tool_defs)
|
|
215
|
+
choice_tokens = @request.tool_choice ? ContextAccounting.estimate_json_tokens(@request.tool_choice) : 0
|
|
216
|
+
thinking_tokens = @request.thinking.is_a?(Hash) && @request.thinking.any? ? ContextAccounting.estimate_json_tokens(@request.thinking) : 0
|
|
217
|
+
|
|
218
|
+
msg_tokens + sys_tokens + tool_tokens + choice_tokens + thinking_tokens
|
|
221
219
|
end
|
|
222
220
|
|
|
223
221
|
# The system prompt as it will be dispatched — baseline + GAIA + prior
|
|
@@ -227,17 +225,10 @@ module Legion
|
|
|
227
225
|
EnrichmentInjector.inject(system: @request.system, enrichments: @enrichments || {})
|
|
228
226
|
end
|
|
229
227
|
|
|
230
|
-
# The tool catalog dispatch will send (special + client + registry), NOT
|
|
231
|
-
# just @request.tools — the dispatch guard counts native_dispatch_tools,
|
|
232
|
-
# so routing must too or a heavy-tool request under-counts and picks a
|
|
233
|
-
# too-small lane. Built read-only here (never memoized) because
|
|
234
|
-
# native_tool_definitions caches and applies the local-provider cap
|
|
235
|
-
# against @resolved_provider, which is not set at routing time; an
|
|
236
|
-
# uncapped over-estimate is the safe direction for a "will it fit" filter.
|
|
237
228
|
def routing_estimate_tool_defs
|
|
238
229
|
defs = Tools::Special.pinned_definitions.map(&:to_h)
|
|
239
230
|
Array(@request.tools).each { |t| defs << (t.respond_to?(:to_h) ? t.to_h : t) }
|
|
240
|
-
|
|
231
|
+
Array(@discovered_tools).each { |t| defs << (t.respond_to?(:to_h) ? t.to_h : t) }
|
|
241
232
|
defs
|
|
242
233
|
rescue StandardError => e
|
|
243
234
|
handle_exception(e, level: :warn, handled: true, operation: 'llm.pipeline.routing_estimate_tool_defs')
|
|
@@ -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)
|
|
@@ -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
|
|
@@ -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/inventory.rb
CHANGED
|
@@ -25,6 +25,8 @@ module Legion
|
|
|
25
25
|
validate_lane!(lane: lane)
|
|
26
26
|
return policy_skip(lane: lane) if policy_denied?(lane: lane)
|
|
27
27
|
|
|
28
|
+
lane = enrich_lane_range(lane: lane)
|
|
29
|
+
|
|
28
30
|
existing = live_map[lane[:id]]
|
|
29
31
|
resolved_health = if health == :preserve
|
|
30
32
|
existing&.dig(:health) || default_health
|
|
@@ -180,6 +182,26 @@ module Legion
|
|
|
180
182
|
(base * health_mult).to_i
|
|
181
183
|
end
|
|
182
184
|
|
|
185
|
+
def enrich_lane_range(lane:)
|
|
186
|
+
provider_settings = Legion::Settings.dig(:extensions, :llm, lane[:provider_family].to_sym)
|
|
187
|
+
return lane unless provider_settings.is_a?(Hash)
|
|
188
|
+
|
|
189
|
+
instances = provider_settings[:instances]
|
|
190
|
+
return lane unless instances.is_a?(Hash)
|
|
191
|
+
|
|
192
|
+
inst_cfg = instances[lane[:instance_id]] || instances[lane[:instance_id]&.to_sym]
|
|
193
|
+
return lane unless inst_cfg.is_a?(Hash)
|
|
194
|
+
|
|
195
|
+
min_val = inst_cfg[:preferred_min_context_tokens]
|
|
196
|
+
max_val = inst_cfg[:preferred_max_context_tokens]
|
|
197
|
+
return lane if min_val.nil? && max_val.nil?
|
|
198
|
+
|
|
199
|
+
enriched = lane.dup
|
|
200
|
+
enriched[:preferred_min_context_tokens] = min_val if min_val
|
|
201
|
+
enriched[:preferred_max_context_tokens] = max_val if max_val
|
|
202
|
+
enriched
|
|
203
|
+
end
|
|
204
|
+
|
|
183
205
|
def lane_weights_from_settings(lane:)
|
|
184
206
|
tier_weights = Legion::Settings.dig(:llm, :routing, :tier_weights) || {}
|
|
185
207
|
provider_sym = lane[:provider_family].to_sym
|
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
|
|
@@ -76,7 +105,9 @@ module Legion
|
|
|
76
105
|
|
|
77
106
|
return nil if eligible.empty?
|
|
78
107
|
|
|
79
|
-
eligible
|
|
108
|
+
pool = range_sieve(eligible: eligible, estimated_context: estimated_context)
|
|
109
|
+
|
|
110
|
+
pool
|
|
80
111
|
.group_by { |lane| lane[:lane_weight] }
|
|
81
112
|
.max_by { |weight, _| weight }
|
|
82
113
|
.last
|
|
@@ -190,6 +221,30 @@ module Legion
|
|
|
190
221
|
|
|
191
222
|
private
|
|
192
223
|
|
|
224
|
+
def range_sieve(eligible:, estimated_context:)
|
|
225
|
+
return eligible if estimated_context.nil?
|
|
226
|
+
|
|
227
|
+
specific, generalist = eligible.partition { |lane| lane_has_range?(lane) }
|
|
228
|
+
matched = specific.select { |lane| lane_in_range?(lane: lane, estimated_context: estimated_context) }
|
|
229
|
+
|
|
230
|
+
return matched unless matched.empty?
|
|
231
|
+
return generalist unless generalist.empty?
|
|
232
|
+
|
|
233
|
+
eligible
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def lane_has_range?(lane)
|
|
237
|
+
!lane[:preferred_min_context_tokens].nil? || !lane[:preferred_max_context_tokens].nil?
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def lane_in_range?(lane:, estimated_context:)
|
|
241
|
+
lower = (lane[:preferred_min_context_tokens] || 0).to_i
|
|
242
|
+
upper = lane[:preferred_max_context_tokens]
|
|
243
|
+
upper = upper ? upper.to_i : Float::INFINITY
|
|
244
|
+
|
|
245
|
+
estimated_context >= lower && estimated_context < upper
|
|
246
|
+
end
|
|
247
|
+
|
|
193
248
|
def lane_passes_hard_filters?(lane:, type:, tiers:, providers:, instances:, models:,
|
|
194
249
|
capabilities:, thinking:, privacy:, estimated_context:, **)
|
|
195
250
|
return false if lane[:type] != type
|
data/lib/legion/llm/settings.rb
CHANGED
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.18
|
|
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
|