pwn 0.5.749 → 0.5.751

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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +2 -2
  3. data/documentation/Blockchain.md +107 -9
  4. data/documentation/pwn-ai-Agent.md +310 -4
  5. data/documentation/pwn_silent_help_learn_demo.gif +0 -0
  6. data/etc/default_skills/pwn/banner/SKILL.md +7 -3
  7. data/etc/default_skills/pwn/blockchain/btc/SKILL.md +8 -5
  8. data/etc/default_skills/pwn/blockchain/eth/SKILL.md +12 -3
  9. data/etc/default_skills/pwn/blockchain/eth/references/urls.md +1 -1
  10. data/etc/default_skills/pwn/plugins/repl/SKILL.md +5 -0
  11. data/etc/default_skills/pwn/plugins/repl/ai/SKILL.md +4 -0
  12. data/etc/default_skills/pwn/plugins/repl/ai/references/urls.md +5 -0
  13. data/etc/default_skills/pwn/plugins/repl/ai_console/SKILL.md +48 -0
  14. data/etc/default_skills/pwn/plugins/repl/ai_console_commands/SKILL.md +47 -0
  15. data/etc/default_skills/pwn/plugins/repl/ai_console_usage/SKILL.md +48 -0
  16. data/etc/default_skills/pwn/plugins/repl/ai_swarm/SKILL.md +46 -0
  17. data/lib/pwn/ai/agent/loop.rb +287 -18
  18. data/lib/pwn/ai/agent/swarm.rb +28 -4
  19. data/lib/pwn/ai/open_ai.rb +4 -2
  20. data/lib/pwn/banner.rb +743 -2
  21. data/lib/pwn/blockchain/btc.rb +494 -196
  22. data/lib/pwn/blockchain/eth.rb +440 -81
  23. data/lib/pwn/blockchain.rb +6 -0
  24. data/lib/pwn/config.rb +22 -0
  25. data/lib/pwn/migrate.rb +9 -1
  26. data/lib/pwn/plugins/repl/ai.rb +182 -17
  27. data/lib/pwn/plugins/repl/ai_console.rb +1581 -0
  28. data/lib/pwn/plugins/repl/ai_console_commands.rb +282 -0
  29. data/lib/pwn/plugins/repl/ai_console_usage.rb +144 -0
  30. data/lib/pwn/plugins/repl/ai_swarm.rb +375 -0
  31. data/lib/pwn/plugins/repl.rb +25 -3
  32. data/lib/pwn/version.rb +1 -1
  33. data/spec/lib/pwn/ai/agent/loop_spec.rb +258 -1
  34. data/spec/lib/pwn/ai/agent/turn_finalizer_spec.rb +3 -2
  35. data/spec/lib/pwn/ai/open_ai_spec.rb +5 -0
  36. data/spec/lib/pwn/banner_spec.rb +643 -0
  37. data/spec/lib/pwn/blockchain/btc_spec.rb +372 -1
  38. data/spec/lib/pwn/blockchain/eth_spec.rb +205 -0
  39. data/spec/lib/pwn/blockchain_spec.rb +6 -0
  40. data/spec/lib/pwn/config_spec.rb +10 -0
  41. data/spec/lib/pwn/migrate_spec.rb +40 -3
  42. data/spec/lib/pwn/plugins/repl/ai_console_commands_spec.rb +65 -0
  43. data/spec/lib/pwn/plugins/repl/ai_console_spec.rb +1434 -0
  44. data/spec/lib/pwn/plugins/repl/ai_console_usage_spec.rb +29 -0
  45. data/spec/lib/pwn/plugins/repl/ai_swarm_spec.rb +127 -0
  46. data/spec/lib/pwn/plugins/repl_pwn_vault_spec.rb +5 -3
  47. data/spec/lib/pwn/plugins/repl_spec.rb +167 -1
  48. data/third_party/pwn_rdoc.jsonl +229 -10
  49. metadata +18 -6
  50. data/etc/default_skills/pwn/blockchain/btc/references/urls.md +0 -3
