legion-llm 0.16.8 → 0.16.9

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.
Files changed (113) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +4 -58
  3. data/CHANGELOG.md +20 -0
  4. data/CLAUDE.md +3 -8
  5. data/Gemfile +4 -3
  6. data/RULES.md +97 -0
  7. data/legion-llm.gemspec +1 -1
  8. data/lib/legion/llm/api/anthropic/messages.rb +7 -0
  9. data/lib/legion/llm/api/client_translators/anthropic_messages.rb +86 -73
  10. data/lib/legion/llm/api/client_translators/openai_chat.rb +62 -54
  11. data/lib/legion/llm/api/client_translators/openai_responses.rb +91 -90
  12. data/lib/legion/llm/api/client_translators/shared_extractors.rb +98 -18
  13. data/lib/legion/llm/api/debug_formats.rb +11 -37
  14. data/lib/legion/llm/api/inventory_admin.rb +16 -12
  15. data/lib/legion/llm/api/model_catalog.rb +25 -28
  16. data/lib/legion/llm/api/namespaces/anthropic/messages/count_tokens.rb +5 -8
  17. data/lib/legion/llm/api/namespaces/anthropic/messages.rb +6 -1
  18. data/lib/legion/llm/api/namespaces/native/models.rb +2 -2
  19. data/lib/legion/llm/api/namespaces/native/providers.rb +9 -12
  20. data/lib/legion/llm/api/namespaces/native/routing.rb +3 -4
  21. data/lib/legion/llm/api/namespaces/openai/models.rb +9 -40
  22. data/lib/legion/llm/api/namespaces/registration.rb +2 -2
  23. data/lib/legion/llm/api/native/models.rb +139 -5
  24. data/lib/legion/llm/api/native/offerings.rb +55 -61
  25. data/lib/legion/llm/api/native/providers.rb +47 -13
  26. data/lib/legion/llm/api/native/routing.rb +3 -4
  27. data/lib/legion/llm/api/native/tiers.rb +31 -24
  28. data/lib/legion/llm/api/openai/chat_completions.rb +8 -0
  29. data/lib/legion/llm/api/openai/models.rb +2 -1
  30. data/lib/legion/llm/api/openai/responses.rb +6 -0
  31. data/lib/legion/llm/api/routing_error_mapper.rb +4 -3
  32. data/lib/legion/llm/api/stream_assembler.rb +46 -121
  33. data/lib/legion/llm/api/translators/anthropic_response.rb +13 -4
  34. data/lib/legion/llm/api/translators/openai_response.rb +5 -0
  35. data/lib/legion/llm/api.rb +7 -0
  36. data/lib/legion/llm/cache/response.rb +3 -3
  37. data/lib/legion/llm/cache.rb +0 -14
  38. data/lib/legion/llm/call/daemon_client.rb +1 -1
  39. data/lib/legion/llm/call/dispatch.rb +34 -112
  40. data/lib/legion/llm/call/embeddings.rb +50 -74
  41. data/lib/legion/llm/call/lex_llm_adapter.rb +230 -309
  42. data/lib/legion/llm/call/selection_dispatch.rb +18 -6
  43. data/lib/legion/llm/compat.rb +6 -21
  44. data/lib/legion/llm/context/compressor.rb +12 -1
  45. data/lib/legion/llm/context/curator.rb +2 -2
  46. data/lib/legion/llm/errors.rb +19 -6
  47. data/lib/legion/llm/fleet/dispatcher.rb +56 -37
  48. data/lib/legion/llm/fleet/token_issuer.rb +14 -9
  49. data/lib/legion/llm/fleet.rb +0 -1
  50. data/lib/legion/llm/inference/attempt_context.rb +9 -2
  51. data/lib/legion/llm/inference/audit_publisher.rb +9 -8
  52. data/lib/legion/llm/inference/executor/context_window.rb +44 -23
  53. data/lib/legion/llm/inference/executor/escalation.rb +48 -67
  54. data/lib/legion/llm/inference/executor/payload_builder.rb +16 -5
  55. data/lib/legion/llm/inference/executor/routing.rb +36 -40
  56. data/lib/legion/llm/inference/executor/tool_injection.rb +20 -1
  57. data/lib/legion/llm/inference/executor.rb +198 -164
  58. data/lib/legion/llm/inference/native_tool_loop.rb +75 -51
  59. data/lib/legion/llm/inference/prompt.rb +7 -4
  60. data/lib/legion/llm/inference/request.rb +43 -20
  61. data/lib/legion/llm/inference/response.rb +4 -22
  62. data/lib/legion/llm/inference/route_attempts.rb +72 -27
  63. data/lib/legion/llm/inference/steps/classification.rb +8 -15
  64. data/lib/legion/llm/inference/steps/debate.rb +22 -17
  65. data/lib/legion/llm/inference/steps/prompt_cache.rb +20 -2
  66. data/lib/legion/llm/inference/steps/rag_context.rb +6 -31
  67. data/lib/legion/llm/inference/steps/rag_guard.rb +4 -1
  68. data/lib/legion/llm/inference/steps/tier_assigner.rb +8 -12
  69. data/lib/legion/llm/inference/steps/tool_calls.rb +44 -37
  70. data/lib/legion/llm/inference/steps/tool_history.rb +5 -1
  71. data/lib/legion/llm/inference/steps/trigger_match.rb +5 -15
  72. data/lib/legion/llm/inference.rb +71 -80
  73. data/lib/legion/llm/inventory/discovery.rb +20 -478
  74. data/lib/legion/llm/inventory.rb +37 -335
  75. data/lib/legion/llm/metering.rb +20 -7
  76. data/lib/legion/llm/quality/checker.rb +4 -2
  77. data/lib/legion/llm/router.rb +822 -216
  78. data/lib/legion/llm/routing/TODO.md +1043 -0
  79. data/lib/legion/llm/{router/candidate_evaluation.rb → routing/evaluation.rb} +14 -17
  80. data/lib/legion/llm/routing/filter.rb +406 -0
  81. data/lib/legion/llm/routing/fleet.rb +32 -0
  82. data/lib/legion/llm/{router → routing}/header_constraints.rb +1 -1
  83. data/lib/legion/llm/{router/outcome_classifier.rb → routing/outcome.rb} +39 -28
  84. data/lib/legion/llm/routing/rank.rb +210 -0
  85. data/lib/legion/llm/{router → routing}/settings_snapshot.rb +20 -14
  86. data/lib/legion/llm/{router → routing}/settings_state.rb +4 -4
  87. data/lib/legion/llm/settings/router.rb +98 -0
  88. data/lib/legion/llm/settings/tools.rb +5 -0
  89. data/lib/legion/llm/settings.rb +39 -35
  90. data/lib/legion/llm/tools/dispatcher.rb +6 -1
  91. data/lib/legion/llm/types.rb +0 -4
  92. data/lib/legion/llm/version.rb +1 -1
  93. data/lib/legion/llm.rb +16 -53
  94. metadata +14 -27
  95. data/lib/legion/llm/fleet/handler.rb +0 -267
  96. data/lib/legion/llm/inference/routing_session.rb +0 -144
  97. data/lib/legion/llm/inventory/capabilities.rb +0 -48
  98. data/lib/legion/llm/inventory/discovery/memory_gate.rb +0 -55
  99. data/lib/legion/llm/inventory/discovery/system.rb +0 -138
  100. data/lib/legion/llm/inventory/settings_observer.rb +0 -61
  101. data/lib/legion/llm/router/body_model_hint_policy.rb +0 -97
  102. data/lib/legion/llm/router/candidate_evaluator.rb +0 -399
  103. data/lib/legion/llm/router/escalation/history.rb +0 -28
  104. data/lib/legion/llm/router/input_bound.rb +0 -151
  105. data/lib/legion/llm/router/ranker.rb +0 -231
  106. data/lib/legion/llm/router/rejection_diagnostics.rb +0 -273
  107. data/lib/legion/llm/router/request_requirements.rb +0 -162
  108. data/lib/legion/llm/router/required_capabilities.rb +0 -144
  109. data/lib/legion/llm/router/resolution.rb +0 -74
  110. data/lib/legion/llm/types/chunk.rb +0 -45
  111. data/lib/legion/llm/types/content_block.rb +0 -55
  112. data/lib/legion/llm/types/message.rb +0 -103
  113. data/lib/legion/llm/types/tool_call.rb +0 -87
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ff0c6b5e1aff591b55afb3776a7d14fc8c6fc607bcc5836b0c2a3ffbd31d4e83
4
- data.tar.gz: 4f2e3d428bded9896ce9a4eb1044fc65e7f2025ec5116aba2bd3bdf1a5a94077
3
+ metadata.gz: 5e9b38f4dad17362f5f987ce0010c05d3bcc030aac669e791eee35cf3e5019e6
4
+ data.tar.gz: 9f7e8c585e0f3decdf9c1607668ed582def66427c9602c4f5858fd9985aea30f
5
5
  SHA512:
