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.
- checksums.yaml +4 -4
- data/.rubocop.yml +13 -2
- data/B1b-conformance-kit.md +79 -0
- data/CHANGELOG.md +27 -0
- data/lex-llm.gemspec +2 -3
- data/lib/legion/extensions/llm/attachment.rb +1 -1
- data/lib/legion/extensions/llm/canonical/chunk.rb +184 -0
- data/lib/legion/extensions/llm/canonical/content_block.rb +126 -0
- data/lib/legion/extensions/llm/canonical/message.rb +138 -0
- data/lib/legion/extensions/llm/canonical/params.rb +61 -0
- data/lib/legion/extensions/llm/canonical/request.rb +117 -0
- data/lib/legion/extensions/llm/canonical/response.rb +124 -0
- data/lib/legion/extensions/llm/canonical/thinking.rb +81 -0
- data/lib/legion/extensions/llm/canonical/tool_call.rb +134 -0
- data/lib/legion/extensions/llm/canonical/tool_definition.rb +98 -0
- data/lib/legion/extensions/llm/canonical/tool_schema.rb +46 -0
- data/lib/legion/extensions/llm/canonical/usage.rb +74 -0
- data/lib/legion/extensions/llm/canonical.rb +50 -0
- data/lib/legion/extensions/llm/chat.rb +3 -5
- data/lib/legion/extensions/llm/connection.rb +5 -1
- data/lib/legion/extensions/llm/error.rb +5 -7
- data/lib/legion/extensions/llm/fleet/envelope_validation.rb +1 -3
- data/lib/legion/extensions/llm/fleet/provider_responder.rb +1 -3
- data/lib/legion/extensions/llm/fleet/token_validator.rb +1 -3
- data/lib/legion/extensions/llm/model/info.rb +4 -6
- data/lib/legion/extensions/llm/models.rb +3 -3
- data/lib/legion/extensions/llm/provider/open_ai_compatible.rb +9 -4
- data/lib/legion/extensions/llm/provider.rb +21 -4
- data/lib/legion/extensions/llm/provider_contract.rb +10 -1
- data/lib/legion/extensions/llm/routing/lane_key.rb +1 -3
- data/lib/legion/extensions/llm/stream_accumulator.rb +40 -1
- data/lib/legion/extensions/llm/streaming.rb +13 -5
- data/lib/legion/extensions/llm/tool.rb +1 -3
- data/lib/legion/extensions/llm/version.rb +1 -1
- data/lib/legion/extensions/llm.rb +118 -35
- data/spec/fixtures/ruby.mp3 +0 -0
- data/spec/fixtures/ruby.mp4 +0 -0
- data/spec/fixtures/ruby.png +0 -0
- data/spec/fixtures/ruby.txt +1 -0
- data/spec/fixtures/ruby.wav +0 -0
- data/spec/fixtures/ruby.xml +1 -0
- data/spec/fixtures/sample.pdf +0 -0
- data/spec/legion/extensions/llm/agent_spec.rb +179 -0
- data/spec/legion/extensions/llm/attachment_spec.rb +25 -0
- data/spec/legion/extensions/llm/auto_registration_spec.rb +38 -0
- data/spec/legion/extensions/llm/canonical/chunk_spec.rb +285 -0
- data/spec/legion/extensions/llm/canonical/content_block_spec.rb +179 -0
- data/spec/legion/extensions/llm/canonical/message_spec.rb +203 -0
- data/spec/legion/extensions/llm/canonical/params_spec.rb +159 -0
- data/spec/legion/extensions/llm/canonical/request_spec.rb +174 -0
- data/spec/legion/extensions/llm/canonical/response_spec.rb +234 -0
- data/spec/legion/extensions/llm/canonical/thinking_spec.rb +151 -0
- data/spec/legion/extensions/llm/canonical/tool_call_spec.rb +191 -0
- data/spec/legion/extensions/llm/canonical/tool_definition_spec.rb +221 -0
- data/spec/legion/extensions/llm/canonical/tool_schema_spec.rb +83 -0
- data/spec/legion/extensions/llm/canonical/usage_spec.rb +178 -0
- data/spec/legion/extensions/llm/configuration_spec.rb +38 -0
- data/spec/legion/extensions/llm/conformance/client_translator_examples.rb +432 -0
- data/spec/legion/extensions/llm/conformance/conformance.rb +51 -0
- data/spec/legion/extensions/llm/conformance/echo_translator.rb +56 -0
- data/spec/legion/extensions/llm/conformance/echo_translator_spec.rb +13 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_empty_response.json +13 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_error_response.json +19 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_fleet_round_trip.json +81 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_metering_audit_events.json +101 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_params_mapping_request.json +21 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_server_tool_continuation_request.json +43 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_server_tool_use_response.json +29 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_simple_text_request.json +13 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_simple_text_response.json +13 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_stop_reason_matrix.json +36 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_accumulated_response.json +20 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_error_chunks.json +26 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_server_tool_chunks.json +52 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_text_chunks.json +33 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_thinking_chunks.json +42 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_streaming_tool_call_chunks.json +41 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_system_prompt_request.json +14 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_thinking_request.json +18 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_thinking_response.json +17 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_tool_results_continuation_request.json +75 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_tool_use_response.json +25 -0
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_tools_request.json +34 -0
- data/spec/legion/extensions/llm/conformance/provider_tool_rendering_examples.rb +77 -0
- data/spec/legion/extensions/llm/conformance/provider_translator_examples.rb +390 -0
- data/spec/legion/extensions/llm/connection_logging_spec.rb +53 -0
- data/spec/legion/extensions/llm/connection_retry_spec.rb +36 -0
- data/spec/legion/extensions/llm/context_spec.rb +127 -0
- data/spec/legion/extensions/llm/credential_sources_spec.rb +468 -0
- data/spec/legion/extensions/llm/error_middleware_spec.rb +102 -0
- data/spec/legion/extensions/llm/error_spec.rb +87 -0
- data/spec/legion/extensions/llm/fleet/provider_responder_spec.rb +120 -0
- data/spec/legion/extensions/llm/fleet/token_validator_spec.rb +163 -0
- data/spec/legion/extensions/llm/fleet/worker_execution_spec.rb +128 -0
- data/spec/legion/extensions/llm/fleet_messages_spec.rb +402 -0
- data/spec/legion/extensions/llm/gemspec_spec.rb +25 -0
- data/spec/legion/extensions/llm/message_spec.rb +64 -0
- data/spec/legion/extensions/llm/model/info_spec.rb +222 -0
- data/spec/legion/extensions/llm/models_spec.rb +104 -0
- data/spec/legion/extensions/llm/provider/open_ai_compatible_spec.rb +203 -0
- data/spec/legion/extensions/llm/provider/open_ai_compatible_tool_calls_array_spec.rb +68 -0
- data/spec/legion/extensions/llm/provider_contract_spec.rb +60 -0
- data/spec/legion/extensions/llm/provider_settings_spec.rb +76 -0
- data/spec/legion/extensions/llm/provider_spec.rb +613 -0
- data/spec/legion/extensions/llm/registry_event_builder_spec.rb +68 -0
- data/spec/legion/extensions/llm/registry_publisher_spec.rb +22 -0
- data/spec/legion/extensions/llm/responses/response_objects_spec.rb +75 -0
- data/spec/legion/extensions/llm/responses/thinking_extractor_spec.rb +75 -0
- data/spec/legion/extensions/llm/routing/model_offering_spec.rb +222 -0
- data/spec/legion/extensions/llm/routing/offering_registry_spec.rb +50 -0
- data/spec/legion/extensions/llm/routing/registry_event_spec.rb +120 -0
- data/spec/legion/extensions/llm/stream_accumulator_spec.rb +155 -0
- data/spec/legion/extensions/llm/streaming_spec.rb +108 -0
- data/spec/legion/extensions/llm/tool_spec.rb +94 -0
- data/spec/legion/extensions/llm/transport/fleet_lane_spec.rb +60 -0
- data/spec/legion/extensions/llm/utils_spec.rb +113 -0
- data/spec/legion/extensions/llm_base_contract_spec.rb +110 -0
- data/spec/legion/extensions/llm_extension_spec.rb +78 -0
- data/spec/legion/extensions/llm_root_spec.rb +51 -0
- data/spec/spec_helper.rb +24 -0
- data/spec/support/fake_llm_provider.rb +148 -0
- data/spec/support/llm_configuration.rb +21 -0
- data/spec/support/rspec_configuration.rb +19 -0
- data/spec/support/simplecov_configuration.rb +20 -0
- 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
|