lex-llm 0.4.18 → 0.5.1

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 (125) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +13 -2
  3. data/B1b-conformance-kit.md +79 -0
  4. data/CHANGELOG.md +27 -0
  5. data/lex-llm.gemspec +2 -3
  6. data/lib/legion/extensions/llm/attachment.rb +1 -1
  7. data/lib/legion/extensions/llm/canonical/chunk.rb +184 -0
  8. data/lib/legion/extensions/llm/canonical/content_block.rb +126 -0
  9. data/lib/legion/extensions/llm/canonical/message.rb +138 -0
  10. data/lib/legion/extensions/llm/canonical/params.rb +61 -0
  11. data/lib/legion/extensions/llm/canonical/request.rb +117 -0
  12. data/lib/legion/extensions/llm/canonical/response.rb +124 -0
  13. data/lib/legion/extensions/llm/canonical/thinking.rb +81 -0
  14. data/lib/legion/extensions/llm/canonical/tool_call.rb +134 -0
  15. data/lib/legion/extensions/llm/canonical/tool_definition.rb +98 -0
  16. data/lib/legion/extensions/llm/canonical/tool_schema.rb +46 -0
  17. data/lib/legion/extensions/llm/canonical/usage.rb +74 -0
  18. data/lib/legion/extensions/llm/canonical.rb +50 -0
  19. data/lib/legion/extensions/llm/chat.rb +3 -5
  20. data/lib/legion/extensions/llm/connection.rb +5 -1
  21. data/lib/legion/extensions/llm/error.rb +5 -7
  22. data/lib/legion/extensions/llm/fleet/envelope_validation.rb +1 -3
  23. data/lib/legion/extensions/llm/fleet/provider_responder.rb +1 -3
  24. data/lib/legion/extensions/llm/fleet/token_validator.rb +1 -3
  25. data/lib/legion/extensions/llm/model/info.rb +4 -6
  26. data/lib/legion/extensions/llm/models.rb +3 -3
  27. data/lib/legion/extensions/llm/provider/open_ai_compatible.rb +9 -4
  28. data/lib/legion/extensions/llm/provider.rb +21 -4
  29. data/lib/legion/extensions/llm/provider_contract.rb +10 -1
  30. data/lib/legion/extensions/llm/routing/lane_key.rb +1 -3
  31. data/lib/legion/extensions/llm/stream_accumulator.rb +40 -1
  32. data/lib/legion/extensions/llm/streaming.rb +13 -5
  33. data/lib/legion/extensions/llm/tool.rb +1 -3
  34. data/lib/legion/extensions/llm/version.rb +1 -1
  35. data/lib/legion/extensions/llm.rb +118 -35
  36. data/spec/fixtures/ruby.mp3 +0 -0
  37. data/spec/fixtures/ruby.mp4 +0 -0
  38. data/spec/fixtures/ruby.png +0 -0
  39. data/spec/fixtures/ruby.txt +1 -0
  40. data/spec/fixtures/ruby.wav +0 -0
  41. data/spec/fixtures/ruby.xml +1 -0
  42. data/spec/fixtures/sample.pdf +0 -0
  43. data/spec/legion/extensions/llm/agent_spec.rb +179 -0
  44. data/spec/legion/extensions/llm/attachment_spec.rb +25 -0
  45. data/spec/legion/extensions/llm/auto_registration_spec.rb +38 -0
  46. data/spec/legion/extensions/llm/canonical/chunk_spec.rb +285 -0
  47. data/spec/legion/extensions/llm/canonical/content_block_spec.rb +179 -0
  48. data/spec/legion/extensions/llm/canonical/message_spec.rb +203 -0
  49. data/spec/legion/extensions/llm/canonical/params_spec.rb +159 -0
  50. data/spec/legion/extensions/llm/canonical/request_spec.rb +174 -0
  51. data/spec/legion/extensions/llm/canonical/response_spec.rb +234 -0
  52. data/spec/legion/extensions/llm/canonical/thinking_spec.rb +151 -0
  53. data/spec/legion/extensions/llm/canonical/tool_call_spec.rb +191 -0
  54. data/spec/legion/extensions/llm/canonical/tool_definition_spec.rb +221 -0
  55. data/spec/legion/extensions/llm/canonical/tool_schema_spec.rb +83 -0
  56. data/spec/legion/extensions/llm/canonical/usage_spec.rb +178 -0
  57. data/spec/legion/extensions/llm/configuration_spec.rb +38 -0
  58. data/spec/legion/extensions/llm/conformance/client_translator_examples.rb +432 -0
  59. data/spec/legion/extensions/llm/conformance/conformance.rb +51 -0
  60. data/spec/legion/extensions/llm/conformance/echo_translator.rb +56 -0
  61. data/spec/legion/extensions/llm/conformance/echo_translator_spec.rb +13 -0
  62. data/spec/legion/extensions/llm/conformance/fixtures/canonical_empty_response.json +13 -0
  63. data/spec/legion/extensions/llm/conformance/fixtures/canonical_error_response.json +19 -0
  64. data/spec/legion/extensions/llm/conformance/fixtures/canonical_fleet_round_trip.json +81 -0
  65. data/spec/legion/extensions/llm/conformance/fixtures/canonical_metering_audit_events.json +101 -0
  66. data/spec/legion/extensions/llm/conformance/fixtures/canonical_params_mapping_request.json +21 -0
  67. data/spec/legion/extensions/llm/conformance/fixtures/canonical_server_tool_continuation_request.json +43 -0
  68. data/spec/legion/extensions/llm/conformance/fixtures/canonical_server_tool_use_response.json +29 -0
  69. data/spec/legion/extensions/llm/conformance/fixtures/canonical_simple_text_request.json +13 -0
  70. data/spec/legion/extensions/llm/conformance/fixtures/canonical_simple_text_response.json +13 -0
  71. data/spec/legion/extensions/llm/conformance/fixtures/canonical_stop_reason_matrix.json +36 -0
  72. data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_accumulated_response.json +20 -0
  73. data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_error_chunks.json +26 -0
  74. data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_server_tool_chunks.json +52 -0
  75. data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_text_chunks.json +33 -0
  76. data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_thinking_chunks.json +42 -0
  77. data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_tool_call_chunks.json +41 -0
  78. data/spec/legion/extensions/llm/conformance/fixtures/canonical_system_prompt_request.json +14 -0
  79. data/spec/legion/extensions/llm/conformance/fixtures/canonical_thinking_request.json +18 -0
  80. data/spec/legion/extensions/llm/conformance/fixtures/canonical_thinking_response.json +17 -0
  81. data/spec/legion/extensions/llm/conformance/fixtures/canonical_tool_results_continuation_request.json +75 -0
  82. data/spec/legion/extensions/llm/conformance/fixtures/canonical_tool_use_response.json +25 -0
  83. data/spec/legion/extensions/llm/conformance/fixtures/canonical_tools_request.json +34 -0
  84. data/spec/legion/extensions/llm/conformance/provider_tool_rendering_examples.rb +77 -0
  85. data/spec/legion/extensions/llm/conformance/provider_translator_examples.rb +390 -0
  86. data/spec/legion/extensions/llm/connection_logging_spec.rb +53 -0
  87. data/spec/legion/extensions/llm/connection_retry_spec.rb +36 -0
  88. data/spec/legion/extensions/llm/context_spec.rb +127 -0
  89. data/spec/legion/extensions/llm/credential_sources_spec.rb +468 -0
  90. data/spec/legion/extensions/llm/error_middleware_spec.rb +102 -0
  91. data/spec/legion/extensions/llm/error_spec.rb +87 -0
  92. data/spec/legion/extensions/llm/fleet/provider_responder_spec.rb +120 -0
  93. data/spec/legion/extensions/llm/fleet/token_validator_spec.rb +163 -0
  94. data/spec/legion/extensions/llm/fleet/worker_execution_spec.rb +128 -0
  95. data/spec/legion/extensions/llm/fleet_messages_spec.rb +402 -0
  96. data/spec/legion/extensions/llm/gemspec_spec.rb +25 -0
  97. data/spec/legion/extensions/llm/message_spec.rb +64 -0
  98. data/spec/legion/extensions/llm/model/info_spec.rb +222 -0
  99. data/spec/legion/extensions/llm/models_spec.rb +104 -0
  100. data/spec/legion/extensions/llm/provider/open_ai_compatible_spec.rb +203 -0
  101. data/spec/legion/extensions/llm/provider/open_ai_compatible_tool_calls_array_spec.rb +68 -0
  102. data/spec/legion/extensions/llm/provider_contract_spec.rb +60 -0
  103. data/spec/legion/extensions/llm/provider_settings_spec.rb +76 -0
  104. data/spec/legion/extensions/llm/provider_spec.rb +613 -0
  105. data/spec/legion/extensions/llm/registry_event_builder_spec.rb +68 -0
  106. data/spec/legion/extensions/llm/registry_publisher_spec.rb +22 -0
  107. data/spec/legion/extensions/llm/responses/response_objects_spec.rb +75 -0
  108. data/spec/legion/extensions/llm/responses/thinking_extractor_spec.rb +75 -0
  109. data/spec/legion/extensions/llm/routing/model_offering_spec.rb +222 -0
  110. data/spec/legion/extensions/llm/routing/offering_registry_spec.rb +50 -0
  111. data/spec/legion/extensions/llm/routing/registry_event_spec.rb +120 -0
  112. data/spec/legion/extensions/llm/stream_accumulator_spec.rb +155 -0
  113. data/spec/legion/extensions/llm/streaming_spec.rb +108 -0
  114. data/spec/legion/extensions/llm/tool_spec.rb +94 -0
  115. data/spec/legion/extensions/llm/transport/fleet_lane_spec.rb +60 -0
  116. data/spec/legion/extensions/llm/utils_spec.rb +113 -0
  117. data/spec/legion/extensions/llm_base_contract_spec.rb +110 -0
  118. data/spec/legion/extensions/llm_extension_spec.rb +78 -0
  119. data/spec/legion/extensions/llm_root_spec.rb +51 -0
  120. data/spec/spec_helper.rb +24 -0
  121. data/spec/support/fake_llm_provider.rb +148 -0
  122. data/spec/support/llm_configuration.rb +21 -0
  123. data/spec/support/rspec_configuration.rb +19 -0
  124. data/spec/support/simplecov_configuration.rb +20 -0
  125. metadata +103 -15
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe Legion::Extensions::Llm::Canonical::Thinking do
6
+ describe '.from_hash' do
7
+ it 'returns a Thinking instance with content and signature' do
8
+ thinking = described_class.from_hash(content: 'reasoning here', signature: 'sig-abc')
9
+
10
+ expect(thinking).to be_a(described_class)
11
+ expect(thinking.content).to eq('reasoning here')
12
+ expect(thinking.signature).to eq('sig-abc')
13
+ end
14
+
15
+ it 'handles string keys' do
16
+ thinking = described_class.from_hash('content' => 'reasoning', 'signature' => 'sig-123')
17
+
18
+ expect(thinking.content).to eq('reasoning')
19
+ expect(thinking.signature).to eq('sig-123')
20
+ end
21
+
22
+ it 'returns nil for nil source' do
23
+ expect(described_class.from_hash(nil)).to be_nil
24
+ end
25
+
26
+ it 'returns nil for empty content and signature' do
27
+ result = described_class.from_hash(content: '', signature: '')
28
+
29
+ expect(result).to be_nil
30
+ end
31
+
32
+ it 'returns nil when both fields are nil' do
33
+ result = described_class.from_hash(content: nil, signature: nil)
34
+
35
+ expect(result).to be_nil
36
+ end
37
+
38
+ it 'returns instance with only content' do
39
+ thinking = described_class.from_hash(content: 'just reasoning')
40
+
41
+ expect(thinking.content).to eq('just reasoning')
42
+ expect(thinking.signature).to be_nil
43
+ end
44
+
45
+ it 'returns instance with only signature' do
46
+ thinking = described_class.from_hash(signature: 'sig-only')
47
+
48
+ expect(thinking.content).to be_nil
49
+ expect(thinking.signature).to eq('sig-only')
50
+ end
51
+ end
52
+
53
+ describe '#to_h' do
54
+ it 'serializes to compact hash' do
55
+ thinking = described_class.new(content: 'reasoning', signature: 'sig-1')
56
+ hash = thinking.to_h
57
+
58
+ expect(hash).to eq(content: 'reasoning', signature: 'sig-1')
59
+ end
60
+
61
+ it 'omits nil values' do
62
+ thinking = described_class.new(content: 'reasoning', signature: nil)
63
+ hash = thinking.to_h
64
+
65
+ expect(hash).to eq(content: 'reasoning')
66
+ end
67
+ end
68
+
69
+ describe '#empty?' do
70
+ it 'returns true when both fields are nil' do
71
+ thinking = described_class.new(content: nil, signature: nil)
72
+ expect(thinking.empty?).to be true
73
+ end
74
+
75
+ it 'returns false when content is present' do
76
+ thinking = described_class.new(content: 'reasoning', signature: nil)
77
+ expect(thinking.empty?).to be false
78
+ end
79
+ end
80
+
81
+ describe 'round-trip' do
82
+ it 'preserves content and signature through from_hash/to_h' do
83
+ original = { content: 'deep reasoning', signature: 'sig-xyz' }
84
+ thinking = described_class.from_hash(original)
85
+ serialized = thinking.to_h
86
+
87
+ expect(serialized).to eq(original)
88
+ end
89
+ end
90
+
91
+ describe '::Config' do
92
+ let(:config_class) { Legion::Extensions::Llm::Canonical::Thinking::Config }
93
+
94
+ describe '.build' do
95
+ it 'creates a config with effort and budget' do
96
+ config = config_class.build(effort: 'high', budget: 10_000)
97
+
98
+ expect(config.effort).to eq('high')
99
+ expect(config.budget).to eq(10_000)
100
+ expect(config.enabled?).to be true
101
+ end
102
+
103
+ it 'converts symbol effort to string' do
104
+ config = config_class.build(effort: :high)
105
+
106
+ expect(config.effort).to eq('high')
107
+ end
108
+
109
+ it 'creates disabled config when no values' do
110
+ config = config_class.build
111
+
112
+ expect(config.enabled?).to be false
113
+ end
114
+ end
115
+
116
+ describe '.from_hash' do
117
+ it 'parses config from hash' do
118
+ config = config_class.from_hash(effort: 'medium', budget: 5000)
119
+
120
+ expect(config.effort).to eq('medium')
121
+ expect(config.budget).to eq(5000)
122
+ end
123
+
124
+ it 'handles string keys' do
125
+ config = config_class.from_hash('effort' => 'low')
126
+
127
+ expect(config.effort).to eq('low')
128
+ end
129
+
130
+ it 'returns nil for nil source' do
131
+ expect(config_class.from_hash(nil)).to be_nil
132
+ end
133
+
134
+ it 'returns nil for empty hash' do
135
+ expect(config_class.from_hash({})).to be_nil
136
+ end
137
+ end
138
+
139
+ describe '#to_h' do
140
+ it 'serializes to compact hash' do
141
+ config = config_class.build(effort: 'high', budget: 10_000)
142
+ expect(config.to_h).to eq(effort: 'high', budget: 10_000)
143
+ end
144
+
145
+ it 'omits nil values' do
146
+ config = config_class.build(effort: 'low')
147
+ expect(config.to_h).to eq(effort: 'low')
148
+ end
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,191 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ 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
48
+ end
49
+
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
+ )
58
+
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)
63
+ end
64
+
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)
115
+ end
116
+ end
117
+
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
123
+ end
124
+
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
129
+ end
130
+ end
131
+
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' })
138
+ 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
+
151
+ expect(hash).to include(
152
+ name: 'search',
153
+ source: :registry,
154
+ data_handling_classification: :public,
155
+ policy_decision: :allowed
156
+ )
157
+ end
158
+ end
159
+
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
165
+
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])
169
+ 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
+
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)
189
+ end
190
+ end
191
+ end
@@ -0,0 +1,221 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ 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
52
+ end
53
+
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
+ )
87
+
88
+ expect(tool.source).to eq({ type: :override })
89
+ end
90
+ end
91
+
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)
122
+ end
123
+ end
124
+
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')
128
+ end
129
+
130
+ it 'removes special characters' do
131
+ expect(described_class.sanitize_tool_name('my-tool!@#')).to eq('my-tool')
132
+ end
133
+
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')
160
+ end
161
+ end
162
+
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'])
170
+ end
171
+
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)
175
+ end
176
+
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: {})
207
+ end
208
+ end
209
+
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
220
+ end
221
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
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
8
+ Legion::Extensions::Llm::Canonical::ToolDefinition.build(
9
+ name: 'get_weather', description: 'Weather lookup', parameters: full_schema
10
+ )
11
+ end
12
+
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
62
+ end
63
+
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
72
+ end
73
+
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)
81
+ end
82
+ end
83
+ end