@@ -0,0 +1,48 @@
1
+ ---
2
+ name: pwn-plugins-repl-aiconsole
3
+ description: Drive PWN::Plugins::REPL::AIConsole from pwn_eval.
4
+ license: MIT
5
+ allowed-tools: [pwn, pwn_eval]
6
+ metadata:
7
+ bundled: true
8
+ generated: true
9
+ module: PWN::Plugins::REPL::AIConsole
10
+ source: pwn/plugins/repl/ai_console.rb
11
+ ---
12
+
13
+ # PWN::Plugins::REPL::AIConsole
14
+
15
+ Single-owner fullscreen agent console. Workers never paint the terminal.
16
+
17
+ ## When to use
18
+
19
+ Call `PWN::Plugins::REPL::AIConsole` from `pwn_eval` when the task needs this module.
20
+ Do not reimplement it in shell.
21
+
22
+ ## Methodologies
23
+
24
+ Generated from `pwn/plugins/repl/ai_console.rb`. Prefer the public class methods below.
25
+ Class methods take `(opts = {})` and read `opts`.
26
+
27
+ ## How to call
28
+
29
+ ```ruby
30
+ PWN::Plugins::REPL::AIConsole.help
31
+ PWN::Plugins::REPL::AIConsole.run(opts)
32
+ ```
33
+
34
+ ## Public methods
35
+
36
+ - `run`
37
+ - `theme`
38
+ - `authors`
39
+ - `help`
40
+
41
+ ## Source
42
+
43
+ `pwn/plugins/repl/ai_console.rb`
44
+
45
+ ## Verification
46
+
47
+ `PWN::Plugins::REPL::AIConsole.respond_to?(:run)` after the
48
+ module is loaded. Read the source for parameter names.
@@ -0,0 +1,47 @@
1
+ ---
2
+ name: pwn-plugins-repl-aiconsolecommands
3
+ description: Drive PWN::Plugins::REPL::AIConsoleCommands from pwn_eval.
4
+ license: MIT
5
+ allowed-tools: [pwn, pwn_eval]
6
+ metadata:
7
+ bundled: true
8
+ generated: true
9
+ module: PWN::Plugins::REPL::AIConsoleCommands
10
+ source: pwn/plugins/repl/ai_console_commands.rb
11
+ ---
12
+
13
+ # PWN::Plugins::REPL::AIConsoleCommands
14
+
15
+ Live slash-command parameter completion. Never calls a provider.
16
+
17
+ ## When to use
18
+
19
+ Call `PWN::Plugins::REPL::AIConsoleCommands` from `pwn_eval` when the task needs this module.
20
+ Do not reimplement it in shell.
21
+
22
+ ## Methodologies
23
+
24
+ Generated from `pwn/plugins/repl/ai_console_commands.rb`. Prefer the public class methods below.
25
+ Class methods take `(opts = {})` and read `opts`.
26
+
27
+ ## How to call
28
+
29
+ ```ruby
30
+ PWN::Plugins::REPL::AIConsoleCommands.help
31
+ PWN::Plugins::REPL::AIConsoleCommands.complete(opts)
32
+ ```
33
+
34
+ ## Public methods
35
+
36
+ - `complete`
37
+ - `authors`
38
+ - `help`
39
+
40
+ ## Source
41
+
42
+ `pwn/plugins/repl/ai_console_commands.rb`
43
+
44
+ ## Verification
45
+
46
+ `PWN::Plugins::REPL::AIConsoleCommands.respond_to?(:complete)` after the
47
+ module is loaded. Read the source for parameter names.
@@ -0,0 +1,48 @@
1
+ ---
2
+ name: pwn-plugins-repl-aiconsoleusage
3
+ description: Drive PWN::Plugins::REPL::AIConsoleUsage from pwn_eval.
4
+ license: MIT
5
+ allowed-tools: [pwn, pwn_eval]
6
+ metadata:
7
+ bundled: true
8
+ generated: true
9
+ module: PWN::Plugins::REPL::AIConsoleUsage
10
+ source: pwn/plugins/repl/ai_console_usage.rb
11
+ ---
12
+
13
+ # PWN::Plugins::REPL::AIConsoleUsage
14
+
15
+ Provider-reported token totals and explicitly configured USD estimates.
16
+
17
+ ## When to use
18
+
19
+ Call `PWN::Plugins::REPL::AIConsoleUsage` from `pwn_eval` when the task needs this module.
20
+ Do not reimplement it in shell.
21
+
22
+ ## Methodologies
23
+
24
+ Generated from `pwn/plugins/repl/ai_console_usage.rb`. Prefer the public class methods below.
25
+ Class methods take `(opts = {})` and read `opts`.
26
+
27
+ ## How to call
28
+
29
+ ```ruby
30
+ PWN::Plugins::REPL::AIConsoleUsage.help
31
+ PWN::Plugins::REPL::AIConsoleUsage.normalize(opts)
32
+ ```
33
+
34
+ ## Public methods
35
+
36
+ - `normalize`
37
+ - `estimate`
38
+ - `authors`
39
+ - `help`
40
+
41
+ ## Source
42
+
43
+ `pwn/plugins/repl/ai_console_usage.rb`
44
+
45
+ ## Verification
46
+
47
+ `PWN::Plugins::REPL::AIConsoleUsage.respond_to?(:normalize)` after the
48
+ module is loaded. Read the source for parameter names.
@@ -0,0 +1,46 @@
1
+ ---
2
+ name: pwn-plugins-repl-aiswarm
3
+ description: Drive PWN::Plugins::REPL::AISwarm from pwn_eval.
4
+ license: MIT
5
+ allowed-tools: [pwn, pwn_eval]
6
+ metadata:
7
+ bundled: true
8
+ generated: true
9
+ module: PWN::Plugins::REPL::AISwarm
10
+ source: pwn/plugins/repl/ai_swarm.rb
11
+ ---
12
+
13
+ # PWN::Plugins::REPL::AISwarm
14
+
15
+ Console-owned controller over the existing Swarm persona APIs.
16
+
17
+ ## When to use
18
+
19
+ Call `PWN::Plugins::REPL::AISwarm` from `pwn_eval` when the task needs this module.
20
+ Do not reimplement it in shell.
21
+
22
+ ## Methodologies
23
+
24
+ Generated from `pwn/plugins/repl/ai_swarm.rb`. Prefer the public class methods below.
25
+ Class methods take `(opts = {})` and read `opts`.
26
+
27
+ ## How to call
28
+
29
+ ```ruby
30
+ PWN::Plugins::REPL::AISwarm.help
31
+ PWN::Plugins::REPL::AISwarm.help(opts)
32
+ ```
33
+
34
+ ## Public methods
35
+
36
+ - `authors`
37
+ - `help`
38
+
39
+ ## Source
40
+
41
+ `pwn/plugins/repl/ai_swarm.rb`
42
+
43
+ ## Verification
44
+
45
+ `PWN::Plugins::REPL::AISwarm.respond_to?(:authors)` after the
46
+ module is loaded. Read the source for parameter names.
@@ -5,6 +5,7 @@ require 'securerandom'
5
5
  require 'digest'
