actionagent 1.8.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (168) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/builds/action_agent.css +1 -1
  3. data/app/assets/builds/action_agent.js +76 -73
  4. data/app/assets/builds/action_agent_recorder.js +78 -0
  5. data/app/assets/builds/action_agent_replay.js +226 -0
  6. data/app/controllers/action_agent/api/agent_runs_controller.rb +3 -1
  7. data/app/controllers/action_agent/api/agents_controller.rb +1 -0
  8. data/app/controllers/action_agent/api/api_keys_controller.rb +12 -2
  9. data/app/controllers/action_agent/api/base_controller.rb +63 -2
  10. data/app/controllers/action_agent/api/claude_logins_controller.rb +93 -0
  11. data/app/controllers/action_agent/api/code_sessions_controller.rb +71 -12
  12. data/app/controllers/action_agent/api/dashboard_assistant_controller.rb +2 -2
  13. data/app/controllers/action_agent/api/draft_pull_requests_controller.rb +248 -0
  14. data/app/controllers/action_agent/api/evaluation_fixes_controller.rb +27 -0
  15. data/app/controllers/action_agent/api/evaluations_controller.rb +58 -7
  16. data/app/controllers/action_agent/api/explorations_controller.rb +213 -0
  17. data/app/controllers/action_agent/api/github_app_manifests_controller.rb +125 -0
  18. data/app/controllers/action_agent/api/github_connections_controller.rb +40 -2
  19. data/app/controllers/action_agent/api/github_installations_controller.rb +280 -0
  20. data/app/controllers/action_agent/api/input_requests_controller.rb +77 -0
  21. data/app/controllers/action_agent/api/mcp_controller.rb +46 -12
  22. data/app/controllers/action_agent/api/members_controller.rb +36 -0
  23. data/app/controllers/action_agent/api/project_explorations_controller.rb +80 -0
  24. data/app/controllers/action_agent/api/project_install_pull_requests_controller.rb +305 -0
  25. data/app/controllers/action_agent/api/project_secrets_controller.rb +96 -0
  26. data/app/controllers/action_agent/api/project_setup_controller.rb +151 -0
  27. data/app/controllers/action_agent/api/project_sign_ins_controller.rb +112 -0
  28. data/app/controllers/action_agent/api/projects_controller.rb +512 -0
  29. data/app/controllers/action_agent/api/provider_keys_controller.rb +176 -24
  30. data/app/controllers/action_agent/api/provider_models_controller.rb +37 -15
  31. data/app/controllers/action_agent/api/recording_event_ingest_controller.rb +67 -0
  32. data/app/controllers/action_agent/api/sandbox_browser_tickets_controller.rb +50 -0
  33. data/app/controllers/action_agent/api/sandbox_browsers_controller.rb +65 -0
  34. data/app/controllers/action_agent/api/sandboxes_controller.rb +148 -8
  35. data/app/controllers/action_agent/api/scenario_catalog_entries_controller.rb +188 -0
  36. data/app/controllers/action_agent/api/scenario_catalogs_controller.rb +302 -0
  37. data/app/controllers/action_agent/api/session_recordings_controller.rb +165 -34
  38. data/app/controllers/action_agent/api/sessions_controller.rb +111 -0
  39. data/app/controllers/action_agent/api/telemetry_keys_controller.rb +21 -0
  40. data/app/controllers/action_agent/dashboard_controller.rb +17 -6
  41. data/app/controllers/action_agent/session_player_controller.rb +58 -0
  42. data/app/controllers/concerns/action_agent/api/agent_serialization.rb +1 -0
  43. data/app/controllers/concerns/action_agent/api/mcp_dashboard_tools.rb +627 -20
  44. data/app/controllers/concerns/action_agent/api/project_browser.rb +46 -0
  45. data/app/controllers/concerns/action_agent/api/project_secret_authorization.rb +57 -0
  46. data/app/jobs/action_agent/agent_execution_job.rb +9 -39
  47. data/app/jobs/action_agent/agent_resume_job.rb +84 -0
  48. data/app/jobs/action_agent/code_session_job.rb +39 -10
  49. data/app/jobs/action_agent/draft_pull_request_job.rb +43 -0
  50. data/app/jobs/action_agent/exploration_job.rb +80 -0
  51. data/app/jobs/action_agent/input_request_expiry_job.rb +22 -0
  52. data/app/jobs/action_agent/project_evaluation_job.rb +59 -0
  53. data/app/jobs/action_agent/project_setup_job.rb +21 -0
  54. data/app/jobs/action_agent/sandbox_cleanup_job.rb +20 -3
  55. data/app/jobs/action_agent/sandbox_provision_job.rb +77 -10
  56. data/app/jobs/action_agent/sandbox_run_job.rb +9 -37
  57. data/app/jobs/action_agent/verify_evaluation_fix_job.rb +37 -0
  58. data/app/models/action_agent/agent.rb +72 -26
  59. data/app/models/action_agent/agent_run.rb +134 -23
  60. data/app/models/action_agent/agent_template.rb +35 -1
  61. data/app/models/action_agent/api_key.rb +14 -0
  62. data/app/models/action_agent/catalog_scenario.rb +75 -0
  63. data/app/models/action_agent/code_session.rb +21 -5
  64. data/app/models/action_agent/draft_pull_request.rb +131 -0
  65. data/app/models/action_agent/evaluation.rb +148 -0
  66. data/app/models/action_agent/evaluation_run.rb +143 -53
  67. data/app/models/action_agent/evaluation_scenario_result.rb +15 -3
  68. data/app/models/action_agent/exploration.rb +847 -0
  69. data/app/models/action_agent/github_installation.rb +159 -0
  70. data/app/models/action_agent/input_request.rb +291 -0
  71. data/app/models/action_agent/model_pricing.rb +214 -34
  72. data/app/models/action_agent/project.rb +789 -0
  73. data/app/models/action_agent/project_secret.rb +339 -0
  74. data/app/models/action_agent/provider_key.rb +207 -4
  75. data/app/models/action_agent/recording_event.rb +145 -0
  76. data/app/models/action_agent/recording_snapshot.rb +7 -7
  77. data/app/models/action_agent/sandbox_session.rb +300 -16
  78. data/app/models/action_agent/scenario_catalog.rb +172 -0
  79. data/app/models/action_agent/scenario_product.rb +57 -0
  80. data/app/models/action_agent/scenario_set.rb +138 -0
  81. data/app/models/action_agent/session_recording.rb +121 -14
  82. data/app/models/action_agent/telemetry_trace.rb +74 -19
  83. data/app/models/concerns/action_agent/ownable.rb +84 -13
  84. data/app/serializers/action_agent/evaluation_serializer.rb +41 -10
  85. data/app/serializers/action_agent/input_request_serializer.rb +47 -0
  86. data/app/serializers/action_agent/scenario_catalog_serializer.rb +23 -0
  87. data/app/serializers/action_agent/session_summary_serializer.rb +146 -0
  88. data/app/services/action_agent/agent_execution_service.rb +450 -72
  89. data/app/services/action_agent/agent_scorecard.rb +50 -21
  90. data/app/services/action_agent/agent_sync.rb +5 -2
  91. data/app/services/action_agent/agent_tool_roster.rb +19 -4
  92. data/app/services/action_agent/agent_toolbox.rb +215 -9
  93. data/app/services/action_agent/browser_live_ticket.rb +78 -0
  94. data/app/services/action_agent/browser_sidecar.rb +213 -0
  95. data/app/services/action_agent/browser_sign_in.rb +163 -0
  96. data/app/services/action_agent/browser_tool_redaction.rb +92 -0
  97. data/app/services/action_agent/claude_code_auth.rb +70 -4
  98. data/app/services/action_agent/codex_session_events.rb +37 -0
  99. data/app/services/action_agent/dashboard_assistant_service.rb +24 -10
  100. data/app/services/action_agent/draft_pull_request_publisher.rb +719 -0
  101. data/app/services/action_agent/evaluation_fix.rb +101 -0
  102. data/app/services/action_agent/evaluation_fix_comparison.rb +66 -0
  103. data/app/services/action_agent/evaluation_report_import.rb +84 -5
  104. data/app/services/action_agent/evaluation_run_cost.rb +609 -0
  105. data/app/services/action_agent/evaluation_runner_service.rb +29 -10
  106. data/app/services/action_agent/evaluation_standing.rb +152 -0
  107. data/app/services/action_agent/explorer_execution_service.rb +517 -0
  108. data/app/services/action_agent/github_client.rb +405 -64
  109. data/app/services/action_agent/live_updates.rb +31 -0
  110. data/app/services/action_agent/local_sandbox_backend.rb +1263 -102
  111. data/app/services/action_agent/local_sandbox_claude_login.rb +151 -0
  112. data/app/services/action_agent/mcp_client.rb +6 -2
  113. data/app/services/action_agent/mcp_recording_middleware.rb +104 -145
  114. data/app/services/action_agent/mcp_tool_dispatcher.rb +36 -13
  115. data/app/services/action_agent/mock_sandbox_backend.rb +3 -1
  116. data/app/services/action_agent/playwright_mcp_client.rb +7 -0
  117. data/app/services/action_agent/project_boot_fix.rb +309 -0
  118. data/app/services/action_agent/project_capabilities.rb +200 -0
  119. data/app/services/action_agent/project_install_pull_request.rb +225 -0
  120. data/app/services/action_agent/project_preflight.rb +178 -0
  121. data/app/services/action_agent/project_secret_discovery.rb +142 -0
  122. data/app/services/action_agent/project_setup.rb +429 -0
  123. data/app/services/action_agent/provider_credentials.rb +102 -0
  124. data/app/services/action_agent/recording_event_ingest.rb +181 -0
  125. data/app/services/action_agent/runtime_tool_roster.rb +52 -0
  126. data/app/services/action_agent/sample_data/conference_ticket.rb +516 -0
  127. data/app/services/action_agent/sandbox_boot_spec.rb +558 -0
  128. data/app/services/action_agent/sandbox_browser.rb +299 -0
  129. data/app/services/action_agent/sandbox_browser_driver.rb +85 -0
  130. data/app/services/action_agent/sandbox_credential_paths.rb +26 -0
  131. data/app/services/action_agent/sandbox_mail.rb +103 -0
  132. data/app/services/action_agent/sandbox_manifest.rb +166 -13
  133. data/app/services/action_agent/sandbox_orchestrator.rb +290 -23
  134. data/app/services/action_agent/sandbox_patch.rb +253 -0
  135. data/app/services/action_agent/scenario_catalog_import.rb +255 -0
  136. data/app/services/action_agent/scenario_catalog_repository_import.rb +81 -0
  137. data/app/services/action_agent/scenario_evaluation_runner.rb +121 -17
  138. data/app/services/action_agent/secret_scrubber.rb +33 -4
  139. data/app/services/action_agent/session_index.rb +270 -0
  140. data/app/services/action_agent/session_recording_service.rb +11 -0
  141. data/app/services/action_agent/session_timeline.rb +505 -0
  142. data/app/views/action_agent/github_app_manifests/created.html.erb +55 -0
  143. data/app/views/action_agent/session_player/show.html.erb +19 -0
  144. data/config/routes.rb +153 -2
  145. data/lib/action_agent/claude_login_process.rb +107 -0
  146. data/lib/action_agent/engine.rb +55 -5
  147. data/lib/action_agent/secret_requests.rb +80 -0
  148. data/lib/action_agent/version.rb +1 -1
  149. data/lib/action_agent.rb +513 -22
  150. data/lib/generators/action_agent/install_generator.rb +45 -5
  151. data/lib/generators/action_agent/templates/action_agent.rb.erb +161 -0
  152. data/lib/generators/action_agent/templates/add_code_session_runner.rb.erb +9 -0
  153. data/lib/generators/action_agent/templates/migrations/015_add_provider_key_scope.rb.erb +72 -0
  154. data/lib/generators/action_agent/templates/migrations/016_create_active_agent_input_requests.rb.erb +85 -0
  155. data/lib/generators/action_agent/templates/migrations/017_create_active_agent_recording_events.rb.erb +57 -0
  156. data/lib/generators/action_agent/templates/migrations/018_add_browser_to_active_agent_sandbox_sessions.rb.erb +25 -0
  157. data/lib/generators/action_agent/templates/migrations/019_create_active_agent_projects.rb.erb +79 -0
  158. data/lib/generators/action_agent/templates/migrations/020_add_github_app_installations.rb.erb +62 -0
  159. data/lib/generators/action_agent/templates/migrations/021_create_active_agent_explorations.rb.erb +63 -0
  160. data/lib/generators/action_agent/templates/migrations/023_create_active_agent_draft_pull_requests.rb.erb +75 -0
  161. data/lib/generators/action_agent/templates/migrations/024_add_kind_to_active_agent_project_secrets.rb.erb +14 -0
  162. data/lib/generators/action_agent/templates/migrations/025_create_active_agent_scenario_catalogs.rb.erb +110 -0
  163. data/lib/generators/action_agent/templates/migrations/026_add_evaluation_fixes_to_code_sessions.rb.erb +21 -0
  164. data/lib/tasks/action_agent.rake +33 -0
  165. data/lib/tasks/browser.rake +22 -0
  166. data/lib/tasks/sandbox.rake +1 -1
  167. data/lib/tasks/scenario_catalogs.rake +71 -0
  168. metadata +100 -2
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ # A GitHub App installation an owner linked (Settings -> Integrations), and
5
+ # the repositories they made available on it.
6
+ #
7
+ # Nothing here is a credential. A checkout or a repository listing mints an
8
+ # installation token when it needs one, limited to one repository where it
9
+ # can be and to the permission that step needs, and the token is never
10
+ # stored. +repositories+ holds only what GitHub listed for the installation
11
+ # when the owner chose them (see Api::GithubInstallationsController#update).
12
+ #
13
+ # The +github_account_*+ columns describe the GitHub user or organization
14
+ # the App is installed on, and are unrelated to the owner's +account_id+.
15
+ # An installation is linked to one owner at most: +installation_id+ is
16
+ # unique across every owner.
17
+ class GithubInstallation < ApplicationRecord
18
+ include Ownable
19
+ owned_by :account, :user
20
+
21
+ ACCOUNT_TYPES = %w[User Organization].freeze
22
+ # The permission a checkout's fetch needs.
23
+ CHECKOUT_PERMISSIONS = { contents: "read" }.freeze
24
+ # The permission listing an installation's repositories needs.
25
+ LISTING_PERMISSIONS = { metadata: "read" }.freeze
26
+
27
+ validates :installation_id, :github_account_id, :github_account_login, presence: true
28
+ validates :installation_id, uniqueness: true
29
+ validates :github_account_type, inclusion: { in: ACCOUNT_TYPES }
30
+
31
+ # JSON columns carry no default on MySQL or SQLite (see the migration), so
32
+ # an unset column reads as nil.
33
+ def repositories
34
+ Array(super)
35
+ end
36
+
37
+ def permissions
38
+ value = super
39
+ value.is_a?(Hash) ? value : {}
40
+ end
41
+
42
+ def repository_names
43
+ repositories.map { |repo| repo["full_name"] }
44
+ end
45
+
46
+ def repository(full_name)
47
+ repositories.find { |repo| repo["full_name"].casecmp?(full_name.to_s) }
48
+ end
49
+
50
+ # Whether GitHub will still mint tokens for this installation, as far as
51
+ # the dashboard last heard: false once a mint found it removed or
52
+ # suspended, until a later mint succeeds or it is linked again.
53
+ def usable?
54
+ removed_at.nil? && suspended_at.nil?
55
+ end
56
+
57
+ # Mints an installation token limited to +permissions+ and, when given,
58
+ # +repository_ids+. A refusal because the installation was removed or
59
+ # suspended is recorded on the row before it is raised again, and a
60
+ # successful mint clears that record, since GitHub mints for neither.
61
+ #
62
+ # @raise [GithubClient::InstallationUnavailable]
63
+ # @return [String] the token, which the caller must not store
64
+ def mint_token!(permissions:, repository_ids: nil)
65
+ token = GithubClient.mint_installation_token(installation_id, permissions: permissions, repository_ids: repository_ids)
66
+ update_columns(removed_at: nil, suspended_at: nil) if persisted? && !usable?
67
+ token
68
+ rescue GithubClient::InstallationUnavailable => e
69
+ mark_unavailable!(e.reason)
70
+ raise
71
+ end
72
+
73
+ # The checkout of +full_name+ a sandbox backend clones, without a token.
74
+ # Reading it calls nothing; #checkout_spec! is the one that mints.
75
+ #
76
+ # @raise [ArgumentError] when +full_name+ is not a selected repository
77
+ def checkout_spec(full_name, ref: nil, token: nil)
78
+ repo = repository(full_name) or raise ArgumentError, "#{full_name} is not an available repository"
79
+
80
+ {
81
+ repository: repo["full_name"],
82
+ ref: ref.presence || repo["default_branch"],
83
+ clone_url: "https://github.com/#{repo["full_name"]}.git",
84
+ username: "x-access-token",
85
+ token: token
86
+ }
87
+ end
88
+
89
+ # #checkout_spec with a freshly minted token that reads the contents of
90
+ # that one repository. Carries the token: hand it to a backend, never to
91
+ # a response.
92
+ def checkout_spec!(full_name, ref: nil)
93
+ repo = repository(full_name) or raise ArgumentError, "#{full_name} is not an available repository"
94
+
95
+ token = mint_token!(permissions: CHECKOUT_PERMISSIONS, repository_ids: [ repo["id"] ])
96
+ checkout_spec(full_name, ref: ref, token: token)
97
+ end
98
+
99
+ # A client for the installation's repository listing, with a token that
100
+ # can read repository metadata and nothing else.
101
+ def listing_client
102
+ GithubClient.new(mint_token!(permissions: LISTING_PERMISSIONS))
103
+ end
104
+
105
+ # Records what GitHub reported about the installation when it was linked.
106
+ def assign_from_github(installation)
107
+ account = installation["account"] || {}
108
+ assign_attributes(
109
+ github_account_id: account["id"],
110
+ github_account_login: account["login"],
111
+ github_account_type: account["type"],
112
+ repository_selection: installation["repository_selection"],
113
+ permissions: installation["permissions"].is_a?(Hash) ? installation["permissions"] : {},
114
+ suspended_at: installation["suspended_at"],
115
+ removed_at: nil
116
+ )
117
+ end
118
+
119
+ def as_summary
120
+ {
121
+ id: id,
122
+ installation_id: installation_id,
123
+ account_login: github_account_login,
124
+ account_type: github_account_type,
125
+ repository_selection: repository_selection,
126
+ permissions: permissions,
127
+ repositories: repositories,
128
+ status: status,
129
+ settings_url: settings_url,
130
+ linked_at: created_at&.iso8601,
131
+ updated_at: updated_at&.iso8601
132
+ }
133
+ end
134
+
135
+ # "active", "suspended" or "removed".
136
+ def status
137
+ return "removed" if removed_at
138
+ return "suspended" if suspended_at
139
+
140
+ "active"
141
+ end
142
+
143
+ # Where the installation's repository access is changed on GitHub.
144
+ def settings_url
145
+ if github_account_type == "Organization"
146
+ "https://github.com/organizations/#{github_account_login}/settings/installations/#{installation_id}"
147
+ else
148
+ "https://github.com/settings/installations/#{installation_id}"
149
+ end
150
+ end
151
+
152
+ private
153
+
154
+ def mark_unavailable!(reason)
155
+ column = reason == :suspended ? :suspended_at : :removed_at
156
+ update_column(column, Time.current) if persisted? && self[column].nil?
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,291 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAgent
4
+ # A question a paused run put to a person, and the answer the run resumes
5
+ # with.
6
+ #
7
+ # A tool asks by returning an ActiveAgent::InputRequest, which ends the
8
+ # generation paused. AgentRun#record_result! then stores one of these per
9
+ # paused tool call. The requests of one pause share a `pause_key` and the
10
+ # pause's checkpoint, and the run resumes (AgentResumeJob) once every one of
11
+ # them is answered or declined.
12
+ #
13
+ # Statuses:
14
+ # - `pending` waiting for a person
15
+ # - `answered` answered; an answered `confirm` request is an approval
16
+ # - `declined` declined; the paused tool does not run
17
+ # - `expired` not answered before `expires_at`
18
+ # - `cancelled` the run was cancelled, or ended without the answer
19
+ #
20
+ # The owner columns are copied from the subject's agent when the request is
21
+ # created. `answer` and `checkpoint` are encrypted at rest and are never
22
+ # serialized (InputRequestSerializer).
23
+ class InputRequest < ApplicationRecord
24
+ include Ownable
25
+ owned_by :account, :user
26
+
27
+ # Raised when the request can no longer be answered or declined: it was
28
+ # settled already, or it expired.
29
+ class Conflict < StandardError; end
30
+
31
+ # Raised when an answer does not fit the request.
32
+ class InvalidAnswer < StandardError; end
33
+
34
+ KINDS = ActiveAgent::InputRequest::KINDS.map(&:to_s).freeze
35
+ # The answers that approve a `confirm` request, no answer among them, and
36
+ # the answers that decline it.
37
+ CONFIRM_APPROVALS = [ nil, true, "true" ].freeze
38
+ CONFIRM_DECLINES = [ false, "false" ].freeze
39
+ # The shortest answer a `secret` request takes. A secret is scrubbed from
40
+ # what a run records wherever it appears inside a value, so a short one
41
+ # would also replace the same characters in unrelated values.
42
+ SECRET_MIN_LENGTH = 8
43
+ # The only subjects a request is created for. Later subjects join this list.
44
+ SUBJECT_TYPES = %w[ActionAgent::AgentRun].freeze
45
+
46
+ belongs_to :subject, polymorphic: true
47
+
48
+ if ActionAgent.encrypt_credentials
49
+ encrypts :answer
50
+ encrypts :checkpoint
51
+ end
52
+
53
+ enum :status, { pending: 0, answered: 1, declined: 2, expired: 3, cancelled: 4 }
54
+
55
+ validates :kind, inclusion: { in: KINDS }
56
+ validates :subject_type, inclusion: { in: SUBJECT_TYPES }
57
+ validates :prompt, :pause_key, presence: true
58
+
59
+ before_validation :copy_owner_from_subject, on: :create
60
+
61
+ scope :recent, -> { order(created_at: :desc, id: :desc) }
62
+ # Pending requests past their `expires_at`.
63
+ scope :overdue, -> { pending.where(expires_at: ..Time.current) }
64
+ # Every column but the answer and the checkpoint, which no listing shows
65
+ # and which hold a whole conversation.
66
+ scope :for_listing, -> { select(*(column_names - %w[answer checkpoint]).map { |name| arel_table[name] }) }
67
+
68
+ # Stores the requests of one pause, sharing a pause key and the checkpoint
69
+ # the run resumes from.
70
+ #
71
+ # @param subject [AgentRun] the paused run
72
+ # @param requests [Array<ActiveAgent::InputRequest>] one per paused tool call
73
+ # @param checkpoint [Hash] the paused response's checkpoint
74
+ # @return [Array<InputRequest>]
75
+ def self.record_pause!(subject, requests, checkpoint:)
76
+ pause_key = SecureRandom.uuid
77
+ stored_checkpoint = checkpoint.to_json
78
+ expires_at = ActionAgent.input_request_ttl&.from_now
79
+ requested_by_id = user_id_of(subject.try(:actor))
80
+
81
+ requests.map do |request|
82
+ create!(
83
+ subject: subject,
84
+ pause_key: pause_key,
85
+ kind: request.kind.to_s,
86
+ prompt: request.prompt,
87
+ options: request.options,
88
+ answer_schema: request.schema,
89
+ tool_call_id: request.tool_call_id,
90
+ tool_name: request.tool_name,
91
+ arguments: request.kind.to_s == "confirm" ? request.metadata.to_h["arguments"] : nil,
92
+ checkpoint: stored_checkpoint,
93
+ expires_at: expires_at,
94
+ requested_by_id: requested_by_id
95
+ )
96
+ end
97
+ end
98
+
99
+ # Expires every overdue request in +relation+ (see #expire!).
100
+ #
101
+ # @param relation [ActiveRecord::Relation] the requests to look through
102
+ # @return [Integer] how many requests it expired
103
+ def self.expire_overdue!(relation = all)
104
+ relation.overdue.for_listing.find_each.count(&:expire!)
105
+ end
106
+
107
+ # The id of +user+ when it is a record of the configured user class, so
108
+ # an account or a service object is never stored as a person.
109
+ def self.user_id_of(user)
110
+ user_class = ActionAgent.user_class&.safe_constantize
111
+ user.id if user_class && user.is_a?(user_class)
112
+ end
113
+
114
+ # The requests the same pause raised, this one included.
115
+ # @return [ActiveRecord::Relation]
116
+ def pause_requests
117
+ self.class.where(subject_type: subject_type, subject_id: subject_id, pause_key: pause_key)
118
+ end
119
+
120
+ # Whether every request of the pause is answered or declined, so the run
121
+ # can resume.
122
+ def pause_settled?
123
+ pause_requests.where.not(status: [ :answered, :declined ]).none?
124
+ end
125
+
126
+ # Whether this request belongs to the latest pause of its subject. A run
127
+ # resumed from one pause may pause again, and then only the later pause's
128
+ # checkpoint continues it.
129
+ def current_pause?
130
+ self.class.where(subject_type: subject_type, subject_id: subject_id).order(id: :desc).pick(:pause_key) == pause_key
131
+ end
132
+
133
+ # The checkpoint the pause stored, as a Hash.
134
+ def checkpoint_data
135
+ JSON.parse(checkpoint.to_s)
136
+ end
137
+
138
+ # What the paused tool call is dispatched again with: `false` for a
139
+ # declined request, `true` for an approved `confirm`, the answer
140
+ # otherwise.
141
+ def resume_answer
142
+ return false if declined?
143
+ return true if kind == "confirm"
144
+
145
+ answer
146
+ end
147
+
148
+ # The values a `choice` answer may take: each option, or its `value` when
149
+ # the option is a hash.
150
+ # @return [Array<String>]
151
+ def choice_values
152
+ Array(options).map { |option| option.is_a?(Hash) ? option.fetch("value", option["label"]) : option }.map(&:to_s)
153
+ end
154
+
155
+ # Whether +user+ may answer or decline this request. A multi-tenant
156
+ # install refuses a nil user, so every answer names who gave it.
157
+ # Otherwise a configured permission checker decides
158
+ # (:answer_input_request). Without one:
159
+ #
160
+ # - multi-tenant: only the run's actor answers a request that records
161
+ # one (`requested_by_id`), because the run acts as that person
162
+ # - single-tenant: anyone may answer
163
+ #
164
+ # The handler of a `secret` request can refuse on top of that (see
165
+ # SecretRequests).
166
+ def answerable_by?(user)
167
+ return false if ActionAgent.multi_tenant? && user.nil?
168
+ return false unless SecretRequests.answerable_by?(self, user)
169
+ return ActionAgent.permitted?(user, :answer_input_request, self) if ActionAgent.permission_checker
170
+ return true unless ActionAgent.multi_tenant? && requested_by_id
171
+
172
+ self.class.user_id_of(user) == requested_by_id
173
+ end
174
+
175
+ # Answers the request, and enqueues AgentResumeJob when it was the last
176
+ # of its pause to settle. A `confirm` request is approved by `true` or by
177
+ # no answer, and declined by `false` (see #decline!), the way
178
+ # ActiveAgent::InputRequest reads `false`.
179
+ #
180
+ # @param value [String, Boolean, nil] the answer
181
+ # @param user [Object, nil] who answered
182
+ # @raise [Conflict] when the request is no longer pending or has expired
183
+ # @raise [InvalidAnswer] when the answer is blank, not text, not one of a
184
+ # `choice` request's options, shorter than SECRET_MIN_LENGTH for a
185
+ # `secret` request, or neither `true` nor `false` for a `confirm`
186
+ # request
187
+ # @return [self]
188
+ def answer!(value, user: nil)
189
+ return decline!(user: user) if kind == "confirm" && CONFIRM_DECLINES.include?(value)
190
+
191
+ settle!(:answered, value: value, user: user)
192
+ end
193
+
194
+ # Expires the request when it is still pending past `expires_at`. The
195
+ # rest of its pause is cancelled, and its run fails, because it can no
196
+ # longer resume. Returns whether it expired the request.
197
+ def expire!
198
+ expired = false
199
+ subject.with_lock do
200
+ reload
201
+ next unless pending? && expires_at&.past?
202
+
203
+ expire_pause!
204
+ expired = true
205
+ end
206
+ subject.try(:broadcast_update) if expired
207
+ expired
208
+ end
209
+
210
+ # Declines the request: its tool does not run, and the model reads an
211
+ # error as the call's result.
212
+ #
213
+ # @raise [Conflict] when the request is no longer pending or has expired
214
+ # @return [self]
215
+ def decline!(user: nil)
216
+ settle!(:declined, user: user)
217
+ end
218
+
219
+ private
220
+
221
+ # Every settlement of a pause takes the subject's row lock, so two answers
222
+ # to the same pause are applied one after the other and the second sees
223
+ # the first. A failure is raised after the lock's transaction commits,
224
+ # so an expiry it recorded is kept.
225
+ def settle!(status, value: nil, user: nil)
226
+ failure = nil
227
+ resume = false
228
+
229
+ subject.with_lock do
230
+ reload
231
+ failure = settle_failure(status, value)
232
+ next if failure
233
+
234
+ update!(
235
+ status: status,
236
+ answer: status == :answered && kind != "confirm" ? value.to_s : nil,
237
+ answered_by_id: self.class.user_id_of(user),
238
+ answered_at: Time.current
239
+ )
240
+ resume = pause_settled?
241
+ end
242
+ raise failure if failure
243
+
244
+ AgentResumeJob.perform_later(id) if resume && subject.is_a?(AgentRun)
245
+ self
246
+ end
247
+
248
+ def settle_failure(status, value)
249
+ return Conflict.new("This request was already #{self.status}") unless pending?
250
+
251
+ if expires_at&.past?
252
+ expire_pause!
253
+ return Conflict.new("This request expired at #{expires_at.iso8601}")
254
+ end
255
+
256
+ message = status == :answered ? answer_error(value) : nil
257
+ InvalidAnswer.new(message) if message
258
+ end
259
+
260
+ # Marks this request expired and the rest of its pause cancelled, and
261
+ # fails the run, which can no longer resume.
262
+ def expire_pause!
263
+ update!(status: :expired)
264
+ pause_requests.pending.update_all(status: self.class.statuses[:cancelled], updated_at: Time.current)
265
+ return unless subject.try(:awaiting_input?)
266
+
267
+ subject.update!(status: :failed, completed_at: Time.current, error_message: "An input request expired before it was answered")
268
+ end
269
+
270
+ def answer_error(value)
271
+ if kind == "confirm"
272
+ return CONFIRM_APPROVALS.include?(value) ? nil : "Answer a confirm request with true or false"
273
+ end
274
+
275
+ return "Answer with text" unless value.is_a?(String) || value.is_a?(Numeric)
276
+ return "An answer is required" if value.to_s.strip.empty?
277
+ return "A secret must be at least #{SECRET_MIN_LENGTH} characters" if kind == "secret" && value.to_s.length < SECRET_MIN_LENGTH
278
+ return nil unless kind == "choice"
279
+
280
+ "#{value.to_s.truncate(64).inspect} is not one of the options" unless choice_values.include?(value.to_s)
281
+ end
282
+
283
+ def copy_owner_from_subject
284
+ source = subject.respond_to?(:agent) ? subject.agent : subject
285
+ return unless source
286
+
287
+ self.account_id = source.try(:account_id)
288
+ self.user_id = source.try(:user_id)
289
+ end
290
+ end
291
+ end