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,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("&", "&").gsub("<", "<").gsub(">", ">")
|
|
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
|