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,271 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module SessionContext
5
+ class EvidencePacket
6
+ MAX_BYTES = 65_536
7
+ Result = Data.define(:chunks, :warnings, :source_refs) do
8
+ def source_refs_for(chunk)
9
+ ref_index = source_refs.to_h do |source_ref|
10
+ [EvidencePacket.safe_ref_text(source_ref), source_ref]
11
+ end
12
+ represented_refs = []
13
+ seen_refs = {}
14
+
15
+ String(chunk).each_line(chomp: true) do |line|
16
+ prefix, = line.split(" ", 2)
17
+ source_ref = ref_index[prefix]
18
+ next unless source_ref
19
+ next if seen_refs.key?(prefix)
20
+
21
+ seen_refs[prefix] = true
22
+ represented_refs << source_ref
23
+ end
24
+
25
+ represented_refs.freeze
26
+ end
27
+ end
28
+ TRUNCATABLE_KEYS = {
29
+ message: ["text"].freeze,
30
+ tool_use: %w[input name].freeze,
31
+ observed: ["label"].freeze
32
+ }.freeze
33
+ UNSAFE_SOURCE_REF_TEXT_PATTERN = /(?:\p{Space}|\p{Cntrl})/
34
+
35
+ class << self
36
+ def safe_ref_text(source_ref)
37
+ text = source_ref.to_s
38
+ normalized_text = normalize_ref_text(text, source_ref)
39
+
40
+ if normalized_text.match?(UNSAFE_SOURCE_REF_TEXT_PATTERN)
41
+ raise ArgumentError,
42
+ "Source ref text cannot contain whitespace or control characters (#{source_ref_type(source_ref)})"
43
+ end
44
+
45
+ normalized_text
46
+ end
47
+
48
+ private
49
+
50
+ def normalize_ref_text(text, source_ref)
51
+ return text if text.encoding == Encoding::UTF_8 && text.valid_encoding?
52
+
53
+ utf8_text = duplicate_as_utf8(text)
54
+ return utf8_text if utf8_text.valid_encoding?
55
+ return utf8_text if utf8_bytes?(text)
56
+
57
+ raise ArgumentError,
58
+ "Source ref text must be valid UTF-8 or ASCII-only in a safely " \
59
+ "convertible encoding (#{source_ref_type(source_ref)})"
60
+ rescue Encoding::UndefinedConversionError, Encoding::InvalidByteSequenceError
61
+ raise ArgumentError,
62
+ "Source ref text must be valid UTF-8 or ASCII-only in a safely " \
63
+ "convertible encoding (#{source_ref_type(source_ref)})"
64
+ end
65
+
66
+ def duplicate_as_utf8(text)
67
+ String.new(text, encoding: text.encoding).dup.force_encoding(Encoding::UTF_8)
68
+ end
69
+
70
+ def utf8_bytes?(text)
71
+ text.bytes.all? { |byte| byte < 128 }
72
+ end
73
+
74
+ def source_ref_type(source_ref)
75
+ source_ref.class.name || source_ref.class.to_s
76
+ end
77
+ end
78
+
79
+ def call(transcript:, observed:)
80
+ warnings = []
81
+ carried_refs = []
82
+ seen_refs = {}
83
+ lines = []
84
+
85
+ transcript.entries.each do |entry|
86
+ entry.parts.each do |part|
87
+ line = serialize_transcript_part(entry.role, part, warnings)
88
+ next unless line
89
+
90
+ remember_ref(seen_refs, carried_refs, part.source_ref)
91
+ lines << line
92
+ end
93
+ end
94
+
95
+ observed_lines(observed).each do |source_ref, payload|
96
+ line = bounded_serialized_line(source_ref, payload, TRUNCATABLE_KEYS.fetch(:observed), warnings)
97
+ remember_ref(seen_refs, carried_refs, source_ref)
98
+ lines << line
99
+ end
100
+
101
+ Result.new(
102
+ chunks: chunk_lines(lines).freeze,
103
+ warnings: warnings.map { |warning| String.new(warning).freeze }.freeze,
104
+ source_refs: carried_refs.freeze
105
+ )
106
+ end
107
+
108
+ private
109
+
110
+ def serialize_transcript_part(role, part, warnings)
111
+ return if part.injected
112
+
113
+ case part.type
114
+ when :text
115
+ return unless %i[user assistant system].include?(role)
116
+
117
+ bounded_serialized_line(
118
+ part.source_ref,
119
+ {
120
+ "kind" => "message",
121
+ "role" => role.to_s,
122
+ "text" => part.text.to_s
123
+ },
124
+ TRUNCATABLE_KEYS.fetch(:message),
125
+ warnings
126
+ )
127
+ when :tool_use
128
+ bounded_serialized_line(
129
+ part.source_ref,
130
+ {
131
+ "kind" => "tool_use",
132
+ "role" => role.to_s,
133
+ "name" => part.name || "(unknown)",
134
+ "input" => part.text.to_s
135
+ },
136
+ TRUNCATABLE_KEYS.fetch(:tool_use),
137
+ warnings
138
+ )
139
+ end
140
+ end
141
+
142
+ def observed_lines(observed)
143
+ lines = []
144
+
145
+ observed.files.each do |item|
146
+ classifications = ["file"]
147
+ classifications.unshift("document") if EvidenceCollector.document_item?(item)
148
+ item.source_refs.each do |source_ref|
149
+ lines << [source_ref, observed_payload(item, classifications)]
150
+ end
151
+ end
152
+
153
+ lines
154
+ end
155
+
156
+ def observed_payload(item, classifications)
157
+ {
158
+ "kind" => "observed",
159
+ "classifications" => classifications.freeze,
160
+ "label" => item.label,
161
+ "action" => item.attributes.fetch(:action).to_s
162
+ }
163
+ end
164
+
165
+ def serialize_line(source_ref, payload)
166
+ source_ref_text = validated_source_ref_text(source_ref)
167
+ "#{source_ref_text} #{JSON.generate(payload)}"
168
+ end
169
+
170
+ def bounded_serialized_line(source_ref, payload, truncatable_keys, warnings)
171
+ fail_if_fixed_overhead_too_large(source_ref, payload, truncatable_keys)
172
+
173
+ line = serialize_line(source_ref, payload)
174
+ return line if line.bytesize <= MAX_BYTES
175
+
176
+ bounded_payload = payload.dup
177
+
178
+ truncatable_keys.each do |key|
179
+ next unless bounded_payload[key].is_a?(String)
180
+
181
+ bounded_value = maximal_fitting_value(source_ref, bounded_payload, key)
182
+ bounded_payload[key] = bounded_value
183
+
184
+ line = serialize_line(source_ref, bounded_payload)
185
+ return warning_line(source_ref, line, warnings) if line.bytesize <= MAX_BYTES
186
+ end
187
+
188
+ raise ArgumentError, "Evidence line for #{source_ref} cannot fit within #{MAX_BYTES} bytes"
189
+ end
190
+
191
+ def fail_if_fixed_overhead_too_large(source_ref, payload, truncatable_keys)
192
+ minimal_payload = payload.each_with_object({}) do |(key, value), normalized|
193
+ normalized[key] = truncatable_keys.include?(key) && value.is_a?(String) ? "" : value
194
+ end
195
+
196
+ return if serialize_line(source_ref, minimal_payload).bytesize <= MAX_BYTES
197
+
198
+ raise ArgumentError,
199
+ "Source ref and fixed payload overhead cannot fit within #{MAX_BYTES} bytes for #{source_ref}"
200
+ end
201
+
202
+ def maximal_fitting_value(source_ref, payload, key)
203
+ original = payload.fetch(key)
204
+ low = 0
205
+ high = original.bytesize
206
+ best = ""
207
+
208
+ while low <= high
209
+ middle = (low + high) / 2
210
+ candidate = utf8_prefix(original, middle)
211
+ payload[key] = candidate
212
+
213
+ if serialize_line(source_ref, payload).bytesize <= MAX_BYTES
214
+ best = candidate
215
+ low = middle + 1
216
+ else
217
+ high = middle - 1
218
+ end
219
+ end
220
+
221
+ payload[key] = original
222
+ best
223
+ end
224
+
225
+ def utf8_prefix(text, max_bytes)
226
+ String.new(text.byteslice(0, max_bytes), encoding: Encoding::UTF_8).scrub("")
227
+ end
228
+
229
+ def warning_line(source_ref, line, warnings)
230
+ warnings << "Truncated oversized evidence line for #{source_ref}"
231
+ line
232
+ end
233
+
234
+ def chunk_lines(lines)
235
+ chunks = []
236
+ current_chunk = String.new
237
+
238
+ lines.each do |line|
239
+ if current_chunk.empty?
240
+ current_chunk << line
241
+ next
242
+ end
243
+
244
+ projected_size = current_chunk.bytesize + 1 + line.bytesize
245
+
246
+ if projected_size <= MAX_BYTES
247
+ current_chunk << "\n" << line
248
+ else
249
+ chunks << current_chunk.freeze
250
+ current_chunk = String.new(line)
251
+ end
252
+ end
253
+
254
+ chunks << current_chunk.freeze unless current_chunk.empty?
255
+ chunks
256
+ end
257
+
258
+ def remember_ref(seen_refs, carried_refs, source_ref)
259
+ key = validated_source_ref_text(source_ref)
260
+ return if seen_refs.key?(key)
261
+
262
+ seen_refs[key] = true
263
+ carried_refs << source_ref
264
+ end
265
+
266
+ def validated_source_ref_text(source_ref)
267
+ self.class.safe_ref_text(source_ref)
268
+ end
269
+ end
270
+ end
271
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module SessionContext
5
+ module ImmutableValue
6
+ CYCLIC_ERROR = "cyclic arrays and hashes are not supported"
7
+ private_constant :CYCLIC_ERROR
8
+
9
+ class Copier
10
+ def initialize
11
+ @copies = {}.compare_by_identity
12
+ @active = {}.compare_by_identity
13
+ end
14
+
15
+ def copy(value)
16
+ case value
17
+ when String
18
+ String.new(value).freeze
19
+ when Array
20
+ copy_array(value)
21
+ when Hash
22
+ copy_hash(value)
23
+ else
24
+ value
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ def copy_array(array)
31
+ detect_cycle!(array)
32
+ return @copies.fetch(array) if @copies.key?(array)
33
+
34
+ duplicate = []
35
+ @copies[array] = duplicate
36
+ @active[array] = true
37
+ array.each { |entry| duplicate << copy(entry) }
38
+ duplicate.freeze
39
+ ensure
40
+ @active.delete(array)
41
+ end
42
+
43
+ def copy_hash(hash)
44
+ detect_cycle!(hash)
45
+ return @copies.fetch(hash) if @copies.key?(hash)
46
+
47
+ duplicate = {}
48
+ @copies[hash] = duplicate
49
+ @active[hash] = true
50
+ hash.each do |key, value|
51
+ duplicate[copy(key)] = copy(value)
52
+ end
53
+ duplicate.freeze
54
+ ensure
55
+ @active.delete(hash)
56
+ end
57
+
58
+ def detect_cycle!(value)
59
+ raise ArgumentError, CYCLIC_ERROR if @active[value]
60
+ end
61
+ end
62
+ private_constant :Copier
63
+
64
+ module_function
65
+
66
+ def copy(value)
67
+ Copier.new.copy(value)
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module SessionContext
5
+ InjectedContext = Data.define(:kind, :bytes, :occurrences, :source_refs, :text) do
6
+ def initialize(kind:, bytes:, occurrences:, source_refs:, text: nil)
7
+ normalized_kind = normalize_symbol(kind, :kind)
8
+ normalized_bytes = normalize_nonnegative_integer(bytes, :bytes)
9
+ normalized_occurrences = normalize_positive_integer(occurrences, :occurrences)
10
+ normalized_source_refs = normalize_source_refs(source_refs)
11
+ normalized_text = normalize_optional_string(text, :text)
12
+ validate_consistency!(
13
+ bytes: normalized_bytes,
14
+ occurrences: normalized_occurrences,
15
+ source_refs: normalized_source_refs,
16
+ text: normalized_text
17
+ )
18
+
19
+ super(
20
+ kind: normalized_kind,
21
+ bytes: normalized_bytes,
22
+ occurrences: normalized_occurrences,
23
+ source_refs: normalized_source_refs,
24
+ text: normalized_text
25
+ )
26
+ end
27
+
28
+ private
29
+
30
+ def normalize_symbol(value, name)
31
+ return value if value.is_a?(Symbol)
32
+ return value.to_sym if value.respond_to?(:to_sym)
33
+
34
+ raise TypeError, "#{name} must be symbolizable"
35
+ end
36
+
37
+ def normalize_nonnegative_integer(value, name)
38
+ integer = normalize_integer(value, name)
39
+ raise ArgumentError, "#{name} must be greater than or equal to 0" if integer.negative?
40
+
41
+ integer
42
+ end
43
+
44
+ def normalize_positive_integer(value, name)
45
+ integer = normalize_integer(value, name)
46
+ raise ArgumentError, "#{name} must be greater than or equal to 1" if integer < 1
47
+
48
+ integer
49
+ end
50
+
51
+ def normalize_integer(value, name)
52
+ integer =
53
+ if value.is_a?(Integer)
54
+ value
55
+ elsif value.respond_to?(:to_int)
56
+ value.to_int
57
+ elsif value.is_a?(String)
58
+ Integer(value, exception: false)
59
+ end
60
+
61
+ raise TypeError, "#{name} must be an Integer or integer-like value" if integer.nil?
62
+
63
+ integer
64
+ end
65
+
66
+ def normalize_source_refs(value)
67
+ Array(value).map do |source_ref|
68
+ unless source_ref.is_a?(SourceRef)
69
+ raise TypeError,
70
+ "source_refs must contain only Agent::SessionContext::SourceRef values"
71
+ end
72
+
73
+ source_ref
74
+ end.freeze
75
+ end
76
+
77
+ def normalize_optional_string(value, name)
78
+ return if value.nil?
79
+ raise TypeError, "#{name} must be a String" unless value.respond_to?(:to_str)
80
+
81
+ String.new(value.to_str).freeze
82
+ end
83
+
84
+ def validate_consistency!(bytes:, occurrences:, source_refs:, text:)
85
+ raise ArgumentError, "occurrences must equal source_refs length" unless occurrences == source_refs.length
86
+ return if text.nil? || bytes == text.bytesize
87
+
88
+ raise ArgumentError, "bytes must equal text bytesize"
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module SessionContext
5
+ class InjectedContextCollector
6
+ def call(transcript, include_text: false)
7
+ validate_include_text!(include_text)
8
+ groups = {}
9
+
10
+ transcript.entries.each do |entry|
11
+ entry.parts.each do |part|
12
+ next unless part.injected
13
+
14
+ text = injected_text(part)
15
+ group = groups[text] ||= {
16
+ kind: Transcript.injection_kind(transcript.session.agent, text) || :provider_meta,
17
+ text:,
18
+ source_refs: []
19
+ }
20
+ group.fetch(:source_refs) << part.source_ref
21
+ end
22
+ end
23
+
24
+ groups.values.map do |group|
25
+ source_refs = group.fetch(:source_refs)
26
+ text = group.fetch(:text)
27
+ InjectedContext.new(
28
+ kind: group.fetch(:kind),
29
+ bytes: text.bytesize,
30
+ occurrences: source_refs.length,
31
+ source_refs:,
32
+ text: include_text ? text : nil
33
+ )
34
+ end.freeze
35
+ end
36
+
37
+ private
38
+
39
+ def validate_include_text!(value)
40
+ return if [true, false].include?(value)
41
+
42
+ raise ArgumentError, "include_text must be true or false"
43
+ end
44
+
45
+ def injected_text(part)
46
+ text = part.text
47
+ raise TypeError, "injected parts must contain text" unless text.respond_to?(:to_str)
48
+
49
+ text.to_str
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module SessionContext
5
+ Item = Data.define(:kind, :label, :detail, :evidence, :source_refs, :attributes) do
6
+ EVIDENCE_VALUES = %i[observed explicit inferred].freeze
7
+
8
+ def initialize(kind:, label:, evidence:, source_refs:, detail: nil, attributes: {})
9
+ unless EVIDENCE_VALUES.include?(evidence)
10
+ raise ArgumentError,
11
+ "evidence must be one of: #{EVIDENCE_VALUES.join(", ")}"
12
+ end
13
+
14
+ super(
15
+ kind: kind.to_sym,
16
+ label: normalize_string(label, :label),
17
+ detail: normalize_optional_string(detail, :detail),
18
+ evidence: evidence,
19
+ source_refs: ImmutableValue.copy(Array(source_refs)),
20
+ attributes: normalize_attributes(attributes)
21
+ )
22
+ end
23
+
24
+ private
25
+
26
+ def normalize_attributes(attributes)
27
+ raise TypeError, "attributes must be a Hash" unless attributes.is_a?(Hash)
28
+
29
+ attributes.each_with_object({}) do |(key, value), normalized|
30
+ normalized[normalize_attribute_key(key)] = ImmutableValue.copy(value)
31
+ end.freeze
32
+ end
33
+
34
+ def normalize_attribute_key(key)
35
+ return key if key.is_a?(Symbol)
36
+ return key.to_sym if key.respond_to?(:to_sym)
37
+
38
+ raise TypeError, "attribute keys must be symbolizable"
39
+ end
40
+
41
+ def normalize_string(value, name)
42
+ raise TypeError, "#{name} must be a String" unless value.respond_to?(:to_str)
43
+
44
+ String.new(value.to_str).freeze
45
+ end
46
+
47
+ def normalize_optional_string(value, name)
48
+ return if value.nil?
49
+
50
+ normalize_string(value, name)
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module SessionContext
5
+ Prompt = Data.define(:index, :at, :text, :source_refs) do
6
+ def initialize(index:, at:, text:, source_refs:)
7
+ super(
8
+ index: normalize_positive_index(index),
9
+ at: at,
10
+ text: normalize_string(text, :text),
11
+ source_refs: ImmutableValue.copy(Array(source_refs))
12
+ )
13
+ end
14
+
15
+ private
16
+
17
+ def normalize_positive_index(value)
18
+ index =
19
+ if value.is_a?(Integer)
20
+ value
21
+ elsif value.respond_to?(:to_int)
22
+ value.to_int
23
+ elsif value.is_a?(String)
24
+ Integer(value, exception: false)
25
+ end
26
+
27
+ raise TypeError, "index must be an Integer or integer-like value" if index.nil?
28
+ raise ArgumentError, "index must be greater than or equal to 1" if index < 1
29
+
30
+ index
31
+ end
32
+
33
+ def normalize_string(value, name)
34
+ raise TypeError, "#{name} must be a String" unless value.respond_to?(:to_str)
35
+
36
+ String.new(value.to_str).freeze
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module SessionContext
5
+ class PromptExtractor
6
+ def call(transcript)
7
+ prompt_index = 0
8
+
9
+ prompts = transcript.entries.each_with_object([]) do |entry, collected|
10
+ next unless entry.role == :user
11
+
12
+ contributing_parts = entry.parts.select { |part| part.type == :text && !part.injected }
13
+ next if contributing_parts.empty?
14
+
15
+ text = contributing_parts.map(&:text).join
16
+ next if text.empty?
17
+
18
+ prompt_index += 1
19
+ collected << Prompt.new(
20
+ index: prompt_index,
21
+ at: entry.at,
22
+ text: text,
23
+ source_refs: contributing_parts.map(&:source_ref)
24
+ )
25
+ end
26
+
27
+ prompts.freeze
28
+ end
29
+ end
30
+ end
31
+ end