agent-session_context 0.1.0
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 +7 -0
- data/CHANGELOG.md +28 -0
- data/LICENSE.txt +21 -0
- data/README.md +268 -0
- data/exe/agent-session-context +6 -0
- data/lib/agent/session_context/builder.rb +146 -0
- data/lib/agent/session_context/cli/options.rb +213 -0
- data/lib/agent/session_context/cli.rb +229 -0
- data/lib/agent/session_context/config.rb +206 -0
- data/lib/agent/session_context/errors.rb +15 -0
- data/lib/agent/session_context/evidence_collector.rb +227 -0
- data/lib/agent/session_context/evidence_packet.rb +271 -0
- data/lib/agent/session_context/immutable_value.rb +71 -0
- data/lib/agent/session_context/injected_context.rb +92 -0
- data/lib/agent/session_context/injected_context_collector.rb +53 -0
- data/lib/agent/session_context/item.rb +54 -0
- data/lib/agent/session_context/prompt.rb +40 -0
- data/lib/agent/session_context/prompt_extractor.rb +31 -0
- data/lib/agent/session_context/renderers/human_display.rb +113 -0
- data/lib/agent/session_context/renderers/json.rb +13 -0
- data/lib/agent/session_context/renderers/json_lines.rb +13 -0
- data/lib/agent/session_context/renderers/markdown.rb +126 -0
- data/lib/agent/session_context/renderers/serializer.rb +124 -0
- data/lib/agent/session_context/renderers/text.rb +122 -0
- data/lib/agent/session_context/semantic_categories.rb +89 -0
- data/lib/agent/session_context/semantic_pipeline.rb +151 -0
- data/lib/agent/session_context/semantic_schema.rb +75 -0
- data/lib/agent/session_context/session_resolver.rb +147 -0
- data/lib/agent/session_context/snapshot.rb +128 -0
- data/lib/agent/session_context/source_ref.rb +46 -0
- data/lib/agent/session_context/subprocess_runner.rb +362 -0
- data/lib/agent/session_context/summarizers/claude.rb +126 -0
- data/lib/agent/session_context/summarizers/codex.rb +132 -0
- data/lib/agent/session_context/summarizers/command_execution_policy.rb +134 -0
- data/lib/agent/session_context/summarizers.rb +35 -0
- data/lib/agent/session_context/summary_parser.rb +219 -0
- data/lib/agent/session_context/transcript.rb +236 -0
- data/lib/agent/session_context/version.rb +7 -0
- data/lib/agent/session_context.rb +56 -0
- metadata +112 -0
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "English"
|
|
4
|
+
require "open3"
|
|
5
|
+
|
|
6
|
+
module Agent
|
|
7
|
+
module SessionContext
|
|
8
|
+
class SubprocessRunner
|
|
9
|
+
DEFAULT_CLOCK = lambda {
|
|
10
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
11
|
+
}
|
|
12
|
+
private_constant :DEFAULT_CLOCK
|
|
13
|
+
|
|
14
|
+
class TimeoutError < StandardError
|
|
15
|
+
attr_reader :timeout_seconds
|
|
16
|
+
|
|
17
|
+
def initialize(timeout_seconds)
|
|
18
|
+
@timeout_seconds = timeout_seconds
|
|
19
|
+
super("subprocess exceeded #{timeout_seconds} seconds")
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
class OutputLimitError < StandardError
|
|
24
|
+
attr_reader :stream, :max_output_bytes
|
|
25
|
+
|
|
26
|
+
def initialize(stream:, max_output_bytes:)
|
|
27
|
+
@stream = stream
|
|
28
|
+
@max_output_bytes = max_output_bytes
|
|
29
|
+
super("#{stream} exceeded #{max_output_bytes} bytes")
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def initialize(timeout_seconds:, max_output_bytes:, clock: DEFAULT_CLOCK, termination_grace_seconds: 0.5)
|
|
34
|
+
@timeout_seconds = validate_timeout!(timeout_seconds)
|
|
35
|
+
@max_output_bytes = validate_max_output_bytes!(max_output_bytes)
|
|
36
|
+
@clock = validate_clock!(clock)
|
|
37
|
+
@termination_grace_seconds = validate_termination_grace!(termination_grace_seconds)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def call(env:, argv:, stdin_data:)
|
|
41
|
+
deadline = monotonic_now + @timeout_seconds
|
|
42
|
+
stdin = stdout = stderr = wait_thread = stdin_thread = nil
|
|
43
|
+
stdin_error = nil
|
|
44
|
+
completed = false
|
|
45
|
+
stdout_buffer = String.new.b
|
|
46
|
+
stderr_buffer = String.new.b
|
|
47
|
+
|
|
48
|
+
stdin, stdout, stderr, wait_thread = Open3.popen3(
|
|
49
|
+
env,
|
|
50
|
+
*argv,
|
|
51
|
+
unsetenv_others: true,
|
|
52
|
+
**process_group_options
|
|
53
|
+
)
|
|
54
|
+
prepare_streams!(stdin, stdout, stderr)
|
|
55
|
+
stdin_thread, stdin_error = start_stdin_writer(stdin, stdin_data)
|
|
56
|
+
|
|
57
|
+
status = monitor_process(
|
|
58
|
+
deadline:,
|
|
59
|
+
wait_thread:,
|
|
60
|
+
stdout:,
|
|
61
|
+
stderr:,
|
|
62
|
+
stdout_buffer:,
|
|
63
|
+
stderr_buffer:,
|
|
64
|
+
stdin_thread:,
|
|
65
|
+
stdin_error:
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
join_stdin_thread!(stdin_thread, stdin_error)
|
|
69
|
+
completed = true
|
|
70
|
+
[stdout_buffer, stderr_buffer, status]
|
|
71
|
+
ensure
|
|
72
|
+
cleanup_call(
|
|
73
|
+
wait_thread:,
|
|
74
|
+
stdin:,
|
|
75
|
+
stdout:,
|
|
76
|
+
stderr:,
|
|
77
|
+
stdin_thread:,
|
|
78
|
+
stdin_error:,
|
|
79
|
+
completed:,
|
|
80
|
+
primary_error: $ERROR_INFO
|
|
81
|
+
)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def validate_timeout!(value)
|
|
87
|
+
validate_numeric!(value, name: "timeout_seconds", allow_zero: false)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def validate_max_output_bytes!(value)
|
|
91
|
+
unless value.is_a?(Integer) && value.positive?
|
|
92
|
+
raise ArgumentError,
|
|
93
|
+
"max_output_bytes must be a positive Integer"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
value
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def validate_clock!(value)
|
|
100
|
+
raise ArgumentError, "clock must respond to call" unless value.respond_to?(:call)
|
|
101
|
+
|
|
102
|
+
value
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def validate_termination_grace!(value)
|
|
106
|
+
validate_numeric!(value, name: "termination_grace_seconds", allow_zero: true)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def validate_numeric!(value, name:, allow_zero:)
|
|
110
|
+
valid_type = value.is_a?(Integer) || value.is_a?(Float)
|
|
111
|
+
unless valid_type
|
|
112
|
+
raise ArgumentError,
|
|
113
|
+
"#{name} must be a #{allow_zero ? "nonnegative" : "positive"} finite number"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
finite = !value.is_a?(Float) || value.finite?
|
|
117
|
+
positive_enough = allow_zero ? value >= 0 : value.positive?
|
|
118
|
+
unless finite && positive_enough
|
|
119
|
+
raise ArgumentError,
|
|
120
|
+
"#{name} must be a #{allow_zero ? "nonnegative" : "positive"} finite number"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
value
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def process_group_options
|
|
127
|
+
return { new_pgroup: true } if Gem.win_platform?
|
|
128
|
+
|
|
129
|
+
{ pgroup: true }
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def prepare_streams!(stdin, stdout, stderr)
|
|
133
|
+
stdin.binmode
|
|
134
|
+
stdout.binmode
|
|
135
|
+
stderr.binmode
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def start_stdin_writer(stdin, stdin_data)
|
|
139
|
+
error_box = { error: nil }
|
|
140
|
+
writer_thread = Thread.new do
|
|
141
|
+
write_stdin(stdin, stdin_data)
|
|
142
|
+
rescue Errno::EPIPE
|
|
143
|
+
nil
|
|
144
|
+
rescue IOError => e
|
|
145
|
+
error_box[:error] = e unless stdin.closed?
|
|
146
|
+
rescue StandardError => e
|
|
147
|
+
error_box[:error] = e
|
|
148
|
+
ensure
|
|
149
|
+
close_stream(stdin)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
[writer_thread, error_box]
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def write_stdin(stdin, stdin_data)
|
|
156
|
+
return if stdin_data.nil?
|
|
157
|
+
|
|
158
|
+
payload = stdin_data.dup.force_encoding(Encoding::BINARY)
|
|
159
|
+
stdin.write(payload)
|
|
160
|
+
stdin.flush
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def monitor_process(deadline:, wait_thread:, stdout:, stderr:, stdout_buffer:, stderr_buffer:, stdin_thread:,
|
|
164
|
+
stdin_error:)
|
|
165
|
+
streams = { stdout: stdout, stderr: stderr }
|
|
166
|
+
status = nil
|
|
167
|
+
|
|
168
|
+
loop do
|
|
169
|
+
raise_writer_error!(stdin_thread, stdin_error)
|
|
170
|
+
stdin_writer_finished = stdin_writer_finished?(stdin_thread)
|
|
171
|
+
|
|
172
|
+
raise TimeoutError, @timeout_seconds if timed_out?(deadline)
|
|
173
|
+
|
|
174
|
+
status = wait_thread.value if wait_thread.join(0)
|
|
175
|
+
break if status && streams.empty? && stdin_writer_finished
|
|
176
|
+
|
|
177
|
+
ready = wait_for_ready_streams(wait_thread, stdin_thread, streams.values, deadline)
|
|
178
|
+
|
|
179
|
+
ready.each do |stream|
|
|
180
|
+
name = streams.key(stream)
|
|
181
|
+
next unless name
|
|
182
|
+
|
|
183
|
+
drain_stream!(
|
|
184
|
+
name:,
|
|
185
|
+
stream:,
|
|
186
|
+
buffer: name == :stdout ? stdout_buffer : stderr_buffer,
|
|
187
|
+
streams:
|
|
188
|
+
)
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
status || wait_thread.value
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def drain_stream!(name:, stream:, buffer:, streams:)
|
|
196
|
+
chunk = stream.read_nonblock(4096, exception: false)
|
|
197
|
+
|
|
198
|
+
case chunk
|
|
199
|
+
when :wait_readable
|
|
200
|
+
nil
|
|
201
|
+
when nil
|
|
202
|
+
close_stream(stream)
|
|
203
|
+
streams.delete(name)
|
|
204
|
+
else
|
|
205
|
+
append_chunk!(buffer, chunk, name)
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def append_chunk!(buffer, chunk, stream_name)
|
|
210
|
+
remaining = @max_output_bytes - buffer.bytesize
|
|
211
|
+
if chunk.bytesize > remaining
|
|
212
|
+
buffer << chunk.byteslice(0, remaining) if remaining.positive?
|
|
213
|
+
raise OutputLimitError.new(stream: stream_name, max_output_bytes: @max_output_bytes)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
buffer << chunk
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def wait_for_ready_streams(wait_thread, stdin_thread, readable_streams, deadline)
|
|
220
|
+
interval = poll_interval(deadline)
|
|
221
|
+
return [] if interval <= 0
|
|
222
|
+
|
|
223
|
+
if readable_streams.empty?
|
|
224
|
+
wait_deadline = monotonic_now + interval
|
|
225
|
+
wait_thread.join(remaining_poll_time(wait_deadline))
|
|
226
|
+
stdin_thread&.join(remaining_poll_time(wait_deadline))
|
|
227
|
+
return []
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
IO.select(readable_streams, nil, nil, interval)&.first || []
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def raise_writer_error!(stdin_thread, error_box)
|
|
234
|
+
return unless stdin_thread
|
|
235
|
+
return unless stdin_thread.join(0)
|
|
236
|
+
return unless error_box[:error]
|
|
237
|
+
|
|
238
|
+
raise error_box[:error]
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def stdin_writer_finished?(stdin_thread)
|
|
242
|
+
return true unless stdin_thread
|
|
243
|
+
|
|
244
|
+
!stdin_thread.join(0).nil?
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def cleanup_call(wait_thread:, stdin:, stdout:, stderr:, stdin_thread:, stdin_error:, completed:, primary_error:)
|
|
248
|
+
terminate_process(wait_thread) if wait_thread && !completed
|
|
249
|
+
rescue StandardError
|
|
250
|
+
nil
|
|
251
|
+
ensure
|
|
252
|
+
close_stream(stdin)
|
|
253
|
+
close_stream(stdout)
|
|
254
|
+
close_stream(stderr)
|
|
255
|
+
begin
|
|
256
|
+
join_stdin_thread!(stdin_thread, stdin_error, suppress_error: !primary_error.nil?) if stdin_thread
|
|
257
|
+
rescue StandardError
|
|
258
|
+
nil
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def terminate_process(wait_thread)
|
|
263
|
+
pid = wait_thread.pid
|
|
264
|
+
|
|
265
|
+
if Gem.win_platform?
|
|
266
|
+
terminate_direct_child(wait_thread)
|
|
267
|
+
else
|
|
268
|
+
terminate_process_group(pid)
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
wait_thread.value
|
|
272
|
+
rescue Errno::ECHILD
|
|
273
|
+
nil
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def terminate_direct_child(wait_thread)
|
|
277
|
+
pid = wait_thread.pid
|
|
278
|
+
|
|
279
|
+
begin
|
|
280
|
+
Process.kill("TERM", pid)
|
|
281
|
+
rescue Errno::EINVAL
|
|
282
|
+
kill_direct_child(pid)
|
|
283
|
+
return
|
|
284
|
+
rescue Errno::ESRCH
|
|
285
|
+
return
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
return unless wait_thread.join(@termination_grace_seconds).nil?
|
|
289
|
+
|
|
290
|
+
kill_direct_child(pid)
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def terminate_process_group(pid)
|
|
294
|
+
Process.kill("TERM", -pid)
|
|
295
|
+
rescue Errno::ESRCH
|
|
296
|
+
nil
|
|
297
|
+
else
|
|
298
|
+
sleep_cleanup_grace(@termination_grace_seconds)
|
|
299
|
+
|
|
300
|
+
begin
|
|
301
|
+
Process.kill("KILL", -pid)
|
|
302
|
+
rescue Errno::ESRCH
|
|
303
|
+
nil
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def kill_direct_child(pid)
|
|
308
|
+
Process.kill("KILL", pid)
|
|
309
|
+
rescue Errno::ESRCH
|
|
310
|
+
nil
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def timed_out?(deadline)
|
|
314
|
+
monotonic_now >= deadline
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def remaining_seconds(deadline)
|
|
318
|
+
[deadline - monotonic_now, 0].max
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def poll_interval(deadline)
|
|
322
|
+
[remaining_seconds(deadline), 0.05].min
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def remaining_poll_time(wait_deadline)
|
|
326
|
+
[wait_deadline - monotonic_now, 0].max
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def monotonic_now
|
|
330
|
+
@clock.call
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def cleanup_monotonic_now
|
|
334
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def sleep_cleanup_grace(duration)
|
|
338
|
+
return if duration.zero?
|
|
339
|
+
|
|
340
|
+
deadline = cleanup_monotonic_now + duration
|
|
341
|
+
|
|
342
|
+
sleep([deadline - cleanup_monotonic_now, 0.01].min) while cleanup_monotonic_now < deadline
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def close_stream(stream)
|
|
346
|
+
return unless stream
|
|
347
|
+
return if stream.closed?
|
|
348
|
+
|
|
349
|
+
stream.close
|
|
350
|
+
rescue IOError, Errno::EBADF
|
|
351
|
+
nil
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def join_stdin_thread!(stdin_thread, error_box, suppress_error: false)
|
|
355
|
+
return unless stdin_thread
|
|
356
|
+
|
|
357
|
+
stdin_thread.join
|
|
358
|
+
raise error_box[:error] if !suppress_error && error_box && error_box[:error]
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Agent
|
|
6
|
+
module SessionContext
|
|
7
|
+
module Summarizers
|
|
8
|
+
class Claude
|
|
9
|
+
include CommandExecutionPolicy
|
|
10
|
+
|
|
11
|
+
MAX_OUTPUT_BYTES = 1_048_576
|
|
12
|
+
PROVIDER_ENV_KEYS = %w[ANTHROPIC_API_KEY CLAUDE_CODE_OAUTH_TOKEN CLAUDE_CONFIG_DIR].freeze
|
|
13
|
+
private_constant :PROVIDER_ENV_KEYS
|
|
14
|
+
|
|
15
|
+
def initialize(runner: nil, timeout_seconds: Config::DEFAULT_TIMEOUT_SECONDS)
|
|
16
|
+
initialize_command_execution_policy(
|
|
17
|
+
runner:,
|
|
18
|
+
timeout_seconds:,
|
|
19
|
+
max_output_bytes: MAX_OUTPUT_BYTES,
|
|
20
|
+
provider_env_keys: PROVIDER_ENV_KEYS
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def name
|
|
25
|
+
:claude
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def call(prompt:, schema:)
|
|
29
|
+
stdout, stderr, status = run_command!(prompt:, argv: command_argv(schema))
|
|
30
|
+
fail_command!(stdout:, stderr:, status:) unless status.success?
|
|
31
|
+
if stdout.strip.empty?
|
|
32
|
+
raise SummarizerFailed, failure_message(
|
|
33
|
+
"#{name} summarizer produced empty stdout",
|
|
34
|
+
stdout:,
|
|
35
|
+
stderr:
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
envelope = parse_envelope!(stdout, stderr:)
|
|
40
|
+
field_name, value = extract_structured_value!(envelope)
|
|
41
|
+
|
|
42
|
+
normalize_value!(value, field_name:, stdout:, stderr:)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def command_argv(schema)
|
|
48
|
+
[
|
|
49
|
+
"claude",
|
|
50
|
+
"--print",
|
|
51
|
+
"--safe-mode",
|
|
52
|
+
"--tools",
|
|
53
|
+
"",
|
|
54
|
+
"--no-session-persistence",
|
|
55
|
+
"--output-format",
|
|
56
|
+
"json",
|
|
57
|
+
"--json-schema",
|
|
58
|
+
JSON.generate(schema)
|
|
59
|
+
]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def parse_envelope!(stdout, stderr:)
|
|
63
|
+
envelope = JSON.parse(stdout)
|
|
64
|
+
unless envelope.is_a?(Hash)
|
|
65
|
+
raise SummarizerFailed, failure_message(
|
|
66
|
+
"#{name} summarizer produced a non-object JSON envelope",
|
|
67
|
+
stdout:,
|
|
68
|
+
stderr:
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
envelope
|
|
73
|
+
rescue JSON::ParserError, EncodingError, ArgumentError
|
|
74
|
+
raise SummarizerFailed, failure_message(
|
|
75
|
+
"#{name} summarizer produced invalid JSON envelope",
|
|
76
|
+
stdout:,
|
|
77
|
+
stderr:
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def extract_structured_value!(envelope)
|
|
82
|
+
return ["structured_output", envelope["structured_output"]] if envelope.key?("structured_output")
|
|
83
|
+
return ["result", envelope["result"]] if envelope.key?("result")
|
|
84
|
+
|
|
85
|
+
raise SummarizerFailed, "#{name} summarizer JSON envelope did not include structured_output or result"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def normalize_value!(value, field_name:, stdout:, stderr:)
|
|
89
|
+
case value
|
|
90
|
+
when String
|
|
91
|
+
normalize_json_string!(value, stdout:, stderr:, label: field_name)
|
|
92
|
+
else
|
|
93
|
+
JSON.generate(JSON.parse(JSON.generate(value)))
|
|
94
|
+
end
|
|
95
|
+
rescue JSON::GeneratorError, TypeError
|
|
96
|
+
raise SummarizerFailed, failure_message(
|
|
97
|
+
"#{name} summarizer produced a non-JSON #{field_name} value",
|
|
98
|
+
stdout:,
|
|
99
|
+
stderr:,
|
|
100
|
+
output: value.inspect
|
|
101
|
+
)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def normalize_json_string!(value, stdout:, stderr:, label:)
|
|
105
|
+
JSON.generate(JSON.parse(value))
|
|
106
|
+
rescue JSON::ParserError, EncodingError, ArgumentError
|
|
107
|
+
raise SummarizerFailed, failure_message(
|
|
108
|
+
"#{name} summarizer produced invalid JSON in #{label}",
|
|
109
|
+
stdout:,
|
|
110
|
+
stderr:,
|
|
111
|
+
output: value
|
|
112
|
+
)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def fail_command!(stdout:, stderr:, status:)
|
|
116
|
+
raise SummarizerFailed, failure_message(
|
|
117
|
+
"#{name} summarizer command failed",
|
|
118
|
+
stdout:,
|
|
119
|
+
stderr:,
|
|
120
|
+
status:
|
|
121
|
+
)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "tempfile"
|
|
5
|
+
|
|
6
|
+
module Agent
|
|
7
|
+
module SessionContext
|
|
8
|
+
module Summarizers
|
|
9
|
+
class Codex
|
|
10
|
+
include CommandExecutionPolicy
|
|
11
|
+
|
|
12
|
+
MAX_OUTPUT_BYTES = 1_048_576
|
|
13
|
+
PROVIDER_ENV_KEYS = %w[CODEX_HOME OPENAI_API_KEY].freeze
|
|
14
|
+
WHITESPACE_BYTES = [9, 10, 11, 12, 13, 32].freeze
|
|
15
|
+
private_constant :PROVIDER_ENV_KEYS, :WHITESPACE_BYTES
|
|
16
|
+
|
|
17
|
+
def initialize(runner: nil, timeout_seconds: Config::DEFAULT_TIMEOUT_SECONDS)
|
|
18
|
+
initialize_command_execution_policy(
|
|
19
|
+
runner:,
|
|
20
|
+
timeout_seconds:,
|
|
21
|
+
max_output_bytes: MAX_OUTPUT_BYTES,
|
|
22
|
+
provider_env_keys: PROVIDER_ENV_KEYS
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def name
|
|
27
|
+
:codex
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def call(prompt:, schema:)
|
|
31
|
+
Tempfile.create(["agent-context-codex-schema", ".json"]) do |schema_file|
|
|
32
|
+
schema_file.write(JSON.generate(schema))
|
|
33
|
+
schema_file.flush
|
|
34
|
+
|
|
35
|
+
Tempfile.create(["agent-context-codex-output", ".json"]) do |output_file|
|
|
36
|
+
stdout, stderr, status = run_command!(
|
|
37
|
+
prompt:,
|
|
38
|
+
argv: command_argv(schema_path: schema_file.path, output_path: output_file.path)
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
fail_command!(stdout:, stderr:, status:) unless status.success?
|
|
42
|
+
|
|
43
|
+
payload = read_last_message!(output_file.path, stdout:, stderr:)
|
|
44
|
+
normalize_json!(payload, source: "last message", stdout:, stderr:)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def command_argv(schema_path:, output_path:)
|
|
52
|
+
[
|
|
53
|
+
"codex",
|
|
54
|
+
"exec",
|
|
55
|
+
"--ephemeral",
|
|
56
|
+
"--sandbox",
|
|
57
|
+
"read-only",
|
|
58
|
+
"--ignore-user-config",
|
|
59
|
+
"--ignore-rules",
|
|
60
|
+
"--skip-git-repo-check",
|
|
61
|
+
"--output-schema",
|
|
62
|
+
schema_path,
|
|
63
|
+
"--output-last-message",
|
|
64
|
+
output_path,
|
|
65
|
+
"-"
|
|
66
|
+
]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def read_last_message!(path, stdout:, stderr:)
|
|
70
|
+
payload = bounded_file_read(path, "last message", stdout:, stderr:)
|
|
71
|
+
rescue Errno::ENOENT
|
|
72
|
+
raise SummarizerFailed, failure_message(
|
|
73
|
+
"#{name} summarizer did not produce a last message file; output was missing",
|
|
74
|
+
stdout:,
|
|
75
|
+
stderr:
|
|
76
|
+
)
|
|
77
|
+
else
|
|
78
|
+
if blank_bytes?(payload)
|
|
79
|
+
raise SummarizerFailed, failure_message(
|
|
80
|
+
"#{name} summarizer produced an empty last message file",
|
|
81
|
+
stdout:,
|
|
82
|
+
stderr:,
|
|
83
|
+
output: payload
|
|
84
|
+
)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
payload
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def normalize_json!(payload, source:, stdout:, stderr:)
|
|
91
|
+
JSON.generate(JSON.parse(payload))
|
|
92
|
+
rescue JSON::ParserError, EncodingError, ArgumentError
|
|
93
|
+
raise SummarizerFailed, failure_message(
|
|
94
|
+
"#{name} summarizer produced invalid JSON in #{source}",
|
|
95
|
+
stdout:,
|
|
96
|
+
stderr:,
|
|
97
|
+
output: payload
|
|
98
|
+
)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def fail_command!(stdout:, stderr:, status:)
|
|
102
|
+
raise SummarizerFailed, failure_message(
|
|
103
|
+
"#{name} summarizer command failed",
|
|
104
|
+
stdout:,
|
|
105
|
+
stderr:,
|
|
106
|
+
status:
|
|
107
|
+
)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def bounded_file_read(path, label, stdout:, stderr:)
|
|
111
|
+
File.open(path, "rb") do |file|
|
|
112
|
+
payload = file.read(MAX_OUTPUT_BYTES + 1) || "".b
|
|
113
|
+
if payload.bytesize > MAX_OUTPUT_BYTES
|
|
114
|
+
raise SummarizerFailed, failure_message(
|
|
115
|
+
"#{name} summarizer #{label} exceeded #{MAX_OUTPUT_BYTES} bytes",
|
|
116
|
+
stdout:,
|
|
117
|
+
stderr:,
|
|
118
|
+
output: payload
|
|
119
|
+
)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
payload
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def blank_bytes?(value)
|
|
127
|
+
value.empty? || value.bytes.all? { |byte| WHITESPACE_BYTES.include?(byte) }
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|