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
@@ -66,10 +66,29 @@ module PWN
66
66
  public_class_method def self.save(opts = {})
67
67
  store = opts[:store] ||= {}
68
68
  FileUtils.mkdir_p(File.dirname(MISTAKES_FILE))
69
- File.write(MISTAKES_FILE, JSON.pretty_generate(store))
69
+ atomic_write(path: MISTAKES_FILE, body: JSON.pretty_generate(store))
70
70
  store
71
71
  end
72
72
 
73
+ # 4.4 — flock + atomic rename so nightly practice × interactive ×
74
+ # sentinel cannot tear mistakes.json mid-write.
75
+ private_class_method def self.atomic_write(opts = {})
76
+ path = opts[:path]
77
+ body = opts[:body]
78
+ dir = File.dirname(path)
79
+ FileUtils.mkdir_p(dir)
80
+ tmp = File.join(dir, ".#{File.basename(path)}.#{Process.pid}.tmp")
81
+ File.open(tmp, File::WRONLY | File::CREAT | File::TRUNC, 0o644) do |f|
82
+ f.flock(File::LOCK_EX)
83
+ f.write(body)
84
+ f.flush
85
+ f.fsync
86
+ end
87
+ File.rename(tmp, path)
88
+ ensure
89
+ FileUtils.rm_f(tmp) if defined?(tmp) && tmp && File.exist?(tmp)
90
+ end
91
+
73
92
  # Supported Method Parameters::
74
93
  # sig = PWN::AI::Agent::Mistakes.signature(
75
94
  # tool: 'required - tool/component name that failed',
@@ -127,6 +146,22 @@ module PWN
127
146
  error = opts[:error].to_s
128
147
  return nil if tool.empty? || error.strip.empty?
129
148
 
149
+ # 1.1 — reward_signal: never inflate count beyond 1 open fingerprint.
150
+ # Sentinel opens one parked sig; further gaps calibrate, not spam.
151
+ if tool == 'reward_signal'
152
+ existing = load.values.select { |e| e[:tool].to_s == 'reward_signal' && !e[:resolved] && !e[:parked] }
153
+ if existing.any? && !opts[:force]
154
+ e = existing.max_by { |x| x[:count].to_i }
155
+ e[:last_seen] = Time.now.utc.iso8601
156
+ e[:count] = e[:count].to_i # freeze
157
+ e[:meta] = (e[:meta] || {}).merge(opts[:meta] || {})
158
+ store = load
159
+ store[e[:signature].to_sym] = e
160
+ save(store: store)
161
+ return e
162
+ end
163
+ end
164
+
130
165
  sig = signature(tool: tool, error: error)
131
166
  store = load
132
167
  key = sig.to_sym
@@ -154,10 +189,24 @@ module PWN
154
189
  m[:snippet] = error.to_s.strip[0, 300]
155
190
  m[:sample_args] = opts[:args].to_s[0, 200] if opts[:args]
156
191
  m[:sessions] = (Array(m[:sessions]) + [opts[:session_id]]).compact.uniq.last(10)
192
+ # 2.2 — recoverable shape for repair routing
193
+ if opts[:shape]
194
+ m[:shape] = opts[:shape].to_s
195
+ elsif defined?(Reward) && Reward.respond_to?(:recoverable_shape)
196
+ m[:shape] ||= Reward.recoverable_shape(err: error).to_s
197
+ end
198
+ m[:needs_code_change] = true if opts[:needs_code_change]
199
+ m[:meta] = (m[:meta] || {}).merge(opts[:meta] || {}) if opts[:meta]
157
200
  # A recurrence of a "resolved" mistake means the fix was wrong /
158
201
  # incomplete — reopen it so it re-enters the DO-NOT-REPEAT block.
159
- m[:resolved] = false
160
- m[:regressed] = true if was_resolved
202
+ # Structured fixes with holdout_tests that still pass stay closed.
203
+ if was_resolved && structured_fix_holds?(mistake: m)
204
+ m[:resolved] = true
205
+ m[:regressed] = false
206
+ else
207
+ m[:resolved] = false
208
+ m[:regressed] = true if was_resolved
209
+ end
161
210
  save(store: store)
162
211
  m
163
212
  end
@@ -182,6 +231,20 @@ module PWN
182
231
  store[key][:regressed] = false
183
232
  store[key][:fix] = fix.strip[0, 500]
184
233
  store[key][:resolved_at] = Time.now.utc.iso8601