6
- metadata.gz: 83e30aa5a9b7f2608b52159b252a7481c44909537e3643c03f1981a6b4eb5926a9642684444ea8e3b61bb0b628e03bb5e89ffcf0d51d2da077f326d7041f8029
7
- data.tar.gz: 350b3cda23fcd149f9b14b6a11cefb567ab9a3b4b2ca1f2818d9991d8bcf5968156349519a10254e6ea7f7a92e89361fc7de7c6a5f14be28ed06382031b05aad
6
+ metadata.gz: d8043043f67b2cba5ba943c8e19892e7483dbf543aa5ec60825fde817101a0df0f0ad674abc83fd3dca2f5310a741ffecba0613ff79699d16cc097991f7989ae
7
+ data.tar.gz: 4d30a159fc67b9c4e35ca21f792df9aca40ae49475f6cd854b1ecdf1eab22f3593635a92a5df1896733a2eabeed0527eb2d363e251dbcf8b0a782715d0710a3b
data/.rubocop.yml CHANGED
@@ -1,38 +1,12 @@
1
1
  plugins:
2
2
  - rubocop-legion
3
3
 
4
- # These rubocop-legion cops surface in the local-path 0.1.7 build but were not
5
- # in the published 0.1.7 gem the repo previously tracked. They flag broad
6
- # pre-existing patterns unrelated to the N×N enforcement pass; deferred to
7
- # their own cleanup task. NoUnderscorePrefixedKwargs / NoInlineSettingDefaults
8
- # / NoDirectDispatch / NoShapeDuckTyping (the B4 set Phase 6 adopts) remain
9
- # enabled.
10
4
  Legion/RescueLogging/NoCapture:
11
5
  Enabled: false
12
- Legion/ConstantSafety/InheritParam:
13
- Enabled: false
14
-
15
- # rubocop-legion 0.1.9 cops — enabled as of P0 but deferred enforcement:
16
- # these four cops flag broad pre-existing patterns in the codebase that
17
- # predate the SSOT refactor. They gate NEW code from P1 onward; existing
18
- # violations are cleaned up within each phase as the code they govern is
19
- # rewritten. Do not add new violations; do not suppress them with inline
20
- # rubocop:disable without a tracking comment.
21
- #
22
- # TODO(P1): enable Legion/Llm/TaxonomyEnum repo-wide after lane taxonomy
23
- # is established and all :type/:tier/:circuit_state literals updated.
24
- # TODO(P1): enable Legion/Llm/RescueLogLevel repo-wide after back-compat
25
- # :debug rescue handlers are audited and leveled up.
26
- # TODO(P1): enable Legion/Llm/NoLoopDo repo-wide after cache/drain loops
27
- # are converted to bounded iteration.
28
6
  Legion/Llm/TaxonomyEnum:
29
7
  Enabled: false
30
8
  Legion/Llm/RescueLogLevel:
31
9
  Enabled: false
32
- Legion/Llm/NoLoopDo:
33
- Enabled: false
34
- # SettingsAccessPath is enabled for lib/ only — specs legitimately write
35
- # settings via the loader path to set up test fixtures.
36
10
  Legion/Llm/SettingsAccessPath:
