pwn 0.5.627 → 0.5.628

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9e41f7773a146dc00b0f3eb205ef5f0bd4ed7cc894e2a7b0448aa398fc3e23c0
4
- data.tar.gz: 22e69de2b918558f4702adf91b68615eabe0ddc0d5676b4fe2beb1602c935f13
3
+ metadata.gz: 44fdb9e4897d83ad9ab54d65bc774eed6be14c379eea42b1a7984abab23c03bd
4
+ data.tar.gz: ba801dabf738c43b3fe7ce9cf40bdfb38d1cf62f7a0b98b05861626c33d2d008
5
5
  SHA512:
6
- metadata.gz: 271dc24b5a41f4d16ac1a1a1f9c9d4ac6b0cfe2aa3358eb8d7e16058a9036f9e8d73c4528e8a17a8e9c6d31dc263c8a8de56047816f88caad35ba128874e8258
7
- data.tar.gz: 5cacca96d75ff106f28b5d344b98bb91eb01887332598e28106379fccfafbaf2547ce7f27d983ac8d16ad71964480e2c0fce73ffd152ef3f46af771d8306672b
6
+ metadata.gz: 205805e3088246b711249ba3b778e9418fb1616ad576dde6c22c0c632409de88b2545fda88ee30316fe7a203a2ff9a46236136e94ba291a7dc27c800d7b8c02c
7
+ data.tar.gz: 4e439c01661f7707c9bf79e8e4be656e76f9d57d5bb17cf9778536bd1a8ebbc63aebd4effbcce38d4ed89f4cfdbe188072ab710a7374d0a951fd8a0c7bd7d0fd
data/.rubocop_todo.yml CHANGED
@@ -1,6 +1,6 @@
1
1
  # This configuration was generated by
2
2
  # `rubocop --auto-gen-config`
3
- # on 2026-06-05 17:51:34 UTC using RuboCop version 1.86.1.
3
+ # on 2026-07-10 16:56:14 UTC using RuboCop version 1.88.2.
4
4
  # The point is for the user to remove these configuration records
5
5
  # one by one as the offenses are removed from the code base.
6
6
  # Note that changes in the inspected code, or installation of new
@@ -13,7 +13,7 @@ Layout/TrailingWhitespace:
13
13
  Exclude:
14
14
  - 'lib/pwn/banner/radare2_ai.rb'
15
15
 
16
- # Offense count: 11
16
+ # Offense count: 14
17
17
  # Configuration parameters: AllowedMethods, AllowedPatterns.
18
18
  Lint/NestedMethodDefinition:
19
19
  Exclude:
@@ -30,17 +30,16 @@ Lint/RedundantTypeConversion:
30
30
  - 'lib/pwn/plugins/jenkins.rb'
31
31
  - 'lib/pwn/plugins/repl.rb'
32
32
 
33
- # Offense count: 4
34
- Lint/ShadowedException:
35
- Exclude:
36
- - 'lib/pwn/sdr/decoder/flex.rb'
37
- - 'lib/pwn/sdr/decoder/pocsag.rb'
38
-
39
- # Offense count: 302
33
+ # Offense count: 296
40
34
  # This cop supports safe autocorrection (--autocorrect).
41
35
  Lint/UselessAssignment:
42
36
  Enabled: false
43
37
 
38
+ # Offense count: 2
39
+ Lint/UselessConstantScoping:
40
+ Exclude:
41
+ - 'lib/pwn/sdr/decoder/bluetooth.rb'
42
+
44
43
  # Offense count: 1
45
44
  # This cop supports safe autocorrection (--autocorrect).
46
45
  # Configuration parameters: CheckForMethodsWithNoSideEffects.
@@ -49,7 +48,7 @@ Lint/Void:
49
48
  - 'bin/pwn_web_cache_deception'
50
49
 
51
50
  # Offense count: 1
52
- # Configuration parameters: LengthThreshold.
51
+ # Configuration parameters: Max.
53
52
  Metrics/CollectionLiteralLength:
54
53
  Exclude:
55
54
  - 'lib/pwn/plugins/uri_scheme.rb'
@@ -66,7 +65,7 @@ Metrics/MethodLength:
66
65
  Exclude:
67
66
  - 'lib/pwn/banner/code_cave.rb'
68
67
 
69
- # Offense count: 8
68
+ # Offense count: 9
70
69
  Naming/AccessorMethodName:
71
70
  Exclude:
