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
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'tmpdir'
|
|
6
|
+
require 'shellwords'
|
|
7
|
+
require 'stringio'
|
|
8
|
+
|
|
9
|
+
module PWN
|
|
10
|
+
module AI
|
|
11
|
+
module Agent
|
|
12
|
+
# Revision-bound, single-writer team coordinator. Role replies are proposals,
|
|
13
|
+
# never observations. Only host-executed checks can become evidence.
|
|
14
|
+
module Solve
|
|
15
|
+
ROLES = %w[protagonist antagonist verifier integrator].freeze
|
|
16
|
+
|
|
17
|
+
# Steering belongs to the objective, not to one child's replacement request.
|
|
18
|
+
class Control < Loop::Steering
|
|
19
|
+
class Stopped < Exception; end # rubocop:disable Lint/InheritException -- escape provider retry handlers
|
|
20
|
+
class Limit < Exception; end # rubocop:disable Lint/InheritException -- bounded role dispatch
|
|
21
|
+
|
|
22
|
+
def initialize(**args)
|
|
23
|
+
super
|
|
24
|
+
@solve_mutex = Mutex.new
|
|
25
|
+
@guidance = []
|
|
26
|
+
@paused = false
|
|
27
|
+
@stopped = false
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def submit(instruction)
|
|
31
|
+
return if instruction.to_s.strip.empty?
|
|
32
|
+
|
|
33
|
+
@solve_mutex.synchronize { @guidance << instruction.to_s.dup }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def guidance
|
|
37
|
+
@solve_mutex.synchronize { @guidance.dup }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def begin_turn
|
|
41
|
+
@model_calls = 0
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def model(messages:, &block)
|
|
45
|
+
check_stop
|
|
46
|
+
@model_calls = @model_calls.to_i + 1
|
|
47
|
+
raise Limit, 'Role model-call limit reached (25); objective remains incomplete.' if @model_calls > 25
|
|
48
|
+
|
|
49
|
+
super
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def stop
|
|
53
|
+
@solve_mutex.synchronize { @stopped = true }
|
|
54
|
+
request_model_cancel
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def pause(value)
|
|
58
|
+
@solve_mutex.synchronize { @paused = value }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def check_stop
|
|
62
|
+
loop do
|
|
63
|
+
stopped, paused = @solve_mutex.synchronize { [@stopped, @paused] }
|
|
64
|
+
raise Stopped, 'Solve cancelled at a safe boundary; completed work is not undone.' if stopped
|
|
65
|
+
break unless paused
|
|
66
|
+
|
|
67
|
+
sleep 0.05
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def checkpoint(**options)
|
|
72
|
+
@phase = options[:phase] || :boundary
|
|
73
|
+
check_stop
|
|
74
|
+
rescue Stopped
|
|
75
|
+
yield if block_given?
|
|
76
|
+
raise
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Serial role dispatch ensures there is exactly one model writer.
|
|
81
|
+
class Run # rubocop:disable Metrics/ClassLength -- one revision-bound coordinator lifecycle
|
|
82
|
+
def initialize(opts)
|
|
83
|
+
@opts = opts
|
|
84
|
+
@request = opts[:request].to_s.dup.freeze
|
|
85
|
+
raise ArgumentError, 'request is required' if @request.strip.empty?
|
|
86
|
+
|
|
87
|
+
@workspace = File.realpath(opts[:workspace] || Dir.pwd)
|
|
88
|
+
@rounds = Integer(opts.fetch(:rounds, 3))
|
|
89
|
+
raise ArgumentError, 'rounds must be between 1 and 10' unless @rounds.between?(1, 10)
|
|
90
|
+
|
|
91
|
+
@control = opts[:steering] || Control.new(input: StringIO.new, output: StringIO.new)
|
|
92
|
+
raise ArgumentError, 'solve requires Solve::Control for coordinator-wide steering' unless @control.is_a?(Control)
|
|
93
|
+
|
|
94
|
+
@requirements = { 'R0' => @request }
|
|
95
|
+
@states = ROLES.to_h { |role| [role, 'waiting'] }
|
|
96
|
+
@records = []
|
|
97
|
+
@evidence = []
|
|
98
|
+
@objections = []
|
|
99
|
+
@artifacts = {}
|
|
100
|
+
@revision = nil
|
|
101
|
+
@repair = nil
|
|
102
|
+
@guidance = []
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def run
|
|
106
|
+
prepare
|
|
107
|
+
assessment = turn('antagonist', 'assess')
|
|
108
|
+
Array(assessment.fetch(:requirements)).each_with_index { |text, i| @requirements["R#{i + 1}"] = text.to_s }
|
|
109
|
+
@assessment = assessment
|
|
110
|
+
@rounds.times do |index|
|
|
111
|
+
@round = index + 1
|
|
112
|
+
boundary
|
|
113
|
+
@guidance = guidance
|
|
114
|
+
@guidance.each_with_index { |instruction, i| @requirements["S#{i + 1}"] = instruction }
|
|
115
|
+
candidate = turn('protagonist', 'build')
|
|
116
|
+
@artifacts = observe(candidate.fetch(:artifacts))
|
|
117
|
+
raise ArgumentError, 'candidate must declare at least one existing artifact' if @artifacts.empty?
|
|
118
|
+
|
|
119
|
+
@revision = digest(run: @sid, round: @round, request: @request, requirements: @requirements, artifacts: @artifacts, guidance: @guidance)
|
|
120
|
+
@records << { role: 'coordinator', phase: 'candidate', revision: @revision, artifacts: @artifacts.dup, round: @round }
|
|
121
|
+
@candidate = candidate
|
|
122
|
+
critique = turn('antagonist', 'critique')
|
|
123
|
+
@objections = Array(critique.fetch(:objections))
|
|
124
|
+
verification = turn('verifier', 'verify')
|
|
125
|
+
@evidence = execute_checks(verification.fetch(:checks))
|
|
126
|
+
integration = turn('integrator', 'integrate')
|
|
127
|
+
boundary
|
|
128
|
+
return result('accepted', integration[:summary].to_s) if integration[:decision] == 'accept' && acceptable?
|
|
129
|
+
|
|
130
|
+
@repair = { instruction: integration[:repair], objections: @objections, evidence: @evidence,
|
|
131
|
+
stale: !current?, uncovered: uncovered }
|
|
132
|
+
end
|
|
133
|
+
result('incomplete', 'Round limit reached; unresolved, stale, or unverified requirements remain.')
|
|
134
|
+
rescue Control::Stopped => e
|
|
135
|
+
result('cancelled', e.message)
|
|
136
|
+
rescue Control::Limit => e
|
|
137
|
+
result('incomplete', e.message)
|
|
138
|
+
rescue StandardError => e
|
|
139
|
+
result('incomplete', "#{e.class}: #{e.message}")
|
|
140
|
+
ensure
|
|
141
|
+
@lock&.flock(File::LOCK_UN)
|
|
142
|
+
@lock&.close
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
private
|
|
146
|
+
|
|
147
|
+
def prepare
|
|
148
|
+
Registry.discover
|
|
149
|
+
# A file lock also excludes another process's solve writer in this workspace.
|
|
150
|
+
lock_path = File.join(Dir.tmpdir, "pwn-solve-#{Digest::SHA256.hexdigest(@workspace)}.lock")
|
|
151
|
+
@lock = File.open(lock_path, File::RDWR | File::CREAT, 0o600)
|
|
152
|
+
raise 'another solve owns this workspace' unless @lock.flock(File::LOCK_EX | File::LOCK_NB)
|
|
153
|
+
|
|
154
|
+
configured = Swarm.personas(swarm_id: @opts[:swarm_id])
|
|
155
|
+
@sid = Swarm.create(topic: @request)[:swarm_id]
|
|
156
|
+
@personas = ROLES.to_h do |role|
|
|
157
|
+
persona = configured[:"solve_#{role}"] || {}
|
|
158
|
+
engine = (persona[:engine] || Thread.current[:pwn_swarm_engine] || PWN::Env.dig(:ai, :active)).to_s
|
|
159
|
+
raise ArgumentError, "unsupported solve provider: #{engine}" unless Loop::ENGINE_MODS.key?(engine.to_sym)
|
|
160
|
+
|
|
161
|
+
provider = PWN::Env.dig(:ai, engine.to_sym) || {}
|
|
162
|
+
credentials = [provider[:key], provider.dig(:oauth, :bearer_token), provider.dig(:oauth, :refresh_token)]
|
|
163
|
+
raise ArgumentError, "solve_#{role}: configure #{engine} credentials first; solve never starts a login" if engine != 'ollama' && credentials.none? { |value| !value.to_s.strip.empty? && !value.to_s.match?(/\A(?:optional|required)\s*-/) }
|
|
164
|
+
|
|
165
|
+
[role, persona.merge(engine: engine, toolsets: role == 'protagonist' ? %w[terminal pwn] : [],
|
|
166
|
+
role: "#{persona[:role]}\nBuilt-in solve #{role}. Follow the structured handoff; original request is the sole objective.")]
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def turn(role, phase)
|
|
171
|
+
boundary
|
|
172
|
+
@control.begin_turn
|
|
173
|
+
@states[role] = phase
|
|
174
|
+
emit
|
|
175
|
+
payload = { phase: phase, role: role, original_request: @request, requirements: @requirements,
|
|
176
|
+
workspace: @workspace, round: @round, revision: @revision, guidance: guidance,
|
|
177
|
+
assessment: @assessment, candidate: @candidate, artifacts: artifact_text,
|
|
178
|
+
objections: @objections, evidence: @evidence, repair: @repair }
|
|
179
|
+
prior = Thread.current[:pwn_solve_tools]
|
|
180
|
+
Thread.current[:pwn_solve_tools] = role == 'protagonist' ? %w[shell pwn_eval] : []
|
|
181
|
+
reply = Swarm.ask(name: "solve_#{role}", persona: @personas.fetch(role), request: @request,
|
|
182
|
+
swarm_id: @sid, handoff: "#{contract(phase)}\nSOLVE HANDOFF\n#{JSON.generate(payload)}",
|
|
183
|
+
steering: @control, usage_observer: @opts[:usage_observer], on_tool: @opts[:on_tool])
|
|
184
|
+
row = JSON.parse(reply.fetch(:reply), symbolize_names: true)
|
|
185
|
+
raise ArgumentError, "#{role} handoff must be an object" unless row.is_a?(Hash)
|
|
186
|
+
raise ArgumentError, "#{role} returned a stale revision" if %w[critique verify integrate].include?(phase) && row[:revision] != @revision
|
|
187
|
+
|
|
188
|
+
@records << { role: role, phase: phase, revision: @revision, data: row }
|
|
189
|
+
@states[role] = 'done'
|
|
190
|
+
emit
|
|
191
|
+
row
|
|
192
|
+
ensure
|
|
193
|
+
Thread.current[:pwn_solve_tools] = prior
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def contract(phase)
|
|
197
|
+
shapes = {
|
|
198
|
+
'assess' => '{"requirements":["every atomic requirement in original request"],"risks":["independent risk assessment"]}',
|
|
199
|
+
'build' => '{"artifacts":["relative/file"],"summary":"what changed"}',
|
|
200
|
+
'critique' => '{"revision":"exact revision","objections":["unresolved correctness issue; empty only if none"]}',
|
|
201
|
+
'verify' => '{"revision":"exact revision","checks":[{"requirements":["R0","R1"],"command":"real test command","stdout":"exact expected nonempty stdout"}]}',
|
|
202
|
+
'integrate' => '{"revision":"exact revision","decision":"accept or repair","repair":"targeted repair for protagonist","summary":"integrated result"}'
|
|
203
|
+
}
|
|
204
|
+
"Return ONLY a JSON object matching #{shapes.fetch(phase)}. Do not replace the objective. " \
|
|
205
|
+
'Protagonist alone may change files. Review roles have no tool access. Verification commands run by the coordinator ' \
|
|
206
|
+
'in a disposable copy of declared artifacts, not the working tree; declare all test dependencies as artifacts. ' \
|
|
207
|
+
'Checks must test the named requirements, not print a success claim. R0 covers the ENTIRE original request. ' \
|
|
208
|
+
'Never accept missing evidence, unresolved objections, or omitted requirements. Integration requests repairs; it does not write.'
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def observe(paths)
|
|
212
|
+
Array(paths).to_h do |relative|
|
|
213
|
+
raise ArgumentError, 'artifact path must be relative' unless relative.is_a?(String) && !relative.start_with?('/')
|
|
214
|
+
|
|
215
|
+
path = File.realpath(File.join(@workspace, relative))
|
|
216
|
+
raise ArgumentError, "artifact outside workspace: #{relative}" unless path.start_with?("#{@workspace}/")
|
|
217
|
+
raise ArgumentError, "artifact must be a regular file: #{relative}" unless File.file?(path)
|
|
218
|
+
raise ArgumentError, "artifact symlink is not supported: #{relative}" unless path == File.expand_path(relative, @workspace)
|
|
219
|
+
|
|
220
|
+
[relative, Digest::SHA256.file(path).hexdigest]
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def artifact_text
|
|
225
|
+
@artifacts.to_h do |path, sha|
|
|
226
|
+
bytes = File.binread(File.join(@workspace, path))
|
|
227
|
+
raise ArgumentError, "artifact too large for review: #{path} (limit 128 KiB)" if bytes.bytesize > 131_072
|
|
228
|
+
|
|
229
|
+
[path, { sha256: sha, text: bytes.encode('UTF-8', invalid: :replace, undef: :replace) }]
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def execute_checks(checks)
|
|
234
|
+
raise ArgumentError, 'verifier must supply 1..32 checks' unless checks.is_a?(Array) && checks.length.between?(1, 32)
|
|
235
|
+
|
|
236
|
+
@states['verifier'] = 'checking'
|
|
237
|
+
emit
|
|
238
|
+
observations = checks.map do |check|
|
|
239
|
+
boundary
|
|
240
|
+
ids = Array(check[:requirements]).map(&:to_s)
|
|
241
|
+
raise ArgumentError, 'check must name known requirements' if ids.empty? || (ids - @requirements.keys).any?
|
|
242
|
+
raise ArgumentError, 'check needs a command and nonempty expected stdout' if check[:command].to_s.strip.empty? || check[:stdout].to_s.empty?
|
|
243
|
+
|
|
244
|
+
execute_check(check, ids)
|
|
245
|
+
end
|
|
246
|
+
@states['verifier'] = 'done'
|
|
247
|
+
@records << { role: 'verifier', phase: 'verification', revision: @revision, evidence: observations }
|
|
248
|
+
emit
|
|
249
|
+
observations
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def execute_check(check, ids)
|
|
253
|
+
Dir.mktmpdir('pwn-solve-verify') do |copy|
|
|
254
|
+
@artifacts.each_key do |path|
|
|
255
|
+
destination = File.expand_path(path, copy)
|
|
256
|
+
raise ArgumentError, 'invalid artifact copy path' unless destination.start_with?("#{copy}/")
|
|
257
|
+
|
|
258
|
+
FileUtils.mkdir_p(File.dirname(destination))
|
|
259
|
+
FileUtils.cp(File.join(@workspace, path), destination)
|
|
260
|
+
end
|
|
261
|
+
copied = @artifacts.to_h { |path, _sha| [path, Digest::SHA256.file(File.join(copy, path)).hexdigest] }
|
|
262
|
+
raise 'candidate changed while copying verification inputs' unless copied == @artifacts
|
|
263
|
+
|
|
264
|
+
prior = Thread.current[:pwn_solve_tools]
|
|
265
|
+
Thread.current[:pwn_solve_tools] = ['shell']
|
|
266
|
+
command = "cd #{Shellwords.escape(copy)} && (#{check[:command]})"
|
|
267
|
+
raw = Dispatch.call(tool_call: { id: SecureRandom.hex(8), function: { name: 'shell', arguments: JSON.generate(command: command, timeout: 60) } })
|
|
268
|
+
@opts[:on_tool]&.call('shell', { command: command }, raw)
|
|
269
|
+
response = JSON.parse(raw, symbolize_names: true)
|
|
270
|
+
observed = response[:result].is_a?(Hash) ? response[:result] : {}
|
|
271
|
+
unchanged = @artifacts.all? { |path, sha| File.file?(File.join(copy, path)) && Digest::SHA256.file(File.join(copy, path)).hexdigest == sha }
|
|
272
|
+
{ requirements: ids, revision: @revision, command: check[:command], stdout: observed[:stdout],
|
|
273
|
+
stderr: observed[:stderr], exitstatus: observed[:exit], raw: raw,
|
|
274
|
+
passed: response[:success] == true && observed[:exit].is_a?(Integer) && observed[:exit].zero? && observed[:stdout] == check[:stdout] && unchanged && current? }
|
|
275
|
+
ensure
|
|
276
|
+
Thread.current[:pwn_solve_tools] = prior
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def current?
|
|
281
|
+
guidance == @guidance && observe(@artifacts.keys) == @artifacts
|
|
282
|
+
rescue StandardError
|
|
283
|
+
false
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def uncovered
|
|
287
|
+
@requirements.keys - @evidence.select { |row| row[:passed] && row[:revision] == @revision }.flat_map { |row| row[:requirements] }
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def acceptable?
|
|
291
|
+
!@evidence.empty? && @evidence.all? { |row| row[:passed] } && uncovered.empty? && @objections.empty? && current?
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def digest(value)
|
|
295
|
+
Digest::SHA256.hexdigest(JSON.generate(value))
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def guidance
|
|
299
|
+
@control.respond_to?(:guidance) ? @control.guidance : []
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def boundary
|
|
303
|
+
@control&.check_stop
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def emit
|
|
307
|
+
@opts[:on_state]&.call({ roles: @states.dup, request: @request, requirements: @requirements.dup,
|
|
308
|
+
artifacts: @artifacts.dup, objections: @objections.dup, round: @round, revision: @revision,
|
|
309
|
+
routing: (@personas || {}).transform_values { |row| row.slice(:engine, :model) } })
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def result(status, summary)
|
|
313
|
+
@states.transform_values! { |state| %w[waiting done].include?(state) ? state : status }
|
|
314
|
+
emit
|
|
315
|
+
path = File.join(Swarm::SWARM_ROOT, @sid, 'solve.json') if @sid
|
|
316
|
+
report = ["#{status.upcase}: #{summary}", "Original request: #{@request}", "Revision: #{@revision || 'none'}",
|
|
317
|
+
"Artifacts: #{@artifacts.keys.join(', ')}", "Evidence coverage: #{(@requirements.keys - uncovered).join(', ')}",
|
|
318
|
+
"Unverified: #{uncovered.join(', ')}", "Open objections: #{@objections.join('; ')}", "Record: #{path}"].join("\n")
|
|
319
|
+
outcome = { ok: status == 'accepted', status: status, request: @request, swarm_id: @sid, revision: @revision,
|
|
320
|
+
requirements: @requirements, artifacts: @artifacts, objections: @objections, evidence: @evidence,
|
|
321
|
+
records: @records, uncovered: uncovered, reply: report, record_path: path }
|
|
322
|
+
File.write(path, JSON.pretty_generate(outcome), mode: 'w', perm: 0o600) if path
|
|
323
|
+
outcome
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
public_class_method def self.authors
|
|
328
|
+
'AUTHOR(S): 0day Inc. <support@0dayinc.com>'
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
public_class_method def self.help
|
|
332
|
+
puts "USAGE:
|
|
333
|
+
# Run a revision-bound team through the public Swarm solve entry point.
|
|
334
|
+
PWN::AI::Agent::Swarm.solve(request: 'required - original objective')
|
|
335
|
+
# Return module authors.
|
|
336
|
+
#{self}.authors
|
|
337
|
+
"
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
end
|
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
|
|