agent_session_context 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +43 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +298 -0
  5. data/exe/agent-session-context +6 -0
  6. data/lib/agent/session_context/builder.rb +150 -0
  7. data/lib/agent/session_context/cli/options.rb +214 -0
  8. data/lib/agent/session_context/cli.rb +254 -0
  9. data/lib/agent/session_context/config.rb +206 -0
  10. data/lib/agent/session_context/errors.rb +15 -0
  11. data/lib/agent/session_context/evidence_collector.rb +227 -0
  12. data/lib/agent/session_context/evidence_packet.rb +271 -0
  13. data/lib/agent/session_context/immutable_value.rb +71 -0
  14. data/lib/agent/session_context/injected_context.rb +92 -0
  15. data/lib/agent/session_context/injected_context_collector.rb +53 -0
  16. data/lib/agent/session_context/item.rb +54 -0
  17. data/lib/agent/session_context/loop.rb +122 -0
  18. data/lib/agent/session_context/loop_view.rb +248 -0
  19. data/lib/agent/session_context/prompt.rb +40 -0
  20. data/lib/agent/session_context/prompt_extractor.rb +31 -0
  21. data/lib/agent/session_context/renderers/human_display.rb +113 -0
  22. data/lib/agent/session_context/renderers/json.rb +21 -0
  23. data/lib/agent/session_context/renderers/json_lines.rb +25 -0
  24. data/lib/agent/session_context/renderers/markdown.rb +130 -0
  25. data/lib/agent/session_context/renderers/serializer.rb +124 -0
  26. data/lib/agent/session_context/renderers/text.rb +128 -0
  27. data/lib/agent/session_context/semantic_categories.rb +89 -0
  28. data/lib/agent/session_context/semantic_pipeline.rb +151 -0
  29. data/lib/agent/session_context/semantic_schema.rb +75 -0
  30. data/lib/agent/session_context/session_resolver.rb +147 -0
  31. data/lib/agent/session_context/snapshot.rb +128 -0
  32. data/lib/agent/session_context/source_ref.rb +46 -0
  33. data/lib/agent/session_context/subprocess_runner.rb +362 -0
  34. data/lib/agent/session_context/summarizers/claude.rb +126 -0
  35. data/lib/agent/session_context/summarizers/codex.rb +132 -0
  36. data/lib/agent/session_context/summarizers/command_execution_policy.rb +134 -0
  37. data/lib/agent/session_context/summarizers.rb +35 -0
  38. data/lib/agent/session_context/summary_parser.rb +219 -0
  39. data/lib/agent/session_context/tool_call.rb +21 -0
  40. data/lib/agent/session_context/transcript.rb +236 -0
  41. data/lib/agent/session_context/version.rb +7 -0
  42. data/lib/agent/session_context.rb +60 -0
  43. metadata +115 -0
@@ -0,0 +1,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