actionagent 1.2.2 → 1.5.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/README.md +14 -3
- data/app/assets/builds/action_agent.css +1 -1
- data/app/assets/builds/action_agent.js +69 -43
- data/app/controllers/action_agent/api/agent_runs_controller.rb +28 -8
- data/app/controllers/action_agent/api/agents_controller.rb +191 -56
- data/app/controllers/action_agent/api/analytics_controller.rb +31 -9
- data/app/controllers/action_agent/api/base_controller.rb +16 -0
- data/app/controllers/action_agent/api/dashboard_assistant_controller.rb +83 -0
- data/app/controllers/action_agent/api/evaluations_controller.rb +252 -7
- data/app/controllers/action_agent/api/interaction_messages_controller.rb +98 -0
- data/app/controllers/action_agent/api/mcp_controller.rb +13 -3
- data/app/controllers/action_agent/api/mcp_servers_controller.rb +28 -8
- data/app/controllers/action_agent/api/metrics_controller.rb +44 -11
- data/app/controllers/action_agent/api/provider_models_controller.rb +1 -1
- data/app/controllers/action_agent/api/sandboxes_controller.rb +6 -0
- data/app/controllers/action_agent/api/session_recordings_controller.rb +34 -12
- data/app/controllers/action_agent/api/templates_controller.rb +25 -21
- data/app/controllers/action_agent/api/traces_controller.rb +25 -5
- data/app/controllers/action_agent/api/usage_controller.rb +20 -0
- data/app/controllers/action_agent/application_controller.rb +25 -2
- data/app/controllers/action_agent/dashboard_controller.rb +3 -1
- data/app/controllers/concerns/action_agent/api/agent_serialization.rb +53 -0
- data/app/jobs/action_agent/agent_execution_job.rb +40 -20
- data/app/jobs/action_agent/application_job.rb +7 -3
- data/app/jobs/action_agent/evaluation_run_job.rb +18 -0
- data/app/jobs/action_agent/sandbox_cleanup_job.rb +13 -10
- data/app/models/action_agent/agent.rb +74 -23
- data/app/models/action_agent/agent_run.rb +99 -0
- data/app/models/action_agent/agent_template.rb +22 -7
- data/app/models/action_agent/evaluation.rb +64 -4
- data/app/models/action_agent/evaluation_run.rb +190 -2
- data/app/models/action_agent/evaluation_scenario.rb +59 -0
- data/app/models/action_agent/evaluation_scenario_result.rb +86 -0
- data/app/models/action_agent/recording_action.rb +11 -7
- data/app/models/action_agent/sandbox_session.rb +1 -1
- data/app/models/action_agent/session_recording.rb +31 -8
- data/app/models/action_agent/telemetry_trace.rb +126 -3
- data/app/models/concerns/action_agent/adapter_aware.rb +19 -0
- data/app/models/concerns/action_agent/ownable.rb +15 -2
- data/app/queries/action_agent/metrics_report.rb +498 -0
- data/app/serializers/action_agent/agent_message_serializer.rb +1 -0
- data/app/services/action_agent/agent_execution_service.rb +294 -16
- data/app/services/action_agent/agent_registrar.rb +7 -6
- data/app/services/action_agent/agent_toolbox.rb +49 -7
- data/app/services/action_agent/dashboard_assistant_service.rb +342 -0
- data/app/services/action_agent/evaluation_evidence.rb +234 -0
- data/app/services/action_agent/evaluation_runner_service.rb +13 -3
- data/app/services/action_agent/evaluation_tool_resolver.rb +162 -0
- data/app/services/action_agent/mcp_catalog.rb +46 -8
- data/app/services/action_agent/mcp_client.rb +167 -0
- data/app/services/action_agent/mcp_recording_middleware.rb +2 -2
- data/app/services/action_agent/mcp_tool_dispatcher.rb +116 -0
- data/app/services/action_agent/playwright_mcp_client.rb +11 -126
- data/app/services/action_agent/sandbox_orchestrator.rb +12 -1
- data/app/services/action_agent/scenario_evaluation_runner.rb +260 -0
- data/app/services/action_agent/tool_discovery.rb +22 -8
- data/config/routes.rb +36 -3
- data/lib/action_agent/assistant_request_filter.rb +22 -0
- data/lib/action_agent/engine.rb +106 -19
- data/lib/action_agent/version.rb +1 -1
- data/lib/action_agent.rb +104 -6
- data/lib/generators/action_agent/install_generator.rb +20 -7
- data/lib/generators/action_agent/templates/action_agent.rb.erb +12 -0
- data/lib/generators/action_agent/templates/create_active_agent_evaluation_scenarios.rb.erb +79 -0
- data/lib/tasks/action_agent.rake +9 -0
- metadata +22 -5
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionAgent
|
|
4
|
+
# One user-authored task an evaluation replays through its agent: the
|
|
5
|
+
# message a user would send, the group of related tasks it belongs to, and
|
|
6
|
+
# what a passing answer is expected to do.
|
|
7
|
+
#
|
|
8
|
+
# `expectations` holds the scenario-level checks ScenarioEvaluationRunner
|
|
9
|
+
# scores in addition to the evaluation's criteria:
|
|
10
|
+
#
|
|
11
|
+
# "tools" — tool names the agent is expected to call (any one of them)
|
|
12
|
+
# "contains" — substrings or patterns the answer must include
|
|
13
|
+
# "not_contains" — substrings or patterns the answer must avoid
|
|
14
|
+
#
|
|
15
|
+
# `key` is stable within the suite ("blame_3"), so results of successive
|
|
16
|
+
# runs line up by scenario even after the suite is re-imported.
|
|
17
|
+
class EvaluationScenario < ApplicationRecord
|
|
18
|
+
belongs_to :evaluation
|
|
19
|
+
has_many :results, class_name: "EvaluationScenarioResult", foreign_key: :evaluation_scenario_id,
|
|
20
|
+
dependent: :destroy, inverse_of: :scenario
|
|
21
|
+
|
|
22
|
+
validates :key, presence: true, uniqueness: { scope: :evaluation_id }
|
|
23
|
+
validates :prompt, presence: true
|
|
24
|
+
|
|
25
|
+
scope :enabled, -> { where(enabled: true) }
|
|
26
|
+
scope :ordered, -> { order(:position, :id) }
|
|
27
|
+
scope :in_group, ->(group) { where(group: group) }
|
|
28
|
+
|
|
29
|
+
def expectations
|
|
30
|
+
value = super
|
|
31
|
+
value.is_a?(Hash) ? value : {}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def expected_tools
|
|
35
|
+
Array(expectations["tools"]).map(&:to_s).reject(&:blank?)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def expected_patterns
|
|
39
|
+
Array(expectations["contains"]).map(&:to_s).reject(&:blank?)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def forbidden_patterns
|
|
43
|
+
Array(expectations["not_contains"]).map(&:to_s).reject(&:blank?)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def as_json_summary
|
|
47
|
+
{
|
|
48
|
+
id: id,
|
|
49
|
+
key: key,
|
|
50
|
+
group: group,
|
|
51
|
+
prompt: prompt,
|
|
52
|
+
notes: notes,
|
|
53
|
+
expectations: expectations,
|
|
54
|
+
position: position,
|
|
55
|
+
enabled: enabled
|
|
56
|
+
}
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionAgent
|
|
4
|
+
# What one scenario produced under one model in one evaluation run: the
|
|
5
|
+
# AgentRun that replayed it, the answer, the tools it called, its score per
|
|
6
|
+
# criterion, and — when it fell short — the fault and the recommended fix.
|
|
7
|
+
#
|
|
8
|
+
# `fault` is one of FAULTS; `diagnosis` carries the evidence behind it and,
|
|
9
|
+
# when a judge was available, its suggested tool or instruction change.
|
|
10
|
+
class EvaluationScenarioResult < ApplicationRecord
|
|
11
|
+
belongs_to :evaluation_run
|
|
12
|
+
belongs_to :scenario, class_name: "EvaluationScenario", foreign_key: :evaluation_scenario_id, inverse_of: :results
|
|
13
|
+
belongs_to :agent_run, optional: true
|
|
14
|
+
|
|
15
|
+
enum :status, { pending: 0, passed: 1, failed: 2, errored: 3 }
|
|
16
|
+
|
|
17
|
+
# Why a scenario did not pass, from the most mechanical cause to the
|
|
18
|
+
# least; ActiveAgent::Evals::Diagnosis assigns exactly one per failing result.
|
|
19
|
+
FAULTS = ActiveAgent::Evals::Diagnosis::FAULTS
|
|
20
|
+
|
|
21
|
+
validates :model, presence: true
|
|
22
|
+
validates :fault, inclusion: { in: FAULTS }, allow_nil: true
|
|
23
|
+
|
|
24
|
+
scope :for_model, ->(model) { where(model: model) }
|
|
25
|
+
scope :faulted, -> { where.not(fault: nil) }
|
|
26
|
+
|
|
27
|
+
def tool_calls
|
|
28
|
+
value = super
|
|
29
|
+
value.is_a?(Array) ? value : []
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def tool_names
|
|
33
|
+
tool_calls.filter_map { |call| call.is_a?(Hash) ? (call["name"] || call[:name]) : call }.map(&:to_s)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def tool_errors
|
|
37
|
+
tool_calls.select { |call| call.is_a?(Hash) && (call["error"] || call[:error]) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Adapter-provided correlation and context are stored alongside diagnosis
|
|
41
|
+
# in a reserved JSON key, leaving the public diagnosis contract unchanged.
|
|
42
|
+
def replay_metadata
|
|
43
|
+
value = diagnosis&.dig("_replay_metadata")
|
|
44
|
+
value.is_a?(Hash) ? value : {}
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def evaluation_diagnosis
|
|
48
|
+
(diagnosis || {}).except("_replay_metadata", "_scenario_snapshot")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# A catalog can be refreshed without changing what an earlier run asked
|
|
52
|
+
# or expected. Older results did not record this snapshot.
|
|
53
|
+
def evaluated_scenario
|
|
54
|
+
snapshot = diagnosis&.dig("_scenario_snapshot")
|
|
55
|
+
snapshot.is_a?(Hash) ? snapshot : scenario.as_json_summary.stringify_keys
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def as_json_summary
|
|
59
|
+
{
|
|
60
|
+
id: id,
|
|
61
|
+
scenario_id: evaluation_scenario_id,
|
|
62
|
+
scenario_key: evaluated_scenario["key"],
|
|
63
|
+
group: evaluated_scenario["group"],
|
|
64
|
+
prompt: evaluated_scenario["prompt"],
|
|
65
|
+
scenario: evaluated_scenario,
|
|
66
|
+
model: model,
|
|
67
|
+
provider: provider,
|
|
68
|
+
status: status,
|
|
69
|
+
score: score,
|
|
70
|
+
scores: scores,
|
|
71
|
+
output: output,
|
|
72
|
+
tool_calls: tool_calls,
|
|
73
|
+
duration_ms: duration_ms,
|
|
74
|
+
input_tokens: input_tokens,
|
|
75
|
+
output_tokens: output_tokens,
|
|
76
|
+
cost: cost&.to_f,
|
|
77
|
+
fault: fault,
|
|
78
|
+
recommendation: recommendation,
|
|
79
|
+
diagnosis: evaluation_diagnosis,
|
|
80
|
+
metadata: replay_metadata,
|
|
81
|
+
error_message: error_message,
|
|
82
|
+
agent_run_id: agent_run_id
|
|
83
|
+
}
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -75,14 +75,23 @@ module ActionAgent
|
|
|
75
75
|
}
|
|
76
76
|
end
|
|
77
77
|
|
|
78
|
-
|
|
79
|
-
|
|
78
|
+
# The value with passwords, card numbers and the like masked. Public so
|
|
79
|
+
# every read path — /actions, the show timeline, and the export
|
|
80
|
+
# cassette — serializes through the one redaction rule.
|
|
80
81
|
def redacted_value
|
|
81
82
|
return value unless should_redact?
|
|
82
83
|
|
|
83
84
|
"[REDACTED]"
|
|
84
85
|
end
|
|
85
86
|
|
|
87
|
+
# Metadata with the sensitive keys removed. Public for the same reason.
|
|
88
|
+
def safe_metadata
|
|
89
|
+
# Remove any sensitive data from metadata
|
|
90
|
+
metadata.except("password", "credit_card", "cvv", "ssn")
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
86
95
|
def should_redact?
|
|
87
96
|
return false unless value.present?
|
|
88
97
|
|
|
@@ -102,11 +111,6 @@ module ActionAgent
|
|
|
102
111
|
selector_is_sensitive || value_is_sensitive
|
|
103
112
|
end
|
|
104
113
|
|
|
105
|
-
def safe_metadata
|
|
106
|
-
# Remove any sensitive data from metadata
|
|
107
|
-
metadata.except("password", "credit_card", "cvv", "ssn")
|
|
108
|
-
end
|
|
109
|
-
|
|
110
114
|
def extract_url
|
|
111
115
|
case action_type
|
|
112
116
|
when "navigate"
|
|
@@ -48,7 +48,7 @@ module ActionAgent
|
|
|
48
48
|
# Unknown keys are dropped rather than raising — a session outlives a
|
|
49
49
|
# catalog edit.
|
|
50
50
|
def mcp_catalog_entries
|
|
51
|
-
Array(mcp_servers).filter_map { |key|
|
|
51
|
+
Array(mcp_servers).filter_map { |key| MCPCatalog.find(key) }
|
|
52
52
|
end
|
|
53
53
|
|
|
54
54
|
# Check if session is still valid
|
|
@@ -31,20 +31,30 @@ module ActionAgent
|
|
|
31
31
|
name&.start_with?("user_takeover_")
|
|
32
32
|
end
|
|
33
33
|
|
|
34
|
-
# Start a new recording session
|
|
35
|
-
|
|
36
|
-
|
|
34
|
+
# Start a new recording session.
|
|
35
|
+
#
|
|
36
|
+
# The owner column is written here, at creation: the index and recent
|
|
37
|
+
# endpoints scope through it, and a recording nothing ever stamped was
|
|
38
|
+
# invisible in the list to the very person who made it. An explicit
|
|
39
|
+
# +owner+ wins; otherwise the recording inherits the owner of the sandbox
|
|
40
|
+
# or agent it records. Ownable#owner= is a no-op in a single-user
|
|
41
|
+
# install, where nothing is owned.
|
|
42
|
+
def self.start!(agent_run: nil, sandbox_session: nil, name: nil, owner: nil)
|
|
43
|
+
recording = new(
|
|
37
44
|
agent_run: agent_run,
|
|
38
45
|
sandbox_session: sandbox_session,
|
|
39
46
|
name: name || generate_name(agent_run, sandbox_session),
|
|
40
47
|
status: :recording,
|
|
41
48
|
metadata: { started_at: Time.current.iso8601 }
|
|
42
49
|
)
|
|
50
|
+
recording.owner = owner || inherited_owner(agent_run, sandbox_session)
|
|
51
|
+
recording.save!
|
|
52
|
+
recording
|
|
43
53
|
end
|
|
44
54
|
|
|
45
55
|
# Start a user takeover session (for lander demo analytics)
|
|
46
|
-
def self.start_user_session!(visitor_id: nil, parent_demo_id: nil, page_url: nil)
|
|
47
|
-
|
|
56
|
+
def self.start_user_session!(visitor_id: nil, parent_demo_id: nil, page_url: nil, owner: nil)
|
|
57
|
+
recording = new(
|
|
48
58
|
name: "user_takeover_#{Time.current.strftime('%Y%m%d_%H%M%S')}_#{SecureRandom.hex(4)}",
|
|
49
59
|
status: :recording,
|
|
50
60
|
metadata: {
|
|
@@ -56,6 +66,16 @@ module ActionAgent
|
|
|
56
66
|
user_agent: nil # Will be set from request
|
|
57
67
|
}
|
|
58
68
|
)
|
|
69
|
+
recording.owner = owner
|
|
70
|
+
recording.save!
|
|
71
|
+
recording
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Whoever owns the sandbox or agent a recording is made against. Both
|
|
75
|
+
# models declare the same owner candidates as this one, so the record
|
|
76
|
+
# they hand back is of the class this install owns things through.
|
|
77
|
+
def self.inherited_owner(agent_run, sandbox_session)
|
|
78
|
+
sandbox_session&.owner || agent_run&.agent&.owner
|
|
59
79
|
end
|
|
60
80
|
|
|
61
81
|
# Record a browser action
|
|
@@ -112,7 +132,10 @@ module ActionAgent
|
|
|
112
132
|
)
|
|
113
133
|
end
|
|
114
134
|
|
|
115
|
-
# Get timeline data for playback
|
|
135
|
+
# Get timeline data for playback. Values and metadata go through the
|
|
136
|
+
# same redaction the /actions endpoint applies: this is what #show
|
|
137
|
+
# renders, and it used to hand back the cleartext password that
|
|
138
|
+
# /actions had just redacted for the same action.
|
|
116
139
|
def timeline
|
|
117
140
|
recording_actions.order(:sequence).map do |action|
|
|
118
141
|
{
|
|
@@ -121,9 +144,9 @@ module ActionAgent
|
|
|
121
144
|
sequence: action.sequence,
|
|
122
145
|
timestamp_ms: action.timestamp_ms,
|
|
123
146
|
selector: action.selector,
|
|
124
|
-
value: action.
|
|
147
|
+
value: action.redacted_value,
|
|
125
148
|
screenshot_key: action.screenshot_key,
|
|
126
|
-
metadata: action.
|
|
149
|
+
metadata: action.safe_metadata
|
|
127
150
|
}
|
|
128
151
|
end
|
|
129
152
|
end
|
|
@@ -81,7 +81,110 @@ module ActionAgent
|
|
|
81
81
|
end
|
|
82
82
|
end
|
|
83
83
|
|
|
84
|
-
|
|
84
|
+
# The trace columns the Metrics report reads, in the order
|
|
85
|
+
# pluck_metrics_rows returns them (before the span-derived fields).
|
|
86
|
+
METRICS_COLUMNS = %i[
|
|
87
|
+
timestamp agent_class agent_action total_duration_ms status error_message
|
|
88
|
+
total_input_tokens total_output_tokens
|
|
89
|
+
].freeze
|
|
90
|
+
|
|
91
|
+
# Plucks one row per trace with everything the Metrics report needs, so
|
|
92
|
+
# a window is read once:
|
|
93
|
+
#
|
|
94
|
+
# [timestamp, agent_class, agent_action, total_duration_ms, status,
|
|
95
|
+
# error_message, total_input_tokens, total_output_tokens,
|
|
96
|
+
# llm_model, llm_provider, tool_calls, tool_errors]
|
|
97
|
+
#
|
|
98
|
+
# llm_model and llm_provider come from the first llm span; tool_calls
|
|
99
|
+
# and tool_errors count the trace's tool spans and those whose status is
|
|
100
|
+
# ERROR. Same split as pluck_with_llm_model: PostgreSQL digs into the
|
|
101
|
+
# spans jsonb in SQL, other adapters read the column back and count in
|
|
102
|
+
# Ruby.
|
|
103
|
+
def self.pluck_metrics_rows(scope)
|
|
104
|
+
if postgres?
|
|
105
|
+
scope.pluck(
|
|
106
|
+
*METRICS_COLUMNS,
|
|
107
|
+
# spans is cast for the same reason as in pluck_with_llm_model:
|
|
108
|
+
# the column is json on older installs.
|
|
109
|
+
Arel.sql(
|
|
110
|
+
"(SELECT s.value -> 'attributes' ->> 'llm.model' " \
|
|
111
|
+
"FROM jsonb_array_elements(spans::jsonb) AS s " \
|
|
112
|
+
"WHERE s.value ->> 'type' = 'llm' LIMIT 1)"
|
|
113
|
+
),
|
|
114
|
+
Arel.sql(
|
|
115
|
+
"(SELECT s.value -> 'attributes' ->> 'llm.provider' " \
|
|
116
|
+
"FROM jsonb_array_elements(spans::jsonb) AS s " \
|
|
117
|
+
"WHERE s.value ->> 'type' = 'llm' LIMIT 1)"
|
|
118
|
+
),
|
|
119
|
+
Arel.sql(
|
|
120
|
+
"(SELECT COUNT(*) FROM jsonb_array_elements(spans::jsonb) AS s " \
|
|
121
|
+
"WHERE s.value ->> 'type' = 'tool')"
|
|
122
|
+
),
|
|
123
|
+
Arel.sql(
|
|
124
|
+
"(SELECT COUNT(*) FROM jsonb_array_elements(spans::jsonb) AS s " \
|
|
125
|
+
"WHERE s.value ->> 'type' = 'tool' AND s.value ->> 'status' = '#{STATUS_ERROR}')"
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
else
|
|
129
|
+
scope.pluck(:spans, *METRICS_COLUMNS).map do |spans, *rest|
|
|
130
|
+
spans = Array(spans).grep(Hash)
|
|
131
|
+
llm = spans.find { |span| span["type"].to_s == "llm" }
|
|
132
|
+
tools = spans.select { |span| span["type"].to_s == "tool" }
|
|
133
|
+
[
|
|
134
|
+
*rest,
|
|
135
|
+
llm&.dig("attributes", "llm.model"),
|
|
136
|
+
llm&.dig("attributes", "llm.provider"),
|
|
137
|
+
tools.size,
|
|
138
|
+
tools.count { |span| span["status"].to_s == STATUS_ERROR }
|
|
139
|
+
]
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Per-tool call count, error count and summed duration over the tool
|
|
145
|
+
# spans of every trace in +scope+:
|
|
146
|
+
#
|
|
147
|
+
# { "web_search" => { calls: 12, errors: 1, total_ms: 7680.0 }, ... }
|
|
148
|
+
#
|
|
149
|
+
# A tool is named by its span's tool.name attribute, or by the span name
|
|
150
|
+
# minus its "tool." prefix — the same rule as #tool_usage. PostgreSQL
|
|
151
|
+
# groups in SQL over the unnested spans jsonb; other adapters read the
|
|
152
|
+
# spans back and tally in Ruby.
|
|
153
|
+
def self.tool_span_stats(scope)
|
|
154
|
+
if postgres?
|
|
155
|
+
name_sql = "COALESCE(s.value -> 'attributes' ->> 'tool.name', " \
|
|
156
|
+
"regexp_replace(COALESCE(s.value ->> 'name', ''), '^tool\\.', ''))"
|
|
157
|
+
|
|
158
|
+
scope
|
|
159
|
+
.joins("CROSS JOIN LATERAL jsonb_array_elements(#{table_name}.spans::jsonb) AS s")
|
|
160
|
+
.where("s.value ->> 'type' = 'tool'")
|
|
161
|
+
.group(Arel.sql(name_sql))
|
|
162
|
+
.pluck(
|
|
163
|
+
Arel.sql(name_sql),
|
|
164
|
+
Arel.sql("COUNT(*)"),
|
|
165
|
+
Arel.sql("SUM(CASE WHEN s.value ->> 'status' = '#{STATUS_ERROR}' THEN 1 ELSE 0 END)"),
|
|
166
|
+
Arel.sql("SUM(COALESCE((s.value ->> 'duration_ms')::float, 0))")
|
|
167
|
+
)
|
|
168
|
+
.to_h do |name, calls, errors, total_ms|
|
|
169
|
+
[ name, { calls: calls.to_i, errors: errors.to_i, total_ms: total_ms.to_f } ]
|
|
170
|
+
end
|
|
171
|
+
else
|
|
172
|
+
scope.pluck(:spans).each_with_object({}) do |spans, stats|
|
|
173
|
+
Array(spans).each do |span|
|
|
174
|
+
next unless span.is_a?(Hash) && span["type"].to_s == "tool"
|
|
175
|
+
|
|
176
|
+
attributes = span["attributes"] || {}
|
|
177
|
+
name = attributes["tool.name"] || span["name"].to_s.delete_prefix("tool.")
|
|
178
|
+
entry = (stats[name] ||= { calls: 0, errors: 0, total_ms: 0.0 })
|
|
179
|
+
entry[:calls] += 1
|
|
180
|
+
entry[:errors] += 1 if span["status"].to_s == STATUS_ERROR
|
|
181
|
+
entry[:total_ms] += span["duration_ms"].to_f
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def self.create_from_payload(trace, sdk_info = {}, account: nil, agent: nil)
|
|
85
188
|
spans = trace["spans"] || []
|
|
86
189
|
root_span = spans.find { |s| s["parent_span_id"].nil? } || spans.first || {}
|
|
87
190
|
|
|
@@ -135,9 +238,24 @@ module ActionAgent
|
|
|
135
238
|
# Add account if in multi-tenant mode
|
|
136
239
|
attrs[:account] = account if ActionAgent.multi_tenant? && account
|
|
137
240
|
|
|
241
|
+
# A trace the dashboard recorded for one of its own runs belongs to the
|
|
242
|
+
# agent it ran, which only the caller that ran it may name (+agent+,
|
|
243
|
+
# passed by AgentExecutionService). Attribute it up front: left to the
|
|
244
|
+
# registrar, the run's class and action match no authored record —
|
|
245
|
+
# those carry no service_name or agent_class_name — and every dashboard
|
|
246
|
+
# run would register an "observed" twin of the agent that produced it.
|
|
247
|
+
#
|
|
248
|
+
# Never taken from the trace itself. A payload's resource attributes are
|
|
249
|
+
# whatever the reporter sent, and ingest is unauthenticated on a
|
|
250
|
+
# single-tenant install with no ActionAgent.ingest_api_key, so trusting
|
|
251
|
+
# an id from there would let any reporter bind its traces to any
|
|
252
|
+
# dashboard-authored agent by guessing a primary key.
|
|
253
|
+
attrs[:agent_id] = agent&.id
|
|
254
|
+
|
|
138
255
|
create!(attrs).tap { |record| AgentRegistrar.call(record) }
|
|
139
256
|
end
|
|
140
257
|
|
|
258
|
+
|
|
141
259
|
# Sums a span's token counts (used to decide which spans carry the
|
|
142
260
|
# authoritative token data during ingestion).
|
|
143
261
|
#
|
|
@@ -193,8 +311,13 @@ module ActionAgent
|
|
|
193
311
|
duration_ms: span["duration_ms"],
|
|
194
312
|
status: span["status"],
|
|
195
313
|
error: attributes["error.message"],
|
|
196
|
-
|
|
197
|
-
|
|
314
|
+
# Two key families: the framework's instrumentation records
|
|
315
|
+
# tool.input.args / tool.output.result, the activeagents-telemetry
|
|
316
|
+
# ruby_llm adapter records tool.arguments / tool.result. The
|
|
317
|
+
# Interactions serializer already reads both; the Tools view's
|
|
318
|
+
# sample arguments come from here and were blank for adapter traffic.
|
|
319
|
+
arguments: attributes["tool.input.args"] || attributes["tool.arguments"],
|
|
320
|
+
result: attributes["tool.output.result"] || attributes["tool.result"]
|
|
198
321
|
}
|
|
199
322
|
end
|
|
200
323
|
end
|
|
@@ -45,6 +45,25 @@ module ActionAgent
|
|
|
45
45
|
|
|
46
46
|
Time.find_zone("UTC").parse(value.to_s).to_i
|
|
47
47
|
end
|
|
48
|
+
|
|
49
|
+
# Epoch second of the +seconds+-wide bucket holding +value+. Buckets
|
|
50
|
+
# are aligned to the Unix epoch, so the same timestamp lands in the
|
|
51
|
+
# same bucket whatever form an adapter produced it in: a Time
|
|
52
|
+
# (PostgreSQL, or any adapter's type-cast pluck), the UTC string
|
|
53
|
+
# SQLite and MySQL return from raw SQL, or an epoch number.
|
|
54
|
+
def bucket_epoch(value, seconds)
|
|
55
|
+
epoch = value.is_a?(Numeric) ? value.to_i : hour_bucket_epoch(value)
|
|
56
|
+
epoch - (epoch % seconds.to_i)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Start epochs of the +count+ consecutive +seconds+-wide buckets that
|
|
60
|
+
# end with the one containing +now+, oldest first. The last bucket is
|
|
61
|
+
# the live one, so the window these describe is
|
|
62
|
+
# [starts.first, starts.last + seconds) — it runs a little past now.
|
|
63
|
+
def bucket_starts(now, seconds, count)
|
|
64
|
+
last = bucket_epoch(now, seconds)
|
|
65
|
+
Array.new(count) { |index| last - ((count - 1 - index) * seconds) }
|
|
66
|
+
end
|
|
48
67
|
end
|
|
49
68
|
end
|
|
50
69
|
end
|
|
@@ -71,16 +71,29 @@ module ActionAgent
|
|
|
71
71
|
end
|
|
72
72
|
|
|
73
73
|
# The record's owner under the current configuration, or nil.
|
|
74
|
+
#
|
|
75
|
+
# The belongs_to is declared when the class loads, from the
|
|
76
|
+
# configuration at that moment; an owner model configured afterwards
|
|
77
|
+
# (a test, or an initializer that ran late) has the column but not the
|
|
78
|
+
# association, so the foreign key is read directly in that case.
|
|
74
79
|
def owner
|
|
75
80
|
association = self.class.owner_association
|
|
76
|
-
|
|
81
|
+
return nil unless association
|
|
82
|
+
return public_send(association) if respond_to?(association)
|
|
83
|
+
|
|
84
|
+
owner_class = ActionAgent.public_send(CLASS_FOR.fetch(association)).safe_constantize
|
|
85
|
+
owner_id = self[:"#{association}_id"]
|
|
86
|
+
owner_class.find_by(id: owner_id) if owner_class && owner_id
|
|
77
87
|
end
|
|
78
88
|
|
|
79
89
|
# Assigns +owner+ to whichever association this install uses. A no-op
|
|
80
90
|
# when the host app configured no owner model.
|
|
81
91
|
def owner=(record)
|
|
82
92
|
association = self.class.owner_association
|
|
83
|
-
|
|
93
|
+
return unless association
|
|
94
|
+
return public_send(:"#{association}=", record) if respond_to?(:"#{association}=")
|
|
95
|
+
|
|
96
|
+
self[:"#{association}_id"] = record&.id
|
|
84
97
|
end
|
|
85
98
|
end
|
|
86
99
|
end
|