pwn 0.5.639 → 0.5.643

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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +3 -3
  3. data/README.md +1 -1
  4. data/documentation/Cron.md +10 -8
  5. data/documentation/Reinforcement-Learning.md +43 -21
  6. data/documentation/diagrams/cron-scheduling.svg +134 -93
  7. data/documentation/diagrams/dot/cron-scheduling.dot +9 -3
  8. data/documentation/diagrams/dot/pwn-ai-feedback-learning-loop.dot +2 -2
  9. data/documentation/diagrams/dot/reinforcement-learning.dot +9 -3
  10. data/documentation/diagrams/pwn-ai-feedback-learning-loop.svg +103 -101
  11. data/documentation/diagrams/reinforcement-learning.svg +211 -166
  12. data/documentation/pwn-ai-Agent.md +12 -0
  13. data/lib/pwn/ai/agent/curriculum.rb +647 -56
  14. data/lib/pwn/ai/agent/learning.rb +87 -15
  15. data/lib/pwn/ai/agent/loop.rb +164 -13
  16. data/lib/pwn/ai/agent/metrics.rb +32 -13
  17. data/lib/pwn/ai/agent/mistakes.rb +139 -8
  18. data/lib/pwn/ai/agent/prompt_builder.rb +2 -2
  19. data/lib/pwn/ai/agent/registry.rb +13 -3
  20. data/lib/pwn/ai/agent/result.rb +19 -2
  21. data/lib/pwn/ai/agent/reward.rb +415 -42
  22. data/lib/pwn/ai/agent/tools/curriculum.rb +51 -0
  23. data/lib/pwn/ai/agent/tools/reward.rb +15 -4
  24. data/lib/pwn/ai/anthropic.rb +265 -4
  25. data/lib/pwn/ai/ollama.rb +274 -10
  26. data/lib/pwn/ai/open_ai.rb +307 -4
  27. data/lib/pwn/config.rb +137 -12
  28. data/lib/pwn/cron.rb +24 -2
  29. data/lib/pwn/memory.rb +13 -1
  30. data/lib/pwn/version.rb +1 -1
  31. data/spec/integration/persistence_roundtrip_spec.rb +6 -1
  32. data/spec/integration/reinforced_feedback_loop_spec.rb +144 -8
  33. data/spec/lib/pwn/ai/agent/mistakes_spec.rb +27 -0
  34. data/spec/lib/pwn/ai/agent/result_spec.rb +11 -0
  35. data/spec/lib/pwn/ai/agent/reward_spec.rb +89 -0
  36. data/spec/lib/pwn/ai/ollama_spec.rb +251 -0
  37. data/third_party/pwn_rdoc.jsonl +58 -7
  38. metadata +8 -8
@@ -39,7 +39,13 @@ module PWN
39
39
 
40
40
  public_class_method def self.note_outcome(opts = {})
41
41
  task = opts[:task].to_s
42
- success = opts[:success] ? true : false
42
+ # 4.1 — allow success: 'soft' (HER) distinct from true/false
43
+ raw_ok = opts[:success]
44
+ success = if ['soft', :soft].include?(raw_ok)
45
+ 'soft'
46
+ else
47
+ raw_ok ? true : false
48
+ end
43
49
  raise 'ERROR: task is required' if task.strip.empty?
44
50
 
