lex-llm 0.7.6 → 0.8.4

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 (168) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +139 -0
  3. data/RULES.md +97 -0
  4. data/lib/legion/extensions/llm/auto_registration.rb +4 -13
  5. data/lib/legion/extensions/llm/canonical/chunk.rb +107 -94
  6. data/lib/legion/extensions/llm/canonical/content_block.rb +65 -75
  7. data/lib/legion/extensions/llm/canonical/message.rb +53 -69
  8. data/lib/legion/extensions/llm/canonical/params.rb +58 -34
  9. data/lib/legion/extensions/llm/canonical/request.rb +68 -56
  10. data/lib/legion/extensions/llm/canonical/response.rb +62 -78
  11. data/lib/legion/extensions/llm/canonical/strict.rb +105 -0
  12. data/lib/legion/extensions/llm/canonical/thinking.rb +36 -84
  13. data/lib/legion/extensions/llm/canonical/thinking_config.rb +149 -0
  14. data/lib/legion/extensions/llm/canonical/tool_call.rb +43 -53
  15. data/lib/legion/extensions/llm/canonical/tool_definition.rb +56 -46
  16. data/lib/legion/extensions/llm/canonical/tool_schema.rb +15 -22
  17. data/lib/legion/extensions/llm/canonical/usage.rb +47 -40
  18. data/lib/legion/extensions/llm/canonical.rb +7 -5
  19. data/lib/legion/extensions/llm/configuration.rb +40 -10
  20. data/lib/legion/extensions/llm/connection.rb +8 -30
  21. data/lib/legion/extensions/llm/credential_sources.rb +32 -49
  22. data/lib/legion/extensions/llm/discovery/actor.rb +92 -0
  23. data/lib/legion/extensions/llm/discovery/pipeline.rb +604 -0
  24. data/lib/legion/extensions/llm/error.rb +0 -14
  25. data/lib/legion/extensions/llm/fleet/contract_error.rb +15 -0
  26. data/lib/legion/extensions/llm/fleet/envelope_validation.rb +7 -6
  27. data/lib/legion/extensions/llm/fleet/fleet_envelope.rb +66 -0
  28. data/lib/legion/extensions/llm/fleet/protocol.rb +18 -5
  29. data/lib/legion/extensions/llm/fleet/provider_responder.rb +58 -156
  30. data/lib/legion/extensions/llm/fleet/token_validator.rb +15 -21
  31. data/lib/legion/extensions/llm/fleet/worker_execution.rb +107 -154
  32. data/lib/legion/extensions/llm/inventory/errors.rb +0 -1
  33. data/lib/legion/extensions/llm/inventory/evidence.rb +1 -1
  34. data/lib/legion/extensions/llm/inventory/identity.rb +55 -39
  35. data/lib/legion/extensions/llm/inventory/probe_token.rb +9 -12
  36. data/lib/legion/extensions/llm/inventory/publisher.rb +15 -53
  37. data/lib/legion/extensions/llm/inventory/records.rb +36 -100
  38. data/lib/legion/extensions/llm/inventory/registry.rb +54 -48
  39. data/lib/legion/extensions/llm/inventory/snapshot.rb +7 -21
  40. data/lib/legion/extensions/llm/inventory/weight_reconciler.rb +13 -6
  41. data/lib/legion/extensions/llm/inventory/weight_schema.rb +47 -8
  42. data/lib/legion/extensions/llm/provider/open_ai_compatible.rb +178 -182
  43. data/lib/legion/extensions/llm/provider.rb +192 -315
  44. data/lib/legion/extensions/llm/provider_contract.rb +25 -8
  45. data/lib/legion/extensions/llm/provider_settings.rb +5 -26
  46. data/lib/legion/extensions/llm/responses/thinking_extractor.rb +8 -1
  47. data/lib/legion/extensions/llm/responses/tool_arguments.rb +48 -0
  48. data/lib/legion/extensions/llm/routing/provider_outcome.rb +25 -0
  49. data/lib/legion/extensions/llm/routing/records.rb +34 -17
  50. data/lib/legion/extensions/llm/stream_accumulator.rb +186 -270
  51. data/lib/legion/extensions/llm/streaming.rb +50 -35
  52. data/lib/legion/extensions/llm/taxonomies.rb +14 -26
  53. data/lib/legion/extensions/llm/transport/fleet_lane.rb +8 -10
  54. data/lib/legion/extensions/llm/transport/messages/fleet_error.rb +3 -2
  55. data/lib/legion/extensions/llm/transport/messages/fleet_request.rb +6 -8
  56. data/lib/legion/extensions/llm/transport/messages/fleet_response.rb +10 -9
  57. data/lib/legion/extensions/llm/utils.rb +23 -5
  58. data/lib/legion/extensions/llm/version.rb +1 -1
  59. data/lib/legion/extensions/llm.rb +8 -98
  60. data/spec/legion/extensions/llm/auto_registration_spec.rb +4 -9
  61. data/spec/legion/extensions/llm/canonical/chunk_spec.rb +66 -252
  62. data/spec/legion/extensions/llm/canonical/content_block_spec.rb +52 -197
  63. data/spec/legion/extensions/llm/canonical/message_spec.rb +89 -204
  64. data/spec/legion/extensions/llm/canonical/params_spec.rb +55 -136
  65. data/spec/legion/extensions/llm/canonical/request_spec.rb +81 -143
  66. data/spec/legion/extensions/llm/canonical/response_spec.rb +68 -204
  67. data/spec/legion/extensions/llm/canonical/thinking/config_spec.rb +222 -0
  68. data/spec/legion/extensions/llm/canonical/thinking_spec.rb +23 -171
  69. data/spec/legion/extensions/llm/canonical/tool_call_spec.rb +59 -162
  70. data/spec/legion/extensions/llm/canonical/tool_definition_spec.rb +55 -191
  71. data/spec/legion/extensions/llm/canonical/tool_schema_spec.rb +26 -67
  72. data/spec/legion/extensions/llm/canonical/usage_spec.rb +46 -155
  73. data/spec/legion/extensions/llm/configuration_spec.rb +31 -5
  74. data/spec/legion/extensions/llm/conformance/canonical_type_examples.rb +106 -0
  75. data/spec/legion/extensions/llm/conformance/client_translator_examples.rb +1 -2
  76. data/spec/legion/extensions/llm/conformance/conformance.rb +10 -2
  77. data/spec/legion/extensions/llm/conformance/fixtures/canonical_fleet_round_trip.json +1 -1
  78. data/spec/legion/extensions/llm/conformance/fixtures/canonical_thinking_request.json +2 -2
  79. data/spec/legion/extensions/llm/conformance/provider_translator_examples.rb +1 -1
  80. data/spec/legion/extensions/llm/conformance/ssot_contract_conformance_spec.rb +128 -0
  81. data/spec/legion/extensions/llm/conformance/ssot_contract_examples.rb +505 -0
  82. data/spec/legion/extensions/llm/conformance/ssot_provider_examples.rb +11 -10
  83. data/spec/legion/extensions/llm/credential_sources_spec.rb +12 -13
  84. data/spec/legion/extensions/llm/error_spec.rb +2 -12
  85. data/spec/legion/extensions/llm/fleet/exact_offering_spec.rb +59 -45
  86. data/spec/legion/extensions/llm/fleet/provider_responder_spec.rb +173 -95
  87. data/spec/legion/extensions/llm/fleet/token_validator_spec.rb +7 -2
  88. data/spec/legion/extensions/llm/fleet/worker_execution_spec.rb +93 -74
  89. data/spec/legion/extensions/llm/fleet_messages_spec.rb +119 -125
  90. data/spec/legion/extensions/llm/gemspec_spec.rb +1 -2
  91. data/spec/legion/extensions/llm/inventory/boot_spec.rb +4 -4
  92. data/spec/legion/extensions/llm/inventory/identity_spec.rb +127 -110
  93. data/spec/legion/extensions/llm/inventory/probe_token_spec.rb +4 -4
  94. data/spec/legion/extensions/llm/inventory/publisher_spec.rb +8 -61
  95. data/spec/legion/extensions/llm/inventory/records_spec.rb +82 -47
  96. data/spec/legion/extensions/llm/inventory/registry_activation_spec.rb +49 -16
  97. data/spec/legion/extensions/llm/inventory/registry_replacement_spec.rb +5 -4
  98. data/spec/legion/extensions/llm/inventory/snapshot_spec.rb +11 -5
  99. data/spec/legion/extensions/llm/inventory/weight_reconciler_spec.rb +7 -3
  100. data/spec/legion/extensions/llm/inventory/weight_schema_spec.rb +60 -10
  101. data/spec/legion/extensions/llm/provider/open_ai_compatible_spec.rb +128 -68
  102. data/spec/legion/extensions/llm/provider/open_ai_compatible_tool_calls_array_spec.rb +7 -31
  103. data/spec/legion/extensions/llm/provider_contract_spec.rb +10 -15
  104. data/spec/legion/extensions/llm/provider_spec.rb +97 -78
  105. data/spec/legion/extensions/llm/routing/records_spec.rb +38 -5
  106. data/spec/legion/extensions/llm/stream_accumulator_spec.rb +174 -144
  107. data/spec/legion/extensions/llm/streaming_spec.rb +27 -0
  108. data/spec/legion/extensions/llm/taxonomies_spec.rb +43 -43
  109. data/spec/legion/extensions/llm/transport/fleet_lane_spec.rb +1 -1
  110. data/spec/legion/extensions/llm/utils_spec.rb +26 -7
  111. data/spec/legion/extensions/llm_base_contract_spec.rb +55 -90
  112. data/spec/legion/extensions/llm_extension_spec.rb +5 -5
  113. data/spec/support/fake_llm_provider.rb +45 -39
  114. data/spec/support/fake_ssot_harness.rb +7 -2
  115. metadata +13 -54
  116. data/lib/legion/extensions/llm/agent.rb +0 -366
  117. data/lib/legion/extensions/llm/aliases.json +0 -436
  118. data/lib/legion/extensions/llm/aliases.rb +0 -67
  119. data/lib/legion/extensions/llm/attachment.rb +0 -229
  120. data/lib/legion/extensions/llm/chat.rb +0 -354
  121. data/lib/legion/extensions/llm/chunk.rb +0 -10
  122. data/lib/legion/extensions/llm/content.rb +0 -81
  123. data/lib/legion/extensions/llm/context.rb +0 -33
  124. data/lib/legion/extensions/llm/embedding.rb +0 -33
  125. data/lib/legion/extensions/llm/image.rb +0 -109
  126. data/lib/legion/extensions/llm/inventory/capabilities.rb +0 -40
  127. data/lib/legion/extensions/llm/inventory/scoped_refresher.rb +0 -310
  128. data/lib/legion/extensions/llm/message.rb +0 -118
  129. data/lib/legion/extensions/llm/mime_type.rb +0 -75
  130. data/lib/legion/extensions/llm/model/info.rb +0 -286
  131. data/lib/legion/extensions/llm/model/modalities.rb +0 -26
  132. data/lib/legion/extensions/llm/model/pricing.rb +0 -52
  133. data/lib/legion/extensions/llm/model/pricing_category.rb +0 -50
  134. data/lib/legion/extensions/llm/model/pricing_tier.rb +0 -37
  135. data/lib/legion/extensions/llm/model.rb +0 -11
  136. data/lib/legion/extensions/llm/models.json +0 -57313
  137. data/lib/legion/extensions/llm/models.rb +0 -530
  138. data/lib/legion/extensions/llm/models_schema.json +0 -168
  139. data/lib/legion/extensions/llm/moderation.rb +0 -60
  140. data/lib/legion/extensions/llm/registry_event_builder.rb +0 -141
  141. data/lib/legion/extensions/llm/registry_publisher.rb +0 -107
  142. data/lib/legion/extensions/llm/responses/chat_response.rb +0 -43
  143. data/lib/legion/extensions/llm/responses/embedding_response.rb +0 -38
  144. data/lib/legion/extensions/llm/responses/stream_chunk.rb +0 -43
  145. data/lib/legion/extensions/llm/routing/lane_key.rb +0 -66
  146. data/lib/legion/extensions/llm/routing/model_offering.rb +0 -241
  147. data/lib/legion/extensions/llm/routing/offering_registry.rb +0 -101
  148. data/lib/legion/extensions/llm/routing/registry_event.rb +0 -167
  149. data/lib/legion/extensions/llm/thinking.rb +0 -53
  150. data/lib/legion/extensions/llm/tokens.rb +0 -51
  151. data/lib/legion/extensions/llm/tool_call.rb +0 -34
  152. data/lib/legion/extensions/llm/transcription.rb +0 -39
  153. data/lib/legion/extensions/llm/transport/messages/registry_event.rb +0 -44
  154. data/spec/legion/extensions/llm/agent_spec.rb +0 -179
  155. data/spec/legion/extensions/llm/attachment_spec.rb +0 -25
  156. data/spec/legion/extensions/llm/conformance/fixtures/ssot_identity_vectors.json +0 -84
  157. data/spec/legion/extensions/llm/context_spec.rb +0 -127
  158. data/spec/legion/extensions/llm/inventory/capabilities_spec.rb +0 -43
  159. data/spec/legion/extensions/llm/inventory/scoped_refresher_spec.rb +0 -340
  160. data/spec/legion/extensions/llm/message_spec.rb +0 -64
  161. data/spec/legion/extensions/llm/model/info_spec.rb +0 -222
  162. data/spec/legion/extensions/llm/models_spec.rb +0 -104
  163. data/spec/legion/extensions/llm/registry_event_builder_spec.rb +0 -68
  164. data/spec/legion/extensions/llm/registry_publisher_spec.rb +0 -22
  165. data/spec/legion/extensions/llm/responses/response_objects_spec.rb +0 -75
  166. data/spec/legion/extensions/llm/routing/model_offering_spec.rb +0 -281
  167. data/spec/legion/extensions/llm/routing/offering_registry_spec.rb +0 -50
  168. data/spec/legion/extensions/llm/routing/registry_event_spec.rb +0 -120
