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
data/lib/pwn/ai/agent/swarm.rb
CHANGED
|
@@ -237,7 +237,7 @@ module PWN
|
|
|
237
237
|
prior_id = Thread.current[:pwn_swarm_id]
|
|
238
238
|
prior_honesty = Thread.current[:pwn_swarm_honesty]
|
|
239
239
|
sid = opts[:swarm_id] || create(topic: opts[:request].to_s[0, 60])[:swarm_id]
|
|
240
|
-
persona = personas(swarm_id: sid)[name.to_sym]
|
|
240
|
+
persona = opts[:persona] || personas(swarm_id: sid)[name.to_sym]
|
|
241
241
|
raise ArgumentError, "unknown persona: #{name} (see #{AGENTS_FILE})" unless persona
|
|
242
242
|
|
|
243
243
|
if opts[:unit].to_s != ''
|
|
@@ -256,6 +256,7 @@ module PWN
|
|
|
256
256
|
session_id = persona_session(swarm_id: sid, name: name)
|
|
257
257
|
sys = build_persona_prompt(name: name, persona: persona,
|
|
258
258
|
swarm_id: sid, session_id: session_id)
|
|
259
|
+
sys = "#{sys}\n#{opts[:handoff]}" if opts[:handoff]
|
|
259
260
|
|
|
260
261
|
empty_tools = opts[:text_only] == true || Array(persona[:toolsets]).empty?
|
|
261
262
|
Thread.current[:pwn_swarm_depth] = depth + 1
|
|
@@ -269,6 +270,7 @@ module PWN
|
|
|
269
270
|
enabled_toolsets: empty_tools ? [] : persona[:toolsets],
|
|
270
271
|
core_only: empty_tools ? false : true,
|
|
271
272
|
system_role_content: sys,
|
|
273
|
+
solve_handoff: !opts[:handoff].nil?,
|
|
272
274
|
on_tool: opts[:on_tool],
|
|
273
275
|
steering: opts[:steering],
|
|
274
276
|
nested: true
|
|
@@ -354,6 +356,30 @@ module PWN
|
|
|
354
356
|
{ swarm_id: sid, replies: replies }
|
|
355
357
|
end
|
|
356
358
|
|
|
359
|
+
# Run the built-in four-role workflow with host-observed verification.
|
|
360
|
+
# Supported Method Parameters::
|
|
361
|
+
# PWN::AI::Agent::Swarm.solve(
|
|
362
|
+
# request: 'required - original objective, preserved for every role',
|
|
363
|
+
# workspace: 'optional - artifact root, defaults to current directory',
|
|
364
|
+
# rounds: 'optional - bounded repair rounds, 1..10, default 3',
|
|
365
|
+
# swarm_id: 'optional - existing swarm registry',
|
|
366
|
+
# steering: 'optional - Solve::Control coordinator control',
|
|
367
|
+
# on_state: 'optional - observed role state callback',
|
|
368
|
+
# on_tool: 'optional - tool observation callback',
|
|
369
|
+
# usage_observer: 'optional - provider usage callback'
|
|
370
|
+
# )
|
|
371
|
+
public_class_method def self.solve(opts = {})
|
|
372
|
+
prior_id = Thread.current[:pwn_swarm_id]
|
|
373
|
+
prior_honesty = Thread.current[:pwn_swarm_honesty]
|
|
374
|
+
raise ArgumentError, 'request is required' if opts[:request].to_s.strip.empty?
|
|
375
|
+
|
|
376
|
+
Thread.current[:pwn_swarm_honesty] = nil
|
|
377
|
+
Solve::Run.new(opts).run
|
|
378
|
+
ensure
|
|
379
|
+
Thread.current[:pwn_swarm_id] = prior_id
|
|
380
|
+
Thread.current[:pwn_swarm_honesty] = prior_honesty
|
|
381
|
+
end
|
|
382
|
+
|
|
357
383
|
# ------------------------------------------------------------------
|
|
358
384
|
# privates
|
|
359
385
|
# ------------------------------------------------------------------
|
|
@@ -754,6 +780,8 @@ module PWN
|
|
|
754
780
|
on_tool: 'optional - ->(name, args, result) live-UI callback',
|
|
755
781
|
steering: 'optional - Loop::Steering for this persona turn',
|
|
756
782
|
usage_observer: 'optional - callable receiving provider usage',
|
|
783
|
+
persona: 'optional - explicit in-memory persona for the built-in coordinator',
|
|
784
|
+
handoff: 'optional - structured coordinator context, not a replacement request',
|
|
757
785
|
from: 'optional - sender account or address to bind as operator (defaults to caller_label)',
|
|
758
786
|
text_only: 'required - text only value consumed by #ask',
|
|
759
787
|
unit: 'optional - claim key (host+phase) before the child runs',
|
|
@@ -761,6 +789,18 @@ module PWN
|
|
|
761
789
|
engagement_id: 'optional - claim namespace (defaults to swarm_id)'
|
|
762
790
|
)
|
|
763
791
|
|
|
792
|
+
# Solve the unchanged objective with four roles and executed evidence.
|
|
793
|
+
#{self}.solve(
|
|
794
|
+
request: 'required - original objective preserved for every role',
|
|
795
|
+
workspace: 'optional - artifact root, defaults to current directory',
|
|
796
|
+
rounds: 'optional - repair round cap, 1..10, default 3',
|
|
797
|
+
swarm_id: 'optional - registry from which role overrides are inherited',
|
|
798
|
+
steering: 'optional - Solve::Control for coordinator steering/cancellation/pause',
|
|
799
|
+
on_state: 'optional - observed four-role state callback',
|
|
800
|
+
on_tool: 'optional - observed tool callback',
|
|
801
|
+
usage_observer: 'optional - provider usage callback'
|
|
802
|
+
)
|
|
803
|
+
|
|
764
804
|
# Run debate and return its result
|
|
765
805
|
#{self}.debate(
|
|
766
806
|
names: 'required - Array of persona names, order = speaking order',
|
data/lib/pwn/ai/agent.rb
CHANGED
|
@@ -36,6 +36,7 @@ module PWN
|
|
|
36
36
|
autoload :Extrospection, 'pwn/ai/agent/extrospection'
|
|
37
37
|
autoload :Reflect, 'pwn/ai/agent/reflect'
|
|
38
38
|
autoload :Swarm, 'pwn/ai/agent/swarm'
|
|
39
|
+
autoload :Solve, 'pwn/ai/agent/solve'
|
|
39
40
|
autoload :Reward, 'pwn/ai/agent/reward'
|
|
40
41
|
autoload :Verification, 'pwn/ai/agent/verification'
|
|
41
42
|
autoload :Curriculum, 'pwn/ai/agent/curriculum'
|
data/lib/pwn/ai/anthropic.rb
CHANGED
|
@@ -325,6 +325,7 @@ module PWN
|
|
|
325
325
|
# )
|
|
326
326
|
|
|
327
327
|
private_class_method def self.anthropic_rest_call(opts = {})
|
|
328
|
+
opts = opts.merge(non_interactive: true) if Thread.current[:pwn_solve_tools]
|
|
328
329
|
engine = PWN::Env[:ai][:anthropic] if defined?(PWN::Env)
|
|
329
330
|
raise 'ERROR: Anthropic Hash not found in PWN::Env. Run `pwn -Y default.yaml`, then `PWN::Env` for usage.' if engine.nil?
|
|
330
331
|
|
data/lib/pwn/ai/grok.rb
CHANGED
|
@@ -345,6 +345,7 @@ module PWN
|
|
|
345
345
|
# spinner: 'optional - display spinner (defaults to false)'
|
|
346
346
|
# )
|
|
347
347
|
private_class_method def self.grok_rest_call(opts = {})
|
|
348
|
+
opts = opts.merge(non_interactive: true) if Thread.current[:pwn_solve_tools]
|
|
348
349
|
engine = PWN::Env.dig(:ai, :grok) if defined?(PWN::Env)
|
|
349
350
|
raise 'ERROR: Grok Hash not found in PWN::Env. Run `pwn -Y default.yaml`, then `PWN::Env` for usage.' if engine.nil?
|
|
350
351
|
|
data/lib/pwn/ai/open_ai.rb
CHANGED
|
@@ -391,6 +391,7 @@ module PWN
|
|
|
391
391
|
# )
|
|
392
392
|
|
|
393
393
|
private_class_method def self.open_ai_rest_call(opts = {})
|
|
394
|
+
opts = opts.merge(non_interactive: true) if Thread.current[:pwn_solve_tools]
|
|
394
395
|
engine = PWN::Env[:ai][:openai] if defined?(PWN::Env)
|
|
395
396
|
raise 'ERROR: OpenAI Hash not found in PWN::Env. Run `pwn -Y default.yaml`, then `PWN::Env` for usage.' if engine.nil?
|
|
396
397
|
|
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'
|
|
@@ -610,7 +610,7 @@ module PWN
|
|
|
610
610
|
clear_view
|
|
611
611
|
elsif line.match?(%r{\A/verbose(?:\s|$)})
|
|
612
612
|
toggle_verbose(line)
|
|
613
|
-
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])
|
|
614
614
|
run_swarm(line)
|
|
615
615
|
else
|
|
616
616
|
add(:warning, 'Request running. Settings and new requests wait until idle. Use /steer, /input, /swarm status|steer|cancel, /clear, or /verbose.')
|
|
@@ -1062,7 +1062,7 @@ module PWN
|
|
|
1062
1062
|
usage = @usage.snapshot
|
|
1063
1063
|
cost = usage[:estimated_cost_usd] ? format('$%.4f est.', usage[:estimated_cost_usd]) : usage[:cost_status]
|
|
1064
1064
|
swarm = @swarm&.snapshot || {}
|
|
1065
|
-
active = Array(swarm[:jobs]).count { |job|
|
|
1065
|
+
active = Array(swarm[:jobs]).count { |job| AISwarm::Controller::ACTIVE.include?(job[:state]) }
|
|
1066
1066
|
['REQUEST', state, "Elapsed #{elapsed}", "Completed tools #{@completed_tools}",
|
|
1067
1067
|
"Events #{@event_count}", '', 'TOKENS', "#{usage[:input_tokens]} in · #{usage[:output_tokens]} out",
|
|
1068
1068
|
"cached #{usage[:cached_tokens]} · calls #{usage[:calls]}", "Cost #{cost}",
|
|
@@ -1559,6 +1559,10 @@ module PWN
|
|
|
1559
1559
|
when :home then view[:offset] = 0
|
|
1560
1560
|
when :end then view[:offset] = 1_000_000
|
|
1561
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
|
|
1562
1566
|
when 'a', 'b', 'd' then prepare_swarm_mission(key, item)
|
|
1563
1567
|
when 'c', "\u0003"
|
|
1564
1568
|
return unless view[:tab] == :jobs && item
|
|
@@ -1582,9 +1586,9 @@ module PWN
|
|
|
1582
1586
|
minimum = key == 'd' ? 2 : 1
|
|
1583
1587
|
return view[:notice] = "Select at least #{minimum} agent(s) with Space." if names.length < minimum
|
|
1584
1588
|
|
|
1585
|
-
command = { 'a' => '
|
|
1589
|
+
command = { 'a' => 'dm', 'b' => 'broadcast', 'd' => 'debate' }.fetch(key)
|
|
1586
1590
|
view[:confirm] = case command
|
|
1587
|
-
when '
|
|
1591
|
+
when 'dm' then [command, names.first, @editor.text]
|
|
1588
1592
|
when 'broadcast' then [command, '--names', names.join(','), '--', @editor.text]
|
|
1589
1593
|
else [command, names.join(','), '--', @editor.text]
|
|
1590
1594
|
end
|
|
@@ -1614,17 +1618,21 @@ module PWN
|
|
|
1614
1618
|
|
|
1615
1619
|
def execute_swarm_action
|
|
1616
1620
|
args = @workspace.delete(:confirm)
|
|
1617
|
-
|
|
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)
|
|
1618
1624
|
@workspace[:notice] = result[:error] || "#{args.first}: accepted#{" · job #{result[:job_id]}" if result[:job_id]}"
|
|
1619
1625
|
add(result[:ok] == false ? :warning : :notice, @workspace[:notice])
|
|
1620
1626
|
refresh_swarm if args.first == 'spawn'
|
|
1621
|
-
@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)
|
|
1622
1628
|
end
|
|
1623
1629
|
|
|
1624
1630
|
def swarm_content
|
|
1625
1631
|
view = @workspace
|
|
1626
1632
|
if view[:confirm]
|
|
1627
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
|
+
|
|
1628
1636
|
target = args.first == 'broadcast' ? args[2] : args[1]
|
|
1629
1637
|
return [[false, "ACTION: #{args.first}\nTARGET: #{target}\n#{args.first == 'cancel' ? 'Cancel at a safe boundary; completed work is not undone.' : args.last}"]]
|
|
1630
1638
|
end
|
|
@@ -1632,7 +1640,13 @@ module PWN
|
|
|
1632
1640
|
item = items[view[:index]]
|
|
1633
1641
|
rows = []
|
|
1634
1642
|
if view[:detail] && item
|
|
1635
|
-
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
|
|
1636
1650
|
elsif items.empty?
|
|
1637
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.']
|
|
1638
1652
|
else
|
|
@@ -1648,13 +1662,25 @@ module PWN
|
|
|
1648
1662
|
rows
|
|
1649
1663
|
end
|
|
1650
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
|
+
|
|
1651
1677
|
def draw_swarm
|
|
1652
1678
|
@screen.erase
|
|
1653
1679
|
view = @workspace
|
|
1654
1680
|
box(0, 0, @height - 1, @width - 1, "SWARM WORKSPACE · #{view[:tab].to_s.upcase}")
|
|
1655
1681
|
swarm_put(1, 'Tab roster/jobs · j/k move · Enter details', :footer)
|
|
1656
|
-
swarm_put(2, 'Space pick · a
|
|
1657
|
-
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)
|
|
1658
1684
|
swarm_put(4, 'PgUp/Dn Home/End scroll · Esc back', :footer)
|
|
1659
1685
|
rows = swarm_content.flat_map { |selected, text| wrap(swarm_safe(text), @width - 7).map { |row| [selected, row] } }
|
|
1660
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
|
|