37
11
  Exclude:
38
12
  - 'spec/**/*'
@@ -41,38 +15,14 @@ AllCops:
41
15
  TargetRubyVersion: 3.4
42
16
  NewCops: enable
43
17
  SuggestExtensions: false
44
-
45
- # N×N routing guard cops (Phase 6 enforcement; defaults from rubocop-legion config/default.yml).
46
- #
47
- # - NoUnderscorePrefixedKwargs / NoInlineSettingDefaults / NoDirectDispatch are
48
- # enabled repo-wide.
49
- # - NoShapeDuckTyping is enabled on the canonical-only surface where the shape
50
- # contract is fully established by translators. Code at the HTTP/wire ingress
51
- # (client_translator parse_request, StreamAssembler chunk adapter, DebugFormats
52
- # request envelope reader, Response.from_provider_message bridge) legitimately
53
- # inspects shape because that's the layer responsible for normalising into
54
- # canonical. The cop's scope expands here as the executor's canonical
55
- # migration (Phase 4 follow-up) lands.
56
18
  Legion/Framework/NoShapeDuckTyping:
57
- Enabled: true
19
+ Enabled: false
58
20
  Include:
21
+ #- 'lib/legion/llm/api/**/*.rb'
22
+ # - 'lib/legion/llm/inference/**/*.rb'
23
+ Exclude:
59
24
  - 'lib/legion/llm/api/**/*.rb'
60
25
  - 'lib/legion/llm/inference/**/*.rb'
61
- Exclude:
62
- # Legacy tree deprecated this release (R11); deleted next minor.
63
- - 'lib/legion/llm/api/translators/**/*.rb'
64
- - 'lib/legion/llm/api/anthropic/**/*.rb'
65
- - 'lib/legion/llm/api/openai/**/*.rb'
66
- - 'lib/legion/llm/api/native/**/*.rb'
67
- - 'lib/legion/llm/api/shared_helpers.rb'
68
- # Boundary code that legitimately bridges wire ↔ canonical: parse_request
69
- # at the HTTP ingress, the StreamAssembler chunk adapter (P5 explicitly
70
- # accepts both Canonical::Chunk and the legacy StreamChunk shape during
71
- # migration), DebugFormats (reads raw env / reflects request), and
72
- # shared_extractors (normalises arbitrary thinking content shapes).
73
- # Pre-canonical inference steps still navigate raw wire hashes; that
74
- # scope tightens once the executor finishes the canonical migration
75
- # (Phase 4 follow-up).
76
26
  - 'lib/legion/llm/api/client_translators/anthropic_messages.rb'
77
27
  - 'lib/legion/llm/api/client_translators/openai_chat.rb'
78
28
  - 'lib/legion/llm/api/client_translators/openai_responses.rb'
@@ -108,14 +58,12 @@ Metrics/BlockLength:
108
58
  Max: 150
109
59
  Exclude:
110
60
  - 'spec/**/*'
111
-
112
61
  Metrics/AbcSize:
113
62
  Max: 110
114
63
  Metrics/BlockNesting:
115
64
  Max: 4
116
65
  Metrics/CyclomaticComplexity:
117
66
  Max: 50
118
-
119
67
  Metrics/PerceivedComplexity:
120
68
  Max: 50
121
69
  Style/Documentation:
@@ -125,8 +73,6 @@ Style/SymbolArray:
125
73
  Style/FrozenStringLiteralComment:
126
74
  Enabled: true
127
75
  EnforcedStyle: always
128
- Naming/FileName:
129
- Enabled: false
130
76
  Naming/PredicateMethod:
131
77
  Enabled: false
132
78
  Metrics/ParameterLists:
data/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Legion LLM Changelog
2
2
 
