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.
@@ -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: 12, unresolved_only: true)
171
+ top = Mistakes.top(limit: 24, unresolved_only: true)
167
172
  now = Time.now
168
- top.each do |mistake|
169
- next unless budget_hit?(mistake: mistake)
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 while hot?=false'
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 ask → force synthesis instead of burning iters into text-only tail.
219
- # Must NOT fire on routine tool JSON {"success":true} while a multi-step
220
- # English plan still has open tasks — that blocks legitimate completion
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|resolved|File\.write|ruby -c|0 offenses|examples?,\s*0 failures/i
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
- # Weak chat templates (TEMPLATE {{ .Prompt }} on abliterated
1003
- # Gemma etc.) ignore tools: under tool_choice=auto and dump
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
- last_asst = Array(messages).reverse.find { |m| m[:role].to_s == 'assistant' }
1011
- last_txt = last_asst.is_a?(Hash) ? last_asst[:content].to_s : ''
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
- fails = opts[:turn_fails].is_a?(Hash) ? opts[:turn_fails].values.sum : 0
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
- # Greeting / light smalltalk: deterministic ack — no weather echo, no tools.
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
- # How-to: never enter plan_first / task recon / tool thrash (ollama/openwebui).
1942
- if intent == :howto && opts[:force_tools] != true
1943
- return answer_howto(
1944
- request: request,
1945
- session_id: session_id,
1946
- system_role_content: system_role_content
1947
- )
1948
- end
1949
- # Pure prior-turn / vague memory recall: one cheap path, no plan_first.
1950
- if intent == :recall && opts[:force_tools] != true
1951
- return answer_recall(
1952
- request: request,
1953
- session_id: session_id,
1954
- system_role_content: system_role_content
1955
- )
1956
- end
1957
- # General statements: acknowledge briefly — no multi-step task plan.
1958
- # Kind is source of truth (LLM+heuristic). Never short-circuit goals.
1959
- if kind.to_sym == :statement && intent != :recon_act && opts[:force_tools] != true
1960
- return answer_statement(
1961
- request: request,
1962
- session_id: session_id
1963
- )
1964
- end
1965
- # Pure questions that are not how-to/recall: concise answer, no multi-step plan.
1966
- # Host-evidence interrogatives classify as autonomous_goal above so they
1967
- # keep tools (e.g. "what is my hostname?"). force_tools bypasses for tests.
1968
- if kind.to_sym == :question && !%i[recon_act].include?(intent) && opts[:force_tools] != true
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
- tools = Registry.definitions(enabled: opts[:enabled_toolsets], relevance: request)
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
- # Tangible-task breakdown ONLY for autonomous goals.
2000
- # General statements and questions stay without multi-step plans.
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
- tools = Registry.definitions(enabled: opts[:enabled_toolsets], relevance: rq) unless rq.to_s.strip.empty?
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) if needs_breakdown
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 non-goals (statements/questions) and cheap intents.
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
- tools = Registry.definitions(enabled: opts[:enabled_toolsets], relevance: rq) unless rq.to_s.strip.empty?
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
- hot_hint = if local_engine?
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
- text_only_iters = if hot
2120
- if plan_faithful
2121
- 1
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