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
@@ -23,29 +23,125 @@ require 'marcel'
23
23
  require 'ruby_llm/schema'
24
24
  require 'securerandom'
25
25
  require 'time'
26
- require 'zeitwerk'
27
26
  require_relative 'llm/version'
28
27
 
29
28
  module Legion
30
29
  module Extensions
31
30
  # Legion-native namespace for the shared LLM provider framework.
32
31
  module Llm
33
- loader = Zeitwerk::Loader.new
34
- loader.tag = 'lex-llm'
35
- loader.inflector.inflect(
36
- 'api' => 'API',
37
- 'llm' => 'Llm',
38
- 'open_ai_compatible' => 'OpenAICompatible',
39
- 'pdf' => 'PDF',
40
- 'ui' => 'UI'
41
- )
42
- loader.ignore("#{__dir__}/llm/version.rb")
43
- loader.ignore("#{__dir__}/llm/auto_registration.rb")
44
- loader.ignore("#{__dir__}/llm/credential_sources.rb")
45
- loader.ignore("#{__dir__}/llm/transport/exchanges")
46
- loader.ignore("#{__dir__}/llm/transport/messages")
47
- loader.push_dir("#{__dir__}/llm", namespace: self)
48
- loader.setup
32
+ # ------------------------------------------------------------------ #
33
+ # Explicit requires (replaces Zeitwerk autoloading). #
34
+ # Load order: base classes & canonical types first, then anything #
35
+ # that references them. All live under Legion::Extensions::Llm so #
36
+ # unqualified constant lookups resolve via Ruby scope. #
37
+ # ------------------------------------------------------------------ #
38
+
39
+ # --- Base value objects (no internal deps) ---
40
+ require_relative 'llm/mime_type'
41
+ require_relative 'llm/model/info'
42
+ require_relative 'llm/model/modalities'
43
+ require_relative 'llm/model/pricing_category'
44
+ require_relative 'llm/model/pricing_tier'
45
+ require_relative 'llm/model/pricing'
46
+ require_relative 'llm/configuration'
47
+ require_relative 'llm/thinking'
48
+ require_relative 'llm/tokens'
49
+ require_relative 'llm/message'
50
+ require_relative 'llm/tool_call'
51
+ require_relative 'llm/content'
52
+ require_relative 'llm/errors/unsupported_capability'
53
+ require_relative 'llm/error'
54
+
55
+ # --- Build on message/base types ---
56
+ require_relative 'llm/chunk'
57
+ require_relative 'llm/model'
58
+ require_relative 'llm/attachment'
59
+
60
+ # --- Streaming fundamentals (must load before streaming/provider) ---
61
+ require_relative 'llm/stream_accumulator'
62
+ require_relative 'llm/responses/stream_chunk'
63
+ require_relative 'llm/streaming'
64
+
65
+ # --- Context, Connection ---
66
+ require_relative 'llm/context'
67
+ require_relative 'llm/connection'
68
+
69
+ # --- Response normalizers ---
70
+ require_relative 'llm/responses/chat_response'
71
+ require_relative 'llm/responses/embedding_response'
72
+ require_relative 'llm/responses/thinking_extractor'
73
+
74
+ # --- Provider base & allied modules ---
75
+ require_relative 'llm/provider_contract'
76
+ require_relative 'llm/provider_settings'
77
+ require_relative 'llm/provider'
78
+
79
+ # --- Provider subtypes ---
80
+ require_relative 'llm/provider/open_ai_compatible'
81
+
82
+ # --- Routing ---
83
+ require_relative 'llm/routing'
84
+ require_relative 'llm/routing/lane_key'
85
+ require_relative 'llm/routing/offering_registry'
86
+ require_relative 'llm/routing/registry_event'
87
+ require_relative 'llm/routing/model_offering'
88
+
89
+ # --- Models (scans for Provider subclasses) ---
90
+ require_relative 'llm/models'
91
+
92
+ # --- Agent & Chat (reference Provider, Context, Chat at method-time) ---
93
+ require_relative 'llm/agent'
94
+ require_relative 'llm/chat'
95
+
96
+ # --- Domain services ---
97
+ require_relative 'llm/embedding'
98
+ require_relative 'llm/moderation'
99
+ require_relative 'llm/image'
100
+ require_relative 'llm/transcription'
101
+
102
+ # --- Registry & misc support ---
103
+ require_relative 'llm/registry_event_builder'
104
+ require_relative 'llm/registry_publisher'
105
+ require_relative 'llm/auto_registration'
106
+ require_relative 'llm/credential_sources'
107
+ require_relative 'llm/tool'
108
+ require_relative 'llm/utils'
109
+ require_relative 'llm/aliases'
110
+
111
+ # --- Fleet protocol (depends on Provider, Models) ---
112
+ require_relative 'llm/fleet/protocol'
113
+ require_relative 'llm/fleet/settings'
114
+ require_relative 'llm/fleet/token_error'
115
+ require_relative 'llm/fleet/envelope_validation'
116
+ require_relative 'llm/fleet/publish_safety'
117
+ require_relative 'llm/fleet/default_exchange_reply'
118
+ require_relative 'llm/fleet/token_validator'
119
+ require_relative 'llm/fleet/worker_execution'
120
+ require_relative 'llm/fleet/provider_responder'
121
+
122
+ # --- Transport lane (references Fleet exchange/message autoloads) ---
123
+ require_relative 'llm/transport/fleet_lane'
124
+
125
+ # --- Canonical types — explicit self-contained loader ---
126
+ require_relative 'llm/canonical'
127
+
128
+ # --- Transport modules (lazy — depend on optional legion-transport) ---
129
+ # These remain as autoload so boot-time does not force legion-transport.
130
+ module Transport
131
+ # Shared AMQP exchange definitions for fleet routing.
132
+ # Lazy-loaded; only instantiated when legion-transport is available.
133
+ module Exchanges
134
+ autoload :Fleet, File.expand_path('llm/transport/exchanges/fleet', __dir__)
135
+ end
136
+
137
+ # Shared AMQP message envelopes for fleet request/response cycles.
138
+ # Lazy-loaded; only instantiated when legion-transport is available.
139
+ module Messages
140
+ autoload :FleetRequest, File.expand_path('llm/transport/messages/fleet_request', __dir__)
141
+ autoload :FleetResponse, File.expand_path('llm/transport/messages/fleet_response', __dir__)
142
+ autoload :FleetError, File.expand_path('llm/transport/messages/fleet_error', __dir__)
143
+ end
144
+ end
49
145
 
