pwn 0.5.675 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2ed5f171854df678e76d477ad52fee2dd42e1687afc6e950281d2b3e5fe75ab9
4
- data.tar.gz: 35712ab0300a98de2dfc52430278088561b84fcd15e4dd70a3c95cd90ccf4645
3
+ metadata.gz: 65d8aa03f93adc593efbfb8b342885f8ceb815a19a6a8ef8e72c3b0a493a32a5
4
+ data.tar.gz: eee3d957a5975a105240040cf6bd2f0a44a05d35cbac3240ec252f913cc38c5e
5
5
  SHA512:
6
- metadata.gz: 563ee1d396728ef91353cfc4e6d82da6b90662d03e55d1c027319203e47a8bd91d9c723a09cda3a6836dc9b1ed380bb3e0bb76d802dd23157ff7c92d52a3958b
7
- data.tar.gz: 05e3c313c8262273f5799f24a9359b3cadc5448ccebfc5ae031a99a71865b548d24489208f4b7be6e0d4790485ebcc58d35a3ccdafbfddfecdb27eaddd531b9c
6
+ metadata.gz: 03f6a8960538e278b4041981a64ad27c4163e3748a11866c559c81da75152644f62b61658f695369f0281832e03d0810f012f47387b3ff4c456ad2df964b365c
7
+ data.tar.gz: 9b3eec90f64577c1167ae556d713f07f82f1c76d43255a14b7a1b69eaec5cfa2bdd9d99343d0a338aef40d1aa9e6fbd2c4c89d72e9e5d7b35b6a69ab504cdc07
data/bin/pwn_setup CHANGED
@@ -54,6 +54,11 @@ 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!
@@ -65,10 +70,9 @@ begin
65
70
  # the worker as running. Skip list/terminal (no host scheduler needed)
66
71
  # and dry-run (must not spawn).
67
72
  unless opts[:list] || opts[:terminal] || opts[:dry_run]
68
- PWN::Setup.ensure_cron(
69
- restart: false,
70
- dry_run: false
71
- )
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)
72
76
  end
73
77
 
74
78
  if opts[:list]
@@ -102,10 +106,9 @@ begin
102
106
  # last_run actually advances. Skip list/terminal (no host scheduler
103
107
  # needed) and dry-run (must not spawn).
104
108
  unless opts[:list] || opts[:terminal] || opts[:dry_run]
105
- PWN::Setup.ensure_cron(
106
- restart: false,
107
- dry_run: false
108
- )
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)
109
112
  end
110
113
  exit status
111
114
  rescue Interrupt
@@ -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 = []
@@ -1875,6 +1875,7 @@ module PWN
1875
1875
  request = opts[:request].to_s
1876
1876
  session_id = opts[:session_id]
1877
1877
  on_tool = opts[:on_tool]
1878
+ TurnFinalizer.enter_user_path! if defined?(TurnFinalizer)
1878
1879
  # Live coalesced "what am I doing" lines for the TUI (not a model tool).
1879
1880
  ts_state = (TaskSummarizer.fresh(request: request) if defined?(TaskSummarizer) && TaskSummarizer.enabled? && Thread.current[:pwn_reflect_depth].to_i.zero?)
1880
1881
  engine = active_engine
@@ -2307,6 +2308,8 @@ module PWN
2307
2308
  maybe_finish_policy(session_id: session_id, proxy_ok: false, ts_state: ts_state)
2308
2309
  task_summary_flush!(state: ts_state, on_tool: on_tool)
2309
2310
  final_msg
2311
+ ensure
2312
+ TurnFinalizer.leave_user_path! if defined?(TurnFinalizer)
2310
2313
  end
2311
2314
 
