legion-llm 0.14.10 → 0.14.15

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: 259d94128ef1590e757e4929a78e0f260db10ccc447c92033164db40b236198b
4
- data.tar.gz: 9b51f6f2bed159bbdbbd19d30af9c1b54e1905bbc284996865ab5da20b581450
3
+ metadata.gz: 6720d66411b7d2125aab243354ca9f0bf19292b10895695c7e762fe6f23180d0
4
+ data.tar.gz: 87910fc8387d28d65759e207f79bf6d1a2b39e178a4fefe5c8b6bd5f17187f6b
5
5
  SHA512:
6
- metadata.gz: 03c431fb67fdb8a8f319bacde1afad35f98141412b7a721c2ff59b94d8f43ae6f2c0d89369e4d662ce310b4c1e0b4354087c90cf796dfed5a4585e79e4133a7a
7
- data.tar.gz: e8480e859c80a2c98562fe6619de8f1a16ddd6ba7079d3006092c24f08f1ab9a6e31443080fb7e48d7647c1696fe8917e6b2a21f2ea48780ae079d4750732f34
6
+ metadata.gz: 83e7680e3083b13e523f66cdba62ff28680f85bc4e63d0e59ef15b4d7cb260c28c02d1325fa76566f5bd8a6606dc55d552bce9dbf097d45d197a02a2a486ac9d
7
+ data.tar.gz: 9cbc000d34b4afdc8e24dc677ea9a0a22a397868bf1e765d96e2bf7ea173016d293441dbdb6401edd2de30380dd0892cfd73e65550797aca1f4fdaa81e08db0d
data/CHANGELOG.md CHANGED
@@ -1,5 +1,54 @@
1
1
  # Legion LLM Changelog
2
2
 
