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.
Files changed (120) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +2 -2
  3. data/README.md +101 -83
  4. data/bin/pwn_gqrx_scanner +25 -5
  5. data/documentation/AI-Integration.md +42 -26
  6. data/documentation/AWS.md +57 -0
  7. data/documentation/Agent-Tool-Registry.md +56 -0
  8. data/documentation/Banner.md +17 -0
  9. data/documentation/Blockchain.md +18 -0
  10. data/documentation/Bounty.md +21 -0
  11. data/documentation/BurpSuite.md +41 -16
  12. data/documentation/CLI-Drivers.md +58 -0
  13. data/documentation/Configuration.md +66 -0
  14. data/documentation/Contributing.md +33 -19
  15. data/documentation/Cron.md +47 -0
  16. data/documentation/Diagrams.md +6 -2
  17. data/documentation/Drivers.md +43 -16
  18. data/documentation/Extrospection.md +73 -0
  19. data/documentation/FFI.md +14 -0
  20. data/documentation/Fuzzing.md +36 -0
  21. data/documentation/Hardware.md +40 -0
  22. data/documentation/Home.md +5 -4
  23. data/documentation/How-PWN-Works.md +7 -4
  24. data/documentation/Metasploit.md +34 -0
  25. data/documentation/Mistakes.md +96 -0
  26. data/documentation/NmapIt.md +18 -12
  27. data/documentation/PWN.png +0 -0
  28. data/documentation/PWN_Contributors_and_Users.png +0 -0
  29. data/documentation/Persistence.md +40 -0
  30. data/documentation/Plugins.md +97 -47
  31. data/documentation/Reporting.md +25 -18
  32. data/documentation/SAST.md +39 -22
  33. data/documentation/SDR.md +58 -0
  34. data/documentation/Sessions.md +39 -0
  35. data/documentation/Skills-Memory-Learning.md +59 -29
  36. data/documentation/Swarm.md +71 -0
  37. data/documentation/Transparent-Browser.md +26 -22
  38. data/documentation/Troubleshooting.md +44 -25
  39. data/documentation/WWW.md +32 -0
  40. data/documentation/diagrams/agent-tool-registry.svg +286 -0
  41. data/documentation/diagrams/aws-cloud-security.svg +166 -0
  42. data/documentation/diagrams/cron-scheduling.svg +148 -0
  43. data/documentation/diagrams/dot/agent-tool-registry.dot +2 -2
  44. data/documentation/diagrams/dot/extrospection-world-awareness.dot +9 -7
  45. data/documentation/diagrams/dot/hardware-hacking.dot +48 -0
  46. data/documentation/diagrams/dot/memory-skills-detailed.dot +12 -5
  47. data/documentation/diagrams/dot/mistakes-negative-feedback.dot +93 -0
  48. data/documentation/diagrams/dot/overall-pwn-architecture.dot +7 -5
  49. data/documentation/diagrams/dot/persistence-filesystem.dot +30 -0
  50. data/documentation/diagrams/dot/pwn-ai-feedback-learning-loop.dot +19 -7
  51. data/documentation/diagrams/dot/sdr-radio-flow.dot +13 -11
  52. data/documentation/diagrams/extrospection-world-awareness.svg +203 -0
  53. data/documentation/diagrams/hardware-hacking.svg +163 -0
  54. data/documentation/diagrams/memory-skills-detailed.svg +146 -97
  55. data/documentation/diagrams/mistakes-negative-feedback.svg +266 -0
  56. data/documentation/diagrams/overall-pwn-architecture.svg +184 -173
  57. data/documentation/diagrams/persistence-filesystem.svg +189 -0
  58. data/documentation/diagrams/pwn-ai-feedback-learning-loop.svg +217 -149
  59. data/documentation/diagrams/sdr-radio-flow.svg +166 -0
  60. data/documentation/diagrams/swarm-multi-agent.svg +225 -0
  61. data/documentation/fax-spectrogram.png +0 -0
  62. data/documentation/fax-waveform.png +0 -0
  63. data/documentation/pwn-REPL.md +40 -24
  64. data/documentation/pwn-ai-Agent.md +72 -30
  65. data/documentation/pwn_android_war_dialer_session.png +0 -0
  66. data/documentation/pwn_install.png +0 -0
  67. data/documentation/pwn_wallpaper.jpg +0 -0
  68. data/documentation/ringing-spectrogram.png +0 -0
  69. data/documentation/ringing-waveform.png +0 -0
  70. data/lib/pwn/ai/agent/extrospection.rb +65 -6
  71. data/lib/pwn/ai/agent/learning.rb +67 -7
  72. data/lib/pwn/ai/agent/loop.rb +72 -6
  73. data/lib/pwn/ai/agent/mistakes.rb +369 -0
  74. data/lib/pwn/ai/agent/prompt_builder.rb +10 -1
  75. data/lib/pwn/ai/agent/tools/extrospection.rb +2 -2
  76. data/lib/pwn/ai/agent/tools/mistakes.rb +132 -0
  77. data/lib/pwn/ai/agent.rb +1 -0
  78. data/lib/pwn/plugins/jira_data_center.rb +2 -2
  79. data/lib/pwn/sdr/decoder/adsb.rb +101 -0
  80. data/lib/pwn/sdr/decoder/apt.rb +75 -0
  81. data/lib/pwn/sdr/decoder/bluetooth.rb +78 -0
  82. data/lib/pwn/sdr/decoder/dect.rb +84 -0
  83. data/lib/pwn/sdr/decoder/gps.rb +83 -0
  84. data/lib/pwn/sdr/decoder/gsm.rb +50 -29
  85. data/lib/pwn/sdr/decoder/iridium.rb +81 -0
  86. data/lib/pwn/sdr/decoder/lora.rb +87 -0
  87. data/lib/pwn/sdr/decoder/lte.rb +88 -0
  88. data/lib/pwn/sdr/decoder/morse.rb +64 -0
  89. data/lib/pwn/sdr/decoder/p25.rb +71 -0
  90. data/lib/pwn/sdr/decoder/pager.rb +77 -0
  91. data/lib/pwn/sdr/decoder/pocsag.rb +26 -11
  92. data/lib/pwn/sdr/decoder/rfid.rb +88 -0
  93. data/lib/pwn/sdr/decoder/rtl433.rb +95 -0
  94. data/lib/pwn/sdr/decoder/rtty.rb +66 -0
  95. data/lib/pwn/sdr/decoder/wifi.rb +98 -0
  96. data/lib/pwn/sdr/decoder/zigbee.rb +96 -0
  97. data/lib/pwn/sdr/decoder.rb +67 -7
  98. data/lib/pwn/sdr/frequency_allocation.rb +82 -41
  99. data/lib/pwn/sdr/gqrx.rb +186 -95
  100. data/lib/pwn/version.rb +1 -1
  101. data/spec/lib/pwn/ai/agent/mistakes_spec.rb +36 -0
  102. data/spec/lib/pwn/ai/agent/tools/mistakes_spec.rb +12 -0
  103. data/spec/lib/pwn/sdr/decoder/adsb_spec.rb +15 -0
  104. data/spec/lib/pwn/sdr/decoder/apt_spec.rb +15 -0
  105. data/spec/lib/pwn/sdr/decoder/bluetooth_spec.rb +15 -0
  106. data/spec/lib/pwn/sdr/decoder/dect_spec.rb +15 -0
  107. data/spec/lib/pwn/sdr/decoder/gps_spec.rb +15 -0
  108. data/spec/lib/pwn/sdr/decoder/iridium_spec.rb +15 -0
  109. data/spec/lib/pwn/sdr/decoder/lora_spec.rb +15 -0
  110. data/spec/lib/pwn/sdr/decoder/lte_spec.rb +15 -0
  111. data/spec/lib/pwn/sdr/decoder/morse_spec.rb +15 -0
  112. data/spec/lib/pwn/sdr/decoder/p25_spec.rb +15 -0
  113. data/spec/lib/pwn/sdr/decoder/pager_spec.rb +15 -0
  114. data/spec/lib/pwn/sdr/decoder/rfid_spec.rb +15 -0
  115. data/spec/lib/pwn/sdr/decoder/rtl433_spec.rb +15 -0
  116. data/spec/lib/pwn/sdr/decoder/rtty_spec.rb +15 -0
  117. data/spec/lib/pwn/sdr/decoder/wifi_spec.rb +15 -0
  118. data/spec/lib/pwn/sdr/decoder/zigbee_spec.rb +15 -0
  119. data/third_party/pwn_rdoc.jsonl +114 -8
  120. 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
- PROBE_BINS = %w[nmap curl git ruby python3 gcc msfconsole sqlmap burpsuite zaproxy openssl docker].freeze
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) raw drift as low-priority findings when nothing else matched
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 = outcomes(limit: limit)
116
- return '' if rows.empty?
115
+ rows = outcomes(limit: limit)
116
+ fails = outcomes(limit: 200, success: false).first(limit)
117
+ return '' if rows.empty? && fails.empty?
117
118
 
118
- lines = rows.map do |r|
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 = stats
123
+ s = stats
123
124
  hdr = "RECENT OUTCOMES (success_rate=#{(s[:success_rate] * 100).round(1)}% over #{s[:total_outcomes]} attempts)"
124
- "#{hdr}\n#{lines.join("\n")}\n\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: !opts[:final].to_s.strip.empty?,
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
@@ -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 = Result.condition(content: raw, entry: entry)
204
-
205
- on_tool&.call(name, tc.dig(:function, :arguments), result)
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