lex-mesh 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 +7 -0
- data/Gemfile +10 -0
- data/lex-mesh.gemspec +29 -0
- data/lib/legion/extensions/mesh/actors/heartbeat.rb +45 -0
- data/lib/legion/extensions/mesh/client.rb +23 -0
- data/lib/legion/extensions/mesh/helpers/preference_profile.rb +158 -0
- data/lib/legion/extensions/mesh/helpers/registry.rb +79 -0
- data/lib/legion/extensions/mesh/helpers/topology.rb +28 -0
- data/lib/legion/extensions/mesh/runners/mesh.rb +67 -0
- data/lib/legion/extensions/mesh/version.rb +9 -0
- data/lib/legion/extensions/mesh.rb +15 -0
- data/spec/legion/extensions/mesh/actors/heartbeat_spec.rb +70 -0
- data/spec/legion/extensions/mesh/client_spec.rb +15 -0
- data/spec/legion/extensions/mesh/helpers/preference_profile_spec.rb +128 -0
- data/spec/legion/extensions/mesh/helpers/registry_spec.rb +292 -0
- data/spec/legion/extensions/mesh/helpers/topology_spec.rb +91 -0
- data/spec/legion/extensions/mesh/runners/mesh_spec.rb +68 -0
- data/spec/spec_helper.rb +20 -0
- metadata +78 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 6b30e6f5f2a0518bde780c8b661c910fe26ec503ddb285ce60883120f8826825
|
|
4
|
+
data.tar.gz: 456c2c731747543cebb0515abfc44b497382f6c3ca10eb0ad52b21f5816367f8
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 3b6044cd55148c45c91ce715476a0566a8f2aa6a4aafba4607d0049b80eeffe3578e31ceb6e53a5828b9e857335b0a72a6f4eb3af170fd2ca60c4ce4047da534
|
|
7
|
+
data.tar.gz: c8002ef1a93733b0e8f7848bf4db6903c90146cd67882d015c6b2d229b3f03dcff3d2abc2fcdeefb0c7b1ab043d1b5a6c172a129475f1044e1063dad6273d63e
|
data/Gemfile
ADDED
data/lex-mesh.gemspec
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'lib/legion/extensions/mesh/version'
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = 'lex-mesh'
|
|
7
|
+
spec.version = Legion::Extensions::Mesh::VERSION
|
|
8
|
+
spec.authors = ['Esity']
|
|
9
|
+
spec.email = ['matthewdiverson@gmail.com']
|
|
10
|
+
|
|
11
|
+
spec.summary = 'LEX Mesh'
|
|
12
|
+
spec.description = 'Agent-to-agent mesh communication protocol for brain-modeled agentic AI'
|
|
13
|
+
spec.homepage = 'https://github.com/LegionIO/lex-mesh'
|
|
14
|
+
spec.license = 'MIT'
|
|
15
|
+
spec.required_ruby_version = '>= 3.4'
|
|
16
|
+
|
|
17
|
+
spec.metadata['homepage_uri'] = spec.homepage
|
|
18
|
+
spec.metadata['source_code_uri'] = 'https://github.com/LegionIO/lex-mesh'
|
|
19
|
+
spec.metadata['documentation_uri'] = 'https://github.com/LegionIO/lex-mesh'
|
|
20
|
+
spec.metadata['changelog_uri'] = 'https://github.com/LegionIO/lex-mesh'
|
|
21
|
+
spec.metadata['bug_tracker_uri'] = 'https://github.com/LegionIO/lex-mesh/issues'
|
|
22
|
+
spec.metadata['rubygems_mfa_required'] = 'true'
|
|
23
|
+
|
|
24
|
+
spec.files = Dir.chdir(File.expand_path(__dir__)) do
|
|
25
|
+
Dir.glob('{lib,spec}/**/*') + %w[lex-mesh.gemspec Gemfile]
|
|
26
|
+
end
|
|
27
|
+
spec.require_paths = ['lib']
|
|
28
|
+
spec.add_development_dependency 'legion-gaia'
|
|
29
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/actors/every'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module Extensions
|
|
7
|
+
module Mesh
|
|
8
|
+
module Actor
|
|
9
|
+
class Heartbeat < Legion::Extensions::Actors::Every
|
|
10
|
+
def runner_class
|
|
11
|
+
Legion::Extensions::Mesh::Runners::Mesh
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def runner_function
|
|
15
|
+
'heartbeat'
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def time
|
|
19
|
+
10
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def run_now?
|
|
23
|
+
true
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def use_runner?
|
|
27
|
+
false
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def check_subtask?
|
|
31
|
+
false
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def generate_task?
|
|
35
|
+
false
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def args
|
|
39
|
+
{ agent_id: Legion::Settings[:client][:name] }
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/mesh/helpers/topology'
|
|
4
|
+
require 'legion/extensions/mesh/helpers/registry'
|
|
5
|
+
require 'legion/extensions/mesh/runners/mesh'
|
|
6
|
+
|
|
7
|
+
module Legion
|
|
8
|
+
module Extensions
|
|
9
|
+
module Mesh
|
|
10
|
+
class Client
|
|
11
|
+
include Runners::Mesh
|
|
12
|
+
|
|
13
|
+
def initialize(**)
|
|
14
|
+
@mesh_registry = Helpers::Registry.new
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
attr_reader :mesh_registry
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Mesh
|
|
6
|
+
module Helpers
|
|
7
|
+
module PreferenceProfile
|
|
8
|
+
DEFAULTS = {
|
|
9
|
+
verbosity: :normal,
|
|
10
|
+
tone: :professional,
|
|
11
|
+
format: :structured,
|
|
12
|
+
technical_depth: :moderate
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
VALID_VALUES = {
|
|
16
|
+
'verbosity' => %i[terse concise normal detailed verbose],
|
|
17
|
+
'tone' => %i[casual conversational professional formal],
|
|
18
|
+
'format' => %i[plain structured markdown],
|
|
19
|
+
'technical_depth' => %i[high_level moderate deep implementation]
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
SOURCE_CONFIDENCE = {
|
|
23
|
+
'explicit' => 1.0,
|
|
24
|
+
'preference_learning' => 0.75,
|
|
25
|
+
'personality' => 0.4,
|
|
26
|
+
'defaults' => 0.0
|
|
27
|
+
}.freeze
|
|
28
|
+
|
|
29
|
+
INSTRUCTION_MAP = {
|
|
30
|
+
verbosity: {
|
|
31
|
+
terse: 'Keep responses extremely short — one or two sentences max.',
|
|
32
|
+
concise: 'Keep responses brief and to the point.',
|
|
33
|
+
detailed: 'Provide thorough, detailed responses.',
|
|
34
|
+
verbose: 'Be comprehensive and expansive in your responses.'
|
|
35
|
+
},
|
|
36
|
+
tone: {
|
|
37
|
+
casual: 'Use casual, friendly language.',
|
|
38
|
+
conversational: 'Use a warm, conversational tone.',
|
|
39
|
+
formal: 'Use formal, professional language.'
|
|
40
|
+
},
|
|
41
|
+
format: {
|
|
42
|
+
plain: 'Use plain text without formatting.',
|
|
43
|
+
markdown: 'Use rich markdown formatting with headers and code blocks.'
|
|
44
|
+
},
|
|
45
|
+
technical_depth: {
|
|
46
|
+
high_level: 'Keep explanations high-level, avoid implementation details.',
|
|
47
|
+
deep: 'Include technical depth and implementation details.',
|
|
48
|
+
implementation: 'Include implementation details, code examples, and edge cases.'
|
|
49
|
+
}
|
|
50
|
+
}.freeze
|
|
51
|
+
|
|
52
|
+
module_function
|
|
53
|
+
|
|
54
|
+
def resolve(owner_id:, overrides: nil, personality: nil)
|
|
55
|
+
prefs = DEFAULTS.dup
|
|
56
|
+
sources = Set.new([:defaults])
|
|
57
|
+
custom = {}
|
|
58
|
+
|
|
59
|
+
traces = fetch_preference_traces(owner_id: owner_id)
|
|
60
|
+
all_prefs = traces + (overrides || [])
|
|
61
|
+
|
|
62
|
+
all_prefs.sort_by { |p| p[:confidence] || SOURCE_CONFIDENCE.fetch(p[:source].to_s, 0.0) }.each do |pref|
|
|
63
|
+
domain = pref[:domain].to_s
|
|
64
|
+
value = pref[:value].to_s
|
|
65
|
+
source = pref[:source].to_s
|
|
66
|
+
|
|
67
|
+
if domain.start_with?('custom:')
|
|
68
|
+
custom_key = domain.sub('custom:', '')
|
|
69
|
+
custom[custom_key] = value
|
|
70
|
+
sources << source.to_sym
|
|
71
|
+
elsif VALID_VALUES.key?(domain) && VALID_VALUES[domain].include?(value.to_sym)
|
|
72
|
+
prefs[domain.to_sym] = value.to_sym
|
|
73
|
+
sources << source.to_sym
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
prefs.merge(
|
|
78
|
+
personality: personality,
|
|
79
|
+
custom: custom,
|
|
80
|
+
sources: sources.to_a,
|
|
81
|
+
resolved_at: Time.now
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def store_preference(owner_id:, domain:, value:, source: 'explicit')
|
|
86
|
+
return { stored: false, reason: :memory_not_available } unless memory_available?
|
|
87
|
+
|
|
88
|
+
confidence = SOURCE_CONFIDENCE.fetch(source.to_s, 0.5)
|
|
89
|
+
memory_runner.store_trace(
|
|
90
|
+
type: :semantic,
|
|
91
|
+
content_payload: { domain: domain, value: value, source: source }.to_s,
|
|
92
|
+
domain_tags: ['preference', "preference:#{domain}", "owner:#{owner_id}"],
|
|
93
|
+
origin: :direct_experience,
|
|
94
|
+
confidence: confidence
|
|
95
|
+
)
|
|
96
|
+
{ stored: true, domain: domain, value: value, source: source }
|
|
97
|
+
rescue StandardError => e
|
|
98
|
+
{ stored: false, reason: :error, message: e.message }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def clear_preferences(owner_id:, source: nil)
|
|
102
|
+
return { cleared: false, reason: :memory_not_available } unless memory_available?
|
|
103
|
+
|
|
104
|
+
{ cleared: true, owner_id: owner_id, source: source }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def preference_instructions(profile:)
|
|
108
|
+
lines = []
|
|
109
|
+
|
|
110
|
+
INSTRUCTION_MAP.each do |domain, value_map|
|
|
111
|
+
current = profile[domain]
|
|
112
|
+
default = DEFAULTS[domain]
|
|
113
|
+
next if current == default
|
|
114
|
+
|
|
115
|
+
instruction = value_map[current]
|
|
116
|
+
lines << instruction if instruction
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
return nil if lines.empty?
|
|
120
|
+
|
|
121
|
+
lines.join(' ')
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def memory_available?
|
|
125
|
+
defined?(Legion::Extensions::Memory::Runners::Traces)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def memory_runner
|
|
129
|
+
Object.new.extend(Legion::Extensions::Memory::Runners::Traces)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def fetch_preference_traces(owner_id:)
|
|
133
|
+
return [] unless memory_available?
|
|
134
|
+
|
|
135
|
+
traces = memory_runner.retrieve_by_domain(domain_tag: "owner:#{owner_id}")
|
|
136
|
+
traces.select { |t| t[:domain_tags]&.include?('preference') }.filter_map do |trace|
|
|
137
|
+
parse_preference_trace(trace)
|
|
138
|
+
end
|
|
139
|
+
rescue StandardError
|
|
140
|
+
[]
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def parse_preference_trace(trace)
|
|
144
|
+
payload = trace[:content_payload]
|
|
145
|
+
return nil unless payload.is_a?(String)
|
|
146
|
+
|
|
147
|
+
match = payload.match(/domain.*?(\w+).*?value.*?(\w+).*?source.*?(\w+)/)
|
|
148
|
+
return nil unless match
|
|
149
|
+
|
|
150
|
+
{ domain: match[1], value: match[2], source: match[3], confidence: trace[:confidence] }
|
|
151
|
+
rescue StandardError
|
|
152
|
+
nil
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Mesh
|
|
6
|
+
module Helpers
|
|
7
|
+
class Registry
|
|
8
|
+
attr_reader :agents, :capabilities, :messages
|
|
9
|
+
|
|
10
|
+
def initialize
|
|
11
|
+
@agents = {}
|
|
12
|
+
@capabilities = Hash.new { |h, k| h[k] = [] }
|
|
13
|
+
@messages = []
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def register_agent(agent_id, capabilities: [], endpoint: nil)
|
|
17
|
+
@agents[agent_id] = {
|
|
18
|
+
agent_id: agent_id,
|
|
19
|
+
capabilities: capabilities,
|
|
20
|
+
endpoint: endpoint,
|
|
21
|
+
registered_at: Time.now.utc,
|
|
22
|
+
last_seen: Time.now.utc,
|
|
23
|
+
status: :online
|
|
24
|
+
}
|
|
25
|
+
capabilities.each { |cap| @capabilities[cap] << agent_id }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def unregister_agent(agent_id)
|
|
29
|
+
agent = @agents.delete(agent_id)
|
|
30
|
+
return nil unless agent
|
|
31
|
+
|
|
32
|
+
agent[:capabilities].each { |cap| @capabilities[cap].delete(agent_id) }
|
|
33
|
+
agent
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def heartbeat(agent_id)
|
|
37
|
+
agent = @agents[agent_id]
|
|
38
|
+
return nil unless agent
|
|
39
|
+
|
|
40
|
+
agent[:last_seen] = Time.now.utc
|
|
41
|
+
agent[:status] = :online
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def find_by_capability(capability)
|
|
45
|
+
agent_ids = @capabilities[capability] || []
|
|
46
|
+
agent_ids.filter_map { |id| @agents[id] }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def route_message(from:, to: nil, capability: nil, pattern: :unicast, payload: {})
|
|
50
|
+
msg = {
|
|
51
|
+
from: from, to: to, capability: capability,
|
|
52
|
+
pattern: pattern, payload: payload, at: Time.now.utc
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
targets = case pattern
|
|
56
|
+
when :unicast then to ? [@agents[to]].compact : []
|
|
57
|
+
when :multicast then capability ? find_by_capability(capability) : []
|
|
58
|
+
when :broadcast then @agents.values
|
|
59
|
+
else []
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
msg[:delivered_to] = targets.map { |t| t[:agent_id] }
|
|
63
|
+
@messages << msg
|
|
64
|
+
@messages.shift while @messages.size > 1000
|
|
65
|
+
msg
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def online_agents
|
|
69
|
+
@agents.values.select { |a| a[:status] == :online }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def count
|
|
73
|
+
@agents.size
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Mesh
|
|
6
|
+
module Helpers
|
|
7
|
+
module Topology
|
|
8
|
+
PROTOCOLS = %i[grpc websocket rest].freeze
|
|
9
|
+
PATTERNS = %i[unicast multicast broadcast].freeze
|
|
10
|
+
|
|
11
|
+
MESH_SILENCE_TIMEOUT = 30 # seconds
|
|
12
|
+
TRUST_CONSIDER_THRESHOLD = 0.3
|
|
13
|
+
MAX_HOPS = 3
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def valid_protocol?(protocol)
|
|
18
|
+
PROTOCOLS.include?(protocol)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def valid_pattern?(pattern)
|
|
22
|
+
PATTERNS.include?(pattern)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Mesh
|
|
6
|
+
module Runners
|
|
7
|
+
module Mesh
|
|
8
|
+
include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers) &&
|
|
9
|
+
Legion::Extensions::Helpers.const_defined?(:Lex)
|
|
10
|
+
|
|
11
|
+
def register(agent_id:, capabilities: [], endpoint: nil, **)
|
|
12
|
+
mesh_registry.register_agent(agent_id, capabilities: capabilities, endpoint: endpoint)
|
|
13
|
+
Legion::Logging.info "[mesh] registered: agent=#{agent_id} capabilities=#{capabilities.join(',')}"
|
|
14
|
+
{ registered: true, agent_id: agent_id }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def unregister(agent_id:, **)
|
|
18
|
+
result = mesh_registry.unregister_agent(agent_id)
|
|
19
|
+
if result
|
|
20
|
+
Legion::Logging.info "[mesh] unregistered: agent=#{agent_id}"
|
|
21
|
+
{ unregistered: true }
|
|
22
|
+
else
|
|
23
|
+
Legion::Logging.debug "[mesh] unregister failed: agent=#{agent_id} not found"
|
|
24
|
+
{ error: :not_found }
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def heartbeat(agent_id:, **)
|
|
29
|
+
result = mesh_registry.heartbeat(agent_id)
|
|
30
|
+
Legion::Logging.debug "[mesh] heartbeat: agent=#{agent_id} alive=#{!result.nil?}"
|
|
31
|
+
result ? { alive: true } : { error: :not_registered }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def send_message(from:, to: nil, capability: nil, pattern: :unicast, payload: {}, **) # rubocop:disable Metrics/ParameterLists
|
|
35
|
+
return { error: :invalid_pattern } unless Helpers::Topology.valid_pattern?(pattern)
|
|
36
|
+
|
|
37
|
+
msg = mesh_registry.route_message(from: from, to: to, capability: capability,
|
|
38
|
+
pattern: pattern, payload: payload)
|
|
39
|
+
count = msg[:delivered_to].size
|
|
40
|
+
Legion::Logging.debug "[mesh] message: from=#{from} pattern=#{pattern} delivered=#{count} to=#{msg[:delivered_to].join(',')}"
|
|
41
|
+
{ sent: true, delivered_to: msg[:delivered_to], count: count }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def find_agents(capability:, **)
|
|
45
|
+
agents = mesh_registry.find_by_capability(capability)
|
|
46
|
+
Legion::Logging.debug "[mesh] find: capability=#{capability} found=#{agents.size}"
|
|
47
|
+
{ agents: agents.map { |a| a[:agent_id] }, count: agents.size }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def mesh_status(**)
|
|
51
|
+
online = mesh_registry.online_agents
|
|
52
|
+
total = mesh_registry.count
|
|
53
|
+
msgs = mesh_registry.messages.size
|
|
54
|
+
Legion::Logging.debug "[mesh] status: total=#{total} online=#{online.size} messages=#{msgs}"
|
|
55
|
+
{ total: total, online: online.size, message_count: msgs }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def mesh_registry
|
|
61
|
+
@mesh_registry ||= Helpers::Registry.new
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/mesh/version'
|
|
4
|
+
require 'legion/extensions/mesh/helpers/topology'
|
|
5
|
+
require 'legion/extensions/mesh/helpers/registry'
|
|
6
|
+
require 'legion/extensions/mesh/helpers/preference_profile'
|
|
7
|
+
require 'legion/extensions/mesh/runners/mesh'
|
|
8
|
+
|
|
9
|
+
module Legion
|
|
10
|
+
module Extensions
|
|
11
|
+
module Mesh
|
|
12
|
+
extend Legion::Extensions::Core if Legion::Extensions.const_defined? :Core
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Stub the framework actor base class since legionio gem is not available in test
|
|
4
|
+
module Legion
|
|
5
|
+
module Extensions
|
|
6
|
+
module Actors
|
|
7
|
+
class Every # rubocop:disable Lint/EmptyClass
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Intercept the require in the actor file so it doesn't fail
|
|
14
|
+
$LOADED_FEATURES << 'legion/extensions/actors/every'
|
|
15
|
+
|
|
16
|
+
require 'legion/extensions/mesh/actors/heartbeat'
|
|
17
|
+
|
|
18
|
+
RSpec.describe Legion::Extensions::Mesh::Actor::Heartbeat do
|
|
19
|
+
subject(:actor) { described_class.new }
|
|
20
|
+
|
|
21
|
+
describe '#runner_class' do
|
|
22
|
+
it 'returns the Mesh runner module' do
|
|
23
|
+
expect(actor.runner_class).to eq(Legion::Extensions::Mesh::Runners::Mesh)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
describe '#runner_function' do
|
|
28
|
+
it 'returns heartbeat' do
|
|
29
|
+
expect(actor.runner_function).to eq('heartbeat')
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
describe '#time' do
|
|
34
|
+
it 'returns 10' do
|
|
35
|
+
expect(actor.time).to eq(10)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
describe '#run_now?' do
|
|
40
|
+
it 'returns true' do
|
|
41
|
+
expect(actor.run_now?).to be true
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
describe '#use_runner?' do
|
|
46
|
+
it 'returns false' do
|
|
47
|
+
expect(actor.use_runner?).to be false
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
describe '#check_subtask?' do
|
|
52
|
+
it 'returns false' do
|
|
53
|
+
expect(actor.check_subtask?).to be false
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
describe '#generate_task?' do
|
|
58
|
+
it 'returns false' do
|
|
59
|
+
expect(actor.generate_task?).to be false
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
describe '#args' do
|
|
64
|
+
it 'returns a hash with agent_id from Legion::Settings' do
|
|
65
|
+
settings = { client: { name: 'test-agent' } }
|
|
66
|
+
stub_const('Legion::Settings', settings)
|
|
67
|
+
expect(actor.args).to eq({ agent_id: 'test-agent' })
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/mesh/client'
|
|
4
|
+
|
|
5
|
+
RSpec.describe Legion::Extensions::Mesh::Client do
|
|
6
|
+
it 'responds to mesh runner methods' do
|
|
7
|
+
client = described_class.new
|
|
8
|
+
expect(client).to respond_to(:register)
|
|
9
|
+
expect(client).to respond_to(:unregister)
|
|
10
|
+
expect(client).to respond_to(:heartbeat)
|
|
11
|
+
expect(client).to respond_to(:send_message)
|
|
12
|
+
expect(client).to respond_to(:find_agents)
|
|
13
|
+
expect(client).to respond_to(:mesh_status)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
# Stub lex-memory if not loaded
|
|
6
|
+
unless defined?(Legion::Extensions::Memory::Runners::Traces)
|
|
7
|
+
module Legion
|
|
8
|
+
module Extensions
|
|
9
|
+
module Memory
|
|
10
|
+
module Runners
|
|
11
|
+
module Traces
|
|
12
|
+
def retrieve_by_domain(**)
|
|
13
|
+
[]
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def store_trace(**)
|
|
17
|
+
{ stored: true }
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
RSpec.describe Legion::Extensions::Mesh::Helpers::PreferenceProfile do
|
|
27
|
+
let(:profile_mod) { described_class }
|
|
28
|
+
|
|
29
|
+
describe '.resolve' do
|
|
30
|
+
context 'with no preferences stored' do
|
|
31
|
+
it 'returns default profile' do
|
|
32
|
+
result = profile_mod.resolve(owner_id: 'user1')
|
|
33
|
+
expect(result[:verbosity]).to eq(:normal)
|
|
34
|
+
expect(result[:tone]).to eq(:professional)
|
|
35
|
+
expect(result[:format]).to eq(:structured)
|
|
36
|
+
expect(result[:technical_depth]).to eq(:moderate)
|
|
37
|
+
expect(result[:sources]).to eq([:defaults])
|
|
38
|
+
expect(result[:resolved_at]).to be_a(Time)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
context 'with explicit preference stored' do
|
|
43
|
+
it 'overrides default for that domain' do
|
|
44
|
+
result = profile_mod.resolve(owner_id: 'user1', overrides: [
|
|
45
|
+
{ domain: 'verbosity', value: 'concise', source: 'explicit', confidence: 1.0 }
|
|
46
|
+
])
|
|
47
|
+
expect(result[:verbosity]).to eq(:concise)
|
|
48
|
+
expect(result[:sources]).to include(:explicit)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
context 'with multiple sources for same domain' do
|
|
53
|
+
it 'higher confidence wins' do
|
|
54
|
+
result = profile_mod.resolve(owner_id: 'user1', overrides: [
|
|
55
|
+
{ domain: 'tone', value: 'casual', source: 'personality', confidence: 0.4 },
|
|
56
|
+
{ domain: 'tone', value: 'formal', source: 'explicit', confidence: 1.0 }
|
|
57
|
+
])
|
|
58
|
+
expect(result[:tone]).to eq(:formal)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
context 'with personality traits' do
|
|
63
|
+
it 'maps OCEAN traits to preference defaults' do
|
|
64
|
+
result = profile_mod.resolve(
|
|
65
|
+
owner_id: 'user1',
|
|
66
|
+
personality: { openness: 0.9, conscientiousness: 0.3 }
|
|
67
|
+
)
|
|
68
|
+
expect(result[:personality]).to eq({ openness: 0.9, conscientiousness: 0.3 })
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
context 'with custom preferences' do
|
|
73
|
+
it 'includes custom key-value pairs' do
|
|
74
|
+
result = profile_mod.resolve(owner_id: 'user1', overrides: [
|
|
75
|
+
{ domain: 'custom:response_language', value: 'es', source: 'explicit', confidence: 1.0 }
|
|
76
|
+
])
|
|
77
|
+
expect(result[:custom]).to include('response_language' => 'es')
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
describe '.store_preference' do
|
|
83
|
+
it 'returns stored result with trace data' do
|
|
84
|
+
result = profile_mod.store_preference(
|
|
85
|
+
owner_id: 'user1', domain: 'verbosity', value: 'concise', source: 'explicit'
|
|
86
|
+
)
|
|
87
|
+
expect(result[:stored]).to be true
|
|
88
|
+
expect(result[:domain]).to eq('verbosity')
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
it 'returns not_available when lex-memory is not loaded' do
|
|
92
|
+
allow(profile_mod).to receive(:memory_available?).and_return(false)
|
|
93
|
+
result = profile_mod.store_preference(
|
|
94
|
+
owner_id: 'user1', domain: 'verbosity', value: 'concise', source: 'explicit'
|
|
95
|
+
)
|
|
96
|
+
expect(result[:stored]).to be false
|
|
97
|
+
expect(result[:reason]).to eq(:memory_not_available)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
describe '.clear_preferences' do
|
|
102
|
+
it 'returns cleared result' do
|
|
103
|
+
result = profile_mod.clear_preferences(owner_id: 'user1', source: 'explicit')
|
|
104
|
+
expect(result[:cleared]).to be true
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
describe '.preference_instructions' do
|
|
109
|
+
it 'generates natural language prompt instructions from profile' do
|
|
110
|
+
profile = {
|
|
111
|
+
verbosity: :concise,
|
|
112
|
+
tone: :formal,
|
|
113
|
+
format: :structured,
|
|
114
|
+
technical_depth: :deep
|
|
115
|
+
}
|
|
116
|
+
instructions = profile_mod.preference_instructions(profile: profile)
|
|
117
|
+
expect(instructions).to include('brief')
|
|
118
|
+
expect(instructions).to include('formal')
|
|
119
|
+
expect(instructions).to include('implementation')
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
it 'returns nil for all-default profile' do
|
|
123
|
+
profile = { verbosity: :normal, tone: :professional, format: :structured, technical_depth: :moderate }
|
|
124
|
+
instructions = profile_mod.preference_instructions(profile: profile)
|
|
125
|
+
expect(instructions).to be_nil
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe Legion::Extensions::Mesh::Helpers::Registry do
|
|
4
|
+
let(:registry) { described_class.new }
|
|
5
|
+
|
|
6
|
+
describe '#initialize' do
|
|
7
|
+
it 'starts with an empty agents hash' do
|
|
8
|
+
expect(registry.agents).to eq({})
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
it 'starts with an empty capabilities hash' do
|
|
12
|
+
expect(registry.capabilities).to eq({})
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
it 'starts with an empty messages array' do
|
|
16
|
+
expect(registry.messages).to eq([])
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
describe '#register_agent' do
|
|
21
|
+
it 'adds the agent to the agents hash' do
|
|
22
|
+
registry.register_agent('agent-1')
|
|
23
|
+
expect(registry.agents).to have_key('agent-1')
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
it 'stores the agent_id on the record' do
|
|
27
|
+
registry.register_agent('agent-1')
|
|
28
|
+
expect(registry.agents['agent-1'][:agent_id]).to eq('agent-1')
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
it 'defaults status to :online' do
|
|
32
|
+
registry.register_agent('agent-1')
|
|
33
|
+
expect(registry.agents['agent-1'][:status]).to eq(:online)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
it 'stores an empty capabilities array by default' do
|
|
37
|
+
registry.register_agent('agent-1')
|
|
38
|
+
expect(registry.agents['agent-1'][:capabilities]).to eq([])
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
it 'stores provided capabilities' do
|
|
42
|
+
registry.register_agent('agent-1', capabilities: %i[search code_review])
|
|
43
|
+
expect(registry.agents['agent-1'][:capabilities]).to eq(%i[search code_review])
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
it 'stores the endpoint when provided' do
|
|
47
|
+
registry.register_agent('agent-1', endpoint: 'http://host:9000')
|
|
48
|
+
expect(registry.agents['agent-1'][:endpoint]).to eq('http://host:9000')
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
it 'defaults endpoint to nil' do
|
|
52
|
+
registry.register_agent('agent-1')
|
|
53
|
+
expect(registry.agents['agent-1'][:endpoint]).to be_nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
it 'indexes each capability to the agent_id' do
|
|
57
|
+
registry.register_agent('agent-1', capabilities: [:search])
|
|
58
|
+
expect(registry.capabilities[:search]).to include('agent-1')
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
it 'indexes multiple capabilities separately' do
|
|
62
|
+
registry.register_agent('agent-1', capabilities: %i[search compute])
|
|
63
|
+
expect(registry.capabilities[:search]).to include('agent-1')
|
|
64
|
+
expect(registry.capabilities[:compute]).to include('agent-1')
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
it 'accumulates multiple agents under the same capability' do
|
|
68
|
+
registry.register_agent('agent-1', capabilities: [:search])
|
|
69
|
+
registry.register_agent('agent-2', capabilities: [:search])
|
|
70
|
+
expect(registry.capabilities[:search]).to include('agent-1', 'agent-2')
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
it 'records registered_at as a UTC Time' do
|
|
74
|
+
before = Time.now.utc
|
|
75
|
+
registry.register_agent('agent-1')
|
|
76
|
+
after = Time.now.utc
|
|
77
|
+
ts = registry.agents['agent-1'][:registered_at]
|
|
78
|
+
expect(ts).to be_between(before, after)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
it 'sets last_seen on registration' do
|
|
82
|
+
registry.register_agent('agent-1')
|
|
83
|
+
expect(registry.agents['agent-1'][:last_seen]).not_to be_nil
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
describe '#unregister_agent' do
|
|
88
|
+
before { registry.register_agent('agent-1', capabilities: %i[search compute]) }
|
|
89
|
+
|
|
90
|
+
it 'removes the agent from agents hash' do
|
|
91
|
+
registry.unregister_agent('agent-1')
|
|
92
|
+
expect(registry.agents).not_to have_key('agent-1')
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
it 'returns the removed agent record' do
|
|
96
|
+
result = registry.unregister_agent('agent-1')
|
|
97
|
+
expect(result[:agent_id]).to eq('agent-1')
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
it 'removes the agent from each capability index' do
|
|
101
|
+
registry.unregister_agent('agent-1')
|
|
102
|
+
expect(registry.capabilities[:search]).not_to include('agent-1')
|
|
103
|
+
expect(registry.capabilities[:compute]).not_to include('agent-1')
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
it 'does not affect other agents in the same capability' do
|
|
107
|
+
registry.register_agent('agent-2', capabilities: [:search])
|
|
108
|
+
registry.unregister_agent('agent-1')
|
|
109
|
+
expect(registry.capabilities[:search]).to include('agent-2')
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
it 'returns nil for unknown agent_id' do
|
|
113
|
+
expect(registry.unregister_agent('nonexistent')).to be_nil
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
it 'leaves other agents unaffected' do
|
|
117
|
+
registry.register_agent('agent-2')
|
|
118
|
+
registry.unregister_agent('agent-1')
|
|
119
|
+
expect(registry.agents).to have_key('agent-2')
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
describe '#heartbeat' do
|
|
124
|
+
before { registry.register_agent('agent-1') }
|
|
125
|
+
|
|
126
|
+
it 'updates last_seen to a recent UTC time' do
|
|
127
|
+
before = Time.now.utc
|
|
128
|
+
registry.heartbeat('agent-1')
|
|
129
|
+
after = Time.now.utc
|
|
130
|
+
expect(registry.agents['agent-1'][:last_seen]).to be_between(before, after)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
it 'sets status to :online' do
|
|
134
|
+
registry.agents['agent-1'][:status] = :unknown
|
|
135
|
+
registry.heartbeat('agent-1')
|
|
136
|
+
expect(registry.agents['agent-1'][:status]).to eq(:online)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
it 'returns nil for unknown agent_id' do
|
|
140
|
+
expect(registry.heartbeat('nonexistent')).to be_nil
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
describe '#find_by_capability' do
|
|
145
|
+
it 'returns agents that have the requested capability' do
|
|
146
|
+
registry.register_agent('a1', capabilities: [:search])
|
|
147
|
+
registry.register_agent('a2', capabilities: [:search])
|
|
148
|
+
results = registry.find_by_capability(:search)
|
|
149
|
+
ids = results.map { |a| a[:agent_id] }
|
|
150
|
+
expect(ids).to contain_exactly('a1', 'a2')
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
it 'returns only agents with that specific capability' do
|
|
154
|
+
registry.register_agent('a1', capabilities: [:search])
|
|
155
|
+
registry.register_agent('a2', capabilities: [:compute])
|
|
156
|
+
results = registry.find_by_capability(:search)
|
|
157
|
+
expect(results.size).to eq(1)
|
|
158
|
+
expect(results.first[:agent_id]).to eq('a1')
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
it 'returns an empty array when no agents have the capability' do
|
|
162
|
+
expect(registry.find_by_capability(:nonexistent)).to eq([])
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
it 'excludes agents that were unregistered' do
|
|
166
|
+
registry.register_agent('a1', capabilities: [:search])
|
|
167
|
+
registry.unregister_agent('a1')
|
|
168
|
+
expect(registry.find_by_capability(:search)).to eq([])
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
describe '#route_message' do
|
|
173
|
+
before do
|
|
174
|
+
registry.register_agent('a1', capabilities: [:search])
|
|
175
|
+
registry.register_agent('a2', capabilities: [:search])
|
|
176
|
+
registry.register_agent('a3', capabilities: [:compute])
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
describe 'unicast pattern' do
|
|
180
|
+
it 'delivers to the specified recipient' do
|
|
181
|
+
result = registry.route_message(from: 'sender', to: 'a1', pattern: :unicast, payload: { x: 1 })
|
|
182
|
+
expect(result[:delivered_to]).to eq(['a1'])
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
it 'returns empty delivered_to for unknown recipient' do
|
|
186
|
+
result = registry.route_message(from: 'sender', to: 'unknown', pattern: :unicast)
|
|
187
|
+
expect(result[:delivered_to]).to eq([])
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
it 'records the message in the buffer' do
|
|
191
|
+
registry.route_message(from: 'sender', to: 'a1', pattern: :unicast)
|
|
192
|
+
expect(registry.messages.size).to eq(1)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
it 'stores from, to, pattern, and payload on the message' do
|
|
196
|
+
registry.route_message(from: 'sender', to: 'a1', pattern: :unicast, payload: { key: 'val' })
|
|
197
|
+
msg = registry.messages.last
|
|
198
|
+
expect(msg[:from]).to eq('sender')
|
|
199
|
+
expect(msg[:to]).to eq('a1')
|
|
200
|
+
expect(msg[:pattern]).to eq(:unicast)
|
|
201
|
+
expect(msg[:payload]).to eq({ key: 'val' })
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
describe 'multicast pattern' do
|
|
206
|
+
it 'delivers to all agents with the given capability' do
|
|
207
|
+
result = registry.route_message(from: 'sender', capability: :search, pattern: :multicast)
|
|
208
|
+
expect(result[:delivered_to]).to contain_exactly('a1', 'a2')
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
it 'returns empty delivered_to when capability has no agents' do
|
|
212
|
+
result = registry.route_message(from: 'sender', capability: :unknown, pattern: :multicast)
|
|
213
|
+
expect(result[:delivered_to]).to eq([])
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
it 'returns empty delivered_to when no capability is given' do
|
|
217
|
+
result = registry.route_message(from: 'sender', pattern: :multicast)
|
|
218
|
+
expect(result[:delivered_to]).to eq([])
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
describe 'broadcast pattern' do
|
|
223
|
+
it 'delivers to all registered agents' do
|
|
224
|
+
result = registry.route_message(from: 'sender', pattern: :broadcast)
|
|
225
|
+
expect(result[:delivered_to]).to contain_exactly('a1', 'a2', 'a3')
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
it 'returns empty delivered_to when no agents registered' do
|
|
229
|
+
empty = described_class.new
|
|
230
|
+
result = empty.route_message(from: 'sender', pattern: :broadcast)
|
|
231
|
+
expect(result[:delivered_to]).to eq([])
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
describe 'unknown pattern' do
|
|
236
|
+
it 'returns empty delivered_to' do
|
|
237
|
+
result = registry.route_message(from: 'sender', pattern: :unknown_pattern)
|
|
238
|
+
expect(result[:delivered_to]).to eq([])
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
describe 'message buffer cap' do
|
|
243
|
+
it 'shifts old messages when buffer exceeds 1000' do
|
|
244
|
+
1001.times { |i| registry.route_message(from: 'sender', to: "a#{i}", pattern: :unicast) }
|
|
245
|
+
expect(registry.messages.size).to eq(1000)
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
it 'stores a timestamp on the message' do
|
|
250
|
+
before = Time.now.utc
|
|
251
|
+
registry.route_message(from: 'sender', pattern: :broadcast)
|
|
252
|
+
after = Time.now.utc
|
|
253
|
+
expect(registry.messages.last[:at]).to be_between(before, after)
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
describe '#online_agents' do
|
|
258
|
+
it 'returns agents with :online status' do
|
|
259
|
+
registry.register_agent('a1')
|
|
260
|
+
registry.register_agent('a2')
|
|
261
|
+
expect(registry.online_agents.size).to eq(2)
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
it 'excludes agents with non-online status' do
|
|
265
|
+
registry.register_agent('a1')
|
|
266
|
+
registry.agents['a1'][:status] = :offline
|
|
267
|
+
expect(registry.online_agents).to eq([])
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
it 'returns an empty array when no agents are registered' do
|
|
271
|
+
expect(registry.online_agents).to eq([])
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
describe '#count' do
|
|
276
|
+
it 'returns 0 for an empty registry' do
|
|
277
|
+
expect(registry.count).to eq(0)
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
it 'returns the number of registered agents' do
|
|
281
|
+
registry.register_agent('a1')
|
|
282
|
+
registry.register_agent('a2')
|
|
283
|
+
expect(registry.count).to eq(2)
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
it 'decrements after unregistration' do
|
|
287
|
+
registry.register_agent('a1')
|
|
288
|
+
registry.unregister_agent('a1')
|
|
289
|
+
expect(registry.count).to eq(0)
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe Legion::Extensions::Mesh::Helpers::Topology do
|
|
4
|
+
describe 'constants' do
|
|
5
|
+
it 'defines PROTOCOLS as a frozen array of symbols' do
|
|
6
|
+
expect(described_class::PROTOCOLS).to eq(%i[grpc websocket rest])
|
|
7
|
+
expect(described_class::PROTOCOLS).to be_frozen
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
it 'defines PATTERNS as a frozen array of symbols' do
|
|
11
|
+
expect(described_class::PATTERNS).to eq(%i[unicast multicast broadcast])
|
|
12
|
+
expect(described_class::PATTERNS).to be_frozen
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
it 'defines MESH_SILENCE_TIMEOUT as 30' do
|
|
16
|
+
expect(described_class::MESH_SILENCE_TIMEOUT).to eq(30)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
it 'defines TRUST_CONSIDER_THRESHOLD as 0.3' do
|
|
20
|
+
expect(described_class::TRUST_CONSIDER_THRESHOLD).to eq(0.3)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
it 'defines MAX_HOPS as 3' do
|
|
24
|
+
expect(described_class::MAX_HOPS).to eq(3)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
describe '.valid_protocol?' do
|
|
29
|
+
it 'returns true for :grpc' do
|
|
30
|
+
expect(described_class.valid_protocol?(:grpc)).to be true
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
it 'returns true for :websocket' do
|
|
34
|
+
expect(described_class.valid_protocol?(:websocket)).to be true
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
it 'returns true for :rest' do
|
|
38
|
+
expect(described_class.valid_protocol?(:rest)).to be true
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
it 'returns false for an unknown protocol' do
|
|
42
|
+
expect(described_class.valid_protocol?(:http2)).to be false
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
it 'returns false for nil' do
|
|
46
|
+
expect(described_class.valid_protocol?(nil)).to be false
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
it 'returns false for a string version of a valid protocol' do
|
|
50
|
+
expect(described_class.valid_protocol?('grpc')).to be false
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
it 'accepts all PROTOCOLS members as valid' do
|
|
54
|
+
described_class::PROTOCOLS.each do |proto|
|
|
55
|
+
expect(described_class.valid_protocol?(proto)).to be true
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
describe '.valid_pattern?' do
|
|
61
|
+
it 'returns true for :unicast' do
|
|
62
|
+
expect(described_class.valid_pattern?(:unicast)).to be true
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
it 'returns true for :multicast' do
|
|
66
|
+
expect(described_class.valid_pattern?(:multicast)).to be true
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
it 'returns true for :broadcast' do
|
|
70
|
+
expect(described_class.valid_pattern?(:broadcast)).to be true
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
it 'returns false for an unknown pattern' do
|
|
74
|
+
expect(described_class.valid_pattern?(:anycast)).to be false
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
it 'returns false for nil' do
|
|
78
|
+
expect(described_class.valid_pattern?(nil)).to be false
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
it 'returns false for a string version of a valid pattern' do
|
|
82
|
+
expect(described_class.valid_pattern?('unicast')).to be false
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
it 'accepts all PATTERNS members as valid' do
|
|
86
|
+
described_class::PATTERNS.each do |pat|
|
|
87
|
+
expect(described_class.valid_pattern?(pat)).to be true
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/mesh/client'
|
|
4
|
+
|
|
5
|
+
RSpec.describe Legion::Extensions::Mesh::Runners::Mesh do
|
|
6
|
+
let(:client) { Legion::Extensions::Mesh::Client.new }
|
|
7
|
+
|
|
8
|
+
describe '#register' do
|
|
9
|
+
it 'registers an agent' do
|
|
10
|
+
result = client.register(agent_id: 'agent-1', capabilities: [:search])
|
|
11
|
+
expect(result[:registered]).to be true
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
describe '#unregister' do
|
|
16
|
+
it 'unregisters a known agent' do
|
|
17
|
+
client.register(agent_id: 'agent-1')
|
|
18
|
+
result = client.unregister(agent_id: 'agent-1')
|
|
19
|
+
expect(result[:unregistered]).to be true
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
it 'returns error for unknown agent' do
|
|
23
|
+
result = client.unregister(agent_id: 'unknown')
|
|
24
|
+
expect(result[:error]).to eq(:not_found)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
describe '#send_message' do
|
|
29
|
+
it 'sends unicast message' do
|
|
30
|
+
client.register(agent_id: 'agent-1')
|
|
31
|
+
result = client.send_message(from: 'agent-2', to: 'agent-1', payload: { text: 'hello' })
|
|
32
|
+
expect(result[:sent]).to be true
|
|
33
|
+
expect(result[:count]).to eq(1)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
it 'sends multicast by capability' do
|
|
37
|
+
client.register(agent_id: 'agent-1', capabilities: [:search])
|
|
38
|
+
client.register(agent_id: 'agent-2', capabilities: [:search])
|
|
39
|
+
client.register(agent_id: 'agent-3', capabilities: [:compute])
|
|
40
|
+
result = client.send_message(from: 'x', capability: :search, pattern: :multicast)
|
|
41
|
+
expect(result[:count]).to eq(2)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
it 'sends broadcast' do
|
|
45
|
+
client.register(agent_id: 'a1')
|
|
46
|
+
client.register(agent_id: 'a2')
|
|
47
|
+
result = client.send_message(from: 'x', pattern: :broadcast)
|
|
48
|
+
expect(result[:count]).to eq(2)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
describe '#find_agents' do
|
|
53
|
+
it 'finds agents by capability' do
|
|
54
|
+
client.register(agent_id: 'a1', capabilities: [:code_review])
|
|
55
|
+
result = client.find_agents(capability: :code_review)
|
|
56
|
+
expect(result[:count]).to eq(1)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
describe '#mesh_status' do
|
|
61
|
+
it 'returns status' do
|
|
62
|
+
client.register(agent_id: 'a1')
|
|
63
|
+
status = client.mesh_status
|
|
64
|
+
expect(status[:total]).to eq(1)
|
|
65
|
+
expect(status[:online]).to eq(1)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
data/spec/spec_helper.rb
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'bundler/setup'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module Logging
|
|
7
|
+
def self.debug(_msg); end
|
|
8
|
+
def self.info(_msg); end
|
|
9
|
+
def self.warn(_msg); end
|
|
10
|
+
def self.error(_msg); end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
require 'legion/extensions/mesh'
|
|
15
|
+
|
|
16
|
+
RSpec.configure do |config|
|
|
17
|
+
config.example_status_persistence_file_path = '.rspec_status'
|
|
18
|
+
config.disable_monkey_patching!
|
|
19
|
+
config.expect_with(:rspec) { |c| c.syntax = :expect }
|
|
20
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: lex-mesh
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.1
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Esity
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: legion-gaia
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0'
|
|
19
|
+
type: :development
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0'
|
|
26
|
+
description: Agent-to-agent mesh communication protocol for brain-modeled agentic
|
|
27
|
+
AI
|
|
28
|
+
email:
|
|
29
|
+
- matthewdiverson@gmail.com
|
|
30
|
+
executables: []
|
|
31
|
+
extensions: []
|
|
32
|
+
extra_rdoc_files: []
|
|
33
|
+
files:
|
|
34
|
+
- Gemfile
|
|
35
|
+
- lex-mesh.gemspec
|
|
36
|
+
- lib/legion/extensions/mesh.rb
|
|
37
|
+
- lib/legion/extensions/mesh/actors/heartbeat.rb
|
|
38
|
+
- lib/legion/extensions/mesh/client.rb
|
|
39
|
+
- lib/legion/extensions/mesh/helpers/preference_profile.rb
|
|
40
|
+
- lib/legion/extensions/mesh/helpers/registry.rb
|
|
41
|
+
- lib/legion/extensions/mesh/helpers/topology.rb
|
|
42
|
+
- lib/legion/extensions/mesh/runners/mesh.rb
|
|
43
|
+
- lib/legion/extensions/mesh/version.rb
|
|
44
|
+
- spec/legion/extensions/mesh/actors/heartbeat_spec.rb
|
|
45
|
+
- spec/legion/extensions/mesh/client_spec.rb
|
|
46
|
+
- spec/legion/extensions/mesh/helpers/preference_profile_spec.rb
|
|
47
|
+
- spec/legion/extensions/mesh/helpers/registry_spec.rb
|
|
48
|
+
- spec/legion/extensions/mesh/helpers/topology_spec.rb
|
|
49
|
+
- spec/legion/extensions/mesh/runners/mesh_spec.rb
|
|
50
|
+
- spec/spec_helper.rb
|
|
51
|
+
homepage: https://github.com/LegionIO/lex-mesh
|
|
52
|
+
licenses:
|
|
53
|
+
- MIT
|
|
54
|
+
metadata:
|
|
55
|
+
homepage_uri: https://github.com/LegionIO/lex-mesh
|
|
56
|
+
source_code_uri: https://github.com/LegionIO/lex-mesh
|
|
57
|
+
documentation_uri: https://github.com/LegionIO/lex-mesh
|
|
58
|
+
changelog_uri: https://github.com/LegionIO/lex-mesh
|
|
59
|
+
bug_tracker_uri: https://github.com/LegionIO/lex-mesh/issues
|
|
60
|
+
rubygems_mfa_required: 'true'
|
|
61
|
+
rdoc_options: []
|
|
62
|
+
require_paths:
|
|
63
|
+
- lib
|
|
64
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - ">="
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: '3.4'
|
|
69
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
70
|
+
requirements:
|
|
71
|
+
- - ">="
|
|
72
|
+
- !ruby/object:Gem::Version
|
|
73
|
+
version: '0'
|
|
74
|
+
requirements: []
|
|
75
|
+
rubygems_version: 3.6.9
|
|
76
|
+
specification_version: 4
|
|
77
|
+
summary: LEX Mesh
|
|
78
|
+
test_files: []
|