actionagent 1.8.0 → 1.9.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 +4 -4
- data/app/assets/builds/action_agent.css +1 -1
- data/app/assets/builds/action_agent.js +76 -73
- data/app/assets/builds/action_agent_recorder.js +78 -0
- data/app/assets/builds/action_agent_replay.js +226 -0
- data/app/controllers/action_agent/api/agent_runs_controller.rb +3 -1
- data/app/controllers/action_agent/api/agents_controller.rb +1 -0
- data/app/controllers/action_agent/api/api_keys_controller.rb +12 -2
- data/app/controllers/action_agent/api/base_controller.rb +63 -2
- data/app/controllers/action_agent/api/claude_logins_controller.rb +93 -0
- data/app/controllers/action_agent/api/code_sessions_controller.rb +71 -12
- data/app/controllers/action_agent/api/dashboard_assistant_controller.rb +2 -2
- data/app/controllers/action_agent/api/draft_pull_requests_controller.rb +248 -0
- data/app/controllers/action_agent/api/evaluation_fixes_controller.rb +27 -0
- data/app/controllers/action_agent/api/evaluations_controller.rb +58 -7
- data/app/controllers/action_agent/api/explorations_controller.rb +213 -0
- data/app/controllers/action_agent/api/github_app_manifests_controller.rb +125 -0
- data/app/controllers/action_agent/api/github_connections_controller.rb +40 -2
- data/app/controllers/action_agent/api/github_installations_controller.rb +280 -0
- data/app/controllers/action_agent/api/input_requests_controller.rb +77 -0
- data/app/controllers/action_agent/api/mcp_controller.rb +46 -12
- data/app/controllers/action_agent/api/members_controller.rb +36 -0
- data/app/controllers/action_agent/api/project_explorations_controller.rb +80 -0
- data/app/controllers/action_agent/api/project_install_pull_requests_controller.rb +305 -0
- data/app/controllers/action_agent/api/project_secrets_controller.rb +96 -0
- data/app/controllers/action_agent/api/project_setup_controller.rb +151 -0
- data/app/controllers/action_agent/api/project_sign_ins_controller.rb +112 -0
- data/app/controllers/action_agent/api/projects_controller.rb +512 -0
- data/app/controllers/action_agent/api/provider_keys_controller.rb +176 -24
- data/app/controllers/action_agent/api/provider_models_controller.rb +37 -15
- data/app/controllers/action_agent/api/recording_event_ingest_controller.rb +67 -0
- data/app/controllers/action_agent/api/sandbox_browser_tickets_controller.rb +50 -0
- data/app/controllers/action_agent/api/sandbox_browsers_controller.rb +65 -0
- data/app/controllers/action_agent/api/sandboxes_controller.rb +148 -8
- data/app/controllers/action_agent/api/scenario_catalog_entries_controller.rb +188 -0
- data/app/controllers/action_agent/api/scenario_catalogs_controller.rb +302 -0
- data/app/controllers/action_agent/api/session_recordings_controller.rb +165 -34
- data/app/controllers/action_agent/api/sessions_controller.rb +111 -0
- data/app/controllers/action_agent/api/telemetry_keys_controller.rb +21 -0
- data/app/controllers/action_agent/dashboard_controller.rb +17 -6
- data/app/controllers/action_agent/session_player_controller.rb +58 -0
- data/app/controllers/concerns/action_agent/api/agent_serialization.rb +1 -0
- data/app/controllers/concerns/action_agent/api/mcp_dashboard_tools.rb +627 -20
- data/app/controllers/concerns/action_agent/api/project_browser.rb +46 -0
- data/app/controllers/concerns/action_agent/api/project_secret_authorization.rb +57 -0
- data/app/jobs/action_agent/agent_execution_job.rb +9 -39
- data/app/jobs/action_agent/agent_resume_job.rb +84 -0
- data/app/jobs/action_agent/code_session_job.rb +39 -10
- data/app/jobs/action_agent/draft_pull_request_job.rb +43 -0
- data/app/jobs/action_agent/exploration_job.rb +80 -0
- data/app/jobs/action_agent/input_request_expiry_job.rb +22 -0
- data/app/jobs/action_agent/project_evaluation_job.rb +59 -0
- data/app/jobs/action_agent/project_setup_job.rb +21 -0
- data/app/jobs/action_agent/sandbox_cleanup_job.rb +20 -3
- data/app/jobs/action_agent/sandbox_provision_job.rb +77 -10
- data/app/jobs/action_agent/sandbox_run_job.rb +9 -37
- data/app/jobs/action_agent/verify_evaluation_fix_job.rb +37 -0
- data/app/models/action_agent/agent.rb +72 -26
- data/app/models/action_agent/agent_run.rb +134 -23
- data/app/models/action_agent/agent_template.rb +35 -1
- data/app/models/action_agent/api_key.rb +14 -0
- data/app/models/action_agent/catalog_scenario.rb +75 -0
- data/app/models/action_agent/code_session.rb +21 -5
- data/app/models/action_agent/draft_pull_request.rb +131 -0
- data/app/models/action_agent/evaluation.rb +148 -0
- data/app/models/action_agent/evaluation_run.rb +143 -53
- data/app/models/action_agent/evaluation_scenario_result.rb +15 -3
- data/app/models/action_agent/exploration.rb +847 -0
- data/app/models/action_agent/github_installation.rb +159 -0
- data/app/models/action_agent/input_request.rb +291 -0
- data/app/models/action_agent/model_pricing.rb +214 -34
- data/app/models/action_agent/project.rb +789 -0
- data/app/models/action_agent/project_secret.rb +339 -0
- data/app/models/action_agent/provider_key.rb +207 -4
- data/app/models/action_agent/recording_event.rb +145 -0
- data/app/models/action_agent/recording_snapshot.rb +7 -7
- data/app/models/action_agent/sandbox_session.rb +300 -16
- data/app/models/action_agent/scenario_catalog.rb +172 -0
- data/app/models/action_agent/scenario_product.rb +57 -0
- data/app/models/action_agent/scenario_set.rb +138 -0
- data/app/models/action_agent/session_recording.rb +121 -14
- data/app/models/action_agent/telemetry_trace.rb +74 -19
- data/app/models/concerns/action_agent/ownable.rb +84 -13
- data/app/serializers/action_agent/evaluation_serializer.rb +41 -10
- data/app/serializers/action_agent/input_request_serializer.rb +47 -0
- data/app/serializers/action_agent/scenario_catalog_serializer.rb +23 -0
- data/app/serializers/action_agent/session_summary_serializer.rb +146 -0
- data/app/services/action_agent/agent_execution_service.rb +450 -72
- data/app/services/action_agent/agent_scorecard.rb +50 -21
- data/app/services/action_agent/agent_sync.rb +5 -2
- data/app/services/action_agent/agent_tool_roster.rb +19 -4
- data/app/services/action_agent/agent_toolbox.rb +215 -9
- data/app/services/action_agent/browser_live_ticket.rb +78 -0
- data/app/services/action_agent/browser_sidecar.rb +213 -0
- data/app/services/action_agent/browser_sign_in.rb +163 -0
- data/app/services/action_agent/browser_tool_redaction.rb +92 -0
- data/app/services/action_agent/claude_code_auth.rb +70 -4
- data/app/services/action_agent/codex_session_events.rb +37 -0
- data/app/services/action_agent/dashboard_assistant_service.rb +24 -10
- data/app/services/action_agent/draft_pull_request_publisher.rb +719 -0
- data/app/services/action_agent/evaluation_fix.rb +101 -0
- data/app/services/action_agent/evaluation_fix_comparison.rb +66 -0
- data/app/services/action_agent/evaluation_report_import.rb +84 -5
- data/app/services/action_agent/evaluation_run_cost.rb +609 -0
- data/app/services/action_agent/evaluation_runner_service.rb +29 -10
- data/app/services/action_agent/evaluation_standing.rb +152 -0
- data/app/services/action_agent/explorer_execution_service.rb +517 -0
- data/app/services/action_agent/github_client.rb +405 -64
- data/app/services/action_agent/live_updates.rb +31 -0
- data/app/services/action_agent/local_sandbox_backend.rb +1263 -102
- data/app/services/action_agent/local_sandbox_claude_login.rb +151 -0
- data/app/services/action_agent/mcp_client.rb +6 -2
- data/app/services/action_agent/mcp_recording_middleware.rb +104 -145
- data/app/services/action_agent/mcp_tool_dispatcher.rb +36 -13
- data/app/services/action_agent/mock_sandbox_backend.rb +3 -1
- data/app/services/action_agent/playwright_mcp_client.rb +7 -0
- data/app/services/action_agent/project_boot_fix.rb +309 -0
- data/app/services/action_agent/project_capabilities.rb +200 -0
- data/app/services/action_agent/project_install_pull_request.rb +225 -0
- data/app/services/action_agent/project_preflight.rb +178 -0
- data/app/services/action_agent/project_secret_discovery.rb +142 -0
- data/app/services/action_agent/project_setup.rb +429 -0
- data/app/services/action_agent/provider_credentials.rb +102 -0
- data/app/services/action_agent/recording_event_ingest.rb +181 -0
- data/app/services/action_agent/runtime_tool_roster.rb +52 -0
- data/app/services/action_agent/sample_data/conference_ticket.rb +516 -0
- data/app/services/action_agent/sandbox_boot_spec.rb +558 -0
- data/app/services/action_agent/sandbox_browser.rb +299 -0
- data/app/services/action_agent/sandbox_browser_driver.rb +85 -0
- data/app/services/action_agent/sandbox_credential_paths.rb +26 -0
- data/app/services/action_agent/sandbox_mail.rb +103 -0
- data/app/services/action_agent/sandbox_manifest.rb +166 -13
- data/app/services/action_agent/sandbox_orchestrator.rb +290 -23
- data/app/services/action_agent/sandbox_patch.rb +253 -0
- data/app/services/action_agent/scenario_catalog_import.rb +255 -0
- data/app/services/action_agent/scenario_catalog_repository_import.rb +81 -0
- data/app/services/action_agent/scenario_evaluation_runner.rb +121 -17
- data/app/services/action_agent/secret_scrubber.rb +33 -4
- data/app/services/action_agent/session_index.rb +270 -0
- data/app/services/action_agent/session_recording_service.rb +11 -0
- data/app/services/action_agent/session_timeline.rb +505 -0
- data/app/views/action_agent/github_app_manifests/created.html.erb +55 -0
- data/app/views/action_agent/session_player/show.html.erb +19 -0
- data/config/routes.rb +153 -2
- data/lib/action_agent/claude_login_process.rb +107 -0
- data/lib/action_agent/engine.rb +55 -5
- data/lib/action_agent/secret_requests.rb +80 -0
- data/lib/action_agent/version.rb +1 -1
- data/lib/action_agent.rb +513 -22
- data/lib/generators/action_agent/install_generator.rb +45 -5
- data/lib/generators/action_agent/templates/action_agent.rb.erb +161 -0
- data/lib/generators/action_agent/templates/add_code_session_runner.rb.erb +9 -0
- data/lib/generators/action_agent/templates/migrations/015_add_provider_key_scope.rb.erb +72 -0
- data/lib/generators/action_agent/templates/migrations/016_create_active_agent_input_requests.rb.erb +85 -0
- data/lib/generators/action_agent/templates/migrations/017_create_active_agent_recording_events.rb.erb +57 -0
- data/lib/generators/action_agent/templates/migrations/018_add_browser_to_active_agent_sandbox_sessions.rb.erb +25 -0
- data/lib/generators/action_agent/templates/migrations/019_create_active_agent_projects.rb.erb +79 -0
- data/lib/generators/action_agent/templates/migrations/020_add_github_app_installations.rb.erb +62 -0
- data/lib/generators/action_agent/templates/migrations/021_create_active_agent_explorations.rb.erb +63 -0
- data/lib/generators/action_agent/templates/migrations/023_create_active_agent_draft_pull_requests.rb.erb +75 -0
- data/lib/generators/action_agent/templates/migrations/024_add_kind_to_active_agent_project_secrets.rb.erb +14 -0
- data/lib/generators/action_agent/templates/migrations/025_create_active_agent_scenario_catalogs.rb.erb +110 -0
- data/lib/generators/action_agent/templates/migrations/026_add_evaluation_fixes_to_code_sessions.rb.erb +21 -0
- data/lib/tasks/action_agent.rake +33 -0
- data/lib/tasks/browser.rake +22 -0
- data/lib/tasks/sandbox.rake +1 -1
- data/lib/tasks/scenario_catalogs.rake +71 -0
- metadata +100 -2
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionAgent
|
|
4
|
+
# Used to build the timeline of a session: a conversation, a run or a
|
|
5
|
+
# recording, with what happened in it laid out in lanes on one time axis.
|
|
6
|
+
#
|
|
7
|
+
# Lanes:
|
|
8
|
+
# message the conversation's messages
|
|
9
|
+
# llm model calls: the trace's llm spans when the trace is stored,
|
|
10
|
+
# else the run's llm events (agent_runs.logs), else generations
|
|
11
|
+
# tool tool calls: the trace's tool spans when the trace is stored,
|
|
12
|
+
# else the run's tool events, else tool messages
|
|
13
|
+
# browser what the session's recordings captured: recording events
|
|
14
|
+
# other than rrweb, and the legacy recording actions
|
|
15
|
+
#
|
|
16
|
+
# Every entry has an `id`, its `lane`, an absolute `start` (ISO 8601 with
|
|
17
|
+
# milliseconds), a `duration_ms` and the `trace_id` it belongs to, nil when
|
|
18
|
+
# unknown. Each lane is in time order. rrweb events are not in the
|
|
19
|
+
# timeline: they are read page by page from the recording's events
|
|
20
|
+
# endpoint, and `recordings` says how many there are.
|
|
21
|
+
#
|
|
22
|
+
# A session reaches its runs, conversations and traces only through ids
|
|
23
|
+
# the server set (a recording's agent_run_id and agent_context_id, a run's
|
|
24
|
+
# trace_id), each looked up within the caller's Scope. Ids inside event
|
|
25
|
+
# payloads are shown, never followed. Browser tool calls are shown with
|
|
26
|
+
# their typed values masked (BrowserToolRedaction), and no entry carries
|
|
27
|
+
# SessionRecording::SENSITIVE_STATE_KEYS.
|
|
28
|
+
class SessionTimeline
|
|
29
|
+
# What the caller may reach:
|
|
30
|
+
# agents the caller's agents (Api::BaseController#owner_agents)
|
|
31
|
+
# traces the caller's telemetry traces
|
|
32
|
+
# recordings the recordings the caller may open
|
|
33
|
+
Scope = Struct.new(:agents, :traces, :recordings, keyword_init: true)
|
|
34
|
+
|
|
35
|
+
TEXT_LIMIT = 4000
|
|
36
|
+
LANE_LIMIT = 2000
|
|
37
|
+
# Recording event rows loaded per query while the browser lane is filled.
|
|
38
|
+
BROWSER_ROWS_PER_QUERY = 20
|
|
39
|
+
RUN_LOG_KINDS = { "llm" => %w[llm], "tool" => %w[tool agent] }.freeze
|
|
40
|
+
# What the llm lane reads from a generation; its raw response is not.
|
|
41
|
+
GENERATION_COLUMNS = %i[id agent_context_id trace_id model provider finish_reason input_tokens output_tokens
|
|
42
|
+
duration_seconds created_at].freeze
|
|
43
|
+
|
|
44
|
+
# The timeline of +context+, an AgentContext the caller owns: its
|
|
45
|
+
# messages, the runs and traces of its generations, and the recordings
|
|
46
|
+
# linked to the conversation or to one of those runs.
|
|
47
|
+
def self.for_context(context, scope, recordings: nil)
|
|
48
|
+
trace_ids = context.generations.where.not(trace_id: [ nil, "" ]).distinct.pluck(:trace_id)
|
|
49
|
+
runs = trace_ids.any? ? AgentRun.where(agent: scope.agents, trace_id: trace_ids).to_a : []
|
|
50
|
+
recordings ||= scope.recordings.where(agent_context_id: context.id)
|
|
51
|
+
.or(scope.recordings.where(agent_run_id: runs.map(&:id)))
|
|
52
|
+
|
|
53
|
+
new(
|
|
54
|
+
scope, kind: "context", id: context.id,
|
|
55
|
+
contexts: [ context ], runs: runs, trace_ids: trace_ids,
|
|
56
|
+
messages: context.messages.chronological.to_a,
|
|
57
|
+
generations: context.generations.select(*GENERATION_COLUMNS).to_a,
|
|
58
|
+
recordings: recordings
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# The timeline of +run+, an AgentRun the caller owns: its trace, its
|
|
63
|
+
# slice of the conversations its generations were written to, and the
|
|
64
|
+
# recordings linked to it.
|
|
65
|
+
def self.for_run(run, scope, recordings: nil)
|
|
66
|
+
contexts = AgentContext.for_agents(scope.agents)
|
|
67
|
+
.where(id: AgentGeneration.where(trace_id: run.trace_id).select(:agent_context_id)).to_a
|
|
68
|
+
messages = contexts.flat_map { |context| run_slice(context.messages.chronological.to_a, run) }
|
|
69
|
+
|
|
70
|
+
new(
|
|
71
|
+
scope, kind: "run", id: run.id,
|
|
72
|
+
contexts: contexts, runs: [ run ], trace_ids: [ run.trace_id ],
|
|
73
|
+
messages: messages,
|
|
74
|
+
generations: AgentGeneration.where(trace_id: run.trace_id, agent_context_id: contexts.map(&:id))
|
|
75
|
+
.select(*GENERATION_COLUMNS).to_a,
|
|
76
|
+
recordings: recordings || scope.recordings.where(agent_run_id: run.id)
|
|
77
|
+
)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# The timeline of +recording+, one the caller may open: its own browser
|
|
81
|
+
# lane, with the lanes of its conversation when it has one the caller
|
|
82
|
+
# owns, else of its run.
|
|
83
|
+
def self.for_recording(recording, scope)
|
|
84
|
+
context = recording.agent_context_id && AgentContext.for_agents(scope.agents).find_by(id: recording.agent_context_id)
|
|
85
|
+
run = recording.agent_run_id && AgentRun.where(agent: scope.agents).find_by(id: recording.agent_run_id)
|
|
86
|
+
|
|
87
|
+
timeline =
|
|
88
|
+
if context then for_context(context, scope, recordings: [ recording ])
|
|
89
|
+
elsif run then for_run(run, scope, recordings: [ recording ])
|
|
90
|
+
else new(scope, kind: "recording", id: recording.id, recordings: [ recording ])
|
|
91
|
+
end
|
|
92
|
+
timeline.retitle("recording", recording.id)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# The messages of +run+ in a conversation: from the user message stamped
|
|
96
|
+
# with the run's trace id up to the next user message stamped with
|
|
97
|
+
# another, as the run detail shows them. A run whose user message carries
|
|
98
|
+
# no trace id gets the messages written while it ran.
|
|
99
|
+
def self.run_slice(messages, run)
|
|
100
|
+
start = messages.index { |message| message.role == "user" && message.provenance&.dig("trace_id") == run.trace_id }
|
|
101
|
+
return messages.select { |message| written_during?(message, run) } unless start
|
|
102
|
+
|
|
103
|
+
slice = [ messages[start] ]
|
|
104
|
+
messages[(start + 1)..].each do |message|
|
|
105
|
+
trace = message.provenance&.dig("trace_id")
|
|
106
|
+
break if message.role == "user" && trace.present? && trace != run.trace_id
|
|
107
|
+
|
|
108
|
+
slice << message
|
|
109
|
+
end
|
|
110
|
+
slice
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def self.written_during?(message, run)
|
|
114
|
+
started = run.started_at || run.created_at
|
|
115
|
+
finished = run.completed_at || Time.current
|
|
116
|
+
message.created_at.between?(started, finished)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def initialize(scope, kind:, id:, contexts: [], runs: [], trace_ids: [], messages: [], generations: [], recordings: [])
|
|
120
|
+
@scope = scope
|
|
121
|
+
@kind = kind
|
|
122
|
+
@id = id
|
|
123
|
+
@contexts = contexts
|
|
124
|
+
@runs = runs
|
|
125
|
+
@trace_ids = trace_ids.compact.uniq
|
|
126
|
+
@messages = messages
|
|
127
|
+
@generations = generations
|
|
128
|
+
@recordings = recordings.to_a
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Names the session as the caller addressed it, and returns the timeline.
|
|
132
|
+
def retitle(kind, id)
|
|
133
|
+
@kind = kind
|
|
134
|
+
@id = id
|
|
135
|
+
self
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# The timeline as the JSON API returns it.
|
|
139
|
+
# @return [Hash]
|
|
140
|
+
def as_json(*)
|
|
141
|
+
full = { message: message_lane, llm: llm_lane, tool: tool_lane, browser: browser_lane }
|
|
142
|
+
lanes = full.transform_values { |entries| entries.first(LANE_LIMIT) }
|
|
143
|
+
entries = lanes.values.flatten
|
|
144
|
+
starts = entries.map { |entry| Time.iso8601(entry[:start]) }
|
|
145
|
+
ends = entries.zip(starts).map { |entry, start| start + (entry[:duration_ms].to_f / 1000) }
|
|
146
|
+
|
|
147
|
+
SessionRecording.without_sensitive_state(
|
|
148
|
+
session: {
|
|
149
|
+
kind: @kind,
|
|
150
|
+
id: @id,
|
|
151
|
+
started_at: starts.min&.then { |start| timestamp(start) },
|
|
152
|
+
ended_at: ends.max&.iso8601(3),
|
|
153
|
+
trace_ids: @trace_ids,
|
|
154
|
+
agent_run_ids: @runs.map(&:id),
|
|
155
|
+
agent_context_ids: @contexts.map(&:id),
|
|
156
|
+
truncated: full.values.any? { |lane| lane.size > LANE_LIMIT }
|
|
157
|
+
},
|
|
158
|
+
lanes: lanes,
|
|
159
|
+
recordings: @recordings.map { |recording| recording_summary(recording) }
|
|
160
|
+
)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
private
|
|
164
|
+
|
|
165
|
+
# --- message lane --------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
def message_lane
|
|
168
|
+
current_trace = nil
|
|
169
|
+
@messages.map do |message|
|
|
170
|
+
stamped = message.provenance&.dig("trace_id")
|
|
171
|
+
current_trace = stamped if message.role == "user" && stamped.present?
|
|
172
|
+
message_entry(message, stamped.presence || current_trace)
|
|
173
|
+
end.sort_by { |entry| entry[:start] }
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def message_entry(message, trace_id)
|
|
177
|
+
browser_tool = message.role == "tool" ? message.tool_name : nil
|
|
178
|
+
{
|
|
179
|
+
id: "message-#{message.id}",
|
|
180
|
+
lane: "message",
|
|
181
|
+
role: message.role,
|
|
182
|
+
content: clip(BrowserToolRedaction.redact_text(browser_tool, message.content.to_s, message.tool_arguments)),
|
|
183
|
+
tool_name: message.tool_name,
|
|
184
|
+
tool_call_id: message.tool_call_id,
|
|
185
|
+
tool_calls: message.tool_calls_data.map { |call| tool_call_entry(call) },
|
|
186
|
+
start: timestamp(message.created_at),
|
|
187
|
+
duration_ms: 0
|
|
188
|
+
}.compact.merge(trace_id: trace_id)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def tool_call_entry(call)
|
|
192
|
+
return call unless call.is_a?(Hash)
|
|
193
|
+
|
|
194
|
+
call = call.stringify_keys
|
|
195
|
+
call.merge("arguments" => BrowserToolRedaction.redact_arguments(call["name"], call["arguments"]))
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# --- llm and tool lanes --------------------------------------------------
|
|
199
|
+
|
|
200
|
+
def llm_lane
|
|
201
|
+
lane_for("llm") do |trace_id|
|
|
202
|
+
generations_by_trace.fetch(trace_id, []).map { |generation| generation_entry(generation) }
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def tool_lane
|
|
207
|
+
lane_for("tool") do |trace_id|
|
|
208
|
+
tool_messages_by_trace.fetch(trace_id, []).map { |message| tool_message_entry(message, trace_id) }
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# The +lane+ entries of every trace in the session: from the stored
|
|
213
|
+
# trace's spans, else the run's events, else what the block derives from
|
|
214
|
+
# the persisted conversation. A stored trace is the whole record of its
|
|
215
|
+
# tool calls, so a trace with no tool spans made none. A trace with no llm
|
|
216
|
+
# spans comes from a reporter that does not trace model calls, and the
|
|
217
|
+
# other sources stand in.
|
|
218
|
+
def lane_for(lane, &fallback)
|
|
219
|
+
@trace_ids.flat_map do |trace_id|
|
|
220
|
+
span_entries = spans_by_trace.key?(trace_id) ? span_entries(lane, trace_id) : []
|
|
221
|
+
next span_entries if span_entries.any? || (lane == "tool" && spans_by_trace.key?(trace_id))
|
|
222
|
+
|
|
223
|
+
run_entries = run_event_entries(lane, trace_id)
|
|
224
|
+
run_entries.any? ? run_entries : fallback.call(trace_id)
|
|
225
|
+
end.sort_by { |entry| entry[:start] }
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def span_entries(lane, trace_id)
|
|
229
|
+
spans = spans_by_trace[trace_id]
|
|
230
|
+
selected =
|
|
231
|
+
if lane == "llm"
|
|
232
|
+
nested = spans.select { |span| span["type"] == "llm" }
|
|
233
|
+
nested.presence || spans.select { |span| span["parent_span_id"].nil? && span.dig("attributes", "llm.model") }
|
|
234
|
+
else
|
|
235
|
+
spans.select { |span| span["type"] == "tool" }
|
|
236
|
+
end
|
|
237
|
+
selected.filter_map { |span| span_entry(lane, span, trace_id) }
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def span_entry(lane, span, trace_id)
|
|
241
|
+
started = parse_time(span["start_time"])
|
|
242
|
+
return nil unless started
|
|
243
|
+
|
|
244
|
+
attributes = span["attributes"] || {}
|
|
245
|
+
entry = {
|
|
246
|
+
id: "span-#{span['span_id']}",
|
|
247
|
+
lane: lane,
|
|
248
|
+
name: span["name"],
|
|
249
|
+
start: timestamp(started),
|
|
250
|
+
duration_ms: span_duration(span, started),
|
|
251
|
+
status: span["status"],
|
|
252
|
+
trace_id: trace_id
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if lane == "llm"
|
|
256
|
+
entry.merge(
|
|
257
|
+
model: attributes["llm.model"],
|
|
258
|
+
provider: attributes["llm.provider"],
|
|
259
|
+
finish_reason: attributes["llm.finish_reason"],
|
|
260
|
+
tokens: span["tokens"]
|
|
261
|
+
).compact
|
|
262
|
+
else
|
|
263
|
+
name = attributes["tool.name"] || span["name"].to_s.delete_prefix("tool.")
|
|
264
|
+
arguments = attributes["tool.input.args"] || attributes["tool.arguments"]
|
|
265
|
+
result = attributes["tool.output.result"] || attributes["tool.result"]
|
|
266
|
+
entry.merge(
|
|
267
|
+
name: name,
|
|
268
|
+
arguments: BrowserToolRedaction.redact_arguments(name, arguments),
|
|
269
|
+
result: clip(BrowserToolRedaction.redact_text(name, result, arguments)),
|
|
270
|
+
error: attributes["tool.error"] || span["status"] == TelemetryTrace::STATUS_ERROR
|
|
271
|
+
).compact
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def span_duration(span, started)
|
|
276
|
+
return span["duration_ms"].to_f.round(3) if span["duration_ms"]
|
|
277
|
+
|
|
278
|
+
finished = parse_time(span["end_time"])
|
|
279
|
+
finished ? ((finished - started) * 1000).round(3) : 0
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# The run's llm, or tool and agent, events: each a started event paired
|
|
283
|
+
# with the done or error event of the same eid.
|
|
284
|
+
def run_event_entries(lane, trace_id)
|
|
285
|
+
run = runs_by_trace[trace_id]
|
|
286
|
+
return [] unless run
|
|
287
|
+
|
|
288
|
+
events = Array(run.logs).select { |event| event.is_a?(Hash) && RUN_LOG_KINDS.fetch(lane).include?(event["kind"]) && event["eid"] }
|
|
289
|
+
events.group_by { |event| event["eid"] }.filter_map do |eid, pair|
|
|
290
|
+
started = pair.find { |event| event["status"] == "started" }
|
|
291
|
+
finished = pair.find { |event| event["status"] != "started" }
|
|
292
|
+
run_event_entry(lane, eid, started, finished, trace_id)
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def run_event_entry(lane, eid, started, finished, trace_id)
|
|
297
|
+
first = started || finished
|
|
298
|
+
start = parse_time(first["at"])
|
|
299
|
+
return nil unless start
|
|
300
|
+
|
|
301
|
+
finished_at = started && parse_time(finished&.dig("at"))
|
|
302
|
+
duration = finished&.dig("duration_ms") || (finished_at ? ((finished_at - start) * 1000).round : 0)
|
|
303
|
+
name = first["label"].to_s
|
|
304
|
+
arguments = started&.dig("detail")
|
|
305
|
+
{
|
|
306
|
+
id: "log-#{eid}",
|
|
307
|
+
lane: lane,
|
|
308
|
+
name: name,
|
|
309
|
+
start: timestamp(started ? start : start - (duration.to_f / 1000)),
|
|
310
|
+
duration_ms: duration,
|
|
311
|
+
status: finished ? finished["status"] : "started",
|
|
312
|
+
arguments: lane == "tool" ? BrowserToolRedaction.redact_arguments(name, arguments) : nil,
|
|
313
|
+
detail: clip(BrowserToolRedaction.redact_text(name, finished&.dig("detail"), arguments)),
|
|
314
|
+
trace_id: trace_id
|
|
315
|
+
}.compact
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def generation_entry(generation)
|
|
319
|
+
duration = (generation.duration_seconds.to_f * 1000).round
|
|
320
|
+
{
|
|
321
|
+
id: "generation-#{generation.id}",
|
|
322
|
+
lane: "llm",
|
|
323
|
+
name: generation.model,
|
|
324
|
+
model: generation.model,
|
|
325
|
+
provider: generation.provider,
|
|
326
|
+
finish_reason: generation.finish_reason,
|
|
327
|
+
tokens: { input: generation.input_tokens, output: generation.output_tokens },
|
|
328
|
+
start: timestamp(generation.created_at - (duration / 1000.0)),
|
|
329
|
+
duration_ms: duration,
|
|
330
|
+
trace_id: generation.trace_id
|
|
331
|
+
}.compact
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def tool_message_entry(message, trace_id)
|
|
335
|
+
duration = message.metadata&.dig("duration_ms").to_f
|
|
336
|
+
{
|
|
337
|
+
id: "tool-message-#{message.id}",
|
|
338
|
+
lane: "tool",
|
|
339
|
+
name: message.tool_name,
|
|
340
|
+
arguments: BrowserToolRedaction.redact_arguments(message.tool_name, message.tool_arguments),
|
|
341
|
+
result: clip(BrowserToolRedaction.redact_text(message.tool_name, message.content.to_s, message.tool_arguments)),
|
|
342
|
+
start: timestamp(message.created_at - (duration / 1000)),
|
|
343
|
+
duration_ms: duration.round(3),
|
|
344
|
+
trace_id: trace_id
|
|
345
|
+
}.compact
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
# --- browser lane --------------------------------------------------------
|
|
349
|
+
|
|
350
|
+
# The recordings' events and legacy actions in time order, read no further
|
|
351
|
+
# than the earliest LANE_LIMIT + 1 of each: enough to fill the lane and to
|
|
352
|
+
# tell whether it was cut short.
|
|
353
|
+
def browser_lane
|
|
354
|
+
return [] if @recordings.empty?
|
|
355
|
+
|
|
356
|
+
(earliest_event_entries + earliest_action_entries).sort_by { |entry| entry[:start] }
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
# Each row holds at least one event, so the earliest LANE_LIMIT + 1 events
|
|
360
|
+
# are in the first LANE_LIMIT + 1 rows in RecordingEvent.chronological
|
|
361
|
+
# order. Those rows are decoded in that order until no unread row can
|
|
362
|
+
# start before the last entry kept.
|
|
363
|
+
def earliest_event_entries
|
|
364
|
+
wanted = LANE_LIMIT + 1
|
|
365
|
+
kept = []
|
|
366
|
+
starts = RecordingEvent.where(session_recording_id: @recordings.map(&:id)).where.not(kind: "rrweb")
|
|
367
|
+
.chronological.limit(wanted).pluck(:id, :occurred_from)
|
|
368
|
+
|
|
369
|
+
starts.each_slice(BROWSER_ROWS_PER_QUERY) do |slice|
|
|
370
|
+
rows = RecordingEvent.where(id: slice.map(&:first)).index_by(&:id)
|
|
371
|
+
slice.each do |id, occurred_from|
|
|
372
|
+
return kept if kept.size == wanted && timestamp(occurred_from) >= kept.last[:start]
|
|
373
|
+
next unless rows[id]
|
|
374
|
+
|
|
375
|
+
recording_event_entries(rows[id]).each { |entry| keep_earliest(kept, entry, wanted) }
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
kept
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
# Adds +entry+ to +kept+, which stays in start order and holds at most
|
|
382
|
+
# +limit+ entries.
|
|
383
|
+
def keep_earliest(kept, entry, limit)
|
|
384
|
+
return if kept.size == limit && entry[:start] >= kept.last[:start]
|
|
385
|
+
|
|
386
|
+
index = kept.bsearch_index { |other| other[:start] > entry[:start] } || kept.size
|
|
387
|
+
kept.insert(index, entry)
|
|
388
|
+
kept.pop if kept.size > limit
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
def earliest_action_entries
|
|
392
|
+
@recordings.flat_map do |recording|
|
|
393
|
+
recording.recording_actions.ordered.limit(LANE_LIMIT + 1).map { |action| recording_action_entry(action, recording) }
|
|
394
|
+
end
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
def recording_event_entries(row)
|
|
398
|
+
row.events.each_with_index.map do |event, index|
|
|
399
|
+
data = event["data"].is_a?(Hash) ? event["data"] : { "value" => event["data"] }
|
|
400
|
+
if row.kind == "action"
|
|
401
|
+
data = data.merge("parameters" => BrowserToolRedaction.redact_arguments(data["tool_name"], data["parameters"]))
|
|
402
|
+
end
|
|
403
|
+
{
|
|
404
|
+
id: "event-#{row.id}-#{index}",
|
|
405
|
+
lane: "browser",
|
|
406
|
+
kind: row.kind,
|
|
407
|
+
recording_id: row.session_recording_id,
|
|
408
|
+
start: timestamp(RecordingEvent.time_at(event["at"].to_i)),
|
|
409
|
+
duration_ms: data["duration_ms"] || 0,
|
|
410
|
+
data: data
|
|
411
|
+
}.compact.merge(trace_id: row.kind == "action" ? data["trace_id"] : nil)
|
|
412
|
+
end
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
def recording_action_entry(action, recording)
|
|
416
|
+
{
|
|
417
|
+
id: "recording-action-#{action.id}",
|
|
418
|
+
lane: "browser",
|
|
419
|
+
kind: "action",
|
|
420
|
+
recording_id: action.session_recording_id,
|
|
421
|
+
start: timestamp(recording.created_at + (action.timestamp_ms.to_f / 1000)),
|
|
422
|
+
duration_ms: 0,
|
|
423
|
+
data: {
|
|
424
|
+
"action_type" => action.action_type,
|
|
425
|
+
"selector" => action.selector,
|
|
426
|
+
"value" => action.redacted_value,
|
|
427
|
+
"metadata" => action.safe_metadata
|
|
428
|
+
}.compact,
|
|
429
|
+
trace_id: nil
|
|
430
|
+
}
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
def recording_summary(recording)
|
|
434
|
+
{
|
|
435
|
+
id: recording.id,
|
|
436
|
+
name: recording.name,
|
|
437
|
+
status: recording.status,
|
|
438
|
+
source: recording.source,
|
|
439
|
+
agent_run_id: recording.agent_run_id,
|
|
440
|
+
agent_context_id: recording.agent_context_id,
|
|
441
|
+
event_count: recording.event_count,
|
|
442
|
+
dropped_event_count: recording.dropped_event_count,
|
|
443
|
+
rrweb: {
|
|
444
|
+
event_count: rrweb_totals[:event_count].fetch(recording.id, 0),
|
|
445
|
+
first_at: rrweb_totals[:first_at][recording.id]&.iso8601(3),
|
|
446
|
+
last_at: rrweb_totals[:last_at][recording.id]&.iso8601(3)
|
|
447
|
+
},
|
|
448
|
+
created_at: timestamp(recording.created_at)
|
|
449
|
+
}
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
# The rrweb event count and time range of each recording, by recording id.
|
|
453
|
+
def rrweb_totals
|
|
454
|
+
@rrweb_totals ||= begin
|
|
455
|
+
rows = RecordingEvent.where(session_recording_id: @recordings.map(&:id), kind: "rrweb").group(:session_recording_id)
|
|
456
|
+
{ event_count: rows.sum(:event_count), first_at: rows.minimum(:occurred_from), last_at: rows.maximum(:occurred_to) }
|
|
457
|
+
end
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
# --- lookups -------------------------------------------------------------
|
|
461
|
+
|
|
462
|
+
# Spans by trace id, for the session's traces the caller may read.
|
|
463
|
+
def spans_by_trace
|
|
464
|
+
@spans_by_trace ||= @trace_ids.empty? ? {} : @scope.traces.where(trace_id: @trace_ids).pluck(:trace_id, :spans)
|
|
465
|
+
.each_with_object({}) { |(trace_id, spans), by_trace| (by_trace[trace_id] ||= []).concat(Array(spans)) }
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
def runs_by_trace
|
|
469
|
+
@runs_by_trace ||= @runs.index_by(&:trace_id)
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
def generations_by_trace
|
|
473
|
+
@generations_by_trace ||= @generations.group_by(&:trace_id)
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
def tool_messages_by_trace
|
|
477
|
+
@tool_messages_by_trace ||= begin
|
|
478
|
+
current_trace = nil
|
|
479
|
+
@messages.each_with_object({}) do |message, by_trace|
|
|
480
|
+
stamped = message.provenance&.dig("trace_id")
|
|
481
|
+
current_trace = stamped if message.role == "user" && stamped.present?
|
|
482
|
+
(by_trace[current_trace] ||= []) << message if message.role == "tool"
|
|
483
|
+
end
|
|
484
|
+
end
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
# --- helpers -------------------------------------------------------------
|
|
488
|
+
|
|
489
|
+
def clip(text)
|
|
490
|
+
return text unless text.is_a?(String)
|
|
491
|
+
|
|
492
|
+
text.length > TEXT_LIMIT ? "#{text[0, TEXT_LIMIT]}…" : text
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
def timestamp(time)
|
|
496
|
+
time.utc.iso8601(3)
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
def parse_time(value)
|
|
500
|
+
value.present? ? Time.iso8601(value.to_s) : nil
|
|
501
|
+
rescue ArgumentError
|
|
502
|
+
nil
|
|
503
|
+
end
|
|
504
|
+
end
|
|
505
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
<%# The created App's settings and credentials, shown once: nothing stores them. %>
|
|
2
|
+
<% pem = @app["pem"].to_s %>
|
|
3
|
+
<% values = { "GITHUB_APP_ID" => @app["id"], "GITHUB_APP_SLUG" => @app["slug"], "GITHUB_APP_CLIENT_ID" => @app["client_id"], "GITHUB_APP_CLIENT_SECRET" => @app["client_secret"] } %>
|
|
4
|
+
<!doctype html>
|
|
5
|
+
<html lang="en">
|
|
6
|
+
<head>
|
|
7
|
+
<meta charset="utf-8">
|
|
8
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
9
|
+
<meta name="robots" content="noindex">
|
|
10
|
+
<title>GitHub App created</title>
|
|
11
|
+
</head>
|
|
12
|
+
<body style="font-family: -apple-system, 'Segoe UI', Roboto, sans-serif; color: #0f172a; background: #f8fafc; margin: 0;">
|
|
13
|
+
<main style="max-width: 760px; margin: 40px auto; padding: 0 20px;">
|
|
14
|
+
<h1 style="font-size: 22px;">GitHub App created: <%= @app["name"] %></h1>
|
|
15
|
+
<p>
|
|
16
|
+
GitHub created <strong><%= @app["slug"] %></strong><% if @app.dig("owner", "login").present? %> under <strong><%= @app.dig("owner", "login") %></strong><% end %>.
|
|
17
|
+
Its credentials are below and are shown only on this page: the dashboard has not stored them.
|
|
18
|
+
Copy them into the dashboard's configuration now.
|
|
19
|
+
</p>
|
|
20
|
+
<p style="padding: 12px; border: 1px solid #fca5a5; background: #fef2f2; border-radius: 6px;">
|
|
21
|
+
The client secret and the private key give full control of this App. Keep them out of source control.
|
|
22
|
+
</p>
|
|
23
|
+
|
|
24
|
+
<h2 style="font-size: 16px; margin-top: 28px;">As environment variables</h2>
|
|
25
|
+
<pre style="background: #0f172a; color: #e2e8f0; padding: 14px; border-radius: 6px; overflow-x: auto; white-space: pre;"><% values.each do |name, value| %><%= name %>=<%= value %>
|
|
26
|
+
<% end %>GITHUB_APP_PRIVATE_KEY="<%= pem.strip.gsub("\n", "\\n") %>"</pre>
|
|
27
|
+
|
|
28
|
+
<h2 style="font-size: 16px; margin-top: 28px;">Or in an initializer</h2>
|
|
29
|
+
<pre style="background: #0f172a; color: #e2e8f0; padding: 14px; border-radius: 6px; overflow-x: auto;">ActionAgent.configure do |config|
|
|
30
|
+
config.github_app_id = Rails.application.credentials.dig(:github_app, :id)
|
|
31
|
+
config.github_app_slug = Rails.application.credentials.dig(:github_app, :slug)
|
|
32
|
+
config.github_app_client_id = Rails.application.credentials.dig(:github_app, :client_id)
|
|
33
|
+
config.github_app_client_secret = Rails.application.credentials.dig(:github_app, :client_secret)
|
|
34
|
+
config.github_app_private_key = Rails.application.credentials.dig(:github_app, :private_key)
|
|
35
|
+
end</pre>
|
|
36
|
+
<p>with these credentials (<code>bin/rails credentials:edit</code>):</p>
|
|
37
|
+
<pre style="background: #0f172a; color: #e2e8f0; padding: 14px; border-radius: 6px; overflow-x: auto;">github_app:
|
|
38
|
+
id: <%= @app["id"] %>
|
|
39
|
+
slug: <%= @app["slug"] %>
|
|
40
|
+
client_id: <%= @app["client_id"] %>
|
|
41
|
+
client_secret: <%= @app["client_secret"] %>
|
|
42
|
+
private_key: |
|
|
43
|
+
<%= pem.strip.lines.map { |line| " #{line.chomp}" }.join("\n") %></pre>
|
|
44
|
+
|
|
45
|
+
<h2 style="font-size: 16px; margin-top: 28px;">Then</h2>
|
|
46
|
+
<ol>
|
|
47
|
+
<li>Restart the dashboard so it reads the new settings.</li>
|
|
48
|
+
<li>In <a href="<%= @settings_url %>">Settings → Integrations</a>, choose <strong>Install the GitHub App</strong> and pick the repositories checkout sandboxes may use.</li>
|
|
49
|
+
</ol>
|
|
50
|
+
<% if @app["html_url"].to_s.start_with?("https://github.com/") %>
|
|
51
|
+
<p>The App's own settings are on <a href="<%= @app["html_url"] %>">GitHub</a>.</p>
|
|
52
|
+
<% end %>
|
|
53
|
+
</main>
|
|
54
|
+
</body>
|
|
55
|
+
</html>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<%#
|
|
2
|
+
The session player's frame. It holds no data: the dashboard posts the
|
|
3
|
+
events to replay into it (see SessionPlayerController).
|
|
4
|
+
%>
|
|
5
|
+
<!DOCTYPE html>
|
|
6
|
+
<html lang="en">
|
|
7
|
+
<head>
|
|
8
|
+
<meta charset="UTF-8">
|
|
9
|
+
<title>Session player</title>
|
|
10
|
+
<style>
|
|
11
|
+
html, body { margin: 0; height: 100%; overflow: hidden; background: transparent; }
|
|
12
|
+
#session-player { position: relative; margin: 0 auto; }
|
|
13
|
+
</style>
|
|
14
|
+
</head>
|
|
15
|
+
<body>
|
|
16
|
+
<div id="session-player"></div>
|
|
17
|
+
<script src="<%= script_path %>"></script>
|
|
18
|
+
</body>
|
|
19
|
+
</html>
|