6
6
  require 'fileutils'
7
7
  require 'tmpdir'
8
+ require 'fcntl'
8
9
  require 'pwn/ai/agent/mistakes'
9
10
 
10
11
  module PWN
@@ -58,6 +59,172 @@ module PWN
58
59
  # (:escalation_persona) — the local model still produces the final
59
60
  # answer so Learning/Metrics stay attributed to :ollama.
60
61
  module Loop
62
+ # Request-owned input broker; only the provider window is interruptible.
63
+ class Steering
64
+ INPUT_LOCK = Mutex.new
65
+ INPUT_OWNERS = {} # rubocop:disable Style/MutableConstant -- guarded by INPUT_LOCK
66
+
67
+ class ModelCancelled < Exception; end # rubocop:disable Lint/InheritException
68
+
69
+ # Cooperative restart carrying only host-owned conversation state.
70
+ class Restart < Exception # rubocop:disable Lint/InheritException
71
+ attr_reader :messages, :instructions
72
+
73
+ def initialize(messages, instructions)
74
+ @messages = messages
75
+ @instructions = instructions
76
+ super('operator steering')
77
+ end
78
+ end
79
+
80
+ attr_reader :reader
81
+
82
+ def initialize(input:, output:)
83
+ @input = input
84
+ @output = output
85
+ @owner = Thread.current
86
+ @mutex = Mutex.new
87
+ @pending = []
88
+ @phase = :boundary
89
+ end
90
+
91
+ def submit(text)
92
+ @mutex.synchronize do
93
+ if text.strip.empty?
94
+ @output.puts('[pwn-ai] Usage: /steer <instruction>')
95
+ return :empty
96
+ end
97
+ if @phase == :finished
98
+ @output.puts('[pwn-ai] /steer: no active request')
99
+ return :idle
100
+ end
101
+ @pending << text
102
+ notice = @phase == :tool ? 'wait until the current tool finishes; already-started work is not undone' : 'discarding the pending model result at the next safe boundary'
103
+ @output.puts("[pwn-ai] steering queued: #{notice}")
104
+ @output.flush
105
+ if @phase == :model && !@model_signal_sent
106
+ @model_signal_sent = true
107
+ @owner.raise(ModelCancelled)
108
+ end
109
+ end
110
+ :queued
111
+ end
112
+
113
+ def checkpoint(messages:, phase: :boundary)
114
+ instructions = @mutex.synchronize do
115
+ if @pending.empty?
116
+ @phase = phase
117
+ @model_signal_sent = false if phase == :model
118
+ nil
119
+ else
120
+ @phase = :boundary
121
+ @pending.shift(@pending.length)
122
+ end
123
+ end
124
+ return unless instructions
125
+
126
+ yield if block_given?
127
+ raise Restart.new(messages, instructions)
128
+ end
129
+
130
+ def model(messages:, &block)
131
+ # Drain an in-flight signal inside this rescue, never in a tool.
132
+ Thread.handle_interrupt(ModelCancelled => :never) do
133
+ checkpoint(messages: messages, phase: :model)
134
+ Thread.handle_interrupt(ModelCancelled => :immediate, &block)
135
+ ensure
136
+ @mutex.synchronize { @phase = :boundary }
137
+ begin
138
+ Thread.handle_interrupt(ModelCancelled => :immediate) { nil }
139
+ rescue ModelCancelled
140
+ nil
141
+ end
142
+ end
143
+ rescue ModelCancelled
144
+ nil
145
+ end
146
+
147
+ def with_reader
148
+ require 'io/console'
149
+ input_id = @input.fileno
150
+ claimed = INPUT_LOCK.synchronize do
151
+ raise ArgumentError, 'steering input is already owned by an active request' if INPUT_OWNERS.key?(input_id)
152
+
153
+ INPUT_OWNERS[input_id] = self
154
+ end
155
+ original_flags = @input.fcntl(Fcntl::F_GETFL)
156
+ source = @input.dup
157
+ forwarded, @forward = IO.pipe
158
+ @stop_read, @stop_write = IO.pipe
159
+ # Preserve descriptor identity for STDIN and child-process prompts.
160
+ # Non-command lines go to that descriptor, never into Pry's buffer.
161
+ @input.reopen(forwarded)
162
+ forwarded.close
163
+ run = proc do
164
+ @reader = Thread.new { read_lines(source) }
165
+ yield
166
+ ensure
167
+ @stop_write.write('.') unless @stop_write.closed?
168
+ @reader&.join
169
+ end
170
+ source.tty? ? source.cooked(&run) : run.call
171
+ ensure
172
+ if source && !source.closed?
173
+ @input.reopen(source)
174
+ @input.fcntl(Fcntl::F_SETFL, original_flags)
175
+ end
176
+ [source, forwarded, @forward, @stop_read, @stop_write].compact.each { |io| io.close unless io.closed? }
177
+ INPUT_LOCK.synchronize { INPUT_OWNERS.delete(input_id) } if claimed
178
+ end
179
+
180
+ private
181
+
182
+ def read_lines(source)
183
+ buffer = +''
184
+ loop do
185
+ ready = IO.select([source, @stop_read])&.first || []
186
+ break if ready.include?(@stop_read)
187
+
188
+ chunk = source.read_nonblock(4096, exception: false)
189
+ if chunk.nil?
190
+ route_line(buffer) unless buffer.empty?
191
+ break
192
+ end
193
+ next if chunk == :wait_readable
194
+
195
+ buffer << chunk
196
+ while (line = buffer.slice!(/.*?\n/m))
197
+ route_line(line)
198
+ end
199
+ if buffer.bytesize > 65_536
200
+ buffer.clear
201
+ @output.puts('[pwn-ai] input line too long; discarded (limit 64 KiB)')
202
+ end
203
+ end
204
+ rescue IOError, Errno::EBADF
205
+ nil
206
+ ensure
207
+ @forward.close unless @forward.closed?
208
+ end
209
+
210
+ def route_line(line)
211
+ if (match = line.match(%r{\A\s*/steer(?:[ \t]+(.*?))?[\r\n]*\z}m))
212
+ submit(match[1].to_s.strip)
213
+ else
214
+ forward_line(line)
215
+ end
216
+ end
217
+
218
+ def forward_line(line)
219
+ until line.empty?
220
+ return if IO.select([@stop_read], [@forward]).first.include?(@stop_read)
221
+
222
+ written = @forward.write_nonblock(line, exception: false)
223
+ line = line.byteslice(written..) if written.is_a?(Integer)
224
+ end
225
+ end
226
+ end
227
+
61
228
  DEFAULT_MAX_ITERS = 777
