pwn 0.5.663 → 0.5.665
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/lib/pwn/ai/agent/curriculum.rb +12 -4
- data/lib/pwn/ai/agent/learning.rb +443 -14
- data/lib/pwn/ai/agent/loop.rb +25 -5
- data/lib/pwn/ai/agent/mistakes.rb +124 -6
- data/lib/pwn/ai/agent/tools/learning.rb +22 -0
- data/lib/pwn/ai/agent/tools/memory.rb +29 -4
- data/lib/pwn/ai/agent/tools/mistakes.rb +22 -0
- data/lib/pwn/ai/agent/tools/sessions.rb +37 -2
- data/lib/pwn/memory.rb +105 -6
- data/lib/pwn/plugins/repl.rb +2 -2
- data/lib/pwn/sessions.rb +268 -4
- data/lib/pwn/version.rb +1 -1
- data/spec/integration/persistence_roundtrip_spec.rb +2 -2
- data/spec/lib/pwn/ai/agent/lean_stores_spec.rb +269 -0
- data/spec/lib/pwn/ai/agent/learning_spec.rb +137 -0
- data/spec/lib/pwn/ai/agent/loop_spec.rb +42 -0
- data/spec/lib/pwn/ai/agent/mistakes_spec.rb +75 -0
- data/spec/lib/pwn/memory_spec.rb +46 -1
- data/spec/lib/pwn/sessions_spec.rb +40 -1
- data/third_party/pwn_rdoc.jsonl +21 -3
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d289c114e06e9e253df8f9071e23c0ed34786b946c04bf570521f808bca54bf2
|
|
4
|
+
data.tar.gz: c045f0f8e5df6b33a0c9927a72aca147c903010e6ff0a888fee7e2ef04b96ace
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 62f913dfe7d20f3154a55a5f2db7f91589141f87b57f0736d0da7bc1f5ae2a843f3f75cb2e274539ce68b9d1ea82cfed1584f0fcf5bf054fbbb2e28764a29028
|
|
7
|
+
data.tar.gz: 67f7cbcb82a68a099fe659caa47477877ce3e5164f3267903ef6b4191b1103d10f558574e5378967b698b1b07f41184ac4f8291598262db31439332b70389f73
|
|
@@ -292,13 +292,21 @@ module PWN
|
|
|
292
292
|
end
|
|
293
293
|
end
|
|
294
294
|
if commit && defined?(Learning)
|
|
295
|
+
# P29 — keep verdict tag score-aligned (same as auto_introspect).
|
|
296
|
+
sc = v[:score].to_f
|
|
297
|
+
verd = if defined?(Learning) && Learning.respond_to?(:verdict_for_score, true)
|
|
298
|
+
Learning.send(:verdict_for_score, score: sc).to_s
|
|
299
|
+
elsif sc >= 0.6 then 'solved'
|
|
300
|
+
elsif sc >= 0.3 then 'partial'
|
|
301
|
+
else 'wrong'
|
|
302
|
+
end
|
|
295
303
|
Learning.note_outcome(
|
|
296
304
|
task: req[0, 120],
|
|
297
|
-
success:
|
|
298
|
-
score:
|
|
299
|
-
details: "offline_judge #{
|
|
305
|
+
success: sc >= 0.6,
|
|
306
|
+
score: sc,
|
|
307
|
+
details: "offline_judge #{verd}(#{sc.round(2)}) #{v[:rationale]}",
|
|
300
308
|
session_id: sid,
|
|
301
|
-
tags:
|
|
309
|
+
tags: ['offline_judge', 'auto', verd]
|
|
302
310
|
)
|
|
303
311
|
end
|
|
304
312
|
scored << { session_id: sid, score: v[:score], verdict: v[:verdict] }
|
|
@@ -11,12 +11,14 @@ module PWN
|
|
|
11
11
|
# PWN::AI::Agent::Learning is the self-improvement engine that closes
|
|
12
12
|
# the pwn-ai feedback loop. It captures task outcomes, mines session
|
|
13
13
|
# transcripts for durable lessons, promotes successful workflows into
|
|
14
|
-
# reusable skills, and
|
|
15
|
-
# agent gets sharper over time instead of
|
|
14
|
+
# reusable skills, and keeps ~/.pwn lean (memory + learning.jsonl +
|
|
15
|
+
# mistakes + sessions) so the agent gets sharper over time instead of
|
|
16
|
+
# accumulating noise.
|
|
16
17
|
#
|
|
17
18
|
# Data flows:
|
|
18
19
|
# Loop.run --(tool telemetry)--> Metrics.record
|
|
19
20
|
# Loop.run --(final answer)----> Learning.auto_introspect (opt-in)
|
|
21
|
+
# auto_introspect --(throttled)--> Learning.gc_stores! # ~/.pwn lean
|
|
20
22
|
# model --(tool calls)------> learning_note_outcome / _distill_skill
|
|
21
23
|
# PromptBuilder <----------------- Learning.to_context + Metrics.to_context
|
|
22
24
|
#
|
|
@@ -34,6 +36,22 @@ module PWN
|
|
|
34
36
|
INTROSPECT_MIN_STAGES = %i[judge note_outcome fold_judge sentinel].freeze
|
|
35
37
|
|
|
36
38
|
MAX_MEMORY_ENTRIES = 200
|
|
39
|
+
# Lean outcome retention — keep gold RL signal, drop bulk auto noise.
|
|
40
|
+
MAX_OUTCOME_ROWS = 800
|
|
41
|
+
OUTCOME_RETAIN_DAYS = 45
|
|
42
|
+
OUTCOME_RECENT_DAYS = 14
|
|
43
|
+
OUTCOME_DETAILS_MAX = 800
|
|
44
|
+
GOLD_MIN_SCORE = 0.6
|
|
45
|
+
EXEMPLARS_POOL_MIN = 200
|
|
46
|
+
FAILURE_WINDOW_MIN = 200
|
|
47
|
+
HIGH_VALUE_TAGS = %w[
|
|
48
|
+
needs_human extro_verify sdr gqrx rl pwn-ai curriculum hindsight her
|
|
49
|
+
].freeze
|
|
50
|
+
LOW_VALUE_ONLY_TAGS = %w[
|
|
51
|
+
auto loop partial wrong solved offline_judge plan_cover_high
|
|
52
|
+
].freeze
|
|
53
|
+
PRUNE_EVERY_N_APPENDS = 25
|
|
54
|
+
|
|
37
55
|
# E3/P26 — only CVE-ids or software-name + full semver (x.y.z).
|
|
38
56
|
# Two-part floats ("cap 0.2", "proxy 1.0", "judge 37.0") are RL
|
|
39
57
|
# metric crumbs that were scraped by verify_as_reward and flooded
|
|
@@ -85,7 +103,7 @@ module PWN
|
|
|
85
103
|
id: Digest::SHA256.hexdigest("#{task}-#{Time.now.to_f}")[0, 12],
|
|
86
104
|
task: task,
|
|
87
105
|
success: success,
|
|
88
|
-
details: opts[:details].to_s[0,
|
|
106
|
+
details: opts[:details].to_s[0, OUTCOME_DETAILS_MAX],
|
|
89
107
|
session_id: opts[:session_id],
|
|
90
108
|
tags: Array(opts[:tags]).map(&:to_s),
|
|
91
109
|
timestamp: Time.now.utc.iso8601
|
|
@@ -93,6 +111,7 @@ module PWN
|
|
|
93
111
|
entry[:score] = opts[:score].to_f if opts.key?(:score)
|
|
94
112
|
FileUtils.mkdir_p(File.dirname(LEARNING_FILE))
|
|
95
113
|
File.open(LEARNING_FILE, 'a') { |f| f.puts(JSON.generate(entry)) }
|
|
114
|
+
maybe_prune_outcomes!
|
|
96
115
|
|
|
97
116
|
# M4 — default: outcomes live in learning.jsonl ONLY.
|
|
98
117
|
# M4.1 — PROCESS SOPs (rubocop/rake/spec after code changes, etc.)
|
|
@@ -160,13 +179,33 @@ module PWN
|
|
|
160
179
|
|
|
161
180
|
public_class_method def self.to_context(opts = {})
|
|
162
181
|
limit = opts[:limit] || 5
|
|
163
|
-
|
|
164
|
-
|
|
182
|
+
# Fetch a wider window so prefer_primary_tasks can drop critic/red_team
|
|
183
|
+
# envelope rows (REQUEST:/GOAL: prefixes) without starving the block.
|
|
184
|
+
rows = prefer_primary_tasks(rows: outcomes(limit: limit * 4)).first(limit)
|
|
185
|
+
fails = prefer_primary_tasks(rows: outcomes(limit: 200, success: false))
|
|
186
|
+
# Do not mirror the same ids under both headings — that doubled the
|
|
187
|
+
# failure signal and made RECENT OUTCOMES == RECENT FAILURES when the
|
|
188
|
+
# last N attempts all failed (the injected block looked "stuck").
|
|
189
|
+
row_ids = rows.map { |r| r[:id] }.compact
|
|
190
|
+
fails = fails.reject { |r| row_ids.include?(r[:id]) }.first(limit)
|
|
165
191
|
return '' if rows.empty? && fails.empty?
|
|
166
192
|
|
|
167
193
|
fmt = lambda do |r|
|
|
168
|
-
flag = r[:success]
|
|
169
|
-
|
|
194
|
+
flag = case r[:success]
|
|
195
|
+
when true then '✓'
|
|
196
|
+
when 'soft', :soft then '∼'
|
|
197
|
+
else '✗'
|
|
198
|
+
end
|
|
199
|
+
score = r.key?(:score) ? format('%.2f', r[:score].to_f) : '-'
|
|
200
|
+
task = display_task(task: r[:task])
|
|
201
|
+
line = " #{flag} [#{score}] #{task} (#{r[:timestamp]})"
|
|
202
|
+
# Surface a one-line cause crumb so the agent can actually learn
|
|
203
|
+
# from failures instead of only seeing that they failed.
|
|
204
|
+
if r[:success] != true
|
|
205
|
+
crumb = cause_crumb(details: r[:details])
|
|
206
|
+
line += "\n cause: #{crumb}" unless crumb.empty?
|
|
207
|
+
end
|
|
208
|
+
line
|
|
170
209
|
end
|
|
171
210
|
s = stats
|
|
172
211
|
jm = s[:judge_mean]
|
|
@@ -468,7 +507,12 @@ module PWN
|
|
|
468
507
|
v = Reward.judge(request: opts[:request], final: opts[:final], session_id: session_id, proxy_ok: proxy_ok) if defined?(Reward)
|
|
469
508
|
v ||= { score: proxy_ok ? 1.0 : 0.0, success: proxy_ok, verdict: proxy_ok ? :solved : :wrong }
|
|
470
509
|
v[:score] = [v[:score], 0.3].min if crit[:verdict] == :flaw
|
|
471
|
-
|
|
510
|
+
# P29 — critic floor used to leave stale verdict=:solved at score=0.3,
|
|
511
|
+
# producing learning.jsonl rows tagged "solved" with success=false
|
|
512
|
+
# (116+ rows). Always resync verdict/success from the final score.
|
|
513
|
+
v[:verdict] = verdict_for_score(score: v[:score])
|
|
514
|
+
v[:success] = v[:score].to_f >= 0.6
|
|
515
|
+
ok = v[:success]
|
|
472
516
|
|
|
473
517
|
# W1 pending user_correction pair
|
|
474
518
|
pend = Thread.current[:pwn_pending_pref]
|
|
@@ -515,11 +559,14 @@ module PWN
|
|
|
515
559
|
outcome_tags = ['auto', 'loop', v[:verdict].to_s]
|
|
516
560
|
outcome_tags << plan_cov[:tag] if plan_cov && plan_cov[:tag]
|
|
517
561
|
outcome_tags << "plan_cover=#{plan_cov[:score]}" if plan_cov && plan_cov[:total].to_i.positive?
|
|
562
|
+
# P29 — persist the bare user ask (strip REQUEST:/GOAL: envelopes at write time)
|
|
563
|
+
task_txt = display_task(task: opts[:request].to_s)
|
|
564
|
+
task_txt = opts[:request].to_s[0, 100] if task_txt.empty?
|
|
518
565
|
note_outcome(
|
|
519
|
-
task:
|
|
566
|
+
task: task_txt,
|
|
520
567
|
success: ok,
|
|
521
568
|
score: v[:score],
|
|
522
|
-
details: "#{v[:verdict]}(#{v[:score].round(2)}) #{v[:rationale]} | #{opts[:final].to_s[0, 200]}",
|
|
569
|
+
details: "#{v[:verdict]}(#{v[:score].to_f.round(2)}) #{v[:rationale]} | #{opts[:final].to_s[0, 200]}",
|
|
523
570
|
session_id: session_id,
|
|
524
571
|
tags: outcome_tags
|
|
525
572
|
)
|
|
@@ -589,6 +636,20 @@ module PWN
|
|
|
589
636
|
stages_skipped << :extrospect
|
|
590
637
|
end
|
|
591
638
|
|
|
639
|
+
# Keep ~/.pwn RL stores lean on the feedback path (memory +
|
|
640
|
+
# learning.jsonl + mistakes + sessions). Throttled; never raises.
|
|
641
|
+
# Disk-only work: skip only hard budget / budget_hot.
|
|
642
|
+
begin
|
|
643
|
+
if !over_hard.call && !budget_hot && should_gc_stores?
|
|
644
|
+
stages_run << :lean_gc
|
|
645
|
+
gc_stores!(current_session_id: session_id)
|
|
646
|
+
elsif should_gc_stores?
|
|
647
|
+
stages_skipped << :lean_gc
|
|
648
|
+
end
|
|
649
|
+
rescue StandardError => e
|
|
650
|
+
warn "[pwn-ai/learning] post-introspect lean swallowed: #{e.class}: #{e.message}"
|
|
651
|
+
end
|
|
652
|
+
|
|
592
653
|
{
|
|
593
654
|
ok: ok,
|
|
594
655
|
score: v[:score],
|
|
@@ -668,21 +729,44 @@ module PWN
|
|
|
668
729
|
# confidence :heuristic auto-gen self-evicts first.
|
|
669
730
|
if mem.size > cap
|
|
670
731
|
now = Time.now.utc
|
|
671
|
-
|
|
732
|
+
scored = mem.map do |k, v|
|
|
733
|
+
if defined?(PWN::Memory) && PWN::Memory.respond_to?(:protected_entry?) &&
|
|
734
|
+
PWN::Memory.protected_entry?(key: k, entry: v)
|
|
735
|
+
next [k, Float::INFINITY]
|
|
736
|
+
end
|
|
737
|
+
|
|
672
738
|
age_d = (now - Time.parse(v[:timestamp].to_s)) / 86_400.0
|
|
673
739
|
ttl_d = (v[:ttl].to_f / 86_400.0)
|
|
674
740
|
imp = (v[:importance] || 0.5).to_f.clamp(0.05, 1.0)
|
|
675
741
|
conf = (v[:confidence] || (v[:source].to_s == 'human' ? 0.95 : 0.5)).to_f.clamp(0.05, 1.0)
|
|
676
742
|
staleness = ttl_d.positive? ? age_d / ttl_d : age_d / 90.0
|
|
677
|
-
|
|
743
|
+
# lower score = drop first; Infinity protected sorts last
|
|
744
|
+
[k, -(staleness / (imp * conf))]
|
|
678
745
|
rescue StandardError
|
|
679
|
-
0.0
|
|
746
|
+
[k, 0.0]
|
|
747
|
+
end
|
|
748
|
+
# sort ascending by score so lowest (most stale/low-imp) first
|
|
749
|
+
ordered = scored.sort_by { |_k, s| s }
|
|
750
|
+
drop = []
|
|
751
|
+
ordered.each do |pair|
|
|
752
|
+
k = pair[0]
|
|
753
|
+
break if mem.size - drop.size <= cap
|
|
754
|
+
next if defined?(PWN::Memory) && PWN::Memory.respond_to?(:protected_entry?) &&
|
|
755
|
+
PWN::Memory.protected_entry?(key: k, entry: mem[k])
|
|
756
|
+
|
|
757
|
+
drop << k
|
|
680
758
|
end
|
|
681
|
-
drop = sorted.first(mem.size - cap).map(&:first)
|
|
682
759
|
drop.each { |k| mem.delete(k) }
|
|
683
760
|
removed.concat(drop)
|
|
684
761
|
end
|
|
685
762
|
PWN::Memory.save(mem: mem, force: mem.empty?)
|
|
763
|
+
if PWN::Memory.respond_to?(:lean!)
|
|
764
|
+
begin
|
|
765
|
+
PWN::Memory.lean!
|
|
766
|
+
rescue StandardError => e
|
|
767
|
+
warn "[pwn-ai/learning] post-consolidate memory.lean! swallowed: #{e.class}: #{e.message}"
|
|
768
|
+
end
|
|
769
|
+
end
|
|
686
770
|
{ removed: removed.uniq.length, remaining: mem.size }
|
|
687
771
|
end
|
|
688
772
|
|
|
@@ -794,6 +878,103 @@ module PWN
|
|
|
794
878
|
nil
|
|
795
879
|
end
|
|
796
880
|
|
|
881
|
+
# P29 — map score → verdict with the same thresholds as Reward.judge.
|
|
882
|
+
private_class_method def self.verdict_for_score(opts = {})
|
|
883
|
+
s = opts[:score].to_f
|
|
884
|
+
return :solved if s >= 0.6
|
|
885
|
+
return :partial if s >= 0.3
|
|
886
|
+
|
|
887
|
+
:wrong
|
|
888
|
+
end
|
|
889
|
+
|
|
890
|
+
# Strip critic/red_team envelope prefixes so the injected block shows
|
|
891
|
+
# the human ask, not "REQUEST:\n…\nANSWER:" / "GOAL:\n…\nPLAN:".
|
|
892
|
+
private_class_method def self.display_task(opts = {})
|
|
893
|
+
t = opts[:task].to_s.gsub(/\s+/, ' ').strip
|
|
894
|
+
if t.match?(/\AREQUEST:\s*/i)
|
|
895
|
+
body = t.sub(/\AREQUEST:\s*/i, '')
|
|
896
|
+
body = body.split(/\bANSWER:\s*/i, 2).first.to_s
|
|
897
|
+
t = body.strip
|
|
898
|
+
elsif t.match?(/\AGOAL:\s*/i)
|
|
899
|
+
body = t.sub(/\AGOAL:\s*/i, '')
|
|
900
|
+
body = body.split(/\bPLAN:\s*/i, 2).first.to_s
|
|
901
|
+
t = body.strip
|
|
902
|
+
end
|
|
903
|
+
t[0, 100]
|
|
904
|
+
end
|
|
905
|
+
|
|
906
|
+
# Prefer bare user goals over REQUEST:/GOAL: swarm envelopes when both
|
|
907
|
+
# describe the same underlying attempt (offline_judge + critic sessions).
|
|
908
|
+
private_class_method def self.prefer_primary_tasks(opts = {})
|
|
909
|
+
rows = Array(opts[:rows])
|
|
910
|
+
return rows if rows.empty?
|
|
911
|
+
|
|
912
|
+
scored = rows.map do |r|
|
|
913
|
+
t = r[:task].to_s
|
|
914
|
+
envelope = t.match?(/\A\s*(REQUEST:|GOAL:)/i) ? 1 : 0
|
|
915
|
+
# Higher is better: bare task first, then newer (rows already newest-first)
|
|
916
|
+
[r, -envelope]
|
|
917
|
+
end
|
|
918
|
+
# stable: keep relative order within same envelope rank
|
|
919
|
+
scored.sort_by.with_index { |(_, rank), i| [rank, i] }.map(&:first)
|
|
920
|
+
end
|
|
921
|
+
|
|
922
|
+
private_class_method def self.cause_crumb(opts = {})
|
|
923
|
+
d = opts[:details].to_s.gsub(/\s+/, ' ').strip
|
|
924
|
+
return '' if d.empty?
|
|
925
|
+
|
|
926
|
+
# Prefer explicit FLAW / CORRECTED crumbs; else verdict(score) head.
|
|
927
|
+
if (m = d.match(/\bFLAW:\s*(.+)\z/i)) || (m = d.match(/\bFLAW:\s*([^|]+)/i))
|
|
928
|
+
return m[1].to_s.strip[0, 120]
|
|
929
|
+
end
|
|
930
|
+
if (m = d.match(/\bCORRECTED:\s*(.+)\z/i))
|
|
931
|
+
return "corrected: #{m[1].to_s.strip[0, 100]}"
|
|
932
|
+
end
|
|
933
|
+
|
|
934
|
+
d[0, 120]
|
|
935
|
+
end
|
|
936
|
+
|
|
937
|
+
# One-shot / on-load repair: rewrite tags+details where verdict label
|
|
938
|
+
# disagrees with score (solved @ 0.3 etc.). Safe to call repeatedly.
|
|
939
|
+
public_class_method def self.reconcile_verdict_tags!(opts = {})
|
|
940
|
+
return { repaired: 0 } unless File.exist?(LEARNING_FILE)
|
|
941
|
+
|
|
942
|
+
dry = opts[:dry_run] ? true : false
|
|
943
|
+
repaired = 0
|
|
944
|
+
lines = File.readlines(LEARNING_FILE)
|
|
945
|
+
out = lines.map do |l|
|
|
946
|
+
r = JSON.parse(l, symbolize_names: true)
|
|
947
|
+
score = r.key?(:score) ? r[:score].to_f : nil
|
|
948
|
+
next l if score.nil?
|
|
949
|
+
|
|
950
|
+
want = verdict_for_score(score: score).to_s
|
|
951
|
+
tags = Array(r[:tags]).map(&:to_s)
|
|
952
|
+
stale = tags & %w[solved partial wrong unknown]
|
|
953
|
+
next l if stale.empty? || stale.include?(want)
|
|
954
|
+
|
|
955
|
+
repaired += 1
|
|
956
|
+
next l if dry
|
|
957
|
+
|
|
958
|
+
cleaned = tags - %w[solved partial wrong unknown]
|
|
959
|
+
cleaned << want
|
|
960
|
+
r[:tags] = cleaned
|
|
961
|
+
# Fix leading "solved(0.3)" style details head when present
|
|
962
|
+
det = r[:details].to_s
|
|
963
|
+
r[:details] = det.sub(
|
|
964
|
+
/\A(solved|partial|wrong|unknown)\(\d+(?:\.\d+)?\)/i,
|
|
965
|
+
"#{want}(#{format('%.2f', score)})"
|
|
966
|
+
)
|
|
967
|
+
r[:success] = (score >= 0.6) if [true, false].include?(r[:success])
|
|
968
|
+
"#{JSON.generate(r)}\n"
|
|
969
|
+
rescue StandardError
|
|
970
|
+
l
|
|
971
|
+
end
|
|
972
|
+
File.write(LEARNING_FILE, out.join) if !dry && repaired.positive?
|
|
973
|
+
{ repaired: repaired, dry_run: dry }
|
|
974
|
+
rescue StandardError => e
|
|
975
|
+
{ repaired: 0, error: "#{e.class}: #{e.message}" }
|
|
976
|
+
end
|
|
977
|
+
|
|
797
978
|
private_class_method def self.auto_introspect_enabled?
|
|
798
979
|
return false unless defined?(PWN::Env) && PWN::Env.is_a?(Hash)
|
|
799
980
|
|
|
@@ -1086,6 +1267,249 @@ module PWN
|
|
|
1086
1267
|
values.first[0, 200]
|
|
1087
1268
|
end
|
|
1088
1269
|
|
|
1270
|
+
# Supported Method Parameters::
|
|
1271
|
+
# result = PWN::AI::Agent::Learning.prune_outcomes!(
|
|
1272
|
+
# dry_run: 'optional - Boolean (default false)',
|
|
1273
|
+
# max_rows: 'optional - hard cap (default MAX_OUTCOME_ROWS)',
|
|
1274
|
+
# retain_days: 'optional - age floor for low-value drop',
|
|
1275
|
+
# recent_days: 'optional - always keep newer than this'
|
|
1276
|
+
# )
|
|
1277
|
+
#
|
|
1278
|
+
# Keep gold RL rows (success+score>=0.6+session_id), recent window,
|
|
1279
|
+
# high-value tags, and near-miss failures. Dedupe by task+success
|
|
1280
|
+
# keeping best score. Truncate details. Never sacrifices exemplar pool.
|
|
1281
|
+
|
|
1282
|
+
public_class_method def self.prune_outcomes!(opts = {})
|
|
1283
|
+
dry = opts[:dry_run] ? true : false
|
|
1284
|
+
max_rows = (opts[:max_rows] || MAX_OUTCOME_ROWS).to_i
|
|
1285
|
+
retain_days = (opts[:retain_days] || OUTCOME_RETAIN_DAYS).to_f
|
|
1286
|
+
recent_days = (opts[:recent_days] || OUTCOME_RECENT_DAYS).to_f
|
|
1287
|
+
details_max = (opts[:details_max] || OUTCOME_DETAILS_MAX).to_i
|
|
1288
|
+
gold_min = (opts[:gold_min_score] || GOLD_MIN_SCORE).to_f
|
|
1289
|
+
|
|
1290
|
+
return { kept: 0, removed: 0, skipped: true } unless File.exist?(LEARNING_FILE)
|
|
1291
|
+
|
|
1292
|
+
before_bytes = File.size(LEARNING_FILE)
|
|
1293
|
+
rows = File.readlines(LEARNING_FILE).map do |l|
|
|
1294
|
+
JSON.parse(l, symbolize_names: true)
|
|
1295
|
+
rescue StandardError
|
|
1296
|
+
nil
|
|
1297
|
+
end.compact
|
|
1298
|
+
|
|
1299
|
+
now = Time.now.utc
|
|
1300
|
+
age_days = lambda do |r|
|
|
1301
|
+
(now - Time.parse(r[:timestamp].to_s)) / 86_400.0
|
|
1302
|
+
rescue StandardError
|
|
1303
|
+
999.0
|
|
1304
|
+
end
|
|
1305
|
+
|
|
1306
|
+
protected_row = lambda do |r|
|
|
1307
|
+
tags = Array(r[:tags]).map(&:to_s)
|
|
1308
|
+
a = age_days.call(r)
|
|
1309
|
+
return true if a <= recent_days
|
|
1310
|
+
return true if r[:success] == true && r.key?(:score) && r[:score].to_f >= gold_min && r[:session_id].to_s != ''
|
|
1311
|
+
return true if r[:success] == true && !r.key?(:score) && r[:session_id].to_s != '' && a <= retain_days
|
|
1312
|
+
return true if tags.intersect?(HIGH_VALUE_TAGS)
|
|
1313
|
+
return true if r[:success] == false && r.key?(:score) && r[:score].to_f >= 0.5
|
|
1314
|
+
|
|
1315
|
+
false
|
|
1316
|
+
end
|
|
1317
|
+
|
|
1318
|
+
rows.reject! { |r| r[:task].to_s.strip.empty? }
|
|
1319
|
+
|
|
1320
|
+
best = {}
|
|
1321
|
+
rows.each do |r|
|
|
1322
|
+
key = [r[:task].to_s.strip.downcase.gsub(/\s+/, ' ')[0, 160], r[:success].to_s]
|
|
1323
|
+
prev = best[key]
|
|
1324
|
+
if prev.nil?
|
|
1325
|
+
best[key] = r
|
|
1326
|
+
else
|
|
1327
|
+
ps = prev.key?(:score) ? prev[:score].to_f : -1.0
|
|
1328
|
+
rs = r.key?(:score) ? r[:score].to_f : -1.0
|
|
1329
|
+
better = rs > ps || (rs == ps && r[:timestamp].to_s > prev[:timestamp].to_s)
|
|
1330
|
+
better ||= protected_row.call(r) && !protected_row.call(prev)
|
|
1331
|
+
best[key] = r if better
|
|
1332
|
+
end
|
|
1333
|
+
end
|
|
1334
|
+
deduped = best.values
|
|
1335
|
+
removed_dupes = rows.size - deduped.size
|
|
1336
|
+
|
|
1337
|
+
truncated = 0
|
|
1338
|
+
deduped.each do |r|
|
|
1339
|
+
d = r[:details].to_s
|
|
1340
|
+
next if d.bytesize <= details_max
|
|
1341
|
+
|
|
1342
|
+
r[:details] = "#{d[0, details_max]}…[compacted]"
|
|
1343
|
+
truncated += 1
|
|
1344
|
+
end
|
|
1345
|
+
|
|
1346
|
+
protected, unprotected = deduped.partition { |r| protected_row.call(r) }
|
|
1347
|
+
|
|
1348
|
+
kept_unprot = unprotected.reject do |r|
|
|
1349
|
+
a = age_days.call(r)
|
|
1350
|
+
tags = Array(r[:tags]).map(&:to_s)
|
|
1351
|
+
score = r.key?(:score) ? r[:score].to_f : 1.0
|
|
1352
|
+
noise_tags = (tags - LOW_VALUE_ONLY_TAGS).empty? && tags.any?
|
|
1353
|
+
a > retain_days && noise_tags && score < 0.4
|
|
1354
|
+
end
|
|
1355
|
+
|
|
1356
|
+
gold = (protected + kept_unprot).select do |r|
|
|
1357
|
+
r[:success] == true && r[:session_id].to_s != '' &&
|
|
1358
|
+
(!r.key?(:score) || r[:score].to_f >= gold_min)
|
|
1359
|
+
end
|
|
1360
|
+
if gold.size < EXEMPLARS_POOL_MIN
|
|
1361
|
+
need = EXEMPLARS_POOL_MIN - gold.size
|
|
1362
|
+
extra = unprotected.select { |r| r[:success] == true && r[:session_id].to_s != '' }
|
|
1363
|
+
.sort_by { |r| r[:timestamp].to_s }
|
|
1364
|
+
.last(need)
|
|
1365
|
+
kept_unprot = (kept_unprot + extra).uniq
|
|
1366
|
+
end
|
|
1367
|
+
|
|
1368
|
+
fails = (protected + kept_unprot).reject { |r| r[:success] == true }
|
|
1369
|
+
if fails.size < FAILURE_WINDOW_MIN
|
|
1370
|
+
need = FAILURE_WINDOW_MIN - fails.size
|
|
1371
|
+
extra = unprotected.reject { |r| r[:success] == true }
|
|
1372
|
+
.sort_by { |r| r[:timestamp].to_s }
|
|
1373
|
+
.last(need)
|
|
1374
|
+
kept_unprot = (kept_unprot + extra).uniq
|
|
1375
|
+
end
|
|
1376
|
+
|
|
1377
|
+
kept = (protected + kept_unprot).uniq
|
|
1378
|
+
if kept.size > max_rows
|
|
1379
|
+
prot_ids = protected.map { |r| r[:id] }.compact
|
|
1380
|
+
over = kept.size - max_rows
|
|
1381
|
+
victims = kept.reject { |r| prot_ids.include?(r[:id]) }
|
|
1382
|
+
.sort_by { |r| r[:timestamp].to_s }
|
|
1383
|
+
.first(over)
|
|
1384
|
+
v_ids = victims.map { |r| r[:id] }
|
|
1385
|
+
kept = kept.reject { |r| v_ids.include?(r[:id]) }
|
|
1386
|
+
end
|
|
1387
|
+
|
|
1388
|
+
kept = kept.sort_by { |r| r[:timestamp].to_s }
|
|
1389
|
+
|
|
1390
|
+
atomic_jsonl_write(path: LEARNING_FILE, rows: kept) unless dry
|
|
1391
|
+
|
|
1392
|
+
{
|
|
1393
|
+
kept: kept.size,
|
|
1394
|
+
removed: (rows.size - kept.size) + removed_dupes,
|
|
1395
|
+
deduped: removed_dupes,
|
|
1396
|
+
truncated_details: truncated,
|
|
1397
|
+
protected: protected.size,
|
|
1398
|
+
bytes_before: before_bytes,
|
|
1399
|
+
bytes_after: if dry
|
|
1400
|
+
before_bytes
|
|
1401
|
+
else
|
|
1402
|
+
(File.exist?(LEARNING_FILE) ? File.size(LEARNING_FILE) : 0)
|
|
1403
|
+
end,
|
|
1404
|
+
dry_run: dry
|
|
1405
|
+
}
|
|
1406
|
+
end
|
|
1407
|
+
|
|
1408
|
+
# Memory lean + outcome prune.
|
|
1409
|
+
public_class_method def self.lean!(opts = {})
|
|
1410
|
+
dry = opts[:dry_run] ? true : false
|
|
1411
|
+
out = { dry_run: dry }
|
|
1412
|
+
out[:memory] = if defined?(PWN::Memory) && PWN::Memory.respond_to?(:lean!)
|
|
1413
|
+
PWN::Memory.lean!(dry_run: dry)
|
|
1414
|
+
else
|
|
1415
|
+
{ skipped: true }
|
|
1416
|
+
end
|
|
1417
|
+
out[:memory_consolidate] = consolidate(max_entries: opts[:max_entries] || MAX_MEMORY_ENTRIES) unless dry
|
|
1418
|
+
out[:learning] = prune_outcomes!(
|
|
1419
|
+
dry_run: dry,
|
|
1420
|
+
max_rows: opts[:max_rows],
|
|
1421
|
+
retain_days: opts[:retain_days],
|
|
1422
|
+
recent_days: opts[:recent_days],
|
|
1423
|
+
details_max: opts[:details_max],
|
|
1424
|
+
gold_min_score: opts[:gold_min_score]
|
|
1425
|
+
)
|
|
1426
|
+
out
|
|
1427
|
+
end
|
|
1428
|
+
|
|
1429
|
+
# One-shot lean across memory + learning + mistakes + sessions.
|
|
1430
|
+
# Called from auto_introspect (throttled) so the RL feedback loop
|
|
1431
|
+
# keeps ~/.pwn high-signal without a manual learning_gc_stores turn.
|
|
1432
|
+
# Supported Method Parameters::
|
|
1433
|
+
# result = PWN::AI::Agent::Learning.gc_stores!(
|
|
1434
|
+
# dry_run: 'optional - Boolean (default false)',
|
|
1435
|
+
# current_session_id: 'optional - never delete this sessions id',
|
|
1436
|
+
# max_entries: 'optional - Memory consolidate cap',
|
|
1437
|
+
# max_rows: 'optional - learning.jsonl cap',
|
|
1438
|
+
# retain_days: 'optional - outcome / session age floor'
|
|
1439
|
+
# )
|
|
1440
|
+
public_class_method def self.gc_stores!(opts = {})
|
|
1441
|
+
dry = opts[:dry_run] ? true : false
|
|
1442
|
+
res = lean!(
|
|
1443
|
+
dry_run: dry,
|
|
1444
|
+
max_entries: opts[:max_entries],
|
|
1445
|
+
max_rows: opts[:max_rows],
|
|
1446
|
+
retain_days: opts[:retain_days],
|
|
1447
|
+
recent_days: opts[:recent_days],
|
|
1448
|
+
details_max: opts[:details_max],
|
|
1449
|
+
gold_min_score: opts[:gold_min_score]
|
|
1450
|
+
)
|
|
1451
|
+
res[:mistakes] = if defined?(Mistakes) && Mistakes.respond_to?(:lean!)
|
|
1452
|
+
Mistakes.lean!(dry_run: dry)
|
|
1453
|
+
else
|
|
1454
|
+
{ skipped: true }
|
|
1455
|
+
end
|
|
1456
|
+
res[:sessions] = if defined?(PWN::Sessions) && PWN::Sessions.respond_to?(:lean!)
|
|
1457
|
+
sess_opts = { dry_run: dry }
|
|
1458
|
+
sid = opts[:current_session_id].to_s
|
|
1459
|
+
sess_opts[:current_session_id] = sid unless sid.empty?
|
|
1460
|
+
sess_opts[:retain_days] = opts[:retain_days] if opts.key?(:retain_days)
|
|
1461
|
+
sess_opts[:max_files] = opts[:max_files] if opts.key?(:max_files)
|
|
1462
|
+
PWN::Sessions.lean!(**sess_opts)
|
|
1463
|
+
else
|
|
1464
|
+
{ skipped: true }
|
|
1465
|
+
end
|
|
1466
|
+
res
|
|
1467
|
+
end
|
|
1468
|
+
|
|
1469
|
+
# True every PRUNE_EVERY_N_APPENDS rows, or once learning.jsonl
|
|
1470
|
+
# exceeds MAX_OUTCOME_ROWS (forces lean even if modulo miss).
|
|
1471
|
+
private_class_method def self.should_gc_stores?
|
|
1472
|
+
return true unless File.exist?(LEARNING_FILE)
|
|
1473
|
+
|
|
1474
|
+
lines = File.foreach(LEARNING_FILE).count
|
|
1475
|
+
return true if lines >= MAX_OUTCOME_ROWS
|
|
1476
|
+
return true if lines.positive? && (lines % PRUNE_EVERY_N_APPENDS).zero?
|
|
1477
|
+
|
|
1478
|
+
false
|
|
1479
|
+
rescue StandardError
|
|
1480
|
+
false
|
|
1481
|
+
end
|
|
1482
|
+
|
|
1483
|
+
private_class_method def self.maybe_prune_outcomes!
|
|
1484
|
+
return unless File.exist?(LEARNING_FILE)
|
|
1485
|
+
|
|
1486
|
+
lines = File.foreach(LEARNING_FILE).count
|
|
1487
|
+
return if lines < MAX_OUTCOME_ROWS && (lines % PRUNE_EVERY_N_APPENDS != 0)
|
|
1488
|
+
|
|
1489
|
+
prune_outcomes!
|
|
1490
|
+
rescue StandardError => e
|
|
1491
|
+
warn "[pwn-ai/learning] maybe_prune_outcomes! swallowed: #{e.class}: #{e.message}"
|
|
1492
|
+
end
|
|
1493
|
+
|
|
1494
|
+
private_class_method def self.atomic_jsonl_write(opts = {})
|
|
1495
|
+
path = opts[:path]
|
|
1496
|
+
rows = Array(opts[:rows])
|
|
1497
|
+
dir = File.dirname(path)
|
|
1498
|
+
FileUtils.mkdir_p(dir)
|
|
1499
|
+
bak = "#{path}.bak-lean-#{Time.now.utc.strftime('%Y%m%d')}"
|
|
1500
|
+
FileUtils.cp(path, bak) if File.exist?(path) && !File.exist?(bak)
|
|
1501
|
+
tmp = File.join(dir, ".#{File.basename(path)}.#{Process.pid}.tmp")
|
|
1502
|
+
File.open(tmp, File::WRONLY | File::CREAT | File::TRUNC, 0o644) do |f|
|
|
1503
|
+
f.flock(File::LOCK_EX)
|
|
1504
|
+
rows.each { |r| f.puts(JSON.generate(r)) }
|
|
1505
|
+
f.flush
|
|
1506
|
+
f.fsync
|
|
1507
|
+
end
|
|
1508
|
+
File.rename(tmp, path)
|
|
1509
|
+
ensure
|
|
1510
|
+
FileUtils.rm_f(tmp) if defined?(tmp) && tmp && File.exist?(tmp)
|
|
1511
|
+
end
|
|
1512
|
+
|
|
1089
1513
|
# Supported Method Parameters::
|
|
1090
1514
|
# PWN::AI::Agent::Learning.purge_noise
|
|
1091
1515
|
#
|
|
@@ -1139,6 +1563,9 @@ module PWN
|
|
|
1139
1563
|
PWN::AI::Agent::Learning.exemplars_for(request: 'nmap sweep 10/8') # few-shot for Loop.run
|
|
1140
1564
|
PWN::AI::Agent::Learning.export_finetune(format: :sharegpt) # -> ~/.pwn/finetune/*.jsonl
|
|
1141
1565
|
PWN::AI::Agent::Learning.consolidate(max_entries: 200) # M1 semantic-merge + M3 importance-evict
|
|
1566
|
+
PWN::AI::Agent::Learning.lean! # memory + learning.jsonl prune
|
|
1567
|
+
PWN::AI::Agent::Learning.gc_stores! # full ~/.pwn RL lean (mem/learn/mistakes/sessions)
|
|
1568
|
+
PWN::AI::Agent::Learning.prune_outcomes! # learning.jsonl gold-keep cap
|
|
1142
1569
|
PWN::AI::Agent::Learning.purge_noise # one-shot GC of pre-R1 garbage lessons
|
|
1143
1570
|
PWN::AI::Agent::Learning.to_context(limit: 5) # injected by PromptBuilder
|
|
1144
1571
|
PWN::AI::Agent::Learning.stats
|
|
@@ -1146,6 +1573,8 @@ module PWN
|
|
|
1146
1573
|
|
|
1147
1574
|
Enable end-of-run auto-learning with:
|
|
1148
1575
|
PWN::Env[:ai][:agent][:auto_introspect] = true
|
|
1576
|
+
# auto_introspect throttles gc_stores! every PRUNE_EVERY_N_APPENDS
|
|
1577
|
+
# outcomes so ~/.pwn stays lean without a manual GC turn.
|
|
1149
1578
|
|
|
1150
1579
|
#{self}.authors
|
|
1151
1580
|
USAGE
|
data/lib/pwn/ai/agent/loop.rb
CHANGED
|
@@ -172,6 +172,9 @@ module PWN
|
|
|
172
172
|
|
|
173
173
|
# P17 — evidence-enough early final: latest tool rounds already answer
|
|
174
174
|
# the ask → force synthesis instead of burning iters into text-only tail.
|
|
175
|
+
# Must NOT fire on routine tool JSON {"success":true} while a multi-step
|
|
176
|
+
# English plan still has open tasks — that blocks legitimate completion
|
|
177
|
+
# (mid-fix "write the complete final answer now" thrash).
|
|
175
178
|
private_class_method def self.evidence_enough_to_finalize?(opts = {})
|
|
176
179
|
messages = Array(opts[:messages])
|
|
177
180
|
turn_fails = opts[:turn_fails] || {}
|
|
@@ -186,10 +189,23 @@ module PWN
|
|
|
186
189
|
fail_n = turn_fails.values.sum
|
|
187
190
|
return false if fail_n >= 3
|
|
188
191
|
|
|
192
|
+
# English-task gate: multi-step plans only early-final on/after the
|
|
193
|
+
# last tangible task. plan_idx is 0-based; open work => not enough.
|
|
194
|
+
ts_state = opts[:ts_state]
|
|
195
|
+
if ts_state.is_a?(Hash)
|
|
196
|
+
plan = Array(ts_state[:plan])
|
|
197
|
+
if plan.length >= 2
|
|
198
|
+
idx = ts_state[:plan_idx].to_i
|
|
199
|
+
return false if idx < (plan.length - 1)
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
189
203
|
tools_ok = messages.select { |msg| msg[:role].to_s == 'tool' }
|
|
190
204
|
return false if tools_ok.size < 2
|
|
191
205
|
|
|
192
206
|
# Last two tool payloads should look like successful evidence, not errors.
|
|
207
|
+
# Agent tool wrappers always emit "success":true on ok — that alone is
|
|
208
|
+
# NOT proof the user goal is done (do not match bare success JSON).
|
|
193
209
|
last2 = tools_ok.last(2)
|
|
194
210
|
return false if last2.any? do |msg|
|
|
195
211
|
content = msg[:content].to_s
|
|
@@ -203,11 +219,14 @@ module PWN
|
|
|
203
219
|
deep_enough = tools_ok.size >= 3 || (short_plan && tools_ok.size >= plan_steps)
|
|
204
220
|
return false unless deep_enough
|
|
205
221
|
|
|
206
|
-
# Request looks satisfied if tool names/content echo key nouns from request
|
|
207
|
-
# OR we clearly completed a mutation (write/patch/resolve) successfully.
|
|
208
222
|
recent_txt = last2.map { |msg| msg[:content].to_s[0, 500] }.join(' ')
|
|
209
|
-
|
|
210
|
-
|
|
223
|
+
# Goal-shaped completion only — write/patch/verify, not shell success wrappers.
|
|
224
|
+
mutation_done = recent_txt.match?(
|
|
225
|
+
/syntax ok|wrote |patched|resolved|File\.write|ruby -c|0 offenses|examples?,\s*0 failures/i
|
|
226
|
+
)
|
|
227
|
+
return true if mutation_done
|
|
228
|
+
return true if short_plan && tools_ok.size >= plan_steps && fail_n.zero? &&
|
|
229
|
+
request.match?(/\b(what|who|when|where|which|how many|status|list|show|print|uname|cwd|version)\b/i)
|
|
211
230
|
|
|
212
231
|
false
|
|
213
232
|
rescue StandardError
|
|
@@ -1072,7 +1091,8 @@ module PWN
|
|
|
1072
1091
|
i: i,
|
|
1073
1092
|
max_iters: max_iters,
|
|
1074
1093
|
request: request,
|
|
1075
|
-
plan_steps: plan_steps
|
|
1094
|
+
plan_steps: plan_steps,
|
|
1095
|
+
ts_state: ts_state
|
|
1076
1096
|
) && turn_fails['evidence_final'].to_i < 1
|
|
1077
1097
|
turn_fails['evidence_final'] += 1
|
|
1078
1098
|
messages << {
|