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 +4 -4
- data/bin/pwn_setup +11 -8
- data/lib/pwn/ai/agent/learning.rb +5 -0
- data/lib/pwn/ai/agent/loop.rb +3 -0
- data/lib/pwn/ai/agent/policy.rb +15 -0
- data/lib/pwn/ai/agent/prompt_builder.rb +4 -1
- data/lib/pwn/ai/agent/prompt_cache.rb +253 -0
- data/lib/pwn/ai/agent/turn_finalizer.rb +231 -0
- data/lib/pwn/ai/agent.rb +2 -0
- data/lib/pwn/ai/anthropic.rb +11 -1
- data/lib/pwn/ai/gemini.rb +9 -1
- data/lib/pwn/ai/grok.rb +18 -1
- data/lib/pwn/ai/open_ai.rb +16 -1
- data/lib/pwn/config.rb +7 -0
- data/lib/pwn/cron.rb +886 -66
- data/lib/pwn/setup.rb +37 -9
- data/lib/pwn/version.rb +1 -1
- data/spec/lib/pwn/ai/agent/loop_spec.rb +6 -0
- data/spec/lib/pwn/ai/agent/policy_spec.rb +22 -0
- data/spec/lib/pwn/ai/agent/prompt_builder_spec.rb +13 -0
- data/spec/lib/pwn/ai/agent/prompt_cache_spec.rb +118 -0
- data/spec/lib/pwn/ai/agent/turn_finalizer_spec.rb +106 -0
- data/spec/lib/pwn/ai/anthropic_spec.rb +8 -0
- data/spec/lib/pwn/ai/gemini_spec.rb +6 -0
- data/spec/lib/pwn/ai/grok_spec.rb +7 -0
- data/spec/lib/pwn/ai/open_ai_spec.rb +7 -0
- data/spec/lib/pwn/cron_spec.rb +146 -0
- data/spec/lib/pwn/setup_spec.rb +31 -0
- data/third_party/pwn_rdoc.jsonl +92 -1
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 65d8aa03f93adc593efbfb8b342885f8ceb815a19a6a8ef8e72c3b0a493a32a5
|
|
4
|
+
data.tar.gz: eee3d957a5975a105240040cf6bd2f0a44a05d35cbac3240ec252f913cc38c5e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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 = []
|
data/lib/pwn/ai/agent/loop.rb
CHANGED
|
@@ -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>
|
data/lib/pwn/ai/agent/policy.rb
CHANGED
|
@@ -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])}#{
|
|
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
|
|
data/lib/pwn/ai/anthropic.rb
CHANGED
|
@@ -657,7 +657,17 @@ module PWN
|
|
|
657
657
|
temperature: temp,
|
|
658
658
|
messages: anth_messages
|
|
659
659
|
}
|
|
660
|
-
|
|
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
|
-
|
|
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] = [{
|