pwn 0.5.761 → 0.5.762
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/README.md +5 -5
- data/documentation/Swarm.md +113 -0
- data/documentation/pwn-ai-Agent.md +1 -1
- data/etc/default_skills/pwn/ai/agent/solve/SKILL.md +46 -0
- data/etc/default_skills/pwn/ai/agent/swarm/SKILL.md +1 -0
- data/lib/pwn/ai/agent/dispatch.rb +3 -0
- data/lib/pwn/ai/agent/loop.rb +18 -4
- data/lib/pwn/ai/agent/solve.rb +342 -0
- data/lib/pwn/ai/agent/swarm.rb +41 -1
- data/lib/pwn/ai/agent.rb +1 -0
- data/lib/pwn/ai/anthropic.rb +1 -0
- data/lib/pwn/ai/grok.rb +1 -0
- data/lib/pwn/ai/open_ai.rb +1 -0
- data/lib/pwn/plugins/repl/ai.rb +6 -1
- data/lib/pwn/plugins/repl/ai_console.rb +35 -9
- data/lib/pwn/plugins/repl/ai_console_commands.rb +5 -4
- data/lib/pwn/plugins/repl/ai_swarm.rb +44 -11
- data/lib/pwn/version.rb +1 -1
- data/spec/lib/pwn/ai/agent/loop_spec.rb +2 -1
- data/spec/lib/pwn/ai/agent/solve_spec.rb +290 -0
- data/spec/lib/pwn/plugins/repl/ai_console_commands_spec.rb +37 -1
- data/spec/lib/pwn/plugins/repl/ai_console_spec.rb +55 -25
- data/spec/lib/pwn/plugins/repl/ai_swarm_spec.rb +67 -7
- data/third_party/pwn_rdoc.jsonl +16 -0
- metadata +4 -1
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'pry'
|
|
5
|
+
|
|
6
|
+
RSpec.describe 'Swarm solve' do
|
|
7
|
+
include_context 'pwn tmp sandbox'
|
|
8
|
+
|
|
9
|
+
before do
|
|
10
|
+
@workspace = File.join(@tmp, 'work')
|
|
11
|
+
FileUtils.mkdir_p(@workspace)
|
|
12
|
+
allow(PWN::AI::Agent::Swarm).to receive(:build_persona_prompt).and_return('fixture')
|
|
13
|
+
allow(PWN::AI::Agent::Swarm).to receive(:child_inbox).and_return({})
|
|
14
|
+
allow(PWN::AI::Agent::Swarm).to receive(:child_honesty).and_return({})
|
|
15
|
+
allow(PWN::AI::Agent::Swarm).to receive(:bus_append).and_return({})
|
|
16
|
+
@turns = []
|
|
17
|
+
@bad = false
|
|
18
|
+
@reject = false
|
|
19
|
+
@repair = false
|
|
20
|
+
allow(PWN::AI::Agent::Loop).to receive(:run) do |opts|
|
|
21
|
+
handoff = JSON.parse(opts[:system_role_content].split("SOLVE HANDOFF\n").last, symbolize_names: true)
|
|
22
|
+
@turns << [opts, handoff]
|
|
23
|
+
revision = handoff[:revision]
|
|
24
|
+
answer = case handoff[:phase]
|
|
25
|
+
when 'assess' then { requirements: ['The file contains the requested greeting'], risks: ['Wrong bytes'] }
|
|
26
|
+
when 'build'
|
|
27
|
+
File.write(File.join(@workspace, 'greeting.txt'), @bad ? 'wrong' : 'hello')
|
|
28
|
+
{ artifacts: ['greeting.txt'], summary: 'candidate' }
|
|
29
|
+
when 'critique' then { revision: revision, objections: @reject ? ['Wrong output'] : [] }
|
|
30
|
+
when 'verify'
|
|
31
|
+
{ revision: revision, checks: [{ requirements: handoff[:requirements].keys, command: 'cat greeting.txt', stdout: 'hello' }] }
|
|
32
|
+
when 'integrate'
|
|
33
|
+
@bad = false if @repair
|
|
34
|
+
{ revision: revision, decision: handoff[:evidence].all? { |row| row[:passed] } ? 'accept' : 'repair', repair: 'Write hello', summary: 'Greeting delivered' }
|
|
35
|
+
end
|
|
36
|
+
@hook&.call(handoff, answer)
|
|
37
|
+
JSON.generate(answer)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
it 'executes the real nested Loop with a deterministic provider and host tool' do
|
|
42
|
+
allow(PWN::AI::Agent::Loop).to receive(:run).and_call_original
|
|
43
|
+
allow(PWN::AI::Agent::TaskSummarizer).to receive(:enabled?).and_return(false)
|
|
44
|
+
PWN::Env[:ai][:active] = :openai
|
|
45
|
+
PWN::Env[:ai][:openai] = { key: 'offline-fixture-key' }
|
|
46
|
+
@agent_cfg[:plan_first] = false
|
|
47
|
+
runtime = double('enclosing request route')
|
|
48
|
+
expect(runtime).not_to receive(:call)
|
|
49
|
+
Thread.current[:pwn_request_runtime] = runtime
|
|
50
|
+
allow(PWN::AI::OpenAI).to receive(:chat_with_tools) do |opts|
|
|
51
|
+
system = opts[:messages].find { |row| row[:role].to_s == 'system' }[:content]
|
|
52
|
+
handoff = JSON.parse(system.split("SOLVE HANDOFF\n").last, symbolize_names: true)
|
|
53
|
+
phase = handoff[:phase]
|
|
54
|
+
revision = handoff[:revision]
|
|
55
|
+
content = case phase
|
|
56
|
+
when 'assess' then { requirements: ['Greeting bytes'], risks: [] }
|
|
57
|
+
when 'build'
|
|
58
|
+
next({ choices: [{ message: { role: 'assistant', content: nil, tool_calls: [{ id: 'write-greeting', type: 'function', function: { name: 'shell', arguments: JSON.generate(command: "printf hello > #{Shellwords.escape(File.join(@workspace, 'greeting.txt'))}", timeout: 5) } }] } }] }) unless File.exist?(File.join(@workspace, 'greeting.txt'))
|
|
59
|
+
|
|
60
|
+
{ artifacts: ['greeting.txt'], summary: 'written' }
|
|
61
|
+
when 'critique' then { revision: revision, objections: [] }
|
|
62
|
+
when 'verify' then { revision: revision, checks: [{ requirements: %w[R0 R1], command: 'cat greeting.txt', stdout: 'hello' }] }
|
|
63
|
+
when 'integrate' then { revision: revision, decision: 'accept', summary: 'Verified greeting' }
|
|
64
|
+
end
|
|
65
|
+
{ choices: [{ message: { role: 'assistant', content: JSON.generate(content), tool_calls: [] } }] }
|
|
66
|
+
end
|
|
67
|
+
require 'timeout'
|
|
68
|
+
controller = PWN::Plugins::REPL::AISwarm::Controller.new(session_id: 'real-loop-fixture')
|
|
69
|
+
out = Timeout.timeout(15) do
|
|
70
|
+
controller.execute(line: '/swarm mission Write hello to greeting.txt', workspace: @workspace)
|
|
71
|
+
controller.send(:workers).last.value
|
|
72
|
+
end
|
|
73
|
+
expect(out).to include(status: 'accepted')
|
|
74
|
+
expect(out[:evidence].first).to include(stdout: 'hello', exitstatus: 0)
|
|
75
|
+
ensure
|
|
76
|
+
controller&.close
|
|
77
|
+
Thread.current[:pwn_request_runtime] = nil
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
it 'runs all roles with the unchanged request and accepts only executed evidence for the artifact revision' do
|
|
81
|
+
request = 'Write the greeting hello to greeting.txt'
|
|
82
|
+
states = []
|
|
83
|
+
out = PWN::AI::Agent::Swarm.solve(
|
|
84
|
+
request: request, workspace: @workspace, on_state: ->(state) { states << state },
|
|
85
|
+
on_tool: ->(*) { expect(states.last[:roles]['verifier']).to eq('checking') }
|
|
86
|
+
)
|
|
87
|
+
expect(out).to include(ok: true, status: 'accepted', request: request)
|
|
88
|
+
expect(@turns.map { |_, handoff| handoff[:phase] }).to eq(%w[assess build critique verify integrate])
|
|
89
|
+
expect(@turns.map { |opts, _| opts[:request] }.uniq).to eq([request])
|
|
90
|
+
expect(out[:evidence].first).to include(passed: true, stdout: 'hello', exitstatus: 0, revision: out[:revision])
|
|
91
|
+
expect(File.read(File.join(@workspace, 'greeting.txt'))).to eq('hello')
|
|
92
|
+
record = JSON.parse(File.read(out[:record_path]))
|
|
93
|
+
expect(record['revision']).to eq(out[:revision])
|
|
94
|
+
expect(record['evidence'].first['stdout']).to eq('hello')
|
|
95
|
+
expect(record['records']).to include(include('phase' => 'candidate', 'revision' => out[:revision], 'artifacts' => out[:artifacts]))
|
|
96
|
+
expect(out[:reply]).to include('R0', 'greeting.txt', out[:revision])
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
it 'routes the legacy slash entry to an executed solve, not a normal model request' do
|
|
100
|
+
pry = Pry.new
|
|
101
|
+
controller = nil
|
|
102
|
+
Dir.chdir(@workspace) do
|
|
103
|
+
expect(PWN::Plugins::REPL.pwn_ai_dispatch_slash!(request: '/swarm mission Write hello to greeting.txt', pry: pry)).to be(true)
|
|
104
|
+
controller = pry.config.pwn_ai_swarm
|
|
105
|
+
expect(controller).to be_a(PWN::Plugins::REPL::AISwarm::Controller)
|
|
106
|
+
controller.send(:workers).each(&:join)
|
|
107
|
+
expect(controller.snapshot[:jobs].last[:state]).to eq('accepted')
|
|
108
|
+
end
|
|
109
|
+
ensure
|
|
110
|
+
controller&.close
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
it 'binds identical artifact bytes to the specific objective and solve run' do
|
|
114
|
+
first = PWN::AI::Agent::Swarm.solve(request: 'Write hello', workspace: @workspace)
|
|
115
|
+
second = PWN::AI::Agent::Swarm.solve(request: 'Write hello and inspect it', workspace: @workspace)
|
|
116
|
+
expect(first[:artifacts]).to eq(second[:artifacts])
|
|
117
|
+
expect(first[:revision]).not_to eq(second[:revision])
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
it 'rejects failing checks even when integration claims accept' do
|
|
121
|
+
@bad = true
|
|
122
|
+
@hook = ->(handoff, answer) { answer[:decision] = 'accept' if handoff[:phase] == 'integrate' }
|
|
123
|
+
out = PWN::AI::Agent::Swarm.solve(request: 'Write hello', workspace: @workspace, rounds: 1)
|
|
124
|
+
expect(out).to include(status: 'incomplete', ok: false)
|
|
125
|
+
expect(out[:evidence].first).to include(stdout: 'wrong', exitstatus: 0, passed: false)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
it 'repairs through the sole writer and reruns verification against changed bytes' do
|
|
129
|
+
@bad = @repair = true
|
|
130
|
+
out = PWN::AI::Agent::Swarm.solve(request: 'Write hello', workspace: @workspace)
|
|
131
|
+
expect(out[:status]).to eq('accepted')
|
|
132
|
+
builds = @turns.select { |_, row| row[:phase] == 'build' }
|
|
133
|
+
expect(builds.length).to eq(2)
|
|
134
|
+
expect(builds.last.last[:repair]).to include(instruction: 'Write hello')
|
|
135
|
+
verifications = out[:records].select { |row| row[:phase] == 'verification' }
|
|
136
|
+
expect(verifications.map { |row| row[:evidence].first[:passed] }).to eq([false, true])
|
|
137
|
+
expect(out[:records].select { |row| row[:phase] == 'verify' }.map { |row| row[:revision] }.uniq.length).to eq(2)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
it 'does not accept unresolved objections or missing requirement coverage' do
|
|
141
|
+
@reject = true
|
|
142
|
+
out = PWN::AI::Agent::Swarm.solve(request: 'Write hello', workspace: @workspace, rounds: 1)
|
|
143
|
+
expect(out[:status]).to eq('incomplete')
|
|
144
|
+
expect(out[:objections]).to eq(['Wrong output'])
|
|
145
|
+
@reject = false
|
|
146
|
+
@hook = ->(handoff, answer) { answer[:checks].first[:requirements] = ['R1'] if handoff[:phase] == 'verify' }
|
|
147
|
+
out = PWN::AI::Agent::Swarm.solve(request: 'Write hello', workspace: @workspace, rounds: 1)
|
|
148
|
+
expect(out).to include(status: 'incomplete', uncovered: ['R0'])
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
it 'invalidates evidence if the working artifact changes during integration' do
|
|
152
|
+
@hook = ->(handoff, _answer) { File.write(File.join(@workspace, 'greeting.txt'), 'changed') if handoff[:phase] == 'integrate' }
|
|
153
|
+
expect(PWN::AI::Agent::Swarm.solve(request: 'Write hello', workspace: @workspace, rounds: 1)[:status]).to eq('incomplete')
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
it 'rejects stale handoffs and copy-modifying verification' do
|
|
157
|
+
@hook = ->(handoff, answer) { answer[:revision] = 'old' if handoff[:phase] == 'critique' }
|
|
158
|
+
out = PWN::AI::Agent::Swarm.solve(request: 'Write hello', workspace: @workspace)
|
|
159
|
+
expect(out[:reply]).to include('stale revision')
|
|
160
|
+
@hook = lambda do |handoff, answer|
|
|
161
|
+
answer[:checks].first[:command] = 'printf wrong > greeting.txt; printf hello' if handoff[:phase] == 'verify'
|
|
162
|
+
end
|
|
163
|
+
out = PWN::AI::Agent::Swarm.solve(request: 'Write hello', workspace: @workspace, rounds: 1)
|
|
164
|
+
expect(out[:status]).to eq('incomplete')
|
|
165
|
+
expect(File.read(File.join(@workspace, 'greeting.txt'))).to eq('hello')
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
it 'propagates steering to every later role and invalidates the previous candidate' do
|
|
169
|
+
control = PWN::AI::Agent::Solve::Control.new(input: StringIO.new, output: StringIO.new)
|
|
170
|
+
steered = false
|
|
171
|
+
@hook = lambda do |handoff, _answer|
|
|
172
|
+
if handoff[:phase] == 'integrate' && !steered
|
|
173
|
+
control.submit('Also verify exact bytes')
|
|
174
|
+
steered = true
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
out = PWN::AI::Agent::Swarm.solve(request: 'Write hello', workspace: @workspace, steering: control)
|
|
178
|
+
expect(out[:status]).to eq('accepted')
|
|
179
|
+
expect(@turns.count { |_, row| row[:phase] == 'build' }).to eq(2)
|
|
180
|
+
expect(out[:requirements]).to include('S1' => 'Also verify exact bytes')
|
|
181
|
+
expect(@turns.last(4).all? { |opts, row| opts[:request] == 'Write hello' && row[:guidance] == ['Also verify exact bytes'] }).to be(true)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
it 'cancels at a tool boundary without claiming completion or undoing work' do
|
|
185
|
+
control = PWN::AI::Agent::Solve::Control.new(input: StringIO.new, output: StringIO.new)
|
|
186
|
+
@hook = ->(handoff, _answer) { control.stop if handoff[:phase] == 'build' }
|
|
187
|
+
out = PWN::AI::Agent::Swarm.solve(request: 'Write hello', workspace: @workspace, steering: control)
|
|
188
|
+
expect(out[:status]).to eq('cancelled')
|
|
189
|
+
expect(File.read(File.join(@workspace, 'greeting.txt'))).to eq('hello')
|
|
190
|
+
expect(@turns.length).to eq(2)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
it 'refuses unconfigured providers before any role or login runs' do
|
|
194
|
+
PWN::Env[:ai][:active] = :openai
|
|
195
|
+
PWN::Env[:ai][:openai] = {}
|
|
196
|
+
expect(PWN::AI::Agent::Loop).not_to receive(:run)
|
|
197
|
+
out = PWN::AI::Agent::Swarm.solve(request: 'Write hello', workspace: @workspace)
|
|
198
|
+
expect(out[:reply]).to include('configure openai credentials')
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
it 'enforces read-only reviewers at Dispatch even for invented tool calls' do
|
|
202
|
+
@hook = lambda do |handoff, _answer|
|
|
203
|
+
next unless handoff[:phase] == 'critique'
|
|
204
|
+
|
|
205
|
+
raw = PWN::AI::Agent::Dispatch.call(tool_call: { function: { name: 'shell', arguments: JSON.generate(command: "touch #{Shellwords.escape(File.join(@workspace, 'forbidden'))}") } })
|
|
206
|
+
expect(JSON.parse(raw)['success']).to be(false)
|
|
207
|
+
end
|
|
208
|
+
PWN::AI::Agent::Swarm.solve(request: 'Write hello', workspace: @workspace)
|
|
209
|
+
expect(File).not_to exist(File.join(@workspace, 'forbidden'))
|
|
210
|
+
expect(Thread.current[:pwn_solve_tools]).to be_nil
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
it 'routes configured roles without switching defaults or modifying the global registry' do
|
|
214
|
+
PWN::AI::Agent::Swarm.spawn(name: 'solve_antagonist', role: 'Review', engine: :ollama, model: 'Exact/Reviewer:Tag')
|
|
215
|
+
registry = File.binread(PWN::AI::Agent::Swarm::AGENTS_FILE)
|
|
216
|
+
routes = []
|
|
217
|
+
@hook = ->(handoff, _answer) { routes << [handoff[:role], Thread.current[:pwn_swarm_engine], Thread.current[:pwn_swarm_model]] }
|
|
218
|
+
out = PWN::AI::Agent::Swarm.solve(request: 'Write hello', workspace: @workspace)
|
|
219
|
+
expect(out[:status]).to eq('accepted')
|
|
220
|
+
expect(routes.select { |row| row.first == 'antagonist' }).to all(eq(['antagonist', 'ollama', 'Exact/Reviewer:Tag']))
|
|
221
|
+
expect(File.binread(PWN::AI::Agent::Swarm::AGENTS_FILE)).to eq(registry)
|
|
222
|
+
expect(Thread.current[:pwn_swarm_model]).to be_nil
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
it 'never opens OAuth or a credential prompt during a solve provider call' do
|
|
226
|
+
Thread.current[:pwn_solve_tools] = []
|
|
227
|
+
{ openai: [PWN::AI::OpenAI, :open_ai_rest_call], anthropic: [PWN::AI::Anthropic, :anthropic_rest_call], grok: [PWN::AI::Grok, :grok_rest_call] }.each do |engine, (provider, method)|
|
|
228
|
+
PWN::Env[:ai][engine] = {}
|
|
229
|
+
expect(provider).not_to receive(:obtain_oauth_bearer_token)
|
|
230
|
+
expect(PWN::Plugins::AuthenticationHelper).not_to receive(:mask_password)
|
|
231
|
+
expect(provider.send(method, rest_call: 'fixture')).to be_nil
|
|
232
|
+
end
|
|
233
|
+
ensure
|
|
234
|
+
Thread.current[:pwn_solve_tools] = nil
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
it 'pauses dispatch, resumes, and cancels through the coordinator command entry' do
|
|
238
|
+
entered = Queue.new
|
|
239
|
+
release = Queue.new
|
|
240
|
+
@hook = lambda do |handoff, _answer|
|
|
241
|
+
if handoff[:phase] == 'assess'
|
|
242
|
+
entered << true
|
|
243
|
+
release.pop
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
controller = PWN::Plugins::REPL::AISwarm::Controller.new(session_id: 'fixture')
|
|
247
|
+
job = controller.execute(line: '/swarm mission Write hello', workspace: @workspace)
|
|
248
|
+
entered.pop
|
|
249
|
+
expect(controller.execute(line: "/swarm pause #{job[:job_id]}")[:ok]).to be(true)
|
|
250
|
+
release << true
|
|
251
|
+
expect(controller.snapshot[:jobs].last[:state]).to eq('paused')
|
|
252
|
+
expect(controller.execute(line: '/swarm dm other write')[:ok]).to be(false)
|
|
253
|
+
expect(controller.execute(line: "/swarm steer #{job[:job_id]} Check bytes")[:ok]).to be(true)
|
|
254
|
+
expect(controller.execute(line: "/swarm resume #{job[:job_id]}")[:ok]).to be(true)
|
|
255
|
+
controller.execute(line: "/swarm cancel #{job[:job_id]}")
|
|
256
|
+
expect(controller.send(:workers).last.join(3)).not_to be_nil
|
|
257
|
+
expect(controller.snapshot[:jobs].last[:state]).to eq('cancelled')
|
|
258
|
+
ensure
|
|
259
|
+
release << true if release
|
|
260
|
+
controller&.close
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
it 'bounds a nonconverging role without inventing a final answer' do
|
|
264
|
+
control = PWN::AI::Agent::Solve::Control.new(input: StringIO.new, output: StringIO.new)
|
|
265
|
+
control.begin_turn
|
|
266
|
+
25.times { control.model(messages: []) { :fixture } }
|
|
267
|
+
expect { control.model(messages: []) { :unreachable } }.to raise_error(PWN::AI::Agent::Solve::Control::Limit)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
it 'runs a draft-preserving Mission with team through the curses confirmation entry' do
|
|
271
|
+
console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: nil, getch: nil)
|
|
272
|
+
controller = PWN::Plugins::REPL::AISwarm::Controller.new(session_id: 'fixture')
|
|
273
|
+
console.instance_variable_set(:@swarm, controller)
|
|
274
|
+
editor = console.instance_variable_get(:@editor)
|
|
275
|
+
editor.place('Write hello to greeting.txt', 4)
|
|
276
|
+
Dir.chdir(@workspace) do
|
|
277
|
+
console.handle("\u0007")
|
|
278
|
+
console.handle('v')
|
|
279
|
+
expect(controller.snapshot[:jobs]).to be_empty
|
|
280
|
+
expect(console.swarm_content.join).to include('mission')
|
|
281
|
+
console.handle("\r")
|
|
282
|
+
controller.send(:workers).each(&:join)
|
|
283
|
+
expect(controller.snapshot[:jobs].last).to include(state: 'accepted', team: include(roles: include('verifier' => 'done')))
|
|
284
|
+
console.handle("\e")
|
|
285
|
+
expect([editor.text, editor.cursor]).to eq(['Write hello to greeting.txt', 4])
|
|
286
|
+
end
|
|
287
|
+
ensure
|
|
288
|
+
controller&.close
|
|
289
|
+
end
|
|
290
|
+
end
|
|
@@ -3,6 +3,42 @@
|
|
|
3
3
|
require 'spec_helper'
|
|
4
4
|
|
|
5
5
|
describe PWN::Plugins::REPL::AIConsoleCommands do
|
|
6
|
+
it 'advertises the renamed swarm surface in both completers' do
|
|
7
|
+
line = '/swarm '
|
|
8
|
+
labels = described_class.complete(line: line, cursor: line.length)[:items].map { |item| item[:label] }
|
|
9
|
+
legacy = PWN::Plugins::REPL.pwn_ai_complete_command(line: line)
|
|
10
|
+
[labels, legacy].each do |commands|
|
|
11
|
+
expect(commands).to include('mission', 'agents', 'dm')
|
|
12
|
+
expect(commands & %w[solve roster ask]).to be_empty
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
it 'sorts every command group and nested live provider by displayed label' do
|
|
17
|
+
values = %w[zulu alpha Alpha Bravo]
|
|
18
|
+
allow(PWN::Plugins::REPL).to receive(:pwn_ai_engines).and_return(values)
|
|
19
|
+
allow(PWN::Cron).to receive(:list).and_return(values.to_h { |value| [value, {}] })
|
|
20
|
+
allow(PWN::Memory).to receive(:load).and_return(values.to_h { |value| [value, {}] })
|
|
21
|
+
allow(PWN::Sessions).to receive(:list).and_return(values.map { |value| { id: value } })
|
|
22
|
+
stub_const('PWN::Skills', values.to_h { |value| [value, {}] })
|
|
23
|
+
allow(PWN::AI::MCP).to receive(:backends).and_return(values.map { |value| { name: value, tools: values } })
|
|
24
|
+
controller = double(completion_context: { agents: values, jobs: values.map { |value| { id: value } } })
|
|
25
|
+
allow(Dir).to receive(:exist?).with(PWN::AI::Agent::Swarm::SWARM_ROOT).and_return(true)
|
|
26
|
+
allow(Dir).to receive(:children).with(PWN::AI::Agent::Swarm::SWARM_ROOT).and_return(values)
|
|
27
|
+
lines = ['/'] + described_class::COMMANDS.map { |command| "#{command} " } +
|
|
28
|
+
['/cron run ', '/memory recall ', '/sessions resume ', '/skills recall ', '/model list ', '/learning list ',
|
|
29
|
+
'/mcp use ', '/mcp call ', '/swarm dm ', '/swarm cancel ', '/swarm use ', '/swarm debate ']
|
|
30
|
+
lines.each do |line|
|
|
31
|
+
items = described_class.complete(line: line, cursor: line.length, swarm: controller)[:items]
|
|
32
|
+
labels = items.map { |item| item[:label] }
|
|
33
|
+
expect(labels).to eq(labels.sort_by { |label| [label.downcase, label] }), line
|
|
34
|
+
items.each { |item| expect(item[:text]).to eq(line == '/' ? item[:label] : "#{line}#{item[:label]}") }
|
|
35
|
+
end
|
|
36
|
+
line = '/swarm dm '
|
|
37
|
+
expect(described_class.complete(line: line, cursor: line.length, swarm: controller)[:items].first[:label]).to eq('Alpha')
|
|
38
|
+
values << 'aardvark'
|
|
39
|
+
expect(described_class.complete(line: line, cursor: line.length, swarm: controller)[:items].first[:label]).to eq('aardvark')
|
|
40
|
+
end
|
|
41
|
+
|
|
6
42
|
it 'completes command names and parameter positions without a provider call' do
|
|
7
43
|
allow(PWN::Plugins::REPL).to receive(:pwn_ai_engines).and_return(%w[openai grok])
|
|
8
44
|
first = described_class.complete(line: '/mo', cursor: 3)
|
|
@@ -13,7 +49,7 @@ describe PWN::Plugins::REPL::AIConsoleCommands do
|
|
|
13
49
|
expect(engines[:items].map { |item| item[:label] }).to include('openai', 'list')
|
|
14
50
|
expect(engines[:items].map { |item| item[:text] }).to include('/model openai')
|
|
15
51
|
verbose = described_class.complete(line: '/verbose ', cursor: 9)
|
|
16
|
-
expect(verbose[:items].map { |item| item[:label] }).to eq(%w[on
|
|
52
|
+
expect(verbose[:items].map { |item| item[:label] }).to eq(%w[off on])
|
|
17
53
|
free = described_class.complete(line: '/steer keep ', cursor: 12)
|
|
18
54
|
expect(free[:items]).to be_empty
|
|
19
55
|
expect(free[:hint]).to include('free text')
|
|
@@ -17,7 +17,7 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
|
|
|
17
17
|
allow(PWN::AI::Agent::Loop).to receive(:may_finalize?).and_return(true)
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
-
def launch(keys, &tick)
|
|
20
|
+
def launch(keys, startup_delay: 0, render_delay: 0, &tick)
|
|
21
21
|
require 'pty'
|
|
22
22
|
require 'timeout'
|
|
23
23
|
master, input = PTY.open
|
|
@@ -28,7 +28,10 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
|
|
|
28
28
|
frame = []
|
|
29
29
|
position = [0, 0]
|
|
30
30
|
allow(window).to receive(:erase) { frame = [] }
|
|
31
|
-
allow(window).to receive(:refresh)
|
|
31
|
+
allow(window).to receive(:refresh) do
|
|
32
|
+
sleep render_delay if render_delay.positive? && !keys.empty?
|
|
33
|
+
@rendered = frame.dup
|
|
34
|
+
end
|
|
32
35
|
@positions = []
|
|
33
36
|
allow(window).to receive(:setpos) do |*coords|
|
|
34
37
|
position = coords
|
|
@@ -42,6 +45,10 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
|
|
|
42
45
|
allow(window).to receive(:attron) { |_attribute, &block| block.call }
|
|
43
46
|
screen = double('curses', init_screen: window, raw: nil, noecho: nil, curs_set: nil, close_screen: nil,
|
|
44
47
|
lines: 30, cols: 110, stdscr: window, has_colors?: false, resizeterm: nil)
|
|
48
|
+
allow(screen).to receive(:init_screen) do
|
|
49
|
+
sleep startup_delay if startup_delay.positive?
|
|
50
|
+
window
|
|
51
|
+
end
|
|
45
52
|
@pry = Pry.new
|
|
46
53
|
@pry.config.pwn_ai_session_id = PWN::Sessions.create(title: 'console')[:id]
|
|
47
54
|
@pry.config.pwn_ai = true
|
|
@@ -258,28 +265,51 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
|
|
|
258
265
|
foreign&.join
|
|
259
266
|
end
|
|
260
267
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
"\u0003"
|
|
274
|
-
elsif @paint.any? { |row| row.include?('Request cancelled at a safe boundary') }
|
|
275
|
-
"\u0004"
|
|
276
|
-
else
|
|
277
|
-
Thread.pass
|
|
278
|
-
nil
|
|
268
|
+
[0, 3.1].each do |startup_delay|
|
|
269
|
+
it "leaves a provider blocked in native IO without waiting for that read to return (startup #{startup_delay}s)" do
|
|
270
|
+
entered = Queue.new
|
|
271
|
+
returned = Queue.new
|
|
272
|
+
provider = nil
|
|
273
|
+
started = nil
|
|
274
|
+
allow(PWN::AI::Agent::Loop).to receive(:call_engine) do
|
|
275
|
+
Thread.handle_interrupt(Exception => :never) do
|
|
276
|
+
entered << Thread.current
|
|
277
|
+
sleep 8
|
|
278
|
+
returned << true
|
|
279
|
+
end
|
|
279
280
|
end
|
|
281
|
+
keys = "what color is a lemon?\n".chars
|
|
282
|
+
launched = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
283
|
+
launch(keys, startup_delay: startup_delay, render_delay: startup_delay.positive? ? 0.02 : 0) do
|
|
284
|
+
provider ||= entered.pop unless entered.empty?
|
|
285
|
+
if !started && provider&.status == 'sleep'
|
|
286
|
+
# Elapsed is painted before call_engine: it is not a provider barrier.
|
|
287
|
+
# Time from delivered Ctrl+C, excluding startup and per-key rendering.
|
|
288
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
289
|
+
"\u0003"
|
|
290
|
+
elsif @paint.any? { |row| row.include?('Request cancelled at a safe boundary') }
|
|
291
|
+
"\u0004"
|
|
292
|
+
else
|
|
293
|
+
Thread.pass
|
|
294
|
+
nil
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
expect(started).not_to be_nil
|
|
298
|
+
expect(started - launched).to be >= startup_delay
|
|
299
|
+
expect(Process.clock_gettime(Process::CLOCK_MONOTONIC) - started).to be < 3
|
|
300
|
+
expect(returned).to be_empty
|
|
301
|
+
ensure
|
|
302
|
+
# Cancellation deliberately abandons masked IO. Release this fixture only
|
|
303
|
+
# after measuring return, then reap it before RSpec removes its mocks.
|
|
304
|
+
provider ||= entered.pop unless entered.empty?
|
|
305
|
+
begin
|
|
306
|
+
provider&.wakeup if provider&.alive?
|
|
307
|
+
rescue ThreadError
|
|
308
|
+
nil # The provider exited between alive? and wakeup.
|
|
309
|
+
end
|
|
310
|
+
provider&.join(2)
|
|
311
|
+
expect(provider).not_to be_alive if provider
|
|
280
312
|
end
|
|
281
|
-
expect(started).not_to be_nil
|
|
282
|
-
expect(Process.clock_gettime(Process::CLOCK_MONOTONIC) - started).to be < 3
|
|
283
313
|
end
|
|
284
314
|
|
|
285
315
|
it 'forwards tool input, preserves completed evidence before steering, and locks settings while running' do
|
|
@@ -345,7 +375,7 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
|
|
|
345
375
|
console.seed_request_history
|
|
346
376
|
'/verbose '.each_char { |key| console.handle(key) }
|
|
347
377
|
menu = console.instance_variable_get(:@menu)
|
|
348
|
-
expect(menu.map { |item| item[:label] }).to eq(%w[on
|
|
378
|
+
expect(menu.map { |item| item[:label] }).to eq(%w[off on])
|
|
349
379
|
draft = [editor.text.dup, editor.cursor]
|
|
350
380
|
console.handle(:up)
|
|
351
381
|
expect(console.instance_variable_get(:@menu_index)).to eq(1)
|
|
@@ -377,7 +407,7 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
|
|
|
377
407
|
'/verbose '.each_char { |key| console.handle(key) }
|
|
378
408
|
console.handle(:down)
|
|
379
409
|
console.handle("\t")
|
|
380
|
-
expect(editor.text.strip).to eq('/verbose
|
|
410
|
+
expect(editor.text.strip).to eq('/verbose on')
|
|
381
411
|
console.handle("\u0015")
|
|
382
412
|
'/verbose '.each_char { |key| console.handle(key) }
|
|
383
413
|
console.handle(:up)
|
|
@@ -1490,7 +1520,7 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
|
|
|
1490
1520
|
it 'keeps slash parameter completion live across spaces and clears stale menus on submit' do
|
|
1491
1521
|
console = PWN::Plugins::REPL::AIConsole::Console.new(pry: Pry.new, input: StringIO.new, curses: nil, getch: nil)
|
|
1492
1522
|
'/verbose '.chars.each { |key| console.handle(key) }
|
|
1493
|
-
expect(console.instance_variable_get(:@menu).map { |item| item[:label] }).to eq(%w[on
|
|
1523
|
+
expect(console.instance_variable_get(:@menu).map { |item| item[:label] }).to eq(%w[off on])
|
|
1494
1524
|
console.handle('o')
|
|
1495
1525
|
console.handle('f')
|
|
1496
1526
|
console.handle('f')
|
|
@@ -5,9 +5,69 @@ require 'spec_helper'
|
|
|
5
5
|
describe PWN::Plugins::REPL::AISwarm::Controller do
|
|
6
6
|
include_context 'pwn tmp sandbox'
|
|
7
7
|
|
|
8
|
+
it 'routes renamed commands and missing arguments through both console entry points' do
|
|
9
|
+
allow(PWN::AI::Agent::Swarm).to receive(:personas).and_return(scout: { role: 'Inspect' })
|
|
10
|
+
allow(PWN::AI::Agent::Swarm).to receive(:create).and_return(swarm_id: 'fixture')
|
|
11
|
+
allow(PWN::AI::Agent::Swarm).to receive(:ask).and_return(reply: 'Direct reply')
|
|
12
|
+
pry = Pry.new
|
|
13
|
+
console = PWN::Plugins::REPL::AIConsole::Console.new(pry: pry, input: StringIO.new, curses: nil, getch: nil)
|
|
14
|
+
controller = described_class.new(session_id: 'fixture')
|
|
15
|
+
console.instance_variable_set(:@swarm, controller)
|
|
16
|
+
pry.config.pwn_ai_swarm = controller
|
|
17
|
+
['/swarm mission', '/swarm dm', '/swarm dm scout'].each do |line|
|
|
18
|
+
usage = line.include?('mission') ? 'usage: mission REQUEST' : 'usage: dm NAME REQUEST'
|
|
19
|
+
console.submit(line)
|
|
20
|
+
expect(console.instance_variable_get(:@timeline).last[1]).to include(usage)
|
|
21
|
+
expect { PWN::Plugins::REPL.pwn_ai_dispatch_slash!(request: line, pry: pry) }.to output(/#{usage}/).to_stdout
|
|
22
|
+
end
|
|
23
|
+
expect(controller.snapshot[:jobs]).to be_empty
|
|
24
|
+
%w[solve roster ask].each do |old|
|
|
25
|
+
expect(controller.execute(line: "/swarm #{old}")[:error]).to eq("unknown /swarm command: #{old}")
|
|
26
|
+
end
|
|
27
|
+
console.submit('/swarm agents')
|
|
28
|
+
expect(console.instance_variable_get(:@timeline).last[1]).to include('scout')
|
|
29
|
+
expect { PWN::Plugins::REPL.pwn_ai_dispatch_slash!(request: '/swarm agents', pry: pry) }.to output(/scout/).to_stdout
|
|
30
|
+
expect { PWN::Plugins::REPL.pwn_ai_dispatch_slash!(request: '/swarm dm scout inspect', pry: pry) }.to output(/queued/).to_stdout
|
|
31
|
+
controller.send(:workers).each(&:join)
|
|
32
|
+
expect(controller.snapshot[:jobs].last).to include(command: 'dm', result: 'Direct reply')
|
|
33
|
+
ensure
|
|
34
|
+
controller&.close
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
it 'rejects a mission without an original request before starting work' do
|
|
38
|
+
controller = described_class.new(session_id: 'fixture')
|
|
39
|
+
expect(PWN::AI::Agent::Swarm).not_to receive(:create)
|
|
40
|
+
expect(controller.execute(line: '/swarm mission')).to include(ok: false, error: 'usage: mission REQUEST')
|
|
41
|
+
ensure
|
|
42
|
+
controller&.close
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
it 'returns visible errors for malformed commands and synchronous backend failures' do
|
|
46
|
+
controller = described_class.new(session_id: 'fixture')
|
|
47
|
+
expect(controller.execute(line: '/swarm pause absent')).to include(ok: false, error: 'job not found')
|
|
48
|
+
ensure
|
|
49
|
+
controller&.close
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
it 'preserves solve request bytes and exposes real team state and incomplete outcomes' do
|
|
53
|
+
request = "Write 'hello'\nthen verify --literal"
|
|
54
|
+
allow(PWN::AI::Agent::Swarm).to receive(:solve) do |opts|
|
|
55
|
+
expect(opts[:request]).to eq(request)
|
|
56
|
+
expect(opts[:steering]).to be_a(PWN::AI::Agent::Solve::Control)
|
|
57
|
+
opts[:on_state].call(roles: { protagonist: 'build', antagonist: 'done', verifier: 'waiting', integrator: 'waiting' })
|
|
58
|
+
{ status: 'incomplete', reply: 'INCOMPLETE: missing check' }
|
|
59
|
+
end
|
|
60
|
+
controller = described_class.new(session_id: 'fixture')
|
|
61
|
+
controller.execute(line: "/swarm mission #{request}")
|
|
62
|
+
controller.send(:workers).each(&:join)
|
|
63
|
+
expect(controller.snapshot[:jobs].first).to include(state: 'incomplete', team: include(roles: include(protagonist: 'build')))
|
|
64
|
+
ensure
|
|
65
|
+
controller&.close
|
|
66
|
+
end
|
|
67
|
+
|
|
8
68
|
it 'returns visible errors for malformed commands and synchronous backend failures' do
|
|
9
69
|
controller = described_class.new(session_id: 'fixture')
|
|
10
|
-
expect(controller.execute(line: '/swarm
|
|
70
|
+
expect(controller.execute(line: '/swarm dm scout "unfinished')[:ok]).to be(false)
|
|
11
71
|
allow(PWN::AI::Agent::Swarm).to receive(:create).and_raise(IOError, 'fixture storage unavailable')
|
|
12
72
|
expect(controller.execute(line: '/swarm create fixture')).to include(ok: false, error: 'IOError: fixture storage unavailable')
|
|
13
73
|
ensure
|
|
@@ -20,7 +80,7 @@ describe PWN::Plugins::REPL::AISwarm::Controller do
|
|
|
20
80
|
allow(PWN::AI::Agent::Swarm).to receive(:ask).and_return(reply: 'Observed fixture result')
|
|
21
81
|
controller = described_class.new(session_id: 'fixture')
|
|
22
82
|
expect(controller.roster).to include(include(name: 'scout', role: 'Inspect evidence', engine: 'ollama', model: 'fixture'))
|
|
23
|
-
queued = controller.execute(line: '/swarm
|
|
83
|
+
queued = controller.execute(line: '/swarm dm scout inspect')
|
|
24
84
|
controller.send(:workers).each(&:join)
|
|
25
85
|
expect(controller.snapshot[:jobs].first).to include(state: 'completed', result: 'Observed fixture result')
|
|
26
86
|
expect(controller.execute(line: "/swarm steer #{queued[:job_id]} again")[:ok]).to be(false)
|
|
@@ -40,7 +100,7 @@ describe PWN::Plugins::REPL::AISwarm::Controller do
|
|
|
40
100
|
{ reply: 'done' }
|
|
41
101
|
end
|
|
42
102
|
controller = described_class.new(session_id: 'fixture')
|
|
43
|
-
controller.execute(line: '/swarm
|
|
103
|
+
controller.execute(line: '/swarm dm scout inspect')
|
|
44
104
|
owner, worker, local, marker = entered.pop
|
|
45
105
|
expect(owner).to eq(worker)
|
|
46
106
|
expect(local).to eq(:copied)
|
|
@@ -64,7 +124,7 @@ describe PWN::Plugins::REPL::AISwarm::Controller do
|
|
|
64
124
|
controller.send(:workers).each(&:join)
|
|
65
125
|
expect(calls.first[:request]).to eq('--literal mission')
|
|
66
126
|
expect(calls.last[:request]).to include('reply from scout', '--literal mission')
|
|
67
|
-
controller.execute(line: '/swarm
|
|
127
|
+
controller.execute(line: '/swarm dm broken inspect')
|
|
68
128
|
controller.send(:workers).each(&:join)
|
|
69
129
|
expect(controller.snapshot[:jobs].last).to include(state: 'failed', error: 'IOError: fixture unavailable')
|
|
70
130
|
ensure
|
|
@@ -83,7 +143,7 @@ describe PWN::Plugins::REPL::AISwarm::Controller do
|
|
|
83
143
|
opts[:steering].checkpoint(messages: [])
|
|
84
144
|
end
|
|
85
145
|
controller = described_class.new(session_id: 'fixture')
|
|
86
|
-
job = controller.execute(line: '/swarm
|
|
146
|
+
job = controller.execute(line: '/swarm dm scout inspect')
|
|
87
147
|
entered.pop
|
|
88
148
|
expect(controller.execute(line: '/swarm create other')[:ok]).to be(false)
|
|
89
149
|
expect(controller.execute(line: '/swarm use other')[:ok]).to be(false)
|
|
@@ -92,7 +152,7 @@ describe PWN::Plugins::REPL::AISwarm::Controller do
|
|
|
92
152
|
expect(controller.snapshot[:jobs].first).to include(state: 'cancelled')
|
|
93
153
|
expect(foreign).to be_alive
|
|
94
154
|
controller.close
|
|
95
|
-
expect(controller.execute(line: '/swarm
|
|
155
|
+
expect(controller.execute(line: '/swarm dm scout again')[:ok]).to be(false)
|
|
96
156
|
ensure
|
|
97
157
|
controller&.close
|
|
98
158
|
foreign&.kill&.join
|
|
@@ -111,7 +171,7 @@ describe PWN::Plugins::REPL::AISwarm::Controller do
|
|
|
111
171
|
allow(PWN::AI::Agent::Swarm).to receive(:personas).and_return(scout: { role: 'scout' })
|
|
112
172
|
allow(PWN::AI::Agent::Swarm).to receive(:spawn).and_return(name: 'scout')
|
|
113
173
|
controller.execute(line: '/swarm spawn scout inspect evidence')
|
|
114
|
-
queued = controller.execute(line: '/swarm
|
|
174
|
+
queued = controller.execute(line: '/swarm dm scout inspect the fixture', on_event: ->(type, text) { events << [type, text] })
|
|
115
175
|
steering = entered.pop
|
|
116
176
|
expect(queued[:ok]).to eq(true)
|
|
117
177
|
expect(controller.snapshot[:jobs].first[:state]).to eq('running')
|