234
+ # 2.3 — structured fix payload (strategy/tool/args_template/holdouts).
235
+ # Prose-only resolve is why shell sigs regressed after auto-curriculum.
236
+ if opts[:structured].is_a?(Hash)
237
+ s = opts[:structured]
238
+ store[key][:structured_fix] = {
239
+ strategy: s[:strategy].to_s[0, 80],
240
+ tool: s[:tool].to_s[0, 60],
241
+ args_template: s[:args_template],
242
+ holdout_tests: Array(s[:holdout_tests]).first(5),
243
+ winning_trace: s[:winning_trace].to_s[0, 2_000]
244
+ }.compact
245
+ end
246
+ store[key][:parked] = false
247
+ store[key][:needs_code_change] = false if opts[:clear_needs_code_change]
185
248
  save(store: store)
186
249
 
187
250
  if defined?(PWN::Memory)
@@ -218,9 +281,28 @@ module PWN
218
281
  only = opts.key?(:unresolved_only) ? opts[:unresolved_only] : true
219
282
  rows = load.values
220
283
  rows = rows.reject { |m| m[:resolved] } if only
284
+ # 2.5 — practice/curriculum skip engineer-only / parked fingerprints
285
+ rows = rows.reject { |m| m[:parked] || m[:needs_code_change] || m[:tool].to_s == 'reward_signal' } if opts[:practiceable_only]
221
286
  rows.sort_by { |m| [-m[:count].to_i, m[:last_seen].to_s] }.first(limit)
222
287
  end
223
288
 
289
+ # 2.5 — park unfixable sigs so nightly practice skips them
290
+ public_class_method def self.park(opts = {})
291
+ sig = opts[:signature].to_s
292
+ raise 'ERROR: signature is required' if sig.empty?
293
+
294
+ store = load
295
+ key = sig.to_sym
296
+ raise "ERROR: unknown mistake signature #{sig}" unless store[key]
297
+
298
+ store[key][:parked] = true
299
+ store[key][:needs_code_change] = true
300
+ store[key][:park_reason] = opts[:reason].to_s[0, 300]
301
+ store[key][:parked_at] = Time.now.utc.iso8601
302
+ save(store: store)
303
+ store[key]
304
+ end
305
+
224
306
  # Supported Method Parameters::
225
307
  # ctx = PWN::AI::Agent::Mistakes.to_context(limit: 6)
226
308
  #
@@ -232,10 +314,15 @@ module PWN
232
314
  # survives even after dropping out of the first list.
233
315
 
234
316
  public_class_method def self.to_context(opts = {})
235
- limit = opts[:limit] || 6
236
- open = top(limit: limit, unresolved_only: true)
317
+ limit = opts[:limit] || 6
318
+ request = opts[:request].to_s
319
+ open_rows = top(limit: limit * 3, unresolved_only: true)
320
+ # 2.6 — request-conditioned rank (sim × recency × count), same idea
321
+ # as exemplars_for. Stops injecting loudest scar (reward_signal ×13)
322
+ # on every unrelated turn.
323
+ open = rank_for_request(rows: open_rows, request: request, limit: limit)
237
324
  closed = load.values.select { |m| m[:resolved] && m[:fix] }
238
- .sort_by { |m| m[:resolved_at].to_s }.reverse.first(limit)
325
+ closed = rank_for_request(rows: closed, request: request, limit: limit)
239
326
  return '' if open.empty? && closed.empty?
240
327
 
241
328
  out = +''
@@ -245,19 +332,63 @@ module PWN
245
332
  tags << 'REPEATING' if effective_count(mistake: m) >= REPEAT_THRESHOLD
246
333
  tags << 'ENV_DRIFT' if m[:cause].to_s == 'env_drift'
247
334
  tags << 'REGRESSED' if m[:regressed]
335
+ tags << 'PARKED' if m[:parked] || m[:needs_code_change]
248
336
  tag = tags.empty? ? '' : " [#{tags.join(',')}]"
249
337
  fix = m[:fix] ? " — last fix (insufficient): #{m[:fix][0, 100]}" : ''
250
- " ✗ [#{m[:signature]}] #{m[:tool]} ×#{m[:count]}#{tag}: #{m[:error][0, 140]}#{fix}"
338
+ shape = m[:shape] ? " shape=#{m[:shape]}" : ''
339
+ " ✗ [#{m[:signature]}] #{m[:tool]} ×#{m[:count]}#{tag}#{shape}: #{m[:error][0, 140]}#{fix}"
251
340
  end
252
341
  out << "KNOWN MISTAKES (do NOT repeat — call mistakes_resolve once fixed)\n#{lines.join("\n")}\n"