62
229
  ESCALATE_AFTER_FAILS = 4
63
230
  BOUNCE_FAIL_KEYS = %w[
@@ -161,6 +328,7 @@ module PWN
161
328
  end
162
329
 
163
330
  private_class_method def self.wait_trace_step!(opts = {})
331
+ return if Thread.current[:pwn_steering_input]
164
332
  return unless defined?(PWN::Plugins::Log) && PWN::Plugins::Log.respond_to?(:wait_trace_step!)
165
333
 
166
334
  PWN::Plugins::Log.wait_trace_step!(
@@ -1528,20 +1696,26 @@ module PWN
1528
1696
  private_class_method def self.publish_usage(opts = {})
1529
1697
  resp = opts[:response]
1530
1698
  engine = opts[:engine]
1531
- return unless resp.is_a?(Hash) && defined?(PWN::Env) && PWN::Env.is_a?(Hash)
1532
-
1533
- eng_env = PWN::Env.dig(:ai, engine)
1534
- return unless eng_env.is_a?(Hash) && !eng_env.frozen?
1699
+ return unless resp.is_a?(Hash)
1535
1700
 
1536
1701
  usage = resp[:usage]
1537
- # Ollama native /api/chat returns prompt_eval_count / eval_count
1538
- # instead of an OpenAI-shape :usage hash — normalise here so the
1539
- # PS1 dig(:response_history, :usage, :total_tokens) works uniformly.
1540
1702
  if !usage.is_a?(Hash) && (resp[:prompt_eval_count] || resp[:eval_count])
1541
1703
  pt = resp[:prompt_eval_count].to_i
1542
1704
  ct = resp[:eval_count].to_i
1543
1705
  usage = { prompt_tokens: pt, completion_tokens: ct, total_tokens: pt + ct }
1544
1706
  end
1707
+ observer = Thread.current[:pwn_usage_observer]
1708
+ if observer.respond_to?(:call) && (usage.is_a?(Hash) || resp[:prompt_eval_count] || resp[:eval_count])
1709
+ begin
1710
+ observer.call(engine: engine, model: resp[:model], usage: usage.is_a?(Hash) ? usage : resp)
1711
+ rescue StandardError
1712
+ nil
1713
+ end
1714
+ end
1715
+ return unless defined?(PWN::Env) && PWN::Env.is_a?(Hash)
1716
+
1717
+ eng_env = PWN::Env.dig(:ai, engine)
1718
+ return unless eng_env.is_a?(Hash) && !eng_env.frozen?
1545
1719
  return unless usage.is_a?(Hash)
1546
1720
 
1547
1721
  total = usage[:total_tokens] ||
@@ -1860,6 +2034,7 @@ module PWN
1860
2034
  cwt_opts[:think] = true
1861
2035
  else
1862
2036
  cwt_opts[:think] = false
2037
+ cwt_opts[:reasoning_effort] = 'none' if engine == :openai && PWN::Env.dig(:ai, engine, :reasoning_effort).to_s == 'none'
1863
2038
  end
1864
2039
  model = opts[:model] || Thread.current[:pwn_swarm_model]
1865
2040
  cwt_opts[:model] = model unless model.to_s.strip.empty?
@@ -1874,7 +2049,8 @@ module PWN
1874
2049
  cwt_opts[:tool_choice] = env_tc
1875
2050
  else
1876
2051
  has_tool_result = Array(messages).any? { |m| m[:role].to_s == 'tool' }
1877
- need_tools = needs_host_work?(request: Array(messages).find { |m| m[:role].to_s == 'user' }&.[](:content))
2052
+ active_request = Thread.current[:pwn_active_steering_request] || Array(messages).find { |m| m[:role].to_s == 'user' }&.[](:content)
2053
+ need_tools = needs_host_work?(request: active_request)
1878
2054
  cwt_opts[:tool_choice] = has_tool_result || !need_tools ? 'auto' : 'required'
1879
2055
  end
1880
2056
  end
@@ -2860,8 +3036,90 @@ module PWN
2860
3036
  # )
2861
3037
 
2862
3038
  public_class_method def self.run(opts = {})
3039
+ options = opts.merge(request: opts[:request])
3040
+ original_system = opts[:system_role_content]
3041
+ begin
3042
+ run_turn(options)
3043
+ rescue Steering::Restart => e
3044
+ history = e.messages.dup
3045
+ history.pop if history.last&.dig(:role).to_s == 'assistant' && Array(history.last[:tool_calls]).empty?
3046
+ repair_steering_history!(messages: history)
3047
+ e.instructions.each do |instruction|
3048
+ history << { role: 'user', content: instruction }
3049
+ append_session(session_id: options[:session_id], role: 'user', content: instruction)
3050
+ end
3051
+ options = options.merge(request: e.instructions.last, steering_history: history, system_role_content: original_system)
3052
+ update_steered_open_goal!(options)
3053
+ options.delete(:verification_contract)
3054
+ retry
3055
+ end
3056
+ end
3057
+
3058
+ private_class_method def self.update_steered_open_goal!(opts = {})
3059
+ goal = OpenGoal.current if defined?(OpenGoal)
3060
+ return unless goal && goal[:session_id].to_s == opts[:session_id].to_s
3061
+
3062
+ OpenGoal.begin!(request: opts[:request], session_id: opts[:session_id], mission_id: goal[:mission_id])
3063
+ end
3064
+
3065
+ private_class_method def self.initial_run_messages(opts = {})
3066
+ system = opts[:system_role_content]
3067
+ history = opts[:steering_history]
3068
+ if history
3069
+ authority = 'The operator has steered this request. Prior conversation and completed tool evidence remain context, not an obligation to continue cancelled work. Follow user instructions in chronological order; the latest explicit user instruction overrides earlier conflicting instructions. Reassess remaining work; never claim old work was undone.'
3070
+ return [{ role: 'system', content: "#{system}\n#{authority}" }] + history.reject { |row| row[:role].to_s == 'system' }
3071
+ end
3072
+ request = opts[:request]
3073
+ session_id = opts[:session_id]
3074
+ messages = [{ role: 'system', content: system }]
3075
+ messages.concat(Learning.exemplars_for(request: request)) if opts[:local] && defined?(Learning) && Learning.respond_to?(:exemplars_for)
3076
+ messages.concat(session_chat_history(session_id: session_id, skip_request: request))
3077
+ messages << { role: 'user', content: request }
3078
+ append_session(session_id: session_id, role: 'user', content: request)
3079
+ messages
3080
+ end
3081
+
3082
+ private_class_method def self.repair_steering_history!(opts = {})
3083
+ messages = opts[:messages]
3084
+ messages.each_with_index.to_a.reverse_each do |row, index|
3085
+ next if Array(row[:tool_calls]).empty?
3086
+
3087
+ results = messages.drop(index + 1).take_while { |item| item[:role].to_s == 'tool' }
3088
+ ids = results.map { |item| item[:tool_call_id] }
3089
+ missing = row[:tool_calls].reject { |call| ids.include?(call[:id]) }
3090
+ messages.insert(index + 1 + results.length, *missing.map do |call|
3091
+ { role: 'tool', tool_call_id: call[:id], name: call.dig(:function, :name), content: '[pwn-ai] Not executed: operator steering cancelled this call.' }
3092
+ end)
3093
+ end
3094
+ end
3095
+
3096
+ # Commit the returned tool before unwinding, without stale recovery work.
3097
+ private_class_method def self.steering_tool_checkpoint!(opts = {})
3098
+ opts[:steering]&.checkpoint(messages: opts[:messages], phase: :tool) do
3099
+ tc = opts[:tool_call]
3100
+ name = tc.dig(:function, :name).to_s
3101
+ raw = opts[:raw].to_s
3102
+ opts[:messages] << {
3103
+ role: 'tool', tool_call_id: tc[:id], name: name,
3104
+ content: wrap_untrusted_tool(content: raw),
3105
+ artifact_observations: TurnFinalizer.observe_artifacts(
3106
+ paths: opts[:paths], before: opts[:before],
3107
+ effect: Dispatch.effect(name: name, args: tc.dig(:function, :arguments)),
3108
+ success: opts[:success]
3109
+ )
3110
+ }
3111
+ append_session(session_id: opts[:session_id], role: 'tool', content: "#{name} → #{raw}")
3112
+ opts[:on_tool]&.call(name, tc.dig(:function, :arguments), raw)
3113
+ debug_tool_io!(name: name, args: tc.dig(:function, :arguments), result: raw)
3114
+ end
3115
+ end
3116
+
3117
+ private_class_method def self.run_turn(opts = {})
2863
3118
  prior_artifact_contract = Thread.current[:pwn_artifact_contract]
3119
+ prior_steering_request = Thread.current[:pwn_active_steering_request]
3120
+ steering = opts[:steering]
2864
3121
  request = opts[:request].to_s
3122
+ Thread.current[:pwn_active_steering_request] = request if opts[:steering_history]
2865
3123
  session_id = opts[:session_id]
2866
3124
  on_tool = opts[:on_tool]
2867
3125
  i = 0
@@ -2889,6 +3147,7 @@ module PWN
2889
3147
  prepared = prepare_open_goal(
2890
3148
  request: request,
2891
3149
  intent: intent,
3150
+ steering_history: opts[:steering_history],
2892
3151
  nested: nested,
2893
3152
  session_id: session_id,
2894
3153
  unattended: opts[:unattended],
@@ -2914,7 +3173,7 @@ module PWN
2914
3173
  expose_current_session(session_id: session_id)
2915
3174
  Mistakes.check_user_correction(request: request, session_id: session_id) if defined?(Mistakes)
2916
3175
 
2917
- allow_text_only = required_artifacts.empty? && opts[:force_tools] != true
3176
+ allow_text_only = steering.nil? && required_artifacts.empty? && opts[:force_tools] != true
2918
3177
  cheap = allow_text_only && %i[greeting howto recall].include?(intent)
2919
3178
 
2920
3179
  if allow_text_only && intent == :greeting
@@ -3018,13 +3277,7 @@ module PWN
3018
3277
  )
3019
3278
  no_tools = Array(tools).empty?
3020
3279
  Thread.current[:pwn_loop_no_tools] = no_tools
3021
- messages = [{ role: 'system', content: system_role_content }]
3022
- messages.concat(Learning.exemplars_for(request: request)) if local && defined?(Learning) && Learning.respond_to?(:exemplars_for)
3023
- messages.concat(
3024
- session_chat_history(session_id: session_id, skip_request: request)
3025
- )
3026
- messages << { role: 'user', content: request }
3027
- append_session(session_id: session_id, role: 'user', content: request)
3280
+ messages = initial_run_messages(opts.merge(request: request, system_role_content: system_role_content, local: local))
3028
3281
 
3029
3282
  trivia = world_knowledge?(request: request)
3030
3283
  catalog = catalog_lookup?(request: request)
@@ -3094,6 +3347,7 @@ module PWN
3094
3347
 
3095
3348
  i = 0
3096
3349
  loop do
3350
+ steering&.checkpoint(messages: messages)
3097
3351
  i += 1
3098
3352
  # 3.1 — compact fat tool dumps so remote ReadTimeout hops do not
3099
3353
  # retry the same 70-message payload (R1 201215 Anthropic 180s×5).
@@ -3114,7 +3368,11 @@ module PWN
3114
3368
  Thread.current[:pwn_loop_no_tools] = no_tools
3115
3369
  end
3116
3370
  repair_tool_history!(messages: messages)
3117
- msg = call_engine(messages: messages, tools: tools, ts_state: ts_state)
3371
+ msg = if steering
3372
+ steering.model(messages: messages) { call_engine(messages: messages, tools: tools, ts_state: ts_state) }
3373
+ else
3374
+ call_engine(messages: messages, tools: tools, ts_state: ts_state)
3375
+ end
3118
3376
  rescue StandardError => e
3119
3377
  if engine_transient?(error: e)
3120
3378
  engine_blips += 1
@@ -3136,6 +3394,7 @@ module PWN
3136
3394
  raise
3137
3395
  end
3138
3396
  engine_s += (Time.now - t0)
3397
+ steering&.checkpoint(messages: messages)
3139
3398
  PWN::Plugins::TTYSpinner.halt_all! if defined?(PWN::Plugins::TTYSpinner)
3140
3399
  wait_trace_step!(label: 'engine', nested: nested)
3141
3400
  if msg.nil?
@@ -3240,6 +3499,7 @@ module PWN
3240
3499
  verification_outcome = Reward.resolve_outcome(outcome: { score: nil, source: :verification, verification: report })
3241
3500
  on_tool&.call('verification', {}, JSON.generate(report))
3242
3501
  end
3502
+ steering&.checkpoint(messages: messages, phase: :finished)
3243
3503
  debug_progress(msg: "final accepted chars=#{text.to_s.length}")
3244
3504
  quiet_debug_tui!(reason: 'final')
3245
3505
  debug_final_text!(text: text)
@@ -3270,6 +3530,7 @@ module PWN
3270
3530
  )
3271
3531
 
3272
3532
  calls.each do |tc|
3533
+ steering&.checkpoint(messages: messages, phase: :tool)
3273
3534
  name = tc.dig(:function, :name).to_s
3274
3535
  args = tc.dig(:function, :arguments)
3275
3536
  entry = Registry.lookup(name: name)
@@ -3292,8 +3553,9 @@ module PWN
3292
3553
  Dispatch.call(tool_call: tc, session_id: session_id)
3293
3554
  end
3294
3555
  end
3295
- note_payload_result!(name: name, args: args, raw: raw)
3296
3556
  tools_called += 1
3557
+ steering_tool_checkpoint!(steering: steering, messages: messages, tool_call: tc, raw: raw, paths: watch_paths, before: before_host, session_id: session_id, on_tool: on_tool)
3558
+ note_payload_result!(name: name, args: args, raw: raw)
3297
3559
  if opts[:verification_contract]
3298
3560
  after_artifacts = Verification.snapshot(opts[:verification_contract])
3299
3561
  changes = after_artifacts.reject { |path, digest| before_artifacts[path] == digest }
@@ -3303,6 +3565,8 @@ module PWN
3303
3565
  started: started, raw: raw, args: args, session_id: session_id, engine: engine, ts_state: ts_state)
