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 +4 -4
- data/CHANGELOG.md +12 -0
- data/lib/legion/api/llm.rb +72 -1
- data/lib/legion/cli/chat/daemon_chat.rb +220 -0
- data/lib/legion/cli/chat_command.rb +14 -7
- data/lib/legion/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 68888d98222b99e6e88670e8df81245d312dbed7c5f04d59a0cb79e592d65e47
|
|
4
|
+
data.tar.gz: be305f5229f73ad9c28e76a87c5353afac7edfbe8764284db03e34d727af1ef8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
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,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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/legion/version.rb
CHANGED
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.
|
|
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
|