3
+ ## [0.16.9] - 2026-08-20
4
+
5
+ ### Changed
6
+ - **lex-llm 0.8.0 contract conformance.** Gemspec floor raised to `lex-llm >= 0.8.0`; the repo conforms to the 0.8.0 contract cut (Canonical-only internal language, fleet protocol v3, documented embed/image artifacts).
7
+ - **Canonical messages end-to-end.** Client translators no longer de-canonicalize (`inference_messages` bridges deleted); `Inference::Request` canonicalizes inbound messages at the pipeline entry (plain strings, wire hashes, and Canonical objects are accepted; anything else raises). Executor `context_window`, `native_tool_loop`, `step_context_store`, and `prompt_cache` consume strict `Canonical::Message` accessors.
8
+ - **Fleet requestor is protocol v3.** Dispatcher emits `protocol_version: 3` (explicit, no default fill), requires `execution_contract: exact_offering_v1` + a nonempty `offering_id` (no default provider/instance fill), signs both exact claims unconditionally (S3), and rejects legacy field names at the edge. The reply path rehydrates the serialized `Canonical::Response` under `response:` (E3) — the v2 `content`/`finish_reason` projection is gone.
9
+ - **Client-edge dialect aliasing (O03a).** The three client translators translate client-wire spellings at the edge: params (`max_output_tokens`/`num_predict`/`max_completion_tokens` → `max_tokens`, `budget_tokens`/`thinking_budget` → `max_thinking_tokens`, `stop` → `stop_sequences`), content-type aliases (`input_text`/`output_text` → `text`, `image_url` → canonical `image` block), and JSON-string tool arguments → Hash. Canonical types accept canonical keys/types only.
10
+ - **`/api/llm/providers` surfaces SSOT-activated instances.** The listing unions the Inventory Registry snapshot (deduped by provider+instance) with the compatibility call registry.
11
+ - **`RULES.md` installed** (architecture law, byte-for-byte from the SSOT v4 reference).
12
+ - **SSOT v4: routing behavior extracted into stateless mixins under `Legion::LLM::Routing::`.** `Filter` (lane-eligibility axes including the body-model-hint decision ladder, policy enforcement, capability/context/dimension checks), `Rank` (weight/affinity/rendezvous ranking with band partition), `Fleet` (fleet-contract evaluation), `Outcome` (provider-outcome classification — renamed from `OutcomeClassifier`), and immutable records `CandidateEvaluation`/`EvaluationSet` (`routing/evaluation.rb`, 10-axis per-lane readiness snapshot). Each mixin is independently unit-testable with no cross-mixin or Router class dependency.
13
+ - **SSOT v4: Router is a per-request CLASS** (written in `router_new.rb`, wiring pending). One `Legion::LLM::Router.new(request:, operation:, body_model:)` per logical request; it holds the request's attempt state (exclusions, consumed targets, attempt budget, last rejection) — absorbing the former `Inference::RoutingSession` role. Request-derived facts (filters, pins, required capabilities, input bound, context budget, the body-model-hint decision) are computed once in `initialize` and are immutable for the request's lifetime. Inventory is fetched live at each lane decision from `Inventory::Registry` — no captured snapshot.
14
+ - **Routing settings read directly from `Legion::Settings[:llm][:router]`.** Defaults centralized in `lib/legion/llm/settings/router.rb`. Per-provider/instance/model cascades (model policy allow/deny, `enable_<capability>` overrides, preferred-context ranges) are resolved live from `Legion::Settings[:extensions][:llm]` via the shared `SettingsCascade`. The per-request `SettingsState`/`SettingsSnapshot` objects are eliminated from the routing path.
15
+
16
+ ### Removed
17
+ - **Legacy rip (0.8.0 cut).** The `Responses::StreamChunk` detection in `StreamAssembler` (type deleted upstream) and its tool-call accumulator; dead `Types::Message#to_provider_hash`; the legacy `Fleet::Handler` responder path (replaced by the lex-llm `ProviderResponder` registry topology); provider-native `Embedding` value-object handling in the embed consumer (replaced by the documented 0.8.0 embed artifact `{ text:, model:, embedding:, usage: }`).
18
+ - **Legacy `lib/legion/llm/router/` support folder retired.** 11 files deleted (`candidate_evaluator`, `ranker`, `rejection_diagnostics`, `required_capabilities`, `input_bound`, `request_requirements`, `body_model_hint_policy`, `resolution`, `settings_state`, `settings_snapshot`, `header_constraints`) and 2 renamed into the new namespace (`candidate_evaluation` → `routing/evaluation.rb`, `outcome_classifier` → `routing/outcome.rb`) — their logic reproduced across the `routing/` mixins and the Router class. (`escalation/history.rb` remains until the `llm.rb` require graph is rewired.) The 18 old specs under `spec/legion/llm/router/` were removed: 16 reproduced or subsumed by the new `spec/legion/llm/routing/` suites + `router_new_spec.rb`, and the two behavioral regressions (fail-forward release bar, instance-recovery) re-encoded against the Router class as `spec/legion/llm/router/{fail_forward_release_bar,instance_recovery_regression}_spec.rb`.
19
+
20
+ ### Notes
21
+ - **Migration is mid-flight.** The routing mixins (`routing/*.rb`) are landed and spec-covered; the old support folder is deleted. The Router class replacement (`router_new.rb`) is written but not yet wired into the require graph (module→class swap and consumer rewiring are pending). The existing `router.rb` stateless module remains the active entry point until the swap lands.
22
+
3
23
  ## [0.16.8] - 2026-08-19
4
24
 
5
25
  ### Fixed
data/CLAUDE.md CHANGED
@@ -1,8 +1,8 @@
1
- # legion-llm (v0.14.0)
1
+ # legion-llm
2
2
 
3
3
  Core LegionIO gem: LLM routing, provider dispatch, the inference pipeline, and the
4
- OpenAI/Anthropic-compatible API surface. This file is loaded into **every** session — it is
5
- intentionally short. Detailed reference lives in `README.md`; deep history lives in git.
4
+ OpenAI/Anthropic-compatible API surface. Detailed reference lives in `README.md`; deep history
5
+ lives in git.
6
6
 
7
7
  ## What it is (the one-paragraph model)
8
8
 
@@ -14,11 +14,6 @@ gems. The daemon is an **execution proxy**, not a passthrough (see Invariants).
14
14
 
15
15
  ## Build & Test
16
16
 
17
- ```bash
18
- bundle exec rspec # ~3200 examples, must be 0 failures before any commit
19
- bundle exec rubocop # 0 offenses required (rubocop-legion plugin)
20
- ```
21
-
22
17
  The **in-process matrix harness** (`spec/legion/llm/api/matrix/`) is the **commit gate**. It boots
23
18
  the real Sinatra app, mounts `/v1/messages` `/v1/responses` `/v1/chat/completions`, and replays the
24
19
  full client × scenario matrix against a deterministic `FakeProvider` in ~250ms. Any change to
data/Gemfile CHANGED
@@ -8,9 +8,10 @@ legion_settings_path = File.expand_path('../legion-settings', __dir__)
8
8
  gem 'legion-settings', path: legion_settings_path if Dir.exist?(legion_settings_path)
9
9
 
10
10
  group :test do
11
- # The published lex-llm (>= 0.5.3, declared in the gemspec) provides the Canonical
12
- # types and fleet security behavior these specs require. Use the local checkout when
13
- # present (development); CI resolves the published gem via the gemspec dependency.
11
+ # The published lex-llm (>= 0.8.0, declared in the gemspec) provides the 0.8.0
12
+ # Canonical types and fleet protocol-v3 behavior these specs require. Use the
13
+ # local checkout when present (development); CI resolves the published gem via
14
+ # the gemspec dependency.
14
15
  lex_llm_path = File.expand_path('../extensions-ai/lex-llm', __dir__)
15
16
  gem 'lex-llm', path: lex_llm_path if Dir.exist?(lex_llm_path)
16
17
 