45
51
  entry = {
@@ -80,7 +86,7 @@ module PWN
80
86
  nil
81
87
  end
82
88
  rows.compact!
83
- rows.select! { |r| r[:success] == want_ok } unless want_ok.nil?
89
+ rows.select! { |r| want_ok == true ? r[:success] == true : r[:success] == want_ok } unless want_ok.nil?
84
90
  rows.select! { |r| Array(r[:tags]).any? { |t| t.to_s.downcase.include?(tag) } } unless tag.empty?
85
91
  rows.reverse.first(limit)
86
92
  end
@@ -91,7 +97,7 @@ module PWN
91
97
  public_class_method def self.stats
92
98
  rows = outcomes(limit: 10_000)
93
99
  total = rows.length
94
- ok = rows.count { |r| r[:success] }
100
+ ok = rows.count { |r| r[:success] == true }
95
101
  jsum = rows.sum { |r| r[:score] ? r[:score].to_f : { true => 1.0, false => 0.0 }[r[:success]] }
96
102
  skills = defined?(PWN::Skills) && PWN::Skills.is_a?(Hash) ? PWN::Skills.keys.length : 0
97
103
  mem = defined?(PWN::Memory) ? PWN::Memory.load.keys.length : 0
@@ -159,12 +165,18 @@ module PWN
159
165
 
160
166
  now = Time.now.utc
161
167
  # C2 — prioritized replay: priority = judge_score × recency_decay × keyword_sim
168
+ # C2 — strict success:true only (excludes HER success:'soft'). Also
169
+ # down-weight any residual hindsight-tagged rows so partial failures
170
+ # never launder into full-strength few-shot exemplars.
162
171
  pool = outcomes(limit: 500, success: true).reject { |r| r[:session_id].to_s.empty? }
163
172
  scored = pool.map do |r|
164
173
  sim = tokens.count { |t| r[:task].to_s.downcase.include?(t) }.to_f / tokens.length
165
174
  age_d = (now - Time.parse(r[:timestamp].to_s)) / 86_400.0
166
175
  decay = Math.exp(-age_d / 30.0)
167
176
  score = (r[:score] || 1.0).to_f
177
+ tags = Array(r[:tags]).map(&:to_s)
178
+ # HER / soft / hindsight → 0.35× so they cannot dominate C2 priority
179
+ score *= 0.35 if r[:success].to_s == 'soft' || tags.intersect?(%w[hindsight her soft])
168
180
  [r, sim * decay * score]
169
181
  rescue StandardError
170
182
  [r, 0.0]
@@ -196,7 +208,12 @@ module PWN
196
208
  FileUtils.mkdir_p(FINETUNE_DIR)
197
209
  out = opts[:out] || File.join(FINETUNE_DIR, "pwn-#{Time.now.utc.strftime('%Y%m%d')}.jsonl")
198
210
 
199
- sids = outcomes(limit: 10_000, success: true).map { |r| r[:session_id] }.compact.uniq
211
+ # 4.1 exclude HER soft-success from SFT gold (success: 'soft' or tags)
212
+ gold = outcomes(limit: 10_000, success: true).reject do |r|
213
+ r[:success].to_s == 'soft' ||
214
+ Array(r[:tags]).any? { |t| %w[hindsight her].include?(t.to_s) }
215
+ end
216
+ sids = gold.map { |r| r[:session_id] }.compact.uniq
200
217
  rows = 0
201
218
  File.open(out, 'w') do |f|
202
219
  sids.each do |sid|
@@ -299,7 +316,29 @@ module PWN
299
316
  proxy_ok = infer_success(session_id: session_id, final: opts[:final])
300
317
  # S3 — tool-armed constitutional critic runs BEFORE the reward
301
318
  # model so its verdict is evidence, not hindsight.
302
- crit = defined?(Curriculum) ? Curriculum.critic(request: opts[:request], final: opts[:final], session_id: session_id) : { verdict: :pass }
319
+ # P7 force critic when W3 says this engine is overconfident
320
+ force_critic = begin
321
+ eng = (PWN::Env.dig(:ai, :active) if defined?(PWN::Env))
322
+ cal = defined?(Metrics) ? Metrics.calibration(engine: eng) : { n: 0 }
323
+ cal[:n].to_i >= 8 && (cal[:brier].to_f > 0.35 || cal[:overconfidence].to_f > 0.25)
324
+ rescue StandardError
325
+ false
326
+ end
327
+ crit = if defined?(Curriculum)
328
+ if force_critic
329
+ prev = (PWN::Env[:ai][:agent][:critic] if defined?(PWN::Env) && PWN::Env[:ai].is_a?(Hash) && PWN::Env[:ai][:agent].is_a?(Hash))
330
+ begin
331
+ 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?
332
+ Curriculum.critic(request: opts[:request], final: opts[:final], session_id: session_id)
333
+ ensure
334
+ 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?
335
+ end
336
+ else
337
+ Curriculum.critic(request: opts[:request], final: opts[:final], session_id: session_id)
338
+ end
339
+ else
340
+ { verdict: :pass }
341
+ end
303
342
  # R1 — LLM Outcome Reward Model (falls back to calibrated heuristic)
304
343
  v = Reward.judge(request: opts[:request], final: opts[:final], session_id: session_id, proxy_ok: proxy_ok) if defined?(Reward)
305
344
  v ||= { score: proxy_ok ? 1.0 : 0.0, success: proxy_ok, verdict: proxy_ok ? :solved : :wrong }
@@ -322,8 +361,10 @@ module PWN
322
361
  session_id: session_id,
323
362
  tags: ['auto', 'loop', v[:verdict].to_s]
324
363
  )
