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
data/lib/legion/api.rb
CHANGED
|
@@ -47,6 +47,10 @@ require_relative 'api/costs'
|
|
|
47
47
|
require_relative 'api/traces'
|
|
48
48
|
require_relative 'api/stats'
|
|
49
49
|
require_relative 'api/codegen'
|
|
50
|
+
require_relative 'api/router'
|
|
51
|
+
require_relative 'api/library_routes'
|
|
52
|
+
require_relative 'api/sync_dispatch'
|
|
53
|
+
require_relative 'api/lex_dispatch'
|
|
50
54
|
require_relative 'api/graphql' if defined?(GraphQL)
|
|
51
55
|
|
|
52
56
|
module Legion
|
|
@@ -72,6 +76,21 @@ module Legion
|
|
|
72
76
|
Legion::API::OpenAPI.to_json
|
|
73
77
|
end
|
|
74
78
|
|
|
79
|
+
# Root discovery — lists all tiers
|
|
80
|
+
get '/api/discovery' do
|
|
81
|
+
content_type :json
|
|
82
|
+
Legion::JSON.dump({
|
|
83
|
+
infrastructure: [
|
|
84
|
+
{ path: '/api/health', method: 'GET' },
|
|
85
|
+
{ path: '/api/ready', method: 'GET' },
|
|
86
|
+
{ path: '/api/openapi.json', method: 'GET' },
|
|
87
|
+
{ path: '/api/discovery', method: 'GET' }
|
|
88
|
+
],
|
|
89
|
+
libraries: Legion::API.router.library_names,
|
|
90
|
+
extensions: Legion::API.router.extension_names
|
|
91
|
+
})
|
|
92
|
+
end
|
|
93
|
+
|
|
75
94
|
# Health and readiness
|
|
76
95
|
get '/api/health' do
|
|
77
96
|
json_response({ status: 'ok', version: Legion::VERSION })
|
|
@@ -87,8 +106,14 @@ module Legion
|
|
|
87
106
|
content_type :json
|
|
88
107
|
Legion::Logging.warn "API #{request.request_method} #{request.path_info} returned 404: no route matches"
|
|
89
108
|
Legion::JSON.dump({
|
|
90
|
-
|
|
91
|
-
|
|
109
|
+
task_id: nil,
|
|
110
|
+
conversation_id: nil,
|
|
111
|
+
status: 'failed',
|
|
112
|
+
error: {
|
|
113
|
+
code: 404,
|
|
114
|
+
message: "no route matches #{request.request_method} #{request.path_info}"
|
|
115
|
+
},
|
|
116
|
+
meta: { timestamp: Time.now.utc.iso8601, node: Legion::Settings[:client][:name] }
|
|
92
117
|
})
|
|
93
118
|
end
|
|
94
119
|
|
|
@@ -97,12 +122,16 @@ module Legion
|
|
|
97
122
|
err = env['sinatra.error']
|
|
98
123
|
Legion::Logging.log_exception(err, payload_summary: "API #{request.request_method} #{request.path_info} returned 500", component_type: :api)
|
|
99
124
|
Legion::JSON.dump({
|
|
100
|
-
|
|
101
|
-
|
|
125
|
+
task_id: nil,
|
|
126
|
+
conversation_id: nil,
|
|
127
|
+
status: 'failed',
|
|
128
|
+
error: { code: 500, message: err.message },
|
|
129
|
+
meta: { timestamp: Time.now.utc.iso8601, node: Legion::Settings[:client][:name] }
|
|
102
130
|
})
|
|
103
131
|
end
|
|
104
132
|
|
|
105
133
|
# Mount route modules
|
|
134
|
+
register Routes::LexDispatch
|
|
106
135
|
register Routes::Tasks
|
|
107
136
|
register Routes::Extensions
|
|
108
137
|
register Routes::Nodes
|
|
@@ -126,14 +155,14 @@ module Legion
|
|
|
126
155
|
register Routes::Capacity
|
|
127
156
|
register Routes::Audit
|
|
128
157
|
register Routes::Metrics
|
|
129
|
-
register Routes::Llm
|
|
158
|
+
register Routes::Llm unless defined?(Legion::LLM::Routes)
|
|
130
159
|
register Routes::ExtensionCatalog
|
|
131
160
|
register Routes::OrgChart
|
|
132
161
|
register Routes::Governance
|
|
133
162
|
register Routes::Acp
|
|
134
163
|
register Routes::Prompts
|
|
135
164
|
register Routes::Marketplace
|
|
136
|
-
register Routes::Apollo
|
|
165
|
+
register Routes::Apollo unless defined?(Legion::Apollo::Routes)
|
|
137
166
|
register Routes::Costs
|
|
138
167
|
register Routes::Traces
|
|
139
168
|
register Routes::Stats
|
|
@@ -143,6 +172,13 @@ module Legion
|
|
|
143
172
|
use Legion::API::Middleware::RequestLogger
|
|
144
173
|
use Legion::Rbac::Middleware if defined?(Legion::Rbac::Middleware)
|
|
145
174
|
|
|
175
|
+
# Tier-aware router (three-tier namespace)
|
|
176
|
+
class << self
|
|
177
|
+
def router
|
|
178
|
+
@router ||= Legion::API::Router.new
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
146
182
|
# Hook registry (preserved from original implementation)
|
|
147
183
|
class << self
|
|
148
184
|
def hook_registry
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'net/http'
|
|
5
|
+
require 'erb'
|
|
6
|
+
|
|
7
|
+
module Legion
|
|
8
|
+
module CLI
|
|
9
|
+
module Admin
|
|
10
|
+
class PurgeTopology < Thor
|
|
11
|
+
namespace 'admin:purge_topology'
|
|
12
|
+
|
|
13
|
+
def self.exit_on_failure?
|
|
14
|
+
true
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
class_option :json, type: :boolean, default: false, desc: 'Output as JSON'
|
|
18
|
+
class_option :no_color, type: :boolean, default: false, desc: 'Disable color output'
|
|
19
|
+
class_option :host, type: :string, default: 'localhost', desc: 'RabbitMQ management host'
|
|
20
|
+
class_option :port, type: :numeric, default: 15_672, desc: 'RabbitMQ management port'
|
|
21
|
+
class_option :user, type: :string, default: 'guest', desc: 'RabbitMQ management username'
|
|
22
|
+
class_option :password, type: :string, default: 'guest', desc: 'RabbitMQ management password'
|
|
23
|
+
class_option :vhost, type: :string, default: '/', desc: 'RabbitMQ vhost'
|
|
24
|
+
class_option :execute, type: :boolean, default: false, desc: 'Actually delete (default: dry-run)'
|
|
25
|
+
|
|
26
|
+
desc 'purge', 'Enumerate and optionally delete legacy v2.0 topology (legion.{lex} exchanges/queues)'
|
|
27
|
+
def purge
|
|
28
|
+
out = formatter
|
|
29
|
+
out.header('Legion AMQP Topology Migration: v2.0 → v3.0')
|
|
30
|
+
out.spacer
|
|
31
|
+
|
|
32
|
+
legacy = find_legacy_topology
|
|
33
|
+
if legacy[:exchanges].empty? && legacy[:queues].empty?
|
|
34
|
+
out.success('No legacy topology found. Already on v3.0 or never had v2.0 topology.')
|
|
35
|
+
return
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
if options[:json]
|
|
39
|
+
perform_deletion(legacy) if options[:execute]
|
|
40
|
+
out.json({ legacy: legacy, deleted: options[:execute] })
|
|
41
|
+
return
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
report_legacy(out, legacy)
|
|
45
|
+
|
|
46
|
+
if options[:execute]
|
|
47
|
+
perform_deletion(legacy)
|
|
48
|
+
out.success("Deleted #{legacy[:exchanges].size} exchange(s) and #{legacy[:queues].size} queue(s)")
|
|
49
|
+
else
|
|
50
|
+
out.warn('Dry-run mode — pass --execute to delete legacy topology')
|
|
51
|
+
end
|
|
52
|
+
rescue Legion::CLI::Error => e
|
|
53
|
+
formatter.error(e.message)
|
|
54
|
+
exit(1)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
no_commands do # rubocop:disable Metrics/BlockLength
|
|
58
|
+
def formatter
|
|
59
|
+
@formatter ||= Output::Formatter.new(
|
|
60
|
+
json: options[:json],
|
|
61
|
+
color: !options[:no_color]
|
|
62
|
+
)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def vhost_encoded
|
|
68
|
+
ERB::Util.url_encode(options[:vhost])
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def management_api(path)
|
|
72
|
+
uri = URI("http://#{options[:host]}:#{options[:port]}/api#{path}")
|
|
73
|
+
req = Net::HTTP::Get.new(uri)
|
|
74
|
+
req.basic_auth(options[:user], options[:password])
|
|
75
|
+
response = Net::HTTP.start(uri.host, uri.port, open_timeout: 5, read_timeout: 10) do |http|
|
|
76
|
+
http.request(req)
|
|
77
|
+
end
|
|
78
|
+
raise Legion::CLI::Error, "Management API #{response.code}: #{response.body}" unless response.is_a?(Net::HTTPSuccess)
|
|
79
|
+
|
|
80
|
+
::JSON.parse(response.body, symbolize_names: true)
|
|
81
|
+
rescue Errno::ECONNREFUSED
|
|
82
|
+
raise Legion::CLI::Error, "Cannot connect to RabbitMQ management API at #{options[:host]}:#{options[:port]}"
|
|
83
|
+
rescue Net::OpenTimeout, Net::ReadTimeout
|
|
84
|
+
raise Legion::CLI::Error, 'Timed out connecting to RabbitMQ management API'
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def management_delete(path)
|
|
88
|
+
uri = URI("http://#{options[:host]}:#{options[:port]}/api#{path}")
|
|
89
|
+
req = Net::HTTP::Delete.new(uri)
|
|
90
|
+
req.basic_auth(options[:user], options[:password])
|
|
91
|
+
response = Net::HTTP.start(uri.host, uri.port, open_timeout: 5, read_timeout: 10) do |http|
|
|
92
|
+
http.request(req)
|
|
93
|
+
end
|
|
94
|
+
raise Legion::CLI::Error, "Management API #{response.code}: #{response.body}" unless response.is_a?(Net::HTTPSuccess)
|
|
95
|
+
|
|
96
|
+
response
|
|
97
|
+
rescue Errno::ECONNREFUSED
|
|
98
|
+
raise Legion::CLI::Error, "Cannot connect to RabbitMQ management API at #{options[:host]}:#{options[:port]}"
|
|
99
|
+
rescue Net::OpenTimeout, Net::ReadTimeout
|
|
100
|
+
raise Legion::CLI::Error, 'Timed out connecting to RabbitMQ management API'
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Find exchanges and queues matching legacy v2.0 pattern: legion.{lex_name}.*
|
|
104
|
+
# but NOT matching v3.0 pattern (lex.{lex_name}.*) or infrastructure (task, node, etc.)
|
|
105
|
+
def find_legacy_topology
|
|
106
|
+
all_exchanges = management_api("/exchanges/#{vhost_encoded}")
|
|
107
|
+
all_queues = management_api("/queues/#{vhost_encoded}")
|
|
108
|
+
|
|
109
|
+
legacy_exchanges = all_exchanges
|
|
110
|
+
.map { |e| e[:name].to_s }
|
|
111
|
+
.select do |name|
|
|
112
|
+
name.match?(/\Alegion\.[a-z]/) && !name.start_with?('legion.task', 'legion.node', 'legion.crypt', 'legion.extensions',
|
|
113
|
+
'legion.logging')
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
legacy_queues = all_queues
|
|
117
|
+
.map { |q| q[:name].to_s }
|
|
118
|
+
.select { |name| name.match?(/\Alegion\.[a-z]/) && !name.match?(/\Alegion\.(task|node|crypt|extensions|logging)/) }
|
|
119
|
+
|
|
120
|
+
{ exchanges: legacy_exchanges, queues: legacy_queues }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def report_legacy(out, legacy)
|
|
124
|
+
unless legacy[:exchanges].empty?
|
|
125
|
+
out.detail_header("Legacy Exchanges (#{legacy[:exchanges].size})")
|
|
126
|
+
legacy[:exchanges].each { |name| out.detail({ name: name }) }
|
|
127
|
+
out.spacer
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
unless legacy[:queues].empty? # rubocop:disable Style/GuardClause
|
|
131
|
+
out.detail_header("Legacy Queues (#{legacy[:queues].size})")
|
|
132
|
+
legacy[:queues].each { |name| out.detail({ name: name }) }
|
|
133
|
+
out.spacer
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def perform_deletion(legacy)
|
|
138
|
+
legacy[:queues].each do |name|
|
|
139
|
+
management_delete("/queues/#{vhost_encoded}/#{ERB::Util.url_encode(name)}")
|
|
140
|
+
end
|
|
141
|
+
legacy[:exchanges].each do |name|
|
|
142
|
+
management_delete("/exchanges/#{vhost_encoded}/#{ERB::Util.url_encode(name)}")
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
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
|
|
@@ -427,9 +427,9 @@ module Legion
|
|
|
427
427
|
end
|
|
428
428
|
end
|
|
429
429
|
|
|
430
|
-
describe '#
|
|
430
|
+
describe '#absorb' do
|
|
431
431
|
it 'returns success' do
|
|
432
|
-
result = described_class.new.
|
|
432
|
+
result = described_class.new.absorb(url: 'https://#{test_url}')
|
|
433
433
|
expect(result[:success]).to be true
|
|
434
434
|
end
|
|
435
435
|
end
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative '../definitions'
|
|
4
|
+
|
|
3
5
|
module Legion
|
|
4
6
|
module Extensions
|
|
5
7
|
module Absorbers
|
|
6
8
|
class Base
|
|
9
|
+
extend Legion::Extensions::Definitions
|
|
10
|
+
|
|
7
11
|
attr_accessor :job_id, :runners
|
|
8
12
|
|
|
9
13
|
class << self
|
|
@@ -21,10 +25,13 @@ module Legion
|
|
|
21
25
|
end
|
|
22
26
|
end
|
|
23
27
|
|
|
24
|
-
def
|
|
25
|
-
raise NotImplementedError, "#{self.class.name} must implement #
|
|
28
|
+
def absorb(url: nil, content: nil, metadata: {}, context: {})
|
|
29
|
+
raise NotImplementedError, "#{self.class.name} must implement #absorb"
|
|
26
30
|
end
|
|
27
31
|
|
|
32
|
+
# @deprecated Use #absorb instead
|
|
33
|
+
alias handle absorb
|
|
34
|
+
|
|
28
35
|
def absorb_to_knowledge(content:, tags: [], scope: :global, **opts)
|
|
29
36
|
return fallback_absorb(:chunker, content, tags, scope, opts) unless chunker_available?
|
|
30
37
|
return fallback_absorb(:apollo, content, tags, scope, opts) unless apollo_available?
|
|
@@ -19,7 +19,7 @@ module Legion
|
|
|
19
19
|
|
|
20
20
|
absorber = absorber_class.new
|
|
21
21
|
absorber.job_id = job_id
|
|
22
|
-
result = absorber.
|
|
22
|
+
result = absorber.absorb(url: input, content: context[:content],
|
|
23
23
|
metadata: context[:metadata] || {}, context: context)
|
|
24
24
|
publish_event("absorb.complete.#{job_id}", job_id: job_id, absorber: absorber_class.name,
|
|
25
25
|
result: result)
|
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative 'dsl'
|
|
4
|
+
|
|
3
5
|
module Legion
|
|
4
6
|
module Extensions
|
|
5
7
|
module Actors
|
|
6
8
|
module Base
|
|
9
|
+
extend Legion::Extensions::Actors::Dsl
|
|
7
10
|
include Legion::Extensions::Helpers::Lex
|
|
8
11
|
|
|
12
|
+
define_dsl_accessor :use_runner, default: true
|
|
13
|
+
define_dsl_accessor :check_subtask, default: true
|
|
14
|
+
define_dsl_accessor :generate_task, default: false
|
|
15
|
+
define_dsl_accessor :enabled, default: true
|
|
16
|
+
define_dsl_accessor :remote_invocable, default: true
|
|
17
|
+
|
|
9
18
|
def runner
|
|
10
19
|
Legion::Runner.run(runner_class: runner_class, function: function, check_subtask: check_subtask?, generate_task: generate_task?)
|
|
11
20
|
rescue StandardError => e
|
|
@@ -29,8 +38,17 @@ module Legion
|
|
|
29
38
|
nil
|
|
30
39
|
end
|
|
31
40
|
|
|
41
|
+
def self.included(base)
|
|
42
|
+
base.extend(Legion::Extensions::Actors::Dsl) unless base.singleton_class.include?(Legion::Extensions::Actors::Dsl)
|
|
43
|
+
base.define_dsl_accessor(:use_runner, default: true) unless base.respond_to?(:use_runner)
|
|
44
|
+
base.define_dsl_accessor(:check_subtask, default: true) unless base.respond_to?(:check_subtask)
|
|
45
|
+
base.define_dsl_accessor(:generate_task, default: false) unless base.respond_to?(:generate_task)
|
|
46
|
+
base.define_dsl_accessor(:enabled, default: true) unless base.respond_to?(:enabled)
|
|
47
|
+
base.define_dsl_accessor(:remote_invocable, default: true) unless base.respond_to?(:remote_invocable)
|
|
48
|
+
end
|
|
49
|
+
|
|
32
50
|
def use_runner?
|
|
33
|
-
true
|
|
51
|
+
self.class.respond_to?(:use_runner) ? self.class.use_runner : true
|
|
34
52
|
end
|
|
35
53
|
|
|
36
54
|
def args
|
|
@@ -38,19 +56,19 @@ module Legion
|
|
|
38
56
|
end
|
|
39
57
|
|
|
40
58
|
def check_subtask?
|
|
41
|
-
true
|
|
59
|
+
self.class.respond_to?(:check_subtask) ? self.class.check_subtask : true
|
|
42
60
|
end
|
|
43
61
|
|
|
44
62
|
def generate_task?
|
|
45
|
-
false
|
|
63
|
+
self.class.respond_to?(:generate_task) ? self.class.generate_task : false
|
|
46
64
|
end
|
|
47
65
|
|
|
48
66
|
def enabled?
|
|
49
|
-
true
|
|
67
|
+
self.class.respond_to?(:enabled) ? self.class.enabled : true
|
|
50
68
|
end
|
|
51
69
|
|
|
52
70
|
def remote_invocable?
|
|
53
|
-
true
|
|
71
|
+
self.class.respond_to?(:remote_invocable) ? self.class.remote_invocable : true
|
|
54
72
|
end
|
|
55
73
|
end
|
|
56
74
|
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Actors
|
|
6
|
+
module Dsl
|
|
7
|
+
def define_dsl_accessor(name, default:)
|
|
8
|
+
define_singleton_method(name) do |val = :_unset|
|
|
9
|
+
if val == :_unset
|
|
10
|
+
if instance_variable_defined?(:"@#{name}")
|
|
11
|
+
instance_variable_get(:"@#{name}")
|
|
12
|
+
elsif superclass.respond_to?(name)
|
|
13
|
+
superclass.public_send(name)
|
|
14
|
+
else
|
|
15
|
+
default
|
|
16
|
+
end
|
|
17
|
+
else
|
|
18
|
+
instance_variable_set(:"@#{name}", val)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
define_method(name) do
|
|
23
|
+
self.class.public_send(name)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -2,14 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative 'base'
|
|
4
4
|
require_relative 'fingerprint'
|
|
5
|
+
require_relative 'dsl'
|
|
5
6
|
|
|
6
7
|
module Legion
|
|
7
8
|
module Extensions
|
|
8
9
|
module Actors
|
|
9
10
|
class Every
|
|
11
|
+
extend Legion::Extensions::Actors::Dsl
|
|
10
12
|
include Legion::Extensions::Actors::Base
|
|
11
13
|
include Legion::Extensions::Actors::Fingerprint
|
|
12
14
|
|
|
15
|
+
define_dsl_accessor :time, default: 1
|
|
16
|
+
define_dsl_accessor :timeout, default: 5
|
|
17
|
+
define_dsl_accessor :run_now, default: false
|
|
18
|
+
|
|
13
19
|
def initialize(**_opts)
|
|
14
20
|
@timer = Concurrent::TimerTask.new(execution_interval: time, run_now: run_now?) do
|
|
15
21
|
log.debug "[Every] tick: #{self.class}" if defined?(log)
|
|
@@ -25,16 +31,8 @@ module Legion
|
|
|
25
31
|
log.log_exception(e, component_type: :actor)
|
|
26
32
|
end
|
|
27
33
|
|
|
28
|
-
def time
|
|
29
|
-
1
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
def timeout
|
|
33
|
-
5
|
|
34
|
-
end
|
|
35
|
-
|
|
36
34
|
def run_now?
|
|
37
|
-
|
|
35
|
+
run_now
|
|
38
36
|
end
|
|
39
37
|
|
|
40
38
|
def action(**_opts)
|