pwn 0.5.766 → 0.5.772
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/Gemfile +3 -3
- data/documentation/Configuration.md +2 -0
- data/documentation/General-PWN-Usage.md +68 -0
- data/documentation/Harness-Evaluation.md +312 -0
- data/documentation/pwn-ai-Agent.md +135 -6
- data/etc/default_cron/session_cleanup.rb +6 -1
- data/etc/default_skills/pwn/ai/agent/dispatch/SKILL.md +1 -0
- data/etc/default_skills/pwn/ai/agent/open_goal/SKILL.md +1 -0
- data/etc/default_skills/pwn/ai/agent/skill_review/SKILL.md +2 -0
- data/etc/default_skills/pwn/ai/agent/solve/SKILL.md +4 -2
- data/etc/default_skills/pwn/ai/context_ingestion/SKILL.md +1 -1
- data/etc/default_skills/pwn/ai/http_retry/SKILL.md +1 -0
- data/etc/default_skills/pwn/plugins/repl/ai_console/SKILL.md +1 -1
- data/etc/default_skills/pwn/plugins/repl/mesh/SKILL.md +4 -0
- data/etc/default_skills/pwn/plugins/repl/mesh/references/urls.md +3 -0
- data/etc/default_skills/pwn/redaction/SKILL.md +1 -1
- data/etc/default_skills/pwn/runtime_fingerprint/SKILL.md +47 -0
- data/lib/pwn/ai/agent/dispatch.rb +50 -6
- data/lib/pwn/ai/agent/loop.rb +192 -115
- data/lib/pwn/ai/agent/open_goal.rb +43 -1
- data/lib/pwn/ai/agent/registry.rb +6 -1
- data/lib/pwn/ai/agent/request_runtime.rb +508 -17
- data/lib/pwn/ai/agent/result.rb +23 -6
- data/lib/pwn/ai/agent/reward.rb +4 -1
- data/lib/pwn/ai/agent/skill_review.rb +148 -1
- data/lib/pwn/ai/agent/solve.rb +420 -39
- data/lib/pwn/ai/agent/swarm.rb +11 -2
- data/lib/pwn/ai/agent/tools/shell.rb +23 -0
- data/lib/pwn/ai/agent/turn_finalizer.rb +37 -6
- data/lib/pwn/ai/agent/verification.rb +42 -5
- data/lib/pwn/ai/anthropic.rb +59 -5
- data/lib/pwn/ai/cli.rb +48 -4
- data/lib/pwn/ai/context.rb +1 -1
- data/lib/pwn/ai/context_ingestion.rb +16 -1
- data/lib/pwn/ai/gemini.rb +36 -5
- data/lib/pwn/ai/grok.rb +19 -15
- data/lib/pwn/ai/http_retry.rb +14 -0
- data/lib/pwn/ai/mcp/combo_nation.rb +5 -2
- data/lib/pwn/ai/mcp.rb +5 -2
- data/lib/pwn/ai/ollama.rb +46 -5
- data/lib/pwn/ai/open_ai.rb +11 -11
- data/lib/pwn/ai/open_web_ui.rb +46 -5
- data/lib/pwn/config.rb +10 -2
- data/lib/pwn/migrate.rb +9 -1
- data/lib/pwn/plugins/repl/ai.rb +31 -1
- data/lib/pwn/plugins/repl/ai_console.rb +843 -19
- data/lib/pwn/plugins/repl/ai_console_commands.rb +23 -5
- data/lib/pwn/plugins/repl/ai_swarm.rb +65 -5
- data/lib/pwn/plugins/repl/mesh.rb +894 -83
- data/lib/pwn/plugins/repl.rb +2 -0
- data/lib/pwn/redaction.rb +29 -5
- data/lib/pwn/runtime_fingerprint.rb +84 -0
- data/lib/pwn/version.rb +1 -1
- data/lib/pwn.rb +1 -0
- data/spec/fixtures/harness/adapters.json +62 -0
- data/spec/fixtures/harness/adapters.json.sha256 +1 -0
- data/spec/fixtures/harness/experiment_gold.json +9 -0
- data/spec/fixtures/harness/experiment_gold.json.sha256 +1 -0
- data/spec/fixtures/harness/experiment_tasks.json +12 -0
- data/spec/fixtures/harness/experiment_tasks.json.sha256 +1 -0
- data/spec/fixtures/harness/live_comparison.json +21 -0
- data/spec/fixtures/harness/live_comparison.json.sha256 +1 -0
- data/spec/fixtures/harness/manifest.json +290 -0
- data/spec/fixtures/harness/manifest.json.sha256 +1 -0
- data/spec/integration/harness_evaluation_spec.rb +171 -0
- data/spec/integration/harness_layout_spec.rb +15 -0
- data/spec/integration/harness_live_spec.rb +26 -0
- data/spec/integration/harness_package_spec.rb +38 -0
- data/spec/integration/native_ruby_helpers_spec.rb +4 -2
- data/spec/integration/reinforced_feedback_loop_spec.rb +2 -2
- data/spec/lib/pwn/ai/agent/dispatch_spec.rb +47 -3
- data/spec/lib/pwn/ai/agent/loop_spec.rb +479 -17
- data/spec/lib/pwn/ai/agent/open_goal_spec.rb +17 -0
- data/spec/lib/pwn/ai/agent/registry_spec.rb +10 -0
- data/spec/lib/pwn/ai/agent/request_runtime_spec.rb +232 -0
- data/spec/lib/pwn/ai/agent/result_spec.rb +33 -0
- data/spec/lib/pwn/ai/agent/signal_hygiene_spec.rb +1 -1
- data/spec/lib/pwn/ai/agent/skill_review_spec.rb +17 -5
- data/spec/lib/pwn/ai/agent/solve_spec.rb +392 -7
- data/spec/lib/pwn/ai/agent/swarm_spec.rb +19 -0
- data/spec/lib/pwn/ai/agent/tools/ruby_eval_spec.rb +2 -2
- data/spec/lib/pwn/ai/agent/turn_finalizer_spec.rb +20 -0
- data/spec/lib/pwn/ai/agent/verification_spec.rb +12 -0
- data/spec/lib/pwn/ai/anthropic_spec.rb +10 -0
- data/spec/lib/pwn/ai/cli_spec.rb +40 -0
- data/spec/lib/pwn/ai/context_ingestion_spec.rb +12 -0
- data/spec/lib/pwn/ai/grok_spec.rb +35 -0
- data/spec/lib/pwn/ai/mcp/combo_nation_spec.rb +10 -0
- data/spec/lib/pwn/ai/mcp_spec.rb +10 -0
- data/spec/lib/pwn/ai/open_ai_spec.rb +13 -0
- data/spec/lib/pwn/migrate_spec.rb +37 -4
- data/spec/lib/pwn/plugins/repl/ai_console_completion_wrap_spec.rb +110 -0
- data/spec/lib/pwn/plugins/repl/ai_console_spec.rb +307 -3
- data/spec/lib/pwn/plugins/repl/ai_swarm_spec.rb +29 -0
- data/spec/lib/pwn/plugins/repl/ai_vault_edit_spec.rb +221 -0
- data/spec/lib/pwn/plugins/repl_mesh_channel_identity_spec.rb +134 -0
- data/spec/lib/pwn/plugins/repl_mesh_dispatch_spec.rb +2 -0
- data/spec/lib/pwn/plugins/repl_mesh_dm_transport_spec.rb +2 -1
- data/spec/lib/pwn/plugins/repl_mesh_multi_rx_spec.rb +315 -0
- data/spec/lib/pwn/plugins/repl_mesh_origin_gps_spec.rb +120 -0
- data/spec/lib/pwn/plugins/repl_mesh_rx_errors_spec.rb +325 -0
- data/spec/lib/pwn/plugins/repl_mesh_scroll_spec.rb +49 -0
- data/spec/lib/pwn/plugins/repl_mesh_transport_label_spec.rb +43 -15
- data/spec/lib/pwn/plugins/repl_spec.rb +31 -7
- data/spec/lib/pwn/redaction_spec.rb +17 -0
- data/spec/lib/pwn/runtime_fingerprint_spec.rb +53 -0
- data/spec/lib/pwn/session_cleanup_default_spec.rb +1 -1
- data/spec/spec_helper.rb +3 -1
- data/spec/support/harness/cli_faults.rb +112 -0
- data/spec/support/harness/evaluate.rb +191 -0
- data/spec/support/harness/experiment_preload.rb +20 -0
- data/spec/support/harness/experiments.rb +137 -0
- data/spec/support/harness/fake_provider.rb +82 -0
- data/spec/support/harness/fault_preload.rb +44 -0
- data/spec/support/harness/package_acceptance.rb +77 -0
- data/spec/support/harness/record_training.rb +18 -0
- data/spec/support/harness/reference_adapter.py +128 -0
- data/spec/support/harness/runtime_faults.rb +97 -0
- data/spec/support/harness/session_acceptance.rb +70 -0
- data/spec/support/harness/swarm_acceptance.rb +61 -0
- data/third_party/pwn_rdoc.jsonl +129 -3
- metadata +46 -8
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
require 'json'
|
|
4
4
|
require 'fileutils'
|
|
5
5
|
require 'time'
|
|
6
|
+
require 'digest'
|
|
7
|
+
require 'securerandom'
|
|
6
8
|
|
|
7
9
|
module PWN
|
|
8
10
|
module AI
|
|
@@ -14,6 +16,9 @@ module PWN
|
|
|
14
16
|
CONTINUE_RX = /\A\s*(continue|resume|keep going|pick up|carry on)(?:\s+(?:please|the\s+)?(?:goal|task|work))?\s*[.!]?\s*\z/i
|
|
15
17
|
|
|
16
18
|
public_class_method def self.goal_file
|
|
19
|
+
runtime = Thread.current[:pwn_request_runtime]
|
|
20
|
+
return File.join(File.dirname(GOAL_FILE), 'open_goals', Digest::SHA256.hexdigest(runtime.session_id), "#{Digest::SHA256.hexdigest(runtime.request_id)}.json") if runtime
|
|
21
|
+
|
|
17
22
|
GOAL_FILE
|
|
18
23
|
end
|
|
19
24
|
|
|
@@ -29,6 +34,16 @@ module PWN
|
|
|
29
34
|
nil
|
|
30
35
|
end
|
|
31
36
|
|
|
37
|
+
public_class_method def self.for_session(opts = {})
|
|
38
|
+
sid = opts[:session_id].to_s
|
|
39
|
+
pointer = File.join(File.dirname(GOAL_FILE), 'open_goals', Digest::SHA256.hexdigest(sid), 'current.json')
|
|
40
|
+
path = File.file?(pointer) ? pointer : GOAL_FILE
|
|
41
|
+
return unless File.file?(path)
|
|
42
|
+
|
|
43
|
+
row = JSON.parse(File.read(path), symbolize_names: true)
|
|
44
|
+
row if row[:session_id] == sid && !row[:request].to_s.empty?
|
|
45
|
+
end
|
|
46
|
+
|
|
32
47
|
public_class_method def self.begin!(opts = {})
|
|
33
48
|
request = opts[:request].to_s.strip
|
|
34
49
|
return if request.empty?
|
|
@@ -40,7 +55,10 @@ module PWN
|
|
|
40
55
|
mission_id: opts[:mission_id].to_s,
|
|
41
56
|
updated_at: Time.now.utc.iso8601
|
|
42
57
|
}
|
|
43
|
-
|
|
58
|
+
runtime = Thread.current[:pwn_request_runtime]
|
|
59
|
+
row.merge!(request_id: runtime.request_id, checkpoint_path: runtime.checkpoint_path) if runtime
|
|
60
|
+
atomic_write(path: goal_file, row: row)
|
|
61
|
+
atomic_write(path: File.join(File.dirname(goal_file), 'current.json'), row: row) if runtime
|
|
44
62
|
row
|
|
45
63
|
end
|
|
46
64
|
|
|
@@ -50,6 +68,11 @@ module PWN
|
|
|
50
68
|
return :held if mission_open?
|
|
51
69
|
|
|
52
70
|
File.delete(goal_file)
|
|
71
|
+
pointer = File.join(File.dirname(goal_file), 'current.json')
|
|
72
|
+
if Thread.current[:pwn_request_runtime] && File.file?(pointer)
|
|
73
|
+
row = JSON.parse(File.read(pointer), symbolize_names: true)
|
|
74
|
+
File.delete(pointer) if row[:request_id] == Thread.current[:pwn_request_runtime].request_id
|
|
75
|
+
end
|
|
53
76
|
:ok
|
|
54
77
|
rescue StandardError
|
|
55
78
|
:ok
|
|
@@ -76,6 +99,11 @@ module PWN
|
|
|
76
99
|
# Run current and return its result
|
|
77
100
|
#{self}.current
|
|
78
101
|
|
|
102
|
+
# Look up only the named session's resumable request.
|
|
103
|
+
#{self}.for_session(
|
|
104
|
+
session_id: 'required - exact session identity; no cross-session fallback'
|
|
105
|
+
)
|
|
106
|
+
|
|
79
107
|
# Run begin and return its result
|
|
80
108
|
#{self}.begin!(
|
|
81
109
|
request: 'required - request value consumed by #begin!',
|
|
@@ -97,6 +125,20 @@ module PWN
|
|
|
97
125
|
constants.sort
|
|
98
126
|
end
|
|
99
127
|
|
|
128
|
+
private_class_method def self.atomic_write(opts = {})
|
|
129
|
+
path = opts[:path]
|
|
130
|
+
FileUtils.mkdir_p(File.dirname(path), mode: 0o700)
|
|
131
|
+
temp = "#{path}.#{SecureRandom.hex(8)}.tmp"
|
|
132
|
+
File.open(temp, File::WRONLY | File::CREAT | File::EXCL, 0o600) do |file|
|
|
133
|
+
file.write(JSON.generate(opts[:row]))
|
|
134
|
+
file.flush
|
|
135
|
+
file.fsync
|
|
136
|
+
end
|
|
137
|
+
File.rename(temp, path)
|
|
138
|
+
ensure
|
|
139
|
+
File.unlink(temp) if temp && File.exist?(temp)
|
|
140
|
+
end
|
|
141
|
+
|
|
100
142
|
private_class_method def self.mission_open?(opts = {})
|
|
101
143
|
return false unless defined?(PWN::AI::Agent::Mission)
|
|
102
144
|
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
3
5
|
module PWN
|
|
4
6
|
module AI
|
|
5
7
|
module Agent
|
|
@@ -157,6 +159,9 @@ module PWN
|
|
|
157
159
|
schema = opts[:schema]
|
|
158
160
|
return schema unless schema.is_a?(Hash)
|
|
159
161
|
|
|
162
|
+
depth = opts.fetch(:depth, 0)
|
|
163
|
+
raise JSON::NestingError, 'tool schema exceeds JSON nesting limit (100)' if depth >= 100
|
|
164
|
+
|
|
160
165
|
result = schema.dup
|
|
161
166
|
type = schema[:type] || schema['type'] || opts[:inherited_type]
|
|
162
167
|
result[:type] = type if type && !schema.key?(:type) && !schema.key?('type')
|
|
@@ -164,7 +169,7 @@ module PWN
|
|
|
164
169
|
key = schema.key?(combiner) ? combiner : combiner.to_s
|
|
165
170
|
next unless schema[key].is_a?(Array)
|
|
166
171
|
|
|
167
|
-
result[key] = schema[key].map { |branch| explicit_root_types(schema: branch, inherited_type: type) }
|
|
172
|
+
result[key] = schema[key].map { |branch| explicit_root_types(schema: branch, inherited_type: type, depth: depth + 2) }
|
|
168
173
|
end
|
|
169
174
|
result
|
|
170
175
|
end
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'json'
|
|
4
|
+
require 'base64'
|
|
5
|
+
require 'digest'
|
|
6
|
+
require 'fileutils'
|
|
7
|
+
require 'securerandom'
|
|
8
|
+
require 'time'
|
|
9
|
+
require 'uri'
|
|
4
10
|
require 'pwn/ai/agent/profiles'
|
|
5
11
|
require 'pwn/ai/agent/engagement_memory'
|
|
6
12
|
require 'pwn/session_trace'
|
|
@@ -9,34 +15,368 @@ module PWN
|
|
|
9
15
|
module AI
|
|
10
16
|
module Agent
|
|
11
17
|
# Connect request-scoped routing, evidence context and replay records.
|
|
12
|
-
class RequestRuntime
|
|
18
|
+
class RequestRuntime # rubocop:disable Metrics/ClassLength -- one owner for routing, scope and durable evidence
|
|
19
|
+
OUTCOMES = %w[complete incomplete blocked cancelled verification_error].freeze
|
|
20
|
+
attr_reader :original_request, :request, :session_id, :request_id, :contract, :amendments, :outcome, :checkpoint_path, :engine, :last_provider_attempts
|
|
21
|
+
|
|
13
22
|
def initialize(opts = {})
|
|
14
23
|
@session_id = opts[:session_id].to_s
|
|
15
|
-
@
|
|
24
|
+
@request_id = (opts[:request_id] || SecureRandom.uuid).to_s.freeze
|
|
25
|
+
@original_request = opts[:request].to_s.dup.freeze
|
|
26
|
+
@request = @original_request
|
|
27
|
+
@cwd = Dir.pwd
|
|
28
|
+
@amendments = [].freeze
|
|
29
|
+
@observations = {}
|
|
30
|
+
@events = []
|
|
31
|
+
@effects = {}
|
|
32
|
+
@evidence = []
|
|
33
|
+
@provider_config = Marshal.load(Marshal.dump(opts[:provider_config] || (defined?(PWN::Env) ? PWN::Env[:ai] : {}) || {}))
|
|
34
|
+
@engine = opts[:engine] || Thread.current[:pwn_swarm_engine] || @provider_config[:active] || :openai
|
|
35
|
+
@model = opts[:model] || Thread.current[:pwn_swarm_model]
|
|
36
|
+
@explicit_route = opts[:engine] || opts[:model] || Thread.current[:pwn_swarm_engine] || Thread.current[:pwn_swarm_model]
|
|
37
|
+
@provider_attempts = opts.fetch(:provider_attempts, 5).to_i.clamp(1, 100)
|
|
38
|
+
@cancel = opts[:cancel]
|
|
39
|
+
@generation = 0
|
|
40
|
+
@enabled_toolsets = opts[:enabled_toolsets]&.map { |item| item.to_s.dup.freeze }&.freeze
|
|
41
|
+
@verification_contract = opts[:verification_contract] && Marshal.load(Marshal.dump(opts[:verification_contract]))
|
|
42
|
+
@mutex = Mutex.new
|
|
43
|
+
@started_at = Time.now
|
|
44
|
+
@trace = opts.fetch(:trace, true)
|
|
45
|
+
@retrieve = opts.fetch(:retrieve, true)
|
|
46
|
+
root = opts[:artifact_root] || File.join(PWN::Sessions.sessions_dir, 'requests')
|
|
47
|
+
@checkpoint_path = File.join(root, Digest::SHA256.hexdigest(@session_id), "#{Digest::SHA256.hexdigest(@request_id)}.json")
|
|
48
|
+
restore! if opts[:resume]
|
|
49
|
+
rebuild_contract!
|
|
16
50
|
@profile = opts[:profile]
|
|
17
51
|
@router = Profiles.new(profiles: opts[:profiles] || {})
|
|
52
|
+
@engine = @router.lookup(name: @profile)[:provider] if @profile && !@explicit_route
|
|
18
53
|
@memory = EngagementMemory.new(original_goal: @request, session_id: @session_id,
|
|
19
|
-
root:
|
|
54
|
+
root: root,
|
|
20
55
|
window: opts[:window] || 128_000, tool_cap: opts[:tool_cap] || 8192)
|
|
21
56
|
@route = {}
|
|
22
57
|
@retrieval = nil
|
|
58
|
+
checkpoint!
|
|
23
59
|
record(event: 'request', data: { content: @request })
|
|
24
60
|
end
|
|
25
61
|
|
|
62
|
+
# A resumed execution cannot inherit an earlier terminal success.
|
|
63
|
+
def start!
|
|
64
|
+
@outcome = nil
|
|
65
|
+
checkpoint!
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Steering is additive unless the operator explicitly says replace/cancel.
|
|
69
|
+
def amend!(opts = {})
|
|
70
|
+
instruction = opts.fetch(:instruction).to_s
|
|
71
|
+
mode = if instruction.strip.match?(/\Acancel\z/i)
|
|
72
|
+
'cancel'
|
|
73
|
+
elsif instruction.match?(/\Areplace:\s*/i)
|
|
74
|
+
'replace'
|
|
75
|
+
else
|
|
76
|
+
'add'
|
|
77
|
+
end
|
|
78
|
+
@amendments = (@amendments + [{ mode: mode, instruction: instruction.dup.freeze }.freeze]).freeze
|
|
79
|
+
@request = if mode == 'replace'
|
|
80
|
+
instruction.sub(/\Areplace:\s*/i, '').freeze
|
|
81
|
+
elsif mode == 'add'
|
|
82
|
+
"#{@request}\n#{instruction}".freeze
|
|
83
|
+
else
|
|
84
|
+
@request
|
|
85
|
+
end
|
|
86
|
+
rebuild_contract!
|
|
87
|
+
mode == 'cancel' ? finish!(status: 'cancelled') : checkpoint!
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Only host observations enter this store, never parsed tool stdout.
|
|
91
|
+
def observe!(opts = {})
|
|
92
|
+
@mutex.synchronize do
|
|
93
|
+
opts.fetch(:observations).each { |path, row| @observations[path.to_s] = row }
|
|
94
|
+
checkpoint!
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def observations
|
|
99
|
+
@mutex.synchronize { Marshal.load(Marshal.dump(@observations)) }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Called only by the enclosing Loop, never by a provider tool payload.
|
|
103
|
+
# Recheck bytes/identity before rebinding to the parent's turn. The
|
|
104
|
+
# parent still runs its own literal and authored integration checks.
|
|
105
|
+
def accept_child!(opts = {})
|
|
106
|
+
child = opts.fetch(:runtime)
|
|
107
|
+
return unless child.outcome&.dig(:status) == 'complete' && child.contract[:started_at] >= @started_at
|
|
108
|
+
|
|
109
|
+
rows = child.observations.select do |path, row|
|
|
110
|
+
next false unless @contract[:required_artifacts].include?(path) && row[:artifact_turn_id] == child.contract[:id]
|
|
111
|
+
|
|
112
|
+
current = TurnFinalizer.artifact_snapshot(paths: [path])[path]
|
|
113
|
+
current && current == row.except(:artifact_turn_id, :action_id, :tool, :child_request_id, :child_turn_id, :child_provenance)
|
|
114
|
+
end
|
|
115
|
+
rows.transform_values! do |row|
|
|
116
|
+
row.merge(artifact_turn_id: @contract[:id], child_request_id: child.request_id, child_turn_id: child.contract[:id],
|
|
117
|
+
child_provenance: Array(row[:child_provenance]) + [{ request_id: child.request_id, turn_id: child.contract[:id], sha256: row[:sha256], action_id: row[:action_id] }])
|
|
118
|
+
end
|
|
119
|
+
observe!(observations: rows) unless rows.empty?
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def events
|
|
123
|
+
@mutex.synchronize { Marshal.load(Marshal.dump(@events)) }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Private copies keep credentials and defaults out of checkpoints.
|
|
127
|
+
def provider_config(engine)
|
|
128
|
+
Marshal.load(Marshal.dump(@provider_config[engine.to_sym] || {}))
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def update_provider_oauth(engine, oauth)
|
|
132
|
+
@provider_config[engine.to_sym] ||= {}
|
|
133
|
+
@provider_config[engine.to_sym][:oauth] = Marshal.load(Marshal.dump(oauth))
|
|
134
|
+
:updated
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def check_cancelled!
|
|
138
|
+
raise Interrupt, 'request cancelled' if @outcome&.dig(:status) == 'cancelled' || @cancel&.call
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Unknown tools are exclusive barriers; shell cannot opt into reads.
|
|
142
|
+
def each_tool_batch(calls)
|
|
143
|
+
batch = []
|
|
144
|
+
resources = []
|
|
145
|
+
calls.each do |call|
|
|
146
|
+
metadata = Dispatch.scheduling(tool_call: call)
|
|
147
|
+
if metadata[:exclusive] || batch.length >= 8 || resources.intersect?(metadata[:resources])
|
|
148
|
+
yield batch unless batch.empty?
|
|
149
|
+
batch = []
|
|
150
|
+
resources = []
|
|
151
|
+
end
|
|
152
|
+
batch << call
|
|
153
|
+
resources.concat(metadata[:resources])
|
|
154
|
+
next unless metadata[:exclusive]
|
|
155
|
+
|
|
156
|
+
yield batch
|
|
157
|
+
batch = []
|
|
158
|
+
resources = []
|
|
159
|
+
end
|
|
160
|
+
yield batch unless batch.empty?
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def scheduled_calls(calls, session_id:, eligible: nil)
|
|
164
|
+
Enumerator.new do |output|
|
|
165
|
+
each_tool_batch(calls) do |batch|
|
|
166
|
+
reads = eligible ? batch.select { |call| eligible.call(call) } : batch
|
|
167
|
+
results = dispatch_reads(reads, session_id: session_id)
|
|
168
|
+
batch.each { |call| output << [call, results[call]] }
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def dispatch_reads(calls, session_id:)
|
|
174
|
+
return {} if calls.length < 2
|
|
175
|
+
|
|
176
|
+
locals = Thread.current.keys.to_h { |key| [key, Thread.current[key]] }
|
|
177
|
+
workers = calls.map do |call|
|
|
178
|
+
Thread.new do
|
|
179
|
+
locals.each { |key, value| Thread.current[key] = value }
|
|
180
|
+
check_cancelled!
|
|
181
|
+
Dispatch.call(tool_call: call, session_id: session_id)
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
workers.each do |worker|
|
|
185
|
+
check_cancelled! until worker.join(0.05)
|
|
186
|
+
end
|
|
187
|
+
calls.zip(workers.map(&:value)).to_h
|
|
188
|
+
ensure
|
|
189
|
+
workers&.each { |worker| worker.kill if worker.alive? }
|
|
190
|
+
workers&.each(&:join)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def tool_allowed?(opts = {})
|
|
194
|
+
@enabled_toolsets.nil? || @enabled_toolsets.include?(opts[:toolset].to_s) || @enabled_toolsets.include?(opts[:name].to_s)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def verification_contract
|
|
198
|
+
Marshal.load(Marshal.dump(@verification_contract))
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def bind_verification!(opts = {})
|
|
202
|
+
contract = opts[:contract]
|
|
203
|
+
return unless contract
|
|
204
|
+
raise ArgumentError, 'verification contract replacement requires a new request' if @verification_contract && @verification_contract != contract
|
|
205
|
+
|
|
206
|
+
@verification_contract = Marshal.load(Marshal.dump(contract))
|
|
207
|
+
checkpoint!
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def session_bound?(opts = {})
|
|
211
|
+
return false unless opts[:session_id].to_s == @session_id && opts[:request].to_s == @request
|
|
212
|
+
|
|
213
|
+
users = Array(opts[:rows]).select { |row| row[:role].to_s == 'user' }.map { |row| row[:content].to_s }
|
|
214
|
+
expected = [@original_request] + @amendments.map { |row| row[:instruction] }
|
|
215
|
+
users.last(expected.length) == expected
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def verification!(opts = {})
|
|
219
|
+
report = opts.fetch(:report)
|
|
220
|
+
@mutex.synchronize do
|
|
221
|
+
@events << { phase: 'verification', report: report, request_id: @request_id,
|
|
222
|
+
request_sha256: Digest::SHA256.hexdigest(@request),
|
|
223
|
+
original_sha256: Digest::SHA256.hexdigest(@original_request),
|
|
224
|
+
amendments_sha256: Digest::SHA256.hexdigest(JSON.generate(@amendments)),
|
|
225
|
+
at: Time.now.utc.iso8601(9) }
|
|
226
|
+
checkpoint!
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Host verifier adapters supply observations, never tool stdout. Each
|
|
231
|
+
# record is bound to a literal source clause and an exact remote target.
|
|
232
|
+
def observe_evidence!(opts = {})
|
|
233
|
+
requirement = opts.fetch(:requirement).to_s
|
|
234
|
+
source = [{ id: 'original', text: @original_request }] + @amendments.each_with_index.map { |row, index| { id: "amendment:#{index}", text: row[:instruction] } }
|
|
235
|
+
source = source.find { |row| row[:text].include?(requirement) }
|
|
236
|
+
raise ArgumentError, 'evidence must name an original or amended requirement' if requirement.empty? || !source || !@request.include?(requirement)
|
|
237
|
+
raise ArgumentError, 'unsupported evidence kind' unless %w[http command answer].include?(opts[:kind].to_s)
|
|
238
|
+
|
|
239
|
+
row = { id: SecureRandom.uuid, request_id: @request_id, source_id: source[:id], requirement: requirement,
|
|
240
|
+
offset: source[:text].index(requirement), requirement_sha256: Digest::SHA256.hexdigest(requirement),
|
|
241
|
+
scope_sha256: Digest::SHA256.hexdigest(@request), kind: opts[:kind].to_s, locator: opts[:locator],
|
|
242
|
+
sha256: opts.fetch(:sha256), passed: opts[:passed] == true && evidence_target_matches?(opts), provenance: 'host_verifier',
|
|
243
|
+
observed_at: Time.now.utc.iso8601(9), max_age: opts[:max_age] || (300 if opts[:kind].to_s == 'http') }
|
|
244
|
+
@mutex.synchronize do
|
|
245
|
+
@evidence << Marshal.load(Marshal.dump(row))
|
|
246
|
+
checkpoint!
|
|
247
|
+
end
|
|
248
|
+
row
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def evidence
|
|
252
|
+
@mutex.synchronize { Marshal.load(Marshal.dump(@evidence)) }
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def host_evidenced?(host)
|
|
256
|
+
evidence.any? do |row|
|
|
257
|
+
next false unless row[:kind] == 'http' && fresh_evidence?(row)
|
|
258
|
+
|
|
259
|
+
URI.parse(row[:locator]).host == host.to_s.downcase
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def scope_evidenced?
|
|
264
|
+
remaining = @request.dup
|
|
265
|
+
evidence.select { |row| fresh_evidence?(row) }.each { |row| remaining.gsub!(row[:requirement], '') }
|
|
266
|
+
remaining.match?(/\A[\s.,;:!?]*\z/)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# Recovery is an explicit operator action after an independent target
|
|
270
|
+
# read. Merely obtaining a successful HTTP response never clears doubt.
|
|
271
|
+
def reconcile_effect!(opts = {})
|
|
272
|
+
raise ArgumentError, 'operator approval required' unless opts[:operator_approved] == true
|
|
273
|
+
|
|
274
|
+
key = opts.fetch(:payload_sha256)
|
|
275
|
+
row = evidence.find { |item| item[:id] == opts[:evidence_id] && item[:kind] == 'http' && fresh_evidence?(item) }
|
|
276
|
+
action = events.reverse.find { |item| item[:payload_sha256] == key && item[:phase] == 'start' }
|
|
277
|
+
raise ArgumentError, 'fresh exact-target reconciliation evidence required' unless row && action && action[:target] && row[:locator] == action[:target] && Time.iso8601(row[:observed_at]) > Time.iso8601(action[:at])
|
|
278
|
+
|
|
279
|
+
@mutex.synchronize do
|
|
280
|
+
raise ArgumentError, 'effect is not uncertain' unless @effects[key] == 'uncertain'
|
|
281
|
+
|
|
282
|
+
@effects[key] = 'reconciled'
|
|
283
|
+
@events << { phase: 'reconciliation', payload_sha256: key, evidence_id: row[:id], at: Time.now.utc.iso8601(9) }
|
|
284
|
+
checkpoint!
|
|
285
|
+
end
|
|
286
|
+
:reconciled
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def fresh_evidence?(row)
|
|
290
|
+
observed = Time.iso8601(row[:observed_at])
|
|
291
|
+
latest = @evidence.reverse.find { |item| item[:kind] == row[:kind] && item[:locator] == row[:locator] && item[:requirement] == row[:requirement] }
|
|
292
|
+
return false unless latest && latest[:id] == row[:id]
|
|
293
|
+
|
|
294
|
+
row[:request_id] == @request_id && row[:scope_sha256] == Digest::SHA256.hexdigest(@request) && row[:passed] &&
|
|
295
|
+
observed >= @started_at && observed <= Time.now && (!row[:max_age] || Time.now - observed <= row[:max_age].to_f)
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def unmet
|
|
299
|
+
@mutex.synchronize { @effects.value?('uncertain') ? ['uncertain_side_effect'] : [] }
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# A started mutation stays uncertain across interruption or process death.
|
|
303
|
+
# Changing timeout/action IDs does not authorize replay of that mutation.
|
|
304
|
+
def execute_tool(opts = {}, &)
|
|
305
|
+
check_cancelled!
|
|
306
|
+
payload = opts.fetch(:args).reject { |key, _| key.to_s == 'timeout' }
|
|
307
|
+
key = Digest::SHA256.hexdigest(JSON.generate([opts.fetch(:name), canonical(payload)]))
|
|
308
|
+
mutation = !%w[read recall].include?(opts.fetch(:effect).to_s)
|
|
309
|
+
action = { action_id: opts[:action_id], tool: opts[:name], payload_sha256: key, effect: opts[:effect].to_s,
|
|
310
|
+
target: payload[:url] || payload['url'] }
|
|
311
|
+
denied = @mutex.synchronize do
|
|
312
|
+
if mutation && @effects[key] == 'uncertain'
|
|
313
|
+
true
|
|
314
|
+
else
|
|
315
|
+
@effects[key] = 'uncertain' if mutation
|
|
316
|
+
@events << action.merge(phase: 'start', at: Time.now.utc.iso8601(9))
|
|
317
|
+
checkpoint!
|
|
318
|
+
false
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
return { success: false, error: 'uncertain_side_effect', hint: 'Inspect the target before any operator-approved recovery; this mutation was not replayed.' } if denied
|
|
322
|
+
|
|
323
|
+
begin
|
|
324
|
+
result = provider_attempt(&)
|
|
325
|
+
failed = result.is_a?(Hash) && (result[:error] || result['error'] || result[:success] == false || result['success'] == false)
|
|
326
|
+
state = if failed
|
|
327
|
+
mutation ? 'uncertain' : 'failed'
|
|
328
|
+
else
|
|
329
|
+
'completed'
|
|
330
|
+
end
|
|
331
|
+
result
|
|
332
|
+
ensure
|
|
333
|
+
@mutex.synchronize do
|
|
334
|
+
state ||= mutation ? 'uncertain' : 'interrupted'
|
|
335
|
+
@effects[key] = state if mutation
|
|
336
|
+
@events << action.merge(phase: 'end', state: state, at: Time.now.utc.iso8601(9))
|
|
337
|
+
checkpoint!
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def finish!(opts = {})
|
|
343
|
+
status = opts.fetch(:status).to_s
|
|
344
|
+
raise ArgumentError, 'invalid request outcome' unless OUTCOMES.include?(status)
|
|
345
|
+
|
|
346
|
+
@outcome = { status: status, unmet: Array(opts[:unmet]), at: Time.now.utc.iso8601 }.freeze
|
|
347
|
+
checkpoint!
|
|
348
|
+
@outcome
|
|
349
|
+
end
|
|
350
|
+
|
|
26
351
|
def call(messages:, tools:)
|
|
27
352
|
prepared = @memory.compact(messages: messages)
|
|
28
|
-
prepared = retrieval_context(prepared)
|
|
29
|
-
routes = @router.routes(name: @profile, engine:
|
|
30
|
-
if routes.empty?
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
353
|
+
prepared = retrieval_context(prepared) if @retrieve && !tools.nil?
|
|
354
|
+
routes = @router.routes(name: @profile, engine: (@engine if !@profile || @explicit_route), model: (@model if !@profile || @explicit_route))
|
|
355
|
+
routes = [{ provider: @engine, model: @model }] if routes.empty?
|
|
356
|
+
attempt = 0
|
|
357
|
+
previous = Thread.current[:pwn_provider_attempt_owner]
|
|
358
|
+
begin
|
|
359
|
+
check_cancelled!
|
|
360
|
+
Thread.current[:pwn_provider_attempt_owner] = self
|
|
361
|
+
@route = routes[[attempt, routes.length - 1].min]
|
|
362
|
+
attempt += 1
|
|
363
|
+
@last_provider_attempts = attempt
|
|
364
|
+
input = Marshal.load(Marshal.dump(prepared))
|
|
365
|
+
input = [{ role: 'system', content: @route[:system_prompt] }] + input if @route[:system_prompt] && !@route[:system_prompt].empty?
|
|
366
|
+
provider_event('provider_start', attempt)
|
|
367
|
+
begin
|
|
368
|
+
response = provider_attempt { yield(input, @route) }
|
|
369
|
+
ensure
|
|
370
|
+
provider_event('provider_end', attempt)
|
|
39
371
|
end
|
|
372
|
+
rescue StandardError => e
|
|
373
|
+
raise unless attempt < @provider_attempts && provider_transient?(e)
|
|
374
|
+
|
|
375
|
+
check_cancelled!
|
|
376
|
+
provider_backoff(e, attempt)
|
|
377
|
+
retry
|
|
378
|
+
ensure
|
|
379
|
+
Thread.current[:pwn_provider_attempt_owner] = previous
|
|
40
380
|
end
|
|
41
381
|
record(event: 'response', data: response)
|
|
42
382
|
response
|
|
@@ -57,10 +397,140 @@ module PWN
|
|
|
57
397
|
|
|
58
398
|
private
|
|
59
399
|
|
|
400
|
+
def evidence_target_matches?(opts)
|
|
401
|
+
return true unless opts[:kind].to_s == 'http'
|
|
402
|
+
|
|
403
|
+
uri = URI.parse(opts[:locator].to_s)
|
|
404
|
+
return false unless %w[http https].include?(uri.scheme) && uri.host && !uri.userinfo
|
|
405
|
+
|
|
406
|
+
requirement = opts[:requirement].to_s
|
|
407
|
+
urls = URI.extract(requirement, %w[http https])
|
|
408
|
+
return urls.include?(uri.to_s) unless urls.empty?
|
|
409
|
+
|
|
410
|
+
hosts = requirement.scan(/(?<![\w.-])(?:[a-z0-9-]+\.)+[a-z0-9-]+(?![\w.-])/i).map(&:downcase)
|
|
411
|
+
hosts.empty? || hosts.include?(uri.host.downcase)
|
|
412
|
+
rescue URI::InvalidURIError
|
|
413
|
+
false
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
def provider_event(phase, attempt)
|
|
417
|
+
@mutex.synchronize do
|
|
418
|
+
@events << { phase: phase, attempt: attempt, provider: @route[:provider], model: @route[:model], at: Time.now.utc.iso8601(9) }
|
|
419
|
+
checkpoint!
|
|
420
|
+
end
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
def provider_backoff(error, attempt)
|
|
424
|
+
return unless error.respond_to?(:http_code) && error.http_code.to_i == 429
|
|
425
|
+
|
|
426
|
+
delay = PWN::AI::HttpRetry.retry_after_s(response: error.response, retry_count: attempt)
|
|
427
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + delay
|
|
428
|
+
while (remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)).positive?
|
|
429
|
+
check_cancelled!
|
|
430
|
+
sleep [remaining, 0.05].min
|
|
431
|
+
end
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
# A cancellation callback is observed even while a provider is blocked.
|
|
435
|
+
# No extra request deadline is introduced. Join every owned worker.
|
|
436
|
+
def provider_attempt
|
|
437
|
+
return yield unless @cancel
|
|
438
|
+
|
|
439
|
+
locals = Thread.current.keys.to_h { |key| [key, Thread.current[key]] }
|
|
440
|
+
worker = Thread.new do
|
|
441
|
+
locals.each { |key, value| Thread.current[key] = value }
|
|
442
|
+
yield
|
|
443
|
+
end
|
|
444
|
+
worker.report_on_exception = false
|
|
445
|
+
check_cancelled! until worker.join(0.05)
|
|
446
|
+
check_cancelled!
|
|
447
|
+
worker.value
|
|
448
|
+
ensure
|
|
449
|
+
worker.kill if worker&.alive?
|
|
450
|
+
worker&.join
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
def provider_transient?(error)
|
|
454
|
+
return false if PWN::AI::HttpRetry.quota_exhausted?(error: error)
|
|
455
|
+
|
|
456
|
+
status = error.respond_to?(:http_code) ? error.http_code.to_i : 0
|
|
457
|
+
error.is_a?(Timeout::Error) || error.is_a?(SocketError) || error.is_a?(EOFError) ||
|
|
458
|
+
[Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EHOSTUNREACH, Errno::ENETUNREACH].any? { |klass| error.is_a?(klass) } ||
|
|
459
|
+
status == 429 || status.between?(500, 599) || PWN::AI::HttpRetry.retryable?(error: error)
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
def canonical(value)
|
|
463
|
+
case value
|
|
464
|
+
when Hash then value.keys.sort_by(&:to_s).to_h { |key| [key.to_s, canonical(value[key])] }
|
|
465
|
+
when Array then value.map { |item| canonical(item) }
|
|
466
|
+
else value
|
|
467
|
+
end
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
def rebuild_contract!
|
|
471
|
+
@contract = { id: @request_id, request: @request, cwd: @cwd,
|
|
472
|
+
required_artifacts: TurnFinalizer.output_paths(request: @request, cwd: @cwd).map(&:freeze).freeze,
|
|
473
|
+
started_at: @started_at }.freeze
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
def checkpoint!
|
|
477
|
+
FileUtils.mkdir_p(File.dirname(@checkpoint_path), mode: 0o700)
|
|
478
|
+
File.open("#{@checkpoint_path}.lock", File::RDWR | File::CREAT, 0o600) do |lock|
|
|
479
|
+
lock.flock(File::LOCK_EX)
|
|
480
|
+
current = File.file?(@checkpoint_path) ? JSON.parse(File.read(@checkpoint_path)).fetch('generation', 0) : 0
|
|
481
|
+
raise IOError, 'stale request checkpoint: reload before proceeding' unless current == @generation
|
|
482
|
+
|
|
483
|
+
@generation += 1
|
|
484
|
+
write_checkpoint!
|
|
485
|
+
end
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
def write_checkpoint!
|
|
489
|
+
rows = @observations.transform_values do |row|
|
|
490
|
+
row.merge(head: Base64.strict_encode64(row[:head]), tail: Base64.strict_encode64(row[:tail]))
|
|
491
|
+
end
|
|
492
|
+
data = { version: 1, generation: @generation, session_id: @session_id, request_id: @request_id, original_request: @original_request,
|
|
493
|
+
request: @request, amendments: @amendments, cwd: @cwd, started_at: @started_at.iso8601(9),
|
|
494
|
+
observations: rows, outcome: @outcome, events: @events, effects: @effects, evidence: @evidence,
|
|
495
|
+
enabled_toolsets: @enabled_toolsets, verification_contract: @verification_contract }
|
|
496
|
+
FileUtils.mkdir_p(File.dirname(@checkpoint_path), mode: 0o700)
|
|
497
|
+
temp = "#{@checkpoint_path}.#{SecureRandom.hex(8)}.tmp"
|
|
498
|
+
File.open(temp, File::WRONLY | File::CREAT | File::EXCL, 0o600) do |file|
|
|
499
|
+
file.write(JSON.generate(data))
|
|
500
|
+
file.flush
|
|
501
|
+
file.fsync
|
|
502
|
+
end
|
|
503
|
+
File.rename(temp, @checkpoint_path)
|
|
504
|
+
ensure
|
|
505
|
+
File.unlink(temp) if temp && File.exist?(temp)
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
def restore!
|
|
509
|
+
data = JSON.parse(File.read(@checkpoint_path), symbolize_names: true)
|
|
510
|
+
raise ArgumentError, 'request checkpoint identity mismatch' unless data[:version] == 1 && data[:session_id] == @session_id && data[:request_id] == @request_id
|
|
511
|
+
|
|
512
|
+
@original_request = data.fetch(:original_request).freeze
|
|
513
|
+
@generation = data.fetch(:generation, 0)
|
|
514
|
+
@enabled_toolsets = data[:enabled_toolsets]&.freeze
|
|
515
|
+
@verification_contract = data[:verification_contract]
|
|
516
|
+
@request = data.fetch(:request).freeze
|
|
517
|
+
@cwd = data.fetch(:cwd)
|
|
518
|
+
@started_at = Time.iso8601(data.fetch(:started_at))
|
|
519
|
+
@amendments = data.fetch(:amendments).map(&:freeze).freeze
|
|
520
|
+
@outcome = data[:outcome]&.freeze
|
|
521
|
+
@events = data.fetch(:events, [])
|
|
522
|
+
@effects = data.fetch(:effects, {}).transform_keys(&:to_s)
|
|
523
|
+
@evidence = data.fetch(:evidence, [])
|
|
524
|
+
@observations = data.fetch(:observations).to_h do |path, row|
|
|
525
|
+
[path.to_s, row.merge(head: Base64.strict_decode64(row[:head]), tail: Base64.strict_decode64(row[:tail]))]
|
|
526
|
+
end
|
|
527
|
+
end
|
|
528
|
+
|
|
60
529
|
def record(event:, data:)
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
530
|
+
return unless @trace
|
|
531
|
+
|
|
532
|
+
engine = @route[:provider] || @engine
|
|
533
|
+
model = @route[:model] || @model || @provider_config.dig(engine.to_s.to_sym, :model)
|
|
64
534
|
PWN::SessionTrace.append(session_id: @session_id, event: event, data: data, model: model,
|
|
65
535
|
params: @route.merge(provider: engine))
|
|
66
536
|
end
|
|
@@ -86,6 +556,27 @@ module PWN
|
|
|
86
556
|
#{self}.authors
|
|
87
557
|
# Print this usage.
|
|
88
558
|
#{self}.help
|
|
559
|
+
|
|
560
|
+
# RequestRuntime instances own immutable original_request, ordered amendments,
|
|
561
|
+
# an effective request, a revision-bound host observation ledger and outcome.
|
|
562
|
+
# new accepts request:, session_id:, request_id:, artifact_root:, resume:,
|
|
563
|
+
# profile:, profiles:, window:, tool_cap:, trace:, retrieve:,
|
|
564
|
+
# enabled_toolsets:, verification_contract:, provider_config:, engine:,
|
|
565
|
+
# model:, provider_attempts: (default 5 across all routes), cancel: callback.
|
|
566
|
+
# scheduled_calls(calls, session_id:) overlaps at most eight audited
|
|
567
|
+
# independent reads; conflicting resources and unknown tools are barriers.
|
|
568
|
+
# evidence holds typed host verifier observations with source clause IDs,
|
|
569
|
+
# exact locators, digests and freshness. Tool stdout is never evidence.
|
|
570
|
+
# reconcile_effect!(payload_sha256:, evidence_id:, operator_approved:)
|
|
571
|
+
# requires fresh independent exact-target proof after uncertainty.
|
|
572
|
+
# execute_tool owns durable start/end events and uncertain mutation replay denial.
|
|
573
|
+
# events includes verifier reports independent of compacted chat messages.
|
|
574
|
+
# Checkpoint generation checks reject stale writers. Loop restores a session's
|
|
575
|
+
# active OpenGoal identity on a bare continue request.
|
|
576
|
+
# amend!(instruction:) adds scope by default; replace: and cancel are explicit.
|
|
577
|
+
# observe!(observations:) accepts host-only TurnFinalizer snapshots.
|
|
578
|
+
# finish!(status:, unmet:) persists complete/incomplete/blocked/cancelled/verification_error.
|
|
579
|
+
# resume: true requires the same session_id/request_id/root and never replays tools.
|
|
89
580
|
"
|
|
90
581
|
end
|
|
91
582
|
end
|