72
71
  - 'lib/pwn/ai/anthropic.rb'
@@ -78,6 +77,13 @@ Naming/AccessorMethodName:
78
77
  - 'lib/pwn/plugins/jira_data_center.rb'
79
78
  - 'lib/pwn/plugins/vin.rb'
80
79
 
80
+ # Offense count: 5
81
+ # Configuration parameters: MinNameLength, AllowNamesEndingInNumbers, AllowedNames, ForbiddenNames.
82
+ # AllowedNames: as, at, by, cc, db, id, if, in, io, ip, of, on, os, pp, to
83
+ Naming/MethodParameterName:
84
+ Exclude:
85
+ - 'lib/pwn/sdr/decoder/lora.rb'
86
+
81
87
  # Offense count: 2
82
88
  # Configuration parameters: EnforcedStyle, CheckMethodNames, CheckSymbols, AllowedIdentifiers, AllowedPatterns.
83
89
  # SupportedStyles: snake_case, normalcase, non_integer
@@ -98,7 +104,7 @@ Style/Alias:
98
104
  Style/ClassVars:
99
105
  Enabled: false
100
106
 
101
- # Offense count: 273
107
+ # Offense count: 277
102
108
  # This cop supports safe autocorrection (--autocorrect).
103
109
  # Configuration parameters: EnforcedStyle, SingleLineConditionsOnly, IncludeTernaryExpressions.
104
110
  # SupportedStyles: assign_to_condition, assign_inside_condition
@@ -150,13 +156,11 @@ Style/MultipleComparison:
150
156
  - 'lib/pwn/www/duckduckgo.rb'
151
157
  - 'lib/pwn/www/twitter.rb'
152
158
 
153
- # Offense count: 5
159
+ # Offense count: 1
154
160
  # This cop supports safe autocorrection (--autocorrect).
155
161
  Style/RedundantBegin:
156
162
  Exclude:
157
163
  - 'lib/pwn/plugins/transparent_browser.rb'
158
- - 'lib/pwn/sdr/decoder/flex.rb'
159
- - 'lib/pwn/sdr/decoder/pocsag.rb'
160
164
 
161
165
  # Offense count: 94
162
166
  # This cop supports safe autocorrection (--autocorrect).
@@ -172,13 +176,6 @@ Style/RedundantParentheses:
172
176
  Exclude:
173
177
  - 'lib/pwn/plugins/xxd.rb'
174
178
 
175
- # Offense count: 2
176
- # This cop supports safe autocorrection (--autocorrect).
177
- Style/RedundantSelf:
178
- Exclude:
179
- - 'lib/pwn/sdr/decoder/flex.rb'
180
- - 'lib/pwn/sdr/decoder/pocsag.rb'
181
-
182
179
  # Offense count: 12
183
180
  # This cop supports safe autocorrection (--autocorrect).
184
181
  Style/RedundantStringEscape:
@@ -189,14 +186,7 @@ Style/RedundantStringEscape:
189
186
  - 'lib/pwn/sast/redos.rb'
190
187
  - 'vagrant/provisioners/kali_customize.rb'
191
188
 
192
- # Offense count: 6
193
- # This cop supports safe autocorrection (--autocorrect).
194
- Style/RescueModifier:
195
- Exclude:
196
- - 'lib/pwn/sdr/decoder/flex.rb'
197
- - 'lib/pwn/sdr/decoder/pocsag.rb'
198
-
199
- # Offense count: 19
189
+ # Offense count: 17
200
190
  # This cop supports unsafe autocorrection (--autocorrect-all).
201
191
  Style/SlicingWithRange:
202
192
  Exclude:
data/Gemfile CHANGED
@@ -20,7 +20,7 @@ gem 'base32', '0.3.4'
20
20
  gem 'bitcoin-ruby', '0.0.20'
21
21
  gem 'brakeman', '8.0.5'
22
22
  gem 'bson', '5.2.0'
23
- gem 'bundler', '>=4.0.15'
23
+ gem 'bundler', '>=4.0.16'
24
24
  gem 'bundler-audit', '>=0.9.3'
25
25
  gem 'bunny', '3.1.0'
26
26
  gem 'colorize', '1.1.0'
data/README.md CHANGED
@@ -112,7 +112,7 @@ $ cd /opt/pwn
112
112
  $ ./install.sh
113
113
  $ ./install.sh ruby-gem
114
114
  $ pwn