50
146
  Schema = ::RubyLLM::Schema unless const_defined?(:Schema, false)
51
147
 
@@ -148,6 +244,11 @@ module Legion
148
244
  require_policy: false,
149
245
  require_idempotency: true,
150
246
  idempotency_ttl_seconds: 600
247
+ },
248
+ request: {
249
+ logger: {
250
+ request_payload: false
251
+ }
151
252
  }
152
253
  }
153
254
  }
@@ -156,24 +257,6 @@ module Legion
156
257
  def self.provider_settings(...)
157
258
  ProviderSettings.build(...)
158
259
  end
159
-
160
- require_relative 'llm/auto_registration'
161
- require_relative 'llm/credential_sources'
162
- loader.eager_load
163
-
164
- module Transport
165
- # Local autoloads for fleet exchange classes that depend on legion-transport.
166
- module Exchanges
167
- autoload :Fleet, File.expand_path('llm/transport/exchanges/fleet', __dir__)
168
- end
169
-
170
- # Local autoloads for fleet message classes that depend on legion-transport.
171
- module Messages
172
- autoload :FleetRequest, File.expand_path('llm/transport/messages/fleet_request', __dir__)
173
- autoload :FleetResponse, File.expand_path('llm/transport/messages/fleet_response', __dir__)
174
- autoload :FleetError, File.expand_path('llm/transport/messages/fleet_error', __dir__)
175
- end
176
- end
177
260
  end
178
261
  end
179
262
  end