@@ -1,191 +1,88 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'spec_helper'
4
+ require_relative '../conformance/conformance'
4
5
 
5
6
  RSpec.describe Legion::Extensions::Llm::Canonical::ToolCall do
6
- describe '.build' do
7
- it 'creates a tool call with required fields' do
8
- tc = described_class.build(name: 'search', arguments: { query: 'test' })
9
-
10
- expect(tc.name).to eq('search')
11
- expect(tc.arguments).to eq({ query: 'test' })
12
- expect(tc.id).to start_with('call_')
13
- expect(tc.arguments).to be_a(Hash)
14
- end
15
-
16
- it 'generates a random id' do
17
- tc1 = described_class.build(name: 'search')
18
- tc2 = described_class.build(name: 'search')
19
-
20
- expect(tc1.id).not_to eq(tc2.id)
21
- end
22
-
23
- it 'accepts all fields' do
24
- tc = described_class.build(
25
- id: 'call-1',
26
- exchange_id: 'ex-1',
27
- name: 'search',
28
- arguments: { query: 'test' },
29
- source: :registry,
30
- status: :pending,
31
- data_handling_classification: :public,
32
- policy_decision: :allowed
33
- )
34
-
35
- expect(tc.id).to eq('call-1')
36
- expect(tc.exchange_id).to eq('ex-1')
37
- expect(tc.source).to eq(:registry)
38
- expect(tc.status).to eq(:pending)
39
- expect(tc.data_handling_classification).to eq(:public)
40
- expect(tc.policy_decision).to eq(:allowed)
41
- end
42
-
43
- it 'defaults arguments to empty hash' do
44
- tc = described_class.build(name: 'search')
45
-
46
- expect(tc.arguments).to eq({})
47
- end
7
+ let(:type_class) { described_class }
8
+ let(:auto_generated_members) { [:id] }
9
+ let(:type_source) do
10
+ {
11
+ id: 'call_1',
12
+ name: 'get_weather',
13
+ arguments: { location: 'San Francisco' },
14
+ source: 'client',
15
+ status: 'pending',
16
+ category: 'weather',
17
+ metadata: { origin: 'model' }
18
+ }
48
19
  end