data/RULES.md ADDED
@@ -0,0 +1,97 @@
1
+ RULES.md — Legion LLM Architecture Law
2
+ These rules apply to every task, file, repository, agent, model, session, test, refactor, migration, incident, and release.
3
+ The requested task defines what may change. These rules define how the system ALWAYS works.
4
+ Every rule remains active 100% of the time. If requested work conflicts with these rules, stop and surface the conflict before changing code.
5
+ These are architecture laws. Scope, compatibility, urgency, convenience, tests, existing behavior, and model judgment do not change them.
6
+ 1. Canonical is the only internal language.
7
+ Every client translates client wire -> Canonical before shared execution.
8
+ Shared execution carries Canonical through context, tools, routing, direct dispatch, fleet dispatch, and response handling.
9
+ Every provider translates Canonical -> provider wire at the provider boundary, then provider wire -> Canonical before returning to shared execution.
10
+ Every internal boundary validates the Canonical type it is defined to receive and raises immediately when that contract is violated.
11
+ Client Wire -> Client Translator -> Canonical -> Shared Execution -> Canonical -> Provider Translator -> Provider Wire.
12
+ 2. Serialization preserves Canonical.
13
+ Transport may serialize Canonical state. The receiving transport boundary ALWAYS rehydrates the exact Canonical type before execution continues.
14
+ Fleet follows Canonical -> serialize -> wire -> deserialize -> rehydrate Canonical -> Canonical.
15
+ Serialization changes encoding only. Ownership, identity, model, operation, capability, selection, and meaning remain exactly the same.
16
+ After rehydration, shared execution continues only with Canonical objects.
17
+ 3. Every authoritative fact has exactly one owner.
18
+ The owner creates the fact once. Every downstream layer carries, projects, serializes, rehydrates, verifies, or executes that exact fact.
19
+ A downstream layer receiving missing or contradictory authoritative state raises and returns the defect to the owning layer.
20
+ Authority ALWAYS moves forward by preservation.
21
+ Authority is created once and is never recreated downstream.
22
+ 4. Requirements describe the request. Inventory describes reality. Router chooses. Dispatch executes.
23
+ Canonical request construction owns request semantics. RequestRequirements expresses operation, capabilities, modality, context, output, tools, and explicit pins.
24
+ Providers publish exact executable facts into Inventory. Inventory owns canonical instance, offering, lane, capability, context, quota, health, and published weight state.
25
+ Router.next_lane consumes Requirements plus one immutable Inventory snapshot and produces one authoritative Selection.
26
+ Dispatch executes that Selection exactly. Once Selection exists, routing is finished.
27
+ 5. Inventory facts are immutable executable facts.
28
+ Providers publish exact instances and complete offering snapshots through the Inventory publication contract.
29
+ Identity, capability evidence, context evidence, quota domains, availability, and write-time weights are consumed from published Inventory state.
30
+ A changed fact becomes authoritative only through the owning publication or reconciliation path and a new Inventory snapshot.
31
+ Routing reads Inventory. Dispatch verifies and executes Inventory-backed Selection.
32
+ 6. Identity, capabilities, weights, and context policy retain exact ownership.
33
+ Inventory::Identity owns instance, offering, and lane identity; canonical instance identity is provider family plus the operator/configured instance name; physical endpoint data remains secondary.
34
+ Providers publish capability evidence. Requirements state required capabilities. Candidate evaluation compares the two and determines capability eligibility.
35
+ The weight owner computes lane weight at publication time; Inventory stores it; ranking consumes that stored weight; a stored zero disables the lane.
36
+ Preferred-context binning orders eligible candidates into preference bands and preserves eligibility. Capability, health, binning, and weight ALWAYS retain distinct meanings.
37
+ 7. Routing chooses exactly once.
38
+ Router.next_lane is the sole routing authority.
39
+ Candidate evaluation determines eligibility from Requirements and Inventory. Ranking orders eligible candidates from published routing facts.
40
+ Selection freezes the exact provider, instance, offering, lane, model, operation, and routing identity required for execution.
41
+ Every downstream component consumes the Selection it receives.
42
+ Selection is preserved, not reconstructed.
43
+ 8. Exact execution stays exact through every boundary.
44
+ Direct dispatch executes the exact Selection-derived binding it receives.
45
+ Fleet dispatch serializes and signs that exact binding; fleet validation verifies it; fleet rehydration restores it; worker resolution verifies it against authoritative Inventory.
46
+ The selected provider, instance, offering, lane, model, and operation remain identical through projection, signing, transport, validation, rehydration, resolution, and callable invocation.
47
+ A mismatch raises before provider execution.
48
+ An exact execution request ALWAYS remains exact execution.
49
+ 9. Health and errors preserve one authoritative meaning.
50
+ Inventory owns exact-instance availability. An authoritative instance-unavailable result removes that exact instance; readiness probing owns recovery; successful readiness republish re-admits it.
51
+ Overload, timeout, rate limit, model-not-ready, and transient provider failures remain request-local according to ProviderOutcome semantics.
52
+ The first layer that can authoritatively classify an error performs that classification once. Every downstream layer preserves it.
53
+ Programming errors remain programming errors. Contract violations remain contract violations. Routing exhaustion remains the defined typed Rejection.
54
+ 10. Compatibility exists only at explicit edges.
55
+ Supported legacy clients and protocols are translated into the current Canonical and SSOT architecture at explicit compatibility boundaries.
56
+ Shared execution remains Canonical. Routing remains SSOT-driven. Exact execution remains exact.
57
+ Compatibility code adapts an external contract to the current internal architecture.
58
+ The current internal architecture ALWAYS has one representation, one routing authority, one identity system, and one execution truth.
59
+ 11. Fix every defect at its owner.
60
+ Trace the incorrect value to the layer that owns it, then fix that owner.
61
+ Fix client wire in the client translator; Canonical shape in Canonical construction; Requirements in Requirements construction; provider facts in publication; identity in Inventory identity; weights in publication/reconciliation.
62
+ Fix eligibility in candidate evaluation; ordering in ranking; choice in Router.next_lane; execution preservation in dispatch; provider wire in the provider translator.
63
+ The layer where a defect becomes visible is evidence. The owning layer is where the correction belongs.
64
+ 12. A discovered issue remains in its owning domain.
65
+ Complete the requested task inside its stated scope.
66
+ When investigation exposes a separate defect owned by another architectural domain, record and surface it as separate work unless the requested task is explicitly expanded.
67
+ Routing work consumes existing Canonical Requirements and Inventory facts. Canonical work changes Canonical contracts. Provider work changes publication or translation. Transport work changes transport.
68
+ Nearby code never changes ownership. “While we are here” never changes architecture.
69
+ 13. N x N ALWAYS converges through Canonical.
70
+ Equivalent client semantics produce equivalent Canonical state before shared execution. Every provider consumes the same Canonical semantics for the same request.
71
+ When two paths disagree, capture the state at every involved boundary and locate the FIRST point where Canonical meaning diverges.
72
+ Fix that first divergent boundary, then run the exact failing path again.
73
+ Client behavior is proven at client-wire <-> Canonical. Provider behavior is proven at Canonical <-> provider-wire. Shared execution is proven with Canonical throughout.
74
+ 14. Debug from captured authoritative state.
75
+ Capture the actual input at the failing boundary before reasoning from symptoms.
76
+ For translation or transport defects, capture Canonical immediately before and after every involved boundary.
77
+ For routing or dispatch defects, capture Requirements, relevant Inventory facts, Selection, execution binding, and ProviderOutcome.
78
+ Compare each captured value to the contract owned by that layer. Find the first divergence. Fix its owner. Re-run the exact path.
79
+ Then inspect sibling implementations for the same defect class.
80
+ 15. Tests prove the real boundary and the invariant.
81
+ A boundary test exercises the real boundary it claims to protect.
82
+ Fleet tests exercise real serialization, deserialization, Canonical rehydration, signing, validation, exact resolution, and callable dispatch.
83
+ Provider tests exercise the real callable boundary and provider translator. Routing tests exercise real Requirements, Inventory records, candidate evaluation, ranking, and Selection.
84
+ Regression tests prove the violated invariant, not only the observed symptom.
85
+ A green suite is release evidence only when the tested path traverses the real architecture.
86
+ 16. Shared contracts are consumed directly.
87
+ Shared Canonical types own execution representation. Shared Inventory types own inventory state. Shared Routing types own routing state.
88
+ Shared taxonomy owns canonical mappings. Shared ProviderOutcome owns provider-neutral outcomes. Shared fleet protocol owns exact execution claims.
89
+ Every repository consumes these shared owners directly.
90
+ A defect in one shared boundary triggers an audit of every sibling implementation of that boundary. Fix the shared owner centrally whenever the defect belongs to a shared contract.
91
+ 17. Architecture is the release gate.
92
+ Every change preserves every rule in this file.
93
+ Tests, compatibility, historical behavior, migration phase, patch urgency, nearby code, task wording, and model judgment are evaluated UNDER these rules.
94
+ A contradiction between existing behavior and these rules is surfaced as an architecture conflict and resolved at the owning boundary before release.
95
+ Limited scope means do less. Limited scope NEVER means fewer rules apply.
96
+ These rules apply 100% of the time.
97
+ These are the law.
data/legion-llm.gemspec CHANGED
@@ -38,7 +38,7 @@ Gem::Specification.new do |spec|
38
38
  spec.add_dependency 'legion-settings', '>= 1.4.2'
