legionio 1.6.21 → 1.6.24
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 +37 -0
- data/lib/legion/api/lex_dispatch.rb +234 -0
- data/lib/legion/api/library_routes.rb +18 -0
- data/lib/legion/api/llm.rb +72 -1
- data/lib/legion/api/router.rb +98 -0
- data/lib/legion/api/sync_dispatch.rb +102 -0
- data/lib/legion/api.rb +42 -6
- data/lib/legion/cli/admin/purge_topology.rb +149 -0
- data/lib/legion/cli/chat/daemon_chat.rb +220 -0
- data/lib/legion/cli/chat_command.rb +14 -7
- data/lib/legion/cli/generate_command.rb +2 -2
- data/lib/legion/extensions/absorbers/base.rb +9 -2
- data/lib/legion/extensions/actors/absorber_dispatch.rb +1 -1
- data/lib/legion/extensions/actors/base.rb +23 -5
- data/lib/legion/extensions/actors/dsl.rb +29 -0
- data/lib/legion/extensions/actors/every.rb +7 -9
- data/lib/legion/extensions/actors/once.rb +4 -0
- data/lib/legion/extensions/actors/poll.rb +8 -13
- data/lib/legion/extensions/actors/subscription.rb +9 -18
- data/lib/legion/extensions/builders/hooks.rb +19 -2
- data/lib/legion/extensions/builders/routes.rb +20 -5
- data/lib/legion/extensions/builders/runners.rb +2 -0
- data/lib/legion/extensions/core.rb +50 -0
- data/lib/legion/extensions/definitions.rb +48 -0
- data/lib/legion/extensions/helpers/lex.rb +12 -0
- data/lib/legion/extensions/helpers/segments.rb +1 -1
- data/lib/legion/extensions/hooks/base.rb +5 -6
- data/lib/legion/extensions/transport.rb +1 -1
- data/lib/legion/version.rb +1 -1
- metadata +9 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 02c65a88cb7cdf1a90b60cc3fe150983d9f3aa7b465344ab63a2e9128327b943
|
|
4
|
+
data.tar.gz: ff0aa83e8b72dc3452b0d11f08006ddd3cee4d831f99213da2bb65f153f17963
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7b6b41d1f383380ef4c7d6a01b6ca4bc6eb595afc7a3674544d25afb6d88a6fae37f1fefd94542a1bc801e40d18c966b3de8832fea5b6c08c410125a7e08f45f
|
|
7
|
+
data.tar.gz: 6dc2552ec0839432ab21c3664973c78b9ae9c392749758fb8422a35637c5cf9c7c3bf3219594d7b5c82e474e0e6cbe2e286f059de4429aabee660bbe94d058bb
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,43 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [1.6.24] - 2026-03-28
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- `Legion::API.register_library_routes(gem_name, routes_module)` class method: library gems self-register their Sinatra route modules at boot via `router.register_library` + Sinatra `register`. Implemented in `lib/legion/api/library_routes.rb`.
|
|
9
|
+
- `Legion::API::SyncDispatch.dispatch(exchange_name, routing_key, payload, envelope, timeout:)`: synchronous AMQP dispatch using a temporary exclusive reply_to queue with configurable timeout (default 30s). Implemented in `lib/legion/api/sync_dispatch.rb`.
|
|
10
|
+
- Remote dispatch in `LexDispatch`: when a registered extension route's runner class is not loaded in the current process, the request is forwarded via AMQP — async (202) by default or sync (blocks on reply queue) when `X-Legion-Sync: true` header is present. Returns 403 when `definition[:remote_invocable] == false`.
|
|
11
|
+
- `Routes::Llm` and `Routes::Apollo` registration now guarded: skipped in `api.rb` when `Legion::LLM::Routes` / `Legion::Apollo::Routes` are already defined (i.e. self-registered by the library gem).
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
- `api.rb`: requires `api/library_routes` and `api/sync_dispatch`; LLM and Apollo route registration conditional on gem self-registration not already having run.
|
|
15
|
+
|
|
16
|
+
## [1.6.23] - 2026-03-28
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
- `Legion::Extensions::Definitions` mixin: class-level `definition` DSL for method contracts (`desc`, `inputs`, `outputs`, `remote_invocable`, `mcp_exposed`, `idempotent`, `risk_tier`, `tags`, `requires`). Auto-extended onto every runner module at boot by the builder.
|
|
20
|
+
- `Legion::Extensions::Actors::Dsl` mixin: `define_dsl_accessor` generates class-level getter/setter DSL with inheritance and instance delegation. Wired into all actor base classes (`Every`, `Poll`, `Subscription`, `Once`, `Base`).
|
|
21
|
+
- `Absorbers::Base#absorb`: canonical entry point replacing `handle`. `alias handle absorb` preserves backward compatibility.
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
- `Builders::Runners#build_runner_list`: auto-extends `Legion::Extensions::Definitions` onto every discovered runner module unless it already responds to `:definition`.
|
|
25
|
+
- `Hooks::Base`: extended with `Definitions` mixin; `mount` DSL removed (paths fully derived from naming).
|
|
26
|
+
- `Absorbers::Base`: extended with `Definitions` mixin.
|
|
27
|
+
- `AbsorberDispatch`: calls `absorber.absorb` instead of `absorber.handle`.
|
|
28
|
+
- `Helpers::Lex`: all `function_*` helpers and `expose_as_mcp_tool`/`mcp_tool_prefix` marked `@deprecated` — use `definition` DSL instead.
|
|
29
|
+
|
|
30
|
+
## [1.6.22] - 2026-03-27
|
|
31
|
+
|
|
32
|
+
### Added
|
|
33
|
+
- `POST /api/llm/inference` daemon endpoint: accepts a full messages array plus optional tool schemas, runs a single LLM completion pass, and returns `{ content, tool_calls, stop_reason, model, input_tokens, output_tokens }` — the client owns the tool execution loop
|
|
34
|
+
- `Legion::CLI::Chat::DaemonChat` adapter: drop-in replacement for the `RubyLLM::Chat` object that routes all inference through the daemon, executes tool calls locally, and loops until the LLM produces a final text response
|
|
35
|
+
- `spec/legion/api/llm_inference_spec.rb`: 12 examples covering the new `/api/llm/inference` endpoint
|
|
36
|
+
- `spec/legion/cli/chat/daemon_chat_spec.rb`: 25 examples covering `DaemonChat` initialization, tool registration, tool execution loop, streaming, and error handling
|
|
37
|
+
|
|
38
|
+
### Changed
|
|
39
|
+
- `legion chat setup_connection`: replaced `Connection.ensure_llm` (local LLM boot) with a daemon availability check via `Legion::LLM::DaemonClient.available?` — **hard fails with a descriptive error if the daemon is not running**
|
|
40
|
+
- `legion chat create_chat`: now returns a `DaemonChat` instance instead of a direct `RubyLLM::Chat` object; all LLM calls route through the daemon
|
|
41
|
+
|
|
5
42
|
## [1.6.21] - 2026-03-27
|
|
6
43
|
|
|
7
44
|
### Added
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
class API < Sinatra::Base
|
|
7
|
+
module Routes
|
|
8
|
+
module LexDispatch
|
|
9
|
+
def self.registered(app)
|
|
10
|
+
register_discovery(app)
|
|
11
|
+
register_dispatch(app)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Discovery endpoints (GET)
|
|
15
|
+
def self.register_discovery(app)
|
|
16
|
+
# GET /api/extensions/index — list all extensions
|
|
17
|
+
app.get '/api/extensions/index' do
|
|
18
|
+
content_type :json
|
|
19
|
+
names = Legion::API.router.extension_names
|
|
20
|
+
Legion::JSON.dump({ extensions: names })
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# GET /api/extensions/:lex_name/:component_type/:component_name/:method_name — full contract
|
|
24
|
+
app.get '/api/extensions/:lex_name/:component_type/:component_name/:method_name' do
|
|
25
|
+
content_type :json
|
|
26
|
+
entry = Legion::API.router.find_extension_route(
|
|
27
|
+
params[:lex_name], params[:component_type],
|
|
28
|
+
params[:component_name], params[:method_name]
|
|
29
|
+
)
|
|
30
|
+
unless entry
|
|
31
|
+
halt 404, Legion::JSON.dump({
|
|
32
|
+
task_id: nil,
|
|
33
|
+
conversation_id: nil,
|
|
34
|
+
status: 'failed',
|
|
35
|
+
error: { code: 404, message: 'route not found' }
|
|
36
|
+
})
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
amqp_pfx = entry[:amqp_prefix].to_s.then { |p| p.empty? ? "lex.#{params[:lex_name]}" : p }
|
|
40
|
+
response = {
|
|
41
|
+
extension: params[:lex_name],
|
|
42
|
+
component_type: params[:component_type],
|
|
43
|
+
component: params[:component_name],
|
|
44
|
+
method: params[:method_name],
|
|
45
|
+
definition: entry[:definition],
|
|
46
|
+
amqp: {
|
|
47
|
+
exchange: amqp_pfx,
|
|
48
|
+
routing_key: "#{amqp_pfx}.#{params[:component_type]}.#{params[:component_name]}.#{params[:method_name]}"
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if params[:component_type] == 'hooks'
|
|
52
|
+
response[:hook_endpoint] =
|
|
53
|
+
"/api/extensions/#{params[:lex_name]}/hooks/#{params[:component_name]}/#{params[:method_name]}"
|
|
54
|
+
end
|
|
55
|
+
Legion::JSON.dump(response)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Dispatch endpoint (POST)
|
|
60
|
+
def self.register_dispatch(app)
|
|
61
|
+
dispatcher = method(:dispatch_request)
|
|
62
|
+
app.post '/api/extensions/:lex_name/:component_type/:component_name/:method_name' do
|
|
63
|
+
dispatcher.call(self, request, params)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def self.dispatch_request(context, request, params) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
|
|
68
|
+
content_type = 'application/json'
|
|
69
|
+
context.content_type content_type
|
|
70
|
+
|
|
71
|
+
entry = Legion::API.router.find_extension_route(
|
|
72
|
+
params[:lex_name], params[:component_type],
|
|
73
|
+
params[:component_name], params[:method_name]
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
unless entry
|
|
77
|
+
route_key = "#{params[:lex_name]}/#{params[:component_type]}/#{params[:component_name]}/#{params[:method_name]}"
|
|
78
|
+
context.halt 404, Legion::JSON.dump({
|
|
79
|
+
task_id: nil,
|
|
80
|
+
conversation_id: nil,
|
|
81
|
+
status: 'failed',
|
|
82
|
+
error: { code: 404, message: "no route registered for '#{route_key}'" }
|
|
83
|
+
})
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
envelope = build_envelope(request)
|
|
87
|
+
|
|
88
|
+
payload = begin
|
|
89
|
+
body = request.body.read
|
|
90
|
+
body.nil? || body.empty? ? {} : Legion::JSON.load(body)
|
|
91
|
+
rescue StandardError => e
|
|
92
|
+
Legion::Logging.warn "[LexDispatch] invalid JSON body: #{e.message}" if defined?(Legion::Logging)
|
|
93
|
+
context.halt 400, Legion::JSON.dump({
|
|
94
|
+
task_id: nil,
|
|
95
|
+
conversation_id: nil,
|
|
96
|
+
status: 'failed',
|
|
97
|
+
error: { code: 400, message: 'request body is not valid JSON' }
|
|
98
|
+
})
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Remote dispatch: when the runner class is not loaded locally, forward via AMQP
|
|
102
|
+
unless extension_loaded_locally?(entry)
|
|
103
|
+
if definition_blocks_remote?(entry)
|
|
104
|
+
context.halt 403, Legion::JSON.dump({
|
|
105
|
+
task_id: nil,
|
|
106
|
+
conversation_id: nil,
|
|
107
|
+
status: 'failed',
|
|
108
|
+
error: { code: 403, message: 'Method not remotely invocable' }
|
|
109
|
+
})
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
exchange_name = entry[:amqp_prefix].to_s.then { |p| p.empty? ? "lex.#{entry[:lex_name]}" : p }
|
|
113
|
+
routing_key = "#{exchange_name}.#{entry[:component_type]}.#{entry[:component_name]}.#{entry[:method_name]}"
|
|
114
|
+
|
|
115
|
+
if request.env['HTTP_X_LEGION_SYNC'] == 'true'
|
|
116
|
+
result = Legion::API::SyncDispatch.dispatch(exchange_name, routing_key, payload, envelope)
|
|
117
|
+
return Legion::JSON.dump(result)
|
|
118
|
+
else
|
|
119
|
+
unless defined?(Legion::Transport) &&
|
|
120
|
+
Legion::Transport.respond_to?(:connected?) &&
|
|
121
|
+
Legion::Transport.connected?
|
|
122
|
+
context.halt 503, Legion::JSON.dump({
|
|
123
|
+
task_id: nil,
|
|
124
|
+
conversation_id: nil,
|
|
125
|
+
status: 'failed',
|
|
126
|
+
error: { code: 503, message: 'Transport not available' }
|
|
127
|
+
})
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
dispatch_async_amqp(exchange_name, routing_key, payload, envelope)
|
|
131
|
+
context.status 202
|
|
132
|
+
return Legion::JSON.dump(envelope.merge(status: 'queued'))
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
result = Legion::Ingress.run(
|
|
137
|
+
payload: payload.merge(envelope.slice(:task_id, :conversation_id, :parent_id, :master_id, :chain_id)),
|
|
138
|
+
runner_class: entry[:runner_class],
|
|
139
|
+
function: entry[:method_name].to_sym,
|
|
140
|
+
source: 'lex_dispatch',
|
|
141
|
+
generate_task: true
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
response_body = envelope.merge(
|
|
145
|
+
status: result[:status],
|
|
146
|
+
result: result[:result]
|
|
147
|
+
).compact
|
|
148
|
+
|
|
149
|
+
Legion::JSON.dump(response_body)
|
|
150
|
+
rescue StandardError => e
|
|
151
|
+
route_key = "#{params[:lex_name]}/#{params[:component_type]}/#{params[:component_name]}/#{params[:method_name]}"
|
|
152
|
+
Legion::Logging.log_exception(e, payload_summary: "LexDispatch POST #{route_key}", component_type: :api)
|
|
153
|
+
context.status 500
|
|
154
|
+
Legion::JSON.dump({
|
|
155
|
+
task_id: nil,
|
|
156
|
+
conversation_id: nil,
|
|
157
|
+
status: 'failed',
|
|
158
|
+
error: { code: 500, message: e.message }
|
|
159
|
+
})
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def self.parse_header_integer(value)
|
|
163
|
+
return nil if value.nil?
|
|
164
|
+
|
|
165
|
+
Integer(value)
|
|
166
|
+
rescue ArgumentError, TypeError
|
|
167
|
+
nil
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def self.build_envelope(request)
|
|
171
|
+
task_id = parse_header_integer(request.env['HTTP_X_LEGION_TASK_ID'])
|
|
172
|
+
conversation_id = request.env['HTTP_X_LEGION_CONVERSATION_ID'] || ::SecureRandom.uuid
|
|
173
|
+
parent_id = parse_header_integer(request.env['HTTP_X_LEGION_PARENT_ID'])
|
|
174
|
+
master_id = parse_header_integer(request.env['HTTP_X_LEGION_MASTER_ID'])
|
|
175
|
+
chain_id = parse_header_integer(request.env['HTTP_X_LEGION_CHAIN_ID'])
|
|
176
|
+
debug = request.env['HTTP_X_LEGION_DEBUG'] == 'true'
|
|
177
|
+
|
|
178
|
+
{
|
|
179
|
+
task_id: task_id,
|
|
180
|
+
conversation_id: conversation_id,
|
|
181
|
+
parent_id: parent_id,
|
|
182
|
+
master_id: master_id || task_id,
|
|
183
|
+
chain_id: chain_id,
|
|
184
|
+
debug: debug
|
|
185
|
+
}.compact
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Returns true when the runner class referenced by the route entry is
|
|
189
|
+
# available in the current process (i.e. the extension is loaded locally).
|
|
190
|
+
def self.extension_loaded_locally?(entry)
|
|
191
|
+
runner_class = entry[:runner_class]
|
|
192
|
+
return false if runner_class.nil? || runner_class.to_s.empty?
|
|
193
|
+
|
|
194
|
+
# Try constant lookup — safe because runner_class is from the route registry,
|
|
195
|
+
# not from user input.
|
|
196
|
+
parts = runner_class.to_s.split('::').reject(&:empty?)
|
|
197
|
+
parts.reduce(Object) { |mod, name| mod.const_get(name, false) }
|
|
198
|
+
true
|
|
199
|
+
rescue NameError, TypeError
|
|
200
|
+
false
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Returns true when the definition-level flag explicitly disables remote dispatch.
|
|
204
|
+
# Extension-level gate (entry[:lex_name] module) takes precedence over definition flag.
|
|
205
|
+
def self.definition_blocks_remote?(entry)
|
|
206
|
+
defn = entry[:definition]
|
|
207
|
+
return false if defn.nil?
|
|
208
|
+
|
|
209
|
+
defn[:remote_invocable] == false
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Publish an async AMQP message for remote dispatch (fire-and-forget).
|
|
213
|
+
def self.dispatch_async_amqp(exchange_name, routing_key, payload, envelope)
|
|
214
|
+
return unless defined?(Legion::Transport) &&
|
|
215
|
+
Legion::Transport.respond_to?(:connected?) &&
|
|
216
|
+
Legion::Transport.connected?
|
|
217
|
+
|
|
218
|
+
channel = Legion::Transport.channel
|
|
219
|
+
exchange = channel.exchange(exchange_name, type: :topic, durable: true, passive: true)
|
|
220
|
+
message = Legion::JSON.dump(payload.merge(envelope))
|
|
221
|
+
exchange.publish(message, routing_key: routing_key, content_type: 'application/json', persistent: true)
|
|
222
|
+
rescue StandardError => e
|
|
223
|
+
Legion::Logging.warn "[LexDispatch] async AMQP publish failed: #{e.message}" if defined?(Legion::Logging)
|
|
224
|
+
raise
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
class << self
|
|
228
|
+
private :register_discovery, :register_dispatch, :dispatch_request, :parse_header_integer,
|
|
229
|
+
:build_envelope, :extension_loaded_locally?, :definition_blocks_remote?, :dispatch_async_amqp
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
class API < Sinatra::Base
|
|
5
|
+
# Register a library gem's route module with the tier-aware router and mount it
|
|
6
|
+
# on this Sinatra app.
|
|
7
|
+
#
|
|
8
|
+
# Call from the library gem's boot/start method:
|
|
9
|
+
# Legion::API.register_library_routes('llm', Legion::LLM::Routes) if defined?(Legion::API)
|
|
10
|
+
#
|
|
11
|
+
# @param gem_name [String] short name for the library (e.g. 'llm', 'apollo')
|
|
12
|
+
# @param routes_module [Module] a Sinatra::Extension module to register
|
|
13
|
+
def self.register_library_routes(gem_name, routes_module)
|
|
14
|
+
router.register_library(gem_name, routes_module)
|
|
15
|
+
register routes_module
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
data/lib/legion/api/llm.rb
CHANGED
|
@@ -43,6 +43,8 @@ module Legion
|
|
|
43
43
|
end
|
|
44
44
|
|
|
45
45
|
def self.register_chat(app) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
|
|
46
|
+
register_inference(app)
|
|
47
|
+
|
|
46
48
|
app.post '/api/llm/chat' do # rubocop:disable Metrics/BlockLength
|
|
47
49
|
Legion::Logging.debug "API: POST /api/llm/chat params=#{params.keys}"
|
|
48
50
|
require_llm!
|
|
@@ -163,6 +165,75 @@ module Legion
|
|
|
163
165
|
end
|
|
164
166
|
end
|
|
165
167
|
|
|
168
|
+
def self.register_inference(app) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
|
|
169
|
+
app.post '/api/llm/inference' do # rubocop:disable Metrics/BlockLength
|
|
170
|
+
require_llm!
|
|
171
|
+
body = parse_request_body
|
|
172
|
+
validate_required!(body, :messages)
|
|
173
|
+
|
|
174
|
+
messages = body[:messages]
|
|
175
|
+
tools = body[:tools] || []
|
|
176
|
+
model = body[:model]
|
|
177
|
+
provider = body[:provider]
|
|
178
|
+
|
|
179
|
+
unless messages.is_a?(Array)
|
|
180
|
+
halt 400, { 'Content-Type' => 'application/json' },
|
|
181
|
+
Legion::JSON.dump({ error: { code: 'invalid_messages', message: 'messages must be an array' } })
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
session = Legion::LLM.chat(
|
|
185
|
+
model: model,
|
|
186
|
+
provider: provider,
|
|
187
|
+
caller: { source: 'api', path: request.path }
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
unless tools.empty?
|
|
191
|
+
tool_declarations = tools.map do |t|
|
|
192
|
+
ts = t.respond_to?(:transform_keys) ? t.transform_keys(&:to_sym) : t
|
|
193
|
+
tname = ts[:name].to_s
|
|
194
|
+
tdesc = ts[:description].to_s
|
|
195
|
+
tparams = ts[:parameters] || {}
|
|
196
|
+
Class.new do
|
|
197
|
+
define_singleton_method(:tool_name) { tname }
|
|
198
|
+
define_singleton_method(:description) { tdesc }
|
|
199
|
+
define_singleton_method(:parameters) { tparams }
|
|
200
|
+
define_method(:call) { |**_| raise NotImplementedError, "#{tname} executes client-side only" }
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
session.with_tools(*tool_declarations)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
messages.each { |m| session.add_message(m) }
|
|
207
|
+
|
|
208
|
+
last_user = messages.select { |m| (m[:role] || m['role']).to_s == 'user' }.last
|
|
209
|
+
prompt = (last_user || {})[:content] || (last_user || {})['content'] || ''
|
|
210
|
+
|
|
211
|
+
response = session.ask(prompt)
|
|
212
|
+
|
|
213
|
+
tc_list = if response.respond_to?(:tool_calls) && response.tool_calls
|
|
214
|
+
Array(response.tool_calls).map do |tc|
|
|
215
|
+
{
|
|
216
|
+
id: tc.respond_to?(:id) ? tc.id : nil,
|
|
217
|
+
name: tc.respond_to?(:name) ? tc.name : tc.to_s,
|
|
218
|
+
arguments: tc.respond_to?(:arguments) ? tc.arguments : {}
|
|
219
|
+
}
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
json_response({
|
|
224
|
+
content: response.content,
|
|
225
|
+
tool_calls: tc_list,
|
|
226
|
+
stop_reason: response.respond_to?(:stop_reason) ? response.stop_reason : nil,
|
|
227
|
+
model: session.model.to_s,
|
|
228
|
+
input_tokens: response.respond_to?(:input_tokens) ? response.input_tokens : nil,
|
|
229
|
+
output_tokens: response.respond_to?(:output_tokens) ? response.output_tokens : nil
|
|
230
|
+
}, status_code: 200)
|
|
231
|
+
rescue StandardError => e
|
|
232
|
+
Legion::Logging.error "[api/llm/inference] #{e.class}: #{e.message}" if defined?(Legion::Logging)
|
|
233
|
+
json_response({ error: { code: 'inference_error', message: e.message } }, status_code: 500)
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
166
237
|
def self.register_providers(app)
|
|
167
238
|
app.get '/api/llm/providers' do
|
|
168
239
|
require_llm!
|
|
@@ -190,7 +261,7 @@ module Legion
|
|
|
190
261
|
end
|
|
191
262
|
|
|
192
263
|
class << self
|
|
193
|
-
private :register_chat, :register_providers
|
|
264
|
+
private :register_chat, :register_inference, :register_providers
|
|
194
265
|
end
|
|
195
266
|
end
|
|
196
267
|
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
class API < Sinatra::Base
|
|
5
|
+
class Router
|
|
6
|
+
def initialize
|
|
7
|
+
@infrastructure_routes = []
|
|
8
|
+
@library_routes = {}
|
|
9
|
+
@extension_routes = {}
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# --- Infrastructure tier ---
|
|
13
|
+
|
|
14
|
+
def register_infrastructure(path, method: :get, summary: nil)
|
|
15
|
+
@infrastructure_routes << { path: path, method: method, summary: summary }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def infrastructure_routes
|
|
19
|
+
@infrastructure_routes.dup
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# --- Library gem tier ---
|
|
23
|
+
|
|
24
|
+
def register_library(gem_name, routes_module)
|
|
25
|
+
@library_routes[gem_name.to_s] = routes_module
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def library_routes
|
|
29
|
+
@library_routes.dup
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def library_names
|
|
33
|
+
@library_routes.keys
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# --- Extension tier ---
|
|
37
|
+
|
|
38
|
+
def register_extension_route(**opts)
|
|
39
|
+
lex_name = opts[:lex_name]
|
|
40
|
+
component_type = opts[:component_type]
|
|
41
|
+
component_name = opts[:component_name]
|
|
42
|
+
method_name = opts[:method_name]
|
|
43
|
+
key = "#{lex_name}/#{component_type}/#{component_name}/#{method_name}"
|
|
44
|
+
@extension_routes[key] = {
|
|
45
|
+
lex_name: lex_name.to_s,
|
|
46
|
+
amqp_prefix: opts[:amqp_prefix].to_s,
|
|
47
|
+
component_type: component_type.to_s,
|
|
48
|
+
component_name: component_name.to_s,
|
|
49
|
+
method_name: method_name.to_s,
|
|
50
|
+
runner_class: opts[:runner_class],
|
|
51
|
+
definition: opts[:definition]
|
|
52
|
+
}
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def find_extension_route(lex_name, component_type, component_name, method_name)
|
|
56
|
+
key = "#{lex_name}/#{component_type}/#{component_name}/#{method_name}"
|
|
57
|
+
@extension_routes[key]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def extension_routes
|
|
61
|
+
@extension_routes.dup
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def extension_names
|
|
65
|
+
@extension_routes.values.map { |r| r[:lex_name] }.uniq
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def components_for(lex_name)
|
|
69
|
+
@extension_routes.values
|
|
70
|
+
.select { |r| r[:lex_name] == lex_name.to_s }
|
|
71
|
+
.group_by { |r| r[:component_type] }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def methods_for(lex_name, component_type, component_name)
|
|
75
|
+
@extension_routes.values.select do |r|
|
|
76
|
+
r[:lex_name] == lex_name.to_s &&
|
|
77
|
+
r[:component_type] == component_type.to_s &&
|
|
78
|
+
r[:component_name] == component_name.to_s
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def discovery_extension(lex_name)
|
|
83
|
+
comps = components_for(lex_name)
|
|
84
|
+
return nil if comps.empty?
|
|
85
|
+
|
|
86
|
+
comps.transform_values do |routes|
|
|
87
|
+
routes.map { |r| { name: r[:component_name], method: r[:method_name], definition: r[:definition] } }
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def clear!
|
|
92
|
+
@infrastructure_routes.clear
|
|
93
|
+
@library_routes.clear
|
|
94
|
+
@extension_routes.clear
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
class API < Sinatra::Base
|
|
7
|
+
module SyncDispatch
|
|
8
|
+
# Dispatch a message synchronously via AMQP using a temporary reply_to queue.
|
|
9
|
+
# Blocks until a response arrives or the timeout expires.
|
|
10
|
+
#
|
|
11
|
+
# @param exchange_name [String] target exchange (e.g. "lex.github")
|
|
12
|
+
# @param routing_key [String] routing key (e.g. "lex.github.runners.pull_request.create")
|
|
13
|
+
# @param payload [Hash] message payload
|
|
14
|
+
# @param envelope [Hash] task envelope (task_id, conversation_id, etc.)
|
|
15
|
+
# @param timeout [Integer] seconds to wait (default 30)
|
|
16
|
+
# @return [Hash]
|
|
17
|
+
def self.dispatch(exchange_name, routing_key, payload, envelope, timeout: 30)
|
|
18
|
+
unless defined?(Legion::Transport) &&
|
|
19
|
+
Legion::Transport.respond_to?(:connected?) &&
|
|
20
|
+
Legion::Transport.connected?
|
|
21
|
+
return envelope.merge(
|
|
22
|
+
status: 'failed',
|
|
23
|
+
error: { code: 503, message: 'Transport not available for sync dispatch' }
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
perform_dispatch(exchange_name, routing_key, payload, envelope, timeout)
|
|
28
|
+
rescue StandardError => e
|
|
29
|
+
Legion::Logging.error "[SyncDispatch] #{e.class}: #{e.message}" if defined?(Legion::Logging)
|
|
30
|
+
envelope.merge(
|
|
31
|
+
status: 'failed',
|
|
32
|
+
error: { code: 500, message: e.message }
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @api private
|
|
37
|
+
def self.perform_dispatch(exchange_name, routing_key, payload, envelope, timeout)
|
|
38
|
+
response = nil
|
|
39
|
+
mutex = Mutex.new
|
|
40
|
+
condition = ConditionVariable.new
|
|
41
|
+
reply_queue_name = "sync.reply.#{::SecureRandom.uuid}"
|
|
42
|
+
|
|
43
|
+
begin
|
|
44
|
+
channel = Legion::Transport.channel
|
|
45
|
+
reply_queue = channel.queue(reply_queue_name, exclusive: true, auto_delete: true)
|
|
46
|
+
subscribe_reply(reply_queue, mutex, condition) { |r| response = r }
|
|
47
|
+
publish_sync(channel, exchange_name, routing_key, payload, envelope, reply_queue_name)
|
|
48
|
+
wait_for_response(mutex, condition, timeout) { response }
|
|
49
|
+
response || envelope.merge(
|
|
50
|
+
status: 'timeout',
|
|
51
|
+
error: { code: 504, message: "Sync dispatch timed out after #{timeout}s" }
|
|
52
|
+
)
|
|
53
|
+
ensure
|
|
54
|
+
reply_queue&.delete rescue nil # rubocop:disable Style/RescueModifier
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# @api private
|
|
59
|
+
def self.subscribe_reply(reply_queue, mutex, condition)
|
|
60
|
+
reply_queue.subscribe(block: false) do |_delivery_info, _metadata, body|
|
|
61
|
+
parsed = begin
|
|
62
|
+
Legion::JSON.load(body)
|
|
63
|
+
rescue StandardError
|
|
64
|
+
{ raw: body }
|
|
65
|
+
end
|
|
66
|
+
mutex.synchronize do
|
|
67
|
+
yield parsed
|
|
68
|
+
condition.signal
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# @api private
|
|
74
|
+
def self.wait_for_response(mutex, condition, timeout)
|
|
75
|
+
mutex.synchronize do
|
|
76
|
+
deadline = Time.now + timeout
|
|
77
|
+
loop do
|
|
78
|
+
remaining = deadline - Time.now
|
|
79
|
+
break if yield || remaining <= 0
|
|
80
|
+
|
|
81
|
+
condition.wait(mutex, remaining)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# @api private
|
|
87
|
+
def self.publish_sync(channel, exchange_name, routing_key, payload, envelope, reply_queue_name) # rubocop:disable Metrics/ParameterLists
|
|
88
|
+
exchange = channel.exchange(exchange_name, type: :topic, durable: true, passive: true)
|
|
89
|
+
message = Legion::JSON.dump(payload.merge(envelope))
|
|
90
|
+
exchange.publish(
|
|
91
|
+
message,
|
|
92
|
+
routing_key: routing_key,
|
|
93
|
+
reply_to: reply_queue_name,
|
|
94
|
+
content_type: 'application/json',
|
|
95
|
+
persistent: false
|
|
96
|
+
)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private_class_method :perform_dispatch, :subscribe_reply, :wait_for_response, :publish_sync
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|