Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion actionagent/app/assets/builds/action_agent.css

Large diffs are not rendered by default.

86 changes: 43 additions & 43 deletions actionagent/app/assets/builds/action_agent.js

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,15 @@ def index
latest_models = AgentGeneration.where(id: newest_ids.values)
.pluck(:agent_context_id, :model).to_h

previews = message_previews(contexts.map(&:id))

persisted = contexts.map do |context|
serialize_context(context).merge(
source: "platform",
model: latest_models[context.id],
message_count: message_counts[context.id] || 0,
generation_count: generation_counts[context.id] || 0
generation_count: generation_counts[context.id] || 0,
preview: previews[context.id] || { input: nil, output: nil }
)
end

Expand Down Expand Up @@ -75,6 +78,38 @@ def show

private

# What each stream opened with and what it finally answered, so a
# collapsed interaction row says what it was about — the same two lines a
# collapsed trace row shows. Two grouped queries rather than loading
# every message: the list is fifty streams deep and only needs the ends
# of each.
def message_previews(context_ids)
return {} if context_ids.empty?

first_input = edge_messages(context_ids, "user", :minimum)
last_output = edge_messages(context_ids, "assistant", :maximum)

context_ids.index_with do |id|
{
input: InteractionPreview.line(first_input[id]),
output: InteractionPreview.line(last_output[id])
}
end
end

# The first or last message of a role per context. Messages are only ever
# appended, so the smallest and largest ids are the ends of the stream —
# which is the portable form of the DISTINCT ON this would be on
# PostgreSQL, and the same shape the latest-model lookup above uses.
def edge_messages(context_ids, role, edge)
scope = AgentMessage
.where(agent_context_id: context_ids, role: role)
.where.not(content: [ nil, "" ])

ids = scope.group(:agent_context_id).public_send(edge, :id)
AgentMessage.where(id: ids.values).pluck(:agent_context_id, :content).to_h
end

# Agents executing outside the platform never write solid_agent contexts —
# they only report traces. Every reported trace is an interaction: one run
# of one agent. Traces without captured content still show their tool calls,
Expand Down
22 changes: 22 additions & 0 deletions actionagent/app/serializers/action_agent/interaction_preview.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# frozen_string_literal: true

module ActionAgent
# The two lines a collapsed interaction row shows — what the stream was
# asked, and what it finally answered. Both sources of an interaction produce
# them: solid_agent contexts (Api::InteractionsController) and reported
# traces (TraceInteractionSerializer), so a list mixing the two reads the
# same way either side of the row came from.
module InteractionPreview
# One line each. The list is fifty streams deep, and the row clips to a
# single line anyway — sending the whole conversation to be truncated in
# the browser would pay for text nobody reads.
LIMIT = 300

def self.line(value)
text = value.to_s.squish
return nil if text.blank?

text.truncate(LIMIT)
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ def summary
total: @trace.total_input_tokens.to_i + @trace.total_output_tokens.to_i
},
message_count: messages.size,
preview: preview,
# Tool activity is known even when content capture is off, so a run
# reported without prompts still shows what it did.
tool_count: tool_spans.size,
Expand All @@ -65,6 +66,19 @@ def detail

private

# What the run was asked and what it answered — the opening prompt and the
# final assistant turn, since the middle of a stream is tool traffic.
def preview
said = ->(role, list) { list.find { |m| m[:role] == role && m[:content].present? }&.[](:content) }

{
input: InteractionPreview.line(said.call("user", messages)),
# A tool-calling assistant turn carries no prose, so the last one that
# does is the answer.
output: InteractionPreview.line(said.call("assistant", messages.reverse))
}
end

# The generation span. Older traces wrapped a separate `llm` span inside a
# root; newer ones merge them, since the provider loop *is* the interaction.
# Accept both, and never count the same generation twice.
Expand Down Expand Up @@ -96,9 +110,9 @@ def outbound_messages
raw = any_attribute("prompt.input.messages")
return [] if raw.blank?

entries = JSON.parse(raw)
entries = raw.is_a?(String) ? JSON.parse(raw) : raw
entries.is_a?(Array) ? entries.select { |m| m.is_a?(Hash) && m["content"].present? } : []
rescue JSON::ParserError
rescue JSON::ParserError, TypeError
[]
end

Expand Down Expand Up @@ -209,9 +223,13 @@ def generations
# so it renders structurally rather than as an escaped string.
def parsed(value)
return nil if value.blank?
# A reporter can send an attribute already decoded — JSON.parse raises
# TypeError rather than ParserError on a Hash or Array, and one such
# span would otherwise take down the whole Interactions list.
return value unless value.is_a?(String)

JSON.parse(value)
rescue JSON::ParserError
rescue JSON::ParserError, TypeError
value
end

Expand Down
Loading