pwn 0.5.642 → 0.5.647
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/Gemfile +1 -1
- data/README.md +1 -1
- data/documentation/Cron.md +3 -2
- data/documentation/Home.md +1 -1
- data/documentation/Mistakes.md +13 -0
- data/documentation/Reinforcement-Learning.md +37 -3
- data/documentation/Skills-Memory-Learning.md +5 -3
- data/documentation/diagrams/dot/pwn-ai-feedback-learning-loop.dot +3 -3
- data/documentation/diagrams/dot/reinforcement-learning.dot +8 -8
- data/documentation/diagrams/pwn-ai-feedback-learning-loop.svg +318 -319
- data/documentation/diagrams/reinforcement-learning.svg +188 -187
- data/documentation/pwn-ai-Agent.md +5 -4
- data/lib/pwn/ai/agent/curriculum.rb +368 -32
- data/lib/pwn/ai/agent/learning.rb +159 -13
- data/lib/pwn/ai/agent/loop.rb +75 -4
- data/lib/pwn/ai/agent/metrics.rb +140 -10
- data/lib/pwn/ai/agent/mistakes.rb +26 -8
- data/lib/pwn/ai/agent/registry.rb +5 -2
- data/lib/pwn/ai/agent/reward.rb +279 -5
- data/lib/pwn/ai/agent/tools/reward.rb +66 -0
- data/lib/pwn/ai/anthropic.rb +265 -4
- data/lib/pwn/ai/open_ai.rb +307 -4
- data/lib/pwn/config.rb +50 -9
- data/lib/pwn/cron.rb +14 -2
- data/lib/pwn/version.rb +1 -1
- data/spec/integration/reinforced_feedback_loop_spec.rb +507 -12
- data/spec/lib/pwn/ai/agent/reward_spec.rb +64 -1
- data/third_party/pwn_rdoc.jsonl +35 -2
- metadata +3 -3
|
@@ -168,7 +168,10 @@ module PWN
|
|
|
168
168
|
# C2 — strict success:true only (excludes HER success:'soft'). Also
|
|
169
169
|
# down-weight any residual hindsight-tagged rows so partial failures
|
|
170
170
|
# never launder into full-strength few-shot exemplars.
|
|
171
|
+
# P20 — strict success:true AND prefer high judge scores. Drop rows
|
|
172
|
+
# with explicit low ORM score so proxy-true / judge-low cannot be few-shot.
|
|
171
173
|
pool = outcomes(limit: 500, success: true).reject { |r| r[:session_id].to_s.empty? }
|
|
174
|
+
pool = pool.reject { |r| r.key?(:score) && r[:score].to_f < 0.6 }
|
|
172
175
|
scored = pool.map do |r|
|
|
173
176
|
sim = tokens.count { |t| r[:task].to_s.downcase.include?(t) }.to_f / tokens.length
|
|
174
177
|
age_d = (now - Time.parse(r[:timestamp].to_s)) / 86_400.0
|
|
@@ -202,26 +205,65 @@ module PWN
|
|
|
202
205
|
# Modelfile` over the export - the only path to ACTUAL parity with a
|
|
203
206
|
# frontier model, because it changes the weights not just the scaffold.
|
|
204
207
|
|
|
208
|
+
# P12 — SFT quality gate (as hard as DPO source-cap): drop HER/soft,
|
|
209
|
+
# low judge_score, auto-only noise without score, and PRM-compress
|
|
210
|
+
# trajectories so LoRA is not 5MB of "how we flailed".
|
|
211
|
+
SFT_MIN_SCORE = 0.6
|
|
212
|
+
SFT_MAX_TOOL_CHARS = 1_200
|
|
213
|
+
|
|
205
214
|
public_class_method def self.export_finetune(opts = {})
|
|
206
215
|
fmt = (opts[:format] || :sharegpt).to_sym
|
|
207
216
|
min_tools = (opts[:min_tools] || 1).to_i
|
|
217
|
+
min_score = (opts[:min_score] || SFT_MIN_SCORE).to_f
|
|
218
|
+
compress = opts.key?(:compress) ? opts[:compress] : true
|
|
208
219
|
FileUtils.mkdir_p(FINETUNE_DIR)
|
|
209
220
|
out = opts[:out] || File.join(FINETUNE_DIR, "pwn-#{Time.now.utc.strftime('%Y%m%d')}.jsonl")
|
|
210
221
|
|
|
211
|
-
# 4.1 — exclude HER soft-success
|
|
222
|
+
# 4.1 / P12 — exclude HER soft-success + low-score + untagged auto flail
|
|
212
223
|
gold = outcomes(limit: 10_000, success: true).reject do |r|
|
|
213
|
-
r[:
|
|
214
|
-
|
|
224
|
+
tags = Array(r[:tags]).map(&:to_s)
|
|
225
|
+
soft = r[:success].to_s == 'soft' || tags.intersect?(%w[hindsight her soft])
|
|
226
|
+
low = !r[:score].nil? && r[:score].to_f < min_score
|
|
227
|
+
# require a score when present in corpus era that has scores
|
|
228
|
+
soft || low
|
|
229
|
+
end
|
|
230
|
+
# prefer highest-score outcome per session
|
|
231
|
+
by_sid = {}
|
|
232
|
+
gold.each do |r|
|
|
233
|
+
sid = r[:session_id].to_s
|
|
234
|
+
next if sid.empty?
|
|
235
|
+
|
|
236
|
+
prev = by_sid[sid]
|
|
237
|
+
by_sid[sid] = r if prev.nil? || r[:score].to_f >= prev[:score].to_f
|
|
215
238
|
end
|
|
216
|
-
sids =
|
|
239
|
+
sids = by_sid.keys
|
|
217
240
|
rows = 0
|
|
241
|
+
dropped = { tools: 0, empty: 0, load: 0 }
|
|
218
242
|
File.open(out, 'w') do |f|
|
|
219
243
|
sids.each do |sid|
|
|
220
|
-
t =
|
|
221
|
-
|
|
244
|
+
t = begin
|
|
245
|
+
PWN::Sessions.load(session_id: sid)
|
|
246
|
+
rescue StandardError
|
|
247
|
+
dropped[:load] += 1
|
|
248
|
+
next
|
|
249
|
+
end
|
|
250
|
+
tool_n = t.count { |e| e[:role].to_s == 'tool' }
|
|
251
|
+
if tool_n < min_tools
|
|
252
|
+
dropped[:tools] += 1
|
|
253
|
+
next
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
conv = if compress
|
|
257
|
+
compress_finetune_trace(transcript: t, max_tool_chars: SFT_MAX_TOOL_CHARS)
|
|
258
|
+
else
|
|
259
|
+
t.map { |e| { role: e[:role].to_s, content: e[:content].to_s } }
|
|
260
|
+
.reject { |e| e[:role] == 'system' && e[:content].start_with?('Session started') }
|
|
261
|
+
end
|
|
262
|
+
if conv.nil? || conv.empty? || conv.none? { |m| m[:role].to_s == 'assistant' }
|
|
263
|
+
dropped[:empty] += 1
|
|
264
|
+
next
|
|
265
|
+
end
|
|
222
266
|
|
|
223
|
-
conv = t.map { |e| { role: e[:role].to_s, content: e[:content].to_s } }
|
|
224
|
-
.reject { |e| e[:role] == 'system' && e[:content].start_with?('Session started') }
|
|
225
267
|
line = case fmt
|
|
226
268
|
when :openai_jsonl then { messages: conv }
|
|
227
269
|
else { conversations: conv.map { |m| { from: sharegpt_role(role: m[:role]), value: m[:content] } } }
|
|
@@ -230,7 +272,11 @@ module PWN
|
|
|
230
272
|
rows += 1
|
|
231
273
|
end
|
|
232
274
|
end
|
|
233
|
-
{
|
|
275
|
+
{
|
|
276
|
+
path: out, format: fmt, sessions: sids.length, samples: rows,
|
|
277
|
+
bytes: File.size(out), min_score: min_score, compressed: compress,
|
|
278
|
+
dropped: dropped
|
|
279
|
+
}
|
|
234
280
|
end
|
|
235
281
|
|
|
236
282
|
# Supported Method Parameters::
|
|
@@ -317,6 +363,14 @@ module PWN
|
|
|
317
363
|
# S3 — tool-armed constitutional critic runs BEFORE the reward
|
|
318
364
|
# model so its verdict is evidence, not hindsight.
|
|
319
365
|
# P7 — force critic when W3 says this engine is overconfident
|
|
366
|
+
# P24 — under budget_exhaustion_hot?, skip tool-armed critic entirely
|
|
367
|
+
# (or single-shot text) so the critic cannot re-thrash the budget.
|
|
368
|
+
budget_hot = begin
|
|
369
|
+
defined?(Loop) && Loop.respond_to?(:budget_exhaustion_hot?, true) &&
|
|
370
|
+
Loop.send(:budget_exhaustion_hot?)
|
|
371
|
+
rescue StandardError
|
|
372
|
+
false
|
|
373
|
+
end
|
|
320
374
|
force_critic = begin
|
|
321
375
|
eng = (PWN::Env.dig(:ai, :active) if defined?(PWN::Env))
|
|
322
376
|
cal = defined?(Metrics) ? Metrics.calibration(engine: eng) : { n: 0 }
|
|
@@ -324,7 +378,15 @@ module PWN
|
|
|
324
378
|
rescue StandardError
|
|
325
379
|
false
|
|
326
380
|
end
|
|
327
|
-
crit = if defined?(Curriculum)
|
|
381
|
+
crit = if budget_hot && defined?(Curriculum)
|
|
382
|
+
# P24 — cheap text-only critic: no persona tool swarm
|
|
383
|
+
Curriculum.critic(
|
|
384
|
+
request: opts[:request],
|
|
385
|
+
final: opts[:final],
|
|
386
|
+
session_id: session_id,
|
|
387
|
+
text_only: true
|
|
388
|
+
)
|
|
389
|
+
elsif defined?(Curriculum)
|
|
328
390
|
if force_critic
|
|
329
391
|
prev = (PWN::Env[:ai][:agent][:critic] if defined?(PWN::Env) && PWN::Env[:ai].is_a?(Hash) && PWN::Env[:ai][:agent].is_a?(Hash))
|
|
330
392
|
begin
|
|
@@ -349,7 +411,14 @@ module PWN
|
|
|
349
411
|
# user correction on the previous turn.
|
|
350
412
|
pend = Thread.current[:pwn_pending_pref]
|
|
351
413
|
if pend && ok && defined?(Reward)
|
|
352
|
-
Reward.record_preference(
|
|
414
|
+
Reward.record_preference(
|
|
415
|
+
prompt: pend[:prompt],
|
|
416
|
+
rejected: pend[:rejected],
|
|
417
|
+
chosen: opts[:final].to_s,
|
|
418
|
+
source: :user_correction,
|
|
419
|
+
shape: :revised_answer,
|
|
420
|
+
force: true
|
|
421
|
+
)
|
|
353
422
|
Thread.current[:pwn_pending_pref] = nil
|
|
354
423
|
end
|
|
355
424
|
|
|
@@ -361,13 +430,22 @@ module PWN
|
|
|
361
430
|
session_id: session_id,
|
|
362
431
|
tags: ['auto', 'loop', v[:verdict].to_s]
|
|
363
432
|
)
|
|
433
|
+
# P20 — fold episode ORM score into per-tool Metrics so UCB/Thompson
|
|
434
|
+
# /advantage track judge, not only handler-ok. Attribution: every
|
|
435
|
+
# tool touched this session gets the episode score (coarse but
|
|
436
|
+
# closes the bandit onto the north-star scalar).
|
|
437
|
+
fold_judge_into_metrics(session_id: session_id, score: v[:score])
|
|
364
438
|
# R2 — per-step credit assignment ALWAYS (1.6): failed trajectories
|
|
365
439
|
# are where step credit matters; feed negative steps into counterfactual.
|
|
366
440
|
# C3 — HER soft-relabel on failure only.
|
|
367
441
|
Reward.prm(request: opts[:request], session_id: session_id) if defined?(Reward)
|
|
368
442
|
Curriculum.hindsight(request: opts[:request], final: opts[:final], session_id: session_id) if !ok && defined?(Curriculum)
|
|
369
|
-
# W3 — calibration: predicted (from plan_first) vs actual
|
|
370
|
-
|
|
443
|
+
# W3 — calibration: predicted (from plan_first) vs actual.
|
|
444
|
+
# Always try: also recover p(success)= from session PLAN if the
|
|
445
|
+
# live return value was lost (nil) but plan_first did emit one.
|
|
446
|
+
predicted = opts[:predicted]
|
|
447
|
+
predicted = recover_predicted_from_session(session_id: session_id) if predicted.nil?
|
|
448
|
+
Curriculum.calibrate(predicted: predicted, actual: v[:score], engine: PWN::Env.dig(:ai, :active)) if !predicted.nil? && defined?(Curriculum)
|
|
371
449
|
reflect(session_id: session_id) if ok
|
|
372
450
|
Reward.sentinel if defined?(Reward)
|
|
373
451
|
Extrospection.auto_extrospect(session_id: session_id) if defined?(Extrospection)
|
|
@@ -473,6 +551,46 @@ module PWN
|
|
|
473
551
|
# privates
|
|
474
552
|
# -------------------------------------------------------------
|
|
475
553
|
|
|
554
|
+
# P20 — attribute episode judge score to every tool used in session.
|
|
555
|
+
private_class_method def self.fold_judge_into_metrics(opts = {})
|
|
556
|
+
return unless defined?(Metrics) && Metrics.respond_to?(:record_judge)
|
|
557
|
+
|
|
558
|
+
sid = opts[:session_id]
|
|
559
|
+
score = opts[:score]
|
|
560
|
+
return if sid.to_s.empty? || score.nil?
|
|
561
|
+
return unless defined?(PWN::Sessions)
|
|
562
|
+
|
|
563
|
+
names = PWN::Sessions.load(session_id: sid)
|
|
564
|
+
.select { |e| e[:role].to_s == 'tool' }
|
|
565
|
+
.map { |e| e[:content].to_s[/\A([a-z0-9_]+)\s*→/i, 1] }
|
|
566
|
+
.compact
|
|
567
|
+
.uniq
|
|
568
|
+
names.each { |n| Metrics.record_judge(name: n, score: score) }
|
|
569
|
+
rescue StandardError
|
|
570
|
+
nil
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
# P22 — recover plan_first p(success)= from session transcript when
|
|
574
|
+
# the live return floated away (rescue path / degrade).
|
|
575
|
+
private_class_method def self.recover_predicted_from_session(opts = {})
|
|
576
|
+
# Prefer live stash from plan_first in this process.
|
|
577
|
+
stash = Thread.current[:pwn_plan_predicted]
|
|
578
|
+
return stash.to_f.clamp(0.0, 1.0) unless stash.nil?
|
|
579
|
+
|
|
580
|
+
sid = opts[:session_id]
|
|
581
|
+
return nil if sid.to_s.empty? || !defined?(PWN::Sessions)
|
|
582
|
+
|
|
583
|
+
entries = PWN::Sessions.load(session_id: sid)
|
|
584
|
+
plan = entries.reverse.find do |e|
|
|
585
|
+
e[:role].to_s == 'assistant' && e[:content].to_s.match?(/\bPLAN:\b|p\(success\)\s*=/i)
|
|
586
|
+
end
|
|
587
|
+
return nil unless plan
|
|
588
|
+
|
|
589
|
+
plan[:content].to_s[/p\(success\)\s*=\s*([01](?:\.\d+)?)/i, 1]&.to_f
|
|
590
|
+
rescue StandardError
|
|
591
|
+
nil
|
|
592
|
+
end
|
|
593
|
+
|
|
476
594
|
private_class_method def self.auto_introspect_enabled?
|
|
477
595
|
return false unless defined?(PWN::Env) && PWN::Env.is_a?(Hash)
|
|
478
596
|
|
|
@@ -578,6 +696,34 @@ module PWN
|
|
|
578
696
|
warn "[pwn-ai/learning] fact_check swallowed: #{e.class}: #{e.message}"
|
|
579
697
|
end
|
|
580
698
|
|
|
699
|
+
# P12 — PRM-aware SFT trace: keep user + reward>0 tools (else first N) +
|
|
700
|
+
# final assistant, truncate tool payloads. Drops system noise.
|
|
701
|
+
private_class_method def self.compress_finetune_trace(opts = {})
|
|
702
|
+
t = Array(opts[:transcript])
|
|
703
|
+
cap = (opts[:max_tool_chars] || SFT_MAX_TOOL_CHARS).to_i
|
|
704
|
+
fin_idx = t.rindex { |e| e[:role].to_s == 'assistant' && !e[:content].to_s.strip.empty? }
|
|
705
|
+
return [] if fin_idx.nil?
|
|
706
|
+
|
|
707
|
+
user_idx = t[0...fin_idx].rindex { |e| e[:role].to_s == 'user' }
|
|
708
|
+
return [] if user_idx.nil?
|
|
709
|
+
|
|
710
|
+
window = t[user_idx..fin_idx]
|
|
711
|
+
tools = window.select { |e| e[:role].to_s == 'tool' }
|
|
712
|
+
rewarded = tools.select { |e| e[:step_reward].to_i.positive? }
|
|
713
|
+
tools = rewarded unless rewarded.empty?
|
|
714
|
+
tools = tools.first(8)
|
|
715
|
+
|
|
716
|
+
out = []
|
|
717
|
+
out << { role: 'user', content: t[user_idx][:content].to_s[0, 2_000] }
|
|
718
|
+
tools.each do |e|
|
|
719
|
+
out << { role: 'tool', content: e[:content].to_s[0, cap] }
|
|
720
|
+
end
|
|
721
|
+
out << { role: 'assistant', content: t[fin_idx][:content].to_s[0, 4_000] }
|
|
722
|
+
out
|
|
723
|
+
rescue StandardError
|
|
724
|
+
[]
|
|
725
|
+
end
|
|
726
|
+
|
|
581
727
|
private_class_method def self.compress_exemplar(opts = {})
|
|
582
728
|
sid = opts[:session_id]
|
|
583
729
|
cap = opts[:max_msgs] || 6
|
data/lib/pwn/ai/agent/loop.rb
CHANGED
|
@@ -51,6 +51,10 @@ module PWN
|
|
|
51
51
|
module Loop
|
|
52
52
|
DEFAULT_MAX_ITERS = 777
|
|
53
53
|
ESCALATE_AFTER_FAILS = 4
|
|
54
|
+
# P17 — when empty_final / known thrash shapes dominate, stop before
|
|
55
|
+
# burning the full ollama cap so the corpus is not pure terminal failure.
|
|
56
|
+
BUDGET_HARD_STOP_FAILS = 8
|
|
57
|
+
BUDGET_EMPTY_FINAL_STOP = 3
|
|
54
58
|
|
|
55
59
|
ENGINE_MODS = {
|
|
56
60
|
openai: 'PWN::AI::OpenAI',
|
|
@@ -77,6 +81,25 @@ module PWN
|
|
|
77
81
|
{ role: 'assistant', content: txt, tool_calls: [] }
|
|
78
82
|
end
|
|
79
83
|
|
|
84
|
+
# P17 — true when unresolved agent_loop / assistant_answer budget
|
|
85
|
+
# fingerprints dominate Mistakes.top (the #1 live failure mode).
|
|
86
|
+
private_class_method def self.budget_exhaustion_hot?
|
|
87
|
+
return false unless defined?(Mistakes)
|
|
88
|
+
|
|
89
|
+
top = Mistakes.top(limit: 8, unresolved_only: true)
|
|
90
|
+
return false if top.empty?
|
|
91
|
+
|
|
92
|
+
budgetish = top.count do |m|
|
|
93
|
+
t = m[:tool].to_s
|
|
94
|
+
e = m[:error].to_s.downcase
|
|
95
|
+
t == 'agent_loop' || t == 'assistant_answer' ||
|
|
96
|
+
e.include?('budget exhausted') || e.include?('iteration budget')
|
|
97
|
+
end
|
|
98
|
+
budgetish >= 2 || (budgetish >= 1 && top.first[:tool].to_s == 'agent_loop')
|
|
99
|
+
rescue StandardError
|
|
100
|
+
false
|
|
101
|
+
end
|
|
102
|
+
|
|
80
103
|
private_class_method def self.max_iters
|
|
81
104
|
v = (PWN::Env.dig(:ai, :agent, :max_iters) if defined?(PWN::Env))
|
|
82
105
|
n = v.to_i.positive? ? v.to_i : DEFAULT_MAX_ITERS
|
|
@@ -87,6 +110,11 @@ module PWN
|
|
|
87
110
|
# shrink the tool budget so thrash can't compound on bad plans.
|
|
88
111
|
cal = calibration_state
|
|
89
112
|
n = [n, cal[:max_iters_cap]].min if cal[:overconfident]
|
|
113
|
+
# P17 — tighter default when agent_loop budget_exhaustion dominates
|
|
114
|
+
# open mistakes: finish-under-N is the skill gap, not more thrash.
|
|
115
|
+
if budget_exhaustion_hot?
|
|
116
|
+
n = [n, active_engine == :ollama ? 12 : 20].min
|
|
117
|
+
end
|
|
90
118
|
n
|
|
91
119
|
rescue StandardError
|
|
92
120
|
DEFAULT_MAX_ITERS
|
|
@@ -247,8 +275,15 @@ module PWN
|
|
|
247
275
|
rt = Curriculum.red_team_plan(request: opts[:request], plan: plan)
|
|
248
276
|
messages << { role: 'user', content: rt } if rt
|
|
249
277
|
end
|
|
250
|
-
# W3 — extract predicted p(success) for calibration tracking
|
|
251
|
-
|
|
278
|
+
# W3/P22 — extract predicted p(success) for calibration tracking.
|
|
279
|
+
# Accept p(success)=0.7 | p(success) = .7 | confidence=0.7 on last lines.
|
|
280
|
+
predicted = plan[/p\(\s*success\s*\)\s*=\s*([01]?(?:\.\d+)?)/i, 1]&.to_f
|
|
281
|
+
predicted = plan[/\bconfidence\s*=\s*([01]?(?:\.\d+)?)/i, 1]&.to_f if predicted.nil?
|
|
282
|
+
predicted = predicted.clamp(0.0, 1.0) if predicted
|
|
283
|
+
# Stash so auto_introspect / recover can always see it even if the
|
|
284
|
+
# return value is dropped by a caller rescue.
|
|
285
|
+
Thread.current[:pwn_plan_predicted] = predicted
|
|
286
|
+
predicted
|
|
252
287
|
rescue StandardError => e
|
|
253
288
|
warn "[pwn-ai/loop] plan_first swallowed: #{e.class}: #{e.message}"
|
|
254
289
|
nil
|
|
@@ -498,9 +533,20 @@ module PWN
|
|
|
498
533
|
append_session(session_id: session_id, role: 'user', content: request)
|
|
499
534
|
|
|
500
535
|
predicted = nil
|
|
536
|
+
Thread.current[:pwn_plan_predicted] = nil
|
|
501
537
|
cal_state = calibration_state
|
|
502
538
|
force_plan = cal_state[:force_plan]
|
|
503
|
-
|
|
539
|
+
if (force_plan || agent_flag(key: :plan_first, default: local) || budget_exhaustion_hot?) && !Array(tools).empty?
|
|
540
|
+
predicted = plan_first(messages: messages, request: request)
|
|
541
|
+
# P22 — prefer explicit return; fall back to thread stash
|
|
542
|
+
predicted = Thread.current[:pwn_plan_predicted] if predicted.nil?
|
|
543
|
+
end
|
|
544
|
+
if budget_exhaustion_hot?
|
|
545
|
+
messages << {
|
|
546
|
+
role: 'user',
|
|
547
|
+
content: '[pwn-ai/p17] Budget-exhaustion is the top open failure on this host. Prefer the SHORTEST plan that finishes the ask (≤3 tool calls). Emit a final answer as soon as you have evidence — do not explore.'
|
|
548
|
+
}
|
|
549
|
+
end
|
|
504
550
|
if force_plan && cal_state[:cal]
|
|
505
551
|
messages << {
|
|
506
552
|
role: 'user',
|
|
@@ -589,6 +635,31 @@ module PWN
|
|
|
589
635
|
)
|
|
590
636
|
end
|
|
591
637
|
|
|
638
|
+
# P17 — hard stop: empty-final thrash or cumulative fails past cap.
|
|
639
|
+
# Prefer a short apologetic final over another 10 useless tool dumps
|
|
640
|
+
# that poison ORM/PRM/DPO with terminal failures.
|
|
641
|
+
empty_n = turn_fails['empty_final'].to_i
|
|
642
|
+
fail_n = turn_fails.values.sum
|
|
643
|
+
if empty_n >= BUDGET_EMPTY_FINAL_STOP || fail_n >= BUDGET_HARD_STOP_FAILS
|
|
644
|
+
msg = if empty_n >= BUDGET_EMPTY_FINAL_STOP
|
|
645
|
+
'[pwn-ai] stopped: repeated empty finals (budget thrash guard)'
|
|
646
|
+
else
|
|
647
|
+
'[pwn-ai] stopped: too many in-turn failures (budget thrash guard)'
|
|
648
|
+
end
|
|
649
|
+
if defined?(Mistakes)
|
|
650
|
+
Mistakes.record(
|
|
651
|
+
tool: 'agent_loop',
|
|
652
|
+
error: "budget thrash guard fired empty=#{empty_n} fails=#{fail_n} iter=#{i}",
|
|
653
|
+
session_id: session_id,
|
|
654
|
+
source: :loop,
|
|
655
|
+
shape: :budget_thrash
|
|
656
|
+
)
|
|
657
|
+
end
|
|
658
|
+
append_session(session_id: session_id, role: 'assistant', content: msg)
|
|
659
|
+
Learning.auto_introspect(session_id: session_id, request: request, final: msg, predicted: predicted) if defined?(Learning) && should_auto_introspect?(local: local, turn_fails: turn_fails, iter: i)
|
|
660
|
+
return msg
|
|
661
|
+
end
|
|
662
|
+
|
|
592
663
|
next unless local && !escalated && turn_fails.values.sum >= ESCALATE_AFTER_FAILS
|
|
593
664
|
|
|
594
665
|
hint = escalate(request: request, turn_fails: turn_fails, session_id: session_id)
|
|
@@ -599,7 +670,7 @@ module PWN
|
|
|
599
670
|
escalated = true
|
|
600
671
|
end
|
|
601
672
|
|
|
602
|
-
Mistakes.record(tool: 'agent_loop', error: 'iteration budget exhausted without a final answer', session_id: session_id, source: :loop) if defined?(Mistakes)
|
|
673
|
+
Mistakes.record(tool: 'agent_loop', error: 'iteration budget exhausted without a final answer', session_id: session_id, source: :loop, shape: :budget_exhausted) if defined?(Mistakes)
|
|
603
674
|
'[pwn-ai] iteration budget exhausted'
|
|
604
675
|
end
|
|
605
676
|
|
data/lib/pwn/ai/agent/metrics.rb
CHANGED
|
@@ -122,6 +122,8 @@ module PWN
|
|
|
122
122
|
name: name.to_s,
|
|
123
123
|
calls: calls,
|
|
124
124
|
success_rate: rate,
|
|
125
|
+
judge_rate: judge_rate(name: name),
|
|
126
|
+
effective_rate: effective_rate(name: name),
|
|
125
127
|
avg_duration: avg,
|
|
126
128
|
last_error: b[:last_error],
|
|
127
129
|
last_at: b[:last_at]
|
|
@@ -149,12 +151,19 @@ module PWN
|
|
|
149
151
|
scope = engine.to_s.empty? ? 'historical' : "engine=#{engine}"
|
|
150
152
|
scope = "#{scope}, proxy_distrust=#{distrust.round(2)}" if distrust.positive?
|
|
151
153
|
lines = rows.map do |r|
|
|
154
|
+
# P20 — display effective_rate (judge-blended) when available;
|
|
155
|
+
# fall back to distrust haircut on raw proxy.
|
|
152
156
|
rate = r[:success_rate].to_f
|
|
153
|
-
|
|
154
|
-
adj
|
|
157
|
+
eff = r[:effective_rate]
|
|
158
|
+
adj = if eff
|
|
159
|
+
eff.to_f
|
|
160
|
+
else
|
|
161
|
+
rate - ((rate - 0.5) * distrust)
|
|
162
|
+
end
|
|
155
163
|
err = r[:last_error] ? " last_err=#{r[:last_error][0, 60]}" : ''
|
|
156
|
-
|
|
157
|
-
|
|
164
|
+
jtag = r[:judge_rate] ? " judge=#{(r[:judge_rate].to_f * 100).round(1)}%" : ''
|
|
165
|
+
tag = distrust.positive? || r[:judge_rate] ? ' (adj)' : ''
|
|
166
|
+
" - #{r[:name]}: calls=#{r[:calls]} success=#{(adj * 100).round(1)}%#{tag}#{jtag} avg=#{r[:avg_duration]}s#{err}"
|
|
158
167
|
end
|
|
159
168
|
warn_line = distrust.positive? ? "WARNING: reward proxy diverges from judge — success rates haircut by distrust=#{distrust.round(2)}; prefer judge-scored exemplars over raw rates.\n" : ''
|
|
160
169
|
"#{warn_line}TOOL EFFECTIVENESS (#{scope}, adapt tool choice accordingly)\n#{lines.join("\n")}\n\n"
|
|
@@ -176,7 +185,8 @@ module PWN
|
|
|
176
185
|
t = data[name.to_sym] || blank_bucket
|
|
177
186
|
n = [t[:calls].to_f, 1.0].max
|
|
178
187
|
total = [data.values.sum { |v| v[:calls].to_f }, 1.0].max
|
|
179
|
-
mean
|
|
188
|
+
# P20 — mean from effective_rate (judge-blended when distrust high)
|
|
189
|
+
mean = effective_rate(name: name)
|
|
180
190
|
mean + (c * Math.sqrt(Math.log(total) / n))
|
|
181
191
|
rescue StandardError
|
|
182
192
|
1.0
|
|
@@ -190,7 +200,21 @@ module PWN
|
|
|
190
200
|
|
|
191
201
|
public_class_method def self.thompson(opts = {})
|
|
192
202
|
t = (load[:tools] || {})[opts[:name].to_s.to_sym] || blank_bucket
|
|
193
|
-
|
|
203
|
+
# P20 — when judge samples exist and distrust > 0, tilt Beta toward
|
|
204
|
+
# judge_rate so Thompson explore/exploit tracks ORM not handler-ok.
|
|
205
|
+
ok = t[:ok].to_f
|
|
206
|
+
fail = t[:fail].to_f
|
|
207
|
+
jn = Array(t[:judge_window]).length
|
|
208
|
+
if jn >= 3 && proxy_trust < 0.95
|
|
209
|
+
jr = judge_rate(name: opts[:name]).to_f
|
|
210
|
+
# pseudo-counts from judge window, mixed by distrust
|
|
211
|
+
d = (1.0 - proxy_trust).clamp(0.0, 1.0)
|
|
212
|
+
jok = jr * jn
|
|
213
|
+
jfail = (1.0 - jr) * jn
|
|
214
|
+
ok = (ok * (1.0 - d)) + (jok * d)
|
|
215
|
+
fail = (fail * (1.0 - d)) + (jfail * d)
|
|
216
|
+
end
|
|
217
|
+
beta_sample(alpha: ok + 1.0, beta: fail + 1.0)
|
|
194
218
|
rescue StandardError
|
|
195
219
|
0.5
|
|
196
220
|
end
|
|
@@ -205,14 +229,117 @@ module PWN
|
|
|
205
229
|
t = data[opts[:name].to_s.to_sym]
|
|
206
230
|
return 0.0 unless t
|
|
207
231
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
local
|
|
232
|
+
# P20 — local/global from effective_rate so bandit tracks judge
|
|
233
|
+
# when the handler-ok proxy is hacked (proxy_distrust high).
|
|
234
|
+
local = effective_rate(name: opts[:name])
|
|
235
|
+
rates = data.keys.map { |k| effective_rate(name: k) }
|
|
236
|
+
global = rates.empty? ? 0.5 : (rates.sum / rates.length)
|
|
211
237
|
(local - global).round(3)
|
|
212
238
|
rescue StandardError
|
|
213
239
|
0.0
|
|
214
240
|
end
|
|
215
241
|
|
|
242
|
+
# P18 — rolling mean step_reward for a tool (from Reward.prm annotations
|
|
243
|
+
# folded into Metrics via record_step_reward). Range ≈ [-1, 1].
|
|
244
|
+
public_class_method def self.prm_advantage(opts = {})
|
|
245
|
+
data = load[:tools] || {}
|
|
246
|
+
t = data[opts[:name].to_s.to_sym]
|
|
247
|
+
return 0.0 unless t
|
|
248
|
+
|
|
249
|
+
win = Array(t[:prm_window])
|
|
250
|
+
return 0.0 if win.empty?
|
|
251
|
+
|
|
252
|
+
mean = win.sum.to_f / win.length
|
|
253
|
+
# global mean across tools that have prm signal
|
|
254
|
+
globals = data.values.map { |v| Array(v[:prm_window]) }.reject(&:empty?)
|
|
255
|
+
gmean = if globals.empty?
|
|
256
|
+
0.0
|
|
257
|
+
else
|
|
258
|
+
all = globals.flatten
|
|
259
|
+
all.sum.to_f / all.length
|
|
260
|
+
end
|
|
261
|
+
(mean - gmean).round(3)
|
|
262
|
+
rescue StandardError
|
|
263
|
+
0.0
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# P18 — called by Reward.prm after session annotate so live routing
|
|
267
|
+
# can bias toward tools that recently advanced goals (+1 step_reward).
|
|
268
|
+
public_class_method def self.record_step_reward(opts = {})
|
|
269
|
+
name = opts[:name].to_s
|
|
270
|
+
return if name.empty?
|
|
271
|
+
|
|
272
|
+
rew = opts[:reward].to_f.clamp(-1.0, 1.0)
|
|
273
|
+
m = load
|
|
274
|
+
m[:tools] ||= {}
|
|
275
|
+
t = m[:tools][name.to_sym] ||= blank_bucket
|
|
276
|
+
t[:prm_window] = (Array(t[:prm_window]) + [rew]).last(40)
|
|
277
|
+
t[:prm_sum] = t[:prm_window].sum
|
|
278
|
+
t[:prm_n] = t[:prm_window].length
|
|
279
|
+
save(metrics: m)
|
|
280
|
+
t
|
|
281
|
+
rescue StandardError
|
|
282
|
+
nil
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# P20 — fold ORM judge (0..1) into per-tool telemetry so UCB /
|
|
286
|
+
# Thompson / advantage can prefer judge-grounded rates over the
|
|
287
|
+
# inflated handler-ok proxy when proxy_distrust is high.
|
|
288
|
+
public_class_method def self.record_judge(opts = {})
|
|
289
|
+
name = opts[:name].to_s
|
|
290
|
+
return if name.empty?
|
|
291
|
+
|
|
292
|
+
score = opts[:score].to_f.clamp(0.0, 1.0)
|
|
293
|
+
m = load
|
|
294
|
+
m[:tools] ||= {}
|
|
295
|
+
t = m[:tools][name.to_sym] ||= blank_bucket
|
|
296
|
+
t[:judge_window] = (Array(t[:judge_window]) + [score]).last(40)
|
|
297
|
+
t[:judge_sum] = t[:judge_window].sum.to_f
|
|
298
|
+
t[:judge_n] = t[:judge_window].length
|
|
299
|
+
save(metrics: m)
|
|
300
|
+
t
|
|
301
|
+
rescue StandardError
|
|
302
|
+
nil
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# Mean judge score for a tool (nil when no ORM samples yet).
|
|
306
|
+
public_class_method def self.judge_rate(opts = {})
|
|
307
|
+
t = (load[:tools] || {})[opts[:name].to_s.to_sym]
|
|
308
|
+
return nil unless t
|
|
309
|
+
|
|
310
|
+
win = Array(t[:judge_window])
|
|
311
|
+
return nil if win.empty?
|
|
312
|
+
|
|
313
|
+
(win.sum.to_f / win.length).round(3)
|
|
314
|
+
rescue StandardError
|
|
315
|
+
nil
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# Blended success rate: when proxy_distrust > 0 and judge samples
|
|
319
|
+
# exist, mix judge_rate into the handler-ok rate. distrust=1 → pure
|
|
320
|
+
# judge (or 0.5 if no judge data). distrust=0 → pure proxy.
|
|
321
|
+
public_class_method def self.effective_rate(opts = {})
|
|
322
|
+
name = opts[:name].to_s
|
|
323
|
+
data = load[:tools] || {}
|
|
324
|
+
t = data[name.to_sym]
|
|
325
|
+
return 0.5 unless t
|
|
326
|
+
|
|
327
|
+
calls = [t[:calls].to_f, 1.0].max
|
|
328
|
+
proxy = t[:ok].to_f / calls
|
|
329
|
+
win = Array(t[:window])
|
|
330
|
+
proxy = win.sum.to_f / win.length if win.length >= 3
|
|
331
|
+
distrust = 1.0 - proxy_trust
|
|
332
|
+
jrate = judge_rate(name: name)
|
|
333
|
+
if distrust > 0.05 && !jrate.nil?
|
|
334
|
+
# blend: higher distrust → more judge weight
|
|
335
|
+
((proxy * (1.0 - distrust)) + (jrate * distrust)).clamp(0.0, 1.0).round(3)
|
|
336
|
+
else
|
|
337
|
+
proxy.round(3)
|
|
338
|
+
end
|
|
339
|
+
rescue StandardError
|
|
340
|
+
0.5
|
|
341
|
+
end
|
|
342
|
+
|
|
216
343
|
# Supported Method Parameters::
|
|
217
344
|
# cps = PWN::AI::Agent::Metrics.changepoints
|
|
218
345
|
#
|
|
@@ -360,7 +487,10 @@ module PWN
|
|
|
360
487
|
PWN::AI::Agent::Metrics.to_context(limit: 8, engine: :ollama) # injected by PromptBuilder
|
|
361
488
|
PWN::AI::Agent::Metrics.ucb(name: 'shell') # C1 exploration bonus
|
|
362
489
|
PWN::AI::Agent::Metrics.thompson(name: 'shell') # C1 Beta(ok+1,fail+1) sample
|
|
363
|
-
PWN::AI::Agent::Metrics.advantage(name: 'shell') # C1 local − global
|
|
490
|
+
PWN::AI::Agent::Metrics.advantage(name: 'shell') # C1 local − global (P20 judge-blended)
|
|
491
|
+
PWN::AI::Agent::Metrics.record_judge(name: 'shell', score: 0.8) # P20 ORM→metrics
|
|
492
|
+
PWN::AI::Agent::Metrics.judge_rate(name: 'shell') # P20 mean ORM
|
|
493
|
+
PWN::AI::Agent::Metrics.effective_rate(name: 'shell') # P20 proxy⋈judge
|
|
364
494
|
PWN::AI::Agent::Metrics.changepoints(within_secs: 3600) # E1 CUSUM regime changes
|
|
365
495
|
PWN::AI::Agent::Metrics.record_calibration(predicted: 0.8, actual: 1.0, brier: 0.04, engine: :ollama)
|
|
366
496
|
PWN::AI::Agent::Metrics.calibration(engine: :ollama) # W3 Brier / overconfidence
|
|
@@ -257,15 +257,33 @@ module PWN
|
|
|
257
257
|
importance: 0.9
|
|
258
258
|
)
|
|
259
259
|
end
|
|
260
|
-
# W1 — every resolve is a
|
|
261
|
-
# (
|
|
260
|
+
# W1/P9 — every resolve is a preference pair. Prefer structured
|
|
261
|
+
# winning_trace (+ strategy/tool) over first-line fix prose so DPO
|
|
262
|
+
# learns tool trajectories, not commentary.
|
|
262
263
|
if defined?(Reward)
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
)
|
|
264
|
+
sf = store[key][:structured_fix] || {}
|
|
265
|
+
trace = sf[:winning_trace].to_s.strip
|
|
266
|
+
strat = [sf[:strategy], sf[:tool], sf[:args_template]].compact.map(&:to_s).reject(&:empty?).join(' | ')
|
|
267
|
+
# P21/P25 — only write W1 pairs when we have a real winning_trace.
|
|
268
|
+
# Prose-only resolve still updates Memory lesson + structured_fix;
|
|
269
|
+
# it must NOT flood DPO with fix commentary (shape: :fix_prose).
|
|
270
|
+
if trace.length >= 40
|
|
271
|
+
parts = []
|
|
272
|
+
parts << "STRATEGY: #{strat}" unless strat.empty?
|
|
273
|
+
parts << "WINNING_TRACE:\n#{trace[0, 3_500]}"
|
|
274
|
+
parts << "FIX: #{fix.strip[0, 400]}"
|
|
275
|
+
chosen = parts.join("\n")
|
|
276
|
+
rejected = store[key][:snippet].to_s
|
|
277
|
+
rejected = "FAILING: tool=#{store[key][:tool]} err=#{store[key][:error]}" if rejected.strip.empty?
|
|
278
|
+
Reward.record_preference(
|
|
279
|
+
prompt: "#{store[key][:tool]}: #{store[key][:error]}",
|
|
280
|
+
rejected: rejected,
|
|
281
|
+
chosen: chosen,
|
|
282
|
+
source: :mistakes_resolve,
|
|
283
|
+
shape: :winning_trace,
|
|
284
|
+
meta: { signature: sig, strategy: sf[:strategy], tool: sf[:tool] }.compact
|
|
285
|
+
)
|
|
286
|
+
end
|
|
269
287
|
end
|
|
270
288
|
store[key]
|
|
271
289
|
end
|
|
@@ -136,7 +136,7 @@ module PWN
|
|
|
136
136
|
|
|
137
137
|
tokens = query.scan(/[a-z0-9_]{3,}/).uniq
|
|
138
138
|
# C1 — advantage-weighted router:
|
|
139
|
-
# score = α·keyword_sim + β·advantage + γ·UCB(tool)
|
|
139
|
+
# score = α·keyword_sim + β·advantage + γ·UCB(tool) + δ·prm_advantage
|
|
140
140
|
# UCB gives untried / low-N tools an exploration bonus so a single
|
|
141
141
|
# early failure (before its dep was installed) does not blacklist
|
|
142
142
|
# it forever; advantage prefers tools that outperform the fleet.
|
|
@@ -145,12 +145,15 @@ module PWN
|
|
|
145
145
|
trust = defined?(Metrics) && Metrics.respond_to?(:proxy_trust) ? Metrics.proxy_trust : 1.0
|
|
146
146
|
beta = 0.3 * trust
|
|
147
147
|
gamma = 0.2
|
|
148
|
+
# P18 — PRM step_reward advantage closes R2 into the controller
|
|
149
|
+
delta = 0.25 * trust
|
|
148
150
|
scored = entries.map do |e|
|
|
149
151
|
hay = "#{e.name} #{e.toolset} #{e.schema[:description]} #{Array(e.schema.dig(:parameters, :properties)&.keys).join(' ')}".downcase
|
|
150
152
|
sim = tokens.count { |t| hay.include?(t) }
|
|
151
153
|
adv = defined?(Metrics) && Metrics.respond_to?(:advantage) ? Metrics.advantage(name: e.name) : 0.0
|
|
152
154
|
ucb = defined?(Metrics) && Metrics.respond_to?(:ucb) ? Metrics.ucb(name: e.name) : 0.5
|
|
153
|
-
|
|
155
|
+
prm = defined?(Metrics) && Metrics.respond_to?(:prm_advantage) ? Metrics.prm_advantage(name: e.name) : 0.0
|
|
156
|
+
[e, sim, (alpha * sim) + (beta * adv) + (gamma * ucb) + (delta * prm)]
|
|
154
157
|
end
|
|
155
158
|
scored.reject { |_, sim, _| sim.zero? }
|
|
156
159
|
.sort_by { |_, _, s| -s }
|