2312
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
@@ -0,0 +1,231 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PWN
4
+ module AI
5
+ module Agent
6
+ # Hermes-style post-reply learning. Loop.run returns the user-visible
7
+ # answer first; Reward / Policy / Curriculum keep running off that path.
8
+ #
9
+ # User-visible turn (Loop.run)
10
+ # tools, streaming, TaskSummarizer, final text
11
+ # After the reply is already decided
12
+ # TurnFinalizer.defer -> daemon thread
13
+ # re-attaches the R5 episode snapshot
14
+ # Learning.auto_introspect (critic, Reward.judge, PRM, HER, reflect)
15
+ #
16
+ # Default ON during Loop.run (PWN::Env[:ai][:agent][:defer_introspect]).
17
+ # Direct Learning.auto_introspect calls (specs, cron, tools) stay inline
18
+ # so existing ORM contracts still get a synchronous return value.
19
+ module TurnFinalizer
20
+ MUTEX = Mutex.new
21
+ THREADS = [] # rubocop:disable Style/MutableConstant
22
+ MAX_TRACKED = 16
23
+ DEPTH_KEY = :pwn_turn_finalizer_depth
24
+ INLINE_KEY = :pwn_turn_finalizer_inline
25
+ SYNC_KEY = :pwn_turn_finalizer_sync
26
+
27
+ # Supported Method Parameters::
28
+ # PWN::AI::Agent::TurnFinalizer.enter_user_path!
29
+
30
+ public_class_method def self.enter_user_path!
31
+ Thread.current[DEPTH_KEY] = Thread.current[DEPTH_KEY].to_i + 1
32
+ end
33
+
34
+ # Supported Method Parameters::
35
+ # PWN::AI::Agent::TurnFinalizer.leave_user_path!
36
+
37
+ public_class_method def self.leave_user_path!
38
+ depth = Thread.current[DEPTH_KEY].to_i - 1
39
+ Thread.current[DEPTH_KEY] = depth.positive? ? depth : 0
40
+ end
41
+
42
+ public_class_method def self.user_path?
43
+ Thread.current[DEPTH_KEY].to_i.positive?
44
+ end
45
+
46
+ # Supported Method Parameters::
47
+ # ok = PWN::AI::Agent::TurnFinalizer.should_defer?
48
+
49
+ public_class_method def self.should_defer?
50
+ return false if Thread.current[INLINE_KEY]
51
+ return false if Thread.current[SYNC_KEY]
52
+ return false unless user_path?
53
+
54
+ flag = agent_flag(key: :defer_introspect, default: true)
55
+ return false if flag == false || flag.to_s.match?(/\A(0|false|no|off)\z/i)
56
+
57
+ true
58
+ rescue StandardError
59
+ false
60
+ end
61
+
62
+ # Supported Method Parameters::
63
+ # result = PWN::AI::Agent::TurnFinalizer.defer(
64
+ # session_id: 'required - PWN::Sessions id',
65
+ # request: 'optional - original user request',
66
+ # final: 'optional - user-visible final answer',
67
+ # predicted: 'optional - W3 predicted score',
68
+ # plan: 'optional - tangible-task plan',
69
+ # ts_state: 'optional - TaskSummarizer state'
70
+ # )
71
+ #
72
+ # Snapshots the live Policy episode onto this thread, clears it so
73
+ # Loop.maybe_finish_policy is a no-op, and runs Learning.auto_introspect
74
+ # on a daemon thread with the episode re-attached.
75
+
76
+ public_class_method def self.defer(opts = {})
77
+ session_id = opts[:session_id]
78
+ return { skipped: :no_session } if session_id.to_s.strip.empty?
79
+ return { skipped: :no_learning } unless defined?(Learning)
80
+
81
+ episode = nil
82
+ episode = Policy.detach_episode! if defined?(Policy) && Policy.respond_to?(:detach_episode!)
83
+
84
+ payload = {
85
+ session_id: session_id,
86
+ request: opts[:request],
87
+ final: opts[:final],
88
+ predicted: opts[:predicted],
89
+ plan: opts[:plan],
90
+ ts_state: opts[:ts_state],
91
+ inline: true
92
+ }
93
+
94
+ worker = Thread.new do
95
+ Thread.current[INLINE_KEY] = true
96
+ begin
97
+ Policy.attach_episode!(episode: episode) if defined?(Policy) && Policy.respond_to?(:attach_episode!)
98
+ Learning.auto_introspect(payload)
99
+ rescue StandardError => e
100
+ warn "[pwn-ai/turn_finalizer] background review swallowed: #{e.class}: #{e.message}"
101
+ nil
102
+ ensure
103
+ leftover = defined?(Policy) && Policy.respond_to?(:current_episode) && Policy.current_episode
104
+ if leftover && Policy.respond_to?(:finish)
105
+ Policy.finish(
106
+ session_id: session_id,
107
+ proxy_ok: false,
108
+ final: payload[:final],
109
+ ts_state: payload[:ts_state]
110
+ )
111
+ elsif defined?(Policy) && Policy.respond_to?(:attach_episode!)
112
+ Policy.attach_episode!(episode: nil)
113
+ end
114
+ end
115
+ end
116
+ worker.report_on_exception = false
117
+ begin
118
+ worker.name = 'pwn-turn-finalizer'
119
+ rescue StandardError
120
+ nil
121
+ end
122
+ track(thr: worker)
123
+ { deferred: true, session_id: session_id, thread_id: worker.object_id }
124
+ rescue StandardError => e
125
+ warn "[pwn-ai/turn_finalizer] defer swallowed: #{e.class}: #{e.message}"
126
+ # Never drop the learner — fall back to inline review.
127
+ opts_inline = opts.merge(inline: true)
128
+ defined?(Learning) ? Learning.auto_introspect(opts_inline) : { error: e.message }
129
+ end
130
+
131
+ # Supported Method Parameters::
132
+ # result = PWN::AI::Agent::TurnFinalizer.finalize(
133
+ # session_id:, request:, final:, predicted:, plan:, ts_state:,
134
+ # should: true|false
135
+ # )
136
+ #
137
+ # Single entry used when a caller wants the Hermes split without
138
+ # going through Learning.auto_introspect's gate.
139
+
140
+ public_class_method def self.finalize(opts = {})
141
+ return { skipped: :gated } unless opts.fetch(:should, true)
142
+ return { skipped: :no_learning } unless defined?(Learning)
143
+
144
+ if should_defer?
145
+ defer(opts)
146
+ else
147
+ Learning.auto_introspect(opts.merge(inline: true))
148
+ end
149
+ rescue StandardError => e
150
+ warn "[pwn-ai/turn_finalizer] finalize swallowed: #{e.class}: #{e.message}"
151
+ nil
152
+ end
153
+
154
+ # Supported Method Parameters::
155
+ # n = PWN::AI::Agent::TurnFinalizer.join_all!(timeout: 15)
156
+
157
+ public_class_method def self.join_all!(opts = {})
158
+ timeout = (opts[:timeout] || 15).to_f
159
+ list = MUTEX.synchronize do
160
+ pending = THREADS.dup
161
+ THREADS.clear
162
+ pending
163
+ end
164
+ list.each do |thr|
165
+ thr.join(timeout) if thr.alive?
166
+ rescue StandardError
167
+ nil
168
+ end
169
+ list.length
170
+ end
171
+
172
+ # Supported Method Parameters::
173
+ # n = PWN::AI::Agent::TurnFinalizer.pending
174
+
175
+ public_class_method def self.pending
176
+ MUTEX.synchronize { THREADS.count(&:alive?) }
177
+ end
178
+
179
+ public_class_method def self.authors
180
+ "AUTHOR(S):\n 0day Inc. <support@0dayinc.com>\n"
181
+ end
182
+
183
+ public_class_method def self.help
184
+ puts <<~USAGE
185
+ USAGE:
186
+ # Called from Learning.auto_introspect when Loop.run is on the
187
+ # stack. You almost never invoke this directly.
188
+ PWN::AI::Agent::TurnFinalizer.defer(
189
+ session_id: sid,
190
+ request: req,
191
+ final: text,
192
+ plan: plan,
193
+ ts_state: ts
194
+ )
195
+
196
+ # Specs / cron that need the learner to finish before assert:
197
+ PWN::AI::Agent::TurnFinalizer.join_all!(timeout: 15)
198
+
199
+ Toggle via PWN::Env[:ai][:agent][:defer_introspect]
200
+ true (default) — Hermes split: learn after the reply
201
+ false — legacy inline auto_introspect
202
+
203
+ #{self}.authors
204
+ USAGE
205
+ end
206
+
207
+ private_class_method def self.track(opts = {})
208
+ thr = opts[:thr]
209
+ MUTEX.synchronize do
210
+ THREADS.select!(&:alive?)
211
+ THREADS << thr
212
+ THREADS.shift&.kill if THREADS.length > MAX_TRACKED
213
+ end
214
+ rescue StandardError
215
+ nil
216
+ end
217
+
218
+ private_class_method def self.agent_flag(opts = {})
219
+ key = opts[:key]
220
+ default = opts[:default]
221
+ return default unless defined?(PWN::Env)
222
+
223
+ val = PWN::Env.dig(:ai, :agent, key)
224
+ val.nil? ? default : val
225
+ rescue StandardError
226
+ opts[:default]
227
+ end
228
+ end
229
+ end
230
+ end
231
+ end
data/lib/pwn/ai/agent.rb CHANGED
@@ -34,6 +34,8 @@ module PWN
34
34
  autoload :TaskSummarizer, 'pwn/ai/agent/task_summarizer'