325
- # R2 — per-step credit assignment; C3 HER on failure
326
- Reward.prm(request: opts[:request], session_id: session_id) if defined?(Reward) && ok
364
+ # R2 — per-step credit assignment ALWAYS (1.6): failed trajectories
365
+ # are where step credit matters; feed negative steps into counterfactual.
366
+ # C3 — HER soft-relabel on failure only.
367
+ Reward.prm(request: opts[:request], session_id: session_id) if defined?(Reward)
327
368
  Curriculum.hindsight(request: opts[:request], final: opts[:final], session_id: session_id) if !ok && defined?(Curriculum)
328
369
  # W3 — calibration: predicted (from plan_first) vs actual
329
370
  Curriculum.calibrate(predicted: opts[:predicted], actual: v[:score], engine: PWN::Env.dig(:ai, :active)) if opts[:predicted] && defined?(Curriculum)
@@ -541,19 +582,50 @@ module PWN
541
582
  sid = opts[:session_id]
542
583
  cap = opts[:max_msgs] || 6
543
584
  t = PWN::Sessions.load(session_id: sid)
544
- user = t.find { |e| e[:role].to_s == 'user' }
545
- fin = t.rfind { |e| e[:role].to_s == 'assistant' }
546
- return [] unless user && fin
585
+ return [] if t.nil? || t.empty?
586
+
587
+ # Prefer the LAST non-empty assistant as the exemplar final.
588
+ # Blank assistants (common when a prior local-model turn stopped
589
+ # with empty content / eval_count=1) must never be few-shot into
590
+ # the next Ollama turn — Qwen/abliterated builds then echo the
591
+ # empty final and the agent loop returns "" to the user.
592
+ fin_idx = t.rindex { |e| e[:role].to_s == 'assistant' && !e[:content].to_s.strip.empty? }
593
+ return [] if fin_idx.nil?
594
+
595
+ fin = t[fin_idx]
596
+ # Pair with the nearest preceding user so multi-turn sessions do
597
+ # not attach an unrelated first-user prompt to a later answer.
598
+ user = t[0...fin_idx].reverse.find { |e| e[:role].to_s == 'user' }
599
+ return [] unless user
547
600
 
548
601
  # C4 — minimal sufficient trace: prefer steps PRM tagged reward>0
549
- tools = t.select { |e| e[:role].to_s == 'tool' }
602
+ # scoped to the window between that user and the final answer.
603
+ user_idx = t[0...fin_idx].rindex { |e| e.equal?(user) || (e[:role].to_s == 'user' && e[:content] == user[:content]) } || 0
604
+ window = t[user_idx..fin_idx] || []
605
+ tools = window.select { |e| e[:role].to_s == 'tool' }
550
606
  rewarded = tools.select { |e| e[:step_reward].to_i.positive? }
551
607
  tools = rewarded unless rewarded.empty?
552
608
  tools = tools.first([cap - 2, 0].max)
