agent_session_context 1.0.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 (43) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +43 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +298 -0
  5. data/exe/agent-session-context +6 -0
  6. data/lib/agent/session_context/builder.rb +150 -0
  7. data/lib/agent/session_context/cli/options.rb +214 -0
  8. data/lib/agent/session_context/cli.rb +254 -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/loop.rb +122 -0
  18. data/lib/agent/session_context/loop_view.rb +248 -0
  19. data/lib/agent/session_context/prompt.rb +40 -0
  20. data/lib/agent/session_context/prompt_extractor.rb +31 -0
  21. data/lib/agent/session_context/renderers/human_display.rb +113 -0
  22. data/lib/agent/session_context/renderers/json.rb +21 -0
  23. data/lib/agent/session_context/renderers/json_lines.rb +25 -0
  24. data/lib/agent/session_context/renderers/markdown.rb +130 -0
  25. data/lib/agent/session_context/renderers/serializer.rb +124 -0
  26. data/lib/agent/session_context/renderers/text.rb +128 -0
  27. data/lib/agent/session_context/semantic_categories.rb +89 -0
  28. data/lib/agent/session_context/semantic_pipeline.rb +151 -0
  29. data/lib/agent/session_context/semantic_schema.rb +75 -0
  30. data/lib/agent/session_context/session_resolver.rb +147 -0
  31. data/lib/agent/session_context/snapshot.rb +128 -0
  32. data/lib/agent/session_context/source_ref.rb +46 -0
  33. data/lib/agent/session_context/subprocess_runner.rb +362 -0
  34. data/lib/agent/session_context/summarizers/claude.rb +126 -0
  35. data/lib/agent/session_context/summarizers/codex.rb +132 -0
  36. data/lib/agent/session_context/summarizers/command_execution_policy.rb +134 -0
  37. data/lib/agent/session_context/summarizers.rb +35 -0
  38. data/lib/agent/session_context/summary_parser.rb +219 -0
  39. data/lib/agent/session_context/tool_call.rb +21 -0
  40. data/lib/agent/session_context/transcript.rb +236 -0
  41. data/lib/agent/session_context/version.rb +7 -0
  42. data/lib/agent/session_context.rb +60 -0
  43. metadata +115 -0