35
35
  autoload :Policy, 'pwn/ai/agent/policy'
36
36
  autoload :ToolGuard, 'pwn/ai/agent/tool_guard'
37
+ autoload :TurnFinalizer, 'pwn/ai/agent/turn_finalizer'
38
+ autoload :PromptCache, 'pwn/ai/agent/prompt_cache'
37
39
 
38
40
  # Display a List of Every PWN::AI Module
39
41
 
@@ -657,7 +657,17 @@ module PWN
657
657
  temperature: temp,
658
658
  messages: anth_messages
659
659
  }
660
- http_body[:system] = system_str if system_str && !system_str.empty?
660
+ if system_str && !system_str.empty?
661
+ # Hermes-style cache breakpoint on the static system prefix + SKILLS
662
+ # index. Dynamic MEMORY/LEARNING stay in a second uncached block.
663
+ if defined?(PWN::AI::Agent::PromptCache) &&
664
+ PWN::AI::Agent::PromptCache.enabled?(engine: :anthropic)
665
+ blocks = PWN::AI::Agent::PromptCache.anthropic_system_blocks(text: system_str)
666
+ http_body[:system] = blocks unless blocks.empty?
667
+ else
668
+ http_body[:system] = system_str
669
+ end
670
+ end
661
671
 
662
672
  if opts[:tools] && !opts[:tools].empty?
663
673
  http_body[:tools] = opts[:tools].map do |t|
data/lib/pwn/ai/gemini.rb CHANGED
@@ -179,7 +179,15 @@ module PWN
179
179
  maxOutputTokens: opts[:max_tokens] || 8192
180
180
  }
181
181
  }
182
- http_body[:systemInstruction] = { parts: [{ text: system_str }] } if system_str && !system_str.empty?
182
+ if system_str && !system_str.empty?
183
+ if defined?(PWN::AI::Agent::PromptCache) &&
184
+ PWN::AI::Agent::PromptCache.enabled?(engine: :gemini)
185
+ inst = PWN::AI::Agent::PromptCache.gemini_system_instruction(text: system_str)
186
+ http_body[:systemInstruction] = inst if inst
187
+ else
188
+ http_body[:systemInstruction] = { parts: [{ text: system_str }] }
189
+ end
190
+ end
183
191
 
184
192
  if opts[:tools] && !opts[:tools].empty?
185
193
  http_body[:tools] = [{