legionio 1.6.21 → 1.6.22

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4d50e1efc9398c0e2d380c7a921e2ccc8b6e6db3a142d4f4a700ff536555a2e4
4
- data.tar.gz: e44d47e0c4d04362d9a35c6ad6953d0b4a42a5f0f2225dd0420e7f2d1c544ad4
3
+ metadata.gz: 68888d98222b99e6e88670e8df81245d312dbed7c5f04d59a0cb79e592d65e47
4
+ data.tar.gz: be305f5229f73ad9c28e76a87c5353afac7edfbe8764284db03e34d727af1ef8
5
5
  SHA512:
6
- metadata.gz: f4cc63f869d21abc423f7836a25b91f62a815d01d1972004b85728be0b969e332a5f95c9172ab6177f3759e837b6e40bbb30afba1ecd3a817cd5088fff4c675b
7
- data.tar.gz: 719f9a8e3d316937be26ed610b7ba86c653317b476b271d821591fecf5ff8157c02788b03df1468c7fe3af19d76ec1a55cf48312d911a502e30873c51d378bb0
6
+ metadata.gz: 180fc2adda6a37c44accfddd5c25099bab8568462ec58c99f51ccf58203d62d65813831ff569cfcd729524566be12b2c037c6df184efcf1514d9a9bd75964698
7
+ data.tar.gz: d5f1b84b9c562df8766b20923ebec48215b78b28e600744e9b210578a311138df4ffef221f8bb42520272fc71bff8b3041420765d7d876c8a634146927e55b15
data/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [1.6.22] - 2026-03-27
6
+
7
+ ### Added
8
+ - `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
9
+ - `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
10
+ - `spec/legion/api/llm_inference_spec.rb`: 12 examples covering the new `/api/llm/inference` endpoint
11
+ - `spec/legion/cli/chat/daemon_chat_spec.rb`: 25 examples covering `DaemonChat` initialization, tool registration, tool execution loop, streaming, and error handling
12
+
13
+ ### Changed
14
+ - `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**
15
+ - `legion chat create_chat`: now returns a `DaemonChat` instance instead of a direct `RubyLLM::Chat` object; all LLM calls route through the daemon
16
+
5
17
  ## [1.6.21] - 2026-03-27
6
18
 
7
19
  ### Added
@@ -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,220 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/cli/chat_command'
4
+
5
+ begin
6
+ require 'legion/llm/daemon_client'
7
+ rescue LoadError
8
+ # legion-llm not yet loaded; DaemonClient must be defined before DaemonChat#ask is called.
9
+ end
10
+
11
+ module Legion
12
+ module CLI
13
+ class Chat
14
+ # Daemon-backed chat adapter. Matches the interface that Session expects
15
+ # from a chat object (ask, with_tools, with_instructions, on_tool_call,
16
+ # on_tool_result, model, add_message, reset_messages!, with_model).
17
+ #
18
+ # All LLM inference is routed through the running daemon via
19
+ # POST /api/llm/inference. Tool execution runs locally on the client
20
+ # machine — the daemon returns tool_call requests and the client
21
+ # executes them and loops.
22
+ class DaemonChat
23
+ # Minimal response-like object returned from ask.
24
+ # Responds to the same interface Session#send_message reads.
25
+ Response = Struct.new(:content, :input_tokens, :output_tokens, :model)
26
+
27
+ # Minimal model object responding to .id (used by Session#model_id).
28
+ ModelInfo = Struct.new(:id) do
29
+ def to_s
30
+ id.to_s
31
+ end
32
+ end
33
+
34
+ attr_reader :model
35
+
36
+ def initialize(model: nil, provider: nil)
37
+ @model = ModelInfo.new(id: model)
38
+ @provider = provider
39
+ @messages = []
40
+ @tools = []
41
+ @instructions = nil
42
+ @on_tool_call = nil
43
+ @on_tool_result = nil
44
+ end
45
+
46
+ # Sets the system prompt. Returns self for chaining.
47
+ def with_instructions(prompt)
48
+ @instructions = prompt
49
+ self
50
+ end
51
+
52
+ # Registers tool classes for local execution and schema forwarding.
53
+ # Returns self for chaining.
54
+ def with_tools(*tools)
55
+ @tools = tools.flatten
56
+ self
57
+ end
58
+
59
+ # Switches the active model. Returns self for chaining.
60
+ def with_model(model_id)
61
+ @model = ModelInfo.new(id: model_id)
62
+ self
63
+ end
64
+
65
+ # Stores a tool_call callback invoked before each local tool execution.
66
+ def on_tool_call(&block)
67
+ @on_tool_call = block
68
+ end
69
+
70
+ # Stores a tool_result callback invoked after each local tool execution.
71
+ def on_tool_result(&block)
72
+ @on_tool_result = block
73
+ end
74
+
75
+ # Appends a message to the conversation history directly (used by
76
+ # slash commands /fetch, /search, /agent, etc. that inject context).
77
+ def add_message(role:, content:)
78
+ @messages << { role: role.to_s, content: content }
79
+ end
80
+
81
+ # Clears all conversation history (used by /clear slash command).
82
+ def reset_messages!
83
+ @messages = []
84
+ end
85
+
86
+ # Sends a message through the daemon inference loop.
87
+ # Executes any tool_calls locally and loops until the LLM stops.
88
+ # Yields response-like chunks for streaming display (Phase 1: single chunk).
89
+ # Returns a Response object compatible with Session#send_message.
90
+ def ask(message, &on_chunk)
91
+ @messages << { role: 'user', content: message }
92
+
93
+ loop do
94
+ result = call_daemon_inference
95
+
96
+ raise CLI::Error, "Daemon inference error: #{result[:error]}" if result[:status] == :error
97
+ raise CLI::Error, 'Daemon is unavailable' if result[:status] == :unavailable
98
+
99
+ data = extract_data(result)
100
+
101
+ if data[:tool_calls]&.any?
102
+ execute_tool_calls(data[:tool_calls], data[:content])
103
+ else
104
+ on_chunk&.call(Response.new(content: data[:content]))
105
+ @messages << { role: 'assistant', content: data[:content] }
106
+ return build_response(data)
107
+ end
108
+ end
109
+ end
110
+
111
+ private
112
+
113
+ def call_daemon_inference
114
+ Legion::LLM::DaemonClient.inference(
115
+ messages: build_messages,
116
+ tools: build_tool_schemas,
117
+ model: @model.id,
118
+ provider: @provider
119
+ )
120
+ end
121
+
122
+ def extract_data(result)
123
+ # DaemonClient.inference returns { status:, data: { content:, tool_calls:, ... } }
124
+ data = result[:data] || result[:body] || {}
125
+ data.is_a?(Hash) ? data : {}
126
+ end
127
+
128
+ def build_messages
129
+ msgs = []
130
+ msgs << { role: 'system', content: @instructions } if @instructions
131
+ msgs + @messages
132
+ end
133
+
134
+ def build_tool_schemas
135
+ @tools.map do |tool|
136
+ {
137
+ name: tool_name(tool),
138
+ description: tool_description(tool),
139
+ parameters: tool_parameters(tool)
140
+ }
141
+ end
142
+ end
143
+
144
+ def tool_name(tool)
145
+ if tool.respond_to?(:tool_name)
146
+ tool.tool_name
147
+ else
148
+ tool.name.to_s.split('::').last.gsub(/([A-Z])/) do
149
+ "_#{::Regexp.last_match(1).downcase}"
150
+ end.delete_prefix('_')
151
+ end
152
+ end
153
+
154
+ def tool_description(tool)
155
+ tool.respond_to?(:description) ? tool.description : ''
156
+ end
157
+
158
+ def tool_parameters(tool)
159
+ tool.respond_to?(:parameters) ? tool.parameters : {}
160
+ end
161
+
162
+ def execute_tool_calls(tool_calls, assistant_content)
163
+ # Record the assistant turn with tool_calls before appending results.
164
+ @messages << { role: 'assistant', content: assistant_content, tool_calls: tool_calls }
165
+
166
+ tool_calls.each do |tc|
167
+ tc = tc.transform_keys(&:to_sym) if tc.respond_to?(:transform_keys)
168
+ tc_obj = build_tool_call_object(tc)
169
+
170
+ @on_tool_call&.call(tc_obj)
171
+
172
+ result_text = run_tool(tc)
173
+
174
+ result_obj = build_tool_result_object(result_text)
175
+ @on_tool_result&.call(result_obj)
176
+
177
+ @messages << {
178
+ role: 'tool',
179
+ tool_call_id: tc[:id] || tc[:tool_call_id],
180
+ content: result_text.to_s
181
+ }
182
+ end
183
+ end
184
+
185
+ def build_tool_call_object(tool_call)
186
+ Struct.new(:name, :arguments, :id).new(
187
+ name: tool_call[:name].to_s,
188
+ arguments: (tool_call[:arguments] || tool_call[:input] || {}).transform_keys(&:to_sym),
189
+ id: tool_call[:id] || tool_call[:tool_call_id]
190
+ )
191
+ end
192
+
193
+ def build_tool_result_object(text)
194
+ Struct.new(:content).new(content: text.to_s)
195
+ end
196
+
197
+ def run_tool(tool_call)
198
+ name = tool_call[:name].to_s
199
+ arguments = (tool_call[:arguments] || tool_call[:input] || {}).transform_keys(&:to_sym)
200
+
201
+ tool_class = @tools.find { |t| tool_name(t) == name }
202
+ return "Unknown tool: #{name}" unless tool_class
203
+
204
+ tool_class.call(**arguments)
205
+ rescue StandardError => e
206
+ "Tool error (#{name}): #{e.message}"
207
+ end
208
+
209
+ def build_response(data)
210
+ Response.new(
211
+ content: data[:content],
212
+ input_tokens: data[:input_tokens],
213
+ output_tokens: data[:output_tokens],
214
+ model: ModelInfo.new(id: data[:model] || @model.id)
215
+ )
216
+ end
217
+ end
218
+ end
219
+ end
220
+ end
@@ -176,7 +176,14 @@ module Legion
176
176
  def setup_connection
177
177
  Connection.config_dir = options[:config_dir] if options[:config_dir]
178
178
  Connection.log_level = options[:verbose] ? 'debug' : 'error'
179
- Connection.ensure_llm
179
+ Connection.ensure_settings
180
+
181
+ require 'legion/llm/daemon_client'
182
+ return if Legion::LLM::DaemonClient.available?
183
+
184
+ raise CLI::Error,
185
+ "LegionIO daemon is not running. Start it with: legionio start\n " \
186
+ 'All LLM requests must route through the daemon.'
180
187
  end
181
188
 
182
189
  def setup_notification_bridge
@@ -237,13 +244,13 @@ module Legion
237
244
  end
238
245
 
239
246
  def create_chat
240
- opts = {}
241
- opts[:model] = options[:model] || chat_setting(:model)
242
- opts[:provider] = (options[:provider] || chat_setting(:provider))&.to_sym
243
- opts.compact!
244
-
247
+ require 'legion/cli/chat/daemon_chat'
245
248
  require 'legion/cli/chat/tool_registry'
246
- chat = Legion::LLM.chat(**opts, caller: { source: 'cli', command: 'chat' })
249
+
250
+ chat = Chat::DaemonChat.new(
251
+ model: options[:model] || chat_setting(:model),
252
+ provider: (options[:provider] || chat_setting(:provider))&.to_sym
253
+ )
247
254
  chat.with_tools(*Chat::ToolRegistry.all_tools)
248
255
  chat
249
256
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Legion
4
- VERSION = '1.6.21'
4
+ VERSION = '1.6.22'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: legionio
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.6.21
4
+ version: 1.6.22
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -524,6 +524,7 @@ files:
524
524
  - lib/legion/cli/chat/checkpoint.rb
525
525
  - lib/legion/cli/chat/context.rb
526
526
  - lib/legion/cli/chat/context_manager.rb
527
+ - lib/legion/cli/chat/daemon_chat.rb
527
528
  - lib/legion/cli/chat/extension_tool.rb
528
529
  - lib/legion/cli/chat/extension_tool_loader.rb
529
530
  - lib/legion/cli/chat/markdown_renderer.rb