actionagent 1.6.4 → 1.7.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 +5 -0
- data/app/assets/builds/action_agent.css +1 -1
- data/app/assets/builds/action_agent.js +55 -55
- data/app/controllers/action_agent/api/agents_controller.rb +8 -2
- data/app/controllers/action_agent/api/base_controller.rb +20 -4
- data/app/controllers/action_agent/api/dashboard_assistant_controller.rb +0 -11
- data/app/controllers/action_agent/api/evaluation_reports_controller.rb +134 -0
- data/app/controllers/action_agent/api/evaluations_controller.rb +52 -11
- data/app/controllers/action_agent/api/interactions_controller.rb +2 -3
- data/app/controllers/action_agent/api/mcp_controller.rb +3 -1
- data/app/controllers/action_agent/api/provider_models_controller.rb +4 -1
- data/app/controllers/action_agent/api/trace_reports_controller.rb +20 -1
- data/app/controllers/action_agent/api/traces_controller.rb +8 -57
- data/app/controllers/action_agent/application_controller.rb +4 -0
- data/app/controllers/concerns/action_agent/api/ingest_authentication.rb +94 -0
- data/app/models/action_agent/agent.rb +71 -3
- data/app/models/action_agent/application_record.rb +4 -0
- data/app/models/action_agent/evaluation_run.rb +61 -13
- data/app/models/action_agent/telemetry_trace.rb +4 -3
- data/app/models/concerns/action_agent/ownable.rb +18 -6
- data/app/queries/action_agent/metrics_report.rb +1 -1
- data/app/services/action_agent/agent_execution_service.rb +49 -0
- data/app/services/action_agent/agent_sync.rb +136 -0
- data/app/services/action_agent/agent_tool_roster.rb +1 -1
- data/app/services/action_agent/evaluation_report_import.rb +743 -0
- data/app/services/action_agent/evaluation_runner_service.rb +119 -10
- data/app/services/action_agent/scenario_evaluation_runner.rb +8 -3
- data/config/routes.rb +5 -0
- data/lib/action_agent/version.rb +1 -1
- data/lib/action_agent.rb +110 -13
- data/lib/generators/action_agent/install_generator.rb +26 -3
- data/lib/generators/action_agent/templates/action_agent.rb.erb +19 -3
- data/lib/generators/action_agent/templates/add_agent_releases.rb.erb +18 -13
- data/lib/generators/action_agent/templates/add_evaluation_report_identity.rb.erb +54 -0
- data/lib/generators/action_agent/templates/create_active_agent_dashboard_tables.rb.erb +34 -0
- data/lib/generators/action_agent/templates/ensure_agent_release_columns.rb.erb +51 -0
- metadata +8 -2
|
@@ -37,6 +37,7 @@ module ActionAgent
|
|
|
37
37
|
validates :provider, presence: true
|
|
38
38
|
validates :model, presence: true
|
|
39
39
|
validate :validate_action_prompts
|
|
40
|
+
validate :provider_client_installed, if: :will_save_change_to_provider?, unless: :observed?
|
|
40
41
|
|
|
41
42
|
# Status enum
|
|
42
43
|
# `observed` agents were discovered from reported telemetry rather than
|
|
@@ -52,6 +53,7 @@ module ActionAgent
|
|
|
52
53
|
before_validation :apply_conventional_schema_tools, on: :create
|
|
53
54
|
after_create :create_initial_version
|
|
54
55
|
after_update :create_version_on_config_change, if: :configuration_changed?
|
|
56
|
+
after_destroy :release_telemetry_traces, if: :observed?
|
|
55
57
|
|
|
56
58
|
# Scopes
|
|
57
59
|
scope :active_agents, -> { where(status: :active) }
|
|
@@ -122,9 +124,6 @@ module ActionAgent
|
|
|
122
124
|
# Available providers
|
|
123
125
|
PROVIDERS = %w[openai anthropic ollama openrouter].freeze
|
|
124
126
|
|
|
125
|
-
# The ActiveAgent class name this agent's runs are recorded under — the
|
|
126
|
-
# correlation key between platform Agent records and telemetry traces
|
|
127
|
-
# (TelemetryTrace#agent_class) and solid_agent contexts.
|
|
128
127
|
# Tools a schema tool class claims for this agent by naming convention:
|
|
129
128
|
# Reservation -> ReservationTools -> ReservationAgent.
|
|
130
129
|
#
|
|
@@ -156,11 +155,58 @@ module ActionAgent
|
|
|
156
155
|
self.tools = defaults if defaults.any?
|
|
157
156
|
end
|
|
158
157
|
|
|
158
|
+
# The ActiveAgent class name this agent's runs are recorded under — the
|
|
159
|
+
# correlation key between platform Agent records and telemetry traces
|
|
160
|
+
# (TelemetryTrace#agent_class) and solid_agent contexts. An observed
|
|
161
|
+
# agent's reported class need not end in Agent, so its traces are
|
|
162
|
+
# matched on #reported_agent_class rather than on this name.
|
|
159
163
|
def telemetry_agent_class
|
|
160
164
|
base = agent_class_name.presence || name.parameterize(separator: "_").camelize
|
|
161
165
|
base.end_with?("Agent") ? base : "#{base}Agent"
|
|
162
166
|
end
|
|
163
167
|
|
|
168
|
+
# Returns the class name this agent's traces carry in
|
|
169
|
+
# TelemetryTrace#agent_class: the application's own class for an observed
|
|
170
|
+
# agent (`"SupportBot"`), #telemetry_agent_class for any other.
|
|
171
|
+
def reported_agent_class
|
|
172
|
+
observed? ? agent_class_name : telemetry_agent_class
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# The traces in +traces+ recorded for this agent.
|
|
176
|
+
#
|
|
177
|
+
# An observed agent is registered from its application's own class name,
|
|
178
|
+
# one agent per action, so its traces are the ones AgentRegistrar
|
|
179
|
+
# attributed to it plus #unattributed_telemetry_traces. Every other agent
|
|
180
|
+
# reads every trace reported under #telemetry_agent_class, attributed or
|
|
181
|
+
# not.
|
|
182
|
+
#
|
|
183
|
+
# @param traces [ActiveRecord::Relation] the traces the caller may read,
|
|
184
|
+
# already narrowed to its tenant
|
|
185
|
+
# @return [ActiveRecord::Relation]
|
|
186
|
+
def telemetry_traces(traces)
|
|
187
|
+
return traces.for_agent(reported_agent_class) unless observed?
|
|
188
|
+
|
|
189
|
+
traces.where(agent_id: id).or(unattributed_telemetry_traces(traces))
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# The traces in +traces+ attributed to no agent that carry this agent's
|
|
193
|
+
# identity, such as those ingested before AgentRegistrar ran or recorded
|
|
194
|
+
# for an observed agent since deleted. An observed agent's identity is the
|
|
195
|
+
# service, class and action it was registered from; any other agent's is
|
|
196
|
+
# #telemetry_agent_class.
|
|
197
|
+
#
|
|
198
|
+
# @param traces [ActiveRecord::Relation] the traces the caller may read,
|
|
199
|
+
# already narrowed to its tenant
|
|
200
|
+
# @return [ActiveRecord::Relation]
|
|
201
|
+
def unattributed_telemetry_traces(traces)
|
|
202
|
+
identity = { agent_id: nil, agent_class: reported_agent_class }
|
|
203
|
+
# AgentRegistrar registers a trace with an empty action under a nil
|
|
204
|
+
# action_name, so both spellings are this agent's.
|
|
205
|
+
identity.update(service_name: service_name, agent_action: action_name.nil? ? [ nil, "" ] : action_name) if observed?
|
|
206
|
+
|
|
207
|
+
traces.where(identity)
|
|
208
|
+
end
|
|
209
|
+
|
|
164
210
|
# The agent's long-term memory (solid_agent HasMemory contract) — the
|
|
165
211
|
# summary list its runs read/write via the memory tools.
|
|
166
212
|
def memory
|
|
@@ -390,6 +436,13 @@ module ActionAgent
|
|
|
390
436
|
|
|
391
437
|
private
|
|
392
438
|
|
|
439
|
+
# Leaves the traces AgentRegistrar attributed to this observed agent
|
|
440
|
+
# unattributed, so an agent registered again for the same identity reads
|
|
441
|
+
# them through #unattributed_telemetry_traces.
|
|
442
|
+
def release_telemetry_traces
|
|
443
|
+
ActionAgent.trace_model.where(agent_id: id).update_all(agent_id: nil)
|
|
444
|
+
end
|
|
445
|
+
|
|
393
446
|
# Refuses files before creating anything: a run that exists but lost
|
|
394
447
|
# its attachments would execute against the wrong prompt.
|
|
395
448
|
def create_run(input_prompt, action:, attachments:, params:, actor: nil, **attributes)
|
|
@@ -491,6 +544,21 @@ module ActionAgent
|
|
|
491
544
|
action && available_actions.include?(action) ? action : nil
|
|
492
545
|
end
|
|
493
546
|
|
|
547
|
+
# Provider client gems are optional dependencies of activeagent (OpenAI,
|
|
548
|
+
# Ollama and OpenRouter need `openai`, Anthropic needs `anthropic`), so a
|
|
549
|
+
# provider can be picked here that the host never installed. Refuse it
|
|
550
|
+
# when it is chosen, naming the gem, rather than on the agent's first
|
|
551
|
+
# run. Only the providers the engine offers are checked; the host's
|
|
552
|
+
# config/active_agent.yml may point one at another service.
|
|
553
|
+
def provider_client_installed
|
|
554
|
+
return unless PROVIDERS.include?(provider)
|
|
555
|
+
|
|
556
|
+
service = ActiveAgent::Base.provider_config_load(provider)[:service] || provider.camelize
|
|
557
|
+
ActiveAgent::Base.provider_load(service)
|
|
558
|
+
rescue LoadError => e
|
|
559
|
+
errors.add(:provider, "#{provider} can't be used yet: #{e.message}")
|
|
560
|
+
end
|
|
561
|
+
|
|
494
562
|
def validate_action_prompts
|
|
495
563
|
return if action_prompts.blank?
|
|
496
564
|
|
|
@@ -17,6 +17,10 @@ module ActionAgent
|
|
|
17
17
|
|
|
18
18
|
self.abstract_class = true
|
|
19
19
|
|
|
20
|
+
# After abstract_class, so a concern's included block that reads
|
|
21
|
+
# table_name sees nil.
|
|
22
|
+
ActionAgent.model_concern_modules.each { |concern| include concern }
|
|
23
|
+
|
|
20
24
|
# Models that are not themselves owned still answer the ownership
|
|
21
25
|
# questions, so callers can scope any dashboard relation uniformly.
|
|
22
26
|
class << self
|
|
@@ -79,26 +79,74 @@ module ActionAgent
|
|
|
79
79
|
(values.sum.to_f / values.size).round(3)
|
|
80
80
|
end
|
|
81
81
|
|
|
82
|
-
#
|
|
83
|
-
#
|
|
84
|
-
#
|
|
85
|
-
|
|
82
|
+
# The judge's own spend on this run — calls, tokens, estimated cost and
|
|
83
|
+
# how many calls served each purpose — as the runner recorded it; nil
|
|
84
|
+
# for a run that never asked a judge.
|
|
85
|
+
def judge_usage
|
|
86
|
+
value = scores&.dig("_judge_usage")
|
|
87
|
+
value.is_a?(Hash) ? value : nil
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Per-model summaries of a generation-sampling run's cohorts, keyed by
|
|
91
|
+
# model; empty for a scenario run or a run recorded before they were.
|
|
92
|
+
def cohorts
|
|
93
|
+
value = scores&.dig("_cohorts")
|
|
94
|
+
value.is_a?(Hash) ? value : {}
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# What the run spent, for display after it: the agent's side and the
|
|
98
|
+
# judge's, kept apart because they answer different questions.
|
|
99
|
+
#
|
|
100
|
+
# The agent's side is the operating figure — what the interactions cost
|
|
101
|
+
# to serve. For a scenario run that is the replays' estimated cost,
|
|
102
|
+
# tokens and summed model time (`replays` of them); for a
|
|
103
|
+
# generation-sampling run it is the sampled generations' (`samples`),
|
|
104
|
+
# which were served before the run and cost it nothing. `per_interaction`
|
|
105
|
+
# is that cost spread over the interactions, the number a per-conversation
|
|
106
|
+
# budget is set against.
|
|
107
|
+
#
|
|
108
|
+
# `judge` is the evaluation's own overhead: the judge model's calls
|
|
109
|
+
# (scoring, recommending, the verdict, authoring KPIs), which run
|
|
110
|
+
# agent-to-agent and offline. It is present only when a judge was asked.
|
|
111
|
+
#
|
|
112
|
+
# Returns nil for a run that recorded nothing on either side.
|
|
86
113
|
def usage
|
|
87
114
|
totals = scenario_results.pick(
|
|
88
115
|
Arel.sql("COUNT(*)"), Arel.sql("SUM(cost)"), Arel.sql("SUM(input_tokens)"),
|
|
89
116
|
Arel.sql("SUM(output_tokens)"), Arel.sql("SUM(duration_ms)")
|
|
90
117
|
)
|
|
91
118
|
replays = totals&.first.to_i
|
|
92
|
-
|
|
119
|
+
judge = judge_usage
|
|
120
|
+
runtime_ms = completed_at.present? ? ((completed_at - created_at) * 1000).round : nil
|
|
93
121
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
122
|
+
if replays.positive?
|
|
123
|
+
cost = totals[1]&.to_f
|
|
124
|
+
{
|
|
125
|
+
replays: replays,
|
|
126
|
+
cost: cost,
|
|
127
|
+
per_interaction: cost && (cost / replays).round(6),
|
|
128
|
+
input_tokens: totals[2].to_i,
|
|
129
|
+
output_tokens: totals[3].to_i,
|
|
130
|
+
model_time_ms: totals[4].to_i,
|
|
131
|
+
runtime_ms: runtime_ms,
|
|
132
|
+
judge: judge
|
|
133
|
+
}.compact
|
|
134
|
+
elsif cohorts.any?
|
|
135
|
+
samples = cohorts.values.sum { |cohort| cohort["samples"].to_i }
|
|
136
|
+
costs = cohorts.values.filter_map { |cohort| cohort["cost"] }
|
|
137
|
+
cost = costs.any? ? costs.sum.to_f.round(6) : nil
|
|
138
|
+
{
|
|
139
|
+
samples: samples,
|
|
140
|
+
cost: cost,
|
|
141
|
+
per_interaction: cost && samples.positive? ? (cost / samples).round(6) : nil,
|
|
142
|
+
input_tokens: cohorts.values.sum { |cohort| cohort["input_tokens"].to_i },
|
|
143
|
+
output_tokens: cohorts.values.sum { |cohort| cohort["output_tokens"].to_i },
|
|
144
|
+
runtime_ms: runtime_ms,
|
|
145
|
+
judge: judge
|
|
146
|
+
}.compact
|
|
147
|
+
elsif judge
|
|
148
|
+
{ runtime_ms: runtime_ms, judge: judge }.compact
|
|
149
|
+
end
|
|
102
150
|
end
|
|
103
151
|
|
|
104
152
|
# Route templates for the report's fix item actions, relative to the
|
|
@@ -16,9 +16,10 @@ module ActionAgent
|
|
|
16
16
|
# @example Creating a trace with account (multi-tenant mode)
|
|
17
17
|
# ActionAgent::TelemetryTrace.create_from_payload(trace_payload, sdk_info, account: account)
|
|
18
18
|
#
|
|
19
|
-
class TelemetryTrace <
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
class TelemetryTrace < ApplicationRecord
|
|
20
|
+
# Fixed rather than derived from ActionAgent.table_name_prefix: the
|
|
21
|
+
# telemetry migration the install generator writes creates this table
|
|
22
|
+
# under its literal name, whatever prefix the host sets.
|
|
22
23
|
self.table_name = "active_agent_telemetry_traces"
|
|
23
24
|
|
|
24
25
|
# Optional account association for multi-tenant mode
|
|
@@ -73,13 +73,14 @@ module ActionAgent
|
|
|
73
73
|
# The record's owner under the current configuration, or nil.
|
|
74
74
|
#
|
|
75
75
|
# The belongs_to is declared when the class loads, from the
|
|
76
|
-
# configuration at that moment
|
|
77
|
-
# (a test, or an initializer that ran late) has the column but
|
|
78
|
-
# association, so the foreign key is
|
|
76
|
+
# configuration at that moment. An owner model configured afterwards
|
|
77
|
+
# (a test, or an initializer that ran late) has the column but either no
|
|
78
|
+
# association or one declared for another class, so the foreign key is
|
|
79
|
+
# read directly in that case.
|
|
79
80
|
def owner
|
|
80
81
|
association = self.class.owner_association
|
|
81
82
|
return nil unless association
|
|
82
|
-
return public_send(association) if
|
|
83
|
+
return public_send(association) if owner_association_current?(association)
|
|
83
84
|
|
|
84
85
|
owner_class = ActionAgent.public_send(CLASS_FOR.fetch(association)).safe_constantize
|
|
85
86
|
owner_id = self[:"#{association}_id"]
|
|
@@ -87,13 +88,24 @@ module ActionAgent
|
|
|
87
88
|
end
|
|
88
89
|
|
|
89
90
|
# Assigns +owner+ to whichever association this install uses. A no-op
|
|
90
|
-
# when the host app configured no owner model.
|
|
91
|
+
# when the host app configured no owner model. Writes the foreign key
|
|
92
|
+
# directly when the association is missing or was declared for another
|
|
93
|
+
# class, as #owner reads it.
|
|
91
94
|
def owner=(record)
|
|
92
95
|
association = self.class.owner_association
|
|
93
96
|
return unless association
|
|
94
|
-
return public_send(:"#{association}=", record) if
|
|
97
|
+
return public_send(:"#{association}=", record) if owner_association_current?(association)
|
|
95
98
|
|
|
96
99
|
self[:"#{association}_id"] = record&.id
|
|
97
100
|
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
# Whether +association+ was declared for the class the configuration
|
|
105
|
+
# names now.
|
|
106
|
+
def owner_association_current?(association)
|
|
107
|
+
reflection = self.class.reflect_on_association(association)
|
|
108
|
+
reflection.present? && reflection.class_name == ActionAgent.public_send(CLASS_FOR.fetch(association)).to_s
|
|
109
|
+
end
|
|
98
110
|
end
|
|
99
111
|
end
|
|
@@ -440,7 +440,7 @@ module ActionAgent
|
|
|
440
440
|
versions.filter_map do |version|
|
|
441
441
|
agent = version.agent
|
|
442
442
|
next if agent.nil?
|
|
443
|
-
next if @agent && agent.
|
|
443
|
+
next if @agent && agent.reported_agent_class != @agent
|
|
444
444
|
|
|
445
445
|
previous = predecessors[[ version.agent_id, version.version_number - 1 ]]
|
|
446
446
|
instructions_changed = snapshot_of(version)["instructions"].to_s != snapshot_of(previous)["instructions"].to_s
|
|
@@ -500,6 +500,21 @@ module ActionAgent
|
|
|
500
500
|
tool_definitions = tool_schemas
|
|
501
501
|
service = self
|
|
502
502
|
|
|
503
|
+
# A dashboard-authored agent has no Ruby class — it is rows: a tool
|
|
504
|
+
# selection, instructions typed in the builder. That is the common case
|
|
505
|
+
# and the runtime below builds a class for it.
|
|
506
|
+
#
|
|
507
|
+
# An agent mirrored from host code is the other case: the class exists,
|
|
508
|
+
# already declares its own tools (SchemaTools rosters, delegations) and
|
|
509
|
+
# renders its own instructions, and none of that is reachable through
|
|
510
|
+
# `tools` + `instructions` columns. Running the real class keeps the
|
|
511
|
+
# dashboard evaluating what production runs, instead of a rebuilt
|
|
512
|
+
# lookalike. Both runtimes stay; which one applies is decided by whether
|
|
513
|
+
# the class resolves.
|
|
514
|
+
if (host_class = resolved_host_class)
|
|
515
|
+
return run_host_class(host_class, actor: actor, action: action, run_trace_id: run_trace_id)
|
|
516
|
+
end
|
|
517
|
+
|
|
503
518
|
agent_class = Class.new(ActiveAgent::Base) do
|
|
504
519
|
# SolidAgent persists contexts under self.class.name; anonymous
|
|
505
520
|
# classes would fail its agent_name presence validation.
|
|
@@ -677,6 +692,40 @@ module ActionAgent
|
|
|
677
692
|
end
|
|
678
693
|
end
|
|
679
694
|
|
|
695
|
+
# The host class this agent mirrors, when it names one that resolves to a
|
|
696
|
+
# runnable ActiveAgent::Base subclass. Anything else — no class name, a
|
|
697
|
+
# class that no longer exists, a name that resolves to something else — is
|
|
698
|
+
# nil, and the dynamic runtime handles the record as before.
|
|
699
|
+
#
|
|
700
|
+
# @return [Class, nil]
|
|
701
|
+
def resolved_host_class
|
|
702
|
+
return nil unless ActionAgent.run_host_agent_classes
|
|
703
|
+
|
|
704
|
+
name = @agent_record.agent_class_name.presence
|
|
705
|
+
return nil if name.blank?
|
|
706
|
+
|
|
707
|
+
klass = name.safe_constantize
|
|
708
|
+
klass if klass.is_a?(Class) && klass < ActiveAgent::Base
|
|
709
|
+
end
|
|
710
|
+
|
|
711
|
+
# Runs the host's own class. Its tools, delegations and instructions come
|
|
712
|
+
# from the code, so the engine supplies only what is the run's business:
|
|
713
|
+
# the caller, and the trace to correlate against.
|
|
714
|
+
def run_host_class(klass, actor:, action:, run_trace_id:)
|
|
715
|
+
generation = klass.as(actor).public_send(action, **host_action_arguments(klass, action))
|
|
716
|
+
generation.prompt_options[:trace_id] = run_trace_id if generation.respond_to?(:prompt_options)
|
|
717
|
+
generation.generate_now
|
|
718
|
+
end
|
|
719
|
+
|
|
720
|
+
# A code agent's action takes named arguments (`ask(question:)`), so the
|
|
721
|
+
# run's message is passed under the action's own keyword rather than as a
|
|
722
|
+
# bare message the signature would reject.
|
|
723
|
+
def host_action_arguments(klass, action)
|
|
724
|
+
contract = klass.try(:delegation_contracts)&.dig(action.to_sym)
|
|
725
|
+
keyword = contract&.try(:parameters)&.keys&.first
|
|
726
|
+
keyword ? { keyword.to_sym => user_text } : {}
|
|
727
|
+
end
|
|
728
|
+
|
|
680
729
|
def provider_available?(name)
|
|
681
730
|
# The gem's mock provider is a test double: accepted only in the test
|
|
682
731
|
# environment so app runs can never store fabricated output.
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActionAgent
|
|
4
|
+
# Mirrors host ActiveAgent classes into Agent records, so the dashboard can
|
|
5
|
+
# run, evaluate and release the agents an app already has in code.
|
|
6
|
+
#
|
|
7
|
+
# The engine reads an agent's identity from the class rather than asking the
|
|
8
|
+
# host to restate it: name, description and tool roster all come from the
|
|
9
|
+
# class, and re-running the sync updates each record in place. `AgentRelease`
|
|
10
|
+
# already expects `agent_class_name` to be set by "whatever syncs its
|
|
11
|
+
# ActiveAgent classes into Agent records" — this is that, so a host no longer
|
|
12
|
+
# has to write it.
|
|
13
|
+
#
|
|
14
|
+
# The split is deliberate and is the reason this is safe to run on deploy:
|
|
15
|
+
#
|
|
16
|
+
# * **The code owns what an agent is** — name, description, instructions,
|
|
17
|
+
# tools. Rewritten on every sync, so it cannot drift from the class.
|
|
18
|
+
# * **The operator owns how it runs** — provider, model, status. Set once on
|
|
19
|
+
# create and never touched again, so a model chosen in the dashboard
|
|
20
|
+
# survives the next deploy.
|
|
21
|
+
#
|
|
22
|
+
# ActionAgent::AgentSync.call(RecordAgent.all, owner: owner)
|
|
23
|
+
#
|
|
24
|
+
# @see AgentRelease which cuts a version per synced agent
|
|
25
|
+
class AgentSync
|
|
26
|
+
Row = Struct.new(:agent, :created, :skipped, keyword_init: true)
|
|
27
|
+
Result = Struct.new(:rows, :errors, keyword_init: true) do
|
|
28
|
+
def success? = errors.blank?
|
|
29
|
+
def agents = rows.filter_map(&:agent)
|
|
30
|
+
def created = rows.select(&:created)
|
|
31
|
+
def skipped = rows.select(&:skipped)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @param agents [Array<Class>] ActiveAgent::Base subclasses
|
|
35
|
+
# @param owner [Object] the record agents and API keys scope to
|
|
36
|
+
# @param provider [String, Symbol, nil] defaults to the class's own
|
|
37
|
+
# @param model [String, nil] defaults to the class's own
|
|
38
|
+
def self.call(agents, owner:, provider: nil, model: nil)
|
|
39
|
+
new(agents, owner: owner, provider: provider, model: model).call
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def initialize(agents, owner:, provider: nil, model: nil)
|
|
43
|
+
@agents = Array(agents)
|
|
44
|
+
@owner = owner
|
|
45
|
+
@provider = provider
|
|
46
|
+
@model = model
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @return [Result]
|
|
50
|
+
def call
|
|
51
|
+
return Result.new(rows: [], errors: "An owner is required: the engine scopes agents to one.") if @owner.nil?
|
|
52
|
+
|
|
53
|
+
rows = Agent.transaction { @agents.map { |klass| upsert(klass) } }
|
|
54
|
+
Result.new(rows: rows, errors: nil)
|
|
55
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
56
|
+
Result.new(rows: [], errors: e.record.errors.full_messages.join(", "))
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def upsert(klass)
|
|
62
|
+
unless klass.respond_to?(:prompt_options)
|
|
63
|
+
return Row.new(agent: nil, created: false, skipped: "#{klass} is not an ActiveAgent::Base subclass")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
provider = resolved_provider(klass)
|
|
67
|
+
model = resolved_model(klass)
|
|
68
|
+
if provider.blank? || model.blank?
|
|
69
|
+
return Row.new(agent: nil, created: false, skipped: "#{klass} has no provider/model configured")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Slugs are unique per owner, so the lookup is too: unscoped, a second
|
|
73
|
+
# owner's sync would find the first owner's record and rewrite it.
|
|
74
|
+
agent = Agent.for_owner(@owner).find_or_initialize_by(slug: self.class.slug_for(klass))
|
|
75
|
+
created = agent.new_record?
|
|
76
|
+
if created
|
|
77
|
+
agent.owner = @owner
|
|
78
|
+
agent.provider = provider
|
|
79
|
+
agent.model = model
|
|
80
|
+
agent.status = :active
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
agent.assign_attributes(
|
|
84
|
+
name: klass.name.titleize,
|
|
85
|
+
agent_class_name: klass.name,
|
|
86
|
+
description: description_for(klass),
|
|
87
|
+
instructions: instructions_for(klass),
|
|
88
|
+
tools: tool_names_for(klass)
|
|
89
|
+
)
|
|
90
|
+
agent.save!
|
|
91
|
+
Row.new(agent: agent, created: created, skipped: nil)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# "TicketAgent" -> "ticket-agent", the slug an MCP client sees as
|
|
95
|
+
# run_ticket-agent. A namespaced class flattens its separators, because
|
|
96
|
+
# Agent validates slugs as /\A[a-z0-9\-_]+\z/ — "Billing::TicketAgent"
|
|
97
|
+
# becomes "billing-ticket-agent".
|
|
98
|
+
def self.slug_for(klass)
|
|
99
|
+
klass.name.underscore.tr("/", "-").tr("_", "-")
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# An agent's own description if it declares one (a delegation contract is
|
|
103
|
+
# where an agent says what it answers), else its titleized name.
|
|
104
|
+
def description_for(klass)
|
|
105
|
+
contract = klass.try(:delegation_contracts)&.values&.first
|
|
106
|
+
contract&.try(:description).presence || klass.name.titleize
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# The rendered instructions, so the dashboard record runs on the same text
|
|
110
|
+
# the class does rather than a hand-maintained copy.
|
|
111
|
+
#
|
|
112
|
+
# An agent whose instructions are assembled rather than rendered straight
|
|
113
|
+
# from its own template — filled from assigns it computes, or falling back
|
|
114
|
+
# to a template it shares with sibling agents — says so by defining
|
|
115
|
+
# `dashboard_instructions_text`. That is asked first, because only the
|
|
116
|
+
# class knows how its own prompt is built.
|
|
117
|
+
def instructions_for(klass)
|
|
118
|
+
return klass.dashboard_instructions_text.presence if klass.respond_to?(:dashboard_instructions_text)
|
|
119
|
+
|
|
120
|
+
klass.try(:rendered_instructions).presence
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def tool_names_for(klass)
|
|
124
|
+
names = klass.try(:tool_names)
|
|
125
|
+
Array(names).map(&:to_s)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def resolved_provider(klass)
|
|
129
|
+
(@provider || klass.prompt_options[:service]).to_s.downcase.presence
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def resolved_model(klass)
|
|
133
|
+
(@model || klass.prompt_options[:model]).presence
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -48,7 +48,7 @@ module ActionAgent
|
|
|
48
48
|
def initialize(agent:, traces:, hours: ToolDiscovery::DEFAULT_WINDOW_HOURS)
|
|
49
49
|
@agent = agent
|
|
50
50
|
@discovery = ToolDiscovery.new(
|
|
51
|
-
traces:
|
|
51
|
+
traces: agent.telemetry_traces(traces),
|
|
52
52
|
agents: Agent.where(id: agent.id),
|
|
53
53
|
hours: hours
|
|
54
54
|
)
|