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,214 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
module Agent
|
|
6
|
+
module SessionContext
|
|
7
|
+
class CLI
|
|
8
|
+
class Options
|
|
9
|
+
AGENTS = %w[claude codex].freeze
|
|
10
|
+
BACKENDS = %w[auto claude codex].freeze
|
|
11
|
+
OPTION_DEFINITIONS = {
|
|
12
|
+
current: {
|
|
13
|
+
long: "--current",
|
|
14
|
+
value: false,
|
|
15
|
+
register: lambda do |parser, options|
|
|
16
|
+
parser.on("--current") { options[:current] = true }
|
|
17
|
+
end
|
|
18
|
+
}.freeze,
|
|
19
|
+
agent: {
|
|
20
|
+
long: "--agent",
|
|
21
|
+
value: true,
|
|
22
|
+
register: lambda do |parser, options|
|
|
23
|
+
parser.on("--agent AGENT", AGENTS) { |value| options[:agent] = value.to_sym }
|
|
24
|
+
end
|
|
25
|
+
}.freeze,
|
|
26
|
+
format: {
|
|
27
|
+
long: "--format",
|
|
28
|
+
value: true,
|
|
29
|
+
register: lambda do |parser, options|
|
|
30
|
+
parser.on("--format FORMAT", CLI::FORMATS) { |value| options[:format] = value.to_sym }
|
|
31
|
+
end
|
|
32
|
+
}.freeze,
|
|
33
|
+
using: {
|
|
34
|
+
long: "--using",
|
|
35
|
+
value: true,
|
|
36
|
+
register: lambda do |parser, options|
|
|
37
|
+
parser.on("--using BACKEND", BACKENDS) { |value| options[:using] = value.to_sym }
|
|
38
|
+
end
|
|
39
|
+
}.freeze,
|
|
40
|
+
timeout: {
|
|
41
|
+
long: "--timeout",
|
|
42
|
+
value: true,
|
|
43
|
+
register: lambda do |parser, options|
|
|
44
|
+
parser.on("--timeout SECONDS") { |value| options[:timeout] = parse_timeout_argument(value) }
|
|
45
|
+
end
|
|
46
|
+
}.freeze,
|
|
47
|
+
include_injected: {
|
|
48
|
+
long: "--include-injected",
|
|
49
|
+
value: false,
|
|
50
|
+
register: lambda do |parser, options|
|
|
51
|
+
parser.on("--include-injected") { options[:include_injected] = true }
|
|
52
|
+
end
|
|
53
|
+
}.freeze
|
|
54
|
+
}.freeze
|
|
55
|
+
COMMANDS = {
|
|
56
|
+
show: { formats: %i[text markdown json], options: %i[current agent format include_injected] }.freeze,
|
|
57
|
+
prompts: { formats: %i[text markdown json jsonl], options: %i[current agent format] }.freeze,
|
|
58
|
+
loop: { formats: %i[text markdown json jsonl], options: %i[current agent format] }.freeze,
|
|
59
|
+
summarize: { formats: %i[text markdown json], options: %i[current agent format using timeout] }.freeze,
|
|
60
|
+
help: { formats: nil, options: [].freeze }.freeze,
|
|
61
|
+
version: { formats: nil, options: [].freeze }.freeze
|
|
62
|
+
}.transform_values do |descriptor|
|
|
63
|
+
option_names = descriptor.fetch(:options)
|
|
64
|
+
long_options = option_names.each_with_object({}) do |name, rules|
|
|
65
|
+
definition = OPTION_DEFINITIONS.fetch(name)
|
|
66
|
+
rules[definition.fetch(:long)] = definition.fetch(:value) ? :value : :flag
|
|
67
|
+
end.freeze
|
|
68
|
+
|
|
69
|
+
descriptor.merge(
|
|
70
|
+
formats: descriptor[:formats]&.freeze,
|
|
71
|
+
options: option_names.freeze,
|
|
72
|
+
long_options:
|
|
73
|
+
).freeze
|
|
74
|
+
end.freeze
|
|
75
|
+
private_constant :AGENTS, :BACKENDS, :OPTION_DEFINITIONS, :COMMANDS
|
|
76
|
+
|
|
77
|
+
class << self
|
|
78
|
+
def parse(arguments, command:)
|
|
79
|
+
descriptor = command_descriptor(command)
|
|
80
|
+
options = default_options
|
|
81
|
+
validate_argument_encoding!(arguments)
|
|
82
|
+
validate_exact_long_options!(arguments, descriptor:)
|
|
83
|
+
remaining = parser(options, descriptor:).permute(arguments.dup)
|
|
84
|
+
|
|
85
|
+
validate_selection!(options, remaining, command:, descriptor:)
|
|
86
|
+
options.merge(identifier: remaining.first).freeze
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def hinted_format(arguments)
|
|
90
|
+
limit = arguments.index("--") || arguments.length
|
|
91
|
+
effective = :text
|
|
92
|
+
index = 0
|
|
93
|
+
|
|
94
|
+
while index < limit
|
|
95
|
+
token = arguments[index]
|
|
96
|
+
|
|
97
|
+
if token == "--format"
|
|
98
|
+
candidate = arguments[index + 1]
|
|
99
|
+
effective = candidate.to_sym if CLI::FORMATS.include?(candidate)
|
|
100
|
+
index += 2
|
|
101
|
+
next
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
if token.start_with?("--format=")
|
|
105
|
+
candidate = token.split("=", 2).last
|
|
106
|
+
effective = candidate.to_sym if CLI::FORMATS.include?(candidate)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
index += 1
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
effective
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def parse_no_args(arguments, command:)
|
|
116
|
+
descriptor = command_descriptor(command)
|
|
117
|
+
validate_argument_encoding!(arguments)
|
|
118
|
+
validate_exact_long_options!(arguments, descriptor:)
|
|
119
|
+
remaining = OptionParser.new.permute(arguments.dup)
|
|
120
|
+
raise OptionParser::ParseError, "unexpected arguments: #{remaining.join(" ")}" unless remaining.empty?
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
private
|
|
124
|
+
|
|
125
|
+
def default_options
|
|
126
|
+
{
|
|
127
|
+
current: false,
|
|
128
|
+
agent: nil,
|
|
129
|
+
format: :text,
|
|
130
|
+
using: :auto,
|
|
131
|
+
timeout: nil,
|
|
132
|
+
include_injected: false
|
|
133
|
+
}
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def parser(options, descriptor:)
|
|
137
|
+
OptionParser.new do |parser|
|
|
138
|
+
descriptor.fetch(:options).each do |name|
|
|
139
|
+
OPTION_DEFINITIONS.fetch(name).fetch(:register).call(parser, options)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def validate_selection!(options, remaining, command:, descriptor:)
|
|
145
|
+
allowed_formats = descriptor.fetch(:formats)
|
|
146
|
+
unless allowed_formats.include?(options[:format])
|
|
147
|
+
raise OptionParser::ParseError, "--format #{options[:format]} is not supported for #{command}"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
if options[:current] && !remaining.empty?
|
|
151
|
+
raise OptionParser::ParseError, "SESSION and --current are mutually exclusive"
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
if remaining.length > 1
|
|
155
|
+
raise OptionParser::ParseError, "unexpected arguments: #{remaining.drop(1).join(" ")}"
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
raise OptionParser::ParseError, "pass SESSION or --current" if !options[:current] && remaining.empty?
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def validate_argument_encoding!(arguments)
|
|
162
|
+
arguments.each do |argument|
|
|
163
|
+
next unless argument.is_a?(String)
|
|
164
|
+
|
|
165
|
+
candidate = argument.dup
|
|
166
|
+
candidate.force_encoding(Encoding::UTF_8)
|
|
167
|
+
raise OptionParser::InvalidArgument, "arguments must be valid UTF-8" unless candidate.valid_encoding?
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def validate_exact_long_options!(arguments, descriptor:)
|
|
172
|
+
allowed = descriptor.fetch(:long_options)
|
|
173
|
+
limit = arguments.index("--") || arguments.length
|
|
174
|
+
index = 0
|
|
175
|
+
|
|
176
|
+
while index < limit
|
|
177
|
+
token = arguments[index]
|
|
178
|
+
if token.start_with?("--")
|
|
179
|
+
name, value = token.split("=", 2)
|
|
180
|
+
rule = allowed[name]
|
|
181
|
+
raise OptionParser::InvalidOption, token unless rule
|
|
182
|
+
raise OptionParser::InvalidOption, token if rule == :flag && !value.nil?
|
|
183
|
+
|
|
184
|
+
if rule == :value && value.nil?
|
|
185
|
+
index += 2
|
|
186
|
+
next
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
index += 1
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def command_descriptor(command)
|
|
195
|
+
COMMANDS.fetch(command.to_sym)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def parse_timeout_argument(value)
|
|
199
|
+
Integer(value, 10)
|
|
200
|
+
rescue ArgumentError, TypeError
|
|
201
|
+
return Float::NAN if value.casecmp("nan").zero?
|
|
202
|
+
return Float::INFINITY if value.casecmp("infinity").zero? || value.casecmp("inf").zero?
|
|
203
|
+
return -Float::INFINITY if value.casecmp("-infinity").zero? || value.casecmp("-inf").zero?
|
|
204
|
+
|
|
205
|
+
parsed = Float(value, exception: false)
|
|
206
|
+
return parsed unless parsed.nil?
|
|
207
|
+
|
|
208
|
+
raise OptionParser::InvalidArgument, value
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
module Agent
|
|
6
|
+
module SessionContext
|
|
7
|
+
class CLI
|
|
8
|
+
FORMATS = %w[text markdown json jsonl].freeze
|
|
9
|
+
|
|
10
|
+
def initialize(
|
|
11
|
+
argv,
|
|
12
|
+
env: ENV,
|
|
13
|
+
stdout: $stdout,
|
|
14
|
+
stderr: $stderr,
|
|
15
|
+
now: Time.now,
|
|
16
|
+
resolver: SessionResolver.new(env: env),
|
|
17
|
+
builder: Builder.new(now: now),
|
|
18
|
+
backend_factory: Summarizers,
|
|
19
|
+
config_loader: Config
|
|
20
|
+
)
|
|
21
|
+
@argv = argv.dup
|
|
22
|
+
@env = env
|
|
23
|
+
@stdout = stdout
|
|
24
|
+
@stderr = stderr
|
|
25
|
+
@resolver = resolver
|
|
26
|
+
@builder = builder
|
|
27
|
+
@backend_factory = backend_factory
|
|
28
|
+
@config_loader = config_loader
|
|
29
|
+
@current_format = :text
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def run
|
|
33
|
+
case (command = @argv.shift)
|
|
34
|
+
when "show" then show
|
|
35
|
+
when "prompts" then prompts
|
|
36
|
+
# Named loop_command, not loop: Kernel#loop is an instance method
|
|
37
|
+
# available everywhere, and a method named `loop` on this CLI object
|
|
38
|
+
# would shadow it for the rest of this instance.
|
|
39
|
+
when "loop" then loop_command
|
|
40
|
+
when "summarize" then summarize
|
|
41
|
+
when "version", "--version", "-v" then version(@argv)
|
|
42
|
+
when nil then help([], @stdout, 0)
|
|
43
|
+
when "help", "--help", "-h" then help(@argv, @stdout, 0)
|
|
44
|
+
else
|
|
45
|
+
@stderr.puts "unknown command: #{safe_text(command)}"
|
|
46
|
+
1
|
|
47
|
+
end
|
|
48
|
+
rescue Agent::SessionContext::Error, OptionParser::ParseError => e
|
|
49
|
+
emit_error(e)
|
|
50
|
+
1
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def show
|
|
56
|
+
@current_format = Options.hinted_format(@argv)
|
|
57
|
+
selection = Options.parse(@argv, command: :show)
|
|
58
|
+
session = resolve_session(selection)
|
|
59
|
+
include_injected = selection.fetch(:include_injected)
|
|
60
|
+
snapshot = @builder.show(session, include_injected:)
|
|
61
|
+
if include_injected
|
|
62
|
+
@stderr.puts "warning: exact prompts and full injected context may contain secrets; review before sharing"
|
|
63
|
+
else
|
|
64
|
+
@stderr.puts "warning: exact prompts may contain secrets; review before sharing"
|
|
65
|
+
end
|
|
66
|
+
emit_warnings(session.uid, snapshot.warnings)
|
|
67
|
+
write_output(snapshot, format: selection.fetch(:format))
|
|
68
|
+
partial_capture_status(snapshot)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def prompts
|
|
72
|
+
@current_format = Options.hinted_format(@argv)
|
|
73
|
+
selection = Options.parse(@argv, command: :prompts)
|
|
74
|
+
session = resolve_session(selection)
|
|
75
|
+
prompts_result = @builder.prompts_result(session)
|
|
76
|
+
@stderr.puts "warning: exact prompts may contain secrets; review before sharing"
|
|
77
|
+
emit_warnings(session.uid, prompts_result.reader_warnings)
|
|
78
|
+
write_output(prompts_result.prompts, format: selection.fetch(:format))
|
|
79
|
+
prompts_result.partial_capture? ? 1 : 0
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# No "may contain secrets" stderr warning here, unlike show and
|
|
83
|
+
# prompts: this view prints byte sizes and tool names, never prompt or
|
|
84
|
+
# tool-result bodies (LoopView's own privacy rule), so its output is
|
|
85
|
+
# always safe to paste anywhere.
|
|
86
|
+
def loop_command
|
|
87
|
+
@current_format = Options.hinted_format(@argv)
|
|
88
|
+
selection = Options.parse(@argv, command: :loop)
|
|
89
|
+
session = resolve_session(selection)
|
|
90
|
+
loop = @builder.loop(session)
|
|
91
|
+
emit_warnings(session.uid, loop.warnings)
|
|
92
|
+
write_output(loop, format: selection.fetch(:format))
|
|
93
|
+
loop.warnings.empty? ? 0 : 1
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def summarize
|
|
97
|
+
@current_format = Options.hinted_format(@argv)
|
|
98
|
+
selection = Options.parse(@argv, command: :summarize)
|
|
99
|
+
session = resolve_session(selection)
|
|
100
|
+
config = @config_loader.load(session:, env: @env, timeout: selection.fetch(:timeout))
|
|
101
|
+
backend_name = selection.fetch(:using) == :auto ? session.agent : selection.fetch(:using)
|
|
102
|
+
timeout_seconds = configured_timeout_seconds(config)
|
|
103
|
+
@stderr.puts "summarizing with #{safe_text(backend_name)} (timeout: #{format_timeout(timeout_seconds)}s)"
|
|
104
|
+
summarizer = @backend_factory.for(backend_name, timeout_seconds:)
|
|
105
|
+
snapshot = @builder.summarize(session, summarizer:)
|
|
106
|
+
emit_warnings(session.uid, snapshot.warnings)
|
|
107
|
+
write_output(snapshot, format: selection.fetch(:format))
|
|
108
|
+
partial_capture_status(snapshot)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def version(arguments)
|
|
112
|
+
Options.parse_no_args(arguments, command: :version)
|
|
113
|
+
@stdout.puts Agent::SessionContext::VERSION
|
|
114
|
+
0
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def help(arguments, io, status)
|
|
118
|
+
Options.parse_no_args(arguments, command: :help)
|
|
119
|
+
io.puts <<~HELP
|
|
120
|
+
Usage: agent-session-context COMMAND [options] [SESSION]
|
|
121
|
+
|
|
122
|
+
Commands:
|
|
123
|
+
show
|
|
124
|
+
prompts
|
|
125
|
+
loop
|
|
126
|
+
summarize
|
|
127
|
+
version
|
|
128
|
+
help
|
|
129
|
+
|
|
130
|
+
Common options:
|
|
131
|
+
--current Use environment identity, else latest on disk
|
|
132
|
+
--agent claude|codex Narrow explicit lookup or --current disk fallback
|
|
133
|
+
--format text|markdown|json|jsonl
|
|
134
|
+
|
|
135
|
+
Show behavior:
|
|
136
|
+
Includes exact user prompts and an injected-context inventory.
|
|
137
|
+
--include-injected Include deduplicated full injected text
|
|
138
|
+
Excludes assistant messages, thinking, tool-result bodies,
|
|
139
|
+
and raw provider envelopes.
|
|
140
|
+
|
|
141
|
+
Loop behavior:
|
|
142
|
+
Shows the session as the agent loop: prompts, model round trips,
|
|
143
|
+
tool calls paired with their results, and where it stopped.
|
|
144
|
+
Prints byte sizes and tool names, never bodies. Deterministic;
|
|
145
|
+
the ending is always labelled inferred.
|
|
146
|
+
|
|
147
|
+
Summarize options:
|
|
148
|
+
--using auto|claude|codex
|
|
149
|
+
--timeout SECONDS Set a 1-3600s timeout for each provider call
|
|
150
|
+
HELP
|
|
151
|
+
status
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def resolve_session(selection)
|
|
155
|
+
if selection.fetch(:current)
|
|
156
|
+
@resolver.current(agent: selection.fetch(:agent)) do |session|
|
|
157
|
+
@stderr.puts(
|
|
158
|
+
"warning: --current found no session environment identifier; " \
|
|
159
|
+
"using latest session on disk: #{safe_text(session.uid)}"
|
|
160
|
+
)
|
|
161
|
+
end
|
|
162
|
+
else
|
|
163
|
+
@resolver.resolve(selection.fetch(:identifier), agent: selection.fetch(:agent))
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def emit_warnings(session_uid, warnings)
|
|
168
|
+
Array(warnings).each do |warning|
|
|
169
|
+
@stderr.puts "warning: #{safe_text(session_uid)}: #{safe_text(warning)}"
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def write_output(value, format:)
|
|
174
|
+
rendered = renderer_for(format).call(value)
|
|
175
|
+
if human_format?(format)
|
|
176
|
+
@stdout.write(rendered)
|
|
177
|
+
@stdout.write("\n")
|
|
178
|
+
else
|
|
179
|
+
@stdout.write(rendered)
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def renderer_for(format)
|
|
184
|
+
case format
|
|
185
|
+
when :text then Renderers::Text.new
|
|
186
|
+
when :markdown then Renderers::Markdown.new
|
|
187
|
+
when :json then Renderers::JSON.new
|
|
188
|
+
when :jsonl then Renderers::JSONLines.new
|
|
189
|
+
else
|
|
190
|
+
raise OptionParser::ParseError, "unsupported format #{format.inspect}"
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def human_format?(format)
|
|
195
|
+
%i[text markdown].include?(format)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def emit_error(error)
|
|
199
|
+
if @current_format == :json
|
|
200
|
+
error_json = JSON.generate(
|
|
201
|
+
{
|
|
202
|
+
error: {
|
|
203
|
+
type: scrubbed_error_type(error),
|
|
204
|
+
message: Renderers::Serializer.scrub_string(normalized_error_message(error))
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
)
|
|
208
|
+
@stdout.write(error_json)
|
|
209
|
+
else
|
|
210
|
+
@stderr.puts safe_text(normalized_error_message(error))
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def safe_text(value)
|
|
215
|
+
Renderers::HumanDisplay.text_inline(value)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def partial_capture_status(snapshot)
|
|
219
|
+
snapshot.summary_metadata.fetch(:reader_warning_count, 0).positive? ? 1 : 0
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def configured_timeout_seconds(config)
|
|
223
|
+
value = config.timeout_seconds
|
|
224
|
+
if value.is_a?(Integer) && value >= Config::MIN_TIMEOUT_SECONDS && value <= Config::MAX_TIMEOUT_SECONDS
|
|
225
|
+
return value
|
|
226
|
+
end
|
|
227
|
+
if value.is_a?(Float) &&
|
|
228
|
+
value.finite? &&
|
|
229
|
+
value >= Config::MIN_TIMEOUT_SECONDS &&
|
|
230
|
+
value <= Config::MAX_TIMEOUT_SECONDS
|
|
231
|
+
return value
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
raise ConfigurationError, "Invalid configured timeout_seconds."
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def format_timeout(value)
|
|
238
|
+
return value.to_i.to_s if value.is_a?(Float) && value.finite? && value == value.to_i
|
|
239
|
+
|
|
240
|
+
value.to_s
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def normalized_error_message(error)
|
|
244
|
+
return error.args.first.to_s if error.instance_of?(OptionParser::ParseError)
|
|
245
|
+
|
|
246
|
+
error.message.to_s
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def scrubbed_error_type(error)
|
|
250
|
+
Renderers::Serializer.scrub_string(error.class.name.to_s)
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
require "psych"
|
|
5
|
+
|
|
6
|
+
module Agent
|
|
7
|
+
module SessionContext
|
|
8
|
+
class Config < Data.define(:timeout_seconds)
|
|
9
|
+
DEFAULT_TIMEOUT_SECONDS = 300
|
|
10
|
+
MIN_TIMEOUT_SECONDS = 1
|
|
11
|
+
MAX_TIMEOUT_SECONDS = 3600
|
|
12
|
+
ABSENT = Object.new.freeze
|
|
13
|
+
PROJECT_CONFIG_FILENAME = ".agent-context.yml"
|
|
14
|
+
USER_CONFIG_PATH_SEGMENTS = [".config", "agent_context", "config.yml"].freeze
|
|
15
|
+
private_constant :ABSENT, :PROJECT_CONFIG_FILENAME, :USER_CONFIG_PATH_SEGMENTS
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
def load(session:, env: ENV, timeout: nil)
|
|
19
|
+
project_timeout = load_layer(project_source(session))
|
|
20
|
+
user_timeout = load_layer(user_source(env))
|
|
21
|
+
|
|
22
|
+
effective_timeout =
|
|
23
|
+
if timeout.nil?
|
|
24
|
+
if project_timeout.nil?
|
|
25
|
+
user_timeout.nil? ? DEFAULT_TIMEOUT_SECONDS : user_timeout
|
|
26
|
+
else
|
|
27
|
+
project_timeout
|
|
28
|
+
end
|
|
29
|
+
else
|
|
30
|
+
validate_timeout!(timeout, source: "timeout")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
new(timeout_seconds: effective_timeout)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def load_layer(source)
|
|
39
|
+
return nil unless source
|
|
40
|
+
|
|
41
|
+
path = source.fetch(:path)
|
|
42
|
+
stat = stat_config_path(path, source)
|
|
43
|
+
return nil unless stat
|
|
44
|
+
|
|
45
|
+
raise_configuration_error(source, "must be a regular file") unless stat.file?
|
|
46
|
+
|
|
47
|
+
content = read_utf8(path, stat, source)
|
|
48
|
+
raw = parse_yaml(content, source)
|
|
49
|
+
timeout = extract_timeout(raw, source)
|
|
50
|
+
|
|
51
|
+
return nil if timeout.equal?(ABSENT)
|
|
52
|
+
|
|
53
|
+
validate_timeout!(timeout, source: source.fetch(:label), path:)
|
|
54
|
+
rescue ConfigurationError
|
|
55
|
+
raise
|
|
56
|
+
rescue Psych::Exception
|
|
57
|
+
raise_configuration_error(source, "contains invalid YAML")
|
|
58
|
+
rescue SystemCallError
|
|
59
|
+
raise_configuration_error(source, "could not be read")
|
|
60
|
+
rescue ArgumentError
|
|
61
|
+
raise_configuration_error(source, "contains invalid encoding")
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def stat_config_path(path, source)
|
|
65
|
+
File.lstat(path)
|
|
66
|
+
rescue Errno::ENOENT, Errno::ENOTDIR
|
|
67
|
+
nil
|
|
68
|
+
rescue SystemCallError
|
|
69
|
+
raise_configuration_error(source, "could not be read")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def project_source(session)
|
|
73
|
+
project_path = normalize_absolute_path(session.project_path)
|
|
74
|
+
return nil unless project_path
|
|
75
|
+
|
|
76
|
+
{
|
|
77
|
+
label: "project configuration",
|
|
78
|
+
path: File.join(project_path, PROJECT_CONFIG_FILENAME)
|
|
79
|
+
}
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def user_source(env)
|
|
83
|
+
xdg_config_home = normalize_absolute_path(env["XDG_CONFIG_HOME"])
|
|
84
|
+
if xdg_config_home
|
|
85
|
+
return {
|
|
86
|
+
label: "user configuration",
|
|
87
|
+
path: File.join(xdg_config_home, "agent_context", "config.yml")
|
|
88
|
+
}
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
home = normalize_absolute_path(env["HOME"])
|
|
92
|
+
return nil unless home
|
|
93
|
+
|
|
94
|
+
{
|
|
95
|
+
label: "user configuration",
|
|
96
|
+
path: File.join(home, *USER_CONFIG_PATH_SEGMENTS)
|
|
97
|
+
}
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def normalize_absolute_path(value)
|
|
101
|
+
return nil unless value.respond_to?(:to_str)
|
|
102
|
+
|
|
103
|
+
path = value.to_str
|
|
104
|
+
return nil if path.empty?
|
|
105
|
+
return path if Pathname.new(path).absolute?
|
|
106
|
+
|
|
107
|
+
nil
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def read_utf8(path, initial_stat, source)
|
|
111
|
+
open_config_file(path, source) do |file|
|
|
112
|
+
descriptor_stat = file.stat
|
|
113
|
+
raise_configuration_error(source, "must be a regular file") unless descriptor_stat.file?
|
|
114
|
+
ensure_same_identity!(initial_stat, descriptor_stat, source)
|
|
115
|
+
|
|
116
|
+
content = file.read
|
|
117
|
+
content = content.dup.force_encoding(Encoding::UTF_8)
|
|
118
|
+
raise_configuration_error(source, "contains invalid encoding") unless content.valid_encoding?
|
|
119
|
+
|
|
120
|
+
content
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def parse_yaml(content, source)
|
|
125
|
+
return nil if content.empty?
|
|
126
|
+
|
|
127
|
+
Psych.safe_load(
|
|
128
|
+
content,
|
|
129
|
+
permitted_classes: [],
|
|
130
|
+
permitted_symbols: [],
|
|
131
|
+
aliases: false
|
|
132
|
+
)
|
|
133
|
+
rescue Psych::Exception
|
|
134
|
+
raise_configuration_error(source, "contains invalid YAML")
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def extract_timeout(raw, source)
|
|
138
|
+
return ABSENT if raw.nil?
|
|
139
|
+
return ABSENT if raw == {}
|
|
140
|
+
|
|
141
|
+
raise_configuration_error(source, "must be a mapping") unless raw.is_a?(Hash)
|
|
142
|
+
|
|
143
|
+
unknown_keys = raw.keys - ["summarize"]
|
|
144
|
+
raise_configuration_error(source, "contains unsupported settings") unless unknown_keys.empty?
|
|
145
|
+
|
|
146
|
+
summarize = raw.fetch("summarize")
|
|
147
|
+
raise_configuration_error(source, "summarize must be a mapping") unless summarize.is_a?(Hash)
|
|
148
|
+
return ABSENT if summarize.empty?
|
|
149
|
+
|
|
150
|
+
unknown_summarize_keys = summarize.keys - ["timeout_seconds"]
|
|
151
|
+
unless unknown_summarize_keys.empty?
|
|
152
|
+
raise_configuration_error(source,
|
|
153
|
+
"contains unsupported summarize settings")
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
return ABSENT unless summarize.key?("timeout_seconds")
|
|
157
|
+
|
|
158
|
+
summarize.fetch("timeout_seconds")
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def validate_timeout!(value, source:, path: nil)
|
|
162
|
+
unless value.is_a?(Integer) || value.is_a?(Float)
|
|
163
|
+
raise_configuration_error({ label: source, path: },
|
|
164
|
+
"timeout_seconds must be an Integer or Float between " \
|
|
165
|
+
"#{MIN_TIMEOUT_SECONDS} and #{MAX_TIMEOUT_SECONDS}")
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
unless value.finite? &&
|
|
169
|
+
value >= MIN_TIMEOUT_SECONDS && value <= MAX_TIMEOUT_SECONDS
|
|
170
|
+
raise_configuration_error({ label: source, path: },
|
|
171
|
+
"timeout_seconds must be a finite number between " \
|
|
172
|
+
"#{MIN_TIMEOUT_SECONDS} and #{MAX_TIMEOUT_SECONDS}")
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
value
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def open_config_file(path, source)
|
|
179
|
+
flags = File::RDONLY
|
|
180
|
+
flags |= File::NOFOLLOW if File.const_defined?(:NOFOLLOW)
|
|
181
|
+
|
|
182
|
+
File.open(path, flags) do |file|
|
|
183
|
+
file.binmode
|
|
184
|
+
yield file
|
|
185
|
+
end
|
|
186
|
+
rescue SystemCallError, IOError
|
|
187
|
+
raise_configuration_error(source, "could not be read")
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def ensure_same_identity!(initial_stat, descriptor_stat, source)
|
|
191
|
+
return if initial_stat.dev == descriptor_stat.dev && initial_stat.ino == descriptor_stat.ino
|
|
192
|
+
|
|
193
|
+
raise_configuration_error(source, "changed while being read")
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def raise_configuration_error(source, reason)
|
|
197
|
+
label = source.fetch(:label)
|
|
198
|
+
path = source[:path]
|
|
199
|
+
location = path ? " at #{path}" : ""
|
|
200
|
+
|
|
201
|
+
raise ConfigurationError, "Invalid #{label}#{location}: #{reason}."
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agent
|
|
4
|
+
module SessionContext
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
class ConfigurationError < Error; end
|
|
7
|
+
class SessionNotFound < Error; end
|
|
8
|
+
class AmbiguousSession < Error; end
|
|
9
|
+
class CurrentSessionUnavailable < Error; end
|
|
10
|
+
class UnsupportedAgent < Error; end
|
|
11
|
+
class SummarizerUnavailable < Error; end
|
|
12
|
+
class SummarizerFailed < Error; end
|
|
13
|
+
class InvalidSummary < Error; end
|
|
14
|
+
end
|
|
15
|
+
end
|