pwn 0.5.647 → 0.5.650
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/documentation/Agent-Tool-Registry.md +10 -5
- data/documentation/Home.md +1 -1
- data/documentation/How-PWN-Works.md +1 -1
- data/documentation/Reinforcement-Learning.md +36 -1
- data/documentation/What-is-PWN.md +1 -1
- data/documentation/pwn-ai-Agent.md +1 -1
- data/lib/pwn/ai/agent/curriculum.rb +125 -5
- data/lib/pwn/ai/agent/extrospection.rb +18 -4
- data/lib/pwn/ai/agent/learning.rb +171 -63
- data/lib/pwn/ai/agent/loop.rb +72 -9
- data/lib/pwn/ai/agent/metrics.rb +69 -10
- data/lib/pwn/ai/agent/registry.rb +21 -2
- data/lib/pwn/ai/agent/reward.rb +171 -5
- data/lib/pwn/ai/agent/tools/curriculum.rb +23 -0
- data/lib/pwn/ai/agent/tools/reward.rb +20 -0
- data/lib/pwn/ai/agent/tools/ruby_eval.rb +29 -6
- data/lib/pwn/version.rb +1 -1
- data/spec/integration/reinforced_feedback_loop_spec.rb +299 -0
- data/spec/lib/pwn/ai/agent/tools/ruby_eval_spec.rb +39 -1
- data/third_party/pwn_rdoc.jsonl +9 -2
- metadata +1 -1
|
@@ -25,8 +25,41 @@ module PWN
|
|
|
25
25
|
module Learning
|
|
26
26
|
LEARNING_FILE = File.join(Dir.home, '.pwn', 'learning.jsonl')
|
|
27
27
|
FINETUNE_DIR = File.join(Dir.home, '.pwn', 'finetune')
|
|
28
|
+
# P0 — post-answer introspect must not train "stop early" while
|
|
29
|
+
# spending the iteration budget after the final. Soft cap skips
|
|
30
|
+
# expensive stages (tool critic, PRM-LLM, reflect, extrospect);
|
|
31
|
+
# hard cap keeps only note_outcome + judge(heuristic) + sentinel.
|
|
32
|
+
INTROSPECT_SOFT_MS = 2_500
|
|
33
|
+
INTROSPECT_HARD_MS = 8_000
|
|
34
|
+
INTROSPECT_MIN_STAGES = %i[judge note_outcome fold_judge sentinel].freeze
|
|
35
|
+
|
|
28
36
|
MAX_MEMORY_ENTRIES = 200
|
|
29
|
-
|
|
37
|
+
# E3/P26 — only CVE-ids or software-name + full semver (x.y.z).
|
|
38
|
+
# Two-part floats ("cap 0.2", "proxy 1.0", "judge 37.0") are RL
|
|
39
|
+
# metric crumbs that were scraped by verify_as_reward and flooded
|
|
40
|
+
# learning.jsonl with extro_verify :unknown failures.
|
|
41
|
+
CLAIM_METRIC_WORDS = %w[
|
|
42
|
+
cap share proxy judge success only now clears gap score rate mean
|
|
43
|
+
brier overconf distrust trajectory_fraction handler orm prm delta
|
|
44
|
+
limit window pct percent ms iter budget conf confidence n
|
|
45
|
+
ruby python linux kernel host cwd e.g e.g.
|
|
46
|
+
].freeze
|
|
47
|
+
CLAIM_RX = /
|
|
48
|
+
CVE-\d{4}-\d{4,7}
|
|
49
|
+
|
|
|
50
|
+
\b
|
|
51
|
+
(?!
|
|
52
|
+
(?:cap|share|proxy|judge|success|only|now|clears|gap|score|rate|mean|
|
|
53
|
+
brier|overconf|distrust|trajectory_fraction|handler|orm|prm|delta|
|
|
54
|
+
limit|window|pct|percent|ms|iter|budget|conf|confidence|
|
|
55
|
+
ruby|python|linux|kernel|host|cwd|e\.g)
|
|
56
|
+
\b
|
|
57
|
+
)
|
|
58
|
+
[A-Za-z][\w.+-]{2,}
|
|
59
|
+
\s+
|
|
60
|
+
v?\d+\.\d+\.\d+(?:[-+][\w.]+)?
|
|
61
|
+
\b
|
|
62
|
+
/x
|
|
30
63
|
|
|
31
64
|
# Supported Method Parameters::
|
|
32
65
|
# entry = PWN::AI::Agent::Learning.note_outcome(
|
|
@@ -359,18 +392,33 @@ module PWN
|
|
|
359
392
|
return unless session_id
|
|
360
393
|
return unless auto_introspect_enabled?
|
|
361
394
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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.
|
|
395
|
+
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
396
|
+
stages_run = []
|
|
397
|
+
stages_skipped = []
|
|
368
398
|
budget_hot = begin
|
|
369
399
|
defined?(Loop) && Loop.respond_to?(:budget_exhaustion_hot?, true) &&
|
|
370
400
|
Loop.send(:budget_exhaustion_hot?)
|
|
371
401
|
rescue StandardError
|
|
372
402
|
false
|
|
373
403
|
end
|
|
404
|
+
|
|
405
|
+
elapsed_ms = lambda do
|
|
406
|
+
((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000).round
|
|
407
|
+
end
|
|
408
|
+
# soft = skip expensive; hard = stop almost everything
|
|
409
|
+
over_soft = lambda do
|
|
410
|
+
ms = elapsed_ms.call
|
|
411
|
+
ms >= INTROSPECT_SOFT_MS || budget_hot
|
|
412
|
+
end
|
|
413
|
+
over_hard = lambda do
|
|
414
|
+
ms = elapsed_ms.call
|
|
415
|
+
ms >= INTROSPECT_HARD_MS
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
proxy_ok = infer_success(session_id: session_id, final: opts[:final])
|
|
419
|
+
|
|
420
|
+
# S3 critic — BEFORE reward so verdict is evidence.
|
|
421
|
+
# P24/P0 — budget_hot or soft-cap → text_only or skip.
|
|
374
422
|
force_critic = begin
|
|
375
423
|
eng = (PWN::Env.dig(:ai, :active) if defined?(PWN::Env))
|
|
376
424
|
cal = defined?(Metrics) ? Metrics.calibration(engine: eng) : { n: 0 }
|
|
@@ -378,39 +426,51 @@ module PWN
|
|
|
378
426
|
rescue StandardError
|
|
379
427
|
false
|
|
380
428
|
end
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
429
|
+
# P0 — when W1 mix urgently needs :critic pairs, prefer running critic
|
|
430
|
+
need_critic_mix = begin
|
|
431
|
+
mix = defined?(Reward) && Reward.respond_to?(:generator_mix) ? Reward.generator_mix : {}
|
|
432
|
+
Array(mix[:urgent]).include?('critic')
|
|
433
|
+
rescue StandardError
|
|
434
|
+
false
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
crit = { verdict: :pass, source: :skipped }
|
|
438
|
+
# Single skip path (Lint/DuplicateBranch): hard-budget OR no Curriculum.
|
|
439
|
+
if !defined?(Curriculum) || (over_hard.call && !need_critic_mix)
|
|
440
|
+
stages_skipped << :critic
|
|
441
|
+
elsif budget_hot || (over_soft.call && !force_critic && !need_critic_mix)
|
|
442
|
+
stages_run << :critic_text_only
|
|
443
|
+
crit = Curriculum.critic(
|
|
444
|
+
request: opts[:request],
|
|
445
|
+
final: opts[:final],
|
|
446
|
+
session_id: session_id,
|
|
447
|
+
text_only: true
|
|
448
|
+
)
|
|
449
|
+
elsif force_critic
|
|
450
|
+
stages_run << :critic_forced
|
|
451
|
+
prev = (PWN::Env[:ai][:agent][:critic] if defined?(PWN::Env) && PWN::Env[:ai].is_a?(Hash) && PWN::Env[:ai][:agent].is_a?(Hash))
|
|
452
|
+
begin
|
|
453
|
+
PWN::Env[:ai][:agent][:critic] = true if defined?(PWN::Env) && PWN::Env[:ai].is_a?(Hash) && PWN::Env[:ai][:agent].is_a?(Hash) && !PWN::Env[:ai][:agent].frozen?
|
|
454
|
+
crit = Curriculum.critic(request: opts[:request], final: opts[:final], session_id: session_id)
|
|
455
|
+
ensure
|
|
456
|
+
PWN::Env[:ai][:agent][:critic] = prev if defined?(PWN::Env) && PWN::Env[:ai].is_a?(Hash) && PWN::Env[:ai][:agent].is_a?(Hash) && !PWN::Env[:ai][:agent].frozen?
|
|
457
|
+
end
|
|
458
|
+
else
|
|
459
|
+
stages_run << :critic
|
|
460
|
+
crit = Curriculum.critic(request: opts[:request], final: opts[:final], session_id: session_id)
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
# R1 judge — always attempt (heuristic is cheap; LLM gated inside)
|
|
464
|
+
stages_run << :judge
|
|
405
465
|
v = Reward.judge(request: opts[:request], final: opts[:final], session_id: session_id, proxy_ok: proxy_ok) if defined?(Reward)
|
|
406
466
|
v ||= { score: proxy_ok ? 1.0 : 0.0, success: proxy_ok, verdict: proxy_ok ? :solved : :wrong }
|
|
407
467
|
v[:score] = [v[:score], 0.3].min if crit[:verdict] == :flaw
|
|
408
468
|
ok = v[:score] >= 0.6
|
|
409
469
|
|
|
410
|
-
# W1
|
|
411
|
-
# user correction on the previous turn.
|
|
470
|
+
# W1 pending user_correction pair
|
|
412
471
|
pend = Thread.current[:pwn_pending_pref]
|
|
413
472
|
if pend && ok && defined?(Reward)
|
|
473
|
+
stages_run << :user_correction_pref
|
|
414
474
|
Reward.record_preference(
|
|
415
475
|
prompt: pend[:prompt],
|
|
416
476
|
rejected: pend[:rejected],
|
|
@@ -422,6 +482,7 @@ module PWN
|
|
|
422
482
|
Thread.current[:pwn_pending_pref] = nil
|
|
423
483
|
end
|
|
424
484
|
|
|
485
|
+
stages_run << :note_outcome
|
|
425
486
|
note_outcome(
|
|
426
487
|
task: opts[:request].to_s[0, 120],
|
|
427
488
|
success: ok,
|
|
@@ -430,41 +491,69 @@ module PWN
|
|
|
430
491
|
session_id: session_id,
|
|
431
492
|
tags: ['auto', 'loop', v[:verdict].to_s]
|
|
432
493
|
)
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
#
|
|
494
|
+
|
|
495
|
+
stages_run << :fold_judge
|
|
496
|
+
fold_judge_into_metrics(session_id: session_id, score: v[:score], confidence: v[:confidence])
|
|
497
|
+
|
|
498
|
+
# R2 PRM — skip under hard cap (expensive LLM); keep under soft if heuristic path
|
|
499
|
+
if over_hard.call
|
|
500
|
+
stages_skipped << :prm
|
|
501
|
+
elsif defined?(Reward)
|
|
502
|
+
stages_run << :prm
|
|
503
|
+
Reward.prm(request: opts[:request], session_id: session_id)
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
# C3 HER — only on failure; skip hard
|
|
507
|
+
if !ok && defined?(Curriculum) && !over_hard.call
|
|
508
|
+
stages_run << :hindsight
|
|
509
|
+
Curriculum.hindsight(request: opts[:request], final: opts[:final], session_id: session_id)
|
|
510
|
+
else
|
|
511
|
+
stages_skipped << :hindsight unless ok
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
# W3 calibrate — cheap; always when predicted available
|
|
446
515
|
predicted = opts[:predicted]
|
|
447
516
|
predicted = recover_predicted_from_session(session_id: session_id) if predicted.nil?
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
517
|
+
if !predicted.nil? && defined?(Curriculum)
|
|
518
|
+
stages_run << :calibrate
|
|
519
|
+
Curriculum.calibrate(predicted: predicted, actual: v[:score], engine: PWN::Env.dig(:ai, :active))
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
# reflect on success — skip soft/hard (LLM + memory writes)
|
|
523
|
+
if ok && !over_soft.call
|
|
524
|
+
stages_run << :reflect
|
|
525
|
+
reflect(session_id: session_id)
|
|
526
|
+
elsif ok
|
|
527
|
+
stages_skipped << :reflect
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
# R3 sentinel — cheap disk math; always
|
|
531
|
+
if defined?(Reward)
|
|
532
|
+
stages_run << :sentinel
|
|
533
|
+
Reward.sentinel
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
# E ambient extrospect — skip soft (can launch probes)
|
|
537
|
+
if defined?(Extrospection) && !over_soft.call
|
|
538
|
+
stages_run << :extrospect
|
|
539
|
+
Extrospection.auto_extrospect(session_id: session_id)
|
|
540
|
+
else
|
|
541
|
+
stages_skipped << :extrospect
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
{
|
|
545
|
+
ok: ok,
|
|
546
|
+
score: v[:score],
|
|
547
|
+
elapsed_ms: elapsed_ms.call,
|
|
548
|
+
budget_hot: budget_hot,
|
|
549
|
+
stages_run: stages_run,
|
|
550
|
+
stages_skipped: stages_skipped
|
|
551
|
+
}
|
|
452
552
|
rescue StandardError => e
|
|
453
553
|
warn "[pwn-ai/learning] auto_introspect swallowed: #{e.class}: #{e.message}"
|
|
454
554
|
nil
|
|
455
555
|
end
|
|
456
556
|
|
|
457
|
-
# Supported Method Parameters::
|
|
458
|
-
# PWN::AI::Agent::Learning.flip_last_outcome(
|
|
459
|
-
# session_id: 'optional - only flip if the newest outcome belongs to this session',
|
|
460
|
-
# reason: 'optional - why it is being flipped (usually the user correction text)'
|
|
461
|
-
# )
|
|
462
|
-
#
|
|
463
|
-
# Rewrites the most-recently-appended learning.jsonl entry from
|
|
464
|
-
# success:true to success:false. Called by Mistakes.check_user_correction
|
|
465
|
-
# when the user's next message rejects the previous answer, so the
|
|
466
|
-
# 100 %-success illusion is broken and the failure enters the corpus.
|
|
467
|
-
|
|
468
557
|
public_class_method def self.flip_last_outcome(opts = {})
|
|
469
558
|
return { flipped: false } unless File.exist?(LEARNING_FILE)
|
|
470
559
|
|
|
@@ -557,6 +646,7 @@ module PWN
|
|
|
557
646
|
|
|
558
647
|
sid = opts[:session_id]
|
|
559
648
|
score = opts[:score]
|
|
649
|
+
conf = opts[:confidence]
|
|
560
650
|
return if sid.to_s.empty? || score.nil?
|
|
561
651
|
return unless defined?(PWN::Sessions)
|
|
562
652
|
|
|
@@ -565,7 +655,7 @@ module PWN
|
|
|
565
655
|
.map { |e| e[:content].to_s[/\A([a-z0-9_]+)\s*→/i, 1] }
|
|
566
656
|
.compact
|
|
567
657
|
.uniq
|
|
568
|
-
names.each { |n| Metrics.record_judge(name: n, score: score) }
|
|
658
|
+
names.each { |n| Metrics.record_judge(name: n, score: score, confidence: conf) }
|
|
569
659
|
rescue StandardError
|
|
570
660
|
nil
|
|
571
661
|
end
|
|
@@ -680,6 +770,23 @@ module PWN
|
|
|
680
770
|
lessons.uniq.first(5)
|
|
681
771
|
end
|
|
682
772
|
|
|
773
|
+
# P26 — is this CLAIM_RX hit worth spending a headless browser on?
|
|
774
|
+
# Reject metric crumbs, OS/runtime banner lines, and bare versions.
|
|
775
|
+
private_class_method def self.checkable_claim?(opts = {})
|
|
776
|
+
c = opts[:claim].to_s.strip
|
|
777
|
+
return false if c.empty? || c.length < 8
|
|
778
|
+
return true if c.match?(/CVE-\d{4}-\d{4,7}/i)
|
|
779
|
+
|
|
780
|
+
head = c[/\A[A-Za-z][\w.+-]*/].to_s
|
|
781
|
+
return false if CLAIM_METRIC_WORDS.any? { |w| head.casecmp?(w) }
|
|
782
|
+
# require full semver x.y.z for non-CVE claims
|
|
783
|
+
return false unless c.match?(/\bv?\d+\.\d+\.\d+/)
|
|
784
|
+
|
|
785
|
+
true
|
|
786
|
+
rescue StandardError
|
|
787
|
+
false
|
|
788
|
+
end
|
|
789
|
+
|
|
683
790
|
# Auto fact-check post-filter: local models hallucinate CVEs /
|
|
684
791
|
# versions ~5-10x more than frontier ones. When the active engine is
|
|
685
792
|
# :ollama, scan the final for CVE / version-shaped claims and hand
|
|
@@ -690,7 +797,8 @@ module PWN
|
|
|
690
797
|
return unless defined?(PWN::Env) && PWN::Env.dig(:ai, :active).to_s.downcase.to_sym == :ollama
|
|
691
798
|
return unless defined?(Extrospection) && Extrospection.respond_to?(:verify)
|
|
692
799
|
|
|
693
|
-
claims = opts[:final].to_s.scan(CLAIM_RX).flatten.compact.uniq
|
|
800
|
+
claims = opts[:final].to_s.scan(CLAIM_RX).flatten.compact.uniq
|
|
801
|
+
claims = claims.select { |c| checkable_claim?(claim: c) }.first(3)
|
|
694
802
|
claims.each { |c| Extrospection.verify(claim: c, commit: true) }
|
|
695
803
|
rescue StandardError => e
|
|
696
804
|
warn "[pwn-ai/learning] fact_check swallowed: #{e.class}: #{e.message}"
|
data/lib/pwn/ai/agent/loop.rb
CHANGED
|
@@ -112,8 +112,15 @@ module PWN
|
|
|
112
112
|
n = [n, cal[:max_iters_cap]].min if cal[:overconfident]
|
|
113
113
|
# P17 — tighter default when agent_loop budget_exhaustion dominates
|
|
114
114
|
# open mistakes: finish-under-N is the skill gap, not more thrash.
|
|
115
|
+
# Caps are intentionally harsh (8 local / 12 remote): the open
|
|
116
|
+
# fingerprint is "iteration budget exhausted" ×N; more headroom
|
|
117
|
+
# only produces more empty terminal failures for ORM/PRM/DPO.
|
|
115
118
|
if budget_exhaustion_hot?
|
|
116
|
-
|
|
119
|
+
# Remote/overconfident thrash (this host): 12 was still burning full
|
|
120
|
+
# 12-step tool plans. Cap tighter when calibration also says overconfident.
|
|
121
|
+
base = active_engine == :ollama ? 8 : 12
|
|
122
|
+
base = [base, 8].min if cal[:overconfident]
|
|
123
|
+
n = [n, base].min
|
|
117
124
|
end
|
|
118
125
|
n
|
|
119
126
|
rescue StandardError
|
|
@@ -261,17 +268,31 @@ module PWN
|
|
|
261
268
|
# without leaking to the user.
|
|
262
269
|
private_class_method def self.plan_first(opts = {})
|
|
263
270
|
messages = opts[:messages]
|
|
271
|
+
# P17 — under budget_exhaustion_hot the plan must be ultra-short:
|
|
272
|
+
# red_team_plan is a nested agent loop and was the #1 amplifier of
|
|
273
|
+
# iteration-budget exhaustion on this host (together with CF).
|
|
274
|
+
hot = begin
|
|
275
|
+
budget_exhaustion_hot?
|
|
276
|
+
rescue StandardError
|
|
277
|
+
false
|
|
278
|
+
end
|
|
279
|
+
plan_prompt = if hot
|
|
280
|
+
'Before acting: write AT MOST 3 numbered tool calls (name + key args) that finish the ask. Prefer fewer. LAST line: "p(success)=<0.0-1.0>". Reply ONLY with the plan + that line — no tools, no prose.'
|
|
281
|
+
else
|
|
282
|
+
'Before acting: (1) list the exact tool calls (name + key args) you will make, in order; (2) on the LAST line write "p(success)=<0.0-1.0>". Reply ONLY with the numbered plan + that line — do NOT call any tool yet.'
|
|
283
|
+
end
|
|
264
284
|
plan_msg = call_engine(
|
|
265
|
-
messages: messages + [{ role: 'user',
|
|
266
|
-
content: 'Before acting: (1) list the exact tool calls (name + key args) you will make, in order; (2) on the LAST line write "p(success)=<0.0-1.0>". Reply ONLY with the numbered plan + that line — do NOT call any tool yet.' }],
|
|
285
|
+
messages: messages + [{ role: 'user', content: plan_prompt }],
|
|
267
286
|
tools: nil
|
|
268
287
|
)
|
|
269
288
|
return nil unless plan_msg && !plan_msg[:content].to_s.strip.empty?
|
|
270
289
|
|
|
271
290
|
plan = plan_msg[:content].to_s.strip
|
|
272
291
|
messages << { role: 'assistant', content: "PLAN:\n#{plan}" }
|
|
273
|
-
# S4 — adversarial plan review grounded in THIS host's telemetry
|
|
274
|
-
|
|
292
|
+
# S4 — adversarial plan review grounded in THIS host's telemetry.
|
|
293
|
+
# P17 — never fork red_team when budget fingerprints dominate: it is
|
|
294
|
+
# another mini agent loop and compounds iteration-budget exhaustion.
|
|
295
|
+
if defined?(Curriculum) && !hot
|
|
275
296
|
rt = Curriculum.red_team_plan(request: opts[:request], plan: plan)
|
|
276
297
|
messages << { role: 'user', content: rt } if rt
|
|
277
298
|
end
|
|
@@ -563,7 +584,25 @@ module PWN
|
|
|
563
584
|
# 3.1 — compact history on local so tool dumps don't fill num_ctx
|
|
564
585
|
compact_history!(messages: messages) if local
|
|
565
586
|
|
|
566
|
-
|
|
587
|
+
# P17 — on the final iteration, strip tools and demand a plain-text
|
|
588
|
+
# answer. Without this the model happily emits one more tool_calls
|
|
589
|
+
# batch, burns the last slot, and lands on budget_exhausted with
|
|
590
|
+
# nothing the user (or ORM) can use.
|
|
591
|
+
# P17 deepen — when budget_hot, force text-only on the LAST TWO
|
|
592
|
+
# iters so a final tool_calls batch cannot burn the terminal slot.
|
|
593
|
+
text_only_iters = budget_exhaustion_hot? ? 2 : 1
|
|
594
|
+
last_iter = (i >= max_iters - text_only_iters)
|
|
595
|
+
if last_iter
|
|
596
|
+
tag = i >= max_iters - 1 ? 'FINAL ITERATION' : 'PENULTIMATE — wrap up'
|
|
597
|
+
messages << {
|
|
598
|
+
role: 'user',
|
|
599
|
+
content: "[pwn-ai/p17] #{tag} — do NOT call any more tools. " \
|
|
600
|
+
'Write the best answer you can from evidence already in this ' \
|
|
601
|
+
'transcript. If blocked, say what failed and the next single step.'
|
|
602
|
+
}
|
|
603
|
+
end
|
|
604
|
+
|
|
605
|
+
msg = call_engine(messages: messages, tools: last_iter ? nil : tools)
|
|
567
606
|
return '[pwn-ai] engine returned no message' if msg.nil?
|
|
568
607
|
|
|
569
608
|
calls = Array(msg[:tool_calls])
|
|
@@ -613,7 +652,10 @@ module PWN
|
|
|
613
652
|
# alt-persona branch, judge both, inject the winner. Real
|
|
614
653
|
# advantage estimation; (loser, winner) → DPO preference.
|
|
615
654
|
thresh = defined?(Mistakes) ? Mistakes::REPEAT_THRESHOLD : 3
|
|
616
|
-
|
|
655
|
+
# P17 — never fork counterfactual when budget fingerprints dominate:
|
|
656
|
+
# CF is another mini agent loop and is the #1 amplifier of
|
|
657
|
+
# iteration-budget exhaustion on this host.
|
|
658
|
+
if count >= thresh && !escalated && defined?(Curriculum) && !budget_exhaustion_hot?
|
|
617
659
|
cf = (turn_fails["cf:#{fkey}"] += 1) == 1 ? Curriculum.counterfactual(request: request, name: name, args: args, error: tele[:err] || raw[0, 200], hint: hint) : nil
|
|
618
660
|
hint = "#{hint}\n[pwn-ai/counterfactual] branch #{cf[:branch]} (score=#{cf[:score].round(2)}): #{cf[:content]}" if cf
|
|
619
661
|
end
|
|
@@ -670,8 +712,29 @@ module PWN
|
|
|
670
712
|
escalated = true
|
|
671
713
|
end
|
|
672
714
|
|
|
673
|
-
|
|
674
|
-
'
|
|
715
|
+
# P17 — exhaust path must still feed Learning so ORM/PRM/HER see the
|
|
716
|
+
# failure (previously we only Mistakes.record'd and returned a bare
|
|
717
|
+
# string — no session row, no judge, no hindsight).
|
|
718
|
+
final_msg = '[pwn-ai] iteration budget exhausted'
|
|
719
|
+
if defined?(Mistakes)
|
|
720
|
+
Mistakes.record(
|
|
721
|
+
tool: 'agent_loop',
|
|
722
|
+
error: 'iteration budget exhausted without a final answer',
|
|
723
|
+
session_id: session_id,
|
|
724
|
+
source: :loop,
|
|
725
|
+
shape: :budget_exhausted
|
|
726
|
+
)
|
|
727
|
+
end
|
|
728
|
+
append_session(session_id: session_id, role: 'assistant', content: final_msg)
|
|
729
|
+
if defined?(Learning) && should_auto_introspect?(local: local, turn_fails: turn_fails, iter: max_iters)
|
|
730
|
+
Learning.auto_introspect(
|
|
731
|
+
session_id: session_id,
|
|
732
|
+
request: request,
|
|
733
|
+
final: final_msg,
|
|
734
|
+
predicted: predicted
|
|
735
|
+
)
|
|
736
|
+
end
|
|
737
|
+
final_msg
|
|
675
738
|
end
|
|
676
739
|
|
|
677
740
|
# Author(s):: 0day Inc. <support@0dayinc.com>
|
data/lib/pwn/ai/agent/metrics.rb
CHANGED
|
@@ -166,7 +166,23 @@ module PWN
|
|
|
166
166
|
" - #{r[:name]}: calls=#{r[:calls]} success=#{(adj * 100).round(1)}%#{tag}#{jtag} avg=#{r[:avg_duration]}s#{err}"
|
|
167
167
|
end
|
|
168
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" : ''
|
|
169
|
-
|
|
169
|
+
# P0 ops — surface W1 generator_mix when diet is unhealthy so the
|
|
170
|
+
# online controller (and the model) prefer underfilled sources.
|
|
171
|
+
mix_line = ''
|
|
172
|
+
if defined?(Reward) && Reward.respond_to?(:generator_mix)
|
|
173
|
+
begin
|
|
174
|
+
m = Reward.generator_mix
|
|
175
|
+
unless m[:healthy]
|
|
176
|
+
mix_line = "W1 MIX: n=#{m[:n]} traj=#{m[:trajectory_fraction]} " \
|
|
177
|
+
"urgent=#{Array(m[:urgent]).join(',')} " \
|
|
178
|
+
"suppress=#{Array(m[:suppress]).join(',')} " \
|
|
179
|
+
"rec=#{m[:recommendation]}\n"
|
|
180
|
+
end
|
|
181
|
+
rescue StandardError
|
|
182
|
+
mix_line = ''
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
"#{warn_line}#{mix_line}TOOL EFFECTIVENESS (#{scope}, adapt tool choice accordingly)\n#{lines.join("\n")}\n\n"
|
|
170
186
|
end
|
|
171
187
|
|
|
172
188
|
# P4 helper — Registry.rank calls this so β·advantage is scaled down
|
|
@@ -239,30 +255,51 @@ module PWN
|
|
|
239
255
|
0.0
|
|
240
256
|
end
|
|
241
257
|
|
|
242
|
-
# P18 — rolling mean step_reward for a tool
|
|
243
|
-
#
|
|
258
|
+
# P18/P2 — rolling mean step_reward advantage for a tool.
|
|
259
|
+
# Sample-efficiency gate: < PRM_MIN_N samples → 0 (no rank noise).
|
|
260
|
+
# Shrinkage: adv *= min(1, n/PRM_FULL_N) so sparse signal cannot
|
|
261
|
+
# dominate UCB. Zero-variance windows (all +1 or all -1 from a
|
|
262
|
+
# single session) damp to 0.5×.
|
|
263
|
+
PRM_MIN_N = 5
|
|
264
|
+
PRM_FULL_N = 20
|
|
265
|
+
|
|
244
266
|
public_class_method def self.prm_advantage(opts = {})
|
|
245
267
|
data = load[:tools] || {}
|
|
246
268
|
t = data[opts[:name].to_s.to_sym]
|
|
247
269
|
return 0.0 unless t
|
|
248
270
|
|
|
249
271
|
win = Array(t[:prm_window])
|
|
250
|
-
|
|
272
|
+
n = win.length
|
|
273
|
+
return 0.0 if n < PRM_MIN_N
|
|
251
274
|
|
|
252
|
-
mean = win.sum.to_f /
|
|
253
|
-
|
|
254
|
-
globals = data.values.map { |v| Array(v[:prm_window]) }.reject(&:empty?)
|
|
275
|
+
mean = win.sum.to_f / n
|
|
276
|
+
globals = data.values.map { |v| Array(v[:prm_window]) }.select { |w| w.length >= PRM_MIN_N }
|
|
255
277
|
gmean = if globals.empty?
|
|
256
278
|
0.0
|
|
257
279
|
else
|
|
258
280
|
all = globals.flatten
|
|
259
281
|
all.sum.to_f / all.length
|
|
260
282
|
end
|
|
261
|
-
|
|
283
|
+
adv = mean - gmean
|
|
284
|
+
# shrinkage toward 0 until PRM_FULL_N
|
|
285
|
+
shrink = [n.to_f / PRM_FULL_N, 1.0].min
|
|
286
|
+
# variance damp: if all equal, halve influence
|
|
287
|
+
uniq = win.uniq
|
|
288
|
+
var_damp = uniq.length <= 1 ? 0.5 : 1.0
|
|
289
|
+
(adv * shrink * var_damp).round(3)
|
|
262
290
|
rescue StandardError
|
|
263
291
|
0.0
|
|
264
292
|
end
|
|
265
293
|
|
|
294
|
+
public_class_method def self.prm_n(opts = {})
|
|
295
|
+
t = (load[:tools] || {})[opts[:name].to_s.to_sym]
|
|
296
|
+
return 0 unless t
|
|
297
|
+
|
|
298
|
+
Array(t[:prm_window]).length
|
|
299
|
+
rescue StandardError
|
|
300
|
+
0
|
|
301
|
+
end
|
|
302
|
+
|
|
266
303
|
# P18 — called by Reward.prm after session annotate so live routing
|
|
267
304
|
# can bias toward tools that recently advanced goals (+1 step_reward).
|
|
268
305
|
public_class_method def self.record_step_reward(opts = {})
|
|
@@ -290,10 +327,12 @@ module PWN
|
|
|
290
327
|
return if name.empty?
|
|
291
328
|
|
|
292
329
|
score = opts[:score].to_f.clamp(0.0, 1.0)
|
|
330
|
+
conf = opts.key?(:confidence) ? opts[:confidence].to_f.clamp(0.0, 1.0) : 0.7
|
|
293
331
|
m = load
|
|
294
332
|
m[:tools] ||= {}
|
|
295
333
|
t = m[:tools][name.to_sym] ||= blank_bucket
|
|
296
334
|
t[:judge_window] = (Array(t[:judge_window]) + [score]).last(40)
|
|
335
|
+
t[:judge_conf_window] = (Array(t[:judge_conf_window]) + [conf]).last(40)
|
|
297
336
|
t[:judge_sum] = t[:judge_window].sum.to_f
|
|
298
337
|
t[:judge_n] = t[:judge_window].length
|
|
299
338
|
save(metrics: m)
|
|
@@ -302,6 +341,19 @@ module PWN
|
|
|
302
341
|
nil
|
|
303
342
|
end
|
|
304
343
|
|
|
344
|
+
# P1 — mean judge confidence for a tool (nil when no samples).
|
|
345
|
+
public_class_method def self.judge_confidence(opts = {})
|
|
346
|
+
t = (load[:tools] || {})[opts[:name].to_s.to_sym]
|
|
347
|
+
return nil unless t
|
|
348
|
+
|
|
349
|
+
win = Array(t[:judge_conf_window])
|
|
350
|
+
return nil if win.empty?
|
|
351
|
+
|
|
352
|
+
(win.sum.to_f / win.length).round(3)
|
|
353
|
+
rescue StandardError
|
|
354
|
+
nil
|
|
355
|
+
end
|
|
356
|
+
|
|
305
357
|
# Mean judge score for a tool (nil when no ORM samples yet).
|
|
306
358
|
public_class_method def self.judge_rate(opts = {})
|
|
307
359
|
t = (load[:tools] || {})[opts[:name].to_s.to_sym]
|
|
@@ -331,8 +383,15 @@ module PWN
|
|
|
331
383
|
distrust = 1.0 - proxy_trust
|
|
332
384
|
jrate = judge_rate(name: name)
|
|
333
385
|
if distrust > 0.05 && !jrate.nil?
|
|
334
|
-
#
|
|
335
|
-
|
|
386
|
+
# P1 — scale judge weight by judge confidence so a thin local
|
|
387
|
+
# heuristic ORM cannot fully replace proxy when distrust is high.
|
|
388
|
+
# effective_distrust = distrust * mean(confidence), floor 0.15 when
|
|
389
|
+
# we do have judge samples so the signal still moves the needle.
|
|
390
|
+
jconf = judge_confidence(name: name)
|
|
391
|
+
jconf = 0.7 if jconf.nil?
|
|
392
|
+
eff_d = (distrust * jconf).clamp(0.0, 1.0)
|
|
393
|
+
eff_d = [eff_d, 0.15].max if jconf >= 0.3
|
|
394
|
+
((proxy * (1.0 - eff_d)) + (jrate * eff_d)).clamp(0.0, 1.0).round(3)
|
|
336
395
|
else
|
|
337
396
|
proxy.round(3)
|
|
338
397
|
end
|
|
@@ -145,8 +145,27 @@ 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
|
-
|
|
148
|
+
# P18/P2 — PRM step_reward advantage closes R2 into the controller.
|
|
149
|
+
# Sample-efficiency: if fewer than 3 tools have prm_n≥PRM_MIN_N,
|
|
150
|
+
# drop delta to 0 so sparse PRM cannot inject rank variance.
|
|
151
|
+
# Otherwise scale delta by fleet coverage fraction.
|
|
152
|
+
prm_ready = 0
|
|
153
|
+
if defined?(Metrics) && Metrics.respond_to?(:prm_n)
|
|
154
|
+
prm_ready = entries.count do |e|
|
|
155
|
+
Metrics.prm_n(name: e.name).to_i >= begin
|
|
156
|
+
Metrics::PRM_MIN_N
|
|
157
|
+
rescue StandardError
|
|
158
|
+
5
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
fleet = [entries.length, 1].max
|
|
163
|
+
coverage = prm_ready.to_f / fleet
|
|
164
|
+
delta = if prm_ready < 3
|
|
165
|
+
0.0
|
|
166
|
+
else
|
|
167
|
+
0.25 * trust * [coverage / 0.3, 1.0].min
|
|
168
|
+
end
|
|
150
169
|
scored = entries.map do |e|
|
|
151
170
|
hay = "#{e.name} #{e.toolset} #{e.schema[:description]} #{Array(e.schema.dig(:parameters, :properties)&.keys).join(' ')}".downcase
|
|
152
171
|
sim = tokens.count { |t| hay.include?(t) }
|