39
39
  spec.add_dependency 'legion-transport', '>= 1.4.14'
40
40
  spec.add_dependency 'lex-knowledge'
41
- spec.add_dependency 'lex-llm', '>= 0.7.5'
41
+ spec.add_dependency 'lex-llm', '>= 0.8.0'
42
42
  spec.add_dependency 'pdf-reader'
43
43
  spec.add_dependency 'sinatra-contrib', '>= 2.0'
44
44
  spec.add_dependency 'tzinfo', '>= 2.0'
@@ -58,6 +58,13 @@ module Legion
58
58
  text_delta_lines = [] # buffer in-stream deltas until we know if tool calls exist
59
59
 
60
60
  pipeline_response = executor.call_stream do |chunk|
61
+ # ANNOTATE (G3/L4-family, legacy tree): the provider
62
+ # boundary yields Canonical::Chunk — for a canonical
63
+ # chunk the .to_s fallback leaks the chunk's Ruby
64
+ # inspect string to the client as text. The flat legacy
65
+ # API tree (register_legacy) is a coordinated-wave
66
+ # deletion surface; this chunk read is fixed with the
67
+ # tree, not inline.
61
68
  text = chunk.respond_to?(:content) ? chunk.content.to_s : chunk.to_s
62
69
  next if text.empty?
63
70
 
@@ -29,13 +29,15 @@ module Legion
29
29
 
30
30
  Canonical = Legion::Extensions::Llm::Canonical
31
31
 
32
+ # N8: a provider :error stop is an error state, not a clean
33
+ # completion — it renders as 'error', never 'end_turn'.
32
34
  STOP_REASON_MAP = {
33
35
  end_turn: 'end_turn',
34
36
  tool_use: 'tool_use',
35
37
  max_tokens: 'max_tokens',
36
38
  stop_sequence: 'stop_sequence',
37
39
  content_filter: 'content_filter',
38
- error: 'end_turn',
40
+ error: 'error',
39
41
  pause_turn: 'pause_turn'
40
42
  }.freeze
41
43
 
@@ -95,7 +97,10 @@ module Legion
95
97
  routing_explicit = canonical_request.metadata[:routing_explicit]
96
98
  extra[:routing_explicit] = routing_explicit if routing_explicit
97
99
 
