pwn 0.5.757 → 0.5.759

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a9bcba2a6a58d7e3f7990a1dbbf0cc73a1234efe20bfd7dd0eae786ecc4c4c32
4
- data.tar.gz: 252972a638225c5ddd3693839eb4fe7db2311529e3ceb2a32f69f0cc3056fd27
3
+ metadata.gz: 1ef7228011e10bd0573cab148f192e8ef963608a002d4e3d833fcd2e380fd973
4
+ data.tar.gz: 8f238e16e7d0d521deb4b4abdad45fed2d15fb82d92bcc8448b0877bc23b8783
5
5
  SHA512:
6
- metadata.gz: e581b4c0dd6a4b1e05beccfade96bc635f48bf47c63168209b4900e6fb68a9d8e0b2394b4f6458a5aa96a9373248a0f96adb9ae0dcdeea2210f10f908bae630c
7
- data.tar.gz: c87a11936f1cf59e2d6373c2d941ccf33f5aa4edac9e91fa09e1c9b5b871bcf2def227c10a6659131bc4279e05c7b6d85e28eeb23ff701d3ebadb0a3359ba129
6
+ metadata.gz: d6eb621a41250c5332e00f941c8b6b7444f796fcb30c009d774d7a0814f3c268809152275f2c63f7f7076450c75bbe662c0248ec6b21abb9730b44b097fdb139
7
+ data.tar.gz: 8ea184fdfa2018bc59f00aef8585d190907236a3d71df302c21db3eb025813111cd5a7bb289513c5a7f51a816f1c7e094f5b6c0bf420ff50d9a7a27a11ffbc65
data/Gemfile CHANGED
@@ -68,7 +68,7 @@ gem 'ostruct', '0.6.3'
68
68
  gem 'packetfu', '2.0.0'
69
69
  gem 'packetgen', '4.1.1'
70
70
  gem 'pdf-reader', '2.16.0'
71
- gem 'pg', '1.6.3'
71
+ gem 'pg', '1.7.0'
72
72
  gem 'pry', '0.16.0'
73
73
  gem 'pry-doc', '1.7.0'
74
74
  gem 'rake', '13.4.2'
@@ -1923,12 +1923,16 @@ module PWN
1923
1923
 
1924
1924
  content = msg[:content]
1925
1925
  tool_calls = Array(msg[:tool_calls])
1926
+ promoted = false
1926
1927
  # Local/thinking models (Ollama Qwen3, DeepSeek-R1, etc.) sometimes
1927
1928
  # return only :thinking with empty :content and no tool_calls. Promote
1928
1929
  # thinking so the agent does not print a blank final answer.
1929
1930
  if content.to_s.strip.empty? && tool_calls.empty?
1930
1931
  thinking = msg[:thinking].to_s
1931
- content = thinking unless thinking.strip.empty?
1932
+ unless thinking.strip.empty?
1933
+ content = thinking
1934
+ promoted = true
1935
+ end
1932
1936
  end
1933
1937
 
1934
1938
  out = {
@@ -1953,6 +1957,7 @@ module PWN
1953
1957
  out[:_native_content] = msg[:_native_content] if msg[:_native_content]
1954
1958
  thinking = msg[:thinking] || msg[:reasoning_content]
1955
1959
  out[:thinking] = thinking if thinking.to_s.strip != ''
1960
+ out[:_thinking_promoted] = true if promoted
1956
1961
  # Local/abliterated models sometimes emit shell(...) as plain content
1957
1962
  # with empty tool_calls. Coerce registered call-shaped text into
1958
1963
  # structured tool_calls so Loop dispatches instead of FINAL-answering.
@@ -2297,6 +2302,17 @@ module PWN
2297
2302
  nil
2298
2303
  end
2299
2304
 
2305
+ private_class_method def self.emit_thinking!(opts = {})
2306
+ text = opts[:thinking].to_s.strip
2307
+ Thread.current[:pwn_last_thinking] = text
2308
+ Thread.current[:pwn_thinking_promoted] = opts[:promoted] ? true : false
2309
+ return if text.empty?
2310
+
2311
+ opts[:on_tool]&.call('thinking', text, '')
2312
+ rescue StandardError
2313
+ nil
2314
+ end
2315
+
2300
2316
  # Pre-dispatch: one high-level brief for the whole upcoming tool
2301
2317
  # collection (pwn-ai → task is one-to-many with pwn-ai → <tool>).
2302
2318
  # Prefer opts[:tools] = [{name:, args:}, ...]; falls back to single name.
@@ -2304,18 +2320,13 @@ module PWN
2304
2320
  state = opts[:state]
2305
2321
  return nil unless state && defined?(TaskSummarizer) && TaskSummarizer.enabled?
2306
2322
 
2307
- thinking = opts[:thinking].to_s.strip
2308
- line = if thinking.empty?
2309
- TaskSummarizer.about_to(
2310
- name: opts[:name],
2311
- args: opts[:args],
2312
- state: state,
2313
- request: opts[:request],
2314
- tools: opts[:tools]
2315
- )
2316
- else
2317
- thinking
2318
- end
2323
+ line = TaskSummarizer.about_to(
2324
+ name: opts[:name],
2325
+ args: opts[:args],
2326
+ state: state,
2327
+ request: opts[:request],
2328
+ tools: opts[:tools]
2329
+ )
2319
2330
  # about_to returns nil when the brief is a duplicate of the last one
2320
2331
  emit_task_summary(line: line, on_tool: opts[:on_tool]) if line
2321
2332
  line
@@ -3409,6 +3420,11 @@ module PWN
3409
3420
 
3410
3421
  calls = Array(msg[:tool_calls])
3411
3422
  text = msg[:content].to_s
3423
+ emit_thinking!(
3424
+ thinking: msg[:thinking] || msg[:reasoning_content],
3425
+ promoted: msg[:_thinking_promoted],
3426
+ on_tool: on_tool
3427
+ )
3412
3428
 
3413
3429
  # Belt-and-suspenders: plain-text shell(...) / tool forms from local
3414
3430
  # models under weak TEMPLATE {{ .Prompt }} become real tool_calls.
@@ -3525,7 +3541,6 @@ module PWN
3525
3541
  }