553
- msgs = [{ role: 'user', content: "[exemplar] #{user[:content].to_s[0, 400]}" }]
554
- tools.each { |e| msgs << { role: 'assistant', content: "[exemplar tool] #{e[:content].to_s[0, 300]}" } }
555
- msgs << { role: 'assistant', content: "[exemplar final] #{fin[:content].to_s[0, 400]}" }
556
- msgs
609
+
610
+ # Emit a SINGLE user/assistant pair (never consecutive assistant
611
+ # turns). Many local chat templates (Qwen3, Llama-3) collapse or
612
+ # early-stop when two assistant messages land back-to-back with
613
+ # no user between them — observed as done_reason=stop, eval_count=1,
614
+ # empty content, no tool_calls.
615
+ tool_bits = tools.map { |e| e[:content].to_s[0, 220] }.reject { |s| s.strip.empty? }
616
+ body = +''
617
+ unless tool_bits.empty?
618
+ body << "[exemplar tools]\n"
619
+ tool_bits.each_with_index { |s, i| body << "#{i + 1}. #{s}\n" }
620
+ body << "\n"
621
+ end
622
+ body << "[exemplar final]\n#{fin[:content].to_s.strip[0, 400]}"
623
+ return [] if fin[:content].to_s.strip.empty?
624
+
625
+ [
626
+ { role: 'user', content: "[exemplar] #{user[:content].to_s[0, 400]}" },
627
+ { role: 'assistant', content: body }
628
+ ]
557
629
  rescue StandardError
558
630
  []
559
631
  end
@@ -79,11 +79,42 @@ module PWN
79
79
 
80
80
  private_class_method def self.max_iters
81
81
  v = (PWN::Env.dig(:ai, :agent, :max_iters) if defined?(PWN::Env))
82
- v.to_i.positive? ? v.to_i : DEFAULT_MAX_ITERS
82
+ n = v.to_i.positive? ? v.to_i : DEFAULT_MAX_ITERS
83
+ # 0.3 — frontier leakage: live max_iters=80 burns local models.
84
+ # Cap ollama at 25 unless the operator set an explicit lower value.
85
+ n = 25 if active_engine == :ollama && n > 25
86
+ # P7 — W3 controller: when this engine is badly overconfident,
87
+ # shrink the tool budget so thrash can't compound on bad plans.
88
+ cal = calibration_state
89
+ n = [n, cal[:max_iters_cap]].min if cal[:overconfident]
90
+ n
83
91
  rescue StandardError
84
92
  DEFAULT_MAX_ITERS
85
93
  end
86
94
 
95
+ # P7 — read Metrics.calibration for the active engine and decide
96
+ # whether to force plan_first/critic and cap iters.
97
+ # Thresholds: brier > 0.35 OR overconfidence > 0.25 with n >= 8.
98
+ private_class_method def self.calibration_state
99
+ eng = active_engine
100
+ cal = defined?(Metrics) && Metrics.respond_to?(:calibration) ? Metrics.calibration(engine: eng) : { n: 0 }
101
+ n = cal[:n].to_i
102
+ return { overconfident: false, force_plan: false, force_critic: false, max_iters_cap: 25, cal: cal } if n < 8
103
+
104
+ brier = cal[:brier].to_f
105
+ over = cal[:overconfidence].to_f
106
+ bad = brier > 0.35 || over > 0.25
107
+ {
108
+ overconfident: bad,
109
+ force_plan: bad,
110
+ force_critic: bad,
111
+ max_iters_cap: bad ? 12 : 25,
112
+ cal: cal
113
+ }
114
+ rescue StandardError
115
+ { overconfident: false, force_plan: false, force_critic: false, max_iters_cap: 25 }
116
+ end
117
+
87
118
  private_class_method def self.active_engine
88
119
  e = (PWN::Env.dig(:ai, :active) if defined?(PWN::Env)).to_s.downcase.to_sym
89
120
  e == :'' ? :openai : e
@@ -115,14 +146,17 @@ module PWN
115
146
  # !semantic_ok. Kills the phantom 31f1871b8a15 class permanently.
116
147
  sem = defined?(Reward) ? Reward.semantic_ok(name: name, raw: raw, args: opts[:args]) : { ok: raw.include?('"success":true'), semantic_ok: raw.include?('"success":true'), err: raw[/"error":"([^"]{1,300})"/, 1] }
117
148
  dur = started ? (Time.now - started) : 0.0
