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,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agent
|
|
4
|
+
module SessionContext
|
|
5
|
+
module Summarizers
|
|
6
|
+
module CommandExecutionPolicy
|
|
7
|
+
COMMON_ENV_KEYS = %w[
|
|
8
|
+
ALL_PROXY
|
|
9
|
+
HOME
|
|
10
|
+
HTTP_PROXY
|
|
11
|
+
HTTPS_PROXY
|
|
12
|
+
LANG
|
|
13
|
+
NO_PROXY
|
|
14
|
+
PATH
|
|
15
|
+
SSL_CERT_DIR
|
|
16
|
+
SSL_CERT_FILE
|
|
17
|
+
all_proxy
|
|
18
|
+
http_proxy
|
|
19
|
+
https_proxy
|
|
20
|
+
no_proxy
|
|
21
|
+
].freeze
|
|
22
|
+
private_constant :COMMON_ENV_KEYS
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def initialize_command_execution_policy(runner:, timeout_seconds:, max_output_bytes:, provider_env_keys:)
|
|
27
|
+
@timeout_seconds = timeout_seconds
|
|
28
|
+
@max_output_bytes = max_output_bytes
|
|
29
|
+
@provider_env_keys = provider_env_keys.dup.freeze
|
|
30
|
+
@runner = runner || SubprocessRunner.new(timeout_seconds:, max_output_bytes:)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def run_command!(prompt:, argv:)
|
|
34
|
+
response = @runner.call(env: child_env, argv:, stdin_data: prompt)
|
|
35
|
+
validate_runner_response!(response)
|
|
36
|
+
rescue Errno::ENOENT => e
|
|
37
|
+
raise SummarizerUnavailable, "#{name} summarizer is unavailable: #{e.message}"
|
|
38
|
+
rescue SubprocessRunner::TimeoutError
|
|
39
|
+
raise SummarizerFailed, "#{name} summarizer exceeded its #{@timeout_seconds}-second timeout"
|
|
40
|
+
rescue SubprocessRunner::OutputLimitError => e
|
|
41
|
+
raise SummarizerFailed,
|
|
42
|
+
"#{name} summarizer #{translated_stream_label(e.stream)} exceeded #{@max_output_bytes} bytes"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def validate_runner_response!(response)
|
|
46
|
+
unless response.is_a?(Array) && response.length == 3
|
|
47
|
+
raise SummarizerFailed, "#{name} summarizer runner must return [stdout, stderr, status]"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
stdout, stderr, status = response
|
|
51
|
+
|
|
52
|
+
unless status.respond_to?(:success?)
|
|
53
|
+
raise SummarizerFailed, "#{name} summarizer runner must return a status with #success?"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
unless stdout.nil? || stdout.is_a?(String)
|
|
57
|
+
raise SummarizerFailed, "#{name} summarizer runner must return stdout as a String"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
unless stderr.nil? || stderr.is_a?(String)
|
|
61
|
+
raise SummarizerFailed, "#{name} summarizer runner must return stderr as a String"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
stdout = stdout.to_s
|
|
65
|
+
stderr = stderr.to_s
|
|
66
|
+
enforce_output_bounds!("stdout", stdout, status:)
|
|
67
|
+
enforce_output_bounds!("stderr", stderr, status:)
|
|
68
|
+
|
|
69
|
+
[stdout, stderr, status]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def failure_message(summary, stdout: nil, stderr: nil, output: nil, status: nil)
|
|
73
|
+
fragments = [summary]
|
|
74
|
+
append_stream_metadata(fragments, "stdout", stdout)
|
|
75
|
+
append_stream_metadata(fragments, "stderr", stderr)
|
|
76
|
+
append_stream_metadata(fragments, "output", output)
|
|
77
|
+
append_status_metadata(fragments, status)
|
|
78
|
+
fragments.join(". ")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def child_env
|
|
82
|
+
ENV.each_with_object({}) do |(key, value), filtered|
|
|
83
|
+
filtered[key] = value if allowed_env_key?(key)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def allowed_env_key?(key)
|
|
88
|
+
COMMON_ENV_KEYS.include?(key) || @provider_env_keys.include?(key) || key.start_with?("LC_")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def enforce_output_bounds!(label, value, status:)
|
|
92
|
+
return if value.bytesize <= @max_output_bytes
|
|
93
|
+
|
|
94
|
+
raise SummarizerFailed, failure_message(
|
|
95
|
+
"#{name} summarizer #{label} exceeded #{@max_output_bytes} bytes",
|
|
96
|
+
status:,
|
|
97
|
+
stdout: label == "stdout" ? value : nil,
|
|
98
|
+
stderr: label == "stderr" ? value : nil
|
|
99
|
+
)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def append_stream_metadata(fragments, label, value)
|
|
103
|
+
state =
|
|
104
|
+
if value.nil?
|
|
105
|
+
"absent"
|
|
106
|
+
elsif value.empty?
|
|
107
|
+
"empty"
|
|
108
|
+
else
|
|
109
|
+
"present"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
bytesize = value.nil? ? 0 : value.bytesize
|
|
113
|
+
fragments << "#{label}=#{state}(#{bytesize} bytes)"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def append_status_metadata(fragments, status)
|
|
117
|
+
return unless status.respond_to?(:exitstatus)
|
|
118
|
+
|
|
119
|
+
exitstatus = status.exitstatus
|
|
120
|
+
return if exitstatus.nil?
|
|
121
|
+
|
|
122
|
+
fragments << "exit_status=#{exitstatus}"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def translated_stream_label(stream)
|
|
126
|
+
return "stdout" if stream.equal?(:stdout)
|
|
127
|
+
return "stderr" if stream.equal?(:stderr)
|
|
128
|
+
|
|
129
|
+
"output"
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agent
|
|
4
|
+
module SessionContext
|
|
5
|
+
module Summarizers
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def for(name, timeout_seconds: Config::DEFAULT_TIMEOUT_SECONDS)
|
|
9
|
+
case normalize_name(name)
|
|
10
|
+
when :codex then Codex.new(timeout_seconds:)
|
|
11
|
+
when :claude then Claude.new(timeout_seconds:)
|
|
12
|
+
else
|
|
13
|
+
raise unsupported_summarizer(name)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def normalize_name(name)
|
|
18
|
+
return name if name.is_a?(Symbol)
|
|
19
|
+
raise unsupported_summarizer(name) unless name.respond_to?(:to_sym)
|
|
20
|
+
|
|
21
|
+
normalized = name.to_sym
|
|
22
|
+
raise unsupported_summarizer(name) unless normalized.is_a?(Symbol)
|
|
23
|
+
|
|
24
|
+
normalized
|
|
25
|
+
rescue NoMethodError, TypeError, ArgumentError
|
|
26
|
+
raise unsupported_summarizer(name)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def unsupported_summarizer(name)
|
|
30
|
+
UnsupportedAgent.new("unsupported summarizer #{name.inspect}; use claude or codex")
|
|
31
|
+
end
|
|
32
|
+
private_class_method :normalize_name, :unsupported_summarizer
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agent
|
|
4
|
+
module SessionContext
|
|
5
|
+
class SummaryParser
|
|
6
|
+
Result = Data.define(:items, :warnings)
|
|
7
|
+
|
|
8
|
+
ALLOWED_EVIDENCE = %w[explicit inferred].freeze
|
|
9
|
+
TEXT_KEYS = %w[text evidence source_refs].freeze
|
|
10
|
+
TERM_KEYS = %w[term definition evidence source_refs].freeze
|
|
11
|
+
ITEM_KEYS = {
|
|
12
|
+
text: TEXT_KEYS,
|
|
13
|
+
term: TERM_KEYS
|
|
14
|
+
}.freeze
|
|
15
|
+
REQUIRED_CATEGORIES = SemanticCategories.external_keys
|
|
16
|
+
|
|
17
|
+
def call(json, allowed_refs:)
|
|
18
|
+
payload = parse_payload(json)
|
|
19
|
+
allowed_ref_index = build_allowed_ref_index(allowed_refs)
|
|
20
|
+
validate_known_categories!(payload)
|
|
21
|
+
warnings = []
|
|
22
|
+
merged_items = {}
|
|
23
|
+
ordered_items = []
|
|
24
|
+
|
|
25
|
+
payload.each do |category, value|
|
|
26
|
+
definition = SemanticCategories.lookup(category)
|
|
27
|
+
unless definition
|
|
28
|
+
warnings << "Dropped unknown category #{category.inspect}"
|
|
29
|
+
next
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
value.each_with_index do |raw_item, item_index|
|
|
33
|
+
item = parse_item(definition, raw_item, item_index, allowed_ref_index, warnings)
|
|
34
|
+
next unless item
|
|
35
|
+
|
|
36
|
+
key = [item.kind, item.label, item.detail]
|
|
37
|
+
existing = merged_items[key]
|
|
38
|
+
|
|
39
|
+
if existing
|
|
40
|
+
merged_items[key] = merge_item_refs(existing, item)
|
|
41
|
+
else
|
|
42
|
+
merged_items[key] = item
|
|
43
|
+
ordered_items << key
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
Result.new(
|
|
49
|
+
items: ordered_items.map { |key| merged_items.fetch(key) }.freeze,
|
|
50
|
+
warnings: warnings.map { |warning| String.new(warning).freeze }.freeze
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def parse_payload(json)
|
|
57
|
+
payload = JSON.parse(json)
|
|
58
|
+
rescue JSON::ParserError
|
|
59
|
+
raise InvalidSummary, "Invalid JSON summary. Provide a JSON top-level object with the supported categories."
|
|
60
|
+
else
|
|
61
|
+
raise InvalidSummary, "Summary must be a JSON top-level object." unless payload.is_a?(Hash)
|
|
62
|
+
|
|
63
|
+
payload
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def validate_known_categories!(payload)
|
|
67
|
+
REQUIRED_CATEGORIES.each do |category|
|
|
68
|
+
next unless payload.key?(category)
|
|
69
|
+
|
|
70
|
+
raise InvalidSummary, "#{category} must be an array" unless payload[category].is_a?(Array)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
missing_categories = REQUIRED_CATEGORIES - payload.keys
|
|
74
|
+
return if missing_categories.empty?
|
|
75
|
+
|
|
76
|
+
raise InvalidSummary,
|
|
77
|
+
"Summary is missing required categories: #{missing_categories.join(", ")}"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def build_allowed_ref_index(allowed_refs)
|
|
81
|
+
normalize_allowed_refs(allowed_refs).to_h do |source_ref|
|
|
82
|
+
[source_ref.to_s, source_ref]
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def normalize_allowed_refs(allowed_refs)
|
|
87
|
+
refs =
|
|
88
|
+
if allowed_refs.is_a?(Array)
|
|
89
|
+
allowed_refs
|
|
90
|
+
elsif allowed_refs.respond_to?(:to_a) && !allowed_refs.is_a?(String)
|
|
91
|
+
allowed_refs.to_a
|
|
92
|
+
elsif allowed_refs.respond_to?(:each) && !allowed_refs.is_a?(String)
|
|
93
|
+
allowed_refs.each_with_object([]) { |source_ref, collected| collected << source_ref }
|
|
94
|
+
else
|
|
95
|
+
raise TypeError, "allowed_refs must be an enumerable of Agent::SessionContext::SourceRef objects"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
refs.each do |source_ref|
|
|
99
|
+
unless source_ref.is_a?(SourceRef)
|
|
100
|
+
raise TypeError,
|
|
101
|
+
"allowed_refs must contain only Agent::SessionContext::SourceRef objects"
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
refs
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def parse_item(category, raw_item, item_index, allowed_ref_index, warnings)
|
|
109
|
+
category_name = category.external_key
|
|
110
|
+
|
|
111
|
+
unless raw_item.is_a?(Hash)
|
|
112
|
+
warnings << "Dropped #{category_name}[#{item_index}] because items must be objects"
|
|
113
|
+
return
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
expected_keys = ITEM_KEYS.fetch(category.item_shape)
|
|
117
|
+
raw_keys = raw_item.keys.sort
|
|
118
|
+
|
|
119
|
+
unless raw_keys == expected_keys.sort
|
|
120
|
+
warnings << key_mismatch_warning(category_name, item_index, raw_keys, expected_keys)
|
|
121
|
+
return
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
evidence = raw_item["evidence"]
|
|
125
|
+
unless ALLOWED_EVIDENCE.include?(evidence)
|
|
126
|
+
warnings << "Dropped #{category_name}[#{item_index}] because evidence must be explicit or inferred"
|
|
127
|
+
return
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
refs = resolve_source_refs(category_name, item_index, raw_item["source_refs"], allowed_ref_index, warnings)
|
|
131
|
+
return unless refs
|
|
132
|
+
|
|
133
|
+
case category.item_shape
|
|
134
|
+
when :term
|
|
135
|
+
term = raw_item["term"]
|
|
136
|
+
definition = raw_item["definition"]
|
|
137
|
+
|
|
138
|
+
unless term.is_a?(String)
|
|
139
|
+
warnings << "Dropped #{category_name}[#{item_index}] because term must be a string"
|
|
140
|
+
return
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
unless definition.is_a?(String)
|
|
144
|
+
warnings << "Dropped #{category_name}[#{item_index}] because definition must be a string"
|
|
145
|
+
return
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
Item.new(
|
|
149
|
+
kind: category.internal_kind,
|
|
150
|
+
label: term,
|
|
151
|
+
detail: definition,
|
|
152
|
+
evidence: evidence.to_sym,
|
|
153
|
+
source_refs: refs
|
|
154
|
+
)
|
|
155
|
+
else
|
|
156
|
+
text = raw_item["text"]
|
|
157
|
+
unless text.is_a?(String)
|
|
158
|
+
warnings << "Dropped #{category_name}[#{item_index}] because text must be a string"
|
|
159
|
+
return
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
Item.new(
|
|
163
|
+
kind: category.internal_kind,
|
|
164
|
+
label: text,
|
|
165
|
+
detail: nil,
|
|
166
|
+
evidence: evidence.to_sym,
|
|
167
|
+
source_refs: refs
|
|
168
|
+
)
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def resolve_source_refs(category, item_index, raw_refs, allowed_ref_index, warnings)
|
|
173
|
+
unless raw_refs.is_a?(Array) && !raw_refs.empty? && raw_refs.all?(String)
|
|
174
|
+
warnings << "Dropped #{category}[#{item_index}] because source_refs must be a non-empty array of strings"
|
|
175
|
+
return
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
resolved = raw_refs.map { |value| allowed_ref_index[value] }
|
|
179
|
+
if resolved.any?(&:nil?)
|
|
180
|
+
warnings << "Dropped #{category}[#{item_index}] because it referenced an unknown source ref"
|
|
181
|
+
return
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
resolved.uniq.freeze
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def merge_item_refs(existing, item)
|
|
188
|
+
Item.new(
|
|
189
|
+
kind: existing.kind,
|
|
190
|
+
label: existing.label,
|
|
191
|
+
detail: existing.detail,
|
|
192
|
+
evidence: merged_evidence(existing.evidence, item.evidence),
|
|
193
|
+
source_refs: (existing.source_refs + item.source_refs).uniq,
|
|
194
|
+
attributes: existing.attributes
|
|
195
|
+
)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def merged_evidence(left, right)
|
|
199
|
+
return :explicit if left == :explicit || right == :explicit
|
|
200
|
+
|
|
201
|
+
:inferred
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def key_mismatch_warning(category, item_index, raw_keys, expected_keys)
|
|
205
|
+
missing_keys = expected_keys - raw_keys
|
|
206
|
+
extra_keys = raw_keys - expected_keys
|
|
207
|
+
details = []
|
|
208
|
+
details << "missing #{missing_keys.join(", ")}" unless missing_keys.empty?
|
|
209
|
+
details << "extra keys #{extra_keys.join(", ")}" unless extra_keys.empty?
|
|
210
|
+
details << "additional properties are not allowed" unless extra_keys.empty?
|
|
211
|
+
detail_text = details.join("; ")
|
|
212
|
+
detail_suffix = " (#{detail_text})" unless detail_text.empty?
|
|
213
|
+
|
|
214
|
+
"Dropped #{category}[#{item_index}] because item keys must exactly match " \
|
|
215
|
+
"#{expected_keys.join(", ")}#{detail_suffix}"
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agent
|
|
4
|
+
module SessionContext
|
|
5
|
+
TranscriptPart = Data.define(:index, :type, :text, :name, :call_id, :injected, :source_ref) do
|
|
6
|
+
def initialize(index:, type:, injected:, source_ref:, text: nil, name: nil, call_id: nil)
|
|
7
|
+
super(
|
|
8
|
+
index: normalize_positive_index(index, :index),
|
|
9
|
+
type: normalize_symbol(type, :type),
|
|
10
|
+
text: normalize_optional_string(text, :text),
|
|
11
|
+
name: normalize_optional_string(name, :name),
|
|
12
|
+
call_id: normalize_optional_string(call_id, :call_id),
|
|
13
|
+
injected: normalize_boolean(injected),
|
|
14
|
+
source_ref: normalize_source_ref(source_ref)
|
|
15
|
+
)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def normalize_positive_index(value, name)
|
|
21
|
+
index =
|
|
22
|
+
if value.is_a?(Integer)
|
|
23
|
+
value
|
|
24
|
+
elsif value.respond_to?(:to_int)
|
|
25
|
+
value.to_int
|
|
26
|
+
elsif value.is_a?(String)
|
|
27
|
+
Integer(value, exception: false)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
raise TypeError, "#{name} must be an Integer or integer-like value" if index.nil?
|
|
31
|
+
raise ArgumentError, "#{name} must be greater than or equal to 1" if index < 1
|
|
32
|
+
|
|
33
|
+
index
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def normalize_symbol(value, name)
|
|
37
|
+
return value if value.is_a?(Symbol)
|
|
38
|
+
return value.to_sym if value.respond_to?(:to_sym)
|
|
39
|
+
|
|
40
|
+
raise TypeError, "#{name} must be symbolizable"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def normalize_optional_string(value, name)
|
|
44
|
+
return if value.nil?
|
|
45
|
+
|
|
46
|
+
raise TypeError, "#{name} must be a String" unless value.respond_to?(:to_str)
|
|
47
|
+
|
|
48
|
+
String.new(value.to_str).freeze
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def normalize_boolean(value)
|
|
52
|
+
return value if [true, false].include?(value)
|
|
53
|
+
|
|
54
|
+
raise TypeError, "injected must be true or false"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def normalize_source_ref(value)
|
|
58
|
+
raise TypeError, "source_ref must be an Agent::SessionContext::SourceRef" unless value.is_a?(SourceRef)
|
|
59
|
+
|
|
60
|
+
value
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
TranscriptEntry = Data.define(:index, :role, :at, :parts) do
|
|
65
|
+
def initialize(index:, role:, at:, parts:)
|
|
66
|
+
super(
|
|
67
|
+
index: normalize_positive_index(index, :index),
|
|
68
|
+
role: normalize_symbol(role, :role),
|
|
69
|
+
at: at,
|
|
70
|
+
parts: normalize_parts(parts)
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def normalize_positive_index(value, name)
|
|
77
|
+
index =
|
|
78
|
+
if value.is_a?(Integer)
|
|
79
|
+
value
|
|
80
|
+
elsif value.respond_to?(:to_int)
|
|
81
|
+
value.to_int
|
|
82
|
+
elsif value.is_a?(String)
|
|
83
|
+
Integer(value, exception: false)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
raise TypeError, "#{name} must be an Integer or integer-like value" if index.nil?
|
|
87
|
+
raise ArgumentError, "#{name} must be greater than or equal to 1" if index < 1
|
|
88
|
+
|
|
89
|
+
index
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def normalize_symbol(value, name)
|
|
93
|
+
return value if value.is_a?(Symbol)
|
|
94
|
+
return value.to_sym if value.respond_to?(:to_sym)
|
|
95
|
+
|
|
96
|
+
raise TypeError, "#{name} must be symbolizable"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def normalize_parts(value)
|
|
100
|
+
Array(value).map do |part|
|
|
101
|
+
unless part.is_a?(TranscriptPart)
|
|
102
|
+
raise TypeError,
|
|
103
|
+
"parts must contain only Agent::SessionContext::TranscriptPart values"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
part
|
|
107
|
+
end.freeze
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
Transcript = Data.define(:session, :captured_at, :entries, :warnings) do
|
|
112
|
+
class << self
|
|
113
|
+
def capture(session, reader: Agent::Sessions.read(session), now: Time.now)
|
|
114
|
+
entries = []
|
|
115
|
+
|
|
116
|
+
reader.each_message.with_index(1) do |message, message_index|
|
|
117
|
+
entries << TranscriptEntry.new(
|
|
118
|
+
index: message_index,
|
|
119
|
+
role: message.role,
|
|
120
|
+
at: message.at,
|
|
121
|
+
parts: capture_parts(session, message, message_index)
|
|
122
|
+
)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
new(
|
|
126
|
+
session: session,
|
|
127
|
+
captured_at: now,
|
|
128
|
+
entries: entries.freeze,
|
|
129
|
+
warnings: capture_warnings(reader)
|
|
130
|
+
)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def injection_kind(agent, text)
|
|
134
|
+
return unless text.is_a?(String)
|
|
135
|
+
|
|
136
|
+
stripped_text = text.b.lstrip
|
|
137
|
+
marker_kinds = Transcript::INJECTION_MARKERS.fetch(agent&.to_sym, {})
|
|
138
|
+
match = marker_kinds.find { |marker, _kind| stripped_text.start_with?(marker.b) }
|
|
139
|
+
match&.last
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
private
|
|
143
|
+
|
|
144
|
+
def capture_parts(session, message, message_index)
|
|
145
|
+
message.parts.each_with_index.map do |part, part_index|
|
|
146
|
+
TranscriptPart.new(
|
|
147
|
+
index: part_index + 1,
|
|
148
|
+
type: part.type,
|
|
149
|
+
text: part.text,
|
|
150
|
+
name: part.name,
|
|
151
|
+
call_id: part.call_id,
|
|
152
|
+
injected: injected_part?(session.agent, message, part),
|
|
153
|
+
source_ref: SourceRef.new(
|
|
154
|
+
session_uid: session.uid,
|
|
155
|
+
message_index: message_index,
|
|
156
|
+
part_index: part_index + 1
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
end.freeze
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def capture_warnings(reader)
|
|
163
|
+
Array(reader.warnings).map do |warning|
|
|
164
|
+
raise TypeError, "warning must be a String" unless warning.respond_to?(:to_str)
|
|
165
|
+
|
|
166
|
+
String.new(warning.to_str).freeze
|
|
167
|
+
end.freeze
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def injected_part?(agent, message, part)
|
|
171
|
+
return false unless message.role == :user
|
|
172
|
+
return false unless part.type == :text
|
|
173
|
+
|
|
174
|
+
raw_meta_message?(message.raw) || marker_injected?(agent, part.text)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def raw_meta_message?(raw)
|
|
178
|
+
raw.is_a?(Hash) && raw["isMeta"] == true
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def marker_injected?(agent, text)
|
|
182
|
+
!injection_kind(agent, text).nil?
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def initialize(session:, captured_at:, entries:, warnings:)
|
|
187
|
+
super(
|
|
188
|
+
session: session,
|
|
189
|
+
captured_at: captured_at,
|
|
190
|
+
entries: normalize_entries(entries),
|
|
191
|
+
warnings: normalize_warnings(warnings)
|
|
192
|
+
)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
private
|
|
196
|
+
|
|
197
|
+
def normalize_entries(value)
|
|
198
|
+
Array(value).map do |entry|
|
|
199
|
+
unless entry.is_a?(TranscriptEntry)
|
|
200
|
+
raise TypeError,
|
|
201
|
+
"entries must contain only Agent::SessionContext::TranscriptEntry values"
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
entry
|
|
205
|
+
end.freeze
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def normalize_warnings(value)
|
|
209
|
+
Array(value).map do |warning|
|
|
210
|
+
raise TypeError, "warning must be a String" unless warning.respond_to?(:to_str)
|
|
211
|
+
|
|
212
|
+
String.new(warning.to_str).freeze
|
|
213
|
+
end.freeze
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
Transcript.const_set(
|
|
218
|
+
:INJECTION_MARKERS,
|
|
219
|
+
{
|
|
220
|
+
claude: {
|
|
221
|
+
"<command-name>" => :command_name,
|
|
222
|
+
"<command-message>" => :command_message,
|
|
223
|
+
"<command-args>" => :command_args,
|
|
224
|
+
"<local-command-stdout>" => :local_command_stdout,
|
|
225
|
+
"<local-command-stderr>" => :local_command_stderr,
|
|
226
|
+
"<system-reminder>" => :system_reminder
|
|
227
|
+
}.freeze,
|
|
228
|
+
codex: {
|
|
229
|
+
"<environment_context>" => :environment_context,
|
|
230
|
+
"<user_instructions>" => :user_instructions,
|
|
231
|
+
"# AGENTS.md instructions" => :agents_instructions
|
|
232
|
+
}.freeze
|
|
233
|
+
}.freeze
|
|
234
|
+
)
|
|
235
|
+
end
|
|
236
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
require "agent_sessions"
|
|
6
|
+
require "zeitwerk"
|
|
7
|
+
require_relative "session_context/errors"
|
|
8
|
+
|
|
9
|
+
module Agent
|
|
10
|
+
module SessionContext
|
|
11
|
+
LOADER = Zeitwerk::Loader.for_gem_extension(Agent)
|
|
12
|
+
LOADER.ignore(File.expand_path("session_context/errors.rb", __dir__))
|
|
13
|
+
LOADER.inflector.inflect("cli" => "CLI", "json" => "JSON", "json_lines" => "JSONLines")
|
|
14
|
+
LOADER.setup
|
|
15
|
+
private_constant :LOADER
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
def resolve(identifier, agent: nil, env: ENV, catalog: Agent::Sessions)
|
|
19
|
+
SessionResolver.new(catalog:, env: env).resolve(identifier, agent: agent)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def current(agent: nil, env: ENV, catalog: Agent::Sessions)
|
|
23
|
+
SessionResolver.new(catalog:, env: env).current(agent:)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def show(session, include_injected: false, **)
|
|
27
|
+
Builder.new(**).show(session, include_injected:)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def prompts(session, **)
|
|
31
|
+
Builder.new(**).prompts(session)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def summarize(
|
|
35
|
+
session,
|
|
36
|
+
using: nil,
|
|
37
|
+
summarizer: nil,
|
|
38
|
+
timeout: nil,
|
|
39
|
+
env: ENV,
|
|
40
|
+
config_loader: Config,
|
|
41
|
+
backend_factory: Summarizers,
|
|
42
|
+
**
|
|
43
|
+
)
|
|
44
|
+
if summarizer
|
|
45
|
+
raise ArgumentError, "timeout applies only to built-in summarizers" unless timeout.nil?
|
|
46
|
+
|
|
47
|
+
return Builder.new(**).summarize(session, summarizer:)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
config = config_loader.load(session:, env:, timeout:)
|
|
51
|
+
backend = backend_factory.for(using || session.agent, timeout_seconds: config.timeout_seconds)
|
|
52
|
+
Builder.new(**).summarize(session, summarizer: backend)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|