49
20
 
50
- describe '.from_hash' do
51
- it 'parses from hash with symbol keys' do
52
- tc = described_class.from_hash(
53
- id: 'call-1',
54
- name: 'search',
55
- arguments: { query: 'test' },
56
- source: :registry
57
- )
21
+ it_behaves_like 'a canonical type'
58
22
 
59
- expect(tc.id).to eq('call-1')
60
- expect(tc.name).to eq('search')
61
- expect(tc.arguments).to eq({ query: 'test' })
62
- expect(tc.source).to eq(:registry)
23
+ describe 'H1 — .new is as strict as the factories' do
24
+ it 'rejects a poison source enum' do
25
+ expect { described_class.new(name: 'x', source: :bogus) }
26
+ .to raise_error(ArgumentError, /Invalid source: :bogus/)
63
27
  end
64
28
 
65
- it 'normalizes string source to symbol' do
66
- tc = described_class.from_hash(name: 'search', source: 'registry')
67
-
68
- expect(tc.source).to eq(:registry)
69
- end
70
-
71
- it 'normalizes string status to symbol' do
72
- tc = described_class.from_hash(name: 'search', status: 'success')
73
-
74
- expect(tc.status).to eq(:success)
75
- end
76
-
77
- it 'parses JSON string arguments to symbol keys per Legion::JSON convention' do
78
- tc = described_class.from_hash(
79
- name: 'search',
80
- arguments: '{"query":"test","limit":10}'
81
- )
82
-
83
- expect(tc.arguments).to eq({ query: 'test', limit: 10 })
84
- end
85
-
86
- it 'handles string keys' do
87
- tc = described_class.from_hash('name' => 'search', 'arguments' => '{}')
88
-
89
- expect(tc.name).to eq('search')
90
- expect(tc.arguments).to eq({})
91
- end
92
-
93
- it 'returns nil for nil source' do
94
- expect(described_class.from_hash(nil)).to be_nil
95
- end
96
- end
97
-
98
- describe '#with_result' do
99
- it 'returns a new tool call with result' do
100
- tc = described_class.build(name: 'search', source: :registry)
101
- result_tc = tc.with_result(result: { hits: 5 }, status: :success, duration_ms: 100)
102
-
103
- expect(result_tc.result).to eq({ hits: 5 })
104
- expect(result_tc.status).to eq(:success)
105
- expect(result_tc.duration_ms).to eq(100)
106
- expect(result_tc.finished_at).to be_a(Time)
107
- end
108
-
109
- it 'sets error on error status' do
110
- tc = described_class.build(name: 'search')
111
- result_tc = tc.with_result(result: 'not found', status: :error)
112
-
113
- expect(result_tc.error).to eq('not found')
114
- expect(result_tc.status).to eq(:error)
29
+ it 'rejects JSON-string arguments (Hash only, O03a)' do
30
+ expect { described_class.new(name: 'x', arguments: '{"a":1}') }
31
+ .to raise_error(ArgumentError, /arguments expected Hash, got String/)
115
32
  end
