lex-acp 0.1.0 → 0.1.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.
- checksums.yaml +4 -4
- data/Gemfile +8 -0
- data/lex-acp.gemspec +27 -0
- data/lib/legion/extensions/acp/actors/discovery.rb +40 -0
- data/lib/legion/extensions/acp/helpers/agent_card.rb +62 -0
- data/lib/legion/extensions/acp/helpers/capabilities.rb +55 -0
- data/lib/legion/extensions/acp/helpers/protocol.rb +86 -0
- data/lib/legion/extensions/acp/helpers/task_translator.rb +34 -0
- data/lib/legion/extensions/acp/runners/acp.rb +97 -0
- data/lib/legion/extensions/acp/runners/agent.rb +214 -0
- data/lib/legion/extensions/acp/transport/stdio.rb +68 -0
- data/lib/legion/extensions/acp/version.rb +9 -0
- data/lib/legion/extensions/acp.rb +23 -0
- data/spec/actors/discovery_spec.rb +14 -0
- data/spec/helpers/agent_card_spec.rb +41 -0
- data/spec/helpers/task_translator_spec.rb +31 -0
- data/spec/legion/extensions/acp/helpers/capabilities_spec.rb +79 -0
- data/spec/legion/extensions/acp/helpers/protocol_spec.rb +134 -0
- data/spec/legion/extensions/acp/runners/agent_spec.rb +233 -0
- data/spec/legion/extensions/acp/transport/stdio_spec.rb +125 -0
- data/spec/runners/acp_spec.rb +51 -0
- data/spec/spec_helper.rb +36 -0
- metadata +27 -61
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 57e04c7371c8aca9db15606240a5c64db9bf528eab273e5fb1a7f5e07567ebae
|
|
4
|
+
data.tar.gz: e644ef521a23b59e93e2fa0dd40427a331a6531dc19692bf3544940f2308abb4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9e0219a6a833657b12458a3db9835a10f34f014f46737f3a44a3e2d8ef642c6a09b4b68edecc1f009ad3b633f643852ded5f76709873b10f3cd74e6b36304a73
|
|
7
|
+
data.tar.gz: 20661c06e26be70e73e40e51e36a9aed6cc7d6aff960247e91f21fbfdcc5450ff16905245640584972df11fff7b8bdd555f2bf8a2dea2a72655862c92056aaf7
|
data/Gemfile
ADDED
data/lex-acp.gemspec
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
lib = File.expand_path('lib', __dir__)
|
|
4
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
|
5
|
+
require 'legion/extensions/acp/version'
|
|
6
|
+
|
|
7
|
+
Gem::Specification.new do |spec|
|
|
8
|
+
spec.name = 'lex-acp'
|
|
9
|
+
spec.version = Legion::Extensions::Acp::VERSION
|
|
10
|
+
spec.authors = ['Esity']
|
|
11
|
+
spec.email = ['matthewdiverson@gmail.com']
|
|
12
|
+
spec.summary = 'ACP agent protocol adapter for LegionIO'
|
|
13
|
+
spec.description = 'Bidirectional Agent Communication Protocol adapter — exposes Legion agents via ACP and consumes external ACP agents'
|
|
14
|
+
spec.homepage = 'https://github.com/LegionIO/lex-acp'
|
|
15
|
+
spec.license = 'MIT'
|
|
16
|
+
spec.required_ruby_version = '>= 3.4'
|
|
17
|
+
|
|
18
|
+
spec.metadata['homepage_uri'] = spec.homepage
|
|
19
|
+
spec.metadata['source_code_uri'] = 'https://github.com/LegionIO/lex-acp'
|
|
20
|
+
spec.metadata['documentation_uri'] = 'https://github.com/LegionIO/lex-acp'
|
|
21
|
+
spec.metadata['changelog_uri'] = 'https://github.com/LegionIO/lex-acp'
|
|
22
|
+
spec.metadata['bug_tracker_uri'] = 'https://github.com/LegionIO/lex-acp/issues'
|
|
23
|
+
spec.metadata['rubygems_mfa_required'] = 'true'
|
|
24
|
+
|
|
25
|
+
spec.files = Dir.glob('{lib,spec}/**/*') + %w[lex-acp.gemspec Gemfile]
|
|
26
|
+
spec.require_paths = ['lib']
|
|
27
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Acp
|
|
6
|
+
module Actors
|
|
7
|
+
class Discovery < (defined?(Legion::Extensions::Actors::Every) ? Legion::Extensions::Actors::Every : Object)
|
|
8
|
+
class << self
|
|
9
|
+
attr_accessor :time
|
|
10
|
+
end
|
|
11
|
+
self.time = 300
|
|
12
|
+
|
|
13
|
+
def runner_class
|
|
14
|
+
'Legion::Extensions::Acp::Runners::Acp'
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def runner_function
|
|
18
|
+
:discover_agents
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def use_runner?
|
|
22
|
+
true
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def check_subtask?
|
|
26
|
+
false
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def generate_task?
|
|
30
|
+
false
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def args
|
|
34
|
+
{}
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'uri'
|
|
6
|
+
|
|
7
|
+
module Legion
|
|
8
|
+
module Extensions
|
|
9
|
+
module Acp
|
|
10
|
+
module Helpers
|
|
11
|
+
module AgentCard
|
|
12
|
+
CARD_PATH = '/.well-known/agent.json'
|
|
13
|
+
FETCH_TIMEOUT = 5
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def build(name:, url:, capabilities: [], description: nil)
|
|
18
|
+
{
|
|
19
|
+
name: name,
|
|
20
|
+
description: description || 'LegionIO digital worker',
|
|
21
|
+
url: url,
|
|
22
|
+
version: '2.0',
|
|
23
|
+
protocol: 'acp/1.0',
|
|
24
|
+
capabilities: capabilities,
|
|
25
|
+
authentication: { schemes: ['bearer'] },
|
|
26
|
+
defaultInputModes: ['text/plain', 'application/json'],
|
|
27
|
+
defaultOutputModes: ['text/plain', 'application/json']
|
|
28
|
+
}
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def parse(json)
|
|
32
|
+
data = if json.is_a?(String)
|
|
33
|
+
::JSON.parse(json, symbolize_names: true)
|
|
34
|
+
else
|
|
35
|
+
json.transform_keys(&:to_sym)
|
|
36
|
+
end
|
|
37
|
+
return nil unless data[:name] && data[:url]
|
|
38
|
+
|
|
39
|
+
data
|
|
40
|
+
rescue StandardError
|
|
41
|
+
nil
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def fetch(base_url, timeout: FETCH_TIMEOUT)
|
|
45
|
+
uri = URI.join("#{base_url.chomp('/')}/", CARD_PATH.delete_prefix('/'))
|
|
46
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
47
|
+
http.use_ssl = uri.scheme == 'https'
|
|
48
|
+
http.open_timeout = timeout
|
|
49
|
+
http.read_timeout = timeout
|
|
50
|
+
|
|
51
|
+
response = http.get(uri.request_uri)
|
|
52
|
+
return nil unless response.is_a?(Net::HTTPSuccess)
|
|
53
|
+
|
|
54
|
+
parse(response.body)
|
|
55
|
+
rescue StandardError
|
|
56
|
+
nil
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Acp
|
|
6
|
+
module Helpers
|
|
7
|
+
module Capabilities
|
|
8
|
+
PROTOCOL_VERSION = 1
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def agent_info
|
|
13
|
+
version = defined?(Legion::VERSION) ? Legion::VERSION : Acp::VERSION
|
|
14
|
+
{
|
|
15
|
+
name: 'LegionIO',
|
|
16
|
+
version: version,
|
|
17
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
18
|
+
capabilities: agent_capabilities,
|
|
19
|
+
authMethods: []
|
|
20
|
+
}
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def agent_capabilities
|
|
24
|
+
caps = { loadSession: true }
|
|
25
|
+
|
|
26
|
+
if llm_available?
|
|
27
|
+
caps[:promptCapabilities] = {
|
|
28
|
+
supportedMediaTypes: ['text/plain'],
|
|
29
|
+
supportedStopReasons: %w[end_turn cancelled error]
|
|
30
|
+
}
|
|
31
|
+
caps[:sessionCapabilities] = {
|
|
32
|
+
supportedModes: %w[code chat]
|
|
33
|
+
}
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
caps
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def custom_commands
|
|
40
|
+
[
|
|
41
|
+
{ name: 'run_task', description: 'Invoke a Legion runner function (e.g. extension.runner.function key:val)' },
|
|
42
|
+
{ name: 'list_extensions', description: 'List loaded Legion extensions' },
|
|
43
|
+
{ name: 'query_workers', description: 'Show digital worker status' },
|
|
44
|
+
{ name: 'list_schedules', description: 'List scheduled jobs' }
|
|
45
|
+
]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def llm_available?
|
|
49
|
+
defined?(Legion::LLM) && Legion::LLM.respond_to?(:started?) && Legion::LLM.started?
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module Extensions
|
|
7
|
+
module Acp
|
|
8
|
+
module Helpers
|
|
9
|
+
module Protocol
|
|
10
|
+
JSONRPC_VERSION = '2.0'
|
|
11
|
+
|
|
12
|
+
PARSE_ERROR = -32_700
|
|
13
|
+
INVALID_REQUEST = -32_600
|
|
14
|
+
METHOD_NOT_FOUND = -32_601
|
|
15
|
+
INVALID_PARAMS = -32_602
|
|
16
|
+
INTERNAL_ERROR = -32_603
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
def parse(json_string)
|
|
21
|
+
data = ::JSON.parse(json_string, symbolize_names: true)
|
|
22
|
+
return error_response(id: nil, code: INVALID_REQUEST, message: 'Invalid Request') unless data.is_a?(Hash)
|
|
23
|
+
return error_response(id: data[:id], code: INVALID_REQUEST, message: 'Invalid Request') unless data[:jsonrpc] == JSONRPC_VERSION
|
|
24
|
+
|
|
25
|
+
return error_response(id: data[:id], code: INVALID_REQUEST, message: 'Invalid Request') if data.key?(:id) && !data.key?(:method)
|
|
26
|
+
|
|
27
|
+
data
|
|
28
|
+
rescue ::JSON::ParserError
|
|
29
|
+
error_response(id: nil, code: PARSE_ERROR, message: 'Parse error')
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def request(id:, method:, params: nil)
|
|
33
|
+
msg = { jsonrpc: JSONRPC_VERSION, id: id, method: method }
|
|
34
|
+
msg[:params] = params if params
|
|
35
|
+
msg
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def response(id:, result:)
|
|
39
|
+
{ jsonrpc: JSONRPC_VERSION, id: id, result: result }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def error_response(id:, code:, message:, data: nil)
|
|
43
|
+
err = { code: code, message: message }
|
|
44
|
+
err[:data] = data if data
|
|
45
|
+
{ jsonrpc: JSONRPC_VERSION, id: id, error: err }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def notification(method:, params: nil)
|
|
49
|
+
msg = { jsonrpc: JSONRPC_VERSION, method: method }
|
|
50
|
+
msg[:params] = params if params
|
|
51
|
+
msg
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def serialize(hash)
|
|
55
|
+
::JSON.generate(hash)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def read(io)
|
|
59
|
+
loop do
|
|
60
|
+
line = io.gets
|
|
61
|
+
return nil if line.nil?
|
|
62
|
+
|
|
63
|
+
line = line.strip
|
|
64
|
+
next if line.empty?
|
|
65
|
+
|
|
66
|
+
return parse(line)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def write(io, message)
|
|
71
|
+
io.puts(serialize(message))
|
|
72
|
+
io.flush
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def dispatch(message, handlers)
|
|
76
|
+
method_name = message[:method]
|
|
77
|
+
handler = handlers[method_name]
|
|
78
|
+
return error_response(id: message[:id], code: METHOD_NOT_FOUND, message: "Method not found: #{method_name}") unless handler
|
|
79
|
+
|
|
80
|
+
handler.call(message)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Acp
|
|
6
|
+
module Helpers
|
|
7
|
+
module TaskTranslator
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def acp_to_legion(acp_task)
|
|
11
|
+
input = acp_task[:input] || acp_task['input'] || {}
|
|
12
|
+
{
|
|
13
|
+
payload: input.transform_keys(&:to_sym),
|
|
14
|
+
source: 'acp',
|
|
15
|
+
runner_class: acp_task[:runner_class],
|
|
16
|
+
function: acp_task[:function]
|
|
17
|
+
}.compact
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def legion_to_acp(legion_result)
|
|
21
|
+
success = legion_result[:success]
|
|
22
|
+
{
|
|
23
|
+
task_id: legion_result[:task_id],
|
|
24
|
+
status: success ? 'completed' : 'failed',
|
|
25
|
+
output: { data: legion_result[:result] || legion_result[:reason] },
|
|
26
|
+
created_at: legion_result[:created_at]&.to_s,
|
|
27
|
+
completed_at: Time.now.utc.to_s
|
|
28
|
+
}
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'uri'
|
|
6
|
+
require_relative '../helpers/agent_card'
|
|
7
|
+
require_relative '../helpers/task_translator'
|
|
8
|
+
|
|
9
|
+
module Legion
|
|
10
|
+
module Extensions
|
|
11
|
+
module Acp
|
|
12
|
+
module Runners
|
|
13
|
+
module Acp
|
|
14
|
+
def invoke_agent(agent_url:, task:, timeout: 30, **)
|
|
15
|
+
card = Helpers::AgentCard.fetch(agent_url)
|
|
16
|
+
return { success: false, reason: :unreachable } unless card
|
|
17
|
+
|
|
18
|
+
register_in_mesh(card, agent_url)
|
|
19
|
+
response = submit_acp_task(card[:url], task, timeout)
|
|
20
|
+
{ success: true, task_id: response[:task_id], result: response[:output] }
|
|
21
|
+
rescue StandardError => e
|
|
22
|
+
{ success: false, reason: :error, message: e.message }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def register_external(agent_url:, **)
|
|
26
|
+
card = Helpers::AgentCard.fetch(agent_url)
|
|
27
|
+
return { success: false, reason: :unreachable } unless card
|
|
28
|
+
|
|
29
|
+
register_in_mesh(card, agent_url)
|
|
30
|
+
{ success: true, agent_id: card[:name] }
|
|
31
|
+
rescue StandardError => e
|
|
32
|
+
{ success: false, reason: :error, message: e.message }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def list_agents(**)
|
|
36
|
+
agents = mesh_registry.all_agents
|
|
37
|
+
{ success: true, agents: agents, count: agents.size }
|
|
38
|
+
rescue StandardError => e
|
|
39
|
+
{ success: false, reason: :error, message: e.message }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def discover_agents(**)
|
|
43
|
+
urls = acp_settings[:agents] || []
|
|
44
|
+
registered = 0
|
|
45
|
+
urls.each do |url|
|
|
46
|
+
result = register_external(agent_url: url)
|
|
47
|
+
registered += 1 if result[:success]
|
|
48
|
+
end
|
|
49
|
+
{ success: true, scanned: urls.size, registered: registered }
|
|
50
|
+
rescue StandardError => e
|
|
51
|
+
{ success: false, reason: :error, message: e.message }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def submit_acp_task(agent_url, task, timeout)
|
|
57
|
+
uri = URI.join("#{agent_url.chomp('/')}/", 'tasks')
|
|
58
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
59
|
+
http.use_ssl = uri.scheme == 'https'
|
|
60
|
+
http.open_timeout = timeout
|
|
61
|
+
http.read_timeout = timeout
|
|
62
|
+
|
|
63
|
+
request = Net::HTTP::Post.new(uri.request_uri, 'Content-Type' => 'application/json')
|
|
64
|
+
request.body = ::JSON.dump(task)
|
|
65
|
+
response = http.request(request)
|
|
66
|
+
|
|
67
|
+
::JSON.parse(response.body, symbolize_names: true)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def register_in_mesh(card, agent_url)
|
|
71
|
+
return unless defined?(Legion::Extensions::Mesh)
|
|
72
|
+
|
|
73
|
+
mesh = mesh_registry
|
|
74
|
+
mesh.register_agent(
|
|
75
|
+
card[:name],
|
|
76
|
+
capabilities: (card[:capabilities] || []).map(&:to_sym),
|
|
77
|
+
endpoint: agent_url,
|
|
78
|
+
source: :acp
|
|
79
|
+
)
|
|
80
|
+
rescue StandardError
|
|
81
|
+
nil
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def mesh_registry
|
|
85
|
+
@mesh_registry ||= (Legion::Extensions::Mesh::Helpers::Registry.new if defined?(Legion::Extensions::Mesh::Helpers::Registry))
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def acp_settings
|
|
89
|
+
Legion::Settings[:acp] || {}
|
|
90
|
+
rescue StandardError
|
|
91
|
+
{}
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module Extensions
|
|
7
|
+
module Acp
|
|
8
|
+
module Runners
|
|
9
|
+
class Agent
|
|
10
|
+
attr_reader :client_info, :sessions
|
|
11
|
+
|
|
12
|
+
def initialize(transport:)
|
|
13
|
+
@transport = transport
|
|
14
|
+
@client_info = {}
|
|
15
|
+
@sessions = {}
|
|
16
|
+
@handlers = build_handler_map
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def dispatch(message)
|
|
20
|
+
method_name = message[:method]
|
|
21
|
+
handler = @handlers[method_name]
|
|
22
|
+
unless handler
|
|
23
|
+
return Helpers::Protocol.error_response(
|
|
24
|
+
id: message[:id],
|
|
25
|
+
code: Helpers::Protocol::METHOD_NOT_FOUND,
|
|
26
|
+
message: "Method not found: #{method_name}"
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
handler.call(message)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def handle_initialize(msg)
|
|
34
|
+
@client_info = msg.dig(:params, :clientInfo) || {}
|
|
35
|
+
@client_capabilities = msg.dig(:params, :capabilities) || {}
|
|
36
|
+
|
|
37
|
+
info = Helpers::Capabilities.agent_info
|
|
38
|
+
|
|
39
|
+
@transport.send_notification('session/update', {
|
|
40
|
+
commands: Helpers::Capabilities.custom_commands
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
info
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def handle_session_new(_msg)
|
|
47
|
+
session_id = SecureRandom.uuid
|
|
48
|
+
@sessions[session_id] = {
|
|
49
|
+
id: session_id,
|
|
50
|
+
created_at: Time.now.utc.iso8601,
|
|
51
|
+
mode: 'code',
|
|
52
|
+
config: {},
|
|
53
|
+
cancelled: false
|
|
54
|
+
}
|
|
55
|
+
{ sessionId: session_id }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def handle_session_list(_msg)
|
|
59
|
+
session_list = @sessions.map do |id, session|
|
|
60
|
+
{ sessionId: id, createdAt: session[:created_at], mode: session[:mode] }
|
|
61
|
+
end
|
|
62
|
+
{ sessions: session_list }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def handle_session_cancel(msg)
|
|
66
|
+
session_id = msg.dig(:params, :sessionId)
|
|
67
|
+
session = @sessions[session_id]
|
|
68
|
+
return { error: "Session not found: #{session_id}" } unless session
|
|
69
|
+
|
|
70
|
+
session[:cancelled] = true
|
|
71
|
+
{ success: true }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def handle_session_set_mode(msg)
|
|
75
|
+
session_id = msg.dig(:params, :sessionId)
|
|
76
|
+
session = @sessions[session_id]
|
|
77
|
+
return { error: "Session not found: #{session_id}" } unless session
|
|
78
|
+
|
|
79
|
+
mode = msg.dig(:params, :mode)
|
|
80
|
+
session[:mode] = mode
|
|
81
|
+
{ success: true, mode: mode }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def handle_session_set_config_option(msg)
|
|
85
|
+
session_id = msg.dig(:params, :sessionId)
|
|
86
|
+
session = @sessions[session_id]
|
|
87
|
+
return { error: "Session not found: #{session_id}" } unless session
|
|
88
|
+
|
|
89
|
+
key = msg.dig(:params, :key)
|
|
90
|
+
value = msg.dig(:params, :value)
|
|
91
|
+
session[:config][key.to_s] = value
|
|
92
|
+
{ success: true }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def handle_session_prompt(msg)
|
|
96
|
+
session_id = msg.dig(:params, :sessionId)
|
|
97
|
+
session = @sessions[session_id]
|
|
98
|
+
return { error: "Session not found: #{session_id}" } unless session
|
|
99
|
+
|
|
100
|
+
user_text = msg.dig(:params, :prompt, :userMessage, :content) || ''
|
|
101
|
+
|
|
102
|
+
return handle_command(session, user_text) if user_text.start_with?('/')
|
|
103
|
+
|
|
104
|
+
return { error: 'LLM not available — prompt handling requires legion-llm' } unless Helpers::Capabilities.llm_available?
|
|
105
|
+
|
|
106
|
+
session[:cancelled] = false
|
|
107
|
+
chat = Legion::LLM.chat(model: session[:config]['model'], provider: session[:config]['provider']&.to_sym)
|
|
108
|
+
chat.with_instructions("You are LegionIO, an async job engine coding assistant. Mode: #{session[:mode]}.")
|
|
109
|
+
|
|
110
|
+
full_content = +''
|
|
111
|
+
response = chat.ask(user_text) do |chunk|
|
|
112
|
+
next if session[:cancelled]
|
|
113
|
+
|
|
114
|
+
text = chunk.respond_to?(:content) ? chunk.content : chunk.to_s
|
|
115
|
+
next if text.nil? || text.empty?
|
|
116
|
+
|
|
117
|
+
@transport.send_notification('session/update', { contentBlock: { type: 'text', text: text } })
|
|
118
|
+
full_content << text
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
final_content = if response.respond_to?(:content) && !response.content.nil?
|
|
122
|
+
response.content
|
|
123
|
+
else
|
|
124
|
+
full_content
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
stop_reason = session[:cancelled] ? 'cancelled' : 'end_turn'
|
|
128
|
+
{ stopReason: stop_reason, content: final_content }
|
|
129
|
+
rescue StandardError => e
|
|
130
|
+
{ error: "Prompt failed: #{e.message}", stopReason: 'error' }
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
private
|
|
134
|
+
|
|
135
|
+
def handle_command(_session, text)
|
|
136
|
+
parts = text.sub(%r{^/}, '').split(' ', 2)
|
|
137
|
+
command = parts[0]
|
|
138
|
+
args = parts[1]
|
|
139
|
+
|
|
140
|
+
result = case command
|
|
141
|
+
when 'run_task' then execute_run_task(args)
|
|
142
|
+
when 'list_extensions' then execute_list_extensions
|
|
143
|
+
when 'query_workers' then execute_query_workers
|
|
144
|
+
when 'list_schedules' then execute_list_schedules
|
|
145
|
+
else
|
|
146
|
+
{ content: "Unknown command: #{command}" }
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
content = result.is_a?(Hash) ? ::JSON.generate(result) : result.to_s
|
|
150
|
+
@transport.send_notification('session/update', { contentBlock: { type: 'text', text: content } })
|
|
151
|
+
{ stopReason: 'end_turn', content: content }
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def execute_run_task(args)
|
|
155
|
+
return { error: 'Ingress not available' } unless defined?(Legion::Ingress)
|
|
156
|
+
return { error: 'Missing task arguments' } if args.nil? || args.empty?
|
|
157
|
+
|
|
158
|
+
parts = args.split
|
|
159
|
+
runner_path = parts.shift
|
|
160
|
+
path_parts = runner_path.split('.')
|
|
161
|
+
return { error: 'Invalid runner path — expected extension.runner.function' } unless path_parts.size >= 3
|
|
162
|
+
|
|
163
|
+
function = path_parts.pop
|
|
164
|
+
runner_class = path_parts.map { |p| p.split('_').map(&:capitalize).join }.join('::')
|
|
165
|
+
|
|
166
|
+
payload = {}
|
|
167
|
+
parts.each do |pair|
|
|
168
|
+
key, value = pair.split(':', 2)
|
|
169
|
+
payload[key.to_sym] = value if key && value
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
Legion::Ingress.run(runner_class: runner_class, function: function, payload: payload, source: 'acp')
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def execute_list_extensions
|
|
176
|
+
if defined?(Legion::Extensions) && Legion::Extensions.respond_to?(:loaded_extensions)
|
|
177
|
+
Legion::Extensions.loaded_extensions.map { |e| e.respond_to?(:name) ? e.name : e.to_s }
|
|
178
|
+
else
|
|
179
|
+
[]
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def execute_query_workers
|
|
184
|
+
if defined?(Legion::DigitalWorker::Registry)
|
|
185
|
+
Legion::DigitalWorker::Registry.all.map { |w| { id: w.id, name: w.name, status: w.status } }
|
|
186
|
+
else
|
|
187
|
+
[]
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def execute_list_schedules
|
|
192
|
+
if defined?(Legion::Ingress)
|
|
193
|
+
Legion::Ingress.run(runner_class: 'Runners::Scheduler', function: 'list', payload: {}, source: 'acp')
|
|
194
|
+
else
|
|
195
|
+
[]
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def build_handler_map
|
|
200
|
+
{
|
|
201
|
+
'initialize' => method(:handle_initialize),
|
|
202
|
+
'session/new' => method(:handle_session_new),
|
|
203
|
+
'session/list' => method(:handle_session_list),
|
|
204
|
+
'session/cancel' => method(:handle_session_cancel),
|
|
205
|
+
'session/set_mode' => method(:handle_session_set_mode),
|
|
206
|
+
'session/set_config_option' => method(:handle_session_set_config_option),
|
|
207
|
+
'session/prompt' => method(:handle_session_prompt)
|
|
208
|
+
}
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|