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.
- checksums.yaml +4 -4
- data/Gemfile +2 -2
- data/documentation/Blockchain.md +107 -9
- data/documentation/pwn-ai-Agent.md +310 -4
- data/documentation/pwn_silent_help_learn_demo.gif +0 -0
- data/etc/default_skills/pwn/banner/SKILL.md +7 -3
- data/etc/default_skills/pwn/blockchain/btc/SKILL.md +8 -5
- data/etc/default_skills/pwn/blockchain/eth/SKILL.md +12 -3
- data/etc/default_skills/pwn/blockchain/eth/references/urls.md +1 -1
- data/etc/default_skills/pwn/plugins/repl/SKILL.md +5 -0
- data/etc/default_skills/pwn/plugins/repl/ai/SKILL.md +4 -0
- data/etc/default_skills/pwn/plugins/repl/ai/references/urls.md +5 -0
- data/etc/default_skills/pwn/plugins/repl/ai_console/SKILL.md +48 -0
- data/etc/default_skills/pwn/plugins/repl/ai_console_commands/SKILL.md +47 -0
- data/etc/default_skills/pwn/plugins/repl/ai_console_usage/SKILL.md +48 -0
- data/etc/default_skills/pwn/plugins/repl/ai_swarm/SKILL.md +46 -0
- data/lib/pwn/ai/agent/loop.rb +287 -18
- data/lib/pwn/ai/agent/swarm.rb +28 -4
- data/lib/pwn/ai/open_ai.rb +4 -2
- data/lib/pwn/banner.rb +743 -2
- data/lib/pwn/blockchain/btc.rb +494 -196
- data/lib/pwn/blockchain/eth.rb +440 -81
- data/lib/pwn/blockchain.rb +6 -0
- data/lib/pwn/config.rb +22 -0
- data/lib/pwn/migrate.rb +9 -1
- data/lib/pwn/plugins/repl/ai.rb +182 -17
- data/lib/pwn/plugins/repl/ai_console.rb +1581 -0
- data/lib/pwn/plugins/repl/ai_console_commands.rb +282 -0
- data/lib/pwn/plugins/repl/ai_console_usage.rb +144 -0
- data/lib/pwn/plugins/repl/ai_swarm.rb +375 -0
- data/lib/pwn/plugins/repl.rb +25 -3
- data/lib/pwn/version.rb +1 -1
- data/spec/lib/pwn/ai/agent/loop_spec.rb +258 -1
- data/spec/lib/pwn/ai/agent/turn_finalizer_spec.rb +3 -2
- data/spec/lib/pwn/ai/open_ai_spec.rb +5 -0
- data/spec/lib/pwn/banner_spec.rb +643 -0
- data/spec/lib/pwn/blockchain/btc_spec.rb +372 -1
- data/spec/lib/pwn/blockchain/eth_spec.rb +205 -0
- data/spec/lib/pwn/blockchain_spec.rb +6 -0
- data/spec/lib/pwn/config_spec.rb +10 -0
- data/spec/lib/pwn/migrate_spec.rb +40 -3
- data/spec/lib/pwn/plugins/repl/ai_console_commands_spec.rb +65 -0
- data/spec/lib/pwn/plugins/repl/ai_console_spec.rb +1434 -0
- data/spec/lib/pwn/plugins/repl/ai_console_usage_spec.rb +29 -0
- data/spec/lib/pwn/plugins/repl/ai_swarm_spec.rb +127 -0
- data/spec/lib/pwn/plugins/repl_pwn_vault_spec.rb +5 -3
- data/spec/lib/pwn/plugins/repl_spec.rb +167 -1
- data/third_party/pwn_rdoc.jsonl +229 -10
- metadata +18 -6
- 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.
|
data/lib/pwn/ai/agent/loop.rb
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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',
|