lex-agentic-learning 0.1.11 → 0.1.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +12 -0
- data/README.md +1 -1
- data/lib/legion/extensions/agentic/learning/chrysalis/runners/cognitive_chrysalis.rb +1 -1
- data/lib/legion/extensions/agentic/learning/curiosity/runners/curiosity.rb +7 -6
- data/lib/legion/extensions/agentic/learning/outcome_listener/client.rb +22 -9
- data/lib/legion/extensions/agentic/learning/outcome_listener/helpers/constants.rb +2 -0
- data/lib/legion/extensions/agentic/learning/outcome_listener/helpers/lesson_builder.rb +4 -3
- data/lib/legion/extensions/agentic/learning/outcome_listener/runners/outcome_listener.rb +50 -15
- data/lib/legion/extensions/agentic/learning/version.rb +1 -1
- data/spec/legion/extensions/agentic/learning/curiosity/runners/curiosity_spec.rb +54 -0
- data/spec/legion/extensions/agentic/learning/outcome_listener/helpers/lesson_builder_spec.rb +0 -10
- data/spec/legion/extensions/agentic/learning/outcome_listener/runners/outcome_listener_spec.rb +163 -2
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cecc51a27c68a22d11bae4df4784f6b5eeba66d6621911809596a3f8bba5c03b
|
|
4
|
+
data.tar.gz: 4190bc8f28519fad9f5acb205c944c88a99f40459dfd1a23ff62a77c3ec39643
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c79885b2f17dc71be2c0e10f1eb31994e08e9d5b2a0b093fd3576f859a75c8d6c8c4970c2a066eddffac3617b2a0e04a9b9977e6c31a7ddb843a50f232de0c77
|
|
7
|
+
data.tar.gz: ef96ad14cd80eda16d36821f5a327bdd393fb8cf49ab307307edfd2c0e531ba3b481091998407b1e06c8abf9b5868e48d014b95defa8636f5bc35c95a5a86b73
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.1.13] - 2026-10-29
|
|
4
|
+
### Fixed
|
|
5
|
+
- Accept framework keyword/module outcome dispatch and reject unknown statuses or missing task attribution without updating learning models.
|
|
6
|
+
- Deduplicate explicit transport message identities across concurrent listener clients using bounded process-local history and a stable, eagerly initialized lock shared with state resets; repeated task IDs remain distinct executions.
|
|
7
|
+
- Report model update failures and identify task lessons as technical execution evidence, not demonstrated human acceptance.
|
|
8
|
+
- Fail closed when Apollo publication settings cannot be read. Event subscription activation and durable deduplication are unchanged.
|
|
9
|
+
|
|
10
|
+
## [0.1.12] - 2026-05-15
|
|
11
|
+
### Fixed
|
|
12
|
+
- Curiosity `store_insight_in_apollo` now calls `Legion::Apollo.ingest` directly (replacing `Legion::Extensions::Apollo.store` which was a non-existent method and silently failed on every call); adds `access_scope: 'private'` and `identity_principal_id: nil`.
|
|
13
|
+
- OutcomeListener `write_apollo_lesson` now calls `Legion::Apollo.ingest` directly (replacing `ingest_knowledge(content: ...)` which was a dormant `ArgumentError` in Ruby 3.x due to missing positional arg); adds `access_scope: 'private'` and `identity_principal_id: nil`.
|
|
14
|
+
|
|
3
15
|
## [0.1.11] - 2026-05-07
|
|
4
16
|
### Fixed
|
|
5
17
|
- Curiosity self-inquiry now uses native `Legion::LLM.chat` response hashes directly and avoids obsolete legacy LLM fallback calls after native chat succeeds.
|
data/README.md
CHANGED
|
@@ -14,7 +14,7 @@ module Legion
|
|
|
14
14
|
end
|
|
15
15
|
|
|
16
16
|
begin
|
|
17
|
-
include Legion::Extensions::Helpers::Lex
|
|
17
|
+
include Legion::Extensions::Helpers::Lex
|
|
18
18
|
rescue StandardError => e
|
|
19
19
|
log.error "[cognitive_chrysalis] failed to include Helpers::Lex: #{e.class}: #{e.message}"
|
|
20
20
|
end
|
|
@@ -177,15 +177,16 @@ module Legion
|
|
|
177
177
|
end
|
|
178
178
|
|
|
179
179
|
def store_insight_in_apollo(question, insight, domain)
|
|
180
|
-
return unless defined?(Legion::
|
|
180
|
+
return unless defined?(Legion::Apollo)
|
|
181
181
|
|
|
182
|
-
Legion::
|
|
183
|
-
content:
|
|
184
|
-
|
|
185
|
-
|
|
182
|
+
Legion::Apollo.ingest( # rubocop:disable Legion/HelperMigration/DirectKnowledge
|
|
183
|
+
content: "Self-inquiry insight [#{domain}]: #{question}\n\n#{insight}",
|
|
184
|
+
tags: ['gaia-self-inquiry', "domain-#{domain}", 'autonomous-thought'],
|
|
185
|
+
access_scope: 'private',
|
|
186
|
+
identity_principal_id: nil
|
|
186
187
|
)
|
|
187
188
|
rescue StandardError => e
|
|
188
|
-
log.warn "[curiosity:self_inquiry] Apollo
|
|
189
|
+
log.warn "[curiosity:self_inquiry] Apollo ingest failed: #{e.class}: #{e.message}"
|
|
189
190
|
end
|
|
190
191
|
|
|
191
192
|
def create_wonders_from_gaps(gaps)
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'legion/extensions/agentic/learning/meta_learning/client'
|
|
4
|
+
require 'legion/extensions/agentic/learning/scaffolding/client'
|
|
5
|
+
require 'legion/extensions/agentic/learning/learning_rate/client'
|
|
3
6
|
require 'legion/extensions/agentic/learning/outcome_listener/helpers/constants'
|
|
4
7
|
require 'legion/extensions/agentic/learning/outcome_listener/helpers/domain_extractor'
|
|
5
8
|
require 'legion/extensions/agentic/learning/outcome_listener/helpers/lesson_builder'
|
|
@@ -13,32 +16,42 @@ module Legion
|
|
|
13
16
|
class Client
|
|
14
17
|
include Runners::OutcomeListener
|
|
15
18
|
|
|
16
|
-
|
|
19
|
+
@outcome_mutex = Mutex.new
|
|
20
|
+
|
|
21
|
+
# rubocop:disable-next ThreadSafety/ClassInstanceVariable
|
|
17
22
|
class << self
|
|
23
|
+
attr_reader :outcome_mutex
|
|
24
|
+
|
|
18
25
|
def meta_client
|
|
19
|
-
@meta_client ||= MetaLearning::Client.new
|
|
26
|
+
@meta_client ||= Legion::Extensions::Agentic::Learning::MetaLearning::Client.new
|
|
20
27
|
end
|
|
21
28
|
|
|
22
29
|
def scaffolding_client
|
|
23
|
-
@scaffolding_client ||= Scaffolding::Client.new
|
|
30
|
+
@scaffolding_client ||= Legion::Extensions::Agentic::Learning::Scaffolding::Client.new
|
|
24
31
|
end
|
|
25
32
|
|
|
26
33
|
def learning_rate_client
|
|
27
|
-
@learning_rate_client ||= LearningRate::Client.new
|
|
34
|
+
@learning_rate_client ||= Legion::Extensions::Agentic::Learning::LearningRate::Client.new
|
|
28
35
|
end
|
|
29
36
|
|
|
30
37
|
def domain_map
|
|
31
38
|
@domain_map ||= {}
|
|
32
39
|
end
|
|
33
40
|
|
|
41
|
+
def processed_outcomes
|
|
42
|
+
@processed_outcomes ||= {}
|
|
43
|
+
end
|
|
44
|
+
|
|
34
45
|
def reset!
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
46
|
+
outcome_mutex.synchronize do
|
|
47
|
+
@meta_client = nil
|
|
48
|
+
@scaffolding_client = nil
|
|
49
|
+
@learning_rate_client = nil
|
|
50
|
+
@domain_map = nil
|
|
51
|
+
@processed_outcomes = nil
|
|
52
|
+
end
|
|
39
53
|
end
|
|
40
54
|
end
|
|
41
|
-
# rubocop:enable ThreadSafety/ClassInstanceVariable
|
|
42
55
|
end
|
|
43
56
|
end
|
|
44
57
|
end
|
|
@@ -10,6 +10,8 @@ module Legion
|
|
|
10
10
|
DEFAULT_MIN_LESSON_SEVERITY = 0.3
|
|
11
11
|
COMPLETED_STATUS = 'task.completed'
|
|
12
12
|
FAILED_STATUS = 'task.failed'
|
|
13
|
+
EXCEPTION_STATUS = 'task.exception'
|
|
14
|
+
MAX_OUTCOME_HISTORY = 1000
|
|
13
15
|
DEFAULT_DIFFICULTY = 0.5
|
|
14
16
|
DEFAULT_CONFIDENCE_SUCCESS = 0.7
|
|
15
17
|
DEFAULT_CONFIDENCE_FAILURE = 0.5
|
|
@@ -11,10 +11,11 @@ module Legion
|
|
|
11
11
|
|
|
12
12
|
module_function
|
|
13
13
|
|
|
14
|
-
def build(runner_class:, domain:, success:,
|
|
14
|
+
def build(runner_class:, domain:, success:, function: nil, source_agent: nil)
|
|
15
15
|
{
|
|
16
16
|
situation: build_situation(runner_class, function),
|
|
17
17
|
outcome: success ? :success : :failure,
|
|
18
|
+
evidence: :technical_execution,
|
|
18
19
|
lesson: build_lesson(domain, success),
|
|
19
20
|
domain: domain,
|
|
20
21
|
confidence: success ? DEFAULT_CONFIDENCE_SUCCESS : DEFAULT_CONFIDENCE_FAILURE,
|
|
@@ -31,9 +32,9 @@ module Legion
|
|
|
31
32
|
|
|
32
33
|
def build_lesson(domain, success)
|
|
33
34
|
if success
|
|
34
|
-
"Task in domain '#{domain}' completed
|
|
35
|
+
"Task execution in domain '#{domain}' completed without an exception"
|
|
35
36
|
else
|
|
36
|
-
"Task in domain '#{domain}' failed — review for corrective action"
|
|
37
|
+
"Task execution in domain '#{domain}' failed — review for corrective action"
|
|
37
38
|
end
|
|
38
39
|
end
|
|
39
40
|
end
|
|
@@ -10,7 +10,40 @@ module Legion
|
|
|
10
10
|
include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers, false) &&
|
|
11
11
|
Legion::Extensions::Helpers.const_defined?(:Lex, false)
|
|
12
12
|
|
|
13
|
-
def process_outcome(payload = {}, **)
|
|
13
|
+
def process_outcome(payload = {}, **options)
|
|
14
|
+
payload = payload.merge(options)
|
|
15
|
+
status = payload[:status].to_s
|
|
16
|
+
unless [Helpers::Constants::COMPLETED_STATUS, Helpers::Constants::FAILED_STATUS,
|
|
17
|
+
Helpers::Constants::EXCEPTION_STATUS].include?(status)
|
|
18
|
+
return { success: false, skipped: true, reason: :unrecognized_status, outcome: nil }
|
|
19
|
+
end
|
|
20
|
+
if payload[:runner_class].to_s.strip.empty? || payload[:function].to_s.strip.empty?
|
|
21
|
+
return { success: false, skipped: true, reason: :missing_task_context, outcome: nil }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
owner = Legion::Extensions::Agentic::Learning::OutcomeListener::Client
|
|
25
|
+
owner.outcome_mutex.synchronize do
|
|
26
|
+
process_unique_outcome(payload, owner)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def process_unique_outcome(payload, owner)
|
|
33
|
+
message_id = payload[:message_id]
|
|
34
|
+
key = [payload[:source_agent] || payload[:agent_id], message_id]
|
|
35
|
+
tracked = !message_id.to_s.strip.empty?
|
|
36
|
+
return owner.processed_outcomes[key].merge(skipped: true, reason: :duplicate_outcome) if tracked && owner.processed_outcomes.key?(key)
|
|
37
|
+
|
|
38
|
+
result = process_task_outcome(payload)
|
|
39
|
+
if tracked
|
|
40
|
+
owner.processed_outcomes[key] = result
|
|
41
|
+
owner.processed_outcomes.shift while owner.processed_outcomes.size > Helpers::Constants::MAX_OUTCOME_HISTORY
|
|
42
|
+
end
|
|
43
|
+
result
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def process_task_outcome(payload)
|
|
14
47
|
runner_class_name = payload[:runner_class].to_s
|
|
15
48
|
status = payload[:status].to_s
|
|
16
49
|
domain = Helpers::DomainExtractor.extract(runner_class_name)
|
|
@@ -26,7 +59,6 @@ module Legion
|
|
|
26
59
|
lesson = Helpers::LessonBuilder.build(
|
|
27
60
|
runner_class: runner_class_name,
|
|
28
61
|
function: payload[:function],
|
|
29
|
-
status: status,
|
|
30
62
|
domain: domain,
|
|
31
63
|
success: success,
|
|
32
64
|
source_agent: source_agent
|
|
@@ -36,13 +68,12 @@ module Legion
|
|
|
36
68
|
end
|
|
37
69
|
|
|
38
70
|
log.debug "[outcome_listener] domain=#{domain} success=#{success} updates=#{updates.keys}"
|
|
39
|
-
{ success:
|
|
71
|
+
{ success: updates.values.none? { |update| update.key?(:error) },
|
|
72
|
+
domain: domain, outcome: success, evidence: :technical_execution, updates: updates }
|
|
40
73
|
end
|
|
41
74
|
|
|
42
|
-
private
|
|
43
|
-
|
|
44
75
|
def update_meta_learning_model(domain, success)
|
|
45
|
-
meta =
|
|
76
|
+
meta = Legion::Extensions::Agentic::Learning::OutcomeListener::Client.meta_client
|
|
46
77
|
domain_id = resolve_domain_id(meta, domain)
|
|
47
78
|
meta.record_learning_episode(domain_id: domain_id, success: success)
|
|
48
79
|
rescue StandardError => e
|
|
@@ -51,7 +82,7 @@ module Legion
|
|
|
51
82
|
end
|
|
52
83
|
|
|
53
84
|
def update_learning_rate_model(domain, success)
|
|
54
|
-
lr =
|
|
85
|
+
lr = Legion::Extensions::Agentic::Learning::OutcomeListener::Client.learning_rate_client
|
|
55
86
|
lr.record_prediction(correct: success, domain: domain.to_sym)
|
|
56
87
|
rescue StandardError => e
|
|
57
88
|
log.warn "[outcome_listener] learning_rate update failed: #{e.message}"
|
|
@@ -59,7 +90,7 @@ module Legion
|
|
|
59
90
|
end
|
|
60
91
|
|
|
61
92
|
def update_scaffolding_model(domain, success, payload)
|
|
62
|
-
sc =
|
|
93
|
+
sc = Legion::Extensions::Agentic::Learning::OutcomeListener::Client.scaffolding_client
|
|
63
94
|
scaffolds = sc.engine.by_domain(domain: domain)
|
|
64
95
|
return { skipped: true, reason: :no_scaffold } if scaffolds.empty?
|
|
65
96
|
|
|
@@ -72,7 +103,7 @@ module Legion
|
|
|
72
103
|
end
|
|
73
104
|
|
|
74
105
|
def resolve_domain_id(meta, domain)
|
|
75
|
-
map =
|
|
106
|
+
map = Legion::Extensions::Agentic::Learning::OutcomeListener::Client.domain_map
|
|
76
107
|
return map[domain] if map.key?(domain)
|
|
77
108
|
|
|
78
109
|
result = meta.create_learning_domain(name: domain)
|
|
@@ -83,11 +114,15 @@ module Legion
|
|
|
83
114
|
def write_apollo_lesson(lesson, source_agent)
|
|
84
115
|
return unless defined?(Legion::Apollo)
|
|
85
116
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
117
|
+
Legion::Apollo.ingest( # rubocop:disable Legion/HelperMigration/DirectKnowledge
|
|
118
|
+
content: json_generate(lesson),
|
|
119
|
+
tags: ['task_outcome', lesson[:domain]].compact,
|
|
120
|
+
source_agent: source_agent,
|
|
121
|
+
access_scope: 'private',
|
|
122
|
+
content_type: 'task_outcome_lesson',
|
|
123
|
+
knowledge_domain: 'learning',
|
|
124
|
+
identity_principal_id: nil
|
|
125
|
+
)
|
|
91
126
|
rescue StandardError => e
|
|
92
127
|
log.warn "[outcome_listener] apollo write failed: #{e.message}"
|
|
93
128
|
end
|
|
@@ -98,7 +133,7 @@ module Legion
|
|
|
98
133
|
Legion::Settings.dig(:agentic, :learning, :write_to_apollo) != false
|
|
99
134
|
rescue StandardError => e
|
|
100
135
|
log.warn "[outcome_listener] write_to_apollo? check failed: #{e.message}"
|
|
101
|
-
|
|
136
|
+
false
|
|
102
137
|
end
|
|
103
138
|
|
|
104
139
|
def min_lesson_severity
|
|
@@ -199,4 +199,58 @@ RSpec.describe Legion::Extensions::Agentic::Learning::Curiosity::Runners::Curios
|
|
|
199
199
|
expect(client.send(:query_llm_for_wonder, 'why?', :curiosity)).to be_nil
|
|
200
200
|
end
|
|
201
201
|
end
|
|
202
|
+
|
|
203
|
+
describe '#store_insight_in_apollo' do
|
|
204
|
+
before do
|
|
205
|
+
mock_apollo = Module.new do
|
|
206
|
+
def self.started?
|
|
207
|
+
true
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def self.ingest(**)
|
|
211
|
+
{ success: true }
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
stub_const('Legion::Apollo', mock_apollo)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
it 'passes access_scope: private to Legion::Apollo.ingest' do
|
|
218
|
+
allow(Legion::Apollo).to receive(:ingest).and_return({ success: true })
|
|
219
|
+
client.send(:store_insight_in_apollo, 'How does Consul ACL work?', 'found it', 'consul')
|
|
220
|
+
expect(Legion::Apollo).to have_received(:ingest).with(
|
|
221
|
+
hash_including(access_scope: 'private')
|
|
222
|
+
)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
it 'includes the domain and question in the ingested content' do
|
|
226
|
+
allow(Legion::Apollo).to receive(:ingest).and_return({ success: true })
|
|
227
|
+
client.send(:store_insight_in_apollo, 'What is raft?', 'distributed consensus', 'distributed_systems')
|
|
228
|
+
expect(Legion::Apollo).to have_received(:ingest).with(
|
|
229
|
+
hash_including(
|
|
230
|
+
content: a_string_including('distributed_systems'),
|
|
231
|
+
tags: array_including('gaia-self-inquiry', 'domain-distributed_systems')
|
|
232
|
+
)
|
|
233
|
+
)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
it 'is a no-op when Legion::Apollo is not defined' do
|
|
237
|
+
hide_const('Legion::Apollo')
|
|
238
|
+
expect { client.send(:store_insight_in_apollo, 'q?', 'insight', 'test') }.not_to raise_error
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
it 'does not inject process identity as the owner' do
|
|
242
|
+
stub_const('Legion::Identity::Process', Module.new do
|
|
243
|
+
extend self
|
|
244
|
+
|
|
245
|
+
define_method(:identity_hash) do
|
|
246
|
+
{ canonical_name: 'daemon', db_principal_id: 999, db_identity_id: 888 }
|
|
247
|
+
end
|
|
248
|
+
end)
|
|
249
|
+
allow(Legion::Apollo).to receive(:ingest).and_return({ success: true })
|
|
250
|
+
client.send(:store_insight_in_apollo, 'q?', 'insight', 'test')
|
|
251
|
+
expect(Legion::Apollo).to have_received(:ingest).with(
|
|
252
|
+
hash_including(identity_principal_id: nil)
|
|
253
|
+
)
|
|
254
|
+
end
|
|
255
|
+
end
|
|
202
256
|
end
|
data/spec/legion/extensions/agentic/learning/outcome_listener/helpers/lesson_builder_spec.rb
CHANGED
|
@@ -9,7 +9,6 @@ RSpec.describe Legion::Extensions::Agentic::Learning::OutcomeListener::Helpers::
|
|
|
9
9
|
described_class.build(
|
|
10
10
|
runner_class: 'Legion::Extensions::Http::Runners::Get',
|
|
11
11
|
function: 'fetch',
|
|
12
|
-
status: 'task.completed',
|
|
13
12
|
domain: 'http',
|
|
14
13
|
success: true,
|
|
15
14
|
source_agent: 'agent-1'
|
|
@@ -20,7 +19,6 @@ RSpec.describe Legion::Extensions::Agentic::Learning::OutcomeListener::Helpers::
|
|
|
20
19
|
described_class.build(
|
|
21
20
|
runner_class: 'Legion::Extensions::Consul::Runners::Kv',
|
|
22
21
|
function: 'put',
|
|
23
|
-
status: 'task.failed',
|
|
24
22
|
domain: 'consul',
|
|
25
23
|
success: false,
|
|
26
24
|
source_agent: 'agent-2'
|
|
@@ -54,14 +52,6 @@ RSpec.describe Legion::Extensions::Agentic::Learning::OutcomeListener::Helpers::
|
|
|
54
52
|
it 'includes recorded_at timestamp' do
|
|
55
53
|
expect(success_lesson[:recorded_at]).to be_a(Time)
|
|
56
54
|
end
|
|
57
|
-
|
|
58
|
-
it 'builds lesson text for success' do
|
|
59
|
-
expect(success_lesson[:lesson]).to include('completed successfully')
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
it 'builds lesson text for failure' do
|
|
63
|
-
expect(failure_lesson[:lesson]).to include('failed')
|
|
64
|
-
end
|
|
65
55
|
end
|
|
66
56
|
|
|
67
57
|
describe '.build_situation' do
|
data/spec/legion/extensions/agentic/learning/outcome_listener/runners/outcome_listener_spec.rb
CHANGED
|
@@ -109,10 +109,171 @@ RSpec.describe Legion::Extensions::Agentic::Learning::OutcomeListener::Runners::
|
|
|
109
109
|
expect(episodes.proficiency).to be > 0.0
|
|
110
110
|
end
|
|
111
111
|
|
|
112
|
-
it '
|
|
112
|
+
it 'ignores missing outcomes without creating learning state' do
|
|
113
113
|
result = client.process_outcome({})
|
|
114
|
+
expect(result).to include(success: false, skipped: true, outcome: nil)
|
|
115
|
+
expect(Legion::Extensions::Agentic::Learning::OutcomeListener::Client.meta_client.meta_learning_stats[:episode_count]).to eq(0)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
it 'consumes keyword-dispatched technical failures from the current runner' do
|
|
119
|
+
result = client.process_outcome(**failed_payload, status: 'task.exception')
|
|
120
|
+
expect(result).to include(success: true, outcome: false, evidence: :technical_execution)
|
|
121
|
+
expect(result[:updates][:meta_learning]).to include(domain_name: 'consul', success: false)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
it 'ignores nonterminal and unrecognized statuses without changing existing learning' do
|
|
125
|
+
client.process_outcome(completed_payload)
|
|
126
|
+
owner = Legion::Extensions::Agentic::Learning::OutcomeListener::Client
|
|
127
|
+
before = owner.meta_client.learning_domain_ranking
|
|
128
|
+
|
|
129
|
+
[nil, 'task.queued', 'transformer.succeeded', 'unexpected'].each do |status|
|
|
130
|
+
result = client.process_outcome(**completed_payload, status: status)
|
|
131
|
+
expect(result).to include(success: false, skipped: true, reason: :unrecognized_status, outcome: nil)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
expect(owner.meta_client.learning_domain_ranking).to eq(before)
|
|
135
|
+
expect(owner.meta_client.meta_learning_stats[:episode_count]).to eq(1)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
it 'does not attribute context-free transport status updates to an unknown domain' do
|
|
139
|
+
result = client.process_outcome(task_id: 42, status: 'task.completed')
|
|
140
|
+
expect(result).to include(success: false, reason: :missing_task_context)
|
|
141
|
+
expect(Legion::Extensions::Agentic::Learning::OutcomeListener::Client.meta_client.meta_learning_stats[:domain_count]).to eq(0)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
it 'does not reinforce a redelivered identified outcome twice across clients' do
|
|
145
|
+
payload = completed_payload.merge(task_id: 42, message_id: 'delivery-1')
|
|
146
|
+
client.process_outcome(**payload)
|
|
147
|
+
other = Legion::Extensions::Agentic::Learning::OutcomeListener::Client.new
|
|
148
|
+
result = other.process_outcome(**payload)
|
|
149
|
+
expect(result).to include(skipped: true, reason: :duplicate_outcome)
|
|
150
|
+
expect(Legion::Extensions::Agentic::Learning::OutcomeListener::Client.meta_client.meta_learning_stats[:episode_count]).to eq(1)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
it 'reinforces concurrent redeliveries only once per shared state lifecycle' do
|
|
154
|
+
owner = Legion::Extensions::Agentic::Learning::OutcomeListener::Client
|
|
155
|
+
payload = completed_payload.merge(message_id: 'concurrent-delivery')
|
|
156
|
+
|
|
157
|
+
2.times do
|
|
158
|
+
ready = Queue.new
|
|
159
|
+
start = Queue.new
|
|
160
|
+
workers = Array.new(8) do
|
|
161
|
+
Thread.new do
|
|
162
|
+
listener = owner.new
|
|
163
|
+
ready << true
|
|
164
|
+
start.pop
|
|
165
|
+
listener.process_outcome(**payload)
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
workers.size.times { ready.pop }
|
|
169
|
+
workers.size.times { start << true }
|
|
170
|
+
results = workers.map(&:value)
|
|
171
|
+
|
|
172
|
+
expect(results.count { |result| result[:reason] == :duplicate_outcome }).to eq(7)
|
|
173
|
+
expect(results).to all(include(success: true, outcome: true))
|
|
174
|
+
expect(owner.meta_client.meta_learning_stats[:episode_count]).to eq(1)
|
|
175
|
+
owner.reset!
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
it 'keeps different agents and a later terminal status distinct' do
|
|
180
|
+
payload = completed_payload.merge(task_id: 42, message_id: 'delivery-1')
|
|
181
|
+
client.process_outcome(**payload)
|
|
182
|
+
client.process_outcome(**payload, source_agent: 'agent-2')
|
|
183
|
+
client.process_outcome(**payload, status: 'task.exception', message_id: 'delivery-2')
|
|
184
|
+
owner = Legion::Extensions::Agentic::Learning::OutcomeListener::Client
|
|
185
|
+
domain = owner.meta_client.learning_domain_ranking[:ranking].first
|
|
186
|
+
expect(domain).to include(successes: 2, failures: 1)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
it 'does not treat repeated task IDs as delivery identities' do
|
|
190
|
+
2.times { client.process_outcome(**completed_payload, task_id: 42) }
|
|
191
|
+
owner = Legion::Extensions::Agentic::Learning::OutcomeListener::Client
|
|
192
|
+
expect(owner.meta_client.meta_learning_stats[:episode_count]).to eq(2)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
it 'updates the same scaffold decision when invoked as a framework runner module' do
|
|
196
|
+
owner = Legion::Extensions::Agentic::Learning::OutcomeListener::Client
|
|
197
|
+
scaffold = owner.scaffolding_client.create_scaffold(skill_name: 'http_requests', domain: 'http')
|
|
198
|
+
id = scaffold[:scaffold][:id]
|
|
199
|
+
before = owner.scaffolding_client.recommend_scaffolded_task(scaffold_id: id)
|
|
200
|
+
runner = Module.new
|
|
201
|
+
runner.extend(described_class)
|
|
202
|
+
result = runner.process_outcome(**completed_payload)
|
|
203
|
+
|
|
114
204
|
expect(result[:success]).to be true
|
|
115
|
-
|
|
205
|
+
after = owner.scaffolding_client.recommend_scaffolded_task(scaffold_id: id)
|
|
206
|
+
expect(after[:recommendation][:difficulty]).to be > before[:recommendation][:difficulty]
|
|
207
|
+
expect(owner.meta_client.meta_learning_stats[:episode_count]).to eq(1)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
it 'fails closed when the lesson publication setting cannot be read' do
|
|
211
|
+
allow(Legion::Settings).to receive(:dig).with(:agentic, :learning, :write_to_apollo).and_raise(IOError, 'unavailable')
|
|
212
|
+
apollo = Module.new
|
|
213
|
+
stub_const('Legion::Apollo', apollo)
|
|
214
|
+
expect(apollo).not_to receive(:ingest)
|
|
215
|
+
|
|
216
|
+
expect(client.process_outcome(**completed_payload)[:success]).to be true
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
describe '#write_apollo_lesson' do
|
|
221
|
+
let(:lesson) do
|
|
222
|
+
{ runner_class: 'Http::Get', function: 'fetch', status: 'task.completed',
|
|
223
|
+
domain: 'http', success: true, confidence: 0.8 }
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
before do
|
|
227
|
+
mock_apollo = Module.new do
|
|
228
|
+
def self.ingest(**)
|
|
229
|
+
{ success: true }
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
stub_const('Legion::Apollo', mock_apollo)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
it 'passes access_scope: private to Legion::Apollo.ingest' do
|
|
236
|
+
allow(Legion::Apollo).to receive(:ingest).and_return({ success: true })
|
|
237
|
+
client.send(:write_apollo_lesson, lesson, 'agent-1')
|
|
238
|
+
expect(Legion::Apollo).to have_received(:ingest).with(
|
|
239
|
+
hash_including(access_scope: 'private')
|
|
240
|
+
)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
it 'includes source_agent in the ingest call' do
|
|
244
|
+
allow(Legion::Apollo).to receive(:ingest).and_return({ success: true })
|
|
245
|
+
client.send(:write_apollo_lesson, lesson, 'agent-99')
|
|
246
|
+
expect(Legion::Apollo).to have_received(:ingest).with(
|
|
247
|
+
hash_including(source_agent: 'agent-99')
|
|
248
|
+
)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
it 'tags the entry with task_outcome and domain' do
|
|
252
|
+
allow(Legion::Apollo).to receive(:ingest).and_return({ success: true })
|
|
253
|
+
client.send(:write_apollo_lesson, lesson, 'agent-1')
|
|
254
|
+
expect(Legion::Apollo).to have_received(:ingest).with(
|
|
255
|
+
hash_including(tags: array_including('task_outcome', 'http'))
|
|
256
|
+
)
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
it 'is a no-op when Legion::Apollo is not defined' do
|
|
260
|
+
hide_const('Legion::Apollo')
|
|
261
|
+
expect { client.send(:write_apollo_lesson, lesson, 'agent-1') }.not_to raise_error
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
it 'does not inject process identity as the owner' do
|
|
265
|
+
stub_const('Legion::Identity::Process', Module.new do
|
|
266
|
+
extend self
|
|
267
|
+
|
|
268
|
+
define_method(:identity_hash) do
|
|
269
|
+
{ canonical_name: 'daemon', db_principal_id: 999, db_identity_id: 888 }
|
|
270
|
+
end
|
|
271
|
+
end)
|
|
272
|
+
allow(Legion::Apollo).to receive(:ingest).and_return({ success: true })
|
|
273
|
+
client.send(:write_apollo_lesson, lesson, 'agent-1')
|
|
274
|
+
expect(Legion::Apollo).to have_received(:ingest).with(
|
|
275
|
+
hash_including(identity_principal_id: nil)
|
|
276
|
+
)
|
|
116
277
|
end
|
|
117
278
|
end
|
|
118
279
|
end
|