pwn 0.5.760 → 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 +45 -11
- 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/etc/default_skills/pwn/banner/SKILL.md +1 -1
- 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/banner.rb +593 -53
- data/lib/pwn/plugins/repl/ai.rb +6 -1
- data/lib/pwn/plugins/repl/ai_console.rb +81 -18
- 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/banner_spec.rb +428 -20
- data/spec/lib/pwn/plugins/repl/ai_console_commands_spec.rb +37 -1
- data/spec/lib/pwn/plugins/repl/ai_console_spec.rb +218 -27
- data/spec/lib/pwn/plugins/repl/ai_swarm_spec.rb +67 -7
- data/third_party/pwn_rdoc.jsonl +39 -2
- metadata +4 -1
data/lib/pwn/plugins/repl/ai.rb
CHANGED
|
@@ -249,7 +249,7 @@ module PWN
|
|
|
249
249
|
end
|
|
250
250
|
end
|
|
251
251
|
PWN_AI_SLASH_COMMANDS = %w[
|
|
252
|
-
/back /cron /debug /delegate /help /learning /memory /mcp /model /sessions /skills /steer /system-role /trace
|
|
252
|
+
/back /cron /debug /delegate /help /learning /memory /mcp /model /sessions /skills /steer /swarm /system-role /trace
|
|
253
253
|
].freeze
|
|
254
254
|
|
|
255
255
|
PWN_AI_SLASH_SUBCOMMANDS = {
|
|
@@ -261,6 +261,7 @@ module PWN
|
|
|
261
261
|
'/memory' => %w[list recall remember forget clear],
|
|
262
262
|
'/mcp' => %w[list backends use current connect disconnect ping tools call status help],
|
|
263
263
|
'/model' => %w[list],
|
|
264
|
+
'/swarm' => %w[mission agents status create use spawn retire dm debate broadcast tail steer pause resume cancel help],
|
|
264
265
|
'/sessions' => %w[list resume delete stats],
|
|
265
266
|
'/skills' => %w[list recall],
|
|
266
267
|
'/learning' => %w[list requeue]
|
|
@@ -524,6 +525,10 @@ module PWN
|
|
|
524
525
|
args = tokens[1..]
|
|
525
526
|
pi = opts[:pry]
|
|
526
527
|
case cmd
|
|
528
|
+
when '/swarm'
|
|
529
|
+
pi.config.pwn_ai_swarm ||= AISwarm::Controller.new(session_id: pi.config.pwn_ai_session_id)
|
|
530
|
+
result = pi.config.pwn_ai_swarm.execute(line: request, on_event: ->(_type, text) { puts text })
|
|
531
|
+
puts(result[:usage] || result[:error] || result.inspect)
|
|
527
532
|
when '/steer'
|
|
528
533
|
puts(args.empty? ? '[pwn-ai] Usage: /steer <instruction>' : '[pwn-ai] /steer: no active request; submit a normal request instead')
|
|
529
534
|
when '/help'
|
|
@@ -105,6 +105,8 @@ module PWN
|
|
|
105
105
|
|
|
106
106
|
# Read a duplicated terminal rather than stdin, which belongs to tools.
|
|
107
107
|
class Keyboard
|
|
108
|
+
attr_writer :interval
|
|
109
|
+
|
|
108
110
|
KEYS = {
|
|
109
111
|
"\e[A" => :up, "\e[B" => :down, "\e[C" => :right, "\e[D" => :left,
|
|
110
112
|
"\eOA" => :up, "\eOB" => :down, "\eOC" => :right, "\eOD" => :left,
|
|
@@ -120,7 +122,7 @@ module PWN
|
|
|
120
122
|
end
|
|
121
123
|
|
|
122
124
|
def call
|
|
123
|
-
if @source.wait_readable(@buffer.empty? ? 0.04 : 0)
|
|
125
|
+
if @source.wait_readable(@buffer.empty? ? (@interval || 0.04) : 0)
|
|
124
126
|
chunk = @source.read_nonblock(4096, exception: false)
|
|
125
127
|
return :eof if chunk.nil? && @buffer.empty?
|
|
126
128
|
|
|
@@ -402,6 +404,7 @@ module PWN
|
|
|
402
404
|
break if @leaving && !busy?
|
|
403
405
|
|
|
404
406
|
draw
|
|
407
|
+
@getch.interval = @focus == :animation && %i[asteroids galaga frogger].include?(@banner_name) ? 0.01 : 0.04 if @getch.is_a?(Keyboard)
|
|
405
408
|
handle(@getch.call)
|
|
406
409
|
rescue Interrupt
|
|
407
410
|
cancel
|
|
@@ -607,7 +610,7 @@ module PWN
|
|
|
607
610
|
clear_view
|
|
608
611
|
elsif line.match?(%r{\A/verbose(?:\s|$)})
|
|
609
612
|
toggle_verbose(line)
|
|
610
|
-
elsif line.start_with?('/swarm') && %w[status tail steer cancel help
|
|
613
|
+
elsif line.start_with?('/swarm') && %w[status tail steer cancel pause resume help agents].include?(line.split[1])
|
|
611
614
|
run_swarm(line)
|
|
612
615
|
else
|
|
613
616
|
add(:warning, 'Request running. Settings and new requests wait until idle. Use /steer, /input, /swarm status|steer|cancel, /clear, or /verbose.')
|
|
@@ -690,7 +693,7 @@ module PWN
|
|
|
690
693
|
|
|
691
694
|
return clear_view if key == "\u000c"
|
|
692
695
|
|
|
693
|
-
if
|
|
696
|
+
if key == "\u0013" || (key == "\u0007" && (@focus != :animation || @workspace))
|
|
694
697
|
@workspace ? @workspace = nil : open_swarm
|
|
695
698
|
return
|
|
696
699
|
end
|
|
@@ -801,12 +804,22 @@ module PWN
|
|
|
801
804
|
end
|
|
802
805
|
|
|
803
806
|
def handle_animation(key)
|
|
804
|
-
if key ==
|
|
807
|
+
if key == "\u0007"
|
|
808
|
+
names = PWN::Banner.mini_names
|
|
809
|
+
@banner_name = names[(names.index(@banner_name).to_i + 1) % names.length]
|
|
810
|
+
@banner_frame_seconds = PWN::Banner.mini_frame_seconds(name: @banner_name)
|
|
811
|
+
@banner_started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
812
|
+
@banner_game = @banner_game_key = @banner_game_tick = nil
|
|
813
|
+
elsif key == :paste_start
|
|
805
814
|
@focus = :mission
|
|
806
815
|
@paste = true
|
|
807
816
|
@banner_game&.controls
|
|
808
817
|
elsif [:up, :down, :left, :right, ' '].include?(key)
|
|
818
|
+
return if %i[asteroids galaga frogger].include?(@banner_name) && @banner_game_frame&.dig(:state, :explosion).to_i.positive?
|
|
819
|
+
|
|
809
820
|
@banner_game&.key(key)
|
|
821
|
+
# Paint a discrete control immediately, not after the next idle tick.
|
|
822
|
+
@banner_game_tick = nil if %i[asteroids galaga frogger].include?(@banner_name)
|
|
810
823
|
elsif ["\r", "\n", "\e", :newline].include?(key)
|
|
811
824
|
@focus = :mission
|
|
812
825
|
@banner_game&.controls
|
|
@@ -1049,11 +1062,11 @@ module PWN
|
|
|
1049
1062
|
usage = @usage.snapshot
|
|
1050
1063
|
cost = usage[:estimated_cost_usd] ? format('$%.4f est.', usage[:estimated_cost_usd]) : usage[:cost_status]
|
|
1051
1064
|
swarm = @swarm&.snapshot || {}
|
|
1052
|
-
active = Array(swarm[:jobs]).count { |job|
|
|
1065
|
+
active = Array(swarm[:jobs]).count { |job| AISwarm::Controller::ACTIVE.include?(job[:state]) }
|
|
1053
1066
|
['REQUEST', state, "Elapsed #{elapsed}", "Completed tools #{@completed_tools}",
|
|
1054
1067
|
"Events #{@event_count}", '', 'TOKENS', "#{usage[:input_tokens]} in · #{usage[:output_tokens]} out",
|
|
1055
1068
|
"cached #{usage[:cached_tokens]} · calls #{usage[:calls]}", "Cost #{cost}",
|
|
1056
|
-
'', 'SWARM', swarm[:swarm_id] || 'none', "#{active} active · ^
|
|
1069
|
+
'', 'SWARM', swarm[:swarm_id] || 'none', "#{active} active · ^S swarm",
|
|
1057
1070
|
'', 'VIEW', @verbose ? 'verbose' : 'compact · /verbose', scroll_status,
|
|
1058
1071
|
'', 'LAST TOOL', @last_tool || 'not observed']
|
|
1059
1072
|
end
|
|
@@ -1226,6 +1239,7 @@ module PWN
|
|
|
1226
1239
|
cadence = cells ? @banner_frame_seconds : PWN::Banner::MINI_FRAME_SECONDS
|
|
1227
1240
|
frame = ((now - @banner_started) / cadence).floor % PWN::Banner::MINI_FRAME_COUNT
|
|
1228
1241
|
return playable_banner(size, now) if cells && @focus == :animation
|
|
1242
|
+
return automatic_banner(size, now) if cells && size >= 5 && %i[pacman galaga frogger].include?(@banner_name)
|
|
1229
1243
|
return PWN::Banner.mini_cells(name: @banner_name, frame: frame, width: size, height: size, seed: @banner_seed, branding: false) if cells
|
|
1230
1244
|
|
|
1231
1245
|
art = PWN::Banner.mini_frame(name: @banner_name, frame: frame, width: size, height: size, branding: false)
|
|
@@ -1235,6 +1249,24 @@ module PWN
|
|
|
1235
1249
|
canvas
|
|
1236
1250
|
end
|
|
1237
1251
|
|
|
1252
|
+
def automatic_banner(size, now)
|
|
1253
|
+
key = [@banner_session, @banner_name, size, @banner_seed]
|
|
1254
|
+
if @banner_demo_key != key
|
|
1255
|
+
@banner_demo_key = key
|
|
1256
|
+
@banner_demo = PWN::Banner::MiniGame.new(name: @banner_name, width: size, height: size, seed: @banner_seed, demo: true)
|
|
1257
|
+
@banner_demo.step
|
|
1258
|
+
@banner_demo_tick = nil
|
|
1259
|
+
end
|
|
1260
|
+
tick = (now / @banner_frame_seconds).floor
|
|
1261
|
+
if @banner_demo_tick != tick
|
|
1262
|
+
# Advance one frame, never build 1800 or catch up after a modal.
|
|
1263
|
+
@banner_demo_frame = @banner_demo.step
|
|
1264
|
+
@banner_demo_cells = @banner_demo.cells(@banner_demo_frame)
|
|
1265
|
+
@banner_demo_tick = tick
|
|
1266
|
+
end
|
|
1267
|
+
@banner_demo_cells
|
|
1268
|
+
end
|
|
1269
|
+
|
|
1238
1270
|
def playable_banner(size, now)
|
|
1239
1271
|
key = [@banner_session, @banner_name, size, @banner_seed]
|
|
1240
1272
|
if @banner_game_key != key
|
|
@@ -1242,9 +1274,13 @@ module PWN
|
|
|
1242
1274
|
@banner_game = PWN::Banner::MiniGame.new(name: @banner_name, width: size, height: size, seed: @banner_seed)
|
|
1243
1275
|
@banner_game_tick = nil
|
|
1244
1276
|
end
|
|
1245
|
-
|
|
1277
|
+
# Human Snake input needs a full reaction interval on its tiny board;
|
|
1278
|
+
# decorative replay cadence is independent of player movement.
|
|
1279
|
+
cadence = @banner_name == :snake ? 0.2 : @banner_frame_seconds
|
|
1280
|
+
tick = (now / cadence).floor
|
|
1246
1281
|
if @banner_game_tick != tick
|
|
1247
|
-
@
|
|
1282
|
+
@banner_game_frame = @banner_game.step
|
|
1283
|
+
@banner_game_cells = @banner_game.cells(@banner_game_frame)
|
|
1248
1284
|
@banner_game_tick = tick
|
|
1249
1285
|
end
|
|
1250
1286
|
@banner_game_cells
|
|
@@ -1308,9 +1344,10 @@ module PWN
|
|
|
1308
1344
|
def footer_text
|
|
1309
1345
|
text = '/ menu · ^C=cancel · ^D=back · ^L=clear · ^T=toggle pane · ^S=swarm · ^R=search ^O=operations · ↑↓ history · Shift+Enter=newline · Enter=send'
|
|
1310
1346
|
if @focus == :animation
|
|
1311
|
-
controls = {
|
|
1312
|
-
asteroids: '←→ turn · ↑↓ thrust/reverse · Space=fire'
|
|
1313
|
-
|
|
1347
|
+
controls = { pacman: '↑↓←→ start/steer · ● power', snake: '↑↓←→ start/steer', falling_blocks: '←→ move · ↓ faster · Space=rotate',
|
|
1348
|
+
asteroids: '←→ turn · ↑↓ thrust/reverse · Space=fire', galaga: '↑↓←→ move · Space=fire',
|
|
1349
|
+
frogger: '↑↓←→ hop · logs → goals' }.fetch(@banner_name, '↑↓←→ play')
|
|
1350
|
+
return text.sub('↑↓ history', "^G=next game · #{controls}").sub('Shift+Enter=newline · Enter=send', 'Enter/Esc=draft')
|
|
1314
1351
|
end
|
|
1315
1352
|
@focus == :session ? text.sub('↑↓ history', '↑↓ scroll · HOME · PGUP · PGDN · END') : text
|
|
1316
1353
|
end
|
|
@@ -1522,6 +1559,10 @@ module PWN
|
|
|
1522
1559
|
when :home then view[:offset] = 0
|
|
1523
1560
|
when :end then view[:offset] = 1_000_000
|
|
1524
1561
|
when 'r' then refresh_swarm
|
|
1562
|
+
when 'v'
|
|
1563
|
+
view[:confirm] = ['mission', @editor.text] unless @editor.text.strip.empty?
|
|
1564
|
+
when 'p', 'u'
|
|
1565
|
+
view[:confirm] = [key == 'p' ? 'pause' : 'resume', item[:id]] if view[:tab] == :jobs && item
|
|
1525
1566
|
when 'a', 'b', 'd' then prepare_swarm_mission(key, item)
|
|
1526
1567
|
when 'c', "\u0003"
|
|
1527
1568
|
return unless view[:tab] == :jobs && item
|
|
@@ -1545,9 +1586,9 @@ module PWN
|
|
|
1545
1586
|
minimum = key == 'd' ? 2 : 1
|
|
1546
1587
|
return view[:notice] = "Select at least #{minimum} agent(s) with Space." if names.length < minimum
|
|
1547
1588
|
|
|
1548
|
-
command = { 'a' => '
|
|
1589
|
+
command = { 'a' => 'dm', 'b' => 'broadcast', 'd' => 'debate' }.fetch(key)
|
|
1549
1590
|
view[:confirm] = case command
|
|
1550
|
-
when '
|
|
1591
|
+
when 'dm' then [command, names.first, @editor.text]
|
|
1551
1592
|
when 'broadcast' then [command, '--names', names.join(','), '--', @editor.text]
|
|
1552
1593
|
else [command, names.join(','), '--', @editor.text]
|
|
1553
1594
|
end
|
|
@@ -1577,17 +1618,21 @@ module PWN
|
|
|
1577
1618
|
|
|
1578
1619
|
def execute_swarm_action
|
|
1579
1620
|
args = @workspace.delete(:confirm)
|
|
1580
|
-
|
|
1621
|
+
options = { line: Shellwords.join(['/swarm'] + args), on_event: ->(type, text) { @events << [type, text] }, usage_observer: @usage.method(:record) }
|
|
1622
|
+
options[:solve_request] = args.last if args.first == 'mission'
|
|
1623
|
+
result = @swarm.execute(options)
|
|
1581
1624
|
@workspace[:notice] = result[:error] || "#{args.first}: accepted#{" · job #{result[:job_id]}" if result[:job_id]}"
|
|
1582
1625
|
add(result[:ok] == false ? :warning : :notice, @workspace[:notice])
|
|
1583
1626
|
refresh_swarm if args.first == 'spawn'
|
|
1584
|
-
@workspace.merge!(tab: :jobs, index: 0, offset: 0, detail: true) if result[:ok] && %w[
|
|
1627
|
+
@workspace.merge!(tab: :jobs, index: 0, offset: 0, detail: true) if result[:ok] && %w[mission dm broadcast debate].include?(args.first)
|
|
1585
1628
|
end
|
|
1586
1629
|
|
|
1587
1630
|
def swarm_content
|
|
1588
1631
|
view = @workspace
|
|
1589
1632
|
if view[:confirm]
|
|
1590
1633
|
args = view[:confirm]
|
|
1634
|
+
return [[false, "ACTION: mission\nTEAM: protagonist builds; antagonist assesses independently and critiques; verifier runs checks; integrator accepts or requests repair.\nOnly protagonist writes. Verification uses disposable artifact copies (not an OS sandbox).\nORIGINAL REQUEST:\n#{args.last}"]] if args.first == 'mission'
|
|
1635
|
+
|
|
1591
1636
|
target = args.first == 'broadcast' ? args[2] : args[1]
|
|
1592
1637
|
return [[false, "ACTION: #{args.first}\nTARGET: #{target}\n#{args.first == 'cancel' ? 'Cancel at a safe boundary; completed work is not undone.' : args.last}"]]
|
|
1593
1638
|
end
|
|
@@ -1595,7 +1640,13 @@ module PWN
|
|
|
1595
1640
|
item = items[view[:index]]
|
|
1596
1641
|
rows = []
|
|
1597
1642
|
if view[:detail] && item
|
|
1598
|
-
item.each
|
|
1643
|
+
item.each do |key, value|
|
|
1644
|
+
if key == :team
|
|
1645
|
+
rows.concat(solve_rows(value))
|
|
1646
|
+
else
|
|
1647
|
+
rows << [false, "#{key.to_s.upcase}: #{value}"]
|
|
1648
|
+
end
|
|
1649
|
+
end
|
|
1599
1650
|
elsif items.empty?
|
|
1600
1651
|
rows << [false, view[:tab] == :roster ? 'No agents. Press n to add a local persona (no model call).' : 'No jobs yet. Tab to roster; a sends your draft after confirmation.']
|
|
1601
1652
|
else
|
|
@@ -1611,13 +1662,25 @@ module PWN
|
|
|
1611
1662
|
rows
|
|
1612
1663
|
end
|
|
1613
1664
|
|
|
1665
|
+
def solve_rows(team)
|
|
1666
|
+
rows = [[false, "ORIGINAL REQUEST: #{team[:request]}"], [false, "ROUND: #{team[:round]} · REVISION: #{team[:revision]}"]]
|
|
1667
|
+
(team[:roles] || {}).each do |role, state|
|
|
1668
|
+
routing = team.dig(:routing, role) || {}
|
|
1669
|
+
rows << [false, "#{role.upcase}: #{state} · #{routing[:engine]} / #{routing[:model] || 'provider default'}"]
|
|
1670
|
+
end
|
|
1671
|
+
(team[:requirements] || {}).each { |id, text| rows << [false, "#{id}: #{text}"] }
|
|
1672
|
+
(team[:artifacts] || {}).each { |path, sha| rows << [false, "ARTIFACT: #{path} · #{sha}"] }
|
|
1673
|
+
Array(team[:objections]).each { |text| rows << [false, "OPEN: #{text}"] }
|
|
1674
|
+
rows
|
|
1675
|
+
end
|
|
1676
|
+
|
|
1614
1677
|
def draw_swarm
|
|
1615
1678
|
@screen.erase
|
|
1616
1679
|
view = @workspace
|
|
1617
1680
|
box(0, 0, @height - 1, @width - 1, "SWARM WORKSPACE · #{view[:tab].to_s.upcase}")
|
|
1618
1681
|
swarm_put(1, 'Tab roster/jobs · j/k move · Enter details', :footer)
|
|
1619
|
-
swarm_put(2, 'Space pick · a
|
|
1620
|
-
swarm_put(3, 'n new · r refresh · s steer · c cancel', :footer)
|
|
1682
|
+
swarm_put(2, 'v Mission with team · Space pick · a dm · b broadcast · d debate', :footer)
|
|
1683
|
+
swarm_put(3, 'n new · r refresh · s steer · p pause · u resume · c cancel', :footer)
|
|
1621
1684
|
swarm_put(4, 'PgUp/Dn Home/End scroll · Esc back', :footer)
|
|
1622
1685
|
rows = swarm_content.flat_map { |selected, text| wrap(swarm_safe(text), @width - 7).map { |row| [selected, row] } }
|
|
1623
1686
|
page = @height - 10
|
|
@@ -9,7 +9,7 @@ module PWN
|
|
|
9
9
|
/back /clear /cron /debug /delegate /help /input /learning /mcp /memory /menu /model /system-role
|
|
10
10
|
/sessions /skills /status /steer /swarm /trace /verbose
|
|
11
11
|
].freeze
|
|
12
|
-
SWARM = %w[dashboard help
|
|
12
|
+
SWARM = %w[dashboard help mission agents status create use spawn retire dm debate broadcast tail steer pause resume cancel].freeze
|
|
13
13
|
|
|
14
14
|
public_class_method def self.complete(opts = {})
|
|
15
15
|
line = opts[:line].to_s
|
|
@@ -24,7 +24,8 @@ module PWN
|
|
|
24
24
|
else
|
|
25
25
|
candidates(tokens: tokens, prefix: prefix, pry: opts[:pry], swarm: opts[:swarm])
|
|
26
26
|
end
|
|
27
|
-
|
|
27
|
+
# Every depth is resolved live; sort here so no provider can bypass ordering.
|
|
28
|
+
items = values.sort_by { |value| [value.downcase, value] }.map do |value|
|
|
28
29
|
replacement = line_replace(line: line, token: current, value: value)
|
|
29
30
|
{ label: value, text: replacement[:text], cursor: replacement[:cursor] }
|
|
30
31
|
end
|
|
@@ -252,10 +253,10 @@ module PWN
|
|
|
252
253
|
jobs = Array(context[:jobs]).map { |job| job[:id] || job['id'] }
|
|
253
254
|
agents = Array(context[:agents])
|
|
254
255
|
case action
|
|
255
|
-
when 'status', 'steer', 'cancel' then [filter(values: jobs + (action == 'cancel' ? ['all'] : []), prefix: prefix), 'job id']
|
|
256
|
+
when 'status', 'steer', 'cancel', 'pause', 'resume' then [filter(values: jobs + (action == 'cancel' ? ['all'] : []), prefix: prefix), 'job id']
|
|
256
257
|
when 'use' then [filter(values: existing_swarms, prefix: prefix), 'swarm id']
|
|
257
258
|
when 'spawn' then [[], position == 2 ? 'persona name' : 'role text']
|
|
258
|
-
when 'retire', '
|
|
259
|
+
when 'retire', 'dm' then [filter(values: agents, prefix: prefix), 'persona']
|
|
259
260
|
when 'debate' then [filter(values: agents, prefix: prefix), 'comma-separated personas, then topic']
|
|
260
261
|
when 'tail' then [[], 'message limit']
|
|
261
262
|
else [[], 'free text']
|
|
@@ -35,7 +35,7 @@ module PWN
|
|
|
35
35
|
# Session-scoped swarm jobs. Workers never draw the terminal.
|
|
36
36
|
class Controller # rubocop:disable Metrics/ClassLength -- one session-scoped swarm command surface
|
|
37
37
|
MAX_JOBS = 8
|
|
38
|
-
ACTIVE = %w[queued running steering cancelling].freeze
|
|
38
|
+
ACTIVE = %w[queued running steering cancelling paused].freeze
|
|
39
39
|
|
|
40
40
|
def initialize(session_id:)
|
|
41
41
|
@session_id = session_id.to_s
|
|
@@ -46,6 +46,10 @@ module PWN
|
|
|
46
46
|
end
|
|
47
47
|
|
|
48
48
|
def execute(opts = {})
|
|
49
|
+
if (match = opts[:line].to_s.match(%r{\A\s*/swarm[ \t]+mission(?:[ \t](.*))?\z}m))
|
|
50
|
+
return solve([], opts.merge(solve_request: opts.fetch(:solve_request, match[1].to_s)))
|
|
51
|
+
end
|
|
52
|
+
|
|
49
53
|
line = opts[:line].to_s.strip
|
|
50
54
|
tokens = Shellwords.split(line)
|
|
51
55
|
tokens.shift if tokens.first == '/swarm'
|
|
@@ -59,7 +63,7 @@ module PWN
|
|
|
59
63
|
@mutex.synchronize do
|
|
60
64
|
{
|
|
61
65
|
swarm_id: @swarm_id,
|
|
62
|
-
jobs: @jobs.values.map { |job| job.slice(:id, :agent, :state, :command, :result, :error, :last_tool
|
|
66
|
+
jobs: @jobs.values.map { |job| Marshal.load(Marshal.dump(job.slice(:id, :agent, :state, :command, :result, :error, :last_tool, :team))) }
|
|
63
67
|
}
|
|
64
68
|
end
|
|
65
69
|
end
|
|
@@ -97,18 +101,20 @@ module PWN
|
|
|
97
101
|
def dispatch(command, tokens, opts)
|
|
98
102
|
case command
|
|
99
103
|
when 'help', 'dashboard' then help_text
|
|
100
|
-
when '
|
|
104
|
+
when 'agents' then { ok: true, agents: roster, swarm_id: @swarm_id }
|
|
101
105
|
when 'status' then status(tokens.first)
|
|
102
106
|
when 'create' then create(tokens.join(' '))
|
|
103
107
|
when 'use' then use(tokens.first)
|
|
104
108
|
when 'spawn' then spawn(tokens)
|
|
105
109
|
when 'retire' then retire(tokens.first)
|
|
106
|
-
when '
|
|
110
|
+
when 'dm' then ask(tokens, opts)
|
|
111
|
+
when 'mission' then solve(tokens, opts)
|
|
107
112
|
when 'debate' then debate(tokens, opts)
|
|
108
113
|
when 'broadcast' then broadcast(tokens, opts)
|
|
109
114
|
when 'tail' then tail(tokens.first)
|
|
110
115
|
when 'steer' then steer(tokens, opts)
|
|
111
116
|
when 'cancel' then cancel(tokens.first)
|
|
117
|
+
when 'pause', 'resume' then pause(tokens.first, command == 'pause')
|
|
112
118
|
else { ok: false, error: "unknown /swarm command: #{command}", hint: help_text[:usage] }
|
|
113
119
|
end
|
|
114
120
|
end
|
|
@@ -116,7 +122,7 @@ module PWN
|
|
|
116
122
|
def help_text
|
|
117
123
|
{
|
|
118
124
|
ok: true,
|
|
119
|
-
usage: '/swarm
|
|
125
|
+
usage: '/swarm mission REQUEST|agents|status [JOB]|create [topic]|use ID|spawn NAME ROLE [--engine E --model M --toolsets a,b]|retire NAME|dm NAME REQUEST|debate NAMES TOPIC [--rounds N]|broadcast REQUEST [--names a,b]|tail [N]|steer JOB INSTRUCTION|pause JOB|resume JOB|cancel JOB|all'
|
|
120
126
|
}
|
|
121
127
|
end
|
|
122
128
|
|
|
@@ -161,13 +167,26 @@ module PWN
|
|
|
161
167
|
def ask(tokens, opts)
|
|
162
168
|
name = tokens.shift
|
|
163
169
|
request = tokens.join(' ')
|
|
164
|
-
return { ok: false, error: 'usage:
|
|
170
|
+
return { ok: false, error: 'usage: dm NAME REQUEST' } if name.to_s.empty? || request.empty?
|
|
165
171
|
|
|
166
|
-
start_job(agent: name, command: '
|
|
172
|
+
start_job(agent: name, command: 'dm', opts: opts) do |job|
|
|
167
173
|
PWN::AI::Agent::Swarm.ask(name: name, request: request, swarm_id: ensure_swarm, steering: job[:control], usage_observer: opts[:usage_observer], on_tool: tool_callback(job, opts))
|
|
168
174
|
end
|
|
169
175
|
end
|
|
170
176
|
|
|
177
|
+
def solve(tokens, opts)
|
|
178
|
+
request = opts.fetch(:solve_request, tokens.join(' '))
|
|
179
|
+
return { ok: false, error: 'usage: mission REQUEST' } if request.strip.empty?
|
|
180
|
+
|
|
181
|
+
start_job(agent: 'team', command: 'mission', opts: opts) do |job|
|
|
182
|
+
PWN::AI::Agent::Swarm.solve(
|
|
183
|
+
request: request, swarm_id: ensure_swarm, steering: job[:control],
|
|
184
|
+
workspace: opts[:workspace], usage_observer: opts[:usage_observer], on_tool: tool_callback(job, opts),
|
|
185
|
+
on_state: ->(state) { @mutex.synchronize { job[:team] = state } }
|
|
186
|
+
)
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
171
190
|
def debate(tokens, opts)
|
|
172
191
|
flags, positional = split_flags(tokens)
|
|
173
192
|
names = positional.shift.to_s.split(',')
|
|
@@ -219,6 +238,16 @@ module PWN
|
|
|
219
238
|
{ ok: true, job_id: job[:id] }
|
|
220
239
|
end
|
|
221
240
|
|
|
241
|
+
def pause(id, paused)
|
|
242
|
+
job = find_job(id)
|
|
243
|
+
return { ok: false, error: 'job not found' } unless job
|
|
244
|
+
return { ok: false, error: 'pause/resume applies to mission jobs only' } unless job[:command] == 'mission'
|
|
245
|
+
return { ok: false, error: 'job is no longer active' } unless update_active(job, paused ? 'paused' : 'running')
|
|
246
|
+
|
|
247
|
+
job[:control].pause(paused)
|
|
248
|
+
{ ok: true, job_id: id, state: job[:state] }
|
|
249
|
+
end
|
|
250
|
+
|
|
222
251
|
def cancel(id)
|
|
223
252
|
return cancel_all if id.nil? || id == 'all'
|
|
224
253
|
|
|
@@ -245,6 +274,8 @@ module PWN
|
|
|
245
274
|
|
|
246
275
|
def start_job(agent:, command:, opts:)
|
|
247
276
|
return { ok: false, error: 'swarm controller is closed' } if @closed
|
|
277
|
+
return { ok: false, error: 'mission requires exclusive workspace ownership; wait or cancel active jobs' } if (command == 'mission' && busy?) || snapshot[:jobs].any? { |row| row[:command] == 'mission' && ACTIVE.include?(row[:state]) }
|
|
278
|
+
|
|
248
279
|
return { ok: false, error: "job limit #{MAX_JOBS} reached" } if busy_count >= MAX_JOBS
|
|
249
280
|
|
|
250
281
|
ensure_swarm
|
|
@@ -257,16 +288,18 @@ module PWN
|
|
|
257
288
|
job[:worker] = Thread.new do
|
|
258
289
|
locals.each { |key, value| Thread.current[key] = value }
|
|
259
290
|
variables.each { |key, value| Thread.current.thread_variable_set(key, value) }
|
|
260
|
-
|
|
291
|
+
control_class = command == 'mission' ? PWN::AI::Agent::Solve::Control : JobControl
|
|
292
|
+
job[:control] = control_class.new(input: StringIO.new, output: StringIO.new)
|
|
261
293
|
Thread.current[:pwn_steering_input] = job[:control]
|
|
262
294
|
ready << true
|
|
263
295
|
update(job, 'running')
|
|
264
296
|
result = yield(job)
|
|
265
297
|
job[:control].check_stop
|
|
266
|
-
|
|
267
|
-
|
|
298
|
+
state = command == 'mission' ? result[:status] : 'completed'
|
|
299
|
+
update(job, state, result: summarize(result))
|
|
300
|
+
opts[:on_event]&.call(:assistant, "#{agent} [#{id}] #{command} #{state}\n#{summarize(result)}")
|
|
268
301
|
result
|
|
269
|
-
rescue JobControl::Stopped => e
|
|
302
|
+
rescue JobControl::Stopped, PWN::AI::Agent::Solve::Control::Stopped => e
|
|
270
303
|
update(job, 'cancelled', error: e.message)
|
|
271
304
|
opts[:on_event]&.call(:warning, "#{agent} [#{id}] #{e.message}")
|
|
272
305
|
rescue StandardError => e
|
data/lib/pwn/version.rb
CHANGED
|
@@ -1081,7 +1081,8 @@ describe PWN::AI::Agent::Loop do # rubocop:disable Metrics/BlockLength
|
|
|
1081
1081
|
)
|
|
1082
1082
|
).to eq false
|
|
1083
1083
|
src = File.read(described_class.method(:run).source_location.first)
|
|
1084
|
-
expect(src).to match(/
|
|
1084
|
+
expect(src).to match(/skip_compass.*catalog/)
|
|
1085
|
+
expect(src).to match(/skip_plan.*skip_compass/)
|
|
1085
1086
|
expect(src).to match(/force_plan && cal_state\[:cal\] && !skip_plan/)
|
|
1086
1087
|
end
|
|
1087
1088
|
|