116
33
  end
117
34
 
118
- describe 'predicates' do
119
- it 'identifies successful calls' do
120
- tc = described_class.build(name: 'search', status: :success)
121
- expect(tc.success?).to be true
122
- expect(tc.error?).to be false
35
+ describe 'T5 — enum law (declared enums enforced in both factories)' do
36
+ it 'validates source against SOURCE_VALUES' do
37
+ expect { described_class.build(name: 'x', source: 'client') }.not_to raise_error
38
+ expect(described_class.build(name: 'x', source: 'client').source).to eq(:client)
39
+ expect { described_class.build(name: 'x', source: :bogus) }
40
+ .to raise_error(ArgumentError, /Invalid source: :bogus/)
41
+ expect { described_class.from_hash(name: 'x', source: 'nope') }
42
+ .to raise_error(ArgumentError, /Invalid source: :nope/)
123
43
  end
124
44
 
125
- it 'identifies error calls' do
126
- tc = described_class.build(name: 'search', status: :error)
127
- expect(tc.error?).to be true
128
- expect(tc.success?).to be false
45
+ it 'validates status against STATUS_VALUES' do
46
+ expect(described_class.build(name: 'x', status: 'running').status).to eq(:running)
47
+ expect { described_class.build(name: 'x', status: :finished) }
48
+ .to raise_error(ArgumentError, /Invalid status: :finished/)
49
+ expect { described_class.from_hash(name: 'x', status: 'done') }
50
+ .to raise_error(ArgumentError, /Invalid status: :done/)
129
51
  end
