pwn 0.5.617 → 0.5.620
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 +2 -2
- data/README.md +101 -83
- data/bin/pwn_gqrx_scanner +25 -5
- data/documentation/AI-Integration.md +42 -26
- data/documentation/AWS.md +57 -0
- data/documentation/Agent-Tool-Registry.md +56 -0
- data/documentation/Banner.md +17 -0
- data/documentation/Blockchain.md +18 -0
- data/documentation/Bounty.md +21 -0
- data/documentation/BurpSuite.md +41 -16
- data/documentation/CLI-Drivers.md +58 -0
- data/documentation/Configuration.md +66 -0
- data/documentation/Contributing.md +33 -19
- data/documentation/Cron.md +47 -0
- data/documentation/Diagrams.md +6 -2
- data/documentation/Drivers.md +43 -16
- data/documentation/Extrospection.md +73 -0
- data/documentation/FFI.md +14 -0
- data/documentation/Fuzzing.md +36 -0
- data/documentation/Hardware.md +40 -0
- data/documentation/Home.md +5 -4
- data/documentation/How-PWN-Works.md +7 -4
- data/documentation/Metasploit.md +34 -0
- data/documentation/Mistakes.md +96 -0
- data/documentation/NmapIt.md +18 -12
- data/documentation/PWN.png +0 -0
- data/documentation/PWN_Contributors_and_Users.png +0 -0
- data/documentation/Persistence.md +40 -0
- data/documentation/Plugins.md +97 -47
- data/documentation/Reporting.md +25 -18
- data/documentation/SAST.md +39 -22
- data/documentation/SDR.md +58 -0
- data/documentation/Sessions.md +39 -0
- data/documentation/Skills-Memory-Learning.md +59 -29
- data/documentation/Swarm.md +71 -0
- data/documentation/Transparent-Browser.md +26 -22
- data/documentation/Troubleshooting.md +44 -25
- data/documentation/WWW.md +32 -0
- data/documentation/diagrams/agent-tool-registry.svg +286 -0
- data/documentation/diagrams/aws-cloud-security.svg +166 -0
- data/documentation/diagrams/cron-scheduling.svg +148 -0
- data/documentation/diagrams/dot/agent-tool-registry.dot +2 -2
- data/documentation/diagrams/dot/extrospection-world-awareness.dot +9 -7
- data/documentation/diagrams/dot/hardware-hacking.dot +48 -0
- data/documentation/diagrams/dot/memory-skills-detailed.dot +12 -5
- data/documentation/diagrams/dot/mistakes-negative-feedback.dot +93 -0
- data/documentation/diagrams/dot/overall-pwn-architecture.dot +7 -5
- data/documentation/diagrams/dot/persistence-filesystem.dot +30 -0
- data/documentation/diagrams/dot/pwn-ai-feedback-learning-loop.dot +19 -7
- data/documentation/diagrams/dot/sdr-radio-flow.dot +13 -11
- data/documentation/diagrams/extrospection-world-awareness.svg +203 -0
- data/documentation/diagrams/hardware-hacking.svg +163 -0
- data/documentation/diagrams/memory-skills-detailed.svg +146 -97
- data/documentation/diagrams/mistakes-negative-feedback.svg +266 -0
- data/documentation/diagrams/overall-pwn-architecture.svg +184 -173
- data/documentation/diagrams/persistence-filesystem.svg +189 -0
- data/documentation/diagrams/pwn-ai-feedback-learning-loop.svg +217 -149
- data/documentation/diagrams/sdr-radio-flow.svg +166 -0
- data/documentation/diagrams/swarm-multi-agent.svg +225 -0
- data/documentation/fax-spectrogram.png +0 -0
- data/documentation/fax-waveform.png +0 -0
- data/documentation/pwn-REPL.md +40 -24
- data/documentation/pwn-ai-Agent.md +72 -30
- data/documentation/pwn_android_war_dialer_session.png +0 -0
- data/documentation/pwn_install.png +0 -0
- data/documentation/pwn_wallpaper.jpg +0 -0
- data/documentation/ringing-spectrogram.png +0 -0
- data/documentation/ringing-waveform.png +0 -0
- data/lib/pwn/ai/agent/extrospection.rb +65 -6
- data/lib/pwn/ai/agent/learning.rb +67 -7
- data/lib/pwn/ai/agent/loop.rb +72 -6
- data/lib/pwn/ai/agent/mistakes.rb +369 -0
- data/lib/pwn/ai/agent/prompt_builder.rb +10 -1
- data/lib/pwn/ai/agent/tools/extrospection.rb +2 -2
- data/lib/pwn/ai/agent/tools/mistakes.rb +132 -0
- data/lib/pwn/ai/agent.rb +1 -0
- data/lib/pwn/plugins/jira_data_center.rb +2 -2
- data/lib/pwn/sdr/decoder/adsb.rb +101 -0
- data/lib/pwn/sdr/decoder/apt.rb +75 -0
- data/lib/pwn/sdr/decoder/bluetooth.rb +78 -0
- data/lib/pwn/sdr/decoder/dect.rb +84 -0
- data/lib/pwn/sdr/decoder/gps.rb +83 -0
- data/lib/pwn/sdr/decoder/gsm.rb +50 -29
- data/lib/pwn/sdr/decoder/iridium.rb +81 -0
- data/lib/pwn/sdr/decoder/lora.rb +87 -0
- data/lib/pwn/sdr/decoder/lte.rb +88 -0
- data/lib/pwn/sdr/decoder/morse.rb +64 -0
- data/lib/pwn/sdr/decoder/p25.rb +71 -0
- data/lib/pwn/sdr/decoder/pager.rb +77 -0
- data/lib/pwn/sdr/decoder/pocsag.rb +26 -11
- data/lib/pwn/sdr/decoder/rfid.rb +88 -0
- data/lib/pwn/sdr/decoder/rtl433.rb +95 -0
- data/lib/pwn/sdr/decoder/rtty.rb +66 -0
- data/lib/pwn/sdr/decoder/wifi.rb +98 -0
- data/lib/pwn/sdr/decoder/zigbee.rb +96 -0
- data/lib/pwn/sdr/decoder.rb +67 -7
- data/lib/pwn/sdr/frequency_allocation.rb +82 -41
- data/lib/pwn/sdr/gqrx.rb +186 -95
- data/lib/pwn/version.rb +1 -1
- data/spec/lib/pwn/ai/agent/mistakes_spec.rb +36 -0
- data/spec/lib/pwn/ai/agent/tools/mistakes_spec.rb +12 -0
- data/spec/lib/pwn/sdr/decoder/adsb_spec.rb +15 -0
- data/spec/lib/pwn/sdr/decoder/apt_spec.rb +15 -0
- data/spec/lib/pwn/sdr/decoder/bluetooth_spec.rb +15 -0
- data/spec/lib/pwn/sdr/decoder/dect_spec.rb +15 -0
- data/spec/lib/pwn/sdr/decoder/gps_spec.rb +15 -0
- data/spec/lib/pwn/sdr/decoder/iridium_spec.rb +15 -0
- data/spec/lib/pwn/sdr/decoder/lora_spec.rb +15 -0
- data/spec/lib/pwn/sdr/decoder/lte_spec.rb +15 -0
- data/spec/lib/pwn/sdr/decoder/morse_spec.rb +15 -0
- data/spec/lib/pwn/sdr/decoder/p25_spec.rb +15 -0
- data/spec/lib/pwn/sdr/decoder/pager_spec.rb +15 -0
- data/spec/lib/pwn/sdr/decoder/rfid_spec.rb +15 -0
- data/spec/lib/pwn/sdr/decoder/rtl433_spec.rb +15 -0
- data/spec/lib/pwn/sdr/decoder/rtty_spec.rb +15 -0
- data/spec/lib/pwn/sdr/decoder/wifi_spec.rb +15 -0
- data/spec/lib/pwn/sdr/decoder/zigbee_spec.rb +15 -0
- data/third_party/pwn_rdoc.jsonl +114 -8
- metadata +81 -5
|
Binary file
|
|
@@ -41,7 +41,8 @@ module PWN
|
|
|
41
41
|
module Extrospection
|
|
42
42
|
EXTRO_FILE = File.join(Dir.home, '.pwn', 'extrospection.json')
|
|
43
43
|
MAX_OBSERVATIONS = 500
|
|
44
|
-
|
|
44
|
+
RF_BINS = %w[rtl_sdr rtl_test rtl_433 hackrf_info gqrx dump1090 multimon-ng SoapySDRUtil].freeze
|
|
45
|
+
PROBE_BINS = (%w[nmap curl git ruby python3 gcc msfconsole sqlmap burpsuite zaproxy openssl docker] + RF_BINS).freeze
|
|
45
46
|
|
|
46
47
|
# Supported Method Parameters::
|
|
47
48
|
# store = PWN::AI::Agent::Extrospection.load
|
|
@@ -71,7 +72,7 @@ module PWN
|
|
|
71
72
|
# Supported Method Parameters::
|
|
72
73
|
# snap = PWN::AI::Agent::Extrospection.snapshot(
|
|
73
74
|
# persist: 'optional - Boolean, write snapshot to disk & rotate previous (default true)',
|
|
74
|
-
# sections: 'optional - Array subset of [:host, :net, :toolchain, :repo, :env] (default all)'
|
|
75
|
+
# sections: 'optional - Array subset of [:host, :net, :toolchain, :repo, :env, :rf] (default all)'
|
|
75
76
|
# )
|
|
76
77
|
#
|
|
77
78
|
# Captures a fingerprint of the OUTSIDE world. When persist:true the
|
|
@@ -84,7 +85,7 @@ module PWN
|
|
|
84
85
|
true
|
|
85
86
|
end
|
|
86
87
|
sections = Array(opts[:sections]).map(&:to_sym)
|
|
87
|
-
sections = %i[host net toolchain repo env] if sections.empty?
|
|
88
|
+
sections = %i[host net toolchain repo env rf] if sections.empty?
|
|
88
89
|
|
|
89
90
|
snap = {}
|
|
90
91
|
snap[:host] = probe_host if sections.include?(:host)
|
|
@@ -92,6 +93,7 @@ module PWN
|
|
|
92
93
|
snap[:toolchain] = probe_toolchain if sections.include?(:toolchain)
|
|
93
94
|
snap[:repo] = probe_repo if sections.include?(:repo)
|
|
94
95
|
snap[:env] = probe_env if sections.include?(:env)
|
|
96
|
+
snap[:rf] = probe_rf if sections.include?(:rf)
|
|
95
97
|
snap[:captured_at] = Time.now.utc.iso8601
|
|
96
98
|
snap[:fingerprint] = Digest::SHA256.hexdigest(JSON.generate(snap.except(:captured_at)))[0, 16]
|
|
97
99
|
|
|
@@ -110,7 +112,7 @@ module PWN
|
|
|
110
112
|
# obs = PWN::AI::Agent::Extrospection.observe(
|
|
111
113
|
# source: 'required - where the observation came from (nmap, shodan, burp, cve, human, ...)',
|
|
112
114
|
# data: 'required - the observation payload (String or Hash)',
|
|
113
|
-
# category: 'optional - :recon, :vuln, :intel, :target, :network, :misc (default :misc)',
|
|
115
|
+
# category: 'optional - :recon, :vuln, :intel, :target, :network, :env, :rf, :misc (default :misc)',
|
|
114
116
|
# target: 'optional - host/ip/url/asset the observation is about',
|
|
115
117
|
# tags: 'optional - Array of String labels',
|
|
116
118
|
# ttl: 'optional - seconds until this observation is considered stale (default nil = forever)'
|
|
@@ -288,7 +290,21 @@ module PWN
|
|
|
288
290
|
end
|
|
289
291
|
end
|
|
290
292
|
|
|
291
|
-
# 4)
|
|
293
|
+
# 4) :rf observations vs missing SDR hardware / binaries
|
|
294
|
+
rf = snap[:rf] || {}
|
|
295
|
+
hw_present = %i[rtl_sdr hackrf flipper gqrx_sock].any? { |k| rf_present?(val: rf[k]) }
|
|
296
|
+
observations(category: 'rf', limit: 50).each do |ob|
|
|
297
|
+
miss = RF_BINS.select { |b| pkgs[b.to_sym].to_s.empty? }
|
|
298
|
+
if !hw_present
|
|
299
|
+
findings << { kind: :rf_no_hardware, observation: ob[:data], source: ob[:source], target: ob[:target], advice: 'RF observation recorded but no SDR hardware detected in snapshot — plug in RTL-SDR/HackRF/Flipper or start gqrx (`-r`) before trusting RF results.' }
|
|
300
|
+
elsif rf[:gqrx_sock] == false && ob[:source].to_s == 'gqrx'
|
|
301
|
+
findings << { kind: :rf_gqrx_down, observation: ob[:data], target: ob[:target], advice: 'gqrx remote-control socket (127.0.0.1:7356) is closed — start gqrx with remote control enabled before re-running the scan.' }
|
|
302
|
+
elsif !miss.empty?
|
|
303
|
+
findings << { kind: :rf_toolchain_gap, missing: miss, observation: ob[:data], advice: "SDR toolchain gap: install #{miss.join(', ')} to decode/act on this RF observation." }
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# 5) raw drift as low-priority findings when nothing else matched
|
|
292
308
|
Array(delta[:added]).first(5).each { |c| findings << { kind: :env_added, detail: c } } if findings.empty?
|
|
293
309
|
Array(delta[:removed]).first(5).each { |c| findings << { kind: :env_removed, detail: c } } if findings.empty?
|
|
294
310
|
|
|
@@ -346,7 +362,8 @@ module PWN
|
|
|
346
362
|
drift_added: Array(delta[:added]).length,
|
|
347
363
|
drift_removed: Array(delta[:removed]).length,
|
|
348
364
|
toolchain_bins: (snap[:toolchain] || {}).count { |_, v| !v.to_s.empty? },
|
|
349
|
-
listening_ports: Array(snap.dig(:net, :listening)).length
|
|
365
|
+
listening_ports: Array(snap.dig(:net, :listening)).length,
|
|
366
|
+
rf_devices: (snap[:rf] || {}).values_at(:rtl_sdr, :hackrf, :flipper, :gqrx_sock).count { |v| rf_present?(val: v) }
|
|
350
367
|
}
|
|
351
368
|
end
|
|
352
369
|
|
|
@@ -478,6 +495,47 @@ module PWN
|
|
|
478
495
|
{ cwd: Dir.pwd, ruby: RUBY_VERSION }
|
|
479
496
|
end
|
|
480
497
|
|
|
498
|
+
# Passive RF / SDR hardware inventory. NO transmit, NO active spectrum
|
|
499
|
+
# scan — this is the RF analogue of probe_toolchain: "what radios and
|
|
500
|
+
# SDR plumbing are attached / reachable right now?" so drift can flag
|
|
501
|
+
# "HackRF unplugged", "gqrx remote-control down", "new RTL dongle".
|
|
502
|
+
private_class_method def self.probe_rf
|
|
503
|
+
{
|
|
504
|
+
rtl_sdr: sh(cmd: 'timeout 3 rtl_test -t 2>&1 | head -5'),
|
|
505
|
+
hackrf: sh(cmd: 'timeout 3 hackrf_info 2>&1 | head -8'),
|
|
506
|
+
soapy: sh(cmd: 'timeout 3 SoapySDRUtil --find 2>&1 | head -10'),
|
|
507
|
+
gqrx_sock: tcp_open?(host: '127.0.0.1', port: 7356),
|
|
508
|
+
flipper: Dir.glob('/dev/serial/by-id/*Flipper*').any?,
|
|
509
|
+
serial_devs: Dir.glob('/dev/{ttyUSB,ttyACM}*'),
|
|
510
|
+
band_plans: rf_band_plan_keys.length
|
|
511
|
+
}
|
|
512
|
+
rescue StandardError => e
|
|
513
|
+
{ error: "#{e.class}: #{e.message}" }
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
private_class_method def self.tcp_open?(opts = {})
|
|
517
|
+
host = opts[:host] || '127.0.0.1'
|
|
518
|
+
port = opts[:port].to_i
|
|
519
|
+
Socket.tcp(host, port, connect_timeout: 1, &:close)
|
|
520
|
+
true
|
|
521
|
+
rescue StandardError
|
|
522
|
+
false
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
private_class_method def self.rf_present?(opts = {})
|
|
526
|
+
v = opts[:val]
|
|
527
|
+
return false if v.nil? || v == false || v.to_s.strip.empty?
|
|
528
|
+
|
|
529
|
+
v.to_s !~ /no\s+.*devices|no\s+hackrf|not\s+found|no\s+such\s+file|command\s+not\s+found|^false$/i
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
private_class_method def self.rf_band_plan_keys
|
|
533
|
+
require 'pwn/sdr/frequency_allocation' unless defined?(PWN::SDR::FrequencyAllocation)
|
|
534
|
+
PWN::SDR::FrequencyAllocation.band_plans.keys.map(&:to_s).sort
|
|
535
|
+
rescue StandardError
|
|
536
|
+
[]
|
|
537
|
+
end
|
|
538
|
+
|
|
481
539
|
private_class_method def self.flatten(opts = {})
|
|
482
540
|
hash = opts[:hash] || {}
|
|
483
541
|
prefix = opts[:prefix].to_s
|
|
@@ -598,6 +656,7 @@ module PWN
|
|
|
598
656
|
PWN::AI::Agent::Extrospection.snapshot # probe host, persist, return {snapshot:, drift:}
|
|
599
657
|
PWN::AI::Agent::Extrospection.drift(live: true) # what changed vs last snapshot
|
|
600
658
|
PWN::AI::Agent::Extrospection.observe(source: 'nmap', category: :recon, target: '10.0.0.5', data: '22/tcp open ssh 9.6')
|
|
659
|
+
PWN::AI::Agent::Extrospection.observe(source: 'gqrx', category: :rf, target: '433.920MHz', data: 'peak -34.2 dBFS bw=200k')
|
|
601
660
|
PWN::AI::Agent::Extrospection.observations(category: 'recon', target: '10.0.0.5')
|
|
602
661
|
PWN::AI::Agent::Extrospection.intel(query: 'openssl 3.0', record: true)
|
|
603
662
|
PWN::AI::Agent::Extrospection.correlate # introspection x extrospection findings
|
|
@@ -112,16 +112,19 @@ module PWN
|
|
|
112
112
|
|
|
113
113
|
public_class_method def self.to_context(opts = {})
|
|
114
114
|
limit = opts[:limit] || 5
|
|
115
|
-
rows
|
|
116
|
-
|
|
115
|
+
rows = outcomes(limit: limit)
|
|
116
|
+
fails = outcomes(limit: 200, success: false).first(limit)
|
|
117
|
+
return '' if rows.empty? && fails.empty?
|
|
117
118
|
|
|
118
|
-
|
|
119
|
+
fmt = lambda do |r|
|
|
119
120
|
flag = r[:success] ? '✓' : '✗'
|
|
120
|
-
" #{flag} #{r[:task][0, 100]} (#{r[:timestamp]})"
|
|
121
|
+
" #{flag} #{r[:task].to_s[0, 100]} (#{r[:timestamp]})"
|
|
121
122
|
end
|
|
122
|
-
s
|
|
123
|
+
s = stats
|
|
123
124
|
hdr = "RECENT OUTCOMES (success_rate=#{(s[:success_rate] * 100).round(1)}% over #{s[:total_outcomes]} attempts)"
|
|
124
|
-
"#{hdr}\n#{
|
|
125
|
+
out = "#{hdr}\n#{rows.map(&fmt).join("\n")}\n"
|
|
126
|
+
out += "RECENT FAILURES (learn from these — do not repeat)\n#{fails.map(&fmt).join("\n")}\n" unless fails.empty?
|
|
127
|
+
"#{out}\n"
|
|
125
128
|
end
|
|
126
129
|
|
|
127
130
|
# Supported Method Parameters::
|
|
@@ -204,9 +207,10 @@ module PWN
|
|
|
204
207
|
return unless session_id
|
|
205
208
|
return unless auto_reflect_enabled?
|
|
206
209
|
|
|
210
|
+
ok = infer_success(session_id: session_id, final: opts[:final])
|
|
207
211
|
note_outcome(
|
|
208
212
|
task: opts[:request].to_s[0, 120],
|
|
209
|
-
success:
|
|
213
|
+
success: ok,
|
|
210
214
|
details: opts[:final].to_s[0, 300],
|
|
211
215
|
session_id: session_id,
|
|
212
216
|
tags: %w[auto loop]
|
|
@@ -218,6 +222,37 @@ module PWN
|
|
|
218
222
|
nil
|
|
219
223
|
end
|
|
220
224
|
|
|
225
|
+
# Supported Method Parameters::
|
|
226
|
+
# PWN::AI::Agent::Learning.flip_last_outcome(
|
|
227
|
+
# session_id: 'optional - only flip if the newest outcome belongs to this session',
|
|
228
|
+
# reason: 'optional - why it is being flipped (usually the user correction text)'
|
|
229
|
+
# )
|
|
230
|
+
#
|
|
231
|
+
# Rewrites the most-recently-appended learning.jsonl entry from
|
|
232
|
+
# success:true to success:false. Called by Mistakes.check_user_correction
|
|
233
|
+
# when the user's next message rejects the previous answer, so the
|
|
234
|
+
# 100 %-success illusion is broken and the failure enters the corpus.
|
|
235
|
+
|
|
236
|
+
public_class_method def self.flip_last_outcome(opts = {})
|
|
237
|
+
return { flipped: false } unless File.exist?(LEARNING_FILE)
|
|
238
|
+
|
|
239
|
+
lines = File.readlines(LEARNING_FILE)
|
|
240
|
+
return { flipped: false } if lines.empty?
|
|
241
|
+
|
|
242
|
+
last = JSON.parse(lines.last, symbolize_names: true)
|
|
243
|
+
return { flipped: false } if opts[:session_id] && last[:session_id] && last[:session_id] != opts[:session_id]
|
|
244
|
+
return { flipped: false } unless last[:success]
|
|
245
|
+
|
|
246
|
+
last[:success] = false
|
|
247
|
+
last[:flipped_by] = 'user_correction'
|
|
248
|
+
last[:details] = "#{last[:details]} | CORRECTED: #{opts[:reason].to_s[0, 200]}".strip
|
|
249
|
+
lines[-1] = "#{JSON.generate(last)}\n"
|
|
250
|
+
File.write(LEARNING_FILE, lines.join)
|
|
251
|
+
{ flipped: true, id: last[:id] }
|
|
252
|
+
rescue StandardError
|
|
253
|
+
{ flipped: false }
|
|
254
|
+
end
|
|
255
|
+
|
|
221
256
|
# Supported Method Parameters::
|
|
222
257
|
# removed = PWN::AI::Agent::Learning.consolidate(
|
|
223
258
|
# max_entries: 'optional - hard cap on PWN::Memory size (default MAX_MEMORY_ENTRIES)'
|
|
@@ -274,6 +309,31 @@ module PWN
|
|
|
274
309
|
false
|
|
275
310
|
end
|
|
276
311
|
|
|
312
|
+
FAILURE_FINAL_RX = /\[pwn-ai\] (iteration budget exhausted|engine returned no message)|\b(i (was )?unable to|i could not|i couldn'?t|cannot proceed|failed to)\b/i
|
|
313
|
+
|
|
314
|
+
# Derive a success signal stronger than "final answer non-empty":
|
|
315
|
+
# look at the tool-failure ratio inside the just-completed turn AND
|
|
316
|
+
# scan the final text for self-reported failure language. Without
|
|
317
|
+
# this, auto_reflect logs ~100 % success and the negative-feedback
|
|
318
|
+
# side of the learning loop never fires.
|
|
319
|
+
private_class_method def self.infer_success(opts = {})
|
|
320
|
+
final = opts[:final].to_s
|
|
321
|
+
return false if final.strip.empty?
|
|
322
|
+
return false if final.match?(FAILURE_FINAL_RX)
|
|
323
|
+
|
|
324
|
+
sid = opts[:session_id]
|
|
325
|
+
return true unless sid && defined?(PWN::Sessions)
|
|
326
|
+
|
|
327
|
+
entries = PWN::Sessions.load(session_id: sid).last(200)
|
|
328
|
+
tool = entries.select { |e| e[:role].to_s == 'tool' }
|
|
329
|
+
return true if tool.empty?
|
|
330
|
+
|
|
331
|
+
bad = tool.count { |e| e[:content].to_s.include?('"success":false') || e[:content].to_s.match?(/"exit":[1-9]/) }
|
|
332
|
+
(bad.to_f / tool.length) < 0.5
|
|
333
|
+
rescue StandardError
|
|
334
|
+
!final.strip.empty?
|
|
335
|
+
end
|
|
336
|
+
|
|
277
337
|
private_class_method def self.skills_dir
|
|
278
338
|
if defined?(PWN::Config) && PWN::Config.respond_to?(:pwn_skills_path)
|
|
279
339
|
PWN::Config.pwn_skills_path
|
data/lib/pwn/ai/agent/loop.rb
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'json'
|
|
4
|
+
require 'digest'
|
|
5
|
+
require 'pwn/ai/agent/mistakes'
|
|
4
6
|
|
|
5
7
|
module PWN
|
|
6
8
|
module AI
|
|
@@ -14,6 +16,22 @@ module PWN
|
|
|
14
16
|
# native function-calling. State (memory, skills, sessions) is all
|
|
15
17
|
# externalised — Loop.run is stateless aside from the messages array it
|
|
16
18
|
# builds.
|
|
19
|
+
#
|
|
20
|
+
# NEGATIVE-FEEDBACK CLOSURE
|
|
21
|
+
# -------------------------
|
|
22
|
+
# Loop.run is where "learn from mistakes, don't repeat them" is
|
|
23
|
+
# actually enforced. On EVERY failed dispatch it:
|
|
24
|
+
# 1. Records the (tool, normalised_error) fingerprint into
|
|
25
|
+
# PWN::AI::Agent::Mistakes with a PERSISTENT cross-session count.
|
|
26
|
+
# 2. Reads that count back and, if it OR the in-turn count reaches
|
|
27
|
+
# REPEAT_THRESHOLD, prepends a hard "REPEATED FAILURE — change
|
|
28
|
+
# approach" guard to the tool result the model sees next.
|
|
29
|
+
# 3. Appends Mistakes.correction_hint (seen N×, sig, KNOWN FIX: …)
|
|
30
|
+
# so a previously-discovered fix is handed straight back to the
|
|
31
|
+
# model on the FIRST recurrence in a new session — it does not
|
|
32
|
+
# have to fail 3× again to re-learn what it already knew.
|
|
33
|
+
# PromptBuilder.mistakes_block re-injects the top open mistakes and
|
|
34
|
+
# top known fixes into the system prompt of every future turn.
|
|
17
35
|
module Loop
|
|
18
36
|
DEFAULT_MAX_ITERS = 777
|
|
19
37
|
|
|
@@ -49,16 +67,26 @@ module PWN
|
|
|
49
67
|
DEFAULT_MAX_ITERS
|
|
50
68
|
end
|
|
51
69
|
|
|
70
|
+
# Record per-tool telemetry AND, when the dispatch failed, fingerprint
|
|
71
|
+
# the failure into PWN::AI::Agent::Mistakes so recurring errors are
|
|
72
|
+
# counted, surfaced in the system prompt, and can be resolved with an
|
|
73
|
+
# explicit fix. Returns { ok:, err:, mistake: } — :mistake carries the
|
|
74
|
+
# PERSISTED entry (with cumulative :count and any prior :fix) so the
|
|
75
|
+
# caller drives cross-session repeat detection, not just per-turn.
|
|
52
76
|
private_class_method def self.record_metrics(opts = {})
|
|
53
77
|
name = opts[:name]
|
|
54
78
|
started = opts[:started]
|
|
55
79
|
raw = opts[:raw].to_s
|
|
56
|
-
ok = raw.include?('"success":true')
|
|
80
|
+
ok = raw.include?('"success":true') && !raw.match?(/"exit":[1-9]/)
|
|
57
81
|
err = raw[/"error":"([^"]{1,300})"/, 1]
|
|
82
|
+
err ||= raw[/"stderr":"([^"]{4,300})"/, 1] unless ok
|
|
58
83
|
dur = started ? (Time.now - started) : 0.0
|
|
59
84
|
Metrics.record(name: name, success: ok, duration: dur, error: err) if defined?(Metrics)
|
|
85
|
+
m = nil
|
|
86
|
+
m = Mistakes.record(tool: name, error: err || raw[0, 300], args: opts[:args], session_id: opts[:session_id], source: :tool) if !ok && defined?(Mistakes)
|
|
87
|
+
{ ok: ok, err: err, mistake: m }
|
|
60
88
|
rescue StandardError
|
|
61
|
-
nil
|
|
89
|
+
{ ok: true, err: nil, mistake: nil }
|
|
62
90
|
end
|
|
63
91
|
|
|
64
92
|
# Stash the active session_id under PWN::Env[:ai][:session_id] so
|
|
@@ -88,6 +116,26 @@ module PWN
|
|
|
88
116
|
nil
|
|
89
117
|
end
|
|
90
118
|
|
|
119
|
+
# Repeat circuit-breaker. `count` is max(per-turn, persistent) so a
|
|
120
|
+
# signature that already failed in a PREVIOUS session trips the guard
|
|
121
|
+
# on its FIRST recurrence here — the agent does not get to burn the
|
|
122
|
+
# iteration budget re-learning a lesson it already recorded.
|
|
123
|
+
private_class_method def self.guard_repeated_failure(opts = {})
|
|
124
|
+
count = opts[:count].to_i
|
|
125
|
+
result = opts[:result].to_s
|
|
126
|
+
hint = opts[:hint].to_s
|
|
127
|
+
thresh = defined?(Mistakes) ? Mistakes::REPEAT_THRESHOLD : 3
|
|
128
|
+
result = "#{result}\n#{hint}" unless hint.empty?
|
|
129
|
+
return result if count < thresh
|
|
130
|
+
|
|
131
|
+
guard = "[pwn-ai/mistakes] REPEATED FAILURE — this #{opts[:name]} failure signature has " \
|
|
132
|
+
"occurred #{count}× (across sessions). DO NOT retry it verbatim; change " \
|
|
133
|
+
'arguments, pick a different tool, apply the KNOWN FIX below if present, or ' \
|
|
134
|
+
'explain why it cannot succeed. Once a working alternative is found, call ' \
|
|
135
|
+
'mistakes_resolve(signature:, fix:) so future runs skip straight to it.'
|
|
136
|
+
"#{guard}\n#{result}"
|
|
137
|
+
end
|
|
138
|
+
|
|
91
139
|
# Supported Method Parameters::
|
|
92
140
|
# msg = PWN::AI::Agent::Loop.normalize_llm(
|
|
93
141
|
# response: 'required - chat_with_tools response Hash from any provider'
|
|
@@ -172,6 +220,7 @@ module PWN
|
|
|
172
220
|
|
|
173
221
|
Registry.discover
|
|
174
222
|
expose_current_session(session_id: session_id)
|
|
223
|
+
Mistakes.check_user_correction(request: request, session_id: session_id) if defined?(Mistakes)
|
|
175
224
|
|
|
176
225
|
tools = Registry.definitions(enabled: opts[:enabled_toolsets])
|
|
177
226
|
messages = [
|
|
@@ -180,6 +229,8 @@ module PWN
|
|
|
180
229
|
]
|
|
181
230
|
append_session(session_id: session_id, role: 'user', content: request)
|
|
182
231
|
|
|
232
|
+
turn_fails = Hash.new(0)
|
|
233
|
+
|
|
183
234
|
max_iters.times do |i|
|
|
184
235
|
msg = call_engine(messages: messages, tools: tools)
|
|
185
236
|
return '[pwn-ai] engine returned no message' if msg.nil?
|
|
@@ -196,13 +247,27 @@ module PWN
|
|
|
196
247
|
|
|
197
248
|
calls.each do |tc|
|
|
198
249
|
name = tc.dig(:function, :name).to_s
|
|
250
|
+
args = tc.dig(:function, :arguments)
|
|
199
251
|
entry = Registry.lookup(name: name)
|
|
200
252
|
started = Time.now
|
|
201
253
|
raw = Dispatch.call(tool_call: tc)
|
|
202
|
-
record_metrics(name: name, started: started, raw: raw)
|
|
203
|
-
result
|
|
204
|
-
|
|
205
|
-
|
|
254
|
+
tele = record_metrics(name: name, started: started, raw: raw, args: args, session_id: session_id)
|
|
255
|
+
result = Result.condition(content: raw, entry: entry)
|
|
256
|
+
|
|
257
|
+
unless tele[:ok]
|
|
258
|
+
fkey = Digest::SHA256.hexdigest("#{name}|#{args}")[0, 16]
|
|
259
|
+
turn_fails[fkey] += 1
|
|
260
|
+
persist = tele.dig(:mistake, :count).to_i
|
|
261
|
+
hint = defined?(Mistakes) ? Mistakes.correction_hint(tool: name, error: tele[:err] || raw[0, 300]) : ''
|
|
262
|
+
result = guard_repeated_failure(
|
|
263
|
+
name: name,
|
|
264
|
+
count: [turn_fails[fkey], persist].max,
|
|
265
|
+
hint: hint,
|
|
266
|
+
result: result
|
|
267
|
+
)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
on_tool&.call(name, args, result)
|
|
206
271
|
|
|
207
272
|
messages << {
|
|
208
273
|
role: 'tool',
|
|
@@ -218,6 +283,7 @@ module PWN
|
|
|
218
283
|
end
|
|
219
284
|
end
|
|
220
285
|
|
|
286
|
+
Mistakes.record(tool: 'agent_loop', error: 'iteration budget exhausted without a final answer', session_id: session_id, source: :loop) if defined?(Mistakes)
|
|
221
287
|
'[pwn-ai] iteration budget exhausted'
|
|
222
288
|
end
|
|
223
289
|
|