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,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module SessionContext
5
+ Item = Data.define(:kind, :label, :detail, :evidence, :source_refs, :attributes) do
6
+ EVIDENCE_VALUES = %i[observed explicit inferred].freeze
7
+
8
+ def initialize(kind:, label:, evidence:, source_refs:, detail: nil, attributes: {})
9
+ unless EVIDENCE_VALUES.include?(evidence)
10
+ raise ArgumentError,
11
+ "evidence must be one of: #{EVIDENCE_VALUES.join(", ")}"
12
+ end
13
+
14
+ super(
15
+ kind: kind.to_sym,
16
+ label: normalize_string(label, :label),
17
+ detail: normalize_optional_string(detail, :detail),
18
+ evidence: evidence,
19
+ source_refs: ImmutableValue.copy(Array(source_refs)),
20
+ attributes: normalize_attributes(attributes)
21
+ )
22
+ end
23
+
24
+ private
25
+
26
+ def normalize_attributes(attributes)
27
+ raise TypeError, "attributes must be a Hash" unless attributes.is_a?(Hash)
28
+
29
+ attributes.each_with_object({}) do |(key, value), normalized|
30
+ normalized[normalize_attribute_key(key)] = ImmutableValue.copy(value)
31
+ end.freeze
32
+ end
33
+
34
+ def normalize_attribute_key(key)
35
+ return key if key.is_a?(Symbol)
36
+ return key.to_sym if key.respond_to?(:to_sym)
37
+
38
+ raise TypeError, "attribute keys must be symbolizable"
39
+ end
40
+
41
+ def normalize_string(value, name)
42
+ raise TypeError, "#{name} must be a String" unless value.respond_to?(:to_str)
43
+
44
+ String.new(value.to_str).freeze
45
+ end
46
+
47
+ def normalize_optional_string(value, name)
48
+ return if value.nil?
49
+
50
+ normalize_string(value, name)
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module SessionContext
5
+ # One session read as an agent loop: round trips, the tool calls paired
6
+ # across them, who spoke at each step, and how the loop ended.
7
+ #
8
+ # `recorded` is false for an empty session — nothing was recorded, and
9
+ # "absence must never read as presence" is this gem's standing rule
10
+ # (RoundTrip carries the same rule for one group).
11
+ #
12
+ # `warnings` is the reader's warnings first, then this object's own, so
13
+ # the order is deterministic regardless of what the reader happened to
14
+ # find.
15
+ Loop = Data.define(:session, :round_trips, :tool_calls, :speakers, :ending, :recorded, :warnings) do
16
+ # The exact sentence for each ending, so the Loop and whatever renders
17
+ # it say the same words.
18
+ ENDINGS = {
19
+ answered: "the model answered without asking for a tool",
20
+ stopped_in_the_loop: "a tool was asked for and nothing answered it",
21
+ not_a_model_record: "the session stops on a record the model did not write",
22
+ empty: "no round trips were recorded"
23
+ }.freeze
24
+
25
+ # Always true: no store on disk records WHY a session stopped — the
26
+ # on-disk transcript holds no stop reason and no turn count, because
27
+ # those live in the streamed output of a non-interactive run, not in
28
+ # the session file. So the ending is always deduced, and must always
29
+ # be labelled as such rather than presented as a recorded fact.
30
+ def ending_inferred? = true
31
+
32
+ def ending_detail = ENDINGS.fetch(ending)
33
+
34
+ class << self
35
+ # Reads the WHOLE session and says so by returning one built object
36
+ # rather than streaming — it cannot stream, because a tool result
37
+ # can arrive many records after the call it answers, and a
38
+ # streaming pass cannot look forward to find it.
39
+ # Readers::Base#tree carries the same shape of honesty for the same
40
+ # reason.
41
+ def for(reader)
42
+ warnings = []
43
+ trips = reader.round_trips
44
+ speakers = {}
45
+ trips.each { |trip| speakers[trip.index] = speaker_of(trip, warnings) }
46
+ calls = pair(trips, warnings)
47
+ new(session: reader.session, round_trips: trips, tool_calls: calls, speakers: speakers,
48
+ ending: ending_for(trips, speakers), recorded: trips.any? && trips.all?(&:recorded),
49
+ warnings: reader.warnings + warnings)
50
+ end
51
+
52
+ private
53
+
54
+ # The speaker is read from the roles AND the parts together, never
55
+ # the role alone — Claude files a tool result as a `user` message,
56
+ # so a person's prompt and the harness answering a tool look
57
+ # identical by role. A message carrying tool results is the
58
+ # harness whatever role it carries.
59
+ def speaker_of(round_trip, warnings)
60
+ roles = round_trip.roles
61
+ return :model if roles.include?(:assistant)
62
+ return :harness if roles.include?(:tool)
63
+ return :event if roles.include?(:system)
64
+ return :unknown if roles.include?(:unknown)
65
+
66
+ parts = round_trip.parts
67
+ results = parts.select { |part| part.type == :tool_result }
68
+ return :person if results.empty?
69
+
70
+ warnings << "round trip #{round_trip.index} mixes a tool result with other parts" if results.size < parts.size
71
+ :harness
72
+ end
73
+
74
+ # Pairs each call with the result that answers it, by call id —
75
+ # never by position, since two calls can be in flight at once and
76
+ # position would match the wrong one.
77
+ #
78
+ # Gemini and opencode record a call and its result in ONE message,
79
+ # so there answered_in == asked_in; the same call-id pairing covers
80
+ # it without a special case.
81
+ def pair(round_trips, warnings)
82
+ results = {}
83
+ round_trips.each do |trip|
84
+ trip.parts.each do |part|
85
+ # a nil call_id cannot be paired and must not collide with another nil
86
+ next unless part.type == :tool_result && part.call_id
87
+
88
+ results[part.call_id] = [trip.index, part]
89
+ end
90
+ end
91
+
92
+ calls = round_trips.flat_map do |trip|
93
+ trip.calls.map do |part|
94
+ # results.delete returns nil when absent, and destructuring nil
95
+ # gives both index and answer as nil — that is intended, and is
96
+ # exactly an unanswered call.
97
+ pair = part.call_id ? results.delete(part.call_id) : nil
98
+ index, answer = pair
99
+ ToolCall.new(name: part.name.to_s, call_id: part.call_id,
100
+ input_bytes: part.text.to_s.bytesize,
101
+ result_bytes: answer && answer.text.to_s.bytesize,
102
+ asked_in: trip.index, answered_in: index)
103
+ end
104
+ end
105
+
106
+ results.each_key { |id| warnings << "tool result #{id} answers no call" }
107
+ calls
108
+ end
109
+
110
+ def ending_for(round_trips, speakers)
111
+ return :empty if round_trips.empty?
112
+
113
+ last = round_trips.last
114
+ return :not_a_model_record unless speakers[last.index] == :model
115
+ return :stopped_in_the_loop if last.calls.any?
116
+
117
+ :answered
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,248 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module SessionContext
5
+ # Renders one Loop for a human (#ascii, #markdown) or a machine (#to_h).
6
+ #
7
+ # Every rendering prints SIZES, never BODIES. A transcript can hold a
8
+ # credential or a customer's data anywhere — even a shell command line
9
+ # can carry a token — and this gem has no redaction before milestone
10
+ # 0.5, so a truncated preview is not a safer middle ground; it is the
11
+ # same leak with extra steps. A byte count and a tool name carry
12
+ # enough signal to see what a loop did without carrying what it said.
13
+ #
14
+ # Every timestamp goes through #utc: two renderings of the same file
15
+ # must be byte-identical no matter which machine or time zone reads
16
+ # it, and a local time would make that false for a fact (the
17
+ # recorded instant) that has not actually changed.
18
+ #
19
+ # The ending is always printed as inferred, because Loop#ending is
20
+ # always a deduction — no on-disk transcript records WHY a session
21
+ # stopped, since that fact lives in a non-interactive run's streamed
22
+ # output, never in the file itself. Printing it as a plain fact would
23
+ # claim information the file does not hold.
24
+ class LoopView
25
+ YOU_LANE = 2
26
+ HARNESS_LANE = 15
27
+ MODEL_LANE = 50
28
+
29
+ # A label is a record TYPE name (hook_success, turn_duration), never
30
+ # prose — but the field carrying it is a free String, so this shape
31
+ # check is what actually keeps R9 (no bodies) true the day a format
32
+ # starts putting a sentence there instead of a type name.
33
+ LABEL_SHAPE = /\A[A-Za-z0-9_.:-]{1,64}\z/
34
+
35
+ def initialize(loop_model)
36
+ @loop = loop_model
37
+ end
38
+
39
+ def ascii
40
+ lines = [
41
+ "session #{@loop.session.uid}",
42
+ "round trips: #{@loop.round_trips.size} (grouping: #{grouping})",
43
+ "tool calls: #{@loop.tool_calls.size} (#{unanswered_count} unanswered)",
44
+ "",
45
+ ascii_header
46
+ ]
47
+ @loop.round_trips.each { |trip| lines.concat(ascii_round_trip(trip)) }
48
+ lines << "" << "ending: #{@loop.ending} (inferred) — #{@loop.ending_detail}"
49
+ if @loop.warnings.any?
50
+ lines << "" << "warnings:"
51
+ @loop.warnings.each { |warning| lines << " - #{warning}" }
52
+ end
53
+ "#{lines.join("\n")}\n"
54
+ end
55
+
56
+ def markdown
57
+ lines = ["# session #{@loop.session.uid}", "",
58
+ "- round trips: #{@loop.round_trips.size} (grouping: #{grouping})",
59
+ "- tool calls: #{@loop.tool_calls.size} (#{unanswered_count} unanswered)",
60
+ "", "## Round trips", ""]
61
+ @loop.round_trips.each { |trip| lines << markdown_round_trip(trip) }
62
+ lines << "" << "## Tool calls" << ""
63
+ lines.concat(markdown_tool_calls_table)
64
+ lines << "" << "## Ending" << "" << "#{@loop.ending} (inferred) — #{@loop.ending_detail}"
65
+ if @loop.warnings.any?
66
+ lines << "" << "## Warnings" << ""
67
+ @loop.warnings.each { |warning| lines << "- #{warning}" }
68
+ end
69
+ "#{lines.join("\n")}\n"
70
+ end
71
+
72
+ def to_h
73
+ {
74
+ session: { agent: @loop.session.agent, id: @loop.session.id, uid: @loop.session.uid },
75
+ round_trips: @loop.round_trips.map { |trip| round_trip_h(trip) },
76
+ tool_calls: @loop.tool_calls.map { |call| tool_call_h(call) },
77
+ ending: { name: @loop.ending, detail: @loop.ending_detail, inferred: true },
78
+ recorded: @loop.recorded,
79
+ warnings: @loop.warnings
80
+ }
81
+ end
82
+
83
+ private
84
+
85
+ def lane(text, column) = (" " * column) + text
86
+
87
+ # The only "size" a body may leak as: a count, never the bytes themselves.
88
+ def size_of(part) = part.text.to_s.bytesize
89
+
90
+ # UTC, never local: local time would make one recorded instant print
91
+ # two different strings depending on which machine reads the file,
92
+ # which breaks the determinism R8 requires.
93
+ def utc(time) = time&.utc&.iso8601
94
+
95
+ # Counts, never a bare "recorded"/"assumed". Loop#recorded is
96
+ # round_trips.all?(&:recorded), so ONE assumed group collapses the
97
+ # whole answer to false — and on Claude, the one format that does
98
+ # name its groups, every user and harness turn carries no message.id
99
+ # and is assumed by construction. A real session therefore reports
100
+ # false while 54 of its 109 groups were in fact named by the store,
101
+ # and printing that as the single word "assumed" tells the reader
102
+ # this format records nothing. That is the confusion the gem's
103
+ # standing rule forbids: "the store does not record this" must never
104
+ # read the same as "the store recorded none here", in either
105
+ # direction. So say how many, and let the two cases differ visibly.
106
+ def grouping
107
+ named = @loop.round_trips.count(&:recorded)
108
+ return "no round trips" if @loop.round_trips.empty?
109
+ return "all #{named} named by the store" if named == @loop.round_trips.size
110
+ return "none named by the store; one round trip per message" if named.zero?
111
+
112
+ "#{named} of #{@loop.round_trips.size} named by the store"
113
+ end
114
+
115
+ def unanswered_count = @loop.tool_calls.count { |call| !call.answered? }
116
+
117
+ def ascii_header
118
+ line = "#{lane("YOU", YOU_LANE).ljust(HARNESS_LANE)}HARNESS"
119
+ "#{line.ljust(MODEL_LANE)}MODEL"
120
+ end
121
+
122
+ def ascii_round_trip(trip)
123
+ case @loop.speakers[trip.index]
124
+ when :person then [ascii_person(trip)]
125
+ when :model then ascii_model(trip)
126
+ when :harness then ascii_harness(trip)
127
+ when :event then [lane("#{trip.index} [event] #{event_labels(trip)}", HARNESS_LANE)]
128
+ else [lane("#{trip.index} [unrecognized record]", HARNESS_LANE)]
129
+ end
130
+ end
131
+
132
+ def ascii_person(trip)
133
+ bytes = trip.parts.sum { |part| size_of(part) }
134
+ lane("#{trip.index} prompt #{bytes} B", YOU_LANE)
135
+ end
136
+
137
+ def ascii_model(trip)
138
+ lines = [lane("#{trip.index} == round trip ==>", HARNESS_LANE)]
139
+ trip.parts.each { |part| lines << lane(part_line(part), MODEL_LANE) }
140
+ lines << lane(usage_line(trip.usage), MODEL_LANE) if trip.usage
141
+ verdict = trip.calls.any? ? "tool_use: keep looping" : "no tool_use: exit"
142
+ lines << lane("#{trip.index} <== #{verdict}", HARNESS_LANE)
143
+ end
144
+
145
+ def ascii_harness(trip)
146
+ trip.parts.select { |part| part.type == :tool_result }.map do |part|
147
+ lane("#{trip.index} <- tool_result #{tool_name_for(part.call_id)} #{size_of(part)} B " \
148
+ "(#{part.call_id || "no call id"})", HARNESS_LANE)
149
+ end
150
+ end
151
+
152
+ # T4: how ONE content part is shown, inside any round trip that
153
+ # prints its parts one by one. tool_use gets its name plus the size
154
+ # of what it asked for; text/thinking/tool_result get a bare size;
155
+ # image/unknown get only their type name, because the gem never
156
+ # loads those payloads and so has no size worth calling meaningful.
157
+ def part_line(part)
158
+ case part.type
159
+ when :tool_use then "tool_use #{part.name} #{size_of(part)} B"
160
+ when :tool_result then "tool_result #{size_of(part)} B"
161
+ when :text then "text #{size_of(part)} B"
162
+ when :thinking then "thinking #{size_of(part)} B"
163
+ else part.type.to_s
164
+ end
165
+ end
166
+
167
+ def usage_line(usage) = "usage #{usage_fields(usage).join(" ")}"
168
+
169
+ def usage_fields(usage)
170
+ { "in" => usage.input, "out" => usage.output, "cache_read" => usage.cache_read,
171
+ "cache_creation" => usage.cache_creation, "reasoning" => usage.reasoning }
172
+ .compact
173
+ .map { |key, value| "#{key}=#{value}" }
174
+ end
175
+
176
+ # `?` here means exactly what it means everywhere else in this gem:
177
+ # the fact is not knowable from what was recorded, never zero.
178
+ def tool_name_for(call_id)
179
+ return "?" if call_id.nil?
180
+
181
+ @loop.tool_calls.find { |call| call.call_id == call_id }&.name || "?"
182
+ end
183
+
184
+ # R11: a part's text prints as a label only when it is label-shaped
185
+ # (a record TYPE name); anything else — including a stray sentence a
186
+ # future format might put in this free-String field — prints the
187
+ # part's own type name instead, so R9 (no bodies) cannot be broken
188
+ # by a format that starts putting prose where a type name belongs.
189
+ def event_labels(trip)
190
+ trip.parts.map { |part| label_shaped?(part.text) ? part.text : part.type.to_s }.join(", ")
191
+ end
192
+
193
+ def label_shaped?(text) = text.is_a?(String) && LABEL_SHAPE.match?(text)
194
+
195
+ def markdown_round_trip(trip)
196
+ speaker = @loop.speakers[trip.index]
197
+ parts = speaker == :event ? event_labels(trip) : markdown_parts(trip)
198
+ entry = "#{trip.index}. **#{speaker}** — #{parts}"
199
+ at = utc(trip.at)
200
+ entry += " — at #{at}" if at
201
+ entry += " — #{usage_line(trip.usage)}" if trip.usage
202
+ entry
203
+ end
204
+
205
+ def markdown_parts(trip)
206
+ return "(no parts)" if trip.parts.empty?
207
+
208
+ trip.parts.map { |part| part_line(part) }.join("; ")
209
+ end
210
+
211
+ def markdown_tool_calls_table
212
+ [
213
+ "| tool | call id | input | result | asked in | answered in |",
214
+ "|---|---|---|---|---|---|",
215
+ *@loop.tool_calls.map { |call| markdown_tool_call_row(call) }
216
+ ]
217
+ end
218
+
219
+ def markdown_tool_call_row(call)
220
+ # An unanswered call must read as "no answer recorded", never
221
+ # "0 B" — the same distinction ToolCall#result_bytes itself draws,
222
+ # repeated here because nil-to-string interpolation would
223
+ # otherwise silently print an empty cell instead of saying so.
224
+ result = call.answered? ? "#{call.result_bytes} B" : "no answer recorded"
225
+ "| #{call.name} | #{call.call_id || "no call id"} | #{call.input_bytes} B | #{result} | " \
226
+ "#{call.asked_in} | #{call.answered_in || "-"} |"
227
+ end
228
+
229
+ def round_trip_h(trip)
230
+ { index: trip.index, speaker: @loop.speakers[trip.index], recorded: trip.recorded,
231
+ roles: trip.roles, at: utc(trip.at), model: trip.model, usage: trip.usage&.to_h,
232
+ parts: trip.parts.map { |part| part_h(part) } }
233
+ end
234
+
235
+ # name is populated for :tool_use alone (the tool's own name); every
236
+ # other type — including :image/:unknown — carries nil, since a
237
+ # size is not a body but any other free-text field on the part
238
+ # would be.
239
+ def part_h(part) = { type: part.type, name: part.type == :tool_use ? part.name : nil, bytes: size_of(part) }
240
+
241
+ def tool_call_h(call)
242
+ { name: call.name, call_id: call.call_id, input_bytes: call.input_bytes,
243
+ result_bytes: call.result_bytes, asked_in: call.asked_in, answered_in: call.answered_in,
244
+ answered: call.answered? }
245
+ end
246
+ end
247
+ end
248
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module SessionContext
5
+ Prompt = Data.define(:index, :at, :text, :source_refs) do
6
+ def initialize(index:, at:, text:, source_refs:)
7
+ super(
8
+ index: normalize_positive_index(index),
9
+ at: at,
10
+ text: normalize_string(text, :text),
11
+ source_refs: ImmutableValue.copy(Array(source_refs))
12
+ )
13
+ end
14
+
15
+ private
16
+
17
+ def normalize_positive_index(value)
18
+ index =
19
+ if value.is_a?(Integer)
20
+ value
21
+ elsif value.respond_to?(:to_int)
22
+ value.to_int
23
+ elsif value.is_a?(String)
24
+ Integer(value, exception: false)
25
+ end
26
+
27
+ raise TypeError, "index must be an Integer or integer-like value" if index.nil?
28
+ raise ArgumentError, "index must be greater than or equal to 1" if index < 1
29
+
30
+ index
31
+ end
32
+
33
+ def normalize_string(value, name)
34
+ raise TypeError, "#{name} must be a String" unless value.respond_to?(:to_str)
35
+
36
+ String.new(value.to_str).freeze
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module SessionContext
5
+ class PromptExtractor
6
+ def call(transcript)
7
+ prompt_index = 0
8
+
9
+ prompts = transcript.entries.each_with_object([]) do |entry, collected|
10
+ next unless entry.role == :user
11
+
12
+ contributing_parts = entry.parts.select { |part| part.type == :text && !part.injected }
13
+ next if contributing_parts.empty?
14
+
15
+ text = contributing_parts.map(&:text).join
16
+ next if text.empty?
17
+
18
+ prompt_index += 1
19
+ collected << Prompt.new(
20
+ index: prompt_index,
21
+ at: entry.at,
22
+ text: text,
23
+ source_refs: contributing_parts.map(&:source_ref)
24
+ )
25
+ end
26
+
27
+ prompts.freeze
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module SessionContext
5
+ module Renderers
6
+ module HumanDisplay
7
+ module_function
8
+
9
+ def text_inline(value)
10
+ sanitize_string(value, preserve_newlines: false)
11
+ end
12
+
13
+ def text_block(value)
14
+ sanitize_string(value, preserve_newlines: true).split("\n", -1).map { |line| "| #{line}" }.join("\n")
15
+ end
16
+
17
+ def markdown_text(value)
18
+ escape_markdown(text_inline(value))
19
+ end
20
+
21
+ def markdown_literal(value)
22
+ literal = text_inline(value)
23
+ return "<code></code>" if literal.empty?
24
+ return "<code>#{literal}</code>" if literal.match?(/\A +\z/)
25
+
26
+ fence = "`" * [longest_backtick_run(literal) + 1, 1].max
27
+ "#{fence}#{code_span_content(literal)}#{fence}"
28
+ end
29
+
30
+ def markdown_block(value)
31
+ content = sanitize_string(value, preserve_newlines: true)
32
+ fence = "`" * [longest_backtick_run(content) + 1, 3].max
33
+
34
+ ["#{fence}text", content, fence].join("\n")
35
+ end
36
+
37
+ def timestamp(value)
38
+ serialized = Serializer.serialize_timestamp(value)
39
+ return if serialized.nil?
40
+
41
+ serialized.to_s
42
+ end
43
+
44
+ def refs(source_refs)
45
+ Array(source_refs).map { |ref| "#{ref.message_index}:#{ref.part_index}" }.join(", ")
46
+ end
47
+
48
+ def attributes(hash, inline_formatter:)
49
+ Serializer.normalized_hash_entries(hash).map do |key, value|
50
+ "#{inline_formatter.call(key)}=#{attribute_value(value, inline_formatter)}"
51
+ end.join(", ")
52
+ end
53
+
54
+ def attribute_value(value, inline_formatter)
55
+ return "{#{attributes(value, inline_formatter:)}}" if value.is_a?(Hash)
56
+ return value.map { |entry| attribute_value(entry, inline_formatter) }.join(",") if value.is_a?(Array)
57
+
58
+ inline_formatter.call(Serializer.serialize(value).to_s)
59
+ end
60
+
61
+ def sanitize_string(value, preserve_newlines:)
62
+ scrubbed = Serializer.scrub_string(value.to_s)
63
+ buffer = String.new(encoding: Encoding::UTF_8)
64
+
65
+ scrubbed.each_codepoint do |codepoint|
66
+ if preserve_newlines && codepoint == 0x0A
67
+ buffer << "\n"
68
+ next
69
+ end
70
+
71
+ visible = visible_control_escape(codepoint)
72
+ buffer << (visible || codepoint)
73
+ end
74
+
75
+ buffer
76
+ end
77
+
78
+ def escape_markdown(value)
79
+ escaped = value.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;")
80
+ escaped = escaped.gsub(/([\\`*\[\]#])/, "\\\\\\1")
81
+ escaped = escaped.gsub(/(^|[^[:alnum:]])_([^_]+)_([^[:alnum:]]|$)/, '\1\\_\2\\_\3')
82
+ escaped.gsub("-", "\\-")
83
+ end
84
+
85
+ def code_span_content(literal)
86
+ return " #{literal} " if literal.start_with?("`") || literal.end_with?("`")
87
+ return " #{literal} " if literal.start_with?(" ") && literal.end_with?(" ")
88
+
89
+ literal
90
+ end
91
+
92
+ def longest_backtick_run(value)
93
+ value.scan(/`+/).map(&:length).max || 0
94
+ end
95
+
96
+ def visible_control_escape(codepoint)
97
+ case codepoint
98
+ when 0x09
99
+ "\\t"
100
+ when 0x0A
101
+ "\\n"
102
+ when 0x0D
103
+ "\\r"
104
+ when 0x1B
105
+ "\\e"
106
+ when 0x00..0x08, 0x0B..0x0C, 0x0E..0x1A, 0x1C..0x1F, 0x7F, 0x80..0x9F
107
+ format("\\u%04X", codepoint)
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module SessionContext
5
+ module Renderers
6
+ class JSON
7
+ def call(value)
8
+ # A Loop must never reach Serializer.serialize directly: Serializer
9
+ # walks any Data object generically by its members, and Loop holds
10
+ # round_trips -> Message#raw, the full on-disk record with every
11
+ # prompt and tool-result body. LoopView#to_h is the Hash that
12
+ # already strips bodies down to sizes and tool names — serialize
13
+ # THAT, never the Loop itself.
14
+ return ::JSON.generate(Serializer.serialize(LoopView.new(value).to_h)) if value.is_a?(Loop)
15
+
16
+ ::JSON.generate(Serializer.serialize(value))
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agent
4
+ module SessionContext
5
+ module Renderers
6
+ class JSONLines
7
+ def call(value)
8
+ # Same privacy rule as Renderers::JSON: never hand the Loop itself
9
+ # (or its round trips, which carry Message#raw) to Serializer. One
10
+ # line per round trip, from the already-stripped LoopView Hash.
11
+ return loop_lines(value) if value.is_a?(Loop)
12
+
13
+ Array(value).map { |prompt| ::JSON.generate(Serializer.serialize(prompt)) }.join("\n")
14
+ end
15
+
16
+ private
17
+
18
+ def loop_lines(loop_model)
19
+ round_trips = LoopView.new(loop_model).to_h.fetch(:round_trips)
20
+ round_trips.map { |round_trip| ::JSON.generate(Serializer.serialize(round_trip)) }.join("\n")
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end