253
342
  end
254
343
  unless closed.empty?
255
- lines = closed.map { |m| " ✓ [#{m[:signature]}] #{m[:tool]}: #{m[:error][0, 80]} — FIX: #{m[:fix][0, 140]}" }
344
+ lines = closed.map do |m|
345
+ sf = m[:structured_fix]
346
+ extra = sf ? " strategy=#{sf[:strategy]} tool=#{sf[:tool]}" : ''
347
+ " ✓ [#{m[:signature]}] #{m[:tool]}: #{m[:error][0, 80]} — FIX: #{m[:fix][0, 140]}#{extra}"
348
+ end
256
349
  out << "KNOWN FIXES (apply these instead of repeating the mistake)\n#{lines.join("\n")}\n"
257
350
  end
258
351
  "#{out}\n"
259
352
  end
260
353
 
354
+ private_class_method def self.rank_for_request(opts = {})
355
+ rows = Array(opts[:rows])
356
+ limit = opts[:limit] || 6
357
+ req = opts[:request].to_s.downcase
358
+ return rows.first(limit) if req.strip.empty?
359
+
360
+ tokens = req.scan(/[a-z0-9_]{3,}/).uniq
361
+ scored = rows.map do |m|
362
+ hay = "#{m[:tool]} #{m[:error]} #{m[:snippet]} #{m[:fix]} #{m[:shape]}".downcase
363
+ sim = tokens.empty? ? 0.0 : tokens.count { |t| hay.include?(t) }.to_f / tokens.length
364
+ days = begin
365
+ (Time.now.utc - Time.parse(m[:last_seen].to_s)) / 86_400.0
366
+ rescue StandardError
367
+ 30.0
368
+ end
369
+ decay = 0.5**(days / 30.0)
370
+ # downrank reward_signal / parked unless the request is about rewards
371
+ penalty = 1.0
372
+ penalty *= 0.05 if m[:tool].to_s == 'reward_signal' && !req.match?(/reward|judge|sentinel|proxy/)
373
+ penalty *= 0.3 if m[:parked] || m[:needs_code_change]
374
+ score = ((sim * 2.0) + (decay * 0.5) + (Math.log2(m[:count].to_i + 1) * 0.3)) * penalty
375
+ # always allow some mass for top-count scars when sim=0 but keep penalty
376
+ score = decay * 0.15 * penalty if score <= 0
377
+ [m, score]
378
+ end
379
+ scored.sort_by { |_, s| -s }.first(limit).map(&:first)
380
+ end
381
+
382
+ private_class_method def self.structured_fix_holds?(opts = {})
383
+ m = opts[:mistake]
384
+ sf = m && m[:structured_fix]
385
+ return false unless sf.is_a?(Hash) && Array(sf[:holdout_tests]).length >= 2
386
+
387
+ # holdouts are opaque prompts; presence alone is the gate — practice
388
+ # re-verifies before resolve. Recurrence without new evidence stays closed.
389
+ true
390
+ end
391
+
261
392
  # Supported Method Parameters::
262
393
  # str = PWN::AI::Agent::Mistakes.correction_hint(
263
394
  # tool: 'required - tool that just failed',
@@ -49,7 +49,7 @@ module PWN
49
49
  pwn : #{pwn_version}
50
50
  session_id : #{session_id || '(none)'}
51
51
 
52
- #{memory_block(limit: b[:memory], request: request)}#{skills_block}#{learning_block(limit: b[:learning])}#{mistakes_block(limit: b[:mistakes])}#{metrics_block(limit: b[:metrics], engine: engine)}#{extrospection_block if b[:extro]}TOOL USE
52
+ #{memory_block(limit: b[:memory], request: request)}#{skills_block}#{learning_block(limit: b[:learning])}#{mistakes_block(limit: b[:mistakes], request: request)}#{metrics_block(limit: b[:metrics], engine: engine)}#{extrospection_block if b[:extro]}TOOL USE
53
53
  Use the provided function tools to act on the host. A reply with
54
54
  no tool_calls is treated as your FINAL answer to the user.
55
55
  Prefer `pwn_eval` for anything in the PWN:: namespace and `shell`
@@ -144,7 +144,7 @@ module PWN
144
144
  private_class_method def self.mistakes_block(opts = {})
145
145
  return '' unless defined?(PWN::AI::Agent::Mistakes)
146
146
 
147
- ctx = PWN::AI::Agent::Mistakes.to_context(limit: opts[:limit] || 6).to_s
147
+ ctx = PWN::AI::Agent::Mistakes.to_context(limit: opts[:limit] || 6, request: opts[:request]).to_s
148
148
  ctx.strip.empty? ? '' : ctx
