pwn 0.5.674 → 0.5.676

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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +1 -1
  3. data/bin/pwn +2 -0
  4. data/bin/pwn_setup +28 -2
  5. data/lib/pwn/ai/agent/learning.rb +5 -0
  6. data/lib/pwn/ai/agent/loop.rb +13 -0
  7. data/lib/pwn/ai/agent/policy.rb +15 -0
  8. data/lib/pwn/ai/agent/prompt_builder.rb +4 -1
  9. data/lib/pwn/ai/agent/prompt_cache.rb +253 -0
  10. data/lib/pwn/ai/agent/reflect.rb +3 -1
  11. data/lib/pwn/ai/agent/reward.rb +27 -3
  12. data/lib/pwn/ai/agent/task_summarizer.rb +20 -3
  13. data/lib/pwn/ai/agent/turn_finalizer.rb +231 -0
  14. data/lib/pwn/ai/agent.rb +2 -0
  15. data/lib/pwn/ai/anthropic.rb +139 -5
  16. data/lib/pwn/ai/gemini.rb +24 -1
  17. data/lib/pwn/ai/grok.rb +202 -9
  18. data/lib/pwn/ai/ollama.rb +30 -5
  19. data/lib/pwn/ai/open_ai.rb +152 -10
  20. data/lib/pwn/ai/open_web_ui.rb +12 -0
  21. data/lib/pwn/ai.rb +86 -0
  22. data/lib/pwn/config.rb +7 -0
  23. data/lib/pwn/cron.rb +1297 -64
  24. data/lib/pwn/plugins/repl.rb +3 -2
  25. data/lib/pwn/setup.rb +98 -0
  26. data/lib/pwn/version.rb +1 -1
  27. data/spec/documentation/installation_md_spec.rb +6 -3
  28. data/spec/lib/pwn/ai/agent/loop_spec.rb +13 -0
  29. data/spec/lib/pwn/ai/agent/policy_spec.rb +22 -0
  30. data/spec/lib/pwn/ai/agent/prompt_builder_spec.rb +13 -0
  31. data/spec/lib/pwn/ai/agent/prompt_cache_spec.rb +118 -0
  32. data/spec/lib/pwn/ai/agent/reward_spec.rb +6 -0
  33. data/spec/lib/pwn/ai/agent/turn_finalizer_spec.rb +106 -0
  34. data/spec/lib/pwn/ai/anthropic_spec.rb +12 -0
  35. data/spec/lib/pwn/ai/gemini_spec.rb +10 -0
  36. data/spec/lib/pwn/ai/grok_spec.rb +21 -0
  37. data/spec/lib/pwn/ai/ollama_spec.rb +30 -0
  38. data/spec/lib/pwn/ai/open_ai_spec.rb +40 -0
  39. data/spec/lib/pwn/ai/open_web_ui_spec.rb +9 -0
  40. data/spec/lib/pwn/ai_spec.rb +23 -0
  41. data/spec/lib/pwn/cron_spec.rb +289 -0
  42. data/spec/lib/pwn/plugins/repl_spec.rb +12 -0
  43. data/spec/lib/pwn/setup_spec.rb +74 -0
  44. data/spec/support/sandbox.rb +4 -0
  45. data/third_party/pwn_rdoc.jsonl +140 -1
  46. metadata +7 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a62ba09707c5e4256e02e501ace75b21fd3d29e0f3239b64e054743e8481deaa
4
- data.tar.gz: 8c3a604756f02a0f3e47fc7633cc6ed6b7362adb1613a82db8418668b2e3a1fa
3
+ metadata.gz: 65d8aa03f93adc593efbfb8b342885f8ceb815a19a6a8ef8e72c3b0a493a32a5
4
+ data.tar.gz: eee3d957a5975a105240040cf6bd2f0a44a05d35cbac3240ec252f913cc38c5e
5
5
  SHA512:
6
- metadata.gz: cd13158fb37c453d6ed9eb20008ac7896cd9c31c1f4efdc60a18eaa00878ae1bd0a46cdb640ff4a49bd2edd9eeeb6e14a42f2c59c764916641b57a2a040e6477
7
- data.tar.gz: 39a6032209299723a26bb76160b980e4c51f3e7ba5e0bd06d7f1228bde1e5637ef9d91ed7f639071e42f6eeaaa8d9e2be688b678551dc6d14faddf1d8137f492
6
+ metadata.gz: 03f6a8960538e278b4041981a64ad27c4163e3748a11866c559c81da75152644f62b61658f695369f0281832e03d0810f012f47387b3ff4c456ad2df964b365c
7
+ data.tar.gz: 9b3eec90f64577c1167ae556d713f07f82f1c76d43255a14b7a1b69eaec5cfa2bdd9d99343d0a338aef40d1aa9e6fbd2c4c89d72e9e5d7b35b6a69ab504cdc07
data/Gemfile CHANGED
@@ -49,7 +49,7 @@ gem 'jwt', '3.2.0'
49
49
  gem 'libusb', '0.8.0'
50
50
  gem 'luhn', '3.0.0'
51
51
  gem 'mail', '2.9.1'
52
- gem 'mcp', '1.1.0'
52
+ gem 'mcp', '1.2.0'
53
53
  gem 'meshtastic', '0.0.173'
54
54
  gem 'metasm', '1.0.6'
55
55
  gem 'mongo', '2.25.0'
data/bin/pwn CHANGED
@@ -51,9 +51,11 @@ begin
51
51
  # ------------------------------------------------------------------
52
52
  if opts[:setup] == true
53
53
  r = PWN::Setup.check
54
+ PWN::Setup.ensure_cron(restart: false)
54
55
  exit(r[:ok] ? 0 : 1)
55
56
  else
56
57
  PWN::Setup.deps(profile: opts[:setup])
58
+ PWN::Setup.ensure_cron(restart: false)
57
59
  end
58
60
  elsif opts[:ai]
59
61
  # ------------------------------------------------------------------
data/bin/pwn_setup CHANGED
@@ -54,11 +54,27 @@ pwn_driver = PWN::Driver::Parser.new do |options|
54
54
  '<Optional - Print the commands that would run, do not execute>') do |o|
55
55
  opts[:dry_run] = o
56
56
  end
57
+
58
+ options.on('--[no-]cron',
59
+ '<Optional - Enable (default) or disable OS-native persistence of pwn-ai cron jobs>') do |o|
60
+ opts[:cron] = o
61
+ end
57
62
  end
58
63
  pwn_driver.auto_opts_help = false
59
64
  pwn_driver.parse!
60
65
 
61
66
  begin
67
+ status = 0
68
+ # Seed default jobs + start/reuse the in-process scheduler so last_run
69
+ # actually advances. Do this BEFORE the doctor so `pwn setup` reports
70
+ # the worker as running. Skip list/terminal (no host scheduler needed)
71
+ # and dry-run (must not spawn).
72
+ unless opts[:list] || opts[:terminal] || opts[:dry_run]
73
+ cron_opts = { restart: false, dry_run: false }
74
+ cron_opts[:enabled] = opts[:cron] unless opts[:cron].nil?
75
+ PWN::Setup.ensure_cron(**cron_opts)
76
+ end
77
+
62
78
  if opts[:list]
63
79
  PWN::Setup.list_profiles
64
80
  elsif opts[:migrate]
@@ -66,7 +82,7 @@ begin
66
82
  fix: opts[:fix] || opts[:yes],
67
83
  dry_run: opts[:dry_run]
68
84
  )
69
- exit 1 unless r && r[:after] && r[:after][:incompatible].empty?
85
+ status = 1 unless r && r[:after] && r[:after][:incompatible].empty?
70
86
  elsif opts[:terminal]