3526
3542
  end,
3527
3543
  request: request,
3528
- thinking: msg[:thinking] || msg[:reasoning_content],
3529
3544
  on_tool: on_tool
3530
3545
  )
3531
3546
 
data/lib/pwn/ai/ollama.rb CHANGED
@@ -436,6 +436,9 @@ module PWN
436
436
  # - function.arguments must be a Hash/Array object, not a JSON string
437
437
  # (string args → HTTP 400 "can't find closing '}' symbol")
438
438
  # - assistant content nil + tool_calls → "" (Open WebUI form validation)
439
+ # - system/developer rows after the first message are merged into one
440
+ # leading system message. Qwen-style templates raise HTTP 500
441
+ # "System message must be at the beginning" otherwise.
439
442
  private_class_method def self.parse_tool_arguments_object(opts = {})
440
443
  raw = opts[:arguments]
441
444
  case raw
@@ -458,7 +461,7 @@ module PWN
458
461
  end
459
462
 
460
463
  private_class_method def self.normalize_messages_for_ollama(opts = {})
461
- Array(opts[:messages]).filter_map do |m|
464
+ rows = Array(opts[:messages]).filter_map do |m|
462
465
  next unless m.is_a?(Hash)
463
466
 
464
467
  role = (m[:role] || m['role']).to_s
@@ -508,6 +511,71 @@ module PWN
508
511
 
509
512
  out
510
513
  end
514
+ lead_system(messages: rows, fold_into_user: opts[:fold_into_user])
515
+ end
516
+
517
+ # One system role is only safe when the server does not already prepend one.
518
+ # Models whose template raises "System message must be at the beginning"
519
+ # already carry a Modelfile SYSTEM prompt. Fold client system text into
520
+ # the first user message instead of sending a second system role.
521
+ private_class_method def self.lead_system(opts = {})
522
+ parts = []
523
+ rest = []
524
+ Array(opts[:messages]).each do |row|
525
+ next unless row.is_a?(Hash)
526
+
527
+ role = (row[:role] || row['role']).to_s
528
+ if %w[system developer].include?(role)
529
+ text = (row.key?(:content) ? row[:content] : row['content']).to_s
530
+ parts << text unless text.strip.empty?
531
+ else
532
+ rest << row
533
+ end
534
+ end
535
+ return rest if parts.empty?
536
+
537
+ text = parts.join("\n\n")
538
+ return [{ role: 'system', content: text }] + rest unless opts[:fold_into_user]
539
+
540
+ user = rest.find { |row| (row[:role] || row['role']).to_s == 'user' }
541
+ if user
542
+ body = (user.key?(:content) ? user[:content] : user['content']).to_s
543
+ user[:content] = "#{text}\n\n#{body}"
544
+ user.delete('content')
545
+ rest
546
+ else
547
+ [{ role: 'user', content: text }] + rest
548
+ end
549
+ end
550
+
551
+ private_class_method def self.strict_system_template?(opts = {})
552
+ name = opts[:model].to_s.strip
553
+ return false if name.empty?
554
+
555
+ @strict_system_cache ||= {}
556
+ return @strict_system_cache[name] if @strict_system_cache.key?(name)
557
+
558
+ @strict_system_cache[name] = template_rejects_later_system?(model: name)
559
+ rescue StandardError
560
+ false
561
+ end
562
+
563
+ private_class_method def self.template_rejects_later_system?(opts = {})
564
+ name = opts[:model].to_s
565
+ names = [name]
566
+ names << "#{name}:latest" unless name.include?(':')
567
+ names.each do |candidate|
568
+ raw = ollama_rest_call(
569
+ http_method: :post, rest_call: 'api/show',
570
+ http_body: { model: candidate }, timeout: 8, quiet: true, non_interactive: true
571
+ )
572
+ template = PWN::AI::ModelCatalog.parse_row(raw: raw)
573
+ text = template.is_a?(Hash) ? (template[:template] || template['template']).to_s : ''
574
+ return true if text.include?('System message must be at the beginning')
575
+ end
576
+ false
577
+ rescue StandardError
578
+ false
511
579
  end
512
580
 
513
581
  # Supported Method Parameters::
@@ -526,13 +594,16 @@ module PWN
526
594
  # NDJSON chunks back into a single response.
527
595
 
528
596
  public_class_method def self.chat_with_tools(opts = {})
529
- engine = PWN::Env[:ai][:ollama]
530
- messages = normalize_messages_for_ollama(messages: opts[:messages])
531
- raise 'ERROR: messages array is required' if messages.nil? || messages.empty?
532
-
597
+ engine = PWN::Env[:ai][:ollama]
533
598
  model = opts[:model] ||= engine[:model]
534
599
  raise 'ERROR: Model is required. Call #get_models method for details' if model.nil?
535
600
 
