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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +43 -0
- data/LICENSE.txt +21 -0
- data/README.md +298 -0
- data/exe/agent-session-context +6 -0
- data/lib/agent/session_context/builder.rb +150 -0
- data/lib/agent/session_context/cli/options.rb +214 -0
- data/lib/agent/session_context/cli.rb +254 -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/loop.rb +122 -0
- data/lib/agent/session_context/loop_view.rb +248 -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 +21 -0
- data/lib/agent/session_context/renderers/json_lines.rb +25 -0
- data/lib/agent/session_context/renderers/markdown.rb +130 -0
- data/lib/agent/session_context/renderers/serializer.rb +124 -0
- data/lib/agent/session_context/renderers/text.rb +128 -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/tool_call.rb +21 -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 +60 -0
- 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
|