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,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
|