pwn 0.5.680 → 0.5.682
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/.gitignore +1 -0
- data/documentation/AI-Integration.md +1 -1
- data/documentation/Agent-Tool-Registry.md +1 -1
- data/documentation/Configuration.md +3 -6
- data/documentation/How-PWN-Works.md +2 -2
- data/documentation/Reinforcement-Learning.md +1 -1
- data/documentation/diagrams/dot/task-summarizer.dot +3 -3
- data/documentation/pwn-ai-Agent.md +16 -35
- data/lib/pwn/ai/agent/loop.rb +230 -191
- data/lib/pwn/ai/agent/mistakes.rb +17 -0
- data/lib/pwn/ai/agent/policy.rb +55 -5
- data/lib/pwn/ai/agent/prompt_builder.rb +38 -12
- data/lib/pwn/ai/agent/registry.rb +17 -9
- data/lib/pwn/ai/agent/task_summarizer.rb +374 -503
- data/lib/pwn/config.rb +1 -1
- data/lib/pwn/version.rb +1 -1
- data/spec/integration/prompt_builder_spec.rb +6 -4
- data/spec/lib/pwn/ai/agent/loop_spec.rb +251 -33
- data/spec/lib/pwn/ai/agent/mistakes_spec.rb +14 -0
- data/spec/lib/pwn/ai/agent/policy_spec.rb +52 -1
- data/spec/lib/pwn/ai/agent/prompt_builder_spec.rb +4 -9
- data/spec/lib/pwn/ai/agent/registry_spec.rb +22 -2
- data/spec/lib/pwn/ai/agent/signal_hygiene_spec.rb +4 -5
- data/spec/lib/pwn/ai/agent/task_summarizer_spec.rb +321 -90
- data/third_party/pwn_rdoc.jsonl +24 -9
- metadata +1 -1
data/lib/pwn/ai/agent/loop.rb
CHANGED
|
@@ -34,6 +34,12 @@ module PWN
|
|
|
34
34
|
# PromptBuilder.mistakes_block re-injects the top open mistakes and
|
|
35
35
|
# top known fixes into the system prompt of every future turn.
|
|
36
36
|
#
|
|
37
|
+
# COMPLETION
|
|
38
|
+
# ----------
|
|
39
|
+
# The original request is the completion signal. TaskSummarizer and
|
|
40
|
+
# Policy are advisory (compass / rank). Loop keeps calling CORE_TOOLS
|
|
41
|
+
# until that request is done or truly blocked, then stops.
|
|
42
|
+
#
|
|
37
43
|
# LOCAL-MODEL SCAFFOLDING
|
|
38
44
|
# -----------------------
|
|
39
45
|
# When the active engine is :ollama (or the corresponding :agent flags
|
|
@@ -160,22 +166,31 @@ module PWN
|
|
|
160
166
|
|
|
161
167
|
private_class_method def self.maybe_park_budget_scars!
|
|
162
168
|
return unless defined?(Mistakes)
|
|
163
|
-
return if budget_exhaustion_hot?
|
|
164
169
|
return unless Mistakes.respond_to?(:park)
|
|
165
170
|
|
|
166
|
-
top = Mistakes.top(limit:
|
|
171
|
+
top = Mistakes.top(limit: 24, unresolved_only: true)
|
|
167
172
|
now = Time.now
|
|
168
|
-
top.
|
|
169
|
-
|
|
170
|
-
next if mistake[:parked]
|
|
171
|
-
|
|
173
|
+
budget = top.select { |mistake| budget_hit?(mistake: mistake) && !mistake[:parked] }
|
|
174
|
+
budget.each do |mistake|
|
|
172
175
|
stamp = mistake_ts(mistake: mistake)
|
|
173
|
-
# cool detector + scar older than PARK_COOL_SECS → park
|
|
174
176
|
next if stamp && (now - stamp) <= PARK_COOL_SECS
|
|
175
177
|
|
|
176
178
|
Mistakes.park(
|
|
177
179
|
signature: mistake[:signature].to_s,
|
|
178
|
-
reason: 'p17 rate-cool: outside PARK_COOL_SECS
|
|
180
|
+
reason: 'p17 rate-cool: outside PARK_COOL_SECS'
|
|
181
|
+
)
|
|
182
|
+
end
|
|
183
|
+
live = budget.reject do |mistake|
|
|
184
|
+
row = Mistakes.find(signature: mistake[:signature].to_s)
|
|
185
|
+
row.nil? || row[:parked]
|
|
186
|
+
end
|
|
187
|
+
return if live.length <= 1
|
|
188
|
+
|
|
189
|
+
# Never let 2+ budget scars latch hot forever. Keep only the newest.
|
|
190
|
+
live.sort_by { |mistake| mistake_ts(mistake: mistake) || Time.at(0) }[0...-1].each do |mistake|
|
|
191
|
+
Mistakes.park(
|
|
192
|
+
signature: mistake[:signature].to_s,
|
|
193
|
+
reason: 'p17 keep-newest budget scar; extras parked so tomorrow is not hot'
|
|
179
194
|
)
|
|
180
195
|
end
|
|
181
196
|
rescue StandardError
|
|
@@ -215,10 +230,9 @@ module PWN
|
|
|
215
230
|
end
|
|
216
231
|
|
|
217
232
|
# P17 — evidence-enough early final: latest tool rounds already answer
|
|
218
|
-
# the
|
|
219
|
-
#
|
|
220
|
-
#
|
|
221
|
-
# (mid-fix "write the complete final answer now" thrash).
|
|
233
|
+
# the original request → force synthesis. English tasks are an advisory
|
|
234
|
+
# compass only — an open verify tail must not block a finished ask.
|
|
235
|
+
# The original request is the completion signal.
|
|
222
236
|
private_class_method def self.evidence_enough_to_finalize?(opts = {})
|
|
223
237
|
messages = Array(opts[:messages])
|
|
224
238
|
turn_fails = opts[:turn_fails] || {}
|
|
@@ -226,30 +240,15 @@ module PWN
|
|
|
226
240
|
max_i = opts[:max_iters].to_i
|
|
227
241
|
request = opts[:request].to_s
|
|
228
242
|
return false if max_i <= 0 || iter < 2
|
|
229
|
-
# Need runway before the text-only strip, and no thrash.
|
|
230
243
|
return false if turn_fails['empty_final'].to_i.positive?
|
|
231
244
|
return false if turn_fails['incomplete_final'].to_i > 1
|
|
232
245
|
|
|
233
246
|
fail_n = turn_fails.values.sum
|
|
234
247
|
return false if fail_n >= 3
|
|
235
248
|
|
|
236
|
-
# English-task gate: multi-step plans only early-final on/after the
|
|
237
|
-
# last tangible task. plan_idx is 0-based; open work => not enough.
|
|
238
|
-
ts_state = opts[:ts_state]
|
|
239
|
-
if ts_state.is_a?(Hash)
|
|
240
|
-
plan = Array(ts_state[:plan])
|
|
241
|
-
if plan.length >= 2
|
|
242
|
-
idx = ts_state[:plan_idx].to_i
|
|
243
|
-
return false if idx < (plan.length - 1)
|
|
244
|
-
end
|
|
245
|
-
end
|
|
246
|
-
|
|
247
249
|
tools_ok = messages.select { |msg| msg[:role].to_s == 'tool' }
|
|
248
250
|
return false if tools_ok.size < 2
|
|
249
251
|
|
|
250
|
-
# Last two tool payloads should look like successful evidence, not errors.
|
|
251
|
-
# Agent tool wrappers always emit "success":true on ok — that alone is
|
|
252
|
-
# NOT proof the user goal is done (do not match bare success JSON).
|
|
253
252
|
last2 = tools_ok.last(2)
|
|
254
253
|
return false if last2.any? do |msg|
|
|
255
254
|
content = msg[:content].to_s
|
|
@@ -257,20 +256,19 @@ module PWN
|
|
|
257
256
|
!content.match?(/"success"\s*:\s*true/i)
|
|
258
257
|
end
|
|
259
258
|
|
|
260
|
-
# Prefer when plan was short / we already spent half the budget usefully.
|
|
261
259
|
plan_steps = opts[:plan_steps].to_i
|
|
262
260
|
short_plan = plan_steps.positive? && plan_steps <= 3
|
|
263
261
|
deep_enough = tools_ok.size >= 3 || (short_plan && tools_ok.size >= plan_steps)
|
|
264
262
|
return false unless deep_enough
|
|
265
263
|
|
|
266
264
|
recent_txt = last2.map { |msg| msg[:content].to_s[0, 500] }.join(' ')
|
|
267
|
-
# Goal-shaped completion only — write/patch/verify, not shell success wrappers.
|
|
268
265
|
mutation_done = recent_txt.match?(
|
|
269
|
-
/syntax ok|wrote |patched|
|
|
266
|
+
/syntax ok|wrote |patched|File\.write|ruby -c|0 offenses|examples?,\s*0 failures/i
|
|
270
267
|
)
|
|
271
268
|
return true if mutation_done
|
|
269
|
+
return true if request_path_evidenced?(request: request, blob: recent_txt)
|
|
272
270
|
return true if short_plan && tools_ok.size >= plan_steps && fail_n.zero? &&
|
|
273
|
-
request.match?(/\b(what|who|when|where|which|how many|status|list|show|print|uname|cwd|version)\b/i)
|
|
271
|
+
request.match?(/\b(what|who|when|where|which|how many|status|list|show|print|uname|cwd|version|hostname)\b/i)
|
|
274
272
|
|
|
275
273
|
false
|
|
276
274
|
rescue StandardError
|
|
@@ -312,6 +310,94 @@ module PWN
|
|
|
312
310
|
)
|
|
313
311
|
/ix
|
|
314
312
|
|
|
313
|
+
ACT_REQUEST_RX = /
|
|
314
|
+
\b(write|create|implement|fix|patch|replace|refactor|overwrite|
|
|
315
|
+
add (?:a |the )?|update|install|delete|remove|rename)\b
|
|
316
|
+
/ix
|
|
317
|
+
MUTATION_EVIDENCE_RX = /
|
|
318
|
+
printf\s|tee\s|sed\s+-i|ruby\s+-i|>\s|>>\s|file\.write|binwrite|
|
|
319
|
+
patched|wrote\s|syntax\sok|0\s+offenses|examples?,\s*0\s+failures
|
|
320
|
+
/ix
|
|
321
|
+
LOOKUP_REQUEST_RX = /
|
|
322
|
+
\b(what\s+is\s+my|hostname|uname|cwd|whoami|status|version|how\s+many)\b
|
|
323
|
+
/ix
|
|
324
|
+
HOST_PATH_RX = %r{(?:/|\./)[\w./-]+\.\w+}
|
|
325
|
+
|
|
326
|
+
# True only when the ask needs a live host/file effect. World-knowledge
|
|
327
|
+
# questions ("what color is a cherry") do not.
|
|
328
|
+
public_class_method def self.needs_host_work?(opts = {})
|
|
329
|
+
request = opts[:request].to_s
|
|
330
|
+
return false if request.strip.empty?
|
|
331
|
+
return true if request.match?(ACT_REQUEST_RX)
|
|
332
|
+
return true if request.match?(LOOKUP_REQUEST_RX)
|
|
333
|
+
return true if request.match?(HOST_PATH_RX)
|
|
334
|
+
|
|
335
|
+
false
|
|
336
|
+
rescue StandardError
|
|
337
|
+
false
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
# Short world-knowledge asks (no host/file work). Skip the planner LLM
|
|
341
|
+
# and do not bounce a text-only answer.
|
|
342
|
+
public_class_method def self.world_knowledge?(opts = {})
|
|
343
|
+
request = opts[:request].to_s.strip
|
|
344
|
+
return false if request.empty?
|
|
345
|
+
return false if needs_host_work?(request: request)
|
|
346
|
+
return false if request.length > 120
|
|
347
|
+
return false if request.match?(%r{\b(this\s+(?:host|machine|box|system|subnet|file|repo)|/opt/|implement|scan|hosts?)\b}i)
|
|
348
|
+
|
|
349
|
+
request.match?(/\A(?:what|why|who|when|where|which|how)\b/i)
|
|
350
|
+
rescue StandardError
|
|
351
|
+
false
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
# True when a text-only reply cannot yet be the original request.
|
|
355
|
+
# Distinct from English-task leftovers (advisory) and polite handoffs.
|
|
356
|
+
# Never bounce world-knowledge / no-host-work asks.
|
|
357
|
+
private_class_method def self.request_unsatisfied?(opts = {})
|
|
358
|
+
return false if opts[:last_iter]
|
|
359
|
+
return false if world_knowledge?(request: opts[:request])
|
|
360
|
+
return false unless needs_host_work?(request: opts[:request])
|
|
361
|
+
|
|
362
|
+
request = opts[:request].to_s
|
|
363
|
+
messages = Array(opts[:messages])
|
|
364
|
+
tools = messages.select { |msg| msg.is_a?(Hash) && msg[:role].to_s == 'tool' }
|
|
365
|
+
blob = +''
|
|
366
|
+
tools.each do |msg|
|
|
367
|
+
blob << msg[:name].to_s << ' ' << msg[:content].to_s << "\n"
|
|
368
|
+
end
|
|
369
|
+
messages.each do |msg|
|
|
370
|
+
next unless msg.is_a?(Hash) && msg[:role].to_s == 'assistant'
|
|
371
|
+
|
|
372
|
+
Array(msg[:tool_calls]).each do |tc|
|
|
373
|
+
blob << tc.dig(:function, :name).to_s << ' '
|
|
374
|
+
blob << tc.dig(:function, :arguments).to_s << "\n"
|
|
375
|
+
end
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
return false if request.match?(LOOKUP_REQUEST_RX) && tools.any? && blob.length >= 20
|
|
379
|
+
return true if tools.empty?
|
|
380
|
+
return false if blob.match?(MUTATION_EVIDENCE_RX)
|
|
381
|
+
return false if request_path_evidenced?(request: request, blob: blob)
|
|
382
|
+
|
|
383
|
+
true
|
|
384
|
+
rescue StandardError
|
|
385
|
+
false
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
private_class_method def self.request_path_evidenced?(opts = {})
|
|
389
|
+
request = opts[:request].to_s
|
|
390
|
+
blob = opts[:blob].to_s
|
|
391
|
+
paths = request.scan(%r{(?:/|\./)[\w./-]+\.\w+})
|
|
392
|
+
return false if paths.empty?
|
|
393
|
+
|
|
394
|
+
paths.any? do |path|
|
|
395
|
+
blob.include?(path) && blob.match?(
|
|
396
|
+
/open\(|File\.(?:write|open|binwrite)|write\(|puts\s|print\s|>\s|>>\s|tee\s|sed\s+-i|ruby\s+-i|patched|wrote/i
|
|
397
|
+
)
|
|
398
|
+
end
|
|
399
|
+
end
|
|
400
|
+
|
|
315
401
|
private_class_method def self.incomplete_final?(opts = {})
|
|
316
402
|
text = opts[:text].to_s
|
|
317
403
|
return false if text.strip.empty?
|
|
@@ -999,18 +1085,11 @@ module PWN
|
|
|
999
1085
|
if env_tc && !env_tc.to_s.empty?
|
|
1000
1086
|
cwt_opts[:tool_choice] = env_tc
|
|
1001
1087
|
else
|
|
1002
|
-
#
|
|
1003
|
-
#
|
|
1004
|
-
# monologue as content. Stay on required until a tool result
|
|
1005
|
-
# exists AND the last assistant turn already looks like a
|
|
1006
|
-
# genuine final (no monologue / handoff markers). That keeps
|
|
1007
|
-
# pressure on native tool_calls through the mid-loop thrash
|
|
1008
|
-
# that previously returned "Wait, let's try hping3…" as FINAL.
|
|
1088
|
+
# After the first tool result, auto so the model can emit a
|
|
1089
|
+
# real final. Leftover English tasks do not keep required.
|
|
1009
1090
|
has_tool_result = Array(messages).any? { |m| m[:role].to_s == 'tool' }
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
still_acting = last_txt.strip.empty? || incomplete_final?(text: last_txt, last_iter: false)
|
|
1013
|
-
cwt_opts[:tool_choice] = has_tool_result && !still_acting ? 'auto' : 'required'
|
|
1091
|
+
need_tools = needs_host_work?(request: Array(messages).find { |m| m[:role].to_s == 'user' }&.[](:content))
|
|
1092
|
+
cwt_opts[:tool_choice] = has_tool_result || !need_tools ? 'auto' : 'required'
|
|
1014
1093
|
end
|
|
1015
1094
|
end
|
|
1016
1095
|
response = mod.chat_with_tools(cwt_opts)
|
|
@@ -1063,15 +1142,9 @@ module PWN
|
|
|
1063
1142
|
# 3.2 — local models cannot afford auto_introspect (judge+prm+critic+
|
|
1064
1143
|
# sentinel+extro) on every success. Default :failure_only when local.
|
|
1065
1144
|
private_class_method def self.should_auto_introspect?(opts = {})
|
|
1066
|
-
kind = (opts[:kind] || Thread.current[:pwn_request_kind]).to_s.to_sym
|
|
1067
1145
|
intent = (opts[:intent] || Thread.current[:pwn_request_intent]).to_s.to_sym
|
|
1068
|
-
|
|
1069
|
-
# Cheap answers already returned user-visible text. The post-answer
|
|
1070
|
-
# critic + 12s ORM printed ERROR: Timed out reading data from server
|
|
1071
|
-
# after greetings / takes / questions.
|
|
1146
|
+
# Cheap answers already returned user-visible text.
|
|
1072
1147
|
return false if %i[greeting howto recall].include?(intent)
|
|
1073
|
-
return false if kind == :statement
|
|
1074
|
-
return false if kind == :question && fails.zero?
|
|
1075
1148
|
|
|
1076
1149
|
return true unless opts[:local]
|
|
1077
1150
|
|
|
@@ -1116,9 +1189,13 @@ module PWN
|
|
|
1116
1189
|
messages = opts[:messages]
|
|
1117
1190
|
return nil unless state.is_a?(Hash) && messages.is_a?(Array)
|
|
1118
1191
|
return nil unless defined?(TaskSummarizer) && TaskSummarizer.enabled?
|
|
1192
|
+
return nil if respond_to?(:needs_host_work?) && !needs_host_work?(request: opts[:request] || state[:original_request] || state[:request])
|
|
1193
|
+
return nil unless TaskSummarizer.plan_open?(state: state, messages: messages)
|
|
1119
1194
|
|
|
1195
|
+
req = opts[:request]
|
|
1196
|
+
req = state[:original_request] || state[:request] if req.to_s.strip.empty? && state.is_a?(Hash)
|
|
1120
1197
|
text =
|
|
1121
|
-
(TaskSummarizer.active_task_prompt(state: state, force: opts[:force]) if TaskSummarizer.respond_to?(:active_task_prompt))
|
|
1198
|
+
(TaskSummarizer.active_task_prompt(state: state, force: opts[:force], request: req) if TaskSummarizer.respond_to?(:active_task_prompt))
|
|
1122
1199
|
return nil if text.to_s.strip.empty?
|
|
1123
1200
|
|
|
1124
1201
|
messages << { role: 'user', content: text }
|
|
@@ -1335,55 +1412,6 @@ module PWN
|
|
|
1335
1412
|
:act
|
|
1336
1413
|
end
|
|
1337
1414
|
|
|
1338
|
-
# Top-level request kind for task planning (statement | question | autonomous_goal).
|
|
1339
|
-
# Single source of truth: TaskSummarizer.request_kind (LLM + heuristics).
|
|
1340
|
-
# Mirrors intent/heuristics only when TaskSummarizer is unavailable.
|
|
1341
|
-
#
|
|
1342
|
-
# Supported Method Parameters::
|
|
1343
|
-
# kind = PWN::AI::Agent::Loop.request_kind(
|
|
1344
|
-
# request: 'required - user text',
|
|
1345
|
-
# kind: 'optional - precomputed',
|
|
1346
|
-
# llm_kind: 'optional - injected LLM label',
|
|
1347
|
-
# heuristic_only: 'optional - skip LLM'
|
|
1348
|
-
# )
|
|
1349
|
-
public_class_method def self.request_kind(opts = {})
|
|
1350
|
-
req = opts[:request].to_s
|
|
1351
|
-
if defined?(TaskSummarizer) && TaskSummarizer.respond_to?(:request_kind)
|
|
1352
|
-
return TaskSummarizer.request_kind(
|
|
1353
|
-
request: req,
|
|
1354
|
-
kind: opts[:kind],
|
|
1355
|
-
llm_kind: opts[:llm_kind],
|
|
1356
|
-
heuristic_only: opts[:heuristic_only]
|
|
1357
|
-
)
|
|
1358
|
-
end
|
|
1359
|
-
|
|
1360
|
-
case request_intent(request: req)
|
|
1361
|
-
when :greeting, :empty
|
|
1362
|
-
:statement
|
|
1363
|
-
when :howto, :recall
|
|
1364
|
-
:question
|
|
1365
|
-
when :recon_act
|
|
1366
|
-
:autonomous_goal
|
|
1367
|
-
else
|
|
1368
|
-
# :act — distinguish bare questions from work the agent must do.
|
|
1369
|
-
# Host-local facts need tools → autonomous_goal.
|
|
1370
|
-
if defined?(TaskSummarizer) && TaskSummarizer.const_defined?(:NEEDS_LOCAL_EVIDENCE_RX)
|
|
1371
|
-
return :autonomous_goal if req.match?(TaskSummarizer::NEEDS_LOCAL_EVIDENCE_RX)
|
|
1372
|
-
elsif req.match?(/\b(?:hostname|whoami|\bcwd\b|\bpwd\b|my\s+ip)\b/i)
|
|
1373
|
-
return :autonomous_goal
|
|
1374
|
-
end
|
|
1375
|
-
return :question if req.match?(/\?\s*\z/) && !req.match?(
|
|
1376
|
-
/\b(please|implement|fix|patch|refactor|run|scan|find|write|change)\b/i
|
|
1377
|
-
)
|
|
1378
|
-
return :question if req.match?(/\A\s*(?:what|why|when|where|who|which|how)\b/i) &&
|
|
1379
|
-
!req.match?(/\b(please|implement|fix|patch|run|scan)\b/i)
|
|
1380
|
-
|
|
1381
|
-
:autonomous_goal
|
|
1382
|
-
end
|
|
1383
|
-
rescue StandardError
|
|
1384
|
-
:autonomous_goal
|
|
1385
|
-
end
|
|
1386
|
-
|
|
1387
1415
|
public_class_method def self.recon_authorized?(opts = {})
|
|
1388
1416
|
req = opts[:request].to_s
|
|
1389
1417
|
return true if req.match?(AUTH_SCOPE_RX)
|
|
@@ -1913,73 +1941,76 @@ module PWN
|
|
|
1913
1941
|
session_id = opts[:session_id]
|
|
1914
1942
|
on_tool = opts[:on_tool]
|
|
1915
1943
|
TurnFinalizer.enter_user_path! if defined?(TurnFinalizer)
|
|
1916
|
-
# Live coalesced "what am I doing" lines for the TUI (not a model tool).
|
|
1917
|
-
ts_state = (TaskSummarizer.fresh(request: request) if defined?(TaskSummarizer) && TaskSummarizer.enabled? && Thread.current[:pwn_reflect_depth].to_i.zero?)
|
|
1918
1944
|
engine = active_engine
|
|
1919
1945
|
local = local_engine?(engine: engine)
|
|
1920
|
-
system_role_content = opts[:system_role_content] ||= PWN::AI::Agent::PromptBuilder.build(session_id: session_id, request: request)
|
|
1921
|
-
|
|
1922
|
-
Registry.discover
|
|
1923
|
-
maybe_refresh_extro_snapshot!
|
|
1924
|
-
opts[:enabled_toolsets] = default_interactive_toolsets(request: request) unless opts.key?(:enabled_toolsets)
|
|
1925
|
-
expose_current_session(session_id: session_id)
|
|
1926
|
-
Mistakes.check_user_correction(request: request, session_id: session_id) if defined?(Mistakes)
|
|
1927
1946
|
|
|
1947
|
+
# Cheap intent/kind FIRST - before PromptBuilder / Registry / TaskSummarizer
|
|
1948
|
+
# so greetings, FYIs, how-tos, recall, and simple Qs never pay the fat path.
|
|
1928
1949
|
intent = request_intent(request: request)
|
|
1929
|
-
kind = request_kind(request: request)
|
|
1930
1950
|
Thread.current[:pwn_request_intent] = intent
|
|
1931
|
-
Thread.current[:pwn_request_kind] = kind
|
|
1932
1951
|
Thread.current[:pwn_recon_authorized] = recon_authorized?(request: request)
|
|
1933
1952
|
Thread.current[:pwn_extinguished] = {}
|
|
1934
|
-
|
|
1953
|
+
expose_current_session(session_id: session_id)
|
|
1954
|
+
Mistakes.check_user_correction(request: request, session_id: session_id) if defined?(Mistakes)
|
|
1955
|
+
|
|
1956
|
+
cheap = opts[:force_tools] != true && %i[greeting howto recall].include?(intent)
|
|
1957
|
+
|
|
1958
|
+
# Greeting / light smalltalk: deterministic ack - no weather echo, no tools,
|
|
1959
|
+
# no PromptBuilder, no Registry.
|
|
1935
1960
|
if intent == :greeting && opts[:force_tools] != true
|
|
1936
1961
|
return answer_greeting(
|
|
1937
1962
|
request: request,
|
|
1938
1963
|
session_id: session_id
|
|
1939
1964
|
)
|
|
1940
1965
|
end
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
system_role_content
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
return answer_question(
|
|
1970
|
-
request: request,
|
|
1971
|
-
session_id: session_id,
|
|
1972
|
-
system_role_content: system_role_content
|
|
1973
|
-
)
|
|
1966
|
+
|
|
1967
|
+
# Thin system prompt only for remaining cheap paths (howto/recall/statement/question).
|
|
1968
|
+
if cheap
|
|
1969
|
+
system_role_content = opts[:system_role_content]
|
|
1970
|
+
if system_role_content.nil? || system_role_content.to_s.empty?
|
|
1971
|
+
system_role_content = PWN::AI::Agent::PromptBuilder.build(
|
|
1972
|
+
session_id: session_id,
|
|
1973
|
+
request: request,
|
|
1974
|
+
thin: true
|
|
1975
|
+
)
|
|
1976
|
+
opts[:system_role_content] = system_role_content
|
|
1977
|
+
end
|
|
1978
|
+
# How-to: never enter plan_first / task recon / tool thrash.
|
|
1979
|
+
if intent == :howto
|
|
1980
|
+
return answer_howto(
|
|
1981
|
+
request: request,
|
|
1982
|
+
session_id: session_id,
|
|
1983
|
+
system_role_content: system_role_content
|
|
1984
|
+
)
|
|
1985
|
+
end
|
|
1986
|
+
# Pure prior-turn / vague memory recall: one cheap path, no plan_first.
|
|
1987
|
+
if intent == :recall
|
|
1988
|
+
return answer_recall(
|
|
1989
|
+
request: request,
|
|
1990
|
+
session_id: session_id,
|
|
1991
|
+
system_role_content: system_role_content
|
|
1992
|
+
)
|
|
1993
|
+
end
|
|
1974
1994
|
end
|
|
1975
1995
|
|
|
1996
|
+
# --- act / recon / autonomous_goal: full context + tools ---
|
|
1997
|
+
# Reuse precomputed kind so TaskSummarizer.fresh does not classify twice.
|
|
1998
|
+
ts_state = (TaskSummarizer.fresh(request: request) if defined?(TaskSummarizer) && TaskSummarizer.enabled? && Thread.current[:pwn_reflect_depth].to_i.zero?)
|
|
1999
|
+
system_role_content = opts[:system_role_content] ||= PWN::AI::Agent::PromptBuilder.build(
|
|
2000
|
+
session_id: session_id,
|
|
2001
|
+
request: request
|
|
2002
|
+
)
|
|
2003
|
+
|
|
2004
|
+
Registry.discover
|
|
2005
|
+
maybe_refresh_extro_snapshot!
|
|
2006
|
+
opts[:enabled_toolsets] = default_interactive_toolsets(request: request) unless opts.key?(:enabled_toolsets)
|
|
2007
|
+
|
|
1976
2008
|
# R5 — open the live MDP episode BEFORE the first Registry.rank so
|
|
1977
2009
|
# Q(s,a) can advise this turn. Planning still owns the task list.
|
|
1978
2010
|
if defined?(PWN::AI::Agent::Policy) && Policy.respond_to?(:begin_episode)
|
|
1979
2011
|
Policy.begin_episode(
|
|
1980
2012
|
session_id: session_id,
|
|
1981
2013
|
request: request,
|
|
1982
|
-
kind: kind,
|
|
1983
2014
|
intent: intent,
|
|
1984
2015
|
engine: engine,
|
|
1985
2016
|
ts_state: ts_state
|
|
@@ -1990,53 +2021,43 @@ module PWN
|
|
|
1990
2021
|
# TaskSummarizer.emit_plan! we re-rank using English tangible tasks
|
|
1991
2022
|
# so generated tasks — not the bare request — drive which tools
|
|
1992
2023
|
# the model may call.
|
|
1993
|
-
|
|
2024
|
+
# CORE_TOOLS is the default action space. Extra schemas are
|
|
2025
|
+
# opt-in via enabled_toolsets + core_only: false.
|
|
2026
|
+
core_only = opts.fetch(:core_only, true)
|
|
2027
|
+
tools = Registry.definitions(
|
|
2028
|
+
enabled: opts[:enabled_toolsets],
|
|
2029
|
+
relevance: request,
|
|
2030
|
+
core_only: core_only,
|
|
2031
|
+
intent: intent
|
|
2032
|
+
)
|
|
1994
2033
|
messages = [{ role: 'system', content: system_role_content }]
|
|
1995
2034
|
messages.concat(Learning.exemplars_for(request: request)) if local && defined?(Learning) && Learning.respond_to?(:exemplars_for)
|
|
1996
2035
|
messages << { role: 'user', content: request }
|
|
1997
2036
|
append_session(session_id: session_id, role: 'user', content: request)
|
|
1998
2037
|
|
|
1999
|
-
#
|
|
2000
|
-
|
|
2001
|
-
needs_breakdown =
|
|
2002
|
-
if defined?(TaskSummarizer) && TaskSummarizer.respond_to?(:needs_task_breakdown?)
|
|
2003
|
-
TaskSummarizer.needs_task_breakdown?(kind: kind, request: request)
|
|
2004
|
-
else
|
|
2005
|
-
kind.to_sym == :autonomous_goal
|
|
2006
|
-
end
|
|
2007
|
-
ts_state[:request_kind] = kind if ts_state.is_a?(Hash)
|
|
2008
|
-
if needs_breakdown
|
|
2009
|
-
task_summary_plan!(state: ts_state, request: request, on_tool: on_tool)
|
|
2010
|
-
elsif ts_state.is_a?(Hash) && defined?(TaskSummarizer)
|
|
2011
|
-
# Record kind on state; optional one-line kind banner (no task list).
|
|
2012
|
-
ts_state[:plan] = []
|
|
2013
|
-
ts_state[:request_kind] = kind
|
|
2014
|
-
if TaskSummarizer.respond_to?(:format_plan)
|
|
2015
|
-
banner = TaskSummarizer.format_plan(tasks: [], request: request, request_kind: kind)
|
|
2016
|
-
if banner && !banner.to_s.empty?
|
|
2017
|
-
ts_state[:plan_text] = banner
|
|
2018
|
-
ts_state[:plan_emitted] = true
|
|
2019
|
-
emit_task_summary(line: banner, on_tool: on_tool)
|
|
2020
|
-
end
|
|
2021
|
-
end
|
|
2022
|
-
end
|
|
2038
|
+
# Every request gets a task compass.
|
|
2039
|
+
task_summary_plan!(state: ts_state, request: request, on_tool: on_tool) if defined?(TaskSummarizer)
|
|
2023
2040
|
# Re-bind tools from English plan so task list is the sole driver of
|
|
2024
2041
|
# tool exposure/ranking (Registry keyword router + CORE).
|
|
2025
2042
|
if ts_state.is_a?(Hash) && defined?(TaskSummarizer) && TaskSummarizer.respond_to?(:relevance_query)
|
|
2026
2043
|
rq = TaskSummarizer.relevance_query(state: ts_state, request: request)
|
|
2027
|
-
|
|
2044
|
+
unless rq.to_s.strip.empty?
|
|
2045
|
+
tools = Registry.definitions(
|
|
2046
|
+
enabled: opts[:enabled_toolsets],
|
|
2047
|
+
relevance: rq,
|
|
2048
|
+
core_only: core_only,
|
|
2049
|
+
intent: intent
|
|
2050
|
+
)
|
|
2051
|
+
end
|
|
2028
2052
|
end
|
|
2029
2053
|
# English-task-as-primary: inject tangible tasks only for autonomous goals.
|
|
2030
|
-
inject_task_focus!(messages: messages, state: ts_state, force: true
|
|
2031
|
-
|
|
2054
|
+
inject_task_focus!(messages: messages, state: ts_state, force: true, request: request)
|
|
2032
2055
|
predicted = nil
|
|
2033
2056
|
Thread.current[:pwn_plan_predicted] = nil
|
|
2034
2057
|
cal_state = calibration_state
|
|
2035
2058
|
force_plan = cal_state[:force_plan]
|
|
2036
|
-
# Skip plan_first for
|
|
2037
|
-
skip_plan = %i[howto recall greeting].include?(intent)
|
|
2038
|
-
%i[statement question].include?(kind.to_sym) ||
|
|
2039
|
-
!needs_breakdown
|
|
2059
|
+
# Skip plan_first only for remaining cheap intents.
|
|
2060
|
+
skip_plan = %i[howto recall greeting].include?(intent)
|
|
2040
2061
|
if !skip_plan && (force_plan || agent_flag(key: :plan_first, default: local) || budget_exhaustion_hot?) && !Array(tools).empty?
|
|
2041
2062
|
predicted = plan_first(messages: messages, request: request, ts_state: ts_state)
|
|
2042
2063
|
# P22 — prefer explicit return; fall back to thread stash
|
|
@@ -2046,12 +2067,21 @@ module PWN
|
|
|
2046
2067
|
# PLAN: tool-call scaffold jargon (unify_plan! refuses that).
|
|
2047
2068
|
if ts_state.is_a?(Hash) && defined?(TaskSummarizer) && TaskSummarizer.respond_to?(:relevance_query)
|
|
2048
2069
|
rq = TaskSummarizer.relevance_query(state: ts_state, request: request)
|
|
2049
|
-
|
|
2070
|
+
unless rq.to_s.strip.empty?
|
|
2071
|
+
tools = Registry.definitions(
|
|
2072
|
+
enabled: opts[:enabled_toolsets],
|
|
2073
|
+
relevance: rq,
|
|
2074
|
+
core_only: core_only,
|
|
2075
|
+
intent: intent
|
|
2076
|
+
)
|
|
2077
|
+
end
|
|
2050
2078
|
end
|
|
2051
|
-
inject_task_focus!(messages: messages, state: ts_state, force: true)
|
|
2079
|
+
inject_task_focus!(messages: messages, state: ts_state, force: true, request: request)
|
|
2052
2080
|
end
|
|
2053
2081
|
if budget_exhaustion_hot?
|
|
2054
|
-
|
|
2082
|
+
english_open = defined?(TaskSummarizer) && TaskSummarizer.respond_to?(:plan_open?) &&
|
|
2083
|
+
TaskSummarizer.plan_open?(state: ts_state, messages: messages)
|
|
2084
|
+
hot_hint = if local_engine? && !english_open
|
|
2055
2085
|
'[pwn-ai/p17] Budget-exhaustion is the top open failure on this host. ' \
|
|
2056
2086
|
'Prefer the SHORTEST plan that finishes the ask (≤3 tool calls). ' \
|
|
2057
2087
|
'Emit a final answer as soon as you have evidence — do not explore.'
|
|
@@ -2083,7 +2113,7 @@ module PWN
|
|
|
2083
2113
|
compact_history!(messages: messages) if local
|
|
2084
2114
|
# English-task-as-primary: when plan_idx advanced, tell the model
|
|
2085
2115
|
# which plain-English task is active before the next tool batch.
|
|
2086
|
-
inject_task_focus!(messages: messages, state: ts_state)
|
|
2116
|
+
inject_task_focus!(messages: messages, state: ts_state, request: request)
|
|
2087
2117
|
|
|
2088
2118
|
# P17 — on the final iteration, strip tools and demand a plain-text
|
|
2089
2119
|
# answer. Without this the model happily emits one more tool_calls
|
|
@@ -2116,15 +2146,9 @@ module PWN
|
|
|
2116
2146
|
plan_faithful = hot && plan_steps.positive? && plan_steps <= plan_step_limit &&
|
|
2117
2147
|
turn_fails['empty_final'].to_i.zero? &&
|
|
2118
2148
|
turn_fails.values.sum < 2
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
else
|
|
2123
|
-
(local_engine? ? 3 : 2)
|
|
2124
|
-
end
|
|
2125
|
-
else
|
|
2126
|
-
1
|
|
2127
|
-
end
|
|
2149
|
+
# Last-iter strips tools only on the true last slot. English
|
|
2150
|
+
# leftovers and budget-hot must not steal runway from a live goal.
|
|
2151
|
+
text_only_iters = 1
|
|
2128
2152
|
last_iter = (i >= max_iters - text_only_iters)
|
|
2129
2153
|
if last_iter
|
|
2130
2154
|
tag = i >= max_iters - 1 ? 'FINAL ITERATION' : 'PENULTIMATE — wrap up'
|
|
@@ -2138,7 +2162,7 @@ module PWN
|
|
|
2138
2162
|
}
|
|
2139
2163
|
end
|
|
2140
2164
|
|
|
2141
|
-
msg = call_engine(messages: messages, tools: last_iter ? nil : tools)
|
|
2165
|
+
msg = call_engine(messages: messages, tools: last_iter ? nil : tools, ts_state: ts_state)
|
|
2142
2166
|
if msg.nil?
|
|
2143
2167
|
task_summary_flush!(state: ts_state, on_tool: on_tool)
|
|
2144
2168
|
return '[pwn-ai] engine returned no message'
|
|
@@ -2195,6 +2219,21 @@ module PWN
|
|
|
2195
2219
|
}
|
|
2196
2220
|
next
|
|
2197
2221
|
end
|
|
2222
|
+
if request_unsatisfied?(
|
|
2223
|
+
request: request,
|
|
2224
|
+
messages: messages,
|
|
2225
|
+
last_iter: last_iter
|
|
2226
|
+
) && turn_fails['unsatisfied'].to_i < 4
|
|
2227
|
+
turn_fails['unsatisfied'] += 1
|
|
2228
|
+
warn "[pwn-ai/loop] original request not evidenced on iter=#{i}; continuing"
|
|
2229
|
+
messages << {
|
|
2230
|
+
role: 'user',
|
|
2231
|
+
content: '[pwn-ai] The original request is not evidenced yet. ' \
|
|
2232
|
+
'Keep calling CORE_TOOLS (shell, pwn_eval) until that request is ' \
|
|
2233
|
+
'done or truly blocked. Do not declare completion from a listing alone.'
|
|
2234
|
+
}
|
|
2235
|
+
next
|
|
2236
|
+
end
|
|
2198
2237
|
append_session(session_id: session_id, role: 'assistant', content: text)
|
|
2199
2238
|
Learning.auto_introspect(session_id: session_id, request: request, final: text, predicted: predicted, plan: ts_state && ts_state[:plan], ts_state: ts_state) if defined?(Learning) && should_auto_introspect?(local: local, turn_fails: turn_fails, iter: i)
|
|
2200
2239
|
maybe_finish_policy(session_id: session_id, proxy_ok: true, ts_state: ts_state)
|
|
@@ -500,6 +500,7 @@ module PWN
|
|
|
500
500
|
limit = opts[:limit] || 6
|
|
501
501
|
request = opts[:request].to_s
|
|
502
502
|
open_rows = top(limit: limit * 3, unresolved_only: true)
|
|
503
|
+
open_rows = open_rows.reject { |m| budget_scar?(mistake: m) } unless request.match?(/budget|iterat|exhaust|\bagent.?loop\b/i)
|
|
503
504
|
# 2.6 — request-conditioned rank (sim × recency × count), same idea
|
|
504
505
|
# as exemplars_for. Stops injecting loudest scar (reward_signal ×13)
|
|
505
506
|
# on every unrelated turn.
|
|
@@ -534,6 +535,21 @@ module PWN
|
|
|
534
535
|
"#{out}\n"
|
|
535
536
|
end
|
|
536
537
|
|
|
538
|
+
private_class_method def self.budget_scar?(opts = {})
|
|
539
|
+
m = opts[:mistake] || opts[:m] || opts
|
|
540
|
+
return false unless m.is_a?(Hash)
|
|
541
|
+
|
|
542
|
+
shape = m[:shape].to_s
|
|
543
|
+
err = m[:error].to_s.downcase
|
|
544
|
+
tool = m[:tool].to_s
|
|
545
|
+
return true if %w[budget_exhausted budget_thrash].include?(shape)
|
|
546
|
+
return true if err.include?('budget exhausted') || err.include?('iteration budget') || err.include?('budget thrash')
|
|
547
|
+
|
|
548
|
+
%w[agent_loop assistant_answer].include?(tool) && err.include?('budget')
|
|
549
|
+
rescue StandardError
|
|
550
|
+
false
|
|
551
|
+
end
|
|
552
|
+
|
|
537
553
|
private_class_method def self.rank_for_request(opts = {})
|
|
538
554
|
rows = Array(opts[:rows])
|
|
539
555
|
limit = opts[:limit] || 6
|
|
@@ -553,6 +569,7 @@ module PWN
|
|
|
553
569
|
# downrank reward_signal / parked unless the request is about rewards
|
|
554
570
|
penalty = 1.0
|
|
555
571
|
penalty *= 0.05 if m[:tool].to_s == 'reward_signal' && !req.match?(/reward|judge|sentinel|proxy/)
|
|
572
|
+
penalty *= 0.05 if budget_scar?(mistake: m) && !req.match?(/budget|iterat|exhaust|loop/)
|
|
556
573
|
penalty *= 0.3 if m[:parked] || m[:needs_code_change]
|
|
557
574
|
score = ((sim * 2.0) + (decay * 0.5) + (Math.log2(m[:count].to_i + 1) * 0.3)) * penalty
|
|
558
575
|
# always allow some mass for top-count scars when sim=0 but keep penalty
|