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
|
@@ -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
|
-
|
|
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
|
-
|
|
160
|
-
|
|
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
|
|
236
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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)
|
|
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
|
data/lib/pwn/ai/agent/result.rb
CHANGED
|
@@ -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
|
|
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 :
|
|
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'
|