118
- Metrics.record(name: name, success: sem[:ok], duration: dur, error: sem[:err], engine: opts[:engine]) if defined?(Metrics)
149
+ # 1.2 align Metrics proxy with R4 semantic_ok (handler-ok alone
150
+ # was the reward-signal lie: grep exit 1 looked like 100% success
151
+ # while Mistakes stayed quiet, OR the inverse phantom class).
152
+ Metrics.record(name: name, success: sem[:semantic_ok], duration: dur, error: sem[:err], engine: opts[:engine]) if defined?(Metrics)
119
153
  m = nil
120
154
  if !sem[:semantic_ok] && defined?(Mistakes)
121
155
  # E1 — automatic blame attribution: if this tool just tripped a
122
156
  # CUSUM changepoint AND extro drift is present, tag the mistake
123
157
  # cause: :env_drift so it does NOT count toward [REPEATING].
124
158
  cause = attribute_cause(name: name)
125
- m = Mistakes.record(tool: name, error: sem[:err] || raw[0, 300], args: opts[:args], session_id: opts[:session_id], source: :tool, cause: cause)
159
+ m = Mistakes.record(tool: name, error: sem[:err] || raw[0, 300], args: opts[:args], session_id: opts[:session_id], source: :tool, cause: cause, shape: sem[:shape])
126
160
  end
127
161
  { ok: sem[:semantic_ok], err: sem[:err], mistake: m, benign: sem[:benign] }
128
162
  rescue StandardError
@@ -225,11 +259,28 @@ module PWN
225
259
  # persona for a 3-line corrective hint and inject it as a synthetic
226
260
  # tool result. Every escalation is recorded as a Mistake so
227
261
  # export_finetune can later teach the LoRA to NOT need it.
262
+ @escalate_warned = false
228
263
  private_class_method def self.escalate(opts = {})
229
264
  request = opts[:request]
230
265
  turn_fails = opts[:turn_fails]
231
266
  persona = agent_flag(key: :escalation_persona)
232
- return nil unless persona && defined?(Swarm) && Swarm.personas.key?(persona.to_sym)
267
+ # Vault files pre-dating PR-A leave escalation_persona nil; if the
268
+ # default "escalator" persona exists on disk, use it automatically.
269
+ persona = :escalator if (persona.nil? || persona.to_s.empty?) && defined?(Swarm) && Swarm.personas.key?(:escalator)
270
+ unless persona && defined?(Swarm)
271
+ unless @escalate_warned
272
+ warn '[pwn-ai/loop] escalation_persona unset or Swarm unavailable — local thrash will burn iters without a frontier hint. Set PWN::Env[:ai][:agent][:escalation_persona] (default: escalator).'
273
+ @escalate_warned = true
274
+ end
275
+ return nil
276
+ end
277
+ unless Swarm.personas.key?(persona.to_sym)
278
+ unless @escalate_warned
279
+ warn "[pwn-ai/loop] escalation_persona=#{persona.inspect} not in ~/.pwn/agents.yml — define it or set nil. ESCALATE_AFTER_FAILS is a no-op."
280
+ @escalate_warned = true
281
+ end
282
+ return nil
283
+ end
233
284
 
234
285
  summary = turn_fails.map { |k, v| "#{k}: #{v}×" }.join(', ')
