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.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +5 -0
  3. data/app/assets/builds/action_agent.css +1 -1
  4. data/app/assets/builds/action_agent.js +55 -55
  5. data/app/controllers/action_agent/api/agents_controller.rb +8 -2
  6. data/app/controllers/action_agent/api/base_controller.rb +20 -4
  7. data/app/controllers/action_agent/api/dashboard_assistant_controller.rb +0 -11
  8. data/app/controllers/action_agent/api/evaluation_reports_controller.rb +134 -0
  9. data/app/controllers/action_agent/api/evaluations_controller.rb +52 -11
  10. data/app/controllers/action_agent/api/interactions_controller.rb +2 -3
  11. data/app/controllers/action_agent/api/mcp_controller.rb +3 -1
  12. data/app/controllers/action_agent/api/provider_models_controller.rb +4 -1
  13. data/app/controllers/action_agent/api/trace_reports_controller.rb +20 -1
  14. data/app/controllers/action_agent/api/traces_controller.rb +8 -57
  15. data/app/controllers/action_agent/application_controller.rb +4 -0
  16. data/app/controllers/concerns/action_agent/api/ingest_authentication.rb +94 -0
  17. data/app/models/action_agent/agent.rb +71 -3
  18. data/app/models/action_agent/application_record.rb +4 -0
  19. data/app/models/action_agent/evaluation_run.rb +61 -13
  20. data/app/models/action_agent/telemetry_trace.rb +4 -3
  21. data/app/models/concerns/action_agent/ownable.rb +18 -6
  22. data/app/queries/action_agent/metrics_report.rb +1 -1
  23. data/app/services/action_agent/agent_execution_service.rb +49 -0
  24. data/app/services/action_agent/agent_sync.rb +136 -0
  25. data/app/services/action_agent/agent_tool_roster.rb +1 -1
  26. data/app/services/action_agent/evaluation_report_import.rb +743 -0
  27. data/app/services/action_agent/evaluation_runner_service.rb +119 -10
  28. data/app/services/action_agent/scenario_evaluation_runner.rb +8 -3
  29. data/config/routes.rb +5 -0
  30. data/lib/action_agent/version.rb +1 -1
  31. data/lib/action_agent.rb +110 -13
  32. data/lib/generators/action_agent/install_generator.rb +26 -3
  33. data/lib/generators/action_agent/templates/action_agent.rb.erb +19 -3
  34. data/lib/generators/action_agent/templates/add_agent_releases.rb.erb +18 -13
  35. data/lib/generators/action_agent/templates/add_evaluation_report_identity.rb.erb +54 -0
  36. data/lib/generators/action_agent/templates/create_active_agent_dashboard_tables.rb.erb +34 -0
  37. data/lib/generators/action_agent/templates/ensure_agent_release_columns.rb.erb +51 -0
  38. 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
- # Aggregate usage over the run's scenario results, for display after a
83
- # run: estimated cost, token totals, summed model time, and the run's
84
- # wall-clock runtime. Returns nil for a generation-sampling run, which
85
- # replays nothing itself.
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
- return nil if replays.zero?
119
+ judge = judge_usage
120
+ runtime_ms = completed_at.present? ? ((completed_at - created_at) * 1000).round : nil
93
121
 
94
- {
95
- replays: replays,
96
- cost: totals[1]&.to_f,
97
- input_tokens: totals[2].to_i,
98
- output_tokens: totals[3].to_i,
99
- model_time_ms: totals[4].to_i,
100
- runtime_ms: completed_at.present? ? ((completed_at - created_at) * 1000).round : nil
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 < ::ActiveRecord::Base
20
- include ActionAgent::AdapterAware
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; 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.
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 respond_to?(association)
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 respond_to?(:"#{association}=")
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.telemetry_agent_class != @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: traces.for_agent(agent.telemetry_agent_class),
51
+ traces: agent.telemetry_traces(traces),
52
52
  agents: Agent.where(id: agent.id),
53
53
  hours: hours
54
54
  )