71
87
  PWN::Setup.terminal(
72
88
  yes: opts[:yes],
@@ -83,8 +99,18 @@ begin
83
99
  # `pwn setup --check` → same
84
100
  # `pwn setup --check --profile net` → gate ONLY on the :net profile
85
101
  r = PWN::Setup.check(profile: opts[:profile])
86
- exit 1 unless r[:ok]
102
+ status = 1 unless r[:ok]
103
+ end
104
+
105
+ # Seed default jobs + start/reuse the in-process scheduler so
106
+ # last_run actually advances. Skip list/terminal (no host scheduler
107
+ # needed) and dry-run (must not spawn).
108
+ unless opts[:list] || opts[:terminal] || opts[:dry_run]
109
+ cron_opts = { restart: false, dry_run: false }
110
+ cron_opts[:enabled] = opts[:cron] unless opts[:cron].nil?
111
+ PWN::Setup.ensure_cron(**cron_opts)
87
112
  end
113
+ exit status
88
114
  rescue Interrupt
89
115
  puts "\naborted."
90
116
  exit 130
@@ -460,6 +460,11 @@ module PWN
460
460
  return unless session_id
461
461
  return unless auto_introspect_enabled?
462
462
 
463
+ # Hermes split: Loop.run is on the stack → defer critic/judge/PRM/HER
464
+ # onto a daemon thread so the user already has the reply. Specs,
465
+ # cron, and tools pass through (inline:true or no user-path depth).
466
+ return TurnFinalizer.defer(opts.merge(inline: true)) if defined?(TurnFinalizer) && !opts[:inline] && TurnFinalizer.should_defer?
467
+
463
468
  t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
464
469
  stages_run = []
465
470
  stages_skipped = []
@@ -1063,6 +1063,16 @@ module PWN
1063
1063
  # 3.2 — local models cannot afford auto_introspect (judge+prm+critic+
1064
1064
  # sentinel+extro) on every success. Default :failure_only when local.
1065
1065
  private_class_method def self.should_auto_introspect?(opts = {})
1066
+ kind = (opts[:kind] || Thread.current[:pwn_request_kind]).to_s.to_sym
1067
+ 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.
1072
+ return false if %i[greeting howto recall].include?(intent)
1073
+ return false if kind == :statement
1074
+ return false if kind == :question && fails.zero?
1075
+
1066
1076
  return true unless opts[:local]
1067
1077
 
1068
1078
  policy = agent_flag(key: :local_introspect, default: :failure_only).to_s.to_sym
@@ -1865,6 +1875,7 @@ module PWN
1865
1875
  request = opts[:request].to_s
1866
1876
  session_id = opts[:session_id]
1867
1877
  on_tool = opts[:on_tool]
1878
+ TurnFinalizer.enter_user_path! if defined?(TurnFinalizer)
1868
1879
  # Live coalesced "what am I doing" lines for the TUI (not a model tool).
1869
1880
  ts_state = (TaskSummarizer.fresh(request: request) if defined?(TaskSummarizer) && TaskSummarizer.enabled? && Thread.current[:pwn_reflect_depth].to_i.zero?)
1870
1881
  engine = active_engine
@@ -2297,6 +2308,8 @@ module PWN
2297
2308
  maybe_finish_policy(session_id: session_id, proxy_ok: false, ts_state: ts_state)
2298
2309
  task_summary_flush!(state: ts_state, on_tool: on_tool)
2299
2310
  final_msg
2311
+ ensure
2312
+ TurnFinalizer.leave_user_path! if defined?(TurnFinalizer)
2300
2313
  end
2301
2314
 
2302
2315
  # Author(s):: 0day Inc. <support@0dayinc.com>
@@ -434,6 +434,21 @@ module PWN
434
434
  Thread.current[EP_KEY]
435
435
  end
436
436
 
437
+ # Hermes split: snapshot + clear the live episode so Loop.maybe_finish_policy
438
+ # is a no-op on the user-visible path while TurnFinalizer re-attaches it
439
+ # on the background review thread.
440
+
441
+ public_class_method def self.detach_episode!
442
+ ep = Thread.current[EP_KEY]
443
+ Thread.current[EP_KEY] = nil
444
+ ep
445
+ end
446
+
447
+ public_class_method def self.attach_episode!(opts = {})
448
+ Thread.current[EP_KEY] = opts[:episode]
449
+ opts[:episode]
450
+ end
451
+
437
452
  # ----------------------------------------------------------------
438
453
  # Persistence / eval
439
454
  # ----------------------------------------------------------------
@@ -41,6 +41,9 @@ module PWN
41
41
 
42
42
  # Heredoc (not a "..." literal): an unescaped "..." inside a
43
43
  # double-quoted string is parsed as Range (begin..."...end).
44
+ # Skills sit in the STATIC prefix (Hermes index) so prompt-cache
45
+ # breakpoints can pin the persona + SKILLS list; MEMORY/LEARNING
46
+ # remain in the dynamic tail.
44
47
  <<~PROMPT
45
48
  #{base}
46
49
 
@@ -51,7 +54,7 @@ module PWN
51
54
  pwn : #{pwn_version}
52
55
  session_id : #{session_id || '(none)'}
53
56
 
54
- #{memory_block(limit: b[:memory], request: request)}#{recent_turns_block(session_id: session_id, request: request, limit: b[:recent_turns])}#{skills_block}#{learning_block(limit: b[:learning])}#{mistakes_block(limit: b[:mistakes], request: request)}#{metrics_block(limit: b[:metrics], engine: engine)}#{policy_block if b[:policy].to_i.positive?}#{extrospection_block if b[:extro]}TOOL USE
57
+ #{skills_block}#{memory_block(limit: b[:memory], request: request)}#{recent_turns_block(session_id: session_id, request: request, limit: b[:recent_turns])}#{learning_block(limit: b[:learning])}#{mistakes_block(limit: b[:mistakes], request: request)}#{metrics_block(limit: b[:metrics], engine: engine)}#{policy_block if b[:policy].to_i.positive?}#{extrospection_block if b[:extro]}TOOL USE
55
58
  Use the provided function tools to act on the host via NATIVE
56
59
  tool_calls / function calling — never print tool invocations as
57
60
  plain text (e.g. do NOT write shell(command="...") as your answer).
@@ -0,0 +1,253 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+
5
+ module PWN
6
+ module AI
7
+ module Agent
8
+ # Hermes-style prompt-cache breakpoints. The static system prefix +
9
+ # SKILLS index stay cacheable; dynamic MEMORY / LEARNING / MISTAKES /
10
+ # METRICS / EXTROSPECTION stay uncached so a turn-local write is not
11
+ # baked into the prefix.
12
+ #
13
+ # Anthropic: system is an array of text blocks; the last static block
14
+ # carries cache_control: { type: 'ephemeral' }.
15
+ # OpenAI Chat Completions: two system/developer messages (static then
16
+ # dynamic) plus prompt_cache_key for cache routing.
17
+ # xAI Grok Chat Completions: the same message split plus the
18
+ # x-grok-conv-id request header (sticky server routing).
19
+ # Gemini generateContent: systemInstruction.parts = [static, dynamic]
20
+ # so implicit prefix cache can hit the stable head.
21
+ # Ollama: no-op.
22
+ module PromptCache
23
+ STATIC_HEAD = %w[ENVIRONMENT].freeze
24
+ DYNAMIC_HEADS = [
25
+ 'MEMORY',
26
+ 'RECENT TURNS',
27
+ 'LEARNING',
28
+ 'OPERATOR INBOX',
29
+ 'KNOWN MISTAKES',
30
+ 'KNOWN FIXES',
31
+ 'TOOL EFFECTIVENESS',
32
+ 'POLICY',
33
+ 'EXTROSPECTION',
34
+ 'TOOL USE'
35
+ ].freeze
36
+
37
+ # Supported Method Parameters::
38
+ # ok = PWN::AI::Agent::PromptCache.enabled?(engine: :anthropic)
39
+
40
+ public_class_method def self.enabled?(opts = {})
41
+ flag = agent_flag(key: :prompt_cache, default: true)
42
+ return false if flag == false || flag.to_s.match?(/\A(0|false|no|off)\z/i)
43
+
44
+ engine = (opts[:engine] || active_engine).to_s.downcase.to_sym
45
+ supports.include?(engine)
46
+ rescue StandardError
47
+ false
48
+ end
49
+
50
+ # Supported Method Parameters::
51
+ # parts = PWN::AI::Agent::PromptCache.split_system(text: system_prompt)
52
+ #
53
+ # → { static: String, dynamic: String, skills: String }
54
+
55
+ public_class_method def self.split_system(opts = {})
56
+ text = opts[:text].to_s
57
+ return { static: '', dynamic: '', skills: '' } if text.empty?
58
+
59
+ dyn_idx = first_dynamic_index(text: text)
60
+ if dyn_idx.nil?
61
+ skills = extract_skills(text: text)
62
+ return { static: text, dynamic: '', skills: skills }
63
+ end
64
+
65
+ static = text[0...dyn_idx].rstrip
66
+ dynamic = text[dyn_idx..].to_s
67
+ { static: static, dynamic: dynamic, skills: extract_skills(text: static) }
68
+ rescue StandardError
69
+ { static: opts[:text].to_s, dynamic: '', skills: '' }
70
+ end
71
+
72
+ # Supported Method Parameters::
73
+ # blocks = PWN::AI::Agent::PromptCache.anthropic_system_blocks(text: system_prompt)
74
+ #
75
+ # → [ {type:'text', text: static, cache_control:{type:'ephemeral'}},
76
+ # {type:'text', text: dynamic} ] (empty blocks omitted)
77
+
78
+ public_class_method def self.anthropic_system_blocks(opts = {})
79
+ parts = split_system(text: opts[:text])
80
+ blocks = []
81
+ unless parts[:static].to_s.strip.empty?
82
+ blocks << {
83
+ type: 'text',
84
+ text: parts[:static],
85
+ cache_control: { type: 'ephemeral' }
86
+ }
87
+ end
88
+ blocks << { type: 'text', text: parts[:dynamic] } unless parts[:dynamic].to_s.strip.empty?
89
+ blocks
90
+ rescue StandardError
91
+ txt = opts[:text].to_s
92
+ return [] if txt.strip.empty?
93
+
94
+ [{ type: 'text', text: txt }]
95
+ end
96
+
97
+ # Supported Method Parameters::
98
+ # messages = PWN::AI::Agent::PromptCache.openai_messages(messages: [...])
99
+ #
100
+ # Split the first system/developer message into a stable static
101
+ # prefix and a turn-local dynamic tail so OpenAI / xAI implicit
102
+ # prefix matching can hit across turns. Does NOT emit Anthropic
103
+ # cache_control.
104
+
105
+ public_class_method def self.openai_messages(opts = {})
106
+ messages = Array(opts[:messages]).map { |m| m.is_a?(Hash) ? m.dup : m }
107
+ idx = messages.index do |m|
108
+ next false unless m.is_a?(Hash)
109
+
110
+ %w[system developer].include?((m[:role] || m['role']).to_s)
111
+ end
112
+ return messages unless idx
113
+
114
+ msg = messages[idx]
115
+ role = msg[:role] || msg['role']
116
+ text = (msg[:content] || msg['content']).to_s
117
+ parts = split_system(text: text)
118
+ return messages if parts[:static].to_s.strip.empty? || parts[:dynamic].to_s.strip.empty?
119
+
120
+ static_msg = msg.dup
121
+ if static_msg.key?(:content) || !static_msg.key?('content')
122
+ static_msg[:content] = parts[:static]
123
+ else
124
+ static_msg['content'] = parts[:static]
125
+ end
126
+
127
+ dynamic_msg = if msg.key?(:role) || !msg.key?('role')
128
+ { role: role, content: parts[:dynamic] }
129
+ else
130
+ { 'role' => role, 'content' => parts[:dynamic] }
131
+ end
132
+
133
+ messages[idx, 1] = [static_msg, dynamic_msg]
134
+ messages
135
+ rescue StandardError
136
+ Array(opts[:messages])
137
+ end
138
+
139
+ # Supported Method Parameters::
140
+ # inst = PWN::AI::Agent::PromptCache.gemini_system_instruction(text: system_prompt)
141
+ #
142
+ # → { parts: [{text: static}, {text: dynamic}] } (empty omitted)
143
+
144
+ public_class_method def self.gemini_system_instruction(opts = {})
145
+ parts = split_system(text: opts[:text])
146
+ out = []
147
+ out << { text: parts[:static] } unless parts[:static].to_s.strip.empty?
148
+ out << { text: parts[:dynamic] } unless parts[:dynamic].to_s.strip.empty?
149
+ return nil if out.empty?
150
+
151
+ { parts: out }
152
+ rescue StandardError
153
+ txt = opts[:text].to_s
154
+ return nil if txt.strip.empty?
155
+
156
+ { parts: [{ text: txt }] }
157
+ end
158
+
159
+ # Supported Method Parameters::
160
+ # key = PWN::AI::Agent::PromptCache.cache_key(text: system_prompt)
161
+ # key = PWN::AI::Agent::PromptCache.cache_key(key: 'explicit-override')
162
+ #
163
+ # Stable OpenAI prompt_cache_key / xAI x-grok-conv-id. Hashes the
164
+ # static prefix so MEMORY / LEARNING churn does not rotate the key.
165
+
166
+ public_class_method def self.cache_key(opts = {})
167
+ explicit = opts[:key]
168
+ return explicit.to_s unless explicit.to_s.strip.empty?
169
+
170
+ raw = opts[:text].to_s
171
+ static = raw.empty? ? '' : split_system(text: raw)[:static].to_s
172
+ static = raw if static.strip.empty?
173
+ static = 'pwn-ai' if static.strip.empty?
174
+ "pwn-#{Digest::SHA256.hexdigest(static)[0, 24]}"
175
+ rescue StandardError
176
+ 'pwn-ai'
177
+ end
178
+
179
+ # Supported Method Parameters::
180
+ # n = PWN::AI::Agent::PromptCache.cache_marks(blocks: [...])
181
+
182
+ public_class_method def self.cache_marks(opts = {})
183
+ Array(opts[:blocks]).count do |b|
184
+ next false unless b.is_a?(Hash)
185
+
186
+ cc = b[:cache_control] || b['cache_control']
187
+ cc.is_a?(Hash) && (cc[:type] || cc['type']).to_s == 'ephemeral'
188
+ end
189
+ end
190
+
191
+ public_class_method def self.supports
192
+ %i[anthropic openai grok gemini]
193
+ end
194
+
195
+ public_class_method def self.authors
196
+ "AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
197
+ end
198
+
199
+ public_class_method def self.help
200
+ puts <<~USAGE
201
+ USAGE:
202
+ parts = PWN::AI::Agent::PromptCache.split_system(text: system_prompt)
203
+ blocks = PWN::AI::Agent::PromptCache.anthropic_system_blocks(text: system_prompt)
204
+ msgs = PWN::AI::Agent::PromptCache.openai_messages(messages: [...])
205
+ inst = PWN::AI::Agent::PromptCache.gemini_system_instruction(text: system_prompt)
206
+ key = PWN::AI::Agent::PromptCache.cache_key(text: system_prompt)
207
+ # Engine chat_with_tools uses the helpers automatically when
208
+ # PWN::Env[:ai][:agent][:prompt_cache] is truthy (default).
209
+
210
+ Toggle via PWN::Env[:ai][:agent][:prompt_cache]
211
+ #{self}.authors
212
+ USAGE
213
+ end
214
+
215
+ private_class_method def self.first_dynamic_index(opts = {})
216
+ text = opts[:text].to_s
217
+ idxs = DYNAMIC_HEADS.filter_map do |head|
218
+ i = text.index(/^#{Regexp.escape(head)}\b/)
219
+ i
220
+ end
221
+ idxs.min
222
+ end
223
+
224
+ private_class_method def self.extract_skills(opts = {})
225
+ text = opts[:text].to_s
226
+ m = text.match(%r{^SKILLS[^\n]*\n(?:.*\n)*?(?=\n[A-Z][A-Z /]|\z)})
227
+ m ? m[0] : ''
228
+ rescue StandardError
229
+ ''
230
+ end
231
+
232
+ private_class_method def self.active_engine
233
+ return :openai unless defined?(PWN::Env) && PWN::Env.is_a?(Hash)
234
+
235
+ PWN::Env.dig(:ai, :active).to_s.downcase.to_sym
236
+ rescue StandardError
237
+ :openai
238
+ end
239
+
240
+ private_class_method def self.agent_flag(opts = {})
241
+ key = opts[:key]
242
+ default = opts[:default]
243
+ return default unless defined?(PWN::Env)
244
+
245
+ val = PWN::Env.dig(:ai, :agent, key)
246
+ val.nil? ? default : val
247
+ rescue StandardError
248
+ opts[:default]
249
+ end
250
+ end
251
+ end
252
+ end
253
+ end
@@ -97,7 +97,8 @@ module PWN
97
97
  system_role_content: system_role_content,
98
98
  spinner: spinner,
99
99
  timeout: timeout,
100
- temp: temp
100
+ temp: temp,
101
+ quiet: opts[:quiet]
101
102
  )
102
103
  end
103
104
  ensure
@@ -132,6 +133,7 @@ module PWN
132
133
  chat_opts[:timeout] = opts[:timeout] unless opts[:timeout].nil?
133
134
  chat_opts[:temp] = opts[:temp] unless opts[:temp].nil?
134
135
  chat_opts[:model] = opts[:model] unless opts[:model].to_s.empty?
136
+ chat_opts[:quiet] = opts[:quiet] unless opts[:quiet].nil?
135
137
  mod.chat(chat_opts)
136
138
  end
137
139
 
@@ -1157,11 +1157,20 @@ module PWN
1157
1157
  spinner: false,
1158
1158
  model: judge_model,
1159
1159
  timeout: cheap_orm_timeout,
1160
- temp: CHEAP_ORM_TEMP
1160
+ temp: CHEAP_ORM_TEMP,
1161
+ quiet: true
1161
1162
  )