601
+ messages = normalize_messages_for_ollama(
602
+ messages: opts[:messages],
603
+ fold_into_user: strict_system_template?(model: model)
604
+ )
605
+ raise 'ERROR: messages array is required' if messages.nil? || messages.empty?
606
+
536
607
  temp = opts[:temp].to_f
537
608
  temp = engine[:temp].to_f.nonzero? || 1 if temp.zero?
538
609
 
@@ -656,6 +727,10 @@ module PWN
656
727
  end
657
728
 
658
729
  http_body[:messages].push(user_role)
730
+ http_body[:messages] = lead_system(
731
+ messages: http_body[:messages],
732
+ fold_into_user: strict_system_template?(model: model)
733
+ )
659
734
 
660
735
  timeout = opts[:timeout]
661
736
  spinner = opts[:spinner]
@@ -468,6 +468,9 @@ module PWN
468
468
  # - function.arguments must be a Hash/Array object, not a JSON string
469
469
  # (string args → HTTP 400 "can't find closing '}' symbol")
470
470
  # - assistant content nil + tool_calls → "" (Open WebUI form validation)
471
+ # - system/developer rows after the first message are merged into one
472
+ # leading system message. Qwen-style templates raise HTTP 500
473
+ # "System message must be at the beginning" otherwise.
471
474
  private_class_method def self.parse_tool_arguments_object(opts = {})
472
475
  raw = opts[:arguments]
473
476
  case raw
@@ -490,7 +493,7 @@ module PWN
490
493
  end
491
494
 
492
495
  private_class_method def self.normalize_messages_for_ollama(opts = {})
493
- Array(opts[:messages]).filter_map do |m|
496
+ rows = Array(opts[:messages]).filter_map do |m|
494
497
  next unless m.is_a?(Hash)
495
498
 
496
499
  role = (m[:role] || m['role']).to_s
@@ -540,6 +543,71 @@ module PWN
540
543
 
541
544
  out
542
545
  end
546
+ lead_system(messages: rows, fold_into_user: opts[:fold_into_user])
547
+ end
548
+
549
+ # One system role is only safe when the server does not already prepend one.
550
+ # mini-mythos-mission-ready has a Modelfile SYSTEM prompt and a template that
551
+ # raises if any later message has role system. Fold those rows into the
552
+ # first user message instead of sending a second system role.
553
+ private_class_method def self.lead_system(opts = {})
554
+ parts = []
555
+ rest = []
556
+ Array(opts[:messages]).each do |row|
557
+ next unless row.is_a?(Hash)
558
+
559
+ role = (row[:role] || row['role']).to_s
560
+ if %w[system developer].include?(role)
561
+ text = (row.key?(:content) ? row[:content] : row['content']).to_s
562
+ parts << text unless text.strip.empty?
563
+ else
564
+ rest << row
565
+ end
566
+ end
567
+ return rest if parts.empty?
568
+
569
+ text = parts.join("\n\n")
570
+ return [{ role: 'system', content: text }] + rest unless opts[:fold_into_user]
571
+
572
+ user = rest.find { |row| (row[:role] || row['role']).to_s == 'user' }
573
+ if user
574
+ body = (user.key?(:content) ? user[:content] : user['content']).to_s
575
+ user[:content] = "#{text}\n\n#{body}"
576
+ user.delete('content')
577
+ rest
578
+ else
579
+ [{ role: 'user', content: text }] + rest
580
+ end
581
+ end
582
+
583
+ private_class_method def self.strict_system_template?(opts = {})
584
+ name = opts[:model].to_s.strip
585
+ return false if name.empty?
586
+
587
+ @strict_system_cache ||= {}
588
+ return @strict_system_cache[name] if @strict_system_cache.key?(name)
589
+
590
+ @strict_system_cache[name] = template_rejects_later_system?(model: name)
591
+ rescue StandardError
592
+ false
593
+ end
594
+
595
+ private_class_method def self.template_rejects_later_system?(opts = {})
596
+ name = opts[:model].to_s
597
+ names = [name]
598
+ names << "#{name}:latest" unless name.include?(':')
599
+ names.each do |candidate|
600
+ raw = openwebui_rest_call(
601
+ http_method: :post, rest_call: 'ollama/api/show',
602
+ http_body: { model: candidate }, timeout: 8, quiet: true, non_interactive: true
603
+ )
604
+ template = PWN::AI::ModelCatalog.parse_row(raw: raw)
605
+ text = template.is_a?(Hash) ? (template[:template] || template['template']).to_s : ''
606
+ return true if text.include?('System message must be at the beginning')
607
+ end
608
+ false
609
+ rescue StandardError
610
+ false
543
611
  end
544
612
 
545
613
  # Supported Method Parameters::
@@ -559,13 +627,16 @@ module PWN
559
627
  # Bare POST /api/chat is not an API route on stock Open WebUI (405).
560
628
 
561
629
  public_class_method def self.chat_with_tools(opts = {})
562
- engine = PWN::Env[:ai][:openwebui]
563
- messages = normalize_messages_for_ollama(messages: opts[:messages])
564
- raise 'ERROR: messages array is required' if messages.nil? || messages.empty?
565
-
630
+ engine = PWN::Env[:ai][:openwebui]
566
631
  model = opts[:model] ||= engine[:model]
567
632
  raise 'ERROR: Model is required. Call #get_models method for details' unless real_config_value?(value: model)
568
633
 
634
+ messages = normalize_messages_for_ollama(
635
+ messages: opts[:messages],
636
+ fold_into_user: strict_system_template?(model: model)
637
+ )
638
+ raise 'ERROR: messages array is required' if messages.nil? || messages.empty?
639
+
569
640
  temp = opts[:temp].to_f