115
- pwn[v0.5.627]:001 >>> PWN.help
115
+ pwn[v0.5.628]:001 >>> PWN.help
116
116
  ```
117
117
 
118
118
  [![Installing the pwn Security Automation Framework](https://raw.githubusercontent.com/0dayInc/pwn/master/documentation/pwn_install.png)](https://youtu.be/G7iLUY4FzsI)
@@ -136,7 +136,7 @@ $ rvm use ruby-4.0.5@pwn
136
136
  $ gem uninstall --all --executables pwn
137
137
  $ gem install --verbose pwn
138
138
  $ pwn
139
- pwn[v0.5.627]:001 >>> PWN.help
139
+ pwn[v0.5.628]:001 >>> PWN.help
140
140
  ```
141
141
 
142
142
  If using a multi-user RVM install:
@@ -8,6 +8,22 @@ module PWN
8
8
  # Tool-call dispatch: takes a single tool_call object (OpenAI shape),
9
9
  # looks up the registered handler, parses args, runs it, and returns a
10
10
  # JSON string suitable for a role:'tool' message.
11
+ #
12
+ # TOLERANT DISPATCH (local-model scaffolding)
13
+ # -------------------------------------------
14
+ # Local models (qwen/llama/mistral on Ollama) frequently emit almost-
15
+ # right tool calls: `run_shell` instead of `shell`, trailing commas,
16
+ # single-quoted JSON, arguments as a bare string. Strict parsing burns
17
+ # an iteration and often spirals. Dispatch now:
18
+ # * repair_name — Levenshtein-matches unknown names to the closest
19
+ # registered tool and records a Mistakes fingerprint
20
+ # (source: :repair) so the KNOWN MISTAKES block
21
+ # eventually teaches the model the right name.
22
+ # * parse_args — falls back to a JSON5-ish clean-up pass (strip
23
+ # trailing commas, swap single→double quotes, wrap a
24
+ # bare scalar as the tool's sole required arg).
25
+ # Frontier engines never hit these paths — repair is a no-op when the
26
+ # name/JSON are already valid.
11
27
  module Dispatch
12
28
  # Supported Method Parameters::
13
29
  # json_str = PWN::AI::Agent::Dispatch.call(
@@ -22,10 +38,10 @@ module PWN
22
38
  name = (fn[:name] || fn['name']).to_s
23
39
  raw = fn[:arguments] || fn['arguments'] || '{}'
24
40
 
25
- entry = Registry.lookup(name: name)
41
+ entry = Registry.lookup(name: name) || Registry.lookup(name: repair_name(name: name))
26
42
  return JSON.generate(error: "unknown tool: #{name}") unless entry
27
43
 
28
- args = parse_args(raw: raw)
44
+ args = parse_args(raw: raw, entry: entry)
29
45
  result = entry.handler.call(args)
30
46
  JSON.generate(success: true, result: result)
31
47
  rescue StandardError => e
@@ -36,15 +52,69 @@ module PWN
36
52
  )
37
53
  end
38
54
 
55
+ # Supported Method Parameters::
56
+ # fixed = PWN::AI::Agent::Dispatch.repair_name(
57
+ # name: 'required - possibly-wrong tool name emitted by the model'
58
+ # )
59
+ #
60
+ # Returns the closest registered tool name by Levenshtein distance
61
+ # (max distance = 1/3 of the emitted name, min 3) or nil when nothing
62
+ # is close enough. Every successful repair is fingerprinted into
63
+ # Mistakes so the negative-feedback loop trains the model's output
64
+ # format via its own system prompt.
65
+
66
+ public_class_method def self.repair_name(opts = {})
67
+ name = opts[:name].to_s
68
+ return nil if name.empty?
69
+
70
+ pool = Registry.all.map(&:name)
71
+ return nil if pool.empty?
72
+
73
+ best, dist = pool.map { |n| [n, DidYouMean::Levenshtein.distance(n, name)] }
74
+ .min_by(&:last)
75
+ thresh = [(name.length / 3.0).ceil, 3].max
76
+ return nil if dist > thresh
77
+
78
+ if defined?(Mistakes)
79
+ Mistakes.record(
80
+ tool: 'tool_name',
81
+ error: "model emitted '#{name}', repaired to '#{best}'",
82
+ args: name,
83
+ source: :repair
84
+ )
85
+ end
86
+ best
87
+ rescue StandardError
88
+ nil
89
+ end
90
+
39
91
  private_class_method def self.parse_args(opts = {})
