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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +139 -0
- data/RULES.md +97 -0
- data/lib/legion/extensions/llm/auto_registration.rb +4 -13
- data/lib/legion/extensions/llm/canonical/chunk.rb +107 -94
- data/lib/legion/extensions/llm/canonical/content_block.rb +65 -75
- data/lib/legion/extensions/llm/canonical/message.rb +53 -69
- data/lib/legion/extensions/llm/canonical/params.rb +58 -34
- data/lib/legion/extensions/llm/canonical/request.rb +68 -56
- data/lib/legion/extensions/llm/canonical/response.rb +62 -78
- data/lib/legion/extensions/llm/canonical/strict.rb +105 -0
- data/lib/legion/extensions/llm/canonical/thinking.rb +36 -84
- data/lib/legion/extensions/llm/canonical/thinking_config.rb +149 -0
- data/lib/legion/extensions/llm/canonical/tool_call.rb +43 -53
- data/lib/legion/extensions/llm/canonical/tool_definition.rb +56 -46
- data/lib/legion/extensions/llm/canonical/tool_schema.rb +15 -22
- data/lib/legion/extensions/llm/canonical/usage.rb +47 -40
- data/lib/legion/extensions/llm/canonical.rb +7 -5
- data/lib/legion/extensions/llm/configuration.rb +40 -10
- data/lib/legion/extensions/llm/connection.rb +8 -30
- data/lib/legion/extensions/llm/credential_sources.rb +32 -49
- data/lib/legion/extensions/llm/discovery/actor.rb +92 -0
- data/lib/legion/extensions/llm/discovery/pipeline.rb +604 -0
- data/lib/legion/extensions/llm/error.rb +0 -14
- data/lib/legion/extensions/llm/fleet/contract_error.rb +15 -0
- data/lib/legion/extensions/llm/fleet/envelope_validation.rb +7 -6
- data/lib/legion/extensions/llm/fleet/fleet_envelope.rb +66 -0
- data/lib/legion/extensions/llm/fleet/protocol.rb +18 -5
- data/lib/legion/extensions/llm/fleet/provider_responder.rb +58 -156
- data/lib/legion/extensions/llm/fleet/token_validator.rb +15 -21
- data/lib/legion/extensions/llm/fleet/worker_execution.rb +107 -154
- data/lib/legion/extensions/llm/inventory/errors.rb +0 -1
- data/lib/legion/extensions/llm/inventory/evidence.rb +1 -1
- data/lib/legion/extensions/llm/inventory/identity.rb +55 -39
- data/lib/legion/extensions/llm/inventory/probe_token.rb +9 -12
- data/lib/legion/extensions/llm/inventory/publisher.rb +15 -53
- data/lib/legion/extensions/llm/inventory/records.rb +36 -100
- data/lib/legion/extensions/llm/inventory/registry.rb +54 -48
- data/lib/legion/extensions/llm/inventory/snapshot.rb +7 -21
- data/lib/legion/extensions/llm/inventory/weight_reconciler.rb +13 -6
- data/lib/legion/extensions/llm/inventory/weight_schema.rb +47 -8
- data/lib/legion/extensions/llm/provider/open_ai_compatible.rb +178 -182
- data/lib/legion/extensions/llm/provider.rb +192 -315
- data/lib/legion/extensions/llm/provider_contract.rb +25 -8
- data/lib/legion/extensions/llm/provider_settings.rb +5 -26
- data/lib/legion/extensions/llm/responses/thinking_extractor.rb +8 -1
- data/lib/legion/extensions/llm/responses/tool_arguments.rb +48 -0
- data/lib/legion/extensions/llm/routing/provider_outcome.rb +25 -0
- data/lib/legion/extensions/llm/routing/records.rb +34 -17
- data/lib/legion/extensions/llm/stream_accumulator.rb +186 -270
- data/lib/legion/extensions/llm/streaming.rb +50 -35
- data/lib/legion/extensions/llm/taxonomies.rb +14 -26
- data/lib/legion/extensions/llm/transport/fleet_lane.rb +8 -10
- data/lib/legion/extensions/llm/transport/messages/fleet_error.rb +3 -2
- data/lib/legion/extensions/llm/transport/messages/fleet_request.rb +6 -8
- data/lib/legion/extensions/llm/transport/messages/fleet_response.rb +10 -9
- data/lib/legion/extensions/llm/utils.rb +23 -5
- data/lib/legion/extensions/llm/version.rb +1 -1
- data/lib/legion/extensions/llm.rb +8 -98
- data/spec/legion/extensions/llm/auto_registration_spec.rb +4 -9
- data/spec/legion/extensions/llm/canonical/chunk_spec.rb +66 -252
- data/spec/legion/extensions/llm/canonical/content_block_spec.rb +52 -197
- data/spec/legion/extensions/llm/canonical/message_spec.rb +89 -204
- data/spec/legion/extensions/llm/canonical/params_spec.rb +55 -136
- data/spec/legion/extensions/llm/canonical/request_spec.rb +81 -143
- data/spec/legion/extensions/llm/canonical/response_spec.rb +68 -204
- data/spec/legion/extensions/llm/canonical/thinking/config_spec.rb +222 -0
- data/spec/legion/extensions/llm/canonical/thinking_spec.rb +23 -171
- data/spec/legion/extensions/llm/canonical/tool_call_spec.rb +59 -162
- data/spec/legion/extensions/llm/canonical/tool_definition_spec.rb +55 -191
- data/spec/legion/extensions/llm/canonical/tool_schema_spec.rb +26 -67
- data/spec/legion/extensions/llm/canonical/usage_spec.rb +46 -155
- data/spec/legion/extensions/llm/configuration_spec.rb +31 -5
- data/spec/legion/extensions/llm/conformance/canonical_type_examples.rb +106 -0
- data/spec/legion/extensions/llm/conformance/client_translator_examples.rb +1 -2
- data/spec/legion/extensions/llm/conformance/conformance.rb +10 -2
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_fleet_round_trip.json +1 -1
- data/spec/legion/extensions/llm/conformance/fixtures/canonical_thinking_request.json +2 -2
- data/spec/legion/extensions/llm/conformance/provider_translator_examples.rb +1 -1
- data/spec/legion/extensions/llm/conformance/ssot_contract_conformance_spec.rb +128 -0
- data/spec/legion/extensions/llm/conformance/ssot_contract_examples.rb +505 -0
- data/spec/legion/extensions/llm/conformance/ssot_provider_examples.rb +11 -10
- data/spec/legion/extensions/llm/credential_sources_spec.rb +12 -13
- data/spec/legion/extensions/llm/error_spec.rb +2 -12
- data/spec/legion/extensions/llm/fleet/exact_offering_spec.rb +59 -45
- data/spec/legion/extensions/llm/fleet/provider_responder_spec.rb +173 -95
- data/spec/legion/extensions/llm/fleet/token_validator_spec.rb +7 -2
- data/spec/legion/extensions/llm/fleet/worker_execution_spec.rb +93 -74
- data/spec/legion/extensions/llm/fleet_messages_spec.rb +119 -125
- data/spec/legion/extensions/llm/gemspec_spec.rb +1 -2
- data/spec/legion/extensions/llm/inventory/boot_spec.rb +4 -4
- data/spec/legion/extensions/llm/inventory/identity_spec.rb +127 -110
- data/spec/legion/extensions/llm/inventory/probe_token_spec.rb +4 -4
- data/spec/legion/extensions/llm/inventory/publisher_spec.rb +8 -61
- data/spec/legion/extensions/llm/inventory/records_spec.rb +82 -47
- data/spec/legion/extensions/llm/inventory/registry_activation_spec.rb +49 -16
- data/spec/legion/extensions/llm/inventory/registry_replacement_spec.rb +5 -4
- data/spec/legion/extensions/llm/inventory/snapshot_spec.rb +11 -5
- data/spec/legion/extensions/llm/inventory/weight_reconciler_spec.rb +7 -3
- data/spec/legion/extensions/llm/inventory/weight_schema_spec.rb +60 -10
- data/spec/legion/extensions/llm/provider/open_ai_compatible_spec.rb +128 -68
- data/spec/legion/extensions/llm/provider/open_ai_compatible_tool_calls_array_spec.rb +7 -31
- data/spec/legion/extensions/llm/provider_contract_spec.rb +10 -15
- data/spec/legion/extensions/llm/provider_spec.rb +97 -78
- data/spec/legion/extensions/llm/routing/records_spec.rb +38 -5
- data/spec/legion/extensions/llm/stream_accumulator_spec.rb +174 -144
- data/spec/legion/extensions/llm/streaming_spec.rb +27 -0
- data/spec/legion/extensions/llm/taxonomies_spec.rb +43 -43
- data/spec/legion/extensions/llm/transport/fleet_lane_spec.rb +1 -1
- data/spec/legion/extensions/llm/utils_spec.rb +26 -7
- data/spec/legion/extensions/llm_base_contract_spec.rb +55 -90
- data/spec/legion/extensions/llm_extension_spec.rb +5 -5
- data/spec/support/fake_llm_provider.rb +45 -39
- data/spec/support/fake_ssot_harness.rb +7 -2
- metadata +13 -54
- data/lib/legion/extensions/llm/agent.rb +0 -366
- data/lib/legion/extensions/llm/aliases.json +0 -436
- data/lib/legion/extensions/llm/aliases.rb +0 -67
- data/lib/legion/extensions/llm/attachment.rb +0 -229
- data/lib/legion/extensions/llm/chat.rb +0 -354
- data/lib/legion/extensions/llm/chunk.rb +0 -10
- data/lib/legion/extensions/llm/content.rb +0 -81
- data/lib/legion/extensions/llm/context.rb +0 -33
- data/lib/legion/extensions/llm/embedding.rb +0 -33
- data/lib/legion/extensions/llm/image.rb +0 -109
- data/lib/legion/extensions/llm/inventory/capabilities.rb +0 -40
- data/lib/legion/extensions/llm/inventory/scoped_refresher.rb +0 -310
- data/lib/legion/extensions/llm/message.rb +0 -118
- data/lib/legion/extensions/llm/mime_type.rb +0 -75
- data/lib/legion/extensions/llm/model/info.rb +0 -286
- data/lib/legion/extensions/llm/model/modalities.rb +0 -26
- data/lib/legion/extensions/llm/model/pricing.rb +0 -52
- data/lib/legion/extensions/llm/model/pricing_category.rb +0 -50
- data/lib/legion/extensions/llm/model/pricing_tier.rb +0 -37
- data/lib/legion/extensions/llm/model.rb +0 -11
- data/lib/legion/extensions/llm/models.json +0 -57313
- data/lib/legion/extensions/llm/models.rb +0 -530
- data/lib/legion/extensions/llm/models_schema.json +0 -168
- data/lib/legion/extensions/llm/moderation.rb +0 -60
- data/lib/legion/extensions/llm/registry_event_builder.rb +0 -141
- data/lib/legion/extensions/llm/registry_publisher.rb +0 -107
- data/lib/legion/extensions/llm/responses/chat_response.rb +0 -43
- data/lib/legion/extensions/llm/responses/embedding_response.rb +0 -38
- data/lib/legion/extensions/llm/responses/stream_chunk.rb +0 -43
- data/lib/legion/extensions/llm/routing/lane_key.rb +0 -66
- data/lib/legion/extensions/llm/routing/model_offering.rb +0 -241
- data/lib/legion/extensions/llm/routing/offering_registry.rb +0 -101
- data/lib/legion/extensions/llm/routing/registry_event.rb +0 -167
- data/lib/legion/extensions/llm/thinking.rb +0 -53
- data/lib/legion/extensions/llm/tokens.rb +0 -51
- data/lib/legion/extensions/llm/tool_call.rb +0 -34
- data/lib/legion/extensions/llm/transcription.rb +0 -39
- data/lib/legion/extensions/llm/transport/messages/registry_event.rb +0 -44
- data/spec/legion/extensions/llm/agent_spec.rb +0 -179
- data/spec/legion/extensions/llm/attachment_spec.rb +0 -25
- data/spec/legion/extensions/llm/conformance/fixtures/ssot_identity_vectors.json +0 -84
- data/spec/legion/extensions/llm/context_spec.rb +0 -127
- data/spec/legion/extensions/llm/inventory/capabilities_spec.rb +0 -43
- data/spec/legion/extensions/llm/inventory/scoped_refresher_spec.rb +0 -340
- data/spec/legion/extensions/llm/message_spec.rb +0 -64
- data/spec/legion/extensions/llm/model/info_spec.rb +0 -222
- data/spec/legion/extensions/llm/models_spec.rb +0 -104
- data/spec/legion/extensions/llm/registry_event_builder_spec.rb +0 -68
- data/spec/legion/extensions/llm/registry_publisher_spec.rb +0 -22
- data/spec/legion/extensions/llm/responses/response_objects_spec.rb +0 -75
- data/spec/legion/extensions/llm/routing/model_offering_spec.rb +0 -281
- data/spec/legion/extensions/llm/routing/offering_registry_spec.rb +0 -50
- 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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
expect
|
|
62
|
-
|
|
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 '
|
|
66
|
-
|
|
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 '
|
|
119
|
-
it '
|
|
120
|
-
|
|
121
|
-
expect(
|
|
122
|
-
expect(
|
|
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 '
|
|
126
|
-
|
|
127
|
-
expect(
|
|
128
|
-
|
|
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 '
|
|
133
|
-
it '
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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 '
|
|
161
|
-
it '
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
expect(
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
expect
|
|
187
|
-
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 '
|
|
93
|
-
it '
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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 '
|
|
126
|
-
it '
|
|
127
|
-
expect(described_class.
|
|
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 '
|
|
131
|
-
|
|
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 '
|
|
135
|
-
expect(described_class.
|
|
136
|
-
|
|
137
|
-
|
|
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 '
|
|
164
|
-
it '
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
expect(
|
|
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 '
|
|
173
|
-
|
|
174
|
-
|
|
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 '
|
|
178
|
-
expect(described_class.
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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(:
|
|
7
|
-
let(:canonical_tool) do
|
|
6
|
+
let(:tool_definition) do
|
|
8
7
|
Legion::Extensions::Llm::Canonical::ToolDefinition.build(
|
|
9
|
-
name: 'get_weather',
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
|
75
|
-
it '
|
|
76
|
-
expect(
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
expect(
|
|
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
|