98
- messages = inference_messages(canonical_request.messages)
100
+ # N x N law: the executor receives canonical messages end-to-end.
101
+ # No canonical-to-hash de-canonicalization (the former
102
+ # inference_messages bridge is deleted).
103
+ messages = canonical_request.messages
99
104
  request_kwargs = {
100
105
  id: request_id,
101
106
  messages: messages,
@@ -111,7 +116,10 @@ module Legion
111
116
  conversation_id: canonical_request.conversation_id,
112
117
  stream: canonical_request.stream == true,
113
118
  modality: modality,
114
- thinking: thinking_to_inference(canonical_request.thinking),
119
+ # N2: shared execution carries the canonical Thinking::Config
120
+ # (or nil) — the Anthropic dialect is re-shaped by the PROVIDER
121
+ # translator at the provider edge, never in the pipeline.
122
+ thinking: canonical_request.thinking,
115
123
  cache: { strategy: :default, cacheable: true },
116
124
  extra: extra,
117
125
  metadata: canonical_request.metadata
@@ -307,9 +315,13 @@ module Legion
307
315
  end
308
316
 
309
317
  def on_message_delta(stop_reason:, output_tokens:)
318
+ # N8/M7: an absent or unmapped stop state renders as 'error',
319
+ # never a fabricated 'end_turn' — the client must be able to
320
+ # tell a provider-declared completion from a stop the daemon
321
+ # did not observe (matches the sync edge policy).
310
322
  emit('message_delta', {
311
323
  type: 'message_delta',
312
- delta: { stop_reason: STOP_REASON_MAP[stop_reason] || 'end_turn', stop_sequence: nil },
324
+ delta: { stop_reason: STOP_REASON_MAP[stop_reason] || 'error', stop_sequence: nil },
313
325
  usage: { output_tokens: output_tokens.to_i }
314
326
  })
315
327
  end
@@ -369,7 +381,7 @@ module Legion
369
381
  # without buffering through the assembler.
370
382
  def format_tool_call_delta_chunk(canonical_chunk)
371
383
  tc = canonical_chunk.tool_call
372
- args = tc.respond_to?(:arguments) ? tc.arguments : {}
384
+ args = tool_fragment_field(tc, :arguments) || {}
373
385
  block_index = canonical_chunk.block_index || 0
374
386
 
375
387
  if server_tool_chunk?(tc)
@@ -378,8 +390,8 @@ module Legion
378
390
  index: block_index,
379
391
  content_block: {
380
392
  type: 'server_tool_use',
381
- id: tc.respond_to?(:id) ? tc.id : nil,
382
- name: tc.respond_to?(:name) ? tc.name.to_s : '',
393
+ id: tool_fragment_field(tc, :id),
394
+ name: tool_fragment_field(tc, :name).to_s,
383
395
  input: args_as_object(args)
384
396
  }
385
397
  }
@@ -397,11 +409,13 @@ module Legion
397
409
  end
398
410
 
399
411
  def server_tool_chunk?(tool_call)
400
- source = tool_call.respond_to?(:source) ? tool_call.source : nil
412
+ # The chunk fragment's source is the closed dispatch-type enum
413
+ # (R4 fragment contract) — server-executed tools are the
414
+ # registry/special/extension/mcp set (G24).
415
+ source = tool_fragment_field(tool_call, :source)
401
416
  return false if source.nil?
402
417
 
403
- type = source.is_a?(Hash) ? (source[:type] || source['type']) : source
404
- %i[special registry extension mcp].include?(type&.to_sym)
418
+ %i[special registry extension mcp].include?(source.to_sym)
405
419
  end
406
420
 
407
421
  private
@@ -558,11 +572,14 @@ module Legion
558
572
 
559
573
  raw.map do |t|
560
574
  ts = symbolize(t)
575
+ # M5/H1: a client-declared tool carries NO source — source is
576
+ # explicit-or-absent, never fabricated. The dispatch-side source
577
+ # (:client) is stamped by build_tool_definitions (the OUTBOUND
578
+ # tool-definition builder), not on the canonical request tools.
561
579
  {
562
580
  name: ts[:name].to_s,
563
581
  description: ts[:description].to_s,
564
- parameters: ts[:input_schema] || ts[:parameters] || {},
565
- source: { type: :client, executable: false }
582
+ parameters: ts[:input_schema] || ts[:parameters] || {}
566
583
  }
567
584
  end
568
585
  end
@@ -580,7 +597,7 @@ module Legion
580
597
 
581
598
  # Map an Anthropic-style thinking block ({type:, budget_tokens:}) to
582
599
  # the canonical {effort:, budget:} kwargs that
583
- # Canonical::ThinkingConfig.new accepts. Anthropic doesn't carry an
600
+ # Canonical::Thinking::Config.new accepts. Anthropic doesn't carry an
584
601
  # effort spelling — only a budget — so effort stays nil.
585
602
  def extract_thinking(thinking)
586
603
  return nil if thinking.nil?
@@ -595,24 +612,21 @@ module Legion
595
612
 
596
613
  { effort: effort, budget: budget }.compact
597
614
  else
598
- # Anything else is a "thinking is on" flag — no concrete budget.
599
- { effort: thinking.to_s }
600
- end
601
- end
602
-
603
- # Canonical::ThinkingConfig#to_h is {effort:, budget:}. The Inference
604
- # request and downstream provider translators expect the
605
- # Anthropic-style {type: 'enabled', budget_tokens:} shape; map back.
606
- def thinking_to_inference(thinking_obj)
607
- return nil if thinking_obj.nil?
608
-
609
- h = thinking_obj.respond_to?(:to_h) ? thinking_obj.to_h : thinking_obj
610
- return nil unless h.is_a?(Hash) && !h.empty?
615
+ # Bare (non-Hash) flag: the dialect's formal spelling is the
616
+ # {type:, budget_tokens:} block. A bare value maps only when it
617
+ # is itself a closed-enum effort (the M4 EFFORT_BUDGET keys) —
618
+ # honored 1:1. Anything else (true, 'enabled', 'on', ...)
619
+ # expresses neither effort nor budget and this dialect documents
620
+ # no default the config drops out, same treatment as
621
+ # {type: 'enabled'} with no budget above. Never fabricate.
622
+ value = thinking.is_a?(Symbol) ? thinking.to_s : thinking
623
+ return nil unless value.is_a?(String)
624
+
625
+ normalized = value.downcase
626
+ return { effort: normalized } if Canonical::Thinking::Config::EFFORT_BUDGET.key?(normalized)
611
627
 
612
- inference = { type: 'enabled' }
613
- inference[:budget_tokens] = h[:budget] if h[:budget]
614
- inference[:effort] = h[:effort] if h[:effort]
615
- inference
628
+ nil
629
+ end
616
630
  end
617
631
 
618
632
  def build_tool_definitions(canonical_tools)
@@ -635,20 +649,6 @@ module Legion
635
649
  end
636
650
  end
