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.
- checksums.yaml +4 -4
- data/Gemfile +3 -3
- data/README.md +1 -1
- data/documentation/Cron.md +10 -8
- data/documentation/Reinforcement-Learning.md +43 -21
- data/documentation/diagrams/cron-scheduling.svg +134 -93
- data/documentation/diagrams/dot/cron-scheduling.dot +9 -3
- data/documentation/diagrams/dot/pwn-ai-feedback-learning-loop.dot +2 -2
- data/documentation/diagrams/dot/reinforcement-learning.dot +9 -3
- data/documentation/diagrams/pwn-ai-feedback-learning-loop.svg +103 -101
- data/documentation/diagrams/reinforcement-learning.svg +211 -166
- data/documentation/pwn-ai-Agent.md +12 -0
- data/lib/pwn/ai/agent/curriculum.rb +647 -56
- data/lib/pwn/ai/agent/learning.rb +87 -15
- data/lib/pwn/ai/agent/loop.rb +164 -13
- data/lib/pwn/ai/agent/metrics.rb +32 -13
- data/lib/pwn/ai/agent/mistakes.rb +139 -8
- data/lib/pwn/ai/agent/prompt_builder.rb +2 -2
- data/lib/pwn/ai/agent/registry.rb +13 -3
- data/lib/pwn/ai/agent/result.rb +19 -2
- data/lib/pwn/ai/agent/reward.rb +415 -42
- data/lib/pwn/ai/agent/tools/curriculum.rb +51 -0
- data/lib/pwn/ai/agent/tools/reward.rb +15 -4
- data/lib/pwn/ai/anthropic.rb +265 -4
- data/lib/pwn/ai/ollama.rb +274 -10
- data/lib/pwn/ai/open_ai.rb +307 -4
- data/lib/pwn/config.rb +137 -12
- data/lib/pwn/cron.rb +24 -2
- data/lib/pwn/memory.rb +13 -1
- data/lib/pwn/version.rb +1 -1
- data/spec/integration/persistence_roundtrip_spec.rb +6 -1
- data/spec/integration/reinforced_feedback_loop_spec.rb +144 -8
- data/spec/lib/pwn/ai/agent/mistakes_spec.rb +27 -0
- data/spec/lib/pwn/ai/agent/result_spec.rb +11 -0
- data/spec/lib/pwn/ai/agent/reward_spec.rb +89 -0
- data/spec/lib/pwn/ai/ollama_spec.rb +251 -0
- data/third_party/pwn_rdoc.jsonl +58 -7
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
326
|
-
|
|
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
|
-
|
|
545
|
-
|
|
546
|
-
|
|
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
|
-
|
|
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
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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
|
data/lib/pwn/ai/agent/loop.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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:
|
|
302
|
-
tool_calls:
|
|
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
|
-
|
|
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
|
|
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
|
|
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)
|
data/lib/pwn/ai/agent/metrics.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
-
#
|
|
143
|
-
#
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|