130
52
  end
131
53
 
132
- describe '#to_h' do
133
- it 'serializes to compact hash' do
134
- tc = described_class.build(name: 'search', arguments: { query: 'test' })
135
- hash = tc.to_h
136
-
137
- expect(hash).to include(id: tc.id, name: 'search', arguments: { query: 'test' })
54
+ describe 'O03a — arguments Hash only' do
55
+ it 'does not parse JSON-string arguments (the parse + rescue are deleted)' do
56
+ expect { described_class.build(name: 'x', arguments: '{"a":1}') }
57
+ .to raise_error(ArgumentError, /arguments expected Hash, got String/)
58
+ expect { described_class.from_hash(name: 'x', arguments: '{\"a\":1}') }
59
+ .to raise_error(ArgumentError, /arguments expected Hash, got String/)
138
60
  end
139
- end
140
-
141
- describe '#to_audit_hash' do
142
- it 'includes compliance fields' do
143
- tc = described_class.build(
144
- name: 'search',
145
- source: :registry,
146
- data_handling_classification: :public,
147
- policy_decision: :allowed
148
- )
149
- hash = tc.to_audit_hash
150
61
 
151
- expect(hash).to include(
152
- name: 'search',
153
- source: :registry,
154
- data_handling_classification: :public,
155
- policy_decision: :allowed
156
- )
62
+ it 'defaults nil arguments to {} (documented no-arguments default)' do
63
+ expect(described_class.build(name: 'x').arguments).to eq({})
64
+ expect(described_class.from_hash(name: 'x').arguments).to eq({})
157
65
  end
158
66
  end
159
67
 
160
- describe 'SOURCE_VALUES' do
161
- it 'includes all expected source types' do
162
- expect(described_class::SOURCE_VALUES).to eq(%i[client registry special extension mcp])
163
- end
164
- end
68
+ describe 'with_result (canonical mutation-free update)' do
69
+ it 'attaches the result and normalizes error' do
70
+ tc = described_class.build(name: 'x', id: 'c1', status: 'running')
71
+ done = tc.with_result(result: 'ok', status: :success, duration_ms: 12)
72
+ expect(done.result).to eq('ok')
73
+ expect(done.status).to eq(:success)
74
+ expect(done.error).to be_nil
75
+ expect(done.success?).to be(true)
165
76
 
166
- describe 'STATUS_VALUES' do
167
- it 'includes all expected status types' do
168
- expect(described_class::STATUS_VALUES).to eq(%i[pending running success error])
77
+ failed = tc.with_result(result: 'boom', status: :error)
78
+ expect(failed.error).to eq('boom')
79
+ expect(failed.error?).to be(true)
169
80
  end
170
- end
171
-
172
- describe 'round-trip' do
173
- it 'preserves values through from_hash/to_h' do
174
- original = {
175
- id: 'call-1',
176
- name: 'search',
177
- arguments: { query: 'test' },
178
- source: 'registry',
179
- status: 'pending'
180
- }
181
- tc = described_class.from_hash(original)
182
- serialized = tc.to_h
183
81
 
184
- expect(serialized[:id]).to eq('call-1')
185
- expect(serialized[:name]).to eq('search')
186
- expect(serialized[:arguments]).to eq({ query: 'test' })
187
- expect(serialized[:source]).to eq(:registry)
188
- expect(serialized[:status]).to eq(:pending)
82
+ it 'validates the result status enum' do
83
+ tc = described_class.build(name: 'x', id: 'c1', status: 'running')
84
+ expect { tc.with_result(result: 'r', status: :bogus) }
85
+ .to raise_error(ArgumentError, /Invalid status: :bogus/)
189
86
  end
190
87
  end
191
88
  end
@@ -1,221 +1,85 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'spec_helper'
4
+ require_relative '../conformance/conformance'
4
5
 
5
6
  RSpec.describe Legion::Extensions::Llm::Canonical::ToolDefinition do
6
- describe '.build' do
7
- it 'creates a tool definition with name and description' do
8
- tool = described_class.build(name: 'search', description: 'Search the web')
9
-
10
- expect(tool.name).to eq('search')
11
- expect(tool.description).to eq('Search the web')
12
- expect(tool.parameters).to eq(type: 'object', properties: {})
13
- expect(tool.source).to eq({ type: :builtin })
14
- end
15
-
16
- it 'accepts parameters and source' do
17
- tool = described_class.build(
18
- name: 'search',
19
- description: 'Search',
20
- parameters: { type: 'object', properties: { query: { type: 'string' } } },
21
- source: { type: :registry }
22
- )
23
-
24
- expect(tool.parameters).to eq({ type: 'object', properties: { query: { type: 'string' } } })
25
- expect(tool.source).to eq({ type: :registry })
26
- end
27
-
28
- it 'sanitizes tool names' do
29
- tool = described_class.build(name: 'My.Tool.Name!', description: 'test')
30
-
31
- expect(tool.name).to eq('My_Tool_Name')
32
- end
33
-
34
- it 'truncates long tool names' do
35
- long_name = 'a' * 100
36
- tool = described_class.build(name: long_name)
37
-
38
- expect(tool.name.length).to eq(64)
39
- end
40
-
41
- it 'provides fallback name for empty input' do
42
- tool = described_class.build(name: '')
43
-
44
- expect(tool.name).to eq('tool')
45
- end
46
-
47
- it 'converts nil description to empty string' do
48
- tool = described_class.build(name: 'search')
49
-
50
- expect(tool.description).to eq('')
51
- end
7
+ let(:type_class) { described_class }
8
+ let(:auto_generated_members) { [] }
9
+ let(:type_source) do
10
+ {
11
+ name: 'get_weather',
12
+ description: 'Get the weather',
13
+ parameters: { type: 'object', properties: { location: { type: 'string' } } },
14
+ source: { type: :registry, tool_class: 'WeatherTool' },
15
+ metadata: { origin: 'client' }
16
+ }
52
17
  end