Binary file
Binary file
Binary file
@@ -0,0 +1 @@
1
+ Ruby is the best.
Binary file
@@ -0,0 +1 @@
1
+ <truism>Ruby is the best</truism>
Binary file
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe Legion::Extensions::Llm::Agent do
6
+ include_context 'with configured Legion::Extensions::Llm'
7
+ include_context 'with fake llm provider'
8
+
9
+ it 'builds a configured plain chat via .chat with runtime inputs' do
10
+ tool_class = Class.new(Legion::Extensions::Llm::Tool) do
11
+ def name = 'echo_tool'
12
+ end
13
+
14
+ agent_class = Class.new(Legion::Extensions::Llm::Agent) do
15
+ model 'fake-chat-model', provider: :fake_llm, assume_model_exists: true
16
+ inputs :display_name
17
+ instructions { "Hello #{display_name}" }
18
+ tools { [tool_class.new] }
19
+ params { { max_tokens: 12 } }
20
+ end
21
+
22
+ chat = agent_class.chat(display_name: 'Ava')
23
+
24
+ expect(chat.messages.first.role).to eq(:system)
25
+ expect(chat.messages.first.content).to eq('Hello Ava')
26
+ expect(chat.tools.keys).to include(:echo_tool)
27
+ expect(chat.params).to eq(max_tokens: 12)
28
+ end
29
+
30
+ it 'exposes Legion::Extensions::Llm::Chat as chat in execution context for .chat' do
31
+ agent_class = Class.new(Legion::Extensions::Llm::Agent) do
32
+ model 'fake-chat-model', provider: :fake_llm, assume_model_exists: true
33
+ instructions { chat.class.name }
34
+ end
35
+
36
+ chat = agent_class.chat
37
+ expect(chat.messages.first.content).to eq('Legion::Extensions::Llm::Chat')
38
+ end
39
+
40
+ it 'raises when instructions default prompt is missing' do
41
+ agent_class = Class.new(Legion::Extensions::Llm::Agent) do
42
+ model 'fake-chat-model', provider: :fake_llm, assume_model_exists: true
43
+ instructions
44
+ end
45
+
46
+ expect { agent_class.chat }.to raise_error(Legion::Extensions::Llm::PromptNotFoundError, /Prompt file not found/)
47
+ end
48
+
49
+ it 'supports inline schema DSL via schema do ... end' do
50
+ agent_class = Class.new(Legion::Extensions::Llm::Agent) do
51
+ model 'fake-chat-model', provider: :fake_llm, assume_model_exists: true
52
+ schema do
53
+ string :verdict, enum: %w[pass revise]
54
+ string :feedback
55
+ end
56
+ end
57
+
58
+ chat = agent_class.chat
59
+
60
+ expect(chat.schema).to include(name: 'Schema', strict: true, schema: include(type: 'object'))
61
+ expect(chat.schema.dig(:schema, :properties)).to include(
62
+ verdict: include(type: 'string'),
63
+ feedback: include(type: 'string')
64
+ )
65
+ end
66
+
67
+ it 'supports runtime-evaluated schema blocks that return a schema value' do
68
+ agent_class = Class.new(Legion::Extensions::Llm::Agent) do
69
+ model 'fake-chat-model', provider: :fake_llm, assume_model_exists: true
70
+ inputs :strict
71
+
72
+ schema do
73
+ if strict
74
+ {
75
+ type: 'object',
76
+ properties: { answer: { type: 'string' } },
77
+ required: ['answer'],
78
+ additionalProperties: false
79
+ }
80
+ end
81
+ end
82
+ end
83
+
84
+ strict_chat = agent_class.chat(strict: true)
85
+ loose_chat = agent_class.chat(strict: false)
86
+
87
+ expect(strict_chat.schema).to include(name: 'response', strict: true, schema: include(type: 'object'))
88
+ expect(loose_chat.schema).to be_nil
89
+ end
90
+
91
+ it 'can ask using a registered provider' do
92
+ agent_class = Class.new(Legion::Extensions::Llm::Agent) do
93
+ model 'fake-chat-model', provider: :fake_llm, assume_model_exists: true
94
+ instructions 'Answer questions clearly.'
95
+ end
96
+
97
+ stub_const('SpecChatAgent', agent_class)
98
+
99
+ response = SpecChatAgent.new.ask('hello')
100
+ expect(response.content).to include('fake response to hello')
101
+ expect(response.role).to eq(:assistant)
102
+ end
103
+
104
+ it 'delegates add_message to the underlying chat interface' do
105
+ agent_class = Class.new(Legion::Extensions::Llm::Agent) do
106
+ model 'fake-chat-model', provider: :fake_llm, assume_model_exists: true
107
+ end
108
+
109
+ agent = agent_class.new
110
+ message = agent.add_message(role: :user, content: 'Hello')
111
+
112
+ expect(message.role).to eq(:user)
113
+ expect(message.content).to eq('Hello')
114
+ expect(agent.chat.messages.last).to eq(message)
115
+ end
116
+
117
+ it 'exposes messages like Legion::Extensions::Llm::Chat' do
118
+ agent_class = Class.new(Legion::Extensions::Llm::Agent) do
119
+ model 'fake-chat-model', provider: :fake_llm, assume_model_exists: true
120
+ end
121
+
122
+ agent = agent_class.new
123
+ agent.add_message(role: :user, content: 'First')
124
+
125
+ expect(agent.messages).to eq(agent.chat.messages)
126
+ expect(agent.messages.last.content).to eq('First')
127
+ end
128
+
129
+ it 'delegates callback hooks to the underlying chat' do
130
+ fake_chat = Class.new do
131
+ attr_reader :events
132
+
133
+ def initialize
134
+ @events = []
135
+ end
136
+
137
+ def on_new_message(&)
138
+ @events << :new_message
139
+ self
140
+ end
141
+
142
+ def on_end_message(&)
143
+ @events << :end_message
144
+ self
145
+ end
146
+
147
+ def on_tool_call(&)
148
+ @events << :tool_call
149
+ self
150
+ end
151
+
152
+ def on_tool_result(&)
153
+ @events << :tool_result
154
+ self
155
+ end
156
+ end.new
157
+
158
+ agent = Class.new(described_class).new(chat: fake_chat)
159
+
160
+ expect(agent.on_new_message { :ok }).to eq(fake_chat)
161
+ expect(agent.on_end_message { :ok }).to eq(fake_chat)
162
+ expect(agent.on_tool_call { :ok }).to eq(fake_chat)
163
+ expect(agent.on_tool_result { :ok }).to eq(fake_chat)
164
+ expect(fake_chat.events).to eq(%i[new_message end_message tool_call tool_result])
165
+ end
166
+
167
+ it 'supports Enumerable by delegating each to chat' do
168
+ fake_chat = Class.new do
169
+ def each(&)
170
+ return enum_for(:each) unless block_given?
171
+
172
+ %w[first second].each(&)
173
+ end
174
+ end.new
175
+
176
+ agent = Class.new(described_class).new(chat: fake_chat)
177
+ expect(agent.map(&:upcase)).to eq(%w[FIRST SECOND])
178
+ end
179
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'open3'
5
+ require 'rbconfig'
6
+
7
+ RSpec.describe Legion::Extensions::Llm::Attachment do
8
+ it 'supports path attachments from the public API' do
9
+ script = <<~'RUBY'
10
+ require 'legion/extensions/llm'
11
+
12
+ content = Legion::Extensions::Llm::Content.new('What is in this file?', 'spec/fixtures/ruby.txt')
13
+ attachment = content.attachments.first
14
+ puts "#{attachment.filename},#{attachment.mime_type}"
15
+ RUBY
16
+
17
+ stdout, stderr, status = Open3.capture3(
18
+ RbConfig.ruby, '-Ilib', '-e', script,
19
+ chdir: File.expand_path('../../../..', __dir__)
20
+ )
21
+
22
+ expect(status.success?).to be(true), stderr
23
+ expect(stdout.strip).to eq('ruby.txt,text/plain')
24
+ end
25
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe Legion::Extensions::Llm::AutoRegistration do
6
+ # Build a fake provider module that extends AutoRegistration,
7
+ # mimicking what a real lex-llm-* provider would look like.
8
+ let(:fake_provider_class) { Class.new }
9
+
10
+ let(:provider_module) do
11
+ klass = fake_provider_class
12
+ mod = Module.new do
13
+ extend Legion::Extensions::Llm::AutoRegistration
14
+
15
+ const_set(:PROVIDER_FAMILY, :fake_provider)
16
+
17
+ define_singleton_method(:provider_class) { klass }
18
+ end
19
+ mod
20
+ end
21
+
22
+ describe '#discover_instances' do
23
+ it 'returns an empty hash by default' do
24
+ expect(provider_module.discover_instances).to eq({})
25
+ end
26
+ end
27
+
28
+ describe '#provider_aliases' do
29
+ it 'returns an empty alias list by default' do
30
+ expect(provider_module.provider_aliases).to eq([])
31
+ end
32
+ end
33
+
34
+ it 'does not expose legion-llm registry mutation hooks' do
35
+ expect(provider_module).not_to respond_to(:register_discovered_instances)
36
+ expect(provider_module).not_to respond_to(:rediscover!)
37
+ end
38
+ end