40
- raw = opts[:raw]
92
+ raw = opts[:raw]
93
+ entry = opts[:entry]
41
94
  case raw
42
95
  when Hash then symbolize(hash: raw)
43
- when String then raw.strip.empty? ? {} : JSON.parse(raw, symbolize_names: true)
96
+ when String then parse_string_args(raw: raw, entry: entry)
44
97
  when nil then {}
45
98
  else symbolize(hash: raw.to_h)
46
99
  end
100
+ end
101
+
102
+ private_class_method def self.parse_string_args(opts = {})
103
+ raw = opts[:raw].to_s
104
+ entry = opts[:entry]
105
+ return {} if raw.strip.empty?
106
+
107
+ JSON.parse(raw, symbolize_names: true)
47
108
  rescue JSON::ParserError => e
109
+ # Tolerant retry: strip trailing commas, normalise single quotes,
110
+ # coerce a bare scalar into the tool's sole required parameter.
111
+ cleaned = raw.gsub(/,(\s*[}\]])/, '\1').tr("'", '"')
112
+ begin
113
+ return JSON.parse(cleaned, symbolize_names: true)
114
+ rescue JSON::ParserError
115
+ req = Array(entry&.schema&.dig(:parameters, :required))
116
+ return { req.first.to_sym => raw.strip.gsub(/\A["']|["']\z/, '') } if req.length == 1 && !raw.strip.start_with?('{', '[')
117
+ end
48
118
  raise ArgumentError, "invalid JSON arguments: #{e.message}"
49
119
  end
50
120
 
@@ -72,6 +142,8 @@ module PWN
72
142
  }
73
143
  )
74
144
 
145
+ PWN::AI::Agent::Dispatch.repair_name(name: 'run_shell') # => 'shell'
146
+
75
147
  #{self}.authors
76
148
  USAGE
77
149
  end
@@ -23,8 +23,10 @@ module PWN
23
23
  # Everything is file-backed under ~/.pwn so it survives across REPL
24
24
  # restarts and is shared by every future session.
25
25
  module Learning
26
- LEARNING_FILE = File.join(Dir.home, '.pwn', 'learning.jsonl')
26
+ LEARNING_FILE = File.join(Dir.home, '.pwn', 'learning.jsonl')
27
+ FINETUNE_DIR = File.join(Dir.home, '.pwn', 'finetune')
27
28
  MAX_MEMORY_ENTRIES = 200
29
+ CLAIM_RX = /CVE-\d{4}-\d{4,7}|\b[A-Za-z][\w.+-]{2,}\s+v?\d+\.\d+(?:\.\d+)?\b/
28
30
 
29
31
  # Supported Method Parameters::
30
32
  # entry = PWN::AI::Agent::Learning.note_outcome(
@@ -127,6 +129,82 @@ module PWN
127
129
  "#{out}\n"
128
130
  end
129
131
 
132
+ # Supported Method Parameters::
133
+ # msgs = PWN::AI::Agent::Learning.exemplars_for(
134
+ # request: 'required - current user request',
135
+ # limit: 'optional - max exemplar traces to return (default 1)',
136
+ # max_msgs: 'optional - cap on messages per exemplar (default 6)'
137
+ # )
138
+ #
139
+ # Retrieval-augmented BEHAVIOUR: keyword-matches request against
140
+ # prior successful outcomes in learning.jsonl, loads the matching
141
+ # session, and compresses its (user, tool, assistant) trace into a
142
+ # short few-shot exemplar Loop.run splices between system and user.
143
+ # Local models are dramatically better with 1 concrete example than
144
+ # with 25 abstract lessons.
145
+
146
+ public_class_method def self.exemplars_for(opts = {})
147
+ request = opts[:request].to_s.downcase
148
+ limit = (opts[:limit] || 1).to_i
149
+ max_msgs = (opts[:max_msgs] || 6).to_i
150
+ return [] if request.strip.empty?
151
+
152
+ tokens = request.scan(/[a-z0-9_]{3,}/).uniq
153
+ return [] if tokens.empty?
154
+
155
+ hits = outcomes(limit: 500, success: true)
156
+ .reject { |r| r[:session_id].to_s.empty? }
157
+ .map { |r| [r, tokens.count { |t| r[:task].to_s.downcase.include?(t) }] }
158
+ .reject { |_, s| s.zero? }
159
+ .sort_by { |_, s| -s }
160
+ .first(limit)
161
+ .map(&:first)
162
+
163
+ hits.flat_map { |r| compress_exemplar(session_id: r[:session_id], max_msgs: max_msgs) }
164
+ rescue StandardError
165
+ []
166
+ end
167
+
168
+ # Supported Method Parameters::
169
+ # info = PWN::AI::Agent::Learning.export_finetune(
170
+ # format: 'optional - :sharegpt (default) | :openai_jsonl',
171
+ # out: 'optional - output path (default ~/.pwn/finetune/pwn-YYYYMMDD.jsonl)',
172
+ # min_tools: 'optional - only sessions with >= N tool messages (default 1)'
173
+ # )
174
+ #
175
+ # Turns the learning corpus into a supervised dataset: every session
176
+ # whose learning.jsonl outcome is success:true becomes one training
177
+ # sample (system, user, assistant/tool_calls, tool, ..., final). Pair
178
+ # with a weekly PWN::Cron job that runs `ollama create qwen-pwn -f
179
+ # Modelfile` over the export - the only path to ACTUAL parity with a
180
+ # frontier model, because it changes the weights not just the scaffold.
181
+
182
+ public_class_method def self.export_finetune(opts = {})
183
+ fmt = (opts[:format] || :sharegpt).to_sym
184
+ min_tools = (opts[:min_tools] || 1).to_i
185
+ FileUtils.mkdir_p(FINETUNE_DIR)
186
+ out = opts[:out] || File.join(FINETUNE_DIR, "pwn-#{Time.now.utc.strftime('%Y%m%d')}.jsonl")
187
+
188
+ sids = outcomes(limit: 10_000, success: true).map { |r| r[:session_id] }.compact.uniq
189
+ rows = 0
190
+ File.open(out, 'w') do |f|
191
+ sids.each do |sid|
192
+ t = PWN::Sessions.load(session_id: sid)
193
+ next if t.count { |e| e[:role].to_s == 'tool' } < min_tools
194
+
195
+ conv = t.map { |e| { role: e[:role].to_s, content: e[:content].to_s } }
196
+ .reject { |e| e[:role] == 'system' && e[:content].start_with?('Session started') }
197
+ line = case fmt
198
+ when :openai_jsonl then { messages: conv }
199
+ else { conversations: conv.map { |m| { from: sharegpt_role(role: m[:role]), value: m[:content] } } }
200
+ end
201
+ f.puts(JSON.generate(line))
202
+ rows += 1
203
+ end
204
+ end
205
+ { path: out, format: fmt, sessions: sids.length, samples: rows, bytes: File.size(out) }
206
+ end
207
+
130
208
  # Supported Method Parameters::
131
209
  # skill = PWN::AI::Agent::Learning.distill_skill(
132
210
  # name: 'required - snake_case name for the new skill',
@@ -216,6 +294,7 @@ module PWN
216
294
  tags: %w[auto loop]
217
295
  )
218
296
  reflect(session_id: session_id)
297
+ fact_check_local_final(final: opts[:final])
219
298
  Extrospection.auto_extrospect(session_id: session_id) if defined?(Extrospection)
220
299
  rescue StandardError => e
221
300
  warn "[pwn-ai/learning] auto_introspect swallowed: #{e.class}: #{e.message}"
@@ -382,6 +461,48 @@ module PWN
382
461
  lessons.uniq.first(5)
383
462
  end
384
463
 
464
+ # Auto fact-check post-filter: local models hallucinate CVEs /
465
+ # versions ~5-10x more than frontier ones. When the active engine is
466
+ # :ollama, scan the final for CVE / version-shaped claims and hand
467
+ # each to Extrospection.verify - refuted claims become
468
+ # Mistakes(tool:'assumption') so KNOWN MISTAKES warns every future
469
+ # run off that specific hallucination.
470
+ private_class_method def self.fact_check_local_final(opts = {})
471
+ return unless defined?(PWN::Env) && PWN::Env.dig(:ai, :active).to_s.downcase.to_sym == :ollama
472
+ return unless defined?(Extrospection) && Extrospection.respond_to?(:verify)
473
+
474
+ claims = opts[:final].to_s.scan(CLAIM_RX).flatten.compact.uniq.first(3)
475
+ claims.each { |c| Extrospection.verify(claim: c, commit: true) }
476
+ rescue StandardError => e
477
+ warn "[pwn-ai/learning] fact_check swallowed: #{e.class}: #{e.message}"
478
+ end
479
+
480
+ private_class_method def self.compress_exemplar(opts = {})
481
+ sid = opts[:session_id]
482
+ cap = opts[:max_msgs] || 6
483
+ t = PWN::Sessions.load(session_id: sid)
484
+ user = t.find { |e| e[:role].to_s == 'user' }
485
+ fin = t.rfind { |e| e[:role].to_s == 'assistant' }
486
+ return [] unless user && fin
487
+
488
+ tools = t.select { |e| e[:role].to_s == 'tool' }.first([cap - 2, 0].max)
489
+ msgs = [{ role: 'user', content: "[exemplar] #{user[:content].to_s[0, 400]}" }]
490
+ tools.each { |e| msgs << { role: 'assistant', content: "[exemplar tool] #{e[:content].to_s[0, 300]}" } }
491
+ msgs << { role: 'assistant', content: "[exemplar final] #{fin[:content].to_s[0, 400]}" }
492
+ msgs
493
+ rescue StandardError
494
+ []
495
+ end
496
+
497
+ private_class_method def self.sharegpt_role(opts = {})
498
+ case opts[:role].to_s
499
+ when 'user' then 'human'
500
+ when 'assistant' then 'gpt'
501
+ when 'tool' then 'observation'
502
+ else 'system'
503
+ end
504
+ end
505
+
385
506
  private_class_method def self.build_skill_from_session(opts = {})
386
507
  session_id = opts[:session_id]
387
508
  name = opts[:name]
@@ -422,6 +543,8 @@ module PWN
422
543
  PWN::AI::Agent::Learning.reflect(session_id: sid) # LLM or heuristic → PWN::Memory
423
544
  PWN::AI::Agent::Learning.auto_introspect(session_id: sid, request: req, final: text)
424
545
  PWN::AI::Agent::Learning.distill_skill(name: 'quick_recon', session_id: sid)
546
+ PWN::AI::Agent::Learning.exemplars_for(request: 'nmap sweep 10/8') # few-shot for Loop.run
547
+ PWN::AI::Agent::Learning.export_finetune(format: :sharegpt) # -> ~/.pwn/finetune/*.jsonl
425
548
  PWN::AI::Agent::Learning.consolidate(max_entries: 200) # dedupe + prune Memory
426
549
  PWN::AI::Agent::Learning.to_context(limit: 5) # injected by PromptBuilder
427
550
  PWN::AI::Agent::Learning.stats
@@ -32,8 +32,25 @@ module PWN
32
32
  # have to fail 3× again to re-learn what it already knew.
33
33
  # PromptBuilder.mistakes_block re-injects the top open mistakes and
34
34
  # top known fixes into the system prompt of every future turn.
35
+ #
36
+ # LOCAL-MODEL SCAFFOLDING
37
+ # -----------------------
38
+ # When the active engine is :ollama (or the corresponding :agent flags
39
+ # are set) Loop.run additionally:
40
+ # * threads request → PromptBuilder for relevance-ranked MEMORY,
41
+ # * threads request → Registry.definitions(relevance:) for a slimmed
42
+ # tool set (:tool_router),
43
+ # * splices Learning.exemplars_for(request:) between system and user
44
+ # as few-shot behaviour retrieval,
45
+ # * runs a plan-then-act pre-pass (:plan_first) so the model
46
+ # externalises a tool plan before its first dispatch,
47
+ # * escalates to a frontier persona for a 3-line corrective hint
48
+ # once ≥ ESCALATE_AFTER_FAILS in-turn failures accumulate
49
+ # (:escalation_persona) — the local model still produces the final
50
+ # answer so Learning/Metrics stay attributed to :ollama.
35
51
  module Loop
36
- DEFAULT_MAX_ITERS = 777
52
+ DEFAULT_MAX_ITERS = 777
53
+ ESCALATE_AFTER_FAILS = 4
37
54
 
38
55
  ENGINE_MODS = {
39
56
  openai: 'PWN::AI::OpenAI',
@@ -67,6 +84,21 @@ module PWN
67
84
  DEFAULT_MAX_ITERS
68
85
  end
69
86
 
87
+ private_class_method def self.active_engine
88
+ e = (PWN::Env.dig(:ai, :active) if defined?(PWN::Env)).to_s.downcase.to_sym
89
+ e == :'' ? :openai : e
90
+ rescue StandardError
91
+ :openai
92
+ end
93
+
94
+ private_class_method def self.agent_flag(opts = {})
95
+ key = opts[:key]
96
+ v = (PWN::Env.dig(:ai, :agent, key) if defined?(PWN::Env))
97
+ v.nil? ? opts[:default] : v
98
+ rescue StandardError
99
+ opts[:default]
100
+ end
101
+
70
102
  # Record per-tool telemetry AND, when the dispatch failed, fingerprint
71
103
  # the failure into PWN::AI::Agent::Mistakes so recurring errors are
72
104
  # counted, surfaced in the system prompt, and can be resolved with an
@@ -81,7 +113,7 @@ module PWN
81
113
  err = raw[/"error":"([^"]{1,300})"/, 1]
82
114
  err ||= raw[/"stderr":"([^"]{4,300})"/, 1] unless ok
83
115
  dur = started ? (Time.now - started) : 0.0
84
- Metrics.record(name: name, success: ok, duration: dur, error: err) if defined?(Metrics)
116
+ Metrics.record(name: name, success: ok, duration: dur, error: err, engine: opts[:engine]) if defined?(Metrics)
85
117
  m = nil
86
118
  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
119
  { ok: ok, err: err, mistake: m }
@@ -136,6 +168,50 @@ module PWN
136
168
  "#{guard}\n#{result}"
137
169
  end
138
170
 
171
+ # Plan-then-act pre-pass: force the (usually local) model to
172
+ # externalise a numbered tool plan BEFORE it may call any tool. The
173
+ # plan rides along as an assistant message so every subsequent
174
+ # iteration attends over it — cheap chain-of-thought scaffolding
175
+ # without leaking to the user.
176
+ private_class_method def self.plan_first(opts = {})
177
+ messages = opts[:messages]
178
+ plan_msg = call_engine(
179
+ messages: messages + [{ role: 'user',
180
+ content: 'Before acting, list the exact tool calls (name + key args) you will make, in order. Reply ONLY with the numbered plan — do NOT call any tool yet.' }],
181
+ tools: nil
182
+ )
183
+ return unless plan_msg && !plan_msg[:content].to_s.strip.empty?
184
+
185
+ messages << { role: 'assistant', content: "PLAN:\n#{plan_msg[:content].to_s.strip}" }
186
+ rescue StandardError => e
187
+ warn "[pwn-ai/loop] plan_first swallowed: #{e.class}: #{e.message}"
188
+ end
189
+
190
+ # Swarm escalation circuit-breaker: when a local model has burned
191
+ # ≥ ESCALATE_AFTER_FAILS distinct failures this turn, ask a frontier
192
+ # persona for a 3-line corrective hint and inject it as a synthetic
193
+ # tool result. Every escalation is recorded as a Mistake so
194
+ # export_finetune can later teach the LoRA to NOT need it.
195
+ private_class_method def self.escalate(opts = {})
196
+ request = opts[:request]
197
+ turn_fails = opts[:turn_fails]
198
+ persona = agent_flag(key: :escalation_persona)
199
+ return nil unless persona && defined?(Swarm) && Swarm.personas.key?(persona.to_sym)
200
+
201
+ summary = turn_fails.map { |k, v| "#{k}: #{v}×" }.join(', ')
202
+ hint = Swarm.ask(
203
+ name: persona.to_s,
204
+ request: "Local agent is stuck on: #{request}\nFailed attempts: #{summary}\n" \
205
+ 'Give a 3-line corrective hint (which tool, which args, why). Reply with the hint ONLY.'
206
+ )
207
+ reply = hint.is_a?(Hash) ? hint[:reply].to_s : hint.to_s
208
+ Mistakes.record(tool: 'escalation', error: "local stuck after #{turn_fails.values.sum} fails; frontier hint requested", session_id: opts[:session_id], source: :loop) if defined?(Mistakes)
209
+ reply.strip.empty? ? nil : "[pwn-ai/escalation] frontier hint (#{persona}):\n#{reply.strip}"
210
+ rescue StandardError => e
211
+ warn "[pwn-ai/loop] escalate swallowed: #{e.class}: #{e.message}"
212
+ nil
213
+ end
214
+
139
215
  # Supported Method Parameters::
140
216
  # msg = PWN::AI::Agent::Loop.normalize_llm(
141
217
  # response: 'required - chat_with_tools response Hash from any provider'
@@ -184,9 +260,7 @@ module PWN
184
260
  messages = opts[:messages]
185
261
  tools = opts[:tools]
186
262
 
187
- engine = (PWN::Env.dig(:ai, :active) if defined?(PWN::Env)).to_s.downcase.to_sym
188
- engine = :openai if engine == :''
189
-
263
+ engine = active_engine
190
264
  mod_name = ENGINE_MODS[engine]
191
265
  raise "ERROR: Unsupported AI engine for agent loop: #{engine}" unless mod_name
192
266
 
@@ -216,20 +290,24 @@ module PWN
216
290
  request = opts[:request].to_s
217
291
  session_id = opts[:session_id]
218
292
  on_tool = opts[:on_tool]
219
- system_role_content = opts[:system_role_content] ||= PWN::AI::Agent::PromptBuilder.build(session_id: session_id)
293
+ engine = active_engine
294
+ local = engine == :ollama
295
+ system_role_content = opts[:system_role_content] ||= PWN::AI::Agent::PromptBuilder.build(session_id: session_id, request: request)
220
296
 
221
297
  Registry.discover
222
298
  expose_current_session(session_id: session_id)
223
299
  Mistakes.check_user_correction(request: request, session_id: session_id) if defined?(Mistakes)
224
300
 
225
- tools = Registry.definitions(enabled: opts[:enabled_toolsets])
226
- messages = [
227
- { role: 'system', content: system_role_content },
228
- { role: 'user', content: request }
229
- ]
301
+ tools = Registry.definitions(enabled: opts[:enabled_toolsets], relevance: request)
302
+ messages = [{ role: 'system', content: system_role_content }]
303
+ messages.concat(Learning.exemplars_for(request: request)) if local && defined?(Learning) && Learning.respond_to?(:exemplars_for)
304
+ messages << { role: 'user', content: request }
230
305
  append_session(session_id: session_id, role: 'user', content: request)
231
306
 
307
+ plan_first(messages: messages) if agent_flag(key: :plan_first, default: local) && !Array(tools).empty?
308
+
232
309
  turn_fails = Hash.new(0)
310
+ escalated = false
233
311
 
234
312
  max_iters.times do |i|
235
313
  msg = call_engine(messages: messages, tools: tools)
@@ -251,7 +329,7 @@ module PWN
251
329
  entry = Registry.lookup(name: name)
252
330
  started = Time.now
253
331
  raw = Dispatch.call(tool_call: tc)
254
- tele = record_metrics(name: name, started: started, raw: raw, args: args, session_id: session_id)
332
+ tele = record_metrics(name: name, started: started, raw: raw, args: args, session_id: session_id, engine: engine)
255
333
  result = Result.condition(content: raw, entry: entry)
256
334
 
257
335
  unless tele[:ok]
@@ -281,6 +359,15 @@ module PWN
281
359
  content: "#{name} → #{result[0, 1_024]}"
282
360
  )