53
18
 
54
- describe '.from_hash' do
55
- it 'parses from hash with symbol keys' do
56
- tool = described_class.from_hash({ name: 'search', description: 'Search', parameters: { type: 'object' } })
57
-
58
- expect(tool.name).to eq('search')
59
- expect(tool.description).to eq('Search')
60
- expect(tool.parameters).to eq({ type: 'object' })
61
- end
62
-
63
- it 'parses from hash with string keys' do
64
- tool = described_class.from_hash({ 'name' => 'search', 'description' => 'Search' })
65
-
66
- expect(tool.name).to eq('search')
67
- expect(tool.description).to eq('Search')
68
- end
69
-
70
- it 'accepts input_schema as alias for parameters' do
71
- tool = described_class.from_hash({ name: 'search', input_schema: { type: 'object' } })
72
-
73
- expect(tool.parameters).to eq({ type: 'object' })
74
- end
75
-
76
- it 'accepts source parameter' do
77
- tool = described_class.from_hash({ name: 'search', source: { type: :extension } })
78
-
79
- expect(tool.source).to eq({ type: :extension })
80
- end
81
-
82
- it 'overrides source with keyword arg' do
83
- tool = described_class.from_hash(
84
- { name: 'search', source: { type: :builtin } },
85
- source: { type: :override }
86
- )
19
+ it_behaves_like 'a canonical type'
87
20
 
88
- expect(tool.source).to eq({ type: :override })
21
+ describe 'O03a — no input_schema alias' do
22
+ it 'does not translate input_schema (it folds into metadata)' do
23
+ td = described_class.from_hash(name: 'x', input_schema: { type: 'object', properties: {} })
24
+ expect(td.parameters).to eq(type: 'object', properties: {})
25
+ expect(td.metadata).to eq(input_schema: { type: 'object', properties: {} })
89
26
  end
90
27
  end
91
28
 
92
- describe '.from_registry_entry' do
93
- it 'creates from registry entry with tool_class' do
94
- entry = {
95
- name: 'ruby',
96
- description: 'Run Ruby code',
97
- input_schema: { type: 'object' },
98
- tool_class: 'RubyTool',
99
- extension: 'legion-code',
100
- runner: 'RubyRunner',
101
- function: :execute
102
- }
103
- tool = described_class.from_registry_entry(entry)
104
-
105
- expect(tool.name).to eq('ruby')
106
- expect(tool.description).to eq('Run Ruby code')
107
- expect(tool.parameters).to eq({ type: 'object' })
108
- expect(tool.source[:type]).to eq(:registry)
109
- expect(tool.source[:extension]).to eq('legion-code')
110
- end
111
-
112
- it 'creates from registry entry without tool_class' do
113
- entry = {
114
- name: 'custom',
115
- description: 'Custom tool',
116
- parameters: { type: 'object', properties: { name: { type: 'string' } } },
117
- extension: 'custom-ext'
118
- }
119
- tool = described_class.from_registry_entry(entry)
120
-
121
- expect(tool.source[:type]).to eq(:extension)
29
+ describe 'L3 — no coercion of non-Hash input' do
30
+ it 'raises on non-Hash from_hash input (the {} fabrication is deleted)' do
31
+ expect { described_class.from_hash(nil) }
32
+ .to raise_error(ArgumentError, /expected Hash, got NilClass/)
33
+ expect { described_class.from_hash('raw') }
34
+ .to raise_error(ArgumentError, /expected Hash, got String/)
122
35
  end
123
36
  end
124
37
 
125
- describe '.sanitize_tool_name' do
126
- it 'replaces dots with underscores' do
127
- expect(described_class.sanitize_tool_name('my.tool')).to eq('my_tool')
38
+ describe 'schema normalization (frozen inference rules)' do
39
+ it 'defaults missing/empty parameters to an empty object schema' do
40
+ expect(described_class.build(name: 'x').parameters).to eq(type: 'object', properties: {})
128
41
  end
129
42
 
130
- it 'removes special characters' do
131
- expect(described_class.sanitize_tool_name('my-tool!@#')).to eq('my-tool')
43
+ it 'wraps bare property maps in an object schema' do
44
+ td = described_class.build(name: 'x', parameters: { a: { type: 'string' } })
45
+ expect(td.parameters).to eq(type: 'object', properties: { a: { type: 'string' } })
132
46
  end
133
47
 