235
286
  hint = Swarm.ask(
@@ -296,10 +347,20 @@ module PWN
296
347
  msg = resp.dig(:choices, 0, :message) || resp[:assistant_message]
297
348
  return nil unless msg
298
349
 
350
+ content = msg[:content]
351
+ tool_calls = Array(msg[:tool_calls])
352
+ # Local/thinking models (Ollama Qwen3, DeepSeek-R1, etc.) sometimes
353
+ # return only :thinking with empty :content and no tool_calls. Promote
354
+ # thinking so the agent does not print a blank final answer.
355
+ if content.to_s.strip.empty? && tool_calls.empty?
356
+ thinking = msg[:thinking].to_s
357
+ content = thinking unless thinking.strip.empty?
358
+ end
359
+
299
360
  out = {
300
361
  role: 'assistant',
301
- content: msg[:content],
302
- tool_calls: Array(msg[:tool_calls]).map do |tc|
362
+ content: content,
363
+ tool_calls: tool_calls.map do |tc|
303
364
  {
304
365
  id: tc[:id],
305
366
  type: 'function',
@@ -314,6 +375,7 @@ module PWN
314
375
  # them exactly on the next iteration (e.g. Anthropic requires the
315
376
  # original tool_use block to precede a tool_result).
316
377
  out[:_native_content] = msg[:_native_content] if msg[:_native_content]
378
+ out[:thinking] = msg[:thinking] if msg[:thinking]
317
379
  out
318
380
  end
319
381
 
@@ -350,6 +412,64 @@ module PWN
350
412
  end
351
413
  end
352
414
 
415
+ # 3.1 — sliding-window history compaction for local models.
416
+ # Keep: system, original user, PLAN assistant (if any), last K tool
417
+ # pairs (assistant+tool), and the most recent assistant. Stale tool
418
+ # bodies are truncated to history_tool_max_chars.
419
+ private_class_method def self.compact_history!(opts = {})
420
+ messages = opts[:messages]
421
+ return messages unless messages.is_a?(Array) && messages.length > 12
422
+
423
+ keep_pairs = (agent_flag(key: :history_keep_tool_pairs, default: 6) || 6).to_i
424
+ max_chars = (agent_flag(key: :history_tool_max_chars, default: 2_000) || 2_000).to_i
425
+
426
+ head = []
427
+ rest = messages.dup
428
+ # always keep leading system + first user + optional PLAN
429
+ while rest.any? && %w[system user].include?(rest.first[:role].to_s)
430
+ head << rest.shift
431
+ break if head.any? { |m| m[:role].to_s == 'user' }
432
+ end
433
+ head << rest.shift if rest.any? && rest.first[:role].to_s == 'assistant' && rest.first[:content].to_s.start_with?('PLAN:')
434
+
435
+ # find indices of tool messages in rest; keep only last keep_pairs tool groups
436
+ tool_idxs = rest.each_index.select { |i| rest[i][:role].to_s == 'tool' }
437
+ drop_before = tool_idxs.length > keep_pairs ? tool_idxs[-keep_pairs] : 0
438
+ # include the assistant tool_call message immediately before first kept tool
439
+ start = drop_before
440
+ start -= 1 if start.positive? && rest[start - 1] && rest[start - 1][:role].to_s == 'assistant'
441
+ kept = rest[start..] || []
442
+ kept.each do |m|
443
+ next unless m[:role].to_s == 'tool' && m[:content].to_s.length > max_chars
444
+
445
+ m[:content] = "#{m[:content].to_s[0, max_chars]}…[compacted]"
446
+ end
447
+ messages.replace(head + kept)
448
+ messages
449
+ rescue StandardError => e
450
+ warn "[pwn-ai/loop] compact_history swallowed: #{e.class}: #{e.message}"
451
+ opts[:messages]
452
+ end
453
+
454
+ # 3.2 — local models cannot afford auto_introspect (judge+prm+critic+
455
+ # sentinel+extro) on every success. Default :failure_only when local.
456
+ private_class_method def self.should_auto_introspect?(opts = {})
457
+ return true unless opts[:local]
458
+
459
+ policy = agent_flag(key: :local_introspect, default: :failure_only).to_s.to_sym
460
+ case policy
461
+ when :always then true
462
+ when :every_n
463
+ n = (agent_flag(key: :introspect_every_n, default: 3) || 3).to_i
464
+ n = 3 if n < 1
465
+ (opts[:iter].to_i % n).zero?
466
+ else # :failure_only
467
+ opts[:turn_fails].is_a?(Hash) && opts[:turn_fails].values.sum.positive?
468
+ end
469
+ rescue StandardError
470
+ true
471
+ end
472
+
353
473
  # Supported Method Parameters::
354
474
  # final = PWN::AI::Agent::Loop.run(
355
475
  # request: 'required - what the human typed',
@@ -378,22 +498,53 @@ module PWN
378
498
  append_session(session_id: session_id, role: 'user', content: request)
379
499
 
380
500
  predicted = nil
381
- predicted = plan_first(messages: messages, request: request) if agent_flag(key: :plan_first, default: local) && !Array(tools).empty?
501
+ cal_state = calibration_state
502
+ force_plan = cal_state[:force_plan]
503
+ predicted = plan_first(messages: messages, request: request) if (force_plan || agent_flag(key: :plan_first, default: local)) && !Array(tools).empty?
504
+ if force_plan && cal_state[:cal]
505
+ messages << {
506
+ role: 'user',
507
+ content: "[pwn-ai/w3] engine=#{active_engine} is overconfident " \
508
+ "(brier=#{cal_state[:cal][:brier]}, overconf=#{cal_state[:cal][:overconfidence]}). " \
509
+ 'Prefer high-judge exemplars, verify claims, and avoid speculative tool calls.'
510
+ }
511
+ end
382
512
 
383
513
  turn_fails = Hash.new(0)
384
514
  escalated = false
385
515
 
386
516
  max_iters.times do |i|
517
+ # 3.1 — compact history on local so tool dumps don't fill num_ctx
518
+ compact_history!(messages: messages) if local
519
+
387
520
  msg = call_engine(messages: messages, tools: tools)
388
521
  return '[pwn-ai] engine returned no message' if msg.nil?
389
522
 
390
- messages << msg
391
523
  calls = Array(msg[:tool_calls])
524
+ text = msg[:content].to_s
525
+
526
+ # Empty-final guard (local/thinking models): Ollama sometimes
527
+ # returns done_reason=stop with eval_count<=1, empty content, no
528
+ # tool_calls — historically surface as a blank TUI reply. Do NOT
529
+ # commit that as the answer; drop the empty assistant turn,
530
+ # inject a one-shot nudge, and keep iterating.
531
+ if calls.empty? && text.strip.empty?
532
+ warn "[pwn-ai/loop] empty final from #{engine} on iter=#{i}; nudging" if local
533
+ messages << {
534
+ role: 'user',
535
+ content: 'Your previous reply was empty (no tool_calls and no content). ' \
536
+ 'Either call a tool now, or write the final answer for the user as plain text. ' \
537
+ 'Do not reply with an empty message.'
538
+ }
539
+ turn_fails['empty_final'] += 1
540
+ next
541
+ end
542
+
543
+ messages << msg
392
544
 
393
545
  if calls.empty?
394
- text = msg[:content].to_s
395
546
  append_session(session_id: session_id, role: 'assistant', content: text)
396
- Learning.auto_introspect(session_id: session_id, request: request, final: text, predicted: predicted) if defined?(Learning)
547
+ Learning.auto_introspect(session_id: session_id, request: request, final: text, predicted: predicted) if defined?(Learning) && should_auto_introspect?(local: local, turn_fails: turn_fails, iter: i)
397
548
  return text
398
549
  end
399
550
 
@@ -416,8 +567,8 @@ module PWN
416
567
  # alt-persona branch, judge both, inject the winner. Real
417
568
  # advantage estimation; (loser, winner) → DPO preference.
418
569
  thresh = defined?(Mistakes) ? Mistakes::REPEAT_THRESHOLD : 3
419
- if count == thresh && defined?(Curriculum)
420
- cf = Curriculum.counterfactual(request: request, name: name, args: args, error: tele[:err] || raw[0, 200], hint: hint)
570
+ if count >= thresh && !escalated && defined?(Curriculum)
571
+ cf = (turn_fails["cf:#{fkey}"] += 1) == 1 ? Curriculum.counterfactual(request: request, name: name, args: args, error: tele[:err] || raw[0, 200], hint: hint) : nil
421
572
  hint = "#{hint}\n[pwn-ai/counterfactual] branch #{cf[:branch]} (score=#{cf[:score].round(2)}): #{cf[:content]}" if cf
422
573
  end
423
574
  result = guard_repeated_failure(name: name, count: count, hint: hint, result: result)
@@ -476,7 +627,7 @@ module PWN
476
627
 
477
628
  Local-model scaffolding (PWN::Env[:ai][:agent]):
478
629
  :plan_first - Boolean, plan-then-act pre-pass (default: engine == :ollama)
479
- :tool_router - Boolean, slim Registry.definitions to CORE + top-K relevant
630
+ :tool_router - Boolean/nil, slim Registry.definitions (nil=auto on for ollama)
480
631
  :escalation_persona - Swarm persona name for frontier corrective hints when stuck
481
632
  :critic - S3 constitutional critic before every final (Boolean)
482
633
  :red_team_plan - S4 adversarial plan review after plan_first (Boolean)
@@ -55,8 +55,20 @@ module PWN
55
55
  metrics = opts[:metrics] ||= { tools: {} }
56
56
  metrics[:updated_at] = Time.now.utc.iso8601
57
57
  FileUtils.mkdir_p(File.dirname(METRICS_FILE))
58
- File.write(METRICS_FILE, JSON.pretty_generate(metrics))
58
+ # 4.4 — flock + atomic rename
59
+ path = METRICS_FILE
60
+ tmp = File.join(File.dirname(path), ".#{File.basename(path)}.#{Process.pid}.tmp")
61
+ body = JSON.pretty_generate(metrics)
62
+ File.open(tmp, File::WRONLY | File::CREAT | File::TRUNC, 0o644) do |f|
63
+ f.flock(File::LOCK_EX)
64
+ f.write(body)
65
+ f.flush
66
+ f.fsync
67
+ end
68
+ File.rename(tmp, path)
59
69
  metrics
70
+ ensure
71
+ FileUtils.rm_f(tmp) if defined?(tmp) && tmp && File.exist?(tmp)
60
72
  end
61
73
 
62
74
  # Supported Method Parameters::
@@ -131,24 +143,31 @@ module PWN
131
143
  rows = summary(limit: limit, engine: engine)
132
144
  return '' if rows.empty?
133
145
 
146
+ # P4 — when Reward.sentinel says proxy is hacked, haircut displayed
147
+ # success rates so the model does not trust the lie in the prompt.
148
+ distrust = defined?(Reward) && Reward.respond_to?(:proxy_distrust) ? Reward.proxy_distrust : 0.0
134
149
  scope = engine.to_s.empty? ? 'historical' : "engine=#{engine}"
150
+ scope = "#{scope}, proxy_distrust=#{distrust.round(2)}" if distrust.positive?
135
151
  lines = rows.map do |r|
152
+ rate = r[:success_rate].to_f
153
+ # blend toward 0.5 (uninformative) proportional to distrust
154
+ adj = rate - ((rate - 0.5) * distrust)
136
155
  err = r[:last_error] ? " last_err=#{r[:last_error][0, 60]}" : ''
137
- " - #{r[:name]}: calls=#{r[:calls]} success=#{(r[:success_rate] * 100).round(1)}% avg=#{r[:avg_duration]}s#{err}"
156
+ tag = distrust.positive? ? ' (adj)' : ''
157
+ " - #{r[:name]}: calls=#{r[:calls]} success=#{(adj * 100).round(1)}%#{tag} avg=#{r[:avg_duration]}s#{err}"
138
158
  end
139
- "TOOL EFFECTIVENESS (#{scope}, adapt tool choice accordingly)\n#{lines.join("\n")}\n\n"
159
+ 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
+ "#{warn_line}TOOL EFFECTIVENESS (#{scope}, adapt tool choice accordingly)\n#{lines.join("\n")}\n\n"
140
161
  end
141
162
 
142
- # Supported Method Parameters::
143
- # score = PWN::AI::Agent::Metrics.ucb(
144
- # name: 'required - tool name',
145
- # c: 'optional - exploration constant (default 1.4)'
146
- # )
147
- #
148
- # C1 — Upper Confidence Bound. Tools with few calls get an
149
- # exploration bonus so a single early failure (before its dep was
150
- # installed) does NOT permanently blacklist it. Registry.rank uses
151
- # this instead of raw success_rate.
163
+ # P4 helper — Registry.rank calls this so β·advantage is scaled down
164
+ # when the proxy is untrustworthy.
165
+ public_class_method def self.proxy_trust
166
+ d = defined?(Reward) && Reward.respond_to?(:proxy_distrust) ? Reward.proxy_distrust : 0.0
167
+ (1.0 - d.to_f).clamp(0.0, 1.0)
168
+ rescue StandardError
169
+ 1.0
170
+ end
152
171
 
153
172
  public_class_method def self.ucb(opts = {})
154
173
  name = opts[:name].to_s