570
641
  temp = engine[:temp].to_f.nonzero? || 1 if temp.zero?
571
642
 
@@ -682,6 +753,10 @@ module PWN
682
753
  end
683
754
 
684
755
  http_body[:messages].push(user_role)
756
+ http_body[:messages] = lead_system(
757
+ messages: http_body[:messages],
758
+ fold_into_user: strict_system_template?(model: model)
759
+ )
685
760
 
686
761
  timeout = opts[:timeout]
687
762
  spinner = opts[:spinner]
@@ -266,7 +266,7 @@ module PWN
266
266
 
267
267
  # Own the terminal, event pump and the lifetime of exactly one request.
268
268
  class Console # rubocop:disable Metrics/ClassLength -- cohesive single-owner terminal state machine
269
- COLORS = { operator: :operator, task: :task, tool: :tool, result: :result, assistant: :assistant, notice: :notice, warning: :warning }.freeze
269
+ COLORS = { operator: :operator, task: :task, thinking: :notice, tool: :tool, result: :result, assistant: :assistant, notice: :notice, warning: :warning }.freeze
270
270
  PALETTE = %w[cyan green yellow red white blue magenta black].freeze
271
271
 
272
272
  def initialize(pry:, input:, curses:, getch:)
@@ -480,6 +480,8 @@ module PWN
480
480
  on_tool = lambda do |name, args, result|
481
481
  if name.to_s == 'task'
482
482
  @events << [:task, args.to_s]
483
+ elsif name.to_s == 'thinking'
484
+ @events << [:thinking, args.to_s]
483
485
  else
484
486
  @events << [:tool, "#{name}\n#{args.is_a?(String) ? args : args.inspect}"]
485
487
  @events << [:result, result.to_s]
@@ -503,7 +505,8 @@ module PWN
503
505
  enabled_toolsets: PWN::Env.dig(:ai, :agent, :toolsets),
504
506
  on_tool: on_tool, steering: control, debug: @pry.config.pwn_ai_debug, debug_tee: @output
505
507
  )
506
- @events << [:assistant, final.to_s]
508
+ promoted = Thread.current[:pwn_thinking_promoted] && final.to_s.strip == Thread.current[:pwn_last_thinking].to_s.strip
509
+ @events << [:assistant, final.to_s] unless promoted
507
510
  end
508
511
  rescue Control::Stopped => e
509
512
  @events << [:warning, e.message]
@@ -1652,6 +1655,7 @@ module PWN
1652
1655
  puts "USAGE:
1653
1656
  # Ctrl+L clears only the session pane, including while busy.
1654
1657
  # Ctrl+T toggles the active pane between SESSION and MISSION CONTROL. Ctrl+S opens swarm.
1658
+ # Thinking from a model that returns it is a THINKING row in the session pane, above the answer.
1655
1659
  # Up/Down select in menus, recall ~/.pwn/pwn_history in the mission pane, and scroll the session pane when it is active.
1656
1660
  # Search: Enter accepts without sending; Esc restores the draft.
1657
1661
  # Launch the single-owner curses console; non-terminals return unavailable.
@@ -279,6 +279,10 @@ module PWN
279
279
 
280
280
  def tool_callback(job, opts)
281
281
  lambda do |name, args, result|
282
+ if name.to_s == 'thinking'
283
+ opts[:on_event]&.call(:thinking, args.to_s)
284
+ return
285
+ end
282
286
  update(job, job[:state], last_tool: name)
283
287
  opts[:on_event]&.call(:tool, "#{job[:agent]} [#{job[:id]}] #{name}\n#{args.inspect}")
284
288
  opts[:on_event]&.call(:result, result.to_s)
@@ -561,6 +561,19 @@ module PWN
561
561
  mirror.call("#{header}\n#{body_out}")
562
562
  next
563
563
  end
564
+ if name.to_s == 'thinking'
565
+ body = args.is_a?(String) ? args.to_s : args.inspect
566
+ timestamp = Time.now.strftime('%Y-%m-%d %H:%M:%S%z')
567
+ header = "[ #{timestamp} → pwn-ai → thinking ]"
568
+ puts "\e[36m#{header}\e[0m"
569
+ body_out = +''
570
+ body.to_s.each_line do |ln|
571
+ puts "\e[36m #{ln.rstrip}\e[0m"
572
+ body_out << " #{ln.rstrip}\n"
573
+ end
574
+ mirror.call("#{header}\n#{body_out}")
575
+ next
576
+ end
564
577
 
565
578
  argv = args.is_a?(String) ? args.to_s : args.inspect
566
579
  timestamp = Time.now.strftime('%Y-%m-%d %H:%M:%S%z')
@@ -592,9 +605,12 @@ module PWN
592
605
  debug_tee: $stdout
593
606
  )
594
607
  $stdout.flush
595
- puts "\n\e[32m#{final}\e[0m\n\n"
596
- $stdout.flush
597
- PWN::Plugins::Log.mirror_tui!(msg: "\n#{final}\n\n") if pi.config.pwn_ai_debug && defined?(PWN::Plugins::Log)
608
+ promoted = Thread.current[:pwn_thinking_promoted] && final.to_s.strip == Thread.current[:pwn_last_thinking].to_s.strip
609
+ unless promoted
610
+ puts "\n\e[32m#{final}\e[0m\n\n"
611
+ $stdout.flush
612
+ PWN::Plugins::Log.mirror_tui!(msg: "\n#{final}\n\n") if pi.config.pwn_ai_debug && defined?(PWN::Plugins::Log)
613
+ end
598
614
  if pi.config.pwn_ai_debug && sess_id && PWN.const_defined?(:Sessions)