134
- it 'preserves alphanumeric, underscores, and hyphens' do
135
- expect(described_class.sanitize_tool_name('my-tool_123')).to eq('my-tool_123')
136
- end
137
- end
138
-
139
- describe '#to_h' do
140
- it 'serializes to hash with name, description, parameters' do
141
- tool = described_class.build(
142
- name: 'search',
143
- description: 'Search the web',
144
- parameters: { type: 'object' }
145
- )
146
- hash = tool.to_h
147
-
148
- expect(hash).to eq(
149
- name: 'search',
150
- description: 'Search the web',
151
- parameters: { type: 'object' }
152
- )
153
- end
154
-
155
- it 'omits nil values' do
156
- tool = described_class.new('search', '', nil, nil)
157
- hash = tool.to_h
158
-
159
- expect(hash).to eq(name: 'search')
48
+ it 'keeps explicit type and composite schemas as-is' do
49
+ expect(described_class.build(name: 'x', parameters: { type: 'string' }).parameters)
50
+ .to eq(type: 'string')
51
+ expect(described_class.build(name: 'x', parameters: { oneOf: [] }).parameters)
52
+ .to eq(oneOf: [])
160
53
  end
161
54
  end
162
55
 
163
- describe '.normalize_parameters' do
164
- it 'injects type: object when a schema has properties but no type' do
165
- schema = { properties: { task: { type: 'string' } }, required: ['task'] }
166
- result = described_class.normalize_parameters(schema)
167
- expect(result[:type]).to eq('object')
168
- expect(result[:properties]).to eq(task: { type: 'string' })
169
- expect(result[:required]).to eq(['task'])
56
+ describe 'M5 — the name is authoritative (no rewrite, no fabrication)' do
57
+ it 'preserves the client/registry name verbatim (dialect rules live at the provider edge)' do
58
+ expect(described_class.build(name: 'get.weather').name).to eq('get.weather')
59
+ expect(described_class.build(name: 'a' * 80).name).to eq('a' * 80)
60
+ expect(described_class).not_to respond_to(:sanitize_tool_name)
170
61
  end
171
62
 
172
- it 'passes schemas with an explicit type through unchanged' do
173
- schema = { type: 'object', properties: { a: { type: 'string' } } }
174
- expect(described_class.normalize_parameters(schema)).to eq(schema)
63
+ it 'raises on a missing or empty name (the "tool" fabrication is deleted)' do
64
+ expect { described_class.build(name: nil) }
65
+ .to raise_error(ArgumentError, /name must be a non-empty String/)
66
+ expect { described_class.build(name: '') }
67
+ .to raise_error(ArgumentError, /name must be a non-empty String/)
68
+ expect { described_class.from_hash({}) }
69
+ .to raise_error(ArgumentError, /name must be a non-empty String/)
175
70
  end
176
71
 
177
- it 'wraps a bare property map under type:object/properties' do
178
- expect(described_class.normalize_parameters(location: { type: 'string' }))
179
- .to eq(type: 'object', properties: { location: { type: 'string' } })
180
- end
181
-
182
- it 'returns an empty object schema for nil/empty' do
183
- expect(described_class.normalize_parameters(nil)).to eq(type: 'object', properties: {})
184
- expect(described_class.normalize_parameters({})).to eq(type: 'object', properties: {})
185
- end
186
-
187
- it 'symbolizes top-level string keys' do
188
- result = described_class.normalize_parameters('properties' => { 'a' => { 'type' => 'string' } })
189
- expect(result[:type]).to eq('object')
190
- expect(result).to have_key(:properties)
191
- end
192
-
193
- it 'leaves composite schemas (oneOf etc.) without forcing type' do
194
- schema = { oneOf: [{ type: 'string' }, { type: 'integer' }] }
195
- expect(described_class.normalize_parameters(schema)).to eq(schema)
196
- end
197
-
198
- it 'normalizes parameters at construction via .build' do
199
- td = described_class.build(name: 'multi_agent_v1',
200
- parameters: { properties: { task: { type: 'string' } } })
201
- expect(td.parameters[:type]).to eq('object')
202
- end
203
-
204
- it 'normalizes nil parameters to empty object schema via .build' do
205
- td = described_class.build(name: 'bare_tool')
206
- expect(td.parameters).to eq(type: 'object', properties: {})
72
+ it 'does not fabricate a source (explicit or absent, never laundered to builtin)' do
73
+ expect(described_class.build(name: 'x').source).to be_nil
74
+ expect(described_class.build(name: 'x', source: { type: :client }).source).to eq(type: :client)
75
+ expect { described_class.build(name: 'x', source: 'client') }
76
+ .to raise_error(ArgumentError, /source expected Hash/)
207
77
  end
208
78
  end
209
79
 
210
- describe 'round-trip' do
211
- it 'preserves values through from_hash/to_h' do
212
- original = { name: 'search', description: 'Search', parameters: { type: 'object' } }
213
- tool = described_class.from_hash(original)
214
- serialized = tool.to_h
215
-
216
- expect(serialized[:name]).to eq('search')
217
- expect(serialized[:description]).to eq('Search')
218
- expect(serialized[:parameters]).to eq({ type: 'object' })
219
- end
80
+ it 'builds from keyword args with defaults (empty description, absent source)' do
81
+ td = described_class.build(name: 'x')
82
+ expect(td.description).to eq('')
83
+ expect(td.source).to be_nil
220
84
  end
