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.
- checksums.yaml +4 -4
- data/app/assets/builds/action_agent.css +1 -1
- data/app/assets/builds/action_agent.js +76 -73
- data/app/assets/builds/action_agent_recorder.js +78 -0
- data/app/assets/builds/action_agent_replay.js +226 -0
- data/app/controllers/action_agent/api/agent_runs_controller.rb +3 -1
- data/app/controllers/action_agent/api/agents_controller.rb +1 -0
- data/app/controllers/action_agent/api/api_keys_controller.rb +12 -2
- data/app/controllers/action_agent/api/base_controller.rb +63 -2
- data/app/controllers/action_agent/api/claude_logins_controller.rb +93 -0
- data/app/controllers/action_agent/api/code_sessions_controller.rb +71 -12
- data/app/controllers/action_agent/api/dashboard_assistant_controller.rb +2 -2
- data/app/controllers/action_agent/api/draft_pull_requests_controller.rb +248 -0
- data/app/controllers/action_agent/api/evaluation_fixes_controller.rb +27 -0
- data/app/controllers/action_agent/api/evaluations_controller.rb +58 -7
- data/app/controllers/action_agent/api/explorations_controller.rb +213 -0
- data/app/controllers/action_agent/api/github_app_manifests_controller.rb +125 -0
- data/app/controllers/action_agent/api/github_connections_controller.rb +40 -2
- data/app/controllers/action_agent/api/github_installations_controller.rb +280 -0
- data/app/controllers/action_agent/api/input_requests_controller.rb +77 -0
- data/app/controllers/action_agent/api/mcp_controller.rb +46 -12
- data/app/controllers/action_agent/api/members_controller.rb +36 -0
- data/app/controllers/action_agent/api/project_explorations_controller.rb +80 -0
- data/app/controllers/action_agent/api/project_install_pull_requests_controller.rb +305 -0
- data/app/controllers/action_agent/api/project_secrets_controller.rb +96 -0
- data/app/controllers/action_agent/api/project_setup_controller.rb +151 -0
- data/app/controllers/action_agent/api/project_sign_ins_controller.rb +112 -0
- data/app/controllers/action_agent/api/projects_controller.rb +512 -0
- data/app/controllers/action_agent/api/provider_keys_controller.rb +176 -24
- data/app/controllers/action_agent/api/provider_models_controller.rb +37 -15
- data/app/controllers/action_agent/api/recording_event_ingest_controller.rb +67 -0
- data/app/controllers/action_agent/api/sandbox_browser_tickets_controller.rb +50 -0
- data/app/controllers/action_agent/api/sandbox_browsers_controller.rb +65 -0
- data/app/controllers/action_agent/api/sandboxes_controller.rb +148 -8
- data/app/controllers/action_agent/api/scenario_catalog_entries_controller.rb +188 -0
- data/app/controllers/action_agent/api/scenario_catalogs_controller.rb +302 -0
- data/app/controllers/action_agent/api/session_recordings_controller.rb +165 -34
- data/app/controllers/action_agent/api/sessions_controller.rb +111 -0
- data/app/controllers/action_agent/api/telemetry_keys_controller.rb +21 -0
- data/app/controllers/action_agent/dashboard_controller.rb +17 -6
- data/app/controllers/action_agent/session_player_controller.rb +58 -0
- data/app/controllers/concerns/action_agent/api/agent_serialization.rb +1 -0
- data/app/controllers/concerns/action_agent/api/mcp_dashboard_tools.rb +627 -20
- data/app/controllers/concerns/action_agent/api/project_browser.rb +46 -0
- data/app/controllers/concerns/action_agent/api/project_secret_authorization.rb +57 -0
- data/app/jobs/action_agent/agent_execution_job.rb +9 -39
- data/app/jobs/action_agent/agent_resume_job.rb +84 -0
- data/app/jobs/action_agent/code_session_job.rb +39 -10
- data/app/jobs/action_agent/draft_pull_request_job.rb +43 -0
- data/app/jobs/action_agent/exploration_job.rb +80 -0
- data/app/jobs/action_agent/input_request_expiry_job.rb +22 -0
- data/app/jobs/action_agent/project_evaluation_job.rb +59 -0
- data/app/jobs/action_agent/project_setup_job.rb +21 -0
- data/app/jobs/action_agent/sandbox_cleanup_job.rb +20 -3
- data/app/jobs/action_agent/sandbox_provision_job.rb +77 -10
- data/app/jobs/action_agent/sandbox_run_job.rb +9 -37
- data/app/jobs/action_agent/verify_evaluation_fix_job.rb +37 -0
- data/app/models/action_agent/agent.rb +72 -26
- data/app/models/action_agent/agent_run.rb +134 -23
- data/app/models/action_agent/agent_template.rb +35 -1
- data/app/models/action_agent/api_key.rb +14 -0
- data/app/models/action_agent/catalog_scenario.rb +75 -0
- data/app/models/action_agent/code_session.rb +21 -5
- data/app/models/action_agent/draft_pull_request.rb +131 -0
- data/app/models/action_agent/evaluation.rb +148 -0
- data/app/models/action_agent/evaluation_run.rb +143 -53
- data/app/models/action_agent/evaluation_scenario_result.rb +15 -3
- data/app/models/action_agent/exploration.rb +847 -0
- data/app/models/action_agent/github_installation.rb +159 -0
- data/app/models/action_agent/input_request.rb +291 -0
- data/app/models/action_agent/model_pricing.rb +214 -34
- data/app/models/action_agent/project.rb +789 -0
- data/app/models/action_agent/project_secret.rb +339 -0
- data/app/models/action_agent/provider_key.rb +207 -4
- data/app/models/action_agent/recording_event.rb +145 -0
- data/app/models/action_agent/recording_snapshot.rb +7 -7
- data/app/models/action_agent/sandbox_session.rb +300 -16
- data/app/models/action_agent/scenario_catalog.rb +172 -0
- data/app/models/action_agent/scenario_product.rb +57 -0
- data/app/models/action_agent/scenario_set.rb +138 -0
- data/app/models/action_agent/session_recording.rb +121 -14
- data/app/models/action_agent/telemetry_trace.rb +74 -19
- data/app/models/concerns/action_agent/ownable.rb +84 -13
- data/app/serializers/action_agent/evaluation_serializer.rb +41 -10
- data/app/serializers/action_agent/input_request_serializer.rb +47 -0
- data/app/serializers/action_agent/scenario_catalog_serializer.rb +23 -0
- data/app/serializers/action_agent/session_summary_serializer.rb +146 -0
- data/app/services/action_agent/agent_execution_service.rb +450 -72
- data/app/services/action_agent/agent_scorecard.rb +50 -21
- data/app/services/action_agent/agent_sync.rb +5 -2
- data/app/services/action_agent/agent_tool_roster.rb +19 -4
- data/app/services/action_agent/agent_toolbox.rb +215 -9
- data/app/services/action_agent/browser_live_ticket.rb +78 -0
- data/app/services/action_agent/browser_sidecar.rb +213 -0
- data/app/services/action_agent/browser_sign_in.rb +163 -0
- data/app/services/action_agent/browser_tool_redaction.rb +92 -0
- data/app/services/action_agent/claude_code_auth.rb +70 -4
- data/app/services/action_agent/codex_session_events.rb +37 -0
- data/app/services/action_agent/dashboard_assistant_service.rb +24 -10
- data/app/services/action_agent/draft_pull_request_publisher.rb +719 -0
- data/app/services/action_agent/evaluation_fix.rb +101 -0
- data/app/services/action_agent/evaluation_fix_comparison.rb +66 -0
- data/app/services/action_agent/evaluation_report_import.rb +84 -5
- data/app/services/action_agent/evaluation_run_cost.rb +609 -0
- data/app/services/action_agent/evaluation_runner_service.rb +29 -10
- data/app/services/action_agent/evaluation_standing.rb +152 -0
- data/app/services/action_agent/explorer_execution_service.rb +517 -0
- data/app/services/action_agent/github_client.rb +405 -64
- data/app/services/action_agent/live_updates.rb +31 -0
- data/app/services/action_agent/local_sandbox_backend.rb +1263 -102
- data/app/services/action_agent/local_sandbox_claude_login.rb +151 -0
- data/app/services/action_agent/mcp_client.rb +6 -2
- data/app/services/action_agent/mcp_recording_middleware.rb +104 -145
- data/app/services/action_agent/mcp_tool_dispatcher.rb +36 -13
- data/app/services/action_agent/mock_sandbox_backend.rb +3 -1
- data/app/services/action_agent/playwright_mcp_client.rb +7 -0
- data/app/services/action_agent/project_boot_fix.rb +309 -0
- data/app/services/action_agent/project_capabilities.rb +200 -0
- data/app/services/action_agent/project_install_pull_request.rb +225 -0
- data/app/services/action_agent/project_preflight.rb +178 -0
- data/app/services/action_agent/project_secret_discovery.rb +142 -0
- data/app/services/action_agent/project_setup.rb +429 -0
- data/app/services/action_agent/provider_credentials.rb +102 -0
- data/app/services/action_agent/recording_event_ingest.rb +181 -0
- data/app/services/action_agent/runtime_tool_roster.rb +52 -0
- data/app/services/action_agent/sample_data/conference_ticket.rb +516 -0
- data/app/services/action_agent/sandbox_boot_spec.rb +558 -0
- data/app/services/action_agent/sandbox_browser.rb +299 -0
- data/app/services/action_agent/sandbox_browser_driver.rb +85 -0
- data/app/services/action_agent/sandbox_credential_paths.rb +26 -0
- data/app/services/action_agent/sandbox_mail.rb +103 -0
- data/app/services/action_agent/sandbox_manifest.rb +166 -13
- data/app/services/action_agent/sandbox_orchestrator.rb +290 -23
- data/app/services/action_agent/sandbox_patch.rb +253 -0
- data/app/services/action_agent/scenario_catalog_import.rb +255 -0
- data/app/services/action_agent/scenario_catalog_repository_import.rb +81 -0
- data/app/services/action_agent/scenario_evaluation_runner.rb +121 -17
- data/app/services/action_agent/secret_scrubber.rb +33 -4
- data/app/services/action_agent/session_index.rb +270 -0
- data/app/services/action_agent/session_recording_service.rb +11 -0
- data/app/services/action_agent/session_timeline.rb +505 -0
- data/app/views/action_agent/github_app_manifests/created.html.erb +55 -0
- data/app/views/action_agent/session_player/show.html.erb +19 -0
- data/config/routes.rb +153 -2
- data/lib/action_agent/claude_login_process.rb +107 -0
- data/lib/action_agent/engine.rb +55 -5
- data/lib/action_agent/secret_requests.rb +80 -0
- data/lib/action_agent/version.rb +1 -1
- data/lib/action_agent.rb +513 -22
- data/lib/generators/action_agent/install_generator.rb +45 -5
- data/lib/generators/action_agent/templates/action_agent.rb.erb +161 -0
- data/lib/generators/action_agent/templates/add_code_session_runner.rb.erb +9 -0
- data/lib/generators/action_agent/templates/migrations/015_add_provider_key_scope.rb.erb +72 -0
- data/lib/generators/action_agent/templates/migrations/016_create_active_agent_input_requests.rb.erb +85 -0
- data/lib/generators/action_agent/templates/migrations/017_create_active_agent_recording_events.rb.erb +57 -0
- data/lib/generators/action_agent/templates/migrations/018_add_browser_to_active_agent_sandbox_sessions.rb.erb +25 -0
- data/lib/generators/action_agent/templates/migrations/019_create_active_agent_projects.rb.erb +79 -0
- data/lib/generators/action_agent/templates/migrations/020_add_github_app_installations.rb.erb +62 -0
- data/lib/generators/action_agent/templates/migrations/021_create_active_agent_explorations.rb.erb +63 -0
- data/lib/generators/action_agent/templates/migrations/023_create_active_agent_draft_pull_requests.rb.erb +75 -0
- data/lib/generators/action_agent/templates/migrations/024_add_kind_to_active_agent_project_secrets.rb.erb +14 -0
- data/lib/generators/action_agent/templates/migrations/025_create_active_agent_scenario_catalogs.rb.erb +110 -0
- data/lib/generators/action_agent/templates/migrations/026_add_evaluation_fixes_to_code_sessions.rb.erb +21 -0
- data/lib/tasks/action_agent.rake +33 -0
- data/lib/tasks/browser.rake +22 -0
- data/lib/tasks/sandbox.rake +1 -1
- data/lib/tasks/scenario_catalogs.rake +71 -0
- 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
|