599
615
  PWN::Plugins::Log.progress(
600
616
  msg: "session=#{sess_id}",
data/lib/pwn/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PWN
4
- VERSION = '0.5.757'
4
+ VERSION = '0.5.759'
5
5
  end
@@ -2123,36 +2123,33 @@ describe PWN::AI::Agent::Loop do # rubocop:disable Metrics/BlockLength
2123
2123
  expect(src).to include('byteslice(0, 2_048)')
2124
2124
  end
2125
2125
 
2126
- describe 'thinking on the task TUI line' do
2127
- it 'prefers model thinking over the TaskSummarizer brief' do
2126
+ describe 'thinking on the session pane' do
2127
+ it 'keeps the task brief and emits thinking separately' do
2128
2128
  allow(PWN::AI::Agent::TaskSummarizer).to receive(:enabled?).and_return(true)
2129
2129
  allow(PWN::AI::Agent::TaskSummarizer).to receive(:about_to).and_return('task 1/3: scan')
2130
2130
  seen = []
2131
2131
  described_class.send(
2132
- :task_summary_about_to!,
2133
- state: { plan: ['scan'] },
2134
- tools: [{ name: 'shell' }],
2135
- request: 'scan the host',
2132
+ :emit_thinking!,
2136
2133
  thinking: 'Need to fingerprint the service before scanning.',
2137
2134
  on_tool: ->(name, args, _res) { seen << [name, args] }
2138
2135
  )
2139
- expect(seen).to eq([['task', 'Need to fingerprint the service before scanning.']])
2140
- expect(PWN::AI::Agent::TaskSummarizer).not_to have_received(:about_to)
2141
- end
2142
-
2143
- it 'falls back to the TaskSummarizer brief when thinking is blank' do
2144
- allow(PWN::AI::Agent::TaskSummarizer).to receive(:enabled?).and_return(true)
2145
- allow(PWN::AI::Agent::TaskSummarizer).to receive(:about_to).and_return('task 1/3: scan')
2146
- seen = []
2147
2136
  described_class.send(
2148
2137
  :task_summary_about_to!,
2149
2138
  state: { plan: ['scan'] },
2150
2139
  tools: [{ name: 'shell' }],
2151
2140
  request: 'scan the host',
2152
- thinking: ' ',
2153
2141
  on_tool: ->(name, args, _res) { seen << [name, args] }
2154
2142
  )
2155
- expect(seen).to eq([['task', 'task 1/3: scan']])
2143
+ expect(seen).to eq([
2144
+ ['thinking', 'Need to fingerprint the service before scanning.'],
2145
+ ['task', 'task 1/3: scan']
2146
+ ])
2147
+ end
2148
+
2149
+ it 'does not emit a blank thinking row' do
2150
+ seen = []
2151
+ described_class.send(:emit_thinking!, thinking: ' ', on_tool: ->(name, args, _res) { seen << [name, args] })
2152
+ expect(seen).to eq([])
2156
2153
  end
2157
2154
  end
2158
2155
 
@@ -12,10 +12,11 @@ describe PWN::AI::Ollama do
12
12
  allow(described_class).to receive(:sleep)
13
13
  allow(described_class).to receive(:rand).and_return(0.5)
14
14
  allow(PWN::AI::HttpRetry).to receive(:report_event)
15
+ described_class.instance_variable_set(:@strict_system_cache, nil)
15
16
  expect do
16
17
  described_class.chat_with_tools(messages: [{ role: 'user', content: 'hello' }], quiet: quiet)
17
18
  end.to raise_error(RuntimeError, /Ollama.*429.*rate limit exceeded/)
18
- expect(RestClient::Request).to have_received(:execute).exactly(5).times
19
+ expect(RestClient::Request).to have_received(:execute).exactly(6).times
19
20
  expect(described_class).to have_received(:sleep).with(2.5).exactly(4).times
20
21
  end
21
22
  end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ describe 'Ollama and Open WebUI system-message placement' do
6
+ it 'puts a later Ollama system message first so the chat template does not 500' do
7
+ engine = { model: 'mini-mythos-mission-ready', num_ctx: 2048, keep_alive: '1m', temp: 0.1, tool_temp: 0.1 }
8
+ allow(PWN::Env).to receive(:[]).and_call_original
9
+ allow(PWN::Env).to receive(:[]).with(:ai).and_return({ ollama: engine, active: 'ollama' })
10
+ captured = nil
11
+ allow(PWN::AI::Ollama).to receive(:ollama_rest_call) do |**kwargs|
12
+ captured = kwargs[:http_body]
13
+ '{"message":{"role":"assistant","content":"ok"}}'
14
+ end
15
+ PWN::AI::Ollama.chat_with_tools(
16
+ model: 'mini-mythos-mission-ready',
17
+ messages: [
18
+ { role: 'user', content: 'hello' },
19
+ { role: 'system', content: 'must lead' },
20
+ { role: 'developer', content: 'also lead' }
21
+ ]
22
+ )
23
+ expect(captured[:messages].map { |row| row[:role] }).to eq(%w[system user])
24
+ expect(captured[:messages][0][:content]).to include('must lead', 'also lead')
25
+ end
26
+
27
+ it 'folds Open WebUI system text into the user message when the template rejects a second system role' do
28
+ engine = { model: 'mini-mythos-mission-ready:latest', num_ctx: 2048, keep_alive: '1m', temp: 0.1, tool_temp: 0.1 }
29
+ allow(PWN::Env).to receive(:[]).and_call_original
30
+ allow(PWN::Env).to receive(:[]).with(:ai).and_return({ openwebui: engine, active: 'openwebui' })
31
+ PWN::AI::OpenWebUI.instance_variable_set(:@strict_system_cache, nil)
32
+ captured = nil
33
+ allow(PWN::AI::OpenWebUI).to receive(:openwebui_rest_call) do |**kwargs|
34
+ if kwargs[:rest_call].to_s.end_with?('show')
35
+ { template: "raise_exception('System message must be at the beginning.')" }.to_json
36
+ else
37
+ captured = kwargs[:http_body]
38
+ '{"message":{"role":"assistant","content":"ok"}}'
39
+ end
40
+ end
41
+ PWN::AI::OpenWebUI.chat_with_tools(
42
+ model: 'mini-mythos-mission-ready:latest',
43
+ messages: [
44
+ { role: 'system', content: 'operator rules' },
45
+ { role: 'user', content: 'ping' },
46
+ { role: 'system', content: 'ENGAGEMENT MEMORY' }
47
+ ]
48
+ )
49
+ expect(captured[:messages].map { |row| row[:role] }).to eq(%w[user])
50
+ expect(captured[:messages][0][:content]).to include('operator rules', 'ENGAGEMENT MEMORY', 'ping')
51
+ end
52
+ end
@@ -12,10 +12,11 @@ describe PWN::AI::OpenWebUI do
12
12
  allow(described_class).to receive(:sleep)
13
13
  allow(described_class).to receive(:rand).and_return(0.5)
14
14
  allow(PWN::AI::HttpRetry).to receive(:report_event)
15
+ described_class.instance_variable_set(:@strict_system_cache, nil)
15
16
  expect do
16
17
  described_class.chat_with_tools(messages: [{ role: 'user', content: 'hello' }], quiet: quiet)
17
18
  end.to raise_error(RuntimeError, /Open WebUI.*429.*rate limit exceeded/)
18
- expect(RestClient::Request).to have_received(:execute).exactly(5).times
19
+ expect(RestClient::Request).to have_received(:execute).exactly(6).times
19
20
  expect(described_class).to have_received(:sleep).with(2.5).exactly(4).times
20
21
  end
21
22
  end
@@ -83,6 +84,33 @@ describe PWN::AI::OpenWebUI do
83
84
  expect(captured_chat[:stream]).to be true
84
85
  end
85
86
 
87
+ it 'merges later system and developer messages into one leading system message' do
88
+ engine = { model: 'mini-mythos-mission-ready', num_ctx: 2048, keep_alive: '1m', temp: 0.1, tool_temp: 0.1 }
89
+ allow(PWN::Env).to receive(:[]).and_call_original
90
+ allow(PWN::Env).to receive(:[]).with(:ai).and_return({ openwebui: engine, active: 'openwebui' })
91
+ captured = nil
92
+ allow(described_class).to receive(:openwebui_rest_call) do |**kwargs|
93
+ captured = kwargs[:http_body]
94
+ '{"message":{"role":"assistant","content":"ok"}}'
95
+ end
96
+ described_class.chat_with_tools(
97
+ messages: [
98
+ { role: 'system', content: 'operator rules' },
99
+ { role: 'user', content: 'hello' },
100
+ { role: 'system', content: 'ENGAGEMENT MEMORY\nnotes' },
101
+ { role: 'developer', content: 'mid-turn reminder' },
102
+ { role: 'assistant', content: '', tool_calls: [{ id: 'c1', type: 'function', function: { name: 'shell', arguments: '{"cmd":"id"}' } }] }
103
+ ],
104
+ model: 'mini-mythos-mission-ready'
105
+ )
106
+ roles = captured[:messages].map { |row| row[:role] }
107
+ expect(roles).to eq(%w[system user assistant])
108
+ expect(captured[:messages][0][:content]).to include('operator rules')
109
+ expect(captured[:messages][0][:content]).to include('ENGAGEMENT MEMORY')
110
+ expect(captured[:messages][0][:content]).to include('mid-turn reminder')
111
+ expect(captured[:messages][2][:tool_calls].first[:function][:arguments]).to eq({ cmd: 'id' })
112
+ end
113
+
86
114
  it 'assembles native /api/chat NDJSON stream into a single message' do
87
115
  body = [
88
116
  { model: 'm', message: { role: 'assistant', content: 'Hel' }, done: false }.to_json,
@@ -389,6 +389,19 @@ describe 'pwn-ai curses launch' do # rubocop:disable Metrics/BlockLength -- publ
389
389
  expect(colors).to eq(operator_label: 4, operator_body: 5, assistant: 5, task: 2, tool: 1, result: 3)
390
390
  end
391
391
 
392
+ it 'shows model thinking in the session pane even when compact' do
393
+ pry = Pry.new
394
+ console = PWN::Plugins::REPL::AIConsole::Console.new(pry: pry, input: StringIO.new, curses: nil, getch: nil)
395
+ console.instance_variable_set(:@verbose, false)
396
+ console.add(:thinking, 'The lemon is yellow because of carotenoids.')
397
+ console.add(:assistant, 'Yellow.')
398
+ text = console.timeline_rows(60).map { |_color, row| row }.join("\n")
399
+ expect(text).to include('THINKING')
400
+ expect(text).to include('The lemon is yellow because of carotenoids.')
401
+ expect(text).to include('ASSISTANT')
402
+ expect(text).to include('Yellow.')
403
+ end
404
+
392
405
  it 'uses template colors for every unconfigured role without mutating defaults or overrides' do
393
406
  defaults = PWN::Config.env_template.dig(:ai, :tui, :theme)
394
407
  [nil, {}, 'invalid'].each do |configured|
@@ -403,6 +403,7 @@
403
403
  {"messages":[{"role":"user","content":"PWN::AI::Agent::Loop.dispatch_fail_n Usage"},{"role":"assistant","content":"`PWN::AI::Agent::Loop.dispatch_fail_n`: "}]}
404
404
  {"messages":[{"role":"user","content":"PWN::AI::Agent::Loop.duration_unsatisfied? Usage"},{"role":"assistant","content":"`PWN::AI::Agent::Loop.duration_unsatisfied?`: "}]}
405
405
  {"messages":[{"role":"user","content":"PWN::AI::Agent::Loop.emit_task_summary Usage"},{"role":"assistant","content":"`PWN::AI::Agent::Loop.emit_task_summary`: "}]}
406
+ {"messages":[{"role":"user","content":"PWN::AI::Agent::Loop.emit_thinking! Usage"},{"role":"assistant","content":"`PWN::AI::Agent::Loop.emit_thinking!`: "}]}
406
407
  {"messages":[{"role":"user","content":"PWN::AI::Agent::Loop.engine_transient? Usage"},{"role":"assistant","content":"`PWN::AI::Agent::Loop.engine_transient?`: "}]}
407
408
  {"messages":[{"role":"user","content":"PWN::AI::Agent::Loop.escalate Usage"},{"role":"assistant","content":"`PWN::AI::Agent::Loop.escalate`: "}]}
408
409
  {"messages":[{"role":"user","content":"PWN::AI::Agent::Loop.evidence_blob Usage"},{"role":"assistant","content":"`PWN::AI::Agent::Loop.evidence_blob`: "}]}
@@ -1201,10 +1202,13 @@
1201
1202
  {"messages":[{"role":"user","content":"PWN::AI::Ollama.get_model Usage"},{"role":"assistant","content":"`PWN::AI::Ollama.get_model`: Supported Method Parameters\n\nmodel = PWN::AI::Ollama.get_model(name: ‘required - tag name’)\n\nLocal tags have size and family, not token prices. get_model reads /api/show for that tag and returns it. Missing pricing stays missing.\n"}]}
1202
1203
  {"messages":[{"role":"user","content":"PWN::AI::Ollama.get_models Usage"},{"role":"assistant","content":"`PWN::AI::Ollama.get_models`: Supported Method Parameters\n\nresponse = PWN::AI::Ollama.get_models\n"}]}
1203
1204
  {"messages":[{"role":"user","content":"PWN::AI::Ollama.help Usage"},{"role":"assistant","content":"`PWN::AI::Ollama.help`: "}]}
1205
+ {"messages":[{"role":"user","content":"PWN::AI::Ollama.lead_system Usage"},{"role":"assistant","content":"`PWN::AI::Ollama.lead_system`: "}]}
1204
1206
  {"messages":[{"role":"user","content":"PWN::AI::Ollama.normalize_messages_for_ollama Usage"},{"role":"assistant","content":"`PWN::AI::Ollama.normalize_messages_for_ollama`: "}]}
1205
1207
  {"messages":[{"role":"user","content":"PWN::AI::Ollama.ollama_rest_call Usage"},{"role":"assistant","content":"`PWN::AI::Ollama.ollama_rest_call`: Supported Method Parameters\n\nollama_rest_call(\n\nhttp_method: 'optional HTTP method (defaults to GET)',\nrest_call: 'required rest call path relative to base_uri (e.g. api/chat)',\nparams: 'optional params passed in the URI or HTTP Headers',\nhttp_body: 'optional HTTP body sent in HTTP methods that support it e.g. POST',\ntimeout: 'optional timeout in seconds (defaults to 900)',\nspinner: 'optional - display spinner (defaults to false)'\n\n)\n"}]}
1206
1208
  {"messages":[{"role":"user","content":"PWN::AI::Ollama.parse_tool_arguments_object Usage"},{"role":"assistant","content":"`PWN::AI::Ollama.parse_tool_arguments_object`: "}]}
1207
1209
  {"messages":[{"role":"user","content":"PWN::AI::Ollama.real_config_value? Usage"},{"role":"assistant","content":"`PWN::AI::Ollama.real_config_value?`: "}]}
1210
+ {"messages":[{"role":"user","content":"PWN::AI::Ollama.strict_system_template? Usage"},{"role":"assistant","content":"`PWN::AI::Ollama.strict_system_template?`: "}]}
1211
+ {"messages":[{"role":"user","content":"PWN::AI::Ollama.template_rejects_later_system? Usage"},{"role":"assistant","content":"`PWN::AI::Ollama.template_rejects_later_system?`: "}]}
1208
1212
  {"messages":[{"role":"user","content":"PWN::AI::Ollama.visible_from_thinking Usage"},{"role":"assistant","content":"`PWN::AI::Ollama.visible_from_thinking`: "}]}
1209
1213
  {"messages":[{"role":"user","content":"PWN::AI::OpenAI.api_endpoint Usage"},{"role":"assistant","content":"`PWN::AI::OpenAI.api_endpoint`: "}]}
1210
1214
  {"messages":[{"role":"user","content":"PWN::AI::OpenAI.authors Usage"},{"role":"assistant","content":"`PWN::AI::OpenAI.authors`: Author(s)\n\n0day Inc. <support@0dayinc.com>\n"}]}
@@ -1252,10 +1256,13 @@
1252
1256
  {"messages":[{"role":"user","content":"PWN::AI::OpenWebUI.get_model Usage"},{"role":"assistant","content":"`PWN::AI::OpenWebUI.get_model`: Supported Method Parameters\n\nmodel = PWN::AI::OpenWebUI.get_model(name: ‘required - model id’)\n\nOpen WebUI mixes OpenAI-compat rows and proxied Ollama tags. A direct model route is tried first; otherwise the already-fetched catalog row is returned. Local tags without a price stay unpriced.\n"}]}
1253
1257
  {"messages":[{"role":"user","content":"PWN::AI::OpenWebUI.get_models Usage"},{"role":"assistant","content":"`PWN::AI::OpenWebUI.get_models`: Supported Method Parameters\n\nresponse = PWN::AI::OpenWebUI.get_models\n"}]}
1254
1258
  {"messages":[{"role":"user","content":"PWN::AI::OpenWebUI.help Usage"},{"role":"assistant","content":"`PWN::AI::OpenWebUI.help`: "}]}
1259
+ {"messages":[{"role":"user","content":"PWN::AI::OpenWebUI.lead_system Usage"},{"role":"assistant","content":"`PWN::AI::OpenWebUI.lead_system`: "}]}
1255
1260
  {"messages":[{"role":"user","content":"PWN::AI::OpenWebUI.normalize_messages_for_ollama Usage"},{"role":"assistant","content":"`PWN::AI::OpenWebUI.normalize_messages_for_ollama`: "}]}
1256
1261
  {"messages":[{"role":"user","content":"PWN::AI::OpenWebUI.openwebui_rest_call Usage"},{"role":"assistant","content":"`PWN::AI::OpenWebUI.openwebui_rest_call`: Supported Method Parameters\n\nopenwebui_rest_call(\n\nhttp_method: 'optional HTTP method (defaults to GET)',\nrest_call: 'required rest call path relative to base_uri',\nparams: 'optional params passed in the URI or HTTP Headers',\nhttp_body: 'optional HTTP body sent in HTTP methods that support it e.g. POST',\ntimeout: 'optional timeout in seconds (defaults to 900)',\nspinner: 'optional - display spinner (defaults to false)'\n\n)\n"}]}
1257
1262
  {"messages":[{"role":"user","content":"PWN::AI::OpenWebUI.parse_tool_arguments_object Usage"},{"role":"assistant","content":"`PWN::AI::OpenWebUI.parse_tool_arguments_object`: "}]}
1258
1263
  {"messages":[{"role":"user","content":"PWN::AI::OpenWebUI.real_config_value? Usage"},{"role":"assistant","content":"`PWN::AI::OpenWebUI.real_config_value?`: "}]}
1264
+ {"messages":[{"role":"user","content":"PWN::AI::OpenWebUI.strict_system_template? Usage"},{"role":"assistant","content":"`PWN::AI::OpenWebUI.strict_system_template?`: "}]}
1265
+ {"messages":[{"role":"user","content":"PWN::AI::OpenWebUI.template_rejects_later_system? Usage"},{"role":"assistant","content":"`PWN::AI::OpenWebUI.template_rejects_later_system?`: "}]}
1259
1266
  {"messages":[{"role":"user","content":"PWN::AI::OpenWebUI.visible_from_thinking Usage"},{"role":"assistant","content":"`PWN::AI::OpenWebUI.visible_from_thinking`: "}]}
1260
1267
  {"messages":[{"role":"user","content":"PWN::AI::RedTeam.authors Usage"},{"role":"assistant","content":"`PWN::AI::RedTeam.authors`: Author(s)\n\n0day Inc. <support@0dayinc.com>\n"}]}
1261
1268
  {"messages":[{"role":"user","content":"PWN::AI::RedTeam.help Usage"},{"role":"assistant","content":"`PWN::AI::RedTeam.help`: "}]}
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pwn
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.757
4
+ version: 0.5.759
5
5
  platform: ruby
6
6
  authors:
7
7
  - 0day Inc.
@@ -771,14 +771,14 @@ dependencies:
771
771
  requirements:
772
772
  - - '='
773
773
  - !ruby/object:Gem::Version
774
- version: 1.6.3
774
+ version: 1.7.0
775
775
  type: :development
776
776
  prerelease: false
777
777
  version_requirements: !ruby/object:Gem::Requirement
778
778
  requirements:
779
779
  - - '='
780
780
  - !ruby/object:Gem::Version
781
- version: 1.6.3
781
+ version: 1.7.0
782
782
  - !ruby/object:Gem::Dependency
783
783
  name: pry
784
784
  requirement: !ruby/object:Gem::Requirement
@@ -6754,6 +6754,7 @@ files:
6754
6754
  - spec/lib/pwn/ai/model_catalog_spec.rb
6755
6755
  - spec/lib/pwn/ai/model_lookup_spec.rb
6756
6756
  - spec/lib/pwn/ai/ollama_spec.rb
6757
+ - spec/lib/pwn/ai/ollama_system_message_spec.rb
6757
6758
  - spec/lib/pwn/ai/open_ai_oauth_transport_spec.rb
6758
6759
  - spec/lib/pwn/ai/open_ai_spec.rb
6759
6760
  - spec/lib/pwn/ai/open_web_ui_spec.rb