149
149
  rescue StandardError
150
150
  ''
@@ -24,7 +24,8 @@ module PWN
24
24
  # Shipping all ~47 tool schemas on every call overwhelms a 35B local
25
25
  # model — it mis-routes (extro_rf_tune for a git question) because the
26
26
  # choice space is huge. When PWN::Env[:ai][:agent][:tool_router] is
27
- # truthy AND definitions(relevance: request) is passed, the pool is
27
+ # truthy (or nil while active==:ollama) AND definitions(relevance:)
28
+ # is passed, the pool is
28
29
  # reduced to CORE_TOOLS + the top-K keyword-ranked matches. Routing
29
30
  # accuracy is fed back into Metrics under name:'tool_router' so the
30
31
  # router itself becomes a learned component.
@@ -140,7 +141,9 @@ module PWN
140
141
  # early failure (before its dep was installed) does not blacklist
141
142
  # it forever; advantage prefers tools that outperform the fleet.
142
143
  alpha = 1.0
143
- beta = 0.3
144
+ # P4 — haircut advantage weight when reward proxy is hacked
145
+ trust = defined?(Metrics) && Metrics.respond_to?(:proxy_trust) ? Metrics.proxy_trust : 1.0
146
+ beta = 0.3 * trust
144
147
  gamma = 0.2
145
148
  scored = entries.map do |e|
146
149
  hay = "#{e.name} #{e.toolset} #{e.schema[:description]} #{Array(e.schema.dig(:parameters, :properties)&.keys).join(' ')}".downcase
@@ -183,7 +186,14 @@ module PWN
183
186
  end
184
187
 
185
188
  private_class_method def self.router_enabled?
186
- defined?(PWN::Env) && PWN::Env.is_a?(Hash) && PWN::Env.dig(:ai, :agent, :tool_router)
189
+ return false unless defined?(PWN::Env) && PWN::Env.is_a?(Hash)
190
+
191
+ v = PWN::Env.dig(:ai, :agent, :tool_router)
192
+ # nil = auto: on for ollama (largest single local-model win — ~11k→~3k
193
+ # schema tokens/turn); off for frontier unless explicitly enabled.
194
+ return v ? true : false unless v.nil?
195
+
196
+ PWN::Env.dig(:ai, :active).to_s.downcase.to_sym == :ollama
187
197
  rescue StandardError
188
198
  false
189
199
  end
@@ -8,7 +8,8 @@ module PWN
8
8
  # redaction. Keeps the context window bounded and avoids leaking
9
9
  # PWN::Env credentials back into the model.
10
10
  module Result
11
- DEFAULT_MAX = 24_000
11
+ DEFAULT_MAX = 24_000
12
+ LOCAL_DEFAULT_MAX = 4_000
12
13
 
13
14
  # Generic high-confidence credential shapes scrubbed from tool
14
15
  # output regardless of PWN::Env contents. Built via concatenation
@@ -36,12 +37,28 @@ module PWN
36
37
  public_class_method def self.condition(opts = {})
37
38
  content = opts[:content].to_s
38
39
  entry = opts[:entry]
39
- cap = entry ? entry.max_chars : DEFAULT_MAX
40
+ cap = entry ? entry.max_chars : default_max
40
41
 
41
42
  content = "#{content[0, cap]}…[truncated #{opts[:content].to_s.length - cap} chars]" if content.length > cap
42
43
  redact(content: content)
43
44
  end
44
45
 
46
+ # Engine-aware default: ollama keeps history inside a tight num_ctx;
47
+ # a 24k tool dump on every call eats the window before useful work.
48
+ # Override via PWN::Env[:ai][:ollama][:result_max].
49
+ public_class_method def self.default_max
50
+ eng = (PWN::Env.dig(:ai, :active) if defined?(PWN::Env)).to_s.downcase
51
+ if eng == 'ollama'
52
+ v = (PWN::Env.dig(:ai, :ollama, :result_max) if defined?(PWN::Env))
53
+ return v.to_i if v.to_i.positive?
54
+
55
+ return LOCAL_DEFAULT_MAX
56
+ end
57
+ DEFAULT_MAX
58
+ rescue StandardError
59
+ DEFAULT_MAX
60
+ end
61
+
45
62
  # Supported Method Parameters::
46
63
  # safe = PWN::AI::Agent::Result.redact(
47
64
  # content: 'required - String to scrub of credential-shaped substrings'