1162
1163
  text = extract_chat_text(resp: raw)
1163
1164
  return text unless text.strip.empty?
1164
- rescue StandardError
1165
+
1166
+ # Quiet ReadTimeout now returns nil instead of raising. That
1167
+ # empty response already spent the cheap-ORM budget — do not
1168
+ # immediately fire engine_chat_cheap (second 12s stall).
1169
+ return nil
1170
+ rescue StandardError => e
1171
+ # A 12s ReadTimeout already spent the cheap-ORM budget.
1172
+ return nil if timeout_error?(err: e)
1173
+
1165
1174
  nil
1166
1175
  end
1167
1176
  end
@@ -1191,7 +1200,8 @@ module PWN
1191
1200
  system_role_content: opts[:system_role_content],
1192
1201
  spinner: false,
1193
1202
  timeout: cheap_orm_timeout,
1194
- temp: CHEAP_ORM_TEMP
1203
+ temp: CHEAP_ORM_TEMP,
1204
+ quiet: true
1195
1205
  }
1196
1206
  model = judge_model
1197
1207
  chat_opts[:model] = model unless model.to_s.empty?
@@ -1200,6 +1210,20 @@ module PWN
1200
1210
  nil
1201
1211
  end
1202
1212
 
1213
+ private_class_method def self.timeout_error?(opts = {})
1214
+ err = opts[:err]
1215
+ return false if err.nil?
1216
+
1217
+ klass = err.class
1218
+ name = klass.name.to_s
1219
+ return true if name.include?('Timeout') || name.include?('ReadTimeout') || name.include?('OpenTimeout')
1220
+ return true if err.message.to_s.match?(/timed out reading|read timeout|open timeout/i)
1221
+
1222
+ false
1223
+ rescue StandardError
1224
+ false
1225
+ end
1226
+
1203
1227
  private_class_method def self.cheap_orm_timeout