3
+ ## [0.14.15] - 2026-07-09
4
+
5
+ ### Fixed
6
+
7
+ - **OpenAI Responses translator now produces a canonical request equivalent to the Anthropic translator (SSOT / one-oracle).** The same logical request must parse to an identical `Canonical::Request` regardless of client dialect; three divergences were breaking that (caught by the canonical-equivalence checks):
8
+ - **`upstream_body` no longer leaks into `Canonical::Request.metadata`.** It was written at parse, stripped again before dispatch, and never read (the native `call_responses` path is fed the raw body directly), so it was dead weight that made the two dialects' canonical requests differ. Removed.
9
+ - **System prompt goes to the canonical `system:` field, not a `role: system` message.** Responses `instructions` (and inline `developer`/`system` input items) now populate `system:` like the Anthropic translator, instead of being injected as a system message.
10
+ - **Text-then-tool-call is one assistant turn.** A Responses assistant text item immediately followed by `function_call`(s) now merges into a single assistant message (`content` + `tool_calls`), matching the Anthropic single-message shape, instead of splitting into a text message plus an empty-content tool_calls message.
11
+
12
+ ## [0.14.14] - 2026-07-09
13
+
14
+ ### Fixed
15
+
16
+ - **Curator now strips client-harness noise from the mid-conversation system role (GH#168).** The context curator curated `tool` and `assistant` roles but blindly passed every `system`-role message through — so Claude Code's harness injections (task-tool nags repeated 18×, linter file-dumps) accumulated unbounded, consuming up to ~25% of a 60–80K dispatch window and confusing non-Claude models (e.g. ornith/gemma) into hallucinating or dropping tool calls mid-turn. A new `strip_harness_noise` stage (first in both the heuristic and structural curation pipelines) drops a system message only when it (1) matches a known harness pattern or (2) is a verbatim duplicate of an earlier system message. Fail-safe: unrecognized, non-duplicate system messages and all non-system roles pass through untouched.
17
+
18
+ ### Added
19
+
20
+ - `llm.context_curation.harness_noise_strip` (default `true`) and `llm.context_curation.harness_noise_patterns` (default: Claude Code task-nag and linter file-dump markers). The pattern list is a plain substring array designed to grow as new harness injections are identified.
21
+
22
+ ### Internal
23
+
24
+ - Guarded four `String#include?` substring checks (`structured_output`, `classification`, `inventory`, `curator`) with `# rubocop:disable Style/ArrayIntersect`. The cop's autocorrect rewrites `array.any? { |x| str.include?(x) }` to `array.intersect?(str)`, which raises `TypeError` on a String argument — a latent trap for `rubocop -A`.
25
+
26
+ ## [0.14.13] - 2026-07-09
27
+
28
+ ### Fixed
29
+
30
+ - **Leaked chat-template tool-call tokens are synthesized into real tool calls (native_tool_loop pattern 4).** Some models (observed live with gemma served via vLLM; ledger rows 337049/337111/337145/337174) emit tool-call intent as a raw literal token in the response content instead of the structured `tool_calls` field: `<|tool_call>call:NAME{key:<|"|>value<|"|>,...}<tool_call|>` (where `<|"|>` is the string delimiter). Without synthesis the token reached the client verbatim — a "tool call" printed as text with nothing executed. `maybe_synthesize_tool_call_from_content` now recognizes this as a fourth format (after forced-choice JSON, Qwen tag markup, and Qwen single-tag): it parses the token via a scan-based parser (values terminated only by `<|"|>`, so embedded regular quotes and newlines in e.g. browser `code:` args survive — a gsub-to-quotes + JSON.parse approach breaks on those), validates the tool name against `native_dispatch_tools`, supports multiple tokens in one blob, and lets the executor run the tool server-side. Covered by unit specs (captured-verbatim ledger strings) and an in-process matrix scenario asserting all three client formats never surface the raw markers.
31
+
32
+ ## [0.14.12] - 2026-07-09
33
+
34
+ ### Fixed
35
+
36
+ - **BYOK model discovery returns 200 for passthrough models.** Clients like GitHub Copilot validate model availability via `GET /v1/models/:id` before sending inference requests. Previously returned 404 for models not in Inventory (e.g. `copilot-utility-small`), even though the daemon auto-routes them successfully. Now returns a synthetic model object for models listed in `llm.routing.model_passthrough_ids`.
37
+
38
+ ### Added
39
+
40
+ - `llm.routing.model_passthrough_ids` setting (default: `["copilot-utility-small"]`). Models in this list get dedicated `/v1/models/<id>` routes that return a synthetic model object backed by the best available lane's limits. Configurable for additional BYOK client model names.
41
+
42
+ ## [0.14.11] - 2026-07-03
43
+
44
+ ### Fixed
45
+
46
+ - **Routing token estimate now measures the payload dispatch actually sends (one oracle).** `estimate_request_tokens` walks canonical structs/content blocks via `ContextAccounting` instead of a naive `m[:content].to_s` shadow estimator (which read structured content — the shape Claude Code sends — as near-zero), and now counts the *injected* system prompt (baseline + RAG + skills + prior history) and the *full dispatch tool catalog* (special + client + registry), not just `@request.tools`. Fixes small-context local lanes passing the router's context-window filter then overflowing with `ContextOverflow` at dispatch.
47
+ - **Routing estimates the lane-independent-reduced message set.** Extracted a pure `reduce_messages_for_dispatch` (empty-assistant prune + leading-thinking strip + oversized-tool-result trim) so routing measures exactly what `native_dispatch_messages` puts on the wire, minus the lane-dependent `enforce_context_window` compaction (correctly excluded — the lane isn't chosen yet).
48
+ - **History double-send eliminated.** For a client-managed conversation (e.g. Claude Code) the client resends the full history in `@request.messages` every turn; `step_context_load` also injected the server-stored copy as "Prior conversation history" system text — sending the same turns to the provider twice (real tokens, real cost, real context pressure). `reject_client_resent_history` now drops stored turns the client already re-sent, matched by `(role, content-text)` fingerprint, while still injecting the prior turns a server-managed client omitted.
49
+ - **Router/dispatch context-window agreement.** New `llm.routing.context_headroom` (default `0.90`) applies the same headroom the dispatch budget guard uses, so a lane the router picks must still clear `RouteAttempts#enforce_final_context_budget!`.
50
+ - `token_budget` step now shares the same `ContextAccounting` estimator as the router filter and dispatch guard.
51
+
3
52
  ## [0.14.10] - 2026-06-26
4
53
 
5
54
  ### Fixed
data/README.md CHANGED
@@ -93,6 +93,22 @@ Both produced correct, specific technical answers and passed a multi-question re
93
93
 
94
94
  Curation runs **after** each turn (async), never inside the tool loop, so it never adds latency to an in-flight request. With a 60K-token target the validated payloads stay comfortably in the 40–56K range.
95
95
 
96
+ ### The knowledge loop
97
+
98
+ `drop_and_archive` is the strategy that makes curation more than a bigger trash can: instead of discarding overflow context, it sends it to the Apollo knowledge store, where it stays retrievable on future turns. Client-side compaction is lossy truncation — once it's gone, it's gone. This is archival: a fact from turn 3 can resurface at turn 300.
99
+
100
+ Apollo is two stores. The local per-node store ([legion-apollo](https://github.com/LegionIO/legion-apollo)) starts knowledge at confidence 0.5, corroborates at +0.15, decays at 0.005 per pass, and archives below 0.1 ([confidence.rb](https://github.com/LegionIO/legion-apollo/blob/main/lib/legion/apollo/helpers/confidence.rb)). The shared cross-node store ([lex-apollo](https://github.com/LegionIO/lex-apollo)) corroborates at +0.3 — only when a ≥0.9-cosine match arrives from a *different* provider — and begins time decay after 168 hours. The pipeline steps here use the local store (`Legion::Apollo::Local`).
101
+
102
+ Lifecycle status in the local store moves through `pending` → `confirmed` / `disputed` → `deprecated` / `archived`. The shared store is backed by PostgreSQL+pgvector (`lib/legion/extensions/apollo.rb`).
103
+
104
+ The loop closes back up in the Inference pipeline, across three steps:
105
+
106
+ - `knowledge_capture` (`lib/legion/llm/inference/steps/knowledge_capture.rb`) evaluates the response after each turn and writes new knowledge back to Apollo.
107
+ - `rag_context` (`lib/legion/llm/inference/steps/rag_context.rb`) queries Apollo — via the shared-store runner when available, falling back to `Legion::Apollo.retrieve(scope: :all)`, which merges local and shared results — and injects relevant knowledge into the request before it goes out.
108
+ - `gaia_advisory` (`lib/legion/llm/inference/steps/gaia_advisory.rb`) queries `Legion::Apollo::Local` — the local store only — for advisory context: tone, partner history, routing hints. GAIA advises; it doesn't inject prompts. The pipeline is what pulls context in.
109
+
110
+ The receipts for this loop aren't a separate claim: RAG-injected tokens are a recorded column in the lex-llm-ledger aggregates already published in [docs/curation-production-metrics.md](docs/curation-production-metrics.md).
111
+
96
112
  ### And everything else you'd expect from a production gateway
97
113
 
98
114
  Dynamic weighted routing across five tiers, automatic escalation and mid-stream provider failover, per-instance circuit breakers, capability-aware model selection, prompt compression, structured output, embeddings, fleet dispatch over AMQP, unified metering/audit on every request exit, and config-driven model-policy compliance (`model_whitelist`/`model_blacklist`, fail-closed). All of it is documented below.
@@ -0,0 +1,80 @@
1
+ # Context Curation: Production Metrics
2
+
3
+ Aggregate metrics from a production LegionIO deployment, exported from
4
+ [lex-llm-ledger](https://github.com/LegionIO/lex-llm-ledger) — the audit ledger that
5
+ records every LLM request the framework makes. All requests over the measurement
6
+ window, bucketed by conversation length. Nothing filtered, nothing excluded.
7
+
8
+ **The honest headline:** curation saves approximately nothing on short conversations
9
+ and ~98% on long agent sessions — and that asymmetry is the point, because that's
10
+ where the cost lives. In this window, 97.8% of all would-be tokens came from the 50+
11
+ turn bucket, while 99% of conversations by count were single-turn and gained nothing
12
+ (-0.1%, the overhead is noise). Curation doesn't make cheap traffic cheaper; it
13
+ prevents the runaway sessions that dominate spend.
14
+
15
+ ## Baseline definition (read before quoting any percentage)
16
+
17
+ "Naive" means: no client-side context management at all — the full accumulated
18
+ conversation resent on every turn. That is the default behavior of a plain agent loop,
19
+ but it is a worst-case counterfactual, not a claim about what your current tooling
20
+ does. A client that already trims context will see smaller deltas. All percentages
21
+ below use this single denominator: `actually_sent / naive_would_have_sent`.
22
+
23
+ ## Savings vs. naive resend, by conversation length
24
+
25
+ | Turns in conversation | 1 | 2–3 | 4–5 | 6–9 | 10–19 | 20–49 | 50+ |
26
+ |---|---|---|---|---|---|---|---|
27
+ | Total requests | 126,202 | 1,941 | 299 | 343 | 559 | 826 | 10,956 |
28
+ | Conversations | 126,202 | 947 | 69 | 50 | 43 | 29 | 37 |
29
+ | Avg turns/conversation | 1 | 2 | 4.3 | 6.9 | 13 | 28.5 | 296.1 |
30
+ | Naive would have sent (tokens) | 163.4M | 2.59M | 4.00M | 6.65M | 28.2M | 74.4M | 12.35B |
31
+ | Actually sent (tokens) | 163.6M | 2.35M | 3.47M | 5.08M | 12.9M | 20.3M | 285.6M |
32
+ | Avg naive per turn | 1,295 | 1,336 | 13,382 | 19,376 | 50,360 | 90,019 | 1,127,381 |
33
+ | Avg actual per turn | 1,296 | 1,208 | 11,607 | 14,812 | 22,998 | 24,521 | 26,064 |
34
+ | **Reduction vs. naive** | **-0.1%** | **9.6%** | **13.3%** | **23.6%** | **54.3%** | **72.8%** | **97.7%** |
35
+
36
+ The mechanism is visible in the per-turn averages: in the 50+ bucket (dominated by
37
+ long-running agent sessions averaging 296 turns), naive resend grows unbounded toward
38
+ 1.13M tokens per turn while curated context stays flat around 26K. Bounded per-turn
39
+ context is the claim this table proves; percentage reductions are a consequence of it.
40
+
41
+ Token-weighted across all buckets the reduction is ~96%, but quote that number with
42
+ its context: it is almost entirely the 50+ bucket's 97.7%. Reduction scales
43
+ monotonically with conversation depth, and the 50+ figure is not a small-sample
44
+ artifact — it covers 10,956 requests across 37 conversations.
45
+
46
+ ## How the savings happen
47
+
48
+ The Curator runs asynchronously after each turn (no added request latency) and applies
49
+ deterministic strategies — each one a named method in
50
+ [curator.rb](../lib/legion/llm/context/curator.rb):
51
+
52
+ - `strip_thinking` — drops stale reasoning blocks from prior turns
53
+ - `distill_tool_result` — summarizes large tool outputs
54
+ - `fold_resolved_exchanges` — collapses completed tool-call/result pairs
55
+ - `evict_superseded` — drops an old file-read when a newer read replaces it
56
+ - `dedup_similar` — Jaccard-similarity dedup of near-identical content
57
+ - `drop_and_archive` — overflow goes to the knowledge store, not the trash
58
+
59
+ In the 50+ bucket, loaded history of 300.2M tokens was curated down to 106.6M per
60
+ request on average, with a further 75.8M archived to the knowledge store rather than
61
+ resent. The ledger records curation and archival as separate stages that partially
62
+ overlap on the same content, so their per-stage "saved" counters are not additive;
63
+ the end-to-end truth is the naive-vs-actual row above, which is stage-independent.
64
+
65
+ ## Caveats and known gaps, stated plainly
66
+
67
+ - **Single deployment, single author's workload.** Your mix of conversation lengths
68
+ determines your savings; the -0.1% single-turn column is as real as the 97.7% one.
69
+ - **The baseline is modeled, not A/B tested.** See the baseline definition above.
70
+ - **Provider prompt caching was off in this window** (cached and cache-creation token
71
+ columns were zero). These savings come from payload reduction only, so they stack
72
+ with provider-side caching rather than replacing it.
73
+ - **Tool definitions are not yet curated.** They are static, resent every turn, and
74
+ material at scale — a known optimization gap, not a solved problem.
75
+
76
+ ## Reproduce
77
+
78
+ Every number above is a `SELECT` over lex-llm-ledger tables — the same ledger any
79
+ LegionIO deployment gets by installing the extension. The ledger exists for
80
+ finance/audit; these metrics are a side effect of never throwing usage data away.
data/legion-llm.gemspec CHANGED
@@ -7,8 +7,12 @@ Gem::Specification.new do |spec|
7
7
  spec.version = Legion::LLM::VERSION
8
8
  spec.authors = ['Esity']
9
9
  spec.email = ['matthewdiverson@gmail.com']
10
- spec.summary = 'LLM routing and provider orchestration for the LegionIO framework'
11
- spec.description = 'Routes LLM chat, embeddings, tool use, fleet dispatch, auditing, and provider metadata for LegionIO extensions'
10
+ spec.summary = 'LLM gateway: tiered routing, mid-stream failover, and context curation in one proxy'
11
+ spec.description = 'A proxy between AI clients and model backends. Requests translate once to a canonical form, ' \
12
+ 'route to the cheapest capable tier (local, direct, fleet, cloud, frontier), and escalate on ' \
13
+ 'failure with circuit breakers and mid-stream failover. A post-turn Curator bounds long agent ' \
14
+ 'sessions (97.7% context reduction vs naive resend at 50+ turns, production-measured). Provider ' \
15
+ 'adapters are separate lex-llm-* gems.'
12
16
  spec.homepage = 'https://github.com/LegionIO/legion-llm'
13
17
  spec.license = 'Apache-2.0'
14
18
  spec.require_paths = ['lib']
@@ -39,15 +39,15 @@ module Legion
39
39
  log.debug('[llm][client_translator][openai_responses] action=parse_request')
40
40
  body = symbolize(body)
41
41
 
42
- messages = build_messages(body[:input], body[:instructions])
42
+ system, messages = build_system_and_messages(body[:input], body[:instructions])
43
43
  tools = build_tools(body[:tools])
44
44
  params = build_params(body)
45
45
  thinking = build_thinking(body[:reasoning])
46
46
  tool_choice = build_tool_choice(body[:tool_choice])
47
- upstream_body = ensure_reasoning_summary(body)
48
47
 
49
48
  Canonical::Request.build(
50
49
  id: env['HTTP_X_CLIENT_REQUEST_ID'] || "resp_#{SecureRandom.hex(16)}",
50
+ system: system,
51
51
  messages: messages,
52
52
  tools: tools,
53
53
  tool_choice: tool_choice,
@@ -60,8 +60,7 @@ module Legion
60
60
  client_model: body[:model],
61
61
  tier: env['HTTP_X_LEGION_TIER'],
62
62
  routing_explicit: legion_routing_explicit_from_env(env),
63
- external_refs: external_refs(body, env),
64
- upstream_body: upstream_body # preserved for native call_responses path until executor is canonical
63
+ external_refs: external_refs(body, env)
65
64
  }.compact
66
65
  )
67
66
  end
@@ -107,7 +106,7 @@ module Legion
107
106
  thinking: thinking_to_inference(canonical_request.thinking),
108
107
  cache: { strategy: :default, cacheable: true },
109
108
  extra: extra,
110
- metadata: canonical_request.metadata.except(:upstream_body)
109
+ metadata: canonical_request.metadata
111
110
  }
112
111
 
113
112
  apply_canonical_params_to_inference(request_kwargs, canonical_request.params)
@@ -533,18 +532,30 @@ module Legion
533
532
  body
534
533
  end
535
534
 
536
- def build_messages(input, instructions)
537
- messages = case input
538
- when Array
539
- normalize_input_array(input)
540
- when String
541
- [{ role: 'user', content: input }]
542
- else
543
- []
544
- end
545
- messages = [{ role: 'system', content: instructions.to_s }] + messages if instructions
546
-
547
- messages.map do |m|
535
+ # SSOT: system content belongs in the canonical `system:` field (as the
536
+ # Anthropic translator does), NOT as a role:system message. Extract it
537
+ # from top-level `instructions` and from any inline system/developer
538
+ # input items, and return [system_string_or_nil, non_system_messages].
539
+ def build_system_and_messages(input, instructions)
540
+ raw = case input
541
+ when Array then normalize_input_array(input)
542
+ when String then [{ role: 'user', content: input }]
543
+ else []
544
+ end
545
+
546
+ system_parts = []
547
+ system_parts << instructions.to_s if instructions
548
+ messages = []
549
+ raw.each do |m|
550
+ role = (m[:role] || m['role']).to_s
551
+ if role == 'system'
552
+ system_parts << (m[:content] || m['content']).to_s
553
+ else
554
+ messages << m
555
+ end
556
+ end
557
+
558
+ normalized = messages.map do |m|
548
559
  {
549
560
  role: m[:role] || m['role'],
550
561
  content: m[:content] || m['content'],
@@ -552,6 +563,9 @@ module Legion
552
563
  tool_call_id: m[:tool_call_id] || m['tool_call_id']
553
564
  }.compact
554
565
  end
566
+
567
+ system = system_parts.reject(&:empty?).join("\n\n")
568
+ [system.empty? ? nil : system, normalized]
555
569
  end
556
570
 
557
571
  def normalize_input_array(input)
@@ -596,15 +610,22 @@ module Legion
596
610
  def flush_pending_tool_calls(messages, pending)
597
611
  return if pending.empty?
598
612
 
599
- messages << {
600
- role: 'assistant',
601
- content: '',
602
- tool_calls: pending.map do |tc|
603
- args = tc[:arguments]
604
- args = safe_parse_json(args) if args.is_a?(String)
605
- { id: tc[:id], name: tc[:name].to_s, arguments: args || {} }
606
- end
607
- }
613
+ tool_calls = pending.map do |tc|
614
+ args = tc[:arguments]
615
+ args = safe_parse_json(args) if args.is_a?(String)
616
+ { id: tc[:id], name: tc[:name].to_s, arguments: args || {} }
617
+ end
618
+
619
+ # SSOT: an assistant text item immediately followed by function_call(s)
620
+ # is ONE assistant turn (text + tool_calls), matching the Anthropic
621
+ # translator's single-message shape. Merge into the trailing assistant
622
+ # text message rather than emitting a separate empty-content message.
623
+ last = messages.last
624
+ if last && last[:role] == 'assistant' && !last.key?(:tool_calls)
625
+ last[:tool_calls] = tool_calls
626
+ else
627
+ messages << { role: 'assistant', content: '', tool_calls: tool_calls }
628
+ end
608
629
  pending.clear
609
630
  end
610
631
 
@@ -44,6 +44,26 @@ module Legion
44
44
  openai_error(e.message, type: 'server_error', status_code: 500)
45
45
  end
46
46
 
47
+ Models.passthrough_model_ids.each do |passthrough_id|
48
+ app.get "/v1/models/#{passthrough_id}" do
49
+ require_llm!
50
+ log.debug("[llm][api][namespaces][openai][models] action=passthrough_model id=#{passthrough_id}")
51
+ found = Models.synthetic_model_for_auto_route(passthrough_id)
52
+
53
+ unless found
54
+ return openai_error("Model '#{passthrough_id}' not found",
55
+ type: 'invalid_request_error', code: 'model_not_found', status_code: 404)
56
+ end
57
+
58
+ content_type :json
59
+ Legion::JSON.dump(found)
60
+ rescue StandardError => e
61
+ handle_exception(e, level: :error, handled: true,
62
+ operation: 'llm.api.namespaces.openai.models.passthrough')
63
+ openai_error(e.message, type: 'server_error', status_code: 500)
64
+ end
65
+ end
66
+
47
67
  app.get '/v1/models/:id' do
48
68
  require_llm!
49
69
  model_id = params[:id]
@@ -99,6 +119,23 @@ module Legion
99
119
  []
100
120
  end
101
121
 
122
+ def self.passthrough_model_ids
123
+ Legion::Settings[:llm][:routing][:model_passthrough_ids]
124
+ end
125
+
126
+ def self.synthetic_model_for_auto_route(model_id)
127
+ return nil unless Legion::LLM::Inventory.lanes.any?
128
+
129
+ best_lane = Legion::LLM::Inventory.lanes.max_by { |l| l[:lane_weight].to_i }
130
+ return nil unless best_lane
131
+
132
+ Legion::LLM::API::Translators::OpenAIResponse.format_model_object(
133
+ model_id,
134
+ owned_by: 'legionio',
135
+ limits: best_lane[:limits]
136
+ )
137
+ end
138
+
102
139
  def self.to_anthropic_model_list(openai_list)
103
140
  openai_list.map { |m| openai_to_anthropic_model(m) }
104
141
  end
@@ -126,7 +126,11 @@ module Legion
126
126
  end
127
127
 
128
128
  def supports_response_format?(model)
129
+ # rubocop:disable Style/ArrayIntersect -- substring check (each model
130
+ # fragment `include?`d in the model name), NOT array intersection.
131
+ # `intersect?` raises TypeError on the String arg.
129
132
  SCHEMA_CAPABLE_MODELS.any? { |m| model.to_s.include?(m) }
133
+ # rubocop:enable Style/ArrayIntersect
130
134
  end
131
135
 
132
136
  def retry_enabled?
@@ -194,6 +194,43 @@ module Legion
194
194
  result
195
195
  end
196
196
 
197
+ # Strip client-harness noise from the mid-conversation system role
198
+ # (GH#168). Claude Code and similar harnesses inject unbounded
199
+ # system-role messages — task nags (identical text, repeated), linter
200
+ # file dumps — that the rest of the curator never touched, letting them
201
+ # consume ~25% of a dispatch window.
202
+ #
203
+ # System-role only, and fail-safe: a system message is dropped ONLY when
204
+ # it (a) matches a known harness pattern, or (b) is a verbatim duplicate
205
+ # of an earlier system message. Unrecognized, non-duplicate system
206
+ # messages and every non-system message pass through untouched.
207
+ def strip_harness_noise(messages)
208
+ return messages unless setting(:harness_noise_strip, true)
209
+
210
+ patterns = Array(setting(:harness_noise_patterns, []))
211
+ seen_system = {}
212
+ result = messages.reject do |msg|
213
+ next false unless (msg[:role] || msg['role']).to_s == 'system'
214
+
215
+ content = (msg[:content] || msg['content']).to_s
216
+ next true if patterns.any? { |p| content.include?(p.to_s) }
217
+
218
+ if seen_system.key?(content)
219
+ true
220
+ else
221
+ seen_system[content] = true
222
+ false
223
+ end
224
+ end
225
+
226
+ stripped = messages.size - result.size
227
+ if stripped.positive?
228
+ log.info "[llm][curator] action=strip_harness_noise conversation_id=#{@conversation_id} " \
229
+ "stripped=#{stripped} messages_before=#{messages.size} messages_after=#{result.size}"
230
+ end
231
+ result
232
+ end
233
+
197
234
  # Heuristic: deduplicate near-identical messages using Jaccard similarity.
198
235
  def dedup_similar(messages, threshold: nil)
199
236
  return messages unless setting(:dedup_enabled, true)
@@ -352,6 +389,7 @@ module Legion
352
389
  result = messages.map { |msg| strip_thinking(msg) }
353
390
  end
354
391
 
392
+ result = strip_harness_noise(result)
355
393
  result = fold_resolved_exchanges(result)
356
394
  result = evict_superseded(result)
357
395
  dedup_similar(result)
@@ -368,7 +406,8 @@ module Legion
368
406
  end
369
407
 
370
408
  def apply_structural_curation_pipeline(messages)
371
- result = fold_resolved_exchanges(messages)
409
+ result = strip_harness_noise(messages)
410
+ result = fold_resolved_exchanges(result)
372
411
  result = evict_superseded(result)
373
412
  dedup_similar(result)
374
413
  rescue StandardError => e
@@ -575,8 +614,12 @@ module Legion
575
614
  clarification_signals = ['clarif', 'what do you mean', 'i see', 'understood', 'got it', 'correct', 'exactly', 'yes', 'right', 'agree']
576
615
  conclusion_signals = ['in summary', 'to summarize', 'in conclusion', 'therefore', 'so to answer', 'the answer is']
577
616
 
617
+ # rubocop:disable Style/ArrayIntersect -- these are substring checks
618
+ # (signal `include?` against a String), NOT array intersection. The
619
+ # cop's `intersect?` suggestion raises TypeError on a String arg.
578
620
  has_clarification = contents.any? { |c| clarification_signals.any? { |s| c.include?(s) } }
579
621
  has_conclusion = contents.last.length < 500 || conclusion_signals.any? { |s| contents.last.include?(s) }
622
+ # rubocop:enable Style/ArrayIntersect
580
623
 
581
624
  has_clarification && has_conclusion
582
625
  end
@@ -108,11 +108,23 @@ module Legion
108
108
  end
109
109
  end
110
110
 
111
- def strip_thinking_from_history(messages)
112
- before_tokens = ContextAccounting.estimate_message_tokens(messages)
111
+ # Lane-INDEPENDENT reduction applied before dispatch: empty-assistant
112
+ # prune + leading-thinking strip + oversized-tool-result trim. PURE — no
113
+ # @context_accounting writes, no logging — so the routing estimate can
114
+ # call it to measure exactly what native_dispatch_messages will send
115
+ # (minus the lane-dependent enforce_context_window compaction, which is
116
+ # correctly excluded from routing since it depends on the chosen lane).
117
+ def reduce_messages_for_dispatch(messages)
118
+ msgs = Array(messages).reject { |m| empty_assistant_message?(m) }
119
+ msgs = strip_thinking_pure(msgs)
120
+ trim_oversized_tool_results_pure(msgs)
121
+ end
122
+
123
+ # Pure leading-thinking strip. Shared by strip_thinking_from_history
124
+ # (which adds accounting) and reduce_messages_for_dispatch.
125
+ def strip_thinking_pure(messages)
113
126
  preserve_after = last_user_message_index(messages)
114
- stripped_count = 0
115
- result = messages.each_with_index.map do |msg, idx|
127
+ messages.each_with_index.map do |msg, idx|
116
128
  next msg if idx >= preserve_after
117
129
  next msg unless (msg[:role] || msg['role']).to_s == 'assistant'
118
130
 
@@ -122,9 +134,14 @@ module Legion
122
134
  cleaned = strip_leading_thinking_block(content)
123
135
  next msg if cleaned == content
124
136
 
125
- stripped_count += 1
126
137
  msg.merge(content: cleaned)
127
138
  end
139
+ end
140
+
141
+ def strip_thinking_from_history(messages)
142
+ before_tokens = ContextAccounting.estimate_message_tokens(messages)
143
+ result = strip_thinking_pure(messages)
144
+ stripped_count = messages.zip(result).count { |before, after| before != after }
128
145
 
129
146
  after_tokens = ContextAccounting.estimate_message_tokens(result)
130
147
  saved = [before_tokens - after_tokens, 0].max
@@ -158,28 +175,32 @@ module Legion
158
175
  text
159
176
  end
160
177
 
161
- def trim_oversized_tool_results(messages)
178
+ # Pure oversized-tool-result trim. Shared by trim_oversized_tool_results
179
+ # (which adds logging) and reduce_messages_for_dispatch.
180
+ def trim_oversized_tool_results_pure(messages)
162
181
  max_chars = Legion::Settings[:llm][:tool_result_max_dispatch_chars].to_i
163
182
  return messages unless max_chars.positive?
164
183
 
165
184
  preserve_after = last_user_message_index(messages)
166
- trimmed_count = 0
167
- result = messages.each_with_index.map do |msg, idx|
185
+ messages.each_with_index.map do |msg, idx|
168
186
  next msg if idx >= preserve_after
169
187
  next msg unless tool_result_message?(msg)
170
188
 
171
189
  content = msg[:content] || msg['content']
172
190
  next msg unless content.is_a?(String) && content.length > max_chars
173
191
 
174
- trimmed_count += 1
175
192
  msg.merge(content: "#{content[0, max_chars]}\n\n[TRUNCATED: showing first #{max_chars} of #{content.length} chars. " \
176
193
  'If you need more content, make multiple smaller targeted requests ' \
177
194
  '(e.g. read specific line ranges, grep for specific patterns, or request smaller sections).]')
178
195
  end
196
+ end
179
197
 
198
+ def trim_oversized_tool_results(messages)
199
+ result = trim_oversized_tool_results_pure(messages)
200
+ trimmed_count = messages.zip(result).count { |before, after| before != after }
180
201
  if trimmed_count.positive?
181
202
  log.info "[llm][executor] action=trim_tool_results request_id=#{@request.id} trimmed=#{trimmed_count} " \
182
- "max_chars=#{max_chars} preserved_after=#{preserve_after}"
203
+ "max_chars=#{Legion::Settings[:llm][:tool_result_max_dispatch_chars].to_i}"
183
204
  end
184
205
  result
185
206
  end
@@ -198,18 +198,52 @@ module Legion
198
198
  Legion::Settings.dig(:llm, :routing, :allow_body_routing_hints) == true
199
199
  end
200
200
 
201
+ # One oracle: routing must estimate the SAME payload the dispatch guard
202
+ # (RouteAttempts#final_dispatch_token_estimate) measures, or a too-small
203
+ # lane passes the router's context-window filter then overflows at
204
+ # dispatch. Use ContextAccounting (walks canonical structs / content
205
+ # blocks) for messages, estimate the INJECTED system (baseline + RAG +
206
+ # skills + prior-history text), and count client tools. Messages are the
207
+ # lane-independent-reduced set — what actually goes on the wire.
201
208
  def estimate_request_tokens
202
- all_messages = []
203
- all_messages.concat(@enrichments['context:conversation_history'] || [])
204
- all_messages.concat(@request.messages || [])
205
-
206
- estimated = all_messages.empty? ? 0 : estimate_message_tokens(all_messages)
207
- estimated += ((@request.system || '').length / 4.0).ceil
208
- estimated += estimate_tool_token_budget if @request.tools&.any?
209
- estimated += (Legion::JSON.dump(@request.thinking).length / 3.5).ceil if @request.thinking.is_a?(Hash) && @request.thinking.any?
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
+ reduced = reduce_messages_for_dispatch(Array(@request.messages))
214
+
215
+ estimated = ContextAccounting.estimate_message_tokens(reduced)
216
+ estimated += ContextAccounting.estimate_text_tokens(routing_estimate_injected_system)
217
+ estimated += ContextAccounting.estimate_json_tokens(routing_estimate_tool_defs)
218
+ estimated += ContextAccounting.estimate_json_tokens(@request.tool_choice) if @request.tool_choice
219
+ estimated += ContextAccounting.estimate_json_tokens(@request.thinking) if @request.thinking.is_a?(Hash) && @request.thinking.any?
210
220
  estimated
211
221
  end
212
222
 
223
+ # The system prompt as it will be dispatched — baseline + GAIA + prior
224
+ # history + RAG + skills injected. EnrichmentInjector.inject is pure, so
225
+ # calling it here and again at dispatch has no side effects.
226
+ def routing_estimate_injected_system
227
+ EnrichmentInjector.inject(system: @request.system, enrichments: @enrichments || {})
228
+ end
229
+
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
+ def routing_estimate_tool_defs
238
+ defs = Tools::Special.pinned_definitions.map(&:to_h)
239
+ Array(@request.tools).each { |t| defs << (t.respond_to?(:to_h) ? t.to_h : t) }
240
+ Legion::Settings::Extensions.filter_tools(deferred: false).each { |e| defs << e } if registry_tool_injection_requested?
241
+ defs
242
+ rescue StandardError => e
243
+ handle_exception(e, level: :warn, handled: true, operation: 'llm.pipeline.routing_estimate_tool_defs')
244
+ Array(@request.tools).map { |t| t.respond_to?(:to_h) ? t.to_h : t }
245
+ end
246
+
213
247
  def chain_required_capabilities
214
248
  caps = []
215
249
  caps << :streaming if @request.stream == true
@@ -350,6 +350,15 @@ module Legion
350
350
  @context_accounting[:component_status][:archive] = :observed
351
351
  end
352
352
 
353
+ # Kill the history double-send: a client-managed client (e.g. Claude
354
+ # Code) resends the full conversation in @request.messages every turn,
355
+ # and those go on the wire as structured messages. Injecting the same
356
+ # turns AGAIN as "Prior conversation history" system text (via
357
+ # EnrichmentInjector) would send them twice — real tokens, real cost,
358
+ # real context pressure. Only inject the prior turns the client did NOT
359
+ # resend: empty for a fully client-managed turn, the whole stored prefix
360
+ # for a server-managed client.
361
+ history = reject_client_resent_history(history)
353
362
  @enrichments['context:conversation_history'] = history
354
363
  @timeline.record(
355
364
  category: :internal, key: 'context:loaded',
@@ -358,6 +367,21 @@ module Legion
358
367
  )
359
368
  end
360
369
 
370
+ # Drop stored-history messages the client already re-sent in
371
+ # @request.messages, matched by (role, content-text) fingerprint. Prevents
372
+ # the same turns reaching the provider twice (structured messages +
373
+ # injected system text). See step_context_load.
374
+ def reject_client_resent_history(history)
375
+ resent = Array(@request.messages).to_set { |m| history_dedup_key(m) }
376
+ Array(history).reject { |m| resent.include?(history_dedup_key(m)) }
377
+ end
378
+
379
+ def history_dedup_key(msg)
380
+ role = (msg[:role] || msg['role']).to_s
381
+ content = msg.is_a?(Hash) ? (msg[:content] || msg['content']) : msg
382
+ [role, ContextAccounting.content_text(content).to_s.strip]
383
+ end
384
+
361
385
  def maybe_compact_history(conv_id, history)
362
386
  # Guard against recursive compaction: if this thread is already compacting,
363
387
  # skip to prevent infinite loops when compressor calls back into chat_direct
@@ -22,6 +22,18 @@ module Legion
22
22
  \s*<tool_use_value>(?<value>[^<]*)</tool_use_value>
23
23
  }mx
24
24
 
25
+ # Leaked chat-template tool-call token some models (e.g. gemma via vLLM)
26
+ # emit as literal text instead of the structured tool_calls field. One
27
+ # block per tool call; captured live from the ledger (2026-07):
28
+ # <|tool_call>call:NAME{key:<|"|>value<|"|>,key2:<|"|>value2<|"|>}<tool_call|>
29
+ # LEAKED_TOKEN_RE captures NAME + the raw {..} body; LEAKED_TOKEN_ARG_RE
30
+ # walks the body as key:<|"|>value<|"|> pairs. The value capture is
31
+ # non-greedy and terminated only by the <|"|> delimiter, so embedded
32
+ # regular quotes and newlines (common in browser `code:` args) survive —
33
+ # a gsub-to-quotes + parse-as-object approach would break on those.
34
+ LEAKED_TOKEN_RE = /<\|tool_call>call:(?<name>[^{]+?)(?<body>\{.*?\})<tool_call\|>/m
35
+ LEAKED_TOKEN_ARG_RE = /(?<key>\w+):<\|"\|>(?<value>.*?)<\|"\|>/m
36
+
25
37
  private
26
38
 
27
39
  def execute_native_tool_loop
@@ -337,7 +349,7 @@ module Legion
337
349
  # When the provider's response carries tool-call intent in plain text
338
350
  # rather than the structured tool_calls field, synthesize a structured
339
351
  # tool call so downstream translators emit the correct client-native
340
- # shape. Three formats are recognized (in order):
352
+ # shape. Four formats are recognized (in order):
341
353
  # 1. forced-choice JSON args (vLLM with tool_choice forcing a tool):
342
354
  # result text starts with `{"` and parses to a Hash.
343
355
  # 2. Qwen tag markup (no forced choice required):
@@ -346,6 +358,9 @@ module Legion
346
358
  # 3. Qwen single-tag form (live capture from qwen3.6-27b 2026-06):
347
359
  # <TOOL>arg-string</TOOL> — only synthesizable when the named
348
360
  # tool's schema declares a single required string param.
361
+ # 4. Leaked chat-template token (gemma via vLLM, live capture 2026-07):
362
+ # <|tool_call>call:NAME{key:<|"|>value<|"|>,...}<tool_call|>
363
+ # The named tool must be in native_dispatch_tools.
349
364
  #
350
365
  # NONDETERMINISM NOTE: qwen3.6-27b also frequently emits plain
351
366
  # narrative text (e.g. "Running `ls -la`...") with NO recoverable
@@ -364,7 +379,33 @@ module Legion
364
379
  markup = synthesize_qwen_markup_tool_call(text, round)
365
380
  return markup if markup.any?
366
381
 
367
- synthesize_qwen_single_tag_tool_call(text, round)
382
+ single_tag = synthesize_qwen_single_tag_tool_call(text, round)
383
+ return single_tag if single_tag.any?
384
+
385
+ synthesize_leaked_token_tool_call(text, round)
386
+ end
387
+
388
+ # Pattern 4. Parse leaked chat-template tokens
389
+ # (<|tool_call>call:NAME{key:<|"|>value<|"|>,...}<tool_call|>) into
390
+ # structured tool calls. Supports multiple tokens in one blob. Only
391
+ # synthesizes calls whose NAME is a known native tool; unknown names are
392
+ # skipped (the response surfaces as text). Arguments are always strings —
393
+ # that is all the token encodes.
394
+ def synthesize_leaked_token_tool_call(text, round)
395
+ synthesized = text.scan(LEAKED_TOKEN_RE).filter_map do |name, body|
396
+ name = name.to_s.strip
397
+ next if name.empty? || lookup_native_tool_definition(name).nil?
398
+
399
+ arguments = body.to_s.scan(LEAKED_TOKEN_ARG_RE).each_with_object({}) do |(key, value), acc|
400
+ acc[key.to_s.strip] = value.to_s
401
+ end
402
+ { id: "call_#{SecureRandom.hex(10)}", name: name, arguments: arguments }
403
+ end
404
+ return [] if synthesized.empty?
405
+
406
+ log.info "[llm][native_tool_loop] action=synthesized_tool_call source=leaked_token round=#{round} " \
407
+ "count=#{synthesized.size} tools=#{synthesized.map { |s| s[:name] }.join(',')}"
408
+ synthesized
368
409
  end
369
410
 
370
411
  def synthesize_forced_choice_tool_call(text, round)
@@ -110,7 +110,11 @@ module Legion
110
110
  patterns << name if text.match?(regex)
111
111
  end
112
112
 
113
+ # rubocop:disable Style/ArrayIntersect -- substring check (each keyword
114
+ # `include?`d in the text), NOT array intersection. `intersect?` raises
115
+ # TypeError on the String arg.
113
116
  phi_found = PHI_KEYWORDS.any? { |kw| text.downcase.include?(kw) }
117
+ # rubocop:enable Style/ArrayIntersect
114
118
  patterns << :phi_keyword if phi_found
115
119
  if text.match?(EMAIL_PATTERN) && (standalone_email_pii? || phi_found || patterns.any?)
116
120
  patterns.delete(:email)
@@ -54,22 +54,16 @@ module Legion
54
54
  "session token budget exceeded: #{total} >= #{limit}"
55
55
  end
56
56
 
57
+ # One oracle: same estimator the router filter and dispatch guard use.
58
+ # ContextAccounting walks canonical structs / content blocks; the
59
+ # injected system carries baseline + history + RAG + skills.
57
60
  def estimate_input_tokens
58
- content_chars = @request.messages.sum { |m| message_content_chars(m[:content]) }
59
61
  injected_system = EnrichmentInjector.inject(
60
62
  system: @request.system,
61
63
  enrichments: @enrichments || {}
62
64
  )
63
- system_chars = injected_system.to_s.length
64
- (content_chars + system_chars) / 4
65
- end
66
-
67
- def message_content_chars(content)
68
- return content.to_s.length unless content.is_a?(Array)
69
-
70
- content.sum do |part|
71
- part.is_a?(Hash) ? (part[:text] || part['text']).to_s.length : part.to_s.length
72
- end
65
+ ContextAccounting.estimate_message_tokens(@request.messages) +
66
+ ContextAccounting.estimate_text_tokens(injected_system)
73
67
  end
74
68
  end
75
69
  end
@@ -206,6 +206,9 @@ module Legion
206
206
 
207
207
  # Whitelist takes precedence over blacklist (M2). Substring match —
208
208
  # a model is denied if NO whitelist pattern is contained in its name.
209
+ # rubocop:disable Style/ArrayIntersect -- substring checks (patterns
210
+ # `include?`d in the model name), NOT array intersection; `intersect?`
211
+ # raises TypeError on the String arg.
209
212
  if sets[:whitelist]
210
213
  return true if sets[:whitelist].none? { |p| model.include?(p) }
211
214
 
@@ -213,6 +216,7 @@ module Legion
213
216
  end
214
217
 
215
218
  sets[:blacklist]&.any? { |p| model.include?(p) } || false
219
+ # rubocop:enable Style/ArrayIntersect
216
220
  end
217
221
 
218
222
  # Specificity cascade — same precedence as lex-llm Provider#model_whitelist:
@@ -209,8 +209,15 @@ module Legion
209
209
 
210
210
  return false if thinking == :require && !available.include?(:thinking)
211
211
 
212
+ # Apply the same headroom the dispatch budget guard uses: a lane is only
213
+ # eligible when estimated_context fits within context_window * headroom.
214
+ # Keeps routing and RouteAttempts#enforce_final_context_budget! in
215
+ # agreement so the router never picks a lane the pre-dispatch guard rejects.
212
216
  context_window = lane.dig(:limits, :context_window)
213
- return false if estimated_context && context_window && context_window.to_i < estimated_context
217
+ if estimated_context && context_window
218
+ usable = (context_window.to_i * context_headroom).to_i
219
+ return false if usable < estimated_context
220
+ end
214
221
  return false if privacy == :strict && %i[cloud frontier].include?(lane[:tier])
215
222
 
216
223
  true
@@ -229,6 +236,18 @@ module Legion
229
236
  model
230
237
  end
231
238
 
239
+ # Fraction of a lane's context_window the router treats as usable when
240
+ # applying the estimated_context filter. Mirrors the dispatch-time budget
241
+ # guard so routing and dispatch agree on what fits. Clamped to (0, 1].
242
+ def context_headroom
243
+ value = Legion::Settings.dig(:llm, :routing, :context_headroom)
244
+ value = value.to_f
245
+ value.positive? && value <= 1.0 ? value : 1.0
246
+ rescue StandardError => e
247
+ handle_exception(e, level: :warn, handled: true, operation: 'router.context_headroom')
248
+ 1.0
249
+ end
250
+
232
251
  def default_rng
233
252
  @default_rng ||= Random.new
234
253
  end
@@ -220,9 +220,18 @@ module Legion
220
220
  # Used by Inventory.write_lane to compute lane_weight = tier_w * provider_w * instance_w * model_w * health_mult.
221
221
  tier_weights: { direct: 105, local: 110, fleet: 110, cloud: 120, frontier: 150 },
222
222
  max_attempts: 3,
223
+ # Fraction of a lane's context_window the router treats as usable when
224
+ # applying the estimated_context hard filter. A lane is excluded when
225
+ # estimated_context >= context_window * context_headroom. Mirrors the
226
+ # dispatch-time headroom (context_curation.context_window_threshold and
227
+ # RouteAttempts#enforce_final_context_budget!'s 0.90) so routing and
228
+ # dispatch agree on what "fits" — a lane the router picks must still
229
+ # clear the pre-dispatch budget guard. 1.0 disables headroom.
230
+ context_headroom: 0.90,
223
231
  # Body-level routing hints are gated by this flag. Auto-routing aliases
224
232
  # like legionio/auto are still accepted as "you pick" intent.
225
233
  allow_body_routing_hints: false,
234
+ model_passthrough_ids: %w[copilot-utility-small],
226
235
  auto_routing_model_aliases: %w[legionio auto],
227
236
  default_intent: { privacy: 'normal', effort: 'moderate', operation: 'chat', cost: 'normal' },
228
237
  # Last-resort fallback model when both `default_model` and the
@@ -409,6 +418,23 @@ module Legion
409
418
  superseded_eviction: true,
410
419
  dedup_enabled: true,
411
420
  dedup_threshold: 0.85,
421
+ # Strip client-harness noise that accumulates unbounded in the
422
+ # mid-conversation system role (Claude Code task nags, linter file
423
+ # dumps, etc.). See GH#168. Two mechanisms, both system-role only:
424
+ # 1. exact-dedup: a verbatim-duplicate system message is dropped
425
+ # (the first occurrence is kept).
426
+ # 2. pattern strip: a system message whose content matches any
427
+ # substring in harness_noise_patterns is dropped entirely.
428
+ # Fail-safe: an unrecognized, non-duplicate system message is never
429
+ # touched. The pattern list is designed to grow over time as new
430
+ # harness injections are identified.
431
+ harness_noise_strip: true,
432
+ harness_noise_patterns: [
433
+ # Claude Code "task tools haven't been used recently" nag.
434
+ "task tools haven't been used recently",
435
+ # Claude Code linter/formatter file-dump note.
436
+ 'was modified, either by the user or by a linter'
437
+ ],
412
438
  target_context_tokens: 60_000,
413
439
  context_window_threshold: 0.90,
414
440
  archive_dropped_turns: true,
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Legion
4
4
  module LLM
5
- VERSION = '0.14.10'
5
+ VERSION = '0.14.15'
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.10
4
+ version: 0.14.15
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -191,8 +191,12 @@ dependencies:
191
191
  - - ">="
192
192
  - !ruby/object:Gem::Version
193
193
  version: '2.0'
194
- description: Routes LLM chat, embeddings, tool use, fleet dispatch, auditing, and
195
- provider metadata for LegionIO extensions
194
+ description: A proxy between AI clients and model backends. Requests translate once
195
+ to a canonical form, route to the cheapest capable tier (local, direct, fleet, cloud,
196
+ frontier), and escalate on failure with circuit breakers and mid-stream failover.
197
+ A post-turn Curator bounds long agent sessions (97.7% context reduction vs naive
198
+ resend at 50+ turns, production-measured). Provider adapters are separate lex-llm-*
199
+ gems.
196
200
  email:
197
201
  - matthewdiverson@gmail.com
198
202
  executables: []
@@ -220,6 +224,7 @@ files:
220
224
  - bin/h200-setup-prereboot.sh
221
225
  - bin/h200-setup-remaining-prereboot.sh
222
226
  - bin/h200-setup-resume-safe.sh
227
+ - docs/curation-production-metrics.md
223
228
  - docs/work/planning/p1-results.md
224
229
  - docs/work/planning/p2-results.md
225
230
  - docs/work/planning/p3-results.md
@@ -470,5 +475,6 @@ required_rubygems_version: !ruby/object:Gem::Requirement
470
475
  requirements: []
471
476
  rubygems_version: 3.6.9
472
477
  specification_version: 4
473
- summary: LLM routing and provider orchestration for the LegionIO framework
478
+ summary: 'LLM gateway: tiered routing, mid-stream failover, and context curation in
479
+ one proxy'
474
480
  test_files: []