@@ -0,0 +1,227 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module SessionContext
5
+ class EvidenceCollector
6
+ DOCUMENT_BASENAMES = %w[agents.md claude.md readme readme.md].freeze
7
+ DOCUMENT_EXTENSIONS = %w[.md .markdown .txt .pdf .doc .docx .odt .rtf].freeze
8
+ PATH_KEYS = %w[path file_path filename source destination target].freeze
9
+ # rubocop:disable-next Layout/LineLength -- splitting this regexp would obscure its URL grammar
10
+ SCHEMELESS_URL_PATTERN = %r{(?<![A-Za-z0-9_./-])(?:[A-Za-z0-9-]+\.)+[A-Za-z]{2,}(?::\d+)?(?:/[^\s"'<>?#]+)+(?:\?[^\s"'<>#]*)?(?:#[^\s"'<>]*)?}i
11
+ SCHEME_URL_PATTERN = %r{[A-Za-z][A-Za-z0-9+\-.]*://[^\s"'<>]+}
12
+ TOOL_ACTIONS = {
13
+ "Read" => :read,
14
+ "read_file" => :read,
15
+ "view_image" => :read,
16
+ "Write" => :modified,
17
+ "Edit" => :modified,
18
+ "MultiEdit" => :modified,
19
+ "apply_patch" => :modified,
20
+ "move_file" => :modified
21
+ }.freeze
22
+ PATH_SCAN_PATTERN = %r{(?:\.\.?/|/)?[A-Za-z0-9._-]+(?:/[A-Za-z0-9._-]+)+}
23
+
24
+ Result = Data.define(:files, :tool_activity) do
25
+ def documents
26
+ files.select { |item| EvidenceCollector.document_item?(item) }.freeze
27
+ end
28
+ end
29
+
30
+ class << self
31
+ def document_item?(item)
32
+ item.kind == :file && document_path?(item.label)
33
+ end
34
+
35
+ def document_path?(path)
36
+ basename = File.basename(path).downcase
37
+ extension = File.extname(path).downcase
38
+
39
+ DOCUMENT_BASENAMES.include?(basename) || DOCUMENT_EXTENSIONS.include?(extension)
40
+ end
41
+ end
42
+
43
+ def call(transcript)
44
+ tool_activity = []
45
+ files = {}
46
+
47
+ transcript.entries.each do |entry|
48
+ entry.parts.each do |part|
49
+ next unless part.type == :tool_use
50
+
51
+ parsed_input = parse_input(part.text)
52
+ tool_activity << build_tool_item(part, parsed_input)
53
+
54
+ extract_paths(part.text, parsed_input).each do |path|
55
+ add_item(files, kind: :file, path: path, action: action_for(part.name), source_ref: part.source_ref)
56
+ end
57
+ end
58
+ end
59
+
60
+ file_items = files.values.freeze
61
+
62
+ Result.new(files: file_items, tool_activity: tool_activity.freeze)
63
+ end
64
+
65
+ private
66
+
67
+ def build_tool_item(part, parsed_input)
68
+ Item.new(
69
+ kind: :tool,
70
+ label: part.name || "(unknown)",
71
+ evidence: :observed,
72
+ source_refs: [part.source_ref],
73
+ attributes: tool_attributes(part.call_id, parsed_input)
74
+ )
75
+ end
76
+
77
+ def tool_attributes(call_id, parsed_input)
78
+ attributes = { call_id: call_id }
79
+ return attributes unless parsed_input.is_a?(Hash)
80
+
81
+ attributes[:input_keys] = parsed_input.keys.map { |key| String.new(key.to_s).freeze }.sort.freeze
82
+ attributes
83
+ end
84
+
85
+ def parse_input(raw_input)
86
+ return unless raw_input.is_a?(String)
87
+
88
+ JSON.parse(raw_input)
89
+ rescue JSON::ParserError, TypeError
90
+ nil
91
+ end
92
+
93
+ def extract_paths(raw_input, parsed_input)
94
+ keyed_paths = extract_keyed_paths(parsed_input)
95
+ return keyed_paths unless keyed_paths.empty?
96
+
97
+ scan_plain_text(raw_input)
98
+ end
99
+
100
+ def extract_keyed_paths(parsed_input)
101
+ return [] unless parsed_input.is_a?(Array) || parsed_input.is_a?(Hash)
102
+
103
+ seen = {}
104
+ collected = []
105
+ collect_keyed_paths(parsed_input, seen, collected, under_path_key: false)
106
+ collected.freeze
107
+ end
108
+
109
+ def collect_keyed_paths(value, seen, collected, under_path_key:)
110
+ case value
111
+ when Hash
112
+ value.each do |key, child|
113
+ collect_keyed_paths(child, seen, collected, under_path_key: under_path_key || PATH_KEYS.include?(key.to_s))
114
+ end
115
+ when Array
116
+ value.each do |child|
117
+ collect_keyed_paths(child, seen, collected, under_path_key: under_path_key)
118
+ end
119
+ when String
120
+ append_unique_path(collected, seen, value) if valid_keyed_path?(value) && under_path_key
121
+ end
122
+ end
123
+
124
+ def scan_plain_text(raw_input)
125
+ return [].freeze unless raw_input.is_a?(String)
126
+
127
+ spans = url_spans(raw_input)
128
+ span_index = 0
129
+ seen = {}
130
+
131
+ raw_input.to_enum(:scan, PATH_SCAN_PATTERN).each_with_object([]) do |_ignored, collected|
132
+ match = Regexp.last_match
133
+ span_index = advance_span_index(spans, span_index, match.begin(0))
134
+ next if overlap?(spans, span_index, match.begin(0), match.end(0))
135
+
136
+ append_unique_path(collected, seen, match[0])
137
+ end.freeze
138
+ end
139
+
140
+ def valid_keyed_path?(value)
141
+ !value.empty? && !value.include?("\0") && !url_like?(value)
142
+ end
143
+
144
+ def url_like?(value)
145
+ value.match?(/\A#{SCHEME_URL_PATTERN}\z/o) || value.match?(/\A#{SCHEMELESS_URL_PATTERN}\z/o)
146
+ end
147
+
148
+ def url_spans(raw_input)
149
+ spans = []
150
+
151
+ [SCHEME_URL_PATTERN, SCHEMELESS_URL_PATTERN].each do |pattern|
152
+ raw_input.to_enum(:scan, pattern).each do
153
+ match = Regexp.last_match
154
+ spans << [match.begin(0), match.end(0)]
155
+ end
156
+ end
157
+
158
+ merge_spans(spans)
159
+ end
160
+
161
+ def merge_spans(spans)
162
+ return [].freeze if spans.empty?
163
+
164
+ sorted_spans = spans.sort_by { |start_index, end_index| [start_index, end_index] }
165
+ merged = [sorted_spans.first.dup]
166
+
167
+ sorted_spans.drop(1).each do |start_index, end_index|
168
+ current_span = merged.last
169
+
170
+ if start_index <= current_span[1]
171
+ current_span[1] = [current_span[1], end_index].max
172
+ else
173
+ merged << [start_index, end_index]
174
+ end
175
+ end
176
+
177
+ merged.freeze
178
+ end
179
+
180
+ def advance_span_index(spans, span_index, match_start)
181
+ span_index += 1 while span_index < spans.length && spans[span_index][1] <= match_start
182
+ span_index
183
+ end
184
+
185
+ def overlap?(spans, span_index, _match_start, match_end)
186
+ span_index < spans.length && spans[span_index][0] < match_end
187
+ end
188
+
189
+ def append_unique_path(collected, seen, path)
190
+ return if seen.key?(path)
191
+
192
+ seen[path] = true
193
+ collected << path
194
+ end
195
+
196
+ def action_for(tool_name)
197
+ TOOL_ACTIONS.fetch(tool_name.to_s, :referenced)
198
+ end
199
+
200
+ def add_item(collection, kind:, path:, action:, source_ref:)
201
+ key = [kind, path, action]
202
+ existing = collection[key]
203
+
204
+ if existing
205
+ return if existing.source_refs.include?(source_ref)
206
+
207
+ collection[key] = Item.new(
208
+ kind: kind,
209
+ label: path,
210
+ evidence: :observed,
211
+ source_refs: existing.source_refs + [source_ref],
212
+ attributes: existing.attributes
213
+ )
214
+ return
215
+ end
216
+
217
+ collection[key] = Item.new(
218
+ kind: kind,
219
+ label: path,
220
+ evidence: :observed,
221
+ source_refs: [source_ref],
222
+ attributes: { action: action }
223
+ )
224
+ end
225
+ end
226
+ end
227
+ end
@@ -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