pwn 0.5.667 → 0.5.669
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/.rubocop_todo.yml +1 -0
- data/CONTRIBUTING.md +15 -0
- data/Gemfile +4 -4
- data/README.md +4 -4
- data/documentation/AI-Integration.md +5 -3
- data/documentation/Agent-Tool-Registry.md +18 -14
- data/documentation/Configuration.md +2 -0
- data/documentation/Contributing.md +13 -0
- data/documentation/Diagrams.md +1 -1
- data/documentation/Home.md +4 -4
- data/documentation/How-PWN-Works.md +6 -6
- data/documentation/Persistence.md +17 -0
- data/documentation/Reinforcement-Learning.md +39 -39
- data/documentation/SDR.md +1 -1
- data/documentation/Sessions.md +1 -0
- data/documentation/Skills-Memory-Learning.md +2 -0
- data/documentation/What-is-PWN.md +3 -3
- data/documentation/diagrams/agent-tool-registry.svg +43 -43
- data/documentation/diagrams/ai-integration-tool-calling.svg +112 -85
- data/documentation/diagrams/dot/agent-tool-registry.dot +4 -4
- data/documentation/diagrams/dot/ai-integration-tool-calling.dot +7 -3
- data/documentation/diagrams/dot/memory-skills-detailed.dot +2 -2
- data/documentation/diagrams/dot/overall-pwn-architecture.dot +6 -4
- data/documentation/diagrams/dot/persistence-filesystem.dot +1 -1
- data/documentation/diagrams/dot/sdr-radio-flow.dot +1 -1
- data/documentation/diagrams/memory-skills-detailed.svg +182 -182
- data/documentation/diagrams/overall-pwn-architecture.svg +154 -146
- data/documentation/diagrams/persistence-filesystem.svg +6 -6
- data/documentation/diagrams/sdr-radio-flow.svg +1 -1
- data/documentation/pwn-ai-Agent.md +29 -13
- data/lib/pwn/ai/agent/dispatch.rb +161 -0
- data/lib/pwn/ai/agent/extrospection.rb +9 -5
- data/lib/pwn/ai/agent/learning.rb +2 -1
- data/lib/pwn/ai/agent/loop.rb +500 -19
- data/lib/pwn/ai/agent/prompt_builder.rb +36 -10
- data/lib/pwn/ai/agent/reflect.rb +1 -0
- data/lib/pwn/ai/agent/registry.rb +1 -1
- data/lib/pwn/ai/agent/result.rb +3 -3
- data/lib/pwn/ai/agent/swarm.rb +1 -1
- data/lib/pwn/ai/agent/task_summarizer.rb +41 -3
- data/lib/pwn/ai/agent/tools/memory.rb +18 -4
- data/lib/pwn/ai/agent/tools/shell.rb +32 -0
- data/lib/pwn/ai/grok.rb +3 -0
- data/lib/pwn/ai/ollama.rb +131 -78
- data/lib/pwn/ai/open_ai.rb +3 -0
- data/lib/pwn/ai/open_web_ui.rb +715 -0
- data/lib/pwn/ai.rb +1 -0
- data/lib/pwn/config.rb +70 -36
- data/lib/pwn/memory.rb +140 -11
- data/lib/pwn/memory_index.rb +82 -21
- data/lib/pwn/plugins/repl.rb +42 -11
- data/lib/pwn/version.rb +1 -1
- data/spec/integration/reinforced_feedback_loop_spec.rb +8 -6
- data/spec/lib/pwn/ai/agent/dispatch_spec.rb +78 -0
- data/spec/lib/pwn/ai/agent/extrospection_spec.rb +36 -0
- data/spec/lib/pwn/ai/agent/loop_spec.rb +148 -0
- data/spec/lib/pwn/ai/agent/task_summarizer_spec.rb +10 -0
- data/spec/lib/pwn/ai/agent/tools/shell_spec.rb +21 -0
- data/spec/lib/pwn/ai/grok_spec.rb +5 -0
- data/spec/lib/pwn/ai/ollama_spec.rb +36 -0
- data/spec/lib/pwn/ai/open_ai_spec.rb +5 -0
- data/spec/lib/pwn/ai/open_web_ui_spec.rb +302 -0
- data/spec/lib/pwn/config_spec.rb +70 -0
- data/spec/lib/pwn/memory_spec.rb +63 -0
- data/spec/lib/pwn/plugins/repl_pwn_vault_spec.rb +134 -0
- data/third_party/pwn_rdoc.jsonl +37 -5
- metadata +12 -9
data/lib/pwn/ai/agent/loop.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'json'
|
|
4
|
+
require 'securerandom'
|
|
4
5
|
require 'digest'
|
|
5
6
|
require 'pwn/ai/agent/mistakes'
|
|
6
7
|
|
|
@@ -60,6 +61,7 @@ module PWN
|
|
|
60
61
|
openai: 'PWN::AI::OpenAI',
|
|
61
62
|
grok: 'PWN::AI::Grok',
|
|
62
63
|
ollama: 'PWN::AI::Ollama',
|
|
64
|
+
openwebui: 'PWN::AI::OpenWebUI',
|
|
63
65
|
anthropic: 'PWN::AI::Anthropic',
|
|
64
66
|
gemini: 'PWN::AI::Gemini'
|
|
65
67
|
}.freeze
|
|
@@ -237,6 +239,11 @@ module PWN
|
|
|
237
239
|
# goal was done ("shall I proceed?", "next step:", "want me to…").
|
|
238
240
|
# Loop.run treats no-tool_calls as FINAL; this detector lets us refuse
|
|
239
241
|
# that handoff and keep the tool loop alive for multi-step autonomy.
|
|
242
|
+
#
|
|
243
|
+
# Local/thinking models (gemma/Qwen abliterated etc.) often emit a
|
|
244
|
+
# monologue that NARRATES the next tool ("Wait, let's try hping3…")
|
|
245
|
+
# without producing native tool_calls or shell(...). Treat that as
|
|
246
|
+
# incomplete too so the loop re-pressures tools instead of FINAL.
|
|
240
247
|
INCOMPLETE_FINAL_RX = /
|
|
241
248
|
\b(shall\s+i|should\s+i|may\s+i|can\s+i|want\s+me\s+to|do\s+you\s+want\s+me|
|
|
242
249
|
next\s+single\s+step|next\s+step\s*:|awaiting\s+your\s+(ok|approval|go-ahead|confirmation)|
|
|
@@ -248,6 +255,21 @@ module PWN
|
|
|
248
255
|
)\b
|
|
249
256
|
/ix
|
|
250
257
|
|
|
258
|
+
# Narrated-intent monologue without a structured tool call.
|
|
259
|
+
# Distinct from INCOMPLETE_FINAL_RX (polite handoff to the human).
|
|
260
|
+
MONOLOGUE_TOOL_INTENT_RX = /
|
|
261
|
+
\b(
|
|
262
|
+
wait[,\s]+let'?s\s+try|
|
|
263
|
+
let'?s\s+try\s+(one|to|again|hping|nmap|ping|sudo|shell|running|checking)|
|
|
264
|
+
i\s+(?:will|'ll)\s+(?:just\s+)?(?:try|run|check|probe|scan|use)\b|
|
|
265
|
+
actually,?\s+i\s+will\b|
|
|
266
|
+
one\s+more\s+thing\b|
|
|
267
|
+
if\s+it\s+fails\b.{0,80}\bthen\s+we\s+can\b|
|
|
268
|
+
verification\s+complete\b|
|
|
269
|
+
report\s+that\s+(?:the\s+)?verification\s+failed\b
|
|
270
|
+
)
|
|
271
|
+
/ix
|
|
272
|
+
|
|
251
273
|
private_class_method def self.incomplete_final?(opts = {})
|
|
252
274
|
text = opts[:text].to_s
|
|
253
275
|
return false if text.strip.empty?
|
|
@@ -258,6 +280,22 @@ module PWN
|
|
|
258
280
|
return true if text.include?('?') && text.length < 900 &&
|
|
259
281
|
text.match?(/\b(proceed|continue|confirm|apply|next)\b/i)
|
|
260
282
|
|
|
283
|
+
# Thinking/monologue that plans tools in prose (esp. Ollama gemma).
|
|
284
|
+
# Cap length so a real long final answer is not bounced forever.
|
|
285
|
+
if text.length < 12_000 && text.match?(MONOLOGUE_TOOL_INTENT_RX)
|
|
286
|
+
# Prefer bounce when no concrete shell(...) / tool call is embedded.
|
|
287
|
+
return true unless defined?(Dispatch) && Dispatch.respond_to?(:tool_calls_from_text)
|
|
288
|
+
return true if Dispatch.tool_calls_from_text(text: text).empty?
|
|
289
|
+
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# Degenerate repetition ("Wait, let's try…" loop) always incomplete.
|
|
293
|
+
lines = text.lines.map { |l| l.strip.downcase }.reject(&:empty?)
|
|
294
|
+
if lines.length >= 6
|
|
295
|
+
uniq_ratio = lines.uniq.length.to_f / lines.length
|
|
296
|
+
return true if uniq_ratio < 0.35
|
|
297
|
+
end
|
|
298
|
+
|
|
261
299
|
false
|
|
262
300
|
rescue StandardError
|
|
263
301
|
false
|
|
@@ -268,7 +306,7 @@ module PWN
|
|
|
268
306
|
n = v.to_i.positive? ? v.to_i : DEFAULT_MAX_ITERS
|
|
269
307
|
# 0.3 — frontier leakage: live max_iters=80 burns local models.
|
|
270
308
|
# Cap ollama at 25 unless the operator set an explicit lower value.
|
|
271
|
-
n = 75 if
|
|
309
|
+
n = 75 if local_engine? && n > 75
|
|
272
310
|
# P7 — W3 controller: when this engine is badly overconfident,
|
|
273
311
|
# shrink the tool budget so thrash can't compound on bad plans.
|
|
274
312
|
cal = calibration_state
|
|
@@ -282,7 +320,7 @@ module PWN
|
|
|
282
320
|
# refuse polite handoffs and finish early when evidence is enough.
|
|
283
321
|
# CF / red_team forks stay suppressed while hot.
|
|
284
322
|
if budget_exhaustion_hot?
|
|
285
|
-
hot_cap =
|
|
323
|
+
hot_cap = local_engine? ? 24 : 75
|
|
286
324
|
n = [n, hot_cap].min
|
|
287
325
|
end
|
|
288
326
|
n
|
|
@@ -311,7 +349,7 @@ module PWN
|
|
|
311
349
|
remote_cap = 120
|
|
312
350
|
local_cap = 24
|
|
313
351
|
cap = if bad
|
|
314
|
-
(eng
|
|
352
|
+
(local_engine?(engine: eng) ? local_cap : remote_cap)
|
|
315
353
|
else
|
|
316
354
|
75
|
|
317
355
|
end
|
|
@@ -326,6 +364,16 @@ module PWN
|
|
|
326
364
|
{ overconfident: false, force_plan: false, force_critic: false, max_iters_cap: 75 }
|
|
327
365
|
end
|
|
328
366
|
|
|
367
|
+
# Local/on-box engines share tight-context scaffolding (plan_first,
|
|
368
|
+
# tool_router, result caps, tool_choice pressure). Both :ollama
|
|
369
|
+
# (native server) and :openwebui (gateway in front of models) count.
|
|
370
|
+
private_class_method def self.local_engine?(opts = {})
|
|
371
|
+
eng = opts.key?(:engine) ? opts[:engine].to_s.downcase.to_sym : active_engine
|
|
372
|
+
%i[ollama openwebui].include?(eng)
|
|
373
|
+
rescue StandardError
|
|
374
|
+
false
|
|
375
|
+
end
|
|
376
|
+
|
|
329
377
|
private_class_method def self.active_engine
|
|
330
378
|
e = (PWN::Env.dig(:ai, :active) if defined?(PWN::Env)).to_s.downcase.to_sym
|
|
331
379
|
e == :'' ? :openai : e
|
|
@@ -452,7 +500,7 @@ module PWN
|
|
|
452
500
|
rescue StandardError
|
|
453
501
|
false
|
|
454
502
|
end
|
|
455
|
-
plan_prompt = if hot &&
|
|
503
|
+
plan_prompt = if hot && local_engine?
|
|
456
504
|
# Local hot: ultra-short plan — finish-under-8 is the skill gap.
|
|
457
505
|
'Before acting: write AT MOST 3 numbered tool calls (name + key args) that finish the ask. Prefer fewer. LAST line: "p(success)=<0.0-1.0>". Reply ONLY with the plan + that line — no tools, no prose.'
|
|
458
506
|
elsif hot
|
|
@@ -587,6 +635,184 @@ module PWN
|
|
|
587
635
|
warn "[pwn-ai/loop] publish_usage swallowed: #{e.class}: #{e.message}"
|
|
588
636
|
end
|
|
589
637
|
|
|
638
|
+
# Ollama / Open WebUI proxied /ollama/api/chat expect function.arguments
|
|
639
|
+
# as a JSON *object* (map), not a JSON-encoded string. Loop.normalize_llm
|
|
640
|
+
# and openai_wire_tool_call stringify for OpenAI/xAI; replaying that
|
|
641
|
+
# history into local engines produces HTTP 400:
|
|
642
|
+
# {"detail":"Value looks like object, but can't find closing '}' symbol"}
|
|
643
|
+
# because the gateway parses the string as if it were raw object text.
|
|
644
|
+
private_class_method def self.parse_tool_arguments(opts = {})
|
|
645
|
+
raw = opts[:arguments]
|
|
646
|
+
case raw
|
|
647
|
+
when Hash, Array
|
|
648
|
+
# Object form (Hash) is Ollama-native; Array is rare but keep as-is.
|
|
649
|
+
raw
|
|
650
|
+
when nil
|
|
651
|
+
{}
|
|
652
|
+
when String
|
|
653
|
+
s = raw.strip
|
|
654
|
+
return {} if s.empty?
|
|
655
|
+
|
|
656
|
+
begin
|
|
657
|
+
parsed = JSON.parse(s, symbolize_names: true)
|
|
658
|
+
return parsed if parsed.is_a?(Hash) || parsed.is_a?(Array)
|
|
659
|
+
rescue JSON::ParserError
|
|
660
|
+
# fall through
|
|
661
|
+
end
|
|
662
|
+
# Non-JSON bare string — wrap so the schema still gets an object.
|
|
663
|
+
{ value: s }
|
|
664
|
+
else
|
|
665
|
+
{ value: raw.to_s }
|
|
666
|
+
end
|
|
667
|
+
end
|
|
668
|
+
|
|
669
|
+
private_class_method def self.ollama_wire_tool_call(opts = {})
|
|
670
|
+
tc = opts[:tool_call]
|
|
671
|
+
return nil unless tc.is_a?(Hash)
|
|
672
|
+
|
|
673
|
+
fn = tc[:function] || tc['function'] || {}
|
|
674
|
+
name = fn[:name] || fn['name'] || tc[:name] || tc['name']
|
|
675
|
+
args = fn[:arguments] || fn['arguments'] || tc[:arguments] || tc['arguments']
|
|
676
|
+
{
|
|
677
|
+
id: (tc[:id] || tc['id'] || "call_#{SecureRandom.hex(4)}").to_s,
|
|
678
|
+
type: (tc[:type] || tc['type'] || 'function').to_s,
|
|
679
|
+
function: {
|
|
680
|
+
name: name.to_s,
|
|
681
|
+
arguments: parse_tool_arguments(arguments: args)
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
end
|
|
685
|
+
|
|
686
|
+
# Supported Method Parameters::
|
|
687
|
+
# wire = PWN::AI::Agent::Loop.ollama_wire_messages(
|
|
688
|
+
# messages: 'required - in-memory OpenAI-ish messages (may have String args)'
|
|
689
|
+
# )
|
|
690
|
+
#
|
|
691
|
+
# Returns a deep-copied array safe for Ollama / Open WebUI ollama/api/chat:
|
|
692
|
+
# - parses JSON-string function.arguments into Hash/Array objects
|
|
693
|
+
# - coerces nil assistant content to '' when tool_calls present
|
|
694
|
+
# (Open WebUI GenerateChatCompletionForm rejects content:null alone)
|
|
695
|
+
# - drops _native_content / _text_tool_coerced / thinking private keys
|
|
696
|
+
# - stringifies Hash/Array message content (tool results) to JSON text
|
|
697
|
+
|
|
698
|
+
public_class_method def self.ollama_wire_messages(opts = {})
|
|
699
|
+
messages = opts[:messages]
|
|
700
|
+
Array(messages).filter_map do |m|
|
|
701
|
+
next unless m.is_a?(Hash)
|
|
702
|
+
|
|
703
|
+
role = (m[:role] || m['role']).to_s
|
|
704
|
+
out = { role: role }
|
|
705
|
+
|
|
706
|
+
tcs = m[:tool_calls] || m['tool_calls']
|
|
707
|
+
wired_tcs = nil
|
|
708
|
+
if tcs
|
|
709
|
+
wired_tcs = Array(tcs).filter_map { |tc| ollama_wire_tool_call(tool_call: tc) }
|
|
710
|
+
out[:tool_calls] = wired_tcs unless wired_tcs.empty?
|
|
711
|
+
end
|
|
712
|
+
|
|
713
|
+
if m.key?(:content) || m.key?('content')
|
|
714
|
+
content = m.key?(:content) ? m[:content] : m['content']
|
|
715
|
+
out[:content] = case content
|
|
716
|
+
when nil
|
|
717
|
+
# Open WebUI: null content without tool_calls 400s;
|
|
718
|
+
# with tool_calls prefer "" over null.
|
|
719
|
+
wired_tcs && !wired_tcs.empty? ? '' : nil
|
|
720
|
+
when String then content
|
|
721
|
+
when Hash, Array then JSON.generate(content)
|
|
722
|
+
else content.to_s
|
|
723
|
+
end
|
|
724
|
+
elsif wired_tcs && !wired_tcs.empty?
|
|
725
|
+
out[:content] = ''
|
|
726
|
+
end
|
|
727
|
+
|
|
728
|
+
name = m[:name] || m['name']
|
|
729
|
+
out[:name] = name.to_s if name && !name.to_s.empty?
|
|
730
|
+
|
|
731
|
+
tcid = m[:tool_call_id] || m['tool_call_id']
|
|
732
|
+
out[:tool_call_id] = tcid.to_s if tcid && !tcid.to_s.empty?
|
|
733
|
+
|
|
734
|
+
out
|
|
735
|
+
end
|
|
736
|
+
end
|
|
737
|
+
|
|
738
|
+
# OpenAI-compatible wire format (xAI Grok / OpenAI chat.completions):
|
|
739
|
+
# function.arguments MUST be a JSON string, not a map; message.content
|
|
740
|
+
# MUST be a string (or null). Internal Hash arguments from Ollama or
|
|
741
|
+
# Dispatch.tool_calls_from_text caused:
|
|
742
|
+
# 422 Unprocessable Entity: messages[N]: invalid type: map, expected a string
|
|
743
|
+
private_class_method def self.stringify_tool_arguments(opts = {})
|
|
744
|
+
raw = opts[:arguments]
|
|
745
|
+
case raw
|
|
746
|
+
when String then raw.empty? ? '{}' : raw
|
|
747
|
+
when Hash, Array then JSON.generate(raw)
|
|
748
|
+
when nil then '{}'
|
|
749
|
+
else
|
|
750
|
+
s = raw.to_s
|
|
751
|
+
s.empty? ? '{}' : s
|
|
752
|
+
end
|
|
753
|
+
end
|
|
754
|
+
|
|
755
|
+
private_class_method def self.openai_wire_tool_call(opts = {})
|
|
756
|
+
tc = opts[:tool_call]
|
|
757
|
+
return nil unless tc.is_a?(Hash)
|
|
758
|
+
|
|
759
|
+
fn = tc[:function] || tc['function'] || {}
|
|
760
|
+
name = fn[:name] || fn['name'] || tc[:name] || tc['name']
|
|
761
|
+
args = fn[:arguments] || fn['arguments'] || tc[:arguments] || tc['arguments']
|
|
762
|
+
{
|
|
763
|
+
id: (tc[:id] || tc['id'] || "call_#{SecureRandom.hex(4)}").to_s,
|
|
764
|
+
type: (tc[:type] || tc['type'] || 'function').to_s,
|
|
765
|
+
function: {
|
|
766
|
+
name: name.to_s,
|
|
767
|
+
arguments: stringify_tool_arguments(arguments: args)
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
end
|
|
771
|
+
|
|
772
|
+
# Supported Method Parameters::
|
|
773
|
+
# wire = PWN::AI::Agent::Loop.openai_wire_messages(
|
|
774
|
+
# messages: 'required - in-memory OpenAI-ish messages (may have Hash args / internal keys)'
|
|
775
|
+
# )
|
|
776
|
+
#
|
|
777
|
+
# Returns a deep-copied array safe for OpenAI / xAI chat.completions:
|
|
778
|
+
# - drops _native_content / _text_tool_coerced / thinking private keys
|
|
779
|
+
# - stringifies function.arguments maps
|
|
780
|
+
# - coerces Hash/non-string content to JSON/string (nil kept for assistant tool turns)
|
|
781
|
+
|
|
782
|
+
public_class_method def self.openai_wire_messages(opts = {})
|
|
783
|
+
messages = opts[:messages]
|
|
784
|
+
Array(messages).filter_map do |m|
|
|
785
|
+
next unless m.is_a?(Hash)
|
|
786
|
+
|
|
787
|
+
role = (m[:role] || m['role']).to_s
|
|
788
|
+
out = { role: role }
|
|
789
|
+
|
|
790
|
+
if m.key?(:content) || m.key?('content')
|
|
791
|
+
content = m.key?(:content) ? m[:content] : m['content']
|
|
792
|
+
out[:content] = case content
|
|
793
|
+
when nil then nil
|
|
794
|
+
when String then content
|
|
795
|
+
when Hash, Array then JSON.generate(content)
|
|
796
|
+
else content.to_s
|
|
797
|
+
end
|
|
798
|
+
end
|
|
799
|
+
|
|
800
|
+
name = m[:name] || m['name']
|
|
801
|
+
out[:name] = name.to_s if name && !name.to_s.empty?
|
|
802
|
+
|
|
803
|
+
tcid = m[:tool_call_id] || m['tool_call_id']
|
|
804
|
+
out[:tool_call_id] = tcid.to_s if tcid && !tcid.to_s.empty?
|
|
805
|
+
|
|
806
|
+
tcs = m[:tool_calls] || m['tool_calls']
|
|
807
|
+
if tcs
|
|
808
|
+
wired = Array(tcs).filter_map { |tc| openai_wire_tool_call(tool_call: tc) }
|
|
809
|
+
out[:tool_calls] = wired unless wired.empty?
|
|
810
|
+
end
|
|
811
|
+
|
|
812
|
+
out
|
|
813
|
+
end
|
|
814
|
+
end
|
|
815
|
+
|
|
590
816
|
# Supported Method Parameters::
|
|
591
817
|
# msg = PWN::AI::Agent::Loop.normalize_llm(
|
|
592
818
|
# response: 'required - chat_with_tools response Hash from any provider'
|
|
@@ -618,7 +844,9 @@ module PWN
|
|
|
618
844
|
type: 'function',
|
|
619
845
|
function: {
|
|
620
846
|
name: tc.dig(:function, :name) || tc[:name],
|
|
621
|
-
arguments:
|
|
847
|
+
arguments: stringify_tool_arguments(
|
|
848
|
+
arguments: tc.dig(:function, :arguments) || tc[:arguments]
|
|
849
|
+
)
|
|
622
850
|
}
|
|
623
851
|
}
|
|
624
852
|
end
|
|
@@ -628,6 +856,17 @@ module PWN
|
|
|
628
856
|
# original tool_use block to precede a tool_result).
|
|
629
857
|
out[:_native_content] = msg[:_native_content] if msg[:_native_content]
|
|
630
858
|
out[:thinking] = msg[:thinking] if msg[:thinking]
|
|
859
|
+
# Local/abliterated models sometimes emit shell(...) as plain content
|
|
860
|
+
# with empty tool_calls. Coerce registered call-shaped text into
|
|
861
|
+
# structured tool_calls so Loop dispatches instead of FINAL-answering.
|
|
862
|
+
if out[:tool_calls].empty? && defined?(Dispatch) && Dispatch.respond_to?(:tool_calls_from_text)
|
|
863
|
+
coerced = Dispatch.tool_calls_from_text(text: out[:content].to_s)
|
|
864
|
+
if coerced.any?
|
|
865
|
+
out[:tool_calls] = coerced.map { |tc| openai_wire_tool_call(tool_call: tc) }
|
|
866
|
+
out[:content] = nil
|
|
867
|
+
out[:_text_tool_coerced] = true
|
|
868
|
+
end
|
|
869
|
+
end
|
|
631
870
|
out
|
|
632
871
|
end
|
|
633
872
|
|
|
@@ -652,11 +891,50 @@ module PWN
|
|
|
652
891
|
|
|
653
892
|
mod = Object.const_get(mod_name)
|
|
654
893
|
if mod.respond_to?(:chat_with_tools)
|
|
655
|
-
|
|
656
|
-
|
|
894
|
+
# xAI/OpenAI reject Hash function.arguments / Hash content (422 map→string).
|
|
895
|
+
# Ollama / Open WebUI reject *string* function.arguments (HTTP 400
|
|
896
|
+
# "can't find closing '}' symbol") — opposite of OpenAI wire form.
|
|
897
|
+
wire_msgs = if %i[grok openai].include?(engine)
|
|
898
|
+
openai_wire_messages(messages: messages)
|
|
899
|
+
elsif local_engine?(engine: engine)
|
|
900
|
+
ollama_wire_messages(messages: messages)
|
|
901
|
+
else
|
|
902
|
+
messages
|
|
903
|
+
end
|
|
904
|
+
cwt_opts = {
|
|
905
|
+
messages: wire_msgs,
|
|
657
906
|
tools: tools,
|
|
658
907
|
spinner: true
|
|
659
|
-
|
|
908
|
+
}
|
|
909
|
+
# Ollama + abliterated / weak chat-templates often ignore tools: and
|
|
910
|
+
# answer in prose (or print shell(...) as text). Force native
|
|
911
|
+
# tool_calls until at least one tool result is already in history;
|
|
912
|
+
# after that, auto so the model can emit a real final answer.
|
|
913
|
+
# Respect explicit PWN::Env[:ai][:ollama][:tool_choice] override.
|
|
914
|
+
if local_engine?(engine: engine) && tools && !tools.empty?
|
|
915
|
+
env_tc = begin
|
|
916
|
+
PWN::Env.dig(:ai, engine, :tool_choice)
|
|
917
|
+
rescue StandardError
|
|
918
|
+
nil
|
|
919
|
+
end
|
|
920
|
+
if env_tc && !env_tc.to_s.empty?
|
|
921
|
+
cwt_opts[:tool_choice] = env_tc
|
|
922
|
+
else
|
|
923
|
+
# Weak chat templates (TEMPLATE {{ .Prompt }} on abliterated
|
|
924
|
+
# Gemma etc.) ignore tools: under tool_choice=auto and dump
|
|
925
|
+
# monologue as content. Stay on required until a tool result
|
|
926
|
+
# exists AND the last assistant turn already looks like a
|
|
927
|
+
# genuine final (no monologue / handoff markers). That keeps
|
|
928
|
+
# pressure on native tool_calls through the mid-loop thrash
|
|
929
|
+
# that previously returned "Wait, let's try hping3…" as FINAL.
|
|
930
|
+
has_tool_result = Array(messages).any? { |m| m[:role].to_s == 'tool' }
|
|
931
|
+
last_asst = Array(messages).reverse.find { |m| m[:role].to_s == 'assistant' }
|
|
932
|
+
last_txt = last_asst.is_a?(Hash) ? last_asst[:content].to_s : ''
|
|
933
|
+
still_acting = last_txt.strip.empty? || incomplete_final?(text: last_txt, last_iter: false)
|
|
934
|
+
cwt_opts[:tool_choice] = has_tool_result && !still_acting ? 'auto' : 'required'
|
|
935
|
+
end
|
|
936
|
+
end
|
|
937
|
+
response = mod.chat_with_tools(**cwt_opts)
|
|
660
938
|
publish_usage(response: response, engine: engine)
|
|
661
939
|
normalize_llm(response: response)
|
|
662
940
|
else
|
|
@@ -826,6 +1104,175 @@ module PWN
|
|
|
826
1104
|
nil
|
|
827
1105
|
end
|
|
828
1106
|
|
|
1107
|
+
# Request intent for routing (how-to vs act/recon). Local models thrash when
|
|
1108
|
+
# pure explanation asks are force-planned into multi-step host probes.
|
|
1109
|
+
# :howto → answer with explanation only (no plan_first / no live recon).
|
|
1110
|
+
# :recon_act → live discovery; requires explicit authorization language.
|
|
1111
|
+
# :act → general agent work with tools.
|
|
1112
|
+
HOWTO_RX = /
|
|
1113
|
+
\b(
|
|
1114
|
+
how\s+to|how\s+do\s+i|how\s+can\s+i|how\s+would\s+i|how\s+does\s+one|
|
|
1115
|
+
what\s+is\s+the\s+(?:syntax|command|usage|flag|option)|
|
|
1116
|
+
explain\s+how|show\s+me\s+how|examples?\s+of\s+using|
|
|
1117
|
+
manual\s+for|usage\s+of|syntax\s+for|man\s+page
|
|
1118
|
+
)\b
|
|
1119
|
+
/ix
|
|
1120
|
+
|
|
1121
|
+
LIVE_RECON_RX = /
|
|
1122
|
+
\b(
|
|
1123
|
+
(?:find|discover|enumerate|scan|sweep|probe|map)\s+
|
|
1124
|
+
(?:live\s+)?(?:hosts?|ips?|targets?|subnet|network|range)|
|
|
1125
|
+
live\s+hosts?\s+(?:can\s+you\s+)?find|
|
|
1126
|
+
what\s+live\s+hosts|
|
|
1127
|
+
ping\s+sweep\s+(?:of\s+)?(?:this|the|my)\s+
|
|
1128
|
+
|(?:run|do|perform)\s+(?:a\s+)?(?:ping\s+)?sweep
|
|
1129
|
+
|scan\s+(?:this|the|my)\s+(?:subnet|network|lan|range)
|
|
1130
|
+
)\b
|
|
1131
|
+
/ix
|
|
1132
|
+
|
|
1133
|
+
AUTH_SCOPE_RX = /
|
|
1134
|
+
\b(
|
|
1135
|
+
(?:in[-\s]?scope|authorized|authorised|engagement|written\s+permission|
|
|
1136
|
+
bug\s*bounty|explicit(?:ly)?\s+allowed|lab\s+only|my\s+lab|
|
|
1137
|
+
roe\b|rules?\s+of\s+engagement|i\s+own\s+this|owned\s+by\s+me|
|
|
1138
|
+
permission\s+to\s+(?:scan|test|probe)|scope:\s*\S+)
|
|
1139
|
+
)\b
|
|
1140
|
+
/ix
|
|
1141
|
+
|
|
1142
|
+
public_class_method def self.request_intent(opts = {})
|
|
1143
|
+
req = opts[:request].to_s
|
|
1144
|
+
return :empty if req.strip.empty?
|
|
1145
|
+
|
|
1146
|
+
# Live-action recon takes precedence over bare "how to" when both appear
|
|
1147
|
+
# only if the user clearly asks the agent to do the sweep here.
|
|
1148
|
+
live = req.match?(LIVE_RECON_RX) && req.match?(
|
|
1149
|
+
/\b(can\s+you|could\s+you|please|go\s+ahead|now|on\s+this\s+host|
|
|
1150
|
+
this\s+subnet|this\s+network|find\s+(?:for\s+me|me)|discover)\b/ix
|
|
1151
|
+
)
|
|
1152
|
+
return :recon_act if live || (req.match?(LIVE_RECON_RX) && !req.match?(HOWTO_RX))
|
|
1153
|
+
return :howto if req.match?(HOWTO_RX)
|
|
1154
|
+
# Interrogative documentation without "how to"
|
|
1155
|
+
if req.match?(/\b(what\s+(?:flags?|options?|switches?)|usage|syntax)\b/i) &&
|
|
1156
|
+
!req.match?(/\b(run|execute|scan|find|discover)\b/i)
|
|
1157
|
+
return :howto
|
|
1158
|
+
end
|
|
1159
|
+
|
|
1160
|
+
:act
|
|
1161
|
+
rescue StandardError
|
|
1162
|
+
:act
|
|
1163
|
+
end
|
|
1164
|
+
|
|
1165
|
+
public_class_method def self.recon_authorized?(opts = {})
|
|
1166
|
+
req = opts[:request].to_s
|
|
1167
|
+
return true if req.match?(AUTH_SCOPE_RX)
|
|
1168
|
+
|
|
1169
|
+
# Explicit engage flag from operator / REPL
|
|
1170
|
+
v = (PWN::Env.dig(:ai, :agent, :recon_authorized) if defined?(PWN::Env))
|
|
1171
|
+
return true if v == true || v.to_s =~ /\A(1|true|yes|on)\z/i
|
|
1172
|
+
|
|
1173
|
+
false
|
|
1174
|
+
rescue StandardError
|
|
1175
|
+
false
|
|
1176
|
+
end
|
|
1177
|
+
|
|
1178
|
+
# Pure how-to: one text-only chat (no tools, no plan_first, no task recon).
|
|
1179
|
+
private_class_method def self.answer_howto(opts = {})
|
|
1180
|
+
request = opts[:request].to_s
|
|
1181
|
+
session_id = opts[:session_id]
|
|
1182
|
+
system_role_content = opts[:system_role_content].to_s
|
|
1183
|
+
engine = active_engine
|
|
1184
|
+
mod_name = ENGINE_MODS[engine]
|
|
1185
|
+
raise "ERROR: Unsupported AI engine for agent loop: #{engine}" unless mod_name
|
|
1186
|
+
|
|
1187
|
+
mod = Object.const_get(mod_name)
|
|
1188
|
+
howto_sys = <<~SYS
|
|
1189
|
+
#{system_role_content}
|
|
1190
|
+
|
|
1191
|
+
INTENT: HOW-TO / DOCUMENTATION (this turn only)
|
|
1192
|
+
The user wants a concise explanation of how to use a tool or technique.
|
|
1193
|
+
Answer in plain US English with example command lines only.
|
|
1194
|
+
Do NOT call tools. Do NOT plan multi-step work. Do NOT run scans,
|
|
1195
|
+
probes, rubocop, rake, or host verification. Do NOT invent task
|
|
1196
|
+
traces or internal planner monologue. Do NOT claim you ran anything.
|
|
1197
|
+
If the topic is network discovery, give safe lab examples and note
|
|
1198
|
+
that live sweeps need explicit in-scope authorization.
|
|
1199
|
+
SYS
|
|
1200
|
+
|
|
1201
|
+
txt =
|
|
1202
|
+
if mod.respond_to?(:chat)
|
|
1203
|
+
r = mod.chat(
|
|
1204
|
+
request: request,
|
|
1205
|
+
system_role_content: howto_sys,
|
|
1206
|
+
spinner: true
|
|
1207
|
+
)
|
|
1208
|
+
if r.is_a?(Hash)
|
|
1209
|
+
(r.dig(:choices, -1, :content) || r.dig(:choices, -1, :text) || r[:content]).to_s
|
|
1210
|
+
else
|
|
1211
|
+
r.to_s
|
|
1212
|
+
end
|
|
1213
|
+
else
|
|
1214
|
+
# chat_with_tools without tools
|
|
1215
|
+
messages = [
|
|
1216
|
+
{ role: 'system', content: howto_sys },
|
|
1217
|
+
{ role: 'user', content: request }
|
|
1218
|
+
]
|
|
1219
|
+
msg = call_engine(messages: messages, tools: nil)
|
|
1220
|
+
msg.is_a?(Hash) ? msg[:content].to_s : msg.to_s
|
|
1221
|
+
end
|
|
1222
|
+
|
|
1223
|
+
txt = txt.to_s.strip
|
|
1224
|
+
if txt.empty?
|
|
1225
|
+
txt = <<~FALLBACK
|
|
1226
|
+
Example hping3 ICMP ping sweep (lab / authorized targets only):
|
|
1227
|
+
|
|
1228
|
+
# Needs root/CAP_NET_RAW for raw sockets
|
|
1229
|
+
sudo hping3 -1 --flood -c 1 192.168.1.x
|
|
1230
|
+
# Or loop a /24 carefully (slow rate to avoid DoS):
|
|
1231
|
+
for i in $(seq 1 254); do
|
|
1232
|
+
sudo hping3 -1 -c 1 -N $i 192.168.1.$i 2>/dev/null | grep -q "icmp" && echo "up 192.168.1.$i"
|
|
1233
|
+
done
|
|
1234
|
+
|
|
1235
|
+
Prefer intentional CIDR targeting over blind sweeps. Live host
|
|
1236
|
+
discovery without explicit engagement/scope authorization is out
|
|
1237
|
+
of bounds for this agent.
|
|
1238
|
+
FALLBACK
|
|
1239
|
+
end
|
|
1240
|
+
|
|
1241
|
+
append_session(session_id: session_id, role: 'user', content: request)
|
|
1242
|
+
append_session(session_id: session_id, role: 'assistant', content: txt)
|
|
1243
|
+
if defined?(Learning) && should_auto_introspect?(local: local_engine?, turn_fails: {}, iter: 0)
|
|
1244
|
+
Learning.auto_introspect(
|
|
1245
|
+
session_id: session_id,
|
|
1246
|
+
request: request,
|
|
1247
|
+
final: txt,
|
|
1248
|
+
predicted: 0.9,
|
|
1249
|
+
plan: ['Explain tool usage without live recon'],
|
|
1250
|
+
ts_state: nil
|
|
1251
|
+
)
|
|
1252
|
+
end
|
|
1253
|
+
txt
|
|
1254
|
+
rescue StandardError => e
|
|
1255
|
+
warn "[pwn-ai/loop] answer_howto swallowed: #{e.class}: #{e.message}"
|
|
1256
|
+
"Could not produce a how-to answer (#{e.class}: #{e.message}). Retry with a frontier engine or ask for a specific flag/example."
|
|
1257
|
+
end
|
|
1258
|
+
|
|
1259
|
+
private_class_method def self.refuse_unauthorized_recon(opts = {})
|
|
1260
|
+
request = opts[:request].to_s
|
|
1261
|
+
session_id = opts[:session_id]
|
|
1262
|
+
txt = <<~REFUSE
|
|
1263
|
+
I will not run a live subnet sweep or raw-socket host discovery from this agent without explicit in-scope authorization.
|
|
1264
|
+
|
|
1265
|
+
Your request looks like active recon on "this" network/subnet. To proceed, restate with engagement language such as:
|
|
1266
|
+
- "authorized engagement" / "in-scope" / "lab only" / "I own this network"
|
|
1267
|
+
- or set PWN::Env[:ai][:agent][:recon_authorized] = true for this session
|
|
1268
|
+
|
|
1269
|
+
If you only wanted the command syntax, ask: how to do a ping sweep of a subnet using hping3?
|
|
1270
|
+
REFUSE
|
|
1271
|
+
append_session(session_id: session_id, role: 'user', content: request)
|
|
1272
|
+
append_session(session_id: session_id, role: 'assistant', content: txt)
|
|
1273
|
+
txt
|
|
1274
|
+
end
|
|
1275
|
+
|
|
829
1276
|
# Supported Method Parameters::
|
|
830
1277
|
# final = PWN::AI::Agent::Loop.run(
|
|
831
1278
|
# request: 'required - what the human typed',
|
|
@@ -842,13 +1289,27 @@ module PWN
|
|
|
842
1289
|
# Live coalesced "what am I doing" lines for the TUI (not a model tool).
|
|
843
1290
|
ts_state = (TaskSummarizer.fresh(request: request) if defined?(TaskSummarizer) && TaskSummarizer.enabled? && Thread.current[:pwn_reflect_depth].to_i.zero?)
|
|
844
1291
|
engine = active_engine
|
|
845
|
-
local = engine
|
|
1292
|
+
local = local_engine?(engine: engine)
|
|
846
1293
|
system_role_content = opts[:system_role_content] ||= PWN::AI::Agent::PromptBuilder.build(session_id: session_id, request: request)
|
|
847
1294
|
|
|
848
1295
|
Registry.discover
|
|
849
1296
|
expose_current_session(session_id: session_id)
|
|
850
1297
|
Mistakes.check_user_correction(request: request, session_id: session_id) if defined?(Mistakes)
|
|
851
1298
|
|
|
1299
|
+
intent = request_intent(request: request)
|
|
1300
|
+
Thread.current[:pwn_request_intent] = intent
|
|
1301
|
+
Thread.current[:pwn_recon_authorized] = recon_authorized?(request: request)
|
|
1302
|
+
# How-to: never enter plan_first / task recon / tool thrash (ollama/openwebui).
|
|
1303
|
+
if intent == :howto && opts[:force_tools] != true
|
|
1304
|
+
return answer_howto(
|
|
1305
|
+
request: request,
|
|
1306
|
+
session_id: session_id,
|
|
1307
|
+
system_role_content: system_role_content
|
|
1308
|
+
)
|
|
1309
|
+
end
|
|
1310
|
+
# Live recon without authorization: refuse rather than probe the LAN.
|
|
1311
|
+
return refuse_unauthorized_recon(request: request, session_id: session_id) if intent == :recon_act && !recon_authorized?(request: request) && opts[:force_tools] != true
|
|
1312
|
+
|
|
852
1313
|
# Initial tool pool from the user request (bootstrap only). After
|
|
853
1314
|
# TaskSummarizer.emit_plan! we re-rank using English tangible tasks
|
|
854
1315
|
# so generated tasks — not the bare request — drive which tools
|
|
@@ -875,7 +1336,8 @@ module PWN
|
|
|
875
1336
|
Thread.current[:pwn_plan_predicted] = nil
|
|
876
1337
|
cal_state = calibration_state
|
|
877
1338
|
force_plan = cal_state[:force_plan]
|
|
878
|
-
|
|
1339
|
+
skip_plan = (intent == :howto)
|
|
1340
|
+
if !skip_plan && (force_plan || agent_flag(key: :plan_first, default: local) || budget_exhaustion_hot?) && !Array(tools).empty?
|
|
879
1341
|
predicted = plan_first(messages: messages, request: request, ts_state: ts_state)
|
|
880
1342
|
# P22 — prefer explicit return; fall back to thread stash
|
|
881
1343
|
predicted = Thread.current[:pwn_plan_predicted] if predicted.nil?
|
|
@@ -889,7 +1351,7 @@ module PWN
|
|
|
889
1351
|
inject_task_focus!(messages: messages, state: ts_state, force: true)
|
|
890
1352
|
end
|
|
891
1353
|
if budget_exhaustion_hot?
|
|
892
|
-
hot_hint = if
|
|
1354
|
+
hot_hint = if local_engine?
|
|
893
1355
|
'[pwn-ai/p17] Budget-exhaustion is the top open failure on this host. ' \
|
|
894
1356
|
'Prefer the SHORTEST plan that finishes the ask (≤3 tool calls). ' \
|
|
895
1357
|
'Emit a final answer as soon as you have evidence — do not explore.'
|
|
@@ -949,7 +1411,7 @@ module PWN
|
|
|
949
1411
|
# Plan-faithful: delay the text-only strip when a plan is executing
|
|
950
1412
|
# cleanly. Remote hot allows longer plans (runway 25); local hot
|
|
951
1413
|
# still favors short plans under the 8-iter cap.
|
|
952
|
-
plan_step_limit =
|
|
1414
|
+
plan_step_limit = local_engine? ? 3 : 12
|
|
953
1415
|
plan_faithful = hot && plan_steps.positive? && plan_steps <= plan_step_limit &&
|
|
954
1416
|
turn_fails['empty_final'].to_i.zero? &&
|
|
955
1417
|
turn_fails.values.sum < 2
|
|
@@ -957,7 +1419,7 @@ module PWN
|
|
|
957
1419
|
if plan_faithful
|
|
958
1420
|
1
|
|
959
1421
|
else
|
|
960
|
-
(
|
|
1422
|
+
(local_engine? ? 3 : 2)
|
|
961
1423
|
end
|
|
962
1424
|
else
|
|
963
1425
|
1
|
|
@@ -984,6 +1446,20 @@ module PWN
|
|
|
984
1446
|
calls = Array(msg[:tool_calls])
|
|
985
1447
|
text = msg[:content].to_s
|
|
986
1448
|
|
|
1449
|
+
# Belt-and-suspenders: plain-text shell(...) / tool forms from local
|
|
1450
|
+
# models under weak TEMPLATE {{ .Prompt }} become real tool_calls.
|
|
1451
|
+
if calls.empty? && !text.strip.empty? && !last_iter &&
|
|
1452
|
+
defined?(Dispatch) && Dispatch.respond_to?(:tool_calls_from_text)
|
|
1453
|
+
coerced = Dispatch.tool_calls_from_text(text: text)
|
|
1454
|
+
if coerced.any?
|
|
1455
|
+
wired = coerced.map { |tc| openai_wire_tool_call(tool_call: tc) }
|
|
1456
|
+
msg = msg.merge(tool_calls: wired, content: nil, _text_tool_coerced: true)
|
|
1457
|
+
calls = wired
|
|
1458
|
+
text = ''
|
|
1459
|
+
warn "[pwn-ai/loop] coerced #{wired.length} text tool call(s) on iter=#{i}" if local
|
|
1460
|
+
end
|
|
1461
|
+
end
|
|
1462
|
+
|
|
987
1463
|
# Empty-final guard (local/thinking models): Ollama sometimes
|
|
988
1464
|
# returns done_reason=stop with eval_count<=1, empty content, no
|
|
989
1465
|
# tool_calls — historically surface as a blank TUI reply. Do NOT
|
|
@@ -1005,15 +1481,16 @@ module PWN
|
|
|
1005
1481
|
|
|
1006
1482
|
if calls.empty?
|
|
1007
1483
|
# P28 — refuse polite mid-goal handoffs so multi-step tasks stay autonomous.
|
|
1008
|
-
if incomplete_final?(text: text, last_iter: last_iter) && turn_fails['incomplete_final'].to_i <
|
|
1484
|
+
if incomplete_final?(text: text, last_iter: last_iter) && turn_fails['incomplete_final'].to_i < 4
|
|
1009
1485
|
turn_fails['incomplete_final'] += 1
|
|
1010
1486
|
warn "[pwn-ai/loop] incomplete final on iter=#{i}; continuing autonomously"
|
|
1011
1487
|
messages << {
|
|
1012
1488
|
role: 'user',
|
|
1013
|
-
content: '[pwn-ai/p28] That reply
|
|
1014
|
-
'Do NOT
|
|
1015
|
-
'
|
|
1016
|
-
'
|
|
1489
|
+
content: '[pwn-ai/p28] That reply was incomplete (handoff or narrated next step). ' \
|
|
1490
|
+
'Do NOT monologue about what you will try. Do NOT ask the user to ' \
|
|
1491
|
+
'confirm. Emit NATIVE tool_calls NOW (e.g. shell with a concrete ' \
|
|
1492
|
+
'command). Never print shell(...) as plain text. Only emit a final ' \
|
|
1493
|
+
'answer when the request is complete or truly blocked with evidence.'
|
|
1017
1494
|
}
|
|
1018
1495
|
next
|
|
1019
1496
|
end
|
|
@@ -1190,8 +1667,12 @@ module PWN
|
|
|
1190
1667
|
Supported engines: #{ENGINE_MODS.keys.join(', ')}
|
|
1191
1668
|
Set PWN::Env[:ai][:active] to choose; PWN::Env[:ai][:agent][:max_iters] to bound.
|
|
1192
1669
|
|
|
1670
|
+
Intent routing (all engines; critical for ollama/openwebui):
|
|
1671
|
+
how-to / usage questions → text-only explanation (no tools, no plan_first)
|
|
1672
|
+
live subnet sweeps without scope language → refuse
|
|
1673
|
+
:recon_authorized - Boolean session flag to allow raw-socket / sweep tools
|
|
1193
1674
|
Local-model scaffolding (PWN::Env[:ai][:agent]):
|
|
1194
|
-
:plan_first - Boolean, plan-then-act pre-pass (default: engine
|
|
1675
|
+
:plan_first - Boolean, plan-then-act pre-pass (default: local engine :ollama/:openwebui)
|
|
1195
1676
|
:tool_router - Boolean/nil, slim Registry.definitions (nil=auto on for ollama)
|
|
1196
1677
|
:escalation_persona - Swarm persona name for frontier corrective hints when stuck
|
|
1197
1678
|
:critic - S3 constitutional critic before every final (Boolean)
|