221
85
  end
@@ -3,81 +3,40 @@
3
3
  require 'spec_helper'
4
4
 
5
5
  RSpec.describe Legion::Extensions::Llm::Canonical::ToolSchema do
6
- let(:full_schema) { { type: 'object', properties: { city: { type: 'string' } }, required: ['city'] } }
7
- let(:canonical_tool) do
6
+ let(:tool_definition) do
8
7
  Legion::Extensions::Llm::Canonical::ToolDefinition.build(
9
- name: 'get_weather', description: 'Weather lookup', parameters: full_schema
8
+ name: 'get_weather',
9
+ description: 'Get the weather',
10
+ parameters: { type: 'object', properties: { location: { type: 'string' } } }
10
11
  )
11
12
  end
12
13
 
13
- describe '.extract' do
14
- it 'extracts from a Canonical::ToolDefinition' do
15
- result = described_class.extract(canonical_tool)
16
- expect(result[:type]).to eq('object')
17
- expect(result[:properties]).to eq(city: { type: 'string' })
18
- end
19
-
20
- it 'extracts from a Hash with :parameters' do
21
- result = described_class.extract({ parameters: full_schema })
22
- expect(result[:type]).to eq('object')
23
- expect(result[:properties]).to eq(city: { type: 'string' })
24
- end
25
-
26
- it 'extracts from a Hash with :input_schema' do
27
- result = described_class.extract({ input_schema: full_schema })
28
- expect(result[:type]).to eq('object')
29
- expect(result[:properties]).to eq(city: { type: 'string' })
30
- end
31
-
32
- it 'extracts from a Hash with :params_schema' do
33
- result = described_class.extract({ params_schema: full_schema })
34
- expect(result[:type]).to eq('object')
35
- expect(result[:properties]).to eq(city: { type: 'string' })
36
- end
37
-
38
- it 'extracts from an object responding to params_schema' do
39
- tool = Struct.new(:params_schema).new(full_schema)
40
- result = described_class.extract(tool)
41
- expect(result[:type]).to eq('object')
42
- expect(result[:properties]).to eq(city: { type: 'string' })
43
- end
44
-
45
- it 'returns empty object schema for nil' do
46
- expect(described_class.extract(nil)).to eq(type: 'object', properties: {})
47
- end
48
-
49
- it 'returns empty object schema for empty hash' do
50
- expect(described_class.extract({})).to eq(type: 'object', properties: {})
51
- end
52
- end
53
-
54
- describe '.tool_name' do
55
- it 'gets name from Canonical::ToolDefinition' do
56
- expect(described_class.tool_name(canonical_tool)).to eq('get_weather')
57
- end
58
-
59
- it 'gets name from Hash' do
60
- expect(described_class.tool_name({ name: 'foo' })).to eq('foo')
61
- end
14
+ it 'extracts the normalized schema from a ToolDefinition' do
15
+ expect(described_class.extract(tool_definition)).to eq(
16
+ type: 'object', properties: { location: { type: 'string' } }
17
+ )
62
18
  end
63
19
 
64
- describe '.tool_description' do
65
- it 'gets description from Canonical::ToolDefinition' do
66
- expect(described_class.tool_description(canonical_tool)).to eq('Weather lookup')
67
- end
68
-
69
- it 'gets description from Hash' do
70
- expect(described_class.tool_description({ description: 'bar' })).to eq('bar')
71
- end
20
+ it 'returns the tool name and description' do
21
+ expect(described_class.tool_name(tool_definition)).to eq('get_weather')
22
+ expect(described_class.tool_description(tool_definition)).to eq('Get the weather')
72
23
  end
73
24
 
74
- describe 'ToolDefinition compatibility readers' do
75
- it 'params_schema returns normalized parameters' do
76
- expect(canonical_tool.params_schema).to eq(full_schema)
77
- end
78
-
79
- it 'input_schema aliases params_schema' do
80
- expect(canonical_tool.input_schema).to eq(canonical_tool.params_schema)
25
+ describe '04 §6 — ToolDefinition ONLY (dual-shape accessors deleted)' do
26
+ it 'raises on a raw Hash' do
27
+ expect { described_class.extract({ name: 'x' }) }
28
+ .to raise_error(ArgumentError, /expected Canonical::ToolDefinition, got Hash/)
29
+ expect { described_class.tool_name({ name: 'x' }) }
30
+ .to raise_error(ArgumentError, /expected Canonical::ToolDefinition, got Hash/)
31
+ expect { described_class.tool_description({ name: 'x' }) }
32
+ .to raise_error(ArgumentError, /expected Canonical::ToolDefinition, got Hash/)
33
+ end
34
+
35
+ it 'raises on nil and other wrong classes' do
36
+ expect { described_class.extract(nil) }
37
+ .to raise_error(ArgumentError, /expected Canonical::ToolDefinition, got NilClass/)
38
+ expect { described_class.tool_name('str') }
39
+ .to raise_error(ArgumentError, /expected Canonical::ToolDefinition, got String/)
81
40
  end
82
41
  end
83
42
  end