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,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Agent
6
+ module SessionContext
7
+ module Summarizers
8
+ class Claude
9
+ include CommandExecutionPolicy
10
+
11
+ MAX_OUTPUT_BYTES = 1_048_576
12
+ PROVIDER_ENV_KEYS = %w[ANTHROPIC_API_KEY CLAUDE_CODE_OAUTH_TOKEN CLAUDE_CONFIG_DIR].freeze
13
+ private_constant :PROVIDER_ENV_KEYS
14
+
15
+ def initialize(runner: nil, timeout_seconds: Config::DEFAULT_TIMEOUT_SECONDS)
16
+ initialize_command_execution_policy(
17
+ runner:,
18
+ timeout_seconds:,
19
+ max_output_bytes: MAX_OUTPUT_BYTES,
20
+ provider_env_keys: PROVIDER_ENV_KEYS
21
+ )
22
+ end
23
+
24
+ def name
25
+ :claude
26
+ end
27
+
28
+ def call(prompt:, schema:)
29
+ stdout, stderr, status = run_command!(prompt:, argv: command_argv(schema))
30
+ fail_command!(stdout:, stderr:, status:) unless status.success?
31
+ if stdout.strip.empty?
32
+ raise SummarizerFailed, failure_message(
33
+ "#{name} summarizer produced empty stdout",
34
+ stdout:,
35
+ stderr:
36
+ )
37
+ end
38
+
39
+ envelope = parse_envelope!(stdout, stderr:)
40
+ field_name, value = extract_structured_value!(envelope)
41
+
42
+ normalize_value!(value, field_name:, stdout:, stderr:)
43
+ end
44
+
45
+ private
46
+
47
+ def command_argv(schema)
48
+ [
49
+ "claude",
50
+ "--print",
51
+ "--safe-mode",
52
+ "--tools",
53
+ "",
54
+ "--no-session-persistence",
55
+ "--output-format",
56
+ "json",
57
+ "--json-schema",
58
+ JSON.generate(schema)
59
+ ]
60
+ end
61
+
62
+ def parse_envelope!(stdout, stderr:)
63
+ envelope = JSON.parse(stdout)
64
+ unless envelope.is_a?(Hash)
65
+ raise SummarizerFailed, failure_message(
66
+ "#{name} summarizer produced a non-object JSON envelope",
67
+ stdout:,
68
+ stderr:
69
+ )
70
+ end
71
+
72
+ envelope
73
+ rescue JSON::ParserError, EncodingError, ArgumentError
74
+ raise SummarizerFailed, failure_message(
75
+ "#{name} summarizer produced invalid JSON envelope",
76
+ stdout:,
77
+ stderr:
78
+ )
79
+ end
80
+
81
+ def extract_structured_value!(envelope)
82
+ return ["structured_output", envelope["structured_output"]] if envelope.key?("structured_output")
83
+ return ["result", envelope["result"]] if envelope.key?("result")
84
+
85
+ raise SummarizerFailed, "#{name} summarizer JSON envelope did not include structured_output or result"
86
+ end
87
+
88
+ def normalize_value!(value, field_name:, stdout:, stderr:)
89
+ case value
90
+ when String
91
+ normalize_json_string!(value, stdout:, stderr:, label: field_name)
92
+ else
93
+ JSON.generate(JSON.parse(JSON.generate(value)))
94
+ end
95
+ rescue JSON::GeneratorError, TypeError
96
+ raise SummarizerFailed, failure_message(
97
+ "#{name} summarizer produced a non-JSON #{field_name} value",
98
+ stdout:,
99
+ stderr:,
100
+ output: value.inspect
101
+ )
102
+ end
103
+
104
+ def normalize_json_string!(value, stdout:, stderr:, label:)
105
+ JSON.generate(JSON.parse(value))
106
+ rescue JSON::ParserError, EncodingError, ArgumentError
107
+ raise SummarizerFailed, failure_message(
108
+ "#{name} summarizer produced invalid JSON in #{label}",
109
+ stdout:,
110
+ stderr:,
111
+ output: value
112
+ )
113
+ end
114
+
115
+ def fail_command!(stdout:, stderr:, status:)
116
+ raise SummarizerFailed, failure_message(
117
+ "#{name} summarizer command failed",
118
+ stdout:,
119
+ stderr:,
120
+ status:
121
+ )
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "tempfile"
5
+
6
+ module Agent
7
+ module SessionContext
8
+ module Summarizers
9
+ class Codex
10
+ include CommandExecutionPolicy
11
+
12
+ MAX_OUTPUT_BYTES = 1_048_576
13
+ PROVIDER_ENV_KEYS = %w[CODEX_HOME OPENAI_API_KEY].freeze
14
+ WHITESPACE_BYTES = [9, 10, 11, 12, 13, 32].freeze
15
+ private_constant :PROVIDER_ENV_KEYS, :WHITESPACE_BYTES
16
+
17
+ def initialize(runner: nil, timeout_seconds: Config::DEFAULT_TIMEOUT_SECONDS)
18
+ initialize_command_execution_policy(
19
+ runner:,
20
+ timeout_seconds:,
21
+ max_output_bytes: MAX_OUTPUT_BYTES,
22
+ provider_env_keys: PROVIDER_ENV_KEYS
23
+ )
24
+ end
25
+
26
+ def name
27
+ :codex
28
+ end
29
+
30
+ def call(prompt:, schema:)
31
+ Tempfile.create(["agent-context-codex-schema", ".json"]) do |schema_file|
32
+ schema_file.write(JSON.generate(schema))
33
+ schema_file.flush
34
+
35
+ Tempfile.create(["agent-context-codex-output", ".json"]) do |output_file|
36
+ stdout, stderr, status = run_command!(
37
+ prompt:,
38
+ argv: command_argv(schema_path: schema_file.path, output_path: output_file.path)
39
+ )
40
+
41
+ fail_command!(stdout:, stderr:, status:) unless status.success?
42
+
43
+ payload = read_last_message!(output_file.path, stdout:, stderr:)
44
+ normalize_json!(payload, source: "last message", stdout:, stderr:)
45
+ end
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ def command_argv(schema_path:, output_path:)
52
+ [
53
+ "codex",
54
+ "exec",
55
+ "--ephemeral",
56
+ "--sandbox",
57
+ "read-only",
58
+ "--ignore-user-config",
59
+ "--ignore-rules",
60
+ "--skip-git-repo-check",
61
+ "--output-schema",
62
+ schema_path,
63
+ "--output-last-message",
64
+ output_path,
65
+ "-"
66
+ ]
67
+ end
68
+
69
+ def read_last_message!(path, stdout:, stderr:)
70
+ payload = bounded_file_read(path, "last message", stdout:, stderr:)
71
+ rescue Errno::ENOENT
72
+ raise SummarizerFailed, failure_message(
73
+ "#{name} summarizer did not produce a last message file; output was missing",
74
+ stdout:,
75
+ stderr:
76
+ )
77
+ else
78
+ if blank_bytes?(payload)
79
+ raise SummarizerFailed, failure_message(
80
+ "#{name} summarizer produced an empty last message file",
81
+ stdout:,
82
+ stderr:,
83
+ output: payload
84
+ )
85
+ end
86
+
87
+ payload
88
+ end
89
+
90
+ def normalize_json!(payload, source:, stdout:, stderr:)
91
+ JSON.generate(JSON.parse(payload))
92
+ rescue JSON::ParserError, EncodingError, ArgumentError
93
+ raise SummarizerFailed, failure_message(
94
+ "#{name} summarizer produced invalid JSON in #{source}",
95
+ stdout:,
96
+ stderr:,
97
+ output: payload
98
+ )
99
+ end
100
+
101
+ def fail_command!(stdout:, stderr:, status:)
102
+ raise SummarizerFailed, failure_message(
103
+ "#{name} summarizer command failed",
104
+ stdout:,
105
+ stderr:,
106
+ status:
107
+ )
108
+ end
109
+
110
+ def bounded_file_read(path, label, stdout:, stderr:)
111
+ File.open(path, "rb") do |file|
112
+ payload = file.read(MAX_OUTPUT_BYTES + 1) || "".b
113
+ if payload.bytesize > MAX_OUTPUT_BYTES
114
+ raise SummarizerFailed, failure_message(
115
+ "#{name} summarizer #{label} exceeded #{MAX_OUTPUT_BYTES} bytes",
116
+ stdout:,
117
+ stderr:,
118
+ output: payload
119
+ )
120
+ end
121
+
122
+ payload
123
+ end
124
+ end
125
+
126
+ def blank_bytes?(value)
127
+ value.empty? || value.bytes.all? { |byte| WHITESPACE_BYTES.include?(byte) }
128
+ end
129
+ end
130
+ end
131
+ end
132
+ end
@@ -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,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module SessionContext
5
+ # One tool call, paired with whatever answered it. The pairing is by
6
+ # CALL ID, never by position — a model can have two calls in flight at
7
+ # once, and pairing by position would match the wrong call to the wrong
8
+ # result.
9
+ #
10
+ # result_bytes is nil, not 0, when nothing answered the call: an
11
+ # unanswered call and a call answered with an empty body are different
12
+ # facts, and defaulting the body to "" would report a 0-byte answer for
13
+ # a call that was never answered.
14
+ #
15
+ # Only sizes are carried, never bodies — transcripts hold credentials
16
+ # and customer data, and this gem has no redaction before milestone 0.5.
17
+ ToolCall = Data.define(:name, :call_id, :input_bytes, :result_bytes, :asked_in, :answered_in) do
18
+ def answered? = !result_bytes.nil?
19
+ end
20
+ end
21
+ end