3304
3566
  result = Result.condition(content: raw, entry: entry, session_id: session_id)
3305
3567
 
3568
+ steering_tool_checkpoint!(steering: steering, messages: messages, tool_call: tc, raw: result, paths: watch_paths, before: before_host, session_id: session_id, success: tele[:ok], on_tool: on_tool)
3569
+
3306
3570
  unless tele[:ok]
3307
3571
  fkey = Digest::SHA256.hexdigest("#{name}|#{args}")[0, 16]
3308
3572
  turn_fails[fkey] += 1
@@ -3346,6 +3610,7 @@ module PWN
3346
3610
  role: 'tool',
3347
3611
  content: "#{name} → #{result[0, session_tool_budget(name: name, result: result)]}"
3348
3612
  )
3613
+ steering&.checkpoint(messages: messages)
3349
3614
  end
3350
3615
 
3351
3616
  # Do not inject "stop calling tools". Long goals keep CORE_TOOLS
@@ -3382,6 +3647,7 @@ module PWN
3382
3647
  raise
3383
3648
  ensure
3384
3649
  Thread.current[:pwn_artifact_contract] = prior_artifact_contract
3650
+ Thread.current[:pwn_active_steering_request] = prior_steering_request
3385
3651
  unless nested
3386
3652
  Thread.current[:pwn_loop_active] = nil
3387
3653
  Thread.current[:pwn_loop_deliverables] = nil
@@ -3422,6 +3688,8 @@ module PWN
3422
3688
  private_class_method def self.prepare_open_goal(opts = {})
3423
3689
  request = opts[:request].to_s
3424
3690
  intent = opts[:intent]
3691
+ return { request: request, intent: intent } if opts[:steering_history]
3692
+
3425
3693
  nested = opts[:nested]
3426
3694
  if defined?(Mission) && (mid = Mission.active_id)
3427
3695
  Mission.recover!(id: mid)
@@ -3511,6 +3779,7 @@ module PWN
3511
3779
  on_tool: 'optional - ->(name, args, result) callback for live UI',
3512
3780
  system_role_content: 'optional - override default system prompt (built from session_id if not provided)',
3513
3781
  verification_contract: 'optional - host-owned Verification.run checks; execute at final boundary and attribute observed artifacts',
3782
+ steering: 'optional - request-owned Loop::Steering control; REPL owns its terminal reader and restarts with revised completion scope',
3514
3783
  trusted_context: 'optional - host-observed capability/prerequisite scope; never copied from model arguments',
3515
3784
  debug: 'optional - debug value consumed by #run',
3516
3785
  from: 'optional - sender account or address to bind as operator',