637
651
 
638
- # Canonical messages → plain hashes the executor's Inference::Request
639
- # currently expects. Round-trips text/tool_calls/tool_call_id.
640
- def inference_messages(canonical_messages)
641
- canonical_messages.map do |m|
642
- hash = m.respond_to?(:to_h) ? m.to_h : m
643
- {
644
- role: hash[:role],
645
- content: hash[:content],
646
- tool_calls: hash[:tool_calls],
647
- tool_call_id: hash[:tool_call_id]
648
- }.compact
649
- end
650
- end
651
-
652
652
  def build_content(pipeline_response)
653
653
  tool_calls = extract_tool_calls(pipeline_response)
654
654
  blocks = []
@@ -684,18 +684,15 @@ module Legion
684
684
  blocks.empty? ? [{ type: 'text', text: '' }] : blocks
685
685
  end
686
686
 
687
+ # G3: the envelope carries the provider's Canonical::Thinking —
688
+ # member reads only (the former Hash-OR-member dual shape was the
689
+ # split-world seam).
687
690
  def thinking_content_block(pipeline_response)
688
691
  thinking = pipeline_response.respond_to?(:thinking) ? pipeline_response.thinking : nil
689
692
  return nil if thinking.nil?
690
693
 
691
- normalized = if thinking.is_a?(Hash)
692
- thinking.transform_keys { |k| k.respond_to?(:to_sym) ? k.to_sym : k }
693
- else
694
- { content: thinking.respond_to?(:content) ? thinking.content : thinking.to_s }
695
- end
696
-
697
- content = normalized[:content].to_s
698
- signature = normalized[:signature].to_s
694
+ content = thinking.content.to_s
695
+ signature = thinking.signature.to_s
699
696
 
700
697
  return nil if content.empty? && signature.empty?
701
698
 
@@ -704,31 +701,31 @@ module Legion
704
701
  block
705
702
  end
706
703
 
704
+ # G3: the envelope carries Array<Canonical::ToolCall> — member
705
+ # reads only (the former respond_to?/Hash dual shape was the
706
+ # split-world seam).
707
707
  def extract_tool_calls(pipeline_response)
708
708
  tools = pipeline_response.respond_to?(:tools) ? pipeline_response.tools : nil
709
709
  return [] if tools.nil?
710
710
 
711
711
  Array(tools).map do |tc|
712
- source = if tc.respond_to?(:source)
713
- tc.source
714
- else
715
- (tc.is_a?(Hash) ? tc[:source] : nil)
716
- end
717
712
  {
718
- id: tc.respond_to?(:id) ? tc.id : (tc[:id] if tc.is_a?(Hash)),
719
- name: tc.respond_to?(:name) ? tc.name : (tc[:name] if tc.is_a?(Hash)),
720
- arguments: tc.respond_to?(:arguments) ? tc.arguments : (tc[:arguments] if tc.is_a?(Hash)),
721
- result: tc.respond_to?(:result) ? tc.result : (tc[:result] if tc.is_a?(Hash)),
722
- server_tool: server_tool_source?(source)
713
+ id: tc.id,
714
+ name: tc.name,
715
+ arguments: tc.arguments,
716
+ result: tc.result,
717
+ server_tool: server_tool_source?(tc.source)
723
718
  }
724
719
  end
725
720
  end
726
721
 
722
+ # The canonical ToolCall.source is the closed dispatch-type enum —
723
+ # server-executed tools are the registry/special/extension/mcp set
724
+ # (G24).
727
725
  def server_tool_source?(source)
728
726
  return false if source.nil?
729
727
 
730
- type = source.is_a?(Hash) ? (source[:type] || source['type']) : source
731
- %i[special registry extension mcp].include?(type&.to_sym)
728
+ %i[special registry extension mcp].include?(source.to_sym)
732
729
  end
733
730
 
734
731
  def format_stop_reason(pipeline_response)
@@ -739,14 +736,29 @@ module Legion
739
736
  return 'tool_use' if tool_calls.any? { |tc| !tc[:server_tool] && tc[:result].nil? }
740
737
 
741
738
  stop = pipeline_response.respond_to?(:stop) ? pipeline_response.stop : nil
742
- reason = stop.is_a?(Hash) ? (stop[:reason] || stop['reason']) : stop.to_s
743
- case reason.to_s
744
- when 'tool_use' then 'tool_use'
745
- when 'max_tokens' then 'max_tokens'
746
- when 'content_filter' then 'content_filter'
747
- when 'stop'
748
- pipeline_response.respond_to?(:stop_sequence) && pipeline_response.stop_sequence ? 'stop_sequence' : 'end_turn'
749
- else 'end_turn'
739
+ # The envelope's stop member is the { reason: } Hash — one shape
740
+ # (G3).
741
+ reason = stop.is_a?(Hash) ? (stop[:reason] || stop['reason']) : nil
742
+ # N8/M7: only known completion states render as completions.
743
+ # :error stays an error, and an absent or unmapped stop state
744
+ # surfaces as 'error' — the client must be able to tell a
745
+ # provider-declared end_turn from a stop the daemon did not
746
+ # observe. Never fabricate :end_turn. (The legacy :stop dialect
747
+ # value is not in the canonical stop vocabulary — it falls
748
+ # through to the error surface like any unmapped state.)
749
+ case reason&.to_sym
750
+ when :end_turn
751
+ 'end_turn'
752
+ when :stop_sequence
753
+ 'stop_sequence'
754
+ when :tool_use
755
+ 'tool_use'
756
+ when :max_tokens
757
+ 'max_tokens'
758
+ when :content_filter
759
+ 'content_filter'
760
+ else
761
+ 'error'
750
762
  end
751
763
  end
752
764
 
@@ -754,7 +766,8 @@ module Legion
754
766
  return result if result.is_a?(String)
755
767
 
756
768
  Legion::JSON.dump(result)
757
- rescue StandardError
769
+ rescue StandardError => e
770
+ handle_exception(e, level: :warn, operation: 'llm.client_translator.anthropic.serialize_result')
758
771
  result.to_s
759
772
  end
760
773
  end