283
361
  end
362
+
363
+ next unless local && !escalated && turn_fails.values.sum >= ESCALATE_AFTER_FAILS
364
+
365
+ hint = escalate(request: request, turn_fails: turn_fails, session_id: session_id)
366
+ if hint
367
+ messages << { role: 'tool', tool_call_id: "escalation_#{i}", name: 'frontier_hint', content: hint }
368
+ append_session(session_id: session_id, role: 'tool', content: "frontier_hint → #{hint[0, 1_024]}")
369
+ end
370
+ escalated = true
284
371
  end
285
372
 
286
373
  Mistakes.record(tool: 'agent_loop', error: 'iteration budget exhausted without a final answer', session_id: session_id, source: :loop) if defined?(Mistakes)
@@ -309,6 +396,11 @@ module PWN
309
396
  Supported engines: #{ENGINE_MODS.keys.join(', ')}
310
397
  Set PWN::Env[:ai][:active] to choose; PWN::Env[:ai][:agent][:max_iters] to bound.
311
398
 
399
+ Local-model scaffolding (PWN::Env[:ai][:agent]):
400
+ :plan_first - Boolean, plan-then-act pre-pass (default: engine == :ollama)
401
+ :tool_router - Boolean, slim Registry.definitions to CORE + top-K relevant
402
+ :escalation_persona - Swarm persona name for frontier corrective hints when stuck
403
+
312
404
  #{self}.authors
313
405
  USAGE
314
406
  end