1204
1228
  n = agent_flag(key: :reward_llm_timeout, default: CHEAP_ORM_TIMEOUT).to_i
1205
1229
  n = CHEAP_ORM_TIMEOUT if n < 2
@@ -566,7 +566,9 @@ module PWN
566
566
  request: user,
567
567
  system_role_content: system,
568
568
  suppress_pii_warning: true,
569
- spinner: false
569
+ spinner: false,
570
+ timeout: sidecar_timeout,
571
+ quiet: true
570
572
  )
571
573
  text = reflect_text(resp: resp)
572
574
  return text unless text.to_s.strip.empty?
@@ -846,7 +848,9 @@ module PWN
846
848
  request: user,
847
849
  system_role_content: system,
848
850
  suppress_pii_warning: true,
849
- spinner: false
851
+ spinner: false,
852
+ timeout: sidecar_timeout,
853
+ quiet: true
850
854
  )
851
855
  text = reflect_text(resp: resp)
852
856
  return text unless text.to_s.strip.empty?
@@ -896,7 +900,9 @@ module PWN
896
900
  r = mod.chat(
897
901
  request: opts[:request],
898
902
  system_role_content: opts[:system_role_content],
899
- spinner: false
903
+ spinner: false,
904
+ timeout: sidecar_timeout,
905
+ quiet: true
900
906
  )
901
907
  reflect_text(resp: r)
902
908
  rescue StandardError => e
@@ -911,6 +917,17 @@ module PWN
911
917
  :ollama
912
918
  end
913
919
 
920
+ # Kind / plan classification is a sidecar hop. Bound it so a hung
921
+ # model cannot stall the user's real answer, and stay quiet on timeout.
922
+ private_class_method def self.sidecar_timeout
923
+ n = (PWN::Env.dig(:ai, :agent, :sidecar_llm_timeout) if defined?(PWN::Env)).to_i
924
+ n = 20 if n < 5
925
+ n = 60 if n > 60
926
+ n
927
+ rescue StandardError
928
+ 20
929
+ end
930
+
914
931
  # Parse JSON array, fenced JSON, or numbered/bulleted plain text.
915
932
  #
916
933
  # Supported Method Parameters::