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.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +14 -3
  3. data/app/assets/builds/action_agent.css +1 -1
  4. data/app/assets/builds/action_agent.js +69 -43
  5. data/app/controllers/action_agent/api/agent_runs_controller.rb +28 -8
  6. data/app/controllers/action_agent/api/agents_controller.rb +191 -56
  7. data/app/controllers/action_agent/api/analytics_controller.rb +31 -9
  8. data/app/controllers/action_agent/api/base_controller.rb +16 -0
  9. data/app/controllers/action_agent/api/dashboard_assistant_controller.rb +83 -0
  10. data/app/controllers/action_agent/api/evaluations_controller.rb +252 -7
  11. data/app/controllers/action_agent/api/interaction_messages_controller.rb +98 -0
  12. data/app/controllers/action_agent/api/mcp_controller.rb +13 -3
  13. data/app/controllers/action_agent/api/mcp_servers_controller.rb +28 -8
  14. data/app/controllers/action_agent/api/metrics_controller.rb +44 -11
  15. data/app/controllers/action_agent/api/provider_models_controller.rb +1 -1
  16. data/app/controllers/action_agent/api/sandboxes_controller.rb +6 -0
  17. data/app/controllers/action_agent/api/session_recordings_controller.rb +34 -12
  18. data/app/controllers/action_agent/api/templates_controller.rb +25 -21
  19. data/app/controllers/action_agent/api/traces_controller.rb +25 -5
  20. data/app/controllers/action_agent/api/usage_controller.rb +20 -0
  21. data/app/controllers/action_agent/application_controller.rb +25 -2
  22. data/app/controllers/action_agent/dashboard_controller.rb +3 -1
  23. data/app/controllers/concerns/action_agent/api/agent_serialization.rb +53 -0
  24. data/app/jobs/action_agent/agent_execution_job.rb +40 -20
  25. data/app/jobs/action_agent/application_job.rb +7 -3
  26. data/app/jobs/action_agent/evaluation_run_job.rb +18 -0
  27. data/app/jobs/action_agent/sandbox_cleanup_job.rb +13 -10
  28. data/app/models/action_agent/agent.rb +74 -23
  29. data/app/models/action_agent/agent_run.rb +99 -0
  30. data/app/models/action_agent/agent_template.rb +22 -7
  31. data/app/models/action_agent/evaluation.rb +64 -4
  32. data/app/models/action_agent/evaluation_run.rb +190 -2
  33. data/app/models/action_agent/evaluation_scenario.rb +59 -0
  34. data/app/models/action_agent/evaluation_scenario_result.rb +86 -0
  35. data/app/models/action_agent/recording_action.rb +11 -7
  36. data/app/models/action_agent/sandbox_session.rb +1 -1
  37. data/app/models/action_agent/session_recording.rb +31 -8
  38. data/app/models/action_agent/telemetry_trace.rb +126 -3
  39. data/app/models/concerns/action_agent/adapter_aware.rb +19 -0
  40. data/app/models/concerns/action_agent/ownable.rb +15 -2
  41. data/app/queries/action_agent/metrics_report.rb +498 -0
  42. data/app/serializers/action_agent/agent_message_serializer.rb +1 -0
  43. data/app/services/action_agent/agent_execution_service.rb +294 -16
  44. data/app/services/action_agent/agent_registrar.rb +7 -6
  45. data/app/services/action_agent/agent_toolbox.rb +49 -7
  46. data/app/services/action_agent/dashboard_assistant_service.rb +342 -0
  47. data/app/services/action_agent/evaluation_evidence.rb +234 -0
  48. data/app/services/action_agent/evaluation_runner_service.rb +13 -3
  49. data/app/services/action_agent/evaluation_tool_resolver.rb +162 -0
  50. data/app/services/action_agent/mcp_catalog.rb +46 -8
  51. data/app/services/action_agent/mcp_client.rb +167 -0
  52. data/app/services/action_agent/mcp_recording_middleware.rb +2 -2
  53. data/app/services/action_agent/mcp_tool_dispatcher.rb +116 -0
  54. data/app/services/action_agent/playwright_mcp_client.rb +11 -126
  55. data/app/services/action_agent/sandbox_orchestrator.rb +12 -1
  56. data/app/services/action_agent/scenario_evaluation_runner.rb +260 -0
  57. data/app/services/action_agent/tool_discovery.rb +22 -8
  58. data/config/routes.rb +36 -3
  59. data/lib/action_agent/assistant_request_filter.rb +22 -0
  60. data/lib/action_agent/engine.rb +106 -19
  61. data/lib/action_agent/version.rb +1 -1
  62. data/lib/action_agent.rb +104 -6
  63. data/lib/generators/action_agent/install_generator.rb +20 -7
  64. data/lib/generators/action_agent/templates/action_agent.rb.erb +12 -0
  65. data/lib/generators/action_agent/templates/create_active_agent_evaluation_scenarios.rb.erb +79 -0
  66. data/lib/tasks/action_agent.rake +9 -0
  67. 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
- private
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| McpCatalog.find(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
- def self.start!(agent_run: nil, sandbox_session: nil, name: nil)
36
- create!(
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
- create!(
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.value,
147
+ value: action.redacted_value,
125
148
  screenshot_key: action.screenshot_key,
126
- metadata: action.metadata
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
- def self.create_from_payload(trace, sdk_info = {}, account: nil)
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
- arguments: attributes["tool.input.args"],
197
- result: attributes["tool.output.result"]
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
- association && public_send(association)
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
- public_send(:"#{association}=", record) if association
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