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.
Files changed (40) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +28 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +268 -0
  5. data/exe/agent-session-context +6 -0
  6. data/lib/agent/session_context/builder.rb +146 -0
  7. data/lib/agent/session_context/cli/options.rb +213 -0
  8. data/lib/agent/session_context/cli.rb +229 -0
  9. data/lib/agent/session_context/config.rb +206 -0
  10. data/lib/agent/session_context/errors.rb +15 -0
  11. data/lib/agent/session_context/evidence_collector.rb +227 -0
  12. data/lib/agent/session_context/evidence_packet.rb +271 -0
  13. data/lib/agent/session_context/immutable_value.rb +71 -0
  14. data/lib/agent/session_context/injected_context.rb +92 -0
  15. data/lib/agent/session_context/injected_context_collector.rb +53 -0
  16. data/lib/agent/session_context/item.rb +54 -0
  17. data/lib/agent/session_context/prompt.rb +40 -0
  18. data/lib/agent/session_context/prompt_extractor.rb +31 -0
  19. data/lib/agent/session_context/renderers/human_display.rb +113 -0
  20. data/lib/agent/session_context/renderers/json.rb +13 -0
  21. data/lib/agent/session_context/renderers/json_lines.rb +13 -0
  22. data/lib/agent/session_context/renderers/markdown.rb +126 -0
  23. data/lib/agent/session_context/renderers/serializer.rb +124 -0
  24. data/lib/agent/session_context/renderers/text.rb +122 -0
  25. data/lib/agent/session_context/semantic_categories.rb +89 -0
  26. data/lib/agent/session_context/semantic_pipeline.rb +151 -0
  27. data/lib/agent/session_context/semantic_schema.rb +75 -0
  28. data/lib/agent/session_context/session_resolver.rb +147 -0
  29. data/lib/agent/session_context/snapshot.rb +128 -0
  30. data/lib/agent/session_context/source_ref.rb +46 -0
  31. data/lib/agent/session_context/subprocess_runner.rb +362 -0
  32. data/lib/agent/session_context/summarizers/claude.rb +126 -0
  33. data/lib/agent/session_context/summarizers/codex.rb +132 -0
  34. data/lib/agent/session_context/summarizers/command_execution_policy.rb +134 -0
  35. data/lib/agent/session_context/summarizers.rb +35 -0
  36. data/lib/agent/session_context/summary_parser.rb +219 -0
  37. data/lib/agent/session_context/transcript.rb +236 -0
  38. data/lib/agent/session_context/version.rb +7 -0
  39. data/lib/agent/session_context.rb +56 -0
  40. 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